Dijkstra算法入门

转载:https://blog.csdn.net/robinvista/article/details/61421034 css

前言

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

1. 最短路径问题

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

web


   答案揭晓,最短路径就是图 1(b) 中的黄色线段(为了突出它,我特地画得比普通的边粗一些)。由于两点间直线段最短,起点和目标点之间恰好存在这样组成直线段的边。你可能以为这太简单了,甚至有智商被侮辱的感受。事实偏偏相反,这个例子不是太肤浅了,而是太深入了。咱们能够从中找到一条规律,这条规律过重要了,以致于我不得不将它单独放在一段:
  
   规律 0 :一条直线段上任意两点之间的那部分线段仍然是直线段。
  
   数学家们喜欢干的一件事就是推广——将特殊推广到通常,将简单推广到复杂。好比牛顿的第一个数学发现就是将二项展开式的指数从正整数推广到负数和分数。咱们也来试着将前面这条规律推广一下,因而就获得了下一条规律:
  
   规律 1 :一条最短路径上任意两个节点之间的那部分路径仍然是它们的最短路径。
  
   根据咱们的平常经验,规律1彷佛是对的,可是咱们要从逻辑上证实它的正确性。若是对的不容易发现破绽,那咱们就反其道而行之,从错的开始推导。咱们假设规律 1 是错的,也就是说:最短路径上存在两个节点,它们之间的那部分路径不是最短路径。图 2(a) 展现的例子就是这种状况,在起点 s 和目标点 t 之间的黄色曲线是它们的最短路径。在这条最短路径上,有两个节点 a b a b 之间的最短路径(蓝色曲线)比黄色曲线上的那部分更短。从图中能够看到,节点 a b 将黄色曲线分红了三段,即 s - a 段、 a - b 段和 b - t 段。三段长度之和就是 s t 的最短路径的长度,咱们用 L m i n 表示。若是咱们用蓝色曲线替换掉黄色曲线上的 a - b 段,如图 2(b) 左侧所示,那么这个从新组合获得的新路径长度显然小于 L m i n 。新的路径比 s t 之间的最短路径(黄色曲线)还短,这显然是矛盾的,也就说明规律1是正确的。
  想一想看,咱们能不能将规律1换种说法:一条最短路径上的任意两个节点之间的最短路径仍然在这条路径上。看起来好像差很少,但实际上是不严谨的,由于咱们并不知道最短路径是否是惟一的。若是任意两个节点之间的最短路径都只有一条,那么这样说就是对的。可是在有些状况下,两个节点之间的最短路径可能会有不止一条 (它们的长度都是最短的,但通过的节点不一样)。因此咱们仍是应该采用规律1的说法。

2. 搜索算法

2.1 松弛 (relax)

  啊哈!这个小世界开始有意思起来了,咱们发现了其中的一条规律。但别高兴的太早,咱们怎么利用这条规律呢?若是我给你一条路径,你能够用规律 1 来验证它究竟是不是最短的。若是你能在这条路径上找到两个节点,在它们之间有更短的路径,那你能够自信地说我给你的路径确定不是最短的。注意:规律 1 的重点是“最短路径上”。非最短路径上也可能包含最短的子路径;而两个最短路径拼接到一块儿获得的路径未必是最短的。规律 1 没有告诉咱们怎么计算最短路径。咱们试试把规律 1 反过来是什么,这样就获得了另外一条规律:
  
  规律 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 ) 边和 ( b , c ) 边表示的路,如今你知道超近道直接走 ( a , c ) 边更快。每超一次近道,路径就会短一些,直到你最终找到最短的路径。
  依照上面几步操做咱们最终总能找到最短路径。可这是一个好方法吗?看起来彷佛不太好。首先咱们并不知道运行多少步才能找到短路径。假如你迷路了,向别人问路。那人给你指了一个方向却没告诉你还有多远,你会不会内心没底。其次是第 2 步,很明显第 2 步自己就是一个最短路径问题,它如何求解咱们仍是不知道。
  虽然上述方法缺乏实用价值,但至少它的方向是对的,咱们能够从中受到启发。这个方法能够形象的比做被抻长的橡皮筋恢复的过程。若是将路径视为橡皮筋,那么路径的长度就对应橡皮筋中储存的弹性势能。最短路径就是天然状态下(不受外力)的橡皮筋,它不会再缩短了。开始随意肯定的路径至关于被抻长的橡皮筋,而之后每一次超近道均可以当作橡皮筋在自身弹力做用下缩短恢复的过程。咱们称这一过程为“松弛”(relax),意思就是松开抻长的橡皮筋,让它缩短从而释放掉多余的弹性势能,如图 5 所示。

   松弛现象很容易理解,可是松弛发生的前提条件是什么呢?让咱们将注意力集中到路径中的某一条边,如图 6(a) 所示。假设一条路径从起点 s 节点出发,依次通过 a 节点和 b 节点(可是 ( a , b ) 边不在这条路径上)。再假设 s 节点与 a 节点间的路径长度为 2,那么咱们就认为 a 节点的能量是 2。 a 节点与 b 节点间的路径长度为 6,咱们认为 b 节点的能量是 2 + 6 = 8。边 ( a , b ) 的长度是 3,路径若是通过 ( a , b ) 边, b 节点的能量就是 2 + 3 = 5。 b 节点的能量降低了(5 < 8)。因此,一条边松弛发生的条件是要可以下降边上节点的能量。反过来想,若是路径通过 ( a , b ) 边,没有下降 b 节点的能量,那么说明 a , b 间的路径长度小于或等于 ( a , b ) 边的长度,此时不该该松弛。
   之后咱们正式称呼一个节点的能量(或距离)为它的值,一个节点 a 的值表示为 d ( a ) 。一个节点的值定义为链接起点和这个节点的路径的长度。若是链接起点和这个节点的路径不止一条,咱们只选择最短的那条路径。显然,若是咱们找到了起点和这个节点之间的最短路径,那么节点的值就是最小的了,它不会再变小了。反之,若是节点的值是最小的,那么咱们就知道了最短路径的长度了。
  松弛的过程很简单,用程序实现也不复杂。为了便于理解,我把松弛程序用伪代码写出来,如 Algorithm 1 所示。Relax 函数负责实现松弛,它的输入是两个相邻的节点 a b 。注意Relax( a , b ) 的输入是区分顺序的。 d ( b ) d ( a ) + l ( a , b ) 表示对 ( a , b ) 边松弛,也就是令 b 节点的值等于 d ( a ) + l ( a , b ) (更准确的说,是对 b 节点松弛,由于 a 节点的值没变)。结束赋值后还没完,咱们还要记录下是谁让 b 节点的值下降。咱们让 a 节点做为 b 节点的“母亲节点”。 b 节点能够有不少邻居,可是只能有一个“母亲”。而 b 节点是那种“有奶即是娘”的节点:谁让它的值下降,它就认谁作娘。咱们用 p ( b ) 表示 b 节点的“母节点”。

   终于找到了一件稍微顺手点的工具了,咱们应该如何使用它呢?回想图 3(b) 的例子,若是咱们对初始路径上每两个相邻节点之间的边进行松弛,就获得如图 6(c) 所示的新路径。让人欣慰的是,新路径确实更短了,可是好像还远远不是最短的路径。这是为何呢?注意在松弛时,咱们只考虑了一部分边,也就是两端节点都在初始路径上的边,由于只有这些节点的值是已知的,这样咱们才能判断松弛条件是否知足。但是若是初始路径并不经过最短路径的节点,那么再怎么松弛也不会获得最短路径。

2.2 全部边依次松弛

  咱们知道了只松弛一部分边达不到理想的效果,缘由就是初始路径不必定与最短路径有同样的节点。固然,咱们不知道最短路径通过哪些的节点。可否扩大范围,对全部的边都松弛呢?固然能够。只是除了起点以外,咱们对其它全部节点的值都不清楚,这意味着咱们没法判断松弛的条件。不过,咱们能够认为起点的值是0,由于起点到起点的路径最短就是0,不会有比0更短的路径了。这时咱们再也不须要先寻找一个初始路径了。咱们能够将其它全部节点的值都认为是无穷大,也就是说没有路径到达它们。每应用一次松弛,它们的值都会改变一点。咱们能够编程实现这个过程,如 Algorithm 2 所示。
编程


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

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

   松弛完后,咱们怎么找到通往目标节点的路径呢?答案是,咱们在松弛边的时候,相应节点的母节点同时也就肯定了。咱们能够从目标节点开始,先找到它的母节点,这个母节点也有本身的母节点,咱们能够一直向回追溯(backtrack),直到其中一个母节点是起点为止。起点到目标点的路径就由这一系列相邻母节点定义的边组成。这一过程如 Algorithm 3 所示。
   将目标点带入回溯函数,获得的路径如图8(a) 所示,它看上去确实比以前的短多了。可是咱们心中有个大问号——这样获得的路径是最短的吗?或者更准确地问:当“图”中的全部边都不能再松弛时,全部节点的值都是最小的吗? 咱们证实一下:假设某个节点 v 与起点 s 之间的最短路径是 v v 1 v 2 v 3 v k s 。既然这条路径上的每条边都不知足松弛条件,那就有
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 )
   将后一个不等式依次带入到前一个当中,最后就能获得
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 ( s ) = 0 ,因此上面不等式的右边恰好是 v s 之间的最短路径的长度。 d ( v ) 不可能比最短路径的长度还小(不然就不叫最短路径了),因此只能等于最短路径的长度。哈哈,结论是大快人心的——全部节点的值都是最小的,并且从全部节点出发进行回溯获得的路径都是链接起点的最短路径,如图 8(b) 所示,我用不一样颜色和宽度的线将起点到其它节点的最短路径画出来了(这里称“起点”为“终点”彷佛更合适,由于它看起来像个盆地,周围的水流都汇聚到它这里了)。值得注意的是,全部节点的最短路径组成一个树形结构,这好像是对规律1的回应。
   咱们不只获得了起点到目标点的最短路径,还顺便把起点到全部节点的最短路径都找出来了。问题解决了,到了说再见的时候了吗?若是你对这个计算结果还满意的话,那么确实能够结束了。但若是你是个完美主义者,这个方法还值得进一步雕琢。在大型的“图”中,例若有 6000 个节点, 12000 条边的网格图, n 至少要取 75 ,程序要作 12000 × 75 × 2 = 1800000 次松弛条件判断,这就致使程序很是缓慢。这个方法应该还有改进的空间。你也许会问:为何是对全部边松弛,而不是对全部节点松弛呢?其实,两者是同样的,因为咱们是从边的缩短联想到橡皮筋松弛的,因此选择从边的角度讲解更天然。固然,从节点的角度进入是同样,不管结果仍是计算效率。

2.3 标记法 (Labeling method)

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

markdown

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


   1. 若是是按照 { a , b } 的顺序 (咱们将外层的while循环每执行一次称为一轮扫描):
   2. 若是是按照 { b , a } 的顺序:
   若是咱们将松弛过程视为节点值的传递(由小到大),那么 c 第一次被添加时,它的值传递给了后续节点(也就是树叶上的节点);当 c 第二次被添加时,它的值被邻居 b 变小了,这个更小的值又一次传递给树叶节点。其结果就是树叶上的每一个节点都被松弛了两次(一次被较大的 d ( c ) = 6 ,一次被较小的 d ( c ) = 3 )。这就解释了图9(b)中先扫描 b 比先扫描 a 多了几乎一倍的扫描次数。

2.4 改进的标记法 (Modified labeling method)

  图9(a)所示的例子给了咱们一个启示,那就是在访问邻居时应该遵照必定的规则——应该先去敲值最小的邻居的门。让咱们的思惟稍微跳跃一下,既然访问邻居要按照最小原则,那么从 C h a n g e d L i s t 列表中选择节点是否是也应该遵循这样的规则呢。为了验证这个猜测,咱们作个试验。下面咱们对标记法作一个小小的修改,如Algorithm 5中红色字体显示的。咱们用改进的标记法解决图9(a)的例子,结果代表咱们的猜想是对的。并且咱们还发现,这时即便选择邻居时不按照最小原则,对结果也没有影响。其实咱们仔细思考一下就会想到一点,访问邻居的前后顺序并不重要,它之因此会影响程序的扫描次数,是由于邻居进入到了 C h a n g e d L i s t 列表,程序从 C h a n g e d L i s t 中取出节点时是按照节点被添加的顺序(也就是访问邻居的顺序)。因此,从 C h a n g e d L i s t 列表取节点的策略才是影响程序效率的关键。咱们的结论是:从 C h a n g e d L i s t 列表中取节点时,先取值最小的那个(买菜先挑便宜的)。svg

2.5 Dijkstra算法

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

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

函数


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

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

2.6 Dijkstra和他的算法


   Edsger Wybe Dijkstra 的父亲是高中化学老师,母亲是业余数学家。1956 年从莱顿大学数学和理论物理专业毕业后,Dijkstra 到阿姆斯特丹大学攻读博士,3 年后毕业。毕业论文题目是:自动计算机的通讯方式,研究内容是第一代商业计算机的汇编语言设计。Dijkstra 终生过着斯巴达式的简朴生活,他不看电视、不看电影、也几乎不使用手机。Dijkstra 和夫人平时喜欢弹钢琴和听音乐会 [ 1 ] 。Dijkstra 可能把大部分时间都花在思考上,他说过一些有意思的话,例如:

  “The question of whether a computer can think is no more interesting than the question of whether a submarine can swim.”工具

  1956 年,Dijkstra 在阿姆斯特丹数学中心工做期间被指派了一项任务:为演示新建造的计算机而设计一个数学问题并编写对应的求解程序。所设计的问题要可以展现计算机的强大性能,同时越简单越好,以便于被更多的人理解。Dijkstra 挑选了一个最短路径问题:在荷兰的 64 个城市之间寻找最短的运输路线,随后他开始思考求解方法。一天与未婚妻在咖啡馆里消遣时,Dijkstra 花了20分钟构思出了这个问题的解决方法,Dijkstra 算法由此诞生。三年后,Dijkstra 将这一方法连同对另外一个相关问题的解法撰写成论文,发表在学术期刊上。论文题目是 A Note on Two Problems in Connexion with Graphs。这篇论文只有两页半长,里面没有出现一个数学公式,没有一幅图,甚至连一个例子也没有。就是这样一篇论文,迄今为止已经被引用了超过 17000 次。(事实上,Dijkstra 并非 Dijkstra 算法的最先发现者,其思想至少在 1950 年代早期就出现了,只不过在当时只流传于几个小圈子里)
post


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

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

  咱们借助这样一个事实:若是 R 是从 P Q 的最短路径上的一个节点,那么知道了后者( P Q 的最短路径)就等于知道了 P R 的最短路径。

  这句话也许让你以为似曾相识。没错!那就是本文最开始获得的规律1。只不过,Dijkstra 将目光放在了起点 P 到任意节点 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 ] 。他们早于 Dijkstra 提出了著名的 Bellman–Ford 算法,其适用于边的权重为负数时的最短路径问题(也是 Dijkstra 算法不能适用的场合)。显然 Dijkstra 意识到了“最优性原理”,可是他止步于最短路径问题。而 Bellman 做为一名职业数学家,他的眼光要深远得多。
  到此为止,关于 Dijkstra 算法咱们就告一段落了。你可能会好奇,Dijkstra 算法还有改进的余地吗?我以为,Dijkstra 算法已是较“原始”的算法了,它的适用范围和性能也难以知足今天的需求了。对它的改进一直在进行中,新的算法层出不穷(双向 Dijkstra 算法、 A 算法、快速扫描法…),咱们才刚刚起步。可是无论怎样,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.

相关文章
相关标签/搜索