Treap
简介
Treap(树堆)是一种 弱平衡 的 二叉搜索树。
Treap 的结点除了被维护的 权值()之外,还附加了一个随机的 优先级()。其中,权值满足二叉搜索树性质,优先级满足堆性质(小根堆或大根堆)。
其中,二叉搜索树的性质是指:
- 左子节点的权值()比父节点小。
- 右子节点的权值()比父节点大。
堆的性质是:
- 子节点优先级()比父节点大或小(取决于是小根堆还是大根堆)。
不难看出,如果用的是同一个值,那么这两种数据结构在组合后会变成一条链,所以我们再在搜索树的基础上,引入一个给堆的值 。对于 值,我们维护搜索树的性质,对于 值,我们维护堆的性质。其中 这个值是随机给出的。
下图就是一个 Treap 的例子(这里使用的是小根堆,即根节点的优先级最小)。

那我们为什么需要大费周章的去让这个数据结构符合树和堆的性质,并且随机给出堆的值呢?
要理解这个,首先需要理解朴素二叉搜索树的问题。在给朴素搜索树插入一个新节点时,我们需要从这个搜索树的根节点开始递归,如果新节点比当前节点小,那就向左递归,反之亦然。
最后当发现当前节点没有子节点时,就根据新节点的值的大小,让新节点成为当前节点的左或右子节点。
如果插入结点的权值是随机的(换言之,是随机插入的),那这个朴素搜索树的高度较小(接近 ,其中 为结点数),而每一层的节点数较多,即它的形状会非常的「胖」。上图的 Treap 就是一个例子。因此此时的任意操作复杂度都将会是 左右。
不过,这只是在随机情况下的复杂度,如果我们按照下面这个非常有序的顺序给一个朴素的搜索树插入节点:
那么这棵树将会退化成链,即变得非常「瘦长」(每次插入的节点都比前面的大,所以都被安排到右子节点了):

不难看出,查询的复杂度也从 变成了 .
而 treap 为了解决这个问题、达到一个较为「平衡」的状态,通过维护随机的优先级满足堆性质,「打乱」了节点的插入顺序,从而让二叉搜索树达到了理想的复杂度,避免了退化成链的问题。
Treap 复杂度的证明
由于 treap 各种操作的复杂度都和所操作的结点的深度有关,我们首先证明,所有结点的期望高度都是 .
记号约定
为了方便表述,我们约定:
- 是节点个数。
- Treap 结点中满足二叉搜索树性质的称为 权值,满足堆性质的(也就是随机的)称为 优先级。不妨设优先级满足小根堆性质。
- 表示权值第 小的结点。
- 表示集合 ,即按权值升序排列后第 个到第 个的结点构成的集合。
- 表示结点 的深度。规定根节点的深度是 .
- 是一个指示器随机变量,当 是 的祖先时值为 ,否则为 . 特别地,.
- 表示事件 发生的概率。
树高的证明
由于结点 的深度等于它祖先的个数,因此有
那么根据期望的线性性,有
由于 是指示器随机变量,它的期望就等于它为 的概率,因此
我们先证明引理: 当且仅当 的优先级是 中最小的。
引理的证明
证明:考虑分类讨论 和 的情况。
- 若 是根节点:由于优先级满足小根堆性质, 的优先级最小,并且对于任意的 , 都是 的祖先。
- 若 是根节点:同理, 优先级最小,因此 不是 中优先级最小的;同时 也不是 的祖先。
- 若 和 在根节点的两个子树中(一左一右),那么根节点 . 因此 的优先级不可能是 中最小的(因为根节点的比它小)。同时,由于 和 分属两个子树, 也不是 的祖先。
- 若 和 在根节点的同一个子树中,此时可以将这个子树单独拿出来作为一棵新的 treap,递归进行上面的证明即可。
那么根据引理,深度的期望可以转化成
又因为结点的优先级是随机的,我们假定集合 中任何一个结点的优先级最小的概率都相同,那么
因此每个结点的期望高度都是 .
而朴素的二叉搜索树的操作的复杂度均是 ,同时 treap 维护堆性质的复杂度也是 的,因此 treap 各种操作的期望复杂度都是 .
期望复杂度的感性理解
首先,我们需要认识到一个节点的 属性是和它所在的层数有直接关联的。再回忆堆的性质:
- 子节点值()比父节点大或小(取决于是小根堆还是大根堆)
我们发现层数低的节点,比如整个树的根节点,它的 属性也会更小(在小根堆中)。并且,在朴素的搜索树中,先被插入的节点,也更有可能会有比较小的层数。我们可以把这个 属性和被插入的顺序关联起来理解,这样,也就理解了为什么 treap 可以把节点插入的顺序通过 打乱。
给 treap 插入新节点时,需要同时维护树和堆的性质。其中,搜索树的性质可以在插入时维护,而堆性质的维护则有两种处理方法,分别是旋转和分裂、合并。使用这两种方法的 treap 被分别称为 旋转 treap 和 无旋 treap。
旋转 treap
旋转 treap 维护平衡的方式为旋转,和 AVL 树的旋转操作类似,分为 左旋 和 右旋。即在满足二叉搜索树的条件下根据堆的优先级对 treap 进行平衡操作。
旋转 treap 在做普通平衡树题的时候,是所有平衡树中常数较小的。
下面的讲解中的代码用指针实现了旋转 treap,文末附有数组形式的完整实现。
代码中的 rank
代表前面讲的优先级( 属性),该属性满足的是小根堆性质。
节点结构
旋转
旋转操作是 treap 的一个非常重要的操作,主要用来在保持 treap 树性质的同时,调整不同节点的层数,以达到维护堆性质的作用。
旋转操作的左旋和右旋可能不是特别容易区分,以下是两个较为明显的特点:
旋转操作的含义:
- 在不影响搜索树性质的前提下,把和旋转方向相反的子树变成根节点(如左旋,就是把右子树变成根节点)
- 不影响性质,并且在旋转过后,跟旋转方向相同的子节点变成了原来的根节点(如左旋,旋转完之后的左子节点是旋转前的根节点)
左旋和右旋操作是相互的,如下图。

插入
类似普通二叉搜索树的插入,但是需要在插入的过程中通过旋转来维护优先级的堆性质。
删除
主要就是分类讨论,不同的情况有不同的处理方法,删完了树的大小会有变化,要注意更新。并且如果要删的节点有左子树和右子树,就要考虑删除之后让谁来当父节点(维护 rank 小的节点在上面)。
根据值查询排名
操作含义:查询以 cur 为根节点的子树中,val 这个值的大小的排名(该子树中小于 val 的节点的个数 + 1)
根据排名查询值
要根据排名查询值,我们首先要知道如何判断要查的节点在树的哪个部分:
以下是一个判断方法的表:
左子树 | 根节点/当前节点 | 右子树 |
---|---|---|
排名 ≤ 左子树的大小 | 排名 > 左子树的大小,并且 ≤ 左子树的大小 + 根节点的重复次数 | 排名 > 左子树的大小 + 根节点的重复次数 |
注意如果在右子树,递归的时候需要对原来的 rank
进行处理。递归的时候就相当去查,在右子树中为这个排名的值,为了把排名转换成基于右子树的,需要把原来的 rank
减去左子树的大小和根节点的重复次数。
可以把所有节点想象成一个排好序的数组,或者数轴(如下),
1 -> |左子树的节点|根节点|右子树的节点| -> n ^ 要查的排名 ⬇转换成基于右子树的排名 1 -> |右子树的节点| -> n ^ 要查的排名
这里的转换方法就是直接把排名减去左子树的大小和根节点的重复数量。
查询第一个比 val 小的节点
注意这里使用了一个类中的全局变量,q_prev_tmp
。
这个值是只有在 val 比当前节点值大的时候才会被更改的,所以返回这个变量就是返回 val 最后一次比当前节点的值大,之后就是更小了。
查询第一个比 val 大的节点
跟前一个很相似,只是大于小于号换了一下。
完整代码
以下为treap的模板代码: