在 这个网址有很多讨论这个问题的文章,我看了其中两篇。一篇是《 The K shortest paths problem》,把求前k短路径的问题阐述得很详细,并且介绍了一些经典的算法。但是这篇文章主要讲的是怎么求前k短 可以带环的路径。另一篇是《 The K shortest loopless paths problem》,它就在前一篇的基础上讲了怎么求前k短的 无环路径。虽然它讲得不是很详细,但是有了前一篇文章的基础,还是看得懂的。
根据上面两篇文章所讲的,求前k短 无环路径的最好的算法是 Yen算法(一个名叫Yen的人发名的。但是Yen本人写的文章我下载不到,好像是要收费的。)。作者在文章中也介绍了他自已发明的算法,名叫MPS算法。但是根据我尝试下来,这只是实验性的算法,并不适用于所有情况(横滨赛区有一些很阴险的数据,可以让这个算法超时),所以先不去管它。
光有这两篇文章作为理论支撑并不足以做出2006年横滨的最后一题,因为在实现上还有很多细节和优化方法需要推敲。所以我决定要写一下。
首先我简单地描述一下Yen算法。设P i为从起点s到终点t的第i短的无环路径。一开始,P 1,也就是从s到t的最短路径,可以通过Dijkstra、Bellman-Ford或BFS等算法轻易地求出。接下来要依次求出P 2,P 3……P k。
我们把P 1~P i看成一棵树,称为T i,它的根节点是s,所有叶节点都是t。举个例子,假设s = 1,t = 5,我们求出的P 1~P 5如下:
1 -> 2 -> 3 -> 5 1 -> 3 -> 2 -> 5 1 -> 3 -> 5 1 -> 4 -> 5 1 -> 4 -> 3 -> 5 |
则T 5就是图1所示的一棵树。
图 1
设dev i为P i的 偏离点(deviation node),表示在T i上,P i对应的那一分枝中,第1个(按从s到t的顺序)不在T i - 1上的点(i > 1)。为了说话方便,设dev 1为P 1的第2个点。因此,在图1中,dev 1~dev 5的编号分别为2、3、5、4和3。显而易见,dev i至少是P i的第2个点。
接下来是Yen算法的主要部分。每当求出一个P i时,都可以从P i上发展出若干条 候选路径。发展的方法是这样的,对于P i上从dev i的 前一个点到t的 前一个点这一段上的每个点v,都可以发展出一条候选路径。用P isv表示P i上从s到v的子路径,用P ivt表示从v到t的满足下列表条件的 最短路径:
条件1. 设点v在Ti上对应的点为v',则Pivt上从点v出发的那条边不能与Ti上从点v'出发的任何一条边相同。 |
条件2. Pivt上,除了点v,其它点都不能出现在Pisv上。 |
如果找得出P ivt,则把P isv和P ivt连起来就组成了一条候选路径。条件1保证了候选路径不与P 1~P i重复;条件2保证了候选路径无环。
在图1中的例子的基础上,我们举一个发展候选路径的例子。在求出了P 5之后,我们要在P 5上发展候选路径。P 5的偏离点是3号点。因此v的范围是{4, 3}。
当v = 4时,P isv = 1 -> 4,因此,根据条件1,在P ivt上不能出现1号点。找到P 5上的4号点在T 5上对应的那一点,也就是图2中位于蓝色的3号点上面的4号点,在T 5上从它出发的有(4, 5)和(4, 3)这两条边,因此,根据条件2,在P ivt上不能出现这两条边。假设在这样的情况下,我们求出了从4号点到t的最短路径为4 -> 2 -> 5,它就是P ivt。此时发展出的候选路径就是1 -> 4 -> 2 -> 5。
当v = 3时,P isv = 1 -> 4 -> 3,因此,根据条件1,在P ivt上不能出现1号点和4号点。找到P 5上的3号点在T 5上对应的那一点,也就是图2中蓝色的3号点,在T 5上从它出发的只有(3, 5)这一条边,因此,根据条件2,在P ivt上不能出现边(3, 5)。假设在这样的情况下,我们求出了从3号点到t的最短路径为3 -> 2 -> 5,它就是P ivt。此时发展出的候选路径就是1 -> 4 -> 3 -> 2 -> 5。
图 2
显而易见,在从P i发展出的所有候选路径中,只有当v是dev i的前一个点时,条件1才有可能阻挡掉2条或2条以上边。当v不是dev i的前一个点时,条件1只会阻挡掉1条边,那就是本身位于P i上,从v出发的那条边。
不仅从P i,从之前的P 1~P i - 1,我们都发展过若干条候选路径。从候选路径的集合中取出最短的一条,就是P i + 1。把P i + 1从候选路径的集合里删掉;然后再从它发展新的候选路径,添加到候选路径的集合里,如此循环,直到求出P k为止。如果在求到P k之前,候选路径的集合就空了,那么说明P k不存在。
下面分析一下这个算法的时间复杂度。设图中有N个点,那么每条P i上最多也就有N个点,那么从P i最多发展出N条候选路径。每发展一条候选路径,当中都包含一次求最短路径的过程,它的时间大约是O(N 2)。因此从P i发展候选路径的时间最多要O(N 3)。我们要求k个P i,因此总的时间最差情况下是 O(k * N3)。
理论部分描述完了,下面就要讲一下这个算法在2006年横滨的最后一题中的具体实现和优化。这道题目中有如下约束条件:有向图,图中最多50个点,所有边长都是正数,两点之间最多只有一条边;如果有若干条长度相同的路径,则按照字典顺排名;k不超过200。下面讲一下我的做法,我用的语言是C++。
一、数据结构
一条路径我用vector<int>来存放,候选路径的集合我用priority_queue存放。图用矩阵表示就足够快了,因为这道题中出了很多极限数据,也就是两两点之间都有边,这时邻接链表的优势就没有了。
二、实现条件1
每当一条候选路径P c被发展出来时,它的偏离点dev c我们就知道了;此时我们还知道了当从P c发展新的候选路径时,如果v是dev c的前一个点,则条件1需要阻挡哪些边:就是从P i发展P c的时候条件1阻挡掉的那些边,再加上P c上从dev c的前一个点到dev c的那条边。
比如在图2中,我们发展的P c为1 -> 4 -> 2 -> 5这条路径的时候,立刻就知道了dev c是2号点。在发展它时,条件1把边(4, 5)和(4, 3)阻挡了,再加上P c上的(4, 2)这条边,那么将来在P c上从4号点发展新的候选路径时,条件1需要阻挡的3条边现在都知道了。
我把上述的偏离点和条件1要阻挡的边也存在了每条路径的数据结构里,这样每次发展候选路径时,我就不用为了找这些信息而费劲地去遍历T i了。
知道了条件1需要阻挡的边,我们就可以用一个布尔型二维数组来标记它们。在求P ivt时,就可以时刻检查有没有经过被阻挡的边了。
三、实现条件2 以及 连接Pisv和Pivt
有一个比较巧妙的方法可以既实现条件2,又快速地把P isv和P ivt连接起来。
在求最短路径的算法中,不管用什么算法,我们都需要两个数组: d[]记录起点到每个点的最短距离; pre[]记录每个点在最短路径中的前驱点。
我求P ivt用的是Dijkstra算法(正好这题中规定了所有边长都是正数,因此可以用Dijkstra)。我们知道在Dijkstra算法中,我们要管理一个集合Q,其中是还没有确定最短路径的点。初始情况下所有点都在集合Q中 ,除了d[s]为0,其它所有点的 d[]值都为无穷大。在Dijkstra算法中,每一次迭代时都从Q中取出 d[]值最小的点,再从它作一系计算,这里就不详说了。
现在为了使条件2阻挡的点不被包含在P ivt中,我们可以在初始化的时候就把所有位于P isv上的点的 d[]值和 pre[]值都按照P isv的样子预设好,然后把除了v以外的所有位于P isv上的点都从Q中删除(我用一个布尔数组表示每个点是否属于集合Q),然后再做Dijkstra。这样求出的路径不仅在P ivt上实现了条件2,而且最终的结果自然就把P isv和P ivt连接起来了。
这里还要讲一下我的一个教训。我一开始没有用Dijkstra算法,而是写了一个简单的BFS来求P ivt,实现条件2的方法和上面差不多。但是结果超时。后来我才发现,在BFS中每次有点要进队列的时候,一定要判一下队列里是不是已经有这个点了。我就是因为没有判重,结果做了很多重复的计算。
四、求按字典序排名最前的路径
首先把整张图反过来,也就是所有从a到b的边都变成从b到a。把题目中给出的起点和终点也反一下。这样求出的所有路径和在原图中求出的路径都是等价的,只是都反了一下。
在求最短路径的过程中,我们都会用到relax操作。对于一条边(a, b),设它的长度为w(a, b),则relax(a, b)表示:
if ( d[a] + w(a, b) < d[b] ) { d[b] = d[a] + w(a, b); pre[b] = a; } |
现在我们在relax中再追加一项操作:
if ( d[a] + w(a, b) == d[b] && a < pre[b] ) { pre[b] = a; } |
这样我们最终求出的最短路径就是反向的字典序最先的路径,因为我们让每个点的前驱点的编号都尽量地小。再加上我们本来就把整张图反过来了,所以我们就得到了正向的字典序最先的路径。
但是这个算法似乎只适用于没有长度为0的环的图。比如图3所示的这张图中, 点1和点2构成了一个长度为0的环。那么这时从1到3的字典序最先的最短路径是1 -> 2 -> 3,还是1 -> 2 -> 1 -> 2 -> 3,还是1 -> 2 -> 1 -> 2 -> 1 -> 2 -> 3?这时我们只能说字典序最先的最短路径不存在了。
图 3
到这里我算是写完了。难免有讲得不清楚的地方,对于许多细节问题,还是请看 我的代码吧。