Slope trick 学习笔记

Slope trick 学习笔记

概述

Slope trick 是一种维护凸函数优化 dp 的方式。通过记录函数的转折点和最右段的一次函数,就可以表示出一个凸函数。

一个转折点 \(x\) 表示在 \(x\) 处斜率变化量为 1(由维护的是上凸壳或下凸壳决定),若在 \(x\) 处斜率差 \(a\) \(>1\),就放置 \(a\)\(x\)。通常在题目中,斜率和转折点都是整数,因此可以用一个堆 / 平衡树来存储所有转折点。

例如:\(f(x) = |x|\) 的转折点是 0,记录的序列是 \(\{0, 0\}\)

性质:

两个凸函数 \(f(x), g(x)\),设其所有转折点分别为 \(S_1, S_2\),直线为 \(f_1, f_2\),则 \(h(x) = f(x) + g(x)\) 也是凸函数,且满足 \(S_3 = S_1 +S_2, f_3 = f_1+f_2\)

例 1:【CF713C】Sonya and Problem Wihtout a Legend

\(a_i \leftarrow a_{i} - i\),转化为单调不降。设 \(dp_{i, j}\) 表示 \(a_i = j\) 时最小花费,则 \(dp_{i, j} = \min\limits_{k = 1}^{j}dp_{i - 1, k}+|a_i - j|\)

\(g_{i, j}\)\(dp_{I, j}\) 前缀最小值,则 \(dp_{I, j} = g_{i - 1, j}+|a_i - j|\)。 可归纳证明 \(dp_i, g_i\) 为下凸函数,且所有斜率都是整数。

由于 \(g_{i, j}\)\(dp_{i, j}\) 前缀最小值,设最小值位置为 \(h\)。则 \(g_i\) 的图像和 \(f_i\) 几乎完全相同,且 \(x \geq h\) 后,\(g_i\) 为一条平行于 \(x\) 轴的直线。因此可以忽略 \(x \geq h\) 的所有转折点。

\(f_i\)\(g_{i - 1}\) 加入 \(|x - a_i|\) 得到,根据凸函数性质,等价于加入 \(\{a_i, a_i\}\)

\(h \leq a_i\) 时,所有斜率都应该 \(-1\)\(a_i\) 变成新的 \(h\),因此直接加一个 \(a_i\)

\(h > a_i\) 时,原来的 \(h\) 应该被删去,新图像往上平移了 \(h - a_i\),因此将答案加上 \(h - a_i\),弹出 \(h\),加入两个 \(a_i\)

核心代码:

int main() {
    n = read(); 
    for(int i = 1; i <= n; ++i) {
        int x = read() - i; pq.push(x); 
        if(x < pq.top()) ans += pq.top() - x, pq.pop(), pq.push(x); 
    }
    cout << ans; 
}

例 2:【11.04-NOIP模拟】最大值

\(dp_{x, i}\) 表示 \(x\) 子树中选择了 \(i\) 个点的最大距离和,则先由儿子背包得来,再 \(dp_{x, i} \leftarrow dp_{x, i} + 2w \times \min(i, k - i)\)

经过归纳可知,\(dp_{x, i}\) 为下凸函数,证明如下:

两个凸函数的 \(\min, \ +\) 卷积为凸包的闵可夫斯基和,仍然是凸包,\(f(x) = 2w \times \min(x, k - x)\) 也是凸函数,因此 \(dp_{x}\) 是关于 \(i\) 的凸函数。

同时,两个凸函数的 \((\min, +)\) 卷积,等于其差分做归并排序,由于 \(dp_{x, 0} = 0\),因此用 \(a_i\) 表示 \(dp_{x, i} - dp_{x, i - 1}\) 即可。初始 \(a_1= 0, a_2 = \infty\),可以不加入。

\(f(x)\) 加上函数 \(g = \min(x, k - x)\) 后,等价于所有 \(i \leq \dfrac k 2\)\(a_i \leftarrow a_i +2w\)\(i >\dfrac k 2\)\(a_i \leftarrow a_{i} - 2w\),但当 \(k\) 为奇数时,\(\dfrac k 2\)\(\dfrac k 2 +1\) 相同,因此需要用三个堆分别维护 \(\leq \dfrac k 2\),当 \(k\) 为奇数时的 \(\dfrac {k+1} 2\)\(> \dfrac k 2\)\(a_i\),那么修改就可以用两个懒标记维护了。

核心代码:

	struct Heap {
		ll tg1, tg3; 
		std :: priority_queue < ll, vector < ll >, std :: greater < ll > > q1, q2, q3; 
		inline int gsize() {
	        return q1.size() + q2.size() + q3.size(); 
		}
		inline void ins(ll x) {
			q1.push(x - tg1); 
			if(q1.size() > k / 2) {
				ll v = q1.top() + tg1; q1.pop(); 
				q2.push(v); if(q2.size() > (k & 1)) {
					ll v2 = q2.top(); q2.pop(); q3.push(v2 - tg3); 
					if(q3.size() > k / 2) q3.pop(); 
 				}
			}
		}
		inline vector < ll > get() {
			vector < ll > sav; 
			while(q1.size()) sav.push_back(q1.top() + tg1), q1.pop(); 
			while(q2.size()) sav.push_back(q2.top()), q2.pop(); 
			while(q3.size()) sav.push_back(q3.top() + tg3), q3.pop(); 
			return sav; 
		}
	} h[M]; 
	inline void merge(Heap &x, Heap &y) {
		if(x.gsize() < y.gsize()) std :: swap(x, y); 
		vector < ll > cur = y.get(); 
		for(ll &z : cur) x.ins(z);
	}
	inline void dfs(int x, int fa, int w) {
		h[x].ins(0); 
		for(pii &it : adj[x]) {
			int y = it.fi, z = it.se; if(y == fa) continue ; dfs(y, x, z); 
			merge(h[x], h[y]); 
		}
		h[x].tg1 += (ll)2 * w, h[x].tg3 -= (ll)2 * w; 
	}
	inline void mian() {
		n = read(), k = read(); 
		for(int i = 1; i < n; ++i) {
			int u = read(), v = read(), w = read(); 
			adj[u].push_back(pii(v, w)), adj[v].push_back(pii(u, w)); 
		}
		dfs(1, 0, 0); 
		ll ans = 0; vector < ll > cur = h[1].get(); 
		for(ll &z : cur) ans += z; 
		cout << ans; 
	}

例 3:[ABC217H] Snuketoon

将所有事件按照 \(x\) 轴排序,设 \(dp_{i, j}\) 表示当前 \(t = i\)\(x = j\) 的最小花费,转移 \(dp_{i, j} \leftarrow \min( dp_{i - 1, j}, dp_{i - 1, j}, dp_{i - 1, j+1})\),若存在 \((T, D, X) = (i, 0,x)\),则 \(dp_{i, j} \leftarrow dp_{i, j}+ \max(0, X - j)\),否则 \(dp_{i, j} \leftarrow dp_{i, j} +\max(0, j- X)\)

注意到 \(f_1(x) = \max(0, X - x)\) 为斜率 -1 的一次函数,\(f_2(x) = \max(0, x - X)\) 为斜率 1 的一次函数。一次操作 \(f_{i, j} \leftarrow \min(f_{i - 1, j - 1}, f_{i- 1, j}, f_{i-1,j+1})\) 等价于将原凸包按照最小值点分成两半,斜率 \(<0\) 的向左平移 1 位,\(> 0\) 向右平移一位,最小值个数 \(+2\),因此维护一下移动的偏移量即可。设两边最小值位置在 \(l, r\)

\(D = 0\)\(X \leq r\) ,相当于在 $<0 $ 的点中加入 \(X\)\(x>r\) 相当于在 \(> 0\) 的点加入 \(X\),将右侧 \(=0\) 的斜率变成 \(-1\),因此将其从 \(>0\) 的点加入 \(<0\) 的点,并累加答案。

\(D = 1\) 同理。

核心代码:

	inline void mian() {
		n = read(); int lst = 0; ll d1 = 0, d2 = 0, ans = 0; 
		for(int i = 1; i <= n; ++i) pq1.push(0), pq2.push(0); 
		for(int i = 1; i <= n; ++i) {
			int t = read(), d = read(), x = read(), r = t - lst; lst = t; 
			d1 -= r, d2 += r; 
			if(!d) {
			    if(x <= pq2.top() + d2) pq1.push(x - d1); 
			    else ans += x - pq2.top() - d2, pq1.push(pq2.top() + d2 - d1), pq2.pop(), pq2.push(x - d2); 
			}
			else {
				if(x >= pq1.top() + d1) pq2.push(x - d2); 
				else ans += pq1.top() + d1 - x, pq2.push(pq1.top() + d1 - d2), pq1.pop(), pq1.push(x - d1); 
			}
		}
		cout << ans; 

例 4:【CF1534G】A New Beginning

切比雪夫距离 \((x, y)\) 转曼哈顿距离 \((\dfrac {x+y}2,\dfrac{x-y}2)\),为了避免浮点数变成 \((x+y, x- y)\),最后答案再除以 2 即可。

问题变成初始在 \((0, 0)\) 的点,每次 \(x+1\)\(y \leftarrow y+1\)\(y \leftarrow y - 1\)。容易发现收集一个 \((x_0,y_0)\) 最优的方式是当 \(x = x_0\) 时,代价为 \(|y - y_0|\),当向上或者向下是一定都不如直接到 \(x_0\) 优。因此 \(f_{i, j} \leftarrow \min(f_{i - 1, j - 1}, f_{i - 1, j+1})\)\(f_{i, j} \leftarrow f_{i, j} +|j - y|, \forall (x, y), s.t. \ i = x\)

虽然 \(f_{i, j}\) 不能由 \(f_{i - 1, j}\) 转移,但容易发现能走到的 \((x, y)\) 必然同奇偶,因此若走到 \((x, y)\)\(x, y\) 不同奇偶,则该点上一定不存在 potato,不会对答案造成影响,因此可以加上 \(f_{i - 1, j}\) 的转移,于是就和 例 4 相同了。

例 5: [APIO2016] 烟火表演

\(dp_{x, i}\) 表示 \(x\) 子树中,所有叶子节点距离 \(x\)\(i\) 的最小修改量。转移显然:\(dp_{x, i} = \sum\limits_{y}\min\limits_{j \leq i}dp_{y, j}+|w_y+j - i|\)

容易发现,\(dp_{x, i}\) 是下凸函数。对每个 \(dp_{x, i}\) 分别考虑最小值的来源,设 \(g_{x, i} = \min\limits_{j \leq i}dp_{y, j}+|w_y+j - i|\),函数的左右侧最小值点分别为 \([l, r]\)

  • \(i <L\) 时,随着 \(j\)\(i\) 逐渐减小,以 1 的变化量变小,但 \(dp_{y, j}\) 斜率 \(\geq 1\),因此 \(j = i\) 时最优,\(g_{x, i} = dp_{y, i}+w_{y}\)
  • \(L \leq i \leq L +w_y\) 时,显然 \(j = L\) 时最优,\(g_{x, i} = dp_{x, L}+w_y+L - i\)
  • \(L + w_y \leq i \leq R+w_y\) 时,显然 \(j = i - w_y\) 最优,\(g_{x, i} = dp_{x, L}\).
  • \(i>R+w_y\) 时,\(j\) 到达 \(i - w_j\) 时继续减到 \(R\) 最优,\(g_{x, i} = dp_{x,R} + i - R - w_y\)

考虑转折点的变化情况,\(i>R+w_y\) 变成了斜率为 1 的直线,\([L+w_y, R+w_y]\) 变成了斜率为 0,\([L, L+w_y]\) 变成了斜率为 -1,比较发现,即是删去所有斜率 \(\geq 0\) 的点,加入 \(\{L+w_y, R+w_y\}\) 得到的新凸函数。

因此维护方式也就呼之欲出了,只需要合并两个点的转折点,将所有斜率 \(\geq 0\) 的转折点弹出,找到 \(L, R\),加入 \(L+w_y, R+w_y\) 即可。最后的答案可根据 \(f_{1,0} = \sum w_i\) 逆推得到。

如何找到斜率 \(\geq 0\) 的转折点?显然合并一个儿子最大斜率 \(+1\),因此设点 \(x\) 儿子个数为 \(cnt\),只需要把前 \(cnt - 1\) 大的点弹出,剩下的就是 \(L, R\) 了。容易用可并堆维护。

核心代码:

	inline void dfs(int x, int fa, ll w) {
		for(pii &e : adj[x]) 
		   dfs(e.fi, x, e.se), rt[x] = merge(rt[x], rt[e.fi]); if(x == 1) return ; 
		int u = adj[x].size(); while(u > 1) pop(rt[x]), --u; 
		ll R = t[rt[x]].val; pop(rt[x]); ll L = t[rt[x]].val; pop(rt[x]); 
		rt[x] = merge(rt[x], new_node(L + w)), rt[x] = merge(rt[x], new_node(R + w)); 
	}
	inline void mian() {
		n = read(), m = read(); ll ans = 0; 
		for(int i = 2; i <= n + m; ++i) {
			int f = read(), w = read(); ans += w; 
			adj[f].push_back(pii(i, w)); 
		}
		dfs(1, 0, 0); int u = adj[1].size(); while(u) pop(rt[1]), --u; 
		while(rt[1]) ans -= t[rt[1]].val, pop(rt[1]); 
		cout << ans; 
	}
posted @ 2023-02-05 12:45  henrici3106  阅读(539)  评论(2编辑  收藏  举报