写在前面:
话说江湖上散落着各式各样的大侠,有上千个之多。他们没有什么正当职业,整天背着剑在外面走来走去,碰到和自己不是一路人的,就免不了要打一架。但大侠们有一个优点就是讲义气,绝对不打自己的朋友。而且他们信奉“朋友的朋友就是我的朋友”,只要是能通过朋友关系串联起来的,不管拐了多少个弯,都认为是自己人。
这样一来,江湖上就形成了一个一个的群落,通过两两之间的朋友关系串联起来。而不在同一个群落的人,无论如何都无法通过朋友关系连起来,于是就可以放心往死了打。但是两个原本互不相识的人,如何判断是否属于一个朋友圈呢?
我们可以在每个朋友圈内推举出一个比较有名望的人,作为该圈子的代表人物,这样,每个圈子就可以这样命名“齐达内朋友之队”“罗纳尔多朋友之队”……两人只要互相对一下自己的队长是不是同一个人,就可以确定敌友关系了。 但是还有问题啊,大侠们只知道自己直接的朋友是谁,很多人压根就不认识队长,要判断自己的队长是谁,只能漫无目的的通过朋友的朋友关系问下去:“你是不是队长?你是不是队长?” 这样一来,队长面子上挂不住了,而且效率太低,还有可能陷入无限循环中。
于是队长下令,重新组队。队内所有人实行分等级制度,形成树状结构,我队长就是根节点,下面分别是二级队员、三级队员。每个人只要记住自己的上级是谁就行了。遇到判断敌友的时候,只要一层层向上问,直到最高层,就可以在短时间内确定队长是谁了。由于我们关心的只是两个人之间是否连通,至于他们是如何连通的,以及每个圈子内部的结构是怎样的,甚至队长是谁,并不重要。所以我们可以放任队长随意重新组队,只要不搞错敌友关系就好了。于是,门派产生了。
并查集
并查集(Union-find Sets)是一种非常精巧而实用的数据结构,它主要用于处理一些不相交集合的合并问题。一些常见的用途有求连通子图、求最小生成树的 Kruskal 算法和求最近公共祖先(Least Common Ancestors, LCA)等。
使用并查集时,首先会存在一组不相交的动态集合
,一般都会使用一个整数表示集合中的一个元素。
每个集合可能包含一个或多个元素,并选出集合中的某个元素作为代表,称为集合的代表元
。
每个集合中具体包含了哪些元素是不关心的,具体选择哪个元素作为代表一般也是不关心的。我们关心的是,对于给定的元素,可以很快的找到这个元素所在的集合(的代表),以及合并两个元素所在的集合,而且这些操作的时间复杂度都是常数级的。
并查集的实现原理也比较简单,就是使用树来表示集合,树的每个节点就表示集合中的一个元素,树根对应的元素就是该集合的代表元,如图 1 所示。

图中有两棵树,分别对应两个集合,其中第一个集合为 ,代表元素是
;第二个集合为
,代表元素是
。
并查集的基本操作有三个:
-
makeSet(s):建立一个新的并查集,其中包含 s 个单元素集合。【初始化】
-
unionSet(x, y):把元素 x 和元素 y 所在的集合合并,要求 x 和 y 所在的集合不相交,如果相交则不合并。【合并】
-
find(x):找到元素 x 所在的集合的代表,该操作也可以用于判断两个元素是否位于同一个集合,只要将它们各自的代表比较一下就可以了。【查找】
这也就是为什么叫并查集的原因,顾名思义,就是为了进行查和并操作。
【初始化】
树的节点表示集合中的元素,指针表示指向父节点的指针,根节点的指针指向自己,表示其没有父节点。

#define N 105
int parent[N];// 树型结构的根节点
int r[N];// 树的秩
//初始化
int init(int n) //对n个结点初始化
{
for(int i = 0; i < n; i++)
{
parent[i] = i; // 每个结点的上级都是自己
r[i] = 0; // 每个结点构成的树的秩为0
}
}
【查找】
沿着每个结点的父节点不断向上查找,最终就可以找到该树的根结点,即该集合的代表元素。
int find_parent(int x) // 查找结点x的根结点
{
if(parent[x] == x) // 递归出口:x的上级为x本身,即x为根结点
{
return x;
}
return find_parent(parent[x]);// 递归查找
}
通过下面的图,我们发现,普通的查找过程相对较麻烦,例如寻找d结点的根节点,我们需要通过才能最终找到。
但是,如果我们使用路径压缩算法
,那么只需要查找一次,就能确定d结点的结点,即。

//【递归版本】
// 改进查找算法:完成路径压缩,将x的上级直接变为根结点,那么树的高度就会大大降低
int find_parent(int x) // 查找结点x的根结点
{
if(parent[x] == x) // 递归出口:x的上级为x本身,即x为根结点
{
return x;
}
return parent[x] = find_parent(parent[x]);// 递归查找,此代码相当于先找到根结点rootx,然后pre[x]=rootx
}
//【路径压缩的另一种写法】
//【个人更偏向于这种写法】
int find_parent(int x)
{
if(parent[x]!=x)parent[x]=find_parent(parent[x]);
return parent[x];
}
【合并】
并查集的合并也非常简单,就是将一个集合的树根指向另一个集合的树根。

//【并】
// 朴素合并
void unite(int x,int y)
{
int root_x, root_y;
root_x = find_parent(x);
root_y = find_parent(y);
if(root_x!=root_y)parent[root_y]=root_x;
}
也可以采用一种优化方法——按秩合并
,注意初始化的时候需要将全部置0。

//【并】
// 按秩合并
void unite(int x,int y)
{
int root_x, root_y;
root_x = find_parent(x);
root_y = find_parent(y);
// 属于同一个集合
if(root_x == root_y)return;
// 属于不同集合
//令 y的根结点的上级为 root_x
if(r[root_x] > r[root_y])parent[root_y] = root_x;
else
{
// 秩相等,合并之后秩需要加 1
if(r[root_x] == r[root_y])r[root_y]++;
parent[root_x] = root_y;
}
}
并查集的分析与用法:
时空复杂度:
-
时间复杂度:
同时使用路径压缩、按秩(rank)合并优化的程序每个操作的平均时间仅为,其中
是
的反函数,
是急速增加的阿克曼函数。因为
是其反函数,故
在
十分巨大时还是小于 5。因此,平均运行时间是一个极小的常数。实际上,这是渐近最优算法:Fredman 和 Saks 在 1989 年解释了
的平均时间内可以获得任何并查集。
-
空间复杂度:
(n为元素个数)
按秩合并会多一个保存秩的辅助空间,即。
用途:
-
判断是否是同一类:
这个很简单,给定要判定的两个节点,find_parent()一下就可以了 -
判断有多少个类/是否连通:
这个就基本上要遍历一下所有的节点,然后再把它们的父亲节点加入到set集合里,然后再统计一下个数即可。 -
最小生成树:Kruskal算法
写在最后:
参考资料:
- 博客:算法模板学习专栏之并查集(一)入门(文字来源)
- 博客:数据结构4——并查集(入门)(图片、文字来源)【讲解很有意思的一篇博客】
- 博客:并查集(图片、文字来源)【推荐】
- 维基百科:并查集(文字来源)
终于对并查集有了新的认识,以前仅仅知其名,不知其用。
后面我们将讲讲带权并查集,算是对普通并查集的进阶。同时也将在最小生成树的Kruskal算法中用到并查集。
网友评论