今天是算法数据结构专题的第33篇文章,我们一起来聊聊最短路问题。
最短路问题也属于图论算法之一,解决的是在一张有向图当中点与点之间的最短距离问题。最短路算法有很多,比较常用的有bellman-ford、dijkstra、floyd、spfa等等。这些算法当中主要可以分成两个分支,其中一个是bellman-ford及其衍生出来的spfa,另外一个分支是dijkstra以及其优化版本。floyd复杂度比较高,一般不太常用。
我们今天的文章介绍的是bellman-ford以及它的衍生版本spfa算法。
图的存储
我们要对一张有向图计算最短路,那么我们首先要做的就是将一张图存储下来。关于图的存储的数据结构,常用的方法有很多种。最简单的是邻接矩阵,所谓的邻接矩阵就是用一个二维矩阵存储每两个点之间的距离。如果两个点之间没有边相连,那么设为无穷大。
可以参考一下下图。
这种方法的好处是非常直观,实现也很简单,但是缺点也很明显。这个二维矩阵所消耗的空间复杂度是 V 2 V^2 V2,这里的V指的是顶点的数量。当顶点的数量稍稍大一些之后,带来的开销是非常庞大的。一般情况下我们的图的边的密集程度是不高的,也就是说大量点和点之间没有边相连,我们浪费了很多空间。
针对邻接矩阵浪费空间的问题,后来又提出了一种新的数据结构就是邻接表。
所谓的邻接表也就是说我们把顶点一字排开存入数组当中,每个顶点对应一条链表。这条链表当中存储了这个点可以到达的其他点的信息。比如下图当中V1点可以连通V2和V3,V1在数组当中的下标为0,所以下标为0的元素是一个存储了V2和V3下标的链表,也就是图中的1和2。
邻接表的好处是可以最大化利用空间,有多少条边存储多少信息。但是也有缺点,除了实现稍稍复杂一点之外,另外一个明显的缺点就是我们没办法直接判断两点之间是否有边存在,必须要遍历链表才可以。
除了邻接矩阵和邻接表之外,还有一些其他的数据结构可以完成图的存储。比如前向星、边集数组、链式前向星等等。这些数据结构并没有比邻接表有质的突破,对于非算法竞赛同学来说,能够熟练用好邻接表也就足够了。
Bellman-Ford算法
刚才上面描述当中提到的算法除了floyd算法是计算的所有点对之间的最短距离之外,其他算法解决的都是单源点最短路的问题。所谓的单源点可以简单理解成单个的出发点,也就是说我们求的是从图上的一个点出发去往其他每个点的最短距离。既然如此,我们的出发点以及到达点都是确定的,不确定的只是它们之间的距离而已。
为什么我们会将bellman-ford算法和dijkstra算法区分开呢?因为两者的底层逻辑是不同的,bellman-ford算法的底层逻辑是动态规划, 而dijkstra算法的底层逻辑是贪心。
bellman-ford算法的得名也和人有关,我们之前在介绍KMP算法的时候曾经说过。由于英文表意能力不强,所以很多算法和公式都是以人名来取名。bellman-ford是Richard Bellman 和 Lester Ford 分别发表的,实际上还有一个叫Edward F. Moore也发表了这个算法,所以有的地方会称为是Bellman-Ford-Moore 算法。
算法的原理非常简单,利用了动态规划的思想来维护源点出发到各个点的最短距离。
它的核心思维是松弛,所谓的松弛可以理解成找到了更短的路径对原路径进行更新。对于一个有V个节点的有向图进行V-1轮松弛,从而找到源点到所有点的最短距离。
初始的时候我们会用一个数组记录源点到其他所有点的距离,对于与源点直接相连的点来说,这个距离就是它和源点的距离否则则是无穷大。对于第一轮松弛来说,我们寻找的是源点出发经过一个点到达其他点的最短距离。我们用s代表源点,我们寻找的就是s经过点a到达点b,使得距离小于s直接到b的距离。
第二轮松弛就是寻找的s经过两个点到达第三个点的最短距离,同理,对于一个有V个点的图来说,两个点之间最多经过V-1个点,所以我们需要V-1轮松弛操作。
它的伪代码非常简单,我们直接来看:
for (var i = 0; i < n - 1; i++) {
for (var j = 0; j < m; j++) {//对m条边进行循环
var edge = edges[j];
// 松弛操作
if (distance[edge.to] > distance[edge.from] + edge.weight ){
distance[edge.to] = distance[edge.from] + edge.weight;
}
}
}
Bellman-ford的算法很好理解,实现也不难,但是它有一个缺点就是复杂度很高。我们前面说了一共需要V-1轮松弛,每一轮松弛我们都需要遍历E条边,所以整体的复杂度是 O ( V E ) O(VE) O(VE),E指的是边的数量。想想看,假设对于一个有1w个顶点,10w条边的图来说,这个算法是显然无法得出结果的。
所以为了提高算法的可用性,我们必须对这个算法进行优化。我们来分析一下复杂度巨大的原因,主要在两个地方,一个地方是我们松弛了V-1次,另外一个地方是我们枚举了所有的边。松弛V-1次是不可避免的,因为可能存在极端的情况需要V-1次松弛才可以达成。但我们每次都枚举了所有的边感觉有点浪费,因为其中大部分的边是不可能达成新的松弛的。那有没有办法我们筛选出来可能构成新的松弛的边呢?
针对这个问题的思考和优化引出了新的算法——spfa。
SPFA算法
SPFA算法的英文全称是Shortest Path Faster Algorithm,从名字上我们就看得出来这个算法的最大特点就是快。它比Bellman-ford要快上许多倍,它的复杂度是 O ( k E ) O(kE) O(kE),这里的k是一个小于等于2的常数。
SPFA的核心原理和Bellman-ford算法是一样的,也是对点的松弛。只不过它优化了复杂度,优化的方法也很简单,用一个队列维护了可能存在新的松弛的点。这样我们每次从这些点出发去寻找其他可以松弛的点加入队列,这里面的原理很简单,只有被松弛过的点才有可能去松弛其他的点。
SPFA的代码也很短,实现起来难度很低,单单从代码上来看和普通的宽搜区别并不大。
import sys
from queue import Queue
que = Queue()
# 邻接表存储边
edges = [[]]
# 维护是否在队列当中
visited = [False for _ in range(V)]
dis = [sys.maxsize for _ in range(V)]
dis[s] = 0
que.put(s)
while not que.emtpy():
u = que.get()
for v, l in edges[u]:
if dis[u] + l < dis[v]:
dis[v] = dis[u] + l
if not visited[v]:
que.add(v)
dis[u] = False
到这里,关于Bellman-ford和SPFA算法的介绍就结束了,需要提醒一点,这两个算法都不能处理负环的情况。也就是权重之和是负数的环,这样会无限松弛陷入死循环当中,可以在求最短路之前通过拓扑排序排查,也可以记录每个点进入队列的次数,通过设置阈值的方式进行排除。
今天的文章到这里就结束了,如果喜欢本文的话,请来一波素质三连,给我一点支持吧(关注、转发、点赞)。