第 24 章结尾你手里握着这么一张表:
| 转移右边的第一维 | 一维怎么写 | |
|---|---|---|
| 01 背包 | i-1 |
倒序 |
| 完全背包 | i |
正序 |
这一章的两道题,都不需要在这张表上加任何东西:
- 二维费用背包(同时限制重量和体积)—— 表原样就能用,只是多一层循环。 前半章真正的收获是一个你多半没想过的问题: 「两层容量循环都要倒序」这句口诀里,有一层其实是白记的。
- 分组背包(每组至多选一件)—— 也没有新公式,但三层循环的顺序不能错。 而写错的两种方式,跑出来正好分别是第 23 章和第 24 章的正确答案。
所以这一章的主题只有一句:顺序决定你读到的是上一轮还是这一轮, 而那决定了你解的是哪一道题。
1上半场一句话问题:拿一件要同时付两种代价
物品还是「拿或不拿」,还是每件最多一件 —— 和第 23 章一模一样。
唯一的变化是:拿一件要同时付出重量 w 和体积 u,而背包对两者各有一个上限 W 和 V。
n件物品,第i件价值v[i]、重w[i]、占u[i]。 总重不超过W、总体积不超过V,求最大总价值。
不在思路上,只在下标上:
f[j] → f[j][u] 状态多一维
f[j - w] → f[j - w][u - vol] 转移的下标多一维
一层容量循环 → 两层容量循环「每件最多拿一件」这条没有变,所以转移右边的第一维还是 i-1,倒序还是倒序。
是「每件能拿几件」决定方向,不是「有几维费用」。
2先用手算一遍:三个数字,一眼看出体积那一维在干什么
重量上限 W = 10,体积上限 V = 8
① 价值 6,重 4,占 2
② 价值 5,重 3,占 4
③ 价值 4,重 2,占 3
④ 价值 3,重 2,占 2
- 只看重量(假装没有体积这一回事):选 ①②③ → 重 4+3+2 = 9 ≤ 10,价值 15。 但它们的体积是 2+4+3 = 9 > 8 —— 装不进去。
- 两个限制都算:选 ①②④ → 重 9 ≤ 10、体积 8 ≤ 8,价值 14。这才是答案。
- 顺带看一眼完全背包(假设每件能拿无限件):①×2 + ③ → 重 10、体积 7,价值 16。
15 → 14 → 16 这三个数后面每一步都会回来验: 15 是「忘了体积维」的答案,14 是正解,16 是「循环方向写反」的答案。
3暴力:第 23 章那份 2ⁿ 枚举子集,改一行就能用
// 二维费用背包 —— 暴力:2ⁿ 枚举子集//// 为什么这一章前半段的标准答案又是「枚举子集」:因为二维费用背包的**题目**// 和 01 背包一模一样 —— 每件物品还是「拿或不拿」,只是「拿」这个动作现在要// 同时付出两种代价(重量和体积),而背包对两种代价各有一个上限。//// 所以第 23 章那份 2ⁿ 暴力原样就能改:枚举完一个子集,从「只检查重量」// 变成「重量和体积都要检查」。★ 这正是这半章唯一的新东西 ——// **多一维费用,只是多一个要同时满足的限制。**//// 题意:n 件物品,第 i 件价值 v[i]、重量 w[i]、体积 u[i],每件最多拿一件。// 背包重量上限 W、体积上限 V。求最大总价值。// 输入:第一行 n W V,接下来 n 行每行三个数 v w u// 输出:最大总价值//// ⚠ 只能用在很小的数据上(vol2Gen.cpp 造的是 n ≤ 8)。n = 30 时 2ⁿ 就是十亿。
#include <bits/stdc++.h>using namespace std;
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
int n; long long W, V; if (!(cin >> n >> W >> V)) return 0; vector<long long> v(n), w(n), u(n); for (int i = 0; i < n; i++) cin >> v[i] >> w[i] >> u[i];
long long best = 0; for (long long s = 0; s < (1LL << n); s++) { long long sw = 0, su = 0, sv = 0; for (int i = 0; i < n; i++) if (s >> i & 1) { sw += w[i]; su += u[i]; sv += v[i]; } // ★ 和第 23 章唯一的差别就在这一行:两个上限都要满足 if (sw <= W && su <= V) best = max(best, sv); } cout << best << "\n"; return 0;}点「运行 ▶」看结果
改的就是那一行判断:if (sw <= W) → if (sw <= W && su <= V)。
题目多一个限制,暴力就多一个 && —— 这句话待会儿在 DP 里会变成「多一层循环」。
它慢得离谱(2ⁿ),但绝不会错,这半章的标准答案就是它。
4★ 关键一步(一):多一维费用,就多一层循环
第 23 章的一维写法:
for (int i = 1; i <= n; i++)
for (int j = W; j >= w[i]; j--)
f[j] = max(f[j], f[j - w[i]] + v[i]);二维费用只要在中间塞一层:
for (int i = 1; i <= n; i++)
for (int j = W; j >= w[i]; j--)
for (int u = V; u >= vol[i]; u--) // ← 新加的一层
f[j][u] = max(f[j][u], f[j - w[i]][u - vol[i]] + v[i]);其余一个字都不用改。 为什么可以这么放心?因为第 24 章那句话:
转移右边的第一维是 i-1 还是 i,由「每件能拿几件」决定 ——
而这道题每件还是最多一件,所以还是 i-1,所以还是倒序。
时间从 O(nW) 变成 O(nWV),空间从 O(W) 变成 O(WV)。
多一维费用的代价全在这里,第 7 步会实测。
// 二维费用背包 —— 正解:01 背包多加一层循环//// 状态从 f[j] 变成 f[j][u]:「重量不超过 j、体积不超过 u 时的最大价值」。// 转移和第 23 章长得一模一样,只是下标多了一维://// f[j][u] = max( f[j][u], f[j - w][u - vol] + v )// ↑↑↑ 右边的第一维(物品维)仍然是 i-1//// ★ 所以第 24 章那张「写 i 还是 i-1」的表**原样就能用**:// 每件最多拿一件 → 右边必须是 i-1 → 一维压缩之后要倒序。// 多出来的那一维费用**没有改变任何一件事**,它只是多了一层循环。//// ⚠ 关于「两层容量循环都要倒序」这句口诀 —— 它多记了一半,真相见 vol2In.cpp:// 决定「读到的是上一轮还是这一轮」的只有**外层**那一维。// 这里两层都写倒序,是因为这样最好记、也最不容易出错。//// 时间 O(nWV),空间 O(WV)。// 输入输出同 vol2Brute.cpp。
#include <bits/stdc++.h>using namespace std;
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
int n; long long W, V; if (!(cin >> n >> W >> V)) return 0;
// f[j][u]:重量不超过 j、体积不超过 u 时的最大价值 vector<vector<long long>> f(W + 1, vector<long long>(V + 1, 0));
for (int i = 0; i < n; i++) { long long v, w, vol; cin >> v >> w >> vol; for (long long j = W; j >= w; j--) // ★ 外层倒序:读到的必须是「还没放这件物品」的值 for (long long u = V; u >= vol; u--) // 内层跟着倒序(好记;其实方向无所谓,见 vol2In.cpp) f[j][u] = max(f[j][u], f[j - w][u - vol] + v); }
cout << f[W][V] << "\n"; return 0;}点「运行 ▶」看结果
跑出来 14,和第 2 步手算的一样。
5★ 关键一步(二):那句口诀里,有一层是白记的
几乎所有资料都会告诉你「二维费用的两层容量循环都要倒序」。这句话没错,但它多记了一半。
读 f[j-w][u-vol] 时,读到的必须是「还没放这件物品」的值。
现在盯住 f 这张表的形状:它是按外层下标 j 分成一整行一整行的。
- 外层倒序时,
j-w那一整行这一轮根本还没被碰过 —— 里面每一格都还是上一轮的值。 既然整行都没动过,那内层在这一行里从左往右还是从右往左读,读到的是同一个东西。 - 外层一旦正序,
j-w那一整行早就填完了 —— 内层再怎么倒序也救不回来。
★ 所以:方向的决定权只在外层。内层怎么写,一点关系都没有。
不用信我,跑一遍。下面这份代码是故意把内层写成正序的:
// 二维费用背包:外层倒序、**内层正序** —— 这一份不是 bug,它是对的//// 为什么要专门留一份「看起来写错了」的代码:因为「两层容量循环都要倒序」这句口诀,// 有一半是白记的。这份代码就是证据 —— 它在 300 组数据上和 vol2.cpp **一模一样**,// 一组都不差(check:viz 钉着这一条)。//// ★ 真正的规则从来只有一句:**读 f[j-w][u-vol] 时,读到的必须是「还没放这件物品」的值。**//// 而 f 是按外层下标 j 分成一整行一整行的。外层倒序时,j-w < j 那一整行// **这一轮根本还没被碰过** —— 里面每一格都还是上一轮的值。// 既然整行都没动过,那内层在这一行里从左往右还是从右往左读,读到的是同一个东西。//// 反过来,外层一旦正序,j-w 那一整行**已经全部更新完了**,// 内层再怎么倒序也救不回来(那就是 vol2Up.cpp,它解的是完全背包)。//// ⚠ 前提是 w ≥ 1。如果允许出现重量为 0 的物品,j-w 就是 j 自己(同一行),// 上面这套「整行还没动过」的论证立刻失效,内层的方向就又要命了。// —— 又一个「题目对边界的约定必须抄进注释」的例子。//// 结论不是「以后内层随便写」,而是:知道为什么之后,**你只需要盯住外层**。// 平时仍然建议两层都写倒序,因为那样不用每次都想一遍。//// 输入输出同 vol2Brute.cpp。
#include <bits/stdc++.h>using namespace std;
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
int n; long long W, V; if (!(cin >> n >> W >> V)) return 0;
vector<vector<long long>> f(W + 1, vector<long long>(V + 1, 0));
for (int i = 0; i < n; i++) { long long v, w, vol; cin >> v >> w >> vol; for (long long j = W; j >= w; j--) // 外层倒序 —— 说了算的是它 for (long long u = vol; u <= V; u++) // 内层正序 —— 一点关系都没有 f[j][u] = max(f[j][u], f[j - w][u - vol] + v); }
cout << f[W][V] << "\n"; return 0;}点「运行 ▶」看结果
还是 14。而且不是这一组数据碰巧 —— check:viz 拿 300 组随机数据跑过,
它和 vol2.cpp 一组不差(第 8 步那张表里,它的「被抓轮数」是 0 / 300)。
反过来,把外层写成正序:
// ✗ 故意写错(一):二维费用背包,**外层容量循环写成了正序**//// 和 vol2.cpp 只差一个方向:// 正确: for (j = W; j >= w; j--) 本文件: for (j = w; j <= W; j++)//// 它不会崩、不会报错,只是安静地给你另一道题的正确答案 ——// **它解的是二维费用的完全背包**(每件可以拿无限件),也就是 vol2Full.cpp。//// 为什么:j 正序时,读 f[j-w][...] 会读到「本轮已经放过这件物品」的那一行,// 于是同一件物品可以一而再、再而三地被放进去。// 这就是第 23 章「正序解的是完全背包」在二维里的原样重演。//// check:viz 用 300 组数据钉死了这条恒等式:本文件的输出 ≡ vol2Full.cpp 的输出。//// 内层的方向在这里是**故意保留倒序**的 —— 为的是说清楚一件事:// 内层写什么方向都救不回来,**方向的决定权只在外层**(对照 vol2In.cpp)。//// 输入输出同 vol2Brute.cpp。
#include <bits/stdc++.h>using namespace std;
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
int n; long long W, V; if (!(cin >> n >> W >> V)) return 0;
vector<vector<long long>> f(W + 1, vector<long long>(V + 1, 0));
for (int i = 0; i < n; i++) { long long v, w, vol; cin >> v >> w >> vol; for (long long j = w; j <= W; j++) // ✗ 正序 —— 这一件事就把题目换掉了 for (long long u = V; u >= vol; u--) // 内层仍然是倒序,但救不回来 f[j][u] = max(f[j][u], f[j - w][u - vol] + v); }
cout << f[W][V] << "\n"; return 0;}点「运行 ▶」看结果
跑出来 16 —— 正是第 2 步手算的完全背包答案。
vol2Up.cpp 不是随机地错,它精确地解了二维费用的完全背包:
check:viz 用 300 组数据钉死了这条恒等式:两份代码的输出一组不差。
把四种写法摆在一起,这半章就说完了:
// 把二维费用的**四种循环方向**并排跑成一张表//// 为什么要有这份代码:正文里那句「只有外层方向说了算」是推出来的,// 推理再顺也不如把四种写法摆在同一组数据上,让答案自己说话。//// 四行输出对应四种写法(外层容量循环 × 内层容量循环,各有正序 / 倒序)://// 倒序 + 倒序 ← vol2.cpp,正解(01 背包)// 倒序 + 正序 ← vol2In.cpp,**也是正解**(内层方向根本不影响)// 正序 + 倒序 ← vol2Up.cpp,解的是完全背包// 正序 + 正序 ← 同样是完全背包//// 最后两列是「和第一行一样吗」「和第三行一样吗」,也就是// **这种写法解的到底是 01 背包还是完全背包**。// 你会看到答案按外层方向分成整整齐齐的两组,内层那一列从头到尾没起过作用。//// 输入输出:输入同 vol2Brute.cpp;不给输入就用一组内置的默认数据(正文和动画用的那一组)。//// ⚠ 表头里一个数字都不能有 —— check-viz 是按行里的数字解析这张表的// (第 20 章的 knapTable.cpp 踩过这个坑)。
#include <bits/stdc++.h>using namespace std;
struct Item { long long v, w, u; };
// outerUp / innerUp:那一层是不是正序static long long solve(const vector<Item>& it, long long W, long long V, bool outerUp, bool innerUp) { vector<vector<long long>> f(W + 1, vector<long long>(V + 1, 0)); for (const auto& x : it) { if (outerUp) { for (long long j = x.w; j <= W; j++) { if (innerUp) for (long long u = x.u; u <= V; u++) f[j][u] = max(f[j][u], f[j - x.w][u - x.u] + x.v); else for (long long u = V; u >= x.u; u--) f[j][u] = max(f[j][u], f[j - x.w][u - x.u] + x.v); } } else { for (long long j = W; j >= x.w; j--) { if (innerUp) for (long long u = x.u; u <= V; u++) f[j][u] = max(f[j][u], f[j - x.w][u - x.u] + x.v); else for (long long u = V; u >= x.u; u--) f[j][u] = max(f[j][u], f[j - x.w][u - x.u] + x.v); } } } return f[W][V];}
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
int n; long long W, V; vector<Item> it; if (cin >> n >> W >> V) { it.resize(n); for (auto& x : it) cin >> x.v >> x.w >> x.u; } else { // 默认数据:正文第二步手算的那一组(重量和体积**各自都会吃紧**) W = 10; V = 8; it = {{6, 4, 2}, {5, 3, 4}, {4, 2, 3}, {3, 2, 2}}; }
const long long down = solve(it, W, V, false, false); // 两层都倒序 = 01 背包 const long long up = solve(it, W, V, true, false); // 外层正序 = 完全背包
// ⚠ 这里的空格是**手数出来的**,没有用 setw 对齐中文 —— // setw 数的是字节数,而一个汉字占 3 个字节、却只占 2 格宽,对出来必歪。 // (每个中文字段的内容宽度都是固定的,所以直接补固定个数的空格最省事。) cout << " 外层容量循环 内层容量循环 答案 和两层都倒序一样 和两层都正序一样\n"; cout << " ------------ ------------ ------ ---------------- ----------------\n"; for (int o = 0; o < 2; o++) for (int i = 0; i < 2; i++) { long long ans = solve(it, W, V, o == 1, i == 1); cout << " " << (o == 1 ? "正序" : "倒序") << " " // 4 格宽 + 11 空格 << (i == 1 ? "正序" : "倒序") << " " << setw(6) << ans << " " << (ans == down ? "是" : "否") << " " // 2 格宽 + 17 空格 << (ans == up ? "是" : "否") << "\n"; } return 0;}点「运行 ▶」看结果
不给输入就用第 2 步那组数据,输出:
外层容量循环 内层容量循环 答案 和两层都倒序一样 和两层都正序一样
------------ ------------ ------ ---------------- ----------------
倒序 倒序 14 是 否
倒序 正序 14 是 否
正序 倒序 16 否 是
正序 正序 16 否 是
答案按外层方向分成整整齐齐的两组,内层那一列从头到尾没起过作用。
6动画:一帧填一整行,看那一行「这轮动过没有」
这个动画一帧填一整行 f[j][*],因为要讲的正是「整行」。
来源行只有两种颜色:绿色 = 这一轮还没碰过(还是上一轮的值),
红色 = 这一轮已经填完了。
- 外层倒序:来源行从头到尾都是绿的,「同一件最多装进同一格几次」恒为 1;
- 外层正序:红色开始出现,那个计数器涨到 2 —— 它已经不是 01 背包了。
切了之后你会发现:颜色没变、计数器没变、答案没变、连播放的步数都没变。
这不是动画坏了 —— 这就是第 5 步那个结论的画面版。 一行要么整体动过、要么整体没动过,内层在行内的先后根本影响不到这件事。
顺带一个细节值得看:外层正序时,并不是每一行的来源都是红的。
j < 2w 的那几行,来源行 j-w 这一轮还没轮到,所以是绿的 ——
红色是从 j ≥ 2w 开始出现的,而那正好就是「同一件放得下第二件」的地方。
7实测:多一维费用要多花多少
先看 DP 相对暴力值多少(W、V 固定 200,只改物品件数 n):
本机实测(./vol2GenBig n 200 200,固定种子):
| n | 2ⁿ 暴力 | 三层循环 |
|---|---|---|
| 20 | 0.053 秒 | 0.005 秒 |
| 22 | 0.205 秒 | 0.004 秒 |
| 24 | 0.848 秒 | 0.006 秒 |
| 26 | 3.483 秒 | 0.005 秒 |
n 每加 2,暴力翻四倍;DP 那一列压根没动 —— 因为它的规模是 n × W × V,和 n 只是线性关系。
一维背包的 f 只有一行,W = 100000 也才 800 KB。
二维费用的 f 是一整张表,W 和 V 是乘起来的。
本机实测(./vol2GenBig 100 2000 V,固定种子,只改体积上限 V):
| V | 耗时 | 峰值内存 | 其中这张表占 |
|---|---|---|---|
| 100 | 0.01 秒 | 5.5 MB | 1.6 MB |
| 200 | 0.02 秒 | 6.8 MB | 3.2 MB |
| 400 | 0.05 秒 | 9.9 MB | 6.4 MB |
| 800 | 0.17 秒 | 16.1 MB | 12.8 MB |
最后一列是 (W+1) × (V+1) × 8 字节算出来的,减一减就能看出剩下那 3 ~ 4 MB 是进程底噪。
W = V = 5000 时这张表就是 200 MB —— 大多数题目的空间限制是 256 MB,
所以二维费用题的两个上限通常都开得很小(P1855 是 200×200)。看到题面里两个上限都很小,
心里就该有数了:出题人是在提示你上二维费用。
(这四行的答案分别是 8546 / 8122 / 7678 / 7286。看着像「V 越大答案越小」,其实只是随机波动 ——
换个种子跑 ./vol2GenBig 100 2000 V 7,四个答案是 7711 / 8412 / 8265 / 8779,反过来了。
因为生成器把物品体积也按 V 等比例放大了:题目的难度没变,变的只有那张表的大小。)
8★ 对拍(一):以及一次「生成器不狠等于白跑」的现场演示
// 二维费用背包 —— 正解:01 背包多加一层循环//// 状态从 f[j] 变成 f[j][u]:「重量不超过 j、体积不超过 u 时的最大价值」。// 转移和第 23 章长得一模一样,只是下标多了一维://// f[j][u] = max( f[j][u], f[j - w][u - vol] + v )// ↑↑↑ 右边的第一维(物品维)仍然是 i-1//// ★ 所以第 24 章那张「写 i 还是 i-1」的表**原样就能用**:// 每件最多拿一件 → 右边必须是 i-1 → 一维压缩之后要倒序。// 多出来的那一维费用**没有改变任何一件事**,它只是多了一层循环。//// ⚠ 关于「两层容量循环都要倒序」这句口诀 —— 它多记了一半,真相见 vol2In.cpp:// 决定「读到的是上一轮还是这一轮」的只有**外层**那一维。// 这里两层都写倒序,是因为这样最好记、也最不容易出错。//// 时间 O(nWV),空间 O(WV)。// 输入输出同 vol2Brute.cpp。
#include <bits/stdc++.h>using namespace std;
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
int n; long long W, V; if (!(cin >> n >> W >> V)) return 0;
// f[j][u]:重量不超过 j、体积不超过 u 时的最大价值 vector<vector<long long>> f(W + 1, vector<long long>(V + 1, 0));
for (int i = 0; i < n; i++) { long long v, w, vol; cin >> v >> w >> vol; for (long long j = W; j >= w; j--) // ★ 外层倒序:读到的必须是「还没放这件物品」的值 for (long long u = V; u >= vol; u--) // 内层跟着倒序(好记;其实方向无所谓,见 vol2In.cpp) f[j][u] = max(f[j][u], f[j - w][u - vol] + v); }
cout << f[W][V] << "\n"; return 0;}300 轮实测,三份「改了方向或漏了东西」的代码:
| 改动 | 被抓 | 第几轮 | 它其实解了哪道题 |
|---|---|---|---|
| 外层容量循环写成正序 | 237 / 300 | 第 2 轮 | 二维费用的完全背包 |
| 把体积那一维整个忘了 | 141 / 300 | 第 2 轮 | 普通一维 01 背包 |
| 外层倒序、内层正序 | 0 / 300 | — | ← 它就是正解(第 5 步那个 ★) |
最后一行是这张表最值钱的地方:对拍抓不住它,不是因为数据不够狠,而是因为它根本没错。
我另写了一份生成器 vol2GenLoose.cpp,只改一件事:把体积上限放到大得永远吃不紧
(所有物品的体积加起来都装得下)。同样跑 300 轮:
| 改动 | 正常数据 | 体积永远吃不紧的数据 |
|---|---|---|
| 把体积那一维整个忘了 | 141 / 300 | 0 / 300 |
| 外层容量循环写成正序 | 237 / 300 | 277 / 300 |
道理简单得可怕:体积上限永远卡不住答案时,这道题本来就等于一维 01 背包 —— 体积维写没写,答案根本没区别。
这是第 7、20、22、24 章那条规矩的第五次现形: 要随机的是「算法依赖的那个量」。 这半章依赖的是「第二维吃不吃紧」, 不是 n、不是 W。一个体积永远宽松的生成器,跑一万轮也是绿的,交上去就是 WA。
9下半场:分组背包 —— 每组至多选一件
物品被分成若干组,每组至多选一件(也可以一件都不选)。背包容量
W,求最大总价值。
这个限制比看起来常见得多:「三种显卡只能装一张」「同一门课的多个上课时段只能选一个」—— 只要出现「互斥的几个选项」,就是分组背包。
手算一组(后面每一步都会回来验):
容量 W = 11
第 1 组: (价值 6, 重 4) (价值 5, 重 3)
第 2 组: (价值 7, 重 3) (价值 6, 重 3)
第 3 组: (价值 3, 重 2) (价值 8, 重 6)
第 4 组: (价值 1, 重 7) (价值 1, 重 8)
- 正确答案:第 1 组拿
6/4、第 2 组拿7/3、第 3 组拿3/2,第 4 组整组跳过 —— 重 9 ≤ 11,价值 16。 - 要是忘了「每组只能一件」:第 2 组的
7/3和6/3一起拿,再加第 1 组的5/3和第 3 组的3/2, 重 11、价值 21。 - 要是每件还能拿无限次:
7/3拿三件 +3/2一件 = 重 11、价值 24。
16 / 21 / 24。 记住这三个数 —— 待会儿它们会分别对应三种循环顺序。
10暴力:DFS 枚举「每组选谁」
// 分组背包 —— 暴力:DFS 枚举「每一组选哪一件(或者一件都不选)」//// ★ 它是**完全不同的思路**:不做 DP、不压维、根本不关心循环顺序,// 只是把「每组的选择」全排一遍。这一点很重要 ——// 这一章的三个错误版本全都是「顺序写错」,而顺序写错的代码之间是会互相「印证」的// (第 20 章那条规矩:**标准答案最好用完全不同的思路写出来**)。//// 复杂度 ∏(每组件数 + 1):5 组每组 3 件就是 4⁵ = 1024,还行;// 15 组每组 3 件就是 4¹⁵ ≈ 十亿,跑不动了 —— 正文第 12 步会实测这条曲线。//// 题意:物品被分成 g 组,**每组至多选一件**(也可以不选)。背包容量 W,求最大总价值。// 输入:第一行 g W// 接下来 g 行,每行先是这一组的件数 cnt,再跟 cnt 对「价值 重量」// 输出:最大总价值//// 这个「每行一组」的输入格式是本章自己定的,好处是分组关系一眼可见;// 洛谷 P1757 是「每件物品自带组号」,读入时归一下类就变成这个样子。
#include <bits/stdc++.h>using namespace std;
struct Item { long long v, w; };
int g;long long W, best;vector<vector<Item>> grp;
// 处理到第 gi 组,还剩 rest 容量,已经攒了 got 的价值void dfs(int gi, long long rest, long long got) { if (gi == g) { best = max(best, got); return; } dfs(gi + 1, rest, got); // 这一组一件都不选 for (const auto& it : grp[gi]) // 或者恰好选一件 if (it.w <= rest) dfs(gi + 1, rest - it.w, got + it.v);}
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
if (!(cin >> g >> W)) return 0; grp.resize(g); for (int i = 0; i < g; i++) { int cnt; cin >> cnt; grp[i].resize(cnt); for (auto& it : grp[i]) cin >> it.v >> it.w; }
best = 0; dfs(0, W, 0); cout << best << "\n"; return 0;}点「运行 ▶」看结果
输入格式是本章自己定的:第一行 组数 容量,接下来每行一组
(先是这一组的件数,再跟着若干对「价值 重量」)。这样分组关系一眼可见。
洛谷 P1757 是「每件物品自带组号」,读入时归一下类就变成这个样子。
它跑出来 16,和手算一致。复杂度是 ∏(每组件数 + 1) —— 每多一组就乘一次,
第 13 步会看到它是怎么爆炸的。
11★ 关键一步(三):三层循环,顺序决定你解的是哪道题
for (int gi = 0; gi < g; gi++) // ① 组
for (int j = W; j >= 0; j--) // ② 容量,倒序
for (auto& it : grp[gi]) // ③ 组内枚举 —— 必须在最里层
if (it.w <= j) f[j] = max(f[j], f[j - it.w] + it.v);★ 为什么必须是这个顺序 —— 还是那一句话,和第 23、24 章一字不差:
看你读到的 f[j-w] 是「上一轮」的还是「这一轮」的。
只不过这里的「一轮」不再是一件物品,而是一整组:
j 倒序时,f[j-w] 还没被这一组碰过 —— 它代表「前 gi-1 组的最优解」。
在它上面加一件,这一组就正好只出了一件。「每组至多一件」是这么保证的。
组内那层循环放在最里面,起的正是这个作用:本组的每一件都从同一个「本组还没出手」的 局面出发去试,试完取最好的那一个。
// 分组背包 —— 正解。★ 这一章真正的新东西,而它全部的难点就是**三层循环的顺序**//// for (每一组 gi) ← 组在最外层// for (j = W; j >= 0; j--) ← 容量在中间,倒序// for (这一组里的每一件 it) ← 组内枚举在最里层// f[j] = max(f[j], f[j - it.w] + it.v);//// ★ 为什么必须是这个顺序 —— 还是那一句话,和第 23、24 章一字不差:// **看你读到的 f[j - w] 是「上一轮」的还是「这一轮」的。**//// 这里的「一轮」不再是一件物品,而是**一整组**。// j 倒序时,f[j - w] 还没被这一组碰过 —— 它代表「前 gi-1 组的最优解」,// 在它上面加一件,这一组就正好只出了一件。「每组至多一件」是这么保证的。//// 如果把组内那层循环提到容量循环外面(groupFlat.cpp),// 同一组的第二件物品就会读到「第一件已经放进去」的 f,于是一组能选两件 ——// 那就退化成了无视分组的普通 01 背包。// 如果把容量写成正序(groupUp.cpp),同一件还能被反复拿 —— 那是完全背包。//// ⚠ 注意 j 要一直倒序到 0(而不是到某个 w[i] 为止)—— 因为内层每件物品的重量不同,// 下界只能在最里面用 `it.w <= j` 判,不能提到中层循环的边界上。//// 时间 O(W × 总件数),空间 O(W)。// 输入输出同 groupBrute.cpp。
#include <bits/stdc++.h>using namespace std;
struct Item { long long v, w; };
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
int g; long long W; if (!(cin >> g >> W)) return 0; vector<vector<Item>> grp(g); for (int i = 0; i < g; i++) { int cnt; cin >> cnt; grp[i].resize(cnt); for (auto& it : grp[i]) cin >> it.v >> it.w; }
vector<long long> f(W + 1, 0);
for (int gi = 0; gi < g; gi++) // ① 组 for (long long j = W; j >= 0; j--) // ② 容量,倒序 for (const auto& it : grp[gi]) // ③ 组内枚举 —— 必须在最里层 if (it.w <= j) f[j] = max(f[j], f[j - it.w] + it.v);
cout << f[W] << "\n"; return 0;}点「运行 ▶」看结果
现在把中间那两层换个位置:
// ✗ 故意写错(三):把**组内枚举那层循环提到了容量循环外面**//// 和 group.cpp 只差两行的先后顺序://// 正确: for (组) for (j = W..0) for (组内每件)// 本文件:for (组) for (组内每件) for (j = W..0) ← 中间两层换了个位置//// 这是分组背包唯一的、也是最容易犯的错误,而且它**不会报错、不会崩溃**。//// ★ 它解的是另一道题:**无视分组的普通 01 背包**(groupIgnore.cpp)。// 因为这样一来,同一组的第二件物品读 f[j-w] 时,读到的是「第一件已经放进去」的值 ——// 于是一组里能选出两件、三件。分组这个限制被整个抹掉了。//// 而「组内枚举」这层循环放在最里面时,它读的 f[j-w] 还没被本组任何一件动过,// 所以本组最多只能出一件。**同样三层循环,换个顺序就是另一道题。**//// check:viz 用 300 组数据钉死了这条恒等式:本文件的输出 ≡ groupIgnore.cpp 的输出。// 这是第 23 章(正序 ≡ 完全背包)、第 24 章(倒序 ≡ 01 背包)之后,// 同一个现象的第三次出现。//// 输入输出同 groupBrute.cpp。
#include <bits/stdc++.h>using namespace std;
struct Item { long long v, w; };
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
int g; long long W; if (!(cin >> g >> W)) return 0; vector<vector<Item>> grp(g); for (int i = 0; i < g; i++) { int cnt; cin >> cnt; grp[i].resize(cnt); for (auto& it : grp[i]) cin >> it.v >> it.w; }
vector<long long> f(W + 1, 0);
for (int gi = 0; gi < g; gi++) for (const auto& it : grp[gi]) // ✗ 组内枚举跑到了外面 for (long long j = W; j >= it.w; j--) f[j] = max(f[j], f[j - it.w] + it.v);
cout << f[W] << "\n"; return 0;}点「运行 ▶」看结果
跑出来 21 —— 正是第 9 步那个「忘了每组只能一件」的答案。
因为这样一来,同一组的第二件读 f[j-w] 时,读到的是「第一件已经放进去」的值。
再把容量循环写成正序:
// ✗ 故意写错(四):分组背包的**容量循环写成了正序**//// 三层循环的位置全对,只有中间那层的方向反了://// 正确: for (组) for (j = W; j >= 0; j--) for (组内每件)// 本文件:for (组) for (j = 0; j <= W; j++) for (组内每件)//// ★ 它解的又是另一道题:**无视分组的完全背包**(groupFull.cpp)——// 正序意味着 f[j-w] 已经被本组更新过,于是本组的物品可以被反复拿,// 想拿几件拿几件,还能混着拿。「每组至多一件」被彻底放开了。//// 于是分组背包这一章有了一张很漂亮的表:**两种写错顺序的方式,// 正好分别是第 23 章和第 24 章的正确答案。**//// 组内枚举提到容量外 → 01 背包(每件至多一件,但不管分组)// 容量循环写成正序 → 完全背包(每件想拿几件拿几件)// 两个都写对 → 分组背包(每组至多一件)//// 三道题,同样三层循环,区别只在顺序。这也是为什么这一章反复强调:// **别背口诀,问自己「我读到的 f[j-w] 是这一轮的还是上一轮的」。**//// check:viz 用 300 组数据钉死了这条恒等式:本文件的输出 ≡ groupFull.cpp 的输出。//// 输入输出同 groupBrute.cpp。
#include <bits/stdc++.h>using namespace std;
struct Item { long long v, w; };
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
int g; long long W; if (!(cin >> g >> W)) return 0; vector<vector<Item>> grp(g); for (int i = 0; i < g; i++) { int cnt; cin >> cnt; grp[i].resize(cnt); for (auto& it : grp[i]) cin >> it.v >> it.w; }
vector<long long> f(W + 1, 0);
for (int gi = 0; gi < g; gi++) for (long long j = 0; j <= W; j++) // ✗ 正序 for (const auto& it : grp[gi]) if (it.w <= j) f[j] = max(f[j], f[j - it.w] + it.v);
cout << f[W] << "\n"; return 0;}点「运行 ▶」看结果
跑出来 24 —— 正是「每件还能拿无限次」的那个答案。
同样三层循环、同样一批物品,顺序决定你解的是哪一道题:
| 怎么写 | 答案 | 它解的是 | 出处 |
|---|---|---|---|
| 组 → 容量倒序 → 组内 | 16 | 分组背包 | 本章 |
| 组 → 组内 → 容量倒序 | 21 | 无视分组的 01 背包 | 第 23 章 |
| 组 → 容量正序 → 组内 | 24 | 无视分组的完全背包 | 第 24 章 |
后两行不是「大概差不多」,是恒等:check:viz 拿 300 组随机数据,
把 groupFlat.cpp 和 groupIgnore.cpp(老老实实的 01 背包)、
groupUp.cpp 和 groupFull.cpp(老老实实的完全背包)各对了一遍,一组不差。
连着第 23 章(01 写成正序 ≡ 完全背包)、第 24 章(完全写成倒序 ≡ 01 背包), 再加上本章前半段那条(二维费用外层正序 ≡ 二维费用的完全背包), 背包这三章一共钉死了五条这样的恒等式。它们都在说同一件事:
DP 的循环顺序不是「格式」,它就是题目本身。 写反了不会报错、不会崩溃、不会有警告 —— 它只是安静地去解另一道题,然后把答案交给你。
12动画:盯住格子下面那排圆点
下拉框有四档(一个正确、三个错误),但你只要盯每一格下面那排圆点 —— 那是「这一格里装了本组的几件」。题目要求每组至多一件,所以:
| 写法 | 圆点最多几个 | 答案 |
|---|---|---|
| 正确 | 1 | 16 |
| 组内枚举提到容量外 | 2 | 21 |
| 容量循环写成正序 | 3 | 24 |
| 漏掉「本组不选」这个选项 | 1 | 4 |
最后那一档(groupMust.cpp)犯的不是顺序错误,是读题错误:
把「每组至多选一件」写成了「每组必须选一件」。
代码上只少写了一点点 —— f[j] 自己没有被放进候选里:
正确: f[j] = max(f[j], f[j - w] + v); // f[j] 自己在里面 = 允许这一组不选
错误: best = 组内所有件里最好的那个; f[j] = best; // 把 f[j] 挤掉了少的那个「f[j] 自己」,就是「这一组一件都不选」这个选项。 第 4 组(又重又不值钱)本该被整组跳过,现在只能硬着头皮拿一件,答案从 16 掉到 4。
而它的圆点始终是 1 个 —— 计数器全程正常。 顺序类的 bug 计数器看得见,读题类的 bug 只能靠对拍。
// ✗ 故意写错(五):把「每组**至多**选一件」写成了「每组**必须**选一件」//// 前两个错误都是顺序问题,这一个是**读题问题** —— 而且它藏在一个很不起眼的地方://// 正确: f[j] = max(f[j], f[j - w] + v); ← f[j] 自己也在候选里 = 可以不选// 本文件:best = 组内所有件里最大的那个;f[j] = best; ← 把 f[j] 挤掉了//// 少写的那个「f[j] 自己」,就是「这一组一件都不选」这个选项。// 它一没了,每一组就都被逼着出一件,答案只会变小(有时小很多)。//// ⚠ 这类 bug 的特点是**在小数据上经常蒙对** —— 只要每组都「选一件比不选强」,// 两份代码的答案就一样。所以生成器必须造出「这一组里的东西又重又不值钱」的局面// (groupGen.cpp 的 kind 2 就是干这个的)。//// 顺带一提:真有题目要求「每组必须选一件」,那就是**另一道题**,// 正确写法是把 f 的初值设成 -∞ 再单独处理(第 23 章 exact.cpp 那个「恰好装满」的套路)。// 而不是像这里一样,糊里糊涂地把「不选」这个选项弄丢。//// 输入输出同 groupBrute.cpp。
#include <bits/stdc++.h>using namespace std;
struct Item { long long v, w; };
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
int g; long long W; if (!(cin >> g >> W)) return 0; vector<vector<Item>> grp(g); for (int i = 0; i < g; i++) { int cnt; cin >> cnt; grp[i].resize(cnt); for (auto& it : grp[i]) cin >> it.v >> it.w; }
vector<long long> f(W + 1, 0);
for (int gi = 0; gi < g; gi++) for (long long j = W; j >= 0; j--) { long long best = -1; for (const auto& it : grp[gi]) if (it.w <= j) best = max(best, f[j - it.w] + it.v); // ✗ 没有把 f[j] 放进候选 if (best >= 0) f[j] = best; // ✗ 直接覆盖 = 这一组必须出一件 }
cout << f[W] << "\n"; return 0;}点「运行 ▶」看结果
13实测:每多一组,暴力乘以 4,正解只加 3 件
本机实测(./groupGenBig 组数,固定种子,每组 3 件):
| 组数 | DFS 暴力 | 三层循环 | 正解要处理的件数 |
|---|---|---|---|
| 10 | 0.006 秒 | 0.004 秒 | 30 |
| 12 | 0.038 秒 | 0.004 秒 | 36 |
| 14 | 0.568 秒 | 0.004 秒 | 42 |
| 15 | 2.399 秒 | 0.004 秒 | 45 |
groupGenBig.cpp 的第一版把容量定死成 2000、重量取 100 ~ 500,
结果 15 组时暴力只要 0.037 秒 —— 快得莫名其妙。
原因是 DFS 里那句 if (it.w <= rest):装到第五六件就再也装不下了,
那棵 4^组数 的树根本没长出来,等于白送了一个剪枝(第 16 章那三板斧之一)。
改成「容量 = 每组最重那件之和的六成」之后,一大半的组都还装得下,树才真的长开, 15 组从 0.037 秒涨到 2.399 秒。
要对比暴力有多慢,先确认它真的走到底了。 这和第 24 章「只造大 k」是同一类错误的两个面孔。
14★ 对拍(二)
// 分组背包 —— 正解。★ 这一章真正的新东西,而它全部的难点就是**三层循环的顺序**//// for (每一组 gi) ← 组在最外层// for (j = W; j >= 0; j--) ← 容量在中间,倒序// for (这一组里的每一件 it) ← 组内枚举在最里层// f[j] = max(f[j], f[j - it.w] + it.v);//// ★ 为什么必须是这个顺序 —— 还是那一句话,和第 23、24 章一字不差:// **看你读到的 f[j - w] 是「上一轮」的还是「这一轮」的。**//// 这里的「一轮」不再是一件物品,而是**一整组**。// j 倒序时,f[j - w] 还没被这一组碰过 —— 它代表「前 gi-1 组的最优解」,// 在它上面加一件,这一组就正好只出了一件。「每组至多一件」是这么保证的。//// 如果把组内那层循环提到容量循环外面(groupFlat.cpp),// 同一组的第二件物品就会读到「第一件已经放进去」的 f,于是一组能选两件 ——// 那就退化成了无视分组的普通 01 背包。// 如果把容量写成正序(groupUp.cpp),同一件还能被反复拿 —— 那是完全背包。//// ⚠ 注意 j 要一直倒序到 0(而不是到某个 w[i] 为止)—— 因为内层每件物品的重量不同,// 下界只能在最里面用 `it.w <= j` 判,不能提到中层循环的边界上。//// 时间 O(W × 总件数),空间 O(W)。// 输入输出同 groupBrute.cpp。
#include <bits/stdc++.h>using namespace std;
struct Item { long long v, w; };
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
int g; long long W; if (!(cin >> g >> W)) return 0; vector<vector<Item>> grp(g); for (int i = 0; i < g; i++) { int cnt; cin >> cnt; grp[i].resize(cnt); for (auto& it : grp[i]) cin >> it.v >> it.w; }
vector<long long> f(W + 1, 0);
for (int gi = 0; gi < g; gi++) // ① 组 for (long long j = W; j >= 0; j--) // ② 容量,倒序 for (const auto& it : grp[gi]) // ③ 组内枚举 —— 必须在最里层 if (it.w <= j) f[j] = max(f[j], f[j - it.w] + it.v);
cout << f[W] << "\n"; return 0;}300 轮实测,六个错误版本汇总(前三个是二维费用,后三个是分组):
| 故意写错的地方 | 被抓 | 第几轮 |
|---|---|---|
| 二维费用:外层容量循环正序 | 237 / 300 | 第 2 轮 |
| 二维费用:忘了体积维 | 141 / 300 | 第 2 轮 |
| 二维费用:外层倒序、内层正序 | 0 / 300 | 它是对的 |
| 分组:组内枚举提到容量外 | 249 / 300 | 第 1 轮 |
| 分组:容量循环正序 | 291 / 300 | 第 1 轮 |
| 分组:漏掉「本组不选」 | 92 / 300 | 第 3 轮 |
groupMust.cpp 只在一种局面下露馅:某一组差到宁可整组跳过。
容量一旦宽松,多塞一件哪怕很差的东西也不亏 —— 正解自己也会去拿它,两份代码答案一样。
所以生成器改了三次,每次只动一处,每次都实测:
| 生成器怎么改 | 抓获率 |
|---|---|
最初:容量 W 独立随机 |
24 / 300 |
改成由物品反推 W(背包一定吃紧) |
53 / 300 |
| 「垃圾组」独立掷骰子,出现率从 1/3 提到 1/2 | 65 / 300 |
垃圾组的重量改成按 W 定(每件都吃掉半个背包以上) |
92 / 300 |
抓不住不代表 bug 不存在,只代表你的数据里没有它需要的那个局面。
还有一件事,第 9 步那组手算数据本身就是证据:如果第 4 组(那组「又重又不值钱」的)不存在,
groupMust.cpp 在那组数据上会恰好蒙对(同样是 16)。
默认数据蒙对,300 轮里照样错 92 轮 —— 样例过了,什么都证明不了。
15这一章可以带走的四样东西
【1】多一维费用 = 多一层循环,别的什么都不用改。
状态多一维、转移下标多一维、循环多一层。方向由「每件能拿几件」决定,
和「有几维费用」没有半点关系 —— 第 24 章那张表原样就能用。
代价是空间 O(WV):两个上限是乘起来的,所以这类题的上限都开得很小。
【2】二维费用里,只有外层那一维的方向说了算。
因为 f 是按外层下标分成整行的,一行要么整体动过、要么整体没动过。
(口诀仍然建议两层都写倒序 —— 不是因为必须,是因为那样不用每次都想一遍。)
【3】分组背包的三层循环:组 → 容量倒序 → 组内枚举。 组内那层必须在最里面,这样本组的每一件都从「本组还没出手」的同一个局面出发。 写反的两种方式,正好分别是第 23、24 章的正确答案 —— 顺序不是格式,顺序就是题目本身。
【4】对拍抓不住,有两种完全不同的原因,别混为一谈。 一种是它本来就没错(外倒内正,0/300); 另一种是你的数据里没有它需要的那个局面(体积永远宽松 → 忘了体积维 0/300)。 写完生成器先问一句:我这份数据,能让错误的写法必定失败吗?
第 26 章:区间 DP(石子合并)。
背包三章的状态都是「前 i 个物品 + 剩多少容量」,
下一章的状态换成了一段区间 f[l][r] —— 而它的填表顺序既不是从左到右,也不是从右到左,
而是按区间长度从小到大。理由和这一章一模一样:长区间要读短区间的值,
所以短的必须先算好。
「依赖谁,就先填谁」(第 21 章那句话)第四次登场。
16自测
- 洛谷 P1855 榨取kkksc03解析 → —— 二维费用裸题(钱和时间两个上限)。注意两个上限都只有 200 —— 这就是本章第 7 步说的那个信号
- 洛谷 P1507 NASA的食物计划解析 → —— 同样是二维费用(体积和质量),换了张皮。两题对着写一遍,「多一层循环」就再也忘不了
- 洛谷 P1757 通天之分组背包解析 → —— 分组背包模板题。输入是「每件物品自带组号」,先归类再跑 —— 正好练一遍本章那个输入格式的转换
- 洛谷 P1064 金明的预算方案解析 → —— NOIP2006。主件 + 附件,看上去是新题型,其实是分组背包:把「主件单买 / 主件+附件1 / 主件+附件2 / 主件+两个附件」当成一组里的四件互斥物品。★ 这题是「看出它是分组背包」的经典训练
- 洛谷 P1541 乌龟棋解析 → —— NOIP2010。四种卡片各用了几张 → 四维费用,也就是四层循环。本章那句「多一维就多一层」的极致版本
- 洛谷 P5322 排兵布阵解析 → —— BJOI2019。进阶:先想清楚「对第 i 座城堡派 x 个兵能赢几个对手」,再把每座城堡的所有 x 当成一组。适合确认自己是真的会了