Union-Find算法详解
Union-Find 算法,也就是常说的并查集算法,主要是解决图论中「动态连通性」问题的。
一、问题介绍
动态连通性其实可以抽象成给⼀幅图连线。⽐如下⾯这幅图,总共有 10 个节点,他们互不相连,分别⽤ 0~9 标记:
Union-Find 算法主要需要实现这两个 API:
class UF:
/* 将 p 和 q 连接 */
def union(self, p, q);
/* 判断 p 和 q 是否连通 */
def connected(self, p, q);
/* 返回图中有多少个连通分量 */
def count();
这⾥所说的「连通」是⼀种等价关系,也就是说具有如下三个性质:
- ⾃反性:节点 p 和 p 是连通的。
- 对称性:如果节点 p 和 q 连通,那么 q 和 p 也连通。
- 传递性:如果节点 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个。
判断这种「等价关系」⾮常实⽤,⽐如说编译器判断同⼀个变量的不同引⽤,⽐如社交⽹络中的朋友圈计算等等。
这样,你应该⼤概明⽩什么是动态连通性了,那么⽤什么模型来表示这幅图的连通状态呢?⽤什么数据结构来实现代码呢?
二、基本思路
本文中,我们使⽤森林(若⼲棵树)来表示图的动态连通性,⽤数组来具体实现这个森林。
怎么⽤森林来表示连通性呢?我们设定树的每个节点有⼀个指针指向其⽗节点,如果是根节点的话,这个指针指向⾃⼰。⽐如说刚才那幅 10 个节点的图,⼀开始的时候没有相互连通,就是这样:
对于union函数,如果某两个节点被连通,则让其中的(任意)⼀个节点的根节点接到另⼀个节点的根节点上:
对于connected函数,如果节点 p 和 q 连通的话,它们⼀定拥有相同的根节点。因此我们需要新增一个API用于找寻节点所在子树的根节点,即Find函数,定义如下:
/* 返回某个节点 x 的根节点 */
def find(int x):
// 根节点的 parent[x] == x
while (parent[x] != x)
x = parent[x];
return x;
更新后的union和connected函数如下:
def union(int p, int q):
rootP = find(p);
rootQ = find(q);
if (rootP == rootQ)
return;
// 将两棵树合并为⼀棵
parent[rootP] = rootQ;
// parent[rootQ] = rootP 也⼀样
count--; // 两个分量合⼆为⼀
def connected(int p, int q):
rootP = find(p);
rootQ = find(q);
return rootP == rootQ;
⾄此,Union-Find 算法就基本完成了。
后续关于该算法时间复杂度的优化可以参考UNION-FIND算法详解。
下面,本文将从具体实例来对该算法进行实践。
990. 等式方程的可满足性
解法:并查集
我们可以将每一个变量看作图中的一个节点,把相等的关系 == 看作是连接两个节点的边,那么由于表示相等关系的等式方程具有传递性,即如果 a==b 和 b==c 成立,则 a==c 也成立。也就是说,所有相等的变量属于同一个连通分量。因此,我们可以使用并查集来维护这种连通分量的关系。具体步骤如下:
- 首先遍历所有的等式,构造并查集。同一个等式中的两个变量属于同一个连通分量,因此将两个变量进行合并。
- 然后遍历所有的不等式。同一个不等式中的两个变量不能属于同一个连通分量,因此对两个变量分别查找其所在的连通分量,如果两个变量在同一个连通分量中,则产生矛盾,返回 false。
- 如果遍历完所有的不等式没有发现矛盾,则返回 true。
class Solution:
def equationsPossible(self, equations: List[str]) -> bool:
# 存储一棵树
parents = [i for i in range(26)]
# 记录树的重量(涉及时间复杂度的优化,可参考上文链接)
size = [1 for _ in range(26)]
# 将节点 p 和节点 q 连通
def union(left, right):
nonlocal parents
nonlocal size
rootA = find(left)
rootB = find(right)
# 已连通,则提前返回
if rootA == rootB:
return
sizeA = size[rootA]
sizeB = size[rootB]
# 小树接到大树下面,较平衡
if sizeA < sizeB:
parents[rootA] = rootB
size[rootB] += sizeA
else:
parents[rootB] = rootA
size[rootA] += sizeB
# 返回节点node的连通分量根节点,根节点root的父节点是它本身,即parents[root] = root
def find(node):
x = ord(node) - ord("a")
while x != parents[x]:
# 进行路径压缩,涉及时间复杂度优化,可以参考上文中链接
parents[x] = parents[parents[x]]
x = parents[x]
return x
# 遍历一次,构造并查集
for equation in equations:
if "==" in equation:
h, t = equation.split("==")
union(h, t)
# print(parents)
# 遍历第二次,进行正误性判断
for equation in equations:
if "==" not in equation:
h, t = equation.split("!=")
if find(h) == find(t):
return False
return True
94. 二叉树的中序遍历
解法:递归
利用之前文章所讲述的二叉树遍历框架即可求解。
class Solution:
def inorderTraversal(self, root: Optional[TreeNode]) -> List[int]:
result = []
def traverse(node):
nonlocal result
if not node:
return
# 前序遍历位置
traverse(node.left)
# 中序遍历位置
result.append(node.val)
traverse(node.right)
# 后序遍历位置
traverse(root)
return result