【算法】滑动窗口
目录
1、长度最小的子数组

1.1 代码原理讲解
这道题很容易就能想到使用暴力枚举,双层枚举产生一个区间,再利用在这个区间中遍历计算出这个区间的和,此时的时间复杂度是O(N^3),当然,对暴力枚举的方法优化的话可以让时间复杂度到O(N^2),因为可以用一个sum来不断更新这个区间的和
对暴力枚举的优化
1. 由题目可知,数组中的数据都是正整数,也就是说,加的值越多,和越大(单调性)
当left和right所围成区间的和已经大于等于target时,再让right++,形成的新的区间的和一定比原来的区间的和大,所以也大于等于target,但同时长度也更长,而我们要寻找的时长度最小的子数组,所以当left和right所围成区间的和第一次大于等于target,再让right往后遍历实际上是没有意义的,所以当围成区间大于等于target时,right停止,让left++

2.当left++时,我们是可以知道[left + 1, right]这个区间的值的,所以可以不用移动right,只更新sum即可

根据单调性推理出的这两个性质,我们可以使用 "同向双指针" 来解决这个题,"同向双指针"就是在两指针移动的过程中,维护[left,right]这个区间中的信息,像这里就是和,就像一个窗口从左向右遍历数组。所以"同向双指针" 也称为 滑动窗口
什么时候用? 当暴力枚举时,两指针都可以不倒退(通常利用单调性)
怎么用?1. left = 0,right = 0
2. 进窗口
3. 判断,出窗口
在后两步中还会穿插一个更新结果的步骤,只是这个更新结果的步骤有些题是在进窗口的时候更新,有些题是在出窗口的时候更新,需要就题论题,这道题是在出窗口的时候更新


正确性:在前面单调性中,有些情况没有枚举到,为什么还是正确的呢?
虽然有些情况没有枚举到,但是我们已经知道剩下的情况枚举了也是白枚举(单调性),利用单调性,规避了很多没有必要的枚举
时间复杂度

1.2 代码实现
class Solution {
public:
int minSubArrayLen(int target, vector<int>& nums) {
int left = 0,right = 0,sum = 0;
int n = nums.size(),len = INT_MAX;
while(right < n)
{
while(right < n && sum < target) // 固定left,让right一直向右,直到sum大于等于target再出循环
{ // 当然也有一种情况是left和左边剩下所有的数的和小于target,所以出去要判断一下
sum += nums[right];
if(sum >= target) break;
else right++;// 入窗口
}
if(right == n) break;// 在当前的[left,right]区间内,枚举到了最后都没有大于target的就直接出循环
len = min(len,right - left + 1);// 取当前窗口的长度和现在的len中小的呢一个来更新len
sum -= nums[left++];// 出窗口
if (sum < target) right++;
// 注意,left出窗口后要检查一下sum是否小于target了,若小于则right要++,否则进入下一次循环时
// sum+=nums[right]时会出现一个right加了两次的行为
}
return len == INT_MAX? 0 : len;
}
};
2、无重复字符的最长子串
2.1 解法一(滑动窗口)

这道题很容易就能想到暴力枚举+哈希表的解法,暴力枚举每个字符的最长子串的长度,再取一个最大值就是结果,但是这样的最坏时间复杂度是O(N^2)的
对暴力枚举的优化
1. 有些字符是没有必要枚举的

若是暴力解法,此时就会让l和r都回到e的位置,再来一遍上面的步骤,会发现,r仍然是移动到a的位置就停下了,原因就在于重复的a在e的后面,所以应该让l先跳过a的位置
2. r没有必要回来
l跳过了a后,l和r之间就没有重复的字符了,所以r是没有必要回来的
此时,很明显满足滑动窗口的使用条件
进窗口:当l和r之间没有重复字符时
判断,出窗口:当l和r之间有重复字符时,此时出窗口,让l++,并将哈希表的l++的值移除,直到l和r之间没有重复字符
更新结果:当l和r之间出现重复字符时,此时r指向的位置没有放进哈希表,也就是说此时l和r之间的长度就是以这个l为起点的最长子串,此时就更新
class Solution {
public:
int lengthOfLongestSubstring(string s) {
int n = s.size();
if(n == 0) return 0;
if(n == 1) return 1;
int hx[128] = {0},sum = 0,left = 0,right = 0;
while(right < n)
{
while(right < n && hx[s[right]] == 0)
{
hx[s[right]]++;
right++;
}
// 更新结果
sum = max(sum, right - left);
// 出窗口,这里利用上面的判断,因为如果有重复,上面的while不会进去
hx[s[left]]--;
left++;
}
return sum;
}
};
时间复杂度是O(N),空间复杂度是O(1)
2.2 解法二(动态规划)
我们以f(i)表示以第i个字符为结尾的不包含重复字符的子字符串的最长长度。我们从左向右逐一扫描字符串的每个字符,当我们计算f(i)时,我们已经知道了f(i-1),并且f(0)=1
如果第i个字符在之前没有出现过,那么f(i)=f(i-1)+1。例如,"arabcacfr",显然f(0)=1,在计算f(1)时,'r'在之前是没有出现过的,所以f(1)=f(0)+1=2
如果第i个字符在之前出现过,我们需要先计算第i个字符和它上次出现在字符串中的距离d,接着分两种情况讨论。
第一种情况,d <= f(i-1),说明以第i-1个字符为结尾的不包含重复字符的子字符串中含有第i个字符,所以f(i)=d。如"arabcacfr"中的f(2),很明显,'a'在之前出现过了,所以d是2,由前面计算可知,f(1)=2,所以以第1个字符为结尾的最长字串中含有'a',所以f(2)=2
第二种情况,d > f(i-1),此时第i个字符上次出现在以第i-1个字符为结尾的不包含重复字符的子字符串之前,所以f(i)=f(i-1)+1。如"arabcacfr"中的最后一个字符'r',即f(8),易知f(7)=3,'r'与上一次出现过的距离d是7,所以f(8)=4
class Solution {
public:
int lengthOfLongestSubstring(string s) {
int n = s.size();
if(n == 0) return 0;
int dp[n],hx[128];// dp[i]表示以第i个字符结尾的最长不重复子串的长度
for(int i = 0;i<128;i++) hx[i] = -1;// hx数组用来记录每个字符最后一次出现的位置
dp[0] = 1,hx[s[0]] = 0;
for(int i = 1;i < n;i++)
{
if(hx[s[i]] == -1) dp[i] = dp[i - 1] + 1;
else
{
int d = i - hx[s[i]];
if(d <= dp[i - 1]) dp[i] = d;
else dp[i] = dp[i - 1] + 1;
}
hx[s[i]] = i;
}
return *max_element(dp,dp+n);
}
};
3、最大连续1的个数III

这道题要实现翻转无疑是很困难的,所以我们可以对其进行转化。转化:找出最长的子数组,0的个数不超过k个。注意,题目里面说的是翻转最多k个0,不一定真的要翻转到k个
暴力解法是枚举每一个数作为子数组的起始位置,最后再计算出最长的

按照暴力枚举的做法,此时应该让l和r都到第2个1的位置,再来一次前面的操作,但是会发现,r仍然停留在那个位置,因为0没动,所以正确的做法应该是l往前,并且要将窗口中0的数量减少到小于等于k才能正常出窗口,因为此时窗口中0的数量正常了,也就可以更新结果了
class Solution {
public:
int longestOnes(vector<int>& nums, int k) {
int n = nums.size();
int ret = 0;
for(int left = 0,right = 0,zero = 0;right < n;right++)
{
if(nums[right] == 0) zero++;
while(zero > k)
if(nums[left++] == 0) zero--;
ret = max(ret,right - left + 1);
}
return ret;
}
};
4、将x减到0的最小操作数

这道题要从前面和后面取值是非常麻烦的,所以这时可以对其进行转化:首先计算出这个数组全部元素的和,然后用和减去x,得到k,也就是需要在数组中找一段连续的子数组,让这段连续子数组的和等于k,这样子就变成了前面的题目。因为这道题的数组中的元素都是大于等于1的,所以可以使用滑动窗口。若数组中的元素有0或者小于0的,这样会失去单调性,就不能使用滑动窗口了
1. left = 0,right = 0
2. 进窗口:gen + nums[right]
3. 判断:当gen > k 时出窗口
出窗口:gen -= nums[left]
更新结果:出完窗口更新结果,即gen == k时
class Solution {
public:
int minOperations(vector<int>& nums, int x) {
int n = nums.size(),sum = 0;
for(int i = 0;i<n;i++) sum += nums[i];
int k = sum - x;
if(k < 0) return -1; // 当k小于0,说明数组中所有的数加起来都小于x
if(k == 0) return n; // 当k等于0,说明数组中所有的数加起来刚好等于x
int left = 0,right = 0,gen = 0,ans = 0;
while(right < n)
{
gen += nums[right];
while(gen > k)
gen -= nums[left++];
if(gen == k)
ans = max(ans,right - left + 1);
right++;
}
return ans == 0 ? -1 : n - ans;
}
};
5、水果成篮

以上这段话,实际上的意思就是:找出一个最长的子数组的长度,子数组中不超过两种类型的水果
很明显,right没必要回去,所以是可以使用滑动窗口的
1. left = 0,right = 0
2. 进窗口:当前窗口的水果种类小于等于2时
3. 判断:若当前窗口中的水果种类大于2则出窗口
出窗口:注意此时出窗口不是出一个left就能让sum--,只有当hash[nums[left]] == 0时,才能让sum--
更新结果:在出窗口之前更新结果
class Solution {
public:
int totalFruit(vector<int>& fruits) {
int sum = 0,left = 0,right = 0,ans = 0,n = fruits.size(); // sum来记录当前窗口中有几种水果
int hash[n]; // 哈希表来记录当前窗口中每一种类型的果树踩采摘了几颗
for(int i = 0;i<n;i++) hash[i] = 0;
while(right < n)
{
if(hash[fruits[right]] == 0) // 当前窗口第一次出现这种果树,则哈希表和sum都要++
{
hash[fruits[right]]++;
sum++;
}
else hash[fruits[right]]++; // 当前窗口已经有了这种果树,则只让哈希表++
if(sum > 2) // 当前窗口果树种类超过2种,则需要先更新结果,并出窗口
{
ans = max(ans,right - left);
while(sum > 2)
{
hash[fruits[left]]--;
if(hash[fruits[left]] == 0) sum--; // 只有当窗口中彻底没了这种果树才能让sum--
left++;
}
}
right++;
}
ans = max(ans,right - left); // 最后还要再更新一次结果,因为可能right一直到最后都没有更新结果
return ans;
}
};
6、找到字符串中所有字母的异位词

6.1 滑动窗口
因为字符串 p 的变位词的长度一定与字符串 p 的长度相同,所以我们可以在字符串 s 中构造一个长度为与字符串 p 的长度相同的滑动窗口,并在滑动中维护窗口中每种字母的数量;当窗口长度与p长度相同时,就比较一下窗口中的字符串是否与p相同,若相同则将left的数值存进数组中,若不相同,则left++。注意:当字符串 s 的长度小于字符串 p 的长度时,字符串 s 中一定不存在字符串 p 的变位词。但是因为字符串 s 中无法构造长度与字符串 p 的长度相同的窗口,所以这种情况需要单独处理。
class Solution {
public:
vector<int> findAnagrams(string s, string p) {
int n1 =s.size(),n2 = p.size();
vector<int> v;
if(n1 < n2) return v;
vector<int> v_s(26);
vector<int> v_p(26);
for(int i = 0;i < 26;i++) v_s[i] = v_p[i] = 0;
for(int i = 0;i < n2;i++) v_p[p[i] - 'a']++;
int left = 0,right = 0;
while(right < n1)
{
while(right - left < n2)
{
v_s[s[right++] - 'a']++;
}
if(v_s == v_p) v.push_back(left);
v_s[s[left++] - 'a']--;
}
return v;
}
};
6.2 对滑动窗口的优化
在上面的代码中,我们用v_s来表示当前窗口中各个字符的个数,v_p来表示字符串p中各个字符的个数,当当前窗口中元素个数与p的大小相同时,即v_s的长度与v_p的长度相同时,就比较一下v_s与v_p是否相等,若相等,则说明此时窗口中的字符就是p的异位词,但是比较v_s和v_p调用的是vector的operator==,时间复杂度是O(N),此时可以利用一个count来统计窗口中有效字符的个数。同样也是要创建哈希表v_s和v_p,作用与前面相同,并且仍然维护窗口中的字符个数与p中字符个数相同。当往窗口中放入元素时,放入后,若窗口中这个元素的个数小于等于p中这个元素的个数,则count++,若大于则不++。当窗口中元素个数与p中元素个数相同时,比较count是否与p的大小相等,若相等,说明窗口中都是有效元素,则将left放入返回数组中,若不相等,则出窗口。优化后,少了直接比较v_s和v_p的步骤
class Solution {
public:
vector<int> findAnagrams(string s, string p) {
int n1 =s.size(),n2 = p.size();
vector<int> v;
if(n1 < n2) return v;
vector<int> v_s(26);
vector<int> v_p(26);
for(int i = 0;i < 26;i++) v_s[i] = v_p[i] = 0;
for(int i = 0;i < n2;i++) v_p[p[i] - 'a']++;
int left = 0,right = 0,count = 0;
while(right < n1)
{
while(right - left < n2)
{
v_s[s[right] - 'a']++;
if(v_s[s[right] - 'a'] <= v_p[s[right] - 'a']) count++;
right++;
}
if(count == n2) v.push_back(left);
if(v_s[s[left] - 'a'] <= v_p[s[left] - 'a']) count--;
v_s[s[left] - 'a']--;
left++;
}
return v;
}
};
7、串联所有单词的子串

这道题中有一个十分重要的条件,就是words中所有字符串的长度相同
会发现这一道题与上一道题是类似的,可以对其进行转化

就是对上一道题字符的位置都改成了字符串,所以这一道题的算法原理与上一道题是类似的,这里就不过多赘述了,只介绍一下几个不同的地方
1. 哈希表
上一道题我们是使用数组来充当哈希表的,因为上一道题都是字符,但是这一道题不行,因为都是字符串,所以需要使用unordered_map<string,int>
2. left指针和right指针的移动
因为这道题中一次是需要将一个字符串放进哈希表的,所以left指针和right指针一次是需要移动words[0].size()个字符的。注意:一次是将right和right前words[0].size()个字符放进哈希表
3. 滑动窗口的执行次数

为了遍历道字符串s中所有的单词,必须从不同起点,进行words[0].size()次滑动窗口
class Solution {
public:
vector<int> findSubstring(string s, vector<string>& words) {
vector<int> ret;
unordered_map<string,int> hash1; // 保存words里面所有单词的频次
for(const auto& e : words) hash1[e]++;
int len = words[0].size(),m = words.size(),n = s.size();
for(int i = 0;i < len;i++) // 执行len次
{
unordered_map<string,int> hash2; // 维护窗口中单词的频次
for(int left = i,right = i,count = 0;right + len <= n;right += len)
{
// 进窗口 + 维护count
string in = s.substr(right,len);
hash2[in]++;
if(hash1.count(in) && hash2[in] <= hash1[in]) count++;
// 判断
if(right - left + 1 > len * m)
{
// 出窗口 + 维护count
string out = s.substr(left,len);
if(hash1.count(out) && hash2[out] <= hash1[out]) count--;
hash2[out]--;
left += len;
}
// 更新结果
if(count == m) ret.push_back(left);
}
}
return ret;
}
};
注意,在这里面有两个步骤,都有调用道unordered_map<string,int>的operator[],因为operator[]当传入的值容器中没有时,会插入,所以一定要先确保容器中有才能比较,所以先调用count
8、最小覆盖子串

这道题的方法与前两道题是类似的,但是有一个较大的区别是前两道题的窗口大小是固定的,这道题的窗口大小是不固定的
所以当right遍历到最后的时候,不一定就遍历到了所有的情况,除非此时left和right的距离刚刚好等于t的长度,否则是没有遍历到全部情况的,所以当right遍历到s末尾时,还需要让left继续向前走,直到left和right的距离等于t的长度

s的子串中某个字符出现个数比t中这个字符出现次数大是没关系的,只要不小于就好
class Solution {
public:
string minWindow(string s, string t) {
int s_size = s.size(), t_size = t.size();
if (s_size < t_size) return "";
int left = 0, right = 0, min_left = -1, min_right = s_size, count = 0;
unordered_map<char, int> hash1;
unordered_map<char, int> hash2; // 记录t中各个字符出现的频次
for (int i = 0; i < t_size; i++) hash2[t[i]]++;
while (right - left >= t_size || right < s_size) // 当right走到最后,并没有遍历到全部的情况
{
while (right < s_size && count < t_size)
{
// 进窗口
hash1[s[right]]++;
if (hash2.count(s[right]) && hash1[s[right]] <= hash2[s[right]]) count++;
right++;
}
if (count == t_size)
{
// 更新结果
if (right - left < min_right - min_left)
{
min_left = left;
min_right = right;
}
}
// 出窗口
if (hash2.count(s[left]) && hash1[s[left]] <= hash2[s[left]]) count--;
hash1[s[left]]--;
left++;
}
if(min_left == -1) return "";
return s.substr(min_left, min_right - min_left);
}
};更多推荐
所有评论(0)