浅谈无旋转 Treap
简介
Treap = Tree + Heap。Treap 是一种弱平衡的二叉搜索树。
相较普通的二叉搜索树,平衡二叉树与之最显著的区别就是后者是 “平衡的”,即采取了一些措施防止搜索树退化为一条链。严格来说,平衡二叉搜索树要求对于任意节点,左子树和右子树的高度差不可超过 。维护这一性质往往需要各种复杂的旋转,反而可能会带来较大的常数。而弱平衡二叉树虽然不能保证左右子树高度差不超过 ,但是可以基本保证树的平衡,难以退化成长链的情况。
Treap 通过二叉堆的性质来维护二叉树的平衡。一个二叉(小根)堆满足:一个节点的两个儿子的值都小于节点本身。但是这样的规定与二叉搜索树的性质矛盾…… 为了解决这一问题只好让每个节点包含两个键值,其中随机生成的 满足二叉堆性质,而要保存的数据 满足二叉搜索树性质。由于主流的随机数生成算法很难出现完全单调的序列,故以此可以保证 Treap “基本平衡“。
无旋转的 Treap
节点定义
根据前面的描述,我们可以如下定义 Treap 中的节点:
class Treap {
public:
int val; // 当前节点处的值
int rnd; // 随机值
int siz; // 以当前节点为根子树的大小
int son[2]; // 左右儿子的下标
};
Treap trp[SIZE]; // 内存池
int trpPt; // 内存池中第一个空闲节点的下标在后文的实现中有一个略微取巧的地方:将 trp[0]
初始化为空节点,因此节点下表应当从
开始分配。这样带来的好处是判断节点是否为空的时候更加方便(只需要判断下标是否为
就行了)。此外,后文为了让代码更加直观,定义了如下函数:
/* node(rt): int -> Treap& 获取下标为 rt 的节点信息 */
Treap& node(int rt) { return trp[rt]; }
/* lson(rt): int -> Treap& 获取下标为 rt 的左儿子节点信息 */
Treap& lson(int rt) { return trp[trp[rt].son[0]]; }
/* rson(rt): int -> Treap& 获取下标为 rt 的右儿子节点信息 */
Treap& rson(int rt) { return trp[trp[rt].son[1]]; }
/* maintain(rt): int -> void 更新以 rt 为根的子树大小 */
void maintain(int rt) { node(rt).siz = lson(rt).siz + rson(rt).siz + 1; }核心操作
一般的 Treap 是带有旋转操作的(正如大部分平衡树一样),而无旋转的 Treap 却用巧妙的方式避免了旋转。无旋转 Treap 抽象出了如下两种核心操作:
- :合并两棵子树(它们的根节点分别是 和 ,并且满足 中的所有 均小于 中的任意 ),复杂度 ;
- :将以 为根节点的树分裂为分别以 为根的两棵子树,其中前者包含 中的前 小的节点(也可以实现为值小于 的节点),后者包含剩余节点。复杂度 。
注意:合并操作的前提是其中一棵 Treap 上的所有键值(即 )小于另一棵 Treap 上的键值,否则只能启发式合并!
有了这两个操作,我们就可以实现平衡树的大部分功能了。但在此之前,让我们先来看看如何实现这两个核心函数。
合并
我们假设现在维护的是满足小根堆性质的 Treap,记将被合并的两棵子树的根节点为 和 。我们以分治的思想考虑合并。
首先我们考虑 应满足小根堆性质,故 和 中 较小的点应当成为另一点的祖先。不妨假设 (反之是同理的),则 应当成为 的祖先。
接下来考虑 应满足二叉搜索树性质。由于 节点原先的左子树中的值一定小于 中的值,所以事实上我们只需要合并 节点原先的右子树和 。因此我们可以递归下去,也就完成了合并操作。
当然,最后还需要更新一下新的子树大小。
参考代码如下:
int merge(int fstRt, int sndRt) {
if (fstRt == 0)
return sndRt;
if (sndRt == 0)
return fstRt;
if (node(fstRt).rnd < node(sndRt).rnd) {
node(fstRt).son[1] = merge(node(fstRt).son[1], sndRt);
maintain(fstRt);
return fstRt;
} else {
node(sndRt).son[0] = merge(fstRt, node(sndRt).son[0]);
maintain(sndRt);
return sndRt;
}
}分裂
分裂其实有两种方式:
- 按排名分裂:前 小的数构成一棵子树,剩余的构成另一棵;
- 按权值分裂:小于等于 的数构成一棵子树,剩余的构成另一棵。
接下来我们主要演示按排名分裂(按权值分裂基本同理),其实现也基于分治思想。
对于以 为根的树,我们首先要考虑根要被拆分到值较小的左子树 还是值较大的右子树 去。如果当前左子树的大小大于等于 ,则说明 以及其右子树都是应当被分到 中去的。那我们不妨先把 当作 。接下来的问题就是要在 的中继续分裂出前 小的值给 。因此我们递归地调用下去就好了。
而如果 的左子树大小小于 ,则说明整个左子树都应该给 。类似地,不妨把 当作 ,然后递归地在 中分裂出前 的值给 就好了。
最后不要忘了需要更新新的子树大小。
参考代码如下:
void split(int rt, int k, int & fstRt, int & sndRt) {
if (rt == 0) {
fstRt = 0; sndRt = 0;
return;
}
if (k <= lson(rt).siz) {
sndRt = rt;
split(node(rt).son[0], k, fstRt, node(rt).son[0]);
} else {
fstRt = rt;
split(node(rt).son[1], k - lson(rt).siz - 1, node(rt).son[1], sndRt);
}
maintain(rt);
}对于按权值分裂也是同理的,这里就只给出代码了:
void splitByVal(int rt, int val, int & fstRt, int & sndRt) {
if (rt == 0) {
fstRt = 0; sndRt = 0;
return;
}
if (node(rt).val > val) {
sndRt = rt;
splitByVal(node(rt).son[0], val, fstRt, node(rt).son[0]);
} else {
fstRt = rt;
splitByVal(node(rt).son[1], val, node(rt).son[1], sndRt);
}
maintain(rt);
}这意味着什么?
支持快速插入删除的数组
- 在任意位置
插入节点
:
- ;
- ;
- 在任意位置
删除节点:
- ;
- ;
- ;
- 询问位置
处节点的值:
- ;
- ;
- 得到了 节点的值~
- ;
看起来很有用~ 基本可以代替掉块状链表了。
平衡树
我们不妨先直接拉一道家喻户晓的模板题来举个例子:普通平衡树 - 题目 - LibreOJ。
题目中要求我们实现 类操作:
- 插入数 ;
- 删除数 (若有多个相同的 则只删除一个);
- 查询数 的排名(若有多个相同的 则输出最小排名);
- 查询排名为 的数;
- 求 的前驱(即小于 的数中的最大值);
- 求 的后继(即大于 的数中的最小值)。
下面我们就试着主要靠两个核心函数来解决上述 个问题。
插入
首先我们将带插入节点的 赋成随机值,然后按 将 Treap (按权值分裂) 成小于等于 的 和大于 的 。我们不妨把待插入的 看成一棵只含一个节点的 Treap,先将其与 ,再得到的 Treap 与 ,就完成插入了。
删除
与插入类似,首先我们按 将 Treap 成小于等于 的 和大于 的 ,再将 成小于等于 的 和大于 (即等于 )的 。接下来,我们将 和 在一起即可。
上述操作会把所有等于 的数全部删掉。如果题目只要求删掉一个 ,可以考虑将 的左子树和右子树合并在一起得到 。这样一来就等于舍弃了 的根节点,使得 中等于 的数少了一个。最后再把 也 进答案。
查询数的排名
实际上就是查询有多少个数比 小,然后将结果加 即是排名。
考虑现按 将 Treap 成 和 ,这样一来 的大小加上 就是我们需要的答案。查询完后再将 和 起来恢复原状即可。
查询排名对应的数
我们只需要实现按排名分裂,就可以跟上面类似直接 一下就好了。
查询前驱或后继
这里我们以查询前驱为例(后继完全同理):
我们考虑先将 Treap 按 成 和 ,那么 树中最大的数(即第 大)就是我们要查询的答案。借用上面的查询排名对应数的函数即可实现。
参考代码
大家可以看看 我的代码 来对照理解一番~
%%%
- chen_tr - 偷懒专用平衡树——Treap
- LadyLex - 无旋treap:从好奇到入门
- yyf0309 - 无旋转Treap简介