滑动窗口的题目总结

88 阅读4分钟

摘要

本文转载作者labuladong的我写了首诗,把滑动窗口算法变成了默写题

基本介绍

说起滑动窗口算法,很多读者都会头疼。这个算法技巧的思路非常简单,就是维护一个窗口,不断滑动,然后更新答案么。LeetCode 上有起码 10 道运用滑动窗口算法的题目,难度都是中等和困难。该算法的大致逻辑如下:

int left = 0, right = 0;

while (right < s.size()) {
    // 增大窗口
    window.add(s[right]);
    right++;

    while (window needs shrink) {
        // 缩小窗口
        window.remove(s[left]);
        left++;
    }
}

这个算法技巧的时间复杂度是 O(N),比字符串暴力算法要高效得多。

其实困扰大家的,不是算法的思路,而是各种细节问题。比如说如何向窗口中添加新元素,如何缩小窗口,在窗口滑动的哪个阶段更新结果。即便你明白了这些细节,也容易出 bug,找 bug 还不知道怎么找,真的挺让人心烦的。

所以今天我就写一套滑动窗口算法的代码框架,我连再哪里做输出 debug 都给你写好了,以后遇到相关的问题,你就默写出来如下框架然后改三个地方就行,还不会出 bug

/* 滑动窗口算法框架 */
void slidingWindow(string s, string t) {
    Map<Character,Integer> window = new HashMap<>();
    Map<Character,Integer> needs = new HashMap<>();
    for(char c : t.toCharArray()) {
        int count = needs.getOrDefault(c, 0);
        needs.put(c, count+1);
    }

    int left = 0, right = 0;
    int valid = 0; 
    while (right < s.size()) {
        // c 是将移入窗口的字符
        char c = s[right];
        // 增大窗口
        right++;
        // 进行窗口内数据的一系列更新
        ...

        /*** debug 输出的位置 ***/
        printf("window: [%d, %d)\n", left, right);
        /********************/

        // 判断左侧窗口是否要收缩
        while (window needs shrink) {
            // d 是将移出窗口的字符
            char d = s[left];
            // 缩小窗口
            left++;
            // 进行窗口内数据的一系列更新
            ...
        }
    }
}

其中两处...表示的更新窗口数据的地方,到时候你直接往里面填就行了

而且,这两个...处的操作分别是扩大和缩小窗口的更新操作,等会你会发现它们操作是完全对称的。

题目

76. 最小覆盖子串

题目链接:leetcode.cn/problems/mi…

image.png

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

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

PS:理论上你可以设计两端都开或者两端都闭的区间,但设计为左闭右开区间是最方便处理的。因为这样初始化left = right = 0时区间[0, 0)中没有元素,但只要让right向右移动(扩大)一位,区间[0, 1)就包含一个元素0了。如果你设置为两端都开的区间,那么让right向右移动一位后开区间(0, 1)仍然没有元素;如果你设置为两端都闭的区间,那么初始区间[0, 0]就包含了一个元素。这两种情况都会给边界处理带来不必要的麻烦。

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

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

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

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

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

初始状态:

image.png

增加right,直到窗口[left, right)包含了T中所有字符:

image.png

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

image.png

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

image.png

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

如果你能够理解上述过程,恭喜,你已经完全掌握了滑动窗口算法思想。现在我们来看看这个滑动窗口代码框架怎么用

首先,初始化windowneed两个哈希表,记录窗口中的字符和需要凑齐的字符:

Map<Character,Integer> window = new HashMap<>();
Map<Character,Integer> needs = new HashMap<>();
for(char c : t.toCharArray()) {
    int count = needs.getOrDefault(c, 0);
    needs.put(c, count+1);
}

然后,使用leftright变量初始化窗口的两端,不要忘了,区间[left, right)是左闭右开的,所以初始情况下窗口没有包含任何元素:

int left = 0, right = 0;
int valid = 0while (right < s.size()) {
    // 开始滑动
}

其中valid变量表示窗口中满足need条件的字符个数,如果validneed.size的大小相同,则说明窗口已满足条件,已经完全覆盖了串T

现在开始套模板,只需要思考以下四个问题

1、当移动right扩大窗口,即加入字符时,应该更新哪些数据?

2、什么条件下,窗口应该暂停扩大,开始移动left缩小窗口?

3、当移动left缩小窗口,即移出字符时,应该更新哪些数据?

4、我们要的结果应该在扩大窗口时还是缩小窗口时进行更新?

如果一个字符进入窗口,应该增加window计数器;如果一个字符将移出窗口的时候,应该减少window计数器;当valid满足need时应该收缩窗口;应该在收缩窗口的时候更新最终结果。

代码如下:

class Solution {
    public static String minWindow(String s, String t) {
        Map<Character,Integer> window = new HashMap<>();
        Map<Character,Integer> needs = new HashMap<>();
        for(char c : t.toCharArray()) {
            int count = needs.getOrDefault(c, 0);
            needs.put(c, count+1);
        }
        int left = 0, right = 0;
        int valid = 0;
        //记录最小覆盖字串的起始索引以及长度
        int start = 0, len = Integer.MAX_VALUE;
        while(right < s.length()) {
            //c是将移入窗口的字符
            char c = s.charAt(right);
            //扩大窗口,[left,right),窗口左闭右开
            right++;
            if(needs.containsKey(c)) {
                int count = window.getOrDefault(c, 0);
                window.put(c, count+1);
                if(window.get(c).equals(needs.get(c))) {
                    valid++;
                }
            }
            //判断左侧窗口是否要收缩
            while(valid == needs.values().size()) {
                //在这里更新最小的覆盖字串
                if(right - left < len) {
                    start = left;
                    len = right-left;
                }
                //d是将移出窗口的字符
                char d = s.charAt(left);
                //缩小窗口
                left++;
                //进行窗口内数据的一系列更新
                if(needs.containsKey(d)) {
                    if(window.get(d).equals(needs.get(d))) {
                        valid--;
                    }
                    window.put(d, window.get(d)-1);
                }
            }
        }
        //返回最小覆盖字串
        return len == Integer.MAX_VALUE ? "" : s.substring(start, start + len);
    }     
} 

567. 字符串的排列

题目链接:leetcode.cn/problems/pe…

image.png 注意哦,输入的s1是可以包含重复字符的,所以这个题难度不小。

这种题目,是明显的滑动窗口算法,相当给你一个S和一个T,请问你S中是否存在一个子串,包含T中所有字符且不包含其他字符

首先,先复制粘贴之前的算法框架代码,然后明确刚才提出的 4 个问题,即可写出这道题的答案:

class Solution {
    public boolean checkInclusion(String s1, String s2) {
        Map<Character,Integer> window = new HashMap<>();
        Map<Character,Integer> needs = new HashMap<>();
        for(char c : s1.toCharArray()) {
            int count = needs.getOrDefault(c, 0);
            needs.put(c, count+1);
        }
        int left = 0, right = 0;
        int valid = 0;
        while(right < s2.length()) {
            char c = s2.charAt(right);
            right++;
            if(needs.containsKey(c)) {
                int count = window.getOrDefault(c, 0);
                window.put(c, count+1);
                if(window.get(c).equals(needs.get(c))) {
                    valid++;
                }
            }
            //判断左侧窗口是否要收缩,这里if或者while都可,这里>=或者==都可以
            while(right-left >= s1.length()) {
                if(valid == needs.values().size()) {
                    return true;     
                }
                char d = s2.charAt(left);
                left++;
                if(needs.containsKey(d)) {
                    if(window.get(d).equals(needs.get(d))) {
                        valid--;
                    }
                    window.put(d, window.get(d)-1);
                }
            }
        }
        return false;
    }
}

对于这道题的解法代码,基本上和最小覆盖子串一模一样,只需要改变两个地方:

1、本题移动left缩小窗口的时机是窗口大小大于s1.length()时,应为排列嘛,显然长度应该是一样的。

2、当发现valid == needs.values().size()时,就说明窗口中就是一个合法的排列,所以立即返回true

至于如何处理窗口的扩大和缩小,和最小覆盖子串完全相同。

438. 找到字符串中所有字母异位词

题目链接: leetcode.cn/problems/fi…

image.png

呵呵,这个所谓的字母异位词,不就是排列吗,搞个高端的说法就能糊弄人了吗?相当于,输入一个串S,一个串T,找到S中所有T的排列,返回它们的起始索引

直接默写一下框架,明确刚才讲的 4 个问题,即可秒杀这道题:

代码如下:

class Solution {
    public List<Integer> findAnagrams(String s, String p) {
        List<Integer> res = new ArrayList<>();
        Map<Character,Integer> window = new HashMap<>();
        Map<Character,Integer> needs = new HashMap<>();
        for(char c : p.toCharArray()) {
            int count = needs.getOrDefault(c, 0);
            needs.put(c, count+1);
        }
        int left = 0, right = 0;
        int valid = 0;
        while(right < s.length()) {
            //c是将移入窗口的字符
            char c = s.charAt(right);
            //扩大窗口,[left,right),窗口左闭右开
            right++;
            if(needs.containsKey(c)) {
                int count = window.getOrDefault(c, 0);
                window.put(c, count+1);
                if(window.get(c).equals(needs.get(c))) {
                    valid++;
                }
            }
            //判断左侧窗口是否要收缩,这里if或者while都可,这里>=或者==都可以
            while(right-left >= p.length()) {
                //在这里更新最小的覆盖字串
                if(valid == needs.values().size()) {
                    res.add(left);     
                }
                //d是将移出窗口的字符
                char d = s.charAt(left);
                //缩小窗口
                left++;
                //进行窗口内数据的一系列更新
                if(needs.containsKey(d)) {
                    if(window.get(d).equals(needs.get(d))) {
                        valid--;
                    }
                    window.put(d, window.get(d)-1);
                }
            }
        }
        return res;
    }
}

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

题目链接:leetcode.cn/problems/lo…

image.png

这个题终于有了点新意,不是一套框架就出答案,不过反而更简单了,稍微改一改框架就行了,代码如下:

class Solution {
    public int lengthOfLongestSubstring(String s) {
        Map<Character,Integer> window = new HashMap<>();
        int res = 0;
        int left = 0, right = 0;
        while(right < s.length()) {
            //c是将移入窗口的字符
            char c = s.charAt(right);
            //扩大窗口,[left,right),窗口左闭右开
            right++;
            //进行窗口内一系列更新
            int count = window.getOrDefault(c, 0);
            window.put(c, count+1);
             
            //判断左侧窗口是否要收缩
            while(window.get(c) > 1) {
                //d是将移出窗口的字符
                char d = s.charAt(left);
                //缩小窗口
                left++;
                //进行窗口内数据的一系列更新
                window.put(d, window.get(d)-1); 
            }
            res = Math.max(res, right-left);
        }
        return res;
    }
}