1.定義
並查集是一種維護集合的數據結構,它的名字中“並”、“査”、“集”分別取自Union(合併)、Find(査找)、Set(集合)這3個單詞。也就是說,並查集支持下面兩個操作:
①合併:合併兩個集合。
②査找:判斷兩個元素是否在一個集合。
那麼並査集是用什麼實現的呢?其實就是用一個數組:
int father[N];
其中fahter[i]表示元素i的父親結點,而父親結點本身也是這個集合內的元素(1≤i≤N)。例如father[1]=2就表示元素1的父親結點是元素2,以這種父系關係來表示元素所屬的集合。另外,如果 father[i]=i,則說明元素i是該集合的根結點,但對同一個集合來說只存在一個根結點,且將其作爲所屬集合的標識。
如下圖所示,father數組的情況如下:
2.基本操作
總體來說,並查集的使用需要先初始化father數組,然後再根據需要進行查找或合併的操作。
(1).初始化
一開始,每個元素都是獨立的一個集合,因此需要令所有 father[i]等於i:
for(int i=1;i<=N;i++){
father[i] = i; //令father[i]爲-1也可,此處father[i]=i爲例
}
(2).查找
由於規定同一個集合中只存在一個根結點,因此查找操作就是對給定的結點尋找其根結點的過程。實現的方式可以是遞推或是遞歸,但是其思路都是一樣的,即反覆尋找父親結點,直到找到根結點(即father[i]=i的結點)先來看遞推的代碼:
//finderFather函數返回元素x所在集合的根結點
int finderFather(int x){
while(x != father[x]){ //如果不是根結點,繼續循環
x = father[x]; //獲得自己的父親結點
}
return x;
}
以上圖爲例,要查找元素4的根結點是誰,應按照上面的遞推方法,流程如下:
①x=4,father[4]=2,因此4!= father[4],於是繼續查;
②x=2,father[2]=1,因此2!= father[2],於是繼續查;
③x=1,father[1]=1,因此1= father[1],找到根結點,返回1。
當然,這個過程也可以用遞歸來實現:
int finderFather(int x){
if(x == father[x])
return x; //如果找到根結點,則返回結點編號x
else
return finderFather(father[x]); //否則,遞歸判斷x的父親結點是否是根結點
}
3.合併
合併是指把兩個集合合併成一個集合,題目中一般給出兩個元素,要求把這兩個元素所在的集合合併。具體實現上一般是先判斷兩個元素是否屬於同一個集合,只有當兩個元素屬於不同集合時才合併,而合併的過程一般是把其中一個集合的根結點的父親指向另一個集合的根結點。
於是思路就比較清晰了,主要分爲以下兩步:
①對於給定的兩個元素a、b,判斷它們是否屬於同一集合。可以調用上面的查找函數,對這兩個元素a、b分別查找根結點,然後再判斷其根結點是否相同。
②合併兩個集合:在①中已經獲得了兩個元素的根結點faA與faB,因此只需要把其中的父親結點指向另一個結點。例如可以令 father[faA]=faB,當然反過來令 father[faB]=faA也是可以的,兩者沒有區別。
以下圖爲例,把元素4和元素6合併
過程如下:
①判斷元素4和元素6是否屬於同一個集合:元素4所在集合的根結點是1,元素6所在集合的根結點是5,因此它們不屬於同一個集合。
②合併兩個集合:令 father[5]=1,即把元素5的父親設爲元素1於是有了合併後的集合,如圖下圖所示。
現在可以寫出合併的代碼了:
void Union(int a, int b){
int faA = findFather(a); //查找a的根結點,記爲faA
int faB = findFather(a); //查找a的根結點,記爲faB
if(faA!=faB){ //如果不屬於同一個集合
father[faA] = faB; //合併它們
}
}
這裏需要注意的是,很多初學者會直接把其中一個元素的父親設爲另一個元素,即直接令 father[a]=b來進行合併,這並不能實現將集合合併的效果。例如,將上面例子中的 father[4]設爲6,或是把 father[6]設爲4,就不能實現集合合併的效果,如下圖所示。
因此,初學者使用上面給出的Union函數來進行合併操作。
最後說明並査集的一個性質。在合併的過程中,只對兩個不同的集合進行合併,如果兩個元素在相同的集合中,那麼就不會對它們進行操作。這就保證了在同一個集合中一定不會產生環,即並査集產生的每一個集合都是一棵樹。
4.路徑壓縮
上面講解的並查集查找函數是沒有經過優化的,在極端情況下效率較低。現在來考慮一種情況,即題目給出的元素數量很多並且形成一條鏈,那麼這個查找函數的效率就會非常低如下圖所示,總共有10^5個元素形成一條鏈。那麼假設要進行10^5次査詢,且每次查詢都查詢最後面的結點的根結點,那麼每次都要花費10^5的計算量查找,這顯然無法承受。
那應該如何去優化查詢操作呢?由於finderFather函數的目的就是查找根結點,例如下面這個例子:
father[1] = 1;
father[2] = 1;
father[3] = 2;
father[4] = 3;
因此,如果只是爲了査找根結點,那麼完全可以想辦法把操作等價地變成:
對應的圖形變化如下:
這樣相當於把當前查詢結點的路徑上的所有結點的父親都指向根結點,查找的時候就不需要一直回溯去找父親了,查詢的複雜度可以降爲O(1)。
那麼,如何實現這種轉換呢?回憶之前查找函數finderFather的查找過程,可以知道是從給定結點不斷獲得其父親結點而最終到達根結點的。因此轉換的過程可以概括爲如下兩個步驟:
①按原先的寫法獲得x的根結點r。
②重新從x開始走一遍尋找根結點的過程,把路徑上經過的所有結點的父親全部改爲根結點r。
於是可以寫出代碼:
int findFather(int x){
//由於x在下面的while中會變成根結點,因此先把原來的x保存一下
int a = x;
while(x != father[x]){ //尋找根結點
x = father[x];
}
//到這裏,x存放的是根結點,下面就把路徑上的所有的結點father都改爲根結點
while(a != father[a]){
int z= a; //因爲a要被father[a]覆蓋,所以先保存a的值,以修改father[a]
a = father[a]; //a回溯父親結點
father[z] = x; //將原先的根結點a的父結點改爲根結點
}
return x; //返回根結點
}
這樣就可以在查找時把尋找根結點的路徑壓縮了。由於涉及一些複雜的數學推導,讀者可以把路徑壓縮後的並查集查找函數均推效率認爲是一個幾乎爲O(1)的操作。而喜歡遞歸的讀者,,也可以採用下面的遞歸寫法:
int findFather(int x){
if(x == father[x])
return x; //如果找到根結點,則返回結點編號x
else{
int F = findFather(father[x]); //提櫃尋找father[x]的根結點F
father[x] = F; //將根結點F賦值給father[x]
return F; //返回根結點
}
}