並查集到帶權並查集

zhxmdefj發表於2019-07-02

並查集到帶權並查集

合併-查詢問題

在說並查集之前,我們先講一下合併-查詢問題

合併-查詢問題。顧名思義,就是既有合併又有查詢操作的問題

舉個例子:

  1. 有一群人,他們之間有若干好友關係。
  2. 如果A是B好友的好友,或者好友的好友的好友等等,即通過若干好友可以認識,那麼我們說A和B是間接好友。如果兩個人有直接或者間接好友關係,那麼我們就說他們在同一個朋友圈
  3. 隨著時間的變化,這群人中有可能會有新的朋友關係,比如A和C變成了好友,那麼C和B也是間接好友了
  4. 查詢操作:這時候我們需要對當中某些人是否在同一朋友圈進行詢問:B和C是否在一個朋友圈中?(是否是直接或間接欸好友?)

2和3是合併操作,4是查詢操作

樸素演算法

暴力直接的方式:每個人用一個編號來表示他所在的朋友圈(下圖用顏色表示編號),如果有新認識的朋友,我們就合併朋友圈:把兩人的朋友圈中所有人編號改成同一個

並查集到帶權並查集

  1. A和B是好友,屬於紅色組,C和D是好友,屬於藍色組
  2. 詢問兩個人是否在同一個朋友圈,判斷他們標記(顏色)是否相同
  3. 過了不久,A和D又成為了好友,我們把兩個朋友圈中所有的人標記變成相同的顏色,這就完成了一次合併的操作

假設我們要合併A和D的朋友圈,需要找到所有和D在同一朋友圈裡的人,並把標記改為A所在的朋友圈

//group[i]表示i所在朋友圈的顏色(編號)
//合併朋友圈B到A
for(int i =0; i<n; i++){
    if (group[i] == group[D]){
        group[i] = group[A]
    }
}

需求分析

這個時候我們就需要建立一種資料結構,能夠高效的處理這三種操作,分別是

  • MakeSet(x),建立一個只有元素x的集合,且x不應出現在其他的集合中
  • Union(x, y),將元素x所在集合Sx和元素y所在的集合Sy合併,這裡我們假定Sx不等於Sy
  • FindSet(x),查詢元素x所在集合的代表

回到最開始的題:

  1. 有一群人,他們之間有若干好友關係。
  2. 如果A是B好友的好友,或者好友的好友的好友等等,即通過若干好友可以認識,那麼我們說A和B是間接好友。如果兩個人有直接或者間接好友關係,那麼我們就說他們在同一個朋友圈
  3. 隨著時間的變化,這群人中有可能會有新的朋友關係,比如A和C變成了好友,那麼C和B也是間接好友了
  4. 查詢操作:這時候我們需要對當中某些人是否在同一朋友圈進行詢問:B和C是否在一個朋友圈中?(是否是直接或間接欸好友?)

假設這裡有5個人,起初每個人都互相不認識,每個人都是一個集合,於是呼叫n次 MakeSet() 來建立n個集合

當有兩個人互相認識的時候,那麼我們用 Union() 來合併兩個集合

詢問x和y是否在同一集合,我們呼叫兩次 Find() 檢視x和y所在集合的根,通關判斷根是否相同,從而判斷他們是否在同一個集合內

我們看到Union(1,4),首先我們先找到1和4所在樹的根,1的根就是1,而4的根就是3,這時候我們把這兩顆樹合併,並把1設定為3的父親節點,這時候就完成了兩顆樹的合併

並查集到帶權並查集

起初有5個人,編號為1-5,剛開始大家都互不認識,所以各自為一個節點的樹,自己就是根結點

這時候如果1和2認識了,那麼我們就把這兩個節點所代表的樹合併起來,由編號較小的1作為根

接著3和4又認識了,那麼我們重複剛剛的過程,把3和4所代表樹合併起來

現在1和4認識了,首先我們先找到1和4所在樹的根,1的根就是1,而4的根就是3,兩個根不同證明他們原本不在同一棵樹上,我們就要把這兩顆樹合併,把1設定為3的父親節點

在上述一系列操作中,初始化構建樹就是MakeSet操作,樹的合併就是Union操作,而找根的過程就是FindSet操作

這個資料結構就是並查集

並查集是什麼

管理元素分組情況的資料結構,在並查集中,每個不相交的集合都用一顆有根樹來表示,每個元素都是樹上的一個節點

並查集可以高效地進行如下操作:

  • 查詢元素a和元素b是否屬於同一集合(組)
  • 合併元素a和元素b所在集合(組)

並查集的結構

樹形結構實現的

並查集到帶權並查集

並查集支援的操作

  • MakeSet建立組(集合)

    //p[i]表示根為i的集合
    void MakeSet(){
        for(int i = 0; i < N; i++)
            p[i] = i;
  • Union合併,從一個組的根向另一個組的根連邊,這樣兩棵樹變成了一棵樹,也就把兩個集合合併為一個集合了

    void UnionA(int x, int y)
    {
      int xRoot = FindSet(x);
      int yRoot = FindSet(y);
      //將集合x所在合併到y所在集合上
      parent[xRoot] = yRoot;
    }

並查集到帶權並查集

  • FindSet查詢:如果兩個節點的根相同,就可以知道它們屬於同一集合

    //Find(x) return the root of x 查詢所在集合的根
    //對父節點遞迴呼叫Find,直到找到根為止
    int FindSet(int x){
        if(x == parent[x])    //如果父節點是的根就是自身,返回這個節點
          return x;
        else             //否則遞迴呼叫查詢父親的根節點
              return FindSet(parent[x]);
    }

並查集到帶權並查集

並查集的優化

在樹形資料結構中,如果發生了退化的情況,複雜度就會變得很高,具體來講,像下面這棵樹,如果我們越加越深,那麼每次呼叫Find可能會需要O(n)的時間,總的複雜度在最壞情況下就是O(nQ)了

並查集到帶權並查集

解決方案:

1. 路徑壓縮

遞迴找到根節點的時候,把當前節點到根節點間所有節點的父節點都設定為根節點

例如我們現在要找元素9所在樹的根節點,在找根節點的過程中使用路徑壓縮,也就是說9到根的路徑上的節點9,6,3,1的父節點都設定成為根節點0,所以在 FindSet(9) 之後,樹就變成了下面的樣子,這就是路徑壓縮,具體來講就是加一行程式碼

並查集到帶權並查集

int FindSet(int x){
      if(x == parent[x])    //如果父節點是的根就是自身,返回這個節點
          return x;
      else{
          parent[x] = FindSet(parent[x]);//路徑上節點的父節點都設定成為根節點0
          return FindSet(parent[x]);
          //或者直接一句 return p[x] = FindSet(p[x]);
      }             
  }

2. 按秩合併(啟發式合併)

路徑壓縮時直接將節點的父親修改成最終的祖先節點,在破壞原先的樹結構的同時,在有些題目中也會損失資訊

對於每棵樹,記錄這棵樹的高度rank,合併時如果兩棵樹的高度不同,從高度小的向高度大的連邊

並查集到帶權並查集

void Union(int x, int y){
    int xRoot = FindSet(x);//找出雙方的根
    int yRoot = FindSet(y);

    if(xRoot == yRoot) return;//同根則結束
    
    if(rank[xRoot] < rank[yRoot])//比較高度
        parent[xRoot] = yRoot;
    else if(rank[xRoot] > rank[yRoot]) 
        parent[yRoot] = xRoot;
    else{ //rank[xRoot] == rank[yRoot]
        parent[yRoot] = xRoot;
        rank[xRoot]++;
    }
}

程式碼模板

//initial the sets 這裡有5個人,每個人都是一個集合
void MakeSet(){
    for(int i = 0; i < N; i++){
        parent[i] = i;
    }
}

//壓縮路徑
int FindSetA(int x){
    if(x == parent[x])
        return x;
    else
        return parent[x] = FindSet(parent[x]);
}
//啟發式合併:不壓縮路徑,保持樹結構
int FindSetB(int x){
    if(x == parent[x])
        return x;
    else
        return FindSetB(parent[x]);
}   

//普通合併
void UnionA(int x, int y)
{
    int xRoot = FindSet(x);
    int yRoot = FindSet(y);
    //將集合x所在合併到y所在集合上
    parent[xRoot] = yRoot;
}

//啟發式合併:小樹接在大樹上
void UnionB(int x, int y){
    int xRoot = FindSet(x);//找出雙方的根
    int yRoot = FindSet(y);
    if(xRoot == yRoot) return;//同根則結束
    
    if(rank[xRoot] < rank[yRoot]) 
        parent[xRoot] = yRoot;
    else if(rank[xRoot] > rank[yRoot]) 
        parent[yRoot] = xRoot;
    else{
        parent[yRoot] = xRoot;
        rank[xRoot]++;
    }
}

bool IsSameRoot(int x, int y){
    //printf("root of %d: %d\n", x, FindSet(x));
    //printf("root of %d: %d\n", y, FindSet(y));
    return FindSet(x) == FindSet(y);
}

例題part1

hdu1232 城鎮交通

Problem Description
某省調查城鎮交通狀況,得到現有城鎮道路統計表,表中列出了每條道路直接連通的城鎮。省政府“暢通工程”的目標是使全省任何兩個城鎮間都可以實現交通(但不一定有直接的道路相連,只要互相間接通過道路可達即可)。問最少還需要建設多少條道路?

Input
測試輸入包含若干測試用例。每個測試用例的第1行給出兩個正整數,分別是城鎮數目N ( < 1000 )和道路數目M;隨後的M行對應M條道路,每行給出一對正整數,分別是該條道路直接連通的兩個城鎮的編號。為簡單起見,城鎮從1到N編號。
注意:兩個城市之間可以有多條道路相通,也就是說

3 3
1 2
1 2
2 1

這種輸入也是合法的
當N為0時,輸入結束,該用例不被處理。

Output
對每個測試用例,在1行裡輸出最少還需要建設的道路數目。

Sample Input

4 2
1 3
4 3
3 3
1 2
1 3
2 3
5 2
1 2
3 5
999 0
0

Sample Output

1
0
2
998

問將所有獨立的集合連線起來還需要幾條路,那隻要找到獨立集合個數-1就是答案

這裡做法很簡單,用給出的資料建樹,再遍歷每一個節點,此節點為根節點則總集合數目++,rank都不需要了

#include <stdio.h>
const int MAX = 1000;
int parent[MAX];
//初始化集合
void MakeSet(int n){
    int i;
    for (i = 1; i <= n; i++)
        parent[i] = i;
}
//查詢函式
int FindSet(int x){
    if (x == parent[x])
        return x;
    else
        return parent[x] = FindSet(parent[x]);
}
//合併函式
void Union(int a, int b){
    int aRoot, bRoot;
    aRoot = FindSet(a);
    bRoot = FindSet(b);
    if (aRoot != bRoot)
        parent[aRoot] = bRoot;
}
int main()
{
    int n, m, a, b;
    while (scanf("%d", &n) != EOF){
        if (!n) break;
        MakeSet(n);
        scanf("%d", &m);
        for (int i = 0; i < m; ++i){
            scanf("%d %d", &a, &b);
            Union(a, b);
        }
        //確定連通分量個數
        int sum = 0;
        for (int i = 1; i <= n; ++i)
            if (parent[i] == i)
                sum++;
        printf("%d\n", sum - 1);
    }
    return 0;
}

poj1611 感染的學生

一些學生被分組,0號可能感染病毒,跟他同一集合的也可能感染,那麼給出幾個分組,問可能感染的人數

Sample Input

100 4
2 1 2
5 10 13 11 12 14
2 0 1
2 99 2
200 2
1 5
5 1 2 3 4 5
1 0
0 0

Sample Output

4
1
1

開個陣列sumInSet統計每個集合的人數,以0號作為根節點,sumInSet[0]就是和0在同個集合的人數,即為答案,做一下路徑壓縮即可,也不需要rank

#include <bits/stdc++.h>
using namespace std;

const int MAX = 30001;
int sumInSet[MAX];//每個集合人數
int parent[MAX];
int trank[MAX];//樹高

int FindSet(int x) {
    if (x == parent[x])
        return x;
    else //帶路徑壓縮
        return parent[x] = FindSet(parent[x]);
}
void Union(int x, int y) {
    int xRoot = FindSet(x);//找出雙方的根
    int yRoot = FindSet(y);
    if (xRoot == yRoot) return;//同根則結束
    if (trank[xRoot] > trank[yRoot]) {//讓rank比較高的作為父結點
        parent[yRoot] = xRoot;
        sumInSet[xRoot] += sumInSet[yRoot];
    }
    else {
        parent[xRoot] = yRoot;
        if (trank[xRoot] == trank[yRoot])
            trank[yRoot]++;
        sumInSet[yRoot] += sumInSet[xRoot];
    }
}
int main()
{
    int n, m;
    int k, x, y;
    while (scanf("%d%d", &n, &m)!=EOF) {
        if (n == 0 && m == 0) return 0;
        //init
        for (int i = 0; i < n; i++) {
            parent[i] = i;
            sumInSet[i] = 1;
            trank[i] = 0;
        }
        for (int i = 0; i < m; i++) {
            scanf("%d%d", &k, &x);
            k--;
            while (k--) {
                scanf("%d", &y);
                Union(x, y);
            }
        }
        printf("%d\n", sumInSet[FindSet(0)]);
    }
    return 0;
}

帶權並查集

普通的並查集僅僅記錄的是集合的關係,這個關係無非是同屬一個集合或者是不在一個集合

帶權並查集不僅記錄集合的關係,還記錄著集合內元素的關係或者說是集合內元素連線線的權值

普通並查集本質是不帶權值的圖,而帶權並查集則是帶權的圖

考慮到權值就會有以下問題:

  1. 每個節點都記錄的是與根節點之間的權值,那麼在Find的路徑壓縮過程中,權值也應該做相應的更新,因為在路徑壓縮之前,每個節點都是與其父節點連結著,那個Value自然也是與其父節點之間的權值
  2. 在兩個並查集做合併的時候,權值也要做相應的更新,因為兩個並查集的根節點不同

向量偏移法

對於集合裡的任意兩個元素x,y而言,它們之間必定存在著某種聯絡,因為並查集中的元素均是有聯絡的(這點是並查集的實質,要深刻理解),否則也不會被合併到當前集合中,那麼我們就把這2個元素之間的關係量轉化為一個偏移量

路徑壓縮

int FindSet(int x) {
    if (x == parent[x])
        return x;
    else {
        int t = parent[x]; //記錄原父節點編號
        parent[x] = FindSet(parent[x]); //父節點變為根節點,此時value[x]=父節點到根節點的權值
        value[x] += value[t]; //當前節點的權值加上原本父節點的權值
         return parent[x]
    }
}

因為在路徑壓縮後父節點直接變為根節點,此時父節點的權值已經是父節點到根節點的權值了,將當前節點的權值加上原本父節點的權值,就得到當前節點到根節點的權值

合併

已知x,y根節點分別為xRoot,yRoot,如果有了x、y之間的關係,合併如果不考慮權值直接修改parent就行了,但是現在是帶權並查集,必須得求出xRoot與yRoot這條邊的權值是多少,很顯然x到yRoot兩條路徑的權值之和應該相同,就不難得出上面程式碼所表達的更新式(但是需要注意並不是每個問題都是這樣更新的,有時候可能會做取模之類的操作,這一點在之後的例題中可以體現)

並查集到帶權並查集

    int xRoot = FindSet(x);
    int yRoot = FindSet(y);
    if (xRoot != yRoot)
    {
        parent[xRoot] = yRoot;
        value[xRoot] = -value[x] + value[y] + s;
    }

例題part2

hdu3308

給出區間[1,n],下面有m組資料,l r v表示[l,r]區間和為v,每輸入一組資料,判斷此組條件是否與前面衝突,輸出衝突的資料的個數

用一個value[]陣列儲存從某點到其根節點距離

  1. roota != rootb時,

    合併操作將roota併入rootb,roota~>rootb = b~>rootb - b~>roota

    然後我們可以知道 b~>roota = a~>roota - a~>b

    所以最後可以推出 roota ~>rootb = b~>rootb + a~>b - a~>roota

    而roota的根節點是rootb,所以 roota~>rootb = value[roota]

    然後依次推出得到 value[roota] = -value[a]+value[b]+v (a~>b=v)

    並查集到帶權並查集

  2. roota==rootb

    a~>b = a~>root - b~>root然後得到表示式 v = value[a]-value[b] (一定要記住這裡的sum都是相對於根節點的,sum的更新在路徑壓縮的時候更新了)

並查集到帶權並查集

#include <stdio.h>
#include <algorithm>
#include <string.h>
using namespace std;
const int N = 200010;
int parent[N];
int value[N];  ///記錄當前結點到根結點的距離

int FindSet(int x) {
    if (x == parent[x])
        return x;
    else {
        int t = parent[x]; //記錄原父節點編號
        parent[x] = FindSet(parent[x]); //父節點變為根節點,此時value[x]=父節點到根節點的權值
        value[x] += value[t]; //當前節點的權值加上原本父節點的權值
        return parent[x]
            // value[x] += value[FindSet(parent[x])];
            // return parent[x] = FindSet(parent[x]);
    }
}

int main()
{
    int n, m;
    while (scanf("%d%d", &n, &m) != EOF) {
        for (int i = 0; i <= n; i++) {
            parent[i] = i;
            value[i] = 0;
        }
        int ans = 0;
        while (m--) {
            int a, b, v;
            scanf("%d%d%d", &a, &b, &v);
            a--;
            int roota = FindSet(a);
            int rootb = FindSet(b);
            if (roota == rootb) {
                if (value[a] - value[b] != v) ans++;  ///精華部分1
            }
            else {
                parent[roota] = rootb;
                value[roota] = -value[a] + value[b] + v; ///精華部分2
            }
        }
        printf("%d\n", ans);
    }
    return 0;
}

POJ2492

每次給出兩個昆蟲的關係(異性關係),然後發現這些條件中是否有悖論

第一組資料

1 2
2 3
1 3

1和2是異性,2和3是異性,然後說1和3是異性就顯然不對了

用r[x]儲存的是x與其根節點rx的關係,0代表同性1代表異性

這道題與上一道題唯一的不同是權值不是累加的關係而是相當於二進位制的個位,也就是累加結果取%2。

#include <cstdio>

const int maxn = 2000 + 10;

int parent[maxn];
int value[maxn];  //與根節點的關係,如果值為1則為異性如果為0則為同性

int FindSet(int x) {
    int t = parent[x];
    if (parent[x] != x) {
        parent[x] = FindSet(parent[x]);
        value[x] = (value[x] + value[t]) % 2;  // 更新關係
    }
    return parent[x];
}

int main() 
{
    int flag;
    int kase = 0;
    int T;
    int x, y;
    int n, m;
    scanf("%d", &T);
    while (T--) {
        flag = 1;
        scanf("%d%d", &n, &m);
        for (int i = 1; i <= n; i++) {
            parent[i] = i;
            value[i] = 0;
        }
        while (m--) {
            scanf("%d%d", &x, &y);
            if (!flag) continue;
            int rx = FindSet(x);
            int ry = FindSet(y);
            if (rx == ry) {
                if ((value[x] - value[y]) % 2 == 0) {
                    flag = 0;
                }
            }
            else {
                parent[rx] = ry;
                value[rx] = (value[x] - value[y] + 1) % 2;
            }
        }
        printf("Scenario #%d:\n", ++kase);
        if (flag)
            printf("No suspicious bugs found!\n\n");
        else
            printf("Suspicious bugs found!\n\n");
    }
    return 0;
}

我們再把上一道題升級一下,從兩個種類擴充為三個種類,由於三個種類的關係依舊是一個所以依然可以套帶權並查集模版,有幾個種類就取幾模,這裡是%3

poj1182 食物鏈

Description

動物王國中有三類動物A,B,C,這三類動物的食物鏈構成了有趣的環形。A吃B, B吃C,C吃A。
現有N個動物,以1-N編號。每個動物都是A,B,C中的一種,但是我們並不知道它到底是哪一種。
有人用兩種說法對這N個動物所構成的食物鏈關係進行描述:
第一種說法是"1 X Y",表示X和Y是同類。
第二種說法是"2 X Y",表示X吃Y。
此人對N個動物,用上述兩種說法,一句接一句地說出K句話,這K句話有的是真的,有的是假的。當一句話滿足下列三條之一時,這句話就是假話,否則就是真話。
1) 當前的話與前面的某些真的話衝突,就是假話;
2) 當前的話中X或Y比N大,就是假話;
3) 當前的話表示X吃X,就是假話。
你的任務是根據給定的N(1 <= N <= 50,000)和K句話(0 <= K <= 100,000),輸出假話的總數。

Input

第一行是兩個整數N和K,以一個空格分隔。
以下K行每行是三個正整數 D,X,Y,兩數之間用一個空格隔開,其中D表示說法的種類。
若D=1,則表示X和Y是同類。
若D=2,則表示X吃Y。

Output

只有一個整數,表示假話的數目。

Sample Input

100 7
1 101 1
2 1 2
2 2 3
2 3 3
1 1 3
2 3 1
1 5 5

Sample Output

3

這道題難度會稍微上升,上一題看不太懂也沒關係,我這裡展開來細講

向量偏移法:對於集合裡的任意兩個元素x,y而言,它們之間必定存在著某種聯絡,因為並查集中的元素均是有聯絡的(這點是並查集的實質,要深刻理解),否則也不會被合併到當前集合中,那麼我們就把這2個元素之間的關係量轉化為一個偏移量

我們先用數值表示三種基本關係:

  • 兩者同類 = 0
  • 吃父節點 = 1
  • 被父節點吃 = 2

我們需要處理兩種關係轉化

1.在同一棵樹上,根節點相同

A-r-B表示A和B之間的關係是r,比如A-1-B代表A吃B。

現在,若已知A~>B = r1,B~>C = r2,求A~>C

如下圖,ABC的關係就像向量,A~>B,B~>C的關係已知並且可以量化

並查集到帶權並查集

A~>B B~>C A~>C
0 0 0
0 1 1
0 2 2
1 0 1
1 1 2
1 2 0
2 0 2
2 1 0
2 2 1

於是我們不難發現,A~>C=(r1+r2)%3(這裡模3是保證偏移量取值始終在[0,2]間)

這個就是在FindSet(int x)函式中用到的更新x與parent[x]的關係

2.兩棵樹合併

合併X的根節點和Y的根節點,同時修改各自的rank

現有a-r-b,Pa和Pb分別是a和b的根節點,我們已知的是

  • a~>Pa = rank[a]
  • b~>Pb = rank[b]
  • a~>b = r

顯然我們可以得到

  • Pa~>a = (3-rank[a])%3
  • Pb~>b = (3-rank[b])%3

假如合併後a為新的樹的根節點,那麼原先Pa樹上的節點不需變化,Pb樹則要改變,因為rank[i]值為該節點i和樹根的關係。這裡只改變rank(Pb)即可,因為在進行 FindSet() 操作時可相應改變Pb樹的所有節點的值rank值

於是問題變成了Pb~>Pa = ?

Pa~>Pb = Pb~>b + b~>a + a~>Pa = 3-rank[b] + (3-r)%3 + rank[a]

並查集到帶權並查集

#include <iostream>
const int MAX = 50005;

int parent[MAX];
int rank[MAX];

void MakeSet(int x){
    parent[x] = x;
    rank[x] = 0;
}

//查詢x的集合,回溯時壓縮路徑,並修改x與father[x]的關係
int FindSet(int x)
{
    if (x == parent[x])
        return x;
    else {
        //更新x與father[X]的關係
        rank[x] = (rank[x] + rank[parent[x]]) % 3;
        return parent[x] = FindSet(parent[x]);
    }
}

//合併x,y所在的集合
void Union(int x, int y, int d)
{
    int xRoot = FindSet(x);
    int yRoot = FindSet(y);
    //將集合x所在合併到y所在集合上
    parent[xRoot] = yRoot;
    //更新x的根與x的父節點的關係
    rank[xRoot] = (rank[y] - rank[x] + 3 + d) % 3;
}

int main()
{
    int ans = 0;
    int n, k, x, y, d, xRoot, yRoot;
    scanf("%d%d", &n, &k);
    for (int i = 1; i <= n; ++i)
        MakeSet(i);
    while (k--) {
        scanf("%d%d%d", &d, &x, &y);
        //如果x或y比n大,或x吃x,是假話
        if (x > n || y > n || (d == 2 && x == y)) {
            ans++;
        }
        else
        {
            xRoot = FindSet(x);
            yRoot = FindSet(y);
            //如果x,f的父節點相同 ,那麼可以判斷給出的關係是否正確的
            if (xRoot == yRoot) {
                if ((rank[x] - rank[y] + 3) % 3 != d - 1)
                    ans++;
            }
            else {
                //否則合併x,y
                Union(x, y, d - 1);
            }
        }
    }
    printf("%d\n", ans);
    return 0;
}

相關文章