设H=(V,T)
是图G=(V,E)
的子图,H是图G的生成树,当且仅当H是一个无环的连通图。生成树具有以下性质:
- H有|V-1|条边与|V|个顶点
- H是最小化连接的:去除任意一条边都会是H不连通
- H是最大无环结构:添加任意一条边都会构造一个环
给出一个无向加权图G=(V,E)
,最小生成树问题(minimun spanning tree,MST):求一个生成树使得每条边的权值的和最小。
本节在讨论解决最小生成树的两种贪心算法:Kruskal算法与Prim算法之前,先说明通用的、形式化的最小生成树的生成算法,这个是证明两个算法正确性的关键。然后说明这两种贪心算法,最后给出Prim算法的C++实现。
一 最小生成树的形成
假设A是某棵最小生成树的子集,生成最小生成树的算法就是循环选择一条合适的边(u,v),将其加入到A中,同时满足A是某棵最小生成树的子集的条件,我们把这样的边(u,v)称为集合A的安全边。因此通用最小生成树的伪代码:
这里的关键是辨认安全边的规则。介绍一些定义方便证明算法的正确性:
-
切割(cut):无向图
G=(V,E)
的一个切割(S,V-S)
是集合V的一个划分 -
横跨(cross):如果一条边 ( u , v ) ∈ E (u,v)\in E (u,v)∈E的一个端点在集合S中,另一个端点在集合V-S中,则称该边横跨
cut(S,V-S)
-
尊重(respect):集合A中不包含
cross(u,v)
,则称该切割尊重集合A -
轻边(light edge):在横跨一个切割的所有边中,权重最小的边称为轻边
**定理1:辨析安全边的规则:设(u,v)是横跨切割(S,V-S)的轻量边,且切割(S,V-S)尊重最小生成树的子集A,那么边(u,v)对于集合A来说就是安全的。**下面是证明过程:
假设T是一棵包括A的最小生成树,边(u,v)分为以下两种情况:
case1:如果T包含了轻边(u,v),(u,v)对于A来说一定是安全的。
case2:T不包含轻边(u,v),为了说明,(u,v)对于A来说一定是安全的,我们构造一个最小生成树 T ‘ T^` T‘,使得它包含 A ⋃ ( u , v ) A\bigcup{(u,v)} A⋃(u,v)
因为T不包含(u,v),边(u,v)与T中从节点u到节点v的唯一简单路径p形成一个环,如上图所示。由于u与v属于切割(黑节点,白节点)的两端,T中至少有一条边(x,y)属于路径p且横跨该切割。
又因为切割尊重集合A,所以边(x,y) 不在集合A中,我们将(u,v)替换(x,y)得到一棵新的生成树 T ‘ = T − { ( x , y ) } ⋃ { ( u , v ) } T^`=T-\{(x,y)\}\bigcup \{(u,v)\} T‘=T−{(x,y)}⋃{(u,v)}
下面证明 T ‘ T^` T‘也是一棵最小生成树:由于(x,y)与轻边(u,v)都横跨切割(S,V-S),有 w ( u , v ) < w ( x , y ) w(u,v)<w(x,y) w(u,v)<w(x,y),因此:
w ( T ‘ ) = w ( T ) − w ( x , y ) + w ( u , v ) ≤ w ( T ) w(T^`)=w(T)-w(x,y)+w(u,v)\leq w(T) w(T‘)=w(T)−w(x,y)+w(u,v)≤w(T)
因为T是一棵MST,所以 T ‘ T^` T‘同样也是一棵MST。这样的话(u,v)对于A来说就是安全边。
后面要介绍的两个算法就是使用了定理2:辨析安全边的推论
设图G=(V,E)是一个连通无向图,定义了一个权重函数w
。设集合A为G的MST的顶点子集,设森林
G
A
=
(
V
,
A
)
G_A=(V,A)
GA=(V,A)中,如果边(u,v)是连接
G
A
G_A
GA的两个连通分量的轻边,则(u,v)对于A来说是安全的。
Kruskal算法和Prim算法都属于贪心算法,都使用了一条具体的贪心规则来确定GENERIC-MST
算法第三行所描述的安全边:
- 在Kruskal算法中,集合A(某棵最小生成树的子集)是一个森林,图中的结点就是森林的结点,每次加到集合A中的安全边永远是权重最小的连接两个不同分量的边
- 在Prim算法中,集合A是一棵树,每次加入到A中的安全边永远是连接A与A之外某个节点的边中权重最小的边
二 Kruskal算法
Kruskal算法找到安全边的办法是,在所有连接森林中两棵不同树的边里面(在森林中,一个结点也可以看作一棵树),找到权重最小的边(u,v)。根据辨析安全边的推论【定理2】,这条边(u,v)是对A安全边(u-v是连接了 G A G_A GA中的两个连通分量的轻边),下图为示例:
Kruskal算法实现依赖于并查集这一数据结构(后面会补充),每个集合代表当前森林中的一棵树,操作FIND-SET(u)
返回包含元素u的集合的代表元素,可以通过测试FIND-SET(u)
是否等于FIND-SET(v)
来判断结点u与节点v是否属于同一棵树,通过操作Union
过程来对两棵树进行合并。伪代码如下:
Kruskal实现的方式准备放在并查集的那节中。
三 Prim算法
3.1 原理与伪代码
Prim算法特性是,集合A中的边总是构成一棵树,这棵树从任意的根节点r开始,一直长大到覆盖V中所有的结点为止,算法每一步在连接集合A和A之外的结点的所有边中,选择一条轻边加入A中。根据辨析安全边的规则【定理1】,这条边(u,v)是对A安全边:以(A,V-A)作为图的切割,选择一条横跨切割的轻边。
为了有效的实现Prim算法,需要一种快速的方法来选择一条新的边加入到集合A所构成的树中,伪代码中无向连通图G与最小生成树的根节点为r,所有不在树A中的结点保存在基于key
属性的最小优先队列Q中。对于每个节点v,属性v.key
保存的是连接v与树A中的节点的所有直连边中最小的权值,如果没有直连边,则
v
.
k
e
y
=
∞
v.key=\infty
v.key=∞。属性v.π
给出的是结点v在树中的父节点。当Prim算法终止时,最小优先队列Q将为空,而G中的MST-A的状态为:
A
=
{
(
v
,
v
.
π
)
:
v
∈
V
−
{
r
}
}
A=\{(v,v.π): v\in V-\{r\}\}
A={(v,v.π):v∈V−{r}}
时间复杂度分析:
3.2 C++实现
/**
* 最小生成树的Prim算法
*/
#include <iostream>
#include <vector>
#include <queue>
#include <algorithm>
using namespace std;
int key[100];//结点n到最小生成树的直连最小权值
//自定义priority_queue的优先顺序
struct cmp{
bool operator ()(int &a,int &b){
return key[a]>key[b];//key最小值优先
}
};
/**
* Prim算法
* @param n 结点个数,结点标号从0~n-1
* @param edges 边集合 一个向量表示{node1,node2,weight}
* @param start 起始节点
*/
void Prim(int n,vector<vector<int>> &edges,int start){
vector<int> A;// MST的顶点子集
int p[n];// 前驱结点
for(int i=0;i<n;i++){
key[i] = INT_MAX;
p[i] = -1;// 没有前驱节点
}
key[start] = 0;// 设置起始节点
priority_queue<int,vector<int>,cmp> pq;// key最小优先队列
for(int i=0;i<n;i++)// 优先队列初始化
pq.push(i);
while(!pq.empty()){
int u = pq.top();// 找到到树A权值最小的结点
pq.pop();
A.push_back(u);
for(vector<int> edge:edges){
int v = -1;// 所有边中找到与u相连的边 v指向与u相连的另一个顶点
if(edge[0] == u)
v = edge[1];
if(edge[1] == u)
v = edge[0];
// v存在,更新不在树A的结点v的key值与p值
if(v != -1 && !count(A.begin(),A.end(),v)){
if(edge[2]<key[v]){
key[v] = edge[2];
p[v] = u;
// 更新pq
priority_queue<int,vector<int>,cmp> pq1;
while(!pq.empty()){
pq1.push(pq.top());
pq.pop();
}
pq = pq1;
}
}
}
}
// 打印结果
for(int i=0;i<n;i++){
if(i == start)
cout<<"MST root is node "<<i<<endl;
else
cout<<"node "<<i<<"`s parent node is "<<p[i]<<endl;
}
}
int main(){
// {{0,1,1},{1,2,1},{2,3,1},{0,3,1}}
// {{0,1,1},{1,2,1},{2,3,2},{0,3,2},{0,4,3},{3,4,3},{1,4,6}}
vector<vector<int>> edges = {{0,1,1},{1,2,1},{2,3,2},{0,3,2},{0,4,3},{3,4,3},{1,4,6}};
Prim(5,edges,0);
}
测试用例:
-
示例一
-
示例二
参考
【1】算法导论