字母异位词(Anagram)是指由相同字母重新排列组合形成的不同单词。比如"eat"、"tea"、"ate"就是一组字母异位词。这个问题要求我们将给定的字符串数组中的字母异位词分组归类。
这个问题的关键在于如何高效地判断两个字符串是否为字母异位词。最直观的方法是检查两个字符串是否包含完全相同的字母,只是顺序不同。但在实际编程实现中,我们需要考虑更高效的判断方式。
字母异位词有两个重要特征:
输入是一个字符串数组,例如:
cpp复制["eat", "tea", "tan", "ate", "nat", "bat"]
期望输出是将字母异位词分组后的二维数组:
cpp复制[["bat"],["nat","tan"],["ate","eat","tea"]]
最直接的思路是将每个字符串排序,排序后的字符串作为分组的key。因为字母异位词排序后会得到相同的字符串。
实现步骤:
时间复杂度分析:
空间复杂度:O(n*k),需要存储所有字符串
更优化的方法是统计每个字符串中各个字母的出现次数,将计数结果作为分组的key。
实现步骤:
时间复杂度分析:
空间复杂度:O(n*k)
| 方法 | 时间复杂度 | 空间复杂度 | 适用场景 |
|---|---|---|---|
| 排序法 | O(n*klogk) | O(n*k) | 字符串较短时效率高 |
| 计数法 | O(n*k) | O(n*k) | 字符串较长时更优 |
在实际应用中,当字符串平均长度较小时(k<10),排序法的常数因子较小,可能表现更好;当字符串较长时,计数法的优势会更明显。
cpp复制class Solution {
public:
vector<vector<string>> groupAnagrams(vector<string>& strs) {
unordered_map<string, vector<string>> mp;
for (string &str : strs) {
string key = str;
sort(key.begin(), key.end());
mp[key].emplace_back(str);
}
vector<vector<string>> ans;
for (auto &pair : mp) {
ans.emplace_back(pair.second);
}
return ans;
}
};
关键点说明:
unordered_map存储分组,查找效率O(1)emplace_back比push_back更高效,避免不必要的拷贝cpp复制class Solution {
public:
vector<vector<string>> groupAnagrams(vector<string>& strs) {
unordered_map<string, vector<string>> mp;
for (string &str : strs) {
vector<int> count(26, 0);
for (char &c : str) {
count[c - 'a']++;
}
string key;
for (int i = 0; i < 26; ++i) {
key += to_string(count[i]) + '#';
}
mp[key].emplace_back(str);
}
vector<vector<string>> ans;
for (auto &pair : mp) {
ans.emplace_back(pair.second);
}
return ans;
}
};
关键点说明:
当输入包含空字符串时:
cpp复制输入: [""]
输出: [[""]]
两种方法都能正确处理这种情况,空字符串排序后仍为空字符串,计数结果全为0。
当输入为单个字符时:
cpp复制输入: ["a"]
输出: [["a"]]
这也是边界情况,两种方法都能正确处理。
字母异位词分组在实际中有多种应用:
在面试中遇到这个问题时:
在计数法中,我们需要将数字计数转换为字符串key。如果不使用分隔符,像"12"和"1""2"会产生相同的key:
使用'#'分隔后:
C++中的sort函数是不稳定排序,但对于这个问题不影响结果,因为我们只关心排序后的字符串是否相同,不关心原始顺序。
使用unordered_map而不是map,因为:
如果字符串很多且很长,可以考虑:
根据实际场景选择合适的方法:
在实际编程竞赛中,排序法实现简单,代码量少,通常是首选。在生产环境中,如果性能关键,可能需要根据实际数据特点选择或实现混合策略。
好的测试用例应该包括:
示例测试用例:
cpp复制vector<string> test1 = {"eat","tea","tan","ate","nat","bat"};
vector<string> test2 = {""};
vector<string> test3 = {"a"};
vector<string> test4 = {"",""};
vector<string> test5(10000, "abcdefghijklmnopqrstuvwxyz");
emplace_back避免不必要的拷贝Python实现示例(排序法):
python复制def groupAnagrams(strs):
d = {}
for s in strs:
key = tuple(sorted(s))
d[key] = d.get(key, []) + [s]
return list(d.values())
Java实现示例(计数法):
java复制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']++;
String key = Arrays.toString(count);
map.computeIfAbsent(key, k -> new ArrayList<>()).add(s);
}
return new ArrayList<>(map.values());
}
对于非常大的数据集,可以考虑并行处理:
根据字符串长度动态选择方法:
cpp复制if (str.length() < threshold) {
// 使用排序法
} else {
// 使用计数法
}
计数法本质上是在设计一个哈希函数,将字母异位词映射到相同的key。好的哈希函数应该:
从数学角度看,字母异位词构成了一个等价类,其中:
对于超大规模数据(如全网文本),可以将问题分布到多台机器:
可以类比整理扑克牌:
在实际编码中,我发现几个值得注意的点:
key生成效率:计数法中字符串拼接可能成为瓶颈,可以考虑预分配内存或使用更高效的方法生成key。
哈希冲突:虽然理论上计数法的key不会冲突,但实现时要注意分隔符的选择,避免意外冲突。
缓存友好性:排序法对短字符串更高效的部分原因是CPU缓存友好,小数据量时排序非常快。
代码简洁性:有时候为了微小的性能提升而增加代码复杂度并不值得,需要权衡。
测试覆盖:特别注意边界条件测试,如空字符串、重复字符串、所有字符串相同等情况。