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