treap
treap,又叫树堆。我们可以用它去维护一个序列,或者一个集合。裸的带旋的treap可以支持插入,删除,查第$k$大,查排名,以及其他的二叉搜索树的操作;无旋treap可以支持所有splay支持的操作(区间修改,翻转,覆盖,信息合并等等),操作的复杂度是$O(\log n)$的,并且不是均摊的!
关于treap的形态
它是一棵二叉树,并且,从原序列的结构上来看,它是一棵二叉搜索树,中序遍历拖出来可以得到原来的序列;而每个节点还有一个随机的权值$key$,从$key$上来看,整棵树满足堆的性质。(下面我们都假设是小根堆)
性质1:对于一个序列,每个点的$key$之间的大小关系确定的前提下,这个序列构造出来的treap是唯一的
(哦对了这个treap也是一个笛卡尔树)
我们考虑这个序列中$key$最小的那个位置,它必须是树的根(因为是堆)。然后它左边的那些位置必须在它的左子树内,右边那些位置则必须在它的右子树内(因为它是二叉搜索树)。并且它左边的、$key$最小的那个位置一定是它的左儿子(因为是堆)……这样下来,我们会发现这棵树形态是唯一确定的。
性质2:一个随机生成的长度为$n$的序列,它的笛卡尔树的高度期望是$O(\log n)$。
证明算法导论上面有,可是我太菜了看不懂qwq。
关于随机权值
LYC建议我用xorshift,因为stl自带的rand()值域小而且慢。
下面这个写法,循环节是$2^{32}-1$,$seed$初始化为一个非$0$的数就可以了。
1 |
|
wiki上的介绍(点击References处的链接可以下载论文)
带旋treap
我没有写过呢,因为感觉它没啥用。
大概的实现原理就是,插入一个数的时候,先把这个数在叶子的位置插入(要保证满足二叉搜索树的性质),然后它的$key$可能就不满足堆的性质了,我们就可以把它向上旋转(和splay类似,旋转后二叉搜索树的性质仍然满足,而该节点和父亲的位置交换了)。删除的时候,先把这个点向下旋转到叶子(选择$key$较小的儿子把它旋上来),然后把这个点删掉。其他操作和二叉搜索树是一样的。
而且这个东西还不能支持区间操作,简直不如 01 Trie。
参考这篇blog
无旋treap
(又叫fhq treap)这是个好东西,splay支持的东西(什么区间翻转啊,区间修改啊)它都支持,而且还比splay好写,而且还可以可持久化!
它的操作依赖于两个函数:
1.split:
1)将树的中序遍历里面的前$k$个节点分离出来,形成两个treap,一个里面是前$k$个节点,另一个里面是后$n-k$个节点。
2)(如果这棵树上的节点是对于$val$满足二叉搜索树的性质)将树中$val$小于等于$x$的节点分离到第一棵treap中,将其他的节点分离到第二棵treap当中。
2.merge:将两个treap(要求第一个treap的节点的$val$,也就是我们要让它满足二叉搜索树性质的那一维,必须全部小于第二个$treap$的节点的$val$)合并为一个treap。
merge
假设第一个treap的根为$a$,第二个treap的根为$b$,那么分两种情况讨论:
1)$a$的$key$小于$b$的$key$,那么$a$应该在$b$的上面,又因为要满足二叉搜索树的性质,所以合并$a$的右儿子与$b$即可。
2)否则,合并$b$的左儿子与$a$即可。
我实在是不知道它是怎么想出来的,不过它显然是对的。
1 | void merge(int &r,int a,int b) |
split
分离出前$k$个节点:我们考虑根节点属于那一棵treap,这只需要判断它的左儿子的$sz$即可。
1)如果左儿子的$sz$大于等于$k$,那么根节点属于第二棵treap,并且它的右儿子全部属于第二棵treap,我们递归分离左子树。
2)否则,这个点属于第一棵treap,并且它的左子树全部属于第一棵treap,我们递归分离右子树即可。
1 | void split(int x,int k,int &a,int &b) |
分离出权值小于等于$x$的点:同理,判断根节点属于哪一棵treap即可。
1 | void splitv(int x,int v,int &a,int &b) |
从上面split和merge的实现可以看出来,和两个操作的复杂度与treap的高度是同级的,而由于$key$是随机的,所以树高的期望是$O(\log n)$的,所以这两个操作的复杂度是$O(\log n)$的。
接下来我们来看一些常规的操作:
insert 插入
假如我们要在第$p$个元素后面插入一个元素,那么把前$p$个元素split出来,然后对将要新加入的元素单独建一棵树(实际上树中只有一个点),把原来的前$p$个元素,新加入的元素,后$n-p$个元素merge在一起就可以了。
而如果是要插入一段区间,我们就需要先把这个区间build成一个treap,利用单调栈维护最右链即可,代码如下:
1 | int new_node(int v) |
注意对新加入的元素单独建树的时候,所有维护区间信息的变量($sz$,$su m$之类的)要赋初值,因为我们是采取了合并的方式,要保证它是一棵合法的树。这一点和splay不一样。
维护$val$满足二叉搜索树性质的也是类似,只不过分离的是所有小于等于$v-1$的节点。
1 | void insert(int v) |
delete 删除
假如要删除第$p$个元素,我们就两次split把原树分成三个部分:前$p-1$个,第$p$个,后$n-p$个。然后直接把前$p-1$个和后$n-p$个merge起来就可以了。删除一段区间也是类似的。
1 | //下面的代码删除第p个元素开始的tot个元素,recycle是一个回收子树内所有节点的函数 |
如果是按照$val$满足二叉搜索树性质,并且可能有多个元素的$val$相同,假设我们现在要删除一个$val$等于$v$的元素,那么就把$val$等于$v$的单独分裂出来,把这棵treap根节点的左儿子和右儿子merge起来(本质上就是删掉一个节点),然后再把三棵树merge回去。
1 | void del(int v) |
区间操作
区间修改:我们把这段区间分离出来,然后打标记就可以啦。
区间查询:我们把这段区间分离出来,返回这棵treap的根的子树信息就可以了。
1 | //区间翻转 |
查排名
直接将小于等于$v-1$的分离出来,返回第一棵树的$sz+1$即可。
1 | int rnk(int v) |
查前驱后继
前驱:将小于等于$v-1$的分离出来,然后返回第一棵树中最大的元素。
后继:将小于等于$v$的分离出来,然后返回第二棵树中最小的元素。
(当然也可以用二叉搜索树的写法)
1 | //返回前驱,后继的节点编号 |
其他操作
和splay,二叉搜索树一样的~
模板题
普通平衡树 我的提交记录
文艺平衡树 我的提交记录
维护数列 我的提交记录
可持久化Treap
split 与 merge
split
可持久化意味着我们在操作的时候不能够修改任何的东西。
观察一下split的代码:
1 | void split(int x,int k,int &a,int &b) |
我们实际上修改了什么?分离完后,第一棵树的最右链上的点的右儿子,以及第二棵树上最左链上的点的左儿子。那么我们把这些最右链和最左链上的点复制一遍,在复制的版本上修改就可以了。
1 | void split(int x,int k,int &a,int &b) |
merge
还是看代码:
1 | void merge(int &r,int a,int b) |
我们实际上修改的,是第一棵树的最右链上的点和第二棵树最左链上的点。复制一遍即可。
1 | void merge(int &r,int a,int b) |
等等,真的需要复制吗?对于许多的题目,我们都是先split,然后再merge,merge的时候第一棵树的最右链和第二棵树的最左链在split的时候都已经复制过了,所以其实可以直接赋值,不再复制节点,即和普通的无旋treap一样的写法。
区间操作
有了函数式的split和merge,我们就可以可持久化带插入,单点修改,删除的无旋treap了。
但是一旦涉及到区间操作,就涉及到打标记,就很难受了。
看到网上的题解大多数是这样子实现的:下放标记的时候,把儿子节点复制一遍,像这样——
1 | void push_down(int x) |
这样子显然是正确的,但是感觉空间会造成浪费。能不能直接像可持久化线段树那样,把标记永久化了呢?
答案是肯定的!我在这篇题解中介绍了它。空间和时间相对于前面的写法都要优秀一些。
复杂度
时间复杂度比较显然,单次操作是$O(\log n)$的。
但是空间复杂度,尽管单次操作是$O(\log n)$的,但是每次操作要split和merge好几次,而且本来这个树高也不是严格$\log n$的,所以我觉得TGSteven的话很有道理:数组能开多大开多大!