一分鐘說清楚並查集
分離集合 (disjoint set) 是一種經典的資料結構,它有三類操作:
Make-set(a) :生成包含一個元素a的集合S;
Union(X, Y) :合併兩個集合X和Y;
Find-set(a) :查詢元素a所在集合S,即通過元素找集合控制代碼;
它非常適合用來解決集合 合併與查詢 的問題,也常稱為 並查集 。
一、並查集的連結串列實現
如上圖,並查集可以用 連結串列 來實現。
連結串列實現的並查集,Find-set(a)的時間複雜度是多少?
集合裡的 每個元素,都指向“集合的控制代碼” ,這樣可以使得“查詢元素a所在集合S”,即 Find-set(a)操作在O(1)的時間內完成 。
連結串列實現的並查集,Union(X, Y)的時間複雜度是多少?
假設有集合:
S1={7,3,1,4}
S2={1,6}
合併S1和S2兩個集合,需要做兩件事情:
(1) 第一個集合的尾元素,鏈向第二個集合的頭元素(藍線1);
(2) 第二個集合的所有元素,指向第一個集合的控制代碼(藍線2,3);
合併完的效果是:
變成了一個更大的集合S1。
集合合併時,將 短的連結串列,往長的連結串列上接 ,這樣變動的元素更少,這個優化叫做“ 加權合併 ”。
畫外音:實現的過程中,集合控制代碼要儲存元素個數,頭元素,尾元素等屬性,以方便上述操作進行。
假設 每個集合的平均元素個數是n , Union(X, Y)操作的時間複雜度是O(n) 。
能不能Find-set(a)與Union(X, Y)都在O(1)的時間內完成呢?
可以,這就引發了並查集的第二種實現方法。
二、並查集的有根樹實現
什麼是有根樹,和普通的樹有什麼不同?
常用的set,就是用普通的二叉樹實現的,其元素的資料結構是:
element{
int data;
element* left;
element* right;
}
通過左指標與右指標,父親節點指向兒子節點。
而有根樹,其元素的資料結構是:
element{
int data;
element* parent;
}
通過兒子節點,指向父親節點。
假設有集合:
S1={7,3,1,4}
S2={1,6}
通過如果通過有根樹表示,可能是這樣的:
所有的元素,都通過parent指標指向集合控制代碼,所有元素的Find-set(a)的時間複雜度也是O(1)。
畫外音:假設集合的首個元素,代表集合控制代碼。
有根樹實現的並查集,Union(X, Y)的過程如何?時間複雜度是多少?
通過有根樹實現並查集,集合合併時,直接將一個集合控制代碼,指向另一個集合即可。
如上圖所示,S2的控制代碼,指向S1的控制代碼,集合合併完成:S2消亡,S1變為了更大的集合。
容易知道, 集合合併的時間複雜度為O(1) 。
會發現,集合合併之後, 有根樹的高度變高了 ,與“加權合併”的優化思路類似,總是把 節點數少的有根樹,指向節點數多的有根樹 (更確切的說,是高度矮的樹,指向高度高的樹),這個優化叫做“ 按秩合併 ”。
新的問題來了, 集合合併之後,不是所有元素的Find-set(a)操作都是O(1)了,怎麼辦?
如圖S1與S2合併後的新S1, 首次 “通過元素6來找新S1的控制代碼”,不能在O(1)的時間內完成了,需要兩次操作。
但為了讓 未來“通過元素6來找新S1的控制代碼”的操作能夠在O(1)的時間內完成 ,在首次進行Find-set(“6”)時,就要將元素6“尋根”路徑上的所有元素,都指向集合控制代碼,如下圖。
某個元素如果不直接指向集合控制代碼, 首次 Find-set(a)操作的過程中,會將該路徑上的所有元素都直接指向控制代碼, 這個優化叫做“ 路徑壓縮 ”。
畫外音: 路徑上的元素第二次執行Find-set(a)時,時間複雜度就是O(1)了。
實施“路徑壓縮”優化之後, Find-set的平均時間複雜度仍是O(1) 。
結論
通過連結串列實現並查集:
● Find-set的時間複雜度,是 O(1) 常數時間
● Union的時間複雜度,是集合平均元素個數,即 線性時間
畫外音:別忘了“加權合併”優化。
通過有根樹實現並查集:
● Union的時間複雜度,是 O(1) 常數時間
● Find-set的時間複雜度,通過“ 按秩合併 ”與“ 路徑壓縮 ”優化後,平均時間複雜度也是O(1)
使用並查集,非常適合解決“微信群覆蓋”問題。
思路比結論重要,有收穫就是好的。
原文釋出時間為:2018-11-20
本文作者:58沈劍