LCT總結——概念篇+洛谷P3690[模板]Link Cut Tree(動態樹)(LCT,Splay)

Flash_Hu發表於2018-01-21

為了優化體驗(其實是強迫症),蒟蒻把總結拆成了兩篇,方便不同學習階段的Dalao們切換。

LCT總結——應用篇戳這裡

概念、性質簡述

首先介紹一下鏈剖分的概念(感謝laofu的講課)
鏈剖分,是指一類對樹的邊進行輕重劃分的操作,這樣做的目的是為了減少某些鏈上的修改、查詢等操作的複雜度。
目前總共有三類:重鏈剖分,實鏈剖分和並不常見的長鏈剖分

重鏈剖分

實際上我們經常講的樹剖,就是重鏈剖分的常用稱呼。
對於每個點,選擇最大的子樹,將這條連邊劃分為重邊,而連向其他子樹的邊劃分為輕邊。
若干重邊連線在一起構成重鏈,用樹狀陣列或線段樹等靜態資料結構維護。
至於有怎樣優秀的性質等等,不在本總結的討論範疇了(其實是因為本蒟蒻連樹剖都不會)

實鏈剖分

同樣將某一個兒子的連邊劃分為實邊,而連向其他子樹的邊劃分為虛邊。
區別在於虛實是可以動態變化的,因此要使用更高階、更靈活的Splay來維護每一條由若干實邊連線而成的實鏈。
基於性質更加優秀的實鏈剖分,LCT(Link-Cut Tree)應運而生。
LCT維護的物件其實是一個森林。
在實鏈剖分的基礎下,LCT資磁更多的操作

  • 查詢、修改鏈上的資訊(最值,總和等)
  • 隨意指定原樹的根(即換根)
  • 動態連邊、刪邊
  • 合併兩棵樹、分離一棵樹(跟上面不是一毛一樣嗎
  • 動態維護連通性
  • 更多意想不到的操作(可以往下滑一滑)

想學Splay的話,推薦巨佬yyb的部落格


LCT的主要性質如下:

  1. 每一個Splay維護的是一條從上到下按在原樹中深度嚴格遞增的路徑,且中序遍歷Splay得到的每個點的深度序列嚴格遞增。
    是不是有點抽象哈
    比如有一棵樹,根節點為\(1\)(深度1),有兩個兒子\(2,3\)(深度2),那麼Splay有\(3\)種構成方式:
    \(\{1-2\},\{3\}\)
    \(\{1-3\},\{2\}\)
    \(\{1\},\{2\},\{3\}\)(每個集合表示一個Splay)
    而不能把\(1,2,3\)同放在一個Splay中(存在深度相同的點)

  2. 每個節點包含且僅包含於一個Splay中

  3. 邊分為實邊和虛邊,實邊包含在Splay中,而虛邊總是由一棵Splay指向另一個節點(指向該Splay中中序遍歷最靠前的點在原樹中的父親)。
    因為性質2,當某點在原樹中有多個兒子時,只能向其中一個兒子拉一條實鏈(只認一個兒子),而其它兒子是不能在這個Splay中的。
    那麼為了保持樹的形狀,我們要讓到其它兒子的邊變為虛邊,由對應兒子所屬的Splay的根節點的父親指向該點,而從該點並不能直接訪問該兒子(認父不認子)。

各種操作

\(access(x)\)

LCT核心操作,也是最難理解的操作。其它所有的操作都是在此基礎上完成的。
因為性質3,我們不能總是保證兩個點之間的路徑是直接連通的(在一個Splay上)。
access即定義為打通根節點到指定節點的實鏈,使得一條中序遍歷以根開始、以指定點結束的Splay出現。
蒟蒻深知沒圖的痛苦qwq
所以還是來幾張圖吧。
下面的圖片參考YangZhe的論文
有一棵樹,假設一開始實邊和虛邊是這樣劃分的(虛線為虛邊)
LCT總結——概念篇+洛谷P3690[模板]Link Cut Tree(動態樹)(LCT,Splay)

那麼所構成的LCT可能會長這樣(綠框中為一個Splay,可能不會長這樣,但只要滿足中序遍歷按深度遞增(性質1)就對結果無影響)
LCT總結——概念篇+洛谷P3690[模板]Link Cut Tree(動態樹)(LCT,Splay)
現在我們要\(access(N)\),把\(A-N\)的路徑拉起來變成一條Splay。
因為性質2,該路徑上其它鏈都要給這條鏈讓路,也就是把每個點到該路徑以外的實邊變虛。
所以我們希望虛實邊重新劃分成這樣。
LCT總結——概念篇+洛谷P3690[模板]Link Cut Tree(動態樹)(LCT,Splay)
然後怎麼實現呢?
我們要一步步往上拉。
首先把\(splay(N)\),使之成為當前Splay中的根。
為了滿足性質2,原來\(N-O\)的重邊要變輕。
因為按深度\(O\)\(N\)的下面,在Splay中\(O\)\(N\)的右子樹中,所以直接單方面將\(N\)的右兒子置為\(0\)(認父不認子)
然後就變成了這樣——
LCT總結——概念篇+洛谷P3690[模板]Link Cut Tree(動態樹)(LCT,Splay)

我們接著把\(N\)所屬Splay的虛邊指向的\(I\)(在原樹上是\(L\)的父親)也轉到它所屬Splay的根,\(splay(I)\)
原來在\(I\)下方的重邊\(I-K\)要變輕(同樣是將右兒子去掉)。
這時候\(I-L\)就可以變重了。因為\(L\)肯定是在\(I\)下方的(剛才\(L\)所屬Splay指向了\(I\)),所以I的右兒子置為\(N\),滿足性質1。
然後就變成了這樣——
LCT總結——概念篇+洛谷P3690[模板]Link Cut Tree(動態樹)(LCT,Splay)

\(I\)指向\(H\),接著\(splay(H)\)\(H\)的右兒子置為\(I\)
LCT總結——概念篇+洛谷P3690[模板]Link Cut Tree(動態樹)(LCT,Splay)

\(H\)指向\(A\),接著\(splay(A)\)\(A\)的右兒子置為\(H\)
LCT總結——概念篇+洛谷P3690[模板]Link Cut Tree(動態樹)(LCT,Splay)

\(A-N\)的路徑已經在一個Splay中了,大功告成!
程式碼其實很簡單。。。。。。迴圈處理,只有四步——

  1. 轉到根;
  2. 換兒子;
  3. 更新資訊;
  4. 當前操作點切換為輕邊所指的父親,轉1
inline void access(int x){
    for(int y=0;x;y=x,x=f[x])
        splay(x),c[x][1]=y,pushup(x);//兒子變了,需要及時上傳資訊
}

\(makeroot(x)\)

只是把根到某個節點的路徑拉起來並不能滿足我們的需要。更多時候,我們要獲取指定兩個節點之間的路徑資訊。
然而一定會出現路徑不能滿足按深度嚴格遞增的要求的情況。根據性質1,這樣的路徑不能在一個Splay中。
Then what can we do?
\(makeroot\)定義為換根,讓指定點成為原樹的根。
這時候就利用到\(access(x)\)和Splay的翻轉操作。
\(access(x)\)\(x\)在Splay中一定是深度最大的點對吧。
\(splay(x)\)後,\(x\)在Splay中將沒有右子樹(性質1)。於是翻轉整個Splay,使得所有點的深度都倒過來了,\(x\)沒了左子樹,反倒成了深度最小的點(根節點),達到了我們的目的。
程式碼

inline void pushr(int x){//Splay區間翻轉操作
    swap(c[x][0],c[x][1]);
    r[x]^=1;//r為區間翻轉懶標記陣列
}
inline void makeroot(int x){
    access(x);splay(x);
    pushr(x);
}

關於pushdown和makeroot的一個相關的小問題詳見下方update(關於pushdown的說明)

\(findroot(x)\)

\(x\)所在原樹的樹根,主要用來判斷兩點之間的連通性(findroot(x)==findroot(y)表明\(x,y\)在同一棵樹中)
程式碼:

inline int findroot(R x){
    access(x); splay(x);
    while(c[x][0])pushdown(x),x=c[x][0];
//如要獲得正確的原樹樹根,一定pushdown!詳見下方update(關於findroot中pushdown的說明)
    splay(x);//保證複雜度
    return x;
}

同樣利用性質1,不停找左兒子,因為其深度一定比當前點深度小。

\(split(x,y)\)

神奇的\(makeroot\)已經出現,我們終於可以訪問指定的一條在原樹中的鏈啦!
split(x,y)定義為拉出\(x-y\)的路徑成為一個Splay(本蒟蒻以\(y\)作為該Splay的根)
程式碼

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

x成為了根,那麼x到y的路徑就可以用\(access(y)\)直接拉出來了,將y轉到Splay根後,我們就可以直接通過訪問\(y\)來獲取該路徑的有關資訊

\(link(x,y)\)

連一條\(x-y\)的邊(本蒟蒻使\(x\)的父親指向\(y\),連一條輕邊)
程式碼

inline bool link(int x,int y){
    makeroot(x);
    if(findroot(y)==x)return 0;//兩點已經在同一子樹中,再連邊不合法
    f[x]=y;
    return 1;
}

如果題目保證連邊合法,程式碼就可以更簡單

inline void link(int x,int y){
    makeroot(x);
    f[x]=y;
}

\(cut(x,y)\)

\(x-y\)的邊斷開。
如果題目保證斷邊合法,倒是很方便。
使\(x\)為根後,\(y\)的父親一定指向\(x\),深度相差一定是\(1\)。當\(access(y),splay(y)\)以後,\(x\)一定是\(y\)的左兒子,直接雙向斷開連線

inline void cut(int x,int y){
    split(x,y);
    f[x]=c[y][0]=0;
    pushup(y);//少了個兒子,也要上傳一下
}

那如果不一定存在該邊呢?
充分利用好Splay和LCT的各種基本性質吧!
正確姿勢——先判一下連通性(注意\(findroot(y)\)以後\(x\)成了根),再看看\(x,y\)是否有父子關係,還要看\(y\)是否有左兒子。
因為\(access(y)\)以後,假如y與x在同一Splay中而沒有直接連邊,那麼這條路徑上就一定會有其它點,在中序遍歷序列中的位置會介於\(x\)\(y\)之間。
那麼可能\(y\)的父親就不是\(x\)了。
也可能\(y\)的父親還是\(x\),那麼其它的點就在\(y\)的左子樹中

只有三個條件都滿足,才可以斷掉。

inline bool cut(int x,int y){
    makeroot(x);
    if(findroot(y)!=x||f[y]!=x||c[y][0])return 0;
    f[y]=c[x][1]=0;//x在findroot(y)後被轉到了根
    pushup(x);
    return 1;
}

如果維護了\(size\),還可以換一種判斷

inline bool cut(int x,int y){
    makeroot(x);
    if(findroot(y)!=x||sz[x]>2)return 0;
    f[y]=c[x][1]=0;
    pushup(x);
    return 1;
}

解釋一下,如果他們有直接連邊的話,\(access(y)\)以後,為了滿足性質1,該Splay只會剩下\(x,y\)兩個點了。
反過來說,如果有其它的點,\(size\)不就大於\(2\)了麼?


其實,還有一些LCT中的Splay的操作,跟我們以往學習的純Splay的某些操作細節不甚相同。
包括\(splay(x),rotate(x),nroot(x)\)(看到許多版本LCT寫的是\(isroot(x)\),但我覺得反過來會方便些)
這些區別之處詳見下面的模板題註釋。

update(關於findroot中pushdown的說明)

蒟蒻真的一時沒注意這個問題。。。。。。Splay根本沒學好
找根的時候,當然不能保證Splay中到根的路徑上的翻轉標記全放掉。
所以最好把pushdown寫上。
Candy巨佬的總結對pushdown問題有詳細的分析
只不過蒟蒻後來經常習慣這樣判連通性(我也不知道怎麼養成的

makeroot(x);
if(findroot(y)==x)//後續省略

這樣好像沒出過問題,那應該可以證明是沒問題的(makeroot保證了x在LCT的頂端,access(y)+splay(y)以後,假如x,y在一個Splay裡,那x到y的路徑一定全部放完了標記)
導致很久沒有發現錯誤。。。。。。
另外提一下,假如LCT題目在維護連通性的情況中只可能出現合併而不會出現分離的話,其實可以用並查集哦!(實踐證明findroot很慢)
這樣的例子有不少,比如下面“維護鏈上的邊權資訊”部分的兩道題都是的。
甚至聽到Julao們說有少量題目還專門卡這個常數。。。。。。XZY巨佬的部落格就提到了

update(關於pushdown的說明)

我pushdown和makeroot有時候會這樣寫,常數小一點

void pushdown(int x){
    if(r[x]){
        r[x]=0;
        int t=c[x][0];
        r[c[x][0]=c[x][1]]^=1;
        r[c[x][1]=t]^=1;
    }
}
void makeroot(int x){
    access(x);splay(x);
    r[x]^=1;
}

這種寫法等於說當x有懶標記時,x的左右兒子還是反的
那麼如果findroot裡實在要寫pushdown,那麼這種pushdown就會出現問題(參考評論區@ zjp_shadow巨佬的指正)
再次update,蒟蒻發現這種問題還是可以避免的,若用這種pushdown,findroot這樣寫就好啦

inline int findroot(int x){
    access(x);splay(x);
    pushdown(x);
    while(lc)pushdown(x=lc);
    splay(x);
    return x;
}

當題目中維護的資訊與左右兒子順序有關的時候,pushdown如果用這種不嚴謹寫法會是錯的(比如[NOI2005]維護數列(這是Splay題)和洛谷P3613 睡覺困難綜合徵)
再次update,夏丶沐瑾巨佬指出這種問題也是可以避免的,把pushup這樣寫就好啦

inline void pushup(int x){
    pushdown(lc);pushdown(rc);//加上兩個
    //......
}

所以此總結以及下面模板裡的pushdown,常數大了一點點,卻是更穩妥、嚴謹的寫法

//pushr同上方makeroot部分
void pushdown(int x){
    if(r[x]){
        if(c[x][0])pushr(c[x][0]);//copy自模板,然後發現if可以不寫
        if(c[x][1])pushr(c[x][1]);
        r[x]=0;
    }
}
void makeroot(int x){
    access(x);splay(x);
    pushr(x);//可以看到兩種寫法造成makeroot都是不一樣的
}

這種寫法等於說當x有懶標記時,x的左右兒子已經放到正確的位置了,只是兒子的兒子還是反的
那麼這樣就不會出問題啦
兩種寫法差別還確實有點大呢

模板

洛谷P3690 【模板】Link Cut Tree (動態樹)(點選進入題目)
最基本的LCT操作都在這裡,也沒有更多額外的複雜操作了,確實很模板。

#include<bits/stdc++.h>
#define R register int
#define I inline void
#define G if(++ip==ie)if(fread(ip=buf,1,SZ,stdin))
#define lc c[x][0]
#define rc c[x][1]
using namespace std;
const int SZ=1<<19,N=3e5+9;
char buf[SZ],*ie=buf+SZ,*ip=ie-1;
inline int in(){
    G;while(*ip<'-')G;
    R x=*ip&15;G;
    while(*ip>'-'){x*=10;x+=*ip&15;G;}
    return x;
}
int f[N],c[N][2],v[N],s[N],st[N];
bool r[N];
inline bool nroot(R x){//判斷節點是否為一個Splay的根(與普通Splay的區別1)
    return c[f[x]][0]==x||c[f[x]][1]==x;
}//原理很簡單,如果連的是輕邊,他的父親的兒子裡沒有它
I pushup(R x){//上傳資訊
    s[x]=s[lc]^s[rc]^v[x];
}
I pushr(R x){R t=lc;lc=rc;rc=t;r[x]^=1;}//翻轉操作
I pushdown(R x){//判斷並釋放懶標記
    if(r[x]){
        if(lc)pushr(lc);
        if(rc)pushr(rc);
        r[x]=0;
    }
}
I rotate(R x){//一次旋轉
    R y=f[x],z=f[y],k=c[y][1]==x,w=c[x][!k];
    if(nroot(y))c[z][c[z][1]==y]=x;c[x][!k]=y;c[y][k]=w;//額外注意if(nroot(y))語句,此處不判斷會引起致命錯誤(與普通Splay的區別2)
    if(w)f[w]=y;f[y]=x;f[x]=z;
    pushup(y);
}
I splay(R x){//只傳了一個引數,因為所有操作的目標都是該Splay的根(與普通Splay的區別3)
    R y=x,z=0;
    st[++z]=y;//st為棧,暫存當前點到根的整條路徑,pushdown時一定要從上往下放標記(與普通Splay的區別4)
    while(nroot(y))st[++z]=y=f[y];
    while(z)pushdown(st[z--]);
    while(nroot(x)){
        y=f[x];z=f[y];
        if(nroot(y))
            rotate((c[y][0]==x)^(c[z][0]==y)?x:y);
        rotate(x);
    }
    pushup(x);
}
/*當然了,其實利用函式堆疊也很方便,代替上面的手工棧,就像這樣
I pushall(R x){
    if(nroot(x))pushall(f[x]);
    pushdown(x);
}*/
I access(R x){//訪問
    for(R y=0;x;x=f[y=x])
        splay(x),rc=y,pushup(x);
}
I makeroot(R x){//換根
    access(x);splay(x);
    pushr(x);
}
int findroot(R x){//找根(在真實的樹中的)
    access(x);splay(x);
    while(lc)pushdown(x),x=lc;
    splay(x);
    return x;
}
I split(R x,R y){//提取路徑
    makeroot(x);
    access(y);splay(y);
}
I link(R x,R y){//連邊
    makeroot(x);
    if(findroot(y)!=x)f[x]=y;
}
I cut(R x,R y){//斷邊
    makeroot(x);
    if(findroot(y)==x&&f[y]==x&&!c[y][0]){
        f[y]=c[x][1]=0;
        pushup(x);
    }
}
int main()
{
    R n=in(),m=in();
    for(R i=1;i<=n;++i)v[i]=in();
    while(m--){
        R type=in(),x=in(),y=in();
        switch(type){
        case 0:split(x,y);printf("%d\n",s[y]);break;
        case 1:link(x,y);break;
        case 2:cut(x,y);break;
        case 3:splay(x);v[x]=y;//先把x轉上去再改,不然會影響Splay資訊的正確性
        }
    }
    return 0;
}

相關文章