資料結構——並查集Union Find
一、並查集解決了什麼問題?
1、網路中節點間的連線狀態:這裡的網路是一個抽象的概念,指的是使用者之間形成的網路
2、兩個或兩個以上集合之間的交集
二、對並查集的設計
對於一組資料,主要支援兩個操作
public interface UnionFind {
//int getSize();
boolean isConnected(int p , int q);
void unionElements(int p , int q);
}
並查集版本1:Quick Find
並查集的基本資料表示
id為0、2、4、6、8它們都對應值0,所以可以認為它們屬於同一個集合。這就解釋了上面書寫的方法isConnected(p,q),只要p和q的id所隱射的值是否一致即可,實際上就是find(p) == find(q)?,這種查詢稱為:Quick Find,它的時間複雜度:O(1)
那麼union合併這個操作是怎樣的呢?
如上圖所示,id為單數對應一個集合1,id為雙數對應一個集合0,如果我們進行union(1,4),即把集合1與集合2進行合併,變為:
所以Quick Find下union時間複雜度為O(n)
//version 1 public class UnionFind1 implements UnionFind{ //建立一個id陣列 private int [] id; public UnionFind1(int size){ id = new int[size]; for(int i = 0 ; i < id.length ; i++) id[i] = i; } public int getSize() { return id.length; } //查詢元素p所對應的集合編號 private int find(int p){ if(p < 0 && p >= id.length) throw new IllegalArgumentException("p is out of bound"); return id[p]; } //查詢元素p或元素p是否屬於同一個集合 public boolean isConnected(int p, int q) { return find(p) == find(q); } //合併元素p與元素q所屬的集合 public void unionElements(int p, int 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; } } }
我們使用陣列實現了版本1 Quick Find並查集的並查集,其中:
boolean isConnected(int p , int q); //時間複雜度O(1)
void unionElements(int p , int q); //時間複雜度O(n)
下面我們使用樹結構實現並查集,且這個樹十分奇怪,是由孩子節點指向父節點的。
並查集版本2:使用樹實現
①我們將每一個元素看做是一個節點,圖中節點3指向節點2,節點2作為根節點,對於節點2而言,它也有一個指標指向自己。
②如果節點1所對應的元素需要和節點3所對應的元素進行合併union,實際就是將節點1的指標指向節點3的根節點,如圖:
③圖中5是根節點,6和7都是5的孩子節點同時指向節點5,若想讓7節點和2節點進行合併,則只需讓7的根節點5指向2即可
如果想讓節點7與節點3合併,實際圖是一樣的,即節點7的根節點5指向節點3的根節點2。
我們發現,每一個節點都只有一個指標,我們可以使用陣列來表示這個指標的關係,在初始化的時候,我們讓每一個節點都指向自己,如總共有10個元素:
嚴格來說我們的並查集並不是一個樹形結構,而是一個森林。
如果我們要union(4,3)的話,實際上就是將4的指標指向節點3
如果我們要union(3,8)的話,實際上就是將3的指標指向節點8
如果我們要union(6,5)的話,實際上就是將6的指標指向節點5
如果我們要union(9,4)的話,實際上就是將9的指標指向節點4所在樹的根節點,此時就涉及一個查詢操作了,檢視上圖中的陣列圖,4指向了3,3指向了8,而8指向了自己,那麼就讓節點9指向節點8
為什麼我們不讓節點9直接指向節點4呢?如果9指向4,實際就是生成一個連結串列,樹的優勢無法體現,現在節點9指向節點8,若我們需要查詢節點9的根節點是什麼?只需要一次查詢即可,所以對應的陣列圖改變為:
實際上我們的這種資料結構中,union操作的時間複雜度為O(h),h為樹的深度
public class UnionFind2 implements UnionFind{
//建立一個id陣列
private int [] parent;
public UnionFind2(int size){
parent = new int[size];
for(int i = 0 ; i < parent.length ; i++)
parent[i] = i;
}
public int getSize() {
return parent.length;
}
//查詢元素p所對應的集合編號
//O(h)複雜度,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;
}
//查詢元素p或元素p是否屬於同一個集合
//O(h)複雜度,h為樹的高度
public boolean isConnected(int p, int q) {
return find(p) == find(q);
}
//合併元素p與元素q所屬的集合
//O(h)複雜度,h為樹的高度
public void unionElements(int p, int q) {
int pRoot = find(p);
int qRoot = find(q);
if(pRoot == qRoot)
return;
parent[pRoot] = qRoot;
}
}
並查集版本3:基於size進行優化
目前我們完成了兩個版本的Union Find。在版本2中,find()方法時間複雜度是O(h) ,查詢的過程實際上是一個不斷索引的過程,需要在不同的地址空間完成跳轉,在find次數過多的情況下,有可能造成union過程合併後樹的高度h過大,造成查詢效能降低。
慢的原因實際為:我們總讓 parent[pRoot] = qRoot; 而並沒有考慮過兩個樹的特點。於是我們考慮基於size進行優化,我們需要考慮合併的兩棵樹分別有多少個節點,如:
此時高度為4,我們完全可以讓節點9指向4所在的根節點,這樣高度僅為3。
讓節點個數少的樹的根節點指向節點個數多的樹的根節點,這樣union後所形成的樹深度較低。
具體程式設計優化:
public class UnionFind3 implements UnionFind{
//建立一個id陣列
private int [] parent;
//新增一個數組
private int[] sz; //表示以i為根的集合中元素的個數
public UnionFind3(int size){
sz = new int[size]; //對sz進行初始化
parent = new int[size];
for(int i = 0 ; i < parent.length ; i++) {
parent[i] = i;
sz[i] = 1;
}
}
public int getSize() {
return parent.length;
}
//查詢元素p所對應的集合編號
//O(h)複雜度,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;
}
//查詢元素p或元素p是否屬於同一個集合
//O(h)複雜度,h為樹的高度
public boolean isConnected(int p, int q) {
return find(p) == find(q);
}
//合併元素p與元素q所屬的集合
//O(h)複雜度,h為樹的高度
public void unionElements(int p, int q) {
int pRoot = find(p);
int qRoot = find(q);
if(pRoot == qRoot)
return;
if(sz[pRoot] < sz[qRoot]){
parent[pRoot] = qRoot;
sz[qRoot] += sz[pRoot];
}else{
parent[qRoot] = pRoot;
sz[pRoot] += sz[qRoot];
}
}
}
並查集版本4:基於rank進行優化
即在每一個節點上記錄這個節點為根的對應樹它的最大深度是多少,在合併時候應使用深度比較低的樹指向深度比較高的樹。
圖中我們需要union(4,2),如果我們使用版本3:基於size進行優化的話,節點4所在的樹節點總數為3個,節點2所在樹節點總數為6個,即將節點個數少的樹根節點8指向節點較多的樹根節點7,如圖:
合併之後樹的最大深度由3增加為了4,更加合理的方案是讓根節點7指向根節點8,即深度低的樹指向深度高的樹,如圖:
樹的最大深度仍然為3,這樣的優化稱為基於rank的優化,使用rank[i]表示根節點為i的樹的高度。
package cn.itcats.unionFind;
public class UnionFind4 implements UnionFind{
//建立一個id陣列
private int [] parent;
//新增一個數組
private int[] rank; //表示以i為根的集合中元素的深度
public UnionFind4(int size){
rank = new int[size]; //對sz進行初始化
parent = new int[size];
for(int i = 0 ; i < parent.length ; i++) {
parent[i] = i;
rank[i] = 1;
}
}
public int getSize() {
return parent.length;
}
//查詢元素p所對應的集合編號
//O(h)複雜度,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;
}
//查詢元素p或元素p是否屬於同一個集合
//O(h)複雜度,h為樹的高度
public boolean isConnected(int p, int q) {
return find(p) == find(q);
}
//根據兩個元素所在樹的rank不同判斷合併方向
//將rank比較低的集合合併到rank高的集合上
public void unionElements(int p, int q) {
int pRoot = find(p);
int qRoot = find(q);
if(pRoot == qRoot)
return;
if(rank[pRoot] < rank[qRoot]){
parent[pRoot] = qRoot;
}else if(rank[qRoot] < rank[pRoot]){
parent[qRoot] = pRoot;
}else{
parent[pRoot] = qRoot;
rank[qRoot] += 1;
}
}
}
三、路徑壓縮
圖示的三棵樹實際上都是等效的,他們都相互連線,但它們深度不同,對應的查詢效率也不同。
之前我們在對樹進行union操作的時候,都是將某個樹的根節點指向另外一個樹的根節點,在運算元量大的情況下,就難免造成樹的深度過大。所謂的路徑壓縮所解決的問題就是將一個比較高的樹壓縮稱為矮的樹。對於並查集而言,每一個樹的子樹個數並沒有限制,理想情況下我們希望我們的樹是上圖所示中間那個形狀【即只有兩層,根節點在第一層,子節點在第二層】
如我們進行find(4)操作,需要經過4次定址,樹的高度為5,即:
當我們向上遍歷的時候執行 parent[p] = parent[parent[p]],即將節點p的父節點設定為節點p父親節點的父親節點。
此時0已經是根節點無需繼續向上查找了。整棵樹從原來的深度為5降到了深度為3,也就是我們在查詢節點4的根節點時候,把樹的結構也改變了。在version4基礎上,只需要修改find()方法即可具體的編碼實現:
//新增路徑壓縮的過程
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;
}
基於路徑壓縮版本6
如果我們想將路徑壓縮為下圖形狀,需要藉助遞迴實現:
同樣,只需要在version4的基礎上,修改find()方法即可
//新增路徑壓縮的過程
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];
}
經過效能測試發現還是version5效能最好,因為遞迴呼叫本身就是有效能開銷的。
四、壓縮後的並查集時間複雜度分析
O(1) < O(log*n) < O(logn)