概述
前面两篇我们已经讲了动态规划的基础思路和线性 DP。
如果说线性 DP 主要训练的是:
text
沿着一条线往前推
那么背包问题训练的就是:
text
在有限容量里做选择
背包问题非常经典,也非常容易让初学者卡住。原因不在于代码多复杂,而在于它同时考察三件事:
- 状态怎么定义
- 物品能不能重复选
- 一维优化时循环顺序怎么写
本篇重点讲两个最基础、最重要的模型:
01 背包完全背包
学完这篇,你应该能分清 01 背包和完全背包,并能独立写出二维 DP、一维优化和循环顺序。
核心概念:什么是背包问题
背包问题通常有这样一个背景:
有一个容量为
bagSize的背包,有若干物品,每个物品有重量和价值,问在容量限制内能获得的最大价值。
比如:
text
物品重量:1, 3, 4
物品价值:15, 20, 30
背包容量:4
你要决定哪些物品放进背包,让总重量不超过 4,同时总价值最大。
背包问题的三个关键变量
- 物品下标
i:当前考虑到第几个物品 - 背包容量
j:当前背包最多还能装多少重量 - 状态值
dp:在当前条件下能得到的最大价值
背包问题的本质
背包问题的本质不是"装东西",而是"做选择":
- 当前物品选不选
- 当前物品能不能重复选
- 选了之后容量怎么变化
背包 DP 的本质,是在容量限制下,枚举物品选择并保存最优结果。
01 背包:每个物品最多只能选一次
先看最经典的 01 背包。
它的限制是:
text
每个物品只有一个,要么选,要么不选
所以叫 01,意思是每个物品只有两种状态:
0:不选1:选
问题示例
text
weights = [1, 3, 4]
values = [15, 20, 30]
bagSize = 4
可选方案包括:
- 选重量
1的物品,价值15 - 选重量
3的物品,价值20 - 选重量
4的物品,价值30 - 选重量
1 + 3的两个物品,价值15 + 20 = 35
所以最大价值是 35。
01 背包二维 DP:先把状态定义清楚
初学背包时,建议先写二维 DP。
二维 DP 更容易理解,因为状态含义比较直观。
状态定义
定义:
text
dp[i][j] 表示从下标 0 到 i 的物品里任意选择,放进容量为 j 的背包,能得到的最大价值
这里有两个维度:
i:考虑到哪个物品j:当前背包容量
状态转移
对于第 i 个物品,有两种选择。
1. 不选第 i 个物品
如果不选第 i 个物品,那么结果直接继承上一行:
text
dp[i][j] = dp[i - 1][j]
2. 选第 i 个物品
如果选第 i 个物品,前提是当前容量 j 放得下它。
选了之后,背包剩余容量是:
text
j - weights[i]
所以价值是:
text
dp[i - 1][j - weights[i]] + values[i]
合并转移
因此:
text
dp[i][j] = max(
dp[i - 1][j],
dp[i - 1][j - weights[i]] + values[i]
)
注意第二项只有在 j >= weights[i] 时才成立。
当前物品只有选和不选两种情况,选了就只能从上一层状态转移过来。
01 背包二维代码实现
java
class Solution {
public int zeroOneKnapsack(int[] weights, int[] values, int bagSize) {
int n = weights.length;
int[][] dp = new int[n][bagSize + 1];
for (int j = weights[0]; j <= bagSize; j++) {
dp[0][j] = values[0];
}
for (int i = 1; i < n; i++) {
for (int j = 0; j <= bagSize; j++) {
dp[i][j] = dp[i - 1][j];
if (j >= weights[i]) {
dp[i][j] = Math.max(
dp[i][j],
dp[i - 1][j - weights[i]] + values[i]
);
}
}
}
return dp[n - 1][bagSize];
}
}
初始化为什么这样写
第一行 dp[0][j] 表示只考虑第 0 个物品。
如果容量 j 能装下第 0 个物品,那么最大价值就是 values[0]。
所以:
java
for (int j = weights[0]; j <= bagSize; j++) {
dp[0][j] = values[0];
}
为什么返回 dp[n - 1][bagSize]
因为我们最终要考虑所有物品,并且背包容量是 bagSize。
所以答案就是:
text
dp[n - 1][bagSize]
二维 01 背包最重要的是看懂 dp[i][j] 代表"前 i 个物品在容量 j 下的最优解"。
01 背包一维优化:为什么可以压缩空间
观察二维转移:
text
dp[i][j] 只依赖 dp[i - 1][j] 和 dp[i - 1][j - weights[i]]
也就是说,第 i 行只依赖上一行。
因此可以把二维数组压缩成一维数组。
一维状态定义
定义:
text
dp[j] 表示容量为 j 的背包,当前能得到的最大价值
转移写成:
text
dp[j] = max(dp[j], dp[j - weights[i]] + values[i])
关键问题:为什么容量要倒序遍历
01 背包一维优化时,容量必须倒序遍历:
java
for (int j = bagSize; j >= weights[i]; j--) {
dp[j] = Math.max(dp[j], dp[j - weights[i]] + values[i]);
}
原因是:
text
每个物品只能选一次
如果正序遍历,dp[j - weights[i]] 可能已经在本轮被当前物品更新过。
这样就会导致同一个物品被重复使用。
01 背包倒序遍历,是为了保证当前物品不会在同一轮被重复选择。
01 背包一维代码实现
java
class Solution {
public int zeroOneKnapsack(int[] weights, int[] values, int bagSize) {
int[] dp = new int[bagSize + 1];
for (int i = 0; i < weights.length; i++) {
for (int j = bagSize; j >= weights[i]; j--) {
dp[j] = Math.max(dp[j], dp[j - weights[i]] + values[i]);
}
}
return dp[bagSize];
}
}
一维写法的理解方式
外层枚举物品,内层枚举容量。
对于每个物品,都尝试把它放进不同容量的背包里。
如果放进去能让价值更大,就更新 dp[j]。
一维 01 背包的模板很短,但倒序遍历是不能改的核心规则。
完全背包:每个物品可以选无限次
接着看 完全背包。
它和 01 背包最关键的区别是:
text
每个物品可以重复选择
比如有一个重量为 2、价值为 5 的物品,只要容量允许,就可以选多次。
问题示例
text
weights = [1, 3, 4]
values = [15, 20, 30]
bagSize = 4
如果每个物品可以无限选,那么最优方案可能是:
text
选 4 个重量为 1 的物品,总价值 60
这和 01 背包完全不同。
完全背包允许同一个物品重复使用,所以转移来源和循环顺序都会变化。
完全背包二维 DP:和 01 背包的关键差异
二维完全背包也可以这样定义:
text
dp[i][j] 表示从下标 0 到 i 的物品里任意选择,放进容量为 j 的背包,能得到的最大价值
不选第 i 个物品时:
text
dp[i][j] = dp[i - 1][j]
选第 i 个物品时,注意这里不是从 dp[i - 1] 转移,而是从 dp[i] 转移:
text
dp[i][j] = dp[i][j - weights[i]] + values[i]
因为第 i 个物品可以继续选。
合并转移
text
dp[i][j] = max(
dp[i - 1][j],
dp[i][j - weights[i]] + values[i]
)
这里和 01 背包最大的区别是第二项:
| 类型 | 选当前物品时的来源 | 原因 |
|---|---|---|
| 01 背包 | dp[i - 1][j - weights[i]] |
当前物品只能选一次 |
| 完全背包 | dp[i][j - weights[i]] |
当前物品可以重复选 |
01 背包选当前物品后看上一行,完全背包选当前物品后仍然可以看当前行。
完全背包一维代码实现
完全背包一维写法最常见,也最重要。
java
class Solution {
public int completeKnapsack(int[] weights, int[] values, int bagSize) {
int[] dp = new int[bagSize + 1];
for (int i = 0; i < weights.length; i++) {
for (int j = weights[i]; j <= bagSize; j++) {
dp[j] = Math.max(dp[j], dp[j - weights[i]] + values[i]);
}
}
return dp[bagSize];
}
}
为什么完全背包容量要正序遍历
完全背包允许同一个物品重复选择。
所以我们希望 dp[j - weights[i]] 可以使用本轮已经更新过的结果。
这正好对应正序遍历。
如果倒序遍历,就会变成"当前物品只能用一次",那就退化成 01 背包了。
完全背包正序遍历,是为了允许当前物品在同一轮被重复选择。
01 背包和完全背包的模板对比
这两个模板长得非常像,区别主要在内层循环方向。
01 背包模板
java
for (int i = 0; i < n; i++) {
for (int j = bagSize; j >= weights[i]; j--) {
dp[j] = Math.max(dp[j], dp[j - weights[i]] + values[i]);
}
}
完全背包模板
java
for (int i = 0; i < n; i++) {
for (int j = weights[i]; j <= bagSize; j++) {
dp[j] = Math.max(dp[j], dp[j - weights[i]] + values[i]);
}
}
对比表
| 类型 | 每个物品可选次数 | 容量遍历方向 | 核心原因 |
|---|---|---|---|
| 01 背包 | 最多一次 | 倒序 | 防止同一物品重复使用 |
| 完全背包 | 无限次 | 正序 | 允许同一物品重复使用 |
判断背包类型时,先看物品能选几次,再决定容量循环方向。
组合问题和排列问题:循环顺序还有另一层含义
很多背包题不一定问最大价值,而是问方案数。
这时循环顺序还会影响"组合"和"排列"。
组合问题
如果不关心顺序,例如:
text
1 + 2 和 2 + 1 算同一种方案
通常先遍历物品,再遍历容量:
java
for (int i = 0; i < nums.length; i++) {
for (int j = nums[i]; j <= target; j++) {
dp[j] += dp[j - nums[i]];
}
}
排列问题
如果关心顺序,例如:
text
1 + 2 和 2 + 1 算两种方案
通常先遍历容量,再遍历物品:
java
for (int j = 1; j <= target; j++) {
for (int i = 0; i < nums.length; i++) {
if (j >= nums[i]) {
dp[j] += dp[j - nums[i]];
}
}
}
为什么顺序会影响结果
因为 dp[j] 的更新顺序决定了方案是按"物品集合"统计,还是按"选择顺序"统计。
求组合通常物品在外,求排列通常容量在外。
常见题型:背包不一定长得像背包
很多题表面上看不出背包,但本质都是背包模型。
01 背包常见变形
- 每个数字只能用一次
- 选一些元素凑成目标和
- 把数组分成两个和相等的子集
- 每个物品只能选或不选
典型状态:
text
dp[j] 表示容量为 j 时的最优值或可行性
完全背包常见变形
- 硬币可以无限使用
- 数字可以重复选择
- 求凑成目标值的最少数量
- 求凑成目标值的方案数
典型状态:
text
dp[j] 表示凑成 j 的最优值、最少数量或方案数
只要题目出现"容量、目标值、选或不选、能否重复选",就应该联想到背包。
背包问题的状态类型
背包不只可以求最大价值,还可以求很多不同目标。
| 问法 | dp[j] 含义 |
初始化思路 |
|---|---|---|
| 最大价值 | 容量为 j 的最大价值 |
通常初始化为 0 |
| 是否可达 | 是否能凑出容量 j |
dp[0] = true |
| 方案数量 | 凑出 j 的方案数 |
dp[0] = 1 |
| 最少物品数 | 凑出 j 的最少数量 |
dp[0] = 0,其他设为较大值 |
是否可达示例
java
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];
}
}
这里倒序遍历,说明每个 num 只能用一次。
最少数量示例
java
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);
}
}
这里正序遍历,说明每个 coin 可以重复使用。
背包题先判断求什么,再决定 dp[j] 存最大值、布尔值、方案数还是最少数量。
常见问题点
1. 分不清 01 背包和完全背包
最核心的问题是:
text
当前物品能不能重复选?
如果不能重复选,就是 01 背包。
如果可以重复选,就是完全背包。
2. 一维优化时循环方向写反
这是最常见错误。
- 01 背包:容量倒序
- 完全背包:容量正序
3. 初始化不符合题意
不同问题初始化不同:
- 最大价值通常初始化为
0 - 可行性问题通常
dp[0] = true - 方案数问题通常
dp[0] = 1 - 最小值问题通常先填充一个较大值
4. 忘记判断容量是否够
二维写法里选当前物品时,一定要判断:
text
j >= weights[i]
一维写法可以通过循环起点规避这个问题。
5. 把组合数和排列数混在一起
求方案数时,一定要看题目是否关心顺序。
如果题目说 [1, 2] 和 [2, 1] 算不同方案,那就是排列。
如果只关心选了哪些数,那就是组合。
背包题大多数错误都来自类型判断、循环方向和初始化。
复杂度分析:背包 DP 的成本怎么看
设:
- 物品数量为
n - 背包容量为
bagSize
二维写法
时间复杂度:
text
O(n * bagSize)
空间复杂度:
text
O(n * bagSize)
一维优化
时间复杂度仍然是:
text
O(n * bagSize)
空间复杂度优化为:
text
O(bagSize)
为什么时间没有变
因为物品和容量仍然都要枚举一遍。
一维优化只是减少了保存状态的空间。
背包 DP 通常是物品数乘容量,一维优化主要优化空间,不改变时间复杂度。
标准思考流程:遇到背包题应该怎么想
建议按下面顺序分析。
第一步:判断是不是背包
看题目里有没有这些关键词:
- 目标和
- 容量限制
- 选一些元素
- 每个元素能不能重复用
- 最大值、最小值、方案数、可行性
第二步:判断物品能选几次
text
只能选一次 -> 01 背包
可以无限选 -> 完全背包
第三步:定义 dp[j]
想清楚:
text
dp[j] 表示什么?
它可能表示:
- 最大价值
- 是否能凑出
- 凑出方案数
- 最少使用数量
第四步:确定初始化
初始化一定要服务于 dp[j] 的含义。
第五步:确定循环顺序
text
01 背包:物品在外,容量倒序
完全背包:物品在外,容量正序
排列问题:容量在外,物品在内
背包题不要先背代码,先判断类型、状态、初始化和循环顺序。
模板总结
01 背包最大价值模板
java
int[] dp = new int[bagSize + 1];
for (int i = 0; i < n; i++) {
for (int j = bagSize; j >= weights[i]; j--) {
dp[j] = Math.max(dp[j], dp[j - weights[i]] + values[i]);
}
}
return dp[bagSize];
完全背包最大价值模板
java
int[] dp = new int[bagSize + 1];
for (int i = 0; i < n; i++) {
for (int j = weights[i]; j <= bagSize; j++) {
dp[j] = Math.max(dp[j], dp[j - weights[i]] + values[i]);
}
}
return dp[bagSize];
01 背包可行性模板
java
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];
完全背包最少数量模板
java
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];
背包模板看起来像几行代码,但每一行都对应着物品次数、状态含义和循环顺序。
总结
背包问题是动态规划里非常重要的一类题。
它的难点不是公式多,而是模型判断和循环顺序。
你可以重点记住下面几句话:
- 01 背包:每个物品最多选一次
- 完全背包:每个物品可以重复选
- 01 背包一维优化时容量倒序遍历
- 完全背包一维优化时容量正序遍历
- 最大价值、可行性、方案数、最少数量对应不同
dp[j]含义 - 求组合通常物品在外,求排列通常容量在外
- 初始化必须和状态定义严格对应
背包问题刚开始学会觉得绕,但一旦把"能不能重复选"和"循环顺序"这两个点打通,很多题都会变成同一套模板的变形。