滑动窗口算法的基本思想是使用两个指针,通常是左指针(left)和右指针(right)来定义窗口,通过移动这两个指针,调整窗口的大小和位置,从而在不重复计算的情况下找到问题的解。
以下是滑动窗口算法的一般步骤:
滑动窗口算法适用于一些问题,例如:
下面我们通过几个真题来学习这个算法
题目链接:https://leetcode.cn/problems/minimum-size-subarray-sum/
给定一个含有 n
个正整数的数组和一个正整数 target
。
找出该数组中满足其总和大于等于 target
的长度最小的 连续子数组 [numsl, numsl+1, ..., numsr-1, numsr]
,并返回其长度**。**如果不存在符合条件的子数组,返回 0
。
示例 1:
输入:target = 7, nums = [2,3,1,2,4,3]
输出:2
解释:子数组 [4,3] 是该条件下的长度最小的子数组。
示例 2:
输入:target = 4, nums = [1,4,4]
输出:1
示例 3:
输入:target = 11, nums = [1,1,1,1,1,1,1,1]
输出:0
思路
在最开始没接触这种算法题时,我们要先建立滑动窗口的概念,首先像这种题目要求是连续的情况下,我们就要想到这种算法思想,然后滑动窗口主要分为四个步骤,进窗口、判断、出窗口、更新结果;其中更新结果要根据不同的情况放在不同的位置,比如上面这道题
这里使用滑动窗口算法,用于找到数组中和大于等于目标值 target
的最短子数组的长度。
left
和 right
,都初始指向数组的起始位置。同时定义变量 sum
用来记录当前窗口中元素的和,以及变量 len
用来记录当前找到的最短子数组的长度。right
向右移动,累加元素的值到 sum
中。如果 sum
大于等于目标值 target
,说明当前窗口的和满足条件。left
向右移动,缩小窗口大小,同时更新 len
为当前窗口的长度。然后从 sum
中减去左侧移出窗口的元素的值。right
移动到数组的末尾。在整个过程中,不断更新 len
为找到的最短子数组的长度。len
,如果没有找到满足条件的子数组,返回 0。代码
class Solution {
public:
int minSubArrayLen(int target, vector<int>& nums) {
int n=nums.size(),sum=0,len=INT_MAX;
for(int left=0,right=0;right<n;right++){
sum+=nums[right];
while(sum>=target){
len=min(len,right-left+1);
sum-=nums[left++];
}
}
return len==INT_MAX?0:len;
}
};
题目链接:https://leetcode.cn/problems/longest-substring-without-repeating-characters/
给定一个字符串 s
,请你找出其中不含有重复字符的 最长子串 的长度。
示例 1:
输入: s = "abcabcbb"
输出: 3
解释: 因为无重复字符的最长子串是 "abc",所以其长度为 3。
示例 2:
输入: s = "bbbbb"
输出: 1
解释: 因为无重复字符的最长子串是 "b",所以其长度为 1。
示例 3:
输入: s = "pwwkew"
输出: 3
解释: 因为无重复字符的最长子串是 "wke",所以其长度为 3。
请注意,你的答案必须是 子串 的长度,"pwke" 是一个子序列,不是子串。
思路
这里使用暴力枚举的方法是可以通过的,但是不推荐,这里我们使用滑动窗口来进行遍历,再借用一个哈希数组来记录字符出现的频次,每个字符最多出现一次,如果出现两次,那么左边一直向右滑动,直到变为1,再进行滑动时,我们更新最大长度,在使用哈希存储时,因为这里是字符,我们可以使用数组模拟哈希结构,这样可以提高效率。
left
和 right
分别指向字符串的起始位置,并初始化一个长度为 128 的哈希表 hash
用于记录字符出现的次数。同时,初始化变量 len
用于记录当前的最长无重复字符子串的长度。right
向右移动,同时在哈希表中记录字符的出现次数。如果发现当前字符的出现次数大于 1,说明出现了重复字符。left
向右移动,缩小窗口大小。在这个过程中,不断减少哈希表中左指针对应字符的出现次数,直到当前窗口中没有重复字符为止。len
为当前窗口的长度 right - left + 1
与已经记录的最大长度的较大者。right
移动到字符串的末尾。len
,即最长无重复字符子串的长度。代码
class Solution {
public:
int lengthOfLongestSubstring(string s) {
int hash[128]={0};
int n=s.size(),len=0,left=0,right=0;
while(right<n){
hash[s[right]]++;
while(hash[s[right]]>1) hash[s[left++]]--;
len = max(len,right-left+1);
right++;
}
return len;
}
};
题目链接:https://leetcode.cn/problems/max-consecutive-ones-iii/
给定一个二进制数组 nums
和一个整数 k
,如果可以翻转最多 k
个 0
,则返回 数组中连续 1
的最大个数 。
示例 1:
输入:nums = [1,1,1,0,0,0,1,1,1,1,0], K = 2
输出:6
解释:[1,1,1,0,0,1,1,1,1,1,1]
粗体数字从 0 翻转到 1,最长的子数组长度为 6。
示例 2:
输入:nums = [0,0,1,1,0,0,1,1,1,0,1,1,0,0,0,1,1,1,1], K = 3
输出:10
解释:[0,0,1,1,1,1,1,1,1,1,1,1,0,0,0,1,1,1,1]
粗体数字从 0 翻转到 1,最长的子数组长度为 10。
思路
这里我们首先不要被题目误导,题目让我们翻转0,我们也不用真的翻转,我们可以利用滑动窗口的思想,在窗口内最大容纳k个0,超过就出窗口,直到又是k个0,再继续入窗口,每次更新最大长度即可。
left
和 right
分别指向数组的起始位置,并初始化变量 zero
用于记录当前窗口中 0 的个数。同时,初始化变量 len
用于记录当前的最大长度。right
向右移动。如果当前元素为 0,将 zero
增加 1。k
,说明需要缩小窗口,将左指针 left
向右移动,减少窗口中 0 的个数。在这个过程中,如果左指针指向的元素是 0,则将 zero
减少 1。len
为当前窗口的长度 right - left + 1
与已经记录的最大长度的较大者。right
移动到数组的末尾。len
,即包含最多 k
个 0 的连续子数组的最大长度。代码
class Solution {
public:
int longestOnes(vector<int>& nums, int k) {
int n=nums.size(),zero=0,len=0;
for(int left=0,right=0;right<n;++right){
if(nums[right]==0) zero++;
while(zero>k)
if(nums[left++]==0) zero--;
len=max(len,right-left+1);
}
return len;
}
};
题目链接:https://leetcode.cn/problems/minimum-operations-to-reduce-x-to-zero/
给你一个整数数组 nums
和一个整数 x
。每一次操作时,你应当移除数组 nums
最左边或最右边的元素,然后从 x
中减去该元素的值。请注意,需要 修改 数组以供接下来的操作使用。
如果可以将 x
恰好 减到 0
,返回 最小操作数 ;否则,返回 -1
。
示例 1:
输入:nums = [1,1,4,2,3], x = 5
输出:2
解释:最佳解决方案是移除后两个元素,将 x 减到 0 。
示例 2:
输入:nums = [5,6,7,8,9], x = 4
输出:-1
示例 3:
输入:nums = [3,2,20,1,1,3], x = 10
输出:5
解释:最佳解决方案是移除后三个元素和前两个元素(总共 5 次操作),将 x 减到 0 。
思路
首先我们看到这道题,可能并不容易想到用滑动窗口来解,可能有很多人开始会想到双指针,但是,那样要处理的细节太多,不容易通过,其实这里我们可以将问题转化,从左右两边减去最少的操作数来达到要求,我们可以转换成将数组总和减去X,在中间找到最长的子数组之和等于这个数,再将原数组长度与子数组长度相减便可以求出最小操作数,这样是不是就更简单了呢?
sum
中。t
,即 t = sum - x
。right
向右移动,累加元素的值到 tmp
中。同时,在内层循环中,如果 tmp
大于目标差值 t
,将左指针 left
向右移动,减少窗口的和。在每一步中,检查当前窗口的和是否等于目标差值 t
。t
,则更新 ret
为当前窗口的长度 right - left + 1
和已经记录的最大长度的较大者。ret
仍然为初始值 -1,则说明无法找到符合条件的子数组,返回 -1。代码
class Solution {
public:
int minOperations(vector<int>& nums, int x) {
int n=nums.size(),sum=0,ret=-1;
for(const auto& num : nums) sum+=num;
int t=sum-x;
if(t<0) return -1;
for(int left=0,right=0,tmp=0;right<n;++right){
tmp+=nums[right];
while(tmp>t) tmp-=nums[left++];
if(tmp==t) ret=max(ret,right-left+1);
}
if(ret==-1) return -1;
else return n-ret;
}
};
题目链接:https://leetcode.cn/problems/fruit-into-baskets/
你正在探访一家农场,农场从左到右种植了一排果树。这些树用一个整数数组 fruits
表示,其中 fruits[i]
是第 i
棵树上的水果 种类 。
你想要尽可能多地收集水果。然而,农场的主人设定了一些严格的规矩,你必须按照要求采摘水果:
给你一个整数数组 fruits
,返回你可以收集的水果的 最大 数目。
示例 1:
输入:fruits = [1,2,1]
输出:3
解释:可以采摘全部 3 棵树。
示例 2:
输入:fruits = [0,1,2,2]
输出:3
解释:可以采摘 [1,2,2] 这三棵树。
如果从第一棵树开始采摘,则只能采摘 [0,1] 这两棵树。
示例 3:
输入:fruits = [1,2,3,2,2]
输出:4
解释:可以采摘 [2,3,2,2] 这四棵树。
如果从第一棵树开始采摘,则只能采摘 [1,2] 这两棵树。
示例 4:
输入:fruits = [3,3,3,1,2,1,1,2,3,3,4]
输出:5
解释:可以采摘 [1,2,1,1,2] 这五棵树。
思路
首先我们这里同样可以采取滑动窗口的方式进出窗口,同时更新最大长度即可,这里需要借助哈希容器来记录水果的种类,当然,我们也可以直接用数组来模拟哈希,这样可以提高效率,这里题目告诉我们最大长度是10^5
,因此我们可以建立一个100001大小的数组来模拟哈希。
left
和 right
分别指向数组的起始位置,并初始化一个数组 hash
用于记录水果的出现次数。同时,初始化变量 ret
用于记录最大长度,以及变量 kind
用于记录当前窗口中不同水果的种类数。right
向右移动。如果当前水果是一种新的水果(即 hash[fruits[right]] == 0
),则将 kind
增加 1。在哈希表中记录当前水果的出现次数。kind
大于 2,说明窗口中包含了超过两种不同水果。进入内层循环,将左指针 left
向右移动,减小窗口的大小。在这个过程中,不断减少哈希表中左指针指向的水果的出现次数,并更新 kind
。ret
为当前窗口的长度 right - left + 1
与已经记录的最大长度的较大者。right
移动到数组的末尾。ret
,即包含最多两种不同水果的连续子数组的最大长度。代码
class Solution {
public:
int totalFruit(vector<int>& fruits) {
int hash[100001]={0};
int ret=0,kind=0,n=fruits.size();
for(int left=0,right=0;right<n;++right){
if(hash[fruits[right]]==0) kind++;
hash[fruits[right]]++;
if(kind>2){
hash[fruits[left]]--;
if(hash[fruits[left]]==0) kind--;
left++;
}
ret=max(ret,right-left+1);
}
return ret;
}
};
题目链接:https://leetcode.cn/problems/find-all-anagrams-in-a-string/
给定两个字符串 s
和 p
,找到 s
中所有 p
的 异位词 的子串,返回这些子串的起始索引。不考虑答案输出的顺序。
异位词 指由相同字母重排列形成的字符串(包括相同的字符串)。
示例 1:
输入: s = "cbaebabacd", p = "abc"
输出: [0,6]
解释:
起始索引等于 0 的子串是 "cba", 它是 "abc" 的异位词。
起始索引等于 6 的子串是 "bac", 它是 "abc" 的异位词。
示例 2:
输入: s = "abab", p = "ab"
输出: [0,1,2]
解释:
起始索引等于 0 的子串是 "ab", 它是 "ab" 的异位词。
起始索引等于 1 的子串是 "ba", 它是 "ab" 的异位词。
起始索引等于 2 的子串是 "ab", 它是 "ab" 的异位词。
思路
这里我们可以借助两个哈希表,先存储p字符串的各字母个数,再使用另一个哈希表,进行长度为p字符串长度的固定滑动窗口对s字符串进行遍历,合法字符进窗口计数++,合法字符出窗口计数–,只有计数与p字符串长度相等时,存储窗口左下标,最后返回存储数组
代码
class Solution {
public:
vector<int> findAnagrams(string s, string p) {
vector<int> ret;
int hash1[26]={0};
int hash2[26]={0};
for(const auto& c:p) hash1[c-'a']++;
int n=s.size(),m=p.size(),count=0;
for(int left=0,right=0;right<n;++right){
char c1=s[right];
if(++hash2[c1-'a']<=hash1[c1-'a']) count++;
if(right-left+1>m){
char c2=s[left++];
if(hash2[c2-'a']--<=hash1[c2-'a']) count--;
}
if(count==m) ret.push_back(left);
}
return ret;
}
};
题目链接:https://leetcode.cn/problems/substring-with-concatenation-of-all-words/
给定一个字符串 s
和一个字符串数组 words
。 words
中所有字符串 长度相同。
s
中的 串联子串 是指一个包含 words
中所有字符串以任意顺序排列连接起来的子串。
words = ["ab","cd","ef"]
, 那么 "abcdef"
, "abefcd"
,"cdabef"
, "cdefab"
,"efabcd"
, 和 "efcdab"
都是串联子串。 "acdbef"
不是串联子串,因为他不是任何 words
排列的连接。返回所有串联子串在 s
中的开始索引。你可以以 任意顺序 返回答案。
示例 1:
输入:s = "barfoothefoobarman", words = ["foo","bar"]
输出:[0,9]
解释:因为 words.length == 2 同时 words[i].length == 3,连接的子字符串的长度必须为 6。
子串 "barfoo" 开始位置是 0。它是 words 中以 ["bar","foo"] 顺序排列的连接。
子串 "foobar" 开始位置是 9。它是 words 中以 ["foo","bar"] 顺序排列的连接。
输出顺序无关紧要。返回 [9,0] 也是可以的。
示例 2:
输入:s = "wordgoodgoodgoodbestword", words = ["word","good","best","word"]
输出:[]
解释:因为 words.length == 4 并且 words[i].length == 4,所以串联子串的长度必须为 16。
s 中没有子串长度为 16 并且等于 words 的任何顺序排列的连接。
所以我们返回一个空数组。
示例 3:
输入:s = "barfoofoobarthefoobarman", words = ["bar","foo","the"]
输出:[6,9,12]
解释:因为 words.length == 3 并且 words[i].length == 3,所以串联子串的长度必须为 9。
子串 "foobarthe" 开始位置是 6。它是 words 中以 ["foo","bar","the"] 顺序排列的连接。
子串 "barthefoo" 开始位置是 9。它是 words 中以 ["bar","the","foo"] 顺序排列的连接。
子串 "thefoobar" 开始位置是 12。它是 words 中以 ["the","foo","bar"] 顺序排列的连接。
思路
其实这里问题和上面的问题相同,只不过将字符变成了字符串,根据题目条件每个组合字符串都是相等长度,所以我们的计数条件也可以比较明确,就是一个字串的长度与所有子串之积,而且我们要保证不能有遗漏的情况,所以我们要从不同的起始点开始遍历单个字串长度次,还需要注意的一点就是这里是字符串,所以我们不能使用数组来模拟哈希,这里就需使用容器,其它的和上面的算法并无二异,都是使用滑动窗口的方式。
hash1
记录单词列表 words
中每个单词的出现次数。i
,其中 i
的范围是 [0, len - 1]
,其中 len
是单词长度。这是为了确保能够覆盖到所有可能的子串。right
向右移动,每次移动一个单词的长度 len
。在每一步中,截取字符串 str1
,表示当前窗口的单词。然后,更新哈希表 hash2
,表示当前窗口内各单词的出现次数。str1
是否在哈希表 hash1
中出现,并且当前窗口内该单词的出现次数不超过 hash1
中的次数。如果是,则将 count
增加 1。这里和下面的count–,先检查是一个提升效率的方式,不写也不影响代码运行,主要原因是如果hash1
中没有这个键,它就会直接插入,这对性能的消耗可能很大。len * m
,则需要收缩窗口。将左指针 left
向右移动,每次移动一个单词的长度 len
。在每一步中,截取字符串 str2
,表示当前窗口收缩的单词。然后,检查截取的字符串 str2
是否在哈希表 hash1
中出现,并且当前窗口内该单词的出现次数不超过 hash1
中的次数。如果是,则将 count
减少 1。count
等于单词列表 words
的长度 m
,说明当前窗口中包含了所有的单词,将左指针的位置加入结果数组 ret
。right
移动到字符串 s
的末尾。ret
。代码
class Solution {
public:
vector<int> findSubstring(string s, vector<string>& words) {
vector<int> ret;
unordered_map<string,int> hash1;
for(const auto& str:words) hash1[str]++;
int n=s.size(),m=words.size(),len=words[0].size();
for(int i=0;i<len;++i){
unordered_map<string,int> hash2;
for(int left=i,right=i,count=0;right+len<=n;right+=len){
string str1=s.substr(right,len);
hash2[str1]++;;
if(hash1.count(str1)&&hash2[str1]<=hash1[str1]) count++;
if(right-left+1>len*m){
string str2=s.substr(left,len);
if(hash1.count(str2)&&hash2[str2]<=hash1[str2]) count--;
hash2[str2]--;
left+=len;
}
if(count==m) ret.push_back(left);
}
}
return ret;
}
};
题目链接:https://leetcode.cn/problems/minimum-window-substring/
给你一个字符串 s
、一个字符串 t
。返回 s
中涵盖 t
所有字符的最小子串。如果 s
中不存在涵盖 t
所有字符的子串,则返回空字符串 ""
。
注意:
t
中重复字符,我们寻找的子字符串中该字符数量必须不少于 t
中该字符数量。s
中存在这样的子串,我们保证它是唯一的答案。示例 1:
输入:s = "ADOBECODEBANC", t = "ABC"
输出:"BANC"
解释:最小覆盖子串 "BANC" 包含来自字符串 t 的 'A'、'B' 和 'C'。
示例 2:
输入:s = "a", t = "a"
输出:"a"
解释:整个字符串 s 是最小覆盖子串。
示例 3:
输入: s = "a", t = "aa"
输出: ""
解释: t 中两个字符 'a' 均应包含在 s 的子串中,
因此没有符合条件的子字符串,返回空字符串。
思路
这里和前面两题类似,还是使用滑动窗口加哈希的方式来解决,只不过这里可以有重复字符,因此我们在计数时只要有一个,就不再计数,出窗口也一样,当计数和记录的种类相同时,我们更新起始位置和最小长度,最后返回字串即可。
hash1
和 hash2
分别记录字符串 t
中字符的出现次数和当前窗口中字符的出现次数。同时,使用变量 kind
记录不同字符的种类数。t
中字符次数: 遍历字符串 t
,更新数组 hash1
中对应字符的出现次数,并更新 kind
。right
向右移动。在每一步中,截取字符串 c1
表示当前窗口的右端字符。然后,更新数组 hash2
中右指针指向字符的出现次数,并根据当前窗口中字符的出现次数与字符串 t
中相应字符的出现次数比较,更新 count
。count
等于 kind
,说明当前窗口中包含了字符串 t
中的所有字符。进入内层循环,更新最小窗口的长度和起始位置。将左指针 left
向右移动,减小窗口的大小。在这个过程中,不断减少数组 hash2
中左指针指向字符的出现次数,并根据当前窗口中字符的出现次数与字符串 t
中相应字符的出现次数比较,更新 count
。minl
和 begin
。right
移动到字符串 s
的末尾。代码
class Solution {
public:
string minWindow(string s, string t) {
int hash1[128]={0};
int hash2[128]={0};
int kind=0;
for(const auto& ch:t)
if(hash1[ch]++==0) kind++;
int begin=-1,minl=INT_MAX,n=s.size();
for(int left=0,right=0,count=0;right<n;++right){
char c1=s[right];
if(++hash2[c1]==hash1[c1]) count++;
while(count==kind){
if(right-left+1<minl){
minl=right-left+1;
begin=left;
}
char c2=s[left++];
if(hash2[c2]--==hash1[c2]) count--;
}
}
if(begin==-1) return "";
return s.substr(begin,minl);
}
};