LC 438 找到所有字母异位词:暴力超时后,我靠滑动窗口一招搞定

169 阅读7分钟

不知道你有没有这种感觉:刷字符串子串题的时候,脑子第一反应永远是暴力截取 + 对比。我当初刷这道题的时候就是这德行,心想不就是找字母异位词吗?截出来排个序对比不就完了?结果写完跑示例秒过,一提交直接超时,给我整不会了。

题目说穿了很简单

给两个字符串 s 和 p,找出 s 里所有是 p 的异位词的子串,返回它们的起始下标。异位词就是字母种类和数量完全一样,只是顺序不同,比如 "abc" 和 "bac" 就是一对。

最开始的笨办法:暴力对比

最开始我思路特别直,完全没多想:

  1. 遍历 s,每次截一段长度和 p 一样的子串
  2. 把子串和 p 都排个序,转成字符串比一比
  3. 一样就把起始下标塞进结果里

写起来是真快,三五行就搞定了,跑示例也没问题。可一提交碰到长字符串直接超时 —— 毕竟每次排序都有开销,再套一层循环,数据量大了根本顶不住。

后来开窍了:固定窗口 + 字母计数

踩了超时的坑我才反应过来:我们对比的本质是「字母出现的次数」,排序完全是绕了个大弯。而且窗口每次只往右挪一位,中间的字符计数是可以复用的,根本不用每次从头算。

这不就是典型的固定大小滑动窗口嘛!

思路其实特别顺:

  • 因为都是小写字母,用两个长度 26 的数组,分别存「目标 p 的字母频率」和「当前窗口的字母频率」
  • 窗口长度固定是 p 的长度,右指针一步步往右走,把新字符加进窗口计数
  • 窗口长度凑够 p 的长度时,对比两个计数数组:完全一致就是异位词
  • 对比完把窗口最左边的字符减掉,窗口整体右移一位

拿示例 1 举个例子就懂了:s = "cbaebabacd",p = "abc"(长度 3)

  1. 右指针走到索引 2,窗口是 "cba",计数和 p 一致,把起始索引 0 加入结果
  2. 窗口右移,去掉最左的 c,加上索引 3 的 e,窗口变成 "bae",计数不一致
  3. 继续右移,重复 “加右边、减左边、对比” 的操作,直到遍历完 s

比每次重新排序高效太多了。

代码实现(JavaScript)

这里纠正一个我之前的认知误区:我以前一直以为 LeetCode 没引入 lodash,后来才发现人家 JS 环境默认就挂了全局 _,_.isEqual 是能直接跑的。不过我还是更推荐自己封装对比函数,一来逻辑更轻量,二来面试手写也不会慌,总不能面试的时候跟面试官说我调个 lodash 吧。

var findAnagrams = function (s, p) {
    const sLen = s.length;
    const pLen = p.length;
    const res = [];
    // 用数组存a-z的频率,比Map省事儿,毕竟只有26个小写字母
    const windowFreq = new Array(26).fill(0);
    const targetFreq = new Array(26).fill(0);
    const aCode = 'a'.charCodeAt(0);

    // 封装频率数组对比函数,主逻辑更清爽
    const isFreqEqual = (arr1, arr2) => {
        for (let i = 0; i < 26; i++) {
            if (arr1[i] !== arr2[i]) return false;
        }
        return true;
    }

    // 先统计目标串p的字母出现次数
    for (let i = 0; i < pLen; i++) {
        targetFreq[p[i].charCodeAt(0) - aCode]++;
    }

    // 右指针遍历s,一步步滑动窗口
    for (let right = 0; right < sLen; right++) {
        // 当前字符进入窗口,计数+1
        const curIndex = s[right].charCodeAt(0) - aCode;
        windowFreq[curIndex]++;

        // 计算当前窗口的左边界
        const left = right - pLen + 1;
        // 窗口还没凑够p的长度,先不对比
        if (left < 0) continue;

        // 调用封装好的对比函数,判断当前窗口是不是异位词
        if (isFreqEqual(windowFreq, targetFreq)) {
            res.push(left);
        }

        // 窗口右移前,把左边界的字符移出计数
        // 别问我为什么写在对比后面,我写反过,卡了十分钟
        const leftIndex = s[left].charCodeAt(0) - aCode;
        windowFreq[leftIndex]--;
    }

    return res;
};

顺手补一个偷懒写法,LeetCode 上能直接运行:把对比那一行换成 _.isEqual,代码会更短。

// 偷懒写法:LeetCode 环境默认支持 lodash,可直接使用
if (_.isEqual(windowFreq, targetFreq)) {
    res.push(left);
}

不过刷题还是建议手写对比逻辑,毕竟只有 26 次循环,性能完全够用,还能练一下底层实现思路。

对了,左边界 right - pLen + 1 这个计算我一开始也写错过,少写了 +1,窗口长度永远差一位,找 bug 找了半天,属实是经典的边界问题了。

进阶优化:更优雅的单数组滑动窗口

上面的双数组写法好理解,但每次都要遍历 26 个字母对比,还是有点啰嗦。我后来刷多了才发现,其实只用一个计数数组就能搞定,连对比步骤都省了,写起来更清爽。

核心思路换个角度想:我们先给 p 里的每个字母预存 “出现次数额度”。窗口右移加入新字符时,就消耗一个对应字母的额度;如果额度减成负数了,说明当前窗口里这个字母太多了,超过了 p 里的数量。这时候我们就不断右移左边界,把移出窗口的字母额度还回去,直到当前字母的额度不再是负数。神奇的地方来了:当窗口长度刚好等于 p 的长度时,就说明窗口里所有字母的额度都刚好匹配,就是我们要找的异位词。

为啥不用再逐个对比了?因为我们保证了窗口里每个字母的数量都不会超过 p 里的数量,而总长度又和 p 一样,那自然每个字母的数量都恰好相等。这个思路我第一次看懂的时候拍了下大腿,太巧妙了。

代码写出来是这样的:

var findAnagrams = function(s, p) {
    // 只用一个计数数组,先统计 p 的字母额度
    const cnt = new Array(26).fill(0); 
    for (const c of p) {
        cnt[c.charCodeAt() - 'a'.charCodeAt()]++;
    }

    const ans = [];
    let left = 0;
    for (let right = 0; right < s.length; right++) {
        const c = s[right].charCodeAt() - 'a'.charCodeAt();
        cnt[c]--; // 右端点字母进入窗口,消耗一个额度

        // 当前字母额度为负,说明窗口里这个字母太多了,收缩左边界
        while (cnt[c] < 0) { 
            cnt[s[left].charCodeAt() - 'a'.charCodeAt()]++; // 左端点字母离开窗口,归还额度
            left++;
        }

        // 窗口长度恰好等于 p 的长度,说明完美匹配
        if (right - left + 1 === p.length) { 
            ans.push(left); 
        }
    }
    return ans;
};

这个写法不仅变量更少,还省掉了每次循环里对比 26 个字母的开销,逻辑也很顺,我现在写这题优先用这个版本。

复杂度到底怎么算?

这里分开说两种写法的复杂度,也方便大家对比差异。

第一种双数组固定窗口的写法,时间复杂度严格来讲是 O(m + n × |Σ|) 。其中 m 是 p 的长度,n 是 s 的长度,|Σ| 是字符集大小,这题里就是 26。主要开销是统计 p 的 O (m),加上遍历 s 的 O (n),再乘上每次对比 26 个字母的 O (|Σ|)。因为 26 是固定常数,日常说它是线性时间也没问题,但面试的时候说完整会更显功底。

第二种单数组优化的写法,时间复杂度就是标准的 O(m + n) 。每个字符最多进入窗口一次、移出窗口一次,没有额外的遍历对比开销,是真正的纯线性时间,效率更高。

两种写法的空间复杂度都是 O(|Σ|) ,都只开了常数大小的计数数组,不会随输入规模增长。

最后唠两句

这题算是滑动窗口的经典入门题,核心就是抓住「固定窗口大小」的特点,复用中间的计数结果,避免重复计算。

给大家提几个我踩过的坑:

  • 别上来就写暴力排序,长字符串必超时
  • 窗口左右边界的索引要算仔细,差 1 是常事
  • 刷题可以用工具函数,但最好能手写核心逻辑,面试才不慌

其实滑动窗口练熟了,这类子串问题套路性很强,手到擒来。

你刷这道题的时候,第一想法是啥?有没有在边界计算上栽过跟头?评论区聊聊呗,我每条都会看~如果觉得这篇题解对你有帮助,点个赞让更多小伙伴看到呀