Files

163 lines
8.2 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
### **C++ 算法:并查集**
#### **一、 引言**
##### **1.1 问题引入:朋友圈的合并与查询**
想象一下,我们班里有 $N$ 个同学,刚开学时大家互不相识,每个人都是一个独立的“小团体”。随着时间推移,不断有两人成为好朋友(例如“小明和小红成了好朋友”)。我们规定,**朋友的朋友也是朋友**,这样就会形成一个个越来越大的“朋友圈”。
现在,班主任需要随时进行两种操作:
1. **合并 (Union)**:当两个人成为新朋友时,快速把他们各自的“朋友圈”合并成一个更大的圈子。
2. **查询 (Find)**:随时提问:同学 A 和同学 B 现在属于同一个朋友圈吗?
##### **1.2 常规思路的困境**
我们尝试用已学的知识来解决这个问题:
* **思路一:用数组存圈子编号**
* 定义 `group_id[i]` 表示 $i$ 号同学所在的圈子编号。
* **查询 (A, B)**:只需比较 `group_id[A] == group_id[B]`。速度极快,时间复杂度 **O(1)**。
* **合并 (A, B)**:假设A在1号圈,B在2号圈。为了合并,我们必须**遍历所有学生**,把所有组号为 2 的人都改成 1。速度慢,时间复杂度 **O(N)**。
* 如果进行 $M$ 次合并,总耗时 $O(M \times N)$,数据量稍大就会超时。
* **思路二:用图的邻接表 + DFS/BFS**
* 把同学看作顶点,朋友关系看作双向边。
* **合并 (A, B)**:在图里加一条边 `adj[A].push_back(B)`。快,时间复杂度 **O(1)**。
* **查询 (A, B)**:要判断A和B是否连通,必须从A开始做一次完整的 DFS 或 BFS 去找 B。慢,时间复杂度 **O(N+M)**。
**总结**:常规方法总是顾此失彼(查询快合并就慢,合并快查询就慢)。我们需要一种全新的数据结构,能让**合并和查询都非常快**。它就是我们今天要学习的——**并查集**。
---
#### **二、 核心思想**
并查集的核心思想,就像现实生活中的组织架构:
每个圈子(集合)都推选出一个唯一的**“老大”(代表元)**,这个圈子里的所有事情,都由老大出面代表。
我们只需要一个非常简单的一维数组:
`int parent[N];`
* `parent[i]` 存储的是 $i$ 号同学的**直接上级**是谁。
##### **2.1 初始化**
一开始,所有人都不认识,每个人都是自己圈子的老大。
```cpp
void init(int n) {
for (int i = 1; i <= n; i++) {
parent[i] = i; // 初始时,每个人的上级就是他自己(自己是老大)
}
}
```
##### **2.2 查询操作:`find(x)`**
**目标**:找出 $x$ 所在圈子的**最终老大**是谁。
怎么找?顺藤摸瓜,逐级上报!
1. 问 $x$ 他的直接上级 `parent[x]` 是谁。
2. 如果上级就是他自己 (`parent[x] == x`),说明 $x$ 就是最高级别的老大,寻找结束。
3. 如果上级不是自己,就把寻找老大的任务抛给他的上级,去问 `find(parent[x])`。
4. 这样一条链式地问上去,必定能找到最终老大。
如果只做到这一步,并查集还是慢的。试想一条长长的单线联系:1的上级是2,2的上级是3... 99的上级是100(老大)。1找老大要汇报99次!
##### **2.3 优化:路径压缩**
小兵 1 心想:既然我最终的老大是司令 100,那我干嘛每次都要通过班长、排长、连长逐级传话呢?我直接越级,**把司令设为我的直接上级**不行吗?
这就是并查集的优化——**路径压缩**。在 `find(x)` 函数**递归返回**的过程中,顺手把经过的所有节点,直接挂到最终老大的名下。
```cpp
int find(int x) {
// 递归边界:如果我自己就是老大,返回我自己
if (parent[x] == x) {
return x;
}
// 核心代码:
// 1. find(parent[x]) 会递归找到最终老大。
// 2. 将最终老大的编号直接赋值给 parent[x]。
// 3. 返回这个最终老大。
return parent[x] = find(parent[x]);
}
```
经过路径压缩后,原本长长的一条链,就会被压缩到扁平。下次再执行 `find` 时,只需一步,时间复杂度接近 **O(1)**!
##### **2.4 合并操作:`unite(u, v)`**
有了 `find` 工具,合并操作实现起来就非常轻松。
$u$ 和 $v$ 成为朋友,要求合并他们的朋友圈。怎么做?**让一个圈子的老大,去认另一个圈子的老大当新老大!**
```cpp
void unite(int u, int v) {
int rootU = find(u); // 找到 u 的老大
int rootV = find(v); // 找到 v 的老大
// 如果老大不一样,说明本来不在一个圈子,需要合并
if (rootU != rootV) {
parent[rootU] = rootV; // 让 u 的老大 认 v 的老大 当上级
}
}
```
**思考**:只改了 `parent[rootU] = rootV` 这一行,整个圈子就合并了吗?
**答案是肯定的!** 因为原来以 `rootU` 为老大的圈子里的小弟们,下次调用 `find` 找老大时,会先找到 `rootU`,然后发现 `rootU` 上面又有新老大了(`rootV`),于是大家都会顺藤摸瓜找到 `rootV`,并在路径压缩的帮助下,直接把自己挂到 `rootV` 名下。
---
#### **三、 实战演练**
##### **案例一:亲戚 (P238页 例17-1)**
**需求**:给出 $n$ 个人,$m$ 对亲戚关系(若 $x,y$ 是亲戚,$y,z$ 是亲戚,则 $x,z$ 也是亲戚)。随后有 $p$ 次询问,判断两个人是否是亲戚。
**分析**:
1. “亲戚关系传递” 就是把人合并到一个圈子。
2. “判断是否是亲戚” 就是判断两人是不是在同一个圈子(老大是不是同一个人)。
**解题流程**:
1. 读取 $n$,执行 `init(n)` 初始化并查集。
2. 读取 $m$ 条关系,对每对关系执行 `unite(u, v)`。
3. 读取 $p$ 次询问,对每对查询,判断 `find(u) == find(v)`。是则输出 Yes,否则输出 No。
##### **案例二:村村通 (B0248)**
**需求**:现有 $n$ 个城镇,$m$ 条已有道路。要求整个城镇网络全部连通(任意两城互达)。问:**最少还需要建设多少条道路?**
**分析**:
城镇是节点,道路是边。这 $n$ 个节点初始时可能由几个互不连通的子图(岛屿)组成。
**一个非常重要的图论结论**:要把 $K$ 个互不连通的岛屿连接成一个完整的连通图,最少需要 **$K - 1$** 条边。
现在的核心问题变成了:**如何计算当前图中,有多少个独立连通的“岛屿”(集合)?**
**并查集的妙用**:
1. **初始状态**:$n$ 个城镇没有任何道路,就是 $n$ 个独立的集合,每个城镇自己是老大。
2. **处理已有道路**:每输入一条道路 `(u, v)`,如果它们本来不连通(老大不同),我们就 `unite(u, v)` 将它们合并。
3. **统计孤岛数量**:所有道路处理完后,我们如何知道还剩多少个集合?
* 在并查集中,每个独立的集合**有且仅有一个最终老大**。
* 老大的特征是什么?**`parent[i] == i`**!只有老大的上级是他自己。
* 因此,我们只需遍历 $1$ 到 $n$,统计有多少个满足 `parent[i] == i` 的节点即可,假设数量为 $K$。
4. **得出答案**:最少需要的道路数 = $K - 1$。
**核心代码片段**:
```cpp
int island_count = 0;
for (int i = 1; i <= n; i++) {
// 如果我的上级还是我自己,说明我是某个 孤岛集合 的老大
if (parent[i] == i) {
island_count++;
}
}
cout << island_count - 1 << endl; // 需要的边数 = 孤岛数 - 1
```
---
#### **四、 总结**
* **并查集 (DSU)** 是一种用于处理**不相交集合的合并及查询**问题的数据结构。
* **核心操作**:
* **`init`**:初始化 (`parent[i] = i`)。
* **`find`**:找老大,并配合**路径压缩** (`parent[x] = find(parent[x])`),将长链压扁,保证效率。
* **`unite`**:合并集合,老大认老大 (`parent[rootU] = rootV`)。
* **应用场景**:朋友关系传递、连通性判断、图的连通块数量统计(如村村通问题)、以及未来将要学习的 Kruskal 最小生成树算法。
* **复杂度**:在应用了路径压缩后,并查集的查询和合并操作的时间复杂度几乎可以看作是 **O(1)**(准确说复杂度应该是反阿克曼函数)。