关于 01 背包问题的简单解释,理解状态转移与继承的相似性
01背包问题
01 背包问题是动态规划入门的经典题目,这个问题涉及的内容并不简单,这篇文章并不直接讲状态转移,而是通过 “继承” 的机制来间接解释 “状态转移”。
注意:动态规划(Dynamic Programming)在后续称为
dp或DP
一、问题:登山装备选择
想象你是一名登山者,准备征服一座高峰。你有一个限重 10kg 的背包,面前有多种装备可以选择。每种装备都有重量和重要程度(价值),但背包容量有限。你会如何选择装备,让这次登山之旅获得最大价值?
装备清单:
| 装备 | 重量 | 价值 | 性价比(价值/重量) |
|---|---|---|---|
| 水壶 | 2kg | 6元 | 3.0 |
| 食物 | 3kg | 8元 | 2.67 |
| 帐篷 | 4kg | 9元 | 2.25 |
| 睡袋 | 5kg | 10元 | 2.0 |
这就是经典的01背包问题:每种装备只能选择 0 次或 1 次。
二、为什么简单策略会失败?
1. 按价值排序选择
- 睡袋(
10元) → 帐篷(9元) → 食物(8元) - 重量:
5+4+3=12kg > 10kg,超重了! - 只能选
睡袋+帐篷:价值19元,重量9kg
2. 按性价比排序选择
- 水壶(
3.0) → 食物(2.67) → 帐篷(2.25) → 睡袋(2.0) - 水壶+食物+帐篷:重量
2+3+4=9kg,价值6+8+9=23元 - 剩余
1kg装不下睡袋
但真的是最优吗? 如果选择 水壶+睡袋 呢?
- 重量:
2+5=7kg,价值:6+10=16元,还剩3kg - 再加食物:总重量
10kg,总价值24元!
这说明贪心策略不能保证全局最优,我们需要考虑所有可能的组合。
三、一个更聪明的解决方案
我们可以使用动态规划来解决!这个名字咋听上去很夸张,实际上就是把大问题拆成一串小问题,把小问题的答案记下来,省得重复算。
怎么理解呢?
相信你小学的时候做过这样一道题:
小明想买 18 元的书,手里有 5 元、6 元、7 元的零钱各若干张,最少需要几张?
那时候你不懂任何算法,但是你却能做出来,你的方法也简单粗暴:穷举所有可能的结果。
-
先列出所有可能(从少到多试)。
- 只用 1 张?5、6、7 都不够 18,不行。
- 用 2 张?
5+5=10,不够
5+6=11,不够
…
7+7=14,还是不够 → 2 张不行。
-
用 3 张?
把 5、6、7 像积木一样加 3 次,找到正好 18 的组合:
6 + 6 + 6 = 18 ✔️
其它 3 张组合都不是 18。现在已经没有必要计算更多张了,三张 6 就是最优解。
以上就是最简单的穷举。
但是你可能也会用面额反推张数的方法:
- 6 元需要一张 6 元,
- 7 元需要一张 7 元,
- 8 元不能计算
- 9 月不能计算
- 10 元不能计算
- 11 元需要 5 + 6 元
- 12 元需要 6 + 6 元
- 13 元需要 6 + 7 元
- 14 元需要 7 + 7 元,这里已经使用了最大面额两张,意味着下一个数额必须增加一张。
- 15 元需要 5 + 5 + 5 元
- 16 元需要 5 + 5 + 6 元
- 17 元需要 5 + 6 + 6 元
- 18 元需要 6 + 6 + 6 元
这就是动态规划!实际上这个题有一个专名的名字:硬币找零问题(Coin Change Problem),属于动态规划的典型应用。
有时候我们在看到类似问题的时候会听见另一个算法 “贪心算法”
1. 动态规划和贪心算法的区别是什么呢?
| 特征 | 贪心算法 | 动态规划 |
|---|---|---|
| 决策方式 | 局部最优选择 | 全局最优解 |
| 回溯性 | 不回头,一次决策 | 考虑所有可能组合 |
| 适用条件 | 贪心选择性质 | 最优子结构 + 重叠子问题 |
| 时间复杂度 | 通常较低 | 通常较高 |
| 解的质量 | 不保证最优 | 保证最优 |
2. 同样以硬币找零作为对比
场景:
面额[5, 6, 7],
凑11元
- 贪心思路:总是选最大面额
7 + ?→ 需要4元(无解)- 退回选
6 + 5 = 11✓
- 动态规划思路:考虑所有可能
dp[11]=min(dp[6]+1, dp[5]+1, dp[4]+1)- 最终找到
5 + 6 = 11✓
结果:这个例子中两者都能找到最优解
3. 为什么在这个题目中用动态规划,而不是贪心?
物品:
[重量10,价值60],
[重量20,价值100],
[重量30,价值120]
容量:50
- 贪心算法(按价值密度):
- 选密度最高的物品0(密度6.0)
- 剩余容量40,无法再装其他物品
- 结果:价值60 ❌
- 动态规划:
- 考虑所有组合
- 发现选择物品1+物品2更优
- 结果:价值220(100+120) ✓
接下来的问题是:为怎么知道什么时候用什么算法?
4. 何时选择哪种算法?
4.1 选择贪心算法的条件
- 问题具有贪心选择性质
- 局部最优选择能导致全局最优解
- 效率要求高
- 问题相对简单
典型应用:
- 活动选择问题
- 最小生成树(Kruskal, Prim)
- 霍夫曼编码
- 分数背包问题
4.2 选择动态规划的条件
- 最优子结构
- 问题的最优解包含子问题的最优解
- 重叠子问题
- 递归过程中会重复计算相同的子问题
- 需要保证最优解
典型应用:
- 0-1背包问题
- 最长公共子序列
- 硬币找零问题
- 编辑距离
4.3 思维模式对比
贪心算法思维:"做当前看起来最好的选择,相信这会导致全局最优"
动态规划思维:"考虑所有可能的选择,通过比较找到真正的最优解"
动态规划简单来说就是记小答案:
- 先算 1 元最少几张,2 元最少几张……直到 18 元,每一步都把答案写在小纸条上。
- 算 13 元时,直接看「13-5」「13-6」「13-7」这三张纸条的最小值 +1 即可,不用重新算。
- 最后纸条上 18 元的位置写着 3(6+6+6)。
5. 动态规划的核心思想
如果我们已经知道了"前
i-1种装备的最优解",能否推导出"前i种装备的最优解"?
我们只需要知道当前这个装备是不是能装下:
- 如果不能,那之前的就是最优解。
- 如果能,那么再看价值是哪个高:
- 如果选的价值高,就装下。
- 如果不选的价值高,就不装。
如此反复计算即可。关于这个方法的完备性(是否为全局最优解),马上会解释。
状态定义: dp[i][w] = 考虑前 i 种装备,背包容量为 w 时的最大价值
dp表示动态规划的表格,关于这个表格可以先查看后面的填表部分
状态转移方程:
如果 重量[i] > w:
dp[i][w] = dp[i-1][w] // 装不下,只能不选
否则:
dp[i][w] = max(
dp[i-1][w], // 不选第i种装备
dp[i-1][w-重量[i]] + 价值[i] // 选第i种装备
)
6. 动态规划完备性的疑问
在我学习这个算法的时候,心里有很多疑问,比如有没有可能当前物品装不下,但是当前的物品和之前的某个物品构成最优解,在算法中却被忽略的情况,即是否满足数学上的完备性?这个问题我们在这里解释。
我们可以通过归纳来理解:
首先我们画个图表,你也可以查看详细的填表过程:
| 装备\容量 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 无装备 - 0kg - 0v | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
| 水壶 - 2kg - 6v | 0 | 0 | 6 | 6 | 6 | 6 | 6 | 6 | 6 | 6 | 6 |
| 食物 - 3kg - 8v | 0 | 0 | 6 | 8 | 8 | 14 | 14 | 14 | 14 | 14 | 14 |
| 帐篷 - 4kg - 9v | 0 | 0 | 6 | 8 | 9 | 14 | 15 | 17 | 17 | 23 | 23 |
| 睡袋 - 5kg - 10v | 0 | 0 | 6 | 8 | 9 | 14 | 16 | 17 | 18 | 23 | 24 |
我们和算法填表的方向要么是从上往下,要么是从左往右,这样的方式保证了下一个值将继承上一个值,什么意思呢?
比如我们背包容量(kg)为 0、1、2、3、10 的时候,我们选择的过程是怎样的?
背包容量为 0kg 的时候,不能装任何东西,但是需要注意,不装任何东西就是最优解。
背包容量为 1kg 的时候,也不能装任何东西,也是最优解。
背包容量为 2kg 的时候,只可以装水壶,是最优解。
背包容量为 3kg 的时候,可以装食物,那么要不要装呢?
- 之前的最优解是
水壶,价值为 6v,现在可以选择的是食物,价值为 8v,相比之下,食物的价值高于水壶,所以选择食物。 - 装食物是最优解。
背包容量为 10,的时候,我们可以推断背包容量为 9 的时候,前一个背包容量所标记的价值数是最优解。
但是,真的是这样吗?
这是水平继承和垂直继承的根本区别,水平继承是贪心算法,即:当前选什么只与上一个背包容量有关。
它通过在每一步选择当前状态下看起来最优的解,逐步推导出问题的解。贪心算法的核心思想是局部最优选择,即每一步都做出当前最优的决策,而不考虑全局最优解的整体情况。
比如在计算 dp[2][5] 的时候,背包容量为 5,可以放下食物。所以基于水平继承,要放食物,但是放下食物之余还可以放水壶。水平继承不能考虑到这个问题。
基于以上,可以发现贪心算法解决这个问题的时候,存在两个问题:
-
忽略组合可能性:这个方法会用食物“替换”水壶,但实际上3kg容量下最优解应该是只选食物,而 5kg 容量下最优解是水壶+食物的组合!
-
局部最优 ≠ 全局最优:每次只考虑"当前最好的单个物品",忽略了物品组合的情况。
在动态规划中,问题不是这样解的。
动态规划是垂直继承的。
为了更好地理解动态规划,需要先理解继承问题。
2.1 动态规划中的 “继承” 问题
我们选或者不选都是在考虑继承问题。
如果我们不选当前的物品:继承上一行相同容量下的最大价值。
dp[i][w] = dp[i-1][w]
所以表格表现的位置是当前背包容量下,上一个物品的位置。
以 dp[3][2] 为例,物品为帐篷,背包容量为 2,背包容量不能放下当前的物品,所以继承上一个选择。
又以 dp[3][5]为例,物品为帐篷,背包容量为 5,背包容量可以放下当前的物品,但是当前物品的价值不如继承上一个选择的的价值,所以继承上一个选择。
如果我们选择当前的物品:继承上一行减去当前物品重量的容量下的最大价值,再加上当前物品的价值
dp[i][w] = dp[i-1][w - weight[i]] + value[i]
以dp[4][8] 为例,物品为睡袋,背包容量为 8,背包容量可以放下当前物品,所以进行决策:
-
考虑不选睡袋:继承dp
[3][8]即dp[4][8] = 17 -
考虑选睡袋:
-
睡袋占用
5kg空间,剩余容量 =8 - 5 = 3kg,需要注意的是,这个 3kg 是过去已经查出来了的,因为最开始是从 0 开始算的,所以这个数一定存在。初学者容易卡在这里:我们上一个背包容量获得了一个最优解,那为什么要从当前背包容量要减去当前物品的重量?
我们捋一下:
首先是有背包容量我们才能放物品;
其次,由于我们是从 0 开始计算的,
所以可以得出,结论一:
当前背包容量以前的任何背包容量都是有最优解的;因为我们要放入一个物品,
所以要从
当前背包容量减去当前物品的重量,获得一个在放入这个新的物品之前能够占用的重量。但是由于结论一,我们可以保证获得的这个数仍然是最优解。再次说明:这个数字是不加新物品的最优解,并且有空间放入新物品。
所以现在放入新的物品,检查新的价值是不是比之前更优,如果是就放入,如果不是就不放,保留上一步的解。
-
查找
dp[3][3]=8(前3个装备在3kg容量下的最优值),找出来之后我们加上当前物品的价值就可以了。 -
总价值 =
8 + 10 = 18
-
-
选择更优方案:
max(17, 18) = 18
你能发现,实际上动态规划特殊性在于不关心具体的组合内容,我们只需要知道最优解是多少。
或者说,无论怎么选择,要么是考虑上一次的解(价值)dp[i][w] = dp[i-1][w],要么是之前某个特定位置的解(价值)加上现在的价值作为解 dp[i][w] = dp[i-1][w - weight[i]] + value[i]。
在之前那个特定的位置,包含了以下信息:
- 这个位置的解是考虑所有组合,而不在乎具体的选择。
- 这个位置不包含新的物品
在动态规划填表过程中,每个格子 dp[i][w] 的值都不是凭空产生的,而是从之前计算的格子"继承"而来。这种继承有两种形式:
2.1.1 垂直继承(直接继承)
- 来源:
dp[i-1][w]→dp[i][w] - 含义: 在相同容量下,不选择当前物品i,直接继承前面的最优解
- 位置关系: 正上方的格子
- 决策: "我不要第i个物品,保持原来的最优解"
2.1.2 斜向继承(选择继承)
- 来源:
dp[i-1][w-weight[i]]→dp[i][w] - 含义: 选择当前物品i,从"减去物品重量后的容量"的最优解继承
- 位置关系: 左上方向的格子(向左移动weight[i]个位置)
- 决策: "我要第i个物品,从能装下它的最优状态继承"
2.1.3 继承的选择机制
每个格子都面临一个选择:
dp[i][w] = max(
dp[i-1][w], // 垂直继承(不选)
dp[i-1][w-weight[i]] + value[i] // 斜向继承(选择)
)
2.1.4 为什么叫“继承”?
- 状态延续: 每个格子都建立在前面格子的基础上
- 最优性保持: 继承的都是子问题的最优解
- 无后效性: 一旦确定继承关系,不会被后续决策影响
2.1.5 “继承”与状态转移?
在这里我用继承来帮助理解,实际上上面的描述完全是状态转移的过程。
动态规划里说的“状态转移”是用已算出的较小子问题的最优值,按照公式推出更大子问题的最优值。
形象地说:
把 dp[i] 看成一张小纸条,上面记着 “到 i 为止的最优结果”;
dp[i+1] 这张新纸条,只是把之前若干张小纸条上的数拿来加一加、比一比后写上去——这就是状态转移,
2.2 最优性原理(贝尔曼原理 Bellman Principle of Optimality)
动态规划基于贝尔曼原理(最优性原理):
一个整体最优的决策序列,它的任何一段子序列也必须是对应子问题的最优决策。简单来说就是“一个问题的最优解包含其子问题的最优解”
比如从北京到上海开车最快路线 = 北京 → 济南 → 南京 → 上海,那么其中的北京 → 济南这一段,必须是“北京到济南”这一子问题的最快路线;
是否可能存在另一条更短的北京 → 济南路线呢?
答案是不存在,如果存在,它就与“整体最快”矛盾。
其核心是:
- 把大问题拆成重叠子问题(Overlapping Subproblems)。
- 利用子问题的最优解递推或记忆化得到原问题最优解。
- 这正是动态规划(DP)的理论基石:只要满足“最优子结构 + 无后效性”,就能用贝尔曼原理写出状态转移方程。
这保证了:
- 当我们计算
dp[i][w]时,所依赖的dp[i-1][...]都已经是最优解 - 不管这些子问题对应什么具体的物品组合,它们都是最优的
- 因此基于它们计算出的
dp[i][w]也必然是最优的
四、详细填表过程
初始化DP表格
| 装备\容量 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 0(无装备) | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
第1行:只考虑水壶(2kg, 6元)
对于每个容量位置,判断是否能装下水壶:
- 容量0-1kg:装不下 → 价值0
- 容量2-10kg:能装下 → 价值6
| 装备\容量 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 0(无装备) | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
| 1(水壶) | 0 | 0 | 6 | 6 | 6 | 6 | 6 | 6 | 6 | 6 | 6 |
第2行:考虑水壶+食物(3kg, 8元)
关键决策点分析:
dp[2][3]:容量3kg
- 不选食物:
dp[1][3]= 6元(只选水壶) - 选食物:
dp[1][3-3] + 8=dp[1][0] + 8=0 + 8= 8 元 - 结论:
dp[2][3]=max(6, 8)= 8元(选食物更好)
dp[2][5]:容量5kg
- 不选食物:
dp[1][5]= 6元 - 选食物:
dp[1][5-3] + 8=dp[1][2] + 8=6 + 8= 14 元 - 结论:
dp[2][5]=max(6, 14)= 14 元(水壶+食物组合)
继续填充第2行:
| 装备\容量 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 0(无装备) | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
| 1(水壶) | 0 | 0 | 6 | 6 | 6 | 6 | 6 | 6 | 6 | 6 | 6 |
| 2(+食物) | 0 | 0 | 6 | 8 | 8 | 14 | 14 | 14 | 14 | 14 | 14 |
第3行:考虑水壶+食物+帐篷(4kg, 9元)
dp[3][4]:容量4kg
- 不选帐篷:
dp[2][4]= 8 元 - 选帐篷:
dp[2][4-4] + 9=dp[2][0] + 9=0 + 9= 9 元 - 结论:
dp[3][4]=max(8, 9)= 9 元(只选帐篷)
dp[3][7]:容量7kg
- 不选帐篷:
dp[2][7]= 14 元(水壶+食物) - 选帐篷:
dp[2][7-4] + 9=dp[2][3] + 9=8 + 9= 17 元 - 结论:
dp[3][7]=max(14, 17)= 17 元(食物+帐篷)
dp[3][9]:容量9kg
- 不选帐篷:
dp[2][9]= 14元 - 选帐篷:
dp[2][9-4] + 9=dp[2][5] + 9=14 + 9= 23 元 - 结论:
dp[3][9]=max(14, 23)= 23 元(水壶+食物+帐篷)
| 装备\容量 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 0(无装备) | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
| 1(水壶) | 0 | 0 | 6 | 6 | 6 | 6 | 6 | 6 | 6 | 6 | 6 |
| 2(+食物) | 0 | 0 | 6 | 8 | 8 | 14 | 14 | 14 | 14 | 14 | 14 |
| 3(+帐篷) | 0 | 0 | 6 | 8 | 9 | 14 | 15 | 17 | 17 | 23 | 23 |
第4行:考虑所有装备+睡袋(5kg, 10元)
dp[4][5]:容量5kg
- 不选睡袋:
dp[3][5]= 14 元 - 选睡袋:
dp[3][5-5] + 10=dp[3][0] + 10=0 + 10= 10 元 - 结论:
dp[4][5]=max(14, 10)= 14 元
dp[4][7]:容量7kg
- 不选睡袋:
dp[3][7]= 17 元(食物+帐篷) - 选睡袋:
dp[3][7-5] + 10=dp[3][2] + 10=6 + 10= 16 元 - 结论:
dp[4][7]=max(17, 16)= 17 元
dp[4][10]:容量10kg(最终答案!)
- 不选睡袋:
dp[3][10]= 23元(水壶+食物+帐篷) - 选睡袋:
dp[3][10-5] + 10=dp[3][5] + 10=14 + 10= 24 元 - 结论:
dp[4][10]=max(23, 24)= 24 元
最终DP表格
| 装备\容量 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 0(无装备) | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
| 1(水壶) | 0 | 0 | 6 | 6 | 6 | 6 | 6 | 6 | 6 | 6 | 6 |
| 2(+食物) | 0 | 0 | 6 | 8 | 8 | 14 | 14 | 14 | 14 | 14 | 14 |
| 3(+帐篷) | 0 | 0 | 6 | 8 | 9 | 14 | 15 | 17 | 17 | 23 | 23 |
| 4(+睡袋) | 0 | 0 | 6 | 8 | 9 | 14 | 16 | 17 | 18 | 23 | 24 |
🔙 回溯找出具体方案
从 dp[4][10] = 24开始回溯:
最优解:
- 选择装备:水壶(2kg,6元) + 食物(3kg,8元) + 睡袋(5kg,10元)
- 总重量:
2 + 3 + 5= 10kg(正好装满) - 总价值:
6 + 8 + 10= 24 元
五、算法复杂度分析
空间优化:
由于 dp[i] 只依赖 dp[i-1],可以压缩为一维数组:
for i in range(n):
for w in range(W, weight[i]-1, -1): # 倒序遍历
dp[w] = max(dp[w], dp[w-weight[i]] + value[i])
六、代码实现
1. 问题定义
0-1背包问题:给定n个物品,每个物品有重量wgt[i]和价值val[i],背包容量为cap。
每个物品只能选择0次或1次,求能装入背包的物品最大价值。
2. 寻找子问题结构
关键洞察:对于第 i 个物品,我们只有两种选择:
- 不选择第
i个物品 - 选择第
i个物品(前提是背包还有足够容量)
这启发我们用前 i 个物品和剩余容量 c 来定义子问题。
3. 状态定义
定义dp[i][c]:考虑前i个物品,背包容量为 c 时的最大价值
# dp[i][c] = 前i个物品,容量为c的背包能装的最大价值
4. 推导状态转移方程
对于dp[i][c],考虑第 i 个物品(索引为i-1):
情况1:物品 i 太重,装不下
if wgt[i-1] > c:
dp[i][c] = dp[i-1][c] # 只能不选,等于前i-1个物品的结果
情况2:物品i可以装下 我们要在两种方案中选择价值更大的:
- 方案A:不选物品
i,价值为dp[i-1][c] - 方案B:选择物品
i,价值为dp[i-1][c-wgt[i-1]] + val[i-1]
else:
dp[i][c] = max(dp[i-1][c], dp[i-1][c-wgt[i-1]] + val[i-1])
5. 边界条件
# 没有物品时,价值为0
dp[0][c] = 0 # 对所有c
# 背包容量为0时,价值为0
dp[i][0] = 0 # 对所有i
6. 代码实现推导
步骤1:初始化
n = len(wgt)
# 创建(n+1) × (cap+1)的表格,多一行一列处理边界
dp = [[0] * (cap + 1) for _ in range(n + 1)]
步骤2:状态转移
for i in range(1, n + 1): # 考虑前i个物品
for c in range(1, cap + 1): # 背包容量从1到cap
if wgt[i - 1] > c: # 注意:第i个物品的索引是i-1
dp[i][c] = dp[i - 1][c]
else:
dp[i][c] = max(dp[i - 1][c],
dp[i - 1][c - wgt[i - 1]] + val[i - 1])
步骤3:返回结果
return dp[n][cap] # 考虑所有n个物品,容量为cap的最大价值
7. 完整代码
def knapsack_dp(wgt: list[int], val: list[int], cap: int) -> int:
"""0-1 背包:动态规划解法
Args:
wgt: 物品重量列表
val: 物品价值列表
cap: 背包容量
Returns:
能装入背包的最大价值
"""
n = len(wgt)
# 初始化 dp 表:dp[i][c] = 前i个物品,容量c的最大价值
# 大小为(n+1) × (cap+1),第0行和第0列自动为0(边界条件)
dp = [[0] * (cap + 1) for _ in range(n + 1)]
# 状态转移:逐行填表
for i in range(1, n + 1): # 考虑前i个物品
for c in range(1, cap + 1): # 背包容量从1到cap
# 当前考虑的是第i个物品(索引为i-1)
if wgt[i - 1] > c:
# 物品i太重,装不下,只能不选
dp[i][c] = dp[i - 1][c]
else:
# 物品i能装下,在选和不选中取最大值
# 不选:dp[i-1][c]
# 选:dp[i-1][c-wgt[i-1]] + val[i-1]
dp[i][c] = max(dp[i - 1][c],
dp[i - 1][c - wgt[i - 1]] + val[i - 1])
# 返回考虑所有物品、使用全部容量的最大价值
return dp[n][cap]
8. 关键设计细节解释
为什么 dp 表大小是 (n+1)×(cap+1) ?
- 第 0 行表示"没有物品"的情况,值全为
0 - 第 0 列表示"背包容量为 0 "的情况,值全为
0 - 这样避免了边界条件的特殊处理
为什么用wgt[i-1]而不是wgt[i]?
dp表的第i行表示"前i个物品"- 但物品数组的索引从
0开始 - 所以第
i个物品在数组中的索引是i-1
状态转移的本质
dp[i-1][c]:基于前i-1个物品的最优解dp[i-1][c-wgt[i-1]] + val[i-1]:为物品i腾出空间后,加上物品i的价值
这样设计的动态规划确保了:
- 最优子结构:大问题的最优解包含子问题的最优解
- 无后效性:当前状态的选择不影响之前的状态
- 重叠子问题:通过表格避免重复计算
时间复杂度:\(O(n×cap)\),空间复杂度:\(O(n×cap)\)
可以测试运行的 Rust 示例
/// 0-1背包问题:动态规划解法
///
/// # 参数
/// * `wgt` - 物品重量切片
/// * `val` - 物品价值切片
/// * `cap` - 背包容量
///
/// # 返回值
/// * 能装入背包的最大价值
///
/// # 示例
/// ```
/// let wgt = vec![1, 3, 4];
/// let val = vec![15, 20, 30];
/// let cap = 5;
/// let result = knapsack_dp(&wgt, &val, cap);
/// assert_eq!(result, 35);
/// ```
fn knapsack_dp(wgt: &[i32], val: &[i32], cap: usize) -> i32 {
let n = wgt.len();
// 初始化 dp 表:dp[i][c] = 前i个物品,容量c的最大价值
// 大小为(n+1) × (cap+1),第0行和第0列自动为0(边界条件)
let mut dp = vec![vec![0; cap + 1]; n + 1];
// 状态转移:逐行填表
for i in 1..=n { // 考虑前i个物品
for c in 1..=cap { // 背包容量从1到cap
// 当前考虑的是第i个物品(索引为i-1)
if wgt[i - 1] as usize > c {
// 物品i太重,装不下,只能不选
dp[i][c] = dp[i - 1][c];
} else {
// 物品i能装下,在选和不选中取最大值
// 不选:dp[i-1][c]
// 选:dp[i-1][c-wgt[i-1]] + val[i-1]
let not_take = dp[i - 1][c];
let take = dp[i - 1][c - wgt[i - 1] as usize] + val[i - 1];
dp[i][c] = not_take.max(take);
}
}
}
// 返回考虑所有物品、使用全部容量的最大价值
dp[n][cap]
}
/// 优化版本:空间复杂度从O(n×cap)优化到O(cap)
/// 因为状态转移只依赖于上一行,所以可以用一维数组
fn knapsack_dp_optimized(wgt: &[i32], val: &[i32], cap: usize) -> i32 {
let n = wgt.len();
let mut dp = vec![0; cap + 1];
for i in 0..n {
// 倒序遍历,避免重复使用当前物品
for c in (wgt[i] as usize..=cap).rev() {
dp[c] = dp[c].max(dp[c - wgt[i] as usize] + val[i]);
}
}
dp[cap]
}
/// 回溯版本:不仅返回最大价值,还能知道选择了哪些物品
fn knapsack_with_items(wgt: &[i32], val: &[i32], cap: usize) -> (i32, Vec<usize>) {
let n = wgt.len();
let mut dp = vec![vec![0; cap + 1]; n + 1];
// 填充dp表
for i in 1..=n {
for c in 1..=cap {
if wgt[i - 1] as usize > c {
dp[i][c] = dp[i - 1][c];
} else {
let not_take = dp[i - 1][c];
let take = dp[i - 1][c - wgt[i - 1] as usize] + val[i - 1];
dp[i][c] = not_take.max(take);
}
}
}
// 回溯找出选择的物品
let mut selected_items = Vec::new();
let mut i = n;
let mut c = cap;
while i > 0 && c > 0 {
// 如果dp[i][c] != dp[i-1][c],说明选择了物品i
if dp[i][c] != dp[i - 1][c] {
selected_items.push(i - 1); // 存储物品索引
c -= wgt[i - 1] as usize;
}
i -= 1;
}
selected_items.reverse(); // 保持原始顺序
(dp[n][cap], selected_items)
}
fn main() {
// 基本测试
let wgt = vec![1, 3, 4];
let val = vec![15, 20, 30];
let cap = 5;
println!("=== 0-1背包问题测试 ===");
println!("物品重量: {:?}", wgt);
println!("物品价值: {:?}", val);
println!("背包容量: {}", cap);
let result1 = knapsack_dp(&wgt, &val, cap);
let result2 = knapsack_dp_optimized(&wgt, &val, cap);
let (result3, items) = knapsack_with_items(&wgt, &val, cap);
println!("\n基础版本最大价值: {}", result1);
println!("优化版本最大价值: {}", result2);
println!("回溯版本最大价值: {}, 选择的物品索引: {:?}", result3, items);
// 验证选择的物品
let selected_weight: i32 = items.iter().map(|&i| wgt[i]).sum();
let selected_value: i32 = items.iter().map(|&i| val[i]).sum();
println!("选择物品的总重量: {}, 总价值: {}", selected_weight, selected_value);
}
七、实际应用场景
1. 应用领域
2. 具体案例
案例1:投资组合优化
- 背包容量:总投资金额
- 物品:各种投资项目
- 重量:每个项目的投资金额
- 价值:预期收益
案例2:云服务器资源分配
- 背包容量:总计算资源
- 物品:不同的计算任务
- 重量:任务所需资源
- 价值:任务的重要程度
3. 算法扩展
3.1 背包问题家族
3.2 高级变种
八、总结
1. 为什么动态规划有效?
三个关键特征:
- 最优子结构:大问题的最优解包含子问题的最优解
- 重叠子问题:递归过程中相同的子问题被多次求解
- 无后效性:当前状态包含了所有相关的历史信息
2. 状态转移的直觉理解
每个 dp[i][w] 都在回答一个问题:
"面对第
i种装备,在当前背包容量w下,我应该选择它还是不选择它?"
这个决策只需要考虑两种情况:
- 不选择:继承之前的最优解
dp[i-1][w] - 选择:为当前装备腾出空间,加上它的价值
dp[i-1][w-weight[i]] + value[i]
选择其中价值更大的方案,就是当前状态的最优解。

浙公网安备 33010602011771号