1. 程式人生 > 實用技巧 >並查集(詳)

並查集(詳)

作用:判斷兩個點是否屬於同一個集合


引入:(例題【洛谷P1551 親戚】)

題意大致如下:輸入n表示有n個人,m對x,y表示x,y在同一集合裡,k對詢問表示查詢x,y是否在同一集合

這個問題的核心在於如何判斷x,y在同一集合

其中一種暴力做法是,

使用陣列fa[ ]表示:點x隸屬於fa[x]的集合

初始化:

那麼毫無疑問,初始化即為

for(int i=1;i<=n;++i) fa[i]=i;

因為最開始自己是屬於只包括自己的集合

updata:

for(int i=1;i<=m;++i){
    int u,v;
    scanf("%d%d",&u,&v);
    
while(u!=fa[u]) u=fa[u]; while(v!=fa[v]) v=fa[v]; fa[u]=v; }

對於updata,每次把每兩個點中的一個點的fa[ ]值修改,可以使這個集合擁有兩個性質:

1.該個集合中總有一個點x可以使fa[x]=x,暫且x稱為"集合頭",每個集合的集合頭都不同

2.不論從那個點用fa[ ]值進行遞迴,總可以找到x

我們就可以順利地用集合頭代表這個集合

其中fa[u]=v等效於fa[v]=u,都是把兩個集合合併成一個集合,所以實質上集合的合併等同於集合頭的合併。

query:

只要判定兩點的集合頭是否相同,若相同則是同一個集合

for
(int i=1;i<=k;++i){ int u,v; scanf("%d%d",&u,&v); while(u!=fa[u]) u=fa[u]; while(v!=fa[v]) v=fa[v]; if(u==v) printf("Yes\n"); else printf("No\n"); }

貼:

所以放一下暴力程式碼,暴力O(mn)仍然可以水過這題.....

#include<bits/stdc++.h>
#define maxn 1000010
using namespace std;

int n,m,k,fa[maxn];

int main(){ scanf("%d%d%d",&n,&m,&k); for(int i=1;i<=n;++i) fa[i]=i; for(int i=1;i<=m;++i){ int u,v; scanf("%d%d",&u,&v); while(u!=fa[u]) u=fa[u]; while(v!=fa[v]) v=fa[v]; fa[u]=v; } for(int i=1;i<=k;++i){ int u,v; scanf("%d%d",&u,&v); while(u!=fa[u]) u=fa[u]; while(v!=fa[v]) v=fa[v]; if(u==v) printf("Yes\n"); else printf("No\n"); } return 0; }

優化:

我們可以發現大量的時間用在了遞迴尋找集合點的過程上

如果在每次尋找的過程中把集合中的點直接連在集合頭上,那麼下次尋找就可以大大縮短尋找時間

路徑合併:

一種基本的加速,有了路徑合併的才叫並查集

本題中加速後的的複雜度大致是O(klogn),這個不給出證明了,比較複雜

若此時將把7的集合合併進5的集合裡

#include<bits/stdc++.h>
#define maxn 1000010
using namespace std;

int n,m,k,fa[maxn];

int find(int x){
    int k=x;
    while(k!=fa[k]) k=fa[k];
    while(x!=fa[x]){
        int tmp=x;
        x=fa[x];
        fa[tmp]=k;
    }
    return k;
}

int main(){
    scanf("%d%d%d",&n,&m,&k);
    for(int i=1;i<=n;++i) fa[i]=i;
    for(int i=1;i<=m;++i){
        int u,v;
        scanf("%d%d",&u,&v);
        fa[find(u)]=find(v);
    }
    for(int i=1;i<=k;++i){
        int u,v;
        scanf("%d%d",&u,&v);
        u=find(u);
        v=find(v);
        if(u==v) printf("Yes\n");
        else printf("No\n");
    }
    return 0;
}

可以發現有明顯的加速

常見版本:

更為常見的版本通常是遞迴進行的,因為這樣方便(碼量少),而且經常用合併函式進行

集合合併也包括集合附帶屬性的合併,用函式會顯得比較簡潔

#include<bits/stdc++.h>
#define maxn 100010
using namespace std;

int n,m,k,fa[maxn];

int find(int x){
    if(x!=fa[x]) fa[x]=find(fa[x]);
    return fa[x];
}

void merge(int x,int y){
    int fa1=find(x),fa2=find(y);
    if(fa1==fa2) return;
    else fa[fa1]=fa2;
}

int main(){
    scanf("%d%d%d",&n,&m,&k);
    for(int i=1;i<=n;++i) fa[i]=i;
    for(int i=1;i<=m;++i){
        int u,v;
        scanf("%d%d",&u,&v);
        merge(u,v);
    }
    for(int i=1;i<=k;++i){
        int u,v;
        scanf("%d%d",&u,&v);
        int fa1=find(u),fa2=find(v);
        if(fa1==fa2) printf("Yes\n");
        else printf("No\n");
    }
    return 0;
}

關於並查集按順序連邊:

並查集只能合併集合不能取消,如果要求減邊,那麼先用並查集合併到最後的結果再反過來加邊即可。

如果按一定順序連邊就按照那個規則連就行了。

例題【洛谷P1111 修復公路】(按時間順序連邊)

#include<bits/stdc++.h>
#define maxn 100010
using namespace std;

int n,m,ans,fa[maxn];

struct node{
    int x,y,t;
}data[maxn];

bool cmp(node a,node b){
    return a.t<b.t;
}

int find(int x){
    if(x!=fa[x]) fa[x]=find(fa[x]);
    return fa[x];
}

void merge(int x,int y){
    int fa1=find(x),fa2=find(y);
    if(fa1==fa2) return;
    else{
        fa[fa1]=fa2;
        ++ans;
    }
}

int main(){
    scanf("%d%d",&n,&m);
    for(int i=1;i<=n;++i) fa[i]=i;
    for(int i=1;i<=m;++i) scanf("%d%d%d",&data[i].x,&data[i].y,&data[i].t);
    sort(data+1,data+m+1,cmp);
    for(int i=1;i<=m;++i){
        merge(data[i].x,data[i].y);
        if(ans==n-1){
            printf("%d",data[i].t);
            return 0;
        }
    }
    printf("-1");
    return 0;
}

關於連通塊:

並查集可以做連通塊的題目

目前連通塊的個數=最開始的連通塊數-並和次數

注意合併次數指兩個不同的集合合併

例題【洛谷P1197 星球大戰】(因為要減邊所以反過來做+求連通塊個數)

#include<bits/stdc++.h>
#define maxn 400010
using namespace std;

int n,m,k,ans,fa[maxn],pl[maxn];
bool v[maxn];

int head[maxn],edge_num;
struct{
    int to,nxt;
}edge[maxn];
void add_edge(int from,int to){
    edge[++edge_num].nxt=head[from];
    edge[edge_num].to=to;
    head[from]=edge_num;
}

int find(int x){
    if(x!=fa[x]) fa[x]=find(fa[x]);
    return fa[x];
}

void merge(int x,int y){
    int fa1=find(x),fa2=find(y);
    if(fa1==fa2) return;
    else fa[fa1]=fa2,--ans;
}

void Build(){
    for(int i=1;i<=n;++i) fa[i]=i;
    ans=n;
    for(int i=1;i<=n;++i)
        if(!v[i])
            for(int j=head[i];j;j=edge[j].nxt){
                int to=edge[j].to;
                if(!v[to]) merge(i,to);
            }
}

void Solve(int x){
    if(x==0) return;
    v[pl[x]]=false;
    for(int i=head[pl[x]];i;i=edge[i].nxt){
        int to=edge[i].to;
        if(!v[to]) merge(pl[x],to);
    }    
    int res=ans;
    Solve(x-1);
    printf("%d\n",res-x+1);
}

int main(){
    scanf("%d%d",&n,&m);
    for(int i=1;i<=m;++i){
        int u,v;
        scanf("%d%d",&u,&v);
        add_edge(u+1,v+1);
        add_edge(v+1,u+1);
    }
    scanf("%d",&k);
    for(int i=1;i<=k;++i)
        scanf("%d",&pl[i]),pl[i]+=1,v[pl[i]]=true;
    Build();
    int res=ans;
    Solve(k);
    printf("%d\n",res-k);
    return 0;
}

關於帶權並查集:

這些權值的處理可以根據並查集路徑壓縮的特質來處理

若是集合的權值,可以直接新建陣列仿照fa[ ]的合併進而合併即可

另外有一點特別神奇,點的權值處理可以在路徑壓縮是進行處理

例題【洛谷P1196 銀河英雄傳說

每在兩列合併時,都需要將該列所有的戰艦更新位置嗎?

這樣的話不就和最開始暴力“並查集”一樣了嗎。

我們可以發現,每列的戰艦x的位置可以通過已知fa[x]更新出來

loc[x]表示x戰艦的位置

只要進行一下兩種操作即可

1.每次合併,先把i列頭頭的位置更新出來,先處理i列頭頭的位置,i列頭頭的位置=j列的長度+1

2.我們規定只在路徑壓縮的時候更新該戰艦的位置,那麼第i列第x個戰艦的真實位置即loc[x]=loc[fa[x]]+loc[x]-1

這裡有點繞,理一理,當集合更新的後,loc[x]還是以fa[x]為艦首時的位置,而因為遞迴的緣故loc[fa[x]]已經更新完畢,所以loc[fa[x]]是正確的,loc[x]目前看來是錯誤的

而更新完loc[x]後,fa[x]變為j列的艦頭,loc[j列艦頭]=1,所以以後更新到x也不用擔心loc[ ]值的改動

為了使用fa[x]來處理loc所以並查集部分有變化

注意這題中合併要按順序,是i列放在j列後面

#include<bits/stdc++.h>
#define maxn 30010
using namespace std;

int T,x,y;
char ch;
int fa[maxn],siz[maxn],loc[maxn];

int find(int x){
    if(fa[x]==x) return x;
    int fath=find(fa[x]);
    loc[x]=loc[fa[x]]+loc[x]-1;
    fa[x]=fath;
    return fa[x];
}

int main(){
    scanf("%d",&T);
    for(int i=1;i<=maxn;++i) fa[i]=i,siz[i]=1,loc[i]=1; 
    for(int i=1;i<=T;++i){
        cin>>ch>>x>>y;
        int fa1=find(x),fa2=find(y);
        if(ch=='M') fa[fa1]=fa2,loc[fa1]=siz[fa2]+1,siz[fa2]+=siz[fa1];
        else{
            if(fa1!=fa2) printf("-1\n");
            else printf("%d\n",abs(loc[y]-loc[x])-1);
        }
    }
    return 0;
}

關於反集:

就是題目給定的規則,要好幾個並查集一起操作

例題1【洛谷P1892 團伙

關係整理(由於題目要求所以只需要連出友好線即可):

若p---敵--->q

p的敵人---友--->q的敵人

p的敵人---友--->q的朋友

q的敵人---友--->p的朋友

q的敵人---友--->p的敵人

若p---友--->q

p的朋友---友--->q的朋友

q的朋友---友--->p的朋友

把x+n的點當作x的敵人,那麼n+1~n*2都是敵人集合

#include<bits/stdc++.h>
using namespace std;
int father[10010]; int n,m,p,q,ans; char ch;
int find(int x) { if(father[x]!=x) father[x]=find(father[x]); return father[x]; }
int main(){ cin>>n>>m; for(int i=1;i<=2*n;i++) father[i]=i; for(int i=1;i<=m;i++){ cin>>ch>>p>>q; if(ch=='F') father[find(p)]=find(q); else{ father[find(n+p)]=find(q); father[find(n+q)]=find(p); } } for(int i=1;i<=n;i++) if(father[i]==i) ans++; cout<<ans; return 0; }

例題2【洛谷P2024 食物鏈

類似於例1,這次是3個集合互連。

#include<bits/stdc++.h>
#define maxn 100010
using namespace std;

int n,m,fa[maxn*3],ans=0;

int find(int x){
    if(fa[x]!=x) fa[x]=find(fa[x]);
    return fa[x];
}

int main(){
    scanf("%d%d",&n,&m);
    for(int i=1;i<=3*n;++i) fa[i]=i;
    for(int i=1;i<=m;++i){
        int opt,x,y;
        scanf("%d%d%d",&opt,&x,&y);
        if(x>n||y>n){++ans;continue;}
        int fa1=find(x),fa2=find(y),fa3=find(x+n),fa4=find(y+n),fa5=find(x+n+n),fa6=find(y+n+n);
        //12同類 34獵物 56天敵
        if(opt==1){    
            if(fa1==fa4||fa1==fa6) ++ans;
            else fa[fa1]=fa[fa2],fa[fa3]=fa[fa4],fa[fa5]=fa[fa6];
        }
        else{
            if(x==y){++ans;continue;}
            if(fa1==fa2||fa4==fa1) ++ans;
            else fa[fa6]=fa[fa1],fa[fa3]=fa[fa2],fa[fa5]=fa[fa4];
        }
    }        
    printf("%d",ans);
}