再论斜率优化
前言
早在2024年9月3日,本蒟蒻就发布了一篇斜率优化的讲解,然而我那个时候过于菜了,理解不是很深刻,于是今天重写一篇。
问题
见洛谷P10979,至于那个削弱版的绿题,就是费用提前计算,我之前讲得挺明白了,这里不再讲了。
再那个题中,我们使用了前缀和和费用提前计算优化,DP转移式如下。
d p i = m i n ( d p j + s u m t i × ( s u m c i − s u m c j ) + s × ( s u m c n − s u m c j ) ) dp_i = min(dp_j + sumt_i \times (sumc_i - sumc_j) + s \times (sumc_n - sumc_j)) dpi=min(dpj+sumti×(sumci−sumcj)+s×(sumcn−sumcj))
其中 s u m c sumc sumc和 s u m t sumt sumt就是 c c c和 t t t的前缀和,其余变量如题中所述, d p i dp_i dpi就是完成前 i i i个任务的最小花费, j j j是枚举的。
我们要枚举 j j j,就得 n 2 n^2 n2,过不了。
考虑怎么不枚举 j j j。
在线段树优化DP的时候,我们都是把和 j j j有关的挪到一边,这是有道理的,因为这么做就可以把 i i i和 j j j拆开,方便上手法维护。
化一化式子,得到:
d p j − ( s u m t i + s ) × s u m c j = d p i − s u m c i × s u m t i − s u m c n × s dp_j - (sumt_i + s) \times sumc_j = dp_i - sumc_i \times sumt_i - sumc_n \times s dpj−(sumti+s)×sumcj=dpi−sumci×sumti−sumcn×s
上线段树的想法彻底破灭了,左边还有 i i i,你发现含 i i i的项和含 j j j的项相乘了,但是只有一项,这个就要使用斜率优化了。
斜率优化
然后你继续瞪眼,发现这个像直线的方程,你要右面尽可能小,就是将 d p j dp_j dpj和 s u m c j sumc_j sumcj带入这条斜率为 s u m t i + s sumt_i + s sumti+s的直线,让截距最小 (防止你不会:截距指直线和y轴交点到原点的距离)
为了方便,我们统一钦定 s u m c j sumc_j sumcj为x轴 d p j dp_j dpj为y轴。
然后怎么让截距最小呢?可以让这条直线从负无穷的位置移上来,这里复用一下两年前的老图。

发现碰到的第一个点就是最小转移点。
这个有性质,如图。

只有黄色的点能碰到,也就是一个下凸包,这个凸包可以单调队列维护。
维护出来了之后,我们只需要找到最后一条斜率小于 s u m t i + s sumt_i + s sumti+s的线段的右端点就行了。
但是,最坏情况下,所有点都在凸包中,我们扫一遍还是O(n)的。
怎么办,考虑斜率是 s u m t i + s sumt_i + s sumti+s,随着 i i i增加肯定是递增的。
所以可以放进单调队列里,斜率小就弹掉,队头就是最佳转移点,时间复杂度O(n)。
cpp
#include<bits/stdc++.h>
#define int long long
using namespace std;
const int N = 300010;
int n,s;
int c[N],t[N],sumc[N],sumt[N];
int dp[N];
int q[N],head = 1,tail = 1;
signed main(){
cin>>n>>s;
for(int i = 1;i<=n;i++){
cin>>t[i]>>c[i];
sumt[i] = sumt[i-1]+t[i];
sumc[i] = sumc[i-1]+c[i];
}
dp[1] = 0;
q[1] = 0;
for(int i = 1;i<=n;i++){
int k = s + sumt[i];//斜率
//计算前两个点的斜率,为(dp[q[head+1]] - dp[q[head]]) / (sumc[q[head+1]] - sumc[q[head]]),斜率<=k弹出
while(head<tail && (dp[q[head+1]] - dp[q[head]]) <= (sumc[q[head+1]] - sumc[q[head]]) * k){
head++;
}
//更新dp值,我们已经维护出了最优转移点,式子套上来就行
dp[i] = dp[q[head]] - (k * sumc[q[head]]) + (sumc[i] * sumt[i]) + (sumc[n] * s);
//i和tail的斜率 (dp[i] - dp[q[tail]]) / (sumc[i] - sumc[q[tail]])
//tail和tail-1的斜率 (dp[q[tail]] - dp[q[tail-1]]) / (sumc[q[tail]] - sumc[q[tail-1]])
while(head<tail && (dp[i] - dp[q[tail]]) * (sumc[q[tail]] - sumc[q[tail-1]]) <= (dp[q[tail]] - dp[q[tail-1]]) * (sumc[i] - sumc[q[tail]])){
tail--;
}
//插入i
q[++tail] = i;
}
cout<<dp[n]<<'\n';
return 0;
}
另一种问题
t i t_i ti可以为负,话说为啥用时还能是负的?
我们的斜率不仅失去了单调递增的性质,而且还可以是负的。
照常维护下凸包,维护出的是这样的:

绿色的是之前没有的。
发现斜率还是单调的,我们判定最佳转移点的方式不变,只是我们不能直接弹掉队头,之后可能还有用。
直接二分 就好了,多一个log时间复杂度可以接受。
代码如下
cpp
#include<bits/stdc++.h>
#define int long long
using namespace std;
const int N = 300010;
int n,s;
int c[N],t[N],sumc[N],sumt[N];
int dp[N];
int q[N],tail = 1;
signed main(){
cin>>n>>s;
for(int i = 1;i<=n;i++){
cin>>t[i]>>c[i];
sumt[i] = sumt[i-1]+t[i];
sumc[i] = sumc[i-1]+c[i];
}
dp[1] = 0;
q[1] = 0;
for(int i = 1;i<=n;i++){
int k = s + sumt[i];//斜率
//我们规定一下,二分的是斜率<=k的线段的左端点,这样+1就是答案了
int l = 1,r = tail,mid,res = -1;
while(l<=r&&tail!=1){
mid = (l+r)>>1;
int h1 = q[mid],h2 = q[mid+1];//左右端点
//(dp[h2] - dp[h1]) / (sumc[h2] - sumc[h1]) <= k
if((dp[h2] - dp[h1]) <= k * (sumc[h2] - sumc[h1])){
res = mid;
l = mid+1;
}else{
r = mid-1;
}
}
if(res==-1)res = 1;//就一个点特判一下
else res++;
//res为q的下标,我们将其还原为原序列的下标
res = q[res];
//更新dp值,我们已经维护出了最优转移点,式子套上来就行
dp[i] = dp[res] - (k * sumc[res]) + (sumc[i] * sumt[i]) + (sumc[n] * s);
//i和tail的斜率 (dp[i] - dp[q[tail]]) / (sumc[i] - sumc[q[tail]])
//tail和tail-1的斜率 (dp[q[tail]] - dp[q[tail-1]]) / (sumc[q[tail]] - sumc[q[tail-1]])
while(tail > 1 && (dp[i] - dp[q[tail]]) * (sumc[q[tail]] - sumc[q[tail-1]]) <= (dp[q[tail]] - dp[q[tail-1]]) * (sumc[i] - sumc[q[tail]])){
tail--;
}
//插入i
q[++tail] = i;
}
cout<<dp[n]<<'\n';
return 0;
}
后记
可以发现,dp问题中想暴力很重要,一眼看不出用什么,那就化式子,化完之后就能看出用线段树之类的数据结构还是斜率优化了,用slope trick之类的也说不定?
本文作者是蒟蒻,如有错误请各位神犇指点
森林古猿出品,必属精品,请认准CSDN森林古猿1