【滑动窗口入门篇】

滑动窗口入门篇
本篇提供: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)

结语

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

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

结束...

相关推荐
卡尔特斯30 分钟前
Android Kotlin 项目代理配置【详细步骤(可选)】
android·java·kotlin
白鲸开源31 分钟前
Ubuntu 22 下 DolphinScheduler 3.x 伪集群部署实录
java·ubuntu·开源
ytadpole39 分钟前
Java 25 新特性 更简洁、更高效、更现代
java·后端
纪莫1 小时前
A公司一面:类加载的过程是怎么样的? 双亲委派的优点和缺点? 产生fullGC的情况有哪些? spring的动态代理有哪些?区别是什么? 如何排查CPU使用率过高?
java·java面试⑧股
JavaGuide2 小时前
JDK 25(长期支持版) 发布,新特性解读!
java·后端
用户3721574261352 小时前
Java 轻松批量替换 Word 文档文字内容
java
白鲸开源2 小时前
教你数分钟内创建并运行一个 DolphinScheduler Workflow!
java
CoovallyAIHub2 小时前
中科大DSAI Lab团队多篇论文入选ICCV 2025,推动三维视觉与泛化感知技术突破
深度学习·算法·计算机视觉
Java中文社群3 小时前
有点意思!Java8后最有用新特性排行榜!
java·后端·面试
代码匠心3 小时前
从零开始学Flink:数据源
java·大数据·后端·flink