Re:从零开始的斜率优化 DP 详解

update
点击查看详情
时间 修改
2025.7.26 改正“oi-wiki”拼写错误

斜率优化 DP 概述

斜率优化 DP 作为动态规划 DP 的重要优化手段,其核心思想源于数形结合与凸包理论的早期研究。虽然具体的“斜率优化” 术语在 21 世纪初随着算法竞赛的发展逐渐明确,但相关的优化思想可追溯至更早的数学与计算机科学领域。斜率优化的核心价值在于降低动态规划的时间复杂度。当 DP 转移方程中包含决策变量与当前变量的乘积项时,朴素解法需枚举所有可能决策,而斜率优化可实现优化。

前置知识

直线的方程

先看 百度百科

直线的方程为一次函数,表达式一般为 \(y=kx+b\)。其中 \(k\) 为斜率,\(b\) 为截距。

凸包

先看 百度百科 &oi-wiki

定义一

在平面上能包含所有给定点的最小凸多边形叫做凸包。

如图:

-0ce3391c88cbc75e98f1ac2a76d52dca.md.png

其中蓝色多边形为凸包:-c25ed77f0db69a19cfb890a1edf50633.md.png

实际上可以理解为用一个橡皮筋包含住所有给定点的形态。

定义二

凸包中相邻的边若斜率愈来愈大则称下凸壳,相邻的边若斜率愈来愈小称上凸壳。

正文

本文会通过任务安排组题来学习斜率优化 DP。

任务安排 —— 前缀和优化 + 费用提前计算 DP

题目链接

题面

题目描述

\(n\) 个任务排成一个序列在一台机器上等待完成(顺序不得改变),这 \(n\) 个任务被分成若干批,每批包含相邻的若干任务。

从零时刻开始,这些任务被分批加工,第 \(i\) 个任务单独完成所需的时间为 \(t_i\)。在每批任务开始前,机器需要启动时间 \(s\),而完成这批任务所需的时间是各个任务需要时间的总和(同一批任务将在同一时刻完成)。

每个任务的费用是它的完成时刻乘以一个费用系数 \(p_i\)。请确定一个分组方案,使得总费用最小。

输入格式

第一行一个正整数 \(n\)
第二行是一个整数 \(s\)

下面 \(n\) 行每行有一对数,分别为 \(t_i\)\(p_i\),表示第 \(i\) 个任务单独完成所需的时间是 \(t_i\) 及其费用系数 \(p_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,p_i \le 100\)

【样例解释】
如果分组方案是 \(\{1,2\},\{3\},\{4,5\}\),则完成时间分别为 \(\{5,5,10,14,14\}\),费用 \(C=15+10+30+42+56\),总费用就是 \(153\)


显然这是一道 DP 题,为防止题中 \(f\) 与递推数组名冲突规定其为 \(p\)

解法 1

容易想到一个非常暴力的 DP。

\(f_{m,k}\) 表示前 \(i\) 个任务分成 \(j\) 组完成的最小代价。

初始化为 \(f_{m,k}=\left\{\begin{matrix} +\infty & m \ne 0 \vee k \ne 0\\ 0 & m = k = 0\end{matrix}\right.\)

易得转移方程为 \(f_{i,j} = \min\limits_{1 \leq k \leq i} \left\{ f_{l-1,j-1} + \left(s + \sum_{l=k}^{i} t_l\right) \cdot \sum_{x=l}^{i} p_l\right\}\)

其中几个求和函数可以使用前缀和维护。

\(st_m = \sum_{i=1}^mt_i\)\(sp_m = \sum_{i=1}^mp_i\)

转移方程化为 \(f_{i,j} = \min\limits_{1 \leq k \leq i} \left\{ f_{l-1,j-1} + \left(s + st_k-st_{i-1}\right) \cdot (sp_k-sp_{i-1})\right\}\)

时间复杂度:\(\Theta(n^3)\)

解法 2

解法一已经无法再有明显的优化,考虑修改状态。

状态 \(f_{i,j}\)\(j\) 只和以后的任务产生的代价有关,考虑去除这一维并提前计算。

\(f_i\) 表示处理前 \(i\) 个任务的最小总费用。

易得初始化为 \(f_m=\left\{\begin{matrix} +\infty & m \ne 0\\ 0 & m = 0\end{matrix}\right.\)

考虑当 \(m = i\) 时如何转移。

枚举一个 \(j \le i\) 表示把 \(\left[j + 1, i\right]\) 划分为一批。

\(j\) 个任务完成最小代价为 \(f_j\),这一次分批所花费的代价 \(\sum_{k=1}^it_k\times\sum_{k=j+1}^np_i\),提前计算分批对之后的任务产生机器开机时间影响 \(s\times\sum_{i+1}^np_i\)

由此,DP 转移式为 \(f_i=\min\limits_{0\le j\le i}\left\{f_j+\sum_{k=1}^it_k\times\sum_{k=j+1}^np_i+s\times\sum_{i+1}^np_i\right \}\)

同样使用前缀和优化。

DP 转移式可化为 \(f_i=\min\limits_{0\le j\le i}\left\{f_j+st_i\times(sp_i-sp_j)+s\times(sp_n-sp_j)\right\}\)

时间复杂度:\(\Theta(n^2)\)

code

点击查看代码
#include <bits/stdc++.h>

#define int long long

using namespace std;

const int N = 5e3 + 5;

int t[N], p[N], st[N], sp[N], f[N], n, s;

signed main() {
	cin >> n >> s;
	for (int i = 1; i <= n; i ++ )	cin >> t[i] >> p[i];

	for (int i = 1; i <= n; i ++ )
		st[i] = st[i - 1] + t[i],
		sp[i] = sp[i - 1] + p[i];

	memset(f, 0x3f, sizeof f);	f[0] = 0;

	for (int i = 1; i <= n; i ++ )
		for (int j = 0; j < i; j ++ )
			f[i] = min(f[i], f[j] + s * (sp[n] - sp[j]) + st[i] * (sp[i] - sp[j]));

	cout << f[n] << '\n';
	return 0;
}

任务安排 2—— 斜率优化 DP(单调队列维护)

题目链接

题面

题目背景

本题是 P2365 强化版,是 P5785 弱化版,用于让学生循序渐进地了解斜率优化 DP。

题目描述

机器上有 \(n\) 个需要处理的任务,它们构成了一个序列。这些任务被标号为 \(1\)\(n\),因此序列的排列为 \(1 , 2 , 3 \cdots n\)。这 \(n\) 个任务被分成若干批,每批包含相邻的若干任务。从时刻 \(0\) 开始,这些任务被分批加工,第 \(i\) 个任务单独完成所需的时间是 \(T_i\)。在每批任务开始前,机器需要启动时间 \(s\),而完成这批任务所需的时间是各个任务需要时间的总和。

注意,同一批任务将在同一时刻完成。每个任务的费用是它的完成时刻乘以一个费用系数 \(p_i\)

请确定一个分组方案,使得总费用最小。

输入格式

第一行一个整数 \(n\)。第二行一个整数 \(s\)

接下来 \(n\) 行,每行有一对整数,分别为 \(T_i\)\(p_i\),表示第 \(i\) 个任务单独完成所需的时间是 \(T_i\) 及其费用系数 \(p_i\)

输出格式

一行,一个整数,表示最小的总费用。

输入输出样例 #1

输入 #1

5
1
1 3
3 2
4 3
2 3
1 4

输出 #1

153

说明 / 提示

对于 \(100\%\) 数据,\(1 \le n \le 3 \times 10^5\)\(1 \le s \le 2^8\)\(1\le T_i \le 2^8\)\(0 \le p_i \le 2^8\)


解题思路

题面与上题一模一样,但是 \(n\) 的范围扩大到了 \(3\times 10^5\)

DP 的状态在上题中已经优化到了一维(最优),考虑优化转移。

对于方程 \(f_i=\min\limits_{0\le j\le i}\left\{f_j+st_i\times(sp_i-sp_j)+s\times(sp_n-sp_j)\right\}\),把 \(\min\) 函数扔到火星得:

\[f_i=f_j+st_i\times(sp_i-sp_j)+s\times(sp_n-sp_j) \]

展开得:

\[f_i=f_j+st_i\times sp_i-st_i\times sp_j+s\times sp_n-s\times sp_j \]

\(f_j\) 放到左边,\(f_i\) 放到右边得:

\[f_j=f_i-st_i\times sp_i+st_i\times sp_j-s\times sp_n+s\times sp_j \]

把与 \(i\) 有关或的和与 \(j\) 有关的分开得:

\[f_j=(st_i + s)sp_j+(f_i-st_i\times sp_i-s\times sp_n) \]

我们建立一个以 \(sp_j\) 为横坐标 \(f_j\) 为纵坐标,发现上式可看作一个以 \(st_i + s\) 为斜率,\(f_i-st_i\times sp_i-s\times sp_n\) 为截距的直线,对于每一种选择都对映了一个以 \(sp_j\) 为横坐标,\(f_j\) 为纵坐标的点,如下图所示:

12d912d70cc7bcc449413326ee8ff269.md.png

\(i\) 固定时,直线的斜率也是固定的,每当直线经过一点就可以得到一个截距,易发现截距最小时 \(f_i\) 也取到最小值。这说明直线从下向上运动到第一个点是就得到了最优解,如下:

我们来研究一下何时一个点有可能被取到。

考虑三个点如图形成一个上凸壳:

image-bab4a90d59da6b4552abbb3b4400b124.md.png

可以发现此时中间的点不可能被选到。

反之当三点形成如图一个下凸壳:

50aff800397c59fac17b468ef4ee25ed.md.png

易发现此时中间的点可以取到。

所以我们只需要维护一个下凸壳即可。

设一二两点的连线的斜率为 \(k_1\),二三两点的连线斜率为 \(k_2\),进行决策的直线斜率为 \(K\)

继续观察可以发现,一个点是最优解当且仅当 \(k_1 \le K \le k_2\)。简单来说若把决策的直线和所有凸包上的线段按斜率排序,则最优点就在直线该排的位置上,如图:

5a7778afaff6f8cb165e727321596b2a.md.png

回到转移方程:\(f_i=\min\limits_{0\le j\le i}\left\{f_j+st_i\times(sp_i-sp_j)+s\times(sp_n-sp_j)\right\}\)

和直线的方程:\(f_j=(st_i + s)sp_j+(f_i-st_i\times sp_i-s\times sp_n)\)

这里 \(0\le j\le i\),所以当 \(i\) 增加时会有新的一个点加入。又因 \(sp\) 有单调性,故加入的点会在凸壳的最右端。由于只有在下凸壳中的点才可能最优,并且下凸壳中的线段斜率单调递增,故可以使用单调队列维护。又因为 \(st\) 单调递增(直线的斜率单调递增),凸包最右边的未选上的点不可能再被选上,所以把其弹出队列,队列最右端的点即为答案。

code

点击查看代码
#include <bits/stdc++.h>

#define int long long

using namespace std;

const int N = 3e5 + 5;

int t[N], p[N], st[N], sp[N], f[N], n, s, q[N], l, r;

signed main() {
	cin >> n >> s;
	for (int i = 1; i <= n; i ++ )	cin >> t[i] >> p[i];

	for (int i = 1; i <= n; i ++ )
		st[i] = st[i - 1] + t[i],
		sp[i] = sp[i - 1] + p[i];

	memset(f, 0x3f, sizeof f);	f[0] = 0;	l = r = 1;	q[1] = f[0]; 

	for (int i = 1; i <= n; i ++ ) {
		while (l < r && (f[q[l + 1]] - f[q[l]]) <= (s + st[i]) * (sp[q[l + 1]] - sp[q[l]]))	l ++ ;
		/*
		(f[q[l + 1]] - f[q[l]]) <= (s + st[i]) * (sp[q[l + 1]] - sp[q[l]])
	==> (f[q[l + 1]] - f[q[l]]) / (sp[q[l + 1]] - sp[q[l]]) <= s + st[i]
		即最右侧两点形成线段的斜率小于当前决策斜率,右侧点不可能再被使用,弹出队列。 
		*/
		int j = q[l];
		f[i] = f[j] + s * (sp[n] - sp[j]) + st[i] * (sp[i] - sp[j]);
	  //f_i = f_j + st_i * (sp_i - sp_j) + s * (sp_n - sp_j)
	  	while (l < r && (f[q[r]] - f[q[r - 1]]) * (sp[i] - sp[q[r]]) >= (f[i] - f[q[r]]) * (sp[q[r]] - sp[q[r - 1]]))
	  		r -- ;
	  	/*
	  	(f[q[r]] - f[q[r - 1]]) * (sp[i] - sp[q[r]]) >= (f[i] - f[q[r]]) * (sp[q[r]] - sp[q[r - 1]])
	==> (f[q[r]] - f[q[r - 1]]) / (sp[q[r]] - sp[q[r - 1]]) >= (f[i] - f[q[r]]) / (sp[i] - sp[q[r]])
		维护单调队列中斜率的单调性 
	  	*/
	  	q[++ r] = i;
	}

	cout << f[n] << '\n';
	return 0;
}

[ZJOI2010] 任务安排——斜率优化 DP(单调栈 + 二分维护)

题面

题目描述

机器上有 \(n\) 个需要处理的任务,它们构成了一个序列。这些任务被标号为 \(1\)\(n\),因此序列的排列为 \(1 , 2 , 3 \cdots n\)。这 \(n\) 个任务被分成若干批,每批包含相邻的若干任务。从时刻 \(0\) 开始,这些任务被分批加工,第 \(i\) 个任务单独完成所需的时间是 \(T_i\)。在每批任务开始前,机器需要启动时间 \(s\),而完成这批任务所需的时间是各个任务需要时间的总和。

注意,同一批任务将在同一时刻完成。每个任务的费用是它的完成时刻乘以一个费用系数 \(C_i\)

请确定一个分组方案,使得总费用最小。

输入格式

第一行一个整数 \(n\)
第二行一个整数 \(s\)

接下来 \(n\) 行,每行有一对整数,分别为 \(T_i\)\(C_i\),表示第 \(i\) 个任务单独完成所需的时间是 \(T_i\) 及其费用系数 \(C_i\)

输出格式

一行,一个整数,表示最小的总费用。

输入输出样例 #1

输入 #1

5
1
1 3
3 2
4 3
2 3
1 4

输出 #1

153

说明/提示

对于 \(100\%\) 数据,\(1 \le n \le 3 \times 10^5\)\(1 \le s \le 2^8\),$ \left| T_i \right| \le 2^8$,\(0 \le C_i \le 2^8\)

题目思路

这道题的题面还与上道题相同,但是 \(\left| T_i \right| \le 2^8\),所以 \(st\) 不具有单调性。

\(st\) 无单调性会产生什么影响呢?

看直线方程:\(f_j=(st_i + s)sp_j+(f_i-st_i\times sp_i-s\times sp_n)\)

这说明直线的斜率不具有单调性。

怎么办呢?我们用一个单调栈维护整个下凸壳,可以发现单调栈内的斜率是递增的。我们每次二分出最后个比决策直线斜率小的线段,其右端点即为答案。

证明参考:一个点是最优解当且仅当 \(k_1 \le K \le k_2\)

点击查看代码
#include <bits/stdc++.h>

#define int long long

using namespace std;

const int N = 3e5 + 5;

int t[N], p[N], st[N], sp[N], f[N], n, s, top, sta[N];

int search(int i) {
	int l = 1, r = top, ans = 0;
	while (l <= r) {
		int mid = (l + r) >> 1, a = sta[mid - 1], b = sta[mid];
		if (f[b] - f[a] <= (st[i] + s) * (sp[b] - sp[a]))
			l = mid + 1, ans = mid;
		else	r = mid - 1;
	}
	return sta[ans];
}

signed main() {
	cin >> n >> s;
	for (int i = 1; i <= n; i ++ )	cin >> t[i] >> p[i];

	for (int i = 1; i <= n; i ++ )
		st[i] = st[i - 1] + t[i],
		sp[i] = sp[i - 1] + p[i];

	memset(f, 0x3f, sizeof f);	sta[top = 1] = f[0] = 0; 

	for (int i = 1; i <= n; i ++ ) {
		int j = search(i);
		f[i] = f[j] + s * (sp[n] - sp[j]) + st[i] * (sp[i] - sp[j]);
	  	while (top > 1 && (f[sta[top]] - f[sta[top - 1]]) * (sp[i] - sp[sta[top]]) >= (f[i] - f[sta[top]]) * (sp[sta[top]] - sp[sta[top - 1]]))
			top -- ;
	  	sta[++ top] = i;
	}

	cout << f[n] << '\n';
	return 0;
}

此章仅讨论了 DP 方程中判断函数为 \(\min\) 的情况,当函数为 \(\max\) 时只需决策直线从向上靠近决策点变成向下靠近,所以维护上凸壳即可。

习题

两项单调

P2120 [ZJOI2007] 仓库建设 - 洛谷

P3195 [HNOI2008] 玩具装箱 - 洛谷

都不单调

P4655 [CEOI 2017] Building Bridges - 洛谷

题单

斜率优化DP - 洛谷

posted @ 2025-07-22 17:23  _Charllote  阅读(161)  评论(2)    收藏  举报