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 是常事
  • 刷题可以用工具函数,但最好能手写核心逻辑,面试才不慌

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

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

相关推荐
名字还没想好☜1 小时前
React 实现暗黑模式切换:localStorage 持久化、SSR 首屏闪烁与跟随系统主题
前端·javascript·react.js·ecmascript·react·next.js
find1star2 小时前
LeetCode 141:环形链表
java·算法·leetcode·链表
迅翼SwiftWing2 小时前
编队系列(总)|固定翼编队如何实现稳定飞行?通信架构、协同算法与工程约束深度解析
算法·架构
自动化监测Learner2 小时前
主流 Web 端地图引擎对比:选型指南与优劣分析
javascript
LB21122 小时前
力扣160 21 86
算法·leetcode·职场和发展
小智老师PMP3 小时前
2026深度解析|PMP第八版与NPDP核心侧重点本质区别(管理类证书怎么选)
开发语言·分布式·算法·职场和发展·产品经理
HugoStudio_SWAN3 小时前
洛谷 B4450 / B3867 / B3923 智慧购物、储蓄与做题——从程序到生活
c++·学习·程序人生·算法·生活
a187927218314 小时前
【算法】动态规划第二篇:双序列 DP 三课——继承、计步与断链
算法·leetcode·动态规划·dp·回溯·暴力·算法讲解
江畔柳前堤4 小时前
前台·中台·后台:2026年AI原生时代的架构全景图
开发语言·人工智能·算法·机器学习·架构·scala·ai-native
文心快码BaiduComate5 小时前
2026 OPC x AI Coding 趋势案例交流会圆满落幕:一个人就一支研发团队
算法