樹鏈剖分是解決樹上問題的一種常見數據結構,對於樹上路徑修改及路徑信息查詢等問題有着較優的復雜度。樹鏈剖分分為兩種:重鏈剖分和長鏈剖分,因為長鏈剖分不常見,應用也不廣泛,所以通常說的樹鏈剖分指的是重鏈剖分。在這里講解並總結一下樹鏈剖分的實現、優秀性質及應用。
重鏈剖分
先來介紹幾個重鏈剖分的專業名詞:
- 重兒子:每個點的子樹中,子樹大小(即節點數)最大的子節點
- 輕兒子:除重兒子外的其他子節點
- 重邊:每個節點與其重兒子間的邊
- 輕邊:每個節點與其輕兒子間的邊
- 重鏈:重邊連成的鏈
- 輕鏈:輕邊連成的鏈
重鏈剖分顧名思義是按輕重鏈進行剖分,對於每個點找到重兒子,如果多個子樹節點數同樣多,隨便選一個作為重兒子就好了,一個點也可以看做一條重鏈。
用圖來形象的描述一下,粗邊就代表重邊啦qwq
重鏈剖分的實現是由兩次dfs來實現的,第一次dfs處理出每個點的重兒子son[],子樹大小size[],深度d[]及父節點f[]
具體實現很簡單,回溯時直接比較當前子節點和重兒子子樹大小關系來更新重兒子
void dfs(int x) { size[x]=1; d[x]=d[f[x]]+1; for(int i=head[x];i;i=next[i]) { if(to[i]!=f[x]) { f[to[i]]=x; dfs(to[i]); size[x]+=size[to[i]]; if(size[to[i]]>size[son[x]]) { son[x]=to[i]; } } } }
而第二遍dfs則是要處理出每個點所在重鏈的鏈頭top[]
void dfs2(int x,int tp)//dfs2(root,root); { top[x]=tp; if(son[x]) { dfs2(son[x],tp); } for(int i=head[x];i;i=next[i]) { if(to[i]!=f[x]&&to[i]!=son[x]) { dfs2(to[i],to[i]); } } }
通過代碼及圖示可以發現重鏈剖分的一些性質:
1、所有重鏈互不相交,即每個點只屬於一條重鏈
2、所有重鏈長度和等於節點數(鏈長指鏈上節點數)
3、一個點到根節點的路徑上經過的邊中輕邊最多只有log條
前兩個性質好理解,那么第三個性質是為什么呢?因為最壞情況就是這個點到根路徑上經過的邊都是輕邊,那么每走一條輕邊到達這個點的父節點就代表這個父節點至少還有一個與當前子樹同樣大的子樹,也就是說每走一條輕邊走到的點的子樹大小就要*2,因此最多只能走log次。這也是為什么要選重兒子而不是隨便一個兒子的原因。
重鏈剖分有什么用呢?
舉個例子:求LCA
對於求x,y的lca,可以每次優先爬點所在重鏈鏈頭深的點,如果兩個點不在同一條重鏈上,那么直接把鏈頭深的點跳到鏈頭,重復這個過程,直到兩個點處在同一條重鏈上,直接輸出深度淺的點就是lca了。因為重鏈是直接跳到鏈頭,時間復雜度是O(1)的,而跳輕邊最多就log條,因此求兩個點的lca時間復雜度是O(logn)。具體實現如下。
#include<set> #include<map> #include<stack> #include<queue> #include<cmath> #include<vector> #include<cstdio> #include<cstring> #include<iostream> #include<algorithm> #define ll long long using namespace std; int n,m,rt; int x,y; int head[500010]; int to[1000010]; int next[1000010]; int son[500010]; int size[500010]; int top[500010]; int d[500010]; int f[500010]; int tot; void add(int x,int y) { tot++; next[tot]=head[x]; head[x]=tot; to[tot]=y; } void dfs(int x) { size[x]=1; d[x]=d[f[x]]+1; for(int i=head[x];i;i=next[i]) { if(to[i]!=f[x]) { f[to[i]]=x; dfs(to[i]); size[x]+=size[to[i]]; if(size[to[i]]>size[son[x]]) { son[x]=to[i]; } } } } void dfs2(int x,int tp) { top[x]=tp; if(son[x]) { dfs2(son[x],tp); } for(int i=head[x];i;i=next[i]) { if(to[i]!=f[x]&&to[i]!=son[x]) { dfs2(to[i],to[i]); } } } int lca(int x,int y) { while(top[x]!=top[y]) { if(d[top[x]]<d[top[y]]) { swap(x,y); } x=f[top[x]]; } return d[x]<d[y]?x:y; } int main() { scanf("%d%d%d",&n,&m,&rt); for(int i=1;i<n;i++) { scanf("%d%d",&x,&y); add(x,y); add(y,x); } dfs(rt); dfs2(rt,rt); for(int i=1;i<=m;i++) { scanf("%d%d",&x,&y); printf("%d\n",lca(x,y)); } }
通過用樹鏈剖分求lca我們發現重鏈剖分重鏈的用途——O(1)移動到鏈頭!但只是能求lca了,和剛開始寫的維護樹上信息也沒關系啊?
通過第二次dfs可以觀察到,每個點遍歷子節點時優先遍歷的是重兒子,這說明什么?每條重鏈的dfs序上的位置是連續的一段,而每一次在樹上移動是直接移動到鏈頭,這就可以對這一條重鏈上的信息區間修改或者查詢,直接把dfs序架在線段樹上就能實現了!事實上按優先遍歷重兒子得出的dfs序就是樹剖序。
這樣對於文章開頭提到的維護路徑信息就可以對樹剖序建線段樹通過爬lca時每次跳鏈頭來區間修改或查詢。因為單次修改或查詢線段樹時間復雜度是O(logn),所以單次對路徑修改或查詢時間復雜度就是O(log2n)。
樹鏈剖分+線段樹的題比較多,在這里只推薦幾個經典題目
長鏈剖分
長鏈剖分和重鏈剖分差不多,只不過是將子樹中深度最大的子節點當成重兒子,而維護的信息也從size[]變成了mx[]表示子樹中的最大深度。
為了方便講解,節點與重兒子之間的邊就叫長邊吧,其他邊叫短邊。
長鏈剖分也同樣需要兩遍dfs來維護信息,與重鏈剖分類似,在這里不再放代碼,兩遍dfs在下面lca的代碼中可以看到。
在某些特殊情況中長鏈剖分和重鏈剖分可能相同。
長鏈剖分有一些更好的性質:
1、任意點的任意祖先所在長鏈長度一定大於等於這個點所在長鏈長度
2、所有長鏈長度之和就是總節點數
3、一個點到根的路徑上經過的短邊最多有√n條
同樣證明一下第三個性質,因為一個點x往上走一條短邊就意味着它走到的點至少還有一個長度和x往下最長鏈長度相同的鏈。這樣每走一條短邊要加的點數為1、2、3、4……所以要加的點數是k2個(k是走的短邊數,嚴格來說應該是k*(k+1)/2),因此k最大為√n。這樣也就說明了用長鏈剖分求lca的時間復雜度是O(n√n)。
長鏈剖分求lca的過程和重鏈剖分一樣,在這里就不再說了。
#include<set> #include<map> #include<stack> #include<queue> #include<cmath> #include<vector> #include<cstdio> #include<cstring> #include<iostream> #include<algorithm> #define ll long long using namespace std; int n,m,rt; int x,y; int head[500010]; int to[1000010]; int next[1000010]; int son[500010]; int mx[500010]; int top[500010]; int d[500010]; int f[500010]; int tot; void add(int x,int y) { tot++; next[tot]=head[x]; head[x]=tot; to[tot]=y; } void dfs(int x) { d[x]=d[f[x]]+1; mx[x]=d[x]; for(int i=head[x];i;i=next[i]) { if(to[i]!=f[x]) { f[to[i]]=x; dfs(to[i]); mx[x]=max(mx[to[i]],mx[x]); if(mx[to[i]]>mx[son[x]]) { son[x]=to[i]; } } } } void dfs2(int x,int tp) { top[x]=tp; if(son[x]) { dfs2(son[x],tp); } for(int i=head[x];i;i=next[i]) { if(to[i]!=f[x]&&to[i]!=son[x]) { dfs2(to[i],to[i]); } } } int lca(int x,int y) { while(top[x]!=top[y]) { if(d[top[x]]<d[top[y]]) { swap(x,y); } x=f[top[x]]; } return d[x]<d[y]?x:y; } int main() { scanf("%d%d%d",&n,&m,&rt); for(int i=1;i<n;i++) { scanf("%d%d",&x,&y); add(x,y); add(y,x); } dfs(rt); dfs2(rt,rt); for(int i=1;i<=m;i++) { scanf("%d%d",&x,&y); printf("%d\n",lca(x,y)); } }
長鏈剖分性質的應用有一道練習題BZOJ3252攻略
長鏈剖分應用:
O(nlogn)預處理,單次O(1)在線查詢一個點的k級祖先
這個應用不是很廣,因為只有在n特別大時才能體現出優勢,但對於某些題可以簡便地找到k級祖先。
首先想最暴力的方法每次朴素爬到父親節點,這樣單次查詢時間復雜度是O(n)。
再進行優化,用倍增往上爬,單次時間復雜度O(logn)
因為倍增是滿log的,那么用另一種求lca的方法重鏈剖分,這樣雖然還是O(logn),但常數小了一點
再想想能不能把倍增和重鏈剖分一起用?先找出比k小的最高的2的冪次,然后維護每個點的往上跳的倍增數組,先跳2的最高次冪再重鏈剖分,這樣快了一點,但還是不能O(1)。
那么我們能不能把跳完2的最高次冪的那個點的祖先都記錄下來呢?這樣預處理時間復雜度就爆炸了。
如果只預處理每條重鏈鏈頭的祖先和鏈上的節點呢?但往上要預處理多長的祖先?
這時聯想上面講到的長鏈剖分的第一個性質,將重鏈剖分換成長鏈剖分,暴力預處理每個鏈頭往上鏈長個祖先及這條鏈上的所有點,因為只有鏈頭預處理,而所有鏈長和是節點總數,所以預處理這一步時間復雜度是O(2n)。再預處理出所有數二進制的最高次冪,每次跳最大一步之后O(1)查詢。
具體怎么查?為什么往上預處理鏈長個祖先?
我們分類討論:
1、當k級祖先在當前鏈上時,直接查鏈頭存的鏈信息
2、當k級祖先不在當前鏈上但在跳2的最高次冪到的點x所在的鏈上時,直接查點x所在那條鏈的鏈頭存的鏈信息
3、當k及祖先既不在當前鏈上,也不在跳2的最高次冪到的點x所在的鏈上時,因為x距離查詢點深度最少為k/2(跳的是2的最高次冪),那么x往下的長鏈長度至少為k/2,也就是說x所在長鏈長度至少為k/2,x所在鏈的鏈頭往上預處理的祖先至少有k/2個,一定包含k級祖先。
這就是為什么要用長鏈剖分而不是重鏈剖分的原因,重鏈剖分沒有長鏈剖分的第一個性質。
#include<queue> #include<cmath> #include<cstdio> #include<cstring> #include<iostream> #include<algorithm> #define ll long long using namespace std; int n,m; int x,y; int tot; int ans; int head[300300]; int nex[600600]; int to[600600]; int f[300300][20]; int son[300300]; int mx[300300]; int d[300300]; int top[300300]; int st[600600]; vector<int>s[300300]; vector<int>t[300300]; void add(int x,int y) { tot++; nex[tot]=head[x]; head[x]=tot; to[tot]=y; } void dfs(int x,int fa) { d[x]=d[fa]+1; mx[x]=d[x]; f[x][0]=fa; for(int i=1;i<=19;i++) { if(f[x][i-1]) { f[x][i]=f[f[x][i-1]][i-1]; } else { break; } } for(int i=head[x];i;i=nex[i]) { if(to[i]!=fa) { dfs(to[i],x); if(mx[to[i]]>mx[son[x]]) { son[x]=to[i]; mx[x]=mx[to[i]]; } } } } void dfs2(int x,int tp) { top[x]=tp; if(son[x]) { dfs2(son[x],tp); } for(int i=head[x];i;i=nex[i]) { if(to[i]!=f[x][0]&&to[i]!=son[x]) { dfs2(to[i],to[i]); } } } void find(int x) { int rt=x; int len=mx[x]-d[x]; x=f[rt][0]; while(d[rt]-d[x]<=len&&x) { s[rt].push_back(x); x=f[x][0]; } x=rt; while(son[x]) { t[rt].push_back(son[x]); x=son[x]; } } int main() { scanf("%d",&n); for(int i=1;i<n;i++) { scanf("%d%d",&x,&y); add(x,y); add(y,x); } dfs(1,0); dfs2(1,1); st[1]=0; for(int i=2;i<=n;i++) { st[i]=st[i>>1]+1; } for(int i=1;i<=n;i++) { if(i==top[i]) { find(i); } } scanf("%d",&m); for(int i=1;i<=m;i++) { scanf("%d%d",&x,&y); x=x^ans; y=y^ans; if(y==0) { ans=x; } else if(y>=d[x]) { ans=0; } else { x=f[x][st[y]]; y-=(1<<st[y]); if(y==0) { ans=x; } else if(y<d[x]-d[top[x]]) { ans=t[top[x]][d[x]-d[top[x]]-y-1]; } else if(y==d[x]-d[top[x]]) { ans=top[x]; } else { ans=s[top[x]][y-d[x]+d[top[x]]-1]; } } printf("%d\n",ans); } }
練習題只找到一道BZOJ4381
O(n)處理可合並的與深度有關的子樹信息(例如某深度點數、某深度點權和)
首先還是先想暴力,dfs整棵樹,回溯時將每一深度的信息合並,時間復雜度O(n*maxdep)
再優化一下,還是想到重鏈剖分,因為每個點合並時第一個子節點可以直接繼承下來(繼承一般是用指針O(1)優化,具體后面再講),剩下子樹暴力遍歷,因為重鏈剖分后每個點不被繼承而被暴力遍歷最多logn次(每個點到根路徑上最多log條輕邊是需要被遍歷的),因此時間復雜度是O(nlogn)。
再想想能發現根本不用遍歷其他子樹,只要合並子樹已有信息就好了。
但我們發現重鏈剖分在合並深度信息時不怎么優秀,因為每個點的輕兒子可能深度更深,合並還是很慢。
重鏈剖分不具有重兒子最深的性質但長鏈剖分具有啊!因此只要把重鏈剖分換成長鏈剖分,每次還是繼承重兒子,其他的暴力合並。那么這樣的時間復雜度呢?我們考慮一棵子樹信息被暴力合並當且僅當這棵子樹的根節點與其父節點之間的邊是短邊,合並的代價是這棵子樹中最長鏈的長度(也就是這棵子樹的深度),而這棵子樹的根節點就是這個最長鏈的鏈頭,那么也就轉化成了只有每條鏈的鏈頭會被暴力合並且合並的時間復雜度是鏈長。因為所有鏈長和是n,所以這樣做的時間復雜度就是O(n)。有了這個應用就可以優化許多與深度有關的樹形DP了。
例如BZOJ4543
再來說一下怎么用指針O(1)優化。
因為繼承重兒子相當於把重兒子的數組復制一遍,那么我們可以把所有節點的數組開成一個大數組,而每個節點的數組變成指針數組,每次繼承時O(1)把父節點指針移到重兒子數組指針處,因為繼承之后重兒子信息就沒用了,因此暴力合並可以直接在父節點指針指向的數組那一段直接修改。
當然也不是所有情況都用指針來優化,如果求某一深度區間的信息時,可以求出長鏈剖分序,每次將輕兒子信息合並到長鏈上,之后查詢每個點時只要查詢這個點往下的長鏈上的信息就是整棵子樹中的信息了。
例如BZOJ1758