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

完全背包与多重背包

上一章那个「bug」,这一章是正确答案。两个 ★:把「拿几件」那层循环砍掉,以及用二进制把 k 件压成 log k 堆。

例题:完全背包 · 多重背包 建议用时:120 分钟
上一章那个「bug」,其实是这一章的答案

第 23 章你亲手确认过一件事:01 背包的一维循环写成正序, 跑出来的不是垃圾,而是完全背包的正确答案 —— wrong.cppcomplete.cpp 拿 300 组数据跑,输出一模一样,一组不差。

所以这一章的第一句话是:完全背包的代码,你已经写出来了。

那还有什么可讲的?两件事,而且都比「改个方向」重要得多:

  1. 为什么正序是对的 —— 光记住方向,题目一变形你就再也推不回来。 这一章会从二维推一遍,你会看到那层「第 i 种拿几件」的循环是怎么被砍掉的。
  2. 件数有上限怎么办(多重背包:第 i 种最多 k 件)。 这是本章第二个 ★,答案是一个很漂亮的技巧:二进制拆分

1 一句话问题:三种背包的区别只有一句话

物品都是「价值 v、重量 w」,背包容量 W,求最大总价值。三种题型的差别只在每种能拿几件

题型每种最多拿几件这一章
01 背包1 件第 23 章
完全背包无限件★ 前半章
多重背包k[i] 件(题目给的)★ 后半章
★ 先记住这个「夹心」结构

01 ≤ 多重 ≤ 完全

多重背包夹在中间:k = 1 时它退化成 01 背包,k 大到「反正也装不完」时它退化成完全背包。 这个结构后面会用到三次 —— 检查代码、设计数据、debug 的时候都用得上。

2 先用手算一遍:同一组物品,三个答案

容量 W = 12
①  价值 7,重 4
②  价值 5,重 3
③  价值 3,重 2
  • 01 背包(每种最多 1 件):①+②+③ = 重 4+3+2 = 9,价值 15。装不下更多了 → 15
  • 多重背包(假设 ① 最多 2 件,②③ 各 1 件):①×2 + ② = 重 4+4+3 = 11,价值 7+7+5 = 19
  • 完全背包(无限件):①×3 = 重 12,价值 21

15 → 19 → 21,同一批物品,只因为「能拿几件」不同。这三个数后面每一步都会回来验。

3 暴力:DFS 枚举「第 i 种拿几件」

fullBrute.cppDFS 枚举拿几件
输入(stdin)
输出
点「运行 ▶」看结果

第 23 章的 2ⁿ 枚举子集在这里不够用了:「拿或不拿」只有两个分支, 而这里每种物品的分支数是 W / w[i] + 1,各不相同。

它慢得离谱,但绝对不会错 —— 这一章前半段的标准答案就是它。

4 第一版 DP:把「拿几件」直接写进转移

照着第 23 章的套路改,最自然的写法就是把 k 塞进转移:

f[i][j] = max{ f[i-1][j - k*w[i]] + k*v[i] }     k = 0, 1, 2, ... 只要装得下
fullNaive.cpp朴素二维 O(nW²)
输入(stdin)
输出
点「运行 ▶」看结果

它是对的(后面会用 300 组数据验),但多了一层循环: O(n × W × W/w),最坏 O(nW²)W = 40000 时这个平方就要了命。

5 ★ 关键一步(一):那层循环可以整个砍掉

★ 关键的一步

盯住朴素转移里被枚举的那一排来源:f[i-1][j]f[i-1][j-w]f[i-1][j-2w]f[i-1][j-3w]……

现在把它们按「第 i 种拿了几件」分成两类

  • 一件都不拿f[i-1][j]。就一个。
  • 至少拿一件:先放一件进去(花掉 w、赚到 v),剩下的容量 j-wi 种还可以继续拿 —— 而「前 i 种物品、容量 j-w、第 i 种随便拿」 这件事,正好就是 f[i][j-w] 的定义!

★ 于是:

f[i][j] = max( f[i-1][j],  f[i][j-w[i]] + v[i] )
                            ↑↑↑ 第一维是 i,不是 i-1

「拿 2 件、3 件、4 件……」全都递归地藏在 f[i][j-w] 里面了, 因为那一格自己也是这么算出来的。一层循环,凭空消失。

⚠ 请把它和第 23 章那句铁律并排放

转移右边的第一维因为
01 背包(第 23 章)必须是 i-1每件最多一件,不能从「已经考虑过它」的局面再拿
完全背包(本章)就是 i每件想拿几件拿几件,从「已经拿过它」的局面继续拿正合适

是题目决定写 i 还是 i-1。倒序 / 正序只是它在一维下的写法,不是两条要背的口诀。

6 动画:来源从「一整排」塌缩成「两格」

★ 完全背包:把「拿几件」那层循环砍掉
答案 18
第 1 / 42 步
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
·
·
·
·
·
·
·
·
·
·
这一格看了几个来源
0
累计看了多少个格子
0
答案(两种写法一样)
蓝色 = 正在填的 f[i][j],绿色 = 上一行的来源,红色 = 同一行的来源 f[i][j−w]。 红色那一格就是完全背包和 01 背包的全部区别:第 23 章不许它出现(每件最多一件), 这一章非它不可(每件想拿几件拿几件)。 两种写法答案完全相同,请只看「累计看了多少个格子」那个数 —— 那才是差别所在。
f[i][j] = 前 i 种物品、容量不超过 j 的最大价值。朴素转移要问「第 i 种拿几件」,于是要看上一行的一整排格子:j、j−w、j−2w……

下拉框切换两种写法,只看画面上那个累计查看的来源格数: 默认数据(就是第 23 章那 4 件物品,W = 9)上,朴素要看 85 个格子,优化之后只要 66 个。

⚠ 别被 85 vs 66 骗了 —— 这个比例是被 W 压小的

差距是 W / (2w) 这个量级。W = 9 太小,看起来只差一点点; 但 W 一大,朴素那边的每一格都要看几百上千个来源,而优化后永远只看两格。 第 9 步的实测表会让你看到真实的差距。

红色那一格(同一行的 f[i][j-w])才是这个动画真正要你记住的东西: 第 23 章不许它出现,这一章非它不可。

7 压成一维:这就是你上一章写出来的那三行

二维压一维,问的还是第 23 章那个问题:读 f[j-w] 时,读到的是「上一行」还是「这一行」?

只不过这次我们想要读到这一行(因为转移右边就是 i)—— 所以 j 从小到大,正序

full.cpp完全背包正解:一维正序
输入(stdin)
输出
点「运行 ▶」看结果
★ 两章合起来只有一张表
一维循环方向读到的 f[j−w] 是每种能拿几件
01 背包j = W → w[i](倒序)上一行的最多 1 件
完全背包j = w[i] → W(正序)这一行的无限件

其余部分一个字符都不差。你可以把 full.cpp 和第 23 章的 fast.cpp 并排打开对一遍。

8 ★ 反过来也成立:完全背包写成倒序,就变回了 01 背包

fullWrong.cpp✗ 故意写错:倒序
输入(stdin)
输出
点「运行 ▶」看结果

上面这份跑出来是 15 —— 正是第 2 步手算的01 背包答案。

★ 两个「bug」互为对方的正确解法

第 23 章:01 背包写成正序 → 得到完全背包的正确答案(300 组一模一样)。 本章  :完全背包写成倒序 → 得到 01 背包的正确答案(300 组一模一样)。

check:viz 把后一条也钉死了:fullWrong.cpp 和第 23 章那份 fast.cpp, 拿 300 组数据跑,输出一组不差

所以这两句口诀你只需要记住任何一句,另一句是它的反面 —— 忘了就现推。 更好的办法是连口诀都别记,只记「转移右边写 i 还是 i-1」,方向自己会掉出来。

再看一眼第 23 章那组数据(4 件物品,W = 9),三个数字连起来了:

跑法答案出处
第 23 章 fast.cpp(01,倒序)14上一章的正确答案
第 23 章 wrong.cpp(01 写成正序)18上一章的「错误答案」
本章 full.cpp(完全背包,正序)18同一个数
本章 fullWrong.cpp(完全写成倒序)14又转回去了
第 23 章的 fast.cpp(原样搬来对照)01 背包:一维倒序

9 实测:砍掉那层循环值多少

同题对比:朴素 O(nW²) vs 正解 O(nW)
先跑 5000,再改成 20000、40000。⚠ 这次变的是 W 不是 n —— 因为多出来那层循环的长度是 W/w。
朴素 O(nW²)
正解 O(nW)

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

W朴素 O(nW²)正解 O(nW)
5 0000.074 秒0.004 秒
10 0000.273 秒0.004 秒
20 0001.059 秒0.005 秒
40 0004.196 秒0.007 秒

W 翻倍,朴素的耗时翻四倍(0.273 → 1.059 → 4.196),正解几乎是直线。

10 下半场:件数有上限(多重背包)

题目再变一个字:i 种最多只有 k[i] 件。

multiBrute.cppDFS 枚举拿几件(≤ k)
输入(stdin)
输出
点「运行 ▶」看结果

输入每行多了一个 k。上面这组就是第 2 步那组数据,跑出来是 19 —— 夹在 15 和 21 中间。

⚠ 第一个念头(用完全背包做)为什么不行

完全背包的正序会让同一种物品被拿无限多次, 它根本没有任何地方能塞下「最多 k 件」这个限制。

后面第 15 步会实测:直接拿完全背包当多重背包用,300 轮里被抓 188 轮。

11 朴素做法:把 k 件摊开成 k 件独立的物品

这个念头一点都不丢人 —— 它是对的,而且转化本身就是正解的地基:

「第 i 种最多拿 k 件」 ≡ 「有 k 件一模一样的物品,每件最多拿一件」

后面这句就是 01 背包。摊开,然后倒序,一个字都不用改。

multiNaive.cpp摊成 k 件,跑 01 背包
输入(stdin)
输出
点「运行 ▶」看结果

(它会在 [stderr] 里顺带告诉你摊开成了多少件,跟下一步对比用。)

问题只有一个:复杂度是 O(W × Σk[i])。 100 种物品、每种 1000 件、W = 5000 → 五亿格,交上去就是 TLE。

12 ★ 关键一步(二):二进制拆分

★ 关键的一步

再问一遍:DP 到底需要什么?

不需要「这是第 3 件还是第 7 件」,它只需要能凑出 0 ~ k 之间的任意件数。 至于是怎么凑出来的,DP 一点都不关心。

而「用最少的堆凑出 0 ~ k 的所有整数」,第 3 章已经回答过了 —— 二进制

1, 2, 4, 8, ..., 2^(t-1),  余数

其中 2^t - 1 ≤ k,最后单独放一堆余数 k - (2^t - 1)(为 0 就不要)。

★ 为什么一定够用(两句话):

  1. 1,2,4,…,2^(t-1) 能凑出 0 ~ 2^t-1 的每一个数(这就是二进制表示); 再加上余数那一堆,就能凑到 k。因为余数 ≤ 2^t-1,两段接得上,中间不留空。
  2. 而所有堆加起来正好等于 k,所以也凑不出比 k 更多的件数 —— 上限也管住了。

于是件数从 k 掉到 ⌈log₂(k+1)⌉k = 1000 时从 1000 堆变成 10 堆,一百倍。

拆完之后,每一堆当成一件普通物品(价值 t·v、重量 t·w), 因为每堆只能「整堆拿或整堆不拿」—— 那正是 01 背包,倒序照旧。

multi.cpp多重背包正解:二进制拆分
输入(stdin)
输出
点「运行 ▶」看结果

13 动画 + 把「一定凑得出」验给你看

★ 二进制拆分:k 件变成 log k 堆
13 件 → 4 堆(最大 40)
第 1 / 21 步
分好的堆(每一堆只能整堆拿或整堆不拿 —— 那就是 01 背包)
还剩 13
朴素要几件
13
拆成几堆
0
验证阶段
零到 k 全能凑出
上面每一竖列是一堆,小方块是堆里的件数(超过 8 件就省略)。 验证阶段绿色的那几堆,就是凑出「拿 N 件」用到的堆。 请特别看 k 不是 2ⁿ−1 的情况(比如默认的 13):最后那一堆是**余数**, 正是它把 0~k 的后半段接上的。
一共 13 件同样的物品。朴素做法是摊成 13 件独立物品,但我们真正需要的只是「能凑出 0 ~ 13 之间的任意件数」—— 那就用二进制。

动画分两段:先分堆(1、2、4……分不动了把余数单独成一堆), 然后把 0 ~ k 每一个件数都凑一遍给你看

改改上面的 k 试试 7(正好是 2³-1,没有余数)和 8(余数是 1),感受一下余数那一堆的作用。

split.cpp拆分表 + 逐个验证 0~k
输入(stdin)
输出
点「运行 ▶」看结果

不给输入就用一组默认的 k。输出:

     k   朴素件数   二进制堆数   拆法                             零到 k 全能凑出
  ----   --------   ----------   ------------------------------   ---------------
     1          1            1   1                                是
     7          7            3   1+2+4                            是
     8          8            4   1+2+4+1                          是
    13         13            4   1+2+4+6                          是
   100        100            7   1+2+4+8+16+32+37                 是
  1000       1000           10   1+2+4+8+16+32+64+128+256+489     是
这份代码不是「打印一下」,它是在证明

最后一列不是写死的「是」—— split.cpp 对每个 k 都把 0 ~ k 全枚举一遍, 真的用子集和检查每个件数凑不凑得出来。

check:viz 又把整张表和动画那边的拆分逐行对了一次 (堆数、具体拆法、以及那个「是」)。

14 实测:Σk 变成 Σlog k 值多少

同题对比:朴素:摊成 Σk 件 vs 二进制拆分:Σlog k 堆
先跑 1000,再改成 10000、100000。⚠ 这次变的是 k —— n 和 W 都不动,因为拉开差距的只有 k。
朴素:摊成 Σk 件
二进制拆分:Σlog k 堆

本机实测(n = 100W = 5000 固定,只改件数上限 k):

k 上限朴素摊开二进制拆分朴素耗时拆分耗时
10530 件276 堆0.005 秒0.004 秒
1005 140 件581 堆0.012 秒0.005 秒
1 00053 340 件915 堆0.087 秒0.005 秒
10 000480 340 件1 232 堆0.770 秒0.005 秒
100 0005 140 340 件1 585 堆8.252 秒0.005 秒

k 每涨十倍,朴素那列也涨十倍;而二进制那列每次只多三百来堆(每种物品多 3 ~ 4 堆)。 这就是「乘法」和「对数」的区别。

顺手一个能白捡的优化

k 再大也没用 —— 装满整个背包也只能放 W / w[i] 件。所以读入时加一句:

k = min(k, W / w);

k = 100000w = 20W = 5000 时,k 立刻被压到 250。 这一句同时也解释了完全背包为什么是多重背包的特例k 无限大,等价于 k = W / w

15 ★ 对拍(两台)

前半章:完全背包。 标准答案是 DFS 枚举拿几件。

对拍器
★ 生成器必须造出「轻」物品(w ≤ W/2)—— 只有同一种能拿第二件时,完全背包和 01 背包的答案才会不一样。全是重物品的数据,方向写反了也看不出来。

后半章:多重背包。 标准答案同样是 DFS,而不是 multiNaive.cpp —— 那份和 multi.cpp 都是「拆成 01 再 DP」,同一个思路写两遍只能验出打字错误 (第 20 章那条规矩)。

对拍器
★ 生成器的灵魂是「k 有大有小」:k 很小(1~3)、k 贴着 W/w 的边界、k 大到拿不完,三种都要有。

300 轮实测,五种故意写错的版本全被抓住:

故意写错的地方被抓第几轮
完全背包写成倒序290 / 300第 1 轮
完全背包内层写成 j > w[i](差一)208 / 300第 2 轮
二进制拆分忘了余数那一堆137 / 300第 1 轮
二进制拆分不扣 k(堆的总和超过 k)79 / 300第 1 轮
拿完全背包当多重背包(无视 k 上限)188 / 300第 1 轮
★ 同样三个 bug,换一批数据就一个都抓不住

我把多重背包的生成器改成「只造大 k」(每种都多到装不完),同样跑 300 轮:

故意写错的地方正常数据只有大 k 的数据
忘了余数那一堆137 / 3003 / 300
不扣 k,总和超过 k79 / 3000 / 300
无视 k 上限188 / 3000 / 300

后两个一轮都抓不到。道理很简单:k 大到反正装不完的时候, 多重背包本来就退化成了完全背包 —— 上限写没写对,答案根本没区别。

这是第 7、20、22 章那条规矩的又一次现形,而且这次代价最惨重: 要随机的是「算法依赖的那个东西」。这里依赖的是 k 的大小,不是 n、不是 W。 一个只造大 k 的生成器,跑一万轮也是绿的,交上去就是 WA。

16 这一章可以带走的四样东西

★ 关键的一步

【1】不要记「01 倒序、完全正序」这两句口诀,记转移右边写 i 还是 i-1 每件最多一件 → 必须 i-1 → 一维倒序;每件随便拿 → 就是 i → 一维正序。 两个方向互为对方的正确解法,忘了任何一句都能从这里推回来。

【2】「枚举拿几件」这层循环,往往可以被一个「同一行的引用」吃掉。 f[i][j-w] 里已经装着「第 i 种再拿几件」的全部情况了。 这个「让状态自己递归地包含更多情况」的手法,在完全背包之外还会反复见到。

【3】需要的不是「哪几件」,而是「能凑出哪些数量」—— 于是二进制。 k 件 → log k 堆。它和第 3 章的二进制枚举、第 28 章的状压是同一族的东西: 把「一个集合」和「一个整数」对应起来。

【4】生成器要打在「算法依赖的那个量」上。 这一章依赖的是 k 的大小。只造大 k,三个真 bug 里有两个一轮都抓不到。 写完生成器先问自己一句:我这份数据,能让错误的写法必定失败吗?

下一章预告

第 25 章:二维费用背包与分组背包。

多一维费用(比如同时限制重量和体积)就多一层循环,方向照旧 —— 到那时你会发现,本章这张「写 i 还是 i-1」的表原样就能用

分组背包则是另一种限制:「每组至多选一个」,循环顺序不能错 —— 又一个「顺序写反了不报错、只给你错答案」的例子。

再往后:多重背包其实还能做到 O(nW)(单调队列优化), 那要等第 35 章讲完单调队列再回来收这个尾。

17 自测

自测清单0 / 10
配套练习
  • 洛谷 P1616 疯狂的采药 —— 完全背包裸题,就是第 23 章 P1048 的完全背包版。两题对着交一遍,方向的差别一辈子忘不了
  • 洛谷 P1853 投资的最大效益 —— 完全背包 + 多年滚动。每年跑一次完全背包,本金滚到下一年 —— 「DP 套在循环里」的入门题
  • 洛谷 P1776 宝物筛选 —— 多重背包模板题,n·k 大到不拆分必 TLE。二进制拆分的标准练习
  • 洛谷 P2347 砝码称重 —— NOIP1996。布尔多重背包(能不能称出某个重量),转移是 f[j] |= f[j-w]。数据小,拆不拆都能过 —— 正好拿来验证「拆完答案不变」
  • 洛谷 P1077 摆花 —— NOIP2012。多重背包的方案数版本:max 换成加法、初值 f[0]=1(第 23 章第 12 步那个套路)
  • 洛谷 P5365 英雄联盟 —— 进阶。要先看出「买 k 个皮肤的花费」是分组背包/多重的味道,而且答案要开 long long。适合确认自己是真的会了
这一章我卡在哪(过一个月回来看,这几行比整章正文都值钱)