给定一个字符串数组,把互为字母异位词(字母相同、排列不同)的字符串分到同一组。核心问题是"怎么判断两个字符串是异位词":把每个字符串的字母排序,异位词排序后完全相同,把这个排序后的字符串当作哈希表的键,同一个键下的字符串就是一组。另一种键是 26 个字母各自出现的次数。本文用真实数据展示"用字符集合当键"会丢掉什么,实测排序键与计数键的耗时在字符串长度不同时的差别,给出五种语言的实现,并把哈希分组的过程做成分步演示。
完整题目见官方页面(链接在文末)。分组之间的先后、组内字符串的先后都不作要求,核对时先把每组内部排序,再把所有组排序后比较。基准解是逐个字符串与已有分组的第一个比较字母计数(Counter),与哈希键是两种独立的写法。一共 12 组用例,覆盖:官方示例、只有一个空字符串、两个空字符串、重复的字符串、同样的字母种类但数量不同的字符串:
输入 [["eat","tea","tan","ate","nat","bat"]] 输出 [["eat","tea","ate"],["tan","nat"],["bat"]] 输入 [[""]] 输出 [[""]] 输入 [["a"]] 输出 [["a"]] 输入 [["",""]] 输出 [["",""]] 输入 [["ab","ba","ab"]] 输出 [["ab","ba","ab"]] 输入 [["abc","cab","bca","abd"]] 输出 [["abc","cab","bca"],["abd"]]
一个容易想到的错误:用字符串里有哪些字母(集合)当键。但异位词要求每个字母出现的次数也相同,集合把次数丢掉了。下面是真实结果:
输入 ['aab', 'abb', 'ab', 'abab', 'ba'] 用字符集合当键: [['aab', 'abb', 'ab', 'abab', 'ba']] solve(排序后的字符串当键): [['aab'], ['abb'], ['ab', 'ba'], ['abab']]
aab、abb、ab、abab、ba 里,字母种类都是 a 和 b,用集合当键把它们全部分到了同一组,而实际上 aab、abb、abab 各自独立,只有 ab 和 ba 是异位词。
对每个字符串,把它的字符排序得到键,放进哈希表,键相同的字符串放在同一个列表里,最后取出所有列表。Python 用字典,Java 用 LinkedHashMap(保持首次出现的顺序),Go 用"键到结果下标"的映射加切片(这样输出顺序是确定的,不依赖 Go 的 map 遍历顺序),JavaScript 用 Map,C++ 用 unordered_map 记录下标。Python、Java、Go、JavaScript、C++ 都是真实编译运行的。
Python · 解法代码def solve(strs): groups = {} for s in strs: key = "".join(sorted(s)) # 字母异位词排序后完全相同,把它当作分组的键 groups.setdefault(key, []).append(s) return list(groups.values())
$ python3 solution.py # 统一入口:逐行读取 cases.jsonl,调用上面的 solve,打印 JSON [["eat","tea","tan","ate","nat","bat"]] → [["eat","tea","ate"],["tan","nat"],["bat"]] [[""]] → [[""]] [["a"]] → [["a"]] [["",""]] → [["",""]] [["ab","ba","ab"]] → [["ab","ba","ab"]] [["abc","cab","bca","abd"]] → [["abc","cab","bca"],["abd"]]
Java · 解法代码class Solution { public List<List<String>> groupAnagrams(String[] strs) { Map<String, List<String>> groups = new LinkedHashMap<>(); for (String s : strs) { char[] cs = s.toCharArray(); Arrays.sort(cs); // 字母异位词排序后完全相同,把它当作分组的键 groups.computeIfAbsent(new String(cs), k -> new ArrayList<>()).add(s); } return new ArrayList<>(groups.values()); } }
$ javac -d build Main.java Solution.java && java -cp build Main < cases.jsonl [["eat","tea","tan","ate","nat","bat"]] → [["eat","tea","ate"],["tan","nat"],["bat"]] [[""]] → [[""]] [["a"]] → [["a"]] [["",""]] → [["",""]] [["ab","ba","ab"]] → [["ab","ba","ab"]] [["abc","cab","bca","abd"]] → [["abc","cab","bca"],["abd"]]
Go · 解法代码import "sort" func groupAnagrams(strs []string) [][]string { index := map[string]int{} // 排序后的字符串 → 它在 res 里的位置 res := [][]string{} for _, s := range strs { b := []byte(s) sort.Slice(b, func(i, j int) bool { return b[i] < b[j] }) // 字母异位词排序后完全相同,把它当作分组的键 key := string(b) if k, ok := index[key]; ok { res[k] = append(res[k], s) } else { index[key] = len(res) res = append(res, []string{s}) } } return res }
$ go build -o bin . && ./bin < cases.jsonl [["eat","tea","tan","ate","nat","bat"]] → [["eat","tea","ate"],["tan","nat"],["bat"]] [[""]] → [[""]] [["a"]] → [["a"]] [["",""]] → [["",""]] [["ab","ba","ab"]] → [["ab","ba","ab"]] [["abc","cab","bca","abd"]] → [["abc","cab","bca"],["abd"]]
JavaScript · 解法代码function groupAnagrams(strs) { const groups = new Map(); for (const s of strs) { const key = s.split("").sort().join(""); // 字母异位词排序后完全相同,把它当作分组的键 if (!groups.has(key)) groups.set(key, []); groups.get(key).push(s); } return [...groups.values()]; }
$ node run.js < cases.jsonl [["eat","tea","tan","ate","nat","bat"]] → [["eat","tea","ate"],["tan","nat"],["bat"]] [[""]] → [[""]] [["a"]] → [["a"]] [["",""]] → [["",""]] [["ab","ba","ab"]] → [["ab","ba","ab"]] [["abc","cab","bca","abd"]] → [["abc","cab","bca"],["abd"]]
C++ · 解法代码#include <algorithm> #include <string> #include <unordered_map> #include <vector> using namespace std; class Solution { public: vector<vector<string>> groupAnagrams(vector<string>& strs) { unordered_map<string, int> index; // 排序后的字符串 → 它在 res 里的位置 vector<vector<string>> res; for (auto& s : strs) { string key = s; sort(key.begin(), key.end()); // 字母异位词排序后完全相同,把它当作分组的键 auto it = index.find(key); if (it == index.end()) { index[key] = res.size(); res.push_back({s}); } else { res[it->second].push_back(s); } } return res; } };
$ clang++ -std=c++17 -O2 -o bin sol.cpp && ./bin < cases.jsonl [["eat","tea","tan","ate","nat","bat"]] → [["eat","tea","ate"],["tan","nat"],["bat"]] [[""]] → [[""]] [["a"]] → [["a"]] [["",""]] → [["",""]] [["ab","ba","ab"]] → [["ab","ba","ab"]] [["abc","cab","bca","abd"]] → [["abc","cab","bca"],["abd"]]
共 12 组用例(含边界),以暴力解的结果为基准逐组比对: Python 通过 12/12 Java 通过 12/12 Go 通过 12/12 JavaScript 通过 12/12 C++ 通过 12/12
如果字符串只包含小写字母,还可以用 26 个字母各自出现的次数(一个长度 26 的元组)当键,避免排序。排序的代价是 O(L log L)(L 是字符串长度),计数是 O(L)。我造了 2000 个字符串,每个都是从 10 个基础串之一随机打乱得到,保证有大量异位词,在三种长度下比较两种键的耗时(每个取三次中最快的一次),并确认分组结果一致:
每个字符串长度为 L 的 2000 个随机小写字符串(每个字符串取自其中一个 10 个基础串的随机排列,保证有大量异位词):排序当键 vs 计数当键 L= 10: 排序当键 0.90 ms 计数当键 1.25 ms 分组一致=True(共 10 组) L= 100: 排序当键 12.39 ms 计数当键 6.23 ms 分组一致=True(共 10 组) L=1000: 排序当键 160.10 ms 计数当键 55.40 ms 分组一致=True(共 10 组)
字符串很短时(L = 10),排序当键更快(0.90ms 对 1.25ms),因为计数需要建一个 26 长度的数组再转成元组,固定开销更大;字符串更长时计数当键更快:L = 100 时 12.4ms 对 6.2ms,L = 1000 时 160.1ms 对 55.4ms(约 2.9 倍)。这只是 Python 里的实测,其他语言里排序和计数各自的相对开销可能不同。
每个字符串长 6,取自字母 a 到 e,字符串个数 n 分别为 1000、3000、1 万、3 万,每个规模取三次中最快的一次。基准解要逐个与已有分组比较,最坏是 O(n × 组数):
字符串个数 n(每个长 6,字母 a-e) | 暴力解 | 本文解法
1000 | 4.905 ms | 0.272 ms
3000 | 14.568 ms | 0.820 ms
10000 | 48.342 ms | 2.712 ms
30000 | (未测,太慢) | 8.440 ms本文解法从 1000 到 3 万(30 倍)耗时增长约 31 倍,是线性;基准解从 1000 到 1 万增长约 9.9 倍。n = 1 万时基准 48.3ms,哈希分组 2.7ms,约 18 倍。基准在 n = 3 万时没有再测。
Python 版本另外和基准解做了随机对拍,字符串由 a、b、c 组成,长 0 到 4,包含空串和重复的字符串:
随机对拍 5000 组(0 到 10 个字符串,每个由 a、b、c 组成、长 0 到 4(包含空串和重复的字符串);结果按「分组的集合」比较,不要求分组或组内的先后),solve 与 brute 的结果全部一致。
另外四种语言只在上面 12 组固定用例上验证过,没有做大规模随机对拍。
下面的演示用官方示例。每一帧都是把上面的解法在这组输入上真实执行记录下来的,运行器还检查了演示的最终分组与 solve 的返回值一致。eat、tea、ate 排序后都是 aet,落在同一组。