LeetCode 热门 100 题里,第 49 题“字母异位词分组”属于那种一眼看去人畜无害、真动手全是细节的经典题。我第一次刷它的时候,觉得这不就是字符串排序套个哈希表吗?后来在面试实战和帮别人复盘代码时才发现,这道题关于哈希 key 的设计、复杂度的推导、边界条件的处理,能写出来的内容远比题目本身长。这篇文章会从题目理解开始,把排序法和计数法两种主流解法一步步拆开,再附上我实际踩过的坑和排查记录,适合正在准备面试、刚刷到“哈希表”专题的朋友。看完你能独立手撕这道题,也能应付面试官随后的追问。
1. 这题到底在问什么?先别急着写代码
1.1 什么是字母异位词
题目给了一堆字符串,要求把“字母异位词”分到同一组。字母异位词的定义是:由相同数量、相同种类的字母组成,只是排列顺序不同。比如“eat”和“tea”,“tan”和“nat”,每组里的词打乱字母顺序后就是同一个词。
举个例子:
strs = ["eat", "tea", "tan", "ate", "nat", "bat"]
正确输出可以是:
[["bat"], ["nat", "tan"], ["ate", "eat", "tea"]]
组与组的顺序无所谓,组内顺序也无所谓,只要是这些集合就行。也就是说,题目并没有要求你把组内再按字典序排列,也没有要求组之间按某种顺序输出,这一点在很多人的代码里被过度实现——多写排序纯属浪费。
判断两个单词是否异位词,最朴素的办法是逐个比较字母数量,或者把两个词排序后比较。单看两个词似乎很容易,但题目输入最多有 10^4 个字符串,每个字符串最长 100 个字符,两两比较肯定不行。所以核心就是:能不能找一个“归一化”的方法,让同一组的词都映射到同一个标识上,再用哈希表聚合。
1.2 题目边界和隐含条件
LeetCode 官方题解里明确写了输入约束:strs.length 在 1 到 10^4 之间,strs[i].length 在 0 到 100 之间,且 strs[i] 仅包含小写字母。但这是“当前版本”的约束,面试官完全可能改条件,比如加上大写字母、数字,甚至中文。
我为什么要专门提约束?因为解法是跟着约束走的。约束只有小写字母,计数法才能固定开一个长度为 26 的数组;如果字符范围变大,数组长度就要跟着变,或者换一种 key 设计。这道题表面上考哈希表,实际上考的是“不变量”的提取:你需要找到一种表示方式,让相同字母组合的词拥有完全相同的 key,让不同字母组合的词尽可能不撞 key。
还有一个容易被忽略的边界:字符串为空。空字符串没有任何字母,它的排序结果是空串,计数数组是全 0。无论哪种解法,都要保证空字符串能被单独分到一组,而不是变成特殊值导致程序崩溃。我见过有人直接在函数开头写if s is None,把空串过滤掉了,导致结果丢失,这就是读题不仔细。
2. 排序法:最直觉的做法,也是面试的及格线
2.1 排序法为什么能成立
排序法基于一个非常直观的观察:两个字符串互为异位词,当且仅当它们排序后的结果完全相同。“eat”排序是“aet”,“tea”排序也是“aet”,“ate”排序还是“aet”;“bat”排序是“abt”,和其他词都不一样。
所以算法骨架就是三句话:
- 遍历所有字符串;
- 对当前字符串排序,得到 key;
- 把原字符串塞进
哈希表[key]对应的列表里。
最后把哈希表的所有 value 收集起来,就是答案。
这个方案为什么是面试的及格线?因为它简单、正确、容易解释,几乎不会写错。哪怕面试官后面要求优化,你也已经证明了“我能快速给出一个可行方案”。我面试别人的时候,最怕的不是候选人给出排序法,而是候选人连排序法都说不清楚,直接上计数法然后卡在 key 拼接上。
2.2 代码实现与复杂度
Python 写法最简洁,因为 sorted 可以直接作用于字符串,返回字符列表,再 join 回字符串就行:
def groupAnagrams(strs): from collections import defaultdict lookup = defaultdict(list) for s in strs: key = ''.join(sorted(s)) lookup[key].append(s) return list(lookup.values())这里用defaultdict(list)比普通 dict 方便得多:遇到新 key 时自动初始化一个空列表,省掉了if key not in lookup: lookup[key] = []这行判断。如果你用普通 dict,一定要记得手动处理键不存在的情况,否则会抛 KeyError。
Java 需要先把字符串转成 char 数组排序,再转回 String:
public List<List<String>> groupAnagrams(String[] strs) { Map<String, List<String>> map = new HashMap<>(); for (String s : strs) { char[] arr = s.toCharArray(); Arrays.sort(arr); String key = new String(arr); map.computeIfAbsent(key, k -> new ArrayList<>()).add(s); } return new ArrayList<>(map.values()); }C++ 里直接对字符串排序就行,因为 std::string 支持原地排序:
class Solution { public: vector<vector<string>> groupAnagrams(vector<string>& strs) { unordered_map<string, vector<string>> mp; for (string& s : strs) { string key = s; sort(key.begin(), key.end()); mp[key].push_back(s); } vector<vector<string>> ans; for (auto& [k, v] : mp) ans.push_back(v); return ans; } };三个语言的做法本质一样,唯一的区别只是语言 API。
复杂度也很有必要说清楚:假设输入有 n 个字符串,每个字符串平均长度是 k,那么每个字符串排序需要 O(k log k),总时间复杂度就是 O(n × k log k)。空间上,哈希表存的是所有原始字符串,差不多是 O(n × k)。
这里有个很多人忽略的点:如果字符串数量很大、但每个字符串很短,排序法的 log k 部分其实可以忽略不计,性能完全够用。这也是我在实际面试中推荐“先写排序法”的原因——大多数输入场景下它已经能 AC 了,没必要一上来就炫耀计数法。
3. 计数法:进阶解法,理解 key 的设计是关键
3.1 计数数组怎么当 key
面试官紧接着大概率会问:“能不能把复杂度里的 log k 去掉?”这时候就要引出计数法。
既然互为异位词的单词拥有完全相同的字母计数,那我们就不排序,直接统计每个字母出现次数,得到一个长度 26 的数组。比如“eat”的计数是[1, 0, 0, 0, 1, 0, ..., 1](a 出现 1 次,e 出现 1 次,t 出现 1 次),"tea" 的计数完全一样。数组天然适合做“归一化表示”。
问题来了:数组本身不是哈希表的合法 key。Python 里 list 不可哈希,Java 里数组的 equals 是引用比较,直接用数组当 key 会出事。所以需要把计数数组序列化成一个可以哈希、且不会歧义的字符串。
这里就涉及一个经典坑:序列化格式。如果你直接不加分隔符地把数字拼在一起,比如把[1, 0]拼成"10",把[10, 1]也拼成"101",两者就撞了。虽然原题限制字符串长度最大 100,理论上字母计数最多 100,数字长度最多 3 位,但数字间没有分隔符时是会产生歧义的。
所以常见做法是加一个分隔符,比如"1#0#0#...#1",或者显式给每个数字固定宽度(补零到 3 位:"001#000#000#...")。我在实际测试中发现,补零的方式更费空间但完全无歧义,加分隔符的方式直观、可读性强,两者都可以。更优雅一点,在 Python 里也可以直接用tuple(count)作为 key,因为元组可哈希,而且不需要拼字符串。
3.2 实现与分隔符的坑
用计数法实现时,我建议先明确 key 的设计,再写主循环。下面是一段 Python 实现:
def groupAnagrams(strs): from collections import defaultdict lookup = defaultdict(list) for s in strs: count = [0] * 26 for ch in s: count[ord(ch) - ord('a')] += 1 key = '#'.join(str(x) for x in count) lookup[key].append(s) return list(lookup.values())这段代码的 key 是"1#1#0#0#...#1"这样的字符串。每次统计完一组计数,就把它拼成 key,然后塞进哈希表。
Java 版本同样要用 StringBuilder 拼 26 个数字:
public List<List<String>> groupAnagrams(String[] strs) { Map<String, List<String>> map = new HashMap<>(); for (String s : strs) { int[] count = new int[26]; for (char c : s.toCharArray()) { count[c - 'a']++; } StringBuilder sb = new StringBuilder(); for (int i = 0; i < 26; i++) { sb.append(count[i]).append('#'); } String key = sb.toString(); map.computeIfAbsent(key, k -> new ArrayList<>()).add(s); } return new ArrayList<>(map.values()); }如果你希望在 Python 里避开字符串拼接的开销,可以直接用tuple(count)作为 key,因为 tuple 是天然可哈希的:
def groupAnagrams(strs): from collections import defaultdict lookup = defaultdict(list) for s in strs: count = [0] * 26 for ch in s: count[ord(ch) - ord('a')] += 1 lookup[tuple(count)].append(s) return list(lookup.values())这种方式在可读性上甚至更好,但我面试时仍然更常听到字符串拼接的版本,因为很多人的第一反应是“数组不能当 key,那就转成字符串”。两种都可以,关键是不要忘掉分隔符。
计算一下复杂度:每个字符串扫描一遍统计计数,耗时 O(k),key 生成也是 O(1)(因为长度固定 26),n 个字符串总计 O(n × k)。空间上哈希表依然存所有字符串,O(n × k)。
这个解法的精髓在于,它把“判断两个词是否异位词”从“排序后比较”变成了“统计后比较”,时间上砍掉了 log k 因子。但你也看到了,代价是代码复杂了一点,需要额外设计 key。面试官真正想听的,往往就是你能不能把这个 key 的设计讲明白、讲利索。
4. 两种解法怎么选?面试官到底想看到什么
4.1 复杂度对照与适用场景
整理一张表帮助你记忆:
| 解法 | 时间复杂度 | 空间复杂度 | 代码复杂度 | 最适用场景 |
|---|---|---|---|---|
| 排序法 | O(n × k log k) | O(n × k) | 低 | 字符串短、数量大、第一版 |
| 计数法 | O(n × k) | O(n × k) | 中 | 字符串长、追求线性、变体题 |
注意,计数法的空间复杂度仍然是 O(n × k),因为最终的答案必须返回所有原字符串。有人以为计数法节省空间,其实并没有,它省的是时间。
如果面试里的字符串都是英文单词,平均长度也就是 5 到 10 个字符,排序法的 log k 几乎可以忽略,两种解法跑起来几乎没有区别。但如果把输入换成 DNA 序列、长文本片段,k 可能会到几千甚至上万,这时候排序法每次排序都要花不少时间,计数法优势就非常明显。
4.2 面试时的推进策略
以我自己的经验,面试官面对这题通常有两条追问线。第一条:顺着排序法,问你能不能保证 key 不重复;第二条:复杂度太高了,你能不能优化到线性。无论哪条,落脚点都是计数法。
所以我建议的回答节奏是:
- 先说暴力思路:两两比较,复杂度 O(n² × k),太慢;
- 再说排序法:利用排序后的归一化表示,O(n × k log k);
- 最后引出计数法:计数数组作为 key,O(n × k);
- 主动分析两种方案的复杂度,并指出 key 序列化的歧义问题。
这套“暴力 → 优化 → 再优化 → 边界讨论”的流程,比直接默写代码更能加分。面试官要的是沟通能力和思维路径,不是背题。
我模拟一段对话给你感觉:
面试官:“排序法很好,还能优化吗?” 你:“可以。既然异位词的本质是字母个数相同,我可以统计每个词各字母的出现次数,用计数数组作为唯一标识。因为原题限定小写字母,数组长度固定 26,总复杂度能降到 O(n × k)。” 面试官:“数组怎么放进哈希表?” 你:“序列化成带分隔符的字符串,或者转成不可变的元组。这里要注意分隔符,不然数字会粘在一起产生歧义。”
这段对话如果练熟,基本上就过关了。
5. 我踩过的坑:边界条件与细节排查
5.1 空字符串、单字符串、重复字符串
先说空字符串。输入strs = [""]时,排序法得到 key"",计数法得到"0#0#...#0",都会正常分到一组,输出[[""]]。但有些人会在读取字符串时假设“非空”,或者用if not s: continue跳过空串,把结果搞丢。这个我在代码审查里见过不止一次。
再说重复字符串。输入["a", "a"]时,两个"a"应该放在同一个组里,输出[["a", "a"]],而不是[["a"], ["a"]]。很多初学者以为去重了就可以,其实哈希表的 value 是列表,重复元素会依次 append 进去,天然保留重复项。这里完全不需要额外处理,但你要能解释清楚。
还有单个字符的情况:["a", "b"],每个字符自己一组。排序法和计数法都能正常处理,注意别在统计的时候越界。比如count[c - 'a']只有在 c 确实是小写字母时才是合法的数组下标,如果混入大写字符就变负数了,这是很隐蔽的运行时错误。
5.2 字符范围变了怎么办
原题限定小写字母,但实际面试中可能问:如果字符串包含大写字母、数字,甚至中文,解法怎么改?
处理方式有两种。第一种是直接扩大计数数组的规模:如果字符范围是 ASCII 可打印字符,可以开长度 128 的数组;如果是 Unicode,就开一个 65536 的数组,或者干脆用Counter字典。
第二种是把字符做归一化后再计数:比如先统一转小写,再统计;或者把非字母字符单独映射到固定位置。关键原则是:你定义的 key 必须对所有字符无歧义,并且同一个词的不同排列得到的 key 完全一致。只要满足这两点,解法数学上是正确的。
前面提到的“不加分隔符会撞 key”的例子,在字符范围扩大后会变得更严重。比如计数[1, 0, 11]拼成"1011",而[10, 1, 1]也拼成"1011",这在字母只有 26 个时其实很难发生,因为计数数字位数有限,但一旦字符串长度很长、某些字母计数能到 100 以上,数字长度就不可控。所以不管范围怎么变,我都建议用分隔符或固定宽度,这个习惯养成后能避免很多隐藏 bug。
5.3 常见问题速查表
| 症状 | 可能原因 | 解决办法 |
|---|---|---|
| 分组结果少了一组 | 过滤了空字符串 | 不要跳过空串,直接参与哈希 |
| key 冲突导致错误分组 | 计数法没加分隔符 | 改成#拼接或使用元组 |
| 超时 | 字符串很长还用了排序法 | 改用计数法,砍掉 log k |
| 输出顺序不如预期 | 误以为要求排序 | 题目允许任意顺序,无需排序 |
| 重复字符串丢失 | 提前做了去重 | value 用列表,保留所有原串 |
| 用了可变对象做 key | Python 里用了 list | 转成 tuple 或字符串 |
这张表是我在实际刷题和帮人 review 代码时总结出来的,排查问题的效率很高。
6. 这道题之外:相关题与学习路线
6.1 从 49 题延伸出去的高频题
LeetCode 里与“字母异位词计数/分组”相关的题不少,我按学习顺序列一下:
- 有效的字母异位词:只判断两个词是不是异位词,用计数数组即可,是 49 题的缩小版。
- 找到字符串中所有字母异位词:滑动窗口加计数器,在长串里找短串所有异位词起始位置,是计数法的典型变体。
- 字母异位词分组:就是本文这道题,用哈希表聚合归一化 key。
- 字符串的排列:与 438 类似,判断一个串的某个子串是不是另一个串的排列。
这几道题串起来就是“计数 + 哈希 + 滑动窗口”的完整练习路线。做完这组题,你会发现它们其实是同一套思路:“找不变量,然后用哈希表把相同的东西聚在一起”。
另外,最近周赛和热门 100 题里还经常出现“基本计算器”这类表达式求值题,它们和本文的主题不同,属于栈与缓存。“爱吃香蕉的狒狒”(875)这种二分答案题,也是热门题里的常客,但它和字符串分组完全是两套思维。刷题的时候要注意归类:字符串分组题归到“哈希 + 归一化”,表达式题归到“栈解析”,每类掌握一两道代表题,比盲目刷 300 题有效得多。
6.2 一个刷题技巧:把“分组”问题归类
遇到“把具有某种相同特征的字符串分到同一组”这种要求,我的第一反应永远是:找一个特征提取函数。把这个函数设计好,问题就解决了一大半。
- 对字母异位词,特征函数是“排序后的字符串”或“计数序列”;
- 对同字母不同大小写的词,特征是“小写后的字符串”;
- 对相同频率的数字,特征是“统计后的频率元组”。
这种“特征 + 哈希表”的模式,在算法题里非常通用。你可以把它理解成一个银行柜台:所有特征相同的用户都走同一条通道,最终站在同一队列里。设计好通道(key),队列自然就分好了。
同样,我在 LeetCode 周赛里看到不少字符串分组题,本质上都可以用这套模板。你在准备面试时,如果能熟练地说出“我先设计一个无歧义的 key,再用哈希表聚合”,面试官通常会点头表示满意。
结尾:一点个人体会
刷了几百道题之后回头看,49 题其实是一个非常典型的分水岭:能 AC 的人很多,但能把 key 设计讲清楚的人不多。我个人至今的习惯是,遇到字符串分组题,先想“特征函数”,再想“哈希”,最后验证边界。这一套流程帮我避免了不少隐蔽的 bug。
最后再分享一个小技巧:写完代码后,手动跑几个极端用例再提交,比如strs = [""]、strs = ["a", "a"]、strs = ["", "b"]。这三组用例基本能覆盖 90% 的边界问题。几年前我在面试现场因为漏掉了空字符串,分组结果少了一组,被面试官追问后才恍然大悟,从那以后再也没有犯过同样的错误。
希望这篇文章能帮你把 49 题从“背答案”变成“理解答案”。刷题这件事,刷的数量不重要,刷出来的思考路径才是真正能带走的东西。