第 3 章用二进制枚举列过「每件拿或不拿」的所有子集; 第 20 章拿 01 背包当靶子,证明了「按性价比排序」的贪心是错的(160 vs 220)。
两次都欠着同一个东西:那正确的解法到底是什么。 这一章把它补上。
而这一章真正的难关不在「写出 DP」—— 二维表照着转移方程填,十分钟就会。 难关在最后那一步压缩:把二维压成一维之后,内层循环必须倒着写。
正着写不报错、不崩溃、不警告,只是安静地给你一个偏大的答案 —— 和第 21 章那个「填错顺序」是同一类毛病。这次我们把它画出来, 而且会证明一件更有意思的事:它不是随机地错,它精确地解了另一道题。
1 一句话问题
有 n 件物品,第 i 件价值 v[i]、重量 w[i],每件最多拿一件(不能切开、不能拿两件)。
背包最多装 W 的重量。求能装走的最大总价值。
输入 4 9 ← 4 件物品,容量 9
6 3 ← 价值 6,重 3
5 4
8 5
2 2
输出 14
「01」这两个字就是说:每件物品的选择只有 0(不拿)和 1(拿)两种,没有中间状态。 (可以切开的那个版本叫「部分背包」,第 20 章讲过 —— 那题贪心是对的。 一字之差,难度天差地别,原因第 20 章也讲透了:交换论证里「拿出来一点点」这一步做不了。)
2 先用手算一遍
容量只有 9,把装得下的组合都列出来:
| 拿哪几件 | 总重 | 总价值 |
|---|---|---|
| ① + ③ | 3 + 5 = 8 | 6 + 8 = 14 |
| ② + ③ | 4 + 5 = 9 | 5 + 8 = 13 |
| ① + ② + ④ | 3 + 4 + 2 = 9 | 6 + 5 + 2 = 13 |
| ① + ② | 3 + 4 = 7 | 6 + 5 = 11 |
| ③ + ④ | 5 + 2 = 7 | 8 + 2 = 10 |
| 只拿 ③ | 5 | 8 |
三件的组合只有 ①②④ 装得下(其它都超 9),四件全拿是 14 更装不下。 所以答案是 14,拿第 ① 和第 ③ 件,总重 8 —— 还空着一格没装满。
最优解并不需要正好装满。这件事在第 12 步会变成一整个坑: 题目一旦改成「必须恰好装满」,同一组数据的答案就从 14 掉到 13。
3 暴力:2ⁿ 枚举子集(第 20 章那份,原样搬过来)
点「运行 ▶」看结果
每件物品「拿或不拿」,2ⁿ 种组合全试一遍,装得下就更新答案。
没有任何想法,所以绝对不会错 —— 这一章后面所有写法都拿它当尺子。
这份代码和它的生成器一个字都不用改就能复用,因为第 20 章打贪心的时候就是用它当标准答案的。 (第 21 章复用第 17 章的暴力,也是同一件事。标准答案和生成器是跨章节资产,别重写。)
4 实测:暴力慢在哪
本机实测(容量固定 W = 1000,只改物品件数 n):
| n | 2ⁿ 枚举子集 | O(nW) 的 DP |
|---|---|---|
| 20 | 0.086 秒 | 0.006 秒 |
| 22 | 0.344 秒 | 0.005 秒 |
| 24 | 1.395 秒 | 0.005 秒 |
| 25 | 2.840 秒 | 0.006 秒 |
| 26 | 5.717 秒 | 0.005 秒 |
| 27 | 11.803 秒 | 0.005 秒 |
n 每加 1,暴力的时间就翻一倍(0.344 → 1.395 → 5.717 → 11.803,每加 2 就翻四倍)。
而 DP 那一列压根没动 —— 因为它的工作量是 n × W 个格子,
n 从 20 涨到 27,格子从 20000 涨到 27000,根本不算涨。
一条是指数,一条是多项式。n = 100 时暴力要 2¹⁰⁰ 步(宇宙年龄不够用),
DP 只要 10 万格,还是眨眼的事。
慢在哪:2ⁿ 个子集里,绝大多数只是「前几件的选择相同、后面不同」的重复劳动。
DP 要做的就是把「前 i 件已经选完之后的局面」归成一类,只算一次。
5 ★ 关键一步(一):状态,以及那个决定一切的 i−1
沿用第 21、22 章的三件套。状态要能回答「做后面的决定还需要知道什么」, 这题需要知道两件事:还剩几件物品没考虑、背包还能装多少。于是两维:
f[i][j]= 只在前i件物品里挑、总重量不超过j时,能拿到的最大价值
转移用第 21 章那句万能问法 —— 最后一件物品(第 i 件)是拿还是不拿?
只有两种,都试一遍取大的:
f[i][j] = f[i-1][j] // 不拿第 i 件
f[i][j] = max(f[i][j], f[i-1][j - w[i]] + v[i]) // 拿第 i 件(前提 j >= w[i])边界:f[0][j] = 0 —— 一件都不挑,价值当然是 0。这一行不用想,白送的。
顺序:i 从小到大(第 i 行依赖第 i-1 行),j 随便。答案在 f[n][W]。
★ 现在盯住转移右边那两个式子:它们的第一维都是 i-1,一个 i 都没有。
这不是巧合,是「每件最多拿一件」这句题意的全部化身:
第 i 件物品只能从还没考虑过它的局面上叠加。
一旦右边出现 f[i][...],那就是从「已经考虑过第 i 件」的局面再加一件第 i 件 —— 它就被拿了两次。
这一句是整章的地基。后面所有的坑,坑底都是它。
点「运行 ▶」看结果
6 动画:二维表是怎么填出来的
每填一格,画面会同时高亮它的两个来源:正上方(不拿)和左上方 j−w(拿)。
看两遍,把「两个来源都在上一行」这件事看进眼睛里 —— 下一步就不用背口诀了。
动画最后几步是倒着走一遍还原方案:从 f[n][W] 出发,
和正上方一样就是「没拿」,不一样就是「拿了」,往左上跳 w[i] 格。默认数据上走出来是第 1、3 件,总重 8。
7 第一刀:只留两行(滚动数组)
二维表有个现实问题:n = 1000、W = 10⁵ 时是 1 亿格 × 8 字节 = 800 MB,
空间先炸,跟时间没关系。
但看一眼转移就会发现:f[i][*] 只用到 f[i-1][*],再往前的行一辈子用不着了。
那留着 n+1 行干什么?留两行,轮流当「上一行」和「这一行」:
cur = i & 1,pre = cur ^ 1,转移一个字没改。这里 j 正着倒着都行 ——
因为 cur 和 pre 是两块不同的内存,写 cur 的时候 pre 那行是完整的、没被这轮碰过的上一行。
请把这句话记住一秒钟,因为下一刀砍掉的正是它。
8 ★ 关键一步(二):第二刀砍掉之后,倒序是唯一的活路
两行也别留了。反正每一格只是「拿自己和 j−w 那格比一比」,就在一行上原地改:
for (int i = 1; i <= n; i++)
for (long long j = W; j >= w[i]; j--) // ★ 倒着!
f[j] = max(f[j], f[j - w[i]] + v[i]);★ 为什么必须倒着。现在上一行和这一行挤在同一块内存里了,
所以每次读 f[j - w[i]] 都要问一句:这一格现在是「上一行的值」还是「这一行的值」?
j - w[i] 比 j 小,所以答案完全取决于扫描方向:
| 方向 | 比 j 小的格子这轮…… | 读到的是 | 相当于二维的 |
|---|---|---|---|
倒序 j = W → w[i] | 还没轮到 | 上一行的值 ✓ | f[i-1][j-w] ✓ |
正序 j = w[i] → W | 刚刚被改过 | 这一行的值 ✗ | f[i][j-w] ✗ |
而 f[i][j-w] 里可能已经装了第 i 件物品 —— 再加一件,它就被拿了第二次、第三次……
倒序不是规定,是「不许出现 f[i][...]」这条铁律在一维下的唯一实现方式。
你现在不需要背它,只需要记得第 5 步那句:转移右边的第一维必须是 i−1。
点「运行 ▶」看结果
9 动画:把「同一件物品被拿了三次」直接画出来
下拉框可以切换倒序 / 正序。盯住两样东西:
- 被读的那一格是什么颜色:黄色 = 这轮还没动过(正常),红色 = 这轮刚被改过(出事了);
- 每一格下面那排圆点:这一格里装了几件当前这一轮的物品。一个点正常,两个点以上就是重复拿。
倒序那边圆点永远不超过一个,红色一次都不会出现。
正序那边处理第一件物品时就已经出事:一件价值 6、重 3 的东西,在 f[9] 里凑出了 18(3 × 6)。
10 逐行看:trace 把两种方向并排打出来
点「运行 ▶」看结果
不给输入就用默认那组数据。输出长这样:
倒序(正确) 正序(错误)
初始 :0 0 0 0 0 0 0 0 0 0 初始 :0 0 0 0 0 0 0 0 0 0
第 1 件后:0 0 0 6 6 6 6 6 6 6 第 1 件后:0 0 0 6 6 6 12 12 12 18 ← 已经错了
第 2 件后:0 0 0 6 6 6 6 11 11 11 第 2 件后:0 0 0 6 6 6 12 12 12 18
第 3 件后:0 0 0 6 6 8 8 11 14 14 第 3 件后:0 0 0 6 6 8 12 12 14 18
第 4 件后:0 0 2 6 6 8 8 11 14 14 第 4 件后:0 0 2 6 6 8 12 12 14 18
答案 = 14 答案 = 18
正序那边第一行就已经错了:只处理了一件物品,f[6] 就是 12、f[9] 就是 18。
这就是为什么 check:viz 对这份代码验的是每一行,而不只是最后的答案 ——
「答案蒙对了但中间过程早就错了」这种情况,只比答案是查不出来的。
(这也是本站所有动画的规矩:连画面上的计数器一起比。)
11 ★ 正序不是「随机地错」—— 它精确地解了另一道题
把 fast.cpp 的倒序改成正序,你得到的不是垃圾,而是一份完全正确的完全背包代码
(完全背包 = 每件物品有无限多件,第 24 章的内容)。
// 01 背包(每件最多一件)
for (long long j = W; j >= w[i]; j--) f[j] = max(f[j], f[j - w[i]] + v[i]);
// 完全背包(每件无限件)
for (long long j = w[i]; j <= W; j++) f[j] = max(f[j], f[j - w[i]] + v[i]);两份代码逐字符对比,唯一的区别就是 j 的方向。
道理是同一个,只是这回反过来用:正序时 f[j-w] 是本轮已经更新过的值,
也就是「已经考虑过第 i 件」之后的最优解 —— 在它上面再叠一件第 i 件,
正是完全背包想要的「这件还能再拿」。
check:viz 把这件事钉死了:用第 20 章的生成器造 300 组数据,
wrong.cpp 和 complete.cpp 的输出 300 组一模一样,一组不差。
初学者在二维表里最常见的手滑,是把转移右边写成 f[i][j - w[i]](少打了个 -1)。
它和一维正序是同一个 bug,而且不是「差不多」,是一模一样: 拿同样 300 组数据跑,这两份错误代码的输出 300 组完全相同。
所以「倒序」和「右边要写 i-1」根本是一句话的两种说法。记住一句就够了。
12 ⚠ 另一个坑:「恰好装满」只改初始化
题目改一个字:要求把背包正好装满(装不满输出 -1),求最大价值。
转移方程一个字都不用改。要改的只有 f 的初值:
| 题目要求 | 初值 | 为什么 |
|---|---|---|
| 不要求装满 | 全部 f[j] = 0 | 「容量 j,什么都不装」是合法状态,价值 0 |
| 必须装满 | f[0] = 0,其余 f[j] = -∞ | 「容量 j 正好装满」在还没放东西时根本不存在 |
点「运行 ▶」看结果
同一组默认数据:不要求装满是 14,要求正好装满只有 13。 因为 14 那个方案(第 1、3 件)重量是 8,差一格没装满 —— 就是第 2 步让你记住的那件事。
-∞ 不是什么魔法数字,它的意思是**「不可达」**。
转移只会从存在的状态转出去(-∞ 再加多少还是极负,永远抢不过别人),
于是「不可达」这个性质就自动一路传播下去了,最后 f[W] 还是 -∞ 就说明装不满。
同一个套路还会反复出现:
求方案数初值 f[0] = 1 其余 0;求最小价值初值 f[0] = 0 其余 +∞。
转移方程管的是「怎么算」,初值管的是「从哪儿开始、哪些地方压根没有」。
⚠ 实现细节:-∞ 别真写 LLONG_MIN,加上 v[i] 会溢出。写 LLONG_MIN / 4 之类留出余量,
或者干脆判一句「来源不可达就不转移」(exact.cpp 两样都做了)。
exact.cpp 用两种完全不同的思路算同一道题:2ⁿ 枚举出所有重量正好 = W 的组合,
和初值 -∞ 的 DP。check:viz 验这两行 300 组一致。
这是第 20 章立的规矩:标准答案要用完全不同的思路写,同一个思路写两遍只能验出打字错误。 顺带一个实测数字:300 组里有 96 组「恰好装满」和「不限装满」答案不同, 另有 39 组根本装不满 —— 这个坑在随机数据上出现得非常频繁,别指望蒙混过关。
13 要输出「拿了哪几件」怎么办
点「运行 ▶」看结果
第 22 章那个「记 pre 再回溯」的通用套路,在背包这里有个更省事的版本:
什么都不用记,因为二维表本身就是历史。从 f[n][W] 倒着走:
f[i][j] == f[i-1][j]→ 第i件没拿,跳到(i-1, j)- 否则 → 第
i件拿了,跳到(i-1, j - w[i])
而这正好点破了一维写法的代价:它把中间过程全覆盖掉了,没有历史可以回溯。
要最优值 → 一维;要方案 → 老老实实开二维表。 这条规律对所有 DP 都成立,不是背包特有的。
check:viz 对这份代码做的是硬验证:选出的编号不重不越界、总重量 ≤ W、
而且价值之和正好等于第一行那个最大值。只验第一行的话,
「价值对了但方案是编的」这种 bug 一样抓不出来。
14 顺带一测:一维到底省了多少空间
n = 500、W = 20000(一千万格)时,三种写法本机实测
(数据是 ./genBig 500 20000 造的,种子固定,你可以原样复现):
| 写法 | 峰值内存 | 答案 |
|---|---|---|
二维 f[n+1][W+1] | 84 152 KB(约 82 MB) | 181948 |
| 滚动两行 | 4 280 KB | 181948 |
| 一维 | 4 060 KB | 181948 |
(后两个里有约 3.7 MB 是进程本身的固定开销,真正的数组只有 160 KB。)
答案完全一样,空间差 20 倍。竞赛的内存限制通常是 128 MB 或 256 MB ——
n = 1000、W = 10⁵ 时二维要 800 MB,这不是「优化」,是能不能交题的问题。
15 ★ 对拍
把右边换成你自己写的,或者换成下面这些故意写错的版本。300 轮实测,每一种都被抓住了:
| 故意写错的地方 | 300 轮里被抓 | 第几轮首次被抓 |
|---|---|---|
一维正序(j = w[i] → W) | 251 轮 | 第 2 轮 |
二维转移右边写成 f[i][j-w[i]] | 251 轮 | 第 2 轮 |
每处理一件物品就把 f 清零 | 290 轮 | 第 1 轮 |
内层循环写成 j > w[i](差一,漏掉正好用完的那格) | 200 轮 | 第 1 轮 |
| 「恰好装满」却把初值全写成 0 | 96 轮 | 第 2 轮 |
第 1 轮就是蒙对的一轮 —— 那组数据只有 2 件物品、容量 2, 唯一装得下的那件恰好塞满,想拿第二件也没地方,正序和倒序自然一样。
所有 w[i] 都大于 W/2 的时候,正序和倒序结果必然相同(拿两件根本装不下)。
你要是随手编几组「东西很重、包很小」的数据自测,会全部通过。
这正是第 20 章那张「300 轮里错了 34 / 151 / 37 轮」的表想说的事: 九成场合都对的错误代码,比一眼就崩的错误代码危险得多。
16 这一章可以带走的四样东西
【1】转移右边的第一维必须是 i−1。
这是「每件最多拿一件」唯一的技术含义。一维倒序、二维不能写 f[i][j-w],
都是它的推论。记这一条,别记两条口诀。
【2】先写二维,再压。 直接背一维那三行,你迟早会背错方向;从二维推下来,倒序就是必然结果而不是规定。 考场上写不确定的时候,就在草稿纸上把二维转移写出来,方向自己会跳出来。
【3】「安静地给你错答案」是 DP 最典型的失败方式。 第 21 章填错顺序如此,这一章循环写反也如此:不报错、不崩溃、还经常蒙对。 唯一靠得住的防线是对拍,而且生成器要让「错误直觉」必定失败 —— 这里靠的是「容量小、重量和容量同一量级」。
【4】转移管「怎么算」,初值管「从哪儿开始、哪些状态压根不存在」。
恰好装满用 -∞、方案数用 1、最小值用 +∞ —— 同一个套路,后面每一章都会再用。
第 24 章:完全背包与多重背包。
★ 关键一步这次是反过来的:完全背包就是要正序 —— 你在这一章亲手确认过的那个「bug」,到那里是唯一正确的写法。 两章要并排着看,才能真正明白方向的含义。
多重背包(每件有 k 个)则会引出一个漂亮的技巧:二进制拆分,
它跟第 3 章的二进制枚举、第 28 章的状压是同一族的东西。
17 自测
- 洛谷 P1048 采药 —— NOIP2005。最标准的 01 背包模板题,时间就是重量、价值就是价值。先交二维再交一维,对比一下内存
- 洛谷 P1049 装箱问题 —— NOIP2001。只有体积没有价值 —— 把体积同时当价值就行。这一步转化是背包题的第一道门槛
- 洛谷 P1164 小A点菜 —— 求「恰好花完」的方案数。本章第 12 步那个套路的直接应用:初值 f[0] = 1,其余 0,转移从 max 换成加法
- 洛谷 P1060 开心的金明 —— NOIP2006。价值 = 价格 × 重要度,读懂题就是模板。适合用来确认自己是真的会了
- 洛谷 P2925 干草出售 —— USACO。「恰好装满」的味道,而且数据范围大到必须用一维 —— 二维会 MLE
- 洛谷 P1877 音量调节 —— 状态是「能不能达到某个音量」(布尔背包)。转移变成 f[j] |= f[j-w],倒序的道理一模一样