本系列文章将于2021年整理出版,书名《算法竞赛专题解析》。
前驱教材:《算法竞赛入门到进阶》 清华大学出版社 2019.8
网购:京东 当当 作者签名书
如有建议,请加QQ 群:567554289,或联系作者QQ:15512356
《算法竞赛入门到进阶》的第4章“搜索技术”,讲解了递归、BFS、DFS的原理,以及双向广搜、A*算法、剪枝、迭代加深搜索、IDA*的经典例题,适合入门搜索算法。
本文分几篇专题介绍搜索扩展内容、讲解更多习题,便于读者深入掌握搜索技术。
第1篇:搜索基础。
第2篇:剪枝。
第3篇:广搜进阶。
第4篇:A*搜索。
第5篇:
本文是第4篇。
A*搜索算法(A* Search Algorithm)可以高效率解决一类最短路径问题:给定一个确定起点、一个确定终点(或者可以预测的终点),求起点到终点的最短路径。
A*算法的核心是一个估价函数f = g + h。它的效率取决于函数h的设计。
最短路问题的算法很多,例如双向广搜的效率也较高,而A*算法比双向广搜效率更高。另外,从本文的例题(K短路等)可以看出,A*算法可以解决更复杂的问题。
A*算法的技术可以概况为:A*算法 = 贪心最优搜索 + BFS + 优先队列。
在图问题中,“Dijkstra + 优先队列”就是“BFS + 优先队列”,此时也可以概况为:“A*算法 = 贪心最优搜索 + Dijkstra + 优先队列”。
下面以图的最短路径问题为例,推理出A*算法的原理。
注意,除了图这种应用场合,A*算法还能在更多场合下得到应用。
贪心最优搜索(Greedy Best First Search )是一种启发式搜索,效率很高,但是得到的解不一定是最优的。
算法的基本思路就是贪心:从起点出发,在它的邻居结点中选择下一个结点时,挑那个到终点最近的结点。当然,实际上不可能提前知道结点到终点的距离,更不用说挑选出最近的邻居点了。所以,只能采用估计的方法,例如在网格图中,根据曼哈顿距离来估算邻居结点到终点的距离。
如何编程?仍然用“BFS + 优先队列”,不过,在优先队列中排序的,不是从起点s到当前点k的距离,而是从当前点k到终点t的距离。
很明显,贪心最优搜索避开了大量结点,只挑那些“好”结点,速度极快,但是显然得到的路径不一定最优。
在无障碍的网格图中,贪心最优搜索算法的结果是最优解。因为用于估算的曼哈顿距离就是实际存在的最短路,所以每次找到的下一个结点,显然是最优的。
在有障碍的网格图中,根据曼哈顿距离选下一跳结点,路线会一直走到碰壁,然后再绕路,最后得到的不一定是最短路径。
贪心搜索的算法思想是:“只看终点,不管起点”。走一步看一步,不回头重新选择,走错了也不改正。而且,用曼哈顿距离这种简单的估算,也不能提前绕开障碍。
贪心最优搜索的图解,见下面“1.5 三种算法对比”。
用优先队列实现的Dijkstra(BFS) ,能比较高效地求得一个起点到所有其他点的最短路径。Dijkstra算法有BFS的通病:下一步的搜索是盲目的,没用方向感。即使给定了终点,Dijkstra也需把几乎所有的点和边放进优先队列进行处理,直到优先队列弹出终点为止。所以它适合用来求一个起点到所有其他结点的最优路径,而不是只求到一个终点的路径。
Dijkstra的算法思想是:“只看起点,不管终点”。等遍历得差不多了,总会碰到终点的。
Dijkstra的图解,见下面“1.5 三种算法对比”。
A*算法是贪心最优搜索和Dijkstra的结合,“既看起点,又看终点”。它比Dijkstra快,因为它不像Dijkstra一样盲目;它有贪心搜索的预测能力,而且能得到最优解。
它是如何结合这两个算法的?
设起点是s,终点是t,算法走到当前位置i点,把s-t的路径分为两部分:s-i-t。
(1)s-i的路径,由Dijkstra保证最优性;
(2)i-t的路径,由贪心搜索进行预测,选择i的下一个结点;
(3)当走到i碰壁时,i将被丢弃,并回退到上一层重新选择新的点j,j仍由Dijkstra保证最优性。
以上思路可以用一个估价函数来具体操作:
f(i) = g(i) + h(i)
f(i)是对i点的评估,g(i)是从s到k的代价,h(i)是从k到t的代价。
若g = 0,则f = h,A*就退化为贪心搜索;
若h = 0,则f = g,A*就退化为Dijkstra。
A*每次根据最小的f(i)来选择下一个点。 g(i)是已经走过的路径,是已知的;h(i)是预测未走过的路径;所以f(i)的性能取决于h(i)的计算。
A*算法的复杂度,在最差情况下的上界是Dijkstra,或者BFS+队列,一般情况下会更优。
A*算法的解是最优的吗?答案是确定的,它的解和Dijkstra的解一样,是最短路径。
当k到达终点t时,有h(t) = 0,那么f(t) = g(t) + h(t) = g(t),而g(t)是通过Dijkstra求得的最优解,所以在终点t这个位置,A*算法的解是最优的。
总结:A*算法通过Dijkstra获得最优性结果;通过贪心最优搜索预测扩展方向,减少搜索的结点数量。
下面这张图 ,准确地说明了三种算法的区别。图中起点是s,终点是t,黑格是障碍。图中精心设置了障碍的位置,以演示三种算法是如何绕过障碍的。
三个算法都基于"BFS+优先队列"。有数字的格子是搜索过的结点,并进入优先队列处理。阴影格是最后得到的一条完整路径。格子中的数字是距离,按曼哈顿距离计算。Dijkstra(BFS)算法遍历的格子最多,贪心最优搜索算法遍历的格子最少。
(1)Dijkstra(BFS)算法。格子中的数字,是从起点s到这个格子的最短距离。算法搜索格子时,把这些格子到起点的距离送入优先队列,当弹出时,就得到了s到这些格子的最短路径。最后,当终点t从优先队列弹出时,即得到s到t的最短距离14。
(2)贪心最优搜索。格子中的数字,是从这个格子到终点t的曼哈顿距离。读者可以仔细分析它的工作过程,这里简单说明如下:从s沿最小曼哈顿距离,一直走到碰壁处的2;2从优先队列弹出后,剩下最小的是3;3弹出后,剩下最小的是4;…;持续这个过程,那些看起来更近但是最终碰壁的结点被逐个弹走,直到拐过障碍,最后到达t。得到的路径不是最优路径。
(3)A*搜索。例如格子i中的数字,是“s到i的最短路 + i到t的曼哈顿距离”。算法在扩展格子的过程中,标记数字的格子都会进入优先队列。在图示中,先弹出所有标记为10的格子,再弹出标记为12的格子,直到最后弹出终点t。最后得到的s-t最短路径也是14。
如何打印出完整的一条路径?三个算法都基于BFS,而BFS记录路径是非常简单的:在结点u扩展邻居点v的时候,在v上记录它的前驱结点u,即可以从v回溯到u;到达目的后,从终点逐步回溯到起点,就得到了路径。在Dijkstra算法中,每次从优先队列中弹出的,都是得到了最短路径的结点,从它们扩展出来的邻居结点,也会继续形成最短路径,所以能根据前驱和后继结点的关系,方便地打印出一条完整的最短路径 1。A*算法用Dijkstra算法来确定前驱后继的关系,也一样可以打印出一条最短路径。贪心最优搜索的路径打印最简单,就是普通BFS的路径打印。
在二维平面上,有3种方法可以近似计算h。下面的(i.x, i.y)是i点的坐标,(t.x, t.y)是终点t的坐标。
(1)曼哈顿距离。应用场景:只能在四个方向(上,下,左,右)移动。
h(i) = abs (i.x – t.x) + abs (i.y – t.y)
(2)对角线距离。应用场景:可以在八个方向上移动,例如国际象棋的国王的移动。
h(i) = max {abs(i.x – t.x), abs(i.y – t.y)}
(3)欧几里得距离。应用场景:可以向任何方向移动。
h(i) = sqrt ( (i.x – t.x)2 + (i.y – t.y)2 )
非平面问题,需要设计合适的h函数,后面的例题中有一些比较复杂的h函数。
设计h时注意以下基本规则:
(1)g和h应该用同样的计算方法。例如h是曼哈顿距离,g也应该是曼哈顿距离。如果计算方法不同,f = g + h就没有意义了。
(2)根据应用情况正确选择h。各个结点的h值,应该能正确反映它们到终点的距离远近。例如下一跳结点有2个选项:A(280, 319)、B(300, 300),如果用曼哈顿距离应该选A,用欧氏距离应该选B。如果只能走四个方向(需要按曼哈顿距离计算路径),用欧式距离计算就会出错。
(3)h应该优于实际存在的所有路径。前面的例子中,h(i)小于等于i-t的所有可能路径长度,也就是说,最后得到的实际路径,长度大于h(i)。这个规则可以用下面两点讨论来说明。
1)h(i)比i-t的实际存在的最优路径长。假设这条实际的最优路径是path,由于程序是根据h(i)来扩展下一个结点的,所以很可能会放弃path,而选择另一条非最优的路径,这会造成错误。
2)h(i)比i-t的所有实际存在的路径都短。此时i-t上并不存在一条长度为h(i)的路径,如果程序根据h(i)来扩展下一结点,最后肯定会碰壁;但是不要紧,程序会利用BFS的队列操作弹走这些错误的点,退回到合适的结点,从而扩展出实际的路径,所以仍能保证正确性。
上面第(3)点最重要,应用A*算法时应特别注意。
A*算法的主要难点是设计合适的h函数,而编码很容易。例如图问题中,Dijkstra或BFS使用g函数,A*使用f = g + h函数,那么编码时只要用f代替g即可。读者可以尝试把图论的最短路径题目改成用A*算法实现,例如poj 2243。
下面给出2个复杂一点的例题。
问题描述poj 2249 :给定一个图,定义起点s和终点t,以及数字k;求s到t的第k短的路。允许环路。相同长度的不同路径,也被认为是完全不同的。
K短路问题是A*算法应用的经典例子,几乎完全套用了A*算法的估价函数。
下面分别用暴力法和A*算法求解。
(1)暴力法:BFS + 优先队列
用BFS搜索所有的路径,用优先队列让路径按从短到长的顺序输出。
“BFS+优先队列”求最短路,在“BFS+优先队列”这一节中曾讲解过。其原理是当再次扩展到某个点i时,如果这次的新路径比上次到达i的路径更短,就替代它;优点队列可以让结点按路径长短先后输出,从而保证最优性。队列的元素是一个二元组(i, dist(s-i)),即结点i和路径s-i的长度。
BFS求所有路径,就是最简单的“BFS + 优先队列”,再次扩展邻居i时,计算它到s的距离,然后直接进队列,并不与上次i进队列的情况进行比较。一个结点i会进入优先队列很多次,因为可以从它的多个邻居分别走过来;每一次代表了一个从s到i的路径。优先队列可以让这些路径按dist从短到长的顺序输出,i从优先队列中第x次弹出,就是s到i的第x个短路。对于终点t,统计它出队列的次数,第K次时停止,这就是第K短路。
在K短路问题中,路径有可能形成环路。有的题目允许环路,有的不允许。如果允许环路,那么想在环路上绕多少圈都可以,环路上的结点反复进入队列,K可以无限大。
在最短路算法中并不需要判断环路,因为更新操作有去掉环路的隐含作用。
复杂度:因暴力法需要生成几乎所有的路径,而路径数量是指数增长的,所以暴力法的复杂度非常高。
下面用一个简单的图例解释BFS暴力搜索所有路径的过程。
下面的表格给出了算法的步骤。结点后面的下标表示从s到这个结点的路径长度,例如 u 2 u_2 u2,就是二元组(u, 2),即结点u,以及s-u的路径长度2。步骤中没有列出环路。
步骤 | 出队 | 邻居进队 | 优先队列 | 新得到的路径 | 输出队头的路径 |
---|---|---|---|---|---|
1 | s s s | { s 0 s_0 s0} | |||
2 | s 0 s_0 s0 | u,v | { u 2 , v 4 u_2,v_4 u2,v4} | { s s s- u 2 u_2 u2, s s s- v 4 v_4 v4} | |
3 | u 2 u_2 u2 | v , t v,t v,t | v 3 , v 4 , t 8 v_3,v_4,t_8 v3,v4,t8 | { s s s- u 2 u_2 u2- v 3 v_3 v3, s s s- u 2 u_2 u2- t 8 t_8 t8} | s s s- u 2 u_2 u2 |
4 | v 3 v_3 v3 | t t t | { v 4 , t 8 , t 6 v_4, t_8, t_6 v4,t8,t6} | s s s- u 2 u_2 u2- v 3 v_3 v3- t 6 t_6 t6 | s s s- u 2 u_2 u2- v 3 v_3 v3 |
5 | v 4 v_4 v4 | u , t u,t u,t | { t 8 , t 6 , u 5 , t 7 t_8, t_6,u_5, t_7 t8,t6,u5,t7} | s s s- v 4 v_4 v4- u 5 u_5 u5, s s s- v 4 v_4 v4- t 7 t_7 t7 | s s s- v 4 v_4 v4 |
6 | u 5 u_5 u5 | t t t | { t 8 , t 6 , t 7 , t 11 t_8, t_6, t_7, t_{11} t8,t6,t7,t11} | s s s- v 4 v_4 v4- u 5 u_5 u5- t 11 t_{11} t11 | s s s- v 4 v_4 v4- u 5 u_5 u5 |
7 | t 6 t_6 t6 | { t 8 , t 7 , t 11 t_8, t_7, t_{11} t8,t7,t11} | s s s- u 2 u_2 u2- v 3 v_3 v3- t 6 t_6 t6 | ||
8 | t 7 t_7 t7 | u u u | { t 8 , t 11 , u 13 t_8, t_{11}, u_{13} t8,t11,u13} | s s s- v 4 v_4 v4- t 7 t_7 t7- u 13 u_{13} u13 | s s s- v 4 v_4 v4- t 7 t_7 t7 |
9 | t 8 t_8 t8 | v v v | { t 11 , u 13 , v 11 t_{11}, u_{13}, v_{11} t11,u13,v11} | s s s- u 2 u_2 u2- t 8 t_8 t8- v 11 v_{11} v11 | s s s- u 2 u_2 u2- t 8 t_8 t8 |
10 | t 11 t_{11} t11 | { u 13 , v 11 u_{13}, v_{11} u13,v11} | s s s- v 4 v_4 v4- u 5 u_5 u5 − t 11 -t_{11} −t11 | ||
11 | v 11 v_{11} v11 | { u 13 u_{13} u13} | s s s- u 2 u_2 u2- t 8 t_8 t8- v 11 v_{11} v11 | ||
12 | u 13 u_{13} u13 | {} | s s s- v 4 v_4 v4- t 7 t_7 t7- u 13 u_{13} u13 |
从第二列的“出队”可以看到,共产生10个路径,按从短到长的顺序排队输出。从起点s到终点t共有4条路径,t在第7、8、9、10步出队的时候,输出了第1、第2、第3、第4路径。表格中也列出了s到每个结点的多个路径和它们的长度,例如s-u有3个路径,s-v有3个路径。
(2)A*算法求K短路
从暴力法可以知道:
1)从优先队列弹出的顺序,是按这些结点到s的距离排序的。
2)一个结点i从优先队列第x次弹出,就是s-i的第x短路;终点t从队列中第K次弹出,就是s-t的第K短路。
如何优化暴力法?是否可以套用A*算法?
联想前面讲解A*算法求最短路的例子,A*算法的估价函数f(i) = g(i) + h(i),g是从起点s到i的距离,h是i到终点t的最短距离(例子中是曼哈顿距离)。
那么在K短路问题中,可以设计几乎一样的估价函数。g(i)仍然是起点s到i的距离;而h(i),只是把曼哈顿距离改为从i到t的最短距离。这个最短距离如何求?用Dijkstra算法,以终点t为起点,求所有结点到t的最短距离即可。
编程非常简单。仍用暴力法的“BFS+优先队列”,但是在优先队列中,用于计算的不再是g(i),而是f(i)。当终点t第K次弹出队列时,就是第K短路。
根据前面对A*算法原理的解释,求K短路的过程将得到很大优化。虽然在最差情况下,算法复杂度的上界仍是暴力法的复杂度,但优化是很明显的。
Power Hungry Cows http://poj.org/problem?id=1945
题目描述:两个变量a、b,初始值a = 1, b = 0。每一步可以执行一次a×2,b×2,a+b,|a-b|之一的操作,并把结果再存回a或者b。问最快多少步能得到一个整数P,1 <= P <= 20,000。
例如P = 31,需要6步:
a b
初始值: 1 0
a×2,存到b: 1 2
b×2: 1 4
b×2: 1 8
b×2: 1 16
b×2: 1 32
b-a: 1 31
样例输入:
31
样例输出:
6
题解:
(1)BFS+剪枝
这一题是典型的BFS。从{a, b}可以转移到8种情况,即{2a, a}、{2a, b}、{2b,a}、{2b,b}等等。把每种{a, b}看成一个状态,那么1个状态可以转移到8个状态。编码时,再加上去重和剪枝。此题P不是太大,“BFS+剪枝”可行。
(2)A*算法
如何设计估价函数f(i) = g(i) + h(i) ?
g(i)是从初始态到i状态的步数。h(i)是从i状态到终点的预估最少步数,它应该小于实际的步数。如何设计呢?容易观察到,{a, b}中的较大数,一直乘以2递增,是最快的。例如样例中的31,在起点状态, 2 5 2^5 25 > 31,经5步可以超过目标值,所以h = 5。
下面的题目有多种实现方法,尝试用A*算法来做。
洛谷P1379 八数码难题,https://www.luogu.com.cn/problem/P1379。八数码有多种解法,A*也是经典解法之一。
洛谷 2324 骑士精神,https://www.luogu.com.cn/problem/P2324
洛谷P2901 Cow Jogging https://www.luogu.com.cn/problem/P2901,K短路。
如果需要打印出最短路径,参考《算法竞赛入门到进阶》“10.9 最短路”,给出了路径打印的代码。 ↩︎