洛谷P2365/5785 任务安排 题解 斜率优化DP
任务安排1(小数据):https://www.luogu.com.cn/problem/P2365
任务安排2(大数据):https://www.luogu.com.cn/problem/P5785
题目描述
有 \(N\) 个任务排成一个序列在一台机器上等待执行,它们的顺序不得改变。机器会把这 \(N\) 个任务分成若干批,每一批包含连续的若干个任务。从时刻 \(0\) 开始,任务被分批加工,执行第 \(i\) 个任务所需的时间是 \(T_i\)。另外,在每批任务开始前,机器需要 \(S\) 的启动时间,故执行一批任务所需的时间是启动时间 \(S\) 加上每个任务所需时间之和。
一个任务执行后,将在机器中稍作等待,直至该批任务全部执行完毕。也就是说,同一批任务将在同一时刻完成。每个任务的费用是它的完成时刻乘以一个费用系数 \(C_i\)。
请为机器规划一个分组方案,使得费用最小。
输入格式
第一行是 \(N\) ,第二行是 \(S\)。
下面 \(N\) 行每行有一对数,分别为 \(T_i\) 和 \(C_i\),均为不大于 \(100\) 的正整数,表示第 \(i\) 个任务单独完成所需的时间是 \(T_i\) 机器费用系数 \(C_i\)。
输出格式
输出一个整数,表示最小的总费用。
样例输入
5
1
1 3
3 2
4 3
2 3
1 4
样例输出
153
数据规模
50%的数据保证 \(1 \lt N \le 5000, 1 \le S \le 50, 1 \le T_i, C_i \le 100\) ;
100%的数据保证 \(1 \le N \le 3 \times 10^5, 1 \le S,T_i,C_i \le 512\)。
问题分析
解法一:
求出 \(T,C\) 的前缀和 \(sumT,sumC\),即
设 \(F[i][j]\) 表示把前 \(i\) 个任务分成 \(j\) 批执行的最小费用,则第 \(j\) 批任务的完成时间就是 \(j \times S + sumT[i]\)。
以第 \(j-1\) 批和第 \(j\) 批任务的分界点为DP的“决策”,(设第 \(j-1\) 批的最后一个任务是 \(k\),第 \(j\) 批的最后一个任务是 \(i\))状态转移方程为:
实现代码如下:
#include <bits/stdc++.h>
using namespace std;
const int maxn = 5000;
int n, S, T, C, sumT[maxn], sumC[maxn], f[maxn][maxn], ans = -1;
int main() {
cin >> n >> S;
for (int i = 1; i <= n; i ++) {
cin >> T >> C;
sumT[i] = sumT[i-1] + T;
sumC[i] = sumC[i-1] + C;
}
memset(f, -1, sizeof(f));
for (int j = 1; j <= n; j ++) {
for (int i = j; i <= n; i ++) {
if (j == 1) {
f[i][j] = (S + sumT[i]) * sumC[i];
continue;
}
for (int k = j-1; k < i; k ++) {
assert(f[k][j-1] != -1);
int tmp = f[k][j-1] + (S * j + sumT[i]) * (sumC[i] - sumC[k]);
if (f[i][j] == -1 || f[i][j] > tmp) f[i][j] = tmp;
}
}
}
for (int i = 1; i <= n; i ++) {
if (ans == -1 || ans > f[n][i]) ans = f[n][i];
}
cout << ans << endl;
return 0;
}
该解法的时间复杂度是 \(O(n^3)\)。
解法二:
本题并没有规定需要把任务分成多少批,在上一个解法中之所以需要批数 \(j\),是因为我们需要知道机器启动了多少次(每次启动都要 \(S\) 单位时间),从而计算出 \(i\) 所在的一批任务的完成时刻。
事实上,在执行一批任务时,我们不容易直接得知在此之前机器启动过几次。但我们知道,机器因执行这批任务而花费的启动时间 \(S\),会累加到在此之后所有任务的完成时刻上。
设 \(F[i]\) 表示把前 \(i\) 个任务分成若干批执行的最小费用,状态转移方程为:
在上式中,第 \(j+1 \sim i\) 个任务在同一批内完成,\(sumT[i]\) 是忽略机器的启动时间,这批任务的完成时刻。因为这批任务的执行,机器的启动时间 \(S\) 会对第 \(j+1\) 个之后的所有任务产生影响,故我们把这部分补充到费用中。
也就是说,我们没有直接求出每批任务的完成时间,而是在一批任务“开始”对后续任务产生影响时,就先把费用累加到结果中。这是一种名为 “费用提前计算” 的经典思想。
实现代码如下:
#include <bits/stdc++.h>
using namespace std;
const int maxn = 5000;
int n, S, T, C, sumT[maxn], sumC[maxn], f[maxn];
int main() {
cin >> n >> S;
for (int i = 1; i <= n; i ++) {
cin >> T >> C;
sumT[i] = sumT[i-1] + T;
sumC[i] = sumC[i-1] + C;
}
memset(f, -1, sizeof(f));
f[0] = 0;
for (int i = 1; i <= n; i ++) {
for (int j = 0; j < i; j ++) {
int tmp = f[j] + sumT[i] * (sumC[i] - sumC[j]) + S * (sumC[n] - sumC[j]);
if (f[i] == -1 || f[i] > tmp) f[i] = tmp;
}
}
cout << f[n] << endl;
return 0;
}
该解法的时间复杂度为 \(O(N^2)\)。
解法三:
对上一题的算法二进行优化,先对状态转移方程稍作变形,把常数、仅与 \(i\) 有关的项、仅与 \(j\) 有关的项 以及 \(i,j\) 的乘积项分开。
把 \(\min\) 函数去掉,把关于 \(j\) 的值 \(F[j]\) 和 \(sumC[j]\) 看做变量,其余部分看做常数,得到:
在 \(sumC[j]\) 为横坐标, \(F[j]\) 为纵坐标的平面直角坐标系中,这是一条以 \(S + sumT[i]\) 为斜率,\(F[i] - sumT[i] \times sumC[i] - S \times sumC[N]\) 为截距的直线。也就是说,决策候选集合是坐标系中的一个点集,每个决策 \(j\) 都对应着坐标系中的一个点 \((sumC[j], F[j])\)。每个待求解的状态 \(F[i]\) 都对应着一条直线的截距,直线的斜率是一个固定的值 \(S + sumT[i]\),截距未知。当截距最小化时,\(F[i]\) 也取到最小值。
该问题实际上是一个线性规划问题,高中数学有所涉及。令直线过每个决策点 \((sumC[j], F[j])\),都可以求得一个截距,其中使截距最小的一个就是最优决策。体现在坐标系中,就是用一条斜率为固定正整数的直线自下而上平移,第一次接触到某个决策点时,就得到了最小截距。如图所示:
对于任意三个决策点 \((sumC[j_1], F[j_1])\),\((sumC[j_2], F[j_2])\) 和 \((sumC[j_3], F[j_3])\),不妨设 \(j_1 \lt j_2 \lt j_3\),因为 \(T,C\) 均为正整数,亦有 \(sumC[j_1] \lt sumC[j_2] \lt sumC[j_3]\)。根据及时排除无用决策的思想,我们考虑 \(j_2\) 可能成为最优决策的条件。
从上图中我们发现,\(j_2\) 有可能成为最优决策,当且仅当 \(j_1\) 到 \(j_2\) 的斜率小于 \(j_2\) 到 \(j_3\) 的斜率,即:
小于号两侧实际上都是连接两个决策点的线段的斜率。通俗地讲,我们应该维护“连接相邻两点的线段斜率”单调递增的一个“下凸壳”,只有这个“下凸壳”的顶点才有可能成为最优决策。实际上,对于一条斜率为 \(k\) 的直线,若某个顶点左侧线段线段的斜率比 \(k\) 小,右侧线段的斜率比 \(k\) 大,则该顶点就是最优决策。换言之,如果把这条直线和所有线段组成一个序列,那么令直线截距最小化的顶点就出现在按照斜率大小排序时,直线应该排在的位置上。如图所示:
在本题中,\(j\) 的取值范围是 \(0 \le j \lt i\),随着 \(i\) 的增大,每次会有一个新的决策进入候选集合。因为 \(sumC\) 的单调性,新决策在坐标系中的横坐标一定大于之前的所有决策,出现在凸壳的最右端。另外,因为 \(sumT\) 的单调性,每次求解“最小截距”的直线斜率 \(S+sumT[i]\) 也单调递增,如果我们只保留凸壳上“连接相邻两点的线段斜率”大于 \(S+sumT[i]\) 的部分,那么凸壳的最左端点就一定是最优决策。
综上所述,我们可以建立单调队列 \(q\),维护这个下凸壳。队列中保存若干个决策变量,它们对应凸壳上的顶点,且满足横坐标 \(sumC\) 递增、连接相邻两点的线段斜率也递增。需要支持的操作与一般的单调队列题目类似,对于每个状态变量 \(i\):
- 检查队首的两个决策变量 \(Q_l\) 和 \(Q_{l+1}\),若斜率 \(\frac{F[Q_{l+1}] - F[Q_l]}{sumC[Q_{l+1}] - sumC[Q_l]} \le S + sumT[i]\),则让 \(Q_l\) 出队,继续检查新的队首。
- 直接取队首 \(j = Q_l\) 为最优决策,执行状态转移,计算出 \(F[i]\)。
- 把新决策 \(i\) 从队尾插入,在插入之前,若三个决策点 \(j_1 = Q_{r-1}, j_2 = Q_r, j_3 = i\) 不满足斜率单调递增(不满足下凸性,即 \(j_2\) 是无用决策),则直接从队尾让 \(Q_r\) 出队,继续检查新的队尾。
实现代码如下:
#include <bits/stdc++.h>
using namespace std;
const int maxn = 300030;
int n, q[maxn], l, r;
long long S, T, C, sumT[maxn], sumC[maxn], f[maxn];
int main() {
cin >> n >> S;
for (int i = 1; i <= n; i ++) {
cin >> T >> C;
sumT[i] = sumT[i-1] + T;
sumC[i] = sumC[i-1] + C;
}
memset(f, -1, sizeof(f));
f[0] = 0;
q[l = r = 1] = 0;
for (int i = 1; i <= n; i ++) {
while (l < r && f[q[l+1]] - f[q[l]] <= (S + sumT[i]) * (sumC[q[l+1]] - sumC[q[l]])) l ++;
f[i] = f[q[l]] - (S + sumT[i]) * sumC[q[l]] + sumT[i] * sumC[i] + S * sumC[n];
while (l < r && (f[q[r]]-f[q[r-1]]) * (sumC[i]-sumC[q[r]]) >= (f[i]-f[q[r]]) * (sumC[q[r]]-sumC[q[r-1]])) r --;
q[++r] = i;
}
cout << f[n] << endl;
return 0;
}
整个算法的时间复杂度为 \(O(N)\)。
与一般的单调队列优化DP的模型相比,本题维护的“单调性”依赖于队列中相邻两个元素之间的某种“比值”。因为这个值对应线性规划的坐标系中的斜率,所以我们在本题中使用的优化方法称为“斜率优化”。
以上分析针对 \(T_i\) 为正数的情况,接下来我们来考虑 \(T_i\) 为负数的情况。
与任务安排1不同的是,任务安排2中任务的执行时间 \(T\) 可能是负数。这意味着 \(sumT\) 不具有单调性,从而需要最小化截距的直线的斜率 \(S + sumT[i]\) 不具有单调性。所以,我们不能在单调队列中只保留凸壳上“连接相邻两点的线段斜率”大于 \(S + sumT[i]\) 的部分,而是必须维护整个凸壳。这样一来,我们就不需要在队首把斜率与 \(S + sumT[i]\) 比较。
队首也不一定是最优决策,我们可以在单调队列中二分查找,求出一个位置 \(p\),\(p\) 左侧线段的斜率比 \(S + sumT[i]\) 小,右侧线段的斜率比 \(S+sumT[i]\) 大,\(p\) 就是最优决策。
实现代码如下:
#include <bits/stdc++.h>
using namespace std;
const int maxn = 300030;
int n, q[maxn], l, r;
long long S, T, C, sumT[maxn], sumC[maxn], f[maxn];
int my_binary_search(int k) {
if (l == r) return q[l];
int L = l, R = r;
while (L < R) {
int mid = (L + R) / 2;
if (f[q[mid+1]] - f[q[mid]] <= k * (sumC[q[mid+1]] - sumC[q[mid]])) L = mid + 1;
else R = mid;
}
return q[L];
}
int main() {
cin >> n >> S;
for (int i = 1; i <= n; i ++) {
cin >> T >> C;
sumT[i] = sumT[i-1] + T;
sumC[i] = sumC[i-1] + C;
}
memset(f, -1, sizeof(f));
f[0] = 0;
q[l = r = 1] = 0;
for (int i = 1; i <= n; i ++) {
int p = my_binary_search(S + sumT[i]);
f[i] = f[p] - (S + sumT[i]) * sumC[p] + sumT[i] * sumC[i] + S * sumC[n];
while (l < r && (f[q[r]]-f[q[r-1]]) * (sumC[i]-sumC[q[r]]) >= (f[i]-f[q[r]]) * (sumC[q[r]]-sumC[q[r-1]])) r --;
q[++r] = i;
}
cout << f[n] << endl;
return 0;
}