在 LeetCode 上刷题时,经常会遇到一类经典问题:在主串中找出所有与目标串互为字母异位词(Anagram)的子串。这类题目看似简单,但若采用暴力解法,很容易陷入性能瓶颈。今天我想和大家分享一种更优雅、高效的解法——滑动窗口 + 哈希表(计数数组) 。
什么是字母异位词?
首先明确一下概念:两个字符串互为字母异位词,意味着它们包含完全相同的字符,且每个字符出现的次数也完全相同,只是顺序不同。例如 "abc" 和 "bca" 就是一对字母异位词。
因此,判断两个字符串是否为字母异位词的关键,并不在于字符的顺序,而在于字符频次的一致性。
暴力解法为何不可取?
最初我想到的思路是:
- 在字符串
s中,每次取出长度等于p的子串; - 对该子串进行
split → sort → join,得到一个标准化的字符串; - 与同样处理过的
p进行比较; - 如果相等,就记录当前起始索引。
这种方法逻辑清晰,但时间复杂度非常高
滑动窗口 + 计数数组:优化的核心思想
既然字母异位词只关心字符频次,那我们完全可以用一个长度为 26 的数组来统计每个字母出现的次数(假设只包含小写字母)。这样,判断两个字符串是否为异位词,就变成了比较两个计数数组是否完全相等。
而“滑动窗口”技巧,则能让我们在移动窗口时,只更新进出窗口的字符计数,避免重复遍历整个子串。
具体实现步骤
以下是我在 LeetCode 上通过的 JavaScript 代码,配合详细注释:
var findAnagrams = function(s, p) {
// 如果 p 比 s 还长,肯定找不到,直接返回空数组
if (p.length > s.length) return [];
// 创建两个长度为 26 的计数数组 初始时用0填充
const need = new Array(26).fill(0); // 记录 p 中每个字母需要多少个
const window = new Array(26).fill(0); // 记录当前滑动窗口中每个字母有多少个
// 辅助函数:将字符 'a'~'z' 转换为 0~25 的索引
function charToIndex(char) {
return char.charCodeAt(0) - 'a'.charCodeAt(0);
}
// 第一步:统计 p 中每个字符的出现次数
for (let char of p) {
need[charToIndex(char)]++;
}
// 第二步:初始化滑动窗口(前 p.length 个字符)
for (let i = 0; i < p.length; i++) {
window[charToIndex(s[i])]++;
}
const result = [];
// 第三步:检查初始窗口是否匹配
if (arraysEqual(window, need)) {
result.push(0);
}
// 第四步:滑动窗口向右移动
for (let i = p.length; i < s.length; i++) {
// 左边字符滑出窗口
const leftChar = s[i - p.length];
window[charToIndex(leftChar)]--;
// 右边字符滑入窗口
const rightChar = s[i];
window[charToIndex(rightChar)]++;
// 检查当前窗口是否与 p 构成异位词
if (arraysEqual(window, need)) {
result.push(i - p.length + 1);
}
}
return result;
};
// 辅助函数:判断两个长度为 26 的数组是否完全相等
function arraysEqual(a, b) {
for (let i = 0; i < 26; i++) {
if (a[i] !== b[i]) return false;
}
return true;
}
算法优势分析
- 时间复杂度:O(n),其中
n是字符串s的长度。每个字符最多被访问两次(进窗口和出窗口),计数数组比较固定为 26 次。 - 空间复杂度:O(1),因为计数数组大小固定为 26,不随输入规模变化。
相比暴力解法,效率提升非常明显,尤其在处理长字符串时优势巨大。
总结
这道题很好地展示了滑动窗口与哈希思想(计数数组) 的结合威力。它提醒我们:在处理字符串匹配、子串查找等问题时,不要只盯着字符本身,更要关注其内在的统计特征。
如果你也在刷 LeetCode,不妨多思考:能否用“状态压缩”或“频次统计”代替逐字比较?很多时候,换个角度,就能从 O(n²) 优化到 O(n)。
希望这篇分享对你有帮助!欢迎在评论区交流你的解题思路~