滑动窗口思想的介绍与单调队列代码实现

发布于:2025-03-22 ⋅ 阅读:(25) ⋅ 点赞:(0)

活动发起人@小虚竹 

 目录

前言

滑动窗口的基本思想

举例说明 239. 滑动窗口最大值 - 力扣(LeetCode)

 暴力做法 

 滑动窗口优化

算法分析

使用例题数据进行模拟

输入

初始状态

步骤解析

前言

本篇博客我们介绍滑动窗口,这是一种特殊的双指针技巧思想,在算法竞赛和工程应用中,滑动窗口是一种用于优化暴力解法、降低时间复杂度的高效技巧,通常用于解决连续子数组、子字符串相关的问题。它通过左闭右开的窗口 [left, right),并通过移动窗口的左右边界来寻找符合条件的子数组或子字符串。

在计算机网络中,也有滑动窗口协议.

它的核心思想是: 发送方维护一个“窗口”,表示当前允许发送但尚未确认的数据报文数量;接收方也维护一个窗口,表示它能接收的最大数据量。数据在网络中传输时,每个数据包都有一个序号,窗口随着数据包的确认而“滑动”,从而实现流量控制和高效数据传输。

滑动窗口的基本思想

一个线性数据结构(数组或字符串)中,使用两个指针(或索引)来维护一个窗口,窗口在数据结构上滑动,从而避免重复计算,提高效率。

它是一种双指针技巧,适用于连续子数组/子串的计算,比如求最大值、最小值、和、乘积等

接下来,我们通过举例一道力扣的模板题来说明该技巧的优势

举例说明 239. 滑动窗口最大值 - 力扣(LeetCode)

 暴力做法 

  • 直接遍历数组,对于每个大小为k的子数组,用Math.max 求取最大值  
  • 时间复杂度 O(N * K),每次计算最大值都要遍历窗口 O(K)

代码实现:

import java.util.Arrays;

class Solution {
    public int[] maxSlidingWindow(int[] nums, int k) {
        if (nums.length == 0) return new int[0];
        
        int n = nums.length;
        int[] res = new int[n - k + 1]; // 结果数组
        for (int i = 0; i <= n - k; i++) {
            int maxVal = Integer.MIN_VALUE;
            for (int j = i; j < i + k; j++) {
                maxVal = Math.max(maxVal, nums[j]); // 计算窗口最大值
            }
            res[i] = maxVal;
        }
        return res;
    }

    public static void main(String[] args) {
        Solution solution = new Solution();
        int[] nums = {1, 3, -1, -3, 5, 3, 6, 7};
        int k = 3;
        System.out.println(Arrays.toString(solution.maxSlidingWindow(nums, k)));
    }
}

 滑动窗口优化

为了减少时间复杂度,我们可以创建一个单调队列当作滑动窗口

只需要O(n) 的复杂度,就可以得到最大值

笔者首先贴出代码,然后对它进行详细解释

import java.util.*;

class Solution {
    public int[] maxSlidingWindow(int[] nums, int k) {
        if (nums == null || nums.length == 0) return new int[0];

        int n = nums.length;
        int[] res = new int[n - k + 1]; // 结果数组
        Deque<Integer> deque = new ArrayDeque<>(); // 单调递减队列(存索引)

        for (int i = 0; i < n; i++) {
            // 1. 移除不在窗口范围的元素(左端越界)
            if (!deque.isEmpty() && deque.peekFirst() <= i - k) {
                deque.pollFirst();
            }

            // 2. 维护队列单调递减(移除比当前值小的元素)
            while (!deque.isEmpty() && nums[deque.peekLast()] <= nums[i]) {
                deque.pollLast();
            }

            // 3. 添加新元素索引
            deque.addLast(i);

            // 4. 记录窗口最大值(窗口形成后才开始存结果)
            if (i >= k - 1) {
                res[i - k + 1] = nums[deque.peekFirst()];
            }
        }

        return res;
    }

    public static void main(String[] args) {
        Solution solution = new Solution();
        int[] nums = {1, 3, -1, -3, 5, 3, 6, 7};
        int k = 3;
        System.out.println(Arrays.toString(solution.maxSlidingWindow(nums, k)));
    }
}

算法分析

  1. 初始化双端队列: 我们首先要初始化一个双端队列。队列的作用是存储当前窗口内可能成为最大元素的下标。为什么要存储下标呢?因为这样可以避免每次都访问 nums[i] 数组中的元素,而是直接通过下标来获取元素的值。另外,存储下标还能够帮助我们追踪那些已经滑出窗口的元素。

    双端队列的第一个元素就是当前窗口的最大值的下标。队列会始终保持一个递减的顺序,即队头是当前窗口内最大的元素的下标。这样当我们在滑动窗口时,队头的元素始终是我们需要的最大值。

  2. 滑动窗口的两种情况: 当我们向右滑动窗口时,会遇到两种常见情况:

    • 第一种情况:队头元素离开窗口:
      这意味着队头的元素已经不在当前滑动窗口内了,必须将其从队列中弹出。这个操作是 O(1) 的时间复杂度。此时,我们更新队列,使其只包含当前窗口内的元素。

    • 第二种情况:从队尾进入新元素:
      进入窗口的新元素需要和已经在队列中的元素进行比较大小。这一步是滑动窗口的关键。为了保证队列中的元素是递减的,我们会从队尾开始弹出队列中的元素,直到队尾的元素比当前元素小。然后将当前元素的下标加入队列中。这时,队列的队头始终是窗口内的最大元素。

      这个步骤类似单调栈的思路,因此我们需要保证队列中的元素保持递减顺序。

  3. 每次滑动都要固定添加后续元素: 在每一次滑动窗口时,我们需要进行以下操作:

    • 检查并移除不再在窗口内的元素。
    • 将新的元素索引加入队列。
    • 每次滑动窗口后,队头的元素即为当前窗口的最大值,加入结果数组。

    这一步非常关键,因为它保证了每个窗口的最大值能够有效计算,并且在时间复杂度上能够达到 O(N)。

  4. 一旦滑动窗口形成,记录最大值,什么时候形成呢?  当下标 i >= k-1  . 从此以后,窗口内的最大值可以在每次滑动时通过队头元素直接获取。每次滑动时,会有元素从队头离开,也会有新元素从队尾进入,从而更新队列的状态,维持最大值的正确性。

使用例题数据进行模拟

我们使用例题数据

nums = {1, 3, -1, -3, 5, 3, 6, 7} 和滑动窗口大小 k = 3 来进行模拟。
输入
  • 数组 nums = {1, 3, -1, -3, 5, 3, 6, 7}
  • 滑动窗口大小 k = 3
初始状态
  • 数组 nums = {1, 3, -1, -3, 5, 3, 6, 7}
  • 结果数组 res(用来存储每个窗口的最大值):[]
  • 滑动窗口大小 k = 3
  • 使用一个双端队列 deque 来维护窗口内的元素索引。
步骤解析

步骤 1:处理 nums[0] = 1

  • deque 为空,直接将当前索引 0 入队。

  • deque = [0]

步骤 2:处理 nums[1] = 3

  • nums[1] = 3 大于队列中索引为 0nums[0] = 1,所以从队列中移除 0

  • 1 入队。

  • deque = [1]

步骤 3:处理 nums[2] = -1

  • nums[2] = -1 小于队列中 nums[1] = 3,所以直接入队。

  • deque = [1, 2]

此时,滑动窗口已经形成了大小为 3 的窗口,窗口内容是 [1, 3, -1]。窗口的最大值是 3,将其加入 res

  • res = [3]

步骤 4:处理 nums[3] = -3

  • nums[3] = -3 小于队列中 nums[2] = -1,所以直接入队。

  • deque = [1, 2, 3]

此时,滑动窗口内容是 [3, -1, -3]。窗口的最大值是 3,将其加入 res

  • res = [3, 3]

步骤 5:处理 nums[4] = 5

  • nums[4] = 5 大于队列中的 nums[3] = -3,所以移除 3

  • nums[4] = 5 大于队列中的 nums[2] = -1,所以移除 2

  • nums[4] = 5 大于队列中的 nums[1] = 3,所以移除 1

  • 4 入队。

  • deque = [4]

此时,滑动窗口内容是 [-1, -3, 5]。窗口的最大值是 5,将其加入 res

  • res = [3, 3, 5]

步骤 6:处理 nums[5] = 3

  • nums[5] = 3 小于队列中的 nums[4] = 5,所以直接入队。

  • deque = [4, 5]

此时,滑动窗口内容是 [5, 3, 3]。窗口的最大值是 5,将其加入 res

  • res = [3, 3, 5, 5]

步骤 7:处理 nums[6] = 6

  • nums[6] = 6 大于队列中的 nums[5] = 3,所以移除 5

  • nums[6] = 6 大于队列中的 nums[4] = 5,所以移除 4

  • 6 入队。

  • deque = [6]

此时,滑动窗口内容是 [3, 3, 6]。窗口的最大值是 6,将其加入 res

  • res = [3, 3, 5, 5, 6]

步骤 8:处理 nums[7] = 7

  • nums[7] = 7 大于队列中的 nums[6] = 6,所以移除 6

  • 7 入队。

  • deque = [7]

此时,滑动窗口内容是 [3, 6, 7]。窗口的最大值是 7,将其加入 res

  • res = [3, 3, 5, 5, 6, 7]

最终结果

滑动窗口的最大值数组为:[3, 3, 5, 5, 6, 7]

 综上所述,我们可以用滑动窗口思想实现更低的复杂度

部分OJ题分享

笔者再分享一些OJ题,可以加深理解

第一题2841. 几乎唯一子数组的最大和 - 力扣(LeetCode)

这题也是滑动窗口思想可以解决的,只需要额外的用HashMap 算一下是否有至少m个元素

 .size() 方法 可以计算 HashMap 中键值对的数量

class Solution {
    public long maxSum(List<Integer> nums, int m, int k) {
        int n = nums.size();
        long maxSum = 0;
        long sum = 0;
        // 哈希表记录窗口内元素的频次
        HashMap<Integer,Integer> hashMap = new LinkedHashMap<>();
        // 初始化前 k 个元素的窗口
        for(int i = 0;i<k;i++)
        {
            hashMap.put(nums.get(i),hashMap.getOrDefault(nums.get(i),0)+1);
            sum+=nums.get(i);
        }
        // 检查窗口是否符合“几乎唯一”条件
        if(hashMap.size()>=m)
        {
            maxSum = sum;
        }
        // 滑动窗口,窗口右移
        for(int i = k;i<n;i++)
        {
            // 移除窗口最左边的元素
            int left = nums.get(i-k);
            hashMap.put(left,hashMap.get(left)-1);
            if(hashMap.get(left)==0)
            {
                hashMap.remove(left);
            }
            sum-=left;
            // 添加窗口右边的新元素
            int right = nums.get(i);
            hashMap.put(nums.get(i),hashMap.getOrDefault(nums.get(i),0)+1);
            sum+=right;
            // 如果当前窗口包含至少 m 个不同的元素,更新最大和
            if(hashMap.size()>=m)
            {
                maxSum = Math.max(sum,maxSum);
            }
        }
        return maxSum;
    }
}

第二题3090. 每个字符最多出现两次的最长子字符串 - 力扣(LeetCode) 

                                      

这一题虽然标签是简单,但它要求我们构建一个不定长的滑动窗口

具体思路如下

  1. 使用两个指针(leftright)来表示当前窗口的范围,left 表示窗口的左边界,right 表示窗口的右边界。
  2. 使用一个哈希表(charCount)来存储窗口内每个字符出现的次数。
  3. 当窗口内某个字符出现超过两次时,移动 left 指针,直到该字符的出现次数不超过两次。
  4. 每次更新窗口时,计算当前窗口的长度,并记录最大值。

代码实现:

import java.util.HashMap;

class Solution {
    public int maximumLengthSubstring(String s) {
        // 哈希表,用于记录当前窗口内每个字符的出现次数
        HashMap<Character, Integer> charCount = new HashMap<>();
        
        int left = 0; // 滑动窗口的左边界
        int maxLength = 0; // 最长符合条件的子字符串的长度
        
        // 遍历字符串
        for (int right = 0; right < s.length(); right++) {
            // 更新当前字符的计数
            char currentChar = s.charAt(right);
            charCount.put(currentChar, charCount.getOrDefault(currentChar, 0) + 1);
            
            // 如果当前窗口内某个字符出现超过2次,移动左指针
            while (charCount.get(currentChar) > 2) {
                char leftChar = s.charAt(left);
                charCount.put(leftChar, charCount.get(leftChar) - 1);
                left++; // 移动左边界
            }
            
            // 计算当前窗口的长度并更新最大长度
            maxLength = Math.max(maxLength, right - left + 1);
        }
        
        return maxLength; // 返回最长符合条件的子字符串的长度
    }
}

 结尾

关于滑动窗口思想和单调队列的实现就简单介绍到此,感谢能看到这里的读者!