1. 程式人生 > >動態樹 LCT(Link-Cut-Tree)--入門教程

動態樹 LCT(Link-Cut-Tree)--入門教程

什麼是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;
}