並查集的理解與實現總結

sparkle merit發表於2017-11-25

並查集的應用十分廣泛,包括一些演算法,當應用上並查集的時候,也會更容易實現。下面總結下並查集的相關內容。
什麼是並查集?
個人的理解是:並查集就是對集合三種常用操作的再一次抽象。分別是集合的合併(Union)、元素的搜尋(Find)和對集合的分解。因為這3中操作非常常用並且又不囿於集合,所以就把這一組操作抽象成一個獨立的資料結構。
標準定義
在一些應用問題中,需要將n個不同的元素劃分成一組不相交的集合。開始時每個元素自成一個單元素集合,然後按照一定規律將歸於同一組元素的集合合併,在此過程中需要反覆查詢某個元素歸屬於哪個集合的運算,適合於描述這類問題的抽象資料型別稱為並查集(union-find set)。
並查集的3種操作!
從上面的定義也可以看出來,並查集的三種操作是:

(1)Union(Root1, Root2):把子集合Root2併入集合Root1中。要求這兩個集合互不相交,否則不執行合併。
(2)Find(x):搜尋單元素x所在的集合,並返回該集合的名字。
(3)UnionFindSets(s):建構函式,將並查集中s個元素初始化為s個只有一個單元素的子集合。

並查集的一種實現方案
實現並查集的方式有多種,這裡主要總結用**樹結構(父指標表示法)**來實現並查集及其相關操作。
用這種實現方式,每個集合用一棵樹表示,樹的每一個節點代表集合的一個單元素。所有各個集合的全集合構成一個森林,並用樹與森林的父指標表示法來實現。其下標代表元素名。第I個陣列元素代表包含集合元素I的樹節點。樹的根節點的下標代表集合名,根節點的父為-1,表示集合中元素個數。
下面看一個例子:
全集合是S = {0,1,2,3,4,5,6,7,8,9},初始化每個元素自成為一個單元素子集合。(書上原圖,感覺挺清晰的)
這裡寫圖片描述
經過一段時間的計算,這些子集合併成3個集合,他們是全集合S的子集合:S1 = {0,6,7,8},S2= {1,4,9},S3 = {2,3,5}。則表示他們並查集的樹形結構如下圖:
這裡寫圖片描述
上面陣列中的元素值有兩種含義:
(1)負數表示當前節點是樹的根節點,負數的絕對值表示樹中節點的個數,也即集合中元素的個數。
(2)正數表示其所屬的樹的根節點,由樹形表示很容易理解,這也是樹的父指標表示的定義。
經過上面對相關資料的組織,再回頭來看並查集的3中核心操作是怎樣依託於樹來實現的:

(1)將root2併入到root1中,其實就可以直接把root2的陣列元素(就是他的父節點)改成root1的名字(就是他所在的陣列下標)。
下面的圖表示了合併兩個子集合的過程:
這裡寫圖片描述
(2)查詢x所屬於的根節點(或者說是x所屬於的集合),就可以一直找array[x],直到array[x]小於0,則證明找到了根(所在集合)。
下面的圖示意了查詢一個節點所屬集合的過程:
這裡寫圖片描述
(3)將整個集合初始化為單元素集合,其實就是建立樹的父指標陣列的過程,把陣列元素全初始化為-1,也就表示了每個元素都各佔一個集合。

有了上面的理論,程式碼也比較容易實現出來!下面給出了一個程式碼的例項:

/*
*樹結構構建並查集,其中樹用父指標形式表示
*/
#include <iostream>

const int DefaultSize = 10;

class UFSets {										//集合中的各個子集合互不相交
public:
	UFSets(int sz = DefaultSize);					//建構函式       (並查集的基本操作)
	~UFSets() { delete[] parent; }					//解構函式
	UFSets& operator = (UFSets& R);					//過載函式:集合賦值
	void Union(int Root1, int Root2);				//兩個子集合合併 (並查集的基本操作)
	int Find(int x);								//搜尋x所在集合	 (並查集的基本操作)
	void WeightedUnion(int Root1, int Root2);		//加權的合併演算法 
private:
	int *parent;		//集合元素陣列(父指標陣列)
	int size;			//集合元素的數目
};

UFSets::UFSets(int sz) {
	//建構函式,sz是集合元素的個數,父指標陣列的範圍0到sz-1
	size = sz;										//集合元素的個數
	parent = new int[size];							//開闢父指標陣列
	for (int i = 0; i < size; i ++) {				//初始化父指標陣列
		parent[i] = -1;								//每個自成單元素集合
	}
}

int UFSets::Find(int x) {
	//函式搜尋並返回包含元素x的樹的根
	while (parent[x] >= 0) {
		x = parent[x];
	}
	return x;
}

void UFSets::Union(int Root1, int Root2) {
	//函式求兩個不相交集合的並,要求Root1與Root2是不同的,且表示了子集合的名字
	parent[Root1] += parent[Root2];					//更新Root1的元素個數
	parent[Root2] = Root1;							//令Root1作為Root2的父節點
}

void UFSets::WeightedUnion(int Root1, int Root2) {
	//使用節點個數探查方法求兩個UFSets集合的並
	int r1 = Find(Root1);							//找到root1集合的根
	int r2 = Find(Root2);							//找到root2集合的根
	if (r1 != r2) {									//兩個集合不屬於同一樹
		int temp = parent[r1] + parent[r2];			//計算總節點數
		if (parent[r2] < parent[r1]) {				//注意比較的是負數,越小元素越多,此處是r2元素多
			parent[r1] = r2;						//r1作為r2的孩子 
			parent[r2] = temp;						//更新r2的節點個數
		}
		else {
			parent[r2] = r1;						//...
			parent[r1] = temp;						//...
		}
	}
}



程式碼的註釋比較詳盡,我就不在贅言。但是有一個注意點我已經寫在了下面!

當前並查集的改進!
的確,有一個極端的狀況使得上面的樹實現的並查集效能低下!問題原因在於,這裡沒有規定子集合並的順序,更確切的說是子集一直在向同一個方向依附:
下面的圖片展示了當Union(0,1),Union(1,2),Union(2,3),Union(3,4)執行完後的樹的形狀。
這裡寫圖片描述
在這種極端情況下他編變成了一個單連結串列(退化的樹),這樣的話,用Find函式查詢完所有的節點所歸屬的集合將會開銷的時間複雜度為:O(n^2)。
怎樣來改變這種狀況,就是在合併兩個子集的時候,規定順序,程式碼中是讓元素多的始終作為父節點,這樣就避免了這種麻煩。
另外還可以用效能更加的查詢演算法,例如摺疊規則壓縮路徑。並查集的一個重要的應用是在圖論中生成最小生成樹的Kruskal演算法,充分體現了並查集的優越性和思想,之後會寫相關的博文總結此演算法!

參考書目:《資料結構 C++語言描述 第二版》 殷人昆著

想一起解決程式設計中遇到的麻煩嗎?想一起學習更多軟體知識嗎?想找一群志同道合的朋友嗎?想找到自己關於計算機真正的興趣所在嗎?那就加入我們吧!(老學長公眾號剛剛開通不久,每隔3天會發表一篇有質量的文章,希望大家多支援!)
公眾號:奇妙的coco

相關文章