斜率优化DP例题:任务安排 三道题 详细讲解
这三道题难度递增,循序渐进,能更好地帮助读者学会斜率优化 DP。(公式较多,慢慢看,不要因为公式多而畏难,很好理解的。)
一、洛谷 P2365 [IOI 2002] 任务安排
题目大意
有 \(n\ (n\le 5000)\) 个任务需要被完成,你可以把这些任务全部分成任意多组,每组内的任务必须连续。同组任务会在同一时刻完成,该批任务的加工时间之和为 \(\sum_{i=l}^r T_i\)。每批任务开始前机器需要启动/预热时间 \(s\)。任务 \(i\) 完成的费用为完成时间乘以 \(F_i\),求如何分组能让费用最小。
思路分析
最暴力的想法
设 $dp_{i,j}$ 表示把前 $i$ 个任务分成 $j$ 批的最小费用。设 $t$ 和 $f$ 数组分别为 $T$ 数组和 $F$ 数组的前缀和。显然,第 \(j\) 批任务完成的时间就是第 \(j\) 批的最后一个任务(任务 \(i\))在不分批情况下的结束时间 \(t_i\),加上前面任务需要的开机时间 \(s\times j\)。
那么状态转移公式就是:
这样做的时间复杂度是 \(O(n^3)\) 的,会超时(这里也就不贴代码了)。
考虑优化
这次我们大胆设 \(dp_i\) 表示前 \(i\) 个任务分批执行的最小费用(\(t\) 和 \(f\) 的含义同上)。
这里采用费用提前计算:如果从 \(dp_j\) 转移到 \(dp_i\),那么 \(j+1\sim i\) 是同一批完成的。新开这一批会产生启动时间 \(s\),它会使 \(j+1\sim n\) 的所有任务完成时间都增加 \(s\),因此费用提前增加 \(s\times(f_n-f_j)\)。于是有:
这次的时间复杂度是 \(O(n^2)\),可以通过此题。最终 \(dp_n\) 仍为答案。
代码
#include<bits/stdc++.h>
using namespace std;
typedef long long ll;
uint8_t buf[1<<20],*p1,*p2;
#define gc() (p1==p2&&(p2=(p1=buf)+fread(buf,1,1<<20,stdin),p1==p2)?EOF:*p1++)
ll rd(){
ll x=0,sign=1,ch=gc();
while(ch<'0'||ch>'9'){
if(ch=='-'){
sign=-1;
}
ch=gc();
}
while(ch>='0'&&ch<='9'){
x=(x<<1)+(x<<3)+(ch-'0');
ch=gc();
}
return x*sign;
}
void write(ll x){
if(x<0){
x=-x;
putchar('-');
}
if(x>9){
write(x/10);
}
putchar(x%10+'0');
}
ll n,s,t[5005],f[5005],dp[5005];
int main(){
n=rd();
s=rd();
for(ll i=1;i<=n;i++){
t[i]=rd()+t[i-1];
f[i]=rd()+f[i-1];
dp[i]=1LL<<60;
}
dp[0]=0;
for(ll i=1;i<=n;i++){
for(ll j=0;j<i;j++){
dp[i]=min(dp[i],dp[j]+t[i]*(f[i]-f[j])+s*(f[n]-f[j]));
}
}
write(dp[n]);
return 0;
}
二、洛谷 P10979 任务安排 2
题目大意
和上面那题一模一样,只是 \(n\) 的范围从 \(n\leq 5000\) 变成了 \(n\leq 3\times 10^5\)。
思路分析
整理状态转移方程
这里为了和题目变量名相同、方便理解,把上题中的 $F$ 数组换成 $C$ 数组(只是换了个名),相应地,$f$ 数组改名为 $c$ 数组。刚才 \(O(n^2)\) 的代码肯定是过不了,所以考虑优化(下面的过程可以在纸上和我一起一点点推导)。
先把刚才求出来的式子搬过来,省略掉 \(\min\):
相当于我们现在要求上面那个式子的最小值。给它变个形,让与 \(j\) 有关和与 \(j\) 无关的项分开:
决策点
我们称 \(j\) 的每一个取值为一个决策点。假设决策点 \(j1\) 优于决策点 \(j2\),即
化简一下:
移项:
根据上面这个式子,如果我们已知 \(c_{j1}<c_{j2}\),那么 \(\frac{dp_{j2}-dp_{j1}}{c_{j2}-c_{j1}}>t_i+s\)。
反过来,如果已知 \(c_{j1}<c_{j2}\):
如果 \(\frac{dp_{j2}-dp_{j1}}{c_{j2}-c_{j1}}>t_i+s\),那么决策点 \(j1\) 优于决策点 \(j2\);
如果 \(\frac{dp_{j2}-dp_{j1}}{c_{j2}-c_{j1}}<t_i+s\),那么决策点 \(j2\) 优于决策点 \(j1\)。
决策点形象化表示
对于每个决策点 \(j\),都有一个数对 \((c_j,dp_j)\),我们都把它看做是平面直角坐标系上的一个点,叫做第 \(j\) 点,即 \((x_j,y_j)\),此点的横纵坐标分别为 \(x_j=c_j\),\(y_j=dp_j\)。
那么
$$ k_{j1,j2}=\frac{y_{j2}-y_{j1}}{x_{j2}-x_{j1}}=\frac{dp_{j2}-dp_{j1}}{c_{j2}-c_{j1}} $$就是第 \(j1\) 点和第 \(j2\) 点的连线的斜率。

找最优决策点
把平面直角坐标系的 \(x=c_j\)、\(y=dp_j\) 代入状态转移方程:\(dp_i=y-(t_i+s)\cdot x+t_i\cdot c_i+s\cdot c_n\)。
整理一下,得到:
再设 \(K=t_i+s\),\(B=dp_i-c_i\cdot t_i-c_n\cdot s\),那么原式就变成了:
上过初中的就会知道这是一条斜率为 \(t_i+s\),纵轴截距为 \(dp_i-c_i\cdot t_i-c_n\cdot s\) 的直线。
显然对于每个 \(i\),这条直线的斜率是固定的,而纵轴截距中只有 \(dp_i\) 这一项是变化的,所以 \(B\) 越小,\(dp_i\) 越小。
再回到题目,之前的状态转移方程要求的就是“在 \(0\leq j<i\) 范围内选择一个 \(j\),使得转移过来的 \(dp_i\) 的值最小”。
该问题转化到图上就是在 \(0\leq j<i\) 的范围内找到一个决策点 \(j\),使得直线 \(y=Kx+B\) 在经过点 \((x_j,y_j)\) 时,纵轴截距 \(B\) 最小。
那么找到的这个决策点 \(j\),即对应的 \((c_j,dp_j)\),就是最优决策点。
对于已知的两个决策点 \(j1\)、\(j2\),满足 \(x_{j1}<x_{j2}\):
- 如果 \(k_{j1,j2}>K\),也就是两点连线的斜率大于直线 \(y=Kx+B\) 的斜率,那么决策点 \(j1\) 优于决策点 \(j2\)。(忘了怎么求 \(k_{j1,j2}\) 的点击跳转)
- 如果 \(k_{j1,j2}<K\),也就是两点连线的斜率小于直线 \(y=Kx+B\) 的斜率,那么决策点 \(j2\) 优于决策点 \(j1\)。

在上面这张图中,\(k_{j1,j2}<k_{j2,j3}\),我们就称 \(j2\) 为下凸点,\(j2\) 就可能是最优决策点。
相反的,\(k_{j1,j4}>k_{j4,j3}\),我们就称 \(j4\) 为上凸点。上凸点会被删除,不会成为最优决策点。
下凸壳就是下凸点构成的点集。不难得出,在下凸壳中,相邻决策点连线的斜率是单调递增的,即 \(k_{q_l,q_{l+1}}<k_{q_{l+1},q_{l+2}}<\cdots<k_{q_{r-1},q_r}\),所以我们用单调队列维护下凸壳。
这里先提前说明,我们用数组模拟双端队列,\(q_l\) 即为队首元素,\(q_r\) 为队尾元素。
单调队列操作过程
由于 \(c\) 数组是 \(C\) 数组的前缀和数组,而本题中 \(C\) 的每一个元素均大于 \(0\),所以 \(c\) 数组是严格单调递增的。对应到图上,新增的决策点 \(i-1\) 一定在下凸壳最后一个点 \(q_r\) 的右侧(因为图上每个点 \(i\) 的横坐标对应其 \(c_i\) 的值)。
队尾出队
如果新增了点 \(i-1\)(先不入队)导致下凸壳的最后一个点 \(q_r\) 变成了上凸点,就把 \(q_r\) 删掉,直到没有上凸点的产生,这时候再把决策点 \(i-1\) 入队。
具体来说(示意图为例子,可以自己对着看):
不断地比较 \(k_{q_r,i-1}\) 和 \(k_{q_{r-1},q_r}\) 的大小(忘了怎么求 \(k_{q_r,i-1}\) 的点击跳转):
- 如果 \(k_{q_r,i-1}\leq k_{q_{r-1},q_r}\),则将 \(q_r\) 出队;
- 如果队中只有一个元素或 \(k_{q_r,i-1}>k_{q_{r-1},q_r}\),则停止比较,并且将 \(i-1\) 入队。

队首出队
因为直线的斜率 \(K=t_i+s\) 随着 \(i\) 的增大是单调递增的,所以对于 \(k_{q_l,q_{l+1}}\leq K\) 的点,\(q_l\) 绝对不可能成为最优决策点了,就可以直接扔掉。
具体来说(示意图为例子,可以自己对着看):
不断地比较 \(k_{q_l,q_{l+1}}\) 和 \(K\) 的大小(忘了怎么求 \(k_{q_l,q_{l+1}}\) 的点击跳转):
- 如果 \(k_{q_l,q_{l+1}}\leq K\),则将 \(q_l\) 出队;
- 如果队中只有一个元素或 \(k_{q_l,q_{l+1}}>K\),则停止比较,此时 \(q_l\) 就是最优决策点;
- 将新求出的最优决策点 \(q_l\) 代入状态转移方程,得出新的状态 \(dp_i\)。

注意点
取斜率时会用到除法,直接除会导致精度丢失,所以用到交叉相乘:
- 判断 \(k_{q_r,i-1}\leq k_{q_{r-1},q_r}\),即判断 \(\frac{y_{i-1}-y_{q_r}}{x_{i-1}-x_{q_r}}\leq\frac{y_{q_r}-y_{q_{r-1}}}{x_{q_r}-x_{q_{r-1}}}\),我们变为判断 \((y_{i-1}-y_{q_r})\cdot(x_{q_r}-x_{q_{r-1}})\leq (y_{q_r}-y_{q_{r-1}})\cdot(x_{i-1}-x_{q_r})\)。
- 判断 \(k_{q_l,q_{l+1}}\leq K\),即判断 \(\frac{y_{q_{l+1}}-y_{q_l}}{x_{q_{l+1}}-x_{q_l}}\leq K\),我们变为判断 \(y_{q_{l+1}}-y_{q_l}\leq K\cdot(x_{q_{l+1}}-x_{q_l})\)。
记得开 long long!交叉相乘建议使用 __int128,避免溢出。
时间复杂度
因为每个元素只入队一次,也只出队一次,所以时间复杂度为 \(O(n)\)。
代码
#include<bits/stdc++.h>
using namespace std;
typedef long long ll;
uint8_t buf[1<<20],*p1,*p2;
#define gc() (p1==p2&&(p2=(p1=buf)+fread(buf,1,1<<20,stdin),p1==p2)?EOF:*p1++)
ll rd(){
ll x=0,sign=1,ch=gc();
while(ch<'0'||ch>'9'){
if(ch=='-'){
sign=-1;
}
ch=gc();
}
while(ch>='0'&&ch<='9'){
x=(x<<1)+(x<<3)+(ch-'0');
ch=gc();
}
return x*sign;
}
void write(ll x){
if(x<0){
x=-x;
putchar('-');
}
if(x>9){
write(x/10);
}
putchar(x%10+'0');
}
const ll maxn=3e5+5;
ll n,s,t[maxn],c[maxn],q[maxn],dp[maxn],l=1,r;
bool cmp(ll dy1,ll dx1,ll dy2,ll dx2){
return (__int128)dy1*dx2<=(__int128)dy2*dx1;
}
int main(){
n=rd();
s=rd();
for(ll i=1;i<=n;i++){
t[i]=rd()+t[i-1];
c[i]=rd()+c[i-1];
}
for(ll i=1;i<=n;i++){
while(l<r&&cmp(dp[i-1]-dp[q[r]],c[i-1]-c[q[r]],dp[q[r]]-dp[q[r-1]],c[q[r]]-c[q[r-1]])){
r--;
}
q[++r]=i-1;
while(l<r&&cmp(dp[q[l+1]]-dp[q[l]],c[q[l+1]]-c[q[l]],t[i]+s,1)){
l++;
}
dp[i]=dp[q[l]]-(t[i]+s)*c[q[l]]+c[i]*t[i]+s*c[n];
}
write(dp[n]);
return 0;
}
三、洛谷 P5785 [SDOI2012] 任务安排
题目大意
题目模型和上面那题相同,只是 \(T_i\) 的范围从 \(T_i\leq 2^8\) 变成了 \(|T_i|\leq 2^8\),也就是 \(T_i\) 出现了负数。
思路分析
以下所有数组名称及含义均与上题相同,忘了的可点击跳转。
推导
大体思路和 任务安排 2 相同,但 \(T_i\) 可以为负,所以 \(t_i\) 不单调,查询斜率 \(K=t_i+s\) 也不单调。
因此:
- 凸壳必须完整保留,不能像之前那样用
while(l<r&&···){l++;···}永久弹出队首; - 每次在凸壳上二分找最优决策点。
凸壳的维护条件
依旧是:
- 如果 \(k_{q_r,i-1}\leq k_{q_{r-1},q_r}\),则将 \(q_r\) 出队;
- 如果队中只有一个元素或 \(k_{q_r,i-1}>k_{q_{r-1},q_r}\),则停止比较,并且将 \(i-1\) 入队。
二分
在下凸壳上二分,寻找满足 \(k_{q_{mid},q_{mid+1}}\leq t_i+s\) 的最右位置,最后 \(q_{lo}\) 即最优决策点。二分的细节请看代码。
注意点
时间复杂度
加上了二分,所以时间复杂度为 \(O(n\log n)\)。
代码
#include<bits/stdc++.h>
using namespace std;
typedef long long ll;
uint8_t buf[1<<20],*p1,*p2;
#define gc() (p1==p2&&(p2=(p1=buf)+fread(buf,1,1<<20,stdin),p1==p2)?EOF:*p1++)
ll rd(){
ll x=0,sign=1,ch=gc();
while(ch<'0'||ch>'9'){
if(ch=='-'){
sign=-1;
}
ch=gc();
}
while(ch>='0'&&ch<='9'){
x=(x<<1)+(x<<3)+(ch-'0');
ch=gc();
}
return x*sign;
}
void write(ll x){
if(x<0){
x=-x;
putchar('-');
}
if(x>9){
write(x/10);
}
putchar(x%10+'0');
}
const ll maxn=3e5+5;
ll n,s,t[maxn],c[maxn],dp[maxn];
ll q[maxn],l=1,r=0;
ll p,lo,hi,mid,a,b;
bool isadd;
bool mul(ll A,ll B,ll C,ll D){
return (__int128)A*D<=(__int128)C*B;
}
bool check(ll x,ll y,ll z){
return mul((dp[z]-dp[y]),(c[z]-c[y]),(dp[y]-dp[x]),(c[y]-c[x]));
}
int main(){
n=rd();
s=rd();
for(ll i=1;i<=n;i++){
t[i]=rd()+t[i-1];
c[i]=rd()+c[i-1];
}
for(ll i=1;i<=n;i++){
p=i-1;
isadd=true;
while(l<=r&&c[q[r]]==c[p]){
//处理 c 相同的情况:相同横坐标只保留 dp 更小的点
if(dp[q[r]]<=dp[p]){
isadd=false;
break;
}else{
r--;
}
}
if(isadd){//维护下凸壳
while(l<r&&check(q[r-1],q[r],i-1)){
r--;
}
q[++r]=p;
}
lo=l,hi=r;
while(lo<hi){
//在凸壳上二分最优决策点
mid=(lo+hi)>>1;
a=q[mid],b=q[mid+1];
//若 k(a,b)<=t[i]+s,则 b 比 a 更优
if(mul(dp[b]-dp[a],c[b]-c[a],t[i]+s,1)){
lo=mid+1;
}else{
hi=mid;
}
}
dp[i]=dp[q[lo]]-c[q[lo]]*(t[i]+s)+c[i]*t[i]+c[n]*s;
}
write(dp[n]);
return 0;
}
如果您觉得有不正确或不妥的地方,欢迎在评论区指正,感谢!
制作不易,如果觉得对你有帮助,就帮忙点个赞吧!(❁´◡`❁)

浙公网安备 33010602011771号