排序、堆与贪心:先排序,再一遍扫过去¶
这一类题的解法常常只有两行:排个序,然后扫一遍。难的是想清楚"按什么排"和"为什么这么选是对的"。堆则是"动态取最值"的标准工具——Top-K 采样、多路归并、调度器每次挑最早空闲的机器,用的都是它。这一章把 Top-K、多路归并、区间问题、任务调度这几个模板讲清楚,并对照推理系统里的排序与装箱问题。
自测:能答上来就可以跳过本章
- 求 n 个数里最大的 k 个,用排序和用堆各是什么复杂度?什么时候该用哪个?
- Python 的
heapq是小顶堆,要大顶堆怎么办?堆里要放元组时要注意什么? - 合并重叠区间按什么排序?"最多选几个互不重叠的区间"又按什么排序?
sorted的稳定性是什么意思?多关键字排序怎么写?- 贪心为什么需要"证明"?举一个贪心不对的例子。
自测参考答案(先自己答,再展开对照)
- 排序是 O(n log n),维护大小为 k 的小顶堆是 O(n log k)。k 远小于 n 时(比如一亿条日志里取前 100)用堆;k 接近 n、或者后面还要用整体有序的结果时直接排序。还有一个 O(n) 平均的快速选择(
quickselect),只要第 k 大而不要顺序时最快。 - 把值取负(
heappush(h, -x)),或者放元组(-优先级, 数据)。元组比较是逐项的,所以第二项必须可比较;放对象时要加一个单调递增的序号打破平局,否则会去比较对象本身而报TypeError。 - 合并重叠区间按起点排序;最多选不重叠的区间按终点排序(越早结束越好,给后面留的空间最多)。按起点选最多是错的。
- 稳定是指排序不改变相等元素之间的原有顺序。多关键字直接写
key=lambda x: (-x[1], x[0])(先按第二项降序、再按第一项升序),或者利用稳定性排两次(先排次关键字,再排主关键字)。 - 因为贪心每一步都做"当前最优"的选择,不一定导致全局最优。要么给出交换论证("任何最优解都可以改造成包含我这一步选择的解"),要么举反例。经典反例:硬币面额
[1, 3, 4]凑 6,贪心先拿 4 再拿两个 1 共 3 枚,最优是两个 3 共 2 枚——所以零钱兑换要用动态规划。
堆:动态取最值¶
# 堆的三种典型用法:Top-K、多路归并、以及"动态取最小"的调度
import heapq
import random
rng = random.Random(0)
data = [rng.randrange(1000) for _ in range(20000)]
# 1. Top-K:维护一个大小为 k 的小顶堆,堆顶是"第 k 大"
def top_k(a, k):
heap = []
for x in a:
if len(heap) < k:
heapq.heappush(heap, x)
elif x > heap[0]: # 比第 k 大还大,替换堆顶
heapq.heapreplace(heap, x)
return sorted(heap, reverse=True)
print("Top-5:", top_k(data, 5), "(和排序取前 5 一致:", sorted(data, reverse=True)[:5] == top_k(data, 5), ")")
print("复杂度:排序 O(n log n),堆 O(n log k)——k 远小于 n 时堆明显更省")
# 2. 多路归并:k 条有序序列合成一条
streams = [sorted(rng.randrange(100) for _ in range(5)) for _ in range(4)]
print("\n四条有序序列:", streams)
print("归并结果:", list(heapq.merge(*streams)))
# 3. 调度:每次取"最早空闲"的机器,就是一个小顶堆
def schedule(tasks, machines):
"""tasks 是每个任务的耗时,返回全部完成的时刻"""
heap = [0.0] * machines # 每台机器的空闲时刻
heapq.heapify(heap)
for cost in tasks:
free_at = heapq.heappop(heap) # 最早空闲的机器
heapq.heappush(heap, free_at + cost)
return max(heap)
tasks = [5, 3, 8, 2, 7, 1, 6]
for m in (1, 2, 3):
print(f"{len(tasks)} 个任务(共 {sum(tasks)} 单位)用 {m} 台机器:{schedule(tasks, m):.0f} 单位完成,"
f"理论下界 {max(max(tasks), sum(tasks) / m):.1f}")
print("\n把任务按耗时从大到小排序再调度(最长优先,LPT):")
for m in (2, 3):
print(f" {m} 台:顺序到达 {schedule(tasks, m):.0f},先排序 {schedule(sorted(tasks, reverse=True), m):.0f}")
Top-5: [999, 999, 999, 999, 999] (和排序取前 5 一致: True )
复杂度:排序 O(n log n),堆 O(n log k)——k 远小于 n 时堆明显更省
四条有序序列: [[28, 29, 46, 69, 71], [1, 6, 6, 56, 68], [33, 48, 51, 52, 66], [2, 26, 39, 81, 94]]
归并结果: [1, 2, 6, 6, 26, 28, 29, 33, 39, 46, 48, 51, 52, 56, 66, 68, 69, 71, 81, 94]
7 个任务(共 32 单位)用 1 台机器:32 单位完成,理论下界 32.0
7 个任务(共 32 单位)用 2 台机器:18 单位完成,理论下界 16.0
7 个任务(共 32 单位)用 3 台机器:12 单位完成,理论下界 10.7
把任务按耗时从大到小排序再调度(最长优先,LPT):
2 台:顺序到达 18,先排序 16
3 台:顺序到达 12,先排序 11
三个用法各对应一类题:
- Top-K:维护大小为 k 的小顶堆,堆顶就是"当前的第 k 大"。比它小的直接丢弃。推理里的对应是采样时的 top-k(词表 15 万里取前 50),GPU 上用的是并行归约版本,思路一样。
- 多路归并:k 条有序序列各放一个"当前元素"进堆,每次取最小的、补上它的后继。外排序、合并 k 个有序链表、分布式查询的结果归并都是它。Python 的
heapq.merge直接可用。 - 调度:堆里放"每台机器的空闲时刻",每次取最早空闲的。这就是推理调度器的雏形——不过真实调度还要考虑 KV 显存、前缀命中、优先级(见调度器)。
heapq 的两个坑:只有小顶堆(要大顶堆就取负);元组比较会逐项进行,所以放 (优先级, 对象) 时要在中间插一个序号,否则优先级相同时会去比较对象本身。
排序:稳定性与自定义键¶
Python 的 sorted 是 Timsort:最坏 O(n log n),对"部分有序"的数据接近 O(n),并且稳定。稳定性让多关键字排序有两种写法:
items.sort(key=lambda x: (-x.score, x.name)) # 一次排完:分数降序、同分按名字
items.sort(key=lambda x: x.name) # 或者排两次,先次要后主要
items.sort(key=lambda x: -x.score)
几个实用的点:
- 不要写
cmp风格的比较函数,Python 3 只接受key;实在需要就用functools.cmp_to_key。 - 按多个字段排序时用元组,不要拼字符串。
- 大数据量只要前 k 个:
heapq.nlargest(k, data)比sorted(data)[:k]省内存。 - 快速选择(
quickselect)平均 O(n) 找第 k 大,最坏 O(n²)(随机选枢轴可以避免)。面试问"O(n) 找第 k 大"时说的就是它。
贪心:排序 + 一遍扫描¶
# 贪心:先按某个关键字排序,再一遍扫过去。难的不是代码,是证明"为什么这么选是对的"
def merge_intervals(intervals):
"""合并重叠区间:按起点排序,能接上就扩展右端点"""
out = []
for start, end in sorted(intervals):
if out and start <= out[-1][1]:
out[-1][1] = max(out[-1][1], end)
else:
out.append([start, end])
return out
def max_non_overlapping(intervals):
"""最多能选几个互不重叠的区间:按**终点**排序,能选就选"""
count, last_end = 0, float("-inf")
for start, end in sorted(intervals, key=lambda t: t[1]):
if start >= last_end:
count += 1
last_end = end
return count
def min_rooms(meetings):
"""同时进行的最大数量(需要几个会议室 / 几个并发槽位):把端点排序后扫描"""
events = sorted([(s, 1) for s, _ in meetings] + [(e, -1) for _, e in meetings])
cur = best = 0
for _, delta in events: # 同一时刻先处理结束(-1 排在 +1 前面)
cur += delta
best = max(best, cur)
return best
data = [[1, 3], [2, 6], [8, 10], [15, 18]]
print("区间:", data)
print("合并后:", merge_intervals(data))
print("最多能选几个互不重叠:", max_non_overlapping(data))
print("需要几个并发槽位:", min_rooms(data))
print()
print("三道题排序的关键字不同:合并按起点,选最多按终点,算并发按端点扫描。")
print("按起点选最多是错的——先来的可能很长,挡住后面好几个短的。")
print()
# 推理系统里的例子:按输出长度排序能减少"批次里的浪费"
reqs = [("A", 500), ("B", 20), ("C", 480), ("D", 30)]
for name, order in [("按到达顺序", reqs), ("按预估长度分组", sorted(reqs, key=lambda t: t[1]))]:
batches = [order[:2], order[2:]]
waste = sum(max(x[1] for x in b) * len(b) - sum(x[1] for x in b) for b in batches)
print(f"{name}:分两批 {[[x[0] for x in b] for b in batches]},浪费的 token 槽位 {waste}")
区间: [[1, 3], [2, 6], [8, 10], [15, 18]]
合并后: [[1, 6], [8, 10], [15, 18]]
最多能选几个互不重叠: 3
需要几个并发槽位: 2
三道题排序的关键字不同:合并按起点,选最多按终点,算并发按端点扫描。
按起点选最多是错的——先来的可能很长,挡住后面好几个短的。
按到达顺序:分两批 [['A', 'B'], ['C', 'D']],浪费的 token 槽位 930
按预估长度分组:分两批 [['B', 'D'], ['C', 'A']],浪费的 token 槽位 30
贪心题的解法通常就是"按某个关键字排序,然后一遍扫过去",难点全在按什么排:
| 题型 | 排序关键字 | 直觉 |
|---|---|---|
| 合并重叠区间 | 起点 | 能接上就扩展右端点 |
| 最多不重叠区间 / 最少箭数 | 终点 | 越早结束,给后面留的空间越多 |
| 会议室数量 / 最大并发 | 端点扫描(差分) | 进一个 +1、出一个 -1 |
| 最少平台数、任务调度 | 堆 + 时间顺序 | 每次复用最早空闲的资源 |
| 分发饼干、分配任务 | 两个数组都排序 | 双指针配对 |
| 加油站、跳跃游戏 | 一遍扫描维护"能到的最远处" | 局部可达性 |
最后那个例子是推理系统里的真实问题:同一批请求的输出长度差异越大,浪费越多。一个批次要跑到最长的那个请求结束,短请求的槽位就空着(连续批处理会及时替换掉完成的请求,但一步之内仍然是对齐的)。所以按预估长度分组、或者把长请求单独成批,能明显提高吞吐——这和"装箱问题"是同一类贪心。大规模 MoE 部署里的专家负载均衡(EPLB)也是装箱:把专家分配到卡上,使最重的卡尽量轻(见大规模 EP 部署)。
贪心要么能证明,要么就是错的。 常用的证明手法是交换论证:假设存在一个最优解和我的选择不同,把它换成我的选择后不会变差。证不出来就老实用动态规划——零钱兑换(面额 [1, 3, 4] 凑 6)就是贪心翻车的经典例子。
这一章的题在考什么¶
| 看到这些字眼 | 往哪想 |
|---|---|
| 第 k 大、前 k 个、频率最高 | 堆(O(n log k))或快速选择(O(n)) |
| 合并 k 个有序、外排序 | 多路归并(堆) |
| 区间、重叠、合并、最少删除 | 排序 + 贪心(注意按起点还是终点) |
| 会议室、并发数、最大重叠 | 端点排序扫描 / 差分 |
| 任务分配、最早完成、最少机器 | 堆模拟 + 贪心(LPT) |
| 装箱、负载均衡、分组 | 贪心近似(最大剩余、最长优先) |
| 自定义排序、多关键字 | key=lambda x: (-a, b) |
怎么讲清楚
Top-K 先给三种解法和复杂度:"排序 O(n log n)、大小为 k 的堆 O(n log k)、快速选择平均 O(n)",再说场景(数据流只能用堆、要全序就排序)。讲贪心必须主动说清楚按什么排序和为什么:合并区间按起点、选最多不重叠按终点(交换论证:越早结束越好)。heapq 的细节(只有小顶堆、元组里要加序号防止比较对象)是很好的加分点。能把题目映射到工程会更好:Top-K 对应采样、多路归并对应外排序与分布式结果合并、装箱对应专家负载均衡、按输出长度分组对应减少批次里的浪费。最后别忘了说"贪心要能证明,证不出来就上动态规划"。
小结¶
- Top-K 用大小为 k 的小顶堆 O(n log k),只要第 k 大可以用快速选择 O(n) 平均。
-
heapq只有小顶堆(取负得大顶堆);元组比较要加序号防止比较对象本身。 - 多路归并用堆,是外排序和分布式结果合并的基础。
- 贪心 = 排序 + 一遍扫描;合并区间按起点、选最多不重叠按终点、算并发用端点扫描。
- 调度用堆取"最早空闲",装箱用最长优先;推理里对应请求分组与专家负载均衡。
- 贪心要能证明(交换论证),证不出来就用动态规划。
本章练习题(8 道)
在浏览器里直接写代码、跑测试;也可以在本地用 python practice/judge.py 判题。
- 274. 估算:批次里的浪费(简单)
- 275. 估算:KV 块与并发数(简单)
- 276. 跳跃游戏(中等)
- 277. 数据流里的第 k 大与中位数(中等)
- 278. 区间三题(中等)
- 279. 划分字母区间与合并账户(中等)
- 280. 任务调度与装箱(中等)
- 281. Top-K:堆与快速选择(中等)
发现错误或有更好的写法?在 GitHub 上编辑此页提交问题