倍增优化DP

概述


倍增优化DP是一类常见的DP优化方式,多见于多询问的DP问题,这是因为倍增优化DP可以在 O(logn) 的时间内拼出一个区间,如果用普通DP,很可能出现爆空间等错误。

倍增优化DP的基本状态设计是 fi,j,通常表示 i2j 步出现的结果或构成的区间的贡献。当然,这只是框架,还要加其他状态依题目具体而定。

在设计倍增的状态时,要注意几点:

  • “步”,必须是整体。注意:权值往往是不能划分的,但是,权值所属的物品有时可以当做 “步”。
  • 树上倍增的题目,fi,j 表示 i2j 级祖先路径上的信息,或表示 i2j 祖先的信息。
  • 倍增修改相对困难,若一道题的倍增支持修改,那么 fi,j 中的 i 一般不大,由于 j2 的指数,所以当做常数看待,这种题基本用数据结构维护。
  • fj 是由两个 fj1 合并得来,可以理解为满足区间可加性
  • 如果是一步步跳,那么一般考虑倍增。
  • 如果 fi,j 表示的不是区间,那么通常要记录从 i 出发,走 2j 步,走到哪里。不然不能用两个 j1 的状态拼凑出 j 的状态。

应用


这种优化思想在题目中用的很多,在不少算法中也有涉及。

倍增求LCA :设 fi,j 表示 i2j 辈祖先节点,可以发现,这里的 “步” 就是每次跳的一步,不是权值,O(nlogn) 预处理,O(logn) 查询。

RMQ区间最值 :设 fi,j 表示区间 [i,i+2j1] 的最值,可以做到 O(nlogn) 预处理,O(1) 查询。

题目

跑路


先找上面提到的 “步”,发现无论是 “秒”,还是两个点之间的长度,都是 1,于是我们可以设秒为步,规范的说:设fi,j,k 表示从 i 出发,经过 2k 秒能否到 j ,然后在 fi,j,k=1(i,j) 之间连权值为 1 的边(跑路器每秒可以跑 2k 米),跑一遍最短路即可。

为什么要用倍增?题目提示很明显:

每秒钟可以跑 2k 千米。

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


首先,肯定不能设出 fi,j 这种从 i 开始,跨越 2j 长度的最小线段数,这种倍增纯粹胡来。因为权值一般是不能放进 j 里面的,这不符合我们对 "步" 的定义,而且没有办法转移,因为没有办法将两个 j1 的状态进行合并。

可以设 fi,j 表示从点 i 出发,跨越 2j 条线段,最远可以到达的点,这里用到了上面提及的技巧,“权值是不能作为状态的,但是权值的主人往往可以作为状态”。很容易写出转移方程:

fi,j=ffi,j1,j1

初始化 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


如果没有修改操作,直接设 fi,j,k 表示从 (i,j) 出发,走 2k 步可以到的坐标,就是道纯纯的屑题。

但是有修改操作。。。

众所周知,暴力可以极大的降低题目难度。

考虑每次先暴力的将在矩阵上的点 (x,y) 跳到第一列 (x,1) ,然后一圈圈的跳,最后再将没有跳够的暴力补上。如果中间的跳圈可以倍增实现,那么查询一次的时间复杂度就是 O(m+logk) ,完全可以接受。

由于我们只关心从第一列的某行,跳几圈会到第一列的第几行,所以可以设 fi,j 表示从第1列的 i 行出发,跳 2j 圈,会跳到哪里。

现在我们考虑如何支持修改。

我们再维护一个数组 dpl,r,i 表示在第 i 行,从 l 开始走,走完区间 [l,r] ,也就是走到 r+1,会处于哪一行。考虑这个dp如何转移,显然,这种类似区间dp的东西可以选择一个断点,记作 k . 于是:

dpl,r,i=dpk+1,r,dpl,k,i

由于这不是最优化或者计数问题,所以我们可以随意取一个点,取中点吧。设 k=mid=l+r2,式子改写为:

dpl,r,i=dpmid+1,r,dpl,mid,i

是不是很像线段树的pushup

没错,这个东西是满足区间可加性的,所以考虑用线段树储存。

对于修改 (x,y) 的状态,最近的会影响 y1 列的状态,所以对 y1 列进行单点修改即可。

于是,dp1,m,i 就是 fi,0 的答案,在每次询问时,暴力修改倍增数组 f 即可。

时间复杂度:O(nlogn)

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出发,会走到的城市,分别记作 gai,gbi.

多询问的DP问题,比较明显是倍增了。

考虑如何倍增,可以发现,记城市个数和天数都是可以的,毕竟两者都可以看做一步,或者说一个整体。

fi,j,0/1 表示,从城市 i 出发,走 2j 天,第一天是 小A/小B 开车,会到达的城市。

转移方程也很简单:

fi,0,0/1=gai/gbifi,1,0/1=ffi,0,1/0,0,1/0fi,j>1,0/1=ffi,j1,0/1,j1,0/1

其中 j=1 的情况一定要注意,因为这两天先是A开车,再是B开车。

由于题目还要求小A和小B分别行驶的路程,我们设 dai,j,0/1 表示从城市 i 出发,走 2j 天,小A/小B 先开车,小A行驶的距离;dbi,j,0/1 表示从城市 i 出发,走 2j 天,小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;
}
posted @   2017BeiJiang  阅读(469)  评论(0编辑  收藏  举报
编辑推荐:
· Linux系列:如何用 C#调用 C方法造成内存泄露
· AI与.NET技术实操系列(二):开始使用ML.NET
· 记一次.NET内存居高不下排查解决与启示
· 探究高空视频全景AR技术的实现原理
· 理解Rust引用及其生命周期标识(上)
阅读排行:
· 单线程的Redis速度为什么快?
· 展开说说关于C#中ORM框架的用法!
· Pantheons:用 TypeScript 打造主流大模型对话的一站式集成库
· SQL Server 2025 AI相关能力初探
· 为什么 退出登录 或 修改密码 无法使 token 失效
点击右上角即可分享
微信分享提示