并查集
一、简介
并查集(Union/Find)从名字可以看出,主要涉及两种基本操作:合并和查找。这说明初始时并查集中的元素是不相交的,经过一系列的基本操作(Union),最终合并成一个大的集合。而在某次合并之后,有一种合理的需求:某两个元素是否已经处在同一个集合中了?因此就需要Find操作。
并查集是一种不相交集合的数据结构,设有一个动态集合S={s1,s2,s3,.....sn},每个集合通过一个代表来标识,代表就是动态集合S中的某个元素。比如,若某个元素 x 是否在集合 s1 中(Find操作),返回集合 s1 的代表元素即可。这样,判断两个元素是否在同一个集合中也是很方便的,只要看Find(x)和Find(y)是否返回同一个代表即可。
为什么是动态集合S呢?因为随着Union操作,动态集合S中的子集合个数越来越少。数据结构的基本操作决定了它的应用范围,对并查集而言,一个简单的应用就是判断无向图的连通分量个数,或者判断无向图中任何两个顶点是否连通。
二、逻辑结构
并查集S={s1,s2,s3,.....sn}由若干子集合si构成,并查集的逻辑结构就是一个森林。si表示森林中的一棵子树。一般以子树的根作为该子树的代表。
三、存储结构
对于并查集的存储结构,可用一维数组和链表来实现。下面介绍一维数组的实现.
四、相关算法
1、并查集的初始化和查找(Find)
由于Find操作需要找到该子集合的代表元素,而代表元素是树根,因此需要保存树中结点的父亲。对于每一个结点,如果知道了父亲,沿着父结点不断往上查找就可以最终找到树根。
为了简单起见,假设一维数组s中的每个元素 s[i] 表示该元素 i 的父亲。这里有两个需要注意的地方:①我们用一维数组来存储并查集,数组的元素s[i]表示的是结点的父亲的位置。②数组元素的下标 i 则是结点的标识。如:s[5]=4,表示:结点5的父亲是结点4。
//初始化一个数组
int s[1000];
//查找根节点
int Find(int x){
int r=x;
//返回根节点 r
while (s[r] != r)
r=s[r];
return r;
}
2、并查集的合并
Union操作就是将两个不相交的子集合合并成一个大集合。简单的Union操作是非常容易实现的,因为只需要把一棵子树的根结点指向另一棵子树即可完成合并。这里的合并很随意,任给的2个节点x和y,只要用Find找到他们的根节点,随机把其中一个指向另外一个根节点就完成了合并。
// 合并2个节点
void Union(int x,int y)
{
int fx=Find(x),fy=Find(y);
if(fx!=fy)
{
s[fy]=fx;
}
}
3、Union/Find的改进——路径压缩
上面介绍的Union操作很随意:任选一棵子树,将另一棵子树的根指向它即完成了合并。如果一直按照上述方式合并,很可能产生一棵非常不平衡的子树。树越来越高了,此时会影响到Find操作的效率。这里介绍一种快速的Find方法:路径压缩。
路径压缩是指在Find操作进行时,使Find查找路径中的顶点的父亲都直接指向为树根(这很明显地改变了子树的高度,因为最终并查集中的数最大高度为2)。例如,Find要查找x的树根,引入i作为当前节点,j作为当前节点的父节点。每遍历一个当前节点i,就将i节点指向x的根节点r,然后当前节点i更新为其父亲j的位置,依次向上更新,直到全部完成。
// Find查找根节点
int Find(int x)
{
int r=x;
while(r!=s[r])
r=s[r];
// 压缩路径
int i=x,j;
while(s[i]!=r)
{
j=s[i];
s[i]=r;
i=j;
}
return r;
}
4、整体代码实现
#include <iostream>
using namespace std;
int s[1050];
bool t[1050]; //t 用于标记独立块的根结点
// Find查找根节点
int Find(int x)
{
int r=x;
while(r!=s[r])
r=s[r];
// 压缩路径
int i=x,j;
while(s[i]!=r)
{
j=s[i];
s[i]=r;
i=j;
}
return r;
}
// 合并2个节点
void Union(int x,int y)
{
int fx=Find(x),fy=Find(y);
if(fx!=fy)
{
s[fy]=fx;
}
}
int main()
{
int N,M,a,b,i,j,ans;
while(scanf("%d%d",&N,&M)&&N)
{
for(i=1;i<=N;i++) //初始化
s[i]=i;
for(i=1;i<=M;i++) //吸收并整理数据
{
scanf("%d%d",&a,&b);
Union(a,b);
}
memset(t,0,sizeof(t));
for(i=1;i<=N;i++) //标记根结点
{
t[Find(i)]=1;
}
for(ans=0,i=1;i<=N;i++)
if(t[i])
ans++;
printf("%d\n",ans-1);
}
return 0;
}
五、应用场景及例子
1、应用场景
并查集数据结构非常简单,基本操作也很简单,但是用途比较大。比如,求解无向图中连通分量的个数,生成迷宫。这些应用本质上就是:初始时都是一个个不连通的对象,经过一步步处理,变成连通的了。如迷宫,初始时,起点和终点不连通,随机地打开起点到终点路径上的一个方向,直至起点和终点连通了,就生成了一个迷宫。如无向图的连通分量个数,初始时,将无向图中各个顶点视为不连通的子集合,对图中每一条边,相当于union这条边对应的两个顶点分别所在的集合,直至所有的边都处理完后,还剩下的集合的个数即为连通分量的个数。
2、例子
题目:
班上有N名学生。其中有些人是朋友,有些则不是。他们的友谊具有是传递性。如果已知A是B的朋友,B是C的朋友,那么我们可以认为A也是C的朋友。所谓的朋友圈,是指所有朋友的集合。
给定一个N * N的矩阵M,表示班级中学生之间的朋友关系。如果Mi= 1,表示已知第 i 个和 j 个学生互为朋友关系,否则为不知道。你必须输出所有学生中的已知的朋友圈总数。
实例:
输入:
[[1,1,0],
[1,1,0],
[0,0,1]]
输出: 2
说明:已知学生0和学生1互为朋友,他们在一个朋友圈。
第2个学生自己在一个朋友圈。所以返回2。
思路:这道题目转化为求图的连通分量,可以用bfs做,但是并查集dsu来做更简单。
//先给出并查集的封装类,这里不仅封装了union、find操作,还封装了一个size计算有多少种类。
package dsu;
public class UnionFind {
public int[] parent;
// 初始化并查集
public UnionFind(int n){
parent = new int[n];
for (int i = 0; i < n; i++) {
parent[i] = i;
}
}
// 并查集查找根
public int find(int x){
while (x != parent[x]){
parent[x] = parent[parent[x]];//将祖父节点作为父亲节点
x = parent[x];//自己升级为父亲节点,达到路径压缩的效果
}
return x;
}
// 并查集的合并
public void union(int p,int q){
int rootP = find(p);
int rootQ = find(q);
if(rootP != rootQ) parent[rootP] = rootQ;//合并为一个根
}
public int size(){//union完成之后,size可以返回图的连通分量的个数
int r = 0;
for (int i = 0; i < parent.length; i++) {
if(i == parent[i]) r++;//索引等于数组元素为这一种类的根
}
return r;
}
}
// 计算最后的size也就是朋友圈个数
public int findCircleNum(int[][] M) {
UnionFind uf = new UnionFind(M.length);
for (int i = 0; i < M.length; i++) {
for (int j = 0; j < i; j++) {//根据对称性,遍历一半即可
if(M[i][j]==1) uf.union(i,j);//邻接矩阵元素为1,表示相识,可以union到一起
}
}
return uf.size();//union完之后size就是朋友圈的个数
}