並查集
在計算機科學中,並查集是一種樹型的數據結構,用於處理一些不交集()的合併及查詢問題。有一個聯合- 查找算法( )定義了兩個用於此數據結構的操作:
- Find:確定元素屬於哪一個子集。它可以被用來確定兩個元素是否屬同一子集。
- Union:將兩個子集合併成同一個集合。
由於支持這兩種操作,一個不相交集也常被稱爲聯合-查找數據結構()或合併-查找集合()。其他的重要方法,,用於建立單元素集合。有了這些方法,許多經典的劃分問題可以被解決。
爲了更加精確的定義這些方法,需要定義如何表示集合。一種常用的策略是爲每個集合選定一個固定的元素,稱爲代表,以表示整個集合。接着, 返回 所屬集合的代表,而 使用兩個集合的代表作爲參數。
並查集森林
並查集森林是一種將每一個集合以樹表示的數據結構,其中每一個節點保存着到它的父節點的引用(見意大利麪條堆棧)。這個數據結構最早由 和 於 年提出,但是經過了數年才完成了精確的分析。
在並查集森林中,每個集合的代表即是集合的根節點。“查找”根據其父節點的引用向根行進直到到底樹根。“聯合”將兩棵樹合併到一起,這通過將一棵樹的根連接到另一棵樹的根。實現這樣操作的一種方法是
function MakeSet(x)
x.parent := x
function Find(x)
if x.parent == x
return x
else
return Find(x.parent)
function Union(x, y)
xRoot := Find(x)
yRoot := Find(y)
xRoot.parent := yRoot
這是並查集森林的最基礎的表示方法,這個方法不會比鏈表法好,這是因爲創建的樹可能會嚴重不平衡;然而,可以用兩種辦法優化。
第一種方法,稱爲“按秩合併”,即總是將更小的樹連接至更大的樹上。因爲影響運行時間的是樹的深度,更小的樹添加到更深的樹的根上將不會增加秩除非它們的秩相同。在這個算法中,術語“秩”替代了“深度”,因爲同時應用了路徑壓縮時(見下文)秩將不會與高度相同。單元素的樹的秩定義爲 ,當兩棵秩同爲r的樹聯合時,它們的秩 。只使用這個方法將使最壞的運行時間提高至每個 或操作 。優化後的 和 僞代碼:
function MakeSet(x)
x.parent := x
x.rank := 0
function Union(x, y)
xRoot := Find(x)
yRoot := Find(y)
if xRoot == yRoot
return
// x和y不在同一個集合,合併它們。
if xRoot.rank < yRoot.rank
xRoot.parent := yRoot
else if xRoot.rank > yRoot.rank
yRoot.parent := xRoot
else
yRoot.parent := xRoot
xRoot.rank := xRoot.rank + 1
第二個優化,稱爲“路徑壓縮”,是一種在執行“查找”時扁平化樹結構的方法。關鍵在於在路徑上的每個節點都可以直接連接到根上;他們都有同樣的表示方法。爲了達到這樣的效果, 遞歸地經過樹,改變每一個節點的引用到根節點。得到的樹將更加扁平,爲以後直接或者間接引用節點的操作加速。這兒是 :
function Find(x)
if x.parent != x
x.parent := Find(x.parent)
return x.parent
這兩種方法的優勢互補,同時使用二者的程序每個操作的平均時間僅爲 , 是 的反函數,其中 是急速增加的阿克曼函數。因爲 是其的反函數,故 在 十分巨大時還是小於 。因此,平均運行時間是一個極小的常數。
實際上,這是漸近最優算法: 和 在 年解釋了 的平均時間內可以獲得任何並查集。
主要操作
需要注意的是,一開始我們假設元素都是分別屬於一個獨立的集合裏的。
合併兩個不相交集合
操作很簡單:先設置一個數組(陣列) ,表示 的“父親”的編號。 那麼,合併兩個不相交集合的方法就是,找到其中一個集合最父親的父親(也就是最久遠的祖先),將另外一個集合的最久遠的祖先的父親指向它。
void Union(int x,int y)
{
int fx = getfather(x);
int fy = getfather(y);
if(fy != fx)
father[fx] = fy;
}
判斷兩個元素是否屬於同一集合
仍然使用上面的數組。則本操作即可轉換爲尋找兩個元素的最久遠祖先是否相同。尋找祖先可以採用遞歸實現,見後面的
路徑壓縮算法。
bool same(int x,int y)
{
return getfather(x) == getfather(y);
}
/*返回true 表示相同根結點,返回false不相同*/
並查集的優化
路徑壓縮
剛纔我們說過,尋找祖先時採用遞歸,但是一旦元素一多起來,或退化成一條鏈,每次 都將會使用 的複雜度,這顯然不是我們想要的。
對此,我們必須要進行路徑壓縮,即我們找到最久遠的祖先時“順便”把它的子孫直接連接到它上面。這就是路徑壓縮了。使用路徑壓縮的代碼如下:
int getfather(int v)
{
if (father[v] == v)
return v;
else
{
father[v] = getfather(father[v]); //路徑壓縮
return father[v];
}
}
Rank合併
合併時將元素所在深度小的集合合併到元素所在深度大的集合。
void judge(int x ,int y)
{
int fx = getfather(x);
int fy = getfather(y);
if (rank[fx] > rank[fy])
father[fy] = fx;
else
{
father[fx] = fy;
if(rank[fx] == rank[fy])
++rank[fy];//重要的是祖先的rank,所以只用修改祖先的rank就可以了,子節點的rank不用管
}
}
初始化:
memset(rank, 0, sizeof(rank));
時間及空間複雜度
時間複雜度
同時使用路徑壓縮、按秩( ) 合併優化的程序每個操作的平均時間僅爲 , 其中 是 的反函數, 是急速增加的阿克曼函數。因爲 是其反函數,故 在 十分巨大時還是小於 。因此,平均運行時間是一個極小的常數。實際上,這是漸近最優算法: 和 在 年解釋了 的平均時間內可以獲得任何並查集。
空間複雜度
( 爲元素的數量)
應用
算法的優化