CC++ & Algorithm

并查集——朋友圈合并与查询的好帮手

困难4
语言版本:C++
概述:并查集是一种树形结构,用于高效处理不相交集合的合并与查找操作,同时用路径压缩和按秩合并优化到几乎常数时间。

并查集——朋友圈合并与查询的好帮手

想象一下,你在班上有好多小伙伴,大家分成几个“朋友圈”:比如“游戏小队”、“作业互助组”、“篮球爱好者”等等。你经常要问:“小明和小红是不是同一个朋友圈的?”(查询操作)或者“今天‘游戏小队’和‘篮球爱好者’决定合并成‘运动游戏联盟’!”(合并操作)。如果每找一次都要挨个问所有人,那就太慢了。并查集(Disjoint Set Union,DSU)就是专门用来高效处理这种不相交集合(没有交集的几个朋友圈)的合并与查询的数据结构。它用一棵树来表示每个集合,树根就是这个集合的“代表”。通过两种优化——路径压缩按秩合并——几乎每次操作都只需要 O(α(n)) 时间,α(n) 是一个增长极慢的反阿克曼函数,可以看作常数时间。


一、并查集的三个核心操作

并查集主要做三件事:

  1. 初始化:每个人单独成一个集合,自己就是自己的“老大”。
  2. 查找:从一个人出发,沿着“上级”一直往上走,直到找到“老大”(树根)。
  3. 合并:把两个集合的“老大”连接起来,让其中一个“老大”成为另一个的手下。

下面我们用生活中的例子逐步拆解。

1. 初始化:每人自成“光杆司令”

假设班级有 5 个同学,编号 0~4。一开始谁都不认识谁,所以每个同学自己就是一个朋友圈,自己是自己的“组长”(根节点)。我们用数组 parent 来记录每个人的上级(父节点),初始时 parent[i] = i,表示每个人的老大是自己。

我们可以用一个结构体 DSU 来封装所有操作:

struct DSU {
    vector<int> parent;  // parent[i] 表示i的父节点(上级)
    vector<int> rank;    // rank[i] 表示以i为根的树的近似高度(秩)

    // 构造函数:初始化n个元素,每人独立
    DSU(int n) {
        parent.resize(n);          // 分配n个位置
        rank.resize(n, 0);         // 初始秩都为0
        for (int i = 0; i < n; i++) {
            parent[i] = i;         // 每个人都是自己的根
        }
    }
};

生活例子:开学第一天,没人认识别人,每个同学都是自己小圈子的“组长”。

2. 查找(Find):找到“老大”

查找操作 find(x) 返回元素 x 所在集合的根节点(老大)。怎么做?从 x 开始,不断往父节点走,直到走到一个 parent[x] == x 的节点,那就是根。

int find(int x) {
    while (parent[x] != x) {
        x = parent[x];     // 往上找上级
    }
    return x;
}

生活例子:你想知道“小明”的终极老大是谁,就问小明“你的上级是谁?”,小明告诉你“小刚”,你再问小刚“你的上级是谁?”……直到有人回答“我就是老大!”。

路径压缩优化:如果每次查找都一层层往上爬,树可能会变得很高(比如一条链),效率就低。路径压缩的做法是:在查找的同时,把沿途经过的所有节点直接挂到根节点下面,这样下次再找这些节点就一步到位了。

int find(int x) {
    if (parent[x] != x) {                 // 如果x不是根
        parent[x] = find(parent[x]);       // 递归找根,并压缩路径
    }
    return parent[x];                     // 返回根
}

图解:假设树是 3 → 2 → 1 → 0(3的上级是2,2的上级是1,1的上级是0,0是根)。调用 find(3) 时,会一路递归到根0,然后回溯时把2、3的parent都设为0。第二次再找2或3,直接返回0。

注意:路径压缩用递归实现简洁,但如果数据量很大(比如10^7),递归深度可能爆栈。可以改成迭代版本(先找到根,再二次遍历压缩),但递归在竞赛中常用且足够。

3. 合并(Union):把两个朋友圈合并

合并操作 unite(x, y)x 所在集合和 y 所在集合合并成一个。方法是:先找到两个集合的根 rxry,如果相同就不需要合并;否则让一个根成为另一个的父节点。

最简单的合并:

void unite(int x, int y) {
    int rx = find(x);
    int ry = find(y);
    if (rx == ry) return;       // 已经在同一个集合
    parent[rx] = ry;            // 把rx接到ry下面(或者反过来)
}

生活例子:两个小圈子想合并,就让一个圈子的老大认另一个圈子的老大当上级。

按秩合并优化:如果总是把大的树接到小的树下,树会越来越高,查找变慢。按秩合并的思路是:维护一个“秩”(rank),通常表示树的高度(或大小)。合并时,把秩小的根接到秩大的根下面,如果秩相等,接完后秩加1。

void unite(int x, int y) {
    int rx = find(x);
    int ry = find(y);
    if (rx == ry) return;
    if (rank[rx] < rank[ry]) {
        parent[rx] = ry;            // rx的秩小,把rx接到ry上
    } else if (rank[rx] > rank[ry]) {
        parent[ry] = rx;            // ry的秩小,把ry接到rx上
    } else {
        parent[ry] = rx;            // 秩相等,接到任一个,另一个秩+1
        rank[rx]++;                 // 新根的秩增加
    }
}

为什么这样优化:路径压缩会改变树的高度,所以 rank 其实不是精确高度,只是上界。但按秩合并与路径压缩一起使用时,能保证树的高度始终是 O(log n),实际效果非常好。


二、常见错误(新手容易踩的坑)

  1. 忘记初始化 parent[i] = i
    如果初始化 parent 全为0,那么 find(0) 会无限循环(因为 parent[0]=0,但其他节点指向0算正常?注意:当所有parent都是0时,0的根是0,但1的根会去找0,而0的根是0,所以1的根是0,表面上没问题。但如果你把初始化混淆了,可能让本来独立的元素指向同一个根,导致错误的合并。一定要每个元素初始化自己为根

  2. 递归 find 深度过大导致栈溢出
    当数据规模很大(比如n=10^6)且树退化为链(即使有路径压缩,第一次查找前可能链很长),递归深度可能超过编译器默认栈空间(大约1MB)。解决方案:

    • 改用迭代版本 find:
      int find(int x) {
          int root = x;
          while (parent[root] != root) root = parent[root]; // 找到根
          while (x != root) {                               // 路径压缩
              int next = parent[x];
              parent[x] = root;
              x = next;
          }
          return root;
      }
      
    • 增加编译器栈空间(不推荐,不通用)。
  3. 在合并前忘记 find 而直接用 xyparent
    很多人会写成 if (parent[x] != parent[y]) 来判断是否在同一集合。但 parent[x] 不一定是根(可能只是上级),这样判断会出错。一定要先查找根再比较。

  4. 按秩合并时秩更新错误
    当两棵树秩相等时,合并后新树的秩要加1。如果忘记加,会导致秩信息不准,后续合并可能效率下降。下面是一个错误写法:

    if (rank[rx] == rank[ry]) {
        parent[ry] = rx;
        // 漏掉了 rank[rx]++;
    }
    

    这样合并后,rx 的秩还是原来的值,但实际树高度变大了,下次按秩合并就可能错误地把它当作矮树接给别人。

  5. 修改 parent 时没有先找到根
    不要直接 parent[x] = y,因为这样只是把 x 的上级改成 y,而不是合并整个集合。正确做法是先求根再连根。

  6. 路径压缩和按秩合并的先后顺序
    两者可以同时使用,没有冲突。但注意:按秩合并依赖的 rank 在路径压缩后可能不再是真实高度,但这不影响正确性——它只是近似值,用来防止过度增长。


三、完整可运行的代码示例(朋友圈分组问题)

我们设计一个场景:班级有 n 个学生(编号 0 到 n-1)。老师知道 m 对朋友关系,朋友关系是双向的,朋友的朋友也是朋友(即传递性)。问最后形成了几个朋友圈(连通块)?

下面的代码用并查集实现,包含路径压缩和按秩合并,并输出每个学生所属的朋友圈根。

#include <iostream>
#include <vector>
using namespace std;

struct DSU {
    vector<int> parent;  // 父节点数组
    vector<int> rank;    // 秩数组(近似高度)

    // 初始化:n个元素,每人独立
    DSU(int n) {
        parent.resize(n);
        rank.resize(n, 0);
        for (int i = 0; i < n; i++) {
            parent[i] = i;  // 自己就是根
        }
    }

    // 查找根,带路径压缩(递归版)
    int find(int x) {
        if (parent[x] != x) {
            parent[x] = find(parent[x]);  // 路径压缩
        }
        return parent[x];
    }

    // 合并两个集合,按秩合并
    void unite(int x, int y) {
        int rx = find(x);   // 找x的根
        int ry = find(y);   // 找y的根
        if (rx == ry) {
            return;         // 已经在同一集合,无需合并
        }
        // 确保rx是秩较小的根,方便统一操作
        if (rank[rx] < rank[ry]) {
            parent[rx] = ry;       // 小树接在大树下
        } else if (rank[rx] > rank[ry]) {
            parent[ry] = rx;
        } else {
            parent[ry] = rx;       // 秩相等,任意接一个,秩+1
            rank[rx]++;
        }
    }

    // 判断两个元素是否在同一集合
    bool same(int x, int y) {
        return find(x) == find(y);
    }
};

int main() {
    int n = 7; // 0~6号同学
    DSU dsu(n);

    // 添加朋友关系(合并操作)
    dsu.unite(0, 1); // 0和1是朋友
    dsu.unite(1, 2); // 1和2是朋友 → 0,1,2成为一组
    dsu.unite(3, 4); // 3和4是朋友
    dsu.unite(5, 6); // 5和6是朋友
    dsu.unite(4, 5); // 4和5是朋友 → 3,4,5,6成为一组

    // 查询
    cout << "0和2是否在同一组?" << (dsu.same(0, 2) ? "是" : "否") << endl; // 是
    cout << "0和3是否在同一组?" << (dsu.same(0, 3) ? "是" : "否") << endl; // 否
    cout << "2和6是否在同一组?" << (dsu.same(2, 6) ? "是" : "否") << endl; // 否

    // 计算一共有几个朋友圈(即根节点个数)
    int groups = 0;
    for (int i = 0; i < n; i++) {
        if (dsu.find(i) == i) {   // 如果自己是根,就是一个集合的代表
            groups++;
        }
    }
    cout << "总共有 " << groups << " 个朋友圈。" << endl; // 应该输出2:{0,1,2} 和 {3,4,5,6}

    // 输出每个同学所属的根(代表)
    cout << "每个同学的根节点:";
    for (int i = 0; i < n; i++) {
        cout << dsu.find(i) << " ";
    }
    cout << endl; // 例如:0 0 0 3 3 3 3 (树根可能因合并顺序不同,但同一组根相同)

    return 0;
}

运行结果:

0和2是否在同一组?是
0和3是否在同一组?否
2和6是否在同一组?否
总共有 2 个朋友圈。
每个同学的根节点:0 0 0 3 3 3 3

四、并查集的更多应用与相关指引

并查集虽然简单,但它在很多算法问题中都是基础工具:

  • 克鲁斯卡尔(Kruskal)最小生成树:按边权从小到大排序,用并查集判断是否形成环。如果边的两个端点不在同一集合,就加入这条边并合并集合。
  • 判断无向图中的连通分量个数:把所有边合并后,数一数有多少个根。
  • 离线查询:比如题目给出若干“连边”操作和“询问两点是否连通”操作,用并查集可以高效回答。
  • 带权并查集:在路径压缩时维护节点到根的距离或其他信息,用于解决“奇偶性”或“相对关系”问题(例如“敌人与朋友”关系)。
  • 可撤销并查集:支持时光倒流(撤销上一次合并),常用于离线算法或回滚操作。

进阶学习建议:当你熟悉并查集基础后,可以尝试以下题目(按难度递增):

  1. 洛谷 P3367 【模板】并查集
  2. 洛谷 P1551 亲戚
  3. 洛谷 P1525 [NOIP2010 提高组] 关押罪犯(带权并查集)
  4. 力扣 547. 省份数量(朋友圈问题)
  5. 力扣 990. 等式方程的可满足性(并查集维护相等关系)

并查集就像一个高效的社会关系管理局,随时可以回答“这两人是一伙的吗?”并且能在很快的时间内把两伙人合并。掌握它,你就拥有了一把处理“分组”问题的利器!

例题精讲

1单选题

在并查集中同时使用路径压缩和按秩合并优化后,单次查找操作(Find)的时间复杂度最接近于以下哪个?

AO(log n)
BO(α(n))
CO(n)
DO(1)
2判断题

并查集只能处理不相交集合的合并与查询,无法用于判断无向图是否存在环。

3填空题
以下是用路径压缩优化实现的并查集查找操作,请补充空缺位置的代码。

int find(int x) {
    if (parent[x] == x) return x;
    return parent[x] = ___;
}
4单选题

在并查集的按秩合并优化中,“秩”通常被定义为以下哪一项?

A集合中元素的总个数
B树的高度(上界)
C树的节点度数之和
D树的路径长度
5填空题
下面是按秩合并的并查集合并操作,请补充空缺的代码。

void merge(int x, int y) {
    int rx = find(x), ry = find(y);
    if (rx == ry) return;
    if (rak[rx] < rak[ry]) {
        parent[rx] = ry;
    } else if (rak[rx] > rak[ry]) {
        parent[ry] = rx;
    } else {
        parent[ry] = rx;
        ___;
    }
}