图论简笔(下)——最小生成树、最短路径问题


前期链接: 深度优先搜索、广度优先搜索、拓扑排序

最小生成树

Prim算法

该算法于1930年由捷克数学家沃伊捷赫·亚尔尼克(英语:Vojtěch Jarník)发现;并在1957年由美国计算机科学家罗伯特·普里姆(英语:Robert C. Prim)独立发现;1959年,艾兹格·迪科斯彻再次发现了该算法。因此,在某些场合,普里姆算法又被称为DJP算法、亚尔尼克算法或普里姆-亚尔尼克算法。

Prim算法的性质接近贪心,它的目的是用总和最短的路径连接图中所有的结点。
具体操作为:每到一个节点,把它与未联通点之间的路径加入到队列中并排序,然后取最短的边连接未知点,直到所有点都被连接。
(随便捏一个数据)
在这里插入图片描述
此时①到达各个点的距离为
① x
② 7
③ INF
④ INF
⑤ INF
⑥ INF
⑦ 8
(x表示已连通,INF表示无法连通)
根据贪心原理,此处连接①和②,费用为7
在这里插入图片描述
更新①②形成的整体与其它点的连通情况
① x
② x
③ 4
④ 2
⑤ INF
⑥ 3
⑦ 6
显然连接④
在这里插入图片描述
以下给出剩余步骤
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
最终结果图:
在这里插入图片描述
假设有n个结点,把找到一条边作为一次循环操作,总共有n-1循环。
每次循环包括
1.找出最短的边并连接
2.更新集合体对其余各个点的距离
算法的时间复杂度为 O ( n 2 ) O(n^2) O(n2)

int map[MAXN+1][MAXN+1];//保存各个节点之间的距离
bool vis[MAXN+1]={false};//标记被访问过的结点
int dis[MAXN+1];//记录集合体到剩余节点的最短距离
int Prim()
{
	int sum=0;
	int node=1;
	vis[node]=true;
	for(int i=1;i<=n;++i)
		dis[i]=map[node][i];
	for(int e=1;e<n;++e)
	{
		int min=0xffffff;
		for(int i=1;i<=n;++i)
			if(!vis[i]&&dis[i]<min)
			{
				min=dis[i];
				node=i;
			}
		vis[node]=true;
		sum+=min;
		for(int i=1;i<=n;++i)
			if(!vis[i]&&dis[i]>map[node][i])
				dis[i]=map[node][i];
	}
	return sum;
}



Kruskal算法

Kruskal算法是一种用来查找最小生成树的算法,由Joseph Kruskal在1956年发表。用来解决同样问题的还有Prim算法和Boruvka算法等。三种算法都是贪心算法的应用。和Boruvka算法不同的地方是,Kruskal算法在图中存在相同权值的边时也有效。

Kruskal算法也是利用贪心的性质,但与Prim算法生成图的方式不同。Kruskal算法把边从小到大一次性排列,然后从最短的边开始连接,检查每一条边两端点的是否在一个集合体中,如果已经相连,则遍历次短的边。
原理图:
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
Kruskal和Prime算法殊途同归。

Kruskal算法具体操作步骤:
1.将各条边的距离从小到大排序。
2.从最短边开始遍历。
a)如果该边的两个端点不在一个集合中,连接这两条边。
b)如果该边的两个端点在一个集合中,遍历下一条边。

判断两个端点是否已在一个集合中可用并查集(将会在文章最后补充)
该算法的时间复杂度为 O ( E l o g E ) O(ElogE) O(ElogE),E为边的数量。
(该代码纯手打,未经过编译)

struct EDGE
{
	int u;
	int v;
	int value;
}edge[MAXE];
vector<EDGE> e[MAXN+1];
int f[MAXN+1];
int Kruskal()
{
	int sum=0;
	int ct=0;
	for(int i=1;i<=n;++i)
		for(int j=0;j<e[i].size();++j)
			edge[ct++]=e[i][j];
	init(f);//初始化,每一个结点所在集合为它自身
	sort(edge,edge+ct,cmp);
	for(int i=0;i<ct;++i)
	{
		EDGE k=edge[i];
		if(find(k.u)!=find(k.v))//查找u和v是否在同一个集合
		{
			sum+=k.value;
			f[find(k.v)]=k.u;//把v归到u所在的集合
		}
	}
	return sum;
} 



两种算法的比较、并查集

先附上该题分别用两种算法AC的代码

Prim

#include<cstdio>
#include<iostream>
#include<cstring>
using namespace std;
int map[101][101];
bool vis[101];
int dis[101];
int n;
int prim()
{
	int sum=0;
	int node=1,newnode;
	vis[node]=true;
	for(int i=1;i<=n;++i)
		dis[i]=map[node][i];
	for(int i=1;i<n;++i)
	{
		int min=0xffffff;
		for(int j=1;j<=n;++j)
			if(vis[j]==false&&dis[j]<min)
			{	
				min=dis[j];
				node=j;
			} 
		vis[node]=true;
		sum+=min;
		for(int j=1;j<=n;++j)
			if(vis[j]==false&&map[node][j]<dis[j])
				dis[j]=map[node][j];
	}
	return sum;
}
int main()
{
	cin>>n;
	for(int i=1;i<=n;++i)
		for(int j=1;j<=n;++j)
			cin>>map[i][j];
	int ans=prim();
	cout<<ans<<endl;
	return 0;
}

Kruskal

#include<iostream>
#include<algorithm>
#include<cstdio>
using namespace std;
struct kruskal
{
	int u;
	int v;
	int e;
}kru[10004];
int n;
int fa[101];
bool cmp(kruskal p1,kruskal p2)
{
	return p1.e<p2.e;
}
int find(int node)
{
	if(fa[node]==node)return node;
	return fa[node]=find(fa[node]);
}
int main()
{
	cin>>n;
	int p=1;
	for(int i=1;i<=n;++i)
	{
		fa[i]=i;
		for(int j=1;j<=n;++j)
		{
			int k;
			cin>>k;
			if(j>i)
			{
				kru[p].u=i;
				kru[p].v=j;
				kru[p++].e=k;
			}
		}
	}
	sort(kru+1,kru+p,cmp);
	int ans=0;
	int node=1;
	int ct=1;
	while(ct!=n)
	{
		if(find(kru[node].u)!=find(kru[node].v))
		{
			ans+=kru[node].e;
			fa[find(kru[node].v)]=kru[node].u;
			++ct;
		}
		++node;
	}
	cout<<ans<<endl;
	return 0;	
} 

看完两种AC方案,接下来讨论一下两种算法的优先选择。
Prim算法的时间复杂度为 O ( n 2 ) O(n^2) O(n2),与边数无关。
Kruskal算法的时间复杂度为 O ( E l o g E ) O(ElogE) O(ElogE),与结点数无关。
所以可以轻易得出结论:Prim算法适合边数很多的稠密图,Kruskal算法适合边数较少的稀疏图。



——并查集——
并查集用于合并和判断点与点之间的关系,并查集包含两个部分:并、查。
以下代码给出并查集的两种操作。

#include<cstdio>
#include<cstring>
#include<iostream>
#include<algorithm>
using namespace std;
int N,M;
int dot[10001];
int find(int d)//查找操作
{
	if(dot[d]==d)
		return d;
	return dot[d]=find(dot[d]);
}
int main()
{
	int z,x,y;
	while(cin>>N>>M)
	{
		for(int i=1;i<=N;++i)//初始化,每个点所在的集合分开
			dot[i]=i;
		while(M--)
		{
			cin>>z>>x>>y;
			if(z==1)
				dot[find(x)]=find(y);//合并点集
			else if(z==2)
			{
				if(find(x)==find(y))//判断两点是否在同一个集合
					printf("Y\n");
				else
					printf("N\n");
			}
		}
	}
	return 0;
}




最短路径

Floyd-Warshall算法

Floyd算法又称为插点法,是一种利用动态规划的思想寻找给定的加权图中多源点之间最短路径的算法,与Dijkstra算法类似。该算法名称以创始人之一、1978年图灵奖获得者、斯坦福大学计算机科学系教授罗伯特·弗洛伊德命名。

Floyd-Warshall算法能够求任意两点间的最短路径。

思想大致是这样的:
假设 e d g e [ i ] [ j ] edge[i][j] edge[i][j]表示 i i i j j j之间的路径长度,如果不连通则设为无限大。你想知道 a a a d d d的最短路径,图中还有另外两个点 b b b c c c
在这里插入图片描述
如果 a a a d d d的最短路径经过 b b b,那么最终答案的构成就是 a a a b b b的最短路径+ b b b d d d的最短路径,再假设 b b b d d d最短路径经过 c c c…通过这个递推可以得出一个结论:对于每两个点,我们枚举它们中间经过的一个点,就可以得出这两个点之间的最短路径。
为了符合递推原理,不至于出现不确定的路径相加,我们在枚举起始点和终点前,先枚举中间点。
按照循环顺序,首先考察 a a a为中间点,所有点之间距离不变。于是考察 b b b为中间点,枚举起点 a a a终点 b b b,最短路径即为 e d g e [ a ] [ b ] = 11 edge[a][b]=11 edge[a][b]=11;接着检查是否更新 a a a c c c之间路径,原先为INF,当以 b b b为中间点时,更新为19。依次类推,可以得出如下表格。
在这里插入图片描述
该算法的代码也很简单

int edge[MAXN][MAXN];//储存初始路径
int n;//节点数
int k;//中间点
int i;//起点
int j;//终点
for(k=1;k<=n;++k)
	for(i=1;i<=n;++i)
		for(j=1;j<=n;++j)
			edge[i][j]=min(edge[i][j],edge[i][k]+edge[k][j]);

复杂度考察:
从以上模板可以看出,弗洛伊德算法只需要暴力三层循环枚举中间点、起点、终点即可,所以时间复杂度与图的稠密程度无关且只与节点数有关,判定复杂度为 O ( N 3 ) O(N^3) O(N3). 而且本算法的代码极其简短,适合做题时手抽筋且数据小(保险N<=100)的情况



Bellman-Ford算法

Bellman - ford算法是求含负权图的单源最短路径的一种算法,效率较低,代码难度较小。其原理为连续进行松弛,在每次松弛时把每条边都更新一下,若在n-1次松弛后还能更新,则说明图中有负环,因此无法得出结果,否则就完成。

贝尔曼算法基于动态规划思想实现,用于计算指定起点与其它点之间的最短路径。

其思路大致如下:

1)创建一个数组 d i s [ M A X N ] dis[MAXN] dis[MAXN]记录起点到各个节点的距离,初始化为INF,起点处值设为0;该数组用于持续更新,直到得出起点到所有点的最短路径。

2)进行 n − 1 n-1 n1次循环,保证正常情况下能找到最短路径。每次循环,所有边进行遍历,对于一条边的起点u和终点v,如果 d i s [ u ] + e d g e [ u ] [ v ] &lt; d i s [ v ] dis[u]+edge[u][v]&lt;dis[v] dis[u]+edge[u][v]<dis[v],则更新 d i s [ v ] dis[v] dis[v],使 v v v到起点的距离向最短路径靠近。

3)检测图中是否存在负权环。对所有边进行一次遍历,如果存在 d i s [ v ] dis[v] dis[v]可以再次更新,则证明有负权环。
在这里插入图片描述
每一次循环至少保证能找出一个点,起点到这个点的所有情况中距离是最短的。
在这种不包含负权环的情况下,最终 d i s dis dis数组为0、8、6、5、12、4.

如果包含负权环,设一组数据为:u(边起点)、v(边终点)、w(权值)
1、2、8
2、3、-4
3、4、-4
4、2、3
则存在2,3,4构成的环永久更新。

模板演示

struct EDGE{int u,v,w}edge[MAXE];//记录边的信息
int n;//节点数
int e;//边数
int dis[MAXN];//更新起点到其它点的最短距离
bool flag;//判断负权环
void Bellman_ford(int st)//接收起点
{
	for(int i=1;i<=n;++i)
		dis[i]=INF;
	dis[st]=0;
	//初始化dis数组
	
	for(int i=1;i<n;++i)
		for(int j=1;j<=e;++j)
			dis[edge[j].v]=min(dis[edge[j].v],dis[edge[j].u]+edge[j].w);
	//更新dis数组
	
	for(int j=1;j<=e;++j)
		if(dis[edge[j].v]>dis[edge[j].u]+edge[j].w)
		{
			flag=true;
			break;
		}
	//判断负权环
}							

复杂度考察
一共进行了 n − 1 n-1 n1次循环,嵌套一个 E E E的循环,最后再加上查负权环的循环,最终时间复杂度约为 O ( N E ) O(NE) O(NE)。如果遇到稠密图,最坏情况接近 N 3 N^3 N3,可能还不如弗洛伊德算法写起来快捷。但是,使用队列优化后,该算法的效率可以大大提升,稍后会提到。



Dijkstra算法

迪杰斯特拉算法是由荷兰计算机科学家狄克斯特拉于1959 年提出的,因此又叫狄克斯特拉算法。是从一个顶点到其余各顶点的最短路径算法,解决的是有向图中最短路径问题。迪杰斯特拉算法主要特点是以起始点为中心向外层层扩展,直到扩展到终点为止。

狄克斯特拉算法的核心思想是贪心,用来解指定起点与其它各点之间的最短路径。复杂度为 O ( n 2 ) O(n^2) O(n2)

大致思路:
1)设一个dis数组记录起点到其它点的距离,起点处值为0,其它点处值为INF;设一个flag数组记录是否找到到该点的最短路径。
2)从起点开始遍历和它连着的边,更新起点与其它点的连接情况,并记录最短边相连的点,标记起点到该点的最短路径。
3)用新得到的点继续更新起点与各个点的距离,记录更新到的最短的点,重复这一操作。
4)当没有点被更新时,退出循环。
(感觉有点像prim算法)
结合P3371 【模板】单源最短路径(弱化版)裸的Dijkstra算法

#include<iostream>
#include<vector>
using namespace std;
struct edg{
	int v;
	long long val;
}; 
vector<edg> edge[10004];
int n,m,s;
long long dis[10004];
bool vis[10004];
void Dijkstra(int st)
{
	for(int i=0;i<edge[st].size();++i)
		dis[edge[st][i].v]=min(dis[edge[st][i].v],edge[st][i].val);
	dis[st]=0;
	vis[st]=true;
	while(true)
	{
		int re=-1;
		for(int i=1;i<=n;++i)
			if(vis[i]==false&&(re==-1||dis[re]>dis[i])) re=i;
		if(re==-1) break;
		vis[re]=true;
		for(int i=0;i<edge[re].size();++i)
			dis[edge[re][i].v]=min(dis[edge[re][i].v],dis[re]+edge[re][i].val);
	}
}
int main()
{
	cin>>n>>m>>s;
	for(int i=1;i<=n;++i)
		dis[i]=2147483647;
	for(int i=1;i<=m;++i)
	{
		int fr;
		edg newedge;
		cin>>fr>>newedge.v>>newedge.val;
		if(fr==newedge.v) continue;
		edge[fr].push_back(newedge);
	}
	Dijkstra(s);
	for(int i=1;i<=n;++i)
		cout<<dis[i]<<" ";
	return 0;
}




Bellman-Ford算法的队列优化(SPFA)

SPFA算法的全称是:Shortest Path Faster Algorithm,是西南交通大学段凡丁于 1994 年发表的论文中的名字。不过,段凡丁的证明是错误的,且在 Bellman-Ford 算法提出后不久(1957 年 [2] )已有队列优化内容,所以国际上不承认 SPFA 算法是段凡丁提出的。

Bellman-Ford算法在点遍历到一定程度时,会出现没有点被更新的情况,所以很多情况下n-1次循环是多余的。SPFA思路和Bellmam-Ford算法大体相同,只是加上一个队列存储距离被更新的点,当队列为空时,则退出循环。
用队列优化之后,看起来更像BFS。复杂度为最坏为 O ( V E ) O(VE) O(VE)
结合P3371 【模板】单源最短路径(弱化版)
代码很好懂:

#include<iostream>
#include<vector>
#include<queue>
#include<utility>
#define inf 2147483647
using namespace std;
int n,m,s;
queue<int> q;
long long dis[10004];
bool vis[10004];
vector< pair<int,long long> > edge[10004];
void SPFA(int st)
{
	for(int i=1;i<=n;++i)
		dis[i]=inf;
	dis[st]=0;
	q.push(st);
	while(!q.empty())
	{
		int u=q.front();
		q.pop();
		vis[u]=false;
		for(int i=0;i<edge[u].size();++i)
		{
			int v=edge[u][i].first;
			long long val=edge[u][i].second;
			if(dis[v]>dis[u]+val)
			{
				dis[v]=dis[u]+val;
				if(!vis[v])
				{
					vis[v]=true;
					q.push(v);
				}
			}
		}
	}
}
int main()
{
	cin>>n>>m>>s;
	for(int i=1;i<=m;++i)
	{
		int fr;
		pair<int ,long long> node;
		cin>>fr>>node.first>>node.second;
		edge[fr].push_back(node);
	}
	SPFA(s);
	for(int i=1;i<=n;++i)
		cout<<dis[i]<<" ";
	return 0;
} 




Dijkstra算法的堆优化

Dijkstra算法在寻找下一个用于更新的结点时会遍历所有点,这样就需要花费n的时间,可以把每次更新的点加入到优先队列,每次从队列中弹出的第一个结点就是用于更新的结点。如果该节点在顶端时前恰好又被更新了,那么轮到它时直接跳过,读取队列中下一个结点。复杂度为 O ( E l o g E ) O(ElogE) O(ElogE)
P4779 【模板】单源最短路径(标准版)

#include<iostream>
#include<vector>
#include<queue>
#include<utility>
#define inf 2147483647
using namespace std;

int n,m,s;
long long dis[100005];

vector< pair<int,int> > edge[100005];

priority_queue<pair<int,int>,vector<pair<int,int> >,greater<pair<int,int> > > q;

void Dijkstra(int st)
{
	for(int i=1;i<=n;++i)
		dis[i]=inf;
	dis[st]=0;
	q.push(make_pair(dis[st],st));
	while(!q.empty())
	{
		pair<int,int> u=q.top();
		q.pop();
		if(dis[u.second]<u.first) continue;
		for(int i=0;i<edge[u.second].size();++i)
		{
			int v=edge[u.second][i].first;
			int val=edge[u.second][i].second;
			if(dis[v]>dis[u.second]+val)
			{
				dis[v]=dis[u.second]+val;
				q.push(make_pair(dis[v],v));
			}
		}
	}
}
int main()
{
	cin>>n>>m>>s;
	for(int i=1;i<=m;++i)
	{
		int fr;
		pair<int,int> node;
		cin>>fr>>node.first>>node.second;
		edge[fr].push_back(node);
	}
	Dijkstra(s);
	for(int i=1;i<=n;++i)
		cout<<dis[i]<<" ";
	return 0;
}

年初三咕到现在QAQ

补充

P3366 【模板】最小生成树
P3371 【模板】单源最短路径(弱化版)
P4779 【模板】单源最短路径(标准版)
读入数据用了没有关闭同步的cin,与真实数据出入较大,横向对比相同
在这里插入图片描述

  • 2
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值