1. 问题背景与核心思路
字母异位词分组是LeetCode上经典的字符串处理问题,编号49。题目要求给定一个字符串数组,将字母异位词组合在一起。字母异位词指的是字母相同但排列不同的字符串,比如"eat"、"tea"、"ate"就是一组字母异位词。
这个问题的关键在于如何高效判断两个字符串是否为字母异位词。常见思路有三种:
-
排序法:将每个字符串排序后作为key,相同key的字符串即为字母异位词。时间复杂度O(NKlogK),其中N是字符串数量,K是字符串最大长度。
-
计数法:统计每个字符串中各个字母的出现次数,将计数结果作为key。时间复杂度O(NK),空间复杂度略高。
-
质数乘积法:给每个字母分配一个质数,计算字符串的质数乘积作为key。这种方法理论上最快但可能溢出。
在C++实现中,排序法代码最简洁且实际运行效率不错,适合面试和日常刷题使用。下面我们重点讲解这种实现方式。
2. C++实现详解
2.1 基础版本实现
我们先看最直接的实现方式:
cpp复制#include <vector>
#include <string>
#include <unordered_map>
#include <algorithm>
using namespace std;
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>> result;
for (auto& p : mp) {
result.push_back(p.second);
}
return result;
}
这个版本的核心逻辑:
- 使用unordered_map存储分组,key是排序后的字符串
- 遍历每个字符串,先排序得到key,然后将原字符串存入对应分组
- 最后将map中的value转换为vector返回
2.2 性能优化版本
基础版本有些可以优化的地方:
- 避免在循环内创建临时string
- 预分配结果vector空间
- 使用移动语义减少拷贝
优化后的代码:
cpp复制vector<vector<string>> groupAnagrams(vector<string>& strs) {
unordered_map<string, vector<string>> mp;
mp.reserve(strs.size()); // 预分配空间
for (const string& s : strs) {
string key = s;
sort(key.begin(), key.end());
mp[move(key)].emplace_back(s); // 使用移动语义
}
vector<vector<string>> result;
result.reserve(mp.size()); // 预分配空间
for (auto& p : mp) {
result.emplace_back(move(p.second)); // 使用移动语义
}
return result;
}
这些优化在小数据量时差异不大,但当输入规模较大时(如10^4量级),性能提升明显。
3. 关键知识点解析
3.1 unordered_map的使用技巧
在这个问题中,unordered_map比map更合适,因为:
- 不需要key有序
- 平均情况下unordered_map的插入和查询都是O(1)复杂度
使用时的注意事项:
- 对于自定义类型作为key,需要提供hash函数和相等比较
- reserve()可以预先分配空间,避免rehash带来的性能损耗
- 查找元素时最好用find()而不是直接访问,避免意外插入
3.2 排序算法的选择
标准库的sort()对于字符串排序已经足够高效,但了解其原理有助于面试:
- 对于短字符串(长度≤32),sort()使用插入排序
- 对于长字符串,使用快速排序+堆排序的混合算法(introsort)
- 平均时间复杂度O(NlogN)
如果字符串长度差异很大,可以考虑先按长度分组再处理,但会增加代码复杂度。
3.3 移动语义的应用
现代C++中,移动语义可以显著提升性能:
- move()将左值转为右值引用,避免不必要的拷贝
- emplace_back()直接在容器内构造对象,比push_back()更高效
- 对于像vector这样的容器,移动操作只需复制三个指针(begin,end,capacity)
4. 边界条件与测试用例
完整的解决方案需要考虑各种边界情况:
- 空输入:
[]→ 返回[] - 单个字符串:
["a"]→ 返回[["a"]] - 所有字符串相同:
["a","a","a"]→ 返回[["a","a","a"]] - 无字母异位词:
["abc","def","ghi"]→ 返回[["abc"],["def"],["ghi"]] - 大小写敏感:题目通常说明只考虑小写字母
- 包含空字符串:
["",""]→ 返回[["",""]]
测试时可以这样验证:
cpp复制void test() {
vector<string> input = {"eat","tea","tan","ate","nat","bat"};
auto result = groupAnagrams(input);
for (auto& group : result) {
for (string& s : group) {
cout << s << " ";
}
cout << endl;
}
// 预期输出三组:
// eat tea ate
// tan nat
// bat
}
5. 其他语言实现对比
虽然题目要求C++实现,但了解其他语言的写法有助于拓宽思路:
5.1 Python实现
python复制def groupAnagrams(strs):
d = {}
for s in strs:
key = tuple(sorted(s))
d[key] = d.get(key, []) + [s]
return list(d.values())
Python版本更简洁,利用了tuple的可哈希特性。
5.2 Java实现
java复制public List<List<String>> groupAnagrams(String[] strs) {
Map<String, List<String>> map = new HashMap<>();
for (String s : strs) {
char[] ca = s.toCharArray();
Arrays.sort(ca);
String key = String.valueOf(ca);
map.computeIfAbsent(key, k -> new ArrayList<>()).add(s);
}
return new ArrayList<>(map.values());
}
Java 8的computeIfAbsent方法让代码更简洁。
6. 复杂度分析与优化空间
6.1 时间复杂度
设N是字符串数量,K是字符串最大长度:
- 排序每个字符串:O(KlogK)
- N个字符串:O(NKlogK)
- 哈希表操作:O(1)平均情况下
- 总体:O(NKlogK)
6.2 空间复杂度
- 哈希表存储所有字符串:O(NK)
- 排序时的临时空间:O(K)(原地排序)
- 总体:O(NK)
6.3 可能的优化方向
- 如果字符串长度差异大,可以先按长度分组再处理
- 对于ASCII字符串,计数法可以用固定大小的数组代替排序
- 并行处理:多线程分别处理不同字符串(但要注意哈希表的线程安全)
7. 实际工程中的应用
这类字符串分组问题在实际工程中很常见:
- 日志分析:将相似错误信息的日志分组
- 数据清洗:识别并合并相似但不完全相同的记录
- 生物信息学:DNA序列的相似性分析
- 拼写检查:找出可能的拼写错误变体
理解这个算法有助于解决更复杂的实际问题。例如,可以扩展为支持:
- 模糊匹配(允许少量字符不同)
- 加权比较(某些字符更重要)
- 大规模分布式处理
8. 常见错误与调试技巧
新手实现时容易犯的错误:
- 忘记排序直接比较字符串
- 使用map而非unordered_map导致性能下降
- 没有处理空输入的情况
- 错误地使用了移动语义导致后续访问出错
调试时可以:
- 打印中间key的值
- 检查哈希表的大小是否符合预期
- 对每个分组单独验证是否为字母异位词
- 使用valgrind等工具检查内存问题
9. 扩展思考
这个问题可以有多种变体:
- 分组大小超过一定阈值才输出
- 找出最大的字母异位词组
- 允许最多一个字符不同的"近似"字母异位词
- 多语言支持(如处理Unicode字符)
每种变体都需要调整核心算法,但基本思路仍然适用。理解基础问题的解法是解决变体的关键。
