並查集(Disjoint Set)


http://www.cnblogs.com/cyjb/p/UnionFindSets.html

http://blog.csdn.net/dm_vincent/article/details/7655764

http://blog.csdn.net/dm_vincent/article/details/7769159

並查集(Union-find Sets)是一種非常精巧而實用的數據結構,它主要用於處理一些不相交集合的合並問題。一些常見的用途有求連通子圖、求最小生成樹的 Kruskal 算法和求最近公共祖先(Least Common Ancestors, LCA)等。

使用並查集時,首先會存在一組不相交的動態集合 S={S1,S2,,Sk}S={S1,S2,⋯,Sk},一般都會使用一個整數表示集合中的一個元素。

每個集合可能包含一個或多個元素,並選出集合中的某個元素作為代表。每個集合中具體包含了哪些元素是不關心的,具體選擇哪個元素作為代表一般也是不關心的。我們關心的是,對於給定的元素,可以很快的找到這個元素所在的集合(的代表),以及合並兩個元素所在的集合,而且這些操作的時間復雜度都是常數級的。

並查集的基本操作有三個:

  1. makeSet(s):建立一個新的並查集,其中包含 s 個單元素集合。
  2. unionSet(x, y):把元素 x 和元素 y 所在的集合合並,要求 x 和 y 所在的集合不相交,如果相交則不合並。
  3. find(x):找到元素 x 所在的集合的代表,該操作也可以用於判斷兩個元素是否位於同一個集合,只要將它們各自的代表比較一下就可以了。

並查集的實現原理也比較簡單,就是使用樹來表示集合,樹的每個節點就表示集合中的一個元素,樹根對應的元素就是該集合的代表,如圖 1 所示。

圖 1 並查集的樹表示

圖中有兩棵樹,分別對應兩個集合,其中第一個集合為 {a,b,c,d}{a,b,c,d},代表元素是 aa;第二個集合為 {e,f,g}{e,f,g},代表元素是 ee。

樹的節點表示集合中的元素,指針表示指向父節點的指針,根節點的指針指向自己,表示其沒有父節點。沿着每個節點的父節點不斷向上查找,最終就可以找到該樹的根節點,即該集合的代表元素。

現在,應該可以很容易的寫出 makeSet 和 find 的代碼了,假設使用一個足夠長的數組來存儲樹節點(很類似之前講到的靜態鏈表),那么 makeSet 要做的就是構造出如圖 2 的森林,其中每個元素都是一個單元素集合,即父節點是其自身:

圖 2 構造並查集初始化

相應的代碼如下所示,時間復雜度是 O(n)O(n):

1
2
3
4
5
6
const  int  MAXSIZE = 500;
int  uset[MAXSIZE];
 
void  makeSet( int  size) {
     for ( int  i = 0;i < size;i++) uset[i] = i;
}

接下來,就是 find 操作了,如果每次都沿着父節點向上查找,那時間復雜度就是樹的高度,完全不可能達到常數級。這里需要應用一種非常簡單而有效的策略——路徑壓縮。

路徑壓縮,就是在每次查找時,令查找路徑上的每個節點都直接指向根節點,如圖 3 所示。

圖 3 路徑壓縮

我准備了兩個版本的 find 操作實現,分別是遞歸版和非遞歸版,不過兩個版本目前並沒有發現有什么明顯的效率差距,所以具體使用哪個完全憑個人喜好了。

1
2
3
4
5
6
7
8
9
10
int  find( int  x) {
     if  (x != uset[x]) uset[x] = find(uset[x]);
     return  uset[x];
}
int  find( int  x) {
     int  p = x, t;
     while  (uset[p] != p) p = uset[p];
     while  (x != p) { t = uset[x]; uset[x] = p; x = t; }
     return  x;
}

最后是合並操作 unionSet,並查集的合並也非常簡單,就是將一個集合的樹根指向另一個集合的樹根,如圖 4 所示。

圖 4 並查集的合並

這里也可以應用一個簡單的啟發式策略——按秩合並。該方法使用秩來表示樹高度的上界,在合並時,總是將具有較小秩的樹根指向具有較大秩的樹根。簡單的說,就是總是將比較矮的樹作為子樹,添加到較高的樹中。為了保存秩,需要額外使用一個與 uset 同長度的數組,並將所有元素都初始化為 0。

1
2
3
4
5
6
7
8
void  unionSet( int  x,  int  y) {
     if  ((x = find(x)) == (y = find(y)))  return ;
     if  (rank[x] > rank[y]) uset[y] = x;
     else  {
         uset[x] = y;
         if  (rank[x] == rank[y]) rank[y]++;
     }
}

下面是按秩合並的並查集的完整代碼,這里只包含了遞歸的 find 操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const  int  MAXSIZE = 500;
int  uset[MAXSIZE];
int  rank[MAXSIZE];
 
void  makeSet( int  size) {
     for ( int  i = 0;i < size;i++)  uset[i] = i;
     for ( int  i = 0;i < size;i++)  rank[i] = 0;
}
int  find( int  x) {
     if  (x != uset[x]) uset[x] = find(uset[x]);
     return  uset[x];
}
void  unionSet( int  x,  int  y) {
     if  ((x = find(x)) == (y = find(y)))  return ;
     if  (rank[x] > rank[y]) uset[y] = x;
     else  {
         uset[x] = y;
         if  (rank[x] == rank[y]) rank[y]++;
     }
}

除了按秩合並,並查集還有一種常見的策略,就是按集合中包含的元素個數(或者說樹中的節點數)合並,將包含節點較少的樹根,指向包含節點較多的樹根。這個策略與按秩合並的策略類似,同樣可以提升並查集的運行速度,而且省去了額外的 rank 數組。

這樣的並查集具有一個略微不同的定義,即若 uset 的值是正數,則表示該元素的父節點(的索引);若是負數,則表示該元素是所在集合的代表(即樹根),而且值的相反數即為集合中的元素個數。相應的代碼如下所示,同樣包含遞歸和非遞歸的 find 操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
const  int  MAXSIZE = 500;
int  uset[MAXSIZE];
 
void  makeSet( int  size) {
     for ( int  i = 0;i < size;i++) uset[i] = -1;
}
int  find( int  x) {
     if  (uset[x] < 0)  return  x;
     uset[x] = find(uset[x]);
     return  uset[x];
}
int  find( int  x) {
     int  p = x, t;
     while  (uset[p] >= 0) p = uset[p];
     while  (x != p) {
         t = uset[x];
         uset[x] = p;
         x = t;
     }
     return  x;
}
void  unionSet( int  x,  int  y) {
     if  ((x = find(x)) == (y = find(y)))  return ;
     if  (uset[x] < uset[y]) {
         uset[x] += uset[y];
         uset[y] = x;
     else  {
         uset[y] += uset[x];
         uset[x] = y;
     }
}

如果要獲取某個元素 x 所在集合包含的元素個數,可以使用 -uset[find(x)] 得到。

並查集的空間復雜度是 O(n)O(n) 的,這個很顯然,如果是按秩合並的,占的空間要多一些。find 和 unionSet 操作都可以看成是常數級的,或者准確來說,在一個包含 nn 個元素的並查集中,進行 mm 次查找或合並操作,最壞情況下所需的時間為 O(mα(n))O(mα(n)),這里的 αα 是 Ackerman 函數的某個反函數,在極大的范圍內(比可觀察到的宇宙中估計的原子數量 10801080 還大很多)都可以認為是不大於 4 的。具體的時間復雜度分析,請參見《算法導論》的 21.4 節 帶路徑壓縮的按秩合並的分析。

 

 

本文主要介紹解決動態連通性一類問題的一種算法,使用到了一種叫做並查集的數據結構,稱為Union-Find

更多的信息可以參考Algorithms 一書的Section 1.5,實際上本文也就是基於它的一篇讀后感吧。

原文中更多的是給出一些結論,我嘗試給出一些思路上的過程,即為什么要使用這個方法,而不是別的什么方法。我覺得這個可能更加有意義一些,相比於記下一些結論。

 

 

關於動態連通性

我們看一張圖來了解一下什么是動態連通性:


 

 

假設我們輸入了一組整數對,即上圖中的(4, 3) (3, 8)等等,每對整數代表這兩個points/sites是連通的。那么隨着數據的不斷輸入,整個圖的連通性也會發生變化,從上圖中可以很清晰的發現這一點。同時,對於已經處於連通狀態的points/sites,直接忽略,比如上圖中的(8, 9)

 

 

動態連通性的應用場景:

  • 網絡連接判斷:

如果每個pair中的兩個整數分別代表一個網絡節點,那么該pair就是用來表示這兩個節點是需要連通的。那么為所有的pairs建立了動態連通圖后,就能夠盡可能少的減少布線的需要,因為已經連通的兩個節點會被直接忽略掉。

  • 變量名等同性(類似於指針的概念)

在程序中,可以聲明多個引用來指向同一對象,這個時候就可以通過為程序中聲明的引用和實際對象建立動態連通圖來判斷哪些引用實際上是指向同一對象。

 

對問題建模:

在對問題進行建模的時候,我們應該盡量想清楚需要解決的問題是什么。因為模型中選擇的數據結構和算法顯然會根據問題的不同而不同,就動態連通性這個場景而言,我們需要解決的問題可能是:

  • 給出兩個節點,判斷它們是否連通,如果連通,不需要給出具體的路徑
  • 給出兩個節點,判斷它們是否連通,如果連通,需要給出具體的路徑

 

就上面兩種問題而言,雖然只有是否能夠給出具體路徑的區別,但是這個區別導致了選擇算法的不同,本文主要介紹的是第一種情況,即不需要給出具體路徑的Union-Find算法,而第二種情況可以使用基於DFS的算法。

 

建模思路:

最簡單而直觀的假設是,對於連通的所有節點,我們可以認為它們屬於一個組,因此不連通的節點必然就屬於不同的組。隨着Pair的輸入,我們需要首先判斷輸入的兩個節點是否連通。如何判斷呢?按照上面的假設,我們可以通過判斷它們屬於的組,然后看看這兩個組是否相同,如果相同,那么這兩個節點連通,反之不連通。為簡單起見,我們將所有的節點以整數表示,即對N個節點使用0N-1的整數表示。而在處理輸入的Pair之前,每個節點必然都是孤立的,即他們分屬於不同的組,可以使用數組來表示這一層關系,數組的index是節點的整數表示,而相應的值就是該節點的組號了。該數組可以初始化為:

 

 

[java]  view plain  copy
 
 print?
  1. for(int i = 0; i < size; i++)  
  2.     id[i] = i;    

 

即對於節點i,它的組號也是i

 

初始化完畢之后,對該動態連通圖有幾種可能的操作:

  • 查詢節點屬於的組

數組對應位置的值即為組號

  • 判斷兩個節點是否屬於同一個組

分別得到兩個節點的組號,然后判斷組號是否相等

  • 連接兩個節點,使之屬於同一個組

分別得到兩個節點的組號,組號相同時操作結束,不同時,將其中的一個節點的組號換成另一個節點的組號

  • 獲取組的數目

初始化為節點的數目,然后每次成功連接兩個節點之后,遞減1

 

API

我們可以設計相應的API



 


 

 

注意其中使用整數來表示節點,如果需要使用其他的數據類型表示節點,比如使用字符串,那么可以用哈希表來進行映射,即將String映射成這里需要的Integer類型。

 

分析以上的API,方法connectedunion都依賴於findconnected對兩個參數調用兩次find方法,而union在真正執行union之前也需要判斷是否連通,這又是兩次調用find方法。因此我們需要把find方法的實現設計的盡可能的高效。所以就有了下面的Quick-Find實現。

 

 

Quick-Find 算法:

[java]  view plain  copy
 
 print?
  1. public class UF  
  2. {  
  3.     private int[] id; // access to component id (site indexed)  
  4.     private int count; // number of components  
  5.     public UF(int N)  
  6.     {  
  7.         // Initialize component id array.  
  8.         count = N;  
  9.         id = new int[N];  
  10.         for (int i = 0; i < N; i++)  
  11.             id[i] = i;  
  12.     }  
  13.     public int count()  
  14.     { return count; }  
  15.     public boolean connected(int p, int q)  
  16.     { return find(p) == find(q); }  
  17.     public int find(int p)  
  18.     { return id[p]; }  
  19.     public void union(int p, int q)  
  20.     {   
  21.         // 獲得p和q的組號  
  22.         int pID = find(p);  
  23.         int qID = find(q);  
  24.         // 如果兩個組號相等,直接返回  
  25.         if (pID == qID) return;  
  26.         // 遍歷一次,改變組號使他們屬於一個組  
  27.         for (int i = 0; i < id.length; i++)  
  28.             if (id[i] == pID) id[i] = qID;  
  29.         count--;  
  30.     }  
  31. }  

舉個例子,比如輸入的Pair(5 9),那么首先通過find方法發現它們的組號並不相同,然后在union的時候通過一次遍歷,將組號1都改成8。當然,由8改成1也是可以的,保證操作時都使用一種規則就行。

 


 

 

上述代碼的find方法十分高效,因為僅僅需要一次數組讀取操作就能夠找到該節點的組號,但是問題隨之而來,對於需要添加新路徑的情況,就涉及到對於組號的修改,因為並不能確定哪些節點的組號需要被修改,因此就必須對整個數組進行遍歷,找到需要修改的節點,逐一修改,這一下每次添加新路徑帶來的復雜度就是線性關系了,如果要添加的新路徑的數量是M,節點數量是N,那么最后的時間復雜度就是MN,顯然是一個平方階的復雜度,對於大規模的數據而言,平方階的算法是存在問題的,這種情況下,每次添加新路徑就是“牽一發而動全身”,想要解決這個問題,關鍵就是要提高union方法的效率,讓它不再需要遍歷整個數組。

 

Quick-Union 算法:

考慮一下,為什么以上的解法會造成“牽一發而動全身”?因為每個節點所屬的組號都是單獨記錄,各自為政的,沒有將它們以更好的方式組織起來,當涉及到修改的時候,除了逐一通知、修改,別無他法。所以現在的問題就變成了,如何將節點以更好的方式組織起來,組織的方式有很多種,但是最直觀的還是將組號相同的節點組織在一起,想想所學的數據結構,什么樣子的數據結構能夠將一些節點給組織起來?常見的就是鏈表,圖,樹,什么的了。但是哪種結構對於查找和修改的效率最高?毫無疑問是樹,因此考慮如何將節點和組的關系以樹的形式表現出來。

 

如果不改變底層數據結構,即不改變使用數組的表示方法的話。可以采用parent-link的方式將節點組織起來,舉例而言,id[p]的值就是p節點的父節點的序號,如果p是樹根的話,id[p]的值就是p,因此最后經過若干次查找,一個節點總是能夠找到它的根節點,即滿足id[root] = root的節點也就是組的根節點了,然后就可以使用根節點的序號來表示組號。所以在處理一個pair的時候,將首先找到pair中每一個節點的組號(即它們所在樹的根節點的序號),如果屬於不同的組的話,就將其中一個根節點的父節點設置為另外一個根節點,相當於將一顆獨立的樹編程另一顆獨立的樹的子樹。直觀的過程如下圖所示。但是這個時候又引入了問題。


 

 

在實現上,和之前的Quick-Find只有findunion兩個方法有所不同:

[java]  view plain  copy
 
 print?
  1. private int find(int p)  
  2. {   
  3.     // 尋找p節點所在組的根節點,根節點具有性質id[root] = root  
  4.     while (p != id[p]) p = id[p];  
  5.     return p;  
  6. }  
  7. public void union(int p, int q)  
  8. {   
  9.     // Give p and q the same root.  
  10.     int pRoot = find(p);  
  11.     int qRoot = find(q);  
  12.     if (pRoot == qRoot)   
  13.         return;  
  14.     id[pRoot] = qRoot;    // 將一顆樹(即一個組)變成另外一課樹(即一個組)的子樹  
  15.     count--;  
  16. }  


樹這種數據結構容易出現極端情況,因為在建樹的過程中,樹的最終形態嚴重依賴於輸入數據本身的性質,比如數據是否排序,是否隨機分布等等。比如在輸入數據是有序的情況下,構造的BST會退化成一個鏈表。在我們這個問題中,也是會出現的極端情況的,如下圖所示。

 

 

 

為了克服這個問題,BST可以演變成為紅黑樹或者AVL樹等等。

 

然而,在我們考慮的這個應用場景中,每對節點之間是不具備可比性的。因此需要想其它的辦法。在沒有什么思路的時候,多看看相應的代碼可能會有一些啟發,考慮一下Quick-Union算法中的union方法實現:

[java]  view plain  copy
 
 print?
  1. public void union(int p, int q)  
  2. {   
  3.     // Give p and q the same root.  
  4.     int pRoot = find(p);  
  5.     int qRoot = find(q);  
  6.     if (pRoot == qRoot)   
  7.         return;  
  8.     id[pRoot] = qRoot;  // 將一顆樹(即一個組)變成另外一課樹(即一個組)的子樹  
  9.     count--;  
  10. }  


上面 id[pRoot] = qRoot 這行代碼看上去似乎不太對勁。因為這也屬於一種“硬編碼”,這樣實現是基於一個約定,即p所在的樹總是會被作為q所在樹的子樹,從而實現兩顆獨立的樹的融合。那么這樣的約定是不是總是合理的呢?顯然不是,比如p所在的樹的規模比q所在的樹的規模大的多時,pq結合之后形成的樹就是十分不和諧的一頭輕一頭重的”畸形樹“了。

 

 

 

所以我們應該考慮樹的大小,然后再來決定到底是調用:

id[pRoot] = qRoot 或者是 id[qRoot] = pRoot


 

 

 

即總是size小的樹作為子樹和size大的樹進行合並。這樣就能夠盡量的保持整棵樹的平衡。

 

所以現在的問題就變成了:樹的大小該如何確定?

我們回到最初的情形,即每個節點最一開始都是屬於一個獨立的組,通過下面的代碼進行初始化:

[java]  view plain  copy
 
 print?
  1. for (int i = 0; i < N; i++)  
  2.     id[i] = i;    // 每個節點的組號就是該節點的序號  



 

 

以此類推,在初始情況下,每個組的大小都是1,因為只含有一個節點,所以我們可以使用額外的一個數組來維護每個組的大小,對該數組的初始化也很直觀:

[java]  view plain  copy
 
 print?
  1. for (int i = 0; i < N; i++)  
  2.     sz[i] = 1;    // 初始情況下,每個組的大小都是1  



 

而在進行合並的時候,會首先判斷待合並的兩棵樹的大小,然后按照上面圖中的思想進行合並,實現代碼:

 

[java]  view plain  copy
 
 print?
  1. public void union(int p, int q)  
  2. {  
  3.     int i = find(p);  
  4.     int j = find(q);  
  5.     if (i == j) return;  
  6.     // 將小樹作為大樹的子樹  
  7.     if (sz[i] < sz[j]) { id[i] = j; sz[j] += sz[i]; }  
  8.     else { id[j] = i; sz[i] += sz[j]; }  
  9.     count--;  
  10. }  


Quick-Union  Weighted Quick-Union 的比較:


 

 

 

可以發現,通過sz數組決定如何對兩棵樹進行合並之后,最后得到的樹的高度大幅度減小了。這是十分有意義的,因為在Quick-Union算法中的任何操作,都不可避免的需要調用find方法,而該方法的執行效率依賴於樹的高度。樹的高度減小了,find方法的效率就增加了,從而也就增加了整個Quick-Union算法的效率。

 

上圖其實還可以給我們一些啟示,即對於Quick-Union算法而言,節點組織的理想情況應該是一顆十分扁平的樹,所有的孩子節點應該都在height1的地方,即所有的孩子都直接連接到根節點。這樣的組織結構能夠保證find操作的最高效率。

 

那么如何構造這種理想結構呢?

find方法的執行過程中,不是需要進行一個while循環找到根節點嘛?如果保存所有路過的中間節點到一個數組中,然后在while循環結束之后,將這些中間節點的父節點指向根節點,不就行了么?但是這個方法也有問題,因為find操作的頻繁性,會造成頻繁生成中間節點數組,相應的分配銷毀的時間自然就上升了。那么有沒有更好的方法呢?還是有的,即將節點的父節點指向該節點的爺爺節點,這一點很巧妙,十分方便且有效,相當於在尋找根節點的同時,對路徑進行了壓縮,使整個樹結構扁平化。相應的實現如下,實際上只需要添加一行代碼:

[java]  view plain  copy
 
 print?
  1. private int find(int p)  
  2. {  
  3.     while (p != id[p])  
  4.     {  
  5.         // 將p節點的父節點設置為它的爺爺節點  
  6.         id[p] = id[id[p]];  
  7.         p = id[p];  
  8.     }  
  9.     return p;  
  10. }  


至此,動態連通性相關的Union-Find算法基本上就介紹完了,從容易想到的Quick-Find到相對復雜但是更加高效的Quick-Union,然后到對Quick-Union的幾項改進,讓我們的算法的效率不斷的提高。

這幾種算法的時間復雜度如下所示:

Algorithm

Constructor

Union

Find

Quick-Find

N

N

1

Quick-Union

N

Tree height

Tree height

Weighted Quick-Union

N

lgN

lgN

Weighted Quick-Union With Path Compression

N

Very near to 1 (amortized)

Very near to 1 (amortized)

 

對大規模數據進行處理,使用平方階的算法是不合適的,比如簡單直觀的Quick-Find算法,通過發現問題的更多特點,找到合適的數據結構,然后有針對性的進行改進,得到了Quick-Union算法及其多種改進算法,最終使得算法的復雜度降低到了近乎線性復雜度。

 

如果需要的功能不僅僅是檢測兩個節點是否連通,還需要在連通時得到具體的路徑,那么就需要用到別的算法了,比如DFS或者BFS。

 

 

首先還是回顧和總結一下關於並查集的幾個關鍵點:

 

  1. 以樹作為節點的組織結構,結構的形態很是否采取優化策略有很大關系,未進行優化的樹結構可能會是“畸形”樹(嚴重不平衡,頭重腳輕,退化成鏈表等),按尺寸(正規說法叫做秩,后文全部用秩來表示)進行平衡,同時輔以路徑壓縮后,樹結構會高度扁平化。
  2. 雖然組織結構比較復雜,數據表示方式卻十分簡潔,主要采用數組作為其底層數據結構。一般會使用兩個數組(parent-link array and size array),分別用來保存當前節點的父親節點以及當前節點所代表子樹的秩。第一個數組(parent-link array)無論是否優化,都需要使用,而第二個數組(size array),在不需要按秩合並優化或者不需要保存子樹的秩時,可以不使用。根據應用的不同,可能需要第三個數組來保存其它相關信息,比如HDU-3635中提到的“轉移次數”。
  3. 主要操作包括兩部分,union以及find。union負責對兩顆樹進行合並,合並的過程中可以根據具體應用的性質選擇是否按秩優化。需要注意的是,執行合並操作之前,需要檢查待合並的兩個節點是否已經存在於同一顆樹中,如果兩個節點已經在一棵樹中了,就沒有合並的必要了。這是通過比較兩個節點所在樹的根節點來實現的,而尋找根節點的功能,自然是由find來完成了。find通過parent-link數組中的信息來找到指定節點的根節點,同樣地,也可以根據應用的具體特征,選擇是否采用路徑壓縮這一優化手段。然而在需要保存每個節點代表子樹的秩的時候,則無法采用路徑壓縮,因為這樣會破壞掉非根節點的尺寸信息(注意這里的“每個”,一般而言,在按秩合並的時候,需要的信息僅僅是根節點的秩,這時,路徑壓縮並無影響,路徑壓縮影響的只是非根節點的秩信息)。

 

以上就是我認為並查集中存在的幾個關鍵點。關於並查集更詳盡的演化過程,可以參考上一篇關於並查集的文章:《並查集算法原理和改進

 

言歸正傳,來看幾個利用並查集來解決問題的例子:

(說明:除了第一個問題貼了完整的代碼,后面的問題都只會貼出關鍵部分的代碼)

HDU-1213 How many tables

問題的描述是這樣的:

Today is Ignatius' birthday. He invites a lot of friends. Now it's dinner time. Ignatius wants to know how many tables he needs at least. You have to notice that not all the friends know each other, and all the friends do not want to stay with strangers.

One important rule for this problem is that if I tell you A knows B, and B knows C, that means A, B, C know each other, so they can stay in one table.

For example: If I tell you A knows B, B knows C, and D knows E, so A, B, C can stay in one table, and D, E have to stay in the other one. So Ignatius needs 2 tables at least.

 

對這個問題抽象之后,就是要求進行若干次union操作之后,還會剩下多少顆樹(或者說還剩下多少Connected Components)。反映到這個例子中,就是要求有多少“圈子”。其實,這也是社交網絡中的最基本的功能,每次系統向你推薦的那些好友一般而言,會跟你在一個“圈子”里面,換言之,也就是你可能認識的人,以並查集的視角來看這層關系,就是你們掛在同一顆樹上。

 

給出實現代碼如下:

 

[java]  view plain  copy
 
 print?
  1. import java.io.BufferedReader;  
  2. import java.io.IOException;  
  3. import java.io.InputStreamReader;  
  4. import java.io.PrintWriter;  
  5.   
  6. public class Main {  
  7.   
  8.     public static void main(String[] args) throws NumberFormatException,  
  9.             IOException {  
  10.         BufferedReader br = new BufferedReader(new InputStreamReader(System.in));  
  11.         PrintWriter out = new PrintWriter(System.out);  
  12.   
  13.         int totalCases = Integer.parseInt(br.readLine());  
  14.   
  15.         WeightedQUWithPathCompression uf;  
  16.   
  17.         String[] parts;  
  18.         while (totalCases > 0) {  
  19.             parts = br.readLine().split(" ");  
  20.             // based on 1, not 0  
  21.             uf = new WeightedQUWithPathCompression(  
  22.                     Integer.parseInt(parts[0]) + 1);  
  23.             // construct the uf  
  24.             int tuples = Integer.parseInt(parts[1]);  
  25.             while (tuples > 0) {  
  26.                 parts = br.readLine().split(" ");  
  27.                 uf.union(Integer.parseInt(parts[0]), Integer.parseInt(parts[1]));  
  28.                 tuples--;  
  29.             }  
  30.             out.println(uf.count() - 1);  
  31.             br.readLine();  
  32.             totalCases--;  
  33.         }  
  34.         out.flush();  
  35.     }  
  36. }  
  37.   
  38. class WeightedQUWithPathCompression {     
  39.     private int count;  
  40.     private int[] id;  
  41.     private int[] size;  
  42.   
  43.     public WeightedQUWithPathCompression(int N) {  
  44.         this.count = N;  
  45.         this.id = new int[N];  
  46.         this.size = new int[N];  
  47.   
  48.         for (int i = 0; i < this.count; i++) {  
  49.             id[i] = i;  
  50.             size[i] = 1;  
  51.         }  
  52.     }  
  53.   
  54.     private int find(int p) {  
  55.         while (p != id[p]) {  
  56.             id[p] = id[id[p]];  // 路徑壓縮,會破壞掉當前節點的父節點的尺寸信息,因為壓縮后,當前節點的父節點已經變了  
  57.             p = id[p];  
  58.         }  
  59.   
  60.         return p;  
  61.     }  
  62.   
  63.     public void union(int p, int q) {  
  64.         int pCom = this.find(p);  
  65.         int qCom = this.find(q);  
  66.   
  67.         if (pCom == qCom) {  
  68.             return;  
  69.         }  
  70.         // 按秩進行合並  
  71.         if (size[pCom] > size[qCom]) {  
  72.             id[qCom] = pCom;  
  73.             size[pCom] += size[qCom];  
  74.         } else {  
  75.             id[pCom] = qCom;  
  76.             size[qCom] += size[pCom];  
  77.         }  
  78.         // 每次合並之后,樹的數量減1  
  79.         count--;  
  80.     }  
  81.   
  82.     public int count() {  
  83.         return this.count;  
  84.     }  
  85. }  

 

最后,通過調用count方法獲取的返回值就是樹的數量,也就是“圈子”的數量。

 

根據問題的具體特性,上面同時采用了兩種優化策略,即按秩合並以及路徑壓縮。因為問題本身對合並的先后關系以及子樹的秩這類信息不敏感。然而,並不是所有的問題都這樣,比如下面這一道題目,他對合並的先后順序就有要求:

 

HDU-3635 Dragon Balls:

http://acm.hdu.edu.cn/showproblem.PHP?pid=3635

 

題意:起初球i是被放在i號城市的,在年代更迭,世事變遷的情況下,球被轉移了,而且轉移的時候,連帶該城市的所有球都被移動了:T A B(A球所在的城市的所有球都被移動到了B球所在的城市),Q A(問:A球在那城市?A球所在城市有多少個球呢?A球被轉移了多少次呢?)

(上面題意的描述摘自:http://www.cnblogs.com/Shirlies/archive/2012/03/06/2382118.html)

 

在這道題中,對子樹進行合並時,就不能按秩進行合並,因為合並是有先后關系的。

我們重點關注一下要回答的問題是什么,比如Q A代表的問題就是:

A球在哪里? --- 這個問題好回答,A球所在的城市就是該子樹的根節點,即find方法的返回值。

A球所在的城市有多少個球? --- 同樣地,這個問題的答案就是size數組中對應位置的信息,雖然本題不能按秩進行合並優化,但是秩還是需要被保存下來的。

A球被轉移了多少次呢? --- 這個問題畫張圖,就比較好理解了:

首先將球1所在城市的所有球轉移到球2所在的城市中,即城市2,然后將球1所在城市的所有球轉移到球3所在的城市中,即城市3。顯然,在第二步中,1球已經不在城市1中,因為其在第一步中已經轉移到城市2了。然后第二步實際就是將城市2中的所有球(包括球1和球2)都轉移到城市3中。

緊接着,將1球所在城市的球全部轉移(包括球1,2,3)到球4所在的城市中,即是將3和4進行合並。這個時候如果直接進行合並的話,會得到一個鏈表狀的結構,這種結構使我們一直都力求避免的,所以可以采用前面使用的路徑壓縮進行優化。路徑壓縮的具體做法就不贅述了。現在需要考慮的是,經過這3輪合並,球1到底移動了多少次?如果從最后的結果圖來看,球1最后到城市4,應該移動了2次,即1->3, 3->4。但是,仔細想想就會發現,這是不正確的。因為在T1 2中球1首先移動到了城市2,然后T 1 3,表示1球所在的城市中的所有球被移動到了城市3中,即城市2中的球移動到城市3中,這會對1球進行一次移動。以此類推,最后在T 1 4中,1球從城市3中移動到了城市4中,又發生了一次移動,因此,1球一共移動了3次,1->2, 2->3, 3->4。那么這就存在問題了,至少在最后的圖中,這一點很不直觀,因為從1到4的路徑上,已經沒有2的蹤跡了。顯然,這是路徑壓縮帶來的副作用。因為采用了路徑壓縮,所以對樹結構造成了一些破壞,具體而言,是能夠推導出球的轉移次數的信息被破壞了。試想一下,如果沒有進行路徑壓縮,轉移次數實際上是很直觀的,從待求節點到根節點走過的路徑數,就是轉移次數。

 

所以為了解決引入路徑壓縮帶來的問題,需要引入第三個數組來保存每個球的轉移次數。結合題意,每次在進行轉移的時候,是轉移該球所在城市中所有的球到目標球所在的城市,把這句話抽象一下,就是只有根節點才能夠進行合並。因此,現有的union方法還是適用的,因為它在進行真正的合並之前,還是需要首先找到兩個待合並節點的根節點。然后合並的時候,將第一個球所在城市的的號碼的轉移次數加1。按照這種想法,實現代碼為:

 

[java]  view plain  copy
 
 print?
  1. private static void union(int p, int q) {  
  2.       int pRoot = find(p);  
  3.       int qRoot = find(q);  
  4.   
  5.       if (pRoot == qRoot) {  
  6.          return;  
  7.       }  
  8.   
  9.       // 不能進行按秩合並,且在合並時,對第一個球的轉移次數進行遞增  
  10.       id[pRoot] = qRoot;  
  11.       trans[pRoot]++;  
  12.       size[qRoot] += size[pRoot];  
  13.    }  

 

 

但是跟蹤一下以上代碼的調用過程不難發現,最后的球1,2,3,4的轉移次數分別為1,1,1,0(唯一對trans數組進行影響的操作目前只存在於union方法中,見上)。顯然,這是不正確的,正確的轉移次數應該是3,2,1,0。那么是什么地方出了岔子呢,還是看看路徑壓縮就明白了,在路徑壓縮的時候,只顧着壓縮,而沒有對轉移次數進行更新。

 

那么如何進行更新呢?看看上圖,1本來是2的孩子,現在卻成了3的孩子,跳過了2,因此可以看成,1->2->3的路徑被壓縮成了1->3,即2->3的這條路徑被壓縮了。被壓縮在了1->3中,因此更新的操作也就有了基本的想法,我們可以講被壓縮的那條路徑中的信息增加到壓縮后的結果路徑中,對應前面的例子,我們需要把2->3的信息給添加到1->3,用代碼來表示的話,就是:

trans[1] += trans[2];

 

一般化后,實現代碼如下所示:

 

[java]  view plain  copy
 
 print?
  1. private static int find(int q) {  
  2.       while (id[q] != id[id[q]]) {   //如果q不是其所在子樹的根節點的直接孩子  
  3.          trans[q] += trans[id[q]];   //更新trans數組,將q的父節點的轉移數添加到q的轉移數中  
  4.          id[q] = id[id[q]];          //對其父節點到其爺爺節點之間的路徑進行壓縮  
  5.       }  
  6.       return id[q];  
  7.    }  

 

最后,如果需要獲得球A的轉移次數,直接獲取trans[A]就OK了。

 

HDU-1856 More is better

這道題目的目的是想知道經過一系列的合並操作之后,查詢在所有的子樹中,秩的最大值是多少,簡而言之,就是最大的那顆子樹包含了多少個節點。

很顯然,這個問題也能夠同時使用兩種優化策略,只不過因為要求最大秩的值,需要有一個變量來記錄。那么在哪個地方來更新它是最好的呢?我們知道,在按秩進行合並的時候,需要比較兩顆待合並子樹的秩,因此可以順帶的將對秩的最大值的更新也放在這里進行,實現代碼如下:

 

[java]  view plain  copy
 
 print?
  1. private static void union(int p, int q) {  
  2.     int pRoot = find(p);  
  3.     int qRoot = find(q);  
  4.   
  5.     if (pRoot == qRoot) {  
  6.         return;  
  7.     }  
  8.   
  9.     if (sz[pRoot] > sz[qRoot]) {  
  10.         id[qRoot] = pRoot;  
  11.         sz[pRoot] += sz[qRoot];  
  12.         if (sz[pRoot] > max) {    // 如果合並后的樹的秩比當前最大秩還要大,替換之  
  13.             max = sz[pRoot];  
  14.         }  
  15.     } else {  
  16.         id[pRoot] = qRoot;  
  17.         sz[qRoot] += sz[pRoot];  
  18.         if (sz[qRoot] > max) {    // 如果合並后的樹的秩比當前最大秩還要大,替換之  
  19.             max = sz[qRoot];  
  20.         }  
  21.     }  
  22. }  

這樣,在完成了所有的合並操作之后,max中保存的即為所需要的信息。

 

HDU-1272 | HDU-1325 小希的迷宮 | Is it a tree ?

http://acm.hdu.edu.cn/showproblem.php?pid=1272

http://acm.hdu.edu.cn/showproblem.php?pid=1325

這兩個問題都是判斷是否合並后的結構是一棵樹,即結構中應該沒有環路,除此之外,還有邊數和頂點數量的之間的關系,應該滿足edges + 1 = nodes。

對於並查集,后者可以通過檢查最后的connected components的數量是否為1來確定。

當然,兩者在題目描述上還是有一定的區別,前者是無向圖,后者是有向圖。但是對於使用並查集來實現時,這一點的區別僅僅體現在合並過程無法按秩優化了。其實,如果能夠采用路徑壓縮,按秩優化的效果就不那么明顯了,因為每次進行查詢操作的時候,會對被查詢的節點進行路徑壓縮(參見find方法),可以說這是一種“懶優化”,或者叫做“按需優化”。而按秩合並則是一個主動優化的過程,每次進行合並的時候都會進行。而采用按秩合並優化,需要額外一個保存size信息的數組,在一些應用場景中,對size信息並不在意,因此為了實現可選的優化方法而增加空間復雜度,就有一些得不償失了。並且,對於按秩合並以及路徑壓縮到底能夠提高多少效率,我們目前也並不清楚,這里做個記號,以后有空了寫一篇相關的文章。

 

扯遠了,回到正題。前面提到了判斷一張圖是否是一顆樹的兩個關鍵點:

 

  1. 不存在環路(對於有向圖,不存在環路也就意味着不存在強連通子圖)
  2. 滿足邊數加一等於頂點數的規律(不考慮重邊和指向自身的邊)
第一條,在並查集中應該如何實現呢?
現在我們對並查集也有一定的認識了,其實很容易我們就能夠想出,當兩個頂點的根節點相同時,就代表添加了這一條邊后會出現環路。這很好解釋,如果兩個頂點的根節點是相同的,代表這兩個頂點已經是連通的了,對於已經連通的兩個頂點,再添加一條邊,必然會產生環路。
第二條呢?
圖中的邊數,我們可以在每次進行真正合並操作之前(也就是,在確認兩個待合並的頂點的根節點不相同時)進行記錄。然后頂點數,也就是整個合並過程中參與進來的頂點個數了,可以使用一個布爾數組來進行記錄,出現后將相應位置設為true,最后進行一輪統計即可。
 
相關實現:
[java]  view plain  copy
 
 print?
  1. private static void union(int p, int q) {  
  2.    int pRoot = find(p);  
  3.    int qRoot = find(q);  
  4.   
  5.    if (pRoot == qRoot) {  
  6.       valid = false;  // 此處的valid是一個boolean變量,置為false表示改圖不是一顆樹  
  7.       return;  
  8.    }  
  9.    mark[p] = true;  
  10.    mark[q] = true;   // p和q參與到最后的頂點數量的統計  
  11.    edges++;   // 在合並之前,將邊的數量遞增  
  12.    id[qRoot] = pRoot;  
  13. }  

 

------------------------------------------總結的分割線---------------------------------------

 

就目前看來,一般問題都是圍繞着並查集的兩個主要操作,union和find做文章,根據具體應用,增加一些信息,增加一些邏輯,例如上題中的轉移次數,或者是根據問題特征選擇使用合適的優化策略,按秩合並以及路徑壓縮。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM