滑动窗口
在上一篇文章中,我们了解到了双指针算法,在双指针算法中我们知道了前后指针法,这篇文章就要提到前后指针法的一个经典的使用 ------ 滑动窗口,在前后指针法中,我们知道一个指针在前,一个指针在后,但是两个指针都是向前移动,如果两个指针之间的元素或者下标之间的差值有一些别的作用或者含义的时候(本质上就是子数组或者子串),我们会使用到滑动窗口这个算法思想,下面以第一道例题为引例。
题目实战
引例 ------ 长度最小的子数组
https://leetcode.cn/problems/minimum-size-subarray-sum/
这道题目要求我们找到最少元素构成的子数组之间所有的数据总和大于等于 target , 也就是找子数组的题目,如果使用暴力美学,那么就需要枚举出所有的子数组,然后进行比较,时间复杂度为 O(N ^ 2)
暴力美学也是使用的是前后指针法,但是由于没有利用好递增关系,所以你也可以认为滑动窗口也是前后指针法的进阶,我们先这样看,使用一个指针遍历数组,然后使用 count 来统计遍历过的元素之和,由于数组的元素是正整数,所以 count 会随着 指针的移动而增加,在数学上,我们认为这是一种递增关系。
这样我们可以先让一个指针遍历数组(这个可以叫进窗口),直到 count >= target 的时候,就需要更新最短的长度,以及让 后指针 向前移动(这个可以叫出窗口),直到 count < target,此时这个循环会让count 减小,这也就让长度减小,所以要在循环中也顺便更新最短的长度。这就是滑动窗口的思路
思考为什么前指针不需要后退,而是直接向前遍历即可?
在暴力美学中,前指针都是回到后指针前一个位置,然后开始继续向前遍历计算新的 count,但是由于具有递增关系,前指针回退是没有必要的,因为已经计算好当前的 count 了,你的回退反而浪费原本的count 这个数值。只需要继续向前遍历即可,我们这个 count 在滑动窗口是动态变化的,根据前指针移动而增加,根据后指针移动而减少。
所以滑动窗口是对前后指针法的暴力美学的优化
在思考问题的时候,我们优先会想到暴力美学,因为它直观也容易想到,如果符合滑动窗口的特性,我们就可以尝试优化。
总结一下滑动窗口的算法思想:
1.进窗口
2.出窗口
3.更新答案(下面是说成数据)(根据题意调整具体位置)
时间复杂度为O(2N) = O(N)
java
class Solution {
public int minSubArrayLen(int target, int[] nums) {
int len = nums.length;
int count = 0;
int ans = Integer.MAX_VALUE;
for(int left = 0, right = 0; right < len; right++) {
//进窗口
count += nums[right];
//出窗口
while(count >= target) {
ans = Math.min(ans, right - left + 1); //更新数据
count -= nums[left++];
}
}
return ans == Integer.MAX_VALUE ? 0 : ans;
}
}
无重复字符的最长子串
https://leetcode.cn/problems/longest-substring-without-repeating-characters/description/
解析:
如果使用暴力美学,还是前后指针法,由于这是求最长的字符串(也就是子串问题),对于子串子数组我们优先会想到滑动窗口算法。
在计算不重复的字符串的时候,前指针遇到不重复的字符,两个指针之间的距离就会自增,如果遇到重复的字符,后指针就会向前移动进行去重,按照暴力美学,前指针应该回退到后指针的前一个位置,但是有必要回退吗?首先因为经过后指针的去重,此时前后指针之间就没有重复的字符,这时候没有必要前指针回退,而这时候就是滑动窗口算法的特点。
使用滑动窗口,将不重复的字符加入到窗口中,当遇到重复的字符后指针就向前移动进行去重,再进行完进窗口和出窗口后,我们就可以更新最长长度了。
如何去重呢?大家可以使用Java 给我们提供的 HashSet,也就是哈希表进行去重。
补充:字符串转化成 字符数组的 方法 是 toCharArray().
本题使用 滑动窗口 + 哈希表,时间复杂度为O(2N) = O(N)
java
class Solution {
public int lengthOfLongestSubstring(String ss) {
int ans = Integer.MIN_VALUE;
char[] s = ss.toCharArray();
int len = s.length;
Set<Character> set = new HashSet<>();
for(int left = 0, right = 0; right < len; right++) {
char ch = s[right];
if(!set.contains(ch)) {
set.add(ch); //进窗口
} else {
while(ch != s[left++]) {
set.remove(s[left-1]);//出窗口
}
}
//更新数据
ans = Math.max(ans,right - left + 1);
}
return ans == Integer.MIN_VALUE ? 0 : ans;
}
}
最大连续1的个数
https://leetcode.cn/problems/max-consecutive-ones-iii/description/
解析:
由于这是字数组问题,可以考虑滑动窗口,由于题目给定的 K 是最大零数字的承载量,所以如果超过这个量就需要进行去重操作,后指针直接前移,在零元素的前一个位置停下即可,这是出窗口,进窗口的条件很简单就是小于等于承载量的时候,更新数据放在最后,这样就保证这是一个符合题意的子数组。
承载量如何判断?引入一个变量 count 来记录当前的零元素个数
java
class Solution {
public int longestOnes(int[] nums, int k) {
int ans = Integer.MIN_VALUE;
int len = nums.length;
int count = 0;
for(int left = 0, right = 0; right < len; right++) {
//进窗口
if(nums[right] == 0) {
count++;
}
//出窗口
if(count > k) {
while(nums[left++] != 0) {;}
count--;
}
//更新数据
ans = Math.max(ans, right - left + 1);
}
return ans;
}
}
将 x 减到 0 的最小操作数
https://leetcode.cn/problems/minimum-operations-to-reduce-x-to-zero/
解析:
由于每次只能减去最左端或者最右端的数值,所以如果最后有结果的话,那就是数组减去左区间和右边间,这两个区间的所有数字之和恰好等于 x
题目要求求出最小的操作数,说明左右区间的长度之和要最小。
如果正着求比较难的话,我们可以尝试反着求,也就是求中间部分最长,求中间部分一看就知道可以使用滑动窗口的方法。中间部分的数值应该等于 数组的所有数据之和减去 x ,我这里使用 target 变量来接受。
现在就是处理滑动窗口了,使用 sum 来接受此时 前后指针之间的所有的数据之和,先进窗口(sum 加数据),然后如果 sum > target 的时候就要出窗口(使用循环),最后更新数据的条件就是当 target == sum 时,进行更新数据。
细微条件处理:当数组的所有的数据之和小于 x 的时候,直接返回 -1 ,不能进入滑动窗口的部分,因为 target 计算后 sum - x = target < 0,在滑动窗口中 sum 始终大于 taget ,最终会一直出窗口,然后发生数组越界访问。
java
class Solution {
public int minOperations(int[] nums, int x) {
int sum = 0;
for(int y : nums) {
sum += y;
}
int target = sum - x;
if(target < 0) {
return -1;
}
int len = nums.length;
int count = Integer.MIN_VALUE;
sum = 0;
for(int left = 0, right = 0; right < len; right++) {
//进窗口
sum += nums[right];
//出窗口
while(sum > target) {
sum -= nums[left++];
}
//更新数据
if(sum == target) {
count = Math.max(count, right - left + 1);
}
}
return count == Integer.MIN_VALUE ? -1 : len - count;
}
}
水果成篮
https://leetcode.cn/problems/fruit-into-baskets/
解析:
最多只能装两种水果类型,题目要求我们要求出最大的采摘数量,显而易见的要使用滑动窗口。
进窗口,直接边遍历边进。
出窗口,就是遇到第三个水果类型,需要进行调整。
更新数据,在前两步骤做好就可以进行数据的更新了。
现在就要讨论用什么东西来装水果?
我们可以使用哈希表,Map 来接受水果类型和该水果目前的数量。
整体思路就是 哈希表加滑动窗口
java
class Solution {
public int totalFruit(int[] fruits) {
Map<Integer,Integer> map = new HashMap<>();
int len = fruits.length;
int count = Integer.MIN_VALUE;
for(int left = 0, right = 0; right < len; right++) {
//进窗口
map.put(fruits[right],map.getOrDefault(fruits[right],0) + 1);
//出窗口
while(map.size() > 2) {
map.put(fruits[left],map.get(fruits[left]) - 1);
if(map.get(fruits[left]) == 0) {
map.remove(fruits[left]);
}
left++;
}
//更新数据
count = Math.max(count, right - left + 1);
}
return count;
}
}
找到字符串中所有字母异位词
https://leetcode.cn/problems/find-all-anagrams-in-a-string/description/
解析:
还是子串问题,使用滑动窗口。由于涉及到字符串的查找,所以我们可以使用 toCharArray() 方法将字符串转化为数组,便于我们的使用。
如何存储两个字符串的内容:我们会想到使用哈希表,使用Map 来接受 字母 ------ 出现数量 这个键值对,但是题目说了字符串只包含小写字母,那我们可以自己创建两个数组来模拟哈希表,而且使用数组更加方便。
如何比较两个字符串的内容?很多老铁一定想过使用循环遍历,而且我们还是使用数组来模拟哈希表,那就更加方法,这里介绍一种更好的方法,不需要循环遍历就可以比较成功。
使用一个变量来接收有效的字符数量。
如何使用?
我们在进出窗口的过程中顺便统计有效字符的个数,什么是有效的字符,我们需要和另一个哈希表的内容进行判断
这也就说明了我们要事先制作好一个哈希表,然后才开始遍历字符串 s
在遍历过程中,s 的哈希表对应的下标的元素先自增,然后开始判断,当这个字符也存在于 p 的哈希表中,并且 s 对应的元素值要小于等于 p 哈希表的,就算作有效字符
如果是大于 p 的,说明这个字符要么是重复字符要么是 p 哈希表不存在的字符。
出窗口:什么时候要出窗口?答:当当前的字符串长度大于 字符串 p 的长度 ,因为我们在进窗口的时候,只会统计有效字符个数,当有效字符个数等于 p 字符串的时候,才会更新数据,而此时满足当前 前后指针夹着的字符串全是有效字符 这个条件,只要当前的字符串的长度大于 p 字符串的长度的时候,就一定不是有效字符串,这时候就可以出窗口,顺便调整 s 哈希表的内容,由于此时的调整可能会删除掉有效字符,所以这个也要考虑有效字符的数量的变化,如果你要删除的字符对应的 s 哈希表的数值 小于等于 p 哈希表的时候 ------ 有效字符数量就要自减,并且出窗口的次数实际上就是一次,所以用不到循环。
java
class Solution {
public List<Integer> findAnagrams(String ss, String pp) {
List<Integer> list = new ArrayList<>();
char[] s = ss.toCharArray();
char[] p = pp.toCharArray();
int[] hash1 = new int[26];
int[] hash2 = new int[26];
for(char x : p) {
hash2[x-'a']++;
}
int len = s.length;
int count = 0;
int size = p.length;
for(int left = 0, right = 0; right < len; right++) {
char ch = s[right];
//进窗口
if(++hash1[ch-'a'] <= hash2[ch-'a']) {
count++;
}
//出窗口
if(right - left + 1 > size) {
if(hash1[s[left]-'a']-- <= hash2[s[left]-'a']) {
count--;
}
left++;
}
//更新数据
if(count == size) {
list.add(left);
}
}
return list;
}
}
串联所有单词的子串
https://leetcode.cn/problems/substring-with-concatenation-of-all-words/description/
解析:
这道题目和上一道单词的异位词很相似,只不过这道题目是以单词为单位的。
不过我们如果将单词视为一个字符的话,思路和上面的一模一样。
使用两个哈希表存放单词。
如何划分字符串,由于 words 中每个单词的长度都是一致的,所以我们可以将字符串按照这个数量进行划分。
我们从暴力美学出发,如果要找出所有的子字符串,那么就需要两层循环。
在暴力美学的内层循环中我们要找到符合的子串,可以使用滑动窗口来求解,因为要比较的单词的长度都是一致的 ,所以我们可以按照单词的长度对字符串进行划分,也就是说在内层循环中使用滑动窗口的时候,指针每次移动都是加单词的长度
那么外层循环有必要遍历整个字符串吗?
答:没有必要,因为单词的长度是一致的,所以你单词的划分最多的情况就是单词的长度:
当外层循环遍历次数超过单词长度的时候,你会发现单词的划分和前面的循环重合,所以这些超过单词长度的循环次数是没有必要的,你如果硬要遍历的话,也可以,就是时间复杂度差了些。
所以继续优化的话,最外层循环次数就是单词的长度
最后就是要注意方法的使用了,如何划分字符串,使用 substring(), 哈希表的使用方法要注意 getOrDefault(),如果不存在的话可以返回默认值,但是不会加入到哈希表,其他的就不说了,不了解这些方法的,可以去我往期文章中查阅:String 类
JavaDS ------ 二叉搜索树、哈希表、Map 与 Set
java
class Solution {
public List<Integer> findSubstring(String s, String[] words) {
List<Integer> list = new ArrayList<>();
Map<String,Integer> mapW = new HashMap<>();
for(String x : words) {
mapW.put(x,mapW.getOrDefault(x,0) + 1);
}
int lenS = s.length();
int len = words[0].length();
Map<String,Integer> mapS = new HashMap<>();
int count = 0;
int size = words.length;
for(int i = 0; i < len; i++) {
mapS.clear();
count = 0;
for(int left = i, right = i; right + len <= lenS; right+=len) {
String str = s.substring(right,right+len);
mapS.put(str,mapS.getOrDefault(str,0) + 1); //进窗口
if(mapS.get(str) <= mapW.getOrDefault(str,0)) {
count++;
}
//出窗口
if(right - left + 1 > len * size) {
String ch = s.substring(left,left+len);
if(mapS.get(ch) <= mapW.getOrDefault(ch,0)) {
count--;
}
mapS.put(ch,mapS.get(ch)-1);
left += len;
}
//更新数据
if(count == size) {
list.add(left);
}
}
}
return list;
}
}
最小覆盖子串
https://leetcode.cn/problems/minimum-window-substring/
解析:
还是一个子串问题,使用滑动窗口,由于字符串全是英文字母组成,我们可以使用数组来模拟哈希表的映射关系。
这里我们可以使用 count 变量来统计有效字符个数。
因为题目要求出最小覆盖子串,所以难免会有重复或者不存在于 t 的字符出现,所以为了便于判断是否已经容纳好 t 的所有字符,减少循环出现,我们可以使用额外的变量来统计有效字符个数
进窗口:这个无需多言,直接进
出窗口和更新数据要小心点,当 count 等于有效字符个数的时候,我们要更新数据,那什么时候出窗口呢?如果按照之前的讨论进窗口------出窗口------更新数据是很难实现的,在最开始的时候,我就跟大家提到更新数据这个操作要按照实际情况来讨论,在这道题目就体现出来了。
首先更新完数据后,我们希望在下一个滑动窗口循环中能得到一个残缺的字符串,就是 count 要小于 有效字符总数这一个条件,所以在更新数据之后,我们可以进行出窗口的操作,要注意 count 的 变化,既然要达到 count 要小于 有效字符总数这个条件,我们可以在更新数据的时候写一个循环,正好随着出窗口的过程中一起将数据更新。
java
class Solution {
public String minWindow(String ss, String tt) {
String str = "";
if(ss.length() < tt.length()) {
return str;
}
int lenS = Integer.MAX_VALUE;
int[] hash1 = new int[128];
int[] hash2 = new int[128];
char[] s = ss.toCharArray();
char[] t = tt.toCharArray();
for(char x : t) {
hash2[x]++;
}
int len = s.length;
int count = 0;
int size = t.length;
for(int left = 0, right = 0; right < len; right++) {
//进窗口
char ch = s[right];
hash1[ch]++;
if(hash1[ch] <= hash2[ch]) {
count++;
}
while(count == size) {
//更新数据
if(lenS > right - left + 1) {
lenS = right - left + 1;
str = ss.substring(left,right+1);
}
//出窗口
char x = s[left++];
if(hash1[x]-- <= hash2[x]) {
count--;
}
}
}
return str;
}
}
小结
滑动窗口一般用于子数组或者子串问题上
滑动窗口的基本思路:
1.设置好两个指针 (left = 0, right = 0)
2.进窗口控制 right 指针
3.出窗口控制 left 指针
4.根据实际情况更新数据
一些注意事项:
如果发现字符串完全由字母组成,可以直接使用数组来模拟哈希表,这样做会方便很多
如果涉及到有效字符或者有效数字的时候,可以引入一个变量 来进行统计