FHQ-Treap学习笔记
longdie
2020-12-31 14:16:11
# FHQ-Treap学习笔记
---
如果你没有学过平衡树,先去这个博客[关于二叉查找树的一些事儿(bst详解,平衡树入门)](https://www.luogu.com.cn/blog/ztz11/pinghengshu-bst)简单学习一下,然后你可在这个博客[浅析Treap——平衡树](https://www.luogu.com.cn/blog/HOJQVFNA/qian-xi-treap-ping-heng-shu)先学习一下 Treap 的思想, 然后你就可以看这篇博客了。
可能有些人会怀疑 Treap 的时间复杂度的稳定性,事实上,Treap是最快的几种平衡树之一,我的这个[提交记录](https://www.luogu.com.cn/record/39451996)证明确实 Treap 很快,可能 splay 在这道题里也很快,那么建议你去看一下这个题[普通平衡树(数据加强版)](https://www.luogu.com.cn/problem/P6136),你就会发现 Treap 比 splay 快一倍。
但是 Treap 最不好的一点是用处太少,它不能解决序列上的问题,比如文艺平衡树。所以很多人直接选择学习 splay,但是 FHQ-Treap 也可以解决文艺平衡树的问题,换句话说,基本上 splay 可以干的 FHQ-Treap 都能干。
而且它的速度也很快,反正比 splay 快多了。
最关键的是,它的代码量很小,我们都知道 splay 大概在 100 行左右,而 FHQ-Treap 却是在 50 行左右,码量小了一半,这样就使得在考场上可以用更少的时间写一个平衡树,而不是用 STL 里的 set。
本篇博客不会讲解的太深,大概主要讲解各种操作。
## 一些基础
我们规定 `val[N], siz[N], tr[N][2], rd[N]` 分别表示这个点的权值,个数,左右儿子,和随机值。
下面是两个基础函数:
```cpp
inline void pushup(int now) { siz[now] = siz[tr[now][0]] + siz[tr[now][1]] + 1; }
```
pushup操作是每个平衡树里都有的,但是这个不同的是,它只多加了 1,这是因为在 FHQ-Treap 中对于权值相同的点我们选择反复插入,而不是在一个点上记录个数。
```cpp
inline int new_node(int x) {
val[++cnt] = x, siz[cnt] = 1, rd[cnt] = rand(); return cnt;
}
```
这个是建一个新点,与其它平衡树不同的是,它不需要管自己的左右儿子是谁。
## 核心操作1:分裂(split)
分裂操作非常好理解,就是把一个大的平衡树分裂为两个小的**平衡树**,实际上我们是按照区间进行分裂的,即假设值域为 $[l, r]$,我们规定一个值 val, 我们把 $[l, val]$ 上的点分裂成左子树,把 $[val + 1, r]$ 上的点分裂成右子树。
这样分裂的目的是为了可以在合并在一起,不要忘记我们必须满足平衡树的性质。
![](https://images.cnblogs.com/cnblogs_com/DZN2004/1829949/o_2010071121449.png)
看这张我瓢过来的图片应该就可以看懂。
其实我并没有讲为什么这样分裂是正确的,这个有兴趣自己查吧。(其实很好理解的)
然后就是代码了:
```cpp
inline void split(int now, int p, int &x, int &y) {
//如果当前访问节点为空,将左右区间树的虚拟节点赋值为0。
if(now == 0) return x = 0, y = 0, void();
//如果当前节点的val<=k则该节点与其左子树一并归入左区间树,在左区间树中对右儿子建立虚拟节点并继续分裂右子树。
if(p >= val[now]) x = now, split(tr[now][1], p, tr[now][1], y);
//如果当前节点的val>k则该节点与其右子树一并归入右区间树,在右区间树中对左儿子建立虚拟节点并继续分裂左子树。
else y = now, split(tr[now][0], p, x, tr[now][0]);
pushup(now);
//分裂完之后上传左右儿子的维护值至now节点,若now为空在先前就已经return。
}
```
注意别忘了取地址。
事实上,我们应该把根节点开成全局变量,与其他平衡树不一样的是,我们需要至少开三个根节点。
## 核心操作2:合并 (merge)
分裂是为了解决当前的问题,而合并是为了更好的解决下一个问题。
我们发现我们进行分裂操作的时候左右区间的交集是空集,这就为我们的合并提供了正确性。
具体来说,合并采用节点的随机值进行合并,每次把随机值小的当根节点,这样就保证了树的平衡(跟 Treap 的原理很类似)
![](https://images.cnblogs.com/cnblogs_com/DZN2004/1829949/o_2010071128580vhs3c4i.png)
还是张图片。
**小贴士:左区间树与右区间树不能存在区间相交或顺序颠倒的情况**
代码:
```cpp
inline int merge(int x, int y) {
//如果有任意一个树为空就返回非空的一棵的根
if(!x || !y) return x + y;
if(rd[x] < rd[y]) {
tr[x][1] = merge(tr[x][1], y), pushup(x); return x;
}
else {
tr[y][0] = merge(x, tr[y][0]), pushup(y); return y;
}
}
```
感觉合并与线段树合并很像。
## 分裂与合并的应用
利用分裂合并我们可以做些什么呢?我们可以维护一个有序序列并支持插入、删除、查询指定排名的一个数、查询一个数的排名、前驱、后继等。
### 插入
设插入的权值为x,那么我们把平衡树分裂为 $[l, x]$ 和 $[x + 1, r]$ 两棵平衡树,然后把新加入的节点也看作一课平衡树,最后**依次**合并三棵平衡树就可以了。
```cpp
split(rt, x, rt1, rt2);
rt = merge(merge(rt1, new_node(x)), rt2);
```
### 删除
删除分两种,一种是把权值为 x 的节点全部删除,一种是只删除一个,其实这两个是差不多的。
我们首先分裂出 $[l, x-1]$, $[x, x]$ 和 $[x+1, r]$ 三个区间。
如果全部删除,那没直接合并 $[l, x-1]$ 和 $[x+1, r]$ 两个区间即可。
```cpp
split(rt, x, rt1, rt3), split(rt1, x-1, rt1, rt2);
rt = merge(rt1, rt3);
```
如果只删除一个,我们就只把 $[x, x]$ 的根节点删去即可。
```cpp
split(rt, x, rt1, rt3), split(rt1, x-1, rt1, rt2);
rt2 = merge(tr[rt2][0], tr[rt2][1]);
rt = merge(merge(rt1, rt2), rt3);
```
### 查询某个数的排名
按照上面的思路,我们只需要把区间分裂为 $[l, x-1]$ 和 $[x, r]$。
然后 x 的排名就是 siz[rt1] + 1;
```cpp
split(rt, x-1, rt1, rt2);
printf("%d\n", siz[rt1] + 1);
rt = merge(rt1, rt2);
```
### 查询指定排名的一个数
这个只能暴力的往下找了,没别的好办法。
```cpp
inline int kth(int now, int k) {
while(1) {
if(k <= siz[tr[now][0]]) now = tr[now][0];
else if(k == siz[tr[now][0]] + 1) return now;
else k -= siz[tr[now][0]] + 1, now = tr[now][1];
}
}
```
### 查询前驱 与 后继
对于前驱我们把区间分裂为 $[l, x-1]$ 和 $[x, r]$, 然后直接查询第一个区间排名为 siz[rt1] 的数即可,查询后继与查询前驱是类似的。
```cpp
前驱
split(rt, x-1, rt1, rt2);
printf("%d\n", val[kth(rt1, siz[rt1])]);
rt = merge(rt1, rt2);
```
```cpp
后继
split(rt, x, rt1, rt2);
printf("%d\n", val[kth(rt2, 1)]);
rt = merge(rt1, rt2);
```
当然 FHQ-Treap 还有很多高级操作,可是我还没有学习,暂时先讲这么多吧。
## 例题
[【模板】普通平衡树](https://www.luogu.com.cn/problem/P3369)
直接给份代码:
```cpp
#include <bits/stdc++.h>
using namespace std;
const int N = 5e5 + 5;
inline int read(int x = 0) { return scanf("%d", &x), x; }
int val[N], siz[N], tr[N][2], rd[N], rt, rt1, rt2, rt3, cnt;
inline void pushup(int now) { siz[now] = siz[tr[now][0]] + siz[tr[now][1]] + 1; }
inline int new_node(int x) {
val[++cnt] = x, siz[cnt] = 1, rd[cnt] = rand(); return cnt;
}
inline int merge(int x, int y) {
if(!x || !y) return x + y;
if(rd[x] < rd[y]) {
tr[x][1] = merge(tr[x][1], y), pushup(x); return x;
}
else {
tr[y][0] = merge(x, tr[y][0]), pushup(y); return y;
}
}
inline void split(int now, int p, int &x, int &y) {
if(now == 0) return x = 0, y = 0, void();
if(p >= val[now]) x = now, split(tr[now][1], p, tr[now][1], y);
else y = now, split(tr[now][0], p, x, tr[now][0]);
pushup(now);
}
inline int kth(int now, int k) {
while(1) {
if(k <= siz[tr[now][0]]) now = tr[now][0];
else if(k == siz[tr[now][0]] + 1) return now;
else k -= siz[tr[now][0]] + 1, now = tr[now][1];
}
}
int main() {
int n = read(), op, x;
while(n--) {
op = read(), x = read();
switch(op) {
case 1: split(rt, x, rt1, rt2);
rt = merge(merge(rt1, new_node(x)), rt2); break;
case 2: split(rt, x, rt1, rt3), split(rt1, x-1, rt1, rt2);
rt2 = merge(tr[rt2][0], tr[rt2][1]), rt = merge(merge(rt1, rt2), rt3); break;
case 3: split(rt, x-1, rt1, rt2), printf("%d\n", siz[rt1] + 1);
rt = merge(rt1, rt2); break;
case 4: printf("%d\n", val[kth(rt, x)]); break;
case 5: split(rt, x-1, rt1, rt2), printf("%d\n", val[kth(rt1, siz[rt1])]);
rt = merge(rt1, rt2); break;
case 6: split(rt, x, rt1, rt2), printf("%d\n", val[kth(rt2, 1)]);
rt = merge(rt1, rt2); break;
}
}
return 0;
}
```
用时不算多,只有 300ms,比 splay 还是快一些的。
[普通平衡树(数据加强版)](https://www.luogu.com.cn/problem/P6136)
这道题可以真正看出 FHQ-Treap 和 splay 的时间效率的差距,splay 跑了 14s, 而FHQ-Treap只跑了 7.6s,确实是快很多。
## 总结
个人感觉 FHQ-Treap 既好理解又不容易错,时间效率很高,关键是代码具短。
打熟了之后大概10分钟就可以了。
而且 FHQ-Treap 扩展的功能很多,完全不弱于 splay。
好了,今天的讲解就到这里了。