在一些有N個元素的集合應用問題中,我們通常是在開始時讓每個元素構成一個單元素的集合,然后按一定順序將屬於同一組的元素所在的集合合並,其間要反復查找一個元素在哪個集合中。這一類問題近幾年來反復出現在信息學的國際國內賽題中,其特點是看似並不復雜,但數據量極大,若用正常的數據結構來描述的話,往往在空間上過大,計算機無法承受;即使在空間上勉強通過,運行的時間復雜度也極高,根本就不可能在比賽規定的運行時間(1~3秒)內計算出試題需要的結果,只能用並查集來描述。
並查集是一種樹型的數據結構,用於處理一些不相交集合(Disjoint Sets)的合並及查詢問題。常常在使用中以tree來表示。
用途
維護一個無向圖的連通性,判斷n個點m條邊時最少加多少邊可以連通所有點
判斷在一個無向圖中,兩點間加邊是否會產生環(最小生成樹克魯斯卡爾中有用到)
維護集合等操作
操作
(1)Union(Root1, Root2):把子集合Root2並入集合Root1中。要求這兩個集合互不相交,否則不執行合並。
(2)Find(x):搜索單元素x所在的集合,並返回該集合的名字。
(3)UnionFindSets(s):構造函數,將並查集中s個元素初始化為s個只有一個單元素的子集合。
初始化
用數組來建立一個並查集,數組下標代表元素,下標對應的值代表父節點,全部初始化為-1,根節點為一個集合的元素個數,數組的長度為並查集的初始連通分量的個數。並查集要求各集合是不相交的,因此要求x沒有在其他集合中出現過。算法如下:
//構造函數
UF(int size){ this->count = size; array = new int[size]; for(int i = 0 ; i < size ; i++){ this->array[i] = -1; } }
查找操作
返回能代表x所在集合的節點,通常返回x所在集合的根節點。這里的查找操作通常采用路徑壓縮的辦法,即在查找過程中組不減小樹的高度,把元素逐步指向一開始的根節點。這樣下次再找根節點的時間復雜度會變成o(1)。如下圖所示
算法如下:
//查找操作,路徑壓縮
int Find(int x){ if(this->array[x] < 0){ return x; }else{ //首先查找x的父節點array[x],然后把根變成array[x],之后再返回根 return this->array[x] = Find(this->array[x]); } }
並操作
將包含x,y的動態集合合並為一個新的集合。合並兩個集合的關鍵是找到兩個集合的根節點,如果兩個根節點相同則不用合並;如果不同,則需要合並。
這里對並操作有兩種優化:根節點存樹高的相反數或者根節點存集合的個數的相反數,這兩種方法統稱按秩歸並。通常選用第二種方法。
歸並過程如下圖:
算法如下:
//並操作,跟結點存儲集合元素個數的負數
//通過對根結點的比較
void Uion(int root1, int root2){ root1 = this->Find(root1); root2 = this->Find(root2); if(root1 == root2){ return; }else if(this->array[root1] < this->array[root2]){ //root1所代表的集合的個數大於root2所代表集合的個數 //因為為存放的是元素個數的負數 this->array[root1] += this->array[root2]; this->array[root2] = root1; count--; }else{ this->array[root2] += this->array[root1]; this->array[root1] = root2; count--; } } }
實現方案
1 樹結構(父指針表示法)
用這種實現方式,每個集合用一棵樹表示,樹的每一個節點代表集合的一個單元素。所有各個集合的全集合構成一個森林,並用樹與森林的父指針表示法來實現。其下標代表元素名。第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)。