替罪羊树——简单粗暴的数据结构

前言

       感谢 隐语的影法师 ,帮我找出了代码里的两个错误!真是让人没想到,之前的两个错误拼在一起居然过了模板题qwq

       2019.11.18 update:更新了一些奇怪的东西qwq……(就是修了点锅

正题

       这个名字取得比较玄乎,一眼看上去并不知道有什么卵用,但是, 如果你是刚学平衡树的新手,那么从替罪羊树开始学一定是个绝佳的选择,因为它是个很优雅的平衡树,什么叫优雅?暴力即是优雅!

       如果在一棵平衡的二叉搜索树内进行查询等操作,时间就可以稳定在log(n),但是每一次的插入节点和删除节点,都可能会使得这棵树不平衡,最坏情况就是退化成一条链,显然我们不想要这种树,于是各种维护的方法出现了,大部分的平衡树都是通过旋转来维护平衡的,但替罪羊树就很厉害了,一旦发现不平衡的子树,立马拍扁重建,这就是替罪羊树的核心:暴力重建

       先来说说我的替罪羊树上的每个节点包含些什么:

 

       zuo,you:记录该节点的左右儿子

       x:该节点的值

       tot:有多少个值为x的数

       size,trsize,whsize:size表示以该节点为根的子树内有多少个节点,trsize表示有多少个有效节点(这个后面再讲啦),whsize表示有多少个(也就是子树内所有节点的tot的和)

       fa:该点的父亲

       tf:该点是否有删除标记(这个也会在后面讲啦)

 

       那以洛谷上的模板题【模板】普通平衡树来作为例子吧!

       我们分开讨论每一种操作:

       操作1——加点:

       先找到一个特殊的节点,如果那个节点的值等于要加的那个点,那么直接让那个节点的tot+1即可,否则如果比那个节点的值要小,就让新加的节点成为它的左儿子,不然就是右儿子。

       那么怎么找那个“特殊的节点”呢?假如我以x为关键字去查找,先从根节点开始,假如x比根节点的值要小,那我就去它的左儿子那里,否则去右儿子,直到满足这两个条件中的一个:找到了值为x的节点或不能继续往下走。

       那么这个所谓的特殊的节点的性质也是很显然了,二叉搜索树是在维护一个有序的序列,这个特殊的节点其实就是与新加入的节点的值相同的节点或新加入的节点的前驱或后继。

       那找点和加点的代码如下:

       找点:

int find(int x,int now)//now表示当前找到哪个点
{
    if(xtree[now].x&&tree[now].you)return find(x,tree[now].you);
    return now;
}

       加点:

void add(int x)
{
    if(root==0)//假如当前没有根节点,也就是当前的树是空的,那么直接让他成为根
    {
        build(x,root=kk(),0);//新建节点(后面有讲)
        return;
    }
    int p=find(x,root);//找到特殊点
    if(x==tree[p].x)
    {
        tree[p].tot++;
        if(tree[p].tf)tree[p].tf=false,updata(p,1,0,1);
        else updata(p,0,0,1);
    }
    else if(x

       然后再加上里面用到的几个函数:

       新建节点:

void build(int x,int y,int fa)//初始化树上编号为y的节点,它的值为x,父亲为fa
{
    tree[y].zuo=tree[y].you=0;tree[y].fa=fa;tree[y].tf=false;
    tree[y].x=x;tree[y].tot=tree[y].size=tree[y].trsize=tree[y].whsize=1;
}

       updata函数,更新父亲以及爷爷以及祖先们的size,trsize还有whsize:

void updata(int x,int y,int z,int k)
{
    if(!x)return;//假如到头了就停止
    tree[x].trsize+=y;
    tree[x].size  +=z;//对齐qwq
    tree[x].whsize+=k;
    updata(tree[x].fa,y,z,k);
}

       kk函数就是个内存池,手写貌似快一点。

       操作2——删点:

       删点,严格来说是删掉一个数,假如我要删一个值为x的数,那就先找到值为x的节点,然后tot-1。

       没啦?当然不是,假如tot-1之后tot变成0了怎么办?这意味着这个节点不存在了,然后我们删掉它?假如把它删了,那它的左右儿子何去何从?所以我们不能动它,给它打个标记,标记这货被删除了,然后就行了。

       代码在此:

void del(int x)
{
    int p=find(x,root);
    tree[p].tot--;
    if(!tree[p].tot)tree[p].tf=true,updata(p,-1,0,-1);
    else updata(p,0,0,-1);
    find_rebuild(root,x);
}

       各位肯定敏锐的发现了这两个函数里都用到了一个函数,find_rebuild,回顾上面的内容,我提到过,每一次的加点和删点都有可能使这棵树不平衡,假如有一棵子树不平衡,我们就需要将其重建,所以,find_rebuild就是用来查找需要重建的子树。

       先说一下怎么重建吧。

       因为需要重建的子树必定是二叉搜索树,那么这棵子树的中序遍历一定是一个严格上升的序列,于是我们就先中序遍历一下,把树上的有效节点放到一个数组里面,注意无效节点(无效节点也就是被打了删除标记的点)不要,毕竟它名存实亡。

       然后我们再把数组中的节点重建成一棵极其平衡的完全二叉树(按完全二叉树的方法来建,但因为节点数的原因,不一定是一棵完全二叉树),具体方法就是每一次选取数组中间的节点,让它成为根,左边的当他左子树,右边的当他右子树,因为左边的都比他小,右边的都比他大,所以建出来的依然是一棵二叉搜索树。然后再对它的左右儿子进行相同操作即可。

       然后我们再讲一讲怎么找需要重建的子树。

       我们设每一次add或del的数为x,在将这个数加入到树中或从树中删除之后,假如在树中值为x的节点是y,那我们考虑到其实每一次可能需要重构的子树只会是以 根到y路径上的节点 为根的子树,那么我们就可以从根往y走一次,看看谁要重建就好了。还有个问题,为啥不从y往根走呢?打个比方,假如根到y路径上有两个点,a和b,并且a是b的祖先节点,然后特别巧的发现b和a都是需要重建的,那么,这时候我们只需要重建以a为根的子树,因为重建完之后,以b为根的子树其实也重建完了,但要是从y往根走呢?那么先会重建b,然后到a的时候还是要重建,那显然没有直接重建a要好,所以要从根往y走。

       最后一个小重点,怎么判断一棵替罪羊树是否平衡呢?(判断的方法不唯一,只要保持平衡即可,这里只是给出较一般的本人的做法)

       在替罪羊树中,定义了一个平衡因子α,α的范围因题而异,一般取0.5~1.0之间,若题目没有特殊说明,一般就取个中0.75就好了。那这个α有啥用呢?替罪羊树判断一棵子树是否平衡的方法是:如果 x的左(右)子树的节点数量 > 以x为根的子树的节点数量*α   ,那么,以x为根的这棵子树就是不平衡的。显然的,如果有一棵子树的大小超过了 以x为根的子树的节点数量*α,那么这种节点一边倒的情况对于查询来说肯定就很慢,所以,这个时候我们就将它重建。

       还有一种情况,我们提到过,替罪羊树的删除只是打个标记,那么,我们在查询的时候还是有可能经过打了删除标记的节点的,假如有删除标记的节点多了,那效率自然就会变得特别低,所以,我们需要再判断一下,假如在一棵子树中,有超过30%的点被删除了,那么就把这棵树重建。

       find_rebuild代码如下:

void find_rebuild(int now,int x)//now表示现在走到的节点,x表示要一直走到值为x的点
{
    if((double)tree[tree[now].zuo].size>(double)tree[now].size*alpha||
    (double)tree[tree[now].you].size>(double)tree[now].size*alpha||
    (double)tree[now].size-(double)tree[now].trsize>(double)tree[now].size*0.3){rebuild(now);return;}
    if(tree[now].x!=x)find_rebuild(x

       rebuild代码如下:

void rebuild(int x)//重建以x为根的子树
{
    tt=0;
    dfs_rebuild(x);//进行中序遍历并将有效节点压入数组
    if(x==root)root=readd(1,tt,0);//x就是根,那么root就变成重建之后的那棵树的根
    //readd用来把数组里的节点重新建成一棵完全二叉树,并返回这棵树的根
    else
    {
        updata(tree[x].fa,0,-(tree[x].size-tree[x].trsize),0);//因为拍扁重建后的树中,被打了删除标记的节点将消失
        //所以,要将祖先们的size进行更改,也就是减去被删去的节点
        if(tree[tree[x].fa].zuo==x)tree[tree[x].fa].zuo=readd(1,tt,tree[x].fa);
        else tree[tree[x].fa].you=readd(1,tt,tree[x].fa);
    }
}

       readd代码如下:

int readd(int l,int r,int fa)
{
    if(l>r)return 0;//没有点了
    int mid=(l+r)>>1;//选中间的点作为根
    int id=kk();
    tree[id].fa=fa;//更新各项
    tree[id].tot=shulie[mid].tot;
    tree[id].x=shulie[mid].x;
    tree[id].zuo=readd(l,mid-1,id);
    tree[id].you=readd(mid+1,r,id);
    tree[id].whsize=tree[tree[id].zuo].whsize+tree[tree[id].you].whsize+shulie[mid].tot;
    tree[id].size=tree[id].trsize=r-l+1;
    tree[id].tf=false;
    return id;//记得返回
}

       还有中序遍历dfs_rebuild的代码:

void dfs_rebuild(int x)
{
    if(x==0)return;
    dfs_rebuild(tree[x].zuo);//先去左儿子
    if(!tree[x].tf)shulie[++tt].x=tree[x].x,shulie[tt].tot=tree[x].tot;//假如没有删除标记,就只将他的x和tot加进数组,因为其他东西都没有用
    ck[++t]=x;//仓库,存下废弃的节点
    dfs_rebuild(tree[x].you);//再去右儿子
}

       最后还有之前用了好多次的kk函数:

int kk()
{
    if(t>0)return ck[t--];//假如仓库内有点,就直接用
    else return ++len;//否则再创造一个点
}

       然后……就剩下几个基本操作啦!

       操作3——查找x的排名:

       我们只需要像find函数一样走一遍就好了,在走的时候,如果是往右儿子走,就让ans加上左子树的的个数,再加上当前节点的tot,因为x一定比他们都大,否则就往左儿子走,当走到值为x的点时就结束。

       代码也肯定很简单的啦!

void findxpm(int x)//这么简单应该也不用什么注释了吧(其实就是比较懒)
{
    int now=root;
    int ans=0;
    while(tree[now].x!=x)
    {
        if(x

       操作4——查找排名为x的数:

       类似的,先从根走起,假如当前节点的左子树的的个数比x要小,那么让x减掉左子树的数的个数,然后在看一下当前节点的tot是否大于x,是的话答案就是这个节点了,否则让x减去它的tot,然后往右儿子那里跑,重复以上操作即可。

       代码依然是那么简单。

void findpmx(int x)
{
    int now=root;
    while(1)
    {
        if(x<=tree[tree[now].zuo].whsize)now=tree[now].zuo;
        else
        {
            x-=tree[tree[now].zuo].whsize;
            if(x<=tree[now].tot)
            {
                printf("%d\n",tree[now].x);
                return;
            }
            x-=tree[now].tot;
            now=tree[now].you;
        }
    }
}

       要注意!这两个函数里用的都是whsize!

       操作5——查找x的前驱

       因为替罪羊树中有删除标记这个东西,所以它的查找前驱会慢一点。

       具体做法:先找到值为x的节点,然后看看有没有左儿子,如果有,就将左子树遍历一遍,顺序是:右儿子->根->左儿子,找到的第一个没有被删除的节点就是答案。

       因为被删除节点数不会超过40%,所以不用担心算法会退化成O(n)的。

       代码在此:

void dfs_rml(int x)
{
    if(tree[x].you!=0)dfs_rml(tree[x].you);
    if(ans)return;
    if(!tree[x].tf)
    {
        printf("%d\n",tree[x].x);
        ans=true;return;
    }
    if(tree[x].zuo!=0)dfs_rml(tree[x].zuo);
}
void pre(int now,int x,bool zy)
{
    if(!zy)//如果是从左儿子处来的,那么直接去父亲那里就好了
    {
    	pre(tree[now].fa,x,tree[tree[now].fa].you==now);
    	return;
    }
    if(!tree[now].tf&&tree[now].x

       操作6——查找x的后继

       类似操作5,代码基本一样。

void dfs_lmr(int x)
{
    if(tree[x].zuo!=0)dfs_lmr(tree[x].zuo);
    if(ans)return;
    if(!tree[x].tf)
    {
        printf("%d\n",tree[x].x);
        ans=true;return;
    }
    if(tree[x].you!=0)dfs_lmr(tree[x].you);
}
void nxt(int now,int x,bool zy)
{
    if(!zy)
    {
    	nxt(tree[now].fa,x,tree[tree[now].fa].you!=now);
    	return;
    }
    if(!tree[now].tf&&tree[now].x>x)
    {
    	printf("%d\n",tree[now].x);
    	return;
    }
    if(tree[now].you)
    {
    	ans=false;
        dfs_lmr(tree[now].you);
        return;
    }
    nxt(tree[now].fa,x,tree[tree[now].fa].you!=now);
}

完整代码如下:

#include 
#include 
#include 
 
struct node{int zuo,you,x,tot,size,trsize,whsize,fa;bool tf;};
node tree[1000010];
int len=0,n,root=0;
int ck[1000010],t=0;
double alpha=0.75;
void build(int x,int y,int fa)
{
    tree[y].zuo=tree[y].you=0;tree[y].fa=fa;tree[y].tf=false;
    tree[y].x=x;tree[y].tot=tree[y].size=tree[y].trsize=tree[y].whsize=1;
}
inline int kk()
{
    if(t>0)return ck[t--];
    else return ++len;
}
void updata(int x,int y,int z,int k)
{
    if(!x)return;
    tree[x].trsize+=y;
    tree[x].size  +=z;
    tree[x].whsize+=k;
    updata(tree[x].fa,y,z,k);
}
int find(int x,int now)
{
    if(xtree[now].x&&tree[now].you)return find(x,tree[now].you);
    return now;
}
struct sl{int x,tot;}shulie[1000010];
int tt;
void dfs_rebuild(int x)
{
    if(x==0)return;
    dfs_rebuild(tree[x].zuo);
    if(!tree[x].tf)shulie[++tt].x=tree[x].x,shulie[tt].tot=tree[x].tot;
    ck[++t]=x;
    dfs_rebuild(tree[x].you);
}
int readd(int l,int r,int fa)
{
    if(l>r)return 0;
    int mid=(l+r)>>1;int id=kk();
    tree[id].fa=fa;
    tree[id].tot=shulie[mid].tot;
    tree[id].x=shulie[mid].x;
    tree[id].zuo=readd(l,mid-1,id);
    tree[id].you=readd(mid+1,r,id);
    tree[id].whsize=tree[tree[id].zuo].whsize+tree[tree[id].you].whsize+shulie[mid].tot;
    tree[id].size=tree[id].trsize=r-l+1;
    tree[id].tf=false;
    return id;
}
void rebuild(int x)
{
    tt=0;
    dfs_rebuild(x);
    if(x==root)root=readd(1,tt,0);
    else
    {
        updata(tree[x].fa,0,-tree[x].size+tree[x].trsize,0);
        if(tree[tree[x].fa].zuo==x)tree[tree[x].fa].zuo=readd(1,tt,tree[x].fa);
        else tree[tree[x].fa].you=readd(1,tt,tree[x].fa);
    }
}
void find_rebuild(int now,int x)
{
    if((double)tree[tree[now].zuo].size>(double)tree[now].size*alpha||
    (double)tree[tree[now].you].size>(double)tree[now].size*alpha||
    (double)tree[now].size-(double)tree[now].trsize>(double)tree[now].size*0.4){rebuild(now);return;}
    if(tree[now].x!=x)find_rebuild(xx)
    {
    	printf("%d\n",tree[now].x);
    	return;
    }
    if(tree[now].you)//假如有右儿子的话,进来必定能够找到一个解,所以可以对now进行随意的改动 
    {
    	ans=false;
        dfs_lmr(tree[now].you);
        return;
    }
    nxt(tree[now].fa,x,tree[tree[now].fa].you!=now);
}

int main()
{
    scanf("%d",&n);
    while(n--)
    {
        int id,x;
        scanf("%d %d",&id,&x);
        if(id==1)add(x);
        if(id==2)del(x);
        if(id==3)findxpm(x);
        if(id==4)findpmx(x);
        if(id==5)pre(find(x,root),x,true);
        if(id==6)nxt(find(x,root),x,true);
    }
}

       产品售后服务:

       1、关于α

       不知各位有木有想过,α的值究竟与效率有什么关系呢?仔细想想,当α的值越小,那么替罪羊树就越容易重构,那么树也就越平衡,查询的效率也就越高,自然修改(加点和删点)的效率也就低了,所以,如果查询操作比较多的话,就可以将α的值设小一点,反之,假如修改操作多,自然α的值就要大一点了。

       还有,α不能等于1 or 0.5,假如它等于0.5,那么当一棵树被重构之后如果因为节点数问题,不能完全重构成一个完全二叉树,那么显然,对于这棵树的根,他的 | 左子树节点数量 - 右子树节点数量 | 很可能会等于1,那么如果往多的那棵子树上加一个节点,那么这棵树又得重构一次,最坏情况时间会变成n^2,显然死定了。。那么等于1会怎么样?还能怎么样!你觉得会有一棵子树的大小大于整棵树的大小吗?!

       2、关于时间复杂度

       除了重构操作,其他操作的时间复杂度显然都是log(n)的,那么下面看一下重构的时间复杂度。

       虽然重构一次的时间复杂度是O(n)的,但是,均摊下来其实只是O(logn)。

       考虑极端情况,每次都把整棵树重构。

       那么我们就需要每次都往根的一棵子树内加点,假设一开始是平衡的,那么左右子树各有50%的节点,那么要使一棵子树内含有超过75%的节点,那么这棵子树就需要在原来的基础上增加2倍的节点数。也就是说,当最差情况时,整棵替罪羊树的节点数要翻个倍,才会重构。那么最差情况时也就是在4,8,16,32……个节点时才会重构,于是重构的总的时间复杂度也就是O(nlogn)了,加上一些杂七杂八的重构,也不过就是加上一个很小的常数,可以省略不计。所以,替罪羊树的时间复杂度依然是O(nlogn)的。(感谢评论指出蒟蒻笔者忘写时间复杂度qwq)

 

       售后服务完毕,感谢阅读!

你可能感兴趣的:(#,平衡树)