并查集
一个管理元素所属集合的数据结构,通常实现为一个森林,每一个集合实现为一棵树。并查集的应用不体现其树形结构的特性,更倾向于连通性,所以不分类到 Tree(或许应该分类到图论)
定义
并查集初始状态下,每个元素都位于一个单独的集合,表示为一棵只有根节点的树。我们需要维护一个 parent 数组记录每个元素所在树的祖先节点,不需要维护子节点信息
并查集有两种操作:
1- “并”,将两个元素所在的树合并为一棵树,表示处于同一集合
2- “查”,返回某一元素的 parent 值,可以通过判断两个元素的 parent 值是否相等来判断两个元素是否处于同一集合
实现
初始化
因为我们只需要维护每个节点的 parent 信息,所以一个一维数组就可以存储整个并查集
1 2 3 | |
查操作
查找一个节点所在树的根节点,因为每个节点都知道自己的某个祖先,所以利用 parent 链进行递归传递,最终能够获得根节点
1 2 3 4 5 | |
路径压缩
不难发现在向上寻找根节点的过程中,路径上每个节点对应的根节点都是最终找到的那一个
并查集只依赖树形结构的连通性,因此我们将路径上所有的节点的根节点都一并更新,作扁平化处理
1 2 3 4 5 6 | |
在路径压缩的优化下,并查集的查操作从最坏 $O(n)$ 优化到了 $O(\alpha(n))$ 的(几乎是)常数时间复杂度($\alpha(n)$ 是反 Ackerman 函数,看不懂)
并操作
先找到两个节点的根节点,然后将 src 节点的根节点连接到 dst 节点的根节点,(尤其是搭配路径压缩)可以减少树的深度,降低均摊复杂度
1 2 3 | |
启发式合并
实际上,因为我们只考虑树形结构的连通性,正确性上并不需要区分哪个节点作为根节点 dst,但是为了降低并操作后树的深度,我们可以选择节点较少或深度较小的树作为 src
下面的两种方法中,按大小合并更常用一些
按大小合并 (Union by Size)
我们需要额外记录一个 size 信息:每棵树的节点个数 / 集合的大小
初始化时额外记录一个 sz 数组,记录集合大小:
1 2 3 4 5 6 7 | |
合并时需要比较大小进行合并
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | |
按秩合并 (Union by Rank)
我们需要额外记录一个 rank 信息:每棵树的高度上界(不是高度本身)
初始化时额外记录一个 rank 数组:
1 2 3 4 5 6 7 | |
合并时需要比较秩进行合并,因为 rank 指的是每棵树的高度上界,只有两棵树的秩相等时,并操作之后才会增加树的秩
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | |
同时使用路径压缩和按秩合并的启发式搜索,前者是否对后者有影响?
虽然路径压缩会减小树的高度,但是秩记录的是树的高度的上界而不是树高度本身。路径压缩不会增大树的高度,也就不会改变秩
我们只是通过树的相对大小启发式合并,因此不会有影响
Extras
单点删除
如果直接将一个点删除并重建整个树,时间复杂度上升到 $O(n)$,因此考虑懒删除法:
节点在树上不会被删除,这个节点变成了虚节点,其不再具有实际结点的意义,但是维系了整个集合的连通性
如何实现呢?我们首先需要为虚节点单独开辟额外的空间,然后建立一个映射表,指示每个节点实际上处于的位置
(比如总共有 1000 个节点,节点 2 被删除以后映射到 1001,原来的节点 2 只用来维系原来的树的连通性)
1 2 3 4 5 6 7 8 | |
然后添加删除操作:
1 2 3 4 5 6 7 | |
查操作需要相应的修改,先将节点进行一次映射
1 2 3 4 5 6 7 | |
对于普通的并操作也是多一次映射,这在 find 操作中已经包含
1 2 3 4 | |
对于启发式合并,只需要多一步 x = f[x] 的操作即可
这种懒删除法在删除操作较多时会增大空间开销,是否会降低查找速率
坏消息是:这种方法在删除操作频繁时确实会提高空间复杂度,dsu 数组的大小可能需要动态调整,这取决于删除操作的次数
好消息是:因为路径压缩的优化,单次查找的时间复杂度依旧是 $O(\alpha(n))$
正确性没有验证
