滑动窗口算法思想(附经典例题)

滑动窗口算法思想是一个高频的算法思想,其所涉及的问题也是比较广泛、比较经典的,同时难度也通常是不小的,这篇文章谨记录自己对滑动窗口算法思想的一些思考和总结,也欢迎补充、纠正错误,与诸君共勉。

在介绍滑动窗口之前,我想先借助计算机网络中的概念知识来引入一下,学过计算机网络的同学,都知道滑动窗口协议(Sliding Window Protocol),该协议是 TCP协议 的一种应用,用于网络数据传输时的流量控制,以避免拥塞的发生。该协议允许发送方在停止并等待确认前发送多个数据分组。由于发送方不必每发一个分组就停下来等待确认。因此该协议可以加速数据的传输,提高网络吞吐量。

滑动窗口算法其实和这个是一样的,只是用的地方场景不一样,可以根据需要调整窗口的大小,有时也可以是固定窗口大小。

一、算法概念

滑动窗口算法是在给定特定窗口大小(当然也可以是动态可变窗口)的数组或者字符串上进行操作的算法,该算法主要的用途就是在于将嵌套循环时间复杂度的效率优化成为线性时间复杂度。简而言之,滑动窗口算法在一个特定大小的字符串或数组上进行操作,而不在整个字符串和数组上操作,这样就降低了问题的复杂度,从而也达到降低了循环的嵌套深度。

从字面意思上理解的话:

  • 滑动:说明这个窗口是移动的,也就是移动是按照一定方向来的。

  • 窗口:窗口大小并不是固定的,可以不断扩容直到满足一定的条件;也可以不断缩小,直到找到一个满足条件的最小窗口;当然也可以是固定大小。

二、算法大体框架

一般来讲,滑动窗口算法需要借助双指针技巧,通常也是离不开队列这样一个数据结构的,哈希表查找也会在这里应用到,不妨拿字符串来简单举个框架,其实字符串和数组是相通的,本质都是一样的,关键是思想框架。

(这里借助一篇优秀博客的图示讲解,https://www.cnblogs.com/huansky/p/13488234.html

滑动窗口算法的思路是这样:

  1. 我们在字符串 S 中使用双指针中的左右指针技巧,初始化 left = right = 0,把索引闭区间 [left, right] 称为一个「窗口」。

  2. 我们先不断地增加 right 指针扩大窗口 [left, right],直到窗口中的字符串符合要求(包含了 T 中的所有字符)。

  3. 此时,我们停止增加 right,转而不断增加 left 指针缩小窗口 [left, right],直到窗口中的字符串不再符合要求(不包含 T 中的所有字符了)。同时,每次增加 left,我们都要更新一轮结果。

  4. 重复第 2 和第 3 步,直到 right 到达字符串 S 的尽头。

这个思路其实也不难,第 2 步相当于在寻找一个「可行解」,然后第 3 步在优化这个「可行解」,最终找到最优解。左右指针轮流前进,窗口大小增增减减,窗口不断向右滑动。

下面画图理解一下,needs 和 window 相当于计数器,分别记录 T 中字符出现次数和窗口中的相应字符的出现次数。

初始状态:
在这里插入图片描述

增加 right,直到窗口 [left, right] 包含了 T 中所有字符:
在这里插入图片描述

现在开始增加 left,缩小窗口 [left, right]。

在这里插入图片描述

直到窗口中的字符串不再符合要求,left 不再继续移动。

在这里插入图片描述

之后重复上述过程,先移动 right,再移动 left…… 直到 right 指针到达字符串 S 的末端,算法结束。

如果你能够理解上述过程,恭喜,你已经完全掌握了滑动窗口算法思想。至于如何具体到问题,如何得出此题的答案,都是编程问题,等会提供一套模板,理解一下就会了。

三、算法伪代码模板

动态窗口大小的模板:

    string s, t;
    // 在 s 中寻找 t 的「最小覆盖子串」
    int left = 0, right = 0;
    string res = s;
    
    while(right < s.size()) {
        window.add(s[right]);
        right++;
        // 如果符合要求,说明窗口构造完成,移动 left 缩小窗口
        while (window 符合要求) {
            // 如果这个窗口的子串更短,则更新 res
            res = minLen(res, window);
            window.remove(s[left]);
            left++;
        }
    }
    return res;

固定窗口大小的模板:

  // 固定窗口大小为 k
    string s;
    // 在 s 中寻找窗口大小为 k 时的所包含最大元音字母个数
    int  right = 0;while(right < s.size()) {
        window.add(s[right]);
        right++;
        // 如果符合要求,说明窗口构造完成,
        if (right>=k) {
            // 这是已经是一个窗口了,根据条件做一些事情
           // ... 可以计算窗口最大值等 
            // 最后不要忘记把 right -k 位置元素从窗口里面移除
        }
    }
    return res;    

 四、经典例题

1.动态窗口大小+字符串

76. 最小覆盖子串https://leetcode-cn.com/problems/minimum-window-substring/

给你一个字符串 s 、一个字符串 t 。返回 s 中涵盖 t 所有字符的最小子串。如果 s 中不存在涵盖 t 所有字符的子串,则返回空字符串 "" 。

注意:

  • 对于 t 中重复字符,我们寻找的子字符串中该字符数量必须不少于 t 中该字符数量。
  • 如果 s 中存在这样的子串,我们保证它是唯一的答案。

示例 1:

输入:s = "ADOBECODEBANC", t = "ABC"
输出:"BANC"

示例 2:

输入:s = "a", t = "a"
输出:"a"

示例 3:

输入: s = "a", t = "aa"
输出: ""
解释: t 中两个字符 'a' 均应包含在 s 的子串中,
因此没有符合条件的子字符串,返回空字符串。

提示:

  • 1 <= s.length, t.length <= 105
  • s 和 t 由英文字母组成

其实,看完这道经典例题很容易发现,这题的解题思路分析其实就是上面所举的图示讲解。不妨再回去看一眼吧,这里给出我的代码,虽然没有在代码里留下注释,不过我会一步步分析下来的。

class Solution {
public:
    string minWindow(string s, string t) {
        map<char,int> needs;
        int needCnt=0;
        for(auto ch:t){
            needs[ch]++;
            needCnt++;
        }
        int i=0;int j=0;
        int ansl=0;int ansr=-1;
        int windowlen=INT_MAX;
        int len=s.size();
        while(j<len){
            if(needs[s[j]]>0)needCnt--;
            needs[s[j]]--;
            if(needCnt==0){
                while(i<j){
                    if(needs[s[i]]==0)break;
                    needs[s[i]]++;
                    i++;
                }
                if(j-i<windowlen){
                    windowlen=j-i;
                    ansl=i;
                    ansr=j;
                }
                needs[s[i]]++;
                needCnt++;
                i++;
            }
            j++;
        }
        if(windowlen==INT_MAX)return "";
        return s.substr(ansl,windowlen+1);
    }
};

思路已经在上面的图示中有了解释,就是先增加右指针j,到第一次满足条件的位置,然后在增加左指针i,到第一次不满足条件的位置,维护更新答案区间和最小窗口长度windowlen,然后让i++,重复j的步骤,直到j不满足循环条件,如果windowlen被更新过了,那么给出答案区间,反之,给出空串。

有了这样的思路,其实这题难度倒集中在对于哈希表的维护,其实当你真正开始写的时候,你会发现哈希表的维护并不是那么容易用代码写清楚并且有高鲁棒性的,这里分享一下我最终确定的思路,我们维护一个动态的哈希表needs,表示我们还需要的字符的数量,一定要注意,我们表示的是还需要的字符的数量,如果为负数,则表示这个字符是多余的,我们不需要管他。并标记一个变量needCnt,表示t字符串中应该匹配元素的数量,只有当对应需要匹配的字符出现时,我们才让needCnt减一,同时为了控制保证t中字符数量在后序更新的时候不会被扰乱,我们让每个s中的字符都在needs减一,这样便可以保证题意条件下不会出错,这里需要好好体会一下。

在增加i的循环中,我们需要先判断一下needs对应的是否为0,如果为0,则表示这个字符如果丢了就不满足题意了。你可能会有疑问,那之前更新needs的时候,你让s所有的字符都自减了,那这里让needs为0的那个字符不会有可能不是t中的字符吗,这样不就是错的吗。

其实这才是理解的最关键的一步,我们先判断是否为0,因为只有t中的字符一开才会在needs中为正,不管他后来减为多少,无关的字符一开始肯定是负的,至于t中相关字符是减为0还是减为负数,我们不用在意,因为即使是负数的话,根据我们needs的定义,就表明这个字符可以被丢弃,是多余的,那么第一个出现needs为0的字符一定就可以保证这个字符丢掉的话我们就不满足和t中相匹配的条件了。

由此,也就和前面所解释的操作相互呼应起来,也不会有任何问题了。

这是关键思想部分,其他的代码部分理解起来就比较简单了。

2.固定窗口+数组

239. 滑动窗口最大值icon-default.png?t=LBL2https://leetcode-cn.com/problems/sliding-window-maximum/

给你一个整数数组 nums,有一个大小为 k 的滑动窗口从数组的最左侧移动到数组的最右侧。你只可以看到在滑动窗口内的 k 个数字。滑动窗口每次只向右移动一位。

返回滑动窗口中的最大值。

示例 1:

输入:nums = [1,3,-1,-3,5,3,6,7], k = 3
输出:[3,3,5,5,6,7]
解释:
滑动窗口的位置                最大值
---------------               -----
[1  3  -1] -3  5  3  6  7       3
 1 [3  -1  -3] 5  3  6  7       3
 1  3 [-1  -3  5] 3  6  7       5
 1  3  -1 [-3  5  3] 6  7       5
 1  3  -1  -3 [5  3  6] 7       6
 1  3  -1  -3  5 [3  6  7]      7

示例 2:

输入:nums = [1], k = 1
输出:[1]

示例 3:

输入:nums = [1,-1], k = 1
输出:[1,-1]

示例 4:

输入:nums = [9,11], k = 2
输出:[11]

示例 5:

输入:nums = [4,-2], k = 2
输出:[4]

提示:

  • 1 <= nums.length <= 105
  • -104 <= nums[i] <= 104
  • 1 <= k <= nums.length

这是一道固定窗口大小,其实借助双指针和优先队列内置API来解的话这不是一道很难的题目,这里直接给出代码,是自己维护优先队列,直接用STL会更简单。

class Solution {
public:
    //经典滑动窗口的最大值 因为是最大值 维护一个单调双端队列 滑动窗口->队列 双指针技巧简化
    vector<int> maxSlidingWindow(vector<int>& nums, int k) {
        vector<int> res;
        int i=1-k;
        int j=0;
        deque<int> dq;
        int len=nums.size();
        for(j=0;j<len;i++,j++){
            if(i>0 && nums[i-1]==dq.front())dq.pop_front();
            while(!dq.empty() && dq.back()<nums[j])dq.pop_back();
            dq.push_back(nums[j]);
            if(i>=0)res.emplace_back(dq.front());
        }   
        return res;
    }
};

 到此就差不多啦,愿诸君有所获!

  • 1
    点赞
  • 11
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

敲码的钢珠

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值