深入理解并查集(DSU)——从理论架构到竞赛实战

深入理解并查集(DSU)——从理论架构到竞赛实战

在图论和高级数据结构中,有这样一个极其优雅的存在:它的核心代码不到 10 行,却能以近乎 \(O(1)\) 的时间复杂度完美解决复杂的“动态连通性”问题。它就是并查集(Disjoint Set Union,简称 DSU 或 Union-Find)

无论是处理社交网络中的连通块,还是作为 Kruskal 最小生成树算法的基石,并查集都是算法竞赛(ICPC/CCPC)和日常刷题(洛谷/PTA)中必拿满分的基础模块。本文将从底层理论出发,手撕 C++ 核心代码,并结合实战场景剖析其应用。

一、 并查集的核心思想

顾名思义,并查集主要支持两种核心操作:

  1. 查(Find):确定某个元素属于哪一个集合,即找到该集合的“代表元素”(根节点)。
  2. 并(Union):将两个不同的集合合并成一个集合。

底层逻辑:

并查集在底层通过“森林”来维护。每个集合是一棵树,树中的每一个节点记录着它的父节点。

  • 如果两个节点的“根节点”相同,说明它们在同一个集合里(互相连通)。
  • 合并时,只需将其中一棵树的根节点,作为子节点挂载到另一棵树的根节点下。

二、 C++ 理论代码与核心优化

在最坏情况下,基础的并查集可能会退化成一条长链表,导致单次查询的时间复杂度退化为 \(O(N)\)。为了达到近乎常数的查询速度,我们必须引入两大核心优化:路径压缩(Path Compression)按秩合并(Union by Rank/Size)

1. 现代 C++ 模板实现(适合工程与复杂图论题)

利用 std::vectorstd::iota,我们可以封装一个极其规范的 DSU 结构体:

C++

#include <iostream>
#include <vector>
#include <numeric>

struct DSU {
    std::vector<int> parent;
    std::vector<int> rank;

    // 初始化:每个元素自成一派,根节点就是自己
    DSU(int n) {
        parent.resize(n);
        rank.assign(n, 1); // 初始秩(深度)为 1
        std::iota(parent.begin(), parent.end(), 0); // 将 parent 填入 0, 1, 2... n-1
    }

    // 查(Find):寻找根节点,并引入【路径压缩】
    int find(int x) {
        if (parent[x] != x) {
            // 回溯时,将沿途的所有节点直接挂在根节点下
            parent[x] = find(parent[x]); 
        }
        return parent[x];
    }

    // 并(Union):引入【按秩合并】,将矮树挂在树下
    bool unite(int x, int y) {
        int rootX = find(x);
        int rootY = find(y);

        if (rootX == rootY) return false; // 已经在同一集合

        // 保证 rootX 是秩较大的那棵树
        if (rank[rootX] < rank[rootY]) {
            std::swap(rootX, rootY);
        }
        
        parent[rootY] = rootX; // 矮树挂在高树下
        if (rank[rootX] == rank[rootY]) {
            rank[rootX]++; // 如果一样高,新根节点深度 + 1
        }
        return true;
    }

    // 判断是否连通
    bool isConnected(int x, int y) {
        return find(x) == find(y);
    }
};

2. 算法竞赛“极简提速版”(墙裂推荐)

在紧张的机试或天梯赛中,手写几十行结构体太慢了。通常为了极致的代码编写速度和运行效率,我们会省略“按秩合并”(因为单靠“路径压缩”已经能应对 99% 的题目),直接用原生数组配合三目运算符写出单行 find 函数:

C++

const int N = 1e5 + 10;
int p[N]; // 祖先数组

// 祖传单行 Find + 路径压缩
int find(int x) {
    return p[x] == x ? x : p[x] = find(p[x]);
}

// 初始化
void init(int n) {
    for (int i = 1; i <= n; i++) p[i] = i;
}

// 合并操作:直接把 x 的根挂到 y 的根上
void unite(int x, int y) {
    p[find(x)] = find(y);
}

数学之美: 加入“路径压缩”后,并查集的均摊时间复杂度为 \(O(\alpha(N))\)。这里的 \(\alpha\) 是阿克曼函数的反函数,在整个宇宙的物理量级下,\(\alpha(N) \le 4\)。因此,它在实际应用中完全等价于常数复杂度 \(O(1)\)

三、 实战分析:并查集能解决什么问题?

实战场景 1:无向图的连通块与判环

给定图的若干条边 (u, v)

  • 求连通块个数:初始化 \(N\) 个集合,每次成功的 unite(u, v) 操作都会让独立集合数减 1,最终剩下的集合数就是连通块个数。
  • 找环:在加入边 (u, v) 之前,如果发现 find(u) == find(v),说明这两个点原本就已经连通,现在再连一条边,必定形成环

实战场景 2:Kruskal 最小生成树

Kruskal 的核心就是并查集,极其适合 C++ 选手使用 std::sort 配合自定义结构体使用:

  1. 将所有边按权值 weight 从小到大排序。
  2. 遍历边集,如果边两端的点不在同一个集合(find(u) != find(v)),就把这条边加入生成树,并执行 unite(u, v)
  3. 否则,跳过这条边(防止成环)。

实战场景 3:种类并查集与扩展域(洛谷 P2024 食物链)

遇到“A 吃 B”、“A 和 B 是天敌”这种非单一连通关系时,一维的并查集就捉襟见肘了。

在 C++ 中,我们通常直接开辟 \(3 \times N\) 大小的数组(p[3*N]):

  • x 表示同类域。

  • x + n 表示捕食域(x 吃谁)。

  • x + 2n 表示天敌域(谁吃 x)。

    通过维护这些偏移量的相对关系,并查集立刻升维,能够解决错综复杂的逻辑推导问题。这是图论中极其惊艳的降维打击思想。

四、 总结

并查集(DSU)是算法竞赛中性价比极高的数据结构。“代码越短,力量越大”在它身上体现得淋漓尽致。

掌握好核心的 return p[x] == x ? x : p[x] = find(p[x]); 这一行神仙代码,你在面对连通性、最小生成树以及复杂集合关系时,就能真正做到游刃有余。

posted @ 2026-06-09 22:21  阿尹想学会C++  阅读(2)  评论(0)    收藏  举报