斜率优化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\)。

那么状态转移公式就是:

\dp_{i,j}=\\min_{0\\leq k\

这样做的时间复杂度是 \(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)\)。于是有:

\dp_i=\\min_{0\\leq j\

这次的时间复杂度是 \(O(n^2)\),可以通过此题。最终 \(dp_n\) 仍为答案。

代码

cpp 复制代码
#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\):

\dp_i=dp_j+t_i\\times(c_i-c_j)+s\\times(c_n-c_j) \\

相当于我们现在要求上面那个式子的最小值。给它变个形,让与 \(j\) 有关和与 \(j\) 无关的项分开:

\dp_i=dp_j-(t_i+s)\\cdot c_j+t_i\\cdot c_i+s\\cdot c_n,\\qquad 0\\leq j\

决策点

我们称 \(j\) 的每一个取值为一个决策点 。假设决策点 \(j1\) 优于决策点 \(j2\),即

\dp_{j1}-(t_i+s)\\cdot c_{j1}+t_i\\cdot c_i+s\\cdot c_n \< dp_{j2}-(t_i+s)\\cdot c_{j2}+t_i\\cdot c_i+s\\cdot c_n \\

化简一下:

\dp_{j1}-(t_i+s)\\cdot c_{j1}\

移项:

\(t_i+s)\\cdot(c_{j2}-c_{j1})\

根据上面这个式子,如果我们已知 \(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\)。

整理一下,得到:

\y=(t_i+s)\\cdot x+dp_i-c_i\\cdot t_i-c_n\\cdot s \\

再设 \(K=t_i+s\),\(B=dp_i-c_i\cdot t_i-c_n\cdot s\),那么原式就变成了:

\y=Kx+B \\

上过初中的就会知道这是一条斜率为 \(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_{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} 的点击跳转)):

  • 如果 \(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}} 的点击跳转)):

  • 如果 \(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)\)。

代码

cpp 复制代码
#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}\) 即最优决策点。二分的细节请看代码。

注意点

和 任务安排 2 相同,可点击跳转。

时间复杂度

加上了二分,所以时间复杂度为 \(O(n\log n)\)。

代码

cpp 复制代码
#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;
}

如果您觉得有不正确或不妥的地方,欢迎在评论区指正,感谢!
制作不易,如果觉得对你有帮助,就帮忙点个赞吧!(❁´◡`❁)