滑动窗口算法技巧

大家好,我是 方圆。在我刷了一些滑动窗口相关的题目之后,发现很有技巧性,只要掌握了解题思路,就会很简单,所以我决定用这篇帖子记录一下,也帮助同样在刷滑动窗口相关题目的同学。

使用滑动窗口解决的问题一般会有 连续子数组子串 关键信息,我将滑动窗口题目分成两大类:固定窗口动态窗口 问题。前者比较简单,循环维护满足规定大小的窗口即可;后者还能进行细分,分为最大窗口问题和最小窗口问题。最大窗口需要在满足条件的情况下尽可能地扩大 right 指针的值,让窗口尽可能大,最小窗口则是在满足条件的基础上,不断地缩小窗口,让窗口尽可能的小。以上我认为就能将所有滑动窗口问题全部涵盖在内了,大家可以根据自己感兴趣的部分选择性阅读,读完之后推荐做一下相关题目来加深对滑动窗口的理解。如果大家想要找刷题路线的话,可以参考 Github: LeetCode。

固定窗口

在滑动窗口类型的题目中,固定窗口大小的问题是最简单的,我认为它遵循着下面这种模式:

int left = 0, right = 0;
while (right < nums.length) {
    // 相关变量赋值操作

    // 如果在符合窗口大小的情况下进行题解判断
    if (right - left + 1 == windowLength) {
        // 判断是否符合题解,符合条件则记录答案

        // 窗口缩小,将 left 指针处的元素移除
        left++;
    } 

    // 窗口右移
    right++;
}

窗口移动过程中不断扩大,直到满足窗口大小,进行相关逻辑处理,处理完之后把窗口 left 指针处元素移除,缩小窗口,下次再进入循环时,将再次满足指定窗口大小。

下面是一些相关的练习,大家可以做一下,掌握思路之后比较简单:


相关练习
  • 438. 找到字符串中所有字母异位词
    public List<Integer> findAnagrams(String s, String p) {
        List<Integer> res = new ArrayList<>();
        int[] mark = new int[26];
        for (char c : p.toCharArray()) {
            mark[c - 'a']++;
        }
        int needCount = p.length();

        int left = 0, right = 0;
        while (right < s.length()) {
            int index = s.charAt(right) - 'a';
            if (mark[index] > 0) {
                needCount--;
            }
            mark[index]--;

            if (right - left + 1 == p.length()) {
                if (needCount == 0) {
                    res.add(left);
                }
                int leftIndex = s.charAt(left) - 'a';
                if (mark[leftIndex] >= 0) {
                    needCount++;
                }
                mark[leftIndex]++;
                left++;
            }

            right++;
        }

        return res;
    }
  • 567. 字符串的排列
    public boolean checkInclusion(String s1, String s2) {
        int[] mark = new int[26];
        char[] s1CharArray = s1.toCharArray();
        for (char c : s1CharArray) {
            mark[c - 'a']++;
        }
        int needCount = s1.length();

        int left = 0, right = 0;
        while (right < s2.length()) {
            int rightIndex = s2.charAt(right) - 'a';
            if (mark[rightIndex] > 0) {
                needCount--;
            }
            mark[rightIndex]--;

            if (right - left + 1 == s1.length()) {
                if (needCount == 0) {
                    return true;
                }
                int leftIndex = s2.charAt(left) - 'a';
                if (mark[leftIndex] >= 0) {
                    needCount++;
                }
                mark[leftIndex]++;
                left++;
            }

            right++;
        }

        return false;
    }
  • 1052. 爱生气的书店老板

这道题的技巧很有意思,我们一起看一下。根据题意可知,老板在不生气的时候,顾客是不变的,而且不生气的状态也不会变成生气,所以我们可以先把能确定的顾客累加计算起来,并把这些累加过的顾客置为零。之后我们维护一个大小为 minutes 的窗口滑动,统计在该固定窗口下能留下的顾客的最大值就是我们能够最多留下的顾客了,最后不生气下的顾客和生气时能留下的最多的顾客即为所求:

    public int maxSatisfied(int[] customers, int[] grumpy, int minutes) {
        int already = 0;
        for (int i = 0; i < customers.length; i++) {
            if (grumpy[i] == 0) {
                already += customers[i];
                customers[i] = 0;
            }
        }
        
        int max = 0;
        int sum = 0;
        int left = 0, right = 0;
        while (right < customers.length) {
            sum += customers[right];
            
            if (right - left + 1 == minutes) {
                max = Math.max(max, sum);
                sum -= customers[left++];
            }
            
            right++;
        }
        
        return already + max;
    }

动态窗口

我认为动态窗口问题可以归为两类,分别是求 最小窗口 和求 最大窗口 问题,我们先来看下解题思路的模板,注意其中的注释信息:

  • 求最小窗口的模板如下:
int left = 0, right = 0;
while(right < nums.length) {
    // 当前元素进入窗口,计算窗口相关值的变化
    doSomething();

    // 求最小窗口:在满足条件的情况下,不断地缩小窗口,即 left 右移
    while(condition && left <[=] right) {
        // 窗口缩小一次记录一次结果值
        res = ?;

        // 移除窗口左边界的值
        left++;
    }

    right++;
}
  • 求最大窗口的模板如下:
int left = 0, right = 0;
while(right < nums.length) {
    // 当前元素进入窗口,计算窗口相关值的变化
    doSomething();

    // 求最大窗口:在不满足条件的情况下,不断地缩小窗口来试图满足条件,即 left 右移
    while(condition && left <[=] right) {
        // 不断地移除窗口左边界的值,直到符合条件
        left++;
    }
    // 计算结果值
    res = ?;

    right++;
}

我们可以发现这两个模板很像,不同的是 while 循环条件和计算结果值的位置:

  • 计算最小窗口是在 满足条件的情况下不断的缩小窗口,每移动一次都需要计算一次结果,因为每次移动都比当前的窗口小;

  • 计算最大窗口时,我们需要让 right 指针尽可能地右移,右移的越多则窗口越大,只有在不满足条件时才缩小窗口,那么 while 条件需要在 不满足条件的情况下不断地缩小窗口直到条件满足,保证在 while 代码块处理完之后都是符合题意的,再计算结果值就没有问题了。

此外,需要注意 while 循环中被标记的等号条件,如果有的题目条件要求将窗口内所有元素都移除的话,则需要等号,否则不需要。

下面的题目都是结合以上模板思路来的,大家可以练习一下,我相信你会发现动态滑动窗口可以很简单。


相关练习
  • LCR 008. 长度最小的子数组

我们先来看下这道题,由题意可知我们需要在 满足条件时不断地(while) 缩小窗口,以此来得到最小的窗口,注意其中 left <= right 的等号条件,因为可能存在单个值要比 target 大的情况,所以需要添加等号,必要时将窗口内所有元素都移除:

    public int minSubArrayLen(int target, int[] nums) {
        int res = Integer.MAX_VALUE;
        int sum = 0;
        int left = 0, right = 0;
        while (right < nums.length) {
            sum += nums[right];

            while (sum >= target && left <= right) {
                res = Math.min(res, right - left + 1);
                sum -= nums[left++];
            }

            right++;
        }

        return res == Integer.MAX_VALUE ? 0 : res;
    }
  • 76. 最小覆盖子串

别看这道是困难的题目,但是逻辑和上一题基本一致,都是在满足条件的情况下,不断地(while) 缩小窗口以获取最小的窗口:

    public String minWindow(String s, String t) {
        String res = "";
        int[] mark = new int[128];
        for (char c : t.toCharArray()) {
            mark[c]++;
        }
        int needCount = t.length();

        int left = 0, right = 0;
        while (right < s.length()) {
            if (mark[s.charAt(right)] > 0) {
                needCount--;
            }
            mark[s.charAt(right)]--;

            while (needCount == 0 && left <= right) {
                if ("".equals(res) || right - left + 1 < res.length()) {
                    res = s.substring(left, right + 1);
                }
                if (mark[s.charAt(left)] >= 0) {
                    needCount++;
                }
                mark[s.charAt(left++)]++;
            }

            right++;
        }

        return res;
    }
  • 713. 乘积小于 K 的子数组

  • 992. K 个不同整数的子数组

713 题目和 992 题目基本相同,我们以 992 为例,来讲解一下这道题的思路,明白了其中的要点 713 题目也会迎刃而解。

首先我们需要了解:当前区间子数组数量为区间元素个数,如下图所示:

滑动窗口算法技巧_第1张图片

我们接着看:992 题目要求统计数组中不同整数为 k 的子数组数目,我们以 nums = [1, 2, 1, 2, 3], k = 2 为例,符合条件的数组为:[1, 2], [1, 2, 1], [1, 2, 1, 2], [2, 1], [2, 1, 2], [1, 2], [2, 3]。在统计 [1, 2] 区间的子数组时,采用上述方法计算子数组数量为 2,我们需要去掉其中不符合条件的子数组 [1],计数值为 1;在统计 [1, 2, 1] 区间时,子数组计数为 3,其中 [1, 2, 1] 和 [2, 1] 符合条件,而 [1] 不符合条件,需要去掉,所以计数值为 2;[1, 2, 1, 2] 区间同理,不再赘述。我们可以发现某个符合条件的区间,采用上述计数算法时会将不符合条件的区间也包含进来,例如区间 [1, 2] 它的子数组为 2,而符合 k = 2 条件的子数组数为 1,我们需要将 k = 1 的区间删掉。解题思路能确定下来了:即把符合条件 k 的区间计数下来,然后将符合条件 k - 1 的区间数量减去。

但是,我们还是拿区间 [1, 2] 看一下,如果我们统计 k = 1 的所有区间,发现它的数量为 2,即 [1] 和 [2],而区间 [1, 2] 的子数组数量也为 2,此时作差得到的结果为 0,而不是 1,这是哪里出了问题呢?我们看一下统计的 k = 1 的所有区间,[2] 是包含在 [1, 2] 的子数组中需要被去掉的没有问题,但是我们统计的 [1] 可没有包含在 [1, 2] 的子数组中,这样我们相当于多去掉了一个数量,导致我们结果不对,所以我想到了两个办法解决:

  1. 将 1 ~ k 所有符合条件的区间数量全部统计出来,再将 1 ~ k - 1 所有符合条件的区间全部统计出来,用前者减去后者即为所求,拿 [1, 2] 为例,统计 k = 1 和 k = 2 的子数组数量为 3,k = 1 的子数组数量为 2,作差即为答案

  2. 当找到符合条件 k 的区间时,统计子数组数量,并将其中子数组符合 1 ~ k - 1 条件的子数组移除,这也能得到我们想要的答案,大家感兴趣可以试一下

题解是根据方法 1 写出来的,如下:

    public int subarraysWithKDistinct(int[] nums, int k) {
        return doSubarraysWithKDistinct(nums, k) - doSubarraysWithKDistinct(nums, k - 1);
    }


    private int doSubarraysWithKDistinct(int[] nums, int k) {
        int res = 0;
        HashMap<Integer, Integer> numCounts = new HashMap<>();
        int left = 0, right = 0;
        while (right < nums.length) {
            numCounts.put(nums[right], numCounts.getOrDefault(nums[right], 0) + 1);

            while (numCounts.size() > k && left <= right) {
                Integer count = numCounts.get(nums[left]);
                if (count == 1) {
                    numCounts.remove(nums[left]);
                } else {
                    numCounts.put(nums[left], --count);
                }
                left++;
            }

            res += right - left + 1;
            right++;
        }

        return res;
    }
  • 3. 无重复字符的最长子串

这道题要找最长子串,看到最长那么我们肯定是需要在符合条件的情况下疯狂地(while)把 right 指针向右滑动,在不符合条件地情况下不断地删除 left 指针处的字符直到符合条件为止,注意其中 left < right 的条件,我们没加等号,因为单个字符是一定不会重复的:

    public int lengthOfLongestSubstring(String s) {
        int res = 0;
        HashSet<Character> mark = new HashSet<>();
        int left = 0, right = 0;
        while (right < s.length()) {
            while (mark.contains(s.charAt(right)) && left < right) {
                mark.remove(s.charAt(left++));
            }
            mark.add(s.charAt(right));
            res = Math.max(res, right - left + 1);

            right++;
        }

        return res;
    }
  • 1695. 删除子数组的最大得分

本题和上一题思路一致,上一题会了这题根本不可能做不出来!!!

    public int maximumUniqueSubarray(int[] nums) {
        HashSet<Integer> mark = new HashSet<>();
        int res = 0;
        int sum = 0;
        int left = 0, right = 0;
        while (right < nums.length) {
            sum += nums[right];
            while (mark.contains(nums[right]) && left < right) {
                sum -= nums[left];
                mark.remove(nums[left++]);
            }
            mark.add(nums[right]);
            res = Math.max(res, sum);

            right++;
        }

        return res;
    }
  • 904. 水果成篮

本题是需要满足的条件是最大的窗口大小不能超过 2,超过 2 时不断地将窗口左侧的水果种类移除即可:

    public int totalFruit(int[] fruits) {
        int res = 0;
        HashMap<Integer, Integer> fruitTypeNum = new HashMap<>();
        int left = 0, right = 0;
        while (right < fruits.length) {
            fruitTypeNum.put(fruits[right], fruitTypeNum.getOrDefault(fruits[right], 0) + 1);

            while (fruitTypeNum.size() > 2 && left < right) {
                Integer num = fruitTypeNum.get(fruits[left]);
                if (num == 1) {
                    fruitTypeNum.remove(fruits[left]);
                } else {
                    fruitTypeNum.put(fruits[left], --num);
                }
                left++;
            }

            res = Math.max(res, right - left + 1);
            right++;
        }

        return res;
    }
  • 1004. 最大连续1的个数 III

刷题刷到这里突然觉得简单起来了!这个也是典型的动态窗口,在不满足条件的情况下,我们需要不断地(while)缩小窗口来满足条件,注意其中的 left <= right,因为 k 可能为 0,必要时需要将窗口内的单个元素 0 在窗口中移除:

    public int longestOnes(int[] nums, int k) {
        int res = 0;
        int left = 0, right = 0;
        while (right < nums.length) {
            if (nums[right] == 0) {
                k--;
            }

            while (k < 0 && left <= right) {
                if (nums[left] == 0) {
                    k++;
                }
                left++;
            }
            res = Math.max(res, right - left + 1);

            right++;
        }

        return res;
    }
  • 1208. 尽可能使字符串相等

本题和上一题基本一致,依然是在不满足条件的情况下不断地(while)缩小窗口,在条件中添加了等于号,因为如果单个元素开销过大也需要被移除:

    public int equalSubstring(String s, String t, int maxCost) {
        int res = 0;
        int left = 0, right = 0;
        while (right < s.length()) {
            maxCost -= Math.abs(s.charAt(right) - t.charAt(right));

            while (maxCost < 0 && left <= right) {
                maxCost += Math.abs(s.charAt(left) - t.charAt(left));
                left++;
            }
            res = Math.max(res, right - left + 1);

            right++;
        }

        return res;
    }
  • 424. 替换后的最长重复字符

这道题虽然和其他求最大窗口题目的思路一致,但是窗口缩小的条件并不是特别容易想出来,我们一起看一下:每次窗口滑动都记录当前窗口内最大的字符数量,如果 当前窗口长度 - 最大字符数量 > 能够替换的字符数 的话,那么我们就需要不断地缩小窗口。

虽然我们记录了当前窗口内的最大字符数量,并没有记录具体哪个字符的数量是最多的,缩小窗口的时候只是简单的将窗口 left 指针左移了而已,那有同学会问了,如果 left 指针处的字符正好是字符数量最大的字符怎么办,这个时候我们缩小它,执行替换的次数应该不变呀?确实,但是有一个问题需要思考一下:字符的顺序需要被考虑进来吗?我们现在已经记录了当前窗口内数量最多的字符数,那么我们每将窗口缩小一次,就相当于将非最大数量的字符移除,当窗口大小和窗口内最多的字符数相等时,就相当于是把所有非最大数量字符全部移除了,也就是没有发生任何字符替换的情况,这样我们每次都是维护当前窗口内的最大字符数,通过缩小窗口大小表示将其他字符移除,来达到满足条件的窗口,这样再计算结果值,大家再想一想,是不是呢:

    public int characterReplacement(String s, int k) {
        int res = 0;
        int maxCount = 0;
        int[] mark = new int[26];
        int left = 0, right = 0;
        while (right < s.length()) {
            int index = s.charAt(right) - 'A';
            mark[index]++;
            maxCount = Math.max(maxCount, mark[index]);

            while (right - left + 1 - maxCount > k) {
                mark[s.charAt(left++) - 'A']--;
            }
            res = Math.max(res, right - left + 1);

            right++;
        }

        return res;
    }
  • 2024. 考试的最大困扰度

与上一题思路一致,不再赘述:

    public int maxConsecutiveAnswers(String answerKey, int k) {
        int res = 0;
        int tCount = 0, fCount = 0;
        int left = 0, right = 0;
        while (right < answerKey.length()) {
            if (answerKey.charAt(right) == 'T') {
                tCount++;
            } else {
                fCount++;
            }
            int maxCount = Math.max(tCount, fCount);

            while (right - left + 1 - maxCount > k) {
                if (answerKey.charAt(left) == 'T') {
                    tCount--;
                } else {
                    fCount--;
                }
                left++;
            }
            res = Math.max(res, right - left + 1);

            right++;
        }

        return res;
    }

巨人的肩膀

  • 【深度解析】这道题和76. 最小覆盖子串的区别

  • c++ 滑动窗口,理解 right - left 【992. K 个不同整数的子数组】

  • [滑动窗口真滴简单!] 闪电五连鞭带你秒杀12道中档题 (附详情解析)

你可能感兴趣的:(算法)