01 / 本课学习路线
本课学习路线
阅读与推演约 110 分钟,练习约 55 分钟,进阶练习另需约 20 分钟
02 / 学习目标与先修自测
学完本课你能做什么,以及开始前需要会什么
贪心题的代码通常只有「排序 + 一遍扫描」十几行,难点全在排序键选对没有。本课教的是选排序键的流程:候选 → 反例 → 交换论证 → 编码。
| 学完后能做的事 | 正文位置 | 检查方式 |
|---|---|---|
| 给区间调度写出排序依据,并说出那句交换论证 | 第 04 节 | 自查第 2 条、练习 4 |
| 构造小规模反例,否定按开始时间或按时长排序的策略 | 第 04 节 | 自查第 3 条、代码自测 |
| 转场、相等这类边界条件按题目要求确定,不是猜的 | 第 05 节 | 自查第 4 条、练习 2 |
| 解释救生艇「最重配最轻」为什么安全 | 第 06 节、补充学习 | 自查第 5 条、练习 5 |
| 写出「排序 + 一遍扫描」并通过 P3110、P3118 | 第 08 节 | 必做任务 1、2 |
先修自测:下面 5 题请先自己写答案,再展开对照。答不出的按括号里的位置补看再回来。本课假定你已完成模块 2 的「排序与并列规则」和模块 3 的「栈、队列与单调栈」。
展开先修自测答案
自测 1:[(1, 4), (3, 9)]——按每个元组的第 2 项(下标 1)升序。本课的排序键就写在 key= 里。
自测 2:600 与 660;元组不能改,要改就新建一个。本课用 (开始, 结束) 表示一场演出。
自测 3:<= 会进(处理最后剩下的一个人),< 不会。P3118 里 l == r 时那个人要单独一条船,所以用 <=。
自测 4:假;加入后为真。P2651 用集合记录「这个数字已经在栈里」。
自测 5:"423"。P2651 最后把栈里的字符拼成一个字符串输出。
03 / 概念与术语
贪心、排序键、反例、交换论证、并列规则
贪心只在「局部最优不会排除更优的后续方案」时成立。判断这一点靠两件事:找不到反例,并且说得出交换论证。
| 术语 | 含义 | 代码里的位置 |
|---|---|---|
| 贪心 | 每一步只保留当前最优的一个选择,不回头 | 排序后的一遍 for 扫描 |
| 排序键 | 「当前最优」按什么排:开始时间 / 时长 / 结束时间 / 重量 | sorted(..., key=...) |
| 选取条件 | 扫描到一项时,选不选它的判断式(含题目的附加规则) | if start >= cur_end + 15 |
| 反例 | 一组小输入,让候选策略得到比最优解差的结果 | 第 04 节三张表;代码自测 |
| 交换论证 | 「假设最优解没选它,把最优解里对应的那个换成它,答案不会变差」 | 写在纸上,不在代码里 |
| 并列规则 | 排序键相同时先取谁;只问数量时通常不影响答案,要求输出方案时按题目规则做第二关键字 | key=lambda s: (s[1], s[0]) |
补充学习(选学)救生艇问题:为什么「最重配最轻」是安全的约 6 分钟P3118 的交换论证,与双指针的配合方式
排序后考虑最重的人 H:如果连最轻的人 L 都不能和 H 同船,H 只能独占一条船;如果能,将 L 与 H 配对不会增加所需船数——L 是最容易被安排的人,把 L 留给别人不会让任何方案变好(交换论证:任何「L 配别人」的方案,把 L 换给 H 后船数不增)。
实现上排序后两头双指针:右指针(最重)每轮必走一步,左指针(最轻)能配就走。循环里每人恰好被处理一次,O(n log n) 排序 + O(n) 扫描。
补充学习(选学)反例构造的三个方向约 5 分钟构造小规模反例的三种常用方法
① 一个大元素配两个小元素:构造一个「排序键很优、真实收益很差」的大元素(很长的演出、很重的物品),与两个互不冲突的小元素放在一起;② 两个并列元素:构造排序键相同、但对后续选择影响不同的两个元素,检查策略是否会选错;③ 边界输入:n=1、全部相同、恰好相等(转场恰好 15 分钟)——策略在极端输入上最容易暴露问题。3~4 个元素足够,不需要构造大规模数据。
04 / 三个候选排序键
按开始时间、按时长、按结束时间:各自的反例,只有一个成立
以 P3110「观看文艺汇演」为例:第一行 N(≤ 1000),随后 N 行每行开始时间 T 和持续时间 L(分钟,0 ≤ T ≤ 1440,0 < L ≤ 100);每场只能完整观看,连续两场之间至少 15 分钟转场;输出最多能看几场。下面三组反例都用这个输入格式,数字是本课自己算的。
| 策略 | 选取过程 | 场数 |
|---|---|---|
| 按开始时间 | 先选 [0,100];[1,2] 开始 1 < 100+15,跳过;[30,31] 同样跳过 | 1 |
| 按结束时间 | 先选 [1,2];[30,31] 开始 30 ≥ 2+15 = 17,选;[0,100] 开始 0 < 31+15,跳过 | 2 |
一个很长的演出开始得最早,按开始时间会把它先选上,后面全被它挡住。这就是「一个大元素配两个小元素」的构造。
| 策略 | 选取过程 | 场数 |
|---|---|---|
| 按时长最短 | 先选最短的 [22,32];[0,20] 结束 20 → 22 < 20+15,冲突;[35,55] 开始 35 < 32+15 = 47,冲突 | 1 |
| 按结束时间 | 先选 [0,20];[22,32] 开始 22 < 35,跳过;[35,55] 开始 35 ≥ 20+15 = 35,选 | 2 |
最短的那场正好横跨两场的衔接点:它自己只有 10 分钟,却把前后两场都挤掉了。
| 排序键 | 结论 | 依据 |
|---|---|---|
| 按开始时间 | 错 | 上面第一组反例 |
| 按时长最短 | 错 | 上面第二组反例 |
| 按结束时间 | 对 | 交换论证:把最优解的第一场换成「结束最早」的那场,它结束得不比原来晚,后面的选择只会更宽松;对余下的场次重复这个论证 |
交换论证的句式要能自己说出来
「假设最优解没选它,把最优解里对应的那个换成它,答案不会变差。」能说出这句话,贪心才成立;说不出,就先找反例。并列也是排序键的一部分:结束时间相同时先选哪一场,只问数量时通常不影响答案;要求输出方案时,题目会给出并列规则(如编号小者优先)——写成排序键的第二关键字。
05 / P3110 逐场推演
把 15 分钟转场写进选取条件;四场演出,含「恰好 15 分钟」的等号
转场规则不改排序键(仍按结束时间),只改选取条件:开始时间 ≥ 上一场结束时间 + 15。题面写的是「至少 15 分钟」,所以等号成立时可以选。
| 演出 (开始, 结束) | 上一场结束 + 15 | 判断 | 已选场数 |
|---|---|---|---|
| (600, 660) | —(第一场) | 选 | 1 |
| (630, 690) | 660 + 15 = 675 | 630 < 675,跳过 | 1 |
| (700, 760) | 675 | 700 ≥ 675,选 | 2 |
| (775, 835) | 760 + 15 = 775 | 775 ≥ 775,等号成立,选 | 3 |
输出 3。最后一场是等号情形:把 >= 写成 > 会漏掉它,输出 2(第 09 节错误表)。时间若给成 HH:MM,先统一换算成分钟再比;本题直接给分钟数。
再算一组:2 / 0 60 / 70 60
(0, 60) 选;(70, 130) 开始 70 < 60 + 15 = 75,跳过 → 输出 1 若漏掉 +15(当成普通区间调度):70 ≥ 60,会输出 2
题目页参考题解按开始时间排序、再用「结束更早的替换当前」处理包含关系,与按结束时间排序等价;本课用按结束时间排序,一遍扫描不需要替换分支。
06 / P3118 贪心 + 双指针
最重的人先安排,能配上当前最轻的就同船
P3118「租车骑绿岛」:第一行最大载重 m 和人数 n,第二行 n 个体重;每条船最多两人、总重不超过 m;输出最少船数。题面示例与「救生艇」示例相同:3 4 / 3 2 2 1 → 3。
| 轮 | l(最轻) | r(最重) | w[l] + w[r] | 动作 | 船数 |
|---|---|---|---|---|---|
| 1 | 0 (1) | 3 (3) | 4 > 3 | 3 单独上船,r ← 2 | 1 |
| 2 | 0 (1) | 2 (2) | 3 ≤ 3 | 1 与 2 同船,l ← 1、r ← 1 | 2 |
| 3 | 1 (2) | 1 (2) | l == r | 剩下的 2 单独上船,r ← 0 | 3 |
输出 3。第 3 轮 l == r:循环条件必须是 l <= r,否则最后一个人没船。右指针每轮必走一步,所以每人恰好处理一次。
| 轮 | 配对 | 船数 |
|---|---|---|
| 1 | 1 + 9 = 10 ≤ 10 → 同船 | 1 |
| 2 | 2 + 8 = 10 → 同船 | 2 |
| 3 | 7 单独 | 3 |
若改成「最轻配次轻」:1 + 2 同船,7 单独,8 单独,9 单独 → 4 条。最轻的人是最容易安排的,留给最重的人才不浪费。
07 / P2651 逐位贪心
栈顶比当前小、且后面还有同数字可用,才弹栈
P2651「删除重复数字后的最大数字」:一个数字串(范围 [1, 100000]),每个数字只保留一次、保持原有顺序,输出能得到的最大数。题面示例:12341 → 2341;42234 → 423。
| 读到 | 剩余次数(4 / 2 / 3) | 动作 | 栈 |
|---|---|---|---|
| 4 | 1 / 2 / 1 | 栈空,入栈 | 4 |
| 2 | 1 / 1 / 1 | 2 < 栈顶 4,直接入栈 | 4 2 |
| 2 | 1 / 0 / 1 | 2 已在栈中,跳过 | 4 2 |
| 3 | 1 / 0 / 0 | 栈顶 2 < 3,但 2 的剩余次数是 0,不能弹;入栈 | 4 2 3 |
| 4 | 0 / 0 / 0 | 4 已在栈中,跳过 | 4 2 3 |
输出 423。读到 3 时若不看剩余次数就把 2 弹掉,结果 43 少了一个 2(第 09 节错误表)。12341:读到 2 时栈顶 1 比 2 小、后面还有一个 1,弹掉第一个 1;2、3、4 依次入栈;读到最后一个 1 时它已不在栈中,入栈——最终 2341。
两条状态缺一不可:剩余次数决定「能不能弹」,已在栈中的集合决定「要不要跳过」。弹栈条件里的比较是严格小于:栈顶等于当前数字的情况不会出现(相同数字已被跳过)。
08 / 从排序键到程序
三道题的完整参考程序,每一步落在哪几行
先自己写完并提交一次,再展开对照。
| 步骤 | P3110 | P3118 | P2651 |
|---|---|---|---|
| 排序键 | key=lambda s: s[1](结束时间) | sorted(weights) | 不排序:按原顺序扫描 |
| 选取条件 | start >= cur_end + 15 | w[l] + w[r] <= limit | stack[-1] < ch and remain[stack[-1]] > 0 |
| 扫描方式 | 一遍 for | while l <= r 双指针 | 一遍 for + 栈 |
| 答案 | 已选场数 | 船数 | "".join(stack) |
展开完整参考程序 1:P3110 观看文艺汇演(先自己写完并提交一次,再展开对照)
完整程序:P3110(标准输入 → 标准输出)
Pythonimport sys
data = sys.stdin.read().split()
n = int(data[0])
shows = []
for i in range(n):
t, l = int(data[1 + 2 * i]), int(data[2 + 2 * i])
shows.append((t, t + l)) # (开始, 结束)
shows.sort(key=lambda s: s[1]) # 排序键:结束时间早的在前
count = 0
cur_end = -10**9 # 已选的最后一场的结束时间(初始:无)
for start, end in shows:
if start >= cur_end + 15: # 选取条件:开始时间 ≥ 上一场结束 + 15 分钟转场(等号成立:「至少 15 分钟」)
count += 1
cur_end = end
print(count)选取条件里的 + 15 与 >= 缺一不可:分别用第 05 节的四场演出(3)和 2 / 0 60 / 70 60(1)核对;题目页参考题解按开始时间排序 + 替换,结果相同。
展开完整参考程序 2:P3118 租车骑绿岛
完整程序:P3118(标准输入 → 标准输出)
Pythonimport sys
data = sys.stdin.read().split()
limit, n = int(data[0]), int(data[1])
weight = sorted(int(x) for x in data[2:2 + n]) # 轻的在前、重的在后
l, r = 0, n - 1
boats = 0
while l <= r: # 每轮处理最重的 weight[r]
if weight[l] + weight[r] <= limit: # 最轻的能和最重的同船
l += 1
r -= 1 # 无论配没配上,最重的这个人都上船了
boats += 1
print(boats)用题面示例 3 4 / 3 2 2 1 → 3 与第 06 节的 10 5 / 9 8 7 2 1 → 3 核对。
展开完整参考程序 3:P2651 删除重复数字后的最大数字(进阶练习)
完整程序:P2651(标准输入 → 标准输出)
Pythonimport sys
s = sys.stdin.readline().strip()
remain = {} # 每个数字后面还剩几次可用
for ch in s:
remain[ch] = remain.get(ch, 0) + 1
stack = [] # 从栈底到栈顶递减:大数字尽量靠前
in_stack = set() # 当前已在栈里的数字(每个数字最终只出现一次)
for ch in s:
remain[ch] -= 1 # 走过一个,它的剩余次数减 1
if ch in in_stack:
continue # 已经用过:直接跳过(保留前面那个位置)
while stack and stack[-1] < ch and remain[stack[-1]] > 0: # 栈顶比当前小、且后面还有同数字可用 → 弹掉
in_stack.remove(stack.pop())
stack.append(ch)
in_stack.add(ch)
print("".join(stack))用题面示例 12341 → 2341、42234 → 423 核对;思路与题目页参考题解相同(计数 + 已用集合 + 单调栈)。
09 / 边界、反例与复杂度
错误做法在具体输入上各输出什么
下面每一行都是一个具体的错误程序,给出输入、错误输出与正确输出;把参考程序改成对应写法就能复现。
| 错误做法 | 输入 | 错误输出 | 正确输出 | 判题结果 |
|---|---|---|---|---|
| P3110 按开始时间排序 | 3 / 0 100 / 1 1 / 30 1 | 1 | 2 | 答案错误(WA) |
| P3110 按时长排序 | 3 / 0 20 / 35 20 / 22 10 | 1 | 2 | 答案错误(WA) |
P3110 选取条件写 >(等号漏掉) | 第 05 节四场 | 2 | 3 | 答案错误(WA) |
| P3110 漏掉 + 15 | 2 / 0 60 / 70 60 | 2 | 1 | 答案错误(WA) |
| P3118 最轻配次轻 | 10 5 / 9 8 7 2 1 | 4 | 3 | 答案错误(WA) |
| P3118 不排序直接双指针 | 10 4 / 1 9 2 8 | 3 | 2 | 答案错误(WA) |
P3118 循环写 l < r | 5 3 / 1 2 3 | 1 | 2 | 答案错误(WA) |
| P2651 不看剩余次数就弹栈 | 42234 | 43 | 423 | 答案错误(WA) |
| P2651 不记录「已在栈中」 | 2121 | 221 | 21 | 答案错误(WA) |
前两行就是第 04 节的两组反例:写代码前先跑反例,比提交后看 WA 快得多。
| 做法 | 时间 | 本课规模下 |
|---|---|---|
| 排序 + 一遍扫描 / 双指针 | O(n log n) | N ≤ 1000 瞬间完成 |
| 每步线性找「当前最优」 | O(n²) | N = 1000 约 10⁶ 次比较,仍可通过,但没必要 |
| 枚举所有子集 | O(2ⁿ) | N = 30 已 10⁹,超时 |
10 / 渐进练习与参考答案
跟做 → 改一个条件 → 独立实现 → 迁移
每题先在纸上或文件里做完,再展开答案。
练习 1(跟做):按第 05 节表的格式,把 3 / 0 20 / 35 20 / 22 10 按结束时间排序后逐场推演,写出每一场的判断。
展开练习 1 答案
排序后 (0,20)、(22,32)、(35,55)。(0,20) 选;(22,32):22 < 20+15 = 35,跳过;(35,55):35 ≥ 35,等号成立,选。输出 2。
练习 2(改一个条件):把「至少 15 分钟转场」改成「不需要转场」,2 / 0 60 / 70 60 的答案变成多少?代码改哪里?
展开练习 2 答案
2。只改选取条件里的常数:start >= cur_end + 0(即 start >= cur_end);排序键不变。这也说明为什么附加规则要写进比较式而不是排序键。
练习 3(改一个条件):P2651 改成「删除重复数字后的最小数字」,42234 和 12341 的答案各是多少?弹栈条件怎么改?
展开练习 3 答案
234 和 1234。弹栈条件里的比较方向反过来:栈顶比当前数字大、且后面还有同数字可用时弹栈(stack[-1] > ch)。剩余次数与已在栈中两条状态不变。
练习 4(独立实现):把 P3110 写成函数 max_shows(shows, gap=15),用第 04、05 节的四组输入和「去掉转场」的变式各写一条断言。
展开练习 4 答案
max_shows 的参考实现(自带断言)
Pythondef max_shows(shows, gap=15):
# shows: [(开始, 结束)];连续两场之间至少 gap 分钟
order = sorted(shows, key=lambda s: s[1]) # 排序键:结束时间
count, cur_end = 0, -10**9
for start, end in order:
if start >= cur_end + gap: # 选取条件:写进比较式,不改排序键
count += 1
cur_end = end
return count
assert max_shows([(600, 660), (630, 690), (700, 760), (775, 835)]) == 3 # 第 05 节表;775 = 760 + 15 恰好可选
assert max_shows([(0, 100), (1, 2), (30, 31)]) == 2 # 按开始时间只得 1
assert max_shows([(0, 20), (35, 55), (22, 32)]) == 2 # 按时长只得 1
assert max_shows([(0, 60), (70, 130)]) == 1 # 转场不够 15 分钟
assert max_shows([(0, 60), (70, 130)], gap=0) == 2 # 练习 2:去掉转场gap 作为参数:附加规则只出现在选取条件里。
练习 5(迁移):把 P3118 写成函数 min_boats(limit, weights),用题面示例、l == r、轻配轻反例、不排序反例、单人各写一条断言。
展开练习 5 答案
min_boats 的参考实现(自带断言)
Pythondef min_boats(limit, weights):
w = sorted(weights)
l, r = 0, len(w) - 1
boats = 0
while l <= r: # 每轮处理最重的 w[r]
if w[l] + w[r] <= limit: # 最轻的能同船就带上
l += 1
r -= 1
boats += 1
return boats
assert min_boats(3, [3, 2, 2, 1]) == 3 # P3118 题面示例(与「救生艇」示例相同)
assert min_boats(5, [1, 2, 3]) == 2 # l == r 时那个人单独一条船
assert min_boats(10, [9, 8, 7, 2, 1]) == 3 # 轻配轻会得 4
assert min_boats(10, [1, 9, 2, 8]) == 2 # 不排序会得 3
assert min_boats(4, [4]) == 1五条断言分别对应第 06 节与第 09 节的用例。
11 / 读题要求与复习自评
三道题的要求对照,以及完成本课之后怎么复习
提交前把下表过一遍;题目页的题面与样例是最终依据。
| 项目 | P3110 观看文艺汇演 | P3118 租车骑绿岛 | P2651 删除重复数字 |
|---|---|---|---|
| 输入 | N;N 行「开始 持续」(分钟) | 「限重 人数」;一行体重 | 一个数字串 |
| 输出 | 最多场数 | 最少船数 | 最大数字 |
| 排序键 / 扫描 | 结束时间;一遍扫描 | 体重升序;双指针 | 原顺序;单调栈 |
| 附加规则 | 至少 15 分钟转场(等号可选) | 每船至多两人 | 每个数字恰好保留一次 |
| 示例 | 本课自算 4 / 600 60 / … → 3 | 3 4 / 3 2 2 1 → 3 | 12341 → 2341;42234 → 423 |
需要对照解法时,展开本页第 08 节的三份完整参考程序。复习与自评:本课的「学习完成检查」六条是自评,不改变题目的通过(AC)状态,本课算完成的条件以页面下方「学习完成检查」处的说明为准。复习时用三个问题自测:① 不看正文,给「按开始时间」和「按时长」各口算一组三场演出的反例;② 说出区间调度那句交换论证;③ 不看表格,重推题面示例 42234 的栈。答不出哪一条,就回到对应的节重读,再做第 10 节对应的练习。
12 / 练习
按顺序完成本课的任务
必做题已通过 0/2 道;进阶练习已通过 0/1 道
给两个错误排序键各构造一个反例
代码自测自主练习练习重点:用 3~4 个区间,分别否定「按开始时间」和「按时长」两种策略;预计用时:10 分钟
完成标准:两个反例都能口算出「错误策略得几场、正确答案得几场」
需要时查看提示
用「一个大元素配两个小元素」的方法:[0,100] 配 [1,2]、[30,31]。按开始时间会先选 [0,100],只能得到 1 场;正确答案 2 场。按时长的反例自己构造——让最短的那场恰好横跨两场的衔接点(第 04 节有一组)。
P3110 · 观看文艺汇演
必做任务 1练习重点:按结束时间排序;转场 15 分钟写进选取条件;预计用时:25 分钟
完成标准:能说出转场规则为什么不改排序键、只改比较式
需要时查看提示
输入每行是「开始时间 持续时间」,结束 = 开始 + 持续。排序键仍是结束时间。选取条件从「开始时刻 s ≥ 当前已选演出的结束时刻(cur_end)」变成「s ≥ cur_end + 15」(等号成立,题目要求是「至少」)。第 05 节有逐场推演。
P3118 · 租车骑绿岛
必做任务 2练习重点:排序后使用左右双指针:最重者与最轻者重量之和不超过上限时同船;预计用时:20 分钟
完成标准:能用交换论证说明「最重配最轻」为什么安全
需要时查看提示
第一行「限重 人数」,第二行体重。排序后 l、r 两个指针,在重量数组(weight)上:weight[l] + weight[r] ≤ 上限则 l += 1;无论配没配上,r -= 1、船数 +1。循环条件 l ≤ r,注意 l == r 时那个人单独一条船。第 06 节有逐轮表。
P2651 · 删除重复数字后的最大数字
进阶练习 1进阶练习练习重点:逐位贪心:栈顶字符比当前字符小、且后续仍有相同字符可用时,就弹栈;预计用时:20 分钟
完成标准:能解释「什么时候可以弹栈」——后面必须还有同字符可用
需要时查看提示
对每个重复字符只保留一个的前提下让结果最大:扫描时维护单调栈,弹栈条件 = 栈顶字符 < 当前字符 且 栈顶字符在后面还会出现。先统计每个字符的剩余次数再扫,已在栈中的字符直接跳过。第 07 节有逐位表。
提交结果
提交结果说明与处理方法
- WA
答案错误
三个常见错误:排序键选错(先重跑你的反例)、转场 15 分钟的等号边界、时间没换算成同一单位。第 09 节的表给出了每种错误的具体输出
- PE
格式错误
只要求输出场数就不要输出方案;行尾多余空格逐项核对
- RE
运行错误
读入组数与实际行数不符;P3118 第一行是「限重 人数」两个数
- TLE
超时
每步线性扫描找「当前最优」是 O(n²)——先排序,再一遍扫过
- AC
通过
把你构造的反例作为输入运行自己的程序,确认它真的选对了
13 / 学习完成检查
本课学习完成检查
完成本课需要:必做题全部通过,并勾选本课的全部学习完成检查;进阶练习、基础加练与复习题单独统计,不影响完成状态。登录后,勾选记录会保存到账号,并更新课程总览的完成状态。