以下為本人學習並查集的體會與總結。
並查集概念
並查集也被稱為不相交集數據結構。顧名思義,並查集主要操作是合並與查詢,它是把初始不相交的集合經過多次合並操作后合並為一個大集合,然后可以通過查詢判斷兩個元素是否已經在同一個集合中了。
並查集的應用場景一般就是動態連通性的判斷,例如判斷網絡中的兩台電腦是否連通,在程序中判斷兩個變量名是否指向同一內存地址等。
並查集的實現
並查集的存儲結構
並查集邏輯上是森林,我們可以選出一個根節點作為代表,其他子結點指向根結點表示都在同一片森林中。在這里,並不關心結點的子結點是誰,只關心父結點是誰,所以物理上可以簡單用python的列表來表示並查集,列表的下標表示結點,列表元素的值表示父結點。
並查集的API
根據並查集的特性,可以設計以下api
class UnionFind(object): """並查集類""" def __init__(self, n): """長度為n的並查集""" def find(self, p): """查找p的根結點(祖先)""" def union(self, p, q): """連通p,q 讓q指向p""" def is_connected(self, p, q): """判斷pq是否已經連通"""
並查集的初始化
並查集的初始化有幾種,無非就是用一種特殊的方式來表示初始的每一個元素都不相交,等待后續的合並操作。
第一種初始化方式是用列表的下標初始化對應位置的值,當一個並查集S[i] == i 時則判斷它自己就是根結點。
def __init__(self, n): """長度為n的並查集""" self.uf = [i for i in range(n + 1)] # 列表0位置空出 self.sets_count = n # 判斷並查集里共有幾個集合, 初始化默認互相獨立
第二種初始化方式將列表每一個結點初始化為-1,列表的結點值為負數表示它自己就是根結點,這樣做還有一個好處可以用-n表示自己的子結點的數量,下面的按規模優化中可以讓結點數量小的樹並到結點多的樹上,提高find操作的效率。我們就選用這種方式來初始化。
def __init__(self, n): """長度為n的並查集""" self.uf = [-1 for i in range(n + 1)] # 列表0位置空出 self.sets_count = n # 判斷並查集里共有幾個集合, 初始化默認互相獨立
並查集的查詢
查詢操作是查找某個結點所在的集合,返回該集合的根結點,即返回列表的下標。下面是一種簡單的查詢,代碼如下。
def find(self, p): while self.uf[p] >= 0: p = self.uf[p] return p
可以很清楚的看出上面的方法很簡單,找到結點元素值為負的表示找到了根結點並返回,但是該種方法在極端情況下(由樹退化為鏈表)效率不高,查找的效率為O(n),如下左圖所示

查詢是並查集核心操作之一,它的效率也決定了整個算法的效率,所以在規模很大的情況下,O(n)的時間復雜度是不被接受的,那就需要改進,改進的方法就是路徑壓縮。路徑壓縮的思想也很簡單,就是在查找根結點的過程中,順便把子結點的父結點改成根結點,這樣下次查詢的效率只需要o(1)的時間復雜度就可以完成,大大提高了效率。改進后效果圖如上右圖所示。
路徑壓縮的find操作可以通過遞歸實現
def find(self, p): """尾遞歸""" if self.uf[p] < 0: return p self.uf[p] = self.find(self.uf[p]) return self.uf[p]
可以發現這個遞歸是尾遞歸,可以改進成循環的方式
def find(self, p): """查找p的根結點(祖先)""" r = p # 初始p while self.uf[p] > 0: p = self.uf[p] while r != p: # 路徑壓縮, 把搜索下來的結點祖先全指向根結點 self.uf[r], r = p, self.uf[r] return p
並查集的合並
合並兩棵樹的操作可以簡單的規定讓右邊的樹的根結點指向左邊樹的根結點,示意圖如下左圖所示。


直接右往左合並的缺點就是當右邊的規模大於左邊的規模時,在查找時,做路徑壓縮需要把右邊所有的根結點更改為左邊的根結點,如上右圖所示,這明顯有些划不來,所以合並的一種優化方式就是按規模合並,即把規模小的樹往規模大的樹上合並。其實還有一種按秩合並(樹高度小的往高度大的合並而不改變樹的整體高度),但是這種方法不與路徑壓縮兼容,因為路徑壓縮直接改變了樹的高度,所以本人選擇按規模合並和路徑壓縮結合的方式優化並查集。代碼如下
def union(self, p, q): """連通p,q 讓q指向p""" proot = self.find(p) qroot = self.find(q) if proot == qroot: return elif self.uf[proot] > self.uf[qroot]: # 負數比較, 左邊規模更小 self.uf[qroot] += self.uf[proot] self.uf[proot] = qroot else: self.uf[proot] += self.uf[qroot] # 規模相加 self.uf[qroot] = proot self.sets_count -= 1 # 連通后集合總數減一
連通性的判斷
有了查找操作,判斷兩個結點是否連通就顯得容易多了,一行代碼就可以搞定,就是判斷他們的根結點是否相同。
def is_connected(self, p, q): """判斷pq是否已經連通""" return self.find(p) == self.find(q) # 即判斷兩個結點是否是屬於同一個祖先
完整代碼附錄
1 class UnionFind(object): 2 """並查集類""" 3 def __init__(self, n): 4 """長度為n的並查集""" 5 self.uf = [-1 for i in range(n + 1)] # 列表0位置空出 6 self.sets_count = n # 判斷並查集里共有幾個集合, 初始化默認互相獨立 7 8 # def find(self, p): 9 # """查找p的根結點(祖先)""" 10 # r = p # 初始p 11 # while self.uf[p] > 0: 12 # p = self.uf[p] 13 # while r != p: # 路徑壓縮, 把搜索下來的結點祖先全指向根結點 14 # self.uf[r], r = p, self.uf[r] 15 # return p 16 17 # def find(self, p): 18 # while self.uf[p] >= 0: 19 # p = self.uf[p] 20 # return p 21 22 def find(self, p): 23 """尾遞歸""" 24 if self.uf[p] < 0: 25 return p 26 self.uf[p] = self.find(self.uf[p]) 27 return self.uf[p] 28 29 def union(self, p, q): 30 """連通p,q 讓q指向p""" 31 proot = self.find(p) 32 qroot = self.find(q) 33 if proot == qroot: 34 return 35 elif self.uf[proot] > self.uf[qroot]: # 負數比較, 左邊規模更小 36 self.uf[qroot] += self.uf[proot] 37 self.uf[proot] = qroot 38 else: 39 self.uf[proot] += self.uf[qroot] # 規模相加 40 self.uf[qroot] = proot 41 self.sets_count -= 1 # 連通后集合總數減一 42 43 def is_connected(self, p, q): 44 """判斷pq是否已經連通""" 45 return self.find(p) == self.find(q) # 即判斷兩個結點是否是屬於同一個祖先
參考
其他並查集
普通的並查集只是簡單的記錄了和集合的關系,即判斷是否屬於該集合,而帶權並查集則是不近記錄了和集合的關系,還記錄了集合內元素的關系,一般就是指代集合內元素和根結點的關系,實現起來也很簡單,就是額外利用一個列表value[], 來記錄每個結點與根結點的關系。然后在每次合並和路徑壓縮中更新權值。更新的規則遵循向量法則,處理環類關系的問題中,還可以取模更新。具體可以參考一下文章。
- https://blog.csdn.net/yjr3426619/article/details/82315133
- https://blog.csdn.net/u013075699/article/details/80379263
- https://blog.csdn.net/sunmaoxiang/article/details/80959300
