並查集(Union-Find)算法全面詳解

一、前言

在看一個算法題時,其中一種解法用到了並查集,並查集在《算法第四版——1.5案例研究: union-find 算法》中有講解,這裏按照自己的理解記錄一下並查集。

 

二、用途

並查集用於判斷連個點所在的集合是否屬於同一個集合,若屬於同一個集合但還未合併則將兩個集合進行合併。同一個集合的意思是這兩個點是連通的,直接相連或者通過其它點連通。

 

三、什麼是並查集

並查集,在一些有N個元素的集合應用問題中,我們通常是在開始時讓每個元素構成一個單元素的集合,然後按一定順序將屬於同一組的元素所在的集合合併,其間要反覆查找一個元素在哪個集合中。其特點是看似並不複雜,但數據量極大,若用正常的數據結構來描述的話,往往在空間上過大,計算機無法承受;即使在空間上勉強通過,運行的時間複雜度也極高,用並查集來描述可以很好的解決這類問題。並查集是一種樹型的數據結構,用於處理一些不相交集合(Disjoint Sets)的合併及查詢問題。常常在使用中以森林來表示。在處理並查集的時候需要用到動態連通性概念,那什麼是動態連通性呢?

3.1 動態連通性

首先我們詳細地說明一下問題:問題的輸入是一列整數對,其中每個整數都表示一個某種類型的對象,一對整數pq可以被理解爲"p和a是相連的"。我們假設“相連”是一種等價關係,這也就意味着它具有:

  • 自反性:p和p是相連的;
  • 對稱性:如果p和q是相連的,那麼q和p也是相連的;
  • 傳遞性:如果p和q是相連的且q和r是相連的,那麼p和r也是相連的。

等價關係能夠將對象分爲多個等價類。在這裏,當且僅當兩個對象相連時它們才屬於同一個等價類。我們的目標是編寫一個程序來過濾掉序列中所有無意義的整數對(兩個整數均來自於同一個等價類中)。換句話說,當程序從輸入中讀取了整數對p q時,如果已知的所有整數對都不能說明p和q是相連的,那麼則將這一對整數寫入到輸出中。如果已知的數據可以說明p和q是相連的,那麼程序應該忽略pq這對整數並繼續處理輸入中的下一對整數。圖1.5.1用一個例子說明了這個過程。爲了達到所期望的效果,我們需要設計一個數據結構來保存程序已知的所有整數對的足夠多的信息,並用它們來判斷一對新對象是否是相連的。我們將這個問題通俗地叫做動態連通性問題。這個問題可能有以下應用。

3.1.1 網絡

輸入中的整數表示的可能是一個大型計算機網絡中的計算機,而整數對則表示網絡中的連接。這個程序能夠判定我們是否需要在p和q之間架設一條新的連接才能進行通信,或是我們可以通過已有的連接在兩者之間建立通信線路;或者這些整數表示的可能是電子電路中的觸點,而整數對錶示的是連接觸點之間的電路;或者這些整數表示的可能是社交網絡中的人,而整數對錶示的是朋友關係。在此類應用中,我們可能需要處理數百萬的對象和數十億的連接。

3.1.2 變量名等價性

某些編程環境允許聲明兩個等價的變量名(指向同一個對象的多個引用)。在一系列這樣的聲明之後,系統需要能夠判別兩個給定的變量名是否等價。這種較早出現的應用(如FORTRAN語言)推動了我們即將討論的算法的發展。

3.1.3 數學集合

在更高的抽象層次上,可以將輸入的所有整數看做屬於不同的數學集合。在處理一個整數對pq時,我們是在判斷它們是否屬於相同的集合。如果不是,我們會將p所屬的集合和a所屬的集合歸併到同一個集合。

爲了進一步限定話題,我們會在本節以下內容中使用網絡方面的術語,將對象稱爲觸點,將整數對稱爲連接,將等價類稱爲連通分量或是簡稱分量。簡單起見,假設我們有用0到N-1的整數所表示的N個觸點。

 

四、API

爲了說明問題,我們設計了一份API來封裝所需的基本操作:初始化、連接兩個觸點、判斷包含某個觸點的分量、判斷兩個觸點是否存在於同一個分量之中以及返回所有分量的數量。詳細的API如表1.5.1所示。

如果兩個觸點在不同的分量中,union()操作會將兩個分量歸併。find()操作會返回給定觸點所在的連通分量的標識符。connected()操作能夠判斷兩個觸點是否存在於同一個分量之中。count()方法會返回所有連通分量的數量。一開始我們有N個分量,將兩個分量歸併的每次union()操作都會使分量總數減一。

我們馬上就將看到,爲解決動態連通性問題設計算法的任務轉化爲了實現這份API。所有的實現都應該:

  • 定義一種數據結構表示已知的連接;
  • 基於此數據結構實現高效的union()、find()、 connected()和count()方法

衆所周知,數據結構的性質將直接影響到算法的效率,因此數據結構和算法的設計是緊密相關的。API已經說明觸點和分量都會用int值表示,所以我們可以用一個以觸點爲索引的數組id[]作爲基本數據結構來表示所有分量。我們將使用分量中的某個觸點的名稱作爲分量的標識符,因此你可以認爲每個分量都是由它的觸點之一所表示的。一開始,我們有N個分量,每個觸點都構成了一個只含有它自己的分量,因此我們將id[i]的值初始化爲i,其中i在0到N-1之間。對於每個觸點i,我們將find()方法用來判定它所在的分量所需的信息保存在id[i]之中。connected()方法的實現只用一條語句find(p) == find(g),它返回一個布爾值,我們在所有方法的實現中都會用到connected()方法。

總之,我們維護了兩個實例變量,一個是連通分量的個數,一個是數組id[],find()和union()的實現是剩餘內容將要討論的主題。

 

五 實現

我們將討論三種不同的實現,它們均根據以觸點爲索引的id[]數組來確定兩個觸點是否存在於相同的連通分量中。

5.1 quick-find 算法

保證當且僅當id[p]等於id[q]時p和q是連通的。換句話說,在同一個連通分量中的所有觸點在id[]中的值必須全部相同。這意味着connected(p,q)只需要判斷id[p] ==id[q],當且僅當p和q在同一連通分量中該語句纔會返回true。爲了調用union (p,q)確保這一點,我們首先要檢查它們是否已經存在於同一個連通分量之中。如果是我們就不需要採取任何行動,否則我們面對的情況就是p所在的連通分量中的所有觸點的id[]值均爲同一個值,而q所在的連通分量中的所有觸點的id[]值均爲另一個值。要將兩個分量合二爲一,我們必須將兩個集合中所有觸點所對應的id[]元素變爲同一個值。如表1.5.2所示。爲此,我們需要遍歷整個數組,將所有和id [p]相等的元素的值變爲id[q]的值。我們也可以將所有和id[q]相等的元素的值變爲id[p]的值——兩者皆可。根據上述文字得到的find()和union()的代碼簡單明瞭,如下面的代碼框所示。圖1.5.3顯示的是我們的開發用例在處理測試數據tinyUF.txt時的完整軌跡。

5.1.1 quick-find 算法分析

find()操作的速度顯然是很快的,因爲它只需要訪問id[]數組一次。但quick-find算法一般無法處理大型問題,因爲對於每一對輸入union()都需要掃描整個id[]數組。

 

5.2 quick-union 算法

quick-union 算法的重點是提高union()方法的速度,它和quick-find算法是互補的。它也基於相同的數據結構——以觸點作爲索引的id[]數組。每個觸點所對應的id[]元素都是同一個分量中的另一個觸點的名稱(也可能是它自己)——我們將這種聯繫稱爲鏈接。在實現find()方法時,我們從給定的觸點開始,由它的鏈接得到另一個觸點,再由這個觸點的鏈接到達第三個觸點,如此繼續跟隨着鏈接直到到達一個根觸點,即鏈接指向自己的觸點。當且僅當分別由兩個觸點開始的這個過程到達了同一個根觸點時它們存在於同一個連通分量之中。爲了保證這個過程的有效性,我們需要union(p, q)來保證這一點。它的實現很簡單:我們由p和q的鏈接分別找到它們的根觸點,然後只需將一個根觸點鏈接到另一個即可將一個分量重命名爲另一個分量,因此這個算法叫做quick-union。和剛纔一樣,無論是重命名含有p的分量還是重命名含有q的分量都可以。圖1.5.5顯示了quick-union算法在處理tinyUF.txt時的軌跡。圖1.5.4能夠很好地說明圖1.5.5(見1.5.2.4節)中的軌跡,我們接下來要討論的就是它。

 

5.2.1 quick-union 算法分析

quick-union算法看起來比quick-find算法更快,因爲它不需要爲每對輸入遍歷整個數組。但它能夠快多少呢?分析quick-union算法的成本比分析quick-find算法的成本更困難,因爲這依賴於輸人的特點。在最好的情況下,find()只需要訪問數組一次就能夠得到一個觸點所在的分量的標識符;而在最壞情況下,這需要N-1次數組訪問。我們可以將quick-union算法看做是quick-find算法的一種改良,因爲它解決了quick-find算法中最主要的問題(union()操作總是線性級別的)。對於一般的輸入數據這個變化顯然是一次改進,但quick-union算法仍然存在問題,我們不能保證在所有情況下它都能比quick-find算法快得多(對於某些輸人,quick-union算法並不比quick-find算法快)。

 

5.3 加權quick-union 算法

我們現在會記錄每一棵樹的大小並總是將較小的樹連接到較大的樹上。這項改動需要添加一個數組和一些代碼來記錄樹中的節點數,它能夠大大改進算法的效率。我們將它稱爲加權quick-union算法(如圖1.5.7所示)。該算法在處理tinyUF.txt時構造的森林如圖1.5.8中左側的圖所示。即使對於這個較小的例子,該算法構造的樹的高度也遠遠小於未加權的版本所構造的樹的高度。

5.3.1 加權quick-unio 算法

圖1.5.8顯示了加權quick-union算法的最壞情況。其中將要被歸併的樹的大小總是相等的(且總是2的冪)。這些樹的結構看起來很複雜,但它們均含有2^2個節點,因此高度都正好是n。另外,當我們歸併兩個含有2^2個節點的樹時,我們得到的樹含有2^(2+1)個節點,由此將樹的高度增加到了n+1。由此推廣我們可以證明加權quick-union算法能夠保證對數級別的性能。加權quick-union算法的實現如算法1.5所示。

 

六、三種算法特點

 

七、展望——《算法第四版1.5.3》

  • 完整而詳細地定義問題,找出解決問題所必需的基本抽象操作並定義一份API
  • 簡潔地實現一種初級算法,給出一個精心組織的開發用例並使用實際數據作爲輸入
  • 當實現所能解決的問題的最大規模達不到期望時決定改進還是放棄。
  • 逐步改進實現,通過經驗性分析或(和)數學分析驗證改進後的效果。
  • 用更高層次的抽象表示數據結構或算法來設計更高級的改進版本。
  • 如果可能儘量爲最壞情況下的性能提供保證,但在處理普通數據時也要有良好的性能。
  • 在適當的時候將更細緻的深入研究留給有經驗的研究者並繼續解決下一個問題。

 

八、感想

先看了一道算法題解答中用到了並查集,然後再看並查集,導致自己在看並查集的時候腦子裏一直盤旋的是那道算法題,在看並查集的時候一直在想怎樣用並查集更好的解決那道算法題,以至於忽略了並查集算法本身需要解決的問題。期間還在想p和q之間路徑能不能找到。

在看一個已經定於好算法時,需要專注的是這個算法本身,問題擴展應該是在理解當前算法之後。《算法第四版1.5.3》其中有一條說道——完整而詳細地定義問題

 

九、編碼實現

//==========================================================================
/**
* @file    : 01_UnionFind.h
* @blogs   : https://blog.csdn.net/nie2314550441/article/details/106954784
* @author  : niebingyu
* @title   : 並查集
* @purpose : 並查集用於判斷連個點所在的集合是否屬於同一個集合,
*            若屬於同一個集合但還未合併則將兩個集合進行合併。
* 
*/
//==========================================================================
#pragma once
#include <vector>
#include <unordered_set>
#include <iostream>
#include <algorithm>
using namespace std;

#define NAMESPACE_UNIONFIND namespace NAME_UNIONFIND {
#define NAMESPACE_UNIONFINDEND }
NAMESPACE_UNIONFIND

// 方法一 quick-find
class UF
{
public:
    UF(int N)
    {
        if (N < 0) return;

        m_count = N;
        m_arr.resize(N);
        for (int i = 0; i < N; ++i)
        {
            m_arr[i] = i;
        }
    }

    // p(0 到 N-1)所在的分量的標識符
    int find(int p)
    {
        if (p < 0 || p >= m_arr.size())
            return -1;

        return m_arr[p];
    }

    // 在 p 和 q 之間添加一條連續
    void Union(int p, int q)
    {
        int pID = find(p);
        int qID = find(q);
        
        if (pID == qID)
            return;
        
        for (int i = 0; i < m_arr.size(); ++i)
        {
            if (m_arr[i] == pID)
                m_arr[i] = qID;
        }

        --m_count;
    }

    // 如果 p 和 q 存在於同一個分量中則返回 true
    bool connected(int p, int q)
    {
        return find(p) == find(q);
    }
    
    // 連通分量的數量
    int count()
    {
        return m_count;
    }

private:
    int m_count;            // 風量數量
    vector<int> m_arr;      // 分量id(以觸點作爲索引)
};

// 方法二 quick-union
class QF
{
public:
    QF(int N)
    {
        if (N < 0) return;

        m_count = N;
        m_arr.resize(N);
        for (int i = 0; i < N; ++i)
        {
            m_arr[i] = i;
        }
    }

    // p(0 到 N-1)所在的分量的標識符
    int find(int p)
    {
        if (p < 0 || p >= m_arr.size())
            return -1;

        while (p != m_arr[p])
        {
            p = m_arr[p];
        }

        return p;
    }

    // 在 p 和 q 之間添加一條連續
    void Union(int p, int q)
    {
        int pRoot = find(p);
        int qRoot = find(q);

        if (pRoot == qRoot)
            return;

        m_arr[pRoot] = qRoot;

        --m_count;
    }

    // 如果 p 和 q 存在於同一個分量中則返回 true
    bool connected(int p, int q)
    {
        return find(p) == find(q);
    }

    // 連通分量的數量
    int count()
    {
        return m_count;
    }

private:
    int m_count;            // 風量數量
    vector<int> m_arr;      // 分量id(以觸點作爲索引)
};

// 方法三 加權quick-union 
class WeightedQuickUnionUF
{
public:
    WeightedQuickUnionUF(int N)
    {
        m_count = N;
        m_id.resize(N);
        m_sz.resize(N);
        for (int i = 0; i < N; ++i)
        {
            m_id[i] = i;
            m_sz[i] = i;
        }
    }

    // p(0 到 N-1)所在的分量的標識符
    int find(int p)
    {
        if (p < 0 || p >= m_id.size())
            return -1;

        while (p != m_id[p])
        {
            p = m_id[p];
        }

        return p;
    }

    // 在 p 和 q 之間添加一條連續
    void Union(int p, int q)
    {
        int pi = find(p);
        int qi = find(q);

        if (pi == qi)
            return;

        if (m_sz[pi] < m_sz[qi])
        { 
            m_id[pi] = qi; 
            m_sz[qi] += m_sz[pi]; 
        }
        else                    
        { 
            m_id[qi] = pi; 
            m_sz[pi] += m_sz[qi]; 
        }

        --m_count;
    }

    // 如果 p 和 q 存在於同一個分量中則返回 true
    bool connected(int p, int q)
    {
        return find(p) == find(q);
    }

    // 連通分量的數量
    int count()
    {
        return m_count;
    }

private:
    vector<int> m_id;   // 父鏈接數組(由觸點索引)
    vector<int> m_sz;   // (由觸點索引的)各個根節點所對應的風量的大小
    int m_count;        // 連通分量的數量
};

//////////////////////////////////////////////////////////////////////
// 測試 用例 START
struct PQ
{
    int p, q;
    PQ(int pi, int qi) :p(pi), q(qi) {}
};

void test(const char* testName, vector<PQ> nums, int count)
{
    //UF S(10);
    //QF S(10);
    WeightedQuickUnionUF S(10);
    for (int i = 0; i < nums.size(); ++i)
    {
        S.Union(nums[i].p, nums[i].q);
    }

    // 粗略校驗
    if (S.count() == count)
        cout << testName << ", solution passed." << endl;
    else
        cout << testName << ", solution failed. S.count():" << S.count() << " ,count:" << count << endl;
}

// 測試用例
void Test1()
{
    vector<PQ> gArr = { PQ(4,3), PQ(3,8), PQ(6,5), PQ(9,4), PQ(2,1), PQ(8,9), PQ(5,0), PQ(7,2), PQ(6,1), PQ(1,0), PQ(6,7) };
    int expect = 2;

    test("Test1()", gArr, expect);
}

NAMESPACE_UNIONFINDEND
// 測試 用例 END
//////////////////////////////////////////////////////////////////////

void UnionFind_Test()
{
    NAME_UNIONFIND::Test1();
}

執行結果:

 

 

 

 

 

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章