這是一個建立於 的文章,其中的資訊可能已經有所發展或是發生改變。
最近做 B 站彈幕分析 的項目,學習 Jieba 中文分詞的動態規划算法,發現自己的演算法知識待系統的學習,遂讀 Sedgewick 的《演算法 C 實現第三版》,這一系列演算法的代碼放在 Github,文章會同步到 SF,隨意轉載。
連通性問題
問題概述
先來看一張圖:
在這個彼此串連和斷開的點網路中,我們可以找到一條 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-44-9 4-98-0 8-02-3 2-35-6 5-62-9 2-3-4-9 5-9 5-97-3 7-34-8 4-85-6 5-60-2 0-8-4-3-26-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 mainimport ...const N = 10var id [N]intfunc 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-%d\n", 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-%d\n", 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-%d\n", 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]intvar size [N]intfunc 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-%d\n", 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 是結點集合的大小,T 是樹的高度。
演算法 |
初始化的複雜度 |
合并複雜度 |
尋找複雜度 |
快速尋找 |
N |
N(全遍曆) |
1(數組取值對比) |
快速合并 |
N |
T(遍曆樹) |
T(遍曆樹) |
帶權快速合并 |
N |
lg N |
lg N |
路徑壓縮的帶權快速合并 |
N |
接近1(樹的高度幾乎為2) |
接近1 |
總結
上邊介紹了 4 種解決連通性問題的演算法,從低效完成準系統的快速尋找,到不斷最佳化降低複雜度接近1 的路徑壓縮帶權快速合并。可以學到演算法解決程式問題的大致步驟:先完成準系統,再針對低效操作來最佳化降低複雜度。
原文:https://wuyin.io/2018/01/27/c...