深蓝黑背景封面。左侧是一条左到右的处理流程链:第一张卡片「300 万条字符串」注「逐条流式读取」;箭头指向第二张卡片「哈希计数」注「词 → 频次,O(N) 扫一遍」;箭头指向一个二叉小顶堆图形,堆顶节点标「最小值 = 第 10 名」,注「堆容量固定为 10」;箭头指向结果卡「Top-10 热门词」以青色高亮。底部一行注「新词频 ≤ 堆顶直接丢弃,> 堆顶则替换堆顶再下沉调整,每条最多 log 10 次比较」。右侧大标题「从 300 万条字符串里找最热门的 10 条」,副标题「扫一遍 + 定容小顶堆:O(N log K) 在线求解」。左上标注「面试经典 · 算法」,右上圆角胶囊标注「Day 01 · 11-18」。 图 1:本文封面,扫一遍计数加定容小顶堆,在线求出 Top-10。

先说明属性:这是一篇经典题解析,题目出自本地历史面试资料,与任何公司的当前招聘无关。Top-K 是数据处理面试的常客:300 万条字符串找最热门的 10 条,内存装得下,考点全在「怎么用最小代价维护一个动态门槛」。这套思路在日志高频意图统计、提示词热词分析等数据管线里也是最常见的第一道统计工序。

先拆题:两个子问题,不是一个。

很多人第一反应是「排序取前十」,这是把两个问题混成了一个。题目实际包含两步:第一步统计每条字符串出现多少次,第二步从计数结果里找频次最高的 10 个。第一步只能用哈希表扫一遍,300 万条就是 300 万次哈希查询累加,时间 O(N)、空间 O(不同字符串数)——300 万条字符串、去重后通常远小于 300 万,内存装得下,这一步没有优化余地也不需要优化。真正的考点在第二步。

第二步的标准解法:容量固定为 K 的小顶堆。

维护一个最多装 10 个元素的小顶堆,堆里始终保存「扫描到目前为止频次最高的 10 个词」,堆顶是其中的最小值,也就是当前第 10 名的频次——它是门槛。每扫到一个新词频,只跟堆顶比一次:不超过堆顶,直接丢弃,O(1) 淘汰;超过堆顶,弹出堆顶、插入新元素、下沉调整,代价 O(log K)。扫完所有不同词,堆里就是答案。

复杂度账要会算:不同词数记为 M,每个词最多一次堆操作,总时间 O(M log K),K=10 时 log₂K 约等于 4,常数极小;堆的空间 O(K),与数据量无关。对比全排序的 O(M log M):M 是几十万量级时差距是几十倍,而且排序要一次性持有全量数据的顺序结构,堆方案是在线的、流式的,数据边来边处理。

三个常见失分点。

一是全排序,把 O(M log K) 做成 O(M log N),暴露了复杂度敏感度不够。二是堆不固定容量,把所有词都塞进堆里再弹出前 10,空间退化成 O(M),失去在线处理的意义。三是忘记先计数,直接对原始 300 万条字符串建堆——堆里的元素应该是「词和频次」对,不是原始串。

用代码实测一遍。

下面用 Node.js 22.15.0 完整跑一遍:先造 300 万条 Zipf 式倾斜分布的字符串(少数词高频、多数词低频),哈希计数后用定容小顶堆取 Top-10,再和全排序结果对拍(node topk.js):

class MinHeap {
  constructor(k) { this.k = k; this.a = []; }
  peek() { return this.a[0]; }
  push(x) { // 上浮
    this.a.push(x);
    let i = this.a.length - 1;
    while (i > 0) {
      const p = (i - 1) >> 1;
      if (this.a[p][1] <= this.a[i][1]) break;
      [this.a[p], this.a[i]] = [this.a[i], this.a[p]];
      i = p;
    }
  }
  pop() { // 弹出堆顶并下沉
    const top = this.a[0], last = this.a.pop();
    if (this.a.length) {
      this.a[0] = last;
      let i = 0;
      for (;;) {
        const l = 2 * i + 1, r = l + 1; let m = i;
        if (l < this.a.length && this.a[l][1] < this.a[m][1]) m = l;
        if (r < this.a.length && this.a[r][1] < this.a[m][1]) m = r;
        if (m === i) break;
        [this.a[m], this.a[i]] = [this.a[i], this.a[m]];
        i = m;
      }
    }
    return top;
  }
  offer(x) { // 核心:容量固定为 K
    if (this.a.length < this.k) this.push(x);
    else if (x[1] > this.a[0][1]) { this.pop(); this.push(x); }
  }
}

const cnt = new Map(); // 第一步:哈希计数
// ……(生成 300 万条字符串并累加频次,略)
const heap = new MinHeap(10);
for (const e of cnt) heap.offer(e); // 第二步:扫一遍计数表

实测输出:

总条数: 3000000 不同词数: 50000
堆方案耗时(ms): 2.2
Top-10: w0:81075 w1:21034 w2:14918 w3:11980 w4:9863 w5:8635 w6:7840 w7:7111 w8:6551 w9:6059
全排序耗时(ms): 7.0
两种方法结果一致: true
log2(K) = 3.32

5 万个不同词,堆方案约 2.2 毫秒、全排序约 7.0 毫秒,结果完全一致;差距会随 M 增大继续拉大。

深蓝黑背景概念图。左侧是一个十节点小顶堆示意图:堆顶节点以青色描边高亮标注「频次 86 = 当前第 10 名」,其余节点频次 87 至 153 递增排列,父子间以灰色连线连接,堆下方注「堆里始终是当前扫描过的词里频次最高的 10 个」。堆下方一个判定分叉:新词频 72 指向灰色叉号「≤ 堆顶,丢弃」;新词频 120 指向青色对勾「> 堆顶,弹出堆顶、插入后下沉」。右侧两张卡片:复杂度账本卡列出计数哈希表空间 O(不同词数)、堆空间 O(K)、时间 O(N log K) 且 K=10 时 log₂K≈4;误区卡列出三个常见错误:全排序浪费 O(N log N)、堆不固定容量导致内存失控、忘记先计数直接对原始串建堆。底部总结条:堆顶是「第 K 名的门槛」,每个新元素只跟门槛比一次,绝大多数元素 O(1) 被淘汰。 图 2:堆顶就是「第 K 名的门槛」,每个新元素只跟门槛比一次。

答题时把「在线」二字说透。

建议的答题顺序:先拆出计数和选 Top-K 两步,给出哈希表加小顶堆的组合,主动报复杂度(时间 O(N + M log K)、空间 O(M + K)),再点出堆顶即门槛、多数元素 O(1) 被淘汰这个核心直觉。如果面试官追问「数据量再大一百倍、内存装不下怎么办」,那是分治加外排序的话题,属于另一篇文章的范畴,本文不展开。你第一次写 Top-K 时用的是堆还是排序?有没有在「堆容量忘了固定」上栽过跟头?欢迎分享。