一看到"字母异位词分组"这道题,我的第一反应是:LeetCode 编号 49,哈希表分类,面试高频,看起来好像就是"哈希 + 字符串分组"的组合。可真正动手做的时候会发现,简单的是思路,容易翻车的是哈希键的定义。如果用不好键,或者没想清楚"什么能作为键、什么不能作为键",代码写出来不是性能差,就是直接编译报错。这篇文章就把这道题从头到尾拆开,从原理讲到代码,附带我实际刷题和面试候选人的时候反复踩过的坑。
这道题的要求很简单:给你一个字符串数组,把字母异位词分到同一组。所谓字母异位词,就是字母种类和数量完全一样,只是排列顺序不同。比如 "eat"、"tea"、"ate",它们都是三个字母 e、a、t 的排列,所以必须归为一组;"tan" 和 "nat" 也互为异位词;而 "bat" 跟前面任何一组都凑不上。题目最后要输出的就是这些分组结果。
1. 读题时先抓住"异位词分组"到底在找什么
1.1 题目的真正难点不是"相同",而是"等价"
很多人上手这道题时,第一反应是去两两比较字符串是否相等。这是自然反应,但它马上会陷入一个泥潭:字符串相等是严格字符按位相同,而字母异位词是"字符集合相同但顺序不同"。你不能简单地用 s1 == s2 判断,得先定义一个"等价关系"。
这个等价关系的定义,是整道题的题眼。
如果两个字符串互为异位词,那么它们必须满足两个条件:字符种类完全一样;每个字符出现的次数完全一样。也就是说,它们拥有的"字符计数向量"相同。比如 "abcc" 和 "cabc",计数都是 a:1、b:1、c:2。这个向量就是它们的公共身份。
所以题目就转化成了:把拥有同一个计数向量的字符串扔进同一个桶里。我们需要的是一种方法,能够快速从任意字符串算出它的"身份标识",并且这个标识必须是稳定、唯一、便于比较的。看到这你应该已经意识到了,哈希表就是干这个的。
1.2 哈希表为什么适合这道题?
哈希表的本质是:通过一个哈希函数,把任意对象映射到一个可比较的键值,然后根据键值把数据放进桶里。这道题需要做的恰恰是:为每个字符串计算一个"归一化后的键",让互为异位词的字符串得到同一个键,然后以键为维度分组。
类比一下就懂了。假设你是快递分拣员,手上有一堆包裹,分拣规则是"只要是收件人姓名相同的包裹就放同一个货架"。这时你不会去读包裹里装的是什么,而是看一眼收件人名字,然后直接丢到对应货架。哈希表就是那个"货架",而收件人名字就是"键"。
在字母异位词分组里,收件人名字不能直接写原始字符串,因为 "eat" 和 "tea" 虽然是同一个人,但字面上不一样。所以你得给每个包裹重新贴一个"规范化标签",让同一组字符串都在标签上写着同一个暗号。
1.3 暴力法,先跑一遍才懂为什么要优化
先看最直观的暴力解法:把数组里每个字符串和其他字符串两两比较,判断是否互为异位词。判断两个字符串是否互为异位词,可以排序后比较,也可以用计数数组比较。
假设有 n 个字符串,每个字符串平均长度为 k,两两比较一轮是 O(n²),每次比较时如果做排序,复杂度是 O(k log k)。总体就是 O(n² k log k)。这个复杂度在 LeetCode 的测试数据下基本是过不去的:字符串数量一多,二次方的开销会迅速膨胀。
暴力法的本质问题是:它把每对字符串都当成潜在的匹配对象,但并没有提取公共身份。而哈希表解法之所以高效,是因为它把"两两比较"降级成了"每人都领一个统一的编号,编号相同的人自动归队"。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 分组方案的分水岭:用什么作为哈希键
代码实现的分水岭,不在于你是用 Python 还是 C++,而在于你选择什么作为哈希表的键。整道题的正解,本质上就是选键问题。
2.1 排序键:一行代码完成归一化
最常见的做法是:把字符串里的字符排序,排序后的结果作为键。
"eat" 排序后是 "aet";"tea" 排序后也是 "aet";"ate" 排序后还是 "aet"。三个字符串都映射到同一个键,自然被分到同一组。这背后的道理是:排序不会改变字符种类和数量,只会改变顺序。如果两个字符串互为异位词,它们的字符排序结果必然相同。
这个方案的优点是简单直接,几乎不需要什么特别的数学思考。Python 里写起来尤其爽:
python复制from collections import defaultdict
def group_anagrams(strs):
table = defaultdict(list)
for s in strs:
key = "".join(sorted(s))
table[key].append(s)
return list(table.values())
C++ 的代码也不复杂:
cpp复制#include <string>
#include <vector>
#include <unordered_map>
#include <algorithm>
using namespace std;
class Solution {
public:
vector<vector<string>> groupAnagrams(vector<string>& strs) {
unordered_map<string, vector<string>> table;
for (const string& s : strs) {
string key = s;
sort(key.begin(), key.end());
table[key].push_back(s);
}
vector<vector<string>> result;
result.reserve(table.size());
for (auto& pair : table) {
result.push_back(move(pair.second));
}
return result;
}
};
时间复杂度是 O(n × k log k)。n 是字符串个数,k 是最长字符串长度。因为每个字符串都要排序。这个复杂度在一般面试场景下已经够用了,LeetCode 也认这个解法。
2.2 计数键:把字符频次编码成字符串
如果你觉得排序还是有点慢,或者面试官追问"能不能做到 O(nk)",那就需要换一种键的设计思路。
互为异位词的两个字符串,字符计数完全一致。那我们直接统计每个字符串里每个字母出现的次数,然后把"统计结果"本身当作键。
问题是:统计结果怎么表示?在 Python 里,你可以用 tuple:
python复制def group_anagrams(strs):
table = defaultdict(list)
for s in strs:
count = [0] * 26
for ch in s:
count[ord(ch) - ord('a')] += 1
table[tuple(count)].append(s)
return list(table.values())
tuple 可以作为 Python 字典的键,因为它是可哈希的,而 list 不行。这一点非常容易踩坑,后面细说。
在 C++ 里事情就没那么顺了。std::vector<int> 不能直接作为 unordered_map 的键,因为标准库没有为 vector 提供默认的哈希函数。常见做法是把计数数组拼成一个字符串,比如 "1#0#2#0#...",然后把这个字符串作为 key。
cpp复制#include <string>
#include <vector>
#include <unordered_map>
#include <array>
using namespace std;
class Solution {
public:
vector<vector<string>> groupAnagrams(vector<string>& strs) {
unordered_map<string, vector<string>> table;
for (const string& s : strs) {
array<int, 26> count{};
for (char c : s) {
count[c - 'a']++;
}
string key;
key.reserve(26 * 2);
for (int i = 0; i < 26; i++) {
key += '#';
key += to_string(count[i]);
}
table[key].push_back(s);
}
vector<vector<string>> result;
result.reserve(table.size());
for (auto& pair : table) {
result.push_back(move(pair.second));
}
return result;
}
};
为什么中间要加分隔符 #?因为如果不加分隔符,[1, 2] 这种计数可能被拼成 "12",而 [12, 0] 也会被拼成 "120",但它们不该是同一个键。加入分隔符后,[1, 2] 变成 "#1#2",[12, 0] 变成 "#12#0",就不会混淆了。
计数键的时间复杂度是 O(n × k),每个字符串只需要遍历一次再拼接 26 个数字。从理论上说,当 k 比较大的时候,计数法比排序法更有优势,因为它省掉了排序的 log k 因子。
2.3 排序键和计数键的取舍
很多文章会说计数键一定比排序键好,我不这么看。在实际工程场景和面试里,排序键的常数极小,实现最简单,也最不容易出错。如果面试官没有刻意要求复杂度,排序键完全可以作为首版答案。
我个人的习惯是:先写排序键,把代码跑通;如果面试官追问"能不能再优化",再提计数键,顺便讲清楚计数键为什么能从 O(nk log k) 降到 O(nk)。这样既能展现思维过程,又不会一开始就陷入编码细节里。
两者对比如下:
| 维度 | 排序键 | 计数键 |
|---|---|---|
| 键的形式 | 排序后的字符串 | 字符频次编码串或 tuple |
| 时间复杂度 | O(n × k log k) | O(n × k) |
| 实现复杂度 | 简单 | 稍复杂,需要处理编码歧义 |
| 语言兼容性 | 几乎所有语言都能直接写 | C++ 需要自定义键编码 |
| 适合场景 | 快速实现、字符串短 | 字符串长、面试优化追问 |
3. 完整代码与边界情况的落地处理
题目本身不复杂,但它对语言特性和边界情况的考察很细致。我就围绕 Python 和 C++ 这两门最常见的语言展开,把每个细节讲透。
3.1 Python 实现与 defaultdict 的细节
Python 版本里,我推荐直接用 collections.defaultdict(list)。它的好处是,访问一个不存在的键时,会默认创建一个空 list,省去手动判断键是否存在的代码。
python复制from collections import defaultdict
def group_anagrams(strs):
groups = defaultdict(list)
for word in strs:
key = "".join(sorted(word))
groups[key].append(word)
return list(groups.values())
这里要注意,sorted(word) 返回的是一个字符列表,必须用 "".join(...) 拼接成字符串才能作为键。如果不 join,直接拿 list 当键,就会遇到 TypeError: unhashable type: 'list',因为 list 在 Python 里是不可哈希对象,不能作为字典的键。
如果你用计数法,也要把计数结果转成 tuple 而不是 list:
python复制from collections import defaultdict
def group_anagrams(strs):
groups = defaultdict(list)
for word in strs:
count = [0] * 26
for ch in word:
count[ord(ch) - ord('a')] += 1
groups[tuple(count)].append(word)
return list(groups.values())
这里的 tuple(count) 也是不可变的,Python 会基于元素内容计算哈希值,所以同样内容的 tuple 会得到同一个键。
3.2 C++ 实现与 unordered_map 的注意点
C++ 里最稳妥的写法,是排序字符串后用 unordered_map<string, vector<string>>。string 自带哈希特化,可以直接作为键,不需要额外写哈希函数。
我在面试里见过有人想当然地写:
cpp复制unordered_map<vector<int>, vector<string>> table; // 错误
这就是典型的编译不过的写法。vector<int> 没有默认哈希函数,除非你自己提供 std::hash 的特化,否则别这么写。
如果一定要用计数键,推荐的做法是像前面那样拼成字符串。拼字符串本身也是 O(26) 的额外开销,但因为 26 是个常数,整体复杂度仍然是 O(nk)。
C++ 里还有一种做法是自定义哈希函数,把 array<int, 26> 映射为 size_t。这样可以让代码看起来更"哈希",但我个人不推荐在面试场景下这样做,因为自定义哈希写得不够好的话,既浪费时间又容易引入错误。字符串编码方案虽然在性能上多了一次拼接和遍历,但正确性更容易保证,也更容易向面试官解释。
3.3 容易忽略的边界情况
空字符串。如果输入的数组里有空字符串 "",排序后还是空字符串,计数也全是 0。它应该自己成为一组,代码不应该报错。排序法和计数法都能自然处理。
只有单个字符的字符串。比如 ["a", "a", "b"],它们排序后分别是 "a"、"a"、"b",分组结果就是 [["a", "a"], ["b"]]。没有特殊逻辑。
输入包含大写字母。LeetCode 原题的输入范围是 s[i] 仅包含小写字母,但如果你在实际面试中遇到更开放的问题,可以问清楚输入范围。如果包含大写字母,计数数组的大小需要扩展,或者排序法天然不受影响,因为排序基于字符编码。
Unicode 和中文。如果字符串不只是英文字母,排序法依然有效,但计数数组方案就需要重新设计。比如汉字没有固定的 26 个字符集,这时候我会直接用 Counter 或者排序法,避免刻板使用固定长度的数组。
4. 我实际调试中踩过的三个坑
这道题我刷过不只一次,也拿它面过不少候选人。有些坑是新手容易踩,有些坑是经验丰富的人也容易忽略的。
4.1 Python 里把 list 当键导致崩溃
我第一次用计数法写 Python 时,直接写了 groups[count].append(word),然后被 TypeError 教做人了。字典的键必须是可哈希的,而 list 是可变的,不可哈希。这不仅是语法限制,更是语言设计的合理性:如果一个可变对象能当键,那你在键上做一次 append 或 sort,哈希值就会变,整个数据结构就乱了。
所以遇到这种场景,要么转成 tuple,要么转成字符串。时刻记住:在 Python 里,字典键要选不可变对象。
4.2 排序后覆盖了原始字符串
C++ 里写排序法时,有人会直接这样写:
cpp复制sort(s.begin(), s.end());
table[s].push_back(s);
这样就把原始字符串弄丢了。因为 strs 里的字符串被排序后,push 进去的是排序后的结果,不是原始值。正确答案是先复制一份再排序:
cpp复制string key = s;
sort(key.begin(), key.end());
table[key].push_back(s);
这看起来是个低级错误,但在写 sort 的时候,脑子里只想着"我要排序后的字符串作 key",手一快就覆盖了原值。解决方法是把排序前后的用途分清:原始字符串用于分组结果,排序后的字符串只用于索引。
4.3 unordered_map 的迭代顺序不稳定
unordered_map 的内部顺序由哈希表的大小、哈希函数和插入顺序共同决定,不是确定的。如果你直接遍历 unordered_map 生成结果,不同编译器、不同运行环境下,输出分组的顺序可能不同。
LeetCode 的题目要求返回任意顺序,所以这不影响判题。但如果你在实际项目里需要稳定输出,或者面对候选人时他要求"输出顺序和原数组一致",那就要另做处理。一种做法是额外维护一个 vector<string> 记录首次出现的 key 顺序,最后按这个顺序输出。这个细节在工程上很常见。
4.4 不要混淆"哈希表的哈希冲突"和"键设计错误"
哈希表里的哈希冲突是指:两个不同的键经过哈希函数计算后,落到了同一个桶。这个问题由 unordered_map 或 Python dict 内部处理,通常不会影响正确性,只是会影响性能。而键设计错误是指:两个本应属于不同分组的字符串,被归一化成了同一个键。这个问题是算法层面的,一旦出现,分组结果就是错的,而且哈希表数据结构没法帮你纠正。
打个比方,哈希冲突是快递仓库里两个不同货架号被算成了同一个货架号,仓库管理系统会解决;但键设计错误,是你给所有快递统一贴了同一个收件人标签,系统认为它们都该送到同一个人手里。
所以面试时,如果面试官问"哈希冲突怎么处理",请回答开放寻址法、链地址法以及扩容机制;如果问的是"这道题怎么避免分组错误",请回答键的归一化必须严格保证等价关系。这两个问题不是一个维度。
5. 面试官追问:还能不能更进一步?
写跳出标准答案后,面试官通常会开始追问。这些追问可以分成几个层次,从小的优化到大的架构思维。
5.1 质数乘积:一种巧妙的计数键变体
如果你记得"质因数分解唯一性"这个数学事实,就会想到一个非常精简的键设计:把 26 个字母分别映射到 26 个质数,然后计算字符串里每个字符对应质数的乘积。根据唯一分解定理,质数乘积相同,代表字符集合和数量完全相同。
python复制from collections import defaultdict
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(strs):
groups = defaultdict(list)
for word in strs:
key = 1
for ch in word:
key *= primes[ord(ch) - ord('a')]
groups[key].append(word)
return list(groups.values())
这个方案的时间复杂度也是 O(nk),而且不需要 26 次拼接操作,常数上通常比计数编码串更小。但它在 C++ 里有一个致命问题:溢出。string 最长可能到达上千甚至上万,质数乘积会飞速膨胀,unsigned long long 也撑不住。能用它的前提是字符串不要太长,或者语言本身支持大整数(比如 Python 的 int 是任意精度)。
这种方案适合作为脑力激荡的加分项,不适合当作严格工程实现。面试的时候说出来,会显得你对数论和哈希键的设计有理解,但紧接着要主动说明它的问题,否则容易被认为是只背答案。
5.2 如果输入量极大,怎么处理?
如果输入数组大到内存装不下,单机跑不动,这就是典型的分布式 MapReduce 场景。Map 阶段,对每个字符串计算它的 key(排序结果或计数编码),输出 <key, value>;Shuffle 阶段,相同 key 的数据被打到同一台机器;Reduce 阶段,直接收集成组。
这个思路在思路层面和 LeetCode 这题完全一致:先定义等价键,再按键分组。它不仅在面试里能体现工程思维,在实际的日志聚合、数据清洗场景里也很实用。比如你要统计一批网页请求中"URL 去掉 query 参数后相同"的流量,做法完全一致。
5.3 为什么不能用首字母或者字符串长度当键?
有些人刚开始会想到:相似字符串的首字母相同或者长度相同,于是想用长加首字母当键。这是错误的。因为 "ab" 和 "ac" 长度相同、首字母都是 'a',但它们不是异位词;"abc" 和 "cba" 是异位词,但和 "aabc" 的长度不同。这种粗粒度键会把不应该合并的字符串合并到一起,属于"键设计错误"。
键设计正确性的唯一判据是:互为异位词必须同键,不互为异位词必须不同键。任何一步简化,只要破坏了第二条,就会导致分组错误。
6. 从这道题延伸出去的通用套路
这道题的价值不止于一个解法,它背后有一个通用的场景:如何将一组对象按照某个等价关系分组。这类题在 LeetCode 上反复出现,核心思路是一样的。
6.1 建立"规范化函数"的思维
所谓规范化函数,就是定义一个映射 f,使得两个对象满足等价关系时,f 的结果相同;不满足等价关系时,f 的结果不同。然后你就可以把 f 的结果作为哈希表的键,轻松完成分组。
字母异位词的 f 是"排序后的字符串"或"字符计数编码"。相似的题:判断两个字符串是否同构,f 可以设计成每个字符首次出现位置的映射;判断两个单词是否可以通过调整顺序得到,f 就是排序后的字母序列。
这个思维一旦建立,很多"分组"类题目都会变得简单:你要做的不是从零开始设计算法,而是为对象设计一个合适的规范化函数,然后交给哈希表。
6.2 相似题目有哪些?
几道跟这道题直接相关的题目:
- LeetCode 242:有效的字母异位词。不需要分组,只要判断两个字符串是否互为异位词,用计数数组就能解决。
- LeetCode 438:找到字符串中所有字母异位词。在长串里滑动窗口,用字符计数比较每个窗口是否是目标词的异位词。
- LeetCode 249:移位字符串分组。把字符串循环移位作为等价关系,需要设计一个更抽象的规范化键。
- LeetCode 49 的变体:给一组单词,找字母异位词,但要求输出每个组里所有单词按字典序排序。解法一样,只是收尾时对每组再排序。
你会发现它们的解法核心都是同一个:写一个函数,把同组对象映射到同一个键。
6.3 给自己用的复盘清单
刷完这道题,我建议你花几分钟复盘一下这张清单,比多刷十道重复题更有价值:
- 能不能口算出排序键的时间复杂度和空间复杂度?
- 能不能解释为什么计数键在 C++ 里要编码成字符串?
- 能不能写出 Python 里 tuple 键
groups[tuple(count)].append(s)这一行,而不会踩到 list 不可哈希的坑? - 能不能说清楚"哈希冲突"和"规范化键设计错误"的区别?
- 如果面试官问"质数乘积键为什么可能溢出",能不能直接答上来?
这五个问题都能顺畅答出来,这道题才算真正吸收了。我自己的经验是,算法题刷完之后,花同样多时间复盘"为什么这么做、还有没有别的做法、这个做法在什么场景下会失效",比盲目开新题有效得多。
最后分享一个小技巧:遇到任何"分组"类题目,先在纸上写出等价关系的定义,再画几个例子验证你选的键是否能区分不同组。键没问题,代码通常就是水到渠成的事。
