0.Overview
说起滑动窗口算法,很多读者都会头疼。这个算法技巧的思路非常简单,就是维护⼀个窗口,不断滑动,然后更新答案。Leetcode上有起码 10 道运用滑动窗口算法的题目,难度都是中等和困难。这个算法技巧的时间复杂度是 O(N),比字符串暴力匹配效率要高得多,该算法的最基本的逻辑框架如下:
int left = 0, right = 0;
while (right < s.size()) {
// 增⼤窗⼝
window.add(s[right]);
right++;
while (window needs shrink) {
// 缩⼩窗⼝
window.remove(s[left]);
left++;
}
}
后续实战篇前看我的滑动窗口专栏。
1.单字符串框架详述
该框架常用于只给定一个字符串,让我们求满足某种条件的子串,框架如下:
/* 滑动窗⼝算法框架 */
void slidingWindow(string s) {
unordered_map<char, int> window;
int left = 0, right = 0;
while (right < s.size()) {
//c是将移⼊窗⼝的字符
char c = s[right];
//增⼤窗⼝
right++;
//进⾏窗⼝内数据的⼀系列更新
...
//判断左侧窗⼝是否要收缩
while (window needs shrink) {
//d是将移出窗⼝的字符
char d = s[left];
//缩⼩窗⼝
left++;
//进⾏窗⼝内数据的⼀系列更新
...
}
// 全局判断,是否是所需结果
...
}
// 返回结果
...
}
注意:
· 其中两处 ... 表示的更新窗口数据的地方,到时候你直接往里面填就行了。而且,这两个 ... 处的操作分别是扩大和缩小窗口的更新操作,等会你会发现它们操作是完全对称的。 另外,虽然滑动窗口代码框架中有⼀个嵌套的 while 循环,但算法的时间复杂度依然是 O(N),其中 N 是输入字符串/数组的长度。为什么呢?简单说,字符串/数组中的每个元素都只会进入窗口一次,然后被移出窗口一次,不会说有某些元素多次进⼊和离开窗口,所以算法的时间复杂度就和字符串/数组的长度成正比。
· unordered_map 就是哈希表(字典)。
· 我们进入while后的第一步就是将新元素纳入我们窗口后然后更新右边界,所以我们窗口(Window)本质上是左开右闭区间 [ ),即left指向我们窗口第一位,right指向窗口结束的下一位。
· 在我们进行完窗口的一系列更新后,我们开始缩小左边界(其实我们滑动窗口的本质就是,窗口右边界不断向后滑动的同时,只要窗口内元素满足条件,则左边界保持不变,直至到达某一时刻,窗口内元素不满足条件了,则我们开始收缩左边界即将左边界右移)收缩左边界就是将left右移,但同时需要更新窗口内的元素。
举例:
解析如下:
int lengthOfLongestSubstring(string s) {
unordered_map<char, int> window;
int left = 0, right = 0;
int res = 0; // 记录结果
while (right < s.size()) {
char c = s[right];
right++;
// 进⾏窗⼝内数据的⼀系列更新
window[c]++;
// 判断左侧窗⼝是否要收缩
while (window[c] > 1) {
// 这时待收缩的字符
char d = s[left];
// 收缩窗口
left++;
// 进⾏窗⼝内数据的⼀系列更新
window[d]--;
}
// 在这⾥更新答案
res = max(res, right - left);
}
return res;
}
1.首先我们进入while循环第一步是将新元素纳入窗口,然后右移右边界:
char c = s[right];
right++;
2.回顾上面框架,然后就需要更新窗口数据,我们这里是最简单的更新,只需要一步:
window[c]++;
即将窗口内元素c对应计数+1(原来是0现在是1)
3.再判断做窗口需不需要收缩(就是看此时窗口还蛮不满足条件):
while(window[c] > 1)
什么意思呢?其实就是每当我们纳入新元素后,我们就可能发生窗口内对应的 c 这个元素出现的次数>1,此时就需要收缩窗口,那除了 c 别的元素次数就不会>1吗?当然不会,因为我们窗口需要满足的条件就是每个元素出现次数等于1,所以在这一步while之前,窗口肯定通过我们这一步处理使之满足条件的,这一步while不符合条件了也进行同样的处理;
我们还要说明一点,虽然这题我们收缩窗口一定只会收缩一次,为什么还要用while呢?因为我们的框架是普适的,所以不是在任何情况下收缩一次就能解决问题。
4.处理步骤也很简单:
char d = s[left];
left++;
5. 进⾏窗口内数据的⼀系列更新:
window[d]--;
6.窗口收缩完成之后,又是一个满足条件的新窗口啦!此时就要有个判断-它是不是全局最优呢?
res = max(res, right - left);
7.最后返回全局最优解:
return res;
2.双字符串框架详述
他与我们前面给定单个字符串的不同在于,这里用到到计数器need及valid,need就是统计模式串中各个字符的个数(s是主串,t是模式串),valid就是统计每次窗口有多少个元素满足need中所对应字符的次数-need[.]
string slidWindow(string s, string t) {
unordered_map<char, int> need, window;
for (char c : t) need[c]++;
int left = 0, right = 0;
int valid = 0;
while (right < s.size()) {
//c是将移⼊窗⼝的字符
char c = s[right];
//扩⼤窗⼝
right++;
//进⾏窗⼝内数据的⼀系列更新
if (need.count(c)) {
window[c]++;
if (window[c] == need[c])
valid++;
}
//判断左侧窗⼝是否要收缩
while (window needs shrink) {
// 全局判断,是否是所需结果
...
//d是将移出窗⼝的字符
char d = s[left];
//缩⼩窗⼝
left++;
//进⾏窗⼝内数据的⼀系列更新
if (need.count(d)) {
if (window[d] == need[d])
valid--;
window[d]--;
}
}
}
//返回结果
}
注意:
相对于单字符串的处理,我们的全局判断是放在窗口收缩的while里面而不是和前面单字符串处理一样放在后面,why?其实本质上是一样的!!!!区别在于单字符串处理中窗口收缩while括号里的条件是不满足全局条件,而双字符串处理中括号里是既定的全局条件(可以参考后面例子和单字符串的例子来理解),所以我们的全局判断,寻找所需结果是放在while里面的,我们完全可以讲条件取否,这样就可以把全局判断挪到while后面和前面单字符串处理一致了,但是为了我们算法的多样性和逻辑的丰富性,我们都写出来了。
举例:
解析如下:
vector<int> findAnagrams(string s, string p) {
unordered_map<char,int>need,window;
for(char c:p) need[c]++;
int left=0,right=0;
int valid=0;
vector<int>res;
while(right<s.size()){
char c=s[right];
right++;
if(need.count(c)){
window[c]++;
if(window[c]==need[c])
valid++;
}
while(right-left==p.size()){
if(valid==need.size())
res.push_back(left);
char d=s[left];
left++;
if(need.count(d)){
if(window[d]==need[d])
valid--;
window[d]--;
}
}
}
return res;
}
1.关于为什么全局判断放在窗口收缩while里的原因前面解释了
2.
if (need.count(c)){
window[c]++;
if (window[c]==need[c])
valid++;
}
怎么理解?
第一个if是判断该字符c是否是我们模式串中有的字符,如果不是,你添加or删除它不会怎么着;如果是,则需要将窗口中对应该字符c的出现次数加一即:window[c]++
第二个if是判断窗口中该字符c出现次数经过添加后后是否达到了我们模式串中对应字符所需要的次数-need[c],如果达到了,则valid++
3.
if(need.count(d)){
if (window[d]==need[d])
valid--;
window[d]--;
}
与添加相反,删除(收缩)时,我们需先判断此时该字符是否达到了我们模式串中对应字符所需要的次数-need[c],若是,先valid--,再删除;
若不是valid不变,直接删除:window[d]--