快速选择 (Quickselect)
快速选择:懒惰的艺术
算法背后的故事:侦探与名次
想象你是一名在一场 50,000 人马拉松比赛现场的侦探。你并不关心所有人的最终排名,你只想找到那个恰好第 100 名冲过终点线的人。
你可以等待官方把 50,000 人的成绩全部排好序,但这可能需要几个小时。
相反,你随机挑选了一名选手——我们叫他 基准点 (Pivot)。你问所有人:“你是在这个人之前还是之后冲过终点线的?” 突然间,你发现这名基准点选手恰好是第 500 名。 这是个绝佳的消息!你瞬间知道,那个第 100 名肯定在“更快”的那一组人里。现在,你可以彻底无视掉那个“更慢”组里的 49,500 人了。
这就是 快速选择 (Quickselect),Tony Hoare 快速排序的一个变种。它是寻找特定排名最聪明的方法,因为它拒绝做任何非绝对必要的工作。
为什么需要它?
快速选择是寻找 统计数据 和 TopK 项的核心引擎。
- 中位数: 在不排序整个群体的情况下,找到数据集的中间值(例如家庭收入中位数)。
- 排行榜: “告诉我得分第 10 高的玩家是谁。”
- 性能: 排序全部数据需要 ,而快速选择在平均情况下仅需 的线性时间。
算法是如何“思考”的
这个算法像是一个目标极其明确的旅行者,它从不回头。
分区 (Divide): 就像快速排序一样,它挑选一个基准点,并将列表分为“较小”和“较大”两部分。
现实检查: 它观察“较小”组的规模。
- 第 K 個元素在这一组吗?太好了,扔掉大的那一半。
- 基准点本身就是第 K 個元素吗?恭喜,你找到了!
- 第 K 個元素在“较大”组吗?扔掉小的那一半,继续。
单行道: 这就是“懒惰”的部分。快速排序会递归处理两边。而快速选择只递归处理其中一边。它本质上是在一个已分区数组上的二分查找。
工程决策: 的幻影
快速选择在平均情况下极快,但它也有一个“最坏情况”的噩梦。
- 陷阱: 如果你每次都选到最差的基准点(例如在找最大值时选到了最小值),速度会降到 。
- 防护: 在生产环境(如 C++ 的
std::nth_element)中,工程师会使用 Introselect 算法——它以快速选择开始,如果检测到分区效率太低,则自动切换到堆排序。
实现参考 (Python)
import random
def quickselect(arr, k):
"""
寻找第 k 小的元素(索引从 0 开始)。
"""
if len(arr) == 1:
return arr[0]
# 1. 选拔:随机挑选基准点,避免 O(N^2) 陷阱
pivot = random.choice(arr)
# 2. 分区
lows = [x for x in arr if x < pivot]
highs = [x for x in arr if x > pivot]
pivots = [x for x in arr if x == pivot]
# 3. 决策
if k < len(lows):
# 目标在小值组
return quickselect(lows, k)
elif k < len(lows) + len(pivots):
# 基准点本身就是我们要找的人!
return pivots[0]
else:
# 目标在大值组
# 调整 k 的值,因为我们跳过了较小的组
return quickselect(highs, k - len(lows) - len(pivots))
# 使用示例
# data = [3, 2, 1, 5, 4]
# print(quickselect(data, 2)) # 寻找第 3 小(索引为 2) -> 输出: 3小结
快速选择教会我们:为了找到一个答案,你不需要整理整个世界。通过激进地忽略那些无关的数据,我们可以在一个充满对数复杂度的世界里实现线性级的速度。它提醒我们,在工程艺术中,知道该忽略什么与知道该修复什么同样重要。
