並查集(Union-Find Algorithm),看這一篇就夠了
動態連線(Dynamic connectivity)的問題
所謂的動態連線問題是指在一組可能相互連線也可能相互沒有連線的物件中,判斷給定的兩個物件是否聯通的一類問題。這類問題可以有如下抽象:
- 有一組構成不相交集合的物件
- union: 聯通兩個物件
- find: 返回兩個物件之間是否存在一條聯通的通路
ˇ
在使用union-find處理動態連線的問題時,我們一般將這一組物件抽象為一個數組。
對於這組物件,其中相互連線的一些物件構成的子集稱為聯通集。
演算法目的:能夠在如下條件下高效解決動態連線的問題
Union
命令和Find
命令可能交替被呼叫- 操作的總數
M
可能很大 - 集合中的物件數目
N
可能很大
Quick find
資料結構:
- 輸入陣列
id[]
的長度為N
。且每一個物件最初的id
都為其本身。 - 當且僅當
p
和q
具有相同的id
時p
和q
才是聯通的。 id[]
陣列中儲存對應物件所屬的聯通集的root的id。
演算法:
Union
:欲將p
和q
相連,相當於合併包含p
的聯通集和包含q
的聯通集,也就是將所有id
與id[p]
相同的物件的id
改為id[q]
。Find
:檢查p
和q
的id
是否相同即可。
示例:
對於下表所示的物件集合,如果我們呼叫union(1,3)
,則需要將所有id
為2
的物件的id
改為4
。經過這個操作之後,原先的兩個聯通集[1,2]
[3,4]
如今成為了一個聯通集。
| i | 0 | 1 | 2 | 3 | 4 |
| id[i] | 0 | 2 | 2 | 4 | 4 |
==>
| i | 0 | 1 | 2 | 3 | 4 |
| id[i] | 0 | 4 | 4 | 4 | 4 |
Quick find的Java實現
public class QuickFind {
int[] id;
public QuickFind(int n) {
id = new int[n];
for (int i = 0; i < n; i++) {
id[i] = i;
}
}
public void union(int p, int q) {
int pid = id[p];
int qid = id[q];
for (int i = 0; i < this.id.length; i++) {
if (id[i] == pid) {
id[i] = qid;
}
}
}
public boolean find(int p, int q) {
return id[p] == id[q];
}
}
時間複雜度分析
find()
操作的時間複雜度為O(1)
。union()
操作的時間複雜度為O(N)
。
Quick union
顯而易見,Quick find演算法太慢了。如果我們想要重複呼叫union()
N次,時間複雜度將為O(N^2)
。那麼我們如何優化其時間複雜度呢?
我們可以採用稱為lazy approach的方法來進行優化。所謂的lazy approach,也就是我們在設計演算法的時候,對於一個步驟儘量減少其工作量,直到我們不得不進行這些工作的時候才進行。對於優化Quick find演算法而言,就是我們儘量減少union()
中的工作量,知道我們在呼叫find()
的時候再去補上之前偷懶沒有做的工作。那麼我們如何減少union()
中的工作量呢?
答案是:直到有必要前,我們並不改變一個聯通集中的每一個元素的id
。
在Quick find演算法中,我們每一次union()
操作都會將一個聯通集中的每一個元素的id
改為聯通集中root元素的id
。現在我們將其改變為僅僅將新元素所屬的聯通集的root的id
改為另一個元素所屬的聯通集的root的id
。直到我們需要判斷兩個元素是否連通的時候,也就是呼叫find()
的時候,我們就尋找兩個元素所屬的聯通集的root id是否相同。
資料結構:
- 輸入陣列
id[]
的長度為N
。且每一個物件最初的id
都為其本身。 - 當且僅當
p
和q
具有相同的root id
時p
和q
才是聯通的。 id[]
陣列中儲存相應物件的parent的id。i
的root為id[id[id[...id[i]...]]]
。
演算法:
Union
:欲將p
和q
相連,也就是將q
所屬的聯通集融合為p
所屬的聯通集的root的子聯通集,即將q
所屬的聯通集的root的id改為p
所屬的聯通集的root的id。Find
:檢查p
和q
的root id
是否相同。
示例:
對於下表所示的物件集合,如果我們呼叫union(1,3)
,則需要將3
所述的聯通集的root的id
改為1
所屬的聯通集的root的id
,也就是將id[4]
改為2
。
| i | 0 | 1 | 2 | 3 | 4 |
| id[i] | 0 | 2 | 2 | 4 | 4 |
==>
| i | 0 | 1 | 2 | 3 | 4 |
| id[i] | 0 | 2 | 2 | 4 | 2 |
Quick union的Java實現
public class QuickUnion{
int[] id;
public QuickUnion(int n) {
this.id = new int[n];
for (int i = 0; i < n; i++) {
id[i] = i;
}
}
public void union(int p, int q) {
int rootP = getRoot(p);
int rootQ = getRoot(q);
id[rootQ] = rootP;
}
public boolean find(int p, int q) {
return getRoot(p) == getRoot(q);
}
private int getRoot(int i) {
while (i != id[i]) {
i = id[i];
}
return i;
}
}
時間複雜度分析
find()
操作的時間複雜度最壞情況下為O(N)
。union()
操作的時間複雜度最壞情況下為O(1)
。
Quick union的表現將隨著我們不斷呼叫union()
構建聯通集而變差。因為代表這個聯通集的樹越來越高,呼叫getRoot()
的開銷也就越來越大。
Weighted Quick Union with Path Compression
通過以上的分析,我們得到了一個稍快的演算法Quick union,但其時間複雜度會隨著聯通集所對應的樹越來越高而變差。我們是否可以進一步優化這個演算法呢?
答案是可以的。既然其表現隨著樹的高度增長而變差,那麼我們就需要找出一些方法來使聯通集所構造的樹更加扁平。通過以下兩種方法,我們可以大大減少樹的高度。
Weighted Quick union
以Quick union為基礎,我們額外利用一個sz[]
儲存每一個聯通集中物件的數量。在呼叫union()
的時候,我們總是把物件數目較少的聯通集連線到物件數目較多的聯通集中。通過這種方式,我們可以在一定程度上緩解樹的高度太大的問題,從而改善Quick union的時間複雜度。
演算法
Union
:在Quick union的基礎上,將較小的聯通集併入較大的聯通集中。並且在合併之後更新sz[]
陣列中對應的聯通集的大小。Find
:與Quick union相同。
時間複雜度分析
find()
操作的時間複雜度最壞情況下為O(lgN)
。
原因在於我們每次都將包含物件較少的聯通集連線到包含物件較大的聯通集上,因此產生的聯通集在最壞情況下的高度為O(lgN)
。union()
操作的時間複雜度最壞情況下為O(lgN)
。
原因與find()
相同。
Path compression
以Quick union為基礎,在尋找物件i
所對應的聯通集的root的過程之後,將中途所檢查過的每一個物件對應的id
都改為root(i)
。如下面的例子所示:
在實際程式碼實現的時候,為簡單起見,我們並不將所有檢查過的物件的id
都改為root(i)
,而是將每一個元素的id
改為其parent
的id
。這樣雖然無法完全將樹扁平化,但可以達到近似的優化效果。
演算法
Union
:在Quick union的基礎上,每次在尋找某一個物件所對應的聯通集的root的時候,將沿途遇到的每一個物件的id
改為id[id[i]]
。或者記錄下root的id
,用另一個迴圈來將沿途每一個物件的id
改為root的id
。Find
:與Quick union相同。
時間複雜度分析
union()
:最壞情況下為O(lgN)
。find()
:最壞情況下為O(lgN)
。
Weighted Quick Union with Path Compression時間複雜度分析
理論上,從一個完全不相連通的N
個物件的集合開始,任意順序的M
次union()
呼叫所需的時間為O(N+Mlg*N)
。
其中lg*N
稱為迭代對數(iterated logarithm)。實數的迭代對數是指須對實數連續進行幾次對數運算後,其結果才會小於等於1。這個函式增加非常緩慢,可以視為近似常數(例如2^65535
的迭代對數為5
)。
因此我們可以認為Weighed Quick Union with Path Compression是一個線性時間的演算法。
Weighted Quick Union with Path Compression的Java實現
public class WeighedQuickUnionWithPathCompression{
int[] id;
int[] sz;
public WeighedQuickUnionWithPathCompression(int n) {
this.id = new int[n];
this.sz = new int[n];
for (int i = 0; i < n; i++) {
id[i] = i;
sz[i] = 1;
}
}
public void union(int p, int q) {
int rootP = getRoot(p);
int rootQ = getRoot(q);
// weighted quick union
if (sz[rootP] >= sz[rootQ]) {
id[rootQ] = rootP;
sz[rootP] += sz[rootQ];
} else {
id[rootP] = rootQ;
sz[rootQ] += sz[rootP];
}
}
public boolean find(int p, int q) {
return getRoot(p) == getRoot(q);
}
private int getRoot(int i) {
while (i != id[i]) {
// path compression
id[i] = id[id[i]];
i = id[i];
}
return i;
}
}