堆与优先队列:TopK 与调度

本节目标

堆的索引公式:数组表示完全二叉树,parent(i) = (i-1)>>1,left = 2i+1,right = 2i+2。最小堆保证父 ≤ 子。

// 运行环境:Node.js 14+
// 保存为 dsa-l13.js,执行:node dsa-l13.js
// MinHeap 实现见 dsa-l7.js,这里复用同款
class MinHeap {
  #h = []
  push(x) { this.#h.push(x); this.#siftUp(this.#h.length - 1) }
  pop() {
    if (!this.#h.length) return undefined
    const top = this.#h[0], last = this.#h.pop()
    if (this.#h.length) { this.#h[0] = last; this.#siftDown(0) }
    return top
  }
  peek() { return this.#h[0] }
  get size() { return this.#h.length }
  #siftUp(i) { while (i > 0) { const p = (i - 1) >> 1; if (this.#h[p] <= this.#h[i]) break; [this.#h[p], this.#h[i]] = [this.#h[i], this.#h[p]]; i = p } }
  #siftDown(i) { const n = this.#h.length; while (true) { let m = i, l = 2*i+1, r = 2*i+2; if (l < n && this.#h[l] < this.#h[m]) m = l; if (r < n && this.#h[r] < this.#h[m]) m = r; if (m === i) break; [this.#h[m], this.#h[i]] = [this.#h[i], this.#h[m]]; i = m } }
}

// TopK:维护大小为 k 的最小堆,遍历完堆顶即第 k 大
function topK(arr, k) {
  const heap = new MinHeap()
  for (const x of arr) {
    heap.push(x)
    if (heap.size > k) heap.pop() // 超过 k 就扔掉当前最小
  }
  return heap // 堆内即 TopK
}
const data = [3, 1, 5, 8, 2, 9, 4]
const h = topK(data, 3)
console.log([...Array(h.size)].map(() => h.pop())) // [9, 8, 5](降序弹出)

合并 K 个有序链表:用小顶堆每次取各链表头的最小值(leetcode 23)。

名词解释

课后练习

  1. 用最小堆求"第 k 大"为什么维护 k 大小的堆、弹出最小的?
    • 答案:堆里始终留着"目前见过最大的 k 个",堆顶是这 k 个里最小的,即全局第 k 大;遍历完数组,堆顶就是答案。
  2. 合并 K 个有序链表为什么用堆?
    • 答案:每次只需从 K 个链表头里取最小,堆能 O(log K) 取到并补入下一节点,总复杂度 O(N log K),远比逐个合并 O(NK) 高效。

总结

堆是"局部有序即可"思想的极致体现——它不保证整体排序,只保证"堆顶是最值",却足以解决一大类问题。这一点和数组排序形成鲜明对比:当你只需要 TopK 而不需要全排序时,堆把复杂度从 O(n log n) 降到 O(n log k),在 n 很大、k 很小时收益巨大。我特别想点出 siftUp/siftDown 这两个操作是堆的全部灵魂:插入后上浮、删除后下沉,每个都是 O(log n)。一旦你亲手写出它们(见 dsa-l7),优先队列、调度器、Dijkstra 最短路径都只是"在合适的地方调用堆"而已。工程里遇到"每次取最值"的需求,第一时间想到堆,你的算法档次立刻不同。