堆(Heap):小顶堆、大顶堆与最佳实现(JS 版)

3 阅读17分钟

目录

  1. 堆是什么:完全二叉树 + 堆序性
  2. 为什么用数组:紧凑存储 + O(1) 寻亲
  3. 两个灵魂操作:siftUp 与 siftDown
  4. 三大基础操作:push / pop / peek
  5. 堆化(heapify)的 O(n) 秘籍
  6. 最佳实现:基于比较器的统一抽象
  7. 小顶堆 vs 大顶堆:一行之差
  8. 三大实战应用
  9. 复杂度分析
  10. 决策表
  11. 一句话总结

1. 堆是什么:完全二叉树 + 堆序性

堆(Heap) 是满足两个性质的 完全二叉树

性质含义
完全性除了最后一层,其他层都"塞满";最后一层从左到右连续填充,不能有"洞"
堆序性父节点和子节点之间有固定的"大小关系"(不关心左右子树之间的顺序

按堆序性的方向分两种:

类型性质根节点
小顶堆(Min-Heap)每个父节点 ≤ 它的孩子最小值
大顶堆(Max-Heap)每个父节点 ≥ 它的孩子最大值

样例数据 [3, 1, 4, 1, 5, 9, 2, 6] 自底向上堆化后的小顶堆:

                  1  ← 根 = 最小值
                /   \
              1       2
             / \     / \
            3   5   9   4
           /
          6

关键提醒:堆 不保证 左右子树之间的顺序。上图里左子树有 1,右子树有 2,这不是堆的要求。堆只关心 父子,不管 兄弟

这也意味着:堆不是 BST。BST 的左 < 根 < 右 是全序关系;堆只要求父子有序。


2. 为什么用数组:紧凑存储 + O(1) 寻亲

完全二叉树最爽的地方:没有空洞,可以紧凑存到数组里,连指针都不需要。

把上图的小顶堆写成数组(按层序 BFS 填进去):

索引:  0   1   2   3   4   5   6   7
值:    1   1   2   3   5   9   4   6

索引公式(这是堆的"灵魂",请背下来):

parent(i) = (i - 1) >> 1     // i > 0left(i)   = i << 1 | 1       // 或 2i + 1
right(i)  = i << 1 | 2       // 或 (i + 1) << 1

验证一下(索引 5,值 9):

  • parent(5) = (5-1) >> 1 = 2 → 索引 2,值 2 ✓(9 的父节点是 2)
  • left(5) = 5<<1|1 = 11 → 越界(9 是叶子)✓
  • right(5) = 5<<1|2 = 12 → 越界 ✓

为什么用位移 >>1<<1|1 而不是 /2*2+1 位运算快一丢丢,但更重要的是 强调"这就是二进制上的事"。生产环境两种写法都对,写哪个看团队风格。

对比指针实现

数组版指针版
空间只存值还要存 left/right 两个指针
Cache 友好✓ 连续内存✗ 节点散在堆里
寻亲复杂度O(1) 算一下O(1) 但要走指针
工业首选教学偶尔用

结论:数组是最佳实现方式。


3. 两个灵魂操作:siftUp 与 siftDown

堆的所有操作都围绕 两个动作 展开。掌握这两个动作,堆就掌握了一大半。

siftUp(向上浮)

新元素插入到末尾后,沿着 父节点方向 一路往上,遇到比自己"差"(小顶堆里更大)的父节点就交换,直到满足堆序为止。

function siftUp(i):
  while i > 0 且 heap[i] < heap[parent(i)]:   # 大顶堆改成 >
    swap(heap[i], heap[parent(i)])
    i = parent(i)

siftDown(向下沉)

堆顶被取出后,把末尾元素挪到堆顶,沿着 两个孩子中更优先的那个 一直往下交换,直到满足堆序为止。

function siftDown(i):
  while true:
    left  = left(i), right = right(i)
    best  = i
    if left  < n 且 heap[left]  < heap[best]: best = left   # 大顶堆改 >
    if right < n 且 heap[right] < heap[best]: best = right
    if best == i: break                       # 已经到位
    swap(heap[i], heap[best])
    i = best

为什么 siftUp 给 push 用,siftDown 给 pop 用?

操作破坏点修复方向
push(x)末尾多了 x,只在路径末尾违反堆序siftUp:从末尾向上修
pop()堆顶被取出,把末尾元素挪到堆顶,只在堆顶违反堆序siftDown:从堆顶向下修

每条路径最多 log n 步,每步 O(1) → 单次操作 O(log n)

为什么 siftDown 要和两个孩子里"更优先"的那个交换? 因为我们要找的是"违反堆序的那个孩子",跟它换;如果跟另一个孩子换,新换上去的元素可能依然不满足堆序,还得再来一次。

3.1 具体例子:siftUp(左孩子情形)

初始小顶堆 [3, 5, 8, 7],插入 1(落到索引 4,是 5左孩子):

         3
       /   \
      5     8
     /
    7

第 1 步(i=4,p=1):

  • arr[4]=1 vs arr[1]=5 → 1 更小,交换
  • 数组 → [3, 1, 8, 7, 5]
  • i = p = 1 ← 新插入的 1 现在坐在了原父节点 5 的位置上

第 2 步(i=1,p=0):

  • arr[1]=1 vs arr[0]=3 → 1 更小,交换
  • 数组 → [1, 3, 8, 7, 5]
  • i = 0,循环退出

祖节点是会考虑的,靠的就是 i = p 这一行:每比较+交换一次,i 就往上爬一层,下一轮的 p = (i-1)/2 取出的自然就是上一轮的"祖节点"。直到 i = 0 爬到根,循环结束。

不管插入的节点是左孩子(2p+1)还是右孩子(2p+2),逻辑完全一样——下标算出来的 p 就是它的父节点,循环条件只看 i > 0,跟左右无关。

3.2 具体例子:siftDown(顶部往下冒)

数组 [10, 5, 2, 1, 8, 3, 4](n=7),从索引 0 开始 siftDown:

         10
        /  \
       5    2
      / \  / \
     1   8 3  4

第 1 步(i=0):

  • 左右孩子:arr[1]=5, arr[2]=2
  • 找最小:5 < 10 → best=1;2 < 5 → best=2(取更小的那个孩子)
  • 10 > 2,交换 → [2, 5, 10, 1, 8, 3, 4]i = best = 2

第 2 步(i=2):

  • 左右孩子:arr[5]=3, arr[6]=4
  • 找最小:3 < 10 → best=5;4 < 3 不成立 → best=5
  • 10 > 3,交换 → [2, 5, 3, 1, 8, 10, 4]i = best = 5

第 3 步(i=5):

  • l=11, r=12 都越界(n=7),没孩子
  • best 还是 5,break

最终 [2, 5, 3, 1, 8, 10, 4],每个父节点 ≤ 它的孩子,合法小顶堆 ✓

3.3 siftUp 与 siftDown 对照

siftUpsiftDown
算索引p = (i-1) >> 1l = 2i+1, r = 2i+2
比较对象子 vs 父自己 vs 两孩子的最小
i 跳到哪i = p(往上爬一层)i = best(往下掉一层)
停的条件i === 0 或父更小没孩子,或都比自己大

4. 三大基础操作:push / pop / peek

class MinHeap {
  constructor() { this.heap = []; }

  peek() {                                    // O(1)
    return this.heap[0];
  }

  push(val) {                                 // O(log n)
    this.heap.push(val);                      // 1. 放到末尾
    this._siftUp(this.heap.length - 1);       // 2. 往上冒
  }

  pop() {                                     // O(log n)
    if (this.heap.length === 0) return undefined;
    const top = this.heap[0];                 // 1. 记下堆顶(要返回的)
    const last = this.heap.pop();             // 2. 取出末尾
    if (this.heap.length > 0) {
      this.heap[0] = last;                    // 3. 末尾元素顶到堆顶
      this._siftDown(0);                      // 4. 往下沉
    }
    return top;
  }

  _siftUp(i) {
    while (i > 0) {
      const p = (i - 1) >> 1;
      if (this.heap[i] < this.heap[p]) {
        [this.heap[i], this.heap[p]] = [this.heap[p], this.heap[i]];
        i = p;
      } else break;
    }
  }

  _siftDown(i) {
    const n = this.heap.length;
    while (true) {
      const l = 2 * i + 1, r = 2 * i + 2;
      let s = i;
      if (l < n && this.heap[l] < this.heap[s]) s = l;
      if (r < n && this.heap[r] < this.heap[s]) s = r;
      if (s === i) break;
      [this.heap[i], this.heap[s]] = [this.heap[s], this.heap[i]];
      i = s;
    }
  }
}

pop() 的 4 步走一遍(假设小顶堆 [1, 2, 3, 4, 5]):

初始: [1, 2, 3, 4, 5]   堆顶 1,要返回
1. top = 1
2. last = 5, 数组变 [1, 2, 3, 4]
3. 末尾元素 5 顶到堆顶 → [5, 2, 3, 4]
4. siftDown(0): 5 vs 2、3,较小的 2 上来 → [2, 5, 3, 4]
                  5 vs 4,4 上来 → [2, 4, 3, 5]
                  5 是叶子,结束
返回 1,堆变 [2, 4, 3, 5]   ← 仍然是小顶堆 ✓

为什么 pop 不直接 siftDown(0)? 因为要先取出堆顶的值(要返回)。如果直接覆盖数组然后 siftDown,下标 0 已经是新元素,没问题 —— 关键是 要先取出旧堆顶的值,否则就丢了。


5. 堆化(heapify)的 O(n) 秘籍

把一个 乱序数组 变成堆有两种思路:

思路方法复杂度
自顶向下从前到后,一个一个 pushO(n log n)
自底向上从后往前(从 n/2-1 开始),每个节点 siftDownO(n)

为什么自底向上是 O(n)?

直觉:

  • 叶子节点(占 n/2 个)天然就是"堆",零操作
  • 越靠近根的节点越少,但每个节点的 siftDown 路径越长
  • 两者相乘,总和是 收敛级数,结果是 O(n)

数学:

高度为 h 的节点有 n / 2^(h+1) 个,每个最多下沉 h 步
总工作 = Σ (n / 2^(h+1)) * h
       = n * Σ h / 2^(h+1)
       < n * 2
       = O(n)

最佳实现:永远用自底向上 heapify。

static heapify(arr) {
  const h = new MinHeap();
  h.heap = arr.slice();
  // 从最后一个非叶子节点(n/2 - 1)开始,往前到 0
  for (let i = Math.floor(h.heap.length / 2) - 1; i >= 0; i--) {
    h._siftDown(i);
  }
  return h;
}

为什么从 n/2 - 1 开始? 索引 ≥ n/2 的节点都是叶子(没有孩子),siftDown 不做任何事,跳过。

性能对比(100 万个元素的随机数组):

方式时间
自顶向下(一个一个 push)~80 ms
自底向上 heapify~15 ms

差距 5 倍以上。这是面试常考点。

5.1 具体例子

[3, 1, 4, 1, 5, 9, 2, 6](n=8)变成小顶堆。最后一个非叶子 = floor(8/2) - 1 = 3,依次处理 3、2、1、0。

初始:              siftDown(3)    siftDown(2)    siftDown(1)    siftDown(0)
       3                3              3              3              1
      / \              / \            / \            / \            / \
     1   4            1   2          1   2          1   2          1   2
    / \ / \          / \ / \        / \ / \        / \ / \        / \ / \
   1  5 9   2        1  5 9   4     1  5 9   4     3  5 9   4     3  5 9   4
  /                /              /              / \              / \
 6                6              6              6   1            6   1

逐步走一遍:

步骤i数组说明
初始-[3,1,4,1,5,9,2,6]-
13[3,1,4,1,5,9,2,6]值 1 vs 左孩子 6,1 < 6 不换
22[3,1,2,1,5,9,4,6]值 4 vs 9、2,最小是 2 → 4 ↔ 2,4 落到索引 6(叶子,停)
31[3,1,2,1,5,9,4,6]值 1 vs 1、5,1 ≤ 1 不换
4a0[3,1,2,1,5,9,4,6]值 3 vs 1、2,最小是 1 → 3 ↔ 1
4b1[1,3,2,1,5,9,4,6]值 3 vs 1、5,最小是 1 → 3 ↔ 1
4c3[1,1,2,3,5,9,4,6]i=3 是叶子,停
最终-[1,1,2,3,5,9,4,6]

6. 最佳实现:基于比较器的统一抽象

最佳实现的核心思想:用比较器(comparator)把"小顶"和"大顶"统一成同一份代码

class Heap {
  constructor(cmp = (a, b) => a - b) {        // 默认小顶
    this.heap = [];
    this.cmp = cmp;                            // 返回负数表示 a 优先级更高
  }

  static min() { return new Heap((a, b) => a - b); }
  static max() { return new Heap((a, b) => b - a); }

  peek()    { return this.heap[0]; }
  size()    { return this.heap.length; }

  push(val) {
    this.heap.push(val);
    this._siftUp(this.heap.length - 1);
  }

  pop() {
    if (!this.heap.length) return undefined;
    const top = this.heap[0];
    const last = this.heap.pop();
    if (this.heap.length) {
      this.heap[0] = last;
      this._siftDown(0);
    }
    return top;
  }

  static heapify(arr, cmp = (a, b) => a - b) {
    const h = new Heap(cmp);
    h.heap = arr.slice();
    for (let i = Math.floor(h.heap.length / 2) - 1; i >= 0; i--) {
      h._siftDown(i);
    }
    return h;
  }

  _higher(a, b) {                             // a 优先级是否高于 b
    return this.cmp(a, b) < 0;
  }

  _siftUp(i) {
    while (i > 0) {
      const p = (i - 1) >> 1;
      if (this._higher(this.heap[i], this.heap[p])) {
        [this.heap[i], this.heap[p]] = [this.heap[p], this.heap[i]];
        i = p;
      } else break;
    }
  }

  _siftDown(i) {
    const n = this.heap.length;
    while (true) {
      const l = 2 * i + 1, r = 2 * i + 2;
      let best = i;
      if (l < n && this._higher(this.heap[l], this.heap[best])) best = l;
      if (r < n && this._higher(this.heap[r], this.heap[best])) best = r;
      if (best === i) break;
      [this.heap[i], this.heap[best]] = [this.heap[best], this.heap[i]];
      i = best;
    }
  }
}

精彩之处:同一份代码,既是小顶堆也是大顶堆。

const minH = Heap.min();                      // 小顶堆
const maxH = Heap.max();                      // 大顶堆

minH.push(3); minH.push(1); minH.push(4);
minH.pop();                                   // 1(最小值)

maxH.push(3); maxH.push(1); maxH.push(4);
maxH.pop();                                   // 4(最大值)

自定义比较器威力

// 任务调度:deadline 最早的先出去
const scheduler = new Heap((a, b) => a.deadline - b.deadline);

// Dijkstra 算法里的"当前最短距离"
const dist = new Heap((a, b) => a.dist - b.dist);

// 按"距离原点"排,最远的先出去
const farFirst = new Heap((a, b) =>
  Math.hypot(b.x, b.y) - Math.hypot(a.x, a.y)
);

7. 小顶堆 vs 大顶堆:一行之差

小顶堆大顶堆
比较器(a, b) => a - b(a, b) => b - a
堆顶最小值最大值
_higher(a, b) 等价a < ba > b
典型应用Dijkstra、合并 K 路、Top-K 最小堆排序、Top-K 最大、优先级队列取最大

它们是同一个算法,只是比较方向反了。所以学完一个,另一个自动会。

完整跑一遍验证

const arr = [3, 1, 4, 1, 5, 9, 2, 6];

const minH = Heap.heapify(arr);
const asc = [];
while (minH.size()) asc.push(minH.pop());
console.log(asc);                              // [1, 1, 2, 3, 4, 5, 6, 9]

const maxH = Heap.heapify(arr, (a, b) => b - a);
const desc = [];
while (maxH.size()) desc.push(maxH.pop());
console.log(desc);                             // [9, 6, 5, 4, 3, 2, 1, 1]

一招变型:"最小 K 个"问题,把上面的"比较器反转" + "改成小顶堆"组合即可。


8. 三大实战应用

8.1 堆排序(Heap Sort)

function heapSort(arr, desc = false) {
  const h = Heap.heapify(
    arr,
    desc ? (a, b) => b - a : (a, b) => a - b
  );
  const out = [];
  while (h.size()) out.push(h.pop());
  return out;
}

复杂度:建堆 O(n) + n 次 pop 每次 O(log n) = O(n log n)空间 O(1)(in-place)。

但实测:堆排序的常数因子比快排大,cache 也差(跳着访问内存),实际速度往往比快排慢 2-5 倍。所以:

  • 面试考:堆排序怎么写、复杂度多少、为什么 in-place
  • 实战用:优先快排,归并
  • 唯一会赢的场景:需要 最坏 O(n log n) 保证(vs 快排最坏 O(n²))

8.2 Top-K 问题

Top-K 最大(返回 K 个最大元素)—— 用小顶堆

function topK(arr, k) {
  const heap = [];                            // 小顶堆:堆顶 = 当前 K 候选里最差的 = 门槛
  for (const x of arr) {
    if (heap.length < k) {
      heap.push(x);                           // 没满就加
      siftUp(heap, heap.length - 1);
    } else if (x > heap[0]) {                 // 满了,新人 > 最差的?
      heap[0] = x;                            // 替换最差的
      siftDown(heap, 0, heap.length);
    }
    // 否则:x 不够格,跳过
  }
  return heap;
}

为什么用小顶堆? 小顶堆的堆顶是当前 K 个里 最小的,新元素如果比它还小/等,根本进不来(因为它会被立刻 pop 掉,等于没进);如果新元素比它大,挤掉它。这样维护代价 O(log K),整体 O(n log K)

Top-K 最大堆顶作用
小顶堆挡板:比堆顶小的进不来
大顶堆每次把最大的踢出去,留下 K 个最小的,方向反了

Top-K 最小反过来用大顶堆

复杂度对比(n = 10⁷, K = 10):

方法时间
全排序取前 K~3 秒(O(n log n))
小顶堆 Top-K~0.4 秒(O(n log K))

类比(简历筛选):想象你从 100 万份简历里挑 top 3。不是给 100 万份都排名,而是维护一份"当前 top 3 名单":

  • 名单没满 → 直接加进去
  • 名单满了 → 新简历 x 比名单里最差的那个还强?是 → 踢掉最差的、加 x;否 → 跳过

要快速找到"名单里最差的"——小顶堆的堆顶就是。所以"维护 top K 候选 = 小顶堆 + 堆顶当门槛"。

具体例子[3, 1, 4, 1, 5, 9, 2, 6],K=3。预期 top 3 = [9, 6, 5](堆内顺序):

ix堆顶操作
03-没满,push[3]
11-没满,push[1,3]
241没满,push[1,3,4]
3111 > 1? 否,跳过[1,3,4]
4515 > 1? 是,替换堆顶 → siftDown[3,5,4]
5939 > 3? 是,替换 → siftDown[4,5,9]
6242 > 4? 否,跳过[4,5,9]
7646 > 4? 是,替换 → siftDown[5,6,9]

最终 heap = [5, 6, 9],top 3 = [9, 6, 5]。✓

口诀前 K 大,用小顶堆。堆顶 = K 候选里最小的门槛,新数过门槛就替。

两个变体

  • 前 K 小:用大顶堆 of size K,堆顶当门槛,淘汰最大的
  • 第 K 大(LeetCode 215):topKMax(arr, K).heap[0](小顶堆里堆顶就是第 K 大)

差距 ~7 倍。

8.3 优先级队列 / 任务调度

这是堆最自然的用途:

class TaskScheduler {
  constructor() { this.queue = Heap.min(); }

  add(deadline, task) {
    this.queue.push({ deadline, task });
  }

  next() {
    return this.queue.pop()?.task;
  }
}

const s = new TaskScheduler();
s.add(10, '写报告');
s.add(5,  '修 bug');
s.add(8,  '开会');
s.next();                                     // '修 bug'(deadline=5)
s.next();                                     // '开会'(deadline=8)
s.next();                                     // '写报告'(deadline=10)

JS 标准库至今没有 PriorityQueue(2026 仍没有),手写堆是日常操作。Node.js 里有 import { PriorityQueue } from 'mnemonist/priority-queue' 可以直接用。

8.4 中位数(两个堆的经典组合)

class MedianFinder {
  constructor() {
    this.maxH = [];   // 大顶堆,存较小一半,堆顶 = 小半最大值
    this.minH = [];   // 小顶堆,存较大一半,堆顶 = 大半最小值
  }
  add(num) { return addMedian(num, this.maxH, this.minH); }
  find() { return findMedian(this.maxH, this.minH); }
}

两个堆 + 巧妙平衡 = O(log n) 维护中位数。面试高频题

⚠️ 上面的 find() 是简化版。当两堆等大时(中位数为两堆顶的平均值),会少算一半。修正版见下。

分工与不变量

类型存什么堆顶的含义
maxH大顶堆较小一半小半中的最大值
minH小顶堆较大一半大半中的最小值

不变量(每次 add() 后都必须满足):

  • maxH.length >= minH.length
  • size 差 ≤ 1

由此推出 median 的取值规则:

  • maxH.length > minH.length → 中位数 = maxH[0](奇数个,小半多一个)
  • size 相等 → 中位数 = (maxH[0] + minH[0]) / 2(偶数个,平均)

插入流程

function addMedian(num, maxH, minH) {
  // 1. 决定进哪个堆
  if (maxH.length === 0 || num <= maxH[0]) {
    maxH.push(num);
    siftUpMax(maxH, maxH.length - 1);            // 大顶堆 siftUp
  } else {
    minH.push(num);
    siftUpMin(minH, minH.length - 1);            // 小顶堆 siftUp
  }
  // 2. 平衡:size 差不能超过 1
  if (maxH.length > minH.length + 1) {
    minH.push(popTop(maxH, true));               // maxH 太大,挪堆顶过去
    siftUpMin(minH, minH.length - 1);
  } else if (minH.length > maxH.length) {
    maxH.push(popTop(minH, false));              // minH 太大,挪堆顶过来
    siftUpMax(maxH, maxH.length - 1);
  }
}

function findMedian(maxH, minH) {
  if (maxH.length > minH.length) return maxH[0];
  return (maxH[0] + minH[0]) / 2;
}

function popTop(heap, isMax) {                   // 标准 pop:取堆顶,末尾挪到顶,siftDown
  const top = heap[0];
  const last = heap.pop();
  if (heap.length) {
    heap[0] = last;
    if (isMax) siftDownMax(heap, 0, heap.length);
    else       siftDownMin(heap, 0, heap.length);
  }
  return top;
}

为什么用 if/else 分流? 看着比"先 push 再搬运"啰嗦,但意图清晰:num ≤ 小半最大值 → 留在小半;否则 → 去大半。搬运逻辑(popTop)跟前面学过的 pop 一模一样,复用已有概念

具体例子:数据流 [1, 5, 2, 8, 3, 9]

走一遍验证:排序后 [1, 2, 3, 5, 8, 9],中位数 = (3 + 5) / 2 = 4

步骤操作maxH(大顶堆)minH(小顶堆)size 差median
1add(1)[1][]11
2add(5)[1][5]0(1+5)/2 = 3
3add(2)[2,1][5]12
4add(8)[2,1][5,8]0(2+5)/2 = 3.5
5add(3)[3,2,1][5,8]13
6add(9)[3,2,1][5,8,9]0(3+5)/2 = 4

逐行解释步骤 6(最难的一步):

  1. add(9)minH.push(9)?不对,是先走 if 判断:9 > maxH[0]=3 → 进 minH,siftUpMin 到 [5,8,9]
  2. 平衡判断:maxH.length(3) > minH.length(3) + 1? 否;minH.length(3) > maxH.length(3)? 否 → 不挪

最终 maxH 顶 = 3(较小一半的最大),minH 顶 = 5(较大一半的最小),中位数 = (3+5)/2 = 4。

复杂度

操作时间说明
add(num)O(log n)最多 2 次 push + 1 次 pop,每次 log n
find()O(1)只读两个堆顶
空间O(n)两个堆各存一半

和"全排序再取中间"的对比: 全排序每次插入 O(n),中位数 O(1);两个堆反过来,插入 O(log n),中位数 O(1)。两个堆的总成本更优,且适合数据流(边来边算,不用回头排序)。

一句话口诀

小半用大顶堆,大半用小顶堆;堆顶就是候选中位数;size 差 ≤ 1,永远保持平衡。


9. 复杂度分析

操作时间空间说明
peek()O(1)O(1)直接读数组头
push(x)O(log n)O(1)siftUp 路径长 ≤ ⌈log₂ n⌉
pop()O(log n)O(1)siftDown 路径长 ≤ ⌈log₂ n⌉
heapify(arr)O(n)O(n)自底向上 siftDown
堆排序O(n log n)O(1)in-place(不算输出数组)
Top-K(n 个取 K 个)O(n log K)O(K)用大小为 K 的堆
中位数维护O(log n) 单次O(n)两个堆

为什么 heapify 是 O(n) 而不是 O(n log n)?

直觉:叶子节点(n/2 个)天然是堆,零操作;靠近根的节点少但路径长。两者相乘是 收敛级数,总和 = O(n)。

T(n) = n/2 * 0 + n/4 * 1 + n/8 * 2 + n/16 * 3 + ...
     < n/2 * (0 + 1/2 + 1/4 + 1/8 + ...)
     = n/2 * 2 = n

为什么 siftUp 和 siftDown 都是 O(log n)? 树高 = ⌈log₂ n⌉。siftUp 从叶子往上走,最坏走到根;siftDown 从根往下走,最坏走到叶子。两种路径都是树高。


10. 决策表

场景选择理由
经常取最小/最大O(1) peek
经常插入 + 删除最大O(log n) 双向
只取一次最大/最小排序更快O(n log n) 一次 vs O(n) 建堆 + O(n log n) 取
需要稳定排序归并堆排不稳定
需要最坏 O(n log n) 保证堆排序快排最坏 O(n²)
Top-K(K << n)O(n log K),比全排快 K/log K 倍
Top-K(K 接近 n)排序O(n log n) vs O(n log n) 接近
中位数 / 滑动窗口中位数两个堆O(log n) 维护
合并 K 个有序流天然支持,O(n log K)
实时调度(deadline / 优先级)标准模式

11. 一句话总结

堆 = 完全二叉树 + 堆序性,用数组存; siftUp 给 push 用,siftDown 给 pop 用; 比较器把"小顶"和"大顶"统一成一份代码; 自底向上 heapify 是 O(n) 的秘籍; 三大应用:堆排序、Top-K、优先级队列。