← 返回博客列表

一、什么是并查集

并查集(Disjoint Set Union,简称 DSU;也叫 Union-Find)是一种用于管理元素分组的数据结构。它主要支持两个操作:

一句话定义
并查集 = 一片森林 + "找根"和"挂接"两步操作。同一棵树上的元素属于同一集合,树根就是集合的代表。

它的典型应用场景是动态连通性问题:在不断"添加连接"的过程中,快速回答"这两个点是否连通"。如果用朴素数组维护,单次查询是 O(n);用并查集配合路径压缩,单次操作接近 O(1)。

1.1 三种常见实现对比

实现方式 find 复杂度 union 复杂度 特点
朴素并查集 O(n) O(n) 最简单,树可能退化成链
按秩合并 O(log n) O(log n) 树高始终被控制在 log n
路径压缩 + 按秩合并 近 O(1)(α(n)) 近 O(1)(α(n)) 推荐写法,反阿克曼函数
⚠ 关于"按秩合并"和"按大小合并"
两者效果相近,但不能和路径压缩同时使用"按秩"严格定义——因为路径压缩会改变树高,rank 不再是真实高度。本文中 rank 应理解为"一个估计值",按 rank 合并仍然是正确的。也可以改用按 size 合并,更直观。

二、核心原理与图解

并查集的本质是用一棵树表示一个集合。每个节点有一个 parent 指针指向父节点,根节点的 parent 指向自己。判断两个元素是否同集合,只需看它们的根是否相同。

2.1 并查集的树形结构

并查集:两棵树 = 两个集合 集合 A(根 = 1) 1 root 3 5 7 9 集合 B(根 = 2) 2 root 4 6 8 find(7) → 1,find(8) → 2,根不同 → 不在同一集合
图 1:并查集用两棵树表示两个不相交集合

2.2 find 与路径压缩

朴素的 find 是一路顺着 parent 往上找根。但若树退化成链,每次 find 都要 O(n)。路径压缩(Path Compression)的核心思想是:在 find 的过程中,把沿途所有节点直接挂到根下面,让树变得"扁平"。

路径压缩:find(7) 之前与之后 压缩前(链状) 1 root 3 5 7 find(7) 走 3 步 → 压缩后 压缩后(扁平化) 1 root 3 5 7 下次 find(7) 只需 1 步 沿途节点 3、5、7 都直接挂到根 1 下
图 2:路径压缩——find(7) 时把沿途所有节点直接挂到根

2.3 union by rank(按秩合并)

合并两棵树时,如果把高的树挂到矮的树下,整体高度会增加;反之则不会。所以总是把矮树挂在高的树根下,这就是按秩合并。

按秩合并:合并 rank=2 的树与 rank=1 的树 rank=2 A a1 a2 a3 正确:矮树挂高树 高度不变,仍为 2 错误:高树挂矮树 高度变为 3,树变深 合并后(rank 仍为 2) A a1 a2 a3 B b1 B 的根直接挂到 A 的根下,整体高度不变
图 3:按秩合并——总是把矮树挂到高树的根下

2.4 复杂度分析

⏱ 时间复杂度:α(n)(反阿克曼函数)
路径压缩 + 按秩合并同时使用时,单次 find/union 的均摊复杂度是 α(n)。α(n) 在 n < 10^80 时都不超过 4,实际可以当成 O(1)。

💾 空间复杂度:O(n)
需要 parent 数组和 rank 数组,各 O(n)。

三、通用模板

下面是带路径压缩 + 按秩合并的完整并查集类,是面试和竞赛的标准写法,可以直接背下来套用:

class UnionFind {
private:
    vector<int> parent;
    vector<int> rank_;   // rank_[i] 表示以 i 为根的树的高度估计
    int count;          // 当前连通分量个数

public:
    // 构造:每个元素自成一集合,parent 指向自己,rank 为 0
    UnionFind(int n) : parent(n), rank_(n, 0), count(n) {
        for (int i = 0; i < n; ++i)
            parent[i] = i;
    }

    // 查找 x 的根,同时做路径压缩(沿途节点直接挂到根)
    int find(int x) {
        if (parent[x] != x)
            parent[x] = find(parent[x]);  // 递归压缩
        return parent[x];
    }

    // 合并 x 和 y 所在集合,返回是否真的合并了(原本就同集合则返回 false)
    bool unite(int x, int y) {
        int rx = find(x), ry = find(y);
        if (rx == ry) return false;  // 已在同一集合

        // 按秩合并:矮树挂到高树下
        if (rank_[rx] < rank_[ry]) swap(rx, ry);
        parent[ry] = rx;
        if (rank_[rx] == rank_[ry]) ++rank_[rx];  // 等高时合并后高度 +1

        --count;  // 连通分量数减 1
        return true;
    }

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

    int getCount() const { return count; }
};
模板三要点
1. find 用递归写法——一行 parent[x] = find(parent[x]) 同时完成查找和压缩,简洁优雅
2. unite 中三步走——先 find 两边根,再比较 rank 决定谁挂谁,最后维护 rank 与 count
3. count 字段——维护连通分量个数,很多题目(如"省份数量""连通块数")直接返回它即可

四、示例一:省份数量(LeetCode 547)

题目:有 n 个城市,其中一些彼此相连通。给定一个 n×n 的矩阵 isConnected,其中 isConnected[i][j] = 1 表示第 i 个城市和第 j 个城市直接相连。省份是一组直接或间接相连的城市。返回省份的数量。

4.1 思路

每个城市初始自成一个集合。遍历矩阵的上三角(避免重复),若 isConnected[i][j] = 1,就 union(i, j)。最终连通分量数就是省份数量。

省份数量:4 个城市,2 个省份 初始:4 个独立城市 0 1 2 3 连接矩阵: 0 1 2 3 0 [1 1 0 0] 1 [1 1 0 0] 2 [0 0 1 1] 3 [0 0 1 1] 合并后:2 个省份 0 1 省份 A 2 3 省份 B count = 2 直接返回 get_count()
图 4:省份数量——合并连通的城市后,连通分量数即答案

4.2 C++ 代码

#include <vector>
using namespace std;

class UnionFind {
    vector<int> parent, rank_;
public:
    UnionFind(int n) : parent(n), rank_(n, 0) {
        for (int i = 0; i < n; ++i) parent[i] = i;
    }
    int find(int x) {
        return parent[x] == x ? x : parent[x] = find(parent[x]);
    }
    bool unite(int x, int y) {
        int rx = find(x), ry = find(y);
        if (rx == ry) return false;
        if (rank_[rx] < rank_[ry]) swap(rx, ry);
        parent[ry] = rx;
        if (rank_[rx] == rank_[ry]) ++rank_[rx];
        return true;
    }
};

int findCircleNum(vector<vector<int>>& isConnected) {
    int n = isConnected.size();
    UnionFind uf(n);
    // 只遍历上三角,避免重复合并
    for (int i = 0; i < n; ++i) {
        for (int j = i + 1; j < n; ++j) {
            if (isConnected[i][j] == 1) uf.unite(i, j);
        }
    }
    // 统计根的个数(自己是自己 parent 的就是根)
    int ans = 0;
    for (int i = 0; i < n; ++i)
        if (uf.find(i) == i) ++ans;
    return ans;
}
💡 也可以维护 count 字段
如果在 UnionFind 类里加一个 count 字段,每次 unite 成功就 --count,最后直接 return uf.getCount(),代码会更简洁。这里为了展示通用思路,用了"统计根个数"的写法。

五、示例二:账户合并(LeetCode 721)

题目:给定一个账户列表 accounts,每个账户包含一个名字和若干邮箱。如果两个账户有共同邮箱,则它们属于同一个人。合并属于同一个人的账户,返回合并后的账户列表(每个账户的邮箱按字典序升序排列)。

5.1 思路:把邮箱当节点

这题的难点在于合并的"实体"不是下标而是字符串邮箱。处理方法是:用哈希表把每个邮箱映射到一个下标,然后用并查集管理这些下标。同一账户内的所有邮箱两两 union(其实只要把第一个邮箱和其余的 union 即可)。

账户合并:用邮箱作为并查集节点 原始账户: John john@mail.com john_new@mail.com John john_new@mail.com john_work@mail.com Mary mary@mail.com → 合并 合并后(2 个人): John john@mail.com john_new@mail.com john_work@mail.com Mary mary@mail.com 关键:john_new 同时出现在两个 John 账户里,所以这两个账户合并成同一人
图 5:账户合并——以邮箱为节点,共享邮箱触发账户合并

5.2 C++ 代码

#include <vector>
#include <string>
#include <unordered_map>
#include <map>
#include <algorithm>
using namespace std;

class UnionFind {
    vector<int> parent, rank_;
public:
    UnionFind(int n) : parent(n), rank_(n, 0) {
        for (int i = 0; i < n; ++i) parent[i] = i;
    }
    int find(int x) {
        return parent[x] == x ? x : parent[x] = find(parent[x]);
    }
    void unite(int x, int y) {
        int rx = find(x), ry = find(y);
        if (rx == ry) return;
        if (rank_[rx] < rank_[ry]) swap(rx, ry);
        parent[ry] = rx;
        if (rank_[rx] == rank_[ry]) ++rank_[rx];
    }
};

vector<vector<string>> accountsMerge(vector<vector<string>>& accounts) {
    unordered_map<string, int> emailToId;  // 邮箱 → 下标
    unordered_map<string, string> emailToName; // 邮箱 → 名字
    UnionFind uf(10000);  // 预留足够下标
    int id = 0;

    // 第一步:给每个邮箱分配 id,并把同一账户内的邮箱两两 union
    for (const auto& acc : accounts) {
        const string& name = acc[0];
        for (int i = 1; i < acc.size(); ++i) {
            const string& email = acc[i];
            if (!emailToId.count(email)) {
                emailToId[email] = id++;
                emailToName[email] = name;
            }
            // 把当前邮箱和账户第一个邮箱 union
            uf.unite(emailToId[acc[1]], emailToId[email]);
        }
    }

    // 第二步:按根邮箱分组收集所有邮箱
    map<string, vector<string>> groups;  // map 自动按 key 排序
    for (const auto& [email, idx] : emailToId) {
        string rootEmail;
        // 找根邮箱:通过 id 反查
        int rootId = uf.find(idx);
        for (const auto& [e, i] : emailToId)
            if (i == rootId) { rootEmail = e; break; }
        groups[rootEmail].push_back(email);
    }

    // 第三步:组装结果
    vector<vector<string>> ans;
    for (auto& [rootEmail, emails] : groups) {
        vector<string> account;
        account.push_back(emailToName[rootEmail]);
        sort(emails.begin(), emails.end());
        for (const auto& e : emails) account.push_back(e);
        ans.push_back(account);
    }
    return ans;
}
💡 优化小技巧
上面的"通过 id 反查根邮箱"做法比较朴素。更高效的做法是直接维护 idToEmail 反向映射,或者用根 id 作为分组 key,避免遍历哈希表。这里为了清晰起见保留显式写法。

六、示例三:冗余连接(LeetCode 684)

题目:一棵树有 n 个节点(标号 1 到 n)和 n-1 条边。现在多加了一条边,使得图中恰好出现一个环。给定这 n 条边的列表,返回可以删去的那条边,使得剩下的图变成一棵树。如果有多个答案,返回输入中最后出现的那条。

6.1 思路:并查集判环

这是一道非常典型的并查集应用题。逐条加入边,每次 union 之前先 find 两端点:

冗余连接:边 [1,2] 形成环(题目要求返回最后出现的冗余边) 1 2 3 4 5 冗余边 [2,3] 处理流程: edges = [[1,2],[2,3],[3,4],[1,5],[2,4]] [1,2] → union(1,2) ✓ [2,3] → union(2,3) ✓ [3,4] → union(3,4) ✓ [1,5] → union(1,5) ✓ [2,4] → find(2)==find(4)! 形成环,返回 [2,4] 逐条加边,第一次出现"两端点已连通"的边就是答案
图 6:冗余连接——并查集判环,遇到环边立即返回

6.2 C++ 代码

#include <vector>
using namespace std;

class UnionFind {
    vector<int> parent, rank_;
public:
    UnionFind(int n) : parent(n), rank_(n, 0) {
        for (int i = 0; i < n; ++i) parent[i] = i;
    }
    int find(int x) {
        return parent[x] == x ? x : parent[x] = find(parent[x]);
    }
    bool unite(int x, int y) {
        int rx = find(x), ry = find(y);
        if (rx == ry) return false;  // 已连通,加这条边会成环
        if (rank_[rx] < rank_[ry]) swap(rx, ry);
        parent[ry] = rx;
        if (rank_[rx] == rank_[ry]) ++rank_[rx];
        return true;
    }
};

vector<int> findRedundantConnection(vector<vector<int>>& edges) {
    int n = edges.size();  // n 条边,n 个节点(标号 1..n)
    UnionFind uf(n + 1);  // 下标 1..n,多开一个

    for (const auto& e : edges) {
        int u = e[0], v = e[1];
        // 如果 u 和 v 已经在同一集合,这条边就是冗余的
        if (!uf.unite(u, v)) {
            return e;  // 题目保证恰好一个环,遇到即返回
        }
    }
    return {};  // 不会走到这里
}
💡 为什么"第一次出现的冗余边"就是答案?
题目要求"返回输入中最后出现的那条冗余边"。由于我们按输入顺序逐条加边,第一次遇到 unite 返回 false 的那条边就是当前顺序下最早导致环的边。但题目保证恰好有一个环,所以这条边必在环上;且因为后续边无法破坏已形成的环,它就是最终答案。

七、总结与适用场景

并查集核心要点

维度 要点
本质 用树形结构表示集合,find 找根,union 挂接
时间复杂度 α(n)(路径压缩 + 按秩合并),实际可视为 O(1)
空间复杂度 O(n)——parent 数组 + rank 数组
识别信号 题目问"连通分量""是否同一集合""合并""判环""动态加边"
关键优化 路径压缩(find 时拍平树)+ 按秩/按大小合并(控制树高)

典型题目清单

🎯 记忆口诀
"父亲找我我找根,找到根后我挂根;合并看秩谁挂谁,等秩加一莫忘了。"
前两句讲路径压缩,后两句讲按秩合并。掌握这两点,并查集就稳了。