1. 程式人生 > >並查集和Union-Find演算法及其改進

並查集和Union-Find演算法及其改進

並查集

定義

       並查集(Union-Find Sets)也叫不相交集合(Disjoint Sets),是一種用於維護若干個不相交元素所構成的集合的一種樹型資料結構。並查集常用來處理一些不相交元素的合併與查詢問題,在使用中常常用森林來表示。

主要操作

        一個並查集資料結構維護了一個不相交動態集的集合。我們用一個代表(根節點)來標識每個集合,它是這個集合的某個成員。在一些應用中,我們不關心哪個成員被用來作為代表,僅僅關心的是2次查詢動態集合的代表中,如果這些查詢沒有修改動態集合,則這兩次查詢應該得到相同的答案。其他一些應用可能會需要一個預先說明的規則來選擇代表,比如選擇集合中最小的成員。
       對於一個並查集,我們希望支援三個操作:
1.初始化(Initialize)
       用於建立一個新的集合,假設它的唯一成員(因而為代表)是x。因為各個集合是不相交的,故x不會出現在別的某個集合中。
2.合併(Union)
       Union(x,y)將包含x和y的兩個動態集合合併成一個新的集合,即這兩個集合的並集。合併後的新集合的代表可以是原來兩個集合中的任意一個成員。由於我們要求各個集合之間不相交,故要“消除”原有的兩個集合。實際上,我們經常把其中一個集合的元素併入另一個集合中,來代替刪除操作。
3.查詢(Find)
       Find(x)返回一個指標,這個指標指向包含x的(唯一)集合的代表(根節點)。

       因此,並查集的資料結構定義如下(也可以使用單鏈表結構來定義並查集):

#ifndef UNIONFIND_H
#define UNIONFIND_H

#include <iostream>

class UnionFind {
private:
	int *id;//記錄分量
	int cnt;//記錄連通分量的數量
	int n;
public:
	UnionFind (int N);//初始化分量陣列
	~UnionFind ();//解構函式
	int Count (void);//返回連通分量的數量
	bool Connected (int p, int q);//判斷兩分量是否在同一集合中
	int Find (int p);//查詢分量
	void Union (int p, int q);//合併兩個分量
};

#endif

       並查集的許多應用之一是確定無向圖的連通分量,如圖所示(圖G的頂點集用G.V表示,邊集用G.E表示):


       並查集的操作偽碼如下:

//判斷連通性並決定是否合併
Conneted-Components(G)
for each vextex v∈G.V
	Initialize(v)
for each edge(u,v)∈G.E
	if Find(u)≠Find(v)
		Union(u,v)
//判斷兩頂點是否在同一連通分量
Same-Components(u,v)
if Find(u) == Find(v)
	return true
else
	return false


       Conneted-Components開始時,將每個頂點v放在它自己的集合中,然後對每條邊(u,v),它將包含u和v的集合進行合併。處理完所有的邊之後,兩個頂點在相同的連通分量當且僅當與之對應的物件在相同的集合中。因此Conneted-Components以這種方式算出集合,使得過程Same-Components能確定兩個頂點是否在相同的連通分量中。過程如上圖所示。
       在該連通分量演算法的實際實現中,圖和並查集的表示需要相互引用。也就是說,一個表示頂點的物件會包含一個指向與之對應的不相交集合物件的指標;反之亦然。

Union-Find演算法

       本文討論的Union-Find演算法均為不需要給出具體路徑的演算法,而給出具體路徑的演算法需要基於DFS,寫在另外的博文。

動態連通性

       先給一張圖


       假設我們輸入了一組整數對,即上圖中的(4, 3) (3, 8)等等,每對整數代表這兩個points/sites是連通的。那麼隨著資料的不斷輸入,整個圖的連通性也會發生變化,從上圖中可以很清晰的發現這一點。同時,對於已經處於連通狀態的points/sites,直接忽略,比如上圖中的(8, 9)。
       基於前面演算法的描述,對Union-Find演算法的實現如下:

#include <iostream>
#include <fstream>
#include "UnionFind.h"


UnionFind::UnionFind (int N) {
	cnt = N;
	n = N;
	id = new int[N];
	for (int i = 0; i < N; i++)
		id[i] = i;
}

UnionFind::~UnionFind () {
	delete []id;
}

int UnionFind::Count (void) {
	return cnt;
}

bool UnionFind::Connected (int p, int q) {
	return (Find(p) == Find(q));
}

int UnionFind::Find (int p){}

void UnionFind::Union (int p, int q){}

int main (void) {
	int n, p, q;
	std::ifstream in("tinyUF.txt");
	if (!in.good())
		std::cerr << "File open failed!\n";
	else {
		in >> n;
		UnionFind uf(n);
		while (!in.eof()) {
			in >> p >> q;
			if (uf.Connected(p,q))
				continue;
			uf.Union(p,q);
			std::cout << p << " " << q << std::endl;
		}
		std::cout << uf.Count() << " components" << std::endl; 
	}	
	return 0;
}

Quick-Find演算法

       我們根據以頂點為索引的id[]陣列來確定是否存在相同的連通分量中(下面的演算法也是一樣)。Quick-Find演算法保證了當且僅當id[p]和id[q]相等時p和q是連通的。換句話說,在同一個連通分量中的所有頂點在id[]中的值必須全部相同。這意味著Conneted(p,q)只需要判斷id[p]==id[q]。為了在合併時確保這一點,我們需要檢查它們是否存在於同一個連通分量中。如果是則不採取任何行動,否則將頂點p所對應的id[]全部變為頂點q對應的id[]值以達到合併目的。


 

#include <iostream>
#include <fstream>
#include "UnionFind.h"


UnionFind::UnionFind (int N) {
	cnt = N;
	n = N;
	id = new int[N];
	for (int i = 0; i < N; i++)
		id[i] = i;
}

UnionFind::~UnionFind () {
	delete []id;
}

int UnionFind::Count (void) {
	return cnt;
}

bool UnionFind::Connected (int p, int q) {
	return (Find(p) == Find(q));
}

int UnionFind::Find (int p) {
	return id[p];
}

void UnionFind::Union (int p, int q) {
	int pID = Find(p);
	int qID = Find(q);
	if (pID == qID)
		return ;
	for (int i = 0; i < n; i++)
		if (id[i] == pID)
			id[i] = qID;
	cnt--;
}

int main (void) {
	int n, p, q;
	std::ifstream in("tinyUF.txt");
	if (!in.good())
		std::cerr << "File open failed!\n";
	else {
		in >> n;
		UnionFind uf(n);
		while (!in.eof()) {
			in >> p >> q;
			if (uf.Connected(p,q))
				continue;
			uf.Union(p,q);
			std::cout << p << " " << q << std::endl;
		}
		std::cout << uf.Count() << " components" << std::endl; 
	}	
	return 0;
}


       Quick-Find演算法中Find()操作的速度是很快的,因為它只需要訪問id[]陣列一次。但Quick-Find演算法一般無法處理大型資料,因為對於每一對輸入Union()都需要掃描整個id[]。在Quick-Find演算法中,每次Find()呼叫只需要訪問陣列一次,而合併兩個分量的Union()操作訪問陣列的次數在(N+3)~(2N+1)之間。對於需要新增新路徑的情況,就涉及到對組號的修改,因為並不能確定哪些節點的組號需要被修改,因此就必須對整個陣列進行遍歷,找到需要修改的節點,逐一修改。如果要新增的新路徑的數量是M,節點數量是N,那麼最後的時間複雜度就是MN,顯然是一個平方階的複雜度,對於大規模的資料而言,平方階的演算法是存在問題的,這種情況下,每次新增新路徑就是“牽一髮而動全身”。此外,假設我們使用Quick-Find演算法解決動態連通性問題並且最後只得到了一個連通分量,那麼至少需要N-1次呼叫Union(),即至少(N+3)(N+1)~N²次陣列訪問。因此該演算法的執行時間對於最終只能得到少數連通分量的一般應用是平方級別的,因此用Quick-Find演算法處理大型資料不是好的選擇(可以自行下載largeUF.txt來測試)。

Quick-Union演算法

       鑑於Quick-Find演算法的缺點,我們要針對Union()方法進行優化。
       Quick-Find演算法之所以會“牽一髮而動全身”,是因為每個節點所屬的組號都是單獨記錄的,所以一旦涉及到修改、刪除等操作時,就需要逐個尋找,所以如何將節點更好地組織起來是解決問題的關鍵。組織的方式有很多種,最常見且直觀的就是將組號相同的節點連線在一起,故我們很容易想到一類資料結構——樹。所以我們就需要把節點連線成樹。在不改變底層資料結構,即不改變陣列表示的前提下,我們需要賦予id[]陣列更多的意義,讓每一個節點對應的陣列元素值都代表同一個分量中的另一個節點(也可能是它自己),這種聯絡稱為連結。對於Find()方法,id[p]的值就是p節點的父節點的序號,如果p是樹根的話,id[p]的值就是p,因此最後經過若干次查詢,一個節點總是能夠找到它的根節點,即滿足id[root]=root的節點也就是該連通分量的根節點了,然後就可以使用根節點的序號來表示連通分量。所以當且僅當分別由兩個節點開始的這個過程到達了同一個根節點時它們存在於同一個連通分量中。如此一來,Union()方法即可將一對節點分別呼叫Find()方法判斷是否已經在同一連通分量中,若不是則將一個根節點連結到另一個根節點之中。過程如圖所示。

#include <iostream>
#include <fstream>
#include "UnionFind.h"


UnionFind::UnionFind (int N) {
	cnt = N;
	n = N;
	id = new int[N];
	for (int i = 0; i < N; i++)
		id[i] = i;
}

UnionFind::~UnionFind () {
	delete []id;
}

int UnionFind::Count (void) {
	return cnt;
}

bool UnionFind::Connected (int p, int q) {
	return (Find(p) == Find(q));
}

int UnionFind::Find (int p) {
	while (p != id[p])
		p = id[p];
	return p;
}

void UnionFind::Union (int p, int q) {
	int pRoot = Find(p);
	int qRoot = Find(q);
	if (pRoot == qRoot)
		return ;
	id[pRoot] = qRoot;
	cnt--;
}

int main (void)
{
	int n, p, q;
	std::ifstream in("largeUF.txt");
	if (!in.good())
		std::cerr << "File open failed!\n";
	else {
		in >> n;
		UnionFind uf(n);
		while (!in.eof()) {
			in >> p >> q;
			if (uf.Connected(p,q))
				continue;
			uf.Union(p,q);
			std::cout << p << " " << q << std::endl;
		}
		std::cout << uf.Count() << " components" << std::endl; 
	}	
	return 0;
}


       此演算法改進了Quick-Find演算法中Union()方法的低效性,因此這個演算法叫做Quick-Union演算法。我們用largeUF.txt中的資料進行測試時可以明顯感覺到Quick-Union演算法相較於Quick-Find演算法優化了很多。

       Quick-Union演算法比Quick-Find演算法更快,因為它不需要為每對輸入遍歷整個陣列。我們從演算法本身來分析,最好情況下,Find()方法只需要訪問陣列一次就能夠得到一個節點所在的分量的識別符號;而在最壞情況下,則需要2N+1次陣列訪問。因此我們可以估計出在最好情況下Quick-Union演算法處理動態連通性問題的執行時間是線性級別的,而在最壞情況下執行時間是平方級別。故我們不能保證在所有情況下Quick-Union演算法都要比Quick-Find演算法快。然而由於一個問題處於極端情況的概率較小,因此雖然 Quick-Union演算法只是對Quick-Find演算法進行了一個小改進,但也收到了一定成效。
       接下來分析Quick-Union演算法的弊端。我們在Quick-Union演算法中採用了樹的結構,因此也很容易出現樹型結構的極端情況。比如在輸入資料有序的情況下(輸入的整數對有序),構造出來的樹會像下面這樣退化成連結串列:

       好在我們只需要對Quick-Union演算法做一個小改進就可以解決這個問題。即引入權值概念來構造樹。

Weighted Quick-Union演算法

       仔細分析Quick-Union演算法中的Union()方法,我們可以發現Union()方法總是將一邊的樹連結到另一邊去(如我的是左邊連結到右邊),這樣做有一個很嚴重的問題:假設每一次p樹的規模都要比q樹要小,那麼最終會造成前面講到的極端情況,整棵樹變成了頭重腳輕的畸形樹。


       因此,我們需要對Union()方法做些改進,增加一個判斷,判斷兩棵樹的大小,確保每次都將小樹連結到大樹去,這樣就可以有效解決上述問題。為此我們需要對Union-Find的結構以及實現做一些修改:

#ifndef UNIONFIND_H
#define UNIONFIND_H

#include <iostream>

class UnionFind
{
private:
	int *id;//記錄分量
	int *sz;//記錄每個根節點對應的分量大小
	int cnt;//記錄連通分量的數量
	int n;
public:
	UnionFind (int N);//初始化分量陣列
	~UnionFind ();//解構函式
	int Count (void);//返回連通分量的數量
	bool Connected (int p, int q);//判斷兩分量是否在同一集合中
	int Find (int p);//查詢分量
	void Union (int p, int q);//合併兩個分量
};

#endif

       標頭檔案添加了sz陣列來儲存每個根節點對應的分量大小,同時在實現中,對Find()方法進行改進:

#include <iostream>
#include <fstream>
#include "UnionFind.h"


UnionFind::UnionFind (int N) {
	cnt = N;
	n = N;
	id = new int[N];
	for (int i = 0; i < N; i++)
		id[i] = i;
	sz = new int[N];
	for (int j = 0; j < N; j++)
		sz[j] = 1;//初始情況下每個分量的大小都為1
}

UnionFind::~UnionFind () {
	delete []id;
	delete []sz;
}

int UnionFind::Count (void) {
	return cnt;
}

bool UnionFind::Connected (int p, int q) {
	return (Find(p) == Find(q));
}

int UnionFind::Find (int p) {
	while (p != id[p])
		p = id[p];
	return p;
}

void UnionFind::Union (int p, int q) {
	int pRoot = Find(p);
	int qRoot = Find(q);
	if (pRoot == qRoot)
		return ;
	if (sz[pRoot] < sz[qRoot]) {
		id[pRoot] = qRoot;
		sz[qRoot] += sz[pRoot];
	}
	else {
		id[qRoot] = pRoot;
		sz[pRoot] += sz[qRoot];
	}
	cnt--;
}

int main (void) {
	int n, p, q;
	std::ifstream in("largeUF.txt");
	if (!in.good())
		std::cerr << "File open failed!\n";
	else {
		in >> n;
		UnionFind uf(n);
		while (!in.eof()) {
			in >> p >> q;
			if (uf.Connected(p,q))
				continue;
			uf.Union(p,q);
			std::cout << p << " " << q << std::endl;
		}
		std::cout << uf.Count() << " components" << std::endl; 
	}	
	return 0;
}


       Quick-Union演算法和Weighted Quick-Union演算法比較:


 

       從上圖可以直觀看出,通過新增判斷條件讓小樹連結到大樹之後,最後得到的樹的高度大大減小了,這非常有用,因為Quick-Union演算法中不可避免地要常常用到Find()方法,而該方法執行的效率取決於樹的高度。
由圖我們可以得到一些啟示:既然如此,如果我們將所有樹全部都連結到同一個根節點上呢?

壓縮路徑的Weighted Quick-Union演算法

       由前面得到的啟示,我們可以將路徑壓縮,得到一顆幾乎完全扁平化的樹。當然我們不希望像Quick-Find演算法那樣通過修改大量連結來做到這一點,因此我們可以對Weighted Quick-Union演算法進行改進,在Find()方法檢查節點的同時將它們直接連結到根節點,故只需要在Find()方法中新增一個迴圈,將所有分量連結到同一根節點上。

#include <iostream>
#include <fstream>
#include "UnionFind.h"


UnionFind::UnionFind (int N) {
	cnt = N;
	n = N;
	id = new int[N];
	for (int i = 0; i < N; i++)
		id[i] = i;
	sz = new int[N];
	for (int j = 0; j < N; j++)
		sz[j] = 1;//初始情況下每個分量的大小都為1
}

UnionFind::~UnionFind () {
	delete []id;
	delete []sz
}

int UnionFind::Count (void) {
	return cnt;
}

bool UnionFind::Connected (int p, int q) {
	return (Find(p) == Find(q));
}

int UnionFind::Find (int p) {
	int root = p, tmp;
	while (root != id[root])
		root = id[root];
	while (id[p] != root) {
		tmp = p;
		p = id[p];
		id[tmp] = root;
	}
	return root;
}

void UnionFind::Union (int p, int q) {
	int pRoot = Find(p);
	int qRoot = Find(q);
	if (pRoot == qRoot)
		return ;
	if (sz[pRoot] < sz[qRoot]) {
		id[pRoot] = qRoot;
		sz[qRoot] += sz[pRoot];
	}
	else {
		id[qRoot] = pRoot;
		sz[pRoot] += sz[qRoot];
	}
	cnt--;
}

int main (void)
{
	int n, p, q;
	std::ifstream in("largeUF.txt");
	if (!in.good())
		std::cerr << "File open failed!\n";
	else {
		in >> n;
		UnionFind uf(n);
		while (!in.eof()) {
			in >> p >> q;
			if (uf.Connected(p,q))
				continue;
			uf.Union(p,q);
			std::cout << p << " " << q << std::endl;
		}
		std::cout << uf.Count() << " components" << std::endl; 
	}	
	return 0;
}



       這和Quick-Union演算法在理想情況下所得到的樹非常接近,而且壓縮路徑既簡單又有效。但在實際情況下已經不太可能對加權Quick-Union演算法繼續進行任何改進了。下面給出Union-Find演算法的幾種實現的時間複雜度對比:

       最後我們可以得出結論:路徑壓縮的加權Quick—Union演算法是最優的演算法,但並非在所有操作下都能在常數時間內完成。也就是說,使用路徑壓縮的加權Quick-Union演算法的每個操作在最壞情況下(平均分攤後)都不是常數級別的,而且不存在其他演算法能夠保證Union-Find演算法的所有操作在均攤後都是常數級別的。使用路徑壓縮的加權Quick-Union演算法已經是對於這個問題能夠給出的最優解了。

                                           本文部分內容摘自《演算法導論(第3版)》、《演算法(第4版)》,圖片來自於百度圖片