3.6 顺序统计量:选择比排序容易
这一节,你能解决什么问题
学完这一节,你能够:
- 理解"选择比排序容易"的核心洞察,知道找第 k 小只需要 Θ(n),而排序需要 Θ(n log n)
- 分析随机选择和确定性选择的复杂度,理解"期望 vs 最坏"的权衡
- 判断什么时候用选择而不是排序,避免"过度求解"浪费
问题情境
某数据分析平台需要实时计算数据流的中位数。
工程师小刘的实现:
def median(data):
sorted_data = quicksort(data) # Θ(n log n)
return sorted_data[len(data) // 2]同事看到后问:"为什么每次计算中位数都要排序?你只需要一个元素的位置,为什么要给出所有元素的顺序?"
问题:计算中位数需要 Θ(n log n) 吗?
答案:不需要。选择问题可以 Θ(n)。
直观思路
先问自己一个问题:我需要什么信息?
排序:给出全部顺序信息 → Θ(n log n) 选择:给出局部位置信息 → Θ(n)
为什么差距这么大?
排序要确定 n 个元素的相对顺序。n! 种排列,需要 lg(n!) ≈ n log n bit 信息。
选择只要确定一个元素的位置——"左边有多少个元素"。只有 n 种可能位置,lg n bit 信息。
关键洞察:信息量差距 n 倍 → 复杂度差距也是。
规范定义
顺序统计量
定义:第 k 小的元素(第 1 小是最小值,第 n 小是最大值,第 ⌈n/2⌉ 小是中位数)
选择问题:给定 n 个元素,找到第 k 小的元素
复杂度对比
| 问题 | 复杂度 | 信息需求 |
|---|---|---|
| 排序全部元素 | Θ(n log n) | 全部顺序信息 |
| 找第 k 小 | Θ(n) | 一个位置信息 |
| 找最小值 | Θ(n) | 最小是哪个 |
| 同时找 min 和 max | Θ(⌈3n/2⌉ - 2) | min 和 max |
算法实现
随机选择:期望 Θ(n)
类似快排的分区,但只递归一边。
def randomized_select(A, p, r, k):
if p == r:
return A[p]
q = partition(A, p, r) # 分区
i = q - p + 1 # pivot 的排名
if k == i: # pivot 就是第 k 小
return A[q]
elif k < i: # 第 k 小在左边
return randomized_select(A, p, q-1, k)
else: # 第 k 小在右边
return randomized_select(A, q+1, r, k-i)一步步执行:
找 [3, 2, 9, 0, 7, 5, 4, 8, 6, 1] 的第 4 小
Step 1:随机 pivot = 5(索引 5)
分区:[3, 2, 0, 4, 1 | 5 | 9, 7, 8, 6]
pivot 排名 = 6
第 4 小 < 第 6 小 → 在左边 [3, 2, 0, 4, 1]
Step 2:在左边找第 4 小
pivot = 2
分区:[0, 1 | 2 | 3, 4]
pivot 排名 = 3
第 4 小 > 第 3 小 → 在右边 [3, 4],找第 (4-3) = 1 小
Step 3:在 [3, 4] 找第 1 小
pivot = 3,排名 1 → 找到!
答案:第 4 小是 3确定性选择(BFPRT):最坏 Θ(n)
用"5 元组"技巧,保证 pivot 不极端。
def bfprt(A, k):
# 1. 分组:每组 5 个
groups = [A[i:i+5] for i in range(0, len(A), 5)]
# 2. 找每组中位数
medians = [median_of_5(g) for g in groups]
# 3. 找中位数的中位数(作为 pivot)
pivot = bfprt(medians, len(medians)//2)
# 4. 分区
# 5. 递归一边这个方法是怎么想到的
随机选择的思路
问题:怎么快速找到第 k 小?
朴素想法:排序后取第 k 个 → Θ(n log n)
洞察:类似快排分区,但只递归一边。
关键:分区后,pivot 的位置告诉我们第 k 小在哪一边。
确定性选择的思路
问题:随机选择最坏 Θ(n²)。怎么保证最坏 Θ(n)?
洞察:保证 pivot 不极端 → 递归规模保证缩小。
为什么是 5?
分组大小影响递归规模:
| 分组大小 | 系数和 | 是否收敛 | 复杂度 |
|---|---|---|---|
| 3 | 1/3 + 2/3 = 1 | 不收敛 | Θ(n log n) |
| 5 | 1/5 + 7/10 = 0.9 | 收敛 | Θ(n) |
| 7 | 1/7 + 9/14 ≈ 0.78 | 收敛 | Θ(n) |
分组 5 是最小的可行选择。
正确性分析
随机选择:期望 Θ(n)
为什么期望 Θ(n)?
快排递归两边:期望深度 Θ(log n),总时间 Θ(n log n)。
选择只递归一边:期望总规模 = n + n/2 + n/4 + ... = Θ(2n)。
关键:只递归一边,总规模是几何级数。
确定性选择:最坏 Θ(n)
为什么最坏 Θ(n)?
分组 5 保证:
- 至少 3n/10 个元素 ≤ pivot
- 至少 3n/10 个元素 ≥ pivot
递归规模 ≤ 7n/10。
T(n) ≤ T(n/5) + T(7n/10) + Θ(n)
= Θ(n) (系数和 0.9 < 1)复杂度分析
| 方法 | 期望时间 | 最坏时间 | 适用场景 |
|---|---|---|---|
| 随机选择 | Θ(n) | Θ(n²) | 一般场景 |
| BFPRT | Θ(n) | Θ(n) | 需要最坏保证 |
什么时候不该用这个方法
该用选择的情况
- 找第 k 小(单个位置信息)
- 找中位数
- 找最小值或最大值
- Top-k 且 k << n
该用排序的情况
- 需要全部顺序信息
- 多次查询不同位置
- 需要稳定性(选择算法不稳定)
本节小结
解决了什么问题?
找第 k 小元素,Θ(n) 而不是 Θ(n log n)。
核心方法是什么?
- 随机选择:分区 + 递归一边
- 确定性选择:分组 5 + 中位数的中位数
为什么正确?
分区保证 pivot 在正确位置。递归缩小规模。
代价是什么?
- 随机选择:期望 Θ(n),最坏 Θ(n²)
- 确定性选择:最坏 Θ(n),但常数大
适用场景?
需要单个位置信息,不需要全部顺序。
练习
基本理解
练习 1:对 [3, 2, 9, 0, 7, 5, 4, 8, 6, 1],找中位数(第 5 小),手动执行随机选择。
练习 2:为什么分组 3 不能保证线性时间?写出递归方程验证。
方法应用
练习 3:同时求 min 和 max。
朴素方法:逐个比较,比较次数 2(n-1)。
优化方法:成对比较,比较次数 ⌈3n/2⌉ - 2。
为什么成对比较更高效?写出分析。
错误诊断
练习 4:以下实现有什么问题?
def select_bug(A, k):
q = partition(A, 0, len(A)-1)
if k == q:
return A[q]
elif k < q:
return select_bug(A[:q], k) # ← 这里
else:
return select_bug(A[q+1:], k) # ← 这里分析:递归范围和 k 的处理是否正确?
方案比较
练习 5:场景选择。
| 场景 | 应该用什么? | 理由 |
|---|---|---|
| 找最小值 | ||
| 找中位数 | ||
| 找第 10 小(查询一次) | ||
| 找第 k 小(查询 100 次,不同 k) | ||
| Top-50(n = 10^6) |
开放设计
练习 6:数据流中位数。
设计一个 Skill,实时计算数据流的中位数:
- 支持动态插入
- 支持快速查询中位数
- 分析复杂度
提示:可以用两个堆(最大堆 + 最小堆)。
上一节:3.5 线性时间排序
下一节:3.7 哈希与随机化