滑动窗口最大值问题:单调队列的巧妙应用
在算法面试中,滑动窗口问题是高频考点之一。今天我们要解决的是滑动窗口最大值问题,这是一个看似简单但蕴含巧妙思路的经典题目。
问题描述
给定一个整数数组 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:维护单调性
当新元素 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 |
关键要点总结
- 存储索引而非值:便于判断元素是否在窗口内
- 单调递减队列:队首始终是当前窗口最大值
- 双端操作:队尾维护单调性,队首处理窗口边界
- 线性时间复杂度:每个元素最多进出队列各一次
扩展思考
这个思路还可以应用到其他滑动窗口问题:
- 滑动窗口最小值(使用单调递增队列)
- 滑动窗口中位数(需要更复杂的数据结构)
- 带限制条件的滑动窗口问题
单调队列是解决滑动窗口极值问题的利器,掌握这个技巧会让你在算法面试中更加游刃有余!