问题描述
- 在二维蜂窝网格地图上,寻找一条从任意起点到任意终点的最短路径
难点分析:
- 寻路算法由于其调用频繁,对性能要求很高;同时由于用户直接看到算法的效果,需要对寻路结果美化
问题拆解:
- 首先需要找到一种在二维平面任意点间快速找到通路的算法,深入了解数据的流转过程后需要尝试对时空间消耗的优化
- 理解蜂窝网格地图中,点于点间关系的不同
- 多个切面对比多种算法,选择当前场景的最优解
解决方案:
- 我最终的一版中,是基于A*算法 + 使用消息队列 + 变权值抗锯齿 + 提高GC频率 的结果
- 过程中,一些探索性实验暂无结果
- 尝试通过 “参考方向选择next节点” 的手段抗锯齿,但结果还没法让人满意
- 尝试通过 “双向搜索” 的方式优化耗时,但预备技术较多,暂定TODO
- 对于物理资源的优化层次较浅
优化思路:
以下大篇幅按照时间序列记录我关于寻路算法的探索路径:
- V1:朴素的A star算法
- V2:构建基于蜂巢网格的A star 算法
- V3:基于A star的第一次优化 —— 使用优先队列
- V4:基于A star的第二次优化 —— 方向选择抗锯齿
- V5:基于A star的第三次优化(伪) —— 增大行动步长
- V6:基于A star的第四次优化 —— JPS
- V7:基于A star的第五次优化 —— 双向搜索
- V8:资源优化初探
以下是对性能优化的结果统计,每版都是 从(300,200)到(1200,1200)的单次寻路 ,其中关键优化在于引入优先队列和改变h值占比
版本 | 时长 | 探测点数 | 最终步长 | 特性 / 优化 |
---|---|---|---|---|
V1 | 38.8s | 20012 | 1512 | 粗略的A Star |
V2.1 | 11s | 4929 | 1806 | 提高h值占比 |
V2.2 | 9s | 3935 | 1710 | 改用欧几里得距离 |
V3 | 0.1172s | 3935 | 1710 | 使用优先队列 |
V4 | 0.1970s | 8206 | 1733 | 抗锯齿 |
V5(伪) | 0.144s | 5940 | 1070 | 增大步长 |
V6 | 0.72s | 687 | 285 | JPS |
V7 | 预计在0.1S下 | 预 | 双向搜索 |
以下报告分为两部分,首先是关于以上探索流程的代码分析和结果呈现,其次是过程中解决问题的记录
-
V1 基础版本
-
A Star 算法简述
相比于BFS,A Star算法通过启发函数,尝试找到指向目标节点的方向,以此在减少节点遍历程度。
具体过程为,对于出发点周围的可达点,计算其f值(启发函数值,用来刻画从上一个点到终点,经过该点时的成本),将其加入探索点集合(open_set),从中选择f值最小的点,作为下一步的出发点。并将其加入以探索集合(close_set),防止绕圈;再从出发点迭代寻找。
方法精妙的点在于,当发现某个新的点之前被探索过,但这次新计算的f值比之前的更低,本质上意味着新的这条路比之前的路更短,此时将该节点的parent属性更新,即完成了路径的选择。
最后从goal_node,沿着每个节点的parent反向寻址,即可找到f值最低的整条路径。
-
实验分析:
-
结果:
访问节点:20012 步长:1512 time cost:38.804S
图片由python绘制,黄线表示最终路径,蓝色区域为探测范围;绘图由另一个程序完成,计时中并无绘图IO
-
基于蜂巢网格的A star 算法
游戏地图中,像素点的分布不同于横平竖直的网格,最终的呈现一个像素点周围6个邻居的蜂巢网格,这需要对A*算法中的如下细节进行修正
-
到达成本为1的邻居由4个变成6个
-
刻画点与点之间距离的方式变得抽象
一种刻画距离的方式是类似数北京一环二环:
具体来说,(q=0,r=0,s=0)到(q=0,r=3,s=-3)相隔三层
使用曼哈顿距离
(abs(aq - bq) + abs(ar - br) + abs(as - bs)) / 2
计算可得(0+3+3)/ 2 = 3实测路线为上图:
可以发现,使用曼哈顿距离会比较偏好沿着q、r、s三个轴的方向;
以下有两种优化思路:
-
既然曼哈顿天生偏好走直线,那只需要将h值的占比提高,略微降低最优性,提高搜索速度
访问节点:4929 最终路径长度:1806 time cost = 0:00:11.51
我将h值直接平方倍,但时间消耗减少成四分之一,路线选择上确实随意了许多
-
曼哈顿距离的描述可能有点不合理,比如说上图中(0,0,0)到(0,3,-3)或(-1,3,-2)的曼哈顿距离相同,但是肉眼可见到(-1,3,-2)更近,这使用欧几里得距离可以修正
实测效果:
访问节点:3935 最终路径长度:1710 time cost = 0:00:8.91
经过其他点的多次实验,表明使用 欧几里得距离 + 增大h权值 更好
-
-
V3 引入优先队列
-
简述:
二版本中考虑优化从open_set中取出最小值的操作,有以下两个思路
-
维护原本open_set内部有序,添加元素时插入到有序位置**(O(n))**,取最小值只需要访问头部元素
-
引入优先队列优化open_set,由于open_set在程序中需要完成 根据k-v快速查找(O(1)), 迅速取出最小值(O(1)),快速添加删除元素(O(logn)) 所以我选择用空间换取时间——保留原始k-v的table,平行添加一个优先队列;
基于最差时间复杂度的考虑,使用第二种方案
由于Lua没有原生的k-v类数据结构(类似Java中Map.Entity),于是手动封装Outernode类型,作为优先队列的元素。
-
-
测试结果:
访问节点:3935 最终步长:1710 time cost = 0:00:00.117248
可以看出,使用优先队列后缩减了大量时间,故原始A*算法中,时间的消耗主要在open_set中大量的排序上;
-
-
V4 抗锯齿
-
简述
原先基于优先队列选择f值最小的方案没有考虑方向,如果想要方向上尽量和之前的一致,下面有三种思路:
- 优先队列中 并不优先选f值最小的项,而是在几个f值够小的项中,选择一个和上次方向一致的
- 由启发函数规避拐点
- 增大步长( V4 中说明)
Implement
-
在第一种思路下,考虑优先队列前n个元素,n理论上应该取3,因为由A点向B点方向前进一格只有三种方向,所以在open_set前三个元素中必然有和上次移动同向的(没有则说明可能有障碍物)
n == 3
n == 30
提出纯路线:
其实可以看到,算法是沿着预设的思路走的,它确实尽可能的在沿着上一步的方向走,直到碰到障碍物不得不拐弯;但是可以看出效果并不好,原因之一如下:
-
原本如果是沿着类波浪线行走,会被算法优化 成拐弯较大的直线
当地图的障碍物较多时,拐弯就和十分频繁,上图的”楼梯“状寻路原因就在于此,虽然实现了走更多的直线,但失去了丝滑的美感,对用户而言体验不好
-
第二种思路的精妙之处在于:它通过改变整个A*算法寻路的命脉——启发函数,通过将点的状态抽象量化成数值,来自动规避掉不符合预期的点;
它将F值附加了额外代价C,用来刻画一次行走是否是拐点;这很像计量经济学中虚拟变量的运用,都是将状态量化成数值的思路
-
核心代码:
function calc_G(x, y, n2, cost, last_direction) local ax1 = rbUtils.evenrPos_to_axial(x, y) local ax2 = rbUtils.oddr_to_axial(n2) ♥ if ax1.q - ax2.q == last_direction.q or ax1.r - ax2.r == last_direction.r then -- 直线 return cost else return cost + 2 end end
功能为,在一个点扫描到某邻居时,判断这个邻居和当前点间的方向和之前的方向是否一致,如果方向不至于(出现转弯点,则增加2点f值,使得对这个点的偏好超过直线方向
(实测中发现,♥号标注的判断无论使用 且 还是 或 效果几乎没有区别,说明当一个方向上匹配时,选出的那个点在另一个方向上也会匹配)
-
测试效果:
访问节点:8206 最终步长:1733 time cost:0.197s
宏观的路线看不出细节的差异,以下选取更特殊的例子加以说明
即使是在X-Y坐标轴,偶数列和轴坐标有半个像素的错位,但平滑的优化程度可以提现
-
-
第三种思路作为V5
-
-
-
-
V5 增大步长(伪提升)
-
部分代码
motion = [[2, 0, 2], [0, 2, 2], [-2, 0, 2], [0, -2, 2], [-2, -2, 2], [-2, +2, 2]]
-
简述:
增加步长有逆天的优化效果,但这种效果其实是假象
甚至当步长为4个单位时,几乎要沿直线前进了
但其实很明显可以看到,随着步长越来越长,原本是障碍物的点都被如履平地了;所以绕的路少了,加入open_set的点少了,时间自然减短很多,但可以并不适合这张地图
访问节点:5946 最终步长:1170 time cost:0.144
我们的地图里,小障碍物很多,于是增大步长的操作会导致用户移动时和障碍物穿模,并不适用
但假如某些地图中,障碍物全是大山大河,使用增加步长的方式想必是最快的优化,因为假如步长变成n倍,地图可以被探索的区域数量就变成n方分之一。
-
-
V6 JPS
-
简述
JPS可以说是A* + 剪枝,就是将一些显然的中间节点跳过,不添加到open_set中
感性的理解JPS:在起点到终点之间,不能直线通过是因为要避开障碍物,而从起点到某个障碍物可以直线到达。jps通过在障碍物附近寻找forced Neighbor,继而确定探索过程能否直接跳到跳点。
-
结果堪称银弹
0:00:00.733006
适当降低h占比提高精度
-
-
V7 双向搜索
之前的版本中,无数次调整h值占f值的比例,为了在准确和效率中找到平衡;假如换一种思路,让两个线程并行计算,一个从起点向终点探索,一个从终点向起点探索,二者共享静态close_set集合,当其中出现交集,说明找到了通路
首先需要解决的第一个问题就是:假如两条线碰不上呢?
- 即使在最坏情况下,两个线程分别找到两条路,时间上和单一线程相比可能只相差了CPU切换的时间
- 当给A*算法中,g的比例升高,理论上一定会找到最优解;所以当保证两个线程都是基于最优解来探索,那么一定会在最优解的某处碰面。
其次就是编码问题,预计难点在于:lua多线程,共享资源加锁...
-
V8 资源优化
因为这里考虑的是CPU和内存如何优化,其中运行耗时也是参考指标之一;假如选择运行太快的算法很难体现改变,同时运行太快导致我在资源监视器很难捕捉到有效信息,所以选择常规运行时长为10S左右的一套代码。后期学习gperftools对资源利用做更细致追踪
-
原始资源消耗
-
CPU:7%左右
-
内存:167MB
- 耗时:10.454S
-
-
关于内存消耗的优化,我查到提高GC频率有显著效果
collectgarbage("setpause",100) collectgarbage("setstepmul",5000) collectgarbage("restart")
提高gc频率后效果如下:
- 内存:53MB
- 耗时:14S
-
减少不必要对象或table
我的优先队列版本中,每个Node{x,y,cost,parent}外由包了一层OuterNode{c_id,Node}作为PriorityQueue的元素,这就导致扫描过的点构成的对象翻倍,但都是小对象可能也很适合mark-and-sweep
-
之前内存消耗(设置提高GC频率的参数下):65MB
-
取消OuterNode,其他全部封装入Node后,内存消耗 90MB:
这种匪夷所思的结果,我猜想可能是因为lua垃圾回收器默认使用mark-and-sweep方式,这种内存管理的方式天生适合内存占用较小的对象,即使这些小对象数量很多,但朝生夕死,同时任何内存碎片都能被充分利用;而较大的对象由于无法存入碎片空间,只能动态申请额外内存,或者Stop the world来垃圾回收。
综上所述还是将代码改成小对象间引用的模式。
-
-
各版本总结与对比
-
寻路算法本质上就是在一张地图上找到两个点之间的通路,这种人眼一下就看出来的问题交给计算机却需要相当程度的运算,这其中的原因之一就在于——计算机不知道终点大致在哪个方向
于是早期的寻路方式简单暴力,如BFS和贪心,分别依靠算力和运气来寻路
而 A Star 算法的出现打破了这个局势,正是因为A Star通过启发函数来刻画人类对终点位置的感知
-
A Star的出现并发万事大吉,对其运行时的优化工作还能提升很多性能
数据结构层面:我们需要尝试一些更精简的数据结构让算法的时间复杂度和空间复杂度减低
算法本身层面:由于对算法的评估并不是单纯的最快或最好,而是添加其他指标(如丝滑、避障)后,各方面都力求满意解。这就可能需要引入其他变量,或使用某个子算法来优化
底层支持层面:算法运行于语言之上,语言运行在虚拟机之上,虚拟机运行在操作系统上,每一层都有潜在的问题和优化空间
-
A Star之后,其他成功的衍生算法应运而生
-
AGV算法在A Star的基础上,引入估价权重并考虑栅格间距值设定,提出拐点回溯处理的路径优化方法来减少不必要拐弯次数
-
JPS & JPS+,JPS算法尝试减少加入open_set点的个数,JPS+在JPS之上对地图进行预处理,但这个思路并不适合动态地图,而据刚哥说我们的游戏地图更改频繁,计算需要根据实时地图进行
-
B Star算法仿照真实动物寻路过程,遇到障碍物则分两个方向分别探索,据说效率是普通A*算法的几十倍
-
问题记录
-
相同内容的table并不是同一块内存,而table作为hashmap时,key是否相同是直接看这个对象的内存地址的
在open_set中,原本尝试key直接用点的坐标 { x = 500, y = 1000 } 形式,但是出现程序无法终止的情况
具体原因如下
- 程序无法终止,是因为open_set中选择的下一个点在路径上徘徊
- 徘徊的原因是:出现了比目标点f值更小点,且这个点在宏观路径上是往回走的
- 理论上f值由g和h组成,h值越接近终点越小,g一直差不多,所以f应该越走越小;出现前面的某个点f值比后面点f值小的原因是:前面的没删掉
- 通过观测每轮中,open_set的大小,可以推测出确实是因为删除失败
- 而删除失败的原因是:通过新构造的 { x = 500, y = 1000 } ,无法对应到open_set中原本的key。
解决方案是:将坐标的table序列化成数字
function JPS:positionSerialize(jp) local x = jp[1] local y = jp[2] local serialization = x * 10000 + y return serialization end
-
关于 初始A*算法时间过长 的debug
在寻路地址从(400,400)到(1200,1200)测试中,出现时间超长无法结束的问题,我将寻路的过程动态生成后观测其搜索过程,时序截图如下
传统A*寻路中,大致会首先沿着一条线走,碰壁后从新的一条线出发(抽象的说,这些遍历到的区域应该是由start_node到goal_node方向的平行线汇聚的);但我的程序内,探索过程就像Dijkstra一样;这里就怀疑h值应该提高占比
于是推断在离start_node较近位置的一些节点,g值数值较小且相近,h值较大,但由于开方操作降低了部分精度;导致对 f 的计算中 h 价值降低,当提高h的权重后,效果立竿见影的提高