1008 字
5 分钟
438. 找到字符串中所有字母异位词:从计数数组理解滑动窗口

本文是 LeetCode 刷题记录 的第一篇题解。

这题的要求很直接:给定 sp,找出 s 中所有和 p 由同样字母组成的子串起始位置。

我第一次看这份代码时,真正卡住的不是“滑动窗口”四个字,而是这两个细节:s[i] - 'a' 为什么要减 'a',以及窗口里如果有重复字母,到底怎么判断。

后来把它想成一个固定大小的字符计数表之后,这题就没有那么玄学了。

1. sCount(26) 不是神秘数组#

题目说 sp 只包含小写英文字母,所以字母范围已经固定:az,正好 26 个。

s[i] - 'a' 做的事情,是把字母映射成数组下标:

字符下标
a0
b1
c2
z25

所以 sCount 可以理解成一个固定大小的哈希表:下标代表某个字母,里面存的是这个字母在当前窗口中出现了几次。

比如当前窗口是 "abac",那么 a 的位置存 2bc 的位置各存 1。这不是在记录字母有没有出现过,而是在记录它们分别出现了多少次。

2. 窗口为什么要固定成 p 的长度#

如果一个子串是 p 的异位词,它的长度一定和 p 一样。于是窗口没有必要忽大忽小,直接固定为 p.size() 就够了。

s = "cbaebabacd"p = "abc" 为例,第一扇窗口是 "cba"。它和 "abc" 的顺序不同,但 abc 都各出现一次,所以两个计数表相同,起始位置 0 就是答案。

窗口往右移动一格时,不需要把新窗口再数一遍。只需要做两件事:左边离开的字符次数减一,右边新进来的字符次数加一。第一扇窗口从 "cba" 变成 "bae" 时,c 离开,e 进入,其他计数保持不动。

这就是我现在理解的滑动窗口:范围固定,窗口移动时只维护变化的那两项。听起来没有模板那么厉害,实际就是别每次从头数。

3. 这里没有检查“字母不能重复”#

我一开始误以为 sCount == pCount 是在判断窗口里每种字母只出现一次。其实完全不是。

它比较的是 26 个位置上的出现次数是否全部相同。假设 p = "aab",窗口是 "aba":两个字符串里 a 都出现两次,b 都出现一次,所以它们依然是异位词。

异位词允许重复,甚至必须把重复次数算对。代码真正要问的是:窗口里的每一种字母,出现次数是不是恰好和 p 一样。

4. 源码#

class Solution {
public:
vector<int> findAnagrams(string s, string p) {
int pLen = p.size(), sLen = s.size();
if(sLen < pLen){
return vector<int>();
}
vector<int> ans;
vector<int> sCount(26);
vector<int> pCount(26);
for (int i = 0; i < pLen; ++i){
++sCount[s[i] - 'a'];
++pCount[p[i] - 'a'];
}
if(sCount == pCount){
ans.emplace_back(0);
}
for (int i = 0; i < sLen - pLen; ++i){
--sCount[s[i] - 'a'];
++sCount[s[i + pLen] - 'a'];
if (sCount == pCount){
ans.emplace_back(i + 1);
}
}
return ans;
}
};

代码前半段先数出 p 和第一扇窗口的字符频率。之后循环里的 --++ 就对应窗口左边出去、右边进来。每次计数表相等,就把当前窗口的起点记进答案。

5. 我这次记下的东西#

这题让我先记住的不是某个滑动窗口模板,而是一个更具体的模型:固定范围 + 增量维护统计

当窗口长度固定、字符种类有限时,用一个 26 长度的计数数组就能把“两个字符串是不是由同样字母组成”变成一次直接比较。下次再看到类似题,先想窗口要维护什么统计量,可能比先搜索“滑动窗口模板”更有用。

时间复杂度是 O(|s|)。窗口每移动一次只更新两个字符,比较的数组长度固定为 26;额外空间也是 O(1)