|
||
首先,为了说话方便,列出一些术语:
在启发式搜索中,对于每个状态 x,启发函数 f(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。接着,我们可以使用以下启发函数:
初始状态中, 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 中可以看到在点 t 后面也发展了一条偏离路径。这条偏离路径显然不再需要是第 2 短的,而是从 t 出发再回到 t 的最短的路径。 上面讲的是从 x1 发展状态的情况。从之后的 xi 发展状态的时候还有一点要注意:在我们寻找偏离边 u -> v' 的时候,如果 u == xi.pre (也就是当 要找的偏离边 和 xi 的偏离边 是从同一点出发时),则要注意 u -> v' 不仅要和 u -> xi.v 不同,而且要和 xi 的所有祖先状态中从点 u 出发的那条边都不同,不然新发展的状态岂不是和 xi 的祖先状态重复了。 图 2 如此一来,可能有很多偏离路径都是从同一点偏离出来的,但是它们的偏离边都不相同。要在程序中实现这一点,可以在每个状态中记录下所有祖先状态的偏离边。 显然 Yen 算法也是一个 A* 算法,但是它有一个特点,前面已经说过了,就是最大的那个循环最多只要做 K 次,因为每当一个状态出队列时,我们就找到了一个解。因此基本上可以估计出算法的时间复杂度:
1.3. MPS 算法 同样是在《The K shortest paths problem》这篇文章中,还介绍了作者自已发明的 MPS 算法(MPS 是该文章的三位作者的名字缩写)。它的框架和 Yen 算法相同,但是有一个优化,可以加快寻找偏离边的速度。方法就是把从每个点出发的所有边,都按照从该条边走向 t 的最短距离 升序排序(最好用邻接链表描述图)。 图 3 这样一来,寻找偏离边的时间就只有 O(1) 了。因为我们从某一点第一次发展偏离边时,只要选它的邻接链表中的第一条边;下一次再从该点发展时,只要选第二条边……再也不用一一扫描所有边了,也不用担心会和祖先状态的偏离边重复了。 假设图中有 N 个点,从每个点出发的边最多也是 N 条。那么排序一个点的邻接链表需要 O(N * lg N) 的时间,排序整个邻接链表的时间就是 O(N2 * lgN);搜索的时间由 Yen 算法的 O(K * N2) 降至 O(K * N)。因此,整个算法在最差情况下的时间复杂度大约就是 O(N2 * lgN + K * N)。(从数字上看,好像也没有比 Yen 算法快到哪去……但是实际试下来确实是快的。) 2. 求前 K 短的 无环 路径(的长度) 2.1. 典型的启发式搜索 网友 richard 在他的这篇文章里介绍了,把 1.1 节中的算法稍加修改,就可以用来求无环的前 K 短路径。修改方法就是在每个状态中保存 Psv 所经过的点;当从一个状态发展新状态时,下一步走的点不能出现在 Psv 中(如果点比较少的话,用位运算就可以很快地对此进行判断。)。这样一来,最终求出的路径就无环了。 图 4 但是启发式搜索一定会先走蓝色的边,然后尝试其后的所有路径。直到实在走投无路径时,才会回过头来走红色的边,因为从长度来看,红色边的优先级实在太低了(虽然它才是正解)。假设图中有 N 个点,可以想象,启发式搜索会先尝试 O(N!) 条错误的路径,那就太可怕了。 我曾经想过下面一些优化的方案,但是好像都行不通:
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。 相关文章:
|