阶段 5 · 动态规划 · 第 24 章普及组 J

完全背包与多重背包

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

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

第 23 章你亲手确认过一件事:01 背包的一维循环写成正序, 跑出来的不是垃圾,而是完全背包的正确答案 —— wrong.cpp 和 complete.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 枚举拿几件
// 完全背包 —— 暴力:DFS 枚举「第 i 件拿几个」
//
// 为什么不能像第 23 章那样用 2ⁿ 枚举子集:完全背包里每件物品有**无限多件**,
// 「拿或不拿」不够用了,得问「拿几个」—— 而「几个」的上界是 W / w[i],
// 于是搜索树的分叉数不再是 2,而是每层各不相同。
//
// 这份代码是这一章第一个对拍的标准答案。它慢得离谱,但它绝对不会错,
// 因为它根本没有「想法」,只是把所有拿法都试了一遍。
//
// 题意:n 种物品,第 i 种价值 v[i]、重量 w[i],**每种可以拿任意多件**(包括 0 件)。
// 背包最多装 W。求最大总价值。
// 输入:第一行 n W,接下来 n 行每行两个数 v w
// 输出:最大总价值
//
// ⚠ 只能用在很小的数据上(fullGen.cpp 造的是 n ≤ 6、W ≤ 24)。
// W = 100、w[i] = 1 时这棵树就有 100 层分叉,跑到天荒地老。
#include <bits/stdc++.h>
using namespace std;
int n;
long long W, best;
vector<long long> v, w;
// 处理到第 i 种物品,还剩 rest 的容量,已经攒了 got 的价值
void dfs(int i, long long rest, long long got) {
if (i == n) { best = max(best, got); return; }
// 第 i 种拿 k 件,k 从 0 一直到装不下为止
for (long long k = 0; k * w[i] <= rest; k++)
dfs(i + 1, rest - k * w[i], got + k * v[i]);
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
if (!(cin >> n >> W)) return 0;
v.resize(n);
w.resize(n);
for (int i = 0; i < n; i++) cin >> v[i] >> w[i];
best = 0;
dfs(0, W, 0);
cout << best << "\n";
return 0;
}
点一下即可编辑
输入(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²)
// 完全背包 —— 朴素二维:把「拿几个」直接写进转移
//
// 从暴力到正解之间的那一级台阶。照着第 23 章的思路改,最自然的写法就是它:
//
// f[i][j] = max{ f[i-1][j - k*w[i]] + k*v[i] } k = 0, 1, 2, ... 只要装得下
//
// 也就是「第 i 种拿 0 件、1 件、2 件……分别是多少,取最大的」。
// 它是对的,但**多了一层 k 的循环**:复杂度 O(n × W × W/w),最坏 O(nW²)。
//
// W = 5000 时 W² 就是两千五百万,再乘 n 就跑不动了 —— 这一章的 ★ 就是来砍掉这层 k 的。
//
// 输入输出同 fullBrute.cpp。这份代码在正文里的作用是**当对照组**:
// 它和 full.cpp 答案永远相同,只是慢得多。
#include <bits/stdc++.h>
using namespace std;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
long long W;
if (!(cin >> n >> W)) return 0;
vector<long long> v(n + 1), w(n + 1);
for (int i = 1; i <= n; i++) cin >> v[i] >> w[i];
vector<vector<long long>> f(n + 1, vector<long long>(W + 1, 0));
for (int i = 1; i <= n; i++)
for (long long j = 0; j <= W; j++)
for (long long k = 0; k * w[i] <= j; k++) // ← 多出来的这一层
f[i][j] = max(f[i][j], f[i - 1][j - k * w[i]] + k * v[i]);
cout << f[n][W] << "\n";
return 0;
}
点一下即可编辑
输入(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-w 里 第 i 种还可以继续拿 —— 而「前 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完全背包正解:一维正序
// 完全背包 —— 正解。和第 23 章的 fast.cpp 只差一个方向
//
// 01 背包 :for (long long j = W; j >= w[i]; j--) ← 倒序
// 完全背包:for (long long j = w[i]; j <= W; j++) ← 正序
//
// ★ 这一章的关键一步,其实是**把 fullNaive.cpp 那层 k 的循环砍掉**。
// 先看二维:把「第 i 种拿了至少一件」这件事单独拎出来 ——
//
// 不拿第 i 种 : f[i][j] = f[i-1][j]
// 至少拿一件第 i 种 : f[i][j] = f[i][j - w[i]] + v[i]
// ↑↑↑ 注意第一维是 **i**,不是 i-1
//
// 为什么可以是 i:f[i][j-w[i]] 的含义是「前 i 种物品、容量 j-w[i] 的最优解」,
// 而第 i 种**本来就允许再拿**,所以在它上面直接加一件是合法的 ——
// 而且它内部已经把「第 i 种拿 2 件、3 件……」的情况全都算进去了。
// 于是「拿 k 件」这层循环就被一句「拿一件」递归地包含了。
//
// ⚠ 和第 23 章正好相反,那里的铁律是「右边的第一维必须是 i-1」(每件最多一件);
// 这里的铁律是「右边的第一维就是 i」(每件想拿几件拿几件)。
// **是题目决定了写 i 还是 i-1,倒序 / 正序只是它在一维下的写法。**
//
// 压成一维之后:读 f[j - w[i]] 时希望读到的是**本轮已经更新过**的值,
// 所以 j 要从小到大 —— 正序。
//
// 输入输出同 fullBrute.cpp。复杂度 O(nW) 时间、O(W) 空间。
//
// 这份代码和第 23 章的 complete.cpp 一模一样(那里是当反例出现的)。
// 你在上一章亲手确认过:01 背包写成正序,跑出来的就是完全背包的正确答案。
#include <bits/stdc++.h>
using namespace std;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
long long W;
if (!(cin >> n >> W)) return 0;
vector<long long> v(n + 1), w(n + 1);
for (int i = 1; i <= n; i++) cin >> v[i] >> w[i];
vector<long long> f(W + 1, 0);
for (int i = 1; i <= n; i++)
for (long long j = w[i]; j <= W; j++) // ★ 正序!倒着写就变回 01 背包
f[j] = max(f[j], f[j - w[i]] + v[i]);
cout << f[W] << "\n";
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
★ 两章合起来只有一张表
一维循环方向 读到的 f[j−w] 是 每种能拿几件
01 背包 j = W → w[i](倒序) 上一行的 最多 1 件
完全背包 j = w[i] → W(正序) 这一行的 无限件

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

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

fullWrong.cpp✗ 故意写错:倒序
// 完全背包写成倒序 —— 这是一份**故意写错**的代码,而且它错得和上一章完全对称
//
// 第 23 章:01 背包写成正序 → 跑出来的是**完全背包**的正确答案
// 这一章 :完全背包写成倒序 → 跑出来的是**01 背包**的正确答案
//
// 两个「bug」互为对方的正确解法。这不是巧合,是同一句话的两面:
// **方向决定了 f[j-w] 是「这一轮的」还是「上一轮的」,
// 也就决定了同一种物品能不能被反复拿。**
//
// check-viz 把这件事钉死了:用 fullGen.cpp 造 300 组数据,
// 这份代码的输出和第 23 章 `code/23-knapsack-01/fast.cpp` 的输出**300 组一模一样**。
//
// 所以「完全背包正序、01 背包倒序」这两句口诀,你只要记住其中任何一句 ——
// 另一句是它的反面,忘了就现推。
//
// 输入输出同 fullBrute.cpp。把它贴进对拍器(标准答案用 fullBrute.cpp)看看第几轮被抓。
#include <bits/stdc++.h>
using namespace std;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
long long W;
if (!(cin >> n >> W)) return 0;
vector<long long> v(n + 1), w(n + 1);
for (int i = 1; i <= n; i++) cin >> v[i] >> w[i];
vector<long long> f(W + 1, 0);
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]);
cout << f[W] << "\n";
return 0;
}
点一下即可编辑
输入(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 000 0.074 秒 0.004 秒
10 000 0.273 秒 0.004 秒
20 000 1.059 秒 0.005 秒
40 000 4.196 秒 0.007 秒

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

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

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

multiBrute.cppDFS 枚举拿几件(≤ k)
// 多重背包 —— 暴力:DFS 枚举「第 i 种拿几件」(0 ~ k[i])
//
// 这一章第二个对拍的标准答案。和 fullBrute.cpp 只差一处:
// 每种物品的件数不再是「无限」,而是最多 k[i] 件。
//
// ★ 它是**完全不同的思路**:不做任何 DP,也不做任何拆分,只是把所有拿法试一遍。
// 这一点很重要 —— multiNaive.cpp 和 multi.cpp 本质上都是「拆成 01 背包再 DP」,
// 拿它们互相对拍只能验出打字错误,验不出「拆分本身就想错了」。
// (第 20 章那条规矩:**标准答案最好用完全不同的思路写。**)
//
// 题意:n 种物品,第 i 种价值 v[i]、重量 w[i]、**最多 k[i] 件**。背包最多装 W。
// 输入:第一行 n W,接下来 n 行每行三个数 v w k
// 输出:最大总价值
//
// ⚠ 只能用在很小的数据上(multiGen.cpp 造的是 n ≤ 5、W ≤ 30)。
#include <bits/stdc++.h>
using namespace std;
int n;
long long W, best;
vector<long long> v, w, cnt;
void dfs(int i, long long rest, long long got) {
if (i == n) { best = max(best, got); return; }
for (long long t = 0; t <= cnt[i] && t * w[i] <= rest; t++)
dfs(i + 1, rest - t * w[i], got + t * v[i]);
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
if (!(cin >> n >> W)) return 0;
v.resize(n);
w.resize(n);
cnt.resize(n);
for (int i = 0; i < n; i++) cin >> v[i] >> w[i] >> cnt[i];
best = 0;
dfs(0, W, 0);
cout << best << "\n";
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

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

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

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

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

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

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

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

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

multiNaive.cpp摊成 k 件,跑 01 背包
// 多重背包 —— 朴素做法:把「k 件同样的物品」拆成 k 件独立的物品,跑 01 背包
//
// 这是最该先想到的做法,而且它一点都不丢人:**转化**本身就是正确的思路,
// 「第 i 种最多拿 k 件」和「有 k 件一模一样的物品,每件最多拿一件」是同一件事。
//
// 而且它顺手回答了一个常见疑问:多重背包**不能**用完全背包的正序写法,
// 因为正序会让同一种物品被拿无限多次,管不住「最多 k 件」这个上限。
// 拆成 01 之后,倒序照旧。
//
// 复杂度 O(W × Σk[i])。k 全是 1 时它就是 01 背包,k 很大时它会炸:
// 100 种物品、每种 1000 件、W = 5000 → 5000 × 100000 = 5 亿格。
// ★ 这一章第二个关键一步(二进制拆分)就是来砍这个 Σk 的。
//
// 这份代码在正文里的作用是**中间台阶 + 对照组**:
// 它和 multi.cpp 答案永远相同,只是慢得多。
//
// 输入输出同 multiBrute.cpp。
#include <bits/stdc++.h>
using namespace std;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
long long W;
if (!(cin >> n >> W)) return 0;
// 拆:第 i 种的 k 件,一件一件地摊开
vector<pair<long long, long long>> items; // (价值, 重量)
long long total = 0;
for (int i = 0; i < n; i++) {
long long v, w, k;
cin >> v >> w >> k;
total += k;
for (long long t = 0; t < k; t++) items.push_back({v, w});
}
vector<long long> f(W + 1, 0);
for (auto [v, w] : items)
for (long long j = W; j >= w; j--) // 拆成 01 之后,倒序照旧
f[j] = max(f[j], f[j - w] + v);
cout << f[W] << "\n";
// 顺带把摊开之后的物品件数打到 stderr,方便和 multi.cpp 对比(不影响对拍)
cerr << "摊开成 " << total << " 件 01 物品\n";
return 0;
}
点一下即可编辑
输入(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多重背包正解:二进制拆分
// 多重背包 —— 正解:二进制拆分 + 01 背包
//
// ★ 这一章第二个关键一步。
//
// multiNaive.cpp 把 k 件摊成 k 件独立物品,于是有 Σk 件要处理。
// 但仔细想想:我们真正需要的只是「**能凑出 0 ~ k 之间的任意件数**」,
// 至于是怎么凑出来的,DP 并不关心。
//
// 而「用最少的堆凑出 0 ~ k 的所有整数」这件事,第 3 章已经给过答案了 ——
// **二进制**。把 k 拆成
//
// 1, 2, 4, 8, ..., 2^(t-1), k - (2^t - 1)
//
// 其中 t 是使 2^t - 1 ≤ k 的最大值,最后那一堆是余数(可能为 0,就不要了)。
//
// 为什么这样拆一定够用:
// 前面那些 1,2,4,...,2^(t-1) 可以凑出 0 ~ 2^t-1 的每一个整数(这就是二进制表示);
// 再加上余数那一堆,就能凑出 (k-2^t+1) ~ k。两段接得上(因为余数 ≤ 2^t-1),
// 所以 0 ~ k 一个不漏。而且**凑不出比 k 大的数**(总和正好是 k),上限也管住了。
//
// 举例:k = 13 → 1, 2, 4, 6(4 堆)。想拿 11 件?6 + 4 + 1。想拿 3 件?2 + 1。
// split.cpp 会把 0 ~ k 每个数都验一遍。
//
// 于是件数从 k 降到 ⌈log₂(k+1)⌉:k = 1000 时从 1000 堆降到 10 堆,**一百倍**。
// 复杂度 O(W × Σlog k[i])。
//
// 拆完之后每一堆当成一件普通的 01 物品(价值 t·v、重量 t·w),倒序照旧 ——
// 因为每一堆**只能整堆拿或整堆不拿**,正是 01 背包。
//
// 输入输出同 multiBrute.cpp。
#include <bits/stdc++.h>
using namespace std;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
long long W;
if (!(cin >> n >> W)) return 0;
vector<pair<long long, long long>> items; // (价值, 重量),每个元素是一「堆」
long long piles = 0;
for (int i = 0; i < n; i++) {
long long v, w, k;
cin >> v >> w >> k;
for (long long t = 1; t <= k; k -= t, t <<= 1) { // t = 1, 2, 4, 8, ...
items.push_back({t * v, t * w});
piles++;
}
if (k > 0) { items.push_back({k * v, k * w}); piles++; } // 剩下的余数单独成一堆
}
vector<long long> f(W + 1, 0);
for (auto [v, w] : items)
for (long long j = W; j >= w; j--) // 每堆只能整堆拿,所以是 01 背包 → 倒序
f[j] = max(f[j], f[j - w] + v);
cout << f[W] << "\n";
cerr << "拆成 " << piles << " 堆\n";
return 0;
}
点一下即可编辑
输入(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
// 把二进制拆分**拆给你看**,并且逐个验证「0 ~ k 每个件数都凑得出来」
//
// 为什么要有这份代码:二进制拆分的正确性是一句话带过的
//(「1,2,4,… 能凑出二进制表示的每一个数」),但一句话说服不了人。
// 这份代码对每个 k 都把 0 ~ k 全枚举一遍,真的去检查每个数能不能由那些堆凑出来。
//
// ★ 顺带把这一步真正的收益量出来:k 从 1000 降到 10 堆,是一百倍,不是一点点。
//
// 拆法:t = 1, 2, 4, 8, ... 每次从 k 里扣掉 t,扣不动了就把剩下的余数单独成一堆。
//
// 输入:一行若干个 k(空格隔开)。不给输入就用一组默认的 k。
// 输出:一张表。每行是「k / 朴素要几件 / 拆成几堆 / 具体怎么拆 / 零到 k 是不是全能凑出」
//
// ⚠ 表头里一个数字都不能有 —— check-viz 是按「行里的数字」解析这张表的,
// 表头混进数字会被当成数据(第 20 章的 knapTable.cpp 就踩过这个坑)。
#include <bits/stdc++.h>
using namespace std;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
vector<long long> ks;
long long x;
while (cin >> x) if (x >= 0) ks.push_back(x);
if (ks.empty()) ks = {1, 2, 3, 4, 5, 7, 8, 13, 100, 1000};
cout << " k 朴素件数 二进制堆数 拆法 零到 k 全能凑出\n";
cout << " ---- -------- ---------- ------------------------------ ---------------\n";
for (long long k : ks) {
vector<long long> piles;
long long rest = k;
for (long long t = 1; t <= rest; rest -= t, t <<= 1) piles.push_back(t);
if (rest > 0) piles.push_back(rest);
// 这些堆能凑出哪些件数?(子集和,布尔 DP)
vector<char> ok(k + 1, 0);
ok[0] = 1;
for (long long p : piles)
for (long long j = k; j >= p; j--) // 每堆只能用一次 → 01 背包 → 倒序
if (ok[j - p]) ok[j] = 1;
bool all = true;
for (long long j = 0; j <= k; j++) if (!ok[j]) all = false;
string how;
for (size_t i = 0; i < piles.size(); i++) {
if (i) how += "+";
how += to_string(piles[i]);
}
cout << setw(6) << k << setw(11) << k << setw(13) << (long long)piles.size()
<< " " << left << setw(33) << how << right
<< (all ? "是" : "否") << "\n";
}
return 0;
}
点一下即可编辑
输入(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 = 100、W = 5000 固定,只改件数上限 k):

k 上限 朴素摊开 二进制拆分 朴素耗时 拆分耗时
10 530 件 276 堆 0.005 秒 0.004 秒
100 5 140 件 581 堆 0.012 秒 0.005 秒
1 000 53 340 件 915 堆 0.087 秒 0.005 秒
10 000 480 340 件 1 232 堆 0.770 秒 0.005 秒
100 000 5 140 340 件 1 585 堆 8.252 秒 0.005 秒

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

顺手一个能白捡的优化

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

k = min(k, W / w);

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

15★ 对拍(两台)

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

对拍器
★ 生成器必须造出「轻」物品(w ≤ W/2)—— 只有同一种能拿第二件时,完全背包和 01 背包的答案才会不一样。全是重物品的数据,方向写反了也看不出来。
// 完全背包 —— 正解。和第 23 章的 fast.cpp 只差一个方向
//
// 01 背包 :for (long long j = W; j >= w[i]; j--) ← 倒序
// 完全背包:for (long long j = w[i]; j <= W; j++) ← 正序
//
// ★ 这一章的关键一步,其实是**把 fullNaive.cpp 那层 k 的循环砍掉**。
// 先看二维:把「第 i 种拿了至少一件」这件事单独拎出来 ——
//
// 不拿第 i 种 : f[i][j] = f[i-1][j]
// 至少拿一件第 i 种 : f[i][j] = f[i][j - w[i]] + v[i]
// ↑↑↑ 注意第一维是 **i**,不是 i-1
//
// 为什么可以是 i:f[i][j-w[i]] 的含义是「前 i 种物品、容量 j-w[i] 的最优解」,
// 而第 i 种**本来就允许再拿**,所以在它上面直接加一件是合法的 ——
// 而且它内部已经把「第 i 种拿 2 件、3 件……」的情况全都算进去了。
// 于是「拿 k 件」这层循环就被一句「拿一件」递归地包含了。
//
// ⚠ 和第 23 章正好相反,那里的铁律是「右边的第一维必须是 i-1」(每件最多一件);
// 这里的铁律是「右边的第一维就是 i」(每件想拿几件拿几件)。
// **是题目决定了写 i 还是 i-1,倒序 / 正序只是它在一维下的写法。**
//
// 压成一维之后:读 f[j - w[i]] 时希望读到的是**本轮已经更新过**的值,
// 所以 j 要从小到大 —— 正序。
//
// 输入输出同 fullBrute.cpp。复杂度 O(nW) 时间、O(W) 空间。
//
// 这份代码和第 23 章的 complete.cpp 一模一样(那里是当反例出现的)。
// 你在上一章亲手确认过:01 背包写成正序,跑出来的就是完全背包的正确答案。
#include <bits/stdc++.h>
using namespace std;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
long long W;
if (!(cin >> n >> W)) return 0;
vector<long long> v(n + 1), w(n + 1);
for (int i = 1; i <= n; i++) cin >> v[i] >> w[i];
vector<long long> f(W + 1, 0);
for (int i = 1; i <= n; i++)
for (long long j = w[i]; j <= W; j++) // ★ 正序!倒着写就变回 01 背包
f[j] = max(f[j], f[j - w[i]] + v[i]);
cout << f[W] << "\n";
return 0;
}
点一下即可编辑

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

对拍器
★ 生成器的灵魂是「k 有大有小」:k 很小(1~3)、k 贴着 W/w 的边界、k 大到拿不完,三种都要有。
// 多重背包 —— 正解:二进制拆分 + 01 背包
//
// ★ 这一章第二个关键一步。
//
// multiNaive.cpp 把 k 件摊成 k 件独立物品,于是有 Σk 件要处理。
// 但仔细想想:我们真正需要的只是「**能凑出 0 ~ k 之间的任意件数**」,
// 至于是怎么凑出来的,DP 并不关心。
//
// 而「用最少的堆凑出 0 ~ k 的所有整数」这件事,第 3 章已经给过答案了 ——
// **二进制**。把 k 拆成
//
// 1, 2, 4, 8, ..., 2^(t-1), k - (2^t - 1)
//
// 其中 t 是使 2^t - 1 ≤ k 的最大值,最后那一堆是余数(可能为 0,就不要了)。
//
// 为什么这样拆一定够用:
// 前面那些 1,2,4,...,2^(t-1) 可以凑出 0 ~ 2^t-1 的每一个整数(这就是二进制表示);
// 再加上余数那一堆,就能凑出 (k-2^t+1) ~ k。两段接得上(因为余数 ≤ 2^t-1),
// 所以 0 ~ k 一个不漏。而且**凑不出比 k 大的数**(总和正好是 k),上限也管住了。
//
// 举例:k = 13 → 1, 2, 4, 6(4 堆)。想拿 11 件?6 + 4 + 1。想拿 3 件?2 + 1。
// split.cpp 会把 0 ~ k 每个数都验一遍。
//
// 于是件数从 k 降到 ⌈log₂(k+1)⌉:k = 1000 时从 1000 堆降到 10 堆,**一百倍**。
// 复杂度 O(W × Σlog k[i])。
//
// 拆完之后每一堆当成一件普通的 01 物品(价值 t·v、重量 t·w),倒序照旧 ——
// 因为每一堆**只能整堆拿或整堆不拿**,正是 01 背包。
//
// 输入输出同 multiBrute.cpp。
#include <bits/stdc++.h>
using namespace std;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
long long W;
if (!(cin >> n >> W)) return 0;
vector<pair<long long, long long>> items; // (价值, 重量),每个元素是一「堆」
long long piles = 0;
for (int i = 0; i < n; i++) {
long long v, w, k;
cin >> v >> w >> k;
for (long long t = 1; t <= k; k -= t, t <<= 1) { // t = 1, 2, 4, 8, ...
items.push_back({t * v, t * w});
piles++;
}
if (k > 0) { items.push_back({k * v, k * w}); piles++; } // 剩下的余数单独成一堆
}
vector<long long> f(W + 1, 0);
for (auto [v, w] : items)
for (long long j = W; j >= w; j--) // 每堆只能整堆拿,所以是 01 背包 → 倒序
f[j] = max(f[j], f[j - w] + v);
cout << f[W] << "\n";
cerr << "拆成 " << piles << " 堆\n";
return 0;
}
点一下即可编辑

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 / 300 3 / 300
不扣 k,总和超过 k 79 / 300 0 / 300
无视 k 上限 188 / 300 0 / 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
配套练习
这一章我卡在哪(过一个月回来看,这几行比整章正文都值钱)