并查集是一种用途广泛的数据结构,能够快速地处理集合的合并和查询问题,并且实现起来非常方便,在很多场合中都有着非常巧妙的应用,。
本文首先介绍并查集的定义、原理及具体实现,然后以其在最小生成树算法中的一个经典应用为例讲解其具体使用方法。
一 并查集原理及实现
并查集是一种树型的数据结构,用于处理一些不相交集合的合并及查询问题。
并查集在使用中通常以森林来表示,每个集合组织为一棵树,并且以树根节点为代表元素。实际中以一个数组father[x]即可实现,表示节点x的父亲节点。另外用一个变量n表示节点的个数。但为了提升性能,通常还用一个数组rnk[x]表示节点x的子树的深度,具体后面解释。
const int mx = 100000 + 1; //最大节点个数 int n; //节点个数 int father[mx]; //节点的父亲 int rnk[mx]; //节点的子树深度
并查集通常支持三种操作:初始化、查找、合并。
1. 初始化
初始化把每个点所在集合初始化为其自身,即n个节点就有n个集合。遍历一次n个节点,把每个的父亲初始为自己,子树的深度初始化为0。
void makeSet() //初始化 { for (int i = 0; i < n; i++) father[i] = i, rnk[i] = 0; }
2. 查找
查找元素所在的集合,即根节点。因为一个集合只用根节点作为代表元,查找的时候只要沿着father数组一直往上走直到根节点为止,而根节点的父亲为其本身,于是代码为:
int findSetOriginal(int x) //非路径压缩查找 { if (x != father[x]) x = father[x]; return father[x]; }
但实际中会做一个称为路径压缩的优化。因为从该节点x到根节点可能会有很长的一条路径,查找的时间复杂度极端情况下为O(n)。我们可以在查找的过程中,把每个节点的父亲都指向跟节点,于是查找完成之后原本长度为n的一条路径变成了n条长度为1的路径,这些节点的查找时间复杂相应变成了O(1)。路径压缩的实现如下所示:
int findSet(int x) //递归路径压缩查找 { if (x != father[x]) father[x] = findSet(father[x]); return father[x]; }
但实际中递归算法可能会造成栈溢出的问题,以下为相应的非递归算法。主要思想是先找到该集合的根节点,然后把路径上的节点的父亲都改为根节点。
int findSetNonRecursive(int x) //非递归路径压缩查找 { int root = x; //根节点 while (root != father[root]) root = father[root]; int tem = x; while (tem != root) //路径压缩 { int temFather = father[tem];//暂存父亲节点 father[tem] = root; //更新父亲为根 tem = temFather; //移动到父亲节点 } return root; //返回根节点 }
3. 合并
将两个元素所在的集合合并为一个集合。合并的时候先使用2中的查找函数找到两个集合的根节点。如果根节点相同,说明属于同一个集合,则不需要合并。如果不同,只需把一个根节点的父亲指向另一个根节点即可。
实际中使用了一个称为按秩合并的优化,因为直接合并可能产生一棵深度很深的树,这不利于后续的查找。前面的rnk[x]数组表示节点x的秩,即该节点子树的深度。合并时我们总是把秩小的节点的父亲指向秩大的节点之上,这样可以尽量较少新生成的树的深度。当两个节点的秩相同时,新生成树的根节点的秩需要加1,因为子树的深度增加了1,否则子树深度没有变化,秩也不需要改变。
int unionSet(int x, int y) //合并 { x = findSet(x), y = findSet(y); if (x == y) return 0; //属于同一个集合,不需合并 if (rnk[x] > rnk[y]) father[y] = x; else father[x] = y, rnk[y] += rnk[x] == rnk[y]; return 1; //属于不同集合,且已合并成功 }
并查集(Union-find Sets)是一种非常精巧而实用的数据结构,它主要用于处理一些不相交集合的合并问题。一些常见的用途有求连通子图、求最小生成树的 Kruskal 算法和求最近公共祖先(Least Common Ancestors, LCA)等。
使用并查集时,首先会存在一组不相交的动态集合 S={S1,S2,?,Sk},一般都会使用一个整数表示集合中的一个元素。
每个集合可能包含一个或多个元素,并选出集合中的某个元素作为代表。每个集合中具体包含了哪些元素是不关心的,具体选择哪个元素作为代表一般也是不关心的。我们关心的是,对于给定的元素,可以很快的找到这个元素所在的集合(的代表),以及合并两个元素所在的集合,而且这些操作的时间复杂度都是常数级的。
并查集的基本操作有三个:
- makeSet(s):建立一个新的并查集,其中包含 s 个单元素集合。
- unionSet(x, y):把元素 x 和元素 y 所在的集合合并,要求 x 和 y 所在的集合不相交,如果相交则不合并。
- find(x):找到元素 x 所在的集合的代表,该操作也可以用于判断两个元素是否位于同一个集合,只要将它们各自的代表比较一下就可以了。
并查集的实现原理也比较简单,就是使用树来表示集合,树的每个节点就表示集合中的一个元素,树根对应的元素就是该集合的代表,如图 1 所示。
注意:寻找祖先时我们一般采用递归查找,但是当元素很多亦或是整棵树变为一条链时,每次Find_Set(x)都是O(n)的复杂度,有没有办法减小这个复杂度呢?
答案是肯定的,这就是路径压缩,即当我们经过"递推"找到祖先节点后,"回溯"的时候顺便将它的子孙节点都直接指向祖先,这样以后再次Find_Set(x)时复杂度就变成O(1)了,如下图所示;可见,路径压缩方便了以后的查找。
参考文献:
http://noalgo.info/454.html
http://www.cnblogs.com/cyjb/p/UnionFindSets.html
http://www.ahathinking.com/archives/10.html