文章目录
并查集原理
先看一个场景把,现在有十个人坐火车,这十个人一开始是来自三个地区的互不认识的。但是通过交流他们来自相同地区的人就凑到了一起。假设三人来自成都,第三人来自北京,四人来西安。
所以现在这十个人的状态从每个人都是独立的集合,合并成了三个集合。
现在给出操作就是:1,查询某两个人是不是属于一个集合的。2,将a同学和b同学所在的集合合并。3,查询一个集合中有多少人。
上述的这种情况我们就可以使用并查集来解决,并且在合并集合,查询两个人是不是在同一个集合,这两种操作上的时间复杂度接近O(1)
首先上面的问题我们可以使用朴素算法来接近,就是开一个和人数相同的数组,然后每个人对应一个位置,这个位置存放的数就是代表这个人属于那个集合。
这时候我们查询的时间复杂度是O(1),但是合并两个集合的时候时间复杂度就是O(N)了需要遍历整个数组。
下面来讲一下并查集的原理和如何接近上述问题。
首先并查集是通过一个数组的形式来组织的。
用树的方式维护每一个集合。
数组里面的每个节点位置都是存放他的根节点。
数组初始化全都是-1,其实也可以初始化成自己的下标。都是可以的,初始化成-1,有一个好处就是-1可以当作记录额外的信息。比如abs(-1)就是集合里面的元素个数,可以通过这个来维护
现在着些人合并成了三个集合。此时他们的数组表示形式就是下面这种情况。
总结:
- **数组的下标对应集合中人的编号 **
- **数组中如果为负数,负号代表根,数字代表该集合中人的个数 **
- 数组中如果为非负数,代表该元素的父节点在数组中的下标
并查集代码实现1
class UnionFindSet
{
public:
UnionFindSet(int n)
:_ufs(n,-1)
{}
int find(int x)
{
int root = x;
while (_ufs[root] >= 0) root = _ufs[root];
//路径压缩
while (_ufs[x] >= 0)
{
int p = _ufs[x];
_ufs[x] = root;
x = p;
}
return root;
}
void Union(int a, int b)
{
int root1 = find(a);
int root2 = find(b);
if (root1 == root2)
return;
//按秩合并
if (_ufs[root2] < _ufs[root1])
swap(root1, root2);
_ufs[root1] += _ufs[root2];
_ufs[root2] = root1;
}
bool InOneSet(int a, int b)
{
return find(a) == find(b);
}
int size()const
{
int count = 0;
for (auto item : _ufs)
if (item <= 0)
count++;
return count;
}
int Usize(int a)
{
return abs(_ufs[find(a)]);
}
private:
vector<int> _ufs;//元素存放到数组里面
//unordered_map<T, int> _IndexMap;//下标与元素的映射
};
这里使用的并查集的写法初始情况是全都置为-1的。这个并查集不仅仅可以用来合并结合,查询两元素是不是一个集合,而且还可以得到这一个集合里面有多少个元素。
就是数组里面存放的数字就是该集合的元素个数。
并查集优化
并查集这里有两个优化方式:1,路径压缩,2,按秩合并。
常用的是第一种,我在上面代码中也写出了。
路径压缩一般是与find查找节点的根节点融合在一起,将要查找的x节点一路到根节点的节点的父亲都置为根节点。
路径优化的情景,当数据量过大的时候,可能会出现并查集的一棵树深度过深导致效率下降。所以每一次查找节点的时候都进行路径压缩,下次查找的效率机会变得很高。
这里的绿线是查找x节点之前的链接方式,蓝线是查找完成并且有路径压缩之后的连接情况。
可能会导致单次查找的效率下降,但是大大增加了后序查找的效率。
按秩合并,意思就是说,要合并两个集合的时候,将元素个数较小的那个结合合并到元素个数较大的那个集合当中去。这种合并方式可以降低树整体的高度。
比如:原来元素少的a集合有1001个点,有两层,元素多的b集合有2001个点,也是两层。a合并到b的时候有1000个节点的高度+1。但是在b集合合并到a集合的时候就会发现,此时2000个点的高度+1,因此将节点少的集合,合并到节点多的集合有利于降低查找的时间复杂度。
并查集代码实现2
上面写的并查集是针对数字元素或者数字编号的。
下面我们写一个用单词来对应的并查集,实际也很简单,只需要增加一个unordered_map将下标与单词映射在一起,就可以通过单词查找这个单词在数组中的下标。
template<class T>
class UnionFindSet
{
public:
UnionFindSet(int n , const vector<T>& x)
:_ufs(n, -1)
{
for (int i = 0; i < n; i++)
{
_IndexMap[x[i]] = i;//建立映射关系
}
}
int FindIndex(const T& x)
{
auto it = _IndexMap.find(x);
if (it == _IndexMap.end())
return -1;
return it->second;
}
int find(const T& x)
{
int root = FindIndex(x);
while (_ufs[root] >= 0) root = _ufs[root];
//路径压缩
int xi = FindIndex(x);
while (_ufs[xi] >= 0)
{
int p = _ufs[xi];
_ufs[xi] = root;
xi = p;
}
return root;
}
void Union(const T& a, const T& b)
{
int root1 = find(a);
int root2 = find(b);
if (root1 == root2)
return;
//按秩合并
if (_ufs[root2] < _ufs[root1])
swap(root1, root2);
_ufs[root1] += _ufs[root2];
_ufs[root2] = root1;
}
int Usize(const T& a)
{
return abs(_ufs[find(a)]);
}
bool InOneSet(const T& a, const T& b)
{
return find(a) == find(b);
}
int size()const
{
int count = 0;
for (auto item : _ufs)
if (item <= 0)
count++;
return count;
}
private:
vector<int> _ufs;//元素存放到数组里面
unordered_map<T, int> _IndexMap;//下标与元素的映射
};
基本思路上都是一样的,就是多了一层映射的关系。
数组实现并查集
在有些比赛中,可能使用STL会比较慢,使用数组比较简单,所以这里提供了数组实现版本。
const int N = 100010;//根据数据范围
int ufs[N]; //存储每个点的父节点
// 返回x的根节点
int find(int x)
{
if (ufs[x] != x) ufs[x] = find(ufs[x]);
return ufs[x];
}
// 初始化,假定节点编号是1~n
for (int i = 1; i <= n; i ++ ) ufs[i] = i;
// 将a集合,合并到b集合
usf[find(a)] = find(b);
这里是使用递归的方式进行路径压缩,此时ufs初始化的值不是-1,而是i才可以。如果初始化时全是-1,那么路径压缩就用循环。
这里因为我们初始化用的是i,所以,数组存储的额外信息,集合的个数就没有了。此时如果我们想要使用这个信息,需要新开一个Size数组配合并查集记录元素的个数。
const int N = 100010;//根据题目数据范围拟定
int ufs[N],size[N] //存储每个点的父节点
// 返回x的根节点
int find(int x)
{
if (ufs[x] != x) ufs[x] = find(ufs[x]);
return ufs[x];
}
// 初始化,假定节点编号是1~n
for (int i = 1; i <= n; i ++ )
{
ufs[i] = i;
size[i] = 1;//初始化size
}
// 将a集合,合并到b集合
size[find(b)] += size[find(a)];//合并集合之前先合并size
usf[find(a)] = find(b);
并查集练习
下面通过两道题,来演示一下在做题中如何快速写出并查集接近问题,实际应用中用到最多的两个接口就是,将两个集合合并,查询两个元素是不是处于一个集合之内。根据题目要求看情况写。
剑指 Offer II 116. 省份数量
class Solution {
public:
int findCircleNum(vector<vector<int>>& isConnected) {
int sz = isConnected.size();
vector<int> ufs(sz,-1);
auto Find = [&ufs](int n){
int root = n;
while (ufs[root] >= 0)
{
root = ufs[root];
}
if (ufs[n] >= 0)
ufs[n] = root;
return root;
};
for(int i = 0;i<sz;i++)
{
for(int j = 0;j < isConnected[i].size(); j++)
{
if(isConnected[i][j] == 1)
{
int root1 = Find(i);
int root2 = Find(j);
if(root1 != root2)
{
ufs[root1] += ufs[root2];
ufs[root1] = root2;
}
}
}
}
int count = 0;
for(auto e : ufs)
if(e < 0)
count++;
return count;
}
};
实际中只需要将多次使用的find函数独立出来即可,其他的直接在代码内实现就可以了。这里我是用的lambda表达式来代替函数。lambda实际就是一个类的匿名对象。这个类和less,greater的类相似,也就是和仿函数相似。通过重载了()来实现像调用函数一样的方式使用对象(可执行对象)
990. 等式方程的可满足性
class Solution {
public:
bool equationsPossible(vector<string>& equations) {
vector<int> ufs(26,-1);
auto Find = [&ufs](int n)
{
int root = n;
while (ufs[root] >= 0)
{
root = ufs[root];
}
if (ufs[n] >= 0)
ufs[n] = root;
return root;
};
for(auto& str : equations)
{
if(str[1] == '=')
{
int root1 = Find(str[0] - 'a');
int root2 = Find(str[3] - 'a');
if(root1 != root2)
{
ufs[root1] += ufs[root2];
ufs[root2] = root1;
}
}
}
for(auto& str : equations)
{
if(str[1] == '!')
{
int root1 = Find(str[0] - 'a');
int root2 = Find(str[3] - 'a');
if(root1 == root2)
return false;
}
}
return true;
}
};