书接上回:二叉搜索树 BST。二叉搜索树是本文所讲平衡树的必要前置知识。
平衡树简介
平衡树是为了解决普通二叉搜索树(
在平衡树中,同时存在着二叉搜索树的性质和堆的性质(Treap = Tree + Heap)。为了让树更加平衡,我们引入一个随机化的值,用来满足平衡树的堆性质。通过引入这么一个随机变量,把原先可能完全有序的序列打乱,让它从期望上尽量接近一棵满二叉树,进而实现稳定时间复杂度的效果。
平衡树的代码实现
如何生成更好的随机数
大部分人比较清楚的是我们的牢朋友 srand()
和
rand()
,调用前者以设置随机数引擎的随机种子,后者用于生成一个随机数。然而,如果你忘记先使用
srand()
设置种子,程序会每一次默认一个固定的种子来生成随机数,这样一来生成出来的随机数就不“随机”了——它们会变成固定的一串序列。不过你也可以凭经验,记住某个种子来达到理想的效果……
那么如何生成更“随机”的随机数呢?标准库里内置了
random_device
,向系统索取一个随机数;C++同时也内置了一个整数随机数生成算法
mt19937
(以及它的64位长整型版本
mt19937_64
,仍然需要人工设置种子),它的全称是梅森旋转算法,用它们生成随机数的代码如下:
1 | // Random device |
使用无符号整型是为了防止溢出问题。
据说是无符号整型的溢出问题导致《文明6》中的“
非暴力不合作运动发起人”甘地通常是第一个向整个世界扔核弹的领导人。
基本创建
1 | struct Treap { |
全局变量和二叉搜索树基本相同,不同的是引入了一个随机设备
random_device
用来生成随机数。
1 | int idx = 0, root; |
单点创建、建树的思路和二叉搜索树一模一样,只是在创建新点是要赋一个额外的随机化参数。
1 | int allocate(int dat, bool on_init = false) { |
节点旋转
节点的左旋和右旋可谓是平衡树的看门好戏了葡萄糖:我也是,这两者都是用来维持平衡树平衡性的利器。接下来介绍两种节点旋转的法则:
左旋
看下图(图源 OI Wiki - 二叉搜索树 & 平衡树),节点
从图中我们可以直观地总结出左旋的规则:节点的右孩子向左上旋转成为原节点的父节点,原节点向左下旋转成为一个左孩子,让原先右孩子的左孩子变成原节点的右孩子。在本例中:节点
右旋
看下图(图源 OI Wiki - 二叉搜索树 & 平衡树),节点
我们也可以总结出右旋的规则:节点的左孩子向右上方旋转变成父节点,原节点向右下转变为一个右孩子,让原先左孩子的右孩子变成原节点的左孩子。本例中:节点
简记技巧
从实质角度来讲:
左旋拎右左挂右,右旋拎左右挂左。
——
如果还不能理解比如我,可以看下面这个基于代码的顺口溜:
《右旋——输钱》
钱赔了
q = tree[p].ls
赔了前任
tree[p].ls = tree[q].rs
穷人贫
tree[q].rs = p
贫穷
p = q
《左旋——还钱》
强迫人
q = tree[p].rs
派人抢了
tree[p].rs = tree[q].ls
钱来赔
tree[q].ls = p
赔钱
p = q
事实上可以只背一段,对应的另一种旋转的代码就是在保持左右节点不变的情况下(不更改
ls
和 rs
的位置),把访问下标的 p
和 q
对调即可。当然 p = q
这一句是通用的、q
的定义也要稍作变通。
或者你还可以从中序遍历的角度来记忆,我们知道左旋和右旋不改变原树的中序遍历。而对于形如下图的二叉搜索树:
我们可以通过把树压成一条线来获取它的中序遍历序列(如图),因为旋转后父子节点会交换,因此把中序链条拉起来之后要保证原来的子节点(蓝色圆形)要在上边。可以对照例图自行模拟该过程加深记忆。
1 | void update(int idx) { |
数据插入
基本思路与
在这里,我着重实现小根堆平衡树,这意味着层数越小的节点所携带的随机数更小。如果当前插入一个值,需要被插入到左子树中,但是新节点的随机数更小一些,我们就需要把它提到上边去,对应起来就是右旋上去;反之就是左旋。简称:“左大右旋,右大左旋”。因为每次操作后都要更新根节点的数据,因此这里使用
else if
。
1 | void insert(int &idx, int dat) { |
节点删除
基本思路是把一个节点不断旋转成一个叶子节点,然后直接删去。有如下几种情况:
- 如果待删去点的重复次数大于
,那么减去一。 - 如果不存在右子树,让左子树替代它,直接把它删除。
- 如果不存在左子树,让右子树替代它,直接把它删除。
- 如果出现“左大右小”,则右旋。
- 如果出现“左小右大”,则左旋。
- 如果不在以上所有情况中,代表已经到达叶子节点,直接删除。
可以发现前三点与一般的
1 | void remove(int &idx, int dat) { |
前驱 & 后继
在模板题里,题面没有保证所查询前驱的点一定存在于树之中。原先基于查询目标点,再不断深入子树的方法就不再适用。这里介绍一种新的基于递归全树的方法。
以求前驱为例,需要满足以下几点要求:
- 如果下标无意义,返回负无穷
- 如果当前根节点大于等于所给值,证明目标点和它的前驱在左子树中,递归查找
- 否则,当前点就已经满足小于目标点的性质,作为前驱的潜力候选者,与在右子树查找的结果取最大值
这些要求都很简单明了,不再过多解释。
1 | int precursor(int idx, int dat) { |
有关排名查询、数值查询的求解代码均与
典例演练
洛谷 P3369 [模板] 普通平衡树
题目地址:P3369
题目难度:提高+/省选-
您需要写一种数据结构(可参考题目标题),来维护一些数,其中需要提供以下操作:
- 插入一个数
。 - 删除一个数
(若有多个相同的数,应只删除一个)。 - 定义排名为比当前数小的数的个数
。查询 的排名。 - 查询数据结构中排名为
的数。 - 求
的前驱(前驱定义为小于 ,且最大的数)。 - 求
的后继(后继定义为大于 ,且最小的数)。 对于操作 3,5,6,不保证当前数据结构中存在数
。 输入格式:
第一行为
,表示操作的个数,下面 行每行有两个数 和 , 表示操作的序号( ) 输出格式:
对于操作
每行输出一个数,表示对应答案。 数据范围: 对于
的数据, , 来源:Tyvj1728 原名:普通平衡树
在此鸣谢
同样是模板题,为了节省篇幅,代码在我的云剪切板。
你怎么知道我调了一整天才调出来的
后记 & 鸣谢
和我开始打
在这里仍然需要致谢:
参考资料:
[1] DWHJHY.树堆-TREAP [EB/OL] .https://www.luogu.com/article/ugcagegy, 2024-4-17/2024-7-15
[2] Brailliant11001.入门平衡树——Treap [EB/OL] .https://www.luogu.com/article/tyvidvb6, 2024-2-1/2024-7-15