START
随机平衡二叉查找树 Treap
[注:本文大部分资料选自郭家宝 《随机平衡二叉查找树 Treap的分析与应用》,结合老师的通俗讲解及本人某些吐槽写就……尴尬]
一、 二叉查找树
1.概述:二叉查找树(Binary Search Tree)是基于插入思想的一种在线的排序数据结构,简称BST。这种数据结构的基本思想是在二叉树的基础上,规定一定的顺序,使数据可以有序地存储。二叉查找树运用了像二分查找一样的查找方式,并且基于链式结构存储,从而实现了高效的查找效率和完美的插入时间。
2.定义
二叉查找树是具有下列性质的二叉树:
(1)若它的左子树不空,则左子树上所有结点的值均小于它的根结点的值;
(2)若它的右子树不空,则右子树上所有结点的值均大于它的根结点的值;
(3)它的左、右子树也分别为二叉查找树。
以上性质被称作BST性质。
如图就是一棵二叉查找树:
3.遍历
根据BST性质,我们如果要从小到大输出所有结点的值,只需对二叉查找树进行中序遍历。
4.查找
比如说我们要查找一个值x。现在从根结点开始查找,对每棵树/子树,都进行如下操作:
(1)如果x小于根结点的值,则在左子树中继续递归查找x。
(2)如果x等于根结点的值,则查找成功
(3)如果x大于根结点的值,则在右子树中继续递归查找x
(4)如果当前结点为空,则退出递归,查找失败
期望时间复杂度:O(logN)
5.插入
比如说插入一个值x。现在从根结点开始递归,进行如下操作:
(1)如果x小于根结点的值,则在左子树中继续递归寻找位置
(2)如果x等于根结点的值,有两种方法可以处理。第一种是规定插入左子树。第二种更方便,是给每个结点增加一个域,用于记录这个值的个数。插入则个数++。(后文使用的是这种方法)
(3)如果x大于根结点的值,则在右子树中继续递归寻找位置
(4)如果当前结点为空,则在此建立新的结点,进行插入。
期望时间复杂度:O(logN)
6.删除
方法一、懒惰删除lazy deletion
直接将待删除结点的num--;
这样编码简洁明了,但是会造成树中有许多空的结点,造成空间的浪费、还影响效率。在时空复杂度要求不高的时候,可以使用这种删除方法。
方法二、
对于结点的删除,需要分情况考虑:
(1) 如果是叶节点,则直接删除
(2) 如果是链节点(只有一个非空子树),则用它的子节点代替它的位置。
(3) 如果有两棵非空子树,则为了维护BST性质,要用它的前驱或后继来代替它。所谓前驱,就是它左子树中的最大值。所谓后继,就是它右子树中的最小值。
方法二期望时间复杂度:O(logN)
7.二叉查找树的平衡性讨论
对待随机的数据,二叉查找树可以达到很好的平衡效果。
但是,对于有序的数据,二叉查找树的平衡性就会被打破了。如图所示,这棵树成了一条链,造成不管是插入、删除、查找,时间复杂度都是O(N)的,无疑会造成效率的低下。
只有维护二叉树的平衡性,才能保证O(NlogN)的期望时间复杂度,达到高效。
为了维护二叉查找树的平衡性,各种自动平衡二叉查找树应运而生,有AVL树、红黑树、Treap、SBT等等。下文要介绍的就是相对而言编程难度“易”的Treap。
二、 Treap(Tree+Heap)
1.定义
从本质上说,Treap也是一棵二叉查找树,时刻满足BST性质。只不过,在这其中加入了随机、旋转等操作之后,使得树能够拥有可观的平衡性。
2.原理阐述
Treap在BST的基础上,给每个结点增添了一个随机生成的修正值。任意时刻,修正值都必须满足最小堆/最大堆性质。为了使树同时满足BST性质和最小堆性质,要对树进行旋转操作。正是旋转,促使了Treap的平衡。
3. 如何旋转?
如下图所示,很显然,2和4的修正值不满足最小堆性质,因而要调整结构,进行旋转,使其满足最小堆性质。
[PS:右图在实际的Treap中是不成立的,仅作阐述旋转原理的例子]
此处定义两种旋转方式:左旋和右旋
(左旋一个子树,会把它的根节点旋转到根的左子树位置,同时根节点的右子节点成为子树的根;右旋一个子树,会把它的根节点旋转到根的右子树位置,同时根节点的左子节点成为子树的根)
左旋和右旋称呼并不重要,其实就是根据最小堆性质,在左子结点的修正值小于根结点的修正值时,要将左子结点旋到根的位置,对于右子结点也是同理。
旋转后的树,一定满足最小堆性质吗?当然是的。不难解释,就留给读者自己思考。[滑稽]
另外,在旋转时,还必须维护BST性质。如果不对子树的结构也作出调整,显然无法满足。怎么调整呢?
根据原图的BST性质,上图中3的大小是介于2和4之间的。那么为了维护性质,也将它放在一个大小介于2和4之间的位置上,这个位置,即是旋转之后,4的左子结点。
不妨多画几个图,就很好理解了:如果要把原根的左子节点变成新根,则要将(yg)左子节点的 右子树 变成 新的右子结点(即原根)的左子树;同理,如果要把原根的右子节点变成新根,则要将(yg)右子结点的 左子树 变成 新的左子结点(即原根)的右子树。
听起来有点绕,但是不难想通。归纳了一句话:左的右等于右的左,右的左等于左的右。【滑稽】
于是,上图就变成了下面这副模样:
在代码实现的时候,无需分类特殊处理,化异为同即可。
总而言之,旋转的意义在于:
它可以使不满足堆序的节点通过调整位置,重新满足堆序,而不改变BST性质。
4.遍历和查找
由于Treap满足BST性质,因而跟BST的操作相同。
5.插入
与BST的操作类似,不难找到此节点应放的位置。
如果树中有结点的值等于插入的值,那么直接num++。
否则,需要新建一个结点,同时给它赋予一个修正值。此时,一旦这个修正值打破了最小堆性质,就要进行旋转操作。将这棵子树旋转上去后,又可能破坏了上面的最小堆性质,那就旋转到满足最小堆性质为止。
下图是一个过程的模拟:
插入4:
左旋3:
对以5为根的子树进行右旋:
至此,树的修正值满足堆序,调整结束,插入完成。
6.删除
与BST相同,首先,我们可以找到待删除的结点。
然后,有三种办法可以进行删除:
(1)懒惰删除
(2)BST删除方法二
(3)上面的两种方法,都没有利用到Treap特有的随机性,修正值。第三种方法在Treap中更为通用。需要分情 况考虑:
情况一:如果待删除结点是叶子节点或链结点,就用它的子节点来代替它。
情况二:通过旋转的方法,将待删除结点旋转到一个可以直接删除的位置,再用情况一的方法直接删除它。
怎么旋转呢?
为了满足最小堆性质,我们要把待删除结点修正值较小的子节点旋转到待删除结点的位置。旋转方法如上文所述。
下面给出一个删除例子:需删除6。
4的修正值较小,于是右旋结点6
7的修正值较小,左旋结点6。
此时,6可以被直接删除,用子节点5来代替它。
三、 Treap与其它平衡树对比
这是论文中给出的对比表格,表示无语……Treap的编程难度不算易了吧……分分钟100+……可能因为我弱,100+都嫌多……【笑哭】
不过说实话,Treap确实是个很强大的东西,相对于线段树来说,它的时间复杂度差不多,而且占的空间更小,开N就够了。而且,能够实现很多线段树不能实现的功能……
本弱有N^2不能解决的问题,往往想要求助线段树。看来以后要换个宿主啦~
四、 Treap的其它功能
1.查找最值
如果要查找最小值,则不停地访问Treap的左子树,更新最优值。直到遇到空节点为止。
查找最大值也是同理。
2.查找前驱和后继
注意,此处的前驱和后继不同于前文。假设我们要查找x的前驱和后继,此处的前驱指的是在Treap中小于等于x的最大值,后继指的是在Treap中大于等于x的最小值。
解决这个问题,我们采用贪心逼近法。
(1)查找前驱
如果当前结点的值小于等于x,则更新最优值,并在右子树中继续查找。
如果当前结点的值大于x,则在左子树中继续查找。
如果当前结点为空,则查找结束。
(2)查找后继
如果当前结点的值大于等于x,则更新最优值,并在左子树中继续查找。
如果当前结点的值小于x,则在右子树中继续查找。
如果当前结点为空,则查找结束。
在信息学题目中,查找前驱、后继是经常要用到的。
3. Treap的排序问题
Treap 定义中“左小于中小于右”,仅仅是逻辑上的定义。实际上我们可以以任何有序的规则排序。只需修改定义,操作即可。
4.Treap的类型
Treap中并不仅限于存储int类型,可以比较大小的类型都可以存储于Treap中。
一般情况下,我们认为元素之间比较大小的时间复杂度是O(1)。但是有些类型进行比较时会耗费大量时间,无法承受。例如字符串,作为检索字符串的容器,更推荐 Trie,而不是平衡树。平衡树仅适合做元素间相互比较时间很少的类型的有序存储容器。
[关于Trie,本人还没有学过,留坑待补]
5.维护子树大小
在Treap中,我们称以一个子树的所有节点的权值(即num)之和,为子树的大小。如果想要查找元素的排名、或者排名为k的元素,就很有必要维护子树的大小了。
由于插入、删除都要进行旋转,因此要对子树的大小进行动态的维护。
每进行一次旋转,都要重新计算子节点和根结点的子树大小。
在插入时,新增结点的子树大小为1。递归返回时,要重新计算各节点的子树大小。
在删除时,将待删除结点旋转到可直接删除的位置之后,将它的子树大小置为0,递归返回时重新计算各节点的子树大小。
7.查询排名第k的元素
假设当前节点为P。我们对P进行分类讨论:
[P的左子树结点个数为P.leftsize(),右子树结点个数为P.rightsize(),P的权值num 为weight]
(1)如果k<P.leftsize(),目标元素一定在P的左子树中,且它在P的左子树中的排名也为k。
(2)如果k<= P.leftsize()+weight,则目标元素为P。
(3)如果k> P.leftsize()+weight,则目标元素在P的右子树中,且目标元素在P的右子树中的排名为k- P.leftsize()-weight。
以上步骤可以通过递归实现。不过,执行以上步骤有一个大前提——Treap的结点个数大于等于k。
时间复杂度:O(logN)
7.查询元素排名
它与查询排名为k的元素近似为互逆运算。
我们规定,如果在 Treap 照片那个有多个重复的元素,则这个元素的排名为最小的排名。
假设当前节点为P,cur表示目前搜寻到了cur个数,这cur个数都小于P。
(1)如果now< P.value,则在P的左子树中查找排名。
(2)如果now==P.value,则rank=P.leftsize()+1;
(3)如果now> P.value,则cur+=P.leftsize()+P.weight,在P的右子树中继续查找排名。
8.维护附加关键字。
像线段树那样,在平衡树中也可以维护区间最值,总和等附加关键字,应用尤为广泛。
8.习题应用
SMOJ 2165 Treap模板题
SMOJ 2166 数列的和
SMOJ 2167 郁闷的出纳员
SMOJ 2212 郁闷的小J
9.小感
Treap的代码实现中,最好要用到指针。作为一个不太会用指针的蒟蒻,写起代码来举步维艰……
要去好好参透一下……
10.关于代码实现
下面附SMOJ 2165的代码,是插入,查找排名k,以及删除操作的模板。
代码如下:
#include <cstdio>
#include <cstring>
#include <algorithm>
#include <cstdlib>
#include <ctime>
const int MAXN=1e6+5;
int n,x,ch;
struct Tnode
{
Tnode *son[2];
int tot,si;//tot为此点权值,si表示当前节点及其子树的权值和,即 当前子树大小。
int v,fix;//fix为修正值,v为此结点的具体值。
Tnode(int vv=0)//如果不传参数,默认为0;否则为传入值
{
son[0] = son[1] = 0;
v=vv;
}
void Update()//计算当前子树大小
{
si=tot;
if(son[0]) si+=son[0]->si;
if(son[1]) si+=son[1]->si;
}
}*r, Mem[MAXN], *cur;
Tnode *Newnode(int v = 0)
{
(*cur)=Tnode(v);
return cur ++;
}
void Rotate(Tnode *&x,int t)//传0,则代表要把son[0]即左子节点旋到当前节点上;传1则反之。
{
// 此处理解可带入0、1模拟一下
Tnode *y= x -> son[t];
x -> son[t]= y ->son[t^1];//右的左等于左的右,左的右等于右的左。
y -> son[t^1] = x;//新根的t^1子节点为原根
x -> Update();//重新计算子树大小。
y -> Update();
x = y;
}
void Insert(Tnode *&r,int x)
{
if(!r)//遇到空节点,x的归宿
{
r = Newnode(x);//新建节点
r -> tot= r -> si = 1;//节点权值、子树大小初始化为1
r -> fix= rand();//赋予一个随机值作为修正值
return;
}
if(x == r->v)//如果有值相同的结点,直接增加其权值、子树大小
{
( r -> tot)++;
( r -> si)++;
return;
}
int t= (x > ( r -> v ) );//如果x大于当前节点的值,则t=1,x应插入右子树;否则 t=0,x应插入 左子树
Insert( r -> son[t] , x);//继续递归查找位置 插入
if( r -> son[t] -> fix < r -> fix) Rotate(r,t);//如果修正值不满足堆序,则进行旋转。r为当前节点,t为用哪个子节点来代替根节点
r -> Update();//由于旋转之后,树的结构会发生变化,因此需重新计算子树的大小
}
int Ask(Tnode *&r,int k)//询问排名为k的元素是什么
{
if( !r ) return 0;//访问到空节点,则询问失败
// if( r -> si < k) return 0;//如果子树大小小于k,则无法查询,必须要返回
int lefts = ( r -> son[0] ) ? ( r -> son[0] -> si ): 0;//一定要先判断是否存在左子节点,不然,访问不存在的空间,会RE
if( k <= lefts) return Ask( r -> son[0], k );//如果k在右子树中,递归查找
if( k <= lefts + r -> tot) return r -> v;//如果k在[lefts+1,lefts+r->tot]范围内,则答案一定为 r->v
return Ask( r -> son[1], k - lefts - r -> tot);//否则,k在右子树中,且它在右子树中的排名为k - lefts - r -> tot
}
//此处为懒惰删除
/*void Delete(Tnode *&r,int x)
{
if(r->v==x)
{
r->tot--;
r->si--;
return;
}
int t= x > r->v;
Delete(r->son[t],x);
r -> Update();
}*/
void Delete(Tnode *&r, int x)//删除
{
if (x == r -> v) //如果找到待删除的值
{
if ( r -> son[0] && r -> son[1])//如果有左右子节点,则要旋转后删除
{
int t = ( r -> son[1] -> fix > r -> son[0] -> fix);//选取修正值较小的作为新根
Rotate(r, t^1);//将新根旋上来
Delete(r -> son[t], x);//继续递归,找底层位置
r -> Update();
}
else
if (! r -> son[0] && ! r -> son[1])//如果左右子节点都为空,即叶子节点,则直接删除它
{
if(r -> tot == 1) r = 0;
else
{
r -> tot--;
r -> si--;
}
}
else //如果为链节点,则要么tot-1,要么用它的子节点代替它。
{
if( r -> tot > 1)
{
r -> tot--;
r -> si--;
}
else if ( r -> son[0]) r = r -> son[0];
else r = r -> son[1];
}
return ;
}
Delete(r -> son[x > r->v], x);//继续查找待删除节点
r -> Update();
}
int main()
{
freopen("2165.in","r",stdin);
freopen("2165.out","w",stdout);
srand((int)time(0));
cur = Mem;
scanf("%d",&n);
r=0;
for(int i=1;i<=n;i++)
{
scanf("%d%d",&ch,&x);
if(ch==1)
Insert(r,x);
else
if(ch==2)
printf("%d\n",Ask(r,x));
else
Delete(r,x);
}
return 0;
}