fhq-treap
25.08.11
参考 这篇文章。
在学习 fhq-treap 之前,我们先要搞清楚二叉搜索树。
二叉搜索树 (BST)
定义
- 二叉搜索树是一颗二叉树。
- 空树是二叉搜索树。
- 二叉搜索树的左子树的所有结点的权值小于等于根节点,右子树的所有结点的权值大于等于根节点。
- 二叉搜索树的左子树、右子树均为二叉搜索树。
性质
- 一颗二叉树的中序遍历权值单调不降是 Ta 成为二叉搜索树的充要条件。(重点!)
- 二叉搜索树的结构使得我们可以很方便地查找一个数的排名、前驱、后继和全局第 \(k\) 小,复杂度均为 \(\Theta(深度)\).
问题
维护一颗普通的二叉搜索树是简单的,但复杂度没有保证。如果二叉搜索树的深度很大,则上面的操作和暴力算法无异。因此,在实际应用中我们必须维护一颗“平衡”的二叉搜索树,即树高“不太高”的——平衡树,以保证使用时的复杂度。
Treap
Treap = Tree + heap.
Treap 是一颗笛卡尔树,每个结点的两个权值分别满足二叉搜索树的性质和二叉堆 (后以小根堆为例) 的性质。
我们为每个结点随机分配一个值 \(rnd\),如果我们能在维护关于 \(val\) 的二叉搜索树的同时维护这个关于 \(rnd\) 的堆,那么最后的东西的树高大概在 \(\Theta(\log n)\) 量级。
性质
不妨令 \(val\) 和 \(rnd\) 都互不相同,则一颗 Treap 的形态是唯一确定的。再次强调:虽然二叉搜索树和小根堆的形态都不唯一,但 Treap 的形态是唯一的。
首先根节点唯一 (\(rnd\) 最小), 然后左子树的根是 \(val_v<val\) 的所有 \(v\) 中 \(rnd\) 最小的,右子树是 \(val_v>val\) 的所有 \(v\) 中 \(rnd\) 最小的,依次类推。
这也解释了为什么二叉搜索树和堆的形态都不唯一。二叉搜索树的根不是唯一的,堆哪个是左子哪个是右子是不确定的。Treap 则两者都能确定。
再次强调:结构确定是 Treap 的本质。
维护性质的方式
因为二叉搜索树只需要保证中序遍历单调不降,所以我们可以通过旋转在中序遍历不变的前提下改变结点之间的父子关系,进而维护堆的性质以保证树高。
这里要讲的 fhq-treap (无旋 Treap) 则是通过合并两颗 Treap 为大的一颗 Treap 以保证树高的。
fhq-treap
前文提到,二叉树成为二叉搜索树的充要条件是中序遍历单调不降。又因为 Treap 结构确定,我们就可以用一个有序序列代表这颗 Treap, 表示这颗 Treap 是“所有满足中序遍历是这个序列的所有二叉树里满足堆性质的”那颗二叉树。
如此,因为 Treap 的确定性,我们只要把区间拆对了,拆出来的两颗 Treap 就一定是对的。只要把区间合并对了,合成的大 Treap 就也一定是对的。
下面介绍 fhq-treap 的核心操作:分裂与合并。
分裂(split)
假设我们要以 \(p\) 为界把 \(val\in [l,r]\) 的大 Treap 拆成 \([l,p]\) 和 \([p+1,r]\) 两个 Treap。称 \([l,p]\) 的树为左区间树,另一个为右区间树,则分裂过程可以表示如下图(这里直接搬原作者的图了):

左右区间树分别有一个虚拟结点表示接下来放的数处于树的什么位置。对于当前点 \(u\), 如果 \(val_u\le p\), 则 \(u\) 及 \(u\) 的左子树里的点 \(v\) 也一定满足 \(val_v\le p\) (这是二叉搜索树的性质)。所以,我们将 \(u\) 及 \(u\) 的左子树整体挪到左区间树的虚拟节点的位置。又因为剩余的数一定满足 \(val\ge p\), 所以将左区间树的虚拟结点设为 \(u\) 的右子,在原树上递归地分裂 \(u\) 的右子。如果 \(val_u>p\) 同理,就不多讲了。
因为分裂的过程并没有改变结点之间的祖先关系,所以分裂后的两颗树也必定满足堆的性质。
注意一下边界,如果我们对一颗空树执行 split, 则必须给左右区间树的根节点赋上 \(0\) 的初始值。否则,我们访问到的空结点就一定是叶子的那不存在的左子或右子,直接赋为 \(0\) 也是没有问题的。
这部分的代码如下:
inline void pushup(int u){
tr[u].sz=tr[tr[u].lc].sz+tr[tr[u].rc].sz+1;
}
void split(int u,int lim,int &lroot,int &rroot){
if(u==0) return lroot=root=0,void();
if(tr[u].val<=lim) lroot=u,split(tr[u].rc,lim,tr[u].rc,rroot);//新的左区间虚拟结点需要放在 u 的右子的位置
else rroot=u,split(tr[u].lc,lim,lroot,tr[u].lc);//新的右区间虚拟结点要放在 u 的左子的位置
pushup(u);//更新 sz, 方便进行排名查询
}
合并(merge)
首先要特别说明一件事情:我们只会合并两个表示区间无交的 Treap.
反过来想,如果两颗 Treap 的表示区间有交,可能右区间树的 \(val\) 比左区间树小,在遍历时我们就没有很好的办法使其满足 Treap 的性质,不仅为二叉搜索树还为二叉堆。但我们也有一个好消息:因为 fhq-treap 的分裂操作是以某个值为界进行分裂,所以我们只需要会合并两个表示区间无交的数就好了。
正因如此,二叉搜索树的性质很容易被满足:只要把左区间树的中序遍历全部在右区间树左边就行了。
但我们显然不能如此草率,还有堆的性质需要我们满足。先满足根节点:记左区间树的根节点为 \(u\), 右区间树的根节点为 \(v\), 则 \(rnd\) 值较小的为根。
不过二叉树不能变成三叉树,又因为我们要保证中序遍历的顺序:如果 \(u\) 成为了根,右区间树还要完整地继续和 \(u\) 的右子树合并;如果 \(v\) 成为了根,左区间树还要完整地和 \(v\) 的左子树合并。
这部分的代码如下:
int merge(int lroot,int rroot){
if(lroot==0||rroot==0) return lroot|rroot;//其中一个是空树, 直接返回非空的那个就好了
if(tr[lroot].rnd<tr[rroot].rnd){//lroot 成为根,rroot 要完整地继续和 lroot 的右子合并
tr[lroot].rc=merge(tr[lroot].rc,rroot);
pushup(lroot);//一定不要忘记更新 sz!
return lroot;
} else {//rroot 成为根,lroot 要完整地继续和 rroot 的左子合并
tr[rroot].lc=merge(lroot,tr[rroot].lc);
pushup(rroot);
return rroot;
}
}
基本操作
包括插入, 删除, 查询全局第 \(k\) 小, 查询一个数的排名、前驱、后继。不想写了,读者可以选择点开 P3369 普通平衡树,并接着点击“查看题解”获得更好的体验。
这里 也给出一份用 fhq-treap 实现的 P3369 的 AC 代码。
进阶操作
平衡树还可以维护序列。
你或许会疑惑:平衡树不是 BST 吗,BST 的中序遍历要有序啊,怎么维护一个普通的不能再普通的序列呢?
其实一个普通的序列也是有一个东西有序的:下标单调递增。
所以我们令 BST 的中序遍历为原序列,即下标单调不降的序列,就可以像链表一样干插入、删除的事情,同时保持 \(\Theta(\log n)\) 的操作和访问复杂度 (所谓访问 \(a_i\), 就是查找下标第 \(i\) 小的元素)。
fhq-treap 则更胜一筹,在上面这一思想的基础上,Ta 能进行区间操作并单点查询。
具体地,我们通过分裂操作把原序列分割成 \([1,l-1],[l,r],[r+1,n]\) 三段,然后给 \([l,r]\) 上一个 lazytag 再合并回去。如此根据不同的 lazytag 设计方式,fhq-treap 可以支持区间翻转(这几乎是其他许多数据结构做不了的),区间加等一系列区间操作,在此不一一列举。
关于插入:P3850 [TJOI2007] 书架.
很板,不讲了。注意插入操作需要我们 fhq-treap 按排名分裂,代码差别不大就不放了。
关于区间翻转:P3391 【模板】文艺平衡树.
放一些警示后人:
- 当我们试图进入一个结点的儿子时,一定要下传标记。
- 注意把区间 \([l,r]\) 分割出来的代码是下面这样的,一定要先在右端点切:
int x,y,z;
split(root,r,y,z),split(y,l-1,x,y);//或:split(root,l-1,x,y),split(y,r-l+1,y,z);
//要先把 r 右侧的东西切出来,再把 l 左边的部分切出来。否则就要特别注意到底怎么切。
这里 是完整代码。

浙公网安备 33010602011771号