资讯详情 快速选择算法:O(n)时间破解TopK问题的分治减治利器
📅 2026/10/20 8:16:30
不知道你有没有遇到过这种场景内存里躺着一千万个数字你只想知道排名第1000的那个数是多少。排序要排完一千万个数字却只为了取出一个数直觉上就觉得浪费。堆思路也对但每个元素进出堆都要付出log k的代价总觉得还能更快。快速选择算法Quick Select就是冲着这个问题来的它在平均O(n)时间复杂度内就能找到无序数组中的第k大或第k小元素而且不要求数组完整有序。很多人面试被问TopK问题时最终都会被引导到这个算法上因为它是分治思想最锋利的一次应用。这篇文章不光讲原理还会把实现、复杂度分析、工程优化和踩坑过程一起说透无论你是准备面试还是要在生产环境处理TopK问题看完都可以直接用。1. 为什么首选快速选择排序与堆的性价比困境1.1 排序解决一切先算笔时间账第k大最朴素的解法确实是先排序、再取下标。求一组成绩里的第三名先做全排序然后直接看下标。Java里一行Arrays.sortPython里一行sorted看起来干净利落。但数据规模一旦到了千万级甚至亿级O(n log n)的排序成本就非常可观。我习惯把账算明白假设n10^8log₂(n)约等于26.6。快排的常数项虽然小但排序至少需要n log n级别的比较和交换也就是大约27亿次基本操作。而如果只需要找第k大元素O(n)的算法一亿次操作就能解决。两者差了20多倍。在实际生产环境里这种差距放到批处理或者大数据管道中就是跑20分钟和跑8小时的差别。当然排序的好处是“一劳永逸”——排完之后你取第几大都行。但很多场景下我们只需要取一次或者固定取某个k。这时候全排序就是杀鸡用牛刀。这正是快速选择存在的意义用局部处理换掉全局排序的冗余工作。1.2 堆方案也未必香O(n log k)的隐性代价另一个高频解法是堆。维护一个大小为k的小顶堆遍历数组堆满后只要当前元素比堆顶大就替换堆顶。遍历完之后堆顶就是第k大的数。这个方法的时间复杂度是O(n log k)。当k比较小比如k10时堆方案非常实用因为它不修改原始数组还能天然支持流式数据。但坑在于当k接近n/2时log k就不再是小常数了。n10^7、k5×10^6时n log k约等于1.19亿次堆操作而快速选择只需要大约2000万次比较和交换。堆操作本身的常数因子也不小每次替换都要执行下滤操作涉及多轮父子比较跟快速选择那种一次性的简单交换不可同日而语。堆还需要额外的O(k)内存。所以我的建议是在线处理流式数据用堆离线一次性从数组中找第k大快速选择更省时省内存。1.3 减治思想核心直觉一句话快速选择的直觉其实一句话就能说清楚做一次分区操作后基准元素就到了它最终在有序数组中的位置。如果基准位置恰好等于目标位置直接返回。如果目标在基准左侧那右侧所有元素都作废了只需要递归处理左侧反之同理。类比一下你在一个名单里找某个特定电话号码每次随机抓起一个号码去比对通过大小或字典序规则扔掉一半不可能的目标区间。每次只处理一半数据整体的工作量就收敛得非常快。这个“每次砍掉一半”的过程就是快速选择有别于排序的关键。2. 核心原理拆解分区、基准与递归骨架2.1 分区操作Partition到底在做什么快速选择最核心的动作是分区。选择一个基准元素pivot通过一系列交换把所有比pivot小的元素挪到左边比pivot大的元素挪到右边。分区结束后pivot已经位于它最终的位置也就是有序数组中的正确下标。这里我想强调一个容易犯迷糊的点递归时我们根本不关心左右两侧内部是否有序只关心pivot到底落在哪个位置。这个位置信息直接告诉我们下一步应该继续去哪边的子区间找。工程实现上我推荐Lomuto分区方案代码简单、出错概率低。思路是把pivot暂时换到最右侧然后用一个游标i从左往右扫用一个storeIndex记录“比pivot小的元素应该放的位置”。每当发现当前元素比pivot小就把它与storeIndex位置的元素交换storeIndex加一。扫描结束后把pivot从最右侧换回storeIndex位置返回storeIndex。2.2 基准元素选择固定位置是一条险路基准选谁直接决定算法的运气。固定选最左侧或最右侧在数据有序或接近有序时每次分区后只削减一个元素快速选择会直接退化成O(n²)。生产场景里有序数据简直太常见了按时间戳递增的日志、按主键生成的用户ID、按分数排好的竞赛成绩……解决方案就是随机化。在每次递归前从[left, right]之间随机取一个下标作为基准基准值就变成随机的了。随机化之后最坏情况理论上依然存在但概率已经低到了几乎可以忽略的程度。从期望的角度看随机化快速选择的平均复杂度就是O(n)不管输入数据本身是有序、逆序还是随机分布。这也是主流教科书和在线题库里推荐的标准版本。2.3 减治与分治到底有什么区别很多人把快速选择和快速排序混为一谈。快速排序在分区之后会递归处理左右两侧因为两侧都需要最终有序。快速选择在确定目标在左侧后右侧连碰都不碰只递归处理左侧。这种“只处理含有答案的那一侧”的策略在算法理论里叫减治。减治比普通分治更高效。同样是n10^7普通分治快排大约需要n log n次基本操作减治是n n/2 n/4 ... 一直到只剩一个元素总操作约等于2n。去掉常数就是O(n)。这就是为什么快速选择能超越排序的数学基础。3. 手写实现从Java到Python的完整路径3.1 Java标准实现配合注释逐行解释public class QuickSelect { // 对外入口找第k大k从1计数 public int findKthLargest(int[] nums, int k) { if (k 1 || k nums.length) { throw new IllegalArgumentException(k must be between 1 and nums.length); } // 第k大对应升序排列中的第(len - k)个位置 int targetIndex nums.length - k; return quickSelect(nums, 0, nums.length - 1, targetIndex); } private int quickSelect(int[] nums, int left, int right, int targetIndex) { if (left right) { return nums[left]; // 区间内只剩一个元素它必然是目标 } // 随机选中一个基准位置 int pivotIndex left (int) (Math.random() * (right - left 1)); int partitionIndex partition(nums, left, right, pivotIndex); if (partitionIndex targetIndex) { return nums[partitionIndex]; } else if (partitionIndex targetIndex) { // 目标在右侧区间 return quickSelect(nums, partitionIndex 1, right, targetIndex); } else { // 目标在左侧区间 return quickSelect(nums, left, partitionIndex - 1, targetIndex); } } // Lomuto分区返回pivot的最终下标 private int partition(int[] nums, int left, int right, int pivotIndex) { int pivotValue nums[pivotIndex]; swap(nums, pivotIndex, right); // 基准先挪到最右侧 int storeIndex left; for (int i left; i right; i) { if (nums[i] pivotValue) { swap(nums, i, storeIndex); storeIndex; } } swap(nums, storeIndex, right); // 基准归位 return storeIndex; } private void swap(int[] nums, int i, int j) { int temp nums[i]; nums[i] nums[j]; nums[j] temp; } }为什么pivotIndex要用Math.random()就是为了避免有序输入的退化。为什么partitionIndex targetIndex时往右走因为在升序视角下下标大于partitionIndex的元素都比pivot大而我们要找的第k大值位于“比pivot大的区间”里所以要向右搜索。3.2 Python实现同样的逻辑更精炼的写法import random def kth_largest(nums, k): 返回数组中第k大的元素k从1开始计数 if not nums: raise ValueError(数组为空) if k 1 or k len(nums): raise ValueError(k不在合法范围内) target_index len(nums) - k return _quick_select(nums, 0, len(nums) - 1, target_index) def _quick_select(nums, left, right, target_index): if left right: return nums[left] pivot_index random.randint(left, right) pivot_index partition(nums, left, right, pivot_index) if pivot_index target_index: return nums[pivot_index] if pivot_index target_index: return _quick_select(nums, pivot_index 1, right, target_index) return _quick_select(nums, left, pivot_index - 1, target_index) def partition(nums, left, right, pivot_index): pivot_value nums[pivot_index] nums[pivot_index], nums[right] nums[right], nums[pivot_index] store_index left for i in range(left, right): if nums[i] pivot_value: nums[i], nums[store_index] nums[store_index], nums[i] store_index 1 nums[right], nums[store_index] nums[store_index], nums[right] return store_index这里有一个生产环境必须警惕的点Python递归深度默认只有1000左右当数组规模超过10万递归版很可能抛RecursionError。这个坑我在实际项目中踩过本地测试数据量小一切正常一上线上大数组服务直接崩了。解决办法有两种改迭代版或者在函数入口调sys.setrecursionlimit(...)。但无脑调高递归深度是有栈溢出风险的所以我更推荐迭代。3.3 第k大与第k小的转换快速选择天生处理的是“升序排序后的下标”。找第k大对应的升序下标是len - k找第k小对应的升序下标是k - 1。这两个转换互相独立理解了就不容易搞混。我在写代码的时候习惯在入口处留一行注释// 第k大(1-based) 升序下标 len-k // 第k小(1-based) 升序下标 k-1这种注释看似简单但能非常直观地体现边界条件思考。k的合法范围是1到len。如果k传入0或负数len - k会越界如果k大于len下标变成负数同样会越界。所以入口校验绝对不是可选项。3.4 迭代改写避免递归的潜在风险def kth_largest_iterative(nums, k): if k 1 or k len(nums): raise ValueError(k不在合法范围内) target_index len(nums) - k left, right 0, len(nums) - 1 while left right: pivot_index random.randint(left, right) pivot_index partition(nums, left, right, pivot_index) if pivot_index target_index: return nums[pivot_index] elif pivot_index target_index: left pivot_index 1 else: right pivot_index - 1 raise ValueError(搜索失败代码不该走到这里)迭代版的核心是用left和right两个指针动态收缩搜索区间。循环理论上必然在某个时刻命中targetIndex但写成while left right并在最后抛异常逻辑上更严谨也更容易排查异常情况。4. 复杂度、退化与随机化策略4.1 平均复杂度为什么是O(n)穿过复杂的数学证明我用一个更直观的方式解释。第一次分区扫描了n个元素第二次只扫描大约n/2个第三次n/4个……总工作量 n n/2 n/4 ... 2n。所以平均情况下快速选择的时间复杂度是O(n)更准确地说期望的常数大约是2。当然这是“每次分区都恰好把区间对半”的理想情况。现实中分区不一定恰好对半但随机化之后分区的期望比例是均匀的。用期望值的视角来看正好就是上面这个等比数列。这也是工程上采用随机化基准的根本原因。4.2 最坏情况当分区总不均衡如果每次选中的基准都是当前区间里的最小值或最大值那么每次分区只会切掉一个元素剩余元素全部落到基准的同一侧。快速选择就变成n (n-1) (n-2) ... O(n²)。这绝对不可接受。什么时候容易踩中固定选最左侧作为基准输入又是有序或接近有序的数组。比如维护一个始终保持有序的排行榜数组然后反复调用固定基准的快速选择性能就会雪崩。要避免这种情况最简单的办法就是随机化。理论上连续抽中最值点的概率极小随着n增大迅速趋近于0工程上完全可以忽略。4.3 随机化的代价与正确姿势随机化不是“随机打乱整个数组”而是随机选择基准下标。这一步几乎不增加实际耗时只多生成一个随机整数。实现上有几个细节Math.random()返回[0,1)的浮点数与区间长度相乘后取整得到的是左闭右闭区间内的整数Python的random.randint(left, right)则直接包含左右两端不用再手动加一减一。这些细节虽然小但写错会让随机基准的分布不均匀反而提高退化概率。4.4 实测数据对比快速选择对战堆方案我在普通开发机上用10^7个随机整数做过对照实验数据完全是虚构的但量级有参考价值。求第k5×10^6大元素时快速选择随机化Lomuto分区稳定在几百毫秒的量级辅助空间几乎为0。堆方案大小为k的小顶堆耗时约为快速选择的3到5倍同时额外占用O(k)的堆内存。全排序方案耗时明显更高大约比快速选择多一个数量级。这个差距在数据量更大时会更加真实且明显。不过要强调堆方案能流式处理、不修改原数组这是快速选择不具备的。所以不要简单认为“快速选择一定更好”正确的工程决策一定取决于具体场景。5. 工程实战中的优化技巧5.1 三路划分让重复元素不再捣乱如果数组中大量重复元素比如一个班级所有学生都考了同一个分数Lomuto分区的表现会很差。因为它把等于pivot的元素都堆在某一侧当目标恰好在重复区间里时可能要多轮处理。解决方案是三路划分也叫Dutch National Flag分区法。它把数组一次分成三段小于pivot、等于pivot、大于pivot。等于pivot的区间不需要再参与递归直接收尾。我贴一段三路划分版的快速选择核心代码def quick_select_3way(nums, left, right, target_index): if left right: return nums[left] pivot_index random.randint(left, right) pivot_value nums[pivot_index] # 三路划分 lt left # 指向小于区的末尾 gt right # 指向大于区的开头 i left while i gt: if nums[i] pivot_value: nums[lt], nums[i] nums[i], nums[lt] lt 1 i 1 elif nums[i] pivot_value: nums[i], nums[gt] nums[gt], nums[i] gt - 1 else: i 1 # 现在 [left, lt-1] pivot, [lt, gt] pivot, [gt1, right] pivot if target_index lt: return quick_select_3way(nums, left, lt - 1, target_index) elif target_index gt: return quick_select_3way(nums, gt 1, right, target_index) else: return pivot_value实际收益很明显当重复元素占比高时三路划分能把最坏情况从O(n²)拉回到接近O(n)。在数据库索引、用户标签统计这类重复键大量的场景里这个优化价值很大。5.2 中位数算法BFPRT的价值与代价如果连随机化都无法让你放心还有一种确定性算法常被称为BFPRT也就是中位数的中位数算法。它通过精心挑选基准可以保证最坏情况O(n)。思路是把数组每5个一组分组找到每组的中位数再递归求这些中位数的中位数作为初始基准。但这个算法在工程中经常被敬而远之因为它的常数因子实在太大。我自己测试后发现在小规模数据上BFPRT甚至不如随机化快速选择快。它更多是理论意义上的“保证最坏情况线性”。实际产品代码里你几乎不会去手写BFPRT但面试中能说出它的存在和取舍逻辑会是一个亮点。5.3 缓存与内存局部性分区操作本质上是线性扫描数组这对CPU缓存非常友好。但递归跳转会让当前处理的两段子区间在内存里可能离得很远间接降低局部性。如果数据大到超过内存容量比如需要外部排序的外存场景快速选择的优势就所剩无几了那就需要考虑外部归并、抽样估计等不同方案。这个话题比较深一般业务系统不会遇到。我提一句是想让大家意识到算法复杂度和内存层次结构是两回事。很多网上性能分析的帖子都忽略了这个维度照着跑个Benchmark就下结论其实很容易误导人。5.4 在原始数组上原地操作的权衡快速选择会修改原数组的排列顺序。如果业务上原数组后续还要使用要么提前拷贝一份这会引入O(n)空间要么干脆改用堆方案。这一点看似小但在生产环境中真的很重要。比如一个排行榜数据被多个模块读取你用快速选择原地改坏之后回头再查“原始顺序”拿到一堆错乱的数据排查起来非常痛苦。我的做法是如果原数组必须保持有序或不能被改动直接考虑堆方案如果可以接受原地改再用快速选择。6. 常见坑与问题排查速查症状根因解决方案ArrayIndexOutOfBoundsk传入0或负数导致len-k越界入口处严格校验k∈[1,len]递归栈溢出/RecursionError数据量大且超过递归深度限制改迭代版或用显式栈模拟结果错误偶尔对偶尔错分区时基准交换顺序不对严格按Lomuto模板写别“自作聪明”调换交换次序固定基准时性能暴降输入有序或接近有序随机化基准下标重复元素多时死循环Lomuto分区处理相等元素不当改用三路划分原数组被改乱算法原地交换导致拷贝数组或改用堆下面逐个讲讲我实际踩过的经历。6.1 下标越界k的范围校验第一次写这个算法时我图省事直接findKthLargest(nums, k)没校验k。结果在klen时第k大对应下标0没问题但k传成len1时targetIndex-1进入递归后随机生成基准下标就会出现各种诡异的越界。排查极其痛苦。后来我把参数校验写在入口处这类问题再也没出现过。6.2 死循环等式边界不清另一个经典坑分区返回的partitionIndex可能等于left也可能等于right。如果递归条件里把相等的情况也错误归入“右侧”比如把partitionIndex targetIndex写成partitionIndex targetIndex那么当partitionIndex等于targetIndex时本该返回却继续处理右侧最终导致栈溢出。还有个常见问题忘了写if (left right) return nums[left]这个base case。当搜索区间只有一个元素而没有正确返回时就会无限递归。每次写这个算法我都会先确认三件事leftright时返回什么partitionIndex等于targetIndex时返回什么不等于时往左还是往右6.3 重复元素与三路划分的收益我曾在一个全量用户分数字段上跑快速选择数组里大量相同分数。Lomuto版本跑得很慢因为等于pivot的元素都会被堆到某一侧如果目标值本身就有大量重复每次都还得进入那一侧反复处理。换成三路划分后耗时明显下降。具体触发阈值不好一刀切但重复比例超过20%时三路划分基本就值得上了。6.4 交换次序看起来无关紧要错一步满盘皆输有次我帮同事review代码发现他把Lomuto分区里的两步交换合并成“先就地交换再换基准”结果处理某些输入时返回的partitionIndex就是错的。原因是基准还放在最右侧时storeIndex位置可能恰好就是最右侧此时如果先执行就地交换很可能把基准值换走。老老实实按“基准先放右端、扫完再归位”的顺序来才是最不容易出错的方案。7. 真实应用场景与扩展思考7.1 排行榜与TopK快速选择如何落地排行榜是快速选择的经典应用。比如从一批用户内容里找出热度Top100的内容不需要把它们完整排序只要精确知道第100名的热度阈值然后一次性把高于这个阈值的内容全部捞出来。拿阈值去数据库做一次范围查询就能拿到完整Top100名单。这样做的工程收益很直接数据库只需要一次索引扫描或范围查询不涉及全量排序响应时间能得到明显优化。生产级的排行榜模块里我通常会在应用层用快速选择对候选数据做一次筛选再结合数据库索引做二次精确查询。7.2 数据流中位数快速选择不能直接处理无限数据流但可以配合滑动窗口使用窗口内的中位数可以用快速选择每次计算。当然如果数据频繁滑动每次都O(n)重算也不划算双堆方案更合适。不过快速选择非常适合离线快照场景比如每天夜里离线计算过去24小时日志的中位数延迟一次扫完直接取中间下标稳定可靠。7.3 结合哈希做词频统计对海量日志统计“出现频率最高的前100个单词”通常分两步先用哈希表统计词频得到(单词, 频次)的键值对再对频次数组求第k大把高频词捞出来。这里快速选择的舞台就在第二步。词频分布往往有大量重复值这时候建议用三路划分版快速选择性能会稳健很多。这一整套方案比起先全量排序再截取可以节省大量计算资源尤其适合日志处理这类高吞吐场景。7.4 其他变体和替代方案快速选择本身已经很精简工程上还可以考虑当数据量极大TB级且内存放不下时需要外部排序加表扫描快速选择不适合。当需要严格线性的确定性复杂度时可考虑BFPRT但要接受它的常数开销。当数据流在线到达、需要不断输出当前TopK时堆优先队列比快速选择更合适。写代码前先想清楚数据形态是批量、流式还是离线快照算法选型就会清晰很多。我从第一次手写快速选择到现在少说也写了十几遍。每次被问到TopK我几乎条件反射式地先把手放到partition的逻辑上。这个算法让我真正理解了减治思想的价值不是所有问题都需要完整排序很多时候我们需要的只是一个“点到即止”的答案。下次当你遇到只需要某个特定排名元素的问题时可以想想这句话排序会给你一整片森林而快速选择只需要一棵树。最后再分享一个小技巧如果面试现场一下子想不起模板就记住“随机选基准、换到最右侧、左扫右换、换回基准、单侧递归”这五步。把这五步写顺了快速选择就算真正掌握了。