题单 · 习题解析

洛谷 P1757 通天之分组背包

★★★ 题面上那两行数字:「1 ≤ k ≤ 100」是组数、「0 ≤ cᵢ ≤ 10⁴」是组号 —— 差 100 倍;顺手把组号编成 1..k 的生成器把那个 bug 抓成精确的 0

原题:洛谷 P1757出自 第 25 章 二维费用与分组背包 的题单题面本地存档:2026-08-29
⚠ 先自己写一遍,再往下看

这一页不是标准答案,是一条阶梯:把大多数人真实会写出的第一版摆出来, 看它错在哪、慢在哪,再一步一步改。所以它对「已经自己动手撞过一次墙」的人最有用。
还没写过的话,先去写 —— 写出来的哪怕是错的,也比直接看这一页收获大。

0题目原文(本地存了一份)

为什么要在这儿抄一份题面

原题在洛谷上(页头有链接)。别人的网站不归我们管,打不开、改版、题号调整都可能发生。 所以每个解析页都把题面转录一份存在本地,跟着仓库一起进版本库。

转录自洛谷 P1757,日期见页头。两边不一致时信原站。

题目背景

【管理员备注】本题数据实际满足 aᵢ, bᵢ 为非负整数,且 0 ≤ cᵢ ≤ 10⁴。 鉴于此题为分组背包经典例题,不会加入 aᵢ, bᵢ, cᵢ 为负数之类的无意义的 hack 数据。

直达通天路·小 A 历险记第二篇

题目描述

自 01 背包问世之后,小 A 对此深感兴趣。一天,小 A 去远游,却发现他的背包不同于 01 背包, 他的物品大致可分为 k 组,每组中的物品相互冲突,现在,他想知道最大的利用价值是多少。

输入格式

两个数 m, n,表示一共有 n 件物品,背包能承受的最大重量为 m

接下来 n 行,每行 3 个数 aᵢ, bᵢ, cᵢ,表示物品的重量,利用价值,所属组数。

输出格式

一个数,最大的利用价值。

说明/提示

0 ≤ m ≤ 10001 ≤ n ≤ 10001 ≤ k ≤ 100aᵢ, bᵢ, cᵢint 范围内。

输入输出样例

输入

45 3
10 10 1
10 5 1
50 400 2

输出

10

背包能装 45,三件物品:前两件同属第 1 组(互相冲突,只能选一件),第三件在第 2 组但重 50,装不下。 ⇒ 只能在第 1 组里挑价值大的那件:10

★ 这一组样例挡住了「第一行读反」(打 0)和「组内提到容量外」(打 15), ⚠ 而对本页的主角 ——「只扫组号 1..100」—— 完全无能为力(样例的组号就是 1 和 2)。

1★ 第一关不是 DP,是那两个数的顺序

题面第一句:「两个数 m, n,表示一共有 n 件物品,背包能承受的最大重量为 m」—— 先容量、后件数。而绝大多数背包题是反过来写的(n 在前)。

p1757Swap.cpp✗ 第一行读成了 n m
// ✗ P1757 的第一个坑,和算法一点关系都没有:第一行两个数读反了。
//
// 题面第一句是「两个数 `m, n`,表示一共有 `n` 件物品,背包能承受的最大重量为 `m`」——
// **先容量、后件数**,而绝大多数背包题是反过来的。
//
// ★ 这一版把它读成了 `n m`。官方样例 `45 3` 于是变成「45 件物品、容量 3」:
// 后面只有 3 行可读,读完就没了 ⇒ 打出 0。**样例当场挡住。**
// ⚠ 这份代码老老实实检查了 `scanf` 的返回值 —— 读不到就停,
// 免得拿一堆未初始化的垃圾去做 DP(那样打出来的数**换个编译器就变**,没法写进正文)。
#include <bits/stdc++.h>
using namespace std;
int main() {
int m, n;
if (scanf("%d %d", &n, &m) != 2) return 0; // ✗ 读反了
map<int, vector<pair<int, int>>> by;
for (int i = 0; i < n; i++) {
int a, b, c;
if (scanf("%d %d %d", &a, &b, &c) != 3) break; // 读不到就停
by[c].push_back({ a, b });
}
if (m < 0) m = 0;
vector<int> f(m + 1, 0);
for (auto& kv : by)
for (int j = m; j >= 0; j--)
for (auto& it : kv.second)
if (it.first <= j) f[j] = max(f[j], f[j - it.first] + it.second);
printf("%d\n", f[m]);
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

样例 45 3 于是变成「45 件物品、容量 3」,后面只有 3 行可读 ⇒ 打出 0样例当场挡住。

⚠ 演示错误写法时,别让它去读没读到的东西

这一版老老实实检查了 scanf 的返回值,读不到就停。 如果放任它拿未初始化的局部变量去做 DP,打出来的数换个编译器就变 —— 第 43 章那一列就是这么栽的:靠未定义行为出错的数字原理上不可复现, 写进正文等于写了一句下次跑就不成立的话。

2★ 关键的一步:按组号归类,然后三层循环

第 25 章正文第 11 步那三层循环,一个字都不用改:

    for 每一组
        for j = m .. 0          ← 容量,倒序
            for 组内每一件
                f[j] = max(f[j], f[j - a] + b)

这道题唯一多出来的动作是归类:物品是「每件自带组号」发下来的, 而且同一组的物品在输入里不一定连着(生成器专门打乱过)。

p1757.cpp★ 这一版就能 AC
// P1757 通天之分组背包 —— ★ 这一版就能 AC。
//
// 第 25 章下半场那三层循环,原样搬过来:
// for 每一组
// for j = m .. 0 ← 容量,倒序
// for 组内每一件
// f[j] = max(f[j], f[j - a] + b)
//
// ⚠ 这道题真正要过的第一关不是 DP,是**读入**:
// ① 第一行是 `m n` —— **先容量、后件数**(读反了会读到一堆垃圾,见 p1757Swap.cpp);
// ② 物品是「每件自带组号」,要先按组号归类;
// ③ ★★ 而**组号不是 1..k** —— 题面写的是「`1 ≤ k ≤ 100`」(**组数**至多 100),
// 管理员备注又写着「`0 ≤ cᵢ ≤ 10⁴`」(**组号**能到一万)。这两句差 100 倍。
// ⇒ 这里用一张 `10001` 个桶的表归类,再把**真正出现过**的组号收集起来跑。
#include <bits/stdc++.h>
using namespace std;
const int MAXC = 10000;
int main() {
int m, n;
if (scanf("%d %d", &m, &n) != 2) return 0; // ★ 先 m(容量)后 n(件数)
vector<vector<pair<int, int>>> grp(MAXC + 1); // grp[组号] = {(重量, 价值), ...}
vector<int> ids;
for (int i = 0; i < n; i++) {
int a, b, c;
if (scanf("%d %d %d", &a, &b, &c) != 3) return 0;
if (c < 0 || c > MAXC) continue;
if (grp[c].empty()) ids.push_back(c);
grp[c].push_back({ a, b });
}
vector<int> f(m + 1, 0);
for (int c : ids)
for (int j = m; j >= 0; j--) // 容量倒序:读到的 f[j-a] 还没被本组碰过
for (auto& it : grp[c]) // 组内枚举必须在最里层
if (it.first <= j) f[j] = max(f[j], f[j - it.first] + it.second);
printf("%d\n", f[m]);
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

3★★★ 题面上那两行数字,一行说的是「有几组」,另一行说的是「组号能有多大」

数据范围那一行写着 1 ≤ k ≤ 100k 在题目描述里是「物品大致可分为 k 组」—— 组数。而管理员备注里另有一句:0 ≤ cᵢ ≤ 10⁴ —— 那是组号的取值范围

★★ 这两句差 100 倍,而它们说的根本不是一件事

至多 100 个组,组号却可以撒在 0 ~ 10 000 之间。

于是「照着 k ≤ 100 开数组、照着 for (g = 1; g <= 100; g++) 扫一遍」这种写法, 会把组号更大的那些物品整组漏掉

p1757K100.cpp✗ 只扫组号 1..100
// ✗ P1757 的第二个坑:照着「`1 ≤ k ≤ 100`」这一行,把组号也当成了 1..100。
//
// 题面数据范围里那一行写着 `1 ≤ k ≤ 100`,而 `k` 在题目描述里指的是「物品大致可分为 k 组」——
// 也就是**组数**。可管理员备注里另写了一句:`0 ≤ cᵢ ≤ 10⁴` —— 那是**组号的取值范围**。
//
// ★★ 这两句差 **100 倍**,而它们说的是两件事:
// 「有几组」是 100,「组号能有多大」是 10 000。
//
// 这一版只扫组号 1..100,于是组号更大的那些物品**整组被漏掉**(答案偏小)。
#include <bits/stdc++.h>
using namespace std;
int main() {
int m, n;
if (scanf("%d %d", &m, &n) != 2) return 0;
vector<vector<pair<int, int>>> grp(101);
for (int i = 0; i < n; i++) {
int a, b, c;
if (scanf("%d %d %d", &a, &b, &c) != 3) return 0;
if (c >= 1 && c <= 100) grp[c].push_back({ a, b }); // ✗ 组号 > 100 的全丢了
}
vector<int> f(m + 1, 0);
for (int c = 1; c <= 100; c++)
for (int j = m; j >= 0; j--)
for (auto& it : grp[c])
if (it.first <= j) f[j] = max(f[j], f[j - it.first] + it.second);
printf("%d\n", f[m]);
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

样例上它打出 10,一点破绽都没有 —— 因为样例的组号就是 1 和 2。

★★★ 而顺手写的生成器会把这个 bug 藏得严严实实

只拧一个开关:组号怎么发(各 300 轮,别的一个字不改)

组号怎么发 「只扫 1..100」被抓 而「真有组号 > 100」的轮数
顺手编成 1, 2, 3, …(几乎所有人的第一版生成器) 精确的 0 0
照题面撒在 [0, 10⁴] 268 / 300 300 / 300

★ 上面那个 0 不是概率低,是结构性的:组号根本没机会超过 100。 ⇒ 又一次「两种 0,造两档就能分开」,而这一次造出那一档几乎不花钱 —— 生成器里发组号那一行改一改而已。

⚠ 值得记住的是默认值的方向:写生成器时「组号就编 1, 2, 3」是最自然的动作, 而它正好把题面明写的那个取值范围抹掉了。 ⇒ 第 22 章 P1020 那条规矩的另一种形态:生成器要照抄题面写的东西, 而不是照抄你以为的样子。

4★ 组内那层循环提到外面 —— 它精确地在解另一道题

p1757Flat.cpp✗ 组内提到了容量循环外面
// ✗ P1757 的第三个坑:组内那层循环提到了容量循环**外面**。
//
// 第 25 章正文第 11 步那张表:三层循环的顺序**决定你解的是哪一道题**。
// 组 → 组内 → 容量倒序,读到的 `f[j-a]` 是「本组已经放过一件」的值 ⇒ 同一组能拿好几件。
//
// ★ 它不是「随便错」:它精确地等于**无视分组的 01 背包**(p1757Ignore.cpp),300 轮逐组相等。
#include <bits/stdc++.h>
using namespace std;
int main() {
int m, n;
if (scanf("%d %d", &m, &n) != 2) return 0;
map<int, vector<pair<int, int>>> by;
for (int i = 0; i < n; i++) {
int a, b, c;
if (scanf("%d %d %d", &a, &b, &c) != 3) return 0;
by[c].push_back({ a, b });
}
vector<int> f(m + 1, 0);
for (auto& kv : by)
for (auto& it : kv.second) // ✗ 组内提到了外面
for (int j = m; j >= it.first; j--)
f[j] = max(f[j], f[j - it.first] + it.second);
printf("%d\n", f[m]);
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

样例上它打出 15(把同组两件一起拿走了),样例挡住

★ 300 轮逐组相等:它 ≡ 无视分组的 01 背包
300 轮:它 vs 老老实实的 01 背包 300 / 300 逐组相等
默认档里它被抓 142 / 300

⇒ 第 25 章正文第 11 步那张表在真题上的复现: 循环顺序不是「格式」,它就是题目本身。

p1757Ignore.cpp(无视分组的 01 背包)另一道题的正确答案

5⚠⚠ 那三层循环也有一个没写出来的前提:重量 ≥ 1

组内那层在最里面,读的是 f[j-a]一旦 a = 0,读到的就是同一格 —— 而那一格可能已经被本组的前一件改过了。于是同一组会被拿走好几件。

⚠ 而管理员备注写的是「aᵢ, bᵢ非负整数」—— 0 是允许的

★★ 量一遍(参照物是「每组选谁」的 DFS 暴力,各 300 轮)
生成器 标准三层循环 vs 暴力 稳的写法 vs 暴力 而「真有重量为 0 的物品」
照顺手的写法随机(a ≥ 1 0 0 0
允许重量为 0 37 / 300 不一致 0 110 / 300

★ 治法只有一行:组内枚举时统一读「这一组还没动手之前」的那份 f (每组多拷一份,O(组数 × m) = 100 × 1000,随便)。

⇒ 这是本章前一页 P1855 那件事的第二次现场: 那一页是「二维费用的内层循环怎么写都一样 —— 前提是 m ≥ 1」, 这一页是「组内枚举放最里层就对 —— 前提是 a ≥ 1」。 ★★ 同一个前提在同一章里咬了两次,而两次都写在题面没管的那个下界上。

⚠ 说清楚证据等级:这不是说交上去会 WA —— 洛谷那份数据里显然没有这种输入(否则全网的模板写法都过不了)。 量它是为了记住那个前提存在,以及生成器该往哪儿拧。

p1757Safe.cpp(对 a = 0 也稳的写法)★ 一行之差

6★ 规模:一道三十秒的算术题

每件物品被内层扫一遍容量 ⇒ 顶格 n × m 1000 × 1000 = 100 万
按组号归类要多少个桶 10 001 个(组号 ≤ 10⁴,不是 100)
f 数组 3 KB

⇒ 这道题的复杂度和「有几组」无关 —— 每件物品只会被它自己那一组扫到一次

7度量程序、生成器和参照物

p1757Count.cpp度量程序(本页所有数字都出自它)
p1757Gen.cpp数据生成器
p1757Brute.cpp参照物:DFS 枚举「每组选谁」(300 轮不一致 0 轮)

8一页纸

第一关 第一行是 m n(先容量后件数)—— 读反了样例当场打 0
关键的一步 按组号归类(同组的物品在输入里不一定连着),然后组 → 容量倒序 → 组内
★★★ 题面那两行数字 1 ≤ k ≤ 100组数0 ≤ cᵢ ≤ 10⁴组号 —— 差 100 倍,说的不是一件事
那个精确的 0 生成器把组号编成 1..k ⇒「只扫 1..100」被抓 0 / 300;照题面撒 ⇒ 268 / 300
组内提到外面 无视分组的 01 背包(300/300 逐组相等),默认档被抓 142
⚠⚠ 那三层循环的前提 a ≥ 1。允许重量为 0 之后,标准写法和暴力 37 / 300 不一致(稳的写法 0)
★★ 同一章第二次 P1855 是「内层方向无所谓 —— 前提 m ≥ 1」,这里是「组内放最里层 —— 前提 a ≥ 1
规模 顶格 1000 × 1000 = 100 万次;桶表 10 001 个;f 数组 3 KB