堆与优先队列实现实时排行榜 / TopK 流

本节目标

场景:直播弹幕实时打分,要随时给出"当前分最高的前 10 名"。用最大堆维护 TopK:新分数来了就入堆,堆超 K 就弹出当前最小(若用最小堆)或最大(若用最大堆保留 TopK 最小的)。下面用最小堆保留"前 K 大":

// 运行环境:Node.js 14+
// 保存为 dsa-l24.js,执行:node dsa-l24.js

class MinHeap {
  #h = []
  push(x) { this.#h.push(x); this.#siftUp(this.#h.length - 1) }
  pop() { if (!this.#h.length) return undefined; const t = this.#h[0], l = this.#h.pop(); if (this.#h.length) { this.#h[0] = l; this.#siftDown(0) } return t }
  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 } }
}

// 实时排行榜:保留分数最高的 K 名
class Leaderboard {
  constructor(k) { this.k = k; this.heap = new MinHeap() }
  submit(score) {
    this.heap.push(score)
    if (this.heap.size > this.k) this.heap.pop() // 超出则扔掉当前最小
  }
  topK() {
    const snapshot = [...this.heap['#h']]        // 复制堆数组
    return snapshot.sort((a, b) => b - a)         // 降序展示
  }
}

// 调用示例:模拟弹幕打分流
const lb = new Leaderboard(3)
;[88, 95, 70, 99, 82, 91].forEach(s => lb.submit(s))
console.log(lb.topK()) // [99, 95, 91](前 3 高)

流式 TopK:数据量太大放不进内存时,不必存全部,只维护大小为 K 的堆,逐条处理,空间 O(K)、时间 O(N log K)。这是日志分析、实时监控的标配。

名词解释

课后练习

  1. 流式 TopK 为什么只用 O(K) 内存而不是 O(N)?
    • 答案:堆只保存"目前见过最优的 K 个",新数据若不如堆顶就直接丢弃,无需保留全部 N 条,故内存恒定 O(K)。
  2. Leaderboard.topK 为什么先复制堆数组再排序,而不是直接弹堆?
    • 答案:直接 pop 会破坏堆(清空它);复制一份再排序只是为"展示",不影响后续继续 submit。

总结

堆在这一节完成了从"算法题"到"生产组件"的转身。实时排行榜是几乎所有社交/直播/游戏产品的刚需,而它的内核就是一个 K 大小的堆——新分数入堆、超量就弹堆顶,全程 O(log K)。最打动我的是"流式 TopK"这个思想:当数据大到内存装不下(比如全站用户打分日志),你根本不需要存全部,只要维护一个 K 大小的堆边到边处理,内存恒定、结果正确。这背后是"只需最优摘要、不必保留全集"的工程智慧,在实时监控、异常检测、推荐候选召回里无处不在。优先队列还有更广的舞台:任务调度按优先级出队、限流器按过期时间弹出、Dijkstra 按距离取最小——凡是"每次取最值"的地方,堆都是默认答案。把堆用熟,你会发现一半的"实时/调度"问题都迎刃而解。