求第 K 大,为什么反而要用最小堆?

别再背 TopK 模板了:搞懂"为什么用小顶堆",面试一次讲清

备选标题:

  1. 《彻底搞懂 TopK:最小堆为什么能求"第 K 大"》
  2. 《别再背 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 有 heapqJS 什么都没有。 面试让你用堆,你就得自己写。好在核心就两个方法: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) 先计数,再按频次建堆

再补三个高频坑:

  1. 堆方向别反。 求第 K 用小顶堆,求第 K 用大顶堆------「求大留大、用最小顶踢门槛」。
  2. k 可能大于数组长度。 先做边界判断,堆大小不超过 nums.length
  3. JS 没有原生堆。 面试口述可以提「手写堆把 O(n log n) 降到 O(n log k)」;如果只是刷题求过,用 sort 一行能 AC,但别把「sort 是堆」混为一谈。

结尾:记住「踢最小的」这个动作

回到开头那个问题------为什么求第 K 大,要用最小堆。

因为 TopK 的本质不是「找到最大的」,而是「不断地踢掉不够大的」。而踢东西要踢最差的那个,最小值必须在堆顶,所以是小顶堆。

如果只记住一句话,记住这句:

求第 K 大用小顶堆------你要维护「最大的 K 个」,就得让「这 K 个里最小的」待在堆顶,新元素跟它比一下,不够格就滚。

下次再写 TopK,别急着背模板,先问自己一句:「我要留下谁、踢掉谁?那个该被踢的,待在堆顶吗?」 想清楚这个,大顶堆还是小顶堆,你自己就能推导出来。

留一个开放问题:如果数据是源源不断流进来的(不是一次性数组),第 K 大要实时更新,堆和排序的差别会更大还是更小?欢迎在评论区说说你的分析,或者你被「求大用小堆」反直觉坑过的经历。


相关推荐
jingchao19981 小时前
Cannot read properties of null (reading ‘insertBefore‘)
前端·javascript·vue.js
没落之王2 小时前
易学使者郑氏正脉郑冰推演马航事件
大数据·人工智能·算法·机器学习·可用性测试
梦醒沉醉2 小时前
2、JavaScript控制流和错误处理
javascript
晓得迷路了2 小时前
栗子前端技术周刊第 142 期 - DeepSeek Harness、pnpm 12 RC、crypto‑js...
前端·javascript·ai编程
Fluxart.ai2 小时前
Etsy手工制品换背景,用什么AI能保留手作质感?
前端·javascript·人工智能
阿部多瑞 ABU2 小时前
告别手工核图:基于 .NET + MuPDFCore 的 CAD 等轴测 PDF 材料表提取与新旧版本对比实战
后端·算法·ui·pdf·c#
郝学胜-神的一滴2 小时前
并查集深度入门:从玄学抽象到 QuickFind & QuickUnion 源码实战
数据结构·c++·python·程序人生·算法·软件开发
比奇堡裤头村2 小时前
统计学习方法——决策树
算法·决策树·学习方法
小玮看世界2 小时前
[Python] str() 和 join() 的区别与实战避坑指南
前端·javascript·python