Skip to content

Commit

Permalink
Update 01.Union-Find.md
Browse files Browse the repository at this point in the history
  • Loading branch information
itcharge committed Dec 26, 2023
1 parent f1e3e94 commit ffd2fa1
Showing 1 changed file with 36 additions and 36 deletions.
72 changes: 36 additions & 36 deletions Contents/07.Tree/05.Union-Find/01.Union-Find.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,13 @@

- 并查集中的「查」是对于集合中存放的元素来说的,通常我们需要查询两个元素是否属于同一个集合。

如果我们只是想知道一个元素是否在集合中,可以通过 `Python` 或其他语言中的 `set` 集合来解决。而如果我们想知道两个元素是否属于同一个集合,则仅用一个 `set` 集合就很难做到了。这就需要用到我们接下来要讲解的「并查集」结构。
如果我们只是想知道一个元素是否在集合中,可以通过 Python 或其他语言中的 `set` 集合来解决。而如果我们想知道两个元素是否属于同一个集合,则仅用一个 `set` 集合就很难做到了。这就需要用到我们接下来要讲解的「并查集」结构。

根据上文描述,我们就可以定义一下「并查集」结构所支持的操作接口:

- **合并 `union(x, y)`**:将集合 `x` 和集合 `y` 合并成一个集合。
- 查找 `find(x)`:查找元素 `x` 属于哪个集合。
- **查找 `is_connected(x, y)`**:查询元素 `x``y` 是否在同一个集合中。
- **合并 `union(x, y)`**:将集合 $x$ 和集合 $y$ 合并成一个集合。
- **查找 `find(x)`**:查找元素 $x$ 属于哪个集合。
- **查找 `is_connected(x, y)`**:查询元素 $x$$y$ 是否在同一个集合中。

### 1.2 并查集的两种实现思路

Expand All @@ -36,17 +36,17 @@

如果我们希望并查集的查询效率高一些,那么我们就可以侧重于查询操作。

在使用「快速查询」思路实现并查集时,我们可以使用一个「数组结构」来表示集合中的元素。数组元素和集合元素是一一对应的,我们可以将数组的索引值作为每个元素的集合编号,称为 `id`。然后可以对数组进行以下操作来实现并查集:
在使用「快速查询」思路实现并查集时,我们可以使用一个「数组结构」来表示集合中的元素。数组元素和集合元素是一一对应的,我们可以将数组的索引值作为每个元素的集合编号,称为 $id$。然后可以对数组进行以下操作来实现并查集:

- **当初始化时**:将每个元素的集合编号初始化为数组下标索引。则所有元素的 `id` 都是唯一的,代表着每个元素单独属于一个集合。
- **合并操作时**:需要将其中一个集合中的所有元素 `id` 更改为另一个集合中的 `id`,这样能够保证在合并后一个集合中所有元素的 `id` 均相同。
- **查找操作时**:如果两个元素的 `id` 一样,则说明它们属于同一个集合;如果两个元素的 `id` 不一样,则说明它们不属于同一个集合。
- **当初始化时**:将每个元素的集合编号初始化为数组下标索引。则所有元素的 $id$ 都是唯一的,代表着每个元素单独属于一个集合。
- **合并操作时**:需要将其中一个集合中的所有元素 $id$ 更改为另一个集合中的 $id$,这样能够保证在合并后一个集合中所有元素的 $id$ 均相同。
- **查找操作时**:如果两个元素的 $id$ 一样,则说明它们属于同一个集合;如果两个元素的 $id$ 不一样,则说明它们不属于同一个集合。

举个例子来说明一下,我们使用数组来表示一系列集合元素 `{0}, {1}, {2}, {3}, {4}, {5}, {6}, {7}`,初始化时如下图所示。从下图中可以看出:元素的集合编号就是数组的索引值,代表着每个元素属于一个集合。
举个例子来说明一下,我们使用数组来表示一系列集合元素 $\left\{ 0 \right\}, \left\{ 1 \right\}, \left\{ 2 \right\}, \left\{ 3 \right\}, \left\{ 4 \right\}, \left\{ 5 \right\}, \left\{ 6 \right\}, \left\{ 7 \right\}$,初始化时如下图所示。从下图中可以看出:元素的集合编号就是数组的索引值,代表着每个元素属于一个集合。

![](https://qcdn.itcharge.cn/images/20220505145234.png)

当我们进行一系列的合并操作后,比如合并后变为 `{0}, {1, 2, 3}, {4}, {5, 6}, {7}`,合并操作的结果如下图所示。从图中可以看出,在进行一系列合并操作后,下标为 `1``2``3` 的元素集合编号是一致的,说明这 `3` 个 元素同属于一个集合。同理下标为 `5``6` 的元素则同属于另一个集合。
当我们进行一系列的合并操作后,比如合并后变为 $\left\{ 0 \right\}, \left\{ 1, 2, 3 \right\}, \left\{ 4 \right\}, \left\{5, 6\right\}, \left\{ 7 \right\}$,合并操作的结果如下图所示。从图中可以看出,在进行一系列合并操作后,下标为 $1$、$2$、$3$ 的元素集合编号是一致的,说明这 $3$ 个 元素同属于一个集合。同理下标为 $5$$6$ 的元素则同属于另一个集合。

![](https://qcdn.itcharge.cn/images/20220505145302.png)

Expand Down Expand Up @@ -86,21 +86,21 @@ class UnionFind:

> **注意**:与普通的树形结构(父节点指向子节点)不同的是,基于森林实现的并查集中,树中的子节点是指向父节点的。
此时,我们仍然可以使用一个数组 `fa` 来记录这个森林。我们用 `fa[x]` 来保存 `x` 的父节点的集合编号,代表着元素节点 `x` 指向父节点 `fa[x]`
此时,我们仍然可以使用一个数组 $fa$ 来记录这个森林。我们用 $fa[x]$ 来保存 $x$ 的父节点的集合编号,代表着元素节点 $x$ 指向父节点 $fa[x]$

当初始化时,`fa[x]` 值赋值为下标索引 `x`。在进行合并操作时,只需要将两个元素的树根节点相连接(`fa[root1] = root2`)即可。而在进行查询操作时,只需要查看两个元素的树根节点是否一致,就能知道两个元素是否属于同一个集合。
当初始化时,$fa[x]$ 值赋值为下标索引 $x$。在进行合并操作时,只需要将两个元素的树根节点相连接(`fa[root1] = root2`)即可。而在进行查询操作时,只需要查看两个元素的树根节点是否一致,就能知道两个元素是否属于同一个集合。

总结一下,我们可以对数组 `fa` 进行以下操作来实现并查集:
总结一下,我们可以对数组 $fa$ 进行以下操作来实现并查集:

- **当初始化时**:将每个元素的集合编号初始化为数组 `fa` 的下标索引。所有元素的根节点的集合编号不一样,代表着每个元素单独属于一个集合。
- **当初始化时**:将每个元素的集合编号初始化为数组 $fa$ 的下标索引。所有元素的根节点的集合编号不一样,代表着每个元素单独属于一个集合。
- **合并操作时**:需要将两个集合的树根节点相连接。即令其中一个集合的树根节点指向另一个集合的树根节点(`fa[root1] = root2`),这样合并后当前集合中的所有元素的树根节点均为同一个。
- **查找操作时**:分别从两个元素开始,通过数组 `fa` 存储的值,不断递归访问元素的父节点,直到到达树根节点。如果两个元素的树根节点一样,则说明它们属于同一个集合;如果两个元素的树根节点不一样,则说明它们不属于同一个集合。
- **查找操作时**:分别从两个元素开始,通过数组 $fa$ 存储的值,不断递归访问元素的父节点,直到到达树根节点。如果两个元素的树根节点一样,则说明它们属于同一个集合;如果两个元素的树根节点不一样,则说明它们不属于同一个集合。

举个例子来说明一下,我们使用数组来表示一系列集合元素 `{0}, {1}, {2}, {3}, {4}, {5}, {6}, {7}`,初始化时如下图所示。从下图中可以看出:元素的集合编号就是数组 `fa` 的索引值,代表着每个元素属于一个集合。
举个例子来说明一下,我们使用数组来表示一系列集合元素 $\left\{0\right\}, \left\{ 1 \right\}, \left\{ 2 \right\}, \left\{ 3 \right\}, \left\{ 4 \right\}, \left\{ 5 \right\}, \left\{ 6 \right\}, \left\{ 7 \right\}$,初始化时如下图所示。从下图中可以看出:元素的集合编号就是数组 $fa$ 的索引值,代表着每个元素属于一个集合。

![](https://qcdn.itcharge.cn/images/20220507112934.png)

当我们进行一系列的合并操作后,比如 `union(4, 5)``union(6, 7)``union(4, 7)` 操作后变为 `{0}, {1}, {2}, {3}, {4, 5, 6, 7}`,合并操作的步骤及结果如下图所示。从图中可以看出,在进行一系列合并操作后,`fa[4] == fa[5] == fa[6] == fa[fa[7]]`,即 `4``5``6``7` 的元素根节点编号都是 `4`,说明这 `4` 个 元素同属于一个集合。
当我们进行一系列的合并操作后,比如 `union(4, 5)``union(6, 7)``union(4, 7)` 操作后变为 $\left\{ 0 \right\}, \left\{ 1 \right\}, \left\{ 2 \right\}, \left\{ 3 \right\}, \left\{ 4, 5, 6, 7 \right\}$,合并操作的步骤及结果如下图所示。从图中可以看出,在进行一系列合并操作后,`fa[4] == fa[5] == fa[6] == fa[fa[7]]`,即 $4$、$5$、$6$、$7$ 的元素根节点编号都是 $4$,说明这 $4$ 个 元素同属于一个集合。

![](https://qcdn.itcharge.cn/images/20220507142647.png)

Expand Down Expand Up @@ -189,9 +189,9 @@ def find(self, x): # 查找元素根节点的集合

> **按深度合并(Unoin By Rank)**:在每次合并操作时,都把「深度」较小的树根节点指向「深度」较大的树根节点。
我们用一个数组 `rank` 记录每个根节点对应的树的深度(如果不是根节点,其 `rank` 值相当于以它作为根节点的子树的深度)。
我们用一个数组 $rank$ 记录每个根节点对应的树的深度(如果不是根节点,其 $rank$ 值相当于以它作为根节点的子树的深度)。

初始化时,将所有元素的 `rank` 值设为 `1`。在合并操作时,比较两个根节点,把 `rank` 值较小的根节点指向 `rank` 值较大的根节点上合并。
初始化时,将所有元素的 $rank$ 值设为 $1$。在合并操作时,比较两个根节点,把 $rank$ 值较小的根节点指向 $rank$ 值较大的根节点上合并。

下面是一个「按深度合并」的例子。

Expand Down Expand Up @@ -234,9 +234,9 @@ class UnionFind:

> **按大小合并(Unoin By Size)**:这里的大小指的是集合节点个数。在每次合并操作时,都把「集合节点个数」较少的树根节点指向「集合节点个数」较大的树根节点。
我们用一个数组 `size` 记录每个根节点对应的集合节点个数(如果不是根节点,其 `size` 值相当于以它作为根节点的子树的集合节点个数)。
我们用一个数组 $size$ 记录每个根节点对应的集合节点个数(如果不是根节点,其 $size$ 值相当于以它作为根节点的子树的集合节点个数)。

初始化时,将所有元素的 `size` 值设为 `1`。在合并操作时,比较两个根节点,把 `size` 值较小的根节点指向 `size` 值较大的根节点上合并。
初始化时,将所有元素的 $size$ 值设为 $1$。在合并操作时,比较两个根节点,把 $size$ 值较小的根节点指向 $size$ 值较大的根节点上合并。

下面是一个「按大小合并」的例子。

Expand Down Expand Up @@ -280,26 +280,26 @@ class UnionFind:

### 3.3 按秩合并的注意点

看过「按深度合并」和「按大小合并」的实现代码后,大家可能会产生一个疑问:为什么在路径压缩的过程中不用更新 `rank` 值或者 `size` 值呢?
看过「按深度合并」和「按大小合并」的实现代码后,大家可能会产生一个疑问:为什么在路径压缩的过程中不用更新 $rank$ 值或者 $size$ 值呢?

其实,代码中的 `rank` 值或者 `size` 值并不完全是树中真实的深度或者集合元素个数。
其实,代码中的 $rank$ 值或者 $size$ 值并不完全是树中真实的深度或者集合元素个数。

这是因为当我们在代码中引入路径压缩之后,维护真实的深度或者集合元素个数就会变得比较难。此时我们使用的 `rank` 值或者 `size` 值更像是用于当前节点排名的一个标志数字,只在合并操作的过程中,用于比较两棵树的权值大小。
这是因为当我们在代码中引入路径压缩之后,维护真实的深度或者集合元素个数就会变得比较难。此时我们使用的 $rank$ 值或者 $size$ 值更像是用于当前节点排名的一个标志数字,只在合并操作的过程中,用于比较两棵树的权值大小。

换句话说,我们完全可以不知道每个节点的具体深度或者集合元素个数,只要能够保证每两个节点之间的深度或者集合元素个数关系可以通过 `rank` 值或者 `size` 值正确的表达即可。
换句话说,我们完全可以不知道每个节点的具体深度或者集合元素个数,只要能够保证每两个节点之间的深度或者集合元素个数关系可以通过 $rank$ 值或者 $size$ 值正确的表达即可。

而根据路径压缩的过程,`rank` 值或者 `size` 值只会不断的升高,而不可能降低到比原先深度更小的节点或者集合元素个数更少的节点还要小。所以,`rank` 值或者 `size` 值足够用于比较两个节点的权值,进而选择合适的方式进行合并操作。
而根据路径压缩的过程,$rank$ 值或者 $size$ 值只会不断的升高,而不可能降低到比原先深度更小的节点或者集合元素个数更少的节点还要小。所以,$rank$ 值或者 $size$ 值足够用于比较两个节点的权值,进而选择合适的方式进行合并操作。

## 4. 并查集的算法分析

首先我们来分析一下并查集的空间复杂度。在代码中,我们主要使用了数组 `fa` 来存储集合中的元素。如果使用了「按秩合并」的优化方式,还会使用数组 `rank` 或者数组 `size` 来存放权值。因为空间复杂度取决于元素个数,不难得出空间复杂度为 $O(n)$。
首先我们来分析一下并查集的空间复杂度。在代码中,我们主要使用了数组 $fa$ 来存储集合中的元素。如果使用了「按秩合并」的优化方式,还会使用数组 $rank$ 或者数组 $size$ 来存放权值。因为空间复杂度取决于元素个数,不难得出空间复杂度为 $O(n)$。

在同时使用了「路径压缩」和「按秩合并」的情况下,并查集的合并操作和查找操作的时间复杂度可以接近于 $O(1)$。最坏情况下的时间复杂度是 $O(m * \alpha(n))$。这里的 $m$ 是合并操作和查找操作的次数,$\alpha(n)$ 是 Ackerman 函数的某个反函数,其增长极其缓慢,也就是说其单次操作的平均运行时间可以认为是一个很小的常数。
在同时使用了「路径压缩」和「按秩合并」的情况下,并查集的合并操作和查找操作的时间复杂度可以接近于 $O(1)$。最坏情况下的时间复杂度是 $O(m \times \alpha(n))$。这里的 $m$ 是合并操作和查找操作的次数,$\alpha(n)$ 是 Ackerman 函数的某个反函数,其增长极其缓慢,也就是说其单次操作的平均运行时间可以认为是一个很小的常数。

总结一下:

- 并查集的空间复杂度:$O(n)$。
- 并查集的时间复杂度:$O(m * \alpha(n))$。
- 并查集的时间复杂度:$O(m \times \alpha(n))$。

## 5. 并查集的最终实现代码

Expand Down Expand Up @@ -380,17 +380,17 @@ class UnionFind:

#### 6.1.2 题目大意

**描述**:给定一个由字符串方程组成的数组 `equations`,每个字符串方程 `equations[i]` 的长度为 `4`,有以下两种形式组成:`a==b``a!=b``a``b` 是小写字母,表示单字母变量名。
**描述**:给定一个由字符串方程组成的数组 $equations$,每个字符串方程 $equations[i]$ 的长度为 $4$,有以下两种形式组成:`a==b``a!=b`$a$$b$ 是小写字母,表示单字母变量名。

**要求**:判断所有的字符串方程是否能同时满足,如果能同时满足,返回 `True`,否则返回 `False`
**要求**:判断所有的字符串方程是否能同时满足,如果能同时满足,返回 $True$,否则返回 $False$

**说明**

- $1 \le equations.length \le 500$。
- $equations[i].length == 4$。
- $equations[i][0]$ 和 $equations[i][3]$ 是小写字母。
- $equations[i][1]$ 要么是 `'='`,要么是 `'!'`
- `equations[i][2]``'='`
- $equations[i][2]$`'='`

**示例**

Expand All @@ -407,7 +407,7 @@ class UnionFind:
这就需要用到并查集,具体操作如下:

- 遍历所有等式方程,将等式两边的单字母变量顶点进行合并。
- 遍历所有不等式方程,检查不等式两边的单字母遍历是不是在一个连通分量中,如果在则返回 `False`,否则继续扫描。如果所有不等式检查都没有矛盾,则返回 `True`
- 遍历所有不等式方程,检查不等式两边的单字母遍历是不是在一个连通分量中,如果在则返回 $False$,否则继续扫描。如果所有不等式检查都没有矛盾,则返回 $True$

#### 6.1.4 代码

Expand Down Expand Up @@ -460,11 +460,11 @@ class Solution:

#### 6.2.2 题目大意

**描述**:有 `n` 个城市,其中一些彼此相连,另一些没有相连。如果城市 `a` 与城市 `b` 直接相连,且城市 `b` 与城市 `c` 直接相连,那么城市 `a` 与城市 `c` 间接相连。
**描述**:有 $n$ 个城市,其中一些彼此相连,另一些没有相连。如果城市 $a$ 与城市 $b$ 直接相连,且城市 $b$ 与城市 $c$ 直接相连,那么城市 $a$ 与城市 $c$ 间接相连。

「省份」是由一组直接或间接链接的城市组成,组内不含有其他没有相连的城市。

现在给定一个 `n * n` 的矩阵 `isConnected` 表示城市的链接关系。其中 `isConnected[i][j] = 1` 表示第 `i` 个城市和第 `j` 个城市直接相连,`isConnected[i][j] = 0` 表示第 `i` 个城市和第 `j` 个城市没有相连。
现在给定一个 $n \times n$ 的矩阵 $isConnected$ 表示城市的链接关系。其中 `isConnected[i][j] = 1` 表示第 $i$ 个城市和第 $j$ 个城市直接相连,`isConnected[i][j] = 0` 表示第 $i$ 个城市和第 $j$ 个城市没有相连。

**要求**:根据给定的城市关系,返回「省份」的数量。

Expand All @@ -491,7 +491,7 @@ class Solution:
#### 6.2.3 解题思路

具体做法如下:
- 遍历矩阵 `isConnected`。如果 `isConnected[i][j] = 1`,将 `i` 节点和 `j` 节点相连。
- 遍历矩阵 $isConnected$。如果 `isConnected[i][j] = 1`,将 $i$ 节点和 $j$ 节点相连。
- 然后判断每个城市节点的根节点,然后统计不重复的根节点有多少个,也就是集合个数,即为「省份」的数量。

#### 6.2.4 代码
Expand Down

0 comments on commit ffd2fa1

Please # to comment.