「算法筆記」Link-Cut Tree


一、簡介

Link-Cut Tree (簡稱 LCT) 是一種用來維護動態森林連通性的數據結構,適用於動態樹問題。

類比樹剖,樹剖是通過靜態地把一棵樹剖成若干條鏈然后用一種支持區間操作的數據結構維護,而 LCT 則是動態地去處理這個問題。這里引入實鏈剖分。

實鏈剖分:

  • 與重鏈剖分類似,同樣將與某一個兒子的連邊划分為 實邊,其余兒子的連邊為 虛邊
  • 對於一個點連向它兒子的所有邊,選擇⼀條邊為實邊,其他邊為虛邊。虛實之間是可以進行 轉換 的。對於⼀條由實邊組成的鏈,我們稱之為 實鏈

每個節點能且僅能存在於一條實鏈中。實鏈是 節點深度遞增 的一條樹鏈,實鏈與實鏈間通過 虛邊 連接。

因為實鏈剖分靈活且可變(虛實可以 動態變化),LCT 采用 Splay 來維護每一條 實鏈

因為一條實鏈上每個點的深度互異,所以 Splay 以 點的深度 為關鍵字。那么在一個 Splay 中,左邊的點就是這條實鏈上深度比自己小的,右邊的點就是深度比自己大的。(中序遍歷這個 Splay 得到的點序列,從前到后對應原樹自上到下的這條實鏈)

一個 Splay 的根節點的 \(fa\) 為這條實鏈 鏈頂節點 在原樹中的 父親\(fa\) 指 Splay 中的 \(fa\))。

二、一些性質

某些可能不算是性質,反正就放在一起寫了 QAQ

  • 每一個 Splay 維護的是一條 從上到下 在原樹中 深度嚴格遞增 的鏈,且 中序遍歷 Splay 得到的點的深度嚴格遞增。

  • 每個節點包含且僅包含在一個 Splay 中(因為一個節點只能包含在一條實鏈上啊)。

  • 實邊包含在 Splay 中,而虛邊則是一個 Splay 指向另一個節點所對應的邊。具體地,虛邊是由一個 Splay 的 根節點 \(rt\),指向該 Splay 中序遍歷最靠前的節點 \(x\)(即該 Splay 在原樹中深度最小的節點,也就是實鏈的 鏈頂節點)在原樹中的父親 \(y\)。我們令 \(fa(rt)=y\)。特別地,若 \(x\) 為原樹的根節點,則無需連邊。(\(fa\) 指 Splay 中的 \(fa\))

  • 顯然 \(rt\) 認了 \(y\) 這個父親后,父親不會認這個兒子。原因是兩者不在同一條實鏈上,所以父親的左右兒子一定沒有它。
  • 虛邊就將所有的 Splay 連接了起來。

注意到一個節點 \(x\) 可能有 多個 兒子,而只能與其中 一個 兒子​的連邊為實邊。

為了保持樹的形態,我們要讓 \(x\) 到其他兒子 \(y\) 的邊變為虛邊。記 \(y\) 所屬的 Splay 的根節點為 \(rt\)。因為 \((x,y)\) 為虛邊,所以 \(y\) 一定是它所對應的實鏈的鏈頂節點,因此還要令 \(fa(rt)=x\),而 \(x\) 不能直接訪問 \(y\)(認父不認子)。

三、LCT 的操作

1. access(x)

操作:將根節點到 \(x\) 上的邊都變成實邊,使根到 \(x\) 的路徑成為一條實鏈,並且 \(x\) 為該實鏈的最下端。

考慮 \(x\) 所在的實鏈。如圖所示,設 \(x\) 所在實鏈的頂端為 \(y\),最下端為 \(z\)

先把 \(x\) 旋轉到它所在的 Splay 的根。Splay 的關鍵字為 \(dep\),那么 \(x\) 的左子樹就是 \((y,x)\) 這部分,右子樹就是 \((x,z)\) 這部分(不包括 \(x\))。

因為 \(x\) 為最終要得到的實鏈的最下端,所以要先把 \(x\) 和它右兒子的邊斷開。

\(fa(x)\) 為 \(k\)\(fa\) 指 Splay 中的 \(fa\))。易知 \(k\)\(y\) 的父親(一個 Splay 的根節點的 \(fa\) 為這條實鏈鏈頂節點在原樹中的父親)。

考慮 \(k\) 所在的實鏈。我們先把 \(k\) 旋轉到它所在的 Splay 的根。與之前同理,\(k\) 的右子樹就是從 \(k\)\(k\) 所在實鏈的最下端的部分。所以把 \(k\) 和它右兒子的邊斷開,然后和 \(x\) 相連即可。

具體實現:

  • \(\text{splay}(x)\) 到當前實鏈的根,把 \(x\) 和右兒子的邊斷開。

  • 接下來對於實鏈上面的虛邊,令 \(y\) 為實鏈頂端節點的父親,那么 \(\text{splay}(y)\) 之后,將 \(y\) 的右兒子斷開,然后和 \(x\) 相連,這樣就將原來的虛邊變成實邊。

  • 不斷重復直到當前實鏈包含根。

在代碼實現時,我們可以 \(\text{splay}(x)\) 后,令 \(rc(x)=y\)(初始時 \(y\)\(0\))。然后令 \(y=x\)\(x=fa(x)\),重復操作。

void access(int x){
    for(int y=0;x;y=x,x=fa[x])
        splay(x),rc[x]=y,pushup(x);    //別忘了 pushup 
}

2. makeroot(x)

操作:\(x\) 變為原樹的根節點。

\(1\) 為原來的根節點。把根換成 \(x\) 后,只會修改 \((1,x)\) 這段路徑上的點的父子關系(邊的方向改變了。原來 \(y\)\(z\) 的父親,會變成 \(z\)\(y\) 的父親)。

(對於不在 \((1,x)\) 這段路徑上兩個點 \(y,z\),把根換成 \(x\)\(y,z\) 的父子關系不變)

所以我們可以先 \(\text{access}(x)\),此時 \(x\) 所在的 Splay 就代表了從 \(1\)\(x\) 這條實鏈。

對於一個點 \(x\)\(fa(x)\) 就是 \(x\) 在 Splay 中的前驅。那么根換成 \(x\) 之后,直接翻轉整個 Splay,使得 \(x\) 變成原來 \(fa(x)\) 的前驅即可,這樣就實現了父子關系的修改。

所以將 \(x\) 旋轉到根,然后在 \(x\) 上打上翻轉標記 \(rev\) 即可。

void makeroot(int x){
    access(x),splay(x),reverse(x);
}

3. findroot(x)

操作:找到 \(x\) 所在的樹的根。用來判斷兩點的連通性。

\(\text{access}(x)\) 之后,根節點一定是 \(x\) 所在的實鏈中深度最小的節點。

所以,可以先 \(\text{access}(x)\),然后 \(\text{splay}(x)\),根節點就是 \(x\) 一直向左走得到的節點。

int findroot(int x){
    access(x),splay(x);
    while(lc[x]) pushdown(x),x=lc[x];    //一直向左走 
    return splay(x),x;    //最后 splay 一下防止被卡 
}

4. isroot(x)

操作:判斷 \(x\) 是否為所在 Splay 的根。

之前說了,一個 Splay 的根節點 \(rt\)\(fa\) 為這條實鏈鏈頂節點在原樹中的父親。\(rt\) 認了這個父親后,顯然父親不會認這個兒子。原因是兩者不在同一條實鏈上,所以父親的左右兒子一定沒有它。

所以就可以直接判斷 \(x\) 是否為 \(x\) 的父親的兒子。

bool isroot(int x){
    return lc[fa[x]]!=x&&rc[fa[x]]!=x; 
}

5. split(x,y)

操作:把 \(x\)\(y\) 的路徑單獨拿出來,使其成為一個 Splay。最后 \(y\) 為 Splay 的根。

\(\text{makeroot}(x)\)\(x\) 作為根節點,然后 \(\text{access}(y)\),此時 \(y\) 所在的 Splay 就代表了 \(x\)\(y\) 的路徑。最后 \(\text{splay}(y)\) 即可。

void split(int x,int y){
    makeroot(x),access(y),splay(y);
} 

LCT 維護鏈信息的時候,就可以先 \(\text{split}(x,y)\) 將路徑 \((x,y)\) 提取到以 \(y\) 為根的 Splay 中,把樹鏈信息的修改和統計轉化為平衡樹上的操作。

6. link(x,y)

操作:連一條虛邊 \((x,y)\)(如果已經連通則不操作)。

\(\text{makeroot}(x)\) 之后,顯然 \(x\) 為它所在 Splay 中深度最小的點,直接令 \(fa(x)=y\) 即可。

連通性的檢查:\(x\) 成為根節點后,如果 \(\text{findroot}(y)=x\) 則說明 \(x,y\) 連通。

\(\text{findroot}(y)\) 中已經執行了 \(\text{access}(y)\)\(\text{splay}(y)\),則 \(y\) 成為了所在 Splay 的根節點。

void link(int x,int y){
    makeroot(x);
    if(findroot(y)!=x) fa[x]=y;
} 

7. cut(x,y)

操作:將邊 \((x,y)\) 斷開(如果沒有邊則不執行)。

\(\text{split}(x,y)\),那么此時 \(x\) 所在的 Splay 只包含 \(x,y\)。直接斷開即可。

顯然在 \(\text{split}(x,y)\) 后,\(x\) 為原樹的根,\(y\) 為對應 Splay 的根,\(fa(x)=y,lc(y)=x\)\(x\) 的深度比 \(y\) 淺,注意 \(\text{split}(x,y)\) 前要保證兩點連通)。

若不保證操作合法,還需判斷 \((x,y)\) 這條邊 是否存在

存在邊 \((x,y)\) 的條件(均要滿足):

  1. \(x,y\) 在同一棵樹內,即 \(\text{findroot(y)}=x\)。(這個在 \(\text{split}(x,y)\) 前就可以判了

  2. \(fa(x)=y\),否則意味着 \(x,y\) 雖然在同一個 Splay 中卻沒有連邊。

  3. \(rc(x)=0\),否則意味着 \(x,y\) 的路徑上有其他的鏈。

void cut(int x,int y){
    if(findroot(x)!=findroot(y)) return ;
    split(x,y);
    if(fa[x]==y&&!rc[x]) fa[x]=lc[y]=0,pushup(y);
} 

四、模板

\(\text{rotate}(x)\) 在修改 \(x\) 的祖父的兒子時,必須判斷 \(x\) 的父親是否為所在 Splay 的根,否則 \(0\) 的兒子會被定義為 \(x\),而 \(x\) 則永遠不可能成為根節點,在 \(\text{splay}\) 函數中將會無限循環。

以下代碼中,\(y=fa(x),z=fa(y)\),若 \(y\) 為根節點,則 \(lc(z)\neq y\)\(rc(z)\neq y\),所以不會令 \(lc(z)=x\)\(rc(z)=x\),不存在這個問題。

//Luogu P3690
#include<bits/stdc++.h>
#define int long long
using namespace std;
const int N=1e5+5;
int n,m,val[N],opt,x,y,lc[N],rc[N],fa[N],s[N],tag[N];
void pushup(int p){
    s[p]=s[lc[p]]^s[rc[p]]^val[p];
}
void rev(int p){
    swap(lc[p],rc[p]),tag[p]^=1;
}
void pushdown(int p){
    if(!tag[p]) return ;
    rev(lc[p]),rev(rc[p]),tag[p]=0;
}
bool isroot(int x){
    return lc[fa[x]]!=x&&rc[fa[x]]!=x; 
}
void rotate(int x){
    int y=fa[x],z=fa[y];
    pushdown(y),pushdown(x);
    if(x==lc[y]) lc[y]=rc[x],fa[rc[x]]=y,rc[x]=y;    //zig(x)
    else rc[y]=lc[x],fa[lc[x]]=y,lc[x]=y;    //zag(x)
    fa[y]=x,fa[x]=z;
    if(y==lc[z]) lc[z]=x;
    else if(y==rc[z]) rc[z]=x;
    pushup(y),pushup(x);
}
void splay(int x){    //所有操作的目標都是對應 Splay 的根,只需傳一個參數 
    pushdown(x);
    while(!isroot(x)){
        int y=fa[x],z=fa[y];
        if(!isroot(y)) rotate((x==lc[y])==(y==lc[z])?y:x);  
        rotate(x);
    }
}
void access(int x){
    for(int y=0;x;y=x,x=fa[x])
        splay(x),rc[x]=y,pushup(x);
}
void makeroot(int x){
    access(x),splay(x),rev(x);
}
int findroot(int x){
    access(x),splay(x);
    while(lc[x]) pushdown(x),x=lc[x];
    return splay(x),x;
}
void split(int x,int y){
    makeroot(x),access(y),splay(y);
} 
void link(int x,int y){
    makeroot(x);
    if(findroot(y)!=x) fa[x]=y;
} 
void cut(int x,int y){
    if(findroot(x)!=findroot(y)) return ;
    split(x,y);
    if(fa[x]==y&&!rc[x]) fa[x]=lc[y]=0,pushup(y);
} 
signed main(){
    scanf("%lld%lld",&n,&m);
    for(int i=1;i<=n;i++)
        scanf("%lld",&val[i]);
    while(m--){
        scanf("%lld%lld%lld",&opt,&x,&y);
        if(!opt) split(x,y),printf("%lld\n",s[y]);
        else if(opt==1) link(x,y);
        else if(opt==2) cut(x,y);
        else splay(x),val[x]=y,pushup(x);
    } 
    return 0;
}

注意:\(\text{split}\) 要保證兩點連通,\(\text{cut}\) 要保證兩點直接相連,\(\text{link}\) 要保證兩點不連通。不要少了 \(pushdown\)\(pushup\)。不然可能會出現玄學錯誤。

Link-Cut Tree 的基本操作復雜度為均攤 \(\mathcal{O}(\log n)\)

五、應用

LCT 的一些基本應用。可參考 OI Wiki

  • 維護樹鏈信息:\(\text{split}(x,y)\) 然后轉化為 Splay 操作。

  • 維護連通性:\(\text{findroot}(x)\) 判斷一下。算是並查集的升級版。

  • 維護邊雙連通分量:將邊雙連通分量縮成點,用並查集維護。每次添加一條邊,若所連接的兩點不連通就 \(\text{link}\),否則就意味着有環,把環縮成一個點,並查集也合並在一起。

  • 維護邊權:拆邊。對於每條邊 \((x,y)\) 建立一個對應點 \(z\),連邊的時候就 \(\text{link}(x,z),\text{link}(z,y)\),刪邊同理。數組別開小了。

  • 維護子樹信息:統計虛子樹的信息。

一些套路:刪邊操作不好進行,則可考慮離線逆向進行操作,改刪邊為加邊。

只出現合並而不出現分離的情況下,因為 \(\text{findroot}\) 較慢,有時可以考慮用並查集(可以用於卡常?)。

維護子樹信息

把維護子樹信息單獨拿出來講。LCT 並不擅長維護子樹信息。

虛兒子:即父親為 \(x\),但 \(x\) 在 Splay 中的左右兒子並不包含它的節點。(與 \(x\) 在原圖中有直接連邊但和 \(x\) 不在同一個 Splay 中的節點)

LCT“認父不認子”,不方便直接進行子樹的統計。子樹可以分為 實子樹 和 虛子樹。

我們已經可以通過 Splay 知道實子樹(原樹中的實鏈)的信息總和。考慮統計一個節點 \(x\) 所有虛兒子代表的子樹的貢獻。

\(sz(x)\) 表示節點 \(x\) 的子樹大小(包括實子樹大小和虛子樹大小),\(sz_2(x)\) 表示節點 \(x\) 所有虛兒子(通過虛邊指向 \(x\))代表的子樹的大小。

由於 實子樹 \(+\) 虛子樹 \(+\) 自己 \(=\) 整個子樹,所以 \(sz(x)=sz(lc(x))+sz(rc(x))+sz_2(x)+1\)

在所有可能導致虛兒子關系變化的地方(\(\text{pushup},\text{access},\text{link}\))都要更新 \(sz_2(x)\)

void pushup(int p){
    sz[p]=sz[lc[p]]+sz[rc[p]]+sz2[p]+1;
}
void access(int x){
    for(int y=0;x;y=x,x=fa[x])
        splay(x),sz2[x]+=sz[rc[x]]-sz[y],rc[x]=y,pushup(x);    //x 與其原右兒子的連邊和 x 和新右兒子的連邊的虛實情況發生了變化。加上新虛邊所連的子樹的貢獻,減去剛剛邊長實邊所連的子樹的貢獻 
}
void link(int x,int y){
    makeroot(x);
    if(findroot(y)!=x) splay(y),fa[x]=y,sz2[y]+=sz[x],pushup(y);    //y 多了一個虛兒子 x。splay(y) 后 sz2[y] 再加 sz[x] 就不會影響信息的正確性了(y 已沒有祖先) 
} 

LCT 維護子樹信息時,新建一個附加值存儲虛子樹的貢獻,在統計時將其加入本節點的答案,在改變邊的虛實時及時維護。

注意不能直接維護子樹最值,因為在將一條虛邊變成實邊時要排除原先虛邊的貢獻。可以對每個節點開一個平衡樹維護節點的虛子樹中的最值,以便進行查詢和更改。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM