【刷题日记】P2365 任务安排(斜率优化动态规划)
题目
P2365 任务安排
题目描述
\(n\) 个任务排成一个序列在一台机器上等待完成(顺序不得改变),这 \(n\) 个任务被分成若干批,每批包含相邻的若干任务。
从零时刻开始,这些任务被分批加工,第 \(i\) 个任务单独完成所需的时间为 \(t_i\)。在每批任务开始前,机器需要启动时间 \(s\),而完成这批任务所需的时间是各个任务需要时间的总和(同一批任务将在同一时刻完成)。
每个任务的费用是它的完成时刻乘以一个费用系数 \(f_i\)。请确定一个分组方案,使得总费用最小。
输入格式
第一行一个正整数 \(n\)。
第二行是一个整数 \(s\)。
下面 \(n\) 行每行有一对数,分别为 \(t_i\) 和 \(f_i\),表示第 \(i\) 个任务单独完成所需的时间是 \(t_i\) 及其费用系数 \(f_i\)。
输出格式
一个数,最小的总费用。
输入输出样例 #1
输入 #1
5
1
1 3
3 2
4 3
2 3
1 4
输出 #1
153
说明/提示
【数据范围】
对于 \(100\%\) 的数据,\(1\le n \le 5000\),\(0 \le s \le 50\),\(1\le t_i,f_i \le 100\)。
【样例解释】
如果分组方案是 \(\{1,2\},\{3\},\{4,5\}\),则完成时间分别为 \(\{5,5,10,14,14\}\),费用 \(C=15+10+30+42+56\),总费用就是 \(153\)。
解法一:线性动态规划
设状态 $ dp[i] $ 表示,前 $ i $ 个任务的分配方案已确定时,造成的总代价。
为保证无后效性,要把启动代价和该组代价提前算出来。每次启动时,代价就是后面所有任务的系数( $ f $ )之和乘以 $ s $ 。该组的代价就是该组最后一个任务的结束时间(忽略启动时间)乘以该组所有任务的系数之和。
为了方便描述,假设 $ T $ 表示 $ t $ 的前缀和, $ F $ 表示 $ f $ 的前缀和。枚举 $ j $ 表示上一组的最后一个任务,那么该组任务就是从第 $ j+1 $ 个到第 $ i $ 个任务。
启动损失: $ S \times (F[n] - F[j]) $
本身时间代价损失: $ T[i] \times (F[i] - F[j]) $
状态转移方程为:
代码:
#include <bits/stdc++.h>
using namespace std;
using i64 = long long;
i64 n, s, t[5010], f[5010], T[5010], F[5010], dp[5010];
int main(){
ios::sync_with_stdio(false);
cin.tie(0), cout.tie(0);
cin >> n >> s;
for(i64 i = 1;i<=n;i++)
cin >> t[i] >> f[i], T[i] = T[i-1] + t[i], F[i] = F[i-1] + f[i];
memset(dp, 10, sizeof(dp)), dp[0] = 0;
for(i64 i = 1;i<=n;i++)
for(i64 j = 0;j<i;j++)
dp[i] = min(dp[i], dp[j] + s * (F[n] - F[j]) + T[i] * (F[i] - F[j]));
cout << dp[n];
return 0;
}
这样就可以 $ O(n^2) $ 地完成这道题。但还有更好的方法:
解法二:斜率优化动态规划
摘出刚才的状态转移方程:
变形整理:
得到了一个线性方程记作 $ y = kx + b $,其中 $ dp[i] $ 只与 $ b $ 有关。要使 $ b $ 尽可能小,就要 $ y - kx $ 尽可能小。
注意, $ k $ 只与 $ i $ 有关且随着 $ i $ 的增加而增加, $ y $ 和 $ x $ 都只与 $ j $ 有关且都随着 $ j $ 的增加而增加, $ b $ 只与 $ i $ 有关且与 $ dp[i] $ 呈正相关,这是斜率优化动态规划的前提,这样的话我们就可以最小化 $ b $ 来最小化 $ dp[i] $ 。
想象:现在我们确定了一个 $ i $, 想求最小化 $ dp[i] $ :那么 $ k $ 就确定了(前面说过的, $ k $ 只与 $ i $ 有关)。我们就要找到一个点 $ j (F[j], dp[j]) $ ,经过该点且斜率为 $ k $ 的直线,纵轴截距最小。
假设这里有三个点备选: $ j_1, j_2, j_3 $ ,从左到右排序。假如三个点呈现“上凸包”形也就是连线的斜率先大后小,那么 $ j_2 $ 就永远不会被取到,所以我们要维护一个下凸包,保证斜率是递增的。(这里想不来,可以先往后看,文章最后我会再解答一次!)
假如备选的下凸包已经准备好,接下来所有“过 $ j_x $ 的截距”都指代“过第 $ x $ 个备选点的,斜率为 $ k $ 的直线的纵轴截距”:
-
假如 $ k $ 小于前两个点连线的斜率,过 $ j_1 $ 的截距就比过 $ j_2 $ 的截距小(想象一下,目标直线好像是在向上平移,截距当然变大了)
-
假如 $ k $ 等于前两个点连线的斜率,过 $ j_1 $ 的截距就跟过 $ j_2 $ 的截距一样大(因为就是同一条直线嘛!这条直线就是前两个点的连线)
-
假如 $ k $ 大于前两个点连线的斜率,过 $ j_1 $ 的截距就比过 $ j_2 $ 的截距大(想象一下,目标直线好像是在向右平移,截距当然变小了)
万幸,我们知道, $ k $ 也是随着 $ i $ 增加逐渐递增的!也就是说,上述的三种情况中,如果第三种( $ j_1 $ 不如 $ j_2 $ )发生了,那么 $ j_1 $ 以后永远都不如 $ j_2 $ (因为 $ k $ 只会越来越大),那么我们可以永远将 $ j_1 $ 踢出备选集!
我们当前的需求就很明确了:
-
如果末尾加入点,会使末尾出现“上凸”的形状,那么末尾要踢出一个点;
-
如果 $ k $ 增加之后,前两个点连线的斜率已经比 $ k $ 小了,那么开头要踢出第一个点;
-
第二条需求,踢完点之后,还留在备选集的第一个点就是当前最优点。
这需要一个单调队列来维护。另外,我们还需要访问第二个点和倒数第二个点,STL中的deque不方便操作,一般直接使用静态队列(也就是数组模拟)。时间复杂度为 $ O(n) $ 。
代码:
#include <bits/stdc++.h>
using namespace std;
using i64 = long long;
i64 n, s, t[5010], f[5010], T[5010], F[5010], dp[5010];
i64 dq[5010], head = 0, tail = 1;
double slope(i64 x, i64 y){
return (double)(dp[y] - dp[x]) / (F[y] - F[x]);
}
int main(){
ios::sync_with_stdio(false);
cin.tie(0), cout.tie(0);
cin >> n >> s;
for(i64 i = 1;i<=n;i++)
cin >> t[i] >> f[i], T[i] = T[i-1] + t[i], F[i] = F[i-1] + f[i];
for(i64 i = 1;i<=n;i++){
double k = (double)s + T[i];
while(tail - head >= 2 && slope(dq[head], dq[head+1]) <= k) head++;
i64 j = dq[head];
dp[i] = dp[j] + s * (F[n] - F[j]) + T[i] * (F[i] - F[j]);
while(tail - head >= 2 && slope(dq[tail-2], dq[tail-1]) >= slope(dq[tail-1], i)) tail--;
dq[tail++] = i;
}
cout << dp[n];
return 0;
}
回到前面的问题:为什么备选集必须是“下凸包”?还记得 $ k $ 与前两点连线斜率的三个大小关系嘛?
假设这里有三个点备选: $ j_1, j_2, j_3 $ ,从左到右排序,而且是“上凸”,斜率先大后小。
-
假如 $ k $ 小于前两个点连线的斜率,过 $ j_1 $ 的截距 < 过 $ j_2 $ 的截距, $ j_1 $ 是最优点!(跟之前一样)
-
假如 $ k $ 等于前两个点连线的斜率,那 $ j_1 $ 和 $ j_2 $ 一样好,因为是同一条直线,所以 $ j_1 $ 还是最优点!(跟之前一样)
-
假如 $ k $ 大于 $ j_1 $ 与 $ j_2 $ 连线的斜率,而因为上凸, $ j_2 $ 与 $ j_3 $ 的斜率更小,踢完 $ j_1 $ ,紧接着 $ j_2 $ 就要挨踢, $ j_3 $ 是最优点!
合着 $ k $ 小的时候, $ j_1 $ 好;$ k $ 大的时候, $ j_3 $ 好。 $ j_2 $ 这个“折中”的方案,永远不会被采纳,所以提前丢掉。
这里总结:
将原状态转移方程变形,要求:
-
$ k $ 只与 $ i $ 有关且随着 $ i $ 的增加而增加
-
$ y $ 和 $ x $ 都只与 $ j $ 有关且都随着 $ j $ 的增加而增加
-
$ b $ 只与 $ i $ 有关且与 $ dp[i] $ 呈正相关
维护单调队列,在备选集中,斜率单调递增。
注意易错点:
-
斜率计算要用
double -
初始时
0要进队,要注意0的坐标安排(一般默认 $ (0, 0) $ )。
问答环节:
刚才的 $ k = S + T[i] $ ,$ T $ 是前缀和数组(单调递增),所以 $ k $ 随着 $ i $ 的增加而增加。那如果 $ T $ 不是单调递增的呢?如果 $ T $ 上下浮动,那么应该怎么斜率优化?
点击查看答案
不能再踢出备选集中的点!!每次都要二分找出当前第一个能使前两个点连线的斜率大于等于 $ k $ 的位置。
希望这不仅是一篇某题题解,更能帮助读者理解斜率优化dp,举一反三。

浙公网安备 33010602011771号