前言
滑动窗口算法是解决子数组/子串问题的高效技巧,它通过动态维护一个窗口区间来避免暴力枚举的冗余计算。无论是寻找最短覆盖子串、统计无重复字符的最长序列,还是计算固定窗口内的最大值,滑动窗口都能以O(n)的时间复杂度优雅解决。
该算法的核心在于用双指针标记窗口边界,根据条件灵活调整指针位置,同时利用哈希表等结构快速判断窗口状态。相比暴力解法,滑动窗口通过剔除无效数据显著提升了效率,特别适合处理具有单调性或明确约束条件的连续序列问题。本文将结合典型例题,深入解析滑动窗口的应用场景和实现技巧。
滑动窗口
滑动窗口算法是解决子数组/子串问题的高效技巧,它通过动态维护一个窗口区间来避免暴力枚举的冗余计算。
核心思想
- 窗口的动态性:用双指针(
left
,right
)表示当前窗口的左右边界,根据条件灵活移动指针。 - 剪枝优化:通过剔除无效数据(如重复字符、不符合条件的元素),避免重复计算。
滑动窗口之所以取名于滑动窗口,在于其通过两个指针,来维护一个区间
这个区间就好似一个窗口,并且通过不断对其他元素进行判断,选择性地更新窗口
当一个元素符合条件,让其入窗口
- 左边的指针不动
- 右边的指针向后移动,窗口增大
当一个元素不符合条件,让整个窗口出现问题,就需要出窗口
- 右边的指针不动
- 左边的指针向后移动,窗口缩小,直到窗口内的元素符合条件
所有的滑动窗口问题,都会有两个必须动作
- 入窗口,遇到合法元素让其入窗口
- 出窗口,不断缩小窗口,直到剔除违法元素
所以,滑动窗口也有模版
- 入窗口
- 进行判断,是否出窗口
- 更新结果
其中,入窗口一定在出窗口前面
但更新结果具体在哪里,需要结合具体情况具体分析
什么时候用滑动窗口
判断一个问题是否适合用滑动窗口算法解决,可以从以下几个特征入手:
-
连续子序列/子数组问题
当题目要求处理数组或字符串的连续子序列时,比如"最长无重复字符子串"、"最小覆盖子串"等,滑动窗口通常是最佳选择。 -
明确的窗口约束条件
问题通常会给出明确的窗口条件,比如:
- 窗口内元素和满足特定要求(如≥target)
- 窗口内元素需要满足某些特性(如无重复字符)
- 需要寻找满足条件的最短/最长窗口
-
单调性问题
当窗口的扩张和收缩呈现单调性时(即右指针右移时,左指针只会保持不变或右移),滑动窗口特别有效。 -
可优化的暴力解法
如果问题的暴力解法是O(n²)复杂度(如双重循环枚举所有子数组),且可以通过维护一个窗口来优化,就适合用滑动窗口。 -
固定大小窗口
当问题要求处理固定长度的子数组时(如计算所有长度为k的子数组的平均值),滑动窗口是最直接的解决方案。
当遇到同时满足以上多个特征的问题时,就可以考虑使用滑动窗口算法了。
滑动窗口解决问题
解决:将X减到0的最小操作数
困惑:
滑动窗口是需要维护一段连续的区间的,但本题是每次删去最左元素或者最右元素,好像和滑动窗口八竿子打不着
但你仔细去思考就会发现,如果你按照正常思维去做这道题,老实本分地移除最左和最右元素,你就会发现,这道题目异常的难
解题思路:
正难则反,既然正面难做,就换个思路来
正面要求我们求出,最少的最左和最右元素组合使得x减为0的个数
那么反面思考一下,令原本数组的和为sum,去除符合条件的最左数和最右数
剩下的元素的和 = sum - x
我们需要求出最少个数的符合条件的最左数和最右数组合,那么不妨求最长的使得和为sum-x的子数组
因此,原本的问题从求最少个数的最左数和最右数组合,变成了求最长的和为sum-x的子数组
子数组?
- 直接使用双指针维护一个滑动窗口,这个滑动窗口就是一个子数组,记录其和为 new_sum
什么时候入窗口?什么时候出窗口?
入窗口,遇到新元素直接入窗口即可
- new_sum += nums[right]
出窗口,当子数组的和 > sum-x
- new_sum -= nums[left++];
出窗口是一个循环的过程,直到符合条件才能进行进入下一次入窗口
更新结果需要在入窗口和出窗口的后面,因为更新后的窗口可能就是我们需要的窗口
代码
class Solution {
public:
int minOperations(vector<int>& nums, int x) {
int sum = 0;
for(auto& e:nums) sum+=e;
int target = sum - x;
if(target<0) return -1;
sum = 0;
int ret = INT_MIN;
for(int left = 0,right = 0;right<nums.size();right++){
//入窗口
sum += nums[right];
//判断,是否出窗口
while(sum>target){
if(left<nums.size())sum -= nums[left];
left++;
}
//更新结果
if(sum == target) ret = max(ret,right-left+1);
}
return ret == INT_MIN?-1:nums.size()-ret;
}
};
解决:找到字符串中的所有字母异位词
要求:
找到字符串中所有字母异位词,即找到和目标字符串p中有着相同元素的所有子字符串
暴力解法:
两层for循环,第一层for循环固定起点,第二层for循环以p的长度从起点遍历字符串
遇到相同的,就直接返回下标,时间复杂度为n^2
滑动窗口:
使用左右双指针维护滑动窗口
入窗口,从起点开始,遇到一个元素直接入窗口
出窗口,判断此时滑动窗口的大小是否大于p的长度,大于则出窗口
更新结果,如果此时滑动窗口中的子串是p的异位词,则记录此时left的下标
如何判断滑动窗口中的子串符合要求呢?
最简单的方式:使用两个hash表进行记录
hash1用来记录p中每个单词出现的个数
hash2用来记录滑动窗口子串中每个单词出现的个数
当hash1完全等于hash2,则滑动窗口中的子串是p的异位词
巧妙方式:使用一个int整型count和两个hasn表来进行判断
好像更麻烦了,其实不然
hash1用来记录p中每个单词出现的个数
hash2用来记录滑动窗口子串中每个单词出现的个数
count用来记录有效字符出现的个数
判断是否是有效元素,hash2[i] <= hash1[i]
当入窗口时,进入的元素为有效元素,则count++
当出窗口时,进入的元素为有效元素,则count--
当count = p.size(),此时滑动窗口中的子串是p的异位词
代码:
class Solution {
public:
//悟已往之不谏,知来者之可追
vector<int> findAnagrams(string s, string p) {
vector<int> hash1(26);
vector<int> hash2(26);
vector<int> ret;
int count = 0;
for(auto&x:p) hash1[x-'a']++;
for(int left=0,right=0;right<s.size();right++){
//入窗口
int num = s[right]-'a';
hash2[num]++;
if(hash2[num]<=hash1[num]) count++;
//判断条件,出窗口
if((right-left+1)>p.size()){
num = s[left++]-'a';
if(hash2[num]<=hash1[num]) count--;
hash2[num]--;
}
//更新结果
if(count == p.size()) ret.push_back(left);
}
return ret;
}
};
例题:最小覆盖子串
解题思路:
使用左右指针,维护一个滑动窗口
对滑动窗口内的子串进行判断,是否覆盖了t中的所有字符
- 入窗口,直接right++
- 判断当前滑动窗口中的子串是否满足条件
如果满足,则执行以下两步
- 更新结果
- 出窗口,left++
如果仍然满足,则重复执行以上两步,直到子串不满足条件
代码:本次使用两个哈希表来判断是否满足覆盖子串的要求
class Solution {
public:
bool check(unordered_map<char,int>& hash1,unordered_map<char,int>& hash2){
for(auto x:hash1){
if(x.second > hash2[x.first]) return false;
}
return true;
}
string minWindow(string s, string t) {
unordered_map<char,int> hash1;
unordered_map<char,int> hash2;
int ret = INT_MAX;
string re_str="";
//初始化hash1
for(auto& x:t) hash1[x]++;
for(int right=0,left=0;right<s.size();right++){
//入窗口
hash2[s[right]]++;
while(check(hash1,hash2)){
//更新结果
if((right-left+1)<ret){
ret = right-left+1;
re_str = s.substr(left,ret);
}
//出窗口
if(left<s.size()) hash2[s[left++]]--;
}
}
return ret==INT_MAX?"":re_str;
}
};
这里留下一道题,希望大家在看完上述题目解析后能够自己解决
结语
滑动窗口算法就像一位精明的裁缝,用两根指针作为它的剪刀,在数据序列这块布料上精准裁剪出我们需要的子串。它优雅地避免了暴力枚举的冗余计算,以O(n)的时间复杂度高效解决了大量子数组问题。
通过本文的讲解,我们了解到滑动窗口的核心在于动态维护一个满足条件的窗口区间,通过左右指针的巧妙移动来寻找最优解。无论是处理固定大小的窗口,还是可变长度的子串,滑动窗口都展现出了其强大的适应性和效率。
记住,当你遇到需要处理连续子序列、且具有明确约束条件的问题时,不妨考虑滑动窗口这个利器。它不仅能帮你提升算法效率,更能培养你对问题边界条件的敏感度。