1. 程式人生 > 其它 >解決連通性問題的四種演算法

解決連通性問題的四種演算法

連通性問題 問題概述

先來看一張圖:

在這個彼此連線和斷開的點網路中,我們可以找到一條 p 點到 q 點的路徑。在計算機網路中判斷兩臺主機是否連通、在社交網路中判斷兩個使用者是否存在間接社交關係等,都可以抽象成連通性問題。

問題抽象

可將網路中的點(主機、人)抽象為物件,p-q 表示 p連線到q,連通關係可傳遞: p-q & q-r => p-r;為簡述問題,將兩個物件標記為一個整數對,則給定整數對序列就能描述出點網路。

如下圖結點數 N = 5 的網路(使用 0 ~ N-1表示物件),可用整數對序列 0-1 1-3 2-4 來描述連通關係, 其中 0 和 3 也是連通的,存在兩個連通分量:{0, 1, 3} 和 {2, 4}

問題:給定描述連通關係的整數對序列,任給其中兩個整數 p 和 q,判斷其是否能連通?

問題示例

輸入 	不連通	連通 
3-4 	3-4
4-9 	4-9
8-0 	8-0
2-3 	2-3
5-6 	5-6
2-9 		2-3-4-9	
5-9 	5-9
7-3 	7-3
4-8 	4-8
5-6 		5-6
0-2 		0-8-4-3-2
6-1 	6-1

對應的連通圖如下,黑線表示首次連線兩個結點,綠線表示兩結點已存在連通關係:

演算法一:快速查詢演算法

使用陣列 id[i] 儲存結點的值, i 為結點序號,即初始狀態序號和陣列值相同 :

當輸入前兩個連通關係後, id[i] 變化如下:

可以看出, id[i] 的值是完成連通後,i 連線到的終點結點。若 p 和 q 連通,則 id[p] 和 id[q] 值應相等。

如完成 4-9 後, id[3] 和 id[4] 的值均為終點結點 9。此時判斷 3 和 9 是否連通,直接判斷 id[3] 和 id[9] 的值是否相等,相等則連通,不等則不存在連通關係。顯然 id[3] == id[9] == 9,即存在連通關係。

演算法實現

/** file: 1.1-quick_find.go */
package main

import ...

const N = 10
var id [N]int

func main() {
	reader := bufio.NewReader(os.Stdin)

	// 初始化 id 陣列,元素值與結點序號相等
	for i := 0; i < N; i++ {
		id[i] = i
	}

	// 讀取命令列輸入
	for {
		data, _, _ := reader.ReadLine()
		str := string(data)
		if str == "n" {
			continue
		}
		if str == "#" {
			break
		}

		values := strings.Split(str, " ")
		p, _ := strconv.Atoi(values[0])
		q, _ := strconv.Atoi(values[1])

		if Connected(p, q) {
			fmt.Printf("Already Connected nodes: %d-%dn", p, q)
			continue
		}
		Union(p, q)
	}
}

// 判斷整數 p 和 q 的結點是否連通
func Connected(p, q int) bool {
	return id[p] == id[q]
}

// 連通 p-q 結點
func Union(p, q int) {
	pid := id[p]
	qid := id[q]
	// 遍歷 id 陣列,將所有值為 id[p] 的結點全部替換為 id[q]
	for i := 0; i < N; i++ {
		if id[i] == pid {
			id[i] = qid
		}
	}
	fmt.Printf("Unconnected nodes: %d-%dn", p, q)
}
執行效果:能判斷 2-9 已存在連通關係

複雜度

快速查詢演算法在判斷 p 和 q 是否連通時,只需判斷 id[p] 和 id[q] 是否相等。但 p 和 q 不連通時會進行合併,每次合併都需要遍歷整個陣列。特性:查詢快、合併慢

演算法二:快速合併演算法

概述

快速查詢演算法每次合併都會全遍歷陣列導致低效。我們想能不能不要每次都遍歷 id[] ,優化為每次只遍歷陣列的部分值,複雜度都會降低。

這時應想到樹結構,在連通關係的傳遞性中,p->r & q->r => p->q,可將 r 視為根,p 和 q 視為子結點,因為 p 和 q 有相同的根 r,所以 p 和 q 是連通的。這裡的樹是連通關係的抽象。

資料結構

使用陣列作為樹的實現:

結點陣列 id[N],id[i] 存放 i 的父結點 i 的根結點是 id[id[...id[i]...]],不斷向上找父結點的父結點...直到根結點(父結點是自身) 使用樹的優勢

將整數對序列的表示從陣列改為樹,每個結點儲存它的父結點位置,這種樹有 2 點好處:

判斷 p 和 q 是否連通:是否有相同的根結點 合併 p 到 q:將 p 的根結點改為 q 的根結點(無需全遍歷,快速合併) 例子:

對於上邊的整數對序列,查詢、合併過程如下,橙色是合併動作、灰色是已連通狀態、綠色是儲存樹的陣列。

注意紅色的 2-3,不是直接把 2 作為 3 的子結點,而是找到 3 的根結點 9,合併 2-3 與 3-4-9 ,生成 2-9

演算法實現:

/** file: 1.2-quick_union.go */

// p 和 q 有相同的根結點,則是連通的
func Connected(p, q int) bool {
	return getRoot(p) == getRoot(q)
}

// 連通 p-q 結點
func Union(p, q int) {
	pRoot := getRoot(p)
	qRoot := getRoot(q)
	id[pRoot] = qRoot		// q 樹的根此時有了父結點(p 樹的根),完成合並
	fmt.Printf("Unconnected nodes: %d-%dn", p, q)
}


// 獲取結點 i 的根結點
func getRoot(i int) int {
	// 沒到根結點就繼續向上尋找
	for i != id[i] {
		i = id[i]
	}
	return i
}

演算法三:帶權快速合併演算法

概述

快速合併演算法有一個缺陷:資料量很大時,任意合併子樹,會導致樹越來越高,在查詢根結點時要遍歷陣列大部分的值,依舊會很慢。下圖中判斷 p、q 是否連通,就需要查詢 13 個結點:

如果樹合併後的依舊比較矮,各子樹之間平衡,則查詢根結點會少遍歷很多結點,下圖中再判斷 p、q 是否連通,只需查詢 7 個結點:

平衡樹的構建

構建平衡的樹需要在合併時,將小樹合併到大樹上,保證合併後的樹增高緩慢或者就不增高,從而使大部分的合併需要遍歷的結點大大減少。區分小樹、大樹使用的是樹的權值:子樹含有結點的個數。

資料結構

樹結點的儲存依舊使用 id[i] ,但需要一個額外的陣列 size[i],記錄結點 i 的子結點數。

演算法實現

/**
file: 1.3-weighted_version.go
在快速合併演算法的基礎上,只需要在合併操作中,將小樹合併到大樹上即可
*/

var id [N]int
var size [N]int

func main() {
 	// 初始化 id 陣列,元素值與結點序號相等
	for i := 0; i < N; i++ {
		id[i] = i
		size[i] = i
	} 
  	...
}  

...

// 連通 p-q 結點
func Union(p, q int) {
	pRoot := getRoot(p)
	qRoot := getRoot(q)

	// p 樹是大樹
	if size[pRoot] < size[qRoot] {
		id[pRoot] = qRoot
		size[qRoot] += size[pRoot]
	} else {
		id[qRoot] = id[pRoot]
		size[pRoot] += size[qRoot]
	}

	id[pRoot] = qRoot // q 樹的根此時有了父結點(p 樹的根),完成合並
	fmt.Printf("Unconnected nodes: %d-%dn", p, q)
}

演算法四:路徑壓縮的加權快速合併演算法

概述

加權快速合併演算法在大部分整數對都是直接連線的情況下,生成的樹依舊會比較高,比如序列:

10-8 8-6 11-9 12-9 9-6 6-3 7-3 3-1 4-1 5-1 1-0 2-0

生成的樹如下:

此時判斷 9-2 的連通關係,需要分別找到 9 和 2 的根結點。在尋找 9 的根結點時經過 6、3、1樹,因為6、3、1樹的子節點和 9 一樣,根結點都是 0,所以直接把6、3、1樹變成 0 的子樹。如下:

優化

每次計算某個節點的根結點時,將沿路檢查的結點也指向根結點。儘可能的展平樹,在檢查連通狀態時將大大減少遍歷的結點數目。

演算法實現

/**
file: 1.4-path_compression_by_halving.go
改動的程式碼很少,但很精妙
*/

// 獲取結點 i 的根結點
func getRoot(i int) int {
	// 沒到根結點就繼續向上尋找
	for i != id[i] {
		id[i] = id[id[i]]		// 將結點、結點的父結點不斷往上挪動,直到都連線上了根結點
		i = id[i]
	}
	return i
}

複雜度

演算法

初始化的複雜度

合併複雜度

查詢複雜度

快速查詢

N

N(全遍歷)

1(陣列取值對比)

快速合併

N

T(遍歷樹)

T(遍歷樹)

帶權快速合併

N

lg N

lg N

路徑壓縮的帶權快速合併

N

接近1(樹的高度幾乎為2)

接近1

總結

上邊介紹了 4 種解決連通性問題的演算法,從低效完成基本功能的快速查詢,到不斷優化降低複雜度接近1 的路徑壓縮帶權快速合併。可以學到演算法解決程式問題的大致步驟:先完成基本功能,再針對低效操作來優化降低複雜度。

原文:https://wuyin.io/2018/01/27/c...