27.最小生成树

一、问题介绍

生成树被定义为:一个连通无向图的生成子图,同时要求是树。也即在图的边集中选择 n − 1 n-1 n1 条,将所有顶点连通。

我们将最小生成树(Minimum Spanning Tree,MST)定义为:无向连通图的为边权和最小的生成树。

只有连通图才有生成树,而对于非连通图,只存在生成森林。

现在广泛使用的最小生成树算法主要有两种,分别为 Kruskal 算法和 Prim 算法,下面具体介绍一下每个算法。

二、Kruskal算法

1.简介

Kruskal 算法是一种常见并且好写的最小生成树算法,由 Kruskal 发明。该算法的基本思想是从小到大加入边,是个贪心算法,一般需要借助并查集这一数据结构来实现。

2.算法流程

  • 将所有的边按边权从小到大排序,这里使用了贪心的思想
  • 维护一个森林,按顺序取出边,查询边的两个结点是否在同一棵树中
    • 如果在,则不进行任何操作。
    • 如果不在,则连接两棵树,将这条边计入最小生成树。
  • 查询两点是否在同一棵树和连接两树使用并查集维护。
  • 时间复杂度 O ( m log ⁡ m ) O(m\log m) O(mlogm)

3.贪心证明

思路很简单,为了造出一棵最小生成树,我们从最小边权的边开始,按边权从小到大依次加入,如果某次加边产生了环,就扔掉这条边,直到加入了 n − 1 n-1 n1 条边,即形成了一棵树。

证明:使用归纳法,证明任何时候 Kruskal 算法选择的边集都被某棵 MST 所包含。

  • 基础:对于算法刚开始时,没有任何一条边,显然成立(最小生成树存在)。

  • 归纳:假设某时刻成立,当前边集为 F,令 T 为这棵 MST,考虑下一条加入的边 e。

    • 如果 e 属于 T,那么成立。
    • 否则,T+e 一定存在一个环,考虑这个环上不属于 F 的另一条边 f(一定只有一条)。

首先,f 的权值一定不会比 e 小,不然 f 会在 e 之前被选取。

然后,f 的权值一定不会比 e 大,不然 T+e-f 就是一棵比 T 还优的生成树了。

因此,f 和 e 的权值相等,T+e-f 也是一棵最小生成树,且包含了 F。

4.模板代码

#include 
#include 
#include 
#include 

using namespace std;
typedef long long ll;
const ll maxn=5010;
const ll maxm=200010;
struct node
{
    ll u;
	ll v;
    ll w;
}e[maxm];
bool cmp (node a,node b)
{
    return a.w<b.w;
}
ll fa[maxn];
ll get(ll x)
{
    if(x==fa[x])
        return x;
    return fa[x]=get(fa[x]);
}
ll kruskal(ll n,ll m)
{
    ll sum=0;
    for(ll i=1;i<=n;i++)
        fa[i]=i;
    sort(e,e+m,cmp);
    for(ll i=0;i<m;i++)
    {
        ll fu=get(e[i].u);
        ll fv=get(e[i].v);
        if(fu!=fv)
        {
            fa[fv]=fu;
            sum+=e[i].w;
        }
    }
    return sum;
}
int main()
{
    ll n,m;
    cin>>n>>m;
    for(ll i=0;i<m;i++)
        cin>>e[i].u>>e[i].v>>e[i].w;
    cout<<kruskal(n,m)<<endl;
    
    return 0;
}

三、Prim算法

1.简介

Prim 算法是另一种常见并且好写的最小生成树算法。该算法的基本思想是从一个结点开始,不断加点,这和以加边的方式进行的 Kruskal 算法有所不同。在算法的流程上与 Dijkstra有更多的相似之处。

2.算法流程

其实跟 Dijkstra 算法一样,每次找到距离最小的一个点,以及用新的边更新其他结点的距离,可以暴力找也可以用堆维护。

  • 从任意一个结点开始,将结点分成两类:已加入的,未加入的。
  • 每次从未加入的结点中,找一个与已加入的结点之间边权最小值最小的结点(dijkstra 算法这里是找的离源点最近的点)。
  • 然后将这个结点加入,并连上那条边权最小的边.
  • 重复 n-1 次即可。

堆优化的方式类似 Dijkstra 的堆优化,但如果使用二叉堆等不支持 O ( 1 ) O(1) O(1) decrease-key 的堆,复杂度就不优于 Kruskal,常数也比 Kruskal 大。所以,一般情况下都使用 Kruskal 算法。而在稠密图尤其是完全图上,暴力 Prim 的复杂度比 Kruskal 优,但 不一定 实际跑得更快。

暴力: O ( n 2 + m ) O(n^2+m) O(n2+m);二叉堆: O ( ( n + m ) log ⁡ n ) O((n+m)\log n) O((n+m)logn);Fib 堆: O ( n log ⁡ n + m ) O(n \log n + m) O(nlogn+m)

3.证明

证明:在每一步,都存在一棵最小生成树包含已选边集。

  • 基础:只有一个结点的时候,显然成立。
  • 归纳:如果某一步成立,当前边集为 F,属于 T 这棵 MST,接下来要加入边 e。
    • 如果 e 属于 T,那么显然成立。
    • 否则,考虑 T+e 中形成的环上,另一条可以加入当前边集的边 f。

首先,f 的权值一定不小于 e 的权值,否则就会选择 f 而不是 e 了。

然后,f 的权值一定不大于 e 的权值,否则 T+e-f 就是一棵更小的生成树了。

因此,f 和 e 的权值相等,T+e-f 也是一棵最小生成树,且包含了 F。

4.模板代码

#include 
#include 
#include 
#include 

using namespace std;
typedef int ll;
const ll maxn=5010;
ll mp[maxn][maxn],d[maxn],n,m,ans,cnt=0;
bool vis[maxn];
void prim()
{
	memset(d,0x3f,sizeof(d));
	memset(vis,false,sizeof(vis));
	d[1]=0;
	for(ll i=1;i<n;i++)
	{
		ll x=0;
		for(ll j=1;j<=n;j++)
			if(!vis[j] && (x==0 || d[j]<d[x]))
				x=j;
		if(d[x]!=d[0])
			cnt++;
		vis[x]=true;
		for(ll y=1;y<=n;y++)
			if(!vis[y])
				d[y]=min(d[y],mp[x][y]);
	}
}
int main()
{
	memset(mp,0x3f,sizeof(mp));
    cin>>n>>m;
    for(ll i=0;i<m;i++)
    {
    	ll x,y,z;
    	cin>>x>>y>>z;
    	mp[x][y]=mp[y][x]=min(mp[x][y],z);
	}
	prim();
	for(ll i=2;i<=n;i++)
		ans+=d[i];
	if(cnt==n-1)
		cout<<ans<<endl;
	else
		cout<<"orz"<<endl;
	
	return 0;
}

四、作业

P3366 【模板】最小生成树

1.Kruskal算法

P1111 修复公路

P2820 局域网

P1546 [USACO3.1]最短网络 Agri-Net

P1195 口袋的天空

P2330 [SCOI2005]繁忙的都市

2.Prim算法

P2504 [HAOI2006] 聪明的猴子

P1194 买礼物

你可能感兴趣的:(算法竞赛讲义,图论,算法,最小生成树,并查集)