ACM算法总结 数据结构(二)(树)

本文深入探讨了树形数据结构的多种类型及其关键特性,包括二叉搜索树、堆、Treap、Splay和LCT等。每种结构都详细讲解了其支持的操作、应用场景及模板代码,为读者提供了全面的树形数据结构知识。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >



一般我们考虑的树都是无向树,即联通非循环图(也就是联通无向图中没有环),如果为非联通非循环图,我们称之为森林

而在树形结构中,二叉树具有很多很好的性质,也有很多有用的应用,所以一般都以二叉树为基础进行讨论。




二叉搜索树

一种中序遍历为原序列的树形结构,对于每一个子树,左儿子比自己小,右儿子比自己大,如果必要的话,可以给每个结点加权,记录每个数出现次数。

一个二叉搜索树:

基于这种结构,二叉搜索树支持以下功能:

  • 插入:从根节点开始不断比较,小往左,大往右,最后放到合适位置;
  • 删除:对于叶子结点直接删除;对于有一个儿子的结点,删除后把儿子放到该结点位置;否则,寻找右子树中最小的值(即以右儿子为根,不断往左寻找)进行替换;
  • 查找:这个就很简单了;
  • 查询排名:带有 size 的深度搜索;
  • 前继后继:跟查找差不多;




堆是一种完全二叉树,分为大根堆小根堆,大根堆即所有子树的根节点都比儿子要大,小根堆同理。

一个大根堆:

基于这种数据结构,堆支持以下功能:

  • 查询最值:即堆顶;
  • 插入:我们将新的值放在最底层最右边,然后不停向上更新,直到新插入的值小于父亲;
  • 弹出:将最后的值放到堆顶,然后不停向下更新更大的儿子,直到该值大于所有儿子;




Treap

尽管对于随机数来说二叉搜索树非常高效,但是在一些特殊情况下,它可能会退化成一条较长的链。所以,随机平衡二叉搜索树——Treap,用于解决平衡问题。Treap=Tree+Heap,Treap结合了二叉搜索树和堆的性质,为每一个结点分配一个随机数,在满足二叉搜索树的性质的前提下,对随机数满足堆的性质。这样由于随机数是随机的,所以二叉搜索树是平衡的。

Treap的核心操作是旋转,从以下例子可以看出,不管是左旋还是右旋,都不会影响Treap的二叉搜索树的性质:

我们可以看出,旋转的规律就是:把当前结点的反儿子(左旋右儿子,右旋左儿子)的正儿子(左旋左儿子,右旋右儿子)拿出来,然后把待旋转的两个结点对应移一下,再把拿出来的结点放到原来结点的反方向。

由于考虑到Treap要满足堆的性质,所以我们在插入、删除结点的时候,还要考虑满足大根堆或者小根堆,这通过旋转来实现。插入和删除的操作跟普通二叉搜索树差不多,只是对于插入操作,某个结点插入了新的儿子时,要对随机数数组 rd 进行 rotate(旋转)操作,以满足堆性质;对于删除操作,可以将待删除的结点 rotate 到叶子结点处,这样方便处理,中途也要考虑满足堆的性质。

其它的功能,凡是二叉搜索树能满足和实现的,Treap也能同样地实现。

Treap模板如下(普通平衡树):

// t为每个结点的值;num为每个值出现次数;ch为儿子指针;rd为结点随机数;siz为结点子树大小
// root记录树根,tot记录结点个数

struct treap
{
    int tot,root;
    int *t,*num,(*ch)[2],*rd,*siz;

    treap(int maxn)
    {
        t=new int[maxn](); num=new int[maxn]();
        rd=new int[maxn](); siz=new int[maxn]();
        ch=new int[maxn][2](); root=tot=0;
    }

    void push_up(int k) {siz[k]=siz[ch[k][0]]+siz[ch[k][1]]+num[k];}

    void rotate(int &k,int d)
    {
        int x=ch[k][d^1];
        ch[k][d^1]=ch[x][d]; ch[x][d]=k;
        push_up(k); push_up(x);
        k=x;
    }

    void insert(int &k,int x)
    {
        if(!k) {k=++tot; t[k]=x; num[k]=siz[k]=1; rd[k]=rand(); return;}
        else if(t[k]==x) {num[k]++; siz[k]++; return;}
        else
        {
            int d=x>t[k];
            insert(ch[k][d],x);
            if(rd[k]<rd[ch[k][d]]) rotate(k,d^1);
            push_up(k);
        }
    }
    void insert(int x) {insert(root,x);}

    void del(int &k,int x)
    {
        if(!k) return;
        if(x!=t[k]) del(ch[k][x>t[k]],x);
        else
        {
            if(!(ch[k][0]|ch[k][1])) {num[k]--; siz[k]--; if(!num[k]) k=0;}
            else if(!ch[k][0] && ch[k][1]) {rotate(k,0); del(ch[k][0],x);}
            else if(ch[k][0] && !ch[k][1]) {rotate(k,1); del(ch[k][1],x);}
            else {int d=rd[ch[k][0]]>rd[ch[k][1]]; rotate(k,d); del(ch[k][d],x);}
        }
        push_up(k);
    }
    void del(int x) {del(root,x);}

    int ranking(int k,int x)
    {
        if(!k) return 0;
        if(x==t[k]) return siz[ch[k][0]]+1;
        if(x<t[k]) return ranking(ch[k][0],x);
        return ranking(ch[k][1],x)+siz[ch[k][0]]+num[k];
    }
    int ranking(int x) {return ranking(root,x);}

    int who_ranking(int k,int r)
    {
        if(!k) return 0;
        if(r<=siz[ch[k][0]]) return who_ranking(ch[k][0],r);
        if(r>siz[ch[k][0]]+num[k]) return who_ranking(ch[k][1],r-siz[ch[k][0]]-num[k]);
        return t[k];
    }
    int who_ranking(int r) {return who_ranking(root,r);}

    int pre(int k,int x)
    {
        if(!k) return -1e9;
        if(t[k]>=x) return pre(ch[k][0],x);
        return max(t[k],pre(ch[k][1],x));
    }
    int pre(int x) {return pre(root,x);}

    int suf(int k,int x)
    {
        if(!k) return 1e9;
        if(t[k]<=x) return suf(ch[k][1],x);
        return min(t[k],suf(ch[k][0],x));
    }
    int suf(int x) {return suf(root,x);}
};




Splay(伸展树)

也是一种可以让二叉搜索树平衡的数据结构,其中核心操作是旋转(rotate),与Treap类似(其实我们可以想象,二叉搜索树退化是因为一边太大一边太小,而旋转操作就是解决这个问题的)。Splay树的独特之处在于有一个splay操作,该操作把某一结点旋转为根结点(或其它结点),通过不停地splay保证树的平衡性,同时便于各种操作。

旋转操作和Treap差不多,但是在这里旋转的对象是和其父亲关联的(Treap中和儿子关联),我们执行一次rotate操作相当于把目标结点向上进了一步,同时不影响二叉搜索树的性质。

而伸展操作本质上就是不停地rotate,直到根结点为止。这里需要注意,如果目标结点和父亲和祖父三点一线的话,要先rotate父亲结点(其实不特殊化也可以,但是先旋转父亲的话可以让树更加平衡)。

下面列举一下常见操作的实现:

  • 旋转:略;
  • splay:略;
  • 插入:同普通二叉搜索树差不多,但是要注意维护相关数组,以及插入的结点需要splay;
  • 排名:同普通二叉搜索树差不多,但是找到目标结点后要splay;
  • pre:根结点前驱,因为限定为根结点,所以直接从根结点左儿子开始不停往右到底就行了;
  • suf:根结点后继,同上,直接从根结点右儿子开始不停往左到底就行了;
  • 删除:ranking(x) 一下将目标结点splay至根结点,然后分类讨论即可;

Splay树模板如下(普通平衡树):

// t为每个结点的值;num为每个值出现次数;ch为儿子指针;siz为结点子树大小;f为父亲指针
// root记录树根,tot记录结点个数

struct splay_tree
{
    int root,tot;
    int *t,*num,(*ch)[2],*siz,*f;

    splay_tree(int maxn)
    {
        t=new int[maxn](); num=new int[maxn]();
        siz=new int[maxn](); f=new int[maxn]();
        ch=new int[maxn][2](); root=tot=0;
    }

    int which(int k) {return k==ch[f[k]][1];}
    void clear(int k) {t[k]=num[k]=ch[k][0]=ch[k][1]=siz[k]=f[k]=0;}
    void push_up(int k) {siz[k]=siz[ch[k][0]]+siz[ch[k][1]]+num[k];}

    void rotate(int k)
    {
        int fa=f[k],gf=f[fa],w=which(k);
        ch[fa][w]=ch[k][w^1]; f[ch[fa][w]]=fa;
        ch[k][w^1]=fa; f[fa]=k; f[k]=gf;
        if(gf) ch[gf][ch[gf][1]==fa]=k;
        push_up(fa); push_up(k);
    }

    void splay(int k,int goal)   // 将k旋转到goal的儿子(goal为0时旋转到根)
    {
        for(int fa;(fa=f[k])!=goal;rotate(k))
            if(f[fa]!=goal) rotate(which(k)==which(fa)?fa:k);
        if(!goal) root=k;
    }

    void insert(int x)
    {
        if(!root) {clear(root=++tot); num[root]=siz[root]=1; t[root]=x; return;}
        int k=root,fa=0;
        while(1)
        {
            if(x==t[k]) {num[k]++; push_up(k); push_up(fa); splay(k,0); return;}
            fa=k; k=ch[fa][t[fa]<x];
            if(!k) {clear(++tot); num[tot]=siz[tot]=1; ch[f[tot]=fa][t[fa]<x]=tot; t[tot]=x; push_up(fa); splay(tot,0); return;}
        }
    }

    int ranking(int x)  // x必须在里面
    {
        int k=root,ans=0;
        while(1)
        {
            if(x<t[k]) {k=ch[k][0]; continue;}
            if(ch[k][0]) ans+=siz[ch[k][0]];
            if(t[k]==x) {splay(k,0); return ans+1;}
            ans+=num[k]; k=ch[k][1];
        }
    }

    int who_ranking(int r)
    {
        int k=root;
        while(1)
        {
            if(ch[k][0] && r<=siz[ch[k][0]]) {k=ch[k][0]; continue;}
            int temp=num[k];
            if(ch[k][0]) temp+=siz[ch[k][0]];
            if(r<=temp) return t[k];
            r-=temp; k=ch[k][1];
        }
    }

    int pre()   // 当前根结点前继
    {
        int k=ch[root][0];
        while(ch[k][1]) k=ch[k][1];
        return k;
    }

    int suf()   // 当前根结点后继
    {
        int k=ch[root][1];
        while(ch[k][0]) k=ch[k][0];
        return k;
    }

    void del(int x)
    {
        ranking(x);
        if(num[root]>1) {num[root]--; push_up(root); return;}
        if(!ch[root][0] && !ch[root][1]) {clear(root); root=0; return;}
        if(!ch[root][1]) {int k=root; f[root=ch[root][0]]=0; clear(k); return;}
        if(!ch[root][0]) {int k=root; f[root=ch[root][1]]=0; clear(k); return;}
        int k=root,p=pre();
        splay(p,0);
        ch[root][1]=ch[k][1]; f[ch[k][1]]=root;
        clear(k); push_up(root);
    }
};

写成结构体是为了方便调用,同时将内部变量与外面隔离开来。


Treap是利用自己构造的随机数据去保证二叉搜索树的平衡性,而Splay通过特殊的伸展操作保证平衡性;在平衡性条件下,不论是堆操作还是splay操作,复杂度都是 O(logn) 级别的;实际应用中,splay的功能还是更加强大一些(比如区间处理),它几乎所有操作都会把目标结点splay到根结点上去,这保证了平衡性,同时在根结点上进行分析也更加简洁。

用splay进行区间处理的思路一般是:t数组记录1-n,也就是原本下标,我们要处理区间 [l,r] ,可以把 l-1 splay到根结点,把 r+1 splay到根结点的右儿子,这样根结点的右儿子的左子树就是待处理的区间了。为了避免左右特殊处理,往往可以添加两个虚拟结点。

进行区间翻转的维护时,我们借鉴线段树 lazy标签的思路,对对应区间结点打标记,需要访问的时候往下传即可。要注意这里区间翻转的实现是交换儿子,而这样的操作之后事实上该树已经不满足二叉搜索树的性质了,我们说的比如说把 r+1 splay到根结点,实际上是指在树上排名为 r+1 的结点。

区间翻转维护代码如下(文艺平衡树):

const int maxn=1e5+5;
int root=1,tot,n,m;
int t[maxn],ch[maxn][2],siz[maxn],f[maxn],rev[maxn];

int which(int k) {return k==ch[f[k]][1];}
void clear(int k) {t[k]=ch[k][0]=ch[k][1]=siz[k]=f[k]=0;}
void push_up(int k) {siz[k]=siz[ch[k][0]]+siz[ch[k][1]]+1;}

void rotate(int k)
{
    int fa=f[k],gf=f[fa],w=which(k);
    ch[fa][w]=ch[k][w^1]; f[ch[fa][w]]=fa;
    ch[k][w^1]=fa; f[fa]=k; f[k]=gf;
    if(gf) ch[gf][ch[gf][1]==fa]=k;
    push_up(fa); push_up(k);
}

void splay(int k,int goal)
{
    for(int fa;(fa=f[k])!=goal;rotate(k))
        if(f[fa]!=goal) rotate(which(k)==which(fa)?fa:k);
    if(!goal) root=k;
}

void push_down(int k)
{
    if(!rev[k]) return;
    rev[ch[k][0]]^=1; rev[ch[k][1]]^=1;
    swap(ch[k][0],ch[k][1]);
    rev[k]=0;
}

int findd(int x)
{
    int k=root;
    while(1)
    {
        push_down(k);
        if(x<=siz[ch[k][0]]) k=ch[k][0];
        else
        {
            x-=siz[ch[k][0]]+1;
            if(!x) return k;
            k=ch[k][1];
        }
    }
}

int build(int l,int r,int fa)
{
    if(l>r) return 0;
    int mid=(l+r)>>1,k=++tot;
    t[k]=mid; f[k]=fa;
    ch[k][0]=build(l,mid-1,k);
    ch[k][1]=build(mid+1,r,k);
    push_up(k);
    return k;
}

void dfs(int k)
{
    if(!k) return;
    push_down(k);
    dfs(ch[k][0]);
    if(t[k]>1 && t[k]<n+2) printf("%d ",t[k]-1);
    dfs(ch[k][1]);
}

int main()
{
    //freopen("input.txt","r",stdin);
    n=read(),m=read();
    build(1,n+2,0);
    while(m--)
    {
        int l=read(),r=read();
        int a=findd(l),b=findd(r+2);
        splay(a,0); splay(b,a);
        rev[ch[ch[root][1]][0]]^=1;
    }
    dfs(root);

    return 0;
}




LCT

Link-cut Tree,动态树的一种,用来处理可添加边和删除边的树上问题。

LCT其实就是用 splay树去维护实链剖分后的实链,实链剖分和重链剖分类似,重链剖分是根据重儿子决定重边和轻边,而实链剖分是随意指定实边和虚边,这里说“随意”并不是真的随意,只是说实边虚边是可以变动的,而不是像重链剖分那样根据树形去决定。结合了 splay树维护实链信息,LCT变得非常灵活。

LCT中有两个重要的概念:原树辅助树。原树指的是原本森林中一颗一颗的树,辅助树是由若干个 splay树组成的用于维护原树的树,所以,LCT是若干个辅助树的集合,用于维护整个森林。辅助树上的每个 splay 维护一条实链,且这个 splay 根据结点在原树中的深度满足二叉搜索性,每个 splay 的父亲为其最小结点在原树上的父亲。

鉴于实链剖分和重链剖分的相似特征,LCT也是用于维护树上区间可合并值的数据结构,同时支持加边和删边操作。

LCT的一些重要操作:

  • is_root(k):判断 k 是否为所在 splay 的根;
  • which(k):判断 k 是 splay 上哪个儿子;
  • push_up/push_down:结点维护的信息更新;
  • rotate/splay: splay 树的基本操作(这里的 splay 是将当前结点旋转至当前 splay树的根),但要注意这里和基础的 splay树不太一样;
  • access(k):将结点 k 到原树树根的这一条链变成实链,组成一个 splay树(这个操作非常重要!),这个操作过后,结点 k 是该实链中深度最深的结点;
  • make_root(k):使结点 k 成为其所在树的树根,我们只用先 access(k)、splay(k)(这时由 splay树的性质,k没有右儿子),然后我们将 k 的儿子翻转,结点 k 就成为了当前 splay树中最小的结点,也就是原树中这条实链中深度最浅的结点,也就是根结点;
  • find_root(k):寻找结点 k 所在树的根结点,同样 access(k)、splay(k) 后,根结点就是当前 splay树中最小的结点;
  • link(x,y):如果 x 和 y 不在同一棵树中,就把 x 变成其所在树的树根(make_root(x)),然后令其父亲为 y;
  • cut(x,y):把 x 变成树根,然后 access(y)、splay(y) 后,如果 x 和 y 有连边,那么 x 此时一定是 y 的左儿子,删除该边即可;

对于 洛谷P3690 Link Cut Tree ,要求维护森林中路径权值异或和,并且支持加边、删边和单点修改。代码如下:

const int maxn=3e5+5;
int n,m;
int f[maxn],rev[maxn],a[maxn],t[maxn],Q[maxn],ch[maxn][2];

bool is_root(int k) {return ch[f[k]][0]!=k && ch[f[k]][1]!=k;}
bool which(int k) {return k==ch[f[k]][1];}
void push_up(int k) {t[k]=t[ch[k][0]]^t[ch[k][1]]^a[k];}
void push_down(int k) {if(rev[k]) rev[ch[k][0]]^=1,rev[ch[k][1]]^=1,rev[k]=0,swap(ch[k][0],ch[k][1]);}

void rotate(int k)
{
    int fa=f[k],gf=f[fa],w=which(k);
    if(!is_root(fa)) ch[gf][ch[gf][1]==fa]=k;
    ch[fa][w]=ch[k][w^1]; f[ch[k][w^1]]=fa;
    ch[k][w^1]=fa; f[fa]=k; f[k]=gf;
    push_up(fa); push_up(k);
}

void splay(int k)
{
    int top=1; Q[0]=k;
    for(int i=k;!is_root(i);i=f[i]) Q[top++]=f[i];
    REP_(i,top-1,0) push_down(Q[i]);
    for(int fa=f[k];!is_root(k);rotate(k),fa=f[k])
        if(!is_root(fa)) rotate(which(k)==which(fa)?fa:k);
}

void access(int k) {int t=0; while(k) splay(k),ch[k][1]=t,push_up(k),t=k,k=f[k];}
void make_root(int k) {access(k); splay(k); rev[k]^=1;}
int  find_root(int k) {access(k); splay(k); while(ch[k][0]) k=ch[k][0]; return k;}
void link(int x,int y) {if(find_root(x)!=find_root(y)) make_root(x),f[x]=y;}
void cut(int x,int y) {make_root(x); access(y); splay(y); if(ch[y][0]==x) f[x]=ch[y][0]=0;}

int main()
{
    n=read(),m=read();
    REP(i,1,n) a[i]=t[i]=read();
    while(m--)
    {
        int op=read(),x=read(),y=read();
        if(!op) make_root(x),access(y),splay(y),printf("%d\n",t[y]);
        else if(op==1) link(x,y);
        else if(op==2) cut(x,y);
        else make_root(x),a[x]=y,push_up(x);
    }

    return 0;
}

对于维护树上区间的问题,一般都是要求询问结点 x 到结点 y 路径上点的权值和、异或和等等信息,而LCT支持 access 操作,所以可以 make_root(x)、access(y)、splay(y) 后,在 x 和 y 所在的 splay树中,结点 y 就存储了这条路径信息,结点 x 是其中的最小结点。

对于 洛谷P2147 洞穴勘测 ,要求判断加边删边后的连通性,那就更容易了,判断 x 和 y 所在树的根结点是不是相同即可。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值