【模板】单调队列 / 滑动窗口
用两个单调队列在线维护窗口的最小值与最大值;另附 FHQ-Treap 和 multiset 的对照实现。
启发记录: 单调队列的经典模板题,能清楚理解如何淘汰既更差又更早过期的候选。多解: multiset
OJ: luogu
题目 ID: P1886
难度:普及
标签:单调队列队列模板题Treap集合pythoncpp
日期: 2026-06-18 14:57
目录
形式化题目
给定长度为
窗口之间的重叠部分是固定的:相邻两个窗口只差「删掉最左边一个元素、加入最右边一个元素」这一步。所有做法都围绕这一点展开。
解法总览
三种解法都能独立完成本题,前一种是本题的正统做法,后两种都是「把窗口当成有序多重集」这一模型的实现:
- 解法一(
main.cpp,正式主解):单调队列。抓住「值更差且更早过期的候选永远没用」这一性质,把窗口里还有可能成为答案的候选压成一个单调队列,队头就是答案,整体。 - 解法二(
main-fhq.py):把窗口当成一个有序多重集(multiset),直接维护「第 1 小」和「第小」。每次右移就删除一个出窗元素、插入一个入窗元素,再用 FHQ-Treap 查询第 小。复杂度是 ,练习价值在于「插入 / 删除 / 查询第 小」这三个接口。 - 解法三(
main-multiset.cpp):和解法二完全相同的模型,但直接使用 C++ 标准库的std::multiset,不手写平衡树,是这道题在 C++ 里最省事的写法。
也就是说,解法一是本题的正统做法,解法二、三是拿本题当有序多重集的练习场:解法三用现成容器,解法二手写结构。想看更系统的基础讲解,可以参考 rbook 里的《单调队列》: https://rbook2.roj.ac.cn/data_structure/monotonic_queue/index.html
解法一:单调队列
思路
先看最直接的办法:对每个窗口都重新扫描其中的 k 个元素,分别求最小值和最大值。
这个暴力版本很直观:
#include <bits/stdc++.h>
using namespace std;
const int maxn = 1000000 + 5;
int n, k;
int a[maxn]; // 输入序列,使用 1-based 下标。
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> n >> k;
for (int i = 1; i <= n; i++) {
cin >> a[i];
}
// 枚举长度为 k 的每个连续窗口,l 表示窗口左端点。
for (int l = 1; l + k - 1 <= n; l++) {
int mn = a[l]; // 先用窗口第一个元素初始化最小值。
// 逐个检查当前窗口中的元素,暴力求出最小值。
for (int i = l; i < l + k; i++) {
mn = min(mn, a[i]);
}
// 第一个答案前不输出空格,其余答案前输出一个空格。
if (l > 1) {
cout << ' ';
}
cout << mn;
}
cout << '\n';
// 再次枚举所有窗口,暴力求出每个窗口的最大值。
for (int l = 1; l + k - 1 <= n; l++) {
int mx = a[l]; // 先用窗口第一个元素初始化最大值。
for (int i = l; i < l + k; i++) {
mx = max(mx, a[i]);
}
if (l > 1) {
cout << ' ';
}
cout << mx;
}
cout << '\n';
return 0;
}但它的复杂度是
这题的关键观察是:
- 如果一个新元素更小,那么队尾那些更大或相等、而且更早进入窗口的元素,以后都不可能再成为最小值;
- 如果一个新元素更大,那么队尾那些更小或相等、而且更早进入窗口的元素,以后都不可能再成为最大值。
所以我们可以分别维护两个存“下标”的单调队列:
qmin:值递增,队头是当前窗口最小值下标;qmax:值递减,队头是当前窗口最大值下标。
每次处理位置 i 时:
- 先把所有已经不在窗口中的下标从队头删掉;
- 再从队尾删掉所有不可能成为未来答案的候选;
- 把当前下标
i入队; - 当
时,队头就是当前窗口答案。
C++ 实现
qmin、qmax保存下标,并用head、tail模拟双端队列;这样不会产生节点对象,适合的数据范围。 - 队列里的下标严格递增;最小值队列对应的值非降,最大值队列对应的值非升。
- 两行答案分别暂存到静态数组,最后以空格分隔输出。
代码
/**
* Author by Rainboy blog: https://rainboylv.com github: https://github.com/rainboylvx
* rbook: -> https://rbook.roj.ac.cn https://rbook2.roj.ac.cn
* rainboy的学习导航网站: https://idx.roj.ac.cn
* create_at: 2026-06-19 22:27
* update_at: 2026-09-14 17:00
*/
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 1000000 + 5;
int n, k;
int a[MAXN]; // 输入序列,使用 1-based 下标。
deque<int> qmin, qmax; // 分别保存求最小值、最大值时的下标。
int ans_min[MAXN], ans_max[MAXN]; // 每个窗口的最小值和最大值。
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> n >> k;
for (int i = 1; i <= n; i++) {
cin >> a[i];
}
// 双端队列中保存的是下标,不是元素值。
// qmin 对应的 a[下标] 递增,qmax 对应的 a[下标] 递减。
int answer_count = 0; // 已经得到的窗口数量。
for (int i = 1; i <= n; i++) {
// 当前窗口是 [i - k + 1, i]。
// 下标 <= i - k 的元素已经滑出窗口,从队头删除。
while (!qmin.empty() && qmin.front() <= i - k) {
qmin.pop_front();
}
// 保持队列中的 a[下标] 递增。
// 新元素更小,则队尾较大的元素以后不可能成为最小值,可以删除。
while (!qmin.empty() && a[qmin.back()] >= a[i]) {
qmin.pop_back();
}
qmin.push_back(i);
// 同样维护一个递减队列,用来查询当前窗口的最大值。
while (!qmax.empty() && qmax.front() <= i - k) {
qmax.pop_front();
}
// 保持队列中的 a[下标] 递减。
// 新元素更大,则队尾较小的元素以后不可能成为最大值,可以删除。
while (!qmax.empty() && a[qmax.back()] <= a[i]) {
qmax.pop_back();
}
qmax.push_back(i);
// i >= k 时,第一个长度为 k 的窗口已经形成。
// 单调队列的队头分别就是当前窗口最小值、最大值所在的下标。
if (i >= k) {
answer_count++;
ans_min[answer_count] = a[qmin.front()];
ans_max[answer_count] = a[qmax.front()];
}
}
// 第一行输出所有窗口的最小值。
for (int i = 1; i <= answer_count; i++) {
if (i > 1) {
cout << ' ';
}
cout << ans_min[i];
}
cout << '\n';
// 第二行输出所有窗口的最大值。
for (int i = 1; i <= answer_count; i++) {
if (i > 1) {
cout << ' ';
}
cout << ans_max[i];
}
cout << '\n';
return 0;
}复杂度
- 时间复杂度:
(每个下标最多进队一次、出队一次) - 空间复杂度:
解法二:FHQ-Treap 有序多重集
思路
就是把fhq-treap 当成 multiset 来使用
换一个模型:窗口里的元素就是一个可重集合,我们只需要它能回答两个问题——「最小值是多少」和「最大值是多少」,也就是第
窗口 [l, l+k-1] → 窗口 [l+1, l+k]
删除 a[l](出窗) 插入 a[l+k](入窗)于是整道题退化成三个基本操作:插入、删除一个值、查询第 k 小。FHQ-Treap(无旋 Treap)正是实现这种有序多重集的常用结构,它只用两个核心操作就能拼出全部接口:
split(u, v):按值把树切成 (<= v)与(> v)两棵,递归下去后顺着原路径改接儿子并更新子树大小;merge(x, y):要求中所有值都不大于 中所有值,比较两棵根的随机优先级,优先级大的当父节点,继续递归合并。
为什么树高是 merge 只在两者之间比大小,因此树形等价于对节点随机建堆,期望高度为
为什么第 size,从根往下走:若
本题的实现直接复用了 P3369 的 FHQ-Treap 模板:Node + FHQTreap 两部分逐字符相同,本题只在文件末尾追加了 solve() 与 main(),只调用 insert / delete / kth 三个接口。
- 节点是
__slots__ = ("val", "pri", "size", "l", "r")的轻量对象,size记录子树大小,l/r是左右儿子; split/merge是模板里的两个核心,insert与delete都由它们拼出;kth从根往下走,比较与左子树大小决定去向,是标准的 BST 定位。
因为题目保证删除的元素一定在窗口内,delete 一定能命中,不需要处理「删除不存在的值」。
注意模板与本题的规模不匹配。 这是通用写法(对象节点 + 递归 + 三段分裂删除),常数较大。实测 main.cpp。
Python 知识
sys.setrecursionlimit(300000):split / merge都是递归的,随机优先级只保证期望树高,留足上限更稳。__slots__省掉每个节点的__dict__,在级别的节点数上有明显的内存与访问收益。 typing.Optional / Tuple / List标注让None表示的「空树」在阅读代码时更清楚。random.getrandbits(64)取种子:避免固定种子被针对性构造数据退化。- 把
solve(a, n, k)单独抽出,main()只负责读入与输出,便于本地对拍时直接调用;模板部分一行未改,两个题目可以对照阅读。
代码
import random
import sys
from typing import Optional, Tuple, Any
# FHQ-Treap 的 split / merge 都是递归实现的。
# 随机优先级保证树高期望为 O(log n),但为了保险仍把递归上限调高。
sys.setrecursionlimit(300000)
class Node:
"""Treap 节点"""
__slots__ = ("val", "pri", "size", "l", "r")
def __init__(self, val: Any, pri: int):
self.val = val
self.pri = pri
self.size = 1
self.l: Optional["Node"] = None
self.r: Optional["Node"] = None
class FHQTreap:
"""
FHQ-Treap(无旋平衡树)
基于按值分裂 (split) 与合并 (merge) 维护动态有序集合。
支持:插入、删除单次出现、按值查排名、查第 k 小、查前驱、查后继。
所有基本操作的期望时间复杂度均为 O(log n)。
"""
def __init__(self, seed: Optional[int] = 233):
self.root: Optional[Node] = None
self.rng = random.Random(seed)
def _size(self, u: Optional[Node]) -> int:
return u.size if u is not None else 0
def _push_up(self, u: Node) -> None:
"""由左右子树大小更新当前节点子树大小"""
u.size = self._size(u.l) + self._size(u.r) + 1
def _new_node(self, val: Any) -> Node:
return Node(val, self.rng.randint(1, 2**31 - 1))
def split(self, u: Optional[Node], val: Any) -> Tuple[Optional[Node], Optional[Node]]:
"""
按数值 val 分裂:
- 左树 x:包含所有节点值 <= val 的节点
- 右树 y:包含所有节点值 > val 的节点
"""
if u is None:
return None, None
if u.val <= val:
x, y = self.split(u.r, val)
u.r = x
self._push_up(u)
return u, y
else:
x, y = self.split(u.l, val)
u.l = y
self._push_up(u)
return x, u
def merge(self, x: Optional[Node], y: Optional[Node]) -> Optional[Node]:
"""
合并两棵树 x 和 y:
前提:x 中所有节点的值 <= y 中所有节点的值
依据节点的随机优先级保持大根堆性质
"""
if x is None or y is None:
return x if y is None else y
if x.pri > y.pri:
x.r = self.merge(x.r, y)
self._push_up(x)
return x
else:
y.l = self.merge(x, y.l)
self._push_up(y)
return y
def insert(self, val: Any) -> None:
"""插入一个数值 val"""
x, y = self.split(self.root, val)
node = self._new_node(val)
self.root = self.merge(self.merge(x, node), y)
def delete(self, val: Any) -> None:
"""
删除一个数值等于 val 的节点(若存在多个同值节点仅删除其中一个)。
通过将集合切为 (< val)、(== val)、(> val) 三部分,丢弃 (== val) 的一个节点后拼回。
"""
x, z = self.split(self.root, val)
x, y = self.split(x, val - 1)
if y is not None:
# 丢弃 y 的根节点,将其左右子树合并
y = self.merge(y.l, y.r)
self.root = self.merge(self.merge(x, y), z)
def rank(self, val: Any) -> int:
"""
查询数值 val 在集合中的排名(小于 val 的元素个数 + 1)。
采用 BST 遍历,常数小于 split/merge 且无需改变树结构。
"""
u = self.root
ans = 0
while u is not None:
if u.val < val:
ans += self._size(u.l) + 1
u = u.r
else:
u = u.l
return ans + 1
def kth(self, k: int) -> Any:
"""
查询集合中第 k 小的元素(1-based)。
若 k 超出合法范围 [1, size()] 则抛出 IndexError。
"""
if not (1 <= k <= self.size()):
raise IndexError(f"kth index {k} out of range (size={self.size()})")
u = self.root
while u is not None:
l_sz = self._size(u.l)
if k <= l_sz:
u = u.l
elif k == l_sz + 1:
return u.val
else:
k -= l_sz + 1
u = u.r
raise IndexError("kth not found")
def pre(self, val: Any) -> Optional[Any]:
"""
查询 val 的前驱(小于 val 的最大值)。
若不存在严格小于 val 的值,返回 None。
"""
u = self.root
ans = None
while u is not None:
if u.val < val:
ans = u.val
u = u.r
else:
u = u.l
return ans
def succ(self, val: Any) -> Optional[Any]:
"""
查询 val 的后继(大于 val 的最小值)。
若不存在严格大于 val 的值,返回 None。
"""
u = self.root
ans = None
while u is not None:
if u.val > val:
ans = u.val
u = u.l
else:
u = u.r
return ans
def size(self) -> int:
"""返回集合中的节点总数"""
return self._size(self.root)
def __len__(self) -> int:
return self.size()
def empty(self) -> bool:
"""判断集合是否为空"""
return self.root is None
def clear(self) -> None:
"""清空平衡树"""
self.root = None
# ===== 以下是本题(P1886 滑动窗口)特有的部分 =====
# 上面的 Node / FHQTreap 与 P3369 使用的是同一份模板,一行未改。
# 本题只需要其中的 insert / delete / kth 三个接口:
# 窗口 [i-k+1, i] 的最小值 = kth(1),最大值 = kth(k)。
def solve(a, n, k):
"""
用有序多重集求每个滑动窗口的最小值与最大值。
窗口右移一格 = 删除出窗元素 a[i-k] + 插入入窗元素 a[i]。
"""
# 用系统熵做随机种子,避免固定种子被针对性构造数据退化。
tree = FHQTreap(random.getrandbits(64))
# 先把第一个窗口 [0, k-1] 装进树里
for i in range(k):
tree.insert(a[i])
mins = [tree.kth(1)]
maxs = [tree.kth(k)]
# 之后每次右移一格:删掉出窗的,插入入窗的
for i in range(k, n):
tree.delete(a[i - k])
tree.insert(a[i])
mins.append(tree.kth(1))
maxs.append(tree.kth(k))
return mins, maxs
def main() -> None:
# 一次性读入全部输入并按空白切分,再惰性转成整数。
# 用迭代器顺序取值,比循环里反复 input() / split() 快很多。
data = iter(map(int, sys.stdin.buffer.read().split()))
n = next(data)
k = next(data)
a = [next(data) for _ in range(n)]
mins, maxs = solve(a, n, k)
out = sys.stdout
out.write(" ".join(map(str, mins)))
out.write("\n")
out.write(" ".join(map(str, maxs)))
out.write("\n")
if __name__ == "__main__":
main()复杂度
- 时间复杂度:
。每个窗口两次修改(一次删除、一次插入)加两次查询,单次期望 。 - 空间复杂度:
。节点数最多为 (每个元素只插入一次)。
解法三:C++ std::multiset
思路
模型和解法二完全一样:把窗口当成有序多重集,只需知道集合的最小值与最大值。区别在于 C++ 标准库已经提供了现成的有序可重集 std::multiset,不必自己写平衡树。
用到的三个接口:
insert(x):插入一个(允许重复); find(x)配合erase(it):删除一个等于的元素; *ms.begin()是当前最小值,*ms.rbegin()是当前最大值。
这里有一个很容易踩的坑:直接写 ms.erase(x) 会把所有等于 find(x) 拿到某一个等于 erase(it)。因为窗口里一定有 find 保证不会返回 end()。
窗口滑动和前面一样:先把第一个窗口装进去,之后每次删 a[i-k]、插 a[i],再读首尾元素。
同样是有序多重集模型,底层都是平衡树(std::multiset 是红黑树,FHQ-Treap 是笛卡尔树),但语言和实现常数差别巨大:std::multiset 就是最短的路。
代码
/**
* Author by Rainboy blog: https://rainboylv.com github: https://github.com/rainboylvx
* rbook: -> https://rbook.roj.ac.cn https://rbook2.roj.ac.cn
* rainboy的学习导航网站: https://idx.roj.ac.cn
* create_at: 2026-09-14 16:17
* update_at: 2026-09-14 16:17
*/
// main-multiset.cpp:用 C++ 自带的 std::multiset(有序可重集)维护滑动窗口。
// 窗口 [i-k+1, i] 的最小值 = 集合首元素,最大值 = 集合尾元素。
// 窗口右移一格 = 删除出窗元素 + 插入入窗元素,每次操作 O(log n),总 O(n log n)。
#include <bits/stdc++.h>
using namespace std;
const int maxn = 1000000 + 5;
int n, k;
int a[maxn]; // 输入序列,1-based
int ans_min[maxn]; // 每个窗口的最小值
int ans_max[maxn]; // 每个窗口的最大值
multiset<int> ms; // 当前窗口元素组成的有序可重集
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> n >> k;
for (int i = 1; i <= n; i++) {
cin >> a[i];
}
// 先把第一个窗口 [1, k] 装进有序可重集
for (int i = 1; i <= k; i++) {
ms.insert(a[i]);
}
int cnt = 0;
for (int i = k; i <= n; i++) {
if (i > k) {
// 窗口右移一格:删掉出窗的 a[i-k],加入入窗的 a[i]
multiset<int>::iterator it = ms.find(a[i - k]);
ms.erase(it);
ms.insert(a[i]);
}
// 有序可重集的首元素最小,尾元素最大
cnt++;
ans_min[cnt] = *ms.begin();
ans_max[cnt] = *ms.rbegin();
}
for (int i = 1; i <= cnt; i++) {
if (i > 1) {
cout << ' ';
}
cout << ans_min[i];
}
cout << '\n';
for (int i = 1; i <= cnt; i++) {
if (i > 1) {
cout << ' ';
}
cout << ans_max[i];
}
cout << '\n';
return 0;
}复杂度
- 时间复杂度:
。每次窗口右移一次删除加一次插入,每次 。 - 空间复杂度:
。
复杂度对比
| 方案 | 语言 / 维护结构 | 每次窗口的代价 | 时间复杂度 | 空间复杂度 |
|---|---|---|---|---|
main.cpp |
C++,两个单调队列 | 均摊 |
||
main-fhq.py |
Python,手写 FHQ-Treap | 期望 |
||
main-multiset.cpp |
C++,std::multiset |
单调队列快在它丢弃了信息:它不关心窗口里全部元素,只保留还有机会成为答案的那一小部分候选。两种多重集解法保留了完整的多重集,所以能回答「第
同样是 FHQ-Treap,写法不同常数差别很大:main-fhq.py 为了和 P3369 的模板保持逐字符一致,用的是对象节点 + 递归 + 三段分裂删除,std::multiset 在同规模下只要约 0.56s。
总结
单调队列的本质是:只保留窗口里还有机会成为答案的候选。
这道题是最标准的单调队列模板题,关键规则只有两条:
- 过期的从队头删;
- 更差的从队尾删。
而如果窗口要回答的是「第 std::multiset,Python 用 FHQ-Treap 手写。三种解法共用同一个观察——相邻窗口只差一个出窗元素和一个入窗元素。
图示解析
这张图把本题的建模、关键转移、实现检查和训练方法压缩到一页,适合读完正文后复盘。
