首先,为了说话方便,列出一些术语:

    在启发式搜索中,对于每个状态 x,启发函数 f(x) 通常是这样的形式:

f(x) = g(x) + h(x)

    其中 g(x) 是从初始状态走到 x 所花的代价;h(x) 是从 x 走到目标状态所需要的代价的 估计值

    相对于 h(x),还有一个概念叫 h*(x),表示从 x 走到目标状态所需要的实际最小代价(当然,这个值有时我们是事先无法知道的)。

    如果在你的启发函数里,能保证 h(x) <= h*(x),也就是说,你不能高估了从 x 走到目标状态所需要的代价,那就可以说这个搜索是 A* 算法(这里的“*”,英文就读作 star)。

    A* 算法的特点是,如果存在从初始状态走到目标状态的最小代价的解,那么用 A* 算法搜索时,第一个找到的解就一定是最小代价的。这就是所谓的可采纳(admissible)。

1. 求前 K 短的 可以带环的 路径(的长度)

    1.1. 典型的启发式搜索

    设起点为 s;终点为 t;对于一个点 v,dt(v) 表示从 v 走到 t 的最短路径的长度(可以在初始化的时候全都算好)。

    网友 richard 教会了我,可以用最典型的启发式搜索来解这个问题。一个状态 x 表示的是从 s 走到某个点的一条路径,把这个点记作 x.v,把这条路径的长度记作 x.len。接着,我们可以使用以下启发函数:

g(x) = x.len;  h(x) = dt(x.v);
∴ f(x) = g(x) + h(x) = x.len + dt(x.v)

    初始状态中, x.v = s; x.len = 0。然后每次让优先队列(所谓的  Open 表)中 f(x) 值最小的状态 x 出队,再跟据图中所有从 x.v 出发的边发展下一层状态,让它们进队列。优先队列中不存在判重复的问题,因为每个状态所代表的路径肯定是不一样的。

    不难想通,这是一个 A* 算法,因为这里的 h(x) 本身就是 h*(x),当然满足 h(x) <= h*(x)。因此可以说,在每次出队列的状态 x 中,第一次遇到 x.v == t 时,就找到了从 s 到 t 的第一短的路径,它的长度就是 f(x)……第 k 次遇到 x.v == t 时,就找到了从 s 到 t 的第 k 短的路径。

    1.2. Yen 算法

    我从《The K shortest paths problem》这篇文章中学到了另一个算法,名叫 Yen 算法(Yen 是发明者的名字)。它和上面讲的典型的 A* 算法使用相同的启发函数,但是状态的含义以及扩展状态的方式不同。

    在 Yen 算法中,状态 x 不仅可以代表从 s 走到 x.v 的一条路径(记作 Psv),更代表了一条从 s 到 t 的完整的路径,也就是 Psv 再连接上 从 x.v 到 t 的最短路径。这一整条路径(记作 Px)的长度就是我们的启发函数 f(x)。

    在每个状态 x 中,还需要保存 x.v 在 Psv 中的前一个点,我们记作 x.pre。边 x.pre -> x.v 就称作 Px偏离边deviation edge); Px 上从 x.pre 到 t 的这一段子路径就称为 Px 的偏离路径deviation path)。为什么叫作偏离路径,看到后面都明白了。

    先求出从 s 到 t 的最短路径,它就是初始状态 x1 所要代表的路径。设它的第一条边是 s -> a,则  x1.v = a; x1.len = w(s, a) (w(s, a) 表示边 s -> a 的长度);  x1.pre = s,也就是说,规定 Px1 的偏离边是 s -> a。

    把 x1 放进优先队列。接下来,每当进入最大的循环的第 i 轮,从优先队列里出队的状态(启发值最小的,也就是路径长度目前最短的状态,记作 xi)就代表了第 i 短的解。第一轮出队的当然是前面定义的初始状态 x1。下面要从它发展新的状态,作为可能的第 2 短的解,放进优先队列。发展的方法如下:

    对于 Px1 的偏离路径上的每一条边(设它为 u -> v),都要找出另一条边 u -> v',满足在所有从点 u 出发的边当中, w(u, v') + dt(v') 仅仅高于 w(u, v) + dt(v) (或与它相同);也就是说,从 u 出发,走 u -> v 这条边到终点是最近的,走 u -> v' 这条边是第 2 近的(或者一样近)。从每一条 u -> v',我们都可以发展出一个新状态 x': x'.v = v'; x'.len = w(Psu) + w(u, v'); x'.pre = u,也就是说 Px' 的偏离边就是 u -> v'。

图 1

    图 1 给出了一个例子。假设图中蓝色和黑色的边组成的路径就是 P x1,蓝色边是它的偏离路径;那些红色的边就是前面说的那些 u -> v';红色的虚线就代表了从每个 v' 到 t 的最短路径。可见,每条 P x' 都是从 u -> v' 开始从 P x1 “身上” 偏离出来的,因此把 从偏离边到终点 的这一段路径称为 P x'偏离路径

    注意,由于本问题中求的路径是可以带环的,所以走到终点以后还可以回头再走。因此,在图 1 中可以看到在点 t 后面也发展了一条偏离路径。这条偏离路径显然不再需要是第 2 短的,而是从 t 出发再回到 t 的最短的路径。

    上面讲的是从 x1 发展状态的情况。从之后的 xi 发展状态的时候还有一点要注意:在我们寻找偏离边 u -> v' 的时候,如果 u == xi.pre (也就是当 要找的偏离边 和 xi 的偏离边 是从同一点出发时),则要注意 u -> v' 不仅要和 u -> xi.v 不同,而且要和 xi 的所有祖先状态中从点 u 出发的那条边都不同,不然新发展的状态岂不是和 xi 的祖先状态重复了。

图 2

    图 2 给出了一个例子。假设蓝色路径是从黑色路径中发展出的偏离路径;当从蓝色路径发展偏离路径时,要找的是除了蓝色和黑色的边以外,能以最短的距离走到 t 的那条边,假设这里我们找到的是红色的那条边;当从红色路径发展偏离路径时,要找的是除了红色、蓝色和黑色的边以外,能以最短的距离走到 t 的那条边,假设这里我们找到的是绿色的那条边。

    如此一来,可能有很多偏离路径都是从同一点偏离出来的,但是它们的偏离边都不相同。要在程序中实现这一点,可以在每个状态中记录下所有祖先状态的偏离边。

    显然 Yen 算法也是一个 A* 算法,但是它有一个特点,前面已经说过了,就是最大的那个循环最多只要做 K 次,因为每当一个状态出队列时,我们就找到了一个解。因此基本上可以估计出算法的时间复杂度:

  • 设图中有 N 个点,那么一条偏离路径上最多只有 N 条边(因为它是一条边 加上 从某一点到终点的最短路径),也就是说,从一个状态最多发展出 N + 1 个新状态(偏离路径上的每条边发展出一个,从点 t 再发展出一个)。
  • 寻找一条偏离路径时,需要扫描从一个点出发的所有边,暂且假设从一个点出发的边最多也是 N 条,那么这一步要花 O(N) 的时间。
  • 可以想象优先队列(Open 表)里最多有 O(K * N) 个元素,所以每次维护优先队列的时间差不多是 O( lg (K * N) )。
     因此,总的时间复杂度,在最差情况下,差不多就是  O( K * ( N2 + lg(K * N) ) )。当然这只是我个人估计一下,不要太拿它当回事。

    1.3. MPS 算法

    同样是在《The K shortest paths problem》这篇文章中,还介绍了作者自已发明的 MPS 算法(MPS 是该文章的三位作者的名字缩写)。它的框架和 Yen 算法相同,但是有一个优化,可以加快寻找偏离边的速度。方法就是把从每个点出发的所有边,都按照从该条边走向 t 的最短距离 升序排序(最好用邻接链表描述图)。

第K短路径_第1张图片

图 3

    图 3 给出了一个例子。图中从点 s 出发的边有红、蓝和绿三条,延着它们到达终点 t 的最短距离分别为 3、 2 和 4。因此把从 s 出发的边排序为 (蓝, 红, 绿)。

    这样一来,寻找偏离边的时间就只有 O(1) 了。因为我们从某一点第一次发展偏离边时,只要选它的邻接链表中的第一条边;下一次再从该点发展时,只要选第二条边……再也不用一一扫描所有边了,也不用担心会和祖先状态的偏离边重复了。

    假设图中有 N 个点,从每个点出发的边最多也是 N 条。那么排序一个点的邻接链表需要 O(N * lg N) 的时间,排序整个邻接链表的时间就是 O(N2 * lgN);搜索的时间由 Yen 算法的 O(K * N2) 降至 O(K * N)。因此,整个算法在最差情况下的时间复杂度大约就是 O(N2 * lgN + K * N)。(从数字上看,好像也没有比 Yen 算法快到哪去image……但是实际试下来确实是快的。)

2. 求前 K 短的 无环 路径(的长度)

    2.1. 典型的启发式搜索

    网友 richard 在他的这篇文章里介绍了,把 1.1 节中的算法稍加修改,就可以用来求无环的前 K 短路径。修改方法就是在每个状态中保存 Psv 所经过的点;当从一个状态发展新状态时,下一步走的点不能出现在 Psv 中(如果点比较少的话,用位运算就可以很快地对此进行判断。)。这样一来,最终求出的路径就无环了。

图 4

    这个算法在大多数情况下确实很好用,但是在  2006 年横滨赛区的最后一题中,就有一组阴险的数据可以让这个算法超时。如图 4 所示,从点 s 出发只有两条边:蓝色的很短,红色的非常长(既使把图中所有边的长度都加起来,也没有它长);能走向点 t 的只有一条边,它的起点正是蓝色边的终点;图的其它部分有很多点,它们两两之间都有边(图 4 中只是象征性地画了一下,实际上有更多点)。可以想象,只要第一步走了蓝色的边,那么能到达点 t 的无环的路径只有一条,那就是 s -> 蓝色的点 -> t。从第 2 短的解开始,都必须走红色的边。

    但是启发式搜索一定会先走蓝色的边,然后尝试其后的所有路径。直到实在走投无路径时,才会回过头来走红色的边,因为从长度来看,红色边的优先级实在太低了(虽然它才是正解)。假设图中有 N 个点,可以想象,启发式搜索会先尝试 O(N!) 条错误的路径,那就太可怕了。

    我曾经想过下面一些优化的方案,但是好像都行不通:

  • 如果在一个我们想要发展的新状态 x 中,从 s 到 x.v 的路径 和 从 x.v 到 t 的最短路径上有重复的点(前者的点集被保存在 x 中;后者的点集可以在初始化所有最短路径时记录下来),则不让它进优先队列(Open 表)?

        这样做是不对的。因为虽然从 x.v 到 t 的最短路径不能构成无环的解,但是这并不代表从 x.v 到 t 的稍长一点的路径就不可能构成无环的解。因此,这样的状态还是必须得发展下去,否则就可能错过了一些解。

  • 在优先队列(Open 表)里只保存前 K 个最优的状态,比较差的状态就不进队列了?

        这样做更加是不对的。因为图 4 这个例子就已经明确地说明了,启发值比较优先的状态并不一定能通向正解,而启发值较差的状态说不定就能通向正解。

     总之,目前在我看来,典型的启发式搜索对于图 4 中的这种情况真的是没辙。当然,我希望 richard 能够反驳这个论点。

    2.2. Yen 算法

    Yen 算法的无环版本我在这篇文章里已经写过了。其思想和它的可以带环的版本相同,只是在找偏离路径的时候,不能再用初始化求好的现成的最短路径了,因为它们可能无法构成无环的解;而是要当场求一条最短路径,在求的过程中屏蔽掉前半条路径经过的点,以保证整条路径无环。

    由于 Yen 算法的无环版本在找偏离路径时,不再是扫描从一个点出发的所有边,而是运行一次最短路径算法。所以它的最差时间复杂度 由可以带环版本的 O( K * ( N2 + lg(K * N) ) ) 升至 O( K * ( N3 + lg(K * N) ) )

    2.3. MPS 算法

    作者还是 M、 P 和 S 这三个人,在《The K shortest loopless paths problem》这篇文章中介绍了 MPS 算法的无环版本。它和 MPS 算法的可以带环版本基本相同,只是在最大的循环中,每当一个状态出队列时,判断它是否无环,如果无环才算找到一个解。当然,不管出队的状态有没有环,都需要从它发展新状态,原因在 2.1 节中已经说过了。

    这个算法在一般情况下(比如随机生成的图)会比 Yen 算法的无环版本快很多,毕竟它在寻找偏离路径上有很大的时间优势。但是它的致命伤和典型的启发式搜索一样,就是像图 4 那样的情况。因为它在决定偏离路径时,还是以启发为主,并不能确定找到的是正解,我就不多说了。

    就写到这里吧。最后总结一下,上面介绍的求前 K 短路径的各种算法,不管是有环的版本还是无环的版本,都是 A* 算法。正因为这样,它们才能保证能依次求出前 K 短的解。最后面好像写得有点潦草,但是我觉得足以说明问题了。跟本文有关的题目,我知道的还有 UVA 10740 和 PKU 2449。