题单 · 习题解析

洛谷 P1855 榨取kkksc03

★★★ 第 25 章正文证过「二维费用的内层循环怎么写都一样」—— 这一页问它的前提:题面没给 mᵢ 的下界,加一档 m = 0,那个精确的 0 当场变成 51/300

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

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

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

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

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

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

题目描述

洛谷 2 的团队功能是其他任何 OJ 和工具难以达到的。借助洛谷强大的服务器资源, 任何学校都可以在洛谷上零成本的搭建 OJ 并高效率的完成训练计划。

为什么说是搭建 OJ 呢?为什么高效呢?

原题插图一:团队里的私有题目

因为,你可以上传私有题目,团队外别人是无法看到的。我们还能帮你们评测!

你可以创建作业,给组员布置任务,查看组员的完成情况,还可以点评任意一份代码!

你可以创建比赛!既可以是 OI 赛制还可以是 ICPC 赛制!既可以是团队内部的私有比赛, 也可以公开赛,甚至可以指定谁可以参加比赛。这样,搞「x 校联赛」最合适不过了。 洛谷凭借这个功能,希望能够提供公开及私有比赛的另外一个平台。

原题插图二:团队里的比赛

值得说明的是,本次比赛就是采用团队私有题目 + 邀请比赛的机制。

洛谷的运营组决定,如果一名 OIer 向他的教练推荐洛谷,并能够成功的使用 (成功使用的定义是:该团队有 20 个或以上的成员,上传 10 道以上的私有题目, 布置过一次作业并成功举办过一次公开比赛), 那么他可以浪费掉 kkksc03 的一些时间的同时消耗掉 kkksc03 的一些金钱以满足自己的一个愿望。

kkksc03 的时间和金钱是有限的,所以他很难满足所有同学的愿望。 所以他想知道在自己的能力范围内,最多可以完成多少同学的愿望?

输入格式

第一行三个整数 n, M, T,表示一共有 n1 ≤ n ≤ 100)个愿望, kkksc03 的手上还剩 M0 ≤ M ≤ 200)元,他的暑假有 T0 ≤ T ≤ 200)分钟时间。

2 ~ n+1mᵢtᵢ 表示第 i 个愿望所需要的金钱和时间。

输出格式

一行,一个数,表示 kkksc03 最多可以实现愿望的个数。

输入输出样例

输入

6 10 10
1 1
2 3
3 2
2 5
5 2
4 3

输出

4

六个愿望、10 元、10 分钟。取 (1,1)(2,3)(3,2)(4,3) 这四个: 一共 10 元、9 分钟,两样都没超 ⇒ 4 个。 ★ 五个为什么不行:钱最少的五个是 1+2+2+3+4 = 12 元 > 10,怎么挑都超。

★ 这一组样例只挡住了四个错法里的一个(外层正序打出 10)—— 贪心、合并费用、内层正序三个全都照样打出 4

1第一版:几乎所有人的第一反应是贪心 —— 「先挑便宜的」

题目只数件数(每个愿望都值 1 分),所以第一反应特别顺:先做代价小的那些,能多做几个。

p1855Greedy.cpp✗ 第一版:按 m + t 升序,能塞就塞
// ✗ P1855 的第一版:贪心 —— 「先挑便宜的」。
//
// 这是绝大多数人真实的第一反应,而且它的理由听着很顺:
// 既然只数**件数**(每个愿望都值 1 分),那当然先做代价小的那些。
//
// ⚠ 问题在于「代价小」没有主语:这里有**两种**代价。
// 这一版把它们加起来当成一把尺子(`m + t` 升序),能塞就塞。
//
// ★ 它到底错多少,页面上量过:不是「经常错得离谱」,而是「偶尔错一点点」——
// 这正是这类上当最难自查的地方。
#include <bits/stdc++.h>
using namespace std;
int main() {
int n, M, T;
if (scanf("%d %d %d", &n, &M, &T) != 3) return 0;
vector<pair<int, int>> a(n);
for (int i = 0; i < n; i++)
if (scanf("%d %d", &a[i].first, &a[i].second) != 2) return 0;
sort(a.begin(), a.end(), [](const pair<int, int>& x, const pair<int, int>& y) {
return x.first + x.second < y.first + y.second; // 两种代价加起来当一把尺子
});
int cnt = 0;
for (int i = 0; i < n; i++)
if (a[i].first <= M && a[i].second <= T) { M -= a[i].first; T -= a[i].second; cnt++; }
printf("%d\n", cnt);
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

样例上它打出 4,和正解一样。⚠ 而这正是这个上当最难自查的地方。

★ 「便宜」这个词没有主语 —— 这道题有两种代价

(1, 9)(5, 5) 谁更便宜?按 m + t 排,前者 10、后者 10,一样便宜; 可如果钱只剩 3 元、时间还有一整天,那两件里只有第一件拿得动

两个上限是各自独立的,一把尺子量不出来。

2★ 那它到底错多少 —— 「多久错一次」和「错的时候差多少」是两件事

★★ 沿「容量 / 单件费用」的比值拧(`n ≤ 16`,单件费用 ≤ 8,各 300 轮)
比值(容量 ÷ 费用上限) 1 2 4 8 16
贪心被抓 1 9 13 28 23
错的时候平均少 50.00% 41.66% 27.73% 23.08% 17.62%
⚠ 而正解本身就是 0 的轮数 137 59 35 22 5

两条曲线形状不同:抓获率一路上升、到第 8 档见顶(28)之后回落到 23, 而「错的时候差多少」是一路往下的 —— 和第 20 章 P1048 那页量出来的形状一模一样。 ⇒ 「多久错一次」和「错的时候差多少」别混成一个数。

⚠⚠ 第三行是这张表最该看的一行:比值 1 那一档 300 轮里有 137 轮正解本身就是 0 (钱和时间都太少,一个愿望都实现不了)。那 137 轮里「两版一致」验的是零。第 14 章 P1746 那条「一致有两种:都算对了,和都没算」的又一次现场。

3第二版:把两种费用加起来,退回一维背包

第二个特别顺的想法:既然都是代价,把 m + t 当成一个重量、M + T 当成容量, 第 23 章那份一维 01 背包一个字都不用改。

p1855Sum.cpp✗ 第二版:合并两种费用
// ✗ P1855 的第二版:把两种费用**加起来**,退回一维背包。
//
// 想法是:「反正都是代价,`m + t` 一起算,容量就是 `M + T`」——
// 这样第 23 章那份一维 01 背包一个字都不用改。
//
// ⚠ 它错在**两个上限是各自独立的**:
// `m` 花超了不能拿 `t` 的余额去垫。合并之后这堵墙就没了。
//
// ★ 它有一条能写下来的性质:**答案恒 ≥ 正解**(只会高估,绝不会低估)——
// 因为每一组合法方案在合并之后仍然合法(`Σm ≤ M` 且 `Σt ≤ T` ⇒ `Σ(m+t) ≤ M+T`),
// 而反过来不成立。页面上那张表就是拿这条对出来的。
#include <bits/stdc++.h>
using namespace std;
int f[405]; // 容量 M + T ≤ 400
int main() {
int n, M, T;
if (scanf("%d %d %d", &n, &M, &T) != 3) return 0;
int cap = M + T;
for (int i = 0; i < n; i++) {
int m, t;
if (scanf("%d %d", &m, &t) != 2) return 0;
for (int j = cap; j >= m + t; j--)
f[j] = max(f[j], f[j - m - t] + 1);
}
printf("%d\n", f[cap]);
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

样例上它也打出 4

★ 而它「算了什么」能说死:这一版的答案恒 ≥ 正解

每一组合法方案在合并之后仍然合法(Σm ≤ MΣt ≤ TΣ(m+t) ≤ M+T), 反过来不成立 —— 合并之后,钱花超的那部分可以拿时间的余额去垫。

300 轮里「它的答案 ≥ 正解」 300 / 300
默认档里它被抓 148 / 300

它只会高估,绝不会低估 —— 所以在洛谷上交它,得到的是「答案偏大」的 WA, 而不是「有时大有时小」。说清楚一个 bug 算了什么,它的所有表现都是白送的推论。

4★ 正解:多一维费用,就多一层循环

第 25 章正文第 4 步那句口诀,原样搬过来 —— 这道题价值全是 1(只数件数):

    for 每个愿望 i
        for j = M .. m[i]      ← 钱,倒序
            for k = T .. t[i]  ← 时间,倒序
                f[j][k] = max(f[j][k], f[j-m][k-t] + 1)
p1855.cpp★ 这一版就能 AC
// P1855 榨取kkksc03 —— ★ 这一版就能 AC。
//
// 二维费用 01 背包的**裸题**:每个愿望要同时付「钱」和「时间」两种代价,
// 而**价值全是 1**(题目只问「最多实现几个」)。
//
// 所以第 25 章那句口诀原样搬过来,只是内层多了一维:
// for 每个愿望
// for j = M .. m[i] ← 钱,倒序
// for k = T .. t[i] ← 时间,倒序
// f[j][k] = max(f[j][k], f[j-m][k-t] + 1)
//
// ⚠ 两个容量维**都**要倒序 —— 只要有一维写成正序,同一个愿望就能被实现好几次
// (见 p1855Up.cpp,那时候它解的是「每个愿望可以许愿无限次」那道题)。
#include <bits/stdc++.h>
using namespace std;
int f[205][205]; // f[j][k] = 花掉不超过 j 元、k 分钟,最多实现几个愿望
int main() {
int n, M, T;
if (scanf("%d %d %d", &n, &M, &T) != 3) return 0;
for (int i = 0; i < n; i++) {
int m, t;
if (scanf("%d %d", &m, &t) != 2) return 0;
for (int j = M; j >= m; j--)
for (int k = T; k >= t; k--)
f[j][k] = max(f[j][k], f[j - m][k - t] + 1);
}
printf("%d\n", f[M][T]);
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
★ 「能不能这么做」是一道三十秒的算术题 —— 而题面已经把答案写在数据范围里了
顶格 n = 100M = T = 200 ⇒ 内层执行 4 000 000
f201 × 201int 157 KB(限制 128 MB)

⇒ 第 25 章正文第 7 步那句话在这道题上兑现了: 二维费用题的两个上限通常都开得很小(这里是 200 × 200)—— 看到题面里两个上限都是两三位数,出题人就是在提示你上二维费用。

5★ 错法(一):外层写成正序 —— 它精确地在解另一道题

p1855Up.cpp✗ 外层(钱)那一维正序
// ✗ P1855 的错法(一):**外层**(钱)那一维写成了正序。
//
// 第 25 章正文第 5 步证过:二维费用里**方向的决定权只在外层**。
// 外层一旦正序,`f[j-m][…]` 那一整行这一轮早就填完了 —— 同一个愿望可以被反复实现。
//
// ★ 而它不是「随便错」:它**精确地**解了「每个愿望可以实现无限次」那道题
// (= p1855Full.cpp,300 轮逐组相等)。
#include <bits/stdc++.h>
using namespace std;
int f[205][205];
int main() {
int n, M, T;
if (scanf("%d %d %d", &n, &M, &T) != 3) return 0;
for (int i = 0; i < n; i++) {
int m, t;
if (scanf("%d %d", &m, &t) != 2) return 0;
for (int j = m; j <= M; j++) // ✗ 外层正序
for (int k = T; k >= t; k--) // 内层仍然倒序,救不回来
f[j][k] = max(f[j][k], f[j - m][k - t] + 1);
}
printf("%d\n", f[M][T]);
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

样例上它打出 10 —— 六个愿望却实现了十个,这一次样例挡住了

★ 它不是「随便错」:它等于「每个愿望可以实现无限次」那道题
300 轮:它 vs 二维费用的完全背包 300 / 300 逐组相等

⇒ 第 23 章(01 写成正序 ≡ 完全背包)、第 24 章(完全写成倒序 ≡ 01 背包)、 第 25 章正文(二维费用外层正序 ≡ 二维费用的完全背包)—— 这是同一条恒等式的第四次现场。

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

6★★★ 错法(二):内层写成正序 —— 正文说「怎么写都一样」,那句话在这道题上还成立吗

第 25 章正文第 5 步专门证过一件事:

二维费用的方向决定权只在外层。外层倒序时,f[j-m] 那一整行这一轮根本没被碰过, 所以内层从左往右还是从右往左,读到的是同一个东西

p1855In.cpp外层倒序 + 内层正序
// P1855:外层倒序、**内层正序** —— 第 25 章正文说过「内层怎么写都一样」。
//
// ★★★ 这一页要问的是:那句话在这道题上还成立吗?
//
// 正文那条论证有一个没写出来的前提:`j - m` 得**真的是另一行**。
// 而它成立的条件是 `m ≥ 1` —— 一旦某个愿望的**金钱费用是 0**,
// `f[j-0][k-t]` 读的就是**同一行**,内层方向立刻变成决定性的:
// 那个愿望会沿着时间轴被反复实现。
//
// ⚠ 而 P1855 的题面**根本没给 m、t 的下界**(只写了 `0 ≤ M ≤ 200`、`0 ≤ T ≤ 200`)。
// ⇒ 页面上那张表:照「m, t ≥ 1」随机 300 轮抓 **0** 次(结构性的 0,不是概率低);
// 生成器加一档「允许 m = 0」,当场 300 / 300。
#include <bits/stdc++.h>
using namespace std;
int f[205][205];
int main() {
int n, M, T;
if (scanf("%d %d %d", &n, &M, &T) != 3) return 0;
for (int i = 0; i < n; i++) {
int m, t;
if (scanf("%d %d", &m, &t) != 2) return 0;
for (int j = M; j >= m; j--) // 外层倒序(对的)
for (int k = t; k <= T; k++) // 内层正序 —— 只有 m = 0 时才咬人
f[j][k] = max(f[j][k], f[j - m][k - t] + 1);
}
printf("%d\n", f[M][T]);
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

照题面随机跑 300 轮,它和正解 一次都没有不一致 —— 那句话看上去稳稳成立。

⚠⚠ 但那条论证有一个没写出来的前提:j - m 得真的是「另一行」

m = 0 的时候,f[j-0][k-t] 读的就是同一行,而内层正序会一路读到刚写过的格子 —— 那个愿望会沿着时间轴被反复实现。

⇒ 于是问题变成:题面允许 m = 0 吗? 翻回去看那两行:只写了 0 ≤ M ≤ 2000 ≤ T ≤ 200关于 mᵢtᵢ 的取值范围,题面一个字都没有。

★★★ 造一档「允许费用为 0」的数据 —— 那个 0 当场就不是 0 了
生成器 内层正序被抓 而「存在 m = 0 的愿望」的轮数
照顺手的写法随机(m, t ≥ 1 精确的 0 0
允许费用为 0 51 / 300 132 / 300

★ 上面那个 0 不是概率低,是结构性的 —— 正文第 5 步那段论证就是它的证明, 加多少轮都不会变(第 12 章 P1226 那条「两种 0,造两档就能分开」)。 ⇒ 而分开之后结论很干净:这是「生成器缺一档」,不是「题面挡死」。

★ 顺带又量到一次「触发条件 ≠ 抓获数」:132 轮里只有 51 轮真被抓(差 2.6 倍)—— 因为「存在 m = 0 的愿望」只是第一层,那个愿望还得真的落在最优解上、而且时间还有富余。 这条曲线本书已经量了十几次,从「一个不差」到「差 150 倍」都有。

7★★ 同一个旋钮,三个错法三条不同的曲线

★ 还是上面那把「比值」旋钮(各 300 轮),把三个错法一起量
比值(容量 ÷ 费用上限) 1 2 4 8 16
贪心 1 9 13 28 23
合并费用 96 136 154 174 120
外层正序 44 123 205 251 286

外层正序是唯一一条单调的(44 → 286 一路涨); 另外两个都在第 8 档见顶之后回落(贪心 1 → 28 → 23,合并费用 96 → 174 → 120)。

⇒ 「顺手写的生成器够不够狠」这句话没有统一答案 —— 它得连着「你要抓哪个 bug」一起说。 ★ 而两头都差的原因不一样:比值太小时一个愿望都装不下(正解常常是 0), 比值太大时全都装得下(谁做都对)。

8★ 官方样例这一次不是「一测就死的过滤器」

本书从第 19 章起连着量到一条规律:官方样例挡住的往往是「每组都错」的错法, 放过的是「偶尔才错」的。这一页给它添了一个例外

错法 样例挡住了吗 默认档 300 轮被抓
贪心 ✗ 放过(也打 4) 17
合并费用 ✗ 放过(也打 4) 148
内层正序 ✗ 放过(也打 4) 0(结构性)
外层正序 挡住(打 10) 134

外层正序在默认档也只有 134 / 300,却被样例当场打死。 ⇒ 规律终究是规律,不是定律:样例挡不挡得住,只能一个一个试。

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

p1855Count.cpp度量程序(本页所有数字都出自它)
p1855Gen.cpp数据生成器
p1855Brute.cpp参照物:2ⁿ 枚举子集(300 轮不一致 0 轮)

10一页纸

关键的一步 二维费用 01 背包的裸题,而且价值全是 1(只数件数)⇒ 多一维费用就多一层循环
规模 顶格 100 × 200 × 200 = 400 万次,f157 KB —— 三十秒的算术
★ 读题的信号 两个上限都只有 200 ⇒ 出题人在提示你上二维费用(正文第 7 步那条)
第一版:贪心 「便宜」没有主语 —— 两个上限各自独立。★ 抓获率第 8 档见顶后回落、错的幅度一路下降(两条曲线形状不同)
第二版:合并费用 恒 ≥ 正解(300/300)—— 只会高估;默认档被抓 148
错法(一)外层正序 ≡ 二维费用的完全背包(300/300 逐组相等);样例当场打死
★★★ 错法(二)内层正序 正文说「内层怎么写都一样」——⚠ 那句话的前提是 m ≥ 1题面没给 mᵢ 的下界
那个「精确的 0」 照顺手的写法随机 0 / 300(结构性),允许 m = 0 当场 51 / 300 ⇒ 是生成器缺一档
★ 同一把旋钮 只有外层正序是单调的(44 → 286),贪心和合并费用都在第 8 档见顶后回落
⚠ 一致有两种 比值最小那一档,300 轮里 137 轮正解本身就是 0 —— 那些轮次验的是零