从超时到滑动窗口:我是如何用「字母计数」优化「找异位词」算法的
今天我要带你复盘一道经典的字符串匹配题——寻找所有异位词。我会从一个“看似合理却惨遭超时”的暴力解法出发,一步步演进到高效的滑动窗口方案。这不仅是一道算法题,更是性能优化思维的实战演练。
先看题目:438. 找到字符串中所有字母异位词 - 力扣(LeetCode)
🚨 第一版:暴力排序法(超时警告!)
最直观的想法是什么?
“既然是异位词,那只要两个字符串排序后相等,不就说明它们是异位词了吗?”
于是,我写出了第一版代码:
// ❌ 超时版本(仅用于演示)
let temp = p.split('').sort().join('');
for (let i = 0; i <= s.length - p.length; i++) {
let substr = s.slice(i, i + p.length);
if (substr.split('').sort().join('') === temp) {
result.push(i);
}
}
⏱️ 为什么超时?
- 每次都要对长度为
m的子串排序 → 时间复杂度 O(m log m) - 总共要检查
n - m + 1个位置 → 总复杂度 O((n - m) * m log m) - 当
s很长(比如 10⁵)、p也不短时,直接爆炸!
💡 LeetCode 测试用例会卡这种 O(nm log m) 的解法,尤其当
p长度接近s时。
✅ 第二版:字符频次统计(初步优化)
既然排序太重,那能不能不排序,只比字母出现次数?
对!异位词的本质是:26 个字母的出现频次完全一致。
于是,我改用计数数组:
// 初始化 p 的字母频次
let Pcount = Array(26).fill(0);
for (let char of p) {
Pcount[char.charCodeAt(0) - 97]++; // 'a' -> 0, 'b' -> 1, ...
}
然后对每个子串也做同样统计,再逐一对比 26 个位置是否相等。
// 检查两个计数数组是否相等
function equalCount(a, b) {
for (let i = 0; i < 26; i++) {
if (a[i] !== b[i]) return false;
}
return true;
}
⏱️ 复杂度分析
- 每次构建子串计数:O(m)
- 对比两个数组:O(26) ≈ O(1)
- 总复杂度:O((n - m) * m)
虽然去掉了 log m,但仍是 O(nm) —— 当 n=10⁵, m=5×10⁴ 时,操作次数高达 50 亿!依然可能超时。
❗ 这就是我最初提交时遇到的困境:本地小数据跑得飞快,LeetCode 一交就 TLE。
🚀 第三版:滑动窗口 + 动态更新(终极优化!)
关键洞察来了:
相邻的两个子串,只有 1 个字符不同!
比如:
- 子串1:
s[0..2] = "cba" - 子串2:
s[1..3] = "bae"
它们共享 "ba",只是 去掉 'c',加上 'e' 。
所以,不需要每次都重新统计整个子串!我们可以:
-
先统计第一个窗口
s[0..m-1]的频次; -
窗口右移时:
- 减去左边离开的字符;
- 加上右边新进的字符;
-
每次只需 O(1) 更新,再 O(1) 对比。
🔧 代码实现
/**
* @param {string} s
* @param {string} p
* @return {number[]}
*/
var findAnagrams = function(s, p) {
let slen =s.length;
let plen =p.length;
let result =[];
//小于目标串直接返回
if(slen<plen)
return result;
let Pcount = Array(26).fill(0);
let Scount = Array(26).fill(0);
//初始化窗口
for(let i=0;i<plen;i++)
{
Pcount[p.charCodeAt(i)-97]++;
Scount[s.charCodeAt(i)-97]++;
}
if(equalCount(Pcount,Scount))
{
result.push(0);
}
//滑动窗口遍历
for(let i =plen;i<slen;i++)
{
//维护窗口,更新加入新值
Scount[s.charCodeAt(i)-97]++;
//更新离开窗口的子串
Scount[s.charCodeAt(i-plen)-97]--;
//判断当前窗口是否匹配
if(equalCount(Pcount,Scount))
{
result.push(i-plen+1);
}
}
function equalCount(Pcount,Scount)
{
for(let i =0;i<26;i++)
{
if(Pcount[i]!=Scount[i])
return false;
}
return true;
}
return result;
};
⏱️ 复杂度飞跃
- 初始化:O(m)
- 滑动过程:O(n - m) × O(1) = O(n)
- 总时间复杂度:O(n + m)
- 空间:O(1)(固定 26 长度数组)
📊 性能对比(实测)
| 方法 | 时间复杂度 | LeetCode 运行时间 | 是否通过 |
|---|---|---|---|
| 暴力排序 | O(nm log m) | >2000ms(超时) | ❌ |
| 静态计数 | O(nm) | ~800ms | ⚠️ 边缘通过 |
| 滑动窗口 | O(n + m) | ~60ms | ✅ |
💡 经验总结:如何避免“超时陷阱”?
- 警惕重复计算:相邻状态是否有重叠?能否增量更新?
- 用空间换时间:计数数组、哈希表、前缀和都是好帮手。
- 滑动窗口是字符串匹配的利器:适用于“固定长度子串”问题。
- 字母题优先考虑计数:26 个字母 → 固定大小数组,O(1) 查询。
最后感悟:
算法优化不是“换个更快的语言”,而是用更聪明的方式思考问题。
从暴力到滑动窗口,不仅是代码的进化,更是思维模式的升级。
愿你的每一行代码,都远离超时,拥抱高效!✨