1. 程式人生 > 實用技巧 >並查集入門(模版使用)

並查集入門(模版使用)

本文參考自《演算法筆記》並查集篇

1 並查集的定義

什麼是並查集?並查集可以理解為是一種維護資料集合的結構。名字中並查集分別取自於單詞並(union合併),查(find查詢),集(set集合)。一個並查集需要有一下兩個功能:

  • 合併:合併兩個集合
  • 查詢:判斷兩個元素是否在一個集合裡

並查集主要是用一個數組來實現的,也就是一個father[N],我們定義father[i]表示i的父親結點。父親結點也是存在於這個集合當中。同時,如果father[i] == i的話,我們也說father[i]是i的根結點。一個集合裡面同屬的集合裡面只有一個根結點。我們可以看看下面的示例

father[1] = 1; //結點1的父親結點是1,同時也說1號是這個集合裡面的根結點
father[2] = 1;//結點2的父結點是1
father[3] = 2;  //結點3的父結點是2
father[4] = 2;//結點4的父結點是2

father[5] = 5; //結點5的父親結點是5,同時也說5是這個集合裡面的根結點
father[6] = 6; //結點6的父結點是5

在上面中,根結點同屬1的我們視為一個集合,根結點為5的,我們視為一個集合,這樣我們就得到了兩個集合。

2 並查集的基本操作

首先我們對並查集需要進行初始化的操作,然後才能根據需要進行合併或者查詢的操作。

2.1初始化

一開始每個元素都是一個集合,所以需要對陣列進行father[i] = i的操作

for(int i = 1; i <= N; i++){
  father[i] = i;
}

2.2 查詢

由於我們說過了同一個集合只有一個根結點,因此查詢就是在一個集合中查詢到根結點的操作。實現方式可以是遞迴或者遞推,思路都是一樣,就是反覆尋找父親結點。
先來看遞推的程式碼:

//findFather函式返回元素x所在集合的根結點
int findFather(int x){
  while(x != father[x]){
    x = father[x];
  }
  return x;
}

我們以圖1為例,按照上面的遞推方法,走一下查詢元素4的根結點的流程:

  • x = 4 ,father[x] = 2,因為 4 != father[4],所以繼續查;
  • x = 2,father[x] = 1,因為 2 != father[2],所以繼續查;
  • x = 1,father[x] = 1,因為 1 == father[1],所以找到根結點,返回1。

再來看遞迴的程式碼:

int  findFather(int x){
  if(x == father[x]) return x;  //如果找到根結點,直接返回x
  else return findFather(father[x]); //否則,遞迴判斷x的父親結點是不是根結點
}

2.3 合併

合併就是將兩個集合合併成一個集合,比如說題目中一般給出兩個元素說這兩個是一個集合,那麼就需要將其合併。具體實現上一般是先判斷兩個元素是否屬於同一個集合,只有當兩個元素屬於不同集合才能合併。而合併的過程就是一個元素的根結點的父親結點指向另一個元素的根結點。
具體思路主要是以下兩個步驟

  1. 對於給定的兩個元素,判斷是否屬於一個集合,可以呼叫上面的查詢元素,分別查詢其的根結點,然後判斷根結點是否相同。
  2. 合併兩個集合,在步驟1中我們已經找到兩個元素的根結點,如果兩個根結點不相同的話,我們將元素a的根結點fa,元素b的根結點fb,令father[fa] = fb,當然反過來也是可以的,father[fb] = fa。這樣子就合併了兩個集合。

比如我們合併元素4和6的兩個集合,找到它們的根結點,然後進行合併。如圖1.0操作後變圖1.1

具體實現程式碼如下

void union(int a,int b){
  int faA = findFather(a);  //查詢a的根結點,記為faA
  int faB = findFather(b); //查詢b的根結點,記為faB
  if(faA != faB){
    father[faA] = faB;  //進行合併,反過來也是一樣的
  }
}

合併的過程,只是對兩個不同的集合進行合併,如果兩個元素在相同的集合中,那麼就不會對它們進行操作。這就保證了同一個集合中一定不會產生環,即並查集每一個集合都是一棵樹。

3 路徑壓縮

像上面的查詢函式是沒有經過優化的,在極端的情況下效率極低。比如,題目給出的元素很多形成一條鏈,那麼這個查詢的函式效率就會非常低。如圖1.2所示,總共有10^5個元素形成一條鏈,那麼假設要進行10^5次查詢,且每次查詢都要查詢最後面的結點的根結點,那麼每次都要花費10^5的計算量查詢,這顯然無法接受。

那麼該如何優化呢?
我們以下面這個為例子

father[1] = 1;
father[2] = 1;
father[3] = 2;
father[4] = 2;

由於是查詢根結點,所以我們可在查詢的過程中,等價操作於

father[1] = 1;
father[2] = 1;
father[3] = 1;
father[4] = 1;

變化的過程如圖1.3

具體做法就是

  1. 按照原先的寫法獲得x的根結點r
  2. 重新從x開始走一遍尋找根結點的過程,把路徑上經過的所有結點的父親全部改為根結點r

由此我們可以寫出程式碼:
(使用遞推的方式)

int findFather(int x){
  //由於x在下面的while中會變成根結點,所以先把原來的x存一下
  int a = x;
  while(x != father[x]){ //尋找根結點
    x = father[x];
   }
   //到這裡,x存放的是根結點,下面把路徑上的所有結點的father都改成根結點
  while(a != father[a]){
    int z = a; //因為a要被father[a]覆蓋,所以先儲存a的值,方便修改father[a]
    a = faher[a]; //回溯父親結點
    faher[z] = x; //將原先的結點a的父親改成根結點x
  }
  return x; //返回根結點
}

(使用遞迴的方式)

int findFather(int v){
  if(v == father[v]) return v; //找到根結點
  else{
    int F = findFather(father[v]);   //遞迴尋找fatehr[v] 的根結點的F
    father[v] = F; //將根結點F賦給father[v]
    return F; //返回根結點F 
  }
}

並查集的基本使用方式就這些,具體的變形還需要具體情況來視。下面給出一道題目可以練練手
leetcode990.等式方程的可滿足性