在上一次写完单调栈之后,答应大家写的单调队列,他们都是对栈这一数据结构的应用。
如果你已经学过队列,应该知道它最大的特点是:先进先出。
谁先来,谁先走。
这听起来很公平,但在某些算法问题里,光公平还不够。比如我们经常会遇到这样一个场景:
有一个窗口在数组上不断滑动,每次都要快速知道窗口里的最大值或最小值。
这时候,普通队列就有点吃力了。
因为普通队列只知道谁先进来、谁先出去,却不知道当前队列里谁最大。你想找最大值,就只能遍历整个窗口。如果窗口大小是 k,数组长度是 n,那总复杂度可能变成 O(nk)。
而单调队列就是为了解决这个问题诞生的。
它既保留了队列的时间顺序,又能快速告诉你当前窗口的最大值或最小值。
简单说:
单调队列 = 队列的先进先出 + 单调结构的快速最值能力。
一、为什么需要单调队列?
我们先看一个很常见的问题。
给你一个数组:
nums = [1, 3, -1, -3, 5, 3, 6, 7]
窗口大小:
k = 3
现在窗口从左往右滑动,每次输出窗口中的最大值。
过程如下:
窗口位置 最大值
[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
最终答案是:
[3, 3, 5, 5, 6, 7]
如果每次窗口滑动后都重新扫描窗口里的 k 个元素,那么当然可以做,但效率不高。
我们希望做到:
- 每个元素最多进队一次;
- 每个元素最多出队一次;
- 每次都能
O(1)获取当前最大值; - 整体复杂度控制在
O(n)。
这正是单调队列擅长的事情。
二、普通队列的问题在哪里?
普通队列只维护元素的先后顺序。
比如窗口中有:
[1, 3, -1]
普通队列会老老实实保存:
1 -> 3 -> -1
但如果你问它:“当前最大值是谁?”
它并不知道。
它只能从头到尾扫一遍,发现最大值是 3。
当窗口右移时,1 被移出,-3 被加入:
[3, -1, -3]
最大值仍然是 3。
但如果下一次移出的刚好是最大值 3,窗口变成:
[-1, -3, 5]
这时最大值就变成了 5。
问题来了:
当最大值被移出窗口后,如何快速知道新的最大值?
普通队列解决不了这个问题。
优先队列,也就是堆,可以快速获得最大值,但它不天然满足“先进先出”的窗口顺序。堆关心的是大小,不关心谁先进入窗口。
所以我们需要一种结构,既要懂顺序,又要懂大小。
这就是单调队列。
三、什么是单调队列?
单调队列本质上还是一个队列,只不过它内部的元素会保持某种单调性。
如果我们要维护最大值,就让队列从队头到队尾保持 单调递减。
也就是说:
队头 队尾
最大值 -> 次大值 -> 更小值
7 -> 5 -> 3
这样一来,队头永远是当前窗口中的最大值。
如果我们要维护最小值,就让队列从队头到队尾保持 单调递增。
本文主要以“滑动窗口最大值”为例,所以我们维护的是一个单调递减队列。
图片位置建议一:放在这里
建议放一张“普通队列 vs 单调队列”的对比图。
图片内容可以这样设计:
- 左边是普通队列:
1 -> 3 -> -1,标注“需要遍历才能找到最大值”; - 右边是单调队列:
3 -> -1,标注“队头就是最大值”; - 用箭头强调:单调队列会把没有竞争力的元素提前移除。
图片标题可以是:
普通队列只维护顺序,单调队列同时维护顺序和最值信息。
四、单调队列的核心规则
假设我们要维护窗口最大值。
当一个新元素 n 进入队列时,我们会从队尾开始比较:
- 如果队尾元素小于
n,说明它以后不可能成为最大值; - 因为
n比它大,而且n比它更晚进入窗口; - 只要
n还在窗口里,那个更小的旧元素就永远没有机会成为最大值; - 所以可以直接把旧元素从队尾删除。
这就是单调队列最关键的思想:
新来的大元素,会淘汰前面比它小的元素。
举个例子。
当前单调队列是:
[5, 3, 1]
现在新元素 4 进来。
队尾是 1,比 4 小,删除。
队列变成:
[5, 3]
队尾是 3,也比 4 小,删除。
队列变成:
[5]
队尾是 5,比 4 大,停止。
最后把 4 加入队尾:
[5, 4]
整个队列依然保持单调递减。
五、为什么可以删除那些更小的元素?
这是理解单调队列的关键。
假设有两个元素:
- 旧元素
a - 新元素
b
并且满足:
a < b
同时,b 比 a 晚进入窗口。
那么在后续窗口滑动过程中,只要 a 还没出窗口,b 一定也还没出窗口。因为 b 是后来进入的,它会比 a 更晚离开。
既然:
b比a大;b比a活得更久;
那么 a 就永远不可能成为最大值。
所以我们可以放心删除 a。
这不是乱删,而是在提前清理“没有未来竞争力”的元素。
六、单调队列需要哪些 API?
普通队列一般有两个核心操作:
push:从队尾加入元素;pop:从队头移除元素。
单调队列在此基础上多了一个获取最值的能力。
以维护最大值为例,它通常需要这几个方法:
class MonotonicQueue {
// 在队尾加入元素,同时维护单调递减结构
void push(int n);
// 返回当前队列中的最大值
int max();
// 如果队头元素等于 n,就将它移出
void pop(int n);
}
其中最特殊的是 pop(int n)。
为什么出队时还要传入一个 n?
因为有些元素在进入单调队列时,可能已经被更大的元素“淘汰”了。也就是说,它虽然曾经属于窗口,但已经不在单调队列里了。
所以窗口左边界移出某个元素时,我们只需要判断:
如果这个元素刚好等于单调队列队头,说明它确实还在队列中,而且它就是当前最大值,需要删除。
否则,不用管它。
七、单调队列的实现
以 Java 为例,我们可以用 LinkedList 来实现单调队列。
因为单调队列需要在队头和队尾都进行删除操作,所以双端队列结构非常合适。
import java.util.LinkedList;
class MonotonicQueue {
private LinkedList<Integer> q = new LinkedList<>();
public void push(int n) {
// 队尾所有小于 n 的元素,都不可能再成为最大值
while (!q.isEmpty() && q.getLast() < n) {
q.pollLast();
}
// 将 n 加入队尾
q.addLast(n);
}
public int max() {
// 队头就是当前最大值
return q.getFirst();
}
public void pop(int n) {
// 只有当即将移出的元素等于队头时,才真正删除
if (!q.isEmpty() && q.getFirst() == n) {
q.pollFirst();
}
}
}
这段代码非常短,但思想很精妙。
push 负责维护单调性。
max 直接返回队头。
pop 负责配合滑动窗口移除过期元素。
八、用单调队列解决滑动窗口最大值
现在我们回到 LeetCode 239:滑动窗口最大值。
题目要求:
给定数组
nums和整数k,返回每个大小为k的滑动窗口中的最大值。
思路如下:
- 遍历数组;
- 先把前
k - 1个元素放入窗口; - 从第
k个元素开始,每加入一个新元素,就形成一个完整窗口; - 调用
window.max()记录当前窗口最大值; - 再把窗口最左边的元素移出。
完整代码如下:
import java.util.ArrayList;
import java.util.LinkedList;
import java.util.List;
class Solution {
class MonotonicQueue {
private LinkedList<Integer> q = new LinkedList<>();
public void push(int n) {
while (!q.isEmpty() && q.getLast() < n) {
q.pollLast();
}
q.addLast(n);
}
public int max() {
return q.getFirst();
}
public void pop(int n) {
if (!q.isEmpty() && q.getFirst() == n) {
q.pollFirst();
}
}
}
public int[] maxSlidingWindow(int[] nums, int k) {
MonotonicQueue window = new MonotonicQueue();
List<Integer> res = new ArrayList<>();
for (int i = 0; i < nums.length; i++) {
if (i < k - 1) {
// 先填满窗口的前 k - 1 个元素
window.push(nums[i]);
} else {
// 加入新元素,窗口形成
window.push(nums[i]);
// 当前窗口最大值就是单调队列队头
res.add(window.max());
// 移出窗口最左侧元素
window.pop(nums[i - k + 1]);
}
}
int[] ans = new int[res.size()];
for (int i = 0; i < res.size(); i++) {
ans[i] = res.get(i);
}
return ans;
}
}
九、手动模拟一遍过程
还是用这个例子:
nums = [1, 3, -1, -3, 5, 3, 6, 7]
k = 3
我们看窗口滑动时,单调队列如何变化。
i = 0,加入 1
窗口未形成,单调队列:[1]
i = 1,加入 3
3 比 1 大,删除 1
窗口未形成,单调队列:[3]
i = 2,加入 -1
窗口 [1, 3, -1] 形成
单调队列:[3, -1]
当前最大值:3
继续往后:
移出 1
1 不等于队头 3,不用删除
单调队列:[3, -1]
i = 3,加入 -3
窗口 [3, -1, -3] 形成
单调队列:[3, -1, -3]
当前最大值:3
移出 3
3 等于队头 3,删除
单调队列:[-1, -3]
i = 4,加入 5
5 比 -3 大,删除 -3
5 比 -1 大,删除 -1
单调队列:[5]
当前最大值:5
你会发现,单调队列并不保存窗口里的所有元素。
它只保存那些“未来可能成为最大值”的元素。
这也是它高效的原因。
图片位置建议三:放在这里
建议放一张“滑动窗口与单调队列同步变化”的动态图或分步骤图。
可以分成三列:
| 当前窗口 | 单调队列 | 当前最大值 |
|---|---|---|
[1, 3, -1] | [3, -1] | 3 |
[3, -1, -3] | [3, -1, -3] | 3 |
[-1, -3, 5] | [5] | 5 |
[-3, 5, 3] | [5, 3] | 5 |
[5, 3, 6] | [6] | 6 |
[3, 6, 7] | [7] | 7 |
图片标题可以是:
滑动窗口向右移动时,单调队列只保留可能成为最大值的元素。
十、为什么时间复杂度是 O(n)?
很多人第一次看单调队列代码时,会有一个疑问:
push 方法里有一个 while 循环,那它不是可能退化成 O(n) 吗?
单独看某一次 push,确实可能删除很多元素。
但从整体来看,每个元素:
- 最多被加入队列一次;
- 最多被删除队列一次。
所以所有元素加起来,总操作次数不会超过 2n 级别。
因此总时间复杂度是:
O(n)
这是一种典型的摊还分析。
空间复杂度则和窗口大小有关:
O(k)
因为单调队列中最多保存窗口内的元素。
十一、单调队列和单调栈有什么区别?
单调队列和单调栈很像,都是通过维护单调性来提前淘汰无用元素。
但它们的使用场景不同。
单调栈更适合解决:
- 下一个更大元素;
- 下一个更小元素;
- 每个元素左右两边第一个比它大或小的元素;
- 柱状图最大矩形;
- 每日温度。
单调栈通常关注的是:
某个元素附近第一个比它大或小的元素是谁。
单调队列更适合解决:
- 滑动窗口最大值;
- 滑动窗口最小值;
- 固定区间内最值;
- 动态窗口内最值;
- 一些需要维护区间最优值的 DP 优化问题。
单调队列通常关注的是:
当前窗口或区间中的最大值、最小值是多少。
一句话总结:
单调栈解决“下一个更大/更小”,单调队列解决“窗口最值”。
十二、如何扩展为最小值队列?
如果想维护最小值,只需要把比较方向反过来。
维护最大值时,队列递减:
while 队尾元素 < 新元素,删除队尾
维护最小值时,队列递增:
while 队尾元素 > 新元素,删除队尾
最小值队列代码如下:
import java.util.LinkedList;
class MonotonicMinQueue {
private LinkedList<Integer> q = new LinkedList<>();
public void push(int n) {
while (!q.isEmpty() && q.getLast() > n) {
q.pollLast();
}
q.addLast(n);
}
public int min() {
return q.getFirst();
}
public void pop(int n) {
if (!q.isEmpty() && q.getFirst() == n) {
q.pollFirst();
}
}
}
所以,单调队列并不是只能求最大值。
它可以求最大值,也可以求最小值。
关键看你维护的是递减队列还是递增队列。
十三、例题推荐
读完这篇文章,你可以尝试下面这些题目。
例题一:LeetCode 239. Sliding Window Maximum
中文题名:滑动窗口最大值
难度:困难
题意:
给定数组 nums 和窗口大小 k,窗口每次向右移动一位,返回每个窗口中的最大值。
示例:
输入:nums = [1,3,-1,-3,5,3,6,7], k = 3
输出:[3,3,5,5,6,7]
解法关键词:
- 滑动窗口;
- 单调递减队列;
- 队头维护最大值。
这是单调队列最经典的模板题,建议优先掌握。
例题二:LCR 184. 设计自助结算系统
这道题本质上是让你设计一个队列系统,支持:
- 加入元素;
- 移出元素;
- 快速获取当前最大值。
它和普通队列最大的区别在于:
普通队列只能
O(1)入队和出队,但不能O(1)获取最大值。
所以可以使用两个队列:
- 一个普通队列,维护真实入队顺序;
- 一个单调递减队列,维护当前最大值。
核心思路:
- 入队时,普通队列正常加入;
- 单调队列删除队尾所有更小元素,再加入当前元素;
- 出队时,如果出队元素等于单调队列队头,也同步删除;
- 查询最大值时,直接返回单调队列队头。
示意代码:
import java.util.LinkedList;
import java.util.Queue;
class Checkout {
private Queue<Integer> data = new LinkedList<>();
private LinkedList<Integer> maxq = new LinkedList<>();
public int get_max() {
if (maxq.isEmpty()) {
return -1;
}
return maxq.getFirst();
}
public void add(int value) {
data.offer(value);
while (!maxq.isEmpty() && maxq.getLast() < value) {
maxq.pollLast();
}
maxq.addLast(value);
}
public int remove() {
if (data.isEmpty()) {
return -1;
}
int value = data.poll();
if (!maxq.isEmpty() && maxq.getFirst() == value) {
maxq.pollFirst();
}
return value;
}
}
这道题非常适合帮助你理解:
单调队列不是替代普通队列,而是辅助普通队列维护最值。
例题三:剑指 Offer 59 - II. 队列的最大值
如果你在刷剑指 Offer,这道题和 LCR 184 非常类似。
题目要求设计一个队列,支持:
max_value():获取最大值;push_back(value):入队;pop_front():出队。
这也是典型的“普通队列 + 单调队列”设计题。
建议你在写完 LeetCode 239 后立刻做这题。
因为 239 偏算法应用,而这题偏数据结构设计。
例题四:滑动窗口最小值
题意:
给定数组 nums 和窗口大小 k,返回每个窗口中的最小值。
示例:
输入:nums = [1,3,-1,-3,5,3,6,7], k = 3
输出:[-1,-3,-3,-3,3,3]
这道题和滑动窗口最大值几乎一样,只需要把单调递减队列改成单调递增队列。
也就是:
while 队尾元素 > 当前元素,删除队尾
这题可以帮助你确认自己是不是真的理解了单调队列,而不是只背了最大值模板。
例题五:窗口最大值与最小值之差不超过 limit
这是一个更进阶的滑动窗口问题。
题意大致是:
给定数组 nums 和整数 limit,找出最长连续子数组,使得子数组中的最大值和最小值之差不超过 limit。
这类题一般需要同时维护:
- 一个单调递减队列,用来获取最大值;
- 一个单调递增队列,用来获取最小值。
当:
max - min > limit
说明窗口不合法,需要移动左边界。
这类题能让你真正体会到单调队列在复杂滑动窗口中的威力。
十四、单调队列模板总结
维护最大值模板:
class MaxQueue {
LinkedList<Integer> q = new LinkedList<>();
void push(int x) {
while (!q.isEmpty() && q.getLast() < x) {
q.pollLast();
}
q.addLast(x);
}
int max() {
return q.getFirst();
}
void pop(int x) {
if (!q.isEmpty() && q.getFirst() == x) {
q.pollFirst();
}
}
}
维护最小值模板:
class MinQueue {
LinkedList<Integer> q = new LinkedList<>();
void push(int x) {
while (!q.isEmpty() && q.getLast() > x) {
q.pollLast();
}
q.addLast(x);
}
int min() {
return q.getFirst();
}
void pop(int x) {
if (!q.isEmpty() && q.getFirst() == x) {
q.pollFirst();
}
}
}
滑动窗口模板:
for (int i = 0; i < nums.length; i++) {
if (i < k - 1) {
window.push(nums[i]);
} else {
// 新元素入窗口
window.push(nums[i]);
// 记录当前窗口答案
res.add(window.max());
// 旧元素出窗口
window.pop(nums[i - k + 1]);
}
}
十五、最后总结
单调队列是一种专门为“动态窗口最值”服务的数据结构。
它的核心思想可以总结成三句话:
-
队头永远保存当前窗口的最值。
如果维护最大值,就让队列从队头到队尾单调递减;如果维护最小值,就让队列从队头到队尾单调递增。 -
新元素入队时,会淘汰队尾中所有不可能成为答案的元素。
维护最大值时,队尾比新元素小的元素会被删除;维护最小值时,队尾比新元素大的元素会被删除。 -
窗口左侧元素离开时,只在它等于队头元素时才弹出。
因为有些元素可能早就在入队时被淘汰了,所以出窗口时不一定还存在于单调队列中。
所以,单调队列并不是一个复杂的数据结构,它真正巧妙的地方在于:
提前删除那些“未来不可能成为最值”的元素,只保留有竞争力的候选者。
在滑动窗口最大值这类问题中,单调队列可以让我们在 O(1) 时间获取当前窗口最大值,并让整体算法保持 O(n) 的时间复杂度。
如果你能理解这句话:
比我小、比我早进来的元素,在我存在期间永远不可能成为最大值。
那么你就真正理解了单调队列的灵魂。
它看起来像是在排队,但其实每个元素都在竞争“窗口最值”的位置。能留下来的,都是在未来某个时刻仍然有机会成为答案的元素。