并查集:基础认识与模拟实现


目录

一、什么是并查集

并查集(Disjoint-Set Union,DSU,也叫 Union-Find)是一种专门用来处理**"不相交集合"**的合并与查询问题的数据结构。

什么意思呢?

在一些应用问题中,我们时常会遇见需要将n个元素划分到一些不相交的集合内,开始时,每个自成一个单元素集合,随后根据一些规律,将归于同一组的集合进行合并。在此过程中要反复用到查询某个元素归属于那个集合的运算 。适合于描述这类问题的抽象数据类型称为并查集(union-find set)

假设某公司今年校招,全国总共招了 10 个人:西安 4 人,成都 3 人,武汉 3 人。这 10 个同学来自不同的学校,起初互不相识,每个人都是独立的小团体。

我们现在给这些学生编号:{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}

被录取后,大家要去公司报到。每个地方的学生自发组织成小分队一起上路:

  • 西安小分队s1 = {0, 6, 7, 8},队长是 0 号同学
  • 成都小分队s2 = {1, 4, 9},队长是 1 号同学
  • 武汉小分队s3 = {2, 3, 5},队长是 2 号同学

一趟火车下来,小分队内部互相都认识了,变成了一个个"朋友圈"。但不同小分队之间的个体还是陌生人

这时候,并查集要帮我们解决的核心问题就三个:

  1. 查找:某个同学属于哪个小分队?(找队长)
  2. 判断:两个同学是不是同一个朋友圈的?(判断是否同队)
  3. 合并:西安的 8 号同学和成都的 1 号同学玩到了一起,于是经过他们两个介绍,最终两个小分队合并成一个大圈子,怎么办?

这就是并查集名字的由来------ (Union,合并集合)、 (Find,查找根节点)、(Set,集合)。

依旧是上面的这个例子,我们把这个数组的下标当做每一个人的编号,那么这个数组对应编号中所存储的值,就代表这个的组长(小分队的头目),而对于每一个组长,我们都用负数来表示其身份是组长,随后负数的绝对值就表示当前这个小组算上他,一共有多少个人!

最后数组的表示结果就如下:

上面0,1,2之间目前是没有任何关系线连接起来 。编号6,7,8同学属于0号小分队,该小分队中有4人(包含队长0);编号为4和9的同学属于1号小分队,该小分队有3人(包含队长1),编号为3和5的同学属于2号小分队,该小分队有3个人(包含队长2)。

根据上面图形,以及各个编号之间关系我们可以得出结论:

  1. 数组的下标对应集合中元素的编号

  2. 数组中如果为负数,负号代表根,数字代表该集合中元素个数

  3. 数组中如果为非负数,代表该元素双亲在数组中的下标

在公司工作一段时间后,西安小分队中8号同学与成都小分队1号同学奇迹般的玩到了一起,两个 小圈子的学生相互介绍,最后成为了一个小圈子,因此数组中的对应下标存储的值也需要进行改变才对:

所以我们就让1以及他的两个组员4,9,都加入了0所主导的小组中去,两个组合并在一起,组长还是0/

因此现在0的小组有7个人,2的小组有3个人,总共两个组。

如果我们想知道某个人的小组或者组长是谁,我们一般可以通过去找这个人对应存储的值,比如我们要判断9的组,就去数组中找。

发现他对应的值是1,这代表是另外一个人=,随后我们就去找1对应的值,来去判断1是不是组长。最后发现1下标的值是0,也不是组长,那我们就继续去找0,0下标的值是-7,那么我们就说9是在0主导的小组中,组长是0,且他们组有abs(-7)=7个人!

通过以上例子可知,并查集一般可以解决一下问题:

  1. 查找元素属于哪个集合

沿着数组表示树形关系以上一直找到根(即:树中中元素为负数的位置)

  1. 查看两个元素是否属于同一个集合

沿着数组表示的树形关系往上一直找到树的根,如果根相同表明在同一个集合,否则不在

  1. 将两个集合归并成一个集合

将两个集合中的元素合并

将一个集合名称改成另一个集合的名称

  1. 集合的个数

遍历数组,数组中元素为负数的个数即为集合的个数。

二、并查集的底层实现

初始化

并查集的底层实现非常巧妙,它不需要真的去建图、建树 ,用一个一维数组就能搞定!

就跟我们之前所说的例子一样,可以用一个 vector<int> 来存数据,下标就是学生的编号。数组里的值代表什么呢?

数组值 含义
负数 这个同学是队长(根节点) ,负号的绝对值代表这个小分队有多少人
非负数 这个同学的直接上级是谁,值就是上级在数组中的下标

初始状态下,10 个同学互不相识,每个人都是自己的小队长,队伍里只有 1 个人。所以数组初始化为:

plain 复制代码
下标:  0   1   2   3   4   5   6   7   8   9
值:   -1  -1  -1  -1  -1  -1  -1  -1  -1  -1
cpp 复制代码
#include <vector>
#include <iostream>
using namespace std;

class UnionFindSet
{
public:
    UnionFindSet(size_t n)
        : _ufs(n, -1)  // 每个人都是独立的集合,大小为 1
    {}

private:
    vector<int> _ufs;  // 核心数组,下标是编号,值是父节点或集合大小
};

那么这就是并查集的初始形态,如果我们对这个并查集进行一系列的合并操作,就会导致出现很多种分组的情况。

查找根

所以我们可以给这个并查集的各个操作完成其函数的实现。

比如我们最基础的操作肯定就是查找一个人的组长,这个功能也可以用于判断他是不是组长,那就是我们之前所说的过程,不断去数组中查找该下标对应的值并进行判断,这其实就是一个树状结构。

代码实现就是沿着父指针一直往上爬,直到遇到负数

cpp 复制代码
int FindRoot(const int& x)
{
    int root = x;
    while (_ufs[root] >= 0 )// 不是根,就继续往上找
    {
        root = _ufs[root];
    }
    return root;
}

合并组

在这个并查集中,每个人一开始都是孤身一人,但随着时间的推移难免就会有不同的小团体形成,这就代表的组的合并。

这个组的合并是并查集中最有说法的部分,我们先来说简单的,优化后面再说。

由于组的合并并不只是一个合并另外一个人,所以还会出现多人组合并多人组的情况。

如果合并的时候,不去找每个组的根(组长),其的合并就会变得异常麻烦,因此我们的合并操作之前,必须先将两个组的组长找到,这里就会调用我们上面实现的FindRoot。

在找到组长后,我们只需要将其中一个组长b,在数组中对应的值改成组长a,就相当于合并完成了。这样b组原本的成员在findroot的时候都会先找到b,随后根据b所对应的值找到a组的组长a。

这样两个组就算合并完成,但是需要注意的是,我们不能对相同组进行合并啊,比如a与b本来就是同一个组的人,你怎么能把别人再合并一次呢?

所以在找到组长之后进行判断,组长是否为同一人,是的话就代表是同一组。

cpp 复制代码
    void Union(const int& x1, const int& x2)
    {
        int root1 = FindRoot(x1);
        int root2 = FindRoot(x2);

        // 本身就在一个集合,没必要合并
        if (root1 == root2)
            return;

        _ufs[root1] += _ufs[root2];  // 更新集合的人数
        _ufs[root2] = root1;         // 改变其的上下级关系
    }

在这里,其实在合并前判断一下,因为两个组的人数肯定会有差距,为了合并之后方便处理,我们还是选择让小组往大组合并:

c++ 复制代码
void Union(int x1, int x2)
{
    int root1 = FindRoot(x1);
    int root2 = FindRoot(x2);
    
    // 本身就在一个集合,没必要合并
    if (root1 == root2)
        return;

    // 控制数据量小的往大的集合合并
    if (abs(_ufs[root1]) < abs(_ufs[root2]))
        swap(root1, root2);

    _ufs[root1] += _ufs[root2];  // 更新大集合的人数
    _ufs[root2] = root1;         // 小队长认大队长做上级
}

判断是否同队

这个就很简单了,看两个同学的队长是不是同一个人。

我们这里单独写出来作为一个功能是为了方便在使用时直接就if进行判断,而不是再手动调用两个查找函数。

cpp 复制代码
bool InSet(int x1, int x2)
{
    return FindRoot(x1) == FindRoot(x2);
}

统计组的数量

当然,如果需要实时监测这个并查集中当前存在的组的数量,我们也需要提供接口。

这个判断组别的数量也很简单,因为我们已经规定组长所对应的值是负数,所以我们只需要遍历数组,查找有多少个组长就可以了。

cpp 复制代码
size_t SetSize()
{
    size_t size = 0;
    for (size_t i = 0; i < _ufs.size(); ++i)
    {
        if (_ufs[i] < 0)
            ++size;
    }
    return size;
}

合并操作的优化问题

到上面为止,并查集已经可以用了。但如果数据量很大、查询很频繁,树可能会变得很深,查找就会变慢!

所以我们需要两个经典优化:

优化一:路径压缩(Path Compression)

大家想啊,如果 9 号同学要找队长,路径是 9 → 4 → 1,每次找都要爬两层,多麻烦!

路径压缩 的思想是:既然都爬到根了,不如顺手把路上所有人的直接上级都改成根节点。这样下次再找,一步到位!

这里1分别是2345的父节点,4是67的父节点,6是89的父节点,7是10的父节点,如果查找9的时候进行路径压缩,那么就会导致查询路上的9和6的父节点都变成1.

代码实现(迭代版,避免递归栈溢出):

cpp 复制代码
int FindRoot(int x)
{
    int root = x;
    while (_ufs[root] >= 0)
    {
        root = _ufs[root];  // 先找到根
    }

    // 路径压缩:把沿途所有节点的父节点直接指向根
    while (_ufs[x] >= 0)
    {
        int parent = _ufs[x];
        _ufs[x] = root;  // 直接挂到根下
        x = parent;
    }

    return root;
}

进行路径压缩的方法不止迭代,还可以使用递归,只是如果出现太多层的节点向上递归,就容易出现栈溢出的情况,所以如何实现就看各位自己的喜欢。

经过一次查找后,树被"压平"了,后续查询几乎就是 O(1)!

当然,你也可以不用每次都进行路径压缩,你可以计数统计当前查询层数,如果超过你设定的值就进行压缩。

优化二:按大小合并

我们在 Union 里其实已经用了这个优化------总是让人少的队伍并入人多的队伍

cpp 复制代码
if (abs(_ufs[root1]) < abs(_ufs[root2]))
    swap(root1, root2);

这样做的好处是:树高被严格控制,不会出现一边倒的"长链"。

单用一个优化,时间复杂度是 O(log n)。两个一起用 ,时间复杂度接近 O(α(n))

这个 α(n) 是反阿克曼函数,增长极其缓慢:

  • n = 10⁴ 时,α(n) ≤ 4
  • n = 10⁶⁰⁰ 时,α(n) 仍然 ≤ 4

所以在实际应用中,完全可以认为是常数时间!这就是并查集被称为"近乎 O(1)"的原因。


四、完整代码与测试

好了,把上面的内容整合起来,就是我们最终的并查集实现:

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

class UnionFindSet
{
public:
    UnionFindSet(size_t n)
        : _ufs(n, -1)  // 每个人都是独立的集合,大小为 1
    {
    }

    // 查找根 + 路径压缩
    int FindRoot(const int& x)
    {
        int root = x;
        while (_ufs[root] >= 0)
        {
            root = _ufs[root];
        }

        // 路径压缩
        while (_ufs[x] >= 0)
        {
            int parent = _ufs[x];
            _ufs[x] = root;
            x = parent;
        }

        return root;
    }

    void Union(const int& x1, const int& x2)
    {
        int root1 = FindRoot(x1);
        int root2 = FindRoot(x2);

        // 本身就在一个集合,没必要合并
        if (root1 == root2)
            return;

        _ufs[root1] += _ufs[root2];  // 更新大集合的人数
        _ufs[root2] = root1;         // 改变其的上下级关系
    }
    bool InSet(const int& x1, const int& x2)
    {
        return FindRoot(x1) == FindRoot(x2);
    }

    // 统计集合个数
    size_t SetSize()
    {
        size_t size = 0;
        for (size_t i = 0; i < _ufs.size(); ++i)
        {
            if (_ufs[i] < 0)
                ++size;
        }
        return size;
    }
private:
    vector<int> _ufs;  // 核心数组,下标是编号,值是父节点或集合大小
};

五、经典问题与应用

并查集虽然代码短,但应用场景非常多,这里列几个最常见的:

1.省份数量 / 朋友圈数量

LCR 116. 省份数量 - 力扣(LeetCode)

直接建并查集,最后返回 SetSize() 就是答案。或者说不用太过麻烦,直接用一个Lambda表达式把并查集的那几个需要用到的功能函数,比如FindRoot这些实现出来使用就可以。

2.岛屿数量

200. 岛屿数量 - 力扣(LeetCode)

这道题可以使用广搜与暴搜,同样的,也可以使用并查集代替搜索。

为了求出岛屿的数量,我们可以扫描整个二维网格。如果一个位置为 1,则将其与相邻四个方向上的 1 在并查集中进行合并。

最终岛屿的数量就是并查集中连通分量的数目。

3.冗余连接(判环)

LCR 118. 冗余连接 - 力扣(LeetCode)

在一棵树中,边的数量比节点的数量少 1。如果一棵树有 n 个节点,则这棵树有 n−1 条边。这道题中的图在树的基础上多了一条附加的边,因此边的数量也是 n。

树是一个连通且无环的无向图,在树中多了一条附加的边之后就会出现环,因此附加的边即为导致环出现的边。

可以通过并查集寻找附加的边。初始时,每个节点都属于不同的连通分量。遍历每一条边,判断这条边连接的两个顶点是否属于相同的连通分量。

如果两个顶点属于不同的连通分量,则说明在遍历到当前的边之前,这两个顶点之间不连通,因此当前的边不会导致环出现,合并这两个顶点的连通分量。

如果两个顶点属于相同的连通分量,则说明在遍历到当前的边之前,这两个顶点之间已经连通,因此当前的边导致环出现,为附加的边,将当前的边作为答案返回。

这道题的本质就是:用并查集模拟建树过程,边建边查,哪条边连上去会形成环,哪条边就是答案

具体来说:假设一共有 n 个节点,我们依次处理每条边。每次处理时,先检查边的两个端点是否已经在同一个集合中------如果不在 同一个集合,说明它们目前还不连通,这条边是安全的,可以加入,同时合并这两个集合;如果已经在同一个集合,说明在当前这条边出现之前,这两个点之间已经有路径相连了,再加上这条边就必然形成一个环,而树里是不允许有环的,所以这条边就是那条导致环出现的多余边,直接返回它。

最终答案一定是处理过程中第一次遇到"两个端点已经在同一集合"的那条边,因为之前的边都在正常建树,不会形成环,而一旦出现环,这条边就是我们要找的附加边。

4 Kruskal 最小生成树------并查集的经典应用

想象你要在 n 个城市之间修路,使得任意两个城市都能互相到达(直接或间接),同时希望总修路成本最低。这就要构建一棵最小生成树。Kruskal 算法的思路很直接:把所有候选道路按造价从低到高排序,然后从最便宜的开始考虑,能加就加。关键是"能加"的含义------如果这条道路连接的两个城市已经在之前的决策中连通了,那么加入这条路就会形成环,在树里是绝对不允许的,所以必须跳过。判断"是否已经连通"这个操作,正需要并查集来高效完成。并查集能在近乎 O(1) 的时间内告诉我们两个节点是否在同一个连通分量中,从而让我们能快速决定当前边是否应该加入最小生成树。

具体流程是:先初始化并查集,每个节点自成一个集合;然后遍历按权值升序排列的所有边。对每条边 (u, v, w),调用 find(u)find(v),如果发现它们属于不同的集合,说明当前边连接了两个尚未连通的部分,加入这条边既不会形成环,又能让总成本增加最小,所以将它纳入生成树,同时调用 union(u, v) 合并这两个集合。如果发现它们已经在同一个集合中,那就说明这条边是多余的,若强行加入会形成环,直接跳过即可。这样一直处理到生成树包含了 n-1 条边时,就得到了总权重最小的生成树。整个算法的正确性依赖于贪心策略和并查集对环检测的高效支持。

5.带权并查集------在连通关系之上附加数值信息

普通并查集只解决了"是否连通"的问题,而带权并查集在此基础上额外维护了每个节点到其根节点之间的某种数值关系,比如距离、差值、倍数等。这个"权值"可以理解为节点与根节点之间的相对偏移量,在路径压缩的过程中需要同步更新,以保证每个节点到新根节点的权值始终正确。

最典型的例题之一是食物链问题:动物分为 A、B、C 三类,A 吃 B,B 吃 C,C 吃 A,给定若干条捕食关系描述,要求判断其中哪些描述与已有事实矛盾。这时不仅要知道两个动物是否在同一个集合(即关系是否已经被定义过),还要知道它们之间具体是同类、捕食还是被捕食关系。用带权并查集时,每个节点到根节点的权值可以取 0、1、2,分别表示与根节点同类、被根节点捕食、捕食根节点,这样合并时通过权值的模 3 运算就能推算出任意两点之间的关系。

另一个经典例子是银河英雄传说问题:有 N 列战舰,每次操作要么把一列整体移动到另一列末尾,要么询问两艘战舰之间的战舰数量。这个需求同样可以用带权并查集实现,每个节点除了记录父节点外,还要记录到队首的距离以及所在队列的总大小,合并时通过更新权值来快速回答任意两艘战舰之间的间隔。相比于普通的并查集,带权并查集的核心难点在于路径压缩时如何正确更新权值,以及在合并两个集合时如何根据已有信息推算出新根之间的权值关系。理解了这个更新逻辑,带权并查集就能帮我们解决很多涉及传递性数值关系的问题,而不仅仅是简单的连通性判断。


额外提一嘴,如果题目交给我们的元素不是 0 ~ n-1 这样的连续整数,比如是字符串、坐标对,就可以用 map 做离散化,即用哈希表或者map获得一个映射关系,以此找到对应的数组下标。

cpp 复制代码
template<class T>
class UnionFindSet
{
public:
    UnionFindSet(const T* a, size_t n)
    {
        for (size_t i = 0; i < n; ++i)
        {
            _a.push_back(a[i]);
            _indexMap[a[i]] = i;
        }
        _ufs.resize(n, -1);
    }
    
    // ... FindRoot / Union / InSet 等实现与上面相同,
    // 只是查找时先通过 _indexMap 把 T 转成下标 ...
    
private:
    vector<T> _a;           // 编号找人
    map<T, int> _indexMap;  // 人找编号
    vector<int> _ufs;       // 并查集本体
};

写在最后

并查集这个数据结构,代码量极小,但思想非常精妙。数组模拟森林 + 负数存大小 + 路径压缩 + 按大小合并,不说后面这两个,其实把最基础的并查集掌握好就可以了,我们只是说存在有特殊情况肯会导致出错。

希望这篇文章能帮到你!如果有任何问题,欢迎在评论区交流~

相关推荐
电商API_180079052471 小时前
电商商品价格监控工具淘宝京东商品价格抓取API项目实操分享
java·大数据·开发语言·前端·数据挖掘
zzzll11111 小时前
深度剖析:HashMap 并发死循环与 ConcurrentHashMap 两代演进全解
java·开发语言
如意猴1 小时前
【C++】001--C++入门(1)
开发语言·c++
秋名RG1 小时前
2026/5/27 生产事故复盘与整改方案
java
hetao17338371 小时前
2026-09-01~09-04 hetao1733837 的刷题记录
c++·算法
Evand J1 小时前
【MATLAB例程,图像滤波5】 反谐波均值滑动窗口滤波(CHMF)图像降噪与质量评价,附代码下载链接
图像处理·算法·计算机视觉·matlab·均值算法·滑动窗口滤波·均值滑动
血小板要健康2 小时前
链表 阶段算法总结
java·数据结构·笔记·算法·leetcode·链表
坐吃山猪2 小时前
【多线程】CompletableFuture使用
java·开发语言·多线程
a187927218312 小时前
【算法】回溯算法(三):三记重锤与 N 皇后——记忆化、状态设计与三层漏斗
算法·leetcode·go·剪枝·回溯·n皇后·算法讲解