1. 并查集的介绍
并查集是一种树型的数据结构,用于处理一些不相交集合的合并及查询问题(即所谓的并、查)。
在一些应用问题中,需要将n个不同的元素划分成一些不相交的集合。开始时,每个元素自成一个单元素集合,然后按一定的规律将归于同一组元素的集合合并。在此过程中要反复用到查询某一个元素归属于那个集合的运算。适合于描述这类问题的抽象数据类型称为并查集(union-find set)。
比如:某公司今年校招全国总共招生10人,西安招4人,成都招3人,武汉招3人,10个人来自不同的学校,起先互不相识,每个学生都是一个独立的小团体,我们给这10个人进行编号。
每个地方的学生自发组织成小分队一起上路,于是:西安的4名同学(编号为0,6,7,8)组成了一个团体,成都的三名同学(编号为1,4,9)组成了一个团体,武汉的3名同学(编号为2,3,5)组成了一个团体。假设由编号最小的同学担任队长。
有了这个结构之后,我们很容易就能判断出来某两位同学是否属于同一个团体,并且合并两个集合也非常方便,这就是并查集的价值所在。
2. 并查集的原理
在实际中我们可以通过一个数组来实现并查集,还是上面那个例子。我们定义一个数组,这个数组的下标就是每个成员的编号,数组中的值有两层意思:
1.对队长(根节点)来说,数组中保存这个集合中所有元素的个数 * -1。
2.对成员(除了根节点的其他结点)来说,数组中保存的是他的父节点的下标。
所以数组中值 < 0 的都是根节点,根节点得绝对值是这个集合的元素个数。
最初一开始所有同学都是一个独立的团体,所以先初始化为 -1。
后续相同城市的同学组成了一个团体。
物理结构图如下:
逻辑结构图如下:
并查集可以用来解决哪些问题?
1.查找元素属于哪个集合
由于数组中保存的是他父节点的下标,所以我们可以根据这个特点,一路向上查找,直到找到一个是值为负数的结点即可
2.查找两个元素是否属于同一集合
沿着数组表示的树形关系往上一直找到树的根,如果根相同表明在同一个集合,否则不在
3.将两个集合合并成一个集合
需要做以下两步
- 将两个集合中的元素合并
- 将一个集合名称改成另一个集合的名称
比如上面的例子,{0,6,7,8}集合和{1,4,9}进行合并。
注意观察数组的变化,1号由原来的-3变成0,0号由原来的-4变成了-7。
4.集合的个数
遍历数组,数组中元素为负数的个数即为集合的个数。
3. 并查集的实现
并查集的实现通常包含如下接口:
- 1.查找父节点下标
- 2.合并两个集合
- 3.返回集合的个数
- 4.判断两个值是否在同一个集合中
class UnionFindSet
{
public:
UnionFindSet(size_t n)
: _ufs(n, -1)
{}
//返回父节点的下标
size_t FindRoot(size_t posi);
//合并两个集合
bool Union(size_t x1, size_t x2);
//返回集合的个数
size_t Count();
//判断两个值是否在同一个集合中
bool InSameSet(size_t x1, size_t x2);
private:
vector<int> _ufs;
};
并查集的特点:
1.数组的下标对应集合中元素的编号
2. 数组中如果为负数,负号代表根,数字代表该集合中元素个数
3. 数组中如果为非负数,代表该元素双亲在数组中的下标
3.1 找到根节点
size_t FindRoot(size_t posi)
{
while (_ufs[posi] >= 0)
{
posi = _ufs[posi];
}
return posi;
}
查找思路:如果一个结点是根节点,那么直接返回下标即可,如果不是根节点,那么就一路向上查找,直到找到根。
以上图为例,比如我们要查找9的根。根据数组我们可以看到9的父节点是1。继续向上查找,1号的父节点是0,到了0号,我们发现数据中保存的是-7,一个小于0的数,表示这个结点就是一个父节点。所以我们最终得到了9的根节点是0号。
3.2 合并两个集合
bool Union(size_t x1, size_t x2)
{
int root1 = FindRoot(x1);
int root2 = FindRoot(x2);
// 如果他们本身就在同一个集合中
if (root1 == root2)
return false;
// 默认将root1为较大的集合
if (_ufs[root1] > _ufs[root2])
swap(root1, root2);
//将root2合并到root1下
_ufs[root1] += _ufs[root2];
_ufs[root2] = root1;
return true;
}
先找到两个集合的根,如果这两个不在一个集合我们就进行合并,合并的方式是将一个集合当作另一个集合的根。
当然,上面的只是逻辑图,物理模型如下:
还有一点值得注意,就是我们要让root1为较大的那个集合,root2为较小的那个集合,这样做可以提高一点效率,因为我们是将root2合并到root1上的,如果root2的元素过多,root2再接到root1下,那么将会导致,root2中的所有元素每次都要多找一个值才能找到。
3.3 集合的个数
size_t Count()
{
size_t count = 0;
for (auto e : _ufs)
{
if (e < 0)
++count;
}
return count;
}
遇到一个小于0的数说明这是一个集合,每遇到一个集合直接++,最后返回最终的结果。
3.4 判断两个数是否在同一个集合
bool InSameSet(size_t x1, size_t x2)
{
int root1 = FindRoot(x1);
int root2 = FindRoot(x2);
if (root1 == root2)
return true;
else
return false;
}
直接找根即可,如果是同一个根就说明在同一个集合。
3.5 路径压缩
并查集的优势就是,能快速的查看两个数是否属于同一集合。但是有个问题,我们的合并两个集合是将一个集合直接变成另一个集合的孩子。也就是说,如果数据量较大的情况下,会导致并查集的高度变得很大,也就影响了查找效率,所以我们可以使用路径压缩的方式,减少树的高度。
路径压缩的方式是:我们使用FindRoot查找一个结点时,就把这个结点到根节点路径上的所有结点都变成根的孩子结点。
以下图为例:我们使用FIndRoot查找9号结点的根。
将9到根结点0路径上所有的结点都变成根节点0的孩子,也就是将9和1变成0的孩子。
我们发现以后再查找9所在的集合,只需找两次就行了。
//返回父节点的下标
size_t FindRoot(size_t posi)
{
int root = posi;
while (_ufs[root] >= 0)
{
root = _ufs[root];
}
//路径压缩
while (_ufs[posi] >= 0)
{
int parent = _ufs[posi];
_ufs[posi] = root;
posi = parent;
}
return posi;
}
3.6 完整代码
class UnionFindSet
{
public:
UnionFindSet(size_t n)
: _ufs(n, -1)
{}
//返回父节点的下标
size_t FindRoot(size_t posi)
{
int root = posi;
while (_ufs[root] >= 0)
{
root = _ufs[root];
}
//路径压缩
while (_ufs[posi] >= 0)
{
int parent = _ufs[posi];
_ufs[posi] = root;
posi = parent;
}
return posi;
}
bool Union(size_t x1, size_t x2)
{
int root1 = FindRoot(x1);
int root2 = FindRoot(x2);
// 如果他们本身就在同一个集合中
if (root1 == root2)
return false;
// 默认将root1为较大的集合
if (_ufs[root1] > _ufs[root2])
swap(root1, root2);
//将root2合并到root1下
_ufs[root1] += _ufs[root2];
_ufs[root2] = root1;
return true;
}
size_t Count()
{
size_t count = 0;
for (auto e : _ufs)
{
if (e < 0)
++count;
}
return count;
}
bool InSameSet(size_t x1, size_t x2)
{
int root1 = FindRoot(x1);
int root2 = FindRoot(x2);
if (root1 == root2)
return true;
else
return false;
}
private:
vector<int> _ufs;
};
4. 并查集相关题目
4.1 省份数量
题目链接:LCR 116. 省份数量 - 力扣(LeetCode)
题目描述:
解题思路:如果两个城市连接了,就将他们加入同一个集合,最终判断有几个集合即可。
方法一:使用我们自己写的并查集
class UnionFindSet
{
public:
UnionFindSet(size_t n)
: _ufs(n, -1)
{}
//返回父节点的下标
size_t FindRoot(size_t posi)
{
int root = posi;
while (_ufs[root] >= 0)
{
root = _ufs[root];
}
//路径压缩
while (_ufs[posi] >= 0)
{
int parent = _ufs[posi];
_ufs[posi] = root;
posi = parent;
}
return posi;
}
bool Union(size_t x1, size_t x2)
{
int root1 = FindRoot(x1);
int root2 = FindRoot(x2);
// 如果他们本身就在同一个集合中
if (root1 == root2)
return false;
//将root2合并到root1下
_ufs[root1] += _ufs[root2];
_ufs[root2] = root1;
return true;
}
size_t Count()
{
size_t count = 0;
for (auto e : _ufs)
{
if (e < 0)
++count;
}
return count;
}
bool InSameSet(size_t x1, size_t x2)
{
int root1 = FindRoot(x1);
int root2 = FindRoot(x2);
if (root1 == root2)
return true;
else
return false;
}
void Print()
{
for (auto e : _ufs)
{
std::cout << e << " ";
}
std::cout << std::endl;
}
private:
vector<int> _ufs;
};
class Solution {
public:
int findCircleNum(vector<vector<int>>& isConnected) {
int n = isConnected.size();
UnionFindSet ufs(n);
for (size_t i = 0; i < n; i++)
{
for (size_t j = 0; j < n; j++)
{
if (isConnected[i][j] == 1)
{
ufs.Union(i, j);
}
}
}
ufs.Print();
return ufs.Count();
}
};
发现相同省份就将他们加入同一个集合即可。
但是这种方式非常麻烦,要先手动实现一个并查集,实际上我们可以发现,我们只用到了Union函数和Count函数。
方案二:模拟并查集
我们完全可以用数组模拟出一个并查集,只需要实现其中的合并功能就行了。
class Solution {
public:
int findCircleNum(vector<vector<int>>& isConnected) {
int n = isConnected.size();
vector<int> ufs(n, -1);
auto FindRoot = [&ufs](size_t index)
{
int root = index;
while (ufs[root] >= 0)
{
root = ufs[root];
}
return root;
};
for (size_t i = 0; i < n; i++)
{
for (size_t j = 0; j < n; j++)
{
if (isConnected[i][j] == 1)
{
//先找根
int root1 = FindRoot(i);
int root2 = FindRoot(j);
if (root1 != root2)
{
ufs[root1] += ufs[root2];
ufs[root2] = root1;
}
}
}
}
size_t count = 0;
for (auto e : ufs)
{
if (e < 0)
++count;
}
return count;
}
};
4.2 等式方程的可满足性
题目链接:990. 等式方程的可满足性 - 力扣(LeetCode)
题目描述:
解题思路:如果表达式的符号为==,就将左右两个操作数加入同一个集合中,遍历一遍之后,再次遍历,找到表达式为 != 的符号,判断左右两边是否在同一个集合中,如果不满足条件则返回false,满足则返回true。
class Solution {
public:
bool equationsPossible(vector<string>& equations) {
int n = equations.size();
vector<int> ufs(26, -1);
auto FindRoot = [&ufs](size_t index)
{
while (ufs[index] >= 0)
{
index = ufs[index];
}
return index;
};
for (auto& str : equations)
{
if (str[1] == '=')
{
int root1 = FindRoot(str[0] - 'a');
int root2 = FindRoot(str[3] - 'a');
if (root1 != 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;
}
};