【帶權並查集】理論和應用

小酷miki發表於2018-05-20

這篇文章主要講解帶權並查集的理論、設計和實踐。

理論

並查集本質

這和以往的並查集模型不太一樣。並查集的資料結構使用陣列實現時,那麼資料結構的本質的是一個含有多棵樹的森林。下圖是普通並查集的連線情況。

普通並查集

並查集連線方式

每一顆樹本身代表其所有結點是在同一集合內,連線整個集合是通過陣列的下標代表當前結點的序號,相應陣列的值代表其父結點的序號的方式,這樣的連線不帶有其他關係

帶權並查集

而在帶權並查集是使得連線集合內的元素之間再新增一層關係,即兩個元素之間還帶有權值的意義。
帶權並查集
從中我們不難發現普通並查集本質是不帶權值的圖,而帶權並查集則是帶權的圖。

設計

普通並查集只使用陣列id[MAXNUM]表示每個結點的父結點的情況,如果要設計帶權並查集,顯而易見我們需要另外構造一個陣列來表示【權】,假設是R陣列。

R陣列是代表每個結點與其父結點的權值,也就是每個結點與其父結點的關係。
對於並查集的【並】和【查】操作來說,需要修改的部分:
【並】:並操作的實現有兩種,一種是id[qRoot] = pRoot 直接將後面一個結點設定為前一個結點的父結點;另一種是按照樹的秩大小決定合併,也叫Quick-Union 演算法。
在【先後關係】的情況下,不能按照秩的大小進行合併。後者的演算法效率明顯優於前者。但是在路徑壓縮的前提下,後者的優化情況並沒有這麼明顯。
許多文章都是採用前者的方法去做,但是經過不少實踐(做題目)過後者的方法也是可以的,因為帶權並查集從本質去看沒有前後關係,下面會教大家如何在帶權並查集下使用。
【查】:採用路徑壓縮,在將當前結點的父結點指向結點的父結點的父結點,也就是當前結點的父結點指向結點的爺爺結點,id[p] = id[id[p]],在指向之前將當前結點到父結點的權值和父結點到爺爺結點的權值進行處理,這個得具體根據題目決定。

應用

下面以POJ 1182 , HDU 3038這兩道例題進行分析帶權並查集的具體使用。

POJ 1182

【問題拆分】:假設題目所求的答案為ans。首先x,y不在1到N的範圍內,則ans++。如果x,y都在1到N之間,則根據d的值進行相應的合併,合併時保持著關係(權值)。在合併之前進行檢查,如果不符合前面的合併則ans++。

這裡說到的關係需要利用一個r陣列表示該結點與父節點的關係 ,其中r[i]=0代表同一類,r[i]=1代表被父節點吃,r[i]=2代表吃父節點。

剛才我們說過了帶權並查集和普通並查集的區別就是在合併和查詢(實際上是路徑壓縮)時對關係進行修改(改變r陣列)。
首先我們看路徑壓縮的情況下,r陣列有什麼變化
【路徑壓縮】:路徑壓縮排行的操作是將當前結點的父結點指向結點的爺爺結點,這個時候r陣列會發現什麼變化呢,下圖是路徑壓縮的某一個步驟。
路徑壓縮

從中可以看出y,z的關係沒有改變。其中x指向了z,也就是id[x] = z;x和y的關係不存在,這個可以忽略,因為x和z建立新的關係,會覆蓋x和y的關係。也就是r[x]的意義從表示x和y的關係變成x和z的關係。我們通過列出全部的情況進行判斷x和z的變化情況。

(x, y) (y, z) (x,z) 如何判斷
0 0 0 0+0 = 0
0 1 1 0+1 = 1
0 2 2 0+2 = 2
1 0 1 1+0 = 1
1 1 2 1+1 = 2
1 2 0 (1+2)% 3 = 0
2 0 2 2+0 = 2
2 1 0 (2+1)% 3 = 0
2 2 1 (2+2)% 3 = 1

我們可以看出經過壓縮後x和z的關係,即為r[z] = (r[x] + r[y]) % 3;也就是說在壓縮前經歷了上面的關係變化。所以路徑壓縮的程式碼如下:

// 查詢父節點
int Find(int p) {
    while (id[p] != id[id[p]]) {   //如果q不是其所在子樹的根節點的直接孩子
        // 更新關係(權值)
        r[p] = (r[p] + r[id[p]] ) % 3;
         id[p] = id[id[p]];          //對其父節點到其爺爺節點之間的路徑進行壓縮
      }
    return id[p];
}

【合併】:和路徑壓縮一樣,我們先看看合併後結點的變化。
合併
其中可以看出變化的r[qRoot],那麼r[qRoot]的變化情況如何求得呢?
這裡使用到的是一種【向量思維】去處理,這個非常關鍵。

結論:qRoot->pRoot = qRoot -> q + q -> p + p ->pRoot

也就是我們求qRoot和pRoot之間的關係時可以通過中間的關係的向量和進行求解。
其中qRoot -> q等於 -r[q], p ->pRoot = r[p],而q -> p需要進行探討。
現在可以知道r[qRoot] = r[p] - r[q] + q->p;
【由題目可知】,我們在輸出p q d時由d可知p和q的關係,如果d = 1時,代表p,q是同類,則q->p = 0。如果d = 2時,代表p吃q,則q->p = 1。所以q->p = d -1 .
所以r[qRoot] = r[p] - r[q] + d - 1,為了保持r陣列的值在0-2範圍內,
r[qRoot] = (r[p] - r[q] + d - 1 + 3 )% 3。
所以合併的程式碼如下:

// 合併 p ,q節點
void Union(int p, int q, int d) {
    int pRoot = Find(p);
    int qRoot = Find(q);

    if (pRoot == qRoot) {
        return;
    }
    id[qRoot] = pRoot;
    r[qRoot] = (r[p] - r[q] + 3 + (d - 1)) % 3;
}

POJ 1182完整程式碼

#include <iostream>
#include <cstdio>
#include <map>
using namespace std;

const int MAXNUM = 50000 + 10;
int id[MAXNUM];
int Size[MAXNUM];
int r[MAXNUM];//存與父節點的關係 0 同一類,1被父節點吃,2吃父節點
// 初始化
void make_set(int n){
    for(int i = 1 ; i <= n ; i++){
        id[i] = i;
        Size[i] = 1;
        r[i] = 0;
    }
}

// 查詢父節點
int Find(int p) {
    while (id[p] != id[id[p]]) {   //如果q不是其所在子樹的根節點的直接孩子
        // 更新關係(權值)
        r[p] = (r[p] + r[id[p]] ) % 3;
         id[p] = id[id[p]];          //對其父節點到其爺爺節點之間的路徑進行壓縮
      }
    return id[p];
}

// 合併 p ,q節點
void Union(int p, int q, int d) {
    int pRoot = Find(p);
    int qRoot = Find(q);

    if (pRoot == qRoot) {
        return;
    }
    id[qRoot] = pRoot;
    r[qRoot] = (r[p] - r[q] + 3 + (d - 1)) % 3;
}

int main(){
    //freopen("input.txt","r",stdin);
    //freopen("output.txt","w",stdout);
    int N,K;
    int d,x,y;
    scanf("%d %d", &N,&K);
    make_set(N);
    int ans = 0;

    for(int i = 0;i < K; i++){
        scanf("%d %d %d",&d,&x,&y);

        if(x <= 0|| N < x || y <= 0 || N < y){
            ans++;
            continue;
        }
        if(Find(x) == Find(y)){
            // 不是同類
            if(d == 1 && r[x] != r[y])
                ans++;
            // 如果 x 沒有吃 y
            if(d == 2 && (r[x] + 1) % 3 != r[y])
                ans++;
        }else{  
            Union(x,y,d);
        }
    }
    printf("%d\n",ans);

    return 0;
}

HDU 3038

由上面題目啟發,設定sum陣列為當前結點到父結點的和。例如6 10 100,則設定id[6] = 10, sum[6] = 100。代表6的父結點是10,而6到10的和為100。由於A[6] + A[7] + A[8] + A[9] + A[10] = sum[10] - sum[5],所以輸入後進行處理,這樣在後面的換算中可以直接得出相減得到答案。
同樣得,在帶權並查集中,使用向量思維去求即可。
【路徑壓縮】
由於sum陣列為當前結點到父結點的和,當將當前結點的父結點指向結點的爺爺結點,r的值需要從【當前結點到父結點的和】變成【當前結點到父結點的和 + 父結點到爺爺結點的和】.程式碼如下:

// 查詢父節點
int Find(int p) {
    while (id[p] != id[id[p]]) {   //如果q不是其所在子樹的根節點的直接孩子
        sum[p] = sum[p] + sum[id[p]];
         id[p] = id[id[p]];          //對其父節點到其爺爺節點之間的路徑進行壓縮
      }
    return id[p];
}

【合併】
由於qRoot->pRoot = qRoot -> q + q -> p + p ->pRoot ,
那麼sum[qRoot] = - sum[q] + s + sum[p],其中s是p到q的序列和。

例子和演示圖
9 10 100
7 8 20
7 9 40
qRoot->pRoot = qRoot -> q + q -> p + p ->pRoot =>
8 -> 10 = 8 -> 7 + 7 ->9 + 9->10 =>
8 -> 10 = -20 + 40 + 100 = 120

合併2

所以合併的程式碼如下:

// 合併 p ,q節點
void Union(int p, int q, int s) {
    int pRoot = Find(p);
    int qRoot = Find(q);

    if (pRoot == qRoot) {
        return;
    }
    id[pRoot] = qRoot;
    sum[pRoot] = sum[q] - sum[p] + s;
}

完整程式碼:

#include <iostream>
#include <cstdio>
#include <map>
#include <string.h>
using namespace std;

const int MAXNUM = 200000 + 10;
int id[MAXNUM];
int sum[MAXNUM];
// 初始化
void make_set(int n){
    for(int i = 0 ; i <= n ; i++){
        id[i] = i;
        sum[i] = 0;
    }
}

// 查詢父節點
int Find(int p) {
    while (id[p] != id[id[p]]) {   //如果q不是其所在子樹的根節點的直接孩子
        sum[p] = sum[p] + sum[id[p]];
         id[p] = id[id[p]];          //對其父節點到其爺爺節點之間的路徑進行壓縮
      }
    return id[p];
}

// 合併 p ,q節點
void Union(int p, int q, int s) {
    int pRoot = Find(p);
    int qRoot = Find(q);

    if (pRoot == qRoot) {
        return;
    }
    id[pRoot] = qRoot;
    sum[pRoot] = sum[q] - sum[p] + s;
}

int main(){
    //freopen("input.txt","r",stdin);
    //freopen("output.txt","w",stdout);
    int n , m;
    int p , q, s;
    int ans;
    while(scanf("%d %d", &n , &m)!=EOF){
        make_set(n); ans = 0;
        for(int i = 0 ; i < m ; i++){
            scanf("%d %d %d",&p, &q, &s);
            p--;
            if(Find(p) == Find(q) ){
                if(sum[p] - sum[q] != s )
                    ans++;
            }else{
                Union(p, q ,s);
            }
        }
        printf("%d\n", ans);
    }


    return 0;
}

練習題

POJ 1962
POJ 1703
POJ 2492
POJ 2912
POJ 1733
HDU 3047
hihoCoder 1515
POJ 1984

題外話:合併操作改寫成Quick-Union 演算法

(1)從POJ 1182的題目來看,設當前結點序號是p,其父結點是pRoot,則id[p] = pRoot,那麼r[p]表示p到pRoot的關係和表示pRoot到p的關係其實都可以。從而可以推導當pRoot和qRoot決定誰做父親結點時,任一選擇其中一個則父親結點,則相應陣列r表示的意思對應上即可。例子假設讓pRoot當qRoot的父親結點,那麼id[qRoot] = pRoot,那麼qRoot->pRoot = qRoot -> q + q -> p + p ->pRoot =》 r[qRoot] = r[p] - r[q] + d - 1;
反之id[pRoot] = qRoot,pRoot -> qRoot = pRoot -> p + p -> q + q -> qRoot
=>r[pRoot] = r[q] -r[p] + 1 - d。
程式碼:

// 合併 p ,q節點
void Union(int p, int q, int d) {
    int pRoot = Find(p);
    int qRoot = Find(q);

    if (pRoot == qRoot) {
        return;
    }

    // 按秩進行合併
    if (Size[pRoot] > Size[qRoot]) {
        id[qRoot] = pRoot;
        Size[pRoot] += Size[qRoot];
        r[qRoot] = (r[p] - r[q] + 3 + (d - 1)) % 3;
    } else {
        id[pRoot] = qRoot;
        Size[qRoot] += Size[pRoot];
        r[pRoot] = (r[q] - r[p] + (1 - d)  + 3) % 3;
    }
}

(2)HDU 3038 ,同理。

// 合併 p ,q節點
void Union(int p, int q, int s) {
    int pRoot = Find(p);
    int qRoot = Find(q);

    if (pRoot == qRoot) {
        return;
    }
    // 按秩進行合併
    if (Size[pRoot] > Size[qRoot]) {
        id[qRoot] = pRoot;
        Size[pRoot] += Size[qRoot];
        sum[qRoot] = sum[p] - sum[q] + s;
    } else {
        id[pRoot] = qRoot;
        Size[qRoot] += Size[pRoot];
        sum[pRoot] = sum[q] - sum[p] - s;
    }
}

相關文章