[算法学习笔记] 并查集

提示:本文并非并查集模板讲解,是在模板基础上的进一步理解以及拓展。

Review

并查集可以用来维护集合问题。例如,已知 \(a,b\) 同属一个集合,\(b,c\) 同属一个集合。那么 \(a,b,c\) 都属一个集合。

并查集分为 合并,查询 操作。定义 \(fa_i\) 表示点 \(i\) 的父亲。为了降低复杂度,在 find 操作向上递归查祖先时我们同步将 \(fa_i\) 更改为 \(i\) 的祖先。这就是所谓路径压缩。

对于合并,为了方便直接合并即可。当然也可以按秩合并优化,虽然我认为优化效果不大。

上述是普通并查集的基本操作。

朴素并查集例题

我们来看一道例题。

JSOI2008 星球大战

Description

共有 \(n\) 个点,\(m\) 个链接关系,一共会进行 \(k\) 次操作,每次操作删除一个点 \(a_i\),你需要给出每次删除点 \(a_i\) 后连通块数量

注意到维护连通块,考虑并查集。

但是并查集对于删点操作不好实现,“正难则反”是算法竞赛一个重要的思想。我们可以考虑建点。

具体地,初始计算出所有的操作都进行完后还剩下的连通块数量。然后逆序处理每个操作,对于每个操作,计算出新建该点后会减少几个连通块。这也就要求我们预处理每个点直接相连的点,当然这也很简单。

需要注意,每次新建一个点,连通块数量都会先加一。

代码
#include <bits/stdc++.h>
using namespace std;
typedef pair<int,int> PAIR;
const int N = 1000010;
vector <PAIR> Edge;
int n,m,k;
int banned[N];
int ans;
int p[N];
int fa[N],dist[N];
int died[N];
vector <int> kkk[N];
int find(int x)
{
	if(fa[x] == x) return x;
	fa[x] = find(fa[x]);
	return fa[x];
}
void Init()
{
	for(int i=0;i<=n;i++)
	{
		fa[i] = i;
		dist[i] = 1;
	}
}
void merge(int i,int j)
{
	int x = find(i),y = find(j);
	if(x == y) return;
	if(dist[x] <= dist[y]) fa[x] = fa[y];
	else fa[y] = fa[x];
	if(dist[x] == dist[y] && x != y) dist[y] ++;
}
int main()
{
//	freopen("input.txt","r",stdin);
	ios::sync_with_stdio(false);
	cin.tie(0);
	cout.tie(0);
	cin>>n>>m;
	for(int i=0;i<m;i++)
	{
		int x,y;
		cin>>x>>y;
		Edge.push_back({x,y});
		Edge.push_back({y,x});
		kkk[x].push_back(y);
		kkk[y].push_back(x);
	}
	ans = n;
	cin>>k;
	Init();
	for(int i=1;i<=k;i++)
	{
		int x;
		cin>>x;
		died[i] = x;
		banned[x] = 1;
	}
	ans = n-k;
	for(int t=0;t<Edge.size();t++)
	{
		int i = Edge[t].first,v = Edge[t].second;
		if(banned[i]) continue;
		if(banned[v]) continue;
		if(find(i) == find(v)) continue;
		merge(i,v);
		ans --;
	}
	p[k+1] = ans;
	for(int t=k;t>=1;t--)
	{
		ans ++;
		banned[died[t]] = 0;
		for(auto v:kkk[died[t]])
		{
			int i = died[t];
			if(banned[i]) continue;
			if(banned[v]) continue;
			if(find(i) == find(v)) continue;
			merge(i,v);
			ans --;
	}
		p[t] = ans;
	}
	for(int i=1;i<=k+1;i++) cout<<p[i]<<endl;
	return 0;
}

扩展域并查集

并查集的传递性非常强大,对于普通的传递关系问题,并查集可以轻松解决。但是,对于有种类关系的,比如"敌人的敌人是朋友” 此类关系又该如何维护呢?

这里就需要“扩展域并查集”了,它的基本思想,是将 1 个点拆分成若干虚点,通过合并虚点维护各点间的关系。

相比起带权并查集,优点在于仅用到传统的并查集;缺点在于 空间复杂度 为 点数 $\times $ 状态数,而且需要全面考虑体现在扩展域上的所有等价关系。这种“拆点”思想是非常美妙且常用的思想,广泛应用于各种算法/习题。

我们来看几道例题。

拆分两域 敌人朋友模型

这里的“敌人朋友模型” 指维护“敌人的敌人是朋友”。一般将每个点拆分成两个域,敌人域和朋友域。

模板 BOI2003 团伙
Description

现在有 \(n\) 个人,他们之间有两种关系:朋友和敌人。我们知道:

  • 一个人的朋友的朋友是朋友
  • 一个人的敌人的敌人是朋友

现在要对这些人进行组团。两个人在一个团体内当且仅当这两个人是朋友。请求出这些人中最多可能有的团体数。

不难发现,本题的关键在于维护 “敌人的敌人是朋友” 这种关系。如果没有这层限制,那本题就是朴素的并查集模板题。

实际上,我们只需要开两倍并查集。对于 \(\forall x,y\) ,若 \(x,y\) 是朋友,合并 \(x,y\) 即可。这是普通并查集操作。反之,若 \(x,y\) 是敌人,分别合并 \(x,y+n\),\(x+n,y\) 即可。这样我们就解决了问题。

上述操作,我们将每个节点 \(i\) 拆分成两个域,敌人域和朋友域。

接下来我们将通过画图,来解释这样的合并方式是如何工作的。

模拟样例:已知 \(1,2\) 是敌人关系,\(2,4\) 是敌人关系。按照要求,\(1,4\) 应是朋友关系。\(n=4\)
image

不难发现,\(4\) 通过敌人 \(2\)\(2\) 与敌人 \(1\)\(4\)\(1\) 在同一种类里相连,朋友关系。这就是最简单的种类并查集的工作原理。

这样,我们就解决了本题。

代码
#include <bits/stdc++.h>
using namespace std;
const int N = 100010;
int n,m;
int fa[N];
int dist[N];
int ans = 0;
vector <int> Edge;
void Init()
{
	for(int i=1;i<=n*2;i++) 
	{
		fa[i] = i;
		dist[i] = 1;
	}
}
int find(int x)
{
	if(x == fa[x]) return x;
	fa[x] = find(fa[x]);
	return fa[x];
}
void merge(int i,int j)
{
	int x = find(i),y = find(j);
	if(x == y) return;
	if(dist[x] < dist[y]) fa[x] = fa[y];
	else fa[y] = fa[x];
	if(dist[x] == dist[y] && x != y) dist[y] ++; 
}
int main()
{
//	freopen("input.txt","r",stdin);
	ios::sync_with_stdio(false);
	cin.tie(0);
	cout.tie(0);
	cin>>n>>m;
	Init();
	for(int i=1;i<=m;i++)
	{
		char op;
		int p,q;
		cin>>op>>p>>q;
		if(op == 'F')
		{
			merge(p,q);
		}
		else 
		{
			merge(p,q+n);
			merge(q,p+n);
		}
	}
	for(int i=1;i<=n;i++)
	{
	//	int f = find(i); 
		if(fa[i] == i) ans ++;
	}
	cout<<ans<<endl;
	return 0;
}
贪心+此模型模板 NOIP 2010 TG 关押罪犯
Description

\(m\) 对仇恨关系,每对关系对应 \(i,j,k\) 表示,若 \(i,j\) 在同一个集合内会产生大小为 \(k\) 的仇恨值。你需要将 \(n\) 个点分到两个集合中,使得产生的最大仇恨值最小。

定义朋友域为 \([1,n]\),敌人域为 \([n+1,2n]\)。贪心地将所有仇恨按照从大到小的顺序排序,然后对于每一组仇恨,将其分为两个集合,即合并 \(i,j+n\)\(i+n,j\)

判定不合法,当且仅当 \(i,j\) 已经在一个集合中,输出当前仇恨值即可。

考虑证明。

反证:对于矛盾情况,定义为 \(a\)\(b\) 有矛盾值 \(k_1\),\(a\)\(c\) 有矛盾值 \(k_2\),\(b\)\(c\) 有矛盾值 \(k_3\)。满足 \(k_1 \leq k_2 \leq k_3\)。依上述策略,将 \(b,c\) 放入同一集合。矛盾值为 \(k_3\)

若交换,即先将 \(b,c\) 放入不同集合。此时,无论先处理 \((a,b)\) 还是先处理 \((b,c)\) 答案不会更优。

代码
#include <bits/stdc++.h>
using namespace std;
const int N = 1001000;
int fa[N],dist[N];
struct Node
{
	int u,v,w;
}qwq[N];
int n,m;
bool cmp(Node a,Node b)
{
	return a.w > b.w;
}
void Init()
{
	for(int i=1;i<=n*2;i++)
	{
		fa[i] = i;
		dist[i] = 1;
	}
}
int find(int x)
{
	if(x == fa[x]) return x;
	fa[x] = find(fa[x]);
	return fa[x];
}
void merge(int i,int j)
{
	int x = find(i),y = find(j);
	if(dist[x] < dist[y]) fa[x] = fa[y];
	else fa[x] = fa[y];
	if(dist[x] == dist[y] && x != y) dist[y] ++;
	
}
int main()
{
//	freopen("input.txt","r",stdin);
	ios::sync_with_stdio(false);
	cin.tie(0);
	cout.tie(0);
	cin>>n>>m;
	for(int i=1;i<=m;i++)
	{
		cin>>qwq[i].u>>qwq[i].v>>qwq[i].w;
	}
	sort(qwq+1,qwq+m+1,cmp);
	Init();
	for(int i=1;i<=m;i++)
	{
		if(find(qwq[i].u) == find(qwq[i].v))
		{
			cout<<qwq[i].w<<endl;
			return 0;
		}
		merge(qwq[i].u,qwq[i].v+n);
		merge(qwq[i].v,qwq[i].u+n);
	}
	cout<<"0"<<endl;
	return 0;
}

多扩展域并查集

前文的两个域并查集,比较板,基本上直接套用即可。

但很多时候,两个域并不能解决问题。接下来将展示几个例题。

典型例题 [NOI2001] 食物链
Description

共有三种关系,每种关系描述了 \(x\)\(y\) 的关系。

  • \(1\) \(x\) \(y\) 表示 \(x\)\(y\) 是同类。
  • \(2\) \(x\) \(y\) 表示 \(x\)\(y\)

每次给出关系后,你需要判断该关系是否为假。定义如下。

  • 当前的话与前面的某些真的话冲突,就是假话;
  • 当前的话中 \(X\)\(Y\)\(N\) 大,就是假话;
  • 当前的话表示 \(X\)\(X\),就是假话。

你需要给出假的关系数量。

注意到 “同类” 直接普通并查集维护即可。对于吃的关系,看似类似上文“敌人”关系,但本题有明确的关系,两个域的扩展域并查集无法确定谁吃谁。无法解决问题。

本题开始给出了 \(A,B,C\) 三群动物之间的关系。不妨尝试开多个扩展域,也就是将一个点拆分成多个点。具体地,将 \(\forall i\) 拆分为 \(i+n,i+2n\)。我们定义,区间 \([1,n]\) 维护 \(A\) 群。区间 \([n+1,2n]\) 维护 \(B\) 群,区间 \([2n+1,3n]\) 维护 \(C\) 群。 依据题意,得出 \(A\)\(B\), \(B\)\(C\)\(C\)\(A\)。这是下面系列操作的依据。(这里的群系是相对的,我们把一个点拆成三个点只是为了便于确立关系,并不是说一个点只能属于一个群系)

考虑每一种关系。

对于关系 1,即 \(x,y\) 为朋友关系,我们确信 \(x,y\) 一定属于 \(A,B,C\) 中同一群系。但我们不知道它属于哪一种,需要全部合并。即令 \((x,y),(x+n,y+n),(x+2n,y+2n)\) 合并。合并扩展域是必要的,这样才能完成跨域的关系传递。

对于关系 2,即 \(x\)\(y\) ,上文提到 \(A\)\(B\)\(C\)\(A\)。我们需要对这两种吃的情况进行处理。即令 \((x,y+n),(x+2n,y)\) 合并。合并顺序需要注意。比如固定一个顺序令 \(fa_{u}=find(v+n)\) 表示 \(v\)\(u\)

这里的合并和上题不同,上题的敌人关系是互为敌人,故双向合并。这里需要弄明白。

再考虑不合法情况。

对于操作 1,显然当且仅当 \(x\)\(y\)\(y\)\(x\) 或越界不合法。判定吃的关系非常简单,依据上文 \(A\)\(B\) ,\(C\)\(A\) 双向判断即可。

对于操作 2,当且仅当 \(y\)\(x\)\(x,y\) 为同类不合法。

具体详见代码注释。

代码
#include <bits/stdc++.h>
using namespace std;
const int N = 10000010;
int n,k;
int fa[N];
int ans = 0;
int find(int x)
{
    if(fa[x] == x) return x;
    fa[x] = find(fa[x]);
    return fa[x];
}
int main()
{
    ios::sync_with_stdio(false);
    cin.tie(0);
    cout.tie(0);
    cin>>n>>k;
    for(int i=1;i<=3*n;i++) fa[i] = i; // init 初始化
    for(int i=1;i<=k;i++)
    {
        int op,u,v;
        cin>>op>>u>>v;
        if(op == 1)
        {
            if(u > n || v > n) ans ++; //越界
            else if(find(u+n) == find(v) || find(v+n) == find(u)) ans++; // 如果二者是吃与被吃的关系不合法
            else
            {
                fa[find(u)] = fa[find(v)]; // 分别在本域,扩展域进行合并
                fa[find(u+n)] = fa[find(v+n)];
                fa[find(u+n+n)] = fa[find(v+n+n)];
            }
        }
        else
        {
            if(u == v) ans ++;
            else if(u > n || v > n) ans ++; //越界
            else if(fa[find(u)] == fa[find(v)] || fa[find(u)] == fa[find(v+n)]) ans ++; // 如果关系反了,或者他们两个是同类不合法
            else
            {
                fa[find(u + n)] = find(v); //按照一定的顺序合并,合并规则依据题面
				fa[find(u + n + n)] = find(v + n);
				fa[find(u)] = find(v + n + n);
            }
        }
    }
    cout<<ans<<endl;
    return 0;
}
posted @   SXqwq  阅读(26)  评论(1编辑  收藏  举报
相关博文:
阅读排行:
· 阿里最新开源QwQ-32B,效果媲美deepseek-r1满血版,部署成本又又又降低了!
· 单线程的Redis速度为什么快?
· SQL Server 2025 AI相关能力初探
· AI编程工具终极对决:字节Trae VS Cursor,谁才是开发者新宠?
· 展开说说关于C#中ORM框架的用法!
点击右上角即可分享
微信分享提示