总言
主要内容:并查集理解及简单模拟实现(Union、Find、优化策略)。
文章目录
1、并查集原理
1.1、基本介绍
1)、概念介绍
场景引入: 在一些应用问题中,需要将n个不同的元素划分成一些不相交的集合。开始时,每个元素自成一个单元素集合,然后按一定的规律将归于同一组元素的集合合并。在此过程中要反复用到查询某一个元素归属于哪个集合的运算。 适合于描述这类问题的抽象数据类型称为并查集(union-find set)。
并查集(Union-Find):一种树型的数据结构,常常以森林来表示。主要用于处理一些不相交集合(Disjoint Sets)的合并及查询问题,也可以用来解决连通性问题,如判断图中的两个节点是否连通,或者将多个连通分量合并成一个连通分量。
2)、并查集的存储结构
并查集是使用一个数组来存储每个元素所属集合的代表元素(通常称为父节点或根节点)。其存储结构可以通过 树的双亲表示法 来理解,有如下特点:
1、数组的下标对应集合中元素的编号。
2、若数组中存储的是负数,该处对应元素代表树的根,该负数的绝对值代表这个集合中元素总数。
3、若数组中存储的是非负数,则代表这个元素的双亲在数组中的下标位置(可通过该下标,找到当前元素的双亲)。
1.2、功能说明
1)、并查集的主要操作
初始化: 为每个元素创建一个独立的集合,其代表元素为自身。
查找(Find): 查找一个元素所属的集合的代表元素(根节点)。
合并(Union): 将两个元素所属的集合合并成一个集合。
通过这些操作,并查集可以有效地维护集合的合并和查询操作,并在需要时提供有关集合连通性的信息。
2)、查集一般可以解决以下问题
1、查找元素属于哪个集合: 沿着数组表示树形关系以上一直找到根(即:树中中元素为负数的位置)。
2、查看两个元素是否属于同一个集合 :沿着数组表示的树形关系往上一直找到树的根,如果根相同表明在同一个集合,否则不在。
3、将两个集合归并成一个集合:将两个集合中的元素合并,将一个集合名称改成另一个集合的名称。
4、统计集合的个数:遍历数组,数组中元素为负数的个数即为集合的个数。
2、并查集实现
2.1、框架搭建:初始化
1)、一个前提说明
以下是对并查集的简单实现。说明:
1、若实际存储的是字符串或者其它类型的元素,则:①由编号ID找元素内容,因vector下标直接对应,相对方便;②由元素内容找编号ID,可引入Map等容器进行映射辅佐。如下所示:
#pragma once
#include<iostream>
#include<vector>
#include<map>
template<class T>
class UnionFindSet
{
public:
UnionFindSet(const T* arr, size_t n)
{
for (size_t i = 0; i < n; ++i)
{
_ufs.push_back(arr[i]);
_indexMap[arr[i]] = i;
}
}
private:
std::vector<T> _ufs;
std::map<T, int> _indexMap;
};
这里 我们只是实现int类型的并查集(不引入映射关系等)。
2)、演示框架:并查集的初始化
并查集的初始化: 每个元素自成一个单元素子集合,每个子集合的数组值为-1
。
class UnionFindSet
{
public:
//构造:一共有多少个值
UnionFindSet(size_t size)
:_set(size, -1)
{}
private:
std::vector<int> _set;
};
并查集中主要的接口如下: 最主要的是Union和Find
// 将两个集合中元素合并
void Union(int x1, int x2);
// 给一个元素的编号,找到该元素所在集合的根
int FindRoot(int x);
// 判断两个元素是否在同一集合
bool InSet(int x1, int x2);
// 并查集中的集合个数
size_t SetSize();
2.2、FindRoot:查找元素属于哪个集合(找根)
函数 FindRoot 的实现基于递归或迭代的思想(也可以改写成递归模式),它通过一个循环来不断查找元素 x 的父节点,直到找到一个代表元素(即根节点)为止。
// 查找元素属于哪个集合(找根)
int FindRoot(int x)
{
while (_ufs[x] >= 0)
{
x = _ufs[x];
}
return x;
}
特别说明: 这个查找过程的时间复杂度在最坏情况下是 O(n),其中 n 是并查集中元素的数量。在实际应用中,通常会采用路径压缩优化技术,即在查找过程中,将路径上的所有节点直接指向根节点,从而减少后续查找操作的时间复杂度(后续讲解)。
2.3、Union:将两个集合归并成一个集合(合并)
在并查集中,合并两个集合通常意味着将两个原本不相交的集合(或称为连通分量)连接成一个更大的集合。合并操作通过修改并查集中元素的父节点,追溯到根结点来实现。
宏观上思路不变,具体实现细节看个人。比如,如果有需求,也可以对两root进行大小判断,用小根合并大根,或用大根合并小根(并查集中对此无要求)。
// 合并两个集合
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;
}
优化(可选): 为了提高后续查找操作的效率,可以在合并时应用一些优化技术,如路径压缩或按秩合并。
①路径压缩是指在合并过程中,将合并路径上的所有节点直接指向新的根节点,从而缩短后续查找操作的路径长度。
②按秩合并则是根据集合的大小或秩来决定合并时哪个集合成为新的父集合,以减少树的高度。
2.4、InSert:是否在相同集合中
只用判断两元素的根是否相同,即可判断这两元素是否在相同集合中。
bool InSet(int x1, int x2)
{
return FindRoot(x1) == FindRoot(x2);
}
2.5、SetSize:有几个集合(树的数量)
遍历数组找负元素即可: 在并查集中,判断有几个集合(或连通分量)通常可以通过遍历并查集的所有元素,并统计不同的根节点的数量来实现(每个根节点代表一个独立的集合)。
size_t SetSize()
{
size_t count = 0;
for (auto x : _ufs)
{
if (x < 0)
++count;
}
return count;
}
2.6、优化策略
2.6.1、几种常见的并查集优化策略
1)、举例如下
1、路径压缩:
在查找过程中,不仅找到元素的根节点,还将查找路径上的所有节点直接连接到根节点。这样,树的高度得到显著降低,从而提高了后续查找操作的效率。
需要注意的是,路径压缩虽然优化了查找操作,但可能会略微增加合并操作的开销,因为它改变了树的结构。
2、按秩合并:
每个节点维护一个秩的值,表示以该节点为根的子树的高度(或其他表示树大小的度量)。
在合并两个集合时,将秩较小的树合并到秩较大的树上,以减少树的高度。这有助于保持树的平衡,减少合并后的树的高度。
3、按大小合并:
与按秩合并类似,但使用子树的大小(即集合中元素的数量)作为合并的依据。
将元素数量较少的集合合并到元素数量较多的集合中,以避免产生过深的树。
4、基于哈希表的并查集:
对于大规模数据集合,可以使用哈希表来加速查找和合并操作。
每个元素映射到哈希表中的某个位置,通过哈希表的查找和插入操作来实现并查集的合并与查询。
5、懒惰合并:
在某些应用中,可能不需要立即合并所有集合。可以延迟合并操作,直到真正需要合并时再进行。
这种策略可以减少不必要的合并操作,提高整体效率。
6、离线处理:
如果所有操作都是预先知道的(例如,在算法竞赛中),可以通过离线处理来优化并查集的性能。
通过分析所有操作序列,可以预先确定某些合并操作的顺序或方式,以减少树的高度或合并操作的次数。
7、并查集的分裂与合并:
在某些情况下,可能需要动态地分裂和合并集合。这可以通过维护额外的数据结构(如辅助树或链表)来实现,以支持高效的分裂和合并操作
这些优化技术可以单独使用,也可以结合使用,以达到更好的性能效果。
下述优化中,我们考虑两方面:
1、FindRoot查找根时,进行路径压缩;
2、Union合并集合时,将元素数量较少的集合合并到元素数量较多的集合中。
2.6.2、路径压缩
1)、演示例子
如下述例子: 所形成的并查集汇总,存在树的深度很深的集合。如果保持不变,会导致每次查询时,效率消耗。
int arr[] = { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 };
int n = sizeof(arr) / sizeof(arr[0]);
UnionFindSet ufs(n);
ufs.Union(0, 1);
ufs.Union(0, 2);
ufs.Union(8, 9);
ufs.Union(7, 8);
ufs.Union(6, 7);
ufs.Union(5, 6);
ufs.Union(4, 5);
因此,对进行路径压缩可以帮助我们解决查找操作过程中的效率问题。
写法如下:
// 查找元素属于哪个集合(找根)
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;
}
优化效果如下:
2.6.3、按大小合并
// 合并两个集合
void Union(int x1, int x2)
{
// 找两元素的根
int root1 = FindRoot(x1);
int root2 = FindRoot(x2);
// 若给定的两元素在同一集合,没必要合并。
if (root1 == root2)
return;
// 合并两集合,是从根开始对集合进行合并(注意修改合并后元素个数,以及存储位置的下标)
// 优化策略2:按大小合并。将元素数量较少的集合合并到元素数量较多的集合中,以避免产生过深的树。
if (abs(_ufs[root1]) < abs(_ufs[root2]))// 默认root1集合的数量大于root2,若不满足,交换。
std::swap(root1, root2);
_ufs[root1] += _ufs[root2];
_ufs[root2] = root1;
}
降低树的高度: 在并查集中,我们通常使用树形结构来表示集合之间的关系。每个集合的根节点代表该集合,而其他节点则通过指向父节点的指针与根节点相连。当我们将一个较小的集合合并到一个较大的集合时,我们实际上是将较小集合的根节点连接到较大集合的某个节点上。这样做的好处是,它有助于保持整个并查集的结构相对扁平,即树的深度较小。较小的树深度意味着在进行查找操作时,我们需要遍历的节点数量较少,从而提高了查找效率。
减少合并操作的开销: 当我们将一个较小的集合合并到一个较大的集合时,我们需要更新的节点数量相对较少。相反,如果我们总是将较大的集合合并到较小的集合,那么每次合并都可能需要更新大量的节点,导致合并操作的开销增加。通过优先合并较小的集合,我们可以降低合并操作的复杂度,提高整体性能。
平衡树的形态: 通过优先合并较小的集合,我们可以在一定程度上保持树的平衡。虽然并查集并不总是能够完全平衡树的形态(特别是当我们不使用额外的优化策略时),但这种合并策略有助于减少树中过深或过长的路径,从而提高操作的平均效率。
2.7、整体实现汇总
#pragma once
#include<iostream>
#include<vector>
#include<map>
class UnionFindSet
{
public:
//构造:一共有多少个值
UnionFindSet(size_t size)
:_ufs(size, -1)
{}
// 合并两个集合
void Union(int x1, int x2)
{
// 找两元素的根
int root1 = FindRoot(x1);
int root2 = FindRoot(x2);
// 若给定的两元素在同一集合,没必要合并。
if (root1 == root2)
return;
// 合并两集合,是从根开始对集合进行合并(注意修改合并后元素个数,以及存储位置的下标)
// 优化策略:按大小合并。将元素数量较少的集合合并到元素数量较多的集合中,以避免产生过深的树。
// 默认root1集合的数量大于root2,若不满足,交换。
if (abs(_ufs[root1]) < abs(_ufs[root2]))
std::swap(root1, root2);
_ufs[root1] += _ufs[root2];
_ufs[root2] = root1;
}
// 查找元素属于哪个集合(找根)
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;
}
bool InSet(int x1, int x2)
{
return FindRoot(x1) == FindRoot(x2);
}
size_t SetSize()
{
size_t count = 0;
for (auto x : _ufs)
{
if (x < 0)
++count;
}
return count;
}
private:
std::vector<int> _ufs;
};
3、并查集应用举例
3.1、省份数量
3.1.1、题解:利用并查集
version1,可以利用上述写的并查集类来求解:
class Solution {
public:
int findCircleNum(vector<vector<int>>& isConnected) {
//创建一个并查集用于后续判断省份
UnionFindSet ufs(isConnected.size());
//遍历n×n的数组,若ij处为1,则表示当前ij两城市在一个省份,需要合并。
for(int i = 0; i < isConnected.size(); ++i)
{
for(int j = 0; j < isConnected[0].size(); ++j)
{
if(isConnected[i][j] == 1)
ufs.Union(i,j);
}
}
//返回集合数目,即省份数目
return ufs.SetSize();
}
};
class UnionFindSet
{
public:
//构造:一共有多少个值
UnionFindSet(size_t size)
:_ufs(size, -1)
{}
// 合并两个集合
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]))
// std::swap(root1, root2);
_ufs[root1] += _ufs[root2];
_ufs[root2] = root1;
}
// 查找元素属于哪个集合(找根)
int FindRoot(int x)
{
while (_ufs[x] >= 0)
{
x = _ufs[x];
}
return x;
}
bool InSet(int x1, int x2)
{
return FindRoot(x1) == FindRoot(x2);
}
size_t SetSize()
{
size_t count = 0;
for (auto x : _ufs)
{
if (x < 0)
++count;
}
return count;
}
private:
std::vector<int> _ufs;
};
version2,实际上这里并不需要将整个并查集实现,我们只需要借助并查集的功能特性即可。因此,可以手动控制并查集:查找是否属于同一身份、若属于则将其合并,最后返回合并后的集合个数。
class Solution {
public:
int findCircleNum(vector<vector<int>>& isConnected) {
// 手动控制并查集
vector<int> ufs(isConnected.size(), -1);
// 查找根
auto findRoot = [&ufs](int x)
{
while(ufs[x] >=0 ) x = ufs[x];
return x;
};
for(int i = 0; i < isConnected.size(); ++i)
{
for(int j = 0; j < isConnected[0].size(); ++j)
{
if(isConnected[i][j] == 1)
{ //找根,判断是否需要合并集合
int root1 = findRoot(i);
int root2 = findRoot(j);
if(root1 != root2)
{
ufs[root1] += ufs[root2];
ufs[root2] = root1;
}
}
}
}
int n = 0;//获取集合个数
for(auto e: ufs)
{
if(e < 0) ++n;
}
return n;
}
};
3.2、等式方程的可满足性
题源:链接
3.2.1、题解:利用并查集
由于等式相等具有传递性,比较容易想到使用并查集:
1、扫描所有等式,将等式两边的顶点进行合并;
2、再扫描所有不等式,检查每一个不等式的两个顶点是不是在一个连通分量里,如果在,则返回 false 表示等式方程有矛盾。如果所有检查都没有矛盾,返回 true。
class Solution {
public:
bool equationsPossible(vector<string>& equations) {
//这里的元素是字符串,且只有26个英文字母,并查集处可直接建立ASCII码映射
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[root2] = root1;
}
}
}
//第二遍遍历:查找不等式,判断其左右元素是否在同一集合,若在,则相悖
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;
}
};