目錄
一、並查集-數組模擬Quick Find-1
並查集解決的的問題——網絡間節點的連接狀態。
使用並查集可以快速的判斷兩個節點之間是否是相連接的。不同於求解兩點之間的路徑,並查集不關心兩點之間是通過怎樣的路徑進行連接,正因爲它沒有求解其他不必要的結果,因此它的效率快。
首先我們定義一個並查集的接口:
public interface UF {
int getSize();
// 判斷兩個元素是否是相連的
boolean isConnected(int p, int q);
// 將兩個元素合併在一起
void unionElements(int p, int q);
}
1、數組模擬Quick Find
quick find 的實現邏輯,存儲值爲1-9的數據,對於數據1-9,他們所屬的集合分成0和1;當需要判斷兩個數據是否是連接狀態時,只需要判斷這兩個數據對應的集合是不是一樣的。
比如0和2是相連接的,因爲他們同屬於0這個集合,而1和2是不相連接的,因爲他們對應的集合分別是1和0;按照這種邏輯,當我們需要判斷兩個節點是否是相連接的時候,只需要比較他們所屬的集合是否相同就可以了,這種操作的時間複雜度爲O(1)。
但是,當isConnected操作爲O(1)複雜度的時候,對應的unionElements操作就爲O(n)的複雜度。因爲,當執行unionElements(0,1)的時候,我們需要把所有對應的元素都遍歷一遍,查找到屬於0集合的元素,並全部改寫成1集合,以實現合併操作。
簡單的代碼實現:
public class UnionFind1 implements UF{
private int[] id;
public UnionFind1(int size){
id = new int[size];
// 初始化的時候,每個元素都屬於不同的集合
for (int i = 0; i < id.length; i++) {
id[i] = i;
}
}
@Override
public int getSize() {
return id.length;
}
@Override
public boolean isConnected(int p, int q) {
return find(p) == find(q);
}
// 合併元素p和元素q所屬的集合
@Override
public void unionElements(int p, int q) {
int pID = find(p);
int qID = find(q);
// 兩個元素如果本來就是屬於相同的集合,就沒有合併的並要
if (pID == qID) {
return;
}
for (int i = 0; i < id.length; i++) {
if (id[i] == pID) {
id[i] = qID;
}
}
}
// 查找元素p所對應的集合編號
private int find(int p){
if(p < 0 || p >= id.length){
throw new IllegalArgumentException("p is out of bound.");
}
return id[p];
}
}
二、改進:Quick Union-2
使用樹的結構來實現並查集,此處並查集的樹結構是倒過來的,由子節點指向父節點,它的是實現邏輯如下:
我們將每個元素都看成是一個節點,如圖2是3的根節點(根節點2自己指向了自己),他們屬於同一個集合;當節點1和節點3需要合併時,我們只要把節點的1的指針指向節點3的根節點2就可以了。同樣的,節點7和3需要合併,此時把7所在樹的根節點5的指針指向節點3的根節點2就可以了,此時,完成了7所在樹的所有節點的合併操作。
對於數據的初始化操作,一開始,所有元素都是一顆獨立的樹
此處,我們仍然使用數組的形式來進行索引存儲
實現相關並操作後的結構圖示示例:
根據樹結構實現的並查集:
public class UnionFind2 implements UF {
private int[] parent;
public UnionFind2(int size){
parent = new int[size];
// 初始化的時候,所有的元素都是一個獨立的樹
for (int i = 0; i < parent.length; i++) {
parent[i] = i;
}
}
@Override
public int getSize() {
return parent.length;
}
@Override
public boolean isConnected(int p, int q) {
return find(p) == find(q);
}
@Override
public void unionElements(int p, int q) {
// 查找根節點所屬集合
int pRoot = find(p);
int qRoot = find(q);
if(pRoot == qRoot){
return;
}
// 把根節點的指針指向q進行合併
parent[pRoot] = qRoot;
}
// 查找元素p所對應的集合編號,時間複雜度爲O(h)
private int find(int p){
if (p < 0 || p >= parent.length) {
throw new IllegalArgumentException("p is out of bound.");
}
// 判斷是不是根節點(根節點自己等於自己)
while(p != parent[p]){
p = parent[p];
}
return p;
}
}
使用樹狀結構的好處是縮短了查詢的時間複雜度,相比使用數組進行實現的時間複雜度O(n),明顯O(log(n))的效率要高很多。對於使用樹來說,算法的遍歷深度只跟樹的層級有關。
三、基於size的優化-Quick Union-3
在我們使用特殊的樹狀結構來實現並查集的時候,我們只是把樹進行了簡單的合併,並沒有考慮到樹的形狀。
// 把根節點的指針指向q進行合併
parent[pRoot] = qRoot;
那麼,當有如下並查集數據,要實現Union(4,9),按照我們之前的邏輯是把8指向9(8—>9)這樣來實現,此時8爲根節點的樹的層級爲3,執向9以後,9爲根節點的樹的層級結構爲4,我們發現並查集的層級增加了(意味着遍歷深度又增加了)。更有極端的情況是整棵樹退化成一個鏈表的結構,使得樹的層級就等於節點的個數,算法的複雜度退化成了O(n)級別。
那麼,有沒有辦法減少樹的層級呢?
我們可以嘗試使用size,對比兩個將要合併的節點,比較他們的所在樹的節點個數,我們讓節點個數少的節點合併到節點個數多的那個節點上,如圖,我們不讓8—>9,而是讓9—>8;這樣操作的結果,就是樹的層級沒有改變,仍然是3層。
接下來,我們修改一下代碼,只要在Quick Union-2的基礎上,新增一個維護變量sz,sz[i]表示以i爲根的集合元素個數。
private int[] sz; // sz[i]表示以i爲根的集合元素個數
public UnionFind3(int size){
parent = new int[size];
sz = new int[size];
// 初始化的時候,所有的元素都是一個獨立的樹
for (int i = 0; i < size; i++) {
parent[i] = i;
// 每一集合一開始都只有一個元素
sz[i] = 1;
}
}
然後更改索引的指向邏輯,把要實現合併的節點少的樹的根節點指向節點多的樹的根節點,其他邏輯不變
// 如果pRoot節點的數量小於qRoot節點的數量,pRoot->qRoot
if(sz[pRoot] < sz[qRoot]){
parent[pRoot] = qRoot;
// 樹的節點數量維護
sz[qRoot] += sz[pRoot];
}else{// qRoot->pRoot
parent[qRoot] = pRoot;
// 樹的節點數量維護
sz[pRoot] += sz[qRoot];
}
整體代碼如下:
public class UnionFind3 implements UF {
private int[] parent;
private int[] sz; // sz[i]表示以i爲根的集合元素個數
public UnionFind3(int size){
parent = new int[size];
sz = new int[size];
// 初始化的時候,所有的元素都是一個獨立的樹
for (int i = 0; i < size; i++) {
parent[i] = i;
// 每一集合一開始都只有一個元素
sz[i] = 1;
}
}
@Override
public int getSize() {
return parent.length;
}
@Override
public boolean isConnected(int p, int q) {
return find(p) == find(q);
}
@Override
public void unionElements(int p, int q) {
// 查找根節點所屬集合
int pRoot = find(p);
int qRoot = find(q);
if(pRoot == qRoot){
return;
}
// 如果pRoot節點的數量小於qRoot節點的數量,pRoot->qRoot
if(sz[pRoot] < sz[qRoot]){
parent[pRoot] = qRoot;
// 樹的節點數量維護
sz[qRoot] += sz[pRoot];
}else{// qRoot->pRoot
parent[qRoot] = pRoot;
// 樹的節點數量維護
sz[pRoot] += sz[qRoot];
}
}
// 查找元素p所對應的集合編號,時間複雜度爲O(h)
private int find(int p){
if (p < 0 || p >= parent.length) {
throw new IllegalArgumentException("p is out of bound.");
}
// 判斷是不是根節點(根節點自己等於自己)
while(p != parent[p]){
p = parent[p];
}
return p;
}
}
四、基於Rank的優化-Quick Union-4
基於size的維護其實還存在一個問題,那就是當合並節點“7”所在樹的size > 另一個合併節點“8”所在樹的size;但是節點“8”所在樹的深度卻要比節點“7”所在樹的深度要高,如圖。
按照我們原有的邏輯,實現的結果是這樣的,樹的層級由最高爲3,變成了最高爲4,結合的深度又增加了。
按照之前我們對算法的分析,應該把深度低的樹指向深度高的樹,這樣的操作結果纔是最合理的,因爲不會增加樹的層級,如下效果:
接下來,我們在之前的代碼上繼續進行優化,在Quick Union-3的基礎上我們不再維護size了,轉而維護樹的深度rank;
private int[] parent;
private int[] rank; // rank[i]表示以i爲根的集合所表示的樹的層數
public UnionFind4(int size){
parent = new int[size];
rank = new int[size];
// 初始化的時候,所有的元素都是一個獨立的樹
for (int i = 0; i < size; i++) {
parent[i] = i;
// 每個集合一開始的層級都是1
rank[i] = 1;
}
}
我們根據深度來實現合併的邏輯
// 將rank低的集合合併到rank高的集合上
if(rank[pRoot] < rank[qRoot]){
parent[pRoot] = qRoot;
}else if(rank[pRoot] > rank[qRoot]){// qRoot->pRoot
parent[qRoot] = pRoot;
}else{// parent[pRoot] == parent[qRoot]
parent[qRoot] = pRoot;
rank[pRoot] += 1;
}
整體的代碼實現如下:
public class UnionFind4 implements UF {
private int[] parent;
private int[] rank; // rank[i]表示以i爲根的集合所表示的樹的層數
public UnionFind4(int size){
parent = new int[size];
rank = new int[size];
// 初始化的時候,所有的元素都是一個獨立的樹
for (int i = 0; i < size; i++) {
parent[i] = i;
// 每個集合一開始的層級都是1
rank[i] = 1;
}
}
@Override
public int getSize() {
return parent.length;
}
@Override
public boolean isConnected(int p, int q) {
return find(p) == find(q);
}
@Override
public void unionElements(int p, int q) {
// 查找根節點所屬集合
int pRoot = find(p);
int qRoot = find(q);
if(pRoot == qRoot){
return;
}
// 將rank低的集合合併到rank高的集合上
if(rank[pRoot] < rank[qRoot]){
parent[pRoot] = qRoot;
}else if(rank[pRoot] > rank[qRoot]){// qRoot->pRoot
parent[qRoot] = pRoot;
}else{// parent[pRoot] == parent[qRoot]
parent[qRoot] = pRoot;
rank[pRoot] += 1;
}
}
// 查找元素p所對應的集合編號,時間複雜度爲O(h)
private int find(int p){
if (p < 0 || p >= parent.length) {
throw new IllegalArgumentException("p is out of bound.");
}
// 判斷是不是根節點(根節點自己等於自己)
while(p != parent[p]){
p = parent[p];
}
return p;
}
}
五、路徑壓縮-Quick Union-5
所謂的路徑壓縮,是針對樹的深度來說的,一般情況下,一棵樹它的層級越低,它的查詢效率越快;如果是退化成鏈表的情況,查詢元素,需要從頭到尾遍歷整個集合,所以它的效率是最差的。因此,我們需要想辦法壓縮整棵樹的高度,來提高數據結構的運算效率。
如果,我們在每一次查詢的時候,發現節點的父親節點還有一個有父親節點,那麼就讓這個節點指向父親節點的父親節點,即執行一次 parent[p] = parent[parent[p]];便可以對樹結構進行有效的路勁壓縮。
在代碼中的實現也比較簡單,我們只要在Quick Union-4的基礎上,改變一下查詢的方法就可以了,如下,在查詢方法中添加一行代碼: parent[p] = parent[parent[p]];
// 查找元素p所對應的集合編號,時間複雜度爲O(h)
private int find(int p){
if (p < 0 || p >= parent.length) {
throw new IllegalArgumentException("p is out of bound.");
}
// 判斷是不是根節點(根節點自己等於自己)
while(p != parent[p]){
parent[p] = parent[parent[p]];
p = parent[p];
}
return p;
}
整體的實現代碼如下:
public class UnionFind5 implements UF {
private int[] parent;
private int[] rank; // rank[i]表示以i爲根的集合所表示的樹的層數
public UnionFind5(int size){
parent = new int[size];
rank = new int[size];
// 初始化的時候,所有的元素都是一個獨立的樹
for (int i = 0; i < size; i++) {
parent[i] = i;
// 每個集合一開始的層級都是1
rank[i] = 1;
}
}
@Override
public int getSize() {
return parent.length;
}
@Override
public boolean isConnected(int p, int q) {
return find(p) == find(q);
}
@Override
public void unionElements(int p, int q) {
// 查找根節點所屬集合
int pRoot = find(p);
int qRoot = find(q);
if(pRoot == qRoot){
return;
}
// 將rank低的集合合併到rank高的集合上
if(rank[pRoot] < rank[qRoot]){
parent[pRoot] = qRoot;
}else if(rank[pRoot] > rank[qRoot]){// qRoot->pRoot
parent[qRoot] = pRoot;
}else{// parent[pRoot] == parent[qRoot]
parent[qRoot] = pRoot;
rank[pRoot] += 1;
}
}
// 查找元素p所對應的集合編號,時間複雜度爲O(h)
private int find(int p){
if (p < 0 || p >= parent.length) {
throw new IllegalArgumentException("p is out of bound.");
}
// 判斷是不是根節點(根節點自己等於自己)
while(p != parent[p]){
parent[p] = parent[parent[p]];
p = parent[p];
}
return p;
}
}
注意:rank在進行路徑壓縮後並不是真正代表的樹的深度,有時候,一個層級的節點,它們的rank值並不相同,所以路徑壓縮後的rank更相當於表示一個排序。同時,我們也不一定要刻意去維護一棵樹的精確深度,在並查集中,這樣是沒有意義的,反而會增加數據結構的性能消耗。
六、更理想的路徑壓縮-Quick Union-6
前邊我們也提到了,並查集樹的深度越低,它的效率越快,那麼我們可不可以查找一個節點時,直接把它的索引指向根節點,進行如下圖這種扁平式的優化呢?
答案是可以的,我們需要藉助遞歸的實現,在查詢的邏輯中,使用遞歸實現的代碼如下
// 查找元素p所對應的集合編號,時間複雜度爲O(h)
private int find(int p){
if (p < 0 || p >= parent.length) {
throw new IllegalArgumentException("p is out of bound.");
}
// 判斷是不是根節點
if(p != parent[p]){
parent[p] = find(parent[p]);
}// 返回根節點
return parent[p];
}
這段代碼的邏輯是,我們不停的去查找根節點的索引,直到查找到爲止。通過這段代碼邏輯,我們可以直接把當前查找節點的索引指向到樹的根節點上。
注:因爲遞歸是需要消耗性能的,Quick Union-6 的效率要比Quick Union-5(非遞歸實現)要差一點,在此這種實現僅作參考。
七、測試
對於上述實現的並查集,可以通過以下簡單的測試程序進行測試
public class Main {
private static double testUF(UF uf, int m) {
// 一共維護了多少數據
int size = uf.getSize();
Random random = new Random();
long startTime = System.nanoTime();
// 首先進行m次的合併操作
for (int i = 0; i < m; i++) {
int a = random.nextInt(size);
int b = random.nextInt(size);
uf.unionElements(a, b);
}
// 然後再進行m次的查詢操作
for (int i = 0; i < m; i++) {
int a = random.nextInt(size);
int b = random.nextInt(size);
uf.isConnected(a, b);
}
long endTime = System.nanoTime();
return (endTime - startTime) / 1000000000.0;
}
public static void main(String[] args) {
int size = 10000000;
int m = 10000000;
// UnionFind1 uf1 = new UnionFind1(size);
// System.out.println("UnionFind1:" + testUF(uf1, m) + "s");
//
// UnionFind2 uf2 = new UnionFind2(size);
// System.out.println("UnionFind2:" + testUF(uf2, m) + "s");
UnionFind3 uf3 = new UnionFind3(size);
System.out.println("UnionFind3:" + testUF(uf3, m) + "s");
UnionFind4 uf4 = new UnionFind4(size);
System.out.println("UnionFind4:" + testUF(uf4, m) + "s");
UnionFind5 uf5 = new UnionFind5(size);
System.out.println("UnionFind5:" + testUF(uf5, m) + "s");
UnionFind6 uf6 = new UnionFind6(size);
System.out.println("UnionFind6:" + testUF(uf6, m) + "s");
}
}