(轉載)並查集的作用

zhuyong9914發表於2009-05-31

轉自 http://blog.163.com/xueshanhaizi@126/blog/static/3725024520087161035310/

引題——親戚(relation)

【問題描述】若某個家族人員過於龐大,要判斷兩個是否是親戚,確實還很不容易,現在給出某個親戚關係圖,求任意給出的兩個人是否具有親戚關係。

規定:x和y是親戚,y和z是親戚,那麼x和z也是親戚。如果x,y是親戚,那麼x的親戚都是y的親戚,y的親戚也都是x的親戚。(人數≤5000,親戚關係≤5000,詢問親戚關係次數≤5000)。

【演算法分析】

1. 演算法1,構造圖論模型。

用一個n*n的二維陣列描述上面的圖形,記憶各個點之間的關係。然後,只要判斷給定的兩個點是否連通則可知兩個元素是否有“親戚”關係。                               

但要實現上述演算法,我們遇到兩個困難:                                       

(1)空間問題:需要n2的空間,而n高達5000!

(2)時間問題:每次判斷連通性需要O(n)的處理。

該演算法顯然不理想。

並查集多用於圖論問題的處理優化,我們看看並查集在這裡的表現如何。

2. 演算法2,並查集的簡單處理。

我們把一個連通塊看作一個集合,問題就轉化為判斷兩個元素是否屬於同一個集合。

假設一開始每個元素各自屬於自己的一個集合,每次往圖中加一條邊a-b,就相當於合併了兩個元素所在集合A和B,因為集合A中的元素用過邊a-b可以到達集合B中的任意元素,反之亦然。

當然如果a和b本來就已經屬於同一個集合了,那麼a-b這條邊就可以不用加了。

(1)具體操作:

① 由此用某個元素所在樹的根結點表示該元素所在的集合;

② 判斷兩個元素時候屬於同一個集合的時候,只需要判斷他們所在樹的根結點是否一樣即可;

③ 也就是說,當我們合併兩個集合的時候,只需要在兩個根結點之間連邊即可。

(2)元素的合併圖示:

(3)判斷元素是否屬於同一集合:

用father[i]表示元素i的父親結點,如剛才那個圖所示:

faher[1]:=1;faher[2]:=1;faher[3]:=1;faher[4]:=5;faher[5]:=3

至此,我們用上述的演算法已經解決了空間的問題,我們不再需要一個n2的空間來記錄整張圖的構造,只需要用一個記錄陣列記錄每個結點屬於的集合就可以了。

但是仔細思考不難發現,每次詢問兩個元素是否屬於同一個集合我們最多還是需要O(n)的判斷!

3. 演算法3,並查集的路徑壓縮。

演算法2的做法是指就是將元素的父親結點指來指去的在指,當這課樹是鏈的時候,可見判斷兩個元素是否屬於同一集合需要O(n)的時間,於是路徑壓縮產生了作用。

路徑壓縮實際上是在找完根結點之後,在遞迴回來的時候順便把路徑上元素的父親指標都指向根結點。

這就是說,我們在“合併5和3”的時候,不是簡單地將5的父親指向3,而是直接指向根節點1,由此我們得到了一個複雜度只是O(1)的演算法。

〖程式清單〗

(1)初始化:

for i:=1 to n do father[i]:=i;

因為每個元素屬於單獨的一個集合,所以每個元素以自己作為根結點。

(2)尋找根結點編號並壓縮路徑:

function getfather(v : integer) : integer;

    begin

      if father[v]=v then exit(v);

      father[v]:=getfather(father[v]);

      getfather:=father[v];

    end;

(3)合併兩個集合:

proceudre merge(x, y : integer);

    begin

      x:=getfather(x);

      y:=getfather(y);

      father[x]:=y;

    end;

(4)判斷元素是否屬於同一結合:

function judge(x, y : integer) : boolean;

    begin

      x:=getfaher(x);

      y:=gefather(y);

      if x=y then exit(true)

             else exit(false);

    end;

這個的引題已經完全闡述了並查集的基本操作和作用。

三、並查演算法

通過對上面引題的分析,我們已經十分清楚——所謂並查集演算法就是對不相交集合(disjoint set)進行如下兩種操作:

(1)檢索某元素屬於哪個集合;

(2)合併兩個集合。

我們最常用的資料結構是並查集的森林實現。也就是說,在森林中,每棵樹代表一個集合,用樹根來標識一個集合。有關樹的形態在並查集中並不重要,重要的是每棵樹裡有那些元素。

1. 合併操作

為了把兩個集合S1和S2並起來,只需要把S1的根的父親設定為S2的根(或把S2的根的父親設定為S1的根)就可以了。

這裡有一個優化:讓深度較小的樹成為深度較大的樹的子樹,這樣查詢的次數就會少些。這個優化稱為啟發式合併。可以證明:這樣做以後樹的深度為O(logn)。即:在一個有n個元素的集合,我們將保證移動不超過logn次就可以找到目標。

【證明】我們合併一個有i個結點的集合和一個有j個結點的集合,我們設i≤j,我們在一個小的集合中增加一個被跟隨的指標,但是他們現在在一個數量為i+j的集合中。由於:

1+log i=log(i+i)<=log(i+j);

所以我們可以保證性質。

由於使用啟發式合併演算法以後樹的深度為O(logn),因此我們可以得出如下性質:啟發式合併最多移動2logn次指標就可以決定兩個事物是否想聯絡。

同時我們還可以得出另一個性質:啟發式快速合併所得到的集合樹,其深度不超過 ,其中n是集合S中的所有子集所含的成員數的總和。

【證明】我們可以用歸納法證明:

當i=1時,樹中只有一個根節點,即深度為1

又|log2 1|+1=1所以正確。

假設i≤n-1時成立,嘗試證明i=n時成立。

不失一般性,可以假設此樹是由含有m(1≤m≤n/2)個元素,根為j的樹Sj,和含有n-m個元素、根為k的樹Sk合併而得到,並且,樹j合併到樹k,根是k。

(1)若合併前:子樹Sj的深度<子樹Sk的深度

則合併後的樹深度和Sk相同,深度不超過:

|log2(n-m)|+1

顯然不超過|log2 n|+1;

(2)若合併前:子樹Sj的深度≥子樹Sk的深度

則合併後的樹的深度為Sj的深度+1,即:

(|log2m|+1)+1=|log2(2m)|+1<=|log2n|+1  

小結:實踐告訴我們,上面所陳述的性質對於一個m條邊n個事物的聯絡問題,最多執行mlogn次指令。我們只是增加了一點點額外的程式碼,我們就把程式的效率很大地提升了。大量的實驗可以告訴我們,啟發式合併可以線上形時間內解答問題。更確切地說,這個演算法執行時間的花費,很難再有更加明顯的優秀、高效的演算法了。

2. 查詢操作

查詢一個元素u也很簡單,只需要順著葉子到根結點的路徑找到u所在的根結點,也就是確定了u所在的集合。

這裡又有一個優化:找到u所在樹的根v以後,把從u到v的路徑上所有點的父親都設定為v,這樣也會減少查詢次數。這個優化稱作路徑壓縮(compresses paths)。

壓縮路徑可以有很多種方法,這裡介紹兩種最常用的方法:

(1)滿路徑壓縮(full compresses paths):這是一種極其簡單但又很常用的方法。就是在新增另一個集合的時候,把所有遇到的結點都指向根節點。

(2)二分壓縮路徑(compresses paths by halving):具體思想就是把當前的結點,跳過一個指向父親的父親,從6而使整個路徑減半深度減半。這種辦法比滿路徑壓縮要快那麼一點點。資料越大,當然區別就會越明顯。

壓縮路徑的本質使路徑深度更加地減小,從而使訪問的時候速度增快,是一種很不錯的優化。在使用路徑壓縮以後,由於深度經常性發生變化,因此我們不再使用深度作為合併操作的啟發式函式值,而是使用一個新的rank數。剛建立的新集合的rank為0,以後當兩個rank相同的樹合併時,隨便選一棵樹作為新根,並把它的rank加1;否則rank大的樹作為新根,兩棵樹的rank均不變。

3. 時間複雜度

並查集進行n次查詢的時間複雜度是O(n )(執行n-1次合併和m≥n次查詢)。其中 是一個增長極其緩慢的函式,它是阿克曼函式(Ackermann Function)的某個反函式。它可以看作是小於5的。所以可以認為並查集的時間複雜度幾乎是線性的。

通過上面的分析,我們可以得出:並查集適用於所有集合的合併與查詢的操作,進一步還可以延伸到一些圖論中判斷兩個元素是否屬於同一個連通塊時的操作。由於使用啟發式合併和路徑壓縮技術,可以講並查集的時間複雜度近似的看作O(1),空間複雜度是O(N),這樣就將一個大規模的問題轉變成空間極小、速度極快的簡單操作。

 

相關文章