最短路问题——单源最短路
目录
一、前置知识
1. 最短路问题引入
i. 什么是最短路问题
最短路问题(short-path problem)是网络理论解决的典型问题之一,可用来解决管路铺设、线路安装、厂区布局和设备更新等实际问题。基本内容是:若网络中的每条边都有一个数值(长度、成本、时间等),则找出两节点(通常是源节点和阱节点)之间总权和最小的路径就是最短路问题。1
用最简单的话语来说,最短路问题就是解决图中两点之间如何走路程最短的问题。
ii. 什么是单源最短路
最短路问题分为许多类别,而单源最短路是其中较为基础,也较为常见的一类问题。
单源最短路,与多源最短路相对应,是以图中的一个点作为起点,计算该点到其余所有点之间最短距离的方法。
2. 图的定义
图的定义有很多种方式,下面给出一种:
图 (graph) 是一个二元组 G = ( V ( G ) , E ( G ) ) G=(V(G), E(G)) G=(V(G),E(G))。其中 V ( G ) V(G) V(G) 是非空集,称为点集 (vertex set),对于 V V V 中的每个元素,我们称其为顶点 (vertex) 或节点 (node),简称点; E ( G ) E(G) E(G) 为 V ( G ) V(G) V(G) 各结点之间边的集合,称为边集 (edge set)。2
简单来说,图就是由数个点和边构成的一种结构,两个点之间通过边连接。
边可分为有向边和无向边,两者的区别在于有向边限定了两点之间行进的方向,而无向边没有做出限定。
无向边 | 有向边 |
---|---|
![]() | ![]() |
允许的路径: 1 → 2 1\to2 1→2 和 2 → 1 2\to1 2→1 | 允许的路径:仅允许 1 → 2 1\to2 1→2 |
同时,对于某些场景(例如最短路的计算),每一条边都可以被赋予一个值,使得不同的边具有不同的权重,叫做边的边权。
以下就是一个完整的无向带权图(所有边都是无向边并且包含边权):
二、单源最短路算法
下面介绍几种常见的单源最短路计算方法:
1. Bellman-Ford
Bellman-Ford 算法是最基础,也是最经典的算法。其基本过程是:
-
初始化:
创建一个大小为顶点数 ∣ V ∣ |V| ∣V∣ 的数组 d i s dis dis 用于记录 每一个点 到 起始点 的距离,将起点的初始值设为 0 0 0,其余的点初始值设置为 ∞ \infin ∞。
-
松弛:
对于图中的每一条边都进行一次松弛。
所谓松弛,指的是这样一个过程:
对于一条 u → v u\to v u→v 的边 e = ( u , v ) e=(u,v) e=(u,v),如果满足 d i s u + w e i g h t e < d i s v dis_{u}+weight_{e}<dis_{v} disu+weighte<disv ,则进行赋值 d i s v = d i s u + w e i g h t e dis_{v}=dis_{u}+weight_{e} disv=disu+weighte 。
也就是说如果 节点 u u u 到起始点的距离 加上 边 e e e 的边权 仍然比 v v v 之前到起始点的距离 更近,则需要 更新 v v v 到起始点的距离。
-
循环:
重复第 2 2 2 步操作 ∣ V ∣ − 1 |V|-1 ∣V∣−1 次。此时可以尝试再次重复步骤 2 2 2,如果发现还有边能够被松弛,则说明存在负权环。
(负权环:从一个顶点开始沿一条边和点都不重复的路径可以回到该点,且该路径上所有边的边权之和为负。有负权环的图不存在最短路。)
Bellman-Ford 算法的正确性可由数学归纳法推出,具体证明过程在这里省略。
参考核心代码
struct EDGE{
int from,to,weight;
};// 这个结构体用于表示一条边
vector<EDGE> edge;// 这是可变长度数组,用于储存每一条边
int dis[10005];// 步骤 1 提到的 dis 数组
void BellmanFord() {
int n,m,s;// n 代表总节点数,m 代表总边数,s 代表起始节点的编号
fscanf(stdin,"%d %d %d",&n,&m,&s);
for(int i=1;i<=n;i++){
dis[i]=INT_MAX;// 初始化,INT_MAX 可看作正无穷
}
dis[s]=0;
for(int i=0;i<m;i++){
int from,to,weight;
scanf("%d%d%d",&from,&to,&weight);
edge.push_back({from,to,weight});// push_back 表示在数组最后插入数据
}
for(int i=1;i<n;i++){
for(int j=0;j<edge.size();j++){
if(dis[edge[j].from]+edge[j].weight<dis[edge[j].to])
dis[edge[j].to]=dis[edge[j].from]+edge[j].weight; //松弛操作
}
}
// 此时 dis 中的所有值就是每个点对应的最短距离,特别的,如果此时距离仍是无穷大说明该点不可到达
}
优缺点
-
优点:
-
代码简单
-
可以在存在负权边的情况下使用,可以检测负权环
-
-
缺点:
- 时间复杂度较高,为 O ( V E ) O(VE) O(VE)(其中 V V V 是图中总点数, E E E 是总边数)
2. SPFA
SPFA (Shortest Path Faster Algorithm) 算法是 Bellman-Ford 算法的一种优化。能在一定程度(随机数据)上降低时间复杂度,然而在一些特定的图中,其时间复杂度仍与 Bellman-Ford 相同。
SPFA 算法采用 队列 进行优化。
具体过程如下:
-
初始化:
d i s dis dis 数组的初始化同 Bellman-Ford。
SPFA 需要初始化一个队列,并将起始点装入,还需要初始化一个标记数组,用于记录节点是否已经在队列中(此时初始节点的值为 t r u e true true,其余节点为 f a l s e false false)。
-
松弛:
松弛的时候,先从队列头拿出一个节点(并修改该点的标记为 f a l s e false false)。
然后针对与这个点相连的所有边进行松驰。如果一条边满足松弛的条件,在松弛后,还需要检查这条边的终点是否在队列里,如果不在,就将该节点推进队列中(并修改标记)。
-
循环:
循环直到队列为空。
特别的,可以额外使用一个数组记录每一个节点进入队列的次数,如果次数大于等于总节点数 ∣ V ∣ |V| ∣V∣ 则说明存在负环。
参考核心代码
struct EDGE{
int to,weight;
};
vector<EDGE> edge[10005];// 这是另外一种存边方式,edge[i] 代表这个数组里储存了起点为 i 的全部边
queue<int> q;// 队列的 STL 写法
int dis[10005];
bool in[10005];// 标记数组
void SPFA(){
int n,m,s;
fscanf(stdin,"%d%d%d",&n,&m,&s);
for(int i=1;i<=n;i++){
dis[i]=INT_MAX;
}
for(int i=0;i<m;i++){
int from,to,weight;
fscanf(stdin,"%d%d%d",&from,&to,&weight);
edge[from].push_back({to,weight});
}
dis[s]=0;
q.push(s);// 将起始点推入队列
in[s]=1;// 改变标记
while(!q.empty()){
int from=q.front();// 选取当前队列中最前的一个点作为起始点
q.pop();
in[from]=0;// 改变标记状态
for(int j=0;j<edge[from].size();j++){
int to=edge[from][j].to;
int weight=edge[from][j].weight;
// 对于与起始点相连的每一条边遍历
if(dis[from]+weight<dis[to]){
dis[to]=dis[from]+weight;
// 松弛操作
if(!in[to]){
q.push(to);
in[to]=1;
// 如果该点不在队列里就推入队列,并改变标记状态
}
}
}
}
}
优缺点
-
优点:
- 可以在存在负权边的情况下使用,可以检测负权环
- 平均时间复杂度较低,随机数据下表现良好
-
缺点:
- 最大时间复杂度仍然较高,仍为 O ( V E ) O(VE) O(VE),特殊的数据下运行十分缓慢
3. 朴素 Dijkstra
Dijkstra 可以较快的求解无负权图的单源最短路。
Dijkstra 的基本思想是贪心,流程如下:
-
初始化:
初始化 d i s dis dis 数组,步骤与 Bellman-Ford 的初始化相同。还需要构建两个集合 S S S 和 T T T,并将所有点放在集合 T T T 中。
-
在 T T T 集合中取一个到起始点距离最短的点(即 d i s dis dis 最小的点),将该点加入 S S S 集合。
-
对于刚刚取到的点,遍历与它相连的每一条边并进行松弛(即尝试更新每一个相邻点的 d i s dis dis)。
-
不断重复第 2、3 步,直到 T T T 集合为空。
具体的正确性证明在此省略。
参考核心代码
struct EDGE{
int to,weight;
};
vector<EDGE> edge[100005];
int dis[100005];
bool inS[100005]={0};// 这个数组表示点是否在 S 集合中
void Dijkstra() {
int n,m,s;
fscanf(stdin,"%d%d%d",&n,&m,&s);
for(int i=1;i<=n;i++){
dis[i]=INT_MAX;
}
dis[s]=0;// 初始化
for(int i=0;i<m;i++){
int from,to,weight;
fscanf(stdin,"%d %d %d",&from,&to,&weight);
edge[from].push_back({to,weight});
}
bool hasT=1;
while(hasT){
int from=0;
int mindis=INT_MAX;
hasT=0;
for(int i=1;i<=n;i++){
if(!inS[i]){
hasT=1;
if(dis[i]<=mindis){
mindis=dis[i];
from=i;
}
}
}
if(!hasT) break;
// 通过循环找还在 T 集合中的 dis 最小的点
inS[from]=1;
for(int i=0;i<edge[from].size();i++){
int to=edge[from][i].to;
int weight=edge[from][i].weight;
if(dis[from]<dis[to]-weight) // 这里用了减号,主要是防止溢出
dis[to]=dis[from]+weight;
//松弛操作
}
}
}
优缺点
-
优点:
- 可较为快速求出单源最短路,朴素算法时间复杂度 O ( V 2 ) O(V^2) O(V2)
-
缺点:
- 不可以出现负权边,否则会破坏贪心的正确性
4. 优先队列/堆优化 Dijkstra
优先队列 Dijkstra 和 堆优化 Dijkstra 是同一种优化方法。
从上面的朴素 Dijkstra 中可以看出,使用循环求最小值是一个时间复杂度的瓶颈,如果能够较快的求出最小值,将会大大优化时间复杂度。
幸运的是,这样的数据结构是存在的。
优先队列是一种特殊的数据结构,一般使用二叉堆来实现,能够做到插入元素时自动进行排序,且能够快速取出最小/最大值。
优先队列插入/取出元素的时间复杂度均为 O ( log n ) O(\log n) O(logn),非常迅速。
我们可以使用优先队列来替代循环取最小值的步骤,只需稍微修改朴素 Dijkstra 的代码。
参考核心代码
struct EDGE{
int to,weight;
};
vector<EDGE> edge[100005];
priority_queue<pair<int,int>,vector<pair<int,int>>,greater<pair<int,int>>> q;
// 这里使用了 STL 中的 priority_queue,即优先队列来优化。
// 尖括号里的第一项是插入优先队列中的元素类型,这里使用 pair<int,int> 作为元素。
// 使用 pair 可以将两个数值绑定在一起自动排序,类似 struct,但是 struct 需要做修改才能在优先队列里自动排序。
// pair 排序时先以第一项的值排序,第一项的值相同时按第二项排序。
// 在该代码中,pair 的第一项会存储节点的 dis 值(并且以这一项为依据排序),第二项存储这个 dis 值对应的节点编号
// 尖括号里的第二项 vector<pair<int,int>> 是默认值,但不能省略。
// 尖括号里的第三项 greater<pair<int,int>> 可以改变排序方式,将默认的从大到小改为从小到大。
int dis[100005];
bool inS[100005]={0};
void OptimizedDijkstra() {
int n,m,s;
fscanf(stdin,"%d%d%d",&n,&m,&s);
for(int i=1;i<=n;i++){
dis[i]=INT_MAX;
}
for(int i=0;i<m;i++){
int from,to,weight;
fscanf(stdin,"%d %d %d",&from,&to,&weight);
edge[from].push_back({to,weight});
}
dis[s]=0;
// 以上都和朴素 Dijkstra 相同
q.push({0,s});
// 向优先队列插入元素
while(!q.empty()){
int from=q.top().second;
// q.top() 可以获得优先队列最前(即 dis 最小)的一个 pair。
// 这里我们只需要编号(即 pair 的第二项),可以通过 .second 取出 pair 中的第二项。
q.pop();
// q.pop() 可以删除优先队列最前的元素。
if(inS[from]) continue;
// 在这里检查节点是不是已经在集合 S 中,如果是,就不能继续接下来的松弛。
// 这里采用的是“懒更新”的策略,一个节点可能会多次插入队列中,但是我们只在取到它的时候才判断它在不在集合 T 中。
inS[from]=1;
for(int i=0;i<edge[from].size();i++){
int to=edge[from][i].to;
int weight=edge[from][i].weight;
if(dis[from]<dis[to]-weight){
dis[to]=dis[from]+weight;
q.push({dis[to],to});
// 将更新过的节点插入优先队列中
}
}
}
}
优缺点
- 优点:
- 可快速求出单源最短路,时间复杂度非常优秀,为 O ( ( V + E ) log V ) O((V+E) \log V) O((V+E)logV)
- 缺点:
- 不可以出现负权边,与朴素 Dijkstra 相同
三、练习题
- P3371 【模板】单源最短路径(弱化版) - 洛谷
- P4779 【模板】单源最短路径(标准版) - 洛谷
- P3385 【模板】负环 - 洛谷
- P1629 邮递员送信 - 洛谷
- P1576 最小花费 - 洛谷
- P1339 [USACO09OCT] Heat Wave G - 洛谷