深蓝黑背景品牌封面:居中大标题「10 个 1G 文件的词频排序」,副标题「哈希切分 + 小顶堆:单机版 MapReduce」;下方原创装饰——三个文件方块经分流箭头汇入一组按高度降序排列的词频柱条;顶部标注「历史题目复盘 · 数据处理」与「Day 05 · 09-23」 图 1:本文封面,点明题目与「哈希切分加小顶堆」的组合拳。

先说明属性:这是一篇历史题目复盘,题目出自本地历史面试资料(见文末来源说明),与任何公司的当前招聘无关。海量数据题的内核都一样:内存装不下,就不能把问题整体搬进内存,只能想办法把它切成装得下的若干份——难点从不在「切」,而在「切完之后结果还能不能正确合并」。

想象面试现场,面试官说:「有 10 个 1G 的文件,每行一个搜索词,内存只有几百 MB。按词频从高到低排序,输出 Top-N,怎么做?」

第一步,审题:瓶颈在哪。

10G 数据、几百 MB 内存,直接全量计数不可能。但注意一个松动之处:我们不需要同时处理全部数据,只需要保证「同一个词的所有出现被统计在一起」。如果能把数据切成每份都装得下的小块,且同词不拆散,问题就退化成逐块统计再合并。

第二步,核心招式:哈希切分。

逐行读每个文件,对每行的词计算 hash(词) mod 50,把词追加写进对应编号的小文件——10G 切成 50 份,每份约 200MB,可以装入内存。这一步的关键不变式是:同一个词哈希值固定,它的所有出现必然落进同一个小文件,一次都不会漏、也不会重。正确性就靠这一条不变式撑着,它成立,后面所有步骤才合法。

第三步,逐桶统计加小顶堆取 Top-K。

每个小文件装进内存,用哈希表做词频计数;然后对计数结果过一遍容量为 K 的小顶堆——比堆顶大就替换堆顶再下滤。堆的容量恒为 K,每个桶只花 O(词数 log K) 时间、O(K) 额外内存,就筛出该桶的局部 Top-K。要 Top-N 全局榜时,把 50 份局部 Top-K 归并再排序即可。为什么局部 Top-K 可以代表全局?一个词若进得了全局 Top-K,它在自己桶里的计数等于它的全局计数,必然也在桶内前 K——反证即得,这个推导面试时要主动讲。

流程图:左侧五步流水线——①输入为 10 个 1G 文件、内存装不下只能流式逐行读;②按 hash(词) mod 50 把每个词写进对应编号的小文件,每份约 200MB 可装入内存;③每个小文件内用哈希表做词频计数;④对计数结果过一遍容量为 K 的小顶堆,取出该文件的 Top-K;⑤把 50 份局部 Top-K 归并排序得到全局频率榜。右侧说明关键不变式(同一个词的所有出现必然落在同一小文件,计数完整才可合并)、为什么用小顶堆(容量恒为 K,比堆顶大就替换,O(n log K) 时间 O(K) 内存),以及它与 MapReduce 的对应关系:切分相当于 Map 侧分片、单文件统计相当于 Reduce、归并相当于最终聚合 图 2:五步流水线,以及它与 MapReduce 的对应关系。

第四步,跑一遍可运行的缩微版。

下面这份代码用小样数据走完全流程,并与直接全量统计交叉复核,用 Node.js 22.15.0 直接运行(node word-freq.js)即可:

'use strict';

function hashWord(word) {
  let h = 0;
  for (const ch of word) h = (h * 31 + ch.charCodeAt(0)) >>> 0;
  return h;
}

// 容量 K 的小顶堆:比堆顶大就替换,始终保存「目前见过的前 K 大」
class MinHeap {
  constructor(k) {
    this.k = k;
    this.a = []; // 元素为 [词, 频次]
  }
  push(item) {
    const a = this.a;
    if (a.length < this.k) {
      a.push(item);
      for (let i = a.length - 1; i > 0 && a[i][1] < a[(i - 1) >> 1][1]; i = (i - 1) >> 1) {
        [a[i], a[(i - 1) >> 1]] = [a[(i - 1) >> 1], a[i]];
      }
    } else if (item[1] > a[0][1]) {
      a[0] = item;
      for (let i = 0; ; ) {
        let s = i;
        const l = 2 * i + 1;
        const r = 2 * i + 2;
        if (l < a.length && a[l][1] < a[s][1]) s = l;
        if (r < a.length && a[r][1] < a[s][1]) s = r;
        if (s === i) break;
        [a[i], a[s]] = [a[s], a[i]];
        i = s;
      }
    }
  }
  entries() {
    return [...this.a];
  }
}

// —— 造数据:模拟 10 个 1G 文件的小样(真实场景逐行流式读取,这里用内存数组代替)——
const corpus = [
  ['apple', 30], ['banana', 22], ['cherry', 15], ['date', 12],
  ['elderberry', 9], ['fig', 7], ['grape', 5], ['honeydew', 3],
];
const files = Array.from({ length: 3 }, () => []);
for (const [w, c] of corpus) for (let i = 0; i < c; i++) files[i % 3].push(w);

// 第一步:哈希切分——同一个词的所有出现必然落进同一编号的小文件
const BUCKET_COUNT = 8;
const buckets = Array.from({ length: BUCKET_COUNT }, () => []);
for (const file of files) for (const w of file) buckets[hashWord(w) % BUCKET_COUNT].push(w);
const appleBuckets = new Set();
for (const file of files) for (const w of file) if (w === 'apple') appleBuckets.add(hashWord(w) % BUCKET_COUNT);
console.log(`apple 散布在 3 个文件中,切分后落进的桶号 -> ${[...appleBuckets]}(必然只有 1 个)`);

// 第二步:每个小文件装入内存,用哈希表计数;第三步:过容量 K 的小顶堆取局部 Top-K
const K = 3;
const partials = buckets.map((bucket) => {
  const counts = new Map();
  for (const w of bucket) counts.set(w, (counts.get(w) ?? 0) + 1);
  const heap = new MinHeap(K);
  for (const entry of counts) heap.push(entry);
  return heap.entries();
});

// 第四步:归并 8 份局部 Top-K,再按频次降序排列得全局榜
const merged = partials.flat().sort((x, y) => y[1] - x[1]).slice(0, K);
console.log(`全局 Top-3 -> ${merged.map(([w, c]) => `${w}(${c})`).join(', ')}`);

// 正确性复核:小样数据直接全量计数对比
const direct = new Map();
for (const f of files) for (const w of f) direct.set(w, (direct.get(w) ?? 0) + 1);
const expected = [...direct.entries()].sort((x, y) => y[1] - x[1]).slice(0, K);
console.log(`直接全量统计复核 -> ${expected.map(([w, c]) => `${w}(${c})`).join(', ')}`);
if (JSON.stringify(merged) !== JSON.stringify(expected)) throw new Error('分治结果与全量统计不一致');

实测输出如下:

apple 散布在 3 个文件中,切分后落进的桶号 -> 2(必然只有 1 个)
全局 Top-3 -> apple(30), banana(22), cherry(15)
直接全量统计复核 -> apple(30), banana(22), cherry(15)

三行输出分别验证了「同词同桶」的不变式、分治流水线结果、以及与全量统计的一致性。

易错点清单。

一,按文件平均分而不是按词哈希切分——同一个词被拆进不同桶,计数残缺,合并出来的榜是错的,这是最致命的错误。二,桶数拍脑袋——桶的大小要按内存反推,10G 配几百 MB 内存就切 50 到 100 份。三,用大顶堆或全排序取 Top-K——容量 K 的小顶堆才是 O(n log K) 的关键。四,讲不出局部 Top-K 能代表全局的反证,方案停在「大概是对的」。

回到面试现场,建议的答题节奏:先点破瓶颈是内存不是计算,再给哈希切分加不变式,然后逐桶计数加小顶堆,最后归并全局榜并补一句「这就是 MapReduce 的单机版」——切分对应 Map 侧分片、单文件统计对应 Reduce、归并对应最终聚合。

语料词频统计是训练数据清洗的第一步。如果题目追问「某个词恰好在两个桶边界上哈希倾斜怎么办」,你会怎么应对?欢迎写出你的思路。

参考资料

  • Node.js v22.x 官方 API 文档(文中代码运行环境为 Node.js 22.15.0,Map、Set、数组排序等行为以此为准) — https://nodejs.org/docs/latest-v22.x/api/