力扣第49题,字母异位词分组,在力扣热题100里属于“祖师爷”级别的存在。很多刚开始刷题的朋友,第一次接触哈希表分组类题目,刷到的就是它;面试里它出现的频率也相当高,能在三分钟内写出正确解法并把复杂度讲清楚,基本就能给面试官留下“算法基本功扎实”的第一印象。这题看着不难,但真正吃透它,牵扯到的细节其实不少:排序与字符串拼接的用法、哈希键怎么设计、为什么Python里要用元组当字典键而不是列表、两种主流解法的复杂度差异、以及遇到非小写字母输入时怎么扩展。这篇文章我会从题目拆解开始,把三种常用解法、本地调试方式、高频报错和面试变体一起讲完,适合刚入门Python并正在刷力扣HOT100的读者,也适合想在面试前把哈希表分组题彻底搞明白的人。
1. 先搞清楚题目到底在考什么
1.1 题目描述与样例
题面很精简:给你一个字符串数组,把“字母异位词”分到同一组。字母异位词指的是由相同字母重新排列得到的单词,比如eat、tea、ate,它们字母组成完全一样,只是顺序不同,所以必须分到同一组。
输入:strs = ["eat", "tea", "tan", "ate", "nat", "bat"] 输出:[["bat"], ["nat", "tan"], ["ate", "eat", "tea"]]这个输出顺序是无所谓的,力扣判题时只比较内容,不比较子数组的先后顺序。也就是说,你返回的子列表内部顺序、子列表之间的排列顺序,只要分组逻辑正确,都能通过。
1.2 考点拆解:它考的不只是“分组”
表面上看,这题是“字符串分组”,题目难度也标的是中等,但很多人第一反应是去写双层循环:拿每个字符串和已有的组逐个比较,判断是不是异位词。这个思路在字符串数量很少时没问题,一旦数据量变大,时间复杂度就失控了。
我理解这道题真正的考点只有一个:如何为一个字符串设计一个稳定的哈希键。所谓稳定的键,就是“只要是字母异位词,算出来的键一定相同;只要不是字母异位词,算出来的键一定不同”。想到这一步,分组就是一次遍历把字符串塞进字典,复杂度直线下降。
围绕这个核心,题目实际考察了三个层次的能力:
- 第一层:知道哈希表可以用来做分组统计,能用字典
key -> list的结构收集结果; - 第二层:能找到“字母异位词之间存在的不变量”,也就是排序后的字符串、字符频次统计、质数乘积等;
- 第三层:能结合Python语言特性写出不出错的高效代码,比如
tuple可哈希而list不可哈希,defaultdict和setdefault的取舍等。
正因为这题把“找不变量 + 哈希表应用”结合得非常典型,它才能常年稳坐热题100的位置,也成为很多同类题的母题。
2. 解法一:排序法,把混乱字符串翻译成统一标识
2.1 核心思路:异位词排序后必然相等
排序法的思路非常直白:既然互为字母异位词的字符串里面包含的字符完全相同,那把它们的字符按顺序排好,得到的字符串一定一样。eat排序后是aet,tea排序后也是aet,ate排序后还是aet。这三个单词在排序字符串这个维度上收敛到了同一个值。
于是整个算法的流程就是:遍历每个字符串 -> 对字符串排序得到 key -> 把原字符串 append 到 dict[key] 对应的列表里。第一次遇到某个 key 时,用defaultdict(list)自动初始化一个空列表,后面直接追加就行。
这其实有点像生活中整理混在一起的卡片:每张卡片上写着一个乱序单词,你先把每张卡片里的字母按字母表顺序重新排好,再把排好顺序相同的卡片归到同一个盒子里,最后盒子里的原始卡片就是一组异位词。
2.2 代码实现与Python语法细节
from typing import List from collections import defaultdict class Solution: def groupAnagrams(self, strs: List[str]) -> List[List[str]]: table = defaultdict(list) for s in strs: key = "".join(sorted(s)) table[key].append(s) return list(table.values())这段代码有四个细节值得单独说:
sorted(s):对字符串排序,返回的是字符列表。比如sorted("eat")返回['a', 'e', 't']。这个函数不会修改原字符串,而是生成新的列表,所以不担心影响原数据。"".join(sorted(s)):把字符列表拼接成字符串。如果漏掉join,直接把sorted(s)这个列表当 key,代码会立刻报错,因为列表是不可哈希的,不能作为字典键。defaultdict(list):访问不存在的键时,会自动调用list()创建一个空列表作为默认值。这比每次用if key not in table判断要简洁得多。list(table.values()):字典的值是若干个列表,直接转成外层列表返回,正好符合题目要求的二维列表格式。
2.3 复杂度分析与适用场景
时间复杂度是 O(n * k * log k),其中 n 是字符串数量,k 是字符串的最大长度。每个字符串都要做一次排序,排序算 O(k log k),所以总耗时是这个量级。空间复杂度是 O(n * k),因为要把所有字符串都放进字典的值里,这个开销是无法避免的,毕竟结果本身就要占这么多空间。
排序法的优点是代码短、思路清晰,面试时作为第一个答案非常合适。它唯一的短板就是排序本身带了log k的常数,当字符串特别长时,排序开销会明显变大。面试官这时候往往会追问一句:能不能更快?这就自然过渡到了计数法。
3. 解法二:计数法,把字符串压缩成频次向量
3.1 核心思路:不排序,直接统计每个字符出现的次数
互为异位词的两个字符串,不仅“排序后相等”,还有一个更本质的性质:每个字母出现的次数完全相等。eat和tea都包含一个e、一个a、一个t。所以,与其排序,不如直接统计每个单词里26个小写字母各出现多少次,把统计结果作为哈希键。
对于只包含小写字母的输入,可以用一个长度为26的数组[0] * 26来计数。数组第0位表示字母a的出现次数,第1位表示字母b的出现次数,以此类推。遍历字符串,每读到一个字符,就把对应位置加1。最后把这个数组转成元组tuple(count),作为字典的键。
这里为什么必须转成元组?因为数组是 list,而 list 不可哈希。在Python里,只有不可变类型才能作为字典的键,比如字符串、数字、元组,而 list 是可变类型,无法计算稳定的哈希值,所以直接拿 list 当 key 会抛出TypeError: unhashable type: 'list'。
3.2 代码实现与逐步讲解
from typing import List from collections import defaultdict class Solution: def groupAnagrams(self, strs: List[str]) -> List[List[str]]: table = defaultdict(list) for s in strs: count = [0] * 26 for ch in s: count[ord(ch) - ord('a')] += 1 key = tuple(count) table[key].append(s) return list(table.values())关键步骤拆开看:
[0] * 26:生成长度26的全零列表,代表a到z的计数。ord(ch) - ord('a'):把字母转换成0到25的下标。比如ord('a') - ord('a') = 0,ord('b') - ord('a') = 1。这是字符转下标的标准写法,比手写if ch == 'a': ... elif ch == 'b': ...要优雅得多。tuple(count):把 list 转成 tuple,比如(1, 0, 0, ..., 1),这样它就成了可哈希的不可变对象,可以安全地作为字典键。table[key].append(s):同一个频次向量的字符串,一定互为异位词,直接归到同一组。
时间复杂度降到了 O(n * k),因为每个字符串只需要完整遍历一遍,遍历过程中做的都是常数时间的操作。当字符串长度很大时,计数法明显优于排序法,这也是面试官期望你答出来的优化方向。
3.3 排序法与计数法的直观对比
| 维度 | 排序法 | 计数法 |
|---|---|---|
| 设计思路 | 排序后字符串作为键 | 字符频次向量作为键 |
| 时间复杂度 | O(n * k * log k) | O(n * k) |
| 空间复杂度 | O(n * k) | O(n * k) |
| 代码长度 | 更短,容易写 | 稍长,多一段计数逻辑 |
| 适用场景 | 字符串较短、追求代码简洁 | 字符串较长、需要极限性能 |
| 扩展难度 | 大小写混合时排序规则复杂 | 扩展计数字符集相对自然 |
我自己的习惯是:面试中先快速写出排序法,表示我能正确解决;然后主动补充,如果字符串长度很大或者数据规模很夸张,可以进一步优化成计数法,边说边写。这样既展示了代码能力,也展示了复杂度优化的意识,比闷头直接写一种解法要好得多。
4. 进阶优化与本地调试的正确姿势
4.1 字符集不固定时怎么扩展计数法
力扣这题默认输入只包含小写字母,所以长度26的数组刚刚好。但真实面试中经常出现变体:“如果字符串可能包含大写字母、数字甚至空格,怎么办?”
这时候有两条路可以走。
第一条路:扩大计数数组的长度。ASCII字符集一共128个常用字符,用[0] * 128统计,下标直接用ord(ch),不需要减去任何基准值,代码反而更简单。这个方法只适用于 ASCII 范围内的字符。
第二条路:直接用defaultdict(int)做计数器。每个字符作为键,出现次数作为值,最后把计数器的所有键值对排序后转成元组作为最终键。
def get_key(s: str): counter = {} for ch in s: counter[ch] = counter.get(ch, 0) + 1 return tuple(sorted(counter.items()))为什么要sorted(counter.items())?因为counter.items()本身是无序的。如果直接用元组套字典的 items,两个内容的counter可能因为插入顺序不同得到不同的元组,这就破坏了我们想要的“同组同键”的性质。排序之后,键值对排列顺序一致,才能保证键的稳定性。
如果字符集合很大、字符串又特别长,还可以考虑质数乘积法:给26个字母分别映射一个质数,比如a=2, b=3, c=5, d=7...,然后遍历字符串把所有质数相乘,乘积作为键。质因数分解的唯一性保证了两个异位词的乘积一定相同,非异位词乘积一定不同。但乘积会暴涨,Python虽然支持大整数,这个方案在面试中可以作为加分项提一嘴,实际生产里还是计数法更可控。
4.2 本地跑通和VSCode调试
力扣网页版直接写Solution类就行,但我一直建议大家本地建一个 Python 文件跑一遍,尤其是想验证自己写的用例时,本地调试比在网页上一遍遍提交效率高得多。这里给个完整的本地测试模板:
from typing import List from collections import defaultdict class Solution: def groupAnagrams(self, strs: List[str]) -> List[List[str]]: table = defaultdict(list) for s in strs: key = "".join(sorted(s)) table[key].append(s) return list(table.values()) if __name__ == "__main__": sol = Solution() test_cases = [ (["eat", "tea", "tan", "ate", "nat", "bat"], 3), ([""], 1), (["a"], 1), (["abc", "bca", "cab", "aaa"], 2), ] for strs, expected_group_count in test_cases: result = sol.groupAnagrams(strs) print(f"输入: {strs}") print(f"分组数: {len(result)}, 预期: {expected_group_count}") print(f"结果: {result}") print("-" * 40)在 VSCode 里建议装 LeetCode 插件,写好代码后直接右键Debug,可以在key = "".join(sorted(s))这一行打断点,逐行看每个字符串生成的 key 到底是什么。这个习惯能帮你快速理解哈希键的设计思路,也能排查那种“明明逻辑对但分组不对”的玄学问题。
5. 高频踩坑与排查记录
5.1 常见错误速查表
这题代码量不大,但报错种类不少。我把这些年见到的典型错误统一整理成表格,方便你对号入座:
| 错误现象 | 出错原因 | 解决方案 |
|---|---|---|
TypeError: unhashable type: 'list' | 直接把sorted(s)或计数列表当成字典 key | 用"".join(sorted(s))或tuple(count)转成不可变类型 |
| 分组数异常变多 | counter.items()没有排序,导致相同内容得到不同键 | 使用tuple(sorted(counter.items()))保持键稳定 |
| 输出子列表顺序不对 | 误以为输出顺序必须和样例一致 | 力扣不检查子数组顺序,只要分组正确就行 |
| 输入包含空字符串时结果不对 | 忘记sorted("")和空计数器都能正常工作 | 确认空字符串的 key 是"",单独成组即可 |
| 输入含大写字母时计数错乱 | 计数数组长度只设了26 | ASCII 场景扩到[0] * 128;通用场景用计数器扩展方案 |
| 本地运行报模块找不到 | 没安装 Python 或环境变量配置异常 | 检查python --version,必要时重装并勾选 “Add to PATH” |
5.2 面试追问怎么接
面试官如果继续往下问,通常围绕三个方向:
第一个方向是“优化到 O(n*k) 之后还能再快吗”。你可以说理论上排序法或计数法已经是线性级别了,因为至少要把每个字符读一遍;如果字符串特别长,还可以用质数乘积法减少键的存储空间,但要注意数值溢出问题。
第二个方向是“如果只判断两个字符串是否互为异位词,怎么做”。这是力扣242题,直接复用计数法思路,比较两个字符串的频次统计是否一致就行。
第三个方向是“如果内存有限制怎么办”。这时候可以考虑对字符串先排序,再基于排序后的结果做外部排序和分组,适合超大文件场景。面试中能主动分析数据规模和资源瓶颈,比闷头写代码更拉好感。
5.3 我踩过的一个坑
有段时间我在本地用字符串格式的 key,比如"1#0#0#..."代替 tuple,想着这样能看到直观结果。结果因为拼接时忘记处理两位数以上的频次(比如字母出现10次时"10"和"1"+"0"会产生歧义),导致边界用例分组错乱。后来发现最稳的方案就是直接用 tuple 或者带分隔符的格式,比如"#".join(map(str, count)),绝不能裸拼接数字字符串。这个小问题让我记住了:哈希键的设计不只是“能区分”就行,还必须在所有边界条件下保持无歧义。
6. 从这一题延伸出去:同类题与刷题策略
6.1 相邻题目串讲
学透一道题最好的方式,就是马上做它的姊妹题。和49题强相关的题目至少有三道:
力扣242题“有效的字母异位词”:给两个字符串,判断是否互为异位词。这题就是49题的判断单组版本,直接计数法或者排序法都能解,适合作为49题的前置练习。
力扣438题“找到字符串中所有字母异位词”:在一个长字符串里找到所有和模式串互为异位词的子串起点。这题需要用到滑动窗口 + 频次统计,是计数法从“单个字符串统计”到“子串窗口统计”的升级。
力扣567题“字符串的排列”:判断一个字符串是否包含另一个字符串的排列。思路和438几乎一样,本质是判断某个窗口内的字符频次是否与目标串完全一致。
这几道题一起吃透,你会形成一个完整的知识块:字符串频次统计、哈希键设计、滑动窗口三件套。以后再碰到“分组”“排列”“异位词”相关的题目,第一反应就不是死记模板,而是主动联想“可以用哪种不变量来作为统一标识”。
6.2 刷题顺序建议
如果你正在按力扣HOT100刷题,我的建议是把49题放在“哈希表专项”里尽早做。它不像链表、二叉树那样需要复杂的指针维护,也不像动态规划那样需要大量的状态推理,它考察的是一个非常通用且朴素的思维:如何给一堆对象找一个稳定的分类标识。
第一遍刷的时候,只写排序法,目标是能独立通过。第二遍刷的时候,尝试直接写计数法,要求自己不看答案,20分钟内写完并跑通。第三遍复习时,可以把这道题和242、438、567放到同一天做,对比它们的联系和差异。三轮下来,这题的代码你可能还是会忘,但“设计稳定哈希键”的思路会牢牢长在脑子里。
6.3 后续可以做的扩展练习
如果你对这道题意犹未尽,还可以尝试两个扩展方向:
第一个方向是“变形题设计”。给自己出题:如果输入是数字数组,要求把“同一组数字经过重排列后相等”的数字归到一起,你会怎么设计键?答案是直接对数字排序,和排序法如出一辙。这个练习能让你意识到,哈希键设计思维不限于字符串,而是适用于一切“将元素重排后等价”的问题。
第二个方向是“输出优化”。力扣不要求组内顺序,但在实际业务里可能要求输出结果按每组字符串数量降序排列,或者按字典序排列。理解了字典的分组过程后,对table.values()做一次sorted(..., key=...)就能实现,这些扩展改动都很轻量。
我个人在实际操作中的体会是:刷算法题,记忆力是最靠不住的资产,真正值钱的是你能否在题目里“看到熟悉的结构”。49题的排序法,本质上是把一个麻烦的问题——判断两个字符串是否同构——转化成了一个简单的问题——比较两个键是否相等。这个“化归”的思维,比任何一行代码都值得你反复琢磨。如果你正在被力扣HOT100的题量吓到,不用慌,从49题这种“一只脚踩在基础上、另一只脚踩在思维上”的题目切入,先把哈希键想明白再动手写,会比盲目刷几十道题有用得多。