并查集(Union Find),从字面意思不太好理解这东西是个啥,但从名字大概能够得知与查询和集合有关,而实际也确实如此。并查集其实是一种很不同的树形结构,用于处理一些不相交集合(Disjoint Sets)的合并及查询问题。java
之因此说并查集是一种“不同”的树形结构,是由于通常的树形结构都是父节点指向子节点的,而并查集则是反过来,子节点指向父节点,而且这棵树会是一棵多叉树。算法
并查集能够高效的用来解决链接问题(Connectivity Problem),咱们来看下面这样的一张图:数组
能够看到,该图中有不少的点,有些点之间有链接,而有些点之间则没有链接。那么此时就有一个问题是:这图中任意的两个点是否可能经过一条路径链接起来。对于这个问题,咱们使用并查集就能够高效的求解,由于并查集能够很是快地判断网络中节点间的链接状态。这里的网络指的是广义的网络,例如用户之间造成的社交网络,有时候也叫作图。bash
并查集对于一组数据来讲,主要支持两种操做:网络
union(p, q)
,把两个不相交的集合合并为一个集合。isConnected(p, q)
,查询两个元素是否在同一个集合中,也就是是否能够链接的。根据这两个操做,咱们就能够定义出并查集的接口了,这是由于并查集能够有多种实现方式,这里定义接口来作统一抽象:数据结构
package tree.unionfind; /** * 并查集接口 * * @author 01 * @date 2021-01-28 **/ public interface UnionFind { /** * 查询两个元素是否在同一个集合中 * * @param p p * @param q q * @return true or false */ boolean isConnected(int p, int q); /** * 合并两个元素到同一个集合中 * * @param p p * @param q q */ void unionElements(int p, int q); /** * 并查集中的元素数量 * * @return int */ int getSize(); }
若是咱们但愿并查集的查询效率高一些,那么咱们就能够侧重于查询操做,实现一个“Quick Find”性质的并查集。咱们可使用数组来表示并查集中的数据,数组中存放每一个元素所在的集合编号,例如 0 和 1。而数组的索引则做为每一个元素的 id,这样咱们在查询的时候,只须要根据数组索引取出相应的两个元素的集合编号,判断是否相等就能得知这两个集合是否存储在同一集合中,也就知道这两个元素是否能够“链接”。具体以下图:dom
例如,传入的 p 和 q,分别是 1 和 3。那么根据数组索引找到的元素编号都为 1,此时就能够判断出这两个元素属于同一集合,也就表明这两个元素之间能够“链接”,反之同理。因为数组的特性,这个查询的时间复杂度就是 $O(1)$,咱们就认为称这个并查集具备“Quick Find”性质。ide
合并操做也很简单,经过传入的 p 和 q,获得它们的集合编号。而后遍历数组,找其中一个集合编号,假定找的是 p 的集合编号,找到后将其更新为 q 的集合编号便可。固然,反过来也是能够的,这个没有特殊的规定。因为要遍历数组,所以合并操做的时间复杂度就是 $O(n)$。可见,“Quick Find”是牺牲了合并操做的效率。性能
具体的实现代码以下:测试
package tree.unionfind; /** * “Quick Find”性质的Union-Find * * @author 01 */ public class UnionFind1 implements UnionFind { /** * “Quick Find”性质的Union-Find本质就是一个数组 */ private final int[] ids; public UnionFind1(int size) { ids = new int[size]; // 初始化,每个ids[i]指向本身所在的数组索引,由于此时没有合并的元素 for (int i = 0; i < size; i++) { ids[i] = i; } } @Override public int getSize() { return ids.length; } /** * 查找元素p所对应的集合编号 * O(1)复杂度 */ private int find(int p) { if (p < 0 || p >= ids.length) { throw new IllegalArgumentException("p is out of bound."); } return ids[p]; } /** * 查看元素p和元素q是否所属一个集合,这里的p和q就是表示的数组索引 * O(1)复杂度 */ @Override public boolean isConnected(int p, int q) { // 只须要判断两个元素所属的集合编号是否相等便可 return find(p) == find(q); } /** * 合并元素p和元素q所属的集合 * O(n) 复杂度 */ @Override public void unionElements(int p, int q) { int pId = find(p); int qId = find(q); // 已是属于同一集合 if (pId == qId) { return; } // 合并过程须要遍历一遍全部元素,将两个元素的所属集合编号合并 for (int i = 0; i < ids.length; i++) { if (ids[i] == pId) { ids[i] = qId; } } } }
有“Quick Find”天然就有“Quick Union”,“Quick Find”的查询和合并操做不是那么的平衡,时间复杂度相差得比较大,是彻底牺牲了合并操做的性能。所以,这种并查集的实现思路并不经常使用,而“Quick Union”是相对来讲更经常使用,以及更标准的实现思路。由于“Quick Union”是基于树的,虽然这棵树也可使用数组来表示。
使用“Quick Union”思路实现并查集时,咱们将每个元素,看作是一个节点。但与普通的树形结构不一样的是,并查集的树是子节点指向父节点的,在以前也提到过。以下:
能够看到,3 这个子节点是指向它的父节点 2 的,而这个父节点是一个根节点则是会本身指向本身。接下来,咱们先看看合并操做。若是咱们要让元素 3 和元素 1 进行合并,只须要让元素 1 指向元素 3 的父节点元素 2 便可。以下所示:
还有一种状况就是,合并另外一棵树的子节点。例如,节点 5 有两个子节点 6 和 7,此时但愿将节点 7 与以前的节点 2 进行合并。对于这种状况其实只须要将其父节点 5 与节点 2 进行合并便可。以下所示:
从上图能够看出,“Quick Union”的并查集在合并集合时,其实就是在合并两棵树,而一棵树就是在表示一个集合。理解这种表示集合的方式很是重要。属于同一个根节点的元素,咱们就能够认为它们属于同一个集合。集合的合并就是树的合并,合并的方式是一棵树的根节点挂到另外一棵树的根节点下,成为对方的子树。就像是一个集合与另外一个集合合并后,成为对方的子集。
咱们使用数组来表示树形结构的并查集时,子节点指向父节点的指针实际就是存储父节点的数组索引。并且在初始化后,未进行合并操做时,每一个元素都是本身成为一棵树的根节点,表明不一样的集合。也就是说此时会有多棵树,这种状况称之为森林结构,这也是为何会存在合并两棵树的状况。以下所示:
对应的数组表示以下:
基于上图,若是咱们此时合并 4 和 3 这两个元素,也就是将 4 的指针指向 3,3 成为 4 的父节点。以下:
那么只须要更新数组中索引为 4 的元素的值为 3 便可,由于子节点只须要存储父节点的数组索引,此时就完成了合并操做。以下所示:
咱们再看看其他状况的合并操做:
因为树的特性,此时并查集的查询操做时间复杂度就是 $O(h)$,$h$ 为树的高度。由于查询两个节点是否属于同一集合,就等同于查询这两个节点是否属于同一棵树。那么,就得找到这两个节点的根节点,判断是不是同一个节点,因此时间复杂度取决于树的高度。同理,合并操做也是同样的,由于 B 节点须要与 A 节点合并的话,那么就得找到 A 节点的根节点,并将本身挂载到该根节点下。
接下来,咱们就实现“Quick Union”性质的并查集。代码以下:
package tree.unionfind; /** * “Quick Union”性质的Union-Find * * @author 01 */ public class UnionFind2 implements UnionFind { /** * “Quick Union”性质的Union-Find,使用一个数组构建一棵指向父节点的树 * parent[i]表示第一个元素所指向的父节点 */ private final int[] parent; public UnionFind2(int size) { parent = new int[size]; // 初始化,此时每个parent[i]指向本身,表示每个元素本身自成一颗树 for (int i = 0; i < size; i++) { parent[i] = i; } } @Override public int getSize() { return parent.length; } /** * 查找过程, 查找元素p所对应的集合编号 * O(h)复杂度, h为树的高度 */ private int find(int p) { if (p < 0 || p >= parent.length) { throw new IllegalArgumentException("p is out of bound."); } // 不断去查询本身的父亲节点, 直到到达根节点 // 根节点的特色: parent[p] == p while (p != parent[p]) { p = parent[p]; } return p; } /** * 查看元素p和元素q是否所属一个集合 * O(h)复杂度, h为树的高度 */ @Override public boolean isConnected(int p, int q) { return find(p) == find(q); } /** * 合并元素p和元素q所属的集合 * O(h)复杂度, h为树的高度 */ @Override public void unionElements(int p, int q) { // 找到p和q的根节点 int pRoot = find(p); int qRoot = find(q); if (pRoot == qRoot) { return; } // 将q的根节点挂载到p的根节点下,成为它的子节点 parent[pRoot] = qRoot; } }
在上一小节中,咱们实现了“Quick Union”性质的并查集,这也是并查集标准的实现方式。但这只是一个基础的实现,仍有许多优化空间。本小节就演示一下其中一种优化方法:基于size的优化。
在基础的“Quick Union”实现中,对 q 和 p 进行合并时,咱们只是简单地把 q 的根节点挂载到 p 的根节点下,没有去判断另外一棵树是什么形状的。此时在极端的状况下,并查集中的这棵树可能会退化成线性的时间复杂度:
为了解决这个问题,咱们须要在合并时,考虑当前这棵树的size,也就是须要判断一下树中的节点数量。经过这个节点数量来决定合并方向,将节点数量少的那棵树合并到节点数量多的那棵树上。以下所示:
具体的实现代码以下:
package tree.unionfind; /** * 基于size优化的Union-Find * * @author 01 */ public class UnionFind3 implements UnionFind { /** * parent[i]表示第一个元素所指向的父节点 */ private final int[] parent; /** * sz[i]表示以i为根的集合中元素个数 */ private final int[] sz; public UnionFind3(int size) { parent = new int[size]; sz = new int[size]; // 初始化, 每个parent[i]指向本身, 表示每个元素本身自成一个集合 for (int i = 0; i < size; i++) { parent[i] = i; sz[i] = 1; } } @Override public int getSize() { return parent.length; } /** * 查找过程, 查找元素p所对应的集合编号 * O(h)复杂度, h为树的高度 */ private int find(int p) { if (p < 0 || p >= parent.length) { throw new IllegalArgumentException("p is out of bound."); } // 不断去查询本身的父亲节点, 直到到达根节点 // 根节点的特色: parent[p] == p while (p != parent[p]) { p = parent[p]; } return p; } /** * 查看元素p和元素q是否所属一个集合 * O(h)复杂度, h为树的高度 */ @Override public boolean isConnected(int p, int q) { return find(p) == find(q); } /** * 合并元素p和元素q所属的集合 * O(h)复杂度, h为树的高度 */ @Override public void unionElements(int p, int q) { int pRoot = find(p); int qRoot = find(q); if (pRoot == qRoot) { return; } // 根据两个元素所在树的元素个数不一样判断合并方向 // 将元素个数少的集合合并到元素个数多的集合上 if (sz[pRoot] < sz[qRoot]) { parent[pRoot] = qRoot; sz[qRoot] += sz[pRoot]; } else { parent[qRoot] = pRoot; sz[pRoot] += sz[qRoot]; } } }
在上一小节中,咱们介绍了基于 size 的优化,这是一种最基础的并查集优化方式,从基于 size 的优化咱们能够过渡到基于 rank 的优化。由于基于 size 的优化在某些极端状况下,仍然存在一些问题,另外基于 rank 的优化也是并查集的标准优化方式。
基于 size 的优化的问题就在于,咱们但愿树的高度尽可能低,可是 size 小不意味着高度就低。而相较而言,rank 能够更好地衡量高度。由于这里的 rank 表示的是树的层级数量,而不是像 size 那样的节点数量。
咱们来看一个例子:
在这个例子中,咱们要合并 4 和 2 这两个节点。从图中能够看到,2 所在的树共有 6 个节点,而 4 所在的树共有 3 个节点。若是使用的是基于 size 的优化,那么 size 小的要向 size 大的合并,4 所在的根节点 8 就须要挂到 2 所在的根节点 7 下。合并后,以下图所示:
能够看到,在这种状况下,基于 size 的优化就不是最优的,合并后的树的高度反而变高了。因此更合理的作法应该是层数低的向层数高的合并,也就是 rank 小的向 rank 大的合并。在此例中,就应该是 2 所在的根节点 7 挂到 4 所在的根节点 8 下。以下图所示:
改进后,基于 rank 优化的并查集代码以下:
package tree.unionfind; /** * 基于rank优化的Union-Find * * @author 01 */ public class UnionFind4 implements UnionFind { /** * rank[i]表示以i为根的集合所表示的树的层数(高度) */ private final int[] rank; /** * parent[i]表示第i个元素所指向的父节点 */ private final int[] parent; public UnionFind4(int size) { rank = new int[size]; parent = new int[size]; // 初始化, 每个parent[i]指向本身, 表示每个元素本身自成一个集合 for (int i = 0; i < size; i++) { parent[i] = i; rank[i] = 1; } } @Override public int getSize() { return parent.length; } /** * 查找过程, 查找元素p所对应的集合编号 * O(h)复杂度, h为树的高度 */ private int find(int p) { if (p < 0 || p >= parent.length) { throw new IllegalArgumentException("p is out of bound."); } // 不断去查询本身的父亲节点, 直到到达根节点 // 根节点的特色: parent[p] == p while (p != parent[p]) { p = parent[p]; } return p; } /** * 查看元素p和元素q是否所属一个集合 * O(h)复杂度, h为树的高度 */ @Override public boolean isConnected(int p, int q) { return find(p) == find(q); } /** * 合并元素p和元素q所属的集合 * O(h)复杂度, h为树的高度 */ @Override public void unionElements(int p, int q) { int pRoot = find(p); int qRoot = find(q); if (pRoot == qRoot) { return; } // 根据两个元素所在树的rank不一样判断合并方向 // 将rank低的集合合并到rank高的集合上 if (rank[pRoot] < rank[qRoot]) { // 被合并的树高度不会增长,不须要维护rank parent[pRoot] = qRoot; } else if (rank[qRoot] < rank[pRoot]) { parent[qRoot] = pRoot; } else { // 层数相同,向任意一方合并便可 parent[pRoot] = qRoot; // 而后须要维护一下rank的值,由于层数相同,被合并的树必然层数会+1 rank[qRoot] += 1; } } }
基于 rank 的优化其实在通常的状况下已经没什么问题了,并且也能获得一个比较好的性能,但在 rank 的基础上,咱们仍然还能够再进一步的优化。这种优化方式叫:路径压缩。在下图中,虽然树的高度不一样,但这几个并查集都是等价的:
从上图中,明显能够看出左边的这棵树性能最低,由于其树的高度最高。所以,咱们就知道树的高度是影响性能的一个主要缘由。然而即使是基于 rank 的优化也没法避免数据量较大的状况下致使树的高度太高的问题,因此咱们就得使用路径压缩这种优化方式来解决这个问题。
那么咱们要如何进行路径压缩呢?其实只须要在 find
方法中增长一句代码便可:parent[p] = parent[parent[p]]
。咱们知道find
方法的主要逻辑是从指定的节点开始,一直循环往上找到它的根节点为止。
而这句代码的做用就是每次都将当前节点挂到其父节点的父节点上,这样就实现了查找过程就是一个压缩路径的过程。例如,咱们要查找下图中,4 这个节点的根节点:
此时将这个节点挂载到其父节点的父节点上,就造成了这个样子:
而后再继续这个循环,直到达到根节点,就完成了一次路径压缩:
具体的实现代码以下:
package tree.unionfind; /** * 基于路径压缩优化的Union-Find * * @author 01 */ public class UnionFind5 implements UnionFind { /** * rank[i]表示以i为根的集合所表示的树的层数 * 在后续的代码中, 咱们并不会维护rank的语意, 也就是rank的值在路径压缩的过程当中, 有可能再也不表示树的层数值 * 这也是咱们的rank不叫height或者depth的缘由, 它只是做为比较的一个标准 */ private final int[] rank; /** * parent[i]表示第i个元素所指向的父节点 */ private final int[] parent; public UnionFind5(int size) { rank = new int[size]; parent = new int[size]; // 初始化, 每个parent[i]指向本身, 表示每个元素本身自成一个集合 for (int i = 0; i < size; i++) { parent[i] = i; rank[i] = 1; } } @Override public int getSize() { return parent.length; } /** * 查找过程, 查找元素p所对应的集合编号 * O(h)复杂度, h为树的高度 */ private int find(int p) { if (p < 0 || p >= parent.length) { throw new IllegalArgumentException("p is out of bound."); } // 在查找根节点的过程当中对路径进行压缩 while (p != parent[p]) { // 每次都将当前节点挂到其父节点的父节点上 parent[p] = parent[parent[p]]; p = parent[p]; } return p; } /** * 查看元素p和元素q是否所属一个集合 * O(h)复杂度, h为树的高度 */ @Override public boolean isConnected(int p, int q) { return find(p) == find(q); } /** * 合并元素p和元素q所属的集合 * O(h)复杂度, h为树的高度 */ @Override public void unionElements(int p, int q) { int pRoot = find(p); int qRoot = find(q); if (pRoot == qRoot) { return; } // 根据两个元素所在树的rank不一样判断合并方向 // 将rank低的集合合并到rank高的集合上 if (rank[pRoot] < rank[qRoot]) { parent[pRoot] = qRoot; } else if (rank[qRoot] < rank[pRoot]) { parent[qRoot] = pRoot; } else { parent[pRoot] = qRoot; // 维护一下rank的值 rank[qRoot] += 1; } } }
看到以上代码后,可能你会有一个疑问,为何在压缩路径的过程当中不用更新 rank 呢?事实上,这正是咱们将这个变量叫作 rank 而不是叫诸如 depth 或者 height 的缘由。由于这个 rank 只是咱们作的一个标志当前节点排名的一个数字。当咱们引入了路径压缩之后,维护这个深度的真实值会相对困难一些。
而实际上,这个 rank 的做用,只是在 union 的过程当中,比较两个节点的深度。换句话说,咱们彻底能够不知道每一个节点具体的深度,只要保证每两个节点深度的大小关系能够被 rank 正确表达便可。而这个 rank 确实能够正确表达两个节点之间深度的大小关系。
由于根据咱们的路径压缩的过程,rank 高的节点虽然被抬了上来(深度下降),可是不可能下降到比原先深度更小的节点还要小。因此,rank 足以胜任比较两个节点的深度,进而选择合适的节点进行 union 这个任务。也就是说,此时 rank 更像是一个权重值,而不是表示树实际的深度。
在本小节所介绍的路径压缩算法,只能将一棵树压缩到高度为 3。那么有没有办法将一棵树压缩到高度只有 2 呢?如同下图这样:
答案是有的,咱们可使用递归的方式,将树的高度压缩为 2 。但因为是使用递归实现的,递归开销比较大,因此其性能也不会比以前介绍的压缩方式性能高,甚至还不如。具体实现代码以下:
private int find(int p) { if (p < 0 || p >= parent.length) { throw new IllegalArgumentException("p is out of bound."); } // 递归实现路径压缩,将全部的节点直接压缩到根节点上,也就是每次压缩树的高度都会变成2 if (p != parent[p]) { parent[p] = find(parent[p]); } return parent[p]; }
最后,咱们来编写一个简单的测试用例,对这几种方式实现的并查集进行性能测试,对比一下不一样实现方式的性能差距。代码以下:
package tree.unionfind; import java.util.Random; public class UnionFindTests { private static double testUnionFind(UnionFind uf, int m) { int size = uf.getSize(); Random random = new Random(); long startTime = System.nanoTime(); for (int i = 0; i < m; i++) { int a = random.nextInt(size); int b = random.nextInt(size); uf.unionElements(a, b); } for (int i = 0; i < m; i++) { int a = random.nextInt(size); int b = random.nextInt(size); uf.isConnected(a, b); } long endTime = System.nanoTime(); return (endTime - startTime) / 1000000000.0; } public static void main(String[] args) { int size = 1000000; int m = 1000000; UnionFind1 uf1 = new UnionFind1(size); System.out.println("UnionFind1 : " + testUnionFind(uf1, m) + " s"); UnionFind2 uf2 = new UnionFind2(size); System.out.println("UnionFind2 : " + testUnionFind(uf2, m) + " s"); UnionFind3 uf3 = new UnionFind3(size); System.out.println("UnionFind3 : " + testUnionFind(uf3, m) + " s"); UnionFind4 uf4 = new UnionFind4(size); System.out.println("UnionFind4 : " + testUnionFind(uf4, m) + " s"); UnionFind5 uf5 = new UnionFind5(size); System.out.println("UnionFind5 : " + testUnionFind(uf5, m) + " s"); } }
输出结果以下:
UnionFind1 : 436.5402681 s UnionFind2 : 1337.1119902 s UnionFind3 : 0.0927705 s UnionFind4 : 0.0725342 s UnionFind5 : 0.0553162 s