平衡树之Treap—强大的数据结构

Treap介绍

Treap ,是平衡树的分支之一,故也支持旋转操作,在数据结构中也称树堆,之所以叫树堆,是因为 Treap = Tree (树)+ Heap (堆)。其基本操作的期望时间复杂度为 O (log n )。相对于其他的平衡二叉搜索树,Treap的特点是实现简单,且能基本实现随机平衡的结构。

正题

Treap 是一棵二叉排序树,它的左子树和右子树分别是一个 Treap ,和一般的二叉排序树不同的是, Treap 记录一个额外的数据,就是优先级。
Treap 里面的每一个元素都有一个随机的数值作为优先级,如果就优先级来看, Treap 将会满足堆的性质(大根堆或小根堆)。由于优先级是随机出来的,因此期望树高为 log n

构建

构建一棵Treap,有两种方法。
第一种是较为简单,将数按顺序加入,并给加入的元素附上一个随机优先级,在对元素进行插入操作即可。时间复杂度 O (n log n )。
第二种就是直接维护一个栈。考虑到优先级会随着深度的增加而减小,因此可以维护一个栈来完成Treap的构建,一个元素入栈一次退栈一次,故时间复杂度是 O (n)的,在这里就不细讲了。

如果实在不会第二种用第一种也无妨,毕竟要维护一棵 Treap 就意味着会有询问或修改操作,单次操作的期望复杂度是 O (log n )的,因而总体来说总时间复杂度并不会有很大区别。

插入

首先先按照二叉搜索树的插入一样将元素插入到一个叶节点上,然后就从下往上维护堆的性质,利用旋转操作,如果儿子节点的优先级比父亲优的话就把儿子节点旋转至他父亲的位置。因为期望树高是log n 的,所以期望时间复杂度O( log n )。
附上代码。

Code:

//tree[x].size是指以x为根的子树大小
//tree[x].v是指以x节点的实际数值
//tree[x].l和tree[x].r分别是指x的左儿子和右儿子
//tree[x].rnd是指x的随机优先级
//leftturn是左旋操作,rightturn是右旋操作
//接下来的都一样,就不加注释了
void insert(int &k,int x)//此Treap为小根堆
{
    if(!k)
    {
        k=++size;
        tree[k].size=1;tree[k].v=x;tree[k].rnd=rand();
        return;
    }
    tree[k].size++;
    else if(x>tree[k].v)
    {
        insert(tree[k].r,x);
        if(tree[tree[k].r].rnd<tree[k].rnd)leftturn(k);//维护堆性质
    }
    else 
    {
        insert(tree[k].l,x);
        if(tree[tree[k].l].rnd<tree[k].rnd)rightturn(k);
    } 
}

删除

先找到要删除的那个节点,然后把该节点优先级较大的儿子旋转到自己的位置,直到该节点选转到了叶节点的位置,便可以直接删除了。期望时间复杂度O( log n )。
附上代码。

Code:

void del(int &k,int x)
{
    if(!k)return; 
    if(tree[k].v==x)
    {
        if(tree[k].l*tree[k].r==0)k=tree[k].l^tree[k].r;//有一个儿子为空
        else if(tree[tree[k].l].rnd<tree[tree[k].r].rnd)
            rightturn(k),del(k,x);
        else leftturn(k),del(k,x);
    }
    else if(x>tree[k].v)
        tree[k].size--,del(tree[k].r,x);
    else tree[k].size--,del(tree[k].l,x);
}

查找

查找和一般的二叉排序树一样,但是由于Treap的随机化结构, Treap 中查找的期望复杂度是 O (log n )。

区别

Treap比其他的平衡树更易实现,细节更少,易调试,效率上来说也不比其他的平衡树差。

其次就是 Treap 的结构不是固定的,而像 splay 等平衡树的结构在数据相同的情况下结构的变化都是相同的,而 Treap 对于相同的数据结构不太可能相同,因此如果存在能够卡掉像 splay 等平衡树的情况, Treap 则不会因此而被卡掉,虽然目前为止我也没有见过这种情况。

再然后, splay 的时间复杂度是 均摊 log n 的,而Treap的时间复杂度是期望 log n ,这之间还是有挺大的区别的。

总而言之,Treap与其他平衡树区别的本质在于 Treap 加入了随机化。

可持久化Treap

如果你认真研究 Treap ,你就会发现普通的 Treap 大多数时候都不能维护别的平衡树能够维护的操作,例如区间翻转操作,因而就出现了可持久化 Treap
可持久化 Treap 有两个基本操作——分离( split )和合并( merge ),在这两个操作的基础上 Treap 就能维护更多其他的操作。

分离(split)

分离操作会将一棵 Treap 分离成两棵 Treap ,一般为将一棵大小的 size Treap 分成前 k 个和后size- k 个,分为两棵Treap,因而 split 函数返回值类型为 pair
具体实现如下,每次要从以 o 为根的Treap分成前 k 个和后tree[ o ].size- k 个,先分类讨论,判断分离位置位于左子树还是右子树,递归进行,直至k 0 。期望复杂度为树高,即O( log n )。

看看代码理解会更好。

Code:

//change(o)是指重新计算节点o的信息(更新节点o的信息)
//down(o)是指将o的标记下传至儿子节点
//下面merge也是一样

#define fi first
#define se second
typedef pair<int,int> P;

P split(int o,int k)
{
    if(!k)return P(0,o);
    down(o);
    if(size[tree[o].l]>=k){
        P ls=split(tree[o].l,k);
        tree[o].l=ls.se;
        change(o);
        return P(ls.fi,o);
    }
        P ls=split(tree[o].r,k-tree[tree[o].l].size-1);
        tree[o].r=ls.fi;
        change(o);
        return P(o,ls.se);
}

合并(merge)

函数merge有两个自变量,分别为需要被合并的两棵 Treap 的根节点,返回值 x 表示当前合并完的Treap的根节点编号。合并过程也不难,每次合并从两个根中选择优先级较大的节点作为新的根节点,这时问题就转换为另外两棵 Treap 合并的问题了。
期望复杂度为树高,即 O (log n )。

还是看看代码好,一开始我都是看代码看会的,很好理解,合并的代码如下

Code:

int merge(int a,int b)
{
    if((!a)||(!b))return a^b;
    down(a); 
    down(b);
    if(tree[a].rnd>tree[b].rnd){
        tree[a].r=merge(tree[a].r,b);
        change(a);  
        return a;
    }
    tree[b].l=merge(a,tree[b].l);
    change(b);
    return b;
}

可持久化Treap的其他操作

merge split 操作可以拓展到别的操作。

注释:接下来的 root 都是整个序列对应的 Treap 根节点,也代表着整棵 Treap

插入

对于插入操作,例如我要在 root 的第 k 个位置(即在前k个数之后)插入一个数 x ,那就先split( root , k ),分成first second 两棵 Treap ,然后先合并 first x 成为新的Treap,记为 root ,再合并 root second ,得到的新的 Treap 即为完成了插入操作后的 Treap

还是那句话,看看标程帮助理解。

Code:
#define fi first
#define se second
typedef pair<int,int> P;

int insert(int root,int x,int k)
{
    P ls=split(root,k);
    tree[++size]=x;
    tree[size].rnd=rand()%123456789;
    tree[size].size=1;
    root=merge(ls.fi,size);
    root=merge(root,ls.se);
    return root;
}
删除

对于删除操作,例如我要删除 root 的第 k 个位置上的数,那就先split( root , k ),得到first second 两棵子树分别对应原序列的 1 ~k位置上的数和 k +1~ size 位置上的数,再对 first split 操作,分离出 1 ~k- 1 k,最后将 1 ~k- 1 second两棵 Treap 合并便删除了第 k 个数。

看看代码。

Code:

#define fi first
#define se second
typedef pair<int,int> P;

int del(int root,int k)
{
    P ls=split(root,k);
    P s=split(ls.fi,k-1);
    root=merge(s.fi,ls.se);
    return root;
}

区间修改和询问

有了split merge 操作,区间修改也不难。
利用 split 操作,将整棵 Treap 分成三棵 Treap ,分别为 1 ~l- 1 l~ r r+ 1 ~size,然后将中间的那段 l ~r区间对应的 Treap 打上一个标记,再从前往后合并三棵 Treap 即可。

询问也差不多是这样吧,都是分成三棵子树。

这个就没有必要写什么代码了,自己理解就好。

可持久化操作

仔细想一下,对于每次操作,都是由多个的 split merge 组成,然而对于每一个 split merge 操作,每一次改变的点数(信息发生变化的点的个数)期望为 log n 个,故当然可以持久化。

总结

相对于普通的Treap,可持久化 Treap 增加了可持久化操作和区间修改询问操作,却少了平衡树基本的旋转操作。
可持久化 Treap 大部分时候都可以取代 splay ,相比 splay 又更容易实现和调试,除了不能用于 LCT ,但陈 bg 大神说他找到用可持久化 Treap 维护 LCT 的方法,具体情况我也不是很清楚。

除此之外,可持久化 Treap 还能做到别的平衡树做不到的操作,也就是说有些题只能用可持久化 Treap 来做,这种题一般一定要用到 split 来完成一些奇怪的操作。

如果有什么错误或可以改进的地方,还请各位大佬指出,谢谢。

就这么多了,希望能够帮助到大家。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值