GESP 7级

并查集

7级 · DP/图论/搜索

📖 一句话理解

并查集(Union-Find)是一种处理"集合合并"和"查询是否在同一集合"的数据结构。想象一群人,有的是朋友。并查集能快速回答:"A和B是朋友吗?"以及"A和B做朋友"(合并两个朋友圈)。
💡 为什么重要?
并查集是最优雅的数据结构之一,代码短小但功能强大。它广泛用于:最小生成树(Kruskal算法)、判断图的连通性、社交网络的好友关系、动态连通性问题等。GESP 7级考试中,并查集是必考内容。
📋 前置知识
  • 一维数组:int fa[N] 的使用
  • 递归:函数调用自己(find函数会用到递归)
  • 了解 图的基本概念 会有帮助

🧠 并查集的核心思想

🔑 每个集合是一棵"树"

并查集用数组 fa[] 来维护集合关系:

  • fa[x] = y 表示 "x的父节点是y"
  • fa[x] = x 表示 "x是自己所在集合的根(代表元)"
  • 从任意节点往上找,最终找到的根就是该集合的代表元

如果两个节点的根相同,它们就在同一集合中。

两个核心操作

📐 find和merge
find(x):找x所在集合的根(代表元)
merge(x, y):把x和y所在的集合合并成一个

判断是否同集合:find(x) == find(y)

🔢 逐步推演

初始5个独立集合:{1}, {2}, {3}, {4}, {5}

📊 操作过程

初始:fa[1]=1, fa[2]=2, fa[3]=3, fa[4]=4, fa[5]=5
merge(1,2):1的根是1,2的根是2,fa[1]=2
  集合变为:{1→2}, {3}, {4}, {5}
merge(3,4):fa[3]=4
  集合变为:{1→2}, {3→4}, {5}
merge(2,5):2的根是2,5的根是5,fa[2]=5
  集合变为:{1→2→5}, {3→4}, {}
find(1):1→2→5,根是5
find(5):根是5
find(1) == find(5) → 同一集合 ✓

📐 路径压缩(关键优化)

💡 什么是路径压缩?

如果不优化,find(x)可能要沿着链一路往上找,最坏O(n)。

路径压缩:在find的过程中,把沿途所有节点直接连到根上。这样下次查找就是O(1)。

代码只有一行变化:fa[x] = find(fa[x])

💻 并查集完整代码(逐行注释)
#include <iostream>
using namespace std;

const int N = 105;
int fa[N];  // fa[x] = x的父节点

// 查找x所在集合的根(带路径压缩)
int find(int x) {
    if (fa[x] == x)         // 如果x就是根
        return x;           // 返回根
    return fa[x] = find(fa[x]);  // ⭐ 路径压缩:递归找根,同时把x直接连到根
    // 等价于:fa[x] = find(fa[x]); return fa[x];
}

// 合并x和y所在的集合
void merge(int x, int y) {
    fa[find(x)] = find(y);  // 把x的根连到y的根上
}

int main() {
    int n;  // 节点数
    cin >> n;

    // 初始化:每个节点自成一个集合
    for (int i = 1; i <= n; i++)
        fa[i] = i;          // 每个节点的父节点是自己(自己是根)

    // 示例:合并操作
    merge(1, 2);   // 合并1和2
    merge(3, 4);   // 合并3和4
    merge(2, 4);   // 合并2和4 → 现在1,2,3,4都在同一集合

    // 查询:1和3是否在同一集合?
    if (find(1) == find(3))
        cout << "1和3在同一集合" << endl;
    else
        cout << "1和3不在同一集合" << endl;

    return 0;
}

📊 路径压缩的效果

📝 压缩前 vs 压缩后

压缩前(链状结构):

5 ← 4 ← 3 ← 2 ← 1

find(1) 要找4次才能到根

压缩后(扁平结构):

5 ← 1, 2, 3, 4(全部直接连到5)

find(1) 只要1次就能到根!

加上路径压缩,并查集的每个操作几乎是 O(1)(精确说是反阿克曼函数,实际中常数很小)。

🎯 经典应用

应用1:Kruskal最小生成树

💡 思路
Kruskal算法按边权从小到大排序,依次加入不会形成环的边。
怎么判断"加入这条边是否形成环"? 用并查集!如果两个端点已经在同一集合中,加入这条边就会形成环,跳过。

应用2:朋友圈问题

给定n个人和m对好友关系,问有多少个朋友圈。

💻 朋友圈计数代码
// 输入n个人,m对好友关系,输出朋友圈个数
int main() {
    int n, m;
    cin >> n >> m;

    for (int i = 1; i <= n; i++) fa[i] = i;  // 初始化

    for (int i = 0; i < m; i++) {
        int a, b;
        cin >> a >> b;
        merge(a, b);  // 合并好友
    }

    // 统计有多少个不同的根(即朋友圈个数)
    int cnt = 0;
    for (int i = 1; i <= n; i++) {
        if (fa[i] == i)  // 如果i是根
            cnt++;        // 这是一个独立的朋友圈
    }
    cout << cnt << endl;  // 输出朋友圈个数
}

🚨 易错点

⚠️ 常见错误汇总
  1. 忘记路径压缩:find函数中没有 fa[x] = find(fa[x]),效率会很差。一定要写路径压缩!
  2. merge时找根:fa[find(x)] = find(y),不是 fa[x] = y!必须先找根再合并。
  3. 初始化不能忘:必须先 fa[i] = i 初始化,否则 fa 数组是随机值。
  4. 节点编号范围:看清题目是0-based还是1-based,初始化循环要对应。
  5. 统计连通分量:数 fa[i] == i 的个数,不是数 fa[i] != i 的个数。
  6. 按秩合并:更高级的优化是按集合大小合并(小树连到大树),和路径压缩一起用接近O(1)。考试中只写路径压缩通常就够用。

🎯 练习建议

📝 循序渐进练习路径
  1. 先背模板:并查集的代码非常短,把 find 和 merge 背下来。
  2. 手动模拟:在纸上画出fa数组的变化过程。
  3. 做经典题目:
    • P3367 【模板】并查集(洛谷模板题)
    • P1525 关押罪犯(并查集 + 二分)
    • P1111 修复公路(并查集 + 倒序处理)
  4. 学习Kruskal:并查集 + 贪心 = Kruskal最小生成树算法。