动态树 LCT(Link-Cut-Tree)--入门教程

根据杨哲先生的论文(QTREE),可以得知,动态树问题是一类问题的统称,而解决这种问题最常用到的数据结构就是LCT(Link-Cut-Tree)。

LCT的大体思想类似于树链剖分中的轻重链剖分,轻重链剖分是处理出重链来,由于重链的定义和树链剖分是处理静态树所限,重链不会变化,变化的只是重链上的边或点的权值。由于这个性质,我们用线段树来维护树链剖分中的重链,但是LCT解决的是动态树问题(包含静态树),所以需要用更灵活的splay来维护这里的“重链”。


根据个人理解,动态树问题就是要:

  1. 给你一些森林,维护他们的联通性
  2. 在某棵树上的链(或链上节点)上做查询或修改。

很容易想到,如果没有第一点,那么第二点可以用树链剖分轻松解决。所以我们要学习动态树。


相关名词及原理

定义一些常用的量:

Preferred child(偏爱子节点):如果最后被访问的点在X的儿子P节点的子树中,那么称P为X的Preferred child,如果一个点被访问,他的Preferred child为null(即没有)。

Preferred edge(偏爱边):每个点到自己的Preferred child的边被称为Preferred edge。

Preferred path(偏爱路径):由Preferred edge组成的不可延伸的路径称为Preferred path。

access(u):访问u点,既把u到根的路径打通成为实边,并且u的孩子节点的实边变成虚边。也就是这条Preferred Path一端是根,一端是u。

解释

这样我们可以发现一些比较显然的性质,每个点在且仅在一条Preferred path上,也就是所有的Preferred path包含了这棵树上的所有的点,这样一颗树就可以由一些Preferred path来表示(类似于轻重链剖分中的重链),我们用splay来维护每个条Preferred path,关键字为深度,也就是每棵splay中的点左子树的深度都比当前点小,右节点的深度都比当前节点的深度大。这样的每棵splay我们称为Auxiliary tree(辅助树),每个Auxiliary tree的根节点保存这个Auxiliary tree与上一棵Auxiliary tree中的哪个点相连。这个点称作他的Path parent。

大致上讲就是把某棵Splay的根节点指向这棵Splay所在实边最上方的点的父亲,也就是说,这是一条虚边。注意到这条虚边是单向的,由某棵Splay的根指向某一节点,但是那个节点的孩子里面并没有它。LCT的精髓就是通过这一条条的虚边将整棵大树串起来。


操作与操作原理

网络上有很多神犇大牛写的LCT论文的确很不错,但是毕竟我只是蒟蒻,还是看得云里雾里。所以在这里从结构体到建树方法,再到标记的上传下传都会详细介绍。留给刚刚接触LCT的初学者,方便大家学习,加深自己对于LCT的理解。

在讲解所有的操作之前,请各位读者好好想清splay(既LCT中的Auxiliary tree)的性质,如果遇到了看不明白的地方,也请回来看看这几个性质再仔细想一想为什么:

  1. 根据splay的性质,splay的中序遍历的顺序一定是与插入顺序相同的(pushback意义上的插入)。
  2. 根据上一条性质,splay中每一个结点的左子节点一定是比该结点插入得早,每一个结点的右子节点一定是比该结点插入得晚。
  3. 所以在splay中(注意是在splay中),某个结点的左子节点在树上一定是该结点的上方的点。

结构体

struct Node
{
    int key,siz;
    int add,minu,sum;
    bool flip;
    Node *ch[2],*fa;

    void push_mul(const int m) { 
        minu=minu*m;
        sum=sum*m;
        add=add*m;
        key=key*m;
    }
    void push_add(const int a) {
        sum=sum+a*siz;
        add=add+a;
        key=key+a;
    }
}_memory[maxn],*null=_memory;

/***下传结点信息***/
void clear_mark(Node* const x)
{
    if(x==null) return;
    if(x->flip)
    {
        x->ch[0]->flip^=1;
        x->ch[1]->flip^=1;
        std::swap(x->ch[0],x->ch[1]);
        x->flip=false;
    }
    if(x->minu!=1)
    {
        if(x->ch[0]!=null)
            x->ch[0]->push_mul(x->minu);
        if(x->ch[1]!=null)
            x->ch[1]->push_mul(x->minu);
        x->minu=1;
    }
    if(x->add)
    {
        if(x->ch[0]!=null)
            x->ch[0]->push_add(x->add);
        if(x->ch[1]!=null)
            x->ch[1]->push_add(x->add);
        x->add=0;
    }
}

/***更新结点信息***/
void update(Node* const x)
{
    x->siz=x->ch[0]->siz+x->ch[1]->siz+1;
    x->sum=x->key+x->ch[0]->sum+x->ch[1]->sum;
}

建树

Make_tree():顾名思义就是建树了,在这里我们用*node[i]表示指向第i个结点的指针,递归建树。这里要注意,建树的时候我们只处理了子结点与父节点的关系,换句话说就是:孩子认了爸,但是爸还没认孩子。因为在进行Access操作之前,图中所有的边还都是虚边(不是Preferred edge的边)。

说明:neigh是个vector邻接表,用于存边,wei[i]表示i点的点权值。

/***建树***/
void Make_tree(int u,int fa)
{
    Node* const node=_memory+u;
    node->fa=_memory+fa;
    node->key=node->sum=node->siz=node->minu=1;
    node->ch[0]=node->ch[1]=null;
    for(int i=0;i<(int)neigh[u].size();i++) if(neigh[u][i]!=fa)
        Make_tree(neigh[u][i],u);
}

旋转

Rotate(Node* const,const int):最基础的操作,就是将传入的cur结点旋转至其父结点的位置。代码从上到下的步骤依次为:把指向父亲位置的指针提取到tmp中;将父亲位置原本连着cur的边连到旋转时cur让出的儿子;cur让出的儿子与新父亲“确定关系”,但前提是这个让出的儿子不是空指针;tmp的父亲变成cur的父亲,cur上位(这时需要画个图自行YY);cur继承tmp与父节点之间的关系;既然cur已上位,那么确定cur与tmp之间的关系(画图很重要,脑补);更新tmp的信息。

在这里画图理解很重要,虽然挺简单的。

/***将cur结点旋转到父结点位置***/
void Rotate(Node* const cur,const int dir)
{
    Node* const tmp=cur->fa;
    tmp->ch[dir^1]=cur->ch[dir];
    if(cur->ch[dir]!=null) cur->ch[dir]->fa=tmp;
    cur->fa=tmp->fa;
    if(tmp->fa->ch[0]==tmp) cur->fa->ch[0]=cur;
    else if(tmp->fa->ch[1]==tmp) cur->fa->ch[1]=cur;
    tmp->fa=cur;
    cur->ch[dir]=tmp; //只有在这里连接实边(Preferred edge)
    tmp->maintain();
}

Splay操作

Splay_parent(Node*,Node*&)Splay(Node* const):这个是继Rotate之后第二基础的操作了,所有的操作一定会用到splay。有的人说会写平衡树的那个splay就会写这个splay,但是我的几乎所有同学都是按照刘汝佳刘老师的模版写的splay,既从根节点开始向下找然后向上旋到根节点。不过在LCT中,并不是每两个有父子关系的结点都能通过“找儿子” 的方式去遍历,因为一开始的时候图中的边都是虚边,既只能通过找爸爸的方式向上旋到根节点。所以说只能从某个结点结点向上推。

这里我还加入了一个函数Splay_parent(Node*,Node*&),就是为了保证在旋x结点的时候不会被旋出splay,也就是说在旋的时候不会走虚边。同时也把y指针处理为x指针当前的父亲结点。

在splay函数里面只有一个循环,我们用*y和*z分别表示*x的父亲结点和爷爷结点,保证在同一棵splay的情况才会向上旋。而且务必要注意的是,旋转之前,旋谁就先传一下谁的标记。如果有爷爷结点的话,就一套双旋带上去;否则直接一个单旋,结束战斗。

最后别忘了随手maintain一下。

/***看x的父亲y是否是x所在Splay的父亲***/
bool Splay_parent(Node* x,Node* (&y))
{
    return (y=x->fa)!=null && (y->ch[0]==x || y->ch[1]==x);
}

/***将x结点旋到splay的根***/
void Splay(Node* const x)
{
    clear_mark(x);
    for(Node *y,*z;Splay_parent(x,y);)
        if(Splay_parent(y,z))
        {
            clear_mark(z);
            clear_mark(y);
            clear_mark(x);
            const int c=y==z->ch[0];
            if(x==y->ch[c]) Rotate(x,c^1),Rotate(x,c);
            else Rotate(y,c),Rotate(x,c);
        }
        else
        {
            clear_mark(y);
            clear_mark(x);
            Rotate(x,x==y->ch[0]);
            break;
        }
    update(x);
    return;
}

一开始第一次写的时候我就崩溃在了bool Splay_parent()里面,原因很简单,因为我建树的时候默认node[1]结点的父亲是node[0],而node[0]指针因为在建树的时候没有遍历过,也就是说node[0]没有被new Node()赋初值,导致node[0]一直都是0x0,在调用node[0]->fa时崩溃,所以提醒大家在第一次写的时候一定要注意。


访问

Access(u):访问操作是核心操作,我们保证做完之后,把u到根的路径打通成为实边,并且u的孩子节点的实边变成虚边。也就是这条Prefer Path一端是根,一端是u。

/***访问u结点***/
Node* Access(Node* u)
{
    Node* v=null;
    for(;u!=null;u=u->fa)
    {
        Splay(u);
        u->ch[1]=v;
        update(v=u);
    }
    return v;
}

大致的意思就是说,每次把一个节点旋到Splay的根,然后把上一次的Splay的根节点当作当前根的右孩子(也就是原树中的下方)。第一次初始 v=null是为了清除u原来的孩子。 因为不是每次access都需要把最后节点旋到Splay的根,所以我就不在最后splay(v)了。 返回值是最后访问到的节点,也就是原树的根。

具体原理如图所示:

这是一棵树本来的样子(只有虚边):

这里写图片描述


然后在执行过程中它已经有一些链了:

这里写图片描述


然后我们用Access访问u结点之后:

这里写图片描述

所以在这里再说一遍,反复理解,我们保证做完之后,把u到根的路径打通成为实边,并且u的孩子节点的实边变成虚边。也就是这条Prefer Path一端是根,一端是u。返回的指针Node*是以根节点为根,从跟到u结点的Preferred path。


以上是LCT的核心操作,如果你看懂了上面的核心操作,那么请继续往下看。如果没有看懂上面的核心操作的话,那么再看几遍直到看懂为止。因为以下的实际应用的操作都是基于核心操作进行的。


换根

Make_root(Node* const):换根操作。首先将从根到x结点的链提出来(Access(x)),将其翻转,这里所说的翻转是splay意义上的左右翻转,而在树的形态的意义上是上下翻转,既改变了父子关系,更准确地说是调换了父子关系。(这段话没看懂的话,翻上去看看性质里面是怎么说的)

翻转了父子关系之后,传一下标记,顺带把x提到根节点就好了。

/***使x结点变成根***/
void Make_root(Node* const x)
{
    Access(x)->flip=true;
    Splay(x);
    return;
}

找根

Node* Get_root(Node*):找到x所在结点的根,返回值就是指向根的位置的指针。其实现的原理就是找到一条连接根和x的链,找到链之后不断向左子节点推。如果不知道为什么向左子结点推的话就再看看性质,因为在向splay里面向左子结点推就相当于在树上向根推啊!

/***得到x所在的树的树根***/
Node* Get_root(Node* x)
{
    for(x=Access(x);clear_mark(x),x->ch[0]!=null;x=x->ch[0]);
    return x;
}

合并与分离

Link(Node* const,Node* const)Cut(Node* const,Node* const):这个就是LCT最经常使用的操作了。不过理解了上面的部分之后都比较好理解。

Link就是连接两棵子树,先让x变成x所在的子树的根,然后从向y连一条虚边即可,最后那个Access可以不用,好像没有什么影响的样子。

Cut就是将一棵子树分为两个,也就可以理解为以x为树根,把y到x的路径分离出来。(要是看不懂代码的话就回去翻翻性质)

/***连接两棵树***/
void Link(Node* const x,Node* const y)
{
    Make_root(x);
    x->fa=y;
    Access(x);
    return;
}

/***割开两棵树***/
void Cut(Node* const x,Node* const y)
{
    Make_root(x);
    Access(y);
    Splay(y);
    y->ch[0]->fa=null;
    y->ch[0]=null;
    update(y);
    return;
}

询问与修改

Query(Node*,Node* )Modify(Node*,Node* ,const int):就是问题中经常出现的查询和修改。以从x结点到y结点的路径上的点权权值和为例。

/***查询x和y路径上的相关值***/
int Query(Node* x,Node* y)
{
    Make_root(x);
    Access(y),Splay(y);
    return y->sum;
}

/***将x到y路径上的值加上val***/
void Modify_add(Node* x,Node* y,const int val)
{
    Make_root(x);
    Access(y),Splay(y);
    y->push_add(val);
    return;
}

LCT裸题例题 (BZOJ2631)

Description

一棵n个点的树,每个点的初始权值为1。对于这棵树有q个操作,每个操作为以下四种操作之一:
+ u v c:将u到v的路径上的点的权值都加上自然数c;
- u1 v1 u2 v2:将树中原有的边(u1,v1)删除,加入一条新边(u2,v2),保证操作完之后仍然是一棵树;
* u v c:将u到v的路径上的点的权值都乘上自然数c;
/ u v:询问u到v的路径上的点的权值和,求出答案对于51061的余数。

Input
第一行两个整数n,q
接下来n-1行每行两个正整数u,v,描述这棵树
接下来q行,每行描述一个操作

Output
对于每个/对应的答案输出一行

Sample Input
3 2
1 2
2 3
* 1 3 4
/ 1 1

Sample Output
4

100%的数据保证,1<=n,q<=10^5,0<=c<=10^4


思路

LCT裸题,连边的时候Link,断边的时候Cut。


注意:

对于覆盖类标记不用想太多,如果是像区间赋值这样会“覆盖”已有的非覆盖类标记的标记,那就清除已有标记,然后直接打上新的标记;如果是区间翻转这种和其他标记独立开来的标记,就单独处理。处理这类标记时不需要先clear_mark。

对于非覆盖类标记就要仔细思考了。因为标记之间有可能互相影响,所以处理标记的顺序是很重要的。就拿这道题来说,有两类非覆盖类标记:加法标记和乘法标记。应该先下传乘法标记,再下传加法标记。同时下传乘法标记时,要给儿子的加法增量也乘上乘法标记,写成公式就是(x + add) * c = x * c + add* c。有同样标记的线段树题有一道BZOJ1798。Splay和线段树中的标记下传类似(可以说完全相同)。

还有就是标记下传(clear_mark)与信息更新(update)的时机。由于下传的时候会直接修改子节点的key和sum等信息,也就是说,打了标记的节点本身的信息已经是最新了的,所以不要update,update反而错了。个人总结的Splay需要clear_mark的地方分别是rotate过程中对x进行下传、splay过程中每次旋转前对x的祖父节点(如果有)和父节点依次下传,最后在splay过程结束之前先下传再更新。

代码

/**************************************************************
    Problem: 2631
    User: CHN
    Language: C++
    Result: Accepted
    Time:15236 ms
    Memory:9220 kb
****************************************************************/

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

#define pb push_back
#define mp make_pair
#define max(a,b) ((a)>(b)?(a):(b))
#define min(a,b) ((a)<(b)?(a):(b))

/***读入输出优化***/
inline int read()
{
    char ch;
    bool flag=false;
    int a=0;
    while(!(((ch=getchar())>='0' && ch<='9') || ch=='-'));
        if(ch!='-') a=a*10+ch-'0';
        else flag = true;
    while((ch=getchar())>='0' && ch<='9')
        a=a*10+ch-'0';
    if(flag) a=-a;
    return a;
}
void write(int a)
{
    if(a<0)
    {
        putchar('-');
        a=-a;
    }
    if(a>=10) write(a / 10);
    putchar(a%10+'0');
}

const int maxn=int(1e5)+10;
const int moder=51061;
int n,m;
vector <int> neigh[maxn];
int wei[maxn];
unsigned int lop=1;

struct Node
{
    int key,siz;
    int add,minu,sum;
    bool flip;
    Node *ch[2],*fa;

    void push_mul(const long long m) {
        minu=minu*m%moder;
        sum=sum*m%moder;
        add=add*m%moder;
        key=key*m%moder;
    }
    void push_add(const int a) {
        sum=(sum+1LL*a*siz)%moder;
        add=add+a;
        key=key+a;
    }
}_memory[maxn],*null=_memory;

inline void clear_mark(Node* const x)
{
    if(x==null) return;
    if(x->flip)
    {
        x->ch[0]->flip^=1;
        x->ch[1]->flip^=1;
        std::swap(x->ch[0],x->ch[1]);
        x->flip=false;
    }
    if(x->minu!=1)
    {
        if(x->ch[0]!=null)
            x->ch[0]->push_mul(x->minu);
        if(x->ch[1]!=null)
            x->ch[1]->push_mul(x->minu);
        x->minu=1;
    }
    if(x->add)
    {
        if(x->ch[0]!=null)
            x->ch[0]->push_add(x->add);
        if(x->ch[1]!=null)
            x->ch[1]->push_add(x->add);
        x->add=0;
    }
}

inline void update(Node* const x)
{
    x->siz=x->ch[0]->siz+x->ch[1]->siz+1;
    x->sum=x->key+x->ch[0]->sum+x->ch[1]->sum;
}


/***将cur结点旋转到父结点位置***/
inline void Rotate(Node* const cur,const int dir)
{
    Node* const tmp=cur->fa;
    tmp->ch[dir^1]=cur->ch[dir];
    if(cur->ch[dir]!=null) cur->ch[dir]->fa=tmp;
    cur->fa=tmp->fa;
    if(tmp->fa->ch[0]==tmp) cur->fa->ch[0]=cur;
    else if(tmp->fa->ch[1]==tmp) cur->fa->ch[1]=cur;
    tmp->fa=cur;
    cur->ch[dir]=tmp;
    update(tmp);
}

/***看x的父亲y是否是x所在Splay的父亲***/
inline bool Splay_parent(Node* x,Node* (&y))
{
    return (y=x->fa)!=null && (y->ch[0]==x || y->ch[1]==x);
}

/***将x结点旋到根***/
inline void Splay(Node* const x)
{
    clear_mark(x);
    for(Node *y,*z;Splay_parent(x,y);)
        if(Splay_parent(y,z))
        {
            clear_mark(z);
            clear_mark(y);
            clear_mark(x);
            const int c=y==z->ch[0];
            if(x==y->ch[c]) Rotate(x,c^1),Rotate(x,c);
            else Rotate(y,c),Rotate(x,c);
        }
        else
        {
            clear_mark(y);
            clear_mark(x);
            Rotate(x,x==y->ch[0]);
            break;
        }
    update(x);
    return;
}

/***访问u结点***/
inline Node* Access(Node* u)
{
    Node* v=null;
    for(;u!=null;u=u->fa)
    {
        Splay(u);
        u->ch[1]=v;
        update(v=u);
    }
    return v;
}

/***使x结点变成根***/
inline void Make_root(Node* const x)
{
    Access(x)->flip=true;
    Splay(x);
    return;
}

/***得到x所在的树的树根***/
inline Node* Get_root(Node* x)
{
    for(x=Access(x);clear_mark(x),x->ch[0]!=null;x=x->ch[0]);
    return x;
}

/***连接两棵树***/
inline void Link(Node* const x,Node* const y)
{
    Make_root(x);
    x->fa=y;
    Access(x);
    return;
}

/***割开两棵树***/
inline void Cut(Node* const x,Node* const y)
{
    Make_root(x);
    Access(y);
    Splay(y);
    y->ch[0]->fa=null;
    y->ch[0]=null;
    update(y);
    return;
}

/***查询x和y路径上的相关值***/
inline int Query(Node* x,Node* y)
{
    Make_root(x);
    Access(y),Splay(y);
    return y->sum%moder;
}

/***将x到y路径上的值加上val***/
inline void Modify_add(Node* x,Node* y,const int val)
{
    Make_root(x);
    Access(y),Splay(y);
    y->push_add(val);
    return;
}

/***将x到y路径上的值乘上val***/
inline void Modify_minu(Node* x,Node* y,const int val)
{
    Make_root(x);
    Access(y),Splay(y);
    y->push_mul(val);
    return;
}

/***建树***/
void Make_tree(int u,int fa)
{
    Node* const node=_memory+u;
    node->fa=_memory+fa;
    node->key=node->sum=node->siz=node->minu=1;
    node->ch[0]=node->ch[1]=null;
    for(int i=0;i<(int)neigh[u].size();i++) if(neigh[u][i]!=fa)
        Make_tree(neigh[u][i],u);
}

int main()
{
    null->fa=null->ch[0]=null->ch[1]=null;

    n=read(),m=read();
    for(int x,y,i=1;i<n;i++)
    {
        x=read(),y=read();
        neigh[x].pb(y);
        neigh[y].pb(x);
    }

    Make_tree(1,0);

    for(int i=1;i<=m;i++)
    {
        char op=getchar();
        int u=read(),v=read(),c,u1,u2;
        if(op=='+')
            c=read(),
            Modify_add(_memory+u,_memory+v,c);
        else if(op=='*')
            c=read(),
            Modify_minu(_memory+u,_memory+v,c);
        else if(op=='-')
            u1=read(),u2=read(),
            Cut(_memory+u,_memory+v),
            Link(_memory+u1,_memory+u2);
        else if(op=='/')
            write(Query(_memory+u,_memory+v)),
            putchar('\n');
    }

    return 0;
}

  • 2
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值