1. 程式人生 > >最近公共祖先(LCA)詳解

最近公共祖先(LCA)詳解

LCA問題(Least Common Ancestors,最近公共祖先問題),是指給定一棵有根樹T,給出若干個查詢LCA(u, v)(通常查詢數量較大),每次求樹T中兩個頂點u和v的最近公共祖先,即找一個節點,同時是u和v的祖先,並且深度儘可能大(儘可能遠離樹根)。
LCA問題有很多解法:線段樹、Tarjan演算法跳錶RMQ與LCA互相轉化等。本文主要講解Tarjan演算法的原理及詳細實現。

一 LCA問題

LCA問題的一般形式:給定一棵有根樹,給出若干個查詢,每個查詢要求指定節點u和v的最近公共祖先。

LCA問題有兩類解決思路:

  • 線上演算法,每次讀入一個查詢,處理這個查詢,給出答案。
  • 離線演算法,一次性讀入所有查詢,統一進行處理,給出所有答案。

一個LCA的例子如下。比如節點1和6的LCA為0。

lca

二 演算法思路

Tarjan演算法是離線演算法,基於後序DFS(深度優先搜尋)和並查集。如果不熟悉並查集,可以檢視並查集及其在最小生成樹中的應用

演算法從根節點root開始搜尋,每次遞迴搜尋所有的子樹,然後處理跟當前根節點相關的所有查詢。

演算法用集合表示一類節點,這些節點跟集合外的點的LCA都一樣,並把這個LCA設為這個集合的祖先。當搜尋到節點x時,建立一個由x本身組成的集合,這個集合的祖先為x自己。然後遞迴搜尋x的所有兒子節點。當一個子節點搜尋完畢時,把子節點的集合與x節點的集合合併,並把合併後的集合的祖先設為x。因為這棵子樹內的查詢已經處理完,x的其他子樹節點跟這棵子樹節點的LCA都是一樣的,都為當前根節點x。所有子樹處理完畢之後,處理當前根節點x相關的查詢。遍歷x的所有查詢,如果查詢的另一個節點v已經訪問過了,那麼x和v的LCA即為v所在集合的祖先。

其中關於集合的操作都是使用並查集高效完成。

演算法的複雜度為,O(n)搜尋所有節點,搜尋每個節點時會遍歷這個節點相關的所有查詢。如果總的查詢個數為m,則總的複雜度為O(n+m)

比如上面的例子中,前面處理的節點的順序為4->7->5->1->0->…。

當訪問完4之後,集合{4}跟集合{1}合併,得到{1,4},並且集合祖先為1。然後訪問7。如果(7,4)是一個查詢,由於4已訪問過,於是LCA(7,4)為4所在集合{1,4}的祖先,即1。7訪問完之後,把{7}跟{5}合併,得到{5,7},祖先為5。然後訪問5。如果(5,7)是一個查詢,由於7已訪問過,於是LCA(5,7)為7所在集合{5,7}的祖先,即5。如果(5,4)也是一個查詢,由於4已訪問過,則LCA(5,4)為4所在集合{1,4}的祖先,即1。5訪問完畢之後,把{5,7}跟{1,4}合併,得到{1,4,5,7},並且祖先為1。然後訪問1。如果有(1,4)查詢,則LCA(1,4)為4所在集合{1,4}的祖先,為1。1訪問完之後,把{1,4,5,7}跟{0}合併,得到{0,1,4,5,7},祖先為0。然後剩下的2後面的節點處理類似。

三 演算法實現

接下來提供一個完整演算法實現。

使用鄰接表方法儲存一棵有根樹。並通過記錄節點入度的方法找出有根樹的根,方便後續處理。

const int mx = 10000; //最大頂點數
int n, root;		  //實際頂點個數,樹根節點
int indeg[mx];		  //頂點入度,用來判斷樹根
vector<int> tree[mx]; //樹的鄰接表(不一定是二叉樹)

void inputTree() //輸入樹
{
	scanf("%d", &n); //樹的頂點數
	for (int i = 0; i < n; i++) //初始化樹,頂點編號從0開始
		tree[i].clear(), indeg[i] = 0;

	for (int i = 1; i < n; i++) //輸入n-1條樹邊
	{
		int x, y; scanf("%d%d", &x, &y); //x->y有一條邊
		tree[x].push_back(y); indeg[y]++;//加入鄰接表,y入度加一
	}

	for (int i = 0; i < n; i++) //尋找樹根,入度為0的頂點
		if (indeg[i] == 0) { root = i; break; }
}

使用vector陣列query儲存所有的查詢。跟x相關的所有查詢(x,y)都會放在query[x]的陣列中,方便查詢。

vector<int> query[mx]; //所有查詢的內容
void inputQuires() //輸入查詢
{
	for (int i = 0; i < n; i++) //清空上次查詢
		query[i].clear(); 

	int m; scanf("%d", &m); //查詢個數
	while (m--)
	{
		int u, v; scanf("%d%d", &u, &v); //查詢u和v的LCA
		query[u].push_back(v); query[v].push_back(u);
	}
}

然後是並查集的相關資料和操作。

int father[mx], rnk[mx]; //節點的父親、秩
void makeSet() //初始化並查集
{
	for (int i = 0; i < n; i++) father[i] = i, rnk[i] = 0;
}
int findSet(int x) //查詢
{
	if (x != father[x]) father[x] = findSet(father[x]);
	return father[x];
}
void unionSet(int x, int y) //合併
{
	x = findSet(x), y = findSet(y);
	if (x == y) return;
	if (rnk[x] > rnk[y]) father[y] = x;
	else father[x]  = y, rnk[y] += rnk[x] == rnk[y];
}

再就是Tarjan演算法的核心程式碼。

在呼叫Tarjan之前已經初始化並查集給每個節點建立了一個集合,並且把集合的祖先賦值為自己了,因而這裡不用給根節點x單獨建立。

int ancestor[mx]; //已訪問節點集合的祖先
bool vs[mx];	  //訪問標誌
void Tarjan(int x) //Tarjan演算法求解LCA
{
	for (int i = 0; i < tree[x].size(); i++)
	{
		Tarjan(tree[x][i]);		 //訪問子樹
		unionSet(x, tree[x][i]); //將子樹節點與根節點x的集合合併 
		ancestor[findSet(x)] = x;//合併後的集合的祖先為x
	}
	vs[x] = 1; //標記為已訪問
	for (int i = 0; i < query[x].size(); i++) //與根節點x有關的查詢
		if (vs[query[x][i]]) //如果查詢的另一個節點已訪問,則輸出結果
			printf("%d和%d的最近公共祖先為:%d\n", x, 
					query[x][i], ancestor[findSet(query[x][i])]);
}

下面是主程式,再加一個樣例輸入輸出作為測試。

int main()
{
	inputTree();  //輸入樹
	inputQuires();//輸入查詢

	makeSet(); 
	for (int i = 0; i < n; i++) ancestor[i] = i; 
	memset(vs, 0, sizeof(vs)); //初始化為未訪問
	Tarjan(root);
	/*前面例子相關的一個輸入輸出如下:
	8  
	0 1   0 2   0 3   1 4   1 5   5 7   3 6
	7
	1 4   4 5   4 7   5 7   0 5   4 3   1 6
	7和4的最近公共祖先為:1
	5和4的最近公共祖先為:1
	5和7的最近公共祖先為:5
	1和4的最近公共祖先為:1
	6和1的最近公共祖先為:0
	3和4的最近公共祖先為:0
	0和5的最近公共祖先為:0
	*/
}