首先介绍滑动窗口的定义及三个重要的步骤:1收缩条件、2窗口右移更新 和3窗口左移更新,然后以一个简单的定长求窗口和的例子介绍滑动窗口的思想并给出模板;然后还是5道经典力扣题及详细题解、最后总结。
最后的结论是:“有些时候模板也解决不了问题!” 遇到hard题还是回厕所哭去吧….
[1]参考了开源项目模板 github.com/greyireland/algorithm-pattern
[2]参考了博客园huansay的文章www.cnblogs.com/huansky/p/13488234.html
除文中标注的4处引用外,其他皆为原创,保留权力。
1 定义和模板
1.1 滑动窗口的定义
“滑动窗口算法是在给定特定窗口大小的数组或字符串上执行要求的操作。该技术可以将一部分问题中的嵌套循环转变为一个单循环,因此它可以减少时间复杂度。”[2]
两个集合的定义:窗口集合指当前窗口,一般只关心窗口中有用的元素,如窗口和、最大值、字符出现的次数等;需求集合就是题目给的条件,用它是否满足判断是否需要收缩。 滑动窗口在使用方面,我把它分为两类:1、窗口长度固定的;2、窗口长度可以根据需要变化的。两种本质是一样的,只是在窗口是否收缩的条件不一样
我觉得最重要的是要弄清楚收缩条件、窗口右移更新 **和窗口左移更新**
- 收缩条件指何时收缩,并且要用程序的语言(计数、长度或者什么)来判断。
- 定长窗口收缩的条件就是窗口长度>指定长度
- 不定长窗口就是窗口内的元素满足了某个条件、你收缩一下让他不满足。(当然收缩之前一般要保存一下结果)
- 窗口右移更新:指右指针右移之后,窗口内的数据更新,一般只更新有用的信息
- 窗口左移更新:指左指针右移之后,一般是删除掉原左指针对应的元素信息
- ==一般结果集的获取也放在收缩循环里面==
- ==但如果收缩循环没有完成窗口的彻底更新,结果集的获取就不能放在循环里,比如最长子串==
1.2 一个定长窗口的例子
计算长度为k的窗口元素之和,返回一个数组。数组长度为length-k+1、每个元素为对应窗口的和;图片来源于[2]

遍历过程如上图,如果用常规暴力解法,就是两层循环:外层遍历数组,内层遍历窗口中的所有值并加和
1 | for(int i=0;i<nums.length-k;++i){ |
但其实内层是不必要重新遍历一次的:我们如果有了前一个窗口的信息(前一个窗口的和),那我们要求下一个窗口的和,只需要将sumWin
减去移出窗口的那个值&&加上新增进窗口的那个值。
虽然用for循环也可以,这里还是用模板中常用的while循环;并且如果窗口大小固定,收缩窗口其实不需要循环、用if就够了。
1 | //这里爷用通用的left和right指针来操作窗口的范围和元素 |
1.3 滑动窗口模板
先放一个[1]开源项目的通用模板。代码中需要变化的地方[1]:
- 右指针右移之后窗口数据更新
- 判断窗口是否要收缩,结果集的更新紧跟着判断收缩之后
- 左指针右移之后窗口数据更新
- 根据题意计算结果
代码中的变量:
need
和window
都是map集合:need
存是固定的目标字符串元素(或数组);window
存窗口当前所含的元素。match
变量表示窗口中满足need
条件的字符个数,就是匹配次数
1 | /* 滑动窗口算法框架[1] */ |
等我多做几道题之后,再看看能不能把它改写成 java 版的吧,这里还有一个疑问,为什么要搞成左闭右开[left,right)
的区间,左右都闭起来[left,right]
不是更符合常识一点么?还有一个就是如果是窗口长度不变,就不需要用while,用if就够了….不过统一用while也不错。
我还有话要讲:
对于一些字符串、数组贮存问题,因为字符串个数、数字个数都是有限的:ASCII字符的个数是128个、数字个数是10个。所以大多数时候need
和window
可以用桶的思想来贮存字符、显然比Map更快。
使用数组存元素时,字符的值的大小就是数组的下标索引,而字符出现的次数就是该位置应该付的值
2 力扣经典题目及解答
又要开始喜闻乐见的分析题意、和套模板环节了。这跟高考数学物理一般难度的题也没什么区别,就是多做、然后就tmd记住了,遇到差不多的也能写。
2.1 力扣76.最小覆盖子串(困难)
https://leetcode-cn.com/problems/minimum-window-substring/
给你一个字符串 s 、一个字符串 t 。返回 s 中涵盖 t 所有字符的最小子串。如果 s 中不存在涵盖 t 所有字符的子串,则返回空字符串 “” 。注意:如果 s 中存在这样的子串,我们保证它是唯一的答案。
示例 :
1
2 输入:s = "ADOBECODEBANC", t = "ABC"
输出:"BANC"
思路分析
果然是hard级别的题,我上来就分析收缩条件、窗口集合和需求集合:
- 收缩条件,当需求t中的所有字符都出现在窗口中并且出现此处也相同时(完全匹配),需要保存结果并收缩窗口
- 匹配条件用一个match变量表示,每一次循环right右移一格,判断match是否需要+1
- 当match达到最大值时,考虑紧缩窗口left–。紧缩窗口时通过判断紧缩之后对match是否有影响来考虑是否对match–
- 窗口集合win用一个
hashmap
或者数组[]表示,键为元素,值为出现次数。 - 需求集合need同样用一个
hashmap
或者数组[]表示,键为元素,值为出现次数- 只有当元素在need中存在时,才考虑移进或者移出窗口
代码实现
解法一:使用HashMap
实现,会慢一些、但是标标准准整整齐齐。要注意:
- 两个Integer值比较时,要用
a.equals(b)
,因此超过[-128,127]就会new一个对象来比较 - 变量min 和 start end用于保存某一次结果,放在循环外面(全局作用域)
1 | class Solution { |
解法二:字符串的ASCII表总长128,可以用“桶”的思想,将目标子串放在含128个坑的桶中,效率挺高
使用数组存值,可以直接使用char字符作为数组的索引
1 | class Solution { |
2.2 力扣567.字符串的排列
https://leetcode-cn.com/problems/permutation-in-string/
给定两个字符串
s1
和s2
,写一个函数来判断s2
是否包含s1
的排列。换句话说,第一个字符串的排列之一是第二个字符串的 子串 。示例 1:
1
2
3 输入: s1 = "ab" s2 = "eidbaooo"
输出: True
解释: s2 包含 s1 的排列之一 ("ba").
思路分析
借鉴上一题最小覆盖子串的思维,当win的长度与need的长度相同,且其中的元素的数量能够一一对应时说明win中此时是need的一个全排列之一。
win依然用一个map集合或者桶数组表示,并且win里面只存need中有的元素,用来校对是否完全匹配。实际窗口的大小还是right-left
;need依然用一个map集合或者桶数组表示,need只存需求中不重复的元素,并且计数用来校对匹配。实际需求的长度是待匹配的字符串的长度s.length()
还是分析收缩条件、窗口右移更新和窗口左移更新:
收缩条件:这里貌似是一个定长窗口,当窗口长度
right-left
超过子串的长度时就应该收缩了- 也可以参考上一题的,当完全匹配时收缩,可能效率会低一些
窗口右移更新:
- 右指针右移后,如果该元素位于need集合中,就将之添加进win;
- 如果该字符的个数也匹配了,则match++
窗口左移更新:
- 左指针右移后,如果该元素位于need中,将之移出win
- 如果该字符移出以之前是完全匹配的,则需要match–
代码实现
因为只含有小写字母,所以桶的大小定为26,用字符char求索引时减去’a’即可
1 | class Solution { |
2.3 力扣438.找到字符串中所有字母异位词
https://leetcode-cn.com/problems/find-all-anagrams-in-a-string/
给定一个字符串 s 和一个非空字符串 p,找到 s 中所有是 p 的字母异位词的子串,返回这些子串的起始索引。字符串只包含小写英文字母,并且字符串 s 和 p 的长度都不超过 20100。
说明:字母异位词指字母相同,但排列不同的字符串不考虑答案输出的顺序。示例 2:
输入: s: “abab” p: “ab”
输出: [0, 1, 2]
解释:
起始索引等于 0 的子串是 “ab”, 它是 “ab” 的字母异位词。
起始索引等于 1 的子串是 “ba”, 它是 “ab” 的字母异位词。
起始索引等于 2 的子串是 “ab”, 它是 “ab” 的字母异位词。
思路分析
只要出现全排列之一就把起始索引存入结果集合中,其他的和567字符串的排列相似。还是简单分析一下收缩条件、窗口右移更新和窗口左移更新:
- 收缩条件:为定长窗口,窗口长度>字符p的长度,就表示要收缩了
- 窗口右移更新:当need中含当前元素,就将之加入win集合中;如果该字符完全匹配了(个数相同)则match++;
- 窗口左移更新:先判断是否完全匹配,是就加入结果集。然后如果该元素位于need中,就更新match和win
代码实现
做了上一题,这题也就没什么意思了
1 | class Solution { |
2.4 力扣3.无重复字符的最长子串
思路分析
如果出现重复元素就紧缩窗口、紧缩窗口时需要移出left对应的元素。使用一个hashSet集合或者桶数组作为窗口win;需求集合就是窗口本身、要求是不出现重复元素。还是分析收缩条件、窗口右移更新、窗口左移更新:
- 收缩条件:当win中出现了重复元素就需要跟紧缩窗口、知道没有重复元素
- 其实每次加入的是right,所以重复的也只可能是right对应的元素
- 窗口右移更新:right++后,将right加入win集合,其实加不加都无所谓,反正清除重复之后、还要将它重复加入的
- 窗口左移更新:去掉位于left上重复的元素,如果清除完了、记得添加一个回来
代码实现
求得是最长的字符串长度,所以要记录好每次的窗口大小并与之前的取大值
方法一:用hashset作为窗口集合 8ms
1 | class Solution { |
方法二:用桶数组作为win集合 3ms
1 | class Solution { |
2.5 剑指offer.59-1 滑动窗口的最大值(hard)
https://leetcode-cn.com/problems/hua-dong-chuang-kou-de-zui-da-zhi-lcof/
给定一个数组 nums 和滑动窗口的大小 k,请找出所有滑动窗口里的最大值。输入: nums = [1,3,-1,-3,5,3,6,7], 和 k = 3
输出: [3,3,5,5,6,7]
滑动窗口的位置 最大值
[1 3 -1] -3 5 3 6 7 3
1 [3 -1 -3] 5 3 6 7 3
1 3 [-1 -3 5] 3 6 7 5
1 3 -1 [-3 5 3] 6 7 5
1 3 -1 -3 [5 3 6] 7 6
1 3 -1 -3 5 [3 6 7] 7
思路分析
这题真狗、我又没做出来….
思路其实也挺简单、代码实现一下有点绕。就是一个固定长度为k的窗口,窗口使用双端队列的结构、始终保持窗口队列中元素的降序属性(因为较小的值无用可以舍弃)。没滑动一次窗口、就取出队首元素存入结果的数组中。需要注意:
- 因为涉及出队和入队的问题,right由0开始,而left由
0-k+1
处开始,每滑动一次窗口、right和left都需要+1 - 紧缩窗口:就是当队首元素是上一个窗口left位置的值时(也即是现在的
left-1
),需要先从队列中移除- 刚开始left为负,所以要注意left的范围
- 保持队列的降序并添加元素:如果队尾的元素小于要新加的元素 则删除,,直到删干净再加入新元素
代码实现
1 | class Solution { |
3 总结一下
每次刷几道同一类型的经典题,总结一下还是很有好处的。
滑动窗口不外乎两个集合窗口集合windows
和需求集合need
和两个指针left\right
,需求集合首先根据要求将需求集合初始化,然后while循环中right指针不断向前推进、left指针在窗口需要收缩是向右移动,并窗口扩大和收缩过程都需要更新窗口内的数据。while内部伪代码步骤如下:
1 | while(right<length){ |
值得注意的是:
- 结果集的获取和保存一般放在紧缩窗口的while循环内,窗口紧缩的条件也包括窗口(大小)正满足条件的情况
- 作为窗口集合和需求集合,可以选用
hashmap
或者hashset
这样的集合、有时也可以使用数组以桶的形式储存元素
其实有些时候模板也解决不了问题,比如2.5那道滑动窗口最大值,只能说循环迭代的流程需要自己整明白了、在按模板的套路来解题,你的代码能更规范
、更共通
一些。共勉。