内容简介:并查集 Union Find是一种很不一样的树结构,之前学习的树结构都是由父亲指向孩子,而并查集则是由孩子指向父亲的树结构。它可以非常高效地解决连接问题,如下图:要求判断图中任意两点间是否存在一条路径连接这两个点。并查集就可以高效地解决这一类问题。
并查集 Union Find是一种很不一样的树结构,之前学习的树结构都是由父亲指向孩子,而并查集则是由孩子指向父亲的树结构。它可以非常高效地解决连接问题,如下图:
要求判断图中任意两点间是否存在一条路径连接这两个点。
并查集就可以高效地解决这一类问题。
特点
1.并查集可以快速地判断网络中节点间的连接状态。
2.并查集可以快速求出两个集合的并集。
对于一组数据,并查集主要支持两个动作:
1.union(p,q):合并p,q两个数据所在的集合。
2.isConnected(p,q):对于给定的两个数据,查询它们是否属于同一个集合。
然后我们来设计一个并查集的接口
public interface UF { int getSize(); boolean isConnect(int p, int q); void unionElements(int p, int q); } 复制代码
这里传入的参数类型设置的是int,但并查集并不关心传入参数的类型,因为我们可以将p,q当成id映射成其他的数据类型。
Quick Find(第一版并查集)
并查集的基本数据表示
在并查集内部对每个数据进行编号,并且标出每个编号对应的集合。
如图,上面一排的0-9是十个数据的编号,下面的0和1分别对应了两个集合,表明对应数据编号的数据所属的集合,称为id。因此我们可以用数组来存储这些id,在解决连接问题时只需要判断两个数据的id是否相同即可。
代码实现:
public class UnionFind1 implements UF{ private int[] id; public UnionFind1(int size){ id = new int[size]; /** * 假设每个数据都属于不同的集合 */ for (int i = 0; i < id.length; i++){ id[i] = i; } } @Override public int getSize() { return id.length; } } 复制代码
对于一个数据查找它的id的过程我们可以抽象为一个函数,叫做find。很明显这个find函数的时间复杂度为O(1)。所以也称为Quick Find。
代码实现:
/** * 查找元素p对应的集合编号 * @param p * @return */ private int find(int p){ if (p < 0 && p >= id.length){ throw new IllegalArgumentException("p is out of bound."); } return id[p]; } /** * 查询元素p,q是否属于同一个集合 * @param p * @param q * @return */ @Override public boolean isConnect(int p, int q) { return find(p) == find(q); } 复制代码
Quick Find 下的Union
如图对于union(1,4)这个操作意味着图中所有元素要合并成一个集合,至于集合的编号是0还是1这个是无所谓的。
这是经过union(1,4)操作以后的id数组,这个过程需要遍历一遍id数组才能实现,因此这里的union操作时间复杂度为O(n)。
代码实现:
/** * 合并元素p,q所属的集合 * @param p * @param q */ @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 < id.length; i++){ if (id[i] == pID){ id[i] = qID; } } } 复制代码
小结
这一版的并查集的本质就是用数组模拟了一下并查集的操作过程,主要缺点在于union操作的时间复杂度过高。
Quick Union (第二版并查集)真正的并查集底层实现
实现思路
将每一个元素看做一个节点,然后采用开头说的一种由孩子节点指向父亲节点的树结构,对于树的根节点让它指向自身。
如图3是叶子节点指向2,2是根节点指向自身。当有其他元素需要合并进这个集合时,我们让新的节点指向这个树的根节点,如下图所示:
当需要合并两个集合时,即将两个这样的树形结构合并,我们只需要把其中一个树的根节点指向另一个树的根节点即可。
Quick Union 下的数据表示
由于每个节点只有一个指针,因此我们依然可以用数组来存储这些元素。
如图,parent[i]表示的就是第i个节点指向了哪个元素,在初始时每个元素都指向自己。
因为在合并时需要找到元素所属树的根节点,所以这里的union的时间复杂度为O(h),h为树的深度。同理,find操作也为O(h)。
代码实现
public class UnionFind2 implements UF{ private int[] parent; public UnionFind2(int size){ parent = new int[size]; for (int i = 0; i < size; i++){ parent[i] = i; } } @Override public int getSize() { return parent.length; } /** * 查找元素p对应集合编号 * @param p * @return */ private int find(int p){ if (p < 0 && p >= parent.length){ throw new IllegalArgumentException("p is out of bound."); } while (p != parent[p]){ p = parent[p]; } return p; } /** * 查询元素p,q是否属于一个集合 * @param p * @param q * @return */ @Override public boolean isConnect(int p, int q) { return find(p) == find(q); } /** * 合并元素p,q所属的集合 * O(h) * @param p * @param q */ @Override public void unionElements(int p, int q) { int pRoot = find(p); int qRoot = find(q); if (pRoot == qRoot){ return; } parent[pRoot] = qRoot; } } 复制代码
两种实现的性能测试
测试代码
import java.util.Random; /** * className Main * description TODO * * @author ln * @version 1.0 * @date 2019/7/1 18:33 */ public class Main { private static double testUF(UF uf, int m){ int size = uf.getSize(); Random random = new Random(); long startTime = System.nanoTime(); /** * m次合并操作 */ for (int i = 0; i < m; i++){ int a = random.nextInt(size); int b = random.nextInt(size); uf.unionElements(a, b); } /** * m次查询操作 */ for (int i = 0; i < m; i++){ int a = random.nextInt(size); int b = random.nextInt(size); uf.isConnect(a, b); } long endTime = System.nanoTime(); return (endTime - startTime) / 1000000000.0; } public static void main(String[] args) { int size = 10000; int m = 10000; UnionFind1 uf1 = new UnionFind1(size); System.out.println("UnionFind1: " + testUF(uf1, m) + " s"); UnionFind2 uf2 = new UnionFind2(size); System.out.println("UnionFind2: " + testUF(uf2, m) + " s"); } } 复制代码
测试结果
可以粗略地看出第二种实现的效率是明显高于第一种的,如果数据量继续增大这个差距将会更加明显。
但是当我们增加操作次数时再次测试会得到以下的结果
这时第二种反而比第一种慢了,这是因为uf1的实现中find操作是O(1),而uf2的find操作是O(h)且uf2的合并和查询方法都需要调用find方法。还有一个原因是uf2在合并时可能会让根节点不断地指向新节点,这样就导致了原来的树形结构变成了一个链表的样子,而这就会使树的高度不断增加,从而使uf2的find操作变慢。因此我们需要对这个现象进行优化。
基于size的优化
思路
在合并集合时始终让节点个数少的树的根节点指向节点多的树的根节点。
代码实现
public class UnionFind3 implements UF{ private int[] parent; /** * 用于记录每个树的大小 * sz[i]表示以i为根的集合中元素的个数 */ private int[] sz; public UnionFind3(int size){ parent = new int[size]; sz = new int[size]; for (int i = 0; i < size; i++){ parent[i] = i; sz[i] = 1; } } @Override public int getSize() { return parent.length; } /** * 查找元素p对应集合编号 * @param p * @return */ private int find(int p){ if (p < 0 && p >= parent.length){ throw new IllegalArgumentException("p is out of bound."); } while (p != parent[p]){ p = parent[p]; } return p; } /** * 合并元素p,q所属的集合 * O(h) * @param p * @param q */ @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]; } } } 复制代码
测试
与之前的测试方式相同,代码如下
public static void main(String[] args) { int size = 100000; int m = 100000; UnionFind1 uf1 = new UnionFind1(size); System.out.println("UnionFind1: " + testUF(uf1, m) + " s"); UnionFind2 uf2 = new UnionFind2(size); System.out.println("UnionFind2: " + testUF(uf2, m) + " s"); UnionFind3 uf3 = new UnionFind3(size); System.out.println("UnionFind3: " + testUF(uf3, m) + " s"); } 复制代码
测试结果:
可以看出经过优化后的uf3的效率是远远高于之前的实现。
基于rank的优化
根据树的节点个数来优化也有极端的情况
如图当我们要执行union4,2时,我们需要把以7和8为根的两个树合并,如果根据size来优化就会出现下图的情况
此时树的高度变成了4,而原来两棵树的高度分别为2和3,很明显我们可以看出来应该让7指向8才是更好的合并方式。为了避免这种情况我们就需要基于rank来优化。
思路
在每棵树的根节点上记录整棵树的高度,合并时让高度小的树合并到高度大的树。
代码实现 **
public class UnionFind4 implements UF{ private int[] parent; /** * 用于记录每个树的大小 * rank[i]表示以i为根的树的高度 */ private int[] rank; public UnionFind4(int size){ parent = new int[size]; rank = new int[size]; for (int i = 0; i < size; i++){ parent[i] = i; rank[i] = 1; } } /** * 查找元素p对应集合编号 * @param p * @return */ private int find(int p){ if (p < 0 && p >= parent.length){ throw new IllegalArgumentException("p is out of bound."); } while (p != parent[p]){ p = parent[p]; } return p; } /** * 合并元素p,q所属的集合 * O(h) * @param p * @param q */ @Override public void unionElements(int p, int q) { int pRoot = find(p); int qRoot = find(q); if (pRoot == qRoot){ return; } /** * 让高度小的树的根节点指向高度大的树的根节点 * 合并两个高度相同的树时要维护rank数组 */ if (rank[pRoot] < rank[qRoot]){ parent[pRoot] = qRoot; } else if (rank[pRoot] > rank[qRoot]){ parent[qRoot] = pRoot; } else { parent[qRoot] = pRoot; rank[pRoot]++; } } } 复制代码
测试
这里只比较uf3和uf4,将数据量和操作数加到千万级。
结果如下:
由于测试数据的随机性,有时候会出现uf4比uf3慢的情况,但是大多数情况都是4比3快。
路径压缩
路径压缩也是一种优化方法,其思想是在一个节点寻找其根节点的过程中降低整个树的高度。
核心代码为
parent[p] = parent[parent[p]]; 复制代码
即让当前节点指向其父亲节点的父亲节点(其实可以直接让当前节点直接指向根节点,用递归即可实现,这里我就不演示了)。
这个优化方法可以加在rank优化的基础上,使效率再次提高。
只需在uf4的find方法上加入上述的核心代码即可。
Writtern by Autu.
2019.7.1
以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持 码农网
猜你喜欢:- 数据结构 – 用于构建文件系统的数据结构?
- 荐 用Python解决数据结构与算法问题(三):线性数据结构之栈
- 数据结构和算法面试题系列-C指针、数组和结构体
- 请问二叉树等数据结构的物理存储结构是怎样的?
- 数据结构——单链表
- 常用数据结构
本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们。