首先,该文章来自于极客时间网站,王争的专栏——《数据结构与算法之美》,我这里只是做简单的解释、记录并添加自己的见解,只是作为个人笔记,若侵权,马上删除。最后建议直接去该网站上购买该课程看原作者的讲解,一来是支持作者,二来是作者写的确实不错。
关于图,之前提过两种针对无权图的搜索算法——深度优先搜索和广度优先搜索算法。那针对有权图,该如何计算两点之间的最短路径(经过的边的权重和最小)呢?经常使用地图软件时,会自动帮我们规划处最优路线。这个过程就涉及到本节要介绍的最短路径算法(Shortest Path Algorithm)。
算法解析
解决实际问题时,最重要的一点就是建模,也就是将复杂的场景抽象成具体的数据结构。对于这个问题,可以把地图抽象成图。把每个岔路口看作一个顶点,岔路口与岔路口之间的路看作一条边,路的长度就是边的权重。如果路是单行道,我们就在两个顶点之间画一条有向边;如果路是双行道,我们就在两个顶点之间画两条方向不同的边。这样,整个地图就被抽象成一个有向有权图。对应的代码如下:
1 | public class Graph { // 有向有权图的邻接表表示 |
这个最短路径算法,可以用经典算法单源最短路径算法(一个顶点到一个顶点)Dijkstra 算法。具体代码如下:
1 | // 因为Java提供的优先级队列,没有暴露更新数据的接口,所以我们需要重新实现一个 |
我们用 vertexes 数组,记录从起始顶点到每个顶点的距离(dist),起始把所有顶点的 dist 都初始化为无穷大(即代码中 Integer.MAX_VALUE)。把起始顶点的 dist 值初始化为 0,然后将其放到优先级队列中。
从优先级队列中取出 dist 最小的顶点 minVertex,然后考察这个顶点可达的所有顶点(代码中的 nextVertex)。如果 minVertex 的 dist 值加上 minVertex 与 nextVertex 之间边的权重 w 小于 nextVertex 当前的 dist 值,也就是说,存在另一条更短的路径,它经过 minVertex 到达 nextVertex。那我们就把 nextVertex 的 dist 更新为 minVertex 的 dist 值加上 w。然后,我们把 nextVertex 加入到优先级队列中。重复这个过程,直到找到终止顶点 t 或者队列为空。
这就是 Dijkstra 算法的核心逻辑。除此之外,代码中还有两个额外的变量,predecessor 数组和 inqueue 数组。
- predecessor 数组的作用是为了还原最短路径,它记录每个顶点的前驱顶点。最后,我们通过递归的方式,将这个路径打印出来。
- inqueue 数组是为了避免将一个顶点多次添加到优先级队列中。我们更新了某个顶点的 dist 值之后,如果这个顶点已经在优先级队列中了,就不要再将它重复添加进去了
整个过程对应的示例过程如下。下图中,(i, j) 中 i 表示从顶点到该点的最短距离,而 j 表示为得到这个最短距离,需要经过的上一个顶点。这里我对这个过程进行简单说明:首先初始顶点为0,将其添加到优先级队列中。初始顶点0出列,优先级队列中包含 {(10, 0), (15, 0)},从可达距离中找到最短路径对应的顶点为 (10, 0) 并出列。此时优先级队列包含 {(12, 1), (15, 0), (25, 1)}。按照优先级队列出列顺序,顶点 (12, 1) 出列,考察其可达距离并更新优先级队列为 {(13,3), (15, 0), (24,3)}。按照优先级队列出队顺序,顶点 (13, 3) 出列,考察其可达距离并更新优先级队列为 {(15, 0), (18,2) }。按照优先级队列出队顺序,顶点 (15, 0) 出列,考察其可达距离并更新优先级队列为 {(18,2) }。则最后 (18, 2) 出列,完成整个算法。
输出最终顺序时,从顶点 (18, 2) 得到上一个顶点编号为2,从 (13, 3) 得到顶点2的上一个顶点编号为3,依次递归。
那么 Dijkstra 算法的时间复杂度是多少?这个代码中最复杂就是 while 循环嵌套 for 循环那部分代码了。 while 循环最多会执行 V 次(V 表示顶点的个数),而内部的 for 循环的执行次数不确定,跟每个顶点的相邻边的个数有关,我们分别记作 E0,E1,E2,……,E(V-1)。如果我们把这 V 个顶点的边都加起来,最大也不会超过图中所有边的个数 E(E 表示边的个数)。
for 循环内部代码涉及从优先级队列取数据、往优先级队列中添加数据、更新优先级队列中的数据这三个主要操作。而优先级队列是用堆实现的,堆中的这几个操作,时间复杂度都是 O(logV)(堆中的元素个数不会超过顶点的个数 V)。
所以,综合这两部分,再利用乘法原则,整个代码的时间复杂度就是 $O(E*logV)$。
从理论上来讲,用 Dijkstra 算法可以计算出两个点之间的最短路径。但是若一张超级大的地图,岔路口、道路非常多,对应到图这种数据结构,有非常多的顶点和边。如果为了计算两点之间的最短路径,在一个超级大图上动用 Dijkstra 算法,遍历所有的顶点和边,显然会非常耗时。
在工程上,经常要根据问题的实际背景,对解决方案权衡取舍。对于出行线路这种问题,不需要非得求出最优解,大部分时为了兼顾执行效率,只需要给出一个可行的次优解就可以了。
虽然地图很大,但是两点之间的最短路径或者较好的出行路径,并不会很“发散”,只会出现在两点之间和两点附近的区块内。所以只需在整个大地图上,划出一个小的区块,这个小区块恰好可以覆盖住两个点,但又不会很大。我们只需要在这个小区块内部运行 Dijkstra 算法,这样就可以避免遍历整个大图,也就大大提高了执行效率。
若两个点距离比较远,比如从北京海淀区某个地点,到上海黄浦区某个地点。这时可以把北京海淀区或者北京看作一个顶点,把上海黄浦区或者上海看作一个顶点,先规划大的出行路线。比如,如何从北京到上海,必须要经过某几个顶点,或者某几条干道,然后再细化每个阶段的小路线。
不过,在规划路线时,除了最短路径问题之外,还有两个关键问题——最少时间和最少红绿灯。
- 对于最少时间问题,只需要将前面边的权重,从路的长度变成经过这段路所需要的时间。这个时间需要根据拥堵情况时刻变化。
- 对于最少红绿灯问题,实际上把前面边的权重都变成1即可。这时可以用 Dijkstra 算法,还可以使用广度优先搜索算法(因为此时退化为无权图了)。
不过,这里给出的所有方案都非常粗糙。实际地图软件经常用之后讲到的 A* 启发式搜索算法。
总结引申
本节解释了Dijkstra 算法的原理和代码实现。其正确性的证明过程会涉及比较复杂的数学推导。这算法实现思路很经典,可以用来指导、解决其他问题。
例如,有一个翻译系统,只能针对某个词翻译。如果要翻译一整个句子,需要将句子拆成一个一个的单词,再丢给翻译系统。针对每个单词,翻译系统会返回一组可选的翻译列表,并且针对每个翻译打一个分,表示这个翻译的可信程度。
从每个单词的可选翻译列表中选择其中一个翻译,组合起来就是整个句子的翻译。整个句子的翻译得分就是每个单词的翻译得分之和。若希望得到得分最高的前 k 个翻译结果,怎么实现呢?
最简单是借助回溯算法,穷举所有的排列组合情况,然后选出得分最高的 k 个翻译结果。但这样时间复杂度很高为 $O(m^n)$,m 表示平均每个单词的可选翻译个数,n 表示一个句子中包含多少个单词。
实际上,这个问题可以借助 Dijkstra 算法的核心思想,非常高效的解决。把每个单词的可选翻译是按照分数从大到小排列,则 a0b0c0 肯定是得分最高组合结果。把 a0b0c0 及得分作为一个对象,放入到优先级队列中。
每次从优先级队列中取出一个得分最高的组合,并基于这个组合进行扩展。扩展的策略是每个单词的翻译分别替换成下一个翻译。比如 a0b0c0 扩展后,会得到三个组合,a1b0c0、a0b1c0、a0b0c1。把扩展后的组合,加入到优先级队列中。重复这个过程,直到获取到 k 个翻译组合或者队列为空。
那这种实现思路的时间复杂度是多少呢?
假设句子包含 n 个单词,每个单词平均有 m 个可选的翻译,要求得分最高的前 k 个组合结果。每次一个组合出队列,就对应着一个组合结果,我们希望得到 k 个,那就对应着 k 次出队操作。每次有一个组合出队列,就有 n 个组合入队列。优先级队列中出队和入队操作的时间复杂度都是 $O(logX)$,X 表示队列中的组合个数。所以,总的时间复杂度就是 $O(knlogX)$。那 X 到底是多少呢?k 次出入队列,队列中的总数据不会超过 $kn$,也就是说,出队、入队操作的时间复杂度是 $O(log(kn))$。所以,总的时间复杂度就是 $O(knlog(k*n))$,比之前的指数级时间复杂度降低了很多。
课后思考
在计算最短时间的出行路线中,如何获得通过某条路的时间呢?
答:通过某条路的时间与①路长度②路况(是否平坦等)③拥堵情况④红绿灯个数等因素相关。获取这些因素后就可以建立一个回归模型(比如线性回归)来估算时间。其中①②④因素比较固定,容易获得。③是动态的,但也可以通过a.与交通部门合作获得路段拥堵情况;b.联合其他导航软件获得在该路段的在线人数;c.通过现在时间段正好在该路段的其他用户的真实情况等方式估算。
今天讲的是出行路线问题假设是开车出行的,那如果是公交出行呢?如果混合地铁、公交、步行,又该如何规划路线呢?
答:混合公交、地铁和步行时:地铁时刻表是固定的,容易估算。公交虽然没那么准时,大致时间是可以估计的,步行时间受路拥堵状况小,基本与道路长度成正比,也容易估算。总之,感觉公交、地铁、步行,时间估算会比开车更容易,也更准确些。