問題提出
一個集合中有N個點,N個點中有許多的相連的,任意給出兩個點,如何才能快速地知道這兩個點是否是相連(間接相連也算)的 ? 如果不相連,如何才能快速高效地實現連接?這樣的問題在計算機網絡的連接和電子電路中都有出現。
設計API
為了說明問題,我們設計了一份API來封裝所需要的基本操作:初始化整個集合,連接兩個點,判斷包含兩個點的一條連接鏈,判斷兩個點是否相連,返回鏈的數量。
//public class UF
// UF(int N) 初始化整個集合
// void union(int p, int q) 在p,q之間添加一條連接
// int find(int p) p所在的鏈的標識符
// boolean connected(int p, int q) 如果p和q存在于同一條鏈之中則返回true
// int count() 統計集合中鏈的數量
//
當初始化所有的點組成的集合的時候,用1~N-1來表示所有的點。如果兩個點在不同的鏈當中,可以用union來將兩條鏈連接,find方法用來返回某個點所在的連通鏈的標識符,connected用來判斷兩個點是否相連(即是否在一條鏈上),count方法用來返回整個集合當中鏈的條數。
在我們的實現當中,我們將使用一個大小為N的數組來表示N個點,數組的index即是每一個點的名稱,每個index下存儲的東西就是該點所屬鏈的標識符。
最初版本的實現-quick find
import java.util.Scanner;
public class UF {
private int[] id; //分量id,以觸點作為索引
private int count; //分量數量
public UF(int N){
count = N;
id = new int[N];
for (int i = 0;i < N;i++){
id[i] = i;
}
}
public int count(){
return count;
}
public boolean connected(int p, int q){
return find(p) == find(q);
}
public int find(int p){
return id[p];
}
public void union(int p , int q){
//將p和q所屬的分量歸并(連接兩條鏈)
int pID = find(p);
int qID = find(q);
//如果p和q已經在相同的分量當中則不需要采取任何行動
if (pID==qID) return;
for (int i = 0; i < id.length; i++){
if (id[i] == pID) id[i] = qID;
}
count--;
}
public static void main(String[] args){
Scanner sc = new Scanner(System.in);
System.out.println("Input the length of array!");
int N = sc.nextInt();
UF uf = new UF(N);
while (sc.hasNext()){
int p = sc.nextInt();
int q = sc.nextInt();
if (uf.connected(p,q)) continue;
uf.union(p,q);
System.out.println(p+""+q);
}
System.out.println(uf.count + "components");
}
}
這一實現方案在檢查兩個點是否連接是效率非常的高,然而在連接兩條鏈是效率卻十分低下。例如p所在的分量的標識符為1,q所在的分量的標識符為2,當想把p和q所在的分量歸并的時候,就需要遍歷整個數組,將所有標識符為1的索引里的標識符改為2。這樣就導致當我們將N個點全部連通的時候,調用了union方法N-1次,union方法里面又一個循環,這就相當于是兩個循環,所以這個算法的運行時間對于得到少量連通分量的應用來說是平方級別的。當使用100萬個點和200萬條連接的時候,這個算法運行了幾十分鐘。
第一次改進后的實現-quick union
import java.util.Scanner;
public class UF {
private int[] id; //分量id,以觸電作為索引
private int count; //分量數量
public UF(int N){
count = N;
id = new int[N];
for (int i = 0;i < N;i++){
id[i] = i;
}
}
public int count(){
return count;
}
public boolean connected(int p, int q){
return find(p) == find(q);
}
public int find(int p){
while (p != id[p]) p = id[p];
return p;
}
public void union(int p , int q){
int pRoot = find(p);
int qRoot = find(q);
if (pRoot == qRoot) return;
id[pRoot] = qRoot;
count--;
}
public static void main(String[] args){
Scanner sc = new Scanner(System.in);
System.out.println("Input the length of array!");
int N = sc.nextInt();
UF uf = new UF(N);
while (sc.hasNext()){
int p = sc.nextInt();
int q = sc.nextInt();
if (uf.connected(p,q)) continue;
uf.union(p,q);
System.out.println(p+""+q);
}
System.out.println(uf.count + "components");
}
}
這一次的改進主要是為了加快union算法的速度。這一次我們不是使用一條鏈上的點用同一個標識符的方式,而是使用類似于鏈表的方式,比如點1和點2相連,就在索引2里面放置值1,而獨立的一個點則索引和里面放置的值都是索引值。在這個算法里面,find方法顯然比上面要慢一些,確定任意兩個點是否連通的方式是分別由兩個點的索引里面所存的下一個節點的索引一路上溯到它們所在的鏈的根節點,如果是同一個根節點,那么就是連通的,否則就不連通,如果想要他們連通,就使用改良后的union方法將兩個根節點連接起來--由其中一個根節點的索引中存儲另一個根節點的索引值。然而這種改良在很多情況下并沒有比未改良的方法快(簡單分析和實踐都已經證明),而且在一種特殊的輸入下:01,12,23,34,45,……這個鏈會變成一條超長的鏈表,而不是樹的形狀,這樣就和未改進沒有區別,依然類似于遍歷數組。
第二次改進后的實現-加權quick-union算法
幸運的是,我們只需要稍微改變一下上面的算法,就能保證上面的問題不再出現。
與其胡亂的將一棵樹連接到另一棵樹,我們更應該總是將一棵較小的樹添加到一棵較大的樹上,這樣我們就能夠限制樹的高度,從而保證查找的時間復雜度為lgN。
import java.util.Scanner;
public class UF {
private int[] id; //分量id,以觸點作為索引
private int[] size; //由觸電索引的各個節點所對應的分量的大小
private int count; //分量數量
public UF(int N){
count = N;
id = new int[N];
for (int i = 0;i < N;i++){
id[i] = i;
}
size = new int[N];
for (int i = 0;i < N;i++){
size[i] = 1;
}
}
public int count(){
return count;
}
public boolean connected(int p, int q){
return find(p) == find(q);
}
public int find(int p){
while (p != id[p]) p = id[p];
return p;
}
public void union(int p , int q){
int pRoot = find(p);
int qRoot = find(q);
if (pRoot == qRoot) return;
if (size[pRoot] < size[qRoot]){
id[pRoot] = id[qRoot];
size[qRoot] += size[pRoot];
}else {
id[qRoot] = id[pRoot];
size[pRoot] += size[qRoot];
}
count--;
}
public static void main(String[] args){
Scanner sc = new Scanner(System.in);
System.out.println("Input the length of array!");
int N = sc.nextInt();
UF uf = new UF(N);
while (sc.hasNext()){
int p = sc.nextInt();
int q = sc.nextInt();
if (uf.connected(p,q)) continue;
uf.union(p,q);
System.out.println(p+""+q);
}
System.out.println(uf.count + "components");
}
}
這樣就能保證在將N個節點完全連接起來的時候的時間復雜度為NlgN,這樣的時間復雜度已經復合解決很多大型問題的要求。
最優算法-路徑壓縮的加權quick-union算法
在理想情況下,我們都希望所有的節點都直接連接在它的根節點上,這樣就只需一次操作就能找到其根節點,能表現出常數時間,這種方法被稱為路徑壓縮方法。
public int find(int p){
int temp = p;
while (p != id[p]) p = id[p];
id[temp] = id[p];
return p;
}
將find方法如此修改就可以實現路徑壓縮。
此時quick-union算法的速度已經比一開始的時候快了很多很多。
資源以及參考
本筆記是學習普林斯頓大學算法課程以及閱讀其教材《算法》第四版所作
用于跑著玩的擁有100萬個點和200萬條鏈接的文件(直接下載鏈接文件即可)
http://algs4.cs.princeton.edu/15uf/largeUF.txt
命令行運行:
cd到.java文件所在文件夾,執行一下命令,如果.java文件中含有包名,注意將其刪除
% javac UF.java
% java UF < largeUF.txt