背包问题,几乎是所有学动态规划的人绕不过去的一道坎。它在算法面试、竞赛和日常工作里出现的频率,高得超出想象。说白了,背包问题就是一个在有限资源下做取舍的决策模型:给你一堆物品,每个有重量和价值,背包容量固定,问怎么装才能让总价值最大。这个问题看起来简单,但背后的状态定义、转移方程和边界处理,会直接影响你后续学更复杂的动态规划题。我从第一次写出二维DP,到后来只用一维数组装下所有状态,中间踩过不少坑。这篇文章会从最基础的0/1背包讲起,把动态规划的状态定义、转移方程、空间优化一次讲透,再给完整可跑的代码,最后聊几个背包装不下但思想能落地的业务场景。适合准备算法面试的开发者,也适合第一次被DP绕晕的初学者。
1. 背包问题的本质与经典变体
1.1 先看一道最经典的0/1背包题
有N件物品,编号从1到N,第i件物品的重量为w[i],价值为v[i]。现在有一个容量为W的背包,每件物品最多拿一次。问:能装入背包的最大总价值是多少?
我建议你把它想象成一个限重5kg的旅行箱。面前摆着四样东西:帐篷(2kg、实用度12分)、睡袋(1kg、实用度10分)、炉子(3kg、实用度20分)、水壶(2kg、实用度15分)。你要在5kg限制下挑出实用度最高的组合。
手工枚举一下所有不超重的组合:
- 帐篷 + 睡袋:3kg,22分
- 帐篷 + 炉子:5kg,32分
- 帐篷 + 水壶:4kg,27分
- 睡袋 + 炉子:4kg,30分
- 睡袋 + 水壶:3kg,25分
- 炉子 + 水壶:5kg,35分
- 帐篷 + 睡袋 + 水壶:5kg,37分
结果最高是37分,对应选择帐篷、睡袋和水壶。注意,这里面没有炉子。原因是炉子虽然单件价值高,但会挤占其他物品的空间,DP要做的就是帮我们在这种“互相挤占”的关系里找到最优解。
1.2 背包问题到底在解决什么
很多人学背包题容易陷入“背模板”的误区,其实它的本质是一个组合优化问题:在满足总重量约束的前提下,从一组候选物品中选出一个子集,使总价值最大。
这种“容量-代价-价值”的三元组结构,在现实里到处都是:
- 预算有限,从一批功能点里挑优先级最高的做;
- 服务器内存有限,决定哪些缓存数据留下;
- 仓库有容量上限,决定备哪些货;
- 假期有限,决定去哪些城市。
所以背包问题不是一道孤立题,它是一种建模能力。学会背包,相当于掌握了一个通用的资源分配框架。
1.3 三种初级变体:0/1、完全、多重
背包问题有几个经典变体,核心区别只有一条:每件物品能拿几次。
| 变体 | 物品可用次数 | 典型场景 | 遍历方向 |
|---|---|---|---|
| 0/1背包 | 0次或1次 | 旅行装箱、单选决策 | 容量倒序 |
| 完全背包 | 无限次 | 硬币凑金额、无限库存采购 | 容量正序 |
| 多重背包 | 有限次(最多c[i]次) | 每种商品限购c件 | 二进制拆分后再倒序 |
这三个变体的状态定义几乎一样,差异只在转移时如何处理“次数限制”。理解了这一点,后面写代码才不会混淆。
1.4 枚举为什么撑不住
最简单粗暴的解法是枚举所有子集,复杂度O(2^N)。当N=20时,约100万种组合,还能勉强跑;当N=100时,2^100这个数字已经没有任何计算机能扛住了。
动态规划的思路是用“状态”记录中间结果,避免重复计算。背包问题的状态数大约是N×W个,每个状态只做常数次操作,所以复杂度降到O(NW)。N=100、W=100000时,一千万次操作对现代计算机来说只是毫秒级的事。这就是DP的核心价值:用空间换时间,把指数级问题变成多项式问题。
2. 从题目到状态转移:动态规划解题六步法
2.1 六步法总览
我平时做DP题,很少直接“灵光一闪”,而是固定走一套流程。这里整理成六步,几乎适用于所有背包类题目:
- 抽象输入:把题目转成w数组、v数组、容量W,明确每件物品能用几次。
- 确定状态维度:凡是涉及“前i件物品”和“容量j”,就优先考虑二维状态dp[i][j]。
- 定义状态含义:dp[i][j]表示前i件物品放入容量为j的背包能获得的最大价值。
- 写出转移方程:按“最后一步决策”拆解,要么不拿第i件,要么拿第i件。
- 定义边界:dp[0][j]=0、dp[i][0]=0,容量或物品为0时空背包价值也为0。
- 确定答案位置:最终答案是dp[N][W],因为所有物品都参与了决策、容量达到上限。
这套流程看着机械,但能极大减少出错概率。
2.2 状态定义:别怕“dp[i][j]”这串符号
初学者看到dp[i][j]容易发怵,我习惯把它翻译成一句话:当前我正在处理前i件物品,已经使用的容量是j,此刻最多能带走的价值是多少。
举个例子,dp[3][4]的意思是:帐篷、睡袋、炉子这三件物品摆在面前,背包容量只用4kg,最多能装多少价值?从前面手工枚举结果看,睡袋+炉子是4kg、30分,帐篷+睡袋是3kg、22分,所以dp[3][4]=30。
注意“前i件”不是“第i件”,它涵盖了从第1件到第i件所有物品。这是DP里最关键的思维转变:不要一件一件单独想,而是把前i件当成一个整体处理。
2.3 转移方程的直觉:不拿 vs 拿
0/1背包的转移方程只有一行:
dp[i][j] = max(dp[i-1][j], dp[i-1][j-w[i]] + v[i])这个方程的直觉来自两个决策:
- 不拿第i件物品:问题退化成“前i-1件物品放进容量j的背包”,价值是dp[i-1][j]。
- 拿第i件物品:必须先腾出w[i]的空间,所以容量变成j-w[i],价值是前i-1件物品在该容量下的最优值dp[i-1][j-w[i]],再加上第i件物品的价值v[i]。
两者取max,就是当前状态下能做的最好选择。这个“拿或不拿”的二分逻辑,是几乎所有背包DP的基石。
2.4 手算表:用一组小数据走一遍
还是刚才那组数据:N=4,W=5,w=[2,1,3,2],v=[12,10,20,15]。
手工填二维表,行是物品编号i(0到4),列是容量j(0到5):
| dp[i][j] | j=0 | j=1 | j=2 | j=3 | j=4 | j=5 |
|---|---|---|---|---|---|---|
| i=0(无物品) | 0 | 0 | 0 | 0 | 0 | 0 |
| i=1(帐篷) | 0 | 0 | 12 | 12 | 12 | 12 |
| i=2(睡袋) | 0 | 10 | 12 | 22 | 22 | 22 |
| i=3(炉子) | 0 | 10 | 12 | 22 | 30 | 32 |
| i=4(水壶) | 0 | 10 | 15 | 25 | 30 | 37 |
以i=4、j=5为例,此时水壶重2、价值15。不拿水壶,dp[3][5]=32;拿水壶,容量变成3,dp[3][3]+15=22+15=37。37大于32,所以dp[4][5]=37。表格最后右下角的37,就是全局最优解。
我强烈建议新手花十分钟自己把这张表填一遍。填表的过程会逼你理解每一步转移,比看十遍讲解都有用。
3. 空间优化实战:从二维表到一维数组
3.1 二维DP的基础实现
先把最直观的二维版本写出来。Python代码如下:
def knapsack_01(weights, values, capacity): n = len(weights) dp = [[0] * (capacity + 1) for _ in range(n + 1)] for i in range(1, n + 1): w = weights[i - 1] v = values[i - 1] for j in range(capacity + 1): # 不拿第 i 件 dp[i][j] = dp[i - 1][j] # 拿第 i 件 if j >= w: dp[i][j] = max(dp[i][j], dp[i - 1][j - w] + v) return dp[n][capacity]时间复杂度O(NW),空间复杂度O(NW)。缺点是N和W都很大时,二维数组会撑爆内存。比如N=2000、W=100000,二维表就是2亿个整数,直接内存溢出。
3.2 空间优化:一维数组是怎么省下来的
观察转移方程会发现一个关键事实:dp[i][j]只依赖dp[i-1][j]和dp[i-1][j-w[i]],也就是只依赖上一行的数据。
那就不需要保存所有行,只保留一行,每次更新覆盖即可。优化后代码如下:
def knapsack_01_optimized(weights, values, capacity): n = len(weights) dp = [0] * (capacity + 1) for i in range(1, n + 1): w = weights[i - 1] v = values[i - 1] for j in range(capacity, w - 1, -1): dp[j] = max(dp[j], dp[j - w] + v) return dp[capacity]空间复杂度从O(NW)降到O(W),很多N大W不算太大的题都能跑了。时间复杂度仍然是O(NW),这是背包问题的下界,很难再降。
3.3 为什么0/1背包必须倒序遍历
这是背包问题里被问得最多的“为什么”,也是最容易踩坑的地方。
一维数组在更新dp[j]时,dp[j]本身还是上一轮(处理第i件物品之前)的结果,对应dp[i-1][j]。问题是dp[j-w]也必须是上一轮的结果。如果正序遍历j从小到大,那么dp[j-w]可能已经被本轮更新过,变成dp[i][j-w]。此时dp[j] = max(dp[j], dp[i][j-w] + v)就可能把同一件物品放两次。
倒序遍历时,j从大到小,dp[j-w]一定比dp[j]小,所以本轮还没更新到它,读到的仍是上一轮的值。这就保证了每件物品最多被拿一次。
可以用一个生活类比:你有一排抽屉要按顺序倒数据,只能从最后一个抽屉开始往前倒。如果从前往后倒,前面的新数据会污染后面的旧数据。
3.4 完全背包和多重背包的一维写法
完全背包允许每件物品无限拿,所以遍历方向反过来,正序遍历。正序遍历正好让dp[j-w]可能是本轮已经更新过的值,意味着同一件物品可以反复被选中。
def knapsack_complete(weights, values, capacity): n = len(weights) dp = [0] * (capacity + 1) for i in range(1, n + 1): w = weights[i - 1] v = values[i - 1] for j in range(w, capacity + 1): dp[j] = max(dp[j], dp[j - w] + v) return dp[capacity]多重背包每件物品有次数限制c[i],朴素做法是把c[i]拆成c[i]件独立的0/1物品。但如果c[i]很大,比如100000,直接拆分会超时。优化思路是二进制拆分:把c[i]拆成1、2、4、8、...以及最后的余数,让这些组合数可以表示0到c[i]之间的任意数量,本质是把O(c[i])的拆件复杂度降到O(log c[i])。
def knapsack_multi(weights, values, counts, capacity): dp = [0] * (capacity + 1) for i in range(len(weights)): w = weights[i] v = values[i] c = counts[i] k = 1 while c > 0: take = min(k, c) tw = w * take tv = v * take for j in range(capacity, tw - 1, -1): dp[j] = max(dp[j], dp[j - tw] + tv) c -= take k <<= 1 return dp[capacity]这段代码我实测过,c[i]大到10万也不怕,运行时间取决于拆出来的物品总件数,通常只有几十件。
4. 经典变体二连:方案数、路径还原与分组背包
4.1 “恰好装满”和“方案数”是两个高频变体
很多题目不会直接问“最大价值”,而是问“恰好装满背包的方案数”。
这里有一个新手经常忽略的初始化细节:如果题目要求“恰好装满”,不能把所有dp[j]初始化为0,而要把dp[0]设为0,其他dp[j]设为负无穷(比如-10**9)。原因很简单:只有容量恰好为0时,才存在“什么都不装”这个合法方案;容量不为0时,初始状态是“装不满”的非法状态,不能用0去污染后续转移。
方案数的写法也很有意思,转移方程从max变成了加法:
dp[j] = dp[j] + dp[j - w[i]]含义是:达到容量j的方案数 = 不用第i件物品的方案数 + 用第i件物品的方案数。
def count_schemes(weights, capacity): n = len(weights) dp = [0] * (capacity + 1) dp[0] = 1 for i in range(1, n + 1): w = weights[i - 1] for j in range(capacity, w - 1, -1): dp[j] += dp[j - w] return dp[capacity]这个代码跑完,dp[capacity]就是恰好装满容量capacity的方案数。注意遍历方向依然要遵守0/1背包的倒序规则,否则会把每件物品当成无限次使用,方案数会偏大。
4.2 还原具体装法:记录决策与回溯
有时候面试官会追问:“你能告诉我具体选了哪几件物品吗?”这时候光有dp表还不够,需要额外记录每一步的决策。
思路是开一个二维布尔数组choose[i][j],当dp[i][j]是由“拿第i件物品”转移而来时标记为True。最终从dp[n][capacity]回溯:
def knapsack_with_path(weights, values, capacity): n = len(weights) dp = [[0] * (capacity + 1) for _ in range(n + 1)] choose = [[False] * (capacity + 1) for _ in range(n + 1)] for i in range(1, n + 1): w = weights[i - 1] v = values[i - 1] for j in range(capacity + 1): dp[i][j] = dp[i - 1][j] if j >= w and dp[i - 1][j - w] + v > dp[i - 1][j]: dp[i][j] = dp[i - 1][j - w] + v choose[i][j] = True # 回溯具体方案 selected = [] j = capacity for i in range(n, 0, -1): if choose[i][j]: selected.append(i - 1) j -= weights[i - 1] selected.reverse() return dp[n][capacity], selected回溯的逻辑和转移方程正好相反:从最后一个物品开始,如果choose[i][j]为True,说明第i件物品被选中,于是把容量减去w[i],再往前看。这个逆向过程只要在纸上模拟一遍就能彻底搞懂。
4.3 分组背包:每组只能挑一个
分组背包是面试中比多重背包更常出现的变体。题目一般这样:有若干组物品,每组里有多个物品,每组最多只能选一个,问最大价值。
解决分三步:先遍历组,再逆序遍历容量,最后遍历组内物品。代码骨架如下:
def knapsack_group(groups, capacity): # groups 是一个列表,每组又是一个列表,元素为 (weight, value) dp = [0] * (capacity + 1) for group in groups: for j in range(capacity, -1, -1): for w, v in group: if j >= w: dp[j] = max(dp[j], dp[j - w] + v) return dp[capacity]容量倒序的原因和0/1背包一样,保证每组最多选一个;组内物品再互相竞争容量。顺序千万不能乱,我在练习时有过把容量循环和组内循环写反的经历,结果答案瞬间失真。
提示:遇到背包问题,先确认“物品能用几次”,这决定遍历方向;再确认“问最大价值还是方案数”,这决定转移方程用max还是加法;最后确认“是否要求恰好装满”,这决定初始化方式。
5. 踩坑实录:常见错误与排查技巧
5.1 高频bug速查表
我在刷题和帮别人review代码时,整理了一张背包问题bug速查表,命中率非常高:
| 症状 | 可能原因 | 排查方向 |
|---|---|---|
| 答案比预期小很多 | 容量循环范围写错,比如漏了capacity本身 | 检查range的起止值 |
| 一维优化后答案变大 | 0/1背包用了正序遍历 | 改成倒序,或打印dp表确认 |
| 方案数输出偏大 | 物品被重复使用 | 确认0/1背包是否倒序遍历 |
| “恰好装满”答案异常 | 初始化全0 | 把dp[0]=0,其他设为负无穷 |
| 多重背包超时 | 朴素拆件导致物品数爆炸 | 改用二进制拆分 |
| 回溯方案不对 | choose数组记录时机错误 | 确认记录时用的是“严格大于”而非“大于等于” |
最后一条特别隐蔽。如果用dp[i-1][j-w]+v >= dp[i-1][j]去标记choose,会让等于的情况也被记为“选择了第i件”,回溯时会选出一堆价值相同的替代品,方案和预想对不上。我用“严格大于”就干净很多。
5.2 打印dp表:最笨也最有效的调试法
遇到错误先别急着改代码,我习惯在循环里加一行print,把每一轮的一维dp数组打印出来,然后和手算表逐列对比。错在哪一轮、哪一列,一眼就能看出来。
比如0/1背包正序和倒序,打印第2轮数据:
- 正序时,dp[3]会被dp[1]+v更新,dp[4]又被dp[2]+v更新,物品被用了多次。
- 倒序时,dp[5]用dp[3]更新,dp[3]用dp[1]更新,本轮更新不会影响后续更新。
这种差异肉眼可见,比干想逻辑高效得多。
5.3 三个容易想歪的地方
第一个是“剩余容量”和“总容量”的混淆。dp[j]里j表示当前背包已占用容量,不是剩余容量。很多人在理解转移方程时总以为j要倒着减,其实j是从0到W枚举的总容量状态。
第二个是物品索引的偏移问题。代码里weights下标从0开始,但DP状态从1开始表示“前i件”。没对齐的话,答案会少算一件物品。我在二维版本里用weights[i-1]取物品,就是为了对齐这种偏移。
第三个是边界数据的测试。我刚学背包时经常写完就提交,结果被边界情况卡住。现在每次写完都会主动测四个边界:N=0、W=0、单件物品恰好等于容量、所有物品重量总和小于容量。前两个测初始化,后两个测转移方程的正确性。
6. 背包思想在真实业务里的延伸
6.1 预算分配与资源调度
如果把背包里的“容量”替换成“预算”,“重量”替换成“成本”,“价值”替换成“收益”,那就是一个标准的项目资源分配问题。
假设一个团队有100个开发人天,打算在几个候选功能里选一批上线。每个功能有预估成本(人天)和预期收益(业务指标)。这就是0/1背包,甚至可能是分组背包(同一功能的不同实现方案选一个)。用背包DP跑一遍,能快速给出一个“在成本约束下收益最高”的候选组合,作为决策参考。
我在实际使用中最大的感受是:背包问题真正难的不是背模板,而是把现实问题映射成“容量、代价、价值”这三元组。只要映射正确,代码几乎不用改,直接套状态转移方程就行。
6.2 缓存管理:给内存做取舍
缓存系统里,内存空间是容量,每个缓存块有大小和热度(价值)。内存有限时,该保留哪些缓存块,本质就是0/1背包。
当然,真实缓存系统还会有时间维度、淘汰策略、命中率统计等额外逻辑,背包DP不是完整解,但它提供了一个很好的静态取舍基线。我曾经在一个内部工具里用类似思路做数据项的剔除,先把数据项按“大小-价值”建模,再用背包装出一个理论最优子集,与现有的简单淘汰策略对比,发现理论最优比默认策略能多保留约15%的高价值数据。这个差距足以说明背包思想在工程里的价值。
6.3 从背包到思维模型:容量、代价、价值
最后再分享一个小技巧,也是我教别人DP时最常用的一句口头禅:遇到任何优化问题,先问自己三个问题,什么是容量?什么是代价?什么是价值?
- 容量是约束,比如时间、预算、空间、数量。
- 代价是消耗,每个候选方案要占用多少资源。
- 价值是你真正想最大化的指标,比如收益、分数、用户满意度。
一旦能清晰回答这三个问题,就可以判断:物品能用几次?0或1次就是0/1背包,无限次就是完全背包,有限次数就是多重背包,每组选一个就是分组背包。比死记遍历方向靠谱得多。
背包问题表面上是算法题,本质上是一种取舍思维的训练。你在背包里装的每件物品,都是你在有限约束下做的选择。这种选择能力,写代码用得上,做产品决策、安排自己的时间,也一样用得上。希望这篇实战解析能帮你看清DP的那层窗户纸,下次再遇到背包题,能直接上手写出干净、正确的代码。