去年帮团队做技术面试模拟,我随手在 LeetCode 上挑了一道题当考题,题目就是“字母异位词分组”。三个候选人里,有两个能脱口而出用哈希表,可真到了白板上写代码,卡住的点几乎一模一样:不知道拿什么当 key。这道题表面上只是把字符串按异位词关系归组,可一旦你把思路落到代码层面,会发现里面的取舍、边界条件、坑位比想象中多得多。这篇文章我就从那次面试复盘开始,把解题思路、两种主流写法、踩坑记录,以及这题背后真正值得带走的工程思维,完整拆开聊一遍。
1. 一次面试复盘:题面很短,坑位不少
1.1 先搞清楚我们到底要分什么组
字母异位词,英文叫 anagram,指的是两个字符串包含的字符种类和数量完全一致,只是排列顺序不同。比如"eat"、"tea"、"ate",都是由一个e、一个a、一个t组成的,顺序不一样而已,它们就是一组异位词。而"tan"和"nat"又是另一组,因为它们的字符构成是t、a、n。
题目输入是一个字符串数组,比如:
["eat", "tea", "tan", "ate", "nat", "bat"]期望输出是按异位词关系分组后的二维数组,顺序无所谓:
[ ["eat", "tea", "ate"], ["tan", "nat"], ["bat"] ]注意"bat"只有自己一个,也得单独作为一组输出。这个细节很多人第一遍会看漏,等到写测试用例的时候才发现漏了单个元素的组。
这道题在 LeetCode 上是第 49 题,标注难度是中等,但实际上它的入门门槛很低,核心思路可能小学奥数水平就能理解——两个人名字字母相同、调换顺序,本质上就是同一组嘛。真正的分歧在于:你怎么在程序里快速判断任意两个字符串是不是异位词,并且把它们放进同一个集合里。
1.2 “分组”两个字才是真正的题眼
我复盘那次面试时发现,候选人普遍有一个共同问题:他们在纠结“如何判断两个字符串是不是异位词”,比如排序后比较是否相等,或者用两个哈希表数一下字母频次再比。这个方向当然没错,但如果你把思路局限在“两两比较”,复杂度就会变得非常难看——N 个字符串,两两比较一遍就是 O(N²),哪怕每次比较很快,数据一多也扛不住。
正确的切入点是“分组”这个词。听到分组,第一反应应该是:我需要一个映射关系,把同一类的元素映射到同一个标识上。这就意味着要建哈希表,key 是某种“统一的标识”,value 是对应的字符串列表。于是问题从“两两比较”变成了“给每个字符串找一个分组标识”,复杂度直接被压成了线性级别的单次遍历。
这一步思维转换,就是这道题最核心的考点。从算法角度讲,它考的不是排序、不是哈希技巧本身,而是你有没有意识到“分组”这个动作天然就该用哈希表去建模。后面的所有解法,其实都是在回答同一个问题:什么样的 key 能让异位词映射到一起,同时让非异位词不撞车。
2. 排序键法:把乱序字符拉回同一条轨道
2.1 排序为什么能让异位词自动“对暗号”
最容易想到、也最稳的方案是排序键法。思路就一句话:把每个字符串内部的字符按字典序排序,把排序结果当作 key。
为什么它能成立?因为如果两个字符串是异位词,它们的字符构成完全相同,排序之后的结果必然一模一样。反过来,如果两个字符串排序后相同,说明它们的字符构成完全相同,必然是异位词。这是一个等价的充要条件,不会漏,也不会错。
打个比方:你把每个组的人都拉去军训,规定所有人必须按身高从低到高站成一排。原来乱糟糟站队的同一组人,站完之后队伍形态是一样的;不同组的人,哪怕身高组成只差了一点点,站出来的队伍形态也一定不同。排序就是那把“强制整队”的尺子。
还有个容易被忽略的好处:排序后的字符串天然是无碰撞的。因为两个不同构成的字符串,排序结果一定不同,不存在“误分到同一组”的可能性。这一点在面试回答里非常加分,你可以明确告诉面试官,这个方案的 key 是确定性的,不存在哈希碰撞导致的语义错误。
2.2 Python 实现:几十行以内就能跑通
from collections import defaultdict def group_anagrams(strs: list[str]) -> list[list[str]]: groups = defaultdict(list) for s in strs: key = "".join(sorted(s)) groups[key].append(s) return list(groups.values())用defaultdict(list)省掉了手动判断 key 是否存在的步骤,每次计算完 key 直接 append 就行。整个函数的返回值,是把字典的所有 value 转成列表,每一组异位词自然就是一个子列表。
针对题目给的示例,这段代码的执行过程是:
strs = ["eat", "tea", "tan", "ate", "nat", "bat"] "eat" -> sorted("eat") -> ['a', 'e', 't'] -> "aet" -> {"aet": ["eat"]} "tea" -> sorted("tea") -> ['a', 'e', 't'] -> "aet" -> {"aet": ["eat", "tea"]} "tan" -> sorted("tan") -> ['a', 'n', 't'] -> "ant" -> {"aet": [...], "ant": ["tan"]} "ate" -> sorted("ate") -> ['a', 'e', 't'] -> "aet" -> {"aet": ["eat", "tea", "ate"], "ant": ["tan"]} "nat" -> sorted("nat") -> ['a', 'n', 't'] -> "ant" -> {"aet": [...], "ant": ["tan", "nat"]} "bat" -> sorted("bat") -> ['a', 'b', 't'] -> "abt" -> {"aet": [...], "ant": [...], "abt": ["bat"]}最终输出:
[["eat", "tea", "ate"], ["tan", "nat"], ["bat"]]和题目期望完全一致,组内字符串的相对顺序取决于遍历顺序。
2.3 排序键法的性能画像
假设字符串数组里有 N 个字符串,平均每个字符串长度是 K。排序的时间复杂度是 O(K log K),遍历 N 个字符串之后,整体时间复杂度就是 O(N × K log K)。额外的空间消耗主要是每一组的存储和哈希表的 key,整体大概 O(N × K)。
这个复杂度在 LeetCode 的数据规模下完全没问题,几十毫秒就能跑完。即便是实战项目中处理几万个短单词,排序键法也足够快。选择排序键法的最大优势是简单、鲁棒:你不用假设字符集,大小写混着来也行,甚至字符串里出现数字、下划线、中文,只要你用sorted()对字符排序,异位词判断依然成立。这得益于 Python 对任意 Unicode 字符都能排序。
但我必须补一句:排序操作本身是有开销的,尤其当单个字符串特别长,比如几万字符的文本片段,排序一次的成本会明显上升。这时候你可能更想要下面这种不用排序的计数键法。
3. 计数键法:把排序换成字符频率指纹
3.1 用频率表当“身份证”
既然异位词的字符构成完全相同,那我不需要排序,直接统计每个字符出现的次数不就行了?这个统计结果就是字符串的“字符频率指纹”。
以小写字母为例,我可以开一个长度为 26 的数组,下标从 0 到 25 分别对应a到z,遍历字符串,每遇到一个字符,就在对应位置加 1。最后把这个数组转成元组(tuple),当作 key 存进哈希表。
为什么用元组而不是列表?因为 Python 里列表是可变的,不能作为字典的 key。元组不可变、可哈希,天然满足字典键的要求。这一点不少面试候选人会当场卡住,写了个列表当 key,一运行就报TypeError: unhashable type: 'list'。
3.2 代码实现:基础版与通用版
标准的小写字母版写法:
from collections import defaultdict def group_anagrams(strs: list[str]) -> list[list[str]]: groups = defaultdict(list) for s in strs: count = [0] * 26 for ch in s: count[ord(ch) - ord("a")] += 1 groups[tuple(count)].append(s) return list(groups.values())ord(ch) - ord("a")的作用是把字符ch映射到 0-25 的整数下标。比如'a'的 ord 值是 97,'a' - 'a'算出 0,'z' - 'a'算出 25。这是很多语言里处理英文字母的惯用技巧。
上面这个写法有一个隐含假设:字符串只包含小写字母。如果输入里混进了大写字母、数字或者中文,count 数组的下标会越界或者错位。实际刷题时题目一般会明确说明只包含小写字母,但你要是想写一个更通用的版本,可以这样:
from collections import defaultdict def group_anagrams_general(strs: list[str]) -> list[list[str]]: groups = defaultdict(list) for s in strs: freq = defaultdict(int) for ch in s: freq[ch] += 1 key = frozenset(freq.items()) groups[key].append(s) return list(groups.values())这个版本用字典记录每个字符的频次,再用frozenset(freq.items())作为 key。frozenset是可哈希的,而且不关心频次项的排列顺序,天然适合当键。当然,通用版的构建成本比数组版本高一些,在 LeetCode 的 26 个小写字母场景下没必要用。
3.3 两种主流解法怎么选?看数据说话
到了这一步,你手上有两条路:排序键法和计数键法。它们各有优劣,我把关键维度列出来:
| 对比维度 | 排序键法 | 计数键法 |
|---|---|---|
| 核心操作 | 对每个字符串排序 | 统计每个字符频次 |
| 时间复杂度 | O(N × K log K) | O(N × K) |
| 实现难度 | 极低,两三行 | 中等,要理解数组下标映射 |
| 字符集假设 | 几乎无限制 | 数组版默认只支持小写字母 |
| 键是否可读 | 可读,排好序的字符串 | 不可读,是一串数字 |
| 适用场景 | 短字符串、通用字符集 | 长字符串、固定字符集 |
从我自己的刷题习惯来说,LeetCode 上我优先写计数键法,因为字符串长度短的时候两者速度都很快,但计数法的时间复杂度更好,对面试官来说也更能体现你对“字符频率”这个概念的理解。而实际项目的 text processing 场景里,如果字符集不确定、字符串也不长,我会选排序键法,省心,不容易写错。
4. 实战踩坑记录:三个容易翻车的角落
4.1 空字符串到底算不算一组
异位词分组里,空字符串是个容易被忽略的特殊输入。""的排序结果还是"",它的字符频率数组是全零,所以会形成一个单独的组。这没问题,关键是你的代码得能正确处理它,而不是在sorted("")或者count[ord(ch) - ord("a")]的地方报错。
另一个类似的边界是数组里存在重复字符串,比如["a", "a"]。这时候两个"a"应该被放进同一个组,输出[["a", "a"]]。如果输出里出现了两个独立的["a"],说明你的分组逻辑写错了。这个用例我建议你写代码前先在心里过一遍,因为它能同时检验你对“同一字符串多次出现”的处理是否正确。
4.2 哈希键会不会碰撞?要区分两种“碰撞”
这里有个概念特别容易搞混。我们设计的 key(排序字符串或频率元组),会不会把两个不同的异位词分组错误地映射到一起?
不会。这是因为排序键法和计数键法都满足一个性质:key 相同当且仅当两个字符串是异位词。排序字符串是字符重排后的唯一形态,频率元组是字符分布的唯一刻画,两者都是双射关系,天然不存在语义碰撞。
另一种“碰撞”是哈希表底层的哈希冲突,即不同 key 的 hash 值碰巧相同。这是字典本身要处理的问题,Python 的 dict 内部会用开放寻址或链地址法解决,轮不到你操心。你要担心的是前者——千万别设计出多对一的 key。比如有个常见的错误想法:直接统计字符总个数当 key,那"ab"和"cd"长度都是 2,会被错误地分到一组,这显然是错的。
4.3 大写字母和混合字符集
题目如果说只包含小写字母,那数组版计数法是最快路径。但如果你在真实项目里复用它,遇到"Eat"和"ate"这种大小写混排的字符串,计数法会认为它们是不同的字符,因为ord('E')和ord('a')的差值完全不同。
处理方式有几种:最简单的是在统计前统一lower(),把英文字母全部转成小写。这样"Eat"的 key 就会和"ate"一致。还有一种做法是只过滤保留字母字符,忽略空格和标点,这在处理英文文本时很常见。我自己在实际处理搜索词的时候,通常会做一层清洗:小写化 + 去标点 + 压缩连续空格,然后才去统计频次。要是你直接拿原始字符串套用 LeetCode 解法,结果往往会让你怀疑人生。
4.4 一个冷门但高效的黑科技:质数乘积法
既然提到了 key 的设计,我再讲一个在面试里能惊艳全场的技巧:用 26 个质数分别对应 26 个字母,比如a=2、b=3、c=5、d=7……每个字符串的 key 就是所有字母对应质数的乘积。
PRIMES = [ 2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47, 53, 59, 61, 67, 71, 73, 79, 83, 89, 97, 101, ] def group_anagrams_prime(strs: list[str]) -> list[list[str]]: groups = defaultdict(list) for s in strs: product = 1 for ch in s: product *= PRIMES[ord(ch) - ord("a")] groups[product].append(s) return list(groups.values())质数乘积法的依据是算术基本定理:任何正整数都可以唯一分解成质数的乘积,顺序无关。aab和aba都会算出2 × 2 × 3 = 12,而任何其他字母组合都不可能算成 12。它的时间复杂度降到了 O(N × K),而且代码很短,看起来非常聪明。
但我要提醒你,这个方案有一个隐藏风险:当字符串很长时,乘积会指数级膨胀。在 Python 里整数是任意精度的,不会溢出,所以没事。但你要是用 Java 的int或 C++ 的long,很快就会溢出成错误结果。所以这个技巧更适合面试时作为思路补充,真正写产品代码我不会用它,除非能严格限制字符串长度。以前有次我用 Java 跑这个思路,一个 20 来字符的字符串乘积就已经超过了long的安全范围,差点背锅。别问我为什么记得这么清楚。
5. 从一道题抽象出一种通用思维:归一化键
5.1 这道题真正想让你带走的东西
很多人刷题只记结论,刷完“字母异位词分组”,下一次遇到“判断两个字符串是否互为异位词”能想起来,再遇到“将乱序字符串分组”又想半天。其实这三类问题都是同一个套路:归一化键。
什么叫归一化键?就是在一个分组问题里,你为每一个元素计算一个与它一一对应的标准特征,让同一组的元素得到完全相同的特征值。然后把特征值作为哈希表的 key,整个分组问题就变成了“遍历一次 + 按 key 归类”。
这个套路绝不仅限于异位词。比如你有一堆文件名,想把扩展名相同的归到一起,key 就是扩展名;你有一堆订单,想按月归组,key 就是订单时间的年月部分;你在做语音识别后处理,想把同一句话的不同口音文本归组,先做一次拼音或音标归一化,再拿归一化结果当 key。本质上都是一回事。
理解了这一层,“字母异位词分组”就不只是一道孤立的题了,它是一类“把复杂对象投影到标准空间再分组”问题的原型。面试官后续追问的很多变种题,都是在换着花样考这个点。
5.2 跟它捆在一起考的兄弟题目
LeetCode 里和异位词相关的题有好几道,难度递增,值得一起刷:
- 242. 有效的字母异位词:只问你两个字符串是不是异位词,不需要分组。用计数键法就是 O(N) 一次遍历,是最简单的热身题。
- 438. 找到字符串中所有字母异位词:在长串 s 里找短串 p 的所有异位词子串起始位置。这道题要用滑动窗口加频率数组,窗口右移时更新字符频次,和分组题结合得很好。
- 49. 字母异位词分组:就是本文这道题,四边形齐了。
如果你把这三道题连着刷,会发现它们的共同核心都是“字符频率的比较”。区别只在于,一个是一次性比较,一个是在滑动窗口里持续比较,一个是把所有元素统一归组。从一道题延伸到一类题,刷题效率会高很多。
5.3 当数据规模变大,单机哈希表还够用吗
最后聊点工程化的思考。如果数据量非常大,比如你有几千万个词条需要按异位词关系分组,单机内存可能装不下这个哈希表。这时候的通用做法是分而治之:先对每个字符串计算归一化键,然后按键的哈希值分散到不同的分片上去处理,每个分片内部再继续分组。键的设计完全复用,只是把哈希表从单机挪到了分布式环境。
更进一步的思路是,在真实项目里你往往不需要精确分组,只需要“相似”分组。比如搜索引擎的纠错和联想词,它会把"recieve"和"receive"归到一类,但不能硬用异位词关系,因为字符构成并不完全相同。这种场景需要引入编辑距离、拼音归一化甚至向量化之后再聚类,方法已经完全不同,但那套“先计算标准特征,再按特征归组”的骨架依然成立。
说白了,哈希表只是工具,归一化键才是这道题藏在代码后面的思想。你能不能在面试中把这一层讲透,往往比背出代码要重要得多。
我自己对这道题的感受是:它好就好在足够简单,简单到你能看清一整类解题思想的脉络。每次有朋友问刷题该从哪起步,我一般都会推荐把“字母异位词分组”和它的兄弟题一起刷了。代码写得再花哨,不如把这个“投影-归组”的思维刻进脑子,后面遇到再复杂的分组问题,你至少不会慌。