Problem: 438. 找到字符串中所有字母异位词
题目:给定两个字符串 s 和 p,找到 s 中所有 p 的 异位词 的子串,返回这些子串的起始索引。不考虑答案输出的顺序。
【LeetCode 热题 100】438. 找到字符串中所有字母异位词——(解法一)定长滑动窗口+哈希表
【LeetCode 热题 100】438. 找到字符串中所有字母异位词——(解法二)定长滑动窗口+数组
整体思路
这段代码同样用于在字符串 s 中查找所有模式串 p 的异位词。它采用了一种更为巧妙和高效的 可变大小滑动窗口 算法。与前两个版本(一个用 HashMap,一个用固定大小窗口的数组)相比,此版本的核心思想是维护一个“合法”的窗口,该窗口内的字符都是 p 所需要的。
算法的整体思路可以分解为以下步骤:
-
初始化“需求”计数器:
- 算法使用一个大小为 26 的整型数组
cnt。这个数组的含义非常关键:它首先被初始化为模式串p的字符频率,代表着我们“需要”的字符及其数量。 - 例如,如果
p = "aab",则cnt['a'-'a']为 2,cnt['b'-'a']为 1。
- 算法使用一个大小为 26 的整型数组
-
滑动窗口的扩张与收缩:
- 算法使用
left和right两个指针来定义滑动窗口[left, right]。right指针持续向右移动,以扩大窗口。 - 扩张与“消耗”:当
right指针扫过s中的一个字符c时,会执行cnt[c]--。这可以理解为“消耗”掉了一个所需的字符c。- 如果消耗后
cnt[c]仍>= 0,说明这个字符是p所需要的,且目前窗口内该字符的数量尚未超出p的需求。 - 如果消耗后
cnt[c] < 0,这说明窗口内已经包含了多余的字符c(即超出了p的需求量)。
- 如果消耗后
- 收缩以维持“合法性”:
- 一旦
cnt[c]变为负数,就触发while循环。这个循环的目的是收缩窗口的左边界left,直到窗口再次变得“合法”。 - 收缩时,将
left指针指向的字符“归还”给计数器(cnt[s.charAt(left) - 'a']++),然后将left右移。 - 这个过程会一直持续,直到我们刚刚加入的那个多余字符
c的计数cnt[c]不再为负。
- 一旦
- 算法使用
-
判定与记录结果:
- 在每一次
right指针移动后(并可能伴随着left的移动),算法会检查当前窗口[left, right]的大小。 - 如果窗口大小
right - left + 1正好等于模式串p的长度m,这意味着:- 窗口内没有多余的字符(因为
while循环保证了所有cnt值都>= 0)。 - 窗口的总字符数正好是
m。
- 窗口内没有多余的字符(因为
- 这两个条件同时满足的唯一情况就是:窗口内的字符频率与
p完全匹配。因此,这是一个异位词,将其起始索引left加入结果列表。
- 在每一次
这种方法的精髓在于,它不强制窗口大小始终为 m,而是灵活地收缩窗口以排出“无效”字符,一旦窗口在“有效”状态下长度恰好为 m,就找到了一个解。
完整代码
import java.util.ArrayList;
import java.util.List;class Solution {/*** 在字符串 s 中查找 p 的所有异位词的起始索引。* 此版本使用可变大小的滑动窗口和单个计数数组,非常高效。* @param s 主字符串 (假定只包含小写字母)* @param p 模式字符串 (假定只包含小写字母)* @return 一个包含所有匹配起始索引的列表*/public List<Integer> findAnagrams(String s, String p) {// ans 用于存储结果,即所有异位词的起始索引List<Integer> ans = new ArrayList<>();int n = s.length();int m = p.length();// 如果主串比模式串还短,直接返回空列表if (n < m) {return ans;}// cnt 数组作为字符频率计数器。// 初始时,它存储了模式串 p 中需要的各个字符的数量。int[] cnt = new int[26];for (char ch : p.toCharArray()) {cnt[ch - 'a']++;}// left 是滑动窗口的左边界int left = 0;// right 是滑动窗口的右边界,它将遍历整个主串 sfor (int right = 0; right < n; right++) {// 获取右边界字符并将其映射到索引int c = s.charAt(right) - 'a';// 将该字符的所需数量减 1,表示窗口“消耗”了该字符。cnt[c]--;// 关键逻辑:处理窗口内字符冗余的情况。// 如果 cnt[c] < 0,说明窗口内字符 c 的数量已经超出了 p 的需求。// 此时需要从左侧收缩窗口,直到 cnt[c] 恢复到 0。while (cnt[c] < 0) {// "归还" 左边界字符:将其在 cnt 数组中的计数加 1。cnt[s.charAt(left) - 'a']++;// 移动左边界,缩小窗口。left++;}// 检查窗口大小是否等于模式串的长度。// 因为 while 循环保证了窗口内没有多余字符(所有 cnt[i] >= 0),// 如果此时窗口大小恰好为 m,那么它必然是一个异位词。if (right - left + 1 == m) {ans.add(left);}}// 返回最终的结果列表return ans;}
}
时空复杂度
时间复杂度:O(N + M)
- 模式串频率统计:
for (char ch : p.toCharArray())循环遍历模式串p一次,时间复杂度为 O(M),其中M是p的长度。 - 滑动窗口遍历:
- 外层的
for循环由right指针驱动,从0到n-1,所以right指针总共移动了N次。 - 内层的
while循环由left指针驱动。虽然它在for循环内部,但left指针也只是一直向右移动,永不后退。 - 在整个算法的生命周期中,
left指针最多从0移动到n。 - 每个字符
s.charAt(i)最多被right指针访问一次,也最多被left指针访问一次。因此,两个指针的总移动次数是线性的,约为2N。 - 所有数组操作
cnt[...]--和cnt[...]++都是 O(1) 的。
- 外层的
综合分析:
总的时间复杂度是预处理 p 的时间加上两个指针遍历 s 的时间,即 O(M) + O(N) = O(N + M)。这是一个非常高效的线性时间复杂度。
空间复杂度:O(k) 或 O(1)
- 主要存储开销:算法只使用了一个额外的整型数组
cnt。- 该数组的大小是 26,这是一个固定的常数,代表了字符集的大小(
k=26)。它不随输入s或p的长度而改变。
- 该数组的大小是 26,这是一个固定的常数,代表了字符集的大小(
- 结果列表:
ans列表用于存储输出,其空间不计入算法的辅助空间复杂度。 - 其他变量:
n,m,left,right,c等都占用常数空间 O(1)。
综合分析:
算法所需的额外辅助空间主要由 cnt 数组决定。由于其大小是固定的,空间复杂度为 O(k),其中 k=26。因为 k 是一个常数,所以通常也称其空间复杂度为 O(1)。