1. 什么是01背包问题?
01背包问题是一种经典的组合优化问题,它的描述如下:
有n种物品和一个容量为C的背包,每种物品有一个重量w[i]和一个价值v[i],其中i=1,2,…,n。问如何选择物品放入背包,使得背包内的物品总价值最大,且不超过背包的容量?
这里的01表示每种物品只能选择放入或不放入,不能分割或重复。
2. 为什么可以用动态规划解决?
动态规划是一种解决复杂问题的方法,它的核心思想是将一个大问题分解为若干个子问题,然后自底向上地求解子问题,并将子问题的解保存起来,避免重复计算。最后,通过子问题的解组合出原问题的解。
动态规划适用于具有最优子结构和重叠子问题的问题。最优子结构指的是一个问题的最优解可以由其子问题的最优解构成;重叠子问题指的是在求解过程中,有些子问题会被多次遇到和求解。
01背包问题就具有这两个特点。首先,我们可以将原问题分解为n个阶段,每个阶段对应一种物品。在每个阶段,我们需要决定是否将该物品放入背包。如果我们已经知道了前i-1个阶段的最优解,那么第i个阶段的最优解就可以由以下两种情况中的较大者得到:
- 如果第i种物品的重量w[i]大于当前背包的剩余容量j,那么我们不能放入该物品,此时第i个阶段的最优解就等于前i-1个阶段的最优解,即V(i,j)=V(i-1,j);
- 如果第i种物品的重量w[i]小于等于当前背包的剩余容量j,那么我们可以选择放入或不放入该物品。如果不放入,那么第i个阶段的最优解仍然等于前i-1个阶段的最优解,即V(i,j)=V(i-1,j);如果放入,那么第i个阶段的最优解等于前i-1个阶段在容量为j-w[i]时的最优解加上第i种物品的价值v[i],即V(i,j)=V(i-1,j-w[i])+v[i]。因此,在这种情况下,我们需要在这两种选择中取较大者作为第i个阶段的最优解,即V(i,j)=max{V(i-1,j),V(i-1,j-w[i])+v[i]}。
这样,我们就得到了一个递推关系式,它表明了原问题的最优解可以由其子问题的最优解构成,即具有最优子结构。
其次,在求解过程中,我们会遇到很多相同或类似的子问题。例如,在求解V(i,j)时,我们需要知道V(i-1,j)和V(i-1,j-w[i]);而在求解V(i+1,j)时,我们又需要知道V(i,j)和V(i,j-w[i+1])。这些子问题都涉及到前面某些阶段在某些容量下的最优解。如果我们每次都重新计算这些子问题,那么会造成很多重复的工作。为了避免这种情况,我们可以用一个二维数组来保存子问题的解,每次遇到一个子问题,我们先查看数组中是否已经有该子问题的解,如果有,就直接使用;如果没有,就按照递推关系式计算,并将结果存入数组中。这样,我们就可以利用已经求解过的子问题的解,避免重复计算,即利用了重叠子问题。
3. 如何用动态规划求解?
根据上面的分析,我们可以用以下步骤来求解01背包问题:
- 定义一个二维数组dp[n+1][C+1],其中dp[i][j]表示在前i种物品和容量为j的背包下的最优解;
- 初始化边界条件,即dp[0][j]=0(没有物品时,最优解为0)和dp[i][0]=0(没有容量时,最优解为0);
- 从第一种物品开始,遍历每一种物品i;
- 对于每一种物品i,从第一个单位容量开始,遍历每一个单位容量j;
- 对于每一个单位容量j,根据递推关系式计算dp[i][j];
- 最后,返回dp[n][C]作为原问题的最优解。
4. 有什么实例可以说明?
假设有4种物品和一个容量为8的背包,每种物品的重量和价值如下表所示:
物品编号 | 重量 | 价值 |
---|---|---|
1 | 2 | 3 |
2 | 3 | 4 |
3 | 4 | 5 |
4 | 5 | 6 |
按照动态规划的步骤,我们可以得到以下的二维数组:
i\j | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
---|---|---|---|---|---|---|---|---|---|
0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
1 | 0 | 0 | 3 | 3 | 3 | 3 | 3 | 3 | 3 |
2 | 0 | 0 | 3 | 4 | 4 | 7 | 7 | 7 | 7 |
3 | 0 | 0 | 3 | 4 | 5 | 7 | 8 | 9 | 9 |
4 | 0 | 0 | 3 | 4 | 5 | 7 | 8 | 9 | 10 |
我们可以看到,在填写第一行时,由于没有物品可选,所以所有的最优解都是0;在填写第二行时,由于只有第一种物品可选,所以只有当容量大于等于2时才能放入该物品,并获得价值为3的最优解;在填写第三行时,由于有两种物品可选,所以当容量大于等于2时可以选择放入或不放入第二种物品,并取较大者作为最优解;以此类推,在填写第四行时,由于有三种物品可选,所以当容量大于等于4时可以选择放入或不放入第三种物品,并取较大者作为最优解。
最后,在填写第五行时,由于有四种物品可选,所以当容量大于等于5时可以选择放入或不放入第四种物品,并取较大者作为最优解。填写完毕后
我们可以看到,最右下角的值dp[4][8]就是原问题的最优解,即在四种物品和容量为8的背包下的最大价值为10。如果我们想知道这个最优解是由哪些物品组成的,我们可以根据递推关系式反向推导。由于dp[4][8]=dp[3][8-w[4]]+v[4]=dp[3][3]+6=4+6=10,说明第四种物品被放入了背包,并且在放入之前,背包的剩余容量为3,对应的最优解为4。然后我们继续查看dp[3][3],由于dp[3][3]=dp[2][3-w[3]]+v[3]=dp[2][0]+5=0+5=5,说明第三种物品也被放入了背包,并且在放入之前,背包的剩余容量为0,对应的最优解为0。这时候,我们已经找到了所有被放入背包的物品,即第三种和第四种物品。因此,我们可以得出结论:在四种物品和容量为8的背包下的最大价值为10,且是由第三种和第四种物品组成的。
5. 如何用Java实现动态规划算法?
根据上面的步骤和逻辑,我们可以用Java语言来实现动态规划算法。以下是一个可能的代码:
public class Knapsack {
// 物品的重量
private int[] w = {2, 3, 4, 5};
// 物品的价值
private int[] v = {3, 4, 5, 6};
// 物品的数量
private int n = w.length;
// 背包的容量
private int C = 8;
// 动态规划表
private int[][] dp = new int[n + 1][C + 1];
// 求解01背包问题
public void solve() {
// 初始化边界条件
for (int i = 0; i <= n; i++) {
dp[i][0] = 0;
}
for (int j = 0; j <= C; j++) {
dp[0][j] = 0;
}
// 填写动态规划表
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= C; j++) {
if (j < w[i - 1]) {
// 第i种物品的重量大于当前背包的剩余容量,不能放入
dp[i][j] = dp[i - 1][j];
} else {
// 第i种物品的重量小于等于当前背包的剩余容量,可以选择放入或不放入
dp[i][j] = Math.max(dp[i - 1][j], dp[i - 1][j - w[i - 1]] + v[i - 1]);
}
}
}
// 输出最优解
System.out.println("最大价值为:" + dp[n][C]);
// 输出最优解的组成
System.out.print("最优解的组成为:");
int j = C;
for (int i = n; i > 0; i--) {
if (dp[i][j] > dp[i - 1][j]) {
// 第i种物品被放入了背包
System.out.print("物品" + i + " ");
j = j - w[i - 1];
}
if (j == 0) {
break;
}
}
}
public static void main(String[] args) {
Knapsack knapsack = new Knapsack();
knapsack.solve();
}
}
输出结果为:
最大价值为:10
最优解的组成为:物品4 物品3
自行实现如下:
public static void main(String[] args) {
int[] w = {2, 3, 4, 5}; // weight
int[] v = {3, 4, 5, 6}; // value
// 背包的容量
int N = w.length;
int C = 8;
System.out.println(pack(N, C, w, v));
System.out.println(pack2(N, C, w, v));
}
public static int pack(int N, int C, int[] w, int[] v) {
int[][] dp = new int[N + 1][C + 1];
for (int i = 1; i <= N; i++) {
for (int j = 0; j <= C; j++) {
if (j < w[i - 1]) {
dp[i][j] = dp[i - 1][j];
} else {
dp[i][j] = Math.max(dp[i - 1][j], dp[i - 1][j - w[i - 1]] + v[i - 1]);
}
}
}
return dp[N][C];
}
6. 有什么优化和变形的方法?
动态规划算法虽然可以有效地求解01背包问题,但是它也有一些缺点和局限性。例如,它需要一个二维数组来存储子问题的解,这会占用很多空间;它也需要遍历所有的物品和容量,这会消耗很多时间。因此,有一些优化和变形的方法可以提高动态规划算法的效率和适用性。
- 空间优化:由于动态规划表的每一行只依赖于上一行,所以我们可以用一个一维数组来代替二维数组,从而节省空间。具体地,我们可以用一个长度为C+1的数组dp来表示当前阶段的最优解,然后从后往前更新dp的值,即dp[j]=max{dp[j],dp[j-w[i-1]]+v[i-1]}。这样,我们就不需要保存之前阶段的最优解了,只需要一个一维数组就可以完成动态规划。
- 时间优化:由于动态规划表的每一行中,只有部分元素会被更新,而且更新的范围是从w[i-1]到C,所以我们可以只遍历这部分元素,从而节省时间。具体地,我们可以用一个变量maxW来记录当前阶段能够达到的最大重量,然后在每次更新dp时,只遍历从w[i-1]到maxW之间的元素,并更新maxW的值为max{maxW,j}。这样,我们就不需要遍历所有的容量了,只需要遍历可能被更新的容量就可以完成动态规划。
- 变形问题:01背包问题还有一些变形和扩展的问题,例如完全背包问题(每种物品可以无限制地放入)、多重背包问题(每种物品有限定的数量)、分组背包问题(物品分为若干组,每组只能选择一个)、多维背包问题(物品有多种属性,背包有多种限制)等等。这些问题都可以用类似的思路和方法来求解,只是在递推关系式和填表过程中有一些不同。感兴趣的读者可以自行查阅相关资料和代码。
7. 一维数组的优化方法
7.1 二维转 一维的两个问题
问题1: 为什么二维可以转化为一维
二维数组的每一行表示在前i种物品和不同容量下的最优解,而每一行的值只依赖于上一行的值,即dp[i][j]只与dp[i-1][j]和dp[i-1][j-w[i-1]]有关。因此,我们不需要保存所有的行,只需要保存当前行和上一行的值就可以了。而由于当前行的值会覆盖上一行的值,所以我们可以用一个一维数组来代替二维数组,只保存当前行的值,即dp[j]表示在当前阶段和容量为j的背包下的最优解。这样,我们就可以节省空间,从而优化动态规划算法。
问题2: 为什么是逆序而不是顺序
如果我们按照顺序更新dp[j]的值,即从j=0到j=C,那么我们会遇到一个问题:当我们更新dp[j]时,我们需要用到dp[j-w[i-1]]的值,但是这个值可能已经被更新过了,不再是上一阶段的值,而是当前阶段的值。这样就会导致错误的结果。例如,在更新第二种物品时,如果我们按照顺序更新dp[3]的值,那么我们会得到dp[3]=max{dp[3],dp[3-3]+4}=max{7,4}=7,这里的dp[3]已经是放入第一种物品后的最优解了,而不是上一阶段的最优解。这样就忽略了不放入第二种物品的情况,导致错误的结果。
为了避免这个问题,我们可以按照逆序更新dp[j]的值,即从j=C到j=w[i-1],这样就可以保证在更新dp[j]时,dp[j-w[i-1]]还是上一阶段的值,没有被覆盖。例如,在更新第二种物品时,如果我们按照逆序更新dp[3]的值,那么我们会得到dp[3]=max{dp[3],dp[3-3]+4}=max{0,4}=4,这里的dp[3]还是上一阶段的最优解,没有被覆盖。这样就可以正确地考虑放入或不放入第二种物品的情况,得到正确的结果。
7.2 推导过程
我们仍然用上面的例子来说明一维数组的优化方法。假设有4种物品和一个容量为8的背包,每种物品的重量和价值如下表所示:
物品编号 | 重量 | 价值 |
---|---|---|
1 | 2 | 3 |
2 | 3 | 4 |
3 | 4 | 5 |
4 | 5 | 6 |
我们定义一个一维数组dp[C+1],其中dp[j]表示在当前阶段和容量为j的背包下的最优解。我们按照以下步骤来更新dp的值:
-
- 初始化边界条件,即dp[0]=0(没有容量时,最优解为0);
- 从第一种物品开始,遍历每一种物品i;
- 对于每一种物品i,从容量为C开始,递减到w[i-1],遍历每一个单位容量j;
- 对于每一个单位容量j,根据递推关系式更新dp[j],即dp[j]=max{dp[j],dp[j-w[i-1]]+v[i-1]};
- 最后,返回dp[C]作为原问题的最优解。
我们可以用以下的表格来表示dp数组的变化过程:
i\j | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
---|---|---|---|---|---|---|---|---|---|
初始状态 |
我们可以看到,在初始化时,dp数组的所有元素都是0;在更新第一种物品时,由于只有当容量大于等于2时才能放入该物品,并获得价值为3的最优解,所以我们从后往前更新dp[2]到dp[8]的值;在更新第二种物品时,由于有两种物品可选,所以当容量大于等于3时可以选择放入或不放入第二种物品,并取较大者作为最优解,所以我们从后往前更新dp[3]到dp[8]的值;以此类推,在更新第三种和第四种物品时,我们也从后往前更新dp数组的值。最后,我们得到了dp[8]=10作为原问题的最优解。
7.3 方程式
根据上面的推导过程,我们可以得到以下的方程式:
dp[0]=0dp[j]=max{dp[j],dp[j−w[i−1]]+v[i−1]},i=1,2,...,n;j=w[i−1],w[i−1]+1,...,C
7.4 Java实现
根据上面的方程式,我们可以用Java语言来实现一维数组的优化方法。以下是一个可能的代码:
public class Knapsack {
// 物品的重量
private int[] w = {2, 3, 4, 5};
// 物品的价值
private int[] v = {3, 4, 5, 6};
// 物品的数量
private int n = w.length;
// 背包的容量
private int C = 8;
// 动态规划数组
private int[] dp = new int[C + 1];
// 求解01背包问题
public void solve() {
// 初始化边界条件
dp[0] = 0;
// 遍历每一种物品
for (int i = 1; i <= n; i++) {
// 遍历每一个单位容量,从后往前更新
for (int j = C; j >= w[i - 1]; j--) {
// 更新dp[j]的值
dp[j] = Math.max(dp[j], dp[j - w[i - 1]] + v[i - 1]);
}
}
// 输出最优解
System.out.println("最大价值为:" + dp[C]);
// 输出最优解的组成
System.out.print("最优解的组成为:");
int j = C;
for (int i = n; i > 0; i--) {
if (dp[j] > dp[j - w[i - 1]]) {
// 第i种物品被放入了背包
System.out.print("物品" + i + " ");
j = j - w[i - 1];
}
if (j == 0) {
break;
}
}
}
public static void main(String[] args) {
Knapsack knapsack = new Knapsack();
knapsack.solve();
}
}
输出结果为:
最大价值为:10
最优解的组成为:物品4 物品3
自行实现如下:
public static int pack2(int N, int C, int[] w, int[] v) {
int[] dp = new int[C + 1];
for (int i = 1; i <= N; i++) {
for (int j = C; j >= 0; j--) {
if (j >= w[i - 1]) {
dp[j] = Math.max(dp[j], dp[j - w[i - 1]] + v[i - 1]);
}
}
}
return dp[C];
}
8. 总结
动态规划是一种强大而灵活的算法思想,它可以有效地解决许多组合优化问题。01背包问题是动态规划的经典例子,它可以帮助我们理解动态规划的原理和步骤,并为我们解决其他类似或更复杂的问题提供思路和方法。希望本文能够对您有所帮助。谢谢!