动态规划 · 0/1 背包与完全背包
从 LC 416 出发,看背包问题在「物品能选几次」上的两个分支,以及为什么 1D 压缩要看遍历顺序。
1. 核心原理
背包 DP 处理的是一类有限资源下的选取问题:给定若干物品(带重量与价值)和一个容量上限 W,问"最大价值是多少 / 能否恰好装满 / 有多少种装法"。物品的"可选次数"决定了具体形态——只能选一次的是 0/1 背包,可以选任意次的是完全背包,限定上限 k[i] 次的是多重背包,按互斥分组挑一的是分组背包。
朴素思路是把所有子集枚举一遍,再筛容量与目标。子集数 2^n 在 n = 20 以上立刻不可行;即便剪枝,"重量 + 价值"两维约束让一般的搜索难以收敛。背包问题的核心 invariant 是最优子结构:考虑第 i 个物品时的最优值,只依赖于"前 i - 1 个物品 + 剩余容量"的最优值——这把指数级搜索压成了多项式级 DP。
状态定义
设 f[i][w] 为"考虑前 i 个物品、容量上限为 w 时的最优值(最大价值 / 可行性 / 方案数)"。这是背包 DP 最通用的二维定义。维度 i 表示物品下标的"前缀",w 表示当前剩余可装容量。状态空间 O(n × W),每个状态转移 O(1),整体 O(n × W)。
转移方程:选或不选
对第 i 个物品 (weight = w_i, value = v_i),有两种决策:
- 不选:
f[i][w] = f[i - 1][w],状态直接继承 - 选(前提
w ≥ w_i):f[i][w] = f[i - 1][w - w_i] + v_i,从"剩余容量为w - w_i、未考虑第i个物品"的状态扩展
两者取最优(求最大价值时取 max,求方案数时取 +,求可行性时取 or)。这就是 0/1 背包的标准转移。完全背包把第二项的 f[i - 1] 改成 f[i],表示"选了第 i 个之后还能继续选它"。
空间压缩与遍历方向
二维表里 f[i] 只依赖 f[i - 1],所以可以滚动到一维数组 f[w]。压缩后 f[w] = f[w](不选)vs f[w - w_i] + v_i(选)。此时遍历方向决定语义:
- 0/1 背包:
w必须倒序枚举(从W到w_i)。倒序时,更新f[w]用到的f[w - w_i]还保留着"上一行"的值,第i个物品不会被重复使用。 - 完全背包:
w必须正序枚举(从w_i到W)。正序时,f[w - w_i]已经被本轮(即考虑过第i个物品后)更新过,第i个物品可以被反复选入。
这条"倒序 vs 正序"是背包 DP 最容易写错的地方。判断标准只看一句话:物品能选几次。
四类背包一览
| 类型 | 物品能选几次 | 1D 遍历方向 | 代表题 |
|---|---|---|---|
| 0/1 背包 | 至多 1 次 | 倒序 枚举 w | LC 416 / 474 / 494 |
| 完全背包 | 任意次(含 0) | 正序 枚举 w | LC 322 / 518 / 377 / 139 / 279 |
| 多重背包 | 至多 k[i] 次 | 二进制拆分后回 0/1(倒序) | 见 §2 末尾 |
| 分组背包 | 每组至多选 1 件 | 外层枚举组、内层倒序 | 见 §2 末尾 |
三种问法对应三种 DP 形态
同一份骨架,按"问法"换初值与转移符号即可:
| 问法 | dp 类型 | 转移符号 | 初值 |
|---|---|---|---|
| "能否凑出"(boolean) | dp[w] = dp[w] or dp[w - w_i] | or | dp[0] = true,其余 false |
| "最少件数"(min) | dp[w] = min(dp[w], dp[w - w_i] + 1) | min + 1 | dp[0] = 0,其余 INF |
| "方案数"(count) | dp[w] = dp[w] + dp[w - w_i] | + | dp[0] = 1,其余 0 |
§4 的三道例题就是这三种问法的填空题。
2. 通用模板
下面给三类问法 × 两类背包的最小骨架。状态都用一维数组 f,"问法"决定初值与转移符号,"背包类型"决定 w 的遍历方向。模板里只覆盖 0/1 与完全两种主流形态;多重背包二进制拆分后回到 0/1,分组背包在外层套一个"组"循环。
def knapsack_01(weights, values, W):
"""0/1 背包:求最大价值。每件物品至多选一次"""
f = [0] * (W + 1)
for i in range(len(weights)):
# 倒序枚举 w:保证 f[w - weights[i]] 还是"上一行"
for w in range(W, weights[i] - 1, -1):
f[w] = max(f[w], f[w - weights[i]] + values[i])
return f[W]
def knapsack_complete(weights, values, W):
"""完全背包:求最大价值。每件物品可选任意次"""
f = [0] * (W + 1)
for i in range(len(weights)):
# 正序枚举 w:允许 f[w - weights[i]] 已包含第 i 个物品
for w in range(weights[i], W + 1):
f[w] = max(f[w], f[w - weights[i]] + values[i])
return f[W]// 0/1 背包:求最大价值
static int knapsack01(int[] weights, int[] values, int W) {
int[] f = new int[W + 1];
for (int i = 0; i < weights.length; i++) {
// 倒序枚举 w:保证 f[w - weights[i]] 还是"上一行"
for (int w = W; w >= weights[i]; w--) {
f[w] = Math.max(f[w], f[w - weights[i]] + values[i]);
}
}
return f[W];
}
// 完全背包:求最大价值
static int knapsackComplete(int[] weights, int[] values, int W) {
int[] f = new int[W + 1];
for (int i = 0; i < weights.length; i++) {
// 正序枚举 w:允许 f[w - weights[i]] 已包含第 i 个物品
for (int w = weights[i]; w <= W; w++) {
f[w] = Math.max(f[w], f[w - weights[i]] + values[i]);
}
}
return f[W];
}// 0/1 背包:求最大价值
int knapsack01(const vector<int>& weights, const vector<int>& values, int W) {
vector<int> f(W + 1, 0);
for (int i = 0; i < (int)weights.size(); i++) {
// 倒序枚举 w:保证 f[w - weights[i]] 还是"上一行"
for (int w = W; w >= weights[i]; w--) {
f[w] = max(f[w], f[w - weights[i]] + values[i]);
}
}
return f[W];
}
// 完全背包:求最大价值
int knapsackComplete(const vector<int>& weights, const vector<int>& values, int W) {
vector<int> f(W + 1, 0);
for (int i = 0; i < (int)weights.size(); i++) {
// 正序枚举 w:允许 f[w - weights[i]] 已包含第 i 个物品
for (int w = weights[i]; w <= W; w++) {
f[w] = max(f[w], f[w - weights[i]] + values[i]);
}
}
return f[W];
}模板里要注意四点:
- 问法决定初值与符号:求最大价值用
max+ 初值全 0;求"能否凑出"用or+ 初值f[0] = true其余 false;求方案数用++ 初值f[0] = 1其余 0;求最少件数用min+ 初值f[0] = 0其余INF。 W的下界:内层循环只到weights[i],再小就装不下当前物品,跳过。INF的取值:求最少件数时不要用Integer.MAX_VALUE,INF + 1会溢出。用amount + 1或W + 1等"题目上界 + 1"即可。- 多重背包与分组背包:多重背包对单种物品
(w, v, k)做二进制拆分(拆成1, 2, 4, ..., 2^p, 余数几堆),每堆当一个独立物品塞进 0/1 背包;分组背包外层枚举"组",组内枚举本组要选哪一件,仍然倒序。
多重背包:二进制拆分回 0/1
每种物品有 k[i] 件可选(不是 1 件、也不是无限)。朴素做法把每件都当独立物品塞进 0/1 背包,O(n · W · sum(k[i])),当 k[i] 很大时会爆。经典技巧是二进制拆分:把 k[i] 件物品拆成 1, 2, 4, 8, ..., 2^m, (k[i] - 2^(m+1) + 1) 这几堆,每堆当一个"新物品"(重量、价值都是单件的对应倍数),再跑标准 0/1 背包。
# 二进制拆分 O(n · W · log k_max)
items = [] # 拆分后的新物品列表
for w, v, k in zip(weights, values, counts):
p = 1
while p <= k:
items.append((w * p, v * p))
k -= p
p *= 2
if k > 0:
items.append((w * k, v * k)) # 剩余件数也算一堆
# 然后就是标准 0/1 背包
for w, v in items:
for j in range(W, w - 1, -1):
dp[j] = max(dp[j], dp[j - w] + v)正确性:任意 0 ≤ x ≤ k[i] 都能用拆出来的几堆"二进制位组合"凑出来。比如 k = 13 拆成 [1, 2, 4, 6],选 11 件 = 1 + 4 + 6,选 7 件 = 1 + 2 + 4,都能办到。复杂度 O(n · W · log k_max),应对 k = 10⁹ 也游刃有余。更高级的单调队列优化能把单组物品压到 O(W)、整体 O(n · W),面试基本不要求会写,了解即可。
分组背包:组内互斥选一
物品被分成若干组,每组里最多选一件。骨架是"外层枚举组、组内枚举本组要选哪一件、w 仍倒序":
# 分组背包:groups[g] = [(w, v), ...]
for g in range(num_groups):
for w in range(W, -1, -1): # 倒序:保证本组只算一件
for wi, vi in groups[g]:
if w >= wi:
dp[w] = max(dp[w], dp[w - wi] + vi)关键是"w 倒序"在外层、"组内枚举"在最内层——内层连续比较"本组的每件",只更新一次 dp[w],保证本组至多取一件。
记住三种背包的核心区别一句话:0/1 倒序、完全正序、多重二进制拆分回 0/1。
3. 母题精讲
LeetCode 416. 分割等和子集
链接:LeetCode 416
题意:给一个只含正整数的非空数组 nums,判断能否把它分成两个子集,使两个子集的元素和相等。
数据范围:
nums.length:1 到 200nums[i]:1 到 100
示例:
输入:nums = [1, 5, 11, 5]
输出:true
解释:可以分成 [1, 5, 5] 和 [11],两边和都是 11。
输入:nums = [1, 2, 3, 5]
输出:false暴力解:枚举所有子集
转化:设 total = sum(nums)。要分成两个和相等的子集,等价于找一个子集,其和等于 total / 2。如果 total 是奇数,直接返回 false。
最朴素的写法是递归 include/exclude——对每个 nums[i] 决定"放进子集"或"不放",枚举所有 2ⁿ 种组合:
class Solution:
def canPartition_brute(self, nums: list[int]) -> bool:
total = sum(nums)
if total % 2:
return False
target = total // 2
def dfs(i, cur):
if cur == target:
return True
if i == len(nums) or cur > target:
return False
return dfs(i + 1, cur + nums[i]) or dfs(i + 1, cur)
return dfs(0, 0)时间 O(2ⁿ),n = 200 时 2²⁰⁰ —— 完全不可行。即使加 memoization,状态数是 O(n × target),target 最大 200 × 100 / 2 = 10⁴,状态数 2 × 10⁶,可以接受。
关键观察:DP 状态空间是 O(n × sum)
把 dfs(i, cur) 用一个 2D 表存下来——dp[i][j] 表示"用前 i 个物品能否凑出和 j"。
dp[i][j] = dp[i-1][j] # 不选 nums[i-1]
or dp[i-1][j - nums[i-1]] # 选 nums[i-1],前提是 j >= nums[i-1]边界:dp[0][0] = true(0 个物品凑出 0 是可以的),dp[0][j > 0] = false。
这就是经典的 0/1 背包——每个物品最多选一次。下面看完整 DP 表怎么填出来。
LC 416 上 nums=[1,5,11,5] 的 0/1 背包 DP 表 (target=11)
dp[i][j] = 用前 i 个物品能否凑出和 j。红 = true,蓝 = false
复杂度从 O(2ⁿ) 砍到 O(n × target) = 2 × 10⁶,毫秒级。下面把这个套路拆开看。
4. 例题(三道)
下面三道按"问法递增"排:boolean → min → count。每道带 follow-up 链。
4.1 LC 416 分割等和子集
回到本章开头那道题。total = sum(nums) 奇数直接返回 false;偶数时跑 0/1 背包,target = total / 2:
class Solution:
def canPartition(self, nums: list[int]) -> bool:
total = sum(nums)
if total % 2:
return False
target = total // 2
dp = [False] * (target + 1)
dp[0] = True
for x in nums:
for j in range(target, x - 1, -1): # 倒序
dp[j] = dp[j] or dp[j - x]
if dp[target]: # 提前结束
return True
return dp[target]class Solution {
public boolean canPartition(int[] nums) {
int total = 0;
for (int x : nums) total += x;
if ((total & 1) == 1) return false;
int target = total >> 1;
boolean[] dp = new boolean[target + 1];
dp[0] = true;
for (int x : nums) {
// 倒序:避免本轮 dp[j-x] 被同轮先更新过的值污染
for (int j = target; j >= x; j--) {
if (dp[j - x]) dp[j] = true;
}
if (dp[target]) return true;
}
return dp[target];
}
}Java 工业级细节:(total & 1) == 1 比 total % 2 == 1 略快(位运算 vs 取模);total >> 1 同理。对 LC 数据范围 n ≤ 200, x ≤ 100,total ≤ 20000,int 充分。
class Solution {
public:
bool canPartition(vector<int>& nums) {
int total = 0;
for (int x : nums) total += x;
if ((total & 1) == 1) return false;
int target = total >> 1;
vector<bool> dp(target + 1, false);
dp[0] = true;
for (int x : nums) {
// 倒序:避免本轮 dp[j-x] 被同轮先更新过的值污染
for (int j = target; j >= x; j--) {
if (dp[j - x]) dp[j] = true;
}
if (dp[target]) return true;
}
return dp[target];
}
};复杂度 O(n × target) ≈ 200 × 10⁴ = 2 × 10⁶,毫秒级。
Follow-up:上面是"能否凑出"的 boolean 版本。如果题目改成"凑出 amount 至少需要几个硬币(每种硬币可以用任意多次)",问法从 boolean 变 min、物品从 0/1 变成完全 ——这就是 LC 322。
4.2 LC 322 零钱兑换
链接:LeetCode 322
题意:给一个面额数组 coins 和金额 amount,每种面额硬币可以用无限多个。求凑出 amount 至少需要几个硬币;凑不出返回 -1。
数据范围:
coins.length:1 到 12coins[i]:1 到 2³¹ − 1amount:0 到 10⁴
示例:
输入:coins = [1, 2, 5], amount = 11
输出:3
解释:11 = 5 + 5 + 1完全背包 + min 问法。把 dp[j] 初始化成 INF(凑不出),dp[0] = 0:
class Solution:
def coinChange(self, coins: list[int], amount: int) -> int:
INF = amount + 1
dp = [INF] * (amount + 1)
dp[0] = 0
for c in coins:
for j in range(c, amount + 1): # 正序
dp[j] = min(dp[j], dp[j - c] + 1)
return dp[amount] if dp[amount] <= amount else -1class Solution {
public int coinChange(int[] coins, int amount) {
int INF = amount + 1; // 不能用 Integer.MAX_VALUE
int[] dp = new int[amount + 1];
Arrays.fill(dp, INF);
dp[0] = 0;
for (int c : coins) {
for (int j = c; j <= amount; j++) {
dp[j] = Math.min(dp[j], dp[j - c] + 1);
}
}
return dp[amount] > amount ? -1 : dp[amount];
}
}class Solution {
public:
int coinChange(vector<int>& coins, int amount) {
int INF = amount + 1; // 不能用 INT_MAX
vector<int> dp(amount + 1, INF);
dp[0] = 0;
for (int c : coins) {
for (int j = c; j <= amount; j++) {
dp[j] = min(dp[j], dp[j - c] + 1);
}
}
return dp[amount] > amount ? -1 : dp[amount];
}
};复杂度 O(len(coins) × amount) ≈ 12 × 10⁴ = 1.2 × 10⁵。
注意 INF 用 amount + 1 而不是 Integer.MAX_VALUE:Python 用 amount + 1 是为了避免 float('inf') + 1 这种特例;Java / C++ / Go 必须用 amount + 1,否则 INF + 1 会溢出 int 范围导致 min 算出负数得到错误答案——这是 LC 322 三语言都要避开的工程坑。
Follow-up:把"最少几个硬币"换成"有几种凑法",问法从 min 换成 count——这就是 LC 518。
4.3 LC 518 零钱兑换 II
链接:LeetCode 518
题意:给一个面额数组 coins(每种无限多)和金额 amount。计算凑出 amount 的硬币组合数。
数据范围:
coins.length:1 到 300coins[i]:1 到 5000amount:0 到 5000- 题目保证答案在 32 位整数范围内
示例:
输入:amount = 5, coins = [1, 2, 5]
输出:4
解释:4 种凑法:
5 = 5
5 = 2 + 2 + 1
5 = 2 + 1 + 1 + 1
5 = 1 + 1 + 1 + 1 + 1完全背包 + count 问法。dp[0] = 1(凑出 0 的方法只有"什么都不选"一种):
class Solution:
def change(self, amount: int, coins: list[int]) -> int:
dp = [0] * (amount + 1)
dp[0] = 1
for c in coins: # 外层是硬币
for j in range(c, amount + 1): # 内层是金额,正序
dp[j] += dp[j - c]
return dp[amount]下面演示 coins = [1, 2, 5], amount = 5 时 dp 数组每过一枚硬币的演化(颜色:金 = 该轮新更新的格子;红 = dp[0] = 1 起点;蓝 = 已稳定的格子):
LC 518 — dp 数组随每枚硬币逐轮更新
组合数问法:外层硬币、内层金额正序、dp[0] = 1
注意外层是 coins、内层是 amount——交换循环顺序会算出"排列数"而不是"组合数",这是 §5 重点拆解的坑。
复杂度 O(len(coins) × amount)。
关键陷阱:外层循环必须是硬币、内层是金额,不能颠倒。
如果颠倒成"外层金额、内层硬币",得到的是排列数(不同顺序算不同方案),不是题目要的组合数。这就是 LC 377 组合总和 IV 的区别——LC 377 把顺序算不同,所以它的外层是金额、内层是硬币。
5. 边界、易错与复杂度
0/1 背包必须倒序,完全背包必须正序
这是本章最容易出错的点。判断标准是"一件物品要不要被本轮重复使用":
- 倒序枚举
j→dp[j - w]还是上一轮的值(不含本物品),所以本物品至多被选一次 = 0/1 背包 - 正序枚举
j→dp[j - w]可能已经更新过(含本物品),所以本物品可以被多选 = 完全背包
写代码前先在草稿上回答"这道题物品能选几次",再决定方向。
组合数 vs 排列数:循环嵌套顺序
dp[j] += dp[j - c] 的循环顺序决定结果是组合还是排列:
- 外硬币、内金额 → 组合(LC 518,每种硬币只在外层被"引入"一次)
- 外金额、内硬币 → 排列(LC 377,每个金额都遍历所有硬币意味着不同顺序被计数)
记不住的话,每次写完跑一遍 coins=[1,2], amount=3:组合数应该是 2 ([1,1,1] 和 [1,2]),排列数应该是 3 ([1,1,1] / [1,2] / [2,1])。
初值的三种含义
| 问法 | dp[0] 初值 | 其他位置初值 |
|---|---|---|
| boolean | true | false |
| min | 0 | INF(或 amount + 1) |
| count | 1 | 0 |
dp[0] 永远表示"凑出 0 的合法状态"——不选任何物品是合法的,所以 boolean 是 true、最少件数是 0、方案数是 1(只有空选这一种)。
INF 的选择
min 问法用 INF 占位时,避免用 Integer.MAX_VALUE:在 dp[j - c] + 1 时会溢出。用题目数据范围 + 1(如 amount + 1)就够大且不溢出。
多维 0/1 背包
LC 474 一和零是二维容量:背包有"0 的数量"和"1 的数量"两个维度。模板扩展成 dp[c0][c1],倒序枚举两个维度即可。复杂度 O(n × m × len)。
复杂度速查
| 题 | 时间 | 空间 |
|---|---|---|
| LC 416 | O(n × sum / 2) | O(sum / 2) |
| LC 322 | O(len × amount) | O(amount) |
| LC 518 | O(len × amount) | O(amount) |
7. 小结
背包问题的核心选择:物品能选几次决定 1D 压缩的遍历方向(0/1 倒序、完全正序);问法(能否 / 最少 / 方案数)决定初值 + 递推符号。组合数和排列数的差异藏在循环嵌套顺序上。这套模板覆盖 80% 的背包题。
对应灵神题单:背包入门 / 0-1 背包 / 完全背包 / 多维背包
进一步阅读:LeetCode 动态规划题单 下背包标签