力扣hot100-438.找到字符串中所有字母异位词-固定长度滑动窗口详解

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. 能不能进一步优化

可以。

有些题解会维护一个变量,例如 diffvalid,用来记录两个计数数组之间还有多少差异。这样每次窗口滑动时,只更新进出窗口的两个字符,不需要每次调用 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 完全相同。

理解了这个差异,滑动窗口这类题就会清楚很多。

相关推荐
人道领域1 小时前
【LeetCode刷题日记】51.N皇后
数据结构·算法
古城小栈10 小时前
为啥说:训练用BF16,推理用FP16
人工智能·算法·机器学习
KaMeidebaby10 小时前
卡梅德生物技术快报|蛋白 N 端测序在重组贻贝融合蛋白表征中的应用,解决原核表达序列偏移工艺难题
前端·人工智能·物联网·算法·百度
Turbo正则11 小时前
群论在AI中的应用概述
人工智能·算法·抽象代数
ysa05103011 小时前
【并查集】判环
c++·笔记·算法
夏玉林的学习之路11 小时前
如何远程连接服务器
运维·服务器
Jerry11 小时前
KeetCode 44. 开发商购买土地
算法
Jerry12 小时前
KeetCode 58. 区间和
算法
风曦Kisaki12 小时前
#Linux数据库管理Day06:主从同步与MaxScale读写分离
linux·运维·数据库