单调队列
✨ 引入¶
在学习单调队列前,让我们先来看一道例题。
📌 题意: 给定长度为 $n$ 的数组 ${a_i}$,窗口大小为 $k$,对于每个位置 $i$,求区间 $[i,\,i+k-1]$ 上的最大值。
🔒 约束: $n \le 10^6,\ 1 \le k \le n$
💡 最暴力的想法很简单,对于每一段 $i \sim i+k-1$ 的序列,逐个比较来找出最大值(和最小值),时间复杂度约为 $O(n \times k)$。
很显然,这其中进行了大量重复工作,除了开头 $k-1$ 个和结尾 $k-1$ 个数之外,每个数都进行了 $k$ 次比较,而题中 $100\%$ 的数据为 $n \le 10^6$,当 $k$ 稍大的情况下,显然会 TLE。
🎯 这时所用到的就是「单调队列」了。
🧐 介绍¶
单调队列 是一种特殊的双端队列,满足以下两个条件:
- 🔁 支持从两端插入和删除元素(使用
deque
实现) - 📈 队列中元素始终保持单调递增或单调递减
📦 单调队列常用于处理如下问题:
- 🪟 滑动窗口最大值 / 最小值
- 🧩 动态维护某区间内最优解
- 🔄 具有「滑动窗口」性质的问题
本节我们将以「滑动窗口最小值」问题为例,讲解单调队列的使用方式。
🧹 解题思路¶
🧱 单调队列的核心思想¶
队列保存 候选最小值,并保持队列内元素 单调递增。(从队头向队尾单调递曾)
对每个新元素 $a_i$,执行三步:
- 🕒 过期判断:若队首索引 $< i-k+1$,则出队。
- ❌ 剔除冗余:从队尾开始,将所有 $> a_i$ 的元素弹出。
- ➕ 入队:将当前元素位置 $i$ 入队。
📊 操作过程示例¶
原序列为:1 3 -1 -3 5 3 6 7
。
因为我们始终要维护队列保证其 递增 的特点,所以会有如下的事情发生:
操作 | 队列状态 |
---|---|
$1$ 入队 | {1} |
$3$ 比 $1$ 大,$3$ 入队 | {1 3} |
$-1$ 比队列中所有元素小,所以清空队列 $-1$ 入队 | {-1} |
$-3$ 比队列中所有元素小,所以清空队列 $-3$ 入队 | {-3} |
$5$ 比 $-3$ 大,直接入队 | {-3 5} |
$3$ 比 $5$ 小,$5$ 出队,$3$ 入队 | {-3 3} |
$-3$ 已经在窗体外,所以 $-3$ 出队;$6$ 比 $3$ 大,$6$ 入队 | {3 6} |
$7$ 比 $6$ 大,$7$ 入队 | {3 6 7} |
🛠️ 具体实现¶
以维护最小值为例
维护一个双端队列 $q[1\ldots n]$,用两个指针 $h,t$ 表示当前队首、队尾:
- $h=1,t=0$
当 $h>t$ 时,队列为空。
每步处理新元素 $a_i$(窗口大小 $k$):
- ${\bf 删除过期元素}$:若 $h\le t$ 且 $q_h<i-k+1$,则 $\text{h++}$.
- 这意味着队头元素不可能是 $i$ 所在长度为 $k$ 的窗口内的最小值。
- ${\bf 保持单调}$:当 $h\le t$ 且 $a_i< a_{q_t}$,则 $\text{t--}$.
- 这意味着和 $i$ 有关的长度为 $k$ 的窗口,绝对轮不到 $a_{q_t}$ 当最小值。
- ${\bf 入队}$:令 $\text{q[++t]=i}$.
- ${\bf 输出答案}$:若 $i\ge k$,则当前窗口最小值为 $a_{q_h}$.
⏱️ 每个元素最多入队和出队一次,总复杂度为 $O(n)$。
int h = 1, t = 0;
for (int i = 1; i <= n; i++)
{
while (h <= t && i - q[h] + 1 > k) h++; // 队首和 i 的距离超过 k 队首离开
while (h <= t && a[i] < a[q[t]]) t--; // 新的 a[i] 小于队尾,队尾则不可能成为最小值直接离开
q[++t] = i;
if (i >= k)
{
cout << a[q[h]] << " ";
}
}
🧪 经典例题¶
🧮 例题一:最大连续和¶
🔢 题意: 给定长度为 $n$ 的数列 ${a_i}$ 和最大子段长度 $m$,求所有满足 $1\le j\leq i\le n$ 且 $i-j+1\le m$ 的非空子段的最大和:
数据范围:$1 \le n \le 10^6$,$–10^9 \le a_i \le 10^9$,$1 \le m \le n$
📐 转化思路:
定义前缀和
则任意子段 $j\ldots i$ 的和为 $s_i - s_{j-1}$
因此题目可转化为
即就是
此时可以枚举每个 $i$,即在 $i$ 之前找到一个 $j$ 满足 $1\leq i-j\leq m$ 且 $s_i-s_j$ 最大。
- 枚举 $i$ 固定 $s_i$,若希望 $s_i-s_j$ 最大,则 $s_j$ 最小。
相当于在 滑动窗口 $[\,i-m,\,i-1]$ 上维护前缀和的最小值。
具体实现
-
初始化:
- $q[1] = 0,\quad h = 1,\; t = 1$
- 初始将 $s_0$ 进行入队。因此初始化 $h=t=1$
-
删除过期:
- 队首元素 $q[h]$ 是否过期?
- 若 $q[h] < i - m$, $\text{h++}$
- 更新答案:
- 对每个 $i$,在删除过期后:
- $\mathrm{ans} = \max\bigl(\mathrm{ans},\;s_i - s_{q[h]}\bigr)$
- 单调维护:
- 如果当前前缀和更小,就把队尾较大的 剪掉:
- 若 $h\le t$ 且 $s_i \le s_{q[t]}$,$t-1$
- 入队当前下标:
- $\text{q[++t] = i.}$
代码
// 初始化 h = t 保证队列有一个 0,即 s[0] 入队
int h = 1, t = 1, ans = -1e9;
for (int i = 1; i <= n; i++)
{
while (h <= t && i - q[h] > m) h++;
ans = max(ans, s[i] - s[q[h]]); // 入队之前更新答案防止空序列
while (h <= t && s[i] < s[q[t]]) t--;
q[++t] = i;
}
🐄 例题二:[USACO13NOV] Crowded Cows S¶
📌 题意:
有 $N$ 头奶牛 ($1\le N\le5\times10^4$),第 $i$ 头奶牛位于一维坐标 $x_i$,身高 $h_i$。
如果在其 $\pm D$ 范围内($1\le D\le10^9$)同时存在身高 $\ge2h_i$ 的左右两头奶牛,则它会觉得 拥挤。
求 觉得拥挤 的奶牛数量。
📈 策略
- 首先预处理:
- 将所有奶牛按 $x_i$ 从小到大排序,统一为 $(x_1,h_1),\dots,(x_N,h_N)$。
要判断第 $i$ 头牛左右两侧各有牛的身高 $\ge 2h_i$:
- 左侧判断:维护一个窗口 $[\,x_i-D,\,x_i)$ 上的身高最大值 $\displaystyle M_L$。
- 若 $M_L\ge2h_i$,则左侧成立。
- 右侧判断:同理,对窗口 $(x_i,\,x_i+D]$ 维护最大值 $M_R$
$\Rightarrow$ 只需两次单调队列扫描,$\mathcal O(N)$ 时间。
具体实现:
左侧扫描:维护窗口最大身高
- 使用双端队列 q 存储索引,队内对应的 $h$ 单调递减。
- 扫描 $i=1\ldots N$:
- 删除过期:队首索引 $q[h]$ 若满足 $x_i - x[q[h]] > D$,则出队。
- 查询:若队非空且 队首身高 $\ge 2h_i$,则 $i$ 左侧成立(数组打标记)。
- 入队前维护单调:当队尾索引 $i$ 满足 $h_i\geq h_{q[t]}$,则队尾出队。
- 入队当前:$\text{q[++t] = i}$。
右侧扫描与统计答案
- 对 $i=N\ldots1$ 倒序同样维护窗口 $(x_i, x_i+D]$,打上右侧的标记。
- 最终答案:$\sum\limits_{i=1}^N [\,f[i]=1\land g[i]=1\,]$。
- 具体实现可以借助结构体排序,整体时间复杂度为:$O(n\log{n})$。
int h = 1, t = 0;
for (int i = 1; i <= n; i++)
{
while (h <= t && a[i].x - a[q[h]].x > d) h++;
while (h <= t && a[i].y > a[q[t]].y) t--;
q[++t] = i;
if (a[q[h]].y >= a[i].y * 2) f[i] = 1;
}
h = 1, t = 0;
for (int i = n; i >= 1; i--)
{
while (h <= t && a[q[h]].x - a[i].x > d) h++;
while (h <= t && a[i].y > a[q[t]].y) t--;
q[++t] = i;
if (a[q[h]].y >= a[i].y * 2) g[i] = 1;
}
🟫 例题三:[HAOI2007] 理想的正方形¶
📐 题意:
给定一个 $a\times b$ 的整数组成矩阵,求所有 $n\times n$ 正方形子矩阵中
$\max(\text{区间值}) - \min(\text{区间值})$ 的最小值。
约束:$2\le a,b\le1000,\ 1\le n\le\min(a,b)$
🚫 暴力解法:
- 枚举所有左上角 $(i,j)$(共 $(a-n+1)(b-n+1)$ 个),对应子矩阵大小 $n\times n$。
- 内部再扫描 $n^2$ 个元素,求最大/最小。
- 总时间:$\displaystyle O\bigl((a-n+1)(b-n+1)\times n^2\bigr)\approx O(abn^2)$,当 $a,b,n\sim10^3$ 时最坏 $10^{12}$ 次操作,必然超时。
🧮 二维单调队列:
-
行方向预处理: 对每一行,用单调队列,
- 计算该行上每个长度-$n$ 区间的 $\max,\min$,生成 两个矩阵 $\mathrm{rowMax}[i][j],\,\mathrm{rowMin}[i][j]$ (大小 $a\times (b-n+1)$)。
-
列方向滑窗: 对每一列 $j\in[1,b-n+1]$,再用单调队列,
- 在 $\mathrm{rowMax}[\ast][j]$ 上计算每个垂直区间的最大值$\max_{k=i}^{i+n-1}\mathrm{rowMax}[k][j]$,
同理在 $\mathrm{rowMin}$ 上计算最小值。生成 两个矩阵 $\mathrm{colMax}[i][j],\,\mathrm{colMin}[i][j]$ (大小 $(a-n+1)\times (b-n+1)$)。
- 在 $\mathrm{rowMax}[\ast][j]$ 上计算每个垂直区间的最大值$\max_{k=i}^{i+n-1}\mathrm{rowMax}[k][j]$,
-
对每个起点 $(i,j)$,取 $\Delta_{i,j}=\text{colMax}[i][j]-\text{colMin}[i][j]$,
- 最后答案 $\min_{i,j}\Delta_{i,j}$。
📷 可视化图:
-
使用单调队列求出每一行长度为 $n$ 的区间的最大值,如下所示。
xmx[i][j]
的值为原矩阵中所有 $1\times n$ 矩阵的最大值。
- 在对
xmx
数组按照列做一遍单调队列,求出所有 $n\times 1$ 的最大值如下所示。ymx[i][j]
的值为xmx
矩阵中所有 $n\times 1$ 矩阵的最大值,而 xmx 矩阵的值又是原矩阵 A 中所有 $1\times n$ 矩阵的最大值,那么就相当于是原本矩阵中所有 $n\times n$ 的最大值。
代码
#include <bits/stdc++.h>
#define int long long
using namespace std;
const int N = 1e3 + 5;
int a, b, n, mat[N][N], xmx[N][N], ymx[N][N], q[N];
int xmn[N][N], ymn[N][N], ans = 2e9;
signed main()
{
ios::sync_with_stdio(false), cin.tie(0);
cin >> a >> b >> n;
for (int i = 1; i <= a; i++)
for (int j = 1; j <= b; j++)
cin >> mat[i][j];
for (int i = 1; i <= a; i++)
{
int h = 1, t = 0;
for (int j = 1; j <= b; j++)
{
while (h <= t && q[h] < j - n + 1) h++;
while (h <= t && mat[i][j] > mat[i][q[t]]) t--;
q[++t] = j;
if (j >= n)
{
xmx[i][j - n + 1] = mat[i][q[h]];
}
}
}
for (int j = 1; j <= b - n + 1; j++)
{
int h = 1, t = 0;
for (int i = 1; i <= a; i++)
{
while (h <= t && q[h] < i - n + 1) h++;
while (h <= t && xmx[i][j] > xmx[q[t]][j]) t--;
q[++t] = i;
if (i >= n)
{
ymx[i - n + 1][j] = xmx[q[h]][j];
}
}
}
for (int i = 1; i <= a; i++)
{
int h = 1, t = 0;
for (int j = 1; j <= b; j++)
{
while (h <= t && q[h] < j - n + 1) h++;
while (h <= t && mat[i][j] < mat[i][q[t]]) t--;
q[++t] = j;
if (j >= n)
{
xmn[i][j - n + 1] = mat[i][q[h]];
}
}
}
for (int j = 1; j <= b - n + 1; j++)
{
int h = 1, t = 0;
for (int i = 1; i <= a; i++)
{
while (h <= t && q[h] < i - n + 1) h++;
while (h <= t && xmn[i][j] < xmn[q[t]][j]) t--;
q[++t] = i;
if (i >= n)
{
ymn[i - n + 1][j] = xmn[q[h]][j];
}
}
}
for (int i = 1; i <= a - n + 1; i++)
{
for (int j = 1; j <= b - n + 1; j++)
{
ans = min(ans, ymx[i][j] - ymn[i][j]);
}
}
cout << ans;
return 0;
}
时间复杂度分析
- 行预处理:每行 $\ O(b)$,共 $a$ 行,$O(ab)$。
- 列滑窗:每列 $\ O(a)$,共 $b-n+1$ 列,$O(ab)$。
- 总时间:$\boxed{O(ab)}$,可以处理 $10^6$ 量级数据。
- 空间:额外存储两个 $a\times(b-n+1)$ 矩阵,$O(ab)$。