林克砍樹。
如圖:
\(\uparrow\) 這個算非常重要的,一定要先學會 \(\texttt{Splay}\) 。
因為 \(\texttt{LCT}\) 中的 \(\texttt{Splay}\) 與我們平時寫的還不完全等同,所以必須要先理解一般的 $\texttt{Splay} $。
關於 LCT 的定義與性質
基本定義
\(\texttt{LCT}\) 全稱是 \(\texttt{Link/cut Tree}\) ,是一種維護對象為森林的數據結構,基於實鏈剖分原理以及 \(\texttt{Splay}\) 的功能得以實現。
於 \(1982\) 年由 \(\text{Daniel Dominic Sleator}\) 與 \(\text{Robert Endre Tarjan}\) 發明(怎么又是你們),也就是 \(\texttt{Splay}\) 的發明者。
我們應用 \(\texttt{LCT}\) 去解決的問題一般被我們成為動態樹問題。
實鏈剖分
學習 \(\texttt{LCT}\) 之前我們來回顧一下鏈剖分的相關定義。
我們有一顆有根樹,對於每個節點的所有子節點划分出符合一定性質的節點。然后將連接的邊划分出來。這樣對每個節點進行划分,會划分出不同的特殊的鏈。這樣對鏈做區間問題可以減少一些鏈上操作的復雜度。
我們常說的樹鏈剖分,其實就是重鏈剖分。
重鏈剖分就是
-
對每個點選擇子樹最大的子節點進行划分,划分出的連接邊稱為重邊,而若干條重邊組成一條重鏈。
-
剖分后我們發現,預處理標號時優先遍歷重鏈,那么每條重鏈在樹上會形成 dfs 序一定且連續的區間。
-
用數據結構維護連續的重鏈區間可以降低樹鏈查詢修改的復雜度。
而 \(\texttt{LCT}\) 則同樣基於這樣一種稱之為實鏈剖分的操作得以實現。
實鏈剖分特殊之處在於,對於實鏈有着非常模糊的定義,我們不是通過特殊性質划分子節點,而是動態地維護這么一條鏈。
也就是說:
- 實鏈剖分選擇一條邊進行剖分,被選擇的唯一的邊稱為實邊,其他邊為虛邊。
- 實鏈是靈活可變的,實兒子(點通過實邊連向的子節點)也是可變的。
- 正是因為可變,所以我們會采用數據結構維護這些動態的節點 (\(\texttt{Splay}\))。
為什么要這樣呢,因為既然我們維護的樹上問題是動態的,所以每次指定不同的鏈有不同的目的性。也就是說,動態問題導致划分條件會因為需求不同動態變化,只有通過數據結構才能快速實現這樣的轉化。
動態樹問題
我們引入一個看起來眼熟的數據結構題:
要求維護一棵樹,節點個數 \(10^5\) ,支持:
- 修改路徑權值,子樹權值。
- 查詢路徑權值和,子樹權值和。
嗯,這顯然是樹剖。
- 加邊,刪邊,保證樹的形態,在線查詢。
現在這個大致就是一個類動態樹問題。
動態樹問題概括地說就是:
-
維護一個森林,支持刪邊,加邊,保證保持森林形態。
-
可解決的問題包括但不限於:
- 查詢連通性。
- 路徑權值和,子樹權值和。
- 動態圖的割點,橋與連通塊信息(通過生成樹轉化)。
- 樹的合並與分裂(所以說一般會是森林)。
- 換根。
輔助樹
既然要用 \(\texttt{Splay}\) ,怎么用?既然要用實鏈剖分,怎么剖?
我們引入輔助樹的定義與性質:
- 輔助樹對應原森林中的一棵樹。
- 輔助樹由多個 \(\texttt{Splay}\) 構成。
- 通過維護一定形態的輔助樹,我們便可以獲得原樹的信息。
再看輔助樹上的 \(\texttt{Splay}\) 的相關性質:
- 每個 \(\texttt{Splay}\) 維護原樹中一條鏈。
- 要求 \(\texttt{Splay}\) 中序遍歷獲得的節點序列是嚴格按照節點深度嚴格遞增的(通俗地說,就是從上到下一條鏈)。
- 原樹中每個節點只能對應一個 \(\texttt{Splay}\) 。
- 划分出的實邊包含在 \(\texttt{Splay}\) 中,那么 \(\texttt{Splay}\) 維護的就是一條實鏈,虛邊則負責由一棵 \(\texttt{Splay}\) 的根節點指向另一個節點。這個節點是這棵 \(\texttt{Splay}\) 對應的原樹上的鏈的父親節點(即鏈頂的父親)。
- 可以由 \(\texttt{Splay}\) 的根節點通過虛邊連向原樹鏈頂的父親節點,反之卻不能,也就是說認父不認子。
讀者可以自行驗證以上轉化是否符合上述性質。
這樣我們巧妙的結合了實鏈剖分和 \(\texttt{Splay}\) ,就可以嘗試實現了。
算法實現與操作
操作圖示與代碼實現分別參考了 \(\text{FlashHu}\) 的博客 的圖和 \(\text{zcysky}\) 的題解 (你明明就是對着抄了一遍!嗚嗚人家寫的太好看了啊)。
以下代碼均為 Luogu P3690 【模板】Link Cut Tree (動態樹) 的代碼實現部分。
變量信息
ch[N][2] | fa[N] | s[N] | a[N] | rev[N] |
---|---|---|---|---|
節點的左右兒子 | 父親節點 | 子樹權值 | 節點權值 | 翻轉標記 |
我的代碼里有 #define INL inline
見到了不要誤會。
Pushup
UPDATE(x);
是所有數據結構都常見的操作,畢竟修改了左右兒子就會修改父親節點。
INL void UPDATE(int p){s[p]=s[ch[p][0]]^s[ch[p][1]]^a[p];}//Pushup
Pushdown
PD(x);
是區間翻轉標記,因為在 \(\texttt{LCT}\) 后續操作中會出現大量的節點翻轉互換的需求(換根的時候)。
INL void PD(int p)
{
int L=ch[p][0],R=ch[p][1];
if(rev[p]){rev[L]^=1,rev[R]^=1,rev[p]^=1;swap(ch[p][0],ch[p][1]);}
}
ISroot
在一棵輔助樹中,一個節點如果不是一個實兒子,那就不能從父親從上到下找到它。
一棵 \(\texttt{Splay}\) 的根對於這種情況是沒有更上層節點的,也就是說,如果既不是父親的左兒子也不是右兒子,那就是一棵 \(\texttt{Splay}\) 的根。
INL bool ISroot(int p){return ch[fa[p]][0]!=p&&ch[fa[p]][1]!=p;}
Splay
\(\texttt{LCT}\) 的 \(\texttt{Splay}\) 主要是 Rotate(x);Splay(x);
的操作。
這種 \(\texttt{Splay}\) 只需要支持將 \(x\) 旋轉到當前 \(\texttt{Splay}\) 的根的功能來輔助虛實變換的操作。
Rotate(x);
INL void Rotate(int p)
{
int f=fa[p],ff=fa[f];
int L,R;
if(ch[f][0]==p)L=0;
else L=1;//判斷左右兒子
R=L^1;
if(!ISroot(f))
{
if(ch[ff][0]==f)ch[ff][0]=p;
else ch[ff][1]=p;
}//LCT 中 Splay 的根節點比較特殊,所以特判
fa[p]=ff;fa[f]=p;fa[ch[p][R]]=f;
ch[f][L]=ch[p][R];ch[p][R]=f;
UPDATE(f);UPDATE(p);//普通 Splay 旋轉
}
只需要旋轉到根的 Splay(x);
INL void Splay(int p)
{
top=1;stk[top]=p;
for(int i=p;!ISroot(i);i=fa[i])stk[++top]=fa[i];//一直找這條完整的實鏈
for(int i=top;i;i--)PD(stk[i]);//這里需要下傳翻轉標記。
while(!ISroot(p))//一直到根
{
int f=fa[p],ff=fa[f];
if(!ISroot(f))//同樣是特判
{
(ch[f][0]==p)^(ch[ff][0]==f)?Rotate(p):Rotate(f);
}
Rotate(p);
}
}
Access
Access(x);
操作的本質是使得根節點到 \(x\) 的路徑成為一條完整的實鏈。
\(\texttt{LCT}\) 的實現可以理解為:從上到下,每次篩去每條實鏈上深度大於等於之前下方實鏈頂端的節點。這個可以用 \(\texttt{Splay}\) 將下方實鏈的頂端用虛邊連接的,也就是深度往上一層的點,轉為其所在 \(\texttt{Splay}\) 的根節點,然后直接篩去右子樹,顯然就是每條實鏈上深度大於等於之前下方實鏈頂端的所有節點。要求的鏈與鏈之間轉化通過 實邊 \(\rightarrow\) 虛邊 轉化。
可能非常拗口,我們先直接看一遍流程理解一遍。
假設我們有這樣一棵划分好的樹。
那么輔助樹長這樣:
現在要使得 \([A,N]\) 這條鏈成為一條獨立完整的實鏈。也就是 Access(N);
需要這樣的轉化。
對於 \(\texttt{LCT}\) 的實現流程是這樣的:
總結一下 Access(x);
的操作:
Splay(x);
將最低點轉到當前 \(\texttt{Splay}\) 的根節點。ch[x][1]=pre;
舍棄右兒子,將之前已經連好的 \(\texttt{Splay}\) 接到當前根的右兒子。UPDATE(x);
更新一下x=fa[x];
沿着虛邊往上跳下一條實鏈。
所以代碼實現其實很簡單:
INL void Access(int p)
{
for(int t=0;p;t=p,p=fa[p])
Splay(p),ch[p][1]=t,UPDATE(p);
}
Makeroot
光有 Access(x);
肯定是不夠的,因為我們查詢的鏈有時候在原樹上會有深度相同的點(也就是兩端的 \(\text{LCA}\) 是在兩端中間的節點) 。
那我們需要通過一種很巧妙的操作將其變成一條從上到下深度嚴格遞增的鏈。
這個操作就是 換根 。
我們考慮 \(u\rightarrow v\) 的一條鏈穿過了 \(\mathrm {LCA}(u,v)\) ,那么如果將 \(u,v\) 其中一個和 \(\mathrm{LCA}(u,v)\) 互換,那么可以得到 \(u \rightarrow v\) 的深度嚴格遞增鏈。對於一棵樹,根節點 \((\mathrm{root})\) 一定是所有點的 \(\text{Common ancestor}\) ,也就是說,\(\forall (u,v)\in \mathbf E,(\mathrm {root},\mathrm{LCA}(u,v))\) 都是一條深度嚴格遞增的鏈。所以顯然 \(\forall u\in\mathbf V,(\mathrm{root},u)\) 深度嚴格遞增。
於是我們可以通過將 \(u\) 變成為 \(\mathrm{root}\) ,得到 \((u,v)\) 嚴格遞增鏈。
操作很簡單:
Access(p);
獲得原樹根節點到 \(p\) 的 \(\texttt{Splay}\) 。這個時候 \(p\) 屬於 \(\texttt{Splay}\) 中深度最深的節點;Splay(p);
將 \(p\) 移到當前 \(\texttt{Splay}\) 的根節點,由於深度最大,此時 \(p\) 在 \(\texttt{Splay}\) 上沒有右子樹;rev[p]^=1;
為根節點 \(p\) 打上翻轉記號,也就是對整個 \(\texttt{Splay}\) 翻轉,整個左子樹移到右子樹上。那么 \(p\) 變成深度最小的節點,也就是原樹的 \(\mathrm{root}\) 。
這樣這個節點到任何其他節點的路徑按深度嚴格遞增了。
INL void Makeroot(int p)
{
Access(p);Splay(p);rev[p]^=1;
}
Findroot
找到一個節點在原樹中的根。
Access(p);Splay(p);
先分出當前節點到根的路徑,用 \(\texttt{Splay}\) 求出深度關系;p=ch[p][0];
一直向左,找到深度最小的節點就是 \(\mathrm{root}\) 。
同根的節點在同一棵樹上,所以可以用這個操作判連通性。
INL int Find(int p)
{
Access(p);Splay(p);
while(ch[p][0])p=ch[p][0];//一直向左
Splay(p);
return p;
}
Split
基於 Makeroot(p);
的操作,分離出 \(u\rightarrow v\) 的路徑做 \(\texttt{Splay}\) 。
INL void Split(int p,int q)
{
Makeroot(p);Access(q);//保證 p 到 q 嚴格的深度嚴格遞增。
Splay(q);//最后 q 成了根
}
Link
連接兩個點之間的邊。因為認父不認子,所以直接讓其中一個點變成根節點然后將另一個點及其子樹接上去即可。
INL void Link(int p,int q)
{
Makeroot(p);fa[p]=q;
}
一般我們要考慮是否已經聯通:
int xx=LCT.Find(x),yy=LCT.Find(y);
if(xx!=yy)LCT.Link(x,y);
Cut
斷邊操作,細節要略多。
Split(p,q);
后,\(q\) 成了根,那么 \(p\) 必須是其子節點才能斷開。
ch[p][1]==0
\(p\) 不能再有右子節點,不然中序遍歷 \(p\) 與 \(q\) 之間會插入其他節點,則說明 \(p\) 與 \(q\) 沒有直接連邊。ch[q][0]==p&&fa[p]==q
\(q\) 與 \(p\) 是父子關系。
INL void Cut(int p,int q)
{
Split(p,q);
if(ch[p][1]==0&&ch[q][0]==p&&fa[p]==q)
ch[q][0]=0,fa[p]=0;//直接斷開
}
同樣要提前考慮本來的連通性。
int xx=LCT.Find(x),yy=LCT.Find(y);
if(xx==yy)LCT.Cut(x,y);
至此我們完整實現了 \(\texttt{LCT}\) 。
Luogu P3690 【模板】Link Cut Tree (動態樹) 的完整代碼實現如下:
#include<cstdio>
#include<algorithm>
#include<iostream>
#include<cstring>
#include<cmath>
#include<queue>
#include<map>
#include<stack>
//#include<bits/stdc++.h>
#define ll long long
#define ull unsigned long long
#define INL inline
#define Re register
//Tosaka Rin Suki~
using namespace std;
INL int read()
{
int x=0;int w=1;
char ch=getchar();
while((ch<'0'||ch>'9')&&ch!='-')ch=getchar();
if(ch=='-')w=-1,ch=getchar();
while(ch>='0'&&ch<='9')
{x=(x<<1)+(x<<3)+ch-48,ch=getchar();}
return x*w;
}
INL int mx(int a,int b){return a>b?a:b;}
INL int mn(int a,int b){return a<b?a:b;}
const int N=300005;
int a[N],n,m;
struct ReyLCT
{
int stk[N],top;
int ch[N][2],fa[N],s[N],rev[N];
INL void UPDATE(int p){s[p]=s[ch[p][0]]^s[ch[p][1]]^a[p];}
INL void PD(int p)
{
int L=ch[p][0],R=ch[p][1];
if(rev[p]){rev[L]^=1,rev[R]^=1,rev[p]^=1;swap(ch[p][0],ch[p][1]);}
}
INL bool ISroot(int p){return ch[fa[p]][0]!=p&&ch[fa[p]][1]!=p;}
INL void Rotate(int p)
{
int f=fa[p],ff=fa[f];
int L,R;
if(ch[f][0]==p)L=0;
else L=1;
R=L^1;
if(!ISroot(f))
{
if(ch[ff][0]==f)ch[ff][0]=p;
else ch[ff][1]=p;
}
fa[p]=ff;fa[f]=p;fa[ch[p][R]]=f;
ch[f][L]=ch[p][R];ch[p][R]=f;
UPDATE(f);UPDATE(p);
}
INL void Splay(int p)
{
top=1;stk[top]=p;
for(int i=p;!ISroot(i);i=fa[i])stk[++top]=fa[i];
for(int i=top;i;i--)PD(stk[i]);
while(!ISroot(p))
{
int f=fa[p],ff=fa[f];
if(!ISroot(f))
{
(ch[f][0]==p)^(ch[ff][0]==f)?Rotate(p):Rotate(f);
}
Rotate(p);
}
}
INL void Access(int p)
{
for(int t=0;p;t=p,p=fa[p])
Splay(p),ch[p][1]=t,UPDATE(p);
}
INL void Makeroot(int p)
{
Access(p);Splay(p);rev[p]^=1;
}
INL int Find(int p)
{
Access(p);Splay(p);
while(ch[p][0])p=ch[p][0];
Splay(p);
return p;
}
INL void Split(int p,int q)
{
Makeroot(p);Access(q);
Splay(q);
}
INL void Cut(int p,int q)
{
Split(p,q);
if(ch[p][1]==0&&fa[p]==q)
ch[q][0]=0,fa[p]=0;
}
INL void Link(int p,int q)
{
Makeroot(p);fa[p]=q;
}
}LCT;//封裝
int main()
{
//freopen(".in","r",stdin);
//freopen(".out","w",stdout);
n=read();m=read();
for(int i=1;i<=n;i++)
a[i]=read(),LCT.s[i]=a[i];
while(m--)
{
int opt=read();
int x=read(),y=read();
if(opt==0)
{
LCT.Split(x,y);
printf("%d\n",LCT.s[y]);
}
else if(opt==1)
{
int xx=LCT.Find(x),yy=LCT.Find(y);
if(xx!=yy)LCT.Link(x,y);
}
else if(opt==2)
{
int xx=LCT.Find(x),yy=LCT.Find(y);
if(xx==yy)LCT.Cut(x,y);
}
else if(opt==3)
{
LCT.Access(x);LCT.Splay(x);
a[x]=y;LCT.UPDATE(x);
}
}
return 0;
}
復雜度略證
我們采用勢能分析。
約定
- 以 \(x\) 為根的子樹大小為 \(\mathrm {size}(x)\)
- 點 \(x\) 勢能函數為 \(r(x)=\lceil\log_2\mathrm {size}(x)\rceil\)
- 全局勢能函數 \(\displaystyle \Phi=\sum_{x\in \mathbf V} r(x)\)
- \(\Phi(t)\) 為進行 \(t\) 次伸展后的全局勢能。\(\forall t\in [0,T],\Phi(t)\in[n,n\log_2n]\)
Splay 復雜度
之前的博文未對 \(\texttt{Splay}\) 做任何復雜度分析與證明,借此契機補上。
鑒於算法特性,我們只需要分析伸展操作的復雜度。
設有 \(n\) 個節點進行了 \(m\) 次伸展。
對於伸展操作,有 \(6\) 種但是是 \(3\) 組對稱操作,所以考慮分析其中 \(3\) 種。
- \(zig\)
- \(zig-zig\)
- \(zig-zag\)
進行一次 \(\texttt{Splay(v)}\) 操作的均攤復雜度則是:
\(n\) 個點 \(m\) 次則是:
這里面的一個 \(\texttt{trick}\) 是分析勢能時我們發現會有 \(+1\) 的部分 ,那個其實不是 \(\Delta\Phi\) 而是 \(\Delta T=1\) 也就是耗時,但是我們將其合並進入 \(\Phi\) 。
LCT 復雜度
我們只需要分析 \(\texttt{Access}\) 操作。
這個操作由 \(2\) 個部分組成,\(\texttt{Splay}\) 伸展 和 虛實邊轉化。
邊轉化部分:
- 若 \(v\) 是 \(u\) 的兒子節點且 \(\mathrm{size}(v)>\displaystyle\frac{\mathrm{size}(u)}{2}\),則稱 \(v\) 是 \(u\) 的重兒子,\((u,v)\) 為重虛邊。反之為輕兒子,\((u,v)\) 為輕虛邊。
- 勢能函數 \(c\) 代表 \(\texttt{LCT}\) 中的重虛邊數量。 我們可得均攤復雜度 \(w=\Delta T+\Delta c\),走過的輕虛邊上限是 \(\log n\)。
- 那么經過重虛邊:\(c-1,T+1\);(不會產生新的重虛邊)
- 經過輕虛邊:\(c+1,T+1\)(這里 \(c\) 也有可能沒有變化)
- 得到每次最多走 \(\log_2 n\) 條輕虛邊,也就是 \(c+\max\{\log_2 n\}\) 且 \(T_{\max}=\Theta(\log n)\)
- 而每次 \(c-1\) 就是對應 \(T+1\)。
- 那么 \(n\) 個點 \(m\) 次轉化均攤 \(\sum w+c(0)-c(m)\) 得 \(\Theta(m \log n+n)\)
伸展部分:
- 我們只需要吧 \(\mathrm{size}(x)\) 看作輔助樹子樹的大小,之前的證明方法其實是成立的。
- 所以仍是 \(\log n\)。
那么 \(n\) 個點 \(m\) 次 \(\texttt{Access}\) 的總復雜度為 \(\Theta((n+m)\log n)\) 。
至此基礎的 \(\texttt{LCT}\) 學完了,這個東西用途非常多,有很多經典的模型(上面列舉的動態樹問題都有題),可以去找一些題做。
Reference:
- \(\text{OI Wiki}\) - LCT
- \(\textit{Iking123}\) - splay(伸展樹)&LCT(動態樹)復雜度略證
- \(\textit{FlashHu}\) - LCT總結——概念篇+洛谷P3690[模板]Link Cut Tree(動態樹)(LCT,Splay)