目录
0.引例
1.并查集的原理
2.并查集的存储结构
3.并查集的实现
并查集接口总览
构造函数
查找元素属于哪个集合
判断是否属于同一个集合
合并两个集合
集合的个数
4.并查集完整代码附录
5.并查集在OJ中的应用
省份数量
等式方程的可满足性
0.引例
在我们刚上大学的时候,假如一个宿舍10个人,分别来自广东、湖南、江西;一开始谁也不认识谁,于是广东的只和广东的一起玩,湖南的只和湖南的一起玩,江西的只和江西的一起玩,毕竟是老乡嘛~ ,于是一个宿舍整体被划分成了三个小团体。
为了表示方便,我们给10个人编号,从0开始,于是10个人的编号依次为 0~9:
于是,我们可以很直观的看出谁来自于哪里。
后来呢,湖南的小伙伴和江西的小伙伴因为共同的口味,都喜欢吃辣的东西,玩在了一块,于是这两个小集体组成了一个大的集体。
我们可以这样划分:
于是,我们可以简单直观的看出爱吃辣的有哪些同学,不爱吃辣的有哪些同学。
当我们划分好了集合之后,就能很方便的进行元素和集合之间、集合和集合之间的操作了,适合进行这些操作的数据结构就是并查集。
1.并查集的原理
原理:将n个不同的元素划分成一些不相交的集合,开始时,每个元素自成一个单元素集合,然后按一定的规律将属于同一组元素的集合合并;在此过程中要反复用到查询某一个元素归属于那个集合的运算。(适合描述这种问题的抽象数据结构就叫做 —— 并查集)
简单来说,并查集就是能够快速判断一个元素位于哪个集合中,并且能够根据指定条件将不同的集合进行合并的这么一种数据结构。比如上述引例中,根据地域将10个单集合合并成了三个集合,根据是否爱吃辣,把湖南和江西的同学合并成了一个集合。反之,划分好集合之后,我们也可以快速判断一个同学是否爱吃辣?来自哪里?
2.并查集的存储结构
要想实现一个并查集,首先需要确定其底层存储数据的结构,我们使用顺序表,也就是数组来存储。并查集的底层结构和堆类似,用下标表示当前结点和双亲结点之间的关系(重要)。
使用数组存储的理由如下:
- 使用并查集的时候需要对每个元素从0开始编号,而数组天然就是从0开始编号的结构。
- 数组支持随机访问,查找效率高。
数组各字段的含义:
- 数组的下标对应集合中元素的编号
- 数组中如果为负数,负号代表根,数字代表该集合中元素个数(一般选择编号最小的为根)
- 数组中如果为非负数,代表该元素双亲在数组中的下标
以引例为例说明一下:
- 一开始,为每个元素从0开始编号,并且每个元素都是一个集合,并且都代表该集合的根。
- 把引例中的集合转化为并查集的形式
- 用树形结构表示一下各个集合,更加直观
3.并查集的实现
并查集接口总览
首先需要明确的是,使用并查集的时候,我们为每个要存储的数据元素都进行了编号(从0开始),因此,我们在操作并查集的时候,主要通过编号来进行,也就是数组的下标。
class UnionFindSet
{
public:// 构造函数UnionFindSet(size_t n):_ufs(n, -1){}// 查找一个元素所在的集合int FindRoot(int x);// 判断两个元素是否在同一个集合中bool InSet(int x1, int x2);// 合并两个元素所在的集合void Union(int x1, int x2);// 获取集合的个数 注意:不是元素个数size_t SetSize();private:vector<int> _ufs;
};
并查集中最重要的接口就是 合并两个集合Union 和 判断两个元素是否在同一个集合中InSet,这两个函数都需要借助 查找根FindRoot来进行,最后还有一个获取集合的个数SetSize。
构造函数
构造函数只需要将并查集中的所有元素值设置为-1即可,表示当前每个元素都是一个集合,集合中有一个元素。
class UnionFindSet
{
public:// 构造函数:指定并查集的大小,将并查集中的元素值全部设置为-1UnionFindSet(size_t n):_ufs(n, -1){}private:vector<int> _set;
};
查找元素属于哪个集合
对于并查集中的每个元素来说,我们需要标识元素属于哪个集合,我们采用集合中编号最小的元素为集合的根,唯一标识一个集合。
- 编号为0、6、7、8 的元素的根都是0,所以他们属于同一个集合。
- 编号为1、4、9、2、3、5 的元素的根都是1,所以他们属于同一个集合。
因此,查找一个元素位于哪个集合中只需要查找该元素的根即可,那么如何找到一个元素所在集合的根呢?
- 不要忘了,并查集中的元素值为负数,负号表示这是一个根,数字表示这个集合中有几个元素;因此,我们只需要沿着数组表示的树形关系一直往上找,直到找到元素的值为负数即可 (即:树中中元素为负数的位置)。
代码如下所示:
int FindRoot(int x)
{int root = x;// 直到_ufs[root] < 0 表示找到根while (_ufs[root] >= 0){root = _ufs[root];}return root;
}
我们可以分析一下: 当多个集合合并时,可能会出现元素到根的路径比较长的情况,路径比较长的话,查找根的效率就会降低,所以我们可以针对这种情况进行代码的优化 —— 路径压缩。
- 路径压缩的优化可以放在FindRoot中进行。
- 并且只能压缩当前结点到根结点中所涉及的结点。
优化后的代码如下所示:
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;
}
路径压缩示意图:以查找编号为3的元素为例,四层路径变成三层了。
判断是否属于同一个集合
因为,我们通过集合的根来判断一个元素属于哪个集合,因此,我们只需要两个元素所在集合的根是否相同,即可判断两个元素是否位于同一个集合中。
bool InSet(int x1, int x2)
{return FindRoot(x1) == FindRoot(x2);
}
合并两个集合
准确来说应该是合并两个元素所在的集合,实现步骤如下:
- 判断两个元素所在的集合(通过FindRoot函数即可实现),如果本身就在同一个集合中就没有必要合并了。
- 把root2集合中的数据个数加到root1集合中去。
- 将root2集合的根修改为root1。
代码如下:
void Union(int x1, int x2)
{// 计算两个元素所在的集合int root1 = FindRoot(x1);int root2 = FindRoot(x2);// 如果本身就在一个集合就没必要合并了if (root1 == root2)return;// 把root2集合中的数据个数加到root1集合中去_ufs[root1] += _ufs[root2];// 将root2集合的根修改为root1_ufs[root2] = root1;
}
我们可以分析一下:我们将一个集合往另一个集合合并,意味着一个集合将变成另一个集合的孩子;两个集合中元素的个数有多有少,是把数据元素多的往数据元素少的合并好,还是把数据元素少的往数据元素多的合并好?
- 分析可知:肯定是把数据元素少的集合往数据元素多的集合中合并好。比如:A集合往B集合上合并,A集合的根变成B集合的孩子,A集合中剩余的元素变成B集合的后代,如果后代越多,查找根的时间变长,并查集的效率变低。
因此,我们可以对代码进行优化:
void Union(int x1, int x2)
{// 计算两个元素所在的集合int root1 = FindRoot(x1);int root2 = FindRoot(x2);// 如果本身就在一个集合就没必要合并了if (root1 == root2)return;// 优化:控制数据量小的集合往数据量大的集合合并// 保证root2集合中的数据量总是小于root1集合中的数据量if (abs(_ufs[root1]) < abs(_ufs[root2]))swap(root1, root2);// 把root2集合中的数据个数加到root1集合中去_ufs[root1] += _ufs[root2];// 将root2集合的根修改为root1_ufs[root2] = root1;
}
集合的个数
当并查集中的元素的值小于0的时候,说明这是一个集合,遍历查找即可。
size_t SetSize()
{size_t size = 0;// 遍历并查集for (size_t i = 0; i < _ufs.size(); ++i){// _ufs[i] < 0 说明这是一个集合if (_ufs[i] < 0){++size;}}return size;
}
4.并查集完整代码附录
#include <vector>class UnionFindSet
{
public:// 构造函数UnionFindSet(size_t n):_ufs(n, -1){}// 查找指定编号的元素属于哪个集合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);}// 合并两个元素所在的集合void Union(int x1, int x2){// 计算两个元素所在的集合int root1 = FindRoot(x1);int root2 = FindRoot(x2);// 如果本身就在一个集合就没必要合并了if (root1 == root2)return;// 控制数据量小的集合往数据量大的集合合并// 保证root2集合中的数据量总是小于root1集合中的数据量if (abs(_ufs[root1]) < abs(_ufs[root2]))swap(root1, root2);// 把root2集合中的数据个数加到root1集合中去_ufs[root1] += _ufs[root2];// 将root2集合的根修改为root1_ufs[root2] = root1;}// 获取并查集中集合的个数 注意:不是元素的个数size_t SetSize(){size_t size = 0;// 遍历并查集for (size_t i = 0; i < _ufs.size(); ++i){// _ufs[i] < 0 说明这是一个集合if (_ufs[i] < 0){++size;}}return size;}private:vector<int> _ufs; // 底层空间复用vector
};
5.并查集在OJ中的应用
省份数量
题目链接:
LCR 116. 省份数量 - 力扣(LeetCode)https://leetcode.cn/problems/bLyHh0/submissions/584995509/
题目描述:
有 n
个城市,其中一些彼此相连,另一些没有相连。如果城市 a
与城市 b
直接相连,且城市 b
与城市 c
直接相连,那么城市 a
与城市 c
间接相连。
省份 是一组直接或间接相连的城市,组内不含其他没有相连的城市。
给你一个 n x n
的矩阵 isConnected
,其中 isConnected[i][j] = 1
表示第 i
个城市和第 j
个城市直接相连,而 isConnected[i][j] = 0
表示二者不直接相连。
返回矩阵中 省份 的数量。
解题思路:我们使用并查集,将相连的城市添加到同一个集合中,最后,并查集中集合的个数就是省份的数量。
解题代码:我们可以把前面写的并查集代码拷贝一份过去,直接使用即可。
- 需要注意的是:该题目中,采用二维数组存储城市之间的关系,此时,也就天然的为每个城市从0开始编号了,也就符合使用我们所写的并查集的前提的。
class UnionFindSet
{
public:// 构造函数UnionFindSet(size_t n):_ufs(n, -1){}// 查找指定编号的元素属于哪个集合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);}// 合并两个元素所在的集合void Union(int x1, int x2){// 计算两个元素所在的集合int root1 = FindRoot(x1);int root2 = FindRoot(x2);// 如果本身就在一个集合就没必要合并了if (root1 == root2)return;// 控制数据量小的集合往数据量大的集合合并// 保证root2集合中的数据量总是小于root1集合中的数据量if (abs(_ufs[root1]) < abs(_ufs[root2]))swap(root1, root2);// 把root2集合中的数据个数加到root1集合中去_ufs[root1] += _ufs[root2];// 将root2集合的根修改为root1_ufs[root2] = root1;}// 获取并查集中集合的个数 注意:不是元素的个数size_t SetSize(){size_t size = 0;// 遍历并查集for (size_t i = 0; i < _ufs.size(); ++i){// _ufs[i] < 0 说明这是一个集合if (_ufs[i] < 0){++size;}}return size;}private:vector<int> _ufs; // 底层空间复用vector
};class Solution {
public:int findCircleNum(vector<vector<int>>& isConnected) {// 定义一个并查集UnionFindSet ufs(isConnected.size());// 遍历isConnect矩阵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();}
};
运行结果:
等式方程的可满足性
题目链接:
990. 等式方程的可满足性 - 力扣(LeetCode)https://leetcode.cn/problems/satisfiability-of-equality-equations/submissions/584992038/
题目描述:
给定一个由表示变量之间关系的字符串方程组成的数组,每个字符串方程 equations[i]
的长度为 4
,并采用两种不同的形式之一:"a==b"
或 "a!=b"
。在这里,a 和 b 是小写字母(不一定不同),表示单字母变量名。
只有当可以将整数分配给变量名,以便满足所有给定的方程时才返回 true
,否则返回 false
。
示例 1:
输入:["a==b","b!=a"] 输出:false 解释:如果我们指定,a = 1 且 b = 1,那么可以满足第一个方程,但无法满足第二个方程。没有办法分配变量同时满足这两个方程。
示例 2:
输入:["a==b","b==c","a==c"] 输出:true
解题思路:我们遍历字符串数组,将判断相等的字符添加到同一个集合中;然后再次遍历字符串数组,如果两个字符不相等就不能出现在同一个集合中,如果出现在同一个集合中说明不符合等式方程的可满足性。
- 需要注意的是,对于该题目,使用我们编写的并查集存储的元素应该是字符,所以需要为字符编号,好在题目中的字符只能是小写字母,所以,我们可以定义一个并查集,开辟26个空间,并全部初始化为-1。
- 在使用并查集的时候,对小写字符进行编号的映射即可。比如 字符 b -> b - 'a' = 1,映射出的下标即使1了。
- 但是,我们总不能每次需要并查集的时候都手搓一个并查集吧!因此,我们可以开辟一个vector,借助并查集的思想在vector上进行操作即可。
解题代码:
class Solution {
public:bool equationsPossible(vector<string>& equations) {vector<int> ufs(26,-1);// 查找元素位于哪个集合中auto findRoot = [&](int x){int root = x;while(ufs[root] >= 0)root = ufs[root];return root;};// 先处理判断相等的字符串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;}
};
运行结果: