1. 程式人生 > 實用技巧 >「演算法筆記」Link-Cut Tree

「演算法筆記」Link-Cut Tree

一、簡介

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

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

實鏈剖分:

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

因為實鏈剖分靈活且可變(虛實可以動態變化),LCT 採用 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)\)。