
最近在整理哈希相关题目时又翻到了这道经典的字母异位词分组Group AnagramsLeetCode 第 49 题也是后端、客户端笔试面试里出现频率很高的一道题。题意一句话就能说清楚给定一个字符串数组把字母组成相同、只是排列顺序不同的词放到同一组里。比如输入 [eat, tea, tan, ate, nat, bat]输出就应该包含 [eat, tea, ate]、[tan, nat]、[bat] 这三组。上手容易但想写得好、答得深其实涉及字符串归一化、哈希键设计、复杂度权衡等一系列问题。这篇文章就围绕这道题展开讲讲思路演进、几种解法的取舍、边界情况以及我在实际做题和面试中踩过的坑适合准备校招笔面试的应届生、想巩固哈希表应用的在职工程师也适合刚接触算法题、想建立系统刷题方法的人。1. 从一道经典题说起字母异位词分组到底在考什么1.1 题目描述与核心定义拆解先明确题目要求。输入是一个字符串数组strs输出要求把“字母异位词”聚合在一起。什么是字母异位词两个字符串包含的字母种类相同、每个字母出现的次数也相同只是排列顺序不同比如 listen 和 silent、abc 和 cba。这里有两点容易被忽略第一单个字符串自身也算一个分组即使没有其他词和它构成异位词也要单独成组返回第二完全相同的字符串当然属于同一组它是异位词的一种平凡情况。从数学视角看异位词关系是一种等价关系满足自反性、对称性、传递性所以题目的本质是把一个集合按等价关系划分成若干等价类。理解了这一层才会意识到“枚举所有两两组合去判断”是低效的正确做法是给每个等价类找一个唯一标识也就是下文要讲的归一化操作。很多新手拿到题目会先想到把每个字符串排序再比较这没什么问题但容易忽略输出顺序和分组内部顺序的要求。LeetCode 对分组顺序、组内顺序并不做硬性规定但写代码时最好保持稳定比如按原数组顺序输出方便本地调试和测试对比。1.2 为什么这道题常被当作面试题这道题被高频使用是因为它在一个简单的包装下覆盖了多个核心考察点。第一是哈希表的灵活运用。题目本身没有要求用哈希表但最优解天然依赖哈希键的设计面试官可以通过“你选了什么样的键”快速判断你的抽象能力。第二是字符串处理基本功包括遍历、排序、字符频率统计、不可变类型转换这些都是工作中最常用的操作。第三是复杂度的分析和权衡排序法和计数法时间开销不同适合的场景也不同能不能讲清楚区别直接体现算法功底。第四是边界条件处理比如空数组、空字符串、单个字符、超长字符串、非小写字母字符集等。实际面试中我见过不少候选人能写出排序法但追问“如果字符串特别长怎么办”“如果字符不只是小写字母呢”时就卡住了。所以这篇文章不只是讲题解更想帮你把背后的方法论梳理清楚做到“会一道题会一类题”。2. 思路演进从暴力配对到哈希归一化2.1 暴力枚举为什么不可行最直接的想法是两两比较对数组中的任意两个字符串判断它们是不是异位词是则合并到同一组。判断两个字符串是否为异位词常见做法是把各自排序后比较是否相等时间复杂度 O(m log m)m 是字符串平均长度。如果有 n 个字符串两两比较的次数是 C(n, 2)整体复杂度 O(n² m log m)。当 n 达到 10⁴ 甚至 10⁵ 时这个开销完全不可接受。还有更暴力的“全排列匹配法”把每个字符串的所有排列都枚举出来再去其他字符串里查找复杂度直接变成阶乘级别只适用于教学演示实际毫无意义。暴力法的根本问题在于没有利用等价关系的内在结构它对每一对元素都独立做判断完全不考虑分组之间的共享信息。我最早刷这道题时也写过两两比较版本本地测试小样本数据一切正常一提交就超时。后来才明白这种题目考察的是“如何用一次处理给每个元素打标签”而不是“反复两两确认关系”。2.2 关键一步把异位词映射成同一把钥匙要高效分组核心思想是归一化为每个字符串计算一个“规范形式”或“签名”使得所有互为异位词的字符串得到完全相同的签名而非异位词得到不同签名。之后只需用哈希表维护签名到列表的映射遍历原数组对每个字符串计算签名再追加到对应分组即可。这里的逻辑可以类比生活中给快递分类你不需要挨个比对外包装是否一样而是看贴在上面的区域标签标签相同的放进同一个筐。字符串就是快递签名就是标签哈希表就是那一排筐。一次遍历做完总时间只取决于计算签名的成本和处理哈希冲突的成本。基于这个思想问题被拆成两个独立子问题如何设计签名以及如何用哈希表高效组织。前者是算法层面的核心后者是数据结构层面的熟路。很多看似复杂的字符串分组、聚类、去重问题本质都能归约到“设计签名”这一步这也是这道题最值得学习的通用能力。2.3 归一化方案的三种候选有了“签名”的思路具体实现就清晰了。针对英文字母字符串主要有三种签名设计方案。方案一排序字符串。把字符串中的字符按字典序重新排列得到的新字符串作为签名。例如 eat、tea、ate 排序后都是 aet天然落到同一组。方案二字符频率计数。统计每个字符出现次数把计数结果作为一个有序序列作为签名比如 eat 的计数为 [1, 0, 0, ..., 1, ..., 1]a、e、t 各出现一次。因为两个字符串是异位词它们的计数序列必然完全一致。方案三素数乘积。为 26 个字母分别赋予一个素数把字母对应的素数相乘得到一个大整数作为签名利用算术基本定理保证唯一性。三种方案都能完成分组但性能特性差别很大。排序法实现最简单工程上可读性好计数法避免了排序的开销在字符串很长时更优素数乘积法最“酷炫”但要小心整数溢出问题实际代码并不难。你不需要全都掌握才能写题但理解它们的优劣才能在面试中做到“主动分析、主动取舍”而不是被动等面试官提示。3. 两种主流通解排序法和计数法其实都在做同一件事3.1 方案A排序字符串作为哈希键排序法是最容易想到、也最容易写对的做法。对每个字符串s执行sorted(s)得到字符列表再拼回字符串作为键。Python 实现如下from collections import defaultdict def group_anagrams(strs): groups defaultdict(list) for s in strs: key .join(sorted(s)) groups[key].append(s) return list(groups.values())Java 版本同样直观public ListListString groupAnagrams(String[] strs) { MapString, ListString map new HashMap(); for (String s : strs) { char[] chars s.toCharArray(); Arrays.sort(chars); String key new String(chars); map.computeIfAbsent(key, k - new ArrayList()).add(s); } return new ArrayList(map.values()); }代码背后的复杂度很好算。假设 n 是字符串个数m 是平均字符串长度。每个字符串排序耗时 O(m log m)总时间 O(n m log m)。空间上哈希表需要存所有字符串额外的键空间也是 O(n m) 量级。m 不大时这种方法基本是首选因为代码简洁、不容易出错。很多初学者会忽略一个细节Python 里sorted(s)返回的是字符列表必须先join成字符串才能作为哈希键否则会抛TypeError: unhashable type: list。这个坑我在下文的常见问题里会专门展开。3.2 方案B字符频率计数作为哈希键当字符串很长、排序代价偏高时可以用计数法替代排序。做法是统计每个字符串中 26 个字母的出现次数将计数数组转成元组作为键。from collections import defaultdict def group_anagrams(strs): groups defaultdict(list) for s in strs: cnt [0] * 26 for ch in s: cnt[ord(ch) - ord(a)] 1 key tuple(cnt) # 转成不可变元组才能作为哈希键 groups[key].append(s) return list(groups.values())这里选择tuple(cnt)而不是直接用列表是因为 Python 的列表是可变的不能作为字典的键。计数数组本身只有 26 个整数作为键时哈希计算开销很小。时间复杂度方面每个字符串只需 O(m) 次字符计数操作再花 O(26) 的时间把列表转成元组并哈希整体是 O(n(m 26))可以近似看作 O(nm)。在 m 很大的情况下比排序法有明显优势。需要强调的是计数法要求字符集有限且已知。题目如果限定只含小写字母26 的长度没问题如果场景扩展到大小写混合数组要扩到 52 或 128如果是任意 Unicode 字符就得用字典Counter来统计键则是不可变的多项表示。3.3 两种方案怎么选复杂度与工程体验对比刷题和实际项目有一点是共通的没有绝对最优解只有最适合场景的解。排序法和计数法的选择本质上是在“实现简单”和“理论更优”之间做权衡。从时间看排序法 O(n m log m)计数法 O(nm)计数法渐进更优。从空间看排序法会额外产生排序后的键字符串计数法会额外产生长度 26 的元组两者都是 O(nm) 量级的辅助空间但常数不同。从编码看排序法两三行就能搞定计数法需要处理字符到索引的映射稍显繁琐。从通用性看排序法天然适用于任意可排序字符集计数法需要字段限制或动态计数结构。如果面试官不追问写排序法完全足够这也是最稳妥的答案。但如果面试官问“能再优化吗”或者“字符串数百万且超长怎么办”你就应该主动切换到计数法并且能讲清楚两者复杂度差异的来源。我自己的做法是先说排序法作为 baseline再补充计数法作为优化最后根据面试官反应决定是否展开素数法。这套组合拳在面试里效果很好因为展示了完整的思考过程。4. 进阶技巧与边界处理从素数乘积到异常输入4.1 素数乘积法用唯一分解定理干这事排序法和计数法是最常见的但还有一种思路常在讨论区出现为 26 个字母各分配一个素数字符串的签名就是字母对应素数的乘积。由于任何正整数都可以唯一分解为素数乘积两个字符串是异位词当且仅当它们的素数乘积相等。举个例子令 a2、b3、c5、d7、e11、f13……那么 abc 的签名是 2×3×530cba 的签名是 5×3×230。非异位词很难碰撞因为质因数分解具有唯一性。这样处理的好处是签名是一个整数比较和哈希都非常快代码也能写得很紧凑。from collections import defaultdict def group_anagrams(strs): 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] groups defaultdict(list) for s in strs: prod 1 for ch in s: prod * primes[ord(ch) - ord(a)] groups[prod].append(s) return list(groups.values())看起来巧妙但隐患也很明显字符串稍长乘积会迅速变成天文数字。比如一个 10 个字符的词乘积就可能达到 10¹⁰ 量级20 个字符时轻松超过 10²⁰。Python 的整数是任意精度的能撑住Java 原生int会溢出long也只能说“大概率不溢出”不能严格保证。面试时我建议把素数法作为“思路亮点”提一嘴展示数学功底但真正常用的还是前两种。4.2 边界情况清单与防御性编码刷题时很多人只写核心逻辑忽略边界提交后才发现挂了。针对这道题下面这些情况我建议在编码前逐一确认。空数组输入应该返回空列表而不是报错。代码中用defaultdict(list)和list(groups.values())天然满足这一点。空字符串空串的排序键也是空串计数值全部为 0所以会单独组成一组[]符合直观预期。单字符字符串[a]返回[[a]]排序和计数都能正确处理。包含重复词[eat, eat]应该分成一组包含两个 eat而不是去重。大小写混合如果测试数据包含大写字母排序法仍然正确计数法固定 26 长度就会越界必须先统一大小写或扩数组。非字母字符比如数字、空格、符号排序法不受影响计数法则需要调整映射规则。超长字符串例如长度 10⁴ 的字符串排序法会非常慢此时应考虑计数法。防御性编码的关键是不假设输入干净。虽然题目默认只含小写字母但我在本地测试时总会顺手加上混合输入的用例确保后续把代码迁移到真实项目时不会翻车。真实工程里的数据远没有刷题数据那么规矩多一分防御少十分麻烦。5. 面试追问与真实工程场景5.1 面试官常问的 5 个延伸问题写完题解只是第一步面试官通常还会抛出几个追问。我总结了出现频率最高的几个附上我认为比较合理的应对思路。追问一如果输入规模非常大内存放不下所有分组怎么办解决方向是外部排序加哈希分片先对字符串做哈希按哈希值分到不同文件各自内部再分组或者把中间键写成“排序后的字符串”在分布式引擎里按键聚合。核心是让同组数据落到同一个分片。追问二如果字符集不只是 26 个小写字母代码怎么改排序法只需要保证字符可排序通常不用改计数法则要把定长数组换成collections.Counter并把它的元素转成不可变类型作为键否则不可哈希。追问三如果要求每个分组内部按字典序输出怎么办答案是最后对每个分组单独排序即可。这样整体保持分组逻辑不变只在返回前增加一次排序时间复杂度从 O(nm) 变为 O(nm k log k)k 是分组内字符串数量。追问四除了哈希表还能用什么数据结构理论上可以使用字典树前缀树把所有字符串排序后插入前缀树相同路径的叶子聚合为同一组也可以使用并查集预处理两两关系后合并等价类。两者都能解但代码量和时间成本远高于哈希法面试时作为对比提一下即可不建议真写。追问五为什么排序串一定能保证异位词映射到同一个键因为排序操作本身是确定的异位词只是同一多重集的不同排列排序结果必然是同一个字符串。这是一个简单的数学事实但很多候选人只背代码讲不出依据面试官会怀疑你是模板化刷题。5.2 字母异位词分组的工程落地场景如果认为这只是面试题那就低估了“签名归一化”思想的价值。我在实际工作中至少见过三类应用。第一类是搜索引擎和输入法的拼写纠错。用户输入 teh 时系统会聚合一批与它字母组成相近的候选词其中就包括 the、eth 等文本库预先按字母排序后的签名建索引纠错时快速召回候选组。第二类是日志和文本数据的相似聚合。比如统计一批订单备注里的重复模式不同顺序的同样短语可以通过签名聚合成同一类方便做频次分析。第三类是生物信息学里的序列分析。DNA 序列的 k-mer 特征提取经常用到类似的分组思路把长度固定的一段序列按字符组成归并对比不同样本间的组成模式。数据量通常非常庞大计数法的思想统计字符频率比排序法更常用因为可以流水线化处理。第四类是测试数据生成。我写模糊测试时经常随机打乱一批合法字符串的顺序作为输入再通过异位词分组逻辑来校验解析器是否对不同排列一视同仁。这种场景下排序法和计数法的选择会直接影响测试脚本的执行效率所以我会保留两个版本。不要觉得这些场景离刷题很远。算法题的价值恰恰在于抽象出通用模式面试时你如果能主动举出一两个工程场景会比单纯背题给面试官留下更深的印象。6. 常见问题排查与实操心得6.1 高频踩坑点这道题代码量不大但细节坑不少。我在带团队时经常看到下面几类问题整理成速查表给大家参考。Python 里用列表作为字典键导致unhashable type: list。这是初学者最容易犯的错。原因是列表可变哈希值会变化不能当键。解法是.join(sorted(s))转成字符串或tuple(cnt)转成元组。解决方法是先问清楚字符范围。题目如果只含小写字母固定 26 没问题否则要么扩数组要么用Counter转元组。很多解法用Counter作为键的一部分但Counter本身可变不可哈希必须先转成tuple(sorted(counter.items()))或类似形式。字符串太长导致排序法超时。本地测试小数据没问题提交就 TLE。原因是 O(n m log m) 在极端数据下退化严重。解决思路是切换到计数法严格按题目数据规模预估复杂度。除了代码层面的坑还有一个策略层面的问题很多人写完一版通过后就完事了不再思考优化空间。我建议每次刷完题都问自己三个问题这个解法的瓶颈在哪里如果数据量扩大一百倍还能跑吗有没有另一个数据结构能达到不同复杂度长期坚持状态会提升很快。6.2 调试思路与自测用例算法题调试不像是项目排障没有日志系统可用需要自己搭建最小验证环境。我的常用做法是构造几组固定用例逐个跑验证输出。第一组基础用例输入[eat, tea, tan, ate, nat, bat]期望输出三组顺序可以不同但内容必须对应。我建议先用这个用例打通主流程。第二组边界用例空数组[]和空字符串[]检查会不会报错或漏组。第三组单元素用例[a]、[]、[abc]确保单个词能独立成组。第四组重复用例[abc, abc, cba]期望只有一组包含三个字符串。第五组混合边界[, a, ab, ba, abc, acb, bca]重点观察空串和长度不一的字符串是否正确归组。如果算法实现较长我还会写一个简单的暴力版做对拍对每一对字符串排序后比较再用并查集合并等价类。随机生成一批字符串同时喂给暴力和优化版对比分组结果是否完全一致。这个方法能发现很多边界条件下才暴露的 bug比单测更有说服力。6.3 一点个人体会我最初接触这道题时也觉得排序法就够了没必要想太多。后来在真实项目和面试中反复遇到类似的“归一化分组”问题才发现这种思路的迁移价值远超题目本身。无论是处理用户输入的纠错候选还是清洗海量日志中的重复模式本质都是为数据找到一把稳定、高效、可比较的钥匙。所以我给你的建议是不要只背排序法的模板而是把计数法和素数法也写一遍并且能讲清楚各自的时间空间复杂度来源。遇到面试追问时先从排序法讲起再自然过渡到计数法最后用素数法补充一个亮点整个回答会显得非常完整。这一套组合拳足以让你在多数候选人中脱颖而出。