type
Post
status
Published
date
Oct 20, 2022
slug
unionfind
summary
介绍一下动态连通性问题和并查集,草履虫都能看懂哦
tags
开发
category
技术分享
icon
password

动态连通性(Dynamic Connectivity)

我们定义有 N 个相互独立的节点,并提供以下两个操作。
连接:将两个节点进行连接。
是否连通:判断两个节点是否连通
notion image
如图,初始状态下,每一个节点都是单独独立的节点。我们先后连接 节点3节点4节点3节点8节点6节点5节点9节点4节点2节点1节点5节点0节点2节点7节点6节点1节点0节点1 ,此时我们去判断 节点0节点7 是否连通。在执行完多个节点连接的操作后,去判断节点是否连通的问题就是动态连通性问题。
notion image
我们会运用到什么场景?
两个国家是不是在同一片大陆
社交关系的两个人

什么是并查集?

并查集就是合并(Union)、查询(Find)
合并(Union):把两个不相交的集合合并为一个集合。
查询(Find):查询两个元素是否在同一个集合中。
对于动态连通性问题,我们就可以使用并查集来解决。

建立模型

我们使用并查集的思想,去解决动态连通性问题。
  1. 我们可以把连通的节点放入到同一个集合中。
  1. 当我门连接两个不连通的节点时,就将这两个节点所在的集合合并成一个集合。
  1. 当查询两个节点是否连通的时候,只用判断这两个节点是否在一个集合中即可。
notion image
我们抽象如下的接口,实现这个接口以完成不同并查集实现的算法。
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 都是可以的。
notion image
当我们判断 节点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 nodeq node ,他们所对应的集合的编号是 pidqid ,当 id[i] == pidid[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-FindUnion 的实现。
@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; } } }
可以发现,当我们仅仅连通两个节点的时候,他需要将某个节点所在集合中的所有元素都进行更新,这就是造成需要遍历的核心原因。
我们需要提出一个更高效的关系结构,以达到更新更少的节点。除去线性表这种存储结构,我们还能容易想到这个更紧凑的数据结构。

把他们挂在树上

notion image
Find:当连通的节点都存储在一个树上,那么判断他们是否连通只需要判断他们的根节点是否为同一个。例如我们要判断 节点2节点3 是否连通,只需要找到他们的根节点也就是 节点9 ,因此可以判断他们连通。
Union:当我们要连通两个节点的时候,需要找寻到他们的根节点,并让某一个根节点成为另一个根节点的叶子结点。如将 节点3节点5 连通,我们就需要把根节点为节点9 和 根节点为节点6 的两个树进行合并,合并成如下图所示的样子。
notion image

把树捋顺了!!

我们怎么存储这个树结构也是一个学问,你或许写过如下所示的二叉树。
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数组 中的表示方式。
notion image
notion image
初始化,由于每个节点最开始的时候,都是一个单独的集合,所以每个节点都是根节点,因此需要让 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 层的树,此时树退化成了链表。
notion image
notion image
notion image
notion image
 
 

总结

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

notion image
Quick-Union 的形成 On 的原因其实很好理解,聪明的你也肯定想到了解决的办法。那就是反过。让那个独立的树合并进大的树。这样即使合并完所有的节点,树的层数也是2层,当我们只有 5 个节点的时候不明显,但是如果 N = 1000000 呢?原先的 Quick-Union 需要 1000000 次向上查找才能查找到根节点,而这种方式,只需要 2 次,优化效果显著。
notion image

枝繁叶茂

利用刚刚优化的思想,我们很容易相当到,合并两个树的时候,当哪个树拥有的节点数量更多的时候,让他成为另一个的节点的父节点。因此我们需要一个结构存储这个根节点包含的节点数量,我们使用一个 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 实现,他只会在一种情况下出现树的层数变大,那就是两个根节点的层数相同,小根节点成为大根节点叶子节点,而导致大根节点的层数增加。
notion image
notion image
那么节点的层数怎么计算?我们不难发现,我们可以粗浅的认为,但出现节点层数增加的时候,大根节点所包含的节点数量是翻倍的。我们假设有 N 个节点,我们通过 Union 操作使得所有的节点合并成了一个树,那么他最多发生 x 次翻倍,也就是 2^x = N,得到 x = lgN ,而翻倍一次层数+1,可以粗浅的认为这棵树最多有 x = lgN 层。所以 findRoot 的时间复杂度为 lgN。

路径压缩

我们仔细想想,其实对于同一个根下的节点,他们的关系都是一样的,就是他们都在一个集合中,只要他最终的根节点还是那个根节点就行。至于他的父节点?只要是这个树下的任何一个节点都行。那我们其实在 findRoot 的过程就可以想到,把这条路径进行压缩,以减小树的层数。
notion image
notion image
而压缩的办法呢?那就是更新我们所遇到的节点的父节点为祖父节点。
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; } }
 
迁移到事件驱动架构的好处Oh My ZSH 配置