1. 程式人生 > 其它 >並查集Union-find及其在最小生成樹中的應用

並查集Union-find及其在最小生成樹中的應用

並查集是一種用途廣泛的資料結構,能夠快速地處理集合的合併和查詢問題,並且實現起來非常方便,在很多場合中都有著非常巧妙的應用,。

本文首先介紹並查集的定義、原理及具體實現,然後以其在最小生成樹演算法中的一個經典應用為例講解其具體使用方法。

一 並查集原理及實現

並查集是一種樹型的資料結構,用於處理一些不相交集合的合併及查詢問題。

並查集在使用中通常以森林來表示,每個集合組織為一棵樹,並且以樹根節點為代表元素。實際中以一個數組father[x]即可實現,表示節點x的父親節點。另外用一個變數n表示節點的個數。但為了提升效能,通常還用一個數組rnk[x]表示節點x的子樹的深度,具體後面解釋。

const int mx = 100000 + 1; //最大節點個數
int n;            //節點個數
int father[mx]; //節點的父親
int rnk[mx];    //節點的子樹深度

並查集通常支援三種操作:初始化、查詢、合併。

1. 初始化

初始化把每個點所在集合初始化為其自身,即n個節點就有n個集合。遍歷一次n個節點,把每個的父親初始為自己,子樹的深度初始化為0。

void makeSet() //初始化
{
    for (int i = 0; i < n; i++) father[i] = i, rnk[i] = 0;
}

2. 查詢

查詢元素所在的集合,即根節點。因為一個集合只用根節點作為代表元,查詢的時候只要沿著father陣列一直往上走直到根節點為止,而根節點的父親為其本身,於是程式碼為:

int findSetOriginal(int x) //非路徑壓縮查詢
{
    if (x != father[x]) x = father[x];
    return father[x];
}

但實際中會做一個稱為路徑壓縮的優化。因為從該節點x到根節點可能會有很長的一條路徑,查詢的時間複雜度極端情況下為O(n)。我們可以在查詢的過程中,把每個節點的父親都指向跟節點,於是查詢完成之後原本長度為n的一條路徑變成了n條長度為1的路徑,這些節點的查詢時間複雜相應變成了O(1)。路徑壓縮的實現如下所示:

int findSet(int x) //遞迴路徑壓縮查詢
{
    if (x != father[x]) father[x] = findSet(father[x]);
    return father[x];
}

但實際中遞迴演算法可能會造成棧溢位的問題,以下為相應的非遞迴演算法。主要思想是先找到該集合的根節點,然後把路徑上的節點的父親都改為根節點。

int findSetNonRecursive(int x) //非遞迴路徑壓縮查詢
{
    int root = x;        //根節點
    while (root != father[root])
        root = father[root];
    int tem = x;
    while (tem != root)  //路徑壓縮
    {
        int temFather = father[tem];//暫存父親節點
        father[tem] = root;            //更新父親為根
        tem = temFather;            //移動到父親節點
    }
    return root; //返回根節點
}

3. 合併

將兩個元素所在的集合合併為一個集合。合併的時候先使用2中的查詢函式找到兩個集合的根節點。如果根節點相同,說明屬於同一個集合,則不需要合併。如果不同,只需把一個根節點的父親指向另一個根節點即可。

實際中使用了一個稱為按秩合併的優化,因為直接合並可能產生一棵深度很深的樹,這不利於後續的查詢。前面的rnk[x]陣列表示節點x的秩,即該節點子樹的深度。合併時我們總是把秩小的節點的父親指向秩大的節點之上,這樣可以儘量較少新生成的樹的深度。當兩個節點的秩相同時,新生成樹的根節點的秩需要加1,因為子樹的深度增加了1,否則子樹深度沒有變化,秩也不需要改變。

int unionSet(int x, int y) //合併
{
    x = findSet(x), y = findSet(y);
    if (x == y) return 0;    //屬於同一個集合,不需合併
    if (rnk[x] > rnk[y]) father[y] = x;
    else father[x] = y, rnk[y] += rnk[x] == rnk[y];
    return 1;                //屬於不同集合,且已合併成功
}

二 並查集應用

並查集有很多經典的應用。在一些有N個元素的集合應用問題中,我們通常是在開始時讓每個元素構成一個單元素的集合,然後按一定順序將屬於同一組的元素所在的集合合併,其間要反覆查詢一個元素在哪個集合中。

其中一個非常經典的應用是最小生成樹的Kruskal演算法。給定一個具有n個節點的連通圖,它的生成樹是原圖的一個子圖,包含所有n個節點,且有保持圖連通的最少的邊(n-1條邊)。邊權值最小的生成樹是最小生成樹。

kruskal演算法是一個貪心演算法,把所有的邊按權值從小到大依次考慮,如果當前邊加進生成樹中會出現迴路,則丟棄當前邊,否則添加當前邊。

首先新增權最小的邊(0,2),然後新增權第二小的邊(0,1)。然後考慮權為3的邊(1,2),因為新增進這條邊之後,已有的三條件會構成一條迴路,跟生成樹的定義不符,所以(1,2)這條邊不予以考慮。接下來考慮權為4的邊(1,3),新增後不會產生迴路,於是新增。最後考慮權為5的邊(2,3),其新增後產生迴路,同樣丟棄。於是生成的最小生成樹即為右圖的綠色部分。

其實,當添加了3條邊之後最小生成樹已經產生,後面的邊不用再繼續考慮了,因為總共只有4個頂點,其最小生成樹只有3條邊。

現在從並查集的角度考慮這個問題。初始時我們把所有節點自身初始化為一個集合。每次新增一條邊進入最小生成樹時,實際上是把這條邊的兩個節點所在的集合合併。如果添加當前邊之後會產生迴路,實際上是指當前邊的兩個節點所在的集合是一樣的。所以實際上可以把邊按權值從小到大排序,然後逐個合併邊的兩個節點的集合,如果節點集合一樣,就添加了一條新邊,否則直接忽略。

先定義表示圖的資料結構,這裡只需要儲存邊就可以了,以下是邊的結構體陣列,並且包含必要標頭檔案:

#include <iostream>
#include <algorithm>
using namespace std;

const int mxsz = 1000;
struct Edge{
    int st, ed, len;
} edge[mxsz];

然後可以用並查集實現Kruskal演算法了,包含一個邊排序的比較函式,並且以上圖例子作為輸入進行測試:

bool cmp(const Edge &a, const Edge &b) { return a.len < b.len; }
void Kruskal()
{
    //輸入圖
    int nodeNum, edgeNum; //節點數量、邊的數量
    scanf("%d%d", &nodeNum, &edgeNum); 
    for (int i = 0; i < edgeNum; i++) //輸入邊
    {
        int st, ed, len; 
        scanf("%d%d%d", &st, &ed, &len);  
        edge[i].st = st, edge[i].ed = ed, edge[i].len = len;
    }

    //計算最小生成樹權值
    sort(edge, edge + edgeNum, cmp); //邊排序
    n = nodeNum;    makeSet();  //初始化
    int weight = 0;
    for (int i = 0; i < edgeNum; i++) //遍歷邊
        if (unionSet(edge[i].st, edge[i].ed) == 1) //合併邊的兩個節點所在集合
            weight += edge[i].len; //如果節點集合不同,加入最小生成樹中
    printf("最小生成樹權值:%dn", weight);

    /*程式的一個輸入輸出為:
    4 5
    0 1 2
    0 2 1
    1 2 3
    1 3 4
    2 3 5
    最小生成樹權值:7
    */
}

轉載自http://noalgo.info/454.html