11.2 最小生成树
前面提到过,在无向图中,连通且不含圈的图称为树(Tree)。给定无向图G=(V,E),连接G中所有点,且边集是E的子集的树称为G的生成树(Spanning Tree),而权值最小的生成树称为最小生成树(Minimal Spanning Tree,MST)。构造MST的算法有很多,最常见的有两个:Kruskal算法和Prim算法。限于篇幅,这里只介绍Kruskal算法,它易于编写,而且效率很高。
11.2.1 Kruskal算法
Kruskal算法的第一步是给所有边按照从小到大的顺序排列。这一步可以直接使用库函数qsort或者sort。接下来从小到大依次考查每条边(u,v)。
情况1:u和v在同一个连通分量中,那么加入(u, v)后会形成环,因此不能选择。
情况2:如果u和v在不同的连通分量,那么加入(u, v)一定是最优的。为什么呢?下面用反证法——如果不加这条边能得到一个最优解T,则T+(u, v)一定有且只有一个环,而且环中至少有一条边(u', v')的权值大于或等于(u,v)的权值。删除该边后,得到的新树T'=T+(u, v)-(u', v')不会比T更差。因此,加入(u, v)不会比不加入差。
下面是伪代码:
把所有边排序,记第i小的边为e[i](1<=i<m)
初始化MST为空
初始化连通分量,让每个点自成一个独立的连通分量
for(int i = 0; i < m; i++)
if(e[i].u和e[i].v不在同一个连通分量) {
把边e[i]加入MST
合并e[i].u和e[i].v所在的连通分量
}
在上面的伪代码中,最关键的地方在于“连通分量的查询与合并”:需要知道任意两个点是否在同一个连通分量中,还需要合并两个连通分量。
最容易想到的方法是“暴力”——每次“合并”时只在MST中加入一条边(如果使用邻接矩阵,只需G[e[i].u][e[i].v]=1),而“查询”时直接在MST中进行图遍历(DFS和BFS都可以判断连通性)。遗憾的是,这个方法不仅复杂(需要写DFS或者BFS),而且效率不高。
并查集。有一种简洁高效的方法可用来处理这个问题:使用并查集(Union-Find Set)。可以把每个连通分量看成一个集合,该集合包含了连通分量中的所有点。这些点两两连通,而具体的连通方式无关紧要,就好比集合中的元素没有先后顺序之分,只有“属于”和“不属于”的区别。在图中,每个点恰好属于一个连通分量,对应到集合表示中,每个元素恰好属于一个集合。换句话说,图的所有连通分量可以用若干个不相交集合来表示。
并查集的精妙之处在于用树来表示集合。例如,若包含点1,2,3,4,5,6的图有3个连通分量{1,3}、{2,5,6}、{4},则需要用3棵树来表示。这3棵树的具体形态无关紧要,只要有一棵树包含1、3两个点,一棵树包含2、5、6这3个点,还有一棵树只包含4这一个点即可。规定每棵树的根结点是这棵树所对应的集合的代表元(representative)。
如果把x的父结点保存在p[x]中(如果x没有父结点,则p[x]等于x),则不难写出“查找结点x所在树的根结点”的递归程序:int find(int x) { p[x] == x ? x : find(p[x]); },通俗地讲就是:如果p[x]等于x,说明x本身就是树根,因此返回x;否则返回x的父结点p[x]所在树的树根。
问题来了:在特殊情况下,这棵树可能是一条长长的链。设链的最后一个结点为x,则每次执行find(x)都会遍历整条链,效率十分低下。看上去是个很棘手的问题,其实改进方法很简单。既然每棵树表示的只是一个集合,因此树的形态是无关紧要的,并不需要在“查找”操作之后保持树的形态不变,只要顺便把遍历过的结点都改成树根的子结点,下次查找就会快很多了,如图11-5所示。
图11-5 并查集中的路径压缩
这样,Kruskal算法的完整代码便不难给出了。假设第i条边的两个端点序号和权值分别保存在u[i],v[i]和w[i]中,而排序后第i小的边的序号保存在r[i]中(这叫做间接排序。排序的关键字是对象的“代号”,而不是对象本身)。
int cmp(const int i, const int j) { return w[i]<w[j]; } //间接排序函数
int find(int x) { return p[x] == x ? x : p[x] = find(p[x]);}//并查集的find
int Kruskal() {
int ans = 0;
for(int i = 0; i < n; i++) p[i] = i; //初始化并查集
for(int i = 0; i < m; i++) r[i] = i; //初始化边序号
sort(r, r+m, cmp); //给边排序
for(int i = 0; i < m; i++) {
int e = r[i]; int x = find(u[e]); int y = find(v[e]);
//找出当前边两个端点所在集合编号
if(x != y) { ans += w[e]; p[x] = y; } //如果在不同集合,合并
}
return ans;
}
注意,x和y分别是第e条边的两个端点所在连通分量的代表元。合并x和y所在集合可以简单地写成p[x]=y,即直接把x作为y的子结点,则两个树就合并成一棵树了。注意不能写成p[u[e]]=p[v[e]],因为u[e]和v[e]不一定是树根。并查集的效率非常高,在平摊意义下,find函数的时间复杂度几乎可以看成是常数(而union显然是常数时间)。
11.2.2 竞赛题目选解
例题11-2 苗条的生成树(Slim Span, ACM/ICPC Japan 2007, UVa1395)
给出一个n(n≤100)结点的图,求苗条度(最大边减最小边的值)尽量小的生成树。
【分析】
首先把边按权值从小到大排序。对于一个连续的边集区间[L, R],如果这些边使得n个点全部连通,则一定存在一个苗条度不超过W[R]-W[L]的生成树(其中W[i]表示排序后第i条边的权值)。
从小到大枚举L,对于每个L,从小到大枚举R,同时用并查集将新进入[L,R]的边两端的点合并成一个集合,与Kruskal算法一样。当所有点连通时停止枚举R,换下一个L(并且把R重置为L)继续枚举。
例题11-3 买还是建(Buy or Build, ACM/ICPC SWERC 2005, UVa1151)
平面上有n个点(1≤n≤1000),你的任务是让所有n个点连通。为此,你可以新建一些边,费用等于两个端点的欧几里德距离。另外还有q(0≤q≤8)个“套餐”可以购买,如果你购买了第i个套餐,该套餐中的所有结点将变得相互连通。第i个套餐的花费为Ci。如图11-6所示,一共有3个套餐:
它的最优解是购买套餐1和套餐2,然后手动连接两条边,如图11-7所示。
| ![]() |
| 图11-6 3个套餐 | 图11-7 购买套餐1、2并连接边 |
【分析】
最容易想到的算法是:先枚举购买哪些套餐,把套餐中包含的边的权值设为0,然后求最小生成树。由于枚举量为O(2q),给边排序的时间复杂度为O(n2logn),而排序之后每次Kruskal算法的时间复杂度为O(n2),因此总时间复杂度为O(2qn2+n2logn),对于题目的规模来说太大了。
只需一个小小的优化即可降低时间复杂度:先求一次原图(不购买任何套餐)的最小生成树,得到n-1条边,然后每次枚举完套餐后只考虑套餐中的边和这n-1条边,则枚举套餐之后再求最小生成树时,图上的边已经寥寥无几。
为什么可以这样呢?首先回顾一下,在Kruskal算法中,哪些边不会进入最小生成树。答案是:两端已经属于同一个连通分量的边。买了套餐以后,相当于一些边的权变为0,而对于不在套餐中的每条边e,排序在e之前的边一个都没少,反而可能多了一些权值为0的边,所以在原图Kruskal时被“扔掉”的边,在后面的Kruskal中也一样会被扔掉。
本题还有一个地方需要说明:因为Kruskal在连通分量包含n个点时会终止,所以对于随机数据,即使用原始的“暴力算法”,也能很快出解。如果你是命题者,可以这样出一个数据:有一个点很远,而其他n-1个点相互比较近。这样,相距较近的n-1个点之间的C(n-1,2)条边会排序在前面,每次Kruskal都会先考虑完所有这些边。而考虑这些边时是无法让远点和近点连通的。