字母异位词分组 - 详解与优化
问题描述
给你一个字符串数组,请你将 字母异位词 组合在一起。可以按任意顺序返回结果列表。
字母异位词 是由重新排列源单词的所有字母得到的一个新单词。
示例 1:
输入: strs = ["eat", "tea", "tan", "ate", "nat", "bat"]
输出: [["bat"],["nat","tan"],["ate","eat","tea"]]
示例 2:
输入: strs = [""]
输出: [[""]]
示例 3:
输入: strs = ["a"]
输出: [["a"]]
解题思路
核心思想
字母异位词的特点是:它们包含相同的字母,只是字母顺序不同。基于这个特点,我们可以想到几种解决方案:
- 排序法:将每个单词排序,相同的排序结果就是字母异位词。
- 计数法:计算每个单词中字母的出现次数,出现次数相同的就是字母异位词。
- 质数乘积法:用质数表示每个字母,单词的字母对应质数的乘积相同的就是字母异位词。
下面我们详细讲解这几种方法的实现和优化。
方法一:排序法
这是最直观的解法,也是面试中最容易想到和实现的方法。
实现代码
/**
* @param {string[]} strs
* @return {string[][]}
*/
const groupAnagrams = function(strs) {
const map = new Map();
for (let str of strs) {
// 将单词排序作为key
const key = str.split('').sort().join('');
// 使用Map进行分组
if (map.has(key)) {
map.get(key).push(str);
} else {
map.set(key, [str]);
}
}
return Array.from(map.values());
};
解题步骤
- 创建一个 Map 对象来存储分组结果。
- 遍历输入的字符串数组。
- 对每个字符串,将其转为字符数组,排序,再拼接回字符串,作为 Map 的键。
- 如果 Map 中已有该键,将当前字符串加入对应的数组;否则,创建新数组。
- 最后返回 Map 中所有的值,即为分组结果。
复杂度分析
- 时间复杂度:O(n * k * log k),其中 n 是字符串数组的长度,k 是字符串的最大长度。排序需要 O(k * log k) 的时间。
- 空间复杂度:O(n * k),需要存储所有字符串。
优点与缺点
优点:
- 思路直观,易于理解和实现
- 代码简洁,不易出错
缺点:
- 时间复杂度较高,尤其是对于长字符串
方法二:计数法
这种方法通过计算每个字符串中字母的出现次数来判断是否为字母异位词。
实现代码
const groupAnagrams = function(strs) {
const map = new Map();
for (let str of strs) {
const count = new Array(26).fill(0);
for (let char of str) {
count[char.charCodeAt(0) - 'a'.charCodeAt(0)]++;
}
const key = count.join('#');
if (map.has(key)) {
map.get(key).push(str);
} else {
map.set(key, [str]);
}
}
return Array.from(map.values());
};
解题步骤
- 创建一个 Map 对象存储分组结果。
- 遍历输入的字符串数组。
- 对每个字符串,创建一个长度为 26 的数组(对应 26 个小写字母),统计每个字母的出现次数。
- 将计数数组转换为字符串作为 Map 的键。
- 根据键进行分组。
- 返回所有分组。
复杂度分析
- 时间复杂度:O(n * k),其中 n 是字符串数组的长度,k 是字符串的最大长度。
- 空间复杂度:O(n * k)。
优点与缺点
优点:
- 时间复杂度优于排序法
- 不需要改变原字符串的顺序
缺点:
- 需要额外的空间来存储计数数组
- 键的生成可能较慢(涉及数组连接操作)
方法三:质数乘积法
这是一种更加巧妙的方法,利用质数的唯一分解定理。
实现代码
const groupAnagrams = function(strs) {
const 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];
const map = new Map();
for (let str of strs) {
let key = 1;
for (let char of str) {
key *= primes[char.charCodeAt(0) - 'a'.charCodeAt(0)];
}
if (map.has(key)) {
map.get(key).push(str);
} else {
map.set(key, [str]);
}
}
return Array.from(map.values());
};
解题步骤
- 创建一个质数数组,每个质数对应一个小写字母。
- 遍历输入的字符串数组。
- 对每个字符串,计算其中所有字母对应质数的乘积。
- 使用这个乘积作为 Map 的键进行分组。
- 返回所有分组。
复杂度分析
- 时间复杂度:O(n * k),其中 n 是字符串数组的长度,k 是字符串的最大长度。
- 空间复杂度:O(n)。
优点与缺点
优点:
- 时间复杂度优秀
- 键的生成速度快(只涉及乘法运算)
缺点:
- 可能会有数值溢出的风险,特别是对于很长的字符串
- 理解起来稍微复杂一些
优化与改进
在实际应用中,我们还可以进行一些优化:
-
特殊情况处理:对空数组或只有一个元素的数组进行快速处理。
-
使用位运算:如果只需判断字母是否出现而不关心次数,可以使用位运算来进一步优化空间使用。
-
更高效的键生成:使用
String.fromCharCode来生成键,比join方法更高效。
const groupAnagrams = function(strs) {
if (strs.length <= 1) return [strs];
const map = new Map();
const count = new Array(26);
for (let str of strs) {
for (let i = 0; i < 26; i++) count[i] = 0;
for (let i = 0; i < str.length; i++) {
count[str.charCodeAt(i) - 97]++;
}
const key = String.fromCharCode(...count);
if (map.has(key)) {
map.get(key).push(str);
} else {
map.set(key, [str]);
}
}
return Array.from(map.values());
};
总结
字母异位词分组是一个经典的算法问题,它考察了候选人对字符串处理、哈希表使用和算法优化的理解。在解决这个问题时,我们需要考虑以下几点:
- 问题分析:理解字母异位词的本质特征。
- 数据结构选择:使用 Map 来存储分组结果,既高效又直观。
- 算法设计:根据不同的思路(排序、计数、质数乘积),设计相应的算法。
- 优化思路:考虑时间复杂度和空间复杂度的平衡,针对不同场景选择最适合的方法。
- 代码实现:编写清晰、简洁、高效的代码。
这个问题的不同解法展示了如何从最直观的方法逐步优化到更高效的算法,是算法设计和优化的一个很好的例子。在面试中,候选人可以先给出最容易理解的解法,然后逐步优化,这样可以全面展示自己的问题解决能力和算法优化思维。
延伸思考
- 如果输入的字符串包含 Unicode 字符,我们的算法需要如何调整?
- 在分布式系统中,如果要处理大规模的字符串数据,我们应该如何设计算法?
- 这个问题是否可以应用到其他领域,比如图像处理中的相似图像分组?
通过深入思考这些问题,我们可以进一步提升对算法和数据结构的理解,为处理更复杂的实际问题做好准备。