1. 问题
背包问题是一类经典的动态规划问题,它描述了一个人在有限的背包容量下,如何选择一些物品装入背包,使得物品的总价值最大。
根据物品的不同特征和限制条件,背包问题可以分为以下三种基本类型:
- 01背包:每种物品只有一件,可以选择放或不放。
- 完全背包:每种物品有无限件,可以选择放任意件。
- 多重背包:每种物品有有限件,可以选择放至多若干件。
混合背包问题是将以上三种基本类型的背包问题综合起来,即有的物品只能放一次(01背包),有的物品可以放无限次(完全背包),有的物品可以放有限次(多重背包)。
这种问题在实际生活中也很常见,比如在购物时,有些商品是限购的,有些商品是不限购的,我们要在预算内买到最满意的商品组合。
应该怎么求解呢?
2. 解题思路
01背包与完全背包的混合
考虑到在P01和P02中给出的伪代码只有一处不同,故如果只有两类物品:一类物品只能取一次,另一类物品可以取无限次,那么只需在对每个物品应用转移方程时,根据物品的类别选用顺序或逆序的循环即可,复杂度是O(VN)。伪代码如下:
for i=1..N
if 第i件物品属于01背包
for v=V..0
f[v]=max{f[v],f[v-c[i]]+w[i]};
else if 第i件物品属于完全背包
for v=0..V
f[v]=max{f[v],f[v-c[i]]+w[i]};
再加上多重背包
如果再加上有的物品最多可以取有限次,那么原则上也可以给出O(VN)的解法:遇到多重背包类型的物品用单调队列解即可。但如果不考虑超过NOIP范围的算法的话,用P03中将每个这类物品分成O(log n[i])个01背包的物品的方法也已经很优了。
当然,更清晰的写法是调用我们前面给出的三个相关过程。
for i=1..N
if 第i件物品属于01背包
ZeroOnePack(c[i],w[i])
else if 第i件物品属于完全背包
CompletePack(c[i],w[i])
else if 第i件物品属于多重背包
MultiplePack(c[i],w[i],n[i])
在最初写出这三个过程的时候,可能完全没有想到它们会在这里混合应用。我想这体现了编程中抽象的威力。如果你一直就是以这种“抽象出过程”的方式写每一类背包问题的,也非常清楚它们的实现中细微的不同,那么在遇到混合三种背包问题的题目时,一定能很快想到上面简洁的解法,对吗?
解决混合背包问题
要解决混合背包问题,我们首先要明确状态和状态转移方程。我们用dp[i][j]
表示前i
种物品在容量为j
的背包下能获得的最大价值,则状态转移方程为:
dp[i][j] = max(dp[i-1][j], dp[i-1][j-k*v[i]] + k*w[i]) (0 <= k <= s[i])
其中v[i]
和w[i]
分别表示第i
种物品的体积和价值,
s[i]
表示第i
种物品的数量上限(如果是01背包,则s[i] = 1
;
如果是完全背包,则s[i] = INF
;
如果是多重背包,则s[i] > 0
)。
这个状态转移方程的含义是:
对于第i
种物品,我们可以选择放入k
件(k
从0到s[i]
遍历),那么剩余容量就是j-k*v[i]
,对应的价值就是dp[i-1][j-k*v[i]] + k*w[i]
;或者我们可以选择不放入任何件数,那么剩余容量就是j
,对应的价值就是dp[i-1][j]
。我们要在这些选择中取最大值作为当前状态的最优解。
由于状态转移方程中只涉及到当前状态和上一个状态,所以我们可以用一维数组来优化空间复杂度。
我们用dp[j]
表示当前状态下容量为j
的背包能获得的最大价值,则状态转移方程为:
- 如果第
i
种物品是01背包,则:dp[j] = max(dp[j], dp[j-v[i]] + w[i]) (j >= v[i])
- 如果第
i
种物品是完全背包,则:dp[j] = max(dp[j], dp[j-v[i]] + w[i]) (j >= v[i])
- 如果第
i
种物品是多重背包,则:dp[j] = max(dp[j], dp[j-k*v[i]] + k*w[i]) (j >= k*v[i], 0 <= k <= s[i])
注意,这里的状态转移方程和遍历顺序与物品的类型有关。
- 如果是01背包或多重背包,我们要从大到小遍历容量,以避免重复计算;
- 如果是完全背包,我们要从小到大遍历容量,以利用之前的计算结果。
- 另外,对于多重背包,我们还可以用二进制优化的方法,将其转化为若干个01背包,以减少内层循环的次数。
3. 实现
JAVA实例
以下是用JAVA语言实现的混合背包问题的代码:
import java.util.Scanner;
public class MixedKnapsack {
public static void main(String[] args) {
Scanner sc = new Scanner(System.in);
int n = sc.nextInt(); // 物品种数
int m = sc.nextInt(); // 背包容量
int[] v = new int[n]; // 物品体积
int[] w = new int[n]; // 物品价值
int[] s = new int[n]; // 物品数量上限
for (int i = 0; i < n; i++) {
v[i] = sc.nextInt();
w[i] = sc.nextInt();
s[i] = sc.nextInt();
}
sc.close();
System.out.println(maxValue(n, m, v, w, s)); // 输出最大价值
}
// 求解混合背包问题的最大价值
public static int maxValue(int n, int m, int[] v, int[] w, int[] s) {
int[] dp = new int[m + 1]; // 状态数组
for (int i = 0; i < n; i++) { // 遍历物品种类
if (s[i] == -1) { // 01背包
zeroOnePack(dp, v[i], w[i], m);
} else if (s[i] == 0) { // 完全背包
completePack(dp, v[i], w[i], m);
} else { // 多重背包
multiplePack(dp, v[i], w[i], s[i], m);
}
}
return dp[m]; // 返回最大价值
}
// 处理01背包子问题
public static void zeroOnePack(int[] dp, int v, int w, int m) {
for (int j = m; j >= v; j--) { // 从大到小遍历容量
dp[j] = Math.max(dp[j], dp[j - v] + w); // 更新状态
}
}
// 处理完全背包子问题
public static void completePack(int[] dp, int v, int w, int m) {
for (int j = v; j <= m; j++) { // 从小到大遍历容量
dp[j] = Math.max(dp[j], dp[j - v] + w); // 更新状态
}
}
// 处理多重背包子问题(二进制优化)
public static void multiplePack(int[] dp, int v, int w, int s, int m) {
if (v * s >= m) { // 如果物品总体积大于等于背包容量,相当于完全背包
completePack(dp, v, w, m);
return;
}
for (int k = 1; k <= s; k *= 2) { // 二进制拆分物品数量
zeroOnePack(dp, k * v, k * w, m); // 转化为01背包处理
s -= k; // 减去已经拆分的数量
}
if (s > 0) { // 如果还有剩余数量,再
zeroOnePack(dp, s * v, s * w, m); // 转化为01背包处理
}
}
}
JAVA 自有实现及带测试
public class MixedKnapsack {
public static void main(String[] args) {
int N = 5;
int V = 20;
int[] v = new int[]{1, 3, 2, 3, 4}; // volume
int[] w = new int[]{2, 4, 3, 5, 3}; // worth
int[] s = new int[]{3, 0, 3, -1, 6}; // size
System.out.println(dp(v, w, s, N, V));
}
public static int dp(int[] v, int[] w, int[] s, int N, int V) {
int[] dp = new int[V + 1];
for (int i = 1; i <= N; i++) {
if (s[i - 1] == -1) {
zeroOnePack(dp, v[i - 1], w[i - 1], V);
} else if (s[i - 1] == 0) {
completePack(dp, v[i - 1], w[i - 1], V);
} else {
multiplePack(dp, v[i - 1], w[i - 1], s[i - 1], V);
}
}
return dp[V];
}
public static void zeroOnePack(int[] dp, int v, int w, int V) {
for (int j = V; j >= v; j--) {
dp[j] = Math.max(dp[j], dp[j - v] + w);
}
}
public static void completePack(int[] dp, int v, int w, int V) {
for (int j = v; j <= V; j++) {
dp[j] = Math.max(dp[j], dp[j - v] + w);
}
}
public static void multiplePack(int[] dp, int v, int w, int s, int V) {
if (v * s >= V) {
completePack(dp, v, w, V);
return;
}
for (int k = 1; k <= s; k *= 2) {
zeroOnePack(dp, k * v, k * w, V);
s -= k;
}
if (s > 0) {
zeroOnePack(dp, s * v, s * w, V);
}
}
}
4. 小结
有人说,困难的题目都是由简单的题目叠加而来的。这句话是否公理暂且存之不论,但它在本讲中已经得到了充分的体现。本来01背包、完全背包、多重背包都不是什么难题,但将它们简单地组合起来以后就得到了这样一道一定能吓倒不少人的题目。但只要基础扎实,领会三种基本背包问题的思想,就可以做到把困难的题目拆分成简单的题目来解决。