NeFut Logo NeFut
EN 管理员登录

高效单调栈与单调队列的核心原理与应用解析

发布于:2026-05-27 09:17 最后更新:2026-06-06 13:04
#algorithm #Data Structure #C++

核心逻辑与数学原理

单调栈与单调队列是维护元素单调性的线性数据结构。其核心物理模型是动态转折点剔除生存期淘汰

在单调栈中,新元素的加入会破坏原有的单调性。若要求栈内元素单调递增,当新元素 $x$ 小于栈顶元素时,栈顶元素在后续的比较中已被 $x$ 完全遮挡(或替代),失去作为最优解的可能,必须立即弹出。每个元素至多入栈一次、出栈一次,均摊时间复杂度为 $O(N)$。

在单调队列中,引入了“生存期”概念,即滑动窗口的边界约束。单调队列维护的是一个双端队列,其核心物理模型是区间最值(RMQ)的动态演进。对于两个元素 $A[i]$ 和 $A[j]$,若 $i < j$ 且 $A[i] ext{ ≤ } A[j]$,在窗口向右滑动时,$A[i]$ 无论在大小还是生存期上都已被 $A[j]$ 支配。因此,$A[i]$ 成为无用冗余,需从队尾剔除。同时,队头元素若超出窗口左边界 $L = i - K + 1$,则因过期而从队头弹出。


状态设计与算法推导

1. 单调栈:下一个更大元素(Next Greater Element)

设输入序列为 $A$,目标是求解每个位置 $i$ 右侧第一个大于 $A[i]$ 的元素下标 $R[i]$。

$$R[ ext{stk.top()}] = i$$

执行弹出操作 $ ext{stk.pop()}$,重复此过程直到栈空或 $A[i] ext{ ≤ } A[ ext{stk.top()}]$。最后将 $i$ 压入栈中。

2. 单调队列:滑动窗口最值

设窗口大小为 $K$,求解每个位置 $i$ 结束的窗口内的最大值。

  1. 队头去过期:当前下标为 $i$,若 $ ext{q.front()} < i - K + 1$,说明队头最值已滑出窗口,执行 q.pop_front()
  2. 队尾去冗余:当队列不为空且 $A[i] ext{ ≥ } A[ ext{q.back()}]$ 时,队尾元素由于值较小且生存期更短,沦为死节点,执行 q.pop_back()
  3. 入队与决策:将 $i$ 压入队尾。当 $i ext{ ≥ } K - 1$ 时,当前窗口最大值即为 $A[ ext{q.front()}]$。

C++ 标准源码

#include <iostream>
#include <vector>
#include <deque>

using namespace std;

// 模拟洛谷 P5788 【模板】单调栈
void solveMonotoneStack() {
    int n;
    if (!(cin >> n)) return;

    vector<int> a(n + 1);
    vector<int> r(n + 1, 0);
    vector<int> stk; 
    stk.reserve(n + 1); // 预分配内存,规避 vector 动态扩容带来的常数开销

    for (int i = 1; i <= n; ++i) {
        cin >> a[i];
    }

    for (int i = 1; i <= n; ++i) {
        // 关键点:严格大于时触发弹出,栈内存储下标而非具体数值
        while (!stk.empty() && a[i] > a[stk.back()]) {
            r[stk.back()] = i;
            stk.pop_back();
        }
        stk.push_back(i);
    }

    for (int i = 1; i <= n; ++i) {
        cout << r[i] << (i == n ? "" : " ");
    }
    cout << "\n";
}

// 模拟洛谷 P1886 滑动窗口 /【模板】单调队列
void solveMonotoneQueue() {
    int n, k;
    if (!(cin >> n >> k)) return;

    vector<int> a(n);
    for (int i = 0; i < n; ++i) {
        cin >> a[i];
    }

    deque<int> q; // 存储下标

    // 1. 求区间最小值
    for (int i = 0; i < n; ++i) {
        // 队头检查:剔除超出滑动窗口左边界的过期下标
        if (!q.empty() && q.front() < i - k + 1) {
            q.pop_front();
        }
        // 队尾检查:新元素更小,老元素既大又旧,直接淘汰
        while (!q.empty() && a[i] <= a[q.back()]) {
            q.pop_back();
        }
        q.push_back(i);

        if (i >= k - 1) {
            cout << a[q.front()] << (i == n - 1 ? "" : " ");
        }
    }
    cout << "\n";
}

int main() {
    // 优化标准流输入输出性能,禁止与 stdio 同步,避免被卡常数
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    // 根据评测目标切换调用,此处仅作演示
    solveMonotoneStack();

    return 0;
}

NOIP 实战避坑指南

  1. 单调队列队头越界与死循环 在处理滑动窗口移动时,初学者常错写为 if (q.front() == i - k)。这种精确匹配在处理非步长为 1 的动态区间移动,或者中途存在非标数据跳跃时会导致漏判,使队列中残留已过期的非法下标,引发逻辑错误甚至数组越界访问。正确做法是使用严格不等式判断:`if (q.front() < i - k + 1)。此外,在使用while弹出队尾时,必须先检查!q.empty(),否则在空队列上调用back()` 会导致 Undefined Behavior(段错误或死循环)。
  2. 空间开销与下标错位 单调栈、单调队列存储的一定是下标而非权值本身。若错误地存储了权值,将无法进行生存期检查(无法获知该元素是否滑出窗口),也无法精确定位下一个更大元素的位置。单调栈数组大小必须与原序列大小 $N$ 严格一致。在 NOIP/Linux 环境下,若使用标准 std::deque,其内部内存块分段分配,带有较大的常数时间开销。在时限较紧(如 1.0s 内处理 $10^6$ 数据)的题目中,应当改用数组模拟双端队列(用 headtail 指针维护),以达到极致的 O2 编译优化表现。

经典 NOIP/洛谷 真题

1. 洛谷 P2866 [USACO06NOV] Bad Hair Day S

2. 洛谷 P1419 寻找段落

原文链接: local://1.5

[h] 返回首页