DP 学习笔记(一):DP 基础知识,基础 DP 类型

基本概念

动态规划是一种非常常见的算法,它将大问题划分为与它一样但数据规模更小的小问题,而大问题的的最优解决方案又来自于小问题的最优解决方案。简称为 DP(Dynamic Programming)。

动态规划优于暴力枚举的原因是它对于每一个问题,不是从头开始解决,而是基于之前解决的规模更小问题计算得来,这可以大大降低时间复杂度,而思维难度则上升了不止一个难度,而且它可以与数学(概率 DP)、字符串(自动机 DP)、图论(最短路算法)、数据结构(数据结构优化 DP)等多种信息竞赛中的重要版块进行深度融合,因此需要我们认真学习。

一些定义

状态:当前所求问题的信息;

函数:当前所求问题的答案,一般叫做 DP 值;

状态转移方程:如何通过当前所求问题的状态,找到它可以由哪几个小问题推出,并通过那几个小问题的函数推出当前问题的函数。一般用一个递推式子表示。

时间复杂度:\(=\) 状态个数 \(\times\) 转移时间复杂度。


DP 能解决的问题一般具有以下 \(3\) 点性质,下面通过斐波那契数列的求解过程来说明。

斐波那契数列是一种具有递推关系的数列,它的每一个数字都是前两个数字的和:\(1, 1, 2, 3, 5, 8, \dots\)。用函数表示出来就是 \(f_i = f_{i - 1} + f_{i - 2}\),那么它具有以下性质:

重叠子问题

简单来说,就是求解大问题的最优解决方案时,需要将大问题拆分成若干个小问题,小问题会被拆分成更小的问题,这些拆分出的小问题可能会有重复。比如求解斐波那契数列的第五项 \(f_5\)

可以发现,\(f_3\) 被计算了两遍,其实只用计算一遍就可以了,这就是 DP 可以实现较优复杂度的原因,这在小数据规模时还不明显,在数据规模大时就可以极大优化代码。

最优子结构

首先,大问题的最优解包含小问题的最优解,也就是当大问题取得最优解时,小问题也取得最优解。其次,小问题的最优解可以推出大问题的最优解,这就是最优子结构。

在斐波那契数列的求解过程中,求 \(f_i\) 可以拆成求规模更小的 \(f_{i - 1}\)\(f_{i - 2}\),而 \(f_{i - 1}\)\(f_{i - 2}\) 又可以加起来等于 \(f_i\),这样就符合最优子结构。

无后效性

简单来说,就是当我们求出某个问题的最优解时,我们就不再关心这个最优解是如何得到的,也就不再改变这个值了,而是将这个解作为已知继续推出其它问题的最优解。

求解斐波那契数列的过程中,当我们求出 \(f_i\) 的值以后,这个值我们就不再改了。当我们要求 \(f_{i + 1}\)\(f_{i + 2}\),直接把 \(f_i\) 的值拿来用就行了,这就符合最优子结构。

无后效性是可以使用 DP 的前提条件,当后续的操作会影响到之前操作的值时,就无法通过重叠子问题来优化枚举的复杂度,也就无法使用 DP。一般求解 DP 问题都需要考虑 DP 的顺序,让问题没有后效性。

DP 一般有以下 \(3\) 种写法:

记忆化搜索

在搜索时,如果遇到之前求解过的状态,就直接将它的 DP 值拿来用,而不用继续往下递归。

求解斐波那契数列的记忆化搜索代码:

int f[N];
int fib(int x){
	if(x == 1 || x == 2)
		return 1;
	if(f[x])
		return x;
	else
		return fib(x - 1) + fib(x - 2);
} 

填表法

考虑当前状态是由哪几个状态转移而来。

求解斐波那契数列的填表法代码:

int f[N];
f[1] = f[2] = 1;
for(int i = 3; i <= n; i++)
	f[i] = f[i - 1] + f[i - 2];

填表法也是最常见的 DP 写法。

刷表法

考虑当前状态会影响到后续哪几个状态的求解。

求解斐波那契数列的刷表法代码:

int f[N];
f[1] = 1;
for(int i = 1; i <= n; i++){
	f[i + 1] += f[i];
	f[i + 2] += f[i];
}

背包 DP

一类非常经典的线性 DP 题,因此专门提出来讲。

一些定义:\(n\) 表示物品个数,\(m\) 表示背包总容量,\(w_i\) 表示物品重量,\(v_i\) 表示物品价值,\(c_i\) 表示物品个数,\(f\) 表示 DP 函数(代码中为 dp)。

01背包

P1048 [NOIP2005 普及组] 采药

\(f_{i, j}\) 表示考虑前 \(i\) 个物品,总重量为 \(j\) 的最大价值,考虑当前物品选还是不选,那么可以很轻松写出状态转移方程:\(f_{i, j} = \max(f_{i - 1, j}, f_{i - 1, j - w_i} + v_i)\)

完整代码:

#include <bits/stdc++.h>
using namespace std;
const int M = 109, T = 1009;
int w[M], v[M], dp[M][T], n, m;
int main(){
	scanf("%d%d", &m, &n);
	for(int i = 1; i <= n; i++)
		scanf("%d%d", &w[i], &v[i]);
	for(int i = 1; i <= n; i++)
		for(int j = 0; j <= m; j++){
			if(w[i] > j)
				dp[i][j] = dp[i - 1][j];
			else
				dp[i][j] = max(dp[i - 1][j - w[i]] + v[i], dp[i - 1][j]);
		}
	printf("%d", dp[n][t]);
	return 0;
}

滚动数组优化01背包

滚动数组可以优化背包的空间复杂度。

可以发现,\(f_{i, j}\) 的值只与 \(f_{i - 1}\) 这一行有关系,与 \(i - 2, i - 3, \dots, 1\) 这些行都没有关系,那么我们可以只存当前枚举的行上一行的信息,就可以实现空间优化。滚动数组一般有以下两种写法:

交替滚动

开两行数组,一行存计算过的旧的一行,一行存当前计算的一行。

完整代码:

int dp[2][N];
int now = 0, old = 1;
for(int i = 1; i <= n; i++){
	swap(old, now);
	for(int j = 0; j <= m; j++){
		if(w[i] > j)
			dp[now][j] = dp[old][j];
		else
			dp[now][j] = max(dp[old][j], dp[old][j - w[i]] + v[i]);
	}
}

自我滚动

只开一行,一边计算,一边更新。这时候内层循环要倒着来枚举,下面来说明。

考虑当前状态由那些状态更新来:

发现 \(f_{i, j}\) 可能会从 \(f_{i - 1, j - k}\) 转移过来,而从前往后枚举会更新掉 \(f_{i - 1, j - k}\),导致转移错误,因此只能从后往前枚举

完整代码:

int dp[N];
for(int i = 1; i <= n; i++)
	for(int j = m; j >= w[i]; j--)
		dp[j] = max(dp[j], dp[j - w[i]] + v[i]);

bitset优化01背包

当我们在求解某类 \(01\) 背包时,只需要判断某种状态是否可以达到(比如[ABC221G] Jumping sequence),这时就可以用 \(bitset\) 将时间复杂度优化成 \(O(\frac{n^2}{\omega})\),其中 \(\omega = 32\)

首先,这种可行性的背包的转移方程就是 \(f_j = \max(f_j, f_{j - w_i} + v_i)\),因为只用判断当前状态可否到达,因此 \(f_j\) 的值只有 \(0\)\(1\) 两种,那么转移方程可以简化成 \(f_j = f_{j - w_i} \mid f_j\)

\(bitset\) 顾名思义,就是存储 \(0\)\(1\) 的集合,其内部是一个值只有 \(0\)\(1\) 的数组,可以用 bitset <size> name 来定义,可以发现,\(f_j \mid f_{j - w_i}\) 相当于是将当前 DP 数组左移 \(w_i\) 位,再或起来,而 \(bitset\) 也支持将整个数组左右移动,复杂度为 \(O(\frac{len}{\omega})\),那么,我们只需要将循环的第二层改为将当前 \(bitset\) 异或上 \(bitset\) 左移 \(w_j\) 位,就可以将复杂度优化了。

完整代码:

bitset <50000> b;
b.set(0, 1);
for(int i = 1; i <= n; i++)
	b |= (b << w[i]);

完全背包

P1616 疯狂的采药

考虑现在不止一个物品,而是有无穷个物品,但背包有个总容量,因此每个物品最多放 \(\frac{m}{w_i}\) 个,那么考虑在 \(01\) 背包的基础上再枚举一遍物品个数。

还是记 \(f_{i, j}\) 表示考虑前 \(i\) 个物品,总重量为 \(j\) 的最大价值,考虑当前物品选几个,那么可以写出状态转移方程 \(f_{i, j} = f_{i - 1, j - k \times w_i} + k \times v_i(0 \leq k \leq \frac{j}{w_i})\),不过复杂度是 \(O(n^3)\),需要优化。

其实,考虑 \(f_{i, j}\) 的转移方程 \(f_{i, j} = f_{i - 1, j - k \times w_i} + k \times v_i(0 \leq k \leq \frac{j}{w_i})\) 可以写作 \(f_{i, j} = \max(f_{i - 1, j}, v_i + f_{i - 1, j - w_i - k' \times w_i} + k' \times v_i)(0 \leq k' \leq \frac{j - w_i}{w_i})\),将 \(\max\) 中后半部分与 \(f_{i, j - w_i}\) 的转移方程 \(f_{i, j - w_i} = f_{i - 1, j - w_i - k \times w_i} + k \times v_i)(0 \leq k \leq \frac{j - w_i}{w_i})\) 相比较,可以发现原先的表达式被简化成了 \(f_{i, j} = \max(f_{i - 1, j}, f_{i, j - w_i} + v_i)\),现在就可以 \(O(n^2)\) 通过本题了。

由于考虑到 \(f_{i, j}\) 可能会从同一排靠前的位置转移而来,因此用滚动数组优化时,\(j\) 这一维需要正序枚举。

当然,可行性的完全背包也可以用 \(bitset\) 优化,这里不再赘述。

完整代码:

#include <bits/stdc++.h>
using namespace std;
const int N = 1e7 + 5;
long long v[N], w[N], dp[N], n, m;
int main() {
    scanf("%d%d", &n, &m);
    memset(dp, 0, sizeof(dp));
    for(int i = 1; i <= n; i++)
        cin >> w[i] >> v[i];
    for(int i = 1; i <= n; i++)
        for(int j = 0; j <= m; j++)
            if(j >= w[i])
                dp[j] = max(dp[j], dp[j - w[i]] + v[i]);
    printf("%d", dp[m]);
    return 0;
}

由于完全背包中每个物品也不是能选任意多个,因此也可以套用接下来多重背包的优化方式。

多重背包

P1776 宝物筛选

可以发现这和完全背包很像,但有可能物品取不到 \(\frac{j}{w_i}\) 这么多个,那么此处就无法通过完全背包的方式优化时间复杂度,因为你无法判断 \(f_{i, j - w_i}\) 转移时是否已经选满了 \(c_i\) 个。因此只能枚举当前物品选几个,转移方程 \(f_{i, j} = f_{i - 1, j - k \times w_i} + k \times v_i(0 \leq k \leq \min(c_i, \frac{j}{w_i}))\)

注意此时的状态是由上一排转移过来,和 \(01\) 背包类似,因此滚动数组优化时需要倒序枚举。

完整代码(会 TLE):

#include <bits/stdc++.h>
using namespace std;
const int N = 1e5 + 9;
int w[N], v[N], c[N], dp[N], n, m;
int main(){
	scanf("%d%d", &n, &m);
	for(int i = 1; i <= n; i++)
		scanf("%d%d%d", &v[i], &w[i], &c[i]);
	for(int i = 1; i <= n; i++)
		for(int j = m; j >= w[i]; j--)
			for(int k = 1; k <= c[i] && k * w[i] <= j; k++)
				dp[j] = max(dp[j], dp[j - k * w[i]] + k * v[i]);
	printf("%d", dp[m]);
	return 0;
}

二进制拆分优化多重背包

考虑一个物品价值为 \(v\),重量为 \(w\),数量为 \(c\),那么可以发现:

  • 当取 \(1\) 个该物品时,重量为 \(w\),价值为 \(v\)

  • 当取两个该物品时,重量为 \(2 \times w\),价值为 \(2 \times v\)

  • 当取 \(3\) 个该物品时,重量为 \(3 \times w = 2 \times w + w\),价值为 \(3 \times v = 2 \times v + v\)

  • 当取 \(4\) 个该物品时,重量为 \(4 \times w\),价值为 \(4 \times v\)

  • 当取 \(5\) 个该物品时,重量为 \(5 \times w = 4 \times w + w\),价值为 \(5 \times v = 4 \times v + v\)

  • \(\dots\)

可以发现,对于同一种物品,可以把它二进制拆分成 \(\log_2 c_i\) 个物品,这些物品的重量为 \(2^k \times w\),价值为 \(2^k \times v\),而且它们组合在一起就变成任意个该物品,此时可以通过 \(01\) 背包来做了,复杂度降低为 \(O(m \displaystyle\sum_{i = 1}^n \log_2 c_i)\)

完整代码:

#include <bits/stdc++.h>
using namespace std;
const int N = 100010;
int n, m, dp[N];
int v[N], w[N], c[N];
int new_n;
int new_v[N], new_w[N], new_c[N];
int main(){
	scanf("%d%d", &n, &m);
	for(int i = 1; i <= n; i++)
		scanf("%d%d%d", &v[i], &w[i], &c[i]);
	int new_n = 0;
	for(int i = 1; i <= n; i++){
		for(int j = 1; j <= c[i]; j <<= 1){
			c[i] -= j;
			new_w[++new_n] = j * w[i];
			new_v[new_n] = j * v[i];
		}
		if(c[i]){
			new_w[++new_n] = c[i] * w[i];
			new_v[new_n] = c[i] * v[i];
		}
	}
	for(int i = 1; i <= new_n; i++)
		for(int j = m; j >= new_w[i]; j--)
			dp[j] = max(dp[j], dp[j - new_w[i]] + new_v[i]);
	printf("%d", dp[m]);
	return 0;
}

单调队列优化多重背包

DP 学习笔记(三):与斜率有关的 DP 优化

混合背包

P1833 樱花

\(01\) 背包部分看成每个物品最多只能选 \(1\) 个,完全背包部分看成每个物品最多只能选 \(\frac{m}{w_i}\) 个,多重背包部分不变,那么可以直接转化为多重背包。

完整代码(使用了单调队列优化):

#include <bits/stdc++.h>
using namespace std;
const int N = 1e5 + 9;
int n, m, h1, h2, m1, m2;
int dp[N], q[N], num[N];
int w, v, c;
int main(){
	scanf("%d:%d %d:%d %d", &h1, &m1, &h2, &m2, &n);
	m = 60 * (h2 - h1) + m2 - m1;
	for(int i = 1; i <= n; i++){
		scanf("%d%d%d", &w, &v, &c);
		if(c == 0)
			c = 100000000;
		if(c > m / w)
			c = m / w;
		for(int b = 0; b < w; b++){
			int head = 1, tail = 1;
			for(int y = 0; y <= (m - b) / w; y++){
				int tmp = dp[b + y * w] - y * v;
				while(head < tail && q[tail - 1] <= tmp)
					tail--;
				q[tail] = tmp;
				num[tail++] = y;
				while(head < tail && y - num[head] > c)
					head++;
				dp[b + y * w] = max(dp[b + y * w], q[head] + y * v);
			}
		}
	}
	printf("%d", dp[m]);
	return 0;
}

二维费用背包

P1855 榨取kkksc03

\(01\) 背包的基础上再开一维就可以了。

完整代码:

#include <bits/stdc++.h>
using namespace std;
const int N = 209;
int dp[N][N], w[N], t[N], n, m, T;
int main(){
	scanf("%d%d%d", &n, &m, &T);
	for(int i = 1; i <= n; i++)
		scanf("%d%d", &w[i], &t[i]);
	for(int i = 1; i <= n; i++)
		for(int j = m; j >= w[i]; j--)
			for(int k = T; k >= t[i]; k--)
				dp[j][k] = max(dp[j][k], dp[j - w[i]][k - t[i]] + 1);
	printf("%d", dp[m][T]);
	return 0;
}

分组背包

和 01 背包很像,就是不再枚举每个物品,而是枚举每个组别再枚举这个组别中选哪个物品就可以了。

P1757 通天之分组背包

#include <bits/stdc++.h>
using namespace std;
const int N = 109, M = 1e3 + 9;
int dp[M], w[M], v[M], n, m, cnt;
vector <int> vec[N];
int main(){
	scanf("%d%d", &m, &n);
	for(int i = 1; i <= n; i++){
		int num;
		scanf("%d%d%d", &w[i], &v[i], &num);
		cnt = max(cnt, num);
		vec[num].push_back(i);
	}
	for(int k = 1; k <= cnt; k++)
		for(int j = m; j >= 0; j--)
			for(int i = 0; i < vec[k].size(); i++)
				if(j >= w[vec[k][i]])
					dp[j] = max(dp[j], dp[j - w[vec[k][i]]] + v[vec[k][i]]);
				else
					dp[j] = dp[j];
	printf("%d", dp[m]);
	return 0;
}

树形背包 (有依赖的背包)

洛谷 P1064 [NOIP 2006 提高组] 金明的预算方案

这是树形背包的来源,但是这并不是最泛化的树形背包。

由于买附件一定要先买主件,而买主件则没有限制,因此一共有以下 \(5\) 种不同的购买方案:

  • 什么都不买;

  • 只买主件;

  • 买主件和第一个附件(如果有);

  • 买主件和第二个附件(如果有);

  • 买主件和两个附件(如果都有);

我们依然按照 01 背包的 DP 状态设法,记 \(dp_{i, j}\) 表示考虑前 \(i\)主件,总重量为 \(j\) 的最大价值,那么我们就可以写出上面 \(5\) 种情况分别的状态转移方程:

  • \(dp_{i, j} = dp_{i - 1, j}\)

  • \(dp_{i, j} = \max(dp_{i, j}, dp_{i - 1, j - v_{i, 0}} + v_{i, 0} \times p_{i, 0})\)

  • \(dp_{i, j} = \max(dp_{i, j}, dp_{i - 1, j - v_{i, 0} - v_{i, 1}} + v_{i, 0} \times p_{i, 0} + v_{i, 1} \times p_{i, 1})\)

  • \(dp_{i, j} = \max(dp_{i, j}, dp_{i - 1, j - v_{i, 0} - v_{i, 2}} + v_{i, 0} \times p_{i, 0} + v_{i, 2} \times p_{i, 2})\)

  • \(dp_{i, j} = \max(dp_{i, j}, dp_{i - 1, j - v_{i, 0} - v_{i, 1} - v_{i, 2}} + v_{i, 0} \times p_{i, 0} + v_{i, 1} \times p_{i, 1} + v_{i, 2} \times p_{i, 2})\)

注意一下下标,然后这道题就做完了。

点击查看代码
#include <bits/stdc++.h>
using namespace std;
const int N = 3.2e4 + 9, M = 69;
int v[M][3], p[M][3], dp[N], n, m, v1, p1, q1;
signed main(){
	scanf("%d%d", &n, &m);
	for(int i = 1; i <= m; i++){
		scanf("%d%d%d", &v1, &p1, &q1);
		if(q1){
			if(v[q1][1]){
				v[q1][2] = v1;
				p[q1][2] = p1;
			} else {
				v[q1][1] = v1;
				p[q1][1] = p1;
			}
		} else {
			v[i][0] = v1;
			p[i][0] = p1;
		}
	}
	for(int i = 1; i <= m; i++){
		for(int j = n; j >= 1; j--){
			if(v[i][0] <= j)
				dp[j] = max(dp[j], dp[j - v[i][0]] + v[i][0] * p[i][0]);
			if(v[i][0] + v[i][1] <= j)
				dp[j] = max(dp[j], dp[j - v[i][0] - v[i][1]] + v[i][0] * p[i][0] + v[i][1] * p[i][1]);
			if(v[i][0] + v[i][2] <= j)
				dp[j] = max(dp[j], dp[j - v[i][0] - v[i][2]] + v[i][0] * p[i][0] + v[i][2] * p[i][2]);
			if(v[i][0] + v[i][1] + v[i][2] <= j)
				dp[j] = max(dp[j], dp[j - v[i][0] - v[i][1] - v[i][2]] + v[i][0] * p[i][0] + v[i][1] * p[i][1] + v[i][2] * p[i][2]);
		}
	}
	printf("%d", dp[n]);
	return 0;
}

洛谷 P2014 [CTSC1997] 选课

这其实也不是特别泛化的树形背包,因为每个课程的重量是 \(1\)。不过重量不是 \(1\) 可以仿照本题思路列出状态转移方程。

首先可以发现,如果按照依赖关系建图的话,会连出一个森林,此时背包间的合并比较麻烦,因此我们建出一个超级根作为所有树根的直接先修课(比如学会如何写字),我们再强制选超级根就行了。因此我们只需要将 \(m\) 增大 \(1\) 即可。

由于是在树上,因此我们就将 DP 函数改成 \(dp_{u, i}\) 表示在 \(u\) 的子树内,选了 \(i\) 门功课的最大学分。那么我们选的这 \(i\) 门功课一定是一个包含根节点的连通块。于是我们考虑 \(u\) 的儿子 \(v\),我们假设我们在 \(v\) 的子树内选了 \(j\) 门功课,那么这 \(j\) 门功课一定与 \(u\) 联通,满足题目的限制。因此状态转移方程就是 \(dp_{u, i} = \displaystyle\max_{j = 0}^{\min(i - 1, siz_v)} dp_{u, i - j} + dp_{v, j}\)。此时我们在 \(\mathcal O(nm^2)\) 的时间复杂度内解决了问题。

点击查看代码
#include <bits/stdc++.h>
using namespace std;
const int N = 3e2 + 9;
struct Edge{
	int v, nex;
} e[N];
int head[N], ecnt;
void addEdge(int u, int v){
	e[++ecnt] = Edge{v, head[u]};
	head[u] = ecnt;
}
int dp[N][N], n, m;
void DP(int u){
	for(int i = head[u]; i; i = e[i].nex){
		int v = e[i].v;
		DP(v);
		for(int i = m + 1; i >= 1; i--)
			for(int j = 0; j <= i - 1; j++)
				dp[u][i] = max(dp[u][i], dp[u][i - j] + dp[v][j]);
	}
}
int main(){
	scanf("%d%d", &n, &m);
	for(int i = 1; i <= n; i++){
		int fa;
		scanf("%d%d", &fa, &dp[i][1]);
		addEdge(fa, i);
	}
	DP(0);
	printf("%d\n", dp[0][m + 1]);
	return 0;
}

泛化物品背包

泛化物品

考虑这样一个物品,它的价值不是一个定值,而是会随着费用的变化而变化。就像同样一套题目,你在一天的早晨和晚上都去做它,效率肯定是不一样的,说明它的价值和时间有关系。这就是泛化物品的概念。

如果从更数学的角度来说,就是我们定义了一个价值函数 \(v = h(w)\),当我们输入不同的 \(w\) 的时候,这个函数会输出对应的 \(v\) 值。其实我们之前讲的很多背包问题,都是泛化物品背包的一种:

  • \(01\) 背包的 \(h\) 函数为 \(v = [w = w_i]v_i\),表示只有当放入物品的费用为 \(w_i\) 时才会获得 \(v_i\) 的收益;

  • 多重背包的 \(h\) 函数为 \(v = [w_i \mid w]\left[ \displaystyle\frac{w}{w_i} \leq c_i \right] \displaystyle\frac{wv_i}{w_i}\),表示只有当放入物品的费用为 \(w_i\) 的倍数且选的物品个数 \(\leq c_i\) 时才会获得选的物品个数 \(\times v_i\) 的收益;

  • 树形背包例题洛谷 P1064 [NOIP 2006 提高组] 金明的预算方案中,主件和附件一起可以看成一个物品组,你给这个物品组分配不同的花费,就会得到不同的价值,这也是一个泛化背包问题。

泛化物品合并

现在我们要做的,就是选定一个费用,然后把两个泛化物品合并起来,此时我们需要考虑如何将费用分配给这两个物品。

假设分配给这两个物品分别 \(w_1\)\(w——2\) 的费用,那么我们现在要求的最大价值 \(h(w) = h_1(w_1) + h_2(w_2)\),此时我们就得到了一个新的物品,它的价值 \(h(w)\) 受两个泛化物品影响,我们一般通过简化式子,把它变成一个跟 \(w_1 + w_2\) 有关的式子,此时我们从新将 \(w_1 + w_2\) 看成一个整体。然后就可以继续合并了。

对于其中的物品都是泛化物品的背包问题,求它的答案的过程也就是求所有这些泛化物品之和的过程。假设最后合成的泛化物品的价值为 \(h_n(w_1 + w_2 + \dots + w_n) = h_n(w)\),那么我们只需要枚举一遍 \(w\) 即可。

洛谷 P1417 烹调方案

这就是一个比较简单的泛化物品背包问题,甚至不需要合并操作。

我们考虑两个食材 \(x\)\(y\),在它们之前已经花费了 \(t\) 的时间,那么此时如果先将 \(x\) 食材做成菜再做 \(y\),此时的价值为 \(a_x - (t + c_x) \times b_x + a_y - (t + c_x + c_y) \times b_y\);那么此时如果先将 \(y\) 食材做成菜再做 \(x\),此时的价值为 \(a_y - (t + c_y) \times b_y + a_x - (t + c_y + c_x) \times b_x\)。我们假设第一种顺序的价值比第二种大,那么将两式一减,可以得到 \(c_x \times b_y < c_y \times b_x\),此时我们只需要拍一遍序,再做一遍 01 背包即可。这也是泛化物品背包的另一种解法,那就是考虑顺序后再 01 背包。

点击查看代码
#include <bits/stdc++.h>
using namespace std;
#define int long long
const int N = 1e5 + 9;
struct Ingredient{
    int a, b, c;
} p[N];
int dp[N], n, m, ans;
bool cmp(Ingredient x, Ingredient y) {
    return x.c * y.b < y.c * x.b;
}
signed main(){
    scanf("%lld%lld", &m, &n);
    for(int i = 1; i <= n; i++)
        scanf("%d", &p[i].a);
    for(int i = 1; i <= n; i++)
        scanf("%d", &p[i].b);
    for(int i = 1; i <= n; i++)
        scanf("%d", &p[i].c);
    sort(p + 1, p + n + 1, cmp);
    for(int i = 1; i <= n; i++)
        for(int j = m; j >= p[i].c; j--)
            dp[j] = max(dp[j], dp[j - p[i].c] + p[i].a - j * p[i].b);
    for(int i = 1; i <= m; i++)
        ans = max(ans, dp[i]);
    printf("%lld", ans);
    return 0;
}

背包方案与计数

输出任意最优方案

对于 01 背包的函数 \(dp_{i, j}\),我们再记录一个 \(f_{i, j} = 0 / 1\) 表示 \(dp_{i, j}\) 是从 \(dp_{i - 1, j}\) 转移而来还是从 \(dp_{i - 1, j - w_i}\) 转移而来,找寻方案的时候只用从 \(dp_{n, m}\) 倒着不断往回找是从哪个状态转移过来就可以了。

其实,也可以不存 \(f_{i, j}\),此时外层循环我们需要从 \(n\)\(1\) 枚举,这样我们就知道每个状态是由哪个状态转移而来,然后我们再从 \(1\)\(n\) 正序统计答案,此时如果 \(dp_{i, j} = dp_{i + 1, j - w_i}\),那么说明可以选择第 \(i\) 个物品,将第 \(i\) 个物品加入答案即可。注意费用不要超过限制。

输出字典序最小的最优方案

AcWing12 背包问题求具体方案

一个 01 背包问题可能会有多组物品,每组都可以作为答案,现在我们要求出所有组物品中排序好后字典序最小的哪一个。

我们考虑到最优的情况一定是 \(1, 2, 3, \dots\),这启示我们在转移的时候,如果 \(f_{i, j} = f_{i + 1, j - w_i} + v_i\),那么我们应该选取后者来转移,这样可以尽可能选编号小的点来转移。其它的就和输出任意最优方案一样了。

点击查看代码
#include <bits/stdc++.h>
using namespace std;
const int N = 1e3 + 9;
int dp[N][N], w[N], v[N], n, m;
vector <int> vec;
signed main(){
    scanf("%d%d", &n, &m);
    for(int i = 1; i <= n; i++)
        scanf("%d%d", &w[i], &v[i]);
    for(int i = n; i >= 1; i--){
    	for(int j = 0; j <= m; j++){
    		if(j < w[i])
    			dp[i][j] = dp[i + 1][j];
			else
				dp[i][j] = max(dp[i + 1][j], dp[i + 1][j - w[i]] + v[i]);
		} 
	}
	for(int i = 1, j = m; i <= n; i++){
        if(j >= w[i] && dp[i][j] == dp[i + 1][j - w[i]] + v[i]){
            vec.push_back(i);
            j -= w[i];
        }
    }
    for(int i : vec)
    	printf("%d ", i);
    return 0;
}

求第 \(k\) 优方案

HDU2639 Bone Collector II

真是太神秘了,生活中应该没有人去求第 \(k\) 优解吧?

还是以 \(01\) 背包为例,我们这次不止把最优解记录在 \(dp_{i, j}\) 里,我们将前 \(k\) 优解全部记录下来,相当于每个 \(dp_{i, j}\) 是一个大小为 \(k\) 的优先队列。而每次转移的时候,我们需要将 \(dp_{i - 1, j}\)\(dp_{i - 1, j - w_i} + v_i\) 这两个堆合并起来,并且取前 \(k\) 大的值保留下来。此时我们在时间和空间复杂度上都多了一个 \(k\)

其实对于几乎所有求第 \(k\) 大值的问题(比如 \(k\) 短路),我们都可以将原先的 DP 函数看成一个堆,转移时就是堆和堆的合并。在 \(k\) 比较小的时候我们可以直接暴力插入,在 \(k\) 比较大的时候(比如 洛谷 P2483 【模板】k 短路 / [SDOI2010] 魔法猪学院),我们就需要用到可持久化可并堆这一科技了,不过这和 DP 已经没有什么关系了,也就不在这里赘述了。

另外还要注意题目对于第 \(k\) 优解的定义,将策略不同但权值相同的两个方案是看作同一个解还是不同的解。如果是前者,则维护优先队列时要保证队列里的数没有重复的。

点击查看代码
#include<bits/stdc++.h>
using namespace std;
const int N = 1e3 + 9, M = 1e3 + 9, K = 39;
int dp[M][K], w[N], v[N], tmp[K], n, m, k, T;
int main(){
	scanf("%d", &T);
	while(T--){
		scanf("%d%d%d", &n, &m, &k);
		for(int i = 1; i <= n; i++)
			scanf("%d", &v[i]);
		for(int i = 1; i <= n; i++)
			scanf("%d", &w[i]);
		memset(dp, 0, sizeof(dp));
		for(int i = 1; i <= n; i++){
			for(int j = m; j >= w[i]; j--){
				int c1 = 1, c2 = 1, cnt = 1;
				while(cnt <= k && c1 <= k && c2 <= k){
					if(dp[j][c1] > dp[j - w[i]][c2] + v[i])
						tmp[cnt] = dp[j][c1++];
					else
						tmp[cnt] = dp[j - w[i]][c2++] + v[i];
					if(tmp[cnt] != tmp[cnt - 1])
						cnt++;
				}
				while(cnt <= k && c1 <= k){
					tmp[cnt] = dp[j][c1++];
					if(tmp[cnt] != tmp[cnt - 1])
						cnt++;
				}
				while(cnt <= k && c2 <= k){
					tmp[cnt] = dp[j - w[i]][c2++] + v[i];
					if(tmp[cnt] != tmp[cnt - 1])
						cnt++;
				}
				for(int l = 1; l <= k; l++)
					dp[j][l] = tmp[l];
			}
		}
		printf("%d\n", dp[m][k]);
	}
	return 0;
}

输出方案数

此时我们就不用考虑是否是最优解了,而需要统计装满背包的所有方案。

如果 \(n\) 比较小,那么我们可以仿照之前的 DP 转移方程,只是把 \(\max\) 改成 \(+\),因此转移方程就变成了 \(cnt_{i, j} = cnt_{i - 1, j} + cnt_{i - 1, j - w_i}\),初始 \(cnt_{0, 0} = 1\)。那么最终答案就是 \(\displaystyle\sum_{i = 1}^n cnt_{i, m}\)

对于 \(n\) 更大的求方案数的题目,需要用到生成函数,详见组合数学学习笔记(三):生成函数 1(OGF)

输出最优方案数

AcWing11 背包问题求方案数

结合求方案总数和求最优方案,我们只需要最后输出 \(cnt_{n, m}\) 即可。

点击查看代码
#include <bits/stdc++.h>
using namespace std;
const int N = 1e3 + 9, M = 1e3 + 9, MOD = 1e9 + 7;
int dp[M], cnt[M], w[N], v[N], n, m;
int main(){
	scanf("%d%d", &n, &m);
	for(int i = 1; i <= n; i++)
		scanf("%d%d", &w[i], &v[i]);
	for(int i = 0; i <= m; i++)
		cnt[i] = 1;
	for(int i = 1; i <= n; i++)
		for(int j = m; j >= w[i]; j--){
			if(dp[j - w[i]] + v[i] > dp[j]){
				dp[j] = dp[j - w[i]] + v[i];
				cnt[j] = cnt[j - w[i]];
			} else if(dp[j - w[i]] + v[i] == dp[j])
				cnt[j] = (cnt[j - w[i]] + cnt[j]) % MOD;
		}
	printf("%d", cnt[m]);
	return 0;
}

背包合并

背包增减

背包 DP 难题

洛谷 P3188 [HNOI2007] 梦幻岛宝珠

LOJ6089 小 Y 的背包计数问题

非常有启发性的一道题目。

由于所有数字的和一定,因此我们考虑根号分治,最后两部分做一个卷积即可。对于重量小于 \(\sqrt n\) 的物品,直接暴力跑多重背包,时间复杂度为 \(\mathcal O(\sqrt n^3) = \mathcal O(n \sqrt n)\)

现在考虑重量大于 \(\sqrt n\) 的物品,我们发扬人类智慧,将原先往背包中放物品的操作,拆分成一下两种操作:

  • 往背包中加入一个重量为 \(\sqrt n\) 的物品;

  • 让背包中所有物品的重量增加 \(1\)

可以发现这样可以生成所有可能的物品序列。

那么这样做有什么好处呢?此时我们就可以设计出另外一种 DP 方式了。我们设 \(dp_{i, j}\) 表示放了 \(i\) 个物品,总重量为 \(j\) 的方案数。我们考虑上一次操作是第一种还是第二种。如果是第一种,那么 \(dp_{i, j}\) 就可以从 \(dp_{i - 1, j - \sqrt n}\) 转移而来,否则就可以从 \(dp_{i, j - i}\) 转移而来。因此 \(dp_{i, j} = dp_{i - 1, j - \sqrt n} + dp_{i, j - i}\),此时我们发现 \(i\) 不会超过 \(\sqrt n\),而 \(j\) 不会超过 \(n\),那么这一部分的时间复杂度就是 \(\mathcal O(n \sqrt n)\)。因此总时间复杂度就是 \(\mathcal O(n \sqrt n)\) 的。

点击查看代码
#include <bits/stdc++.h>
using namespace std;
#define int long long
const int N = 1e5 + 9, B = 4e2 + 9, MOD = 23333333;
int dp[N], dp2[B][N], sum[N], sum2[N], n, ans, len;
signed main(){
	scanf("%lld", &n);
	dp[0] = sum[0] = 1;
	len = sqrt(n) + 2;
	for(int i = 1; i < len; i++){
		for(int j = 1; j <= n; j++){
			if(j >= i)
				sum[j] = (sum[j - i] + dp[j]) % MOD;
			else
				sum[j] = dp[j];
			if(j >= i * (i + 1))
				dp[j] = (sum[j] - sum[j - i * (i + 1)] + MOD) % MOD;
            else
				dp[j] = sum[j];
		}
	}
	dp2[0][0] = sum2[0] = 1;
	for(int i = 1; i <= n / len; i++)
		for(int j = i * len; j <= n; j++){
			dp2[i][j] = (dp2[i - 1][j - len] + dp2[i][j - i]) % MOD;
			sum2[j] = (sum2[j] + dp2[i][j]) % MOD;
		}
	for(int i = 0; i <= n; i++)
		ans = (ans + dp[i] * sum2[n - i]) % MOD;
	printf("%lld", ans);
	return 0;
}

洛谷 P6453 [COCI 2008/2009 #4] PERIODNI

线性 DP

子序列问题

P1020 [NOIP 1999 提高组] 导弹拦截

P1439 【模板】最长公共子序列

P1091 [NOIP 2004 提高组] 合唱队形

方格取数问题

P7074 [CSP-J2020] 方格取数

P1004 [NOIP 2000 提高组] 方格取数

区间 DP

P1880 [NOI1995] 石子合并

P1220 关路灯

P1063 [NOIP 2006 提高组] 能量项链

状压 DP

普通状压 DP

P1896 [SCOI2005] 互不侵犯

P2704 [NOI2001] 炮兵阵地

P1879 [USACO06NOV] Corn Fields G

轮廓线 DP

数位 DP

P2602 [ZJOI2010] 数字计数

P2657 [SCOI2009] windy 数

P4124 [CQOI2016] 手机号码

树形 DP

普通树形 DP

P2015 二叉苹果树

P1352 没有上司的舞会

换根 DP

参考资料

  • 算法竞赛 罗勇军、郭卫斌
posted @ 2024-09-20 16:58  Orange_new  阅读(66)  评论(0)    收藏  举报