阶段 5 · 动态规划 · 第 25 章提高组 S

二维费用与分组背包

背包三部曲的收官。这一章不学新公式,只把同一句话再用两次:循环顺序决定你读到的是上一轮还是这一轮,写反了不报错,它只是安静地解了另一道题。

需要先学:第 23 章 01 背包、第 24 章 完全背包与多重背包例题:二维费用背包 · 分组背包建议用时:120 分钟
先把第 24 章那张表拿出来 —— 这一章要用它两次

第 24 章结尾你手里握着这么一张表:

转移右边的第一维 一维怎么写
01 背包 i-1 倒序
完全背包 i 正序

这一章的两道题,都不需要在这张表上加任何东西:

  1. 二维费用背包(同时限制重量和体积)—— 表原样就能用,只是多一层循环。 前半章真正的收获是一个你多半没想过的问题: 「两层容量循环都要倒序」这句口诀里,有一层其实是白记的。
  2. 分组背包(每组至多选一件)—— 也没有新公式,但三层循环的顺序不能错。 而写错的两种方式,跑出来正好分别是第 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ⁿ 枚举子集,改一行就能用

vol2Brute.cpp2ⁿ 枚举子集
// 二维费用背包 —— 暴力: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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

改的就是那一行判断: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 步会实测。

vol2.cpp二维费用正解:三层循环
// 二维费用背包 —— 正解: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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

跑出来 14,和第 2 步手算的一样。

5★ 关键一步(二):那句口诀里,有一层是白记的

几乎所有资料都会告诉你「二维费用的两层容量循环都要倒序」。这句话没错,但它多记了一半。

★ 真正的规则只有一句

读 f[j-w][u-vol] 时,读到的必须是「还没放这件物品」的值。

现在盯住 f 这张表的形状:它是按外层下标 j 分成一整行一整行的。

  • 外层倒序时,j-w 那一整行这一轮根本还没被碰过 —— 里面每一格都还是上一轮的值。 既然整行都没动过,那内层在这一行里从左往右还是从右往左读,读到的是同一个东西。
  • 外层一旦正序,j-w 那一整行早就填完了 —— 内层再怎么倒序也救不回来。

★ 所以:方向的决定权只在外层。内层怎么写,一点关系都没有。

不用信我,跑一遍。下面这份代码是故意把内层写成正序的:

vol2In.cpp外层倒序 + 内层正序(它是对的)
// 二维费用背包:外层倒序、**内层正序** —— 这一份不是 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

还是 14。而且不是这一组数据碰巧 —— check:viz 拿 300 组随机数据跑过, 它和 vol2.cpp 一组不差(第 8 步那张表里,它的「被抓轮数」是 0 / 300)。

反过来,把外层写成正序:

vol2Up.cpp✗ 外层正序(内层仍然倒序)
// ✗ 故意写错(一):二维费用背包,**外层容量循环写成了正序**
//
// 和 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

跑出来 16 —— 正是第 2 步手算的完全背包答案。

★ 第 23、24 章那个现象,第三次出现

vol2Up.cpp 不是随机地错,它精确地解了二维费用的完全背包:

vol2Full.cpp(二维费用的完全背包,另一道题的正确答案)两层都正序

check:viz 用 300 组数据钉死了这条恒等式:两份代码的输出一组不差。

把四种写法摆在一起,这半章就说完了:

vol2Order.cpp四种循环方向并排跑
// 把二维费用的**四种循环方向**并排跑成一张表
//
// 为什么要有这份代码:正文里那句「只有外层方向说了算」是推出来的,
// 推理再顺也不如把四种写法摆在同一组数据上,让答案自己说话。
//
// 四行输出对应四种写法(外层容量循环 × 内层容量循环,各有正序 / 倒序):
//
// 倒序 + 倒序 ← 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

不给输入就用第 2 步那组数据,输出:

  外层容量循环   内层容量循环     答案   和两层都倒序一样   和两层都正序一样
  ------------   ------------   ------   ----------------   ----------------
  倒序           倒序               14   是                 否
  倒序           正序               14   是                 否
  正序           倒序               16   否                 是
  正序           正序               16   否                 是

答案按外层方向分成整整齐齐的两组,内层那一列从头到尾没起过作用。

6动画:一帧填一整行,看那一行「这轮动过没有」

★ 二维费用:只有外层那一维的方向说了算
答案 14
第 1 / 35 步
0
1
2
3
4
5
6
7
8
j=0
0
0
0
0
0
0
0
0
0
j=1
0
0
0
0
0
0
0
0
0
j=2
0
0
0
0
0
0
0
0
0
j=3
0
0
0
0
0
0
0
0
0
j=4
0
0
0
0
0
0
0
0
0
j=5
0
0
0
0
0
0
0
0
0
j=6
0
0
0
0
0
0
0
0
0
j=7
0
0
0
0
0
0
0
0
0
j=8
0
0
0
0
0
0
0
0
0
j=9
0
0
0
0
0
0
0
0
0
j=10
0
0
0
0
0
0
0
0
0
同一件最多被装进同一格几次
0
这其实是哪道题
01 背包
答案
…
每一行是一个重量 j,每一列是一个体积 u。蓝色 = 正在填的那一行, 浅绿底 = 这一轮已经填过的行。来源行只有两种颜色:绿色(这一轮还没动过)和 红色(这一轮已经填完了) —— 红色一出现, 同一件物品就能被拿第二次,题目也就从 01 背包变成了完全背包。 现在把「内层 u」那一档换个方向再看一遍:颜色、计数器、答案,一个都不会变。
f[j][u] = 重量不超过 j、体积不超过 u 时的最大价值。一帧填一整行。外层是倒序、内层是倒序 —— 请只盯住绿色那一行:它这一轮到底动过没有。

这个动画一帧填一整行 f[j][*],因为要讲的正是「整行」。 来源行只有两种颜色:绿色 = 这一轮还没碰过(还是上一轮的值), 红色 = 这一轮已经填完了。

  • 外层倒序:来源行从头到尾都是绿的,「同一件最多装进同一格几次」恒为 1;
  • 外层正序:红色开始出现,那个计数器涨到 2 —— 它已经不是 01 背包了。
⚠ 请一定动手切一下「内层 u」那个下拉框

切了之后你会发现:颜色没变、计数器没变、答案没变、连播放的步数都没变。

这不是动画坏了 —— 这就是第 5 步那个结论的画面版。 一行要么整体动过、要么整体没动过,内层在行内的先后根本影响不到这件事。

顺带一个细节值得看:外层正序时,并不是每一行的来源都是红的。 j < 2w 的那几行,来源行 j-w 这一轮还没轮到,所以是绿的 —— 红色是从 j ≥ 2w 开始出现的,而那正好就是「同一件放得下第二件」的地方。

7实测:多一维费用要多花多少

先看 DP 相对暴力值多少(W、V 固定 200,只改物品件数 n):

同题对比:2ⁿ 枚举子集 vs 三层循环 O(nWV)
先跑 22,再改成 24、26。⚠ 变的是 n —— 暴力是 2ⁿ,每加一件就翻一倍。别超过 28。
2ⁿ 枚举子集
三层循环 O(nWV)

本机实测(./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 只是线性关系。

⚠ 但多出来的这一维不是白来的:空间是 O(WV)

一维背包的 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 背包,「忘了体积维」这种大 bug 也会一路绿灯。
// 二维费用背包 —— 正解: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 步那个 ★)

最后一行是这张表最值钱的地方:对拍抓不住它,不是因为数据不够狠,而是因为它根本没错。

★ 换一批「温柔」的数据,那个大 bug 就一轮都抓不到了

我另写了一份生成器 vol2GenLoose.cpp,只改一件事:把体积上限放到大得永远吃不紧 (所有物品的体积加起来都装得下)。同样跑 300 轮:

改动 正常数据 体积永远吃不紧的数据
把体积那一维整个忘了 141 / 300 0 / 300
外层容量循环写成正序 237 / 300 277 / 300

道理简单得可怕:体积上限永远卡不住答案时,这道题本来就等于一维 01 背包 —— 体积维写没写,答案根本没区别。

vol2GenLoose.cpp(故意造得很温柔的生成器)演示用:反面教材

这是第 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 枚举「每组选谁」

groupBrute.cppDFS 枚举每组选哪一件
// 分组背包 —— 暴力: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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

输入格式是本章自己定的:第一行 组数 容量,接下来每行一组 (先是这一组的件数,再跟着若干对「价值 重量」)。这样分组关系一眼可见。 洛谷 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 组的最优解」。 在它上面加一件,这一组就正好只出了一件。「每组至多一件」是这么保证的。

组内那层循环放在最里面,起的正是这个作用:本组的每一件都从同一个「本组还没出手」的 局面出发去试,试完取最好的那一个。

group.cpp分组背包正解:组 → 容量倒序 → 组内枚举
// 分组背包 —— 正解。★ 这一章真正的新东西,而它全部的难点就是**三层循环的顺序**
//
// 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

现在把中间那两层换个位置:

groupFlat.cpp✗ 组内枚举提到了容量循环外面
// ✗ 故意写错(三):把**组内枚举那层循环提到了容量循环外面**
//
// 和 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

跑出来 21 —— 正是第 9 步那个「忘了每组只能一件」的答案。 因为这样一来,同一组的第二件读 f[j-w] 时,读到的是「第一件已经放进去」的值。

再把容量循环写成正序:

groupUp.cpp✗ 容量循环写成了正序
// ✗ 故意写错(四):分组背包的**容量循环写成了正序**
//
// 三层循环的位置全对,只有中间那层的方向反了:
//
// 正确: 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

跑出来 24 —— 正是「每件还能拿无限次」的那个答案。

★ 这一章最值得带走的一张表

同样三层循环、同样一批物品,顺序决定你解的是哪一道题:

怎么写 答案 它解的是 出处
组 → 容量倒序 → 组内 16 分组背包 本章
组 → 组内 → 容量倒序 21 无视分组的 01 背包 第 23 章
组 → 容量正序 → 组内 24 无视分组的完全背包 第 24 章

后两行不是「大概差不多」,是恒等:check:viz 拿 300 组随机数据, 把 groupFlat.cpp 和 groupIgnore.cpp(老老实实的 01 背包)、 groupUp.cpp 和 groupFull.cpp(老老实实的完全背包)各对了一遍,一组不差。

groupIgnore.cpp(无视分组的 01 背包)另一道题的正确答案
groupFull.cpp(无视分组的完全背包)又一道题的正确答案

连着第 23 章(01 写成正序 ≡ 完全背包)、第 24 章(完全写成倒序 ≡ 01 背包), 再加上本章前半段那条(二维费用外层正序 ≡ 二维费用的完全背包), 背包这三章一共钉死了五条这样的恒等式。它们都在说同一件事:

DP 的循环顺序不是「格式」,它就是题目本身。 写反了不会报错、不会崩溃、不会有警告 —— 它只是安静地去解另一道题,然后把答案交给你。

12动画:盯住格子下面那排圆点

★ 分组背包:三层循环,顺序决定你解的是哪道题
答案 16
第 1 / 50 步
第 1 组(至多选一件)
v6 w4
v5 w3
第 2 组(至多选一件)
v7 w3
v6 w3
第 3 组(至多选一件)
v3 w2
v8 w6
第 4 组(至多选一件)
v1 w7
v1 w8
0
1
2
3
4
5
6
7
8
9
10
11
f
0
0
0
0
0
0
0
0
0
0
0
0
同一组最多装进同一格几件
0
「每组至多一件」还成立吗
成立
答案
…
蓝色 = 正在填的 f[j],格子下面的圆点 = 这一格里装了本组几件。 来源格只有两种颜色:绿色(本组还没碰过它)和 红色(本组这一轮已经改过它) —— 红色一出现,本组的第二件就跟着进来了。 最后一档(漏掉「本组不选」)是个例外:圆点始终是 1 个,答案却更小 —— 有的 bug 计数器看不出来,只能靠对拍。
f[j] = 容量不超过 j 时的最大价值。每处理完一组,f 就整体前进一步。请盯住每一格下面那排圆点:它是「这一格里装了本组几件」。题目要求每组至多一件,所以正确的写法里它永远不会超过一个。

下拉框有四档(一个正确、三个错误),但你只要盯每一格下面那排圆点 —— 那是「这一格里装了本组的几件」。题目要求每组至多一件,所以:

写法 圆点最多几个 答案
正确 1 16
组内枚举提到容量外 2 21
容量循环写成正序 3 24
漏掉「本组不选」这个选项 1 4
⚠ 第四档是特意留的:有的 bug,计数器根本看不出来

最后那一档(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 只能靠对拍。

groupMust.cpp✗ 漏掉「本组不选」这个选项
// ✗ 故意写错(五):把「每组**至多**选一件」写成了「每组**必须**选一件」
//
// 前两个错误都是顺序问题,这一个是**读题问题** —— 而且它藏在一个很不起眼的地方:
//
// 正确: 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

13实测:每多一组,暴力乘以 4,正解只加 3 件

同题对比:DFS 枚举每组选谁 vs 三层循环 O(W × 总件数)
先跑 12,再改成 14、15。⚠ 变的是组数 —— 暴力是 ∏(件数+1),每组固定 3 件就是 4^组数。别超过 16。
DFS 枚举每组选谁
三层循环 O(W × 总件数)

本机实测(./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★ 对拍(二)

对拍器
★ 生成器的灵魂是「同一组里真的想选两件」:每组至少两件、组内两件的重量加起来装得下、组数不能太少。少一条,「组内枚举提到外面」这个 bug 就抓不住。
// 分组背包 —— 正解。★ 这一章真正的新东西,而它全部的难点就是**三层循环的顺序**
//
// 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 轮
★ 最后那个 92 / 300 是怎么从 24 / 300 拉上来的

groupMust.cpp 只在一种局面下露馅:某一组差到宁可整组跳过。 容量一旦宽松,多塞一件哪怕很差的东西也不亏 —— 正解自己也会去拿它,两份代码答案一样。

所以生成器改了三次,每次只动一处,每次都实测:

生成器怎么改 抓获率
最初:容量 W 独立随机 24 / 300
改成由物品反推 W(背包一定吃紧) 53 / 300
「垃圾组」独立掷骰子,出现率从 1/3 提到 1/2 65 / 300
垃圾组的重量改成按 W 定(每件都吃掉半个背包以上) 92 / 300
groupGen.cpp改了三次的生成器

抓不住不代表 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自测

自测清单0 / 10
配套练习
  • 洛谷 P1855 榨取kkksc03解析 → —— 二维费用裸题(钱和时间两个上限)。注意两个上限都只有 200 —— 这就是本章第 7 步说的那个信号
  • 洛谷 P1507 NASA的食物计划解析 → —— 同样是二维费用(体积和质量),换了张皮。两题对着写一遍,「多一层循环」就再也忘不了
  • 洛谷 P1757 通天之分组背包解析 → —— 分组背包模板题。输入是「每件物品自带组号」,先归类再跑 —— 正好练一遍本章那个输入格式的转换
  • 洛谷 P1064 金明的预算方案解析 → —— NOIP2006。主件 + 附件,看上去是新题型,其实是分组背包:把「主件单买 / 主件+附件1 / 主件+附件2 / 主件+两个附件」当成一组里的四件互斥物品。★ 这题是「看出它是分组背包」的经典训练
  • 洛谷 P1541 乌龟棋解析 → —— NOIP2010。四种卡片各用了几张 → 四维费用,也就是四层循环。本章那句「多一维就多一层」的极致版本
  • 洛谷 P5322 排兵布阵解析 → —— BJOI2019。进阶:先想清楚「对第 i 座城堡派 x 个兵能赢几个对手」,再把每座城堡的所有 x 当成一组。适合确认自己是真的会了
这一章我卡在哪(过一个月回来看,这几行比整章正文都值钱)