并查集(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「修复公路」问题中,我们关注的并不是:

  • 具体怎么走
  • 经过哪几条路
    而是一个非常核心的问题:

在时间不断推进的过程中,所有村庄是否已经“连成一个整体”?


二、整体设计思路

  1. Info类
    • x, y:连接的两个村庄
    • t:修复完成时间
  2. 递增排序
    infoList.Sort((a, b) => a.t - b.t);
    
  3. 父节点数组
static int[] parent;

含义:

  • parent[i] 表示 节点 i 的父节点
  • 如果 parent[i] == i,说明它是当前集合的根
  1. 连通分量数量
static int setCount;
  • 初始时:setCount = N
  • 每成功合并两个不同集合:setCount--
  • setCount == 1
    • 表示 所有村庄已经连通
  1. Find 操作:查找根节点(带路径压缩)
static int FindRoot(int x)
{
    if (x != parent[x])
    {
        parent[x] = FindRoot(parent[x]);
    }
    return parent[x];
}

这一段代码做了两件事:

  1. 找到集合的最终根节点
  2. 路径压缩
    • 把沿途所有节点直接挂到根上
    • 极大提高后续查询效率

这是并查集“近似 O(1)”性能的核心原因。

  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;
}

语义拆解

  1. 判断是否已经在同一集合
    FindRoot(a.x) != FindRoot(a.y)
    
  2. 只合并根节点
    parent[根x] = 根y
    
  3. 连通分量减少
    setCount--
    
  4. 首次全连通时返回 true

写成 parent[x] = y 会破坏原有集合结构,导致节点“断链”
并查集的合并,永远只能发生在“根节点”之间。

  1. 为什么一定要按时间排序?
  • 公路是“按时间逐步修好”的
  • 我们模拟的是 时间从小到大 的过程
  • 第一次全连通时的 t,一定是最小答案

三、时间与空间复杂度分析

设:

  • N:村庄数量
  • M:公路数量

时间复杂度

  • 排序:O(M log M)
  • 并查集操作:O(M α(N))(近似 O(M))
    总时间复杂度:O(M log M)

空间复杂度

  • parent[]O(N)
  • Info 列表O(M)

四、并查集适用场景总结

特征 是否满足
动态加边
只关心是否连通
不关心具体路径
需要最早时间

posted @ 2025-12-18 17:02  高山仰止666  阅读(6)  评论(0)    收藏  举报