关于 01 背包问题的简单解释,理解状态转移与继承的相似性

01背包问题

01 背包问题是动态规划入门的经典题目,这个问题涉及的内容并不简单,这篇文章并不直接讲状态转移,而是通过 “继承” 的机制来间接解释 “状态转移”。

注意:动态规划(Dynamic Programming)在后续称为 dpDP

一、问题:登山装备选择

想象你是一名登山者,准备征服一座高峰。你有一个限重 10kg 的背包,面前有多种装备可以选择。每种装备都有重量和重要程度(价值),但背包容量有限。你会如何选择装备,让这次登山之旅获得最大价值?

graph TD A[有一个背包] --> B[容量限制: 10kg] C[有多种装备] --> D[每种装备有重量wi] C --> E[每种装备有价值vi] B --> F[选择问题] D --> F E --> F F --> G[目标: 最大化总价值] F --> H[约束: 总重量 ≤ 10kg] G --> I[每种装备只能选0次或1次] H --> I

装备清单:

装备 重量 价值 性价比(价值/重量)
水壶 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元

这说明贪心策略不能保证全局最优,我们需要考虑所有可能的组合。

graph LR A[贪心策略的问题] --> B[按价值选择] A --> C[按重量选择] A --> D[按性价比选择] B --> E[可能超重或浪费空间] C --> F[可能总价值很低] D --> G[局部最优≠全局最优] E --> H[需要全局视角] F --> H G --> H

三、一个更聪明的解决方案

我们可以使用动态规划来解决!这个名字咋听上去很夸张,实际上就是把大问题拆成一串小问题,把小问题的答案记下来,省得重复算。

怎么理解呢?

相信你小学的时候做过这样一道题:

小明想买 18 元的书,手里有 5 元、6 元、7 元的零钱各若干张,最少需要几张

那时候你不懂任何算法,但是你却能做出来,你的方法也简单粗暴:穷举所有可能的结果。

  1. 先列出所有可能(从少到多试)。

    • 只用 1 张?5、6、7 都不够 18,不行。
    • 用 2 张?
      5+5=10,不够
      5+6=11,不够

      7+7=14,还是不够 → 2 张不行。
  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 选择贪心算法的条件

  1. 问题具有贪心选择性质
    • 局部最优选择能导致全局最优解
  2. 效率要求高
  3. 问题相对简单

典型应用

  • 活动选择问题
  • 最小生成树(Kruskal, Prim)
  • 霍夫曼编码
  • 分数背包问题

4.2 选择动态规划的条件

  1. 最优子结构
    • 问题的最优解包含子问题的最优解
  2. 重叠子问题
    • 递归过程中会重复计算相同的子问题
  3. 需要保证最优解

典型应用

  • 0-1背包问题
  • 最长公共子序列
  • 硬币找零问题
  • 编辑距离

4.3 思维模式对比

贪心算法思维:"做当前看起来最好的选择,相信这会导致全局最优"

动态规划思维:"考虑所有可能的选择,通过比较找到真正的最优解"

动态规划简单来说就是记小答案:

  1. 先算 1 元最少几张,2 元最少几张……直到 18 元,每一步都把答案写在小纸条上。
  2. 算 13 元时,直接看「13-5」「13-6」「13-7」这三张纸条的最小值 +1 即可,不用重新算
  3. 最后纸条上 18 元的位置写着 3(6+6+6)。

5. 动态规划的核心思想

如果我们已经知道了"前 i-1种装备的最优解",能否推导出"前 i 种装备的最优解"?

我们只需要知道当前这个装备是不是能装下:

  • 如果不能,那之前的就是最优解。
  • 如果能,那么再看价值是哪个高:
    • 如果选的价值高,就装下。
    • 如果不选的价值高,就不装。

如此反复计算即可。关于这个方法的完备性(是否为全局最优解),马上会解释。

graph TD A[面对第 i 种装备] --> B{背包装得下吗?} B -->|装不下| C["dp[i][w] = dp[i-1][w]"] B -->|装得下| D[两个选择比较] D --> E["不选:dp[i-1][w]"] D --> F["选择:dp[i-1][w-重量[i]] + 价值[i]"] E --> G[取最大值] F --> G G --> H["dp[i][w] = max(不选, 选择)"]

状态定义: 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)为 012310 的时候,我们选择的过程是怎样的?

背包容量为 0kg 的时候,不能装任何东西,但是需要注意,不装任何东西就是最优解。

背包容量为 1kg 的时候,也不能装任何东西,也是最优解。

背包容量为 2kg 的时候,只可以装水壶,是最优解。

背包容量为 3kg 的时候,可以装食物,那么要不要装呢?

  • 之前的最优解是水壶,价值为 6v,现在可以选择的是食物,价值为 8v,相比之下,食物的价值高于水壶,所以选择食物。
  • 装食物是最优解。

背包容量为 10,的时候,我们可以推断背包容量为 9 的时候,前一个背包容量所标记的价值数是最优解。

但是,真的是这样吗?

这是水平继承和垂直继承的根本区别,水平继承是贪心算法,即:当前选什么只与上一个背包容量有关。

它通过在每一步选择当前状态下看起来最优的解,逐步推导出问题的解。贪心算法的核心思想是局部最优选择,即每一步都做出当前最优的决策,而不考虑全局最优解的整体情况。

比如在计算 dp[2][5] 的时候,背包容量为 5,可以放下食物。所以基于水平继承,要放食物,但是放下食物之余还可以放水壶。水平继承不能考虑到这个问题。

基于以上,可以发现贪心算法解决这个问题的时候,存在两个问题:

  1. 忽略组合可能性:这个方法会用食物“替换”水壶,但实际上3kg容量下最优解应该是只选食物,而 5kg 容量下最优解是水壶+食物的组合!

  2. 局部最优 ≠ 全局最优:每次只考虑"当前最好的单个物品",忽略了物品组合的情况。

在动态规划中,问题不是这样解的。

动态规划是垂直继承的。

为了更好地理解动态规划,需要先理解继承问题。

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,背包容量可以放下当前物品,所以进行决策:

  1. 考虑不选睡袋:继承dp[3][8]dp[4][8] = 17

  2. 考虑选睡袋:

    1. 睡袋占用 5kg 空间,剩余容量 = 8 - 5 = 3kg,需要注意的是,这个 3kg 是过去已经查出来了的,因为最开始是从 0 开始算的,所以这个数一定存在。

      初学者容易卡在这里:我们上一个背包容量获得了一个最优解,那为什么要从当前背包容量要减去当前物品的重量?


      我们捋一下:

      首先是有背包容量我们才能放物品;

      其次,由于我们是从 0 开始计算的,

      所以可以得出,结论一:当前背包容量以前的任何背包容量都是有最优解的;

      因为我们要放入一个物品,

      所以要从当前背包容量减去当前物品的重量,获得一个在放入这个新的物品之前能够占用的重量。

      但是由于结论一,我们可以保证获得的这个数仍然是最优解。再次说明:这个数字是不加新物品的最优解,并且有空间放入新物品。

      所以现在放入新的物品,检查新的价值是不是比之前更优,如果是就放入,如果不是就不放,保留上一步的解。

    2. 查找 dp[3][3] = 8(前 3 个装备在 3kg 容量下的最优值),找出来之后我们加上当前物品的价值就可以了。

    3. 总价值 = 8 + 10 = 18

  3. 选择更优方案:max(17, 18) = 18

你能发现,实际上动态规划特殊性在于不关心具体的组合内容,我们只需要知道最优解是多少。

或者说,无论怎么选择,要么是考虑上一次的解(价值)dp[i][w] = dp[i-1][w],要么是之前某个特定位置的解(价值)加上现在的价值作为解 dp[i][w] = dp[i-1][w - weight[i]] + value[i]

在之前那个特定的位置,包含了以下信息:

  1. 这个位置的解是考虑所有组合,而不在乎具体的选择。
  2. 这个位置不包含新的物品

在动态规划填表过程中,每个格子 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)的理论基石:只要满足“最优子结构 + 无后效性”,就能用贝尔曼原理写出状态转移方程。

这保证了:

  1. 当我们计算dp[i][w]时,所依赖的dp[i-1][...]都已经是最优解
  2. 不管这些子问题对应什么具体的物品组合,它们都是最优的
  3. 因此基于它们计算出的 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开始回溯:

graph TB A["dp[4][10] = 24"] --> B{"24 == dp[3][10]?"} B -->|否: 24≠23| C[选了睡袋] C --> D[剩余容量: 10-5=5kg] D --> E["dp[3][5] = 14"] --> F{"14 == dp[2][5]?"} F -->|是: 14==14| G[没选帐篷] G --> H["dp[2][5] = 14"] --> I{"14 == dp[1][5]?"} I -->|否: 14≠6| J[选了食物] J --> K["剩余容量: 5-3=2kg"] K --> L["dp[1][2] = 6"] --> M{"6 == dp[0][2]?"} M -->|否: 6≠0| N[选了水壶] N --> O["最优方案: 水壶+食物+睡袋"]

最优解:

  • 选择装备:水壶(2kg,6元) + 食物(3kg,8元) + 睡袋(5kg,10元)
  • 总重量:2 + 3 + 5 = 10kg(正好装满)
  • 总价值:6 + 8 + 10 = 24 元

五、算法复杂度分析

graph TD A[算法复杂度] --> B["时间复杂度: O(n×W)"] A --> C["空间复杂度: O(n×W)"] B --> D["n=4, W=10 → 40次计算"] C --> E["可优化为O(W)滚动数组"] F[对比其他方法] --> G["暴力穷举: O(2^n)"] F --> H["贪心算法: O(n log n)"] G --> I["n=4时: 16种组合"] G --> J["n=20时: 100万种组合"] H --> K["但不能保证最优解"]

空间优化:
由于 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 的价值

这样设计的动态规划确保了:

  1. 最优子结构:大问题的最优解包含子问题的最优解
  2. 无后效性:当前状态的选择不影响之前的状态
  3. 重叠子问题:通过表格避免重复计算

时间复杂度:\(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. 应用领域

mindmap root((01背包应用)) 资源配置 投资组合优化 项目预算分配 人员任务分配 工程优化 货物装载问题 存储空间分配 网络带宽分配 游戏AI 装备选择策略 技能点分配 道具组合优化 机器学习 特征选择 模型压缩 样本选择

2. 具体案例

案例1:投资组合优化

  • 背包容量:总投资金额
  • 物品:各种投资项目
  • 重量:每个项目的投资金额
  • 价值:预期收益

案例2:云服务器资源分配

  • 背包容量:总计算资源
  • 物品:不同的计算任务
  • 重量:任务所需资源
  • 价值:任务的重要程度

3. 算法扩展

3.1 背包问题家族

graph TD A[背包问题] --> B[01背包] A --> C[完全背包] A --> D[多重背包] A --> E[分组背包] B --> F[每个物品选0或1次] C --> G[每个物品可选无限次] D --> H[每个物品有数量限制] E --> I[物品分组,每组只能选一个]

3.2 高级变种

graph TD J[高级变种] --> K[二维背包] J --> L[依赖背包] J --> M[泛化物品] K --> N[重量+体积两个约束] L --> O[物品间有依赖关系] M --> P[物品可以是其他背包问题]

八、总结

1. 为什么动态规划有效?

三个关键特征:

  1. 最优子结构:大问题的最优解包含子问题的最优解
  2. 重叠子问题:递归过程中相同的子问题被多次求解
  3. 无后效性:当前状态包含了所有相关的历史信息

2. 状态转移的直觉理解

每个 dp[i][w] 都在回答一个问题:

"面对第 i 种装备,在当前背包容量 w 下,我应该选择它还是不选择它?"

这个决策只需要考虑两种情况:

  1. 不选择:继承之前的最优解 dp[i-1][w]
  2. 选择:为当前装备腾出空间,加上它的价值 dp[i-1][w-weight[i]] + value[i]

选择其中价值更大的方案,就是当前状态的最优解。

posted @ 2025-09-01 21:25  斯坦索尼  阅读(95)  评论(0)    收藏  举报