C++ 并查集详解:原理、实现与应用

本篇目标:

1.并查集的基本原理;

2.并查集的存储方式;

3.并查集各个接口的实现与作用;

4.并查集的合并与查找优化;

5.使用并查集省份数量问题。

一. 并查集原理

1.概念理解

在一些实际问题中,需要将 n个不同的元素划分成若干个互不相交的集合。

开始时,每个元素各自属于一个独立的集合。随着元素之间建立联系,需要不断将两个集合合并 。在这个过程中,还需要反复查询某个元素属于哪个集合 ,或者判断两个元素是否属于同一个集合。

适合描述这类问题的数据结构,称为并查集:

英文名称为 Disjoint Set Union,简称 DSU,也经常称为 Union-Find Set。

"并查集"这个名称对应了它最核心的两类操作:

  • 并:将两个集合合并;

  • 查:查找某个元素所属集合的根节点。

2.理解并查集

然后我们通过一个实例来了解并查集,

假设某公司今年在全国校招了10名学生:

  • 西安招聘4人;

  • 成都招聘3人;

  • 武汉招聘3人。

这10名学生来自不同的学校,开始时互不相识。现在按照下面的方式给他们编号:

cpp 复制代码
{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}

一开始,每名学生都是一个独立的小团体:

cpp 复制代码
{0} {1} {2} {3} {4} {5} {6} {7} {8} {9}

此时一共有10个集合。

可以使用一个数组保存这些集合:

cpp 复制代码
下标: 0  1  2  3  4  5  6  7  8  9
数据:-1 -1 -1 -1 -1 -1 -1 -1 -1 -1

数组中的每个位置对应一名学生。数组中的**-1表示**:

  1. 当前元素是所在集合的根节点;

  2. 当前集合一共有1个元素。

毕业后,同一个城市的学生决定结伴前往公司,于是形成了三个小分队:

cpp 复制代码
西安小分队:s1 = {0, 6, 7, 8}
成都小分队:s2 = {1, 4, 9}
武汉小分队:s3 = {2, 3, 5}

假设编号为0、1、2的学生分别担任三个小分队的队长,那么三个集合 可以表示为:

cpp 复制代码
0
├── 6
├── 7
└── 8

1
├── 4
└── 9

2
├── 3
└── 5

对应的数组可以表示为:

cpp 复制代码
下标: 0  1  2  3  4  5  6  7  8  9
数据:-4 -3 -3  2  1  2  0  0  0  1

其中:

  • _ufs[0] == -4,说明0是根节点,以0为根的集合有4个元素;

  • _ufs[1] == -3,说明1是根节点,以1为根的集合有3个元素;

  • _ufs[2] == -3,说明2是根节点,以2为根的集合有3个元素;

  • _ufs[6] == 0,说明6的父节点是0;

  • _ufs[4] == 1,说明4的父节点是1;

  • _ufs[3] == 2,说明3的父节点是2。

由此可以总结出并查集数组的存储规则。

并查集数组的含义:

cpp 复制代码
数组下标表示集合中的元素编号;

如果数组中的值为负数,说明该元素是根节点;

根节点位置的绝对值表示集合中的元素数量;

如果数组中的值为非负数,表示该元素父节点的下标。

例如:

cpp 复制代码
_ufs[0] == -4

表示0是根节点,并且0所在的集合中有4个元素。

cpp 复制代码
_ufs[6] == 0

表示6不是根节点,6的父节点是0。

3 集合的合并

在公司工作一段时间后,西安小分队中的8号同学和成都小分队中的1号同学相互认识。

通过他们之间的联系,西安小分队和成都小分队也互相认识了,因此两个朋友圈可以合并成一个更大的朋友圈:

cpp 复制代码
{0, 6, 7, 8} + {1, 4, 9}

合并后的集合为:

cpp 复制代码
{0, 1, 4, 6, 7, 8, 9}

假设将以1为根的集合合并到以0为根的集合中,树形关系可以表示为:

cpp 复制代码
0
├── 6
├── 7
├── 8
└── 1
    ├── 4
    └── 9

对应数组变为:

cpp 复制代码
下标: 0  1  2  3  4  5  6  7  8  9
数据:-7  0 -3  2  1  2  0  0  0  1

此时:

  • _ufs[0] == -7,表示以0为根的集合有7个元素;

  • _ufs[1] == 0,表示1的父节点变成了0;

  • _ufs[2] == -3,表示以2为根的集合有3个元素。

10名学生现在形成了两个朋友圈:

cpp 复制代码
朋友圈一:{0, 1, 4, 6, 7, 8, 9}
朋友圈二:{2, 3, 5}

4. 并查集的用处

通过上面的例子,可以看出并查集主要支持以下操作。

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

沿着父节点不断向上查找,直到找到数组值为负数的位置。

这个位置就是集合的根节点,也可以看作集合的名称。

例如查找9属于哪个集合:

复制代码
9 → 1 → 0

由于 _ufs[0] 为负数,所以0是9所在集合的根节点。

<2>. 判断两个元素是否属于同一个集合

分别查找两个元素的根节点:

  • 如果根节点相同,说明两个元素属于同一个集合;

  • 如果根节点不同,说明两个元素属于不同集合。

例如:

cpp 复制代码
FindRoot(4) == 0
FindRoot(8) == 0

所以4和8属于同一个集合。

而:

cpp 复制代码
FindRoot(4) == 0
FindRoot(3) == 2

所以4和3不属于同一个集合。

<3>. 合并两个集合

先找到两个元素各自的根节点。

如果根节点不同,就把一个集合的根节点连接到另一个集合的根节点上,同时更新合并后集合的元素数量。

<4>. 统计集合数量

并查集中,每个集合有且只有一个根节点。

根节点在数组中的值为负数,因此只需要统计数组中负数的数量,就可以得到集合数量。

2. 并查集实现

下面使用 vector<int> 实现一个基本的并查集。

下面分别实现每个接口。

2.1 构造函数

cpp 复制代码
UnionFindSet(size_t n)
    : _ufs(n, -1)
{
}

构造函数负责创建一个包含 n个元素的并查集。

刚开始时,每个元素都是一个独立的集合,因此数组中的所有元素都初始化为-1。

例如:

cpp 复制代码
UnionFindSet ufs(5);

内部数组初始化为:

cpp 复制代码
下标: 0  1  2  3  4
数据:-1 -1 -1 -1 -1

这表示当前存在5个集合:

cpp 复制代码
{0} {1} {2} {3} {4}

每个位置都是根节点,每个集合都只有一个元素。

为什么初始化为负数?

并查集使用正负号区分两种情况:

  • 负数:当前元素是根节点;

  • 非负数:当前数值是父节点下标。

-1的绝对值是1,表示当前集合中只有一个元素。

2.2 查找根节点

cpp 复制代码
int FindRoot(size_t x)
{
	size_t root = x;
	while (_ufs[root] >= 0)
	{
		root = _ufs[root];
	}

	return root;
}

接口作用:

FindRoot用于查找某个元素所在集合的根节点。

参数 index 是元素编号,返回值是该元素所在集合的根节点编号。

查找过程:

假设数组中存在下面的父子关系:

cpp 复制代码
    0
   /
  1
 /
9

查找9的根节点时:

  1. _ufs[9] == 1,说明9的父节点是1;

  2. _ufs[1] == 0,说明1的父节点是0;

  3. _ufs[0] < 0,说明0是根节点;

  4. 返回0。

过程如图:

为什么以负数作为结束条件?

cpp 复制代码
只有根节点位置保存的是负数,其他节点位置保存的是父节点下标,而数组下标不可能是负数,因此,只要找到负数,
就说明已经找到了集合的根。

时间复杂度:

cpp 复制代码
查找所需时间与树的高度有关,如果树比较矮,查找速度就很快;如果树退化成一条长链,查找速度就会下降。

2.3 合并两个集合

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

    if (root1 == root2)
    {
        return;
    }

    _ufs[root1] += _ufs[root2];
    _ufs[root2] = root1;
}

接口作用:

Union用于合并 x1 和 x2 所在的集合。

<1>.分别查找根节点

cpp 复制代码
int root1 = FindRoot(x1);
int root2 = FindRoot(x2);

并查集不能简单地把 x2 的父节点设置为 x1,因为 x1 和 x2 不一定是根节点。

正确做法是先找到两个集合的根节点,再合并两棵树。

<2>.判断是否已经在同一个集合

cpp 复制代码
if (root1 == root2)
{
    return;
}

如果两个元素的根节点相同,说明它们已经属于同一个集合。

此时如果继续合并,可能破坏集合大小等信息,所以直接返回false。

<3>.更新集合大小

cpp 复制代码
_ufs[root1] += _ufs[root2];

两个根节点位置保存的都是负数。

假设:

cpp 复制代码
_ufs[root1] == -4
_ufs[root2] == -3

合并后:

cpp 复制代码
_ufs[root1] = -4 + -3 = -7

表示合并后的集合一共有7个元素。

<4>.修改根节点关系

cpp 复制代码
_ufs[root2] = root1;

这一步表示将 root2 所在的集合合并到 root1 所在的集合中。

执行后,root2不再是根节点,它的父节点变成了 root1。

2.4 统计集合数量

cpp 复制代码
std::size_t Count() const
{
    std::size_t count = 0;

    for (int value : _ufs)
    {
        if (value < 0)
        {
            ++count;
        }
    }

    return count;
}

接口作用:

Count用于统计并查集中当前存在多少个互不相交的集合。

统计原理:

每个集合有且只有一个根节点,而根节点对应的数组值一定是负数。

因此,只需要遍历数组,统计负数的数量即可。

例如:

cpp 复制代码
下标: 0  1  2  3  4  5  6  7  8  9
数据:-7  0 -3  2  1  2  0  0  0  1

数组中只有两个负数:

cpp 复制代码
-7和-3

所以当前共有两个集合。

3. 并查集的优化

基本并查集已经可以正确完成查找和合并操作,但如果合并方式不合理,树可能变得很高。

例如:

cpp 复制代码
0 → 1 → 2 → 3 → 4 → 5

查找0的根节点需要经过多个节点。

常见的优化方式包括:

  1. 按集合大小合并;

  2. 路径压缩。

3.1 按集合大小合并

合并两个集合时,将元素数量较少的集合连接到元素数量较多的集合上。

这样可以尽量避免树的高度快速增加。

因为根节点保存的是集合大小的相反数,所以:

  • 数值越小,绝对值越大,集合越大;

  • 数值越大,绝对值越小,集合越小。

例如:

cpp 复制代码
-7 < -3

说明保存-7的集合更大。

合并时可以增加下面的判断:

cpp 复制代码
if (_ufs[root1] > _ufs[root2])
{
    swap(root1, root2);
}

这样可以保证 root1 表示较大的集合,然后把 root2 合并到 root1 中。

3.2 路径压缩

查找根节点后,可以将查找路径上的所有节点直接连接到根节点。

例如,原来的关系为:

cpp 复制代码
9 → 4 → 1 → 0

完成路径压缩后变成:

cpp 复制代码
9 → 0
4 → 0
1 → 0

下一次查找这些元素时,就可以更快地找到根节点。

路径压缩版 FindRoot 可以写成:

cpp 复制代码
int FindRoot(int index)
{
    int root = index;

    // 先找到根节点
    while (_ufs[root] >= 0)
    {
        root = _ufs[root];
    }

    // 将查找路径上的节点直接连接到根节点
    while (index != root)
    {
        int parent = _ufs[index];
        _ufs[index] = root;
        index = parent;
    }

    return root;
}

同时采用按集合大小合并和路径压缩后,并查集单次操作的均摊时间复杂度非常接近 (O(1))。

4. 并查集应用

题目网址 :547. 省份数量 - 力扣(LeetCode)https://leetcode.cn/problems/number-of-provinces/description/

4.1.省份问题

<1>.题目描述

给定一个 n*n 的矩阵 isConnected。

如果:

cpp 复制代码
isConnected[i][j] == 1

表示第 i 个城市和第 j 个城市直接相连。

如果两个城市直接或间接相连,那么它们属于同一个省份。

要求计算省份总数。

例如:

cpp 复制代码
城市0和城市1相连
城市1和城市2相连

即使城市0和城市2没有直接连接,它们仍然可以通过城市1间接连接,因此三个城市属于同一个省份。

<2>. 解题思路

可以将每个城市看作并查集中的一个元素。

开始时,每个城市都是一个独立集合,所以一共有 n 个省份。

然后遍历连接矩阵:

  • 如果 isConnected[i][j] == 1,说明城市 i 和城市 j 相连;

  • 查找两个城市的根节点;

  • 如果根节点不同,就合并两个集合;

  • 遍历结束后,并查集中剩余的集合数量就是省份数量。

为什么可以使用并查集?

因为题目中的"省份"本质上就是一个连通集合。同一个连通集合中的所有城市,不管是直接相连还是间接相连,最后都会被合并到同一个并查集集合中。

<3> 代码实现

cpp 复制代码
class UnionFindSet
{
public:
	UnionFindSet(size_t n)
		:_ufs(n,-1)
	{ }
	void Union(int x1, int x2)
	{
		int root1 = Findroot(x1);
		int root2 = Findroot(x2);
		if (root1 == root2)
		{
			return;
		}

		_ufs[root1] += _ufs[root2];
		_ufs[root2] = root1;
	}
	int Findroot(int x)
	{
		int child = x;
		while (_ufs[child] > 0)
		{
			child = _ufs[child];
		}

		return child;
	}
	int SetSize()
	{
		int size = 0;
		for (size_t i = 0; i < _ufs.size(); i++)
		{
			if (_ufs[i] < 0)
			{
				size++;
			}
		}

		return size;
	}
private:
	std::vector<int> _ufs;
};

class Solution 
{
public:
    int findCircleNum(vector<vector<int>>& isConnected) 
    {
        int n=isConnected.size();
        UnionFindSet _ufs(n);
        for(int i=0;i<n;i++)
        {
            for(int j=i+1;j<n;j++)
            {
                if(isConnected[i][j]==1)
                {
                    _ufs.Union(i,j);
                }
            }
        }

        return _ufs.SetSize();
    }
};

<4> 为什么从 j = i + 1 开始遍历

连接矩阵通常是对称矩阵:

cpp 复制代码
isConnected[i][j] == isConnected[j][i]

如果城市0和城市1相连,那么:

cpp 复制代码
isConnected[0][1] == 1
isConnected[1][0] == 1

只需要处理其中一个位置即可。

另外,矩阵主对角线上的元素通常都是1:

cpp 复制代码
isConnected[i][i] == 1

它表示城市和自己相连,不需要进行合并。

因此,可以只遍历矩阵的上三角部分:

cpp 复制代码
for (std::size_t j = i + 1; ...)

这样能够避免重复处理。

4.2. 等式方程的可满足性

题目网址 :990. 等式方程的可满足性 - 力扣(LeetCode)https://leetcode.cn/problems/satisfiability-of-equality-equations/description/

<1>. 题目描述

给定一个字符串数组 equations,每个字符串表示两个小写字母之间的关系。

表达式只有两种:

cpp 复制代码
a==b
a!=b

其中:

  • a==b表示变量 a 和变量 b 相等;

  • a!=b表示变量 a 和变量 b 不相等。

要求判断是否可以为这些变量赋值,使所有表达式同时成立。

例如:

cpp 复制代码
["a==b", "b==c", "a==c"]

可以满足,因为 a、b、c 可以取相同的值。

而:

cpp 复制代码
["a==b", "b==c", "a!=c"]

无法满足。

前两个表达式可以推出:

cpp 复制代码
a == b == c

但第三个表达式要求:

cpp 复制代码
a != c

两者发生矛盾。

<2> 解题思路

题目中的变量只有26个小写字母,因此可以建立一个包含26个元素的并查集:

cpp 复制代码
a → 0
b → 1
c → 2
...
z → 25

整体处理分为两步。

第一步:处理所有相等关系

遍历所有表达式,将 == 两端的变量合并到同一个集合中。

例如:

cpp 复制代码
a==b
b==c

处理后:

cpp 复制代码
{a, b, c}

它们拥有相同的根节点。

第二步:处理所有不等关系

再次遍历所有表达式,检查 != 两端的变量。

如果两个变量的根节点相同,说明前面的相等关系已经确定它们必须相等,但当前表达式又要求它们不相等,产生矛盾,返回false。

如果所有不等关系都没有发生冲突,就返回true。

<3> 为什么必须遍历两遍

不能按照表达式出现的顺序,一边处理相等关系,一边立即判断不等关系。

例如:

cpp 复制代码
["a!=c", "a==b", "b==c"]

如果第一次看到 a!=c 时立即判断,此时 a 和 c 还不在同一个集合中,看起来没有矛盾。

但是处理完:

cpp 复制代码
a==b
b==c

之后,可以推出:

cpp 复制代码
a==c

此时就与 a!=c 冲突了。

因此必须:

  1. 先处理所有==,建立完整的相等关系;

  2. 再处理所有!=,检查是否存在矛盾。

<4> 代码实现

cpp 复制代码
class Solution 
{
public:
    bool equationsPossible(vector<string>& equations) 
    {
        vector<int> ufs(26,-1);
        auto findroot=[&ufs](int x){
            while(ufs[x]>=0)
            {
                x=ufs[x];
            }
            return x;
        };
        //将相等的放在一块
        for(auto&str:equations)
        {
            if(str[1]=='=')
            {
                int root1=findroot(str[0]-'a');
                int root2=findroot(str[3]-'a');
                if(root1!=root2)
                {
                    ufs[root1]+=ufs[root2];
                    ufs[root1]=root2;
                }
            }
        }
        //本次看看不相等的,如果不相等的root1和root2是同一个父节点
        //就说明之前在相等里面是相等的,说明发起了冲突
        for(auto&str:equations)
        {
            if(str[1]=='!')
            {
                int root1=findroot(str[0]-'a');
                int root2=findroot(str[3]-'a');
                if(root1==root2)
                {
                    return false;
                }
            }
        }

        return true;
    }
};

<5> 示例分析

给定:

cpp 复制代码
["a==b", "b!=c", "c==a"]

第一遍处理相等关系。

处理:

复制代码
a==b

得到:

复制代码
{a, b}

处理:

复制代码
c==a

得到:

复制代码
{a, b, c}

第二遍检查:

复制代码
b!=c

此时 b 和 c 的根节点相同,说明它们必须相等,但表达式要求它们不相等,所以返回:

复制代码
false

总结:

<1>.并查集是一种专门处理集合合并与查询问题的数据结构。

它使用数组表示森林结构:

  • 数组下标表示元素编号;

  • 根节点位置保存负数;

  • 负数的绝对值表示集合大小;

  • 非根节点位置保存父节点下标。

<2>.并查集的核心接口包括:

构造函数:初始化并查集,使每个元素分别属于一个独立集合。

FindRoot:沿着父节点向上查找,获得元素所在集合的根节点。

Union:查找两个元素的根节点。如果根节点不同,就将两个集合合并。

Count:统计数组中负数的数量,从而得到集合数量。

<3>.并查集主要用于解决以下类型的问题:

  • 判断两个元素是否属于同一个集合;

  • 合并两个集合;

  • 统计互不相交的集合数量;

  • 判断无向图中的连通关系;

  • 统计连通分量;

  • 处理具有传递性的等价关系。

<4>.在"省份数量"问题中,一个省份就是一个城市连通集合。将所有直接相连的城市合并后,并查集中的集合数量就是省份数量。

在"等式方程的可满足性"问题中,所有相等的变量应该属于同一个集合。先合并全部相等关系,再检查不等关系两端是否落在同一个集合中,就可以判断方程之间是否存在矛盾。

如果再结合按集合大小合并和路径压缩,并查集的操作效率会非常高,是解决连通性和集合合并问题时非常重要的数据结构。

相关推荐
蛋蛋的就会顺顺的1 小时前
hot100——二叉树
java·数据结构·算法·leetcode·力扣
Code_Solitude2 小时前
c语言:二分法的完整补充
c语言·开发语言·算法
一切皆是因缘际会2 小时前
掌控信息论:同源星际通信基础理论
人工智能·算法·ai
智者知已应修善业2 小时前
【51单片机只有一个8*8点阵数据如何滚动起来一片595+一组IO+花样】2022-1-15
c++·经验分享·笔记·单片机·蓝桥杯·硬件架构
水饺编程2 小时前
第1章,[Win32 章节]:Windows 简史
c语言·c++·visual studio
福大大架构师每日一题3 小时前
Rust 1.99.0发布:C 可变参数、裸函数、Cargo 配置、Rustdoc 性能与大量兼容性调整全解析
c语言·开发语言·rust
动词ing4 小时前
【C语言题目练习】算法 整数反转
c语言·开发语言·算法
无名猿4 小时前
手写一个线程池:任务队列、工作线程与停止顺序的三个设计点
c++·性能优化·并发编程·现代c++
Nil2084 小时前
leetcode 55跳跃游戏
算法·leetcode·游戏