【算法】图的最短路径
封面画师:Nengoro(ネんごろぅ) 封面ID:66360374
本文参考视频:小马哥教育(SEEMYGO) 2019年 恋上数据结构与算法(第二季)
部分图片引用链接:Computer Scientists Establish the Best Way to Traverse a Graph | Quanta Magazine
源码仓库:mofan212/data-structure-and-algorithm (github.com)
辅助学习网址:数据结构和算法动态可视化 Data Structure Visualizations
1. 最短路径
1.1 基本含义
最短路径是指两个顶点之间权值之和最小的路径(有向图、无向图均使用,不能有 负权环)。
对于无权图来说也有最短路径的概念,可以认为无权图相当于是全部边权值为 1 的有权图。
如果图中 存在负权边,但 没有负权环 的时候,这个图也有最短路径。
比如对于上图给出的存在负权边的图来说,A 到 E 的最短路径是: A - B - E
但是如果图中 存在负权环 ,那么这个图不存在最短路径。
通过负权环, A 到 E 的路径可以无限短。
1.2 求解算法
最短路径的典型应用之一: 路径规划问题。
求解最短路径的三个经典算法:
- 单源最短路径算法
- Dijkstra (迪杰斯特拉算法)
- Bellman-Ford (贝尔曼-福特算法)
- 多源最短路径算法
- Floyd(弗洛伊德算法)
2. Dijkstra
2.1 基本概念
Dijkstra 属于单源最短路径算法,用于计算一个顶点到其他所有顶点的最短路径。
使用前提: 图中不能有 负权边
时间复杂度: 可优化 至 , 是边数量, 是节点数量。本文实现的 Dijkstra 的时间复杂度并不是,因为没有用到堆。
该算法由荷兰科学家 Edsger Wybe Dijkstra 发明,曾在 1972 年获得图灵奖, “goto有害论” 也是他提出来的。2002 年,与癌症抗争多年后,Dijkstra 去世,这一年的 PODC 奖颁给了他,获奖论文是他 1974 年关于自稳定系统的论文。为了纪念他,PODC 决定从 2003 年把这个奖项改名为 Dijkstra 奖。所以 Dijkstra 是少数获得过以自己的名字命名的奖项的人之一。
2.2 等价思考
Dijkstra 算法的原理其实跟生活中的一些自然现象完全一样。
把图上每一个顶点都想象成一块小石头,每一条边都想象成一条绳子,每一条绳子都连接着两块小石头,边的权值就是绳子的长度。将小石头和绳子平放在桌子上(下图是一张俯视图,图中黄色表示桌子):
想象一下,手拽这小石头 A ,慢慢地向上提起来,远离桌面。
那么, B、D、C、E 会依次离开桌面:
在提起小石头 A 后,桌面上的小石头会依次离开桌面,当最后一块小石头离开桌面后,一些绳子会绷直,而有些绳子会耷拉着,那么这些绷直的绳子就是 A 到其他小石头的最短路径。
依次离开的顺序取决于: 小石头 A 到其他石头的最短路径。
在这一顿操作中,有一个很关键的信息:后离开桌面的小石头,都是被先离开桌面的小石头拉起来的 。
2.3 执行过程
图中黑色顶点表示“源头”,指最先被拽起来的石头。
图中红色顶点表示直接连着被拽起来的石头的顶点,指下一步就可能被拽起来的石头。
而对于右边的表格来说,绿色表示已经“离开桌面”,已经确定了最终的最短路径,红色表示更新了最短路径信息。
步骤一: 以顶点 A 为源点,求出 A 与能够直接到达的顶点之间的距离,最终确定 A 到 B 之间的最短路径
步骤二: 继续求得 A 到 D 之间的最短路径
松弛操作(Relaxation):更新 2 个顶点之间的最短路径。
这里一般是指:更新源点到另一个点的最短路径。
如何理解更新两个顶点之间最短路径的操作叫松弛操作呢? 😕 可以把原来两个顶点之间的最短路径看成一根绳子,在没有更新最短路径前,这根绳子是绷直的,但是等到更新了新的最短路径后,新的路径相比于原来的路径更短,那么 原来的绳子就会变得松弛 ,新的最短路径的 “绳子” 就会被绷直。
松弛操作的意义:尝试 找出更短的路径长度,以便求出最短路径。需要注意的是,并不是每一次松弛操作都是有效的,就是说可能最先的路径就是最短,那么后面进行的松弛操作就是无效的。
步骤三:确定 A 到 D 的最短路径后,对 DC 、 DE 边进行松弛操作,更新了 A 到 C 、 A 到 E 的最短路径,这样就求出了源点 A 到其他顶点之间的最短路径
如果源头是 A ,要求得 A 到其他顶点的最短路径,就要对 A 的 outedges
进行松弛操作,就可以算出 A 到这些顶点可能存在的最短路径。然后从这些路径长度中找出最短的路径,这个最短的路径就是 A 到那个顶点的最短路径,这样就确认了一条最短路径。确认后,对那个顶点的 outedges
进行松弛操作,更新路径长度,又找出其中最小的,确认第二条最短路径,以此类推直到求出 A 到其他顶点的所有最短路径。
2.4 编码实现
在实现 Dijkstra 算法之前,需要先在接口 Graph
中添加方法,假设命名为 shortestPath(V begin)
,表示求得从 begin
出发到其他顶点的最短路径。
那么问题来了,返回值是什么? 😨
对于返回值来说,最短路径长度是必要的(最短的权值之和),那么还有其他需要返回吗? 😰
有的! 如果可以,最好将最短路径连同最短路径长度一起返回出去。
先初步实现 Dijkstra 算法,将返回值设置为 Map
,Map
中的 key
是其他顶点的 value
,Map
中的 value
是源点到其他顶点之间最短路径长度。即:Graph
接口中完整的方法是 Map<V, E> shortestPath(V begin)
。
1 | public abstract Map<V, E> shortestPath(V begin); |
初步实现
还是惯例,先说说思路:
- 获取源点(通过方法传递的参数获取),并判断是否非空
- 初始化两个
HashMap
,一个命名为selectPaths
表示已经确定了的最短路径,一个命名为paths
表示待确定的最短路径 “们” - 从源点出发,初始化
paths
- 重复执行以下操作,直到
paths
为空,paths
为空时,方法返回selectPaths
:- 获取
paths
中最短的一条路径minVertex
- 将
minVertex
加入到selectPaths
中,将minVertex
从paths
中删除 - 然后对
minVertex
的outEdges
进行松弛操作
- 获取
在上述的思路整理中,涉及到两个要点:一个是从 paths
中获取最短的路径,另一个是对 minVertex
的 outEdges
进行松弛操作。
从 paths
中获取最短的路径时,paths
的类型是 HashMap
,可以采用最小堆来实现,但是使用最小堆实现有很多要点和技巧。这里选择最简单的实现方式,采用遍历方式一个个进行比较最后选出路径最小的。要对 HashMap
中的数据进行遍历,可以使用 entrySet()
方法,这个方法可以返回一个包含了原 Map
中数据的 Set
集合。
在获取 paths
中的最短路径时,由于 Set
中数据是无序的,无法直接获得第一个元素。因此可以设置一个临时变量,这个临时变量用来存储最短的路径,等到将所有路径都比较完后,就直接返回这个临时变量即可。
这个临时变量在最开始时会将其设置为 null
,在遍历 Set
时将遍历到的第一个元素赋值给这个临时变量,然后再使用这个临时变量和 Set
中其他元素进行比较,求出 paths
中的最短路径。为了减少临时变量的非空判断次数,可以使用迭代器,让这个临时变量的初始值设置成迭代器中 0
位置的元素。✌️
对 minVertex
的 outEdges
进行松弛操作时,就是将新的可选择的最短路径 newWeight
和以前的最短路径 oldWeight
进行比较,选出其中最短的路径。如果最短的路径是新的可选择的最短路径,就用该路径覆盖以前的最短路径,反之则不用覆盖。
新的可选择的最短路径指的是源点到 minVertex
的 outEdges
的遍历结果 edge.from
的最短路径加上 edge.weight; 以前的最短路径指的是源点到 minVertex
的 outEdges
的遍历结果 edge.to
的最短路径。
进行松弛操作有一些注意要点,如果 minVertex
的 outEdges
的遍历结果 edge.to
已经存入 selectPaths
或者 edge.to
又回到源点,那么就没必要进行松弛操作。同时 oldWeight
为 null
时,可以理解为距离无穷,直接设置将 newWeight
放进 paths
即可。👊
1 |
|
以上就是 Dijkstra 算法初步实现的代码,以经过初步测试,可放心 “食用”!😃
算法完善
在初步实现中给其返回值设置为 Map<V, E>
,其中 V
表示源点到达的顶点, E
表示最短路径的权值之和。
但真正想要的并不是直接返回到达的顶点而是返回路径,当然也要返回最终到达的点和最短路径的权值之和。
因此需要更改接口 Graph
中的 shortestPath()
方法的返回值,使其返回值能够包含最短路径信息,路径信息其实就是边信息,只不过是有顺序的边信息,因此可以使用线性表来存储多个边信息,达到存储路径的效果。
1 | public abstract Map<V, PathInfo<V, E>> shortestPath(V begin); |
接口更改后,前往 ListGraph
类中更改实现的 shortestPath()
方法。
在修改 shortestPath()
时,将与返回值相关的 HashMap
的 value
类型修改为 PathInfo<V, E>
。
同时 getMinPath()
方法的返回值类型和参数类型也要进行修改,方法内的临时变量类型也要进行修改。
为了后续方便实现 Bellman-Ford 算法,在这里将松弛操作的代码抽取出来,单独写成一个方法 relaxForDijkstra()
。抽取代码时,内部部分代码逻辑相比于初步实现的代码逻辑也发生了些许改变,注意理解。💢
PS:透露一下,Bellman-Ford算法中用不到这里抽取的松弛操作代码,因此将其命名为 relaxForDijkstra()
。
1 |
|
以上就是 Dijkstra 算法完善后的代码,以经过初步测试,可放心 “食用”!👏
上述代码其实还可以进行优化修改,getMinPath()
方法是从 paths
中选取一条最短的路径,根据以前学过的数据结构,可以使用最小堆来实现,但这里的最小堆结构会发生变化,进而产生一些问题,有兴趣可以尝试一下。
在 Dijkstra 算法中,要求图中不能够存在 负权边,注意这里是负权边。虽然最开始时说过没有 负权环 的图有最短路径,但是对于 Dijkstra 算法来说,负权边 都不能有,更不要说负权环了。
那么问题来说,有没有一种算法也是单源最短路径算法,而且图中有负权边也能够计算呢?
那当然是有的, Bellman-Ford 就是这样的一种算法!
3. Bellman-Ford
3.1 基本概念
Bellman-Ford 也属于单源最短路径算法,支持负权边,还 能够检测出是否含有负权环 。
算法原理: 对 所有的边 进行 次松弛操作( 是节点数量),得到所有可能的最短路径
时间复杂度: , 是边数量, 是节点数量
下图的最好情况是恰好按 从左到右 的顺序对边进行松弛操作:
对 所有边 仅需进行 次松弛操作就能计算出 A 到达其他所有顶点的最短路径。
最坏情况是恰好每次都是 从右到左 的顺序对边进行松弛操作:
对 所有边 需进行 次松弛操作才能计算出 A 到达其他所有顶点的最短路径。
3.2 实际案例
假设一个图有八条边、六个顶点,然后使用 Bellman-Ford 进行松弛操作,其过程如下:
某一条进行松弛操作时,判断能否松弛成功,就看这条边的起始顶点的最短路径是否已经确认或起始顶点是否是源点,如果已经确认或者是源点,那么就可以松弛成功,反之则不可以松弛成功。
不难分析出,经过 4 次松弛操作之后,已经计算出了 A 到其他所有顶点的最短路径。
概述一下
就是从求最短路径的图中选取所有的边,对这些边进行 次松弛操作( 指节点数量),直到确认最短路径。
当然,也有可能没有进行 次就松弛操作已经求得了最短路径,但最多进行 次松弛操作 一定 可以求得最短路径(除非图中有 负权环)。
3.3 编码实现
设置一个临时变量 count
,这个临时变量的值等于图中的顶点数减一。
设置一个循环,在这个循环内遍历当前图的所有边,对这些边进行松弛操作,循环次数为 count
次。
与 Dijkstra 相同的是,Bellman-Ford 一开始也要获取源点(通过方法传递的参数获取),并判断是否非空。但是也有不相同的地方,比如 Dijkstra 需要初始化两个 HashMap
,一个命名为 selectPaths
表示已经确定了的最短路径,还有一个命名为 paths
表示待确定的最短路径 “们”,但是对于 Bellman-Ford 来说后者并不需要,而且在 Bellman-Ford 中,selectPaths
的类型修改为 Map<V, PathInfo<V, E>>
。
同时在进行松弛操作时,需要获取 selectPaths
中的边信息。在一开始时,selectPaths
中没有任何边信息,获取到的边信息是空,在这里需要进行判断。如果获取到的边信息是空,直接 continue
即可。
但是这样又会出现一个问题,一开始时 selectPaths
是空的,无法获取到边信息,这个时候又直接 continue
了,那么就会一直无法进行松弛操作,因此初始化 selectPaths
时,需要在这个 HashMap
中添加源点自身到自身的路径信息,最后在方法返回前删除添加的源点路径信息。添加的路径信息中不能设置边信息,因为图中自身到自身是没有边的,这只是一种假想,但是需要添加路径权值。路径权值应该是零,这个零对于不同的类型可能不一样,比如对于整型来说,零就是 0
;对于双浮点数来说,零就是 0.0
;对于自定义类型来说,零就有可能是该类型中某个属性值为零。
这个零值应该是由用户决定的,用户使用了什么类型,这个零值就应该跟着变化,那么可以在 Graph
接口中的 WeightManager
接口添加一个方法 zero()
,这个方法让用户可以自行编写对象的零值,将零值的变化交给用户控制。
1 | public interface WeightManager<E> { |
有了这样的方法,还可以对 Dijkstra 进行修改,修改源点的初始化。在修改之前给 Graph
接口中的 PathInfo
内部类添加两个构造方法:
1 | public static class PathInfo<V, E>{ |
修改 Dijkstra 的源点初始化:
1 | private Map<V, PathInfo<V, E>> dijkstra(V begin) { |
在最开始介绍 Bellman-Ford 时,这个算法 可以检测出是否含有负权环 ,那么应该怎么检测呢?
Bellman-Ford 最多对每条边进行 次松弛操作,如果图中含有负权环,就可以进行超过最多次的松弛操作。那么可以在方法最后再进行一次松弛操作,看看这一次松弛操作能否成功进行。
那么问题又来了,怎么判断松弛操作是否成功进行呢?
可以将松弛操作的返回值设置成布尔类型的就可以了,成功进行了松弛操作就返回 true
,反之就返回 false
。
那么最终 Bellman-Ford 的代码如下:
1 |
|
以上就是 Bellman-Ford 的代码,以经过初步测试,可放心 “食用”!💪
4. Floyd
4.1 概念与原理
Floyd 属于多源最短路径算法 ,能够求出任意 2 个顶点之间的最短路径,支持负权边。
要实现求出任意 2 个顶点之间的最短路径,也可以多次使用 Dijkstra 来求得最短路径。
时间复杂度: ,效率比执行 次 Dijkstra 算法要好(V 是顶点数量)
算法原理
从任意顶点 到任意顶点 的最短路径不外乎有两种可能:
- 直接从 到
- 从 经过若干个顶点到
假设 为顶点 到顶点 的最短路径的距离
对于每一个顶点 ,检查 是否成立
- 如果成立,证明从 到 再到 的路径比 直接到 的路径短,设置
- 当遍历完所有节点 , 中记录的便是 到 的最短路径的距离
伪代码如下:
1 | for (int k = 0; k < V; k++) { |
4.2 编码实现
在编码实现之前,首先得明白什么叫做 “多源最短路径算法” ,理解了这个概念后,才可以编写正确的接口。
所谓 “多源最短路径算法” 指的就说从图中的每一个顶点出发,到其他顶点的最短路径的算法。
在 “单源最短路径算法” 中,接口方法是 Map<V, PathInfo<V, E>> shortestPath(V begin)
,表示源点 begin 到 value 为 V 的顶点的最短路径信息是 PathInfo<V, E>
类型的。
但是对于 “多源最短路径算法” 来说,源点是有多个的,并不是只有一个,因此接口方法就可以不用传递参数。既然源点不止一个,那么如何返回不同源点的最短路径信息呢?
初看似乎很难,实则很简单。不同的源点到达不同的顶点对应了不同的最短路径信息,因此可以在单源最短路径算法接口返回值类型的基础上套一层 Map
,最终得到在 Graph
接口下的 “多源最短路径算法” 接口方法如下:
1 | // 多源最短路径 |
接口方法编写完成后,前往 ListGraph
类实现刚编写的接口方法。
在实现接口时需要先实例一个返回值类型的 HashMap
,最后的返回数据就是实例化的这个 HashMap
。
初始化后,这个 Map
内是没有任何数据的,需要从这个 Map
中提取数据,因此还需要对这个 Map
进行初始化,初始化 Map
就是将图中每一条边都放到这个 Map
中。
初始化完成后,使用伪代码一样的逻辑将真正的代码编写出来,注意空值判断 。
伪代码逻辑遍历图中的顶点,需要进行三层遍历,每次遍历可以拿到一个顶点,总共三个顶点。这三个顶点可以确定三条边,需要注意的是这三个顶点任意两个不能相同,如果存在相同,确定的边就只有一条,为了避免这种情况,需要在 遍历内部加上顶点相同判断 。
这三个顶点的任意两个顶点之间可能不存在直接相连的边,因此还 需要注意空边的情况 。
1 |
|
以上就是 Floyd 的代码,以经过初步测试,可放心 “食用”!😊