单调栈主要用来处理最近更大或更小元素的问题,而单调队列则经常出现在滑动窗口最值问题中。
比如给定一个数组和一个长度为 k 的窗口,让窗口从左向右移动,每移动一次,就要求出当前窗口中的最大值和最小值。
最直接的做法是每次窗口移动后,都重新遍历窗口中的所有元素找最值。这样做虽然简单,但是当数组很长、窗口也很大时,效率就不够了。
单调队列就是为了解决这类问题而出现的一种数据结构。
一、什么是单调队列?
单调队列,顾名思义,就是队列中的元素保持单调递增或者单调递减。
不过,它和普通队列有一点不同:它需要支持两端的插入和删除操作,所以通常使用双端队列 deque 来实现。
C++ 中可以直接使用:
cpp
#include <deque>
deque<int> q;
它常用的操作有:
cpp
q.push_back(x); // 从队尾插入
q.pop_back(); // 删除队尾元素
q.push_front(x); // 从队头插入
q.pop_front(); // 删除队头元素
q.front(); // 获取队头
q.back(); // 获取队尾
单调队列最常见的用途就是求滑动窗口中的最大值和最小值,也可以用于优化一些动态规划问题。
这次先重点整理滑动窗口最值。
二、滑动窗口中的最大值和最小值
先看一个例子:
text
n = 8, k = 3
数组:
1 3 -1 -3 5 3 6 7
现在有一个长度为 3 的窗口,从左向右移动,每次移动一个位置。
窗口变化过程:
text
[1 3 -1] -3 5 3 6 7
1 [3 -1 -3] 5 3 6 7
1 3 [-1 -3 5] 3 6 7
1 3 -1 [-3 5 3] 6 7
1 3 -1 -3 [5 3 6] 7
1 3 -1 -3 5 [3 6 7]
每次窗口移动,都需要找到其中的最大值和最小值。
如果每次都重新遍历三个数,确实可以得到答案,但当 n 和 k 很大时,这种做法就会产生大量重复计算。
实际上,窗口每次只移动一个位置,前一个窗口中的很多元素仍然会留在下一个窗口中。我们没有必要每次都从头寻找最值。
三、单调队列求窗口最小值
先看最小值。
如果想让队头始终保存当前窗口的最小值,可以维护一个单调递增队列。
这里要特别注意:队列中存储的是数组下标,而不是数组元素的值。
为什么要存下标?
因为窗口不断向右移动,有些元素会离开窗口。如果只保存元素值,就很难判断某个元素是否已经过期。
保存下标后,就可以通过下标判断队列中的元素是否仍然属于当前窗口。
1. 维护单调递增
每次加入一个新元素 a[i] 时:
cpp
while(q.size() && a[q.back()] >= a[i])
q.pop_back();
q.push_back(i);
为什么要把队尾大于等于当前元素的下标弹出?
因为我们现在求的是窗口最小值。
假设队列中有一个旧元素,它的值为 5,而新元素的值为 3。
新元素不仅比旧元素小,而且位置更靠右。对于之后的窗口来说,新元素会比旧元素存在得更久。
所以旧元素已经没有必要继续留在队列中,可以直接从队尾删除。
这样维护下来,队列中的元素值就会保持递增。
2. 删除已经离开窗口的元素
完成入队后,还要检查队头是否已经过期。
当前窗口的右端点为 i,窗口长度为 k,那么左端点就是:i - k + 1
因此,只要队头下标小于窗口左端点,就说明它已经不在当前窗口中了。
cpp
if(i - q.front() + 1 > k)
q.pop_front();
当队列不为空时,队头对应的元素就是当前窗口的最小值。
把这些操作放在一起:
cpp
deque<int> q;
for(int i = 1; i <= n; i++)
{
// 维护递增
while(q.size() && a[q.back()] >= a[i])
q.pop_back();
q.push_back(i);
// 删除已经离开窗口的元素
if(i - q.front() + 1 > k)
q.pop_front();
// 窗口长度达到 k 后,输出最小值
if(i >= k)
cout << a[q.front()] << " ";
}
四、单调队列求窗口最大值
最大值的思路和最小值完全类似,只需要把维护队列单调性的条件反过来。
如果要求最大值,就维护一个单调递减队列。
新元素入队之前,弹出所有小于等于当前元素的队尾元素:
cpp
while(q.size() && a[q.back()] <= a[i])
q.pop_back();
q.push_back(i);
这样队列中的元素值就会保持递减,队头始终是当前窗口中的最大值。
完整的核心代码:
cpp
deque<int> q;
for(int i = 1; i <= n; i++)
{
// 维护递减
while(q.size() && a[q.back()] <= a[i])
q.pop_back();
q.push_back(i);
// 删除已经离开窗口的元素
if(i - q.front() + 1 > k)
q.pop_front();
if(i >= k)
cout << a[q.front()] << " ";
}
所以可以这样记:
- 求窗口最小值,维护单调递增队列,队头就是最小值。
- 求窗口最大值,维护单调递减队列,队头就是最大值。
五、模板题:P1886 滑动窗口
题目链接:洛谷 P1886 滑动窗口
这道题就是刚才介绍的模板题。
给定一个长度为 n 的整数序列,以及一个长度为 k 的窗口。窗口从左向右移动,每次移动一个位置,分别输出每次窗口中的最小值和最大值。
我们可以使用两个单调队列分别完成。
完整代码:
cpp
#include <iostream>
#include <deque>
using namespace std;
const int N = 1e6 + 10;
int n, k;
int a[N];
int main()
{
cin >> n >> k;
for(int i = 1; i <= n; i++) cin >> a[i];
deque<int> q; // 存下标
// 窗口内最小值 - 单调递增的队列 - 存下标
for(int i = 1; i <= n; i++)
{
while(q.size() && a[q.back()] >= a[i]) q.pop_back();
q.push_back(i);
// 判断队列里面元素是否在合法窗口内
if(q.back() - q.front() + 1 > k) q.pop_front();
if(i >= k) cout << a[q.front()] << " ";
}
cout << endl;
// 窗口内最大值 - 单调递减的队列 - 存下标
q.clear();
for(int i = 1; i <= n; i++)
{
while(q.size() && a[q.back()] <= a[i]) q.pop_back();
q.push_back(i);
if(q.back() - q.front() + 1 > k) q.pop_front();
if(i >= k) cout << a[q.front()] << " ";
}
cout << endl;
return 0;
}
输入:
text
8 3
1 3 -1 -3 5 3 6 7
输出:
text
-1 -3 -3 -3 3 3
3 3 5 5 6 7
第一行是每个窗口的最小值,第二行是每个窗口的最大值。
这里有个容易忽略的地方:q.clear() 是为了在第二次求最大值之前清空第一次求最小值所用的队列,否则两次计算的数据会互相影响。
六、例题:P2251 质量检测
题目链接:洛谷 P2251 质量检测
这道题把滑动窗口最小值换成了产品质量检测。
一共有 N 件产品,每件产品都有一个质量分数。现在需要统计连续 M 件产品中,质量最差的那件产品的分数。
也就是对于每个长度为 M 的窗口,求出其中的最小值。
例如:
text
输入:
10 4
16 5 6 9 5 13 14 20 8 12
第一个窗口:
text
16 5 6 9
最小值为 5。
第二个窗口:
text
5 6 9 5
最小值仍然为 5。
之后窗口继续向右移动,每次只需要维护当前窗口的最小值即可。
这道题不需要同时求最大值,只使用一个单调递增队列就够了。
参考代码:
cpp
#include <iostream>
#include <deque>
using namespace std;
const int N = 1e6 + 10;
int n, m;
int a[N];
int main()
{
cin >> n >> m;
deque<int> q; // 单调递增的队列 - 存下标
for(int i = 1; i <= n; i++)
{
cin >> a[i];
while(q.size() && a[q.back()] >= a[i]) q.pop_back();
q.push_back(i);
if(q.back() - q.front() + 1 > m) q.pop_front();
if(i >= m) cout << a[q.front()] << endl;
}
return 0;
}
这个题和 P1886 的最小值部分几乎完全一样。题目的变化只是把窗口最小值作为质量检测结果输出,并且每个结果单独占一行。
做完这两道题后,基本就能掌握单调队列在滑动窗口最值问题中的用法了。
七、单调队列为什么是 O(n)?
代码中同样出现了 while 循环,乍一看好像每次都可能执行很多次。
但实际上,每个下标在整个过程中:
- 最多入队一次;
- 最多从队尾弹出一次;
- 最多从队头弹出一次。
一个元素一旦从队列中删除,就不会再次进入队列。
因此,虽然每轮循环可能弹出多个元素,但所有元素加起来,入队和出队的次数都是线性的。
所以整个滑动窗口算法的时间复杂度是:O(n)
空间复杂度也是:O(k)
因为队列只需要保存当前窗口内有可能成为最值的下标。
八、总结
单调队列和普通队列最大的区别,就是它在维护队头、队尾的同时,还要保证队列中元素满足单调性。
解决滑动窗口最值问题时,我觉得最重要的就是三个操作:
- 从队尾删除不可能成为答案的元素,维护单调性。
- 将当前元素的下标加入队尾。
- 从队头删除已经离开窗口的元素。
最后,队头就是当前窗口的最值。
这类题目看起来需要不断更新窗口,但实际上并不需要每次重新遍历整个窗口。单调队列会提前把没有必要保留的元素删除掉,只留下可能成为答案的元素。