并查集
并查集是一种树形结构,主要用于解决元素的分组问题(比如有多少个帮派、有多少个朋友圈),它管理一系列不相交的集合。
并查集主要实现以下几个功能
- 查询某个元素属于哪个集合(属于哪一个连通分量)
- 合并两个集合为一个大集合
- 在一张无向图中维护节点之间的连通性,擅长动态维护许多具有传递性的关系
并查集的介绍及引入
并查集的重要思想,用集合中的一个元素来代表该集合
- 初始状态,对于只有一个元素的集合,代表元素自然是唯一的元素自身
- 1、3号元素经过某种比较或者某种规则进行了合并
- 2、3号元素此时需要经过相同的规则进行比较,但此时,由于并查集的思想,3号元素所在的集合,其代表元素为1号元素,所以1号元素代表集合与2进行比较和合并操作
- 4、5、6号元素经过同样的比较也达成了元素的合并
- 两大集合需要进行合并,此时只需要两个集合的代表元素进行比较,即1,4号元素进行比较即可
- 这是一个树状的结构,要寻找集合的代表元素,只需要一层一层往上访问父节点(图中箭头所指的圆),直达树的根节点(图中橙色的圆)即可。
并查集的三个基本组成
并查集主要由一个father数组,以及查询以及合并操作所组成
- father数组是一个哈希表,用以表明某个元素所在集合的代表元素是谁,比如father[1] = 2,就表示元素1所在集合的代表元素是2。
- 在查询操作里,就是查找能代表某元素x的集合的根节点(代表元素)
- 在合并操作里,就是把两个集合给合并起来。其核心是找到能够代表两个集合的节点
未经过优化时
- 初始化操作
明显,如第一张图所示,在初始状态,每个元素分别以自己为代表作为一个集合
void init(){
for(int i = 1;i <= n;i++){
father[i] = i;
}
}
//利用函数
void init(){
//表示将father从头到尾依次按顺序递加的从0开始赋值,0,1,2,3,4,5...
iota(father.begin(),father.end(),0);
}
- 查询操作
找到元素x所在集合的代表元素
//递归查找
int find_father(x){
if(x != father[x]){
father[x] = find_father(father[x]);
}
return father[x];
}
- 合并操作
把元素 x 和元素 y 所在的集合合并,要求 x 和 y 所在的集合不相交,如果相交则不合并。
void merge(int x,int y){
int a = find_father(x); //x的根节点为a
int b = find_father(y); //y的根节点为b
if(a != b){ //二者不等,说明a,b不是连通的
father[a] = b; //a,b相连,此处把a挂在b上
}
}
经过优化
- 路径压缩(在查找操作中的优化)
在并查集中,我们只关心一某个节点的代表元素(即该元素所在树的根点)是谁,那么我们肯定不希望从节点本身到根节点的路径过长,最好能够达到每个节点直接跟根几点相连的效果。那么这个过程就是路径压缩。
//路径压缩的代码
int find_father(int x){
return x == father[x] ? x:(father[x] = find_father(father[x]));
}
- 按秩合并(在合并操作时的优化)
秩表示树的高度,按秩合并,即在进行合并操作时,总是将有较小秩的根指向有较大秩的根。
不难想到,其实就是将较小复杂度的树往较大负责度的树上合并,这样会使得到根节点距离变长的节点个数变少。
vector<int> rank(10010,1);
void init(){
for(int i = 1;i <= n;i++){
father[i] = i; //初始时,每个元素的根节点为自身
rank[i] = 1; //每个元素的秩都为1,(树高为1)
}
}
void merge(int x,int y){
int a = find_father(x);
int b = find_father(y);
//会发现,秩小的合并到秩大的,新树的秩没有改变
if(rank[a] < rank[b]){
father[a] = b;
}
else if(rank[a] < rank[b]){
father[b] = a; //选择较小秩的树合并到较大秩的树上
}
else{//如果两个树的秩一样,那么随便挑一个作为根节点都可
father[a] = b;
rank[b] ++; //秩相同的两树合并,被当做根节点的那棵树的秩要加1
}
}
-
小结
那么什么时候使用优化过后的路径压缩和按秩合并呢?其实要求并不严格,一般来说优化与否基本没什么影响,但是在时间复杂度要求较高的场景,则必须使用优化之后的做法。 -
从题目中提炼出来的一些奇技淫巧
vector<int> father;
int findfather(int x){//查询
return x == father[x] ? x:(father[x] = findfather(father[x]));
}
void merge(int x,int y){//合并
int a = findfather(x), b = findfather(y);
if(a != b) father[b] = a;
}
1. 判断一个图中有多少个连通分量,即森林中有多少个子树
int cnt = 0;
for(int i = 0;i < father.size();i++){
if(findfather(i) == i) cnt++;
}
2. 将每个连通分量中的元素分别保存,以便后续的遍历
unordered_map<int,vector<int>> mp;
for(int i = 0;i < father.size(); i++){
mp[findfather(i)].push_back(i);
}
3. 将点集映射成数集做一个哈希映射即可
father.resize(100010);
for(int i = 0;i < stones.size();i++){
father[stones[i][0]] = stones[i][0];
father[stones[i][1]+10000] = stones[i][1]+10000; //将二维的点映射成一维的
}
带权并查集(进阶)
基本定义
一般的并查集主要记录节点之间的链接关系,而没有其他的具体的信息,仅仅代表某个节点与其父节点之间存在联系,它多用来判断图的连通性。有的时候在这些边中添加一些额外的信息可以更好的处理需要解决的问题,在每条边中记录额外的信息的并查集就是带权并查集。
带权并查集每个元素的权通常描述其与并查集中祖先的关系,这种关系如何合并,路径压缩时就如何压缩;带权并查集可以推算集合内点的关系,而一般并查集只能判断属于某个集合。
- 带权并查集的路径
压缩在完成一个连通分量的构建之后,每个节点都是记录的与根节点之间的权值;在路径压缩之前,每个节点都是与其父节点连接着的,自然,权值也是与其父节点之间的权值。那么在findfather的路径压缩过程中,权值必须做相应的更新。
int findfather(int x){
if(x != father[x]){
int t = father[x]; //记录父节点编号
father[x] = findfather(father[x]);
value[x] += value[t]; //当前权值加上原父节点的权值
}
return father[x];
}
- 带权并查集的合并操作
x所在集合的代表节点为px,y所在集合的代表节点为py,如果有了x,y之间的关系s,要将px合并到py上
void merge(int x,int y){
int px = findfather(x);
int py = findfather(y);
if(px != py){
father[px] = py;
value[px] = value[y]-value[x]+s;
}
}
例题列表
leetcode684_冗余连接.
leetcode1319_连通网络的操作次数.
leetcode990_等式方程的可满足性.
leetcode1202_交换字符串中的元素.
leetcode947_移除最多的同行或者同列石头.
leetcode婴儿名字.
leetcode765_情侣牵手.