还在用排序找异位词?你可能错过了最优解

58 阅读4分钟

从超时到滑动窗口:我是如何用「字母计数」优化「找异位词」算法的

今天我要带你复盘一道经典的字符串匹配题——寻找所有异位词。我会从一个“看似合理却惨遭超时”的暴力解法出发,一步步演进到高效的滑动窗口方案。这不仅是一道算法题,更是性能优化思维的实战演练

先看题目:438. 找到字符串中所有字母异位词 - 力扣(LeetCode)

image.png


🚨 第一版:暴力排序法(超时警告!)

最直观的想法是什么?

“既然是异位词,那只要两个字符串排序后相等,不就说明它们是异位词了吗?”

于是,我写出了第一版代码:

// ❌ 超时版本(仅用于演示)
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'

所以,不需要每次都重新统计整个子串!我们可以:

  1. 先统计第一个窗口 s[0..m-1] 的频次;

  2. 窗口右移时:

    • 减去左边离开的字符;
    • 加上右边新进的字符;
  3. 每次只需 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

💡 经验总结:如何避免“超时陷阱”?

  1. 警惕重复计算:相邻状态是否有重叠?能否增量更新?
  2. 用空间换时间:计数数组、哈希表、前缀和都是好帮手。
  3. 滑动窗口是字符串匹配的利器:适用于“固定长度子串”问题。
  4. 字母题优先考虑计数:26 个字母 → 固定大小数组,O(1) 查询。

最后感悟
算法优化不是“换个更快的语言”,而是用更聪明的方式思考问题
从暴力到滑动窗口,不仅是代码的进化,更是思维模式的升级

愿你的每一行代码,都远离超时,拥抱高效!✨