这是一篇关于滑动窗口的详细的入门级攻略(真的就只是入门级)。

本文指 ACM 算法中的滑动窗口,而不是 TCP 拥塞控制中使用的滑动窗口协议(虽然两者十分相似)。

一定要看最后的总结呀~

# 滑动窗口的概念

滑动窗口算法是一种在数组中找到满足给定条件的子数组的方法,当然字符串也是适用的。我们通过维护一个子集作为窗口来做到这一点,并在更大的列表中调整和移动该窗口,直到找到解决方案。

# 从经典例题开始

# LeetCode 239. 滑动窗口最大值

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

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

示例1

# 简单的思路

本题的题意是比较清楚的,因此不在赘述题意,不明白题意的话,可以结合示例理解一下。

首先看到题目,很容易想出来的思路是:两重嵌套循环,外循环遍历窗口,内循环遍历当前窗口的每一个数字,自然可以求出每一个窗口的最大值。

用伪代码表示该过程:

伪代码
vector<int> res;
for (遍历每个窗口 i) {
    t = INT_MIN;
    for (遍历窗口中的每个数字 j) {
        t = max(t, j);
    }
    res.push_back(t);
}

分析一下代码,发现这种做法妥妥的是 O (nk) 的时间复杂度(其中 n 为 nums 长度)。而本题的数据范围中,n 和 k 都是10510^5 势必会超时。

那么,我们分析一下,这种暴力的做法是否有什么冗余呢?


很关键

首先,从示例中来看,我们要 求的是最大值,当窗口在 [-1,-3,5] 的时候,遍历窗口中的每个数字,求出 最大值5 之后,窗口向右移动一位来到 [-3,5,3] ,再次遍历窗口中的每个数字,求出最大值,仍然是 5

OK,请注意上述过程,我们在两次求最大值的时候,都遍历了窗口中的所有元素,所以两个窗口中的共同元素 -3 被遍历了两次。

而它 ( -3 ) 有必要被遍历到两次吗?没有必要,甚至它根本不需要再被遍历到。我们注意到它后面的数字 5 ,比它更大,并且在它之后滑出窗口,因此只要 5 存在, -3 就必然不可能是窗口中的最大值。

也就是说,只要比 -3 更大的数字 5 进入窗口, -3 根本就没有必要再被遍历了。

进一步的,你会发现当前窗口中所有比 5 小的数字,其实都不需要被遍历了,因为他们都在 5 之前滑出窗口,并且因为 5 的存在,他们都不可能是最大值。

综上,我们是否可以想到一种方式:用一个具有单调性的双端队列作为窗口,来存储遍历到的每个数字,我们让队列头部时刻保持最大值,这样每次直接取队头元素就是当前窗口的最大值。

很关键结束


# Code

思路虽然说理解起来并不难,但是想实现好,仍然需要诸多细节:

  1. 当前遍历到的元素 i 大于队尾元素的情况下,为了保持单调性,需要将队列中所有比 i 小的元素均弹出队列。
  2. 需要判断队头元素是否已经滑出了窗口。
  3. 什么时候开始输出元素。

为了解决第二点,我们让队列中存储下标,而不是值。这样我们通过下标来判断当前处于哪个窗口,而队列为了保持单调性,可能没有完整存储窗口中所有元素。

vector<int> maxSlidingWindow(vector<int>& nums, int k) {
    vector<int> res;
    deque<int> q;
    for (int i = 0; i < nums.size(); i ++ ) {
        // 代码①。只要队尾元素小于当前元素,就弹出队尾元素,保持队列单调性。  
        while (!q.empty() && nums[q.back()] < nums[i]) {
            q.pop_back();
        }
        q.push_back(i); // 代码②。入队
        // 代码③。当队首元素滑出窗口,就把队首元素出队。
        if (!q.empty() && i - k >= q.front()) {
            q.pop_front();
        }
        // 代码④。窗口形成 才可以开始输出元素。
        if (i - k + 1 >= 0) {
            res.push_back(nums[q.front()]);
        }
    }
    return res;
}

# 代码执行流程

如果读者还不能理解滑动窗口的含义,可以根据下图梳理一遍整个执行流程中队列的变换。
有几点需要注意:

  1. 判断队首元素是否滑出窗口,用了 i-k>=q.front() ,请参考,当前下标 k=3、i=7 的时候,此时窗口中的元素下标应该是 [5,6,7] ,那么 i-k 实际上就是当前窗口的最左边元素的前一个元素,我们判断如果这个元素大于等于队列头部元素 (直接等于也可以,因为一次只能滑动一步),则说明该头部元素已经滑出窗口,既,代码③。
  2. 判断窗口形成,用了 i-k+1>=0 ,也就是当 i=2 的时候,窗口刚好形成。
  3. 请根据代码执行流程来理解滑动窗口的设计思想。

# 时间复杂度

虽然,使用单调队列实现的滑动窗口也有两重循环,但是我们可以发现每个元素,只 进 出 队列一次,因此该实现是标准的线性时间复杂度,既 O (n)。

# LeetCode 3. 无重复字符的最长子串

给定一个字符串 s ,请你找出其中不含有重复字符的 最长子串 的长度。

示例 1:
输入: s = "abcabcbb"
输出: 3
解释:因为无重复字符的最长子串是 "abc",所以其长度为 3。

示例 2:
输入: s = "bbbbb"
输出: 1
解释:因为无重复字符的最长子串是 "b",所以其长度为 1。

示例 3:
输入: s = "pwwkew"
输出: 3
解释:因为无重复字符的最长子串是 "wke",所以其长度为 3。
请注意,你的答案必须是 子串 的长度,"pwke" 是一个子序列,不是子串。

# 思路与实现

本题也是最经典的滑动窗口的应用之一。先来理解一下题目,本题有几个关键点:

  1. 子串的概念是:连续的字符串,不可以隔着选;
  2. 子串中的字符不能重复。
  3. 目标是找到 所有不重复字符子串 中 最长的那一个。

既然是连续的不重复子串,那么一个很容易想到的做法是:嵌套循环,外循环枚举每一个字符 i,内循环 i 处向后枚举每一个字符,内循环的字符如果和外循环的字符重复,则记录最大值,并 break。

用伪代码表示该过程:

伪代码
int res = 0; // 最长的无重复字符子串
for (遍历每个字符 i)
    for (j: i+1 ~ n)
        if (j = n - 1 || s[i] == s[j])
            res = max(res, j - i);
            break;

各位发现没有,这个伪代码怎么和上一题的长的有点像呢?那我们可不可以用相同的思路优化呢?

还是先来看一下这种暴力的做法有什么地方是冗余的操作。以示例 1 中 s = "abcabcbb" 为例。

当我们执行外循环,第一次枚举出 abca 的时候,我们得到了 res=3 ,此时,继续执行外循环,又枚举出了 bcab 。注意,实际上,我们在第一次枚举的时候,实际上已经得知了一个隐含信息,既,bca 是不重复的

大家可以思考一下:当刚好枚举到第二个 a (注意这里的关键词是 "刚好") ,也就是子串 abca 的时候发生重复,那么说明什么?说明当去掉第一个 a 之后,剩下的子串 bca 是不重复的,可是我们继续循环的时候,仍然又枚举了 bca ,此时就出现了冗余操作。

(为了避免歧义,特别说明一下,在这个例子里,出现重复的 a 在子串最左边可能显得有点特殊,但是实际重复的字符可能出现在任何位置。)

如何优化呢?
我们就可以使用滑动窗口的技巧,维护一个没有重复字符的窗口,具体如何做呢?

可以使用双指针来解决,具体的:

  1. 一个指针 right 向后扫描字符串,使用哈希表记录每个字符出现的次数。
  2. 移动指针 left,直到重复的这个字符只出现一次为止。
  3. 更新结果。

# Code

实现上有两个小细节:

  1. 使用 hash 表来统计当前重复的字符;
  2. 因为在第二步已经保证了,没有重复字符,所以在更新结果的时候,直接更新 i~j 的距离就行了,也就是 j-i+1 (注意不是伪代码中的 j-i )。
int lengthOfLongestSubstring(string s) {
    int res = 0;
    unordered_map<char, int> h;
    for (int i = 0, j = 0; j < s.size(); j ++ ) {
        h[s[j]] ++ ; // 统计当前字符的出现次数
        // 当前字符如果重复出现,就移动左指针,直到不重复
        while (h[s[j]] > 1) h[s[i ++ ]] -- ;
        res = max(res, j - i + 1); // 统计结果
    }
    return res;

# 小结

从上面两个例题中,可以看出滑动窗口并不是某个指定的算法,而是一种解题思路(或者说技巧),用来处理某类固定的问题。根据问题的不同选用不同的实现方式,比如:第一题使用了单调队列实现、而第二题则使用了双指针实现。

# 更多的练习题

# LeetCode 76. 最小覆盖子串

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

注意:

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

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

# 实现与分析

本题也是滑动窗口的经典应用,是一个很关键的例题。

首先看一下题意,题目有中有几个关键点:

  1. s 和 t 中均有重复字符,那么对于 t 中的重复字符 x,找到的 s 子串中 x 的数量不能比 t 中少。
  2. 子串必须是连续的。
  3. 很重要的一点是,对于 t 中的每个字符,在 s 子串中的相对位置可以不同。

本题其实是一个经典的滑动窗口应用,并且窗口的长度是可变的。

按照前面一道题(无重复字符的最长子串)的思路分析,我们的算法可以分为以下步骤:

  1. 哈希表ht 统计 t 中每个字符的个数,用一个整形 cnt 来统计当前已经匹配的字符个数;
  2. 用双指针 ij 组成窗口,扫描整个 s 串,用另一个 哈希表hs 来统计当前扫描到的 s 中的字符 s[i] 的个数;
  3. 如果 s 串中的字符也在 t 串中,同时个数不多余 t 串中的字符个数,那就说明该字符是有用的,把 cnt++ ,表示找到了一个有用的字符。
  4. 检查 s [j] 是否多余,如果多余,则向后移动;
  5. 判断 cnt 是否于 t 的大小相等,如果是,则更新答案;

# Code

string minWindow(string s, string t) {
    unordered_map<char, int> ht, hs;
    int cnt = 0;
    string res;
    // 统计 t 中每个字符的个数
    for (int i = 0; i < t.size(); i ++ ) ht[t[i]] ++ ;
    // 双指针形成窗口,扫描 字符串 s
    for (int i = 0, j = 0; i < s.size(); i ++ ) {
        hs[s[i]] ++ ; // 统计字符 s [i] 个数
        // 有效字符可以使 cnt ++ 
        if (hs[s[i]] <= ht[s[i]]) cnt ++ ;
        // 通过移动指针 j 来过滤掉多余字符
        while (j <= i && hs[s[j]] > ht[s[j]]) {
            hs[s[j ++ ]] -- ;
        }
        // 如果有效字符和 t 中字符数量相等,则更新答案
        if (cnt == t.size()) {
            if (res.empty() || i - j + 1 < res.size()) {
                res = s.substr(j, i - j + 1);
            }
        }
    }
    return res;
}

# 一些你可能有的疑问

  1. 为什么仅仅靠 cnt 和 t 中字符数量相等,就可以认定是找到了有效子串呢?不会出现(s 的子串中)某个字符多导致了另外的字符少这种情况吗?

    答:不会的,注意代码的顺序,我们在步骤 4 中过滤了多余的字符,使得当前扫描过的所有 s [i] 的数量一定小于等于它在 t 中的数量,这也是指针 j 存在的意义。这就使得到达步骤 5 的时候,cnt 只有两种情况:1、小于 t 中字符数量;2、等于 t 中字符数量;

  2. cnt 的变化。

    请注意,cnt 只有一种情况会变化,它只会被指针 i 控制,只有在当前 s [i] 是有效字符的时候,才会变化,并且只增不减。那么,它会不会超过 t 的大小,从而使靠后的合法结果没办法统计到呢?不会的,因为当 cnt 等于 t 的大小时,说明所有的有效字符已经找全了,这些已经找全的字符不会再减少了,因为步骤 4 我们只会过滤多余的字符,而已经找到的合法字符不会减少,所以所以当 cnt 第一次等于 t 大小的时候,cnt 的值就固定下来,不会在变化了。

  3. 如何去定义(s 中的)有效字符?

    答:在上述的代码实现中,我们认为(s 中的)有效字符必须满足以下两点:1、在 t 中存在;2、hs 中统计到的数量,没有超过该字符在 ht 中统计的数量。

  4. 如何定义冗余字符?

    答:在上述的代码实现中,我们认为某个有效字符的数量不满足它的第二点,则新统计到的有效字符就是冗余的,需要过滤掉。

  5. 步骤 4 过滤冗余字符,会不会同时把有效字符也过滤掉?

    答:不会的,因为过滤字符是操作是在哈希表中减少它的数量,而不是在滑动窗口中把它移动出去。

  6. 滑动窗口里面存的到底是什么?

    答:滑动窗口 (j~i) 存的有效内容是 包含 t 中所有字符的 s 子串。比如:示例中的 ADOBECBANC

  7. i 和 j 的作用是什么?或者说 为啥要用滑动窗口?

    答:指针 i 负责统计 s [i] 的个数,并且统计有效字符,也就是更新 cnt。指针 j 负责将冗余的 s [i] 去掉,以保证能得到题目要求的 "最小覆盖子串"。

如果还有不懂的问题,请直接把示例给的数据带入到代码中,在脑海里完整执行一遍叭。

# LeetCode 424. 替换后的最长重复字符

给你一个字符串 s 和一个整数 k 。你可以选择字符串中的任一字符,并将其更改为任何其他大写英文字符。该操作最多可执行 k 次。

在执行上述操作后,返回包含相同字母的最长子字符串的长度。

示例 1:
输入:s = "ABAB", k = 2
输出:4

输入:s = "AABABBA", k = 1
输出:4
解释:
将中间的一个 'A' 替换为 'B', 字符串变为 "AABBBBA"。子串 "BBBB" 有最长重复字母,答案为 4。

# 思路及实现

本题的题意比较简单,可以将给定的字符串 s 中的字符任意修改 k 个,然后问修改之后可以得到的包含相同字符的最长子串的长度(最终目标)。

本题有一个重要的关键点,那就是修改哪些字符可以达到最终目标?

要理解这点,必须转明白一个脑筋急转弯:想通过修改让一个子串的字符全相同,那么子串中出现次数最多的字符一定是不用修改的,而修改的一定是其他字符

也就是说只要找出这个子串中出现次数最多的字符的个数 x,然后用这个子串的长度减去 x,就是我需要修改的字符个数。

更进一步的思考,如果我们用一个滑动窗口来保存可以通过修改 k 个字符,达到全部字符相同的子串,那么我们只要在这些子串中找到长度最长的那个就可以了。

具体地:

  1. 使用双指针来维护滑动窗口。枚举字符串 s,用 哈希表h 统计当前字符 s [i] 出现的次数,并且更新当前出现最多字符的次数 maxx;
  2. 移动指针 j 以保证当前子串想要统一字符,需要修改的字符个数不大于 k;
  3. 更新结果;

# Code

int characterReplacement(string s, int k) {
    unordered_map<char, int> h;
    int maxx = 0;
    int res;
    for (int i = 0, j = 0; i < s.size(); i ++ ) {
        h[s[i]] ++ ;  // 统计当前字符个数
        maxx = max(h[s[i]], maxx); // 更新当前最多字符个数
        // 步骤二
        while (i - j + 1 - maxx > k) {
            //s [j] 滑出窗口 因此个数减少
            h[s[j]] -- ;
            // 下面要先更新当前最多字符个数,再移动 j 
            // 不然就更新错字符了。
            maxx = max(h[s[j]], maxx);
            j ++ ;
        }
        res = max(i - j + 1, res);
    }
    return res;
}

# 需要注意的地方

  1. 每次出现字符数量的变更,都可能影响 当前最多字符个数,因此两者必须前后同步变更;
  2. 所以在移动指针 j 的时候,也需要先更新当前最多字符个数,再移动。
  3. 当前滑动窗口中的字符总共是 i-j+1

# LeetCode 1438. 绝对差不超过限制的最长连续子数组

给你一个整数数组 nums ,和一个表示限制的整数 limit,请你返回最长连续子数组的长度,该子数组中的任意两个元素之间的绝对差必须小于或者等于 limit 。

如果不存在满足条件的子数组,则返回 0 。

示例 1:
输入:nums = [8,2,4,7], limit = 4
输出:2
解释:所有子数组如下:
[8] 最大绝对差 |8-8| = 0 <= 4.
[8,2] 最大绝对差 |8-2| = 6 > 4.
[8,2,4] 最大绝对差 |8-2| = 6 > 4.
[8,2,4,7] 最大绝对差 |8-2| = 6 > 4.
[2] 最大绝对差 |2-2| = 0 <= 4.
[2,4] 最大绝对差 |2-4| = 2 <= 4.
[2,4,7] 最大绝对差 |2-7| = 5 > 4.
[4] 最大绝对差 |4-4| = 0 <= 4.
[4,7] 最大绝对差 |4-7| = 3 <= 4.
[7] 最大绝对差 |7-7| = 0 <= 4.
因此,满足题意的最长子数组的长度为 2 。

# 思路及实现

本题有一个比较重要的概念:最大绝对差的含义是区间内最大值和最小值的差值

我们的最终目标是:找到最大绝对差不大于 limit 的最大区间长度

那么这题如何使用滑动窗口解决呢?

我们可以用滑动窗口,来保证窗口内的所有元素 最大绝对值差 不大于 limit。只要大于 limit,就向后移动窗口。

那么如何知道窗口内的最大值和最小值呢?细心的同学可能已经发现了,这不就是第一道题(滑动窗口的最大值)吗?如果不理解,同学们可以再复习一下第一道题。

在第一道题里面,我们用一个单调队列来存储枚举到的数字,那么队列头部自然就是最大值。放到这道题里面,我们也可以这样做,只不过本题需要使用两个单调队列一个单调递减存放最大值,一个单调递增存放最小值,这样我们就能在 O (1) 时间复杂度内判断出 最大绝对差 。

# Code

int longestSubarray(vector<int>& nums, int limit) {
    deque<int> minq; // 单调递增队列,队首是最小值
    deque<int> maxq; // 单调递减队列,队首是最大值
    int res = 0;  // 存储最终结果。
    for (int i = 0, j = 0; i < nums.size(); i ++ ) {
        // 保证两个队列的单调性
        while (!minq.empty() && nums[i] < minq.back()) minq.pop_back();
        while (!maxq.empty() && nums[i] > maxq.back()) maxq.pop_back();
        // 把遍历的数字同时加入两个队列
        minq.push_back(nums[i]);
        maxq.push_back(nums[i]);
        // 当前窗口不满足条件
        while (!minq.empty() && !maxq.empty() && maxq.front() - minq.front() > limit) {
            // 窗口向右移动,不在窗口中的数字,也要从队列中踢出。
            if (nums[j] == minq.front()) minq.pop_front();
            if (nums[j] == maxq.front()) maxq.pop_front();
            j ++ ;
        }
        res = max(res, i - j + 1);  // 更新结果
    }
    return res;
}

本题有点像是前面几道题思路的融合,既用到了双指针来控制大小不固定的滑动窗口,又用到了单调队列来存储窗口中的极值。

# 总结

如果同学们能按照顺序看到现在,并且亲自做了本文提供的五道题目,那么,大家应该对 “滑动窗口” 有了一定的了解。所以我们可以通过这几道题目总结一下,什么时候可以使用滑动窗口,以及如何实现滑动窗口。

  1. 什么时候可以使用滑动窗口?

    从本文的几道题来看,它们具有一个明显的共性,既,具有连续性。这点在题目中均有提示,第一道题自不必说,第二三四题在题目中标明求子字符串,而子字符串必须是连续的,第五题的题面也说明了求连续子数组。

    并且另一个不算明显的共性是,最终目标都是求极值,也就是最大值或最小值,而不用真的求出具体的字符串或者数组。

    综上,在具有连续性的字符串或数组问题上,并且最终答案只需要求极值的问题上,我们可以优先考虑是否可以用滑动窗口。

  2. 滑动窗口应该怎么实现比较好呢?

    其实滑动窗口并没有什么固定的套路,但是从本文的几道题目来看,比较常用的方式是使用双指针来实现,并且辅以单调队列来存储极值,或者辅以哈希表来存储个数。可以粗略的参考下面的伪代码,根据题目灵活运用。

    伪代码
    res;
    q;
    h;
    for (i, j: 序列) {
        while (保证队列单调) q.pop_back();
        q.push_back(i);
        h[s[i]] ++ ;
        
        // 根据题目要求进行操作
        // 在不满足条件的情况下,移动窗口
        j ++ ;
        // 如果使用了队列,那么不在窗口中的元素要从队列头部出队。
        // 如果使用哈希表技术,那么不在窗口的元素的计数要减一 。
        // 更新结果。
        res = max(res, i - j + 1);
    }

    这里面其实还涉及到一个问题,既,窗口的大小用 i-j+1 来表示,这是因为我们是从 0 开始枚举的序列下标,例如:如果当前 i=2,j=0 ,那么当前窗口大小就是 3 ,也就是 i-j+1

END

更新于 阅读次数

请我喝[茶]~( ̄▽ ̄)~*

A Cat Without Sugar 微信支付

微信支付

A Cat Without Sugar 支付宝

支付宝