CSP-S 提高级课程中心 / 数据结构·线性与特殊树 / 第18讲
CSP-S 提高级 · 数据结构·线性与特殊树 · s3-09

笛卡尔树与平衡树:让二叉搜索树 永不退化

BST 的查找是 O(h),高度一歪就退化成链表。旋转、随机优先级、伸展、单调栈——四条不同的路,都通向"树高 O(log n)"这个共同目标。

6 大模块 O(log n) 增删查排名 AVL / Treap / Splay 三大家族 O(n) 单调栈建笛卡尔树 12 道洛谷原题
本讲 · 第 18/52 讲笛卡尔树与平衡树
模块 01

情景导航

从二叉搜索树的"美好承诺"讲起,看它是如何在真实数据面前一败涂地的。

故事排成一列的班级名册

你在给学校做一个"成绩查询系统"。核心需求很朴素:维护一个学生集合,支持四种操作——

· 插入一个学号;· 删除一个学号;· 查询某个学号在集合中的排名(比它小的有几个);· 查询第 k 小的学号。

你毫不犹豫地选择了二叉搜索树(BST):每个节点存一个学号,左子树全比我小、右子树全比我大。查找从根一路比较下去,每一步都能甩掉一半——理论复杂度 O(log n),完美。

你写了 200 行代码,测试通过了。可上线第一天,系统就卡死了。

排查发现,学生们是按学号顺序注册的:20230001、20230002、20230003……每一个新学号都比之前所有的都大,于是它每次都从根一路往右插,成了最右下的那个叶子。

一万个学生,树高一万层。BST 退化成了链表,每次查询都要比较一万次。

更糟的是,查询第 k 大时需要递归一万层——栈溢出,程序直接崩溃。

你盯着那棵歪成一条线的"树",忽然明白了:BST 的 O(log n) 并不是它的性质,而是一个"承诺"。这个承诺只在"树是随机长成的"前提下成立,而真实数据从来不随机。


教练把三种解法写在了黑板上:

解法一(AVL):「插入后发现左右子树高度差超过 1,就旋转把它掰回来。」——严格保持平衡,树高永远是 O(log n)。

解法二(Treap):「给每个节点随机配一个优先级,让树同时满足 BST 性质(按学号)和堆性质(按优先级)。随机性保证了期望高度是 O(log n),而且不用管什么平衡因子。」

解法三(Splay):「每次访问一个节点,就把它一路旋转到根。经常被访问的元素自然浮到上面,靠这个"自调整"保持均摊 O(log n)。而且它还能顺手解决区间翻转这种序列问题。」

你听出了三者的共同点:它们都在做同一件事——用旋转把树的高度控制在 O(log n)。只是"什么时候转、往哪转"的判据不同。

教练又补了一句:「顺便说,如果你只是想求一个数组所有子区间的最小值,别用平衡树。有一棵叫笛卡尔树的树,它用单调栈能在 O(n) 时间内建出来,本质上是"下标满足 BST、值满足堆"的结构——它其实就是一个固定形态的 Treap。」

「所以这一讲,我们其实是在学一件事:如何在二叉树上维持"有序"和"平衡"这两个约束。」

💡 故事背后的算法思想

① BST 的复杂度取决于树高。所有操作(查找、插入、删除、求排名、求第 k 小)都沿着一条从根到叶的路径进行,代价 O(h)。所以要保证效率,就必须保证 h = O(log n)。

② 旋转是调节高度的唯一基本手段。在保持 BST 中序遍历不变的前提下,"左旋"和"右旋"能够改变局部的高度分布。所有平衡树都建立在旋转之上(AVL、Treap、Splay 都是),只有替罪羊树等极少数例外。

③ 三种平衡策略的哲学不同。AVL 是严格平衡(高度差 ≤ 1,查询最快但旋转最频繁);Treap 是随机平衡(靠随机优先级,代码最短);Splay 是自适应平衡(靠访问频率,均摊最优且能处理序列问题)。

④ 笛卡尔树把"平衡"做成了静态结构。给定数组,下标做 BST 键、值做堆键,形态被唯一确定。配合单调栈 O(n) 建树,常用于求 RMQ、划分树、以及和 Treap 的等价性证明。

⑤ 竞赛中的选择。写代码量少选 Treap(约 100 行);要处理区间问题选 Splay 或 FHQ-Treap;要严格理论保证用 AVL;但绝大多数题目都可以用 std::set / pb_ds 库解决——先看能不能用库。

①生活类比
📚

图书馆书架 = 平衡的树

书本按编号排好,但书架每层的书要大致均匀。某一层堆太高,管理员就把几本挪到旁边——这就是旋转。

→ 旋转调节高度
🎲

抽签排队 = 随机优先级

不按身高排队,而是每人抽一个随机号,按号排队。随机化的代价是"偶尔不理想",但期望上队伍一定均匀。

→ Treap 的随机堆性质
🔄

自助餐台前的人气菜 = Splay

常被取用的菜会被服务员挪到最顺手的位置;冷门菜慢慢退到后排。访问越频繁,越容易拿到。

→ 访问后伸展到根
⛰️

山脊线天际图 = 笛卡尔树

把柱状图的高矮看成"优先级",最矮的那根柱子顶在最上面,它左右两边的柱子各自成为左右子树——整个山脊线被唯一分解成一棵树。

→ 下标 BST + 值堆
②核心定义

二叉搜索树(BST):每个节点有一个键值 key,满足"左子树所有键 < 根的键 < 右子树所有键"。这等价于说:对 BST 做中序遍历,得到的序列是递增的。

树高(height):从根到最深叶子的边数。BST 上所有操作的复杂度都是 O(h)。h 最坏可达 n(链状),最好为 ⌈log2(n+1)⌉(完全平衡)。

旋转(rotate):在不破坏中序遍历顺序的前提下,改变局部父子关系、从而调整树高的操作。分左旋(zag)与右旋(zig)两种,互为逆操作。旋转是绝大多数平衡树的唯一"整形"手段。

平衡因子(BF, Balance Factor):节点 u 的 BF 定义为"左子树高度 − 右子树高度"。AVL 树要求所有节点的 |BF| ≤ 1。插入或删除导致 |BF| = 2 时,需要通过旋转修复。

AVL 树:最早被发明的自平衡 BST(1962 年)。严格保证高度差 ≤ 1,因此树高严格 ≤ 1.44 log2 n,查询极快。插入删除时可能触发 LL / RR / LR / RL 四种旋转。

Treap:每个节点额外存一个随机优先级 pri(通常 rand)。整棵树同时满足:按 key 是 BST,按 pri 是小根堆(父的 pri 小于孩子的)。因为 pri 随机,树的期望高度为 O(log n)。

Splay 树:每次操作后,把被访问的节点通过一系列旋转伸展(splay)到根。不记录任何平衡信息,靠"访问即提升"的直觉实现均摊 O(log n),并且支持区间操作(因为伸展相当于把序列切段)。

笛卡尔树(Cartesian Tree):给定一个数组 a[1..n],构造一棵二叉树,使节点下标满足 BST 性质、节点值满足堆性质。它是 Treap 在"优先级固定为 a[i]"时的特例,形态被数组唯一确定,可用单调栈 O(n) 建出。

📌 本讲核心知识地图

板块核心内容对应大纲
BST 基线中序递增;操作 O(h);链式退化到 O(n)【6】平衡树
旋转左旋 / 右旋,中序不变,互逆【6】平衡树
AVL平衡因子;LL / RR 单旋,LR / RL 双旋【6】平衡树
Treap随机 pri + 堆性质;插入/删除靠旋转下沉上浮【6】平衡树
Splay伸展到根;zig-zig / zig-zag;均摊 O(log n)【6】平衡树
序列操作Splay 维护序列,区间翻转靠打标记 + 交换孩子【6】平衡树
笛卡尔树下标 BST + 值堆,形态唯一拓展
单调栈建树找左边第一个比自己小的位置,O(n) 完成拓展
RMQ 转化区间最小值 = 笛卡尔树上 LCA 的值拓展
③核心口诀
🔑 BST 口诀

左小右大中序增,查找代价看树高;
有序数据插进来,一条链子全白搭。

🔑 旋转口诀

右旋:左孩上、父下沉,左孩的右子改做父的左子;
左旋与之对称,中序永远不变。

🔑 AVL 口诀

失衡看方向,同号单旋、异号双旋;
LL 右旋、RR 左旋、LR 先左后右、RL 先右后左。

🔑 Treap 口诀

键按 BST、优先按堆;
插入上浮(旋转到满足堆),删除下沉(旋转到叶子)。

🔑 笛卡尔树口诀

下标 BST、值成堆,形态唯一不用猜;
单调栈扫一遍,"左边第一个比我小的"就是我父亲。

模块 02

知识探究

先把"为什么会歪"讲清楚,再逐个拆开旋转、AVL、Treap、Splay、笛卡尔树五种武器。

1BST 与链式退化 问题根源

先复习 BST 的基本性质。以下五种操作,全都沿着"从根到叶的一条路径"进行:

操作做法复杂度
查找 find(x)x 比当前节点小走左、大走右,相等即找到O(h)
插入 insert(x)同查找,走到空位置挂上新节点O(h)
删除 erase(x)叶子直接删;只有一个孩子用孩子替代;两个孩子用后继替代O(h)
排名 rank(x)走路径累加"左子树大小 + 1",或树外维护 sizeO(h)
第 k 小 kth(k)比较 k 与左子树大小,决定走左、取根还是走右O(h)
📌 关键:复杂度里出现的是 h(树高)而不是 n。所以"能不能保证 h = O(log n)"就成了所有平衡树存在的唯一理由。

现在看最经典的灾难场景——递增序列插入。1, 2, 3, 4, 5, 6, 7 依次插入:

🎬 动画:递增序列 1..7 依次插入 BST 的退化过程
1
插入 1:树为空,1 成为根节点。树高 = 1。
2
插入 2:从根比较,2 > 1 → 走右孩子,空位 → 挂上。现在是 1 → 2(右斜)。树高 = 2。
3
插入 3:3 > 1 → 右;3 > 2 → 右;空位 → 挂上。形态:一条向右的链 1→2→3。树高 = 3。
4
插入 4:一路比较 1、2、3,全部向右,最后挂在 3 的右边。注意:1、2、3 三个节点都没有左孩子,每个新节点都只比较一次就决定了方向——这已经和链表没有区别了。
5
插入 5、6、7:同理,链条不断向右延伸。最终树高 = 7 = n,完全退化成链表。
6
代价对比:查询 7 需要比较 7 次(链表从头走到尾);而在完全平衡的情况下,7 个节点只需要 3 次(log28 = 3)。n = 105 时,差距是 105 次 vs 17 次——差了近 6000 倍。
7
更致命的问题:递归爆栈。如果 kth 或 erase 用递归实现,深度 105 的递归会直接撑爆系统栈(一般只有几 MB)。这就是故事里"系统卡死"的真实原因。
8
不只递增会退化。递减序列会退化成左斜链;而"先小后大交替"的恶意数据(如 1, n, 2, n−1, 3, n−2…)也能构造出高度接近 n/2 的歪树。也就是说,任何"固定规律"的数据都可能被出题人利用来卡掉朴素 BST。
9
解决方案的思路:既然"歪"是插入顺序造成的,那就必须在插入过程中主动调整为平衡——这就是"自平衡二叉搜索树"要解决的问题。
核心领悟:BST 的 O(log n) 是有条件的,条件就是"树是平衡的"。平衡树的全部工作就是为了维护这个条件。
📉 什么时候会退化?
  • 数据已经排好序(递增 / 递减)
  • 数据被恶意构造(交替插入)
  • 插入顺序恰好让每次都在同一侧
  • 某些删除操作会使树逐渐倾斜
✅ 什么情况下不该用平衡树?
  • 只需要"是否存在" → 用 set / unordered_set
  • 只需要"插入 + 离线查询" → 离散化 + 树状数组更快更短
  • 只需要"前驱后继" → 双向链表或并查集也能做
  • n ≤ 5000 → 直接暴力 O(n2) 更稳
2旋转:平衡的唯一手段 基石

旋转的目的:在不改变中序遍历(即不破坏有序性)的前提下,改变局部的父子关系,从而调整高度分布。

以右旋为例。设当前节点是 y,它有左孩子 x,x 有左子树 A、右子树 B,y 有右子树 C:

y x C A B 旋转前:y 的左边偏高 右旋 → x A y B C 旋转后:x 升为根,y 降为右孩子
图 1 · 右旋(zig)。y 的左孩子 x 上升为子树的根,y 下沉为 x 的右孩子,而 x 原来的右子树 B 被"过继"给 y 当左子树。粉色标注的 B 是唯一需要改动的子树。

为什么中序不变?看两次遍历的结果:

旋转前的中序

A(x 的左子树)→ x → B(x 的右子树)→ y → C(y 的右子树)

旋转后的中序

A(x 的左子树)→ x → B(y 的左子树)→ y → C(y 的右子树)

两者的节点顺序完全一致:A, x, B, y, C。唯一的区别是 B 从"x 的右孩子"变成了"y 的左孩子"。只要 B 的所有键都在 x 与 y 之间,"B 挂在 y 的左边"就依然满足 BST 性质。而这一点由 BST 的定义自动保证。

🎬 动画:右旋的四步指针改写
1
记住 x。x = lc[y]。x 将成为新的子树根,所以要把 x 记下来。
2
过继 B。lc[y] = rc[x]。x 原来的右子树 B 现在挂到 y 的左边。这是唯一"搬家"的子树。
3
如果 B 存在,认亲。if (rc[x]) fa[rc[x]] = y。B 的父亲从 x 变成 y(如果用带父亲指针的实现)。
4
y 降级。rc[x] = y。y 成为 x 的右孩子。到这里,x 和 y 之间的父子关系被彻底翻转。
5
接通上级。把 x 接到原来 y 的父亲那里:若 y 原本是祖父的左孩子,则 lc[fa[y]] = x;否则接右边。若 y 原本是根,则根更新为 x。
6
更新父子指针并 pushup。fa[y] = x; fa[x] = 原来的 fa[y];然后先更新 y 的信息(size 等),再更新 x 的信息。顺序不能反——因为 x 的 size 依赖 y 的 size。
7
左旋就是镜像操作。把上面的 lc 与 rc 对调即可:y = rc[x],rc[x] = lc[y],lc[y] = x。
记忆口诀:"内子树过继,父子上位反转,最后接通上级并自下往上更新信息。"旋转只改 3 条边、影响 3 个节点,是 O(1) 操作。
C++左旋与右旋(带父亲指针版)
int ch[MAXN][2];      // ch[u][0] = 左孩子, ch[u][1] = 右孩子
int fa[MAXN], sz[MAXN];

void pushup(int u) {
    sz[u] = sz[ch[u][0]] + sz[ch[u][1]] + 1;
}

// d = 0:对 u 右旋(左孩子上位)   d = 1:对 u 左旋(右孩子上位)
void rotate(int u, int d) {
    int p = fa[u], g = fa[p];
    int w = ch[u][d ^ 1];      // ① u 的"内子树"(右旋时就是 B)

    if (w) fa[w] = p;           // ② 过继 w 给 p
    ch[p][d] = w;

    ch[u][d ^ 1] = p;        // ③ p 降级为 u 的孩子
    fa[p] = u;
    fa[u] = g;                  // ④ 接通上级

    if (g) ch[g][ch[g][1] == p] = u;
    else   root = u;            // p 原是根 → 新根是 u

    pushup(p);                  // ⑤ 先更新 p,再更新 u
    pushup(u);
}
📌 最容易写错的地方:③ 中 ch[u][d^1] = p 的下标。右旋(d=0)时,p 应成为 u 的右孩子,所以是 ch[u][1] = p,即 d^1。同理 ch[p][d] = w 中 p 的左孩子被替换——两处下标都是 d 与 d^1 的配对,写反了树就散了(会成环或丢失子树)。
3AVL 树:用平衡因子严格把关

AVL 的核心思想:任何节点的左右子树高度差都不超过 1。这个约束足够强,可以证明树高 ≤ 1.44 log2(n + 2),因此所有操作严格 O(log n)。

AVL 在每个节点上维护一个 高度 hgt[u](或直接用平衡因子 BF[u] = hgt[lc] − hgt[rc])。插入后沿着路径从底向上回溯更新高度,一旦发现某个节点的 |BF| = 2 就立即修复。

📌 重要性质:插入引发的失衡,只需修复"最低的那个失衡节点"即可(称它为失衡点 α)。修复后整棵树的高度会恢复原状,上层的 BF 也随之恢复正常。所以插入最多两次旋转。但删除不同——删除可能导致上层继续失衡,需要一路修到根,最坏 O(log n) 次旋转。

根据"新节点插在哪",失衡分四种情况。设 α 是最低失衡节点:

情况插在哪BF 特征修复方式
LL 型α 的左孩子的左子树BF[α] = +2 且 BF[lc] = +1一次右旋(对 α)
RR 型α 的右孩子的右子树BF[α] = −2 且 BF[rc] = −1一次左旋(对 α)
LR 型α 的左孩子的右子树BF[α] = +2 且 BF[lc] = −1先对左孩子左旋,再对 α 右旋
RL 型α 的右孩子的左子树BF[α] = −2 且 BF[rc] = +1先对右孩子右旋,再对 α 左旋
LL · 同号

α 左偏 2,左孩子也左偏 1。方向相同 → 单旋(右旋)一次搞定。

RR · 同号

α 右偏 2,右孩子也右偏 1。方向相同 → 单旋(左旋)一次搞定。

LR · 异号

α 左偏但左孩子右偏,折线形状。单旋无法解决,必须两次旋转把折线拉直。

RL · 异号

α 右偏但右孩子左偏,同样是折线。需要两次旋转。

🔑 一句话记忆

看 α 与它"高个子孩子"的 BF 符号是否相同:同号单旋,异号双旋;
双旋时"先转孩子,再转 α"。

🎬 动画:LL 型失衡与一次右旋修复
1
初始平衡。树中有 30(根)、20、40。此时 hgt[30] = 1,左右子树高度分别是 1 和 1,BF[30] = 0。
2
插入 10。10 < 30 → 走左;10 < 20 → 走左;挂到 20 的左边。回溯更新高度:hgt[20] = 1,hgt[30] = 2。
3
发现失衡。BF[30] = hgt[lc] − hgt[rc] = 2 − 0 = +2。|BF| = 2 > 1,节点 30 失衡。再看它的左孩子 20:BF[20] = 1 − 0 = +1。两者都是正数 → 同号 → LL 型。
4
执行右旋(对 30)。30 的左孩子 20 上位成为子树根;20 原来的右子树(空)过继给 30;30 降为 20 的右孩子。由于 30 原来是整棵树的根,新根变为 20。
5
旋转后的结构:20 是根,左孩子 10,右孩子 30。BF[20] = 1 − 1 = 0,完全平衡,树高从 3 降到 2。
6
中序验证:旋转前中序是 10, 20, 30;旋转后中序仍是 10, 20, 30。有序性完全保持,只是形态变矮了。
7
如果是 LR 型呢?比如树里是 30(根)、10(左)、20(10 的右),此时 BF[30] = +2 而 BF[10] = −1,异号。直接对 30 右旋会变成"30 是 10 的右孩子、20 跑到左边",依然不平衡。正确做法:先对 10 左旋(把折线拉直成 LL 型),再对 30 右旋。
核心领悟:AVL 用"高度差 ≤ 1"这一条硬约束,换来了严格的 O(log n)。代价是每次插入删除都要回溯更新高度并可能旋转,常数比其他平衡树大——所以 AVL 查询最快、维护最慢。
C++AVL 的插入与再平衡
int lc[MAXN], rc[MAXN], key[MAXN], hgt[MAXN], tot;

int newnode(int k) {
    key[++tot] = k; hgt[tot] = 1;
    lc[tot] = rc[tot] = 0;
    return tot;
}

void pushup(int u) {
    hgt[u] = max(hgt[lc[u]], hgt[rc[u]]) + 1;
}

int bf(int u) { return hgt[lc[u]] - hgt[rc[u]]; }   // 平衡因子

int rotR(int y) {              // 右旋,返回新的子树根
    int x = lc[y];
    lc[y] = rc[x];  rc[x] = y;
    pushup(y); pushup(x);
    return x;
}
int rotL(int x) {              // 左旋,返回新的子树根
    int y = rc[x];
    rc[x] = lc[y];  lc[y] = x;
    pushup(x); pushup(y);
    return y;
}

// 递归插入,返回新子树根
int insert(int u, int k) {
    if (!u) return newnode(k);
    if (k < key[u])      lc[u] = insert(lc[u], k);
    else if (k > key[u]) rc[u] = insert(rc[u], k);
    else return u;                  // 键值重复,按题目要求处理

    pushup(u);
    int b = bf(u);
    if (b > 1) {                              // 左边偏高
        if (bf(lc[u]) >= 0) return rotR(u);   // LL:单旋
        lc[u] = rotL(lc[u]);                // LR:先转孩子
        return rotR(u);                     //     再转自己
    }
    if (b < -1) {                             // 右边偏高
        if (bf(rc[u]) <= 0) return rotL(u);   // RR:单旋
        rc[u] = rotR(rc[u]);                // RL:先转孩子
        return rotL(u);                     //     再转自己
    }
    return u;
}
📌 代码风格提示:AVL 用"递归 + 返回新子树根"的写法比"带父亲指针 + 手动回溯"简洁得多,而且天然处理了"根被旋转替换"的情况。绝大多数平衡树的模板都推荐这种递归写法。
4Treap:用随机性代替平衡因子 竞赛首选

Treap = Tree + Heap。每个节点除了键值 key 之外,还有一个随机优先级 pri。整棵树同时满足两个性质:

🌳 BST 性质(按 key)
  • 左子树所有 key < 根的 key
  • 右子树所有 key > 根的 key
  • 保证中序遍历递增,支持排名/第 k 小
⛰️ 堆性质(按 pri)
  • 这里用小根堆:父的 pri 小于两个孩子的 pri
  • 根的 pri 是全局最小
  • 保证树"长得均匀"

💡 为什么随机优先级能保证 O(log n)?

想象这样一个过程:把 n 个 key 按 pri 从小到大排序,pri 最小的那个成为根,然后所有比它小的 key 去左边、比它大的去右边,递归建树。

由于 pri 是独立均匀随机分配的,所以"谁成为根"在 n 个 key 中是均匀随机的——这恰好等同于"随机打乱一个序列然后建 BST"。

而随机序列建出的 BST,其期望高度是 O(log n)。关键结论:Treap 就是"随机插入顺序下的 BST",只不过它用 pri 而不是插入时间来决定谁是祖先。

更精确地说,Treap 等价于:把 key 按 pri 排序后依次插入的普通 BST。这一点非常重要,它直接引出了"笛卡尔树"(见后面第 6 节)。

Treap 的实现异常简单,只需要旋转一个手段:

🎬 动画:Treap 的插入(上浮)与删除(下沉)
1
插入 x 的两步走。第一步:按 BST 规则走到空位置,把新节点挂上去(此时 BST 性质满足,但堆性质可能被破坏)。
2
第二步:向上修复堆性质。检查 pri[新节点] 是否小于它父亲的 pri。
3
如果小于 → 违反小根堆 → 旋转!若新节点是父亲的左孩子,就对该父亲右旋;若是右孩子,就左旋。旋转后新节点上升一层。
4
重复上浮。继续和新的父亲比较 pri,直到"父亲的 pri 更小"或者"自己已经是根"。整个过程等价于"把新节点一路冒泡到堆中它该在的位置"。
5
复杂度分析:上浮的层数不超过树高。因为树高期望 O(log n),所以插入期望 O(log n)。注意:单次操作最坏可以达到 O(n)(pri 恰好极端),但期望(对所有随机结果取平均)是 O(log n)。
6
删除 x:反向操作。找到 x 之后,比较它的两个孩子的 pri,把 pri 较小的孩子旋转上来(这就是"把 x 往下沉")。
7
反复下沉,直到 x 变成叶子(没有孩子),直接 delete 掉即可。"删除 = 一路旋转到叶子",这是 Treap 最巧妙的地方——它完全避免了普通 BST 删除时"找后继替代"的复杂讨论。
8
为什么"旋 pri 小的上来"是对的?因为小根堆要求父亲最小。x 要往下走,就该让"更有资格当父亲的那个孩子"(pri 更小)上位。旋转后,x 和新位置的父亲依然满足堆性质。
记忆口诀:插入 = BST 挂上 + 比 pri 上浮;删除 = 比 pri 下沉到叶子再删。整套代码只需一个 rotate 函数,约 100 行搞定。
C++Treap 完整模板(插入 / 删除 / 排名 / 第 k 小 / 前驱后继)
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 100005;
int lc[MAXN], rc[MAXN], key[MAXN], pri[MAXN], sz[MAXN], cnt[MAXN];
int tot, root;
mt19937 rng(19260817);           // 固定种子,便于复现

int newNode(int k) {
    key[++tot] = k;  pri[tot] = rng();
    sz[tot] = cnt[tot] = 1;
    lc[tot] = rc[tot] = 0;
    return tot;
}
void pushup(int u) { sz[u] = sz[lc[u]] + sz[rc[u]] + cnt[u]; }

void rotR(int& y) {                 // 右旋
    int x = lc[y];
    lc[y] = rc[x];  rc[x] = y;
    pushup(y); pushup(x);
    y = x;
}
void rotL(int& x) {                 // 左旋
    int y = rc[x];
    rc[x] = lc[y];  lc[y] = x;
    pushup(x); pushup(y);
    x = y;
}

void insert(int& u, int k) {
    if (!u) { u = newNode(k); return; }
    if (k == key[u]) { cnt[u]++; pushup(u); return; }
    if (k < key[u]) {
        insert(lc[u], k);
        if (pri[lc[u]] < pri[u]) rotR(u);   // 违反小根堆 → 上浮
    } else {
        insert(rc[u], k);
        if (pri[rc[u]] < pri[u]) rotL(u);
    }
    pushup(u);
}

void erase(int& u, int k) {
    if (!u) return;
    if (k == key[u]) {
        if (cnt[u] > 1) { cnt[u]--; pushup(u); return; }
        if (!lc[u] || !rc[u]) { u = lc[u] + rc[u]; return; }  // 至多一个孩子
        if (pri[lc[u]] < pri[rc[u]]) rotR(u);   // 把 pri 小的孩子旋上来
        else                          rotL(u);
        erase(u, k);                            // 下沉一层后继续删
        return;
    }
    if (k < key[u]) erase(lc[u], k);
    else            erase(rc[u], k);
    pushup(u);
}

// 排名:返回比 k 小的数的个数 + 1
int rank(int u, int k) {
    if (!u) return 1;
    if (k == key[u]) return sz[lc[u]] + 1;
    if (k < key[u])  return rank(lc[u], k);
    return sz[lc[u]] + cnt[u] + rank(rc[u], k);
}

// 第 k 小
int kth(int u, int k) {
    if (k <= sz[lc[u]]) return kth(lc[u], k);
    k -= sz[lc[u]];
    if (k <= cnt[u]) return key[u];
    return kth(rc[u], k - cnt[u]);
}

// 前驱: < k 的最大值;后继: > k 的最小值
int pre(int u, int k, int best = INT_MIN) {
    if (!u) return best;
    if (key[u] < k) return pre(rc[u], k, max(best, key[u]));
    return pre(lc[u], k, best);
}
int nxt(int u, int k, int best = INT_MAX) {
    if (!u) return best;
    if (key[u] > k) return nxt(lc[u], k, min(best, key[u]));
    return nxt(rc[u], k, best);
}
✅ Treap 的优点
  • 代码极短(约 100 行),写完不易出错
  • 不用维护高度/平衡因子,无需复杂分类讨论
  • 删除操作特别优雅:旋转到叶子即可
  • 期望 O(log n),实测常数很小
  • 容易改造成可持久化版本
⚠️ Treap 的注意点
  • 期望而非严格 O(log n),理论上可能被极端随机数卡
  • 用 rand() 时要注意范围,最好用 mt19937
  • 递归写法在深度极端时仍有爆栈风险(概率极低)
  • 用固定种子容易被 hack;比赛建议用 chrono 做种子
5Splay 树:把访问过的节点转到根

Splay 的核心思想只有一句话:每次访问一个节点后,通过一系列旋转把它"伸展"到根。不记录任何平衡信息,完全靠这个自调整策略维持均摊 O(log n)。

💡 为什么"访问即提升根"能保证效率?

直觉解释:经常被访问的节点会浮到靠近根的位置,之后访问它就更便宜。这符合"局部性原理"——程序中往往反复访问同样的数据。

均摊证明的思路(势能法):定义势能 Φ = Σu log2(sz[u]),其中 sz 是子树大小。可以证明任意一次 splay 操作的"实际代价 + 势能变化"不超过 O(log n),因此 m 次操作的总代价是 O((n + m) log n),均摊到每次就是 O(log n)。

关键结论:Splay 的单次操作最坏可以是 O(n),但均摊一定是 O(log n)。所以在"只有总时间限制、没有单次时间限制"的题目里完全安全。

伸展操作的精髓在于不能傻乎乎地一层一层转,要区分三种情况(设目标节点为 x,父亲为 p,祖父为 g):

情况条件操作说明
zigp 就是根对 p 旋转一次最简单的情形,x 直接升到根
zig-zigx 与 p 同向(都是左孩子或都是右孩子)先转 p,再转 x顺序不能颠倒!这是 Splay 效率的关键
zig-zagx 与 p 异向(一个左一个右)先转 x,再转 x连续两次对 x 旋转
📌 为什么 zig-zig 必须先转 p 再转 x?如果按"每次只转 x"的朴素做法(连续两次对 x 旋转),树会变成一条长链,达不到平衡效果。先转 p 能把"折线"变成"顺线",再转 x 就能把整段路径压缩得更好。这是 Splay 均摊分析的基石,也是它与"逐层单旋"的本质区别。
🎬 动画:把最深层的节点 splay 到根(三种情况连续出现)
1
判断树的形状。设要伸展的节点是 x,父亲 p,祖父 g。看 x 是 p 的左孩子还是右孩子,p 又是 g 的左孩子还是右孩子。
2
情况 zig-zig(同向)。例如 x 是 p 的左孩子,p 也是 g 的左孩子——这是"顺线"结构。操作是:对 g 旋转(把 p 提上去),再对 p 旋转(把 x 提上去)。
3
效果:x 上升两层,而且整条最长链被"折叠"了,树的整体高度明显下降。这正是 Splay 优于"逐层单旋"的地方。
4
情况 zig-zag(异向)。例如 x 是 p 的左孩子,p 是 g 的右孩子——这是"折线"结构。操作是:先对 p 旋转(把 x 提上去一次),再对 g 旋转(再提一次)。两次旋转都是针对 x 的。
5
效果:折线被拉直,x 同样上升两层,x、p、g 三者的相对顺序被重排成"x 最高、另两者分立左右"。
6
循环条件。反复判断并旋转,直到 fa[x] == 目标位置。如果目标是根,就一直转到 fa[x] == 0。
7
特判:p 已经是根。此时无法构成"祖父-父亲"三层结构,只需对 p 单旋一次(zig),x 就升到根。
8
伸展后要更新信息。每次旋转后都要 pushup,且注意"先更新下沉的节点,再更新上升的节点"。如果维护了区间翻转标记,还要在旋转前后正确处理 pushdown。
核心口诀:同向先转父,异向连转己;父即根则单旋,切勿逐层傻转。
C++Splay 的核心:rotate 与 splay
int ch[MAXN][2], fa[MAXN], sz[MAXN], val[MAXN];
int root, tot;
bool rev[MAXN];                       // 区间翻转标记(文艺平衡树用)

bool isRight(int u) { return ch[fa[u]][1] == u; }
void pushup(int u) { sz[u] = sz[ch[u][0]] + sz[ch[u][1]] + 1; }
void pushdown(int u) {
    if (!rev[u]) return;
    swap(ch[u][0], ch[u][1]);           // 交换左右孩子 = 翻转
    rev[ch[u][0]] ^= 1;
    rev[ch[u][1]] ^= 1;
    rev[u] = false;
}

void rotate(int x) {
    int y = fa[x], z = fa[y], d = isRight(x);
    ch[y][d] = ch[x][d ^ 1];
    if (ch[x][d ^ 1]) fa[ch[x][d ^ 1]] = y;
    ch[x][d ^ 1] = y;  fa[y] = x;
    fa[x] = z;
    if (z) ch[z][ch[z][1] == y] = x;
    pushup(y); pushup(x);
}

// 把 x 伸展到 goal 的孩子位置(goal = 0 表示伸展到根)
void splay(int x, int goal = 0) {
    while (fa[x] != goal) {
        int y = fa[x], z = fa[y];
        if (z != goal) {
            if (isRight(x) == isRight(y)) rotate(y);   // zig-zig:先转父
            else                          rotate(x);   // zig-zag:先转己
        }
        rotate(x);                                     // 最后都转 x
    }
    if (!goal) root = x;
}
📌 这 20 行是整个平衡树模块最值得背下来的代码。它同时搞定了"同向/异向"的判断与"最后统一转 x"的合并——while 里先根据情况做一次(可能不做的)旋转,最后必定再做一次 rotate(x)。这个"先判后转 + 末尾补一转"的写法把三种情况统一成了两行。

Splay 最独特的价值是处理区间问题。核心技巧是:

① 建树时把序列建成一条链(或二分建树),中序 = 原序列
↓
② 要操作区间 [l, r]:把 l−1 号节点 splay 到根
↓
③ 把 r+1 号节点 splay 到"根的右孩子"位置
↓
④ 此时"根的右孩子的左子树"恰好就是区间 [l, r]

这个"两端夹逼"的技巧极其巧妙:用两个哨兵节点把目标区间"夹"成一棵独立的子树,然后就可以对这棵子树打标记(如翻转)、查询、整体删除。这就是"文艺平衡树"(P3391)的全部原理。

💡 区间翻转怎么做到 O(log n)?

答案:打懒标记,和线段树一模一样的思想。在夹出的子树的根上打一个 rev = true 标记,表示"这整棵子树需要翻转"。真正翻转发生在 pushdown 时:交换该节点的左右孩子,并把 rev 标记下传给孩子。

为什么"交换左右孩子"就等于"翻转区间"?因为 BST 的中序对应原序列,而交换左右孩子会让中序遍历的顺序整体倒过来——这正是翻转。

注意:因为要翻转,不能维护"键值"(键会乱),只能维护"位置"。此时树是按"序列位置"的 BST,靠 sz 定位第 k 个元素。

与线段树的对比:线段树也能维护区间信息,但它不能改变元素的相对顺序(翻转会打乱区间划分)。而 Splay 的区间划分是"动态可变的",所以能支持翻转、移动、切分、合并这类"改变顺序"的操作。这是 Splay 不可替代的地方。

6笛卡尔树:把数组"折"成一棵树 拓展重点

定义:给定一个长度为 n 的数组 a[1..n](假设元素两两不同),构造一棵二叉树,满足两个条件:

🌳 BST 性质(按下标)
  • 节点存的是数组下标 i
  • 左子树的节点下标都小于 i
  • 右子树的节点下标都大于 i
  • 因此中序遍历 = 1, 2, 3, …, n
⛰️ 堆性质(按值)
  • 以"最小值"为堆顶(小根堆)
  • 父亲的 a 值小于两个孩子的 a 值
  • 因此根节点是数组最小值的位置
  • 若改成大根堆,则根是最大值的位置

💡 关键性质:笛卡尔树的形态是唯一的

给定数组,这棵树被完全确定,不需要任何选择。理由:

① 根必须是最小值所在的位置(因为堆性质要求根最小,而中序必须保证所有下标都在它两侧)。

② 根确定后,左子树恰好由根左边所有元素组成,右子树由根右边所有元素组成(因为 BST 要求小下标在左、大下标在右)。

③ 左、右子树各自递归地按同样的规则确定。

所以整个结构是自顶向下唯一确定的。这和你熟悉的"给一个数组能画出多少棵 BST"完全不同——那些 BST 没有值约束,而笛卡尔树多了堆约束,形态就锁死了。

2:1 MIN 1:3 leaf 5:2 right 3:4 left 4:5 数组 a = [3, 1, 4, 5, 2] 节点标注 "下标:值" 中序读出下标 1,2,3,4,5 值沿父子递增(小根堆) 根 = 最小值 a[2] = 1 左子树 = 下标 < 2 的部分 右子树 = 下标 > 2 的部分
图 2 · 数组 a = [3, 1, 4, 5, 2] 的最小值笛卡尔树。节点写成"下标:值"。中序读出下标序列 1→2→3→4→5,堆序读出值序列 1→2→3→4→5。粉色根节点对应数组最小值所在的第 2 个位置。

请手工验证一下这棵树:根是下标 2(值 1,最小);左边只有下标 1(值 3),成为左孩子;右边有下标 3、4、5。在右边这三个里,值最小的是下标 5(值 2),所以它成为右子树的根,其左边的下标 3、4 归到它的左子树——其中值最小的是下标 3(值 4),它成为根,下标 4(值 5)作为它的右孩子。完全符合"根是最小值、左右按下标分"的规则。

7单调栈:O(n) 建出笛卡尔树 核心技巧

如果按"每次找区间最小值再递归"来建树,复杂度是 O(n2)。但用单调栈可以做到 O(n)。核心思路是"从左到右扫描,维护当前树的右链"。

💡 什么是"右链"?为什么只维护右链就够了?

当我们从左到右逐个加入下标 i 时,新节点 i 一定是当前已建好这棵树的"最右边"(因为它的下标最大)。而 BST 要求"下标大的在右",所以新节点只能挂在现有树的右链上(从根一路走右孩子得到的路径)。

于是问题简化为:在当前的右链上,找到一个合适的位置把 i 插进去,使堆性质成立。

因为右链上的节点下标递增,如果它们的值也是递增的(小根堆要求父小于子),那就完美。一旦发现某个节点的值比 a[i] 大,就说明 i 应该取代它的位置——把它(以及它的一整段右链)变成 i 的左子树。

算法步骤(对每个 i 从 1 到 n):

🎬 动画:单调栈建笛卡尔树(a = [3, 1, 4, 5, 2])
1
i = 1,a[1] = 3。栈为空,last = 0。栈空所以没有父亲;last = 0 所以没有左孩子。下标 1 暂时成为整棵树的根。压栈 → 栈内:[1]。
2
i = 2,a[2] = 1。看栈顶 1:a[1] = 3 > a[2] = 1 → 弹出,并记 last = 1。栈空了,循环结束。栈空 → 没有父亲;last = 1 ≠ 0 → 下标 1 成为下标 2 的左孩子。压栈 → 栈内:[2]。
3
含义解读:下标 2 的值更小,所以它"有资格"当祖先。而它的下标比 1 大,所以 1 只能去它的左边。一次弹栈就完成了"换根 + 挂左子树"两件事。
4
i = 3,a[3] = 4。栈顶 2:a[2] = 1 < 4 → 不弹,循环结束,last = 0。栈不空 → 下标 2 是下标 3 的父亲,且 3 是它父亲的右孩子。压栈 → 栈内:[2, 3]。
5
i = 4,a[4] = 5。栈顶 3:a[3] = 4 < 5 → 不弹。下标 3 是下标 4 的父亲,4 是右孩子。压栈 → 栈内:[2, 3, 4]。此时右链是 2 → 3 → 4,值依次 1 < 4 < 5,严格递增,堆性质满足。
6
i = 5,a[5] = 2。(关键一步)栈顶 4:a[4] = 5 > 2 → 弹出,last = 4。新栈顶 3:a[3] = 4 > 2 → 弹出,last = 3。新栈顶 2:a[2] = 1 < 2 → 停止。
7
挂接:栈不空 → 下标 2 是下标 5 的父亲(5 是 2 的右孩子,取代了原来 3 的位置)。last = 3 ≠ 0 → 下标 3 成为下标 5 的左孩子。压栈 → 栈内:[2, 5]。
8
验证:下标 3 原来带着自己的右孩子 4 一起被"接手"了,所以 5.left = 3,而 3 的右孩子仍然是 4。整棵子树被完整地"过继",这正是弹栈式操作的美妙之处。
9
结果与递归法一致:根是 2,左孩子 1,右孩子 5;5 的左孩子是 3,3 的右孩子是 4。与图 2 完全吻合。整个过程中,每个下标进栈一次、出栈一次,所以总复杂度 O(n)。
核心口诀:"比栈顶大的直接挂右孩子;比栈顶小的就把栈顶们弹出来,弹出来的那串整体成为我的左子树。"
i=1 (3)
i=2 (1)
i=3 (4)
i=4 (5)
i=5 (2)
栈内变化
[1]
[2]
[2,3]
[2,3,4]
[2,5]
上方是扫描的下标与值,下方是每次操作后的栈状态。可以看到栈始终是"从底到顶、下标递增、值也递增"的单调结构——这正是"单调栈"名字的由来。
C++单调栈 O(n) 建笛卡尔树(小根堆版)
const int MAXN = 100005;
int a[MAXN];
int lc[MAXN], rc[MAXN], stk[MAXN];
int n, top = 0, root;

void build() {
    for (int i = 1; i <= n; i++) {
        int last = 0;
        // 弹出所有"值比 a[i] 大"的栈顶——它们不可能再当祖先了
        while (top && a[stk[top]] > a[i]) {
            last = stk[top--];
        }
        if (top) {                    // ① 栈不空 → 栈顶是新节点的父亲
            rc[stk[top]] = i;          //    新节点挂在它的右孩子位置
        } else {
            root = i;                  //    栈空 → 新节点成为新的树根
        }
        lc[i] = last;                  // ② 被弹出的整条链成为新节点的左子树
        stk[++top] = i;                // ③ 新节点入栈
    }
}
❌ 错误写法:用 >= 而不是 >

如果写成 while (top && a[stk[top]] >= a[i]),在存在相等元素时行为会改变:相等的元素会被弹掉,于是"左边的等于我的"被当成后代处理。笛卡尔树在元素有重复时形态不唯一,必须先和题目确认是按"严格小于"还是"小于等于"建树,或者干脆先用 (值, 下标) 二元组去重排序。

❌ 错误写法:弹栈时忘了记录 last

while (top && a[stk[top]] > a[i]) top--; —— 只是弹栈而没保存"最后一个被弹出的下标"。那么 lc[i] = last 就无从谈起,整棵左子树都丢了。必须每次弹出时 last = stk[top--],循环结束后 last 恰好是"被弹出链的最顶端(最靠近根的那个)"。

🔍 为什么 last 恰好是被弹出链的"最顶端"?

因为弹栈是从栈顶开始,最后被弹出的那个在栈里位置最低,也就是离根最近、下标最小、原先层级最高的那个。它带着它下面的整条链一起"过继"给新节点——而它自己就应该是新节点左子树的根。

注意:栈内从底到顶是"下标递增、值递增"的,所以被弹出来的这一串恰好是原树右链的尾部一段,形状是一条向右的链。把它整体挂成 i 的左子树,就完成了"i 插入到正确位置"的全部工作。

每个下标严格进栈一次、出栈一次,所以 while 的总执行次数 ≤ n,整个算法 O(n)。这就是"均摊分析"的又一经典案例。

8笛卡尔树、Treap 与 RMQ 的三重联系 打通理解

这一段是本讲最"值钱"的部分——它把前面学的东西串成了一条线。

🔗 联系一:笛卡尔树 = 优先级固定的 Treap

回忆 Treap 的定义:节点有 key(BST 键)和 pri(堆键),形态由这两个条件唯一确定。

现在对数组 a 建笛卡尔树:把下标 i 当 key,把 a[i] 当 pri。那么:

· "下标满足 BST" ⟺ "key 满足 BST" ✅

· "值满足堆" ⟺ "pri 满足堆" ✅

两者定义完全一致!唯一的区别只是:Treap 的 pri 是随机生成的(为了让形态随机、期望平衡),而笛卡尔树的 pri 是题目给定的(可能有任何形态,甚至可能退化成链)。

推论(很重要):Treap 的插入过程("BST 挂上去 + 按 pri 旋转上浮")本质上就是动态地建一棵笛卡尔树。而笛卡尔树能 O(n) 建出来,是因为它的全部 pri 事先已知,可以用单调栈一次扫完;Treap 的 pri 是在插入时才产生的,所以只能一边插一边旋转。

🔗 联系二:区间最小值 = 笛卡尔树上两个端点的 LCA

结论:在最小值笛卡尔树中,区间 [l, r] 的最小值 = l 与 r 的最近公共祖先(LCA)所代表的值。

为什么?设 w = LCA(l, r)。因为 w 是 l 和 r 的祖先,所以 l ≤ w ≤ r(BST 性质:子树下标都在一个连续区间内),也就是说 w 落在了 [l, r] 中。

又因为 w 是 l 与 r 的公共祖先中深度最大(位置最低)的那个,它必然位于 l 到 r 的路径上。而堆性质保证"祖先的值小于后代的值",所以 a[w] 小于 l 子树中所有元素的值,也小于 r 子树中所有元素的值。

更严格地说:可以证明 w 就是 [l, r] 中值最小的那个位置。于是区间最小值问题被完全转化成了树上 LCA 问题。

实际意义:配合欧拉序 + ST 表(O(n log n) 预处理、O(1) 查询),就能实现 O(1) 的 RMQ。这正是"笛卡尔树 + LCA"的著名应用。

🔗 联系三:节点 i 的父亲是谁?

这是笛卡尔树最实用的一个性质,很多题目直接用它:

节点 i 的父亲 = "左边第一个比 a[i] 小的位置" 与 "右边第一个比 a[i] 小的位置" 中,值更小的那个。

为什么?设 L = 左边第一个比 a[i] 小的位置,R = 右边第一个比 a[i] 小的位置。i 的父亲必须是"离 i 最近的、值比 a[i] 小的祖先"。而在 i 左边,能成为祖先的只能是那些比 a[i] 小的位置(因为堆性质),其中最靠近 i 的就是 L;右边同理是 R。父亲只能是 L 或 R 之一。

再比较 a[L] 与 a[R]:值更小的那个更"有资格"当父亲(堆性质要求父亲更小)。所以:

fa[i] = (a[L] < a[R]) ? L : R (边界用 +∞ 或 −∞ 哨兵)

这条性质让"单调栈建树"变得完全可解释:栈里存的恰好是"左边所有比 a[i] 小的位置中,从近到远的那一串"(即一个单调递增序列)。弹栈就是淘汰那些不可能成为祖先的、值比我大的元素;弹完后栈顶就是 L。单调栈与"左边第一个更小值"是同一件事的两种表述。

建树手段

AVL / Treap 靠旋转动态维护;笛卡尔树靠单调栈一次建好。前者是"增量式",后者是"整体式"。

平衡保证

AVL 靠严格高度差;Treap 靠随机;Splay 靠均摊;笛卡尔树完全不保证平衡——它可能退化成 O(n) 的链。

适用场景

笛卡尔树是"离线、静态"的:数组给定后建一次,之后用来回答 RMQ 或做 DP。不能动态插入删除。

DP 用法

笛卡尔树上的树形 DP:因为"根的子树 = 一个连续区间",很多"枚举最小值 + 左右区间"的 DP 都能直接在树上做。

典型题目

P5854 模板、P4755 Beautiful Pair(分治+笛卡尔树)、P3793 由乃救爷爷(RMQ)。

一句话总结

笛卡尔树 = 静态 Treap = 区间最值的树形表示 = "左边第一个更小值"的下一层理解。

案例 1P3369【模板】普通平衡树 必做题

题意:维护一个可重集合,支持六种操作:

① 插入 x;② 删除一个 x(若有多个,只删一个);③ 查询 x 的排名(比 x 小的数个数 + 1);④ 查询排名为 k 的数;⑤ 求 < x 的最大数(前驱);⑥ 求 > x 的最小数(后继)。

数据范围:操作数 ≤ 105,|x| ≤ 107。所有操作必须保证 x 存在(即不会查询不存在的数的排名)。

操作做法(Treap)复杂度
插入按 BST 走到位置挂上 → 比较 pri 上浮旋转期望 O(log n)
删除找到后把 pri 小的孩子旋上来,一路下沉到叶子再删期望 O(log n)
排名走路径,遇到"往右走"就累加左子树大小 + 自身 cnt期望 O(log n)
第 k 小比较 k 与左子树大小:k ≤ sz[lc] 走左;否则减掉后看 cnt;再否则走右期望 O(log n)
前驱记录"往右走时遇到的 key"的最大值期望 O(log n)
后继记录"往左走时遇到的 key"的最小值期望 O(log n)
C++P3369 完整代码(Treap 实现)
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 100005;
int lc[MAXN], rc[MAXN], key[MAXN], pri[MAXN], sz[MAXN], cnt[MAXN];
int tot, root;
mt19937 rng(chrono::steady_clock::now().time_since_epoch().count());

int newNode(int k) {
    key[++tot] = k; pri[tot] = rng();
    sz[tot] = cnt[tot] = 1;
    lc[tot] = rc[tot] = 0;
    return tot;
}
void pushup(int u) { sz[u] = sz[lc[u]] + sz[rc[u]] + cnt[u]; }

void rotR(int& y) { int x = lc[y]; lc[y] = rc[x]; rc[x] = y; pushup(y); pushup(x); y = x; }
void rotL(int& x) { int y = rc[x]; rc[x] = lc[y]; lc[y] = x; pushup(x); pushup(y); x = y; }

void insert(int& u, int k) {
    if (!u) { u = newNode(k); return; }
    if (k == key[u]) { cnt[u]++; pushup(u); return; }
    if (k < key[u]) { insert(lc[u], k); if (pri[lc[u]] < pri[u]) rotR(u); }
    else            { insert(rc[u], k); if (pri[rc[u]] < pri[u]) rotL(u); }
    pushup(u);
}

void erase(int& u, int k) {
    if (!u) return;
    if (k == key[u]) {
        if (cnt[u] > 1) { cnt[u]--; pushup(u); return; }
        if (!lc[u] || !rc[u]) { u = lc[u] + rc[u]; return; }
        if (pri[lc[u]] < pri[rc[u]]) rotR(u); else rotL(u);
        erase(u, k); return;
    }
    if (k < key[u]) erase(lc[u], k); else erase(rc[u], k);
    pushup(u);
}

int getRank(int u, int k) {
    if (!u) return 1;
    if (k == key[u]) return sz[lc[u]] + 1;
    if (k < key[u])  return getRank(lc[u], k);
    return sz[lc[u]] + cnt[u] + getRank(rc[u], k);
}

int getKth(int u, int k) {
    if (k <= sz[lc[u]]) return getKth(lc[u], k);
    k -= sz[lc[u]];
    if (k <= cnt[u]) return key[u];
    return getKth(rc[u], k - cnt[u]);
}

int getPre(int u, int k, int best) {
    if (!u) return best;
    if (key[u] < k) return getPre(rc[u], k, max(best, key[u]));
    return getPre(lc[u], k, best);
}
int getNxt(int u, int k, int best) {
    if (!u) return best;
    if (key[u] > k) return getNxt(lc[u], k, min(best, key[u]));
    return getNxt(rc[u], k, best);
}

int main() {
    ios::sync_with_stdio(false); cin.tie(0);
    int n; cin >> n;
    while (n--) {
        int op, x; cin >> op >> x;
        if (op == 1)      insert(root, x);
        else if (op == 2) erase(root, x);
        else if (op == 3) cout << getRank(root, x) << '\n';
        else if (op == 4) cout << getKth(root, x) << '\n';
        else if (op == 5) cout << getPre(root, x, INT_MIN) << '\n';
        else              cout << getNxt(root, x, INT_MAX) << '\n';
    }
    return 0;
}
运行结果(示例)
输入:
8
1 10
1 20
1 30
3 20
4 2
5 25
6 25
2 20

输出:
2
20
20
30
(最后删除 20,无输出)

说明:20 的排名是 2(只有 10 比它小);
第 2 小数是 20;25 的前驱是 20;
25 的后继是 30。请以洛谷题目页面为准。
❌ 忘了维护 cnt(重复元素计数)

题目是可重集,同一个 x 可能插入多次。若每个 x 只建一个节点、不给 cnt 计数,那么第二次插入同样的 x 会出错(要么建了重复节点,要么直接丢弃)。必须在相同 key 的节点上累积 cnt,并在 sz 里把它算进去。

❌ 删除时用 u = lc[u] + rc[u] 却不 pushup

叶子删除时 u = lc[u] + rc[u] 把 u 指向唯一存在(或为空)的孩子。此时 u 的位置被替换,调用方还需要 pushup——代码里是靠 erase 最后的 pushup(u) 完成的,如果 return 提前了就会漏更新。注意上面代码中"return 前不 pushup"是安全的,因为没有修改子树结构(只是 u 自己的位置变了,父亲会 pushup)。

❌ 前驱/后继的初始 best 取错

前驱初始值应为 INT_MIN(负无穷),后继为 INT_MAX。若都取 0,当所有元素都为正数时前驱会错误地返回 0。题目保证询问的数存在,但"前驱可能存在也可能不存在"这一点必须靠哨兵处理。

❌ 用了 rand() 且 RAND_MAX 不够大

Windows 上 RAND_MAX 只有 32767,而 MAXN 是 105——大量节点的 pri 会相同,堆性质退化,树高失控。务必用 mt19937。另外比赛时不要用固定种子,容易被构造数据卡。

📌 更快的做法:如果题目数据允许,直接用 std::set + order_of_key 的思路无法实现(std::set 不支持排名),可以用 pb_ds 库的 tree 容器,六种操作全部一行搞定。但 CSP-S/NOI 系列比赛中不允许使用 pb_ds 的情况较多,还是老老实实写 Treap。
案例 2P5854【模板】笛卡尔树 模板

题意:给定 a[1..n](保证元素两两不同),求它的小根堆笛卡尔树。设第 i 个节点的左右孩子分别是 li、ri(不存在则为 0),请输出 li × i 与 ri × i 的异或和(相当于给一个校验值,避免输出量过大)。

数据范围:n ≤ 107。注意这个 n!这要求算法必须严格 O(n),且常数极小、内存友好。

💡 为什么 n 开到 107?

因为官方想逼你用单调栈。任何带 log 的做法(比如用 set 找前驱后继、或者递归建树)在 107 下都会超时。

同时也提醒你:不能用递归建树——n = 107 时树可能退化成一条链,递归深度 107 直接爆栈。必须写迭代版本(单调栈本身就是迭代的,天然安全)。

另外,输入量很大,需要快读;输出要用 long long(因为 li × i 最大约 1014)。

C++P5854 完整代码(单调栈 + 快读)
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 10000005;
int a[MAXN], lc[MAXN], rc[MAXN], stk[MAXN];
int n, top, root;

// 快读:n = 10^7,必须用
inline int read() {
    int x = 0, f = 1; char c = getchar();
    while (c < '0' || c > '9') { if (c == '-') f = -1; c = getchar(); }
    while (c >= '0' && c <= '9') { x = x * 10 + c - '0'; c = getchar(); }
    return x * f;
}

int main() {
    n = read();
    for (int i = 1; i <= n; i++) a[i] = read();

    for (int i = 1; i <= n; i++) {
        int last = 0;
        while (top && a[stk[top]] > a[i]) last = stk[top--];
        if (top) rc[stk[top]] = i;
        else     root = i;
        lc[i] = last;
        stk[++top] = i;
    }

    long long ansL = 0, ansR = 0;
    for (int i = 1; i <= n; i++) {
        ansL ^= 1LL * lc[i] * i;
        ansR ^= 1LL * rc[i] * i;
    }
    printf("%lld %lld\n", ansL, ansR);
    return 0;
}
空间开销

4 个 int 数组 × 107 = 160 MB。非常吃紧!实际比赛中可以把 stk 与 lc 复用,或把 a 用 int 而 lc/rc 用 short(不行,下标到 107)。最常见的做法是只开必要的数组并注意内存限制。

时间开销

单调栈 O(n),每个元素进出一 次,实测 n = 107 约 0.3~0.5 秒(含快读)。

校验和的用处

官方用"异或和"代替直接输出 2n 个数,既减少了输出量,又能唯一校验树的正确性(异或和碰撞概率极低)。

易错点

① 忘记 root = i(栈空的情况);② 弹出时忘记录 last;③ 用 int 存乘积导致溢出。

案例 3P3391【模板】文艺平衡树 Splay 区间操作

题意:维护一个 1 到 n 的排列,支持 m 次操作:每次给定区间 [l, r],把该区间翻转。最后输出整个序列。

数据范围:n, m ≤ 105。

思路(回顾前面讲的"夹逼"技巧):

① 建树:把 1..n 建成平衡的 Splay(中序 = 原序列),并在两端加哨兵 0 和 n+1
↓
② 每次操作 [l, r]:把第 l 个元素(原序列下标 l,加哨兵后是 l+1)splay 到根
↓
③ 把第 r 个元素(加哨兵后是 r+1)splay 到"根的右孩子"位置
↓
④ 此时"根的右孩子的左子树"就是区间 [l, r],对它打上 rev 标记(异或 1)
↓
⑤ 最后中序遍历输出,中序前先 pushdown 把标记下传
C++P3391 完整代码(Splay 区间翻转)
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 100005;
int ch[MAXN][2], fa[MAXN], sz[MAXN], val[MAXN];
bool rev[MAXN];
int root, tot, n, m;

bool isRight(int u) { return ch[fa[u]][1] == u; }
void pushup(int u) { sz[u] = sz[ch[u][0]] + sz[ch[u][1]] + 1; }

void pushdown(int u) {
    if (!rev[u]) return;
    swap(ch[u][0], ch[u][1]);
    rev[ch[u][0]] ^= 1;
    rev[ch[u][1]] ^= 1;
    rev[u] = false;
}

void rotate(int x) {
    int y = fa[x], z = fa[y], d = isRight(x);
    pushdown(y); pushdown(x);              // 旋转前先把标记下传
    ch[y][d] = ch[x][d ^ 1];
    if (ch[x][d ^ 1]) fa[ch[x][d ^ 1]] = y;
    ch[x][d ^ 1] = y; fa[y] = x;
    fa[x] = z;
    if (z) ch[z][ch[z][1] == y] = x;
    pushup(y); pushup(x);
}

void splay(int x, int goal = 0) {
    while (fa[x] != goal) {
        int y = fa[x], z = fa[y];
        if (z != goal) {
            if (isRight(x) == isRight(y)) rotate(y);
            else                          rotate(x);
        }
        rotate(x);
    }
    if (!goal) root = x;
}

// 找中序第 k 个节点(位置 k,从 1 开始)
int kth(int k) {
    int u = root;
    while (true) {
        pushdown(u);                        // 向下找时必须先下传
        if (k <= sz[ch[u][0]]) u = ch[u][0];
        else {
            k -= sz[ch[u][0]] + 1;
            if (k == 0) return u;
            u = ch[u][1];
        }
    }
}

// 建树:把 [l, r] 建成平衡的 Splay,返回根
int build(int l, int r, int f) {
    if (l > r) return 0;
    int mid = (l + r) >> 1, u = ++tot;
    val[u] = mid - 1;                  // 哨兵:0 号和 n+1 号
    fa[u] = f;  sz[u] = 1;
    ch[u][0] = build(l, mid - 1, u);
    ch[u][1] = build(mid + 1, r, u);
    pushup(u);
    return u;
}

void dfs(int u) {                    // 中序输出
    if (!u) return;
    pushdown(u);
    dfs(ch[u][0]);
    if (val[u] >= 1 && val[u] <= n) printf("%d ", val[u]);
    dfs(ch[u][1]);
}

int main() {
    scanf("%d %d", &n, &m);
    root = build(1, n + 2, 0);      // 共 n+2 个节点(含两个哨兵)
    while (m--) {
        int l, r; scanf("%d %d", &l, &r);
        int L = kth(l), R = kth(r + 2);   // 哨兵导致下标 +1
        splay(L, 0);
        splay(R, L);                       // R 转到 L 的右孩子位置
        rev[ch[R][0]] ^= 1;             // 夹出的子树打翻转标记
    }
    dfs(root);
    return 0;
}
❌ 没有加哨兵节点

如果区间是 [1, n] 的第一个到最后一个,需要找"l−1 号"和"r+1 号"——但这两个位置不存在!所以在序列两端各加一个虚拟节点(哨兵),把它们 splay 上去当"边界"。所有下标随之 +1。这是 Splay 区间操作的固定套路。

❌ 忘记在 kth 和 dfs 里 pushdown

翻转标记会影响左右孩子的顺序。任何"向下走"的过程(找第 k 个、中序遍历、继续 splay)都必须先 pushdown,否则会走到错误的子树里去。记住:"模型改过、走前清标记"。

❌ rotate 里没 pushdown

rotate 会改变父子关系,若节点上还挂着未下传的 rev,交换孩子后语义就乱了。稳妥做法:在 rotate 开头对 y 和 x 都执行 pushdown。这也是 Splay 区间题模板与普通平衡树模板的关键差异。

❌ splay 的目标位置写错

夹逼操作的正确写法是:splay(L, 0) 把 L 转到根,然后 splay(R, L) 把 R 转到"L 的右孩子"位置。这样 R 的左子树就是 [l, r]。若写成 splay(R, 0),夹逼就失效了,打标记会作用到错误的区间上。

💡 为什么"交换左右孩子"就等于"翻转区间"?

因为整个 Splay 的中序遍历对应原序列的顺序。而翻转一段区间,就是把那段序列倒过来读。在二叉树里,"倒过来读中序"最自然的实现就是把每个节点的左右孩子互换——互换后,原本"先读左再读右"变成"先读右再读左",中序结果就整体反序了。

为什么只需要打标记?因为真的把所有节点都交换一遍是 O(区间长度) 的。我们只在那棵"代表整个区间的子树根"上记一笔 rev = 1,表示"这棵子树需要翻转"。等以后真的需要访问它的内部时(pushdown),再交换它自己的两个孩子并把标记传给两个孩子。这与线段树 lazy 标记是同一个思想——延迟传播。

复杂度:每次操作两次 splay,均摊 O(log n),打标记 O(1)。总复杂度 O((n + m) log n)。

模块 03

实践应用

三道动手题,从单点查询到区间操作再到笛卡尔树建模。

题 1模板应用

实现"营业额统计":每天给出一个营业额 a[i],求每天的最小波动值(即 |a[i] − a[j]| 的最小值,j < i)。第一天的最小波动值等于 a[1]。输出所有最小波动值之和。

n ≤ 32767(原题数据)。如果 n ≤ 105 呢?

查看参考答案与解析

关键观察:对于 a[i],与它最接近的那个历史值,必然是它的前驱或后继。因为所有比 a[i] 小的数中,前驱离它最近;所有比它大的数中,后继离它最近。其他数都被这两者"夹在中间",距离更大。

所以算法是:

① 把 a[1] 插入平衡树,答案累加 a[1];

② 对 i = 2..n:在平衡树中查 a[i] 的前驱 p 和后继 q,取 min(a[i]−p, q−a[i])(不存在的方向忽略),累加进答案;然后把 a[i] 插入。

复杂度:每次前驱/后继 O(log n),总共 O(n log n)。

n ≤ 32767 的朴素做法:对每个 i 线性扫描前面的所有 a[j],O(n2) ≈ 109/2,勉强能过(这就是原题数据小的原因)。但如果是 105 级别,必须用平衡树,否则 TLE。这正说明了"数据范围决定算法"——看到 n ≤ 3 × 104 就可以偷懒,看到 105 就必须上数据结构。

细节处理:前驱/后继不存在时用 ±∞ 哨兵,但累加时要判断"这一侧是否真的存在"。也可以直接把哨兵设为 INT_MIN/2 与 INT_MAX/2,这样差值不会溢出,再配合"若前驱不存在则答案为后继−a[i]"的分支判断。

替代做法:把所有权值离散化后,用树状数组 + 二分求前驱后继,也能做到 O(n log n),代码更短(不需要平衡树)。只要题目是离线的(所有询问已知),离散化 + BIT 往往比平衡树更划算。

另一种替代:把所有 (a[i], i) 排序,用双向链表维护"当前还能用的位置",从大到小删除。这是经典的"链表离线"技巧,O(n log n) 排序 + O(n) 处理,常数极小。

题 2区间操作

用 Splay 维护一个序列,支持:① 在位置 p 后插入一个数;② 删除位置 p 的数;③ 查询位置 p 的数;④ 翻转区间 [l, r]。请说明每种操作如何用"夹逼"完成。

查看参考答案与解析

基础设定:建树时加两个哨兵节点(左哨兵在头、右哨兵在尾)。树的中序 = 原序列 + 左右哨兵。用 sz 定位第 k 个位置,键值就是"位置"。

① 在位置 p 后插入 v:把位置 p 对应的节点 splay 到根,把位置 p+1 splay 到"根的右孩子"。此时"根的右孩子的左子树为空"(因为 p 和 p+1 之间没有任何元素)。新建一个节点 v,挂成那棵空左子树的根即可。最后沿路径 pushup。

② 删除位置 p:把位置 p−1 splay 到根,位置 p+1 splay 到"根的右孩子"。此时"根的右孩子的左子树"就是只包含位置 p 这一棵子树(可能不止一个节点,如果那是一个子树)。最简单的情形下把它整棵删掉(ch[R][0] = 0),然后 pushup。

③ 查询位置 p:直接调用 kth(p)——从根往下,比较 k 与左子树大小,走左/取根/走右。每次向下前要 pushdown。最后 splay 到根(可选,但 splay 有助于均摊复杂度)。

④ 翻转区间 [l, r]:把位置 l−1 splay 到根,位置 r+1 splay 到根的右孩子。此时"根的右孩子的左子树"恰好是区间 [l, r],对它打 rev ^= 1。

四个操作的统一模式:都是"先定位两个边界节点并 splay,然后对夹出来的子树做一次操作"。区别只在于: · 插入 → 给空左子树挂新节点 · 删除 → 把左子树清空 · 翻转 → 打 rev 标记 · 查询 → 读该子树的根信息

易错点一:所有"位置"都要加 1(因为有左哨兵)。易错点二:插入后要沿路径 pushup 更新 sz,否则后续 kth 会定位错。易错点三:每次向下走之前必须 pushdown,否则 rev 标记会导致走错分支。

为什么这种"序列 Splay"能成立?因为它的 BST 键不是"值",而是"位置"——中序就是序列顺序。所以只要保持中序不变,就能用"第 k 个"来定位任意位置。这与普通平衡树"按键值比较"完全不同,是 Splay 独有的能力。

题 3笛卡尔树建模

给定数组 a[1..n],求"所有子区间的最大值之和",即 Σ1≤l≤r≤n max(a[l..r])。要求 O(n)。

查看参考答案与解析

核心思路:换个角度统计——"每个元素作为多少个子区间的最大值"。

第一步:对 a 建大根堆笛卡尔树(根是最大值)。这样对任意节点 u,"以 u 为根的子树"恰好对应一个连续区间,且 a[u] 是整个子树区间里的最大值。

第二步:确定每个节点能"管辖"的范围。设以 u 为根的子树对应区间 [L, R](可在建树后一次 DFS 求出)。那么 a[u] 会作为 [l, r] 的最大值,当且仅当:l 在左子树范围内(或就是 u),r 在右子树范围内(或就是 u)。

第三步:计数。设 u 的左子树大小为 szL、右子树大小为 szR。则包含 u 且"最远不超过子树范围"的区间个数为:

(szL + 1) × (szR + 1)

其中 szL + 1 是"左端点可选的位置数"(左子树内任意位置,或就是 u 自己);szR + 1 同理是右端点可选位置数。两者相乘就是"以 u 为最大值"的区间总数。

第四步:求和。答案 = Σu a[u] × (szL[u] + 1) × (szR[u] + 1)。

为什么不会重复计数?因为每一个子区间 [l, r] 的最大值位置是唯一确定的。设那个位置是 p,那么在笛卡尔树上,p 一定是 [l, r] 所有元素对应节点的 LCA(这是前面讲的性质)。反过来说,只有 p 这一个节点的计数会覆盖到 [l, r]。所以每个区间恰好被统计一次。

复杂度:单调栈建树 O(n),DFS 求子树大小 O(n),累加 O(n),总计 O(n)。

还有一种等价的做法(不用笛卡尔树):用单调栈求出每个位置 i 的"左边第一个比它大的位置 L[i]"和"右边第一个比它大的位置 R[i]",那么 i 作为最大值的区间数是 (i − L[i]) × (R[i] − i)。这与笛卡尔树的 (szL+1)(szR+1) 完全等价——因为 szL = i − L[i] − 1,szR = R[i] − i − 1,代入即得。

本题的价值:它展示了笛卡尔树最典型的用途——把"枚举区间"转化成"枚举每个元素作为极值的贡献"。这个技巧叫做"贡献法 / 单调栈统计",在提高级和 NOI 中反复出现(如"所有子区间最小值之和""直方图最大矩形""柱状图中接雨水"等)。有了笛卡尔树的视角,这些题就统一成了"树上子树大小计算"。

模块 04

总结提升

①核心要点与万能模板
知识板块必须记住的核心复杂度
BST中序递增;所有操作 O(h);有序数据会退化成链最坏 O(n)
旋转右旋:左孩上位、内子树过继、接通上级;中序不变O(1)
AVL|BF| ≤ 1;同号单旋、异号双旋;插入最多两次旋转严格 O(log n)
Treapkey 满足 BST、pri 满足小根堆;插入上浮、删除下沉期望 O(log n)
Splay访问后伸展到根;zig-zig 先转父、zig-zag 先转己均摊 O(log n)
Splay 区间两端夹逼:splay(l−1,0) + splay(r+1, l−1),夹出的子树即区间均摊 O(log n)
区间翻转打 rev 标记;pushdown 时交换左右孩子并下传O(1) 打标记
笛卡尔树下标 BST + 值堆;形态唯一;根是最值位置O(n) 建树
单调栈建树弹出所有比 a[i] 大的,弹出一串整体成为 i 的左子树O(n)
RMQ 转化区间最小值 = 两端点在笛卡尔树上的 LCA 的值配 ST 表 O(1)
C++万能模板 · Treap(含六种操作,可平移到其他平衡树)
int lc[MAXN], rc[MAXN], key[MAXN], pri[MAXN], sz[MAXN], cnt[MAXN];
int tot, root;
mt19937 rng(chrono::steady_clock::now().time_since_epoch().count());

int newNode(int k) {
    key[++tot] = k; pri[tot] = rng(); sz[tot] = cnt[tot] = 1;
    return tot;
}
void pushup(int u) { sz[u] = sz[lc[u]] + sz[rc[u]] + cnt[u]; }
void rotR(int& y) { int x = lc[y]; lc[y] = rc[x]; rc[x] = y; pushup(y); pushup(x); y = x; }
void rotL(int& x) { int y = rc[x]; rc[x] = lc[y]; lc[y] = x; pushup(x); pushup(y); x = y; }

void insert(int& u, int k);   // 见案例 1
void erase(int& u, int k);    // 见案例 1
int  getRank(int u, int k);
int  getKth(int u, int k);
int  getPre(int u, int k, int best);
int  getNxt(int u, int k, int best);

🧭 看到题目怎么选平衡树?

① 需要严格的理论保证?→ AVL(严格 O(log n)),但常数最大、代码最复杂。竞赛中很少用。

② 只需要普通平衡树六种操作?→ Treap 或 FHQ-Treap。代码短、常数小,首选。

③ 需要区间操作(翻转、切分、移动)?→ Splay(或 FHQ-Treap)。Splay 的"夹逼"技巧是标准做法。

④ 需要可持久化?→ 可持久化 Treap / FHQ-Treap(Splay 因为要大量旋转,可持久化代价很高)。

⑤ 只需要"前驱后继 + 离线"?→ 离散化 + 树状数组 / 双向链表,比平衡树更短更快。先问自己"能不能不用平衡树"。

⑥ 只是求区间最值(静态)?→ ST 表 / 笛卡尔树 + LCA,O(n) 或 O(n log n) 预处理,查询 O(1),比平衡树好得多。

📌 一句话经验:竞赛里 90% 的"平衡树题"都能用 Treap 或 FHQ-Treap 解决;Splay 只在区间问题时才必要;AVL 主要出现在理论课上。

②拓展案例
拓展 1FHQ-Treap

什么是"无旋 Treap"?它为什么比普通 Treap 更受欢迎?

查看参考答案

FHQ-Treap(范浩强 Treap,也叫分裂合并 Treap)的核心思想:完全不用旋转,只用 split 和 merge 两个操作。

split(u, k, x, y):把以 u 为根的树按值域拆成两棵——x 里全 ≤ k,y 里全 > k。(也可以按"前 k 个元素"拆分,用于序列问题。)实现方式是递归:看 key[u] 与 k 的关系,决定把 u 归到哪一边,然后递归拆它的一个孩子。

merge(x, y):把两棵树合并成一棵(要求 x 的键全部小于 y 的键)。比较 pri[x] 与 pri[y]:谁小谁当根,然后把另一棵树递归地接到它的孩子上。这其实就是"按 pri 决定祖先"的过程。

有了这两个操作,所有操作都变得极其简单:

· 插入 k:split(root, k, x, y); root = merge(merge(x, newNode(k)), y);

· 删除 k:split(root, k, x, z); split(x, k-1, x, y); y = merge(lc[y], rc[y]); root = merge(merge(x, y), z);(把值为 k 的那一段整段丢掉)

· 查询排名 / 第 k 小 / 前驱 / 后继:同样靠 split 把目标值域"切出来"再读信息。

· 区间翻转:建树时用"按位置拆分",翻转就变成 split(root, l-1, a, b); split(b, r-l+1, b, c); rev[b] ^= 1; root = merge(merge(a,b),c);——比 Splay 的夹逼好写太多。

为什么更受欢迎?① 代码更短、更不容易写错(没有旋转的下标处理);② 天然支持可持久化(split/merge 都是"创建新节点"而不是"修改指针",复制路径即可);③ 支持区间操作,一个模板解决所有问题。

代价:常数比普通 Treap 稍大(split + merge 各有一次递归);期望复杂度同样是 O(log n)。

建议:如果你刚开始学平衡树,直接学 FHQ-Treap 性价比最高——一套模板打遍天下。普通 Treap 主要价值在于理解"旋转 + 堆"的思想。

拓展 2笛卡尔树 + 分治

用笛卡尔树解决:"统计有多少个子区间的最大值与最小值之差不超过 K"。(经典难题,说说思路即可)

查看参考答案

这是一道"笛卡尔树 + 启发式合并"的经典题(类似 P4755 Beautiful Pair 的变体)。完整做法非常复杂,这里只讲思想框架。

第一步:建大根堆笛卡尔树。这样每个节点 u 对应一个连续区间,且 a[u] 是该区间的最大值。

第二步:分治。考虑"最大值恰好是 a[u]"的那些区间。设 u 的左子树大小 szL、右子树大小 szR。区间 [l, r] 的最大值为 a[u],当且仅当 l 落在 u 的左子树范围内、r 落在右子树范围内(或其中之一是 u 自己)。

第三步:启发式合并(小的枚举、大的用数据结构查)。如果 szL ≤ szR,就枚举左端点 l,然后需要在右子树中找到一个 r,使得 min(a[l..r]) ≥ a[u] − K。由于把左端固定后,"右端能延伸多远"是单调的,可以用线段树 / 树状数组维护最小值的前缀和来 O(log n) 求出合法 r 的个数。反之若 szR < szL,就枚举右端点。

第四步:复杂度。每次选"较小的那一边"枚举,符合启发式合并的标准套路,总枚举量是 O(n log n),配合数据结构查询 O(log n),总复杂度 O(n log2 n)。

为什么这个思路值得记住?它体现了两个重要的竞赛模式:

① "枚举最大值位置"——把"子区间统计"转化成"以某点为极值的贡献计算"。这是笛卡尔树最核心的用途。

② "启发式合并 / 小大合并"——每次枚举较小的一边,保证总复杂度不超过 O(n log n)。这个技巧在并查集启发式合并、树上启发式合并(DSU on tree)、左偏树合并中都反复出现。它和本讲第 3 讲学的"按大小合并"是同一个思想。

更简单的同类题:P4755 Beautiful Pair(统计 a[i] × a[j] ≤ max(a[i..j]) 的 (i,j) 对数)——同样用笛卡尔树 + 分治 + 启发式合并,是练习这个套路的最佳入门题。

拓展 3其他平衡树

除了 AVL、Treap、Splay,还有哪些平衡树?各自的特点是什么?

查看参考答案

① 红黑树(Red-Black Tree)。通过给节点染色(红/黑)和一套染色规则来维持"近似平衡"(最长路径不超过最短路径的两倍)。插入删除的旋转次数是 O(1)(最多 3 次),比 AVL 少得多,所以工业界的标准库几乎都用它——C++ 的 std::map / std::set、Java 的 TreeMap 都是红黑树实现。理论上更平衡,实际工程上更实用。

② 替罪羊树(Scapegoat Tree)。不加任何平衡信息,插入时正常挂上,然后检查路径上有没有"子树过大"的节点(sz[lc] > α·sz[u] 或 sz[rc] > α·sz[u],α 通常取 0.7)。一旦发现,就把那棵子树重建成完全平衡的("拍平再二分建树")。均摊 O(log n),且不用旋转、支持可持久化(因为重建时是复制而不是修改)。它是"懒惰重建"思想的代表。

③ WAVL 树 / 弱 AVL。AVL 的改良版,放宽平衡约束,使删除时的旋转次数从 O(log n) 降到 O(1)。理论价值高,竞赛中很少用。

④ 跳表(Skip List)。严格说不是树,而是多层链表:底层是完整有序链表,上层是"抽样"出来的索引层。查找时从最高层往下"跳跃"。实现比平衡树简单得多,期望 O(log n),天然支持并发。Redis 的 zset 就是跳表实现。竞赛中偶尔用到(因为它比平衡树好写)。

⑤ 左偏树 / 配对堆 / 斐波那契堆。这些是可并堆——支持 O(log n) 或均摊 O(1) 的"合并两个堆"操作。它们不是 BST,所以不支持"求排名/第 k 小"。适用场景完全不同:可并堆用于"合并两个集合的最值"。

⑥ B 树 / B+ 树。多路平衡查找树,每个节点存多个键、有多个孩子。专为"磁盘/数据库"设计——减少树高就意味着减少磁盘 I/O 次数。MySQL 的索引、文件系统都用 B+ 树。竞赛中不必深入,但要知道它的存在与目的。

选型速查:
· 要排名/第 k 小 → Treap / FHQ-Treap / Splay / 红黑树(用 std::set 不行,它不支持排名)
· 要区间操作 → Splay / FHQ-Treap
· 要可持久化 → FHQ-Treap / 替罪羊树
· 要合并两个堆 → 左偏树 / 配对堆
· 要工程实现 → 红黑树 / 跳表

本讲的定位:CSP-S 提高级大纲要求掌握的就是 AVL、Treap、Splay 三种,其中 Treap 是最需要能独立写出来的。其他平衡树了解思想即可,不需要默写模板。

③思考与启发

💭 思考 1:为什么"随机"能保证平衡?

查看参考答案

因为随机化把"最坏情况"变成了"概率极小的事件"。

BST 退化的根本原因是"插入顺序有规律"。而 Treap 用随机 pri 决定了"谁是祖先",等价于把插入顺序随机打乱。打乱之后的顺序是均匀随机的,而随机顺序建出的 BST 期望高度是 O(log n)。

有多大概率会退化?可以证明,随机 BST 的高度超过 c·log n 的概率是指数级小的(Chernoff 界的典型应用)。也就是说,n = 105 时,坏情况的概率小到在宇宙寿命内都不会发生。

但"随机"不是"绝对安全"。如果出题人知道你的随机种子,就能构造针对性的数据。所以真正的比赛实现会用 chrono::steady_clock 取种子,让攻击者无从下手。这就是"随机化算法必须让对手无法预测随机数"这一原则的体现。

更广泛的启发:随机化是算法设计中一种"以概率换确定性"的通用手段。除了 Treap,还有:随机哈希(防卡)、随机增量(随机化快排的 pivot)、随机撒点(最小圆覆盖、平面最近点对)、Miller-Rabin 素性测试、Pollard-Rho 分解质因数。它们的共同点都是"接受极小的失败概率,换取巨大的效率提升"。

💭 思考 2:既然有 std::set,为什么还要手写平衡树?

查看参考答案

先明确:能用 std::set 解决的问题,绝对不要手写平衡树。库函数常数小、无 bug、代码短,是竞赛中的最佳选择。

std::set 能做的:插入、删除、查找、前驱(--it)、后继(++it)、返回迭代器。

std::set 做不到的(必须手写):

① 求排名(order_of_key)——红黑树节点不存子树大小,无法 O(log n) 求排名。除非用 __gnu_pbds::tree,但那在部分比赛中不可用。

② 求第 k 小——同上。

③ 区间操作(翻转、切分、按位置插入删除)——std::set 的键是值,不能按位置操作。

④ 可持久化——库容器不支持。

⑤ 节点上挂附加信息(如区间和、最大值、懒标记)——库容器不给改。

⑥ 合并两棵平衡树——除 pb_ds 外无解。

所以判断标准很简单:如果题目要"求排名/第 k 小/区间操作/可持久化",就必须手写。否则优先用库。

更重要的理由——理解本身有价值。手写平衡树的过程会让你彻底理解"树高为什么决定复杂度""旋转如何改变结构""懒标记怎么在树上工作"。这些理解会迁移到线段树合并、树链剖分、LCT 等更高级的主题上。库能帮你通过比赛,但手写能帮你走得更远。

💭 思考 3:笛卡尔树为什么"没有平衡树那么有名"?

查看参考答案

因为它解决的是完全不同的问题。

平衡树的使命是"动态维护有序集合"——支持插入删除、求排名、求前驱后继。这是"在线的、动态的"需求。

笛卡尔树的形态由数组完全决定,不能动态插入删除(插一个元素会改变整棵树)。它是"静态的、离线的"结构。它的价值在于:

① 把区间问题转化为树形结构——区间最小值变成 LCA,区间统计变成子树大小计算。

② 把"分治"变成"树上 DP"——"以最小值为分界递归处理左右"这个分治过程,被结构化成了一棵树,于是可以自底向上算子树信息,避免递归分治的边界讨论。

③ 用一个结构统一解释多种技巧——"单调栈求左右第一个更小值""所有子区间最小值之和""直方图最大矩形",这些看似无关的题目,在笛卡尔树的视角下全都是同一件事。

更深的启发:"把一个过程结构化成树"是一种极高价值的思维。除了笛卡尔树,还有:线段树把"区间二分"结构化成树、Kruskal 重构树把"最小生成树合并过程"结构化成树、圆方树把"点双连通分量"结构化成树、后缀树把"所有后缀"结构化成树。当你能把一个问题"构造出一棵树",往往就找到通解了。

回到本讲:笛卡尔树与 Treap 的等价性告诉我们——"随机 Treap"和"固定优先级的笛卡尔树"是同一棵树。求最值用后者(静态 O(n)),要动态修改用前者(期望 O(log n))。同一份结构,两种用法,这就是"打通理解"的快乐。

模块 05

学习检测

先找错,再背口诀——把平衡树最常见的 6 个坑消灭在考试之前。

⚡高频错误诊断
易错1

旋转时下标写反,子树丢失或成环

❌ 错误
ch[u][d] = p; —— 把 d 与 d^1 用反了。右旋(d=0)时 p 应成为 u 的右孩子,写成 ch[u][0] = p 会让 u 的左孩子指着 p、p 又指着 u,直接成环或整棵子树被覆盖丢失。
✅ 正确
牢牢记住:ch[p][d] = w 与 ch[u][d^1] = p。一个是 d,一个是 d^1,配对使用。写完画个 3 节点的小例子验证一下。
口诀:父用 d、子用 d^1,配对不忘更上级!
易错2

pushup 在旋转中的顺序颠倒

❌ 错误
pushup(x); pushup(y);(右旋时先更新上位的 x)—— 此时 y 还未更新,x 读到的还是旧的 y.size,算出来的 size 全错。排名查询会随机出错。
✅ 正确
右旋时先 pushup 下沉的 y,再 pushup 上位的 x(因为 x 的儿子包含 y)。规律:自下而上更新。
口诀:谁在下谁先算,自下而上不出错!
易错3

Splay 的 zig-zig 写成了连续单旋

❌ 错误
写成 while (fa[x]) { rotate(x); } —— 每次只转 x 一层。这样在 zig-zig 情形下,树的形态会退化成一条长链,均摊复杂度从 O(log n) 恶化到 O(n)。
✅ 正确
必须先判断:if (isRight(x) == isRight(y)) rotate(y); else rotate(x);,然后再 rotate(x)。这个"同向先转父"的处理正是 Splay 均摊分析的基石。
口诀:同向先转父,异向先转己,最后都转己!
易错4

可重集忘维护 cnt,导致重复元素丢失

❌ 错误
插入时 if (k == key[u]) return; —— 直接忽略重复元素。但题目是可重集(如 P3369 要"删除一个 x""求 x 的排名"),忽略重复会算错排名。
✅ 正确
加一个 cnt[u]:插入重复时 cnt[u]++;sz[u] 要包括 cnt[u];kth 里要判断 k <= cnt[u];删除时若 cnt[u] > 1 只减一。
口诀:可重集要记 cnt,排名第 k 都靠它!
易错5

笛卡尔树单调栈忘了记录 last

❌ 错误
while (top && a[stk[top]] > a[i]) top--; —— 只弹栈不保存。那么 lc[i] 无从设置,被弹出的整条链直接从树里消失了。
✅ 正确
while (top && a[stk[top]] > a[i]) last = stk[top--];,然后 lc[i] = last;。last 最后停留在"被弹出链的最顶端",恰好就是 i 左子树的根。
口诀:弹栈必记 last,弹出的整串当左子树!
易错6

随机数范围不够,导致 pri 大量重复

❌ 错误
用 rand() 生成 pri。Windows 下 RAND_MAX = 32767,而节点数可能有 105 个——大量节点的 pri 相同,堆性质形同虚设,树高失控退化。
✅ 正确
用 mt19937 rng(chrono::steady_clock::now().time_since_epoch().count());,取 rng() 得到 32 位随机数。种子不要写死,否则可能被构造数据卡掉。
口诀:随机数要够大,种子别写死!
易错7

Splay 区间操作忘记 pushdown

❌ 错误
只在打标记时交换孩子,而 kth、dfs、rotate 里不 pushdown。此时 rev 标记一直挂着,左右子树顺序与实际不符,定位第 k 个会走错分支。
✅ 正确
任何"向下走"的操作前都 pushdown:kth、中序遍历、rotate 开头对 y 和 x 都下传。记住:"模型改过、走前清标记。"
口诀:要下访、先下传,rev 标记别忘了!
📋平衡树与笛卡尔树速查表
环节关键代码 / 要点复杂度
右旋(对 y)x = lc[y]; lc[y] = rc[x]; rc[x] = y; 再 pushup(y)、pushup(x)O(1)
左旋(对 x)y = rc[x]; rc[x] = lc[y]; lc[y] = x; 再 pushup(x)、pushup(y)O(1)
AVL 插入递归插入 → pushup → 算 BF → |BF|>1 时按四种情况旋转O(log n)
AVL 失衡类型LL 右旋 / RR 左旋 / LR 先左后右 / RL 先右后左O(1)
Treap 插入BST 挂上 → if (pri[child] < pri[u]) rotate(u)期望 O(log n)
Treap 删除把 pri 小的孩子旋上来,下沉到叶子再删期望 O(log n)
Splay 伸展同向先转父、异向先转己,最后统一转 x均摊 O(log n)
Splay 区间splay(l-1, 0); splay(r+1, l-1); 夹出子树均摊 O(log n)
区间翻转对夹出的子树 rev ^= 1;pushdown 时 swap 孩子并下传O(1)
笛卡尔树建树单调栈:弹出比 a[i] 大的,lc[i] = last,rc[栈顶] = iO(n)
节点父亲左边第一个更小值 L 与右边第一个更小值 R 中较小者O(1) 查表
区间最小值笛卡尔树上两端点的 LCA 的值配 ST 表 O(1)
子树大小求贡献a[u] 作为最大值的区间数 = (szL+1) × (szR+1)O(n)
需求推荐结构关键操作难度
集合增删查std::setinsert / erase / find★
前驱后继std::set / 平衡树--it / ++it★★
排名 / 第 k 小Treap / Splay / pb_ds走路径累加 sz★★★
可重集 + 排名Treap(带 cnt)维护 cnt 并入 sz★★★
区间翻转 / 移动Splay / FHQ-Treap夹逼 + rev 标记★★★★
静态区间最值ST 表 / 笛卡尔树预处理 O(n log n) 或 O(n)★★★
子区间极值统计笛卡尔树 + DP贡献法:枚举极值位置★★★★
可持久化平衡树FHQ-Treapsplit / merge 复制路径★★★★★
📌知识点笔记卡
🧠 必记概念
  • BST 复杂度看树高 h
  • 旋转是唯一的整形手段
  • AVL:|BF| ≤ 1,同号单旋异号双旋
  • Treap:key 走 BST、pri 走小根堆
  • 笛卡尔树:下标 BST + 值堆,形态唯一
📝 必背模板
  • rotate:ch[p][d] = w;ch[u][d^1] = p
  • pushup 自下而上:先 p 后 u
  • Treap 插入:比 pri,小了就 rotate
  • Splay:同向先转父,最后转 x
  • 单调栈:while 弹出并记 last
⚠️ 考场提醒
  • 能用 std::set 就别手写
  • 随机种子用 chrono,别写死
  • Splay 向下走必 pushdown
  • n = 107 别用递归建树
  • 算内存:笛卡尔树 4 个 int 数组 × n
课后练习

分层练习作业

A基础 / B提高 / C挑战,难度递增。C组后附 12 道洛谷原题。

🟢 A组 · 基础(4题)

A1概念题

依次插入 5, 3, 8, 2, 4, 7, 9 到一棵空的 BST(不旋转)。
① 画出这棵树;② 求树高;③ 求中序遍历序列;④ 若改成插入 1, 2, 3, 4, 5, 6, 7,树高又是多少?

查看参考答案

① 结构:5 是根;左子树以 3 为根(左 2、右 4);右子树以 8 为根(左 7、右 9)。

完整描述:5(3(2,4), 8(7,9))。

② 树高 = 3(从根 5 到叶子 2 经过 3 条边)。n = 7 时理论上最小高度是 ⌈log28⌉ = 3,所以这棵树恰好是最优形态。

③ 中序遍历:2, 3, 4, 5, 7, 8, 9——恰好递增,验证了 BST 的性质。(注意原数据里没有 6,如果插入的是 1..7 就是 1..7 递增。)

④ 插入 1..7 时的形态:1 是根,2 是 1 的右孩子,3 是 2 的右孩子……一条向右的链,树高 = 7 = n。

对比:同样 7 个元素,一个有规律(5,3,8,2,4,7,9 呈"中间数先插"的规律)树高 3;一个递增,树高 7。查找最后一个元素的比较次数分别是 3 次和 7 次。

本题的训练目标:亲手体会"树高完全由插入顺序决定,而与元素个数无关"。这也解释了为什么"先取中间的数"建树能得到最平衡的形态——这正是"二分建树"的原理。

推论:如果一个 BST 能被真正随机化地插入,期望树高是 O(log n)。这直接引出了 Treap 的设计思路。

A2手工模拟

对下面这棵树(x 是根,左孩子 y,y 的右孩子 z,x 的右孩子 w)执行一次"对 x 的右旋"。请写出操作过程和结果,并验证中序不变。

查看参考答案

初始结构:x 有两个孩子,左孩子是 y,右孩子是 w;y 没有左孩子,右孩子是 z。

用嵌套写法:x(y(_, z), w)。

旋转过程(对 x 右旋):

① x 的左孩子是 y,y 的内子树(右子树)是 z;

② 把 z 过继给 x 当左孩子:lc[x] = z;

③ x 降级为 y 的右孩子:rc[y] = x;

④ y 替代 x 原来的位置。若 x 原是整棵树的根,则新根是 y。

旋转后结构:y(_, x(z, w))。

中序验证:

· 旋转前中序:y、z、x、w(先左 y,再 y 的右 z,再 x,再右 w)

· 旋转后中序:y、z、x、w(先左子树空,再 y,再 y 的右子树 x(其中 x 的左 z、右 w))

完全一致!这正是旋转的定义要求。

高度变化分析:

· 旋转前:因 z 挂在 x 的左子树的下面,x 的左高度为 2,右高度为 1。

· 旋转后:x 的左右高度都是 1;y 的右高度是 2,左为 0。

但注意:这次旋转并没有让整棵树变得更平衡(y 反而歪向了右边)!它只是把"左边过深"的具体形状改了。所以"旋转一定让树更平衡"是错的——只有针对正确的失衡类型选择正确的旋转,才能修复平衡。这正是为什么 AVL 要分 LL/RR/LR/RL 四种情况讨论。

本题的训练目标:把"旋转只改 3 条边、中序不变"这一点彻底掌握,并意识到"旋转不是万能的,必须用对场合"。

A3编程题

写出 Treap 的 init / newnode / pushup / rotL / rotR / insert 六个函数,并在 main 中插入 5 个数后中序遍历输出,验证结果递增。

查看参考答案
C++A3 参考代码
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 100005;
int lc[MAXN], rc[MAXN], key[MAXN], pri[MAXN], sz[MAXN], cnt[MAXN];
int tot = 0, root = 0;
mt19937 rng(chrono::steady_clock::now().time_since_epoch().count());

int newNode(int k) {
    key[++tot] = k;  pri[tot] = rng();
    sz[tot] = cnt[tot] = 1;
    lc[tot] = rc[tot] = 0;
    return tot;
}
void pushup(int u) { sz[u] = sz[lc[u]] + sz[rc[u]] + cnt[u]; }

void rotR(int& y) {
    int x = lc[y];
    lc[y] = rc[x];  rc[x] = y;
    pushup(y); pushup(x);              // 自下而上
    y = x;
}
void rotL(int& x) {
    int y = rc[x];
    rc[x] = lc[y];  lc[y] = x;
    pushup(x); pushup(y);
    x = y;
}

void insert(int& u, int k) {
    if (!u) { u = newNode(k); return; }
    if (k == key[u]) { cnt[u]++; pushup(u); return; }
    if (k < key[u]) { insert(lc[u], k); if (pri[lc[u]] < pri[u]) rotR(u); }
    else            { insert(rc[u], k); if (pri[rc[u]] < pri[u]) rotL(u); }
    pushup(u);
}

void inorder(int u) {
    if (!u) return;
    inorder(lc[u]);
    for (int i = 0; i < cnt[u]; i++) cout << key[u] << ' ';
    inorder(rc[u]);
}

int main() {
    int a[5] = {5, 3, 8, 3, 1};   // 故意含重复值,测试 cnt
    for (int x : a) insert(root, x);
    inorder(root);
    cout << '\n';
    cout << "size = " << sz[root] << '\n';
    return 0;
}

预期输出:1 3 3 5 8,然后 size = 5。

自查清单:

· tot 和 root 都初始化为 0 了吗?

· rotR / rotL 的参数是引用(int&)吗?如果不是引用,新根无法传回给调用者。这是最容易被忽略的一点。

· pushup 的顺序是"先下沉的节点、后上位的节点"吗?

· insert 里 pri[child] < pri[u] 的条件对吗?(小根堆,所以是父 pri 必须小于孩子;一旦孩子更小就违反,需要旋转)

· 中序输出时,对 cnt[u] > 1 的节点是否输出了多次?(因为可重集里同一个值出现多次)

调试技巧:写完后可以打印每个节点的 (key, pri, sz) 并检查"每个父的 pri 都小于孩子的 pri"——这是验证 Treap 正确性最快的方法。

A4概念辨析

判断正误并说明理由:
① 平衡树的所有操作复杂度都是严格 O(log n)。
② AVL 树比 Treap 更"平衡",所以 AVL 一定更快。
③ 笛卡尔树一定是一棵平衡的二叉树。
④ Splay 的单次操作可能耗时 O(n),所以 Splay 不如 Treap。
⑤ 如果数组已经有序,笛卡尔树会退化成一条链。

查看参考答案

① 错误。只有 AVL 是严格 O(log n)。Treap 是期望 O(log n)(单次最坏可为 O(n)),Splay 是均摊 O(log n)(单次最坏可为 O(n))。三种"平衡"的含义完全不同。

② 错误。AVL 更平衡意味着查询路径更短,所以查询确实可能更快。但 AVL 维护平衡的代价更大——插入删除时要频繁更新高度并可能多次旋转,而 Treap 只需比较一次 pri 决定是否旋转。对于"插入删除多、查询少"的场景,Treap 反而更快。"更平衡"不等于"综合更快"。

③ 错误。笛卡尔树的形态完全由数组决定,不保证平衡。例如数组 [1, 2, 3, 4, 5](递增),最小值 1 在下标 1,它的右子树包含下标 2..5;在右边这部分里最小值是 2(下标 2),再往右……结果是一条向左的斜链(每次都是"根 + 右子树",没有左孩子)。反之若数组对应"最小元素恰好在中间"的完全二叉树形态,笛卡尔树也是完全平衡的。

④ 错误。Splay 是均摊 O(log n)——意思是"m 次操作的总时间是 O((n+m) log n)"。单次操作慢,意味着它把"预支"的便宜还回去了。在 OI 比赛(只限制总时间)中,均摊复杂度完全够用。而且 Splay 还有 Treap 做不到的能力:区间操作(翻转、切分、移动)。

⑤ 正确。以最小值笛卡尔树为例,数组递增时 a[1] 最小,成为根;它的左子树为空(因为左边没元素),右子树是 a[2..n],其中 a[2] 最小……结果是一条只有右孩子的斜链,高度 n。完全对称地,若数组递减,会变成只有左孩子的斜链。

⑤ 的补充说明:这就是为什么笛卡尔树不能用来做动态查找——它的形态由数据决定,可能完全退化。它的用途是"提供一种树形结构来做静态统计",而不是"加速查找"。

本题的最大价值:它逼你分清"严格 / 期望 / 均摊"这三种复杂度保证,以及"更平衡"和"更高效"之间的区别。这三组概念是平衡树部分最容易被混淆、也最容易出判断题的地方。

🟡 B组 · 提高(4题)

B1AVL 实现

给出 AVL 树插入 10、20、30、40、25 的完整过程(画出每次插入及旋转后的形态,并标出各节点的平衡因子)。

查看参考答案

插入 10:树中只有 10,BF[10] = 0,平衡。

插入 20:20 > 10 → 10 的右孩子。BF[10] = 0 − 1 = −1,平衡。(|−1| ≤ 1 是允许的)

插入 30:30 > 10、30 > 20 → 20 的右孩子。回溯:hgt 更新后 BF[20] = −1,BF[10] = −2。节点 10 失衡,且方向是"右右"→ RR 型 → 对 10 左旋一次。

旋转后:20 是根,左孩子 10,右孩子 30。三个节点的 BF 全为 0。完全平衡。

插入 40:40 > 20、40 > 30 → 30 的右孩子。回溯:BF[30] = −1,BF[20] = 0 − 2 = −2。20 失衡,RR 型 → 对 20 左旋。

旋转后:30 是根,左孩子 20(左孩子 10),右孩子 40。BF[30] = 1 − 1 = 0,BF[20] = 0,BF[40] = 0。平衡。

插入 25(关键的一步):25 > 30?不,25 < 30 → 走左;25 > 20 → 走右;挂到 20 的右边。

回溯:hgt[25] = 1,hgt[20] = 2,hgt[30] = 3。BF[30] = hgt[lc] − hgt[rc] = 2 − 1 = +1,暂时安全。继续看更上层——30 是根,所以没有上层了。

慢着,需要重新确认!让我们重新梳理:插入 40 后树是 30(20(10, _), 40)。hgt[20] = 2(因为 20 有左孩子 10),hgt[40] = 1,hgt[30] = 3,BF[30] = 2 − 1 = +1。注意这里 BF 是 +1,不是 0——所以插入 40 后就已经略微左偏了,只是还没超过 1。

现在插入 25:25 挂在 20 的右边。此时 hgt[20] = max(1, 1) + 1 = 2(不变!因为 20 原来就有左孩子 10,高度已经是 2)。所以 20 的高度没有变化,30 的高度也不变。整棵树插入 25 后没有引发任何失衡!

最终形态:30(20(10, 25), 40)。BF[20] = 0,BF[30] = 1,BF[40] = 0,其余叶子 BF = 0。所有 |BF| ≤ 1,合法。

本题的关键教学点:插入不一定引发失衡。只有当"新节点让某条路径的高度真正增加了"时,才可能失衡。如果插入的位置"填进了原来空着的那个位置"(左右由不平衡变成平衡),高度反而不会增加。

建议:把这道题的五步全部手画一遍,并特别留意"高度是否真的变化"。90% 的 AVL 手算错误都源于"以为每次插入都会让高度加一"。

B2Splay 应用

为什么 Splay 能支持"把区间 [l, r] 整体移动到位置 p 处"这样的操作,而线段树不能?请描述 Splay 的做法。

查看参考答案

根本区别:线段树的区间划分是"静态的",Splay 的区间划分是"动态的"。

线段树把 [1, n] 固定二分成一棵树,每个节点永远对应同一个下标区间。一旦元素移动,整个划分就失效了——所以线段树只能"改值",不能"改序"。

而 Splay 维护的是"中序顺序",只要通过旋转保持中序不变,元素的相对顺序就可以任意重排。"移动区间"本质上是"改变中序顺序",Splay 天然支持。

具体做法("剪切 + 粘贴"三步):

第一步(剪):用夹逼技巧把 [l, r] 切出来。
splay(l-1, 0); splay(r+1, l-1);
记 sub = ch[ch[root][1]][0],这就是区间 [l, r] 对应的子树。把 ch[ch[root][1]][0] = 0,并把 sz 更新——区间就从树上"剪掉"了。

第二步(定位):在剩下的 n − (r−l+1) 个元素中,找到"新位置 p"对应的两个边界节点。注意因为剪掉了一段,原来的下标要重新映射:若 p < l,则边界节点是原来的 p−1 和 p;若 p > r,则要先减去区间长度。然后把这两个节点 splay 上去。

第三步(贴):把剪下来的 sub 接到新位置——即挂成"根的右孩子的左子树"(与夹逼的位置一致)。然后沿路径 pushup 更新 sz。

复杂度:三次 splay,均摊 O(log n)。整个操作是 O(log n)——而如果用数组实现"移动区间",需要 O(n) 次搬移。

同类操作还有:区间删除(剪下来直接丢掉)、区间插入(把新序列建成一棵 Splay 后 merge 进去)、区间赋值 / 区间翻转(打标记)、区间求和(维护 sum)。所有这些"改变顺序"或"整段处理"的操作,Splay 都能 O(log n) 完成。

对比记忆:
· 区间改值(加、乘、赋值、取最值)→ 线段树 / 树状数组(更快更简单)
· 区间改序(翻转、移动、切分、合并)→ Splay / FHQ-Treap(线段树做不到)

典型题目:P2596 [ZJOI2006] 书架(区间的移动 / 置顶 / 置底)、P3850 [TJOI2007] 书架(在位置 p 插入,用 FHQ 更简单)。

B3笛卡尔树应用

给定数组 a = [3, 1, 4, 5, 2],
① 手工用单调栈建出它的最小值笛卡尔树(写出每一步的栈状态);
② 用建好的树求出区间 [3, 5] 的最小值(用 LCA 的方法)。

查看参考答案

① 单调栈过程:

i=1, a=3:栈空,last=0,无父亲、无左孩子。1 暂时是根。栈:[1]

i=2, a=1:栈顶 1 的 a=3 > 1 → 弹出,last=1。栈空停止。栈空 → 2 成为新根;last=1 → lc[2]=1。栈:[2]

i=3, a=4:栈顶 2 的 a=1 < 4 → 不弹。栈非空 → rc[2]=3,3 是 2 的右孩子。栈:[2,3]

i=4, a=5:栈顶 3 的 a=4 < 5 → 不弹。→ rc[3]=4。栈:[2,3,4]

i=5, a=2:栈顶 4 的 a=5 > 2 → 弹出,last=4;栈顶 3 的 a=4 > 2 → 弹出,last=3;栈顶 2 的 a=1 < 2 → 停。栈非空 → rc[2]=5;last=3 → lc[5]=3。栈:[2,5]

最终树结构:根 = 2;lc[2]=1,rc[2]=5;lc[5]=3;rc[3]=4。

即 2(1, 5(3(_, 4), _)),与图 2 完全一致 ✅

② 求区间 [3, 5] 的最小值:

在树上找下标 3 和下标 5 的 LCA。下标 3 的祖先链是 3 → 5 → 2 → (根);下标 5 的祖先链是 5 → 2 → (根)。公共祖先中深度最大的是下标 5。

所以 LCA(3, 5) = 5,对应值 a[5] = 2。答案:区间 [3, 5] 的最小值是 2。

暴力验证:a[3]=4, a[4]=5, a[5]=2 → min = 2 ✅

关键检查:LCA 的下标必须在 [l, r] 内。这里 LCA 是 5,确实在 [3, 5] 中,符合理论。

⚠️ 一个必须注意的方向问题:如果你要求的是最大值的区间查询,就必须建大根堆笛卡尔树(根是最大值)。最小值笛卡尔树只能回答"区间最小值",反过来用会得到错误答案。

本题的训练目标:把"单调栈建树"和"LCA 求 RMQ"两条线串起来。实际做题时,如果要多次查询区间最值,通常会转成"欧拉序 + ST 表"或直接用 ST 表——但理解笛卡尔树与 LCA 的对应关系,是很多高级题目的关键一步。

B4综合设计

我们需要维护一个集合,支持:插入、删除、查询某个数是否存在、查询集合中第 k 小的数。n ≤ 105。
请给出三种不同的实现方案,并比较它们的复杂度、代码量和适用条件。

查看参考答案

方案一:平衡树(Treap)。最直接的方案。

· 插入/删除 O(log n),查存在 O(log n),第 k 小 O(log n)。

· 代码量约 100 行。支持在线操作(来了就做)。

· 缺点:常数较大,容易写错。

方案二:离散化 + 树状数组 + 倍增求第 k 小。

· 先读入所有操作的数值,离散化成 1..m 的排名(因为要"第 k 小",值域必须可比)。

· 用树状数组维护每个值的出现次数:插入 = 单点 +1,删除 = 单点 −1,查存在 = 单点查询。

· 第 k 小用树状数组上的倍增:从高位往低位试,累加 c[i] < k 时的部分,最终定位到第 k 小对应的值。O(log m)。

· 代码量约 60 行,比平衡树短得多,常数小 3~5 倍。

· 缺点:必须离线(要先知道所有可能出现过的数值)。如果题目强制在线(每次操作的参数依赖上一次的答案),这个方案就不能用。

方案三:值域线段树 / 权值线段树 + 动态开点。

· 在值域上建线段树,每个节点记录"这个值域区间里有多少个数"。

· 插入/删除 = 单点更新 O(log V);第 k 小 = 从根往下比较左子树的 count 与 k,O(log V)。

· 如果值域很大(如 109),用动态开点或离散化。用动态开点时不需要离线。

· 代码量约 80 行,常数比平衡树小。而且很容易改造成可持久化(主席树)——这是它相比方案二的巨大优势。

三种方案的对比表:

方案单次复杂度代码量常数是否必须离线易扩展性
Treap期望 O(log n)~100 行中否可扩展区间操作、可持久化
离散化 + BITO(log n)~60 行最小是难扩展(不支持区间操作)
权值线段树O(log V)~80 行小否(动态开点)最易扩展(可持久化 / 合并)

选择建议:

· 能离线 → 首选离散化 + BIT,短、快、稳。

· 要在线但值域不大 → 权值线段树,常数小。

· 要在线且要区间操作(翻转/切分) → Treap / Splay。

· 要可持久化 → 权值线段树(主席树)或 FHQ-Treap。

本题的最大价值:同一个需求有三条路,各有取舍。高手的标志不是"会写平衡树",而是"知道什么时候不该写平衡树"。在 90% 的题目里,离散化 + BIT 都是最快的解法。

🔴 C组 · 挑战(4题)

C1思维题

Treap 的期望高度是 O(log n)。请给出一个直观的证明思路,说明为什么"随机优先级"能保证树高是对数级的。

查看参考答案

证明思路(关键是把 Treap 转化成"随机 BST"):

第一步:等价转化。把 n 个 key 按 pri 从小到大排序,得到序列 kπ(1), kπ(2), …, kπ(n)。按这个顺序依次插入一棵普通 BST,得到的树恰好就是这棵 Treap。(因为 pri 最小的一定成为根,而它在插入序列中排第一;其余元素递归地按同样方式分配。)

由于 pri 是独立均匀随机的,"按 pri 排序"等价于"随机打乱"。所以 Treap = 随机打乱顺序后建的 BST。

第二步:分析随机 BST 的高度。对任意一对 key u < v,u 是 v 的祖先当且仅当 u 是 {u 到 v 之间所有 key} 中 pri 最小的那个。

理由是:Treap 中,一个节点 x 的子树包含"键值在某个连续区间内"的所有节点(BST 性质);而 x 是这棵子树的根当且仅当 x 的 pri 是这个区间内最小的(堆性质)。

第三步:计算深度。节点 v 的深度 = 它的祖先个数 = 满足"u 是 v 的祖先"的 u 的个数 = Σu≠v [u 是 [min(u,v), max(u,v)] 内 pri 最小的]。

对固定的 u、v(假设 u < v),区间 [u, v] 内共有 v − u + 1 个 key。由于 pri 是独立的随机值,每个 key 成为"区间内最小 pri"的概率都是相等的,即 1/(v − u + 1)。

所以: E[depth(v)] = Σu<v 1/(v−u+1) + Σu>v 1/(u−v+1) ≈ 2 ln n = O(log n)

(第一个和式是 1/2 + 1/3 + … + 1/v ≈ ln v,第二个和式同理,都是调和级数。)

第四步:树高。任一节点的期望深度都是 O(log n),所以期望树高 = O(log n)。更严格的结论(用 Chernoff 界)可以证明"树高超过 c·log n 的概率是指数级小的"。

本题的收获:① "随机化 = 等价于随机顺序"这个转化思想极其重要,它把"数据结构问题"变成了"概率统计问题";② 调和级数 Σ1/i ≈ ln n 是随机 BST、快排、跳表分析的共同工具;③ 注意这里算的是 期望深度,单次最坏仍然可能是 O(n)——但概率极小。

延伸:同样的分析方法可以证明"随机快排的期望复杂度是 O(n log n)"(pivot 的排名是随机的,每次划分期望把问题规模减半)。另一个深刻的对照:log2 n 与 ln n 差一个常数倍(约 1.44),所以随机 BST 比完全平衡树高大约 1.44 倍——这就是 Treap 的"平衡代价"。这个 1.44 恰好也是 AVL 树高度上界里的系数,不是巧合。

C2进阶应用

为什么 Splay 能支持"区间翻转",而 Treap(普通版)不能?如果要让 Treap 也支持区间操作,需要怎么改造?

查看参考答案

普通 Treap 不能的原因:它的 BST 键是"值",不是"位置"。

普通 Treap 按键值建树,中序是"按值排序"。要做区间翻转,必须能"定位到第 l 个和第 r 个元素"——但 Treap 里"第 k 小"是按值排的,而不是按原序列的位置排的。翻转一个"值域区间"没有意义。

更根本的是:一旦旋转,"第 k 个位置"对应的节点就变了。而按值建的 Treap 没有"位置"这个概念。

改造方案一(按位置建树的 Treap):让 BST 的键变成"原序列的下标",而不是值。这样中序就是原序列顺序。此时:

· "第 k 个元素"用 sz 定位,和 Splay 的 kth 一样。

· "区间 [l, r]"需要"切出中间那段"——Treap 没有切片能力,但不妨碍用 split 按位置切开。这就引出了方案二。

改造方案二(FHQ-Treap,推荐):用 split(u, k, x, y) 把树按"前 k 个元素"拆成两棵,用 merge 合并。那么区间翻转变成:

split(root, l-1, a, b);
split(b, r-l+1, b, c);
rev[b] ^= 1;   // b 就是区间 [l,r]
root = merge(a, merge(b, c));

四行搞定,比 Splay 的夹逼还简单!而且 split / merge 都不用旋转,pushdown 的处理也更直观。

为什么 FHQ-Treap 能按位置切分?因为 split(u, k, a, b) 的语义是"把以 u 为根的树拆成"前 k 个元素"和"其余"两棵"。它通过递归地比较 sz[lc[u]] + 1 与 k 来决定怎么切,完全不依赖键值的大小关系——所以既支持按值切,也支持按位置切。

三种做法的对比:

结构区间操作方式代码难度是否需旋转可持久化
Splay两端夹逼(splay 两次)较难(要处理 pushdown 时机)是极难
FHQ-Treapsplit 两次 + merge 两次最容易否容易
普通 Treap做不到—是较难

结论与建议:现在(以及过去十年)竞赛圈的主流是 FHQ-Treap。它用同一套 split/merge 模板,同时解决了"普通平衡树"和"区间平衡树"两类问题,还能可持久化。Splay 主要的价值在于理解"伸展"这一巧妙的自调整思想,以及在某些需要"把某节点提到根"的场景(如 LCT)中不可替代。

延伸:把 Treap 的键改成"位置"后,其实就已经不是"查找结构"而是"序列结构"了。这正是平衡树的两大用途分野:一类维护"值的集合"(查前驱后继),一类维护"序列"(区间操作)。看清这一点,你就能在拿到题目时立刻判断该用哪一种。

C3综合题

"所有子区间的最小值之和"与"所有子区间的最大值之和"是两道对称的题。请说明:
① 为什么两者可以用完全对称的方法解决?
② 如果题目要求的是"所有子区间的次小值之和",思路会有什么变化?

查看参考答案

① 对称性来自"取负"这一操作。

观察:min(a[l..r]) = −max(−a[l..r])。也就是说,"求最小值之和"可以转化为"对数组取负后求最大值之和,再取负"。

所以两道题在算法上完全镜像,唯一的区别是:最小值用"小根堆笛卡尔树",最大值用"大根堆笛卡尔树"(或者说,建树的时候把比较符号 > 改成 <)。

统一公式:答案 = Σu a[u] × (szL[u] + 1) × (szR[u] + 1),其中 szL, szR 是以 u 为根的子树中左右孩子的大小。

也可以完全不用笛卡尔树:用单调栈分别求出每个 i 的"左边第一个比它大/小的位置"和"右边第一个比它大/小的位置",直接套 (i − L[i]) × (R[i] − i)。这其实就是笛卡尔树的"隐式形态"——不需要真的把树建出来。

结论:"最值之和"这类题的核心是"贡献法 + 单调栈",笛卡尔树只是给它一个更直观的树形解释。两道题看起来对称,是因为"最小"与"最大"在数学上通过取负完全等价。

② 次小值之和:难度陡增。

为什么难?因为"最小值"有唯一定位(一个区间的最小值位置唯一,可以精确地归功到某个元素上),而"次小值"没有这种"唯一归属"的简单结构——它依赖于"最小值在哪里"。

可行思路一(笛卡尔树 + 分类讨论):在笛卡尔树上考虑每个节点 u 作为"次小值"的区间。设 u 的父节点是 p(p 的值比 u 小)。对某个包含 u 的区间来说,u 是次小值当且仅当区间包含 u、不包含比 u 更小的其他元素、但包含至少一个比 u 小的元素(即包含 p 或 p 的某个祖先)。

于是需要对每个 u 分别计算"包含 u、且最小值恰好是 p(或 p 的某祖先)"的区间数。这需要在笛卡尔树上做更复杂的统计(通常配合"每个节点的子树区间边界"和"父链"信息)。

可行思路二(分治 + 单调栈):用"最小值分治"——每次以区间最小值为分界把区间劈开,然后把"跨过最小值位置"的区间单独统计(这些区间的次小值就是左右两侧各自的最小值中的较小者)。这需要用"启发式合并 + 数据结构"或"笛卡尔树上的树形 DP"来加速。

可行思路三(排序 + 并查集):把元素从小到大依次"激活",用并查集维护"激活元素形成的连续段"。当第 t 个元素被激活时,它把相邻的段合并——此时可以统计"以它为第 t 小(即次小、第三小……)的那些区间"。这个技巧叫做"按值排序 + 并查集合并",是处理"第 k 小之和"类问题的通用套路。

复杂度:思路三可以做到 O(n log n)(排序 + 并查集),是最实用的做法。

本题的价值:它展示了一个重要的规律——"最小值/最大值"问题有唯一的结构化归属(笛卡尔树),而"次小值/第 k 小值"问题往往需要"排序 + 并查集"这种"增量式"的处理方式。从"静态结构"转向"增量处理",是算法思维的一次重要升级。

延伸练习:P4755 Beautiful Pair 就是这类问题的经典代表;"直方图中最大矩形"(P4147)则是最小值笛卡尔树的标准应用。

C4思维题

如果要求平衡树的每个操作最坏情况都是 O(log n)(而不是期望或均摊),有哪些方案?为什么 Treap 和 Splay 都做不到?

查看参考答案

能提供"最坏情况 O(log n)"的结构:

① AVL 树。因为 |BF| ≤ 1 是硬约束,任何时刻树高都不超过 1.44 log2(n+2)。所以单次插入/删除/查找严格 O(log n)。这是最直接的答案。

② 红黑树。虽然没有 AVL 严格,但同样是硬约束(最长路径 ≤ 2 × 最短路径),所以树高 ≤ 2 log2(n+1),严格 O(log n)。

③ 替罪羊树。虽然插入时可能临时不平衡,但一旦"某棵子树的大小超过 α 倍",就立即重建。可以证明重建的代价被"均摊"到之前的插入上,但更关键的是:树高始终被约束在 O(log n) 以内(因为不平衡的节点会被立即重建)。所以每次操作的最坏代价是 O(log n)(重建的代价由之前的操作"预支"了)。严格说替罪羊树是"均摊 O(log n)",但它的树高确实始终有界。

④ B 树 / 2-3-4 树。多路平衡树,通过"节点分裂/合并"保持所有叶子在同一深度,树高严格为 O(log n)。红黑树本质上就是 2-3-4 树的二叉化表示。

为什么 Treap 做不到?

因为 Treap 的 pri 是随机生成的。虽然 pri 恰好单调递增(导致链式退化)的概率是 1/n!(指数级小),但这个概率不是零。所以在最坏情况下,Treap 的树高可以是 n,单次操作可以是 O(n)。

要让 Treap 变成最坏 O(log n),需要给 pri 加上"强制约束"——比如"如果某个节点的深度超过阈值就重建"(这就是替罪羊树的思想),或者"用确定性的优先级使得堆性质与平衡性同时成立"(但这样的优先级很难构造)。本质上,随机化的代价就是"放弃最坏情况保证"。

为什么 Splay 做不到?

Splay 的自调整策略只看"访问频率",不看"树的高度"。可以构造出这样的操作序列:先访问一个"两边都不平衡"的节点把它提到根,此时树变得很歪;然后再访问最深的节点,这次操作就是 O(n)。

关键点在于:Splay 的单次操作代价可以很高,但它"改善"了后续操作的代价。均摊分析表明"总的改善量"足以补偿"总的高代价"。但如果只看单次,Splay 就是 O(n) 的。这是"均摊"与"最坏"的本质区别。

三种保证的对比:

保证类型含义代表结构单次最坏
严格(worst-case)任何一次操作都不超过 O(log n)AVL、红黑树、B 树O(log n)
期望(expected)对随机选择取平均是 O(log n)Treap、随机 BST、跳表O(n),但概率极小
均摊(amortized)连续 m 次操作总计 O(m log n)Splay、替罪羊树O(n),但后续会"还债"

竞赛中的实际选择:绝大多数题目只限制"总时间",所以期望和均摊复杂度完全够用,而且 Treap / Splay 的常数比 AVL 小、代码比红黑树短。只有在"交互题"或"需要保证单次响应时间"的场景下,严格最坏保证才有必要。

本题的核心收获:分清 严格 / 期望 / 均摊 三种复杂度保证的含义与适用场景。这是算法分析中最重要的一组概念之一,也是本讲"思考与启发"部分最想让你带走的东西。

🏆 洛谷原题精选(12道)

以下题目均与平衡树、笛卡尔树相关,按难度从模板到省选排列。

1
P3369
【模板】普通平衡树入门
2
P5854
【模板】笛卡尔树入门
3
P2234
[HNOI2002] 营业额统计普及/提高-
4
P1801
黑匣子普及/提高-
5
P2073
送花普及+/提高
6
P2286
[HNOI2004] 宠物收养场提高+
7
P3391
【模板】文艺平衡树提高+
8
P1486
[NOI2004] 郁闷的出纳员提高+/省选-
9
P1110
[ZJOI2007] 报表统计提高+/省选-
10
P2596
[ZJOI2006] 书架提高+/省选-
11
P3835
【模板】可持久化平衡树省选/NOI-
12
P4755
Beautiful Pair(笛卡尔树+分治)省选/NOI-