滑动窗口是双指针的一种应用,它的特点就是窗口要么扩张,要么向右滑动,不存在窗口缩小的情况。这样当O(N)遍历一遍后,窗口的长度就是要求的最大值。
先看一道最简单的滑动窗口题,给定一串字符串(只包含大写字母),求最长连续序列的长度。这道题用普通的双指针或者别的做法都能做,但是这里用滑动窗口演示一下:
例:ABAA----2 AAAA----4 ACDBB----2 …
开始这两步和普通的双指针没什么区别,left不动,right+1,代表窗口扩张,但是当碰到 “B” 的时候,我们并没有让left直接移到 “B” 的位置,这是普通双指针的做法;而是让left和right同时+1,代表窗口向右滑动。
这时窗口内的序列为 “AB” 不符合连续的要求,接着向右滑动。这也是滑动窗口和普通双指针的区别,普通双指针维护的是连续序列,滑动窗口只记录最长的序列,因为前面已经有 "AA"了,碰到比这个短的连续序列可以直接无视,接着滑动。
这个时候窗口内的序列 “BB” 满足连续的要求,可以先扩张窗口,如果扩张成功,就不动left;如果扩张不成功再left+1,相当于让窗口向右滑动。
此时滑动窗口已经滑到最右边了,滑动窗口的长度就是最长的连续序列长度,可以看到滑动窗口内此时并不是连续序列,因为滑动窗口不会收缩,遇见过最长的序列,这些短的序列就看不上眼了。
图看起来比较直观,但是要用代码实现需要解决一个核心问题: 怎么判断窗口内的序列是连续的? 不管是窗口扩张后,还是窗口滑动后,我们都需要判断当前窗口内的序列是不是连续的,总不能从left开始再数一遍吧,这就失去了滑动窗口的意义了。
注意到两个核心点后,可能思路会清晰一点:
- 序列只有26个大写字母,可以用哈希表,或者说计数表,思想是一样的:int[] count = new int[26]。专门用来记录窗口内各个字母的个数。
- 无论是窗口扩张,还是窗口滑动,right都是+1;left只有在滑动的时候才会+1。
那怎么说明窗口内的序列是连续的呢?只要count中最大值等于窗口长度,就说明是连续的。但是怎么求count中最大的值呢?这又是一个问题,总不能每次从头到尾遍历count数组吧,这就太慢了。
这时第二点就派上用场了,right每次都+1,那么right位置的字母个数要+1,"可能"让最大个数发生变化的就只能是这个字母了。我们只需要让扩张之前的最大值和这个字母的个数比就行了。
可能有人会有疑惑了,那left有时候还要+1呢,这个动作也会改变count啊。这时就要再想一下上面的那句话了,我们是先让窗口扩张的,如果扩张之后不满足连续的要求,就把left位置的字母个数-1,代表这个字母从窗口中减少了一个,然后left+1。也就是我们在比count的最大值的时候,left的位置的字母还没被踢出去呢,当然不会影响我们找最大值。单看文字可能比较干,下面举一个简单的例子:
比如现在窗口内是 “BB”,向右扩张,变成 “BB*” ,这个 * 的个数就是我们要比的,如果这个 * 是 B,那么 2 和 3 比,最大值就变3了;如果这个 * 是别的字母,那 2 和 1比还是2,2 < 3 不连续,需要让窗口向右滑动,那么 left+1 之前先把 B 的个数减一,这样窗口滑动之后变成 “BA” ,B 的个数是 1,A 的个数也是 1。
理解了这个例子就可以开始写代码了:
public int solution(String s) {
if (s == null || s.length() == 0)
return 0;
int left = 0, right = 0;
int maxCount = 0;
int[] count = new int[26];
while (right < s.length()) {
// 窗口最右边的字母个数+1
count[s.charAt(right) - 'A']++;
// 找当前窗口中字母个数的最大值
maxCount = Math.max(maxCount, count[s.charAt(right) - 'A']);
// 最大值小于窗口长度,说明不连续,需要窗口滑动
if (maxCount < right - left + 1) {
count[s.charAt(left) - 'A']--;
left++;
}
// 窗口扩张
right++;
}
// 遍历一遍后,窗口长度就是最长的序列长度
return right - left;
}
下面再看今天的每日一题,也是这道题的变形:
给你一个仅由大写英文字母组成的字符串,你可以将任意位置上的字符替换成另外的字符,总共可最多替换 k 次。在执行上述操作后,找到包含重复字母的最长子串的长度。
猛一看好像和上面的题没啥关系,但是其实上面的代码只改一行,就是这道题的答案。这里无非是判断连续的条件宽松了一点,即有 k 次错的机会,窗口内的序列可以不连续,只要错的字母个数小于等于k,我还可以认为你是连续的。
那只需要改if的条件就可以了,之前是maxCount < right - left + 1就要滑动窗口,现在maxCount + k < right - left + 1才需要滑动窗口,都给你 k 次机会了,结果还是不连续,那就只能滑动窗口了。
滑动窗口模板题
leetcode643:给定 n 个整数,找出平均数最大且长度为 k 的连续子数组,并输出该最大平均数。
输入:[1,12,-5,-6,50,3], k = 4
输出:12.75
解释:最大平均数 (12-5-6+50)/4 = 51/4 = 12.75
public double findMaxAverage(int[] nums, int k) {
int left = 0, right = 0;
int ans = Integer.MIN_VALUE;
int sum = 0;
while (right < nums.length) {
sum += nums[right];
if (right >= k - 1) {
ans = Math.max(ans, sum);
sum -= nums[left];
left++;
}
right++;
}
return ans / (double) k;
}
给你两个长度相同的字符串,s 和 t。
将 s 中的第 i 个字符变到 t 中的第 i 个字符需要 |s[i] - t[i]| 的开销(开销可能为 0),也就是两个字符的 ASCII 码值的差的绝对值。
用于变更字符串的最大预算是 maxCost。在转化字符串时,总开销应当小于等于该预算,这也意味着字符串的转化可能是不完全的。
如果你可以将 s 的子字符串转化为它在 t 中对应的子字符串,则返回可以转化的最大长度。
如果 s 中没有子字符串可以转化成 t 中对应的子字符串,则返回 0。
示例:
输入:s = “abcd”, t = “bcdf”, cost = 3
输出:3
解释:s 中的 “abc” 可以变为 “bcd”。开销为 3,所以最大长度为 3。
public int equalSubstring(String s, String t, int maxCost) {
int left = 0, right = 0;
int sum = 0;
int n = s.length();
while (right < n) {
sum += Math.abs(s.charAt(right) - t.charAt(right));
if (sum > maxCost) {
sum -= Math.abs(s.charAt(left) - t.charAt(left));
left++;
}
right++;
}
return right - left;
}
上面两个题都是不折不扣的模板题,没有任何套路,滑动窗口的核心代码大致可以理解为如下:
枚举右边界,即right++:
把右边界的值加入当前计算,不同的题计算逻辑不同
if (满足某条件,左边界需要收缩)
收缩之前更改记录,即把左边界的值从当前记录中去掉
左边界收缩,即left++
难的题应该就是计算逻辑复杂一些,判断左边界收缩的条件复杂一些,以及如何看出来这是一道滑动窗口的题,个人的刷题量也比较少,后续有新的理解再补。