☰
从分治到减治:快速选择算法如何在线性时间找到第K小元素?
2026/10/13 13:27:07 网站建设 项目流程

如果只允许用一个词概括我在算法这条路上最晚才真正想明白的道理,我会选"次序"。分而治之这四个字谁都会解释——拆成子问题、递归解决、合并结果。可同一个问题,先处理哪个子问题、决定往哪一侧继续深入,这些次序上的差别,能把复杂度差出一个数量级。这篇"次序选择"要聊的,正是分治思想里一个非常经典的问题:在一个无序数组里找出第 K 小的元素,也就是按数值次序排在第 K 位的那个数。

这个问题乍看简单,排序后按下标取就行。但多问一句就很有意思:我们真的有必要把十万个元素全部排好序,才能知道第 500 个最小的是谁吗?这个问题能把人从"全排序"的惯性思维里拽出来,也正好引出分而治之的一个特殊形态——只分一支、只治一半的减治思想。不管你是刚开始刷算法题的初学者,还是已经在写生产代码的工程师,这篇文章都值得花几分钟读一遍:它讲的不是某个偏门技巧,而是一条贯穿二分查找、快速选择、TopK 问题的通用主线。

1. 选择问题:一个排序都能解,但值得较真的题目

先给出形式化描述:给定长度为 n 的数组 A 和一个下标 k(0 ≤ k < n),要求在无序数组中找出按从小到大次序排在第 k+1 位的元素。这个"排在第几位"就是"次序"的含义——我们不关心元素的具体值分布,只关心它在全局顺序中的名次。典型场景遍地都是:统计一批请求延迟的中位数(k = n/2)、从十万条搜索词里挑出热度第 300 名、计算成绩分布的 30 百分位线。这些问题的本质都是同一个:给定一个次序位置,找出对应的元素。

最朴素的做法是排序。C++ 里 sort 一下取 A[k],Python 里sorted(A)[k],一行代码就结束了。排序本身当然没有错,但这里藏着一个容易被忽略的浪费:排序会把所有元素的位置关系全部理顺,而我们只想要其中一个位置。打个比方,这就好比你只是想从一堆人里找出个子第二高的那位,却让整个队伍按身高从头到尾排了一遍。排序在这个问题上的代价是 O(n log n) 次比较,但其中绝大多数比较结果,和最终答案没有任何关系。

那为什么 O(n log n) 不够好?当数据量只有几百几千时,差别确实无所谓;可数据量一旦上到几百万、几千万,log n 这个因子就变成了实打实的成本。一千万个元素,排序大约需要两亿多次比较,而线性算法只需要约一千万次。在实时推荐、广告排序、中间件监控这类对延迟敏感的场景里,这个差距足够让一个接口从"勉强可用"变成"需要扩容"。所以,线性时间的次序选择不是炫技,而是硬需求。

顺着这个诉求再回头看分而治之,会发现一个关键点:分治的"治"阶段,未必一定要把拆出来的子问题全部解决。如果我们能通过一次划分,搞清楚答案落在哪一侧,那么另一侧就完全可以丢弃。这是一种"偏科"的分治——只选需要的那条分支继续深入。二分查找是这么干的,接下来要讲的 QuickSelect 也是这么干的。这里"次序"的第二层含义就出来了:不仅元素有次序,我们对子问题的处理也有次序,先确定哪一侧有答案,再决定往哪边走。

这也是面试官们喜欢考这个题的原因。它不是考你会不会背快排,而是考你对分治本质的理解:什么时候必须两边递归,什么时候剪掉一侧还保证正确。很多人能把快排默写下来,却说不清快排和快速选择的复杂度为什么会差一个 log n。搞明白这个差别,比记住十个模板题都有价值。

2. 核心算法 QuickSelect:划分一次,只深入一侧

2.1 一次划分到底做了什么

QuickSelect 的地基是划分(partition)。划分操作选一个基准值 pivot,把数组原地重排成三段:小于等于 pivot 的靠左,pivot 挪到中间某个位置,大于 pivot 的靠右。划分完成后最关键的性质是:pivot 最终停留的下标,就是它在这个数组全部排好序时应该待的位置。这个性质看起来平平无奇,却是整个算法的定海神针——只要 pivot 落定的下标恰好等于 k,那这个值就是第 k+1 小的元素,直接返回即可。

实现划分的方式有很多,我最常用的是单游标法:基准取区间最右元素,从左往右扫一遍,凡是遇到小于等于 pivot 的元素,就把它换到"左区"边界处,最后再把 pivot 换到左区边界。整个过程中,左半区始终维护着"已经确认不大于 pivot"的元素集合,右半区则是尚未检查的元素。代码如下:

def partition(arr, left, right): pivot = arr[right] i = left for j in range(left, right): if arr[j] <= pivot: arr[i], arr[j] = arr[j], arr[i] i += 1 arr[i], arr[right] = arr[right], arr[i] return i

这段单游标划分的时间复杂度是 O(n),额外空间是 O(1),因为它只用了一个额外的交换动作,没有申请任何辅助数组。这里有一个细节值得注意:比较用的是 <= 而不是 <。这个选择在普通快速排序里影响不大,但在 QuickSelect 里会直接影响递归方向的正确性,尤其是在重复元素很多的数组上:如果改成 <,小于等于区的边界会变得难以界定,pivot 的落点位置也可能出现歧义,最终导致返回的元素不是真正排在第 k 位的值。具体原因我留到第 5 节专门展开。

2.2 递归只追一个方向

有了划分函数,QuickSelect 的主流程就很简单:划分一次,比较 k 和 pivot 落点的关系,决定递归哪一侧。

import random def quickselect(arr, k): if not arr or k < 0 or k >= len(arr): raise ValueError("invalid k") return _quickselect(arr, 0, len(arr) - 1, k) def _quickselect(arr, left, right, k): if left == right: return arr[left] # 随机化:把随机选中的元素换到最右侧再划分 rand_idx = random.randint(left, right) arr[rand_idx], arr[right] = arr[right], arr[rand_idx] pivot_idx = partition(arr, left, right) if k == pivot_idx: return arr[k] elif k < pivot_idx: return _quickselect(arr, left, pivot_idx - 1, k) else: return _quickselect(arr, pivot_idx + 1, right, k)

整个算法的灵魂在最后三行判断上。如果 k 小于 pivot 落点下标,说明答案在左区,右区整块丢弃;如果 k 大于 pivot 落点下标,说明答案在右区,左区整块丢弃;如果相等,直接返回。每次递归都会丢掉一大块确定无用的区间,规模缩减非常激进。

用一个简单例子走一遍:arr = [7, 3, 9, 2, 8, 1, 5],找第 3 小的元素,即 k = 2。第一轮划分选择 5 作为基准,划分后数组变成[3, 2, 1, 5, 8, 9, 7],5 落在下标 3。k = 2 小于 3,所以答案一定在左半部分[3, 2, 1]里。第二轮划分选 1 作基准,划分后得到[1, 2, 3],1 落在下标 0。k = 2 大于 0,于是把 k 更新为2 - 0 - 1 = 1,去右半部分[2, 3]里找。第三轮选 3 作基准,落点下标恰好等于更新后的 k = 1,返回 3。整个数组排序后的第 3 小元素确实是 3。

这种"只追一侧"的分治,在算法理论里有个专门的名字叫减治(decrease and conquer),也叫剪枝式分治。它和传统分治的区别在于:普通分治要把每个子问题的解都凑齐才能合并出原问题的答案,而减治只需沿着一条路径往下走,其他分支全部剪掉。二分查找是最古老的减治例子,QuickSelect 是它在无序数组场景下的推广。

2.3 和快速排序的分道扬镳

快排和快速选择经常被放在一起比,因为它们都用划分函数,代码前半段几乎一模一样。区别在于递归之后:快排对划分出来的左右两个区间都要递归,因为它的目标是让所有元素各归其位;快速选择只递归包含答案的那一侧,因为它的目标仅仅是确定一个位置的值。

这个差异直接反映在复杂度上。快排平均情况递推 T(n) = 2T(n/2) + O(n),解出 O(n log n);快速选择平均情况递推 T(n) = T(n/2) + O(n),解出 O(n)。一个 log n 的差距,根源在于:快排买的是"全序",快速选择只买"单点信息"。工程上的启发也很直白:先想清楚你到底需要多少信息,再决定投入多少计算。这是比任何代码都重要的方法论。

3. 期望 O(n) 是怎么来的,以及最坏情况为什么很糟

3.1 平均情况下的递推

假设基准选得不错,划分后 pivot 大致落在区间中点附近。那么快速选择第一次划分花 O(n),第二次处理规模约 n/2,第三次约 n/4,以此类推。总时间就是等比级数 n + n/2 + n/4 + ...,收敛到 2n,也就是 O(n)。很多人第一次看到这个推导会觉得太巧了:每次只剩下一半,但另一半不是凭空消失,而是被证明"不可能包含答案"后合法地丢弃了,所以每一层的开销都在按比例收缩。

更严谨的写法是设 T(n) 表示期望时间,递推式写作 T(n) = T(n/2) + O(n)。这里的 T(n/2) 是平均意义下的子问题规模,严格的概率推导需要把基准落在每个位置的可能性都考虑进去,结论不变。这是整个算法最漂亮的数学点:看似每次只消掉一半,但这一半是"确定含答案的一半",所以另一半根本不需要再花钱。

3.2 最坏情况:有序数组的陷阱

如果基准固定选最右元素,而数组恰好已经有序,会发生什么?划分后 pivot 落在最右端,左边是 n - 1 个元素,右边是 0 个元素。递归规模从 n 变成 n - 1,于是递推式变成 T(n) = T(n - 1) + O(n),解出 O(n²)。一个为了高效选第 K 小元素而生的算法,在数据恰好有序时反而退化到冒泡水平,这个讽刺是很多人的第一堂复杂度实践课。

注意:固定取最右元素作为基准的 QuickSelect,绝不能在有序或近似有序的数据上直接使用。

真实工程里,这种"看起来随机、其实有序"的数据比比皆是:日志时间戳天然递增、数据库按主键返回的记录有序、监控数据按时间序列排列。如果你写成固定取最右基准,又碰巧把这种数据喂进去,线上性能会瞬间垮掉。所以固定基准的 QuickSelect 只能用于教学,不能直接上线。

3.3 随机化让坏情况几乎不发生

解决方案是随机选择基准。代码里的做法是:先从当前区间随机挑一个下标,把该位置的元素和最右元素交换,再走标准的单游标划分。这样一来,最坏情况的输入依然存在——每次随机都命中当前区间的最值——但它与数据的初始形态彻底解绑,变成了独立的小概率事件。连续若干次都抽到最值的概率按指数级衰减,实际运行中可以认为不会发生。

如果你对随机源有顾虑,或者不想依赖语言自带的随机数,也可以用三数取中:取区间首、中、尾三个元素的中位数作为基准。它在快排里非常流行,在快速选择里效果同样不错,能平滑大部分退化场景。不过要说彻底的随机性,还是直接随机选下标更省事。这一点完全取决于你对"概率保证"的接受程度。

4. BFPRT 算法:让最坏情况也稳定在 O(n)

4.1 为什么是五元素一组

随机化再好,也只是把最坏情况变成小概率事件。某些场景不允许存在"几乎":比如实时竞价、风控拦截、在线计费,延迟超出预算就是事故,你不能跟老板解释"这次是随机数运气不好"。这时候就需要一个确定性算法,让最坏情况也严格是 O(n)。这就是 BFPRT,通常称为中位数的中位数(Median of Medians)算法。

BFPRT 的核心思路是给快速选择换一个质量极高的基准,不再依赖随机,而是靠计算得到。步骤分三步:第一,把数组每 5 个元素分成一组,最后不足 5 个的单独成组;第二,每组内部排序,取每组的中位数;第三,递归地对这些组中位数求中位数,用这个结果作为全局 pivot。

为什么是 5 而不是 3 或 7?3 个一组能得到类似的复杂度结论,但常数和递归深度不划算;7 个一组会减少递归层数,却让每组排序的开销变大。5 正好站在一个平衡点上:每组排序成本很低(5 个元素排序最多十几次比较),又能保证后续的规模收缩率达到理论需要的水平。工程界最终普遍采用 5,是一个在数学和实现之间反复权衡后沉淀下来的经验选择。

4.2 复杂度证明的直觉

假设数组有 n 个元素,分成约 n/5 组,每组取中位数,再对这些中位数递归取中位数作为 pivot。关键性质是:至少有一半的组,它们的组中位数小于等于全局 pivot。每个这样的组有 5 个元素,组内排序后至少前 3 个都不大于组中位数,也就都不大于全局 pivot。因此至少有约 3 × (n/5) / 2 = 3n/10 个元素小于等于 pivot。同理,至少有 3n/10 个元素大于等于 pivot。

这意味着无论 pivot 最后落在哪,左右两侧都至少排除了 30% 的元素。于是递推式变为 T(n) ≤ T(n/5) + T(7n/10) + O(n):第一项是递归求中位数的中位数,第二项是最坏情况下依旧要处理的剩余区间,第三项是分组排序和划分的开销。解这个递推式,两种子问题的规模加起来是 n/5 + 7n/10 = 9n/10,小于 n,所以最终复杂度被压回 O(n)。这个证明的直觉,就是"找基准这件事本身就带来了 10% 的规模收缩"。

4.3 代码与使用建议

实现上,BFPRT 常见的写法是利用三块划分:严格小于 pivot、等于 pivot、严格大于 pivot。

def bfprt(arr, k): if len(arr) <= 5: return sorted(arr)[k] medians = [] for i in range(0, len(arr), 5): group = arr[i:i+5] group.sort() medians.append(group[len(group) // 2]) pivot = bfprt(medians, len(medians) // 2) left = [x for x in arr if x < pivot] mid = [x for x in arr if x == pivot] right = [x for x in arr if x > pivot] if k < len(left): return bfprt(left, k) elif k < len(left) + len(mid): return pivot else: return bfprt(right, k - len(left) - len(mid))

三块划分对重复元素处理非常友好:等于区直接命中返回,不用纠结小于等于边界。代价是每次递归需要复制子数组,空间 O(n)。如果你想省内存,可以改成原地版本,但代码可读性会差很多。我个人的习惯是:讲原理和面试时用原地版思路,工程落地优先可读性,这种非原地版完全够用。

实际使用时还得泼一盆冷水:BFPRT 的常数很大,几十万以内的小数据上通常跑不过随机化 QuickSelect。分组排序、递归取中位数、三块筛选,每一步都有开销。它真正的用武之地是:数据量巨大、又有硬性的最坏情况保证需求。普通业务里,随机化配合合理的工程防护,性价比高得多。

5. 实战中的边界情况与翻车现场

5.1 K 的下标语义是第一大坑

选第 K 小,K 到底是 0 起始还是 1 起始?这个看起来无关紧要的约定,是实际开发里最常踩的坑。我见过不止一次事故:外层函数用"第 i 小"(1 起始)对外暴露,内部递归按 0 起始下标实现,两边没做转换,结果线上数据每个月都整体偏移一位。这类 bug 隐蔽在正确和错误之间,单看边界用例很难发现。

提示:把"第几小"(1 起始)和"数组下标"(0 起始)在入口处显式转换,能避免一整类隐蔽 bug。

我的建议是:对外接口统一使用 1 起始的"第几小"语义,方便调用方理解;内部递归统一使用 0 起始数组下标,方便和 partition 落点对齐。两层之间在入口处做一次显式转换,并在文档里写明:"传入 k=1 表示最小值,k=len(arr) 表示最大值"。宁可多写一行转换,也不要在模糊约定上赌运气。

5.2 重复元素与全相等数组

重复元素是划分实现的照妖镜。如果你在划分时使用 < 而不是 <=,小于等于区的边界会变得不可控,pivot 落点和递归方向都可能出错。使用 <= 时,重复元素会被分到左区,pivot 的落点依然是一个有意义的边界,快速选择的正确性可以保持。

比重复元素更极端的是全相等数组。这种情况下,小于区为空、等于区占满。非原地版 BFPRT 的三块划分对这种场景非常友好,等于区直接一击命中;原地版 QuickSelect 也能正确返回,因为每次划分后 pivot 的位置始终有效。如果你发现写出来的算法在全相等数组上行为异常,优先检查划分函数里的比较符号。

5.3 递归深度、空数组与迭代改写

快速选择最坏情况递归深度是 O(n),随机化虽然让这个概率极低,但 Python 默认递归深度限制(通常 1000)还是可能在某些极端随机种子下被碰到。真要彻底放心,可以改成迭代版本,用 while 循环模拟递归:每次确定 pivot 落点后,把 left 或 right 边界收缩到一侧,不产生新的函数调用。这样既不担心递归深度,也让代码更贴近 C/C++ 里常见的手写版本。

def quickselect_iterative(arr, k): left, right = 0, len(arr) - 1 while left <= right: if left == right: return arr[left] rand_idx = random.randint(left, right) arr[rand_idx], arr[right] = arr[right], arr[rand_idx] pivot_idx = partition(arr, left, right) if k == pivot_idx: return arr[k] elif k < pivot_idx: right = pivot_idx - 1 else: left = pivot_idx + 1

空数组和越界 K 属于必须提前防御的输入。我在公共入口里加了一个校验,K 不在 [0, n) 范围内直接抛异常。别觉得这是小题大做——在数据管道里跑上一个月,什么脏数据都可能出现,算法入口的防御就是第一道护城河。

5.4 一段顺手可用的验证脚本

写完快速选择,先别急着计入线上,用暴力对拍脚本跑一遍:随机生成数组、随机生成长度、随机生成 K,把快速选择的结果和排序取下标的结果逐一比对。几千轮下来,负数、正数、重复值、全相等、单元素数组这些角落基本都能覆盖到。对拍通过后再谈优化和上线,这一步成本极低,却能省掉未来无数个排查 bug 的深夜。

def brute(arr, k): return sorted(arr)[k] for _ in range(2000): arr = [random.randint(-100, 100) for _ in range(random.randint(1, 100))] k = random.randint(0, len(arr) - 1) assert quickselect(arr[:], k) == brute(arr, k)

我习惯把这段对拍脚本保留在项目的测试目录里,以后改任何排序相关代码都会顺手跑一遍。算法题可以靠脑内验证,工程代码必须靠测试说话。两者之间隔着一条明确的分界线。

6. 次序选择之外的延伸思考

6.1 由第K小到中位数、TopK

第 K 小变中位数,只需要令 k = (n - 1) // 2;变 TopK,则找第 n - k + 1 小,把 TopK 的边界值切出来,剩下的自然就是最大的 K 个。排行榜、异常检测、p99 延迟监控,本质上都是选择问题。这些场景的共性在于:你关心的不是全体元素的完整顺序,而只是某个或某几个次序位置上的值。抓住这一点,就能用同一个线性算法覆盖一大批应用。

6.2 二分查找是同一招吗

二分查找其实也是次序选择的一种特殊形式:它在一个已经有序的数组上,通过折半不断缩小候选区间,最终定位到目标值所在的次序位置。区别只在于有序数组天生知道中点的意义,而无序数组必须先花 O(n) 用划分来"购买"中点两侧的次序信息。这么一看,二分查找、快速选择都是同一种思想的实例:每次迭代都做一次划分,然后只保留包含答案的那一侧。

这个统一视角非常值得带进脑海。以后再遇到"在 XX 里找第 Y 个 XX"的问题,第一反应不应该是套模板,而是先问:候选区间是什么?划分代价是什么?丢弃哪一侧是安全的?三个问题想清楚,算法自然就出来了。

6.3 分治中先处理哪一侧的决策价值

回到题目本身。"次序选择"这四个字,表面上是说选择问题,深层其实是分治里的次序决策:先递归哪一侧、哪一侧可以安全丢弃、什么条件下可以提前终止。现实工程里,很多分治算法就是因为没有正视这个次序,习惯性地两个分支都递归,复杂度凭空多出一个 log n;分支更多的场景更是直接膨胀成指数级。

我自己的习惯是:拿到一个分治问题,先画递归树,标出每个节点的规模,再逐个检查哪些子树可以剪枝。对选择类问题,答案通常非常漂亮——永远存在一条只走一侧的路径,把复杂度压下一个量级。这是我在分而治之这个系列里最想传达的一课:分治的价值不只是拆分,更在于拆分之后对方向的选择。

最后分享一个小技巧:实现快速选择时,把划分函数单独抽出来,并在注释里写明"返回 pivot 的最终下标"。这个约定让我后来接 BFPRT、改迭代版本、移植到其他语言时都省了大量脑力。边界和语义这类约定,越早固化越好,未来的你会感谢现在的自己。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询