斜率优化学习笔记
前置芝士:
- 一次函数(好吧其实你只要知道斜率)
- 基本的动态规划能力(暴力的转移)
- 一定的数学能力(指拆平方括号和合并同类项
Part.0 目录
- 铺垫
- 基本策略
- 什么题适合&需要斜率优化
- 基础习题讲解
- 拓展习题讲解
Part 1.铺垫
什么是斜率?
对函数有了解的人都知道,在一次函数中,对于平面直角坐标系的任意两点 \((x_1,y_1)\) 和 \((x_2,y_2)\),连接两点的直线的斜率 \(k=\frac{y_1-y_2}{x_1-x_2}\),这个想必大佬们早已知晓。
Part.2 基本策略
斜率如何帮助我们对动态规划进行优化呢?
首先,对于一个任意裸的状态转移方程,一般来说我们需要循环枚举所有可能的决策点,然后维护答案,但是在一些情况下这么做太慢的,容易惨遭 \(TLE\) ,那么我们应该怎么办?
以 HDU-3507 为例,状态转移方程不再赘述,假设 \(i\) 有两个决策点 \(j\) 和 \(k\),且 \(j \le k\),那么 \(j\) 成为更优决策点的条件是:
\(dp_j+(sum_i-sum_j)^2+m \le dp_k+(sum_i-sum_k)^2+m\)
\(\Leftrightarrow sum_i^2+dp_j-2\times sum_i \times sum_j +sum_j^2\le sum_i^2+dp_k-2\times sum_i \times sum_k +sum_k^2\)
\(\Leftrightarrow dp_j+sum_j^2 - 2 \times sum_i \times sum_j \le dp_k+sum_k^2 - 2 \times sum_i \times sum_k\)
\(\Leftrightarrow (dp_j+sum_j^2)-(dp_k+sum_k^2) \le 2 \times sum_i \times (sum_j-sum_k)\)
\(\Leftrightarrow \frac {(dp_j+sum_j^2)-(dp_k+sum_k^2)}{sum_j-sum_k} \le 2 \times sum_i\)
这个形式有没有想到什么?
这不就是斜率的标准形式吗?
那么,可以将目前可行的决策点丢进一个队列里,然后只要从队首开始,通过斜率判断此时的队头和队列第二项作为决策点是的斜率不等式是否成立,只要成立,那么 \(j\) 永远不会比 \(k\) 优,就 pop
掉,然后继续向上找的符合条件的决策点,反之,直接状态转移方程。
值得一提的是,很多 blog 会画图用上凸壳下凸壳解释,这样确实便于理解但是我这里为了思路简单明了(说白了就是不可能每道题去画图吧)直接从理论分析了 qwq
代码语言则为:while(l+1<=r&&top(q[l+1],q[l])<=2*sum[i]*down(q[l+1],q[l]))l++;
top
& down
分别指分子公式 & 分母公式。
因为要提取的不止队首,所以这个队列要手写(不过你 front
两次也没问题但是又不好写常数又大)。
然后,为了答案的最优性,队列中每相邻两点的对应斜率单调递增,不然反正之后也会被之前的步骤拜拜掉,所以,该队列实际上是一个单调队列。
这样处理之后,就可以排除很多的臃余状态,时间复杂度由 \(O(n^2) -> O(n)\)
即:while(l+1<=r&&top(i,q[r])*down(q[r],q[r-1])<=top(q[r],q[r-1])*down(i,q[r]))r--;
然后将当前节点入队即可。
还有一个细节,为了 while
能顺利执行,我们要在动态规划之前加入一个虚拟节点即 q[++r]=0;
那么到这里,这一题基本结束,斜率优化的基本步骤也到此为止。
Part.3 什么题目应当使用斜率优化呢?
显然,为了能构成斜率,状态转移方程形式如下:
\(dp_i=min(dp_j+A(i,j));\)
其中,\(A(i,j)\) 表示与 \(i,j\) 有关的多项式。
如果后面是仅与 \(i\) 或 \(j\) 有关的多项式就考虑单调队列优化罢。
Part.4 基础习题
1.HDU-3507
见上。
先推柿子。
基本状态转移方程:
\(dp_i=\min(dp_j+(i-j-1+sum_i-sum_j-L)^2);\)
仍然假设 \(i\) 有两个决策点 \(j\) 和 \(k\),且 \(j \le k\)。
接下来怎么办呢?爆展?\(72\) 项得有多大的耐力啊,肝帝罢。
可以按照与 \(i\) 相关和与 \(j\) 相关归纳一下:\(dp_i=min(dp_j+((i+sum_i-1-L)-(j+sum_j))^2);\)
这个东西是可以预处理出来的呀!!1
设 \(h_i=i+sum_i-1-L\),\(g_i=j+sum_j\)
接下来就清爽多了.
参考代码:
#include<bits/stdc++.h>
#define int long long//不开ll见祖宗
using namespace std;
int n,m,q[50005],sum[50005],dp[50005],h[50005],g[50005];
int top(int i,int j){//斜率分子
return (dp[i]+g[i]*g[i])-(dp[j]+g[j]*g[j]);
}
int down(int i,int j){//斜率分母
return g[i]-g[j];
}
signed main(){
cin>>n>>m;{
for(int i=1;i<=n;i++)cin>>sum[i];
sum[0]=dp[0]=0;
for(int i=1;i<=n;i++){
sum[i]+=sum[i-1];
h[i]=sum[i]+i-1-m;
g[i]=sum[i]+i;//预处理
}
int l=1,r=0;
q[++r]=0;
for(int i=1;i<=n;i++){
while(l+1<=r&&top(q[l+1],q[l])<=2*h[i]*down(q[l+1],q[l]))l++;
int j=q[l];
int tmp=i-j-1+sum[i]-sum[j]-m;
dp[i]=dp[j]+tmp*tmp;
while(l+1<=r&&top(i,q[r])*down(q[r],q[r-1])<=top(q[r],q[r-1])*down(i,q[r]))r--;//如上
q[++r]=i;
}
cout<<dp[n]<<'\n';
}
return 0;
}
基础状态转移方程:dp[i]=dp[j]+a*(sum[i]-sum[j])*(sum[i]-sum[j])+b*(sum[i]-sum[j])+c;
开 始 愉 (lie) 快 (kai) 地 推 式 子:
\(dp_j+a\times (sum_i-sum_j)\times (sum_i-sum_j)+b\times(sum_i-sum_j)+c \le dp_k+a\times (sum_i-sum_k)\times (sum_i-sum_k)+b\times(sum_i-sum_k)+c\)
经过爆展和一系列合并之后如下:
\(2\times a \times sum_i \le\frac{(dp_j+a\times sum_j\times sum_j-b\times sum_j)-(dp_k+a\times sum_k\times sum_k-b\times sum_k)}{sum_j-sum_k}\)
然后套用之前的模板(这次求最大值!不要弄反了!)
#include<bits/stdc++.h>
#define int long long//不开ll见祖宗
using namespace std;
int n,m,q[1000005],sum[1000005],dp[1000005],a,b,c;
int top(int i,int j){
return (dp[i]+a*sum[i]*sum[i]-b*sum[i])-(dp[j]+a*sum[j]*sum[j]-b*sum[j]);
}
int down(int i,int j){
return sum[i]-sum[j];
}
signed main(){
cin>>n>>a>>b>>c;
for(int i=1;i<=n;i++)cin>>sum[i];
sum[0]=dp[0]=0;
for(int i=1;i<=n;i++){
sum[i]+=sum[i-1];
}
int l=1,r=0;
q[++r]=0;
for(int i=1;i<=n;i++){
while(l+1<=r&&top(q[l+1],q[l])>=2*a*sum[i]*down(q[l+1],q[l]))l++;
int j=q[l];
dp[i]=dp[j]+a*(sum[i]-sum[j])*(sum[i]-sum[j])+b*(sum[i]-sum[j])+c;
while(l+1<=r&&top(i,q[r])*down(q[r],q[r-1])>=top(q[r],q[r-1])*down(i,q[r]))r--;
q[++r]=i;
}
cout<<dp[n]<<'\n';
return 0;
}
跟之前代码差别不大,重在柿子。
Part.5 拓展习题讲解
之前都是简单的 \(O(n^2)\) 优化成 \(O(n)\)。
那么如果出现了更加复杂的情况呢?
例如 P2365 任务安排
高能预警
\(O(n^3)\) 十分套路,先来看 \(O(n^2)\)。
考虑费用提前,先摆上方程:
//待填坑,关于费用提前的说明
\(dp_j=\min(dp_j+sumT_i\times (sumF_i-sumF_j)+s\times (sumF_n-sumF_i);\)
有人会问:这么写 \(dp_i\) 根本就没有正确性啊?为什么对呢?
这就是费用提前法的高明之处了: \(dp_n\) 之前都是错的且答案偏大,到\(dp_n\) 就对了。
为什么呢?
请看到 \(s\times (sumF_n-sumF_i)\) 这一段,正常动态规划之所以慢,是因为要枚举分段算贡献,但是早加晚加反正都是全加,不如就挨个挨个加上,就能保证且仅能保证 \(dp_n\) 的正确性。
其实到这一步,因为 \(n\) 的范围比较水,实际上已经可以过掉了,但是为了操练斜率优化 (卡最优解,我们还是要推式子。
没有平方,应该还是好推的。
自行参考如下。
int top(register int i,register int j){
return (dp[i]-s*sf[i])-(dp[j]-s*sf[j]);
}
int down(register int i,register int j){
return sf[i]-sf[j];
}
接下来又是板子,由此可见当斜率优化加上各种奇怪优化等于毒瘤(划掉。
最后一个习题:P3648
作为 APIO 的原题,质量还是很高的。
基本状态转移方程:
f[i]=dp[j]+sum[j]*(sum[i]-sum[j]);
本来是二维,但是通过滚动数组的方式可以降维优化。
接下来,只要进行 \(k\) 次动态规划就能得到答案,当然,还要记一下方案。
因为是最后一道题,柿子和代码咕咕咕掉了((,有问题可以康康题解。
完结撒花!!1