第三章 搜索与图论(三)
定义无向连通图的最小生成树 (Minimum Spanning Tree,MST)为边权和最小的生成树 。
注意:只有连通图才有生成树,而对于非连通图,只存在生成森林。
可参考:最小生成树
最小生成树问题对应的图都是无向图,一般有两种常用的解法:
Prim算法:(和Dijkstra算法很像)
朴素版Prim算法——稠密图,时间复杂度$O(n^2)$;
堆优化版Prim算法——稀疏图,时间复杂度$O(m \log n)$
Kruskal算法——稀疏图,时间复杂度$O(m \log n)$
算法的选择:如果是稠密图,一般就用朴素版Prim算法;如果是稀疏图,一般就用Kruskal算法。堆优化版Prim算法一般不常用。
朴素版Prim算法 朴素版Prim算法适用于解决稠密图的最小生成树问题,其思路和Dijkstra算法很像:
1 2 3 4 5 6 7 8 9 10 初始化所有距离为正无穷 dist[i] = INF; for (i = 0 ; i < n; i++){ t = 集合s外距离最近的点; 用t更新其他点到**集合**的距离; s[t] = true ; } 生成树就是:每次选到的t,其距离对应的那条边,所组成的树
例题:Prim算法求最下生成树(Acwing 858)
给定一个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 \le n \le 500, 1 \le m \le 10^5$,图中涉及边的边权的绝对值均不超过10000。
(实际问题:如在城市之间铺路,允许路之间交叉,求铺路总长度的最小值)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 #include <cstring> #include <iostream> #include <algorithm> using namespace std ;const int N = 510 , INF = 0x3f3f3f3f ;int n, m;int g[N][N]; int dist[N]; bool st[N]; int prim () { memset (dist, 0x3f , sizeof dist); int res = 0 ; 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 && dist[t] == INF) return INF; if (i) res += dist[t]; for (int j = 1 ; j <= n; j++) dist[j] = min (dist[j], g[t][j]); st[t] = true ; } return res; } int main () { scanf ("%d%d" , &n, &m); memset (g, 0x3f , sizeof g); while (m--) { int a, b, w; scanf ("%d%d%d" , &a, &b, &w); g[a][b] = g[b][a] = min (g[a][b], w); } int t = prim(); if (t == INF) puts ("impossible" ); else printf ("%d\n" , t); return 0 ; }
Prim算法的堆优化思路和Dijkstra堆优化的思路是一样的:用堆维护上面的dist[]数组,每次找最小值的时间复杂度是$O(1)$,共执行$n$次就是$O(n)$;更新堆中的一个元素是$O(\log n)$,共$m$条边,执行了$m$次就是$O(m \log n)$,所以堆优化版的Prim算法的时间复杂度就是$O(m \log n)$。
Kruskal算法 Kruskal算法适用于解决稀疏图的最小生成树问题,其思路如下:
1 2 3 4 5 6 将所有边按权重从小到大排序; 从小到大枚举每条边 a——b,权重是w if a,b不连通(边不在集合中) 将这条边加入到集合中(其实就是将a和b之间连一条边)
例题:Kruskla算法求最小生成树(Acwing 858)
题目和前一道题一样,数据范围变了变:$1 \le n≤10^5, 1 \le m \le 2∗10^5$,显然这是一个稀疏图问题。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 #include <iostream> #include <algorithm> using namespace std ;const int N = 100010 , M = 200010 , INF = 0x3f3f3f3f ;int n, m;int p[N]; struct Edge { int a, b, w; bool operator < (const Edge &E) const { return w < E.w; } }edges[M]; int find (int x) { if (p[x] != x) p[x] = find (p[x]); return p[x]; } int kruskal () { sort(edges, edges + m); for (int i = 1 ; i <= n; i++) p[i] = i; int res = 0 , cnt = 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) { p[a] = b; res += w; cnt ++; } } if (cnt < n - 1 ) return INF; return res; } 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}; } int t = kruskal(); if (t == INF) puts ("impossible" ); else printf ("%d\n" , t); return 0 ; }
染色法判定二分图 二分图:节点由两个集合组成,且两个集合内部没有边的图 。换言之,存在一种方案,将节点划分成满足以上性质的两个集合。
可参考:二分图 ,介绍了二分图的概念和性质
二分图,通常有两类问题:
染色法判定二分图,(DFS),时间复杂度$O(n+m)$
匈牙利算法,最坏情况下时间复杂度$O(nm)$,但实际运行时间一般远小于$O(nm)$
二分图的性质:一个图是二分图,当且仅当图中不含奇数环 。抽象为染色问题 ,一点为黑色,那连通块中与它相邻的点一定为白色;若连通块中一个点的颜色确定了,整个连通块中点的颜色就确定了。由于图中不含有奇数环,所以整个染色过程一定是没有矛盾的 。
染色法判定二分图的思路如下:
1 2 3 for (int i = 1 ; i <= n; i++) 遍历所有点 if i 没被染色 dfs(i, 1 ); 用深度优先遍历将i所在的连通块中的点都染色; 1 表示点i当前的颜色
例题:染色法判定二分图
给定一个n个点m条边的无向图,图中可能存在重边和自环。请你判断这个图是否是二分图。
输入格式:第一行包含两个整数n和m。接下来m行,每行包含两个整数u和v,表示点u和点v之间存在一条边。
输出格式:如果给定图是二分图,则输出“Yes”,否则输出“No”。
数据范围:$1 \le n\le 10^5, 1 \le m \le 10^5$。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 #include <iostream> #include <algorithm> #include <cstring> using namespace std ;const int N = 100010 , M = 200010 ;int n, m;int h[N], e[M], ne[M], idx; int color[N];void add (int a, int b) { e[idx] = b, ne[idx] = h[a], h[a] = idx++; } bool dfs (int u, int c) { color[u] = c; for (int i = h[u]; i != -1 ; i = ne[i]) { int j = e[i]; if (!color[j]) { if (!dfs(j, 3 - c)) return false ; } else if (color[j] == c) return false ; } return true ; } int main () { scanf ("%d%d" , &n, &m); memset (h, -1 , sizeof h); while (m--) { int a, b; scanf ("%d%d" , &a, &b); add(a, b), add(b, a); } bool flag = true ; for (int i = 1 ; i <= n; i++) if (!color[i]) { if (!dfs(i, 1 )) { flag = false ; break ; } } if (flag) puts ("Yes" ); else puts ("No" ); return 0 ; }
匈牙利算法(二分图最大匹配) 二分图的匹配 :给定一个二分图G,在G的一个子图M中,M的边集{E}中的任意两条边都不依附于同一个顶点,则称M是一个匹配。
二分图的最大匹配 :所有匹配中包含边数最多的一组匹配被称为二分图的最大匹配,其边数即为最大匹配数。
匈牙利算法又称为KM算法,可以用来求解解决二分图最大权匹配。
可以参考:二分图最大权匹配
(奇妙比喻)匈牙利算法的思路是:遍历左边的男生,第一个,第二个都顺利找到了心仪的且还是单身的女伴,到了第三个男生,发现心仪的女生已经有所属了,但他没有放弃,回去看这位女生所属的男生,发现他还有其他心仪的女生,OK那就交换一下,这样三个男生就都有女伴了。
时间复杂度:遍历每个男生,n;每个男生找女伴时最多再遍历m边条,因此最坏情况下,时间复杂度为$O(nm)$,但实际运行的复杂度远小于$O(mn)$。
例题:二分图的最大分配(Acwing 861)
给定一个二分图,其中左半部包含$n_1$个点(编号1~$n_1$),右半部包含$n_2$个点(编号1~$n_2$),二分图共包含m条边。数据保证任意一条边的两个端点都不可能在同一部分中。请你求出二分图的最大匹配数。
输入格式:第一行包含三个整数 n1、 n2 和 m。接下来m行,每行包含两个整数u和v,表示左半部点集中的点u和右半部点集中的点v之间存在一条边。
输出格式:输出一个整数,表示二分图的最大匹配数。
数据范围:$1 \le n_1,n_2 \le 500, 1 \le u \le n_1, 1 \le v \le n_2, 1 \le m \le 10^5$。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 #include <iostream> #include <cstring> #include <algorithm> using namespace std ;const int N = 510 , M = 100010 ;int n1, n2, m;int h[N], e[M], ne[M], idx;int match[N]; bool st[N]; 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 j = e[i]; if (!st[j]) { st[j] = true ; if (match[j] == 0 || find (match[j])) { match[j] = x; return true ; } } } return false ; } int main () { scanf ("%d%d%d" , &n1, &n2, &m); memset (h, -1 , sizeof h); while (m--) { int a, b; scanf ("%d%d" , &a, &b); add(a, b); } int res = 0 ; for (int i = 0 ; i <= n1; i++) { memset (st, false , sizeof st); if (find (i)) res ++; } printf ("%d\n" , res); return 0 ; }
图论题的难点是如何把问题抽象成图论问题。