图论算法刷题的第四十七天
1.并查集理论基础
1.背景
首先要知道并查集可以解决什么问题呢?
并查集常用来解决连通性问题。
大白话就是当我们需要判断两个元素是否在同一个集合里的时候,我们就要想到用并查集。
并查集主要有两个功能:
- 将两个元素添加到一个集合中。
- 判断两个元素在不在同一个集合
接下来围绕并查集的这两个功能来展开讲解。
2.原理讲解
从代码层面,我们如何将两个元素添加到同一个集合中呢。
此时有人会想到:可以把他放到同一个数组里或者set 或者 map 中,这样就表述两个元素在同一个集合。
那么问题来了,对这些元素分门别类,可不止一个集合,可能是很多集合,成百上千,那么要定义这么多个数组吗?
那么有人会想到,那可以定义一个二维数组。
但如果我们要判断两个元素是否在同一个集合里的时候 我们又能怎么办? 只能把而二维数组都遍历一遍。
而且每当想添加一个元素到某集合的时候,依然需要把把二维数组都遍历一遍,才知道要放在哪个集合里。
这仅仅是一个粗略的思路,如果沿着这个思路去实现代码,非常复杂,因为管理集合还需要很多逻辑。
那么我们来换一个思路来看看。
我们将三个元素A,B,C (分别是数字)放在同一个集合,其实就是将三个元素连通在一起,如何连通呢。
只需要用一个一维数组来表示,即:father[A] = B,father[B] = C 这样就表述 A 与 B 与 C连通了(有向连通图)。
代码如下:
// 将v,u 这条边加入并查集
void join(int u, int v) {u = find(u); // 寻找u的根v = find(v); // 寻找v的根if (u == v) return; // 如果发现根相同,则说明在一个集合,不用两个节点相连直接返回father[v] = u;
}
可能有人想,这样我可以知道 A 连通 B,因为 A 是索引下标,根据 father[A]的数值就知道 A 连通 B。那怎么知道 B 连通 A呢?
我们的目的是判断这三个元素是否在同一个集合里,知道 A 连通 B 就已经足够了。
这里要讲到寻根思路,只要 A ,B,C 在同一个根下就是同一个集合。
给出A元素,就可以通过 father[A] = B,father[B] = C,找到根为 C。
给出B元素,就可以通过 father[B] = C,找到根也为为 C,说明 A 和 B 是在同一个集合里。 大家会想第一段代码里find函数是如何实现的呢?其实就是通过数组下标找到数组元素,一层一层寻根过程,代码如下:
// 并查集里寻根的过程
int find(int u) {if (u == father[u]) return u; // 如果根就是自己,直接返回else return find(father[u]); // 如果根不是自己,就根据数组下标一层一层向下找
}
如何表示 C 也在同一个元素里呢? 我们需要 father[C] = C,即C的根也为C,这样就方便表示 A,B,C 都在同一个集合里了。
所以father数组初始化的时候要 father[i] = i,默认自己指向自己。
代码如下:
// 并查集初始化
void init() {for (int i = 0; i < n; ++i) {father[i] = i;}
}
最后我们如何判断两个元素是否在同一个集合里,如果通过 find函数 找到 两个元素属于同一个根的话,那么这两个元素就是同一个集合,代码如下:
// 判断 u 和 v是否找到同一个根
bool isSame(int u, int v) {u = find(u);v = find(v);return u == v;
}
3.路径压缩
在实现 find 函数的过程中,我们知道,通过递归的方式,不断获取father数组下标对应的数值,最终找到这个集合的根。
搜索过程像是一个多叉树中从叶子到根节点的过程。
如果这棵多叉树高度很深的话,每次find函数 去寻找根的过程就要递归很多次。
我们的目的只需要知道这些节点在同一个根下就可以,所以对这棵多叉树的构造只需要这样就可以了。
除了根节点其他所有节点都挂载根节点下,这样我们在寻根的时候就很快,只需要一步,
如果我们想达到这样的效果,就需要 路径压缩,将非根节点的所有节点直接指向根节点。 那么在代码层面如何实现呢?
我们只需要在递归的过程中,让 father[u] 接住 递归函数 find(father[u]) 的返回结果。
因为 find 函数向上寻找根节点,father[u] 表述 u 的父节点,那么让 father[u] 直接获取 find函数 返回的根节点,这样就让节点 u 的父节点 变成根节点。
代码如下,注意看注释,路径压缩就一行代码:
// 并查集里寻根的过程
int find(int u) {if (u == father[u]) return u;else return father[u] = find(father[u]); // 路径压缩
}
以上代码在C++中,可以用三元表达式来精简一下,代码如下:
int find(int u) {return u == father[u] ? u : father[u] = find(father[u]);
}
相信不少人在学习并查集的时候,对上面这三行代码实现的 find函数 很熟悉,但理解上却不够深入,仅仅知道这行代码很好用,不知道这里藏着路径压缩的过程。
所以对于算法初学者来说,直接看精简代码学习是不太友好的,往往忽略了很多细节。
4.代码模板
那么此时并查集的模板就出来了, 整体模板C++代码如下:
int n = 1005; // n根据题目中节点数量而定,一般比节点数量大一点就好
vector<int> father = vector<int> (n, 0); // C++里的一种数组结构// 并查集初始化
void init() {for (int i = 0; i < n; ++i) {father[i] = i;}
}
// 并查集里寻根的过程
int find(int u) {return u == father[u] ? u : father[u] = find(father[u]); // 路径压缩
}// 判断 u 和 v是否找到同一个根
bool isSame(int u, int v) {u = find(u);v = find(v);return u == v;
}// 将v->u 这条边加入并查集
void join(int u, int v) {u = find(u); // 寻找u的根v = find(v); // 寻找v的根if (u == v) return ; // 如果发现根相同,则说明在一个集合,不用两个节点相连直接返回father[v] = u;
}
通过模板,我们可以知道,并查集主要有三个功能。
第一,寻找根节点,函数:find(int u),也就是判断这个节点的祖先节点是哪个。
第二,将两个节点接入到同一个集合,函数:join(int u, int v),将两个节点连在同一个根节点上。
第三,判断两个节点是否在同一个集合,函数:isSame(int u, int v),就是判断两个节点是不是同一个根节点。
5.复杂度分析
这里对路径压缩版并查集来做分析。
空间复杂度: O(n) ,申请一个father数组。
关于时间复杂度,如果想精确表达出来需要繁琐的数学证明,就不在本篇讲解范围内了,大家感兴趣可以自己去深入研究。
这里做一个简单的分析思路。
路径压缩后的并查集时间复杂度在O(logn)与O(1)之间,且随着查询或者合并操作的增加,时间复杂度会越来越趋于O(1)。
了解到这个程度对于求职面试来说就够了。
在第一次查询的时候,相当于是n叉树上从叶子节点到根节点的查询过程,时间复杂度是logn,但路径压缩后,后面的查询操作都是O(1),而 join 函数 和 isSame函数 里涉及的查询操作也是一样的过程。
6.总结
这一节我们讲解了并查集的背景、原理、两种优化方式(路径压缩,按秩合并),代码模板,常见误区,以及模拟过程。
要知道并查集解决什么问题,在什么场景下我们要想到使用并查集。
接下来进一步优化并查集的执行效率,重点介绍了路径压缩的方式,另一种方法:按秩合并,我们在 「拓展」中讲解。
通过一步一步的原理讲解,最后给出并查集的模板,所有的并查集题目都在这个模板的基础上进行操作或者适当修改。
但只给出模板还是不够的,针对大家学习并查集的常见误区,详细讲解了模板代码的细节。
为了让录友们进一步了解并查集的运行过程,我们再通过具体用例模拟一遍代码过程并画出对应的内部数据连接图(有向图)。
这里也建议大家去模拟一遍才能对并查集理解的更到位。
如果对模板代码还是有点陌生,不用担心,接下来我会讲解对应的并查集题目,通过一系列题目练习,大家就会感受到这套模板有多么的好用!
2.并查集例题 寻找存在的路线
题目描述
给定一个包含 n 个节点的无向图中,节点编号从 1 到 n (含 1 和 n )。
你的任务是判断是否有一条从节点 source 出发到节点 destination 的路径存在。
输入描述
第一行包含两个正整数 N 和 M,N 代表节点的个数,M 代表边的个数。
后续 M 行,每行两个正整数 s 和 t,代表从节点 s 与节点 t 之间有一条边。
最后一行包含两个正整数,代表起始节点 source 和目标节点 destination。
输出描述
输出一个整数,代表是否存在从节点 source 到节点 destination 的路径。如果存在,输出 1;否则,输出 0。
输入示例
5 4
1 2
1 3
2 4
3 4
1 4
输出示例
1
提示信息
数据范围:
1 <= M, N <= 100。
#include<iostream>
#include<vector>
using namespace std;int n;// 节点数量
vector<int> father=vector<int>(101,0);// 按照节点大小定义数组大小// 并查集初始化
void init(){for(int i=1;i<=n;i++) father[i]=i;
}// 并查集里寻根的过程
int find(int u){return u == father[u] ? u : father[u] = find(father[u]);
}// 判断 u 和 v是否找到同一个根
bool isSame(int u,int v){u = find(u);v = find(v);return u == v;
}// 将v->u 这条边加入并查集
void join(int u,int v){u=find(u);// 寻找u的根v=find(v);// 寻找v的根if(u==v) return;// 如果发现根相同,则说明在一个集合,不用两个节点相连直接返回father[v]=u;
}
int main(){int m,s,t,source,destination;cin>>n>>m;init();while(m--){cin>>s>>t;join(s,t);}cin>>source>>destination;if(isSame(source,destination)) cout<<1<<endl;else cout<<0<<endl;return 0;
}
思路总结:本题是并查集的基础题目。
并查集可以解决什么问题呢?
主要就是集合问题,两个节点在不在一个集合,也可以将两个节点添加到一个集合中。
这里整理出我的并查集模板如下:
int n = 1005; // n根据题目中节点数量而定,一般比节点数量大一点就好
vector<int> father = vector<int> (n, 0); // C++里的一种数组结构// 并查集初始化
void init() {for (int i = 0; i < n; ++i) {father[i] = i;}
}
// 并查集里寻根的过程
int find(int u) {return u == father[u] ? u : father[u] = find(father[u]); // 路径压缩
}// 判断 u 和 v是否找到同一个根
bool isSame(int u, int v) {u = find(u);v = find(v);return u == v;
}// 将v->u 这条边加入并查集
void join(int u, int v) {u = find(u); // 寻找u的根v = find(v); // 寻找v的根if (u == v) return ; // 如果发现根相同,则说明在一个集合,不用两个节点相连直接返回father[v] = u;
}
以上模板中,只要修改 n 大小就可以。
并查集主要有三个功能:
- 寻找根节点,函数:find(int u),也就是判断这个节点的祖先节点是哪个
- 将两个节点接入到同一个集合,函数:join(int u, int v),将两个节点连在同一个根节点上
- 判断两个节点是否在同一个集合,函数:isSame(int u, int v),就是判断两个节点是不是同一个根节点
简单介绍并查集之后,我们再来看一下这道题目。
为什么说这道题目是并查集基础题目,题目中各个点是双向图链接,那么判断 一个顶点到另一个顶点有没有有效路径其实就是看这两个顶点是否在同一个集合里。
如何算是同一个集合呢,有边连在一起,就算是一个集合。
此时我们就可以直接套用并查集模板。
使用 join(int u, int v)将每条边加入到并查集。
最后 isSame(int u, int v) 判断是否是同一个根 就可以了。
其实别看代码这么多,其实真正写起来的话认真去琢磨,也是很容易的。