1. 引言
最小生成树的定义
最小生成树(Minimum Spanning Tree or MST) 是在给定的无向图G(V,E)中求一棵树T,使得这棵树拥有图G中的所有顶点,这棵树的所有边都来自于图G中的边,并且满足这棵树的边权之和最小。
性质
- 最小生成树是树,因此其边数等于顶点数减1,且树内一定不会有环;
- 对给定的图G(V,E),其最小生成树可以不唯一,但其边权之和一定是唯一的;
- 由于最小生成树是在无向图上生成的,因此其根节点可以是这棵树上的任意一个结点。于是,如果题目中涉及最小生成树本身的输出,为了让最小生成树唯一,一般都会直接给出根节点,读者只需以给出的结点作为根结点来求解最小生成树即可。
算法
- prim
- kruskal
(以上两个算法都采用了贪心法的思想,只是贪心的策略不太一样,一个点贪心,一个边贪心,《王道》上说两者在机试时区别并不大)
2. prim算法
基本思想——点贪心
- 对图G(V,E)设置集合
S
,存放已被访问的顶点,然后每次从集合V-S
中选择与集合S
的最短距离最小的一个顶点(记为u
),访问并加入集合S
。 - 之后,令顶点
u
为中介点,优化所有从u
能到达的顶点v
与集合S
之间的最短距离。这样的操作执行n次(n为顶点个数),直到集合S
已包含所有顶点。
可以发现,prim算法的思想和Dijkstra算法的思想几乎完全相同,只是在涉及最短距离时使用集合S
代替Dijkstra算法中的起点s。
伪代码
//G为图,一般设成全局变量;数组d为顶点与集合S的最短距离
Prim(G,d[]){
初始化;
for(循环n次){
u = 使d[u]最小的还未被访问的顶点的标号;
记u已被访问;
for(从u出发能到达的所有顶点v){
if(v未被访问&&以u为中介点使得v与集合S的最短距离d[v]更优){
将G[u][v]赋值给v与集合S的最短距离d[v];
}
}
}
}
邻接矩阵实现
const int MAXV = 1000; //最大顶点数
const int INF = 1000000000; //设INF为一个很大的数
int n,G[MAXV][MAXV]; //n为顶点数,MAXV为最大顶点数
int d[MAXV]; //顶点与集合S的最短距离
bool vis[MAXV] = {false}; //标记数组,vis[i] == true表示已访问。初值均为false
int prim(){ //默认0号为初始点,函数返回最小生成树的边权之和
fill(d,d+MAXV,INF); //fill函数将整个d数组赋值为INF(慎用memset)
d[0] = 0; //只有0号顶点到集合S的距离为0,其余为INF
int ans = 0; //存放最小生成树的边权之和
for(int i=0;i<n;i++){ //循环n次
int u = -1,MIN = INF; //u使d[u]最小,MIN存放该最小的d[u]
for(int j=0;j<n;j++){ //找到未访问的顶点中d[]最小的
if(vis[j] == false && d[j] < MIN){
u = j;
MIN = d[j];
}
}
//找不到小于INF的d[u],则剩下的顶点和集合S不连通
if(u == -1){
return -1;
}
vis[u] = true; //标记u为已访问
ans += d[u]; //将与集合S距离最小的边加入到最小生成树
for(int v=0;v<n;v++){
//v未访问 && u能到达v && 以u为中介点可以使v离集合S更加近
if(vis[v] == false && G[u][v] != INF && G[u][v] < d[v]){
d[v] = G[u][v]; //将G[u][v]赋值给d[v]
}
}
}
}
邻接表实现
const int MAXV = 1000; //最大顶点数
const int INF = 1000000000; //设INF为一个很大的数
struct Node{
int v,dis; //v为边的目标顶点,dis为边权
};
vector<Node> Adj[MAXV]; //图G,Adj[u]存放从顶点u出发可以到达的所有顶点
int n; //n为顶点数,图G使用邻接表实现,MAXV为最大顶点数
int d[MAXV]; //顶点与集合S的最短距离
bool vis[MAXV] = {false}; //标记数组,vis[i] == true表示已访问。初值均为false
int prim(){ //默认0号为初始点,函数返回最小生成树的边权之和
fill(d,d+MAXV,INF); //fill函数将整个d数组赋值为INF(慎用memset)
d[0] = 0; //只有0号顶点到集合S的距离为0,其余全为INF
int ans = 0; //存放最小生成树的边权之和
for(int i=0;i<n;i++){ //循环n次
int u=-1,MIN = INF; //u使d[u]最小,MIN存放该最小的d[u]
for(int j=0;j<n;j++){ //找到为访问的顶点中d[]最小的
if(vis[j] == false && d[j] < MIN){
u = j;
MIN = d[j];
}
}
//找不到小于INF的d[u],则剩下的顶点和集合S不连通
if(u == 1){
return -1;
}
vis[u] == true; //标记u为已访问
ans += d[u]; //将与集合S距离最小地边加入最小生成树
//只有下面这个for与邻接矩阵的写法不同
for(int j=0;j<Adj[u].size();j++){
int v = Adj[u][j].v; //通过邻接表直接获得u能到达的顶点v
if(vis[u] == false && Adj[u][j].dis < d[v]){
//如果v未访问 && 以u为中介点可以使v离集合S更近
d[v] = G[u][v]; //将G[u][v]赋值给d[v]
}
}
}
return ans; //返回最小生成树的边权之和
}
时间复杂度
和Dijkstra算法一样,这种写法的复杂度是
O
(
V
2
)
O(V^2)
O(V2),其中邻接表实现prim算法可以通过堆优化使时间复杂度降为
O
(
V
l
o
g
V
+
E
)
O(VlogV+E)
O(VlogV+E)。
此外,
O
(
V
2
)
O(V^2)
O(V2)的时间复杂度也说明,尽量在图的顶点数目较少而边数较多的情况下(即稠密图)上使用prim算法。至于为什么prim算法得到的生成树一定是最小生成树,可以参考《算法导论》的相关证明。
证明⭐⭐⭐⭐⭐
命题:对于任意 k < n k<n k<n,存在一棵最小生成树包含算法前 k k k步选择的边。
归纳基础: k = 1 k=1 k=1,存在一棵最小生成树 T T T 包含边 e = { 1 , i } e= \{ 1,i \} e={1,i} ,其中 { 1 , i } \{1,i\} {1,i}是所有关联1的边中权最小的。
归纳步骤:假设算法前 k k k步选择的边构成一棵最小生成树的边,则算法前 k + 1 k+1 k+1步选择的边也构成一棵最小生成树。
归纳基础
证明:存在一棵最小生成树 T T T包含关联结点1的最小权的边 e = { 1 , i } e=\{1,i\} e={1,i}.
证 设
T
T
T为一棵最小生成树,假设
T
T
T不包含
{
1
,
i
}
\{1,i\}
{1,i},则
T
∪
{
{
1
,
i
}
}
T∪\{\{1,i\}\}
T∪{{1,i}}包含一条回路,回路中关联1的另一条边
{
1
,
j
}
\{1,j\}
{1,j}。用
{
1
,
i
}
\{1,i\}
{1,i}代替
{
1
,
j
}
\{1,j\}
{1,j}得到树
T
′
T'
T′,则T’也是一棵生成树,且
W
(
T
′
)
≤
W
(
T
)
W(T')≤W(T)
W(T′)≤W(T)。
归纳步骤
假设算法进行了 k k k步,生成树的边为 e 1 , e 2 , . . . , e k e_1,e_2,...,e_k e1,e2,...,ek,这些边的端点构成集合 S S S,由归纳假设存在 G G G的一棵最小生成树 T T T包含这些边。
算法
k
+
1
k+1
k+1步选择顶点
i
k
+
1
i_{k+1}
ik+1,则
i
k
+
1
i_{k+1}
ik+1到
S
S
S中顶点边权最小,设此边
e
k
+
1
=
{
i
k
+
1
,
i
l
}
e_{k+1}=\{i_{k+1},i_l \}
ek+1={ik+1,il}。若
e
k
+
1
∈
T
e_{k+1}∈T
ek+1∈T,算法
k
+
1
k+1
k+1步显然正确。
假设
T
T
T不含有
e
k
+
1
e_{k+1}
ek+1,则将
e
k
+
1
e_{k+1}
ek+1加到
T
T
T中形成一条回路。这条回路有另外一条连接
S
S
S与
V
−
S
V-S
V−S中的顶点的边
e
e
e。
令 T ∗ = ( T − { e } ) ∪ { e k + 1 } T*=(T-\{e\})∪\{e_{k+1}\} T∗=(T−{e})∪{ek+1},则 T ∗ T* T∗是 G G G的一棵生成树,包含 e 1 e_1 e1, e 2 e_2 e2,…, e k + 1 e_{k+1} ek+1,且 W ( T ∗ ) ≤ W ( T ) W(T*)≤W(T) W(T∗)≤W(T)算法到 k + 1 k+1 k+1步依然得到最小生成树。
3. kruskal算法
与我们的prim算法不同,kruskal算法采用的是边贪心的策略。
基本思想——边贪心
在初始状态时隐去图中所有的边,这样图中每个顶点都自成一个连通块。之后执行下面的步骤:
①对所有边按照边权从小到达排序;
②按边权从小到大测试所有的边,如果当前测试边所连接的两个顶点不在同一个连通块中,则将这条测试边加入到当前最小生成树中;否则,将边遗弃。
③执行步骤②,直到最小生成树中的边数等于总顶点数减1或是测试完所有边时结束。而当结束时如果最小生成树的边数小于总顶点数小于总顶点数减1,说明该图不连通。
伪代码
边
首先是边的定义。相对于prim算法,kruskal算法的主体是边,需要对边进行排序,也需要判断边的两个端点是否在不同的连通块中,因此边的两个端点的编号一定是需要的。于是,定义一个结构体,在里面存放边的两个端点编号和边权即可满足需要。
struct edge{
int u,v; //边的两个端点编号
int cost; //边权
}E[MAXE]; //最多有MAXE条边
在解决了边的定义之后,需要写一个排序函数来让数组E按边权从小到大排序,因此不妨自定义用于sort的cmp函数:
bool cmp(edge a,edge b){
return a.cost < b.cost;
}
接下来就要解决kruskal算法自身的实现了,不妨先来看伪代码(注意结合前面讲解的基本思想进行理解)
int kruskal(){
令最小生成树的边权之和为ans,最小生成树的当前边数Num_Edge;
将所有边按边权从小到大排序;
for(从小到大枚举所有边){
if(当前测试边的两个端点在不同的连通块中){
将该测试边加入到最小生成树中;
ans += 测试边的边权;
最小生成树的当前边数Num_Edge加1;
当边数Num_Edge等于顶点数减1时结束循环;
}
}
return ans;
}
代码实现
在这个伪代码里有两个细节似乎不太直观,即:
①如何判断测试边的两个端点是否在不同的连通块中。
②如何将测试边加入最小生成树中。
事实上,对于这两个问题,可以换一个角度思考。如果将每个连通块当作一个集合,那么就可以将问题转换为判断两个端点是否在同一个集合中,而这个问题可以使用并查集
来解决。
并查集可以通过查询两个结点所在集合的根结点是否相同来判断它们是否在同一个集合中,而合并功能恰好可以把上面提到的第二个细节解决,即只要把测试边的两个端点所在的集合合并,这样就能达到将边加入最小生成树的效果。
假设题目中顶点编号的范围是[1,n],因此在并查集初始化时范围不能弄错。如果下标从0开始,则整个代码中也只需要修改并查集初始化的部分即可。
int father[N]; //并查集数组
int findFather(int x){ //并查集查询函数
...
}
//kruskal函数返回最小生成树的边权之和,参数n为顶点个数,m为图的边数
int kruskal(int n,int m){
//ans为所求边权之和,Num_Edge为当前生成树的边数
int ans = 0;
int Num_Edge = 0;
for(int i=1;i <= n;i++){ //假设题目中顶点范围是[1,n]
father[i] = i; //并查集初始化
}
sort(E,E+m,cmp); //所有边按边权从小到大排序
for(int i=0;i<m;i++){ //枚举所有边
int faU = findFather(E[i].u); //查询测试边两个端点所在集合的根结点
int faV = findFather(E[i].v);
if(faU != faV){ //如果不在一个集合中
father[faU] = faV; //合并集合(即将测试边加入到最小生成树中)
ans += E[i].cost; //边权之和增加测试边的边权
Num_Edge++; //当前生成树的边数加1
if(Num_Edge == n - 1){
break; //边数等于顶点数减1时结束算法
}
}
}
if(Num_Edge != n-1){
return -1; //无法连通时返回-1
}else{
return ans; //返回最小生成树的边权之和
}
}
时间复杂度
可以看出,kruskal算法的时间复杂度主要来源于对边的排序,因此其时间复杂度是 O ( E l o g E ) O(ElogE) O(ElogE),其中E为图的边数。显然kruskal适合顶点数较多、边数较少的情况,这和prim算法恰恰相反。于是可以根据题目所给的数据范围来选择合适的算法,即如果是稠密图(边多),则用prim算法;如果是稀疏图(边少),则用kruskal算法。
此外,在连通图中,kruskal算法能够保证最后一定能形成一棵连通的树。
证明⭐⭐⭐⭐⭐
命题:对于任意 n n n,算法对 n n n阶图找到一棵最小生成树。
证明思路:
归纳基础 证明:n=2,算法正确。G只有一条边,最小生成树就是G。
归纳步骤 证明:假设算法对于n阶图是正确的,其中n>1,则对于任何n+1阶图算法也得到一棵最小生成树。
短接操作
任给n+1个顶点的图G,G中最小权边e={i,j},从G中短接i和j,得到图G’。
归纳步骤证明
4. 注意点
prim
和kruskal
都设计了图不连通时的判断方法。