Dijkstra算法入门

前言
  最短路径问题(Shortest Path Problem)是一类非常重要的问题,它出现在很多领域,例如车辆导航、路由选择、机器人运动规划、物流等。Dijkstra 算法是一种解决最短路径问题的经典算法,同时也是计算机科学中最有名的算法之一。其方法简洁,但蕴藏的思想却很深刻。通过学习 Dijkstra 算法,既可以掌握分析、解决问题的方法,也可以作为进一步学习其它搜索算法的基础。用一句时髦的话说,Dijkstra 算法——你值得拥有。
  然而,对于缺少一定基础的初学者,要彻底理解 Dijkstra 算法有些困难。笔者发现大多数讲述 Dijkstra 算法的书籍或博客往往不求甚解,只知照本宣科地描述算法的流程,而忽视了算法的由来和内在的逻辑。这样的文章是写给机器看的,而不是给人看的。其后果是,初学者读完后仍然似懂非懂,知其然而不知其所以然。而且初学者在编程实现时又会遇到不少麻烦,让他们举步维艰。本文的目的是帮助初学者尽快入门,为此在表达上力求通俗易懂。文中出现的程序都提供了源代码(Mathematica),方便初学者体验程序的运行过程,并对其解剖研究。

1. 最短路径问题
  最短路径问题的研究范围很大,我们只讨论最简单的情况,即:告诉你一个起点和一个目标点,找到从起点出发到达目标点的最短路径,这又称为单源单目标最短路径问题。一般来说,找到一条连接起点和目标点的路径并不太难,但是想找到最短的路径可就没那么容易了。
  在解决这个问题之前,我们首先需要对它用数学语言进行描述。现实世界总是存在各种约束,比如汽车应该沿着道路行驶、电流必须在电缆上传输、上网产生的数据包只能在路由器之间的网线传递。如果不存在约束,那么最短问题就没有研究价值了——只需要在起点和目标点之间画直线就行了。为了表示现实中的各种约束,同时也为了便于用数学方法进行处理,通常选择数学中的“图”(graph)进行描述(研究“图”的数学学科称为“图论”,图 1(a) 展示了一个“图”的例子)。“图”由两种东西组成:节点(vertex)和 边(edge)。图 1(a) 中的圆点表示节点,黑色线段表示边。我们一般用小写字母表示节点,例如节点 a a a、节点 b b b。每条边的两端是两个节点。因为每条边都唯一对应自己的两个节点,所以可以用两个节点表示一条边。我们用 ( a a a, b b b) 表示节点 a a a 与节点 b b b 之间的那条边。我们将“图”中所有的节点放在一起,组成一个集合,记为 V V V ;所有的边也放在一起,记为 E E E。什么是路径(path)呢?一条路径由若干条首尾相接的边组成。我们也可以用一系列相邻的节点表示路径。什么是相邻的节点呢?如果两个节点在同一条边的两端它们就是相邻的,也可以称为“邻居”。一个节点可以有好多个邻居,而且我们假设每个节点至少有一个邻居。既然我们关心路径的长短,就需要有距离的概念。我们定义每条边都对应一个数值,那就是它的长度。我们用 l ( a , b ) l(a,b) l(a,b) 表示 ( a , b ) (a,b) (a,b) 边的长度。我们只考虑长度为非负数的情况(即 l ( a , b ) ≥ 0 l(a,b) ≥ 0 l(a,b)0),因为Dijkstra 算法不适用于负数边长的情况。从此以后,我们进入这个“图”的世界,里面除了节点和边(和它的长度)以外别的什么都没有。你可能会觉得这个小世界太简单、太无聊了。别急,随着我们逐步探索这个小世界,它的丰富多彩将会让你大吃一惊。
  既然寻找最短路径是件很难的事,我们最好先从简单的情况入手。考虑如图 1(a) 所示的例子,这个“图”的节点排列成一个规则网格,所有边的长度都相等,假设长度都是1吧。图中也标出了起点(红色点)和目标点 (绿色点),你能找到它们之间的最短路径吗?

  答案揭晓,最短路径就是图 1(b) 中的黄色线段(为了突出它,我特意画得比普通的边粗一些)。因为两点间直线段最短,起点和目标点之间刚好存在这样组成直线段的边。你可能觉得这太简单了,甚至有智商被侮辱的感觉。事实恰恰相反,这个例子不是太肤浅了,而是太深刻了。我们可以从中找到一条规律,这条规律太重要了,以至于我不得不将它单独放在一段:

  规律 0 0 0:一条直线段上任意两点之间的那部分线段仍然是直线段。

  数学家们喜欢干的一件事就是推广——将特殊推广到一般,将简单推广到复杂。比如牛顿的第一个数学发现就是将二项展开式的指数从正整数推广到负数和分数。我们也来试着将前面这条规律推广一下,于是就得到了下一条规律:

  规律 1 1 1:一条最短路径上任意两个节点之间的那部分路径仍然是它们的最短路径。

  根据我们的日常经验,规律1似乎是对的,但是我们要从逻辑上证明它的正确性。如果对的不容易发现破绽,那我们就反其道而行之,从错的开始推导。我们假设规律 1 是错的,也就是说:最短路径上存在两个节点,它们之间的那部分路径不是最短路径。图 2(a) 展示的例子就是这种情况,在起点 s s s 和目标点 t t t 之间的黄色曲线是它们的最短路径。在这条最短路径上,有两个节点 a a a b b b a b ab ab 之间的最短路径(蓝色曲线)比黄色曲线上的那部分更短。从图中可以看到,节点 a a a b b b 将黄色曲线分成了三段,即 s s s- a a a 段、 a a a- b b b 段和 b b b- t t t 段。三段长度之和就是 s s s t t t 的最短路径的长度,我们用 L m i n L_{min} Lmin 表示。如果我们用蓝色曲线替换掉黄色曲线上的 a a a- b b b 段,如图 2(b) 左侧所示,那么这个重新组合得到的新路径长度显然小于 L m i n L_{min} Lmin 。新的路径比 s s s t t t 之间的最短路径(黄色曲线)还短,这显然是矛盾的,也就说明规律1是正确的。
  想想看,我们能不能将规律1换种说法:一条最短路径上的任意两个节点之间的最短路径仍然在这条路径上。看起来好像差不多,但其实是不严谨的,因为我们并不知道最短路径是不是唯一的。如果任意两个节点之间的最短路径都只有一条,那么这样说就是对的。但是在有些情况下,两个节点之间的最短路径可能会有不止一条 (它们的长度都是最短的,但经过的节点不同)。所以我们还是应该采用规律1的说法。

2. 搜索算法
2.1 松弛 (relax)
  啊哈!这个小世界开始有意思起来了,我们发现了其中的一条规律。但别高兴的太早,我们怎么利用这条规律呢?如果我给你一条路径,你可以用规律 1 来验证它到底是不是最短的。如果你能在这条路径上找到两个节点,在它们之间有更短的路径,那你可以自信地说我给你的路径肯定不是最短的。注意:规律 1 的重点是“最短路径上”。非最短路径上也可能包含最短的子路径;而两个最短路径拼接到一起得到的路径未必是最短的。规律 1 没有告诉我们怎么计算最短路径。我们试试把规律 1 反过来是什么,这样就得到了另一条规律:
  
  规律 2 2 2:如果一条路径上的任意两个节点之间的最短路径仍然在这条路径上,那么这条路径就是最短路径。
  
  我们同样不知道规律 2 是不是成立。但经验告诉我们,它很有可能是对的。我们也需要从逻辑上检验规律 2 的正确性。这里我们可以投机取巧,既然规律 2 适用于路径上的任意两个节点,我们不妨选择这条路径的起点和目标点。因为起点和目标点间的最短路径与这条路径重合,显然这条路径就是最短路径。所以规律 2 是正确的。太棒了,因为我们在小世界中又发现了一个新规律。与规律 1 不同的是,规律 2 的提供了一种操作 —— 把一条不是最短的路径变成最短路径的操作:
  1. 随便选择一条连接起点和目标点的路径(不一定最短)。
  2. 在这条路径上任意选择两个节点,搜索它们之间的最短路径。
  3. 如果找到的最短路径不在原路径上,就用最短路径替换掉原来路径的那部分。
  4. 重复第2步和第3步,直到这条路径的长度不再改变。

  我们同样不知道规律 2 是不是成立。但经验告诉我们,它很有可能是对的。我们也需要从逻辑上检验规律 2 的正确性。这里我们可以投机取巧,既然规律 2 适用于路径上的任意两个节点,我们不妨选择这条路径的起点和目标点。因为起点和目标点间的最短路径与这条路径重合,显然这条路径就是最短路径。所以规律 2 是正确的。太棒了,因为我们在小世界中又发现了一个新规律。与规律 1 不同的是,规律 2 的提供了一种操作——把一条不是最短的路径变成最短路径的操作:

  为了帮助大家理解上面几步的含义,下面我用一个简单的例子来解释。假如你由于工作调动,来到了一个新的城市。这个城市的道路构成了一个交通网,如图 3(a) 所示,其中红色点表示你的住所,绿色点表示你的公司。上班第一天,你想找一条开车最快到公司的路。可是你对这个城市的道路不熟悉,所以你只能勉强找一条能到公司的路,如图 3(b) 中所示的粉色路径(好吧,我承认看起来实在是不怎么好)。

  随着时间的流逝,你对这个城市的交通越来越熟悉,附近每条道路的走向和长度逐渐进入你的记忆。虽然你对整个交通网仍然不是非常了解,但对某几段路和它周边道路的印象还是很清楚的,这是因为你走的次数太多了,有时也会走错或者去其它地方,并由此发现了更多的道路。你逐渐发现你最开始找到的那条路并不是最好的。在某条路附近存在更短的路(图4(a) 中虚线内的部分)。于是,你开始超近道,如图 4(b) 所示。以前你走的是经过 ( a , b ) (a,b) (a,b) 边和 ( b , c ) (b,c) (b,c) 边表示的路,现在你知道超近道直接走 ( a , c ) (a,c) (a,c) 边更快。每超一次近道,路径就会短一些,直到你最终找到最短的路径。
  依照上面几步操作我们最终总能找到最短路径。可这是一个好方法吗?看起来似乎不太好。首先我们并不知道运行多少步才能找到短路径。假如你迷路了,向别人问路。那人给你指了一个方向却没告诉你还有多远,你会不会心里没底。其次是第 2 步,很明显第 2 步本身就是一个最短路径问题,它如何求解我们还是不知道。
  虽然上述方法缺少实用价值,但至少它的方向是对的,我们可以从中受到启发。这个方法可以形象的比作被抻长的橡皮筋恢复的过程。如果将路径视为橡皮筋,那么路径的长度就对应橡皮筋中储存的弹性势能。最短路径就是自然状态下(不受外力)的橡皮筋,它不会再缩短了。开始随意确定的路径相当于被抻长的橡皮筋,而以后每一次超近道都可以看成橡皮筋在自身弹力作用下缩短恢复的过程。我们称这一过程为“松弛”(relax),意思就是松开抻长的橡皮筋,让它缩短从而释放掉多余的弹性势能,如图 5 所示。

  松弛现象很容易理解,但是松弛发生的前提条件是什么呢?让我们将注意力集中到路径中的某一条边,如图 6(a) 所示。假设一条路径从起点 s s s 节点出发,依次经过 a a a 节点和 b b b 节点(但是 ( a , b ) (a,b) (a,b) 边不在这条路径上)。再假设 s s s 节点与 a a a 节点间的路径长度为 2,那么我们就认为 a a a 节点的能量是 2。 a a a 节点与 b b b节点间的路径长度为 6,我们认为 b b b 节点的能量是 2 + 6 = 8。边 ( a , b ) (a,b) (a,b) 的长度是 3,路径如果经过 ( a , b ) (a,b) (a,b) 边, b b b 节点的能量就是 2 + 3 = 5。 b b b 节点的能量下降了(5 < 8)。所以,一条边松弛发生的条件是要能够降低边上节点的能量。反过来想,如果路径经过 ( a , b ) (a,b) (a,b) 边,没有降低 b b b 节点的能量,那么说明 a a a, b b b 间的路径长度小于或等于 ( a , b ) (a,b) (a,b) 边的长度,此时不应该松弛。

  以后我们正式称呼一个节点的能量(或距离)为它的值,一个节点 a a a 的值表示为 d ( a ) d(a) d(a)。一个节点的值定义为连接起点和这个节点的路径的长度。如果连接起点和这个节点的路径不止一条,我们只选择最短的那条路径。显然,如果我们找到了起点和这个节点之间的最短路径,那么节点的值就是最小的了,它不会再变小了。反之,如果节点的值是最小的,那么我们就知道了最短路径的长度了。
  松弛的过程很简单,用程序实现也不复杂。为了便于理解,我把松弛程序用伪代码写出来,如 Algorithm 1 所示。Relax 函数负责实现松弛,它的输入是两个相邻的节点 a a a b b b。注意Relax( a a a, b b b) 的输入是区分顺序的。 d ( b ) ← d ( a ) + l ( a , b ) d(b)←d(a) +l(a,b) d(b)d(a)+l(a,b) 表示对 ( a , b ) (a,b) (a,b) 边松弛,也就是令 b b b 节点的值等于 d ( a ) + l ( a , b ) d(a) + l(a,b) d(a)+l(a,b) (更准确的说,是对 b b b 节点松弛,因为 a a a 节点的值没变)。结束赋值后还没完,我们还要记录下是谁让 b b b 节点的值降低。我们让 a a a 节点作为 b b b 节点的“母亲节点”。 b b b 节点可以有很多邻居,但是只能有一个“母亲”。而 b b b 节点是那种“有奶便是娘”的节点:谁让它的值降低,它就认谁做娘。我们用 p ( b ) p(b) p(b) 表示 b b b 节点的“母节点”。

  终于找到了一件稍微顺手点的工具了,我们应该如何使用它呢?回想图 3(b) 的例子,如果我们对初始路径上每两个相邻节点之间的边进行松弛,就得到如图 6© 所示的新路径。让人欣慰的是,新路径确实更短了,但是好像还远远不是最短的路径。这是为什么呢?注意在松弛时,我们只考虑了一部分边,也就是两端节点都在初始路径上的边,因为只有这些节点的值是已知的,这样我们才能判断松弛条件是否满足。可是如果初始路径并不通过最短路径的节点,那么再怎么松弛也不会得到最短路径。
2.2 所有边依次松弛
  我们知道了只松弛一部分边达不到理想的效果,原因就是初始路径不一定与最短路径有一样的节点。当然,我们不知道最短路径经过哪些的节点。能否扩大范围,对所有的边都松弛呢?当然可以。只是除了起点之外,我们对其它所有节点的值都不清楚,这意味着我们无法判断松弛的条件。不过,我们可以认为起点的值是0,因为起点到起点的路径最短就是0,不会有比0更短的路径了。这时我们不再需要先寻找一个初始路径了。我们可以将其它所有节点的值都认为是无穷大,也就是说没有路径到达它们。每应用一次松弛,它们的值都会改变一点。我们可以编程实现这个过程,如 Algorithm 2 所示。

  下面我们详细解释 Algorithm 2 的每一步。
  1. 首先算法进行初始化,也就是我们刚刚讨论过的,设置节点的值和母节点。由于计算机没办法表示无穷大,把初始值设置成一个很大的数就行 (比如 1000,实际上只要大于所有可能路径的最大值就可以)。
  2. 第一个 for 循环执行 n n n 次,这里 n n n 是人为指定的, n n n 应该是多少我们也不知道。不过没关系,我们会通过几次试验确定它,刚开始不妨先让 n = 1 n=1 n=1
  3. 第二个 for 循环负责扫描边,它从“图”的所有边的集合 E E E 中依次取出一条边 (用 ( a , b ) (a,b) (a,b) 表示),直到所有的边都被取过。这个循环会执行 m m m 次 ( m m m 是“图”中边的个数)。
  4. 第三步的 if 语句用于判断是否需要松弛,如果 d ( b ) ≠ d ( a ) + l ( a , b ) d(b) \ne d(a) + l(a,b) d(b)=d(a)+l(a,b) 或者 d ( a ) > d ( b ) + l ( b , a ) d(a) > d(b) +l(b,a) d(a)>d(b)+l(b,a),则满足松弛的条件,就调用 Relax 函数进行松弛。我们只考虑无方向限制的边,即路径既可以由 a a a b b b,也可以由 b b b a a a,所以这里要判断两次。(对于有方向的边则更简单,只需判断一次即可)。

  我们用该程序求解图 3(a) 所示的例子,看看能得到什么结果(代码可见文件 Example 1.nb)。这个程序只改变节点的值和母节点。可是节点值只是一堆数字,为了更直观地展示结果,我将每个节点的值用等比例高的小球表示,如图 7(a) 所示。值越大,小球的位置越高、颜色越暖(偏红色),反之越小就越爱、颜色越冷(偏蓝色)。从图中可以看出,第一次扫描后起点附近的节点值变化较大,但是远处的节点值仍为初始设定的值,并没有怎么变化。我们增加 n n n 看看会有什么影响。 n = 2 n = 2 n=2 时的结果如图7(b) 所示,更多的节点值发生变化了。当 n = 3 n = 3 n=3 时几乎所有节点值都改变了。我们继续增加 n n n 会怎么样? n n n 应该取多少才合适呢?经过一番试探,我们发现 n > 7 n > 7 n>7 后节点的值不再变化了,如下动画图。
  这说明所有节点的值都稳定到了一个固定值,同时也意味着稳定后的值不存在满足松弛条件的边了。因为如果存在的话, 一定有节点的值会减少(这又是由于松弛条件的标准是严格小于 < < <,而不是小于等于 ≤ \le )。所以,对于这个例子(图 3(a)), n n n 应该取 7。

  松弛完后,我们怎么找到通往目标节点的路径呢?答案是,我们在松弛边的时候,相应节点的母节点同时也就确定了。我们可以从目标节点开始,先找到它的母节点,这个母节点也有自己的母节点,我们可以一直向回追溯(backtrack),直到其中一个母节点是起点为止。起点到目标点的路径就由这一系列相邻母节点定义的边组成。这一过程如 Algorithm 3 所示。

  将目标点带入回溯函数,得到的路径如图8(a) 所示,它看上去确实比之前的短多了。但是我们心中有个大问号——这样得到的路径是最短的吗?或者更准确地问:当“图”中的所有边都不能再松弛时,所有节点的值都是最小的吗? 我们证明一下:假设某个节点 v v v 与起点 s s s 之间的最短路径是 v → v 1 → v 2 → v 3 → ⋯ → v k → s v\rightarrow v_{1}\rightarrow v_{2}\rightarrow v_{3}\rightarrow \cdots \rightarrow v_{k}\rightarrow s vv1v2v3vks。既然这条路径上的每条边都不满足松弛条件,那就有
d ( v ) ≤ d ( v 1 ) + l ( v , v 1 ) d ( v 1 ) ≤ d ( v 2 ) + l ( v 1 , v 2 ) d ( v 2 ) ≤ d ( v 3 ) + l ( v 2 , v 3 ) ⋮ d ( v k ) ≤ d ( s ) + l ( v k , s ) \begin{aligned} &d(v)\leq d(v_{1})+l(v,v_{1})\\ &d(v_{1})\leq d(v_{2})+l(v_{1},v_{2})\\ &d(v_{2})\leq d(v_{3})+l(v_{2},v_{3})\\ &\qquad\qquad\quad\vdots\\ &d(v_{k})\leq d(s)+l(v_{k},s) \end{aligned} d(v)d(v1)+l(v,v1)d(v1)d(v2)+l(v1,v2)d(v2)d(v3)+l(v2,v3)d(vk)d(s)+l(vk,s)   将后一个不等式依次带入到前一个当中,最后就能得到
d ( v ) ≤ d ( s ) + l ( v , v 1 ) + l ( v 1 , v 2 ) + l ( v 2 , v 3 ) + ⋯ + l ( v k , s ) ⏟ v 到 s 的最短路径的长度 d(v)\leq d(s)+\underbrace{l(v,v_{1})+l(v_{1},v_{2})+l(v_{2},v_{3})+\cdots+l(v_{k},s)}_{\text{$v$到$s$的最短路径的长度}} d(v)d(s)+vs的最短路径的长度 l(v,v1)+l(v1,v2)+l(v2,v3)++l(vk,s)   前面我们已经规定了 d ( s ) = 0 d(s) = 0 d(s)=0,所以上面不等式的右边刚好是 v v v s s s 之间的最短路径的长度。 d ( v ) d(v) d(v) 不可能比最短路径的长度还小(否则就不叫最短路径了),所以只能等于最短路径的长度。哈哈,结论是大快人心的——所有节点的值都是最小的,而且从所有节点出发进行回溯得到的路径都是连接起点的最短路径,如图 8(b) 所示,我用不同颜色和宽度的线将起点到其它节点的最短路径画出来了(这里称“起点”为“终点”似乎更合适,因为它看起来像个盆地,周围的水流都汇聚到它这里了)。值得注意的是,所有节点的最短路径组成一个树形结构,这好像是对规律1的回应。
  我们不仅得到了起点到目标点的最短路径,还顺便把起点到所有节点的最短路径都找出来了。问题解决了,到了说再见的时候了吗?如果你对这个计算结果还满意的话,那么确实可以结束了。但如果你是个完美主义者,这个方法还值得进一步雕琢。在大型的“图”中,例如有 6000 6000 6000 个节点, 12000 12000 12000 条边的网格图, n n n 至少要取 75 75 75,程序要做 12000 × 75 × 2 = 1800000 12000\times75\times2=1800000 12000×75×2=1800000 次松弛条件判断,这就导致程序非常缓慢。这个方法应该还有改进的空间。你也许会问:为什么是对所有边松弛,而不是对所有节点松弛呢?其实,二者是一样的,由于我们是从边的缩短联想到橡皮筋松弛的,所以选择从边的角度讲解更自然。当然,从节点的角度进入是一样,无论结果还是计算效率。

2.3 标记法 (Labeling method)

  上一节采用的方法称为“所有边依次松弛方法”。我为什么要强调其中的“依次”呢?因为程序是按照边定义的顺序(也就是在集合 E E E 中出现的顺序)挨个判断是否需要松弛。可是,边“真正”被松弛的顺序是怎样的呢?我们回到图 7,从图中可以看到节点值的改变是从起点附近开始并逐步向外扩展。(我们知道节点值的改变意味着发生松弛)二者顺序的不同导致程序中有很多条件判断是不满足的(边并没有被松弛),这就影响了程序的效率。更好的选择边的顺序能减少不必要的判断,从而能够改善过程的运行效率。我们在对节点的值初始化时,将除起点以外的节点都设为 ∞ ∞ ,唯独将起点的值设为 0。想象一下,如果将起点的值也设为 ∞ ∞ 会有什么后果。后果很简单,那就是所有边的松弛条件都不满足,因此所有节点的值都会保持在 ∞ ∞ 上不变,显然程序无法找到最短路径。将起点值拉低(从 ∞ ∞ 降到 0 0 0),便使起点的邻居满足松弛条件,所以这些节点的值会降低,而这些值发生变化的节点又会使它们的邻居满足松弛条件并使值降低,进而引起连锁反应。
  所以我们需要特别注意值发生变化的那些节点,只有它们的邻居才会松弛。为了利用这一信息,我们将节点分为两类:值发生变化的节点和值没变化的节点。为了区分这两种节点,我们给每个节点一个标签(label)。给那些值发生变化的节点发一个 changed 标签,而给那些值没变化的节点发一个 unchanged 标签。下面我们给出“所有边依次松弛”方法的改进,这就是“标记法”(labeling method)。按照命名的规则,名字应该体现事物的本质特征,这里我们使用“标记”,原因就在于这是它区别于前辈的主要特点。标记法的伪代码如 Algorithm 4 所示。与它的前辈不同的是,我们不再需要人工试探如何选择循环次数 n n n 了。下面我们解释代码的含义:
  1. 首先同样是初始化,这次我们多了一步 —– 定义 C h a n g e d L i s t ChangedList ChangedList列表,它存储了所有携带changed 标签的节点。初始时,我们只将 changed 标签发给起点(认为起点的值从 ∞ ∞ 变为 0 0 0),而其它节点手里都拿着 unchanged 标签。
  2. while 循环依次从 C h a n g e d L i s t ChangedList ChangedList列表中取出一个节点,就将它记为 a a a 吧。注意这里“取出”是选择的意思,被取出的节点实际仍然在列表中,而“踢出”才是真正从列表中把它删掉。
  3. for 循环依次取出 a a a 的邻居,记为 b b b。这里 n e i g h b o r s ( a ) neighbors(a) neighbors(a) 表示 a a a 的所有邻居组成的列表。
  4. if 判断语句我们已经见过了,它仍然负责判断松弛条件。不过这次我们判断 a a a 所有的邻居。如果有邻居满足松弛条件,那么除了调用 Relax 函数外,还要把这个邻居添加进 C h a n g e d L i s t ChangedList ChangedList列表。为什么要添加邻居呢?因为邻居被松弛了,所以它的值改变了,我们应该把它的标签换成 changed。
  5. for 循环结束后, a a a 的所有邻居都被扫描了一遍 (也就是判断了一遍),满足松弛条件的得到了松弛。此时,我们要将 a a a C h a n g e d L i s t ChangedList ChangedList列表里踢出去( a a a 的标签换成了 unchanged),因为 a a a 已经暂时完成了自己的使命:松弛自己的邻居。除非a 的值改变了,否则它不能再一次松弛它的邻居了 (松弛一遍后就不满足松弛条件了)。即便我们将 a a a 留在 C h a n g e d L i s t ChangedList ChangedList列表中,它也没什么用了。 a a a 还会不会回到 C h a n g e d L i s t ChangedList ChangedList 列表中呢?有这个可能,这时 a a a 的值一定是被自己的邻居改变了。

  “对所有边依次松弛”的方法只会傻傻地挨个扫描(判断)每条边,如果满足松弛条件就执行松弛。标记法则聪明的多,在扫描边时,它会挑剔地选择那些最有可能发生松弛的边,然后再去决定是不是真的需要松弛,而最有可能发生松弛的边就是有节点值变化的边。所以标记法的扫描次数要少得多。在有 6000 6000 6000 个节点, 12000 12000 12000 条边的大型网格图中,标记法平均只需要做 25000 25000 25000 次左右的松弛条件判断。而二者得到的结果是一样的。
  我们前进了一大步,这值得庆祝一下!不过我们还可以再接再厉。标记法仍给我们预留了改进的空间:比如第 3 步中“依次取出 a a a 的邻居”。“依次”只是指一个挨一个的取出,并没说从谁开始,我们也没有规定 a a a 的邻居是按什么顺序排列的。利用节点值的变化这一信息,我们排除了大量无效的判断,但还有一个信息我们没有用过—–节点值的大小。为什么会想到节点值的大小呢?节点值的大小对程序的运行效率能有什么影响呢?这时,我们的脑海里还没有什么概念。
  下面这个例子也许能给我们一些启示 (代码可见文件 Example 2.nb)。图 9(a) 展示了一颗“树形”图,我们只需要关注树根和树干部分即可。这部分非常简单,由 4 个节点组成 —— 起点 s s s 位于左下角, s s s 节点有两个邻居: a a a 节点和 b b b 节点, ( b , c ) (b,c) (b,c) 边组成树干部分。我们假设 ( s , b ) (s,b) (s,b) 的边长 l ( s , b ) = 5 l(s,b) = 5 l(s,b)=5,而其它所有边的长度都是单位长度 1。你可能注意到了,三角形 △ s a b \triangle sab sab 的两边之和小于第三边 (1 + 1 < 5)。这是因为此处“边长”不代表传统的距离概念。实际上,我们不必总是局限于距离,边可以对应任意的代价 (或者称为权重,但前提是它不能是负数),比如时间或能量,这样得到的就是时间最短或能量最小的路径。而时间或能量等概念不必遵循三角形两边之和大于第三边的规则。  
  下面我们使用标记法求最短路径。首先进行初始化,起点 s s s 被添加到 C h a n g e d L i s t ChangedList ChangedList列表中,各节点的值为 d ( s ) = 0 d(s) = 0 d(s)=0 d ( a ) = d ( b ) = d ( c ) = ∞ d(a) =d(b) =d(c) = ∞ d(a)=d(b)=d(c)=。这时 C h a n g e d L i s t ChangedList ChangedList列表只包含 s s s 一个元素,所以取出 s s s。然后程序会依次扫描 s s s的所有邻居,也就是 a a a b b b。我们并没有规定邻居在 n e i g h b o r s ( s ) neighbors(s) neighbors(s) 中是按照什么顺序出现的(可以是 { a , b } \{a,b\} {a,b},也可以是 { b , a } \{b,a\} {b,a}),所以它们的顺序不影响最终得到的结果。但是它们的计算过程是一样的吗?我们记录下程序每一次扫描后 C h a n g e d L i s t ChangedList ChangedList列表中元素的个数,结果如图 9(b) 所示。从图中可以看到,二者不仅需要的扫描次数不同,而且每次扫描产生的 C h a n g e d L i s t ChangedList ChangedList元素个数也不同。选择邻居顺序的微小差别为什么会导致计算过程的明显差异?下面我们详细分析一下程序的执行过程,看看问题到底出在哪:

  1. 如果是按照 { a , b } \{a,b\} {a,b}的顺序 (我们将外层的while循环每执行一次称为一轮扫描):

  2. 如果是按照 { b , a } \{b,a\} {b,a}的顺序:

  如果我们将松弛过程视为节点值的传递(由小到大),那么 c c c第一次被添加时,它的值传递给了后续节点(也就是树叶上的节点);当 c c c第二次被添加时,它的值被邻居 b b b变小了,这个更小的值又一次传递给树叶节点。其结果就是树叶上的每个节点都被松弛了两次(一次被较大的 d ( c ) = 6 d(c)=6 d(c)=6,一次被较小的 d ( c ) = 3 d(c)=3 d(c)=3)。这就解释了图9(b)中先扫描 b b b比先扫描 a a a多了几乎一倍的扫描次数。
2.4 改进的标记法 (Modified labeling method)
  图9(a)所示的例子给了我们一个启示,那就是在访问邻居时应该遵守一定的规则——应该先去敲值最小的邻居的门。让我们的思维稍微跳跃一下,既然访问邻居要按照最小原则,那么从 C h a n g e d L i s t ChangedList ChangedList列表中选择节点是不是也应该遵循这样的规则呢。为了验证这个猜想,我们做个试验。下面我们对标记法做一个小小的修改,如Algorithm 5中红色字体显示的。我们用改进的标记法解决图9(a)的例子,结果表明我们的猜测是对的。而且我们还发现,这时即使选择邻居时不按照最小原则,对结果也没有影响。其实我们仔细思考一下就会想到一点,访问邻居的先后顺序并不重要,它之所以会影响程序的扫描次数,是因为邻居进入到了 C h a n g e d L i s t ChangedList ChangedList列表,程序从 C h a n g e d L i s t ChangedList ChangedList中取出节点时是按照节点被添加的顺序(也就是访问邻居的顺序)。所以,从 C h a n g e d L i s t ChangedList ChangedList列表取节点的策略才是影响程序效率的关键。我们的结论是:从 C h a n g e d L i s t ChangedList ChangedList列表中取节点时,先取值最小的那个。


2.5 Dijkstra算法

             “Everything should be made as simple as possible, but not simpler.”
                                                 ------ 爱因斯坦

  我们回过头来看看改进的标记法(Algorithm 5)。即便你是一个完美主义者,你也不得不承认,它已经相当简洁了。短短十行代码就能解决看似困难的最短路径问题。爱因斯坦说过:“任何事情都应该尽量简单,而不是更简单”。这句看似矛盾的话应该怎么理解呢?我认为,对于我们试图解决的最短路径问题来说,追求“尽量简单”就是尽量去除算法中多余的东西,这样我们的算法才能轻装上阵,执行效率才会更高。从这个角度看,“简单”是个优点;可是物极必反,如果我们过分追求简单(总想着“更简单”),把简单(而不是算法的执行效率)当成我们唯一的目的,那么我们就钻进了牛角尖,违背了我们的初衷 —— 设计更好更快的算法。我丝毫不怀疑你能写出更简单的算法,但是在追求简单和运算效率二者之间,请保持平衡,而这才是最难做到的。
  Dijkstra 是平衡的大师。以他的名字命名的 Dijkstra 方法在不牺牲执行效率的前提下,比我们的改进标记法更加简单。Dijkstra 方法 (如 Algorithm 6 所示)只需要一个列表 Q Q Q (类似于 C h a n g e d L i s t ChangedList ChangedList,但存储的内容不同)。程序的运行过程也极其简单,在一开始,所有的节点都被放进 Q Q Q 列表中。然后从 Q Q Q 中取出值最小的节点(记为 a a a),并对它的邻居进行判断并松弛。扫描完 a a a 的所有邻居后, a a a 就会被从 Q Q Q 中删除。如此反复,直到 Q Q Q 为空时算法停止。由于只从 Q Q Q 列表中拿出,从不往里存,所以while 循环运行的次数刚好是“图”中节点的个数。

  虽然 Dijkstra 方法很简单,但是从代码的字里行间,我们看不出来它为什么能找到最短路径。下面我们从逻辑上分析一下:
  在程序运行之前,所有的节点都是未访问节点(即 Q = V Q = V Q=V )。随着程序的运行,未访问节点逐渐转变为已访问节点,直到最后所有节点都被访问了,这时程序就停止了。Dijkstra 方法与改进的标记法最大的不同之处是,节点一旦被从列表中踢出就再也不会放进去了。这说明 Dijkstra方法认为,被踢出的节点值不会再减小了,它已经达到最小了。一旦确定了节点的最小值,最短路径也就确定了(通过回溯找到)。
  为了证明 Dijkstra 方法确实能找到最短路径,我们只需要证明被踢出节点的值就是它的最小值。在证明之前,先定义一个概念。我们将从起点 s s s 出发到达任意一个节点 v v v 的最短路径的长度表示为 δ ( s , v ) δ(s,v) δ(s,v),因为 s s s 一般是固定不变的,所以也可以简写为 δ ( v ) δ(v) δ(v)。“被踢出节点的值就是它的最小值”可以表示为 d ( v ) = δ ( v ) d(v) = δ(v) d(v)=δ(v),这里 v v v 表示被踢出的节点。
  下面的证明采用了数学归纳法,这需要两步证明:
  第一步证明命题在第 1 个节点的情况下成立。这很容易,因为起点的值最小,所以第一个被从 Q Q Q 中踢出来的节点就是起点 s s s。由于 d ( s ) = 0 d(s) = 0 d(s)=0 而且 δ ( s , s ) = 0 δ(s,s) = 0 δ(s,s)=0,所以 d ( s ) = δ ( s ) d(s) =δ(s) d(s)=δ(s),因此命题成立。
  第二步证明如果命题在前 n n n 个节点成立,那么对于前 n + 1 n + 1 n+1 个节点也成立。也就是:前 n n n 个被踢出节点都满足 d ( u ) = δ ( u ) ; u ∈ V − Q d(u) =δ(u); u∈V-Q d(u)=δ(u);uVQ V − Q V-Q VQ 的意思是从 V V V中踢出 Q Q Q后剩余的部分),需要证明第 n + 1 n + 1 n+1 个被踢出来的节点 v v v 也满足 d ( v ) = δ ( v ) d(v) =δ(v) d(v)=δ(v)。这可需要动动脑子了。
  第二步的证明: 根据 Dijkstra 方法的规则,值最小的节点最先被踢出来。所以节点 v v v 在被踢出来之前一定是 Q Q Q 里值最小的。我们猜猜看 v v v 的值会是什么样的。
  1. d ( v ) d(v) d(v) 会是 0 0 0 吗?(我们允许边长为 0 0 0 的情况)如果 d ( v ) = 0 d(v) = 0 d(v)=0,那么它的真实最短路径长度 δ ( s , v ) δ(s,v) δ(s,v) 也一定是 0 0 0。节点的值 d ( v ) d(v) d(v) 一定不会小于它的最小值(最短路径的长度 δ ( s , v ) δ(s,v) δ(s,v)),因为路径的长度一定不会小于最短路径的长度,这是无论如何也不会改变的事实。既然 d ( v ) > δ ( s , v ) d(v) > δ(s,v) d(v)>δ(s,v) δ ( s , v ) δ(s,v) δ(s,v) 又不能是负数,所以只能等于 0 0 0。这样我们就得出 d ( v ) = δ ( v ) d(v) = δ(v) d(v)=δ(v),所以命题成立。
  2. d ( v ) d(v) d(v) 会是无穷大吗?如果 d ( v ) = ∞ d(v) = ∞ d(v)=,那么 Q Q Q 里所有节点的值都是无穷大。这说明 Q Q Q 里的所有节点都不能从起点s 到达。当然它们真实的路径长度可以视为无穷大, δ ( s , v ) = ∞ = d ( v ) δ(s,v) = ∞ =d(v) δ(s,v)==d(v) 。所以还是命题成立。但是本文一开始我们就规定,任何节点都有至少一个邻居,所以总是能从 s s s 出发到达任何节点。这与我们的规定矛盾了,所以 d ( v ) ≠ ∞ d(v)≠ ∞ d(v)=
  3. 排除了以上两种极端的情况,唯一剩下的就是 0 < d ( v ) < ∞ 0 <d(v) <∞ 0<d(v)< 了。既然 d ( v ) d(v) d(v) 有确定的数值,那说明 s s s v v v 之间肯定存在至少一条路径。我们不关心存在多少条路径,我们只关心现在最短的那条(注意我并没有说它是真正的最短路径,它只是程序运行到目前为止找到的路径里最短的一条)。虽然我们不知道这条路径经过哪些节点,但我们可以分成几种可能,从而分别讨论:

   ▶ \blacktriangleright  如果这条路径不经过 Q Q Q 中的节点,那么这条路径就是 s s s v v v 之间真正的最短路径。你可能会怀疑,因为还没有扫描完所有的边呢,怎么能这么早就下结论呢?为了证明这一点,我们用图 10 进行解释,其中的黑色节点表示被踢出来的节点,白色节点表示在 Q Q Q 中的节点,虚线内的区域包含所有被踢出来的节点,曲线表示路径(为了保持画面简单,路径上的节点被省略了),直线表示一个边。图 10(a) 符合我们假设的情况, s s s v v v 之间存在至少一条路径,准确的说是两条: p 1 p1 p1 p 2 p2 p2,而且这两条路径不经过 Q Q Q 中的节点。假设 p 2 p2 p2 路径更短。如果 s s s v v v 之间真正的最短路径比 p 2 p2 p2 更短,我们将真正的最短路径记为 p 3 p3 p3。我们将注意力放到路径的最后一条边上 (连接 v v v 的边)。根据最后一条边上节点 c c c 的位置,路径 p 3 p3 p3 可以分为两种, c c c 要么不在 Q Q Q 中,要么在 Q Q Q 中,分别如图 10(b)、© 所示的例子。我们还是分情况讨论:
  先看第一种情况,如果 c c c 不在 Q Q Q 中(图 10(b)),说明它已经被处理完了。根据第二步最开始的假设: d ( c ) = δ ( c ) d(c) = δ(c) d(c)=δ(c),而且 v v v 作为 c c c 的邻居必然被松弛了,松弛后 d ( v ) d(v) d(v) 已经到达最小了。既然 d ( v ) d(v) d(v) 已经是最小值了,那我们前面为什么还要选择更长的路径 p 2 p2 p2 呢?这不是矛盾的吗?所以不能有比 p 2 p2 p2 更短的路径,否则我们应该选择更短的路径,怎么会轮到 p 2 p2 p2 呢。
  再看第二种情况,如果 c c c Q Q Q 中(图 10©),那么应该有 d ( c ) < d ( v ) d(c) < d(v) d(c)<d(v)。这是因为 c c c v v v 的母节点,而且边长 l ( c , v ) > 0 l(c,v) > 0 l(c,v)>0(如果边长 l ( c , v ) = 0 l(c,v) = 0 l(c,v)=0 就找 c c c 的前一个节点,如果一直找不到就回到情况一了)。可是因为 v v v 的值最小才被从 Q Q Q 中踢出来,既然 d ( c ) < d ( v ) d(c) < d(v) d(c)<d(v),应该踢 c c c 而不是 v v v,这又是矛盾的。
  综上所述,这两种情况都不成立,所以目前找到的这条路径就是 s s s v v v 之间真正的最短路径。
   ▶ \blacktriangleright  如果这条路径经过 Q Q Q 中的节点,证明过程与上面的第二种情况一样,应该有一个节点 c c c Q Q Q 中,所以 d ( c ) < d ( v ) d(c) <d(v) d(c)<d(v)。应该踢 c c c 而不是 v v v。所以这条路径不能经过 Q Q Q 中的节点。                                                                            □ \square
  这样我们就证明了,每个被踢出去的节点 v v v 都满足 d ( v ) = δ ( s , v ) d(v) = δ(s,v) d(v)=δ(s,v)
  证明 Dijkstra 方法花了我们不少力气。你可能会好奇——Dijkstra 到底是怎么想出这个方法的。下面我们来了解一下背景。

2.6 Dijkstra和他的算法

  Edsger Wybe Dijkstra 的父亲是高中化学老师,母亲是业余数学家。1956 年从莱顿大学数学和理论物理专业毕业后,Dijkstra 到阿姆斯特丹大学攻读博士,3 年后毕业,毕业论文题目是《自动计算机的通信方式》,研究内容是第一代商业计算机的汇编语言设计。Dijkstra 终生过着斯巴达式的简朴生活,不看电视、不看电影、也几乎不使用手机,为数不多的爱好是和夫人弹钢琴和听音乐会 [ 1 ] ^{[1]} [1]
  1956 年,Dijkstra 在阿姆斯特丹数学中心工作期间被指派了一项任务:为演示新建造的计算机而设计一个数学问题并编写对应的求解程序。所设计的问题要能够展示计算机的强大性能,同时越简单越好,以便于被更多的人理解。Dijkstra 挑选了一个最短路径问题:在荷兰的 64 个城市之间寻找最短的运输路线,随后他开始思考求解方法。一天与未婚妻在咖啡馆里消遣时,Dijkstra 花了20分钟构思出了这个问题的解决方法,Dijkstra 算法由此诞生。三年后,Dijkstra 将这一方法连同对另一个相关问题的解法撰写成论文,发表在学术期刊上。论文题目是 A Note on Two Problems in Connexion with Graphs。这篇论文只有两页半长,里面没有出现一个数学公式,没有一幅图,甚至连一个例子也没有。就是这样一篇论文,迄今为止已经被引用了超过 17000 次。(事实上,Dijkstra 并不是 Dijkstra 算法的最早发现者,其思想至少在 1950 年代早期就出现了,只不过在当时只流传于几个小圈子里)

  在这篇简短的论文中,Dijkstra 介绍了最短路径问题后,紧接着写下了一句耐人寻味的话。然后,他描述了算法运行的逻辑和实现的细节。我们要是想知道 Dijkstra 是如何灵感迸发,从而构思出这个优雅的算法的,就只能仔细读读这句话了,如下:  

“We use the fact that, if R R R is a node on the minimal path from P P P to Q Q Q, knowledge of the latter implies the knowledge of the minimal path from P P P to R R R. ”

  我们借助这样一个事实:如果 R R R 是从 P P P Q Q Q 的最短路径上的一个节点,那么知道了后者( P P P Q Q Q 的最短路径)就等于知道了 P P P R R R 的最短路径。
  这句话也许让你觉得似曾相识。没错!那就是本文最开始得到的规律1。只不过,Dijkstra 将目光放在了起点 P P P 到任意节点 R R R 上(换句话说,从前向后推)。
  规律1有一个正式的名字 —— “最优性原理”,它的正式提出者 Richard Bellman 是这样说的:

“An optimal policy has the property that whatever the initial state and initial decision are, the remaining decisions must constitute an optimal policy with regard to the state resulting from the first decision.”

  任何一个最优策略都有这样的性质:不管初始状态和初始决策是什么,随后的决策相对于初次决策之后的状态必然构成最优策略。

  Bellman 将目光放在了任意节点到目标点上 (换句话说,从后向前推)。我们得到的规律1只是“最优性原理”在最短路径问题上的一个特例。最优性原理也是一类数学方法 —— 动态规划(Dynamic Programming)的理论基础。Bellman 早在1940年代就开始了动态规划理论的研究,1950~1954 年一系列的论文和报告标志着动态规划理论的成熟。我们无从得知 Dijkstra 是否了解 Bellman 的工作或者从中受到启发,因为他在文中并没有提到 Bellman 的工作,而只引用了另一个学者 Ford 的报告。当时 Ford 和 Bellman 是同事,二人共同就职于大名鼎鼎的兰德公司(RAND) [ 2 ] ^{[2]} [2]。他们早于 Dijkstra 提出了著名的 Bellman–Ford 算法,其适用于边的权重为负数时的最短路径问题(也是 Dijkstra 算法不能适用的场合)。显然 Dijkstra 意识到了“最优性原理”,但是他止步于最短路径问题。而 Bellman 作为一名职业数学家,他的眼光要深远得多。
  到此为止,关于 Dijkstra 算法我们就告一段落了。你可能会好奇,Dijkstra 算法还有改进的余地吗?我觉得,Dijkstra 算法已经是较“原始”的算法了,它的适用范围和性能也难以满足今天的需求了。对它的改进一直在进行中,新的算法层出不穷( A ∗ A^* A算法、跳点搜索算法(Jump Point Search)、快速扫描法…),我们才刚刚起步。但是不管怎样,Dijkstra 算法仍然是基础。要理解新的算法,Dijkstra 算法不可错过,这也是为什么 Dijkstra 的论文(一篇60年前的计算机算法论文)直到今天还在被人引用。

  代码下载地址:http://pan.baidu.com/s/1dFp4bxJ
  [1] R A Krzysztof, 2002, Edsger Wybe Dijkstra (1930–2002): A Portrait of a Genius. Formal Aspects of Computing, 14:92–98.
  [2] M Sniedovich, 2006, Dijkstra’s Algorithm Revisited: the Dynamic Programming Connexion. Control and Cybernetics, 35(3):599–620.

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
©️2022 CSDN 皮肤主题:技术黑板 设计师:CSDN官方博客 返回首页
评论 6

打赏作者

robinvista

你的鼓励将是我创作的最大动力

¥2 ¥4 ¥6 ¥10 ¥20
输入1-500的整数
余额支付 (余额:-- )
扫码支付
扫码支付:¥2
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、C币套餐、付费专栏及课程。

余额充值