438. 找到字符串中所有字母异位词
!abstract 一句话本质 在 s 中维护一个长度等于 p 的"字符频次窗口",不是比较字符串,而是比较字符出现次数。
!quote 链接
题目
给定字符串 s 和 p,返回 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
if与while的判断原则
- 已知一次左移一定能恢复目标条件,例如固定窗口每轮只超长 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 记录还有几种字符没对上。
相似题 / 套用模板
-
- 最小覆盖子串(滑动窗口,不固定窗口长度)
-
- 字符串的排列(与本题几乎相同)
复习记录
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 的所有字符
- 能区分
ifvswhile:定长窗口用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。复习时把两者说成了一个条件