【模板】单调队列 / 滑动窗口

用两个单调队列在线维护窗口的最小值与最大值;另附 FHQ-Treap 和 multiset 的对照实现。

启发题

启发记录: 单调队列的经典模板题,能清楚理解如何淘汰既更差又更早过期的候选。多解: multiset

OJ: luogu

题目 ID: P1886

难度:普及

标签:单调队列队列模板题Treap集合pythoncpp

日期: 2026-06-18 14:57

形式化题目

给定长度为 nn 的序列 a1,,ana_1,\dots,a_n 与窗口大小 kk,对每个满足 1lnk+11 \leqslant l \leqslant n-k+1 的窗口 [l,l+k1][l,\,l+k-1],分别求窗口内元素的最小值与最大值,并按窗口从左到右的顺序输出两行答案。

窗口之间的重叠部分是固定的:相邻两个窗口只差「删掉最左边一个元素、加入最右边一个元素」这一步。所有做法都围绕这一点展开。

解法总览

三种解法都能独立完成本题,前一种是本题的正统做法,后两种都是「把窗口当成有序多重集」这一模型的实现:

  • 解法一(main.cpp,正式主解):单调队列。抓住「值更差且更早过期的候选永远没用」这一性质,把窗口里还有可能成为答案的候选压成一个单调队列,队头就是答案,整体 O(n)O(n)
  • 解法二(main-fhq.py:把窗口当成一个有序多重集(multiset),直接维护「第 1 小」和「第 kk 小」。每次右移就删除一个出窗元素、插入一个入窗元素,再用 FHQ-Treap 查询第 kk 小。复杂度是 O(nlogn)O(n\log n),练习价值在于「插入 / 删除 / 查询第 kk 小」这三个接口。
  • 解法三(main-multiset.cpp:和解法二完全相同的模型,但直接使用 C++ 标准库的 std::multiset,不手写平衡树,是这道题在 C++ 里最省事的 O(nlogn)O(n\log n) 写法。

也就是说,解法一是本题的正统做法,解法二、三是拿本题当有序多重集的练习场:解法三用现成容器,解法二手写结构。想看更系统的基础讲解,可以参考 rbook 里的《单调队列》: https://rbook2.roj.ac.cn/data_structure/monotonic_queue/index.html

解法一:单调队列

思路

先看最直接的办法:对每个窗口都重新扫描其中的 k 个元素,分别求最小值和最大值。

这个暴力版本很直观:

cpp
#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;
}

但它的复杂度是 O(nk)O(nk),在 n<=106n <= 10^6 时肯定过不去。

这题的关键观察是:

  • 如果一个新元素更小,那么队尾那些更大或相等、而且更早进入窗口的元素,以后都不可能再成为最小值;
  • 如果一个新元素更大,那么队尾那些更小或相等、而且更早进入窗口的元素,以后都不可能再成为最大值。

所以我们可以分别维护两个存“下标”的单调队列:

  • qmin:值递增,队头是当前窗口最小值下标;
  • qmax:值递减,队头是当前窗口最大值下标。

每次处理位置 i 时:

  1. 先把所有已经不在窗口中的下标从队头删掉;
  2. 再从队尾删掉所有不可能成为未来答案的候选;
  3. 把当前下标 i 入队;
  4. i>=ki >= k 时,队头就是当前窗口答案。

C++ 实现

  • qminqmax 保存下标,并用 headtail 模拟双端队列;这样不会产生节点对象,适合 n106n \leqslant 10^6 的数据范围。
  • 队列里的下标严格递增;最小值队列对应的值非降,最大值队列对应的值非升。
  • 两行答案分别暂存到静态数组,最后以空格分隔输出。

代码

cpp
/**
 * 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;
}

复杂度

  • 时间复杂度:O(n)O(n)(每个下标最多进队一次、出队一次)
  • 空间复杂度:O(n)O(n)

解法二:FHQ-Treap 有序多重集

思路

就是把fhq-treap 当成 multiset 来使用

换一个模型:窗口里的元素就是一个可重集合,我们只需要它能回答两个问题——「最小值是多少」和「最大值是多少」,也就是第 11 小与第 kk 小。窗口右移时,集合只发生一次删除和一次插入:

text
窗口 [l, l+k-1]  →  窗口 [l+1, l+k]

  删除 a[l](出窗)       插入 a[l+k](入窗)

于是整道题退化成三个基本操作:插入删除一个值查询第 k 小。FHQ-Treap(无旋 Treap)正是实现这种有序多重集的常用结构,它只用两个核心操作就能拼出全部接口:

  • split(u, v):按值把树 uu 切成 (<= v)(> v) 两棵,递归下去后顺着原路径改接儿子并更新子树大小;
  • merge(x, y):要求 xx 中所有值都不大于 yy 中所有值,比较两棵根的随机优先级,优先级大的当父节点,继续递归合并。

为什么树高是 O(logn)O(\log n) 每个节点的优先级独立随机生成,merge 只在两者之间比大小,因此树形等价于对节点随机建堆,期望高度为 O(logn)O(\log n),插入、删除、第 kk 小的期望复杂度都是 O(logn)O(\log n)

为什么第 kk 小可以 O(logn)O(\log n) 查到。 每个节点记录子树大小 size,从根往下走:若 kk 不大于左子树大小就往左走,等于左子树大小加一就命中当前节点,否则减去左子树大小加一后往右走。这与在有序序列上二分定位是同一件事。

本题的实现直接复用了 P3369 的 FHQ-Treap 模板:Node + FHQTreap 两部分逐字符相同,本题只在文件末尾追加了 solve()main(),只调用 insert / delete / kth 三个接口。

  • 节点是 __slots__ = ("val", "pri", "size", "l", "r") 的轻量对象,size 记录子树大小,l / r 是左右儿子;
  • split / merge 是模板里的两个核心,insertdelete 都由它们拼出;
  • kth 从根往下走,比较 kk 与左子树大小决定去向,是标准的 BST 定位。

因为题目保证删除的元素一定在窗口内,delete 一定能命中,不需要处理「删除不存在的值」。

注意模板与本题的规模不匹配。 这是通用写法(对象节点 + 递归 + 三段分裂删除),常数较大。实测 n=106n = 10^6 时需要约 33s,而单调队列只需约 2.6s。本题只是拿它练习接口,真正的提交应使用 main.cpp

Python 知识

  • sys.setrecursionlimit(300000)split / merge 都是递归的,随机优先级只保证期望树高,留足上限更稳。
  • __slots__ 省掉每个节点的 __dict__,在 10510^5 级别的节点数上有明显的内存与访问收益。
  • typing.Optional / Tuple / List 标注让 None 表示的「空树」在阅读代码时更清楚。
  • random.getrandbits(64) 取种子:避免固定种子被针对性构造数据退化。
  • solve(a, n, k) 单独抽出,main() 只负责读入与输出,便于本地对拍时直接调用;模板部分一行未改,两个题目可以对照阅读。

代码

python
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()

复杂度

  • 时间复杂度:O(nlogn)O(n\log n)。每个窗口两次修改(一次删除、一次插入)加两次查询,单次期望 O(logn)O(\log n)
  • 空间复杂度:O(n)O(n)。节点数最多为 nn(每个元素只插入一次)。

解法三:C++ std::multiset

思路

模型和解法二完全一样:把窗口当成有序多重集,只需知道集合的最小值与最大值。区别在于 C++ 标准库已经提供了现成的有序可重集 std::multiset,不必自己写平衡树。

用到的三个接口:

  • insert(x):插入一个 xx(允许重复);
  • find(x) 配合 erase(it):删除一个等于 xx 的元素;
  • *ms.begin() 是当前最小值,*ms.rbegin() 是当前最大值。

这里有一个很容易踩的坑:直接写 ms.erase(x) 会把所有等于 xx 的元素一次删光,而窗口右移只需要删掉一个。正确写法是先用 find(x) 拿到某一个等于 xx 的迭代器,再 erase(it)。因为窗口里一定有 xxfind 保证不会返回 end()

窗口滑动和前面一样:先把第一个窗口装进去,之后每次删 a[i-k]、插 a[i],再读首尾元素。

同样是有序多重集模型,底层都是平衡树(std::multiset 是红黑树,FHQ-Treap 是笛卡尔树),但语言和实现常数差别巨大:n=106n = 10^6 时这份 C++ 代码只需约 0.56s,比 Python 手写的 FHQ-Treap(约 33s)快两个数量级。在 C++ 里遇到「需要有序可重集」,直接用 std::multiset 就是最短的路。

代码

cpp
/**
 * 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;
}

复杂度

  • 时间复杂度:O(nlogn)O(n\log n)。每次窗口右移一次删除加一次插入,每次 O(logn)O(\log n)
  • 空间复杂度:O(n)O(n)

复杂度对比

方案 语言 / 维护结构 每次窗口的代价 时间复杂度 空间复杂度
main.cpp C++,两个单调队列 均摊 O(1)O(1) O(n)O(n) O(n)O(n)
main-fhq.py Python,手写 FHQ-Treap 期望 O(logn)O(\log n) O(nlogn)O(n\log n) O(n)O(n)
main-multiset.cpp C++,std::multiset O(logn)O(\log n) O(nlogn)O(n\log n) O(n)O(n)

单调队列快在它丢弃了信息:它不关心窗口里全部元素,只保留还有机会成为答案的那一小部分候选。两种多重集解法保留了完整的多重集,所以能回答「第 kk 小」这类更一般的问题,代价就是每个操作多一个 log\log。本题只需最小值和最大值,因此单调队列是更合适的选择;多重集的价值在于换一个题目仍然适用。

同样是 FHQ-Treap,写法不同常数差别很大:main-fhq.py 为了和 P3369 的模板保持逐字符一致,用的是对象节点 + 递归 + 三段分裂删除,n=106n = 10^6 时约 33s;如果改成数组内存池(用下标代替对象、插入删除改成非递归的 BST 下降),可以压到约 17s,但仍远慢于单调队列。而 C++ 的 std::multiset 在同规模下只要约 0.56s。

总结

单调队列的本质是:只保留窗口里还有机会成为答案的候选。

这道题是最标准的单调队列模板题,关键规则只有两条:

  • 过期的从队头删;
  • 更差的从队尾删。

而如果窗口要回答的是「第 kk 小」而不是「最小 / 最大」,单调队列就不再适用,这时把窗口当成有序多重集就是通用解法:把窗口的移动看成一次删除加一次插入,答案由第 kk 小给出。C++ 用现成的 std::multiset,Python 用 FHQ-Treap 手写。三种解法共用同一个观察——相邻窗口只差一个出窗元素和一个入窗元素。

图示解析

这张图把本题的建模、关键转移、实现检查和训练方法压缩到一页,适合读完正文后复盘。

一图流解析