並查集入門
一、並查集可以做什麼
對於一個新知識,我比較看重它可以用來做什麼,可以處理什麼問題。否則學了一大堆亂七八糟的,真正到用的時候反而不知所措,這就不好了。那麼,並查集可以拿來做什麼呢?讓我們先通過一道題目來體會它的妙用吧!
Problem Description
某省調查城鎮交通狀況,得到現有城鎮道路統計表,表中列出了每條道路直接連通的城鎮。省政府“暢通工程”的目標是使全省任何兩個城鎮間都可以實現交通(但不一定有直接的道路相連,只要互相間接通過道路可達即可)。問最少還需要建設多少條道路?
Input
測試輸入包含若干測試用例。每個測試用例的第1行給出兩個正整數,分別是城鎮數目N ( < 1000 )和道路數目M;隨後的M行對應M條道路,每行給出一對正整數,分別是該條道路直接連通的兩個城鎮的編號。為簡單起見,城鎮從1到N編號。
注意:兩個城市之間可以有多條道路相通,也就是說
3 3
1 2
1 2
2 1
這種輸入也是合法的
當N為0時,輸入結束,該用例不被處理。Output
對每個測試用例,在1行裡輸出最少還需要建設的道路數目。
此題來源於HDOJ1232
題目稍微有點長,讓我們來把題目簡化一下吧。與這題目等價的一個問法是:
平面上有N個點,這些點形成了M條線段,問還需要增加多少條線段使得任意兩點間都可以連通?
這是一道具有幾何色彩的題,如果你不會做的話,在草稿紙上寫寫畫畫也能摸索出個規律來。這裡用語言來描述一下,假設我是其中一個點,如果我與另一點A相連,那麼就說我與A是連通的;如果A又與B是連通的,那麼我也就與B連通了,因為我可以通過A進而到達B;同樣的,我、A、B還可以和更多的點連通,我們把這些互相連通的點構成的集合稱為連通集。這裡可以得到一個結論:若連通集的任何一個點與這個集合外的一點X連通,則這個連通集的所有點都與點X連通。這樣問題就轉化為了求最後連通集的數目了,為什麼呢?如果最後剩下2個連通集,那麼在2個連通集內各取一點,增加一條線段就可以了。如果剩下Y個連通集,每個連通集派出一個點,讓它們連通的話,只需再增加(Y-1)條線段就好了。那麼,如何確定最後剩下連通集的數目呢?
事實上,這是一道典型的用並查集
二、什麼是並查集
說了這麼多,是時候隆重介紹一下我們的主角登場啦!並查集在維基百科上是這麼定義的:
在電腦科學中,並查集是一種樹型的資料結構,用於處理一些不交集(Disjoint Sets)的合併及查詢問題。有一個聯合-查詢演算法(union-find algorithm)定義了兩個用於此資料結構的操作:
Find:確定元素屬於哪一個子集。它可以被用來確定兩個元素是否屬於同一子集。
Union:將兩個子集合併成同一個集合。
說白了,並查集正如其名,合併和查詢。合併容易理解,從上面的題目中可以看到,散點之間用線段連起來,這就是合併;查詢幹嘛用的呢?上面遺留了一個問題:如何確定剩下的連通集的數目?沒錯,這裡就要用到查詢。它可以幫助我們確定一個點屬於哪個集合。
三、並查集詳解
下面,我們具體來看一下並查集是怎麼運用到解決問題中去的。
- 剛開始有N個點,給它們編號1~N。對於其中的每個點i(1<i<N),用pre[i]表示i所屬的集合,如pre[i]=1,就表示點i屬於集合1。由於剛開始還沒有線段產生,所有點都是孤立的一個點,所以每個點都構成一個連通集。為以示區別,我們讓每個點i所屬的集合編號就等於它自身的值。
//初始化
for(int i=1;i<=N;i++){
pre[i]=i; //點i屬於集合i
}
- 接下來,有M條線段,每條線段都會合並兩個點。那麼怎麼合併呢?很簡單,只要把這兩個點所屬的連通集變成一樣的就可以了,即若x所屬的集合是pre[x],集合y所屬的集合是pre[y],則只需讓pre[x]=pre[y]或pre[y]=pre[x]就行了。如果2個點已經是屬於同一個連通集了,就不用管他。那麼問題來了,怎麼知道2個點是不是屬於同一個連通集呢?前面說到查詢操作find(x)可以查詢點X所屬的集合,我們暫時先不管它,用著再說,大家知道它的用途就行了。
合併點X和點Y
//合併
void union(int x,int y)
{
int fx=find(x),fy=find(y); //用fx表示點x所屬的集合,fy表示點y所屬的集合
if(fx!=fy) //如果點x和點y屬於不同的集合,則合併
pre[fx ]=fy; //把點x劃到點y所屬的集合中(pre[y]=fx也可以,只要能合併就行)
}
- 上面也看到了,合併的操作也是要用到查詢的。那麼查詢究竟是怎樣實現的呢?我們再次回到查詢操作的功能,即:可以查詢點i所屬的集合。剛開始有N個點,構成N個連通集,隨著線段的加入,一些點被合併,也就是說,連通集內就會包含多個點,我們希望看到,對這個集合內的每個點進行查詢,得到的結果都是同一個集合。這要怎麼做呢?
比如說,現在有3個點,點1屬於集合1,點2屬於集合2,點3屬於集合3。union(1,2)得到點1屬於集合2,點2屬於集合2,點3屬於集合3;union(2,3)得到點1屬於集合2,點2屬於集合3,點3屬於集合3。比如說,現在有3個點,點1屬於集合1,點2屬於集合2,點3屬於集合3。下面我們進行2步操作:①union(1,2)②union(2,3)現在1、2、3點都屬於同一個集合了,對不對?那實際上如何呢?
1 | 2 | 3 | |
---|---|---|---|
初始所屬集合 | 1 | 2 | 3 |
union(1,2)後所屬集合 | 2 | 2 | 3 |
union(2,3)後所屬集合 | 2 | 3 | 3 |
可以看到,2次合併操作後1、2、3點本應在同一集合內,但實際上點1卻與2、3在不同的集合。我們把union(x,y)用x⊆y表示,那麼union(1,2)就是1⊆2,union(2,3)就是2⊆3.由數學知識可知,1⊆3。即是說,經過2次合併後,3個點都在集合3內。因而,我們可以用集合3作為這3個點所屬的集合。
有了上面的小例子,我們大概可以知道,對於一個連通集內的所有點,可以用最外層集合作為它們共同的集合。對於每個點進行查詢操作,返回的也是最外層的集合,這樣就能保證同一連通集內的所有點都屬於同一集合。還有一個要注意的地方是,在這些點中總會存在一個點,它的編號和它所屬的集合編號是一樣的,即編號為最外層集合集合編號的點。
下面看下具體實現:
//查詢
int find(int x){
int r=x; //用r暫存x,並用r來找最外層集合
while ( pre[r] != r ) //如果點r的編號和點r所屬集合的編號不等,說明r不是最外層集合
r=pre[r]; //讓r跳到一個更大的集合中去
//迴圈結束後,r即為最外層集合
//下面把上一步經過的點所屬的集合都變成r(這一步叫做路徑壓縮,是為了縮短查詢的時間)
int i=x,j ;
while(pre[i] != r ) { //如果i所屬的集合不是最外層集合,則把它改為r
j = pre[i]; //用j暫存比i大一級的集合
pre[i]= r ; //把點i所屬的集合改為r
i=j; //把j的值再交還給i,使迴圈得以繼續
}
return r ; //返回最大的集合
}
如此一來,不但找到了點x所在的最外層集合,還把點x到點r之間的點所屬的集合都變成了最外層集合。
四、實戰
最後,讓我們完整的寫出程式碼解決問題吧!
import java.util.Scanner;
public class Main{
static final int MAX=1000; //城鎮最大數目1000
static int []pre = new int[MAX+1];
public static void main(String []args){
Scanner in = new Scanner(System.in);
int N = in.nextInt(); //N為城鎮數目
while(N!=0){
int M = in.nextInt();//M為道路數目
//初始化
for(int i=1;i<=N;i++){
pre[i]=i;
}
//合併,每輸入一組資料,合併一組資料
for(int i=0;i<M;i++){
union(in.nextInt(),in.nextInt());
}
//cnt計數,利用每個連通集只有一個點的編號與所屬連通集編號相同的特性
int cnt=0;
for(int i=1;i<=N;i++){
if(i==pre[i]) cnt++;
}
//cnt個連通集連通需要cnt-1個線段
System.out.println(cnt-1);
N=in.nextInt();
}
in.close();
}
//合併
public static void union(int x,int y){
int fx=find(x),fy=find(y);
if(fx!=fy) pre[x]=fy;
}
//查詢
public static int find(int x){
int r=x;
while(r!=pre[r]){
r=pre[r];
}
int i=x,j;
while(pre[i]!=r){
j=pre[i];
pre[i]=r;
i=j;
}
return r;
}
}