讲解

堆(这里专指二叉堆)是一棵「近似完全」的二叉树,用数组紧凑存放:下标 i 的左右孩子分别是 2i+1 和 2i+2,父亲是 (i-1)>>1。它只维护一条偏序:每个节点都不大于(小顶堆)或不小于(大顶堆)它的孩子。注意堆不是有序结构——左右子树之间没有大小关系,它只保证一件事:堆顶永远是全局最值。插入和删除都是 O(log n)(沿一条树高路径调整),取最值 O(1)。

两个核心操作的手法。上浮(siftUp):新元素放数组末尾,然后和父亲比,违反堆序就交换,一路冒到顶或停住。下沉(siftDown):取堆顶时把末尾元素补到根部,然后和两个孩子的较小者比,该换就换,一路沉到底。掌握这两个原语,堆就没有秘密了。JS 没有内置堆,手写 MinHeap 是必备技能——好在代码不到 30 行。

堆的招牌应用是 Top K 问题。求第 K 大元素:维护一个容量 K 的小顶堆,扫完全部元素后堆顶即第 K 大——小顶堆像个「筛子」,比第 K 大还小的元素进不来。这个「容量 K 的反向堆」套路(求最大 K 个用小顶堆、求最小 K 个用大顶堆)初看反直觉,想清楚「堆里要保住的是谁」就通了。Top K 高频元素则先哈希计数,再把 (频次, 值) 丢进容量 K 的堆,把上一章的哈希表和本章的堆串了起来。

示例

手写一个支持自定义比较器的 MinHeap,再用它解决数组第 K 大元素和 Top K 高频元素:

import assert from 'node:assert/strict';

class MinHeap {
  constructor(cmp = (a, b) => a - b) {
    this.data = [];
    this.cmp = cmp;
  }
  size() {
    return this.data.length;
  }
  peek() {
    return this.data[0];
  }
  push(x) {
    this.data.push(x);
    let i = this.data.length - 1;
    while (i > 0) {
      const parent = (i - 1) >> 1;
      if (this.cmp(this.data[parent], this.data[i]) <= 0) break; // 堆序满足
      [this.data[parent], this.data[i]] = [this.data[i], this.data[parent]];
      i = parent;
    }
  }
  pop() {
    const top = this.data[0];
    const last = this.data.pop();
    if (this.data.length > 0) {
      this.data[0] = last;
      let i = 0;
      for (;;) {
        const l = 2 * i + 1;
        const r = 2 * i + 2;
        let smallest = i;
        if (l < this.data.length && this.cmp(this.data[l], this.data[smallest]) < 0) smallest = l;
        if (r < this.data.length && this.cmp(this.data[r], this.data[smallest]) < 0) smallest = r;
        if (smallest === i) break;
        [this.data[smallest], this.data[i]] = [this.data[i], this.data[smallest]];
        i = smallest;
      }
    }
    return top;
  }
}

// 第 K 大:容量 K 的小顶堆,堆里留下的就是最大的 K 个
function findKthLargest(nums, k) {
  const heap = new MinHeap();
  for (const x of nums) {
    heap.push(x);
    if (heap.size() > k) heap.pop(); // 弹掉最小的,保住大者
  }
  return heap.peek();
}

// Top K 高频:先计数,再按频次入堆
function topKFrequent(nums, k) {
  const freq = new Map();
  for (const x of nums) freq.set(x, (freq.get(x) ?? 0) + 1);
  const heap = new MinHeap((a, b) => a.count - b.count);
  for (const [val, count] of freq) {
    heap.push({ val, count });
    if (heap.size() > k) heap.pop();
  }
  return heap.data.map((e) => e.val);
}

// 堆本身的行为:push 任意序,pop 出升序流
const heap = new MinHeap();
for (const x of [5, 1, 8, 3, 9, 2]) heap.push(x);
const sortedOut = [];
while (heap.size() > 0) sortedOut.push(heap.pop());
assert.deepStrictEqual(sortedOut, [1, 2, 3, 5, 8, 9]);

assert.strictEqual(findKthLargest([3, 2, 1, 5, 6, 4], 2), 5);
assert.strictEqual(findKthLargest([3, 2, 3, 1, 2, 4, 5, 5, 6], 4), 4);

const top2 = topKFrequent([1, 1, 1, 2, 2, 3], 2);
assert.strictEqual(top2.length, 2);
assert.ok(top2.includes(1) && top2.includes(2));

console.log('堆示例全部通过');

「push 任意序、pop 出升序流」这个性质顺便给了一个排序算法:堆排序(全部 push 再逐个 pop,O(n log n))——示例第一段其实就演示了它。

常见坑

  • 求第 K 大用大顶堆:大顶堆容量 K 留下的是最小的 K 个,答案反了;记住「求大用小顶,求小用大顶」这个反向直觉。
  • 孩子下标算错:0 基数组孩子是 2i+1 / 2i+2、父亲 (i-1)>>1;有些教材用 1 基(孩子 2i / 2i+1),别混。
  • 下沉时只和左孩子比:必须和两个孩子中的较小者交换,否则换完仍可能违反堆序。
  • pop 空堆或 peek 空堆:面试实现里先答边界——size 为 0 时的返回值要先定义清楚。
  • 能用排序解决却强上堆:K 固定且数组小时直接排序更简洁;堆的价值在 K << n 或数据流式到达的场景,说清楚取舍。

小结

堆 = 数组紧凑存放的偏序树,堆顶即最值;上浮下沉两个原语搞定插入删除;Top K 用容量 K 的反向堆。下一章进入枚举的艺术:回溯。