1. 程式人生 > 其它 >圖論專題-學習筆記:並查集

圖論專題-學習筆記:並查集

目錄

1.概述

並查集是一種資料結構,用於圖論之中(更多時候用於樹),通常用來維護一張無向圖內 \(O(1)\) 判斷兩個點是否在同一個連通塊內,有很多的用法,擴充套件性也比較高。

2.模板

下面還是通過一道模板講解並查集的用法。

link

我們假設這 4 個元素分別表示 4 個人。假設每個人都會在一個群內,第 \(i\) 個人的群主表示為 \(fa_i\) (其實如果抽象成一棵樹,就是 \(i\) 的父親節點)

初始時,每一個人單獨在一個群內,則令 \(fa_i=i\)

(這一步是並查集的初始化,非常重要,否則所有人的群主/每個點的祖先就都變成了不存在的 0 號節點,後續操作就會出現很多奇奇怪怪的錯誤)

看操作 1 :問第 1 個人與第 2 個人在不在同一個群內。

顯然不在了,大家各管各的,輸出 N

操作 2:合併第 1 個人與第 2 個人所在的群。

如何合併呢?此時兩個人分屬於不同的群,現在要將兩個人合成一個群,那麼我們直接把 2 的群主改成 1 不就可以了?即令 \(fa_2=1\) 。從樹的角度看,初始時每一個點都是單獨的根節點,現在在 1 和 2 之間連一條邊,生成一棵新樹,同時令 1 為根節點。

接下來又問 1 與 2 在不在一個群內。

這一步是並查集的判斷操作,判斷時我們發現,\(fa_1=fa_2\)

,那麼他們在一個群內,輸出 Y

此時群組情況如下所示:

1    3    4 
 \
  2

接下來合併 3 4.仿照上述步驟,令 \(fa_4=3\)

這裡說明一下,其實令 \(fa_3=4\) 也是可以的,看個人習慣,本質上並沒有什麼區別,畢竟都在一個群裡面,誰是群主都沒有問題。

群組情況 :

1     3
 \     \
  2     4

下一個操作詢問 1 4 在不在一個群內,\(fa_1=1\),\(fa_3=3\) ,群主不一樣,不在一個群內,輸出 N

下一步合併 2 3,此時。。。。。。

1 不同意了!如果我們修改 \(fa_3=2\) ,沒有什麼問題(具體為什麼見下文),但是萬一程式讓 \(fa_2=3\)

,那麼 1 就不同意了:“ 2 明明在我的群,憑什麼到你的群去了?”怎麼辦?

既然 2 搞不定,我們直接找最高群主 1 談談,直接令 \(fa_1=3\) 就可以解決了。從樹的角度看,就是改變根節點的父親。

群主情況:

     3
    / \
   1   4
    \
     2

接下來問 1 4 在不在一個群內, \(fa_1=fa_4\) ,在一個群內,輸出 Y

然而此時,如果再來一個詢問:詢問 2 4在不在一個群內,要怎麼辦呢?

肉眼可見, 2 4 在一個群內,應該輸出 Y ,然而我們上面的判斷都是根據 \(fa_i\) 是否相等判斷的,此時並不相等,不就出問題了嗎?

為了解決這個問題,方法是:找到最高群主也就是根節點。

看圖,2 的群主是 1 ,而 1 的群主是 3 ,這樣 2 的最高群主不就是 3 了嗎?4 的最高群主也是 3 ,在同一個群內。

如果此時又來一個問題:判斷現在有幾個群要怎麼辦呢?

由於每一個群都有最高群主,且只有最高群主的群主是自己(為什麼?),那麼只要統計出有幾個 \(i \in [1,n]\) 使得 \(fa_i==i\) 即可。也就是找出每一棵樹的根節點。

完美解決~~~

程式碼實現:

  1. 初始化:
    這裡直接一遍 for 即可。
    for(int i=1;i<=n;i++) fa[i]=i;
    
  2. 查詢某個節點的最高群主也就是根節點。
    遞迴查詢即可,程式碼如下:
    int gf(int x) {return (fa[x]==x)?x:gf(fa[x]);}
    
    其中, \(fa_x==x\) 表示找到根節點了(根節點的父親就是根節點,初始化時已經這樣操作過了),找到返回 \(x\) ,否則遞迴查詢。
    然而你以為這樣就結束了嗎?看下圖:
    1-2-3-4-5-6-7-......-op(某極大的數字,比如說 1e5 1e6 之類的)
    
    這樣,查詢一次 \(fa_{op}\) 就要 \(O(1e5或1e6)\) 的時間複雜度,多查幾次不就 TLE 了?為了解決這個問題,我們引入一個優化:路徑壓縮。
    路徑壓縮的目的就是為了解決上面的問題,即在查詢某節點的祖先的時候,我們將一路上查詢的所有節點的父親全部連到根節點,也就是變成下圖:
    1
    | \ \ \   \
    2  3 4 ... op
    
    這樣,查詢複雜度直接降至 \(O(1)\) ,大大優化查詢複雜度。
    而程式碼只需要這樣改:
    int gf(int x) {return (fa[x]==x)?x:fa[x]=gf(fa[x])}
    
  3. 合併操作:
    找到根節點合併即可。
    void hb(int x,int y) {if(gf(x)!=gf(y)) fa[fa[x]]=fa[y];}
    //由於加入了路徑壓縮所以不會有問題。
    
  4. 查詢操作
    判斷兩個點的祖先是否相同即可。
    cout<<((gf(x)==gf(y))?'Y':'N')<<"\n";
    //實測這裡三目運算子外面不加括號會CE
    
  5. 統計樹的數量
    根據上述所講,一遍 for 即可。
    for(int i=1;i<=n;i++) if(fa[i]==i) ans++;
    

程式碼:

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

const int MAXN=1e4+10;
int n,m,fa[MAXN];

int gf(int x) {return (fa[x]==x)?x:fa[x]=gf(fa[x]);}
void hb(int x,int y) {if(gf(x)!=gf(y)) fa[fa[x]]=fa[y];}

int read()
{
	int sum=0,fh=1;char ch=getchar();
	while(ch<'0'||ch>'9') {if(fh=='-') fh=-1;ch=getchar();}
	while(ch>='0'&&ch<='9') {sum=(sum<<3)+(sum<<1)+ch-'0';ch=getchar();}
	return sum*fh;
}

int main()
{
	n=read();m=read();
	for(int i=1;i<=n;i++) fa[i]=i;
	for(int i=1;i<=m;i++)
	{
		int x,y,z;
		z=read();x=read();y=read();
		if(z==1) hb(x,y);
		else cout<<((gf(x)==gf(y))?'Y':'N')<<"\n";
	}
	return 0;
}

如果你看懂了上述程式碼,那麼恭喜你,學會了並查集的基礎操作!

接下來,你將會見到各路例題以及並查集的各種神奇用法。

3.例題

題單:

1.入門題:

[BOI2003]團伙

這道題是一道練手題,思維與演算法難度都不高,就是一個並查集。

首先處理讀入資料,將是朋友的人合併,是敵人的人先存在 \(v\) 數組裡面(使用 vector ,不會的請自行查百度)。

然後根據我的敵人的敵人是我的朋友,三重迴圈再合併一次即可。

程式碼(篇幅有限,只放部分程式碼,下同):

const int MAXN=1000+10;
int n,m,ans,fa[MAXN];
vector<int>v[MAXN];

int main()
{
	n=read();m=read();
	for(int i=1;i<=n;i++) fa[i]=i;
	for(int i=1;i<=m;i++)
	{
		char op;int p,q;
		cin>>op;p=read();q=read();
		if(op=='F') hb(p,q);
		else
		{
			v[p].push_back(q);
			v[q].push_back(p);
		}
	}
	for(int i=1;i<=n;i++)
		for(int j=0;j<v[i].size();j++)
			for(int k=0;k<v[v[i][j]].size();k++) hb(i,v[v[i][j]][k]);
	for(int i=1;i<=n;i++) if(gf(i)==i) ans++;
	cout<<ans<<"\n";
	return 0;
}

2.與別的演算法結合:

搭配購買

賣雲朵可還行

這道題首先,要同時買兩朵雲的操作就很像並查集,因此我們可以考慮使用並查集來求解(通常題目當中出現了 “同時....” / “一起....” 等字眼都有可能是並查集)。

然後,又看到要買雲朵,每種雲朵只有一份,錢數又是有限的,濃濃的透露出 0/1 揹包 的氣息。

因此,本道題的演算法為:並查集 + 0/1 揹包

首先將必須同時購買的物品合併,然後將雲朵組成的一棵棵樹中所有節點的 \(c_i,d_i\) 全部加起來,放到新陣列 \(money_j,value_j\) 中,跑一遍 0/1 揹包即可求解。

程式碼:

const int MAXN=1e4+10;
int n,m,w,c[MAXN],d[MAXN],money[MAXN],value[MAXN],fa[MAXN],ys[MAXN],tmp,f[MAXN];

int main()
{
	n=read();m=read();w=read();
	for(int i=1;i<=n;i++) {c[i]=read();d[i]=read();fa[i]=i;}
	for(int i=1;i<=m;i++)
	{
		int x,y;
		x=read();y=read();
		hb(x,y);
	}//合併操作
	for(int i=1;i<=n;i++) if(gf(i)==i) ys[i]=++tmp;//處理出最後物品個數
	for(int i=1;i<=n;i++)
	{
		money[ys[fa[i]]]+=c[i];
		value[ys[fa[i]]]+=d[i];
	}//算出 money[i] 和 value[i]
	for(int i=1;i<=tmp;i++)
		for(int j=w;j>=money[i];j--)
			f[j]=Max(f[j],f[j-money[i]]+value[i]);// 0/1 揹包
	cout<<f[w]<<"\n";
	return 0;
}

關押罪犯

這道題可以使用二分圖來解,那麼如何使用並查集來解呢?

由於要想辦法讓最大值最小,所以使用二分?

No,這道題不需要使用二分,而是貪心即可。想一想,我們只需要儘量將怒氣值大的罪犯組拆掉不就好了,碰到第一個不能拆掉的就是答案。

因此,這道題的演算法為:並查集 + 貪心。

首先,按照怒氣值從大到小排序一遍。

然後,我們令 \(d_i\) 表示 \(i\) 的第一個會與他發生摩擦的人,初始化為 0 。

接下來處理資料。假設此時我們要處理 \(a\)\(b\) 發生摩擦,怒氣值為 \(c\) 的資訊:

  1. 如果此時 \(a,b\) 已經在一起了,直接輸出 \(c\) ,結束程式。
  2. 否則,他們不在一起,以 \(a\) 為例:如果 \(d_a=0\),說明此時沒有人與他有摩擦,則 \(d_a=b\) ,否則說明已經有人與他有摩擦了,由於只有兩個監獄,那麼合併 \(b,d_a\) 即可。
  3. 正確性:顯然要將 \(d_a,a\) 拆掉。假設 \(b,d_a\) 之間的怒氣值(如果沒有摩擦為 0)為 \(c'\) ,根據之前的排序,必然有 \(c'<c\),那麼顯然合併 \(b,d_a\) 比合並 \(a,b\) 更優。

程式碼:

const int MAXN=20000+10,MAXM=100000+10;
int n,m,fa[MAXN],d[MAXN];
struct node
{
	int a,b,c;
}a[MAXM];

int main()
{
	n=read();m=read();
	for(int i=1;i<=m;i++) {a[i].a=read();a[i].b=read();a[i].c=read();}
	for(int i=1;i<=n;i++) fa[i]=i;
	sort(a+1,a+m+1,cmp);//自行打 cmp 函式
	for(int i=1;i<=m;i++)
	{
		if(gf(a[i].a)!=gf(a[i].b))
		{
			if(!d[a[i].a]) d[a[i].a]=a[i].b;
			else hb(d[a[i].a],a[i].b);
			if(!d[a[i].b]) d[a[i].b]=a[i].a;
			else hb(d[a[i].b],a[i].a);
		}
		else {cout<<a[i].c<<"\n";return 0;}
	}
	cout<<"0\n";
	return 0;
}

3.考思維的題:

[JSOI2008]星球大戰

正常的並查集支援合併操作,但是不支援刪除操作,然而這道題的所有操作不是合併就是刪除,那麼要怎麼辦呢?

既然並查集支援合併操作,那麼我們想辦法支援合併操作就好了唄!

我們將打擊星球的順序 倒過來操作 ,將其視作 重建星球 ,然後每重建一個合併一次,最後處理連通塊個數不就好了qwq。

關於如何處理連通塊個數,這裡提供一個 \(O(1)\) 的思想:

  1. 初始化 \(sum=n\) ,表示有 \(n\) 個連通塊。
  2. 每合併兩個點,\(sum--\)
  3. 注意合併的兩個點不能在同一個連通塊內。

注意:最後輸出答案時要逆序輸出,沒有重建的星球不算在答案內,只有當兩個星球全部重建完成才能合併。這裡我使用 \(book\) 陣列標記是否合併完成。

程式碼:

const int MAXN=4e5+10;
int n,fa[MAXN],m,k,des[MAXN],ans[MAXN],sum;
bool book[MAXN];
vector<int>v[MAXN];

void hb(int x,int y) {if(gf(x)!=gf(y)) {sum--;fa[fa[x]]=fa[y];}}
//注意 sum--;,gf(x)略

int main()
{
	n=read();m=read();
	for(int i=0;i<n;i++) fa[i]=i;
	for(int i=1;i<=m;i++)
	{
		int x=read();int y=read();
		v[x].push_back(y);v[y].push_back(x);
	}
	k=read();sum=n;
	for(int i=1;i<=k;i++) book[des[i]=read()]=1;
	for(int i=0;i<n;i++)
		if(!book[i])
			for(int j=0;j<v[i].size();j++)
				if(!book[v[i][j]]) hb(i,v[i][j]);
	for(int zzh=k;zzh>=1;zzh--)
	{
		ans[zzh]=sum-zzh;
		book[des[zzh]]=0;
		for(int i=0;i<v[des[zzh]].size();i++)
			if(!book[v[des[zzh]][i]]) hb(v[des[zzh]][i],des[zzh]);
	}
	ans[0]=sum;
	for(int i=0;i<=k;i++) cout<<ans[i]<<"\n";
	return 0;
}

[IOI2014]game 遊戲

別看這道題是 IOI 的題目,其實想通了真的非常簡單。你看評級都是綠色

首先,為了讓梅玉只有到最後一個詢問才能判斷是否連通,這裡就有一種思路:我們構造某一張圖使得這張圖連通,且最後一個詢問問的點 \(x,y\) 會連一條邊,這裡記作 \(x->y\) ,但是一旦我們刪去了 \(x->y\) 整張圖就會裂成兩個集合,換句話說, \(x->y\) 是這一張圖的橋/割邊。(橋/割邊的定義:如果刪除某條 \(u->v\) 的邊後途中連通塊個數增加,那麼 \(x->y\) 是這張圖的橋/割邊)

為什麼正確呢?如果 \(x->y\) 是這張圖的割邊,那麼在倒數第二個詢問中梅玉依然不能判斷整張圖是否連通(有 2 個連通塊),此時她必須再問一次才能確定圖是否連通。

因此思路就很明確了,我們對於某個詢問 \(a->b\) ,如果合併 \(a,b\) 以後 \(x,y\) 在一個連通塊內,這顯然不是我們想要的操作,此時不能合併 \(a,b\) ;否則,合併 \(a,b\) 。最後不要忘記輸出最後一條邊是否連通,這裡輸出 01 都可以。不過根據我們構造的方案,最好輸出 1。(實測 0 也能夠通過)

當然,本部落格只是提供了其中一種思路,具體別的思路也請各位發現然後解決。

所以你看,IOI的題也不見得非常難

程式碼:

const int MAXN=1500+10;
int n,m,fa[MAXN],q1[MAXN*MAXN],q2[MAXN*MAXN];

int main()
{
	n=read();m=n*(n-1)/2;
	for(int i=1;i<=m;i++) {q1[i]=read();q2[i]=read();}
	for(int i=1;i<=n;i++) fa[i]=i;
	for(int i=1;i<m;i++)
	{
		int x=q1[i],y=q2[i];
		int fx=gf(x),fy=gf(y);
		int x1=q1[m],y1=q2[m];
		int fx1=gf(x1),fy1=gf(y1);
		if(fx>fy) swap(fx,fy);
		if(fx1>fy1) swap(fx1,fy1);
		if(fx==fx1&&fy==fy1) cout<<"0\n";//判斷 最後兩個點 合併之後是否在同一集合內,在就不合並
		else
		{
			cout<<"1\n";
			hb(x,y);
		}//否則合併
	}
	cout<<"1\n";//輸出 1 也可以
	return 0;
}

4.二維轉一維:

[USACO14JAN]Ski Course Rating G

這道題也是一道與貪心相結合的題目。

首先,我們需要將二維的地圖轉成一維:對於 \((i,j)\) (第 \(i\) 行第 \(j\) 列,下同),我們將其在一維編號為 \((i-1)*m+j\) (注意不是 \(n\)),然後對於相鄰的兩個二維的點連一條邊,將點壓成一維後按照邊權從小到大排序。

然後,對於每一條邊:

  1. 首先,如果這條邊連著的兩個點在一個連通塊內, continue;
  2. 然後,如果兩棵子樹 \(size\) 和大於等於 \(t\),那麼 \(ans+=c*(cnt_i)\)\(cnt_i\) 見下文)。 其中, \(i\) 為兩個子樹的編號,且 \(size_i<t\) 。為什麼大於 \(t\) 的就不能統計了呢?因為之前 \(size_i<t\) 的時候已經統計過一次,此時又統計就會造成浪費,並且即使有新的起點加入,也已經被統計過,沒有意義了。
  3. 而後合併兩個點,如果這兩個點內有起點,我們就將增加的起點個數存在 \(cnt_i\) 裡面。

程式碼:

const int MAXN=500+10;
int t,n,m,a[MAXN][MAXN],b[MAXN][MAXN],fa[MAXN*MAXN],size[MAXN*MAXN],tmp,cnt[MAXN*MAXN];
typedef long long LL;
LL ans;//不開long long見祖宗
struct node
{
	int a,b,c;
}dis[2*MAXN*MAXN];

int main()
{
	//讀入略,a=地圖,b=是否為起點
	for(int i=1;i<=n;i++)
		for(int j=1;j<=m;j++)
		{
			if(j!=m)
			{
				tmp++;
				dis[tmp].a=turn(i,j);dis[tmp].b=turn(i,j+1);dis[tmp].c=abs(a[i][j]-a[i][j+1]);
			}
			if(i!=n)
			{
				tmp++;
				dis[tmp].a=turn(i,j);dis[tmp].b=turn(i+1,j);dis[tmp].c=abs(a[i][j]-a[i+1][j]);
			}
			if(b[i][j]==1) cnt[turn(i,j)]=1;
		}//連邊操作
	for(int i=1;i<=n*m;i++) fa[i]=i,size[i]=1;
	sort(dis+1,dis+tmp+1,cmp);
	for(int i=1;i<=tmp;i++)
	{
		int fx=gf(dis[i].a),fy=gf(dis[i].b);
		if(fx==fy) continue;
		if(size[fy]+size[fx]>=t)
		{
			if(size[fy]<t) ans+=(LL)dis[i].c*cnt[fy];
			if(size[fx]<t) ans+=(LL)dis[i].c*cnt[fx];
		}
		if(size[fx]>size[fy]) swap(fx,fy);
		fa[fx]=fy;
		size[fy]+=size[fx];cnt[fy]+=cnt[fx];//注意更新答案
	}
	cout<<ans<<"\n";
	return 0;
}

小 D 的地下溫泉

這道題類似,首先二維轉一維不說,然後如果兩個相鄰點都是泉水合並。

詢問操作:直接求出詢問點所在樹的 \(size\) 即可,求個最大值。

有個坑點:當心所有點都是土地,此時我們需要輸出 1

修改操作:泉水改土地直接修改地圖然後 \(size--\) 即可。土地改泉水時我們需要新開一個點,改變地圖後令新開的點 \(fa=自己,size=1\) ,然後四周合併一遍即可。注意不能直接在原點上修改,否則會有很多奇奇怪怪的問題。

程式碼:

const int MAXN=1e6+10;
int n,m,fa[MAXN<<1],size[MAXN<<1],q,ys[MAXN<<1],tmp;
int Next[4][2]={{0,1},{1,0},{0,-1},{-1,0}};
char a[MAXN];
//gf(),turn()略
void hb(int x,int y) {if(gf(x)!=gf(y)) {if(size[fa[y]]>size[fa[x]]) swap(x,y);size[fa[y]]+=size[fa[x]];fa[fa[x]]=fa[y];}}

int main()
{
	n=read();m=read();tmp=n*m;
	for(int i=1;i<=n;i++)
		for(int j=1;j<=m;j++)
			cin>>a[turn(i,j)];
	for(int i=1;i<=n*m;i++) {fa[i]=i;size[i]=((a[i]=='.')?1:0);}
	for(int i=1;i<=n;i++)
		for(int j=1;j<=m;j++)
			ys[turn(i,j)]=turn(i,j);
	for(int i=1;i<=n;i++)
		for(int j=1;j<=m;j++)
		{
			if(i!=1)
			{
				if(a[turn(i,j)]=='.'&&a[turn(i-1,j)]=='.') hb(turn(i,j),turn(i-1,j));
			}
			if(j!=1)
			{
				if(a[turn(i,j)]=='.'&&a[turn(i,j-1)]=='.') hb(turn(i,j),turn(i,j-1));
			}
		}
	q=read();
	for(int i=1;i<=q;i++)
	{
		int op,w;
		op=read();w=read();
		if(op==1)
		{
			int flag=1,ans=0;
			for(int j=1;j<=w;j++)
			{
				int x,y;
				x=read();y=read();
				if(a[turn(x,y)]=='.'&&size[gf(ys[turn(x,y)])]>ans)
				{
					ans=size[gf(ys[turn(x,y)])];
					flag=j;
				}
			}
			cout<<flag<<"\n";
		}
		else
		{
			for(int j=1;j<=w;j++)
			{
				int x,y;
				x=read();y=read();
				if(a[turn(x,y)]=='.')
				{
					a[turn(x,y)]='*';
					size[gf(ys[turn(x,y)])]--;
				}
				else
				{
					ys[turn(x,y)]=++tmp;
					a[turn(x,y)]='.';fa[ys[turn(x,y)]]=ys[turn(x,y)];size[ys[turn(x,y)]]=1;
					for(int k=0;k<4;k++)
					{
						int tx=x+Next[k][0];
						int ty=y+Next[k][1];
						if(tx>0&&ty>0&&tx<=n&&ty<=m&&a[turn(tx,ty)]=='.') hb(ys[turn(x,y)],ys[turn(tx,ty)]);
					}
				}
			}
		}
	}
	return 0;
}

5.擴充套件域並查集&邊帶權並查集:

[NOI2001]食物鏈

這道題我是用擴充套件域求解的,各位讀者可以嘗試使用邊帶權求解 (其實是我不會)

擴充套件域的原理:擴大並查集的上限來滿足題目需要。

這道題,我們擴大並查集上線至 \(3*n\) ,由於不知道哪個動物在哪個組,令 \(1...n\) , \(n+1...2*n\) , \(2*n+1...3*n\)為三個組,\(x,x+n,x+2*n\) 表示同一個動物,如果是組內元素同祖先,表示他們是同類關係;如果是跨組同祖先,表示捕食關係,本題規定如果 \(gf(x)==gf(y+n)||gf(x+n)==gf(y+n+n)||gf(x+n+n)==gf(y)\) 那麼 \(x\)\(y\)

如何判定一句話與前面的真話是矛盾的呢?

如果一句話告訴你 \(x,y\) 是同類,但是事實是 \(x\)\(y\) 或者 \(y\)\(x\) ,那麼是假的,否則是真的,合併 \((x,y)\),\((x+n,y+n)\),\((x+n+n,y+n+n)\)。注意都要合併,否則傳遞不及時可能會導致一些錯誤。

如果一句話告訴你 \(x\)\(y\) ,但是事實是 \(x,y\) 是同類或者 \(y\)\(x\) ,那麼是假的,否則是真的,合併 \((x,y+n)\),\((x+n,y+n+n)\),\((x+n+n,y)\)

然後就做完了。如果實在看不懂我的題解,還可以看一看 luogu 題目裡面的題解,或許能夠更好的理解。

程式碼:

const int MAXN=5e4+10;
int n,k,fa[MAXN*3],ans=0;

int main()
{
	n=read();k=read();
	for(int i=1;i<=n*3;i++) fa[i]=i;
	for(int i=1;i<=k;i++)
	{
		int op,x,y;
		op=read();x=read();y=read();
		if(x>n||y>n) ans++;
		else if(op==2&&x==y) ans++;
		else
		{
			if(op==1)
			{
				if(gf(x)==gf(y+n)||gf(x+n)==gf(y)) ans++;
				else hb(x,y),hb(x+n,y+n),hb(x+n+n,y+n+n);
			}
			else
			{
				if(gf(x)==gf(y)||gf(y)==gf(x+n)) ans++;
				else hb(x,y+n),hb(x+n,y+n+n),hb(x+n+n,y);
			}
		}
	}
	cout<<ans<<"\n";
	return 0;
}

[NOI2002]銀河英雄傳說

這道題使用邊帶權並查集來做。注意這一題的合併具有一定的方向性。

首先,我們令 \(front_i\) 表示 \(i\) 到根節點(領頭羊)的距離,初始化為 0。\(num_i\) 表示以 \(i\) 為根節點的樹的大小,初始化為 1。

然後,由於戰隊是一條鏈,但是我們路徑壓縮之後變成了一棵樹,因此在路徑壓縮時先要加入這樣一句話:

front[x]+=front[fa[x]]

保證 \(front\) 更新及時,然後才能路徑壓縮。這裡又要注意,要先計算出 \(gf(fa[x])\) 並且存下之後才能更新,否則資料不夠及時。

合併操作的時候,假設我們將 \(x\) 接到 \(y\) 後面,此時令 \(fx=gf(x),fy=gf(y)\) ,要讓 \(fa_{fx}=num_{fy}\) ,因為此時此刻 \(x\) 不是祖先了,需要更新 \(front_{fx}\) ,不過不用著急將更新下傳到孩子節點,因為路徑壓縮會幫你做好的qwq。

此時,由於 \(fy\) 後面加入了 \(num_{fx}\) 個節點,需要更新 \(num_{fy}+=num{fx}\) ,然後清零 \(num_{fx}\)

統計答案時,不在一個集合內輸出 -1 ,否則輸出 \(|front_{x}-front_{y}|-1\) ,具體為什麼請各位讀者思考。

程式碼:

const int MAXN=30000+10;
int t,fa[MAXN],front[MAXN],num[MAXN];

int gf(int x)
{
	if(fa[x]==x) return x;
	int f=gf(fa[x]);
	front[x]+=front[fa[x]];
	return fa[x]=f;
}

int main()
{
	t=read();
	for(int i=1;i<=30000;i++) fa[i]=i,front[i]=0,num[i]=1;
	for(int i=1;i<=t;i++)
	{
		char ch;int x,y;
		cin>>ch;x=read();y=read();
		if(ch=='M')
		{
			int fx=gf(x);
			int fy=gf(y);
			if(fx!=fy)
			{
				front[fx]=num[fy];
				num[fy]+=num[fx];
				num[fx]=0;
				fa[fx]=fy;
			}
		}
		else
		{
			if(gf(x)!=gf(y)) cout<<"-1\n";
			else cout<<abs(front[x]-front[y])-1<<"\n";
		}
	}
	return 0;
}

[CEOI1999]Parity Game

這道題兩種做法都可以,不過個人認為擴充套件域並查集更好想也更好寫。

將並查集容量擴大 2 倍,如果奇偶性相同則合併 \((x,y),(x+n,y+n)\),否則合併 \((x,y+n),(x+n,y)\) 。如果兩個點已經在同一個集合內,仿照上例直接判斷即可。

考慮到 \(n\) 很大,\(m\) 很小,需要先離散化每一個點。(不會離散化自行百度)

程式碼:

const int MAXN=1e5+10;
int n,m,a[MAXN],fa[MAXN],tmp,l[MAXN],r[MAXN],q[MAXN];

int main()
{
	n=read();m=read();
	for(int i=1;i<=m;i++)
	{
		string str;
		l[i]=read();r[i]=read();cin>>str;
		q[i]=(str=="odd")?1:0;
		a[++tmp]=l[i]-1;a[++tmp]=r[i];//注意存的是l[i]-1,這裡有字首和的思想
	}
	sort(a+1,a+tmp+1);
	n=unique(a+1,a+tmp+1)-a-1;//離散化
	for(int i=0;i<=(n<<1);i++) fa[i]=i;
	for(int i=1;i<=m;i++)
	{
		int x=lower_bound(a+1,a+n+1,l[i]-1)-a;
		int y=lower_bound(a+1,a+n+1,r[i])-a;//找到離散化的點
		//非C++選手請自行打二分,C++選手不懂得查百度
		if(q[i]==1)
			if(gf(x)==gf(y)||gf(x+n)==gf(y+n)) {cout<<i-1<<"\n";return 0;}
			else hb(x,y+n),hb(x+n,y);
		else
			if(gf(x+n)==gf(y)||gf(x)==gf(y+n)) {cout<<i-1<<"\n";return 0;}
			else hb(x,y),hb(x+n,y+n);
	}
	cout<<m<<"\n";
	return 0;
}

4.總結

相信做完上述這 一些例題後,各位都對並查集有了一定的瞭解。不過這些只是並查集的初等應用,並查集還有很多高階版本,比如可持久化並查集。這裡不講這些,太高深 且作者本人不會。並查集很多時候用於圖論之中,或者是判斷是否在同一個集合內。