Slope Trick
嘟嘟嘟,随便记一下,可能是从各种地方糅合得到的 /kk
大致思想
Slope Trick 是维护凸函数的手段。显然上凸下凸本质相同,所以下文统一维护下凸函数。
核心思路即,将凸函数拆成若干一次函数连接的形式,直接维护所有一次函数。考虑到下凸函数的性质是,斜率 \(k\) 单调递减。在题目中,一般斜率不会很大,我们维护可重集合 \(S\) 表示所有斜率增加 \(1\) 的 \(x\) 坐标位置。比如说:
对于上图这个可怕的下凸函数,我们只需要记录初始一次函数 \(y=-2x-3\),转折点 \(S=\{-3,-1,2,6\}\)。
注意斜率在数值上可能不连续,所以要维护可重集。例如说 \(y=|x-c|\) 就维护 \((y=-x+c,\{c,c\})\) 即可。想想为什么!
Slope Trick 多用在 DP 优化。即,写出一个 \(O(n^2)\) 暴力 DP,然后将第一维度视作整个函数,然后 DP 操作就转变为了函数的各种运算,此时再应用 Slope Trick 改为维护一次函数段。具体可以在题目中感受到。
\(\text{ }\)
\(\text{ }\)
P4597 序列 sequence
完整的做一下 Slope Trick 的流程:
第一步,写出暴力 DP 式子。\(f_{i,j}\) 表示前 \(i\) 个数,第 \(i\) 个数 \(\le j\),最小步数。转移即
第二步,改写成函数形式。这一步比较唐:
第三步,分析 \(f(i)\) 性质。可以发现 \(f(i)\) 就是下凸函数。证明的话可以去网上搜,应该还是很多的;但我这里还是想说,真做题的时候别 tm 分析了,赶紧打表,就是看差分数组是否有单调性。一般有规律的话是很明显的。
第四步,优化。我们把操作拆成两步
Step1 是加上一次函数 \(|a_i-j|\),即加上 \((y=-x+a_i,S=\{a_i,a_i\})\) 的函数,直接对应 \(k,b\) 相加,\(S\) 合并即可。
Step2 是前缀 chkmin。就是 \(k\le0\) 的段原封不动,后面段重置成 \(k=0\)。这里的技巧是,用两个对顶堆状物维护 \(S\),大根堆 \(L\) 维护 \(k<0\) 的断点,小根堆 \(R\) 维护 \(k>0\) 的断点。那么如何保证 \(L\) 存储的是 \(k<0\) 的断点;只要时刻保证 \(|L|+k=0\) 即可,如果不是的话,就把 \(L,R\) 的 top 怎么交换一下,使得 \(|L|+k=0\) 就行。
priority_queue <int> L;
priority_queue <int, vector <int>, greater <int>> R;
//+|j-a_i|: y=-x+a[i],{a[i],a[i]}
k -= 1, b += a[i];
while (!R.empty() && L.size() + k != 0) L.push(R.top()), R.pop();
L.push(a[i]), L.push(a[i]);
//pre chkmin
while (!L.empty() && L.size() + k != 0) R.push(L.top()), L.pop();
while (!R.empty()) R.pop(); //all clear k->0
那么如何输出答案?直接还原出来函数即可。既然是求最小值,那么就是取出斜率为 \(0\) 的段落,也就是看 \(L.top\) 的对应纵坐标值即可。考虑对于函数 \(y=kx+b\),已知 \(k,b\),它的下一个函数是 \(y=(k+1)x+b_0\),由于两函数有交点,交点处的 \(x\) 也已知,即 \(kx+b=(k+1)x+b_0\),得 \(b_0=b-x\)。因此能够算出来最后的 \(b\),答案就是 \(y=0x+b=b\)。
vector <int> P; while (!L.empty()) P.push_back(L.top()), L.pop(); //L 是大根堆,这里从小到大存出来 P
reverse(P.begin(), P.end());
for (int x : P) k++, b -= x;
printf("%lld\n", b);
复杂度 \(O(n\log n)\)。
当然这个代码只是方便理解而已,实际上显然很多东西都不用维护(比如 \(R\),根本没用上其实),自己看着办 qwq
稍微总结一下这题的技巧:
- ADD
对于 \((k_0,b_0,S_0)\) 与 \((k_1,b_1,S_1)\) 的合并,新函数直接为 \((k_0+k_1,b_0,b_1,S_0\cup S_1)\),即直接合并。
- PREV_MIN
对于前缀 chkmin 操作,维护对顶堆 \(L,R\) 存储左右凸壳,那么操作就是 clear 掉 \(R\)。
维护 \(L,R\) 的方法:保证 \(|L|+k=0\) 即可。
\(\text{ }\)
\(\text{ }\)
[ARC070E] NarrowRectangles
记 \(len_i=r_i-l_i\)。\(f_{i,j}\) 表示考虑完前 \(i\) 个 Rec 且第 \(i\) 个 Rec 的左端点为 \(j\),答案。
\(f(i)\) 下凸。考虑 Slope Trick。拆分操作,
Step1 是滑动窗口 chkmin,观察函数 / 分类讨论,发现就是 \(k<0\) 的部分向左平移 \(len_i\),\(k>0\) 的部分向右平移 \(len_{i-1}\)。直接打懒标记即可,同时维护 \(b\) 的变化就行。\(L,R\) 元素放入拿出时记得用懒标记还原数值。
(\(b\) 的变化:\(y=kx+b\to y=k(x+t)+b\),即 \(y=kx+(tk+b)\),维护 \(b^{\prime}=b+tk\) 即可)
Step2 是加一次函数,同上题处理。
输出答案同上题处理。
const int N = 1e5 + 5;
int l[N], r[N], len[N];
ll k, b, ltag, rtag; priority_queue <ll> L; priority_queue <ll, vector <ll>, greater <ll>> R;
int main() {
int n; scanf("%d", &n);
for (int i = 1; i <= n; i++) {
scanf("%d%d", &l[i], &r[i]), len[i] = r[i] - l[i];
if (i == 1) {L.push(l[i]), R.push(l[i]), k = -1, b = l[i]; continue;}
//range chkmin [j-len[i-1],j+len[i]]
b += k * len[i], ltag -= len[i], rtag += len[i - 1];
//+|j-li|: (y=-x+li, {li, li})
k--, b += l[i]; while (!R.empty() && L.size() + k != 0) L.push(R.top() + rtag - ltag), R.pop();
L.push(l[i] - ltag), L.push(l[i] - ltag);
while (!L.empty() && L.size() + k != 0) R.push(L.top() + ltag - rtag), L.pop();
}
vector <ll> P; while (!L.empty()) P.push_back(L.top() + ltag), L.pop();
reverse(P.begin(), P.end());
for (ll x : P) k++, b -= x;
printf("%lld\n", b);
return 0;
}
复杂度 \(O(n\log n)\)。
这题的技巧:
- RANGE_MIN
等价于两段区间平移,分别打 tag 维护即可。
\(\text{ }\)
\(\text{ }\)
P4272 [CTSC2009] 序列变换
我还没想懂 /dk。定义域截取真不知道为啥是对的,我乱改了一下就过了,题解写的也是云里雾里。我丢一个代码上来好了,注释的是定义域截取相关操作。其余部分应当是与上题一样的,不写了。
如果有大神能解释下面代码的正确性,欢迎评论告诉我!!万分感谢!!
int n, Q, A, B; scanf("%d%d%d%d", &n, &Q, &A, &B);
for (int i = 1, x; i <= n; i++) {
scanf("%d", &x);
if (x - ltag < 1) ans += 1 - (x - ltag), x = ltag + 1; //+++++++++++
//range chkmin [j-B,j-A]
b -= k * A, ltag += A, rtag += B;
//+|j-ai|: (y=-x+ai, {ai, ai})
k--, b += x; while (!R.empty() && L.size() + k != 0) L.push(R.top() + rtag - ltag), R.pop();
L.push(x - ltag), L.push(x - ltag);
while (!L.empty() && L.size() + k != 0) R.push(L.top() + ltag - rtag), L.pop();
}
vector <ll> P; while (!L.empty()) P.push_back(min(L.top() + ltag, (ll)Q)), L.pop(); //++++++++++
reverse(P.begin(), P.end()); for (ll x : P) k++, b -= x;
printf("%lld\n", b + ans);
\(\text{ }\)
\(\text{ }\)
[JAG2017J] Farm Village
原题中的 \(g_i\) 记作 \(a_i\)。
将贡献拆在每条边上考虑,这需要我们知道左右传输的作物数量。因此设 \(f_{i,j}\) 表示前 \(i\) 个数,\((i,i+1)\) 的边要传输 \(j\) 个作物(\(j\ge0\) 往右传,\(j<0\) 往左传),最小代价。有转移方程
其中 \(g\) 单纯是辅助数组。
注意到,将 \(g_{i-1}\) 平移一格后,\(f_i\) 相当于做 \(g_{i-1}\) 与 \(\{ka_i\}\) 的 \((\min,+)\) 卷积,这种形式的 Trick 是维护差分:原数组的闵可夫斯基和,就是在差分数组上做归并。因此可以先将 \(g\) 平移一格,直接维护差分数组然后 insert 卷积的另一项的差分数组进去,本题中 \(\{a_i,2a_i\}\) 的差分数组是 \(\{a_i,a_i\}\),因此 insert 进去两个 \(a_i\) 即可。
考虑 \(+d_{i-1}|j|\) 在差分数组上对应什么:等价于 \(+d_{i-1}(|j|-|j-1|)\),在 \(j\in\mathbb Z\) 时等价于 \(j\le0\) 时减去 \(d_{i-1}\),\(j\ge1\) 时加上 \(d_{i-1}\)。因此还是维护 \(\le0,>0\) 两个对顶堆,直接在两个堆中分别打 Tag 即可。
此外还有平移操作,直接操作一下两个堆顶即可,比较容易。
const int N = 2e5 + 5;
int d[N], a[N]; ll f0, ltag, rtag; priority_queue <ll> L; priority_queue <ll, vector <ll>, greater <ll>> R;
int main() {
int n; scanf("%d", &n);
for (int i = 1; i < n; i++) scanf("%d", &d[i]);
for (int i = 1; i <= n; i++) scanf("%d", &a[i]);
f0 = a[1], L.push(a[1]), R.push(a[1]);
for (int i = 2; i <= n; i++) {
ltag -= d[i - 1], rtag += d[i - 1]; //+d|j|
if (!R.empty()) {ll v = R.top() + rtag; f0 += v; L.push(v - ltag), R.pop();} //全局平移
for (int t : {0, 1}) {L.push(a[i] - ltag); f0 += a[i]; ll v = L.top() + ltag; f0 -= v, R.push(v - rtag), L.pop();} //加两次,须保证左侧元素数量不变
}
cout << f0 << '\n';
return 0;
}
可以看到代码逻辑会和之前不太一样,这是因为维护的东西完全变了,也不用搞斜率什么鬼的了。
复杂度 \(O(n\log n)\)。
这题的技巧:
- 闵可夫斯基和
用差分数组维护即可,维护的信息变成差分数组的具体值。(不一定要用堆维护,这题刚好用堆更方便而已)
其他操作也要重新放到差分数组上考虑。
NOTE:差分数组维护的是 \(f(i-1),f(i)\) 都有意义的位置 \(\Delta f(i)=f(i)-f(i-1)\)。例如本题中,假如暴力 DP 里面一些位置被初始化成了 \(+\infty\),那肯定是忽略这些项的影响。这样打出来的表就都符合上述结论了。
\(\text{ }\)
\(\text{ }\)
基础练习题
这些题都比较模板,用来熟悉 slope trick 的。
- https://www.luogu.com.cn/problem/AT_dwango2016qual_e
水题,直接做即可。 - https://www.luogu.com.cn/problem/AT_abc217_h
正常还是随便做,但是又有我不会的区间截取,即限制起点是 \(0\)。但是我们可以用奇技淫巧,在开始时插入足够多个 \(|x|\) 来限制起点是 \(0\),可以通过这类型的几乎全部题目。(然而这个策略在 P4272 不适用,原因未知) - https://www.luogu.com.cn/problem/AT_arc123_d
水题,直接做即可。 - https://www.luogu.com.cn/problem/AT_kupc2016_h
水题,直接做即可。注意输出的是 \(f_{n,a_n}\) 而非 \(\min f_{n,x}\),因此最后要变动一下。 - https://www.luogu.com.cn/problem/CF1534G
水题,会 DP 后直接做即可。学会 Trick 后一眼秒 3300! - https://www.luogu.com.cn/problem/P9962
树形DP+启发式合并+对顶堆维护差分数组即可,套路和最后一个例题完全相同。
\(\text{ }\)
\(\text{ }\)
进阶练习题
这些题往往在设计 DP / 应用 slope trick 中有一步是不平凡的,就被单独列出来写题解了。
做菜(cooking)
有 \(n\) 个需求 \((a_i,b_i)\),表示第 \(i\) 个用户需要一个菜,这个菜的制作时间为 \(a_i\),用户吃这个菜的时间为 \(b_i\)。
你同一时刻只能制作一个菜;用户获得菜后会立刻开吃。
对 \(k=1,2,\cdots,n\) 求:恰好满足 \(k\) 个需求的宴会的最短时长。时长定义为:做第一道菜到最后一个用户吃完的时长。
\(1\le n\le5\times10^5\),\(1\le a_i,b_i\le10^9\)。
对于确定顺序的需求序列 \((a_1,b_1),\cdots,(a_k,b_k)\):时长为 \(\max_{i=1}^k\{a_1+a_2+\cdots+a_i+b_i\}\)。
结论:对于不确定顺序的需求序列,按 \(b_i\) 降序选择最优。证明考虑调整法即可。
那么按 \(b_i\) 从大到小排序,考虑 DP。我们希望先设计一个 N^2 的 DP,而我们直接做的话需要记录 \(a_i\) 前缀和,不可接受。这种形式的 Trick 是倒着做,即 \(f_{i,j}\) 表示后 \(i\) 个需求解决了 \(j\) 个的最小值。此时 \(f_{i,j}=\min(f_{i+1,j},\max(f_{i+1,j-1},b_i)+a_i)\)。
为了方便,我们改成 \(b_i\) 从小到大排序,然后转移正着做:\(f_{i,j}=\min(f_{i-1,j},\max(f_{i-1,j-1},b_i)+a_i)\)。
一点打表尝试
写出暴力,满怀欣喜地去打表,却发现不是单调的(哭)
原数组打表
0 0 17 0 17 32 0 17 30 45 0 17 30 43 58 0 17 30 43 58 78 0 11 20 33 46 61 81 0 11 20 33 46 61 80 100 0 11 20 33 46 59 74 93 113 0 11 20 29 42 55 68 83 102 122 0 11 20 29 42 55 68 83 102 122 152 0 11 20 29 42 55 68 83 102 122 147 177 0 11 17 26 35 48 61 74 89 108 128 153 183 0 11 17 26 35 48 61 74 89 108 128 153 180 210 0 11 17 26 35 48 61 74 89 108 128 151 176 203 233 0 11 17 26 35 48 61 74 87 102 121 141 164 189 216 246 0 11 17 20 29 38 51 64 77 90 105 124 144 167 192 219 249 0 11 17 20 23 32 41 54 67 80 93 108 127 147 170 195 222 252 0 11 17 20 23 31 40 49 62 75 88 101 116 135 155 178 203 230 260 0 11 17 20 23 31 40 49 62 75 88 101 116 133 152 172 195 220 247 277 0 11 17 20 22 25 33 42 51 64 77 90 103 118 135 154 174 197 222 249 279 0 11 17 20 22 25 33 42 51 64 77 90 103 117 132 149 168 188 211 236 263 293 0 11 17 20 22 25 32 40 49 58 71 84 97 110 124 139 156 175 195 218 243 270 300 0 11 17 20 22 25 32 40 49 58 71 84 97 110 124 139 156 175 195 218 242 267 294 324 0 11 17 20 22 25 32 40 49 58 71 84 97 110 124 139 156 175 195 216 239 263 288 315 345 0 11 17 20 22 25 32 40 49 58 71 84 97 110 123 137 152 169 188 208 229 252 276 301 328 358 0 11 17 20 22 25 32 40 49 58 71 84 97 110 123 137 152 169 188 208 229 252 276 301 328 355 385 0 11 17 20 22 25 32 40 49 58 69 82 95 108 121 134 148 163 180 199 219 240 263 287 312 339 366 396 0 11 17 20 22 25 32 40 49 58 69 82 95 108 121 134 148 162 177 194 213 233 254 277 301 326 353 380 410 0 11 17 20 22 25 32 35 43 52 61 72 85 98 111 124 137 151 165 180 197 216 236 257 280 304 329 356 383 413 0 11 17 20 22 25 32 35 43 52 61 72 85 98 111 124 137 151 165 180 197 216 236 257 279 302 326 351 378 405 435
差分数组打表
17 17 15 17 13 15 17 13 13 15 17 13 13 15 20 11 9 13 13 15 20 11 9 13 13 15 19 20 11 9 13 13 13 15 19 20 11 9 9 13 13 13 15 19 20 11 9 9 13 13 13 15 19 20 30 11 9 9 13 13 13 15 19 20 25 30 11 6 9 9 13 13 13 15 19 20 25 30 11 6 9 9 13 13 13 15 19 20 25 27 30 11 6 9 9 13 13 13 15 19 20 23 25 27 30 11 6 9 9 13 13 13 13 15 19 20 23 25 27 30 11 6 3 9 9 13 13 13 13 15 19 20 23 25 27 30 11 6 3 3 9 9 13 13 13 13 15 19 20 23 25 27 30 11 6 3 3 8 9 9 13 13 13 13 15 19 20 23 25 27 30 11 6 3 3 8 9 9 13 13 13 13 15 17 19 20 23 25 27 30 11 6 3 2 3 8 9 9 13 13 13 13 15 17 19 20 23 25 27 30 11 6 3 2 3 8 9 9 13 13 13 13 14 15 17 19 20 23 25 27 30 11 6 3 2 3 7 8 9 9 13 13 13 13 14 15 17 19 20 23 25 27 30 11 6 3 2 3 7 8 9 9 13 13 13 13 14 15 17 19 20 23 24 25 27 30 11 6 3 2 3 7 8 9 9 13 13 13 13 14 15 17 19 20 21 23 24 25 27 30 11 6 3 2 3 7 8 9 9 13 13 13 13 13 14 15 17 19 20 21 23 24 25 27 30 11 6 3 2 3 7 8 9 9 13 13 13 13 13 14 15 17 19 20 21 23 24 25 27 27 30 11 6 3 2 3 7 8 9 9 11 13 13 13 13 13 14 15 17 19 20 21 23 24 25 27 27 30 11 6 3 2 3 7 8 9 9 11 13 13 13 13 13 14 14 15 17 19 20 21 23 24 25 27 27 30 11 6 3 2 3 7 3 8 9 9 11 13 13 13 13 13 14 14 15 17 19 20 21 23 24 25 27 27 30 11 6 3 2 3 7 3 8 9 9 11 13 13 13 13 13 14 14 15 17 19 20 21 22 23 24 25 27 27 30
但差分数组还是有很多规律:很多行的一个前缀都是完全相同;排除这个重复前缀后,差分数组就是单调的了。
所以合理猜测前缀可以用特定方法继承出来,后缀有凸性直接维护。此时再观察 DP 式子就很容易证明出相关的结论了。
注意到同一行的元素单调不减、同一列的元素单调不增。又注意到 \(b_i\) 单调不降,因此若某个时刻 \(i\) 满足 \(f_{i,x}\le b_i\),那么 \(i\) 行往后的所有 \(f_{i^\prime,x}\) 都不会变,具体容易归纳证明。那么对每一行 \(i\),我们提取出所有还可能变化的 \(f_{i,j}>b_i\) 的后缀,我们发现转移等价于 \(f_{i,j}=\min(f_{i-1,j},f_{i-1,j-1}+a_i)\),即 \(f(i-1)\) 与 \(\{a_i\}\) 做 \((\min,+)\) 卷积得到 \(f(i)\),这一部分有凸性,依据上文的技巧,直接维护差分数组即可,每次插入 \(a_i\),弹出已经 \(\le b_i\) 的部分,优先队列可以完成。\(O(n\log n)\)