大家好,我是LvZi,今天带来
滑动窗口算法系列|基础概念|例题讲解
一.滑动窗口问题基础概念
滑动窗口本质上是同向双指针
问题,脱胎于双指针.使用两个指针l, r
维护一定长度的数组区间,在r
指针遍历的过程中,执行进窗口,判断,更新结果,出窗口
等操作,当r
指针遍历完毕,就能得到最后的结果
滑动窗口算法的代码比较固定,大致是以下步骤:
进窗口
将元素添加到区间内部 可以使用变量,数组,哈希表维护判断
判断添加元素之后,当前区间是否满足要求;如果满足执行出窗口操作更新结果
虽然放到第三步,但是更新结果的时机要因题而异
滑动窗口之所以快,是因为实现了一次遍历得到结果
,减少了暴力循环带来的冗余操作
1.基本概念
滑动窗口算法是一种高效的算法,用于解决涉及连续子数组或子字符串的问题。它通过维护一个动态窗口
来扫描数组或字符串,从而减少重复计算,提高算法效率。这个动态窗口根据性质可以分为两类:
- 固定大小的滑动窗口
- 可变大小的滑动窗口
2.固定大小的滑动窗口
在固定大小的滑动窗口中,窗口的大小是预先确定的,窗口从左到右逐个滑动。常见问题包括:
- 最大子数组和(大小固定):找到一个大小为k的子数组,使其和最大。
- 平均子数组和(大小固定):找到一个大小为k的子数组,使其和的平均值最大。
3.可变大小的滑动窗口
在可变大小的滑动窗口中,窗口的大小是动态变化的,取决于具体问题。常见问题包括:
- 最小覆盖子串:找到包含所有给定字符的最小子串。
- 最长无重复字符子串:找到没有重复字符的最长子串。
4.技巧和策略
- 双指针技术:使用两个指针(
l,r
),一个指向窗口的起始位置,一个指向窗口的结束位置,以便动态调整窗口大小
。 - 哈希表/字典:常用于记录窗口内元素的频率,帮助快速检查条件(例如字符是否满足要求)。也经常会使用数组模拟哈希表
- 条件判断和滑动窗口的调整:根据问题的要求,动态调整窗口的大小和位置。
- 其实也不用纠结使用定长还是不定长的,只要分析出题目是使用滑动窗口解决就行;窗口的定长还是不定长影响的是
更新结果
的时机,而这个时机根据具体题目具体判断即可 - 还有最重要的一点是:判断是否能使用滑动窗口(同向双指针)的关键点在于
数组是否具有单调性
,注意不是数组元素严格的单增单减,要结合题目所求
二.例题讲解
1.⻓度最⼩的⼦数组
⻓度最⼩的⼦数组
分析
- 最简单的方法就是暴力查找,但是会超时
滑动窗口解法
进窗口
使用sum来维护区间和 遍历到一个数字就加判断
判断sum是否大于等于target 如果成立 更新结果 +出窗口
- 本题的单调性在于:
数组中的元素都是正数
,随着指针的移动,和一定是越来越大的
代码
class Solution {
public int minSubArrayLen(int target, int[] nums) {
int n = nums.length, sum = 0, len = 0x3f3f3f3f;
for(int i = 0, j = 0; j < n; j++) {
sum += nums[j];// 进窗口
while(sum >= target) {// 判断
// 判断成立 更新结果 + 出窗口
len = Math.min(len, j - i + 1);
sum -= nums[i++];
}
}
return len == 0x3f3f3f3f ? 0 : len;
}
}
2.⽆重复字符的最⻓⼦串
⽆重复字符的最⻓⼦串
分析
● 经典的滑动窗口问题,本题的一个技巧在于使用数组模拟哈希表
进窗口
添加字符 字符数组记录字符出现的次数判断
判断添加的字符的出现次数是否>2,如果大于则是重复字符,出窗口更新结果
每次添加进字符就更新一次结果
s的ASCII码范围是0-128,所以可以使用大小为128的数组模拟哈希表
代码
class Solution {
public int lengthOfLongestSubstring(String ss) {
int[] hash = new int[128];
char[] s = ss.toCharArray();
int slow = 0, fast = 0, n = s.length, len = -1;
if(n == 0) return 0;
while(fast < n) {
++hash[s[fast]];// 进窗口
while(hash[s[fast]] > 1)// 判断
hash[s[slow++]]--;// 判断成立 出窗口
len = Math.max(fast - slow + 1, len);// 更新结果
fast++;
}
return len;
}
}
3.最⼤连续 1 的个数 III
最⼤连续 1 的个数 III
分析
- 采用
转化
思想,如果考虑翻转/改变数组,比较麻烦,可以转化为统计区间内部0的个数,只要保证区间内部0的个数不超过k,就一定能翻转成功
滑动窗口思路
进窗口
使用二进制数字统计当前数字出现的次数判断
判断0出现的次数是否超过k,如果超过,出窗口更新结果
每遍历到一个数字就更新一次结果
代码
- 方法一:使用计数器统计0的数量
class Solution {
public int longestOnes(int[] nums, int kk) {
// 使用计数器统计数量 有点抽象
int slow = 0, fast = 0, ret = -1, n = nums.length, k = kk;
while(fast < n) {
if(nums[fast] == 0) k--;
while(k < 0) {
if(nums[slow++] == 0) k++;
}
ret = Math.max(ret, fast - slow + 1);
fast++;
}
return ret;
}
}
- 方法二:使用二进制数组
class Solution {
public int longestOnes(int[] nums, int k) {
// 二进制数组 0下标存储0出现的次数 1下标存储1出现的次数
int[] arr = new int[2];
int slow = 0, fast = 0, ret = -1, n = nums.length;
while(fast < n) {
arr[nums[fast]]++;
while(arr[0] > k)
if(nums[slow++] == 0)
arr[0]--;
ret = Math.max(ret, fast - slow + 1);
++fast;
}
return ret;
}
}
4.将 x 减到 0 的最⼩操作数
将 x 减到 0 的最⼩操作数
分析
- 从左右两端选择最少个数的数字,使得和恰好等于x
- 转化为:
从数组中挑选连续区间的数字,使得和恰好等于target的最长的区间
.和第一题类似,这里求的是满足条件下的最长的子数组
代码
class Solution {
public int minOperations(int[] nums, int x) {
int sum = 0;
for(int n : nums) sum += n;
int target = sum - x;
if(target < 0) return -1;// nums全是正数
int l = 0, r = 0, tmp = 0, n = nums.length, ret = -1;
while(r < n) {
tmp += nums[r];// 进窗口
while(tmp > target) {// 判断
tmp -= nums[l++];// 判断成立 出窗口
}
if(tmp == target)// 更新结果
ret = Math.max(ret, r - l + 1);
++r;
}
if(ret == -1) return -1;
return n - ret;
}
}
5.水果成篮
水果成篮
分析
使用kinds
记录区间内部苹果种类的个数
进窗口
增加对应种类苹果的数量 如果是新种类,kinds++;判断
判断kinds > 2
,如果大于,出窗口更新结果
代码
class Solution {
public int totalFruit(int[] fruits) {
// 从左往右找 满足只有两个种类苹果的最大数目 滑动窗口
int l = 0, r = 0, n = fruits.length, ret = -1, kinds = 0;
int[] hash = new int[n + 1];// 统计种类的数目
while(r < n) {
if(hash[fruits[r]] == 0) ++kinds;// 判断是否是新种类
hash[fruits[r]]++;// 进窗口
// 判断
while(kinds > 2) {
hash[fruits[l]]--;
if(hash[fruits[l]] == 0) kinds--;// 数量为0 种类减1
++l;
}
// 更新结果
ret = Math.max(ret, r - l + 1);
++r;
}
return ret;
}
}
6.找到字符串中所有字⺟异位词(固定窗口大小)
找到字符串中所有字⺟异位词
分析
- 难点在于如何判断两个指针区间的字符串和p字符串是否满足
异位词
- 如果是
异位词
,则字母类型及个数完全相等,可以考虑使用两个哈希表记录字母及出现的频数,但是要求窗口内部的字符必须都在p中
,所以使用cnt来记录有效字符的个数 - 由于都是小写字母,考虑使用数组模拟哈希表,通过数组记录字母出现的频数
- 本题是一个
固定大小
的滑动窗口问题,大小等于字符串p的长度,应该保证窗口的大小始终不标
进窗口
将对应字符的频数加1,并判断是否是有效字符判断
判断当前区间大小是否等于p的长度,如果满足,出窗口更新结果
判断有效字符的个数是否等于p的长度
代码
class Solution {
public List<Integer> findAnagrams(String ss, String pp) {
// 滑动窗口算法
char[] s = ss.toCharArray(), p = pp.toCharArray();
int l = 0, r = 0, n = s.length;
int[] hash1 = new int[26], hash2 = new int[26];
for(char ch : p) hash2[ch - 'a']++;
List<Integer> ret = new ArrayList<>();
int cnt = 0;// 使用cnt统计有效字符的数量
while(r < n) {
// 进窗口
char in = (char)(s[r] - 'a');
hash1[in]++;
if(hash1[in] <= hash2[in]) cnt++;// 有效字符
// 判断 + 出窗口
if(r - l + 1 > p.length) {
char out = (char)(s[l] - 'a');
if(hash1[out] <= hash2[out]) cnt--;
hash1[out]--;
++l;
}
// 更新结果
if(cnt == p.length) ret.add(l);
++r;
}
return ret;
}
}
总结
本题判断是否是异位词的策略很巧妙,即使用一个计数变量cnt
来标记有效字符的个数,判断区间内部有效字符的个数和字符串p是否相等来判断是否是异位词,此外还有两个比较繁琐的判断策略,这里也提供给大家
- 使用hash2统计字符串p中的所有字符及其出现的频率,使用hash1来统计遍历过程中的字符和出现的频率,当区间长度相等时,判断两个哈希表是否相等即可(equals())
- 使用数组模拟哈希表,具体策略和
1
类似,在判断是否相等时可使用循环遍历判断两个数组是否相等
7.串联所有单词的⼦串(分组 + 滑动窗口)
串联所有单词的⼦串
分析
- 本题是
字母异位词
的plus版本,字母异位词中需要判断是否含有某个字符,再遍历的过程中是一个字符一个字符进行判断,本题需要判断的是字符串 - 算法的思路大致和
字母异位词
相等,有三点需要注意
- 滑动窗口的执行次数:在
字母异位词
这道题目中,执行一次滑动窗口就能完成,因为是按字符遍历
,但是本题是按字符串遍历
,需要找到字符串起始字符的位置
,有效字符的起始位置不一定就是0位置,也有可能是1,2,…位置,但是最多等于words[i].length - 1
,所以需要执行words[i].length - 1
次滑动窗口算法 - 哈希表的存储:
字母异位词
中使用哈希数组模拟哈希表,因为都是小写的字符;本题只能使用哈希表还存储字符串和其出现的频率
- l和r指针的移动步数:本题是
按字符串遍历
,所以指针一次移动的步数等于words[i].length - 1
代码
class Solution {
public List<Integer> findSubstring(String s, String[] words) {
List<Integer> ret = new ArrayList<>();
int m = words.length, n = words[0].length(), len = s.length();
if(len < m * n) return ret;
Map<String, Integer> hash1 = new HashMap<>();
for(String str : words) hash1.put(str, hash1.getOrDefault(str, 0) + 1);
for(int i = 0; i < n; i++) {
int l = i, r = i, cnt = 0;
Map<String, Integer> hash2 = new HashMap<>();
while(r + n <= len) {// 等于len的时候也有可能满足条件 下面唯一会发生的越界的就是第一行代码 是左闭右开
// 进窗口
String in = s.substring(r, r + n);
hash2.put(in, hash2.getOrDefault(in, 0) + 1);
if(hash2.get(in) <= hash1.getOrDefault(in, 0)) cnt++;// 有效字符
// 判断 + 出窗口
if(r - l + 1 > m * n) {
String out = s.substring(l, l + n);
if(hash2.get(out) <= hash1.getOrDefault(out, 0)) cnt--;// 删除的是有效字符
hash2.put(out, hash2.get(out) - 1);
l += n;
}
// 更新结果
if(cnt == m) ret.add(l);
r += n;
}
}
return ret;
}
}
8.最小覆盖子串
最小覆盖子串
分析
- 本题的解法思路和上题类似
- 需要注意本题求的是
最小覆盖子串
,求的是最小长度,需要在条件判断成立是更新结果
代码
哈希表解法
class Solution {
public String minWindow(String ss, String tt) {
char[] s = ss.toCharArray(), t = tt.toCharArray();
Map<Character, Integer> hash1 = new HashMap<>();
Map<Character, Integer> hash2 = new HashMap<>();
for(char ch : t) hash2.put(ch, hash2.getOrDefault(ch, 0) + 1);
String ret = "";
int l = 0, r = 0, n = s.length, len = t.length, minlen = 0x3f3f3f3f, cnt = 0;
if(n < len) return ret;
while(r < n) {
// 进窗口
char in = s[r];
hash1.put(in, hash1.getOrDefault(in, 0) + 1);
if(hash1.get(in) <= hash2.getOrDefault(in, 0)) cnt++;
// 判断 + 出窗口 + 更新结果
while(cnt == len) {
if(r - l + 1 < minlen) {
ret = ss.substring(l, r + 1);
minlen = r - l + 1;
}
char out = s[l];
if(hash1.get(out) <= hash2.getOrDefault(out, 0)) cnt--;
hash1.put(out, hash1.get(out) - 1);
++l;
}
++r;
}
return ret;
}
}
数组解法
- s和t中的元素都是英文字母,ASCII码值为
97-122
,可以开辟一个大小为128的数组(128是ASCII码的最大值)
class Solution {
public String minWindow(String ss, String tt) {
char[] s = ss.toCharArray(), t = tt.toCharArray();
int[] hash1 = new int[128], hash2 = new int[128];
for(char ch : t) ++hash2[ch];
String ret = "";
int l = 0, r = 0, n = s.length, len = t.length, minlen = 0x3f3f3f3f, cnt = 0;
if(n < len) return ret;
while(r < n) {
// 进窗口
++hash1[s[r]];
if(hash1[s[r]] <= hash2[s[r]]) cnt++;
// 判断 + 出窗口 + 更新结果
while(cnt == len) {
if(r - l + 1 < minlen) {
ret = ss.substring(l, r + 1);
minlen = r - l + 1;
}
if(hash1[s[l]] <= hash2[s[l]]) cnt--;
hash1[s[l++]]--;
}
++r;
}
return ret;
}
}
-
所谓的算法优化都是建立在暴力解法的基础之上,正是看到了暴力解法的冗余,才想到优化的算法
-
滑动窗口算法有一个比较明显的切入点
求区间内部最长/最短问题