这题我第一次刷的时候,只用了三行代码就过了,当时还觉得挺简单。直到后来在真实业务里处理百万级热门关键词排序,才意识到自己漏掉了这个问题的内核。LeetCode 215 数组中的第K个最大元素,表面上是“排序取下标”的小儿科,但它的每一种解法都在回答一个工程问题:当数据大到不能全排、甚至不能全存的时候,你还能不能快速找出名次靠前的那些元素?
这篇文章把常用解法——堆排序、快速选择、计数排序、标准库里的黑科技——从头到尾过一遍,重点放在“为什么这样写”和“什么场景该选哪个方案”,顺便把那些让人栽跟头的边界细节讲透。如果你正准备面试,这题几乎算TopK问题的入门考题;如果你在做日志分析、排行榜或者推荐粗排,你会发现它的各种变体天天都在出现。我会从最简单的排序写起,一步步深入到快速选择和三路划分,最后给一份可以直接复用的测试清单,希望能帮到正在刷题或者正在解决真实问题的你。
1. 题意拆解:这题到底在问什么
1.1 TopK问题在真实场景里有多常见
先明确题面:给你一个无序整数数组,返回数组中第K个最大的元素。比如[3,2,1,5,6,4],k = 2,答案就是 5。注意,这里的“第K大”不是“第K个不重复的元素”,重复元素要按多次计算。比如[1,2,2,3],第1大是3,第2大是2,第3大还是2。
这个抽象问题在工程里几乎无处不在:新闻热搜榜要取热度前50的关键词,电商推荐系统要截断候选商品到Top 200再进精排,游戏排行榜要查第100名的分数,日志分析要统计出现次数最多的接口。把这些场景剥掉业务外壳,核心都是一个无序集合里找名次的问题,规模从几千到上亿都有可能。
所以这道题在面试里出现频率高是有原因的。面试官并不是真缺一种排序方法,而是想看你在不同资源约束下做取舍的能力。这也是为什么一道看似简单的题能衍生出这么多解法,每种解法都对应一类真实环境。
1.2 第K大和排序之间的关系没那么简单
很多人第一反应是“排序后取nums[n-k]”,这在思路上没有错,但它隐含了一个假设:你有时间和空间去完成一次全量排序。当数组长度是100万时,O(n log n)的排序仍然可能跑进一秒以内;当长度到1亿,内存和耗时都会变得不可接受。
另外还有一个容易搞混的点:第K大在升序数组中的位置是n-k,在降序数组中的位置是k-1。这个下标换算几乎是我见过最多的“低级错误”来源,后面写代码时会反复用到。
还有一点值得注意:如果题目问第K小,不需要重新写一套算法。第K小就是升序数组的第k-1个元素,也等于第n-k+1大(从1开始计数)。熟练掌握这个对称关系,可以让你在面试时少写一半代码。
1.3 各方案复杂度总览
在展开每个方案之前,先把复杂度表放在这里,方便对照。后面每一章会解释这些数字是怎么来的。
| 方案 | 时间复杂度 | 空间复杂度 | 核心特点 |
|---|---|---|---|
| 全量排序 | O(n log n) | O(1)(原地) | 最直观,需要全序 |
| 部分排序 | O(nk) | O(1) | 只在k很小时划得来 |
| nth_element | 期望 O(n) | O(1) | C++标准库自带的选择算法 |
| 大顶堆全员维护 | O(n + k log n) | O(n) | 代码短,内存占用大 |
| 小顶堆只留k个 | O(n log k) | O(k) | 内存友好,适合流式数据 |
| 快速选择 | 期望 O(n),最坏 O(n²) | O(1) | 单次查询最快,细节最坑 |
| 计数排序 | O(n + M) | O(M) | 值域受限时的作弊解法 |
2. 排序法也有讲究:全排、部分排和标准库的隐藏武器
2.1 直接sort整个数组:作为铺垫答案
先给最基础的版本:
int findKthLargest(vector<int>& nums, int k) { sort(nums.begin(), nums.end()); return nums[nums.size() - k]; }复杂度是O(n log n),如果数组长度在几万以内,这个解法没有任何问题。很多语言的内置排序都是高度优化的混合排序,比如C++的sort在小规模数据上会退化成插入排序,常数因子很小,实际跑起来并不慢。
但要注意两点。第一,sort会修改原数组,如果你不希望原数据被破坏,需要先拷贝一份,这会增加O(n)空间。第二,这种做法属于“我先把所有元素排好,再回答你的问题”,它没有利用题目只需要一个名次这个信息,所以从算法上就是亏的。
面试时如果先给出这个答案,我会建议主动补一句:这是最容易想到的方案,但不是最优,因为题目只要求第K个元素,不需要知道所有元素的完整顺序。这样说会让面试官觉得你不是只会背代码,而是真的在分析问题。
2.2 部分排序:k很小时能用的奇招
如果你只想找最大的前3个,会怎么做?一种朴素思路是执行k趟“选择排序”:每趟遍历一遍剩余数组,把当前最大值挪到前面去。这样找第K大只需要 k 趟,复杂度O(nk),空间O(1)。
当 k 远小于 n 的时候,O(nk)可能比O(n log n)更好。举个极端例子:n = 1亿,k = 5,n log n大约是 26亿次操作,而nk只有5亿次操作(虽然带较大常数),差距就出来了。
这个思路在日常代码里偶尔有奇效。比如你只需要排序后最靠前的几十个元素,完全没必要把整个数组排一遍。但回到这道题,k 可能接近 n,这种方案的最坏复杂度会退化得非常难看,所以不是主流解法。它存在的意义是提醒你:算法题别套模板,参数一变,方案优劣就反转。
2.3 nth_element:用库函数优雅过关
C++ 标准库里藏着一个专门解决这类问题的函数:nth_element。它可以保证:如果整个区间排序,第 n 个位置上的元素就位,并且它左边的元素都“不大于”它,右边的元素都“不小于”它(按比较器的语义理解)。函数内部通常是内省选择算法,平均复杂度 O(n)。
用在这道题上,一行核心代码就够了:
int findKthLargest(vector<int>& nums, int k) { nth_element(nums.begin(), nums.begin() + k - 1, nums.end(), greater<int>()); return nums[k - 1]; }greater<int>()让比较器的意义变成“降序”,于是第K大的元素就会落到begin() + k - 1这个位置。如果你不喜欢降序比较器,也可以升序找第n-k个位置。
我在工程代码里遇到“需要TopK但不方便引入额外依赖”的场景,第一反应就是nth_element,因为它速度快、不占额外内存、不易出 bug。但面试白板上我不会只丢这一句,因为面试官想考察你对算法本身的掌控力,而不是对标准库的熟悉程度。通常的做法是:口述快选思路时自己写一遍快速选择,最后再提一句“生产环境其实可以直接用 nth_element”,这样既有深度又有工程感。
3. 堆方案的两条路线:全员维护与只留k个
3.1 大顶堆:思路最直白的堆方案
堆的第一种用法非常直白:把所有数字全部放进一个大顶堆,然后连续弹出 k-1 次堆顶,剩下的堆顶就是第K大。
int findKthLargest(vector<int>& nums, int k) { priority_queue<int> pq(nums.begin(), nums.end()); for (int i = 1; i < k; ++i) { pq.pop(); } return pq.top(); }建堆的过程是O(n),这里用的是自底向上的建堆算法,不是一次次 push。之后每弹一次堆顶是O(log n),所以总复杂度是O(n + k log n)。代码极短,逻辑也容易向面试官解释:堆顶永远最大,弹掉前面的最大元素,剩下的堆顶自然就是第K大。
但这个方案有一个硬伤:空间复杂度是 O(n)。如果数据规模到亿级,堆本身占用的内存就非常可观,而且建堆要遍历全部数据,本质上没省什么力气。它只是把“排序”换成了“堆”,并没有抓住“只需要K个”这个可以压缩空间的关键。
3.2 小顶堆:把空间压到O(k)的关键思路
第二种堆方案是维护一个大小为 k 的最小堆。堆里始终存的是“目前见过的最大K个元素”,堆顶是这K个里面最小的那个,也就是“第K大的当前候选值”。每来一个新元素,拿它和堆顶比较:如果比堆顶大,说明它有资格进入TopK,于是弹出堆顶、把它插进去;如果比堆顶小或等于堆顶,说明它不可能是第K大,直接丢弃。
int findKthLargest(vector<int>& nums, int k) { priority_queue<int, vector<int>, greater<int>> pq; for (int x : nums) { if (pq.size() < k) { pq.push(x); } else if (x > pq.top()) { pq.pop(); pq.push(x); } } return pq.top(); }这里的greater<int>把优先队列默认的大顶堆变成了小顶堆。很多人第一次看这段会卡在这个地方:我找最大,为什么用最小堆?原因很简单:堆顶是当前TopK的“门槛”,新元素只要超过门槛就能进来。如果用最大堆,堆顶变成TopK里最大的那个,你反而没法判断新元素够不够格。
复杂度上,每个元素的插入和弹出都是O(log k),总共O(n log k);空间只有O(k)。当 k 远小于 n 时,这个方案几乎是时间和空间的完美平衡点。Python 的heapq版本写法也顺手:
import heapq class Solution: def findKthLargest(self, nums: List[int], k: int) -> int: heap = nums[:k] heapq.heapify(heap) for x in nums[k:]: if x > heap[0]: heapq.heapreplace(heap, x) return heap[0]3.3 小顶堆为什么是流式数据场景的默认答案
前面几种方案都有一个隐含假设:所有数据一次性全部给到,而且能放进内存。但真实世界经常不是这样。想象一个实时日志流,每秒都会有新的用户行为数据进来,你要随时能回答“当前第1000大是多少”。全量排序做不到,快选也做不到,因为数据不是静止的。
小顶堆特别适合这种场景:它只需要保留K个元素,数据一条一条进来,每一条都在常数对数时间内完成更新。你不需要等所有数据到位,任何时刻堆顶都是当期的第K大。
这个思路再往前走一步,就是数据流中位数的经典解法:一个大顶堆存较小的一半,一个小顶堆存较大的一半,两个堆的尺寸差控制在1,堆顶就能给出中位数。面试官如果顺着215题往深了问,大概率会追到这类变形题。所以小顶堆方案不只是“多一种解法”,它是一条知识链的起点。
4. 快速选择的魔鬼细节:从期望O(n)到最坏O(n²)
4.1 只递归一边:快速选择的本质
快速排序每次 partition 之后要递归处理左右两个子区间,而快速选择只递归处理包含目标下标的那一边。这一点点差别,让它的期望复杂度从O(n log n)降到了O(n)。
可以这样理解复杂度:第一次 partition 需要扫描大约 n 个元素;正常情况下,pivot 会让数组大致对半分,目标所在的一侧大约还剩 n/2 个元素;下一轮扫描 n/2 个;再下一轮 n/4 个……总扫描量是n + n/2 + n/4 + ...,等比数列求和趋向2n。所以期望复杂度是 O(n),这就是“快速选择比排序快一个数量级”的原因。
实现思路和快速排序很像,只是每次 partition 之后看返回的下标:
int findKthLargest(vector<int>& nums, int k) { int left = 0, right = nums.size() - 1; int target = k - 1; // 降序排列后第K大的下标 while (left <= right) { int pos = partition(nums, left, right); if (pos == target) return nums[pos]; if (pos < target) left = pos + 1; else right = pos - 1; } return -1; } int partition(vector<int>& nums, int l, int r) { int pivotIdx = l + rand() % (r - l + 1); swap(nums[pivotIdx], nums[r]); int pivot = nums[r]; int i = l; for (int j = l; j < r; ++j) { if (nums[j] > pivot) { swap(nums[i], nums[j]); ++i; } } swap(nums[i], nums[r]); return i; }上面的 partition 是降序版:大于 pivot 的放到左边,小于等于的留下,最后 pivot 回到它们中间。返回的 pos 表示“这个元素已经处于降序数组第 pos 位”,如果 pos 等于 target,说明第K大就位了。注意rand() % (r - l + 1)在区间长度较大时会有轻微偏置,但应付这道题足够;压测或生产代码建议换mt19937这类均匀随机数。
4.2 固定pivot的退化陷阱:教科书写法为什么会超时
如果偷懒不随机选 pivot,每次固定取第一个或最后一个元素,代码在绝大多数情况下也能跑,但一旦输入是近乎有序的数组,灾难就来了。
以升序数组为例,固定取第一个元素作为 pivot,partition 之后 pivot 位于最左边,右边剩下的都是比它大的元素,而目标往往在很靠右的位置。每轮只缩小了一个元素的搜索范围,扫描量变成n + (n-1) + (n-2) + ...,直接退化成 O(n²)。这种数据在真实场景并不少见——日志时间戳、数据库自增ID、用户ID排好序的列表,都是天然有序的输入。
所以随机化 pivot 不是“锦上添花”,而是把最坏情况的概率降到足够低的必要手段。随机选一个位置,输入数据就算故意构造,也没法稳定触发最坏情况。LeetCode 早期版本甚至专门加了针对固定 pivot 的测试用例,目的就是逼大家用随机化。
4.3 大量重复元素:三路划分是真正的救星
随机化解决了有序数组问题,但重复元素仍然会让经典 partition 崩溃。
假设数组里有大量相同值,比如一万个2夹杂着其他数字。Lomuto 分区会把“小于等于 pivot”的全部堆到一边,导致 pivot 两边极其不平衡。更麻烦的是,如果 pivot 值恰好是2,那一万个2分布在两侧,每轮 partition 都很难有效缩小区间。
解法是荷兰国旗分区,也叫三路划分:一次遍历把数组分成“大于 pivot”“等于 pivot”“小于 pivot”三段。因为等于 pivot 的元素已经不用再排序了,如果目标下标落在中间这段,可以直接返回,根本不需继续递归。这也是三路快选面对重复数据时能保持 O(n) 的原因。
pair<int, int> partition3way(vector<int>& nums, int l, int r, int pivotVal) { int i = l, j = l, k = r; while (j <= k) { if (nums[j] > pivotVal) { swap(nums[i], nums[j]); ++i; ++j; } else if (nums[j] < pivotVal) { swap(nums[j], nums[k]); --k; } else { ++j; } } return {i, k}; // 等于 pivotVal 的区间是 [i, k] }主循环改成按等于区间判断:
while (left <= right) { int pivotVal = nums[left + rand() % (right - left + 1)]; auto [eqL, eqR] = partition3way(nums, left, right, pivotVal); if (target >= eqL && target <= eqR) return nums[target]; if (target < eqL) right = eqL - 1; else left = eqR + 1; }这里pivotVal用的是数值而不是下标,因为 partition 过程中等于 pivot 的元素可能被交换到任意位置。返回的[eqL, eqR]就是整段等于 pivotVal 的元素区间,只要目标下标落在这个区间,答案已经确定。
4.4 迭代写法:别让递归栈成为新的风险
很多教材喜欢用递归实现快速选择,但递归有两个隐患:一是函数调用开销,二是最坏情况下递归深度可能达到 O(n),导致栈溢出。虽然随机化让最坏情况变成小概率事件,但在真实环境里,一段可能跑在生产链路上的代码不应该依赖“运气”。
我上面的代码已经写成了while (left <= right)的迭代形式。这么做的好处除了避免栈溢出,还让“到底下一步搜左区间还是右区间”这个判断变得非常显式。你可以把 target 理解成一个坐标,partition 之后看坐标落在哪一段,就移动到哪一段。思路清晰,代码也容易调试。
5. 计数排序:当数据范围给你开后门
5.1 值域受限时的作弊级解法
这道题有一个容易被忽略的隐藏条件:数值范围是有限的。在很多版本里,-10^4 <= nums[i] <= 10^4。这意味着整个数组塞来塞去,元素只能落在大约两万零一个可能值里。既然如此,完全不需要比较元素,直接统计每个值出现了多少次,就能通过频次累计找到第K大。
int findKthLargest(vector<int>& nums, int k) { const int MIN_VAL = -10000; const int MAX_VAL = 10000; const int OFFSET = 10000; const int RANGE = 20001; vector<int> freq(RANGE, 0); for (int x : nums) { freq[x + OFFSET]++; } int cnt = 0; for (int v = MAX_VAL; v >= MIN_VAL; --v) { cnt += freq[v + OFFSET]; if (cnt >= k) return v; } return -1; }逻辑很简单:统计频次,然后从最大值往下累加,累加值第一次达到 k 的值就是答案。复杂度是O(n + M),M 是值域跨度,这里 M = 20001,比O(n log n)快得多。空间上需要一个定长数组,这也是一种典型的空间换时间策略。
很多人把计数排序当成“排序方法”来记忆,反而忽略了它在“找名次”问题里的直接价值。实际上计数排序并不需要完整重建有序数组,只需要频次累加就能回答排名类问题,这也是它在这道题里显得特别轻量的原因。
5.2 负数和偏移索引的常见错误
计数数组的下标不能是负数,所以负数元素需要做偏移。比如 -10000 映射到下标 0,10000 映射到下标 20000,中间的值依次平移。这个偏移量写错,轻则数组越界,重则统计错位后答案全错。
我见过一个很经典的错误:只处理了正数,负数的x + OFFSET还是负数,运行时直接崩。另一个错误是统计完了从 0 往高位遍历,却用“找第K小”的逻辑去回答“找第K大”,方向选反,结果自然不对。
防御性做法是先把边界常量定义清楚,再写遍历逻辑。从大到小遍历时,循环变量直接用原始数值,只在需要索引时做一次变换,这样代码的意图最清晰,也不容易把自己绕晕。
5.3 计数排序的边界:什么时候不能用
计数排序虽然快,但适用范围很明确:你必须提前知道数据的值域。如果输入是 32 位整数,最大跨度高达 42 亿,开数组就是天方夜谭。浮点数、字符串、自定义对象更是无法直接映射下标。
还有一类场景容易误判:值域不大但 n 很小。比如只有 10 个元素,值域是 0 到 100 万,虽然 M 不小但 n 小,排序反正也就O(n log n),计数排序的空间开销反而更夸张。所以“线性复杂度”并不总是最优,工程选择永远要看实际约束。
把计数排序放在这题的最后讲,是因为它提供了一个和前面完全不同的视角:排序不一定非要比较元素,数据的分布特征本身就是信息。面试时你能主动提到这一点,通常会比单纯背出快排和堆更让面试官眼前一亮。
6. 必测用例与性能实测:一份能直接用的检验清单
6.1 高频边界用例
代码写完不是结束,跑测试才是开始。这道题的边界用例我建议至少覆盖以下几类:
| 场景 | 示例输入 | k | 期望输出 |
|---|---|---|---|
| 基础用例 | [3,2,1,5,6,4] | 2 | 5 |
| 重复元素 | [3,2,3,1,2,4,5,5,6] | 4 | 4 |
| 单元素数组 | [1] | 1 | 1 |
| 全部相同 | [2,2,2,2] | 1 | 2 |
| k等于n | [1,2,3,4] | 4 | 1 |
| 负数混合 | [-1,-5,3,0] | 2 | 0 |
| 大数据量 + 有序输入 | 1..100000 升序 | 50000 | 50001 |
有序输入那个用例是专门用来检验快速选择是否做了随机化。如果固定取第一个元素作为 pivot,这条用例会直接把代码拖进 O(n²) 泥潭,千万注意。
还有一个小建议:测试时把“第K大”和“第K小”交替着测一遍。因为下标换算的两个公式k-1(降序)和n-k(升序)特别容易搞混,每写一种解法都自己手动推一次,能省去很多调试时间。
6.2 性能实测要关注什么
我在本地拿一百万随机整数跑过一轮各方案对照,量级感受大致是这样:全量排序最稳,时间在百毫秒级;nth_element通常比排序快好几倍;小顶堆和快速选择在随机数据上接近,但小顶堆的 log k 因子在 k 不大时可以忽略;计数排序在值域受限时是绝对的线性王者,百万数据几乎是秒完。
不过这个结果仅供参照,实际性能高度依赖语言、编译器和机器。真正值得关注的是它们随数据规模变化的趋势:排序的曲线是无条件上扬的n log n;小顶堆的曲线会随 k 增长而变陡;快选在随机数据上斜率接近线性,但存在尾部风险;计数排序几乎是一条水平线,前提是你没撞上值域爆炸。
笔试题里纸面复杂度是主要评分标准,但真实业务里你还要考虑常数因子、缓存局部性和最坏情况。这也是为什么我前面一直强调:不要只看大O,要看清数据约束再动手。
6.3 实战选型:我的思考顺序
最后分享一个我一直在用的选型思路。拿到任何TopK需求,先问自己四个问题:数据总量多大?值域范围是否有限?内存能不能放下全部数据?查询是一次性的还是要反复执行?
四个问题的答案会直接指向最优方案。总量小,直接排序;值域有限且数据量大,计数排序可能最合适;数据流式到达或者总量大到无法入内存,小顶堆是稳妥答案;单次查询、内存紧张、数据分布未知,快速选择配合随机pivot和三路划分足够强;C++工程代码不追求炫技时,nth_element是性价比之王。
我在实际项目里处理过一次千万级日志的Top100统计,最终落地的方案就是小顶堆。原因很简单:日志数据从磁盘分批读入,内存里只留100个元素,堆顶随时能给出当前第100大的值。换成快速选择,反而需要把全量数据先加载进内存,仅这一点就被否决了。
最后说一个我的习惯
不管题目问的是第K大还是第K小,我在写代码之前都会先在注释里写下换算公式:第K大 = 降序第 k-1 个位置 = 升序第 n-k 个位置。然后才开始动键盘。这个小动作帮我避免过很多次方向搞反的低级错误。
215题的所有解法,说到底就是在不同资源约束下回答同一个问题。数据规模、内存上限、值域范围、查询频率,这四个参数一变,最优解就完全不一样。你在面试或工程里做选择时,别急着背模板,先把这四个问题问一遍,答案自然就出来了。