☰
内存受限下4亿英语短语去重:哈希分片与外部排序实践
2026/10/1 4:46:52 网站建设 项目流程

前几天有准备面试的朋友来问我:内存有限的情况下,怎么对4亿个英语短语去重?这道题在网上流传很广,我也在面试中面试过、也被面试官问过。第一次听到“4亿”这个量级,很多人第一反应是“丢进 Set 里不就行了”,但现实是,你连数据本身的体积可能都还没估算清楚。

这道题的价值不在于背一个标准答案,而在于它同时考察了几件重要的事:你有没有数据规模意识,会不会在大数据量面前先算一笔账;你知不知道内存资源和磁盘 IO 之间的取舍;你能不能把一个看似庞大的问题拆成可以逐个吃掉的小问题。今天我直接用工程视角把这道题拆开,从估算数据体积到几种核心方案,再到面试时怎么组织语言,一次讲透。

1. 先算账:4亿个英语短语,到底有多占内存

1.1 从“裸数据体积”开始估算

很多人一上来就聊算法,我反倒建议先算算数据量级。这个习惯无论是面试还是实际做系统,都能帮你避免很多低级失误。

假设每个英语短语平均 30 个字节左右。这个数字怎么来的?常见短短语像cat、dog不到 10 字节,长一点的如machine learning algorithm到 40 字节,取个平均值 30 已经比较保守。那么:

  • 4亿 × 30 字节 = 120亿字节 = 约 12 GB
  • 如果平均是 50 字节,那就是 20 GB

这还只是“原文本身”的体积。也就是说,哪怕你有一台 16 GB 内存的机器,光把所有短语读进内存就已经很紧张了,更别提去重时还要维护额外的数据结构。而题目限定了“内存有限”,那这个场景基本默认内存是在几个 GB 量级,甚至可能只有 1-2 GB。

所以第一步结论很明确:内存里直接全量放 Set 这个思路,从一开始就不成立。

1.2 语言运行时带来的“隐藏开销”更大

你以为 12 GB 是全部?不是。真正用语言自带的哈希表去存,开销要大得多。我分别按 Java 和 C++ 给你估一下:

Java 里一个 String 本身有对象头,内部还有一个 char[] 数组,这两个对象都有头部信息;char 数组在 UTF-16 编码下每个字符占 2 字节。再加上 HashSet 底层 HashMap 的桶数组、节点对象、链表指针,一个 30 字符的短语在 Set 里实际占的内存通常是 100-200 字节。按 150 字节算:

  • 4亿 × 150 字节 ≈ 60 GB

C++ 情况好一些。std::string 在小字符串优化(SSO)机制下,短字符串直接存在对象内部;但 unordered_set 的节点还要存哈希值、指针、键对象。总体算下来每个条目大约 40-90 字节。按 80 字节估算:

  • 4亿 × 80 字节 ≈ 32 GB

这就是为什么有经验的工程师从来不会在“几十 GB 级数据”前直接 new 一个 HashSet。不是不会写代码,而是心里要先有一张“成本和容量表”。

1.3 真正动手前,先把三个假设问清楚

面试场景题往往故意把条件说得模糊,这时候先提问比先答题重要得多。我通常会确认三件事:

  1. “去重”的定义是什么?短语完全一致才算重复吗?大小写是否敏感?空格和标点要不要统一?如果允许把 “Hello World” 和 “hello world” 视为重复,那方案完全不一样。
  2. “内存有限”到底是多少?512 MB、1 GB、还是 4 GB?不同量级能容纳的桶内数据规模不同,方案参数也要跟着变。
  3. 输入输出的形式是什么?输入是文件里一行一个短语吗?输出只需要一个去重后的结果文件,还是需要保留原始顺序?

问清楚这三件事,本身就是回答的一部分。面试官想看到的往往不是你直接背方案,而是你有没有“先界定问题边界”的意识。

2. 哈希分片:面试里最值得优先讲的方案

2.1 为什么哈希分片能“一招破局”

哈希分片的核心逻辑特别简单:对每个短语计算哈希值,然后取模分到 K 个桶里。相同短语的哈希值一定相同,所以它们必然进同一个桶。于是“全局去重”就变成了“每个桶内部去重”,每个桶的数据量大约是总量的 1/K,内存压力瞬间降下来。

这个思路的本质,是用一次全量的磁盘读写,换取“内存只装得下一个小分片”的可行性。工程上这叫分而治之,思维上叫“把一个不可能在内存里完成的问题,拆成多个能在内存里完成的小问题”。

这也是为什么我推荐在面试中优先讲这个方案:它思路清晰、实现简单、可扩展性强,而且直接命中“内存受限”这个核心约束。对比外部排序,它的复杂度还更低。

2.2 实现步骤:可以用 Python 伪代码快速演示

如果让你用代码表达,我会写一套和下面结构类似的版本:

import hashlib K = 64 # 分片数,后面会讲怎么定 # 第一遍:按哈希把短语分发到 K 个桶文件 with open("input.txt", "r", encoding="utf-8") as fin: bucket_fds = [open(f"bucket_{i}.txt", "w", encoding="utf-8") for i in range(K)] for line in fin: phrase = line.strip() h = int(hashlib.md5(phrase.encode("utf-8")).hexdigest(), 16) bucket = h % K bucket_fds[bucket].write(phrase + "\n") for fd in bucket_fds: fd.close() # 第二遍:逐个桶读入内存去重 with open("deduped.txt", "w", encoding="utf-8") as fout: for i in range(K): seen = set() with open(f"bucket_{i}.txt", "r", encoding="utf-8") as fin: for line in fin: phrase = line.strip() if phrase not in seen: seen.add(phrase) fout.write(phrase + "\n") # 这个桶处理完就释放内存,继续下一个

这里有几个工程细节值得单独说:

  • 不要一次把整个输入文件读入内存。用流式读取,一次处理一行,否则你第一遍分桶就把内存吃满了,方案直接失败。
  • 写桶时用缓冲写入。Python 里可以用io.BufferedWriter,或者干脆在写文件的循环里攒一批再 flush,减少小文件频繁写盘的系统开销。
  • 第二遍逐个桶处理,保证峰值内存只和一个桶的大小相关。这是整个方案的精髓。

2.3 分片数 K 怎么定

K 的选择直接决定内存峰值。原则很简单:单个桶的数据量,必须能稳稳塞进内存预算。

假设单条短语在 Set 里的开销是 80 字节,内存预算只有 1 GB,那一个桶最多大约放:

  • 1 GB / 80 字节 ≈ 1250 万条

总量 4亿,想要单桶不超过 1250 万条,K 至少要:

  • 4亿 / 1250万 ≈ 32

考虑到哈希分布不可能绝对均匀,最好留出 2-3 倍余量,所以我一般会定 K = 64 甚至 K = 128。K 越大,每个桶越小,内存越安全,但临时文件和后续 IO 也会增多,这是一个典型的“空间换 IO”权衡。

还有一个细节:取模的 K 尽量不要用 2 的幂次。某些哈希函数低比特位分布并不均匀,如果 K 是 2 的幂,等于只用了哈希值的低几位,容易扎堆。用质数或者接近质数的 K,会稳很多。

2.4 处理“热桶”:如果某个桶还是太大怎么办

理论归理论,现实里可能会碰到极端情况:某个桶的短语格外多,或者某个桶里的短语特别长,导致它超出了内存预算。这时候有两个处理方向:

  • 二级哈希分片。对超出阈值的大桶,换个哈希函数再分一次子桶,把子桶控制到可接受大小,每个子桶内部去重。逻辑和第一级完全一样,相当于递归调用。
  • 桶内外部排序。如果不想再维护一批文件,也可以直接在单桶内走“分块排序 + 归并”的思路,用磁盘换内存。

我实际见过的情况是:只要第一轮哈希函数选得好、K 留了余量,绝大多数桶都不会太大。但面试时你能主动说出“热桶怎么办”,会显得你见过真实数据里的倾斜问题,这比只会背方案的人高出一个段位。

2.5 时间复杂度和 IO 成本

整个流程是:读一遍全量输入,写 K 个桶;再读 K 个桶,逐个去重后写结果。总共大约两遍读、两遍写,时间接近 O(N),空间是 O(N/K)。

如果按 4亿条、每条约 30 字节来算,单机一次全量读写的 IO 总量大约是十几个 GB 乘以两遍,SSD 上只要十几分钟到半小时就能跑完。这个成本在大数据场景下完全可以接受。

3. 外部排序:把去重变成“有序序列上的相邻比较”

3.1 为什么“有序”之后去重就变得很简单

如果你手里是一个已经全局有序的短语序列,去重就变成了一次线性扫描:每读到一个新短语,只要和上一个对比,相同就跳过,不同就保留。重复项在排序后一定相邻,所以不需要任何额外的哈希表,内存里只要存一个“上一个短语”就够。

这就是外部排序思路的核心逻辑:我不去维护一个“记录所有出现过的元素”的结构,而是通过排序让重复项物理上靠到一起。

3.2 外部排序的标准三步:分块、排序、归并

外部排序的思路是,先用内存能容纳的块大小把大文件切碎,把每个碎片分别排好序,再通过多路归并拼成一个全局有序的序列。

第一步,分块。比如设定一个块大小是 256 MB,每次读入 256 MB 的短语,在内存里排序后写成一个临时文件run_0.txt、run_1.txt……原始数据是 12 GB,那大约会生成 48 个临时文件。

第二步,多路归并。同时打开所有这些临时文件,每个文件维护一个读指针,用最小堆选出当前最小的一条输出,然后继续读对应文件的下一条,恢复到堆里。这样一路输出,得到的就是全局有序的完整序列。

import heapq def k_way_merge(runs, fout): heap = [] for run_id, run in enumerate(runs): phrase = next(run, None) if phrase is not None: heapq.heappush(heap, (phrase, run_id)) last = None while heap: phrase, run_id = heapq.heappop(heap) if phrase != last: # 去重逻辑 fout.write(phrase + "\n") last = phrase nxt = next(runs[run_id], None) if nxt is not None: heapq.heappush(heap, (nxt, run_id))

第三步,在归并输出时顺便去重。因为全局有序,重复项必然连续出现,只需要记住上一次输出的是什么,碰到相同的就直接跳过。这一行代码在场外处理里极其关键。

3.3 内存和磁盘 IO 成本算一算

外部排序的内存占用和分块大小直接相关。每个临时文件只需要一个读缓冲区,堆里最多同时存在“路数”个元素。假设 48 路归并,堆里几百条短语,内存开销可以控制在 MB 级,比哈希分片还要省。

但 IO 就没那么便宜了。每一轮分块排序要写一遍临时文件,归并要读一遍临时文件,最后写结果。如果临时文件数量超过系统文件描述符上限,或者单轮归并结果太大,你还要做多轮归并,每多一轮就多一轮全量的读写。

按 12 GB 原始数据算:第一轮写约 12 GB 临时文件,归并读 12 GB,写结果约几个 GB,总 IO 量大概 30-40 GB。这还是一次归并能完成的情况。所以外部排序的优势是内存极小、输出天然有序,代价是时间比哈希分片慢,IO 也更多。

3.4 和哈希分片怎么选:取决于要不要“有序输出”

我在面试和实际项目里的判断标准很简单:

  • 如果只需要“去重后的结果”,不关心顺序——首选哈希分片,它更快、实现更直观。
  • 如果下游还想做“排序输出”“范围查询”“TopK”,或者内存紧张到只有几百 MB——外部排序更合适,因为排序结果可以一次产出多个用途。
  • 如果两者都想要,也可以先哈希分片,每个桶内排序后再按桶号归并输出。不过这是叠加方案,复杂度上去了,一般不推荐在面试里主动展开,除非面试官追问。

面试时你能把“何时选哪个”说清楚,比单纯写代码更体现工程判断力。

4. 布隆过滤器:它不能单独完成,但能让方案更经济

4.1 布隆过滤器的原理和关键特性

布隆过滤器是个很常见的概率性数据结构:一个 m 位的位数组,搭配 k 个哈希函数。插入一个元素时,把 k 个哈希位置分别置为 1;查询一个元素时,检查这 k 个位置是否全为 1,只要有一个是 0,就说明这个元素一定没出现过。

这个结构有两个让人又爱又恨的特点:

  • 没有漏判(false negative)。布隆说“没见过”,那一定没见过。
  • 有误报(false positive)。布隆说“见过”,可能只是其他元素把那些位也置成了 1。

用大白话说,它像是一个“只记印象不记细节”的保安:你说一个名字,他说好像见过,可能是真见过,可能是记串了;但他说肯定没见过,那就是真没见过。

4.2 算一笔账:4亿规模的布隆过滤器要多少内存

布隆过滤器的内存和误报率强相关。用经典的公式:

  • m = - (n × ln p) / (ln 2)^2
  • k = (m / n) × ln 2

其中 n 是元素数量,p 是期望误报率,m 是需要的位数。

代入 n = 4亿,p = 1%:

  • m ≈ 3.84 × 10^9 bit ≈ 480 MB
  • k ≈ 7

也就是说,一个误报率 1%、能容纳 4亿短语的布隆过滤器,只需要大约 480 MB 内存。如果放宽到 0.1% 误报率,也只需要大约 720 MB。这个内存占用在“有限内存”条件下完全可行,而且每查一个短语只需要算 7 次哈希,速度极快。

这个数字很惊艳,所以我见过不少候选人会直接提出“用布隆过滤器去重”。但这里有个概念陷阱。

4.3 为什么不能用布隆过滤器直接做去重

去重这个动作的定义是:最终输出的集合里,每个短语只出现一次,并且不能漏掉任何一个真实存在的短语。

布隆过滤器的问题在于误报。如果一个从未出现过的短语,因为哈希位置冲突被误判为“已存在”,你在去重逻辑里就会直接跳过它,结果就是把这个短语整个漏掉了。对某些场景这可能无所谓,比如爬虫遇到少量 URL 重复无伤大雅;但对“精确去重”的要求来说,这是不可接受的错误。

所以面试时如果题目没有明确说“允许误报”,你不能把布隆过滤器当作最终去重结构。你可以在回答里主动点出这一点,顺便展示你理解了概率型结构的能力边界。

4.4 正确的组合姿势:布隆过滤器当“预检”,磁盘当“精查”

布隆过滤器虽然不能单干,但它能在精确方案里当加速器。经典的组合思路是:

  1. 内存里维护一个布隆过滤器,外部存储维护一个精确的已见集合(比如哈希分片后的桶文件)。
  2. 来一个短语,先查布隆过滤器。
    • 如果布隆说“肯定没见过”,直接写入结果,同时更新布隆过滤器,不需要去磁盘翻文件。
    • 如果布隆说“可能见过”,再去外部存储里精确查找确认;确认存在就丢弃,确认不存在就写入并更新布隆。
  3. 因为大多数重复短语会被布隆过滤器直接拦住,真正需要走磁盘确认的只是一小部分,整体 IO 大幅下降。

这个模式的本质是“用内存换磁盘随机访问次数”。我在做爬虫 URL 去重时就经常这么干:内存里放一个几百 MB 的布隆过滤器,外面配一层 KV 存储做精确确认,效果比单纯用哈希表快很多。面试时你能把它作为“进一步优化”提出来,会是一个很自然的加分项。

5. 数据本身的可压缩性:几个“加分项”技巧

5.1 排序后做前缀压缩

英语短语不是随机字节序列,它天然有大量的公共前缀。比如the quick brown fox、the quick blue dog这种结构,排序之后相邻的两条短语可能共享很长一段前缀。

利用这一点,存储时可以只记录“与上一条共享的前缀长度 + 差异后缀”。比如上一条是the quick brown fox,下一条是the quick blue dog,那就只需要存共享前缀11字节 + "blue dog"。这种方法在自然语言数据上通常能省 30%-70% 的空间。

注意,前缀压缩主要优化的是“驻留内存/磁盘文件”的体积,不会改变你选用的去重算法,但在内存极紧的场景里,它能让你把 K 值放大、单桶装更多数据,实际效果很可观。

5.2 词表编码:把短语拆成词 ID

另一个思路是拆词。4亿个短语听起来巨大,但构成它们的英语单词数量其实有限,可能就几十万到几百万的规模。那我可以建一个全局词典,把每个单词映射成一个短整数 ID,短语就从“一串字符”变成“一串 ID”。

比如:

  • the cat→[17, 42]
  • the dog→[17, 98]

这样两个短语可能只需要 2 个 int,也就是 8 字节,远小于原来的 10-15 字节字符存储。如果短语有固定长度限制,甚至可以连长度都不用存,进一步压缩。

但这个方案有个前提:你要能可靠地分词,而且处理的是规范的英语短语,不能是一堆没有空格、没有规律的字符串。面试中提到“如果短语结构规整,可以用词表编码进一步压内存”,足以说明你对数据特征有敏感性。

5.3 极低内存下的数据库和分布式扩展

如果内存真的低到连一个分桶都装不下,比如只有 64 MB,那可以退一步用数据库方案:把短语作为主键写入 SQLite 或类 LevelDB 的嵌入式存储,靠数据库的 B+ 树索引去重。原理上仍然是“用磁盘索引换内存”,只是把分桶逻辑外包给了成熟的存储引擎。

如果允许多台机器,那思路就更灵活了。最简单的方式就是把哈希分片的桶分发到不同机器上,每台机器只负责自己的几个桶。本质上还是同一个分治思想,只是把“内存”的范围从单机扩大到了集群。分布式只是把同一个方案放大,而不是引入新概念,这一点你要能在面试里表达清楚。

6. 面试怎么答:10分钟组织一个完整回答

6.1 我的答题节奏:约束先行,方案殿后

回答这类题最忌讳不问条件就猛讲。我会把时间这样分配:

  • 前 1-2 分钟,确认“去重标准”“内存上限”“输入输出格式”三个前提。
  • 接下来 3-5 分钟,主推哈希分片方案:讲清楚原理、K 怎么定、时间复杂度、内存怎么控制。
  • 再用 1-2 分钟补充一句:如果需要全局有序,可以改成外部排序;如果允许一点误差,可以用布隆过滤器做预过滤。
  • 最后留一点时间说工程细节,比如热桶怎么处理、IO 量级估算。

这套节奏的优点是:既有方案,又有取舍,还能体现你考虑过真实实现里的坑。

6.2 高频追问和参考答案

我在模拟面试时,经常会追问下面几个问题,你可以提前准备:

追问一:你会把 K 定为多少?

直接按公式来:K ≈ 总数据量 × 单条内存开销 / 内存预算,再留出至少一倍余量。比如总数据 4亿条、单条 80 字节、预算 1 GB,算出来最小 K 是 32,实际我会选 64 或 128。

追问二:如果某个桶还是太大怎么办?

两个方向:一是对那个桶做一个二级哈希二次分片,递归处理;二是桶内改用外部排序。本质上都是继续“分割到能装进内存为止”。

追问三:要是允许少量误判呢?

那就直接用布隆过滤器当作近似去重结构,按误报率 1% 来算,4亿条大约需要 480 MB 内存,配 7 个哈希函数。然后强调一句:布隆过滤器只能误报不能漏报,所以它适合“宁可多留少量重复,也不能漏数据”的场景。

追问四:如果换到多台机器,方案怎么改?

把哈希分片的桶分发到各机器,每台只处理自己负责的桶集合。分桶逻辑完全复用,只是从“单机多个文件”变成“集群多个节点”。

6.3 最后分享一点我的实际体会

这类题看似是“面试场景题”,其实就是海量数据处理里最常见的一类问题。我自己做爬虫 URL 去重的时候,用的就是 64 个文件分桶,然后把每个桶按顺序读进内存做精确去重,整套流程跑下来,处理上亿条数据只需要一台普通服务器加一块 SSD。

这道题给我的最大启发不是哪个数据结构更高级,而是:先算账,再分治,最后再谈优化。你先对数据规模心里有数,再思考怎么把问题切成内存能承受的小块,布隆过滤器、前缀压缩这些技巧都是建立在这两步之上的锦上添花。能把这条思路讲出来,比背十个方案都管用。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询