您的位置:首页 > 理论基础 > 数据结构算法

偷懒专用平衡树——Treap

2016-03-18 15:41 691 查看

日常开始前胡扯一通

如我上一篇文章所说,先拣软柿子捏,找一些熟悉的东西试试手,权当复习。

当我思考究竟该怎么写这第一篇时,突然意识到Treap可能是一个没什么用的东西,尤其是在实际的工程开发中。在竞赛中,Treap可能是一个好的选择,因为它写起来快,易调试,相比那些复杂的平衡树来说效率并没有太多的下降。然而在实际开发中,要么直接会去使用标准模板库里的map、set这样的现成平衡树,要么索性做的精致一点,直接写一个AVL或者RBTree封装好。Treap就处在了一个比较尴尬的位置。

当然无论如何,这并不影响本文的内容。

特别提醒:本人现在LaTeX基本不会用,目测本文要出现大量的图片……

从二叉查找树说起

为了凑点字数,还是从普通的二叉查找树说起吧。

一个简单的问题

一个长度为n的有序序列,从中查找一个指定的值,要花多少时间?

一个最简单的做法就是一个个去试。如果你运气好,第一个就碰上了;如果你运气不好,最后一个才是你要查的值,那就需要把n个值都检查一遍。时间复杂度O(n)。

当然你可能注意到了有序这个有用的性质,所以可以采用二分查找的方式,具体就不赘述了。时间复杂度O(log(n))。

但是如果要添加一个数据怎么办?要保证序列的有序性,你必须要插入到适当的位置。这个位置同样可以通过二分查找在O(log(n))的时间中找出。

可是插入的过程呢?我们必须把后面的数据一个个顺次往后挪一格,而这需要O(n)的时间。

太慢了!我们需要快一点的方法。

二叉查找树

这种时候,一个可能的解决方法就是使用二叉查找树。

那个说树状数组的给我出来,我保证不打死你

二叉查找树是这样的一种数据结构,保证在任意一个节点上,左儿子的值小于自己的值小于右儿子的值(这里暂且不考虑等于的情况),从而满足中序遍历的结果是有序的。

如图所示:



原谅我弄出这么个lowB的图(掩面)

这一棵二叉查找树的中序遍历就是2-5-8-13-17-20-22-26。

使用二叉查找树有什么好处呢?

假如你插入时的数据基本随机,那么你建立的二叉查找树应该是基本平衡的。所谓平衡,就是这棵树的每个叶子节点的深度都基本相同。如果一棵二叉查找树基本平衡,那么它将会有以下好的性质:

查找一个值的时间约为O(log(n))

插入一个值的时间约为O(log(n))

而且它的代码也非常容易完成,每个操作只需要一个小的递归函数就可以了,目测没几行。

二叉查找树的问题

这么好的东西基本没人用自然是有原因的。

上文提到,“如果二叉查找树是基本平衡的”。

这是个不错的flag,很多时候你都没法保证它是基本平衡的。尤其在竞赛中,这种卡你的数据是必然会出现的。

举个例子,假如你在建树的时候,给你的数据是有序的,那么建出来的树可能就是这样的:



这样一棵二叉查找树就退化成了一个链表。此时,查找操作的时间复杂度就变成了O(n)。

那我还不如写个链表呢

当然,你也可以采用其他的一些方法,比如全读进来再随机插入到树里。但这些方法终归都是有局限性的。

从平衡树到Treap

平衡树

那么,有没有使一棵二叉查找树墙柱保持平衡的方法呢?答案当然是肯定的,这样的二叉查找树就被成为平衡二叉查找树,简称平衡树。

当然这意味着你要写更多的代码

平衡树有很多种,比如说AVL,红黑树,SBT,Splay等等。我们今天要介绍的Treap就是平衡树中相对比较好写的一种。

代价就是慢,当然比起线性表来说还是强多了

Treap

废话扯了这么多终于进入正题了……

Treap,顾名思义就是Tree+Heap。这么命名的原因就是它使用了二叉堆的性质来保持二叉树的平衡。

我们知道,一个二叉(大根)堆满足这样的性质:一个节点的两个儿子的值都小于节点本身的值。如果一个二叉查找树满足这样的性质,那么它就被称作Treap。

但是等等,这样的设定似乎和二叉查找树矛盾啊。一个要求节点值小于右儿子的值,一个要求节点值大于右儿子的值,这显然是不可能做到的。

只有一种方法能够解决,就是让每个节点有2个值,其中一个满足二叉查找树的性质,一个满足大根堆的性质。为方便起见,下面把满足二叉查找树性质的值称作key,把满足大根堆性质的值称作prio(priority的简称)。

每个节点的key我们是无法改变了,为了保证Treap的平衡性,我们需要在prio上做一点文章。其实也没有什么复杂的,就是让每个节点的prio都取一个随机值,这样我们就可以保证这棵树“基本平衡”。

Treap的实现

约定

为了方便叙述,我们做出以下的约定:

struct node{ //节点数据的结构
int key,prio,size; //size是指以这个节点为根的子树中节点的数量
node* ch[2]; //ch[0]指左儿子,ch[1]指右儿子
};

typedef node* tree;

node base[MAXN],nil;
tree top,null,root;

void init(){
top=base;
root=null=&nil;
null->ch[0]=null->ch[1]=null;
null->key=null->prio=2147483647;
null->size=0;
}

inline tree newnode(int k){ //注意这种分配内存的方法也就比赛的时候用用,仅仅是为了提高效率
top->key=k;
top->size=1;
top->prio=random();
top->ch[0]=top->ch[1]=null;
return top++;
}


旋转

事实上,想要一棵树恰巧满足以上的条件并不容易。绝大多数情况下,我们都需要通过旋转的方法来调整树的形态,使得它满足以上的条件。

旋转分左旋和右旋两种,他们都不破坏二叉查找树的性质。如图所示:



代码如下:

void rotate(tree &x,bool d){ //d指旋转的方向,0为左旋,1为右旋
4000

tree y=x->ch[!d];
x->ch[!d]=y->ch[d];
y->ch[d]=x;
x->size=x->ch[0]->size+1+x->ch[1]->size;
y->size=y->ch[0]->size+1+y->ch[1]->size;
x=y;
}


随机数的生成

不知你有没有想过,如果我们采用系统函数生成的随机数,会有出现重复的可能性。如果prio取到了重复的值,则必然会造成堆结构的混乱。

当然,我们有生成不重复的随机数的办法,代码如下:

inline int random(){
static int seed=703; //seed可以随便取
return seed=int(seed*48271LL%2147483647);
}


说实话我也不知道48271这个数字是哪里来的,不过百度告诉我这样可以取遍1-2147483647中的所有数字。

插入、删除和选择第k小项

插入和普通的二叉查找树差不多。但是要注意,插入有可能会破坏Treap的堆性质,所以要通过旋转来维护堆性质。下图就是一个例子:



删除就相对容易很多了,由于Treap满足堆性质,只需要将待删除的节点旋转到叶子节点再删除就可以了。

具体代码如下:

void insert(tree &t,int key){ //插入一个节点
if (t==null) t=newnode(key);
else{
bool d=key>t->key;
insert(t->ch[d],key);
t->size++;
if (t->prio<t->ch[d]->prio) rotate(t,!d);
}
}

void erase(tree &t,int key){ //删除一个节点
if (t->key!=key){
erase(t->ch[key>t->key],key);
t->size--;
}
else if (t->ch[0]==null) t=t->ch[1];
else if (t->ch[1]==null) t=t->ch[0];
else{
bool d=t->ch[0]->prio<t->ch[1]->prio;
rotate(t,d);
erase(t->ch[d],key);
}
}

tree select(int k){ //选择第k小节点
tree t=root;
for (int tmp;;){
tmp=t->ch[0]->size+1;
if (k==tmp) return t;
if (k>tmp){
k-=tmp;
t=t->ch[1];
}
else t=t->ch[0];
}
}


后记

代码来自某大牛学长,本人只做了一点点修改;

其实这篇文章只是个半成品,算了有时间再补充吧;

刚开始写,编辑器不熟练,所以有的地方可能排版会乱;

似乎有点罗嗦了,而且虎头蛇尾,希望以后写能好一点吧;

写起来真累……
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息