前言
背包问题作为动态规划的入门问题,值得我们好好掌握,理解其中原理,以便于应对各式各样的背包变种题目,以下题目所有来源于Acwing。
一、01背包
01背包
01背包的核心为每个物品只能使用一次
。
我们建立二维DP变量f[i][j]
,该变量表示含义为从前i
个物品中挑选,并且选取的物品体积总和小于等于j
的最大价值。到这里我们就可以对状态f[i][j]
基于是否选择第i
个物品这个条件进行集合划分。
f[i][j]
可以由两种状态转化而来:
- 不包含第
i
个物品,f[i-1][j]
- 包含第
i
个物品,f[i-1][j-v[i]] + w[i]
第一种情况比较容易理解,因为不包含第i
个物品,就只能从前i-1
个物品中选取,并且选取的所有物品体积总和小于等于j
的最大价值。
第二种情况包含了第i
个物品,因为选择了第i
个物品的同时,体积不能大于j
,所以状态的转移应当从f[i-1][j-v[i]
这个状态,其中v[i]
表示第i
个物品的体积。选取了第i
个物品,那么就需要加上第i
个物品的价值,所以最终的状态应该是f[i-1][j-v[i]] + w[i]
,其中w[i]
为第i
个物品的价值。
提示:因为状态的转移出现了
i-1
,所以为了方便处理边界问题,循环的物品下标从1
开始。
1. 朴素写法
import java.util.*;
public class Main {
public static void main(String[] args) {
Scanner in = new Scanner(System.in);
int n = in.nextInt(), v = in.nextInt();
int[] vs = new int[n+1], ws = new int[n+1];
for (int i = 1; i <= n; i ++) {
vs[i] = in.nextInt();
ws[i] = in.nextInt();
}
int[][] f = new int[n+1][v+1];
for (int i = 1; i <= n; i ++) {
for (int j = 0; j <= v; j ++) {
// 不包含第i个物品
f[i][j] = f[i-1][j];
if (j >= vs[i]) {
// 选择第i个物品
f[i][j] = Math.max(f[i][j], f[i-1][j-vs[i]] + ws[i]);
}
}
}
System.out.println(f[v]);
}
}
2. 滚动数组写法
通过状态转移方程可以发现f[i][j]
的状态只跟f[i-1][x]
有关,所以只需要使用一维数组来存储上一次的状态结果即可。
这里需要注意的一点是,当使用一维数组存储状态的时候,体积的遍历需要从大到小。因为f[j]
的状态由f[j-v[i]]
状态转移而来,如果从小到大遍历,那么f[j-v[i]]
的状态会提前被更新。也就是原本f[j-v[i]]
应该是f[i-1]
时的状态,但是因为被提前更新,所以f[j-v[i]]
状态变为了f[i]
时的状态,那么二维的状态转移方程就变成f[i][j] = f[i][j-v[i]] + w[i]
,显然跟我们上面推导的方程不同。因此在更新f[j]
状态前,f[j-v[i]]
的状态不能被提前更新,所以需要从大到小遍历背包体积j
。
如果这里看不明白的话,可以结合完全背包的状态转移方程与滚动优化,进行对比分析。
import java.util.*;
public class Main {
public static void main(String[] args) {
Scanner in = new Scanner(System.in);
int n = in.nextInt(), v = in.nextInt();
int[] vs = new int[n+1], ws = new int[n+1];
for (int i = 1; i <= n; i ++) {
vs[i] = in.nextInt();
ws[i] = in.nextInt();
}
int[] f = new int[v+1];
for (int i = 1; i <= n; i ++) {
for (int j = v; j >= vs[i]; j --) {
f[j] = Math.max(f[j], f[j-vs[i]] + ws[i]);
}
}
System.out.println(f[v]);
}
}
二、完全背包
完全背包问题
完全背包问题与01背包唯一不同的地方是,每个物品可以选择无数次。
同样建立二维DP状态f[i][j]
,表示从前i
个物品中选择,且总体积不大于j
的最大价值。因为01背包
中的物品只能选择一次,所以f[i][j]
的状态转移方程只有选与不选两种情况。那么在完全背包中,每个物品可以被选择无数次,所以假设物品i
可以被选择n
次,那么状态转移情况如下:
- 不选择第
i
个物品,f[i-1][j]
- 选择1个第
i
个物品,f[i-1][j - v[i]] + w[i]
- 选择2个第
i
个物品,f[i-1][j - 2 * v[i]] + 2 * w[i]
- …
- 选择n个第
i
个物品,f[i-1][ j - n * v[i]] + n * w[i]
当然这里的n
是存在上限的,因为n * v[i]
的体积不能大于j
,所以按照上面的公式可以暴力求出每个f[i][j]
的状态,但是这样的求法会超时,就不写了。那么正确的状态转移方程应该如下:
- 不选择第
i
个物品,f[i-1][j]
- 选择第
i
个物品,f[i][j - v[i]] + w[i]
注意,上面选择第
i
个物品的状态转移是f[i][j - v[i]] + w[i]
,而不是01背包的f[i-1][j - v[i]] + w[i]
下面就证明下f[i][j - v[i]] + w[i]
的状态转移是如何得到的。
通过暴力的状态遍历,我们可以得到f[i][j]
的状态转移公式为:
f
[
i
,
j
]
=
M
a
x
(
f
[
i
−
1
]
[
j
]
,
f
[
i
−
1
]
[
j
−
v
[
i
]
]
+
w
[
i
]
,
.
.
.
,
f
[
i
−
1
]
[
j
−
n
∗
v
[
i
]
]
+
n
∗
w
[
i
]
)
f\lbrack i,\;j\rbrack=Max(f\lbrack i-1\rbrack\lbrack j\rbrack,\;f\lbrack i-1\rbrack\lbrack j\;-\;v\lbrack i\rbrack\rbrack\;+\;w\lbrack i\rbrack,\;...,\;f\lbrack i-1\rbrack\lbrack j\;-\;n\;\ast\;v\lbrack i\rbrack\rbrack\;+\;n\;\ast\;w\lbrack i\rbrack)
f[i,j]=Max(f[i−1][j],f[i−1][j−v[i]]+w[i],...,f[i−1][j−n∗v[i]]+n∗w[i])
我们同样写下f[i][j - v[i]]
的状态转移公式:
f
[
i
,
j
−
v
[
i
]
]
=
M
a
x
(
f
[
i
−
1
]
[
j
−
v
[
i
]
]
,
f
[
i
−
1
]
[
j
−
2
∗
v
[
i
]
]
+
w
[
i
]
,
.
.
.
,
f
[
i
−
1
]
[
j
−
n
∗
v
[
i
]
]
+
(
n
−
1
)
∗
w
[
i
]
)
f\lbrack i,\;j-v\lbrack i\rbrack\rbrack=Max(f\lbrack i-1\rbrack\lbrack j-v\lbrack i\rbrack\rbrack,\;\;f\lbrack i-1\rbrack\lbrack j\;-\;2\;\ast\;v\lbrack i\rbrack\rbrack\;+\;w\lbrack i\rbrack,\;...\;,\;\;f\lbrack i-1\rbrack\lbrack j\;-\;n\;\ast\;v\lbrack i\rbrack\rbrack\;+\;(n-1)\;\ast\;w\lbrack i\rbrack)
f[i,j−v[i]]=Max(f[i−1][j−v[i]],f[i−1][j−2∗v[i]]+w[i],...,f[i−1][j−n∗v[i]]+(n−1)∗w[i])
经过对比我们发现f[i][j]
的转移状态从第二个开始,都可以有f[i, j-v[i]] + w
表示,所以最终f[i][j]
的状态转移方程为:
f
[
i
,
j
]
=
M
a
x
(
f
[
i
−
1
]
[
j
−
v
[
i
]
]
,
f
[
i
]
[
j
−
v
[
i
]
]
+
w
[
i
]
)
f\lbrack i,\;j\rbrack=Max(f\lbrack i-1\rbrack\lbrack j-v\lbrack i\rbrack\rbrack,\;\;f\lbrack i\rbrack\lbrack j\;-\;v\lbrack i\rbrack\rbrack\;+\;w\lbrack i\rbrack)
f[i,j]=Max(f[i−1][j−v[i]],f[i][j−v[i]]+w[i])
1. 朴素写法
import java.util.*;
public class Main {
public static void main(String[] args) {
Scanner in = new Scanner(System.in);
int n = in.nextInt(), v = in.nextInt();
int[] vs = new int[n+1], ws = new int[n+1];
for (int i = 1; i <= n; i ++) {
vs[i] = in.nextInt();
ws[i] = in.nextInt();
}
int[][] f = new int[n+1][v+1];
for (int i = 1; i <= n; i ++) {
for (int j = 0; j <= v; j ++) {
f[i][j] = f[i-1][j];
if (j >= vs[i]) {
f[i][j] = Math.max(f[i][j], f[i][j-vs[i]] + ws[i]);
}
}
}
System.out.println(f[n][v]);
}
}
2. 滚动数组写法
这里的滚动数组优化的时候,体积j
可以从小到大遍历,因为f[i][j]
的状态转移是由f[i][j-v[i]] + w[i]
而来,并不是从f[i-1]
状态转移。
import java.util.*;
public class Main {
public static void main(String[] args) {
Scanner in = new Scanner(System.in);
int n = in.nextInt(), v = in.nextInt();
int[] vs = new int[n+1], ws = new int[n+1];
for (int i = 1; i <= n; i ++) {
vs[i] = in.nextInt();
ws[i] = in.nextInt();
}
int[] f = new int[v+1];
for (int i = 1; i <= n; i ++) {
for (int j = vs[i]; j <= v; j ++) {
f[j] = Math.max(f[j], f[j-vs[i]] + ws[i]);
}
}
System.out.println(f[v]);
}
}
三、多重背包
多重背包问题
多重背包问题与完全背包问题不同点在于,多重背包问题的物品数量为s
个,并不是可以选取无限个,所以在求解的时候,如果s
个数不大,可以选择直接暴力循环每一种状态。
1. 暴力写法
跟完全背包的状态转移方程类似,不做介绍。
import java.util.*;
public class Main {
public static void main(String[] args) {
Scanner in = new Scanner(System.in);
int n = in.nextInt(), v = in.nextInt();
int[] vs = new int[n+1], ws = new int[n+1], ss = new int[n+1];
for (int i = 1; i <= n; i ++) {
vs[i] = in.nextInt();
ws[i] = in.nextInt();
ss[i] = in.nextInt();
}
int[][] f = new int[n+1][v+1];
for (int i = 1; i <= n; i ++) {
for (int j = 0; j <= v; j ++) {
f[i][j] = f[i-1][j];
// 选择s个第i个物品
for (int s = 1; s <= ss[i]; s ++) {
if (j - s * vs[i] >= 0) {
f[i][j] = Math.max(f[i][j], f[i-1][j-s*vs[i]] + s * ws[i]);
}
}
}
}
System.out.println(f[n][v]);
}
}
2. 二进制优化写法
这里重点介绍,如何将多重背包问题
转化为01背包问题
进行求解。
我们知道01背包
问题的物品是只能选择一次的,所以如何将多重背包中s
个物品转化为只能选择1
次的状态是关键。我们可以通过二进制数的方法来表示s
个物品。
举个例子:假设我们有7个物品(这里的7表示某个物品有7个数量),那么我们将这7个物品分为1,2,4为一组,那么重新分组好的物品,可以表示0~7这个区间内的所有数,也就是是说可以通过选择与不选择这三个数来覆盖区间[0, 7],这样分组后,我们就可以将7个物品的多重背包问题转化为1, 2, 4个不同物品的01背包问题。
如果出现的物品个数并不是
2
n
−
1
2^n-1
2n−1的话,那么我们可以通过从小到大不断减去
2
n
2^n
2n,来求最后剩下的那个常数c
。
再举个例子,假设物品数量是10,那么我们分组结果为1, 2, 4, 3 =>
2
0
,
2
1
,
2
2
,
3
2^0, 2^1, 2^2, 3
20,21,22,3。因为1,2,4可以覆盖范围是[0, 7],再加上3,覆盖区间就可以为[0, 10]。具体逻辑可以看代码,简单来说就是不断减去
2
n
2^n
2n,判断当前剩余的物品数量是否仍然大于
2
n
2^n
2n。
import java.util.*;
public class Main {
public static void main(String[] args) {
Scanner in = new Scanner(System.in);
int n = in.nextInt(), m = in.nextInt();
// 统计分组后的物品总个数cnt
int cnt = 1;
int[] vs = new int[13000], ws = new int[13000];
for (int i = 0; i < n; i ++) {
int v = in.nextInt();
int w = in.nextInt();
int s = in.nextInt();
// 这里k表示2^n, 从1开始
int k = 1;
while (s >= k) {
s -= k;
// 以k个数量为一组的物品体积与价值
vs[cnt] = v * k;
ws[cnt] = w * k;
k *= 2;
cnt ++;
}
// 剩余的常数c的数量
if (s != 0) {
vs[cnt] = v * s;
ws[cnt] = w * s;
cnt ++;
}
}
// 01背包模板
int[] f = new int[m+1];
for (int i = 1; i < cnt; i ++) {
for (int j = m; j >= vs[i]; j --) {
f[j] = Math.max(f[j], f[j - vs[i]] + ws[i]);
}
}
System.out.println(f[m]);
}
}
四、分组背包
分组背包问题
分组背包是指题目提供了不同组的物品,不同组间的物品可以同时选择,同一组内的物品互斥。
同样建立二维DP状态f[i][j]
,但是这里的状态表示为从前i
组中选取的物品,体积不大于j
的最大价值,因为分组的存在,所以i
不能表示第i
个物品。
状态转移方程如下:
- 不选择第
i
组的所有物品,f[i-1][j]
- 选择第
i
组的第k
个物品,f[i-1][j - v[i][k]] + w[i][k]
这里与01背包
不同的是多了分组下的第k
个物品,所以在状态转移的时候,需要多加一层循环,表示第i
组的k
个物品。
1. 朴素写法
import java.util.*;
public class Main {
public static void main(String[] args) {
Scanner in = new Scanner(System.in);
int n = in.nextInt(), m = in.nextInt();
// s[i]表示第i个分组的物品类别数量
int[] s = new int[n+1];
int[][] vs = new int[105][105], ws = new int[105][105];
for (int i = 1; i <= n; i ++) {
s[i] = in.nextInt();
for (int j = 0; j < s[i]; j ++) {
vs[i][j] = in.nextInt();
ws[i][j] = in.nextInt();
}
}
int[][] f = new int[n+1][m+1];
for (int i = 1; i <= n; i ++) {
for (int j = 0; j <= m; j ++) {
f[i][j] = f[i-1][j];
for (int k = 0; k < s[i]; k ++) {
if (j >= vs[i][k])
f[i][j] = Math.max(f[i][j], f[i-1][j-vs[i][k]] + ws[i][k]);
}
}
}
System.out.println(f[n][m]);
}
}
滚动数组的优化算法就留给你们自己去实现,注意状态转移是从f[i]
还是从f[i-1]
。
总结
主要介绍了常见的四种背包问题及其变种问题,需要记住的是体积的遍历顺序,究竟是从大到小还是从小到大,状态转移方程的求解应当记牢,二进制状态的优化在其他DP类型的题目中会经常出现。