LeetCode 438. 找到字符串中所有字母异位词:固定长度滑动窗口详解
1. 这道题到底在问什么
LeetCode 438「找到字符串中所有字母异位词」的题意是:
text
给定两个字符串 s 和 p,找到 s 中所有 p 的异位词子串,返回这些子串的起始索引。
这句话里有三个关键词:
text
1. 子串
2. 异位词
3. 起始索引
先看"子串"。子串必须是连续的。
比如:
text
s = "cbaebabacd"
p = "abc"
"cba" 是 s 的子串,因为它连续出现在下标 0 到下标 2。
"bac" 也是 s 的子串,因为它连续出现在下标 6 到下标 8。
再看"异位词"。两个字符串如果字符种类相同,并且每种字符出现次数也相同,只是顺序不同,那么它们就是字母异位词。
例如:
text
"abc"
"bac"
"cba"
它们都是彼此的异位词,因为它们都有:
text
a 出现 1 次
b 出现 1 次
c 出现 1 次
顺序不重要,字符数量才重要。
所以这道题可以翻译成更直白的话:
text
在 s 中找所有长度等于 p.length() 的连续子串,
只要这个子串的字符出现次数和 p 完全一样,就记录它的起始下标。
2. 为什么这题适合滑动窗口
这题和 LeetCode 3「无重复字符的最长子串」一样,都是字符串上的连续区间问题。
但是两题的窗口特点不同。
第 3 题是:
text
窗口长度不固定。
条件是窗口内不能有重复字符。
目标是求最长长度。
第 438 题是:
text
窗口长度固定。
窗口长度永远等于 p.length()。
条件是窗口字符计数必须和 p 完全一样。
目标是找出所有满足条件的起始下标。
因为 p 的异位词长度一定和 p 一样,所以我们不需要考虑长度不同的子串。
假设:
text
p = "abc"
那么 p.length() = 3。
我们只需要检查 s 中所有长度为 3 的窗口:
text
s[0..2]
s[1..3]
s[2..4]
...
这正好是固定长度滑动窗口。
窗口每次向右移动一格,只发生两件事:
text
1. 左边出去一个字符
2. 右边进来一个字符
所以我们可以维护窗口内每个字符的出现次数,不需要每次重新统计整个子串。
3. 第一性原理:异位词本质是字符计数相同
这题最核心的判断不是字符串顺序,而是字符出现次数。
比如:
text
p = "aab"
那么它的异位词必须满足:
text
a 出现 2 次
b 出现 1 次
所以:
text
"aba" 是异位词
"baa" 是异位词
"abb" 不是异位词,因为 a 少了,b 多了
"abc" 不是异位词,因为 a 少了一个,还多了 c
因此,对于任意一个窗口,我们只需要问一个问题:
text
当前窗口中每个字母出现的次数,是否和 p 中每个字母出现的次数完全一样?
如果一样,当前窗口就是 p 的异位词。
如果不一样,就不是。
题目中的字符是小写英文字母,所以可以用长度为 26 的数组统计字符次数。
java
int[] need = new int[26];
int[] window = new int[26];
含义是:
text
need 记录 p 中每个字母出现多少次
window 记录当前窗口中每个字母出现多少次
数组下标和字母的对应关系是:
text
0 -> 'a'
1 -> 'b'
2 -> 'c'
...
25 -> 'z'
所以某个字符 ch 对应的下标是:
java
ch - 'a'
例如:
java
'a' - 'a' = 0
'b' - 'a' = 1
'c' - 'a' = 2
4. 核心算法流程
设:
text
n = s.length()
m = p.length()
因为窗口长度固定为 m,所以算法流程可以分成几步:
text
1. 如果 n < m,说明 s 比 p 还短,不可能存在答案,直接返回空列表。
2. 统计 p 的字符次数,放入 need。
3. 统计 s 的第一个长度为 m 的窗口,放入 window。
4. 比较 need 和 window,如果一样,记录起始下标 0。
5. 之后窗口不断向右滑动:
- 右边新字符进入窗口
- 左边旧字符离开窗口
- 比较 need 和 window
- 如果一样,记录当前窗口起始下标
这就是固定长度滑动窗口的标准形态。
5. Java 代码完整注释
java
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
class Solution {
public List<Integer> findAnagrams(String s, String p) {
// ans 用来保存所有符合条件的起始下标。
List<Integer> ans = new ArrayList<>();
// n 是字符串 s 的长度。
int n = s.length();
// m 是字符串 p 的长度。
// p 的异位词长度一定等于 m,所以滑动窗口长度固定为 m。
int m = p.length();
// 如果 s 比 p 还短,那么 s 中不可能存在 p 的异位词子串。
if (n < m) {
return ans;
}
// need 统计 p 中每个字符出现的次数。
// window 统计当前滑动窗口中每个字符出现的次数。
//
// 题目只包含小写英文字母,所以数组长度为 26。
// 下标 0 表示 'a',下标 1 表示 'b',以此类推。
int[] need = new int[26];
int[] window = new int[26];
// 初始化 need 和第一个窗口 window。
// 第一个窗口是 s[0..m-1],长度正好等于 p.length()。
for (int i = 0; i < m; i++) {
// 统计 p 中字符出现次数。
need[p.charAt(i) - 'a']++;
// 统计 s 的第一个窗口中字符出现次数。
window[s.charAt(i) - 'a']++;
}
// 判断第一个窗口是否是 p 的异位词。
// 如果 need 和 window 两个数组完全相同,说明每个字母出现次数都一样。
if (Arrays.equals(need, window)) {
ans.add(0);
}
// 从下标 m 开始,窗口向右滑动。
//
// right 表示当前要进入窗口的新字符下标。
// 因为前面已经处理了 s[0..m-1],所以下一个进入窗口的是 s[m]。
for (int right = m; right < n; right++) {
// 右边新字符进入窗口。
window[s.charAt(right) - 'a']++;
// 左边旧字符离开窗口。
//
// 当前新加入的是 right,为了保持窗口长度为 m,
// 需要移除下标 right - m 位置的字符。
window[s.charAt(right - m) - 'a']--;
// 当前窗口范围是 [right - m + 1, right]。
// 所以当前窗口的起始下标是 right - m + 1。
if (Arrays.equals(need, window)) {
ans.add(right - m + 1);
}
}
// 返回所有异位词子串的起始下标。
return ans;
}
}
6. 用 cbaebabacd 手动模拟一遍
用题目经典例子:
text
s = "cbaebabacd"
p = "abc"
p.length() = 3,所以窗口长度固定为 3。
先统计 p:
text
need:
a: 1
b: 1
c: 1
第一个窗口是:
text
s[0..2] = "cba"
这个窗口的字符计数是:
text
window:
a: 1
b: 1
c: 1
和 need 完全一样,所以 "cba" 是 "abc" 的异位词。
记录起始下标:
text
ans = [0]
接下来窗口向右移动一格。
从:
text
"cba"
变成:
text
"bae"
发生了两件事:
text
出去 c
进来 e
此时窗口计数变成:
text
a: 1
b: 1
e: 1
它和 need 不一样,因为 need 需要的是 a、b、c,但当前窗口里没有 c,多了 e。
所以不记录。
继续滑动,直到窗口来到:
text
s[6..8] = "bac"
它的字符计数是:
text
a: 1
b: 1
c: 1
和 need 完全一样。
所以记录起始下标 6:
text
ans = [0, 6]
最终返回:
text
[0, 6]
7. 为什么移除的是 right - m
这行代码容易让人停一下:
java
window[s.charAt(right - m) - 'a']--;
它的含义是:
text
窗口右边新加入了 s[right],为了保持窗口长度还是 m,左边必须移除一个字符。
窗口长度固定为 m。
当 right 进入窗口后,当前窗口的右边界是 right。
如果窗口长度是 m,那么窗口左边界应该是:
text
right - m + 1
也就是说,新窗口应该是:
text
s[right - m + 1 .. right]
那么被挤出去的旧字符,就是新窗口左边界前面的那个位置:
text
right - m
所以代码要减掉:
java
window[s.charAt(right - m) - 'a']--;
举个具体例子。
text
s = "cbaebabacd"
p = "abc"
m = 3
一开始窗口是:
text
s[0..2] = "cba"
现在 right = 3,新字符是:
text
s[3] = 'e'
加入 e 之后,为了窗口长度仍然是 3,需要移除:
text
s[right - m] = s[3 - 3] = s[0] = 'c'
新窗口就变成:
text
s[1..3] = "bae"
这就是 right - m 的来源。
8. 关于复杂度产生的一些疑问:为什么 Arrays.equals 之后还是 O(n)
刷题时很容易对复杂度产生一个疑问:
text
每次窗口滑动后都调用 Arrays.equals(need, window),
这不是还要比较整个数组吗?为什么时间复杂度还能写 O(n)?
这个问题问得非常关键,因为它涉及复杂度分析里"常数"的理解。
这里的数组长度固定是 26。
也就是说:
java
Arrays.equals(need, window)
每次最多只比较 26 个整数。
窗口最多滑动 n - m + 1 次,所以总比较次数大约是:
text
26 * (n - m + 1)
在大 O 复杂度里,固定常数可以省略。
所以:
text
O(26 * n) = O(n)
这并不是说 Arrays.equals 没有成本,而是说它的成本是固定上限的常数级。
如果字符集不是小写英文字母,而是一个很大的字符集,比如需要比较几万个字符的计数数组,那么复杂度分析就不能随便把这个比较成本忽略掉。
但在本题中,题目限定了小写英文字母,字符种类固定为 26,因此这个写法的时间复杂度可以认为是:
text
O(n)
空间复杂度是:
text
O(26)
也就是常数空间。
换句话说,这个版本不是"没有比较数组",而是:
text
每次比较的数组长度固定为 26,所以整体仍然是线性时间。
9. 能不能进一步优化
可以。
有些题解会维护一个变量,例如 diff 或 valid,用来记录两个计数数组之间还有多少差异。这样每次窗口滑动时,只更新进出窗口的两个字符,不需要每次调用 Arrays.equals 比较整个数组。
但是对这道题来说,Arrays.equals + int[26] 已经足够清晰,也足够高效。
如果刚开始学习滑动窗口,优先理解这个版本更好。
因为它把问题拆得很直观:
text
p 有一个字符计数。
窗口有一个字符计数。
两个计数一样,就说明窗口是 p 的异位词。
复杂优化写法只是减少常数,不改变这道题的本质。
10. 总结
这道题的第一性原理是:
text
异位词不看字符顺序,只看每个字符出现次数是否完全相同。
因为 p 的异位词长度一定等于 p.length(),所以我们只需要在 s 上维护一个固定长度窗口。
窗口每次右移一格时:
text
右边进来一个字符
左边出去一个字符
我们用两个长度为 26 的数组维护计数:
text
need 表示 p 的字符计数
window 表示当前窗口的字符计数
当:
java
Arrays.equals(need, window)
为 true 时,说明当前窗口就是 p 的一个异位词,记录窗口起始下标即可。
这题可以记成一句话:
text
用长度为 p.length() 的固定滑动窗口扫描 s,窗口字符计数等于 p 的字符计数时,记录窗口起点。
它和 LeetCode 3 的区别也可以顺手记一下:
text
LeetCode 3:窗口长度不固定,条件是窗口内不能有重复字符。
LeetCode 438:窗口长度固定,条件是窗口字符计数和 p 完全相同。
理解了这个差异,滑动窗口这类题就会清楚很多。