单调队列解决滑动窗口极值:双端队列在 O(N) 复杂度下的单调性维护技巧
在海量流式数据监控、高并发指标统计(如最近 5 分钟接口最大响应耗时)以及动态规划状态加速中,“滑动窗口极值”是一个出现频次极高的高频问题。在面试或算法竞赛中,很多人拿到这类题目第一时间会想到大顶堆(优先队列PriorityQueue)。
堆虽然能以 $O(\log k)$ 维护极值,但它存在两个致命痛点:第一,在窗口右移时,滑出窗口的元素可能并不在堆顶,需要引入“延迟删除”或额外维护哈希表做堆内元素寻址,逻辑极其臃肿;第二,在千万级流式计算场景下,每一个数据点都触发 $\log k$ 次树形调整,CPU Cache 命中率极差。
真正达到理论极限解法的是单调队列(Monotonic Queue)——借助双端队列(Deque),以均摊严格 $O(N)$ 的时间复杂度和 $O(k)$ 的空间复杂度,行云流水地完成动态极值维护。
单调队列的哲学:既生瑜何生亮
单调队列的核心逻辑可以用一句通俗的话来概括:“如果一个选手比你年轻,还比你强,那你就永远没有出头之日了。”
假设我们要求解的是滑动窗口内的最大值:
设当前窗口正在向右移动,新元素从右侧进入窗口。此时在队列中已经存在若干比新元素更早进入的老元素。
如果某个老元素的数值小于或等于新进来的元素,那么无论窗口接下来怎么往右滑动:
- 老元素一定会比新元素先出窗(它的生命周期更短);
- 在老元素存活的整个区间内,新元素都在,且新元素的数值更大。
这意味着,这个老元素在未来的任何时刻,都绝无可能成为窗口内的最大值。它的存在对求解极值没有任何意义。因此,在将新元素推入队列前,必须从**队尾(back)**将所有比它小(或相等)的元素全部暴力剔除。
graph LR subgraph Deque维护过程 direction LR Head[队头: 存活的最大值下标] --> Mid[...] Mid --> Tail[队尾: 候选较小值] end New[新元素到来] -->|从队尾比较| Tail Tail -->|数值 <= 新元素| PopBack[队尾出队 淘汰] New -->|直至队尾 > 新元素| PushBack[新元素下标压入队尾] Head -->|下标超出窗口左边界| PopFront[队头出队 过期]致命细节辨析:为什么必须存下标而非元素值?
很多初学者在手撕单调队列时,总想当然地在双端队列里直接存nums[i]的值,结果往往在“处理元素滑出窗口”时陷入泥潭:
// 典型错误尝试:队列直接存值 std::deque<int> q; // 遍历到 i 时,试图出窗 nums[i - k] if (!q.empty() && q.front() == nums[i - k]) { q.pop_front(); }这种写法在窗口内存在重复数值时会引发灾难性的逻辑崩溃。例如数组为[3, 3, 2, 1],窗口大小 $k = 3$:
- 初始入队两个
3,如果队列维护严格递减,在第二个3入队时误将第一个3弹出; - 当窗口右移需要淘汰首个
3时,直接误把第二个仍在窗口内的3给淘汰掉了。
铁律:单调队列必须存储数组下标(Index)!
存下标具有两大不可替代的优势:
- 天然绑定生命周期:根据当前索引
i和队头下标q.front(),只需一行判断q.front() <= i - k(或< i - k + 1),即可瞬间得知队头元素是否已滑出窗口范围,彻底与重复数值解耦。 - $O(1)$ 获取元素值:通过
nums[q.front()]可以随时随地以 $O(1)$ 时间获取当前窗口的最大值。
维护单调性的边界选择:严格还是非严格?
在从队尾淘汰元素时,判断条件究竟是nums[q.back()] < nums[i]还是nums[q.back()] <= nums[i]?
答案是:必须使用<=(非严格单调递减)!
看一个微型测试场景:当前窗口新进来的元素与队尾元素相等(nums[q.back()] == nums[i])。
- 前面的那个元素,下标更靠前,意味着它会更早滑出窗口;
- 新进来的元素,下标更靠后,生命力更持久;
- 既然两者数值完全相同,那么更老的那个元素完全没有任何继续驻留的价值。
使用<=将相等的老元素从队尾弹出,不仅在数学上完全等价,而且能极致压缩双端队列的实际物理长度,避免无谓的内存开销与冗余遍历。
工业级高效模板实现
下面分别给出 C++ 与 Java 21+ 的高性能标准实现。代码结构经过精心打磨,杜绝一切冗余分支。
1. 现代 C++ 实现(LeetCode 239 标准范式)
#include <vector> #include <deque> class MonotonicQueueSolver { public: std::vector<int> maxSlidingWindow(const std::vector<int>& nums, int k) { int n = nums.size(); if (n == 0 || k <= 0) return {}; std::vector<int> result; result.reserve(n - k + 1); // 预分配内存,杜绝动态扩容重哈希 std::deque<int> dq; // 存储数组下标 for (int i = 0; i < n; ++i) { // 1. 队头生命周期检查:如果队头下标已滑出窗口范围 [i - k + 1, i],出队头 if (!dq.empty() && dq.front() <= i - k) { dq.pop_front(); } // 2. 队尾单调性维护:淘汰所有数值小于或等于当前元素的老元素 while (!dq.empty() && nums[dq.back()] <= nums[i]) { dq.pop_back(); } // 3. 当前下标入队尾 dq.push_back(i); // 4. 窗口成型(长度达到 k)后,队头即为当前窗口的最大值 if (i >= k - 1) { result.push_back(nums[dq.front()]); } } return result; } };2. Java 实现(基于原生数组模拟循环队列的终极性能优化)
在 Java 中,标准库的java.util.ArrayDeque已经非常高效,但在极其苛刻的高频交易或超大规模流式场景下,使用原生一维数组手写双端队列可以彻底消灭对象封装与包装类型拆装箱开销:
public class FastMonotonicQueue { public int[] maxSlidingWindow(int[] nums, int k) { if (nums == null || nums.length == 0 || k <= 0) { return new int[0]; } int n = nums.length; int[] result = new int[n - k + 1]; int[] deque = new int[n]; // 数组模拟双端队列,存放下标 int head = 0; // 队头指针 int tail = 0; // 队尾指针 (开区间,指向下一个可插入位置) for (int i = 0; i < n; i++) { // 1. 出窗校验 if (head < tail && deque[head] <= i - k) { head++; } // 2. 维护单调递减 while (head < tail && nums[deque[tail - 1]] <= nums[i]) { tail--; } // 3. 压入新下标 deque[tail++] = i; // 4. 收集极值 if (i >= k - 1) { result[i - k + 1] = nums[deque[head]]; } } return result; } }复杂度证明:为什么是严格的 O(N)?
很多初学者看到代码外层有一个for循环,内层还有一个while循环,下意识地认为时间复杂度是 $O(N \times k)$。
这种直觉是错误的。证明算法复杂度必须采用摊还分析(Amortized Analysis):
- 整个算法运行期间,数组中的每一个下标 $i$(从 $0$ 到 $n-1$)恰好只会被
push_back进队列一次。 - 队列中的每一个元素,要么在队尾维护单调性时被
pop_back弹出,要么在队头过期时被pop_front弹出,一旦弹出便永远不会再次入队。 - 因此,整个执行生命周期内,所有入队操作总次数为 $N$,所有出队操作总次数上限为 $N$。
总的时间复杂度严格为:
$$\mathcal{O}(N + N) = \mathcal{O}(N)$$
均摊到每一个滑动步骤上,处理新元素的时间复杂度仅为 $\mathcal{O}(1)$。空间复杂度在最坏情况下队列长度不会超过 $k$,为 $\mathcal{O}(k)$。
进阶应用场景:从基础滑动窗口到 DP 状态加速
单调队列的威力绝不仅限于滑动窗口本身,它更是一把斩断高阶动态规划复杂度维度的利剑。
1. 多重背包问题单调队列优化
在经典多重背包中,朴素 DP 复杂度为 $\mathcal{O}(V \sum C_i)$,即便进行二进制拆分也是 $\mathcal{O}(V \sum \log C_i)$。
通过将状态按体积余数 $r = j \pmod w$ 进行分组,状态转移方程转化为:
$$dp[r + p \cdot w] = \max_{p - c \le q \le p} { dp[r + q \cdot w] - q \cdot v } + p \cdot v$$
这本质上就是一个标准的一维滑动窗口求最值模型。借助单调队列,可将多重背包的复杂度彻底压制到不可思议的 $\mathcal{O}(N \times V)$。
2. 子数组长度受限的最大连续和
如本文在评测小参数大模型时提到的那道题目:求长度不超过 $k$ 的最大连续子数组和。
利用前缀和转换:求 $\max(P[i] - P[j])$ 满足 $i - k \le j < i$。
这等价于在滑动的 $[i - k, i - 1]$ 范围内动态查询最小的 $P[j]$。使用单调队列维护递增的前缀和下标,同样能在 $\mathcal{O}(N)$ 内完成秒杀。
总结
单调队列是计算机科学中“以空间换时间”、“以淘汰策略换极致检索性能”的经典缩影。掌握它的核心不在于背诵模板代码,而在于深刻理解以下三点:
- 淘汰劣解的即时性:通过逆序遍历队尾,主动放弃毫无希望的次优解;
- 下标绑定的确定性:用下标代替数值作为队列承载体,赋予单调队列精准的生命周期管理能力;
- 摊还分析的恒定性:每个元素一生进出各一次,换来的是面对海量流数据时从容不迫的坚韧性能。