438. 找到字符串中所有字母异位词

438. 找到字符串中所有字母异位词

!abstract 一句话本质 在 s 中维护一个长度等于 p 的"字符频次窗口",不是比较字符串,而是比较字符出现次数。
!quote 链接

题目

给定字符串 sp,返回 s 中所有是 p 的异位词的起始索引。异位词即字符相同、排列不同的子串。

思路(从暴力到最优)

暴力:枚举 s 中每个长度为 p 的子串,排序后和 p 比较。时间 O((n−m) ⋅ m log m),太慢。

优化一:字符频次数组(你的第一版)

  • 用长度为 26 的数组统计 p 的字符频次。
  • 枚举窗口时,每个窗口内部重新计算频次,然后比较数组是否全等。
  • 时间 O(n ⋅ m),仍不够。

优化二:滑动窗口 + 频次数组(你的优化版)

  • 窗口大小固定为 p.length
  • 右指针右移时加入 s[r],左指针 l = r - p.length + 1 时,窗口合法,比较两个频次数组。
  • 然后移出 s[l],继续滑动。
  • 时间 O(n ⋅ 26) = O(n)。

进一步优化:diff 计数

  • 不必每次比较 26 个位置是否全为 0。
  • 维护一个 diff,表示当前窗口中和 p 频次不同的字符种类数。
  • 入窗、出窗时更新 diff:当某个字符的频次从"目标值"变到"非目标值",diff++;从"非目标值"回到"目标值",diff--
  • diff === 0 即匹配,判断 O(1)。

两种窗口不变量:为什么有时用 if,有时用 while

本题可以从两种角度维护窗口,它们的收缩目标不同。

固定长度窗口:按长度收缩

窗口始终只需要保留最近的 p.length 个字符。右端加入一个字符后,窗口若达到目标长度,就检查一次并移出一个左端字符。每轮只新增一个字符,因此每轮也只需移出一个字符。

javascript 复制代码
if (r - l + 1 === p.length) {
  // 检查当前窗口
  // 移出一个左端字符,为下一轮腾出位置
  l++;
}

这里使用 if,是因为维护目标是一个确定的数值条件:窗口长度等于 p.length

可变长度窗口:按合法性收缩

cnt[i] 表示 p 还允许窗口加入多少个字母 i。合法窗口的不变量是:

text 复制代码
对所有字符 i,都有 cnt[i] >= 0

加入当前字符 c 后,只有 cnt[c] 可能从 0 变成 -1。因为加入前窗口合法、每轮只加入一个字符,所以它最多只超额 1 个,并不存在"严重超额"。

但是,while 仍可能执行多次。原因不是超额数量大,而是造成超额的旧字符未必位于左端 。左指针可能需要先跨过多个无关字符,直到删除窗口中较早出现的 c;如果 c 不属于 p,则会一直删到刚加入的 c 本身。

javascript 复制代码
cnt[c]--;

while (cnt[c] < 0) {
  cnt[indexOf(s[l])]++;
  l++;
}

例如 p = "abc",加入新字符前的合法窗口为 "ba",再加入 a 后得到 "baa"。虽然 a 只超额 1 个,但第一次删除左端的 b 后,两个 a 仍在窗口中;必须继续删除旧的 a,窗口才能重新合法。

!important ifwhile 的判断原则

  • 已知一次左移一定能恢复目标条件,例如固定窗口每轮只超长 1:使用 if
  • 不知道违规元素离左边界有多远,必须持续收缩直到恢复窗口不变量:使用 while
  • 关键不是窗口"固定还是可变"这个标签,而是一次收缩能否保证恢复不变量

这种"右端加入导致窗口违规,左端持续收缩直到重新合法"的逻辑,也出现在 \[0003-无重复字符的最长子串];\[0076-最小覆盖子串] 则是在窗口已经合法时持续收缩,以寻找最短合法窗口。

关键卡点

!warning 我当时卡在这

  • 原代码每个窗口都重新 compare 一遍,实际复杂度是 O(n ⋅ m),不是真的滑动窗口。
  • compare.every(a => a === 0) 本身是 O(26),每次都要扫一遍数组;用 diff 可以降到 O(1)。

代码

javascript 复制代码
var findAnagrams = function(s, p) {
    const ans = [];
    const sChars = new Array(26).fill(0);
    const pChars = new Array(26).fill(0);

    for (const char of p) {
        pChars[char.charCodeAt(0) - 97]++;
    }

    for (let r = 0; r < s.length; r++) {
        const l = r - p.length + 1;
        sChars[s.charCodeAt(r) - 97]++;

        if (l < 0) {
            continue;
        }

        if (isEqual(sChars, pChars)) {
            ans.push(l);
        }

        sChars[s.charCodeAt(l) - 97]--;
    }

    return ans;
};

function isEqual(a, b) {
    for (let i = 0; i < 26; i++) {
        if (a[i] !== b[i]) return false;
    }
    return true;
}

diff 模式写法

cnt[i] 表示 p 中第 i 个字符出现次数减去当前窗口中该字符出现次数。diff 表示当前有多少种字符的数量还没对上。

javascript 复制代码
var findAnagrams = function(s, p) {
    const ans = [];
    const cnt = new Array(26).fill(0);

    for (const char of p) {
        cnt[char.charCodeAt(0) - 97]++;
    }

    let diff = 0;
    for (let i = 0; i < 26; i++) {
        if (cnt[i] !== 0) diff++;
    }

    for (let r = 0; r < s.length; r++) {
        const inIdx = s.charCodeAt(r) - 97;

        if (cnt[inIdx] === 0) diff++;
        cnt[inIdx]--;
        if (cnt[inIdx] === 0) diff--;

        const l = r - p.length + 1;
        if (l < 0) continue;

        if (diff === 0) {
            ans.push(l);
        }

        const outIdx = s.charCodeAt(l) - 97;

        if (cnt[outIdx] === 0) diff++;
        cnt[outIdx]++;
        if (cnt[outIdx] === 0) diff--;
    }

    return ans;
};

单计数数组 + 合法窗口写法

这版不固定窗口长度,而是始终维护"窗口中任意字符都不超过 p 的需求量"。恢复合法后,如果窗口长度恰好等于 p.length,说明每种字符数量都恰好匹配。

javascript 复制代码
var findAnagrams = function (s, p) {
    const ans = [];
    const cnt = new Array(26).fill(0);

    for (const c of p) {
        cnt[c.charCodeAt(0) - 97]++;
    }

    let l = 0;

    for (let r = 0; r < s.length; r++) {
        const inIndex = s.charCodeAt(r) - 97;
        cnt[inIndex]--;

        while (cnt[inIndex] < 0) {
            cnt[s.charCodeAt(l) - 97]++;
            l++;
        }

        if (r - l + 1 === p.length) {
            ans.push(l);
        }
    }

    return ans;
};

复杂度

  • 时间:数组比较写法 O(n ⋅ 26) = O(n),diff 写法 O(n)
  • 空间:O(26) = O(1)

一句话记忆点

!tip 下次看到类似题 "异位词"先看字符频次;固定窗口用 cnt 记录差值,用 diff 记录还有几种字符没对上。

相似题 / 套用模板

    1. 最小覆盖子串(滑动窗口,不固定窗口长度)
    1. 字符串的排列(与本题几乎相同)

复习记录

2026-07-11 第 1 次复习

结果:通过 ✅(阶段 0→1)

记得牢:

  • 能说明窗口长度必须等于 p.length,因为异位词必须恰好使用 p 的所有字符。
  • 能写出固定窗口的"入窗、长度足够时判断、出窗"流程。
  • 能进一步写出只用一个差值数组的可变窗口解法:cnt[c] < 0 说明当前字符超额,收缩左端直到恢复合法。

需要加强:

  • 固定窗口中,窗口长度恰好等于 p.length 时必须检查,不能用 <= 跳过。
  • LeetCode 原生环境没有 Lodash,不能使用 _.isEqual;要手写 26 位比较,或维护差值状态。
  • 区分收缩条件:固定长度窗口按长度只移出一个左端字符;可变长度窗口要用 while,直到窗口恢复合法。

2026-08-17 第 2 次复习

结果:未通过 ❌(阶段 2→0,3 天后复习)

记得牢:

  • 知道窗口大小固定 = p.length,因为异位词必须恰好使用 p 的所有字符
  • 能区分 if vs while:定长窗口用 if 移一个,可变窗口用 while 收缩到合法
  • 能联系 LC 3 的 abba 例子说明 while 的必要性

需要加强:

  • 第一版代码有逻辑错误 :(1) 用 if 而非 while,窗口未恢复合法就继续;(2) if(l >= 0) ans.push(l) 没判断窗口是否真的合法就 push,会误判
  • 看了题解才写出第二版(while 版),不满足独立 AC 标准
  • diff 理解含糊:diff 不是"替代计数数组",而是"计数数组的摘要"------记录有多少种字符的 cnt 不为 0。每次加入/移除字符只影响一个字符的 cnt,只需检查该字符在操作前后是否跨越 0 点:操作前是0 → diff++,操作后变0 → diff--
  • 窗口不变量描述要精确:不是"p中字符都要出现",而是"窗口内每种字符频次 = p中对应频次"

2026-08-23 第 3 次复习

结果:通过 ✅(阶段 0→1,7 天后复习)

记得牢:

  • 独立默写单计数数组 + 合法窗口版代码,一次成型:cnt[c]--while(cnt[c] < 0) 收缩、r - l + 1 === p.length 判定
  • 能解释 while 而非 if 的原因:造成超额的旧字符未必位于左端,一次左移不保证窗口恢复合法
  • 能论证"窗口合法(不超量)+ 长度等于 p.length(不欠量)⇒ 恰好相等",无需再比较数组
  • 复杂度表述正确:时间 O(n)、空间 O(26)=O(1)

需要加强:

  • "窗口合法"与"异位词判定"是两个层次:合法只要求所有 cnt[i] ≥ 0(不超量,窗口可能还短于 p.length);只有合法 长度相等时 cnt 才恰好全为 0。复习时把两者说成了一个条件
相关推荐
Asize18 分钟前
283. 移动零
算法
船厂电气自动化ai大模型2 小时前
AI大模型与数学·第57课 傅里叶全套工具链综合实战:串联级数/连续变换/DFT/FFT,图像、音频、扩散模型完整例题
数据结构·人工智能·深度学习·算法·机器学习
人工智能培训3 小时前
人工智能数据安全下的个人信息保护实践方案
大数据·人工智能·算法·生活
元启数宇4 小时前
幕墙AI设计算法逻辑:元启数宇如何智能分格
人工智能·算法
宵时待雨4 小时前
优选算法专题16:多源BFS
算法·宽度优先
重生之后端学习4 小时前
53. 最大子数组和[中等]✅
java·数据结构·算法·leetcode·职场和发展
INGNIGHT4 小时前
1908 · 布尔表达式求值(stack单调栈&dfs)
开发语言·c++·算法
lucas_AI5 小时前
把表格压成 64 个 token,长文档问答反而更准了:先找表,再看数
人工智能·深度学习·算法
Zzz 小生5 小时前
Lazy Theta*:把昂贵的视线检测留到“真正需要”时再做
人工智能·算法·贪心算法·推荐算法