信奥赛C++提高组csp-s之平衡树(FHQ Treap)

什么是FHQ Treap
FHQ Treap(无旋Treap)是一种基于Tree(二叉搜索树)+ Heap(堆)的数据结构。它的核心特点是不需要旋转操作,仅依靠**分裂(Split)和合并(Merge)**两个核心操作就能实现所有平衡树功能。这也是它相比Splay和有旋Treap的最大优势------代码短小、易于理解、支持可持久化。
Treap中的每个节点都存储两个关键值:val (节点权值,满足BST性质:左子树所有节点val ≤ 当前val ≤ 右子树所有节点val)和key(随机优先级,满足堆性质)。随机优先级的引入使得树的高度在期望上保持O(log n),从而保证所有操作的复杂度为O(log n)。
FHQ Treap所有操作(插入、删除、查询排名、查询第k大、前驱、后继、区间翻转等)都可以通过组合split和merge来完成。
1.1 数据结构存储
每个节点存储:左右儿子编号、节点权值val、随机优先级key、子树大小size(用于按size分裂和排名查询)。
1.2 核心操作一:分裂(Split)
分裂操作将一棵树按权值(或按size)分成两棵树:左树包含所有val ≤ pivot的节点,右树包含所有val > pivot的节点。
实现逻辑:从根节点开始遍历,若当前节点val ≤ pivot,则将该节点及左子树归入左树,然后递归处理右子树;否则归入右树,递归处理左子树。每次递归后更新节点size。
1.3 核心操作二:合并(Merge)
合并操作将两棵树合并为一棵,前提是左树中所有节点的val都小于等于右树中所有节点的val。合并时比较两棵树根的key优先级,key小的作为新树的根(小根堆),然后递归合并子树。
1.4 为什么无旋Treap是平衡的
FHQ Treap的平衡性来源于随机优先级------合并时完全按照优先级大小决定树的形态,不受插入顺序的影响。由于优先级随机,树的期望高度为O(log n),保证了操作效率。
案例研究:普通平衡树
题目描述
您需要动态地维护一个可重集合 M M M,并且提供以下操作:
- 向 M M M 中插入一个数 x x x。
- 从 M M M 中删除一个数 x x x。(若有多个相同的数,应只删除一个)
- 查询 M M M 中有多少个数比 x x x 小,并且将得到的答案加 1 1 1。
- 查询如果将 M M M 从小到大排列后,排名位于第 x x x 位的数。
- 查询 M M M 中 x x x 的前驱(定义为 M M M 中小于 x x x,且最大的数)。
- 查询 M M M 中 x x x 的后继(定义为 M M M 中大于 x x x,且最小的数)。
对于操作 3 , 5 , 6 3,5,6 3,5,6,不保证 当前可重集中存在数 x x x。
对于操作 4 , 5 , 6 4,5,6 4,5,6,保证答案一定存在。
输入格式
第一行为 n n n,表示操作的个数,下面 n n n 行每行有两个数 opt \text{opt} opt 和 x x x, opt \text{opt} opt 表示操作的序号( 1 \\leq \\text{opt} \\leq 6 )。
输出格式
对于操作 3 , 4 , 5 , 6 3,4,5,6 3,4,5,6 每行输出一个数,表示对应答案。
输入输出样例 1
输入 1
10
1 106465
4 1
1 317721
1 460929
1 644985
1 84185
1 89851
6 81968
1 492737
5 493598
输出 1
106465
84185
492737
【数据范围】
对于 100 % 100\% 100% 的数据, 1 ≤ n ≤ 10 5 1\le n \le 10^5 1≤n≤105, ∣ x ∣ ≤ 10 7 |x| \le 10^7 ∣x∣≤107。
思路分析(FHQ Treap)
本题要求动态维护一个可重集,支持插入、删除、查排名、查第k小、查前驱、查后继六种操作。
使用 FHQ Treap(无旋 Treap) 实现,它基于分裂(split)和合并(merge)两个核心操作,代码短、易于理解,且能完美处理重复元素。
核心设计
- 每个节点存储:值
val、随机优先级pri、重复次数cnt、子树总大小sz(即cnt之和)、左右儿子l, r。 - 用数组模拟节点,
0表示空节点。 - 分裂
split(u, key, &x, &y):将树u按值key分成两棵树x和y,其中x中所有节点的值 ≤ key ,y中所有节点的值 > key。 - 合并
merge(x, y):合并两棵树,要求x中所有值 ≤y中所有值,按优先级维持堆性质。 - 更新
update(u):计算sz[u] = sz[l] + sz[r] + cnt[u]。
各操作实现方法
-
插入 x
按
x-1和x分裂成三部分:L(< x)、M(= x)、R(> x)。若
M非空,则增加cnt;否则新建节点。最后合并L, M, R。 -
删除 x
同样分裂成
L, M, R。若M的cnt > 1,则cnt--;否则丢弃M(不合并)。最后合并L, M, R。 -
查询比 x 小的数的个数 +1
按
x-1分裂成L(< x)和R(≥ x)。答案 =sz[L] + 1。合并L, R。 -
查询排名为 k 的数
从根开始递归:若左子树大小 ≥ k,则进入左子树;否则减去左子树大小和当前节点重复次数,进入右子树。
-
查询 x 的前驱(小于 x 的最大数)
按
x-1分裂成L(< x)和R(≥ x)。在L中一直向右走找到最大值。合并。 -
查询 x 的后继(大于 x 的最小数)
按
x分裂成L(≤ x)和R(> x)。在R中一直向左走找到最小值。合并。
所有操作时间复杂度均为 O(log n) ,空间复杂度 O(n)。
代码实现
cpp
#include <bits/stdc++.h>
using namespace std;
const int N = 100010; // 最大操作次数
int n, rt; // rt 为根节点编号
struct Node {
int l, r; // 左右儿子编号
int val, pri; // 值、随机优先级
int cnt, sz; // 重复次数、子树大小(cnt之和)
} tr[N];
// 更新节点 u 的子树大小
void upd(int u) {
tr[u].sz = tr[tr[u].l].sz + tr[tr[u].r].sz + tr[u].cnt;
}
// 创建新节点,返回编号
int add(int v) {
static int idx = 0;
int u = ++idx;
tr[u] = {0, 0, v, rand(), 1, 1};
return u;
}
// 分裂:将以 u 为根的树按值 key 分成 x(≤key)和 y(>key)
void split(int u, int key, int &x, int &y) {
if (!u) {
x = y = 0;
return;
}
if (tr[u].val <= key) {
x = u;
split(tr[u].r, key, tr[u].r, y);
} else {
y = u;
split(tr[u].l, key, x, tr[u].l);
}
upd(u);
}
// 合并:将 x 和 y 两棵树合并(x 中所有值 ≤ y 中所有值)
int merge(int x, int y) {
if (!x || !y) return x | y;
if (tr[x].pri < tr[y].pri) {// 优先级小的作为根(小根堆)
tr[x].r = merge(tr[x].r, y);
upd(x);
return x;
} else {
tr[y].l = merge(x, tr[y].l);
upd(y);
return y;
}
}
// 查询第 k 小的数(k 从 1 开始)
int kth(int u, int k) {
while (u) {
int lsz = tr[tr[u].l].sz;
if (k <= lsz)
u = tr[u].l;
else if (k <= lsz + tr[u].cnt)
return tr[u].val;
else {
k -= lsz + tr[u].cnt;
u = tr[u].r;
}
}
return 0; // 不会执行到这里,因为保证有答案
}
int main() {
srand(time(0)); // 初始化随机种子
scanf("%d", &n);
rt = 0;
while (n--) {
int op, x;
scanf("%d%d", &op, &x);
if (op == 1) {// 插入 x
int L, M, R;
split(rt, x - 1, L, R);
split(R, x, M, R); // M 中为值等于 x 的节点
if (M) tr[M].cnt++, upd(M);
else M = add(x);
rt = merge(merge(L, M), R);
}
else if (op == 2) {// 删除 x
int L, M, R;
split(rt, x - 1, L, R);
split(R, x, M, R);
if (tr[M].cnt > 1) tr[M].cnt--, upd(M);
else M = 0; // 丢弃该节点
rt = merge(merge(L, M), R);
}
else if (op == 3) {// 查询比 x 小的数的个数 +1
int L, R;
split(rt, x - 1, L, R);
printf("%d\n", tr[L].sz + 1);
rt = merge(L, R);
}
else if (op == 4) { // 查询第 x 小的数
printf("%d\n", kth(rt, x));
}
else if (op == 5) { // 查询 x 的前驱
int L, R;
split(rt, x - 1, L, R);
int u = L;
while (tr[u].r) u = tr[u].r; // 左树的最大值
printf("%d\n", tr[u].val);
rt = merge(L, R);
}
else if (op == 6) { // 查询 x 的后继
int L, R;
split(rt, x, L, R);
int u = R;
while (tr[u].l) u = tr[u].l; // 右树的最小值
printf("%d\n", tr[u].val);
rt = merge(L, R);
}
}
return 0;
}
功能分析
| 操作 | 描述 | 实现方式 | 时间复杂度 |
|---|---|---|---|
| 1 | 插入 x | 分裂成 <x、=x、>x,增加重复次数或新建节点 |
O(log n) |
| 2 | 删除 x | 同上,减少重复次数或丢弃节点 | O(log n) |
| 3 | 查询比 x 小的个数 +1 | 分裂成 <x 和 ≥x,输出左子树大小 +1 |
O(log n) |
| 4 | 查询第 k 小的数 | 递归/循环根据子树大小查找 | O(log n) |
| 5 | 查询 x 的前驱 | 分裂成 <x 和 ≥x,在左子树中找最大值 |
O(log n) |
| 6 | 查询 x 的后继 | 分裂成 ≤x 和 >x,在右子树中找最小值 |
O(log n) |
空间复杂度:O(n),最多存储 n 个节点(每个插入操作最多新增一个节点)。
普通Treap vs FHQ Treap(以本题为案例)
1. 核心操作实现
| 操作 | 普通Treap(旋转版) | FHQ Treap(分裂/合并版) |
|---|---|---|
| 插入 | 递归插入后通过旋转调整堆性质 | split 成 ≤x 和 >x,再 merge |
| 删除 | 递归找到节点,合并左右子树(或降优先级后旋转) | split 成 <x、=x、>x,减少计数或丢弃节点 |
| 查排名 | 递归根据大小累加 | split 成 <x 和 ≥x,输出左子树大小+1 |
| 查第k小 | 递归查找 | 与普通Treap相同,但不需要旋转 |
| 前驱 | 递归:若当前≤x则往右,否则往左 | split 成 <x 和 ≥x,取左子树最大值 |
| 后继 | 递归:若当前≥x则往左,否则往右 | split 成 ≤x 和 >x,取右子树最小值 |
2. 代码复杂度
- 普通Treap:需要实现左旋、右旋,插入/删除函数中旋转逻辑较复杂,且删除操作通常要处理将节点旋转到叶子再删除,代码量较多。
- FHQ Treap :只需实现
split和merge两个核心函数,所有操作都基于它们,逻辑清晰统一,代码量少。代码更简洁,不易出错。
3. 性能
- 时间常数 :两者均为 O(log n),但普通Treap的旋转操作涉及多次指针/索引修改,常数略小;FHQ Treap的
split和merge每次操作会递归分裂或合并,常数稍大,但在 n ≤ 1e5 时差距可忽略。 - 空间:两者都是 O(n),每个节点存储左右儿子、值、优先级、大小、重复次数等,基本相同。
4. 扩展性
| 特性 | 普通Treap | FHQ Treap |
|---|---|---|
| 区间操作(如翻转) | 难以实现,需要维护懒标记且旋转会破坏区间结构 | 天然支持,可通过 split 按排名分裂,打标记后合并 |
| 可持久化 | 需要复制节点并旋转,实现复杂 | 只需在 split/merge 时新建节点,可轻松实现可持久化 |
| 多重集 | 需额外存储 cnt,或允许相同值节点 |
同样用 cnt 处理,且分裂策略明确(≤x 与 >x) |
本题仅需基础操作,两者均可胜任。但FHQ Treap的代码模式更通用,稍加修改就能应对更复杂的问题(如文艺平衡树、可持久化平衡树)。
5. 易调试性
- 普通Treap:旋转容易写错方向,调试时需画图检查堆性质是否保持。
- FHQ Treap :
split和merge逻辑直观,可以分段测试(例如先测试split再测试merge),且不需要处理旋转带来的父子关系错乱。调试更容易。
6. 处理重复元素
两者都可通过在节点中增加 cnt 计数来处理重复值。
但普通Treap的删除操作如果采用合并左右子树的方法,会丢失重复计数,需特殊处理;而FHQ Treap通过将相同值节点单独分离,直接修改 cnt 即可,逻辑更自然。
7. 总结
| 维度 | 普通Treap | FHQ Treap |
|---|---|---|
| 代码长度 | 长 | 短 |
| 实现难度 | 中等(需理解旋转) | 低(只需 split/merge) |
| 常数 | 较小 | 略大(可接受) |
| 可扩展性 | 差(难以区间操作/可持久化) | 优秀(天然支持区间和可持久化) |
| 调试难度 | 较高 | 低 |
更多系列知识,请查看专栏:《信奥赛C++提高组csp-s知识详解及案例实践》:
https://blog.csdn.net/weixin_66461496/category_13113932.html
各种学习资料,助力大家一站式学习和提升!!!
cpp
#include<bits/stdc++.h>
using namespace std;
int main(){
cout<<"########## 一站式掌握信奥赛知识! ##########";
cout<<"############# 冲刺信奥赛拿奖! #############";
cout<<"###### 课程购买后永久学习,不受限制! ######";
return 0;
}
1、csp信奥赛高频考点知识详解及案例实践:
CSP信奥赛C++动态规划:
https://blog.csdn.net/weixin_66461496/category_13096895.html点击跳转
CSP信奥赛C++标准模板库STL:
https://blog.csdn.net/weixin_66461496/category_13108077.html 点击跳转
信奥赛C++提高组csp-s知识详解及案例实践:
https://blog.csdn.net/weixin_66461496/category_13113932.html
2、csp信奥赛冲刺一等奖有效刷题题解:
信奥赛C++普及组csp-j初赛&复赛真题题解(持续更新) https://blog.csdn.net/weixin_66461496/category_12808781.html 点击跳转
信奥赛C++提高组csp-s初赛&复赛真题题解(持续更新)
https://blog.csdn.net/weixin_66461496/category_13125089.html
3、GESP C++考级真题题解:

GESP(C++ 一级+二级+三级)真题题解(持续更新):https://blog.csdn.net/weixin_66461496/category_12858102.html 点击跳转

GESP(C++ 四级+五级+六级)真题题解(持续更新):https://blog.csdn.net/weixin_66461496/category_12869848.html 点击跳转

GESP(C++ 七级+八级)真题题解(持续更新):
https://blog.csdn.net/weixin_66461496/category_13117178.html
4、csp/信奥赛C++,完整信奥赛系列课程(永久学习):
https://edu.csdn.net/lecturer/7901 点击跳转
· 文末祝福 ·
cpp
#include<bits/stdc++.h>
using namespace std;
int main(){
cout<<"跟着王老师一起学习信奥赛C++";
cout<<" 成就更好的自己! ";
cout<<" csp信奥赛一等奖属于你! ";
return 0;
}