NeFut Logo NeFut
EN 管理员登录

高效区间最值维护:单调队列的核心原理与实现指南

发布于:2026-05-27 09:17 最后更新:2026-06-21 04:08
#C++ #Tutorial

核心逻辑与数学原理

以求解经典的“滑动窗口最大值(Sliding Window Maximum)”为例。 定义一个长度为 $N$ 的序列 $A$,窗口大小为 $K$。随着窗口右端点 $i$ 从 $1$ 到 $N$ 线性扫描,需要动态获取当前有效窗口 $[i - K + 1, i]$ 内的最大值。

单调队列(Monotonic Queue)作为一种维护区间最值的线性数据结构,其核心思想在于利用时序与大小的双重单调性,及时排除冗余决策。 总时间复杂度从原本暴力解法的 $O(N \cdot K)$ 优化至 $O(N)$

在单调队列中,核心的淘汰逻辑可以总结为“青年才俊原则”:

“随着窗口右端点 i 的线性扫描,队列里存放的是有资格竞争未来最值的‘候选决策点’(下标)。 新元素入队时,会触发无情的淘汰机制。 如果一个留在队中的老决策,生得比你早(下标小,意味着生存期剩得短),长得还没你帅(数值不比你优秀),那么在它仅剩的生命周期里,由于你的存在,它永远不可能成为区间最值。 这个“既老又弱”的决策就沦为了绝对冗余,必须立即被无情剔除。

基于这一原则,新元素入队时会从队尾掀起连续的“弹队”风暴,直到队列恢复“下标严格递增、数值严格递减”的双重单调性。 在该机制下,每一个下标元素一辈子“至多入队一次,至多出队一次”,因此其单步操作在全局视角的均摊复杂度为严格的 $O(1)$。


状态设计与对偶变体

单调队列一律存储元素下标。因为下标不仅能通过 $A[\text{index}]$ 索引数值,还能通过 $i - \text{index} \ge K$ 瞬间判定该决策是否因生命周期终结而失效。

通过微调队尾弹栈符号,可以任意切换求区间最大值或最小值:

目标问题 队头输出的意义 队内数值单调性 队尾去劣弹队条件 冗余决策淘汰本质
区间最大值 当前窗口内的最大值 严格递减(大 $\to$ 小) A[q[tail]] <= A[i] 弹掉位置靠前且数值偏小(或相等)的弱势决策
区间最小值 当前窗口内的最小值 严格递增(小 $\to$ 大) A[q[tail]] >= A[i] 弹掉位置靠前且数值偏大(或相等)的弱势决策

核心算法模板

// 核心逻辑:单调队列维护滑动窗口最值核心片段(以区间最大值为例)
// q[] 模拟双端队列存储下标,head = 1, tail = 0 初始化为空队列
int head = 1, tail = 0;
for (int i = 1; i <= n; ++i) {
    // 1. 队头合法性检查:弹出离开滑动窗口边界的失效元素
    if (head <= tail && q[head] <= i - k) {
        head++;
    }
    // 2. 队尾单调性维护:剔除数值无优势且位置靠前的冗余决策
    while (head <= tail && a[q[tail]] <= a[i]) {
        tail--;
    }
    // 3. 当前新决策入队(延续其生存期潜力)
    q[++tail] = i;

    // 4. 业务结算:当窗口完全成型(i >= k)时,队头即为当前窗口最值
    if (i >= k) {
        ans[i] = a[q[head]];
    }
}

队内状态不变性证明(Invariant Proof)

区间最大值(维护单调递减队列)为例。 设在任意扫描时刻,队列内从队头到队尾存储的元素下标依次为 $q[\text{head}], q[\text{head}+1], \dots, q[\text{tail}]$。我们通过数学归纳法证明该结构死锁了以下两个状态不变性:

  1. 初始状态:遍历前队列为空,不变性显然成立。当扫描到第一个元素 $i=1$ 时,入队后仅有一项,两个不变性依然成立。
  2. 递推(状态转移):假设当前队列满足上述不变性,此时新元素 $i$(必有 $i > q[\text{tail}]$)到来:

NOIP 实战避坑指南

数组模拟双端队列的指针初始化

手写数组模拟双端队列时,常用的初始化格式为 int head = 1, tail = 0;

严格最值去劣中的“等于号”符号陷阱

在队尾单调性维护时,条件究竟用 > 还是 >=? 在标准滑动窗口中,强烈建议使用带有等于号的 a[q[tail]] <= a[i](求最大值)或 a[q[tail]] >= a[i](求最小值)

教练一句话点睛: “如果两个元素数值完全相同,后来的元素 $i$ 无论如何都比先来的 $q[\text{tail}]$ 拥有更长的‘寿命’(更晚断气)。把相等的旧决策弹掉,用新决策替代,能让队列规模更精简,并极大程度延长最值候选的时间线,防止冗余元素堆积。”

规避 std::deque 的巨大常数

NOIP 极其看重运行效率。标准库中的 std::deque 并非采用连续内存存储,其内部涉及指针重载与高频的动态内存分配,常数巨大。在 $10^6$ 级别的数据量下,手写数组 q[MAXN] 配合 head++tail-- 操作没有一点额外开销,运行效率比 STL 稳步提升 3 倍以上。


经典真题讲解

1. 洛谷 P1886 滑动窗口 /【模板】单调队列

核心算法实现

// 核心逻辑:单调队列双向扫描基准线
// 求最小值:维护严格单调递增队列
int head = 1, tail = 0;
for (int i = 1; i <= n; ++i) {
    if (head <= tail && q[head] <= i - k) head++;
    while (head <= tail && a[q[tail]] >= a[i]) tail--; // 剔除大值
    q[++tail] = i;
    if (i >= k) min_ans[i] = a[q[head]];
}

// 求最大值:维护严格单调递减队列
head = 1, tail = 0;
for (int i = 1; i <= n; ++i) {
    if (head <= tail && q[head] <= i - k) head++;
    while (head <= tail && a[q[tail]] <= a[i]) tail--; // 剔除小值
    q[++tail] = i;
    if (i >= k) max_ans[i] = a[q[head]];
}

2. 洛谷 P2216 [HAOI2007] 理想正方形

核心算法实现

// 核心逻辑:以计算二维最大值为例,row_max[i][j] 表示第 i 行闭区间 [j-n+1, j] 的最大值
// 1. 横向扫描:处理每一行的一维窗口
for (int i = 1; i <= a; ++i) {
    int head = 1, tail = 0;
    for (int j = 1; j <= b; ++j) {
        if (head <= tail && q[head] <= j - n) head++;
        while (head <= tail && mat[i][q[tail]] <= mat[i][j]) tail--;
        q[++tail] = j;
        if (j >= n) row_max[i][j] = mat[i][q[head]];
    }
}

// 2. 纵向扫描:在行最值矩阵的基础上处理每一列的一维窗口
int final_ans = 2e9; 
for (int j = n; j <= b; ++j) { // 仅处理有效的纵向列
    int head = 1, tail = 0;
    for (int i = 1; i <= a; ++i) {
        if (head <= tail && q[head] <= i - n) head++;
        while (head <= tail && row_max[q[tail]][j] <= row_max[i][j]) tail--;
        q[++tail] = i;
        if (i >= n) g_max[i][j] = row_max[q[head]][j]; // 此时已获得二维正方形内的最大值
    }
}
// 最小值处理同理,最后遍历所有合法的 (i, j) 刷新全局最小差值:final_ans = min(final_ans, g_max[i][j] - g_min[i][j])

[h] 返回首页