You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

180 lines
15 KiB
Markdown

This file contains invisible Unicode characters!

This file contains invisible Unicode characters that may be processed differently from what appears below. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to reveal hidden characters.

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 19选路算法距离矢量算法为什么会产生无穷计算问题
你好我是微扰君。今天我们一起来学习一种新的解决最短路问题的思路——Bellman-Ford算法以及基于它发展出来的距离矢量算法。
动态路由问题相信你已经理解了上两讲我们也一起学习了解决这个问题的一种经典选路算法——基于Dijkstra算法思想的链路状态算法核心就是每个节点通过通信收集全部的网络路由信息再各自计算。
如果说链路状态算法的思想是全局的、中心化的,**我们今天要学习的距离矢量算法就是本地的、非中心化的,交换信息的数据量会比链路状态少很多**。因为在基于距离矢量算法下的选路协议下,节点之间只用交换到网络中每个其他节点的距离信息,不用关心具体链路,也就是我们所说的距离矢量,而不是泛洪地转发整个网络中每条边的信息。
具体是如何做到的呢这背后计算最短路的核心思想就是Bellman-Ford算法。
## Bellman-Ford算法
我们就先来学习Bellman-Ford算法它同样是一种反复执行“松弛”操作去计算源点S到网络中其他节点距离最短路径的算法所以学过Dijkstra算法的思想我们再理解BellmanFord算法是比较简单的。
不过和Dijkstra用到的贪心思想不同Bellman-Ford算法采用的是动态规划dynamic programming的思想。
首先用同样的数学语言来描述整个图图G=(V,E)包含V个顶点E条边源点是sweight表示节点之间的距离weight\[u\]\[v\]表示节点u和节点v之间的距离distance\[u\]表示从s到u的最短距离在整个算法过程中我们会不断地更新也就是松弛这个距离distance\[u\]的值。
![图片](https://static001.geekbang.org/resource/image/6e/ce/6eb9440fd991116929ef63cff06986ce.jpg?wh=1920x1145)
**Bellman-Ford的核心思路就是我们遍历所有的边 e=(u,v) ,并进行松弛操作**也就是判断distance\[v\]是否小于distance\[u\]+weight\[u\]\[v\]如果是的话就把distance\[v\]设为distance\[u\]+weight\[u\]\[v\]这也标志着在这次遍历中我们为v找到了一条从s出发抵达v更近的路线。
为了保证正确计算出从源点s到每个其他顶点的最短路径怎么做呢
其实也很简单,我们只要**把这个遍历松弛的过程重复V-1次**也就是图上除了自己之外的顶点数量次。这样在每次遍历所有边松弛的过程中distance\[v\]被计算正确的节点都会增加,最终,我们就可以得到所有节点的最短路径(如果你对这个方法有疑惑,我们稍后会梳理证明过程)。
相比Dijkstra算法每次贪心地找最短节点进行松弛的方式Bellman-Ford直接多轮遍历所有边的松弛方式显然可以适应更广的应用场景。
比如现在负边就不再是一个困扰了我们不再需要考虑每个节点加入最短距离树之后可能会因为存在负边而被重新更新距离的情况。和Dijkstra算法一样Bellman-Ford中第i轮松弛结束之后可以确定至少有i个节点到原点的最短路被确定了但我们不再知道当然也没有必要知道是哪一个了。
![](https://static001.geekbang.org/resource/image/00/69/008c9bb6f501a6b622869bcf07cfc969.jpg?wh=2312x1379)
这个代码其实比Dijkstra算法要好实现许多这里我写了一个版本伪代码供你参考我们一起来梳理一下思路
```java
function BellmanFord(list vertices, list edges, vertex source) is
// This implementation takes in a graph, represented as
// lists of vertices (represented as integers [0..n-1]) and edges,
// and fills two arrays (distance and predecessor) holding
// the shortest path from the source to each vertex
distance := list of size n
predecessor := list of size n
// Step 1: initialize graph
for each vertex v in vertices do
distance[v] := inf // Initialize the distance to all vertices to infinity
predecessor[v] := null // And having a null predecessor
distance[source] := 0 // The distance from the source to itself is, of course, zero
// Step 2: relax edges repeatedly
repeat |V|1 times:
for each edge (u, v) with weight w in edges do
if distance[u] + w < distance[v] then
distance[v] := distance[u] + w
predecessor[v] := u
return distance, predecessor
```
整体就是两步:
* 第一步对图的初始化。和Dijkstra算法一样我们需要用distance数组去记录每个节点的距离用predecessor记录每个节点最短路中的前驱节点方便输出最短路径。在刚开始还没有进行遍历松弛的时候把距离都设为无限大前驱节点设为空就行。
* 第二步循环松弛操作。就是我们刚刚说的一共进行V-1次每次循环中都遍历所有的边进行松弛操作。不过注意每次松弛成功也需要更新前置节点。
可以看到代码写起来其实比Dijkstra要简单很多这也是建立在更高的时间复杂度代价下的**Bellman-Ford的整体时间复杂度是O(V\*E)大部分实际场景下边的数量比节点数量大的多所以时间复杂度要比Dijkstra算法差很多**。当然好处在于可以处理图中有负边的情况。
代码的部分还是比较好理解的。Bellman-Ford算法正确性的证明还是要稍微花一点功夫会涉及一点数学的证明如果你觉得理解困难的话可以多找几个例子好好模拟几遍遍历松弛的过程观察一下每一轮遍历之后距离变化的情况相信还是可以掌握的。
### Bellman-Ford算法正确性证明
我们来严格证明一下为什么只要对进行V-1轮的所有边的松弛操作就一定可以得到所有节点到原点的最短路径。
整体证明可以通过数学归纳法实现。数学归纳法我们简单复习一下核心就是两步第一步要证明i=1的时候某个结论成立第二步要证明如果结论在i=n时成立那么i=n+1的情况也成立这样就能证明整个结论的正确性。
首先在Bellman-Ford算法中我们知道进行完第一轮遍历之后一定能得到从源点出发到其他任意节点通过长度最多为1的1跳路径中最短的距离。
那我们假设在进行完第i轮遍历之后可以得到从原点出发到其他任意节点通过长度最多为i的i跳路径中最短的距离判断进行第 i+1 轮松弛时是否能得到从原点出发到其他任意节点通过长度最多为i+1的i+1跳路径中最短的距离呢
答案是肯定的。因为长度为i+1的路径只能从长度为i的路径演化而来假设从s到某个节点v的路径中存在长度为i+1的路径比长度小于等于i的路径更近假设这条路径的第i跳是u那遍历所有边一定能基于此前到u最短的路径加上u->v这条边得到s->v的最短路径。
![图片](https://static001.geekbang.org/resource/image/75/22/75ca9c0bf75215b518722d6dff366022.jpg?wh=1920x1145)
### 负权回路问题
在一个没有负权回路的图中也就是不存在某个回路中边的权重之和是负值的情况显然从s出发到任意节点v的最短路径经过的边数量最多就是V-1因为最短路径不可能经过同一个点两次。
所以我们通过V-1轮松弛可以得到从s出发到任意节点的边数量小于等于V-1的路径中最短的路径自然也就得到了s到任意节点的最短路径。
讲到这里,也就引出了在**Bellman-Ford算法中的一个限制没法处理存在负权回路的情况**。
有时候Bellman-Ford的这一特性也可用来检测负权回路在图中是否存在做法就是进行第V次循环正常情况下这第V次循环不会再有任何边的距离被更新但是如果有边的距离被更新了就说明在图里一定有负权回路。
```java
// Step 3: check for negative-weight cycles
for each edge (u, v) with weight w in edges do
if distance[u] + w < distance[v] then
error "Graph contains a negative-weight cycle"
```
当然,在网络选路算法的场景下,我们肯定是没有负边的,也就没有必要担心负权回路的问题了。
## 距离矢量算法
现在我们已经了解了Bellman-Ford的思想如何用它来解决选路算法中的最短路问题呢
类似链路状态算法的通信,网络中各节点间同样是需要通过彼此的信息交换获得最短路径的信息,但这次我们只关心网络中各节点和自己的邻居们的路径长度,不用获取全局的网络拓扑结构信息。
举个例子帮助你理解。现在我们希望从上海坐汽车去北京,但没有全局的地图(来源[百度地图](https://map.baidu.com/dir/%E4%B8%8A%E6%B5%B7%E5%B8%82/%E5%8C%97%E4%BA%AC%E5%B8%82/@13192288.504976533,4222353.868202584,7.1z?querytype=bt&c=1&sn=1$8eb697c2bdf8bae8b6f42fe7$13523299.000000,3641066.000000$%E4%B8%8A%E6%B5%B7%E5%B8%82$$$&en=1$1f062db5bf2e12b5b04c49e6$12959220.000000,4825334.500000$%E5%8C%97%E4%BA%AC%E5%B8%82$$$&sc=289&ec=131&rn=5&cty=0&csy=0&reqtp=1&da_src=shareurl))不知道怎么走更短,只能打电话问相邻城市的好朋友。如果我们要找出一条最短的路径有两种办法。
![图片](https://static001.geekbang.org/resource/image/a0/03/a02d83c54dd70f8386fb7e28666da903.png?wh=1016x1420)
一种就是让所有人都问自己邻居城市的朋友,收集好所有的公路信息,然后传播给自己邻居城市的朋友;这样经过一段时间,我们就可以从邻居那里获得整个地图各站间的全部信息,从而可以自己研究出一条最短路径,这个思想就是链路状态法。
而另一种就是我们只是问邻居,你那有汽车能到北京吗?
假设到上海距离一样的两个城市常州和苏州都可以抵达北京一个到北京700km另一个到北京800km那我们可能就会选择短的那条经过常州的线路。而常州和苏州怎么知道自己可以到达北京呢也是基于类似的方式从自己邻居城市的朋友那知道的。这个思路其实和距离矢量法本质上是一样的。
所谓的“距离矢量”其实就是在每个节点都维护这样一张距离表:它是一个矩阵,每一行都可以代表一个目标节点,每一列是经过每个邻居到达这个目标节点的最短距离。
![图片](https://static001.geekbang.org/resource/image/9e/30/9edb1f946d6c9ebb07706b9e2ba2f730.jpg?wh=1920x1145)
选路的时候,我们就会**从每行中选择一个经过邻居节点成本最低的邻居,作为路由表的下一跳**。
比如选择从E到达D的路径我们对比E经过A到D、E直接到达D的路径距离分别是第四行的第一列和第四行的第三列显然E直接到达D是一条更短的路径所以路由表下一跳的选择自然也会是D。
整个算法也是迭代进行的,每个节点都会不断地从邻居那里获得最新的距离信息,然后尝试更新自己的距离矩阵,如果发现自己的距离矩阵有变化,才会通知邻居。这样也能避免许多不必要的通信成本。
![图片](https://static001.geekbang.org/resource/image/50/80/504d2735e752b30505bedce61ecc0e80.jpg?wh=1920x1145)
参考这个体现算法逻辑的流程图相信你也一定能意识到为什么我们说这个算法是建立在Bellman-Ford算法思想上的了其实节点间彼此传递信息的时候在做的就是松弛操作等所有的节点都稳定下来也就相当于进行了V-1轮松弛操作这个时候所有节点的距离矢量就会进入稳定没有变化的状态整个算法也就进入了收敛状态。
### 无限计算问题
但是因为每个节点都没有全局的拓扑结构,距离矢量有一个巨大的问题,就是**在一些情况下会产生无限计算的可能**。
比如图中的例子,假设 A、B、C、D 四个节点已经在某一时刻建立了稳定的距离矢量ABC三个节点到D都会经过C节点。
![图片](https://static001.geekbang.org/resource/image/2e/b5/2ec56b8192a7c51df013aa29f4ed73b5.jpg?wh=1920x1145)
此时如果C->D节点突然中断了会发生什么呢
C发现自己到D的路径走不通了就会问自己的邻居B你那边可以到D吗
这个时候B的距离表是没有变化的结果B发现自己可以到D距离为2就会告诉C可以从我这走距离是2。
但是因为距离矢量算法没有任何信息告诉B其实它到D的路径就需要经过C于是C就会把自己到D的路径信息更新为新的B到D的距离加上C到B的距离也就是2+1。
![图片](https://static001.geekbang.org/resource/image/cc/0b/cc7a98ccc48c1d1dd976b877c297a60b.jpg?wh=1920x1145)
而更新之后B又会收到消息你的邻居C距离矩阵变化了从而把自己B到D的距离更新为3+1。
这样的过程会反复执行于是通往D的距离会无限增加。
这个问题就是**路由环路问题,也被称为无限计算问题**。解决思路也比较多,比较常见的做法就是设定一个跳数上限。
比如在RIP协议中16跳就是一个常用的上限如果路径跳数多于16我们就会把这个路径看成不可达的这个时候我们可以让发现某个节点不可达的节点暂时不要相信其他节点发来的距离矢量从而避免路由环路问题的无限计算问题。当然如果有节点和网络断开连接但在跳数没有到达上限之前还是会进行大量无谓的计算。
## 总结
好距离矢量算法到这里就学完了,我们结合链路状态算法简单对比一下。
首先距离矢量算法和链路状态算法背后分别是基于Bellman-Ford算法和Dijkstra算法实现的。
距离矢量算法背后的Bellman-Ford本质就是对所有边无差别的松弛操作迭代地进行很多轮是本地的、非中心化的算法。
节点之间不用交换全部的路由拓扑信息,只需要交换到其他节点的最短距离,就可以让距离矢量算法逐步正确选出最短的路径,直至收敛;节点之间的通信也不需要是同步的,邻居节点的距离矢量在什么时间更新、以什么次序抵达都可以,不会影响选路的正确性。
但是在状态链路算法中完全不同,每个节点都需要通过信息交换获取全部的路由信息,然后各自独立地计算最短路径。虽然带来了更大通信开销,但同时也更加保证了计算的健壮性,不会出现环路计算这样的问题。
这两种基础选路算法值得你好好体会其中的思想可以说现在绝大部分选路算法都是在它们的基础上改进的。另外背后的Dijkstra和Bellman-Ford算法也是算法竞赛中的常考题在各大互联网公司的笔试题中也逐渐开始出现你可以到力扣上找一些题目练习。
### 课后作业
今天留给你的思考题就是前面提到的环路问题,在跳数没有到上限之前,还是会进行大量无谓的计算。有什么更好的解决办法吗?
欢迎你留言与我一起讨论,如果觉得这篇文章对你有帮助的话,也欢迎转发给你的朋友一起学习。我们下节课见~