总结
用途:以非常简单且巧妙的存储方式、算法来解决图论中无向图的节点动态连通(节点分类)的问题。很多复杂的 DFS 算法问题,都可以利用 Union-Find 算法更漂亮地解决。
主要原理:用数组来存储每个节点的直接父节点,这样就足以存储包含多个连通分量的图——在内部为各连通分量自底向上生成了有向生成树并用数组存储(也可以理解为存储的是多棵树组成的森林,每棵树可以是任意叉的;这里的生成树不一定是原图的生成树),在并查集维护的过程中自底向上(自叶节点到根节点)动态维护各树。并查集支持的操作包括:
find(p):找到指定节点所属的根节点。
union(p, q):把p、q两个节点联通起来,也即将两节点分别所在的连通分量合并为一个。两节点原来可能已连通也可能尚未连通。实现:分别找出两节点的根节点,然后将一个根节点作为另一根节点的孩子。
connected(p, q):判断两个节点是否连通。实现:获取两节点的根节点,判断是否是同一个。
count():获取联通分量数,也即节点分类数。
复杂度:
空间复杂度:O(n),n为节点数
时间复杂度:初始化的时间复杂度为O(n);union、connected操作都依赖find且主要代价在find操作,因此时间复杂度看find操作。
find操作花费的时间与当前节点到根节点的路径长有关,平均时间复杂度为树高O(lgn)、最坏为O(n)。实际实现中会进行一些优化以使平均时间复杂度为O(1),见下节。
操作的时间效率优化:为减少各操作的时间,进行两方面的优化,最终各操作时间复杂度为O(1)。两种优化本质上都是为了减少树高。
平衡性优化:union操作时将节点数少的树的根节点接到节点数多的树的根节点上去,而不是反过来。这样可以防止树成为单链。
路径压缩优化:find操作时顺便压缩路径:
常用的路径压缩:如果当前节点不是根节点,则将当前节点上移一层,然后对其新父节点递归执行该操作: find(x){ while(x!=parent[x]) { parent[x]=parent[parent[x]]; x=parent[x]; } return x; } ,此过程将使得指定节点及其各间隔节点到根节点的路径长度均变为原来的ceil(L/2)(可以想象下从有5个节点的单链树中find叶节点时的压缩过程过程,压缩后树的前三层元素将会是:【1、2 3、null null 4 5】)。
更激进的压缩方式: find(x){ while(x!=parent[x]) { parent[x]=find(parent[x]); x=parent[x]; } return x; } ,此过程使得压缩后指定节点及其各非根父节点均直接成为根节点的子节点(同理想象下上述单链例子的压缩过程),在一些场景下该法很有用(见后文除法求值的实例),与上一种相比缺点是单节点压缩的时间空间效率低点,但整体的压缩效果更好因为更快让树高减少。
注:
若不执行路径压缩,则树可能变成任意高,即使有平衡性优化亦如是。例如,有相同节点数的两个连通分量合并,树高会加1,一直合下去则可能非常高。
有了路径压缩优化后,平衡性的优化理论上可以没有,但有平衡性优化可以更快让树高减少,示例如下图:
有了路径压缩优化后,
【只要对各节点执行过find操作,最终树中每个叶节点距离根节点的路径长度都是1】,该结论不成立,反例:假设进行了路径压缩、且不进行平衡性优化而是始终right union,则对(1,2), (2,3), (3,4), (4,5) union的结果将是单链,即使union过程中进行了find操作;
即使路径压缩优化和平衡性优化都启用,也可能得到任意高度的树,例子:对两棵形状完全一样的树A、B执行 union(rootA, rootB) ,由于union过程中find时对rootA、rootB的路径压缩无任何效果,故得到的树高度肯定会增加1,如此同理操作,可使树无限增高。当然,这是比较极端的例子,真实场景中出现的可能性很小,因为实际场景中通常是对非根节点进行union操作,故通常会触发有效的路径压缩。因此,当路径压缩和平衡性优化都启用时,在大多数场景下(非极端场景),当所有union操作完成后大多数叶子节点就已经是根节点的子节点了、可以认为上述结论整体成立(注意是整体情况,而不绝对是这样)。
实际上,启用路径压缩后,不管是否启用平衡性优化,只有在union都完成后对各节点再执行过find操作才能确保上述“树的路径长度都是1”的结论成立。 注意是“union完成后所有节点都执行find操作”,否则该结论不成立(可以想象下包含3个节点的单链,只对中间节点执行find操作并不能使得最大路径长度为1);若采用的是激进的压缩,则由于find压缩时使得当前节点为根节点的子节点、且不论是find、union、connected哪个操作均会触发路径压缩,故若采用激进的路径压缩则可认为上述结论也成立-延迟成立。
代码:
方式1(推荐):要求各元素是从0起的连续数字,且各方法参数指定的元素须处于构造参数n范围内。
//假定方法参数指定的元素都在并查集中存在。最好须保证更方法幂等 class UF { // 连通分量个数 private int count; // 存储某个树节点的父节点 private int[] parent; // 记录树的节点数:size[x]为以x为根节点的子树的节点数 private int[] size; public UF(int n) { this.count = n; parent = new int[n]; size = new int[n]; for (int i = 0; i < n; i++) { parent[i] = i; size[i] = 1; } } public void union(int p, int q) { int rootP = find(p); int rootQ = find(q); if (rootP == rootQ) return; // 小树接到大树下面,较平衡 if (size[rootP] > size[rootQ]) { parent[rootQ] = rootP; size[rootP] += size[rootQ]; } else { parent[rootP] = rootQ; size[rootQ] += size[rootP]; } count--; } public boolean connected(int p, int q) { int rootP = find(p); int rootQ = find(q); return rootP == rootQ; } public int find(int x) { while (parent[x] != x) { // 路径压缩法1 parent[x] = parent[parent[x]]; // 路径压缩法2,更激进的路径压缩 //parent[x] = find(parent[x]); x = parent[x]; } return x; } public int count() { return count; } } //更简洁的实现。最好须保证更方法幂等 class UnionFind { int count; int[] parent; public UnionFind(int n) { count=n; parent = new int[n]; for (int i = 0; i < n; ++i) { parent[i] = i; } } public void union(int index1, int index2) { //parent[find(index1)] = find(index2);//不能单纯这样写,否则不幂等 int rtP=find(p); int rtQ=find(q); if(rtP==rtQ) return; parent[find(index1)] = find(index2); count--; } public int find(int index) { while (parent[index] != index) { parent[index] = parent[parent[index]]; // parent[index] = find(parent[index]);// 激进的压缩 index = parent[index]; } return index; } public boolean connected(int index1, int index2) { return find(index1) == find(index2); } }