← 背包问题九讲 · 动态规划的思考艺术 / 01 背包:每件至多选一次 待审核 1 / 10
Ⅰ · 01 背包 · F[i][v] · v 逆序

01 背包:每件至多选一次

背包问题的起点:N 件物品各带费用 C 与价值 W,背包容量为 V,每件至多选一次,求容量内可得的最大价值。本讲从暴力枚举出发,经贪心的失败,落到动态规划的状态设计 F[i][v]F[i][v]、二维与一维实现(及 v 逆序的由来)、回溯与初始化——它是全系列的地基。

1 · 问题描述

给定 N 件物品,第 i 件费用为 CiC_i、价值为 WiW_i,每件至多被选取一次;背包可承受的费用上限为 V(即容量)。求在所选物品费用之和不超过 V 的前提下,使价值之和最大。CiC_iV 同维度——可以是重量、体积、时间或预算等任一资源。本讲约定 Ci,VC_i, V 为非负整数,这是后续 O(NV)O(NV) 时间复杂度成立的隐含前提(见 §4 末「复杂度谱系与伪多项式」NOTES)。

2 · 朴素方案 · 暴力枚举

最直白的解法:每件物品仅有取 / 不取两种状态,那就把 N 件的全部 2N{2^N} 种组合都枚举一遍,算出每个子集的总费用与总价值,在费用不超过 V 的可行子集里挑价值最大者。用位掩码 mask02N1{2^N-1},第 i 位为 1 即「选第 i 件」。

正确,但规模不可行。暴力枚举考察了所有「取 / 不取」组合,答案必定最优——问题不在正确性,而在计算量:每多一件物品工作量翻倍,N=10 是 1024,N=30 已 ≈10⁹,N=60 约 10¹⁸,按每秒 10⁸ 次计算需要数百年(详见下方对照表)。这棵深度 N 的「决策树」里充满重叠子问题——把它们记忆化,就把 O(2N)O(2^N) 压成 O(NV)O(NV),这就是 DP(见 §4)。

NOTES · 2ᴺ 的爆炸增长 · 朴素方案为何不可扩展

2N{2^N} 代入具体数字,即可看清指数增长的「墙」。下表按每秒 108{10^8} 次基本运算估算耗时:

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 则保留正确性、换一种方式枚举:把决策树的重叠子问题记忆化,把 O(2N)O(2^N) 个节点缩减到 O(NV)O(NV)(i, v)

3 · 贪心方法的提速尝试

既然 O(2N)O(2^N) 在 N=30 就不可用,自然想:能不能一次排序 + 一次扫描(O(NlogN)O(N \log N))就得到答案?比如「按价值从高到低」或「按性价比 W/C 从高到低」逐件贪心选取。下面给一个反例,把「贪心走不通」看清楚。

反例:V=10,物品 A(C6,W10)、B(C5,W7)、C(C5,W7)。两种贪心都先抓性价比/价值最高的 A,剩 4 装不下别的 → 得 10;而 DP 放弃 A、选 B+C(费用 10、价值 14)严格更优。贪心只看单件局部最优,无法权衡「不选高价值件后剩余容量的利用」。

4 · 动态规划 · 状态设计与二维实现

引入二维状态 F[i][v]F[i][v] =「在物品 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ᵢ。答案在 F[N][V]F[N][V]。下面按 i、v 两层循环逐格填表;填满后点「回溯方案」从 F[N][V]F[N][V] 反查每件选没选、把选中的落进背包。

NOTES · 复杂度谱系 · 伪多项式 · NP-hard 与 FPTAS

常有疑问:O(NV)O(NV) 是否已是最优?严格而言并非如此——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)O(NV)O(NV) 对容量 V 是线性的,但 V 在输入中以 log V 个比特表示:若 V 为 32 位整数,最坏情形 O(N232)O(N\cdot 2^{32}) 便不再是多项式。这正是 01 背包仍属 NP-hard 的根本原因——不存在对输入位长多项式的精确算法(除非 P = NP)。工程中 V 通常是较小整数(几千到几百万),O(NV)O(NV) 远低于可等待的极限,故称「够好」。需要对大 V 给出可控误差的近似时,可用 FPTAS,以 O(N3/ε)O(N^3/\varepsilon ) 换取 (1ε)(1-\varepsilon ) 近似比。

NOTES · 二维表的完整推演 · V=8、4 件物品

V=8、四件物品 (C,W) = (2,3), (3,4), (4,5), (5,6)(即上方演示的默认数据),逐行算出 F[i][v]F[i][v]:

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」。其来源为 max(F[2][6],F[2][2]+W3)=max(7,3+5)=8\max (F[2][6], F[2][2]+W_3) = \max (7, 3+5) = 8,即选取了第 3 件。最终答案 F[4][8]=10,对应方案为选第 2、第 4 件(费用 3+5=8、价值 4+6=10)。

回溯具体方案——从 F[N][V]F[N][V] 出发,对每个 i(自 N 递减至 1):若 F[i][v]=F[i1][v]F[i][v]=F[i-1][v] 则第 i 件未选、v 不变;否则第 i 件选取、v 减去 CiC_i。对照上表逐步执行:

起点 (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 采用自底向上填表:从 F[0][]F[0][\cdot ] 逐行算到 F[N][V]F[N][V]。同一递推可改写为自顶向下递归 + 记忆化的等价形式——即把 §2 提到的「2N{2^N} 决策树的重叠子问题记忆化」直接实现:从 F[N][V]F[N][V] 出发,需要哪个子问题就递归求解,首次算出后存入 memo,后续直接读取。两者求解同一函数 F[i][v]F[i][v]、用同一递推,总计算次数同为 O(NV)O(NV),差异在于:自底向上按 (i,v) 字典序遍历整张表,可滚动压缩到 O(V)O(V)(见 §5);自顶向下只触及实际用到的子问题,但需 O(NV)O(NV)memo 表加 O(N)O(N) 递归栈,无法同样压缩。状态空间稀疏时记忆化递归常数更优,稠密(如本题)时填表更快且无栈溢出风险。记忆化递归 / 自底向上 DP / 朴素树递归三者的调用次数对比,见 第 2 讲的 SICP 换零钱演示。

NOTES · 前缀与后缀:两种等价的状态对偶

本讲的 F[i][v]F[i][v]前缀视角:i 是「在物品 1..i 中选取」的候选集合上界。读者偶有把「前 i 件」误读为「剩余未判定的 i 件」,后者对应等价的后缀状态 G[j][v]G[j][v] =「从第 j 件到第 N 件中选取、费用不超过 v 的最大价值」,边界 G[N+1][v]=0,答案为 G[1][V]G[1][V]。两种定义在算法上完全等价,本讲采用前缀。须强调:「前缀 / 后缀」刻画的是候选物品集合的形状,与「一维空间压缩」一节讨论的循环方向(递增 / 递减)是两件不同的事,不要混淆。

NOTES · 多最优解与 tie-breaking

回溯时,若某步同时满足 F[i][v]=F[i1][v]F[i][v]=F[i-1][v]F[i][v]=F[i1][vCi]+WiF[i][v]=F[i-1][v-C_i]+W_i,本讲按「未选」分支返回——这只是一种任意的 tie-breaking 约定。达到同一最优值 F[N][V]F[N][V] 的物品子集可能不止一个;如何枚举或计数全部最优方案,见 第 9 讲(问法变化)的「最优方案总数」,那里把「任选一条」升级为「对每条相等分支累加」的计数语义。

NOTES · 关键概念出处

动态规划这一方法论由 Richard Bellman 于 1950 年代提出,其奠基著作 Dynamic Programming(Princeton University Press, 1957)系统确立了「最优子结构」与「重叠子问题」的框架。01 背包作为整数规划的经典实例,其 O(NV)O(NV) 算法在 1960 年代起由 GilmoreGomory 在切割下料(cutting-stock)问题的线性规划途径中系统讨论(Operations Research, 9(6), 1961)。现代算法教材均设专章介绍,如 CLRS · Introduction to Algorithms。就背包族的系统综述而言,见 Kellerer, Pferschy & Pisinger · Knapsack Problems(Springer, 2004)——该书亦明确:背包问题(就其一般化形式)是 NP-hard 的典型代表,而伪多项式算法对小整数容量足够实用。

5 · 一维空间压缩 · 循环方向

F[i][]F[i][\cdot ] 只依赖上一行 F[i1][]F[i-1][\cdot ],所以可压成一维 F[v]F[v] 滚动复用,空间 O(NV)O(V)O(NV) \to O(V)。但一维下 v 的更新方向决定语义:必须递减(V → C),才能保证写 F[v]F[v] 时读的 F[vC]F[v-C] 还是「未考虑第 i 件」的旧值 → 每件至多选一次。若递增(C → V),F[vC]F[v-C] 本轮已被改写 → 重复选同一件(那就成了完全背包)。下面左右并排对比。

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  ← 该物品被选取两次

关键在第三步:递减时读 F[2]F[2] 仍是「未处理本件」的旧值 0(等价于二维的 F[i1][2]F[i-1][2]),故每件至多一次;递增时 F[2]F[2] 已在本轮被改写成 3(等价于 F[i][2]F[i][2]),于是 F[4]F[4] 把同一件物品又算了一遍 → 6。这「同件可反复选」恰是完全背包的语义,见 第 2 讲

NOTES · 一维压缩的代价 · 失去回溯路径

一维 F 数组只保留「已处理物品 1..i 后的最优价值」,每一轮原地覆盖上一轮的全部内容——拿到 F[V]F[V]无法再回推「哪些物品被选取」,这与 §4 二维表逐行保留历史、可从 F[N][V]F[N][V] 反查的能力恰成对照。若题目同时要求最优值与具体方案,有两条补救:

其一,保留完整的二维表 F[i][v]F[i][v] 用于回溯,即放弃空间压缩、退回 §4 的二维实现。其二,在一维 DP 之外另开一张 N×VN\times V 的「决策来源」位图,每次更新时记录「本格是否选取了第 i 件」,事后据此重建方案——存储仍为 O(NV)O(NV),但相较保留整张数值表,常数更小。

6 · 初始化与边界

初值的两种含义:问「费用不超过 V 的最大价值」→ F[0..V]=0(空背包合法,任意容量都「可达」);问「恰好装满 V」→ F[0]=0F[1..V]=F[1..V]=-\infty(只有「恰好 0」是已知起点,其余暂不可达,-\infty 表「无可行方案」)。下面同一组数据并排跑两种初值,看 -\infty 如何沿转移传播。

NOTES · 两类退化边界 · 由循环范围自然吸收

物品费用全超过容量。取 V=3、物品 (C5,W100)、(C4,W80),任一物品费用均大于 V,不存在可行选取,结果 F[3]=0(什么都不选)。实现无需特判:内层循环 for (v = V; v >= Cᵢ; v--)V<CiV < C_i 时自动为空,边界由循环范围隐式吸收。

物品集为空(N=0)。对称的另一退化是外层 for (const {C,W} of items) 一次也不执行,F 在初始化后即为最终结果(一维约定下 F[0..V]=0,对应「无物品可选,任意容量最大价值均为 0」)。两类边界同理——皆由循环范围自然处理,实现层面无需任何特判。这种「边界被循环自然吸收」是 DP 实现的一个隐性优势,后续各讲也将反复利用。

7 · 应用案例 · 优惠券组合

把券门槛视为费用 C减免金额视为价值 W购物车总额视为容量 V,每张券至多用一次——这就是 01 背包。点卡片可把某张券标记为不可用,滑动总额看最优组合随之切换。下方对照「按面额贪心 / 按性价比贪心 / DP」三种策略的结果差异。

8 · 小结

本讲奠定了全系列的基础:状态 F[i][v]F[i][v]转移方程(选 / 不选取 max)、二维 / 一维实现(及 v 逆序的由来)与回溯第 2 讲(完全背包)只改一个循环方向:把一维的 v 从逆序改成顺序,就允许同一件物品反复选取。