目录
最短路问题简介
定义
最短路问题是图论中的一个基本问题。给定一个带权图(有向图或者无向图),图中的每条边都有一个权值(可以表示距离、时间、费用等),最短路问题就是要在这个图中找到从一个指定的起点到一个指定的终点之间的一条路径,使得这条路径上所有边的权值之和最小。例如,在一个交通网络中,权值可以表示道路的长度,我们想找到从一个城市到另一个城市的最短行驶距离的路线,这就是最短路问题的一个实际应用场景。
总而言之,最短路问题描述的就是从图中的顶点沿着边走,走到另一个顶点的权值总和最小值(每条边都有权值)
如下无向图:
最短路问题的问法比如说:从顶点1到顶点5的最短路是多少,肉眼观察很容易看出1->2->5是最短路,权值=5+3 = 8
分类
按照源点可以把最短路问题进行第一次划分:
- 单源最短路问题
- 多源汇最短路问题
注意:在图论中,多源等价于多个起点,单源等价于只有一个起点。汇点等价于多个终点
其中,单源最短路问题讨论的是从图中的一个点出发到另一个点的最短路
多源汇最短路指的是从图中的多个起点出发,到达多个终点的最短路径组合,由于起点、终点有多个,也就意味着最终不再是简单的一个数,而是一个集合,表示每一个终点到达每一个终点的最短路值
举例
单源最短路:
在一个互联网的网络拓扑图中,以某个核心服务器(作为源点)为起点,计算到其他所有服务器或者终端设备(作为终点)的最短通信路径。这个问题关注的是一个固定起点到其他各个节点的最短距离关系
多源汇最短路:
以物流配送为例,假设有多个仓库(源点)分布在不同的地理位置,并且有多个零售商店(汇点)。需要考虑从每个仓库到每个商店的最短配送路线,这就涉及到多种起点 - 终点的组合情况。
单源最短路问题
单源最短路分类及其解决算法
单源最短路问题我们可以进一步划分,划分如下:
- 所有边权都是正数
- 存在负权边
对于所有边权都是正数的情况,我们有两种解决算法:朴素Dijkstra算法、堆优化版的Dijkstra算法
对于存在负权边的情况,我们也有两种解决算法:Bellman-Ford、SPFA
朴素Dijkstra算法 vs 堆优化版的Dijkstra算法
先讨论两者的时间复杂度,设图中顶点的数量为n,边的数量为m
- 朴素Dijkstra算法的时间复杂度是O(n^2)
- 堆优化版Dijkstra算法的时间复杂度是O(mlogn)
从表面上看,朴素Dijkstra算法好像没堆优化版优秀,但其实不然,两个算法都有它们所适用的场景
- 朴素Dijkstra算法适用于稠密图,堆优化版Dijkstra算法适用于稀疏图。
- 假设有一个完全图有n个顶点,则边的数量是n(n-1),我们近似成n^2。在这种情况下朴素Dijkstra算法的时间复杂度依旧是O(n^2),而堆优化版的Dijkstra算法的时间复杂度是O(n^2logn)
Bellman-Ford vs SPFA
先讨论两者的时间复杂度,设图中顶点的数量为n,边的数量为m
- Bellman-Ford算法的时间复杂度是O(nm)
- SPFA算法的时间复杂度一般情况下是O(m),最坏情况是O(nm)
时间复杂度上看,SPFA显然是更优秀一些的。但这并不意味着Bellman-Ford算法一无是处
适用场景:
- 若题目中对形成最短路的边数有着特殊的限制,例如边数必须小于k。那么此时只能采用Bellman-Ford算法
- 相反,若题目中没对最短路的边数有着限制,那么SPFA算法是更好的!
多源汇最短路问题
多源汇最短路问题存在着一种解法:Floyd算法
该算法时间复杂度是O(n^3)
单源最短路算法
朴素dijkstra算法
算法思路
朴素dijkstra求得每条最短路的算法思路是:
- 初始化起点到起点的距离是0,并设置起点的最短路径为0
- 更新起点相连的点的距离,并确定一个距离最小的边。这条边相连的点一定是从起点到该点的最短路,设置该点的最短路径值
- 更新第二步确定最短路的点相连的点的距离,并确定一个距离最小的边。这条边相连的点一定是从起点到该点的最短路,设置该点的最短路径值。依次类推,最终得出所有点的最短路
图示举例
设下图中,编号为0的点为起点,求得起点到其他点的最短路
- 1的最短路就是0,把1->3和1->2的距离更新出来
- 1->3是已更新并未访问的的权值最小的边,更新3的最短路就是3,并把1->5和1->6的距离更新出来。1->5的距离是3+8 = 11 。1->6的距离是3+7 = 10
- 1->2是已更新并未访问的权值最小的边,更新2的最短路就是5,并把1->4、1->5、1->6的距离更新出来。1->4的距离是5+1=6,1->5的距离是5+3=8,1->6的距离是5+6=11,之前已经求出了一条1->6的路径,取两者中的最小值min(10,11)=10。
- ......
图的存储
在我们正式实现朴素dijkstra算法之间,我们得先能把输入进来的图存储起来。
根据朴素dijkstra的使用场景:稠密图。所以我们最佳的存储方案应该是邻接矩阵!
当使用邻接矩阵存储图的时候,若我们的图是有向图a->b,那么只需要g[a][b]来表示一条边
若我们的图是无向图,那么a->b除了要存储g[a][b]的权值以外,g[b][a]也要设置。
邻接矩阵的实现较为简单,等到算法实现看代码就行!
变量的定义
朴素dijkstra我们一般需要定义如下变量:
- 邻接矩阵存储图
- 判断一个顶点是否已经确定最短路的数组
- 从起点到第i个点的距离数组。距离数组中可能暂时存储的不是最短路,具体看上面的判断数组
算法实现c++
实现步骤:
- 初始化距离数组:对于起点初始化为0,对于其他点初始化为正无穷
- 确定n个点的最短路,循环n次
- 对于每次循环,找到一条未确定的距离数组中最小的点,更新该点的最短路值为已确定状态
- 对于每次循环,找到上一步所确定点的相连点,并更新它们距离数组的值,与它们未更新时距离数组的值取一个最小值
cpp
#include <iostream>
#include <cstring>
using namespace std;
const int N = 110;
int g[N][N];
bool st[N];
int dis[N];
int n , m;
int dijkstra()
{
//初始化距离数组
memset(dis,0x3f,sizeof dis);
dis[1] = 0; //起点固定是1
for(int i = 0 ; i < n ; ++i)
{
int t = -1;
//找到一条未确定的距离数组中最小的点,更新该点的最短路值为已确定状态
for(int j = 1 ; j <= n ; ++j)
if(!st[j] && (t == -1 || dis[t] > dis[j]))
t = j;
st[t] = true;
//找到上一步所确定点的相连点,并更新它们距离数组的值
for(int j = 1 ; j <= n ; ++j)
dis[j] = min(dis[j],dis[t] + g[t][j]);
//与它们未更新时距离数组的值取一个最小值
}
return dis[n];
}
int main()
{
do{
cin >> n >> m;
if(!n && !m) break;
memset(st , 0 , sizeof st);//初始化st数组
memset(g,0x3f,sizeof g); //初始化图
for(int i = 0 ; i < m ; ++i)
{
//插入m条边
int a , b , val;
cin >> a >> b >> val;
g[a][b] = g[b][a] = val; //根据题目要求是一个无向图
}
int t = dijkstra();
printf("%d\n",t);
}while(true);
return 0;
}
堆优化版dijkstra算法
算法思路
堆优化版的优化,指的是对朴素dijkstra算法的优化,我们首先来详细分析下朴素dijkstra算法的时间复杂度(设图中顶点个数是n,顶点的出度为m)
- 找到当前的一条最短路,一共循环n次
- 对于每次循环,找到一条未确定的距离数组中最小的点,更新该点的最短路值为已确定状态。每次循环确定距离数组中最小的点要遍历n次
- 更新第二步确定最短路的点相连的点的距离,并确定一个距离最小的边。这条边相连的点一定是从起点到该点的最短路,设置该点的最短路径值。依次类推,最终得出所有点的最短路,找的次数为m
对于第二步,找到一条未确定的距离数组中最小的点,我们可以使用堆来进行优化。
使用堆优化后,该算法的时间复杂度,稠密图中时间复杂度近似是O(n^2logn),稀疏图中时间复杂度近似O(mlogn)
图的存储
使用堆优化版dijkstra算法一般都是用于稀疏图,稀疏图的存储可以使用邻接表
邻接表本质上就是由多个单链表构成的,我们采用数组的方式来模拟单链表
数组模拟单链表的实现
cpp
#include <iostream>
using namespace std;
const int N = 1e6 + 10;
int e[N] , ne[N] , idx = 1; //头节点的编号是0,所以编号从1开始
void add(int x)
{
//头插x
e[idx] = x , ne[idx] = ne[0] , ne[0] = idx++;
}
int main()
{
ne[0] = -1;
for(int i = 0 ; i < 10 ; ++i)
{
add(i);
}
//遍历单链表
for(int i = ne[0] ; i != -1 ; i = ne[i]) printf("%d ",e[i]);
printf("\n");
return 0;
}
邻接表存储图
邻接表本质就是每个顶点维护一个出度表,也就是说每个顶点分配一个单链表
cpp
#include <iostream>
#include <cstring>
using namespace std;
const int N = 1e6 + 10 , M = 5 * N;
int h[N] , e[M] , ne[N] , idx;
//h中维护的是单链表的表头ne指针
void add(int a , int b)
{
e[idx] = b , ne[idx] = h[a] , h[a] = idx++;
}
int main()
{
memset(h,-1,sizeof h);
//添加边add(a,b) <==> a->b ....
return 0;
}
变量的定义
堆优化版dijkstra我们一般需要定义如下变量:
- 邻接表
- 边的权值表
- 距离表
- 判断表,用于判断该顶点是否遍历过
- 堆,使用STL中的priority_queue
算法实现c++
总体思路与朴素dijkstra算法大致相同,总的来说就是找距离时使用堆来找,不懂可以往前翻翻,不再过多赘述
cpp
#include <iostream>
#include <cstring>
#include <queue>
using namespace std;
using PII = pair<int,int>;
const int N = 1e6 + 10 , M = 2*N;
int h[N] , e[M] , ne[N] , idx;
int w[M] , dis[N];
bool st[N];
int n , m , s;
priority_queue<PII,vector<PII>,greater<PII>> pq;
void add(int a , int b , int c)
{
e[idx] = b , w[idx] = c , ne[idx] = h[a] , h[a] = idx++;
}
void dijkstra(int s)
{
//初始化距离数组
memset(dis,0x3f,sizeof dis);
dis[s] = 0;
//起点入堆
pq.push({0,s});
while(!pq.empty())
{
auto t = pq.top();
pq.pop();
auto ver = t.second , distance = t.first;
if(st[ver]) continue;
st[ver] = true;
//堆中可能出现重边,比如先插入1->2 = 2 又插入1->2 = 3
//需要对重边进行处理,堆中取出的第一次一定是该边的最小边
for(int i = h[ver] ; i != -1 ; i = ne[i])
{
//所有未遍历的相连的边入队
int j = e[i];
if(!st[j])
{
dis[j] = min(dis[j] , distance + w[i]);
pq.push({dis[j] , j});
}
}
}
}
int main()
{
memset(h,-1,sizeof h);
cin >> n >> m >> s;
for(int i = 0 ; i < m ; ++i)
{
int a , b , c;
cin >> a >> b >> c;
add(a,b,c);
}
dijkstra(s);
for(int i = 1 ; i <= n ; ++i) printf("%d ",dis[i]);
return 0;
}
bellman-ford算法
上面两种dijkstra算法解决的是只含有正权边的单源最短路问题,而接下来的bellman-ford算法和spfa算法是用于无所谓边是正权还是负权的单源最短路问题
算法思路
bellman-ford算法一共经历如下步骤:
- 以下步骤循环k次
- 每一次都更新所有顶点的最短距离,即从a->b和已更新的到b的距离取最小值,这一步我们称之为松弛操作
经历k次松弛操作后,bellman-ford算法可以得出,当前已更新的顶点最短路一定是边数<k次的最短路
如下图,假设k = 1
尽管我们能看出,上图到3的最短路一定是1->2->3 = 2,但k=1,也就是只会进行一次松弛操作,那么经历一次松弛操作后,取得的是边数<1的边,到3的距离应该是3
三角不等式:bellman-ford算法可以证明,当经历了n-1次松弛操作(n为顶点数量)后,对于图中任意的顶点都满足 dis[b] <= dis[a] + b,也就是到b的距离一定已经被更新为了最短路
负权回路问题
若从a点到b点之间存在负权回路,那么a到b点的最短距离是负无穷。
如下图:
所谓的负权回路指的是,图中若干个顶点边所形成的环路,并且这个环的权值总和是负数
假设要求的是从1到4的最短路,我们来走一遍
- 选择从1->2的边,距离为1
- 选择从2->3的边,距离为1+2=3
- 到达3以后,有着两种选择,从1->4和从3->1。根据算法逻辑,会选择一条3->1的这条边,并更新1的距离为-4。更新2的距离是-3,更新3的距离是-1
- 此时又走到了3,根据算法逻辑继续选择3->1这条边,更新1的距离是-8,更新2的距离是-7,更新3的距离是-5
- ....
bellman-ford算法判断负权回路
在包含n个顶点的图中,正常情况下(不存在负权回路时),经过n-1次对所有边的松弛操作,就能确定从源点到其他所有顶点的最短路径距离。
在完成了n-1次的松弛操作之后,再额外进行一次对所有边的松弛操作。如果在这一次的松弛操作过程中,仍然存在某些边能够继续更新对应的顶点距离值,那就说明图中存在负权回路。
图的存储
bellman-ford算法只要求能遍历到所有边即可,我们可以使用结构体类型,结构体中定义着边相连的两个顶点与边的权值,遍历结构体即可!
变量的定义
bellman-ford算法要求定义如下变量:
- edges[M]:用于存储所有边,M为边的数量
- dis[N]:用于存储经过一次松弛操作后的顶点距离
- backup[N]:dis的拷贝
为什么要有backup?
我们假设不存在backup,如下图:
第一次松弛操作之前的dis数组的值:0,+∞,+∞
- 进行第一次松弛操作,更新2的距离为1,dis数组为:0,1,+∞
- 当更新3的距离时,若直接拿dis数组更新,那么会dis数组是:0,1,2。这与我们的实际不符,经历一次松弛操作到3的距离应该有仅一条边的限制,即3
- 原因是因为,当我们更新2时,上次松弛操作的值已经被本次的覆盖了,我们更新每个点拿的都应该是上次松弛的结果来更新
算法实现c++
cpp
#include <iostream>
#include <algorithm>
#include <cstring>
using namespace std;
const int N = 510 , K = N , M = 10010;
int dis[N] , backup[N];
struct{
int a,b,w;
}edges[M];
int n , m , k;
int bellman_ford()
{
memset(dis,0x3f,sizeof dis);
dis[1] = 0;
//边的限制是k条,所以松弛k次即可
for(int i = 0 ; i < k ; ++i)
{
memcpy(backup,dis,sizeof dis);
for(int j = 0 ; j < m ; ++j)
{
int a = edges[j].a , b = edges[j].b , w = edges[j].w;
dis[b] = min(dis[b],backup[a] + w);
}
}
return dis[n];
}
int main()
{
scanf("%d%d%d",&n,&m,&k);
for(int i = 0 ; i < m ; ++i)
{
//读入所有边
int a , b , w;
scanf("%d%d%d",&a,&b,&w);
edges[i] = {a,b,w};
}
int t = bellman_ford();
if(t > 0x3f3f3f3f / 2) printf("impossible\n");
else printf("%d\n",t);
return 0;
}
注意:图中可能存在+∞更新了+∞的情况,所以最后t的判断条件是t > 0x3f3f3f3f / 2
假设从起点无法到达编号为5的点,5和6之间有一条边,权值是-2。会出现如下图这种情况:
此时6号点的最短路会变成+∞-2,代码中就是0x3f3f3f3f-2。但实际上这种情况我们也视为6号点不可到达的
spfa算法
算法思路
spfa算法是对bellman-ford算法的优化。
我们根据bellman-ford的更新策略:从起点到b的距离与从起点到a的距离+从a到b的距离。可以发现实际上若从起点到a的距离不变小,那么从起点到b的距离是不会发生更新的!
基于上述特性,我们可以使用一个队列来进行优化,每次松弛时把所有更新了的点插入到队列中,每次松弛时只需取出队列中的元素,更新它的所有出度即可!
队列中存储的元素就是上次松弛时所有更新为dis[a] + w的点
SPFA算法的时间复杂度一般是O(m),有些题目测试用例可能会卡SPFA算法时间复杂度,最终时间复杂度可能卡成最坏情况:O(nm)
在实际运用当中(不考虑卡时间复杂度的情况),SPFA 算法可能是单源最短路算法中效率较高的。
图的存储
SPFA算法图的存储与堆优化dijkstra算法的存储一样,采用邻接表的方式来存储。不再过多赘述
变量的定义
SPFA算法定义如下变量:
- 队列
- 判断数组:主要判断一个点是否已经被添加进了队列中,若已经被添加那么无需重复添加
- 距离数组
算法实现c++
cpp
#include <iostream>
#include <cstring>
#include <queue>
using namespace std;
const int N = 1e5 + 10 , M = 5e6 + 10;
int h[N] , e[M] , ne[M] , w[M] , idx;
int dis[N];
bool st[N];
int n , m , s;
void add(int a , int b , int c)
{
e[idx] = b , w[idx] = c , ne[idx] = h[a] , h[a] = idx++;
}
void spfa(int s)
{
memset(dis,0x3f,sizeof dis);
dis[s] = 0;
queue<int> q;
q.push(s);
st[s] = true;
while(!q.empty())
{
auto t = q.front();
q.pop();
st[t] = false;
for(int i = h[t] ; i != -1 ; i = ne[i])
{
int j = e[i];
if(dis[j] > dis[t] + w[i])
{
dis[j] = dis[t] + w[i];
if(!st[j])
{
q.push(j);
st[j] = true;
}
}
}
}
}
int main()
{
scanf("%d%d%d",&n,&m,&s);
memset(h,-1,sizeof h);
for(int i = 0 ; i < m ; ++i)
{
int a , b , c;
scanf("%d%d%d",&a,&b,&c);
add(a,b,c);
}
spfa(s);
for(int i = 1 ; i <= n ; ++i)
{
if(dis[i] == 0x3f3f3f3f) printf("%d ",0x7fffffff);
else printf("%d ",dis[i]);
}
return 0;
}