【Treap】学习笔记
平衡树有很多种,像什么 Treap、Splay、红黑树啊等等,但其中最好理解、最好入门的还是 Treap 了,而且 Treap 的常数较小,跑起来比大部分平衡树都要快。
我们观察 Treap 这个单词,发现 Treap = Tree + heap。事实上 Treap 的实现确实是基于两个基本结构——二叉搜索树(BST)和堆(Heap)来实现的,因此在讲 Treap 前,我们先要讲讲 BST。
二叉搜索树(BST)
二叉搜索树是满足下面性质的一棵二叉树:
- 树上的任何一个节点都有一个权值,该节点权值小于其左子树中任意节点的权值,大于其右子树中任意节点的权值。(不考虑相等权值)
例如下图就是一棵 BST:

容易证明,一棵 BST 的中序遍历一定是一个严格单调递增的权值序列,因此 BST 的本质其实是动态维护一个有序序列。这也是 Treap 快速查询信息的本质。
可以更严格地证明(由于笔者太菜不会证)一棵随机生成的 BST 的期望高度为 \(\log n\),因此在一棵随机生成的 BST 上跑各种操作的平均时间复杂度是 \(O(\log n)\) 的。但是,我们可以很容易构造出一棵形态为链的 BST,此时 BST 各种操作的复杂度将退化为 \(O(n)\)。
Treap 的基本原理
Treap 的核心思想就是通过某些操作来达到维护 BST 尽量随机性质的,因此 Treap 的每个节点存放了两个权值 \(key\) 和 \(val\)。其中 \(key\) 是 BST 的权值,它用来维护 Treap 的 BST 性质,而 \(val\) 则与堆有关。
我们知道堆是一种完全二叉树,按内部元素排序方式可分为大根堆和小根堆,其满足内部根节点权值是整个堆里最大(小)的。Treap 利用这一点,给树上每个节点赋值一个随机的 \(val\),然后按照堆给节点按 \(val\) 大小调换位置,从而达到使 BST 尽量随机的目的。
具体而言,Treap 里的每个节点需至少存放如下信息:
- 该节点左右儿子编号;
- BST 权值 \(key\);
- 堆随机权值 \(val\)。
由于实际问题中难免碰到节点的 \(key\) 值重复的情况,因此我们需要额外存储一个计数器 \(cnt\),用于存储 \(key\) 值重复的节点个数;此外,我们为方便后续操作,还要记录以当前节点为根的子树大小 \(siz\)。
在结构体内部表示为:
struct Tree
{
int l, r;//左右儿子
int key;//BST 权值
int val;//heap 权值
int cnt;//这个数一共有多少个
int siz;//子树大小
}tr[N];
为方便起见,接下来本文中 Treap 内部均以大根堆方式实现。
Treap 的基本操作
首先我们需要两个参数 \(root\) 和 \(idx\),分别用于实时记录根节点编号以及当前已经分配到了第几个节点。
然后我们就可以建树(build)了。
为了便于我们处理边界,我们应当在插入所有节点前先插入两个 \(key\) 值分别为 \(-\infty\) 以及 \(+\infty\) 的节点作为“哨兵”,按照 BST 性质,此时 Treap 内部是这样的:

相应的,我们需要写一个创建节点(create)的函数:
int create(int key)//创建节点
{
tr[++ idx].key = key;
tr[idx].val = rand();//给节点赋随机 val
tr[idx].cnt = 1;//默认为叶子节点
tr[idx].siz = 1;//默认为叶子节点
return idx;//返回编号
}
以及类似线段树的 pushup 操作:
void pushup(int u)//上传信息
{
tr[u].siz = tr[tr[u].l].siz + tr[tr[u].r].siz + tr[u].cnt;
}
我们就能愉快地建树啦:
void build()//建树
{
create(-INF), create(INF);//插入哨兵,保证不越界
root = 1;//令初始根节点为负无穷
tr[1].r = 2;//INF 为 -INF 右儿子
pushup(root);
}
接下来,就是 Treap 用于维护其高度不超过 \(\log n\) 的重要操作,旋转了。
旋转分为两种,左旋(zag)和右旋(zig),它们之间的关系可以用下图展示(建议多看几遍,尝试感悟一下旋转方式):

可以发现,旋转的本质其实是交换父子节点,类似于堆中的上浮与下沉操作。并且左右旋不影响中序遍历的顺序,不会破坏 BST 性质。
左右旋的代码如下,建议再多看几次图,结合代码理解左右旋操作:
void zig(int &u)//右旋
{
int ls = tr[u].l;
tr[u].l = tr[ls].r, tr[ls].r = u, u = ls;
pushup(tr[u].r), pushup(u);
}
void zag(int &u)//左旋
{
int rs = tr[u].r;
tr[u].r = tr[rs].l, tr[rs].l = u, u = rs;
pushup(tr[u].l), pushup(u);
}
接下来就是插入与删除 Treap 中的节点的操作了。
先来看插入操作(insert)。此时我们考虑先将其变为一个叶子节点,那么可以直接 create,于是我们从根节点出发,按照 BST 性质考虑往下递归。如果当前节点的 \(key\) 值与待插入节点的 \(key\) 值相同,就说明有重复,将计数器加一;如果当前节点的 \(key\) 值大于待插入节点的 \(key\) 值,就将其插入其左子树,否则插入其右子树,以维护 BST 性质。
操作完毕后,我们还需考虑维护大根堆性质,这可以用旋转来实现。具体而言,如果插入左子树后节点的左儿子的 \(val\) 比该节点的 \(val\) 值更大,我们将采用右旋将这两个节点调换位置,右子树同理,采用左旋调换位置。最后还要上传信息。这样就完成了节点的插入,代码实现如下:
void insert(int &u, int key)
{
if(!u) u = create(key);//当前 u 是空的,为叶子节点,直接创建新节点
else//不为空
{
if(tr[u].key == key) tr[u].cnt ++;
else if(tr[u].key > key)//插入左子树
{
insert(tr[u].l, key);
if(tr[tr[u].l].val > tr[u].val) zig(u);//维护堆性质
}
else//插入右子树
{
insert(tr[u].r, key);
if(tr[tr[u].r].val > tr[u].val) zag(u);//维护堆性质
}
}
pushup(u);//更新值
}
对于删除操作(remove),考虑到对于叶子节点可以直接删除,因此我们通过旋转操作将待删除节点变为叶子节点。
删除操作最主要的难点是根据节点及节点左右儿子的 \(key\) 值及 \(val\) 值考虑该左旋还是该右旋。不同的情况有不同的处理方法。首先如果节点有重复,可直接将该节点计数器减一。否则如果当前节点有左子树和右子树,就要判断删除后谁当父节点,再继续递归左右子树;而如果是叶子节点即可直接删去。
直接用语言描述比较乱,可结合代码再好好理解(主要还是分类讨论):
void remove(int &u, int key)
{
if(!u) return;//删除的值不存在
else
{
if(tr[u].key == key)//相同
{
if(tr[u].cnt > 1) tr[u].cnt --;//个数大于 1,直接减去
else
{
if(tr[u].l || tr[u].r)//不是叶子节点,判断左右旋
{
if(!tr[u].r || tr[tr[u].l].val > tr[tr[u].r].val)//右子树为空,或者左儿子 val 大于右儿子 val,右旋
{
zig(u);
remove(tr[u].r, key);//删除右子树
}
else//左旋
{
zag(u);
remove(tr[u].l, key);//删除左子树
}
}
else u = 0;//是叶子节点,直接删除
}
}
else//key 不同
{
if(tr[u].key > key) remove(tr[u].l, key);//删左边
else remove(tr[u].r, key);//删右边
}
}
pushup(u);//更新值
}
接下来就是几个具体的操作了,这里以 P3369 【模板】普通平衡树 为例。
在模板题中,除开插入和删除操作外,我们做的事情有:
- 查询数值 \(x\) 的排名(即在中序遍历中的第几位);
- 查询排名 \(x\) 的数值(即中序遍历中第 \(x\) 位是什么值);
- 查询数值 \(x\) 的前驱(Treap 中严格小于 \(x\) 的最大值);
- 查询数值 \(x\) 的后继(Treap 中严格大于 \(x\) 的最小值)。
这几个操作相较于插入和删除操作比较简单,考虑递归实现,具体可见代码中的注释,已经写得比较详细了:
int qrank(int u, int x)//查询数值 x 排名
{
if(!u) return 1;//不存在,返回无效排名
else
{
if(tr[u].key == x) return tr[tr[u].l].siz + 1;//值相等,答案为左子树大小 + 1
else if(tr[u].key > x) return qrank(tr[u].l, x);//去左子树找排名,因为右边一定更大
else return tr[tr[u].l].siz + tr[u].cnt + qrank(tr[u].r, x);//去右子树找排名,并加上左子树及当前节点
}
}
int qkey(int u, int x)//查询排名 x 的数值
{
if(!u) return INF;//不存在,返回正无穷
else
{
if(tr[tr[u].l].siz >= x) return qkey(tr[u].l, x);//左子树大小大于等于待查询排名,则在左子树中找
else if(tr[tr[u].l].siz + tr[u].cnt >= x) return tr[u].key;//左子树包含不完,加上当前节点后又多了,则一定是当前节点
else return qkey(tr[u].r, x - tr[tr[u].l].siz - tr[u].cnt);//在右子树中找排名,并减去前面的排名
}
}
int qpre(int u, int x)//查询值 x 前驱(严格小于 x 的最大值)
{
if(!u) return -INF;//不存在,返回负无穷
else
{
if(tr[u].key >= x) return qpre(tr[u].l, x);//在左子树中找,因为右边一定更大
return max(tr[u].key, qpre(tr[u].r, x));//左子树中值必然劣于当前节点的 key,因此只需要得到当前节点与右子树最大值即可
}
}
int qnxt(int u, int x)//查询值 x 后继(严格大于 x 的最小值)
{
if(!u) return INF;//不存在,返回正无穷
else
{
//与查询前驱同理
if(tr[u].key <= x) return qnxt(tr[u].r, x);
return min(tr[u].key, qnxt(tr[u].l, x));
}
}
到此为止,Treap 中的基本操作就讲完了,我们来梳理一下。
对于插入和删除操作,重点是通过左右旋操作来维护 Treap 的形态;而对于四个查询操作,我们从根节点出发往下查找,通过当前节点的子树大小以及计数器来考虑答案到底会在左子树或右子树或当前节点中出现。
而对于操作的时间复杂度的话,考虑 Treap 的性质。由于我们给每个节点赋值了一个随机 \(val\),因此 Treap 的形态是较为随机的。而根据前文结论,随机的 BST 的期望高度为 \(\log n\),因此递归的层数不超过 \(\log n\) 层,所有操作的平均时间复杂度均为 \(O(\log n)\)。
例题代码实现
对于模板题 P3369 【模板】普通平衡树,总代码实现如下(需要注意的一点是,由于我们在一开始建树时额外插入了两个“哨兵”节点,因此在与排名相关的查询操作时要将 \(-\infty\) 的节点也考虑上):
#include<bits/stdc++.h>
using namespace std;
const int N = 1e5 + 10, INF = 1e8;
int Q;//询问个数
struct Treap
{
struct Tree
{
int l, r;//左右儿子
int key;//BST 权值
int val;//heap 权值
int cnt;//这个数一共有多少个
int siz;//子树大小
}tr[N];
int root;//根节点
int idx;//当前已经分配到了第 idx 个节点
int create(int key)//创建节点
{
tr[++ idx].key = key;
tr[idx].val = rand();//给节点赋随机 val
tr[idx].cnt = 1;//默认为叶子节点
tr[idx].siz = 1;//默认为叶子节点
return idx;//返回编号
}
void pushup(int u)//上传信息
{
tr[u].siz = tr[tr[u].l].siz + tr[tr[u].r].siz + tr[u].cnt;
}
void build()//建树
{
create(-INF), create(INF);//插入哨兵,保证不越界
root = 1;//令初始根节点为负无穷
tr[1].r = 2;//INF 为 -INF 右儿子
pushup(root);
}
void zig(int &u)//右旋
{
int ls = tr[u].l;
tr[u].l = tr[ls].r, tr[ls].r = u, u = ls;
pushup(tr[u].r), pushup(u);
}
void zag(int &u)//左旋
{
int rs = tr[u].r;
tr[u].r = tr[rs].l, tr[rs].l = u, u = rs;
pushup(tr[u].l), pushup(u);
}
void insert(int &u, int key)
{
if(!u) u = create(key);//当前 u 是空的,为叶子节点,直接创建新节点
else//不为空
{
if(tr[u].key == key) tr[u].cnt ++;
else if(tr[u].key > key)//插入左子树
{
insert(tr[u].l, key);
if(tr[tr[u].l].val > tr[u].val) zig(u);//维护堆性质
}
else//插入右子树
{
insert(tr[u].r, key);
if(tr[tr[u].r].val > tr[u].val) zag(u);//维护堆性质
}
}
pushup(u);//更新值
}
void remove(int &u, int key)
{
if(!u) return;//删除的值不存在
else
{
if(tr[u].key == key)//相同
{
if(tr[u].cnt > 1) tr[u].cnt --;//个数大于 1,直接减去
else
{
if(tr[u].l || tr[u].r)//不是叶子节点,判断左右旋
{
if(!tr[u].r || tr[tr[u].l].val > tr[tr[u].r].val)//右子树为空,或者左儿子 val 大于右儿子 val,右旋
{
zig(u);
remove(tr[u].r, key);//删除右子树
}
else//左旋
{
zag(u);
remove(tr[u].l, key);//删除左子树
}
}
else u = 0;//是叶子节点,直接删除
}
}
else//key 不同
{
if(tr[u].key > key) remove(tr[u].l, key);//删左边
else remove(tr[u].r, key);//删右边
}
}
pushup(u);//更新值
}
int qrank(int u, int x)//查询数值 x 排名
{
if(!u) return 1;//不存在,返回无效排名
else
{
if(tr[u].key == x) return tr[tr[u].l].siz + 1;//值相等,答案为左子树大小 + 1
else if(tr[u].key > x) return qrank(tr[u].l, x);//去左子树找排名,因为右边一定更大
else return tr[tr[u].l].siz + tr[u].cnt + qrank(tr[u].r, x);//去右子树找排名,并加上左子树及当前节点
}
}
int qkey(int u, int x)//查询排名 x 的数值
{
if(!u) return INF;//不存在,返回正无穷
else
{
if(tr[tr[u].l].siz >= x) return qkey(tr[u].l, x);//左子树大小大于等于待查询排名,则在左子树中找
else if(tr[tr[u].l].siz + tr[u].cnt >= x) return tr[u].key;//左子树包含不完,加上当前节点后又多了,则一定是当前节点
else return qkey(tr[u].r, x - tr[tr[u].l].siz - tr[u].cnt);//在右子树中找排名,并减去前面的排名
}
}
int qpre(int u, int x)//查询值 x 前驱(严格小于 x 的最大值)
{
if(!u) return -INF;//不存在,返回负无穷
else
{
if(tr[u].key >= x) return qpre(tr[u].l, x);//在左子树中找,因为右边一定更大
return max(tr[u].key, qpre(tr[u].r, x));//左子树中值必然劣于当前节点的 key,因此只需要得到当前节点与右子树最大值即可
}
}
int qnxt(int u, int x)//查询值 x 后继(严格大于 x 的最小值)
{
if(!u) return INF;//不存在,返回正无穷
else
{
//与查询前驱同理
if(tr[u].key <= x) return qnxt(tr[u].r, x);
return min(tr[u].key, qnxt(tr[u].l, x));
}
}
}T;
int main()
{
T.build();
cin >> Q;
while(Q --)
{
int op, x;
scanf("%d%d", &op, &x);
//所有操作均从 root 开始进行
//由于平衡树性质,层数不超过 logn
if(op == 1) T.insert(T.root, x);
else if(op == 2) T.remove(T.root, x);
else if(op == 3) printf("%d\n", T.qrank(T.root, x) - 1);//因为多了哨兵 -INF,因此排名 - 1
else if(op == 4) printf("%d\n", T.qkey(T.root, x + 1));//同上,找的时候,需要考虑 -INF,因此待查询排名 + 1
else if(op == 5) printf("%d\n", T.qpre(T.root, x));
else printf("%d\n", T.qnxt(T.root, x));
}
return 0;
}

浙公网安备 33010602011771号