type
Post
status
Published
date
Oct 20, 2022
slug
unionfind
summary
介绍一下动态连通性问题和并查集,草履虫都能看懂哦
tags
开发
category
技术分享
icon
password
动态连通性(Dynamic Connectivity)
我们定义有 N 个相互独立的节点,并提供以下两个操作。
连接:将两个节点进行连接。
是否连通:判断两个节点是否连通
如图,初始状态下,每一个节点都是单独独立的节点。我们先后连接
节点3
和 节点4
、节点3
和 节点8
、节点6
和 节点5
、节点9
和 节点4
、节点2
和 节点1
、节点5
和 节点0
、节点2
和 节点7
、节点6
和 节点1
、节点0
和 节点1
,此时我们去判断 节点0
和 节点7
是否连通。在执行完多个节点连接的操作后,去判断节点是否连通的问题就是动态连通性问题。我们会运用到什么场景?
两个国家是不是在同一片大陆
社交关系的两个人
什么是并查集?
并查集就是合并(Union)、查询(Find)
合并(Union):把两个不相交的集合合并为一个集合。
查询(Find):查询两个元素是否在同一个集合中。
对于动态连通性问题,我们就可以使用并查集来解决。
建立模型
我们使用并查集的思想,去解决动态连通性问题。
- 我们可以把连通的节点放入到同一个集合中。
- 当我门连接两个不连通的节点时,就将这两个节点所在的集合合并成一个集合。
- 当查询两个节点是否连通的时候,只用判断这两个节点是否在一个集合中即可。
我们抽象如下的接口,实现这个接口以完成不同并查集实现的算法。
public interface IDynamicConnectivity { void union(int p,int q); boolean connected(int p,int q); }
Quick-Find 快速查询
我们使用一个
int
数组来表示每一个节点所在的集合。如 节点0
所在的集合标记由 id[0]
来存储。由于初始状态下,每一个节点都是一个独立的节点,那么 N 个节点就需要 N 个集合,id
数组的初始化就显而易见了即 id[i] = i
。public class QuickFind implements IDynamicConnectivity { private int[] id; public QuickFind(int size) { id = new int[size]; for (int i = 0; i < id.length; i++) { id[i] = i; } } }
tips: 其实这里等式的右边半只要不重复皆可,它就是集合的唯一标识,不一定需要等于i
,如果你喜欢id[i] = i + 114514
都是可以的。
当我们判断
节点3
和 节点9
是否在一个集合的时候,只需要判断 id[3] == id[9]
即可。那 connected
方法的实现就很简单明了了,而此方法也因此得名 Quick-Find
,因为他查询仅需要 O1 的时间复杂度。@Override public boolean connected(int p, int q) { return id[p] == id[q]; }
那是用这个模型合并两个集合的方法就显而易见了,只需要将需要合并的两个集合的编号
id[node]
设置为其中一个集合的编号 id[node]
即可。现在我们假设连通 p node
和 q node
,他们所对应的集合的编号是 pid
和 qid
,当 id[i] == pid
将 id[i]
设置为 qid
。所以得到union
方法的实现如下。@Override public void union(int p, int q) { int pid = id[p]; int qid = id[q]; for (int i = 0; i < id.length; i++) { if (id[i] == pid) { id[i] = qid; } } }
tips: 这里很容易犯一个错,那就是id[i] == pid
这里,有人可能会写成id[i] == id[p]
,这显然是错误的。因为过程中会修改到id[p] = qid
那么id[p]
之后的节点就变成判断id[i] == qid
。
复杂度分析
空间复杂度:由于需要 N 个空间存储每个节点所在的集合,所以空间复杂度就是 On
时间复杂度分析如下
Init | Union | Find |
N | N | 1 |
Init:初始化数组需要进行遍历时间复杂度为 On
Union:合并集合也需要遍历所以时间复杂度为 On
Find: 查询操作通过数组下标直接对比得出,时间复杂度为 O1
总结
Quick-Find
实现顾名思义,查询很快,他能在 O1 的时间复杂度中获取到结果,但所带来的代价就是 Union
操作的时间复杂度很慢,当我们数组元素非常多时 N 会线性增长。public class QuickFind implements IDynamicConnectivity{ private int[] id; public QuickFind(int size) { id = new int[size]; for (int i = 0; i < id.length; i++) { id[i] = i; } } @Override // On的查找时间 public void union(int p, int q) { int pid = id[p]; int qid = id[q]; for (int i = 0; i < id.length; i++) { if (id[i] == pid) { id[i] = qid; } } } @Override // O1的比较时间 public boolean connected(int p, int q) { return id[p] == id[q]; } }
Quick-Union
刚刚我们了解完了
Quick-Find
实现,如果我们面对的是一个 Union
操作频繁的场景,那么 Quick-Find
就无法体现出他的优势,我们需要更新我们的模型,以提高 Union
操作的效率。我们分析在 Quick-Find
中 Union
的实现。@Override public void union(int p, int q) { int pid = id[p]; int qid = id[q]; for (int i = 0; i < id.length; i++) { if (id[i] == pid) { id[i] = qid; } } }
可以发现,当我们仅仅连通两个节点的时候,他需要将某个节点所在集合中的所有元素都进行更新,这就是造成需要遍历的核心原因。
我们需要提出一个更高效的关系结构,以达到更新更少的节点。除去线性表这种存储结构,我们还能容易想到树这个更紧凑的数据结构。
把他们挂在树上
Find:当连通的节点都存储在一个树上,那么判断他们是否连通只需要判断他们的根节点是否为同一个。例如我们要判断
节点2
和 节点3
是否连通,只需要找到他们的根节点也就是 节点9
,因此可以判断他们连通。Union:当我们要连通两个节点的时候,需要找寻到他们的根节点,并让某一个根节点成为另一个根节点的叶子结点。如将
节点3
和 节点5
连通,我们就需要把根节点为节点9
和 根节点为节点6
的两个树进行合并,合并成如下图所示的样子。把树捋顺了!!
我们怎么存储这个树结构也是一个学问,你或许写过如下所示的二叉树。
public class TreeNode { int value; TreeNode left; TreeNode right; }
但我们或许可以换个思路,这种数据结构是由父节点查找到子节点,自顶向下的。而我们要连通的两个节点,可能是两个叶子结点,这种数据结构我们无法向上查找。所以需要用子节点查询父节点的数据结构。
public class TreeNode { int value; TreeNode father; }
这样我们就可以查询到该节点的父节点了。如果这个节点的父节点就是他自己,则说明他是这个树的根节点。
而最后我们可以通过链表模拟树的方式,来简化这个树的存储。依然是用一个
int[] id
数组,我们使用 id 数组
存储每个节点的父节点,也就是 节点i
的父节点为 id[i]
。如何在这个数组中获取到 节点i
的 根节点
呢?一直通过这个节点向上查询,直到某个节点的父节点是他本身,那么这个节点就是 节点i
的 根节点
,即 id[id[…id[i]…]]
就是 节点i
的 根节点
。下图展示的就是这些节点集合在 id数组
中的表示方式。初始化,由于每个节点最开始的时候,都是一个单独的集合,所以每个节点都是根节点,因此需要让
id[i] = i
,则可以让每一个节点的父节点是自己了。public QuickUnion(int size) { id = new int[size]; for (int i = 0; i < id.length; i++) { id[i] = i; } }
tips: 与 Quick-Find 不同,这里id[i]
不再是单纯的标记,他需要承接节点的关系。
我们再复习一遍在树上的 Union & Find
- Find:当连通的节点都存储在一个树上,那么判断他们是否连通只需要判断他们的根节点是否为同一个。
- Union:当我们要连通两个节点的时候,需要找寻到他们的根节点,并让某一个根节点成为另一个根节点的叶子结点。
是的,Find & Union 都需要有一个找寻梗节点的方法所以我们先实现找寻根节点的方法,我们只需要不断找寻某个节点的父节点,并继续找寻父节点的父节点直到父节点是他本身。
private int findRoot(int p) { while (p != id[p]) { p = id[p]; } return p; }
Find:我们只需要找寻到
节点q
的根节点 和 节点p
的根节点,并比较他们是否是同一个节点即可。@Override public boolean connected(int p, int q) { return findRoot(p) == findRoot(q); }
Union:我们找寻到
节点q
的根节点 和 节点p
的根节点,并放 节点p
的根节点的父节点设置为 节点q
的根节点即可。@Override public void union(int p, int q) { int proot = findRoot(p); int qroot = findRoot(q); id[proot] = qroot; }
复杂度分析
空间复杂度:由于需要 N 个空间存储每个节点所在的集合,所以空间复杂度就是 On
时间复杂度分析如下
Init | Union | Find |
N | 最快O1 最差On | 最快O1 最差On |
Init:初始化的时候需要遍历一遍数组,所以时间复杂度是 On
Union & Find:从代码可知,这两个方法的实现都关联到
findRoot
方法的时间复杂度。而findRoot
方法时间复杂度是不稳定的。他的时间复杂度,与节点距离根节点的高度有关。当每个节点都是根节点的时候,findRoot
方法的时间复杂度就是 O1,当所有节点都在同一棵树上,并且这个树退化成了链表,那么此时时间复杂度就是 On。On 是怎么诞生的?我们现在有 5 个节点,那么当我们union(0,1)
的时候他会形成一个2层的树,但我们继续union(0,2)
的时候就会形成一个3层的树,依次类推,N 个节点最终会形成一个 N 层的树,此时树退化成了链表。
总结
Quick-Union
实现是为了能提高我们合并两个集合的效率,把节点存储在树结构上,可以使得查询只需要访问一条路径上的节点,减少了访问无关节点的访问。虽然 Union & Find
的时间复杂度都是最差 On,看起来好像没有 Quick-Find
实现来的稳定高效,但如果是出现在一个 Union
操作频繁的场景,他带来的收益是客观的。虽然我们提出了一个不稳定的实现,但我们得到了个更高效的模型。public class QuickUnion implements IDynamicConnectivity{ private int[] id; public QuickUnion(int size) { id = new int[size]; for (int i = 0; i < id.length; i++) { id[i] = i; } } @Override public void union(int p, int q) { int proot = findRoot(p); int qroot = findRoot(q); id[proot] = qroot; } @Override public boolean connected(int p, int q) { return findRoot(p) == findRoot(q); } // 最坏 On 的查找时间 private int findRoot(int p) { while (p != id[p]) { p = id[p]; } return p; } }
Weighted Quick-Union
在刚刚我们一起实现完
Quick-Union
后,得到了一个不稳定的最差时间复杂度为 On 的 Union & Find
实现,我们知道影响这个时间复杂度的核心原因是树的层数,很快我们就能想到优化数的层数,以达到优化找寻到根节点的时间复杂度。干掉On
在
Quick-Union
的形成 On 的原因其实很好理解,聪明的你也肯定想到了解决的办法。那就是反过。让那个独立的树合并进大的树。这样即使合并完所有的节点,树的层数也是2层,当我们只有 5 个节点的时候不明显,但是如果 N = 1000000 呢?原先的 Quick-Union
需要 1000000 次向上查找才能查找到根节点,而这种方式,只需要 2 次,优化效果显著。枝繁叶茂
利用刚刚优化的思想,我们很容易相当到,合并两个树的时候,当哪个树拥有的节点数量更多的时候,让他成为另一个的节点的父节点。因此我们需要一个结构存储这个根节点包含的节点数量,我们使用一个
int[] sz
数组存储每个节点包含的节点数量。当我们合并两个节点,如 节点big
和 节点small
的时候,sz[big] = sz[big] + sz[small]
。Init:由于加入了新的
sz
数组我们的初始化也要初始化 sz
数组,初始状态下,每个节点都是独立的根节点,每个节点只包含他一个节点。public class WeightedQuickUnion implements IDynamicConnectivity { private int[] id; private int[] sz; public WeightedQuickUnion(int size) { id = new int[size]; sz = new int[size]; for (int i = 0; i < id.length; i++) { id[i] = i; sz[i] = 1; } } }
Union:当合并两个节点的时候,需要比较他们的所包含的节点数量,让小根节点成为大根节点的叶子节点。并更新大根节点所包含的节点数量。
@Override public void union(int p, int q) { int proot = findRoot(p); int qroot = findRoot(q); if (sz[proot] < sz[qroot]) { id[proot] = qroot; sz[qroot] += sz[proot]; } else { id[qroot] = proot; sz[proot] += sz[qroot]; } }
Find & findRoot: 没有变化。
@Override public boolean connected(int p, int q) { return findRoot(p) == findRoot(q); } private int findRoot(int p) { while (p != id[p]) { p = id[p]; } return p; }
复杂度分析
空间复杂度:由于需要 N 个空间存储每个节点所在的集合,所以空间复杂度就是 On
时间复杂度分析如下
Init | Union | Find |
N | 最快O1 最差OlgN | 最快O1 最差OlgN |
Init:初始化的时候需要遍历一遍数组,所以时间复杂度是 On
Find & Union:和
Quick-Union
一样,Find & Union
的时间复杂是由 findRoot
方法决定的,确切来说是由树的层数决定的。在我们优化后的 Union
实现,他只会在一种情况下出现树的层数变大,那就是两个根节点的层数相同,小根节点成为大根节点叶子节点,而导致大根节点的层数增加。那么节点的层数怎么计算?我们不难发现,我们可以粗浅的认为,但出现节点层数增加的时候,大根节点所包含的节点数量是翻倍的。我们假设有 N 个节点,我们通过
Union
操作使得所有的节点合并成了一个树,那么他最多发生 x 次翻倍,也就是 2^x = N
,得到 x = lgN
,而翻倍一次层数+1,可以粗浅的认为这棵树最多有 x = lgN
层。所以 findRoot
的时间复杂度为 lgN。路径压缩
我们仔细想想,其实对于同一个根下的节点,他们的关系都是一样的,就是他们都在一个集合中,只要他最终的根节点还是那个根节点就行。至于他的父节点?只要是这个树下的任何一个节点都行。那我们其实在
findRoot
的过程就可以想到,把这条路径进行压缩,以减小树的层数。而压缩的办法呢?那就是更新我们所遇到的节点的父节点为祖父节点。
private int findRoot(int p) { while (p != id[p]) { //路径压缩,节点的父节点,变成祖父节点相当于减少了一层。 id[p] = id[id[p]]; p = id[p]; } return p; }
如经过路径压缩,我们使得节点的层数减半。那么最后层数是lg(lg N)也就是lg*N。
总结
我们最后通过加权和路径实现枝繁叶茂,而压缩了层数,提高了
findRoot
的效率,得到了一个高效的并查集算法。public class WeightedQuickUnion implements IDynamicConnectivity{ private int[] id; private int[] sz; public WeightedQuickUnion(int size) { id = new int[size]; sz = new int[size]; for (int i = 0; i < id.length; i++) { id[i] = i; sz[i] = 1; } } //每次合并选择深度最小的那个节点合并 //什么情况下树的深度会加1?当两个节点节点数量一样大的时候深度就会+1 //此时节点数量翻倍,所以我们假设一个节点最多可以翻x倍 //得到 2^x = N所以树最大深度是 lgN @Override public void union(int p, int q) { int proot = findRoot(p); int qroot = findRoot(q); if (sz[proot] < sz[qroot]) { id[proot] = qroot; sz[qroot] += sz[proot]; } else { id[qroot] = proot; sz[proot] += sz[qroot]; } } @Override public boolean connected(int p, int q) { return findRoot(p) == findRoot(q); } //由于 union保证了树深 lgN所以 findRoot最差情况是 lgN private int findRoot(int p) { while (p != id[p]) { //路径压缩,节点的父节点,变成祖父节点相当于减少了一层。 id[p] = id[id[p]]; p = id[p]; } return p; } }
- 作者:NotionNext
- 链接:https://tangly1024.com/article/unionfind
- 声明:本文采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处。