阶段 4 · 贪心 · 第 20 章普及组 J

贪心的正确性:交换论证 + 用对拍打假错误贪心

这一章的主角是三个「看起来非常对」的贪心。每一个都配一台对拍器,让你亲眼看着自己的直觉被数据打脸。

需要先学:第 19 章 贪心基础:排序型贪心例题:找零钱 · 01 背包 · 区间调度建议用时:110 分钟
这一章反着来

前面十九章,每一章都在教你「怎么把一个算法写对」。

这一章教你怎么确认自己写错了。

主角是三个错误的贪心 —— 而且是那种你看了会点头说「这不是显然的吗」的错误贪心。 每一个都配一台对拍器:不用你动脑,点一下开始,几秒钟之内它就会当着你的面崩掉。

为什么值得花一整章干这件事?因为贪心是唯一一类 「代码没写错、样例全过、编译零警告,但整个想法是错的」的算法。 这种错误在考场上不会有任何提示 —— 除非你自己先动手打假它。

1案例一:找零钱

m 种面额的硬币(每种无限多,且含面额 1),要凑出金额 x,最少要几枚?

几乎所有人的第一反应都是:从大到小,能拿就拿。 而且这个直觉有极强的现实依据 —— 你每天用的人民币面额 1 2 5 10 20 50 100,这么找零一定是最省的。

coinGreedy.cpp看起来很对的贪心
// 找零钱 —— 贪心:从大到小,能拿就拿
//
// 题意:有 m 种面额的硬币(每种无限多,且一定包含面额 1),要凑出金额 x。
// 最少要几枚硬币?
//
// 这是全世界最容易骗到人的贪心,因为**在人民币的面额下它是对的**:
// 1 2 5 10 20 50 100,随便凑一个数,从大到小拿一定最省。
// 于是几乎所有人都会默认「找零钱当然贪心」。
//
// 但这个结论**属于那套面额,不属于这个算法**。换一套面额它立刻就错:
// 面额 {1, 3, 4},要凑 6:
// 贪心 → 4 + 1 + 1 = 3 枚
// 最优 → 3 + 3 = 2 枚
// 就这么一个小到可以口算的例子,贪心就输了。
//
// 输入:第一行 m x,第二行 m 个面额
// 输出:贪心用的硬币数
//
// 用它和 coinDp.cpp 对拍,用 coinGen.cpp 造面额 —— 你会发现随机造一套面额,
// 它错的概率高得吓人。
#include <bits/stdc++.h>
using namespace std;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int m;
long long x;
if (!(cin >> m >> x)) return 0;
vector<long long> c(m);
for (int i = 0; i < m; i++) cin >> c[i];
sort(c.begin(), c.end(), greater<long long>()); // 从大到小
long long cnt = 0, left = x;
for (int i = 0; i < m; i++) {
cnt += left / c[i]; // 这种面额能拿几枚就拿几枚
left %= c[i];
}
// 面额里有 1,所以一定凑得出来
cout << cnt << "\n";
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
✗ 换一套面额,它立刻就错

面额 {1, 3, 4},要凑 6:

方案 枚数
贪心 4 + 1 + 1 3 枚
最优 3 + 3 2 枚

一个小到可以口算的例子,贪心就输了。

注意贪心并没有「走错某一步」:它拿 4 的时候,4 确实是当时能拿的最大面额。 错的是「每步拿最大」这个想法本身。

2标准答案要用完全不同的思路写

要打假它,先得有个绝对可靠的东西。这里用 DP(就是第 24 章的完全背包,提前见个面):

coinDp.cpp标准答案(DP)
// 找零钱 —— 标准答案:DP(完全背包的最简形态)
//
// 为什么标准答案要用 DP 写,而不是「换一种贪心」:
// 对拍的两份程序**思路必须不同**。同一个想法写两遍,只能验出打字错误,验不出想法错误。
// 而这一章要抓的恰恰是想法错误。
//
// 状态:dp[i] = 凑出金额 i 最少要几枚硬币
// 转移:dp[i] = min(dp[i - c] + 1),c 取遍所有面额
// 边界:dp[0] = 0(凑 0 块钱要 0 枚)
//
// 它为什么一定对:它**枚举了最后一枚硬币是哪种面额**,四种可能一种都没漏。
// 贪心则是「直接断定最后一枚(其实是第一枚)应该拿最大的」—— 断定得对不对,要证明。
// 这就是 DP 和贪心的全部区别:**DP 全试,贪心直接选。**
//
// 复杂度 O(xm)。x 到 10⁶ 都不成问题,比贪心慢,但慢得起。
// (这个 DP 就是第 24 章的完全背包,到那一章你会回来看这段代码。)
//
// 输入输出格式和 coinGreedy.cpp 完全一样。
#include <bits/stdc++.h>
using namespace std;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int m;
long long x;
if (!(cin >> m >> x)) return 0;
vector<long long> c(m);
for (int i = 0; i < m; i++) cin >> c[i];
const long long INF = LLONG_MAX / 4;
vector<long long> dp(x + 1, INF);
dp[0] = 0;
for (long long i = 1; i <= x; i++)
for (int j = 0; j < m; j++)
if (c[j] <= i && dp[i - c[j]] + 1 < dp[i])
dp[i] = dp[i - c[j]] + 1;
cout << (dp[x] >= INF ? -1 : dp[x]) << "\n"; // 面额里有 1 的话不会出现 -1
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
★ 为什么标准答案不能也写成贪心

dp[i] = min(dp[i - c] + 1):它枚举了最后一枚硬币是哪种面额,一种都没漏。 而贪心是直接断定「应该拿最大的」。

DP 全试,贪心直接选 —— 这就是两者的全部区别,也是为什么 DP 不需要证明而贪心需要。

对拍的铁律(第 9、15 章都强调过):两份程序的思路必须不同。 同一个想法写两遍,只能验出打字错误;这一章要抓的恰恰是想法错误。

3★ 对拍:让它当着你的面崩掉

★ 这台对拍器和前面几章的用法不一样

前面几章,「正解」那一栏放的是正确的代码,让你换成自己写的。

这一章不一样:那一栏里预置的就是错误的贪心。 直接点「开始」, 看它撑不撑得过三轮。然后你再把它换成你以为对的版本,再跑一次。

对拍器
生成器随机造面额,而不是随机造金额 —— 这是关键。面额写死成人民币的话,贪心永远对,对拍跑一万轮也抓不到东西。
// 找零钱 —— 贪心:从大到小,能拿就拿
//
// 题意:有 m 种面额的硬币(每种无限多,且一定包含面额 1),要凑出金额 x。
// 最少要几枚硬币?
//
// 这是全世界最容易骗到人的贪心,因为**在人民币的面额下它是对的**:
// 1 2 5 10 20 50 100,随便凑一个数,从大到小拿一定最省。
// 于是几乎所有人都会默认「找零钱当然贪心」。
//
// 但这个结论**属于那套面额,不属于这个算法**。换一套面额它立刻就错:
// 面额 {1, 3, 4},要凑 6:
// 贪心 → 4 + 1 + 1 = 3 枚
// 最优 → 3 + 3 = 2 枚
// 就这么一个小到可以口算的例子,贪心就输了。
//
// 输入:第一行 m x,第二行 m 个面额
// 输出:贪心用的硬币数
//
// 用它和 coinDp.cpp 对拍,用 coinGen.cpp 造面额 —— 你会发现随机造一套面额,
// 它错的概率高得吓人。
#include <bits/stdc++.h>
using namespace std;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int m;
long long x;
if (!(cin >> m >> x)) return 0;
vector<long long> c(m);
for (int i = 0; i < m; i++) cin >> c[i];
sort(c.begin(), c.end(), greater<long long>()); // 从大到小
long long cnt = 0, left = x;
for (int i = 0; i < m; i++) {
cnt += left / c[i]; // 这种面额能拿几枚就拿几枚
left %= c[i];
}
// 面额里有 1,所以一定凑得出来
cout << cnt << "\n";
return 0;
}
点一下即可编辑

本机用种子 1~300 跑出来的结果:

错误的贪心 300 轮里错了几轮 第几轮第一次被抓
找零钱:从大到小拿 34 轮 第 4 轮
01 背包:按性价比拿(第 6 步) 151 轮 第 3 轮
区间调度:按左端点排(第 10 步) 37 轮 第 2 轮
★ 这张表最该盯的是「错了几轮」那一列

找零钱那个贪心,300 轮里有 266 轮是对的 —— 将近九成。

这正是它可怕的地方:

  • 你手算几个例子 → 全对;
  • 你过了样例 → 全对;
  • 你随便试三五组数据 → 大概率全对;
  • 你交上去 → WA 三个点。

「试了几组都对」不是证据,一次都不是。 只有两种东西算证据: 一个能走通的证明(第 19 章的交换论证),或者一台跑了几百上千轮的对拍器。

4动画:贪心到底在第几枚上走岔

找零钱:贪心在第几枚上走岔
贪心 3 枚 / 最优 2 枚
第 1 / 5 步
贪心拿的
(还没开始)
最优方案
3
3
还差
6
贪心用了
0 枚
最优只要
2 枚
这套面额的最小反例
x = 6
两排硬币都是从大到小摆的。红色的那一枚就是两边第一次不一样的位置 —— 贪心从那里开始走岔,后面全部白搭。 把面额换成 1 2 5 10 试试:怎么凑都岔不了,因为人民币的面额恰好让贪心永远正确。
要凑出 6。贪心的规则是「从大到小,能拿就拿」,下面一枚一枚地拿。

上排是贪心一枚一枚拿的过程,下排是 DP 算出来的最优方案。 红色那一枚就是两边第一次不一样的地方 —— 贪心从那里开始走岔,后面全是徒劳。

建议这样玩:

  1. 默认 {1,3,4} 凑 6,看它在第一枚上就岔了;
  2. 把面额改成 1 2 5 10(人民币),把 x 改成任意数 —— 怎么试都岔不了;
  3. 再改成 1 5 8,点「跳到最小反例」,看它跳到 10(8+1+1 输给 5+5)。

5★ 对拍没抓到,能说明贪心是对的吗

★ 关键的一步

一般情况下:不能。

对拍是证伪工具,不是证明工具。它跑 1000 轮没出事,只说明 「在你造得出来的这类数据上没出事」。反例可能恰好在你的生成器造不出的形状里 —— 比如你的 n 只到 10,而反例最小需要 11 个物品。

能证明贪心正确的只有证明本身(第 19 章的交换论证)。 对拍的作用是:在你花两小时证明之前,先花三分钟确认它值不值得证。

不过找零钱这道题有个漂亮的例外,值得单独讲:

coinFind.cpp找最小反例
换几套面额试试:1 5 8 的最小反例是 10;1 2 5 10 根本没有反例。
// 找零钱 —— 给一套面额,把「最小的反例」找出来
//
// 为什么要有这份代码:
// 对拍能告诉你「你的贪心错了」,但它给你的那组数据往往又大又乱。
// 这份代码换个问法:**给定面额,最小的反例是多少?** 答案通常小得让人难堪 ——
// {1,3,4} 的最小反例是 6,{1,5,8} 的最小反例是 10。全都是能口算的数。
//
// ★ 顺带回答一个这一章绕不开的问题:**对拍没找到反例,能说明贪心是对的吗?**
// 一般情况下:**不能**。对拍只能证伪,不能证明(这一点正文里反复强调)。
//
// 但找零钱这道题有个漂亮的例外 —— Kozen–Zaks 定理(1994):
// 如果一套面额 1 = c₁ < c₂ < … < cₘ 存在反例,
// 那么**最小的那个反例一定小于「最大的两个面额之和」cₘ₋₁ + cₘ**。
// 也就是说,只要在这个范围内扫一遍没找到反例,就可以断言这套面额永远安全 ——
// **有限的检查,换来了对无限多个 x 的保证。** 这才叫证明。
//
// 这份代码会把定理也一起验给你看:先在上界内找最小反例,
// 然后**故意再往外多扫 500**,确认上界外不会冒出「更早没发现」的意外。
//
// 输入:第一行 m,第二行 m 个面额(要包含 1)
// 输出:最小反例(如果有)、方案、以及定理的验证
#include <bits/stdc++.h>
using namespace std;
static long long greedyCount(const vector<long long>& c, long long x, vector<long long>* plan) {
long long cnt = 0, left = x;
for (int i = (int)c.size() - 1; i >= 0; i--) { // c 是升序,从大到小拿
long long k = left / c[i];
cnt += k;
left %= c[i];
if (plan) for (long long t = 0; t < k; t++) plan->push_back(c[i]);
}
return cnt;
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int m;
if (!(cin >> m)) return 0;
vector<long long> c(m);
for (int i = 0; i < m; i++) cin >> c[i];
sort(c.begin(), c.end());
c.erase(unique(c.begin(), c.end()), c.end());
m = (int)c.size();
if (c[0] != 1) { cout << "面额里没有 1,可能有些金额根本凑不出来,先加上 1 再来。\n"; return 0; }
long long bound = (m >= 2) ? c[m - 2] + c[m - 1] : c[m - 1] + 1; // Kozen–Zaks 上界
long long scan = bound + 500; // 故意多扫一截
// DP 求最少枚数,顺便记下「最后一枚拿的是哪种面额」,好把方案还原出来
vector<long long> dp(scan + 1, LLONG_MAX / 4), from(scan + 1, -1);
dp[0] = 0;
for (long long i = 1; i <= scan; i++)
for (int j = 0; j < m; j++)
if (c[j] <= i && dp[i - c[j]] + 1 < dp[i]) { dp[i] = dp[i - c[j]] + 1; from[i] = c[j]; }
cout << "面额:";
for (int i = 0; i < m; i++) cout << c[i] << " \n"[i == m - 1];
long long first = -1, firstOutside = -1;
for (long long x = 1; x <= scan; x++) {
if (greedyCount(c, x, nullptr) > dp[x]) {
if (first < 0) first = x;
if (x >= bound && firstOutside < 0) firstOutside = x;
}
}
if (first < 0) {
cout << "扫到 " << scan << " 都没有反例 —— 这套面额下贪心永远是对的。\n";
cout << "而且根据 Kozen–Zaks 定理,只要扫到 " << bound
<< "(最大的两个面额之和)没出事就够了,后面那 500 个是白扫的。\n";
return 0;
}
vector<long long> gp, bp;
greedyCount(c, first, &gp);
for (long long v = first; v > 0; v -= from[v]) bp.push_back(from[v]);
auto show = [](const vector<long long>& p) {
string s;
for (size_t i = 0; i < p.size(); i++) { if (i) s += " + "; s += to_string(p[i]); }
return s;
};
cout << "最小反例:x = " << first << "\n";
cout << " 贪心 " << gp.size() << " 枚:" << show(gp) << "\n";
cout << " 最优 " << bp.size() << " 枚:" << show(bp) << "\n";
cout << "Kozen–Zaks 上界 = 最大两个面额之和 = " << bound
<< ",定理说最小反例必定小于它:" << first << " < " << bound
<< (first < bound ? " ✓ 成立" : " ✗ 居然不成立?") << "\n";
cout << "又往外多扫了 500,"
<< (firstOutside < 0 ? "上界之外没有出现任何新反例。\n"
: "上界之外也有反例,但那不重要 —— 定理保证的是「最小的那个」在上界内。\n");
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
★ Kozen–Zaks 定理(1994):一个有限的检查,换来无限的保证

对一套面额 1 = c₁ < c₂ < … < cₘ:

如果它存在反例,那么最小的那个反例一定小于「最大的两个面额之和」cₘ₋₁ + cₘ。

于是只要在这个范围里扫一遍没找到反例,就可以断言:这套面额下贪心永远正确 —— 对所有的 x,无穷多个。

这就是「证明」和「对拍」的区别: 对拍说「我试过的都没事」,定理说「不用试了,永远没事」。

coinFind.cpp 会把这件事一起验给你看:它先在上界内找最小反例, 再故意往外多扫 500 —— 你会发现外面确实不会冒出更小的意外。 (npm run check:viz 也在替你反复验这条定理,六套面额,每次跑都验一遍。)

6案例二:01 背包按性价比 —— 最著名的那个上当

n 件物品,第 i 件价值 v[i]、重量 w[i],每件只有一个、不能切开。 背包能装 W 的重量,求最大总价值。

「先拿性价比(价值÷重量)最高的」—— 这个直觉比找零钱那个还要强烈, 因为它在部分背包里是有严格证明的(第 9 步会讲)。

knapGreedy.cpp按性价比贪心(错的)
// 01 背包 —— 按性价比贪心(**这是错的**,这一章就是要打假它)
//
// 「先拿性价比最高的」听起来天经地义:每一公斤都要花得最值。
// 而且它在**部分背包**(物品可以切开卖,见 fracKnap.cpp)里是**完全正确**的,
// 有严格的交换论证。很多人就是这么被带进沟里的。
//
// 但 01 背包不能切。于是会发生这种事:
//
// W = 50,三件物品
// A: 价值 60,重 10 → 性价比 6.0
// B: 价值 100,重 20 → 性价比 5.0
// C: 价值 120,重 30 → 性价比 4.0
//
// 贪心:拿 A(10)、拿 B(20),剩 20 的空间装不下 C → 160
// 最优:B + C = 220
//
// 差了 60。而且注意:**贪心一步都没走错**,每一步都拿了当时性价比最高的、装得下的。
// 错的是「每一步局部最优 ⇒ 整体最优」这个假设本身。
//
// ★ 交换论证为什么在这里失效:
// 部分背包里,把一件低性价比的货换成高性价比的,「同样的重量换来更多价值」,一定不亏。
// 01 背包里你**换不了那个重量** —— 高性价比那件只有一件,且重量固定,
// 换进来会留下一个填不满的空隙,而空隙是白白浪费的。
// **论证卡在哪里,反例就在哪里。**
//
// 输入输出格式和 knapBrute.cpp 一样。
// 拿它和 knapBrute.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), w(n);
for (int i = 0; i < n; i++) cin >> v[i] >> w[i];
vector<int> id(n);
for (int i = 0; i < n; i++) id[i] = i;
// 按 v/w 从大到小。用交叉相乘比较,别用浮点除法 —— 浮点比较是另一个坑,
// v1/w1 > v2/w2 等价于 v1*w2 > v2*w1(w 都是正数)。
sort(id.begin(), id.end(), [&](int a, int b) {
long long lhs = v[a] * w[b], rhs = v[b] * w[a];
return lhs != rhs ? lhs > rhs : w[a] < w[b];
});
long long left = W, got = 0;
for (int i : id)
if (w[i] <= left) { left -= w[i]; got += v[i]; }
cout << got << "\n";
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
✗ 教科书级的反例(就是上面那组数据)

W = 50:

物品 价值 重量 性价比
A 60 10 6.0
B 100 20 5.0
C 120 30 4.0
  • 贪心:拿 A(占 10)、拿 B(占 20),剩下 20 装不下 C → 160
  • 最优:B + C = 220

差了 60。而贪心每一步都拿了当时性价比最高、又装得下的那件 —— 一步都没走错。

7换个排法行不行?三种一起试

很多人的下一反应是「那我换个关键字排」。这份代码一次性回答:

knapTable.cpp三种贪心 + 最优 + 部分背包
第 ⑤ 行是同一个「按性价比」的贪心,但允许把物品切开 —— 注意它比 ④ 还高。
// 01 背包 —— 三种贪心 + 最优解 + 部分背包,五行并排
//
// 为什么要有这份代码:
// 「按性价比排」错了之后,很多人的第一反应是「那我换个排法」——
// 换成按价值大的先拿?换成按重量小的先拿?这份代码一次性回答:**三种全错。**
//
// 而第五行是这一章的题眼:**同一份数据、同一个「按性价比」的贪心,
// 只要允许把物品切开,它立刻变成最优的。**
//
// 也就是说:
// 贪心的正确性不是算法的属性,是**问题的属性**。
// 「这个贪心对不对」这句话本身就问错了 —— 得问「这个贪心在这个问题上对不对」。
//
// 输入输出格式和 knapBrute.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), w(n);
for (int i = 0; i < n; i++) cin >> v[i] >> w[i];
vector<int> id(n);
for (int i = 0; i < n; i++) id[i] = i;
// 三种排法,装法完全一样:顺着看,装得下就装。差别百分之百来自排序。
auto pack = [&](vector<int> order) {
long long left = W, got = 0;
for (int i : order) if (w[i] <= left) { left -= w[i]; got += v[i]; }
return got;
};
vector<int> byV = id, byW = id, byR = id;
sort(byV.begin(), byV.end(), [&](int a, int b) { return v[a] != v[b] ? v[a] > v[b] : w[a] < w[b]; });
sort(byW.begin(), byW.end(), [&](int a, int b) { return w[a] != w[b] ? w[a] < w[b] : v[a] > v[b]; });
sort(byR.begin(), byR.end(), [&](int a, int b) {
long long lhs = v[a] * w[b], rhs = v[b] * w[a];
return lhs != rhs ? lhs > rhs : w[a] < w[b];
});
long long best = 0;
for (int mask = 0; mask < (1 << n); mask++) {
long long sw = 0, sv = 0;
for (int i = 0; i < n; i++) if (mask >> i & 1) { sw += w[i]; sv += v[i]; }
if (sw <= W) best = max(best, sv);
}
// 第五行:同一个「按性价比」的贪心,但允许切开
long long left = W, whole = 0;
double extra = 0;
for (int i : byR) {
if (left <= 0) break;
if (w[i] <= left) { left -= w[i]; whole += v[i]; }
else { extra = (double)v[i] * (double)left / (double)w[i]; left = 0; }
}
long long r1 = pack(byV), r2 = pack(byW), r3 = pack(byR);
auto tag = [&](long long r) { return r == best ? "" : " ← 比最优少"; };
cout << "策略 总价值\n";
cout << "① 先拿价值最大的 " << r1 << tag(r1) << "\n";
cout << "② 先拿重量最小的 " << r2 << tag(r2) << "\n";
cout << "③ 先拿性价比最高的 " << r3 << tag(r3) << "\n";
cout << "④ 枚举全部子集(一定最优) " << best << "\n";
cout << "⑤ 同一个③,但允许切开(部分背包) " << fixed << setprecision(2)
<< (double)whole + extra << "\n";
if (r1 == best && r2 == best && r3 == best)
cout << "\n这组数据太温柔了,三种贪心都蒙对了 —— 换一组再试。\n";
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

上面那组数据跑出来:

策略 总价值
① 先拿价值最大的 220
② 先拿重量最小的 160
③ 先拿性价比最高的 160
④ 枚举全部子集(一定最优) 220
⑤ 同一个③,但允许切开 240.00
⚠ ① 这次蒙对了 —— 别被它骗了

「先拿价值最大的」在这组数据上给出了 220,正好等于最优。

这恰恰是本章第 3 步那个结论的又一次演示:错误的贪心经常蒙对。 把这组数据丢进下面的对拍器,换成 ① 试试,它照样会在几轮内崩掉。

8动画:一步都没走错,结果还是错的

01 背包:每一步都拿性价比最高的,结果还是错的
贪心 160 / 最优 220
第 1 / 5 步
性价比顺序
价值
重量
贪心
最优解
1. 性价比 6.00
60
10
—
·
2. 性价比 5.00
100
20
—
✓ 拿
3. 性价比 4.00
120
30
—
✓ 拿
贪心的总价值
0
剩余容量
50
2ⁿ 暴力的最优
220
同一个贪心,允许切开
240.00
红色那一行就是分岔点:贪心和最优解对同一件物品做了不同的决定。 最后一栏是同一个贪心在「可以切开」的部分背包上的结果 —— 它比 01 背包的最优解还高,因为切开之后背包能被填满,不留空隙。 空隙,就是这个贪心失败的全部原因。
背包能装 50。贪心的规则是「先拿性价比最高的」,所以先把物品按 价值÷重量 从大到小排好,然后一件一件看。

物品已经按性价比从高到低排好。左边是贪心的决定,右边是最优解的决定, 红色那一行就是两边第一次做出不同决定的物品。

右下角三个数字并排放着,是这一章的题眼:160(01 背包贪心)、220(01 背包最优)、240(可切开时的贪心)。

9★ 关键一步:同一个贪心,为什么在部分背包里就对了

fracKnap.cpp部分背包(这里贪心是对的)
// 部分背包 —— 同一个贪心,在这里是**对的**
//
// 和 knapGreedy.cpp 唯一的区别:物品可以**切开**(金粉、大米这类),
// 拿走一半的重量就得到一半的价值。
//
// 于是那个「先拿性价比最高的」贪心立刻变成正确的,而且能证明:
//
// ★ 交换论证:设最优解里装了一部分低性价比的货 y,同时还有高性价比的货 x 没装满。
// 把 y 拿出来一点点(重量 δ),换成同样重量的 x。
// 总重量没变,而价值变化 = δ·(x的性价比 − y的性价比) > 0 —— 严格变好。
// 所以最优解里不可能出现「x 没装满却装了 y」,
// 也就是说最优解一定是「性价比从高到低,能装多少装多少,最后一件切开」。∎
//
// ⚠ 这段论证里**唯一**用到「可以切开」的地方,就是「拿出来一点点、换进去一点点」。
// 01 背包不能切,这一步立刻做不下去 —— 论证在哪里断掉,反例就在哪里。
// **这就是这一章最想让你记住的读法:不要背结论,要盯着论证在哪一步用到了题目的条件。**
//
// 输入输出格式和 knapBrute.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), w(n);
for (int i = 0; i < n; i++) cin >> v[i] >> w[i];
vector<int> id(n);
for (int i = 0; i < n; i++) id[i] = i;
sort(id.begin(), id.end(), [&](int a, int b) {
long long lhs = v[a] * w[b], rhs = v[b] * w[a];
return lhs != rhs ? lhs > rhs : w[a] < w[b];
});
long long left = W, whole = 0; // 整件装进去的部分,全程整数
double extra = 0; // 最后切开的那一件,只有这里是浮点
for (int i : id) {
if (left <= 0) break;
if (w[i] <= left) { left -= w[i]; whole += v[i]; }
else { extra = (double)v[i] * (double)left / (double)w[i]; left = 0; }
}
cout << fixed << setprecision(2) << (double)whole + extra << "\n";
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
★ 交换论证,以及它在哪一步用到了「可以切开」

部分背包的证明: 设最优解里装了一部分低性价比的货 y,同时还有高性价比的货 x 没装满。 把 y 拿出来一点点(重量 δ),换进同样重量的 x:

总重量没变,价值变化 = δ · (x的性价比 − y的性价比) > 0

严格变好。所以最优解里不可能出现这种情况 —— 最优解一定是「性价比从高到低装,最后一件切开」,也就是贪心。∎

现在把这段论证搬到 01 背包: 「拿出来一点点、换进去一点点」这一步做不了 —— 物品不能切。 你只能整件换,而整件换会留下一个填不满的空隙,空隙是白白浪费的。

论证在哪一步断掉,反例就长在哪里:教科书那个反例里, 贪心拿完 A、B 之后剩下的那 20 的空隙,就是它输掉的 60。

★ 于是得到这一章最重要的一句话

贪心的正确性属于问题,不属于算法。

「按性价比排序的贪心对不对」这个问句本身就是错的 —— 必须问 「按性价比排序的贪心,在这个问题上对不对」。

同一份代码,物品能切就对,不能切就错。

所以永远不要凭「我以前做过一道类似的题,那题就是这么贪的」来判断。 类似的题,条件差一个字,答案就换一边。

对拍器
生成器的重点是「容量小、重量大」,让空隙变得致命。如果重量都远小于容量,随便装都塞得下,贪心和最优会一模一样 —— 那样的生成器抓不到任何东西。
// 01 背包 —— 按性价比贪心(**这是错的**,这一章就是要打假它)
//
// 「先拿性价比最高的」听起来天经地义:每一公斤都要花得最值。
// 而且它在**部分背包**(物品可以切开卖,见 fracKnap.cpp)里是**完全正确**的,
// 有严格的交换论证。很多人就是这么被带进沟里的。
//
// 但 01 背包不能切。于是会发生这种事:
//
// W = 50,三件物品
// A: 价值 60,重 10 → 性价比 6.0
// B: 价值 100,重 20 → 性价比 5.0
// C: 价值 120,重 30 → 性价比 4.0
//
// 贪心:拿 A(10)、拿 B(20),剩 20 的空间装不下 C → 160
// 最优:B + C = 220
//
// 差了 60。而且注意:**贪心一步都没走错**,每一步都拿了当时性价比最高的、装得下的。
// 错的是「每一步局部最优 ⇒ 整体最优」这个假设本身。
//
// ★ 交换论证为什么在这里失效:
// 部分背包里,把一件低性价比的货换成高性价比的,「同样的重量换来更多价值」,一定不亏。
// 01 背包里你**换不了那个重量** —— 高性价比那件只有一件,且重量固定,
// 换进来会留下一个填不满的空隙,而空隙是白白浪费的。
// **论证卡在哪里,反例就在哪里。**
//
// 输入输出格式和 knapBrute.cpp 一样。
// 拿它和 knapBrute.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), w(n);
for (int i = 0; i < n; i++) cin >> v[i] >> w[i];
vector<int> id(n);
for (int i = 0; i < n; i++) id[i] = i;
// 按 v/w 从大到小。用交叉相乘比较,别用浮点除法 —— 浮点比较是另一个坑,
// v1/w1 > v2/w2 等价于 v1*w2 > v2*w1(w 都是正数)。
sort(id.begin(), id.end(), [&](int a, int b) {
long long lhs = v[a] * w[b], rhs = v[b] * w[a];
return lhs != rhs ? lhs > rhs : w[a] < w[b];
});
long long left = W, got = 0;
for (int i : id)
if (w[i] <= left) { left -= w[i]; got += v[i]; }
cout << got << "\n";
return 0;
}
点一下即可编辑

10案例三:回到第 19 章那道区间调度

第 19 章已经证明过「按右端点排」是对的。这里把错误版拿出来,让你亲手打假它:

itvLeft.cpp按左端点排(错的)
它输出 1,而最优是 2([2,3] 和 [4,5])。
// 区间调度 —— 按左端点从早到晚(**这是错的**)
//
// 这份代码是第 19 章那道题的「错误版」,放在这一章是为了让你亲手对拍它。
// 除了排序的那一行,它和 code/19-greedy-sorting/itvFast.cpp **一模一样**。
//
// 「早点开始,就能多参加几场」—— 听起来毫无破绽,但它把「开始得早」
// 和「结束得早」这两件事混为一谈了。一场从早开到晚的比赛开始得最早,
// 却把整天都占掉了。
//
// 最小的反例只有三场比赛:
// [1,10]、[2,3]、[4,5] → 它选 1 场,最优 2 场。
//
// 用它和第 19 章的 itvBrute.cpp 对拍(生成器也用那一章的 itvGen.cpp),
// 通常两三轮之内就会被打假。
//
// 冲突的约定和第 19 章一致:**端点重合不算冲突**([1,3] 和 [3,5] 可以都参加)。
#include <bits/stdc++.h>
using namespace std;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
if (!(cin >> n)) return 0;
vector<pair<long long, long long>> a(n); // (l, r)
for (int i = 0; i < n; i++) cin >> a[i].first >> a[i].second;
sort(a.begin(), a.end()); // ← 错就错在这一行:按左端点排
int cnt = 0;
long long lastEnd = LLONG_MIN;
for (int i = 0; i < n; i++) {
if (a[i].first >= lastEnd) { cnt++; lastEnd = a[i].second; }
}
cout << cnt << "\n";
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
对拍器
标准答案和生成器都直接用第 19 章那两份,一个字没改 —— 对拍器是可以跨章节复用的。
// 区间调度 —— 按左端点从早到晚(**这是错的**)
//
// 这份代码是第 19 章那道题的「错误版」,放在这一章是为了让你亲手对拍它。
// 除了排序的那一行,它和 code/19-greedy-sorting/itvFast.cpp **一模一样**。
//
// 「早点开始,就能多参加几场」—— 听起来毫无破绽,但它把「开始得早」
// 和「结束得早」这两件事混为一谈了。一场从早开到晚的比赛开始得最早,
// 却把整天都占掉了。
//
// 最小的反例只有三场比赛:
// [1,10]、[2,3]、[4,5] → 它选 1 场,最优 2 场。
//
// 用它和第 19 章的 itvBrute.cpp 对拍(生成器也用那一章的 itvGen.cpp),
// 通常两三轮之内就会被打假。
//
// 冲突的约定和第 19 章一致:**端点重合不算冲突**([1,3] 和 [3,5] 可以都参加)。
#include <bits/stdc++.h>
using namespace std;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
if (!(cin >> n)) return 0;
vector<pair<long long, long long>> a(n); // (l, r)
for (int i = 0; i < n; i++) cin >> a[i].first >> a[i].second;
sort(a.begin(), a.end()); // ← 错就错在这一行:按左端点排
int cnt = 0;
long long lastEnd = LLONG_MIN;
for (int i = 0; i < n; i++) {
if (a[i].first >= lastEnd) { cnt++; lastEnd = a[i].second; }
}
cout << cnt << "\n";
return 0;
}
点一下即可编辑

11★ 能打假贪心的生成器,是怎么设计出来的

★ 三条规矩(这一章三个生成器都遵守)

【1】随机的必须是「贪心依赖的那个东西」,不是规模。

  • 找零钱:贪心依赖面额,所以要随机面额 —— 随机金额没用。
  • 01 背包:贪心依赖性价比的排序,所以要让重量和容量同一量级,让空隙致命。
  • 区间调度:贪心依赖端点的相对位置,所以坐标范围要小,让区间大量重叠。

【2】范围要小,不是大。

反例几乎总是小的。n = 3、坐标 1~14、面额 ≤ 25 —— 这一章三个反例分别只有 3 件物品、3 场比赛、3 种面额。把 n 开到 1000 只会让暴力跑不完, 而跑不完的对拍等于没有对拍。

【3】故意混入「贪心是对的」那类数据。

coinGen.cpp 里有三分之一的数据是人民币面额 —— 贪心在那上面永远正确。 留着它是有意的:让你亲眼看见「有时候对」和「总是对」完全是两回事。

12⚠ 对拍的三个盲区(必须知道)

盲区一:两份程序错得一模一样

如果标准答案和「正解」是同一个人、用同一个思路写的,那么想错了的地方会一起错, 对拍全绿,但两份都是错的。

破法:标准答案一定要用完全不同的思路(这一章用 DP 和 2ⁿ 枚举,都不是贪心)。

盲区二:小数据查不出溢出

对拍用的是 n ≤ 12 的小数据,int 和 long long 在那里的表现完全一样。 第 19 章那个「总等待时间要用 long long」的坑,对拍永远不会告诉你。

破法:只能靠脑子。凡是「一堆数加起来 / 乘起来」的答案,先写 long long。

盲区三:浮点数不能用 == 比

这一章的部分背包答案是小数。两份程序的计算顺序稍有不同, 末位就可能差一点点,直接 == 会报出一堆假的「不一致」。

而且这个坑比想象的深:C++ 的 setprecision(2) 用的是四舍六入五成双, 77.625 输出成 77.62;而 JavaScript 的 toFixed(2) 是逢五就进,输出 77.63。 同一个数,两种语言的「保留两位」结果不一样。 (这不是编的 —— 这一章的动画交叉验证就是被这个绊住的,脚本里现在写着一行注释记着它。)

破法:比较浮点要用「差值不超过某个容差」,比如 fabs(a - b) <= 1e-6,或者按输出精度的一半来卡。 更省事的办法是让题目里的答案变成整数(比如输出「答案 × 100 的整数部分」)。

13★ 拿到一道疑似贪心的题,按这个清单走

★ 关键的一步
  1. 先写暴力。 2ⁿ、n!、DP,怎么慢怎么来 —— 它是你唯一的尺子。 没有尺子,后面每一步都是猜。

  2. 猜一个贪心策略,试着做交换论证。 假设最优解和贪心在某处不同 → 把它换成贪心的选择 → 证明不会变差。

    • 论证走通了 → 你知道它对,而且知道为什么;
    • 论证卡住了 → 卡住的那一步就是反例的形状。 (部分背包 → 01 背包,卡在「不能切开」,反例就是「填不满的空隙」。)
  3. 不管论证通没通,都去对拍。 300 轮起步,生成器按第 11 步那三条设计。

  4. 对拍绿了,也要回头看第 12 步那三个盲区。

  5. 实在证不出来又不敢赌,就上 DP。(阶段 5 马上就到。) 慢一点的正确算法,永远好过快一点的错误算法 —— 考场上前者拿 70 分,后者拿 0 分。

✓ 阶段 4 收尾:这两章到底教了什么

第 19 章教的是怎么把贪心写对:排序型贪心的形状,以及交换论证怎么做。

第 20 章教的是怎么确认自己没写错:错误的贪心长什么样、 生成器怎么设计才打得中要害、以及对拍治不了的三种病。

合起来是一句话: 贪心不是「感觉对就写」,是「说得出为什么对、并且用机器验过」才写。 说不出、也验不动的时候,老老实实上 DP —— 下一阶段就是它。

14自测

自测清单0 / 9
配套练习
  • 洛谷 P1080 国王游戏解析 → —— NOIP2012。交换论证的教科书题:按 a×b 排序。先自己推交换论证,再看题解。(要写高精度,可以先只做证明部分)
  • 洛谷 P1048 采药解析 → —— NOIP2005。就是本章的 01 背包 —— 故意先用性价比贪心交一发,看着它 WA,再学第 23 章的 DP。这一发 WA 值得挨
  • 洛谷 P2240 部分背包问题解析 → —— 同一个贪心,这里是对的。和上一题对照着做,本章第 9 步那段论证会刻进脑子里
  • 洛谷 P5019 铺设道路解析 → —— NOIP2018。贪心是对的,但你得说得出为什么。先写暴力对拍,再想证明
  • 洛谷 P1090 合并果子解析 → —— NOIP2004。「每次合并最小的两堆」是对的,但「一次排序后顺着合并」是错的 —— 又一个「差一点点就错」的例子。第 37 章会用堆重做它
这一章我卡在哪(过一个月回来看,这几行比整章正文都值钱)
下一阶段预告

阶段 5 · 动态规划,从第 21 章开始。

开场白就是这一章的结尾:当你证不出贪心、又不敢赌的时候,DP 是那个「一定对」的退路。 它的代价是慢一点、代码长一点,换来的是「所有可能都试过了」的踏实。

而且你已经见过它了 —— 第 17 章的记忆化搜索、本章的 coinDp.cpp, 都是 DP。第 21 章要做的只是把它讲明白。