倍增优化DP
概述
倍增优化DP是一类常见的DP优化方式,多见于多询问的DP问题,这是因为倍增优化DP可以在 \(O(\log n)\) 的时间内拼出一个区间,如果用普通DP,很可能出现爆空间等错误。
倍增优化DP的基本状态设计是 \(f_{i,j}\),通常表示 \(i\) 走 \(2^j\) 步出现的结果或构成的区间的贡献。当然,这只是框架,还要加其他状态依题目具体而定。
在设计倍增的状态时,要注意几点:
- “步”,必须是整体。注意:权值往往是不能划分的,但是,权值所属的物品有时可以当做 “步”。
- 树上倍增的题目,\(f_{i,j}\) 表示 \(i\) 到 \(2^j\) 级祖先路径上的信息,或表示 \(i\) 的 \(2^j\) 祖先的信息。
- 倍增修改相对困难,若一道题的倍增支持修改,那么 \(f_{i,j}\) 中的 \(i\) 一般不大,由于 \(j\) 是 \(2\) 的指数,所以当做常数看待,这种题基本用数据结构维护。
- \(f_j\) 是由两个 \(f_{j-1}\) 合并得来,可以理解为满足区间可加性。
- 如果是一步步跳,那么一般考虑倍增。
- 如果 \(f_{i,j}\) 表示的不是区间,那么通常要记录从 \(i\) 出发,走 \(2^j\) 步,走到哪里。不然不能用两个 \(j-1\) 的状态拼凑出 \(j\) 的状态。
应用
这种优化思想在题目中用的很多,在不少算法中也有涉及。
倍增求LCA :设 \(f_{i,j}\) 表示 \(i\) 的 \(2^j\) 辈祖先节点,可以发现,这里的 “步” 就是每次跳的一步,不是权值,\(O(n\log n)\) 预处理,\(O(\log n)\) 查询。
RMQ区间最值 :设 \(f_{i,j}\) 表示区间 \([i,i+2^j-1]\) 的最值,可以做到 \(O(n\log n)\) 预处理,\(O(1)\) 查询。
题目
跑路
先找上面提到的 “步”,发现无论是 “秒”,还是两个点之间的长度,都是 \(1\),于是我们可以设秒为步,规范的说:设\(f_{i,j,k}\) 表示从 \(i\) 出发,经过 \(2^k\) 秒能否到 \(j\) ,然后在 \(f_{i,j,k}=1\) 的 \((i,j)\) 之间连权值为 \(1\) 的边(跑路器每秒可以跑 \(2^k\) 米),跑一遍最短路即可。
为什么要用倍增?题目提示很明显:
每秒钟可以跑 \(2^k\) 千米。
const int N = 100;
int n, m;
int g[N][N][N];
int f[N][N];
void init() {
fr(k, 1, 65)
fr(z, 1, n)
fr(i, 1, n)
fr(j, 1, n)
g[i][j][k] |= (g[i][z][k - 1] && g[z][j][k - 1]);
}
void Floyed() {
fr(k, 1, n)
fr(i, 1, n)
fr(j, 1, n)
if(k != i && i != j && j != k) f[i][j] = min(f[i][j], f[i][k] + f[k][j]);
}
int main() {
cin >> n >> m;
fr(i, 1, m) {
int u, v; cin >> u >> v;
g[u][v][0] = 1;
}
init();
memset(f, 0x3f, sizeof(f));
fr(i, 1, n)
fr(j, 1, n)
fr(k, 0, 65)
if(g[i][j][k]) f[i][j] = 1;
Floyed();
cout << f[1][n] << '\n';
return 0;
}
Minimal Segment Cover
首先,肯定不能设出 \(f_{i,j}\) 这种从 \(i\) 开始,跨越 \(2^j\) 长度的最小线段数,这种倍增纯粹胡来。因为权值一般是不能放进 \(j\) 里面的,这不符合我们对 "步" 的定义,而且没有办法转移,因为没有办法将两个 \(j-1\) 的状态进行合并。
可以设 \(f_{i,j}\) 表示从点 \(i\) 出发,跨越 \(2^j\) 条线段,最远可以到达的点,这里用到了上面提及的技巧,“权值是不能作为状态的,但是权值的主人往往可以作为状态”。很容易写出转移方程:
初始化 \(f\) 数组一定不能只算端点值,要将区间中间的点也计算进去。详细见代码。
对于每次询问,我们从 \(x\) 出发,用类似二进制拆分的思想,从高位到低位依次向后跳,依次统计答案,这里的代码实现有点类似倍增求LCA,详见代码。
const int N = 5e5 + 10;
int n, m, maxn;
int f[N][20];
int main() {
cin >> n >> m;
fr(i, 1, n) {
int l, r; cin >> l >> r;
f[l][0] = max(f[l][0], r);
maxn = max(maxn, r);
}
fr(i, 1, maxn) if(f[i - 1][0] >= i) f[i][0] = max(f[i][0], f[i - 1][0]);
fr(i, 1, 19)
fr(j, 0, maxn) {
f[j][i] = max(f[j][i], f[f[j][i - 1]][i - 1]);
}
while(m --) {
int l, r, np, ans = 0; cin >> l >> r;
np = l;
rf(i, 19, 0) {
if (f[np][i] < r) ans += (1 << i), np = f[np][i];
//不能写小于等于,如果r是右端点,那么跳再多次也是白搭
}
if (f[np][0] >= r) cout << ans + 1 << '\n';
else cout << -1 << '\n';
}
return 0;
}
[CERC2017]Donut Drone
如果没有修改操作,直接设 \(f_{i,j,k}\) 表示从 \((i,j)\) 出发,走 \(2^k\) 步可以到的坐标,就是道纯纯的屑题。
但是有修改操作。。。
众所周知,暴力可以极大的降低题目难度。
考虑每次先暴力的将在矩阵上的点 \((x,y)\) 跳到第一列 \((x',1)\) ,然后一圈圈的跳,最后再将没有跳够的暴力补上。如果中间的跳圈可以倍增实现,那么查询一次的时间复杂度就是 \(O(m+\log k)\) ,完全可以接受。
由于我们只关心从第一列的某行,跳几圈会到第一列的第几行,所以可以设 \(f_{i,j}\) 表示从第1列的 \(i\) 行出发,跳 \(2^j\) 圈,会跳到哪里。
现在我们考虑如何支持修改。
我们再维护一个数组 \(dp_{l,r,i}\) 表示在第 \(i\) 行,从 \(l\) 开始走,走完区间 \([l,r]\) ,也就是走到 \(r+1\),会处于哪一行。考虑这个dp如何转移,显然,这种类似区间dp的东西可以选择一个断点,记作 \(k\) . 于是:
由于这不是最优化或者计数问题,所以我们可以随意取一个点,取中点吧。设 \(k=mid=\frac{l+r}{2}\),式子改写为:
是不是很像线段树的pushup
?
没错,这个东西是满足区间可加性的,所以考虑用线段树储存。
对于修改 \((x, y)\) 的状态,最近的会影响 \(y-1\) 列的状态,所以对 \(y-1\) 列进行单点修改即可。
于是,\(dp_{1,m,i}\) 就是 \(f_{i,0}\) 的答案,在每次询问时,暴力修改倍增数组 \(f\) 即可。
时间复杂度:\(O(n\log n)\)
const int N = 2100;
int n, m, q;
int a[N][N];
int tr[N << 2][N], f[N][31];
int to(int x, int y) {
int ty = (y+1<=m?y+1:1), tx1 = x, tx2 = (x+1<=n?x+1:1), tx3 = (x-1>=1?x-1:n);
if (a[tx1][ty] > a[tx2][ty] && a[tx1][ty] > a[tx3][ty]) return tx1;
else if (a[tx2][ty] > a[tx1][ty] && a[tx2][ty] > a[tx3][ty]) return tx2;
else return tx3;
}
struct Segment_Tree {
void pushup(int nd) {
fr(i, 1, n) tr[nd][i] = tr[nd << 1 | 1][tr[nd << 1][i]];
}
void build(int nd, int l, int r) {
if (l == r) {
fr(i, 1, n) tr[nd][i] = to(i, l);
return ;
}
int mid = l + r >> 1;
build(nd << 1, l, mid);
build(nd << 1| 1, mid + 1, r);
pushup(nd);
}
void change(int nd, int l, int r, int x) { // 更新第 x 列
if (l > x || r < x) return ;
if (x == l && l == r) {
fr(i, 1, n) tr[nd][i] = to(i, l);
return ;
}
int mid = l + r >> 1;
change(nd << 1, l, mid, x);
change(nd << 1 | 1, mid + 1, r, x);
pushup(nd);
}
}T;
int main() {
cin >> n >> m;
fr(i, 1, n) fr(j, 1, m) cin >> a[i][j];
cin >> q;
T.build(1, 1, m);
int nx, ny; nx = ny = 1;
while(q --) {
string s; int k, x, y;
cin >> s;
if (s == "move") {
cin >> k;
if (ny != 1) {
while(k) {
nx = to(nx, ny);
ny = (ny+1<=m?ny+1:1);
k --;
if (ny == 1) break;
}
}
if (!k) { cout << nx << ' ' << ny << '\n'; continue; }
fr(i, 1, n) f[i][0] = tr[1][i];
fr(i, 1, 30) fr(j, 1, n) f[j][i] = f[f[j][i - 1]][i - 1];
int cnt = k / m, bcnt = k % m;
rf(i, 30, 0) {
if ((1 << i) <= cnt) { nx = f[nx][i]; cnt -= (1 << i); }
}
fr(i, 1, bcnt) {
nx = to(nx, ny);
ny = (ny+1<=m?ny+1:1);
}
cout << nx << ' ' << ny << '\n';
}
else {
cin >> x >> y >> k;
a[x][y] = k;
int ty = (y-1>=1?y-1:m);
T.change(1, 1, m, ty);
}
}
return 0;
}
开车旅行
为了方便,首先用set
求出小A和小B分别从城市i出发,会走到的城市,分别记作 \(ga_i,gb_i\).
多询问的DP问题,比较明显是倍增了。
考虑如何倍增,可以发现,记城市个数和天数都是可以的,毕竟两者都可以看做一步,或者说一个整体。
设 \(f_{i,j,0/1}\) 表示,从城市 \(i\) 出发,走 \(2^j\) 天,第一天是 小A/小B 开车,会到达的城市。
转移方程也很简单:
其中 \(j=1\) 的情况一定要注意,因为这两天先是A开车,再是B开车。
由于题目还要求小A和小B分别行驶的路程,我们设 \(da_{i,j,0/1}\) 表示从城市 \(i\) 出发,走 \(2^j\) 天,小A/小B 先开车,小A行驶的距离;\(db_{i,j,0/1}\) 表示从城市 \(i\) 出发,走 \(2^j\) 天,小A/小B 先开车,小B行驶的距离。
转移方程类似于 \(f\) 数组,详见代码。
const int N = 1e5 + 10;
const LL INF = 1e12;
int n, m, h[N];
int ga[N], gb[N];
LL f[18][N][2], da[18][N][2], db[18][N][2];
set<PII> s;
void init() {
s.insert({INF, 0}); s.insert({INF + 1, 0});
s.insert({-INF, 0}); s.insert({-INF - 1, 0});
//提前插入4个最值,有利于避免越界。
rf(i, n, 1) {
PII t = {h[i], i};
auto it = s.lower_bound(t);
++it;
vector <PII> g; g.clear();
fr(j, 0, 3) {
g.push_back(*it);
--it;
}
LL minn = INF + 10, cmin = INF + 10; int p1 = 0, p2 = 0;
rf(j, 3, 0) {
LL d = abs(g[j].first - h[i]);
if (d < minn) {
cmin = minn; minn = d;
p2 = p1; p1 = g[j].second;
}
else if (d < cmin) {
cmin = d; p2 = g[j].second;
}
}
s.insert({h[i], i});
ga[i] = p2; gb[i] = p1;
}
}
void calc(int p, int x, int &la, int &lb) {
la = lb = 0;
rf(i, 17, 0) {
if (f[i][p][0] && la + lb + da[i][p][0] + db[i][p][0] <= x) {
la += da[i][p][0]; lb += db[i][p][0];
p = f[i][p][0];
}
}
}
int main() {
ios::sync_with_stdio(0);
cin.tie(0); cout.tie(0);
cin >> n;
fr(i, 1, n) cin >> h[i];
init();
fr(i, 1, n) {
f[0][i][0] = ga[i]; f[0][i][1] = gb[i];
da[0][i][0] = abs(h[i] - h[ga[i]]);
db[0][i][1] = abs(h[i] - h[gb[i]]);
}
fr(i, 1, 17)
fr(j, 1, n)
fr(k, 0, 1) {
if (i == 1) f[i][j][k]=f[i-1][f[i-1][j][k]][1-k];
else f[i][j][k]=f[i-1][f[i-1][j][k]][k];
if (i == 1) da[i][j][k]=da[0][j][k]+da[0][f[0][j][k]][1-k];
else da[i][j][k] = da[i-1][j][k] + da[i-1][f[i-1][j][k]][k];
if (i == 1) db[i][j][k]=db[0][j][k]+db[0][f[0][j][k]][1-k];
else db[i][j][k] = db[i-1][j][k] + db[i-1][f[i-1][j][k]][k];
}
int x; cin >> x >> m;
LL maxh = -INF, res = 0; double minn = INF;
fr(i, 1, n) {
int la, lb; calc(i, x, la, lb);
double s = lb?la*1.0/lb:INF;
if (s < minn || (s == minn && h[i] > maxh)) {
minn = s;
maxh = h[i];
res = i;
}
}
cout << res << '\n';
fr(i, 1, m) {
int x, s, la, lb;
cin >> s >> x;
calc(s, x, la, lb);
cout << la << ' ' << lb << '\n';
}
return 0;
}