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