【数据结构】高效掌握并查集:核心原理与实战

文章目录

并查集简介

一、并查集(Union-Find)核心概念

并查集是一种高效处理动态连通性问题的数据结构,主要用于解决"元素分组"和"判断两个元素是否属于同一组"的问题。它的核心操作只有两个:

  1. 查找(Find):找到某个元素所属的"根节点"(代表该组的唯一标识);
  2. 合并(Union):将两个元素所在的组合并为一个组。

为了提升效率,通常会加入两个优化:

  • 路径压缩:在查找时,将路径上的所有节点直接指向根节点,减少后续查找的层数;
  • 按秩(或大小)合并:合并时,将秩(树的高度)较小的树合并到秩较大的树下,避免树退化成链表。

二、C/C++ 完整实现

下面是一个完整的、带优化的并查集实现,包含核心操作和示例测试:

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

// 并查集类
class UnionFind {
private:
    vector<int> parent;  // 存储每个节点的父节点
    vector<int> rank;    // 存储每个根节点对应的树的秩(高度)

public:
    // 构造函数:初始化并查集,每个节点的父节点是自己,秩为1
    UnionFind(int n) {
        parent.resize(n);
        rank.resize(n, 1);
        for (int i = 0; i < n; ++i) {
            parent[i] = i;
        }
    }

    // 查找:找到节点x的根节点,同时进行路径压缩
    int find(int x) {
        // 递归版路径压缩(简洁)
        if (parent[x] != x) {
            parent[x] = find(parent[x]);  // 把x的父节点直接指向根节点
        }
        return parent[x];

        // 迭代版路径压缩(避免递归栈溢出,适合大数据量)
        // int root = x;
        // while (parent[root] != root) {
        //     root = parent[root];
        // }
        // // 路径压缩:把x到根节点的所有节点直接指向根
        // while (parent[x] != root) {
        //     int next = parent[x];
        //     parent[x] = root;
        //     x = next;
        // }
        // return root;
    }

    // 合并:将x和y所在的集合合并
    void unite(int x, int y) {
        int rootX = find(x);
        int rootY = find(y);

        // 如果已经在同一个集合,无需合并
        if (rootX == rootY) {
            return;
        }

        // 按秩合并:把秩小的树合并到秩大的树下
        if (rank[rootX] < rank[rootY]) {
            parent[rootX] = rootY;
        } else {
            parent[rootY] = rootX;
            // 如果秩相等,合并后秩+1
            if (rank[rootX] == rank[rootY]) {
                rank[rootX]++;
            }
        }
    }

    // 判断:x和y是否在同一个集合
    bool isConnected(int x, int y) {
        return find(x) == find(y);
    }
};

// 测试示例
int main() {
    // 初始化10个节点(0-9),每个节点独立为一个集合
    UnionFind uf(10);

    // 合并操作
    uf.unite(0, 1);
    uf.unite(1, 2);
    uf.unite(3, 4);
    uf.unite(4, 5);
    uf.unite(5, 6);
    uf.unite(2, 6);  // 合并0-2和3-6两个集合

    // 测试连通性
    cout << "0和6是否连通:" << (uf.isConnected(0, 6) ? "是" : "否") << endl;  // 是
    cout << "0和7是否连通:" << (uf.isConnected(0, 7) ? "是" : "否") << endl;  // 否
    cout << "3和5是否连通:" << (uf.isConnected(3, 5) ? "是" : "否") << endl;  // 是

    // 合并7和0
    uf.unite(7, 0);
    cout << "7和6是否连通:" << (uf.isConnected(7, 6) ? "是" : "否") << endl;  // 是

    return 0;
}

三、代码关键部分解释

  1. 成员变量

    • parent 数组:parent[i] 表示节点 i 的父节点,初始时 parent[i] = i(每个节点自己是根);
    • rank 数组:rank[i] 表示以 i 为根的树的高度,初始时都是1(单个节点的树高度为1)。
  2. find 函数(查找)

    • 核心逻辑是找根节点,同时通过 parent[x] = find(parent[x]) 实现路径压缩,让路径上的所有节点直接指向根,后续查找的时间复杂度接近O(1)。
    • 提供了递归和迭代两种实现:递归简洁,迭代适合大数据量(避免栈溢出)。
  3. unite 函数(合并)

    • 先找到两个节点的根,若根相同则无需合并;
    • 按秩合并:把矮树合并到高树下,避免树退化成链表,保证合并后的树高度尽可能小。
  4. isConnected 函数(判断连通性)

    • 只需判断两个节点的根是否相同,相同则属于同一集合。

四、时间复杂度

  • 无优化的并查集:查找和合并的时间复杂度为O(n);
  • 路径压缩 + 按秩合并的并查集:均摊时间复杂度为O(α(n)),其中α(n)是阿克曼函数的反函数,增长极慢(n为10^600时,α(n)也仅为5),几乎可以认为是O(1)。

五、并查集的典型应用

  1. 解决图的连通分量问题(如:判断图中有多少个连通块);
  2. 处理动态连通性问题(如:网络节点连通、朋友圈问题);
  3. 克鲁斯卡尔(Kruskal)算法求最小生成树(判断边是否会形成环);
  4. 解决分组问题(如:LeetCode 547. 省份数量、LeetCode 684. 冗余连接)。

总结

  1. 并查集的核心是查找(带路径压缩)合并(按秩/大小),这两个优化是提升效率的关键;
  2. 它的核心价值是高效处理动态连通性问题,时间复杂度接近常数;
  3. C/C++ 实现时,常用数组存储父节点和秩,递归/迭代均可实现路径压缩,优先选迭代(避免栈溢出)。

并查集:从原理到实战的完整解析

并查集(Union-Find Set)是一种专为处理动态连通性问题设计的高效数据结构,核心解决"元素分组"和"判断元素归属"问题。本文将从原理、实现到实战应用,全方位拆解并查集的核心逻辑,并结合C++代码完成落地。

一、并查集核心原理

在实际场景中,我们常需要将n个独立元素划分成若干不相交的集合,并支持"合并集合"和"查询元素归属"操作,这正是并查集的核心应用场景。

1.1 核心场景示例

以公司校招10名学生的社交关系为例:

  • 初始状态:10名学生(编号0-9)互不相识,各自为独立集合;
  • 分组阶段:西安小分队{0,6,7,8}、成都小分队{1,4,9}、武汉小分队{2,3,5}形成3个独立朋友圈;
  • 合并阶段:西安8号和成都1号相识,两个小分队合并为1个大集合。

1.2 数组存储规则(核心)

并查集通常用数组存储集合关系,规则如下:

  1. 数组下标 = 元素编号(如下标0对应学生0);
  2. 数组值为负数:表示该下标是集合根节点 ,数值绝对值 = 集合元素个数(如ufs[0] = -4表示0是根,集合有4个元素);
  3. 数组值为非负数:表示该元素的父节点下标 (如ufs[6] = 0表示6的父节点是0)。

1.3 核心操作定义

  1. 查找(Find):找元素的根节点(沿父节点向上,直到值为负数);
  2. 合并(Union):将两个集合合并为一个(把一个集合的根指向另一个集合的根);
  3. 计数(Count):统计集合数量(数组中负数的个数);
  4. 判同集:判断两个元素是否同属一个集合(根节点是否相同)。

二、并查集完整实现(C++)

基于上述原理,实现通用的并查集类,包含核心操作:

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

// 通用并查集类
class UnionFindSet {
public:
    // 构造函数:初始化每个元素为独立集合(值为-1,代表根且集合大小为1)
    UnionFindSet(size_t size) : _ufs(size, -1) {}

    // 查找:返回元素index所在集合的根节点
    int FindRoot(int index) {
        // 边界检查:防止下标越界
        if (index < 0 || index >= _ufs.size()) {
            return -1;
        }
        // 沿父节点向上找根(值为负数时停止)
        while (_ufs[index] >= 0) {
            index = _ufs[index];
        }
        return index;
    }

    // 合并:将x1和x2所在集合合并,成功返回true,失败(同集)返回false
    bool Union(int x1, int x2) {
        int root1 = FindRoot(x1);
        int root2 = FindRoot(x2);

        // 同属一个集合,无需合并
        if (root1 == root2 || root1 == -1 || root2 == -1) {
            return false;
        }

        // 合并规则:将两个集合大小相加,把root2的根指向root1
        _ufs[root1] += _ufs[root2];  // root1集合大小 = 原大小 + root2集合大小
        _ufs[root2] = root1;         // root2不再是根,父节点改为root1
        return true;
    }

    // 统计:返回当前集合的数量(数组中负数的个数)
    size_t Count() const {
        size_t count = 0;
        for (auto e : _ufs) {
            if (e < 0) {
                ++count;
            }
        }
        return count;
    }

    // 辅助打印:查看并查集数组状态(便于调试)
    void Print() const {
        for (size_t i = 0; i < _ufs.size(); ++i) {
            cout << "ufs[" << i << "] = " << _ufs[i] << " ";
        }
        cout << endl;
    }

private:
    vector<int> _ufs;  // 存储集合关系的核心数组
};

// 测试示例
int main() {
    UnionFindSet ufs(10);  // 初始化10个元素(0-9)

    // 模拟西安/成都/武汉小分队分组
    ufs.Union(0, 6);
    ufs.Union(0, 7);
    ufs.Union(0, 8);  // 西安小分队:0为根,size=4
    ufs.Union(1, 4);
    ufs.Union(1, 9);  // 成都小分队:1为根,size=3
    ufs.Union(2, 3);
    ufs.Union(2, 5);  // 武汉小分队:2为根,size=3

    cout << "初始集合数量:" << ufs.Count() << endl;  // 输出3
    ufs.Print();  // 打印数组状态

    // 合并西安和成都小分队(8和1)
    ufs.Union(8, 1);
    cout << "合并后集合数量:" << ufs.Count() << endl;  // 输出2
    ufs.Print();  // 打印合并后状态

    return 0;
}

代码关键说明

  1. 初始化:构造函数将数组所有元素设为-1,代表每个元素独立成集合(大小为1);
  2. FindRoot:通过循环向上找根,时间复杂度取决于树的高度(未优化版);
  3. Union:先找两个元素的根,不同根则合并(小集合合并到大集合);
  4. Count:遍历数组统计负数个数,直接反映当前集合数量。

三、并查集经典应用

3.1 应用1:省份数量(LeetCode 547)

问题描述

给定n x n的矩阵isConnectedisConnected[i][j] = 1表示第i个城市和第j个城市相连,求省份数量(相连的城市为一个省份)。

解题代码
cpp 复制代码
#include <vector>
using namespace std;

class Solution {
public:
    int findCircleNum(vector<vector<int>>& isConnected) {
        int n = isConnected.size();
        // 初始化并查集数组:每个城市独立成省
        vector<int> ufs(n, -1);

        // 定义查找根节点的lambda函数(复用逻辑)
        auto findRoot = [&ufs](int x) {
            while (ufs[x] >= 0) {
                x = ufs[x];
            }
            return x;
        };

        // 遍历矩阵,合并相连的城市
        for (int i = 0; i < n; ++i) {
            for (int j = 0; j < n; ++j) {
                // 仅处理相连且i≠j的情况(避免重复合并)
                if (isConnected[i][j] == 1 && i != j) {
                    int root1 = findRoot(i);
                    int root2 = findRoot(j);
                    if (root1 != root2) {
                        ufs[root1] += ufs[root2];
                        ufs[root2] = root1;
                    }
                }
            }
        }

        // 统计省份数量(集合数)
        int provinceCount = 0;
        for (int val : ufs) {
            if (val < 0) {
                ++provinceCount;
            }
        }
        return provinceCount;
    }
};
核心思路
  1. 矩阵中isConnected[i][j] = 1表示i和j连通,需合并两个城市的集合;
  2. 最终数组中负数的个数即为省份数量;
  3. 用lambda函数封装findRoot,简化代码复用。

3.2 应用2:等式方程的可满足性(LeetCode 990)

问题描述

给定由字符串组成的数组equations,每个字符串形如a==ba!=b,判断所有等式是否能同时成立。

解题代码
cpp 复制代码
#include <vector>
#include <string>
using namespace std;

class Solution {
public:
    bool equationsPossible(vector<string>& equations) {
        // 26个小写字母,初始化并查集
        vector<int> ufs(26, -1);

        // 查找根节点
        auto findRoot = [&ufs](int x) {
            while (ufs[x] >= 0) {
                x = ufs[x];
            }
            return x;
        };

        // 第一步:合并所有"=="的变量(构建连通关系)
        for (const string& eq : equations) {
            if (eq[1] == '=') {
                int a = eq[0] - 'a';  // 字符转下标(a=0, b=1...)
                int b = eq[3] - 'a';
                int rootA = findRoot(a);
                int rootB = findRoot(b);
                if (rootA != rootB) {
                    ufs[rootA] += ufs[rootB];
                    ufs[rootB] = rootA;
                }
            }
        }

        // 第二步:检查所有"!="的变量(验证是否冲突)
        for (const string& eq : equations) {
            if (eq[1] == '!') {
                int a = eq[0] - 'a';
                int b = eq[3] - 'a';
                int rootA = findRoot(a);
                int rootB = findRoot(b);
                // 若"!="的两个变量同属一个集合,等式不成立
                if (rootA == rootB) {
                    return false;
                }
            }
        }

        // 所有等式无冲突
        return true;
    }
};
核心思路
  1. 先处理所有==:将相等的变量合并到同一集合;
  2. 再处理所有!=:若两个变量同属一个集合,说明矛盾,返回false;
  3. 字符转下标:通过eq[0] - 'a'将a-z映射为0-25,适配数组存储。

四、优化方向(进阶)

本文实现的是基础版并查集,实际应用中可通过以下优化降低时间复杂度:

  1. 路径压缩:查找时将路径上的节点直接指向根节点(使树扁平化);
  2. 按秩合并:合并时将小秩(树高度)集合合并到大秩集合下(避免树退化成链表)。

优化后并查集的均摊时间复杂度接近O(1),适合处理大数据量场景。

五、总结

  1. 并查集核心是"查找(Find)"和"合并(Union)",通过数组存储父节点/集合大小,规则简单且高效;
  2. 数组存储规则:负数=根节点(绝对值=集合大小),非负数=父节点下标;
  3. 典型应用:连通分量统计(省份数量)、等式验证(可满足性)、最小生成树(Kruskal算法)等。

并查集是算法面试中的高频考点,掌握其原理和实现后,能快速解决各类连通性问题。

相关推荐
励ℳ2 小时前
机器学习之线性回归算法:从原理到实践的全面解析
算法·机器学习·线性回归
_Twink1e2 小时前
[算法教学]一、前置知识
算法
MicroTech20252 小时前
微算法科技(NASDAQ: MLGO)使用量子傅里叶变换(QFT),增强图像压缩和滤波效率
科技·算法·量子计算
㓗冽2 小时前
矩阵问题(二维数组)-基础题70th + 发牌(二维数组)-基础题71th + 数字金字塔(二维数组)-基础题72th
c++·算法·矩阵
芜湖xin2 小时前
【题解-Acwing】796. 子矩阵的和
算法·前缀和
shehuiyuelaiyuehao2 小时前
23七大排序算法
数据结构·算法·排序算法
Σίσυφος19002 小时前
E=[T]×R 的证明
算法
TracyCoder1232 小时前
LeetCode Hot100(49/100)——33. 搜索旋转排序数组
算法·leetcode
熬了夜的程序员2 小时前
【LeetCode】116. 填充每个节点的下一个右侧节点指针
算法·leetcode·职场和发展