并查集(Union-Find)技术文档
—— 以「P1111 修复公路」为例的代码驱动式总结
//## 题目背景
//A 地区在地震过后,连接所有村庄的公路都造成了损坏而无法通车。政府派人修复这些公路。
//## 题目描述
//给出 A 地区的村庄数 N,和公路数 M,公路是双向的。并告诉你每条公路的连着哪两个村庄,并告诉你什么时候能修完这条公路。问最早什么时候任意两个村庄能够通车,即最早什么时候任意两条村庄都存在至少一条修复完成的道路(可以由多条公路连成一条道路)。
//## 输入格式
//第 1 行两个正整数 N, M。
//下面 M 行,每行 3 个正整数 x, y, t,告诉你这条公路连着 x, y 两个村庄,在时间 t 时能修复完成这条公路。
//## 输出格式
//如果全部公路修复完毕仍然存在两个村庄无法通车,则输出 -1,否则输出最早什么时候任意两个村庄能够通车。
//## 输入输出样例
//### 输入
//```
//4 4
//1 2 6
//1 3 4
//1 4 5
//4 2 3
//```
//### 输出
//```
//5
//```
namespace 并查集之修复公路
{
class Info
{
public int x;
public int y;
public int t;
public Info(int x, int y, int t)
{
this.x = x;
this.y = y;
this.t = t;
}
}
class Program
{
// 并查集的父节点数组
// parent[i] 表示节点 i 的父节点
static int[] parent;
//当前并查集中“连通分量”的数量
//初始为N,每合并成功一次就减1
static int setCount;
//村子数量N 公路数量M
static int N, M;
static List<Info> infoList = new List<Info>();
static void Main(string[] args)
{
string[] strs = Console.ReadLine().Split();
N = int.Parse(strs[0]);
M = int.Parse(strs[1]);
for (int i = 0; i < M; i++)
{
strs = Console.ReadLine().Split();
infoList.Add(new Info(int.Parse(strs[0]), int.Parse(strs[1]), int.Parse(strs[2])));
}
infoList.Sort((a, b) =>
{
return a.t - b.t;
});
//初始化并查集
//每个村庄一开始都是一个独立的集合
parent = new int[N + 1];
for (int i = 0; i <= N; i++)
{
parent[i] = i;
}
//初始连通分量为N
setCount = N;
for (int i = 0; i < infoList.Count; i++)
{
//当全部连通时
if (Union(infoList[i]))
{
//该刚好连通的所消耗的时间就是总花费时间
Console.WriteLine(infoList[i].t);
}
}
if (setCount != 1)
{
//连通分量不为1 表示即使所有路修完了 也不会全连通
Console.WriteLine(-1);
}
}
static int FindRoot(int x)
{
if (x != parent[x])
{
//直接把最终找到的根节点赋给途经的每一个节点
parent[x] = FindRoot(parent[x]);
}
//返回最后的根节点
return parent[x];
}
static bool Union(Info a)
{
//如果根节点相同 表示没连通
if (FindRoot(a.x) != FindRoot(a.y))
{
//让节点a.x的根节点=a.y的根节点
//不要让节点a.x的父节点直接等于a.y或a.y的根节点 这样会让a.x与原来的根节点断开 这是不想发生的
parent[FindRoot(a.x)] = FindRoot(a.y);
//连通分量-1
setCount--;
//当连通分量为1时 表示全部连通 返回true
if (setCount == 1)
{
return true;
}
}
//连通分量不为1 没有全部连通 返回false
return false;
}
}
}
一、问题回顾:修复公路的本质
在 P1111「修复公路」问题中,我们关注的并不是:
- 具体怎么走
- 经过哪几条路
而是一个非常核心的问题:
在时间不断推进的过程中,所有村庄是否已经“连成一个整体”?
二、整体设计思路
- Info类
x, y:连接的两个村庄t:修复完成时间
- 递增排序
infoList.Sort((a, b) => a.t - b.t); - 父节点数组
static int[] parent;
含义:
parent[i]表示 节点 i 的父节点- 如果
parent[i] == i,说明它是当前集合的根
- 连通分量数量
static int setCount;
- 初始时:
setCount = N - 每成功合并两个不同集合:
setCount-- - 当
setCount == 1:- 表示 所有村庄已经连通
- Find 操作:查找根节点(带路径压缩)
static int FindRoot(int x)
{
if (x != parent[x])
{
parent[x] = FindRoot(parent[x]);
}
return parent[x];
}
这一段代码做了两件事:
- 找到集合的最终根节点
- 路径压缩
- 把沿途所有节点直接挂到根上
- 极大提高后续查询效率
这是并查集“近似 O(1)”性能的核心原因。
- Union 操作:合并两个集合
static bool Union(Info a)
{
if (FindRoot(a.x) != FindRoot(a.y))
{
parent[FindRoot(a.x)] = FindRoot(a.y);
setCount--;
if (setCount == 1)
{
return true;
}
}
return false;
}
语义拆解
- 判断是否已经在同一集合
FindRoot(a.x) != FindRoot(a.y) - 只合并根节点
parent[根x] = 根y - 连通分量减少
setCount-- - 首次全连通时返回 true
写成
parent[x] = y会破坏原有集合结构,导致节点“断链”
并查集的合并,永远只能发生在“根节点”之间。
- 为什么一定要按时间排序?
- 公路是“按时间逐步修好”的
- 我们模拟的是 时间从小到大 的过程
- 第一次全连通时的
t,一定是最小答案
三、时间与空间复杂度分析
设:
N:村庄数量M:公路数量
时间复杂度
- 排序:
O(M log M) - 并查集操作:
O(M α(N))(近似 O(M))
总时间复杂度:O(M log M)
空间复杂度
parent[]:O(N)Info 列表:O(M)
四、并查集适用场景总结
| 特征 | 是否满足 |
|---|---|
| 动态加边 | ✅ |
| 只关心是否连通 | ✅ |
| 不关心具体路径 | ✅ |
| 需要最早时间 | ✅ |

浙公网安备 33010602011771号