CategoryResourceRepost/极客时间专栏/数据结构与算法之美/高级篇/44 | 最短路径:地图软件是如何计算出最优出行路径的?.md
louzefeng d3828a7aee mod
2024-07-11 05:50:32 +00:00

205 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<audio id="audio" title="44 | 最短路径:地图软件是如何计算出最优出行路径的?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/f3/45/f321bf8f9180b31bb0e7ec97cd9a9445.mp3"></audio>
基础篇的时候,我们学习了图的两种搜索算法,深度优先搜索和广度优先搜索。这两种算法主要是针对无权图的搜索算法。针对有权图,也就是图中的每条边都有一个权重,我们该如何计算两点之间的最短路径(经过的边的权重和最小)呢?今天,我就从地图软件的路线规划问题讲起,带你看看常用的**最短路径算法**Shortest Path Algorithm
像Google地图、百度地图、高德地图这样的地图软件我想你应该经常使用吧如果想从家开车到公司你只需要输入起始、结束地址地图就会给你规划一条最优出行路线。这里的最优有很多种定义比如最短路线、最少用时路线、最少红绿灯路线等等。**作为一名软件开发工程师,你是否思考过,地图软件的最优路线是如何计算出来的吗?底层依赖了什么算法呢?**
## 算法解析
我们刚提到的最优问题包含三个:最短路线、最少用时和最少红绿灯。我们先解决最简单的,最短路线。
解决软件开发中的实际问题,最重要的一点就是**建模**,也就是将复杂的场景抽象成具体的数据结构。针对这个问题,我们该如何抽象成数据结构呢?
我们之前也提到过,图这种数据结构的表达能力很强,显然,把地图抽象成图最合适不过了。我们把每个岔路口看作一个顶点,岔路口与岔路口之间的路看作一条边,路的长度就是边的权重。如果路是单行道,我们就在两个顶点之间画一条有向边;如果路是双行道,我们就在两个顶点之间画两条方向不同的边。这样,整个地图就被抽象成一个有向有权图。
具体的代码实现,我放在下面了。于是,我们要求解的问题就转化为,在一个有向有权图中,求两个顶点间的最短路径。
```
public class Graph { // 有向有权图的邻接表表示
private LinkedList&lt;Edge&gt; adj[]; // 邻接表
private int v; // 顶点个数
public Graph(int v) {
this.v = v;
this.adj = new LinkedList[v];
for (int i = 0; i &lt; v; ++i) {
this.adj[i] = new LinkedList&lt;&gt;();
}
}
public void addEdge(int s, int t, int w) { // 添加一条边
this.adj[s].add(new Edge(s, t, w));
}
private class Edge {
public int sid; // 边的起始顶点编号
public int tid; // 边的终止顶点编号
public int w; // 权重
public Edge(int sid, int tid, int w) {
this.sid = sid;
this.tid = tid;
this.w = w;
}
}
// 下面这个类是为了dijkstra实现用的
private class Vertex {
public int id; // 顶点编号ID
public int dist; // 从起始顶点到这个顶点的距离
public Vertex(int id, int dist) {
this.id = id;
this.dist = dist;
}
}
}
```
想要解决这个问题,有一个非常经典的算法,最短路径算法,更加准确地说,是**单源最短路径算法**一个顶点到一个顶点。提到最短路径算法最出名的莫过于Dijkstra算法了。所以我们现在来看Dijkstra算法是怎么工作的。
这个算法的原理稍微有点儿复杂,单纯的文字描述,不是很好懂。所以,我还是结合代码来讲解。
```
// 因为Java提供的优先级队列没有暴露更新数据的接口所以我们需要重新实现一个
private class PriorityQueue { // 根据vertex.dist构建小顶堆
private Vertex[] nodes;
private int count;
public PriorityQueue(int v) {
this.nodes = new Vertex[v+1];
this.count = v;
}
public Vertex poll() { // TODO: 留给读者实现... }
public void add(Vertex vertex) { // TODO: 留给读者实现...}
// 更新结点的值并且从下往上堆化重新符合堆的定义。时间复杂度O(logn)。
public void update(Vertex vertex) { // TODO: 留给读者实现...}
public boolean isEmpty() { // TODO: 留给读者实现...}
}
public void dijkstra(int s, int t) { // 从顶点s到顶点t的最短路径
int[] predecessor = new int[this.v]; // 用来还原最短路径
Vertex[] vertexes = new Vertex[this.v];
for (int i = 0; i &lt; this.v; ++i) {
vertexes[i] = new Vertex(i, Integer.MAX_VALUE);
}
PriorityQueue queue = new PriorityQueue(this.v);// 小顶堆
boolean[] inqueue = new boolean[this.v]; // 标记是否进入过队列
vertexes[s].dist = 0;
queue.add(vertexes[s]);
inqueue[s] = true;
while (!queue.isEmpty()) {
Vertex minVertex= queue.poll(); // 取堆顶元素并删除
if (minVertex.id == t) break; // 最短路径产生了
for (int i = 0; i &lt; adj[minVertex.id].size(); ++i) {
Edge e = adj[minVertex.id].get(i); // 取出一条minVetex相连的边
Vertex nextVertex = vertexes[e.tid]; // minVertex--&gt;nextVertex
if (minVertex.dist + e.w &lt; nextVertex.dist) { // 更新next的dist
nextVertex.dist = minVertex.dist + e.w;
predecessor[nextVertex.id] = minVertex.id;
if (inqueue[nextVertex.id] == true) {
queue.update(nextVertex); // 更新队列中的dist值
} else {
queue.add(nextVertex);
inqueue[nextVertex.id] = true;
}
}
}
}
// 输出最短路径
System.out.print(s);
print(s, t, predecessor);
}
private void print(int s, int t, int[] predecessor) {
if (s == t) return;
print(s, predecessor[t], predecessor);
System.out.print(&quot;-&gt;&quot; + t);
}
```
我们用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数组的作用是为了还原最短路径它记录每个顶点的前驱顶点。最后我们通过递归的方式将这个路径打印出来。打印路径的print递归代码我就不详细讲了这个跟我们在图的搜索中讲的打印路径方法一样。如果不理解的话你可以回过头去看下那一节。
inqueue数组是为了避免将一个顶点多次添加到优先级队列中。我们更新了某个顶点的dist值之后如果这个顶点已经在优先级队列中了就不要再将它重复添加进去了。
看完了代码和文字解释,你可能还是有点懵,那我就举个例子,再给你解释一下。
<img src="https://static001.geekbang.org/resource/image/e2/a9/e20907173c458fac741e556c947bb9a9.jpg" alt="">
理解了Dijkstra的原理和代码实现我们来看下**Dijkstra算法的时间复杂度是多少**
在刚刚的代码实现中最复杂就是while循环嵌套for循环那部分代码了。while循环最多会执行V次V表示顶点的个数而内部的for循环的执行次数不确定跟每个顶点的相邻边的个数有关我们分别记作E0E1E2……E(V-1)。如果我们把这V个顶点的边都加起来最大也不会超过图中所有边的个数EE表示边的个数
for循环内部的代码涉及从优先级队列取数据、往优先级队列中添加数据、更新优先级队列中的数据这样三个主要的操作。我们知道优先级队列是用堆来实现的堆中的这几个操作时间复杂度都是O(logV)堆中的元素个数不会超过顶点的个数V
所以综合这两部分再利用乘法原则整个代码的时间复杂度就是O(E*logV)。
弄懂了Dijkstra算法我们再来回答之前的问题如何计算最优出行路线
从理论上讲用Dijkstra算法可以计算出两点之间的最短路径。但是你有没有想过对于一个超级大地图来说岔路口、道路都非常多对应到图这种数据结构上来说就有非常多的顶点和边。如果为了计算两点之间的最短路径在一个超级大图上动用Dijkstra算法遍历所有的顶点和边显然会非常耗时。那我们有没有什么优化的方法呢
做工程不像做理论,一定要给出个最优解。理论上算法再好,如果执行效率太低,也无法应用到实际的工程中。**对于软件开发工程师来说,我们经常要根据问题的实际背景,对解决方案权衡取舍。类似出行路线这种工程上的问题,我们没有必要非得求出个绝对最优解。很多时候,为了兼顾执行效率,我们只需要计算出一个可行的次优解就可以了**。
有了这个原则,你能想出刚刚那个问题的优化方案吗?
虽然地图很大但是两点之间的最短路径或者说较好的出行路径并不会很“发散”只会出现在两点之间和两点附近的区块内。所以我们可以在整个大地图上划出一个小的区块这个小区块恰好可以覆盖住两个点但又不会很大。我们只需要在这个小区块内部运行Dijkstra算法这样就可以避免遍历整个大图也就大大提高了执行效率。
不过你可能会说了,如果两点距离比较远,从北京海淀区某个地点,到上海黄浦区某个地点,那上面的这种处理方法,显然就不工作了,毕竟覆盖北京和上海的区块并不小。
我给你点提示你可以现在打开地图App缩小放大一下地图看下地图上的路线有什么变化然后再思考这个问题该怎么解决。
对于这样两点之间距离较远的路线规划,我们可以把北京海淀区或者北京看作一个顶点,把上海黄浦区或者上海看作一个顶点,先规划大的出行路线。比如,如何从北京到上海,必须要经过某几个顶点,或者某几条干道,然后再细化每个阶段的小路线。
这样,最短路径问题就解决了。我们再来看另外两个问题,最少时间和最少红绿灯。
前面讲最短路径的时候,每条边的权重是路的长度。在计算最少时间的时候,算法还是不变,我们只需要把边的权重,从路的长度变成经过这段路所需要的时间。不过,这个时间会根据拥堵情况时刻变化。如何计算车通过一段路的时间呢?这是一个蛮有意思的问题,你可以自己思考下。
每经过一条边就要经过一个红绿灯。关于最少红绿灯的出行方案实际上我们只需要把每条边的权值改为1即可算法还是不变可以继续使用前面讲的Dijkstra算法。不过边的权值为1也就相当于无权图了我们还可以使用之前讲过的广度优先搜索算法。因为我们前面讲过广度优先搜索算法计算出来的两点之间的路径就是两点的最短路径。
不过这里给出的所有方案都非常粗糙只是为了给你展示如何结合实际的场景灵活地应用算法让算法为我们所用真实的地图软件的路径规划要比这个复杂很多。而且比起Dijkstra算法地图软件用的更多的是类似A*的启发式搜索算法不过也是在Dijkstra算法上的优化罢了我们后面会讲到这里暂且不展开。
## 总结引申
今天,我们学习了一种非常重要的图算法,**Dijkstra最短路径算法**。实际上最短路径算法还有很多比如Bellford算法、Floyd算法等等。如果感兴趣你可以自己去研究。
关于Dijkstra算法我只讲了原理和代码实现。对于正确性我没有去证明。之所以这么做是因为证明过程会涉及比较复杂的数学推导。这个并不是我们的重点你只要掌握这个算法的思路就可以了。
这些算法实现思路非常经典掌握了这些思路我们可以拿来指导、解决其他问题。比如Dijkstra这个算法的核心思想就可以拿来解决下面这个看似完全不相关的问题。这个问题是我之前工作中遇到的真实的问题为了在较短的篇幅里把问题介绍清楚我对背景做了一些简化。
我们有一个翻译系统,只能针对单个词来做翻译。如果要翻译一整个句子,我们需要将句子拆成一个一个的单词,再丢给翻译系统。针对每个单词,翻译系统会返回一组可选的翻译列表,并且针对每个翻译打一个分,表示这个翻译的可信程度。
<img src="https://static001.geekbang.org/resource/image/91/67/91b68e47e0d8521cb3ce66bb9827c767.jpg" alt="">
针对每个单词我们从可选列表中选择其中一个翻译组合起来就是整个句子的翻译。每个单词的翻译的得分之和就是整个句子的翻译得分。随意搭配单词的翻译会得到一个句子的不同翻译。针对整个句子我们希望计算出得分最高的前k个翻译结果你会怎么编程来实现呢
<img src="https://static001.geekbang.org/resource/image/76/53/769cab20f6a50c0b7a4ed571c9f28a53.jpg" alt="">
当然最简单的办法还是借助回溯算法穷举所有的排列组合情况然后选出得分最高的前k个翻译结果。但是这样做的时间复杂度会比较高是O(m^n)其中m表示平均每个单词的可选翻译个数n表示一个句子中包含多少个单词。这个解决方案你可以当作回溯算法的练习题自己编程实现一下我就不多说了。
实际上这个问题可以借助Dijkstra算法的核心思想非常高效地解决。每个单词的可选翻译是按照分数从大到小排列的所以$a_{0}b_{0}c_{0}$肯定是得分最高组合结果。我们把$a_{0}b_{0}c_{0}$及得分作为一个对象,放入到优先级队列中。
我们每次从优先级队列中取出一个得分最高的组合,并基于这个组合进行扩展。扩展的策略是每个单词的翻译分别替换成下一个单词的翻译。比如$a_{0}b_{0}c_{0}$扩展后,会得到三个组合,$a_{1}b_{0}c_{0}$、$a_{0}b_{1}c_{0}$、$a_{0}b_{0}c_{1}$。我们把扩展之后的组合加到优先级队列中。重复这个过程直到获取到k个翻译组合或者队列为空。
<img src="https://static001.geekbang.org/resource/image/e7/6c/e71f307ca575d364ba2b23a022779f6c.jpg" alt="">
我们来看,这种实现思路的时间复杂度是多少?
假设句子包含n个单词每个单词平均有m个可选的翻译我们求得分最高的前k个组合结果。每次一个组合出队列就对应着一个组合结果我们希望得到k个那就对应着k次出队操作。每次有一个组合出队列就有n个组合入队列。优先级队列中出队和入队操作的时间复杂度都是O(logX)X表示队列中的组合个数。所以总的时间复杂度就是O(k*n*logX)。那X到底是多少呢
k次出入队列队列中的总数据不会超过k*n也就是说出队、入队操作的时间复杂度是O(log(k*n))。所以总的时间复杂度就是O(k*n*log(k*n)),比之前的指数级时间复杂度降低了很多。
## 课后思考
<li>
在计算最短时间的出行路线中,如何获得通过某条路的时间呢?这个题目很有意思,我之前面试的时候也被问到过,你可以思考看看。
</li>
<li>
今天讲的出行路线问题,我假设的是开车出行,那如果是公交出行呢?如果混合地铁、公交、步行,又该如何规划路线呢?
</li>
欢迎留言和我分享,也欢迎点击“请朋友读”,把今天的内容分享给你的好友,和他一起讨论、学习。