从TopK到工程选型:LeetCode 215第K大元素全解法深度解析

发布时间:2026/10/18 16:00:37
从TopK到工程选型:LeetCode 215第K大元素全解法深度解析
这题我第一次刷的时候只用了三行代码就过了当时还觉得挺简单。直到后来在真实业务里处理百万级热门关键词排序才意识到自己漏掉了这个问题的内核。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-k1大从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(vectorint 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 5n log n大约是 26亿次操作而nk只有5亿次操作虽然带较大常数差距就出来了。这个思路在日常代码里偶尔有奇效。比如你只需要排序后最靠前的几十个元素完全没必要把整个数组排一遍。但回到这道题k 可能接近 n这种方案的最坏复杂度会退化得非常难看所以不是主流解法。它存在的意义是提醒你算法题别套模板参数一变方案优劣就反转。2.3 nth_element用库函数优雅过关C 标准库里藏着一个专门解决这类问题的函数nth_element。它可以保证如果整个区间排序第 n 个位置上的元素就位并且它左边的元素都“不大于”它右边的元素都“不小于”它按比较器的语义理解。函数内部通常是内省选择算法平均复杂度 O(n)。用在这道题上一行核心代码就够了int findKthLargest(vectorint nums, int k) { nth_element(nums.begin(), nums.begin() k - 1, nums.end(), greaterint()); return nums[k - 1]; }greaterint()让比较器的意义变成“降序”于是第K大的元素就会落到begin() k - 1这个位置。如果你不喜欢降序比较器也可以升序找第n-k个位置。我在工程代码里遇到“需要TopK但不方便引入额外依赖”的场景第一反应就是nth_element因为它速度快、不占额外内存、不易出 bug。但面试白板上我不会只丢这一句因为面试官想考察你对算法本身的掌控力而不是对标准库的熟悉程度。通常的做法是口述快选思路时自己写一遍快速选择最后再提一句“生产环境其实可以直接用 nth_element”这样既有深度又有工程感。3. 堆方案的两条路线全员维护与只留k个3.1 大顶堆思路最直白的堆方案堆的第一种用法非常直白把所有数字全部放进一个大顶堆然后连续弹出 k-1 次堆顶剩下的堆顶就是第K大。int findKthLargest(vectorint nums, int k) { priority_queueint 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(vectorint nums, int k) { priority_queueint, vectorint, greaterint 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(); }这里的greaterint把优先队列默认的大顶堆变成了小顶堆。很多人第一次看这段会卡在这个地方我找最大为什么用最小堆原因很简单堆顶是当前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(vectorint 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(vectorint 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每次固定取第一个或最后一个元素代码在绝大多数情况下也能跑但一旦输入是近乎有序的数组灾难就来了。以升序数组为例固定取第一个元素作为 pivotpartition 之后 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) 的原因。pairint, int partition3way(vectorint 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(vectorint nums, int k) { const int MIN_VAL -10000; const int MAX_VAL 10000; const int OFFSET 10000; const int RANGE 20001; vectorint 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 映射到下标 010000 映射到下标 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]25重复元素[3,2,3,1,2,4,5,5,6]44单元素数组[1]11全部相同[2,2,2,2]12k等于n[1,2,3,4]41负数混合[-1,-5,3,0]20大数据量 有序输入1..100000 升序5000050001有序输入那个用例是专门用来检验快速选择是否做了随机化。如果固定取第一个元素作为 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题的所有解法说到底就是在不同资源约束下回答同一个问题。数据规模、内存上限、值域范围、查询频率这四个参数一变最优解就完全不一样。你在面试或工程里做选择时别急着背模板先把这四个问题问一遍答案自然就出来了。