AI summary
type
status
date
slug
summary
category
tags
icon
password
并查集(Union-Find)算法是一个专门针对「动态连通性」的算法,我之前写过两次,因为这个算法的考察频率高,而且它也是最小生成树算法的前置知识,所以我整合了本文,争取一篇文章把这个算法讲明白。
首先,从什么是图的动态连通性开始讲。

动态连通性

简单说,动态连通性其实可以抽象成给一幅图连线。
我们的 Union-Find 算法主要需要实现这两个 API:
这里所说的「连通」是一种等价关系,也就是说具有如下三个性质:
  1. 自反性:节点 p 和 p 是连通的。
  1. 对称性:如果节点 p 和 q 连通,那么 q 和 p 也连通。
  1. 传递性:如果节点 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 个。
notion image
判断这种「等价关系」非常实用,比如说编译器判断同一个变量的不同引用,比如社交网络中的朋友圈计算等等。
这样,你应该大概明白什么是动态连通性了,Union-Find 算法的关键就在于 union 和 connected 函数的效率。那么用什么模型来表示这幅图的连通状态呢?用什么数据结构来实现代码呢?

基本思路

注意我刚才把「模型」和具体的「数据结构」分开说,这么做是有原因的。因为我们使用森林(若干棵树)来表示图的动态连通性,用数组来具体实现这个森林。
怎么用森林来表示连通性呢?我们设定树的每个节点有一个指针指向其父节点,如果是根节点的话,这个指针指向自己。
如果某两个节点被连通,则让其中的(任意)一个节点的根节点接到另一个节点的根节点上。这样,如果节点 p 和 q 连通的话,它们一定拥有相同的根节点
notion image
代码如下:
至此,Union-Find 算法就基本完成了。是不是很神奇?竟然可以这样使用数组来模拟出一个森林,如此巧妙的解决这个比较复杂的问题!
那么这个算法的复杂度是多少呢?我们发现,主要 API connected 和 union 中的复杂度都是 find 函数造成的,所以说它们的复杂度和 find 一样。
find 主要功能就是从某个节点向上遍历到树根,其时间复杂度就是树的高度。我们可能习惯性地认为树的高度就是 logN,但这并不一定。logN 的高度只存在于平衡二叉树,对于一般的树可能出现极端不平衡的情况,使得「树」几乎退化成「链表」,树的高度最坏情况下可能变成 N
所以说上面这种解法,find , union , connected 的时间复杂度都是 O(N)。这个复杂度很不理想的,你想图论解决的都是诸如社交网络这样数据规模巨大的问题,对于 union 和 connected 的调用非常频繁,每次调用需要线性时间完全不可忍受。
问题的关键在于,如何想办法避免树的不平衡呢?只需要略施小计即可。

平衡性优化

我们要知道哪种情况下可能出现不平衡现象,关键在于 union 过程。
我们一开始就是简单粗暴的把 p 所在的树接到 q 所在的树的根节点下面,那么这里就可能出现「头重脚轻」的不平衡状况:
notion image
长此以往,树可能生长得很不平衡。我们其实是希望,小一些的树接到大一些的树下面,这样就能避免头重脚轻,更平衡一些。解决方法是额外使用一个 size 数组,记录每棵树包含的节点数,我们不妨称为「重量」。
比如说 size[3] = 5 表示,以节点 3 为根的那棵树,总共有 5 个节点。这样我们可以修改一下 union 方法:
这样,通过比较树的重量,就可以保证树的生长相对平衡,树的高度大致在 logN 这个数量级,极大提升执行效率。

路径压缩

这步优化虽然代码很简单,但原理非常巧妙。
其实我们并不在乎每棵树的结构长什么样,只在乎根节点
因为无论树长啥样,树上的每个节点的根节点都是相同的,所以能不能进一步压缩每棵树的高度,使树高始终保持为常数?
notion image
这样每个节点的父节点就是整棵树的根节点,find 就能以 O(1) 的时间找到某一节点的根节点,相应的,connected 和 union 复杂度都下降为 O(1)。
要做到这一点主要是修改 find 函数逻辑,非常简单,但你可能会看到两种不同的写法。
第一种是在 find 中加一行代码:
用语言描述就是,每次 while 循环都会让部分子节点向上移动,这样每次调用 find 函数向树根遍历的同时,顺手就将树高缩短了。
路径压缩的第二种写法是这样:
这种路径压缩的效果如下:
notion image
比起第一种路径压缩,显然这种方法压缩得更彻底,直接把一整条树枝压平,一点意外都没有。就算一些极端情况下产生了一棵比较高的树,只要一次路径压缩就能大幅降低树高,从 摊还分析 的角度来看,所有操作的平均时间复杂度依然是 O(1),所以从效率的角度来说,推荐你使用这种路径压缩算法。
另外,如果使用路径压缩技巧,那么 size 数组的平衡优化就没有必要了
完整的代码如下:
Union-Find 算法的复杂度可以这样分析:构造函数初始化数据结构需要 O(N) 的时间和空间复杂度;连通两个节点 union、判断两个节点的连通性 connected、计算连通分量 count 所需的时间复杂度均为 O(1)。

经典习题

323. 无向图中连通分量的数目

给你输入一个包含 n 个节点的图,用一个整数 n 和一个数组 edges 表示,其中 edges[i] = [ai, bi] 表示图中节点 ai 和 bi 之间有一条边。请你计算这幅图的连通分量个数。
这道题我们可以直接套用 UF 类来解决:

130. 被围绕的区域

给你一个 m x n 的矩阵 board ,由若干字符 'X' 和 'O' 组成,捕获 所有 被围绕的区域
  • 连接:一个单元格与水平或垂直方向上相邻的单元格连接。
  • 区域:连接所有 'O' 的单元格来形成一个区域。
  • 围绕:如果您可以用 'X' 单元格 连接这个区域,并且区域中没有任何单元格位于 board 边缘,则该区域被 'X' 单元格围绕。
通过 原地 将输入矩阵中的所有 'O' 替换为 'X' 来 捕获被围绕的区域。你不需要返回任何值。
notion image
其实这个问题应该归为 岛屿系列问题 使用 DFS 算法解决:
先用 for 循环遍历棋盘的四边,用 DFS 算法把那些与边界相连的 O 换成一个特殊字符,比如 #;然后再遍历整个棋盘,把剩下的 O 换成 X,把 # 恢复成 O。这样就能完成题目的要求,时间复杂度 O(MN)。
但这个问题也可以用 Union-Find 算法解决,虽然实现复杂一些,甚至效率也略低,但这是使用 Union-Find 算法的通用思想,值得一学。
你可以把那些不需要被替换的 O 看成一个拥有独门绝技的门派,它们有一个共同「祖师爷」叫 dummy,这些 O 和 dummy 互相连通,而那些需要被替换的 O 与 dummy 不连通
notion image
这就是 Union-Find 的核心思路,明白这个图,就很容易看懂代码了。
首先要解决的是,根据我们的实现,Union-Find 底层用的是一维数组,构造函数需要传入这个数组的大小,而题目给的是一个二维棋盘。
这个很简单,二维坐标 (x,y) 可以转换成 x * n + y 这个数(m 是棋盘的行数,n 是棋盘的列数),敲黑板,这是将二维坐标映射到一维的常用技巧
其次,我们之前描述的「祖师爷」是虚构的,需要给他老人家留个位置。索引 [0.. m*n-1] 都是棋盘内坐标的一维映射,那就让这个虚拟的 dummy 节点占据索引 m * n 好了。
看解法代码:
其实用 Union-Find 算法解决这个简单的问题有点杀鸡用牛刀,它可以解决更复杂,更具有技巧性的问题,主要思路是适时增加虚拟节点,想办法让元素「分门别类」,建立动态连通关系

990. 等式方程的可满足性

给定一个由表示变量之间关系的字符串方程组成的数组,每个字符串方程 equations[i] 的长度为 4,并采用两种不同的形式之一:"a==b" 或 "a!=b"。在这里,a 和 b 是小写字母(不一定不同),表示单字母变量名。
只有当可以将整数分配给变量名,以便满足所有给定的方程时才返回 true,否则返回 false
比如说,输入 ["a==b","b!=c","c==a"],算法返回 false,因为这三个算式不可能同时正确。
再比如,输入 ["c==c","b==d","x!=z"],算法返回 true,因为这三个算式并不会造成逻辑冲突。
我们前文说过,动态连通性其实就是一种等价关系,具有「自反性」「传递性」和「对称性」,其实 == 关系也是一种等价关系,具有这些性质。所以这个问题用 Union-Find 算法就很自然。
核心思想是,将 equations 中的算式根据 == 和 != 分成两部分,先处理 == 算式,使得他们通过相等关系各自勾结成门派(连通分量);然后处理 != 算式,检查不等关系是否破坏了相等关系的连通性
至此,这道判断算式合法性的问题就解决了,借助 Union-Find 算法,是不是很简单呢?
另外,Union-Find 算法也会在一些其他经典图论算法中用到,比如判断「图」和「树」,以及最小生成树的计算,详情见 Kruskal 最小生成树算法

547. 省份数量

有 n 个城市,其中一些彼此相连,另一些没有相连。如果城市 a 与城市 b 直接相连,且城市 b 与城市 c 直接相连,那么城市 a 与城市 c 间接相连。
省份 是一组直接或间接相连的城市,组内不含其他没有相连的城市。
给你一个 n x n 的矩阵 isConnected ,其中 isConnected[i][j] = 1 表示第 i 个城市和第 j 个城市直接相连,而 isConnected[i][j] = 0 表示二者不直接相连。
返回矩阵中 省份 的数量。
这题当然可以用 BFS 算法 或者 DFS 算法 来解决,但这道题也是 Union-Find 并查集 的经典应用场景,直接把我们在前文实现的 UF 类粘贴过来用就行了。

1361. 验证二叉树

二叉树上有 n 个节点,按从 0 到 n - 1 编号,其中节点 i 的两个子节点分别是 leftChild[i] 和 rightChild[i]
只有 所有 节点能够形成且  形成 一颗 有效的二叉树时,返回 true;否则返回 false
如果节点 i 没有左子节点,那么 leftChild[i] 就等于 -1。右子节点也符合该规则。
注意:节点没有值,本问题中仅仅使用节点编号。
看到这道题我就想到 261. 以图判树,你可以先去做这道题,理解树和图的关键区别之后再来做这道题。
我们解决 261 题的思路是用 Union-Find 并查集算法,但这道题的不同之处在于:
这里的每条边相当于是有方向的,而标准的并查集算法是处理无向图的,如果直接套用的话会出问题。
比如说,一个正常二叉树的节点不可能有两个入度(两个父节点),单纯的并查集算法只能检查是否成环,无法检查每个节点到底有多少入度。
不过我们可以用额外的代码来检查每个节点的入度是否合法,最后用并查集算法检测是否成环,从而判断二叉树是否合法。
除了并查集算法,我们还可以用二叉树的遍历函数来检查是否成环,两种思路我都写了解法代码,具体细节见代码注释。
DFS 遍历的方法效率更高。

947. 移除最多的同行或同列石头

n 块石头放置在二维平面中的一些整数坐标点上。每个坐标点上最多只能有一块石头。
如果一块石头的 同行或者同列 上有其他石头存在,那么就可以移除这块石头。
给你一个长度为 n 的数组 stones ,其中 stones[i] = [xi, yi] 表示第 i 块石头的位置,返回 可以移除的石子 的最大数量。
我们可以把每一块石头看做图中的一个节点,同一行的节点之间互相连通,同一列的节点之间也互相连通,这样,整幅图中就会有若干连通分量:
notion image
按照题目消除石头的规则,最优消除的策略可以消除每个连通分量中的大部分石头,最终无论如何会剩下一个石头。
所以可以消除掉的石头个数就是石头总数和连通分量个数之差。具体解法看代码吧。