【算法】算法學習筆記: 並查集【轉載】

原文鏈接:https://zhuanlan.zhihu.com/p/93647900

前言

並查集被很多OIer認爲是最簡潔而優雅的數據結構之一,主要用於解決一些元素分組的問題。它管理一系列不相交的集合,並支持兩種操作:

  • 合併(Union):把兩個不相交的集合合併爲一個集合。
  • 查詢(Find):查詢兩個元素是否在同一個集合中。
    當然,這樣的定義未免太過學術化,看完後恐怕不太能理解它具體有什麼用。所以我們先來看看並查集最直接的一個應用場景:親戚問題

(洛谷P1551)親戚

題目背景
若某個家族人員過於龐大,要判斷兩個是否是親戚,確實還很不容易,現在給出某個親戚關係圖,求任意給出的兩個人是否具有親戚關係。
題目描述
規定:x和y是親戚,y和z是親戚,那麼x和z也是親戚。如果x,y是親戚,那麼x的親戚都是y的親戚,y的親戚也都是x的親戚。
輸入格式
第一行:三個整數n,m,p,(n<=5000,m<=5000,p<=5000),分別表示有n個人,m個親戚關係,詢問p對親戚關係。
以下m行:每行兩個數Mi,Mj,1<=Mi,Mj<=N,表示Mi和Mj具有親戚關係。
接下來p行:每行兩個數Pi,Pj,詢問Pi和Pj是否具有親戚關係。
輸出格式
P行,每行一個’Yes’或’No’。表示第i個詢問的答案爲“具有”或“不具有”親戚關係。
這其實是一個很有現實意義的問題。我們可以建立模型,把所有人劃分到若干個不相交的集合中,每個集合裏的人彼此是親戚。爲了判斷兩個人是否爲親戚,只需看它們是否屬於同一個集合即可。因此,這裏就可以考慮用並查集進行維護了。


一、並查集的引入

並查集的重要思想在於,用集合中的一個元素代表集合。我曾看過一個有趣的比喻,把集合比喻成幫派,而代表元素則是幫主。接下來我們利用這個比喻,看看並查集是如何運作的。

在這裏插入圖片描述
最開始,所有大俠各自爲戰。他們各自的幫主自然就是自己。(對於只有一個元素的集合,代表元素自然是唯一的那個元素)

現在1號和3號比武,假設1號贏了(這裏具體誰贏暫時不重要),那麼3號就認1號作幫主(合併1號和3號所在的集合,1號爲代表元素)。

在這裏插入圖片描述

現在2號想和3號比武(合併3號和2號所在的集合),但3號表示,別跟我打,讓我幫主來收拾你(合併代表元素)。不妨設這次又是1號贏了,那麼2號也認1號做幫主。

在這裏插入圖片描述
現在我們假設4、5、6號也進行了一番幫派合併,江湖局勢變成下面這樣:
在這裏插入圖片描述

現在假設2號想與6號比,跟剛剛說的一樣,喊幫主1號和4號出來打一架(幫主真辛苦啊)。1號勝利後,4號認1號爲幫主,當然他的手下也都是跟着投降了。
在這裏插入圖片描述

好了,比喻結束了。如果你有一點圖論基礎,相信你已經覺察到,這是一個狀的結構,要尋找集合的代表元素,只需要一層一層往上訪問父節點(圖中箭頭所指的圓),直達樹的根節點(圖中橙色的圓)即可。根節點的父節點是它自己。我們可以直接把它畫成一棵樹:
在這裏插入圖片描述

(好像有點像個火柴人?)
用這種方法,我們可以寫出最簡單版本的並查集代碼。

初始化

int fa[MAXN];
inline void init(int n)
{
    for (int i = 1; i <= n; ++i)
        fa[i] = i;
}

假如有編號爲1, 2, 3, …, n的n個元素,我們用一個數組fa[]來存儲每個元素的父節點(因爲每個元素有且只有一個父節點,所以這是可行的)。一開始,我們先將它們的父節點設爲自己。

查詢

int find(int x)
{
    if(fa[x] == x)
        return x;
    else
        return find(fa[x]);
}

我們用遞歸的寫法實現對代表元素的查詢:一層一層訪問父節點,直至根節點(根節點的標誌就是父節點是本身)。要判斷兩個元素是否屬於同一個集合,只需要看它們的根節點是否相同即可。

合併

inline void merge(int i, int j)
{
    fa[find(i)] = find(j);
}

合併操作也是很簡單的,先找到兩個集合的代表元素,然後將前者的父節點設爲後者即可。當然也可以將後者的父節點設爲前者,這裏暫時不重要。本文末尾會給出一個更合理的比較方法。


二、路徑壓縮

最簡單的並查集效率是比較低的。例如,來看下面這個場景:
在這裏插入圖片描述

現在我們要merge(2,3),於是從2找到1,fa[1]=3,於是變成了這樣:
在這裏插入圖片描述

然後我們又找來一個元素4,並需要執行merge(2,4):
在這裏插入圖片描述

從2找到1,再找到3,然後fa[3]=4,於是變成了這樣:
在這裏插入圖片描述

大家應該有感覺了,這樣可能會形成一條長長的,隨着鏈越來越長,我們想要從底部找到根節點會變得越來越難。

怎麼解決呢?我們可以使用路徑壓縮的方法。既然我們只關心一個元素對應的根節點,那我們希望每個元素到根節點的路徑儘可能短,最好只需要一步,像這樣:
在這裏插入圖片描述

其實這說來也很好實現。只要我們在查詢的過程中,把沿途的每個節點的父節點都設爲根節點即可。下一次再查詢時,我們就可以省很多事。這用遞歸的寫法很容易實現:

合併(路徑壓縮)

int find(int x)
{
    if(x == fa[x])
        return x;
    else{
        fa[x] = find(fa[x]);  //父節點設爲根節點
        return fa[x];         //返回父節點
    }
}

以上代碼常常簡寫爲一行:

int find(int x)
{
    return x == fa[x] ? x : (fa[x] = find(fa[x]));
}

注意賦值運算符=的優先級沒有三元運算符?:高,這裏要加括號。

路徑壓縮優化後,並查集的時間複雜度已經比較低了,絕大多數不相交集合的合併查詢問題都能夠解決。然而,對於某些時間卡得很緊的題目,我們還可以進一步優化。


三、按秩合併

有些人可能有一個誤解,以爲路徑壓縮優化後,並查集始終都是一個菊花圖(只有兩層的樹的俗稱)。但其實,由於路徑壓縮只在查詢時進行,也只壓縮一條路徑,所以並查集最終的結構仍然可能是比較複雜的。例如,現在我們有一棵較複雜的樹需要與一個單元素的集合合併:
在這裏插入圖片描述

假如這時我們要merge(7,8),如果我們可以選擇的話,是把7的父節點設爲8好,還是把8的父節點設爲7好呢?

當然是後者。因爲如果把7的父節點設爲8,會使樹的深度(樹中最長鏈的長度)加深,原來的樹中每個元素到根節點的距離都變長了,之後我們尋找根節點的路徑也就會相應變長。雖然我們有路徑壓縮,但路徑壓縮也是會消耗時間的。而把8的父節點設爲7,則不會有這個問題,因爲它沒有影響到不相關的節點。
在這裏插入圖片描述

這啓發我們:我們應該把簡單的樹往復雜的樹上合併,而不是相反。因爲這樣合併後,到根節點距離變長的節點個數比較少。

我們用一個數組rank[]記錄每個根節點對應的樹的深度(如果不是根節點,其rank相當於以它作爲根節點的子樹的深度)。一開始,把所有元素的rank()設爲1。合併時比較兩個根節點,把rank較小者往較大者上合併。路徑壓縮和按秩合併如果一起使用,時間複雜度接近O(n)O(n) ,但是很可能會破壞rank的準確性。

值得注意的是,按秩合併會帶來額外的空間複雜度,可能被一些卡空間的毒瘤題卡掉。

初始化(按秩合併)

inline void init(int n)
{
    for (int i = 1; i <= n; ++i)
    {
        fa[i] = i;
        rank[i] = 1;
    }
}

合併(按秩合併)

inline void merge(int i, int j)
{
    int x = find(i), y = find(j);    //先找到兩個根節點
    if (rank[x] <= rank[y])
        fa[x] = y;
    else
        fa[y] = x;
    if (rank[x] == rank[y] && x != y)
        rank[y]++;                   //如果深度相同且根節點不同,則新的根節點的深度+1
}

爲什麼深度相同,新的根節點深度要+1?如下圖,我們有兩個深度均爲2的樹,現在要merge(2,5):
在這裏插入圖片描述

這裏把2的父節點設爲5,或者把5的父節點設爲2,其實沒有太大區別。我們選擇前者,於是變成這樣:
在這裏插入圖片描述

顯然樹的深度增加了1。另一種合併方式同樣會讓樹的深度+1。


四、並查集的應用

我們先給出親戚問題的AC代碼:

#include <cstdio>
#define MAXN 5005
int fa[MAXN], rank[MAXN];
inline void init(int n)
{
    for (int i = 1; i <= n; ++i)
    {
        fa[i] = i;
        rank[i] = 1;
    }
}
int find(int x)
{
    return x == fa[x] ? x : (fa[x] = find(fa[x]));
}
inline void merge(int i, int j)
{
    int x = find(i), y = find(j);
    if (rank[x] <= rank[y])
        fa[x] = y;
    else
        fa[y] = x;
    if (rank[x] == rank[y] && x != y)
        rank[y]++;
}
int main()
{
    int n, m, p, x, y;
    scanf("%d%d%d", &n, &m, &p);
    init(n);
    for (int i = 0; i < m; ++i)
    {
        scanf("%d%d", &x, &y);
        merge(x, y);
    }
    for (int i = 0; i < p; ++i)
    {
        scanf("%d%d", &x, &y);
        printf("%s\n", find(x) == find(y) ? "Yes" : "No");
    }
    return 0;
}

接下來我們來看一道NOIP提高組原題:

(NOIP提高組2017年D2T1 洛谷P3958 奶酪)

題目描述
現有一塊大奶酪,它的高度爲hh ,它的長度和寬度我們可以認爲是無限大的,奶酪中間有許多半徑相同的球形空洞。我們可以在這塊奶酪中建立空間座標系,在座標系中, 奶酪的下表面爲 z=0z=0 ,奶酪的上表面爲 z=hz=h
現在,奶酪的下表面有一隻小老鼠 Jerry,它知道奶酪中所有空洞的球心所在的座標。如果兩個空洞相切或是相交,則 Jerry 可以從其中一個空洞跑到另一個空洞,特別地,如果一個空洞與下表面相切或是相交,Jerry 則可以從奶酪下表面跑進空洞;如果一個空洞與上表面相切或是相交,Jerry 則可以從空洞跑到奶酪上表面。
位於奶酪下表面的 Jerry 想知道,在 不破壞奶酪 的情況下,能否利用已有的空洞跑到奶酪的上表面去?
空間內兩點 P1(x1,y1,z1)P_1(x_1, y_1,z_1)P2(x2,y2,z2)P_2(x_2, y_2,z_2)、 的距離公式如下:
dist(P1,P2)=(x1x2)2+(y1y2)2+(z1z2)2dist(P_1, P_2) = \sqrt{(x_1-x_2)^2+(y_1-y_2)^2+(z_1-z_2)^2}
輸入格式
每個輸入文件包含多組數據。
的第一行,包含一個正整數 TT ,代表該輸入文件中所含的數據組數。
接下來是TT組數據,每組數據的格式如下: 第一行包含三個正整數n,hn, hrr ,兩個數之間以一個空格分開,分別代表奶酪中空 洞的數量,奶酪的高度和空洞的半徑。
接下來的nn行,每行包含三個整數x,y,zx,y,z,兩個數之間以一個空格分開,表示空 洞球心座標爲(x,y,z)(x,y,z)
輸出格式
TT行,分別對應TT組數據的答案,如果在第ii組數據中,Jerry 能從下表面跑到上表面,則輸出Yes,如果不能,則輸出No(均不包含引號)。

大家看出這道題和並查集的關係了嗎?

在這裏插入圖片描述
這是二維版本,題目中的三維版本是類似的
大家看看上面這張圖,是不是看出一些門道了?我們把所有空洞劃分爲若干個集合,一旦兩個空洞相交或相切,就把它們放到同一個集合中。

我們還可以劃出2個特殊元素,分別表示底部和頂部,如果一個空洞與底部接觸,則把它與表示底部的元素放在同一個集合中,頂部同理。最後,只需要看頂部和底部是不是在同一個集合中即可。這完全可以通過並查集實現。來看代碼:

#include <cstdio>
#include <cstring>
#define MAXN 1005
typedef long long ll;
int fa[MAXN], rank[MAXN];
ll X[MAXN], Y[MAXN], Z[MAXN];
inline bool next_to(ll x1, ll y1, ll z1, ll x2, ll y2, ll z2, ll r)
{
    return (x1 - x2) * (x1 - x2) + (y1 - y2) * (y1 - y2) + (z1 - z2) * (z1 - z2) <= 4 * r * r;
    //判斷兩個空洞是否相交或相切
}
inline void init(int n)
{
    for (int i = 1; i <= n; ++i)
    {
        fa[i] = i;
        rank[i] = 1;
    }
}
int find(int x)
{
    return x == fa[x] ? x : (fa[x] = find(fa[x]));
}
inline void merge(int i, int j)
{
    int x = find(i), y = find(j);
    if (rank[x] <= rank[y])
        fa[x] = y;
    else
        fa[y] = x;
    if (rank[x] == rank[y] && x != y)
        rank[y]++;
}
int main()
{
    int T, n, h;
    ll r;
    scanf("%d", &T);
    for (int I = 0; I < T; ++I)
    {
        memset(X, 0, sizeof(X));
        memset(Y, 0, sizeof(Y));
        memset(Z, 0, sizeof(Z));
        scanf("%d%d%lld", &n, &h, &r);
        init(n);
        fa[1001] = 1001; //用1001代表底部
        fa[1002] = 1002; //用1002代表頂部
        for (int i = 1; i <= n; ++i)
            scanf("%lld%lld%lld", X + i, Y + i, Z + i);
        for (int i = 1; i <= n; ++i)
        {
            if (Z[i] <= r)
                merge(i, 1001); //與底部接觸的空洞與底部合併
            if (Z[i] + r >= h)
                merge(i, 1002); //與頂部接觸的空洞與頂部合併
        }
        for (int i = 1; i <= n; ++i)
        {
            for (int j = i + 1; j <= n; ++j)
            {
                if (next_to(X[i], Y[i], Z[i], X[j], Y[j], Z[j], r))
                    merge(i, j); //遍歷所有空洞,合併相交或相切的球
            }
        }
        printf("%s\n", find(1001) == find(1002) ? "Yes" : "No");
    }
    return 0;
}

因爲數據範圍的原因,這裏要開一個long long。

並查集的應用還有很多,例如最小生成樹的Kruskal算法等。這裏就不細講了。總而言之,凡是涉及到元素的分組管理問題,都可以考慮使用並查集進行維護。

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