主要介紹並查集演算法實現以及相關優化。
並查集 Union Find
-
圖相關演算法的實現。
-
一種不一樣的樹形結構
連線問題 Connectivity Problem
視覺化的來看連線問題:
左上右下是否是連線的呢?
意義:實際應用中的作用
-
網路中節點間的連線狀態
- 網路是個抽象的概念:使用者之間形成的網路
-
社交網路:Facebook中使用者a和b中的聯絡(好友關係)。是否能聯絡到。
-
音樂電影書籍,多媒體之間形成網路。
-
網際網路網頁之間形成的網路
-
路由器和路由器之間形成的也是網路
-
道理交通,航班排程都是網路
數學中的集合類實現
並就是實現並集。& 查詢
連線問題 & 路徑問題
比路徑問題要回答的問題少(路徑是什麼,連線問題只問有沒有連)
- 和二分查詢作比較:順序查詢法順便回答了rank。和前面其他元素的位置
- 和select作比較:排好序回答問題更多。快排思路select回答問題更少
- 和堆作比較:只關心最大最小。
除了回答問題本身之外是不是額外的回答了別的問題。很有可能就存在
更高效的演算法。:因為高效演算法不需要回答額外的問題。
實現一個最簡單的並查集 Union Find
對於一組資料,主要支援兩個動作:
- union( p , q )
- find( p )
用來回答一個問題
- isConnected( p , q )
最簡單的表示方式;
陣列。0,1.
0-4 5-9
0-4是一組,5-9是一組。組內之間有聯絡,一組內的元素有相同的id
奇偶
奇數是一組,偶數是一組。
namespace UF1 {
class UnionFind {
private:
int *id;
int count;
public:
UnionFind(int n) {
count = n;
id = new int[n];
//初始條件每個元素都是一組
for (int i = 0; i < n; i++)
id[i] = i;
}
~UnionFind() {
delete[] id;
}
//傳入元素p,返回元素對應的id。
int find(int p) {
assert(p >= 0 && p < count);
return id[p];
}
bool isConnected(int p, int q) {
return find(p) == find(q);
}
//傳入兩個元素,並
void unionElements(int p, int q) {
//找到兩個元素的id
int pID = find(p);
int qID = find(q);
//比較id
if (pID == qID)
return;
for (int i = 0; i < count; i++)
//從頭到尾的掃描時間複雜度O(n)
if (id[i] == pID)
id[i] = qID;
}
};
}
複製程式碼
Testhelper.h:
namespace UnionFindTestHelper{
//n是資料量
void testUF1( int n ){
//
srand( time(NULL) );
UF1::UnionFind uf = UF1::UnionFind(n);
time_t startTime = clock();
//O(N*N)的時間複雜度
for( int i = 0 ; i < n ; i ++ ){
int a = rand()%n;
int b = rand()%n;
uf.unionElements(a,b);
//O(n)
}
for(int i = 0 ; i < n ; i ++ ){
int a = rand()%n;
int b = rand()%n;
uf.isConnected(a,b);
//時間複雜度只有O(1)
}
time_t endTime = clock();
cout<<"UF1, "<<2*n<<" ops, "<<double(endTime-startTime)/CLOCKS_PER_SEC<<" s"<<endl;
}
}
複製程式碼
main.cpp:
int main() {
int n = 100000;
UnionFindTestHelper::testUF1(n);
return 0;
}
複製程式碼
執行結果:
UF1, 200000 ops, 32.3533 s
[Finished in 39.7s]
複製程式碼
quick find 查詢時只需要O(1)級別。但是並確很慢
並查集的另一種實現思路
常規實現思路
將每一個元素,看做是一個節點。
元素節點
每個元素擁有一個指向父節點的指標。然後最上面的父節點指標指向自己。
Quick Union
陣列存放父親
parent(i) = i;
初始狀態
union 3 4
union 3 8
union 6 5
union 9 4
要將9連線到4的根節點8上去。陣列中:4-3-8-8 8是4的根節點。9指向8.
4和9連線在一起:因為根相同。
成果
- 其中6和2連線是6的根0和2的根1選取了1將0掛上。
程式碼實現
namespace UF2{
class UnionFind{
private:
int* parent;
int count;
public:
UnionFind(int count){
parent = new int[count];
this->count = count;
for( int i = 0 ; i < count ; i ++ )
parent[i] = i;
}
~UnionFind(){
delete[] parent;
}
//不斷向上找父親
int find(int p){
assert( p >= 0 && p < count );
while( p != parent[p] )
p = parent[p];
return p;
}
//看是否能找到同樣的根
bool isConnected( int p , int q ){
return find(p) == find(q);
}
//找到p的根,和q的根
void unionElements(int p, int q){
int pRoot = find(p);
int qRoot = find(q);
if( pRoot == qRoot )
return;
//把根掛到另一個的根
parent[pRoot] = qRoot;
}
};
}
複製程式碼
執行結果:
UF1, 20000 ops, 0.246341 s
UF2, 20000 ops, 0.059387 s
複製程式碼
當n大的時候,方法1更優了。
並查集的優化
問題1:
union 9,4 & union 4 9
union 9 4
9的元素少,將它指向4的根節點。形成的樹層數低。
// 我們的第三版Union-Find
namespace UF3{
class UnionFind{
private:
int* parent; // parent[i]表示第i個元素所指向的父節點
int* sz; // sz[i]表示以i為根的集合中元素個數
int count; // 資料個數
public:
// 建構函式
UnionFind(int count){
parent = new int[count];
sz = new int[count];
this->count = count;
for( int i = 0 ; i < count ; i ++ ){
parent[i] = i;
sz[i] = 1;
}
}
// 解構函式
~UnionFind(){
delete[] parent;
delete[] sz;
}
// 查詢過程, 查詢元素p所對應的集合編號
// O(h)複雜度, h為樹的高度
int find(int p){
assert( p >= 0 && p < count );
// 不斷去查詢自己的父親節點, 直到到達根節點
// 根節點的特點: parent[p] == p
while( p != parent[p] )
p = parent[p];
return p;
}
// 檢視元素p和元素q是否所屬一個集合
// O(h)複雜度, h為樹的高度
bool isConnected( int p , int q ){
return find(p) == find(q);
}
// 合併元素p和元素q所屬的集合
// O(h)複雜度, h為樹的高度
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];
}
}
};
}
複製程式碼
執行結果:
UF2, 200000 ops, 19.3316 s
UF3, 200000 ops, 0.0184 s
複製程式碼
分析
- 對於UF1來說,雖然isConnected只需要O(1)的時間, 但由於union操作需要O(n)的時間;總體測試過程的演算法複雜度是O(n^2)的
- 對於UF2來說, 其時間效能是O(n*h)的, h為並查集表達的樹的最大高度
- 這裡嚴格來講, h和logn沒有關係, 不過大家可以簡單這麼理解
- 我們後續內容會對h進行優化, 總體而言, 這個h是遠小於n的
- 所以我們實現的UF2測試結果遠遠好於UF1, n越大越明顯:)
- 對於UF3來說, 其時間效能依然是O(n*h)的, h為並查集表達的樹的最大高度
- 但由於UF3能更高概率的保證樹的平衡, 所以效能更優
基於rank的並查集優化
分析
上面合併4和2 依靠集合的size來決定誰指向誰並不完全合理。根據層數才最合理。
基於rank的優化
用rank[i] 表示根節點為i的樹的高度
namespace UF4{
class UnionFind{
private:
int* rank; // rank[i]表示以i為根的集合所表示的樹的層數
int* parent; // parent[i]表示第i個元素所指向的父節點
int count; // 資料個數
public:
// 建構函式
UnionFind(int count){
parent = new int[count];
rank = new int[count];
this->count = count;
for( int i = 0 ; i < count ; i ++ ){
parent[i] = i;
rank[i] = 1;
}
}
// 解構函式
~UnionFind(){
delete[] parent;
delete[] rank;
}
// 查詢過程, 查詢元素p所對應的集合編號
// O(h)複雜度, h為樹的高度
int find(int p){
assert( p >= 0 && p < count );
// 不斷去查詢自己的父親節點, 直到到達根節點
// 根節點的特點: parent[p] == p
while( p != parent[p] )
p = parent[p];
return p;
}
// 檢視元素p和元素q是否所屬一個集合
// O(h)複雜度, h為樹的高度
bool isConnected( int p , int q ){
return find(p) == find(q);
}
// 合併元素p和元素q所屬的集合
// O(h)複雜度, h為樹的高度
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{ // rank[pRoot] == rank[qRoot]
parent[pRoot] = qRoot;
rank[qRoot] += 1; // 此時, 我維護rank的值
}
}
};
}
複製程式碼
分析
-
對於UF3來說, 其時間效能依然是O(n*h)的, h為並查集表達的樹的最大高度,但由於UF3能更高概率的保證樹的平衡, 所以效能更優
-
UF4雖然相對UF3進行有了優化, 但優化的地方出現的情況較少,所以效能更優表現的不明顯, 甚至在一些資料下效能會更差,因為判斷更多了。
執行結果
2000000 ops, 0.313945 s
複製程式碼
路徑壓縮(path Compression)
前面我們都在優化union。其實Find我們也可以進行優化。由於每個節點存的都是它的父親節點,所有每個節點都可以有無數個(多個)孩子。在search值的時候,對於沒有找到的根的節點,可以往上挪一挪。
分析
比如我們要find4
我們將4的父親節點連線為4的父親的父親(如果出現3就是根節點,也沒有關係,因為對於根節點來說,3的父親還是3)
下面考慮4的parent:2 (此時跳過了3,跳2級是沒有問題的)
最後的結果:
修改 find函式
int find(int p){
assert( p >= 0 && p < count );
// path compression 1
while( p != parent[p] ){
parent[p] = parent[parent[p]];
p = parent[p];
}
}
複製程式碼
最優結果的程式碼實現
//path compression 2, 遞迴演算法
if( p != parent[p] )
parent[p] = find( parent[p] );
return parent[p];
複製程式碼
最後的情況
- 寫一個遞迴的函式:呼叫findx,返回的就是x節點的根。讓每個parentx指向findx的結果。findx的結果也是Findparentx的結果。找x的時候,將x的Findparent的結果,指向父親的結果。
優化情況並不明顯。甚至因為遞迴的消耗。所以理論最優不一定實際好。
經過並查集的優化,並查集的操作,時間複雜度近乎是O(1)的
————————-華麗的分割線——————–
看完的朋友可以點個喜歡/關注,您的支援是對我最大的鼓勵。
想了解更多,歡迎關注我的微信公眾號:番茄技術小棧