並查集(Disjoint Set)

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

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

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

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

使用並查集時,首先會存在一組不相交的動態集合 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作文章,根據具體應用,增長一些信息,增長一些邏輯,例如上題中的轉移次數,或者是根據問題特徵選擇使用合適的優化策略,按秩合併以及路徑壓縮。

相關文章
相關標籤/搜索