别再背 TopK 模板了:搞懂"为什么用小顶堆",面试一次讲清
备选标题:
- 《彻底搞懂 TopK:最小堆为什么能求"第 K 大"》
- 《别再背 TopK 模板了:搞懂"为什么用小顶堆",面试一次讲清》
一道面试超高频题:在 10 万个数字里,找出第 3 大的那个。
你大概率会先排序:nums.sort((a, b) => b - a)[2],一行搞定,O(n log n)。这没错,但面试官接着问:「如果数据是流式的、不断有新数字进来,你每次都要全排序吗?」
这时候有人会答「用堆」,然后------写出一个最大堆,还觉得自己很对。
但正确的做法,是最小堆。求「第 K 大」,为什么反而用「最小堆」?想明白这个反直觉的点,TopK 这道题才算真的会了。
这篇文章就是讲清楚这件事的,并且用实测数据告诉你:堆也不是永远更快,它快在哪、什么时候才快。
一句话:堆是什么
堆是一棵「完全二叉树」,小顶堆的每个节点都不比它的孩子大(父 ≤ 子)。所以堆顶永远是当前集合的最小值,取堆顶 O(1),插入/删除 O(log n)。
生活里最像堆的东西,是年会抽奖箱:你想最快摸到「最大那张奖券」,就把箱子设计成「顶部永远浮着当前最小/最大的一张」。放新券会自动沉浮到正确位置,抽走顶部剩下的自动重新排好------你不用每次把箱子倒出来全排一遍。
堆在数组里的存储,用下标算父子,完全不用指针:
text
小顶堆(父 ≤ 子),堆顶 = 最小值:
3 ← 堆顶
/ \
7 5
/ \ / \
9 11 8 13
数组存储:[3, 7, 5, 9, 11, 8, 13]
下标: 0 1 2 3 4 5 6
父节点 parent(i) = (i-1) >> 1
左孩子 left(i) = 2*i + 1
右孩子 right(i) = 2*i + 2
记这三个下标公式,手写堆就只用写两个「维护」动作。
手写一个小顶堆(JS 没有原生堆,这个绕不开)
Java 有 PriorityQueue,Python 有 heapq,JS 什么都没有。 面试让你用堆,你就得自己写。好在核心就两个方法:siftUp(上浮)和 siftDown(下沉)。
javascript
class MinHeap {
constructor() { this.heap = []; }
peek() { return this.heap[0]; } // 🔑 堆顶 = 最小值,O(1)
size() { return this.heap.length; }
push(val) { // 插入:放末尾再上浮
this.heap.push(val);
this._siftUp(this.heap.length - 1);
}
pop() { // 弹出堆顶(最小值)
const top = this.heap[0];
const last = this.heap.pop();
if (this.heap.length > 0) {
this.heap[0] = last; // 末尾元素搬到堆顶,再下沉
this._siftDown(0);
}
return top;
}
_siftUp(i) { // 🔑 比父节点小就往上换
while (i > 0) {
const p = (i - 1) >> 1;
if (this.heap[p] <= this.heap[i]) break;
[this.heap[p], this.heap[i]] = [this.heap[i], this.heap[p]];
i = p;
}
}
_siftDown(i) { // 🔑 和更小的那个孩子换
for (;;) {
let l = i * 2 + 1, r = i * 2 + 2, min = i;
if (l < this.heap.length && this.heap[l] < this.heap[min]) min = l;
if (r < this.heap.length && this.heap[r] < this.heap[min]) min = r;
if (min === i) break;
[this.heap[min], this.heap[i]] = [this.heap[i], this.heap[min]];
i = min;
}
}
}
两个动作的对应用处,一句话记住:
- 插入 → 上浮:新元素从叶子往上「冒泡」,直到不比父小。
- 删除堆顶 → 下沉:把最后一个元素挪到堆顶,再往下「沉」,直到不比孩子大。
⚠️ 下沉时是和更小的那个孩子交换(保证父依然是最小),不是随便跟一个孩子换------换错了堆性质就崩了。
核心:求第 K 大,为什么用小顶堆?
现在回到那个反直觉的问题。求「第 K 大」,直觉是「我关心大的,就用大顶堆」。但大顶堆的堆顶是最大值 ,这对 TopK 没用------你要的是「踢掉小的、留住大的」,而最小值才在最小堆的堆顶。
关键套路只有一句话:
维护一个大小为 K 的小顶堆,让它始终装着「最大的 K 个」。堆顶是这 K 个里最小的 = 全局第 K 大。
新来一个数字 x,只和堆顶比:
javascript
function findKthLargest(nums, k) {
const h = new MinHeap(); // 🔑 小顶堆,大小不超过 k
for (const x of nums) {
if (h.size() < k) {
h.push(x); // 堆还没满,直接进
} else if (x > h.peek()) { // 堆满了,且 x 比"当前第K大"大
h.pop(); // 踢掉最小的(堆顶)
h.push(x); // 让 x 顶替进来
}
// x <= 堆顶:x 不够格进"最大的K个",直接忽略
}
return h.peek(); // 堆顶 = 第 K 大
}
看明白了吗?为什么是「小」顶堆:
- 你要维护「最大的 K 个」,于是需要随时能快速找到这 K 个里最小的那个------因为新元素只需要和「门槛」比,比门槛大就留下、比门槛小就滚。
- 而这个「K 个里最小的」,恰好就是小顶堆的堆顶,O(1) 就能拿到。
如果用大顶堆,堆顶是最大的,新元素来了你根本不知道「该不该踢、踢谁」------因为你关心的最小值,被埋在堆的深处。
求第 K 大用小顶堆,不是反人类,是「踢最小的」这个动作,决定了你必须让最小值待在堆顶。
堆 vs 排序:真实差距取决于 k
「用堆省时间」这句话只说对了一半。实测一下(n=100000 个随机数,找第 K 大):
ini
n=100000 k=10 排序=18ms 小顶堆=2ms
n=100000 k=1000 排序=19ms 小顶堆=2ms
n=100000 k=50000 排序=17ms 小顶堆=10ms
结果里藏着一个容易忽略的真相:
- k 很小(10、1000):堆快约 9 倍。因为堆是 O(n log k),k 小 → log k 很小 → 近乎线性;排序永远是 O(n log n)。
- k ≈ n/2(50000):堆只快 1.7 倍。因为这时 log k ≈ log n,堆在复杂度上没占多少便宜,还背着手写堆更高的常数开销。
所以「堆一定比排序快」是错的。堆赢在 k 远小于 n 的时候;k 一旦接近 n,两者的差距就抹平了。
这个判断,比「会写堆」更值钱------面试官问「什么时候用堆、什么时候用排序」,你要能答出「取决于 k 和 n 的关系」,而不是背一句「堆更快」。
什么时候用堆,一张表说清
| 场景 | 选谁 | 复杂度 |
|---|---|---|
| k 很小,数据量巨大 | 堆 | O(n log k) |
| 数据流、实时维护最值 | 堆 | 每次 O(log k) |
| k 接近 n / 要全部有序 | 排序 | O(n log n) |
| 求中位数(动态) | 两个堆 | 大顶堆存小一半 + 小顶堆存大一半 |
| 前 K 高频元素(347) | 堆 | 先计数,再按频次建堆 |
再补三个高频坑:
- 堆方向别反。 求第 K 大 用小顶堆,求第 K 小用大顶堆------「求大留大、用最小顶踢门槛」。
- k 可能大于数组长度。 先做边界判断,堆大小不超过
nums.length。 - JS 没有原生堆。 面试口述可以提「手写堆把 O(n log n) 降到 O(n log k)」;如果只是刷题求过,用
sort一行能 AC,但别把「sort 是堆」混为一谈。
结尾:记住「踢最小的」这个动作
回到开头那个问题------为什么求第 K 大,要用最小堆。
因为 TopK 的本质不是「找到最大的」,而是「不断地踢掉不够大的」。而踢东西要踢最差的那个,最小值必须在堆顶,所以是小顶堆。
如果只记住一句话,记住这句:
求第 K 大用小顶堆------你要维护「最大的 K 个」,就得让「这 K 个里最小的」待在堆顶,新元素跟它比一下,不够格就滚。
下次再写 TopK,别急着背模板,先问自己一句:「我要留下谁、踢掉谁?那个该被踢的,待在堆顶吗?」 想清楚这个,大顶堆还是小顶堆,你自己就能推导出来。
留一个开放问题:如果数据是源源不断流进来的(不是一次性数组),第 K 大要实时更新,堆和排序的差别会更大还是更小?欢迎在评论区说说你的分析,或者你被「求大用小堆」反直觉坑过的经历。