【刷题日记】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]) $

状态转移方程为:

\[dp[i] = \min_{0 \le j < i} \{ dp[j] + 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) $ 地完成这道题。但还有更好的方法:

解法二:斜率优化动态规划

摘出刚才的状态转移方程:

\[dp[i] = dp[j] + S \times (F[n] - F[j]) + T[i] \times (F[i] - F[j]) \]

变形整理:

\[\underbrace{dp[j]}_{y} = \underbrace{(S + T[i])}_{k} \times \underbrace{F[j]}_{x} + \underbrace{(dp[i] - S \cdot F[n] - T[i] \cdot F[i])}_{b} \]

得到了一个线性方程记作 $ 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,举一反三。

posted @ 2026-02-04 15:41  Alkaid16  阅读(0)  评论(0)    收藏  举报