斜率优化 DP 笔记
零、闲话
本文 markdown 的颜色渲染好像有点问题,把大括号也一起染色了,不过不影响阅读。
之前自学的时候总是找不到一个合适的理解方法。
还会遇到各种奇怪的问题:例如“什么类型的式子可以斜率优化?”“什么情况要用什么类型的优化?”……等等。
另外网上的笔记要么不详细,要么冗余的部分很多,例如:
- 这篇 虽然详细,但排版凌乱废话一大堆。
- 根本就没有必要证明决策单调性。
不过写得还是蛮好的。
是时候解决它们了。
一、引入
这个引入可能比较奇怪……但它确实很好地描述了斜率优化的本质。
你需要维护一个点集 \((x_i,y_i)\),每次给定 \(k\),\(q\) 次查询 \(k x_i + y_i\) 的最大值。
上述问题等价于给定 \((a,b)\),求 \(a x_i + b y_i\) 的最大值,当只给 \(k\) 相当于是查询 \(\frac{a}{b} x_i + y_i\) 的最大值(如果 \(b \lt 0\) 就怎么取反一下求最小值即可)。
- 考虑 \(a=1,b=0\),相当于查询 \(X\) 方向上距离原点最远的点。
- 考虑 \(a=0,b=1\),相当于查询 \(Y\) 方向上距离原点最远的点。
- 扩展到一般的 \((a,b)\),相当于查询 \((0,0)-(a,b)\) 这条线上距离原点最远的点。
然而,这个点一定在凸包上,否则考虑反证,一定存在更远的点。
于是现在只考虑简化版的问题:给定 \(k\) 求 \(k x_i + y_i\) 的最大(小)值。
这样转化模型就可以解释斜率优化中一个很重要的点:\(\max\) 决策维护上凸壳,\(\min\) 决策维护下凸壳。原因显然。
以 \(\max\) 为例,如何在上凸壳找到最优解?
并不难发现,“远近”一定是单峰的。
考虑把“远近”刻画成在 \((0,0)-(k,1)\) 直线上的投影,由于凸包的凸性,距离显然是单峰,有单调性。
单调性!二分!不对,三分!
其实二分和三分都可以做。
三分显然可以解决,因为函数单峰。
至于二分,每次选中一个点 mid,判定它和它后一个点之间的斜率是上升还是下降的即可,对于 \(\max\) 决策,只需要求最左边的点使得它和它下一个点斜率下降,这是好求的。
至此,这个问题可以在 \(O(q \log n)\) 的复杂度内解决。
还有更优的复杂度!
考虑把 \(k\) 离线下来排序,这样保证按顺序处理的时候 \(k\) 单调。
\(k\) 单调的好处就是函数的峰值变化也是单调的。因此只要拿一个指针扫就可以维护最值,复杂度 \(O(n+q)\)。
上面大概做的事情就是维护一个点集(凸壳),支持根据参数查找函数最值。
函数最值这件事情启发我们将其运用在动态规划决策单调性方面的优化。
二、斜率优化
老师说不要学这种推法,没有几何直观。
但至少可以总结出来一种比较模板化的解题方法。
而且试了很多次,还挺顺的。
大致流程
(假设 \(i\) 从 \(j\) 转移而来)
- 分离和 \(j\) 无关的参数 \(c\)。
- 提取只和 \(j\) 有关的参数 \(y\)。
- 确定 \(k, x, y, c\)。
- 推式子。
- 确定维护方法。
注意事项
- 推式子的时候,如果移项有负数,要变号。
- \(\color{red}{需要根据实际的式子确定怎样维护上(下)凸壳}\)。
- 若 \(k\) 单调递减 \(x\) 单调递增,直接用单调队列维护,取队头转移 \(O(n)\)。
- 若 \(k\) 单调递增 \(x\) 单调递增,需要用单调栈维护,取栈顶转移 \(O(n)\)。
- 若只有 \(x\) 单调递增,依然采用单调队列,但需要二分查找最优决策 \(O(n \log n)\)。
- 找到一个点使得左边斜率和右边斜率分别大于或小于某个值。
- 若 \(k, x\) 均不单调,可以用 CDQ 分治思想或李超线段树维护,好像也可以用平衡树吧 \(O(n \log n)\)。
- 观察初始式子是否能化为“只和 \(i\) 有关项”“只和 \(j\) 有关项”“和 \(i, j\) 都有关项”,再尝试斜率优化。在推式子之前应当先尝试是否有更简单或更显然的优化方法。
三、例题
题目大致按个人认为的难度排序。
例 1:AT_dp_z Frog 3
这是我写的第一道斜率优化,其它一般都是该题的变种或加上各种参数。
以这道经典题把斜率优化的流程大致过一遍。
个人认为这篇题解写得很好:学习链接。
显然有:
第一步:分离和 \(j\) 无关的参数。
第二步:提取只和 \(j\) 有关的参数。
设 \(y(j) = dp_j + h_j^2\):
第三步:确定 \(k, x, y, c\)。
对于上式,有 \(\color{blue}{k=-2h_i}, \color{red}{x=h_j}, \color{green}{y = y(j)}, \color{purple}{c = (C + h_i^2)}\)。
相当于是求 \(kx+y\) 的最小值,维护下凸壳,最后加上常数 \(c\)。
第四步:推式子
一般是把 \(k,x,y\) 带进去形成不等式,移项推出只和 \(k\) 相关的不等式:
设 \(j_1 \lt j_2\),且 \(j_1\) 的决策比 \(j_2\) 更优,则有:
将 \(x,y\) 带进去:
移项,注意本题 \(h\) 单调递增所以要变号,同时把 \(k=-2h_i\) 代入:
第五步:确定维护方法
右边长得像一个斜率的式子,这意味着我们用 \(k=2h_i\) 的直线去“逼近”这个下凸壳,通过之前说的二分方法就可以找到最优决策进行转移。
由于要维护下凸壳,并且我们在 DP 要支持动态加入点,所以考虑用单调队列维护这个过程。
具体地,对于队列中的元素,必须保证两两之间斜率单调上升。
之前说过可以用二分找到队列中的最优决策点,但此时不需要这么麻烦。
由于我们用 \(k=2h_i\) 去逼近,而 \(h_i\) 又是单调递增的,所以最优决策点单调向后移动,在单调队列中不断弹出队头去掉不优的决策即可。
#include <bits/stdc++.h>
using namespace std;
const int N = 2e5 + 15;
int n, h[N];
long long C;
int q[N], st, ed;
long long dp[N];
double x(int p) { return h[p]; }
double y(int p) { return dp[p] + h[p] * 1ll * h[p]; }
double slope(int a, int b) {
return (y(b) - y(a)) * 1.00 / (x(b) - x(a));
}
int main() {
scanf("%d%lld", &n, &C);
for (int i = 1; i <= n; i++) scanf("%d", &h[i]);
st = ed = 1, q[ed] = 1;
for (int i = 2; i <= n; i++) {
while (st < ed && slope(q[st], q[st + 1]) <= 2 * h[i]) st++;
int j = q[st];
dp[i] = dp[j] + (h[i] - h[j]) * 1ll * (h[i] - h[j]) + C;
while (st < ed && slope(q[ed - 1], q[ed]) >= slope(q[ed], i)) ed--;
q[++ed] = i;
}
printf("%lld\n", dp[n]);
return 0;
}
例 2:洛谷 P3195 [HNOI2008] 玩具装箱
并不难写出普通转移方程(设 \(s\) 为 \(c\) 的前缀和):
考虑暴力展开平方,发现有整整 \(36\) 项,爽飞了。
推式子需要一些手法……考虑我们斜率优化之前要做什么?
分离 \(i, j\) 相关的项。
所以设 \(a_i = s_i + i, b_j = s_j + j + L + 1\),所以式子就简化为:
一样地,设 \(j_1 \lt j_2\),且 \(j_1\) 比 \(j_2\) 优……然后和上一题类似,最后的式子是:
\(a, b\) 都单增,所以 \(k, x\) 都单增,因此直接单调队列维护即可。
例 3:洛谷 P5785 [SDOI2012] 任务安排
一道很经典的斜率优化题,也是蓝书上的斜优入门题,就详细记录一下吧。
朴素 DP:
由于我们需要知道 \(S\) 被计算了几次,所以相应的也需要知道这是第几批次的任务。
设 \(dp_{i,j}\) 表示分了 \(i\) 批次完成前 \(j\) 个任务的最小代价。
记 \(st,sc\) 分别为 \(t,c\) 的前缀和。
则有转移式:
注意是 \(sc_j - sc_k\) 不是 \(sc_j - sc_{k-1}\)。
这样空间是 \(O(n^2)\),时间复杂度是 \(O(n^3)\)。由于卡空间需要滚动数组优化到 \(O(n)\)。
蓝书上介绍了一种费用提前计算的思想。
设 \(dp_{i}\) 表示把前 \(i\) 个任务分成若干组的最小代价。
发现对于每一组任务,\(S\) 的贡献计算是单增的。即 \(S,2S,3S,\dots,kS\)。
根据分配律,考虑把代价的计算中 \(st\) 和 \(S\) 的部分分开来计算。在当前任务提前计算好后面任务中 \(S\) 的贡献。
即:
此时空间复杂度是 \(O(n)\),时间复杂度 \(O(n^2)\),可以通过弱化版。
现在 \(n \leq 3 \times 10^5\),考虑 \(O(n)\) 等更优的做法。
设求 \(dp_i\) 时,\(j_1 \lt j_2 \lt i\),并且 \(j_1\) 优于 \(j_2\)。
则有:
移项注意负数变号。
取 \(\min\),所以单调队列维护下凸壳。
由于 \(st_i\) 单增,可以直接取队头进行转移。
一定不要把 slope(q[l-1],q[l])
写成 slope(l-1,l)
。
这是很久以前写的题解了。
这么推会有点麻烦,事实上可以先找出 \(x, y\) 然后换元,推完再代入进去会更顺。
例 4:洛谷 P3628 [APIO2010] 特别行动队
设 \(S\) 表示 \(x\) 的前缀和。
看到平方就应该很敏锐地发现是斜优了,因为平方是很明显的 \(i,j\) 各部分相关的形式。
和之前相同地,一样转化形式:
推式子:
取 \(\max\) 决策,上凸壳;\(A \lt 0\) 所以 \(k\) 单减 \(x\) 单增,单调队列队头转移。
于是把 \(x, y\) 带进去就做完了。
例 5:洛谷 P2900 [USACO08MAR] Land Acquisition G
首先发现如果一个矩形被另一个矩形完全包含,则它一定不会对答案有贡献。
因此使用单调栈 \(O(n)\) 处理出有用的矩形,画在平面直角坐标系上 应该是个阶梯状的东西。
之后 \(O(n^2)\) DP 是简单的,设 \(f_i\) 表示前 \(i\) 片土地分组的最小代价。
所以考虑优化,发现这东西 \(i,j\) 相关的项分别相乘,就很斜率。
由于式子很简单,而且换元又会有变量名冲突,可以直接对原式推导:
设 \(j_1 \lt j_2\) 且 \(j_1\) 优于 \(j_2\):
移项,注意 \(y_j\) 单调递减要变号。
\(k, x\) 都递增,可以直接队头转移。
例 6:洛谷 P2120 [ZJOI2007] 仓库建设
设 \(f_i\) 表示强制建 \(i\) 以保护 \([1,i]\) 物品的最小代价。
则容易写出状态转移方程,枚举上一次在 \(j\) 建立仓库:
把式子拆一下,得到:
设 \(sp\) 为 \(p\) 前缀和,\(s\) 为 \(x \times p\) 前缀和。
然后设 \(j_1 \lt j_2\),但 \(j_1\) 优于 \(j_2\),移项得到:
\(x, k\) 都单调递增,直接单调队列队头转移。
例 7:洛谷 P5504 [JSOI2011] 柠檬
假设 \(c_i\) 表示 \(a_i\) 颜色中 \(i\) 是第 \(c_i\) 个出现的。
则不难写出转移方程:
看到平方,果断拆式子:
提取 \(k, x, y, c\):
然后再假设 \(j_1 \lt j_2\),\(j_1\) 优于 \(j_2\),得到:
由于 \(k, x\) 双单增,需要用单调栈维护(卡了很久单调队列)。
例 8:洛谷 P5017 [NOIP 2018 普及组] 摆渡车
设 \(f_i\) 表示运走前 \(i\) 个单位时间来的人,最小的等待时间是多少。
容易得到转移方程:
第一想法是通过 \(i,j\) 二分出 \(t\) 序列合法的 \(l,r\),但显然由于 \(t\) 足够小可以直接在上面做前缀和。
前缀和优化一下,记 \(s\) 为 \(t\) 的前缀和,\(c\) 为 \([0,i]\) 时间内来了多少人。
复杂度 \(O(T^2)\)。
然后可以减少无用转移:发现 \(\geq 2m\) 的段分成两部分 \(m\) 一定不劣,所以减小转移范围,复杂度 \(O(Tm)\)。
还有优化空间!再看一眼式子:
乘积形式已经出来了,那么展开后,照常推式子。
结果是:
这玩意儿的 \(k=i\) 是单增的,\(x=c_j\) 也是单增的,所以可以直接单调队列队头转移。
例 9:洛谷 P4072 [SDOI2016] 征途
平均数:
方差:
化简得:
后者是定值,前者系数 \(m\) 也是定值,因此只要最小化 \(\sum s_i^2\) 即可。
\(O(m n^2)\) DP 是显然的,设 \(dp_{i,j}\) 表示走了 \(i\) 天,走到 \(j\) 这条路,最小代价是多少。
下文 \(s\) 为路径的前缀和。
则有 \(dp_{i,j} = \min\limits_{0 \leq k \lt j} \{ dp_{i-1,k} + (s_j - s_k)^2 \}\)。
然后注意到平方,这是一个很典的斜率优化的式子。
拆式子:
还是设 \(k_1 \lt k_2\),\(k_1\) 优于 \(k_2\):
挺巧的,\(k,x\) 都是 \(s_j\),且是单增的,所以直接单调队列维护,取队头转移就完事了。
例 10:CF311B. Cats Transport
首先考虑 \(a_i \leftarrow t_i - \sum\limits_{j=2}^i d_{h_i}\),即算出一个铲屎官最早什么时候出发能带走这只猫。
然后就和 \(h_i\) 无关了,再把 \(a\) 排序,每次相当于选取一段区间,代价是区间每个数与区间最大值差的绝对值之和。
容易想到二维 DP 状态 \(dp_{i,j}\) 表示前 \(j\) 个分成 \(i\) 组的最小代价。
设 \(s_i\) 为 \(a\) 的前缀和,优化一下:
这样时间复杂度是 \(O(p m^2)\) 的。
然后看到相乘直接斜率优化。
仍然设 \(k_1 \lt k_2\),\(k_1\) 决策优于 \(k_2\),推导得到:
双单增,直接单调队列维护下凸壳,队首转移。
例 11:洛谷 P5468 [NOI2019] 回家路线
很好玩的题,自己推出来了。(虽然最后还是翻题解才发现自己哪里错了……)
这个贡献式子是二次的,而且还和 \(u, v, p, q\) 至少四个参数相关,很吓人。
第一反应是考虑 DP 做这个事情,由于涉及点之间的转移,以及时间先后的问题,所以,设 \(dp_{u, i}\) 表示在 \(i\) 时刻到达 \(u\) 点的最小烦躁值
假设有一条边 \(u \to v\),出发时间 \(p\) 到达时间 \(q\),则不难写出如下方程:
其实就是枚举 \(i\) 时刻到达 \(u\) 点,然后通过该线路转移到 \(v\)。
这个式子有两个问题:
- 空间和时间都爆了。
- 在求 \(dp_{v,q}\) 的时候需要保证 \(dp_{u,i}\) 都已经计算完。
这里我纠结了挺久的,以为只要保证 \(\forall i, dp_{u}\) 被计算完成就行了,接着可以怎么动态开点优化一下空间,时间复杂度再说。
但事实是,这张图不一定是 DAG,不能拓扑排序。
那么不妨跳出来重新看问题,我们只需要保证 \(\forall i \leq q, dp_{u,i}\) 被计算完。
所以显而易见地,可以按照 \(q\)(到站时刻)将所有线路排序,依次插入这些线路。
然后新的问题是,DP 是否有后效性?显然不会,就算有环再绕回来,它也只能影响后面的时刻这个点的 DP 信息。
接下来考虑先排序,然后在上述算法的基础上优化时空复杂度。
首先不需要真的开两维的 dp 数组,因为对于一个点 \(v\) 有用的状态只有 \(\forall (u \to v, p, q)\) 中的 \(dp_{v, q}\)。
因此动态开点(vector)存 DP 值即可。
然后是时间复杂度的问题。
看到平方贡献,想到拆式子 斜率优化。
于是得到 \(k, x, y, c\),接下来推式子,设 \(i_1 \lt i_2\),则有:
观察形式:取 \(\min\) 维护下凸壳,\(x\) 单调递增但 \(k\) 不单调,所以用单调队列二分维护。
然后愉快地 WA 了洛谷 #9 #19
两个点。
之前的做法是按 \(q\) 排序,每次把当前的 \(u \to v\) 完成转移之后直接扔进 \(v\) 的单调队列里面。
问题是,\(p\) 的查询不具有单调性,所以如果之后有从 \(v\) 出发的列车,最优决策可能会被刚刚扔进去的覆盖掉。
所以正确做法是:按照 \(p\) 排序,每次把 \(\leq p\) 的所有待加入 DP 值扔进对应点的单调队列,更新完当前节点 \(v\) 的 DP 值之后也把它先存着,延后再加入。
由于红温了所以代码整得很丑……最初一版的代码其实还挺好看的。
例 12:洛谷 P3994 高速公路
不难想到设 \(f_i\) 表示从 \(i\) 跳到根节点的最小代价。
有 \(i,j\) 相关项相乘的形式!斜率优化!
设 \(dep_{j_1} \lt dep_{j_2}\),且 \(j_1\) 比 \(j_2\) 更优。
移项,\(x\)(\(dep\))单增,负数变号:
\(k, x\) 都单增,可以单调队列队头转移。
但这是一棵树,怎么做斜率优化呢?
如果在从根开始 dfs 的过程中直接维护单调队列,难免会有元素覆盖的情况。
本质上问题就是:一棵子树会影响另一棵子树的下凸壳。
如果暴力弹出加入(还原现场)是 \(O(n^2)\) 的复杂度,那么就不弹出加入了。
队头不弹出,直接保留完整下凸壳,转移的时候二分决策点是 \(O(n \log n)\) 的。
然后处理加入元素的问题:发现实际上由于下凸壳的斜率具有单调性,可以二分出队尾弹出到哪里,然后加入一个新元素 \(u\) 只会覆盖原来的一个元素,只需要维护这个东西最后再复原即可。
核心思想就是利用二分快速查找,以及利用队列弹出队尾之后每次只加入一个元素,把覆盖掉的那个元素给记录一下即可。
更详细地,就是队尾指针移动了,但元素还在。
例 13:洛谷 P4027 [NOI2007] 货币兑换
发现一个重要性质:每次买进一定买完,卖出一定卖完。
如果买进的券在未来可以让你赚钱,那你一定是买完最优,而卖出同理。
那么显然可以干出来一个 \(O(n^2)\) 的 DP,设 \(f_i\) 为前 \(i\) 天能获得的最大收益,考虑枚举 \(j\) 表示在 \(j\) 这一天买入所有券,第 \(i\) 天再买回来。
不妨设 \(s_i = \max\limits_{j=1}^{i} f_j\)。
若第 \(j\) 天买了 \(x\) 张 \(A\) 券和 \(y\) 张 \(B\) 券,设 \(y_j = t, x_j = R_j t\)。
解得:
转移式:
这和 斜率优化 本帖一开始引入的问题是几乎相同的。
借用类似的思路,我们将 \(ax + by\) 转化为 \(kx+b\) 的形式(同除 \(B_i\)):
它两维都不单增,可以 CDQ 分治或者平衡树维护上凸壳。
也可以李超线段树维护。
具体地,“反客为主”。
对于一次函数 \(y=kx+b\),原本 \(\frac{A_i}{B_i}\) 是 \(k\),\(x_j, y_j\) 分别是 \(x,b\)。
现在反过来,令 \(x = \frac{A_i}{B_i}\),对于每一个 \(j\) 相当于一条直线 \(k=x_j, b=y_j\)。
相当于维护 \(j \in [1, i-1]\) 这些直线在 \(x = \frac{A_i}{B_i}\) 处的最大取值,这是李超线段树的模板题。