union-find.md 11.8 KB
Newer Older
L
lucifer 已提交
1 2
# 并查集

L
lucifer 已提交
3
## 背景
L
lucifer 已提交
4

L
lucifer 已提交
5
相信大家都玩过下面的迷宫游戏。你的目标是从地图的某一个角落移动到地图的出口。规则很简单,仅仅你不能穿过墙。
L
lucifer 已提交
6

L
lucifer 已提交
7
![](https://tva1.sinaimg.cn/large/008eGmZEly1goxczig610j30as0ar48s.jpg)
L
lucifer 已提交
8

L
lucifer 已提交
9 10 11
实际上,这道题并不能够使用并查集来解决。 不过如果我将规则变成,“是否存在一条从入口到出口的路径”,那么这就是一个简单的联通问题,只不过用肉眼去观察实在是太费力了。幸好,我们有计算器可以帮助我们。并查集算法就可以用来解决这个问题,并且比暴力枚举效率高。如果地图不变,而不断改变入口和出口的位置,并查集的效果高的超出你的想象。

另外并查集还可以在人工智能中用作图像人脸识别等,感兴趣的可以查找一下相关资料。
L
lucifer 已提交
12 13 14

## 概述

L
lucifer 已提交
15
并查集使用的是一种**树型**的数据结构,用于处理一些不交集(Disjoint Sets)的合并及查询问题。比如让你求两个人是否间接认识,两个地点之间是否有至少一条路径。上面的例子其实都可以抽象为联通性问题。即如果两个点联通,那么这两个点就有至少一条路径能够将其连接起来。值得注意的是,并查集只能回答“联通与否”,而不能回答诸如“具体的联通路径是什么”。如果要回答“具体的联通路径是什么”这个问题,则需要借助其他算法,比如广度优先遍历。
L
lucifer 已提交
16

L
lucifer 已提交
17
并查集(Union-find Algorithm)定义了两个用于此数据结构的操作:
L
lucifer 已提交
18

L
lucifer 已提交
19
- Find:确定元素属于哪一个子集。它可以被用来确定两个元素是否属于同一子集。
L
lucifer 已提交
20

L
lucifer 已提交
21
- Union:将两个子集合并成同一个集合。
L
lucifer 已提交
22 23 24 25 26

## 形象解释

比如有两个司令。 司令下有若干军长,军长下有若干师长。。。

L
lucifer 已提交
27 28 29
### 判断两个节点是否联通

我们如何判断某两个师长是否归同一个司令管呢(连通性)?
L
lucifer 已提交
30

L
lucifer 已提交
31
![](https://tva1.sinaimg.cn/large/007S8ZIlly1ghlufxh5lhj30gs0bzwet.jpg)
L
lucifer 已提交
32

L
lucifer 已提交
33
很简单,我们顺着师长,往上找,找到司令。 如果两个师长找到的是同一个司令,那么两个人就归同一个司令管。
L
lucifer 已提交
34

L
lucifer 已提交
35
代码上我们可以用 parent[x] = y 表示 x 的 parent 是 y,通过不断沿着搜索 parent 搜索找到 root,然后比较 root 是否相同即可得出结论。 这里的 root 实际上就是上文提到的**集合代表**
L
lucifer 已提交
36

L
lucifer 已提交
37 38 39
这个不断往上找的操作,我们一般称为 find,使用 ta 我们可以很容易地求出两个节点是否连通。

### 合并两个联通区域
L
lucifer 已提交
40

L
lucifer 已提交
41 42
如图有两个司令:

L
lucifer 已提交
43
![](https://tva1.sinaimg.cn/large/007S8ZIlly1ghlufys950j30wp0el0th.jpg)
L
lucifer 已提交
44 45 46

我们将其合并为一个联通域,最简单的方式就是直接将其中一个司令指向另外一个即可:

L
lucifer 已提交
47
![](https://tva1.sinaimg.cn/large/007S8ZIlly1ghlug0ni3jj30ym0cojsb.jpg)
L
lucifer 已提交
48 49 50 51 52

以上就是三个核心 API `find``connnected``union`, 的形象化解释,下面我们来看下代码实现。

## 核心 API

L
lucifer 已提交
53 54 55 56 57 58 59 60
首先我们初始化每一个点都是一个连通域,类似下图:

![](https://tva1.sinaimg.cn/large/008eGmZEly1gmm4f8vpp3j30p9024jra.jpg)

为了更加精确的定义这些方法,需要定义如何表示集合。一种常用的策略是为每个集合选定一个固定的元素,称为代表,以表示整个集合。接着,Find(x) 返回 x 所属集合的代表,而 Union 使用两个集合的代表作为参数进行合并。

> 这里的代表就是上面的“司令”。

L
lucifer 已提交
61 62 63 64 65 66 67 68 69
### find

```python
def find(self, x):
    while x != self.parent[x]:
        x = self.parent[x]
    return x
```

L
lucifer 已提交
70 71 72 73 74 75 76 77 78 79
也可使用递归来实现。

```py
def find(self, x):
    if x != self.parent[x]:
        self.parent[x] = self.find(self.parent[x])
        return self.parent[x]
    return x
```

L
lucifer 已提交
80 81 82
这里我在递归实现的 find 过程进行了路径的压缩,每次往上查找之后都会将树的高度降低到 2。

这有什么用呢?我们知道每次 find 都会从当前节点往上不断搜索,直到到达根节点,因此 find 的时间复杂度大致相等于节点的深度,最坏的情况节点的深度等于树的高度,树的高度如果不加控制则可能为节点数,因此 find 的时间复杂度可能会退化到 $O(n)$。而如果进行路径压缩,那么树的平均高度不会超过 $logn$,具体证明略。不过给大家画了一个图来辅助大家理解。
L
lucifer 已提交
83 84 85 86 87

比如对于如下的一个图:

![](https://tva1.sinaimg.cn/large/008eGmZEly1gmm4g5jyb9j30og05naaa.jpg)

L
lucifer 已提交
88 89
图中 r:1 表示 秩为 1,r 是 rank 的简写。这里的秩其实对应的就是上文的 size。(下同,不再赘述)

L
lucifer 已提交
90 91 92 93
调用 find(0) 会逐步找到 3 ,在找到 3 的过程上会将路径上的节点都指向根节点。

![](https://tva1.sinaimg.cn/large/008eGmZEly1gmm4i1vrclg30ni05wtj9.gif)

L
lucifer 已提交
94 95 96 97
极限情况下,每一个路径都会被压缩,这种情况下继续查找的时间复杂度就是 $O(1)$。

![](https://tva1.sinaimg.cn/large/008eGmZEly1gmm4zjf5evj30u00aigml.jpg)

L
lucifer 已提交
98 99
### connected

L
lucifer 已提交
100 101
直接利用上面实现好的 find 方法即可。如果两个节点的祖先相同,那么其就联通。

L
lucifer 已提交
102 103 104 105 106 107 108
```python
def connected(self, p, q):
    return self.find(p) == self.find(q)
```

### union

L
lucifer 已提交
109 110
将其中一个节点挂到另外一个节点的祖先上,这样两者祖先就一样了。也就是说,两个节点联通了。

L
lucifer 已提交
111 112 113 114 115 116
对于如下的一个图:

![](https://tva1.sinaimg.cn/large/008eGmZEly1gmm4avz4iej30lv04rmx9.jpg)

如果我们将 0 和 7 进行一次合并。即 `union(0, 7)` ,则会发生如下过程。

L
lucifer 已提交
117 118 119 120
- 找到 0 的根节点 3
- 找到 7 的根节点 6
- 将 6 指向 3。(为了使得合并之后的树尽可能平衡,一般选择将小树挂载到大树上面,下面的代码模板会体现这一点。3 的秩比 6 的秩大,这样更利于树的平衡,避免出现极端的情况)

L
lucifer 已提交
121 122 123 124
![](https://tva1.sinaimg.cn/large/008eGmZEly1gmm4btv06yg30ni05wwze.gif)

代码:

L
lucifer 已提交
125 126 127 128 129 130
```python
def union(self, p, q):
    if self.connected(p, q): return
    self.parent[self.find(p)] = self.find(q)
```

L
lucifer 已提交
131 132
这里我并没有判断秩的大小关系,目的是方便大家理清主脉络。完整代码见下面代码区。

L
lucifer 已提交
133
## 不带权并查集
L
lucifer 已提交
134

L
lucifer 已提交
135
平时做题过程,遇到的更多的是不带权的并查集。相比于带权并查集, 其实现过程也更加简单。
L
lucifer 已提交
136

L
lucifer 已提交
137
### 带路径压缩的代码模板
L
lucifer 已提交
138 139 140

```python
class UF:
L
lucifer 已提交
141
    def __init__(self, M):
L
lucifer 已提交
142 143 144
        self.parent = {}
        self.size = {}
        self.cnt = 0
L
lucifer 已提交
145
        # 初始化 parent,size 和 cnt
L
lucifer 已提交
146 147 148 149
        for i in range(M):
            self.parent[i] = i
            self.cnt += 1
            self.size[i] = 1
L
lucifer 已提交
150

L
lucifer 已提交
151
    def find(self, x):
L
lucifer 已提交
152 153 154
        if x != self.parent[x]:
            self.parent[x] = self.find(self.parent[x])
            return self.parent[x]
L
lucifer 已提交
155
        return x
L
lucifer 已提交
156 157
    def union(self, p, q):
        if self.connected(p, q): return
L
lucifer 已提交
158 159 160 161 162
        # 小的树挂到大的树上, 使树尽量平衡
        leader_p = self.find(p)
        leader_q = self.find(q)
        if self.size[leader_p] < self.size[leader_q]:
            self.parent[leader_p] = leader_q
L
lucifer 已提交
163
            self.size[leader_q] += self.size[leader_p]
L
lucifer 已提交
164 165
        else:
            self.parent[leader_q] = leader_p
L
lucifer 已提交
166
            self.size[leader_p] += self.size[leader_q]
L
lucifer 已提交
167
        self.cnt -= 1
L
lucifer 已提交
168 169 170 171
    def connected(self, p, q):
        return self.find(p) == self.find(q)
```

L
lucifer 已提交
172 173
## 带权并查集

L
lucifer 已提交
174
上面讲到的其实都是有向无权图,因此仅仅使用 parent 表示节点关系就可以了。而如果使用的是有向带权图呢?实际上除了维护 parent 这样的节点指向关系,我们还需要维护节点的权重,一个简单的想法是使用另外一个哈希表 weight 存储节点的权重关系。比如 `weight[a] = 1 表示 a 到其父节点的权重是 1`
L
lucifer 已提交
175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243

如果是带权的并查集,其查询过程的路径压缩以及合并过程会略有不同,因为我们不仅关心节点指向的变更,也关心权重如何更新。比如:

```
a    b
^    ^
|    |
|    |
x    y
```

如上表示的是 x 的父节点是 a,y 的父节点是 b,现在我需要将 x 和 y 进行合并。

```
a    b
^    ^
|    |
|    |
x -> y
```

假设 x 到 a 的权重是 w(xa),y 到 b 的权重为 w(yb),x 到 y 的权重是 w(xy)。合并之后会变成如图的样子:

```
a -> b
^    ^
|    |
|    |
x    y
```

那么 a 到 b 的权重应该被更新为什么呢?我们知道 w(xa) + w(ab) = w(xy) + w(yb),也就是说 a 到 b 的权重 w(ab) = w(xy) + w(yb) - w(xa)。

当然上面关系式是加法,减法,取模还是乘法,除法等完全由题目决定,我这里只是举了一个例子。不管怎么样,这种运算一定需要满足**可传导性**

### 带路径压缩的代码模板

这里以加法型带权并查集为例,讲述一下代码应该如何书写。

```py
class UF:
    def __init__(self, M):
        # 初始化 parent,weight
        self.parent = {}
        self.weight = {}
        for i in range(M):
            self.parent[i] = i
            self.weight[i] = 0

   def find(self, x):
        if self.parent[x] != x:
            ancestor, w = self.find(self.parent[x])
            self.parent[x] = ancestor
            self.weight[x] += w
        return self.parent[x], self.weight[x]
    def union(self, p, q, dist):
        if self.connected(p, q): return
        leader_p, w_p = self.find(p)
        leader_q, w_q = self.find(q)
        self.parent[leader_p] = leader_q
        self.weight[leader_p] = dist + w_q - w_p
    def connected(self, p, q):
        return self.find(p)[0] == self.find(q)[0]
```

典型题目:

- [399. 除法求值](https://leetcode-cn.com/problems/evaluate-division/)

L
lucifer 已提交
244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259
## 应用

- 检测图是否有环

思路: 只需要将边进行合并,并在合并之前判断是否已经联通即可,如果合并之前已经联通说明存在环。

代码:

```py
uf = UF()
for a, b in edges:
    if uf.connected(a, b): return False
    uf.union(a, b)
return True
```

L
lucifer 已提交
260 261 262 263
题目推荐:

- [684. 冗余连接](https://leetcode-cn.com/problems/redundant-connection/solution/bing-cha-ji-mo-ban-ben-zhi-jiu-shi-jian-0wz2m/)
- [Forest Detection](https://binarysearch.com/problems/Forest-Detection)
L
lucifer 已提交
264 265
- 最小生成树经典算法 Kruskal

L
lucifer 已提交
266 267 268 269 270 271 272 273 274 275 276 277 278 279
## 练习

关于并查集的题目不少,官方给的数据是 30 道(截止 2020-02-20),但是有一些题目虽然官方没有贴`并查集`标签,但是使用并查集来说确非常简单。这类题目如果掌握模板,那么刷这种题会非常快,并且犯错的概率会大大降低,这就是模板的好处。

我这里总结了几道并查集的题目:

- [547. 朋友圈](../problems/547.friend-circles.md)
- [721. 账户合并](https://leetcode-cn.com/problems/accounts-merge/solution/mo-ban-ti-bing-cha-ji-python3-by-fe-lucifer-3/)
- [990. 等式方程的可满足性](https://github.com/azl397985856/leetcode/issues/304)
- [1202. 交换字符串中的元素](https://leetcode-cn.com/problems/smallest-string-with-swaps/)
- [1697. 检查边长度限制的路径是否存在](https://leetcode-cn.com/problems/checking-existence-of-edge-length-limited-paths/)

上面的题目前面四道都是无权图的连通性问题,第五道题是带权图的连通性问题。两种类型大家都要会,上面的题目关键字都是**连通性**,代码都是套模板。看完这里的内容,建议拿上面的题目练下手,检测一下学习成果。

L
lucifer 已提交
280 281
## 总结

L
lucifer 已提交
282 283 284 285 286 287 288 289 290 291 292 293 294
如果题目有连通,等价的关系,那么你就可以考虑并查集,另外使用并查集的时候要注意路径压缩,否则随着树的高度增加复杂度会逐渐增大。

对于带权并查集实现起来比较复杂,主要是路径压缩和合并这块不一样,不过我们只要注意节点关系,画出如下的图:

```
a -> b
^    ^
|    |
|    |
x    y
```

就不难看出应该如何更新拉。
L
lucifer 已提交
295

L
lucifer 已提交
296
本文提供的题目模板是西法我用的比较多的,用了它不仅出错概率大大降低,而且速度也快了很多,整个人都更自信了呢 ^\_^