1. 程式人生 > >並查集入門

並查集入門

一、並查集可以做什麼

對於一個新知識,我比較看重它可以用來做什麼,可以處理什麼問題。否則學了一大堆亂七八糟的,真正到用的時候反而不知所措,這就不好了。那麼,並查集可以拿來做什麼呢?讓我們先通過一道題目來體會它的妙用吧!

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:將兩個子集合併成同一個集合。

說白了,並查集正如其名,合併和查詢。合併容易理解,從上面的題目中可以看到,散點之間用線段連起來,這就是合併;查詢幹嘛用的呢?上面遺留了一個問題:如何確定剩下的連通集的數目?沒錯,這裡就要用到查詢。它可以幫助我們確定一個點屬於哪個集合。

三、並查集詳解

下面,我們具體來看一下並查集是怎麼運用到解決問題中去的。

  1. 剛開始有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
}
  1. 接下來,有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也可以,只要能合併就行)
 }
  1. 上面也看到了,合併的操作也是要用到查詢的。那麼查詢究竟是怎樣實現的呢?我們再次回到查詢操作的功能,即:可以查詢點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;
		}
}