最小生成树+二分图
Acwing算法基础课 第三章 搜索与图论(三)的内容,最小生成树和二分图。最小生成树比较简单,和迪杰斯特拉比较像;然后二分图的匈牙利算法挺有趣的(例子生动),有几个点可能会产生疑惑,文章都提及到了,希望能帮到同样疑惑的小伙伴~(图借的acwing题解的海绵宝宝and其他大佬)

最小生成树
Prim
858.Prim算法求最小生成树
给定一个 n 个点 m 条边的无向图,图中可能存在重边和自环,边权可能为负数。求最小生成树的树边权重之和,如果最小生成树不存在则输出
impossible。给定一张边带权的无向图 G=(V,E),其中 V 表示图中点的集合,E表示图中边的集合,n=|V|,m=|E|。由 V 中的全部 n 个顶点和 E 中 n−1 条边构成的无向连通子图被称为 G 的一棵生成树,其中边的权值之和最小的生成树被称为无向图 G 的最小生成树。
输入格式:第一行包含两个整数 n 和 m。接下来 m 行,每行包含三个整数 u,v,w,表示点 u 和点 v 之间存在一条权值为 w 的边。
输出格式:共一行,若存在最小生成树,则输出一个整数,表示最小生成树的树边权重之和,如果最小生成树不存在则输出
impossible。数据范围:1≤n≤500,1≤m≤10^5,图中涉及边的边权的绝对值均不超过 10000。
输入样例:
4 5 1 2 1 1 3 2 1 4 3 2 3 2 3 4 4输出样例:
6
思路分析
根据以前学过的数据结构,Prim就是“加点”,即每次选一个点加入最小生成树,直到所有点都加入树。代码实际的总体思路和迪杰斯特拉有点像,只是Prim的每一轮是从集合外中选一点 t,t是到集合内的距离最近的点,然后用 t 更新其他点;迪的 dist[i] 是 i 到起点的最短路径,而这里的 dist[i] 是 i 到集合的最短路径。

代码实现
public class _858 {
static final int INF = 0x3f3f3f3f; //定义无穷
static int N = 510;
static int n,m; //输入的图的点数和边数
static int g[][] = new int[N][N]; //用邻接矩阵存储图
static int dist[] = new int[N]; //存储某点到集合的最短路径的值
static boolean st[] = new boolean[N]; //标记是否已经确定到 某个点的最短路径
public static int Prim(){
int result = 0;
Arrays.fill(dist,INF);
//循环n次,每次树中多一个点,直到n个点全部在树中
for (int i = 0; i < n; i++) {
int t = -1;
for (int j = 1; j <= n ; j++) {
if (!st[j] && (t == -1 || dist[t]>dist[j]))
t=j;
}
// 找到的离集合最近的点的距离都是无穷 那就说明不是连通图 不存在最小生成树
if (i!=0 && dist[t] == INF)
return INF;
if (i != 0)
result += dist[t];
for (int j = 1; j <= n ; j++)
dist[j] = Math.min(dist[j],g[t][j]);
st[t] = true;
}
return result;
}
public static void main(String[] args) throws IOException {
BufferedReader bf = new BufferedReader(new InputStreamReader(System.in));
String[] s = bf.readLine().split(" ");
n = Integer.parseInt(s[0]);
m = Integer.parseInt(s[1]);
for (int i = 1; i <= n; i++)
Arrays.fill(g[i], INF);
for (int i = 0; i < m; i++) {
String[] s1 = bf.readLine().split(" ");
int a = Integer.parseInt(s1[0]);
int b = Integer.parseInt(s1[1]);
int c = Integer.parseInt(s1[2]);
g[a][b] = g[b][a] = Math.min(g[a][b],c); //无向图 取最小的那条边
}
int result = Prim();
if (result == INF)
System.out.println("impossible");
else
System.out.println(result);
}
}
t=-1那个点,去看上一个文件的迪杰斯特拉的解释。还有注意一个点,要先更新 result 再去更新集合外点的距离。因为假如一个点自身存在负环,更新到自己时这个 dist[j] 就会不停变小导致 result 一直变小。后续优化思路参考迪杰斯特拉的堆优化。
Kruskal
859. Kruskal算法求最小生成树
给定一个 n 个点 m 条边的无向图,图中可能存在重边和自环,边权可能为负数。求最小生成树的树边权重之和,如果最小生成树不存在则输出
impossible。给定一张边带权的无向图 G=(V,E),其中 V 表示图中点的集合,E 表示图中边的集合,n=|V|,m=|E|。由 V 中的全部 n 个顶点和 E 中 n−1 条边构成的无向连通子图被称为 G 的一棵生成树,其中边的权值之和最小的生成树被称为无向图 G 的最小生成树。输入格式:第一行包含两个整数 n 和 m。接下来 m 行,每行包含三个整数 u,v,w,表示点 u 和点 v 之间存在一条权值为 w 的边。
输出格式:共一行,若存在最小生成树,则输出一个整数,表示最小生成树的树边权重之和,如果最小生成树不存在则输出
impossible。数据范围:1≤n≤105,1≤m≤2∗105。图中涉及边的边权的绝对值均不超过 1000。
输入样例:
4 5 1 2 1 1 3 2 1 4 3 2 3 2 3 4 4输出样例:
6
思路
(比较简单,不多说,看图)判断是否连通时有用到之前学的并查集。

代码实现
public class _859 {
static int N = 200010;
static int n,m; //输入的图的点数和边数
static int[] p = new int[N];
static Edge[] edges = new Edge[N]; //存储边的数组
//查找 x 所在集合的操作(返回该节点的祖先)
public static int find(int x){
if (x != p[x]) //不断向上找爸爸,并进行路径压缩
p[x] = find(p[x]); //给px赋值就是在路径压缩——让路上所有父辈将来都直接变成祖先的儿子(如果直接递归执行find就没有实现路径压缩)
return p[x];
}
public static void main(String[] args) throws IOException {
BufferedReader bf = new BufferedReader(new InputStreamReader(System.in));
String[] s = bf.readLine().split(" ");
n = Integer.parseInt(s[0]);
m = Integer.parseInt(s[1]);
for (int i = 0; i < m; i++) {
String[] s1 = bf.readLine().split(" ");
int a = Integer.parseInt(s1[0]);
int b = Integer.parseInt(s1[1]);
int c = Integer.parseInt(s1[2]);
edges[i] = new Edge(a,b,c);
}
Arrays.sort(edges,0,m);
//初始化并查集
for (int i = 1; i <= n ; i++)
p[i] = i;
int res = 0; //记录最小生成树边权总和
int count = 0; //记录目前一共加入最小生成树的边数
for (int i = 0; i < m; i++) {
int a = edges[i].a;
int b = edges[i].b;
int w = edges[i].w;
if (find(a) != find(b)){ //如果a和b不在一个集合(即二者的祖先不一样)
p[find(a)] = find(b); //让a的祖先的爸爸=b的祖先(即把a合并到b集合)
count++;
res += w;
}
}
if (count < n-1)
System.out.println("impossible");
else
System.out.println(res);
}
}
class Edge implements Comparable<Edge>{
int a,b,w;
public Edge(int a, int b, int w) {
this.a = a;
this.b = b;
this.w = w;
}
@Override
//实现Comparable接口,重写compareTo方法,便于Arrays.sort()方法排序
public int compareTo(Edge e) {
return Integer.compare(w, e.w);//this.w > e.w时,返回1
}
}
二分图
二分图的定义
有两顶点集且图中每条边的的两个顶点分别位于两个顶点集中,每个顶点集中没有边直接相连接!
说人话的定义:图中点通过移动能分成左右两部分,左侧的点只和右侧的点相连,右侧的点只和左侧的点相连。
下图就是个二分图:

染色法
含义
-
开始对任意一未染色的顶点染色。
-
判断其相邻的顶点中,若未染色则将其染上和相邻顶点不同的颜色。
-
若已经染色且颜色和相邻顶点的颜色相同则说明不是二分图,若颜色不同则继续判断。
-
bfs和dfs可以搞定
860. 染色法判定二分图
给定一个 n 个点 m 条边的无向图,图中可能存在重边和自环。请你判断这个图是否是二分图。
输入格式:第一行包含两个整数 n 和 m 。接下来 m 行,每行包含两个整数 u 和 v,表示点 u 和点 v 之间存在一条边。
输出格式:如果给定图是二分图,则输出
Yes,否则输出No。数据范围:1≤n,m≤10^5
输入样例:
4 4 1 3 1 4 2 3 2 4输出样例:
Yes
思路

遍历所有点,如果没染色就调用dfs:1.让他染;2.看他的邻接点:(1)没染,则染,并且递归调用dfs一直往深度染;(2)如果染了但染的是同色,就直接返回false。
代码实现
public class _860 {
static int N = 200010;
static int[] color = new int[N]; //标记染色值的数组。默认0未染色,1、2两种色
static int h[] = new int[N]; //存储各节点的第一条边的下标索引
static int e[] = new int[N]; //第idx条边的终点的编号
static int ne[] = new int[N]; //与 第 idx 条边 同起点 的 下一条边 的 idx
static int idx;
public static void add(int a, int b){
e[idx] = b;
ne[idx] = h[a];
h[a] = idx++;
}
public static boolean dfs(int x, int c){
color[x] = c;
//遍历x的所有邻点 i是x为起点的第一条边的边号
for (int i = h[x]; i != -1 ; i=ne[i]) {
int j = e[i];
//如果邻点j还没染色
if (color[j] == 0) {
//如果 x 的颜色是2,则j染成1;反之x为1,j染成2
if (!dfs(j, 3 - c))
return false;
}
//如果邻点染色了且颜色和x一样
else if (color[j] == c)
return false;
}
return true;
}
public static void main(String[] args) throws Exception{
BufferedReader bf = new BufferedReader(new InputStreamReader(System.in));
String[] s = bf.readLine().split(" ");
int n = Integer.parseInt(s[0]);
int m = Integer.parseInt(s[1]);
Arrays.fill(h,-1);
for (int i = 0; i < m; i++) {
s = bf.readLine().split(" ");
int a = Integer.parseInt(s[0]);
int b = Integer.parseInt(s[1]);
add(a,b);
add(b,a);
}
boolean flag = true;
for (int i = 1; i <= n ; i++) {
if (color[i] == 0){
if (!dfs(i,1)){
flag = false;
break;
}
}
}
if (flag)
System.out.println("Yes");
else
System.out.println("No");
}
}
匈牙利算法
861. 二分图的最大匹配
给定一个二分图,其中左半部包含 n1 个点(编号 1∼n1),右半部包含 n2 个点(编号 1∼n2),二分图共包含 m 条边。数据保证任意一条边的两个端点都不可能在同一部分中。请你求出二分图的最大匹配数。
二分图的匹配:给定一个二分图 G,在 G 的一个子图 M 中,M 的边集 {E} 中的任意两条边都不依附于同一个顶点,则称 M 是一个匹配。
二分图的最大匹配:所有匹配中包含边数最多的一组匹配被称为二分图的最大匹配,其边数即为最大匹配数。
输入格式:第一行包含三个整数 n1、 n2 和 m。接下来 m 行,每行包含两个整数 u 和 v,表示左半部点集中的点 u 和右半部点集中的点 v 之间存在一条边。
输出格式:输出一个整数,表示二分图的最大匹配数。
数据范围:1≤n1,n2≤500,1≤u≤n1,1≤v≤n2,1≤m≤10^5
输入样例:
2 2 4 1 1 1 2 2 1 2 2输出样例:
2
思路
形象化总体思路
根据上述二分图的匹配和最大匹配的概念,这里借助一个很形象的例子去理解匈牙利算法找最大匹配(虽然感觉有点恶臭)。假如你是一个媒人,你手下有 n1 个男生和 n2 个女生,n1个男生对这 n2 个女生中的若干个有好感;n2 个女生也对这 n1 个男生中的若干个有好感。在满足不 ”脚踏多只船“ 的情况下,尽量多的让两两成对。问题就是,怎么牵线能让最多对的男女在一起。
已经把问题简化成通俗的例子,现在再说一下对应到原题的关系:
二分图左半部点集 = n1 个男生 二分图右半部点集 = n2 个女生 二分图中的边 = 男女生之间的好感关系
题给的二分图 = n1 个男生 + n2 个女生 + 他们之间的好感关系(连线表示有好感) 组成的整个图
匹配 = 任意男女生都不存在 “脚踏多只船” 的情况 最大匹配 = 凑成的鸳鸯对数最多
图解匈牙利算法具体过程
下面用图展示具体实现步骤。假设所给二分图如下:
匈牙利算法步骤如下:
-
给 1 号男生找对象,发现第一个和他相连的1号女生还名花无主,连上一条蓝线:

-
给 2 号男生找对象,发现第一个和他相连的 2 号女生还名花无主,连上一条蓝线:

-
给 3 号男生找对象,发现第一个和他相连的 1 号女生已经名花有主,这时,我们试着给这位有主的名花的主(也就是1号男生)另外分配一个女生。(黄色表示这条边被临时拆掉):

-
与1号男生相连的第二个女生是2号女生,但是2号女生也有主了,这时,我们再试着给2号女生的原配(即2号男生)重新找个女生(注意这个步骤和上面是一样的,这是一个递归的过程):

-
此时发现2号男生还能找到3号女生,那么之前的问题迎刃而解了,回溯回去:

-
于是前 3 步(给1、2、3号男生找对象)的结果就是:

-
接下来给 4 号男生找对象。很遗憾,按照第三步的方法我们没法给4号男生腾出来一个女生。至此,匈牙利算法结束。
代码实现
public class _861 {
static int N = 510;
static int M = 100010;
static int n1,n2,m;
static boolean[] st = new boolean[N]; //临时预定数组,st[j]=a表示一轮模拟匹配中,女孩j被男孩a预定了。
static int[] match = new int[N]; //match[j]=a,表示女孩j的现有配对男友是a
static int h[] = new int[N]; //存储各节点的第一条边的下标索引
static int e[] = new int[M]; //第idx条边的终点的编号
static int ne[] = new int[M]; //与 第 idx 条边 同起点 的 下一条边 的 idx
static int idx;
public static void add(int a, int b){
e[idx] = b;
ne[idx] = h[a];
h[a] = idx++;
}
//为男生x尝试找对象的方法,能有办法找到不与前面的兄弟冲突的对象(有冲突解决冲突也算)则返回true,反之返回false
public static boolean find(int x){
//遍历男生x所有有好感的女生
for (int i = h[x]; i != -1; i=ne[i]) {
int j = e[i]; //j为男生x有好感的女生
if (!st[j]) { //如果该女生没被临时预定
st[j] =true; //那就预定!
if (match[j] == 0 || find(match[j])) { //如果她还没有男朋友 or 现任有其他备胎
match[j] = x; //j换现男友了
return true; //并且此次匹配成功为男生x找到女朋友
}
}
}
return false;
}
public static void main(String[] args) throws Exception{
BufferedReader bf = new BufferedReader(new InputStreamReader(System.in));
String[] s = bf.readLine().split(" ");
n1 = Integer.parseInt(s[0]);
n2 = Integer.parseInt(s[1]);
m = Integer.parseInt(s[2]);
Arrays.fill(h,-1);
for (int i = 0; i < m; i++) {
s = bf.readLine().split(" ");
int a = Integer.parseInt(s[0]);
int b = Integer.parseInt(s[1]);
add(a,b);
}
int res = 0; //最终成对的鸳鸯数(二分图的最大匹配数)
//为所有的男生试着找对象
for (int i = 1; i <= n1 ; i++) {
Arrays.fill(st,false); //每为一个男生找对象时,临时预定数组都要清空
//如果找到合理女友,匹配数就+1
if (find(i))
res++;
}
System.out.println(res);
}
}
重点在于这个 find 方法。方法的整体逻辑已经在注释中写的很清楚,下面说几个会产生疑惑的点:
-
st[]的作用:
对于男生x,遍历他所有喜欢的女生。如果某个女生 j 没有被他预定过的话,就标记 j 被他预定,即 st[j]=true。
这时如果:(1) j 还没有匹配过,即 match[j]==0 ,那这个预定就成真了, x 立即得到 j,有 match[j]=x;
(2)j 已经有现男友 k 了,那我们就调用 find(k),看看这个兄弟 k 能不能找到别的备胎。既然是递归调用 find,那必然还是会遍历 k 的所有喜欢的女生,就一定会再找到 j。既然是找备胎,肯定不能再找 j 了。因此st[j]=true 的作用就在此了:能让 k 在找备胎的时候第一时间跳过 j。如果 find(k)返回true,即 x 的好兄弟 k 能找到备胎,那么 j 就让给 x了,预定也成真。
(3)如果 (1)(2)都不满足,即 女生 j 有男朋友了而且男朋友让不了一点,那男生 x 就单着吧,就回到主函数的循环里给下一个男生找对象。
-
输入加边的时候,编号是有可能重复的(因为左右部分的集合是分开编号的),为什么没有影响呢?(为什么还可以像之前那样处理呢?为什么不需要 add(b,a) 呢?)
因为题目的大前提就是这个图是二分图,加的边一定是 左集合的 u 与右集合的 v 相连,不会出现比如左集合中的两点连线这种情况。因此 add 方法的含义就不再是,增加图中两点之间的边了,而是增加两子图之间的边。具体而言,对于 add(a,b) 方法,含义为:加入一条 左集合编号为 a 与右集合编号为 b 的连线。按照这个定义,加入一条左集合编号为 u 到 右集合编号为 v 的边(即题目中输入边的定义),那就执行add(u,v);如果你再执行 add(v,u),意义就变成了 加入一条 左集合编号为 v 的点与右集合编号为 u 的点的连线,显然是错的。
本文围绕最小生成树和二分图展开,介绍Prim和Kruskal算法求最小生成树,包括思路分析与代码实现。还阐述二分图定义,用染色法判定二分图,以及匈牙利算法求二分图最大匹配,结合形象例子和图解说明算法过程,并对代码中易疑惑点进行解释。





