平衡树中的神兵利器——非旋Treap(树堆)学习小记

前言

  之前A组有一道题目,要你维护一个字符串的插一个字符、删一段字符、复制并粘贴一段字符、翻转一段字符、查询一个字符。我当然想到了splay。但是看了眼数据范围:字符串的总长度不超过 2311 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也是可以的。我写了篇博客,读者不妨去了解一下

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值