0%

算法基础(9)

第三章 搜索与图论(三)

定义无向连通图的最小生成树 (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外距离最近的点; //集合s表示当前在连通块中的所有的点
用t更新其他点到**集合**的距离; //注意这里与Dijkstra算法的区别
//点到集合的距离:当前点能连到集合内部的点的边中最短的边的距离;若没有一条边是连到集合内部的,距离就定义为正无穷
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]; //dist[]表示点到集合的距离
bool st[N]; //st[]存点是否已经在连通块中

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++) //注意题目中给的点的编号,从1开始,那j就从1开始
if(!st[j] && (t == -1 || dist[t] > dist[j])) //找到集合外距离最近的点t
t = j;

if(i && dist[t] == INF) return INF;
if(i) res += dist[t]; //注意:要先累加再更新,否则会把负的自环更新进来

for(int j = 1; j <= n; j++) //用t更新其他点到集合的距离
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
将所有边按权重从小到大排序;   //Kruskal算法的瓶颈,O(mlogm)
从小到大枚举每条边 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) //并查集的find函数
{
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) //如果a和b在并查集中没有连通
{
p[a] = b;
res += w; //res存最小生成树中边权的总和
cnt ++;
}
}

//如果循环结束后,连通的边数小于n-1,说明n个点没有全部连通,即不存在最小生成树
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; //两种颜色1和2, 3 - c 就表示与c不同的另一种颜色
}
else if (color[j] == c) return false; //如果与u相邻的点颜色与其相同,说明冲突了
}

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

图论题的难点是如何把问题抽象成图论问题。