力扣49题“字母异位词分组”,在很多刷题清单里都被标记为“简单题”或者“入门题”,但我要说句实话:这道题在面试中能一次写对、并且能把思路讲清楚的人,真的不多。它表面上是在考哈希表,实际上是在考两件事:你能不能把“字母异位词”这个抽象概念转成一个可比较的键,以及你能不能把一个思路讲出复杂度上的道理。
我当年第一次做这道题的时候,脑子里蹦出来的第一个想法是“两两比较”,当场就被现实教育了。后来认真把排序法和计数法都走了一遍,才意识到这道题值得写一篇笔记,把细节掰开揉碎讲清楚。这篇文章没有花架子,就是基于我自己的实操经验,把两种主流解法、代码里的坑、面试时可能被追问的点都整理出来,希望能帮到正在刷题的你。
1. 题目解读:到底在考什么
1.1 为什么说这是一道“数据建模”题
先看题目本身:给你一个字符串数组,把字母异位词组合在一起。字母异位词的定义是“由相同字母重新排列构成的单词”,比如 eat、tea、ate,字母都一样只是顺序不同,它们就是一组。
这个定义里藏着一个关键点:判断两个词是不是异位词,本质上是在问“它们的字母构成是否完全一致”。但在计算机里,我们没法直接“看出”字母构成一致,必须把这种一致变成一个能直接比较的东西。这就是数据建模——把一个语义上的判断,转成数据结构上的比较。
一个很生活化的类比:你去快递站取件,快递架上摆了一堆包裹,每个包裹上贴着不同颜色的标签球。异位词就是颜色相同的球,你要做的是把同色的球归类到一起。但问题是,计算机拿到的是一串乱序字母,它不知道哪两个词“颜色相同”。所以你的任务就是设计一个“给包裹贴标签”的方式,让同类的词贴上同样的标签,然后按标签分组就行。
这个“贴标签”的过程,就是整道题的核心。用什么当标签、怎么算标签,决定了你的解法和复杂度。
1.2 输入输出与边界条件
先把题目的输入输出看清楚:
- 输入:
strs = ["eat", "tea", "tan", "ate", "nat", "bat"] - 输出:
[["bat"], ["nat","tan"], ["ate","eat","tea"]]
注意几点:
第一,输出的分组顺序没有要求,组内顺序也没有要求。这一点在面试里经常有人纠结,其实题目已经说了“可以以任意顺序返回”,不用为了保持某种排序额外花力气。
第二,输入可能包含空字符串。空字符串也是合法的输入,它自己单独成组。很多人写代码时不考虑这个,容易在排序或者计数的时候报错。
第三,字符串只包含小写字母。这一点特别重要,因为它直接决定了计数法可以用固定长度为26的数组来实现。如果题目改成包含大写字母或者数字,计数逻辑就要改,但排序法不用变。
第四,数据量范围:字符数组的长度和每个字符串的长度都有范围限制,在力扣原始题里是0 <= strs.length <= 10^4,每个字符串长度0 <= s.length <= 100。这个范围很关键,后面讲复杂度的时候要拿它来验证方案的可行性。
2. 解题思路:排序法和计数法的双主线
2.1 排序法:让所有异位词回归同一形态
排序法的思路非常直接:对每个字符串按字母排序,排序后得到的字符串就是它的“标准形态”。两个词如果是异位词,它们排序后的结果必然完全相同。
比如 eat 排序后是 aet,tea 排序后也是 aet,ate 排序后还是 aet,三个词就会归到同一个组里。
用哈希表来操作:
- 遍历每个字符串。
- 对当前字符串按字符排序,得到排序后的 s_sorted。
- 以 s_sorted 作为字典的 key,把原始字符串追加到对应的 value 列表里。
- 最后把字典里所有的 value 取出来返回。
代码不长,核心逻辑就几行,但这里有一个关键细节:排序后的字符串必须作为 key,原始字符串作为 value 存进列表。如果你把排序后的字符串存进列表,那分组的信息就丢了。
从建模的角度看,排序法做的事情是“归一化”:把同一个异位词组里的所有单词,映射到同一个标准字符串上。这个思路非常自然,也很好讲清楚,是面试中最容易写对、最容易解释的方案。
2.2 计数法:用字母频率指纹识别异位词
排序法的缺点是每次排序有额外的开销,虽然这个开销在数据量不大时可接受,但还有一种更“精确建模”的思路:用每个字母出现的次数当作指纹。
具体做法是:对每个字符串,统计26个小写字母各出现多少次,把这个计数序列转成一个固定格式的元组,作为字典的 key。
比如 eat 的计数是:a出现1次、e出现1次、t出现1次,其他23个字母都是0次。tea 和 ate 的计数序列完全一样,三个词就会分到同一组。
这里要注意:计数结果转成 key 的时候,千万不能直接用列表。Python 里 list 是可变对象,不能作为字典的 key,你直接拿 list 当 key 会立刻报TypeError: unhashable type: 'list'。正确做法是转成 tuple,tuple 是不可变对象,可以作为 key。
从本质上看,计数法和排序法是同一个思想的两种表达:都是把一个词映射成一个“标准形式”。排序法用字符串本身当标准形式,计数法用字母频率序列当标准形式。区别在于映射的代价:排序需要 O(k log k),计数只需要 O(k),其中 k 是字符串长度。
2.3 两种方案的适用场景
这两条逻辑线没有绝对的对错,选哪个取决于你对效率和代码简洁度的权衡。
如果面试官没有特别要求,排序法是最稳妥的,代码短、思路清晰、几乎不会写错。计数法在理论上更优,但实现代码会长一些,还有一个额外的“转元组”的操作,需要在讲解的时候把为什么用 tuple 而不是 list 说清楚。
我个人的经验是:面试中先给排序法,然后再主动提一句“如果字符串长度很大,可以换成计数法把复杂度降到 O(k)”,这会让面试官觉得你不只是背了题,而是真的理解两种建模方式的取舍。但注意,你需要在讲的时候对两种方案都足够熟悉,有的面试官会当场让你写第二种,别到时候卡住。
3. Python实现:代码逐行解析与优化
3.1 排序法的实现与关键决策点
先上排序法的完整代码:
from typing import List from collections import defaultdict class Solution: def groupAnagrams(self, strs: List[str]) -> List[List[str]]: mp = defaultdict(list) for s in strs: sorted_s = ''.join(sorted(s)) mp[sorted_s].append(s) return list(mp.values())这段代码里有几个决策点值得仔细说。
第一个决策点:用defaultdict(list)还是普通的dict?普通 dict 你得先判断 key 是否存在,不存在就初始化成空列表,存在就直接 append,代码会多出几行判断逻辑:
if sorted_s not in mp: mp[sorted_s] = [] mp[sorted_s].append(s)defaultdict(list)帮你省略了那个 if 判断。它的原理是:访问不存在的 key 时,自动调用list()创建一个空列表作为默认值。这个写法在刷题时很常见,但是要注意,defaultdict只有在访问不存在的 key 时才会自动创建默认值,如果你用mp.get(sorted_s)去取,它不会触发这个机制,返回的是 None。
第二个决策点:''.join(sorted(s))这行干了什么?sorted(s)返回的是排序后的字符列表,比如sorted("eat")返回['a', 'e', 't'],用''.join()把字符列表拼回字符串,得到"aet"。如果你不 join,直接拿列表当 key,下一行就报 TypeError。这个顺序少一步都会出问题。
第三个决策点:返回值是list(mp.values())。注意mp.values()是一个视图对象,不是真正的列表,所以外面要包一层list()。有的题解里写return mp.values()也能通过,那是因为力扣的测试用例会自动转类型,但严谨的写法还是加个list(),避免后续操作用不了列表的方法。
3.2 计数法的实现与细节处理
再上计数法的完整代码:
from typing import List from collections import defaultdict class Solution: def groupAnagrams(self, strs: List[str]) -> List[List[str]]: mp = defaultdict(list) for s in strs: count = [0] * 26 for ch in s: count[ord(ch) - ord('a')] += 1 mp[tuple(count)].append(s) return list(mp.values())这段代码的核心是那行count[ord(ch) - ord('a')] += 1。ord()函数返回字符的 Unicode 码点,小写字母 a 到 z 的码点是连续递增的,所以ord(ch) - ord('a')就能把字母 a-z 映射成数字 0-25,正好对应count数组的索引。
比如处理"eat"的时候:
ord('e') - ord('a')等于 4,count[4]加 1ord('a') - ord('a')等于 0,count[0]加 1ord('t') - ord('a')等于 19,count[19]加 1
最终count数组是[1, 0, 0, 0, 1, 0, ..., 1](下标0、4、19的位置是1),这个数组就是 "eat" 的频率指纹。tea 和 ate 的count数组也完全一样,因为它们的字母构成相同。
然后一行mp[tuple(count)].append(s),把count转成 tuple 作为 key。为什么转 tuple 我在前面说过:list 不可哈希。tuple 是可哈希的,可以安全地作为 dict key。
如果你觉得[0] * 26这个写法不够直观,也可以写成:
count = [0 for _ in range(26)]效果一样,但[0] * 26更简洁。这里有一个容易忽略的点:[0] * 26创建的是每个元素都独立为 0 的列表,不存在引用共享的问题,因为整数是不可变对象。如果你用[[]] * 26就会出大问题,因为这样创建的是 26 个指向同一个列表的引用。刷题列表千万不要用这种乘法构造,必须用推导式。很多初学者在这里踩过坑。
3.3 两种方案的选型建议
从代码长度看,排序法明显更短,因为sorted(s)一行就完成了“归一化”。计数法需要先建数组、再统计、再转元组,三步才能完成同样的工作。
从性能看,计数法更优,因为排序法的sorted(s)是 O(k log k),而计数法统计一遍只要 O(k)。这两个 k 都是单字符串的长度。在最坏情况下,如果所有字符串都很长,计数法的优势会体现出来。
我在实际刷题中会这样选:如果只是做每日一题,排序法写完收工,省心;如果是在做系统设计或者准备面试的复杂追问,我会把两种方案都准备到位,并且能说出选型依据。
还有一个细节:如果输入的字符串长度都很短(比如长度小于10),排序法和计数法在真实运行时间上几乎没有差别,因为 Python 内置的排序算法在短字符串上极度优化,常数因子极小。这种情况下纠结复杂度是多余的,不如把代码写清楚、把思路讲明白更重要。
4. 复杂度分析:面试追问怎么回答
4.1 时间与空间复杂度对比
面试官最喜欢追问的问题是“你这个方案的时间复杂度和空间复杂度是多少”,别慌,这道题的复杂度理清楚很简单。
先看排序法。假设有 n 个字符串,每个字符串平均长度为 k,那么:
- 遍历 n 个字符串,每个字符串做一次排序,排序本身是 O(k log k)。
- 所以总时间复杂度是 O(n * k log k)。
- 空间复杂度方面,哈希表里要存 n 个原始字符串的引用或拷贝,每个字符串长度 k,最坏情况下是 O(nk)。
再看计数法:
- 遍历 n 个字符串,每个字符串做一次字符遍历统计,遍历是 O(k)。
- 总时间复杂度是 O(nk)。
- 空间复杂度和排序法相同,也是 O(nk),因为哈希表里至少要把这些字符串分组存下来。
从大 O 看,计数法的 O(nk) 确实比排序法的 O(n * k log k) 好了一截。但结合题目给的数据范围(n ≤ 10^4,k ≤ 100),实际差距并没有那么大。log(100) 约等于 6.6,也就是每个词最多多一个不到 7 的常数因子,真实跑起来差别不太明显。
4.2 数据规模对方案选择的影响
如果字符串长度 k 很小,比如单词平均长度只有 5,排序法的常数因子几乎可以忽略,代码还更简单,这时候用排序法完全合理。
如果 k 很大,比如处理的是超长字符串,或者输入长度达到 10^5 甚至更大,计数法的优势就很明显了,尤其是当你有大量相同长度的字符串时,计数法能稳定地把每个词的处理时间控制在 O(k)。
还有一个角度值得提:如果输入的字符串不是小写字母,而是包含更多字符集的情况(比如 Unicode 字符、数字、大写字母),计数法的数组长度就不能固定为 26 了。你可以改用字典来统计每个字符的出现次数:
count = {} for ch in s: count[ch] = count.get(ch, 0) + 1然后把排序后的字典项转成 tuple 当 key。这种方法比固定数组更通用,但也会因为字典本身的哈希开销,在实际性能上不一定优于排序法。所以泛化字符集的情况下,排序法往往更稳健。
我在面试中回答复杂度问题时,喜欢先说结论,再补一句“这个复杂度分析基于所有字符串平均长度为 k 的假设”,然后看面试官的反应。大多数面试官会点头让你继续讲,专业一点的可能会追问“如果字符串长度差异很大,复杂度表达式会不会变化”,这时候你就要指出,更精确的表达是把所有字符串长度记为 k1, k2, ..., kn,总复杂度是 O(Σ ki log ki) 或 O(Σ ki),而不是笼统地写 O(nk)。
5. 常见错误与排查实录
5.1 字典 key 的选型陷阱
我在帮别人 review 代码时,最常见的错误就是把 list 直接用成 key,代码长这样:
mp = {} for s in strs: key = sorted(s) # 返回 list mp[key].append(s) # TypeError!运行后立刻报TypeError: unhashable type: 'list'。原因之前说过,list 是可变的,Python 为了保证字典查找的可靠性,禁止用可变对象当 key。修复方法就是加一行''.join()把 list 拼回字符串,或者用 tuple。
这其实很好记:任何要做 key 的数据,必须是不可变对象。tuple、str、int、frozenset 都可以,list、dict、set 都不行。以后遇到类似的题目,第一反应先想想“我这行代码生成的 key 到底是什么类型,可不可哈希”。
5.2 边界条件与特殊输入
空数组输入strs = []时,循环体一次都不执行,返回[],这个逻辑没问题。但要注意,力扣的输入可能包含strs = [""],也就是一个空字符串。
排序法处理空字符串时,sorted("")返回空列表,''.join([])得到空字符串"",所以空字符串会以空字符串作为 key,归到一组。如果输入里有多个空字符串,它们会正确分到同一组,没问题。
计数法处理空字符串时,count数组全为 0,转换为全为 0 的 26 元 tuple,所以空字符串也会正确分组。这个边界一般不会出错,但你要知道它不会报错,避免在面试时被问到慌了神。
还有一个边界是单个字符的字符串,比如["a", "b", "a"]。排序法里sorted("a")得到["a"],拼回"a";计数法里count[0] += 1,得到(1,0,0,...)。两个"a"都会分到同一组,"b"单独一组,符合预期。
5.3 性能优化与内存考量
刷题时很多人会忽略内存问题,但这是真实工程里需要关注的。排序法在每个字符串排序时,Python 会为每个字符串创建临时的字符列表,再创建一个新字符串。如果字符串很长,这个过程会反复创建和销毁对象,垃圾回收压力大。
一个常见的优化是:如果输入本身已经是“一个字符串经常重复出现”的场景,可以先做一个简单的字符串去重,不过这道题不允许提前修改返回值,所以这个优化只能作为思路提一下。
另一个常见的坑是:defaultdict(list)创建默认列表时,列表是可变的,后续在 mp[key] 上执行 append 会修改同一个列表对象,这本身没问题。但如果你不小心把同一个列表对象赋值给多个 key,就会出现“分组污染”,比如代码误写为:
mp = defaultdict(list) base_list = [] for s in strs: mp['key'].append(s) # 所有字符串都进同一个组这种低级错误不常见,但真有人犯。写代码时遇到 dict 的 value 是可变对象,要格外小心对象共享的问题。可以在每次判断 key 的时候打印一下id(mp[key]),如果发现多个 key 对应同一个 id,就是对象被共享了。
另外,我在实际测试中发现,如果字符串里有很多重复的排序结果(比如大量词都是相同字母组成的),哈希表会快速膨胀。Python 字典会自动扩容,扩容时会重新哈希,这个过程会消耗额外时间。如果预先知道数据量很大,可以用普通 dict 提前reserve容量,但 Python 的 dict 没有公开的 reserve 接口,所以这个优化通常是不可用的。遇到超大测试集,建议优先用计数法,它的 key 是 tuple,在哈希和比较上的开销通常比长字符串更低。
5.4 一个真实的排查案例
有一次我帮 A 同学调试代码,他写的代码逻辑上看起来没问题,但提交后总是“超出内存限制”。我扫了一眼,发现他在循环里为每个字符串创建了一个新的defaultdict,然后把每个字符的频次都存进去,最后再把整个字典转成 key。这就是典型的过度设计:题目只需要一个长度为 26 的计数数组,他却用了字典,而且每个字符串都新建一个默认字典,内存和时间的开销都翻了很多倍。
我之前反复强调“计数法用固定数组”,原因就在这里:当字符集是小写字母时,固定数组是最轻量、最省内存的频次记录方式。字典虽然直观,但有额外的哈希开销,在数据量大时会被放大。所以写完代码最好过一遍:我是否用到了比必要工具更重的数据结构?如果只是为了统计26个字母的频次,一个列表就够,不要用字典。
6. 知识点迁移与相似题型
6.1 这道题背后的核心考点
把这道题做透,你其实已经掌握了两组核心技能。
第一组:哈希表的使用场景。哈希表适合做“分组映射”类题目,把符合某种特征的元素映射到同一个 key 上。字母异位词是一种特征,类似的还有“同构字符串”“相同元素分组”等。
第二组:归一化建模。所谓归一化,就是给每个元素找一个标准形式,让同一类元素的比较变成相等比较。你要学会在不同的题目里找到“什么才是合适的归一化方式”。
我自己刷题时总结过一个判断方法:当你遇到一道题,题干里有“根据某种特征分组”或者“判断两个元素是否属于同一类”的描述时,大概率要往哈希表 + 归一化的方向想。特征可以是一个排序后的字符串、一组频次统计、一个状态压缩后的整数,甚至是一个字符串的哈希值——关键是你得找到一个能被计算机直接比较的唯一度量。
6.2 一道题延伸出的一类题
字母异位词分组不是孤立的题目,它和几个经典问题同源。
最直接的是“有效的字母异位词”,给定两个字符串,判断它们是否是字母异位词。这道题其实就是本题的单组版本,你只需要比较两个字符串的计数数组是否相同,复杂度 O(n)。做完了分组题,再看这道题会觉得非常简单。
还有一个稍难一点的变体,涉及把二维矩阵翻转后比较行或列,这种题的本质也是“归一化”。你把矩阵的某一行转成一个可比较的字符串,再去哈希表里查重,思路和字母异位词分组一模一样,只是特征的定义方式变了。
更高级一点的应用,是“文件重复内容分组”类的系统设计题:给一组文件的路径和内容,把内容相同的文件归到同一组。这时候你把文件内容哈希一下作为 key,就是字母异位词分组思路的工程化版本。当然,工程上还要考虑哈希碰撞、大文件分块哈希等细节,但骨架思路是一脉相承的。
6.3 从 LeetCode 到实际工程场景
有人会问:“刷这道题到底有什么用?现实中谁会分字母异位词?”确实,真实业务里不太会遇到全小写字母的异位词分组,但这个思路的直接应用其实是“内容归一化”。
举一个真实业务场景:你在做用户输入的关键词聚合时,用户提交的关键词可能带有不同的空格、大小写、甚至同一个词的不同词形。如果你需要把这些“看起来不同但实际同义”的输入聚合成一组,就需要先做归一化,再按归一化结果分组。比如把所有关键词转成小写、移除空格、按单词排序,再作为 key,就能把同义词归到一组。这个操作的本质,和字母异位词分组完全一样。
另一个场景是“图像去重”或“文档去重”。你先把图片或文本转成一个固定长度的感知哈希或特征向量,然后按特征向量聚类。虽然特征向量的维度和表示方式比 26 个字母复杂得多,但思路还是“归一化 + 哈希分组”。
所以不要小看这道简单题,它教给你的建模能力,能直接迁移到工程里的很多场景。面试官喜欢考这道题,一方面是因为它简单,另一方面是因为如果你能主动讲出这些应用场景,说明你不是机械刷题的人,而是真的理解了背后的方法论。
7. 实测结果与耗时对比
说了这么多理论,补充一下我自己的实际测试数据,供你参考。
我在本地分别用排序法和计数法跑了一组模拟数据:10000 个字符串,每个字符串长度为 100,均匀生成随机小写字母串。两种方法的耗时差异在数据量较小时非常微小。我记录了几个关键数字,但不建议大家把它当作绝对性能标准,因为不同机器差异很大。重点是观察相对趋势。
当字符串长度从 100 提高到 500 时,排序法的耗时增长明显更快,这与时间复杂度的预判一致。计数法几乎不受字符串长度变化影响多少,因为它只需要线性扫描一次就能完成统计。
这个实验结果印证了书上的复杂度分析:在短字符串场景下,两种方法基本打平;在长字符串场景下,计数法明显占优。所以如果你的测试数据里有大量长字符串,计数法会更稳。
另外,Python 内置的排序用的是 TimSort 算法,它不是纯比较排序,对“部分有序”的数据有一定优化,但它仍需要对每个字符串整体排序。所有从绝对性能看,排序法在长字符串场景下依然不如线性扫描的计数法。
还有一点是代码可读性。在团队 code review 场景,排序法的可读性通常比计数法好,因为“排序后相同”这个建模逻辑更直观。计数法虽然性能好,但 tuple 转 key 的写法需要一定的解释成本。这也是我刚才说“面试先答排序法”的原因之一:先让听众理解语义,再讲性能优化,这是符合认知规律的表达顺序。
8. 常见问题速查表
下面这组问题我见过很多次,有些人当场就卡住了。整理成速查表,方便你在面试前快速复习。
如果面试官问“为什么不用 list 当 key”,你的回答是:list 是可变对象,字典要求 key 必须可哈希且不可变,用 list 会抛 TypeError。转成 tuple 是因为 tuple 是不可变的,可以安全作为字典 key。
如果面试官问“排序法的复杂度是多少”,你要说清楚:排序是 O(k log k) 对每个字符串,总共有 n 个字符串,所以是 O(n * k log k),空间 O(nk)。接着可以主动补一句“如果字符串长度 k 很小,这个方案很高效;如果 k 很大,计数法更好”。
如果面试官问“输入能不能包含大写字母”,你要回答:题目限定小写字母,所以计数数组长度 26 是合理的。如果以后扩展字符集,就需要调整统计方式,比如用字典计数。
如果面试官问“结果返回顺序有没有要求”,你要回答:题目允许任意顺序,所以不需要排序返回值。
如果面试官问“空数组和空字符串怎么处理”,你要回答:空数组返回空列表;空字符串会以空字符串为 key 单独成组,计数法里对应全 0 的 tuple,所以多个空字符串会正确归为一组。
这些问题的答案,并不只是背下来就够,关键在于你理解了里面的原理,能用自己的话讲一遍。实战中,能把思路讲得清清楚楚的候选人,往往比闷头写出代码的候选人更能打动面试官。
我个人在实际操作中的体会是:这道题最好的练习方式不是背题解,而是“在半睡半醒的状态下也能写出无 bug 的代码”。我复盘了自己的刷题记录,发现大量手滑型错误都发生在凌晨刷题的时候,比如 sorted 对象忘赋值、append 错写成 extend、defaultdict 用成普通 dict 等。所以如果你想真正掌握这道题,不要只求看过题解,可以找个在线环境反复手敲两三遍,直到闭着眼都能写对为止。
最后再分享一个小技巧:这道题如果你在面试中答得很好,可以顺势提一下“还有另一种用计数数组的实现,字符串很长时更高效”,一般面试官都会顺着让你再讲讲。这等于给了你一次额外展示的机会。把自己准备好的第二种方案在合适的时候抛出来,比被动等提问要自然得多。