bigywater.tech
算法与数据结构 · LeetCode 49

LeetCode 49. 字母异位词分组

给定一个字符串数组,把互为字母异位词(字母相同、排列不同)的字符串分到同一组。核心问题是"怎么判断两个字符串是异位词":把每个字符串的字母排序,异位词排序后完全相同,把这个排序后的字符串当作哈希表的键,同一个键下的字符串就是一组。另一种键是 26 个字母各自出现的次数。本文用真实数据展示"用字符集合当键"会丢掉什么,实测排序键与计数键的耗时在字符串长度不同时的差别,给出五种语言的实现,并把哈希分组的过程做成分步演示。

5,000
组随机对拍,与暴力解的结果全部一致
8 步
分步演示,每一帧都来自解法的真实执行,另附 5 种语言实现

题意与示例

完整题目见官方页面(链接在文末)。分组之间的先后、组内字符串的先后都不作要求,核对时先把每组内部排序,再把所有组排序后比较。基准解是逐个字符串与已有分组的第一个比较字母计数(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())
真实实测 · Python · 前几组用例的输出
$ 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()); } }
真实实测 · Java · 前几组用例的输出
$ 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 · 前几组用例的输出
$ 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()]; }
真实实测 · JavaScript · 前几组用例的输出
$ 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; } };
真实实测 · C++ · 前几组用例的输出
$ 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

排序当键 vs 计数当键

如果字符串只包含小写字母,还可以用 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,落在同一组。

字母异位词分组:['eat', 'tea', 'tan', 'ate', 'nat', 'bat']
正在处理的字符串已经分好组的字符串

    参考与说明

    • 官方题目页:LeetCode 49. 字母异位词分组。本文只用自己的话概括了题意,完整约束以官方页面为准。
    • 数据来自本机真实运行,五种语言的版本写在页脚;耗时对比只用 Python 测量。
    • 没有覆盖:计数键在 Java、Go、JavaScript、C++ 里的实现;包含大写字母或非 ASCII 字符时键的设计。
    京ICP备2026067502号-1京公网安备11010502063487号