本文主要介紹解決動態連通性一類問題的一種算法,使用到了一種叫作並查集的數據結構,稱爲Union-Find。更多的信息能夠參考Algorithms 一書的Section 1.5,實際上本文也就是基於它的一篇讀後感吧。原文中更多的是給出一些結論,我嘗試給出一些思路上的過程,即爲何要使用這個方法,而不是別的什麼方法。我以爲這個可能更加有意義一些,相比於記下一些結論。算法
咱們看一張圖來了解一下什麼是動態連通性:編程
假設咱們輸入了一組整數對,即上圖中的(4, 3) (3, 8)等等,每對整數表明這兩個points/sites是連通的。那麼隨着數據的不斷輸入,整個圖的連通性也會發生變化,從上圖中能夠很清晰的發現這一點。同時,對於已經處於連通狀態的points/sites,直接忽略,好比上圖中的(8, 9)。數組
網絡鏈接判斷:網絡
若是每一個pair中的兩個整數分別表明一個網絡節點,那麼該pair就是用來表示這兩個節點是須要連通的。那麼爲全部的pairs創建了動態連通圖後,就可以儘量少的減小布線的須要,由於已經連通的兩個節點會被直接忽略掉。數據結構
變量名等同性(相似於指針的概念):數據結構和算法
在程序中,能夠聲明多個引用來指向同一對象,這個時候就能夠經過爲程序中聲明的引用和實際對象創建動態連通圖來判斷哪些引用其實是指向同一對象。ui
在對問題進行建模的時候,咱們應該儘可能想清楚須要解決的問題是什麼。由於模型中選擇的數據結構和算法顯然會根據問題的不一樣而不一樣,就動態連通性這個場景而言,咱們須要解決的問題多是:編碼
給出兩個節點,判斷它們是否連通,若是連通,不須要給出具體的路徑spa
給出兩個節點,判斷它們是否連通,若是連通,須要給出具體的路徑設計
就上面兩種問題而言,雖然只有是否可以給出具體路徑的區別,可是這個區別致使了選擇算法的不一樣,本文主要介紹的是第一種狀況,即不須要給出具體路徑的Union-Find算法,而第二種狀況可使用基於DFS的算法。
最簡單而直觀的假設是,對於連通的全部節點,咱們能夠認爲它們屬於一個組,所以不連通的節點必然就屬於不一樣的組。隨着Pair的輸入,咱們須要首先判斷輸入的兩個節點是否連通。如何判斷呢?按照上面的假設,咱們能夠經過判斷它們屬於的組,而後看看這兩個組是否相同,若是相同,那麼這兩個節點連通,反之不連通。爲簡單起見,咱們將全部的節點以整數表示,即對N個節點使用0到N-1的整數表示。而在處理輸入的Pair以前,每一個節點必然都是孤立的,即他們分屬於不一樣的組,可使用數組來表示這一層關係,數組的index是節點的整數表示,而相應的值就是該節點的組號了。該數組能夠初始化爲:
for(int i = 0; i < size; i++) id[i] = i;
即對於節點i,它的組號也是i。
初始化完畢以後,對該動態連通圖有幾種可能的操做:
查詢節點屬於的組
數組對應位置的值即爲組號
判斷兩個節點是否屬於同一個組
分別獲得兩個節點的組號,而後判斷組號是否相等
鏈接兩個節點,使之屬於同一個組
分別獲得兩個節點的組號,組號相同時操做結束,不一樣時,將其中的一個節點的組號換成另外一個節點的組號
獲取組的數目
初始化爲節點的數目,而後每次成功鏈接兩個節點以後,遞減1
咱們能夠設計相應的API:
注意其中使用整數來表示節點,若是須要使用其餘的數據類型表示節點,好比使用字符串,那麼能夠用哈希表來進行映射,即將String映射成這裏須要的Integer類型。
分析以上的API,方法connected和union都依賴於find,connected對兩個參數調用兩次find方法,而union在真正執行union以前也須要判斷是否連通,這又是兩次調用find方法。所以咱們須要把find方法的實現設計的儘量的高效。因此就有了下面的Quick-Find實現。
public class UF { private int[] id; // access to component id (site indexed) private int count; // number of components public UF(int N) { // Initialize component id array. count = N; id = new int[N]; for (int i = 0; i < N; i++) id[i] = i; } public int count() { return count; } public boolean connected(int p, int q) { return find(p) == find(q); } public int find(int p) { return id[p]; } public void union(int p, int q) { // 得到p和q的組號 int pID = find(p); int qID = find(q); // 若是兩個組號相等,直接返回 if (pID == qID) return; // 遍歷一次,改變組號使他們屬於一個組 for (int i = 0; i < id.length; i++) if (id[i] == pID) id[i] = qID; count--; } }
舉個例子,好比輸入的Pair是(5, 9),那麼首先經過find方法發現它們的組號並不相同,而後在union的時候經過一次遍歷,將組號1都改爲8。固然,由8改爲1也是能夠的,保證操做時都使用一種規則就行。
上述代碼的find方法十分高效,由於僅僅須要一次數組讀取操做就可以找到該節點的組號,可是問題隨之而來,對於須要添加新路徑的狀況,就涉及到對於組號的修改,由於並不能肯定哪些節點的組號須要被修改,所以就必須對整個數組進行遍歷,找到須要修改的節點,逐一修改,這一下每次添加新路徑帶來的複雜度就是線性關係了,若是要添加的新路徑的數量是M,節點數量是N,那麼最後的時間複雜度就是MN,顯然是一個平方階的複雜度,對於大規模的數據而言,平方階的算法是存在問題的,這種狀況下,每次添加新路徑就是「牽一髮而動全身」,想要解決這個問題,關鍵就是要提升union方法的效率,讓它再也不須要遍歷整個數組。
考慮一下,爲何以上的解法會形成「牽一髮而動全身」?由於每一個節點所屬的組號都是單獨記錄,各自爲政的,沒有將它們以更好的方式組織起來,當涉及到修改的時候,除了逐一通知、修改,別無他法。因此如今的問題就變成了,如何將節點以更好的方式組織起來,組織的方式有不少種,可是最直觀的仍是將組號相同的節點組織在一塊兒,想一想所學的數據結構,什麼樣子的數據結構可以將一些節點給組織起來?常見的就是鏈表,圖,樹,什麼的了。可是哪一種結構對於查找和修改的效率最高?毫無疑問是樹,所以考慮如何將節點和組的關係以樹的形式表現出來。
若是不改變底層數據結構,即不改變使用數組的表示方法的話。能夠採用parent-link的方式將節點組織起來,舉例而言,id[p]的值就是p節點的父節點的序號,若是p是樹根的話,id[p]的值就是p,所以最後通過若干次查找,一個節點老是可以找到它的根節點,即知足id[root] = root的節點也就是組的根節點了,而後就可使用根節點的序號來表示組號。因此在處理一個pair的時候,將首先找到pair中每個節點的組號(即它們所在樹的根節點的序號),若是屬於不一樣的組的話,就將其中一個根節點的父節點設置爲另一個根節點,至關於將一顆獨立的樹編程另外一顆獨立的樹的子樹。直觀的過程以下圖所示。可是這個時候又引入了問題。
在實現上,和以前的Quick-Find只有find和union兩個方法有所不一樣:
private int find(int p) { // 尋找p節點所在組的根節點,根節點具備性質id[root] = root while (p != id[p]) p = id[p]; return p; } public void union(int p, int q) { // Give p and q the same root. int pRoot = find(p); int qRoot = find(q); if (pRoot == qRoot) return; id[pRoot] = qRoot; // 將一顆樹(即一個組)變成另一課樹(即一個組)的子樹 count--; }
樹這種數據結構容易出現極端狀況,由於在建樹的過程當中,樹的最終形態嚴重依賴於輸入數據自己的性質,好比數據是否排序,是否隨機分佈等等。好比在輸入數據是有序的狀況下,構造的BST會退化成一個鏈表。在咱們這個問題中,也是會出現的極端狀況的,以下圖所示。
爲了克服這個問題,
爲了克服這個問題,BST能夠演變成爲紅黑樹或者AVL樹等等。
然而,在咱們考慮的這個應用場景中,每對節點之間是不具有可比性的。所以須要想其它的辦法。在沒有什麼思路的時候,多看看相應的代碼可能會有一些啓發,考慮一下Quick-Union算法中的union方法實現:
public void union(int p, int q) { // Give p and q the same root. int pRoot = find(p); int qRoot = find(q); if (pRoot == qRoot) return; id[pRoot] = qRoot; // 將一顆樹(即一個組)變成另一課樹(即一個組)的子樹 count--; }
上面 id[pRoot] = qRoot 這行代碼看上去彷佛不太對勁。由於這也屬於一種「硬編碼」,這樣實現是基於一個約定,即p所在的樹老是會被做爲q所在樹的子樹,從而實現兩顆獨立的樹的融合。那麼這樣的約定是否是老是合理的呢?顯然不是,好比p所在的樹的規模比q所在的樹的規模大的多時,p和q結合以後造成的樹就是十分不和諧的一頭輕一頭重的」畸形樹「了。
因此咱們應該考慮樹的大小,而後再來決定究竟是調用:
id[pRoot] = qRoot 或者是 id[qRoot] = pRoot
即老是
即老是size小的樹做爲子樹和size大的樹進行合併。這樣就可以儘可能的保持整棵樹的平衡。
因此如今的問題就變成了:樹的大小該如何肯定?
咱們回到最初的情形,即每一個節點最一開始都是屬於一個獨立的組,經過下面的代碼進行初始化:
for (int i = 0; i < N; i++) id[i] = i; // 每一個節點的組號就是該節點的序號
以此類推,在初始狀況下,每一個組的大小都是1,由於只含有一個節點,因此咱們可使用額外的一個數組來維護每一個組的大小,對該數組的初始化也很直觀:
for (int i = 0; i < N; i++) sz[i] = 1; // 初始狀況下,每一個組的大小都是1
而在進行合併的時候,會首先判斷待合併的兩棵樹的大小,而後按照上面圖中的思想進行合併,實現代碼:
public void union(int p, int q) { int i = find(p); int j = find(q); if (i == j) return; // 將小樹做爲大樹的子樹 if (sz[i] < sz[j]) { id[i] = j; sz[j] += sz[i]; } else { id[j] = i; sz[i] += sz[j]; } count--; }
能夠發現,經過sz數組決定如何對兩棵樹進行合併以後,最後獲得的樹的高度大幅度減少了。這是十分有意義的,由於在Quick-Union算法中的任何操做,都不可避免的須要調用find方法,而該方法的執行效率依賴於樹的高度。樹的高度減少了,find方法的效率就增長了,從而也就增長了整個Quick-Union算法的效率。
上圖其實還能夠給咱們一些啓示,即對於Quick-Union算法而言,節點組織的理想狀況應該是一顆十分扁平的樹,全部的孩子節點應該都在height爲1的地方,即全部的孩子都直接鏈接到根節點。這樣的組織結構可以保證find操做的最高效率。
那麼如何構造這種理想結構呢?
在find方法的執行過程當中,不是須要進行一個while循環找到根節點嘛?若是保存全部路過的中間節點到一個數組中,而後在while循環結束以後,將這些中間節點的父節點指向根節點,不就好了麼?可是這個方法也有問題,由於find操做的頻繁性,會形成頻繁生成中間節點數組,相應的分配銷燬的時間天然就上升了。那麼有沒有更好的方法呢?仍是有的,即將節點的父節點指向該節點的爺爺節點,這一點很巧妙,十分方便且有效,至關於在尋找根節點的同時,對路徑進行了壓縮,使整個樹結構扁平化。相應的實現以下,實際上只須要添加一行代碼:
private int find(int p) { while (p != id[p]) { // 將p節點的父節點設置爲它的爺爺節點 id[p] = id[id[p]]; p = id[p]; } return p; }
至此,動態連通性相關的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。