“容量从大到小”这句话背下来很简单,但真正让我彻底搞明白“01背包为什么要这样写”,是在反复推演和踩坑之后。今天这篇文章,我不打算只复述教科书上的公式——那东西到处都有,而是把这几年我见过、踩过、帮别人排查过的关于 01 背包和它的“一维数组优化”的问题,掰开揉碎地讲清楚:从最原始的思路,到二维 DP,再到为什么可以压成一维,以及那个经典的“逆序更新”到底在防止什么。
如果你正处于“看得懂代码,但让我自己写就懵”的阶段,这篇文章就是给你准备的。我会把每一步的“为什么”都点破,告诉你代码背后的脑回路是怎样的,并且附上我自己实测踩坑记录和排查经验,夸大了说,看懂这篇文章,撞上背包问题的面试题或者程序设计作业,你可以不慌不忙地手写 AC。
1. 问题定义:先搞清楚我们要解决什么
1.1 一句话说清楚 01 背包问题
想象你走进一家便利店,钱包里只有一个背包,容量有限(比如刚好塞下 8 公斤的东西),货架上有几件商品,每件商品有自己的重量和价值。你要决定“拿哪些”,使得背包里装下的总价值最大。
这里的“01”是非常关键的限定条件:** 每件物品只能拿一次,要么拿,要么不拿,没有“拿半个”或者“拿两次”这种事情。** 这对应着现实里很多决策场景:选课(选或不选一门课,但学分和精力有限)、项目排期(做或不做某个项目,但人手资源有限)、资源分配(给某个任务分配或不分配某块硬件资源)。
所以,01 背包的正式定义可以写成:
- 有 N 件物品,每件物品有重量
w[i]和价值v[i]。 - 背包总容量为
C。 - 目标:在总重量不超过
C的前提下,挑选若干件物品,使它们的总价值最大。 - 约束:每件物品要么选(状态为 1),要么不选(状态为 0)。
这样一拆解,你会发现它本质是一个组合优化问题。朴素地看,N 件物品每件都有“选/不选”两种选择,所以暴力枚举所有组合就是 2 的 N 次方种——N 一旦到 20 以上就是天文数字,所以必须用聪明的方法来做。
1.2 暴力搜索为何不行:数据规模带来的第一道坎
先说直接想出来的笨办法:搜索。我们递归枚举每件物品的状态,对每种组合计算总重量和总价值,筛掉超重的,更新最大价值。
# 伪代码示意,N 很大时跑不动 def dfs(index, current_weight, current_value): if current_weight > C: return # 更新答案... dfs(index + 1, current_weight, current_value) # 不选 dfs(index + 1, current_weight + w[index], current_value + v[index]) # 选这个写法在 N 小于 20 的时候还能勉强跑一跑,但 N 到了 30、50、甚至 100 以上,2 的 N 次方指数爆炸,别说比赛了,日常练习都会超时。需要把复杂度从指数级降下来,这是动态规划出场的原因——** 用空间换时间,把重复的子问题结果缓存下来。**
1.3 动态规划的本质:记忆化重复子问题
动态规划能解决这个问题,核心在于它抓住了两个关键特征:
第一,子问题重叠。比如,不管你是先选了物品 1 再考虑物品 2,还是先选了物品 2 再考虑物品 1,剩下的问题都是“在剩余的容量下,从剩下的物品中选最大价值”。这部分子问题会被反复计算,DP 用一张表把它记下来。
第二,最优子结构。假设你已经确定了第 i 件物品拿不拿,那么“前 i 件物品的最优解”,必然包含“前 i-1 件物品在对应容量约束下的最优解”。这为递推提供了逻辑基础。
生活化一点说,动态规划有点像记账:你每次只关心“在某个容量下,目前最好的值是多少”,然后把结果记下来,后面的决策直接查账,而不是重新数一遍货架。
2. 核心思路:二维 DP 是怎么一步步建立起来的
2.1 状态定义:dp[i][j] 到底表示什么
理解所有动态规划的第一步,是读懂状态。在 01 背包的经典二维版本里,定义:
dp[i][j]:考虑前 i 件物品(即第 1 件到第 i 件),当背包容量为 j 时,能够获得的最大总价值。
注意两个关键角度的含义:
- i 是物品的“下标维度”,表示我们从前 i 件物品里做选择。
- j 是容量的“限制维度”,表示当前可用的背包容量。
举个例子,dp[3][5] = 10的意思是:只拿前 3 件物品(编号 1、2、3)在背包容量只有 5 的前提下,能装出来的最大价值是 10。
2.2 状态转移公式推导:拿或者不拿
现在要推导递推公式。当我们在决定第 i 件物品时,只有两种可能:
第一种,不拿第 i 件物品。那么当前商品没有占用任何容量,价值和前 i-1 件物品在容量 j 下的最优解完全一样,即dp[i-1][j]。
第二种,拿第 i 件物品。前提是当前容量 j 至少能装下这件物品(即j >= w[i])。一旦决定拿,我们需要:
- 从背包容量中扣掉 w[i]。
- 用剩下的容量
j - w[i]去装载前 i-1 件物品,这部分的最优解是dp[i-1][j - w[i]]。 - 最后加上这件物品的价值
v[i]。
所以状态转移方程写成:
dp[i][j] = max(dp[i-1][j], dp[i-1][j - w[i]] + v[i])如果j < w[i],装不下第 i 件物品,就只能dp[i][j] = dp[i-1][j]。
这个公式非常优雅,它涵盖了两种情况的比较:拿了可能更赚,但也可能因为占了空间导致后面装不下更值钱的东西,所以取最大值。这就是“最优子结构”在起作用。
2.3 手把手填一张二维表:一个完整的小例子
光有公式不直观。我们用一个具体例子来手推一遍。假设背包容量 C=8,物品如下:
| 物品编号 | 重量 w | 价值 v |
|---|---|---|
| 1 | 2 | 3 |
| 2 | 3 | 4 |
| 3 | 4 | 5 |
| 4 | 5 | 6 |
我们建一张表格,行是物品编号(0 到 4),列是容量(0 到 8),dp[i][j]填在格子里。初始时,dp[0][j] = 0(前 0 件物品,价值肯定是 0)。
先看 i = 1(只考虑第一件物品):
- 容量 0、1 时,重量 2 装不下,dp = 0。
- 容量 2 到 8,能装下物品 1,价值为 3。所以这行从第 2 列开始全部是 3。
再看 i = 2(考虑前两件物品):
- j=0,1:什么都装不下,0。
- j=2:只装物品 1,dp[2][2] = max(dp[1][2]=3, dp[1][0]+4=4)?等等,这里要注意,w[2]=3,j=2 < w[2],装不下物品2,所以 dp[2][2] = dp[1][2] = 3。
- j=3:装得下物品2。max(dp[1][3]=3, dp[1][0]+4=4) = 4。所以容量 3 时,只装物品 2 更值。
- j=5:max(dp[1][5]=3, dp[1][2]+4=3+4=7) = 7。说明同时装物品 1 和物品 2,总重量 5,价值 7,更优。
当我继续推导完所有行,最终表的右下角dp[4][8]就是答案。这里的关键观察是:** 每一行的计算只依赖于上一行的数据**。这给后面的空间优化埋下了伏笔。
物品\容量 0 1 2 3 4 5 6 7 8 0 0 0 0 0 0 0 0 0 0 1 0 0 3 3 3 3 3 3 3 2 0 0 3 4 4 7 7 7 7 3 0 0 3 4 5 7 8 9 9 4 0 0 3 4 5 7 8 9 10最终dp[4][8] = 10。这个 10 代表了在容量 8 下,挑物品 2(重 3,值 4)和物品 4(重 5,值 6)的组合是最优的,总重恰好 8,总价值 10。
2.4 初始化细节:初学者最容易翻车的地方
很多教材只写递推公式,却很少解释 dp 数组初始化的玄机。初学者经常把dp全部初始化为 0 就开跑,但在某些题目,比如“恰好装满背包”时,这种做法就是错的。
- 如果题目问不超过容量 C 时的最大价值:全部初始化为 0 是安全的,因为装不满也是一种合法解,价值可以是 0。
- 如果题目问恰好装满背包时的最大价值:必须把
dp[0][0]初始化为 0,dp[0][j](j>0)初始化为负无穷(比如 -inf),表示“用前 0 件物品恰好装满一个非零容量”是不可能的。在递推时,如果一个状态是从 -inf 转移过来的,说明这个方案不存在,自然会被 max 比较筛掉。
记住这句判断:** 初始化决定了你允许“空背包”出现在什么状态,也决定了不合法的状态长什么样。** 这也是区分“恰好装满”和“不超过容量”的关键代码差异。
3. 一维数组优化:核心中的核心
3.1 空间复杂度的窘境:为什么要压缩
二维表的时间复杂度是O(N*C),空间复杂度同样是O(N*C)。当 N=1000,C=1000 时,表格就有 100 万个整数,勉强能忍;但 C 涨到 1e5、1e6 的时候,内存直接爆掉。
回想我们填表的过程,dp[i][j]其实** 只依赖dp[i-1][...]**,也就是前一行。根本不需要保存全部历史行。这提示我们:可以用一个一维数组滚动更新,每次迭代时数组里存的是“上一轮的结果”,更新完就变成“当前轮的结果”。
3.2 滚动数组:从二维到一维的思维跳跃
定义一维数组dp[j]:在当前已考虑的物品范围内,背包容量为 j 时能获得的最大价值。
在还没处理第 i 件物品时,dp[j]存的是前 i-1 件物品的状态。当我们开始处理第 i 件物品,目标是让dp[j]变成“考虑前 i 件物品的状态”。
我们需要在遍历容量 j 时做出决策:
dp[j] = max(dp[j], dp[j - w[i]] + v[i])那么这个式子成立吗?关键在于一个微妙的地方:** 等式右边的dp[j]应该是上一轮(没考虑当前物品)的值,而等式右边的dp[j - w[i]]也应该是上一轮的值。** 如果遍历容量时是从小到大(正序),那么dp[j - w[i]]可能已经被当前物品更新过了,这就出问题了。
为了说清楚这个问题,必须把“为什么一维循环要倒着写”这个最重要的点讲透。
3.3 为什么必须逆序更新:最重要的一道坎
很多人知道要这样写:
for i in range(1, N + 1): for j in range(C, w[i] - 1, -1): # 逆序 dp[j] = max(dp[j], dp[j - w[i]] + v[i])但详细解释“为什么”,往往不够透彻。我们要用例子说话。
还是上面的数据,但只看一维dp数组,初始全为 0。现在开始处理第 1 件物品,w[1]=2, v[1]=3。如果正序遍历j从 1 到 8 :
- j=2:dp[2] = max(dp[2], dp[0]+3) = 3。此时 dp[2] 被更新为 3(表示已装物品 1)。
- j=4:dp[4] = max(dp[4], dp[2]+3) = max(0, 3+3) = 6。
发现问题了吗?dp[2]已经是“拿了物品 1”之后的状态了,现在又拿了一次物品 1(重量又加了 2,价值又加了 3),相当于** 同一件物品被拿了两次**。这就是“完全背包”的更新效果,但 01 背包要求每件物品只能拿一次,所以正序更新违反了约束。
如果改用逆序遍历:j 从 8 到 1:
- j=8:dp[8] = max(dp[8], dp[6]+3) = 3。此时 dp[6] 还停留在上一轮(也就是 0),没被更新过。
- j=4:dp[4] = max(dp[4], dp[2]+3) = max(0, 0+3) = 3。dp[2] 没被更新过,所以不会发生“重复拿物品”的问题。
- j=2:dp[2] = max(dp[2], dp[0]+3) = 3。
你发现了吗,逆序的关键在于:当我们更新 dp[j] 时,需要参考的dp[j-w[i]]是更小的索引,而逆序保证了** 更小的容量还没有被当前物品更新过**,所以读到的值是“上一轮的旧值”。这就保证了“每件物品只能被选一次”的约束。
用一句大白话记住:** 逆序更新时,我们是在“复用自己之前的旧值”;正序更新时,我们是在“拿自己刚更新的新值继续叠加”。前者是 01 背包,后者会变异成完全背包。**
3.4 图解正序与逆序的差异
我们再具体一点,演示 i=1(w=2, v=3)时,正序和逆序的 dp 数组变化过程。
初始 dp = [0, 0, 0, 0, 0, 0, 0, 0, 0](索引 0 到 8)。
正序遍历 j=2..8:
j=2: dp[2] = max(dp[2]=0, dp[0]+3=3) => dp[2]=3 j=3: dp[3] = max(dp[3]=0, dp[1]+3=3) => dp[3]=3 j=4: dp[4] = max(dp[4]=0, dp[2]+3=6) => dp[4]=6 # 这是重复拿了物品1! j=5: dp[5] = max(dp[5]=0, dp[3]+3=6) => dp[5]=6 # 同样重复 ...逆序遍历 j=8..2:
j=8: dp[8] = max(dp[8]=0, dp[6]+3=3) => dp[8]=3 j=7: dp[7] = max(dp[7]=0, dp[5]+3=3) => dp[7]=3 j=4: dp[4] = max(dp[4]=0, dp[2]+3=3) => dp[4]=3 j=2: dp[2] = max(dp[2]=0, dp[0]+3=3) => dp[2]=3结果数组里,最终 dp[4] = 3,而不是 6。这说明物品 1 没有被“重复计入”。整个数组只有容量 2 到 8 且取了一种合法组合的情况,这才是 01 背包的行为。
这个差异一旦自己亲手推一遍,就会对“逆序”有肌肉记忆——不是靠背,是理解它是在隔离“已经被当前物品污染过的状态”。
3.5 一维完整代码与关键注释
下面是 Python 和 C++ 两种常用实现,我都标注了核心注意点。以 C=8,物品 (w,v) 列表为例。
def knapsack_01(weights, values, capacity): n = len(weights) dp = [0] * (capacity + 1) for i in range(n): # 逆序遍历容量,确保每个物品最多用一次 for j in range(capacity, weights[i] - 1, -1): # 不拿:dp[j]保持不变;拿:dp[j-w[i]] + v[i] dp[j] = max(dp[j], dp[j - weights[i]] + values[i]) return dp[capacity]#include <vector> #include <algorithm> int knapsack01(const std::vector<int>& w, const std::vector<int>& v, int C) { std::vector<int> dp(C + 1, 0); int n = w.size(); for (int i = 0; i < n; ++i) { for (int j = C; j >= w[i]; --j) { dp[j] = std::max(dp[j], dp[j - w[i]] + v[i]); } } return dp[C]; }几个容易忽视的细节:
- 内层循环的
j可以从C起,一见到j < w[i]就终止循环,因为dp[j - w[i]]下标会变成负数,直接越界。 - 更新时
dp[j]的“原值”代表上一轮不拿当前物品的方案,dp[j - w[i]] + v[i]代表拿了当前物品的方案,两者取大。 - 循环
i时,dp数组隐式地在滚动,不需要额外的临时数组。
3.6 一维数组的局限与代价
空间节省下来了,时间并不会更优,仍然是O(N*C)。但代价是:** 一维数组牺牲了“回溯具体方案”的能力**。
如果你不仅想要最大价值,还想要输出到底拿了哪几件物品,二维表会保存完整的决策路径,可以倒推。而一维表只保留了最后一轮的值,无法回溯路径。如果题目要求方案,通常还得保留二维dp,或者额外用一个choice数组记录每个状态是从哪个容量转移来的。
所以在实际工程中,如果内存允许,我会优先用二维表,只有在 C 特别大、内存吃紧时,才切换成一维写法。这个取舍,取决于你有没有“还原路径”的需求。
4. 从 DP 表格到实际代码:边界条件与初值细节
4.1 “恰好装满”与“最多装多少”的初始化陷阱
前面提到过初始化问题,这里展开说。很多题目描述是“背包最多能装价值多少”,这种直接用 0 初始化一维数组即可。但还有一类变种题是“背包恰好装满时能装多少”,比如零钱兑换、部分划分问题,此时初始化方式要微调。
一维数组写法对应地:
- 不超过容量:
dp[j] = 0对全部 j 成立。 - 恰好装满:
dp[0] = 0,dp[j] = -inf(j>0)。
因为转移时dp[j] = max(dp[j], dp[j-w[i]] + v[i]),如果dp[j-w[i]]是 -inf,那说明用“前 i-1 件物品恰好装满 j-w[i]”是无解的,加上当前物品也不可能构成合法解,max 自然不会选它。最后检查dp[C]是否大于 0 即可判断能否装满。
这个技巧在涉及“划分”或“目标值”类的问题中出现的频率极高,值得单独记忆。
4.2 容量循环边界:用闭区间还是开区间
再来一个代码细节:内层循环的写法。假设当前物品重量为 w[i],容量上限 C:
- 从
j = C到j >= w[i],使用j--:这是最常见的写法,左闭右闭,包含了j = w[i]这一档。 - 也可以写成
for j in range(C, w[i] - 1, -1),Python 的range是右开区间,所以终点多减了 1。
很多刚写题的人会在 C++ 版本里忘记等于号,导致容量恰好等于 w[i] 时的转移被跳过,答案莫名其妙变小。排查时先检查这个等号在不在,因为这个 bug 真的非常隐蔽。
4.3 物品无序是否影响结果
01 背包不需要对物品按重量或价值排序,因为 DP 的推演天然覆盖了所有组合。但有一种“隐式排序”的优化,比如当物品重量差异很大时,j的下届可以用前缀重量做一点剪枝,减少无意义循环。不过坦白说,这种优化的收益在常规题目里不算大,更常见的优化是用单调队列处理“多重背包”,那是另一个话题了,这里先不展开。
5. 实战踩坑:我调试 01 背包时遇到的典型错误
5.1 忘记逆序:一维数组输出虚高
某次帮 A 同学查代码,他的一维写法输出始终比标准答案大,而且容量越大的数据偏差越离谱。我一眼扫过去,发现内层for (int j = w[i]; j <= C; j++),正序。这就是典型的“完全背包化”错误,相当于物品被无限重复取用。
排查方法很有代表性:固定一组小数据,比如容量 3,一件物品重量 2 价值 5。正确答案应该是 5(只拿一次),但正序遍历会算出 10(拿两次)。看到这种情况,马上能定位到更新方向错了。
5.2 下标越界与忘记剪枝
另一种常见错误是内层循环没有做j >= w[i]的限制,比如:
for (int j = C; j > 0; j--) { dp[j] = max(dp[j], dp[j - w[i]] + v[i]); }当j < w[i]时,j - w[i]变成负数,轻则读脏数据,重则直接数组越界崩溃。C++ 里这类错误导致崩溃或随机值;Python 里会抛IndexError。正确的写法是让循环在j >= w[i]时停止,或者在内部加 if 判断。
5.3 多组数据没重置 dp 数组
有的题目是多组测试用例,有些同学在循环体外只声明一次 dp,却忘了每组数据前重置。这样上一组的残留值会污染当前组结果。虽然听上去很蠢,但临场紧张时很多人会犯。一个实用的习惯是:把 dp 数组写在每组数据内部,或者每次清空为 0。
5.4 “恰好装满”时误用全零初始化
B 同学做题时问“为什么我算出来永远是一坨 0 或者不对”,一看题目要求“凑到恰好某个容量”,而他初始化dp全为 0。这样就会导致dp[0]不会被特殊标记为“合法”,而所有非零容量也可能被转移,但最后判断时发现答案超过了预期。总之,恰好装满和不超过容量的初始化不同,这是我反复强调的一个坑,值得再次敲黑板。
5.5 常见错误速查表
| 症状 | 可能原因 | 解决方法 |
|---|---|---|
| 答案整体偏大,随容量增长更明显 | 内层循环正序,物品被重复选 | 改为逆序for j: C -> w[i] |
| 答案偏小或为 0 | 恰好装满初始化错误 | dp[0]=0, dp[j>0]=-inf |
| 程序崩溃或数组越界 | j - w[i]负下标 | 限制j >= w[i] |
| 多次运行结果不一致 | dp 数组未清空 | 每组数据前重置 |
| 输出方案时完全不对 | 用一维数组回溯方案 | 保留二维表或记录 choice |
这张表是我实际教学和调试中提炼出来的,排查错误时照着对照,通常能节省大量时间。
6. 变体与扩展:掌握底层逻辑才能举一反三
6.1 从 01 背包到完全背包:一行之差
基础 01 背包是 DP 学习的引子,掌握它之后你会发现,很多变体都是对代码微调就能实现的。比如完全背包(每件物品可以取无限次),只需要把内层循环从逆序改成正序——因为可以无限取,所以允许重复利用“刚更新的自己”。这个一正一逆的对比,是我最喜欢用来检验学习者有没有真正理解 DP 数组含义的问题。
6.2 多重背包:加一层枚举
如果每件物品不是只能取一次,也不是无限次,而是限定取 k 次,这就是多重背包。朴素做法是在 01 背包基础上多一层“枚举当前物品取几个”的循环,复杂度变成O(N*C*k),数据大时会超时,需要用二进制拆分优化成O(N*C*logk),这是后话。理解了 01 背包的“为什么逆序”,多重背包的理解成本就会低很多。
6.3 子集和问题:一类经典转化
所有物品的重量全是“收益”且价值消失时,01 背包就退化成“能否从这些重量中选若干,恰好等于某个目标值”的问题。这就是子集和问题。这种问题在一些任务分配、数据分割场景中非常常见。只要把价值改成 0/1(能凑出 TRUE,不能 FALSE),转移就变成了dp[j] = dp[j] or dp[j-w[i]],dp数组的类型也变成布尔型。
这类变体有力地说明了:背包问题不是一个孤立的模板,而是一种“决策 + 约束 + 最优目标”的建模框架。搞懂核心的 DP 推进逻辑,后面遇到变体,你就能快速地把新问题映射到熟悉的模型上。
6.4 多维费用背包:容量从一个维度到多个维度
有的背包同时受重量和体积两个限制,比如放行李箱,既超重不行,也塞不下。这种要求 dp 数组多加一维变成dp[j][k],转移时同时对两个容量做减法。理解了“状态数等于约束维度数”,多维费用背包就是自然延伸。处理这类问题时,我的经验是别急着优化空间,先画一张多维表理清依赖关系,优化水到渠成。
7. 我的实际使用心得与扩展思路
说到底,01 背包这个题看似简单,但它承载着动态规划最核心的思维模式:状态怎么定、转移怎么写、边界怎么处理、空间怎么压缩。很多人碰到新题目就开始套模板,这恰恰是因为没理解模板每一步背后的工程思维。我自己带过不少人,凡是能把一维数组“逆序更新”讲出所以然的,面对其他 DP 题目就不会太慌。
有一个小技巧,是我在实际写代码时常用的——在提交前,用一组非常小的数据(比如两件物品,容量 3)把 DP 数组的演化过程打印出来。如果打印结果和手动推演对得上,那这题代码基本稳了。尤其是当你犹豫到底该正序还是逆序时,这个小实验 10 秒钟就能给你答案。
最后分享一个扩展思路:01 背包一维优化还可以和“bitset”结合做极致加速。在子集和问题里,布尔型 dp 的转移dp |= dp << w[i],一条位运算指令能同时在很多容量状态上做“或”操作,性能提升非常明显。这在某些对执行效率极其敏感的题目中是隐藏的杀手锏。不过,这属于进阶玩法了,先把基本功打牢,这些扩展自然会水到渠成。