Luke a Pro

Luke Sun

Developer & Marketer

🇺🇦
EN||

快速选择 (Quickselect)

| , 3 minutes reading.

快速选择:懒惰的艺术

算法背后的故事:侦探与名次

想象你是一名在一场 50,000 人马拉松比赛现场的侦探。你并不关心所有人的最终排名,你只想找到那个恰好第 100 名冲过终点线的人。

你可以等待官方把 50,000 人的成绩全部排好序,但这可能需要几个小时。

相反,你随机挑选了一名选手——我们叫他 基准点 (Pivot)。你问所有人:“你是在这个人之前还是之后冲过终点线的?” 突然间,你发现这名基准点选手恰好是第 500 名。 这是个绝佳的消息!你瞬间知道,那个第 100 名肯定在“更快”的那一组人里。现在,你可以彻底无视掉那个“更慢”组里的 49,500 人了。

这就是 快速选择 (Quickselect),Tony Hoare 快速排序的一个变种。它是寻找特定排名最聪明的方法,因为它拒绝做任何非绝对必要的工作。

为什么需要它?

快速选择是寻找 统计数据TopK 项的核心引擎。

  • 中位数: 在不排序整个群体的情况下,找到数据集的中间值(例如家庭收入中位数)。
  • 排行榜: “告诉我得分第 10 高的玩家是谁。”
  • 性能: 排序全部数据需要 O(NlogN)O(N \log N),而快速选择在平均情况下仅需 O(N)O(N) 的线性时间。

算法是如何“思考”的

这个算法像是一个目标极其明确的旅行者,它从不回头。

  1. 分区 (Divide): 就像快速排序一样,它挑选一个基准点,并将列表分为“较小”和“较大”两部分。

  2. 现实检查: 它观察“较小”组的规模。

    • 第 K 個元素在这一组吗?太好了,扔掉大的那一半。
    • 基准点本身就是第 K 個元素吗?恭喜,你找到了!
    • 第 K 個元素在“较大”组吗?扔掉小的那一半,继续。
  3. 单行道: 这就是“懒惰”的部分。快速排序会递归处理两边。而快速选择只递归处理其中一边。它本质上是在一个已分区数组上的二分查找。

工程决策:O(N)O(N) 的幻影

快速选择在平均情况下极快,但它也有一个“最坏情况”的噩梦。

  • 陷阱: 如果你每次都选到最差的基准点(例如在找最大值时选到了最小值),速度会降到 O(N2)O(N^2)
  • 防护: 在生产环境(如 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

小结

快速选择教会我们:为了找到一个答案,你不需要整理整个世界。通过激进地忽略那些无关的数据,我们可以在一个充满对数复杂度的世界里实现线性级的速度。它提醒我们,在工程艺术中,知道该忽略什么与知道该修复什么同样重要。