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

贪心基础:排序型贪心

代码只有一句 sort,难的全在「凭什么这么排」。这一章教你怎么把「感觉对」变成「证明对」。

需要先学:第 10 章 排序:冒泡 → 归并 → 快排例题:排队接水 · 区间调度建议用时:100 分钟
新阶段:贪心

前面十八章里,你写的每一个算法都能「讲清楚为什么对」: 递归是分解,二分靠单调,DFS/BFS 是把所有情况都走了一遍。

贪心不一样。贪心的代码通常短得离谱 —— 这一章的两道题,正解都只有一行 sort 加一个循环。 但它是唯一一类「写出来只要三分钟,证明它对要一小时」的算法。

所以这一章的重点从头到尾只有一件事:怎么确认你的贪心不是在瞎猜。 方法有两个,都要学会: 交换论证(用脑子证)和对拍(用机器验)。缺一个都不够 —— 证明能给你信心,对拍能救你的命。

1一句话问题(一):排队接水

n 个人排队接水,只有一个水龙头,第 i 个人接水要 t[i] 分钟。 排在第 k 位的人,要干等前面 k-1 个人接完。

求一种排队顺序,使所有人等待时间之和最小。

输入

4
7 3 5 1

输出

14

第一行是人数,第二行是每个人打水要多少时间。

2先用手算一遍

拿 7 3 5 1 试两种顺序:

顺序 各自等待 总等待
7 3 5 1(原顺序) 0, 7, 10, 15 32
1 3 5 7(从小到大) 0, 1, 4, 9 14

差了一倍多。为什么差这么多?看这个式子:

总等待 = 0·t[排第1] + 1·t[排第2] + 2·t[排第3] + 3·t[排第4]

排在越前面的人,他的接水时间被越多人「重复承担」:第一个人的时间要被后面 3 个人一起等, 最后一个人的时间谁也不用等。

所以直觉很清楚了:让被乘上大系数的那个数尽量小 —— 快的先接。

但「直觉很清楚」不等于「它是对的」。下面先写一份绝对不会错的暴力,把这个直觉钉死。

3暴力:n! 种顺序全试一遍

brute.cpp全排列
// 排队接水 —— 全排列枚举:n! 种排队顺序,一种一种试过去
//
// 为什么要有这份代码:
// 这一章的正解只有一句 sort,短到让人不敢信 —— 「凭什么按接水时间从小到大排就是最优的?」
// 所以先把「所有顺序都试一遍」老老实实写出来。它慢,但它**绝对不会错**。
// 后面对拍的标准答案就是它:先确认正解是**对的**,再谈它快不快。
//
// 题意:n 个人排队接水,第 i 个人接水要 t[i] 分钟(一个水龙头,一次只能接一个人)。
// 排在第 k 位的人要等前面 k-1 个人全部接完 —— 他的等待时间就是前面那些 t 的和。
// 求「所有人等待时间之和」的最小值。
//
// 输入:第一行 n,第二行 n 个数 t[1..n]
// 输出:最小的总等待时间
//
// 复杂度 O(n! × n)。n = 10 已经要三千多万次加法,n = 12 就基本跑不完了 ——
// 这正是我们需要贪心的理由。
#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<long long> t(n);
for (int i = 0; i < n; i++) cin >> t[i];
// next_permutation 要求从最小的排列开始,才能枚举到全部 n! 种
sort(t.begin(), t.end());
long long best = LLONG_MAX;
do {
// 一个人一个人地过:wait 是「轮到他时已经过去了多久」,也就是他的等待时间
long long total = 0, wait = 0;
for (int i = 0; i < n; i++) {
total += wait;
wait += t[i];
}
best = min(best, total);
} while (next_permutation(t.begin(), t.end()));
cout << best << "\n";
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

用 next_permutation 枚举全部 n! 种排队顺序,每种算一遍总等待,取最小。 它慢得离谱,但它不需要任何聪明的想法 —— 这正是它作为标准答案的价值。

4实测:暴力慢在哪

同题对比:全排列暴力 vs 排序贪心
先跑 11,再改成 12 试试(本机要 4.3 秒)。13 就要一分多钟了 —— 别在网页里跑 13。
全排列暴力
排序贪心

本机实测:

n 全排列暴力 排序贪心
10 0.034 秒 0.005 秒
11 0.36 秒 0.005 秒
12 4.3 秒 0.005 秒
13 62.9 秒 0.005 秒
200000 等到宇宙凉了 0.023 秒
这张表要这么读

n 每加 1,暴力就慢 n 倍(0.034 → 0.36 是 10 倍,0.36 → 4.3 是 12 倍,4.3 → 62.9 是 14.6 倍)。 这就是 n! 的样子 —— 比第 3 章那个 2ⁿ 还要凶得多。

贪心那一栏的 0.005 秒其实是量不出来:本机空跑一个什么都不做的 C++ 程序 (启动进程、读几个数、退出)也要 4~5 毫秒。真正花在算法上的时间比这还小。 只有把 n 拉到 20 万,才勉强量出 0.023 秒。

5★ 关键一步:交换论证

★ 关键的一步

要证明「按 t 从小到大排是最优的」,不需要考虑全部 n! 种顺序,只需要盯住相邻的两个人。

设某个排法里,相邻的两位接水时间是 a 和 b,a 排在前,且 a > b(慢的排在快的前面)。 把这两个人交换一下,总等待时间会怎么变?

他们站在第 k、k+1 位。第 k 位的人,他的接水时间要被后面 n-k 个人等; 第 k+1 位的被 n-k-1 个人等。别的人完全不受影响(他们前面那堆人的总时间没变)。于是:

交换前 = (n-k)·a + (n-k-1)·b
交换后 = (n-k)·b + (n-k-1)·a
       差 = (b - a)·[(n-k) - (n-k-1)] = b - a

总等待时间正好减少 a - b 分钟 —— 而且和他们站在第几位完全无关。

于是:只要队伍里还存在「慢的排在快的前面」的相邻一对,这个排法就一定不是最优的 (因为交换一下就更好了)。反过来说,最优解里不可能有这样的一对。 没有任何一对相邻逆序 = 整个队伍是升序。

这就是交换论证(exchange argument),贪心正确性证明里最常用的一招。它的套路永远是三句话:

  1. 假设最优解和贪心解不一样;
  2. 找到第一个不一样的地方,把它换成贪心的选择;
  3. 说明换完之后答案不会变差 —— 于是贪心解也是最优的。

6把交换论证跑一遍给你看

光看推导容易「看过就忘」。下面这份代码从你给的任意顺序出发, 每次找最左边那对「慢的在前」交换掉,并打印总等待时间少了多少:

swap.cpp交换论证实验
每一行的「少了 X」和「a − b」必须分毫不差地相等。最后两个数字也要盯一眼。
// 排队接水 —— 把「交换论证」真的跑一遍给你看
//
// 为什么要有这份代码:
// 交换论证是一段推导,写在纸上很容易看过去就忘。这份代码把它变成可以观察的事实:
// 从你给的任意顺序出发,每次找到**相邻的一对「慢的在前、快的在后」**就交换它们,
// 并打印总等待时间少了多少。
//
// 你会看到两件事:
// 1. 每一次交换,总等待时间**正好**减少 a − b 分钟(a 是前面那个人,b 是后面那个)。
// 不是「大概」,是分毫不差 —— 而且和这对人站在第几位完全无关。
// 2. 交换到再也找不到这样的一对为止,顺序恰好变成了升序,总等待时间就是 fast.cpp 的答案。
//
// 于是「升序最优」就不再是一句口号:任何非升序的顺序,都能被一路交换着改进到升序。
//
// 顺带一个彩蛋:这个「反复交换相邻逆序对」的过程就是**冒泡排序**(第 10 章),
// 而交换的次数正好是**逆序对个数**(第 11 章)—— 因为交换一对相邻逆序,
// 逆序对个数不多不少刚好减 1。程序最后会把这两个数字并排打给你看。
//
// 输入:第一行 n,第二行 n 个数(**按你想要的排队顺序给**,这里不会先排序)
// 输出:每一次交换的明细 + 最后的核对
#include <bits/stdc++.h>
using namespace std;
// 给定排队顺序,算总等待时间:一个人一个人过(和 brute.cpp / fast.cpp 里的循环一样)
static long long waitSum(const vector<long long>& a) {
long long total = 0, wait = 0;
for (size_t i = 0; i < a.size(); i++) {
total += wait;
wait += a[i];
}
return total;
}
static string joinAll(const vector<long long>& a) {
string s;
for (size_t i = 0; i < a.size(); i++) {
if (i) s += ' ';
s += to_string(a[i]);
}
return s;
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
if (!(cin >> n)) return 0;
vector<long long> a(n);
for (int i = 0; i < n; i++) cin >> a[i];
// 先老实数一遍逆序对(O(n²),第 11 章的暴力版),留到最后核对
long long inv = 0;
for (int i = 0; i < n; i++)
for (int j = i + 1; j < n; j++)
if (a[i] > a[j]) inv++;
long long total = waitSum(a);
cout << "初始顺序 : " << joinAll(a) << "\n";
cout << "初始总等待 : " << total << "\n\n";
long long swaps = 0;
while (true) {
// 从左到右找第一对「慢的在前、快的在后」
int k = -1;
for (int i = 0; i + 1 < n; i++)
if (a[i] > a[i + 1]) { k = i; break; }
if (k < 0) break; // 找不到了 —— 已经是升序
long long x = a[k], y = a[k + 1];
swap(a[k], a[k + 1]);
long long now = waitSum(a); // 交换后重新老实算一遍,不用公式
swaps++;
cout << "#" << setw(2) << swaps
<< " 交换第 " << k + 1 << " 位和第 " << k + 2 << " 位(" << x << " 和 " << y << ")"
<< ":总等待 " << total << " → " << now
<< ",少了 " << total - now
<< " 而 a − b = " << x - y
<< (total - now == x - y ? " ✓ 正好相等" : " ✗ 对不上!")
<< "\n 此刻队伍:" << joinAll(a) << "\n";
total = now;
}
cout << "\n再也找不到「慢的排在快的前面」的相邻一对了,队伍已经是升序。\n";
cout << "共交换 " << swaps << " 次,最终总等待 " << total << "\n";
cout << "原序列的逆序对个数 = " << inv
<< (inv == swaps ? " —— 和交换次数一模一样(第 11 章)\n"
: " —— 居然和交换次数不一样,那说明这份代码写错了\n");
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

7 3 5 1 要交换 5 次才排好,总等待从 32 一路降到 14。

✓ 顺手收回两章的伏笔

这个「反复交换相邻逆序对」的过程,就是第 10 章的冒泡排序。

而交换的次数 —— 5 次 —— 正好是 7 3 5 1 的逆序对个数(第 11 章)。 不是巧合:交换一对相邻的逆序,逆序对总数不多不少刚好减少 1。

所以「把任意顺序改进到最优」需要的交换次数,就是逆序对个数。 swap.cpp 最后一行会把这两个数并排打出来给你核对。

7正解

fast.cpp一行 sort
// 排队接水 —— 正解:把 t 从小到大排序,就完了
//
// 和 brute.cpp 比一比:**算总等待时间的那个循环一模一样,一个字都没改**。
// 唯一的区别是这里只算了一种顺序(升序),而 brute.cpp 算了 n! 种。
//
// ★ 凭什么升序就是最优的?—— 交换论证(跑一下 swap.cpp 亲眼看):
//
// 设某个顺序里,相邻的两个人接水时间是 a、b,且 a > b(也就是「慢的排在快的前面」)。
// 把这两个人交换一下,总等待时间的变化是多少?
//
// 假设他们站在第 k、k+1 位。排在第 k 位的人,他的接水时间会被后面 (n-k) 个人等;
// 第 k+1 位的会被 (n-k-1) 个人等。于是
// 交换前 = (n-k)·a + (n-k-1)·b
// 交换后 = (n-k)·b + (n-k-1)·a
// 交换后 − 交换前 = (b − a)·[(n-k) − (n-k-1)] = b − a
//
// b − a < 0,所以**总等待时间正好减少 a − b 分钟,而且和他们站在哪个位置无关**。
//
// 结论:只要还存在「慢的排在快的前面」的相邻一对,就一定能交换出更优的解。
// 所以最优解里不可能存在这样的一对 —— 那就只能是升序。
//
// 复杂度 O(n log n),瓶颈全在排序上。
#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<long long> t(n);
for (int i = 0; i < n; i++) cin >> t[i];
sort(t.begin(), t.end()); // ★ 整个算法就是这一行
// 下面这段和 brute.cpp 里的一字不差
long long total = 0, wait = 0;
for (int i = 0; i < n; i++) {
total += wait; // 这个人要等前面所有人接完
wait += t[i]; // 然后轮到他,后面的人再多等 t[i]
}
cout << total << "\n"; // 用 long long:n = 1e5、t = 1e3 时会超 int
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

把 fast.cpp 和 brute.cpp 并排看:算总等待的那个循环一个字都没改。 唯一的区别是 brute.cpp 算了 n! 种顺序,而 fast.cpp 只算了一种 —— 升序那种。

复杂度 O(n log n),全花在排序上。

⚠ 这里有个坑,而且它躲得过本章这套对拍

n = 10⁵、t 最大 10³ 时,总等待时间可以到 10⁵ × 10⁵ × 10³ / 2 = 5 × 10¹² 量级 —— int 装不下。

而本章这套对拍不会告诉你:这里的参照物是 n! 全排列暴力,它只跑得动 n ≤ 8 —— 小数据下 int 和 long long 的行为完全一样。

⚠ 但别把这句话记成「对拍查不出溢出」 —— 那么说少了主语。 死结从来不是「溢出」,是那个参照物是暴力:查溢出根本不需要暴力当参照物, 拿同一份算法的 long long 版去比就够了,而这条路没有规模限制。

本章原题 P1223 那一页把它跑了一遍:顶格 n = 1000、t ≤ 10⁶ 对拍 300 轮, int 版被抓 300 / 300,一轮都没漏。

规矩仍然很简单:只要答案是「一堆数加起来 / 乘起来」,一律先写 long long。

8动画:看着总等待时间一次次掉下去

排队接水:每换一对相邻逆序,总等待就少一点
要换 6 次
第 1 / 8 步
第 1 位
7
等 0
第 2 位
3
等 7
第 3 位
9
等 10
第 4 位
2
等 19
第 5 位
5
等 21
总等待时间
57
这一步的变化
—
已交换
0 次
升序(贪心)能做到
34
全排列暴力的答案
34
浅色 = 干等着(这些加起来就是要最小化的总等待时间),深色 = 轮到他接水, 蓝色 = 这一步被交换的那一对。最后两栏永远相等 —— 贪心和暴力给出的是同一个答案。
初始顺序的总等待时间是 57 分钟。下面每一步只做一件事:找到相邻的一对「慢的排在快的前面」,把他们换过来。

浅色那一段是「干等」,深色那一段才是「在接水」。要最小化的就是所有浅色段的总长度。

建议这样玩:

  1. 点「改成最坏顺序(降序)」,看初始的总等待有多大;
  2. 一步一步点,盯住「这一步少了」那一栏 —— 它永远等于被交换的两个数之差;
  3. 播到底,确认「总等待时间」和右边「全排列暴力的答案」对上了。

9一句话问题(二):区间调度

换一道题,同样是排序型贪心,但该按什么排没那么显然了。

n 场比赛,第 i 场占用时间段 [l, r]。你同一时刻只能参加一场, 但上一场结束的时刻可以正好是下一场开始的时刻([1,3] 和 [3,5] 不冲突)。 最多能参加几场?

⚠ 先把「冲突」这个词钉死

「端点重合算不算冲突」是题目规定的,不是数学定理。 本章按洛谷 P1803 的约定:端点重合不算冲突。

这句话决定了代码里写 l >= lastEnd 还是 l > lastEnd —— 一个字之差,答案就不一样。 对拍的两份程序如果对这句话的理解不同,你会调一整晚,还以为是算法错了。

10三种「听起来都对」的排法

拿到这题,几乎所有人都会想到按某个东西排序。候选有三个:

  1. 按开始时间从早到晚 —— 早点开始,能多参加几场?
  2. 按持续时间从短到长 —— 挑短的,占的时间少?
  3. 按结束时间从早到晚 —— 早点结束,留给后面的时间多?

三个听起来都很有道理。而只有第三个是对的。 下面这份代码把三种排法并排跑给你看 (第四行是 2ⁿ 暴力,当尺子):

itvWrong.cpp三种排法并排跑
这组数据是精心挑的:正解 4 场,另外两种排法都只有 3 场。
// 区间调度 —— 四种排序方式并排跑,看看谁是对的
//
// 为什么要有这份代码:
// 「按右端点排序」这句话,光背是没用的,你必须知道**另外两种听起来同样合理的排法错在哪**。
// 这份代码用同一组数据把四种做法并排跑出来:
//
// ① 按左端点从早到晚 —— 「早点开始,能多参加几场」→ 错。
// 一场从早开到晚的比赛开始得最早,却把整天都占了。
// ② 按区间从短到长 —— 「挑短的,占的时间少」→ 也错。
// 一场很短的比赛可能正好卡在两场长比赛的中间,一个换掉俩。
// ③ 按右端点从早到晚 —— 正解。结束得越早,留给后面的时间越多。
// ④ 2ⁿ 枚举子集 —— 一定最优,用来当尺子。
//
// 三种贪心用的是**同一个函数**,只是喂进去的顺序不同 —— 这样对比才公平:
// 差别百分之百来自「怎么排序」,不来自别的地方。
//
// 注意选取时的判断:按给定顺序一个一个看,只要**和已经选中的任何一场都不冲突**就选。
// (对③来说「和最后选的那场不冲突」就够了,因为它的结束时间是递增的;
// 但①②不成立,所以这里统一写成和所有已选的比 —— 老实但公平。)
//
// 输入格式和 itvFast.cpp 一样。输出是一张四行的表。
// 想找一组能打假①②的数据?跑 itvGen.cpp 造,或者直接用第 20 章的对拍器批量找。
#include <bits/stdc++.h>
using namespace std;
struct Seg { long long l, r; };
// 按 order 给出的顺序逐个考察,不冲突就选。返回选出的场数。
static int greedyBy(const vector<Seg>& a, const vector<int>& order) {
vector<Seg> picked;
for (int idx : order) {
bool ok = true;
for (const Seg& p : picked)
if (a[idx].l < p.r && p.l < a[idx].r) { ok = false; break; } // 有重叠部分才算冲突
if (ok) picked.push_back(a[idx]);
}
return (int)picked.size();
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
if (!(cin >> n)) return 0;
vector<Seg> a(n);
for (int i = 0; i < n; i++) cin >> a[i].l >> a[i].r;
vector<int> id(n);
for (int i = 0; i < n; i++) id[i] = i;
vector<int> byL = id, byLen = id, byR = id;
sort(byL.begin(), byL.end(), [&](int x, int y) {
return a[x].l != a[y].l ? a[x].l < a[y].l : a[x].r < a[y].r;
});
sort(byLen.begin(), byLen.end(), [&](int x, int y) {
long long dx = a[x].r - a[x].l, dy = a[y].r - a[y].l;
return dx != dy ? dx < dy : a[x].l < a[y].l;
});
sort(byR.begin(), byR.end(), [&](int x, int y) {
return a[x].r != a[y].r ? a[x].r < a[y].r : a[x].l < a[y].l;
});
// ④ 老实枚举子集当尺子(和 itvBrute.cpp 同一套逻辑)
vector<Seg> s = a;
// 左端点相同时必须再按右端点排(短的在前)。少了这一句,[12,13] 和 [12,12] 这种
// 「长的排在短的前面」会让下面「只和上一个选中的比」判错,最优解会算小 ——
// 这不是假想,是这一章的交叉验证真的抓到过的一次事故。
sort(s.begin(), s.end(), [](const Seg& x, const Seg& y) {
return x.l != y.l ? x.l < y.l : x.r < y.r;
});
int best = 0;
for (int mask = 0; mask < (1 << n); mask++) {
int cnt = 0;
long long lastEnd = LLONG_MIN;
bool ok = true;
for (int i = 0; i < n && ok; i++) {
if (!(mask >> i & 1)) continue;
if (s[i].l < lastEnd) ok = false;
else { lastEnd = s[i].r; cnt++; }
}
if (ok) best = max(best, cnt);
}
int r1 = greedyBy(a, byL), r2 = greedyBy(a, byLen), r3 = greedyBy(a, byR);
cout << "策略 选出的场数\n";
cout << "① 按左端点从早到晚 " << r1 << (r1 == best ? "" : " ← 比最优少") << "\n";
cout << "② 按区间从短到长 " << r2 << (r2 == best ? "" : " ← 比最优少") << "\n";
cout << "③ 按右端点从早到晚(正解) " << r3 << (r3 == best ? "" : " ← 居然不是最优?那是代码写错了") << "\n";
cout << "④ 暴力枚举子集(一定最优) " << best << "\n";
if (r1 == best && r2 == best)
cout << "\n这组数据太温柔了,三种排法都蒙对了 —— 换一组再试(错误的贪心不是每次都错)。\n";
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

本机跑出来:

策略 选出的场数
① 按左端点从早到晚 3
② 按区间从短到长 3
③ 按右端点从早到晚 4
④ 2ⁿ 暴力(一定最优) 4

它们分别错在哪:

  • ① 按开始时间:[1,10] 开始得最早,可它一个人就占掉了整个上午 —— 本来能参加 [2,3] 和 [4,5] 两场的。开始得早,不代表结束得早。
  • ② 按持续时间:[14,16] 只有 2 个单位,看起来很划算, 可它正好卡在 [12,15] 和 [15,18] 中间 —— 一个换掉了俩。

11★ 关键一步:为什么是「结束最早」

★ 关键的一步

直觉版:结束得越早,留给后面的时间就越多。 这是唯一一个直接对「后面还剩多少空间」负责的指标。

严格版(还是交换论证):

设贪心选的第一场是 Y(全场结束最早的那一场),而某个最优解按时间排好后第一场是 X。 因为 Y 是结束最早的,所以 Y.r <= X.r。

现在把最优解里的 X 换成 Y: 原来能排在 X 后面的那些场次,开始时间都 >= X.r >= Y.r, 所以它们照样能排在 Y 后面。于是:

  • 场数一个都没少(换掉一场,补上一场);
  • 而且现在这个最优解的第一场和贪心一致了。

对剩下的部分重复同样的论证,就能把最优解一步步「掰」成贪心解,而场数从头到尾没变过。 所以贪心解和最优解一样多。∎

注意这个论证的形状和排队接水一模一样: 「假设最优解和我不同 → 把它改成和我一样 → 证明改完不会更差」。

12正解 + 实测

itvFast.cpp按右端点排
// 区间调度(线段覆盖)—— 正解:按**右端点**从早到晚排序,能选就选
//
// 题意:n 场比赛,第 i 场占用时间段 [l, r]。一个人同一时刻只能参加一场,
// 但**上一场的结束时刻可以正好是下一场的开始时刻**(即 [1,3] 和 [3,5] 不冲突)。
// 最多能参加几场?
//
// ★ 关键一步:按「结束时间」排序,而不是「开始时间」,也不是「持续时间」。
//
// 为什么是结束时间 —— 交换论证(和排队接水是同一套思路):
// 设最优解按时间排好后第一场是 X,而贪心选的第一场是 Y(Y 是全场结束最早的)。
// 那么 Y.r <= X.r。把最优解里的 X 换成 Y:Y 结束得不比 X 晚,
// 所以原来能接在 X 后面的那些场次,现在照样能接在 Y 后面 ——
// **场数一个没少,而且第一场和贪心一致了**。
// 对剩下的部分重复这个论证,就能把最优解一步步「掰」成贪心解,且场数始终不变。
// 所以贪心解和最优解一样多。
//
// 直觉版说法:**结束得越早,留给后面的时间就越多**。而「开始得早」「时间短」
// 都不能保证这一点 —— 一场从头开到尾的比赛开始得最早,却把整天都占了。
// (错误的那几种排法值多少分,跑 itvWrong.cpp 看表。)
//
// 输入:第一行 n,接下来 n 行每行两个数 l r
// 输出:最多能参加的场数
//
// 复杂度 O(n log n)。
#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); // 存成 (r, l),直接按 r 排
for (int i = 0; i < n; i++) {
long long l, r;
cin >> l >> r;
a[i] = {r, l};
}
sort(a.begin(), a.end()); // ★ 按右端点从早到晚
int cnt = 0;
long long lastEnd = LLONG_MIN; // 上一场的结束时刻
for (int i = 0; i < n; i++) {
long long r = a[i].first, l = a[i].second;
if (l >= lastEnd) { // 不冲突(端点重合算不冲突)
cnt++;
lastEnd = r;
}
}
cout << cnt << "\n";
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

标准答案是 2ⁿ 枚举子集(接第 3 章的二进制枚举):

itvBrute.cpp2ⁿ 枚举子集
同题对比:2ⁿ 枚举子集 vs 按右端点贪心
每加 1,暴力就翻一倍:24 约 0.2 秒,26 约 0.7 秒,28 就要 3.5 秒了。
2ⁿ 枚举子集
按右端点贪心

本机实测:

n 2ⁿ 暴力 排序贪心
22 0.052 秒 0.005 秒
24 0.218 秒 0.005 秒
26 0.67 秒 0.005 秒
28 3.5 秒 0.005 秒
200000 想都别想 0.039 秒

13动画:同一组比赛,三种排法

区间调度:换一种排序,答案就变了
选出 4 / 最优 4
第 1 / 8 步
1. [2,3]
2. [4,5]
3. [1,10]
4. [12,15]
5. [14,16]
6. [15,18]
1
3
5
7
9
11
13
15
17
这种排法选出
0
2ⁿ 暴力的最优解
4
已放弃
0 场
左边的序号就是这种排法的考察顺序。绿色 = 已选中,划掉的 = 因为撞车被放弃, 实心绿 = 挡住当前这一场的那个「罪魁祸首」。 端点重合([1,3] 和 [3,5])不算冲突 —— 这是题目的约定,换一道题可能就反过来。
按右端点从早到晚(正解):考察顺序是 [2, 3] → [4, 5] → [1, 10] → [12, 15] → [14, 16] → [15, 18]。接下来一个一个看,和已经选中的都不冲突就选。

只改左上角那个下拉框,别的什么都不动,看三种排法分别选出几场。

要盯的是:错误的那两种是在哪一步走岔的 —— 它们不是一开始就错, 而是在某一步贪了一个「看起来划算」的区间,然后为此赔上了后面两场。

14★ 对拍:贪心最需要对拍

★ 为什么贪心比别的算法更需要对拍

前面几章的对拍,抓的多半是写错(边界、越界、剪过头)。

贪心不一样:贪心的对拍抓的是想错。 你的代码可能一个字都没写错,编译零警告,样例全过 —— 但排序的关键字选错了, 于是它在 90% 的数据上都对,只在某一类数据上崩。

这种错误只有对拍能发现。 而且你会发现:造出反例往往只需要三五轮随机数据。 下面两个对拍器,把「正解」那一栏换成你自己写的(尤其推荐故意换成「按左端点排」), 点开始,看着自己的直觉在第几轮被打脸。

排队接水(标准答案 = 全排列暴力):

对拍器
生成器故意把取值范围压到 1~6,逼出大量相同的 t —— 排序型贪心最容易死在「相等」上。另外必造升序、降序、n=1 这几种边界。
// 排队接水 —— 正解:把 t 从小到大排序,就完了
//
// 和 brute.cpp 比一比:**算总等待时间的那个循环一模一样,一个字都没改**。
// 唯一的区别是这里只算了一种顺序(升序),而 brute.cpp 算了 n! 种。
//
// ★ 凭什么升序就是最优的?—— 交换论证(跑一下 swap.cpp 亲眼看):
//
// 设某个顺序里,相邻的两个人接水时间是 a、b,且 a > b(也就是「慢的排在快的前面」)。
// 把这两个人交换一下,总等待时间的变化是多少?
//
// 假设他们站在第 k、k+1 位。排在第 k 位的人,他的接水时间会被后面 (n-k) 个人等;
// 第 k+1 位的会被 (n-k-1) 个人等。于是
// 交换前 = (n-k)·a + (n-k-1)·b
// 交换后 = (n-k)·b + (n-k-1)·a
// 交换后 − 交换前 = (b − a)·[(n-k) − (n-k-1)] = b − a
//
// b − a < 0,所以**总等待时间正好减少 a − b 分钟,而且和他们站在哪个位置无关**。
//
// 结论:只要还存在「慢的排在快的前面」的相邻一对,就一定能交换出更优的解。
// 所以最优解里不可能存在这样的一对 —— 那就只能是升序。
//
// 复杂度 O(n log n),瓶颈全在排序上。
#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<long long> t(n);
for (int i = 0; i < n; i++) cin >> t[i];
sort(t.begin(), t.end()); // ★ 整个算法就是这一行
// 下面这段和 brute.cpp 里的一字不差
long long total = 0, wait = 0;
for (int i = 0; i < n; i++) {
total += wait; // 这个人要等前面所有人接完
wait += t[i]; // 然后轮到他,后面的人再多等 t[i]
}
cout << total << "\n"; // 用 long long:n = 1e5、t = 1e3 时会超 int
return 0;
}
点一下即可编辑

区间调度(标准答案 = 2ⁿ 枚举子集):

对拍器
关键不是 n 大,是坐标范围小(1~14)—— 区间才会大量重叠、包含、端点重合。范围开到 1e9 的话随机区间基本互不相干,对拍就白跑了。
// 区间调度(线段覆盖)—— 正解:按**右端点**从早到晚排序,能选就选
//
// 题意:n 场比赛,第 i 场占用时间段 [l, r]。一个人同一时刻只能参加一场,
// 但**上一场的结束时刻可以正好是下一场的开始时刻**(即 [1,3] 和 [3,5] 不冲突)。
// 最多能参加几场?
//
// ★ 关键一步:按「结束时间」排序,而不是「开始时间」,也不是「持续时间」。
//
// 为什么是结束时间 —— 交换论证(和排队接水是同一套思路):
// 设最优解按时间排好后第一场是 X,而贪心选的第一场是 Y(Y 是全场结束最早的)。
// 那么 Y.r <= X.r。把最优解里的 X 换成 Y:Y 结束得不比 X 晚,
// 所以原来能接在 X 后面的那些场次,现在照样能接在 Y 后面 ——
// **场数一个没少,而且第一场和贪心一致了**。
// 对剩下的部分重复这个论证,就能把最优解一步步「掰」成贪心解,且场数始终不变。
// 所以贪心解和最优解一样多。
//
// 直觉版说法:**结束得越早,留给后面的时间就越多**。而「开始得早」「时间短」
// 都不能保证这一点 —— 一场从头开到尾的比赛开始得最早,却把整天都占了。
// (错误的那几种排法值多少分,跑 itvWrong.cpp 看表。)
//
// 输入:第一行 n,接下来 n 行每行两个数 l r
// 输出:最多能参加的场数
//
// 复杂度 O(n log n)。
#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); // 存成 (r, l),直接按 r 排
for (int i = 0; i < n; i++) {
long long l, r;
cin >> l >> r;
a[i] = {r, l};
}
sort(a.begin(), a.end()); // ★ 按右端点从早到晚
int cnt = 0;
long long lastEnd = LLONG_MIN; // 上一场的结束时刻
for (int i = 0; i < n; i++) {
long long r = a[i].first, l = a[i].second;
if (l >= lastEnd) { // 不冲突(端点重合算不冲突)
cnt++;
lastEnd = r;
}
}
cout << cnt << "\n";
return 0;
}
点一下即可编辑

值得故意写错、然后看对拍怎么抓的:

  • 排序写成从大到小 → 第 1 轮就被抓
  • total += wait 和 wait += t[i] 两行调换 → 把自己的接水时间也算成了等待,第 1 轮被抓
  • 区间按左端点排 → 通常 2~3 轮内被抓
  • l >= lastEnd 写成 l > lastEnd(把端点重合当成冲突)→ 第 1 轮被抓, 因为生成器专门造了大量端点重合的数据
  • 总和用 int → 对拍抓不住(小数据不会溢出),只能靠第 7 步那个规矩

15排序型贪心的通用套路

★ 拿到一道疑似贪心的题,按顺序做这四件事
  1. 猜一个排序关键字。 排序型贪心的答案几乎总是「按某个东西排序,然后顺着扫一遍」。 先把候选列出来:开始时间、结束时间、长度、大小、比值……

  2. 试着做交换论证。 假设最优解和你的贪心在某处不同,把那一处换成贪心的选择, 看答案会不会变差。

    • 论证得通 → 你的贪心是对的,而且你知道它为什么对;
    • 论证卡住了 → 八成是排序关键字选错了,换一个再试。
  3. 不管论证通没通,都去对拍。 论证可能出错,代码可能和论证不一致。 标准答案用完全不同的思路写(全排列 / 2ⁿ 枚举 / DP),别用同一个想法写两遍。

  4. 实在证不出来,就别用贪心。 说不出交换论证,只能靠「感觉」, 那就老老实实上搜索或 DP(阶段 5)—— 慢一点的正确算法,永远好过快一点的错误算法。

⚠ 贪心和 DP 的分界线

第 16 章那道小猫爬山也是「一只一只安排」,为什么那里贪心不行,这里就行?

区别在于当前的选择会不会影响后面的可能性:

  • 排队接水:把谁排在前面,不改变后面还能怎么排 —— 只影响系数。贪心可行。
  • 区间调度:选了结束最早的那场,后面能选的只会变多不会变少(交换论证证明的就是这件事)。贪心可行。
  • 装箱(小猫爬山):这只猫塞进哪辆车,会实实在在地改变后面每辆车的剩余容量, 一步走错满盘皆输。贪心不可行,只能搜索。

判断不了的时候,就用第 3 步:对拍。 三分钟就能知道答案。

16自测

自测清单0 / 8
配套练习
这一章我卡在哪(过一个月回来看,这几行比整章正文都值钱)
下一章预告

第 20 章把这一章的第 3 步单独拎出来讲透:用对拍系统地打假错误的贪心。

那一章的主角不是正确的算法,而是四个「看起来非常对」的贪心, 每一个都配一个能在几轮内打假它的对拍器 —— 包括那个几乎人人都会上当的 「背包按性价比排序」。

学完这一章你会写贪心了,学完下一章你才敢在考场上写贪心。