【算法】——佐助们一看就会的滑动窗口

前言

  滑动窗口算法是解决子数组/子串问题的高效技巧,它通过动态维护一个窗口区间来避免暴力枚举的冗余计算。无论是寻找最短覆盖子串、统计无重复字符的最长序列,还是计算固定窗口内的最大值,滑动窗口都能以O(n)的时间复杂度优雅解决。

  该算法的核心在于用双指针标记窗口边界,根据条件灵活调整指针位置,同时利用哈希表等结构快速判断窗口状态。相比暴力解法,滑动窗口通过剔除无效数据显著提升了效率,特别适合处理具有单调性或明确约束条件的连续序列问题。本文将结合典型例题,深入解析滑动窗口的应用场景和实现技巧。

滑动窗口

滑动窗口算法是解决子数组/子串问题的高效技巧,它通过动态维护一个窗口区间来避免暴力枚举的冗余计算。

核心思想​
  1. ​窗口的动态性​​:用双指针(leftright)表示当前窗口的左右边界,根据条件灵活移动指针。
  2. ​剪枝优化​​:通过剔除无效数据(如重复字符、不符合条件的元素),避免重复计算。

滑动窗口之所以取名于滑动窗口,在于其通过两个指针,来维护一个区间

这个区间就好似一个窗口,并且通过不断对其他元素进行判断,选择性地更新窗口

当一个元素符合条件,让其入窗口

  • 左边的指针不动
  • 右边的指针向后移动,窗口增大

当一个元素不符合条件,让整个窗口出现问题,就需要出窗口

  • 右边的指针不动
  • 左边的指针向后移动,窗口缩小,直到窗口内的元素符合条件

所有的滑动窗口问题,都会有两个必须动作

  • 入窗口,遇到合法元素让其入窗口
  • 出窗口,不断缩小窗口,直到剔除违法元素

所以,滑动窗口也有模版

  1. 入窗口
  2. 进行判断,是否出窗口
  3. 更新结果

其中,入窗口一定在出窗口前面

但更新结果具体在哪里,需要结合具体情况具体分析

什么时候用滑动窗口

判断一个问题是否适合用滑动窗口算法解决,可以从以下几个特征入手:

  1. ​连续子序列/子数组问题​
    当题目要求处理数组或字符串的连续子序列时,比如"最长无重复字符子串"、"最小覆盖子串"等,滑动窗口通常是最佳选择。

  2. ​明确的窗口约束条件​
    问题通常会给出明确的窗口条件,比如:

  • 窗口内元素和满足特定要求(如≥target)
  • 窗口内元素需要满足某些特性(如无重复字符)
  • 需要寻找满足条件的最短/最长窗口
  1. ​单调性问题​
    当窗口的扩张和收缩呈现单调性时(即右指针右移时,左指针只会保持不变或右移),滑动窗口特别有效。

  2. ​可优化的暴力解法​
    如果问题的暴力解法是O(n²)复杂度(如双重循环枚举所有子数组),且可以通过维护一个窗口来优化,就适合用滑动窗口。

  3. ​固定大小窗口​
    当问题要求处理固定长度的子数组时(如计算所有长度为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中的所有字符

  1. 入窗口,直接right++
  2. 判断当前滑动窗口中的子串是否满足条件

如果满足,则执行以下两步

  1. 更新结果
  2. 出窗口,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)的时间复杂度高效解决了大量子数组问题。

  通过本文的讲解,我们了解到滑动窗口的核心在于动态维护一个满足条件的窗口区间,通过左右指针的巧妙移动来寻找最优解。无论是处理固定大小的窗口,还是可变长度的子串,滑动窗口都展现出了其强大的适应性和效率。

  记住,当你遇到需要处理连续子序列、且具有明确约束条件的问题时,不妨考虑滑动窗口这个利器。它不仅能帮你提升算法效率,更能培养你对问题边界条件的敏感度。