一文带你摸清滑动窗口算法套路

2,969 阅读6分钟

滑动窗口往往用来解决一些查找满足一定条件的连续区间的性质(长度等)的问题。类似于“请找到满足xx的最x的区间(子串、子数组)的xx”这类问题都可以使用该方法进行解决。既然是滑动窗口,那么滑动和窗口就是最基本的。窗口,顾名思义是维护了线性表上的一小段,这个窗口并不一定固定,可以伸缩可以滑动,后面我们详细分析;滑动,指的是我们的窗口在线性表上能够沿着一定的方向进行移动。说到这还是很抽象,今天我们就拒绝各种专业说辞,拒绝各种奇淫技巧,用最简单的白话最清晰的思路拆解滑动窗口算法的套路。

我们来看一下滑动窗口最核心的代码框架

     int right = 0;
     int left = 0;
     while (right < s.length()) {
         window.add(s[right])
         right++;
         //1.window数据更新
         while (2.窗口收缩判定) {
             window.remove(s[left])
             left++;
             //1.window数据更新
         }
     }
     //3.根据业务需求得到最终结果

从上面可以看出滑动窗口本质上就是双指针,不断去调节两个指针以达到解题目的。很多时候并不存在真正意义上的窗口,而只是靠两个指针维护的一段区间。聪明人都知道这只是最基本的框架,还需要在上面添油加醋。那么具体要在哪添什么油,加什么醋才是关键。这里我总结了几个重要的步骤:

  • window数据更新 窗口既然可以拓展可以滑动,那么必定伴随着数据的进出,当窗口整体滑动或者一边拓展收缩时,窗口中会新添加数据或者减少数据,此时我们需要更新统计数据,因为我们的解往往就跟统计数据密切相关,所以往往我们需要额外分配数据结构来进行窗口数据统计。判断需要记录更新什么,用什么数据结构记录是关键所在。

  • 窗口收缩判定 窗口不会无休止的拓展,有的时候我们是固定滑动窗口,那么很好理解,右边拓展,左边需要收缩以维持一个固定长度;有时候当右边拓展到不符合我们条件时,那么也需要收缩左边以满足我们需要;再或者我们需要优化结果追求一个最优解,那么也需要进行收缩。上述就是常见的收缩场景,当然不局限于此。这里需要说明的左右边也不是绝对的,只是举个最常见的情形,大家灵活判断。

  • 具体业务情形判定 这一步就非常灵活了,不同题考查的解形式自然不尽相同,无法形成相对固定的套路。但是仅仅前两步是无法得到最终的解,还需要进行最后一步的整理判断输出结果,到这里时你距离胜利就一步之遥了。

说了这么多我们拿着整理的套路和步骤先来小试牛刀。

先看道简单的,LeetCode3 leetcode-cn.com/problems/lo…

求满足条件的最长子串,比较典型的滑动窗口算法题。那么我们先套框架。

 public int lengthOfLongestSubstring(String s) {
        int left = 0;
        int right = 0;
        int res = 0;
        while (right < s.length()) {
            char in = s.charAt(right);
            right++;
            while (...) {
                char out = s.charAt(left);
                left++;
            }
        }
        return res;
    }

分析一下:

如果我们维护一个窗口然后不断向右拓展,然后更新下每次进入元素的数量,一旦发现进入的元素在窗口中已经存在了,那么立马开始收缩左边直至该元素数量为1,试想经过上诉步骤后窗口是不是总是保持无重复字符,这个过程中我们记录下窗口最大长度就可以得到最终结果。所以按照我们总结的步骤来就是:

第一步,window数据更新,明确我们在窗口伸缩时要记录什么数据,这里维护一个Hash表,记录窗口中字符的数量,以便我们判断是否有重复字符;

第二步,窗口收缩判定,当进入到窗口的元素在窗口中已经存在了,已经不符合需求了那么我们就需要去从左边缩减至该元素只有一个,使得窗口中一直无重复字符存在。

第三步,具体业务情形判定,既然窗口保持无重复状态,那么取到整个过程中窗口最大长度即可。

按上面步骤把代码填充进去,这题就完成了

 public int lengthOfLongestSubstring(String s) {
        int[] hash = new int[128];
        int left = 0;
        int right = 0;
        int res = 0;
        while (right < s.length()) {
            char in = s.charAt(right);
            hash[in]++;
            right++;
            while (hash[in] > 1) {
                char out = s.charAt(left);
                hash[out]--;
                left++;
            }
            res = Math.max(res,right-left);
        }
        return res;
    }

继续来看一道难度为中等的,LeetCode1052 leetcode-cn.com/problems/gr…

题目看似比较长,读懂后其实表达的含义很简单,翻译一下,就是给定一个customers数组,然后对数组中每个值标识了是否有效,连续X分钟不生气就是一个固定长度的滑动窗口,窗口内的数据都是恒定有效的,求整个滑动过程中有效数据的最大和。

我们想想这个有效值和是不是可以分为两部分,一部分是本身标识为有效值(绿色标识的0,2,1,5)的和,这个和是恒定的,不受窗口影响。另一个部分是窗口内无效值(红色标识的1,7)的和,因为它们最终都变成了有效值,窗口内有效值(绿色标识1)直接在前面就算过了,所以就不用管,那么滑动过程中记录窗口内无效值最大和是不是就可以得到最终结果。

可能很多人会有疑问为什么不直接计算整个滑动窗口的最大和去加上窗口外的有效值和,行的通吗?试想此时窗口外的和是不是成了变量,有可能跟窗口内的结果形成此消彼长的关系,那么最终结果如何判定最大呢?如果还有人说直接求每次滑动它俩和最后得到一个最大和不行嘛?只能说暴力出奇迹np!

所以我们将求有效值最大和的问题剥离到求窗口内无效值的最大和。那么开始上框架写步骤:

1.记录窗口数据,有效值和不用说很简单累加起来并记录就行,注意这里的有效值其实并不属于窗口数据,因为他是贯穿整个数组,只是借助窗口的滑动遍历累加。那么无效值和,就是进入窗口累加,出来减掉,统计最大值就ok;

2.窗口收缩判定,固定窗口不用说,两边指针间距大于固定长度时收缩;

3.业务情形判定,有效值和+窗口内无效值最大和=最终结果;

 public int maxSatisfied(int[] customers, int[] grumpy, int X) {
        int left = 0;
        int right = 0;
        int angry = 0;//无效值和
        int unAngry = 0;//有效值和
        int maxAngry = 0;//滑动窗口内的无效值最大和
        while (right < customers.length) {
            int in = customers[right];
            if (grumpy[right] == 0) {
                unAngry += in;
            } else {
                angry += in;
            }
            right++;
            while (right - left > X) {
                int out = customers[left];
                //如果是无效值就减去
                if (grumpy[left] == 1) {
                    angry -= out;
                }
                left++;
            }
            maxAngry = Math.max(maxAngry, angry);
        }
        return unAngry + maxAngry;
    }

相信现在看代码就比较好理解了。

最后再来一道Hard题,看看用我们套路能否打回原形。LeetCode76 leetcode-cn.com/problems/mi…

分析下,窗口在滑动过程是不是要判断window是否涵盖target所有字符对不对?如何判断呢,我们只需要保证target每个字符的数量都小于或者等于window中相对应字符的数量相等就可以,例如abbc涵盖abc吧,如果target是abcd呢,d的数量就大于了前者就不满足了,所以先要记录window中进出元素的数量。另外为了保证tartget所有字符被涵盖上,我们记录下tartget中不重复字符个数,这样当所有字符个数都满足的时候是不是就可以保证window涵盖了所有target字符。

那么什么时候开始缩减呢?当窗口内的数据可以匹配上target时,我们为了追求一个最优解需要进行一个缩减。如下图步骤2,window内满足涵盖target但是可以缩减至虚线处取到尽可能小的长度,如果缩减到不满足条件了,咋办,继续往右边拓展呗,步骤1中缩减至虚线处,但是不满足涵盖,于是window拓展至步骤2所示。然后重复上面步骤滑动到最右端然后找到每次窗口匹配上后长度的最小值。

最后拿到最小长度的起点截取字符串就可以得到最终的结果。

 public String minWindow(String src, String target) {
        int[] need = new int[128];//target各字符数量记录表
        int[] window = new int[128];//窗口中字符记录表
        int left = 0;
        int right = 0;
        int matchSum = 0; //匹配数量
        int start = 0; //记录满足最小长度的起始位置
        int len = Integer.MAX_VALUE; //最小子串长度 
        int effective = 0;//target中不重复字符的数量

        //统计target各字符数量及不重复字符的数量
        for (int i = 0; i < target.length(); i++) {
            char key = target.charAt(i);
            if (need[key] == 0) {
                effective++;
            }
            need[key]++;
        }
        while (right < src.length()) {
            char in = src.charAt(right);
            right++;
            if (need[in] > 0) {  //证明当前进入窗口的字符是target中的字符
                window[in]++;
                if (need[in] == window[in]) {
                    matchSum++;
                }
            }
            //当全部匹配上后开始缩减窗口
            while (matchSum == effective) {
                //由于left在不断收紧那么就需要不断更新start
                if (right - left < len) {
                    start = left;
                    len = right - left;
                }
                char out = src.charAt(left);
                left++;
                //移出操作与上面进入操作对称
                if (need[out] > 0) {
                    if (need[out] == window[out]) {
                        matchSum--;
                    }
                    window[out]--;
                }
            }
        }
        return len == Integer.MAX_VALUE ? "" : src.substring(start, start + len);
    }

解完是不是觉得Hard题也不过如此。

接下来我们来分析下滑动窗口的时间空间复杂度,窗口滑动到最末端一次遍历,中间步骤都是window数据更新,即使存在窗口缩减左指针也最多滑动到最右端,所以时间复杂度为O(n),空间复杂度就取决于你在进行window数据记录时的数据结构了!

最后想说的是上述总结出来的框架和步骤都是基于通用的滑动窗口题型总结归纳出来,题目考察形式千变万化,不是所有题都能拿上面框架步骤进行生搬硬套,最重要的还是理解其思想,灵活变通以不变应万变!

再归纳几个题型,练手总结。

leetcode-cn.com/problems/lo…

leetcode-cn.com/problems/fi…

leetcode-cn.com/problems/sl…

leetcode-cn.com/problems/pe…

leetcode-cn.com/problems/su…

leetcode-cn.com/problems/ma…