算法之并查集(Union-Find)
总结
用途:以非常简单且巧妙的存储方式、算法来解决图论中无向图的节点动态连通(节点分类)的问题。很多复杂的 DFS 算法问题,都可以利用 Union-Find 算法更漂亮地解决。
主要原理:用数组来存储每个节点的直接父节点,这样就足以存储包含多个连通分量的图——在内部为各连通分量自底向上生成了有向生成树并用数组存储(也可以理解为存储的是多棵树组成的森林,每棵树可以是任意叉的;这里的生成树不一定是原图的生成树),在并查集维护的过程中自底向上(自叶节点到根节点)动态维护各树。并查集支持的操作包括:
find(p):找到指定节点所属的根节点。
union(p, q):把p、q两个节点联通起来,也即将两节点分别所在的连通分量合并为一个。两节点原来可能已连通也可能尚未连通。实现:分别找出两节点的根节点,然后将一个根节点作为另一根节点的孩子。
connected(p, q):判断两个节点是否连通。实现:获取两节点的根节点,判断是否是同一个。
count():获取联通分量数,也即节点分类数。
复杂度:
空间复杂度:O(n),n为节点数
时间复杂度:初始化的时间复杂度为O(n);union、connected操作都依赖find且主要代价在find操作,因此时间复杂度看find操作,各操作的平均时间复杂度在优化前后分别为O(lgn)、O(1)。
find操作花费的时间与当前节点到根节点的路径长有关,平均时间复杂度为树高O(lgn)、最坏为O(n)。实际实现中会进行一些优化以使平均时间复杂度为O(1)(当然,不优化也不会影响结果的正确性),见下节。
操作的时间效率优化:为减少各操作的时间,进行两方面的优化,最终各操作时间复杂度为O(1)。两种优化本质上都是为了减少树高。
平衡性优化:union操作时将节点数少的树的根节点接到节点数多的树的根节点上去,而不是反过来。这样可以防止树成为单链,从而减少下面的路径压缩操作。
路径压缩优化:find操作时顺便压缩路径:
方式1,普通的路径压缩:如果当前节点不是根节点,则将当前节点上移一层,然后对其新父节点递归执行该操作: 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】)。
方式2,激进的压缩方式: find(x){ while(x!=parent[x]) { parent[x]=find(parent[x]); x=parent[x]; } return x; } ,此过程使得压缩后指定节点及其各非根父节点均直接成为根节点的子节点(同理想象下上述单链例子的压缩过程),在一些场景下该法很有用(见后文除法求值的实例),与上一种相比缺点是单节点压缩的时间空间效率低点,但整体的压缩效果更好因为更快让树高减少。
注:普通路径压缩是 达到O(1)平均时间复杂度的必要非充分条件、激进压缩是充要条件、平衡性优化既非充分又非必要条件,即路径压缩优化是必须的、而平衡性优化非必须,实践经验——“仅采用激进压缩”而不启用平衡性优化(通常是union right)是万金油,因为其使得树只有2层。分析如下:
若不执行路径压缩,则树可能变成任意高,即使有平衡性优化亦如是。例如,有相同节点数的两个连通分量合并,树高会加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); } }
方式2:更通用的实现(没有方式1的限制),根据给定的元素数组构建并查集,各方法参数指定的元素不要求处于构造参数范围内(不在则为null)。
1 // 给定的方法参数可以是不在并查集集合中的元素 2 public static class UF2<T> { 3 private int count; 4 private Map<T, T> parent;// 要求key、value均非null 5 private Map<T, Integer> size;// 记录以每个元素为根节点的子树中的节点总数 6 7 public UF2(T[] elements) { 8 int n = elements.length; 9 count = n; 10 size = new HashMap<>(); 11 parent = new HashMap<>(); 12 for (int i = 0; i < n; i++) { 13 size.put(elements[i], 1); 14 parent.put(elements[i], elements[i]); 15 } 16 } 17 18 /** 查找指定元素所在树的根节点,返回null表示不存在 */ 19 public T find(T x) { 20 if (!parent.containsKey(x)) { 21 return null; 22 } 23 while (x != parent.get(x)) { 24 parent.put(x, parent.get(parent.get(x)));// 路径压缩法1 25 // parent.put(x, find(parent.get(x)));//路径压缩法2,更激进的路径压缩 26 x = parent.get(x); 27 } 28 return x; 29 } 30 31 // 不进行平衡性优化,始终让右节点为左节点的根。当然,也可以进行平衡性优化,视具体需求而定 32 public void unionRight(T p, T q) { 33 T rtP = find(p); 34 T rtQ = find(q); 35 if (rtP == rtQ) { 36 return; 37 } 38 if (null == rtP || null == rtQ) { 39 return; 40 } 41 parent.put(rtP, rtQ); 42 size.put(rtQ, size.get(rtP) + size.get(rtQ)); 43 count--; 44 } 45 46 public boolean connnected(T p, T q) { 47 T rtP = find(p); 48 T rtQ = find(q); 49 if (null == rtP || null == rtQ) { 50 return false; 51 } 52 return rtP == rtQ; 53 } 54 }
方式3:与方式2相比更通用,构造参数无需指定元素数组而是在find时动态添加元素,各方法参数指定的元素不要求处于构造参数范围内(不在则添加该元素)。
1 class UF3<T> { 2 private int count; 3 private Map<T, T> parent;// 要求key、value均非null 4 private Map<T, Integer> size;// 记录以每个元素为根节点的子树中的节点总数 5 6 public UF3() { 7 count = 0; 8 size = new HashMap<>(); 9 parent = new HashMap<>(); 10 } 11 12 /** 查找指定元素所在树的根节点,若元素不存在则添加元素 */ 13 public T find(T x) { 14 if (!parent.containsKey(x)) { 15 count++; 16 parent.put(x, x); 17 size.put(x, 1); 18 } 19 while (x != parent.get(x)) { 20 parent.put(x, parent.get(parent.get(x)));// 路径压缩法1 21 // parent.put(x, find(parent.get(x)));//路径压缩法2,更激进的路径压缩 22 x = parent.get(x); 23 } 24 return x; 25 } 26 27 // 不进行平衡性优化,始终让右节点为左节点的根。当然,也可以进行平衡性优化,视具体需求而定 28 public void unionRight(T p, T q) { 29 T rtP = find(p); 30 T rtQ = find(q); 31 if (rtP == rtQ) { 32 return; 33 } 34 // if (null == rtP || null == rtQ) { 35 // return; 36 // } 37 parent.put(rtP, rtQ); 38 size.put(rtQ, size.get(rtP) + size.get(rtQ)); 39 count--; 40 } 41 42 public boolean connnected(T p, T q) { 43 T rtP = find(p); 44 T rtQ = find(q); 45 // if (null == rtP || null == rtQ) { 46 // return false; 47 // } 48 return rtP == rtQ; 49 } 50 }
需要注意的是:
1、在实现并查集时,并不是死脑筋地固定按上述实现,而是可以根据问题的实际情况对find、union、isconnected方法的一个或多个稍加改造,从而更完美巧妙地解决问题。例如用并查集解决最长递增序列问题(见下节)时,union方法不进行平衡性优化而是始终进行单向union;解决除法求值问题时,对find的路径压缩更激进使得压缩后指定节点与根节点的路径长度为1、增加权重数字实现带权并查集。
2、用哪种方式合适需要看具体的题目场景,三种方法都有各自的独特用途。总的来说,方式1最简洁实现最简单且实现时只需要考虑方法参数都是并查集元素的情况,方式2、3更通用但代码上相对麻烦些,实际上可借助Map事先将所有元素值映射成连续数值,从而转为实现1;推荐用方式1或方式3,具体可参阅下面除法求值一例。
实际应用场景示例:Union-Find算法应用:
LeetCode并查集专题:
Impl1的应用示例:130.被围绕的区域、990.等式方程的可满足性、200.岛屿数量、684.找无向连通图中的冗余边、685.找有向连通图中的冗余边。
Impl2的应用示例:128.最长递增序列、399.除法求值;实际上用Impl3更好。除法求值的两种并查集实现的代码:
class Solution { // 并查集法2,将元素映射成整数,更简洁;使用激进的路径压缩 public double[] calcEquation(List<List<String>> equations, double[] values, List<List<String>> queries) { UF uf = new UF(equations.size()*2); Map<String, Integer> eqIndex=new HashMap<>(); for (int i = 0; i < equations.size(); i++) { String eql = equations.get(i).get(0); String eqr = equations.get(i).get(1); eqIndex.put(eql, eqIndex.getOrDefault(eql, eqIndex.size())); eqIndex.put(eqr, eqIndex.getOrDefault(eqr, eqIndex.size())); uf.unionRight(eqIndex.get(eql), eqIndex.get(eqr), values[i]); } double[] ans = new double[queries.size()]; for (int i = 0; i < queries.size(); i++) { String eql = queries.get(i).get(0); String eqr = queries.get(i).get(1); Integer p = eqIndex.get(eql); Integer q = eqIndex.get(eqr); double res = ( null!=p && null!=q && uf.connected(p,q) ) ? uf.findWeight(p) / uf.findWeight(q) : -1; // a/b= (a/R)/(b/R) ans[i] = res; } return ans; } class UF{ private int count; private int[] parent; private int[] size; private double[] weight;//记录每个元素与其父节点的商,在路径压缩时需要相应动态更新 public UF(int n){ count=n; parent=new int[n]; size=new int[n]; weight=new double[n]; for(int i=0;i<n;i++){ parent[i]=i; size[i]=1; weight[i]=1.0; } } public double findWeight(int x){ return weight[x]; } public int find(int x){ while(x!=parent[x]){ int oriParent = parent[x]; parent[x]=find(oriParent);//采用激进的路径压缩,使当前节点及其各非根各父节点直接成为根节点的子节点 weight[x] *= weight[oriParent]; x=parent[x]; } return x; } public void unionRight(int p, int q, double w){ int rtP=find(p); int rtQ=find(q); if(rtP==rtQ) return; parent[rtP]=rtQ; size[rtQ]+=size[rtP]; weight[rtP]=w*weight[q]/weight[p];// p/rtQ = p/q * q/rtQ = p/rtP * rtP/rtQ => rtP/rtQ=... count--; } public boolean connected(int p, int q){ int rtP=find(p); int rtQ=find(q); return rtP==rtQ; } } // 并查集法1 //根据题意,equations中各变量可构成一棵树 public double[] calcEquatio1(List<List<String>> equations, double[] values, List<List<String>> queries) { Set<String> elements = equations.stream().flatMap(e -> e.stream()).collect(Collectors.toSet()); UF1<String> uf = new UF1<>(elements.toArray(new String[0])); for (int i = 0; i < equations.size(); i++) { uf.unionRight(equations.get(i).get(0), equations.get(i).get(1), values[i]); } double[] ans = new double[queries.size()]; for (int i = 0; i < queries.size(); i++) { String p = queries.get(i).get(0); String q = queries.get(i).get(1); Double d1 = uf.findWeight(p); Double d2 = uf.findWeight(q); double res = ( uf.connected(p, q) && null != d1 && null != d2) ? d1 / d2 : -1; // a/b= (a/R)/(b/R) ans[i] = res; } return ans; } // 给定的方法参数可以是不在并查集集合中的元素 public static class UF1<T> { private int count; private Map<T, T> parent;// 要求key、value均非null private Map<T, Integer> size;// 记录以每个元素为根节点的子树中的节点总数 private Map<T, Double> weight;// 记录每个元素与其父节点的商,在路径压缩时需要相应动态更新 public UF1(T[] elements) { int n = elements.length; count = n; size = new HashMap<>(); parent = new HashMap<>(); weight = new HashMap<>(); for (int i = 0; i < n; i++) { weight.put(elements[i], 1.0); size.put(elements[i], 1); parent.put(elements[i], elements[i]); } } /** 获取节点与其根节点的商 */ public Double findWeight(T x) { if (!parent.containsKey(x)) { return null; } double res = 1.0; while (x != parent.get(x)) { res *= weight.get(x); x = parent.get(x); } return res; } /** 查找指定元素所在树的根节点,返回null表示不存在 */ public T find(T x) { if (!parent.containsKey(x)) { return null; } while (x != parent.get(x)) { weight.put(x, weight.get(x) * weight.get(parent.get(x)));// a/c=(a/b)*(b/c) parent.put(x, parent.get(parent.get(x))); x = parent.get(x); } return x; } // 不进行平衡性优化,始终让右节点为左节点的根 public void unionRight(T p, T q, double w) { T rtP = find(p); T rtQ = find(q); if (rtP == rtQ) { return; } if (null == rtP || null == rtQ) { return; } weight.put(rtP, w * findWeight(q)/findWeight(p));// p/rtQ = p/q * q/rtQ = p/rtP * rtP/rtQ => rtP/rtQ=... parent.put(rtP, rtQ); size.put(rtQ, size.get(rtP) + size.get(rtQ)); count--; } public boolean connected(T p, T q) { T rtP = find(p); T rtQ = find(q); if (null == rtP || null == rtQ) { return false; } return rtP == rtQ; } } }
Impl3的应用示例:947.移除同行或同列的石头。
从这些例子(特别是除法求值一题)的并查集解法与借助Map的解法可看出,后者在本质上是借助链表(用Map等形式来表示链表)保存元素依赖关系:多个链表就相当于对元素进行了分类,一个链表中以最早的元素作为该类的标识,同一链表上元素属于同类,这种思想本质上与基于Map实现的并查集(Impl2实现)一样,只不过并查集实现时进行了路径压缩以减少链表遍历(find操作)!!
解题时的经验总结:
解题思路:
用并查集来解决问题时,主要思路是想办法让元素「分门别类」,建立动态连通关系(有时需适时增加虚拟节点),然后用并查集来实现该分类过程。
实现时的策略选择经验:实现方式的选择(三种)、平衡性优化策略的选择(不启用、启用、始终uniron right)、路径压缩的选择(普通的还是激进的)
实现时尽量用第一种或第三种实现方式;实现时根据问题场景对find、union、connected方法稍加改造,例如根据问题场景决定采用哪种路径压缩优化、哪种平衡性优化。根据前述理论和实际解题实践得到的经验是——“仅采用激进压缩”而不启用平衡性优化(通常是union right)是万金油,因为其使得树只有2层。
实现时根据问题场景决定是否增加额外数组等数据结构来辅助记录状态以解决问题,例如增加权重数组来实现带权值的并查集。
并查集与DFS:因为并查集是用来确定两个元素是否属同一连通分量、而通过DFS也可以达到该效果甚至进一步给出了两元素间的连通路径。因此,很多复杂的 DFS 算法问题也可利用 Union-Find 算法更漂亮的解决;同样地,并查集能解决的问题大多(当然不是都可以)也可用DFS解决,哪种简单视具体情况而定,一般而言DFS时间效率高些且实现更简洁但内存空间可能更大些(因为DFS执行时涉及到递归从而有层层的栈空间申请)。可看如下两题的并查集和DFS解法,细细体会两种解法的联系和差异!!这里贴下代码实现:
1 //“778.水位上升的游泳池中游泳”(https://leetcode-cn.com/problems/swim-in-rising-water/)一题的 【并查集解法、dfs解法】 两解法的相似性 与 此题两解法的相似性很类似,可参阅该题解法 2 class Solution { 3 //法1,深度优先搜索:通过DFS找出各岛屿,找过的做标记以免重复找 4 public int numIslands(char[][] grid) { 5 if(null==grid || grid.length==0) return 0; 6 int m=grid.length; 7 int n=grid[0].length; 8 int res=0; 9 for(int i=0;i<m;i++){ 10 for(int j=0;j<n;j++){ 11 if(grid[i][j]=='1'){ 12 res++; 13 dfs(grid,i,j); 14 } 15 } 16 } 17 return res; 18 } 19 //以指定起点开始通过dfs搜索出连通分量,作为一个岛屿 20 private void dfs(char[][]grid, int i, int j){ 21 if(null==grid || grid.length==0) return; 22 int m=grid.length; 23 int n=grid[0].length; 24 if(i<0 || i>=m || j<0 || j>=n ) return; 25 if(grid[i][j]=='1'){ 26 grid[i][j]='2'; 27 dfs(grid, i, j+1); 28 dfs(grid, i+1, j); 29 dfs(grid, i, j-1); 30 dfs(grid, i-1, j); 31 } 32 } 33 34 //法2,借助并查集:找出1组成的连通分量的个数,所有0作为一个连通分量。 35 public int numIslands1(char[][] grid) { 36 if(null==grid || grid.length==0) return 0; 37 38 int m=grid.length; 39 int n=grid[0].length; 40 int vEle=m*n;//虚拟根节点,作为所有0元素的父节点。 41 UF uf=new UF(m*n+1); 42 int[][]d=new int[][]{{0,1},{0,-1},{1,0},{-1,0}}; 43 char ch0='0'; 44 for(int i=0;i<m;i++){ 45 for(int j=0;j<n;j++){ 46 if(grid[i][j]==ch0){ 47 uf.union(i*n+j, vEle); 48 }else{ 49 for(int k=0;k<4;k++){ 50 int x=i+d[k][0]; 51 int y=j+d[k][1]; 52 if(x<0 || x>=m || y<0 || y>=n || grid[x][y]==ch0) 53 { 54 continue; 55 }else{ 56 uf.union(i*n+j, x*n+y); 57 } 58 } 59 } 60 } 61 } 62 return uf.count-1; 63 } 64 public static class UF{ 65 private int count; 66 private int[] parent; 67 private int[] size; 68 public UF(int n){ 69 count=n; 70 parent=new int[n]; 71 size=new int[n]; 72 for(int i=0;i<n;i++){ 73 parent[i]=i; 74 size[i]=1; 75 } 76 } 77 public int find(int x){ 78 while(x!=parent[x]){ 79 parent[x]=parent[parent[x]]; 80 x=parent[x]; 81 } 82 return x; 83 } 84 public void union(int p, int q){ 85 int rtP=find(p); 86 int rtQ=find(q); 87 if(rtP==rtQ){ 88 return; 89 } 90 if(size[rtP]<size[rtQ]){ 91 parent[rtP]=rtQ; 92 size[rtQ]+=size[rtP]; 93 } 94 else{ 95 parent[rtQ]=rtP; 96 size[rtP]+=size[rtQ]; 97 } 98 count--; 99 } 100 public boolean connected(int p, int q){ 101 return find(p)==find(q); 102 } 103 public int count(){ 104 return count; 105 } 106 } 107 }
1 //本题是要从左上到右下的任意路径中选择一条路径,且要求是路径上最高的平台的值最小的路径,该值即为所求(相当于是求最小等待时间)。如果移动也耗时,还可以问两者和最小是多少 2 class Solution { 3 //法2,二分法+dfs。二分法确定满足条件的t值。本质上与法1相同,模拟下雨过程,只不过法1是从小下到大直到得到结果,而这里是通过二分法下雨直到得到结果。平均意义上而言,这里能更快找到结果,但这里的空间代价更大,因为法1从小下到大过程中会保留上次的下雨结果,而这里不会。 4 //实际上本质上类似于“二分法从有重复元素的有序序列中查找指定元素,若指定元素有重则取最左边那个”的情景 5 //此dfs与并查集专辑中岛屿数量(https://leetcode-cn.com/problems/number-of-islands/)一题的DFS解法类似,可参阅之 6 public int swimInWater(int[][] grid) { 7 // 二分法确定最小的t 8 int n=grid.length; 9 int left=0, right=n*n-1; 10 while(left<=right){ 11 int t=left+(right-left)/2; 12 boolean[][] visited=new boolean[n][n]; 13 if(dfs(t,grid,n-1,n-1,visited,0,0)){//值为t时是否可以从左上角到达右下角 14 right=t-1; 15 }else{ 16 left=t+1; 17 } 18 } 19 return left; 20 } 21 //时间为t时,是否可以从指定的起点开始到达指定目标。即对于值小于t的元素组成的多个连通分量,指定的两个位置的元素是否在同一个连通分量中 22 int [][]d=new int[][]{{0,1}, {0,-1}, {-1,0}, {1,0}}; 23 public boolean dfs(int t, int[][]grid, int endI, int endJ, boolean[][]visited, int i, int j){ 24 int n=grid.length; 25 if(i<0 || i>=n || j<0 || j>=n || endI<0 || endI>=n || endJ<0 || endJ>=n || grid[i][j]>t || grid[endI][endJ]>t || visited[i][j]) return false; 26 27 visited[i][j]=true; 28 if(i==endI && j==endJ) return true; 29 for(int k=0;k<4;k++){ 30 int x=i+d[k][0]; 31 int y=j+d[k][1]; 32 if(dfs(t,grid,endI,endJ,visited,x,y)){ 33 return true; 34 } 35 } 36 return false; 37 } 38 39 40 // 法1,并查集实现,O(n*n*nlgn),O(n*n)。模拟下雨过程(t逐渐增加)直到被淹没的平台中存在连通左上、右下的路径,此时t即为所求。下面的实现允许平台高度为不连续的任意值 41 public int swimInWater1(int[][] grid) { 42 int n=grid.length; 43 44 Map<Integer, int[]>index=new HashMap<>();//记录每个元素值所在的位置 45 for(int i=0;i<n;i++){ 46 for(int j=0;j<n;j++){ 47 index.put(grid[i][j], new int[]{i,j}); 48 } 49 } 50 51 int ans=0; 52 UF uf = new UF(n*n);//平台位置的分类 53 int [][]d=new int[][]{{0,1}, {0,-1}, {-1,0}, {1,0}}; 54 for(int t=1;t<n*n;t++){ 55 int[] posT=index.get(t); 56 //归到四周的类中以实现被淹没的平台位置的动态分类 57 for(int k=0;k<4;k++){ 58 int x=posT[0]+d[k][0]; 59 int y=posT[1]+d[k][1]; 60 if(0<=x && x<n && 0<=y && y<n && grid[x][y]<t){ 61 uf.union(posT[0]*n+posT[1], x*n+y); 62 } 63 } 64 if(uf.connected(0,n*n-1)){//两个位置连通了 65 ans=t; 66 break; 67 } 68 } 69 return ans; 70 } 71 class UF { 72 int count; 73 int[] parent; 74 75 public UF(int n) { 76 count=n; 77 parent = new int[n]; 78 for (int i = 0; i < n; ++i) { 79 parent[i] = i; 80 } 81 } 82 83 public void union(int index1, int index2) { 84 int rtP=find(index1); 85 int rtQ=find(index2); 86 if(rtP==rtQ) return; 87 parent[find(index1)] = find(index2); 88 count--; 89 } 90 91 public int find(int index) { 92 while (parent[index] != index) { 93 parent[index] = parent[parent[index]]; 94 // parent[index] = find(parent[index]);// 激进的压缩 95 index = parent[index]; 96 } 97 return index; 98 } 99 100 public boolean connected(int index1, int index2) { 101 return find(index1) == find(index2); 102 } 103 } 104 }
关于图的DFS的模板要点可参阅数据结构_图总结-图的遍历。
转自UnionFind算法详解,写得非常通俗易懂,虽然有些地方不太准确(时间复杂度部分)。
以下为正文
====================
今天讲讲 Union-Find 算法,也就是常说的并查集算法,主要是解决图论中「动态连通性」问题的。名词很高端,其实特别好理解,等会解释,另外这个算法的应用都非常有趣。
说起这个 Union-Find,应该算是我的「启蒙算法」了,因为《算法4》的开头就介绍了这款算法,可是把我秀翻了,感觉好精妙啊!后来刷了 LeetCode,并查集相关的算法题目都非常有意思,而且《算法4》给的解法竟然还可以进一步优化,只要加一个微小的修改就可以把时间复杂度降到 O(1)。
废话不多说,直接上干货,先解释一下什么叫动态连通性吧。
一、问题介绍
简单说,动态连通性其实可以抽象成给一幅图连线。比如下面这幅图,总共有 10 个节点,他们互不相连,分别用 0~9 标记:
现在我们的 Union-Find 算法主要需要实现这两个 API:
class UF {
/* 将 p 和 q 连接 */
public void union(int p, int q);
/* 判断 p 和 q 是否连通 */
public boolean connected(int p, int q);
/* 返回图中有多少个连通分量 */
public int count();
}
这里所说的「连通」是一种等价关系,也就是说具有如下三个性质:
1、自反性:节点p
和p
是连通的。
2、对称性:如果节点p
和q
连通,那么q
和p
也连通。
3、传递性:如果节点p
和q
连通,q
和r
连通,那么p
和r
也连通。
比如说之前那幅图,0~9 任意两个不同的点都不连通,调用connected
都会返回 false,连通分量为 10 个。
如果现在调用union(0, 1)
,那么 0 和 1 被连通,连通分量降为 9 个。
再调用union(1, 2)
,这时 0,1,2 都被连通,调用connected(0, 2)
也会返回 true,连通分量变为 8 个。
判断这种「等价关系」非常实用,比如说编译器判断同一个变量的不同引用,比如社交网络中的朋友圈计算等等。
这样,你应该大概明白什么是动态连通性了,Union-Find 算法的关键就在于union
和connected
函数的效率。那么用什么模型来表示这幅图的连通状态呢?用什么数据结构来实现代码呢?
二、基本思路
注意我刚才把「模型」和具体的「数据结构」分开说,这么做是有原因的。因为我们使用森林(若干棵树)来表示图的动态连通性,用数组来具体实现这个森林。
怎么用森林来表示连通性呢?我们设定树的每个节点有一个指针指向其父节点,如果是根节点的话,这个指针指向自己。比如说刚才那幅 10 个节点的图,一开始的时候没有相互连通,就是这样:
class UF {
// 记录连通分量
private int count;
// 节点 x 的节点是 parent[x]
private int[] parent;
/* 构造函数,n 为图的节点总数 */
public UF(int n) {
// 一开始互不连通
this.count = n;
// 父节点指针初始指向自己
parent = new int[n];
for (int i = 0; i < n; i++)
parent[i] = i;
}
/* 其他函数 */
}
如果某两个节点被连通,则让其中的(任意)一个节点的根节点接到另一个节点的根节点上:
public void union(int p, int q) {
int rootP = find(p);
int rootQ = find(q);
if (rootP == rootQ)
return;
// 将两棵树合并为一棵
parent[rootP] = rootQ;
// parent[rootQ] = rootP 也一样
count--; // 两个分量合二为一
}
/* 返回某个节点 x 的根节点 */
private int find(int x) {
// 根节点的 parent[x] == x
while (parent[x] != x)
x = parent[x];
return x;
}
/* 返回当前的连通分量个数 */
public int count() {
return count;
}
这样,如果节点p
和q
连通的话,它们一定拥有相同的根节点:
public boolean connected(int p, int q) {
int rootP = find(p);
int rootQ = find(q);
return rootP == rootQ;
}
至此,Union-Find 算法就基本完成了。是不是很神奇?竟然可以这样使用数组来模拟出一个森林,如此巧妙的解决这个比较复杂的问题!
那么这个算法的复杂度是多少呢?我们发现,主要 APIconnected
和union
中的复杂度都是find
函数造成的,所以说它们的复杂度和find
一样。
find
主要功能就是从某个节点向上遍历到树根,其时间复杂度就是树的高度。我们可能习惯性地认为树的高度就是logN
,但这并不一定。logN
的高度只存在于平衡二叉树,对于一般的树可能出现极端不平衡的情况,使得「树」几乎退化成「链表」,树的高度最坏情况下可能变成N
。
所以说上面这种解法,find
,union
,connected
的时间复杂度都是 O(N)。这个复杂度很不理想的,你想图论解决的都是诸如社交网络这样数据规模巨大的问题,对于union
和connected
的调用非常频繁,每次调用需要线性时间完全不可忍受。
问题的关键在于,如何想办法避免树的不平衡呢?只需要略施小计即可。
三、平衡性优化
我们要知道哪种情况下可能出现不平衡现象,关键在于union
过程:
public void union(int p, int q) {
int rootP = find(p);
int rootQ = find(q);
if (rootP == rootQ)
return;
// 将两棵树合并为一棵
parent[rootP] = rootQ;
// parent[rootQ] = rootP 也可以
count--;
我们一开始就是简单粗暴的把p
所在的树接到q
所在的树的根节点下面,那么这里就可能出现「头重脚轻」的不平衡状况,比如下面这种局面:
长此以往,树可能生长得很不平衡。我们其实是希望,小一些的树接到大一些的树下面,这样就能避免头重脚轻,更平衡一些。解决方法是额外使用一个size
数组,记录每棵树包含的节点数,我们不妨称为「重量」:
class UF {
private int count;
private int[] parent;
// 新增一个数组记录树的“重量”
private int[] size;
public UF(int n) {
this.count = n;
parent = new int[n];
// 最初每棵树只有一个节点
// 重量应该初始化 1
size = new int[n];
for (int i = 0; i < n; i++) {
parent[i] = i;
size[i] = 1;
}
}
/* 其他函数 */
}
比如说size[3] = 5
表示,以节点3
为根的那棵树,总共有5
个节点。这样我们可以修改一下union
方法:
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--;
}
这样,通过比较树的重量,就可以保证树的生长相对平衡,树的高度大致在logN
这个数量级,极大提升执行效率。
此时,find
,union
,connected
的时间复杂度都下降为 O(logN),即便数据规模上亿,所需时间也非常少。
四、路径压缩
这步优化特别简单,所以非常巧妙。我们能不能进一步压缩每棵树的高度,使树高始终保持为常数?
这样find
就能以 O(1) 的时间找到某一节点的根节点,相应的,connected
和union
复杂度都下降为 O(1)。
要做到这一点,非常简单,只需要在find
中加一行代码:
private int find(int x) {
while (parent[x] != x) {
// 进行路径压缩
parent[x] = parent[parent[x]];
x = parent[x];
}
return x;
}
这个操作有点匪夷所思,看个 GIF 就明白它的作用了(为清晰起见,这棵树比较极端):
可见,调用find
函数每次向树根遍历的同时,顺手将树高缩短了,最终所有树高都不会超过 3(union
的时候树高可能达到 3)。
PS:读者可能会问,这个 GIF 图的find过程完成之后,树高恰好等于 3 了,但是如果更高的树,压缩后高度依然会大于 3 呀?不能这么想。这个 GIF 的情景是我编出来方便大家理解路径压缩的,但是实际中,每次find都会进行路径压缩,所以树本来就不可能增长到这么高,你的这种担心应该是多余的。
五、最后总结
我们先来看一下完整代码:
class UF {
// 连通分量个数
private int count;
// 存储一棵树
private int[] parent;
// 记录树的“重量”
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) {
// 进行路径压缩
parent[x] = parent[parent[x]];
x = parent[x];
}
return x;
}
public int count() {
return count;
}
}
Union-Find 算法的复杂度可以这样分析:构造函数初始化数据结构需要 O(N) 的时间和空间复杂度;连通两个节点union
、判断两个节点的连通性connected
、计算连通分量count
所需的时间复杂度均为 O(1)。