算法入门:并查集(Disjoint Set / Union-Find):连通性问题的利器

一、情景引入:刘备的人马管理问题

东汉末年,刘备起兵后,陆续有各路人马前来投奔。这些人马有的是单枪匹马,有的是成群结队,但每队人马都有一个头领。

初始状态

  • 关羽带着 5 个人来投奔
  • 张飞带着 8 个人来投奔
  • 赵云单人前来
  • 黄忠带着 3 个人来投奔
text 复制代码
关羽队:关羽 → 周仓、关平、廖化、王甫、赵累
张飞队:张飞 → 张苞、关兴、吴班、马岱、魏延、陈式、马忠、鄂焕
赵云队:赵云(单人)
黄忠队:黄忠 → 严颜、陈到、刘封

为了便于管理,刘备只记录每队的头领名字,每个头领自己管理手下。

1.1 问题一:如何判断两个士兵是否一队?

有一天,周仓和关平打架了,刘备想知道他们是不是一队的:

  • 问周仓:你的头儿是谁?→ 关羽
  • 问关平:你的头儿是谁?→ 关羽
  • 结论:都是关羽的人,是一队的,关起来一起教育!

但如果周仓和魏延打架:

  • 问周仓:你的头儿是谁?→ 关羽
  • 问魏延:你的头儿是谁?→ 张飞
  • 结论:不是一队的,各自队长负责处理

这个 "找头儿" 的过程,就是并查集的 Find 操作。

1.2 问题二:如何合并两队人马?

后来刘备有空了,决定整编队伍。他想把关羽队和张飞队合并成一个大队。

合并规则

  • 两队合并后只能有一个队长
  • 刘备选关羽当新队长(因为关羽资历更深)
  • 原张飞队的所有人(包括张飞)都改为认关羽为头儿
text 复制代码
合并前:
关羽队:关羽 → 周仓、关平、廖化、王甫、赵累
张飞队:张飞 → 张苞、关兴、吴班、马岱、魏延、陈式、马忠、鄂焕

合并后:
关羽大队:关羽 → 周仓、关平、廖化、王甫、赵累、张飞、张苞、关兴、...

这个 "整编队伍" 的过程,就是并查集的 Union 操作。

1.3 数据结构的本质

仔细观察这个过程:

  • 每个士兵 指向 自己的头儿
  • 头儿 指向 自己(或更大的头儿)
  • 最终形成一棵 :头儿是根节点,士兵是子节点
text 复制代码
          关羽(根)
         /  |  \  \
    周仓 关平 廖化 张飞(原队长,现在也是队员)
                  /  |  \
              张苞 关兴 魏延

这就是并查集的核心思想!


二、并查集的定义与操作

2.1 什么是并查集?

并查集(Disjoint Set / Union-Find) 是一种用于处理 不相交集合 的数据结构。

名词解释

  • Disjoint(不相交):所有集合之间没有公共元素
  • Union(合并):将两个集合合并为一个
  • Find(查找):找到某个元素所属集合的代表元素

2.2 核心操作

操作 功能 示例
MakeSet(x) 初始化,将 x 作为单独的集合 每个士兵初始是独立的队伍
Find(x) 查找 x 所属集合的代表元素 找到 x 的头儿是谁
Union(x, y) 合并 x 和 y 所在的两个集合 把两队人马合并

2.3 主要应用场景

  1. 判断图的连通性:两个节点是否连通?
  2. 检测环:在无向图中加边时,是否会形成环?
  3. 最小生成树:Kruskal 算法的核心数据结构
  4. 社交网络:判断两个人是否在同一个朋友圈
  5. 等价类划分:将具有传递关系的元素分组

三、实现方式一:链表实现

3.1 数据结构设计

每个集合用一个 链表 表示:

  • 头节点(代表元素):作为集合的标志
  • 链表节点:存储集合中的其他元素
  • 尾节点指针:方便快速合并
text 复制代码
集合 A:[关羽] → [周仓] → [关平] → [廖化] → NULL
         ↑                              ↑
        head                          tail

集合 B:[张飞] → [魏延] → [张苞] → NULL
         ↑                      ↑
        head                  tail

3.2 基本操作实现

cpp 复制代码
#include <iostream>
#include <unordered_map>
#include <string>
using namespace std;

// 链表节点
struct Node {
    string name;          // 元素名称
    Node* next;           // 指向下一个元素
    Node* representative; // 指向代表元素(集合的头)
    
    Node(string n) : name(n), next(nullptr), representative(this) {}
};

// 集合信息
struct SetInfo {
    Node* head;  // 链表头(代表元素)
    Node* tail;  // 链表尾(方便合并)
    int size;    // 集合大小
    
    SetInfo(Node* node) : head(node), tail(node), size(1) {}
};

class DisjointSetList {
private:
    unordered_map<string, Node*> elements;      // 元素名 → 节点
    unordered_map<Node*, SetInfo*> setInfoMap;  // 代表元素 → 集合信息
    
public:
    // 初始化:创建单元素集合
    void MakeSet(string name) {
        if (elements.find(name) != elements.end()) {
            return;  // 已存在
        }
        
        Node* node = new Node(name);
        elements[name] = node;
        setInfoMap[node] = new SetInfo(node);
        
        cout << "创建独立队伍:" << name << endl;
    }
    
    // 查找:返回代表元素
    string Find(string name) {
        if (elements.find(name) == elements.end()) {
            return "";  // 元素不存在
        }
        
        Node* node = elements[name];
        return node->representative->name;
    }
    
    // 合并:加权合并启发式(小集合并入大集合)
    void Union(string name1, string name2) {
        string rep1 = Find(name1);
        string rep2 = Find(name2);
        
        if (rep1.empty() || rep2.empty()) {
            cout << "元素不存在!" << endl;
            return;
        }
        
        if (rep1 == rep2) {
            cout << name1 << " 和 " << name2 << " 已经在同一队" << endl;
            return;
        }
        
        Node* head1 = elements[rep1];
        Node* head2 = elements[rep2];
        SetInfo* set1 = setInfoMap[head1];
        SetInfo* set2 = setInfoMap[head2];
        
        // 加权合并:小集合并入大集合
        if (set1->size < set2->size) {
            swap(head1, head2);
            swap(set1, set2);
            swap(rep1, rep2);
        }
        
        cout << "合并队伍:" << rep2 << " 队并入 " << rep1 << " 队" << endl;
        
        // 将 set2 的所有节点接到 set1 尾部
        set1->tail->next = head2;
        set1->tail = set2->tail;
        
        // 更新 set2 所有节点的代表元素
        Node* current = head2;
        while (current != nullptr) {
            current->representative = head1;
            set1->size++;
            current = current->next;
        }
        
        // 删除 set2 的信息
        setInfoMap.erase(head2);
        delete set2;
    }
    
    // 打印集合
    void PrintSet(string name) {
        string rep = Find(name);
        if (rep.empty()) {
            cout << name << " 不存在" << endl;
            return;
        }
        
        Node* head = elements[rep];
        cout << rep << " 队:";
        Node* current = head;
        while (current != nullptr) {
            cout << current->name;
            if (current->next != nullptr) cout << " → ";
            current = current->next;
        }
        cout << endl;
    }
};

3.3 测试代码

cpp 复制代码
int main() {
    DisjointSetList dsl;
    
    // 初始化各路人马
    cout << "=== 初始化 ===" << endl;
    dsl.MakeSet("关羽");
    dsl.MakeSet("周仓");
    dsl.MakeSet("关平");
    dsl.MakeSet("张飞");
    dsl.MakeSet("魏延");
    dsl.MakeSet("赵云");
    
    // 组建初始队伍
    cout << "\n=== 组建队伍 ===" << endl;
    dsl.Union("关羽", "周仓");
    dsl.Union("关羽", "关平");
    dsl.Union("张飞", "魏延");
    
    // 打印队伍
    cout << "\n=== 当前队伍 ===" << endl;
    dsl.PrintSet("关羽");
    dsl.PrintSet("张飞");
    dsl.PrintSet("赵云");
    
    // 查询
    cout << "\n=== 查询头领 ===" << endl;
    cout << "周仓的头领:" << dsl.Find("周仓") << endl;
    cout << "魏延的头领:" << dsl.Find("魏延") << endl;
    
    // 大合并
    cout << "\n=== 大整编 ===" << endl;
    dsl.Union("关羽", "张飞");
    dsl.PrintSet("关羽");
    
    // 赵云加入
    cout << "\n=== 赵云加入 ===" << endl;
    dsl.Union("赵云", "关羽");
    dsl.PrintSet("关羽");
    
    return 0;
}

3.4 运行结果

text 复制代码
=== 初始化 ===
创建独立队伍:关羽
创建独立队伍:周仓
创建独立队伍:关平
创建独立队伍:张飞
创建独立队伍:魏延
创建独立队伍:赵云

=== 组建队伍 ===
合并队伍:周仓 队并入 关羽 队
合并队伍:关平 队并入 关羽 队
合并队伍:魏延 队并入 张飞 队

=== 当前队伍 ===
关羽 队:关羽 → 周仓 → 关平
张飞 队:张飞 → 魏延
赵云 队:赵云

=== 查询头领 ===
周仓的头领:关羽
魏延的头领:张飞

=== 大整编 ===
合并队伍:张飞 队并入 关羽 队
关羽 队:关羽 → 周仓 → 关平 → 张飞 → 魏延

=== 赵云加入 ===
合并队伍:赵云 队并入 关羽 队
关羽 队:关羽 → 周仓 → 关平 → 张飞 → 魏延 → 赵云

3.5 链表实现的优化:加权合并

朴素合并的问题

如果总是让第二个集合并入第一个集合,可能导致:

  • 大集合并入小集合 → 需要更新很多节点的 representative 指针

加权合并启发式(Weighted Union)

  • 始终让小集合并入大集合
  • 这样需要更新的节点数更少

时间复杂度分析

操作 朴素实现 加权合并
MakeSet O(1) O(1)
Find O(1) O(1)
Union O(n) O(min(n₁, n₂))
m 次操作总时间 O(mn) O(m + n log n)

证明 :使用加权合并时,一个元素被更新 representative 指针的次数最多为 O(log n),因为每次合并后所在集合的大小至少翻倍。


四、实现方式二:树形结构(重点)

4.1 从链表到树的优化

链表实现虽然直观,但有两个问题:

  1. Find 虽然是 O(1),但需要额外空间存储 representative 指针
  2. Union 需要遍历整个链表更新指针

树形结构的优势

  • 每个节点只需存储 父节点指针
  • Find 操作:沿着父指针一路向上找根
  • Union 操作:直接修改一个根的父指针
text 复制代码
链表表示:
[关羽] → [周仓] → [关平]
  ↑        ↑        ↑
每个节点都要记住关羽是代表

树形表示:
      关羽
     /    \
  周仓    关平
只需记住父节点即可

4.2 数据结构设计

cpp 复制代码
class DisjointSetTree {
private:
    unordered_map<string, string> parent;  // 元素 → 父节点
    unordered_map<string, int> rank;       // 元素 → 秩(树的高度估计)
    
public:
    // 初始化
    void MakeSet(string name) {
        if (parent.find(name) != parent.end()) {
            return;
        }
        parent[name] = name;  // 初始时自己是自己的父节点
        rank[name] = 0;       // 初始秩为 0
    }
    
    // 查找(带路径压缩)
    string Find(string name) {
        if (parent.find(name) == parent.end()) {
            return "";
        }
        
        // 路径压缩:递归查找根节点,并将路径上所有节点直接连到根
        if (parent[name] != name) {
            parent[name] = Find(parent[name]);
        }
        return parent[name];
    }
    
    // 合并(按秩合并)
    void Union(string name1, string name2) {
        string root1 = Find(name1);
        string root2 = Find(name2);
        
        if (root1.empty() || root2.empty()) {
            cout << "元素不存在!" << endl;
            return;
        }
        
        if (root1 == root2) {
            cout << name1 << " 和 " << name2 << " 已在同一队" << endl;
            return;
        }
        
        // 按秩合并:矮树接到高树下
        if (rank[root1] < rank[root2]) {
            parent[root1] = root2;
            cout << root1 << " 队并入 " << root2 << " 队" << endl;
        } else if (rank[root1] > rank[root2]) {
            parent[root2] = root1;
            cout << root2 << " 队并入 " << root1 << " 队" << endl;
        } else {
            parent[root2] = root1;
            rank[root1]++;
            cout << root2 << " 队并入 " << root1 << " 队(秩+1)" << endl;
        }
    }
    
    // 判断是否在同一集合
    bool Connected(string name1, string name2) {
        string root1 = Find(name1);
        string root2 = Find(name2);
        return !root1.empty() && !root2.empty() && root1 == root2;
    }
};

4.3 优化一:按秩合并(Union by Rank)

核心思想 :将 矮的树 接到 高的树 下面,避免树越来越高。

秩(Rank):树的高度的一个上界估计值。

text 复制代码
不优化的合并:
  A      B           A
  |      |     →    / \
  C      D          C  B
                       |
                       D
树高从 2 变成 3

按秩合并:
  A      B           B
  |      |     →    / \
  C      D          A  D
                    |
                    C
树高保持为 3

时间复杂度:单独使用按秩合并,Find 操作的时间复杂度为 O(log n)。

4.4 优化二:路径压缩(Path Compression)

核心思想 :Find 时,将路径上所有节点 直接连到根节点

text 复制代码
查找前:
        关羽
        /
      张飞
      /
    魏延
    /
  张苞

查找 张苞 时,路径:张苞 → 魏延 → 张飞 → 关羽

查找后(路径压缩):
        关羽
       / | \
    张飞 魏延 张苞

所有节点直接连到根,下次查找更快!

实现代码

cpp 复制代码
string Find(string name) {
    if (parent[name] != name) {
        parent[name] = Find(parent[name]);  // 递归压缩
    }
    return parent[name];
}

时间复杂度 :路径压缩 + 按秩合并,均摊时间复杂度接近 O(α(n)),其中 α 是反阿克曼函数,增长极慢,实际可认为是常数。

4.5 完整测试

cpp 复制代码
int main() {
    DisjointSetTree dst;
    
    // 初始化
    cout << "=== 初始化 ===" << endl;
    vector<string> names = {"关羽", "周仓", "关平", "张飞", "魏延", "赵云", "黄忠"};
    for (const string& name : names) {
        dst.MakeSet(name);
    }
    
    // 组建队伍
    cout << "\n=== 组建队伍 ===" << endl;
    dst.Union("关羽", "周仓");
    dst.Union("关羽", "关平");
    dst.Union("张飞", "魏延");
    
    // 查询
    cout << "\n=== 查询头领 ===" << endl;
    cout << "周仓的头领:" << dst.Find("周仓") << endl;
    cout << "魏延的头领:" << dst.Find("魏延") << endl;
    cout << "赵云的头领:" << dst.Find("赵云") << endl;
    
    // 判断连通性
    cout << "\n=== 判断是否一队 ===" << endl;
    cout << "周仓和关平:" << (dst.Connected("周仓", "关平") ? "一队" : "不同队") << endl;
    cout << "周仓和魏延:" << (dst.Connected("周仓", "魏延") ? "一队" : "不同队") << endl;
    
    // 大合并
    cout << "\n=== 大整编 ===" << endl;
    dst.Union("关羽", "张飞");
    dst.Union("赵云", "黄忠");
    dst.Union("关羽", "赵云");
    
    // 最终状态
    cout << "\n=== 最终所有人的头领 ===" << endl;
    for (const string& name : names) {
        cout << name << " → " << dst.Find(name) << endl;
    }
    
    return 0;
}

运行结果

text 复制代码
=== 初始化 ===

=== 组建队伍 ===
周仓 队并入 关羽 队
关平 队并入 关羽 队(秩+1)
魏延 队并入 张飞 队

=== 查询头领 ===
周仓的头领:关羽
魏延的头领:张飞
赵云的头领:赵云

=== 判断是否一队 ===
周仓和关平:一队
周仓和魏延:不同队

=== 大整编 ===
张飞 队并入 关羽 队
黄忠 队并入 赵云 队
赵云 队并入 关羽 队

=== 最终所有人的头领 ===
关羽 → 关羽
周仓 → 关羽
关平 → 关羽
张飞 → 关羽
魏延 → 关羽
赵云 → 关羽
黄忠 → 关羽

五、实战应用:Kruskal 最小生成树

5.1 问题背景

给定一个加权无向图,找到一棵生成树,使得所有边的权重和最小。

Kruskal 算法思路

  1. 将所有边按权重从小到大排序
  2. 依次考察每条边:
    • 如果边的两个端点不在同一集合 → 加入这条边,合并两个集合
    • 否则跳过(会形成环)
  3. 重复直到有 n-1 条边

并查集的作用:快速判断两个节点是否连通,避免形成环。

5.2 代码实现

cpp 复制代码
struct Edge {
    string u, v;
    int weight;
    bool operator<(const Edge& other) const {
        return weight < other.weight;
    }
};

vector<Edge> Kruskal(vector<Edge>& edges, vector<string>& vertices) {
    DisjointSetTree dst;
    
    // 初始化并查集
    for (const string& v : vertices) {
        dst.MakeSet(v);
    }
    
    // 排序边
    sort(edges.begin(), edges.end());
    
    vector<Edge> mst;  // 最小生成树的边
    
    for (const Edge& e : edges) {
        // 如果两个端点不在同一集合,加入这条边
        if (!dst.Connected(e.u, e.v)) {
            mst.push_back(e);
            dst.Union(e.u, e.v);
            cout << "加入边:" << e.u << " - " << e.v << " (权重 " << e.weight << ")" << endl;
        }
    }
    
    return mst;
}

int main() {
    vector<Edge> edges = {
        {"成都", "重庆", 3},
        {"成都", "武汉", 7},
        {"重庆", "武汉", 5},
        {"重庆", "长沙", 6},
        {"武汉", "长沙", 4},
        {"武汉", "广州", 8},
        {"长沙", "广州", 2}
    };
    
    cout << "=== Kruskal 最小生成树算法 ===" << endl;
    vector<Edge> mst = Kruskal(edges, cities);
    
    int totalWeight = 0;
    cout << "\n最小生成树的边:" << endl;
    for (const Edge& e : mst) {
        cout << e.u << " - " << e.v << " (权重 " << e.weight << ")" << endl;
        totalWeight += e.weight;
    }
    cout << "总权重:" << totalWeight << endl;
    
    return 0;
}

运行结果

text 复制代码
=== Kruskal 最小生成树算法 ===
加入边:长沙 - 广州 (权重 2)
加入边:成都 - 重庆 (权重 3)
加入边:武汉 - 长沙 (权重 4)
加入边:重庆 - 武汉 (权重 5)

最小生成树的边:
长沙 - 广州 (权重 2)
成都 - 重庆 (权重 3)
武汉 - 长沙 (权重 4)
重庆 - 武汉 (权重 5)
总权重:14

算法分析

  • 排序:O(E log E)
  • 并查集操作:O(E · α(V))
  • 总时间复杂度:O(E log E)

六、两种实现方式对比

6.1 链表 vs 树形结构

特性 链表实现 树形结构(优化后)
空间复杂度 O(n),需要额外存储 representative O(n),只需父指针
MakeSet O(1) O(1)
Find(无优化) O(1) O(树高)
Find(优化后) O(1) O(α(n)) ≈ O(1)
Union(无优化) O(n) O(树高)
Union(优化后) O(log n) O(α(n)) ≈ O(1)
实现复杂度 较复杂 简洁
实际应用 较少使用 主流选择

6.2 为什么树形结构更优?

  1. 空间效率:只需一个父指针数组
  2. 时间效率:路径压缩 + 按秩合并后,均摊复杂度接近常数
  3. 代码简洁:核心代码不超过 30 行
  4. 灵活性:容易扩展(如支持撤销操作)

七、并查集的高级技巧

7.1 记录集合大小

在某些应用中,我们需要知道每个集合的大小。

cpp 复制代码
class DisjointSetWithSize {
private:
    unordered_map<string, string> parent;
    unordered_map<string, int> size;  // 集合大小
    
public:
    void MakeSet(string name) {
        parent[name] = name;
        size[name] = 1;
    }
    
    string Find(string name) {
        if (parent[name] != name) {
            parent[name] = Find(parent[name]);
        }
        return parent[name];
    }
    
    void Union(string name1, string name2) {
        string root1 = Find(name1);
        string root2 = Find(name2);
        
        if (root1 == root2) return;
        
        // 小树并入大树
        if (size[root1] < size[root2]) {
            parent[root1] = root2;
            size[root2] += size[root1];
        } else {
            parent[root2] = root1;
            size[root1] += size[root2];
        }
    }
    
    int GetSize(string name) {
        return size[Find(name)];
    }
};

应用:社交网络中找最大朋友圈。

7.2 带权并查集

在某些问题中,需要维护元素之间的相对关系(如距离、差值)。

示例:判断亲戚关系的代沟

cpp 复制代码
class WeightedDisjointSet {
private:
    unordered_map<string, string> parent;
    unordered_map<string, int> weight;  // 到父节点的权值(代差)
    
public:
    void MakeSet(string name) {
        parent[name] = name;
        weight[name] = 0;
    }
    
    string Find(string name) {
        if (parent[name] != name) {
            string originalParent = parent[name];
            parent[name] = Find(parent[name]);
            weight[name] += weight[originalParent];  // 路径压缩时累加权值
        }
        return parent[name];
    }
    
    void Union(string name1, string name2, int diff) {
        string root1 = Find(name1);
        string root2 = Find(name2);
        
        if (root1 == root2) return;
        
        parent[root2] = root1;
        weight[root2] = weight[name1] - weight[name2] + diff;
    }
    
    int GetDifference(string name1, string name2) {
        if (Find(name1) != Find(name2)) {
            return -1;  // 不在同一集合
        }
        return weight[name2] - weight[name1];
    }
};

应用场景

  • 化学方程式配平
  • 差分约束系统
  • 区间合并问题

7.3 支持删除操作

标准并查集不支持删除,但可以通过以下方式实现:

方法一:懒惰删除(标记删除)

cpp 复制代码
unordered_map<string, bool> deleted;

string Find(string name) {
    if (deleted[name]) return "";  // 已删除
    // ... 正常查找逻辑
}

方法二:重建并查集(适用于删除操作较少的场景)


八、经典例题解析

8.1 LeetCode 547: 省份数量

问题:给定 n 个城市的连接关系矩阵,求有多少个省份(连通分量)。

cpp 复制代码
class Solution {
public:
    int findCircleNum(vector<vector<int>>& isConnected) {
        int n = isConnected.size();
        vector<int> parent(n);
        
        // 初始化
        for (int i = 0; i < n; i++) {
            parent[i] = i;
        }
        
        // 合并连通的城市
        for (int i = 0; i < n; i++) {
            for (int j = i + 1; j < n; j++) {
                if (isConnected[i][j] == 1) {
                    unionSet(parent, i, j);
                }
            }
        }
        
        // 统计根节点数量
        unordered_set<int> roots;
        for (int i = 0; i < n; i++) {
            roots.insert(find(parent, i));
        }
        
        return roots.size();
    }
    
private:
    int find(vector<int>& parent, int x) {
        if (parent[x] != x) {
            parent[x] = find(parent, parent[x]);
        }
        return parent[x];
    }
    
    void unionSet(vector<int>& parent, int x, int y) {
        int rootX = find(parent, x);
        int rootY = find(parent, y);
        if (rootX != rootY) {
            parent[rootX] = rootY;
        }
    }
};

8.2 LeetCode 200: 岛屿数量

问题:给定二维网格,'1' 表示陆地,'0' 表示水,求岛屿数量。

cpp 复制代码
class Solution {
public:
    int numIslands(vector<vector<char>>& grid) {
        if (grid.empty()) return 0;
        
        int m = grid.size();
        int n = grid[0].size();
        DisjointSet ds(m * n);
        int waterCount = 0;
        
        // 将二维坐标映射到一维
        auto getIndex = [&](int i, int j) { return i * n + j; };
        
        for (int i = 0; i < m; i++) {
            for (int j = 0; j < n; j++) {
                if (grid[i][j] == '0') {
                    waterCount++;
                    continue;
                }
                
                int index = getIndex(i, j);
                
                // 向右合并
                if (j + 1 < n && grid[i][j + 1] == '1') {
                    ds.unionSet(index, getIndex(i, j + 1));
                }
                
                // 向下合并
                if (i + 1 < m && grid[i + 1][j] == '1') {
                    ds.unionSet(index, getIndex(i + 1, j));
                }
            }
        }
        
        return ds.getCount() - waterCount;
    }
    
private:
    class DisjointSet {
    private:
        vector<int> parent;
        int count;  // 集合数量
        
    public:
        DisjointSet(int n) : parent(n), count(n) {
            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 unionSet(int x, int y) {
            int rootX = find(x);
            int rootY = find(y);
            if (rootX != rootY) {
                parent[rootX] = rootY;
                count--;  // 集合数量减一
            }
        }
        
        int getCount() { return count; }
    };
};

8.3 LeetCode 684: 冗余连接

问题:给定一个无向图,找出一条可以删除的边,使得删除后图变成一棵树。

cpp 复制代码
class Solution {
public:
    vector<int> findRedundantConnection(vector<vector<int>>& edges) {
        int n = edges.size();
        vector<int> parent(n + 1);
        
        for (int i = 1; i <= n; i++) {
            parent[i] = i;
        }
        
        for (const auto& edge : edges) {
            int u = edge[0], v = edge[1];
            
            // 如果两个节点已经连通,这条边就是冗余的
            if (find(parent, u) == find(parent, v)) {
                return edge;
            }
            
            unionSet(parent, u, v);
        }
        
        return {};
    }
    
private:
    int find(vector<int>& parent, int x) {
        if (parent[x] != x) {
            parent[x] = find(parent, parent[x]);
        }
        return parent[x];
    }
    
    void unionSet(vector<int>& parent, int x, int y) {
        parent[find(parent, x)] = find(parent, y);
    }
};

九、性能分析与证明

9.1 反阿克曼函数

阿克曼函数 A(m, n):增长极快的函数

text 复制代码
A(0, n) = n + 1
A(m, 0) = A(m-1, 1)
A(m, n) = A(m-1, A(m, n-1))

增长速度

  • A(1, n) = 2n
  • A(2, n) = 2^n
  • A(3, n) = 2(2(...^2)) (n 个 2)
  • A(4, 3) > 10^19728 (比宇宙中原子数还多)

反阿克曼函数 α(n):阿克曼函数的反函数

text 复制代码
α(n) = min{k : A(k, k) ≥ n}

实际意义

  • α(10^80) ≈ 4(宇宙原子数)
  • 对于任何实际问题,α(n) ≤ 5

9.2 时间复杂度证明(简述)

定理(Tarjan & van Leeuwen, 1984)

  • 使用路径压缩 + 按秩合并
  • m 次操作(包括 n 次 MakeSet)的总时间:O(m · α(n))
  • 均摊每次操作:O(α(n))

直觉理解

  1. 按秩合并保证树高 ≤ log n
  2. 路径压缩使得后续查找更快
  3. 两者结合产生协同效应

十、实现技巧总结

10.1 数组实现(推荐用于整数索引)

cpp 复制代码
class UnionFind {
private:
    vector<int> parent;
    vector<int> rank;
    
public:
    UnionFind(int n) : parent(n), rank(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];
    }
    
    bool unionSet(int x, int y) {
        int rootX = find(x);
        int rootY = find(y);
        
        if (rootX == rootY) return false;
        
        if (rank[rootX] < rank[rootY]) {
            parent[rootX] = rootY;
        } else if (rank[rootX] > rank[rootY]) {
            parent[rootY] = rootX;
        } else {
            parent[rootY] = rootX;
            rank[rootX]++;
        }
        
        return true;
    }
    
    bool connected(int x, int y) {
        return find(x) == find(y);
    }
};

10.2 哈希表实现(适用于字符串等复杂键)

cpp 复制代码
class UnionFindMap {
private:
    unordered_map<string, string> parent;
    unordered_map<string, int> rank;
    
public:
    void makeSet(string x) {
        if (parent.find(x) == parent.end()) {
            parent[x] = x;
            rank[x] = 0;
        }
    }
    
    string find(string x) {
        if (parent[x] != x) {
            parent[x] = find(parent[x]);
        }
        return parent[x];
    }
    
    bool unionSet(string x, string y) {
        string rootX = find(x);
        string rootY = find(y);
        
        if (rootX == rootY) return false;
        
        if (rank[rootX] < rank[rootY]) {
            parent[rootX] = rootY;
        } else if (rank[rootX] > rank[rootY]) {
            parent[rootY] = rootX;
        } else {
            parent[rootY] = rootX;
            rank[rootX]++;
        }
        
        return true;
    }
};

十一、常见误区与注意事项

11.1 误区一:路径压缩破坏按秩合并

错误认知:路径压缩会改变树高,使得 rank 失效。

正确理解

  • rank 是树高的 上界估计,不是精确值
  • 路径压缩只会让树变矮,不影响优化效果
  • 两者结合不会产生冲突

11.2 误区二:先 Union 再 Find 会更慢

错误认知:Union 操作会让树变高,后续 Find 变慢。

正确理解

  • 按秩合并控制树高
  • 路径压缩在 Find 时自动优化
  • 越用越快,而不是越用越慢

11.3 注意事项

  1. 初始化:所有元素必须先 MakeSet
  2. 边界检查:Find 前确认元素存在
  3. 集合数量:需要额外维护计数器
  4. 不支持删除:标准并查集不支持拆分操作
  5. 非递归实现:对于极深的树,可能栈溢出

非递归 Find 实现

cpp 复制代码
int find(int x) {
    int root = x;
    while (parent[root] != root) {
        root = parent[root];
    }
    
    // 路径压缩
    while (parent[x] != root) {
        int next = parent[x];
        parent[x] = root;
        x = next;
    }
    
    return root;
}

十二、核心总结

12.1 并查集的本质

并查集是一种 用树形结构维护集合划分 的数据结构:

  • 每棵树代表一个集合
  • 根节点是集合的代表元素
  • 通过父指针连接节点

12.2 核心操作

操作 作用 优化策略 时间复杂度
MakeSet 初始化单元素集合 - O(1)
Find 查找代表元素 路径压缩 O(α(n))
Union 合并两个集合 按秩合并 O(α(n))

12.3 优化技巧

  1. 路径压缩:Find 时将路径上所有节点直接连到根
  2. 按秩合并:矮树并入高树
  3. 按大小合并:小集合并入大集合(链表实现)

12.4 应用场景

  • 连通性判断:两个元素是否在同一集合
  • 动态连通性:动态添加边,查询连通性
  • 最小生成树:Kruskal 算法核心
  • 等价类划分:按关系分组
  • 不支持删除:无法拆分集合
  • 不支持查询元素:无法遍历集合中的所有元素

12.5 形象记忆

刘备的人马管理

  • 初始状态:每个人都是独立队伍(MakeSet)
  • 查队长:沿着指挥链向上找(Find)
  • 整编:两队合并,选一个队长(Union)
  • 路径压缩:查完后直接记住最高领导
  • 按秩合并:小队并入大队,减少层级

完整代码仓库:建议将上述所有代码整理成模板,便于刷题时快速调用。

进阶学习

  1. 可持久化并查集(支持历史版本查询)
  2. 动态并查集(支持删除操作)
  3. 并查集在竞赛中的技巧应用

练习建议

  • LeetCode 标签搜索 "Union Find"
  • 力扣题单:并查集专题(约 50 题)
  • 《算法竞赛进阶指南》并查集章节

掌握并查集,图论问题解一半!

相关推荐
luj_17681 小时前
R语言生态优势与学习曲线分析
c语言·开发语言·网络·经验分享·算法
计算机安禾1 小时前
【算法分析与设计】第36篇:计算几何基础:凸包问题的分治与扫描线解法
大数据·人工智能·算法·机器学习·剪枝
货拉拉技术1 小时前
飞速发展的计算机视觉
人工智能·算法
如竟没有火炬2 小时前
寻找峰值——二分
java·开发语言·数据结构·python·算法·散列表
noipp2 小时前
推荐题目:洛谷 P1115 最大子段和
算法
Lumbrologist2 小时前
【C++】零基础入门 · 第 17 节:多线程编程基础
java·c++·算法
轻闲一号机2 小时前
【语音】笔记
前端·笔记·算法
aWty_2 小时前
实分析入门(12)--可测函数
学习·数学·算法·实变函数
海砥装备HardAus3 小时前
无人机姿态解算中「重力矢量观测退化」机理与动态补偿技术
算法·无人机·飞控