LCT學習筆記
前言
老呂又講了LCT,據他說特別簡單,於是就強行灌輸(霧。
打字兩分鍾,畫圖兩小時。。。
引入
-
維護一棵樹,維護以下的操作:
- 鏈上求和
- 鏈上求最值
- 鏈上修改
- 子樹修改
- 子樹求和
可能你第一眼想的是樹鏈剖分,的確,這都是樹鏈剖分的基本操作。
但是如果再增加一些操作呢
-
- 換根
- 斷開樹上的一條邊
- 連接兩個點,保證連接后仍是一棵樹
線段樹就不好做了,於是我們的 LCT 就出場了。
注:樹剖也是可以換根的,可能我上面說的不清楚,具體怎么換可以參考這篇博客。我只是說不太好做。(可能我太菜了)。
簡介
LCT,全稱 Link-Cut Tree,一種動態樹,用來解決動態的樹上問題。說它是樹也不大准確,它維護的其實是一個森林。據我不可信的猜測,這個名字可能是由於這個數據結構特有的特色來命名的,也就是 Link,Cut,支持樹上的刪邊,加邊。這一點是普通線段樹沒法做到的,LCT的 access 也是他的一大特色,也是常用的一個函數。(個人感覺)
構造
我們在學習樹鏈剖分的時候,就知道,將鏈進行剖分,主要有三種形式:
1.重鏈剖分。
只要是按照子樹大小進行剖分,就是把兒子數最多的兒子當做重兒子,重兒子連成的鏈叫做重鏈。
2.長鏈剖分。
並不是很常見,我也不到了解。
3.實鏈剖分。
將樹上的鏈分成虛實兩種,一個點最多只有一個孩子作為實孩子。連接實孩子的稱為實邊,實邊組成的鏈稱為實鏈。
我們在 LCT 中就是采用的是實鏈剖分,其中,實孩子是不固定的,它可以通過我們的修改而發生改變,我想,這也是 LCT 的一個動態,當然,其主要的動態還是動態刪邊和加邊。因此,我們需要選用更靈活的數據結構。
維護一條鏈,理論上 FHQ-Treap 和 Splay 都是可以的,但是 FHQ-Treap 要比 Splay 多一個 \(\log\) ,而且網絡上的題解大部分都寫的是 Splay,因此,這里推薦 Splay 的寫法,不會 Splay 的可以去學習一下,因為這是非常重要的一部分。因為我也沒寫過FHQ-Teap的。
我們在之前說過,一個點頂多只有一個實孩子,也就是說一條實鏈上,每個節點的深度在原樹中都是不同的,因此,我們把深度作為關鍵字用 Splay 維護,對於一個節點,它的左兒子的深度要比它小,右兒子的深度要比它大。
這里補充一下兩個概念:
原樹:也就是我們對其進行剖分的樹。在我們實現的時候,原樹是 不存儲 的,只是為了方便我們理解。
輔助樹:也就是一棵splay,或者說一些 Splay。
-
它維護的是原樹中的一條實鏈,在程序中真正操作的都是輔助樹。中序遍歷這些點的時候,其對應的就是原樹中的一條鏈。
-
在 LCT 中每棵 Splay 的根節點的指向 原樹 中 這條鏈 的鏈頂的父親節點(即鏈最頂端的點的父親節點)。主要的特點在於兒子認父親,而父親不認兒子,對應原樹的一條 虛邊。
基礎操作
我們先造一顆樹。這是一棵原樹。
我們選擇一些邊作為虛邊,選擇一些邊作為實邊。
然后,讓我們畫出輔助樹。
我們找出其中的 Splay,大概就是這個亞子。
了解完這些之后,我們開始今天的重點。
變量聲明
我習慣將變量放到結構體里。
-
tree.ch[0/1]
左右兒子 -
f[N]
父親 -
tree.sum
路徑權值和 -
tree.val
點權 -
tree.laz[N]
翻轉標記
主要的函數:
-
link(x,y)
連接兩個點 -
cut(x,y)
:斷開兩個點間的邊 -
access(x)
:把 \(x\) 點下面的實邊斷開,並把 \(x\) 點一路向上邊到樹的根 -
makeroot(x)
:把 \(x\) 點變為樹的根 -
find(x)
:查找 \(x\) 所在樹的根 -
isroot(x)
:判斷 \(x\) 是否是輔助樹的根 -
split(x,y)
: 提取出 \(x,y\) 間的路徑 -
update(x,y)
: 修改 \(x\) 的點權為 \(y\)。當然還有
rotate
,splay
,pushup
,pushdown
,不過這些都是線段樹或 Splay 的基本操作,就不詳細展開了。
accsee
作用:斷開當前點連的實鏈,到根節點連一條實鏈。
方法:把 \(x\) 點伸展到splay的根,再把它的右子樹連到 \(t\) , \(t\) 的初值為 0,也就了與下一層的實鏈斷開了,然后 \(t\) 更新為 \(x\),而 \(x\) 更新為 \(x\) 的父親,繼續向上連接。因為我們現在的連接,父親認兒子,兒子認父親,一直到根,也就到根連接了一條實鏈。
假設我們 \(access(9)\) ,我們的圖就變成了這樣。原諒我不會制作動圖,沒有詳細的變化過程。
void access(int p)
{
int t=0;//因為當前點是這條鏈的最后一個點,旋轉到根之后右邊的點就是當前點之后的點,也就是要斷開的點
while(p)
{
splay(p);//把 p 伸展到根節點,
rson(p)=t;//不斷讓父親向它連邊,也就是連上了實邊
t=p;
p=f[p];
push_up(p);
}
}
makeroot
作用:把x點變為所在原樹的根。
方法:首先的把 \(x\) 點 \(access\) 到根,把 \(x\) 點到根就變成了一個 Splay,然后把 \(x\) 伸展到根。由於 \(x\) 點是輔助樹在原樹中最下面的點,所以這時其它的點都在 \(x\) 的左子樹上,只要把左子樹變成右子樹,\(x\) 也就變成了根。
我們上面 \(accsee(9)\) ,不妨就繼續讓 \(9\) 變成根。先 Splay 一下。
void makeroot(int p)//是當前點變成原樹里的根節點
{
access(p);//到根節點連實鏈,也就是一顆 splay
splay(p);//將當前點轉到根節點
tree[p].laz^=1//由於 x 點是最后一個,當前為根節點時所有的點都在他的左邊,^一下讓所有的點都在他右邊,就變成了根了
}
findtoot
作用:查找原樹的根
我們想一下,在輔助樹中,怎么才能找到原樹的根呢?
我們發現,位於最頂部的 Splay,它的最左邊的孩子為原樹的根,因為我們要保證 Splay 的形態,先要保證它的中序遍歷和原樹一致。
方法:首先把 \(x\) 點 \(access\) 到原樹的根,並把它 Splay 到輔助樹的根,這時原樹的根就是 \(x\) 左子樹中最左側的點。
再借用上面的 \(access(9)\) 和 \(Spaly(9)\)
int find(int x)//找原樹的根
{
access(x);//x到根建一顆splay
splay(x);//將 x 伸展到根節點
while(lson(x)) push_down(x),x=lson(x);//因為原樹根節點肯定就是中序遍歷的第一個點,也就是最頂上的
return x;// splay的最左邊的兒子,一直找左兒子就行了
}
split
作用:提取出 \(x,y\) 間的路徑
我們再 \(makeroot(9)\) ,圖在前面,就不放了,我們 \(access(10)\) 再 \(Splay(10)\) 。
void split(int x, int y) {
makeroot(x);//首先把x置為根節點
access(y);//生成一顆 Splay
splay(y);
//y維護的就是x - y 路徑上的信息
}
link
作用:把 \(x\) 點和 \(y\) 點之間連一條邊
方法:把 \(x\) 點變成所在原樹的根,然后把 \(x\) 點的父親變成 \(y\) 就可以了。
比如說加一條連向 \(9\) 的邊。
void link(int x,int y)//連邊
{
makeroot(x);//使p變成根節點
f[x]=y;//x變成y的父親,也就是連了邊
}
cut
作用:把 \(x\) 點和 \(y\) 點之間的邊刪掉
方法:把 \(x\) 點變成所在原樹的根,然后把 \(y\) 點 \(access\) 到根,Splay \(y\) 到輔助樹的根,然后斷開y與它左孩子間的邊。由於 \(x\) 是原樹的根,\(y\) 是樹中的一點,所以就 \(y\) 點通過 \(access\) 和 \(x\) 點連到一個輔助樹中時,\(x\) 點一定是它們所在實鏈的鏈頂。而 \(y\) splay到輔助樹的根時,如果 \(x\),\(y\) 間有一條邊,則 \(x\)一定是 \(y\) 的左孩子。
比如說刪去 \(8\to 9\) 這條邊。
void cut(int x,int y)//刪邊
{
makeroot(x);//x變成根節點
access(y);//y通向 x 減了一個實鏈,也就是一顆 splay,因為 x,y之間有邊,所以這顆splay 里面只有兩個點
splay(y);//將 y 轉到頂部
if(lson(y)!=x ||rson(x)) return;//兩者之間本來就沒有邊
f[x]=0;//刪去原來連邊的信息
lson(y)=0;
push_up(x);
}
isroot
作用:判斷是否是splay的根
方法:splay的根結點的父親並不認這個孩子。
注意:原樹的根的父親點是 \(0\)
bool isroot(int x)//判斷當前點是否是實鏈的根節點
{//當前點是根節點因為這它認父親,父親不認兒子
return lson(f[x])!=x && rson(f[x])!=x;
}
下面的部分都是基礎操作,Splay 有個地方有點不一樣,可以看見。
pushup
void push_up(int p)
{
tree[p].sum=tree[lson(p)].sum^tree[rson(p)].sum^a[p];
or
tree[p].sum=tree[lson(p)].sum+tree[rson(p)].sum+a[p];
}
pushdown
void ff(int p)
{
swap(lson(p),rson(p));
tree[p].laz^=1;
}
void push_down(int p)
{
if(!tree[p].laz) return;
if(lson(p)) ff(lson(p));
if(rson(p)) ff(rson(p));
tree[p].laz=0;
}
rotate
void rotate(int x,int op)
{
int y=f[x];
if(!isroot(y))
tree[f[y]].ch[rson(f[y])==y]=x;//原先父親節點與其父親節點的邊斷開,連上現在的這個點
f[x]=f[y];//兒子節點的爸爸換成爺爺
if(tree[x].ch[op])//兒子節點op兒子有的話,改變他的父親為父親
f[tree[x].ch[op]]=y;
tree[y].ch[!op]=tree[x].ch[op];//父親的兒子變成兒子的兒子
f[y]=x;//父親的父親變成兒子
tree[x].ch[op]=y;//兒子的對應兒子變成父親
push_up(y);
//注:注釋里的父親,兒子,爺爺,都表示沒變化之前的稱謂
}
spaly
這里講一下和普通 Splay 的一點區別,就是我們先用棧將我們接下來要旋轉的點存儲下來,然后一起 pushdown 。這樣就不用邊旋轉邊 pushdown。
int sta[M],top;//為了將懶惰標記一氣兒下傳
void splay(int x)
{
sta[++top]=x;
for(int i=x;!isroot(i);i=f[i]) sta[++top]=f[i];
while(top) push_down(sta[top--]);//splay之前先將要旋轉的鏈上的懶惰標記全部下穿,免去了邊旋轉邊下傳的麻煩
while(!isroot(x))//當前點不是根
{
if(!isroot(f[x]))//父親也不是根
{
if((rson(f[x])==x)^(rson(f[f[x]])==f[x]))//不在一邊
rotate(x,lson(f[x])==x);//旋轉當前節點
else
rotate(f[x],lson(f[f[x]])==f[x]);//鏈的情況,旋轉父親節點才能改變形態,旋轉父親節點
}
rotate(x,lson(f[x])==x);
}
push_up(x);
}
習題
-
bzoj2158
-
bzoj2002
-
BZOJ2631tree(LCT模板題,路徑加,路徑乘,求路徑點權和)
-
BZOJ2002[Hnoi2002]彈飛綿羊(LCT練習題,重點在於如何轉化成LCT)
-
BZOJ3669[Noi2014]魔法森林(LCT經典題,利用LCT解決二維最小生成樹)
-
BZOJ4530[Bjoi2014]大融合(LCT維護子樹信息)
-
BZOJ3091城市旅行(LCT區間信息合並)
后面的沒做,做了有時間再補代碼。
參考資料
老師的課件