浅谈斜率优化 DP
算法详解
例题引入
我们来看一道题目:玩具装箱。
有 \(n\) 个玩具,第 \(i\) 个玩具有长度 \(c_i\)。要求将这 \(n\) 个玩具按顺序排成一排并分成若干个段。一个 \([l,r]\) 段的代价为 \((r-l+\sum_{i=l}^{r} c_i-L)^2\),求分段的最小代价。
\(1 \le n \le 5 \times 10^4\),\(1 \le c_i,L \le 10^7\)。
朴素 DP 做法
首先容易想到一个朴素的 DP 做法。
设 \(dp_i\) 表示将前 \(i\) 个物品分段后的最小代价,最终答案即为 \(dp_n\)。
先预处理前缀和 \(s_k = \sum_{i=1}^{k} c_i\),易得状态转移方程 \(dp_i = \min_{j<i} \{ dp_j + (i-j-1+s_i-s_j)^2 \}\)。
做法是可行的,但是在该题 \(5 \times 10^4\) 的数据范围下,这种 \(O(n^2)\) 的暴力是跑不过去的。
斜率优化 DP
考虑简化上面的转移式,令 \(g_i = s_i + i\),\(L' = L + 1\),有 \(dp_i = \min_{j<i} \{ dp_j + (g_i - g_j - L')^2 \}\)
将与 \(j\) 无关的移到外边,可得 \(dp_i - (g_i - L')^2 = \min_{j<i} \{ dp_j + {g_j}^2 + 2s_j(L'-s_i) \}\)。
一次函数斜截式为 \(y = kx+b\),移项得 \(b = y - kx\),将所有只与 \(j\) 有关的信息表示成 \(y\),同时与 \(i\) 和 \(j\) 都有关的表示成 \(kx\),把需要最小化的量即只与 \(i\) 有关的信息表示成 \(b\),则有 \(x_j = g_j\),\(y_j = dp_j + {g_j}^2\),\(k_i = -2(L'-g_i)\),\(b_i = f_i - (s_i - L')^2\)。
那么现在转移式就可以直接写成 \(b_i = \min_{j<i} \{y_j - k_i x_j\}\)。把 \((x_j,y_j)\) 视作二维平面上的点,那么 \(k_i\) 就是斜率,\(b_i\) 是截距。问题转化为选择最合适的 \(j\) 以最小化直线的截距。
盗用借用 OI-wiki 上一张图:
不难发现,可以将斜率 \(k\) 的直线(图中红色线)从下往上平移,直到有一个点 \((x_p,y_p)\) 出现在直线上,此时 \(b_i = y_p - k_i x_p\) 且 \(b_i\) 取到最小值,算完 \(dp_i\) 再把 \((x_i,y_i)\) 也加入点集。
思路是不错的,但是我们该如何维护点集呢?注意到可能让 \(b_i\) 取到最小值的点一定在下凸壳上,于是在寻找 \(p\) 的时候我们便不需要维护整个点集,只维护凸包上的点即可。本题中斜率 \(k_i\) 随着 \(i\) 的增加而增加,因而可以单调队列维护。
具体过程是简单的。每次只需用一条和 \(i\) 相关的直线去切维护的凸包,找到最优解来更新 \(dp_i\)。之后往单调队列里塞进状态 \(dp_i\),此时还得先剔除所有加入新点后不再是凸包上的点的点。
代码实现参考
注意涉及到分数比大小时最好十字相乘转化为整数来比,避免精度误差。
#include<bits/stdc++.h>
#define LL long long
#define UInt unsigned int
#define ULL unsigned long long
#define LD long double
#define pii pair<int,int>
#define pLL pair<LL,LL>
#define pDD pair<LD,LD>
#define fr first
#define se second
#define pb push_back
#define isr insert
#define i128 __int128
using namespace std;
const int N = 5e4+5;
struct line{LL k,b;};
LL n,L,c[N],s[N],dp[N];
deque<line> q;
LL read(){
LL su=0,pp=1;char ch=getchar();
while(ch<'0'||ch>'9'){if(ch=='-')pp=-1;ch=getchar();}
while(ch>='0'&&ch<='9'){su=su*10+ch-'0';ch=getchar();}
return su*pp;
}
LL cntY(line ln,LL x){return ln.k*x+ln.b;}
bool Bad(line l1,line l2,line l3){
i128 Lx=(i128)(l2.b-l1.b)*(l2.k-l3.k);
i128 Rx=(i128)(l3.b-l2.b)*(l1.k-l2.k);
return (Lx>=Rx);
}
int main(){
n=read(),L=read()+1;
for(int i=1;i<=n;i++)
c[i]=read(),s[i]=s[i-1]+c[i]+1;
q.pb({0,0});
for(int i=1;i<=n;i++){
LL x=s[i]-L;
while(q.size()>1&&cntY(q[0],x)>=cntY(q[1],x))q.pop_front();
LL Bnow=cntY(q.front(),x);dp[i]=Bnow+x*x;
line newln={-2*s[i],dp[i]+s[i]*s[i]};
while(q.size()>1&&Bad(q[q.size()-2],q.back(),newln))q.pop_back();
q.pb(newln);
}cout<<dp[n]<<"\n";
return 0;
}
例题讲解
P2365 任务安排
给定 \(n\) 个按一定顺序排列的任务,要求将这 \(n\) 个任务分成若干批,每批包含相邻的若干任务。这些任务从 \(0\) 时刻开始分批加工,每个任务单独加工的时间为 \(t_i\),且每批任务开始前有机器启动时间 \(s\),而完成这批任务所需的时间是各个任务需要时间的总和(同一批任务将在同一时刻完成)。每个任务的费用是它的完成时刻乘以一个费用系数 \(f_i\),请构造一个分组方案使总费用最小。
\(1\le n \le 5000\),\(0 \le s \le 50\),\(1\le t_i,f_i \le 100\)。
首先维护数组 \(st\) 和 \(sf\) 分别表示 \(t\) 和 \(f\) 这两个数组的前缀和,方便后续转移。
定义 \(dp_i\) 表示将前 \(i\) 个物品分成若干批的最小费用,但是你转移的时候就不知道机器启动过几次了。不过可以发现,如果要从 \(dp_j\) 转移到 \(dp_i\),由于编号为 \(j+1 \sim i\) 的任务都是在同一批内完成的,因此只需要把 \(s\) 对这一段的额外影响补充进费用里即可。
于是可以得到转移式 \(dp_i = \max\{ dp_j + st_i(sf_i - sf_j) + s(sf_n - sf_j) \}\),时间复杂度 \(O(n^2)\)。
由于该题数据范围只有 \(5000\),直接这样做就可以 A 了,但是我们依旧来考虑如何对其进行斜率优化。
对式子进行变形与转化,可以发现斜率 \(k_i = s + st_i\),截距 \(b_i = dp_i - st_i sf_i - s \times sf_n\)。
考虑 \(j_1 < j_2 < j_3\),分析 \(j_2\) 成为最优决策的条件,通过画图以及对式子的分析,我们可以得到,当且仅当 \(\dfrac{dp_{j_2} - dp_{j_1}}{sf_{j_2} - sf_{j_1}} < \dfrac{dp_{j_3} - dp_{j_2}}{sf_{j_3} - sf_{j_2}}\),等价于说我们要维护一个斜率单调递增的下凸壳。由于 \(0 \le j < i\),当 \(i\) 增加时,就会有新的决策,如果我们只保留连接相邻两点斜率大于 \(s+st_i\) 的部分,那么最左端的就是最优决策。
于是时间复杂度就被优化成了 \(O(n)\)。
#include<bits/stdc++.h>
#define LL long long
#define UInt unsigned int
#define ULL unsigned long long
#define LD long double
#define pii pair<int,int>
#define pLL pair<LL,LL>
#define pDD pair<LD,LD>
#define fr first
#define se second
#define pb push_back
#define isr insert
#define i128 __int128
using namespace std;
const int N = 5005;
LL n,T,t[N],c[N],dp[N];deque<LL> q;
LL read(){
LL su=0,pp=1;char ch=getchar();
while(ch<'0'||ch>'9'){if(ch=='-')pp=-1;ch=getchar();}
while(ch>='0'&&ch<='9'){su=su*10+ch-'0';ch=getchar();}
return su*pp;
}
bool Canit(LL a1,LL a2,LL k){
return (dp[a2]-dp[a1]<=k*(c[a2]-c[a1]));
}
bool Bad(LL a1,LL a2,LL a3){
LL Lx=(dp[a2]-dp[a1])*(c[a3]-c[a2]);
LL Rx=(dp[a3]-dp[a2])*(c[a2]-c[a1]);
return (Lx>=Rx);
}
int main(){
n=read(),T=read(),q.pb(0);
for(int i=1;i<=n;i++){
LL nt=read(),nc=read();
t[i]=t[i-1]+nt,c[i]=c[i-1]+nc;
}for(int i=1;i<=n;i++){
while(q.size()>1&&Canit(q[0],q[1],t[i]+T))q.pop_front();
dp[i]=dp[q[0]]+c[i]*t[i]+T*c[n]-c[q[0]]*(t[i]+T);
while(q.size()>1&&Bad(q[q.size()-2],q.back(),i))q.pop_back();q.pb(i);
}cout<<dp[n]<<"\n";
return 0;
}
P5017 摆渡车
\(n\) 个人要乘车从 A 点到 B 点,第 \(i\) 个人在第 \(t_i\) 分钟去等车。只有一辆车在工作,但其容量无限大。车从 A 点出发,把车上的人送到 B 点,再回到 A 点,往返一趟共花费 \(m\) 分钟(上下车时间忽略不计)。车要将所有人都送到 B 点,如果你能任意安排车出发的时间,求这 \(n\) 个人的等车时间之和的最小值。
\(1 \le n \le 500\),\(1 \le m \le 100\),\(0 \le t_i \le 4 \times 10^6\)。
考虑先把题意抽象化。将时间看做是一条数轴,每个人按照到达时刻分别对应数轴上可能重合的点。安排车的工作等同于将数轴分成若干个左开右闭段,并且每段长度 \(\ge m\)。而等车时间之和就转换成所有点到各自所属段右边界的距离之和。
这样就有一个 DP 模型了,定义 \(dp_i\) 表示数轴上对 \((-\infty,i]\) 分段且最后一段的右端点是 \(i\),的距离之和(也就是原本的所谓等车时间之和)最小值。显然可以得到转移式 \(dp_i = \min_{j \le i-m} \{ dp_j + \sum_{j<t_k<i} (i-t_k)\}\)。
然而后面 \(\sum\) 的那一堆不好求,考虑用前缀和来优化。于是可以得到新的式子 \(dp_i = \min_{j \le i- m} \{ dp_j + (cnt_i - cnt_j) \times i - (sum_i - sum_j)\}\),其中 \(cnt_i\) 表示所有 \(t\) 值在 \((-\infty,i]\) 区间内的下标个数,而 \(sum_i\) 则表示所有 \(t\) 值在 \((-\infty,i]\) 区间内的 \(t\) 值之和。
这样我们就有了 \(O(\max t ^2)\) 的做法了,在 \(4 \times 10^6\) 的数据范围下完全不可取。
怎么办?当然是凉拌啦!
考虑拆这个式子,不难得到 \(dp_i - cnt_i \times i + sum_i = - i \times cnt_j + dp_j + sum_j\),这完全就是斜率优化的形式啦。斜率 \(i\) 单调上升,维护下凸壳,对于 \(i\) 把 \(i-m\) 塞进队列即可保证决策点 \(j \le i-m\)。
由于每个状态最多进出队列一次,所以时间复杂度是 \(O(\max t)\) 的,稳过。
#include<bits/stdc++.h>
#define LL long long
#define UInt unsigned int
#define ULL unsigned long long
#define LD long double
#define pii pair<int,int>
#define pLL pair<LL,LL>
#define pDD pair<LD,LD>
#define fr first
#define se second
#define pb push_back
#define isr insert
#define i128 __int128
using namespace std;
const int N = 5e6+5;
struct line{LL k,b;};
LL n,m,Sum,Ans,t[N];
LL cnt[N],sum[N],MxTim,dp[N],st,ed,q[N];
LL read(){
LL su=0,pp=1;char ch=getchar();
while(ch<'0'||ch>'9'){if(ch=='-')pp=-1;ch=getchar();}
while(ch>='0'&&ch<='9'){su=su*10+ch-'0';ch=getchar();}
return su*pp;
}
LL Calc(LL x,LL tim){return dp[x]-tim*cnt[x];}
bool Bad(LL a1,LL a2,LL a3){
LL Lx=(dp[a2]-dp[a1])*(cnt[a3]-cnt[a2]);
LL Rx=(dp[a3]-dp[a2])*(cnt[a2]-cnt[a1]);
return (Lx>=Rx);
}
int main(){
n=read(),m=read();
Ans=0x3f3f3f3f3f3f3f3f;
for(int i=1;i<=n;i++)
t[i]=read()+1,MxTim=max(MxTim,t[i]),
cnt[t[i]]++,Sum+=t[i];
for(int i=1;i<=MxTim+m;i++)cnt[i]+=cnt[i-1];
memset(dp,0x3f,sizeof(dp));
dp[0]=0;st=1,ed=1,q[1]=0;
for(int i=1;i<=MxTim+m;i++){
if(i>m){
while(st<ed&&Bad(q[ed-1],q[ed],i-m))ed--;
q[++ed]=i-m;
}while(st<ed&&Calc(q[st],i)>=Calc(q[st+1],i))st++;
dp[i]=dp[q[st]]+i*(cnt[i]-cnt[q[st]]);
if(i>=MxTim)Ans=min(Ans,dp[i]);
}cout<<Ans-Sum<<"\n";
return 0;
}
P2120 仓库建设
一座山上有 \(n\) 个工厂,工厂 \(1\) 在山顶,工厂 \(n\) 在山脚。工厂 \(i\) 距离工厂 \(1\) 的距离为 \(x_i\),且工厂 \(i\) 目前已有成品 \(p_i\) 件。现需要将所有工厂内的成品都存放进仓库,已知在第 \(i\) 个工厂位置建立仓库的费用是 \(c_i\)。对于没有建立仓库的工厂,其产品只能运往编号更大的工厂的仓库。一件产品运送一个单位距离的费用是 \(1\)。请你帮忙设计一个仓库建设的方案,使得总费用(建造费用与运输费用之和)最小。
\(1 \le n \le 10^6\),\(0 \le x_i,p_i,c_i < 2^{31}\)。
设 \(dp_i\) 表示在第 \(i\) 个工厂建仓库且只考虑前 \(i\) 个工厂的成品存放的最小花费,于是可以写出转移式 \(dp_i = \min_{j<i} \{ dp_j + \sum_{k=j+1}^{i} (x_i - x_k)p_k + c_i \}\),于是我们就得到了一个 \(O(n^2)\) 的 DP。
复杂度完全受不了啊,先拆一下式子变成 \(dp_i = \min_{j<i} \{ dp_j + \sum_{k=j+1}^{i} x_i p_k - \sum_{k=j+1}^{i} x_k p_k + c_i \}\)。
设 \(sum_i = \sum_{k=1}^{i} x_k p_k\),\(sp_i =\sum_{k=1}^{i} p_k\),就可以简化成 \(dp_i = \min_{j<i} \{ dp_j + x_i(sp_i- sp_j) - (sum_i - sum_j) + c_i \}\)。
假设前面有两个位置 \(a,b\) 且 \(a<b\),那么如果 \(a\) 转移过来优于 \(b\),不难发现需要满足 \(x_i < \dfrac{(dp_b + sum_b) - (dp_a + sum_a)}{sp_b - sp_a}\)。右边这个东西可以看作是由 \((sp_i,dp_i + sum_i)\) 这类点的构成的直线的斜率,既然斜率需要越来越大,就可以维护一个下凸壳。于是就是斜率优化板题了。
最后注意有的工厂可能没有商品,所以此时会出现构成直线中 \(sp_i - sp_j = 0\),由于横坐标相同的两个点显然要纵坐标更小的更优,所以若 \(y>0\) 就当作是正无穷,反之则为负无穷即可。
#include<bits/stdc++.h>
#define LL long long
#define UInt unsigned int
#define ULL unsigned long long
#define LD long double
#define pii pair<int,int>
#define pLL pair<LL,LL>
#define pDD pair<LD,LD>
#define fr first
#define se second
#define pb push_back
#define isr insert
using namespace std;
const int N = 1e6+5;
LL n,x[N],p[N],c[N],sp[N],spx[N];
LL st,ed,q[N],dp[N],Ans;
LL read(){
LL su=0,pp=1;char ch=getchar();
while(ch<'0'||ch>'9'){if(ch=='-')pp=-1;ch=getchar();}
while(ch>='0'&&ch<='9'){su=su*10+ch-'0';ch=getchar();}
return su*pp;
}
bool Beg(int i,int j,int k){
LD dx=sp[i]-sp[j];
LD dy=dp[i]+spx[i]-dp[j]-spx[j];
return (dy/dx<=k);
}
bool kpD(int i,int j,int k,int l){
LD dx=sp[i]-sp[j];
LD dy=dp[i]+spx[i]-dp[j]-spx[j];
LD bx=sp[k]-sp[l];
LD by=dp[k]+spx[k]-dp[l]-spx[l];
return (dy/dx>=by/bx);
}
int main(){
n=read();
for(int i=1;i<=n;i++)
x[i]=read(),p[i]=read(),c[i]=read(),
sp[i]=sp[i-1]+p[i],spx[i]=spx[i-1]+p[i]*x[i];
st=1,ed=1,dp[0]=0,q[1]=0;
for(int i=1;i<=n;i++){
while(st<ed&&Beg(q[st],q[st+1],x[i]))st++;
dp[i]=dp[q[st]]+c[i]+x[i]*(sp[i]-sp[q[st]])-(spx[i]-spx[q[st]]);
if(!p[i])dp[i]=min(dp[i],dp[i-1]);
while(st<ed&&kpD(q[ed-1],q[ed],q[ed-1],i))ed--;q[++ed]=i;
}Ans=dp[n];
for(int i=n;i&&sp[i]==sp[i-1];Ans=min(Ans,dp[--i]));
cout<<Ans<<"\n";
return 0;
}
概括与总结
斜率优化 DP,是一种用于优化特定形式动态规划转移的高效技巧,其常可以将 \(O(n^2)\) 的 DP 优化为 \(O(n \log n)\)。其核心思想在于将 DP 转移式变形为直线截距的形式,把每个历史状态抽象为平面上的点,通过维护凸包的下凸壳或上凸壳快速找到最优决策点,并借助单调队列或二分查找高效更新与查询。总之,它是一种既巧妙又强大的 DP 优化方法啦!> <

浙公网安备 33010602011771号