搜索与图论(三)
这一节讲解的是最小生成树和二分图
最小生成树
什么是最小生成树?首先,给定一个节点数是n,边数是m的无向连通图G。
则由全部的n个节点,和n-1条边构成的无向连通图被称为G的一颗生成树,在G的所有生成树中,边的权值之和最小的生成树,被称为G的最小生成树。
有两种常用算法:
-
Prim算法(普利姆)
- 朴素版Prim(时间复杂度O(n^2^),适用于稠密图)
- 堆优化版Prim(时间复杂度O(mlogn),适用于稀疏图)
-
Kruskal算法(克鲁斯卡尔)
适用于稀疏图,时间复杂度O(mlogm)
对于最小生成树问题,如果是稠密图,通常选用朴素版Prim算法,因为其思路比较简洁,代码比较短,如果是稀疏图,通常选用Kruskal算法,因为其思路比Prim简单清晰。堆优化版的Prim通常不怎么用。
Prim算法
这里只讲朴素版Prim。其算法流程如下
(其中用集合s
表示,当前已经在连通块中的所有的点)
1. 初始化距离, 将所有点的距离初始化为+∞
2. n次循环
1. t <- 找到不在集合s中, 且距离最近的点
2. 用t来更新其他点到集合s的距离
3. 将t加入到集合s中
注意,一个点t到集合s的距离,指的是:若点t和集合s中的3个点有边相连。则点t到集合s的距离就是,t与3个点相连的边中,权重最小的那条边的权重。
练习图:acwing - 858: Prim算法求最小生成树
题解:(C++)
#include<iostream>
#include<cstring>
using namespace std;
const int N = 510, INF = 0x3f3f3f3f;
int n, m;
int g[N][N], d[N];
bool visited[N];
void prim() {
memset(d, 0x3f, sizeof d); // 初始化距离为正无穷
int sum = 0;
for(int i = 1; i <= n; i++) {
// 循环n次
// 选出距离集合s最小的点
int t = 0;
for(int j = 1; j <= n; j++) {
if(!visited[j] && d[j] <= d[t]) t = j; // 这里用<=, 可以避免对第一次选点做特判
}
if(i == 1) d[t] = 0;// 第一次加入集合的点, 其到集合的距离为0
if(d[t] == INF) {
// 选中的点距离是正无穷, 无效
printf("impossible\n");
return;
}
// 把这个点放到集合s里
visited[t] = true;
sum += d[t]; // 这次放进来的
// 更新其他点到集合s的距离,
for(int j = 1; j <= n; j++) {
if(!visited[j] && g[t][j] != INF && g[t][j] < d[j]) {
d[j] = g[t][j];
}
}
}
printf("%d\n", sum);
}
int main() {
memset(g, 0x3f, sizeof g);
scanf("%d%d", &n, &m);
while(m--) {
int x, y, w;
scanf("%d%d%d", &x, &y, &w);
g[x][y] = min(g[x][y], w);
g[y][x] = g[x][y];
}
prim();
return 0;
}
题解:(Java)
import java.util.Scanner;
/**
* @Author yogurtzzz
* @Date 2021/6/28 16:43
**/
public class Main {
static int INF = 0x3f3f3f3f;
static Scanner scanner = new Scanner(System.in);
public static void main(String[] args) {
int n = readInt(), m = readInt();
int[][] g = new int[n + 1][n + 1];
for(int i = 1; i <= n; i++)
for (int j = 1; j <= n; j++) g[i][j] = INF;
while(m-- > 0) {
int u = readInt(), v = readInt(), w = readInt();
g[v][u] = g[u][v] = Math.min(g[u][v], w); // 无向图, 连两条边
}
int res = prim(g, n);
if (res == INF) System.out.println("impossible");
else System.out.println(res);
}
static int prim(int[][] g, int n) {
int[] d = new int[n + 1];
boolean[] st = new boolean[n + 1];
for(int i = 0; i < n + 1; i++) d[i] = INF;
int sum = 0; // 最小生成树的边权之和
// 循环n次, 每次选择一个不在集合s中, 但是距离集合s最小的点
for(int i = 1; i <= n; i++) {
// 遍历, 找出一个不在s中, 距离最小的点
int t = 0;
for(int j = 1; j <= n; j++) {
if (!st[j] && d[j] <= d[t]) t = j;
}
if (i == 1) d[t] = 0; // 第一次选取的点, 距离应当是0
if (d[t] == INF) return INF; // 若选出来的点到集合的距离是正无穷, 则说明不联通
sum += d[t]; // 将这条边加入到生成树
st[t] = true; // 将点t加入到集合s
// 更新其他点到集合的距离, 只更新和t相连的边
for(int j = 1; j <= n; j++) {
if (!st[j] && g[t][j] != INF && d[j] > g[t][j]) {
d[j] = g[t][j];
}
}
}
return sum;
}
static int readInt() {
return scanner.nextInt();
}
}
Kruskal算法
基本思路:
先将所有边,按照权重,从小到大排序
从小到大枚举每条边(a,b,w)
若a,b不连通,则将这条边,加入集合中(将a点和b点连接起来)
Kruskal算法初始时,相当于所有点都是独立的,没有任何边。
Kruskal不需要用邻接表或者邻接矩阵来存图,只需要存所有边就可以了
其实就是并查集的简单应用,可以参考acwing - 837: 连通块中点的数量
练习题:acwing - 859: Kruskal算法求最小生成树
题解:(C++)
#include<iostream>
#include<cstring>
#include<algorithm>
using namespace std;
const int N = 1e5 + 10, M = 2e5 + 10;
struct Edge {
int a, b, w;
bool operator < (const Edge& W) {
return w < W.w;
}
} edges[M];
int n, m;
int p[N];
int find(int x) {
if(x != p[x]) p[x] = find(p[x]);
return p[x];
}
void kruskal() {
// 先对所有边从小到大排序
sort(edges, edges + m);
int totalW = 0, edgeCtn = 0;
// 枚举全部边
for(int i = 0; i < m; i++) {
int a = edges[i].a, b = edges[i].b, w = edges[i].w;
a = find(a);
b = find(b);
if(a != b) {
// 若a和b不连通, 则加入这条边
p[a] = b; // 将a和b连通
totalW += w;
edgeCtn++;
}
}
if(edgeCtn == n - 1) printf("%d\n", totalW);
else printf("impossible\n");
}
int main() {
scanf("%d%d", &n, &m);
for(int i = 0; i < m; i++) {
int a, b, w;
scanf("%d%d%d", &a, &b, &w);
edges[i] = {a, b, w};
}
for(int i = 1; i <= n; i++) p[i] = i;
kruskal();
return 0;
}
题解:(Java)
import java.util.Arrays;
import java.util.Scanner;
/**
* @Author yogurtzzz
* @Date 2021/6/30 16:42
**/
public class Main {
static 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
public int compareTo(Edge o) {
return w - o.w;
}
}
static Scanner scanner = new Scanner(System.in);
public static void main(String[] args) {
int n = readInt(), m = readInt();
Edge[] edges = new Edge[m];
int[] p = new int[n + 1];
for(int i = 0; i < m; i++) {
int a = readInt(), b = readInt(), w = readInt();
edges[i] = new Edge(a, b, w);
}
// 初始化所有点都是孤立的
for(int i = 1; i <= n; i++) p[i] = i;
kruskal(edges, n, m, p);
}
static void kruskal(Edge[] edges, int n, int m, int[] p) {
// 1. 先按权重从小到大, 对所有边排序
Arrays.sort(edges);
int totalWeight = 0, edgeCtn = 0;
// 2. 遍历所有边
for(int i = 0; i < m; i++) {
int a = edges[i].a, b = edges[i].b, w = edges[i].w;
a = find(a, p);
b = find(b, p);
if (a != b) {
// a和b还不连通, 则连接
p[a] = b;
totalWeight += w;
edgeCtn++;
}
}
// 若存在最小生成树, 则加入的边一共是 n-1 条
if (edgeCtn == n - 1) System.out.println(totalWeight);
else System.out.println("impossible");
}
static int find(int x, int[] p) {
if (x != p[x]) p[x] = find(p[x], p);
return p[x];
}
static int readInt() {
return scanner.nextInt();
}
}
二分图
首先,什么是二分图呢?
二分图指的是,可以将一个图中的所有点,分成左右两部分,使得图中的所有边,都是从左边集合中的点,连到右边集合中的点。而左右两个集合内部都没有边。图示如下
这一节讲了2部分内容,分别是染色法和匈牙利算法。
其中染色法是通过深度优先遍历实现,时间复杂度是O(n×m);匈牙利算法的时间复杂度理论上是O(n×m),但实际运行时间一般远小于O(n×m)。
图论中的一个重要性质:一个图是二分图,当且仅当图中不含奇数环
奇数环,指的是这个环中边的个数是奇数。(环中边的个数和点的个数是相同的)
在一个环中,假设共有4个点(偶数个),由于二分图需要同一个集合中的点不能互相连接。
则1号点属于集合A,1号点相连的2号点就应当属于集合B,2号点相连的3号点应当属于集合A,3号点相连的4号点应当属于集合B。4号点相连的1号点应当属于集合A。这样是能够二分的。
而若环中点数为奇数,初始时预设1号点属于集合A,绕着环推了一圈后,会发现1号点应当属于集合B。这就矛盾了。所以存在奇数环的话,这个图一定无法二分。
可以用染色法来判断一个图是否是二分图,使用深度优先遍历,从根节点开始把图中的每个节点都染色,每个节点要么是黑色要么是白色(2种),只要染色过程中没有出现矛盾,说明该图是一个二分图,否则,说明不是二分图。
染色法
先上个错误解法
#include<iostream>
#include<cstring>
#include<algorithm>
using namespace std;
const int N = 1e5 + 10, M = 2e5 + 10;
// 使用邻接表来存储图
int h[N], e[M], ne[M], idx;
int n, m;
int color[N];
void add(int x, int y) {
// 链表的头插法
e[idx] = y;
ne[idx] = h[x];
h[x] = idx++;
}
bool dfs(int x) {
// 深搜这个节点的全部子节点
for(int i = h[x]; i != -1; i = ne[i]) {
int u = e[i]; // 子节点
if(color[u] == -1) {
// 子节点还未染色, 则直接染色, 并深搜
color[u] = !color[x];
if(!dfs(u)) return false;
} else if(color[u] == color[x]) return false; // 若子节点和父节点颜色一致, 则说明矛盾
}
// 深搜结束, 未出现矛盾, 则染色成功, 判定是二分图
return true;
}
int main() {
memset(h, -1, sizeof h); // 初始化空链表
memset(color, -1, sizeof color); // 颜色初始化为-1, 表示还未染色
scanf("%d%d", &n, &m);
while(m--) {
int x, y;
scanf("%d%d", &x, &y);
add(x, y); // 无向图, 加两条边
add(y, x);
}
color[1] = 0; // 给1号点先染个色
// 不能直接从一个点进行dfs,因为可能整个图不是一个连通图
// 而其他的连通块不一定是二分图
// 所以要对1~n所有点依次进行染色
if(dfs(1)) printf("Yes\n");
else printf("No\n");
return 0;
}
不能直接从一个点直接进行dfs,因为可能整个图不是一个连通图,此时若从一个点进行dfs,则只能把这个点所在的连通块都染色,无法触及到其他的连通块,而其他的连通块不一定是个二分图,所以要对1~n所有点依次进行染色,确保全部点都被染色。
正确解法如下
#include<iostream>
#include<cstring>
#include<algorithm>
using namespace std;
const int N = 1e5 + 10, M = 2e5 + 10; // 由于是无向图, 需要建两条边, 所以边数设为2倍
// 使用邻接表来存储图
int h[N], e[M], ne[M], idx; // 注意这里单链表的实现, 数组大小开为M
int n, m;
int color[N];
void add(int x, int y) {
// 链表的头插法
e[idx] = y;
ne[idx] = h[x];
h[x] = idx++;
}
bool dfs(int x) {
// 深搜这个节点的全部子节点
for(int i = h[x]; i != -1; i = ne[i]) {
int u = e[i]; // 子节点
if(color[u] == -1) {
// 子节点还未染色, 则直接染色, 并深搜
color[u] = !color[x];
if(!dfs(u)) return false;
} else if(color[u] == color[x]) return false; // 若子节点和父节点颜色一致, 则说明矛盾, 自环应该也算矛盾?
}
// 深搜结束, 未出现矛盾, 则染色成功, 判定是二分图
return true;
}
int main() {
memset(h, -1, sizeof h); // 初始化空链表
memset(color, -1, sizeof color); // 颜色初始化为-1, 表示还未染色
scanf("%d%d", &n, &m);
while(m--) {
int x, y;
scanf("%d%d", &x, &y);
add(x, y);
add(y, x);
}
bool flag = true;
// 依次对所有点进行染色
for(int i = 1; i <= n; i++) {
if(color[i] == -1) {
// 该点还未染色, 则直接染色, 随便染一个色即可(0或1), 并dfs, dfs完成后, 就对这个点所在的连通块都染上了色
color[i] = 0;
// 进行dfs, 若在对这个连通块染色的过程中出现矛盾, 则直接break
if(!dfs(i)) {
flag = false;
break;
}
}
}
if(flag) printf("Yes\n");
else printf("No\n");
return 0;
}
题解:Java
import java.util.*;
/**
* @Author yogurtzzz
* @Date 2021/6/30 16:42
**/
public class Main {
static Scanner scanner = new Scanner(System.in);
static int[] h, e, ne;
static int idx;
static int[] color;
public static void main(String[] args) {
int n = readInt(), m = readInt();
h = new int[n + 1];
e = new int[2 * m + 1];
ne = new int[2 * m + 1];
color = new int[n + 1];
Arrays.fill(h, -1);
while(m-- > 0) {
int a = readInt(), b = readInt();
add(a, b);
add(b, a);
}
boolean flag = true;
// 染色
for(int i = 1; i <= n; i++) {
if (color[i] == 0) {
// 还没有被染色
color[i] = 1; // 染色
if (!dfs(i)) { // 深搜, 深搜结束后该点所在的连通块的所有点都会被染色
flag = false;
break;
}
}
}
if (flag) System.out.println("Yes");
else System.out.println("No");
}
static boolean dfs(int x) {
for(int i = h[x]; i != -1; i = ne[i]) {
int u = e[i];
if (color[u] == 0) {
// 还未被染色, 直接染
color[u] = 3 - color[x];
// 染完深搜
if (!dfs(u)) return false;
} else if (color[u] == color[x]) return false;
}
return true;
}
static void add(int a, int b) {
e[idx] = b;
ne[idx] = h[a];
h[a] = idx++;
}
static String readString() {
return scanner.next();
}
static int readInt() {
return scanner.nextInt();
}
}
可以使用二分图来做,也可以使用并查集
//TODO
匈牙利算法
匈牙利算法,是给定一个二分图,用来求二分图的最大匹配的。
给定一个二分图G,在G的一个子图M中,M的边集中的任意两天边,都不依附于同一顶点,则称M是一个匹配。就是每个点只会有一条边相连,没有哪一个点,同时连了多条边。(参考yxc的例子:男生女生恋爱配对,最多能凑出多少对)
所有匹配中包含边数最多的一组匹配,被称为二分图的最大匹配。其边数即为最大匹配数。 假设一个二分图,左半边部分节点表示男生,右半边部分节点表示女生。一个男生节点和一个女生节点连了一条边,则表示这两个人之间有感情基础,可以发展为情侣。当我们把一对男女凑成一对时,称为这两个节点匹配。 匈牙利算法的核心思想是:我们枚举左半边所有男生(节点),每次尝试给当前男生找对象。我们先找到这个男生看上的全部女生。(即找到这个节点连接的所有右侧的节点)。遍历这些女生,当一个女生没有和其他男生配对时,直接将这个女生和这个男生配对。则该男生配对成功。当这个女生已经和其他男生配对了,则尝试给这个女生的男朋友,找一个备胎。如果这个女生的男朋友有其他可选择的备胎。则将这个女生的男朋友和其备胎配对。然后将这个女生和当前男生配对。如此找下去...
#include<iostream>
#include<cstring>
#include<algorithm>
using namespace std;
const int N = 1010, M = 1e5 + 10;
int h[N], e[M], ne[M], idx;
int match[N];
bool st[N]; // 状态变量
int n1, n2, m;
void add(int a, int b) {
e[idx] = b;
ne[idx] = h[a];
h[a] = idx++;
}
// 给一个男生找女朋友
bool find(int x) {
// 找出这个男生所有看上的女生
for(int i = h[x]; i != -1; i = ne[i]) {
int u = e[i]; // 女生节点编号
if(st[u]) continue; // 如果这个女生已经被标记, 则跳过
st[u] = true; // 先把这个女生标记, 使得后续递归时时跳过这个女生
if(match[u] == 0 || find(match[u])) {
// 如果当前这个女生没有被匹配, 或者能够给这个女生已匹配的男生另外找个备胎, 则可以
match[u] = x;
return true;
}
}
return false; // 找了一圈还是不行
}
int main() {
memset(h, -1, sizeof h);
scanf("%d%d%d", &n1, &n2, &m);
while(m--) {
int a, b;
scanf("%d%d", &a, &b);
add(a, b); // 只从左半边连到右半边
}
// 枚举左半边所有点
int res = 0;
for(int i = 1; i <= n1; i++) {
// 每次给一个男生找女朋友时, 清空状态变量
memset(st, false, sizeof st);
if(find(i)) res++;
}
printf("%d\n", res);
return 0;
}
//TODO
小结
二分图相关的算法主要有2个
- 染色法:是用来判断一个图是否是二分图的
- 匈牙利算法:是用来求二分图的最大匹配的