并查集 算法解析+例题

本文介绍了并查集的数据结构,包括基本概念、朴素实现(路径压缩和启发式合并)、带权并查集的应用(如战舰问题)、种类并查集(处理敌对关系和食物链问题),以及在复杂问题中如何利用分块算法优化并查集操作

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

并查集


基础

本质是一个森林,相同集合的元素属于同一棵树中。初始时每个点都是一棵树,自己即为根节点。

朴素的并查集一般支持两种操作:

  • 询问某一个节点属于哪个集合。
  • 合并两个节点所在的集合。

对于前者,在实现上判断两个节点所在树的根节点是否相同。这里有一个优化:路径压缩,即在查询的过程中直接将自己的父亲设为根节点,可以缩短查询需要往上跳的距离。

对于后者,将其中一个节点所在树的根节点的父亲设为另一个节点所在树的根节点的儿子。这里也有一个优化:启发式合并,也叫按秩合并,即总是将节点数少的、深度小的树的根节点的父亲设为另一个根节点,也是缩短了查询时跳跃的距离。

int fa[maxn],siz[maxn]; // fa记录每个点的父亲,siz记录子树大小。
int find(int x) {
    if (x == fa[x]) return x;
    return fa[x] = find(fa[x]); // 路径压缩
}
void Unite(int x,int y) {
    x = find(x), y = find(y);
    if (x == y) return ;
    if (siz[x] < siz[y]) 
        swap(x,y); // 节点数少的设为y
    fa[y] = x, siz[x] += siz[y];
}

在同时使用路径压缩和启发式合并之后,每个操作的平均时间复杂度为 O ( α ( n ) ) O(\alpha(n)) O(α(n)),其中 α ( n ) \alpha(n) α(n) 表示反阿克曼函数,几乎可以认为是 O ( 1 ) O(1) O(1) 的了。

应用场景

基础

可以发现,如果 i i i 告诉了 T i T_i Ti,那么就相当于 i i i 告诉了 T T i T_{T_i} TTi,以此类推。所以就可以用一个并查集维护会知道自己生日的人。如果 i i i 发现 T i T_i Ti 的祖先中有 i i i,说明最终 i i i 会收到自己的生日。所以只需要在不使用路径压缩时,记录一下找根节点时往上跳的步数即可。

带权并查集

对于题目中的合并操作和判断两个战舰是否在同一列中,可以使用并查集维护。但对于计算两点之间的战舰数量,朴素并查集没办法做。

这里引入一个并查集的分支:带权并查集,即在维护元素所属集合的同时,记录该节点到根的距离等权值信息。

d i s i dis_i disi 表示 i i i 到所属队列队头战舰之间的战舰数量,则在同一列中的两个战舰 x , y x,y x,y,它们之间的战舰数量等于 ∣ d i s x − d i s y ∣ − 1 |dis_x-dis_y|-1 disxdisy1。所以我们在并查集的过程中维护 d i s dis dis,因为合并操作会将一整列的战舰挪到某个战舰后面,所以还需维护集合的大小。

#include<bits/stdc++.h>
using namespace std;
const int maxn = 3e4 + 5;
int T,fa[maxn],dis[maxn],siz[maxn];
int find(int u) { 
    if (u == fa[u]) return u;
    int v = fa[u]; fa[u] = find(fa[u]);
    dis[u] += dis[v]; siz[u] = siz[fa[u]];
    return fa[u]; 
}
int main() {
    scanf("%d",&T); char op = getchar();
    for (int i = 1;i < maxn;i ++) fa[i] = i, siz[i] = 1;
    for (int i = 1,u,v;i <= T;i ++) {
        while (op != 'M' && op != 'C') op = getchar();
        scanf("%d%d",&u,&v);
        if (op == 'M') {
            u = find(u), v = find(v);
            dis[u] += siz[v];
            fa[u] = v, siz[v] += siz[u], siz[u] = siz[v];
        } else {
            int fu = find(u), fv = find(v);
            if (fu != fv) puts("-1");
            else printf("%d\n",abs(dis[u] - dis[v]) - 1);
        }
        op = getchar();
    }
    return 0;
}

种类并查集

这两题都提到了敌对关系。一种可行的做法是开一个数组,记录每个人的敌人是谁。

另一种做法就是种类并查集,即一种可以维护有不同关系下元素所属集合的并查集分支。常见的实现方法就是开 n n n 倍大小的并查集,维护 n n n 个种类之间的关系。

所以我们可以开 2 2 2 倍大小的并查集,如果 x , y x,y x,y 互为敌人,则将 x , n + y x,n+y x,n+y 归为一个集合, n + x , y n+x,y n+x,y 归为一个集合;如果 x , y x,y x,y 为朋友,则将 x , y x,y x,y 归为一个集合。

对于 P1892,统计最终有多个集合即为团体数。

#include<bits/stdc++.h>
using namespace std;
const int maxn = 1005;
int a[maxn],fa[maxn << 1]; bool vis[maxn];
int n,m,ans; char op;
int find(int u) { return fa[u] == u ? u : fa[u] = find(fa[u]); }
int main() {
    scanf("%d%d",&n,&m);
    for (int i = 1;i <= n;i ++) 
        fa[i] = i, fa[i + n] = i + n;
    for (int i = 1,u,v;i <= m;i ++) {
        while ((op = getchar()) != 'F' && op != 'E');
        scanf("%d%d",&u,&v);
        if (op == 'F') fa[find(u)] = find(v);
        else fa[find(u + n)] = find(v), fa[find(v + n)] = find(u);
    }
    for (int i = 1;i <= n;i ++)
        if (!vis[find(i)]) ans ++, vis[find(i)] = true;
    printf("%d",ans);
    return 0;
}

对于 P1525,一个显然的贪心思路是:总是优先满足怨气值大的一对罪犯在不同的监狱里。所以按照怨气值从大到小对罪犯进行处理。如果发现此时已经发生了冲突,则还没处理过的罪犯们发生的冲突一定不会比该冲突的影响更大。所以直接输出此时的怨气值即可。

#include<bits/stdc++.h>
using namespace std;
const int maxn = 20005, maxm = 100005;
pair<int,pair<int,int> > a[maxm];
int n,m,fa[maxn << 1];
int find(int x) { return x == fa[x] ? x : fa[x] = find(fa[x]); }
bool cmp(pair<int,pair<int,int> > x,pair<int,pair<int,int> > y) { 
    return x.first > y.first; 
}
int main() {
    scanf("%d%d",&n,&m);
    for (int i = 1;i <= m;i ++) 
        scanf("%d%d%d",&a[i].second.first,&a[i].second.second,&a[i].first);
    sort(a + 1,a + m + 1,cmp);
    for (int i = 1;i <= n;i ++) fa[i] = i, fa[i + n] = i + n;
    for (int i = 1;i <= m;i ++) {
        int u = a[i].second.first,v = a[i].second.second;
        if (find(u) == find(v)) { printf("%d",a[i].first); return 0; }
        fa[find(u + n)] = find(v), fa[find(v + n)] = find(u);
    }
    return puts("0"), 0; // 不发生冲突时要输出0
}

相比于前面两题,本题出现了同类、吃、被吃的关系。首先需要注意到:我们并不知道也不关心每个动物具体属于 A , B , C A,B,C A,B,C 中的哪一种,因为如果 x x x y y y,那么既可以认为 x x x A A A y y y B B B;也可以认为 x x x C C C y y y A A A

我们就可以开三倍大小的并查集,对于题目中的每句话:

  • 如果当前信息为 x x x y y y 是同类的,那么将 x , y x,y x,y 放到同一个集合, x + n , y + n x+n,y+n x+n,y+n 放到同一个集合, x + 2 n , y + 2 n x+2n,y+2n x+2n,y+2n 放到同一个集合。
  • 如果当前信息为 x x x y y y,那么将 x , y + n x,y+n x,y+n 放到同一个集合, x + n , y + 2 n x+n,y+2n x+n,y+2n 放到同一个集合, x + 2 n , y x+2n,y x+2n,y 放到同一个集合。

对于同类的信息,如果 x , y x,y x,y 已经有了吃与被吃的关系( x , y + n x,y+n x,y+n 在同一个集合中),那么显然已经矛盾了;同理对于吃的信息,如果 x , y x,y x,y 已经属于同类,或已经有了与之相反的吃的信息( x , y x,y x,y 或者 x , y + n x,y+n x,y+n 在同一个集合中),那么也已经矛盾了。

#include <bits/stdc++.h>
const int maxn = 1e5 + 5;
int n, m, ans, fa[maxn * 3];
int find(int u) { return fa[u] == u ? u : fa[u] = find(fa[u]); }
int main() {
    scanf("%d%d",&n,&m);
	for (int i = 1; i <= n * 3; i ++) { fa[i] = i; }
	for (int i = 1,op,u,v; i <= m; i ++) {
        scanf("%d%d%d",&op,&u,&v);
		if (u > n || v > n) { ans ++; continue; }
		if (op == 1) {
			if (find(u + n) == find(v) || find(u) == find(v + n)) ans ++;
			else {
				fa[find(u)] = find(v);
				fa[find(u + n)] = find(v + n);
				fa[find(u + n + n)] = find(v + n + n);
			}
		} else {
			if (find(u) == find(v) || find(u) == 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);
			}
		}
	}
	printf("%d", ans);
	return 0;
}

在这里对种类并查集做一个总结:对于不同种类之间的关系,建议画图把不同维的种类之间的关系先理清,对着图实现一下。注意对称的关系也要处理。

综合

最终统计答案显然是将相同部分的数视为一个数字,计算贡献。

令同一个并查集中的元素必须相同。瓶颈在于区间与区间之间的处理,暴力方法显然是每个点每个点挨个合并到一个集合里。我们来联想一下区间上的连边优化:线段树、建虚点、分块 … \dots 这里我们考虑分块,分成 log ⁡ n \log n logn 块,相当于建一个 st 表。

f i , j f_{i,j} fi,j 表示 [ i , i + 2 j ) [i,i+2^j) [i,i+2j) 这个区间所属的集合。对于每个操作 [ l 1 , r 1 ] , [ l 2 , r 2 ] [l1,r1],[l2,r2] [l1,r1],[l2,r2],我们把这两个区间按照 2 2 2 的幂次分块合并。等所有区间都处理完了,我们需要把区间上的集合信息降到点上去。

对于一段大区间 [ i , i + 2 j ) [i,i+2^j) [i,i+2j),将它的集合信息降到 [ i , i + 2 j − 1 ) , [ i + 2 j − 1 , i + 2 j ) [i,i+2^{j-1}),[i+2^{j-1},i+2^j) [i,i+2j1),[i+2j1,i+2j) 两个小区间上,设 [ i , i + 2 j ) [i,i+2^j) [i,i+2j) 所在集合在并查集中的根节点为 [ k , k + 2 j ) [k,k + 2^j) [k,k+2j),则将 [ i , i + 2 j − 1 ) [i,i+2^{j-1}) [i,i+2j1) [ k , k + 2 j − 1 ) [k,k+2^{j-1}) [k,k+2j1) 放到同一个集合,将 [ i + 2 j − 1 , i + 2 j ) [i+2^{j-1},i+2^j) [i+2j1,i+2j) [ k + 2 j − 1 , k + 2 j ) [k+2^{j-1},k+2^j) [k+2j1,k+2j) 放到同一个集合。这样一一对应,降到点上时也就是一一对应的了。

#include<bits/stdc++.h>
#define ll long long
using namespace std;
const int maxn = 1e5 + 5;
const int P = 1e9 + 7;
int fa[maxn][30],n,m;
int find(int x,int b) {
	return x == fa[x][b] ? x : fa[x][b] = find(fa[x][b],b);
}
void Union(int x,int y,int b) {
	if (find(x,b) == find(y,b)) return ;
	fa[find(x,b)][b] = find(y,b);
}
ll ans;
int main() {
	scanf("%d%d",&n,&m);int l1,r1,l2,r2;
	for (int i = 1;i <= n;i ++)
		for (int j = 0;j <= 20;j ++)
			fa[i][j] = i;
	while (m --) {
		scanf("%d%d%d%d",&l1,&r1,&l2,&r2);
		for (int i = 20;i >= 0;i --) 
			if (l1 + (1 << i) - 1 <= r1) {
				Union(l1,l2,i);
				l1 += (1 << i), l2 += (1 << i);
			}
	}
	for (int i = 20;i;i --) // 把集合信息降下去
		for (int j = 1;j + (1 << i) - 1 <= n;j ++) {
			int k = find(j,i);
			Union(j,k,i - 1); 
            Union(j + (1 << (i - 1)),k + (1 << (i - 1)),i - 1);
		}
	for (int i = 1;i <= n;i ++)
		if (fa[i][0] == i) {
			if (ans == 0) ans = 9;
			else ans = (ans * 10ll) % P;
		}
	printf("%lld",ans);
	return 0;
}
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值