知识梯度:浅(背包问题概念)→ 浅中(0-1 背包二维 DP)→ 中(滚动数组优化)→ 中深(416 分割等和子集)→ 深(完全背包→322 零钱兑换)→ 升华(背包问题总结)
目录
引言:从五步法到背包问题
在上一篇中,我们用 DP 五步法拆解了 70 爬楼梯和 198 打家劫舍,掌握了"计数型 DP"和"最值型 DP"的基本框架。结尾我们预告了——背包问题。
如果你刷过 LeetCode 上的 DP 题,会发现一个现象:背包问题几乎占据了 DP 中等题的半壁江山。416 分割等和子集、322 零钱兑换、518 零钱兑换 II、474 一和零、494 目标和……这些题看似各不相同,但底层都共享同一个背包模板。
🤔 思考:为什么背包问题如此重要?因为"有限资源下的最优分配"是计算机科学中最基础的问题模型之一——从内存分配到预算规划,本质都是背包问题。
背包问题体系庞大,但入门的核心只有两个:0-1 背包(每个物品最多选一次)和完全背包(每个物品可以选无限次)。掌握了这两个,背包问题的半壁江山就属于你了。
本文将以 DP 五步法为主线,从 0-1 背包理论基础出发,到滚动数组优化,再到 416 分割等和子集(0-1 背包应用),接着是 322 零钱兑换(完全背包应用),最后做背包问题的系统总结。
0-1 背包理论基础
问题描述
有 N 件物品和一个容量为 W 的背包。第 i 件物品的重量为 w[i],价值为 v[i]。每件物品只能用一次(选或不选),问:在不超过背包容量的前提下,装入哪些物品能使总价值最大?
这就是经典的 0-1 背包问题——"0-1"的含义就是每个物品要么选(1),要么不选(0),没有中间选项。
二维 dp 数组定义
Step 1:确定 dp 数组以及下标的含义
dp[i][j] 表示:从下标为 [0, i] 的物品中任意取,放入容量为 j 的背包,能获得的最大价值。
递推公式
Step 2:确定递推公式
dp[i][j] = max(dp[i-1][j], dp[i-1][j - w[i]] + v[i])
-
不选物品 i:
dp[i][j] = dp[i-1][j] -
选物品 i:
dp[i][j] = dp[i-1][j - w[i]] + v[i]
🤔 思考:为什么是 dp[i-1][j - w[i]] 而不是 dp[i][j - w[i]]?因为每件物品只能选一次,dp[i-1] 确保在考虑物品 i 之前的状态。如果是 dp[i][j - w[i]],那就意味着物品 i 可能被选了多次——这正是 0-1 背包和完全背包的分水岭。
初始化
Step 3:dp 数组初始化
-
dp[i][0] = 0:背包容量为 0,什么都装不了 -
dp[0][j]:当j >= w[0]时,dp[0][j] = v[0];否则dp[0][j] = 0 -
其余元素可以初始化为 0
遍历顺序
Step 4:确定遍历顺序
两层 for 循环,先遍历物品,再遍历背包容量:
for i in range(N): # 遍历物品
for j in range(W+1): # 遍历背包容量
if j < w[i]:
dp[i][j] = dp[i-1][j]
else:
dp[i][j] = max(dp[i-1][j], dp[i-1][j-w[i]] + v[i])
打印 dp 验证
Step 5:举例推导 dp 数组
假设物品:w = [1, 3, 4],v = [15, 20, 30],背包容量 W = 4。
物品\容量 0 1 2 3 4
-----------------------------
物品0(w=1) 0 15 15 15 15
物品1(w=3) 0 15 15 20 35
物品2(w=4) 0 15 15 20 35
最终 dp[2][4] = 35 ✅
三语言代码(二维 DP)
# Python 0-1 背包二维 DP
def knapsack_2d(w, v, W):
N = len(w)
dp = [[0] * (W + 1) for _ in range(N)]
for j in range(w[0], W + 1):
dp[0][j] = v[0]
for i in range(1, N):
for j in range(1, W + 1):
if j < w[i]:
dp[i][j] = dp[i - 1][j]
else:
dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - w[i]] + v[i])
return dp[N - 1][W]
// Java 0-1 背包二维 DP
public int knapsack2D(int[] w, int[] v, int W) {
int N = w.length;
int[][] dp = new int[N][W + 1];
for (int j = w[0]; j <= W; j++) {
dp[0][j] = v[0];
}
for (int i = 1; i < N; i++) {
for (int j = 1; j <= W; j++) {
if (j < w[i]) {
dp[i][j] = dp[i - 1][j];
} else {
dp[i][j] = Math.max(dp[i - 1][j], dp[i - 1][j - w[i]] + v[i]);
}
}
}
return dp[N - 1][W];
}
// C++ 0-1 背包二维 DP
int knapsack2D(vector<int>& w, vector<int>& v, int W) {
int N = w.size();
vector<vector<int>> dp(N, vector<int>(W + 1, 0));
for (int j = w[0]; j <= W; j++) {
dp[0][j] = v[0];
}
for (int i = 1; i < N; i++) {
for (int j = 1; j <= W; j++) {
if (j < w[i]) {
dp[i][j] = dp[i - 1][j];
} else {
dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - w[i]] + v[i]);
}
}
}
return dp[N - 1][W];
}
二维 DP 表格填充过程
滚动数组优化
为什么可以优化?
当前行 i 只依赖上一行 i-1,不需要保留所有行的数据,用一维数组滚动更新即可。
一维 dp 数组
递推公式:dp[j] = max(dp[j], dp[j - w[i]] + v[i])
关键:背包容量必须倒序遍历!
for i in range(N):
for j in range(W, w[i] - 1, -1): # 倒序!
dp[j] = max(dp[j], dp[j - w[i]] + v[i])
🤔 思考:为什么必须倒序?因为一维 dp 中,dp[j] 依赖 dp[j - w[i]],如果正序遍历,dp[j - w[i]] 可能已经被当前物品 i 更新过了——这就相当于物品 i 被选了多次,违反了 0-1 背包的约束。
三语言代码(一维 DP)
# Python 0-1 背包一维 DP
def knapsack_1d(w, v, W):
dp = [0] * (W + 1)
for i in range(len(w)):
for j in range(W, w[i] - 1, -1):
dp[j] = max(dp[j], dp[j - w[i]] + v[i])
return dp[W]
// Java 0-1 背包一维 DP
public int knapsack1D(int[] w, int[] v, int W) {
int[] dp = new int[W + 1];
for (int i = 0; i < w.length; i++) {
for (int j = W; j >= w[i]; j--) {
dp[j] = Math.max(dp[j], dp[j - w[i]] + v[i]);
}
}
return dp[W];
}
// C++ 0-1 背包一维 DP
int knapsack1D(vector<int>& w, vector<int>& v, int W) {
vector<int> dp(W + 1, 0);
for (int i = 0; i < w.size(); i++) {
for (int j = W; j >= w[i]; j--) {
dp[j] = max(dp[j], dp[j - w[i]] + v[i]);
}
}
return dp[W];
}
二维 vs 一维对比表
| 维度 | 二维 DP | 一维 DP | | --- | --- | --- | | 空间复杂度 | O(N×W) | O(W) | | 代码行数 | 较长 | 极简 | | 理解难度 | 直观 | 需要理解倒序原因 | | 适用场景 | 教学/推导过程 | 面试/竞赛 |
416 分割等和子集(Medium)
题目描述
给你一个 只包含正整数 的 非空 数组
nums。请你判断是否可以将这个数组分割成两个子集,使得两个子集的元素和相等。
示例:nums = [1, 5, 11, 5],输出 true([1, 5, 5] 和 [11])
如何转换为 0-1 背包?
-
计算总和 sum,如果 sum 为奇数,直接返回 false
-
target = sum / 2,问题转化为:能否从数组中选出一些数,使它们的和恰好等于 target?
-
这就是一个 0-1 背包问题——每个数选或不选,背包容量为 target,问能否恰好装满
五步法拆解
Step 1:dp[j] 的含义——dp[j] 表示容量为 j 的背包是否能被装满(true / false)
Step 2:递推公式——dp[j] = dp[j] || dp[j - nums[i]]
Step 3:初始化——dp[0] = true,其余为 false
Step 4:遍历顺序——先物品后背包,背包容量倒序(0-1 背包模板)
Step 5:打印 dp 验证——以 nums = [1, 5, 11, 5],target = 11 为例
初始化: dp = [T, F, F, F, F, F, F, F, F, F, F, F]
i=0, nums[0]=1: dp[1] = T
i=1, nums[1]=5: dp[5] = T, dp[6] = T
i=2, nums[2]=11: dp[11] = T
i=3, nums[3]=5: dp[10] = T, dp[11] 已是 T
最终 dp[11] = true ✅
三语言代码
# Python 416 分割等和子集
def canPartition(self, nums: List[int]) -> bool:
total = sum(nums)
if total % 2 != 0:
return False
target = total // 2
dp = [False] * (target + 1)
dp[0] = True
for num in nums:
for j in range(target, num - 1, -1):
dp[j] = dp[j] or dp[j - num]
return dp[target]
// Java 416 分割等和子集
public boolean canPartition(int[] nums) {
int total = 0;
for (int num : nums) total += num;
if (total % 2 != 0) return false;
int target = total / 2;
boolean[] dp = new boolean[target + 1];
dp[0] = true;
for (int num : nums) {
for (int j = target; j >= num; j--) {
dp[j] = dp[j] || dp[j - num];
}
}
return dp[target];
}
// C++ 416 分割等和子集
bool canPartition(vector<int>& nums) {
int total = 0;
for (int num : nums) total += num;
if (total % 2 != 0) return false;
int target = total / 2;
vector<bool> dp(target + 1, false);
dp[0] = true;
for (int num : nums) {
for (int j = target; j >= num; j--) {
dp[j] = dp[j] || dp[j - num];
}
}
return dp[target];
}
复杂度分析
-
时间复杂度:O(n × target)
-
空间复杂度:O(target)
易错点
-
sum 为奇数直接返回 false
-
dp 是 boolean 类型,递推公式是
||不是max -
背包容量从 target 倒序遍历
完全背包理论基础
与 0-1 背包的唯一区别
完全背包和 0-1 背包的 唯一区别 是:每个物品可以无限次选取。
递推公式
dp[j] = max(dp[j], dp[j - w[i]] + v[i])
关键区别:背包容量必须正序遍历!
for i in range(N):
for j in range(w[i], W + 1): # 正序!
dp[j] = max(dp[j], dp[j - w[i]] + v[i])
🤔 思考:为什么正序遍历就变成了完全背包?因为正序遍历时,dp[j - w[i]] 可能已经被当前物品 i 更新过了——这意味着物品 i 可以被多次选取。
遍历顺序的深意
| 遍历顺序 | 结果 | 说明 | | --- | --- | --- | | 先物品后背包(正序) | 组合数 | 不考虑顺序 | | 先背包后物品(正序) | 排列数 | 考虑顺序 |
三语言代码
# Python 完全背包
def complete_knapsack(w, v, W):
dp = [0] * (W + 1)
for i in range(len(w)):
for j in range(w[i], W + 1): # 正序!
dp[j] = max(dp[j], dp[j - w[i]] + v[i])
return dp[W]
// Java 完全背包
public int completeKnapsack(int[] w, int[] v, int W) {
int[] dp = new int[W + 1];
for (int i = 0; i < w.length; i++) {
for (int j = w[i]; j <= W; j++) { // 正序!
dp[j] = Math.max(dp[j], dp[j - w[i]] + v[i]);
}
}
return dp[W];
}
// C++ 完全背包
int completeKnapsack(vector<int>& w, vector<int>& v, int W) {
vector<int> dp(W + 1, 0);
for (int i = 0; i < w.size(); i++) {
for (int j = w[i]; j <= W; j++) { // 正序!
dp[j] = max(dp[j], dp[j - w[i]] + v[i]);
}
}
return dp[W];
}
0-1 背包 vs 完全背包对比表
|
维度
|
0-1 背包
|
完全背包
|
| --- | --- | --- |
|
物品选取次数
|
最多 1 次
|
无限次
|
|
一维 dp 内层遍历
|
倒序
|
正序
|
|
内层循环起始
|
j = W 到 w[i]
|
j = w[i] 到 W
|
|
先物品后背包
|
组合数
|
组合数
|
|
先背包后物品
|
排列数(较少用)
|
排列数
|
322 零钱兑换(Medium)
题目描述
给定不同面额的硬币
coins和一个总金额amount,计算可以凑成总金额所需的 最少的硬币个数。如果没有任何一种硬币组合能组成总金额,返回-1。每种硬币的数量是无限的。
示例:coins = [1, 2, 5], amount = 11,输出 3(5+5+1)
完全背包变体
-
物品:硬币(每种无限量)
-
重量:硬币面额
-
价值:1(每个硬币计数为 1)
-
背包容量:amount
-
目标:装满背包所需的最小物品个数
五步法拆解
Step 1:dp[j] 的含义——dp[j] 表示凑成金额 j 所需的最少硬币个数
Step 2:递推公式——dp[j] = min(dp[j], dp[j - coins[i]] + 1)
Step 3:初始化——dp[0] = 0,其余初始化为 amount + 1
Step 4:遍历顺序——先遍历硬币,再遍历金额(正序,完全背包模板)
Step 5:打印 dp 验证——以 coins = [1, 2, 5], amount = 11 为例
初始化: dp = [0, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12]
i=0, coin=1: dp[1]=1, dp[2]=2, ..., dp[11]=11
i=1, coin=2: dp[2]=1, dp[4]=2, ..., dp[11]=6
i=2, coin=5: dp[5]=1, dp[10]=2, dp[11]=min(6, dp[6]+1)=3
最终 dp[11] = 3 ✅
递推过程 mermaid 图
三语言代码
# Python 322 零钱兑换
def coinChange(self, coins: List[int], amount: int) -> int:
dp = [amount + 1] * (amount + 1)
dp[0] = 0
for coin in coins:
for j in range(coin, amount + 1):
dp[j] = min(dp[j], dp[j - coin] + 1)
return dp[amount] if dp[amount] != amount + 1 else -1
// Java 322 零钱兑换
public int coinChange(int[] coins, int amount) {
int[] dp = new int[amount + 1];
Arrays.fill(dp, amount + 1);
dp[0] = 0;
for (int coin : coins) {
for (int j = coin; j <= amount; j++) {
dp[j] = Math.min(dp[j], dp[j - coin] + 1);
}
}
return dp[amount] > amount ? -1 : dp[amount];
}
// C++ 322 零钱兑换
int coinChange(vector<int>& coins, int amount) {
vector<int> dp(amount + 1, amount + 1);
dp[0] = 0;
for (int coin : coins) {
for (int j = coin; j <= amount; j++) {
dp[j] = min(dp[j], dp[j - coin] + 1);
}
}
return dp[amount] > amount ? -1 : dp[amount];
}
复杂度分析
-
时间复杂度:O(n × amount)
-
空间复杂度:O(amount)
易错点
-
初始化填最大值:
dp[0] = 0,其余填amount + 1(避免Integer.MAX_VALUE溢出) -
无解返回 -1:不是返回 0
-
正序遍历:完全背包
背包问题总结
0-1 背包 vs 完全背包
| 维度 | 0-1 背包 | 完全背包 | | --- | --- | --- | | 每个物品选取次数 | 最多 1 次 | 无限次 | | 一维 dp 内层遍历 | 倒序 | 正序 | | 典型应用 | 416 分割等和子集 | 322 零钱兑换 | | 求组合数 | 先物品后背包(倒序) | 先物品后背包(正序) | | 求排列数 | 先背包后物品(倒序) | 先背包后物品(正序) |
遍历顺序规律
0-1 背包(一维):
for 物品:
for 容量 in 倒序: ← 防止重复选取
完全背包(一维,求组合数):
for 物品:
for 容量 in 正序: ← 允许重复选取
完全背包(一维,求排列数):
for 容量 in 正序:
for 物品: ← 交换遍历顺序
家族题一览
| 题目 | 类型 | 核心思路 | | --- | --- | --- | | 416 分割等和子集 | 0-1 背包(是否存在) | target = sum/2,dp[j] = dp[j] || dp[j-nums[i]] | | 322 零钱兑换 | 完全背包(最小个数) | dp[j] = min(dp[j], dp[j-coins[i]] + 1) | | 518 零钱兑换 II | 完全背包(组合数) | dp[j] += dp[j - coins[i]] | | 377 组合总和 Ⅳ | 完全背包(排列数) | 先背包后物品 | | 474 一和零 | 0-1 背包(二维容量) | 两个容量维度 | | 494 目标和 | 0-1 背包(方案数) | 转换为 left - right = target |
结语
本期核心收获:
-
0-1 背包理论基础——二维 dp 数组、递推公式、五步法拆解
-
滚动数组优化——从二维到一维,理解倒序遍历原因
-
416 分割等和子集——0-1 背包应用,boolean 类型 dp
-
完全背包理论基础——正序遍历,允许重复选取
-
322 零钱兑换——完全背包应用,求最小个数
-
背包问题总结——遍历顺序规律、家族题一览
背包问题的本质,就是在"选"与"不选"之间做最优决策。
下期预告:DP 进阶第二弹——打家劫舍系列(213 打家劫舍 II + 337 打家劫舍 III)。
参考文献
-
力扣官方题解 - 416. 分割等和子集. 416. 分割等和子集 - 力扣(LeetCode)
-
力扣官方题解 - 322. 零钱兑换. 322. 零钱兑换 - 力扣(LeetCode)
-
代码随想录 - 背包理论基础(二维 DP). 动态规划:01背包理论基础 | 动态规划 | 01背包 | 二维dp数组 | 代码随想录-全网最全算法数据结构刷题学习路线|图文+视频教程|免费开源
-
代码随想录 - 背包理论基础(滚动数组). 动态规划:01背包理论基础(滚动数组) | 动态规划 | 滚动数组 | 01背包 | 代码随想录-全网最全算法数据结构刷题学习路线|图文+视频教程|免费开源
-
代码随想录 - 完全背包理论基础. 完全背包理论基础-二维DP数组 | 完全背包 | 二维DP数组 | 状态转移 | 代码随想录-全网最全算法数据结构刷题学习路线|图文+视频教程|免费开源
-
代码随想录 - 416. 分割等和子集. 416. 分割等和子集 | 01 背包 | 动态规划 | 代码随想录-全网最全算法数据结构刷题学习路线|图文+视频教程|免费开源
-
代码随想录 - 322. 零钱兑换. 322. 零钱兑换 | 动态规划 | 完全背包 | 状态转移 | 代码随想录-全网最全算法数据结构刷题学习路线|图文+视频教程|免费开源
-
labuladong - 背包问题框架. labuladong.github.io/algo/di-lin…