前言
如果能夠科學上網,英文水平良好,建議登入cousera進行學習。
平臺上有完整的作業提交平臺,對提交的作業有詳細的性能診斷和反饋;有課程各種資源;有課程討論。
在課程提問區提問還會收到導師的回答。
鏈接:
Algorithms, Part I
Algorithms, Part II
《算法》第四版:testbook鏈接(英文):
https://algs4.cs.princeton.ed...
主要內容
並查集是一種樹形的數據結構,通過這種數據結構能夠有效處理不相交集合間的合併(union)及查詢(find)問題。比如動態連通性問題。
這種數據結構主要涉及兩個操作:
Find:查詢元素屬於哪一個子集。此操作還可以用來確定兩個元素是否屬於同一子集。
Union:將兩個子集合併成到一個集合中。
1. 動態連通性問題(dynamic connectivity)
動態連通性的應用很廣泛:
比如網絡診斷:網絡中的兩臺計算機是否連通,社交網絡中的兩個人是否存在交集,芯片中的電路元件連通性等等。
場景:對象
數碼照片:像素
網絡:計算機
社交網絡:人
...
在編程中我們會對所有這些不同類型的對象進行簡單的編號(0 -- N-1),這樣方便利用整數作爲數組的索引號,快速地訪問每個對象的相關信息,還可以忽略與並查集問題不相關的很多細節。
1.1 建立問題模型
給定一個有N個性質相同的對象的集合
問題大體上所需要的基本操作:
- 連通(Union):兩個對象間可以連通
- 連通性的查詢(Find/connected):查詢兩個對象之間是否有連通路徑
如下圖,通過Union(x,y)命令給若干對象之間建立連通路徑;通過connected(x,y)命令查看兩個對象是否連通。
我們的任務就是對於給定的對象集合高效地實現這兩個命令。因爲合併命令和連通命令交叉混合,所以我們還有一個任務是我們的解決方案能支持大量對象和大量混合操作的高效處理。
1.2 提取連通性質的抽象性
1.2.1 假設“連接到(is connected to)”是一個具有下邊關係性質的等價關係:那麼它會有如下一些性質:
- 反身:每個對象都能連接到自己 (p is connected to p)
- 對稱:如果p與q連通,那麼q也與p連通
- 傳遞:如果p與q連通,q與r連通,那麼p與r連通
這些性質都是直觀自然的。明確了這些等價關係後,下面給出“連通分量”的定義。
1.2.2 連通分量(connected components)
有了等價關係後,一個對象和鏈接的集合就分裂爲子集,這些子集就爲連通分量。連通分量是互相連接對象的最大集合,並且連通分量之間並不連通。如下圖左邊,有3個連通分量。
我們的算法通過維護連通分量來獲得效率,並使用連通分量來高效地應答接收的請求。
有了連通分量的概念後,並查集(即動態聯通問題)要實現的操作有:
- Find 查找: 查找檢查兩個對象是否在相同的連通分量中
- Union 合併命令:將包含兩個對象的連通分量合併爲一個子集(一個連通分量)。如下圖。
1.3 Union-find 數據類型(API)
從之前的討論我們得到了一個數據類型,在編程的概念中,它是爲了解決問題我們想要實現的方法和規範。在Java的模型中,創建一個類來表示這些方法和規範。
其中包含:
- 構造函數:參數是對象的數量,根據對象的數量建立數據結構
- 兩個操作:a) 實現合併; b) 連接超找,返回一個布爾值(邏輯變量:真/假)
在實現算法的過程中,應該明確算法在以下的情況下也必須高效:
- 對象和操作的數量都可能是巨大的
- 合併和連接的混合操作也會非常頻繁
在處理更深層的問題之前,通過設計一個測試客戶端用於檢查API,確保任何一種實現都執行了我們希望他執行的操作。
如下圖,客戶端從標準輸入(tinyUF)中讀取信息。首先讀取“10”這個整數,表示要處理的對象的數量,並創建一個UF對象。然後只要標準輸入中還有輸入,客戶端將從輸入讀取兩個整數,如果他們沒有連通,將他們連通並輸出。否則忽略這條輸入。
2. 實現動態連通性問題的算法
也可以說就是實現並查集的算法。
這些算法的目的並不是給出兩個對象的連通路徑是什麼,而是回答是否存在這麼一條路徑。雖然給出兩個對象相連的路徑也回答了是否連通的問題,但是於我們的目的而言不是直接契合,效率也不高。使用並查集數據結構是一個不錯的策略。
實現動態連通性問題的算法有:
- quick-find 快速查找 (QF)
- quick-union 快速合併 (QU)
- Weighted quick-union 加權優化 (WQU)
- Quick union with path compression 路徑壓縮優化(QUPC)
兩種優化可以結合一起使用,以下簡稱"WQUPC",即 weighted QU + path compression
2.1 quick-find
快速查找算法也稱爲“貪心算法”。
(簡單來說貪心算法就是在對問題求解時總是作出當前看來是最好的選擇,只得出在某種意義上的局部最優解,不一定能得到整體最優解)
2.1.1 Qinck-find 簡介
Qinck-find數據結構:
簡單的對象索引整數數組 id[](size = n)
(具體來說就是當且僅當兩個對象 p 與 q 是在數組中的id一樣,那麼他們即爲連通)
如圖:0,5,6在一個連通分量;1,2,7在一個連通分量;3,4,8,9連通
由此:
Find:檢查 p 和 q 是否 id 值相等
Union:合併兩個給定對象的的兩個連通分量,即需要將與給定對象之一(p)有相同 id 的所有對象的 id 值都變爲另一個給定對象(q)的 id 值
如圖經過 union(6,1), 通過合併6和1,6所在的連通分量中的所有對象(0,5,6)的id值都變爲1的 id 值(1)
這個算法的主要問題是當進行合併操作是,遇往後,需要改變id值的操作就越多。
2.1.2 Java實現:
a) 構造器:建立一個私有化整數數組,並將對應索引的數組項設爲索引值
b) 連通判斷:兩個數組項的 id 值是否相等
c) 合併操作:取出兩個參數的 id 值,遍歷整個數組,找到同第一個參數id值相同的所以id項,並將他們都賦值成第二個參數的id值。
具體代碼實現:QuickFindUF.java
2.1.3 性能分析
衡量因子:代碼需要訪問數組的次數增長量級
構造函數初始化和合並操作時都需要遍歷整個數組,所以他們必須以常數正比於N次訪問數組(增長量級爲N)
查找的操作很快,只需要進行常數次比較(增長量級爲1)
算法的特點是查找很快,但是合併的代價太大,如果需要在N個對象上進行N次合併操作,訪問數組就需要N的平方次增長量級的操作,這是不合理的。平方量級的時間太慢。對於大型問題不能接受平方時間的算法。
隨着計算機變得更快,平方時間算法實際會變得更慢,簡單來說,假如計算機每秒能進行幾十億次的操作,這些計算機的主內存中有幾十億項,大約一秒鐘的時間我們就可以訪問主內存所有的項,現在希望算法對幾十億的對象進行幾十億的操作,快速查找算法需要訪問數組大約10的18次方次,大概是30多年的計算時間。
2.2 quick-union
快速查找對於處理巨大問題太慢,所以第一個嘗試是使用快速合併算法進行替換。
快速合併的算法設計採用了所謂的“懶策略”,即儘量避免計算直到不得不進行計算。
2.2.1 Quick-union簡介
Quick-union數據結構
大小爲N的整數數組 id[](size = N)
這裏的數組看做是一片森林(即一組樹的集合),數組中的每一項包含它在樹中的父節點。
下圖可以看出3的父節點是4, 4的父結點是9,9的父節點是它自身,也就是9是這顆樹的父節點。從某個對象出發,一路從父節點向上就能找到根節點。
由此:
Find:查找兩個對象(p & q)的根節點是否相等
Union:合併兩個對象的連通分量,即把 p 的根節點的值設爲 q 的根節點的值。
如圖, union(3,5) 即把3的根節點(9)的id值設爲5的根節點(6)的id值。由此,3所在的連通分量與5所在的連通分量進行了合併。
可以看出合併兩個連通分量只需要改數組中的一個值,但查找根節點會需要多一些操作。
2.2.2 Java實現:
a) 構造器與Quick-find相同
b) 私有方法root()通過回溯實現尋找根節點
c) 通過私有方法實現查找操作(是否連通操作)。通過判斷兩個對象的根節點是否相同即得出返回
d) 合併操作就是找到兩個對象的根節點,並將第一個根節點的id值設爲第二個對象根節點的id值
詳細實現:QuickUnionUF.java
2.2.3 性能分析:
衡量因子:代碼需要訪問數組的次數增長量級
快速合併的算法依舊很慢。快速查找的缺點在於樹可能太高,這樣對於查找操作的代價太大,最壞的情況需要N次數組訪問才能找到某個對象的根節點。(合併操作的統計也包含了查找跟的操作)
3 quick-union算法改進
3.1 加權
QF 和 QU 都不能支持巨大的動態連通性問題,一種有效的改進辦法:加權 Weighted quick-union
之前說到了 QU 的缺點就是因爲樹太高會引起查找操作代價巨大,那麼在實現QU算法的時候執行一些操作避免得到很高的樹就是改進的一個思路。
QU算法在合併兩顆樹的時候可能會把大樹放在小樹的下邊,如此樹會變高。
爲了避免合併後導致更高的樹,讓小樹的根節點指向大樹的根節點。由此保證所有的對象不會離根節點太遠。可以通過加權的方式可以實現。
WQU:
- 跟蹤每棵樹中對象的個數
- 將小樹(對象所在的連通分量裏的元素個數較少一方)的根節點設爲大樹的根節點的子節點
Java 實現
數據結構
在QU的基礎上添加一個額外的數組 size[i] 記錄以該對象爲根節點的樹中的對象個數
Find: 同QU一樣,只需要檢查根節點是否相同
Union:通過 size[i] 先檢查兩顆樹的大小,然後將小樹的根節點設爲大樹的根節點,同時更新合併後連通分量中根節點項的size數組項的值
完整實現:WeightedQuickUnionUF.java
性能分析
-
運行時間
Find:與結點在樹中的運行時間成正比
Union:給出根節點後花費常量時間 -
命題
樹中任意結點的深度上限爲lgN(此lg以2爲底數) -
證明
理解這個命題的關鍵在於觀察節點(此爲X節點)的深度到底是在何時會增加
a) 只有當T2的尺寸>=T1的尺寸的時候,T1和T2纔會合併爲一個連通分量,此時T1的深度(depth)纔會增加
b) 合併後T1的大小至少是原來的2倍,由此粗略估計,x的深度每增加1,則x所在的集合的大小變爲原來的2倍
c) 如果整個集合的大小爲N,那麼x所在的集合大小最大尺寸只能爲N,那麼從x=1開始,有1*2^depth=N --> depth = lgN
即樹中任意節點的深度最多爲lgN - 三種算法的性能對比
如果N是100萬,則lgN=20;如果N是10億,lgN=30。加權的處理在代碼上並不需要多少的修改,可是性能上卻取得了很大的提升。
3.2 路徑壓縮
在經過加權處理後,還可以更進一步優化這個算法。此爲添加路徑壓縮 Quick union with path compression 的操作。
在試圖尋找包含給定節點的樹的根節點時,我們需要訪問從該節點到根節點路徑上的每個節點。那麼,
於此同時我們可以將訪問到的所有節點的根節點順便移動設置爲他所在的樹的根節點。
這需要付出常數的額外代價。
如圖:當我們找到P的根節點後,把9,6,3的根節點都設爲0(1的根節點已經是樹的根節點):
變爲
這裏做的改變是:將路徑上的每個節點指向它在路徑上的祖父節點,這種實現並沒有把樹完全展平,但在實際應用中兩種方式都超不多一樣好。
將樹完全展平:find(int p) 方法回溯一次路徑找到根節點,然後再回溯一次將樹展平,參考:QuickUnionPathCompressionUF.java
性能分析
以證明:有N個對象,M個合併與查找操作的任意序列,需要訪問數組最多爲 c( N + M lg* N )次。
lg*N 是使N變爲1所需要的對數的次數,叫迭代對數函數。
在真實世界中可以認爲lg*N是一個小於5的數。
這個證明可以不用深究,兩種優化的代碼實現參考:WeightedQuickUnionPathCompressionUF.java
通過WQUPC的優化,之前的例子,假如要對10億個對象進行10億個操作,QF需要30年,現在只需要大概6秒。
課後鞏固(折日更新)
Interview Questions 面試問題
Programming Assignment: Percolation 編程練習
IDE本人使用IntelliJ IDEA, jdk8
需要使用的jar包:
git地址: