背包九讲学习笔记
背包问题是一类经典的动态规划问题,它非常灵活,需要仔细琢磨体会,本文先对背包问题的几种常见类型作一个总结,给出代码模板。
根据维基百科,背包问题(Knapsack problem)是一种组合优化的NP完全(NP-Complete,NPC)问题。问题可以描述为:给定一组物品,每种物品都有自己的重量和价格,在限定的总重量内,我们如何选择,才能使得物品的总价格最高。NPC问题是没有多项式时间复杂度的解法的,但是利用动态规划,我们可以以伪多项式时间复杂度求解背包问题。一般来讲,背包问题有以下几种分类:
- 01背包问题
- 完全背包问题
- 多重背包问题
此外,还存在一些其他考法,例如恰好装满、求方案总数、求所有的方案等。本文接下来就分别讨论一下这些问题。
以下是学习背包九讲的学习笔记。
1. 01背包
1.1 题目
最基本的背包问题就是01背包问题(01 knapsack problem):一共有N件物品,第i(i从1开始)件物品的重量为 \(w[i]\),价值为 $v[i] $。在总重量不超过背包承载上限 $W $ 的情况下,能够装入背包的最大价值是多少?
1.2 分析
如果采用暴力穷举的方式,每件物品都存在装入和不装入两种情况,所以总的时间复杂度是\(O(2^N)\),这是不可接受的。而使用动态规划可以将复杂度降至 $O(NW) $。我们的目标是书包内物品的总价值,而变量是物品和书包的限重,所以我们可定义状态 \(dp\) :
dp[i][j]表示将前i件物品装进限重为j的背包可以获得的最大价值, 0<=i<=N, 0<=j<=W
那么我们可以将dp[0][0…W]初始化为0,表示将前0个物品(即没有物品)装入书包的最大价值为0。那么当 i > 0 时dp[i][j]
有两种情况:
- 不装入第i件物品,即
dp[i−1][j]
; - 装入第i件物品(前提是能装下),即
dp[i−1][j−w[i]] + v[i]
。
即状态转移方程为
dp[i][j] = max(dp[i−1][j], dp[i−1][j−w[i]] + v[i]) // j >= w[i]
由上述状态转移方程可知,dp[i][j]
的值只与dp[i-1][0,...,j-1]
有关,所以我们可以采用动态规划常用的方法(滚动数组)对空间进行优化(即去掉dp
的第一维)。需要注意的是,为了防止上一层循环的dp[0,...,j-1]
被覆盖,循环的时候 j 只能逆向枚举(空间优化前没有这个限制),伪代码为:
// 01背包问题伪代码(空间优化版)
dp[0,...,W] = 0
for i = 1,...,N
for j = W,...,w[i] // 必须逆向枚举!!!
dp[j] = max(dp[j], dp[j−w[i]]+v[i])
时间复杂度为 \(O(NW)\), 空间复杂度为 \(O(W)\) 。由于W的值是W的位数的幂,所以这个时间复杂度是伪多项式时间。
动态规划的核心思想避免重复计算在01背包问题中体现得淋漓尽致。第 $ i$ 件物品装入或者不装入而获得的最大价值完全可以由前面 \(i-1\) 件物品的最大价值决定,暴力枚举忽略了这个事实。
2. 完全背包
2.1 题目
完全背包(unbounded knapsack problem)
与01背包不同就是每种物品可以有无限多个:一共有N种物品,每种物品有无限多个,第 \(i\)( \(i\) 从1开始)种物品的重量为\(w[i]\),价值为 $v[i] $。在总重量不超过背包承载上限 $W $的情况下,能够装入背包的最大价值是多少?
2.2 分析一
我们的目标和变量和01背包没有区别,所以我们可定义与01背包问题几乎完全相同的状态 \(dp\):
dp[i][j] 表示将前i种物品装进限重为j的背包可以获得的最大价值, 0 <= i <= N, 0 <= j <= W
初始状态也是一样的,我们将 \(dp[0][0…W]\)初始化为0,表示将前0种物品(即没有物品)装入书包的最大价值为0。那么当 \(i > 0\) 时dp[i][j]
也有两种情况:
- 不装入第 $i $种物品,即
dp[i−1][j]
,同01背包; - 装入第 \(i\) 种物品,此时和01背包不太一样,因为每种物品有无限个(但注意书包限重是有限的),所以此时不应该转移到
dp[i−1][j−w[i]]
而应该转移到dp[i][j−w[i]]
,即装入第 \(i\) 种商品后还可以再继续装入第种商品。
所以状态转移方程为
dp[i][j] = max(dp[i−1][j], dp[i][j−w[i]] + v[i]) // j >= w[i]
这个状态转移方程与01背包问题唯一不同就是max第二项不是 \(dp[i-1]而是dp[i]\) 。
和01背包问题类似,也可进行空间优化,优化后不同点在于这里的 j 只能正向枚举而01背包只能逆向枚举,因为这里的max第二项是dp[i]
而01背包是dp[i-1]
,即这里就是需要覆盖而01背包需要避免覆盖。所以伪代码如下:
// 完全背包问题思路一伪代码(空间优化版)
dp[0,...,W] = 0
for i = 1,...,N
for j = w[i],...,W // 必须正向枚举!!!
dp[j] = max(dp[j], dp[j−w[i]]+v[i])
由上述伪代码看出,01背包和完全背包问题此解法的空间优化版解法唯一不同就是前者的 j 只能逆向枚举而后者的 j 只能正向枚举,这是由二者的状态转移方程决定的。此解法时间复杂度为 \(O(NW)\) , 空间复杂度为 \(O(W)\) 。
2.3 分析二
除了分析一的思路外,完全背包还有一种常见的思路,但是复杂度高一些。我们从装入第 i 种物品多少件出发,01背包只有两种情况即取0件和取1件,而这里是取0件、1件、2件…直到超过限重\((k > j/w[i])\),所以状态转移方程为:
// k为装入第i种物品的件数, k <= j / w[i]
dp[i][j] = max ((dp[i-1][j − k*w[i]] + k*v[i]) for every k)
同理也可以进行空间优化,需要注意的是,这里max里面是dp[i-1]
,和01背包一样,所以 j 必须逆向枚举,优化后伪代码为
// 完全背包问题思路二伪代码(空间优化版)
dp[0,...,W] = 0
for i = 1,...,N
for j = W,...,w[i] // 必须逆向枚举!!!
for k = [0, 1,..., j/w[i]]
dp[j] = max(dp[j], dp[j−k*w[i]]+k*v[i])
相比于分析一,此种方法不是在O(1)时间求得dp[i][j],所以总的时间复杂度就比分析一大些了。
2.4 分析三、转换成01背包
01背包问题是最基本的背包问题,我们可以考虑把完全背包问题转化为01背包问题来解:将一种物品转换成若干件只能装入0件或者1件的01背包中的物品。
最简单的想法是,考虑到第 i 种物品最多装入 W/w[i] 件,于是可以把第 i 种物品转化为 W/w[i] 件费用及价值均不变的物品,然后求解这个01背包问题。
更高效的转化方法是采用二进制的思想:把第 i 种物品拆成重量为 wi2kwi2k、价值为 vi2kvi2k 的若干件物品,其中 k 取遍满足 wi2k≤Wwi2k≤W 的非负整数。这是因为不管最优策略选几件第 i 种物品,总可以表示成若干个刚才这些物品的和(例:13 = 1 + 4 + 8)。这样就将转换后的物品数目降成了对数级别。具体代码见3.4节模板。
3. 多重背包
3.1 题目
多重背包(bounded knapsack problem)与前面不同就是每种物品是有限个:一共有N种物品,第i(i从1开始)种物品的数量为n[i],重量为w[i],价值为v[i]。在总重量不超过背包承载上限W的情况下,能够装入背包的最大价值是多少?
3.2 分析一
此时的分析和完全背包的分析二差不多,也是从装入第 i 种物品多少件出发:装入第i种物品0件、1件、…n[i]件(还要满足不超过限重)。所以状态方程为:
# k为装入第i种物品的件数, k <= min(n[i], j/w[i])
dp[i][j] = max{(dp[i-1][j − k*w[i]] + k*v[i]) for every k}
同理也可以进行空间优化,而且 j 也必须逆向枚举,优化后伪代码为
// 完全背包问题思路二伪代码(空间优化版)
dp[0,...,W] = 0
for i = 1,...,N
for j = W,...,w[i] // 必须逆向枚举!!!
for k = [0, 1,..., min(n[i], j/w[i])]
dp[j] = max(dp[j], dp[j−k*w[i]]+k*v[i])
总的时间复杂度约为 \(O(NWn¯)=O(W∑_in_i)\)级别。
3.3 分析二、转换成01背包
采用2.4节类似的思路可以将多重背包转换成01背包问题,采用二进制思路将第 i 种物品分成了 \(O(logn_i)\) 件物品,将原问题转化为了复杂度为 \(O(W∑_ilogn_i)\)的 01 背包问题,相对于分析一是很大的改进,具体代码见3.4节。
3.4 代码模板
此节根据上面的讲解给出这三种背包问题的解题模板,方便解题使用。尤其注意其中二进制优化是如何实现的。
/*
用法:
对每个物品调用对应的函数即可, 例如多重背包:
for(int i = 0; i < N; i++)
multiple_pack_step(dp, w[i], v[i], num[i], W);
参数:
dp : 空间优化后的一维dp数组, 即dp[i]表示最大承重为i的书包的结果
w : 这个物品的重量
v : 这个物品的价值
n : 这个物品的个数
max_w: 书包的最大承重
*/
void zero_one_pack_step(vector<int> &dp, int w, int v, int max_w) {
for (int j = max_w; j >= w; j--) // 反向枚举!!!
dp[j] = max(dp[j], dp[j - w] + v);
}
void complete_pack_step(vector<int> &dp, int w, int v, int max_w) {
for (int j = w; j <= max_w; j++) // 正向枚举!!!
dp[j] = max(dp[j], dp[j - w] + v);
// 法二: 转换成01背包, 二进制优化
// int n = max_w / w, k = 1;
// while(n > 0){
// zero_one_pack_step(dp, w*k, v*k, max_w);
// n -= k;
// k = k*2 > n ? n : k*2;
// }
}
void multiple_pack_step(vector<int> &dp, int w, int v, int n, int max_w) {
if (n >= max_w / w) complete_pack_step(dp, w, v, max_w);
else { // 转换成01背包, 二进制优化
int k = 1;
while (n > 0) {
zero_one_pack_step(dp, w * k, v * k, max_w);
n -= k;
k = k * 2 > n ? n : k * 2;
}
}
}
3.5混合三种背包问题
3.5.1Description
有一些物品只能取 \(1\) 件,有一些物品可以取无数件,其他物品可以取 \(z_i\) 件。
3.5.2 Solution
首先考虑 \(01\) 背包和完全背包的混合。只要判断一下当前物品的种类,看看是要顺序还是逆序循环。加上多重背包:按照上面的方法,拆开物品,将其变成 \(01\) 背包(听说可以使用单调队列)。
3.5.3 Code
for (int i = 1; i <= n; i++) {
if (can[i] == INF)
for (int j = m; j >= w[i]; j--)
f[j] = max(f[j], f[j - w[i]] + val[i]);
else
for (int j = w[i]; j <= m; j++)
f[j] = max(f[j], f[j - w[i]] + val[i]);
}
4. 二维费用背包问题
4.1 Description
对于每件物品,这件物品必须同时付出这两种代价,对于每种代价都有一个可付出的最大值(背包容量)。问怎样选择物品可以得到最大的价值。
4.2 Solution
设 $f[i][u][v] $为前 \(i\) 个物品,两种代价分别为 \(u 和 v\) 能够得到的最大价值。转移方程:
可以按照上面优化,去掉 \(i\) 这一维。只要注意 \(01\) 背包是逆序循环,完全背包是顺序循环就行了。代码以 \(01\) 背包为例。
4.3 Code
#include <cstdio>
#include <iostream>
using namespace std;
const int N = 506;
int n, m1, m2, a[N], b[N], val[N], f[N][N];
int main() {
scanf("%d%d%d", &n, &m1, &m2);
for (int i = 1; i <= n; i++)
scanf("%d%d%d", &a[i], &b[i], &val[i]);
for (int i = 1; i <= n; i++)
for (int j = m1; j >= a[i]; j--)
for (int k = m2; k >= a[i]; k--)
f[j][k] = max(f[j][k], f[j - a[i]][k - b[i]] + val[i]);
printf("%d", f[m1][m2]);
return 0;
}
5.分组背包问题
5.1 Description
把 \(n\) 个物品分为 \(k\) 组,每一组物品最多只能选一个。
5.2 Solution
问题变成了,你可以选择本组的一件,或者一件也不选。设 \(f[i][j]\) 为前 \(k\) 组物品花费 \(j\) 能得到的价值。转移方程: $$f[k][j]=max(f[k−1][j],f[k−1][j−w[i]]+val[i])$$
可以按照上面的优化方法,把 \(f\) 数组变成一维。保证每组只能选一个物品,容量的循环要在每一组物品的循环之外。具体看代码。
for (int i = 1; i <= k; i++)
for (int j = m; j > 0; j--)
for (int h = 1; h <= cnt[i]; h++) {
int v = belong[i][h];
f[j] = max(f[j], f[j - w[v]] + val[v]);
}
6. 有依赖的背包问题
6.1 Description
如果选物品 \(i\),必须选物品 $ j$。
6.2 Solution
如果把问题转化为一棵树,若 \(j\) 依赖于 \(i\),使 \(i\) 成为 \(j\) 的父亲。连出一虚拟根。只有选择了 \(i\),才能选择其子树。
设 \(f[i][j]\) 为以 \(i\) 为根的子树,已经选择了 \(j\) 个节点的最大价值。转移方程:
6.3 例题
此题相当于给出 \(n\) 个物品,背包容量为 \(m\),每个物品的重量为 \(1\),价值为 \(s_i\),最大化价值。物品之间存在依赖关系。这样就把问题转化成了一般形式,按上面的思路解即可。
6.4 Code
#include <cstdio>
#include <iostream>
using namespace std;
const int N = 2333;
struct edge {
int nxt, to;
} e[N];
int n, m, fat, cnt = 0, s[N], f[N][N], head[N];
void add(int x, int y) {
e[++cnt] = (edge){head[x], y};
head[x] = cnt;
}
void dp(int x, int fa) {
f[x][1] = s[x];
for (int i = head[x]; i; i = e[i].nxt) {
int v = e[i].to;
if (v == fa) continue;
dp(v, x);
for (int j = m + 1; j > 0; j--)
for (int k = 1; k < j; k++)
f[x][j] = max(f[x][j], f[v][k] + f[x][j - k]);
}
}
int main() {
scanf("%d%d", &n, &m);
for (int i = 1; i <= n; i++) {
scanf("%d%d", &fat, &s[i]);
add(fat, i);
add(i, fat);
}
dp(0, -1);
printf("%d", f[0][m + 1]);
return 0;
}
7. 泛化背包问题
7.1 Description
每个物品没有固定的重量和价值。给它重量 \(i\),就会得到价值 \(h(i)\)。
7.2 Solution
设有泛化物品 \(h\) 和 \(l\),其中 \(i(j)\) 表示给 \(i\) 这个泛化物品设置 \(j\) 费用能得到的价值。若 \(f\) 满足$ f(v)=max(h(k)+l(v−k))$,则称 \(f=h+l\)。
实际上求最终结果的过程,就是不断求泛化物品之和的过程。
最后附上DD大牛的 背包九讲PDF下载: