算法每日一题 | LeetCode 239:滑动窗口最大值
📌 题目描述
给你一个整数数组 nums,有一个大小为 k 的滑动窗口从数组的最左侧移动到数组的最右侧。你只能看到在滑动窗口内的 k 个数字。滑动窗口每次只向右移动一位。
返回滑动窗口中的最大值。
示例 1:
ini
输入:nums = [1,3,-1,-3,5,3,6,7], k = 3
输出:[3,3,5,5,6,7]
解释:
滑动窗口的位置 最大值
--------------- -----
[1 3 -1] -3 5 3 6 7 3
1 [3 -1 -3] 5 3 6 7 3
1 3 [-1 -3 5] 3 6 7 5
1 3 -1 [-3 5 3] 6 7 5
1 3 -1 -3 [5 3 6] 7 6
1 3 -1 -3 5 [3 6 7] 7
示例 2:
ini
输入:nums = [1], k = 1
输出:[1]
🧠 思路分析
这题乍一看挺唬人的------窗口每滑一格,我都得在 k 个元素里找最大值。最朴素的做法就是每到一个新位置就遍历一遍窗口,时间复杂度 O(n×k),数据量一大直接 TLE。
那问题的关键就变成了:怎么在窗口滑动的过程中,快速知道当前最大值是谁?
答案是一个叫 单调队列 的数据结构。名字听着挺高级的,其实说白了就是一个双端队列(Deque),只不过里面存的元素下标对应的值是单调递减的。
为什么是单调递减?因为窗口往右滑的时候,如果一个旧元素比新来的元素小,那这个旧元素永远不可能是最大值了------新来的比它大,而且新来的在窗口里待得还比它久。所以这些"没希望了"的元素可以直接踢掉。
具体来说,这个单调队列里存的是数组下标(不是值本身),维护三件事:
- 踢过期:窗口滑走了,队头下标已经不在窗口内了,直接移除
- 踢弱者:新来的元素如果比队尾的元素大,就把队尾踢掉,循环踢,直到队尾比新来的大或者队空了
- 入队 + 取最大值:把新下标加到队尾,这时候队头就是当前窗口的最大值
举个例子,nums = [1,3,-1,-3,5,3,6,7],k = 3:
ini
i=0, nums[0]=1: 队空,直接入队。deque=[0]
i=1, nums[1]=3: 3 > nums[0]=1,踢掉0,入队。deque=[1]
i=2, nums[2]=-1: -1 < nums[1]=3,不用踢,入队。deque=[1,2]
i>=2了,窗口成形,max = nums[deque.peekFirst()] = nums[1] = 3 ✓
i=3, nums[3]=-3: -3 < nums[2]=-1,不用踢,入队。deque=[1,2,3]
max = nums[1] = 3 ✓
i=4, nums[4]=5: 先检查队头:deque.peekFirst()=1, 1 <= 4-3=1,过期了!踢掉。deque=[2,3]
然后 5 > nums[3]=-3,踢;5 > nums[2]=-1,踢。deque=[]
入队。deque=[4]
max = nums[4] = 5 ✓
i=5, nums[5]=3: 3 < nums[4]=5,不用踢,入队。deque=[4,5]
max = nums[4] = 5 ✓
i=6, nums[6]=6: 队头4 <= 6-3=3?4 > 3,没过期的。
6 > nums[5]=3,踢;6 > nums[4]=5,踢。deque=[]
入队。deque=[6]
max = nums[6] = 6 ✓
i=7, nums[7]=7: 队头6 <= 7-3=4?6 > 4,没过期。
7 > nums[6]=6,踢。deque=[]
入队。deque=[7]
max = nums[7] = 7 ✓
结果: [3, 3, 5, 5, 6, 7]
整个过程中,每个元素最多入队一次、出队一次,所以总的时间复杂度是 O(n),比暴力的 O(nk) 好太多了。
🖼️ 图解与执行流程
用 nums = [1,3,-1,-3,5,3,6,7],k = 3 来画个过程图:
ini
窗口位置 deque 内容(下标) 队头=最大值 输出
─────────────────────────────────────────────────────────────
[1 3 -1] [1, 2] nums[1]=3 3
1 [3 -1 -3] [1, 2, 3] nums[1]=3 3
1 3 [-1 -3 5] [4] nums[4]=5 5
1 3 -1 [-3 5 3] [4, 5] nums[4]=5 5
1 3 -1 -3 [5 3 6] [6] nums[6]=6 6
1 3 -1 -3 5 [3 6 7] [7] nums[7]=7 7
结果: [3, 3, 5, 5, 6, 7]
队列里的下标对应的值永远是从大到小排列的------这就是"单调递减队列"的含义。队头永远是当前窗口的老大(最大值)。
💻 核心代码实现
java
class Solution {
public int[] maxSlidingWindow(int[] nums, int k) {
int length = nums.length;
// 结果数组大小 = 数组长度 - 窗口大小 + 1
int[] res = new int[length - k + 1];
// 双端队列,存的是数组下标,不是值
Deque<Integer> deque = new LinkedList<>();
for (int i = 0; i < length; i++) {
// 第一步:踢掉过期的队头
// 如果队头下标已经不在当前窗口 [i-k+1, i] 范围内了,就移除它
if (!deque.isEmpty() && deque.peekFirst() <= i - k) {
deque.pollFirst();
}
// 第二步:踢掉所有比当前元素小的队尾元素
// 维护单调递减性质:新来的比队尾大,队尾就永远不可能当最大值了
while (!deque.isEmpty() && nums[deque.peekLast()] <= nums[i]) {
deque.pollLast();
}
// 第三步:把当前下标加入队尾
deque.offerLast(i);
// 第四步:当窗口完全成形(i >= k-1),记录当前窗口的最大值
// 队头下标对应的值就是最大值
if (i >= k - 1) {
res[i - k + 1] = nums[deque.peekFirst()];
}
}
return res;
}
}
代码结构很清晰------循环里就四步:踢过期、踢弱者、入队、取最大值。其中"踢弱者"用的是 while 循环,因为新来的元素可能比队列里好几个都大,得一直踢到队尾比它大为止。
复杂度分析
- 时间复杂度 : O(n),虽然有内外两层循环,但每个下标最多入队一次、出队一次,摊下来是线性的
- 空间复杂度 : O(k),队列里最多同时存 k 个下标(窗口大小)
⚠️ 踩坑记录
- 队列里存下标,不是存值 :这点非常关键。存值的话你没法判断元素是不是过期了(滑出窗口了)。只有存下标,才能通过
下标 <= i - k来判断是否该踢 - 过期检查是
<= i - k不是< i - k:窗口左边界是i - k + 1,所以下标<= i - k的就已经在窗口外面了。这里差一个 1 就会出 bug - 踢弱者用的是
<=不是<:nums[deque.peekLast()] <= nums[i]这里等于号也要踢。因为相等的时候,新来的在窗口里待得更久,旧的没用了 - 取最大值的时机 :只有当
i >= k - 1的时候窗口才完整,之前窗口还没凑够 k 个元素,不要往结果里写