并查集(Union-Find)算法介绍
本文主要介紹解決動態(tài)連通性一類問題的一種算法,使用到了一種叫做并查集的數(shù)據(jù)結(jié)構(gòu),稱為Union-Find。
更多的信息可以參考Algorithms?一書的Section 1.5,實際上本文也就是基于它的一篇讀后感吧。
原文中更多的是給出一些結(jié)論,我嘗試給出一些思路上的過程,即為什么要使用這個方法,而不是別的什么方法。我覺得這個可能更加有意義一些,相比于記下一些結(jié)論。
?
關(guān)于動態(tài)連通性
我們看一張圖來了解一下什么是動態(tài)連通性:
?
假設(shè)我們輸入了一組整數(shù)對,即上圖中的(4, 3) (3, 8)等等,每對整數(shù)代表這兩個points/sites是連通的。那么隨著數(shù)據(jù)的不斷輸入,整個圖的連通性也會發(fā)生變化,從上圖中可以很清晰的發(fā)現(xiàn)這一點。同時,對于已經(jīng)處于連通狀態(tài)的points/sites,直接忽略,比如上圖中的(8, 9)。
?
動態(tài)連通性的應(yīng)用場景:
- 網(wǎng)絡(luò)連接判斷:
如果每個pair中的兩個整數(shù)分別代表一個網(wǎng)絡(luò)節(jié)點,那么該pair就是用來表示這兩個節(jié)點是需要連通的。那么為所有的pairs建立了動態(tài)連通圖后,就能夠盡可能少的減少布線的需要,因為已經(jīng)連通的兩個節(jié)點會被直接忽略掉。
- 變量名等同性(類似于指針的概念):
在程序中,可以聲明多個引用來指向同一對象,這個時候就可以通過為程序中聲明的引用和實際對象建立動態(tài)連通圖來判斷哪些引用實際上是指向同一對象。
?
對問題建模:
在對問題進行建模的時候,我們應(yīng)該盡量想清楚需要解決的問題是什么。因為模型中選擇的數(shù)據(jù)結(jié)構(gòu)和算法顯然會根據(jù)問題的不同而不同,就動態(tài)連通性這個場景而言,我們需要解決的問題可能是:
- 給出兩個節(jié)點,判斷它們是否連通,如果連通,不需要給出具體的路徑
- 給出兩個節(jié)點,判斷它們是否連通,如果連通,需要給出具體的路徑
?
就上面兩種問題而言,雖然只有是否能夠給出具體路徑的區(qū)別,但是這個區(qū)別導(dǎo)致了選擇算法的不同,本文主要介紹的是第一種情況,即不需要給出具體路徑的Union-Find算法,而第二種情況可以使用基于DFS的算法。
?
建模思路:
最簡單而直觀的假設(shè)是,對于連通的所有節(jié)點,我們可以認為它們屬于一個組,因此不連通的節(jié)點必然就屬于不同的組。隨著Pair的輸入,我們需要首先判斷輸入的兩個節(jié)點是否連通。如何判斷呢?按照上面的假設(shè),我們可以通過判斷它們屬于的組,然后看看這兩個組是否相同,如果相同,那么這兩個節(jié)點連通,反之不連通。為簡單起見,我們將所有的節(jié)點以整數(shù)表示,即對N個節(jié)點使用0到N-1的整數(shù)表示。而在處理輸入的Pair之前,每個節(jié)點必然都是孤立的,即他們分屬于不同的組,可以使用數(shù)組來表示這一層關(guān)系,數(shù)組的index是節(jié)點的整數(shù)表示,而相應(yīng)的值就是該節(jié)點的組號了。該數(shù)組可以初始化為:
[java] view plaincopy print?
即對于節(jié)點i,它的組號也是i。
?
初始化完畢之后,對該動態(tài)連通圖有幾種可能的操作:
- 查詢節(jié)點屬于的組
數(shù)組對應(yīng)位置的值即為組號
- 判斷兩個節(jié)點是否屬于同一個組
分別得到兩個節(jié)點的組號,然后判斷組號是否相等
- 連接兩個節(jié)點,使之屬于同一個組
分別得到兩個節(jié)點的組號,組號相同時操作結(jié)束,不同時,將其中的一個節(jié)點的組號換成另一個節(jié)點的組號
- 獲取組的數(shù)目
初始化為節(jié)點的數(shù)目,然后每次成功連接兩個節(jié)點之后,遞減1
API
我們可以設(shè)計相應(yīng)的API:
?
注意其中使用整數(shù)來表示節(jié)點,如果需要使用其他的數(shù)據(jù)類型表示節(jié)點,比如使用字符串,那么可以用哈希表來進行映射,即將String映射成這里需要的Integer類型。
?
分析以上的API,方法connected和union都依賴于find,connected對兩個參數(shù)調(diào)用兩次find方法,而union在真正執(zhí)行union之前也需要判斷是否連通,這又是兩次調(diào)用find方法。因此我們需要把find方法的實現(xiàn)設(shè)計的盡可能的高效。所以就有了下面的Quick-Find實現(xiàn)。
?
Quick-Find?算法:
[java] view plaincopy print?舉個例子,比如輸入的Pair是(5,?9),那么首先通過find方法發(fā)現(xiàn)它們的組號并不相同,然后在union的時候通過一次遍歷,將組號1都改成8。當(dāng)然,由8改成1也是可以的,保證操作時都使用一種規(guī)則就行。
?
上述代碼的find方法十分高效,因為僅僅需要一次數(shù)組讀取操作就能夠找到該節(jié)點的組號,但是問題隨之而來,對于需要添加新路徑的情況,就涉及到對于組號的修改,因為并不能確定哪些節(jié)點的組號需要被修改,因此就必須對整個數(shù)組進行遍歷,找到需要修改的節(jié)點,逐一修改,這一下每次添加新路徑帶來的復(fù)雜度就是線性關(guān)系了,如果要添加的新路徑的數(shù)量是M,節(jié)點數(shù)量是N,那么最后的時間復(fù)雜度就是MN,顯然是一個平方階的復(fù)雜度,對于大規(guī)模的數(shù)據(jù)而言,平方階的算法是存在問題的,這種情況下,每次添加新路徑就是“牽一發(fā)而動全身”,想要解決這個問題,關(guān)鍵就是要提高union方法的效率,讓它不再需要遍歷整個數(shù)組。
?
Quick-Union?算法:
考慮一下,為什么以上的解法會造成“牽一發(fā)而動全身”?因為每個節(jié)點所屬的組號都是單獨記錄,各自為政的,沒有將它們以更好的方式組織起來,當(dāng)涉及到修改的時候,除了逐一通知、修改,別無他法。所以現(xiàn)在的問題就變成了,如何將節(jié)點以更好的方式組織起來,組織的方式有很多種,但是最直觀的還是將組號相同的節(jié)點組織在一起,想想所學(xué)的數(shù)據(jù)結(jié)構(gòu),什么樣子的數(shù)據(jù)結(jié)構(gòu)能夠?qū)⒁恍┕?jié)點給組織起來?常見的就是鏈表,圖,樹,什么的了。但是哪種結(jié)構(gòu)對于查找和修改的效率最高?毫無疑問是樹,因此考慮如何將節(jié)點和組的關(guān)系以樹的形式表現(xiàn)出來。
?
如果不改變底層數(shù)據(jù)結(jié)構(gòu),即不改變使用數(shù)組的表示方法的話。可以采用parent-link的方式將節(jié)點組織起來,舉例而言,id[p]的值就是p節(jié)點的父節(jié)點的序號,如果p是樹根的話,id[p]的值就是p,因此最后經(jīng)過若干次查找,一個節(jié)點總是能夠找到它的根節(jié)點,即滿足id[root] = root的節(jié)點也就是組的根節(jié)點了,然后就可以使用根節(jié)點的序號來表示組號。所以在處理一個pair的時候,將首先找到pair中每一個節(jié)點的組號(即它們所在樹的根節(jié)點的序號),如果屬于不同的組的話,就將其中一個根節(jié)點的父節(jié)點設(shè)置為另外一個根節(jié)點,相當(dāng)于將一顆獨立的樹編程另一顆獨立的樹的子樹。直觀的過程如下圖所示。但是這個時候又引入了問題。
?
在實現(xiàn)上,和之前的Quick-Find只有find和union兩個方法有所不同:
[java] view plaincopy print??
樹這種數(shù)據(jù)結(jié)構(gòu)容易出現(xiàn)極端情況,因為在建樹的過程中,樹的最終形態(tài)嚴重依賴于輸入數(shù)據(jù)本身的性質(zhì),比如數(shù)據(jù)是否排序,是否隨機分布等等。比如在輸入數(shù)據(jù)是有序的情況下,構(gòu)造的BST會退化成一個鏈表。在我們這個問題中,也是會出現(xiàn)的極端情況的,如下圖所示。
?
為了克服這個問題,BST可以演變成為紅黑樹或者AVL樹等等。
?
然而,在我們考慮的這個應(yīng)用場景中,每對節(jié)點之間是不具備可比性的。因此需要想其它的辦法。在沒有什么思路的時候,多看看相應(yīng)的代碼可能會有一些啟發(fā),考慮一下Quick-Union算法中的union方法實現(xiàn):
[java] view plaincopy print?
上面 id[pRoot] = qRoot 這行代碼看上去似乎不太對勁。因為這也屬于一種“硬編碼”,這樣實現(xiàn)是基于一個約定,即p所在的樹總是會被作為q所在樹的子樹,從而實現(xiàn)兩顆獨立的樹的融合。那么這樣的約定是不是總是合理的呢?顯然不是,比如p所在的樹的規(guī)模比q所在的樹的規(guī)模大的多時,p和q結(jié)合之后形成的樹就是十分不和諧的一頭輕一頭重的”畸形樹“了。
?
所以我們應(yīng)該考慮樹的大小,然后再來決定到底是調(diào)用:
id[pRoot] = qRoot?或者是?id[qRoot] = pRoot
?
即總是size小的樹作為子樹和size大的樹進行合并。這樣就能夠盡量的保持整棵樹的平衡。
?
所以現(xiàn)在的問題就變成了:樹的大小該如何確定?
我們回到最初的情形,即每個節(jié)點最一開始都是屬于一個獨立的組,通過下面的代碼進行初始化:
[java] view plaincopy print?
?
以此類推,在初始情況下,每個組的大小都是1,因為只含有一個節(jié)點,所以我們可以使用額外的一個數(shù)組來維護每個組的大小,對該數(shù)組的初始化也很直觀:
[java] view plaincopy print?
而在進行合并的時候,會首先判斷待合并的兩棵樹的大小,然后按照上面圖中的思想進行合并,實現(xiàn)代碼:
?
[java] view plaincopy print?
Quick-Union?和?Weighted Quick-Union?的比較:
?
可以發(fā)現(xiàn),通過sz數(shù)組決定如何對兩棵樹進行合并之后,最后得到的樹的高度大幅度減小了。這是十分有意義的,因為在Quick-Union算法中的任何操作,都不可避免的需要調(diào)用find方法,而該方法的執(zhí)行效率依賴于樹的高度。樹的高度減小了,find方法的效率就增加了,從而也就增加了整個Quick-Union算法的效率。
?
上圖其實還可以給我們一些啟示,即對于Quick-Union算法而言,節(jié)點組織的理想情況應(yīng)該是一顆十分扁平的樹,所有的孩子節(jié)點應(yīng)該都在height為1的地方,即所有的孩子都直接連接到根節(jié)點。這樣的組織結(jié)構(gòu)能夠保證find操作的最高效率。
?
那么如何構(gòu)造這種理想結(jié)構(gòu)呢?
在find方法的執(zhí)行過程中,不是需要進行一個while循環(huán)找到根節(jié)點嘛?如果保存所有路過的中間節(jié)點到一個數(shù)組中,然后在while循環(huán)結(jié)束之后,將這些中間節(jié)點的父節(jié)點指向根節(jié)點,不就行了么?但是這個方法也有問題,因為find操作的頻繁性,會造成頻繁生成中間節(jié)點數(shù)組,相應(yīng)的分配銷毀的時間自然就上升了。那么有沒有更好的方法呢?還是有的,即將節(jié)點的父節(jié)點指向該節(jié)點的爺爺節(jié)點,這一點很巧妙,十分方便且有效,相當(dāng)于在尋找根節(jié)點的同時,對路徑進行了壓縮,使整個樹結(jié)構(gòu)扁平化。相應(yīng)的實現(xiàn)如下,實際上只需要添加一行代碼:
[java] view plaincopy print?
至此,動態(tài)連通性相關(guān)的Union-Find算法基本上就介紹完了,從容易想到的Quick-Find到相對復(fù)雜但是更加高效的Quick-Union,然后到對Quick-Union的幾項改進,讓我們的算法的效率不斷的提高。
這幾種算法的時間復(fù)雜度如下所示:
| 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) |
?
對大規(guī)模數(shù)據(jù)進行處理,使用平方階的算法是不合適的,比如簡單直觀的Quick-Find算法,通過發(fā)現(xiàn)問題的更多特點,找到合適的數(shù)據(jù)結(jié)構(gòu),然后有針對性的進行改進,得到了Quick-Union算法及其多種改進算法,最終使得算法的復(fù)雜度降低到了近乎線性復(fù)雜度。
?
如果需要的功能不僅僅是檢測兩個節(jié)點是否連通,還需要在連通時得到具體的路徑,那么就需要用到別的算法了,比如DFS或者BFS。
總結(jié)
以上是生活随笔為你收集整理的并查集(Union-Find)算法介绍的全部內(nèi)容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 上架过程中遇到的问题
- 下一篇: Fragment.setArgument