图论的并查集问题
- 并查集理论基础
- 并查集理论
- 路径压缩
- 代码模板
- 模拟过程
- 找亲戚
- 寻找存在的路径
- 冗余连接
- 冗余连接II
并查集理论基础
参考学习视频: 图论——并查集(详细版)
并查集理论
并查集主要用于处理一些不相交集合的合并问题(连通性问题)
大白话就是当我们需要判断两个元素是否在同一个集合里的时候,我们就要想到用并查集。
并查集的基本操作主要有:
- 初始化
init
- 查询
find
- 合并
join
- 判断
isSame
初始化: 假如有编号为1,2,3,…, n的n个元素,我们用一个数组 fa[] 来存储每个元素的父节点。一开始,我们先将它们的父节点设为自己。
// 并查集初始化
void init(int n)
{
for(int i = 0; i < n; i++)
fa[i] = i;
}
查询: 通过递归函数,查找 i 的祖先
// 并查集里寻根的过程
int find(int i)
{
if(fa[i] == i)
return fa[i]; //递归出口,当到达了祖先位置,就返回祖先
else
return find(fa[i]); // 不断往上查找祖先
}
合并: 使得 v 的祖先(根)是 u 的祖先(根),这里可以使u的根为v的根,修改程序即可,哪种连接均可。
void join(int u, int v)
{
int u_fa = find(u); // 寻找u的根
int v_fa = find(v); // 寻找v的根
// 如果发现根相同,则说明在一个集合,不用两个节点相连直接返回
if(u_fa == v_fa)
return;
//否则v的根 = u的根
else
fa[v_fa] = u_fa;
}
join(1, 2);
join(3, 2);
注意:在join(int u, int v)函数里 要分别对 u 和 v 寻根之后再进行关联。
当执行 join(3, 2)
的时候,会先通过find函数寻找 3的根为3,2的根为1 (第一个join(1, 2)
,将2的根设置为1),所以最后是将1 指向 3。最后构成的图是这样的:
判断: 该函数则判断两个数的组祖先是否相同,相同返true
,不同返回 false
// 判断 u 和 v是否找到同一个根
bool isSame(int u, int v)
{
int u_fa = find(u);
int v_fa = find(v);
//根相同
if(u_fa == v_fa)
return true;
else
return false;
}
路径压缩
在实现 find 函数的过程中,我们知道,通过递归的方式,不断获取father数组下标对应的数值,最终找到这个集合的根。
搜索过程像是一个多叉树中从叶子到根节点的过程,如图:
如果这棵多叉树高度很深的话,每次find函数 去寻找根的过程就要递归很多次。
我们的目的只需要知道这些节点在同一个根下就可以,所以对这棵多叉树的构造只需要这样就可以了,如图:
我们只需要在递归的过程中,让 father[u] 接住 递归函数 find(father[u]) 的返回结果:
// 并查集里寻根的过程
int find(int i)
{
if(fa[i] == i)
return fa[i]; //递归出口,当到达了祖先位置,就返回祖先
else
{
fa[i] = find(fa[i]); // 不断往上查找祖先
return fa[i];
}
}
代码模板
// n根据题目中节点数量而定,一般比节点数量大一点就好
int n = 100;
vector<int> fa(n,0);
// 并查集初始化
void init(void)
{
for(int i = 0; i < n; i++)
fa[i] = i;
}
// 并查集里寻根的过程
int find(int i)
{
if(fa[i] == i)
return fa[i]; //递归出口,当到达了祖先位置,就返回祖先
else
{
fa[i] = find(fa[i]); // 不断往上查找祖先
return fa[i];
}
}
void join(int u, int v)
{
int u_fa = find(u); // 寻找u的根
int v_fa = find(v); // 寻找v的根
// 如果发现根相同,则说明在一个集合,不用两个节点相连直接返回
if(u_fa == v_fa)
return;
//否则v的根 = u的根
else
fa[v_fa] = u_fa;
}
// 判断 u 和 v是否找到同一个根
bool isSame(int u, int v)
{
int u_fa = find(u);
int v_fa = find(v);
return u_fa == v_fa;
}
通过模板,我们可以知道,并查集主要有三个功能。
- 寻找根节点,函数:find(int u),也就是判断这个节点的祖先节点是哪个
- 将两个节点接入到同一个集合,函数:join(int u, int v),将两个节点连在同一个根节点上
- 判断两个节点是否在同一个集合,函数:isSame(int u, int v),就是判断两个节点是不是同一个根节点
模拟过程
-
join(1, 8);
-
join(3, 8);
注意:在join(int u, int v)函数里 要分别对 u 和 v 寻根之后再进行关联。
-
join(1, 7);
-
join(8, 5);
为什么 图中 8 又直接指向了 3 了呢?因为路经压缩了 -
join(2, 9);
-
join(6, 9);
找亲戚
现在有若干家族图谱关系,给出了一些亲戚关系,如Mrry和Tom是亲戚,Tom和Ben是亲戚等等。从这些信息中,你可以推导出Marry和Ben是亲戚。请写一个程序,对于我们的关于亲戚关系的提问,以最快速度给出答案。
输入格式:
- 第一部分是以N,M开始。N为人数,这些人的编号为1,2,3…,N。下面有M行,每行有两个数a,b,表示a和b是亲戚.
- 第二部分是以Q开始。以下Q行有Q个询问,每行为c,d,表示询问c和d是否为亲戚
输出格式: 对于询问c,d,输出一行: 若c,d为亲戚,则输出"YES",否则输出"NO"
输入样例:
10 7
2 4
5 7
1 3
8 9
1 2
5 6
2 3
3
3 4
7 10
8 9
输出样例:
YES
NO
YES
并查集主要有三个功能:
- 寻找根节点,函数:find(int u),也就是判断这个节点的祖先节点是哪个
- 两个节点接入到同一个集合,函数:join(int u, int v),将两个节点连在同一个根节点上
- 判断两个节点是否在同一个集合,函数:isSame(int u, int v),就是判断两个节点是不是同一个根节点
思路
- 本题就是需要将m组数据添加到集合中,建立联系
- 熟悉差并集模板
程序实现:
#include <iostream>
#include <vector>
#include <vector>
using namespace std;
int n; // 人数
vector<int> fa(n+1);
void init(void)
{
for(int i = 1; i <= n; i++)
fa[i] = i;
}
int find(int i)
{
if(fa[i] == i)
return fa[i];
else
{
fa[i] = find(fa[i]);
return fa[i];
}
}
void join(int u, int v)
{
int u_fa = find(u);
int v_fa = find(v);
if(u_fa == v_fa)
return;
fa[v_fa] = u_fa;
}
bool isSame(int u, int v)
{
int u_fa = find(u);
int v_fa = find(v);
if(u_fa == v_fa)
return true;
else
return false;
}
int main()
{
int m, s, t, q;
cin >> n >> m;
init();
while(m--){
cin >> s >> t;
join(s,t);
}
cin >> q;
vector<bool> res(q+1);
for(int i = 1; i <= q;i++)
{
cin >> s >> t;
if(isSame(s,t))
res[i] = true;
else
res[i] = false;
}
for(int i = 1; i <= q;i++)
{
if(res[i])
cout << "YES" << endl;
else
cout << "NO" << endl;
}
return 0;
}
寻找存在的路径
寻找存在的路径
题目描述:
- 给定一个包含 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
提示信息:
思路:
- 本题思路与上题完全一致
- 主要就是集合问题,两个节点在不在一个集合,也可以将两个节点添加到一个集合中。
程序如下:
#include <iostream>
#include <vector>
using namespace std;
int n; //节点数量
vector<int> fa(n+1);
void init()
{
for(int i = 1; i <= n; i++)
fa[i] = i;
}
int find(int i)
{
if(fa[i] == i)
return i;
else
{
fa[i] = find(fa[i]);
return fa[i];
}
}
void join(int u, int v)
{
int u_fa = find(u);
int v_fa = find(v);
if(u_fa == v_fa)
return;
fa[v_fa] = u_fa;
}
bool isSame(int u, int v)
{
int u_fa = find(u);
int v_fa = find(v);
if(u_fa == v_fa)
return true;
else
return false;
}
int main()
{
int m, s, t, src, dst;
cin >> n >> m;
init();
while(m--)
{
cin >> s >> t;
join(s,t);
}
cin >> src >> dst;
if(isSame(src,dst))
cout << 1 << endl;
else
cout << 0 << endl;
return 0;
}
冗余连接
卡码网题目链接(ACM模式)
题目描述
有一个图,它是一棵树,他是拥有 n 个节点(节点编号1到n)和 n - 1 条边的连通无环无向图(其实就是一个线形图),如图1,现在在这棵树上的基础上,添加一条边(依然是n个节点,但有n条边),使这个图变成了有环图,如图2
先请你找出冗余边,删除后,使该图可以重新变成一棵树。
输入描述:
- 第一行包含一个整数 N,表示图的节点个数和边的个数。
- 后续 N 行,每行包含两个整数 s 和 t,表示图中 s 和 t 之间有一条边。
输出描述: 输出一条可以删除的边。如果有多个答案,请删除标准输入中最后出现的那条边。
输入示例:
3
1 2
2 3
1 3
输出示例:
1 3
提示信息:
图中的 1 2,2 3,1 3 等三条边在删除后都能使原图变为一棵合法的树。但是 1 3 由于是标准输出里最后出现的那条边,所以输出结果为 1 3
思路:
-
并查集可以解决什么问题:两个节点是否在一个集合,也可以将两个节点添加到一个集合中。
-
题意:无向图,返回一条可以删去的边,使得结果图是一个有着N个节点的树,如果有多个答案,则返回二维数组中最后出现的边。
-
那么我们就可以从前向后遍历每一条边(优先让前面的边连上),边的两个节点如果不在同一个集合,就加入集合(即:同一个根节点)。
-
如图,节点A 和节点 B 不在同一个集合,那么就可以将两个 节点连在一起。
-
如果边的两个节点已经出现在同一个集合里,说明着边的两个节点已经连在一起了,再加入这条边一定就出现环了。如图
已经判断 节点A 和 节点B 在在同一个集合(同一个根),如果将 节点A 和 节点B 连在一起就一定会出现环。
程序实现:
#include <iostream>
#include <vector>
using namespace std;
int n; //节点数量
vector<int> fa(n+1);
void init()
{
for(int i = 0; i < n;i++)
fa[i] = i;
}
int find(int i)
{
if(fa[i] == i)
return i;
else
{
fa[i] = find(fa[i]);
return fa[i];
}
}
void join(int u, int v)
{
int u_fa = find(u);
int v_fa = find(v);
if(u_fa == v_fa)
return;
fa[v_fa] = u_fa;
}
bool isSame(int u, int v)
{
int u_fa = find(u);
int v_fa = find(v);
return u_fa == v_fa;
}
int main()
{
int s, t;
cin >> n;
init();
for(int i = 0; i < n; i++)
{
cin >> s >> t;
if(isSame(s,t))
{
cout << s << " " << t << endl;
return 0;
}
else
join(s,t);
}
}
问1: 题意是“删除标准输入中最后出现的那条边” ,而代码分明是遇到在同一个根的两个节点立刻就返回了,怎么就求出 最后出现的那条边 了呢?
问2: 认为发现一条冗余边后,后面还可能会有一条冗余边。
题目是在 树的基础上 添加一条边,所以冗余边仅仅是一条。
到这一条可能靠前出现,可能靠后出现。
冗余连接II
卡码网题目链接(ACM模式)
题目描述:
有一种有向树,该树只有一个根节点,所有其他节点都是该根节点的后继。该树除了根节点之外的每一个节点都有且只有一个父节点,而根节点没有父节点。有向树拥有 n 个节点和 n - 1 条边。如图1,现在有一个有向图,有向图是在有向树中的两个没有直接链接的节点中间添加一条有向边。如图2:
输入一个有向图,该图由一个有着 n 个节点(节点编号 从 1 到 n),n 条边,请返回一条可以删除的边,使得删除该条边之后该有向图可以被当作一颗有向树。
输入描述:
- 第一行输入一个整数 N,表示有向图中节点和边的个数。
- 后续 N 行,每行输入两个整数 s 和 t,代表这是 s 节点连接并指向 t 节点的单向边
输入一个有向图,该图由一个有着 n 个节点(节点编号 从 1 到 n),n 条边,请返回一条可以删除的边,使得删除该条边之后该有向图可以被当作一颗有向树。
输入描述
第一行输入一个整数 N,表示有向图中节点和边的个数。
后续 N 行,每行输入两个整数 s 和 t,代表这是 s 节点连接并指向 t 节点的单向边
输出描述: 输出一条可以删除的边,若有多条边可以删除,请输出标准输入中最后出现的一条边。
输入示例:
3
1 2
1 3
2 3
输出示例:
2 3
提示信息:
在删除 2 3 后有向图可以变为一棵合法的有向树,所以输出 2 3
思路
- 本题上题冗余连接及其类似,但本题是一个有向图
- 根据有向树的性质,只有根节点入度为0,其他节点入度都为1(因为该树除了根节点之外的每一个节点都有且只有一个父节点,而根节点没有父节点)
情况一: 如果我们找到入度为2的点,那么删一条指向该节点的边就行了(因为有向树种中的入度最大为1)
找到了节点3 的入度为2,删 1 -> 3 或者 2 -> 3 。选择删顺序靠后便可。
但入度为2 还有一种情况,情况二, 只能删特定的一条边,如图:
节点3 的入度为 2,但在删除边的时候,只能删 这条边(节点1 -> 节点3)
综上,如果发现入度为2的节点 ,我们需要判断删除哪一条边,删除后本图能成为有向树。如果是删哪个都可以,优先删顺序靠后的边。
情况三: 如果没有入度为2的点,说明 图中有环了(注意是有向环),如图
对于情况三,删掉构成环的边就可以了。
程序实现:
把每条边记录下来,并统计节点入度:
int s, t;
vector<vector<int>> edges;
cin >> n;
vector<int> inDegree(n + 1, 0); // 记录节点入度
for (int i = 0; i < n; i++) {
cin >> s >> t;
inDegree[t]++;
edges.push_back({s, t});
}
前两种入度为2的情况,一定是删除指向入度为2的节点的两条边其中的一条,如果删了一条,判断这个图是一个树,那么这条边就是答案。
同时注意要从后向前遍历,因为如果两条边删哪一条都可以成为树,就删最后那一条。
代码如下:
vector<int> vec; // 记录入度为2的边(如果有的话就两条边)
// 找入度为2的节点所对应的边,注意要倒序,因为优先删除最后出现的一条边
for(int i = n - 1; i >= 0; i--){
if(inDegree[edges[i][1]] == 2){ //edges[i][1]: 输入时候第i条的入节点
vec.push_back(i);
}
}
// 有入度为2的节点
if(vec.size() > 0)
{
// 放在vec里的边已经按照倒叙放的,所以这里就优先删vec[0]这条边
if(isTreeAfterRemoveEdge(edges, vec[0])){
cout << edges[vec[0]][0] << " " << edges[vec[0]][1];
}
else{
cout << edges[vec[1]][0] << " " << edges[vec[1]][1];
}
}
再来看情况三,明确没有入度为2的情况,那么一定有向环,找到构成环的边就是要删除的边。
可以定义一个函数,代码如下:
// 在有向图里找到删除的那条边,使其变成树
void getRemoveEdge(const vector<vector<int>>& edges)
我们要解决本题要实现两个最为关键的函数:
isTreeAfterRemoveEdge()
判断删一个边之后是不是有向树getRemoveEdge()
确定图中一定有了有向环,那么要找到需要删除的那条边,此时就用到并查集了。
isTreeAfterRemoveEdge()
判断删一个边之后是不是有向树: 将所有边的两端节点分别加入并查集,遇到要要删除的边则跳过,如果遇到即将加入并查集的边的两端节点本来就在并查集了,说明构成了环。
getRemoveEdge()
确定图中一定有了有向环,那么要找到需要删除的那条边: 将所有边的两端节点分别加入并查集,如果遇到即将加入并查集的边的两端节点本来就在并查集了,说明构成了环。
完成程序:
#include <iostream>
#include <vector>
using namespace std;
int n; // 节点个数
vector<int> fa(1001, 0);
void init(void)
{
for(int i = 1; i <= n; i++)
fa[i] = i;
}
int find(int i)
{
if(i == fa[i])
return fa[i];
else{
fa[i] = find(fa[i]);
return fa[i];
}
}
void join(int u, int v)
{
int u_fa = find(u);
int v_fa = find(v);
if(u_fa == v_fa)
return;
fa[v_fa] = u_fa;
}
bool isSame(int u, int v)
{
int u_fa = find(u);
int v_fa = find(v);
if(u_fa == v_fa)
return true;
else
return false;
}
// 删一条边之后判断是不是树
bool isTreeAfterRemoveEdge(vector<vector<int>> &edges, int deleteEdge)
{
init(); // 初始化并查集
for(int i = 0; i < n; i++)
{
if(i == deleteEdge)
continue;
// 构成有向环了,一定不是树
if(isSame(edges[i][0], edges[i][1]))
return false;
join(edges[i][0], edges[i][1]);
}
return true;
}
// 在有向图里找到删除的那条边,使其变成树
void getRemoveEdge(const vector<vector<int>>& edges)
{
init(); // 初始化并查集
for(int i = 0; i < n; i++)
{
//成环的边
if(isSame(edges[i][0], edges[i][1]))
{
cout << edges[i][0] << " " << edges[i][1];
return;
}
else
join(edges[i][0], edges[i][1]);
}
}
int main()
{
int s, t;
cin >> n;
vector<vector<int>> edges; // 记录边
vector<int> inDegree(n + 1, 0); // 记录节点入度
for(int i = 0; i < n; i++)
{
cin >> s >> t;
inDegree[t]++; // 入节点数++
edges.push_back({s,t}); // 边如容器
}
vector<int> vec; // 记录入度为2的边(如果有的话就两条边)
// 找入度为2的节点所对应的边,注意要倒序,因为优先删除最后出现的一条边
for(int i = n - 1; i >= 0; i--){
if(inDegree[edges[i][1]] == 2){ //edges[i][1]: 输入时候第i条的入节点
vec.push_back(i);
}
}
// 有入度为2的节点 情况一、二
if(vec.size() > 0)
{
// 放在vec里的边已经按照倒叙放的,所以这里就优先删vec[0]这条边
if(isTreeAfterRemoveEdge(edges, vec[0])){
cout << edges[vec[0]][0] << " " << edges[vec[0]][1];
}
else{
cout << edges[vec[1]][0] << " " << edges[vec[1]][1];
}
return 0;
}
// 处理情况三
// 明确没有入度为2的情况,那么一定有有向环,找到构成环的边返回就可以了
getRemoveEdge(edges);
return 0;
}