动态规划中的背包问题:0/1 背包与完全背包的核心解析
动态规划(Dynamic Programming,简称 DP)是算法设计中一种重要的思想,其核心在于通过拆解问题、定义状态、寻找状态转移规律,利用子问题的解来高效求解复杂问题。而背包问题作为动态规划的经典应用场景,尤其是 0/1 背包和完全背包,常常是理解 DP 思想的最佳切入点。
一、动态规划的核心:状态与状态转移
在动态规划中,最关键的两个概念是状态和状态转移:
- 状态:可以理解为问题在某一阶段的 “快照”,通常用一个数组(或矩阵)
dp
来表示。状态的定义需要精准覆盖问题的约束条件和求解目标。 - 状态转移:指如何通过前一阶段的状态推导出当前阶段的状态。简单来说,就是 “用过去的结果决定现在的选择”。
对于背包问题,我们的核心目标是:在给定背包容量的限制下,从若干物品中选择组合,使总价值最大(或满足特定条件)。因此,dp
数组的设计通常围绕两个核心维度:物品和背包容量。
二、0/1 背包:每个物品只能选一次
1. 问题特点
0/1 背包的关键约束是:每个物品要么被选入背包(1 次),要么不选(0 次),不能重复选择。
2. dp 数组的定义
通常定义dp[j]
表示:背包容量为j
时,能获得的最大价值(简化为一维数组,二维数组dp[i][j]
表示前i
个物品中选择时的状态,可优化为一维)。
初始化时,dp[0] = 0
(容量为 0 时价值为 0),其余位置可初始化为 0 或负无穷(根据问题场景调整)。
3. 关键:遍历顺序的设计
0/1 背包的核心是确保每个物品只被使用一次,这完全依赖于遍历顺序的设计:
- 外层循环遍历物品:依次考虑每个物品是否放入背包。
- 内层循环倒序遍历容量:从最大容量
max_weight
向 0 遍历。
为什么要倒序?假设我们正处理第i
个物品,倒序遍历容量j
时,计算dp[j]
所依赖的dp[j - weight[i]]
是 “未处理第i
个物品时的旧状态”(即不包含当前物品)。这样就能保证每个物品在当前容量下只被选择一次。
如果内层正序遍历会怎样?正序遍历会导致dp[j - weight[i]]
可能已经包含了第i
个物品(因为前面的容量已经更新过),从而导致同一物品被多次选择,违背 0/1 背包的约束。
4. Java 代码实现
public class ZeroOneKnapsack {public static int maxValue(int[] weights, int[] values, int capacity) {int n = weights.length;int[] dp = new int[capacity + 1];// 外层遍历物品for (int i = 0; i < n; i++) {// 内层倒序遍历容量(关键)for (int j = capacity; j >= weights[i]; j--) {// 状态转移方程:选或不选当前物品dp[j] = Math.max(dp[j], dp[j - weights[i]] + values[i]);}}return dp[capacity];}public static void main(String[] args) {int[] weights = {2, 3, 4, 5}; // 物品重量int[] values = {3, 4, 5, 6}; // 物品价值int capacity = 8; // 背包容量System.out.println("0/1背包最大价值: " + maxValue(weights, values, capacity)); // 输出9}
}
三、完全背包:每个物品可以选多次
1. 问题特点
完全背包与 0/1 背包的唯一区别是:每个物品可以被无限次选择(只要背包容量允许)。
2. dp 数组的定义
与 0/1 背包一致,dp[j]
仍表示容量为j
时的最大价值。
3. 关键:遍历顺序的调整
完全背包允许物品重复选择,因此遍历顺序与 0/1 背包相反:
- 外层循环遍历物品(与 0/1 背包一致)。
- 内层循环正序遍历容量:从 0 向
max_weight
遍历。
为什么正序可行?正序遍历容量时,计算dp[j]
所依赖的dp[j - weight[i]]
可能已经包含了第i
个物品(因为前面的容量已更新),这恰好符合 “物品可以重复选择” 的需求。例如,当j
增大时,j - weight[i]
可能已经放入过第i
个物品,此时再放入一次,相当于多次选择该物品。
4. Java 代码实现
public class CompleteKnapsack {public static int maxValue(int[] weights, int[] values, int capacity) {int n = weights.length;int[] dp = new int[capacity + 1];// 外层遍历物品for (int i = 0; i < n; i++) {// 内层正序遍历容量(关键)for (int j = weights[i]; j <= capacity; j++) {// 状态转移方程:允许重复选择当前物品dp[j] = Math.max(dp[j], dp[j - weights[i]] + values[i]);}}return dp[capacity];}public static void main(String[] args) {int[] weights = {2, 3, 4, 5}; // 物品重量int[] values = {3, 4, 5, 6}; // 物品价值int capacity = 8; // 背包容量System.out.println("完全背包最大价值: " + maxValue(weights, values, capacity)); // 输出12}
}
四、内外层循环顺序的影响
除了遍历方向,物品和容量的内外层循环顺序也需要注意:
0/1 背包:必须外层遍历物品,内层遍历容量(倒序)。若颠倒顺序(外层容量,内层物品),会导致同一物品在不同容量下被多次考虑,可能重复选择,违背 0/1 约束。
完全背包:外层物品、内层容量(正序)是标准写法,但部分场景下颠倒顺序(外层容量、内层物品)也可实现(需结合具体问题验证)。
反例:0/1 背包错误的循环顺序
// 错误示例:0/1背包使用外层容量、内层物品的循环顺序
public static int wrongZeroOneKnapsack(int[] weights, int[] values, int capacity) {int n = weights.length;int[] dp = new int[capacity + 1];// 错误:外层遍历容量for (int j = 0; j <= capacity; j++) {// 错误:内层遍历物品for (int i = 0; i < n; i++) {if (j >= weights[i]) {dp[j] = Math.max(dp[j], dp[j - weights[i]] + values[i]);}}}return dp[capacity];
}
五、总结:0/1 背包与完全背包的核心区别
特性 | 0/1 背包 | 完全背包 |
---|---|---|
物品选择限制 | 每个物品最多选 1 次 | 每个物品可选无限次 |
内层遍历方向 | 倒序(从大到小) | 正序(从小到大) |
状态依赖 | 依赖 “未包含当前物品” 的旧状态 | 依赖 “可能包含当前物品” 的新状态 |
外层循环 | 必须遍历物品 | 通常遍历物品(可调整) |
代码核心差异 | for (j = capacity; j >= weight[i]; j--) | for (j = weight[i]; j <= capacity; j++) |
通过代码示例可以更直观地理解:遍历顺序的设计直接决定了状态依赖关系,这正是区分 0/1 背包和完全背包的本质所在。动态规划的灵活性在于,通过调整这些细节,就能适配不同的问题约束,而背包问题正是掌握这种思想的绝佳实践。