通过画图我们可以清晰直观地看出,1和3相连,2和5之间不相连。不过我们今天的解法是通过程序而非画图。
直bao接li解法
如果直接去做的话,思路倒也是很简单。我们可以开6个数组对应各个结点,而数组里装的就是与该结点同在一个集合中的其他所有结点。
至于复杂度嘛……可惜到不忍直视……
如果你的离散数学掌握的比较好的话,那我们就可以把这里的“相连”概念给扩展化。可以认为“相连”是一种等价关系,因此它具有:
- 自反性:结点与结点自身是相连的。
- 对称性:如果结点p和结点q相连,那么p和q也是相连的。
- 传递性:如果p和q是相连的且q和r是相连的,那么p和r也是相连的。
如何理解并查集?
并查集是一种维护集合的数据结构,它的名字中“并”“查”“集”三个字分别取自Union(合并)、Find(查找)、Set(集合)这3个单词。它也是一种非常精巧而实用的数据结构,可以用于处理一些不相交集合的合并问题。
那么并查集是用什么实现的呢?先让我们开一个数组:
int father[N];
这个数组在不同的实现方法中所代表的具体含义不同。
实现
在《算法》(第4版)中,作者讨论了三种不同的实现方法。而我本文的思路则与其不同,我会介绍两种实现方法和两种优化方法。
1.quick-union算法
回头看那个father数组,在本算法里,father[i]表示元素i的父亲结点,而父亲结点本身也是这个集合内的元素(自反性)。我们可以把集合关系表示如下图:
那么father数组就可以这么填写:
father[1]=1; //1的父亲结点是1自己father[2]=1; //2的父亲结点是1father[3]=2; //3的父亲结点是2father[4]=4; //4的父亲结点是4自己father[5]=4; //5的父亲结点是4father[6]=4; //6的父亲结点是4
有了father数组,我们就可以查找结点属于哪个集合。通过观察上图我们不难发现,每个集合有且只有一个箭头指向自己的结点,我们不妨称其为根结点,那么根结点就与每个集合形成了一一对应的关系。想查找结点属于哪个集合,只要查找该集合所对应的根结点就可以了。
//循环的写法int find(int x) { //寻找x结点所在集合的根结点 while (x != father[x]) x = father[x]; //x被赋值为自己的父亲结点,逐级向上查找 return x;}//递归的写法int find(int x) { //寻找x结点所在集合的根结点 if (father[x] == x) { // 递归出口:x的父结点为x本身,即x为根结点 return x; } return find(father[x]); // 递归查找}
合并
在使用并查集时,第一步需要先初始化father数组,因为在最开始,每个元素都是一个只属于自己的最小集合。
void Init() { for (int i = 0; i < N; i++) { father[i] = i; }}
在文章最开始的例题中,题目依次给出了两个元素,表示这两个元素属于同一个集合,而我们就需要把这两个元素合并。所谓合并,其实就是“连接”。两个结点之间的连线,在代码里的表示是什么?显而易见就是father数组的值。于是有的同学(比如年轻的我)会很简单地写出了下面的函数:
void Union(int a,int b) { father[a] = b;}
我们可以看到,通过改变father数组的值,即是在两结点(a,b)之间增加了一条连线。比如1和2相连,就令father[2]=1。如果想让3和5相连,我们可以令father[3]=5。结果会如下图所示:
等等,这似乎和直觉有些不对……如果3和5连线,那么全部6个元素应该在同一个集合里而不是像现在这样仍然是两个集合……
这确实是初学者(没错就是我)经常好犯的一个错误,即在合并时直接把其中一个元素的父结点设为另一个元素,即直接令father[a]=b,而这并不能实现合并的效果。真正的Union函数如下:
void Union(int a, int b) { int fa = find(a); int fb = find(b); if (fa != fb) father[fa] = fb; //这里写成father[fb]=fa也可以}
在合并的过程中,由于有第4行判定条件的存在,我们只对两个不同的集合进行了合并,这样保证在合并过程中不会产生环,因此并查集产生的每一个集合都是一棵树。
2.quick-find算法
在这里,我们稍稍改变一下father数组的含义,我们令当且仅当father[a]=father[b]时a和b是连通的。换句话说,在同一个集合中的所有元素的father数组值必须全部相同。这将会带来一个好处,就是find的效率将会增加,因为只要取father数组一次就可以判断该元素所在的集合,不过带来的负面效应就是Union会更加麻烦一些。
void Union(int a, int b) { int fa = find(a); int fb = find(b); if (fa == fb) return; for(int i = 0; i < N; i++) { if (father[i] == fa) father[i] = fb; }}
quick-find算法的分析:
find( )操作的速度显然是很快的,因为它只需要访问id[ ]数组一次。但quick-find算法一般无法处理大型问题,因为对于每一对输入union( )都需要扫描整个id[ ]数组。
《算法》(第4版)
优化
在前面两种实现方法的对比中,quick-union略胜一筹,事实上在我所有使用并查集的情况下,我选择的全都是前者的实现方法。quick-find确实是有优点,那么我们是不是可以对quick-union做一些优化来提高它的查询效率呢?
路 径 压 缩
我们来看这么一种极端情况,4个元素形成了下图的集合关系。
在这个例子中如果要查询4的根结点,需一个个往上查找。这里只举了4个元素所以不觉得低效,但如果有10^5个元素、10^8个元素时,每次查询靠后结点的根结点时,都要经过大量的迭代向上查询,此时的低效就是不能承受的了。
既然我们的目的只是查询根结点,那自然而然地想到了这样的优化:
这样的优化相当于把查询路径上的所有结点的父结点都变为所在集合的根结点,避免了查找时的迭代,复杂度由最恶劣时的O(n)降为O(1)。
实现的方法同样有循环和递归两种,读者在这里可以好好地思考一下。
//循环int find(int x) { int a = x; //记录x的初值 while (x != father[x]) x = father[x]; while (a != father[a]) { //重走原先的查找路径 int temp = a; //记录a的值,因为a即将改变 a = father[a]; //迭代去下一个父结点 father[temp] = x; //把路径上的父结点都赋值为根结点 } return x;}//递归int find(int x) { if (x == father[x]) //找到根结点 return x; int fx = find(father[x]); //递归寻找根结点 father[x] = fx; //路径压缩 return fx;}
按 秩 合 并
还记得前文提到的一个结论吗:并查集产生的每一个集合都是一棵树。
该优化方法使用“秩”来表示树的高度,在合并时,总是将具有较小秩的树的根结点指向具有较大秩的树的根结点。为了保存秩,需要额外使用一个rank数组,并将所有元素都初始化为 0。
int rank[N];for (int i = 0; i < N; i++) { rank[i] = 0; //初始化,这里也可以为1}void Union(int a, int b) { int fa = find(a); int fb = find(b); if (fa == fb) return; if (rank[fa] > rank[fb]) father[fb] = fa; else { //总是将较矮的树合并到较高的树上去 father[fa] = fb; if (rank[fa] == rank[fb]) rank[fb]++; //别忘了合并后树的高度+1 }}
而在《算法》(第4版)中作者介绍了与按秩合并很相似的加权quick-union算法。
在普通的quick-union算法中,两个集合在并运算时是随意连接的。而现在我们会记录每一个集合的大小,并总是将较小的集合连接到较大的集合上去。(想一想为什么这样做可以提高效率?)这项改动需要添加一个size数组来记录集合中的元素个数。
int size[N];for (int i = 0; i < N; i++) { size[i] = 1; //初始化}void Union(int a, int b) { int fa = find(a); int fb = find(b); if (fa == fb) return; if (size[fa] < size[fb]) { father[fa] = fb; size[fb] += fa; //更新集合的大小 } else { //保证将较小的集合连接到较大的集合中去 father[fb] = fa; size[fa] += fb; //更新集合的大小 }}
整合一下代码:
//这里我选择了路径压缩+按秩合并优化int father[N];int rank[N];void Init() { for (int i = 0; i < N; i++) { father[i] = i; } for (int i = 0; i < N; i++) { rank[i] = 0; }}int find(int x) { int a = x; while (x != father[x]) x = father[x]; while (a != father[a]) { int temp = a; a = father[a]; father[temp] = x; } return x;}void Union(int a, int b) { int fa = find(a); int fb = find(b); if (fa == fb) return; if (rank[fa] > rank[fb]) father[fb] = fa; else { father[fa] = fb; if (rank[fa] == rank[fb]) rank[fb]++; }}bool query(int a, int b) { //查询两个结点是否属于同一集合 if (find(a) == find(b)) return true; else return false;}
读者也可自行改写,将其封装成一个类。
复杂度分析
并查集的空间复杂度是O(n),这点是很显然的。如果使用了按秩合并或者加权quick-union算法优化,那么需要一个额外的辅助数组空间。
至于时间复杂度,读者可以把优化后的find( )和Union( )函数的均摊效率看做常数级即O(1)。对数学感兴趣的读者可以参阅《算法导论》的相关内容,里面有证明时间复杂度其实为Ackerman函数的反函数。
用途
并查集的用途不仅仅局限于合并集合。再比如可以通过并查集判断所给的图是否连通(还记得学图论时有什么别的方法可以判断吗?);最小生成树中的克鲁斯卡尔(Kruskal)算法也要用到并查集;还可以解决最近公共祖先(Lowest Common Ancestors,LCA)问题。有兴趣的小伙伴可以在你常去的OJ网站刷题练习。
参考书籍: 《算法》(第4版) 《算法笔记》 讲解插图均使用Microsoft PowerPoint制作
由于作者水平有限,难免会有笔误或是讲解不到位的地方,欢迎在公众号后台留言指正!更期待志同道合的朋友与我私信交流,共同进步。
我是Nu,期待下次在这里和你相见~