Acwing - 算法基础课 - 笔记(九)

267 阅读6分钟

搜索与图论(三)

这一节讲解的是最小生成树和二分图

最小生成树

什么是最小生成树?首先,给定一个节点数是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算法

基本思路:

  1. 先将所有边,按照权重,从小到大排序

  2. 从小到大枚举每条边(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种),只要染色过程中没有出现矛盾,说明该图是一个二分图,否则,说明不是二分图。

染色法

练习题:acwing - 860: 染色法判定二分图

先上个错误解法

#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();
	}
}

课后题:acwing - 257: 关押罪犯

可以使用二分图来做,也可以使用并查集

//TODO

匈牙利算法

匈牙利算法,是给定一个二分图,用来求二分图的最大匹配的。

给定一个二分图G,在G的一个子图M中,M的边集中的任意两天边,都不依附于同一顶点,则称M是一个匹配。就是每个点只会有一条边相连,没有哪一个点,同时连了多条边。(参考yxc的例子:男生女生恋爱配对,最多能凑出多少对)

所有匹配中包含边数最多的一组匹配,被称为二分图的最大匹配。其边数即为最大匹配数。 假设一个二分图,左半边部分节点表示男生,右半边部分节点表示女生。一个男生节点和一个女生节点连了一条边,则表示这两个人之间有感情基础,可以发展为情侣。当我们把一对男女凑成一对时,称为这两个节点匹配。 匈牙利算法的核心思想是:我们枚举左半边所有男生(节点),每次尝试给当前男生找对象。我们先找到这个男生看上的全部女生。(即找到这个节点连接的所有右侧的节点)。遍历这些女生,当一个女生没有和其他男生配对时,直接将这个女生和这个男生配对。则该男生配对成功。当这个女生已经和其他男生配对了,则尝试给这个女生的男朋友,找一个备胎。如果这个女生的男朋友有其他可选择的备胎。则将这个女生的男朋友和其备胎配对。然后将这个女生和当前男生配对。如此找下去...

练习题:acwing - 861: 二分图的最大匹配

#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;
}

课后题:acwing - 372: 棋牌摆放

//TODO

小结

二分图相关的算法主要有2个

  • 染色法:是用来判断一个图是否是二分图的
  • 匈牙利算法:是用来求二分图的最大匹配的