并查集
本文参考了【算法】并查集(Disjoint Set)、并查集详解和并查集
并查集原理
并查集是一种用于处理不相交集合之间合并问题的数据结构,例如求连通子图、判断是否存在环、求最小生成树等。
以判断图中是否有环为例,下图是一个无向图。
首先把每一个顶点都看作是一个集合,逐一地考察每一条边。
- 考察
A---B
,则把 A 和 B 放入同一个集合,有{A, B}, {C}, {D}, {E}
; - 考察
A---C
,则把 A 和 C 放入同一个集合,有{A, B, C}, {D}, {E}
; - 考察
A---D
,则把 A 和 D 放入同一个集合,有{A, B, C, D}, {E}
; - 考察
C---D
,发现 C 和 D 已经在同一个集合里了,这就说明图中存在环。
由这个例子可以看出,并查集的主要操作就是判断两个元素是否在同一个集合中,若不在同一个集合中,则将他们合并。实际上,并查集用一个”代表“来表示一个集合,对于给定的任意一个元素,都可以在常数级别的时间复杂度内找到这个元素的”代表“,即所在的集合。
可见,并查集主要包含两种操作:
- find(x):找到元素 x 所在集合的代表;
- unionSet(x, y): 若 x 和 y 不在同一个集合,则将其合并。
实现(Python)
并查集用树来表示集合,树中的每一个结点就代表集合中的一个元素,树的根结点就是这个集合的”代表“。要注意的是,树中的父子关系并不能代表元素之间的关系。而树是用数组来表示的,索引 i 处存储的是该元素的父结点的索引,而树根结点的父节点就是它本身。首先初始化一个并查集。
def init(n: int) -> List[int]: # 假设有 n 个元素,即开始时每个元素自成一派
return [i for i in range(n)]
接下来写find
操作。只要每次沿着父节点向上,最终就一定能够找到根结点,即对应集合的”代表“。
def find(x: int, path: List[int]) -> int:
root = x
while path[root] != root:
root = path[root]
return root
最后是合并操作。需要判断两个结点的根结点是否相同,如果不同,则将他们合并。
def unionSet(x: int, y: int, path: List[int]) -> bool:
x_root = find(x, path)
y_root = find(y, path)
if x_root == y_root: # 未发生合并,返回False
return False
else: # 需要合并,返回True
path[y_root] = x_root
return True
这时候会发现一个问题,就是之前说的find
操作是在常数时间内的,如果构造树的时候构造出一个一字长蛇阵,那么这个操作显然不是常数级别的。因此需要用到路径压缩。
路径压缩
每次查找时,将结点的父节点改为对应集合的根结点。这样每条经过查找的路径高度都会降为 1。但是由于只在查找时进行路径压缩,因此树的结构仍然有可能是奇形怪状的。
只要修改find
操作的代码就行了。
def find(x: int, path: List[int]) -> int:
if x != path[x]:
path[x] = find(path[x], path)
return path[x]
按秩合并
另开一个跟path
等长的数组,用来存树的高度。每次合并操作时,总是将较矮树的根结点指向较高树的根结点。
def init(n: int) -> List[int]: # 假设有 n 个元素,即开始时每个元素自成一派
path = [i for i in range(n)]
rank = [0 for i in range(n)]
return path, rank
同时,需要修改合并操作的代码。当两棵树的高度一样时,我们把 y 的根结点指向 x 的根结点。
def unionSet(x: int, y: int, path: List[int]) -> bool:
x_root = find(x, path)
y_root = find(y, path)
if x_root == y_root: # 未发生合并,返回False
return False
else: # 需要合并,返回True
if rank[x_root] > rank[y_root]:
path[y_root] = x_root
elif rank[x_root] < rank[y_root]:
path[x_root] = y_root
else:
path[y_root] = x_root
rank[x_root] += 1
return True
例题
LeetCode 547. 朋友圈
班上有 N 名学生。其中有些人是朋友,有些则不是。他们的友谊具有是传递性。如果已知 A 是 B 的朋友,B 是 C 的朋友,那么我们可以认为 A 也是 C 的朋友。所谓的朋友圈,是指所有朋友的集合。
给定一个 N * N 的矩阵 M,表示班级中学生之间的朋友关系。如果\(M_{ij}=1\),表示已知第 i 个和 j 个学生互为朋友关系,否则为不知道。你必须输出所有学生中的已知的朋友圈总数。
思路:
只要直接套用以上的并查集模板即可,最后统计其中树根的个数即可。由上面的假设可知,树根结点满足path[i]=i
。感觉这种不那么复杂的题也不需要按秩合并或者路径压缩了,直接来还快一些。
class Solution:
def findCircleNum(self, M: List[List[int]]) -> int:
def find(x: int, path: List[int]) -> int:
root = x
while path[root] != root:
root = path[root]
return root
def unionSet(x: int, y: int, path: List[int], rank: List[int]) -> bool:
x_root = find(x, path)
y_root = find(y, path)
if x_root == y_root: # 未发生合并,返回False
return False
else: # 需要合并,返回True
if rank[x_root] > rank[y_root]:
path[y_root] = x_root
elif rank[x_root] < rank[y_root]:
path[x_root] = y_root
else:
path[y_root] = x_root
rank[x_root] += 1
return True
N = len(M)
path = [i for i in range(N)]
rank = [0] * N
for i in range(N):
for j in range(i, N):
if M[i][j] == 1 and i != j:
unionSet(i, j, path, rank)
cnt = 0
for i in range(N):
if path[i] == i:
cnt += 1
return cnt