阶段 4 · 贪心 · 第 19 章

贪心基础:排序型贪心

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

例题:排队接水 · 区间调度 建议用时: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, 1532
1 3 5 7(从小到大)0, 1, 4, 914

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

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

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

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

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

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

brute.cpp全排列
输入(stdin)
输出
点「运行 ▶」看结果

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

4 实测:暴力慢在哪

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

本机实测:

n全排列暴力排序贪心
100.034 秒0.005 秒
110.36 秒0.005 秒
124.3 秒0.005 秒
1362.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! 种顺序,只需要盯住相邻的两个人。

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

他们站在第 kk+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」必须分毫不差地相等。最后两个数字也要盯一眼。
输入(stdin)
输出
点「运行 ▶」看结果

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

✓ 顺手收回两章的伏笔

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

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

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

7 正解

fast.cpp一行 sort
输入(stdin)
输出
点「运行 ▶」看结果

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

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

⚠ 这里有个对拍抓不出来的坑

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

而对拍永远不会告诉你这件事:对拍用的是 n ≤ 8 的小数据, 小数据下 intlong long 的行为完全一样。这只能靠脑子。

规矩很简单:只要答案是「一堆数加起来 / 乘起来」,一律先写 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 场。
输入(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按右端点排
输入(stdin)
输出
点「运行 ▶」看结果

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

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

本机实测:

n2ⁿ 暴力排序贪心
220.052 秒0.005 秒
240.218 秒0.005 秒
260.67 秒0.005 秒
283.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 这几种边界。

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

对拍器
关键不是 n 大,是坐标范围小(1~14)—— 区间才会大量重叠、包含、端点重合。范围开到 1e9 的话随机区间基本互不相干,对拍就白跑了。

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

  • 排序写成从大到小 → 第 1 轮就被抓
  • total += waitwait += 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
配套练习
  • 洛谷 P1223 排队接水 —— 本章原题。注意它要输出的是排队顺序和平均等待时间(保留两位小数),比本章多一步输出格式
  • 洛谷 P1803 凌乱的yyy / 线段覆盖 —— 本章第二道题的原题,端点重合的约定也和这里一致
  • 洛谷 P2240 部分背包问题 —— 按「单位价值」排序 —— 排序关键字是算出来的,不是直接给的。想清楚交换论证为什么在这里成立(而在 01 背包里不成立,见第 23 章)
  • 洛谷 P1094 纪念品分组 —— NOIP2007。排序之后用第 7 章的对撞双指针,最贵的配最便宜的。交换论证要动点脑筋
  • 洛谷 P1090 合并果子 —— NOIP2004。每次合并最小的两堆 —— 但一次排序不够用,因为合并出来的新堆还要重新参与排序。这题在等第 37 章的堆,先用 sort 硬做也能过
这一章我卡在哪(过一个月回来看,这几行比整章正文都值钱)
下一章预告

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

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

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