0x46 二叉查找树与平衡树初步

0x46 二叉查找树与平衡树初步

在二叉树中,有两组非常重要的条件,分别是两类数据结构的基础性质。其一是“堆性质”,我们曾在0x17节中提及。二叉堆以及高级数据结构中的所有可合并堆,都满足“堆性质”。其二就是本节即将探讨的“BST结构”,它是二叉查找树(Binary Search Tree)以及所有平衡树的基础。

1.BST

给定一棵二叉树,树上的每个节点都带有一个数值,称为节点的“关键码”。所谓“BST性质”是指,对于树上的任意一个节点:

1.该节点的关键码不小于它的左子树的任意节点的关键码;

2.该节点的关键码不大于它的右子树的任意节点的关键码。

满足上述性质的二叉树就是一颗“二叉查找树”(BST。显然,二叉查找树的中序遍历是一个关键码单调递增的节点序列。

BST的建立

为了避免越界,减少边界情况的特殊判断,我们一般在BST中额外插入一个关键码为正无穷(一个很大的整数)和一个关键码为负无穷的节点。仅由这两个节点构成的BST就是一棵初始的空BST。如图所示。

在这里插入图片描述

简便起见,在接下来操作中,我们假设BST不会含有关键码相同的节点

struct BST{
    int l,r;//左右子节点在数组中的下标
    int val;//节点关键码
}a[SIZE];//数组模拟链表
int tot,root,INF=1<<30;

int New(int val)
{
    a[++tot].val=val;
    return tot;
}
void Build()
{
  	New(-INF),New(INF);
    root=1,a[1].r=2;
}

BST的检索

BST中检索是否存在关键码为 v a l val val的节点。

设变量 p p p等于根节点 r o o t root root,执行以下过程:

1.若 p p p的关键码等于 v a l val val,则已经找到。

2.若 p p p的关键码大于 v a l val val

(1)若 p p p的左子节点为空,则说明不存在 v a l val val

(2)若 p p p的左子节点不为空,在 p p p的左子树中递归进行检索。

3.若 p p p的关键码小于 v a l val val

(1)若 p p p的右子节点为空,则说明不存在 v a l val val

(2)若 p p p的右子节点不为空,在 p p p的右子树中递归进行检索。

在这里插入图片描述

int Get(int p,int val)
{
    if(p==0) return 0; //检索失败
    if(val==a[p].val) return p; //检索成功
    return val<a[p].val?Get(a[p].l,val):Get(a[p].r,val);
}

BST的插入

BST中插入一个新的值 v a l val val(假设目前BST中不存在关键码为 v a l val val的节点)。

BST的检索过程类似。

在发现要走向的 p p p的子节点为空,说明 v a l val val不存在时,直接建立关键码为 v a l val val的新节点作为 p p p的子节点。

在这里插入图片描述

void Insert(int &p,int val)
{
    if(p==0)
    {
        p=New(val); //注意p是引用,其父节点的l或r值会被同时更新
        return;
    }
    if(val==a[p].val) return; //原本BST中已经含有这个关键码
    if(val<a[p].val) Insert(a[p].l,val);
    else Insert(a[p].r,val);
}

BST求前驱/后继

以“后继”为例。 v a l val val的“后继”指的是在BST中关键码大于 v a l val val的前提下,关键码最小的节点。

初始化 a n s ans ans为具有正无穷关键码的那个节点的编号。然后,在BST中检索 v a l val val。在检索过程中,每经过一个节点,都检查该节点的关键码,判断能否更新所求的后继 a n s ans ans

检索完成后,有三种可能的结果:

1.没有找到 v a l val val

此时 v a l val val的后继就在已经经过的节点中, a n s ans ans即为所求。

2.找到了关键码为 v a l val val的节点 p p p,且 p p p没有右子树。

与上一种情况相同, a n s ans ans即为所求。

3.找到了关键码为 v a l val val的节点 p p p,且 p p p有右子树。

p p p的右子节点出发,一直向左走,就找到了 v a l val val的后继。

在这里插入图片描述

int GetNext(int val)
{
    int ans=2;// a[2].val=INF
    int p=root;
    while(p)
    {
        if(val==a[p].val)
        {
            if(a[p].r>0)
            {
                p=a[p].r;
                //右子树一直往左走
                while(a[p].l>0) p=a[p].l;
                ans=p;
            }
            break;
        }
        //每经过一个节点,都尝试更新后继
        if(a[p].val>val&&a[p].val<a[ans].val) ans=p;
        p=val<a[p].val?a[p].l:a[p].r;
    }
    return ans;
}

int GetPre(int val)
{
    int ans=1;// a[1]=-INF
    int p=root;
    while(p)
    {
        if(val==a[p].val)
        {
            if(a[p].l>0)
            {
                p=a[p].l;
                //左子树一直往右走
                while(a[p].r>0) p=a[p].r;
                ans=p;
            }
            break;
        }
        //每经过一个节点,都尝试更新前驱
        if(a[p].val<val&&a[p].val>a[ans].val) ans=p;
        p=val<a[p].val?a[p].l:a[p].r;
    }
    return ans;
}

BST的节点删除

BST中删除关键码为 v a l val val的节点。

首先,在BST中检索 v a l val val,得到节点 p p p

p p p的子节点个数小于2,直接删除 p p p,并令 p p p的子节点代替 p p p的位置,与 p p p的父节点相连。

p p p既有左子树又有右子树,则在BST中求出 v a l val val的后继节点 n e x t next next因为 n e x t next next没有左子树(若 n e x t next next有左子树,则说明 n e x t next next v a l val val之间还有其他值, n e x t next next不是 v a l val val的后继),所以可以直接删除 n e x t next next,并令 n e x t next next的右子树代替 n e x t next next的位置。最后,再让 n e x t next next节点代替 p p p节点,删除 p p p即可。如图所示。

在这里插入图片描述

void Remove(int &p,int val)
{
    //从子树p中删除值为val的节点
    if(p==0) return;
    if(val==a[p].val) //已经检索到值为val的节点
    {
        if(a[p].l==0) p=a[p].r; //右子树代替p的位置,注意p是引用
        else if(a[p].r==0) p=a[p].l; //左子树代替p的位置,注意p是引用
        else
        {
            int next=a[p].r;
            while(a[next].l>0) next=a[next].l;
            //next一定没有左子树,直接删除
            Remove(a[p].r,a[next].val); //这里必须填a[p].r,这样引用会修改右子树的值
            //令节点next代替节点p的位置
            a[next].l=a[p].l,a[next].r=a[p].r;
            p=next; //注意p是引用,root值也会随之修改
        }
        return;
    }
    if(val<a[p].val) Remove(a[p].l,val);
    else Remove(a[p].r,val);
}

在随机数据中,BST一次操作的期望复杂度为 O ( l o g N ) O(logN) O(logN)。然而,BST很容易退化,例如在BST中一次插入一个有序序列,将会得到一条链,平均每次操作的复杂度为 O ( N ) O(N) O(N)。我们称这种左右子树大小相差很大的BST是“不平衡”的。有很多种方法可以维持BST的平衡,从而产生了各种平衡树。

常见的平衡二叉树有TreapSplay、红黑树、AVLSBT、替罪羊树等。其中C++STL中的mapset等就采用了效率很高的红黑树的一种变体。不过,大多数平衡树因为实现比较复杂,或者应用范围能被其他平衡树替代,在算法竞赛等短时间程序设计中并不常用。

2.Treap

满足BST性质且中序遍历为相同序列的二叉查找树是不唯一的。这些二叉查找树是等价的,它们维护的是相同的一组数值。在这些二叉查找树上执行同样的操作,将得到相同的结果。因此,我们可以在维持BST性质的基础上,通过改变二叉查找树的形态,使得树上每个节点的左右子树大小达到平衡,从而使整棵树的深度保持在 O ( l o g N ) O(logN) O(logN)级别。

改变形态并保持BST性质的方法就是“旋转”。最基本的旋转操作称为“单旋转”,它又分为“左旋”和“右旋”。如下图所示。

在这里插入图片描述

注意:有的时候把左、右旋操作定义为一个节点绕其父节点向左或右旋转。我们这里讲解的Treap代码仅记录左右子节点,没有记录父节点,为了方便起见,统一以“旋转前处于父节点位置”(旋转后处于子节点位置)的节点作为左、右旋的作用对象(函数参数)。

以右旋为例。在初始情况下, x x x y y y的左子节点, A A A B B B分别是 x x x的左右子树, C C C y y y的右子树。

“右旋”操作在保持BST性质的基础上,把 x x x变为 y y y的父节点。因为 x x x的关键码小于 y y y的关键码,所以 y y y应该作为 x x x的右子节点。

x x x变成 y y y的父节点后, y y y的左子树就空了出来,于是 x x x原来的右子树 B B B就恰好作为 y y y的左子树。

右旋操作代码如下, z i g ( p ) zig(p) zig(p)可以理解成把 p p p的左子节点绕着 p p p向右旋转

void zig(int &p)
{
    int q=a[p].l;
    a[p].l=a[q].r,a[q].r=p;
    p=q;
}

左旋操作代码如下, z a g ( p ) zag(p) zag(p)可以理解成把 p p p的右子节点绕着 p p p向左旋转

void zag(int &p)
{
    int q=a[p].r;
    a[p].r=a[q].l,a[q].l=p;
    p=q;
}

合理的旋转可使BST变得更“平衡”。如下图所示,对形态为一条链的BST进行一系列单旋操作后,这棵BST变得比较平衡了。

在这里插入图片描述

现在,我们的问题是,怎样才算“合理”的旋转操作呢?我们发现,在随机数据下,普通的BST就是趋于平衡的。Treap的思想就是利用“随机”来创造平衡条件。因为在旋转过程中必须维持BST性质,所以Treap就把“随机”作用在堆性质上

Treap是英文TreeHeap的合成词。Treap在插入每个新节点时,给该点随机生成一个额外的权值。然后像二叉堆的插入过程一样,自底向上依次检查,当某个节点不满足大根堆的性质时,就执行单旋转,使其父节点的关系发生对换。

特别地,对于删除操作,因为Treap支持旋转,我们可以直接找到需要删除的节点,并把它向下旋转成叶节点,最后直接删除。这样就避免了采用类似普通BST的删除方法可能导致的节点信息更新、堆性质维护等复杂问题。

总而言之,Treap通过适当的单旋转,在维持节点关键码满足BST性质的同时,还使得每个节点上随机生成的额外权值满足大根堆性质。Treap是一种平衡二叉查找树,检索、插入、求前驱后继以及删除节点的时间复杂度都是 O ( l o g N ) O(logN) O(logN)

您需要写一种数据结构,来维护一些数,其中需要提供以下操作:

  1. 插入数值 x x x
  2. 删除数值 x x x(若有多个相同的数,应只删除一个)。
  3. 查询数值 x x x 的排名(若有多个相同的数,应输出最小的排名)。
  4. 查询排名为 x x x 的数值。
  5. 求数值 x x x 的前驱(前驱定义为小于 x x x 的最大的数)。
  6. 求数值 x x x 的后继(后继定义为大于 x x x 的最小的数)。

这是一道平衡树的模板题,我们直接用Treap实现即可。

根据题意,数据中可能有相同的数值,我们可以给每个节点增加一个域 c n t cnt cnt,记录该节点的“副本数”,初始为1。若插入已经存在的数值,就直接把“副本数”加1。这样可以比较容易地处理关键码相同的问题。

题目还要求查询排名,我们可以给每个节点增加一个域 s i z e size size,记录以该节点为根的子树中所有节点的“副本数”之和。当不存在重复数值时, s i z e size size其实就是子树大小。

与线段树一样。我们需要在插入或删除时从下往上更新 s i z e size size信息。另外,在发生旋转操作时,也需要同时修改 s i z e size size。最后在BST检索的基础上,通过判断左右子树 s i z e size size的大小,选择适当的一侧递归,就很容易查询排名了。

因为在插入和删除操作时,Treap的形态会发生变化,所以我们一般使用递归实现,以便于在回溯时更新Treap上存储的 s i z e size size等信息。

#include <bits/stdc++.h>
#include <ctime>
#include <stdlib.h>
using namespace std;

const int SIZE=1e5+5;
struct Treap{
    int l,r;
    int val,dat;//节点关键码、权值
    int cnt,size;//副本数、子树大小
}a[SIZE];
int tot,root,n,INF=0x7fffffff;

int New(int val)
{
    a[++tot].val=val;
    a[tot].dat=rand();
    a[tot].cnt=a[tot].size=1;
    return tot;
}

void Update(int p)
{
    a[p].size=a[a[p].l].size+a[a[p].r].size+a[p].cnt;
}

void Build()
{
    New(-INF),New(INF);
    root=1,a[1].r=2;
    Update(root);
}

int GetRankByVal(int p,int val)
{
    if(p==0) return 0;
    if(val==a[p].val) return a[a[p].l].size+1;
    if(val<a[p].val) return GetRankByVal(a[p].l,val);
    return GetRankByVal(a[p].r,val)+a[a[p].l].size+a[p].cnt;
}

int GetValByRank(int p,int rank)
{
    if(p==0) return INF;
    if(a[a[p].l].size>=rank) return GetValByRank(a[p].l,rank);
    if(a[a[p].l].size+a[p].cnt>=rank) return a[p].val;
    return GetValByRank(a[p].r,rank-a[a[p].l].size-a[p].cnt); 
}

void zig(int &p)
{
    int q=a[p].l;
    a[p].l=a[q].r,a[q].r=p,p=q;
    Update(a[p].r),Update(p);
}

void zag(int &p)
{
    int q=a[p].r;
    a[p].r=a[q].l,a[q].l=p,p=q;
    Update(a[p].l),Update(p);
}

void Insert(int &p,int val)
{
    if(p==0)
    {
        p=New(val);
        return;
    }
    if(val==a[p].val)
    {
        a[p].cnt++,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);
}

int GetPre(int val)
{
    int ans=1;
    int p=root;
    while(p)
    {
        if(val==a[p].val)
        {
            if(a[p].l>0)
            {
                p=a[p].l;
                while(a[p].r>0) p=a[p].r;
                ans=p;
            }
            break;
        }
        if(a[p].val<val&&a[p].val>a[ans].val) ans=p;
        p=val<a[p].val?a[p].l:a[p].r;
    }
    return a[ans].val;
}

int GetNext(int val)
{
    int ans=2;
    int p=root;
    while(p)
    {
        if(val==a[p].val)
        {
            if(a[p].r>0)
            {
                p=a[p].r;
                while(a[p].l>0) p=a[p].l;
                ans=p;
            }
            break;
        }
        if(a[p].val>val&&a[p].val<a[ans].val) ans=p;
        p=val<a[p].val?a[p].l:a[p].r;
    }
    return a[ans].val;
}

void Remove(int &p,int val)
{
    if(p==0) return;
    if(val==a[p].val)
    {
        if(a[p].cnt>1)
        {
            a[p].cnt--,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),Remove(a[p].r,val);
            else
                zag(p),Remove(a[p].l,val);
            Update(p);
        }
        else p=0;
        return;
    }
    val<a[p].val?Remove(a[p].l,val):Remove(a[p].r,val);
    Update(p);
}

int main()
{
    srand((unsigned)time(0));
    scanf("%d",&n);
    Build();
    while(n--)
    {
        int opt,x;
        scanf("%d%d",&opt,&x);
        if(opt==1) Insert(root,x);
        else if(opt==2) Remove(root,x);
        else if(opt==3) printf("%d\n",GetRankByVal(root,x)-1); //起始多一个负无穷
        else if(opt==4) printf("%d\n",GetValByRank(root,x+1));
        else if(opt==5) printf("%d\n",GetPre(x));
        else if(opt==6) printf("%d\n",GetNext(x));
    }
    return 0;
}

3.Splay

Splay(伸展树)灵活多变,应用广泛,能够很方便地支持各种动态的区间操作,是用于解决复杂问题的一个重要的高级数据结构。

Splay的核心操作是 s p l a y splay splay(伸展)。一次 s p l a y splay splay操作,其实就是两次旋转,称之为双旋。但这两次旋转在不同情况下,顺序、种类都是不同的。在 Splay 所规定的双旋操作下,可以尽可能维持树的平衡。Splay 通过把每次操作的点,按照它所规定的旋转方式旋转到根节点,以维持平衡。

s p l a y splay splay操作规定:每访问一个节点 x x x后都要强制将其旋转到根节点。定义 p p p x x x的父节点。 s p l a y splay splay操作步骤有三种,具体分为六种情况:

1.zig:在 p p p是根节点时操作。Splay树会根据 x x x p p p间的边旋转。这时只需要旋转一次就可以将 x x x旋转到根节点。

在这里插入图片描述

在这里插入图片描述

2.zig-zig:在 p p p不是根节点且 x x x p p p都是右侧子节点或都是左侧子节点时操作。下方例图显示了 x x x p p p都是左侧子节点时的情况。Splay树首先按照连接 p p p与其父节点 g g g边旋转,然后按照连接 x x x p p p的边旋转。

在这里插入图片描述

即首先将 g g g左旋或右旋,然后将 x x x右旋或左旋。

在这里插入图片描述

3.zig-zag:在 p p p不是根节点且 x x x p p p一个是右侧子节点一个是左侧子节点时操作。Splay树首先按 p p p x x x之间的边旋转,然后按 x x x g g g新生成的结果边旋转。

在这里插入图片描述

即将 x x x先左旋再右旋、或先右旋再左旋。

在这里插入图片描述

具体而言,对于三个节点,其排列方式可能是共线也可能非共线。对于共线的,我们要先将父亲向上旋转,再将要旋转的节点向上旋转。对于非共线的,我们直接把要旋转的节点向上旋转两次即可。

特别地,当Splay执行删除操作时:

1.首先把要删除的点伸展到根节点。

2.如果其个数标记大于1,对个数进行修改即可。

3.个数标记等于1。

(1)如果没有子节点直接把这个点删除,但是,理论上,如果我们进行了类似于 Treap 中的 b u i l d build build 操作,插入了两个无穷节点,这个步骤可以省略。

(2)如果没有左子树或者右子树,直接让唯一的节点成为根节点。否则,即左右子节点都存在,那么可以找到它的前驱,把前驱 s p l a y splay splay 到根节点,随后修改相关指针。 值得注意的是,前驱 s p l a y splay splay到根节点后,其右儿子必定为我们要删除的节点,并且我们要删除的节点必定没有左儿子。这一性质有助于简化代码。

此外,如果我们先写好了前驱函数 G e t P r e GetPre GetPre,其结尾必定会把找到的节点 s p l a y splay splay 到根节点。那么,我们在删除操作的时候直接调用 G e t P r e GetPre GetPre 函数就可以直接把后继 s p l a y splay splay 到根节点了。这也有助于简化代码。

其他操作没有什么特别之处,需要牢记的是,所有操作结束后,均需把操作的点 s p l a y splay splay 到根节点。

#include <bits/stdc++.h>
using namespace std;

const int SIZE=1e5+5,INF=0x7fffffff;
int n;
struct Splay{
    int rt,tot;
    int f[SIZE],ch[SIZE][2]; //父节点,左右子节点
    int val[SIZE],cnt[SIZE],size[SIZE];
    int New(int v)
    {
        val[++tot]=v;
        cnt[tot]=size[tot]=1;
        return tot;
    }
    void Update(int p)
    {
        size[p]=size[ch[p][0]]+size[ch[p][1]]+cnt[p];
    }
    bool get(int p)
    {
        return p==ch[f[p]][1]; //判断是左儿子还是右儿子
    }
    void Build()
    {
        New(-INF),New(INF);
        rt=1,ch[rt][1]=2,f[2]=rt;
        Update(rt);
    }
    void rot(int p) //不同于Treap中的向下旋转,这里是向上旋转
    {
        int x=f[p],y=f[x],u=get(p),v=get(x);
        f[ch[p][u^1]]=x,ch[x][u]=ch[p][u^1];
        f[x]=p,ch[p][u^1]=x;
        Update(x),Update(p);
        f[p]=y;
        if(y) ch[y][v]=p;
    }
    void splay(int p) 
    {
        while(f[p])
        {
            int x=f[p],y=f[x];
            if(y) rot(get(p)==get(x)?x:p);
            rot(p);
        }
        rt=p;
    }
    void Insert(int v)
    {
        int x=rt,y=0;
        while(1)
        {
            if(val[x]==v)
            {
                cnt[x]++,size[x]++;
                Update(y),splay(x);
                break;
            }
            y=x,x=ch[y][val[y]<v];
            if(x==0)
            {
                New(v);
                f[tot]=y,ch[y][val[y]<v]=tot,Update(y);
                splay(tot);
                break;
            }
        }
    }
    int GetValByRank(int rank)
    {
        int p=rt;
        while(1)
        {
            if(rank<=size[ch[p][0]]) p=ch[p][0];
            else if(rank<=size[ch[p][0]]+cnt[p]) break;
            else rank-=size[ch[p][0]]+cnt[p],p=ch[p][1];
        }
        splay(p);
        return val[p];
    }
    int GetRankByVal(int v)
    {
        int p=rt,res=0;
        while(1)
        {
            if(v<val[p])
            {
                if(ch[p][0]==0)
                {
                    res++;
                    break;
                }
                p=ch[p][0];
            }
            else if(v==val[p])
            {
                res+=size[ch[p][0]]+1;
                break;
            }
            else
            {
                res+=size[ch[p][0]]+cnt[p];
                if(ch[p][1]==0)
                {
                    res++;
                    break;
                }
                p=ch[p][1];
            }
        }
        splay(p);
        return res;
    }
    int GetPre(int v)
    {
        return GetValByRank(GetRankByVal(v)-1);
    }
    int GetNext(int v)
    {
        return GetValByRank(GetRankByVal(v+1));
    }
    void Remove(int v)
    {
        GetRankByVal(v); //把第一个大于等于v的数splay到根节点
        if(v!=val[rt]) return;
        if(cnt[rt]>1)
        {
            cnt[rt]--,size[rt]--;
            return;
        }
        if(ch[rt][0]==0||ch[rt][1]==0)
        {
            rt=ch[rt][0]+ch[rt][1];
            f[rt]=0;
            return;
        }
        int p=rt;
        GetPre(v);//v的前驱到了rt节点,此时要删除的p必定是现在的根的右儿子,且必定没有左儿子
        f[ch[p][1]]=rt,ch[rt][1]=ch[p][1];
        size[rt]--;
        return;
    } 
}tree;

int main()
{
    scanf("%d",&n);
    tree.Build();
    while(n--)
    {
        int opt,x;
        scanf("%d%d",&opt,&x);
        if(opt==1) tree.Insert(x);
        else if(opt==2) tree.Remove(x);
        else if(opt==3) printf("%d\n",tree.GetRankByVal(x)-1);
        else if(opt==4) printf("%d\n",tree.GetValByRank(x+1));
        else if(opt==5) printf("%d\n",tree.GetPre(x));
        else if(opt==6) printf("%d\n",tree.GetNext(x));
    }
    return 0;
}
  • 25
    点赞
  • 23
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

谷神星ceres

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值