A*寻路极限优化
github源码:https://github.com/boycy815/fastAStar
这几天在天地会上看到有算法比赛,比的是谁实现的A*寻路速度快,虽然比赛不是那么正规,但是这种展现实力的机会咱也不能落后是不,于是咱也折腾出一个算法提交上去,帖子在此:http://bbs.9ria.com/forum.php?mod=redirect&goto=findpost&ptid=172851&pid=1668442&fromuid=64655
128*128地图规模下1000个随机障碍,在我的电脑上一般不会超过1毫秒,只有一些奇葩的情况下会是1毫秒,没出现过2毫秒的情况。然后我尝试过5000个随机障碍,一般不超过2毫秒,偶尔2毫秒,不存在路径的情况下一般不超过8毫秒。
另外,虽然产生的路径看起来是8方向的,实际计算的时候是使用4方向展开,再通过简单的方式合并四个斜方向和同方向路径,让路径尽量看起来自然。
鉴于参赛程序写得过于装逼,部分同学反映看着累,所以也就有了本文。本文不打算讲得太细致,默认读者已经掌握基本的数据结构并了解了A*搜索的大概流程。不了解A*搜索的请移步:http://www.cnblogs.com/technology/archive/2011/05/26/2058842.html
启发式搜索
A*搜索是一种启发式搜索(可移步:http://baike.baidu.com/view/1237243.htm)。我们通常做寻路的时候,是把起点作为一个树结构的根节点进行遍历,直到找到终点。对于树的遍历,通常的我们有深度优先遍历和广度优先遍历,这个是数据结构的基础。但是通过这些方法做寻路往往显得盲目,有时候眼看终点就在眼前,我们仍然需要先对其他节点展开遍历。然而启发式搜索虽然对树做的也是遍历,可它对节点展开的顺序既不是深度优先也不是广度优先,而是“估计值优先”。这个估计值是树中每个节点绑定的一个值,这个值通过启发式结合节点的一些属性计算出来。好的启发式能产生对搜索目标有导向性的估计值,估计值更优的节点在概率上应更接近搜索目标。通过启发式我们能优化树的节点展开顺序从而更早找到目标节点,减少无谓的节点展开数量,提高效率。
A*的启发式
A*搜索采用f(n) = g(n) + h(n)作为启发式,其中g(n)为起点通过某个路径到达地图格子n的确定耗损,h(n)为地图格子n到达终点的估计耗损。我们通常采用的h(n)有曼哈顿式,欧几里得式等。曼哈顿式为h(n) = dx + dy,其中dx和dy为n点到终点的水平和竖直距离。欧几里得式为h(n) = sqrt(dx^2 + dy^2),代表n点到终点的欧几里得距离。这两个启发式的特点会在下文中讲到。
最佳路径定理
当任何格子n,其h(n)不大于格子其到终点的最小实际耗损时,A*搜索必然会得到最佳路径。我们可以这么理解:当一个格子a为最佳路径上的节点,其当前耗损为i,其到终点的实际耗损为j(假设已经知道),那么最佳路径的耗损为i+j,那么当h(a)不大于j时,f(a)将不大于i+j,在路径搜寻过程中,可能存在很多通往终点的路径,只要其路径长度不是最佳,那么当到达终点(估计耗损等于实际耗损),其耗损必将大于最佳路径的耗损,根据“估计值”小先展开的顺序,f(a)由于不大于最佳路径耗损,所以必定会被先展开,同理所有最佳路径上的格子都会在得到最佳路径之前被展开,所以此定理就成立了。
高效启发式原则
h(n)值的大小将影响A*搜索的效率,更大的值将更有效得减少无谓的分支展开,更快得找到终点。其原因是增加启发式中估计值的权重,可让启发式对消耗的路程不敏感而对终点的方向更加敏感。在不得已时不会去展开大方向不对的格子。但是如果h(n)大于b格子到终点的最小路径损耗,那么寻找出来的路径就不一定是最短。所以结合最佳路径定理,从搜索效率和质量上看,h(n)等于n格子到终点的最小实际损耗是最好的。
启发式选择
从上面的规律来看,曼哈顿式似乎优于欧几里得式。曼哈顿式在没有障碍的时候满足h(n)等于n格子到终点的最小实际损耗。而欧几里得式似乎得到的值小了一点。但是通常人们更愿意使用欧几里得式,因为欧几里得式会优先选择n格子到终点连线上的展开节点。而曼哈顿式对n格子到终点范围内的点都一视同仁。
如图曼哈顿式在n点和终点围城的矩形启发式值都相同,由于程序的循环顺序是固定的,所以会产生这样的路径,虽然正确但不符合人的习惯。
而欧几里得式会在如图的对角线上有更低的启发式值,所以程序会优先选择对角线位置的路径,如果将得到的路径合并斜方向会得到非常符合人习惯的路径。虽然曼哈顿式在效果上逊色于欧几里得式,但是由于曼哈顿式计算简单,拥有不少良好的性质非常适合优化,并且其效果不好的问题可以在前期预处理地图的时候解决,故程序中使用曼哈顿式。
地图预处理
我见不少同学的程序直接在搜索的大循环中拿二维数组来遍历,于是就看到类似如下的代码(听说还是keith peters大师的代码,真是有损大师威名啊):
var startX:int = Math.max(0, node.x - 1); var endX:int = Math.min(_grid.numCols - 1, node.x + 1); var startY:int = Math.max(0, node.y - 1); var endY:int = Math.min(_grid.numRows - 1, node.y + 1); for(var i:int = startX; i <= endX; i++) { for(var j:int = startY; j <= endY; j++) { 取出一个测试节点,要求可通过,并且不在open表和close表中 } } //以上是反例,切勿模仿
如果是一个已经固定的地图,这样的计算完全可以在地图生产的时候就完成。通过在每个格子中保存可展开的周边格子引用,可以在搜索的时候直接取出一个节点可以展开的子节点,而省去判断边界,以及是否可通过这种计算。
另外在“启发式选择”部分提到的曼哈顿式的展开不美观问题,也可以通过预处理解决。我们知道上面那段代码对子节点的展开是按照从左到右,从上到下的顺序,如果是一马平川的地图,某一个方向的节点总是优先被展开,那么就会产生路径偏向一个方向这种情况。那我们要做的就是把子节点的展开顺序错开。
类似这样,红色的格子代表先展开水平方向的节点,后展开竖直方向的节点,白色的反一下。这种先后的控制是通过在预处理阶段对不同的格子的子格子加入子节点列表顺序不同完成的。在搜索阶段程序只需要按照正常顺序展开子节点即可。
其实在地图预处理阶段还能对一些可以组成大格子的小格子们进行合并,从而达到减少格子简化地图的目的。
类似这张地图,黑色为障碍,左上角为起点,右下角为终点。我们把一些连续一片的格子合并成大矩形,如图中合并后变成了6个矩形。然后把矩形每个与外界接壤的小格子所对应的外部矩形作为一个子节点。比如棕色矩形有四个小格子总共接壤三个外部矩形,那么棕色矩形就有四个子节点,其中绿色子节点一个占两位。而耗损的计算,则是根据当前在棕色矩形内的格子位置,以及到达矩形所接壤的格子决定。这种格子合并技术非常适合实际的游戏地图,只是在那种障碍物较多分散并且随机性较大,的地图下发挥不出优势。另外一个不足的地方是前期合并格子的计算成本较大,后续也难以进行进一步优化。所以程序中并没有采用这种方法。
去除close表
A*搜索中存在两种表,open表和close表,这两个表的概念是从启发式搜索中引入的,open表保存等待将被展开的格子,这些格子是通过展开他们的格子得到的,我们需要不断从里面取出估计值最优的节点。close表保存已经被展开过的格子,但是如果里面的格子估计值更新,他需要从close表中删除重新加入open表。
网上曾有人讨论过在A*中close表存在的必要性,一个已经被展开过的格子是否有可能会在进一步搜索过程中产生估计值更小的情况?如果不可能,那么删除close表至少可以节约插入表的计算成本。
这里有个定理,是个充分不必要命题(不必要是因为我还没法证明它的必要性):
A*搜索树中任何一个节点n,如果n的子节点估计值都不小于n的估计值,那么从当它对应的格子从open表中取出时,格子拥有最小的估计值。
也就是说今后在搜索的不会再遇到格子n更小的估计值了。那么简而言之,估计值不递减,close表就没有存在的必要性。
欧几里得式和曼哈顿式都是不递减的。所以close表完全没有存在的必要性。我们只需要在格子内标记其是否被展开过即可。
简化open表的排序
我们搜索的时候需要每次到open表中取得一个估计值最小的格子。所以对于大多数情况下,open表需要每加入一个数据,就要把数据插入到合适的位置,从而保持open表的有序性。这种情况下,“堆”是最理想的数据结构。但是即使是最理想的数据结构,也要经过一个循环多次比较。
但是如果使用4方向地图曼哈顿式启发,情况并非那么复杂,因为在同一时刻数据结构中只可能存在两种值。因为首先对于一个格子来说估计值只有两种可能性,一种是增加2,一种是不增加。而从open表中取出的格子的估计值势必是一个较小值(假设其值是f),那么这个格子产生的子节点被加入open表,为open添加的值种类有f和f+2两种。只要open表中一直存在值是f的格子,那么open表中必定只有f和f+2两种值。除非不再有值是f的子节点加入,并且open表中的f值格子消耗完毕,这个时候最小值变成f+2,接下来open表中增加的就是f+2和f+4两种值。。。
所以无论怎么样,open表实际上只需要两个坑位,我们可以通过hash的方法把估计值映射到对应的坑位里。当然同一个坑位有很多估计值相同的格子,这些格子都被放入一个栈中。
如图就是open表的结构。这里之所以用栈存储相同值的格子是因为当搜索遇到障碍时,展开的节点估计值必定都会是+2,这时可以通过搜索头回溯寻找最接近的非+2展开点。反之如果使用队列,那么会造成从头开始搜的悲剧。
其他一些运算技巧
太零散,问到再说吧。
posted on 2013-02-04 16:01 Clifford 阅读(14060) 评论(3) 编辑 收藏 举报