平衡树

平衡树

用不同平衡树解决模板题

Treap

简介

在维持二叉查找树性质的基础上,通过改变二叉查找树的形态,使得树上每个节点的左右子树大小达到平衡。这样能使整棵树的深度维持在\(O(logn)\)级别。

变量名

  • \(l,r\) 左右子节点在数组中的下标
  • \(val\) 真实权值
  • \(dat\) 随机权值
  • \(cnt\) 权值次数(即已经存在的的数值总共出现的次数,可以理解为副本数。这样在插入时就可以直接在原权值的基础上计数cnt加1,避免了无意义重复插入;而在删除时直接将cnt-1,当cnt减到0的时候删除该节点。增加一个cnt域的作用主要体现在可以比较容易地处理重复权值的问题)
  • \(sz\) 子树大小
struct Treap {
    int l,r,val,dat,cnt,sz;
}a[maxn];

基本操作

Treap的核心操作:旋转

旋转又分为左旋和右旋。看张图感性理解:

  1. 左旋

    \(zag(x)\)\(x\)的右子节点绕着\(x\)向左旋转。结合上图可以以模拟的想法写出代码:

    inline void zag(int &x) {
        int y=a[x].r;
        a[x].r=a[y].l;
        a[y].l=x;
        x=y; //注意此处的x是引用,因为要使原来的x的值跟着改变。
    }
    
  2. 右旋

    \(zig(y)\)\(y\)的左子节点绕着\(y\)向右旋转。

    inline void zig(int &y) {
        int x=a[y].l;
        a[y].l=a[x].r;
        a[x].r=y;
        y=x;
    }
    

总结一点小规律:是?旋,就是把指定节点的?的反方向的子树朝?旋转。

插入

Treap,顾名思义,有着Heap(堆)的性质,这一性质的维护体现在它的随机操作上。

在插入每个新节点时,给该节点随机生成一个额外的权值。然后从下而上依次检查,当某个节点不满足大根堆的性质时,就执行单旋操作,使其与其父节点的相对关系对换。细节看代码:

inline void Getnew(int val) { //得到一个新节点,直接插入在数组尾端。val为原来的真实权值。
    a[++tot].val=val;
    a[tot].dat=rand();
    a[tot].cnt=a[tot].sz=1;
    return tot;
}
inline void Update(int p) { //更新子树大小。
    a[p].sz=a[a[p].l].sz+a[a[p].r].sz+a[p].cnt;
}
inline void Insert(int &p,int val) {
    if (!p) {
        p=Getnew(val);
        return;
    }
    if (val==a[p].val) {
        ++a[p].cnt; //即上文提到过的cnt域的作用,已经出现过的重复权值计数+1。
        Update(p);
        return;
    }
    if (val<a[p].val) {
        Insert(a[p].l,val);
        if (a[p].dat<a[a[p].l].dat) zig(p); //不满足堆性质,左子节点随机权值大于父亲随机权值,执行右旋操作,把左子节点转到原来父亲的位置来。
    }
    else {
        Insert(a[p].r,val);
        if (a[p].dat<a[a[p].r].dat) zag(p); //同上,反之。
    }
    Update(p); //最后别忘记还要来一次更新。
}
删除

旋转操作的方便之处或许也在删除这个操作上体现得淋漓尽致。对于指定要删除的节点,直接把它一路向下旋转成叶节点,最后直接从数组中删去即可。

inline void Delete(int &p,int val) {
    if (!p) return;
    if (val==a[p].val) { //检索到val。
        if (a[p].cnt>1) {
            --a[p].cnt; //也是上文提到过的cnt域的作用,如果这个权值的计数仍旧大于1,说明这个权值为此数值的元素个数大于1,直接减去一个计数即可。
            Update(p);
            return;
        }
        if (a[p].l || a[p].r) { //不是叶节点,向下旋转。
            if (a[p].r==0 || a[a[p].l].dat>a[a[p].r].dat) //当右子树为空或左子节点的随机权值大于右子节点,执行右旋操作,将原指定删除的节点转到其右节点处,再删除其即可。
                zig(p), Delete(a[p].r,val);
            else 
                zag(p), Delete(a[p].l,val); //同上,反之。
            Update(p);
        }
        else p=0; //直接删除叶子节点
        return;
    }
    val<a[p].val?Delete(a[p].l,val):Delete(a[p].r,val);//未检索到val,继续查找。
    Update(p);
}

P3369模板 Treap做法

Splay

简介

二叉查找树的一种。最大的特点是旋转,通过不断将某个节点旋转到根节点,来使整棵树始终保持二叉查找树的性质,此之谓保持平衡。

变量名:

  • \(rt\) 根节点
  • \(tot\) 节点总个数
  • \(fa[i]\) 节点\(i\)的父亲节点
  • \(ch[i][0/1]\) 节点\(i\)的左/右儿子
  • \(val[i]\) 节点\(i\)的权值
  • \(cnt[i]\) 节点\(i\)的权值次数
  • \(sz[i]\) 以节点\(i\)为根的子树大小

基本操作

判断是左儿子还是右儿子

0为左儿子,1为右儿子。

#define get(p) (p==ch[fa[p][1]])
核心操作:旋转

将p旋转上移一个位置。

inline void rot(int p) {
    int x=fa[p],y=fa[x],u=get(p),v=get(x);
    fa[ch[p][u^1]]=x;  ch[x][u]=ch[p][u^1]; //举个例子,若指定节点p是其父亲的右儿子,则把p的左儿子的父亲赋为p的父亲,与此同时,p父亲的右儿子赋为p原来的左儿子(其实这是不是很像Treap的左旋啊,相当于对p的父亲进行左旋操作)。左右反之亦然(不管是哪个方向旋转,都是把当前节点向上旋转罢了)。
    fa[x]=p; ch[p][u^1]=x; upd(x); upd(p);
    fa[p]=y; if (fa[p]) ch[y][v]=p; //将p的父亲赋为p原来的父亲的父亲(就是说它的爷爷(雾),然后把它的爷爷的孩子设成它就完事了。
}

其实代码真的很简短啊,如神的异或操作远远胜过我匮乏的语言与其苍白无力的诠释。我的意思是好背就行。

让我们来一张图感受一下以上代码中以p是其父亲右儿子的举例:

灵活运用rot的splay操作

顾名思义,splay是splay最重要的操作(废话)。

将p旋转为g的儿子(若g为0则旋转为根)。

inline void Splay(int p,int g) {
    while (fa[p]!=g) {
        int x=fa[p],y=fa[x];
        if (y==g) rot(p); //若p的爷爷就是目标g,单旋即可。具体可看上图。
        else rot(get(p)==get(x)?x:p),rot(p); //对于父子共线的情况,即都是在一个方向上的,先将父亲旋转,再将自身旋转;若不共线,将自身旋转两次即可。具体看下图。
    }
    if (!g) rt=p;
}

不共线情况:

共线情况:

删除

这个操作比较特别且富有Splay的特色,很巧妙。

首先把要删除的点通过Splay操作搞到根节点。这个可以借用查询排名函数,也就是可以把第一个大于等于当前点的数弄到根节点去。

若这个节点的计数大于1,把计数并子树大小减去1即可。

若它不存在子节点,直接删除即可。

若有一个子树为空,让那个不为空的子节点成为新的根,然后删去指定节点即可。

如果以上这些特殊情况都不满足,启用PlanB!借用查询前驱函数,把第一个小于等于v的数搞到根节点去。动用您优秀的想象能力,显然,当前节点就变成了根的右儿子,且当前节点一定没有左儿子。为什么?因为比它小的数都是根节点及其左子树。那么就可以直接删去了。再把删去的节点的右儿子赋为根的右儿子即可,容易发现这样完全不会破坏二叉查找树的性质。

以上都是废话,细节看代码:

inline void Delete(int v) {
	GetRank(v); //借用查询排名函数,将第一个大于等于v的数splay到根节点。
	if (v!=val[rt]) return; //v不存在 
	if (cnt[rt]>1) {--cnt[rt]; --sz[rt]; return;}
	int p=rt;
	if (!ch[p][0] && !ch[p][1]) {rt=0; clr(p); return;} //不存在子节点 
	if (!ch[p][0] || !ch[p][1]) {rt=ch[p][0]+ch[p][1]; fa[rt]=0; clr(p); return;}
	GetPre(v); //借用查询前驱函数,把第一个小于等于v的数splay到根节点,那么当前要删除的数v必然是根的右子节点,且v一定没有左子节点。这是我们直接删除p,再把p原来的右子节点赋给根的右子节点。 
	fa[ch[p][1]]=rt; ch[rt][1]=ch[p][1]; clr(p); --sz[rt]; return; 
}
其他操作

大体上没什么区别,细节还是挺需要注意的。

一点不同:所有操作结束后,需要把当前操作的点splay到根节点。

P3369模板 Splay做法

应用

P3391文艺平衡树

Splay技能:实现区间翻转。

代码

FHQ Treap

简介

无旋式Treap。顾名思义,不需要旋转操作,就可以使Treap达到平衡。可以做到任何Splay和普通Treap能做的操作。

变量名

同一般Treap。

基本操作

不得不提使FHQ区别于其他平衡树的维护操作:分裂与合并。

分裂

顾名思义,把一颗Treap分裂成两颗,此过程用递归实现。

分裂还要分为两种,是按什么来分裂?

按权值分裂

权值小于等于v的节点分到左树x,并在左树中对右儿子建立虚拟节点且继续分裂右子树;大于v的分到右树y,并在右树中对左儿子建立虚拟节点且继续分裂左子树。

#define upd(p) a[p].sz=a[a[p].l].sz+a[a[p].r].sz+1
inline void split(int p,int v,int &x,int &y) {
	if (!p) {x=y=0; return;}
	if (a[p].val<=v) x=p, split(a[p].r,v,a[p].r,y);
	else y=p,split(a[p].l,v,x,a[p].l);
	upd(p);
}
按排名分裂

排名<=k的分到左树x,大于k的分到右树y。

inline void split(int p,int k,int &x,int &y) {
    if (!p) {x=y=0; return;}
    if (sz[a[p].l]+1<=k) x=p,split(a[p].r,k-sz[a[p].l],a[p].r,y);
    else y=p,split(a[p].l,k,x,a[p].l);
}
合并

依旧顾名思义,把两颗Treap合成一个,也是递归实现。

过程中以随机权值做条件来判断是左树还是右树。返回合并后的根。

注:我们默认左树x的权值都小于右树y。

inline void split(int p,int k,int &x,int &y) {
    if (!p) {x=y=0; return;}
    if (sz[a[p].l]+1<=k) x=p,split(a[p].r,k-sz[a[p].l],a[p].r,y);
    else y=p,split(a[p].l,k,x,a[p].l);
}
其他操作

比较平常,但是不得不说FHQ是最好写的一种平衡树了。从码量上来看。

P3369模板 FHQ解法

参考资料

  1. 平衡树学习笔记 by xht37
  2. 学习笔记:平衡树 by cyh_toby
  3. 《算法竞赛进阶指南》
posted @ 2021-11-10 22:57  Carlotta24  阅读(153)  评论(0)    收藏  举报