概述
滑动窗口这个方法可以更低的时间复杂度解决一些比较复杂的字符串问题,因为传统解决字符串问题时都是多次扫描,而滑动窗口只从头至尾扫描一次,在这个过程中维护一个窗口,把符合题意的子串限定在这个窗口中,走过一次就能得出答案。那么如何定义这样一个窗口,窗口如何进行更新呢?
窗口的定义可以借助两个指针,左边界和右边界指针,通常我们取左闭右开,刚开始不明白为什么这样取,写今天的笔记突然想到一个点,大家可以往后面看,我会说到。就以两个指针i、j分别代表左右端点,初始时i、j都取0,也就是刚开始窗口大小是0(因为是[i,j)
),然后窗口会先扩大(右边界右移),当满足题目条件时停下(不再扩大),并且开始收缩(左边界右移)。这里我们发现在操作中始终都是左右边界i、j向右移动,也就是不会走回头路(一旦i向左移动了其实就是回溯了,又回去之前走过的路了),因此我们的时间复杂度能保持在O(n)
。
下面以图来直观说明窗口滑动的过程。
首先,初始时我们的窗口两个指针i、j为0,窗口大小size
为0。
然后向前滑动,即j不断+1,i不动,直到窗口内的元素满足题意时停止。这里假设到下图位置时,窗口中元素满足题意了。此时左边界指针i仍然是0,右边界指针j为6,窗口大小是5。这5个元素中就包含我们要的答案。
到此窗口扩大的部分就完成了,接下来就要收缩窗口了。为何要收缩窗口呢?因为我们刚才在移动时并没有对左边界进行移动,所以会导致我们的窗口并不是题中要求的最小的解,因此我们要把左边界不断右移,直到不再满足题意。每次移动我们都记录最优解,当i移动到使窗口不再满足题意时,继续重复刚才的步骤,扩大窗口(右边界j右移,左边界i不动)。
如下图,假设此时仍然满足题意,记录下此时的最优解,然后左边界i继续尝试右移。
移动到下图的位置,通过判断发现现在窗口中的元素不再满足题意了,此时就要开始重新扩大窗口了,重复上面的过程,直到右边界j到达字符串的末尾位置。
最后根据窗口滑动过程中产生的最优解进行处理即可。
总结
下面总结一下滑动窗口类问题的解题思路:
- 确定在谁上进行滑动。
- 确定窗口中的元素需要满足的条件,用合适的数据结构去记录窗口中当前的满足情况,以及最优解如何记录。
- 进行滑动,先扩大(j++),再缩小(i++),永不回退,缩小到不满足题意再继续扩大,直到j走到端点。
- 输出最优解。
题目
LetCode中有如下滑动窗口类题目:(欢迎补充,评论贴出题号,我会完善的~)
难度:简单
难度:中等
难度:困难
例题
本文中先以76题最小覆盖子串为例,来感受一下滑动窗口的思路,在后续刷题的文章中会继续分析更多该类问题,到时会贴上链接。
题目
给你一个字符串 s
、一个字符串 t
。返回 s
中涵盖 t
所有字符的最小子串。如果 s
中不存在涵盖 t
所有字符的子串,则返回空字符串 ""
。
**注意:**如果 s
中存在这样的子串,我们保证它是唯一的答案。
示例 :
输入:s = "ADOBECODEBANC", t = "ABC"
输出:"BANC"
分析
题意为在s中找到包含t中全部字母的一个最短的子串。这就是典型的滑动窗口类问题。我们可以在s上进行滑动,维护一个窗口,记录窗口中当前具有哪些满足t的字母,同时考虑到字母可能会重复,因此我们应该记录每个字母及其个数,这样很自然想到应该用集合类存储,我使用HashMap
来存储字母及个数。
具体的,使用两个集合need
、window
,其中need
维护t中的各个字母及个数,为所需字母集合,key
为字母,value
为对应的个数;window
维护当前窗口中各个字母及个数,key
、value
同上。
实现的第一步就是把t的要求写到need
集合中,后面都是根据这个集合的值去判断窗口怎么移动。
接下来就初始化两个指针i、j,一个变量valid
记录满足t所需字母的个数,当window
中的字母满足了t中一个字母的个数要求(如t中含有2个a,window
中也含有2个才算),valid
就加一,当valid
等于t中字母的个数(不同的字母,也就是need集合的size),就代表当前窗口已经满足要求了,是一个解(但不一定是最优解)。另外还需要两个变量记录最佳子串的位置信息,start
记录起始坐标,len
记录长度,因为我们要找的最优解其实就是最短的子串,因此在比较最优时比较的就是子串的长度,所以len
初始值设为int_max
。
万事俱备,然后就是while
循环进行滑动了,退出条件就是j到达字符串s的端点。在循环中每次都移入一个字母(j++),然后判断移入的这个字母是否是t中需要的,如果是,把window
集合中该字母的value
值加一(没有则创建,具体实现见代码)。接下来判断当前窗口是否已经满足了t串的要求,即need
集合的大小等于valid
,这时就开始收缩窗口,收缩窗口中也是while
循环,退出条件是window
中的字母不再满足need
的需求。在收缩窗口的循环中,每次都移出一个字母(i++),同时也要对window
和valid
做修改。当然最优解就是在收缩窗口的阶段产生的,因此在这个阶段我们还要根据window
去更新最短子串的信息start
、len
。
经过以上的循环,最后的最短子串就是s中以start
为起点,长度为len
的子串。求String
的子串使用substring
方法,而该方法是左闭右开的,即s.substring(1,2)
实际上只有一个元素,这也是我认为滑动窗口也统一为左闭右开的原因之一:统一 。
设置为左闭右开事实上还有一个原因,就是方便计算窗口的长度,我们以下标从0开始为例,0 1 2 3 4这样一个序列,如果i在0位置,j在3位置,实际上窗口中有0、1、2三个元素,长度为3,直接用j-i就可以得到了,而不是像双闭区间求长度还需要再+1。反正是为了方便~
代码
class Solution {
public String minWindow(String s, String t) {
HashMap<Character,Integer> need=new HashMap<>();// 所需的字母集合,key为字母,val为个数
HashMap<Character,Integer> window=new HashMap<>();// 当前窗口中的字母集合,key为字母,val为对应的个数
for(int i=0;i<t.length();i++){
// 如果之前不存在这个key,就设为1,存在就在原来基础上加一
need.put(t.charAt(i),need.getOrDefault(t.charAt(i),0)+1);
}
int left=0,right=0;//初始窗口的左右边界 (左闭右开,所以初始是没有值的)
int valid=0;// 记录满足所需字母的个数,当这个个数等于t.len的时候说明集齐了
int start=0,len=Integer.MAX_VALUE;//记录匹配的最小子串信息(起始坐标、串长度)
while(right<s.length()){
char cur=s.charAt(right);// 本次移入的字母
right++;// 窗口右移
if(need.containsKey(cur)){// 如果本次移入的字母正是需要的
window.put(cur,window.getOrDefault(cur,0)+1);
if(need.get(cur).equals(window.get(cur))) valid++;// 这个字母的数量已经满足了,匹配数+1
}
while(valid==need.size()){// 当窗口中相应字母全部满足要求了,就开始收缩窗口
if(right-left<len){// 如果当前窗口长度比 当前最小子串小 就更新
start=left;len=right-left;
}
char delete=s.charAt(left);
left++;// 窗口左移 移出delete这个值
if(need.containsKey(delete)){// 如果要移除的字母是所需的字母
// 如果窗口中这个字母的数量 现在是等于所需数量的,那么移除后就不再满足该数量了,匹配数-1
if(window.get(delete).equals(need.get(delete))) valid--;
window.put(delete,window.get(delete)-1);// 窗口中该字母的个数-1
}
}
}
return len==Integer.MAX_VALUE?"":s.substring(start,start+len);
}
}
要注意的问题:
hashmap
的比较要用equals
,而不能用==
,因为Integer
是引用类型,会造成判断不准确。- 如果
len
最后仍然是我们赋的初值int_max
,说明没有成功进行过一次缩小窗口的操作,即无解,所以返回空串""
即可。
参考
后记
不知不觉就写到12点多了quq,果然第一天没有完成这个任务…好吧,这就是新年第一篇小文章了!时间还是比较仓促,后续会对这个例题加一些图解,完善的。睡醒继续解一些滑动窗口的题目来更新。大家新年快乐!晚安啦~