并查集的原理
在一些应用问题中,需要将n个不同的元素划分成一些不相交的集合。开始时,每个元素自成一个
单元素集合,然后按一定的规律将归于同一组元素的集合合并。在此过程中要反复用到查询某一
个元素归属于那个集合的运算。适合于描述这类问题的抽象数据类型称为并查集(union-find
set)。
举个例子看看
比如:某公司今年校招全国总共招生10人,西安招4人,成都招3人,武汉招3人,10个人来自不
同的学校,起先互不相识,每个学生都是一个独立的小团体,现给这些学生进行编号:{0, 1, 2, 3,
4, 5, 6, 7, 8, 9}; 给以下数组用来存储该小集体,数组中的数字代表:该小集体中具有成员的个
数。
至于,为什么是负号呢?
聪明的小伙伴们,可以先自己思考一下(为什么人数要用负数表示呢)
毕业后,学生们要去公司上班,每个地方的学生自发组织成小分队一起上路,于是:
西安学生小分队s1={0,6,7,8},成都学生小分队s2={1,4,9},武汉学生小分队s3={2,3,5}就相互认识
了,10个人形成了三个小团体。假设右三个群主0,1,2担任队长,负责大家的出行。
于是就变成了三个小团体,用三棵树表示
然后,我们将这三棵树,也就是三个集合,用双亲表示法,写到一个数组中,这就是并查集。
所以,并查集其实就是一个森林
看到这里,相信,大家应该能够知道为什么会用负数表示人数
OK,
总结一下,
- 当数组中的元素大于0的时候,该元素就是该节点的父节点的下标
- 当数组中的元素小于0的时候,该元素的绝对值就是这颗树,也就是这个集合中的元素的个数
但是,还是存在一个问题,
其实注意看,我们的十名同学编号为0到9,这其实是一个映射,我们在实现的时候建议将这些映射用map存起来,当需要根据同学找编号的时候,就能够发挥作用了。
并查集的实现
并查集主要需要实现以下功能
- 合并
- 查找
- 返回集合的个数
- 判断两个元素是不是在同一个集合
并查集的结构
本质结构是一个森林,用vector来存
但我们最好还需要存一个map,来存映射关系(当然,也可以不存)
查找根节点的位置
查找根节点的位置是并查集操作的核心
如何查找根节点的位置
我们只需要根据下面的两条性质即可
- 当数组中的元素大于0的时候,该元素就是该节点的父节点的下标
- 当数组中的元素小于0的时候,该元素的绝对值就是这颗树,也就是这个集合中的元素的个数
一直循环向上找根节点即可,最后返回下标
int FindRoot(int a)
{
while(_uft[a] >= 0)
{
a = _uft[a];
}
return a;
}
集合的合并
集合的合并,我们只需要去处理根即可,比如下图中
如果我们需要合并4和7,我们只需要找到4所在的集合和7所在的集合,将两个集合合并就行
而合并的时候我们只需要处理根,将1合并到0的下面
具体怎么操作
很简单
我们只需要将1对应的下标中存的个数,加到0对应的下标中存的个数中,在将1对应的下标中存的元素改成0的下标即可。
void Union(int a,int b)
{
int root1 = FindRoot(a);
int root2 = FindRoot(b);
if(root1 == roo2)//在同一个集合就不用合并
return;
_uft[root1] += _uft[root2];
_uft[root2] = root1;
}
并查集中集合的个数
太简单了,直接遍历vector,记录其中负元素的个数
返回即可。
int SetSize()
{
int ret = 0;
for(auto& e:_uft)
{
if (e < 0)
ret++;
}
return ret;
}
判断两个元素是不是在同一个集合
太简单了,直接找两个元素的根,然后进行比较即可
bool IsSameSet(int a, int b)
{
return FindRoot(a) == FindRoot(b);
}
并查集的优化
并查集确实挺不错的,但是,可能在合并多次之后,导致其中有集合的高度过高了,这个时候,查找的时候就不那么好用了。
于是,这里就进行路径压缩,来缩短集合的高度。
网络上有一些地方写这里路径压缩的时候采用递归去写
但是呢,高度已经很高了,用递归的话,资源消耗有会继续加大,所以,我建议采用非递归去写。
主要在两个地方可以进行优化
合并的优化
在合并的时候,我们规定节点少的集合合并到节点多的集合
这样集合的高度就没有增加,这里有点抽象
我们这么想
当节点少的集合合并到节点多的集合
新的高度就是max(节点多的集合,节点少的集合 + 1)
高度没有发生变化
而当节点多的集合合并到节点少的集合
新的高度就是max(节点多的集合 + 1,节点少的集合)
高度增加了
显然,我们应该要将节点少的集合合并到节点多的集合
所以,优化后的合并代码如下
void Union(int a, int b)
{
int root1 = FindRoot(a);
int root2 = FindRoot(b);
if (root1 == root2)//如果在同一个集合就不用合并
return;
if (abs(_uft[root1]) < abs(_uft[root2]))//把节点少的集合合并到节点大的集合
{
swap(root1, root2);
}
_uft[root1] += _uft[root2];
_uft[root2] = root1;
}
查找根节点的优化
这里就是真正的路径压缩了
我们在查找根的时候,如果经历了多次才找到根,那么就可以将这个节点,直接加到根下面去,这样就可以缩短高度。
其实就是,我们在找到根之后,将路径上的节点全部都挂到根下面去,通过这样的操作来缩短高度
int FindRoot(int a)
{
int root = a;;
while (_uft[root] >= 0)
{
root = _uft[root];
}
while (_uft[a] >= 0)//路径压缩
{
int parent = _uft[a];//记录上一个节点的下标
_uft[a] = root;
a = parent;
}
return root;
}
并查集的应用
我们在这里看两道题目
省份数量
链接:leetcode链接
思路分析
我们将一个省份作为一个集合,当两个城市相连,就合并
遍历完之后,再返回并查集中集合的个数即可。
template<class K>
class UnionFindSet
{
private:
vector<int> _uft;//并查集
map<K, int> _indexMap;//下标映射关系
public:
UnionFindSet(int n = 10)
{
_uft.resize(n, -1);
}
void Union(int a, int b)
{
int root1 = FindRoot(a);
int root2 = FindRoot(b);
if (root1 == root2)//如果在同一个集合就不用合并
return;
if (abs(_uft[root1]) < abs(_uft[root2]))//把节点少的集合合并到节点大的集合
{
swap(root1, root2);
}
_uft[root1] += _uft[root2];
_uft[root2] = root1;
}
int FindRoot(int a)
{
int root = a;;
while (_uft[root] >= 0)
{
root = _uft[root];
}
while (_uft[a] >= 0)//路径压缩
{
int parent = _uft[a];//记录上一个节点的下标
_uft[a] = root;
a = parent;
}
return root;
}
int SetSize()
{
int ret = 0;
for (int i = 0; i < _uft.size(); ++i)
{
if (_uft[i] < 0)
ret++;
}
return ret;
}
bool IsSameSet(int a, int b)
{
return FindRoot(a) == FindRoot(b);
}
};
class Solution {
public:
int findCircleNum(vector<vector<int>>& isConnected) {
UnionFindSet<int> uft(isConnected.size());
for(int i = 0;i < isConnected.size();++i)
{
for(int j = 0;j < isConnected[0].size();++j)
{
if(isConnected[i][j]==1)
{
uft.Union(i,j);
}
}
}
return uft.SetSize();
}
};
等式方程式的可满足性
链接:leetcode链接
我们用26个字母去建立一个26个元素的并查集
我们遍历字符串数组,遇到等式的时候,就去合并等式两边的元素所在的集合
遍历完之后,就建立好了并查集
然后开始查找即可
接着再去遍历一遍字符串数组,遇到不等式的时候,去判断等式两边的元素是否不在同一个集合
template<class K>
class UnionFindSet
{
private:
vector<int> _uft;//并查集
map<K, int> _indexMap;//下标映射关系
public:
UnionFindSet(int n = 10)
{
_uft.resize(n, -1);
}
void Union(int a, int b)
{
int root1 = FindRoot(a);
int root2 = FindRoot(b);
if (root1 == root2)//如果在同一个集合就不用合并
return;
if (abs(_uft[root1]) < abs(_uft[root2]))//把节点少的集合合并到节点大的集合
{
swap(root1, root2);
}
_uft[root1] += _uft[root2];
_uft[root2] = root1;
}
int FindRoot(int a)
{
int root = a;;
while (_uft[root] >= 0)
{
root = _uft[root];
}
while (_uft[a] >= 0)//路径压缩
{
int parent = _uft[a];//记录上一个节点的下标
_uft[a] = root;
a = parent;
}
return root;
}
int SetSize()
{
int ret = 0;
for (int i = 0; i < _uft.size(); ++i)
{
if (_uft[i] < 0)
ret++;
}
return ret;
}
bool IsSameSet(int a, int b)
{
return FindRoot(a) == FindRoot(b);
}
};
class Solution {
public:
bool equationsPossible(vector<string>& equations) {
UnionFindSet<string> uft(26);
for(auto& e:equations)
{
if(e[1] == '=')
{
uft.Union(e[0] - 'a',e[3] - 'a');
}
}
for(auto& e:equations)
{
if(e[1] == '!')
{
if(uft.IsSameSet(e[0] - 'a',e[3] - 'a') == true)
return false;
}
}
return true;
}
};
在做算法提的时候简化并查集
并查集用起来爽啊,这么方便,但是,真的每次都要手搓一个并查集出来吗?
其实是不需要的,我们借助lambda表达式,可以实现出找根节点下标的函数即可,其他的函数,可以很快的在过程中写出来
我们以第二道题为例
简化一下代码
bool equationsPossible(vector<string>& equations) {
vector<int> uft(26,-1);
auto FindRoot = [&uft](int a)
{
while(uft[a] >= 0)
a = uft[a];
return a;
};
for(auto& e:equations)
{
if(e[1] == '=')
{
int root1 = FindRoot(e[0] - 'a');
int root2 = FindRoot(e[3] - 'a');
if(root1 != root2)
{
uft[root1] += uft[root2];
uft[root2] = root1;
}
}
}
for(auto& e:equations)
{
if(e[1] == '!')
{
int root1 = FindRoot(e[0] - 'a');
int root2 = FindRoot(e[3] - 'a');
if(root1 == root2)
return false;
}
}
return true;
}
利用lambda表达式可以快速构建起来简化并查集,在做题的时候非常好用。