前言
之前A组有一道题目,要你维护一个字符串的插一个字符、删一段字符、复制并粘贴一段字符、翻转一段字符、查询一个字符。我当然想到了splay。但是看了眼数据范围:字符串的总长度不超过
231−1
2
31
−
1
,我就知道splay没戏,同时也很惊异这题连splay都切不了,那还能做吗?
于是我就打了暴力。
于是那题我至今未切。
大佬们估计都一眼看出是可持久化Treap了。但是我此前只是听说过Treap,并不了解它的做法和功能。
所以我只能从treap开始学起。这里写一篇博客,记录一下。
简介
树堆,在数据结构中也称Treap,是指有一个随机附加域满足堆的性质的二叉搜索树,其结构相当于以随机数据插入的二叉搜索树。其基本操作的期望时间复杂度为
O(log2n)
O
(
l
o
g
2
n
)
。相对于其他的平衡二叉搜索树,Treap的特点是实现简单,且能基本实现随机平衡的结构。
如果一个二叉排序树节点插入的顺序是随机的,这样我们得到的二叉排序树大多数情况下是平衡的,即使存在一些极端情况,但是这种情况发生的概率很小,所以我们可以这样建立一颗二叉排序树,而不必要像AVL那样旋转,可以证明随机顺序建立的二叉排序树在期望高度是
O(log2n)
O
(
l
o
g
2
n
)
,但是某些时候我们并不能得知所有的待插入节点,打乱以后再插入。所以我们需要一种规则来实现这种想法,并且不必要所有节点。也就是说节点是顺序输入的,我们实现这一点可以用Treap。
Treap=Tree+Heap
Treap是一棵二叉排序树,不过不能算是真正的二叉排序树。它的左子树和右子树分别是一个Treap,和一般的二叉排序树不同的是,Treap记录一个额外的数据,就是优先级。Treap在以关键字(它的某个值)构成二叉排序树的同时,它的优先级(随机分配的那个key值)还满足堆的性质(可以是大根堆,也可以是小根堆)。但是这里要注意的是Treap和二叉堆有一点不同,就是二叉堆必须是完全二叉树,而Treap可以并不一定是。
我们知道,Treap维护堆性质的方法可以运用旋转,而且只有左右旋转。但此法无法进行可持久化,所以我没有屑于学它,而是直接跑向了非旋的方法。
定义
我们可以像splay一样,用一个数组来表示一棵Treap。代码如下:
struct Treap
{
int pri;//随机分配的优先级,须满足堆的性质
int key;//键值,须满足二叉排序树的性质
int l,r;//左右儿子
int sz;//子树大小
bool tag;//翻转标记
inline void newnode(int _key)//新建一个键值为_key的点
{
pri=rand();
key=_key;
l=r=0;
sz=1;
}
}t[N];
核心操作:merge、split
merge
merge函数是用来合并a、b两棵子树,然后返回合并后的一整棵树。
如果我们维护的是小根堆,在合并时,我们首先看它们的根节点谁的键值比较小,并且建立对应的父子关系。
又由于平衡树的中序遍历不变,我们又要把b插在a后面,维持一个确定的中序遍历,
所以我们应该一直把a作为merge函数的前一个参数,b作为后一个参数,这个顺序不能换。
这一点应该很显然。
int merge(int a,int b)
{
push(a); push(b); //下传翻转标记
return !a||!b?a+b //如果a和b当中有空树则返回另一棵树
: (t[a].pri<t[b].pri?
(link(a,merge(t[a].r,b),1),update(a),a):
(link(b,merge(a,t[b].l),0),update(b),b));
}
split
split函数正好和merge相反,它的作用是分裂出以o为根的子树的前k个节点,然后作为两棵树返回。
我们首先定义一个pair,两个参数分别为分裂出来的两棵树的根节点。我们设第一个是分离出去的树,第二个是剩下的原树。
然后考虑分离前k个的过程:如果o的左儿子有k个以上节点,我们显然应该去左儿子分离。
然后我们会得到分离完成的树和左儿子剩下的树,这时候把左儿子剩下的部分接回节点o,并把新的o作为分离o剩下的原树。
如果左儿子节点个数不够,我们就去右儿子分离,但是由于k肯定≥size[左儿子]+1,所以整棵左子树以及根节点都会被分裂出去,右子树中的一部分也会受到波及。所以我们要分裂出右子树中的前k-size[左儿子]-1个节点,分裂出来的点接上已经只剩下左子树和根节点的原树,作为此次分裂出来的树;而从右子树分离后的原树,则作为剩下的原树。
#define mp make_pair
typedef pair<int,int> P;
P split(int o,int k)
{
if(!o)return mp(0,0);
push(o);
if(!k)return mp(0,o);
P y;
return t[t[o].l].sz>=k?
(y=split(t[o].l,k ),link(o,y.se,0),update(o),y.se=o,y):
(y=split(t[o].r,k-t[t[o].l].sz-1),link(o,y.fi,1),update(o),y.fi=o,y);
}
有了这两个核心操作之后,实现一些常用操作如insert、delete就易如反掌了。
常用操作:insert、delete
insert
insert操作的作用是将某个值为v的点插进Treap里。这个很简单,直接上代码。
inline void Insert(int v)
{
int k=Getkth(root,v);//在Treap上二分出最后一个值小于v的点的位置
P x=Split(root,k);//位置即点数,所以把所有值小于v的点先分裂出来
t[++cnt].newnode(v);//新建一个值为v的节点
root=merge(merge(x.first,cnt),x.second);//先合并所有值小于v的点和新建的点,再合并上其他点
}
delete
delete操作也和insert正好相反,它的作用是删掉Treap中第一个值为v的点。
void Delete(int v)
{
int k=Getkth(root,v);
P x=Split(root,k);//把所有值小于v的点先分裂出来
P y=Split(x.second,1);//把剩下的原树中第一个点即值等于v的点分裂出来
root=merge(x.first,y.second);//合并所有值小于v的点和所有值大于v的点
}
高级操作:翻转、平移、求和
解决这三个操作,我们可以将所需区间截取出来,然后那块区间就任我们摆布了。但是翻转、区间修改等操作需要打懒标记,方法与splay大同小异。
时间复杂度
在《简介》中就已提到过,你不知道肯定是因为你没认真看。
例题
洛谷P3369 这道题是来自模板OJ洛谷的一道模板题,涉及到插入、删除、查排名、查排名对应的数这些操作。
洛谷P3391 这道题是区间翻转,其实也很简单。比如他要翻转区间[l,r],你可以先把[1..l-1]分离出来,设其为a,[l..n]为b;再将b中的前r-l+1个(即原序列中的区间[l..r])分离出来,设为c,[r+1..n]设为d;我们在c上打上标记,最后令a、c、d合并。
JZOJ3599 这道题也是区间翻转,其实更适合splay做,但是treap也是可以的。我写了篇博客,读者不妨去了解一下。