假设有n个村庄,有些村庄之间有连接的路,有些村庄之间并没有连接的路。
如下图,村庄6和村庄7与其他村庄都没有连接:
设计一个数据结构,能够快速执行2个操作:
- 查询2个村庄之间是否有连接的路
- 连接2个村庄(比如连接1和9,左边和右边的村庄都有路了)
使用数组、链表、平衡二叉树、集合可以执行上面的操作么?当然可以,只是效率会比较低(哈希集合可能效率会高一点,但是有点杀鸡用牛刀)。查询、连接的时间复杂度都是O(n)
。并查集能够办到查询、连接的均摊时间复杂度都是O(α(n)) ,α(n) < 5
,并查集非常适合解决这类“连接”相关的问题。
一、并查集
1.1. 基本概念
并查集(Union Find)也叫作不相交集合(Disjoint Set)。
并查集有2个核心操作:
- 查找(Find):查找元素所在的集合(这里的集合并不是特指
Set
这种数据结构,是指广义的数据集合)。 - 合并(Union):将两个元素所在的集合合并为一个集合。
有2种常见的实现思路:
Quick Find
- 查找(Find)的时间复杂度:
O(1)
- 合并(Union)的时间复杂度:
O(n)
- 查找(Find)的时间复杂度:
Quick Union
- 查找(Find)的时间复杂度:
O(logn)
,可以优化至O(α(n)), α(n) < 5
- 合并(Union)的时间复杂度:
O(logn)
,可以优化至O(α(n)), α(n) < 5
- 查找(Find)的时间复杂度:
1.2. 数据存储方式
假设并查集处理的数据都是整型,那么可以用整型数组来存储数据。如下图,数组索引代表村庄,存储的值代表是哪个集合或父节点。
可以使用下面的图表示他们所属关系:
不难看出,0、1、3
属于同一集合,2
单独属于一个集合,4、5、6、7
属于同一集合。因此,并查集是可以用数组实现的树形结构(二叉堆、优先级队列也是可以用数组实现的树形结构)。
判断两个元素是否在同一个集合中,可判断他们是否是同一个根节点(如上图,0的根节点是1,4的根节点是6)。
1.3. 接口定义
1 | /** |
1.4. 初始化
不管是Quick Find还是Quick Union都需要进行初始化,而且初始化时,每个元素各自属于一个单元素集合。
例如元素0~4:
每个元素就是一个独立集合:
1 | protected int[] parents; |
二、Quick Find
初始化下面的数据:
2.1. Union
合并集合,才能让元素之间建立关系。而合并的本质就是让他们有指向关系。
union(v1, v2)
表示的意思:让v1
所在集合的所有元素都指向v2
的根节点(注意:v1
指向v2
和v2
指向v1
,方向不同但最终结果是一致的)。
合并1和0(把1所在集合的元素都合并到0所在的集合0中),修改1的父节点为0:
合并1和2(把1所在集合的元素都合并到2所在的集合2中)。由于之前0和1在同一个集合0中,所以集合0的所有元素都会被合并到集合2中,修改0和1的父节点为2即可:
合并3和4(把3所在集合的元素都合并到4所在的集合4中)。修改3的父节点为4:
合并0和3(把0所在集合的元素都合并到3所在的集合4中)。由于之前0、1、2在同一个集合2中,所以集合2的所有元素都会被合并到集合4中,修改0、1、2的父节点为4即可:
通过上面的示例可以看出,要想找到一个元素所属的集合,只需要向上找而且只需要找一步就可以找到所属集合(即,根节点),也就是索引对应位置存储的数据就是要找的集合(树的高度最多是2)。
1 | /** |
时间复杂度:O(n)
。
2.2. Find
1 | /** |
以上图为例:
1 | find(0) == 2 |
时间复杂度:O(1)
。
三、Quick Union
3.1. Union
union(v1, v2)
表示的意思:让v1
的根节点指向v2
的根节点(注意和Quick Find的区分)。
还是以下图为例:
合并1和0(1的根节点修改成0的根节点)。0和1的根节点是自己,所以直接修改1的父节点为0:
合并1和2(1的根节点修改成2的根节点)。1原来的根节点是0,2的根节点是自己,所以要把1的根节点0修改为2:
合并3和4(3的根节点修改成4的根节点)。由于3和4原来的根节点都是自己,所以直接修改3的根节点为4:
合并4和1(4的根节点修改成1的根节点)。4原来的根节点是4,1的根节点是2,所以要把4的根节点4修改为2:
合并0和3(0的根节点修改成3的根节点)。0原来的根节点是2,3的根节点是3,所以要把0的根节点2修改为3:
对比Quick Find发现,Quick Find合并节点时需要遍历每一个根节点,把对应的节点修改掉。而Quick Union合并节点只需要找到根节点并改动即可。
1 | /** |
时间复杂度(看find
的时间复杂度):O(logn)
。
3.2. Find
find
返回的是根节点。如下图,传入节点1,要返回的是根节点2。
1 | /** |
以上图为例:
1 | find(0) == 2 |
时间复杂度(树的高度):O(logn)
。
3.3. 优化
在Union的过程中,可能会出现树不平衡的情况,甚至退化成链表(如下图,查找1的根节点时间复杂度是O(n)
)。
针对上面的情况,有2种常见的优化方案:
- 基于
size
的优化:元素少的树嫁接到元素多的树(比如反过来,元素3嫁接到元素1上) - 基于
rank
的优化:矮的树嫁接到高的树(根据树的高矮进行优化是比较科学的,也是推荐做法)
3.3.1. 基于size的优化
维护一个sizes
数组,用来存放根节点所在树的元素数量。
1 | public class QuickUnion_Size extends QuickUnion { |
基于size的优化,也可能会存在树不平衡的问题(如下图),因此可以考虑基于rank的优化。
3.3.2. 基于rank的优化
如下图,按照树的高度进行合并,可以让树的高度整体降低。
合并时,只在高度相等的时候去调整树的高度,而且高度只会增加1。
1 | public class QuickUnion_Rank extends QuickUnion { |
虽然有了基于rank的优化,树会相对平衡一点。但是随着Union次数的增多,树的高度依然会越来越高,导致find操作变慢,尤其是底层节点(因为find是不断向上找到根节点)。这时候就可以使用路径压缩进行优化。
3.3.3. 路径压缩
什么是路径压缩(Path Compression)?在find时使路径上的所有节点都指向根节点,从而降低树的高度。
例,find(1)
操作后会有下面的变化(1到根节点4的路径上所有节点都直接指向根节点,即1、2、3指向根节点):
在find(1)
后依次执行find(0)、find(7)
:
树的高度变矮了,此时find效率会提高很多(union效率也会随之提高)。
1 | public int find(int v) { // v == 1, parents[v] == 2 |
路径压缩使路径上的所有节点都指向根节点,所以实现成本稍高(优化后的执行效率不是很明显)。
还有2种更优的做法,不但能降低树高,实现成本也比路径压缩低:
- 路径分裂(Path Spliting)
- 路径减半(Path Halving)
路径分裂、路径减半的效率差不多,但都比路径压缩要好。
3.3.4. 路径分裂
路径分裂:使路径上的每个节点都指向其祖父节点(parent
的parent
)。
1 | public int find(int v) { |
3.3.5. 路径减半
路径减半:使路径上每隔一个节点就指向其祖父节点(parent
的parent
)
1 | public int find(int v) { |
四、扩展
《维基百科》: https://en.wikipedia.org/wiki/Disjoint-set_data_structure#Time_complexity
大概意思:使用路径压缩、分裂或减半 + 基于rank或者size的优化,可以确保每个操作的均摊时间复杂度为O(α(n)) ,α(n) < 5
。
个人建议的搭配:Quick Union + 基于 rank 的优化 + 路径减半或路径分裂。
上面的使用都是基于整型数据,如果其他自定义类型也想使用并查集呢?
- 方案一:通过一些方法将自定义类型转为整型后使用并查集(比如生成哈希值)
- 方案二:使用链表 + 映射
4.1. 自定义对象
通过Map实现自定义对象是如何使用并查集的。
1 | // 统一实现并查集 |
4.2. 趣说Quick Find和Quick Union的区别
Quick Find:A帮派大哥带着所有小弟加入到新帮派B,A帮派大哥变小弟并和小弟同一级别。
Quick Union:A帮派大哥带着所有小弟加入到新帮派B,A帮派大哥变成新帮派B大哥的小弟,A帮派原来的小弟还是认A帮派为哥。