阶段 5 · 动态规划 · 第 23 章

01 背包

阶段 5 的重头戏。二维表谁都会填,这一章真正要过的关是:压成一维之后,循环为什么必须倒着写。

例题:01 背包 建议用时:120 分钟
这一章你已经见过两次了

第 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 = 86 + 8 = 14
② + ③4 + 5 = 95 + 8 = 13
① + ② + ④3 + 4 + 2 = 96 + 5 + 2 = 13
① + ②3 + 4 = 76 + 5 = 11
③ + ④5 + 2 = 78 + 2 = 10
只拿 ③58

三件的组合只有 ①②④ 装得下(其它都超 9),四件全拿是 14 更装不下。 所以答案是 14,拿第 ① 和第 ③ 件,总重 8 —— 还空着一格没装满

记住这个「空着一格」

最优解并不需要正好装满。这件事在第 12 步会变成一整个坑: 题目一旦改成「必须恰好装满」,同一组数据的答案就从 14 掉到 13

3 暴力:2ⁿ 枚举子集(第 20 章那份,原样搬过来)

knapBrute.cpp(第 20 章)2ⁿ 枚举子集
输入(stdin)
输出
点「运行 ▶」看结果

每件物品「拿或不拿」,2ⁿ 种组合全试一遍,装得下就更新答案。 没有任何想法,所以绝对不会错 —— 这一章后面所有写法都拿它当尺子。

这份代码和它的生成器一个字都不用改就能复用,因为第 20 章打贪心的时候就是用它当标准答案的。 (第 21 章复用第 17 章的暴力,也是同一件事。标准答案和生成器是跨章节资产,别重写。

4 实测:暴力慢在哪

同题对比:2ⁿ 枚举子集 vs O(nW) 的 DP
先跑 22,再改成 25、27。⚠ 容量 W 固定 1000 不跟着变,这样变的只有 n。别超过 28。
2ⁿ 枚举子集
O(nW) 的 DP

本机实测(容量固定 W = 1000,只改物品件数 n):

n2ⁿ 枚举子集O(nW) 的 DP
200.086 秒0.006 秒
220.344 秒0.005 秒
241.395 秒0.005 秒
252.840 秒0.006 秒
265.717 秒0.005 秒
2711.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 件 —— 它就被拿了两次。

这一句是整章的地基。后面所有的坑,坑底都是它。

dp2.cpp二维 DP(先写这个)
输入(stdin)
输出
点「运行 ▶」看结果

6 动画:二维表是怎么填出来的

二维表:每一格的两个来源都在上一行
答案 14
第 1 / 46 步
0
1
2
3
4
5
6
7
8
9
不挑
0
0
0
0
0
0
0
0
0
0
1: v6 w3
·
·
·
·
·
·
·
·
·
·
2: v5 w4
·
·
·
·
·
·
·
·
·
·
3: v8 w5
·
·
·
·
·
·
·
·
·
·
4: v2 w2
·
·
·
·
·
·
·
·
·
·
f[n][W]
回溯出的方案
总重量 / 容量
蓝色 = 正在填的 f[i][j],灰边 = 「不拿」的来源(正上方), 绿色 = 「拿」的来源(左上方 j−w 那一格,填实心表示这一格最后选了「拿」)。 两个来源**都在上一行** —— 记住这件事,下一个动画的倒序就不用背了。 最后那几步是倒着回溯方案,绿色连成的就是走过的路。
f[i][j] = 只在前 i 件物品里挑、容量不超过 j 时的最大价值。第 0 行是「一件都不挑」,全是 0 —— 这就是边界,不用想。

每填一格,画面会同时高亮它的两个来源:正上方(不拿)和左上方 j−w(拿)。 看两遍,把「两个来源都在上一行」这件事看进眼睛里 —— 下一步就不用背口诀了。

动画最后几步是倒着走一遍还原方案:从 f[n][W] 出发, 和正上方一样就是「没拿」,不一样就是「拿了」,往左上跳 w[i] 格。默认数据上走出来是第 1、3 件,总重 8

7 第一刀:只留两行(滚动数组)

二维表有个现实问题:n = 1000W = 10⁵ 时是 1 亿格 × 8 字节 = 800 MB, 空间先炸,跟时间没关系。

但看一眼转移就会发现:f[i][*] 只用到 f[i-1][*]再往前的行一辈子用不着了。 那留着 n+1 行干什么?留两行,轮流当「上一行」和「这一行」:

roll.cpp滚动数组(两行)

cur = i & 1pre = cur ^ 1,转移一个字没改。这里 j 正着倒着都行 —— 因为 curpre 是两块不同的内存,写 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

fast.cpp一维倒序(竞赛里就写这三行)
输入(stdin)
输出
点「运行 ▶」看结果

9 动画:把「同一件物品被拿了三次」直接画出来

★ 一维数组:倒序读到的是上一行,正序读到的是这一行
第 1 / 32 步
物品:1(v6 w3) 2(v5 w4) 3(v8 w5) 4(v2 w2)
0
1
2
3
4
5
6
7
8
9
0
0
0
0
0
0
0
0
0
0
·
·
·
·
·
·
·
·
·
·
这种写法给出
0
正确答案(2ⁿ 枚举)
14
同一件物品最多被拿
0 次
蓝色 = 正在算的 f[j],黄色 = 它读的那一格 f[j−w],红色 = 它读的那一格本轮已经被改过(只有正序才会出现)。 下面那排圆点是「这一格里装了几件当前这轮的物品」:一个点正常, 两个点以上就是同一件东西被拿了不止一次。倒序那边这排点永远不超过一个。
一维 f[j] = 容量 j 时的最大价值。正序:j 从小到大 —— 盯住它读的那一格是什么颜色。

下拉框可以切换倒序 / 正序。盯住两样东西:

  • 被读的那一格是什么颜色:黄色 = 这轮还没动过(正常),红色 = 这轮刚被改过(出事了);
  • 每一格下面那排圆点:这一格里装了几件当前这一轮的物品。一个点正常,两个点以上就是重复拿。

倒序那边圆点永远不超过一个,红色一次都不会出现。 正序那边处理第一件物品时就已经出事:一件价值 6、重 3 的东西,在 f[9] 里凑出了 18(3 × 6)。

10 逐行看:trace 把两种方向并排打出来

trace.cpp每处理完一件物品就打印整个 f
输入(stdin)
输出
点「运行 ▶」看结果

不给输入就用默认那组数据。输出长这样:

倒序(正确)                       正序(错误)
初始    :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.cppcomplete.cpp 的输出 300 组一模一样,一组不差。

complete.cpp完全背包(正序就是它)
顺带解决一个二维写法里的等价错误

初学者在二维表里最常见的手滑,是把转移右边写成 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 正好装满」在还没放东西时根本不存在
exact.cpp两种思路并排算「恰好装满」
输入(stdin)
输出
点「运行 ▶」看结果

同一组默认数据:不要求装满是 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 要输出「拿了哪几件」怎么办

path.cpp连方案一起还原
输入(stdin)
输出
点「运行 ▶」看结果
★ 要方案 → 就不能用一维

第 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 = 500W = 20000(一千万格)时,三种写法本机实测 (数据是 ./genBig 500 20000 造的,种子固定,你可以原样复现):

写法峰值内存答案
二维 f[n+1][W+1]84 152 KB(约 82 MB)181948
滚动两行4 280 KB181948
一维4 060 KB181948

(后两个里有约 3.7 MB 是进程本身的固定开销,真正的数组只有 160 KB。)

答案完全一样,空间差 20 倍。竞赛的内存限制通常是 128 MB 或 256 MB —— n = 1000W = 10⁵ 时二维要 800 MB,这不是「优化」,是能不能交题的问题

15 ★ 对拍

对拍器
生成器是第 20 章打贪心时写的那个,一个字没改:容量小、重量和容量同一量级 —— 这样「装不下」才是常态,各种写错才会露出来。

把右边换成你自己写的,或者换成下面这些故意写错的版本。300 轮实测,每一种都被抓住了

故意写错的地方300 轮里被抓第几轮首次被抓
一维正序j = w[i] → W251 轮第 2 轮
二维转移右边写成 f[i][j-w[i]]251 轮第 2 轮
每处理一件物品就把 f 清零290 轮第 1 轮
内层循环写成 j > w[i](差一,漏掉正好用完的那格)200 轮第 1 轮
「恰好装满」却把初值全写成 096 轮第 2 轮
wrong.cpp✗ 故意写错:正序
⚠ 注意它错得有多温柔:300 轮里有 49 轮蒙对了

第 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 自测

自测清单0 / 10
配套练习
  • 洛谷 P1048 采药 —— NOIP2005。最标准的 01 背包模板题,时间就是重量、价值就是价值。先交二维再交一维,对比一下内存
  • 洛谷 P1049 装箱问题 —— NOIP2001。只有体积没有价值 —— 把体积同时当价值就行。这一步转化是背包题的第一道门槛
  • 洛谷 P1164 小A点菜 —— 求「恰好花完」的方案数。本章第 12 步那个套路的直接应用:初值 f[0] = 1,其余 0,转移从 max 换成加法
  • 洛谷 P1060 开心的金明 —— NOIP2006。价值 = 价格 × 重要度,读懂题就是模板。适合用来确认自己是真的会了
  • 洛谷 P2925 干草出售 —— USACO。「恰好装满」的味道,而且数据范围大到必须用一维 —— 二维会 MLE
  • 洛谷 P1877 音量调节 —— 状态是「能不能达到某个音量」(布尔背包)。转移变成 f[j] |= f[j-w],倒序的道理一模一样
这一章我卡在哪(过一个月回来看,这几行比整章正文都值钱)