滑动窗口算法精讲(Sliding Window Algorithm)

文章目录

  • 滑动窗口算法精讲(Sliding Window Algorithm)
    • 简介
    • 步骤及算法模板
      • 模板1
      • 模板2
    • leetcode例题讲解
      • 入门级
        • 209. 长度最小的子数组
          • 思路:
          • 代码实现
        • 219. 存在重复元素 II
          • 思路
          • 代码实现
        • 220. 存在重复元素 III
          • 思路
          • 代码实现
      • 进阶级
        • 395. 至少有 K 个重复字符的最长子串
          • 思路
          • 代码实现
      • 经典题目
        • 438. 找到字符串中所有字母异位词 (一题三解)
          • 思路1:
          • 代码实现1
          • 思路2
          • 代码实现2
          • 思路3
          • 代码实现3
      • 其它练习题目

滑动窗口算法精讲(Sliding Window Algorithm)

简介

滑动窗口算法的本质是双指针法中的左右指针法,所谓滑动窗口,就像描述的那样,可以理解成是一个会滑动的窗口,每次记录下窗口的状态,再找出符合条件的适合的窗口。它可以将双层嵌套的循环问题,转换为单层遍历的循环问题。使用两个指针一左一右构成一个窗口,就可以将二维循环的问题转化成一维循环一次遍历,相当于通过旧有的计算结果对搜索空间进行剪枝,使时间复杂度从O(n²)降低至O(n),比如经典字符串查找算法Rabin-Karp 指纹字符串查找算法,它本质上也使用了滑动窗口的思想,通过公式推导降低窗口滑动时计算子串哈希值的复杂度。

滑动窗口算法更多的是一种思想或技巧,按照窗口大小是否固定分为固定滑动窗口变长滑动窗口,可以用来解决一些查找满足一定条件连续区间性质(长度等)的问题。往往类似于“请找到满足xx的最x的区间(子串、子数组等)的xx”这类问题都可以使用该方法进行解决。

步骤及算法模板

以下思路是比较形象的滑动窗口问题的解题步骤,但有些题目找到窗口限定比较隐晦,需要具体问题具体分析:

(1)初始化窗口
初始化左右边界 left = right = 0,把索引闭区间 [left, right] 称为一个「窗口」。

(2)寻找可行解
我们先不断地增加 right 指针扩大窗口 [left, right],直到窗口中的满足可行解。

(3)优化可行解
此时,我们停止增加 right,转而不断增加 left 指针缩小窗口 [left, right],直到窗口中的可行解不再符合要求。同时,每次增加 left,我们都要更新一轮结果。

(4)滑动窗口,直至一次遍历结束:
重复第 2 和第 3 步,直到 right 到达到的尽头。

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

模板1

public void slideWindowTemplate(String nums){
    int l = 0, r = 0;        //[初始化窗口]
    //codes...               [其他初始化信息,定义一些维护数据的数据结构]
    while(r < nums.length){
        //codes.....         [维护窗口中的数据] 
        while(l < r && check(xxx) == false){   //[窗口不满足某种性质]
            //codes...       [维护窗口中的数据] 
            l++;             //[缩小窗口]
        }
        //codes..            [更新结果]
        r++;                 //[增大窗口]
    }
}

模板2

public void slideWindowTemplate(String nums){
    //codes...               [其他初始化信息,定义一些维护数据的数据结构]
    for(int l = 0, r = 0; r < nums.length; r++){
        //codes.....         [维护窗口中的数据] 
        while(l < r && check(xxx) == false){   //[窗口不满足某种性质]
            //codes...       [维护窗口中的数据] 
            l++;             //[缩小窗口]
        }
        //codes..            [更新结果]
    }
}

模板1和模板2算法本质是一样的,只是实现不同,读者可根据爱好来选择。

leetcode例题讲解

例题分为三种级别,一是入门级题目,二是进阶题目,三是经典题目分别挑选一道题进行讲解。

入门级

209. 长度最小的子数组

给定一个含有 n 个正整数的数组和一个正整数 target 。

找出该数组中满足其和 ≥ target 的长度最小的 连续子数组 [ n u m s l , n u m s l + 1 , . . . , n u m s r − 1 , n u m s r ] [nums_l, nums_{l+1}, ..., nums_{r-1}, nums_r] [numsl,numsl+1,...,numsr1,numsr],并返回其长度。如果不存在符合条件的子数组,返回 0 。

示例 1:

输入:target = 7, nums = [2,3,1,2,4,3]
输出:2
解释:子数组 [4,3] 是该条件下的长度最小的子数组。
示例 2:

输入:target = 4, nums = [1,4,4]
输出:1
示例 3:

输入:target = 11, nums = [1,1,1,1,1,1,1,1]
输出:0

地址:209. 长度最小的子数组

思路:

本题为变长的滑动窗口,其窗口大小由target决定(窗口总和大于等于target),所以定义一个变量(窗口内总和),小于target右指针右移,大于target左指针右移,等于记录更新窗口大小即可

代码实现
package com.algorithm.num209;

/**
 * 思路:滑动窗口实现,定义一个变量(窗口内总和),
 * 小于target右指针右移,大于target左指针右移,等于记录更新窗口大小
 *
 *  @author hh
 *  @date 2022-2-3 20:10
 */
public class Solution {


    public int minSubArrayLen(int target, int[] nums) {
        int result = Integer.MAX_VALUE;
        int numsLen = nums.length;
        int winNums = 0;
        //双指针l,r
        for(int l = 0,r = 0; r < numsLen; r++){
            winNums += nums[r];
            //如果区间和大于target左指针右移
            while (winNums >= target){
                //先更新长度
                result = Math.min(result,r - l + 1);
                //缩小左窗口
                winNums -= nums[l];
                l++;
            }
        }
        return result == Integer.MAX_VALUE ? 0 : result;
    }
}

219. 存在重复元素 II

给你一个整数数组 nums 和一个整数 k ,判断数组中是否存在两个 不同的索引 i 和 j ,满足 nums[i] == nums[j] 且 abs(i - j) <= k 。如果存在,返回 true ;否则,返回 false 。

示例 1:

输入:nums = [1,2,3,1], k = 3
输出:true
示例 2:

输入:nums = [1,0,1,1], k = 1
输出:true
示例 3:

输入:nums = [1,2,3,1,2,3], k = 2
输出:false

地址:219. 存在重复元素 II

思路

滑动窗口,窗口最大为k,其中r - l <= k,把窗口中的元素保存到集合中,每次左右指针移动更新集合即可,如果集合中存在元素则返回true

代码实现
package com.algorithm.num219;

import java.util.LinkedHashSet;
import java.util.Set;

/**
 * 思路:滑动窗口,窗口最大为k,其中r - l <= k,把窗口中的元素保存到集合中,每次
 * 左右指针移动更新集合即可,如果集合中存在元素则返回true
 *
 *  @author hh
 *  @date 2022-2-3 21:18
 */
public class Solution {

    public boolean containsNearbyDuplicate(int[] nums, int k) {
        int numLength = nums.length;
        Set<Integer> set = new LinkedHashSet<>();
        for(int l = 0, r = 0; r < numLength; r++){
            //判断集合中是否存在,存在则直接返回true,没有则添加到集合中
            if(set.contains(nums[r])){
                return true;
            }
            set.add(nums[r]);
            //如果窗口大小则缩小窗口
            while (r - l >= k){
                set.remove(nums[l]);
                l++;
            }
        }
        return false;
    }
}

220. 存在重复元素 III

给你一个整数数组 nums 和两个整数 k 和 t 。请你判断是否存在 两个不同下标 i 和 j,使得 abs(nums[i] - nums[j]) <= t ,同时又满足 abs(i - j) <= k 。

如果存在则返回 true,不存在返回 false。

示例 1:

输入:nums = [1,2,3,1], k = 3, t = 0
输出:true
示例 2:

输入:nums = [1,0,1,1], k = 1, t = 2
输出:true
示例 3:

输入:nums = [1,5,9,1,5,9], k = 2, t = 3
输出:false

地址: 220. 存在重复元素 III

思路

本题与219题目非常类型,只不过条件有了变化。所以可以选取数据结构TreeSet来实现。使用TreeSet保存窗口中元素,窗口扩大时需要判断有没有大于等于nums[r] - t的元素,如果有,则返回为true,当达到窗口大小缩小窗口,并移除窗口中的元素即可

代码实现
package com.algorithm.num220;

import java.util.TreeSet;

/**
 * 思路:滑动窗口,使用TreeSet保存窗口中元素,窗口扩大时需要判断有没有大于等于nums[r] - t的元素,
 * 如果有,则返回为true,当达到窗口大小缩小窗口,并移除窗口中的元素即可
 *
 *  @author hh
 *  @date 2022-2-3 21:44
 */
public class Solution {

    public boolean containsNearbyAlmostDuplicate(int[] nums, int k, int t) {
        int numsLength = nums.length;
        TreeSet<Long> treeSet = new TreeSet<>();
        for(int l = 0,r = 0; r < numsLength; r++){
            long down = (long) nums[r] - t;
            long up = (long)nums[r] + t;
            //同时满足上下限
            Long cellingVal = treeSet.ceiling(down);
            Long floorVal = treeSet.floor(up);
            if((cellingVal != null && cellingVal <= nums[r]) || (floorVal != null && floorVal >= nums[r])){
                return true;
            }
            treeSet.add((long)nums[r]);
            //是否到达窗口的最大
            while (r - l >= k){
                treeSet.remove((long)nums[l]);
                l++;
            }
        }
        return false;
    }
}

进阶级

395. 至少有 K 个重复字符的最长子串

给你一个字符串 s 和一个整数 k ,请你找出 s 中的最长子串, 要求该子串中的每一字符出现次数都不少于 k 。返回这一子串的长度。

示例 1:

输入:s = “aaabb”, k = 3
输出:3
解释:最长子串为 “aaa” ,其中 ‘a’ 重复了 3 次。
示例 2:

输入:s = “ababbc”, k = 2
输出:5
解释:最长子串为 “ababb” ,其中 ‘a’ 重复了 2 次, ‘b’ 重复了 3 次。

地址:395. 至少有 K 个重复字符的最长子串

思路

题目说明了只包含小写字母(26 个,为有限数据),我们可以枚举最大长度所包含的字符类型数量,答案必然是 [1, 26],即最少包含 1 个字母,最多包含 26 个字母。

你会发现,当确定了长度所包含的字符种类数量时,区间重新具有了二段性质。

当我们使用双指针的时候:

  • 右端点往右移动必然会导致字符类型数量增加(或不变)

  • 左端点往右移动必然会导致字符类型数量减少(或不变)

当然,我们还需要记录有多少字符符合要求(出现次数不少于 k),当区间内所有字符都符合时更新答案。

代码实现
package com.algorithm.num395;

/**
 * 思路:滑动窗口,但本题关键是无法确定窗口的大小,由于字母仅限小写英文字母,所以我们可以枚举窗口中字母类型数量,
 * 从1到26,这样就限定了窗口的大小,从而解决问题。定义一个变量winCharTypeNums保存当前窗口的字母类型数量,当枚举
 * 字母类型数量大于winCharTypeNums时扩大窗口,等于时更新最大长度,小于时缩小窗口。保存字母数量使用数组保存,
 * 扩大或缩小时更新数组
 *
 *  @author hh
 *  @date 2022-2-4 11:16
 */
public class Solution {

    /**
     * 记录满足条件的最大长度
     */
    private int maxLength = Integer.MIN_VALUE;

    public int longestSubstring(String s, int k) {
        int strLength = s.length();
        for(int enumNum = 1; enumNum <= 26; enumNum ++){
            //定义变量
            int winCharTypeNums = 0;
            int[] frequency = new int[26];
            for(int l = 0,r = 0; r < strLength; r++){
                //判断之前数组是否记录,没有记录则winCharTypeNums加1,否则只更新数组
                int rightCharIndex = s.charAt(r) - 'a';
                if(frequency[rightCharIndex] == 0){
                    winCharTypeNums++;
                }
                frequency[rightCharIndex] ++;
                //检查更新满足条件的最大长度
                if(winCharTypeNums == enumNum){
                    check(frequency,k);
                }
                //检测是否需要缩小窗口
                while (winCharTypeNums >  enumNum){
                    //获取左边界字符,判断是否需要更新winCharTypeNums
                    int leftCharIndex = s.charAt(l) - 'a';
                    if(frequency[leftCharIndex] == 1){
                        winCharTypeNums--;
                    }
                    frequency[leftCharIndex]--;
                    l++;
                }
            }
        }
        return maxLength == Integer.MIN_VALUE ? 0 : maxLength;
    }

    private void check(int[] frequency,int k){
        int strLength = 0;
        for (int value : frequency) {
            if (value != 0 && value < k) {
                return;
            }
            strLength += value;
        }
        maxLength = Math.max(strLength,maxLength);
    }

}

经典题目

438. 找到字符串中所有字母异位词 (一题三解)

给定两个字符串 s 和 p,找到 s 中所有 p 的 异位词 的子串,返回这些子串的起始索引。不考虑答案输出的顺序。

异位词 指由相同字母重排列形成的字符串(包括相同的字符串)。

示例 1:

输入: s = “cbaebabacd”, p = “abc”
输出: [0,6]
解释:
起始索引等于 0 的子串是 “cba”, 它是 “abc” 的异位词。
起始索引等于 6 的子串是 “bac”, 它是 “abc” 的异位词。
示例 2:

输入: s = “abab”, p = “ab”
输出: [0,1,2]
解释:
起始索引等于 0 的子串是 “ab”, 它是 “ab” 的异位词。
起始索引等于 1 的子串是 “ba”, 它是 “ab” 的异位词。
起始索引等于 2 的子串是 “ab”, 它是 “ab” 的异位词。

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

思路1:

根据题目要求,我们需要在字符串 s 寻找字符串 p的异位词。因为字符串 p 的异位词的长度一定与字符串 p 的长度相同,所以我们可以在字符串 s 中构造一个长度为与字符串 p 的长度相同的滑动窗口,并在滑动中维护窗口中每种字母的数量;当窗口中每种字母的数量与字符串 p 中每种字母的数量相同时,则说明当前窗口为字符串 p 的异位词

代码实现1
package com.algorithm.num438;

import java.util.LinkedList;
import java.util.List;

/**
 * 思路:滑动窗口,窗口大小固定,每次滑动时需比较是否为异位词
 *
 *  @author hh
 *  @date 2022-2-4 17:39
 */
public class Solution {

    public List<Integer> findAnagrams(String s, String p) {
        List<Integer> result = new LinkedList<>();
        int[] pat = new int[26];
        int[] text = new int[26];
        int strLength = s.length();
        for(char c : p.toCharArray()){
            pat[c - 'a']++;
        }
        for(int l = 0,r = 0; r < strLength; r++){
            text[s.charAt(r) - 'a'] ++;
            if(l + p.length() - 1 == r && this.isAnagrams(text,pat)){
                //检测是否为异位词
                result.add(l);
            }
            while (l + p.length() - 1 <= r){
                text[s.charAt(l) - 'a']--;
                l++;
            }
        }
        return result;
    }

    private boolean isAnagrams(int[] text,int[] pat){
        for(int i = 0; i < text.length; i++){
            if(text[i] != pat[i]){
                return false;
            }
        }
        return true;
    }
}
思路2

在方法一的基础上,我们不再分别统计滑动窗口和字符串 p 中每种字母的数量,而是统计滑动窗口和字符串 p中每种类型字母的差;并引入变量differ 来记录当前窗口与字符串 p 中数量不同类型的字母的,并在滑动窗口的过程中维护它。

在判断滑动窗口中每种字母的数量与字符串 p 中每种字母的数量是否相同时,只需要判断 differ 是否为零即可。

代码实现2
package com.algorithm.num438;

import java.util.LinkedList;
import java.util.List;

/**
 *  @author hh
 *  @date 2022-2-4 21:20
 */
public class Solution2 {

    public List<Integer> findAnagrams(String s, String p) {
        List<Integer> result = new LinkedList<>();
        if(s.length() < p.length()){
            return result;
        }
        //维护窗口的差值
        int[] diffArr = new int[26];
        //diff表示字符类型不同的数量
        int diff = 0;
        //预处理
        for(int i = 0;  i< p.length();i++){
            diffArr[p.charAt(i) - 'a']++;
            diffArr[s.charAt(i) - 'a']--;
        }
        for(int i  = 0 ; i < 26; i++ ){
            if(diffArr[i] != 0){
                diff++;
            }
        }
        if(diff == 0){
            result.add(0);
        }
        for(int l = 0; l < s.length() - p.length(); l++){
            int leftIndex = s.charAt(l) - 'a';
            if(diffArr[leftIndex] == 0){
                diff++;
            }else if(diffArr[leftIndex] == -1){
                diff--;
            }
            diffArr[leftIndex]++;
            int rightIndex = s.charAt(l + p.length()) - 'a';
            if(diffArr[rightIndex] == 0){
                diff++;
            }else if(diffArr[rightIndex] == 1){
                diff--;
            }
            diffArr[rightIndex]--;
            if(diff == 0){
                result.add(l+1);
            }
        }
        return result;
    }
}
思路3

与思路1类似,本思路记录窗口中字母类型数量及词频和字符串p的字母类型数量及词频是否相等。

解法一中每次对滑动窗口的检查都不可避免需要检查两个词频数组,复杂度为 O ( C ) O(C) O(C)

事实上,我们只关心两个数组是否完全一致,因而我们能够只维护一个词频数组 cnt 来实现。

起始处理 p 串时,只对 cnt 进行词频字符自增操作。当处理 s 的滑动窗口子串时,尝试对 cnt 中的词频进行「抵消/恢复」操作:

  • 当滑动窗口的右端点右移时(增加字符),对 cnt 执行右端点字符的「抵消」操作;

  • 当滑动窗口的左端点右移时(减少字符),对 cnt 执行左端点字符的「恢复」操作。

同时,使用变量 a统计 p 中不同字符的数量,使用变量 b统计滑动窗口(子串)内有多少个字符词频与 p 相等。

当滑动窗口移动( 执行「抵消/恢复」)时,如果「抵消」后该字符词频为 0,说明本次右端点右移,多产生了一位词频相同的字符;如果「恢复」后该字符词频数量为 1,说明少了一个为词频相同的字符。当且仅当 a = b 时,我们找到了一个新的异位组。

代码实现3
package com.algorithm.num438;

import java.util.LinkedList;
import java.util.List;

/**
 *  @author hh
 *  @date 2022-2-4 21:20
 */
public class Solution3 {

    public List<Integer> findAnagrams(String s, String p) {
        List<Integer> result = new LinkedList<>();
        //维护窗口的差值
        int[] diffArr = new int[26];
        int strLength = s.length();
        int a = 0,b = 0;
        //预处理
        for(char c : p.toCharArray()){
            diffArr[c - 'a']++;
        }
        for(int i : diffArr){
            if(i > 0){
                a++;
            }
        }
        for(int l = 0,r = 0; r < strLength; r++){
            int rightIndex = s.charAt(r) - 'a';
            if(diffArr[rightIndex] == 1){
                //由不同变为相同
                b++;
            }
            diffArr[rightIndex]--;
            if(l + p.length() - 1 == r && a == b){
                //检测是否为异位词
                result.add(l);
            }
            while (l + p.length() - 1 <= r){
                int leftIndex = s.charAt(l) - 'a';
                if(diffArr [leftIndex] == 0){
                    b--;
                }
                diffArr[leftIndex]++;
                l++;
            }
        }
        return result;
    }
}

其它练习题目

请读者自行练习

  • 入门:424. 替换后的最长重复字符
  • 经典:567. 字符串的排列

你可能感兴趣的:(算法,算法,滑动窗口)