最近樹上公共祖先詳細圖解


一、定義

LCA(Least Common Ancestors),樹上最近公共祖先,顧名思義,也就是說,對於節點u,v,設x=lca(u,v),則,u和v均在x的子樹中,並且x的深度最小。

圖畫得太丑了

如這幅圖中:lca(5,6)=2 ,lca(6,3)=1 ,lca(3,9)=9。

二、解法

(1)dfs序

何為dfs序?即為深度優先搜索遍歷完這棵樹后所獲得的節點訪問先后順序。如上面那副圖的dfs序:

求dfs序代碼:

void dfs(int x,int depth)//遍歷一遍圖求dfs序 { int i; len++; ola[len]=x;dep[len]=depth;//ola數組用來存dfs序 vis[x]=len;//標記x並存下x在ola數組中出現的位置 for(i=first[x];i;i=next[i]) { if(!vis[v[i]]) { dfs(v[i],depth+1); len++; ola[len]=x; dep[len]=depth; } } }

我們可以發現,任何一個節點出現兩次之間,一定包含了他的子樹。於是我們在將dfs序與節點深度結合起來看:

我們再來找5和3的lca,可以由下一幅圖知道1。

不難發現,我們要找的點即為5和3之間深度最小的點。因此我們只需要將dfs序和每個點在dfs序上的位置預處理出來以后,按RMQ問題的套路求解即可。實際上我們只需要記錄每個節點首次出現的位置即可,因為如圖所示,我們查詢2和3的lca

按理說應該查詢這一段的最小值

然而我們查詢這一段也是同樣的結果

因為這一段中

所有的節點都是以2根的子樹的節點,深度不可能小於其lca,對結果影響。

dfs序求lca的原理解決了,接下來我們只需要套RMQ問題的模板就行了!使用st表時間復雜度為O(2Nlog(2N)+N+M) 其中,N為節點數,M為詢問個數。若不知道st表是什么的同學也沒有關系, 當然我們用線段樹,但時間復雜度為O(N+(2N+M)log(2N))常數也挺大,一般不用他。

模板題

然而,對於這道題,N和M都高達5×10^5,dfs序算法會超時3個點。下面貼上作者巨丑無比的代碼(由於是很久之前寫的),大家輕噴。 (對於求lca,dfs序一般少用,大家可以直接跳過看下一種算法,但dfs序的思想很重要,一定要理解)


#include<cstdio> #include<algorithm> using namespace std; const int MAXN=5e5+5; int ola[MAXN*2],dep[MAXN*2],first[MAXN],next[MAXN*2],u[MAXN*2],v[MAXN*2]; int vis[MAXN],st[MAXN*2][25][2],log[2*MAXN]; int n,m,len,p; void dfs(int x,int depth)//遍歷一遍圖求dfs序 { int i; len++; ola[len]=x;dep[len]=depth;//ola數組用來存dfs序 vis[x]=len;//標記x並存下x在ola數組中出現的位置 for(i=first[x];i;i=next[i]) { if(!vis[v[i]]) { dfs(v[i],depth+1); len++; ola[len]=x; dep[len]=depth; } } } void build()//存圖 { int i,j,k; for(i=1;i<n;i++) scanf("%d%d",&u[i],&v[i]), next[i]=first[u[i]],first[u[i]]=i; for(i=n;i<n+n-1;i++) u[i]=v[i-n+1],v[i]=u[i-n+1], next[i]=first[u[i]],first[u[i]]=i; } int main() { int i,j,k; scanf("%d%d%d",&n,&m,&p); build();//建圖 dfs(p,1);//求dfs序 log[0]=-1; for(i=1;i<=len;i++) log[i]=log[i>>1]+1,//預處理log數組 st[i][0][0]=dep[i],st[i][0][1]=ola[i]; for(j=1;j<=log[len];j++)//預處理st表 for(i=1;i+(1<<j)-1<=len;i++) { k=i+(1<<j-1); if(st[i][j-1][0]<st[k][j-1][0]) st[i][j][0]=st[i][j-1][0],st[i][j][1]=st[i][j-1][1]; else st[i][j][0]=st[k][j-1][0],st[i][j][1]=st[k][j-1][1]; }//st[..][..][0]存深度,st[..][..][1] 存點的編號 while(m--) { scanf("%d%d",&i,&j); i=vis[i]; j=vis[j]; if(i>j) k=i,i=j,j=k; k=log[j-i+1]; int l=j-(1<<k)+1; if(st[i][k][0]<st[l][k][0]) printf("%d\n",st[i][k][1]);//愉快輸出答案 else printf("%d\n",st[l][k][1]);//愉快輸出答案 } }

(2) 倍增

如果讓你考慮暴力的算法求解,我們該怎樣做呢?

對於u,v,先將深度較大的點往上跳,跳到和另一個點深度相同。然后將兩個點同時上跳,直到兩個點重合,那么現在我們便找到了u和v的lca,代碼如下:

int lca(int x,int y) { if(dep[x]<dep[y])//我們默認x深度較大 swap(x,y);//若不是,那就需要交換 while(dep[x]>dep[y]) x=fa[x];//將x提到與y深度相同的位置 while(x!=y) x=fa[x],y=fa[y];//暴力求解lca return x; } 

如何考慮優化呢?毫無疑問,上面那種做法費時間就費在一步一步往上跳,若我們能一次性往上跳很長一段距離,那無疑就很好了。

還記得快速冪嗎?我們可以吧a^13拆成a×a^4×a^8,當然我們也可以利用這個思想將往上跳13次換成往上跳8格,4格,1格,自然,我們將往上跳n次優化成了往上跳logn次。那么若何具體實現求lca呢?看下圖:

求x和y(默認x、y深度相等),設i=3,令x和y往上跳2^i格,也就是8格,哦豁!跳到了0號節點上,於是我們令i--,再往上跳4格。

i--,在往上跳2格,哦豁!又跳到外面去了,i再減1,i=0,再往上跳1格。

x和y又跳到了同一個點上,不行,不能跳。此時,算法流程結束,x和y的父親即為他們的lca

這次模擬的最后一步看似冗雜,但卻是必不可缺的,下面上偽代碼:

int lca(int x,int y) { if(dep[x]<dep[y])//我們默認x深度較大 swap(x,y);//若不是,那就需要交換 while(dep[x]>dep[y]) x=fa[x];//將x提到與y深度相同的位置 for(int i=20;i>=0;i--) if(x往上提2^i與y往上提2^i的節點不同) 將x和y分別往上跳2^i格; return fa[x];//最后x的父親即為答案 } 

那么如何,實現將一個節點往上提2^i呢?外面定義一個fa[x][i]表示x號節點往上提2^i格后對應的節點,那么每次,我們只需要,令x=fa[x][i]就行了,說了等於沒說。那么如何和得到這個fa數組呢?我們只需要得到這個dp方程式

fa[x][i]=fa[fa[x][i-1]][i-1] 把往上跳2^i格分為跳兩次2^(i-1)格。

邊界條件:fa[x][0]=father[x];

代碼

void dfs(int x,int father) { dep[x]=dep[father]+1;//處理深度 fa[x][0]=father;//初始化邊界條件 for(int i=1;i<=20;i++) fa[x][i]=fa[fa[x][i-1]][i-1];//dp for(int i=frt[x];i;i=nxt[i]) if(v[i]!=father) dfs(v[i],x);//遍歷這棵樹 }

查詢代碼:

int lca(int x,int y) { if(dep[x]<dep[y])//我們默認x深度較大 swap(x,y);//若不是,那就需要交換 int l=dep[x]-dep[y]; int i=0; while(l) { if(l&1) x=fa[x][i]; i++; l>>=1; }//利用快速冪的思想將x提至與y同一深度 if(x==y) return x;//特判 for(int i=20;i>=0;i--) if(fa[x][i]!=fa[y][i]) x=fa[x][i],y=fa[y][i]; return fa[x][0];//最后x的父親即為答案 } 

完整代碼

#include<cstdio> #include<iostream> using namespace std; const int N=5e5+10; int frt[N],nxt[2*N],v[2*N],dep[N],fa[N][21]; int h,p,n,m,tot; void add(int x,int y)//加邊 { v[++tot]=y; nxt[tot]=frt[x];frt[x]=tot; } void dfs(int x,int father)//預處理fa { int i; dep[x]=dep[father]+1; fa[x][0]=father; for(i=1;i<=20;i++) fa[x][i]=fa[fa[x][i-1]][i-1]; for(i=frt[x];i;i=nxt[i]) if(v[i]!=father) dfs(v[i],x); } int lca(int x,int y) { if(dep[x]<dep[y])//我們默認x深度較大 swap(x,y);//若不是,那就需要交換 int l=dep[x]-dep[y]; int i=0; while(l) { if(l&1) x=fa[x][i]; i++; l>>=1; }//利用快速冪的思想將x提至與y同一深度 if(x==y) return x;//特判 for(int i=20;i>=0;i--) if(fa[x][i]!=fa[y][i]) x=fa[x][i],y=fa[y][i]; return fa[x][0];//最后x的父親即為答案 } int main() { int a,b,i; scanf("%d%d%d",&n,&m,&p); for(i=1;i<n;i++) scanf("%d%d",&a,&b),add(a,b),add(b,a); dfs(p,0); while(m--) { scanf("%d%d",&a,&b); printf("%d\n",lca(a,b)); } }

這是和《信息學奧賽一本通》上面的代碼差不多。常數太大,還有很多很暴力的地方,最慢一個點跑了700ms。

如何優化?

先來看這一句

for(i=1;i<=20;i++) fa[x][i]=fa[fa[x][i-1]][i-1];

顯然,我們不用一直循環到20,因為很多時候,到了后面根本不需要處理,因為已經跳到外面去了,因此我們可以將其改成:

for(int i=1;1<<i<dep[x];i++) fa[x][i]=fa[fa[x][i-1][i-1];

其中,1<<i表示2^i。

還有這一句

for(int i=20;i>=0;i--) if(fa[x][i]!=fa[y][i]) x=fa[x][i],y=fa[y][i];

顯然也沒必要從20開始減,實際上我們只需要從log(dep[x])開始減,由於cmath庫中的log2運算太慢,我們使用O(n)的方法遞推出log數組。

for(int i=1;i<=n;i++) log[i]=log[i>>1]+1;//注意邊界條件log[0]=-1;

因此倍增往上跳的時候只需這樣即可:

for(int i=log[dep[x]];i>=0;i--) if(fa[x][i]!=fa[y][i]) x=fa[x][i],y=fa[y][i];

完整代碼:

#include<cstdio> #include<iostream> using namespace std; const int N=5e5+10; int frt[N],nxt[2*N],v[2*N]; int dep[N],fa[N][20],log[N]; int h,p,n,m,tot; void add(int x,int y)//加邊 { v[++tot]=y; nxt[tot]=frt[x];frt[x]=tot; } void dfs(int x,int father) { int i; dep[x]=dep[father]+1; fa[x][0]=father; for(i=1;1<<i<dep[x];i++)//優化1 fa[x][i]=fa[fa[x][i-1]][i-1]; for(i=frt[x];i;i=nxt[i]) if(v[i]!=father) dfs(v[i],x); } int lca(int x,int y) { if(dep[x]<dep[y]) swap(x,y); int l=dep[x]-dep[y],i=0; while(l)//快速冪優化 { if(l&1) x=fa[x][i]; i++; l>>=1; } if(x==y) return x;//特判 for(i=log[dep[x]];i>=0;i--)//優化2 if(fa[x][i]!=fa[y][i]) x=fa[x][i],y=fa[y][i]; return fa[x][0]; } int main() { int a,b,i; scanf("%d%d%d",&n,&m,&p); log[0]=-1; for(i=1;i<n;i++) scanf("%d%d",&a,&b),log[i]=log[i>>1]+1,//O(n)方法遞推出log[] add(a,b),add(b,a);//無向圖,加雙向邊 log[n]=log[n>>1]+1; dfs(p,0);//預處理 while(m--) { scanf("%d%d",&a,&b); printf("%d\n",lca(a,b));//愉快輸出答案~ } }

這樣一來最慢的一個點只跑了500ms,兩個簡單的優化,便少跑了200ms,說明一些小的優化也是十分重要的!

(3)樹鏈剖分法

顧名思義,樹鏈剖分即為將樹剖分成一條一條的鏈

那么如何來剖分呢?

算法流程

我們將樹中的邊分為輕邊與重邊,如下圖所示,加粗的是重邊,其余的是輕邊。

如何判斷一條邊是輕邊還是重邊呢?

我們規定u節點的所有兒子節點v中,找出size(v)(即為以v為根節點的子樹的大小)最大的v',那么邊(u,v')是重邊,其余的是輕邊。

這樣一來,可以得出輕重邊的一些性質:

1:如果邊(u,v)為輕邊,那么 size(v)\leqsize(v)≤ 1\over221 size(u)size(u)

因為如果 size(v)>size(v)1\over221 size(u)size(u) ,那么size(v)一定是最大的,那么(u,v)就是重邊。

2:從根節點到某一葉節點的路徑上最多有 \log(n)log(n) 條輕邊,因為根據性質一,每走過一條輕邊,子樹的節點數至少減少一半,因此最多走過 \log(n)log(n) 條輕邊便走到了葉節點。

3:我們將一段連續的重邊稱為重路徑,很顯然,重路徑的起點與終點也與輕邊相連,因此重路徑的數量也至多有 \log(n)log(n) 條。

在樹鏈剖分的過程中需要計算以下幾個值:

fa[x]:x節點的父親。

dep[x]:x節點所處的深度。

size[x]:以x節點為根的子樹的大小。

top[x]:x節點所處的重路徑的頂部節點。

son[x]:x節點的重兒子,即(x,son[x])為一條重邊。

這5個值可以用兩遍dfs完成,第一遍dfs求前四個值,代碼如下:

void dfs1(int x) { int tmp=0; size[x]=1; for(int i=frt[x];i;i=nxt[i]) if(v[i]!=fa[x]) { fa[v[i]]=x; dep[v[i]]=dep[x]+1; dfs1(v[i]); size[x]+=size[v[i]]; if(size[v[i]]>tmp) tmp=size[v[i]],son[x]=v[i]; } }

第二遍dfs:

void dfs2(int x) { if(son[x]) { top[son[x]]=top[x]; dfs2(son[x]); } for(int i=frt[x];i;i=nxt[i]) if(v[i]!=fa[x]&&v[i]!=son[x]) { top[v[i]]=v[i]; dfs2(v[i]); } }

那么如何求lca呢

假如u和v的top相同,說明u和v在同一條重路徑上,那么此時lca(u,v)一定是u,v中深度較小的。若u和v的top不同,那么lca(u,v)有可能在其中一個節點的重路經上,也有可能在別的重路徑上,但顯然不可能在top深度較大的重路徑上,於是我們挑出u,v中top深度較大的點,假設是u,我們將u跳到fa[top[u]]的位置,再來看u,v的top值是否相同,如果不同,再這樣往復循環,直到u,v的top相同時,便求得了u,v的lca,為u,v中深度較小的那個點。

代碼如下:

while(top[x]!=top[y]) { if(dep[top[x]]>dep[top[y]]) x=fa[top[x]]; else y=fa[top[y]]; } printf("%d\n",dep[x]<dep[y]?x:y);

完整代碼:

#include<cstdio> #define N 500010 int nxt[N<<1],v[N<<1],frt[N]; int fa[N],top[N],size[N],son[N],dep[N]; int n,m,tot,p; inline void add(int x,int y) { v[++tot]=y; nxt[tot]=frt[x];frt[x]=tot; } void dfs1(int x) { int tmp=0; size[x]=1; for(int i=frt[x];i;i=nxt[i]) if(v[i]!=fa[x]) { fa[v[i]]=x; dep[v[i]]=dep[x]+1; dfs1(v[i]); size[x]+=size[v[i]]; if(size[v[i]]>tmp) tmp=size[v[i]],son[x]=v[i]; } } void dfs2(int x) { if(son[x]) { top[son[x]]=top[x]; dfs2(son[x]); } for(int i=frt[x];i;i=nxt[i]) if(v[i]!=fa[x]&&v[i]!=son[x]) { top[v[i]]=v[i]; dfs2(v[i]); } } int main() { scanf("%d%d%d",&n,&m,&p); for(int i=1;i<n;i++) { int x,y; scanf("%d%d",&x,&y); add(x,y);add(y,x); } dep[p]=1; dfs1(p); dfs2(p); while(m--) { int x,y; scanf("%d%d",&x,&y); while(top[x]!=top[y]) { if(dep[top[x]]>dep[top[y]]) x=fa[top[x]]; else y=fa[top[y]]; } printf("%d\n",dep[x]<dep[y]?x:y); } }

這樣一來,最慢的一個點只跑了360ms,更快了!

(4)最高效解法:Tarjan

Tarjan是一種離線的線性時間復雜度的算法。

算法流程

以下圖為例:

查詢:

4 5

6 7

5 8

10 12

14 18

16 11

7 17

我們用dfs遍歷這顆樹,首先來到一號節點,令vis[1]=1,令f[1]=1,表示節點已被訪問過。進入下一個節點2,令vis[2]=1,f[2]=2,來到3號節點,vis[3]=1,f[3]=3,來到4,f[4]=4,vis[4]=1。

這時我們發現4沒有子節點了,令f[4]=自己的父節點3,看一看有沒有和4號有關的查詢,有一組4-5,但vis[5]=0,不管。回溯至3號節點,同樣,三號節點的子節點也都訪問過了,並且沒有與3相關的hui查詢,令f[3]=2,回到2,進入5,令vis[5]=1,f[5]=5,到6,f[6]=6,vis[6]=1,回到5,vis[6]=5。進入7,f[7]=7,到8,f[8]=8。

8沒有自節點了,看一看有么有與8相關的查詢,有:5-8,vis[5]=1,那么lca(5,8)就可以用並查集得到,為find(5)=5。令f[8]=7,回到7。

7的子節點也都訪問過了,發現一組6-7的查詢,並且vis[6]=1,那么lca(6,7)=find(6)=5。還有一組7-17的查詢,但vis[17]=0,不管。令f[7]=5,回到5。

這時我們發現5的兒子也都走完了,有查詢5-8,且vis[8]=1,lca(5,8)=find(8)=5。令f[5]=2,回到2。令f[2]=1,回到1,進入9,f[9]=9,f[10]=10,f[13]=13,f[14]=14。到14號節點時,有一組14-18的查詢,但vis[18]=0,不管。令f[14]=13,回到13,進入17,f[17]=17。

這時發現一組查詢7-17並且vis[7]=1,那么lca(7,17)=find(7)=1。令f[17]=13,回到13,進入18,vis[18]=18。

發現一組18-14的查詢,則lca(18,14)=find(14)=13。令f[18]=13,回到13,令f[13]=10,回到10,發現一組10-12的查詢,但vis[12]=0,不管。令f[10]=9,回到9,f[9]=1,回到1,進入11,f[11]=11,f[12]=12。

這時發現一組12-10的查詢,vis[10]=1,lca(10,12)=find(10)=1。令f[12]=11,回到11,進入15,f[15]=15,進入16,f[16]=16。

發現查詢16-11,lca(16,11)=find(11)=11。令f[16]=15。回到15,f[15]=11,回到1,算法結束。

代碼實現

#include<cstdio> #define N 500010 int frt[N],v[N<<1],nxt[N<<1],head[N],vis[N],f[N]; int n,m,p,tot; struct query { int nxt,v,ans,vis; }a[N<<1]; inline int read()//快讀 { int x=0; char ch=getchar(); while(ch<'0'||ch>'9') ch=getchar(); while(ch>='0'&&ch<='9') { x=x*10+ch-'0'; ch=getchar(); } return x; } void write(int x)//快輸 { if(x>9) write(x/10); putchar(x%10+'0'); } int find(int x)//並查集 { return x==f[x]?x:f[x]=find(f[x]); } inline void addedge(int x,int y)//加邊 { v[++tot]=y; nxt[tot]=frt[x];frt[x]=tot; v[++tot]=x; nxt[tot]=frt[y];frt[y]=tot;//反向加邊 } inline void addquery(int x,int y)//用一個鄰接表存詢問 { a[++tot].v=y; a[tot].nxt=head[x];head[x]=tot; a[++tot].v=x; a[tot].nxt=head[y];head[y]=tot; } void dfs(int x)//核心過程 { vis[x]=1; for(int i=frt[x];i;i=nxt[i]) if(!vis[v[i]]) dfs(v[i]),f[v[i]]=x;//等遍歷完了v[i]的所有 //子節點后,再令f[v[i]]=x for(int i=head[x];i;i=a[i].nxt)//找關於x的查詢 if(vis[a[i].v]&&!a[i].vis) { a[i].ans=find(a[i].v); a[i].vis=1; if(i&1) a[i+1].ans=a[i].ans,a[i+1].vis=1; else a[i-1].ans=a[i].ans,a[i-1].vis=1; //賦值相鄰的查詢 } } int main() { scanf("%d%d%d",&n,&m,&p); for(int i=1;i<n;i++) { addedge(read(),read()); f[i]=i; } tot=0;f[n]=n; for(int i=1;i<=m;i++) addquery(read(),read()); dfs(p); for(int i=1;i<tot;i+=2) write(a[i].ans),putchar('\n'); }

最慢的一個點只跑了280ms。

總結

一:歐拉序

時間復雜度與空間復雜度都巨大,編程實現復雜度也不小,不建議使用。

二:倍增

時間復雜度較優,空間復雜度巨大,比較好理解,代碼也比較好實現,適用與初學者。

三:樹鏈剖分

時間復雜度與空間復雜度都極優,代碼也好實現,極力推薦,最好的方法。

四:Tarjan

跑得最快,空間也優,但使用情況受限,只針對離線的情況,適用於毒瘤的卡常題。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM