倍增优化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_{i,j}=f_{f_{i,j-1},j-1} \]

初始化 \(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\) . 于是:

\[dp_{l,r,i}=dp_{k+1,r,dp_{l,k,i}} \]

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

\[dp_{l,r,i}=dp_{mid+1,r,dp_{l,mid,i}} \]

是不是很像线段树的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 开车,会到达的城市。

转移方程也很简单:

\[f_{i,0,0/1}=ga_i/gb_i\\ f_{i,1,0/1}=f_{f_{i,0,1/0},0,1/0}\\ f_{i,j>1,0/1}=f_{f_{i,j-1,0/1},j-1,0/1} \]

其中 \(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;
}
posted @ 2022-11-11 23:18  2017BeiJiang  阅读(354)  评论(0编辑  收藏  举报