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

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

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

相关推荐
ocean210333 分钟前
2025-2026年AI算法与模型研发面试高频知识点洞察
人工智能·算法·面试
默_笙34 分钟前
🧀 用户访问一个网站到底经历了什么?全栈部署的"城市观光"指南
前端·javascript
Nil20844 分钟前
leetcode 78子集
数据结构·算法·leetcode
en.en..2 小时前
Linux fork() 工作原理
数据结构·算法
地平线开发者2 小时前
bevformer算法模型详细解读
算法
晓天衡宇•评测社区2 小时前
Seedance 2.5 登顶图生视频榜单,Wan 3.0 在游戏与教育场景进入前列
前端·javascript·网络
卡皮巴拉c992 小时前
基于pnpm搭建monorepo项目
前端·javascript
liliangcsdn3 小时前
ICIR权重矩阵如何加权标准化为综合因子
算法
jianpeng的工程笔记3 小时前
Colab Kaggle 防断开脚本
javascript·深度学习·kaggle·colab
SDWAN_Cheap3 小时前
SD-WAN智能选路技术的算法与实现机制
算法·php·sdwan