图 1:本文封面,点明「两遍扫描定位中位」的主线。
先说明属性:这是一篇历史题目复盘,题目出自本地历史面试资料(见文末来源说明),与任何公司的当前招聘无关。「内存装不下,还要算中位数」是海量数据处理的经典题型,面试官想考察的不是排序算法,而是你能否把「排序整个数据集」换成「计数加定位」。
先给核心结论:分桶计数三遍走——第一遍流式扫描只给桶计数,内存里只有几百个计数器;按计数累加定位中位数所在的桶;第二遍扫描只把中位桶的数读进内存,排序取中位。总代价是两遍磁盘扫描,任意时刻内存只装一个桶。2G 内存绰绰有余。
第一步,先算清楚约束。
10G 个整数,按每个 8 字节算是 80G 数据,2G 内存连零头都装不下,任何「全部读进来再说」的思路直接出局。但换一个角度:中位数是第 50 亿小的那个数,要回答「它是谁」,未必需要知道所有数的名次——只需要知道「比它小的有多少个」。计数比排序便宜得多,这就是破题口。
第二步,分桶计数的三步。
第一遍,分桶计数。按数值高位把整数划进若干桶,比如 64 位整数按高 8 位分 256 个桶。流式扫一遍文件,每个数只让对应桶的计数器加一。内存里只有 256 个计数器,按 8 字节一个总共 2KB,毫无压力。
第二遍,定位中位桶。从最小的桶开始累加计数,加到总数一半的那一刻,所在的桶就是中位数所在的桶——它之前的桶装不下前 50 亿个,它之后装得超。
第三遍,桶内精读。只把这个桶的数重新从磁盘扫进内存。一个桶大约装总数量的二百五十六分之一,约 4000 万个数,300 多 MB,2G 内存轻松装下。排序后按偏移取出中位数。如果某个桶还是太大,就在桶内再按次高位细分一层,递归下去直到装得下。
图 2:分桶找中位数的三步流程,以及与外部排序的代价对比。
第三步,用小数据验证一遍思路。
下面这段代码把「256 个高位桶」缩小成「按十位数分 10 个桶」,完整演示三遍流程并用全排序交叉复核,用 Node.js 22.15.0 直接运行(node median.js):
'use strict';
// 分桶找中位数:第一遍只计数,第二遍只精读中位桶
// 这里用小整数模拟:把「256 个高位桶」缩小成「按十位数分 10 个桶」
function bucketMedian(nums) {
const buckets = new Array(10).fill(0);
for (const n of nums) buckets[Math.floor(n / 10)]++; // 第一遍:流式计数,内存只有 10 个计数器
const total = nums.length;
const half = Math.floor((total + 1) / 2); // 中位数是第 half 小的数
let acc = 0;
let mid = -1;
for (let b = 0; b < 10; b++) {
if (acc + buckets[b] >= half) { mid = b; break; } // 累计计数跨过一半,中位数就在这个桶
acc += buckets[b];
}
// 第二遍:只把中位桶的数装进内存排序,取出中位数
const inBucket = nums.filter((n) => Math.floor(n / 10) === mid).sort((a, b) => a - b);
return { bucket: mid, median: inBucket[half - acc - 1] };
}
// 全量排序复核(内存装得下时才能这么干)
function sortMedian(nums) {
const sorted = [...nums].sort((a, b) => a - b);
return sorted[Math.floor((sorted.length - 1) / 2)];
}
const cases = [
[7, 3, 42, 15, 28, 91, 56, 33, 68, 24, 81],
[5, 5, 5, 5, 5],
[99, 1, 50, 50, 50, 2, 98],
Array.from({ length: 1001 }, (_, i) => (i * 37) % 100), // 1001 个伪随机两位数
];
for (const nums of cases) {
const { bucket, median } = bucketMedian(nums);
const check = sortMedian(nums);
console.log(`n=${nums.length} 中位桶=[${bucket * 10},${bucket * 10 + 9}] 分桶结果 ${median},全排序复核 ${check}`);
if (median !== check) throw new Error('两种方法结果不一致');
}
实测输出:
n=11 中位桶=[30,39] 分桶结果 33,全排序复核 33
n=5 中位桶=[0,9] 分桶结果 5,全排序复核 5
n=7 中位桶=[50,59] 分桶结果 50,全排序复核 50
n=1001 中位桶=[40,49] 分桶结果 49,全排序复核 49
四个用例两种方法全部一致,覆盖了全相同值、大量重复值、千级随机数据三个边界。
易错点清单。
一,第一反应是外部归并排序——思路正确但要多轮磁盘读写,讲不出分桶这个更省的方案就落了俗套。二,桶按个数平均分而不是按数值范围分——分桶的意义是让「第几小」能用计数定位,乱分就失去意义。三,忘记中位桶可能仍然装不下,要讲出递归细分的兜底。四,偶数个数据时中位数是两个中间值的平均,口径要先和面试官对齐,本文演示按奇数个处理。五,把 10G 个整数当成 10G 字节——数量与字节的换算是最先该算的账。
回到面试现场,建议的答题节奏:先算数据量与内存的账,排除全量加载;再给分桶计数三步,强调两遍扫描与内存占用;然后交代桶内过大的递归细分;最后补偶数口径与外部排序备选。数据集的中位数、分位数统计是特征工程的常规操作,内存受限下的分治统计,是数据工程师的基本功。
如果面试官把题目改成「数据是流式到来的,没有文件可以扫两遍」,分桶计数还成立吗?你会换什么近似算法?欢迎写出你的思路。
参考资料
- Node.js v22.x 官方 API 文档(文中代码运行环境为 Node.js 22.15.0,
Array.prototype.filter、数值下划线分隔符等行为以此为准) — https://nodejs.org/docs/latest-v22.x/api/