第 23 章你亲手确认过一件事:01 背包的一维循环写成正序,
跑出来的不是垃圾,而是完全背包的正确答案 ——
wrong.cpp 和 complete.cpp 拿 300 组数据跑,输出一模一样,一组不差。
所以这一章的第一句话是:完全背包的代码,你已经写出来了。
那还有什么可讲的?两件事,而且都比「改个方向」重要得多:
- 为什么正序是对的 —— 光记住方向,题目一变形你就再也推不回来。 这一章会从二维推一遍,你会看到那层「第 i 种拿几件」的循环是怎么被砍掉的。
- 件数有上限怎么办(多重背包:第 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 种拿几件」
// 完全背包 —— 暴力: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;}点「运行 ▶」看结果
第 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, ... 只要装得下
// 完全背包 —— 朴素二维:把「拿几个」直接写进转移//// 从暴力到正解之间的那一级台阶。照着第 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;}点「运行 ▶」看结果
它是对的(后面会用 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动画:来源从「一整排」塌缩成「两格」
下拉框切换两种写法,只看画面上那个累计查看的来源格数:
默认数据(就是第 23 章那 4 件物品,W = 9)上,朴素要看 85 个格子,优化之后只要 66 个。
差距是 W / (2w) 这个量级。W = 9 太小,看起来只差一点点;
但 W 一大,朴素那边的每一格都要看几百上千个来源,而优化后永远只看两格。
第 9 步的实测表会让你看到真实的差距。
红色那一格(同一行的 f[i][j-w])才是这个动画真正要你记住的东西:
第 23 章不许它出现,这一章非它不可。
7压成一维:这就是你上一章写出来的那三行
二维压一维,问的还是第 23 章那个问题:读 f[j-w] 时,读到的是「上一行」还是「这一行」?
只不过这次我们想要读到这一行(因为转移右边就是 i)—— 所以 j 从小到大,正序。
// 完全背包 —— 正解。和第 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;}点「运行 ▶」看结果
| 一维循环方向 | 读到的 f[j−w] 是 | 每种能拿几件 | |
|---|---|---|---|
| 01 背包 | j = W → w[i](倒序) |
上一行的 | 最多 1 件 |
| 完全背包 | j = w[i] → W(正序) |
这一行的 | 无限件 |
其余部分一个字符都不差。你可以把 full.cpp 和第 23 章的 fast.cpp 并排打开对一遍。
8★ 反过来也成立:完全背包写成倒序,就变回了 01 背包
// 完全背包写成倒序 —— 这是一份**故意写错**的代码,而且它错得和上一章完全对称//// 第 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;}点「运行 ▶」看结果
上面这份跑出来是 15 —— 正是第 2 步手算的01 背包答案。
第 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 | ← 又转回去了 |
9实测:砍掉那层循环值多少
本机实测(物品种数固定 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] 件。
// 多重背包 —— 暴力: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;}点「运行 ▶」看结果
输入每行多了一个 k。上面这组就是第 2 步那组数据,跑出来是 19 —— 夹在 15 和 21 中间。
完全背包的正序会让同一种物品被拿无限多次,
它根本没有任何地方能塞下「最多 k 件」这个限制。
后面第 15 步会实测:直接拿完全背包当多重背包用,300 轮里被抓 188 轮。
11朴素做法:把 k 件摊开成 k 件独立的物品
这个念头一点都不丢人 —— 它是对的,而且转化本身就是正解的地基:
「第
i种最多拿k件」 ≡ 「有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;}点「运行 ▶」看结果
(它会在 [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,2,4,…,2^(t-1)能凑出0 ~ 2^t-1的每一个数(这就是二进制表示); 再加上余数那一堆,就能凑到k。因为余数≤ 2^t-1,两段接得上,中间不留空。- 而所有堆加起来正好等于 k,所以也凑不出比
k更多的件数 —— 上限也管住了。
于是件数从 k 掉到 ⌈log₂(k+1)⌉。k = 1000 时从 1000 堆变成 10 堆,一百倍。
拆完之后,每一堆当成一件普通物品(价值 t·v、重量 t·w),
因为每堆只能「整堆拿或整堆不拿」—— 那正是 01 背包,倒序照旧。
// 多重背包 —— 正解:二进制拆分 + 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;}点「运行 ▶」看结果
13动画 + 把「一定凑得出」验给你看
动画分两段:先分堆(1、2、4……分不动了把余数单独成一堆), 然后把 0 ~ k 每一个件数都凑一遍给你看。
改改上面的 k 试试 7(正好是 2³-1,没有余数)和 8(余数是 1),感受一下余数那一堆的作用。
// 把二进制拆分**拆给你看**,并且逐个验证「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;}点「运行 ▶」看结果
不给输入就用一组默认的 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 值多少
本机实测(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 枚举拿几件。
// 完全背包 —— 正解。和第 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 章那条规矩)。
// 多重背包 —— 正解:二进制拆分 + 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 轮 |
我把多重背包的生成器改成「只造大 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自测
- 洛谷 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。适合确认自己是真的会了