一文搞定並查集
並查集是什麼?
並查集是一種樹型的數據結構,用於處理一些不相交集合的合併及查詢問題,常常在使用中以森林來表示。並查集通常用來解決管理若干元素的分組問題
並查集可以高效完成下列操作 (並與查的功能) :
- 並:合併元素a和元素b所在的組
- 查:查詢元素a和元素b是否屬於同一組
並查集的結構
並查集使用樹形結構形成,實際上可以看作是森林
如圖所示,我們可以將左側的分組以右側的森林的形式表示。其中每個元素對應樹中的一個節點,每一個組對應着森林中的一顆樹,在並查集中,通常我們不在意父節點與子節點的順序,實際上樹的形狀也無關緊要,只需要令同一組的元素對應到同一個樹上即可。
並查集的邏輯實現
-
初始化
準備n個節點表示n個元素,在初始化時,彼此之間無關聯,即均單獨成樹
-
合併
合併即爲合併兩個樹,由於每個樹表示一個集合(即相同類別,無順序之分),故只需要讓一個樹的根節點指向另一個樹即可。合併的例子:
-
查詢
查詢即查詢兩個元素是否是同一集合中,即查詢對應的兩個節點是否在同一個樹上,因此只需要查詢兩個節點所對應的樹的根節點是否一致即可。
分析該圖,在合併前,4對應的根節點是3,而7對應的根節點是6,故不在同一樹中,不屬於同一集合。當合並後,4對應的根節點是6,7對應的根節點也是6,故二者根節點相同,說明它們在同一集合中。
並查集邏輯實現的優化
主要優化思路爲儘可能使樹的高度降低,從而儘可能減少查詢的時間複雜度
-
當進行合併時,儘可能使高樹的根節點作爲合併後的根節點。通常方法是記錄每個樹的高度rank,當合並時,比較兩個樹的rank,將rank值大的樹的根作爲合併後的根節點。這樣可以避免樹的複雜度過高
上圖是兩種合併方式,顯然,如果以6爲合併後的根節點,則樹的高度爲3,反之則爲4。故當合併時,如果考慮到兩個樹的高度,將使得最終樹的高度儘可能小
-
進行路徑壓縮,使得並查集更高效。對於每個節點,一旦向上走到了一次根節點,就把這個節點的父親節點直接指向根節點。即若一個節點的根節點已經計算出,則直接將其指向根節點,儘可能避免重複計算
如果已計算得到8的根節點爲6,則直接將8指向6即可,從而降低樹的高度實際上,由於查詢過程是自下而上的,因此可以將查詢過程的路徑上遇到的所有節點均直接指向最終的根節點,也就意味着實際上每次查詢均附帶着實現了路徑壓縮
查詢節點10的根節點的過程中,實際上將得到節點10、節點9、節點8、節點7對應的樹的根節點均爲節點 6,因此可以直接將其指向節點6,實現路徑壓縮。爲了簡化起見,路徑壓縮過程通常不去修改樹的高度值
並查集的複雜度
易知,進行優化後,並查集的效率將非常高,比O(log n) 還快
並查集的實現(C++)
在並查集的實現過程中,通常我們用一個數組即可實現,數組的下標表示所對應的節點,數組的值代表該節點所在的樹的根節點標號
par數組表示節點,存儲內容爲根節點的序號,rank數組表示樹的高度,不需做非常精確的計算
//並查集的實現
#include<iostream>
using namespace std;
const int MAX_N=10000;
int par[MAX_N]; //父節點, 存儲該節點對應的根節點的位置
int rank[MAX_N]; //樹的高度,存儲該樹的位置
void init(int n) //初始化n個元素,使得該n個元素最初的根節點均爲自身
{
for(int i=0;i<n;i++)
{
par[i]=i;
rank[i]=0;
}
}
int find (int x) //查詢第x個節點所在樹的根
{
if(par[x]==x)
return x;
else
return par[x]=find(par[x]); //這一步很巧妙,既通過遞歸找到根節點,又同時完成了路徑的壓縮
}
void unite(int x,int y) //將x和y所在的集合合併
{ //合併時主要考慮兩個根即可
x=find(x);
y=find(y);
if(x==y) return ; //在同一集合,不需操作
if(rank[x]<rank[y]) //rank大的節點作爲合併後的根節點
par[x]=y;
else
{
if(rank[x]==rank[y]) rank[x]++; //如果兩個樹高度一樣,則合併後樹的高度加1
par[y]=x;
}
}
bool same(int x,int y) //判斷x和y是否是同一個集合
{
return find(x)==find(y);
}
//在main函數中做一些簡單的測試:
int main()
{
int n=10;
init(n); //初始化10個節點
for(int i=0;i<n;i++)
cout<<par[i]<<' ';
cout<<endl;
unite(1,2);
unite(2,3);
unite(3,5); //1,2,3,5在同一集合
cout<<par[1]<<' '<<par[2]<<' '<<par[3]<< ' '<<par[5]<<endl;//same number
unite(4,6);
unite(6,7); //4,6,7在同一集合
cout<<same(4,5)<<' '<<same(4,6)<<endl; //0 1
unite(4,5);
cout<<same(5,6)<<endl; //1
}
掌握並查集的實現的幾個函數即可輕鬆應付大多數並查集題目
並查集題目的練習
最近疫情非常嚴重,在此以 poj 上一個SARS病毒傳播的題目爲始:
poj 1611
http://poj.org/problem?id=1611
Title :The Suspects
題目描述: 嚴重急性呼吸道綜合症(SARS)是一種病因不明的非典型肺炎,在2003年3月中旬被認爲是全球性威脅。爲了最大程度地減少向他人的傳播,最好的策略是將嫌疑犯與其他人分開 。在一所大學中,有許多學生團體, 同一學生團體中的學生經常互相交流,一個學生可以加入多個學生團體。爲了防止可能的SARS傳播,該大學收集所有學生團體的成員列表,並在其標準操作程序(SOP)中制定以下規則: 一旦組中的某個成員成爲可疑對象,該組中的所有成員都將成爲可疑對象。但是,他們發現,當已確認一個學生爲可疑對象的情況下,識別出所有可疑對象並不容易。你的工作是編寫一個找到所有嫌疑犯的程序
輸入: 輸入文件包含多種情況:每個測試用例都以一行中的兩個整數n和m開頭,其中n是學生數,m是組數。您可以假設0 <n <= 30000並且0 <= m <=500。每個學生都用0到n-1之間的唯一整數編號,並且最初在所有情況下,學生0都被視爲犯罪嫌疑人。該行之後是組的m個成員列表,每組一行。每行以一個整數k開頭,該整數k代表組中成員的數量。在成員數之後,有k個整數表示該組中的學生。一行中的所有整數至少間隔一個空格。n = 0和m = 0的情況表示輸入的結尾,無需處理。
輸出:輸出對於每種情況的可疑對象數量
分析:根據題目描述,很容易確定出最終的可以對象數量即爲與組員0有直接或間接關係的人數,因此採用並查集,根據所給的關係實現集合的分類,然後檢測與組員0在同一集合的人數即可
/*
核心題意:組員0有傳染病,給出很多人之間的關係,求出和組員0有直接或者間接關係的人數即可
利用並查集,根據每個組員關係建立並查集後,檢測是否處於同一類中即可
*/
#include<iostream>
using namespace std;
const int MAX_N=30002;
int par[MAX_N];
void init(int n)//並查集的初始化
{
for(int i=0;i<n;i++)
par[i]=i;
}
int find(int x) //查找根節點
{
return par[x]==x?x:par[x]=find(par[x]);
}
void unite(int x1,int y1) //合併
{
int x=find(x1);
int y=find(y1);
if(x==y) return;
else par[x]=y;
}
bool same(int x,int y) //判斷是否相同類
{
return find(x)==find(y);
}
int main()
{
int n,m;
cin>>n>>m;
while(n||m)
{
init(n); //初始化很重要!!!
for(int j=1;j<=m;j++)
{
int times,x,y;
cin>>times>>x; //輸入 組員數量和第一個組員
for(int i=1;i<times;i++)
{
cin>>y;
unite(x,y);
}
}
int ans=1;
for(int i=1;i<n;i++) //計算出和0在同一樹的結點數量即可,直接遍歷
if(same(i,0))
ans++;
cout<<ans<<endl;
cin>>n>>m;
}
}
poj 2524
http://poj.org/problem?id=2524
Title : Ubiquitous Religions
題目描述: 當今世界上有太多不同的宗教,很難一一掌握。你有興趣找出你所在大學中有多少不同宗教信仰的學生。大學中有n個學生(0 <n <= 50000),很顯然向每個學生詢問他們的宗教信仰是不可行的,因爲學生數量較多且許多學生不願意公開自己的宗教。爲了避免這些問題,你可以問 m對學生,並詢問他們是否信仰同一宗教(例如,他們可能知道他們是否都參加同一宗教)教會)。從這些數據中,您可能不知道每個人的信仰,但是您可以大致瞭解在校園中可以代表多少種宗教。您可以假設每個學生最多訂閱一種宗教
輸入: 輸入包含多種情況。每種情況都以指定整數n和m的行開頭。接下來的m行分別由兩個整數i和j組成,指定學生i和j信仰相同的宗教。學生從1到n編號。輸入的結尾由其中n = m = 0的行指定
輸出:對於每個測試用例,在一行上打印例號(以1開頭),然後是大學學生所信奉的不同宗教的最大數量
分析:很顯然該題目爲並查集的利用,利用數據採用並查集的方式建立森林,森林中樹的個數即爲宗教的最大數量
/*
分析:共有n個人,m種關係,每種關係意味着彼此屬於同一類別,故並查集即可,最終求出不同的根結點的最大值即可得到。
*/
#include<iostream>
using namespace std;
const int MAX_N=60000;
int par[MAX_N];
int rank[MAX_N];
void init(int n) //初始化n個元素,使得該n個元素最初的父親均爲自身
{
for(int i=0;i<n;i++)
{
par[i]=i;
rank[i]=0;
}
}
int find (int x) //查詢第x個節點所在樹的根
{
return par[x]==x?x:par[x]=find(par[x]);
}
void unite(int x,int y) //將x和y所在的集合合併
{ //合併時主要考慮兩個根即可
x=find(x);
y=find(y);
if(x==y) return ; //在同一集合,不需操作
if(rank[x]<rank[y]) //rank大的節點作爲合併後的根節點
par[x]=y;
else
{
if(rank[x]==rank[y]) rank[x]++;
par[y]=x;
}
}
int main()
{
int n,m,times=1;
scanf("%d%d",&n,&m);
while(n&&m)
{
init(n+1); //初始化並查集
int x,y;
for(int i=1;i<=m;i++) //注意輸入的數字從1開始
{
scanf("%d%d",&x,&y);
unite(x,y); //合併x和y
}
int ans=0;
for(int i=1;i<=n;i++)
if(par[i]==i) //說明是根節點
ans++;
cout<<"Case "<<times<<": "<<ans<<endl;
times++;
scanf("%d%d",&n,&m);
}
}
CF771A
https://www.luogu.com.cn/problem/CF771A
貝爾利馬克研究社交網絡,社交網絡的特點是兩個成員可以成爲朋友。
有n個成員,序號爲從1到n。兩個成員之間可以是朋友,自己與自己不能是朋友。
讓A-B表示A和B成員是朋友。當且僅當滿足以下條件時,網絡是合理的:對於每三個不同的成員(X,Y,Z),如果X-Y和Y-Z,那麼X-Z也應該存在。
例如:如果艾倫和鮑勃是朋友,鮑勃和茜莉是朋友,那麼艾倫和茜莉也應該是朋友。
你能幫我查一下網絡是否合理嗎?相應地打印“是”或“否”輸入:n表示成員數,m表示關係數,其中(,),之後爲m行數字對, 表示二者之間是朋友
輸出:YES或NO
分析:該題的一個解決思路爲採用並查集,首先將全部人數進行分離,形成一個個小集合,即集合內部的人至少和集合中的其他一個人是朋友。判斷網絡是否合理時,實際上也就是判斷該圖是否爲完全圖,判斷完全圖的方法:結點爲n的完全圖,具有 條邊
*/
//主要是檢驗每一個朋友圈是否是每個元素間均互爲朋友,即判斷是否爲完全圖
//則利用並查集分類,分別檢驗每個組是否爲完全圖(完全圖具有特性:節點爲n*(n-1)/2)
#include<iostream>
using namespace std;
#define LL long long
int pre[150001];
int Find(int x){
return x==pre[x]?x:pre[x]=Find(pre[x]); //找到根節點並且壓縮
}
void mix(int x,int y)
{
int xx=Find(x),yy=Find(y);
if(xx!=yy)
{
pre[xx]=yy;
}
}
LL t[15001];
int main(){
int n,m;
scanf("%d%d",&n,&m);
for(int i=1;i<=n;i++){
pre[i]=i;
}
for(int i=0;i<m;i++){
int x,y;
scanf("%d%d",&x,&y);
mix(x,y);
}
for(int i=1;i<=n;i++){
t[Find(i)]++;
}
LL sum=0;
for(int i=1;i<=n;i++){
if(t[i]!=0&&t[i]!=1){
sum+=(t[i]*(t[i]-1)/2); //sum表示每一個都是完全圖時候的路徑數
}
}
if(sum!=m){
printf("NO\n");
}
else{
printf("YES\n");
}
return 0;
}
poj 1182
http://poj.org/problem?id=1182
Title: 食物鏈
描述:物王國中有三類動物A,B,C,這三類動物的食物鏈構成了有趣的環形。A吃B, B吃C,C吃A。
現有N個動物,以1-N編號。每個動物都是A,B,C中的一種,但是我們並不知道它到底是哪一種。
有人用兩種說法對這N個動物所構成的食物鏈關係進行描述:
第一種說法是"1 X Y",表示X和Y是同類。
第二種說法是"2 X Y",表示X吃Y。
此人對N個動物,用上述兩種說法,一句接一句地說出K句話,這K句話有的是真的,有的是假的。當一句話滿足下列三條之一時,
這句就是假話,否則就是真話。
1) 當前的話與前面的某些真的話衝突,就是假話;
2) 當前的話中X或Y比N大,就是假話;
3) 當前的話表示X吃X,就是假話。
你的任務是根據給定的N(1 <= N <= 50,000)和K句話(0 <= K <= 100,000),輸出假話的總數輸入:第一行是兩個整數N和K,以一個空格分隔。
以下K行每行是三個正整數 D,X,Y,兩數之間用一個空格隔開,其中D表示說法的種類。
若D=1,則表示X和Y是同類。
若D=2,則表示X吃Y輸出:只有一個整數,表示假話的數目
分析:
由於有三種集合A,B,C;故對於每隻動物i創建三種關係i-A,i-B,i-C;並用3*N個元素構成並查集。
維護如下: i-x表示"i屬於種類x" 並查集中的每一個組內所有元素代表的情況同時發生或者不發生
故對於每一條信息,這樣操作即可:
- x和y屬於同一種類-----合併x-A和y-A,x-B和y-B,x-C和y-C
- x吃y-----------------合併x-A和y-B,x-B和y-C,x-C和y-A
- 在合併之前,需要判斷是否與之前的信息已經矛盾
#include<iostream>
using namespace std;
const int MAX_N=300000;
int par[MAX_N]; //父節點
int rank[MAX_N]; //樹的高度
void init(int n) //初始化n個元素,使得該n個元素最初的父親均爲自身
{
for(int i=0;i<n;i++)
{
par[i]=i;
rank[i]=0;
}
}
int find (int x) //查詢第x個節點所在樹的根
{
if(par[x]==x)
return x;
else
return par[x]=find(par[x]); //這一步很巧妙,既實現了遞歸找到根節點,又同時完成了路徑的壓縮
}
void unite(int x,int y) //將x和y所在的集合合併
{ //合併時主要考慮兩個根即可
x=find(x);
y=find(y);
if(x==y) return ; //在同一集合,不需操作
if(rank[x]<rank[y]) //rank大的節點作爲合併後的根節點
par[x]=y;
else
{
if(rank[x]==rank[y]) rank[x]++;
par[y]=x;
}
}
bool same(int x,int y) //判斷x和y是否是同一個集合
{
return find(x)==find(y);
}
/////////////////////構造並查集
const int MAX_K=3000000;
int N,K;
int T[MAX_K],X[MAX_K],Y[MAX_K]; //存儲信息類型和輸入的x、y
void solve()
{
//初始化並查集, 元素x,x+N,x+2N分別代表x-A,x-B,x-C;
init(3*N);
int ans=0;
for(int i=0;i<K;i++)
{
int t=T[i];
int x=X[i]-1;//將輸入變到0,1,2...N-1範圍
int y=Y[i]-1;
//輸入不正確
if(x<0||x>=N||y>=N||y<0)
{
ans++; //錯誤信息+1
continue; //結束這次信息判斷
}
if(t==1) //x y同一類
{
if(same(x,y+N)||same(x,y+2*N))
ans++; //如果矛盾
else
{
unite(x,y);
unite(x+N,y+N);
unite(x+N*2,y+N*2); //不矛盾的話將該關係視爲合理
}
}
else //x吃y
{
if(same(x,y)||same(x,y+2*N))
ans++;
else
{
unite(x,y+N);
unite(x+N,y+2*N);
unite(x+2*N,y);
}
}
}
cout<<ans<<endl;
}
int main()
{
cin>>N>>K;
for(int i=0;i<K;i++)
scanf("%d%d%d",&T[i],&X[i],&Y[i]);
solve();
}