最近公共祖先

Part 1:瞭解LCA

\(LCA\)(Least Common Ancestors),中文翻譯是「最近公共祖先」
圖片爆炸了qwq!ios

(原圖來自洛谷)算法

對於給定的一棵樹和這棵樹的樹根(如上圖)數組

給定兩個節點,他們的最近公共祖先(如下簡稱\(LCA\))就是這兩個點分別到樹根的簡單路徑中通過的全部點的交集中,深度最深的一個函數

以上面的圖爲例子,點對\((3,5)\)\(LCA\),咱們按照定義來求:測試

\(step 1\):求出\(3\)到樹根的路徑是\(3 \rightarrow 1 \rightarrow 4\)優化

\(step 2\):求出\(5\)到樹根的路徑是\(5 \rightarrow 1 \rightarrow 4\)spa

因此點對\((3,5)\)\(LCA\)就是點\(1\)翻譯

Part 2:\(LCA\)的求法

\(LCA\)的定義很好理解,相信你們很容易就明白了,如今咱們討論怎麼求兩個節點的\(LCA\)指針

法一:向上標記法

向上標記法是最簡單的求\(LCA\)的暴力算法code

算法主要思路:對於兩個給定節點\((a,b)\),咱們任意挑選一個,這裏咱們假設選擇\(a\)節點

\(step 1:\)\(a\)節點到樹根一個一個節點的遍歷,對遍歷到的節點進行標記

\(step 2:\)\(b\)節點到樹根一個一個節點的遍歷,若是遍歷到一個被標記的節點,那麼這個點就是點對\((a,b)\)\(LCA\)

向上標記法既是按照定義求\(LCA\)——求出簡單路徑交集的最深節點

可是這是一個赤裸裸的暴力,它的複雜度是\(O(n)\),其中\(n\)是點的深度

可是對於一棵比較高大威猛的樹來講,僅僅線性的複雜度顯然不夠優秀,因此代碼就再也不給出了,咱們考慮優化

法二:倍增求\(LCA\)

仍是考慮給定一棵樹和根節點,求樹上點對\((a,b)\)\(LCA\)

上面的算法慢的主要緣由是每次僅僅向上標記\(1\)個節點,那麼咱們想到了倍增算法,每次向上跳\(2^k\)個點

若是想實現這個算法,咱們就得記錄每一個點向上\(2^k\)步的父節點,咱們能夠用以下的預處理方式解決

預處理:記錄各個點的深度和他們\(2^i\)級的的祖先,用數組\(\rm{depth}\)表示每一個節點的深度,\(fa[i][j]\)表示節點\(i\)\(2^j\) 級祖先。

\(LCA\) \(prework\) \(code:\)

void dfs(int now, int fath) {  //now表示當前節點,fath表示它的父親節點
      fa[now][0] = fath; depth[now] = depth[fath] + 1;
      for(int i = 1; i <= lg[depth[now]]; ++i)
            fa[now][i] = fa[fa[now][i-1]][i-1]; 
//這個轉移能夠說是算法的核心之一
//意思是now的2^i祖先等於now的2^(i-1)祖先的2^(i-1)祖先
//原理顯而易見,根據初中數學,能夠獲得這個式子:2^i = 2^(i-1) + 2^(i-1)
      for(int i = head[now]; i; i = e[i].nex)
            if(e[i].t != fath) dfs(e[i].t, now);
}

而後是怎麼倍增的問題,若是咱們拿到點對\((a,b)\)就直接開始向上跳的話,因爲初始時\(a,b\)的深度不同,這樣使得跳躍步數難以控制,增大了思惟量(其實幾乎不可作

爲了解決這個問題,咱們考慮先把\(a,b\)中深的那一個跳到淺的那一個同一深度,而後一塊兒向上倍增跳躍,這樣咱們對於邊界條件的處理會方便許多

調整深度也很簡單,咱們假設\(a\)的深度大於\(b\)的深度,那麼先把\(a\)向上跳\(2^k\)步,若是到達的深度比\(b\)淺,那麼嘗試跳\(2^{k-1}\)步,以此類推,直到跳到與\(b\)的深度相等

調整完了,下面是倍增求\(LCA\)的步驟

\(step 1:\)\(a,b\)調整到同一深度的祖前後,若是這兩個點相同,說明其中深度淺的是\(LCA\),若是不一樣,執行\(2\)

\(step 2:\)若是他們的\(2^k\)祖先是同一個點,說明跳多了,咱們就「反悔」,看看\(2^{k-1}\)是否是同一個點,若是不是同一個點,就向上跳\(2^{k-1}\)

\(step 3:\)重複\(2\),直到跳到某一個深度,此時\(a\)\(b\)的父節點重合了,那麼這個父節點就是所求\(LCA\)

倍增求\(LCA\)核心代碼:

inline int LCA(int x, int y) {
      for(int i=1; i<=n; i++){
        lg[i]=lg[i-1];
        if(i==1<<lg[i-1])lg[i]++;
      }//預處理一下2^k,小小的常數優化
      if(depth[x] < depth[y]) //用數學語言來講就是:不妨設x的深度 >= y的深度
	      swap(x, y);
      while(depth[x] > depth[y])
		x = fa[x][lg[depth[x]-depth[y]] - 1]; //先跳到同一深度,方便處理
      if(x == y)  //若是x是y的祖先,那他們的LCA確定就是x了
	      return x;
      for(int k = lg[depth[x]] - 1; k >= 0; --k) 
            if(fa[x][k] != fa[y][k])  //由於咱們要跳到它們LCA的下面一層,因此它們確定不相等,若是不相等就跳過去。
            x = fa[x][k], y = fa[y][k];
      return fa[x][0];  //返回父節點
}

這樣查詢一次\(LCA\)的複雜度是\(O(logn)\),其中\(n\)是點的深度,比向上標記法快了很多

剩下的建樹就不用講了吧,用鄰接表而後把上面的代碼\(copy\)一下直接調用\(LCA()\)函數便可

2020/8/22 update

更新了一下,由於以爲上面的那個方法雖然常數小,可是對初次接觸\(LCA\)的童鞋不太友好,由於要預處理東西的太多了

這裏給出一個常數比較大可是好寫易懂一點的作法:

首先,仍是要預處理父子關係,可是在\(dfs\)裏咱們只處理一個節點的父親是誰,再也不倍增處理

而後咱們的\(dfs\)就會簡化成這個樣子:

void dfs(const int x){
	vis[x]=true;//標記已經遍歷過這個節點
	for(int i=0;i<v[x].size();i++){//掃描這個邊的全部兒子,這裏v是vector鄰接表
		int y=v[x][i];//兒子是y
		if(vis[y]) continue;//若是y已經訪問過了,continue掉
		dep[y]=dep[x]+1;//兒子的深度比父親大1
		fa[y][0]=x;//兒子的2^0祖先是他的父親
		dfs(y);//遞歸預處理
	}
}

\(dfs\)裏只預處理了每一個節點的\(2^0\)級祖先,而後咱們在調用完\(dfs\)預處理以後,在主函數裏暴力倍增預處理:

for(int i=1;i<=20;i++)//枚舉2的1-20次方
      for(int j=1;j<=n;j++)//枚舉每一個點
            fa[j][i]=fa[fa[j][i-1]][i-1];//同上,一個點的2^i祖先等於他的2^(i-1)祖先的2^(i-1)祖先

而後,用來常數優化的\(lg\)數組也不要了,在\(LCA()\)裏也暴力處理:

inline int LCA(int x,int y){
	if(dep[x]>dep[y]) std::swap(x,y);
	for(int i=20;i>=0;i--)//枚舉跳2^20到2^0步,暴力的向上跳到與x相同的位置
		if(dep[fa[y][i]]>=dep[x])
			y=fa[y][i];
	if(y==x) return x;
	for(int i=20;i>=0;i--)//枚舉一塊兒跳2^20到2^0步,暴力向上爬樹
		if(fa[x][i]!=fa[y][i]){
			x=fa[x][i];
			y=fa[y][i];
		}
	return fa[x][0];//返回最後的父節點就是LCA
}

下面是完整的代碼,看起來很是的簡潔清爽(代價就是常數大

#include<cstdio>
#include<cstring>
#include<queue>
#include<stack>
#include<algorithm>
#include<set>
#include<map>
#include<utility>
#include<iostream>
#include<list>
#include<ctime>
#include<cmath>
#include<cstdlib>
#include<iomanip>
typedef long long int ll;
inline int read(){
	int fh=1,x=0;
	char ch=getchar();
	while(ch<'0'||ch>'9'){ if(ch=='-') fh=-1;ch=getchar(); }
	while('0'<=ch&&ch<='9'){ x=(x<<3)+(x<<1)+ch-'0';ch=getchar(); }
	return fh*x;
}
inline int _abs(const int x){ return x>=0?x:-x; }
inline int _max(const int x,const int y){ return x>=y?x:y; }
inline int _min(const int x,const int y){ return x<=y?x:y; }
inline int _gcd(const int x,const int y){ return y?_gcd(y,x%y):x; }
inline int _lcm(const int x,const int y){ return x*y/_gcd(x,y); }

const int maxn=500005;
const int inf=0x3f3f3f3f;

int n,rot,q;
std::vector<int>v[maxn];

int fa[maxn][25],dep[maxn];
bool vis[maxn]; 
void dfs(const int x){
	vis[x]=true;
	for(int i=0;i<v[x].size();i++){
		int y=v[x][i];
		if(vis[y]) continue;
		dep[y]=dep[x]+1;
		fa[y][0]=x;
		dfs(y);
	}
}
inline int LCA(int x,int y){
	if(dep[x]>dep[y]) std::swap(x,y);
	for(int i=20;i>=0;i--)
		if(dep[fa[y][i]]>=dep[x])
			y=fa[y][i];
	if(y==x) 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];
}

int main(){
	n=read(),q=read(),rot=read();//n個點,q次詢問,根爲rot 
	for(int i=1,x,y;i<=n-1;i++){
		x=read(),y=read();
		v[x].push_back(y);
		v[y].push_back(x);
	}
	dep[rot]=1;//根節點深度爲1,否則LCA調整深度時會出錯 
	dfs(rot);//根是rot 
	for(int i=1;i<=20;i++)
		for(int j=1;j<=n;j++)
			fa[j][i]=fa[fa[j][i-1]][i-1];
	for(int i=0,x,y;i<q;i++){
		x=read(),y=read();//查詢x,y的LCA 
		printf("%d\n",LCA(x,y));
	}
	return 0;
}

上面簡化版的作法具體慢多少?

在一樣的評測環境(無\(O_2\)下),\(n、q\leq500000\)的數據,優化常數的版本\(10\)個測試點一共跑了\(1.03s\),而暴力簡化版跑了\(2.13s\)

因此大概要慢一半以上,這裏仍是建議寫常數小的版本啦\(qwq\)

法三:\(RMQ\)(Range Minimum/Maximum Query)求LCA

算法原理

其實,這種作法算的上是一個奇技淫巧吧

先普及一個小知識點:歐拉序(怎麼又是歐拉

圖片爆炸了qwq!

(其實我嘗試過本身畫圖可是真的太醜了因此仍是用洛谷的圖叭

歐拉序,其實和深度優先遍歷序(先序遍歷)很是的類似:

爲了方便你們理解,咱們先寫出這棵樹的\(dfs\)序:\(4,2,1,3,5\)

那麼我先寫出歐拉序,而後再解釋:\(4,2,4,1,3,1,5,1,4\)

你們可能已經看出來了,歐拉序其實就是\(dfs\)序在回溯時又從新記錄了一下遍歷的點而已

如今給出這樣一張圖:

圖片爆炸了!

圖中給出了一棵以\(1\)爲根的樹,下面的第一個序列是歐拉序,第二行是歐拉序中各點在樹中的深度(深度序)

咱們發現一個有趣的現象——咱們查詢\(x,y\)\(LCA\)其實就是在深度序中\(x\)的下標和\(y\)的下標之間的最小值的下標在歐拉序中所對應的節點

若是上面的語言描述不夠簡潔,咱們用嚴格的數學語言描述一遍:

\(一、\)找到查詢點\(x,y\)在歐拉序中找到這兩個點第一次出現的下標,記做\(l,r\)

\(二、\)在深度序上對區間\([l,r]\)上查詢最小值,記下標爲\(qwq\)

\(三、\)那麼\(x,y\)\(LCA\)就是歐拉序中下標爲\(qwq\)的點

是否是很玄學奇妙,又比倍增\(LCA\)更加易於理解呢?

這個算法的核心原理在於:對於歐拉序遍歷,任意一棵非空子樹的根節點必定在其全部兒子節點的前面和後面各出現一次,而根節點的深度必定比兒子節點的深度淺,那麼兩個點之間出現的深度最小值就是這棵子樹的根節點,也就是包含\(x,y\)最小子樹的根節點,同時也是他們的\(LCA\)

預處理

\(一、\)歐拉序固然要先預處理啦!同時爲了保證珂愛的複雜度不被破壞,咱們還要記錄第\(i\)個點在歐拉序中的下標,這樣咱們單點查詢歐拉序下標的時候就是\(O(1)\)而不是遍歷的\(O(n)\)

\(二、\)深度序,表示第\(i\)個點在\(dfs\)中的層數,這個能夠在求歐拉序的時候順便求出來

預處理\(Code\)

void dfs(const int x,const int depth){
	vis[x]=1;//標記一下已經走過x號節點
	dis[id]=depth;//id是迭代器,depth爲當前深度
	eula[id][0]=x;//記錄歐拉序
	if(eula[x][1]==0) eula[x][1]=id;//這一維表明第i個點的歐拉序下標
	id++;//完成記錄後迭代器++
	for(unsigned int i=0;i<v[x].size();i++)//由於懶,因此用的vector存圖
		if(vis[v[x][i]]==0){//若是沒有被訪問過
			dfs(v[x][i],depth+1);//遞歸遍歷就好
			dis[id]=depth;//再記錄一遍
			eula[id][0]=x;//再記錄一遍
			id++;//完成記錄後迭代器++
		}
}

下面的問題將變成一個赤裸裸的\(RMQ\)問題,對於\(RMQ\)問題咱們有一大堆優秀的算法,好比\(st\)表,線段樹……等等都是優秀的\(RMQ\)算法

因爲弱,我選擇了使用建樹和查詢都是\(log\)級且自帶大常數的線段樹來求\(RMQ\)

求出了\(RMQ\)以後,咱們把這個下標對應到歐拉序中,輸出便可

蒟蒻代碼展現:

#include<cstdio>
#include<cstring>
#include<queue>
#include<stack>
#include<algorithm>
#include<set>
#include<map>
#include<utility>
#include<iostream>
#include<list>
#include<ctime>
#include<cmath>
#include<cstdlib>
inline int read(){
	int fh=1,x=0;
	char ch=getchar();
	while(ch<'0'||ch>'9'){ if(ch=='-') fh=-1;ch=getchar(); }
	while('0'<=ch&&ch<='9'){ x=(x<<3)+(x<<1)+ch-'0';ch=getchar(); }
	return fh*x;
}
inline int _abs(const int x){ return x>=0?x:-x; }
inline int _max(const int x,const int y){ return x>=y?x:y; }
inline int _min(const int x,const int y){ return x<=y?x:y; }
//以上全是缺省源,無視便可,另外本身寫幾個很簡單的函數能夠優化常數哦
const int maxn=500005;
std::vector<int>v[maxn];//vector代替了鄰接表建圖
int n,m,s,vis[maxn],dis[maxn*4],eula[maxn*4][2],id=1;
std::pair<int,int>querynum;//第一維記錄下標,第二維記錄最小值,用於更新 
//eula[i][1]是第i個點的歐拉遍歷下標,eula[][0]是歐拉遍歷序 
//dis[i]第i個點的深度 
void dfs(const int x,const int depth){
	vis[x]=1;
	dis[id]=depth;
	eula[id][0]=x;
	if(eula[x][1]==0) eula[x][1]=id;
	id++,vistimes++;
	for(unsigned int i=0;i<v[x].size();i++){
		if(vis[v[x][i]]==0){
			dfs(v[x][i],depth+1);
				dis[id]=depth;
				eula[id][0]=x;
				id++;
		}
	}
}//上面有註釋了
struct seg{//線段樹的結構體,使用指針建樹比*2,*2+1建樹方法省下一半空間,強烈安利
	int l,r,v,num;//l,r是這個區間的左右端點,v是區間最小值,num,最小值下標,和最小值一塊兒維護
	seg *ls,*rs;//左兒子指針,右兒子指針
	inline bool in_range(const int L,const int R){ return (L<=l)&&(r<=R); }//判斷是不是子集
	inline bool outof_range(const int L,const int R){ return (R<l)||(r<L); }//判斷是否無交集
	inline void push_up(){ 
		v=_min(ls->v,rs->v);//區間最小值=左兒子最小值和右兒子最小值取min
		num=(ls->v<=rs->v)?(ls->num):(rs->num);//維護最小值下標 
	}
	int query(const int L,const int R){
		if(in_range(L,R)){
			if(v<querynum.second){//區間最小值<已知最小值 
				querynum.first=num;//記錄下標 
				querynum.second=v;//更新已知最小值 
			}
			return v;//是查詢區間的子集,返回區間最小值
		}
		if(outof_range(L,R)) return 0x3f3f3f3f;//與查詢區間無交集,返回極大值
		return _min(ls->query(L,R),rs->query(L,R));//遞歸查詢最小值,維護更新下標
	}
};
seg byte[maxn*4],*pool=byte;//使用內存池創建線段樹
seg* New(const int L,const int R){
	seg *u=pool++;
	u->l=L,u->r=R;
	if(L==R){
		u->v=dis[L];
		u->num=L;
		u->ls=u->rs=NULL;
	}else{
		int Mid=(L+R)>>1;
		u->ls=New(L,Mid);
		u->rs=New(Mid+1,R);
		u->push_up();
	}
	return u;
}
int main(){
	n=read(),m=read(),s=read();
	for(int i=1,x,y;i<=n-1;i++){
		x=read(),y=read();
		v[x].push_back(y);
		v[y].push_back(x);
	}//創建鄰接表
	dfs(s,0);//預處理歐拉序和深度序
	seg *rot=New(1,id-1);//建樹
	for(int i=1,x,y;i<=m;i++){
		querynum.first=0;//用來記錄最小值下標
		querynum.second=0x3f3f3f3f;//用來更新最小值,因此先賦值極大
		x=read(),y=read();//讀入查詢點
		int l=eula[x][1],r=eula[y][1];//查詢這兩個點的下標
		if(l>r) std::swap(l,r);//若是l>r,交換,由於線段樹不支持查詢l>r的狀況
		int qwq=rot->query(l,r);//查詢區間最小值下標
		printf("%d\n",eula[querynum.first][0]);//輸出這個下標在歐拉序中的對應節點
	}
	return 0;
}

好了,今天關於\(LCA\)算法的分享就到這裏了(跪求三連

相關文章
相關標籤/搜索