单调栈的“近亲”:用 O(n) 的「单调队列」征服「滑动窗口最大值」
哈喽各位,我是前端小L。
我们的单调栈之旅,已经让我们能够高效地处理“凹槽”(接雨水)和“边界”(柱状图)问题。这些问题的共同点是,我们只关心“入栈”和“出栈”(push_back, pop_back)。
但如果,我们的问题是一个固定大小的“窗口”,在数据流上滑动呢?当窗口向右移动一格时,一个新元素从右侧进入,一个旧元素从左侧离开。
-
“右侧进入,右侧弹出”——单调栈可以胜任。
-
“左侧离开”——单调栈做不到!
这,就是我们需要“双端队列 (Deque)”的原因。而“单调队列”,就是这个问题的终极答案。
力扣 239. 滑动窗口最大值
https://leetcode.cn/problems/sliding-window-maximum/

题目分析:
-
输入:一个数组
nums和一个窗口大小k。 -
过程:一个大小为
k的窗口,从数组的最左侧,一次向右移动一格,直到最右侧。 -
目标:返回一个数组,包含窗口每一步移动时的最大值。
例子: nums = [1, 3, -1, -3, 5, 3, 6, 7], k = 3
-
[1, 3, -1]-3, 5, 3, 6, 7 -> Max = 3 -
1,
[3, -1, -3]5, 3, 6, 7 -> Max = 3 -
1, 3,
[-1, -3, 5]3, 6, 7 -> Max = 5 -
1, 3, -1,
[-3, 5, 3]6, 7 -> Max = 5 -
1, 3, -1, -3,
[5, 3, 6]7 -> Max = 6 -
1, 3, -1, -3, 5,
[3, 6, 7]-> Max = 7 -
答案:
[3, 3, 5, 5, 6, 7]
思路的演进
-
朴素暴力 (O(n*k)): 遍历
n-k+1个窗口,对每个窗口都花 O(k) 时间找到最大值。总时间 O(n*k),太慢。 -
大顶堆 / 优先队列 (O(n log k)): 维护一个大小为
k的大顶堆。窗口滑动时,add(O(log k)),remove(O(k) 或 O(log k) 如果用multiset)。总时间 O(n log k) 或 O(n*k)。还是不够好。
“Aha!”时刻:单调队列的“四步舞” (O(n))
我们需要一个神奇的数据结构,它能 O(1) 地提供当前窗口的最大值,并且在窗口滑动时,能高效地 O(1)(均摊)添加和删除元素。
这个结构就是单调递减队列(存储索引): deque<int> dq; 这个队列有两条铁律:
-
队列中的索引对应的值,必须严格单调递减。
-
队列中只存储当前窗口内的索引。
算法流程(“四步舞”): 我们遍历 nums 数组,索引为 i:
第1步:(维护窗口) 移除队首的“过期”索引
-
if (!dq.empty() && dq.front() == i - k) -
dq.pop_front(); -
解释:
i-k是刚滑出窗口的那个元素的索引。如果它恰好是队首(即它是之前窗口的最大值),我们必须将它从队首移除。
第2步:(维护单调) 移除队尾的“无用”索引
-
while (!dq.empty() && nums[i] >= nums[dq.back()]) -
dq.pop_back(); -
解释:当前元素
nums[i]比队尾的元素nums[dq.back()]要大(或相等)。而nums[i]更“新”(在窗口里待得更久)。这意味着,队尾那个“又老又小”的元素,永远不可能成为未来任何窗口的最大值了(因为有nums[i]挡着)。所以,将它从队尾无情地弹出。
第3步:(添加元素) 将当前索引加入队尾
-
dq.push_back(i); -
解释:经过第2步的“清理”,
nums[i]现在可以安全地加入队尾,并维持队列的单调递减性。
第4步:(报告答案) 记录队首的最大值
-
if (i >= k - 1) -
result.push_back(nums[dq.front()]); -
解释:
i >= k-1意味着窗口已经“满”了。根据我们的单调递减规则,队首dq.front()对应的nums[dq.front()],永远是当前窗口内的最大值!
复杂度深度分析
-
时间复杂度 O(n):
-
这是一个经典的摊销分析 (Amortized Analysis)。
-
表面上看,
for循环内部有一个while循环,似乎是 O(n²)。 -
但是,我们来分析每个元素的“一生”:
nums[i]的索引i,最多只入队一次(push_back)。 -
它也最多只出队一次(要么
pop_front,要么pop_back)。 -
在
n次的for循环中,push_back的总操作是n次。pop_front和pop_back的总操作加起来也绝不会超过n次。 -
所有的
push和pop操作总共是 O(n) 级别。 -
将这些 O(n) 的操作成本,平摊到
n次for循环上,平均每次循环的时间复杂度就是 O(1)。 -
总时间复杂度 O(n)。
-
-
空间复杂度 O(k):
-
在最坏的情况下,例如一个严格递减的数组
[5, 4, 3, 2, 1],while循环(第2步)永远不会执行。 -
此时,窗口内的所有
k个元素的索引都会被压入双端队列中。 -
因此,
dq的大小最多为k。
-
代码实现
#include <vector>
#include <deque>using namespace std;class Solution {
public:vector<int> maxSlidingWindow(vector<int>& nums, int k) {vector<int> result;// deque 中存储的是索引,其对应的值是单调递减的deque<int> dq; for (int i = 0; i < nums.size(); ++i) {// 1. (维护窗口) 移除队首的“过期”索引if (!dq.empty() && dq.front() == i - k) {dq.pop_front();}// 2. (维护单调) 移除队尾的“无用”索引while (!dq.empty() && nums[i] >= nums[dq.back()]) {dq.pop_back();}// 3. (添加元素) 将当前索引加入队尾dq.push_back(i);// 4. (报告答案) 窗口已满if (i >= k - 1) {result.push_back(nums[dq.front()]);}}return result;}
};
总结:从“栈”到“队列”的进化
今天,我们成功地将单调栈(LIFO)进化为了单调队列(Deque, FIFO + LIFO)。
-
单调栈:适用于解决“下一个/上一个 更大/更小”问题,它的“窗口”是不固定的(从
i到left_bound/right_bound)。 -
单调队列:适用于解决“固定大小的滑动窗口”的最值问题,它必须同时处理“从尾部加入”和“从头部移除”。
掌握了单调队列,你就拥有了在 O(n) 时间内处理所有“滑动窗口最值”问题的终极武器!
下期见!
