01 背包:每件至多选一次
背包问题的起点:N 件物品各带费用 C 与价值 W,背包容量为 V,每件至多选一次,求容量内可得的最大价值。本讲从暴力枚举出发,经贪心的失败,落到动态规划的状态设计
、二维与一维实现(及 v 逆序的由来)、回溯与初始化——它是全系列的地基。
1 · 问题描述
给定 N 件物品,第 i 件费用为
、价值为
,每件至多被选取一次;背包可承受的费用上限为 V(即容量)。求在所选物品费用之和不超过 V 的前提下,使价值之和最大。
与 V 同维度——可以是重量、体积、时间或预算等任一资源。本讲约定
为非负整数,这是后续
时间复杂度成立的隐含前提(见 §4 末「复杂度谱系与伪多项式」NOTES)。
2 · 朴素方案 · 暴力枚举
最直白的解法:每件物品仅有取 / 不取两种状态,那就把 N 件的全部
种组合都枚举一遍,算出每个子集的总费用与总价值,在费用不超过 V 的可行子集里挑价值最大者。用位掩码 mask 从 0 到
,第 i 位为 1 即「选第 i 件」。
正确,但规模不可行。暴力枚举考察了所有「取 / 不取」组合,答案必定最优——问题不在正确性,而在计算量:每多一件物品工作量翻倍,N=10 是 1024,N=30 已 ≈10⁹,N=60 约 10¹⁸,按每秒 10⁸ 次计算需要数百年(详见下方对照表)。这棵深度 N
的「决策树」里充满重叠子问题——把它们记忆化,就把
压成
,这就是 DP(见 §4)。
NOTES · 2ᴺ 的爆炸增长 · 朴素方案为何不可扩展
把 代入具体数字,即可看清指数增长的「墙」。下表按每秒 次基本运算估算耗时:
| N | 2ᴺ | 耗时 (10⁸/秒) | 评注 |
|---|---|---|---|
| 10 | 1,024 | ≈ 10 μs | 完全可用 |
| 20 | ≈ 10⁶ | ≈ 10 ms | 仍可用,已接近上限 |
| 30 | ≈ 10⁹ | ≈ 10 s | 人已等不下去 |
| 40 | ≈ 10¹² | ≈ 3 h | 不可用 |
| 60 | ≈ 10¹⁸ | ≈ 数百年 | 比工业革命至今还久 |
增长是逐件翻倍:每多一件物品,工作量乘 2。N 从 30 到 40 仅增 10 件,耗时却从 10 秒跳到 3 小时。朴素枚举的正确性无可争议——它确实给出最优解;失败的不是「对不对」,而是「算不算得完」。后续 §3 贪心牺牲正确性换速度,§4 的 DP
则保留正确性、换一种方式枚举:把决策树的重叠子问题记忆化,把
个节点缩减到
个 (i, v)。
3 · 贪心方法的提速尝试
既然
在 N=30 就不可用,自然想:能不能一次排序 + 一次扫描()就得到答案?比如「按价值从高到低」或「按性价比 W/C 从高到低」逐件贪心选取。下面给一个反例,把「贪心走不通」看清楚。
反例:V=10,物品 A(C6,W10)、B(C5,W7)、C(C5,W7)。两种贪心都先抓性价比/价值最高的 A,剩 4 装不下别的 → 得 10;而 DP 放弃 A、选 B+C(费用 10、价值 14)严格更优。贪心只看单件局部最优,无法权衡「不选高价值件后剩余容量的利用」。
4 · 动态规划 · 状态设计与二维实现
引入二维状态
=「在物品 1..i 中选取、费用之和不超过 v 时的最大价值」。i 是候选集合的上界(前缀视角)。对第 i 件只有「选 / 不选」两种决策:
F[i][v] = max( F[i−1][v], F[i−1][v−Cᵢ] + Wᵢ )(v ≥ Cᵢ;否则只取前者)
不选第 i 件 → 退化成前 i−1 件、容量仍是 v;选 → 腾出 Cᵢ、得 Wᵢ,再在前 i−1 件凑剩下的 v−Cᵢ。答案在 。下面按 i、v 两层循环逐格填表;填满后点「回溯方案」从 反查每件选没选、把选中的落进背包。
NOTES · 复杂度谱系 · 伪多项式 · NP-hard 与 FPTAS
常有疑问: 是否已是最优?严格而言并非如此——01 背包属 NP-hard 问题,但就实际可用而言已经够好。把它放进算法谱系对比即可看清各方案的取舍:
| 算法 | 复杂度 | 最优性 | 评注 |
|---|---|---|---|
| 暴力枚举(§2) | O(N·2ᴺ) | 是(指数) | N=30 已超 10⁹,不可用 |
| 贪心(§3) | O(N log N) | 否 | 快,但可任意偏离最优 |
| 本讲 DP | O(NV) | 是 | 伪多项式:对 V 而非 log V 线性 |
| FPTAS 近似 | O(N³/ε) | 近似 (1−ε) | 以可控误差换取强多项式 |
伪多项式(pseudo-polynomial)。
对容量 V 是线性的,但 V 在输入中以 log V 个比特表示:若 V 为 32 位整数,最坏情形
便不再是多项式。这正是 01 背包仍属 NP-hard 的根本原因——不存在对输入位长多项式的精确算法(除非 P = NP)。工程中 V 通常是较小整数(几千到几百万),
远低于可等待的极限,故称「够好」。需要对大 V 给出可控误差的近似时,可用 FPTAS,以
换取
近似比。
NOTES · 二维表的完整推演 · V=8、4 件物品
取 V=8、四件物品 (C,W) = (2,3), (3,4), (4,5), (5,6)(即上方演示的默认数据),逐行算出
:
| i \ v | 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 |
单元读法举例:F[3][6]=8 表示「在物品 1..3 中选取、费用不超过 6 时的最大价值为 8」。其来源为
,即选取了第 3 件。最终答案 F[4][8]=10,对应方案为选第 2、第 4 件(费用 3+5=8、价值 4+6=10)。
回溯具体方案——从
出发,对每个 i(自 N 递减至 1):若
则第 i 件未选、v 不变;否则第 i 件选取、v 减去
。对照上表逐步执行:
起点 (i,v) = (4,8):
F[4][8]=10, F[3][8]=9 10 ≠ 9 → 第 4 件选取, v ← 8 − 5 = 3
F[3][3]=4, F[2][3]=4 4 = 4 → 第 3 件未选, v = 3
F[2][3]=4, F[1][3]=3 4 ≠ 3 → 第 2 件选取, v ← 3 − 3 = 0
F[1][0]=0, F[0][0]=0 0 = 0 → 第 1 件未选
终止: 选第 2、第 4 件,总费用 3+5=8,总价值 4+6=10 ✓
DP 在给出最优值的同时,可经这一回溯恢复具体最优解——这是它相较贪心(不保留状态空间)与 §5 一维压缩(覆盖式更新丢弃历史)的优势。tie-breaking 与多最优解的处理见下方「多最优解与 tie-breaking」NOTES。
NOTES · 自顶向下记忆化的等价实现
Listing 4.1 采用自底向上填表:从
逐行算到
。同一递推可改写为自顶向下递归 + 记忆化的等价形式——即把 §2 提到的「
决策树的重叠子问题记忆化」直接实现:从
出发,需要哪个子问题就递归求解,首次算出后存入 memo,后续直接读取。两者求解同一函数
、用同一递推,总计算次数同为
,差异在于:自底向上按 (i,v) 字典序遍历整张表,可滚动压缩到
(见 §5);自顶向下只触及实际用到的子问题,但需
的 memo 表加
递归栈,无法同样压缩。状态空间稀疏时记忆化递归常数更优,稠密(如本题)时填表更快且无栈溢出风险。记忆化递归 / 自底向上 DP / 朴素树递归三者的调用次数对比,见 第 2 讲的 SICP 换零钱演示。
NOTES · 前缀与后缀:两种等价的状态对偶
本讲的
取前缀视角:i 是「在物品 1..i 中选取」的候选集合上界。读者偶有把「前 i 件」误读为「剩余未判定的 i 件」,后者对应等价的后缀状态
=「从第 j 件到第 N 件中选取、费用不超过 v 的最大价值」,边界 G[N+1][v]=0,答案为
。两种定义在算法上完全等价,本讲采用前缀。须强调:「前缀 / 后缀」刻画的是候选物品集合的形状,与「一维空间压缩」一节讨论的循环方向(递增 / 递减)是两件不同的事,不要混淆。
NOTES · 多最优解与 tie-breaking
回溯时,若某步同时满足 与 ,本讲按「未选」分支返回——这只是一种任意的 tie-breaking 约定。达到同一最优值 的物品子集可能不止一个;如何枚举或计数全部最优方案,见 第 9 讲(问法变化)的「最优方案总数」,那里把「任选一条」升级为「对每条相等分支累加」的计数语义。
NOTES · 关键概念出处
动态规划这一方法论由 Richard Bellman 于 1950 年代提出,其奠基著作 Dynamic Programming(Princeton University Press, 1957)系统确立了「最优子结构」与「重叠子问题」的框架。01 背包作为整数规划的经典实例,其 算法在 1960 年代起由 Gilmore 与 Gomory 在切割下料(cutting-stock)问题的线性规划途径中系统讨论(Operations Research, 9(6), 1961)。现代算法教材均设专章介绍,如 CLRS · Introduction to Algorithms。就背包族的系统综述而言,见 Kellerer, Pferschy & Pisinger · Knapsack Problems(Springer, 2004)——该书亦明确:背包问题(就其一般化形式)是 NP-hard 的典型代表,而伪多项式算法对小整数容量足够实用。
5 · 一维空间压缩 · 循环方向
只依赖上一行
,所以可压成一维
滚动复用,空间
。但一维下 v 的更新方向决定语义:必须递减(V → C),才能保证写
时读的
还是「未考虑第 i 件」的旧值 → 每件至多选一次。若递增(C → V),
本轮已被改写 → 重复选同一件(那就成了完全背包)。下面左右并排对比。
NOTES · 循环方向反例 · 单件 (C,W)=(2,3)、V=4
用最小规模把「为何必须递减」逐步看清:仅一件物品 (C,W)=(2,3)、容量 V=4。因每件至多选一次,正确解为 F[4]=3。两个方向的 trace 对照:
递减方向(正确):
v=4: F[4] = max(F[4], F[2]+3) = max(0, 0+3) = 3
v=3: F[3] = max(F[3], F[1]+3) = 3
v=2: F[2] = max(F[2], F[0]+3) = 3
最终 F[4] = 3 ✓
递增方向(错误,退化为完全背包):
v=2: F[2] = max(F[2], F[0]+3) = 3
v=3: F[3] = max(F[3], F[1]+3) = 3
v=4: F[4] = max(F[4], F[2]+3) = max(0, 3+3) = 6 ← 该物品被选取两次
关键在第三步:递减时读
仍是「未处理本件」的旧值 0(等价于二维的
),故每件至多一次;递增时
已在本轮被改写成 3(等价于
),于是
把同一件物品又算了一遍 → 6。这「同件可反复选」恰是完全背包的语义,见 第 2 讲。
NOTES · 一维压缩的代价 · 失去回溯路径
一维 F 数组只保留「已处理物品 1..i 后的最优价值」,每一轮原地覆盖上一轮的全部内容——拿到
后无法再回推「哪些物品被选取」,这与 §4 二维表逐行保留历史、可从
反查的能力恰成对照。若题目同时要求最优值与具体方案,有两条补救:
其一,保留完整的二维表 用于回溯,即放弃空间压缩、退回 §4 的二维实现。其二,在一维 DP 之外另开一张 的「决策来源」位图,每次更新时记录「本格是否选取了第 i 件」,事后据此重建方案——存储仍为 ,但相较保留整张数值表,常数更小。
6 · 初始化与边界
初值的两种含义:问「费用不超过 V 的最大价值」→ F[0..V]=0(空背包合法,任意容量都「可达」);问「恰好装满 V」→ F[0]=0、(只有「恰好 0」是已知起点,其余暂不可达,
表「无可行方案」)。下面同一组数据并排跑两种初值,看
如何沿转移传播。
NOTES · 两类退化边界 · 由循环范围自然吸收
物品费用全超过容量。取 V=3、物品 (C5,W100)、(C4,W80),任一物品费用均大于 V,不存在可行选取,结果 F[3]=0(什么都不选)。实现无需特判:内层循环 for (v = V; v >= Cᵢ; v--) 在
时自动为空,边界由循环范围隐式吸收。
物品集为空(N=0)。对称的另一退化是外层 for (const {C,W} of items) 一次也不执行,F 在初始化后即为最终结果(一维约定下 F[0..V]=0,对应「无物品可选,任意容量最大价值均为
0」)。两类边界同理——皆由循环范围自然处理,实现层面无需任何特判。这种「边界被循环自然吸收」是 DP 实现的一个隐性优势,后续各讲也将反复利用。
7 · 应用案例 · 优惠券组合
把券门槛视为费用 C、减免金额视为价值 W、购物车总额视为容量 V,每张券至多用一次——这就是 01 背包。点卡片可把某张券标记为不可用,滑动总额看最优组合随之切换。下方对照「按面额贪心 / 按性价比贪心 /
DP」三种策略的结果差异。
8 · 小结
本讲奠定了全系列的基础:状态
、转移方程(选 / 不选取 max)、二维 / 一维实现(及 v 逆序的由来)与回溯。第 2 讲(完全背包)只改一个循环方向:把一维的 v 从逆序改成顺序,就允许同一件物品反复选取。