前言
滑动窗口也是常考知识点,他算是双指针的一个特殊形式,再复习一下之前做过的习题,顺便将之前刷过的题目分个类,不多废话了!
一、滑动窗口的基本习题
滑动窗口适合处理子串和子区间问题,是因为它能够有效地在一次遍历中解决这些问题。通过动态调整窗口的大小来维护一个连续的子区间,从而避免了对每个可能的子区间进行重复计算,大大降低了时间复杂度。
先做个开胃菜!这道题目之前讲哈希表的时候写过,也是滑动窗口的典型题目,通过对右指针的一次遍历,获得每次区间最大长度。如果出现重复的字母,那么右指针就会停止运动,左指针不断运动到区间不出现重复的字母时候,右指针再继续运动。接下来我们思考一个问题:什么时候统计子串的长度? 我们可以发现,右指针移动时候会增加子串长度,左指针移动会减小字串长度。那么最长的子串一定出现在处理右指针之后,所以每次右指针增加时候判断即可!
int lengthOfLongestSubstring(string s) {
unordered_map<char, int>map;
int i =0, j = 0;
int maxlen = 0;
while (j<s.size())
{
if (map[s[j]] == 0) { map[s[j]]++; j++; maxlen = max(maxlen, j - i); }
else { map[s[i]]--; i++; }
}
return maxlen;
}
这道题目转化过来,就是求最长的只有两个元素的连续子数组,哈希表加滑动窗口,轻松拿下!
int totalFruit(vector<int>& fruits) {
int n = fruits.size();
unordered_map<int, int> map;
int left = 0, right = 0;
int max_len = 0;
while (right<n)
{
map[fruits[right]]++;
while (map.size()>2)
{
map[fruits[left]]--;
if (map[fruits[left]]==0)
{
map.erase(fruits[left]);
}
left++;
}
max_len = max(max_len, right-left+1);
right++;
}
return max_len;
}
这类题目太经典了,以至于很多滑窗的方法基于上面代码的变形,但是只要理解这个问题,就没什么难的。下面这几道题比较简单了,就不贴代码了。。
子数组最大平均数l
长度最小的子数组
爱生气的书店老板
二、不同类型的滑动窗口
2.1 定长的滑动窗口
1.串联所有单词的子串
这道题目确实是困难题目,我自己写了半天终于写出来垃圾代码,但是只通过了大部分案例,,太难受了!要是想解决这个问题,那就要增加时间复杂度,最后有一个例子超时!气死我了。不改通不过这个例子,改了就超时,左右为难了属于是。。
int n = words.size(), m = words[0].size(), len = n * m;
我仔细分析了一下,原来我之前的做法并没有用到滑动窗口。这道题目是要有两个循环的,关键在于这两个循环分别要循环什么!先说最外层的循环,我们应该创建一个len长度的滑动窗口,如果s的长度大于Len,我们才能够进行判断,但是s的长度未必会被m整除,这样的话,我们第一个循环就可以这样定义,使得前面是余数,这样的话后面的都可以被m整除,最终可以遍历到字符串的尾部。
for (int i = 0; i < m; i++)
之后在循环中,我们需要进行两件事:
1.遍历滑动窗口
2.比较滑动窗口中与words中每个单词出现的次数
在本题中,由于我们比较的不是每个单词,而是每个定长的字符串,由于words中每个元素的长度是一定的!这样的话,我们就可以通过遍历每个m长度的小窗口,也就是单词,从而对整个滑窗进行遍历。这也就是第二个循环,代码如下:
for (int j = i; j + m <= s.size(); j += m)
这样的话,代码的基本框架已经确定了,我们只需要解决最后一个问题:判断滑窗是否满足串联子串的要求!
这就有点像找出字符串中的所有字母异位词的问题了,我们可以创建两个哈希表,第一个哈希表记录words中每个单词出现的次数,这个哈希表里面的元素是固定的;第二个哈希表记录滑窗中单词出现的次数,用来与第一个进行比较,判断出结果。
具体的策略就很容易理解了:如果滑窗的长度小于len,那就一直扩充,并且增加哈希表的对应关系,直到等于len,就开始进行窗口移动的过程,添加新的单词,移除最初的单词,这样的话,即可达到想要的要求。具体代码如下:
vector<int> findSubstring(string s, vector<string> &words)
{
int n = words.size(), m = words[0].size(), len = n * m;
vector<int> res;
//统计words中每个单词出现的次数,是固定的
unordered_map<string, int> map;
for (auto word : words)
{
map[word]++;
}
for (int i = 0; i < m; i++)
{
unordered_map<string, int> window;
int cnt = 0;
for (int j = i; j + m <= s.size(); j += m)
{
if (j - i >= len)
{
string temp = s.substr(j - len, m);
window[temp]--;
if (window[temp] < map[temp])
{
cnt--;
}
}
string word = s.substr(j, m);
window[word]++;
if (window[word] <= map[word])
cnt++;
if (cnt == n)
{
res.push_back(j - len + m);
}
}
}
return res;
}
2.2 不定长的滑动窗口
2.最小覆盖子串
这道题目是一个不定长的滑动窗口,不定滑窗的关键在于左边和右边的双指针分别在什么条件下运行。只要把这个判断准了,就可以轻松拿下题目了!
一句话可以总结:先滑动右指针到满足区间覆盖字符串t,然后再移动左指针缩减最小区间范围,记录区间,之后继续移动左指针使区间刚好不能覆盖字符串t,继续移动右指针。
明白了上面的话,代码就比较简单了,我这里使用了两个哈希表来计数对比,其实一个哈希表增删就能实现功能,但总体差不多。
string minWindow(string s, string t)
{
unordered_map<char, int> map;
unordered_map<char, int> window;
int cnt = 0;
for (auto c : t)
{
map[c]++;
cnt++;
}
int left = 0, right = 0, num = 0;
int min_len = 0;
int min_left = 0;
while (right < s.size())
{
window[s[right]]++;
if (window[s[right]] == map[s[right]])
num += map[s[right]];
while (num == cnt)
{
if (min_len == 0 || min_len > right - left + 1)
{
min_len = right - left + 1;
min_left = left;
}
if (window[s[left]] == map[s[left]])
num -= map[s[left]];
window[s[left]]--;
left++;
}
right++;
}
return s.substr(min_left, min_len);
}
这道题目表面看起来和上一道题目没有毛线关系,但是仔细分析一下,还真有关系,能把这道题转化为上面这道题,简直是有点牛B,接下来咱们看看,怎么转化的。
看了这道题的题目,有些无从下手的感觉,关键原因在于,这道哈希表结合滑动窗口的题目,变了一个东西,哈希表的数据结构变了,而且比较难以理解。我们要创建一个哈希表,其中,哈希表的键是所有数组的所有元素,而值是这些元素在哪个数组中出现的索引!借用一下官方的图,以便更好理解。
这样的话,我们需要创建的哈希表应该长这样。
unordered_map<int, vector<int>>
之后问题就进行了转化,对于一个滑动窗口,如果他包含了所有区间的元素,并且这个区间尽可能小,那么这个区间就是我们要求的。这个时候,就彻底转化为了上面的一道题。妙哉!接下来看一下具体的代码。
// 最小区间
vector<int> smallestRange(vector<vector<int>> &nums)
{
int n = nums.size();
int m = nums[0].size();
vector<int> sorted;
// 创建哈希映射,并且找到最大值和最小值
unordered_map<int, vector<int>> map;
int max_num = INT_MIN;
int min_num = INT_MAX;
for (int i = 0; i < n; i++)
{
for (auto num : nums[i])
{
map[num].push_back(i);
max_num = max(max_num, num);
min_num = min(min_num, num);
sorted.push_back(num);
}
}
// cout << max_num << " " << min_num << endl;
// 获取所有元素的去重排序数组
sort(sorted.begin(), sorted.end());
auto unique1 = unique(sorted.begin(), sorted.end());
sorted.erase(unique1, sorted.end());
// for (auto s : sorted)
// cout << s << " ";
// cout << endl;
//滑动窗口遍历去重数组,找到所有可能的区间
int left = 0;
//创建哈希表计数
unordered_map<int, int> set;
vector<vector<int>> ans;
for (int right = 0; right < sorted.size(); right++)
{
for (auto i : map[sorted[right]])
set[i]++;
while (set.size() == n)
{
// bestleft = sorted[left];
// bestright = sorted[right];
ans.push_back({sorted[left], sorted[right]});
for (auto i : map[sorted[left]])
{
if (set[i] <= 1)
set.erase(i);
else
set[i]--;
}
left++;
}
}
// for (int i = 0; i < ans.size(); i++)
// {
// cout << "[" << ans[i][0] << " " << ans[i][1] << "]" << endl;
// }
//比较区间,找到最小区间
int bestleft = min_num;
int bestright = max_num;
for (int i = 0; i < ans.size(); i++)
{
if (ans[i][1] - ans[i][0] < bestright - bestleft)
{
bestleft = ans[i][0];
bestright = ans[i][1];
}
else if (ans[i][1] - ans[i][0] == bestright - bestleft)
{
if (ans[i][0] < bestleft)
{
bestleft = ans[i][0];
bestright = ans[i][1];
}
}
}
//返回最小区间
return {bestleft, bestright};
}
代码复杂的一批,不过过了就行,不枉我调试了半天。。
滑动窗口的本质就是剪枝?
我们知道,滑动窗口在字符串的应用是处理连续的子串,其中这分为固定区间和不固定区间两种情况。现在不考虑题目,我们假设一个字符串为s,我们想求得他的子区间【i,j】的子字符串。以这个问题,我们展开思考。
1.固定长度的区间
这个很简单,对于定长区间,滑窗会比暴力要快。暴力的时间复杂度是O(n*k),如果k比较大,逼近n的话,就要到O(n2)复杂度了。滑动窗口遍历了n-k次,时间复杂度是O(n),更快一些,而且可以遍历所有情况。
2.不固定长度的区间
但是如果是不定长情况的话,就会有些复杂了,最关键的因素就是,不定长滑动窗口不能包含子字符串的所有情况!因为滑动窗口本身就是剪枝操作,我们通过移动左指针或者右指针来对区间进行改变。注意,这里是或!只移动一个指针,那么必定会剪枝一部分子串,而如果我们想要的结果中,包含滑窗之外的情况,那么这道题目就没法使用滑动窗口进行解答了,就需要想其他办法进行解决了。所以说滑窗是好用,但是也要分清场合。
下面举一个简单的例子:
假设左右指针都进行增加操作,此时我移动左指针到2,那么在之后,我就无法访问到[1,4]这个区间了。这个区间已经被剪枝了!下面咱们就看两道题,感受一下剪枝之外的题目如何巧妙解答。
2.3.区间简化的滑窗
1.区间子数组的个数
这个题目可以有很多种解法,这里我使用滑动窗口的方法解答,以此来循序渐进地引入下一个题目。
首先分析一下,要满足最大元素在一个区间内,先看看正常滑窗能否解答。我们可以将所有的元素分为三类
小于 left,用 0表示;
大于等于 left 且小于等于 right,用 1表示;
大于 right,用 2 表示。
这样的话,我们就将题目转化为:找到所有至少包含1,且不包含2的子数组。之后需要讨论多种情况,这里官方的代码不太容易理解。
我这里的做法是,简化问题,题目是求最大数的一个区间,这里把题目转化为:找出 nums 中最大元素小于等于k的子数组。
这一步实在是巧妙,大大的简化了问题,很好写出这个代码:
int lessEqualsThan(vector<int> &nums, int k)
{
int left = 0;
int ans = 0;
for (int right = 0; right < nums.size(); right++)
{
if (nums[right] > k)
left = right + 1;
ans += right - left + 1;
}
return ans;
}
那么最大元素在范围 [left, right] 内的子数组不就等于最大元素小于等于right的子数组的数量减去最大元素小于等于left-1的子数组的数量吗! 这就是解决这个问题的关键!而且是一个重要的思想。需要注意的是,我们求的是闭区间 [left, right],而上面代码求得也是区间,所以减去的应该是left-1,可以画个图更清晰。之后代码就很简单了。
int numSubarrayBoundedMax(vector<int> &nums, int left, int right)
{
return lessEqualsThan(nums, right) - lessEqualsThan(nums, left - 1);
}
刚开始看这道题,需要找到某个子数组中不同整数的个数恰好为k 。我一看,这不是水果成篮一模一样吗?把2改为k就完事了,说时迟那时快,一下就秒了,结果很快啊,一下就报错了。。。于是我分析了原因。有些子数组我是没得到的,举个简单的例子。
nums = [1,2,1,2,3], k = 2。
我根本没法得到[1,2,1]这个子数组,原因在于,我的左右指针都在往右跑,我只能得到[2,1],得不到[1,2,1],这在上文中也分析过了,那么如何解决这个问题呢?
还是进行转化。找某个子数组中不同整数的个数恰好为k 的子数组太难了,因为滑窗遍历过程中会漏掉情况。那么我就稍微一变,找小于等于k的子数组个数,因为之前遗漏的情况都是在等于k之前出现的,所以都是小于等于k,这样就可以用之前的方法安心找到小于等于k的子数组的个数了。
注意,这里是开区间,那么我们回过头来,再找恰好等于k的情况,只需要求小于等于k的子数组数量减去小于等于k-1的子数组数量即可。代码如下。
int lessEqualsThan2(vector<int>& nums, int k)
{
int left = 0;
int count = 0;
int ans = 0;
unordered_map<int, int>map;
for (int right = 0; right < nums.size(); right++)
{
map[nums[right]]++;
while (map.size() > k)
{
count++;
map[nums[left]]--;
if (map[nums[left]] == 0) { map.erase(nums[left]); }
left++;
}
ans += right - left;
}
return ans;
}
int subarraysWithKDistinct(vector<int>& nums, int k) {
return lessEqualsThan2(nums, k) - lessEqualsThan2(nums, k - 1);
}
总结
做了上面的题目,对于滑动窗口我们也算是有了一定的认识。可以发现,滑动窗口通常和哈希表进行结合,从而达到题目的多样性。
1.对于定长滑动窗口来讲需要注意的就是,在每次移动窗口的过程中,左右指针会同时移动。通常为了方便对比,会创建两个哈希表,一个用于最初遍历数组或者字符串,另一个用于滑窗过程中进行变化。在每次滑动窗口中,对比两个哈希表的差异解决问题。
2.对于不定长的滑动窗口,最典型的情况就是,先移动右指针,当右指针满足条件时候,移动左指针直到刚好不满足条件,继续移动右指针。这类题目基本上有一个固定的模板了,下面是一个模板的例子。需要注意的是:
1.先移动右指针,再进行while循环的判定
2.我们想要的结果在代码的哪些位置,可能会出现在while的前,中,后三个部位。他们的区别在于,不同临界情况的左右指针的差别。while循环中,是刚好满足条件的滑窗,while的其他位置是刚好不满足条件的滑窗。具体问题具体分析。
//创建左指针
int left = 0;
//创建哈希表
unordered_map<int, int> set;
//遍历右指针
for (int right = 0; right < sorted.size(); right++)
{
//操作右指针
for (auto i : map[sorted[right]])
set[i]++;
//判断满足的条件,进行左指针操作
while (set.size() == n)
{
ans.push_back({sorted[left], sorted[right]});
for (auto i : map[sorted[left]])
{
if (set[i] <= 1)
set.erase(i);
else
set[i]--;
}
//移动左指针
left++;
}
}
3.最后就是对于一些由于不定滑窗剪枝的遗漏情况,我们可以进行问题的转化,其中2.3第一道题很容易简化为左边和右边两个范围。但是对于第二道题,恰好为k可以转化为小于等于k减去小于等于k-1,这是巧妙且较难想的,以后做题的时候,希望遇到类似的情况,可以想起这种方法。