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;
}