前言
单调队列是单调栈的孪生兄弟。如果说单调栈处理"下一个更大元素",那么单调队列就是处理**"滑动窗口的最大值"**。
我并没有能力让你看完就精通所有队列问题,我只是想让你理解单调队列的核心思想、和单调栈的区别、以及如何用双端队列维护窗口最值。掌握这个技巧,滑动窗口问题不再是难题。
摘要
从"滑动窗口最大值暴力超时"问题出发,剖析单调队列的核心思想与实现技巧。通过队首维护最大值、队尾维护单调性的图解演示、以及滑动窗口的详细推导,揭秘如何把窗口最值查询从O(k)优化到O(1)。配合LeetCode经典题目,给出单调队列的完整套路。
一、从滑动窗口最大值说起
周一早上,哈吉米遇到一道Hard题:
LeetCode 239 - 滑动窗口最大值
给你一个整数数组 nums,有一个大小为 k 的滑动窗口从数组的最左侧移动到数组的最右侧。
你只可以看到在滑动窗口内的 k 个数字。滑动窗口每次只向右移动一位。
返回滑动窗口中的最大值。
示例:
输入: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
哈吉米的暴力代码:
Java版本:
public int[] maxSlidingWindow(int[] nums, int k) {
int n = nums.length;
int[] result = new int[n - k + 1];
for (int i = 0; i <= n - k; i++) {
int max = nums[i];
// 遍历窗口,找最大值
for (int j = i; j < i + k; j++) {
max = Math.max(max, nums[j]);
}
result[i] = max;
}
return result;
}
时间复杂度:O(n×k)
南北绿豆走过来:"数据量多大?"
哈吉米:"nums最多10万,k最多10万,O(n×k)=100亿,妥妥超时。"
南北绿豆:"这是单调队列的经典应用,O(n)解决。"
二、生活化场景:排队上公交
阿西噶阿西:"想象公交站排队上车。"
场景:
队伍:[身高170, 165, 180, 175, 160]
公交车容量:3人
问题:每次3人上车,谁最高?
第1批:[170, 165, 180] → 最高180
第2批:[165, 180, 175] → 最高180
第3批:[180, 175, 160] → 最高180
暴力方法:每批都遍历3个人,找最高的。
单调队列方法:
维护一个队列,队首永远是最高的人
规则:
1. 新人进来,比队尾矮的不影响(留着)
2. 新人进来,比队尾高,队尾的人踢出去(反正永远选不上)
3. 队首的人如果已经出窗口,踢出去
结果:队首永远是窗口内最高的人
哈吉米:"队列里只保留可能成为最大值的人,其他人踢掉。"
南北绿豆:"对,这就是单调队列:队列内元素单调递减(从队首到队尾)。"
三、单调队列 vs 单调栈
南北绿豆:"先看单调队列和单调栈的区别。"
| 对比项 | 单调栈 | 单调队列 |
|---|---|---|
| 数据结构 | 栈(后进先出) | 双端队列(两端都能操作) |
| 单调性 | 从栈底到栈顶单调 | 从队首到队尾单调 |
| 典型问题 | 下一个更大元素 | 滑动窗口最大值 |
| 操作 | 只能从栈顶pop | 可以从队首队尾pop |
为什么需要双端队列?
阿西噶阿西:
- 队首:弹出过期元素(滑出窗口的)
- 队尾:弹出比当前元素小的(永远选不上的)
图示:
flowchart TB
A["单调队列<br/>双端队列Deque"]
B["队首操作<br/>弹出过期元素"]
C["队尾操作<br/>维护单调性"]
D["队首<br/>永远是窗口最大值"]
A --> B
A --> C
B --> D
C --> D
style D fill:#e1ffe1
四、单调队列原理
南北绿豆:"单调队列维护一个单调递减的队列(队首最大)。"
4.1 核心规则
1. 新元素入队前:
- 从队尾开始,弹出所有比新元素小的元素
- 然后新元素从队尾入队
2. 窗口滑动时:
- 检查队首元素是否已经滑出窗口
- 如果滑出,从队首弹出
3. 查询最大值:
- 直接返回队首
为什么要弹出队尾比新元素小的?
阿西噶阿西:"因为新元素更大且更新,之前的小元素永远不会成为最大值了。"
队列:[5, 3](单调递减)
新元素:6
3比6小,且3在6前面(6更新)
→ 3永远选不上了(6在窗口内时,最大值一定是6而不是3)
→ 踢掉3
5比6小,也踢掉
队列:[6]
五、滑动窗口最大值详解
5.1 执行过程演示
示例:nums = [1,3,-1,-3,5,3,6,7], k = 3
单调队列变化:
| i | nums[i] | 窗口 | 队列(存索引) | 队首元素 | 最大值 |
|---|---|---|---|---|---|
| 0 | 1 | [1] | [0] | nums[0]=1 | - |
| 1 | 3 | [1,3] | [1] | nums[1]=3 | - |
| 2 | -1 | [1,3,-1] | [1,2] | nums[1]=3 | 3 |
| 3 | -3 | [3,-1,-3] | [1,2,3] | nums[1]=3 | 3 |
| 4 | 5 | [-1,-3,5] | [4] | nums[4]=5 | 5 |
| 5 | 3 | [-3,5,3] | [4,5] | nums[4]=5 | 5 |
| 6 | 6 | [5,3,6] | [6] | nums[6]=6 | 6 |
| 7 | 7 | [3,6,7] | [7] | nums[7]=7 | 7 |
详细过程:
i=0,nums[0]=1:
队列空,1入队
队列:[0](存的是索引)
i=1,nums[1]=3:
3 > 1(队尾),弹出0
3入队
队列:[1]
i=2,nums[2]=-1:
窗口形成(size=3),记录最大值
-1 < 3(队尾),不弹出,直接入队
队列:[1,2]
最大值:nums[1]=3
i=3,nums[3]=-3:
-3 < -1(队尾),直接入队
队列:[1,2,3]
最大值:nums[1]=3
i=4,nums[4]=5:
5 > -3(队尾),弹出3
5 > -1(队尾),弹出2
5 > 3(队尾),弹出1
5入队
队列:[4]
最大值:nums[4]=5
i=5,nums[5]=3:
3 < 5,直接入队
队列:[4,5]
最大值:nums[4]=5
i=6,nums[6]=6:
窗口滑动,检查队首4是否过期:4 < 6-3+1=4,不过期
6 > 3(队尾),弹出5
6 > 5(队尾),弹出4
6入队
队列:[6]
最大值:nums[6]=6
哈吉米:"队列里只保留可能成为最大值的元素,其他都踢掉。"
5.2 代码实现
Java版本:
public int[] maxSlidingWindow(int[] nums, int k) {
int n = nums.length;
int[] result = new int[n - k + 1];
// 双端队列:存储索引
Deque<Integer> deque = new ArrayDeque<>();
for (int i = 0; i < n; i++) {
// 移除队首过期元素(滑出窗口的)
if (!deque.isEmpty() && deque.peekFirst() < i - k + 1) {
deque.pollFirst();
}
// 维护单调性:移除队尾比当前元素小的
while (!deque.isEmpty() && nums[deque.peekLast()] < nums[i]) {
deque.pollLast();
}
// 当前元素入队
deque.offerLast(i);
// 窗口形成后,记录最大值
if (i >= k - 1) {
result[i - k + 1] = nums[deque.peekFirst()];
}
}
return result;
}
C++版本:
vector<int> maxSlidingWindow(vector<int>& nums, int k) {
int n = nums.size();
vector<int> result;
deque<int> dq;
for (int i = 0; i < n; i++) {
// 移除队首过期元素
if (!dq.empty() && dq.front() < i - k + 1) {
dq.pop_front();
}
// 维护单调性
while (!dq.empty() && nums[dq.back()] < nums[i]) {
dq.pop_back();
}
dq.push_back(i);
if (i >= k - 1) {
result.push_back(nums[dq.front()]);
}
}
return result;
}
Python版本:
def maxSlidingWindow(nums, k):
from collections import deque
result = []
dq = deque()
for i in range(len(nums)):
# 移除队首过期元素
if dq and dq[0] < i - k + 1:
dq.popleft()
# 维护单调性
while dq and nums[dq[-1]] < nums[i]:
dq.pop()
dq.append(i)
if i >= k - 1:
result.append(nums[dq[0]])
return result
时间复杂度:O(n),每个元素最多入队出队各一次。
六、单调队列的核心思想
南北绿豆:"单调队列的核心:队列内元素单调递减(或递增),队首是最大值(或最小值)。"
6.1 为什么是O(n)?
哈吉米:"看起来有while循环,为什么不是O(n²)?"
阿西噶阿西:"和单调栈一样,每个元素最多入队一次、出队一次。"
数组长度n=8
入队次数:8次
出队次数:最多8次(队首过期+队尾弹出)
总操作 = 16次 = O(n)
6.2 单调队列图解
窗口滑动过程:
sequenceDiagram
participant N as nums数组
participant D as 单调队列
participant R as 结果
Note over N: nums=[1,3,-1,-3,5,3,6,7], k=3
N->>D: i=0, nums[0]=1
Note over D: 队列[0]
N->>D: i=1, nums[1]=3
Note over D: 3>1,弹出0<br/>队列[1]
N->>D: i=2, nums[2]=-1
Note over D: -1<3,直接加<br/>队列[1,2]
D->>R: 窗口形成,最大值=nums[1]=3
N->>D: i=3, nums[3]=-3
Note over D: -3<-1,直接加<br/>队列[1,2,3]
D->>R: 最大值=nums[1]=3
N->>D: i=4, nums[4]=5
Note over D: 5>所有,全弹出<br/>队列[4]
D->>R: 最大值=nums[4]=5
七、例题2:和至少为K的最短子数组
7.1 题目
LeetCode 862 - 和至少为 K 的最短子数组(Hard)
给你一个整数数组 nums 和一个整数 k ,找出 nums 中和至少为 k 的最短非空子数组 ,
并返回该子数组的长度。如果不存在这样的子数组 ,返回 -1 。
示例:
输入:nums = [2,-1,2], k = 3
输出:3
输入:nums = [1,2], k = 4
输出:-1
7.2 思路分析
南北绿豆:"这题结合了前缀和+单调队列。"
思路:
- 计算前缀和数组prefix
- 问题转换:找最短的i到j,使得
prefix[j] - prefix[i] >= k - 用单调队列维护prefix,找满足条件的最短距离
阿西噶阿西:"单调队列维护prefix的递增序列。"
为什么要单调递增?
如果prefix[i] >= prefix[j](i < j):
说明nums[i+1..j]的和<=0
对于后面的任意k:
prefix[k] - prefix[j] >= k 时,
prefix[k] - prefix[i]也一定>= k(因为prefix[i]<=prefix[j])
而j-k的距离更短
→ i永远用不上了,可以弹出
7.3 代码实现
Java版本:
public int shortestSubarray(int[] nums, int k) {
int n = nums.length;
// 前缀和
long[] prefix = new long[n + 1];
for (int i = 0; i < n; i++) {
prefix[i + 1] = prefix[i] + nums[i];
}
int minLen = n + 1;
Deque<Integer> deque = new ArrayDeque<>();
for (int i = 0; i <= n; i++) {
// 找满足条件的最短子数组
while (!deque.isEmpty() && prefix[i] - prefix[deque.peekFirst()] >= k) {
minLen = Math.min(minLen, i - deque.pollFirst());
}
// 维护单调递增
while (!deque.isEmpty() && prefix[i] <= prefix[deque.peekLast()]) {
deque.pollLast();
}
deque.offerLast(i);
}
return minLen == n + 1 ? -1 : minLen;
}
C++版本:
int shortestSubarray(vector<int>& nums, int k) {
int n = nums.size();
vector<long> prefix(n + 1, 0);
for (int i = 0; i < n; i++) {
prefix[i + 1] = prefix[i] + nums[i];
}
int minLen = n + 1;
deque<int> dq;
for (int i = 0; i <= n; i++) {
while (!dq.empty() && prefix[i] - prefix[dq.front()] >= k) {
minLen = min(minLen, i - dq.front());
dq.pop_front();
}
while (!dq.empty() && prefix[i] <= prefix[dq.back()]) {
dq.pop_back();
}
dq.push_back(i);
}
return minLen == n + 1 ? -1 : minLen;
}
Python版本:
def shortestSubarray(nums, k):
from collections import deque
n = len(nums)
prefix = [0] * (n + 1)
for i in range(n):
prefix[i + 1] = prefix[i] + nums[i]
minLen = n + 1
dq = deque()
for i in range(n + 1):
while dq and prefix[i] - prefix[dq[0]] >= k:
minLen = min(minLen, i - dq.popleft())
while dq and prefix[i] <= prefix[dq[-1]]:
dq.pop()
dq.append(i)
return -1 if minLen == n + 1 else minLen
八、单调队列总结
8.1 核心要点
南北绿豆总结:
- 数据结构:双端队列(Deque)
- 单调性:队首到队尾单调递减(求最大)或递增(求最小)
- 队首:窗口内的最大/最小值
- 队尾:维护单调性,弹出无用元素
- 时间复杂度:O(n)
8.2 单调队列 vs 单调栈
| 对比项 | 单调栈 | 单调队列 |
|---|---|---|
| 数据结构 | 栈 | 双端队列 |
| 操作 | 只能栈顶pop | 队首队尾都能pop |
| 适用场景 | 下一个更大/更小 | 滑动窗口最值 |
| 典型题目 | LeetCode 739、84 | LeetCode 239、862 |
8.3 识别技巧
阿西噶阿西:
- 看到滑动窗口+最大/最小值,想单调队列
- 看到固定窗口的最值,想单调队列
- 看到区间最值,可能用单调队列
8.4 通用模板
Java版本:
public int[] slidingWindowMax(int[] nums, int k) {
int n = nums.length;
int[] result = new int[n - k + 1];
Deque<Integer> deque = new ArrayDeque<>(); // 存索引
for (int i = 0; i < n; i++) {
// 移除队首过期元素
if (!deque.isEmpty() && deque.peekFirst() < i - k + 1) {
deque.pollFirst();
}
// 维护单调递减:移除队尾比当前小的
while (!deque.isEmpty() && nums[deque.peekLast()] < nums[i]) {
deque.pollLast();
}
deque.offerLast(i);
// 窗口形成,记录最大值
if (i >= k - 1) {
result[i - k + 1] = nums[deque.peekFirst()];
}
}
return result;
}
C++版本:
vector<int> slidingWindowMax(vector<int>& nums, int k) {
int n = nums.size();
vector<int> result;
deque<int> dq;
for (int i = 0; i < n; i++) {
if (!dq.empty() && dq.front() < i - k + 1) {
dq.pop_front();
}
while (!dq.empty() && nums[dq.back()] < nums[i]) {
dq.pop_back();
}
dq.push_back(i);
if (i >= k - 1) {
result.push_back(nums[dq.front()]);
}
}
return result;
}
Python版本:
def slidingWindowMax(nums, k):
from collections import deque
result = []
dq = deque()
for i in range(len(nums)):
if dq and dq[0] < i - k + 1:
dq.popleft()
while dq and nums[dq[-1]] < nums[i]:
dq.pop()
dq.append(i)
if i >= k - 1:
result.append(nums[dq[0]])
return result
参考资料:
- 《算法第四版》- Robert Sedgewick
- 《算法竞赛进阶指南》- 李煜东
- LeetCode题解 - 单调队列专题