算法学习——单调队列

单调栈主要用来处理最近更大或更小元素的问题,而单调队列则经常出现在滑动窗口最值问题中。

比如给定一个数组和一个长度为 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)

因为队列只需要保存当前窗口内有可能成为最值的下标。

八、总结

单调队列和普通队列最大的区别,就是它在维护队头、队尾的同时,还要保证队列中元素满足单调性。

解决滑动窗口最值问题时,我觉得最重要的就是三个操作:

  1. 从队尾删除不可能成为答案的元素,维护单调性。
  2. 将当前元素的下标加入队尾。
  3. 从队头删除已经离开窗口的元素。

最后,队头就是当前窗口的最值。

这类题目看起来需要不断更新窗口,但实际上并不需要每次重新遍历整个窗口。单调队列会提前把没有必要保留的元素删除掉,只留下可能成为答案的元素。

相关推荐
我就是全世界1 小时前
OpenCode Go 套餐划算吗:先看你用哪个模型
开发语言·后端·golang
螺丝钉tina1 小时前
[Playwright] [02]常用命令与调试:Debug UI、Mode和 Trace Viewer
开发语言·javascript·测试工具·ui·自动化
杜 硕2 小时前
从零开始的C++生活——第六讲——01
开发语言·c++·生活
淡漠的蓝精灵2 小时前
web.py:一个简单到极致的 Python Web 框架
开发语言·前端·python·其他
乌暮2 小时前
请求进门先安检:Spring 拦截器与统一功能实战指南
java·开发语言·后端·学习·spring
Zhao-Jichao2 小时前
AI Prompting for Everyone 课程笔记 - 05 使用深度搜索 Using Deep Research
人工智能·笔记
Js.Tx2 小时前
【具体数学 Concrete Mathematics】2.1.5 一般性的方法
算法·具体数学
起个名字费劲死了3 小时前
Halcon学习一:骰子识别
前端·python·学习
এ慕ོ冬℘゜3 小时前
JS 事件循环、宏任务微任务:单线程底层原理 + 执行顺序
开发语言·javascript·ecmascript