[算法学习笔记] 并查集
提示:本文并非并查集模板讲解,是在模板基础上的进一步理解以及拓展。
Review
并查集可以用来维护集合问题。例如,已知 \(a,b\) 同属一个集合,\(b,c\) 同属一个集合。那么 \(a,b,c\) 都属一个集合。
并查集分为 合并,查询 操作。定义 \(fa_i\) 表示点 \(i\) 的父亲。为了降低复杂度,在 find 操作向上递归查祖先时我们同步将 \(fa_i\) 更改为 \(i\) 的祖先。这就是所谓路径压缩。
对于合并,为了方便直接合并即可。当然也可以按秩合并优化,虽然我认为优化效果不大。
上述是普通并查集的基本操作。
朴素并查集例题
我们来看一道例题。
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\)
不难发现,\(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;
}
本文作者:SXqwq,转载请注明原文链接:https://www.cnblogs.com/SXqwq/p/18156576
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 阿里最新开源QwQ-32B,效果媲美deepseek-r1满血版,部署成本又又又降低了!
· 单线程的Redis速度为什么快?
· SQL Server 2025 AI相关能力初探
· AI编程工具终极对决:字节Trae VS Cursor,谁才是开发者新宠?
· 展开说说关于C#中ORM框架的用法!