A*算法中启发函数的使用

来源:互联网 发布:知乎的文章怎么复制 编辑:程序博客网 时间:2024/06/05 17:29

A*算法使用启发函数h(n)来获得对于从任意结点n走到目标结点的最小代价的估计,因此选用一个好的启发函数是非常重要的.

A*算法中启发函数的使用

启发函数可以用来控制A*算法的行为.

  • 在极端情况下,如果h(n)=0,那么只有g(n)实际上是有用的,这时A*算法也就是迪杰斯特拉算法,它能保证一定可以找到一条最优路径.
  • 如果h(n)总是小于(或者等于)从结点n走到目标结点的步数,那么A*算法是一定可以找到最优路径的.h(n)越小,A*扩展的结点越多,导致A*算法越慢.
  • 如果h(n)恰好等于从结点n走到目标结点的步数,A*算法扩展的所有结点都在最优路径上,它不会扩展任何其他无关结点,此时A*算法的速度是非常快的.尽管你无法总是做到这一点,但在某些特定情况下你确实做到.知道A*算法可以在某些时候运行的很好是一件很值得高兴的事.
  • 如果h(n)所给出的信息有时大于从结点n走到目标结点的步数,那么A*算法将无法确保能够找到最优路径,但它会运行得更快.
  • 在另一种极端的情况下,如果h(n)非常接近于g(n),那么只有h(n)将起作用,此时A*算法实际上变成宽度优先搜索.

注意:

从纯粹的技术上说,如果启发函数是对实际耗散值的过低估计的话,A*算法应该叫做简单的A算法.然而,由于在实现上 ,两种算法并无不同的地方,而且在游戏开发社区中,A*算法和A算法并不会被区分开来,所以,接下来我仍然称之为A*算法.

经过上述的讨论后,我们会发现我们面临一个很有趣的问题:我们能通过A*算法得到什么呢?正确的答案是,我们可以很快地得到最优路径.如果h(n)值太低了,那么我们一定能够得到最优路径,但这是以牺牲了算法速度为d代价的.如果h(n)值太高了的话,那么A*算法速度会变快,但是我们无法确保所得到的路径一定是最优的.

在游戏中,A*的这种特性是非常有用的.举个例子,某些时候,你宁愿更快得找出”较优”路径而不是慢悠悠地等着”最优的”路径.为了在h(n)和g(n)中找到平衡,你可以修改其中一个的具体实现.

速度还是准确度?

正如我们提过的,A*算法的表现取决于它的启发函数和耗散函数,这一点在游戏中是很有价值的.权衡速度和准确度的,可以让你的游戏运行得更快.对于大多数游戏而言,你没有必要一定要找出两点间的最优路径.你需要的仅仅是足够接近的答案.你需要什么应视游戏进度和电脑多快决定.

假设你的游戏地图中有两种地形:平原和山地,而且在平原上的单位距离移动代价是1,在山地上的单位距离移动代价是3.(A*算法的目标是找出地图上两点间的最优路径),那么A*算法每在山地上搜索1个单位距离,它在平原上将搜索3个单位距离.这是因为也许存在一条最优路径,它一碰到山地就绕行,即该路径仅仅经过平原地形.通过将两点间的估计距离(即启发信息)设定为1.5,A*算法的速度就可以得到提升了.这时A*算法将比较的是3和1.5,而不是导致它变慢的比较3和1,在山地地形上它的效率将不像后者那么不尽如人意,因此它不会花费太多的时间.或者,你可以通过将A*算法在山地上搜索单位距离的代价降低来提高它的效率–比如说设定为2而不是3,这时A*算法每在山地上搜索1个单位距离,它在平原上将仅仅搜索2个单位距离.两种方法都无法找到最优路径,但能够更快地找到较优路径.

在速度和准确度上的选择并不一定是一成不变的.你可以动态地权衡,这决定于CPU的速度,找出路径的时间对游戏的影响,地图上的结点数目,结点的重要程度,路径群组的大小,游戏的难度级别,或者其他的一些因素.一种动态权衡的方法是,假定最优路径通过单位方格的代价为1,修正的耗散函数如下:

g'(n) = 1 + alpha * (g(n) - 1)

如果alpha=0,那么这个修正的将永远是1.这时,单位距离的代价是被忽略的,A*算法仅是运行在简单的可达方格/不可达方格的程度.如果alpha=1,此时修正的耗散方程将还是原来的耗散方程,这时A*算法效率将带来更好的效率.你可以将alpha设定为[0,1]之间的任意值.

同时,你应该考虑使启发函数返回的是期望最小代价,而不是绝对最小代价.比如说,如果你的地图大部分是单位距离移动代价为2的草地,小部分是单位距离移动代价为1的小路,那么也许你会考虑整副地图启发信息都为2,意味着地图上没有小路.

在速度和准确度上的选择并不一定是全局的.你可以根据某些地图上准确度的重要性来灵活选择.举个例子,假如我们在某些点能够结束路径的重新计算或者改变路径方向,那么选择接近于当前所在结点的路径可能是更为重要的,这样的话,为什么我们还要费事纠结于千里之外的其他路径呢?再举意个例子,在地图上的安全区域,找到最优路径是比较重要的,但是在偷偷的经过敌对势力占领的村子时,快速地得到一条安全的较优路径的要求来得将更为迫切.

比例

A*算法计算f(n)=g(n)+h(n).因此,g(n)和h(n)必须拥有协调的比例.如果g(n)以小时计算,而h(n)以米计算,那么A*算法将会认为g(n)或者h(n)过大,这样的话,要么你无法得到最优路径,或者A*算法将比它原本可以的速度要慢.

精确的启发

如果你的启发函数给出的两个结点间的估计距离恰好等于最优路径上该两点间的距离,那么,正如你将在下一节的图表中看到的,A*算法扩展极少结点.发生在A*算法内部的是,在每一结点,它都会计算f(n)=g(n)+h(n),当h(n)整好等于g(n)时,在遍历最优路径上的结点时,每一结点上的f(n)值恒定不变.所有不在最优路径上的结点的f值都会比最优路径上的结点的f值要高.因为A*算法在考虑完所有拥有较低的f值的结点之前,它并不会考虑f值更高的结点,因此它永远不会偏离最优路径.

已计算过的准确启发信息

一种构造精确启发函数的方法是,预计算最优路径上任意两个结点之间的距离.对于大多数游戏地图而言,这是行不通的.然而,找到近似于精确启发函数的方法是存在的.

  • 考虑所有可能的粗糙结点,它不是最优路径上的结点.预计算任意两个粗糙结点的最短路径.
  • 预计算任意两个拐点(Arthur1989注:请一定猛击链接查看waypoint的定义,才疏学浅的我只能如此翻译了.)之间的最短路径.这是前面粗糙栅格法的一种泛化.

然后,把从任意结点到拐点(Arthur1989注:天阿,为什么又是它,我快自杀了)的估计函数h’加入启发函数h中.最终的启发函数是这样子的:

h(n) = h'(n, w1) + distance(w1, w2), h'(w2, goal)

或者,你可以这样考虑更优的,但是花费计算量更大的启发函数:评价所有临近于起始结点n的结点w1,和所有临近于目标结点的结点w2.

线性精确启发函数

特定情况下,你可以无须通过任何的预计算就得到精确的启发函数.如果你有一个没有任何障碍物的地图,或者地图上没有代价较大的地形,那么从起始结点到目标结点的最优路径就是连接该两点的直线.

如果你正在用着一个简单的启发函数(它并不清楚地图上会有障碍物),那么它将匹配线性精确启发函数,如果不是这样的话,或者在g和h的比例上出现了问题,或者你选择的启发函数类型有些缺陷.


栅格地图的启发函数

在栅格上,有很多著名的启发函数可以使用.

曼哈顿距离

标准的启发函数正是曼哈顿距离.不妨看一下你的耗散函数,假设从一个栅格移动到相邻的栅格上的最小代价D.因此,在我的游戏中,启发信息是D倍的曼哈顿距离.

h(n) = D * (abs(n.x-goal.x) + abs(n.y-goal.y))

你应该使用符合你的耗散函数的单位来衡量.

manhattan

(注:上面的图像中,启发函数可以找到好几条最优路径(下文称之为平局).)

对角线距离

如果你的地图允许对角线移动,那么你需要另外一个启发函数.在直角坐标中,(0,4)到(4,0)的曼哈顿距离是8*D.然而,你本可以简单的将点从(0,4)按东南方向移动至(4,0),所以启发信息应该是4*D.这个函数处理对角线问题,假定直线移动和对角线移动一个栅格的代价都是D:

h(n) = D * max(abs(n.x-goal.x), abs(n.y-goal.y))

diagonal

如果对角线移动一个栅格的代价并不是D,而是类似于D2=sqrt(2)*D,那么上面的启发函数对你而言又是错误的了.你可以使用一个更为复杂的启发函数代替它.

h_diagonal(n) = min(abs(n.x-goal.x), abs(n.y-goal.y))h_straight(n) = (abs(n.x-goal.x) + abs(n.y-goal.y))h(n) = D2 * h_diagonal(n) + D * (h_straight(n) - 2*h_diagonal(n)))

这里我们计算 h_diagonal(n) = 沿着对角线移动的步数, h_straight(n) = 曼哈顿距离,你可以通过考虑所有的对角线移动的步数(每步耗散D2)以及剩下的直线移动的步数(每步耗散D)将这两者结合在一起.

欧几里得距离

如果你的单元可以往任意方向移动(而不是沿着栅格的方向移动),那么你应该使用直线距离:

h(n) = D * sqrt((n.x-goal.x)^2 + (n.y-goal.y)^2)

然而,在这种情况下,由于耗散函数g与启发函数f并不匹配,因此这时A*算法是有问题的.因为欧几里得距离比起曼哈顿距离和对角线距离都更短,你得到的将仍然是最优路径,但A*算法运行时间将更长:

euclidean

平方的欧几里得距离

在好几个A*算法的网页上,我都看见在欧几里得距离关于避免计算量巨大的开方的计算,方法仅仅是对欧几里得距离做平方.

h(n) = D * ((n.x-goal.x)^2 + (n.y-goal.y)^2)

Do not do this!这绝对会导致g和h的比例的问题.当A*算法计算f(n)=g(n)+h(n)的时候,h的平方会导致它远大于g,这意味着你得到的会是过高估计的启发函数.对于更大的h,这会使得g(n)变得无足轻重了,此时A*算法退化成宽度优先搜索.

best-first-search-trap

打破平局

在某些地图中,存在着相同耗散值的路径(Arthur1989注:下文中一律称为平局).比如说,在没有凹凸的平原中,使用栅格将会得到许多路径,他们长度是一样的.A*也许会遍历所有的拥有一样的f值的路径,而不是仅仅遍历一条.(如果你的地图中有很多这样的区域,比起A*算法,对于每个栅格的搜索有其他更好的技巧.)

tie-breaking-off

f值相同导致的平局

为了解决这个问题,我们要么可以调整g,要么可以调整h.相对来说,调整h会更加容易.平局的打破对于每个结点的影响必须是确定的(即:它不应该是一个随机数),而且它必须使得每个结点的f值互异.因为A*算法(OPEN表)按结点的f值大小排序,让每个结点的f值互异也就是说,对于有着”相同的”f值的结点,仅有一个会被扩展.

一种打破平局的方法是,稍微的改变h的(单位).如果我们让h的单位减小,(那么h将变大),随着A*算法向目标结点扩展,f值将会增加.不幸的是,这意味着A*算法更偏向于扩展离起始结点较近的结点,而不是扩展离目标结点较近的结点.所以,我们可以将h的单位稍微增大一点(即使是0.1%),此时,A*算法将扩展目标结点旁边的结点.

heuristic *= (1.0 + p)

因子p的选择应该使得 p<(每走一步的最小代价)/(路径的期望最大值).假设你并不希望路径的步数超过1000,你可以将p设定为1/1000.打破平局的微小改变的结果是,A*算法扩展的结点远比原先的算法要少:

tie-breaking-scale-1

在启发信息中改变h或者g的比例

当存在障碍物时,A*算法还是需要找到一条绕过障碍物的路径,但请注意,当绕过障碍物之后,A*算法扩展很少结点:

tie-breaking-scale-2

Steven van Dijk 提议了一种更为直接的方法,即把h传递给比较函数.当结点的f值是相同的时候,比较函数可以通过h值判定结点优先级来打破平局.

另外一种打破平局的方法是计算坐标(二维地图中,结点的位置即为笛卡尔坐标)的哈希值,以此来调整启发函数.比起上述调整h的方法,它可以打乱更多的平局.对提出建议的 Cris Fuhrman 致敬.

还有一种不同的打破平局的方法是,偏向于离从起始结点到目标结点的直线较近的路径.

dx1 = current.x - goal.xdy1 = current.y - goal.ydx2 = start.x - goal.xdy2 = start.y - goal.ycross = abs(dx1*dy2 - dx2*dy1)heuristic += cross*0.001

上面的代码计算了起始结点到目标结点的向量和当前结点到目标结点的向量的内积.当这些向量不在一队上的时候,内积会变得很大.结果是A*算法稍微的偏向于扩展分布在从起始结点到目标结点的直线附近的结点.当步存在障碍物的时候,A*算法不仅可以扩展更少的结点,它得到的路径看起来也很漂亮.

tie-breaking-cross-1

启发函数中考虑了内积,生成很漂亮的路径

然而,正如上面提过的,当存在障碍物时,A*算法得到的路径看起来会有点奇怪.(注意:路径仍然是最优的)

tie-breaking-cross-2

为了交互地看出这种打破平局的方法带来的效率的提升,你可以查看James Macgill写的A* applet [如果该网址已经不在了,你可以试试 这个镜像].使用”Clear”命令来清空地图,然后在地图的两个角落选中两个结点.当你使用”经典的A*算法”是,你会看见不同结点拥有相同f值的影响.当你使用”Fudge”命令时,你可以看到上面在启发函数中使用内积信息带来的不同.

还有一种打破平局的方法是仔细的构造你的A*算法的优先级队列,它插入一个启发函数值为f的新结点,新结点比原先队列中存在的启发函数值为f的旧结点总是获得更好(坏)的排名.

也许你还可以读读AlphA* algorithm,虽然该算法打破平局的方法更为复杂,但它仍能确保得到的路径是最优的.AlphA*算法是可适应的,而且它应该比上面提到的两种打破平局的方法表现得要更好.但是,后者更为容易实现,因此你可以从后者开始尝试,当需要算法更快时才使用AlphA*算法.

寻找一个区域

如果你想要寻找任何接近于目标结点的区域,你可以构造一个启发函数h’(x),h’(x)是h1(x),h2(x),h3(x),…中的最小值,其中,h1,h2,h3是对于每个临近结点的区域的启发信息.然而,一种更快的方法是,让A*算法查找目标区域的中心结点.