ACM 学习篇 07:并查集

什么是并查集

并查集,英文叫 Union-Find,也叫不相交集合。它专门处理"把一些元素合并成集合"和"查询两个元素是否在同一个集合"这两个操作,而且都很快。

想象一下,你有一堆人,需要不断告诉你:谁和谁是亲戚,谁和谁不是亲戚。每告诉一对关系,你可能要把两个家族合并起来。然后你还要经常问:某两个人是不是亲戚?

用数组和链表做这件事,每次合并要遍历一个集合的所有元素,效率很低。并查集就是来解决这个问题的。

并查集的核心思想

并查集用一种很巧妙的方式表示集合:每个元素指向一个"代表",所有属于同一个集合的元素,最终都指向同一个代表。

打个比方:若干家庭各自选出一个族长,所有家庭成员都认族长为头。并查集就是维护这个"谁认谁当头"的关系。

具体实现上,每个元素有一个父节点。如果一个元素的父节点是它自己,那它就是代表。查找两个元素是否在同一个集合,就是看它们的代表是不是同一个人。

基本操作与代码实现

初始化

一开始,每个元素都是独立的,自己是自己的代表:

vector parent(n + 1);
vector rank(n + 1);  // 秩,用于按秩合并优化

void init(int n) {
    for (int i = 1; i <= n; i++) {
        parent[i] = i;  // 每个元素的父节点是自己
        rank[i] = 0;    // 初始秩为0
    }
}

查找 find

查找操作就是找出一个元素所在集合的代表。朴素写法:

int find(int x) {
    if (parent[x] == x) return x;
    return find(parent[x]);
}

但这样树可能很深,查找效率会退化。于是引入路径压缩:

int find(int x) {
    if (parent[x] != x) {
        parent[x] = find(parent[x]);  // 路径压缩:直接指向根节点
    }
    return parent[x];
}

路径压缩的意思是:沿途所有节点都直接认根节点当代表,就像大家都记住了族长是谁,不用每次都顺着链条往上找了。

合并 union

合并两个集合,就是把一个集合的代表接到另一个代表的下面:

void union(int x, int y) {
    int rx = find(x);
    int ry = find(y);
    if (rx == ry) return;  // 已经在同一个集合,不需要合并
    // 按秩合并优化:秩大的接到秩小的下面
    if (rank[rx] < rank[ry]) {
        parent[rx] = ry;
    } else if (rank[rx] > rank[ry]) {
        parent[ry] = rx;
    } else {
        parent[ry] = rx;
        rank[rx]++;
    }
}

按秩合并的意思是:矮树接到高树下面,这样树不容易变深。两个秩相同的树合并,秩才加一。

判断连通

判断两个元素是否在同一个集合:

bool connected(int x, int y) {
    return find(x) == find(y);
}

典型题:亲戚问题

并查集的经典入门题:给出若干对"是亲戚"的关系,问某两个人是否是亲戚。

int n, m, p;  // n个人,m对亲戚关系,p个询问
cin >> n >> m >> p;

init(n);
for (int i = 0; i < m; i++) {
    int x, y;
    cin >> x >> y;
    union(x, y);
}

for (int i = 0; i < p; i++) {
    int x, y;
    cin >> x >> y;
    if (connected(x, y)) {
        cout << "Yes\n";
    } else {
        cout << "No\n";
    }
}

思路很简单:每对亲戚关系就是一次合并操作,然后每个询问只需要一次查找比较。

典型题:连通分量个数

给一个无向图,问它有几个连通分量。用并查集非常方便:

int countComponents(int n, vector>& edges) {
    init(n);
    for (auto& e : edges) {
        union(e[0], e[1]);
    }
    int cnt = 0;
    for (int i = 0; i < n; i++) {
        if (parent[i] == i) cnt++;
    }
    return cnt;
}

思路:合并所有边,最后数一数有多少个代表,就有多少个连通分量。

并查集还能做什么

  • 合并集合:动态合并多个集合,查询元素归属;
  • 连通分量:无向图连通块个数、岛屿数量;
  • 最小生成树:Kruskal 算法用并查集判断是否成环;
  • 亲戚关系:传递闭包问题;
  • 等式约束:判断一组等式是否矛盾;
  • 分组问题:把有关系的元素归到同一组。

并查集的易错点

  • 忘了路径压缩:不压缩会退化成链表,查询变成 O(n);
  • 忘了按秩合并:不按秩合并,树可能变得又深又歪;
  • 下标从0还是从1开始:看清楚题目输入;
  • 合并时没判断是否 already connected:不加判断会导致重复操作和错误;
  • 用 long long 的情况:如果元素编号很大,用 long long 存 parent。

什么时候用并查集

看到这些问题描述,就可以考虑并查集:

  • 若干元素不断合并,问两个元素是否在同一个集合;
  • 无向图的连通分量个数;
  • 需要判断是否会成环;
  • 传递性的关系:a 和 b 是亲戚,b 和 c 是亲戚,问 a 和 c 是不是亲戚。

这一篇先记住什么

  • 并查集处理集合合并与查询,平均近乎 O(1);
  • find 用路径压缩,union 用按秩合并,这是标准优化;
  • connected 判断两个元素是否在同一个集合;
  • 典型应用:亲戚关系、连通分量、Kruskal;
  • 看到"连通""合并""分组""传递闭包"就想到并查集。

下一篇继续数据结构:堆与优先队列。它是处理最值问题的利器,能高效维护动态集合的最大值或最小值。