图 1:本文封面,点明题目与「切开算」的核心思路。
先说明属性:这是一篇历史题目复盘,题目出自本地历史面试资料(见文末来源说明),与任何公司的当前招聘无关。海量数据处理类题目有一个共同的开场白:数据大到内存装不下。这道题也不例外——一天的访问日志可能有几十上百 GB,问:访问次数最多的那个 IP 是谁?
想象面试现场,面试官补了一句约束:机器内存只有 2GB,日志文件 100GB。
第一步,审题。
直接的想法是建一张哈希表,key 是 IP、value 是次数,扫一遍文件就统计完了。问题是装不下:几千万乃至上亿个不同 IP 同时驻留内存,哈希表本身就爆了。所以题目真正考的只有一个思想——分治:把大问题切成若干个内存装得下的小问题,各自求解,再汇总。
第二步,基础回答:哈希切分 + 局部统计 + 全局汇总。
核心操作是逐行读日志(流式读取,文件再大也不进内存),对每行的 IP 算一个哈希值,按 hash(IP) mod 100 把这行追加到 100 个小文件之一。这一步有个必须讲透的性质:同一个 IP 的哈希值固定,所以它必然全部落进同一个小文件,绝不会被拆散。这就是「可以分别统计再汇总」的合法性来源。
切完之后,每个小文件只有 1GB 量级(假设 IP 分布大致均匀),可以整个装进内存,用哈希表统计出这个小文件的 Top1。最后把 100 个局部 Top1 放在一起比大小,最大的就是全局答案。时间复杂度 O(n),全程只需一次文件扫描加 100 次小文件统计。
图 2:哈希切分保证同一 IP 不拆散,局部 Top1 汇总即全局答案。
第三步,用可运行代码验证关键性质。
下面这份代码模拟整个流程,并额外验证「同一 IP 只进一个桶」这条命脉性质,用 Node.js 22.15.0 直接运行(node top-ip.js)即可:
'use strict';
// 生成一批模拟日志行(真实场景是逐行读文件,不整体进内存)
function* generateLogs(total, distinctIps, hotIp, hotCount) {
for (let i = 0; i < hotCount; i++) yield hotIp; // 埋一个明显的热点 IP
for (let i = 0; i < total - hotCount; i++) {
yield `10.0.${(i * 7) % 250}.${i % distinctIps}`;
}
}
// 稳定的字符串哈希(多项式滚动)
function hash32(s) {
let h = 0;
for (let i = 0; i < s.length; i++) {
h = (Math.imul(h, 31) + s.charCodeAt(i)) | 0;
}
return h >>> 0; // 转无符号
}
// 第一步:按 hash(IP) % 分片数把记录分流到对应桶
function partition(logs, buckets) {
const parts = Array.from({ length: buckets }, () => new Map());
for (const ip of logs) {
const idx = hash32(ip) % buckets;
parts[idx].set(ip, (parts[idx].get(ip) ?? 0) + 1);
}
return parts;
}
// 第二步:每个桶内取局部 Top1
function localTop1(counter) {
let best = null;
for (const [ip, cnt] of counter) {
if (best === null || cnt > best.cnt) best = { ip, cnt };
}
return best;
}
// 第三步:汇总所有局部 Top1,得到全局 Top1
function findTopIp(logs, buckets) {
const parts = partition(logs, buckets);
let global = null;
for (const counter of parts) {
const local = localTop1(counter);
if (local && (global === null || local.cnt > global.cnt)) global = local;
}
return global;
}
const TOTAL = 100000;
const HOT_IP = '203.0.113.7';
const HOT_COUNT = 9001;
const BUCKETS = 100;
const top = findTopIp(generateLogs(TOTAL, 500, HOT_IP, HOT_COUNT), BUCKETS);
console.log(`日志总量 ${TOTAL} 条,分 ${BUCKETS} 个桶统计`);
console.log(`全局 Top1:${top.ip},出现 ${top.cnt} 次`);
// 正确性复核:同一 IP 是否只出现在一个桶里
const parts = partition(generateLogs(TOTAL, 500, HOT_IP, HOT_COUNT), BUCKETS);
const appearIn = parts.filter((c) => c.has(HOT_IP)).length;
console.log(`热点 IP 出现的桶数:${appearIn}(应为 1,证明同一 IP 不会被拆散)`);
if (appearIn !== 1 || top.ip !== HOT_IP || top.cnt !== HOT_COUNT) {
throw new Error('结果校验失败');
}
实测输出如下:
日志总量 100000 条,分 100 个桶统计
全局 Top1:203.0.113.7,出现 9001 次
热点 IP 出现的桶数:1(应为 1,证明同一 IP 不会被拆散)
热点 IP 被准确找出,且它只出现在一个桶里——这正是整个方案正确的前提,代码替你把这条性质钉死了。
可能的追问。
「要的是 Top-10 呢?」桶内统计完接一个大小为 K 的小顶堆,每个桶出局部 Top-K,再全局归并。「某个 IP 特别热,单个桶还是装不下怎么办?」对这个桶二次哈希切分,切得更细。「单机扛不住扫描怎么办?」同样的切分逻辑搬到多台机器,就是 MapReduce 的雏形——这也是 AI 工程里统计训练日志、请求日志的日常套路。
易错点清单。
一,试图一次性把整个文件读进内存,方案从第一步就死了。二,切分时用随机分桶而不是按 IP 哈希,同一个 IP 被拆到多个文件,局部 Top1 汇总出错误答案——这是本题最致命的错。三,只答「分治」两个字,讲不出为什么按哈希切才能保证正确。四,忽略追问空间,Top-K、热点桶、分布式都是现成的下一问。
回到面试现场,建议的答题节奏:先点破「内存装不下」是题眼,给出哈希切分三步框架,主动强调同一 IP 不拆散这条性质,再谈 Top-K 与分布式的延伸。性质讲透了,方案的可信度就立住了。
你在工作中处理过「装不下」的数据吗——当时是分治切开的,还是直接上了分布式框架?欢迎聊聊。
参考资料
- Node.js v22.x 官方 API 文档(文中代码运行环境为 Node.js 22.15.0,生成器与
Map行为以此为准) — https://nodejs.org/docs/latest-v22.x/api/