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

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

题目说穿了很简单

给两个字符串 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 吧。

javascript 复制代码
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,代码会更短。

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

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

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

复杂度到底怎么算?

时间复杂度严格来讲是 O(n + m × |Σ|) 。其中 n 是 p 的长度,m 是 s 的长度,|Σ| 代表字符集的大小 ------ 这题限定了小写字母,所以 |Σ| = 26。拆解一下:

  • 提前统计 p 的字母频率,遍历一遍 p,开销是 O (n)
  • 滑动窗口遍历 s 的过程中,每个字符进出窗口都是 O (1) 操作,这部分是 O (m)
  • 每次窗口长度达标后,要对比两个频率数组是否一致,单次对比要遍历 26 个位置,也就是 O (|Σ|);全程最多对比 n 次,所以这部分是 O (m × |Σ|)

当然因为 26 是个固定常数,不会随输入规模增长,工程上笼统说它是线性时间也没问题,但面试的时候说完整会更显功底。

空间复杂度是 O(|Σ|) 。我们只开了两个长度固定为 26 的计数数组,额外空间不会跟着输入字符串变长而变大,属于常数级别的空间开销。

最后唠两句

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

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

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

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

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

相关推荐
kyriewen2 小时前
面试官问"这段逻辑为什么这样写"——我才发现,这半年我写的代码,我自己都解释不了
前端·javascript·面试
一壶浊酒..2 小时前
Scrape Webpack练习
开发语言·javascript·webpack
Forever Nore4 小时前
学完C语言力扣第一题做不来正常吗
数据结构·算法
hansang_IR4 小时前
【题解】LC:倍增 / 区间并查集(Range Parallel Unionfind)
c++·算法·并查集
Tisfy6 小时前
LeetCode 3731.找出缺失的元素:哈希 / 排序
算法·leetcode·哈希算法·排序·哈希表
默_笙6 小时前
⛵ 我用 React + TS 做了个"调色盘",顺便学会了企业级项目的目录架构
前端·javascript
lucas_AI6 小时前
Q-CueGraph:你的多模态大模型会 zoom,但真的知道该看哪儿吗?
人工智能·算法
渣波6 小时前
拒绝 Redux 样板代码:Zustand 核心原理与实战进阶指南(基础、异步与切片模式)
前端·javascript