AIGC标识 分治法求第k小元素

分治法实战:寻找第 k 小的数 —— 算法描述、复杂度分析与分治思想漫谈

前言

"在一个无序数组中找出第 k 小的数"是算法课和面试中的经典问题。最直观的想法是先排序再取下标,时间复杂度为 $O(n \log n)$。但借助分治法,我们可以把平均时间复杂度降到 $O(n)$——这就是本文的主角 快速选择算法(Quickselect)。本文将用自然语言和伪代码完整描述该算法,分析其最好与最坏时间复杂度,并结合算法分析与设计课程中分治一章的内容,谈谈我对分治法的体会和思考。

一、问题定义

给定一个含 $n$ 个元素的无序数组 $A$ 和一个整数 $k$($1 \le k \le n$),求数组中第 $k$ 小的元素,即如果把数组升序排序后位于下标 $k-1$ 的那个元素。例如数组 [7, 10, 4, 3, 20, 15],$k = 3$ 时答案为 $7$(排序后为 [3, 4, 7, 10, 15, 20])。

二、算法思路:自然语言描述

快速选择算法的核心思想与快速排序一脉相承,都是分治法的应用,但有一个关键区别:快速排序划分后要递归处理两侧子问题,而快速选择划分后只需要递归处理一侧——这正是不需要的那半边可以被直接丢弃的原因。

自然语言描述如下:

  1. 分解(Divide):从数组(的当前区间)中选取一个元素作为基准值(pivot),将区间划分为三部分:所有小于基准的元素放在左边,等于基准的放在中间,大于基准的放在右边。设划分后小于基准的元素个数为 $L$,等于基准的元素个数为 $E$。
  2. 解决(Conquer):比较 $k$ 与划分结果的位置关系,有三种情况:
    • 若 $k \le L$:第 $k$ 小的元素必然落在左半部分,只在左半部分递归查找第 $k$ 小的元素;
    • 若 $L < k \le L + E$:基准值本身就是第 $k$ 小的元素,直接返回基准值;
    • 若 $k > L + E$:第 $k$ 小的元素必然在右半部分,只在右半部分递归查找第 $k - L - E$ 小的元素。
  3. 合并(Combine):本问题无需合并步骤——递归调用返回的结果就是原问题的答案。
  4. 递归出口:当区间只剩一个元素时,该元素即为所求,直接返回。

可以看到,算法的精髓在于:每次划分后,根据基准值的最终排名,可以断定答案只可能存在于某一侧,另一侧整个被剪掉。 问题规模每轮缩小,直到命中答案。

三、伪代码描述

算法 QuickSelect(A, left, right, k)
输入:数组 A,查找区间 [left, right],目标序号 k(全局第 k 小)
输出:A 中第 k 小的元素

QuickSelect(A, left, right, k):
    if left == right:                      // 递归出口:区间只有一个元素
        return A[left]

    pivot ← ChoosePivot(A, left, right)    // 选择基准值(如随机选取)
    mid ← Partition(A, left, right, pivot) // 划分:小于 pivot 的在左,大于的在右
                                           // mid 是 pivot 划分后的最终下标
    rank ← mid - left + 1                  // pivot 在当前区间内的名次(第几小)

    if k == rank:
        return A[mid]                      // 命中:基准就是第 k 小
    else if k < rank:
        return QuickSelect(A, left, mid - 1, k)        // 只递归左半部分
    else:
        return QuickSelect(A, mid + 1, right, k - rank) // 只递归右半部分,k 相应调整

Partition(A, left, right, pivot):          // Lomuto 划分方案
    将 pivot 交换到 A[right]
    storeIndex ← left
    for i ← left to right - 1:
        if A[i] < pivot:
            交换 A[i] 与 A[storeIndex]
            storeIndex ← storeIndex + 1
    交换 A[storeIndex] 与 A[right]
    return storeIndex

执行示例:数组 [7, 10, 4, 3, 20, 15],$k = 3$。

  • 第一轮:选 pivot = 7,划分后为 [4, 3, |7|, 10, 20, 15],pivot 名次为 3,恰等于 $k$,返回 7。

若 pivot 名次是 5 而 $k = 3$,则左半部分 [4, 3, ...](4 个元素)必然包含答案,右半部分连同 pivot 一起被丢弃,递归进入更小的子问题。

四、时间复杂度分析

4.1 分析方法

设 $T(n)$ 为处理 $n$ 个元素的时间。划分操作需扫描整个区间,耗时 $\Theta(n)$;划分后只递归一侧。因此递归式为:

$$T(n) = T(\text{一侧规模}) + \Theta(n)$$

关键在于"一侧规模"有多大,而这完全取决于基准值的选择运气。

4.2 最好时间复杂度:$O(n)$

最好情况:每次划分都恰好把区间对半分(基准值恰好是中位数)。此时递归式为:

$$T(n) = T(n/2) + cn$$

由主定理($a=1,\ b=2,\ f(n)=\Theta(n)$,属于第三种情形,$f(n)$ 渐近大于 $n^{\log_2 1} = 1$)可得 $T(n) = \Theta(n)$。直观地看,各层工作量构成等比数列:

$$n + \frac{n}{2} + \frac{n}{4} + \cdots \le 2n$$

总工作量不超过 $2n$,即 $\Theta(n)$。这是一个漂亮的结论:即使每一步都要花线性时间划分,由于规模按几何级数衰减,总时间仍是线性的。

4.3 平均时间复杂度:$O(n)$

随机选取基准值时,可以证明(通过指示随机变量或递归树分析)期望时间复杂度为 $\Theta(n)$。这就是实践中快速选择优于"排序后取下标"($O(n \log n)$)的原因。

4.4 最坏时间复杂度:$O(n^2)$

最坏情况:每次划分都极度不平衡——基准值恰好是当前区间的最小值(或最大值),一侧为空,另一侧只比原区间少一个元素。例如数组已升序排列,而我们每次固定取第一个元素作基准。此时:

$$T(n) = T(n-1) + cn = c\big(n + (n-1) + \cdots + 1\big) = \Theta(n^2)$$

各层工作量构成等差数列 $n + (n-1) + \cdots + 1 = \frac{n(n+1)}{2}$,退化成了"每层都几乎白干"的局面——本质上与快速排序在有序数组上的退化是同一回事。

4.5 如何避免最坏情况

  • 随机化选基准(Randomized Quickselect):随机选取 pivot,使最坏情况的出现仅取决于随机数而非输入,任何固定输入都无法稳定触发退化,期望复杂度保持 $\Theta(n)$。这是工程实践的标准做法(C++ 的 std::nth_element 即是此类实现)。
  • BFPRT 算法(中位数的中位数,Median of Medians):将元素每 5 个分组,取各组中位数,再递归求这些中位数的中位数作为基准。可以证明这样选出的基准至少能淘汰 $30%$ 的元素,保证每次划分规模缩小到不超过 $\frac{7n}{10}$,递归式 $T(n) \le T(n/5) + T(7n/10) + \Theta(n)$ 解得 $\Theta(n)$,从而获得最坏情况线性时间的保证。不过由于常数较大,实践中通常仍以随机化版本为主。

4.6 复杂度小结

情况 划分特征 递归式 时间复杂度
最好 每次均分 $T(n) = T(n/2) + \Theta(n)$ $\Theta(n)$
平均(随机基准) 期望划分较均衡 期望分析 $\Theta(n)$
最坏 每次最不平衡 $T(n) = T(n-1) + \Theta(n)$ $\Theta(n^2)$
最坏(BFPRT 优化) 每次至少淘汰 30% $T(n/5) + T(7n/10) + \Theta(n)$ $\Theta(n)$

五、对分治法的体会与思考

结合本章的学习,我想从以下几个方面谈谈对分治法的理解。

5.1 分治的本质:不是"分",而是"减"

分治法的三个步骤——分解、解决、合并——中,最容易被忽视的是:划分必须带来实质性的规模缩减,且子问题的解能组合出原问题的解。快速选择给我的最大启发是"剪枝式的分治":划分之后并非所有子问题都需要求解。通过基准值名次这一信息,我们证明了另一半问题不可能包含答案,从而放心地丢弃它。这提示我,分治的威力不仅在于"化整为零",更在于划分过程中获得的额外信息(比如 pivot 的最终位置),善加利用可以省去大量无用功。二分查找其实也是同一思想的极端形式:每轮比较后,一半的数据被确定性排除。

5.2 递归式的平衡决定效率上限

对比快速选择的最好情况 $\Theta(n)$ 与最坏情况 $\Theta(n^2)$,差距全部来自递归树的形状:平衡时树高 $O(\log n)$ 且每轮规模几何衰减;失衡时退化为一条长度为 $n$ 的链。归并排序之所以稳定保持 $O(n \log n)$,正是因为它无条件均分;而快速排序/快速选择把平衡寄托在基准的选取上,换来更小的常数和更好的局部性,代价是存在退化风险。这让我体会到,算法设计中处处是权衡:确定性保证与平均性能、时间复杂度与常数因子之间没有免费的午餐。工程上"随机化 + 期望分析"是化解最坏情况的优雅手段,把"输入的恶意"转化为"随机数的运气"。

5.3 主定理是分析工具,更是设计指南

本章的主定理(Master Theorem)给出了递归式 $T(n) = aT(n/b) + f(n)$ 的三种情形判别。初学时觉得它只是解题公式,深入后才意识到它反向指导着算法设计:

  • 想获得好的复杂度,就要让子问题个数 $a$、规模缩减因子 $b$ 与分解/合并开销 $f(n)$ 三者形成有利的组合;
  • 归并排序 $a=b=2,\ f(n)=\Theta(n)$ 落在第二种情形得到 $\Theta(n\log n)$;
  • 快速选择 $a=1,\ b=2,\ f(n)=\Theta(n)$ 落在第三种情形得到 $\Theta(n)$——子问题只有 1 个是它优于排序的根本原因。

5.4 分治是一种普适的思维方式

跳出算法题,分治思想在计算机科学中无处不在:MapReduce 把海量数据计算分片到集群再汇总结果;数据库的 B+ 树索引本质是把查找范围逐层二分;甚至调试代码时的"二分定位法"、排查线上问题时按模块切分嫌疑范围,都是分治的变体。学好这一章,收获的不只是几个经典算法,而是一种面对复杂问题时"先切分、再归约、后组合"的思维范式。

5.5 对实践的启示

最后一点体会:理论分析要与工程实践互相印证。BFPRT 虽然把最坏情况压到线性,却因常数过大而少有人用;随机化快速选择理论上存在 $O(n^2)$ 的最坏情况,实践中却几乎无敌。这提醒我,大 O 记号刻画的是渐近增长趋势,而非全部真相——常数因子、缓存友好性、实现复杂度同样是算法选型的重要维度。分治一章教会我的,正是用严格的数学工具分析问题,同时保持对实际场景的敏感。

结语

寻找第 k 小元素的快速选择算法是分治法的一堂生动的示范课:一次划分、信息判断、舍弃半边,平均 $O(n)$ 解决问题。它上承二分查找的"减而治之",下接快速排序的划分思想,配合主定理的复杂度分析和随机化、BFPRT 等优化手段,完整展现了算法设计与分析的闭环。理解它,也就理解了分治法"分而治之、减而速之"的真义。

延伸阅读:

  • Hoare, C. A. R. "Algorithm 65: Find." Communications of the ACM, 1961(快速选择原始论文)
  • Blum, Floyd, Pratt, Rivest, Tarjan. "Time bounds for selection." JCSS, 1973(BFPRT 算法)
  • 《算法导论》第 9 章:中位数与顺序统计量
posted @ 2026-10-13 20:46  Heassw  阅读(4)  评论(0)    收藏  举报