最短路问题是图论中最基础的内容,在考试中也常常看到关于最短路的问题或模型。
最短路问题,即在一个图中,寻找两个节点之间的最短路径的问题。最短路问题分为单源最短路径问题(SSSP问题)和多源最短路径问题,在本文中会一一进行讲解。
在讲解最短路问题前,先补充几个知识点:
- 存图的方法:常见的存图方法有邻接表和邻接矩阵,设该图有$n$个节点,$m$条边,则邻接表的空间复杂度为$O(n+m)$,邻接矩阵的空间复杂度为$O(n^2)$,读者在题目中可以根据题目要求选择一种方法进行存图。关于具体操作可以参考各类信息学奥赛入门书籍,在本文中接下来亦会讲解。
- 优先队列(priority_queue):在C++STL中提供的一种数据结构,其内部实际是一个大根堆,功能与大根堆基本相同,需调用头文件<queue>。
- 二元组:C++提供一个二元组的定义与操作,其中:
- pair<类型a,类型b>:定义一个二元组类型,第一维和第二维的变量类型分别为$a,b$
- make_pair(x,y) a:定义一个新的二元组a,类型为先前定义的二元组类型,第一维和第二维的值分别为$x,y$
- a.first:取出二元组a的第一维的值
- a.second:取出二元组a的第二维的值
单源最短路径(SSSP)问题
SSSP问题的基本模型为:给定一个起点s,找到s到图中其它所有节点的最短路径长度。在本文中, d[i]d[i] 表示 ss 到 ii 的最短路径长度, (x,y,z)(x,y,z) 表示从 xx 到 yy 有一条有向边,该条边的权值为 zz 。
Dijkstra算法
Dijkstra算法是解决SSSP问题最常用的算法,其基于贪心的思想实现,优化过后时间复杂度可达到 O(mlogn)O(mlogn) 。但不足的是,由于其算法的流程,Dijkstra算法不能适用于有负权边的图,在下文中会对此进行解释。
算法流程:
- 初始化 d[s]=0d[s]=0
- 找到一个未标记过且 d[x]d[x] 值最小的节点 xx ,对 xx 进行标记
- 遍历 xx 的所有出边 (x,y,z)(x,y,z) ,若 d[y]>d[x]+zd[y]>d[x]+z ,则更新 d[y]=d[x]+zd[y]=d[x]+z
- 重复第2、3步,直到所有边都被标记过
算法原理:
当第2步找到一个未标记过且 d[x]d[x] 值最小的节点 xx 时,由于在图中所有未标记的节点 ii 的 d[i]d[i] 值都大于等于 d[x]d[x] ,若图中没有负权边,即所有边的权值为非负数,则 d[x]d[x] 的值不可能再被其它节点更新,因此可以用它来更新其它节点的值;但若图中存在负权边,则 d[x]d[x] 的值可能被其它节点 ii 的 d[i]d[i] 值加上一个负数更新,因此算法的正确性就无法得到保证,这就是Dijkstra算法不能适用于有负权边的图的原因。当第三步 d[y]>d[x]+zd[y]>d[x]+z 时,说明当前 ss 到节点 yy 的路径比 ss 经过节点 xx 和 (x,y,z)(x,y,z) 到达节点 yy 的路径要长,因此要采用后者的长度来作为更短的路径。而不断重复第2、3步,就可以不断更新全局最小值,达到求出最短路径的目的。
代码:(采用邻接矩阵存图)
#include<iostream>
#include<cstring>
using namespace std;
const int N=5e3;
int n,m,s,edge[N][N],d[N];
bool v[N];
void dijkstra()
{
memset(d,0x3f,sizeof(d));//由于要求的是最小值,因此初始化为最大值
memset(v,0,sizeof(v));//初始化标记
d[s]=0;
for(int i=1;i<n;i++)
{
int x=0;
for(int j=1;j<=n;j++)
if(!v[j] &&(x==0 || d[j]<d[x]))
x=j;//找到未标记过且d[x]值最小的节点x
v[x]=1;//标记
for(int y=1;y<=n;y++)
d[y]=min(d[y],d[x]+edge[x][y]);//遍历x的所有出边(x,y,z),若d[y]>d[x]+z,则更新d[y]=d[x]+z
}
}
int main()
{
cin>>n>>m>>s;
memset(edge,0x3f,sizeof(edge));
for(int i=1;i<=n;i++)
edge[i][i]=0;//初始化
for(int i=1;i<=m;i++)
{
int u,v,w;
cin>>u>>v>>w;
edge[u][v]=min(edge[u][v],w);
}//邻接矩阵的插入操作
dijkstra();
for(int i=1;i<=n;i++)
cout<<d[i]<<" ";
return 0;
}
按照上面这样写的话,算法的时间复杂度是 O(n^2)O(n2) 。我们可以发现,在第2步寻找全局最小值时浪费了大量的时间。因此我们可以利用优先队列实现每次 O(logn)O(logn) 地查找全局最小值,并用邻接表存图,使算法的时间复杂度优化到 O(mlogn)O(mlogn) 。
由于我们要查找的是全局最小值,而优先队列维护的是一个大根堆,于是我们可以采用将权值的相反数插入队列的方式来实现一个小根堆。在下面的代码中,我们用一个二元组来保存节点的信息,第一维保存节点的 dd 值,第二维保存节点的编号。
代码:(堆优化)
#include<iostream>
#include<cstring>
#include<algorithm>
#include<queue>
using namespace std;
const int N=3e5;
int n,m,s,tot=0,head[N],ver[N],Next[N],edge[N],v[N],d[N];
priority_queue< pair<int,int> > q;//定义一个优先队列,类型为一个第一维和第二维都为int类型的二元组
void add(int x,int y,int z)
{
ver[++tot]=y,edge[tot]=z,Next[tot]=head[x],head[x]=tot;
}//邻接表的插入操作
void dijkstra()
{
memset(v,0,sizeof(v));
memset(d,0x3f,sizeof(d));//初始化
d[s]=0;
q.push(make_pair(0,s));//将起点插入优先队列,d值为0
while(q.size())//循环直到队列为空
{
int x=q.top().second;q.pop();//取出队头的节点编号
if(v[x])
continue;//若已访问过,则继续循环。这实际上是优先队列的懒惰删除法的应用
v[x]=1;//标记
for(int i=head[x];i;i=Next[i])//遍历x的出边
{
int y=ver[i],z=edge[i];
if(d[y]>d[x]+z)
{
d[y]=d[x]+z;
q.push(make_pair(-d[y],y));
}//更新操作
}
}
}
int main()
{
cin>>n>>m>>s;
while(m--)
{
int u,v,w;
cin>>u>>v>>w;
add(u,v,w);
}
dijkstra();
for(int i=1;i<=n;i++)
cout<<d[i]<<" ";
return 0;
}
Bellman-Ford算法
Bellman-Ford算法基于迭代思想实现,时间复杂度为 O(nm)O(nm) ,效率较低,且可以用队列优化该算法,即SPFA算法,所以Bellman-Ford算法在竞赛中几乎根本见不到它。
算法流程:
- 扫描所有边 (x,y,z)(x,y,z) ,若 d[y]>d[x]+zd[y]>d[x]+z ,则更新 d[y]=d[x]+zd[y]=d[x]+z
- 重复第1步,直到没有更新操作发生
由于Bellman-Ford算法效率很低,没有深入学习的必要,因此就简单地讲一下就好,读者也没必要尝试去实现该算法。
SPFA算法
SPFA算法,又名“队列优化的Bellman-Ford算法”,顾名思义,即是用队列去优化Bellman-Ford算法。SPFA算法在稀疏图上的时间复杂度为 O(km)O(km) ,其中 kk 是一个较小的常数;但在稠密图上的时间复杂度仍可能退化为 O(nm)O(nm) ,因此SPFA算法被调侃为只活在普及组的算法,因为在NOIp提高组及以上难度的比赛中SPFA算法基本会被卡到体无完肤。
算法流程:
- 建立一个队列,最开始队列中只有一个元素1
- 取出队头节点x,遍历 xx 的所有出边 (x,y,z)(x,y,z) ,若 d[y]>d[x]+zd[y]>d[x]+z ,则更新 d[y]=d[x]+zd[y]=d[x]+z ;同时,若 yy 不在队列中,则将 yy 插入队列
- 重复第2步,直到队列为空
代码:(采用邻接表存图)
#include<iostream>
#include<queue>
using namespace std;
const int N=2e4,M=1e6;
int n,m,s,tot,ver[M],edge[M],Next[M],head[N],d[N];
bool v[N];
queue<int> q;
void add(int x,int y,int z)
{
ver[++tot]=y,edge[tot]=z,Next[tot]=head[x],head[x]=tot;
}//邻接表插入操作
void spfa()
{
d[s]=0,v[s]=true;
q.push(s);
while(!q.empty())
{
int x=q.front();
q.pop();//取出队头
v[x]=0;//去除标记
for(int i=head[x];i;i=Next[i])
{
int y=ver[i],z=edge[i];
if(d[y]>d[x]+z)
{
d[y]=d[x]+z;
if(!v[y])
q.push(y);//若y不在队列中则将y入队
v[y]=true;//标记
}
}//遍历所有出边并更新
}
}
int main()
{
cin>>n>>m>>s;
for(int i=1;i<=m;i++)
{
int x,y,z;
cin>>x>>y>>z;
add(x,y,z);
}
for(int i=1;i<=n;i++)
d[i]=0x7fffffff;//初始化为最大值
spfa();
for(int i=1;i<=n;i++)
cout<<d[i]<<" ";
return 0;
}
SPFA算法和dijkstra算法相比唯一的优点是,SPFA算法在有负权边的图中仍可以正常运行,不过时间复杂度会进一步增加。有一个名为SLF的基于双端队列思想的优化策略可以略微优化SPFA算法的时间复杂度。当图中没有负权边的时候,可以用优先队列代替队列进行进一步优化,不过读者可以发现,该方法与堆优化的dijkstra算法完全相同,可谓是殊途同归。
多源最短路径问题
多源最短路径问题的模型为:给定一个图,求图上任意两点间的最短路径长度
Floyd算法
Floyd算法基于DP的思想,可以以 O(n^3)O(n3) 的时间复杂度解决多源最短路径问题。
设 d[i,j]d[i,j] 表示 ii 到 jj 的最短路径长度,则求 d[i,j]d[i,j] 可以划分为两个子问题,设 k\in[1,n]k∈[1,n] ,则可以划分为求 ii 到 kk 的最短路径和从 kk 到 jj 的最短路径。注意,在这里k是阶段,因此要放在最外层循环。这样状态转移方程就很好得出来了:
d[i][j]=min(d[i][j],d[i][k]+d[k][j]);
代码:(采用邻接矩阵存图)
#include<iostream>
#include<cstring>
using namespace std;
const int N=1e3;
int n,m,d[N][N];
int main()
{
cin>>n>>m;
memset(d,0x3f,sizeof(d));
for(int i=1;i<=n;i++)
d[i][i]=0;//初始化
for(int i=1;i<=m;i++)
{
int u,v,w;
cin>>u>>v>>w;
d[u][v]=min(d[u][v],w);
}
for(int k=1;k<=n;k++)
for(int i=1;i<=n;i++)
for(int j=1;j<=n;j++)
d[i][j]=min(d[i][j],d[i][k]+d[k][j]);
for(int i=1;i<=n;i++)
{
for(int j=1;j<=n;j++)
cout<<d[i][j]<<" ";
cout<<endl;
}
return 0;
}
声明:本文中部分内容参考了lyd的蓝书
练手题:
2019.5.5 于厦门外国语学校石狮分校