窗口在数组上滑动,每移动一步都要知道窗口里的最大值——单调队列能做到全程 O(n)。
有一类问题:一个固定大小为 k 的"窗口",从数组最左边开始,每次往右滑动一格,每滑动一步都要知道当前窗口里的最大值。最直接的想法是每滑动一次,就把窗口里的 k 个数重新比一遍找最大值——数组长度是 n 时,总共要做大约 O(n×k) 次比较,如果 k 比较大,会很慢。
单调队列能把这个问题优化到 O(n)。它和 6.1 节的单调栈是近亲——同样是"维护单调性、提前清理不再有用的数据",区别在于单调队列用的是双端队列(两端都能进出),需要同时处理"窗口滑出去"和"新数据更优"这两种淘汰情况。
用数组 {1, 3, -1, -3, 5, 3, 6, 7}、窗口大小 k=3 演示。维护一个双端队列,队列里存下标,对应的数值从队头到队尾保持递减。每来一个新数 arr[i],要做两件事:
| 1 | vector<int> SlidingWindowMax(vector<int> arr, int k) |
| 2 | { |
| 3 | int n = arr.size(); |
| 4 | deque<int> dq; // 存下标,对应数值从队头到队尾递减 |
| 5 | vector<int> result; |
| 6 | |
| 7 | for (int i = 0; i < n; i++) |
| 8 | { |
| 9 | if (!dq.empty() && dq.front() <= i - k) |
| 10 | { |
| 11 | dq.pop_front(); // 队头已经滑出窗口范围,清理掉 |
| 12 | } |
| 13 | while (!dq.empty() && arr[dq.back()] < arr[i]) |
| 14 | { |
| 15 | dq.pop_back(); // 队尾比新数小,再也不可能是最大值了,清理掉 |
| 16 | } |
| 17 | dq.push_back(i); |
| 18 | |
| 19 | if (i >= k - 1) // 窗口已经凑够k个数,可以记录答案了 |
| 20 | { |
| 21 | result.push_back(arr[dq.front()]); // 队头永远是当前窗口的最大值 |
| 22 | } |
| 23 | } |
| 24 | return result; |
| 25 | } |
x,新数是 y,且 x < y。因为 y 的下标比 x 更靠右,y 会比 x更晚滑出窗口;又因为 y 比 x 大,只要 y 还在窗口里,x 就永远不可能是最大值——x 已经被 y 全面比下去了,留着它毫无意义,直接清理掉。5, 3、3, -3 这种顺序),这正是"单调"二字的含义。单调队列和单调栈用的是同一种"维护单调性、提前清理无用数据"的思想,但解决的问题形状不一样:单调栈关心的是"这个数和它两侧最近的更大/更小值"的关系,扫描方向是一条线走到底,元素一旦弹出就永久消失;单调队列关心的是"固定窗口范围内"的最值,窗口会随着扫描不断"滑走"旧数据,所以需要双端操作——队头负责清理"已经滑出窗口"的旧数据,队尾负责清理"被新数据比下去"的数据。
| 单调栈 | 单调队列 | |
|---|---|---|
| 数据结构 | 栈(一端进出) | 双端队列(两端都能进出) |
| 典型问题 | 找左/右两侧第一个更大/更小的数 | 固定窗口内的最大/最小值 |
| 清理时机 | 新数据破坏单调性时 | 新数据破坏单调性时 + 窗口滑出范围时 |
数组长度 n=50,窗口大小 k=10,对比两种做法的操作次数:
k 个数重新比一遍,总共约 (n-k+1)×k≈410 次;单调队列同样依靠"每个数最多入队一次、出队一次"的均摊分析,总操作次数不超过 2n=100 次,实测 95 次——和 6.1 节单调栈的复杂度证明思路完全一致。k 越大,暴力法的代价越高,单调队列的优势就越明显。| 做法 | 核心思路 | 时间复杂度 |
|---|---|---|
| 暴力法 | 每次滑动都重新比较窗口内所有数 | O(n × k) |
| 单调队列 | 维护递减双端队列,队头即最大值 | O(n) |