【滑动窗口入门篇】

滑动窗口入门篇
本篇提供:Java语言与Go语言版本

里面的代码均可以根据标题链接到对应题目提交通过。

前置知识和滑动窗口介绍

本篇题目示例的都是简单题或者中等题。

题目:

  1. Minimum Size Subarray Sum
  2. Longest Substring Without Repeating Characters.

练习:

  1. Maximum Average Subarray I
前置知识

前置知识: 数组,字符串, 部分题需要用到哈希表处理。

滑动窗口是一种数组操作技巧。

滑动窗口介绍

利用(类似贪心)分析单调性, 找到范围和答案之间的单调性关系。

滑动窗口是一个子数组或者子串区间[left,right], 左右两个都是闭区间 , 借助这个区间+变量/某种结构(比如哈希表)可以做到维护区间信息。

滑动窗口实际上是left,right两个同向指针维护的区间, 左右指针都不回退。=>滑动窗口: 同向双指针+单调性+变量or结构维护区间信息。

滑动窗口的结构与数据结构-队列非常相似, 窗口中进窗口出窗口, 队列中入队与出队。只不过我们可以用双指针直接维护窗口, 不用真的申请一个队列容器维护(浪费空间)。
有关单调队列的部分以后会放在栈与队列模块, 它是维护滑动窗口的最大值和最小值的一种更新结构

有关滑动窗口是双指针的补充

双指针只是一个外壳, 本质是单调性的处理和实际题意处理。 双指针这个皮并不重要。

双指针是根据实际容器使用, 然后发现可以用少数指针替代容器, 进而优化空间的复杂度为 O ( 1 ) O(1) O(1)。

具体题目中的贪心分析,单调性分析, 数学分析, 实际的细节处理 是真正意义上将暴力枚举的时间优化成线性时间的。

滑动窗口实际时间复杂度 O ( n ) O(n) O(n), 它是一种线性时间完成题目的任务。

题目篇

题目1:Minimum Size Subarray Sum
分析题意

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

找出该数组中满足其总和大于等于 target 的长度最小的

子数组

[numsl, numsl+1, ..., numsr-1, numsr] ,并返回其长度。如果不存在符合条件的子数组,返回 0 。

示例1

输入:target = 7, nums = [2,3,1,2,4,3]

输出:2

解释:子数组 [4,3] 是该条件下的长度最小的子数组。

数据范围限制:

  • 1 < = t a r g e t < = 1 0 9 1 <= target <= 10^9 1<=target<=109
  • 1 < = n u m s . l e n g t h < = 1 0 5 1 <= nums.length <= 10^5 1<=nums.length<=105
  • 1 < = n u m s [ i ] < = 1 0 4 1 <= nums[i] <= 10^4 1<=nums[i]<=104

怎么思考呢?

从每个位置开始或者每个位置结尾开始枚举。

比如,依次枚举从0下标开头, 1下标开头,2下标开头...的符合要求的最短长度子数组。

或者,从n-1下标结尾,n-2下标结尾,...向前枚举, 依次找到最短长度子数组。

这里以每个位置为头开始向后枚举为例。

解法一:暴力枚举(TLE)

以示例一举例,

以0位置开头为例,[2,3,1,2]

1位置, [3,1,2,4]

2位置, [1,2,4]

3位置, [2,4,3]

4位置, [4,3]

ans初始为系统最大值Integer.MAX_VALUE

设置一个ans变量记录最小长度, 每次找到一个符合要求的子数组,更新ans。

java 复制代码
class Solution {
    public int minSubArrayLen(int target, int[] nums) {
        int n = nums.length;
        //由于本题求最小值, 初始化ans为系统最大值
        int ans = Integer.MAX_VALUE;
        //外循环枚举每一个位置开头
        for(int i=0;i<n;i++){
            int sum = 0;
            //从开头位置向下延申
            for(int j=i;j<n;j++){
                sum+=nums[j];
                if(sum>=target){
                    ans = Math.min(ans, j-i+1);
                    break;//如果发现一个解,那么一定i位置打头的最短子数组,小优化。
                }
            }
        }
        //若ans还是为最初的值,那么不存在满足要求的一个子数组。
        return ans==Integer.MAX_VALUE?0:ans;
    }
}

为什么会超时?(时间复杂度分析)

确定每个子数组的开始下标后,找到长度最小的子数组最坏下需要 O ( n ) O(n) O(n)的时间。

由于 1 < = n u m s . l e n g t h < = 1 0 5 1 <= nums.length <= 10^5 1<=nums.length<=105

时间复杂度是 O ( n 2 ) O(n^2) O(n2), Java1~2秒处理10^8的指令, 那么10^5的平方时间复杂度必然会超时...

解法二:滑动窗口(最优解)

你需要看看解法一, 然后看这个。

思考一下,我们需要每次找到一个子数组结果, 需要让它以下一个位置为头重新开始吗?

没必要!当扩展到一种结果时, 由于数组元素都是正整数, 那么这个子数组的和一定是单调递增的。如果当前子数组和大于等于target, 应该挪动左边界减小区间的子数组和。 然后根据子数组和选择是否往后扩(挪动右边界)或者挪动左边界。

0位置开头,[2,3,1,2]

可以挪动左边界吗, 发现如果挪动左边界[3,1,2],发现子数组和小于target。好的,那么不能挪动左边界,选择右边界扩展。
[2,3,1,2,4], 这个结果一定是满足条件的,但不一定是最短的。再次判断,是否挪动左边界, 发现挪动一个左边界后[3,1,2,4], 发现子数组和仍然大于等于target, 那么继续挪动左[1,2,4]。发现子数组和恰好等于target,不能继续挪动左边界了。 继续挪动右边界[1,2,4,3],重复上述过程。[4,3],此时左边界不能挪动, 右边界也不能挪动(达到数组边界了), 那么结束。

期间, 用一个变量ans记录满足条件的最小子数组长度。实时更新。

最后返回结果。
Java版本

java 复制代码
class Solution {
    public int minSubArrayLen(int target, int[] nums) {
        //初始化系统最大值
        int ans = Integer.MAX_VALUE;
        for(int l = 0, r = 0 , sum = 0;r < nums.length ; r++){
                //进窗口
                sum += nums[r];

                //判断条件出窗口
                while(sum - nums[l] >= target){
                    //sum : nums[l...r]
                    //如果nums[l]出窗口仍然满足条件, 那么出窗口
                    sum -= nums[l++];
                }

                if(sum >= target){
                    ans = Math.min(ans , r - l + 1);
                }
        }
        //ans若仍然是初始值, 那么根据题意返回0
        return ans == Integer.MAX_VALUE ? 0 : ans;
    }
}

Go版本:

go 复制代码
import "math"
func minSubArrayLen(target int, nums []int) int {
    l,r:=0,0
    //初始为int最大值
    ans := math.MaxInt
    sum := 0
    for r<len(nums){
        sum += nums[r]
        for sum-nums[l]>=target{
            //sum : nums[l...r]
            //如果nums[l]出窗口仍然满足条件, 那么出窗口
            sum -= nums[l]
            l++
        }
        if(sum >= target){
            ans = min(ans, r-l+1)
        }
        r++
    }
    if(ans == math.MaxInt){
        return 0
    }
    return ans
}
//设计min函数, Go语言没有像Java那样内置Math.min(int,int)...
func min(x,y int) int{
    if x>y{
        return y
    }
    return x
}
解法三: 前缀和&二分查找

以下解法三不属于本篇内容滑动窗口的范畴, 感兴趣或者前缀和二分过来的朋友可以参考这种写法

分析:

本题分析求和大于等于target的最小长度子数组。

子数组和不禁让人联想到前缀和, 子数组的区间和开头表示边界前缀和的差值。

构建的前缀和数组还是严格递增的(因为原数组元素是正数), 差值也是严格递增的。还可以使用二分查找。

因此,这题坚定了我们构建前缀和数组的想法。

基本流程就是先构建前缀和数组, 枚举每个位置为头的子数组区域和,然后二分查找。
O ( n ) O(n) O(n)构建前缀和数组
外层循环线性时间枚举每个位置为子数组的开头 外层循环线性时间枚举每个位置为子数组的开头 外层循环线性时间枚举每个位置为子数组的开头
内层循环 O ( l o g n ) :利用前缀和的数组的有序性快速定位到满足的最小右边界 内层循环O(logn): 利用前缀和的数组的有序性快速定位到满足的最小右边界 内层循环O(logn):利用前缀和的数组的有序性快速定位到满足的最小右边界二分查找

时间复杂度是 O ( n + n l o g n ) = > O ( n l o g n ) O(n+nlogn)=>O(nlogn) O(n+nlogn)=>O(nlogn)

空间复杂度: O ( n ) O(n) O(n), 申请前缀和数组空间提供有序性和%O(1)$的子数组区间和查询。
Java版本

java 复制代码
class Solution {
    public int minSubArrayLen(int target, int[] nums) {
        int n = nums.length;
        //下标: 第i个元素, 值:[0...i-1]的前缀和
        int[] prefix = new int[n+1];
        for(int i=1;i<=n;i++){
            prefix[i]=prefix[i-1]+nums[i-1];
        }
        //range(L,R) = prefix(R+1)-prefix()
        int ans = Integer.MAX_VALUE;
        for(int i=0;i<n;i++){
            int l=i+1,r=n;
            while(l<=r){
                int m = l+((r-l)>>1);
                //prefix[m]-prefix[i]是原数组中[i,...,m-1]子数组区间和
                if(prefix[m]-prefix[i]>=target){
                    r = m - 1;
                    //区间长度[i...m-1]=> m-1-i+1 == m-i
                    ans = Math.min(ans, m-i);
                }
                else{
                    l = m + 1;
                }
            }
        }
        return ans == Integer.MAX_VALUE?0:ans;
    }
}

Go版本

go 复制代码
func minSubArrayLen(target int, nums []int) int {
    n := len(nums)
    prefix := make([]int, n+1)
    for i:=1;i<=n;i++{
        prefix[i]=prefix[i-1]+nums[i-1]
    }
    ans := n+1//初始化为一个不可能的值, 比如总长度+1
    //穷举[0...n-1]开头的局部子数组和
    for i:=0;i<n;i++{
        l,r:=i+1,n
        for l<=r{
            m := l+((r-l)>>1)
            if prefix[m]-prefix[i]>=target{
                r = m-1
                ans = min(ans,m-i)
            }else{
                l = m+1
            }
        }
    }
    // 如果没有找到符合条件的子数组,返回 0
    if ans == n+1{
        return 0
    }
    return ans
}

func min(x,y int) int{
    if x>y{
        return y
    }
    return x
}

滑动窗口模板

滑动窗口是一种高效解决子数组或子字符串问题的技巧。

比如某些比较阴的字符串问题就可以套用模板解决。

子数组或者子串必须具有某种"单调性"。 可以用同向双指针来维护窗口的信息

  1. 初始化左右指针:

    • lr 初始化为 0,分别表示窗口的左边界和右边界。索引闭区间 [left, right] , 有些题是[L,r),取决于题目r,l扩展的位置。(比如for循环风格和while循环风格, 扩展后立即自增r还是执行循环完毕自增, 具体分析!)
  2. 扩展右边界:

    • r++ 表示右指针扩展窗口。
    • 根据需求,更新窗口内统计状态 condition(如窗口内的和、频率等)。
  3. 收缩左边界:

    • 当窗口内的状态不符合要求时,通过移动左指针 l++ 缩小窗口,直到窗口满足条件。
    • 同时更新窗口状态 condition,保持与当前窗口内容一致。
  4. 更新结果:

    • 在窗口满足条件时,计算并更新答案(如窗口长度、窗口内容的某种统计结果信息收集)。
    • 更新结果的位置根据题意分析。
  5. 结束条件:

    • 当右指针遍历完整个数组(或字符串)时结束。
练习一:Maximum Average Subarray I

给定 n 个整数,找出平均数最大且长度为 k 的连续子数组,并输出该最大平均数。

输入:[1,12,-5,-6,50,3], k = 4
输出:12.75
解释:最大平均数 (12-5-6+50)/4 = 51/4 = 12.75
  • n == nums.length
  • 1 <= k <= n <= 1 0 5 10^5 105
  • − 1 0 4 -10^4 −104 <= nums[i] <= 1 0 4 10^4 104

套用模板即可。 很明显的滑动窗口。
[1,12,-5,-6]=>[12,-5,-6,50]=>[-5,-6,50,3].

设计一个全局变量ans记录子数组的最大和。
java代码

java 复制代码
class Solution {
    // 1 <= k <= n <= 10^5
    public double findMaxAverage(int[] nums, int k) {
        int ans = Integer.MIN_VALUE;
        for(int l=0,r=0,sum=0;r<nums.length;r++){
            sum += nums[r];
            if(r-l+1==k){
                ans = Math.max(ans,sum);
                sum -= nums[l++];
            }
        }
        return ans*1.0/k;
    }
}

Go代码

go 复制代码
import "math"
func findMaxAverage(nums []int, k int) float64 {
    ans := math.MinInt
    for l,r,sum:=0,0,0;r<len(nums);r++{
        sum += nums[r]
        if(r-l+1==k){
            ans = max(ans, sum)
            sum -= nums[l]
            l++
        }
    }
    return float64(ans)/float64(k)
}

func max(x,y int) int{
    if x>=y{
        return x
    }
    return y
}
题目2:Longest Substring Without Repeating Characters.

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

子串的长度。
示例一

输入: s = "abcabcbb"

输出: 3

解释: 因为无重复字符的最长子串是 "abc",所以其长度为 3。
题目范围

  • 0 < = s . l e n g t h < = 5 ∗ 1 0 4 0 <= s.length <= 5 * 10^4 0<=s.length<=5∗104
  • s 由英文字母、数字、符号和空格组成 s 由英文字母、数字、符号和空格组成 s由英文字母、数字、符号和空格组成 ->ASCII码

你已经具备相关经验了, 那么分析这道题为什么是滑动窗口。

暴力解法的细节不再说了, 必然超时。

首先从左到右可以得出第一个无重复字符的子串abc, 长度为3。

当它再一次将a纳入时, 发现a已经出现过, 于是从子串a的下一个位置开始, 继续向右扩展。

a再一次出现时,说明前面的子串以a结尾的部分已经不满足, 直接选择abca,上次出现a的下一个字符作为出发点,bca是新的子串。

需要回退吗? 不需要! l直接跳到子串中出现重复字符的下一个位置
怎么知道字符上次出现的位置? 哈希表或者数组, 本题涉及ASCII码,显然用数组(常数时间比语言内置哈希表好)

java版本:

java 复制代码
   //leetcode测试链接:https://leetcode.cn/problems/longest-substring-without-repeating-characters/
    class Solution {
        public static int lengthOfLongestSubstring(String str) {
            if(str==null||str.equals("")){
                return 0;
            }
            char[] s = str.toCharArray();
            //i下标:字符的ASCII码-> val:该字符最近出现在字符串中的位置
            int[] last = new int[128];
            //初始化无效值-1
            Arrays.fill(last, -1);
            int ans = 0;//最长子串的长度
            for(int l=0,r=0;r<s.length;r++){
                //更新左边界
                //1. 更新左边界, 保持不变 or s[r]字符重复出现, 哈希表快速索引到重复字符的下一个位置。
                l = Math.max(l, last[s[r]]+1);
                //更新ans
                ans = Math.max(ans, r-l+1);
                last[s[r]] = r;
            }
            return ans;
        }
    }

Go版本:

go 复制代码
func lengthOfLongestSubstring(str string) int {
    if str==""{
        return 0
    }
    //i下标:字符的ASCII码-> val:该字符最近出现在字符串中的位置
    var last [128]int;
    //初始化无效值-1
    for i:=0;i<128;i++{
        last[i]=-1
    }
    ans := 0
    for l,r:=0,0;r<len(str);r++{
        //更新左边界
        //1. 更新左边界, 保持不变 or s[r]字符重复出现, 哈希表快速索引到重复字符的下一个位置
        l = max(l,last[str[r]]+1)
        //更新ans
        ans = max(ans,r-l+1)
        last[str[r]] = r
    }
    return ans
}

func max(x,y int) int{
    if x>y{
        return x
    }
    return y
}

时间复杂度分析:
O ( n ) O(n) O(n)

空间复杂度分析:
O ( 1 ) O(1) O(1)

结语

考虑篇幅有限, 只介绍了两道题目, 其余滑动窗口的题目后续更新。

切忌盲目套用模板, 一定要分析题目, 思考是否符合滑动窗口的特性。

结束...

相关推荐
qystca8 分钟前
洛谷 P1595 信封问题 C语言dp
算法
Yhame.10 分钟前
Java 集合框架中的 List、ArrayList 和 泛型 实例
java
coding侠客10 分钟前
Spring Boot 多数据源解决方案:dynamic-datasource-spring-boot-starter 的奥秘
java·spring boot·后端
委婉待续16 分钟前
java抽奖系统(八)
java·开发语言·状态模式
芳菲菲其弥章26 分钟前
数据结构经典算法总复习(下卷)
数据结构·算法
我是一只来自东方的鸭.39 分钟前
1. K11504 天平[Not so Mobile,UVa839]
数据结构·b树·算法
weixin_537590451 小时前
《Java编程入门官方教程》第八章练习答案
java·开发语言·servlet
星语心愿.1 小时前
D4——贪心练习
c++·算法·贪心算法
光头man1 小时前
【八大排序(二)】希尔排序
算法·排序算法