前面十八章里,你写的每一个算法都能「讲清楚为什么对」: 递归是分解,二分靠单调,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! 种顺序全试一遍
// 排队接水 —— 全排列枚举: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;}点「运行 ▶」看结果
用 next_permutation 枚举全部 n! 种排队顺序,每种算一遍总等待,取最小。
它慢得离谱,但它不需要任何聪明的想法 —— 这正是它作为标准答案的价值。
4实测:暴力慢在哪
本机实测:
| 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),贪心正确性证明里最常用的一招。它的套路永远是三句话:
- 假设最优解和贪心解不一样;
- 找到第一个不一样的地方,把它换成贪心的选择;
- 说明换完之后答案不会变差 —— 于是贪心解也是最优的。
6把交换论证跑一遍给你看
光看推导容易「看过就忘」。下面这份代码从你给的任意顺序出发, 每次找最左边那对「慢的在前」交换掉,并打印总等待时间少了多少:
// 排队接水 —— 把「交换论证」真的跑一遍给你看//// 为什么要有这份代码:// 交换论证是一段推导,写在纸上很容易看过去就忘。这份代码把它变成可以观察的事实:// 从你给的任意顺序出发,每次找到**相邻的一对「慢的在前、快的在后」**就交换它们,// 并打印总等待时间少了多少。//// 你会看到两件事:// 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;}点「运行 ▶」看结果
7 3 5 1 要交换 5 次才排好,总等待从 32 一路降到 14。
这个「反复交换相邻逆序对」的过程,就是第 10 章的冒泡排序。
而交换的次数 —— 5 次 —— 正好是 7 3 5 1 的逆序对个数(第 11 章)。
不是巧合:交换一对相邻的逆序,逆序对总数不多不少刚好减少 1。
所以「把任意顺序改进到最优」需要的交换次数,就是逆序对个数。
swap.cpp 最后一行会把这两个数并排打出来给你核对。
7正解
// 排队接水 —— 正解:把 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;}点「运行 ▶」看结果
把 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动画:看着总等待时间一次次掉下去
浅色那一段是「干等」,深色那一段才是「在接水」。要最小化的就是所有浅色段的总长度。
建议这样玩:
- 点「改成最坏顺序(降序)」,看初始的总等待有多大;
- 一步一步点,盯住「这一步少了」那一栏 —— 它永远等于被交换的两个数之差;
- 播到底,确认「总等待时间」和右边「全排列暴力的答案」对上了。
9一句话问题(二):区间调度
换一道题,同样是排序型贪心,但该按什么排没那么显然了。
n 场比赛,第 i 场占用时间段 [l, r]。你同一时刻只能参加一场,
但上一场结束的时刻可以正好是下一场开始的时刻([1,3] 和 [3,5] 不冲突)。
最多能参加几场?
「端点重合算不算冲突」是题目规定的,不是数学定理。 本章按洛谷 P1803 的约定:端点重合不算冲突。
这句话决定了代码里写 l >= lastEnd 还是 l > lastEnd —— 一个字之差,答案就不一样。
对拍的两份程序如果对这句话的理解不同,你会调一整晚,还以为是算法错了。
10三种「听起来都对」的排法
拿到这题,几乎所有人都会想到按某个东西排序。候选有三个:
- 按开始时间从早到晚 —— 早点开始,能多参加几场?
- 按持续时间从短到长 —— 挑短的,占的时间少?
- 按结束时间从早到晚 —— 早点结束,留给后面的时间多?
三个听起来都很有道理。而只有第三个是对的。 下面这份代码把三种排法并排跑给你看 (第四行是 2ⁿ 暴力,当尺子):
// 区间调度 —— 四种排序方式并排跑,看看谁是对的//// 为什么要有这份代码:// 「按右端点排序」这句话,光背是没用的,你必须知道**另外两种听起来同样合理的排法错在哪**。// 这份代码用同一组数据把四种做法并排跑出来://// ① 按左端点从早到晚 —— 「早点开始,能多参加几场」→ 错。// 一场从早开到晚的比赛开始得最早,却把整天都占了。// ② 按区间从短到长 —— 「挑短的,占的时间少」→ 也错。// 一场很短的比赛可能正好卡在两场长比赛的中间,一个换掉俩。// ③ 按右端点从早到晚 —— 正解。结束得越早,留给后面的时间越多。// ④ 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;}点「运行 ▶」看结果
本机跑出来:
| 策略 | 选出的场数 |
|---|---|
| ① 按左端点从早到晚 | 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正解 + 实测
// 区间调度(线段覆盖)—— 正解:按**右端点**从早到晚排序,能选就选//// 题意: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;}点「运行 ▶」看结果
标准答案是 2ⁿ 枚举子集(接第 3 章的二进制枚举):
本机实测:
| 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动画:同一组比赛,三种排法
只改左上角那个下拉框,别的什么都不动,看三种排法分别选出几场。
要盯的是:错误的那两种是在哪一步走岔的 —— 它们不是一开始就错, 而是在某一步贪了一个「看起来划算」的区间,然后为此赔上了后面两场。
14★ 对拍:贪心最需要对拍
前面几章的对拍,抓的多半是写错(边界、越界、剪过头)。
贪心不一样:贪心的对拍抓的是想错。 你的代码可能一个字都没写错,编译零警告,样例全过 —— 但排序的关键字选错了, 于是它在 90% 的数据上都对,只在某一类数据上崩。
这种错误只有对拍能发现。 而且你会发现:造出反例往往只需要三五轮随机数据。 下面两个对拍器,把「正解」那一栏换成你自己写的(尤其推荐故意换成「按左端点排」), 点开始,看着自己的直觉在第几轮被打脸。
排队接水(标准答案 = 全排列暴力):
// 排队接水 —— 正解:把 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 场比赛,第 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排序型贪心的通用套路
-
猜一个排序关键字。 排序型贪心的答案几乎总是「按某个东西排序,然后顺着扫一遍」。 先把候选列出来:开始时间、结束时间、长度、大小、比值……
-
试着做交换论证。 假设最优解和你的贪心在某处不同,把那一处换成贪心的选择, 看答案会不会变差。
- 论证得通 → 你的贪心是对的,而且你知道它为什么对;
- 论证卡住了 → 八成是排序关键字选错了,换一个再试。
-
不管论证通没通,都去对拍。 论证可能出错,代码可能和论证不一致。 标准答案用完全不同的思路写(全排列 / 2ⁿ 枚举 / DP),别用同一个想法写两遍。
-
实在证不出来,就别用贪心。 说不出交换论证,只能靠「感觉」, 那就老老实实上搜索或 DP(阶段 5)—— 慢一点的正确算法,永远好过快一点的错误算法。
第 16 章那道小猫爬山也是「一只一只安排」,为什么那里贪心不行,这里就行?
区别在于当前的选择会不会影响后面的可能性:
- 排队接水:把谁排在前面,不改变后面还能怎么排 —— 只影响系数。贪心可行。
- 区间调度:选了结束最早的那场,后面能选的只会变多不会变少(交换论证证明的就是这件事)。贪心可行。
- 装箱(小猫爬山):这只猫塞进哪辆车,会实实在在地改变后面每辆车的剩余容量, 一步走错满盘皆输。贪心不可行,只能搜索。
判断不了的时候,就用第 3 步:对拍。 三分钟就能知道答案。
16自测
- 洛谷 P1223 排队接水解析 → —— 本章原题。注意它要输出的是排队顺序和平均等待时间(保留两位小数),比本章多一步输出格式
- 洛谷 P1803 凌乱的yyy / 线段覆盖解析 → —— 本章第二道题的原题,端点重合的约定也和这里一致
- 洛谷 P2240 部分背包问题解析 → —— 按「单位价值」排序 —— 排序关键字是算出来的,不是直接给的。想清楚交换论证为什么在这里成立(而在 01 背包里不成立,见第 23 章)
- 洛谷 P1094 纪念品分组解析 → —— NOIP2007。排序之后用第 7 章的对撞双指针,最贵的配最便宜的。交换论证要动点脑筋
- 洛谷 P1090 合并果子解析 → —— NOIP2004。每次合并最小的两堆 —— 但一次排序不够用,因为合并出来的新堆还要重新参与排序。这题在等第 37 章的堆,先用 sort 硬做也能过
第 20 章把这一章的第 3 步单独拎出来讲透:用对拍系统地打假错误的贪心。
那一章的主角不是正确的算法,而是四个「看起来非常对」的贪心, 每一个都配一个能在几轮内打假它的对拍器 —— 包括那个几乎人人都会上当的 「背包按性价比排序」。
学完这一章你会写贪心了,学完下一章你才敢在考场上写贪心。