【Hot100|11-LeetCode 239. 滑动窗口最大值 】
这段代码是解决 LeetCode 239. 滑动窗口最大值 问题的经典单调队列(双端队列)解法,核心目标是在大小为k的滑动窗口从数组左端滑动到右端的过程中,高效获取每个窗口的最大值,时间复杂度优化到 O (n),空间复杂度 O (k)。下面从问题理解→核心思路→代码逐行解析→实例演示四个维度详细讲解:
一、问题理解
问题要求
给定一个整数数组 nums 和一个整数 k,滑动窗口从数组的最左侧移动到最右侧,每次只向右移动一位。请你找出所有滑动窗口中的最大值,并返回这些最大值组成的数组。
例如:输入 nums = [1,3,-1,-3,5,3,6,7],k = 3,滑动窗口的过程及最大值如下:
- 窗口
[1,3,-1]→ 最大值 3 - 窗口
[3,-1,-3]→ 最大值 3 - 窗口
[-1,-3,5]→ 最大值 5 - 窗口
[-3,5,3]→ 最大值 5 - 窗口
[5,3,6]→ 最大值 6 - 窗口
[3,6,7]→ 最大值 7输出:[3,3,5,5,6,7]
二、核心思路:单调队列维护潜在最大值
暴力解法(每次滑动窗口时遍历 k 个元素找最大值)时间复杂度为 O (nk),效率极低。该解法通过双端队列(Deque)维护一个 “单调递减队列”,仅保留窗口中 “可能成为最大值” 的元素,实现线性时间求解,核心思路:
-
单调队列的作用:队列中存储数组元素的索引(而非值),且这些索引对应的元素值从队首到队尾单调递减。这样,队首元素对应的数值就是当前窗口的最大值。
-
入队规则:当新元素(当前索引
i对应的nums[i])进入窗口时,从队尾移除所有值小于等于当前元素的索引(因为这些元素在当前元素右侧,且值更小,永远不可能成为后续窗口的最大值),再将当前索引加入队尾。 -
出队规则:当窗口滑动时,若队首元素的索引已在窗口左边界左侧(即不在当前窗口内),则从队首移除该索引。
-
记录最大值:当窗口完全形成(即
i >= k-1)后,队首元素对应的数值就是当前窗口的最大值,记录到结果数组中。
三、代码逐行解析
java
运行
import java.util.ArrayDeque;
import java.util.Deque;class Solution {public int[] maxSlidingWindow(int[] nums, int k) {int n = nums.length;// 结果数组:滑动窗口的个数为 n - k + 1(如n=8,k=3时,8-3+1=6个窗口)int[] ans = new int[n - k + 1];// 双端队列:存储元素索引,维护队列内元素值单调递减Deque<Integer> q = new ArrayDeque<>();// 遍历数组的每个元素(i为当前元素索引)for (int i = 0; i < n; i++) {// 1. 新元素从队尾入队,维护队列单调性(从队首到队尾递减)// 若队尾元素值 <= 当前元素值,移除队尾(这些元素不可能成为后续窗口最大值)while (!q.isEmpty() && nums[q.getLast()] <= nums[i]) {q.removeLast();}// 将当前元素索引加入队尾q.addLast(i);// 2. 移除窗口外的元素(队首元素若不在当前窗口内,从队首移除)int left = i - k + 1; // 当前窗口的左边界索引(闭区间)if (q.getFirst() < left) { // 队首索引 < 左边界 → 不在窗口内q.removeFirst();}// 3. 当窗口完全形成(left >= 0)时,记录当前窗口最大值(队首元素值)if (left >= 0) {ans[left] = nums[q.getFirst()];}}return ans;}
}
四、实例演示(直观理解过程)
以测试用例 nums = [1,3,-1,-3,5,3,6,7],k = 3 为例,分步演示队列变化和结果填充:
| 步骤 | i(当前索引) | nums[i] | 队列操作(维护单调性) | 队列元素(索引,对应值) | 窗口左边界 left | 队首是否在窗口内 | 记录结果(ans [left]) |
|---|---|---|---|---|---|---|---|
| 1 | 0 | 1 | 队列为空,直接入队 | [0(1)] | 0 - 3 + 1 = -2 | 无需判断(left<0) | 无(窗口未形成) |
| 2 | 1 | 3 | 队尾 0 (1) <= 3 → 移除;队列为空,入队 1 (3) | [1(3)] | 1-3+1=-1 | 无需判断 | 无 |
| 3 | 2 | -1 | 队尾 1 (3) > -1 → 直接入队 2 (-1) | [1(3), 2(-1)] | 2-3+1=0 | 队首 1 >= 0 → 在窗口内 | ans[0] = nums[1] = 3 |
| 4 | 3 | -3 | 队尾 2 (-1) > -3 → 直接入队 3 (-3) | [1(3), 2(-1), 3(-3)] | 3-3+1=1 | 队首 1 >= 1 → 在窗口内 | ans[1] = nums[1] = 3 |
| 5 | 4 | 5 | 队尾 3 (-3) <=5 → 移除;队尾 2 (-1)<=5→移除;队尾 1 (3)<=5→移除;队列为空,入队 4 (5) | [4(5)] | 4-3+1=2 | 队首 4 >= 2 → 在窗口内 | ans[2] = nums[4] =5 |
| 6 | 5 | 3 | 队尾 4 (5) > 3 → 直接入队 5 (3) | [4(5),5(3)] | 5-3+1=3 | 队首 4 >=3 → 在窗口内 | ans[3] = nums[4] =5 |
| 7 | 6 | 6 | 队尾 5 (3) <=6 → 移除;队尾 4 (5)<=6→移除;队列为空,入队 6 (6) | [6(6)] | 6-3+1=4 | 队首 6 >=4 → 在窗口内 | ans[4] = nums[6] =6 |
| 8 | 7 | 7 | 队尾 6 (6) <=7 → 移除;队列为空,入队 7 (7) | [7(7)] | 7-3+1=5 | 队首 7 >=5 → 在窗口内 | ans[5] = nums[7] =7 |
最终结果:ans = [3,3,5,5,6,7],与预期一致。
五、关键细节解析
1. 为什么队列存储索引而非值?
- 索引可以直接判断元素是否在当前窗口内(通过与左边界
left比较),而值无法做到这一点。例如:队首元素值为 3,但如果其索引小于left,说明已不在窗口内,必须移除。
2. 入队时为什么要移除 “小于等于” 当前元素的队尾元素?
- 假设队尾元素值为
x,当前元素值为y,且x <= y。由于y在x右侧(索引更大),当窗口滑动时,x会比y更早离开窗口,而y的值更大,因此x永远不可能成为后续窗口的最大值,留着只会占用空间,必须移除。 - 若
x == y:保留右侧的y(索引更大)更优,因为y能在窗口中存在更久,可能成为后续窗口的最大值。
3. 窗口何时 “完全形成”?
- 当
i >= k-1时,窗口左边界left = i - k + 1 >= 0,此时窗口包含k个元素(从left到i),可以记录最大值。例如k=3,i=2时left=0,窗口[0,2]刚好包含 3 个元素。
4. 为什么队首一定是当前窗口的最大值?
- 队列通过入队规则维护了 “从队首到队尾单调递减” 的特性,即
nums[q.getFirst()] >= nums[q.get(1)] >= ... >= nums[q.getLast()]。因此,队首元素对应的数值必然是当前窗口的最大值。
六、复杂度分析
-
时间复杂度:O(n)。数组中每个元素最多入队一次、出队一次(入队和出队操作在双端队列中是 O (1)),因此总操作次数为 O (n)。
-
空间复杂度:O(k)。队列中最多存储
k个元素(当窗口内元素严格递减时,所有元素都会被保留),因此空间复杂度为 O (k)。
七、总结
该解法的核心是 **“单调队列动态维护窗口内的潜在最大值”:通过入队时的单调性筛选(移除不可能成为最大值的元素)和出队时的边界检查(移除窗口外的元素),确保队首始终是当前窗口的最大值,从而在 O (n) 时间内高效求解。这种 “单调队列” 思路是解决滑动窗口极值问题 ** 的经典范式,可迁移到 “滑动窗口最小值”“滑动窗口内的次大值” 等类似问题中。
