LCT(Link Cut Tree)總結
概念、性質簡述
首先介紹一下鏈剖分的概念
鏈剖分,是指一類對樹的邊進行輕重劃分的操作,這樣做的目的是為了減少某些鏈上的修改、查詢等操作的複雜度。
目前總共有三類:重鏈剖分,實鏈剖分和並不常見的長鏈剖分。
重鏈剖分
實際上我們經常講的樹剖,就是重鏈剖分的常用稱呼。
對於每個點,選擇最大的子樹,將這條連邊劃分為重邊,而連向其他子樹的邊劃分為輕邊。
若干重邊連線在一起構成重鏈,用樹狀陣列或線段樹等靜態資料結構維護。
這裡就不贅述;
實鏈剖分
同樣將某一個兒子的連邊劃分為實邊,而連向其他子樹的邊劃分為虛邊。
區別在於虛實是可以動態變化的,因此要使用更高階、更靈活的Splay來維護每一條由若干實邊連線而成的實鏈。
基於性質更加優秀的實鏈剖分,LCT(Link-Cut Tree)應運而生。
LCT維護的物件其實是一個森林。
在實鏈剖分的基礎下,LCT資磁更多的操作
同樣將某一個兒子的連邊劃分為實邊,而連向其他子樹的邊劃分為虛邊。
區別在於虛實是可以動態變化的,因此要使用更高階、更靈活的Splay來維護每一條由若干實邊連線而成的實鏈。
基於性質更加優秀的實鏈剖分,LCT(Link-Cut Tree)應運而生。
LCT維護的物件其實是一個森林。
在實鏈剖分的基礎下,LCT資磁更多的操作
- 查詢、修改鏈上的資訊(最值,總和等)
- 隨意指定原樹的根(即換根)
- 動態連邊、刪邊
- 合併兩棵樹、分離一棵樹
- 動態維護連通性
- 更多意想不到的操作(可以往下滑一滑)
LCT的主要性質如下:
-
每一個Splay維護的是一條從上到下按在原樹中深度嚴格遞增的路徑,且中序遍歷Splay得到的每個點的深度序列嚴格遞增。
比如有一棵樹,根節點為
{1−2},{3}
{1−3},{2}
{1},{2},{3}(每個集合表示一個Splay)
而不能把1,2,3同放在一個Splay中(存在深度相同的點)
每個節點包含且僅包含於一個Splay中
邊分為實邊和虛邊,實邊包含在Splay中,而虛邊總是由一棵Splay指向另一個節點(指向該Splay中中序遍歷最靠前的點在原樹中的父親)。
因為性質2,當某點在原樹中有多個兒子時,只能向其中一個兒子拉一條實鏈(只認一個兒子),而其它兒子是不能在這個Splay中的。
那麼為了保持樹的形狀,我們要讓到其它兒子的邊變為虛邊,由對應兒子所屬的Splay的根節點的父親指向該點,而從該點並不能直接訪問該兒子(認父不認子)。
各種操作
access(x)
LCT核心操作,也是最難理解的操作。其它所有的操作都是在此基礎上完成的。
因為性質3,我們不能總是保證兩個點之間的路徑是直接連通的(在一個Splay上)。
access即定義為打通根節點到指定節點的實鏈,使得一條中序遍歷以根開始、以指定點結束的Splay出現。
所以還是來幾張圖吧。
下面的圖片參考YangZhe的論文
有一棵樹,假設一開始實邊和虛邊是這樣劃分的(虛線為虛邊)
那麼所構成的LCT可能會長這樣(綠框中為一個Splay,可能不會長這樣,但只要滿足中序遍歷按深度遞增(性質1)就對結果無影響)
現在我們要access(N),把A−N的路徑拉起來變成一條Splay。
因為性質2,該路徑上其它鏈都要給這條鏈讓路,也就是把每個點到該路徑以外的實邊變虛。
所以我們希望虛實邊重新劃分成這樣。
然後怎麼實現呢?
我們要一步步往上拉。
首先把splay(N),使之成為當前Splay中的根。
為了滿足性質2,原來N−O的重邊要變輕。
因為按深度O在N的下面,在Splay中O在N的右子樹中,所以直接單方面將N的右兒子置為0(認父不認子)
然後就變成了這樣——
我們接著把N所屬Splay的虛邊指向的I(在原樹上是L的父親)也轉到它所屬Splay的根,splay(I)。
原來在II下方的重邊I−K要變輕(同樣是將右兒子去掉)。
這時候I−L就可以變重了。因為L肯定是在I下方的(剛才L所屬Splay指向了I),所以I的右兒子置為N,滿足性質1。
然後就變成了這樣——
I指向H,接著splay(H),H的右兒子置為I。
H指向A,接著splay(A),A的右兒子置為H。
A−N的路徑已經在一個Splay中了,大功告成!
程式碼其實很簡單。。。。。。迴圈處理,只有四步——
- 轉到根;
- 換兒子;
- 更新資訊;
- 當前操作點切換為輕邊所指的父親,轉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)後xx在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);//此處的問題詳見下方update(關於findroot中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的邊斷開。
如果題目保證斷邊合法,倒是很方便。
使xx為根後,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的各種基本性質吧!
正確姿勢——先判一下連通性,再看看x,yx,y是否有父子關係,還要看xx是否有右兒子。
因為access(y)以後,假如y與x在同一Splay中而沒有直接連邊,那麼這條路徑上就一定會有其它點,在中序遍歷序列中的位置會介於x與y之間。
那麼可能x的父親就不是y了。
也可能x的父親還是y,那麼其它的點就在x的右子樹中,就像這樣
只有三個條件都滿足,才可以斷掉。
inline bool cut(int x,int y){ makeroot(x); if(findroot(y)!=x||f[x]!=y||c[x][1])return 0; f[x]=c[y][0]=0; pushup(y); return 1; }
如果維護了sizesize,還可以換一種判斷
inline bool cut(int x,int y){ makeroot(x); if(findroot(y)!=x||sz[y]>2)return 0; f[x]=c[y][0]=0; pushup(y); }
解釋一下,如果他們有直接連邊的話,access(y)以後,為了滿足性質1,該Splay只會剩下x,y兩個點了。
反過來說,如果有其它的點,size不就大於2了麼?
其實,還有一些LCT中的Splay的操作,跟我們以往學習的純Splay的某些操作細節不甚相同。
包括splay(x),rotate(x),nroot(x)(看到許多版本LCT寫的是isroot(x),但我覺得反過來會方便些)
這些區別之處詳見下面的模板題註釋。
update(關於findroot中pushdown的說明)
找根的時候,當然不能保證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的左右兒子還是反的
再次update,發現這種問題還是可以避免的,若用這種pushdown,findroot可以寫,這樣寫就好啦
inline int findroot(int x){ access(x);splay(x); pushdown(x); while(lc)pushdown(x=lc); splay(x); return x; }
所以此總結以及下面模板裡的pushdown,常數大了一點點,卻是更穩妥、嚴謹的寫法
//pushr同上方makeroot部分 void