【最小生成树】Minimum Spanning Tree
前言
此乃小 Oler 的一篇算法随笔,从今日后,还会进行详细的修订。
一、简单介绍(MST)
在一给定的无向图
G
=
(
V
,
E
)
G=(V,E)
G=(V,E) 中,
(
u
,
v
)
(u,v)
(u,v) 代表连接顶点
u
u
u 与顶点
v
v
v 的边,而
w
(
u
,
v
)
w(u,v)
w(u,v) 代表此边的权重,若存在
T
T
T 为
E
E
E 的子集且为无循环图,使得连通所有结点的的
w
(
T
)
w(T)
w(T) 最小,则此
T
T
T 为
G
G
G 的最小生成树。
w
(
t
)
=
∑
(
u
,
v
)
∈
t
w
(
u
,
v
)
w(t)=\sum_{(u,v) \in t} w(u,v)
w(t)=(u,v)∈t∑w(u,v)
最小生成树其实是最小权重生成树的简称。
源自百度 最小生成树
二、概念 and 性质 and 证明
概念
最小生成树:
- 生成树:一个连通图的生成树包含图的所有顶点,并且只含尽可能少的边。对于生成树来说,若砍去它的一条边,则会使生成树变成非连通图;若给它增加一条边,则会形成图的一条回路。
- 最小生成树:对于一个带权连通无向图 G = ( V , E ) G=(V,E) G=(V,E) ,生成树不同,每棵树的权(即树中所有边上得权值之和)也可能不同。设 R R R 为 G G G 的所有生成树的集合,若 T T T 为 R R R 中边的权值之和最小的那棵生成树,则 T T T 称为 G G G 的最小生成树(Minimum-Spanning-Tree,MST)。
性质
- 最小生成树不是唯一的,即最小生成树的树形不唯一, R R R 中可能有多个最小生成树。当图 G G G 中的各边权值互不相等时, G G G 本身是一棵树时,则 G G G 的最小生成树就是它本身。
- 最小生成树的边的权值之和总是唯一的,虽然最小生成树不唯一,但其对应的边的权值之和总是唯一的,而且是最小的。
- 最小生成树的边数为树的顶点减 1 1 1 。
说明
MST性质:
- 设 G = ( V , E ) G=(V,E) G=(V,E) 是一个连通网络, U U U 是顶点集 V V V 的一个非空真子集。若 ( u , v ) (u,v) (u,v) 是 G G G 中一条“一个端点在 U U U 中(如, u ∈ U u \in U u∈U ),另一个端点不在 U U U 中的边(如, v ∈ V − U v \in V-U v∈V−U ),且 ( u , v ) (u,v) (u,v) 具有最小权值,则一定存在 G G G 的一棵最小生成树包括此边 ( u , v ) (u,v) (u,v) 。
证明
为方便说明,先作以下约定:
①. 将集合
U
U
U 中的顶点看作是红色顶点;
②. 而
V
−
U
V-U
V−U (即非子集内的顶点)中的顶点看作是蓝色顶点;
③. 连接红点和蓝点的边看作是紫色边;
④. 权最小的紫边称为轻边(即权重最“轻”的边)。
于是,MST性质中所述的边
(
u
,
v
)
(u,v)
(u,v) 就可简称为轻边。
用反证法证明MST性质:
假设
G
G
G 中任何一棵MST都不含轻边
(
u
,
v
)
(u,v)
(u,v) 。则若
T
T
T 为
G
G
G 的任意一棵MST,那么它不含此轻边。
根据树的定义,则
T
T
T 中必有一条从红点
u
u
u 到蓝点
v
v
v 的路径
P
P
P ,且
P
P
P 上必有一条紫边
(
u
′
,
v
′
)
(u',v')
(u′,v′) 连接红点集和蓝点集,否则
u
u
u 和
v
v
v 不连通。当把轻边
(
u
,
v
)
(u,v)
(u,v) 加入树
T
T
T 时,该轻边和
P
P
P 必构成了一个回路。删去紫边
(
u
′
,
v
′
)
(u',v')
(u′,v′) 后回路亦消除,由此可得另一生成树
T
′
T'
T′ 。
T
′
T'
T′ 和
T
T
T 的差别仅在于
T
′
T'
T′ 用轻边
(
u
,
v
)
(u,v)
(u,v) 取代了
T
T
T 中权重可能更大的紫边
(
u
′
,
v
′
)
(u',v')
(u′,v′) 。因为
w
(
u
,
v
)
≤
w
(
u
′
,
v
′
)
w(u,v) \leq w(u',v')
w(u,v)≤w(u′,v′),所以
w
(
T
′
)
=
w
(
T
)
+
w
(
u
,
v
)
−
w
(
u
′
,
v
′
)
≤
w
(
T
)
w(T')=w(T)+w(u,v)-w(u',v')\leq w(T)
w(T′)=w(T)+w(u,v)−w(u′,v′)≤w(T)
即
T
′
T'
T′ 是一棵比
T
T
T 更优的MST,所以
T
T
T 不是
G
G
G 的MST,这与假设矛盾。
所以,MST性质成立。
三、代码实现
Prim 算法
I.初始化
a
c
r
s
i
,
j
=
+
∞
acrs_{i,j}=+\infty
acrsi,j=+∞ :表示顶点
i
i
i 到顶点
j
j
j 的边权值。
d
i
s
t
i
=
+
∞
dist_i=+\infty
disti=+∞:表示顶点
i
i
i 在真最小生成树中离它最近的节点的距离。
f
i
=
f
a
l
s
e
f_i=false
fi=false :表示顶点
i
i
i 是否已经在最小生成树中。
II.算法流程
- 从图中任取一顶点加入树 T T T ,此时树中只含有一个顶点;
- 之后选择一个与当前 T T T 中顶点集合距离最近的顶点,并将该顶点和相应的边加入 T T T ;
- 每次操作后 T T T 中的顶点树和边数都增加 1 1 1 ,并且把边权值加入记录权总和的 r e s res res 中。
- 以此类推,直至图中所有顶点都并入 T T T,得到的 T T T 就是最小生成树,此时 T T T 中必然有 n − 1 n-1 n−1 条边,若原本的图属于非连通图,那必然也会有若干个顶点无法找到依附于它的顶点,直接返回 ∞ \infty ∞ 。
Code(加点大法)
#include<bits/stdc++.h>
#define int long long
using namespace std;
const int oo=0x3f3f3f3f;
const int N=520;
int n,m,s,u,v,w,res;
int dist[N],arcs[N][N];
bool f[N]; //标记数组标记点是否已经在生成树集合中
void prim() {
dist[s]=0;
for(int i=1;i<=n;i++) {
int t=-1;
for(int j=1;j<=n;j++) { //选择最小距离的点
if(!f[j]&&(t==-1||dist[j]<dist[t]))
t=j;
}
if(dist[t]==oo) {
res=oo;
return ;
}
res+=dist[t];
f[t]=1;
for(int k=1;k<=n;k++) { //更新最小距离
if(f[k]==0&&dist[k]>arcs[t][k])
dist[k]=arcs[t][k];
}
}
return ;
}
signed main() {
scanf("%lld%lld",&n,&m);
memset(dist,oo,sizeof dist);
memset(arcs,oo,sizeof arcs); //初始化
while(m--) {
scanf("%lld%lld%lld",&u,&v,&w);
arcs[u][v]=arcs[v][u]=min(arcs[u][v],w); //输入权值取最小
}
s=1;
prim();
if(res==oo) printf("impossible\n");
else printf("%lld\n",res);
return 0;
}
Prim+堆优化 算法
Prim 的堆优化和 Dijkstra 的堆优化差不多。
I.邻接表存图
由于要使用到优先队列堆优化 Prim 的时间运行效率,在访问时遍历其相邻的边即可,所以只需要用到邻接表来存图。
struct Node {
int to,w,nxt;
Node() {
to=nxt=w=0;
}
Node(int a,int b,int c) {
to=a;
nxt=b;
w=c;
}
}adj[N];
这确实非常容易理解,不必多说了。
II.流程
- 将优先队列定义成小根堆,优先队列元素为 p a i r < i n t , i n t > pair<int,int> pair<int,int> ,其中第一个元素含义为图中顶点 v i v_i vi 到真最小生成树中最近的节点 j j j 的距离 d i s t j dist_j distj ,第二个元素为节点编号 v j v_j vj 。
- 初始化:
d
i
s
t
i
=
∞
dist_i=\infty
disti=∞
将源点 d i s t [ v 0 ] dist[v_0] dist[v0] 设置成 0 0 0 ,并将 { d i s t [ v 0 ] , v 0 dist[v_0],v_0 dist[v0],v0 } 放入优先队列。 - 去取出栈顶的元素,如果,堆顶节点 v j v_j vj 已经在集合 T T T 中,则舍弃该顶点,再次取出堆顶元素,否则把该节点 v j v_j vj 加入集合 T T T 中,修改从顶点 v j v_j vj 出发到集合 T T T 内最近的节点 v k v_k vk 的可达最短长度 d i s t [ k ] dist[k] dist[k] ;若 d i s t [ k ] > v a l u e < j , k > dist[k]>value<j,k> dist[k]>value<j,k> 则更新 d i s t [ k ] = v a l u e < j , k > dist[k]=value<j,k> dist[k]=value<j,k>,其中 v a l u e < j , k > value<j,k> value<j,k> 代表 v j v_j vj 到 v k v_k vk 的边权值。并把节点 { d i s t [ k ] , k dist[k],k dist[k],k } 加入队列当中。
Code2(堆优化大法)
#include<bits/stdc++.h>
#define int long long
#define M(x,y) make_pair(x,y)
using namespace std;
typedef pair<int,int> pll; //稀疏图用邻接表来存
const int oo=0x3f3f3f3f;
const int N=1e6+10;
int n,m,s,x,y,z;
struct Node {
int to,w,nxt;
Node() {
to=nxt=w=0;
}
Node(int a,int b,int c) {
to=a;
nxt=b;
w=c;
}
}adj[N];
int head[N],idx;
int dist[N],res;
int cnt;
bool st[N]; //如果true说明这个顶点i在集合T中
priority_queue<pll,vector<pll>,greater<pll> >heap;
inline void add(int x,int y,int z) {
adj[++idx]=Node(y,head[x],z);
head[x]=idx;
}
void prim() {
for(int i=1;i<=n;i++)
dist[i]=oo;
dist[s]=0;
heap.push(M(0,s)); //这个顺序不能倒
while(!heap.empty()&&cnt<n) {
pll k=heap.top(); //取不在集合T(V-T)中距离最近的点
heap.pop();
int u=k.second;
int distance=k.first;
if(st[u]) continue;
cnt++,res+=distance;
st[u]=1; //把该点加入集合T
for(int i=head[u];i;i=adj[i].nxt) {
int v=adj[i].to,w=adj[i].w; //取出和u相连的点和边权
if(dist[v]>w) {
dist[v]=w; //更新最短距离
heap.push(M(dist[v],v)); //放入优先队列中
}
}
}
return ;
}
signed main() {
scanf("%lld%lld",&n,&m);
while(m--) {
scanf("%lld%lld%lld",&x,&y,&z);
add(x,y,z),add(y,x,z);
}
s=1;
prim();
if(cnt!=n) printf("impossible\n"); //顶点个数不为n,构造不符,直接输出impossible
else printf("%lld\n",res); //反之,输出最小生成树的权和
return 0;
}
Kruskal 算法
I.初始化 & 预处理
f a i = i fa_i=i fai=i :表示顶点 i i i 当前所指向的父亲节点,用于并查集中。
II.并查集
此算法需要用到并查集进行判环,为了优化时间复杂度,我们需要对其进行松弛操作。
int findp(int x) {
if(fa[x]==x) return x;
return fa[x]=findp(fa[x]);
}
这里也就不多讲了,如需深入了解并查集的,博主亲自推荐自家的博客。
III.算法流程
- 初始时只有 n n n 个顶点而无边的非连通图 V ∈ T V \in T V∈T ;
- 由于本算法的思想是每次找最短的边权值进行更新操作,储存完图后,每个顶点自成一个连通分量,然后按照边权从小到大排序;
- 不断选取当前未被选取过且权值最小的边,若该边依附的顶点落在 T T T 中不同的连通分量上,则将此边加入 T T T ,否则舍弃此边而选择下一条权值最小的边;
- 再依次类推,直至
T
T
T 中所有顶点都在一个连通分量上。
IV.Code3(加边大发)
#include<bits/stdc++.h>
#define int long long
using namespace std;
const int N=1e6+10;
int n,m,res,cnt;
struct Edge {
int x,y,z;
}edge[N];
int fa[N];
void init_() {
for(int i=1;i<=n;i++)
fa[i]=i;
}
bool cmp(Edge x,Edge y) {
return x.z<y.z;
}
int findp(int x) { //并查集找祖先
if(fa[x]==x) return x;
return fa[x]=findp(fa[x]);
}
void kruskal() {
for(int i=1;i<=m;i++) {
int u=edge[i].x;
int v=edge[i].y;
int w=edge[i].z;
int xp=findp(u);
int yp=findp(v);
if(xp!=yp) { //是否存在环
res+=w;
cnt++;
fa[xp]=yp;
}
}
return ;
}
signed main() {
scanf("%lld%lld",&n,&m);
init_();
for(int i=1;i<=m;i++) //储存图
scanf("%lld%lld%lld",&edge[i].x,&edge[i].y,&edge[i].z);
sort(edge+1,edge+m+1,cmp); //从小到大排序
kruskal();
if(cnt!=n-1) printf("impossible\n");
else printf("%lld\n",res);
return 0;
}
三、总结
Prim 算法,主要思想在于遍历时对每个点寻找最近的顶点进行更新,时间复杂度为 O ( n 2 ) O(n^2) O(n2) ,适用于稠密图。
Kruskal 算法,主要的流程时每次对于图中任意一点找与其的连边中最小的边权,因可能出现环,所以再用并查集 O ( n ) O(n) O(n) 来判环;预处理时 O ( m log m ) O(m \log m) O(mlogm) 把边从小到大排序,所以总的时间复杂度为 O ( n + m log m ) O(n+m \log m) O(n+mlogm) ,适用于稀疏图。
Prim 若加上堆优化的话时间复杂度为 O ( n log m ) O(n \log m) O(nlogm) ,但代码量相较麻烦,时间复杂度和 Kruskal 算法差不多,一般选用 Kruskal 。
注: n n n 为图中的顶点数目, m m m 为图中边的数量。
题库
古有人云: 听君一席话,胜读十年书
此处留下我的入门练习题单洛谷【最小生成树】 ID:970993
后记
如有侵权,请联系一下我,说明情况,如属实,我会立即撤回文章!谢谢大家支持!