前言
什么是最小生成树呢?回答这个问题需要先知道什么是生成树,一般来说,生成树这个概念是针对无向图来说的,对于一个无向图,假设其有 V V V个顶点, E E E条边,那么在这个无向图中,选择 V − 1 V-1 V−1条边,可以将该图所有顶点连通,这些点和边构成的结构恰好是一颗树,这棵树称为图的生成树.
所谓最小生成树,就是指一个路径带权的无向图,其所有生成树中边权值之和最小的那颗生成树.注意最小生成树形态可能不唯一,但是这个最小权值和一定唯一.本文介绍两种算法求最小生成树,一种是 K r u s k a l Kruskal Kruskal,又称加边法,另一种是 P r i m Prim Prim,又称加点法.
原理
Kruskal(加边原理)
K r u s k a l Kruskal Kruskal被称为"加边法",它对整个边集 E E E进行由小到大排序,然后每次从中选择一个最小的边,判断它是否和生成树中已有的边构成环,若没有成环则加入到生成树当中,否则选择下一条次小的边,如此往复,当选择了 V − 1 V-1 V−1条边时,算法结束,求得最小生成树.
思考以下几个问题:
- 为什么会成环的边不能加入生成树?
因为有 n n n个顶点的树,有 n − 1 n-1 n−1条边,不可能构成环,构成环的树就变成了图.
- 如何判断边是否成环?
使用并查集,就可以维护这个关系了,其过程如下:
首先,对于每条边的两个顶点,将其视作为两个独立的集合,即最初有 V V V个集合.
对于已排序的边集 E E E,贪心地选取最小的边 < u , v , w > <u,v,w> <u,v,w>,有:
- 若发现该边的两个顶点 u u u和 v v v不属于一个集合,则将其所在集合合并,并将该边加入生成树.
- 若发现该边的两个顶点 u u u和 v v v属于一个集合,则说明顶点 u u u和 v v v已经在生成树内,此时再选择该边加入生成树将会构成环,于是我们不选择这条边,而选择下一条边继续上述判断.
对于以下的无向图,用 K r u s k a l Kruskal Kruskal求其最小生成树的过程如下:
![Kruskal](https://img-blog.csdnimg.cn/20190514112612160.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L0FBTWFob25l,size_16,color_FFFFFF,t_70)
Prim(加点原理)
P r i m Prim Prim被称为"加点法",它先选择任意的一个顶点,加入到最小生成树当中,再从边集 E E E中选择生成树中所有顶点的出边中权值最小,且另一个顶点不在生成树中的边,加入到生成树中来,如此往复,直到 V V V个顶点都加入到生成树时,算法结束,求得最小生成树.
思考以下几个问题:
- 为什么要选取生成树中所有顶点的出边中权值最小,且另一个顶点不在生成树中的边,加入生成树?
因为 P r i m Prim Prim是基于贪心的策略求解的,其贪心策略:
选取生成树中所有顶点的出边中权值最小,且另一个顶点不在生成树中的边
等价于
每次从边集 E E E中选择权值最小,又不和生成树已有的边构成环的边,加入到生成树.
这样贪心得出来的生成树必然是最小生成树.
- 如何实现"选取生成树中所有顶点的出边中权值最小,且另一个顶点不在生成树中的边"的操作?
维护一个最小代价数组 l o w c o s t [ ] lowcost[] lowcost[],用来记录生成树中所有顶点到其他顶点的最短路径,每次只需选择一个最小的 l o w c o s t [ i ] lowcost[i] lowcost[i],且点 i i i不在生成树中,将 i i i加入生成树即可.注意生成树中每次加入一个顶点 i i i,都要对 l o w c o s t [ ] lowcost[] lowcost[]数组进行一次更新,以确保 l o w c o s t [ ] lowcost[] lowcost[]的性质不变–记录生成树中所有顶点到其他顶点的最短路径.
对于上面的同一个例子,用 P r i m Prim Prim求其最小生成树的过程如下:
![Prim](https://img-blog.csdnimg.cn/2019051411265474.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L0FBTWFob25l,size_16,color_FFFFFF,t_70)
请读者根据上面的例子,仔细对比 K r u s k a l Kruskal Kruskal和 P r i m Prim Prim的区别,掌握它们各自的思想.
实现
为了实用好写,以下关于树的实现都用双亲数组而非链式结构,这些代码可以直接作为ACM比赛模板使用.
K r u s k a l Kruskal Kruskal:
#include<iostream>
#include<algorithm>
#define maxn 100010
#define mamm 10000010
#define inf 0x3f3f3f3f
using namespace std;
struct node {
int u,v,w;
}e[mamm];
int pre[maxn],tot = 0;
void addedge(int u,int v,int w) {
e[tot].u = u;
e[tot].v = v;
e[tot++].w = w;
}
bool cmp(node x,node y) {
return x.w < y.w;
}
int Find(int x) {
if(pre[x] == x) return x;
return pre[x] = Find(pre[x]);
}
int Kruskal(int n) {
int cnt = 0,ans = 0;
for(int i = 0; i <= n; i++) { //初始化并查集;
pre[i] = i;
}
sort(e,e+tot,cmp); //对边排序;
int fu,fv;
for(int i = 0; i < tot; i++) {
fu = Find(e[i].u);
fv = Find(e[i].v);
if(fu != fv) {
pre[fu] = fv;
ans += e[i].w;
cnt++;
}
if(cnt == n-1) //找到n-1条边,结束;
return ans; //返回最小权值和;
}
return -inf; //失败,说明是非连通图;
}
int main() {
int m,n,u,v,w;
while(cin>>n>>m) {
tot = 0;
for(int i = 0; i < m; i++) {
cin>>u>>v>>w;
addedge(u,v,w);
addedge(v,u,w);
}
cout<<Kruskal(n)<<endl;
}
return 0;
}
上面的标程使用的是邻接表存图,且和一般的邻接表不太一样,这是因为 K r u s k a l Kruskal Kruskal考虑所有的边放在一起排序的结果集,而不强调找到某个点的所有出边这样的常规做法,因此这里的邻接表比标准的邻接表的结构字段里多了一个起始点字段 u u u,去掉了一指针个字段 n x t nxt nxt,这样方便取得一条边的完整信息,且利于排序.由于 v e c t o r vector vector版本的邻接表不能写成这样的形式,这里强烈建议使用结构体版的手写邻接表实现 K r u s k a l Kruskal Kruskal.
P r i m Prim Prim:
#include<iostream>
#include<algorithm>
#include<vector>
#define maxn 100010
#define inf 0x3f3f3f3f
using namespace std;
typedef pair<int,int> pii;
int vis[maxn],lowcost[maxn];
vector<pii> e[maxn];
void init(int n) {
for(int i = 0; i <= n; i++) {
e[i].clear();
}
}
int Prim(int n,int st) { //选择任意一个点作为生成树的起点,这里是st;
int ans = 0;
for(int i = 0; i <= n; i++) {
vis[i] = 0;
lowcost[i] = inf;
}
lowcost[st] = 0;
int cur,len,minn;
for(int k = 0; k < n; k++) { //找n个点;
cur = -1,minn = inf;
for(int i = 0; i <= n; i++) {
if(!vis[i] && lowcost[i] < minn) {
cur = i;
minn = lowcost[i];
}
}
if(cur != -1) {
vis[cur] = 1;
ans += lowcost[cur];
len = e[cur].size();
for(int i = 0; i < len; i++) { //更新lowcost数组;
lowcost[e[cur][i].first] = min(lowcost[e[cur][i].first],e[cur][i].second);
}
}
else
return -inf; //中途就找不到点,失败,说明是非连通图;
}
return ans; //找到n个点,返回最小权值和;
}
int main() {
int m,n,u,v,w;
while(cin>>n>>m) {
init(n);
for(int i = 0; i < m; i++) {
cin>>u>>v>>w;
e[u].push_back(make_pair(v,w));
e[v].push_back(make_pair(u,w));
}
cout<<Prim(n,1)<<endl;
}
return 0;
}
因为 P r i m Prim Prim维护 l o w c o s t [ ] lowcost[] lowcost[]数组,所以不需要对整个边集 E E E进行排序等操作,因而标准的邻接表就可以胜任,所以可以使用 v e c t o r vector vector写,对 l o w c o s t [ ] lowcost[] lowcost[]的维护是整个算法的精髓,如果读者理解了算法思想,这个代码就无需多解释了,为了照顾部分读者的编码习惯,下面放出另一个版本的模板,手写邻接表版本的 P r i m Prim Prim:
#include<iostream>
#include<cstring>
#include<algorithm>
#define maxn 100010
#define maxm 10000010
#define inf 0x3f3f3f3f
using namespace std;
struct node {
int v,w,nxt;
}e[maxm];
int head[maxn],vis[maxn],lowcost[maxn],tot = 0;
void init() {
tot = 0;
memset(head,-1,sizeof head);
}
void addedge(int u,int v,int w) {
e[tot].v = v;
e[tot].w = w;
e[tot].nxt = head[u];
head[u] = tot++;
}
int Prim(int n,int st) { //选择任意一个点作为生成树的起点,这里是st;
int ans = 0;
for(int i = 0; i <= n; i++) {
vis[i] = 0;
lowcost[i] = inf;
}
lowcost[st] = 0;
int cur,minn;
for(int k = 0; k < n; k++) { //找n个点;
cur = -1,minn = inf;
for(int i = 0; i <= n; i++) {
if(!vis[i] && lowcost[i] < minn) {
cur = i;
minn = lowcost[i];
}
}
if(cur != -1) {
vis[cur] = 1;
ans += lowcost[cur];
for(int i = head[cur]; i != -1; i = e[i].nxt) { //更新lowcost数组;
lowcost[e[i].v] = min(lowcost[e[i].v],e[i].w);
}
}
else
return -inf; //中途就找不到点,失败,说明是非连通图;
}
return ans; //找到n个点,返回最小权值和;
}
int main() {
int m,n,u,v,w;
while(cin>>n>>m) {
init();
for(int i = 0; i < m; i++) {
cin>>u>>v>>w;
addedge(u,v,w);
addedge(v,u,w);
}
cout<<Prim(n,1)<<endl;
}
return 0;
}
Prim的堆优化
P r i m Prim Prim算法需要维护一个 l o w c o s t [ ] lowcost[] lowcost[]数组,每次从中找到最短边,再更新整个 l o w c o s t [ ] lowcost[] lowcost[],这个思想和 D i j k s t r a Dijkstra Dijkstra是一样的只是更新操作不同, D i j k s t r a Dijkstra Dijkstra是松弛所有出边, P r i m Prim Prim是 l o w c o s t [ ] lowcost[] lowcost[]当前值和所有出边比较取最小值,这两种操作本质上都是比较选优操作,因此 P r i m Prim Prim的和 D i j k s t r a Dijkstra Dijkstra的时间复杂度一样,为 O ( V 2 ) O(V^2) O(V2).因为 P r i m Prim Prim和 D i j k s t r a Dijkstra Dijkstra的基本操作一致,因此优化 D i j k s t r a Dijkstra Dijkstra的方法同样可以优化 P r i m Prim Prim,下面是一个 P r i m Prim Prim堆(优先队列)优化的模板:
#include<iostream>
#include<algorithm>
#include<queue>
#include<vector>
#define maxn 100010
#define inf 0x3f3f3f3f
using namespace std;
typedef pair<int,int> pii;
int vis[maxn],lowcost[maxn];
vector<pii> e[maxn];
void init(int n) {
for(int i = 0; i <= n; i++) {
e[i].clear();
}
}
struct cmp {
bool operator()(pii a,pii b) {
return a.second > b.second;
}
};
int Prim(int n,int st) { //选择任意一个点作为生成树的起点,这里是st;
int ans = 0,cnt = 0;
for(int i = 0; i <= n; i++) {
vis[i] = 0;
lowcost[i] = inf;
}
lowcost[st] = 0;
int cur,len;
priority_queue<pii,vector<pii>,cmp> q;
q.push(make_pair(st,lowcost[st]));
while(!q.empty()) { //找n个点;
cur = q.top().first;
q.pop();
if(!vis[cur]) {
vis[cur] = 1,cnt++;
ans += lowcost[cur];
len = e[cur].size();
for(int i = 0; i < len; i++) { //更新lowcost数组;
if(lowcost[e[cur][i].first] > e[cur][i].second) {
lowcost[e[cur][i].first] = e[cur][i].second;
q.push(make_pair(e[cur][i].first,e[cur][i].second));
}
}
}
}
if(cnt == n)
return ans; //找到n个点,返回最小权值和;
return -inf;
}
int main() {
int m,n,u,v,w;
while(cin>>n>>m) {
init(n);
for(int i = 0; i < m; i++) {
cin>>u>>v>>w;
e[u].push_back(make_pair(v,w));
e[v].push_back(make_pair(u,w));
}
cout<<Prim(n,1)<<endl;
}
return 0;
}
当然,也可以优化用数组手写邻接表的形式:
#include<iostream>
#include<algorithm>
#include<cstring>
#include<queue>
#include<vector>
#define maxn 100010
#define maxm 10000010
#define inf 0x3f3f3f3f
using namespace std;
typedef pair<int,int> pii;
struct node {
int v,w,nxt;
}e[maxm];
int head[maxn],vis[maxn],lowcost[maxn],tot = 0;
void init() {
tot = 0;
memset(head,-1,sizeof head);
}
void addedge(int u,int v,int w) {
e[tot].v = v;
e[tot].w = w;
e[tot].nxt = head[u];
head[u] = tot++;
}
struct cmp {
bool operator()(pii a,pii b) {
return a.second > b.second;
}
};
int Prim(int n,int st) { //选择任意一个点作为生成树的起点,这里是st;
int ans = 0,cnt = 0;
for(int i = 0; i <= n; i++) {
vis[i] = 0;
lowcost[i] = inf;
}
lowcost[st] = 0;
int cur;
priority_queue<pii,vector<pii>,cmp> q;
q.push(make_pair(st,lowcost[st]));
while(!q.empty()) { //找n个点;
cur = q.top().first;
q.pop();
if(!vis[cur]) {
vis[cur] = 1,cnt++;
ans += lowcost[cur];
for(int i = head[cur]; i != -1; i = e[i].nxt) { //更新lowcost数组;
if(lowcost[e[i].v] > e[i].w) {
lowcost[e[i].v] = e[i].w;
q.push(make_pair(e[i].v,e[i].w));
}
}
}
}
if(cnt == n)
return ans; //找到n个点,返回最小权值和;
return -inf;
}
int main() {
int m,n,u,v,w;
while(cin>>n>>m) {
init();
for(int i = 0; i < m; i++) {
cin>>u>>v>>w;
addedge(u,v,w);
addedge(v,u,w);
}
cout<<Prim(n,1)<<endl;
}
return 0;
}
类比 D i j k s t r a Dijkstra Dijkstra的堆优化可知,上述优化的时间复杂度为 O ( E l o g E ) O(ElogE) O(ElogE).
分析
再来简单地说说时间复杂度的问题(以下关于时间复杂度的讨论均基于邻接表存图的基础之上),如下:
- K r u s k a l Kruskal Kruskal是加边法,需要遍历一遍整个边集 E E E,其中对于每条边的顶点,需要使用并查集查找合并,并查集的时间复杂度为 O ( l o g E ) O(logE) O(logE),因此 K r u s k a l Kruskal Kruskal总的时间复杂度为 O ( E l o g E ) O(ElogE) O(ElogE).
- P r i m Prim Prim是加点法,维护一个 l o w c o s t [ ] lowcost[] lowcost[]数组,每次从中找到最短边,再更新整个 l o w c o s t [ ] lowcost[] lowcost[],这个思想和 D i j k s t r a Dijkstra Dijkstra是一样的(只是更新操作不同),因此它的时间复杂度为 O ( V 2 ) O(V^2) O(V2),当然,因为和 D i j k s t r a Dijkstra Dijkstra异曲同工,我们可以用优先队列优化它时间复杂度就会降低至 O ( E l o g E ) O(ElogE) O(ElogE).
因为这两个算法的侧重点不同引起的时间复杂度不同,导致我们经常在稀疏图中使用 K r u s k a l Kruskal Kruskal算法,在稠密图中使用 P r i m Prim Prim算法,优化后的 P r i m Prim Prim算法可以认为在很大程度上和 K r u s k a l Kruskal Kruskal算法时间复杂度一样.