滑动窗口最大值问题:单调队列的巧妙应用

滑动窗口最大值问题:单调队列的巧妙应用

在算法面试中,滑动窗口问题是高频考点之一。今天我们要解决的是滑动窗口最大值问题,这是一个看似简单但蕴含巧妙思路的经典题目。

问题描述

给定一个整数数组 arr 和一个大小为 w 的滑动窗口,窗口从数组最左侧开始,每次向右移动一位。要求返回每次窗口滑动时窗口内的最大值集合。

示例:

  • 输入:arr = [1, 3, -1, -3, 5, 3, 6, 7], w = 3
  • 输出:[3, 3, 5, 5, 6, 7]

窗口滑动过程:

复制代码
[1, 3, -1] → max = 3
[3, -1, -3] → max = 3  
[-1, -3, 5] → max = 5
[-3, 5, 3] → max = 5
[5, 3, 6] → max = 6
[3, 6, 7] → max = 7

暴力解法分析

最直观的想法是:对于每个窗口,遍历窗口内的所有元素找到最大值。

java 复制代码
// 暴力解法(时间复杂度 O(n*w))
public int[] getMaxWindow(int[] arr, int w) {
    int[] res = new int[arr.length - w + 1];
    for (int i = 0; i <= arr.length - w; i++) {
        int max = arr[i];
        for (int j = i + 1; j < i + w; j++) {
            max = Math.max(max, arr[j]);
        }
        res[i] = max;
    }
    return res;
}

问题: 当数组很大且窗口也很大时,时间复杂度会很高(O(n×w)),效率低下。

单调队列优化思路

我们需要一种更高效的方法。观察发现:

  1. 窗口滑动时,只有两个元素发生变化:左边移出一个,右边移入一个
  2. 我们只关心最大值,不需要知道其他元素的具体值
  3. 如果一个元素比后面的所有元素都小,那它永远不可能成为最大值

基于这些观察,我们可以使用单调递减队列来优化。

什么是单调队列?

单调队列是一个特殊的双端队列,其中的元素按照特定顺序排列:

  • 单调递减队列:队首到队尾,元素值递减
  • 队首始终是当前窗口的最大值

核心思想

我们不在队列中存储元素的值,而是存储元素的索引。这样既能获取值,又能判断索引是否还在窗口范围内。

算法步骤详解

让我们一步步分析算法的执行过程:

步骤1:维护单调性

当新元素 arr[r] 进入窗口时,从队尾开始移除所有小于等于 arr[r] 的元素:

java 复制代码
while(!qmax.isEmpty() && arr[qmax.peekLast()] <= arr[r]){
    qmax.pollLast();
}
qmax.addLast(r);

为什么这样做?

  • 如果队尾元素 ≤ 新元素,那么队尾元素永远不会再成为最大值
  • 移除它们可以保持队列的单调递减性质

步骤2:处理窗口边界

检查队首元素是否已经移出窗口:

java 复制代码
if(qmax.peekFirst() == r - w){
    qmax.pollFirst();
}

为什么是 r - w

  • 当前窗口范围:[r - w + 1, r]
  • r - w 是窗口左边界的前一个位置
  • 如果队首索引等于 r - w,说明它已经不在窗口内了

步骤3:记录结果

当窗口大小达到 w 时,记录当前最大值:

java 复制代码
if(r >= w - 1){
    res[index++] = arr[qmax.peekFirst()];
}

完整代码实现

java 复制代码
package com.fjd.windows;

import java.util.LinkedList;

/**
 * 找出每次滑动窗口中的最大值集合
 * 假设一个固定大小为W的窗口,依次划过arr
 * 返回每一次画出状况的最大值
 */
public class SlidingWindowMaxArray {

    public static int[] getMaxWindow(int[] arr, int w) {
        // 边界条件检查
        if (arr == null || w < 1 || arr.length < w) {
            return null;
        }

        LinkedList<Integer> qmax = new LinkedList<>();
        int[] res = new int[arr.length - w + 1]; // 结果集
        int index = 0;

        for (int r = 0; r < arr.length; r++) {
            // 步骤1:维护单调递减队列
            while (!qmax.isEmpty() && arr[qmax.peekLast()] <= arr[r]) {
                qmax.pollLast();
            }
            qmax.addLast(r);

            // 步骤2:移除超出窗口范围的索引
            if (qmax.peekFirst() == r - w) {
                qmax.pollFirst();
            }

            // 步骤3:当窗口大小达到w时,记录结果
            if (r >= w - 1) {
                res[index++] = arr[qmax.peekFirst()];
            }
        }

        return res;
    }
}

算法复杂度分析

  • 时间复杂度:O(n)

    • 每个元素最多入队一次,出队一次
    • 总操作次数是线性的
  • 空间复杂度:O(w)

    • 队列中最多存储 w 个元素

相比暴力解法的 O(n×w),这是一个巨大的优化!

执行过程演示

arr = [1, 3, -1, -3, 5, 3, 6, 7], w = 3 为例:

r arrr qmax(索引) qmax(值) 窗口 最大值
0 1 0 1 - -
1 3 1 3 - -
2 -1 1,2 3,-1 0,2 3
3 -3 1,2,3 3,-1,-3 1,3 3
4 5 4 5 2,4 5
5 3 4,5 5,3 3,5 5
6 6 6 6 4,6 6
7 7 7 7 5,7 7

关键要点总结

  1. 存储索引而非值:便于判断元素是否在窗口内
  2. 单调递减队列:队首始终是当前窗口最大值
  3. 双端操作:队尾维护单调性,队首处理窗口边界
  4. 线性时间复杂度:每个元素最多进出队列各一次

扩展思考

这个思路还可以应用到其他滑动窗口问题:

  • 滑动窗口最小值(使用单调递增队列)
  • 滑动窗口中位数(需要更复杂的数据结构)
  • 带限制条件的滑动窗口问题

单调队列是解决滑动窗口极值问题的利器,掌握这个技巧会让你在算法面试中更加游刃有余!