并查集
并查集
等价关系是满足下列3个性质的关系R:
- 自反性,对于所有的a∈S,有aRa。
- 对称性,aRb当且仅当bRa。
- 传递性,若aRb且bRc,则aRc。
如何判断a和b是否是等价的的呢?或者说如何将两个有等价关系的元素划入一个集合中?
一个满足a∈S的元素a,其等价类是S的这样一个子集:它包含了所有与a有等价关系的元素。也就是说,等价类形成了对S的划分:S的每一个成员都恰好出现在了一个等价类中,如果a等价b(记作a~b),只需要验证a和b是否在同一个等价类中。
最开始时,所有的元素的都是独立的,或者说是每个元素的等价集合中只有自己,我们这种没有任何相同元素的两个集合为不相交的。
接下来,要判断元素之间是否是等价的,只需要执行两个操作,一个是查找find
,一个是合并union
,就是说,首先查找两个元素之间是否已经在同一个等价类中了,称为find
,如果不在且两者有某种符合条件的等价关系,就对两者进行合并操作,去掉两者各自集合,生成一个新的集合,称为union
。这项工作因此也被称为不相交集的union/find算法。
这种算法是动态的,因为随着算法的执行,集合通过union操作而发生改变。同时,该算法也必然是联机的,当find执行时,它必须给出答案,然后算法才能继续下去。另外有一种脱机的算法,需要观察全部的union和find序列。
联机和脱机:联机是指必须执行当前任务才会进行下一步,脱机是指可以获取到全部的内容,只要在规定的时间或空间范围内完成即可。类似于笔试和面试,笔试一般是脱机的——你只要在考试时间内完成试卷即可,面试一般是联机的——你只有回答了当前的问题,才能继续回答下一个提出的问题。
基本数据结构
对于查找操作来说,我们并不需要返回任何特定的名称或其他,而只需要两个元素在相同集合时,通过find操作两者能够得到相同的结果。于是,我们可以用树来表示每一个集合(树的集合称为森林),因为同一棵树上的每个元素都能找到相同的根。
那么,我们就可以这样做,使用一个数组来表示各元素的父节点,初始化是-1表示是根节点,在union(x,y)操作时,规定将x作为了y这颗树的父节点。
union(4,5)后,5的父节点指向了4,union(6,7)后,7的父节点指向了6,再union(4,6)后,6的父节点指向了4。
简单实现如下,需要说明,这可以完成上述的操作,但不是好的实现。
1 | class UnionSets { |
灵巧求并算法
上述我们规定union(x,y),总是将y的根节点指向x,这是比较随意的,可想而知随着树深度的增加,递归的程度也越深。
那么,我们就要考虑如何在合并的过程中,减少树的深度的增加。
我们可以想到两种方法,一是按大小合并——总是将较小的树合并到较大的树上;二是按高度合并——总是将浅的树合并到深的树上。这并不是困难的,因为我们完全可以用记录位置的数组来记录树的大小和高度,比如一个位置上的值是-3,就可以表示这棵树的大小或高度是3,合并的过程就是负数相加的过程,初始时数组中都是-1(不过高度是从0开始,需要存储的高度减个1)。
1 | // 假设是做过了检查操作了,入参都是合法的 |
路径压缩
我们对于union操作进行了一定程度的优化,同样,find操作也可以更聪明些。在最开始的find操作中,我们只是按照顺序地递归查找了整颗树,如果要查找的元素处于树的很深的位置,相当于每次都要查找相当长的路径。
基于空间局部性的原理,我们有理由这么做——如果要查找的元素较深,那么我们可以把它和其路径上的元素向上提一提。
这种较为聪明的操作叫做路径压缩,当我们要查找x元素时,从x到根路径上的每一个节点,都让其父节点变成根节点。
1 | int UnionSets::find(int x) const { |
路径压缩和按大小合并的做法是完全兼容的,因此可以结合两者做到快速的求不相交集。
一个应用
不相交集最常见的应用就是用于生成一个迷宫,迷宫仅有唯一一条通关路线,并有许多的干扰路线,相当于x和y在且仅在一个等价集合中。于是我们可以用若干相连的方块拼接成一个矩形,从左上角的x方块开始,到右下角的y结束,每次都任意选择两个方块进行合并,直到能find到x和y。