A_star/ A*算法解决自动寻路问题


自动寻路问题在日常许多场合中均有运用:自动导航,游戏路线规划等,其中A*算法是一个被广泛运用且被认定有效的方法,本文章将会探讨并简单实现该算法,实现在迷宫中的自动寻路。

代码链接A_star

自动寻路问题和A*算法简介

在许多游戏中,以及地图导航、路线规划等领域,自动寻路算法扮演着至关重要的角色。这些算法的目标是根据起点和终点之间的地形、障碍物和其他限制条件,找到一条最优或可行的路径。自动寻路算法的广泛的应用和用处包括但不限于以下方面:

  • 游戏中的NPC行动:自动寻路算法用于游戏中的非玩家角色(NPC),使其能够智能地避开障碍物、追踪目标或规避敌人;

  • 策略游戏的路径规划:在策略游戏中,自动寻路算法用于计算最佳的行军路径、资源收集路径或建筑物布局;

  • 实时导航系统:自动寻路算法用于实时导航系统,帮助用户找到最短或最优的路径,以避开交通拥堵或规避其他限制条件.

而自动寻路算法也有其难度所在:如何平衡路径的准确性、可行性,和计算效率,自动寻路算法必须在保证找到可行路径的同时,尽量减少计算时间和资源的消耗,常见难点包括:

  • 地形和障碍物处理:自动寻路算法必须考虑地形的通行能力和障碍物的阻挡效应。例如,对于游戏中的角色或车辆,可能无法通过水域或高山等区域;

  • 路径搜索空间:如果只是笨拙的遍历搜索,地图的规模和复杂性会导致路径搜索空间的迅速增大,使得算法需要处理大量的节点和边。如何高效地搜索路径,以及如何避免陷入局部最优解成为关键问题;

  • 实时性要求:在游戏中,角色需要实时响应玩家的操作,因此自动寻路算法需要在有限的时间内提供可行路径。算法的速度和实时性是挑战之一。

总之,自动寻路算法在游戏和地图导航中具有重要作用,帮助用户和角色在复杂的环境中找到最佳路径,不仅需要考虑地形和障碍物,还需要解决路径搜索空间、实时性和计算效率等难题,以提供高效准确的路径规划。本次将探讨并实现自动寻路算法中一个被广泛运用的算法:A*算法,这是一种启发式搜索算法,结合了Dijkstra算法的最短路径思想和启发式估计函数来进行路径搜索。它在搜索过程中维护一个估计的最短距离,并通过这个估计值来选择下一个节点进行扩展,以期望更快地找到最佳路径。

算法设计

用Prime算法构造迷宫样例

在代码中,我手动构造了两个迷宫测试样例(调用Map_Example实现),可以用来检验算法能否找到最佳路径;而为了之后更好检测算法的搜索效率,我采用了基于Prime算法的迷宫生成方法(对应Map()函数),具体方法为:

  1. 初始化迷宫:创建一个空的网格作为迷宫,每个格子都被标记为墙壁;

  2. 随机选择一个起始格子,并将其设置为路径,并将将起始格子周围的墙壁添加到一个墙壁集合中;

  3. 当集合不为空时,重复以下步骤:

    • 从集合中随机选择一边墙;

    • 如果该墙另一侧的格子也是墙壁,将墙壁打通,使其成为路径;

    • 将与新添加的路径相邻的墙壁添加到集合中。

  4. 所有集合中墙壁都被处理完毕后,迷宫构建完成。

这种迷宫构造算法有一些优良的特性:

  • 迷宫中的路径是连通的,从起点到终点存在唯一路径;

  • 迷宫中的墙壁形成了分离的区域,没有环路或孤立的墙壁;

  • 迷宫中的路径较为均匀分布,没有长时间的死胡同;

正好适合用于测试算法的代码,并且通过设置随机数种子,可以使得每次构造出同一个迷宫。

在这里插入图片描述
在这里插入图片描述

A*算法自动寻路

在算法设计部分,A*算法最重要的是启发函数的设计,用于指导搜索过程并决定节点的探索顺序。

启发函数基于对问题的先验知识和领域特定的信息,提供了一种对节点的估计值。它通常通过计算当前节点到目标节点的预估代价来衡量节点的优先级。启发函数的设计应该满足以下两个条件:

  • 无过估计(Admissible):启发函数不能对从当前节点到目标节点的实际代价进行过高估计,否则,A*算法可能会错过最优解;

  • 一致性(Consistency):启发函数应该满足一致性条件,也称为单调性或者迭代优化性。这意味着从起始节点到任意节点的实际代价,加上该节点到目标节点的估计代价(即整个启发函数值),不会超过从起始节点直接到目标节点的实际代价。数学上表示为: f ( n ) = g ( n ) + h ( n ) ≤ d ( s t a r t , g o a l ) f(n) = g(n)+h(n) \leq d(start, goal) f(n)=g(n)+h(n)d(start,goal),其中f(n)是节点n的启发函数值,g(n) 指的是从起始格子到格子n的实际代价,而 h(n)指的是从格子n到终点格子的估计代价,d(start, goal)是从起始节点到目标节点的实际代价;

常见的用于计算启发函数值的估计函数有欧几里得距离、曼哈顿距离,我将先以曼哈顿距离为启发函数设计算法。为了满足无过估计的要求,只能对上下左右四个方向检索格点。

算法的主要步骤如下:

  1. 将起始格子放入到一个open列表(代表待检索)中;

  2. 循环判断open列表。如果为空,则搜索失败,否则进行迭代;

  3. 从open列表中取出F值最小的节点作为当前节点,并将其加入到close列表(已经检索过)中,如果该节点等于目的点,则搜索成功;

  4. 检索当前节点的所有相邻节点,对于每一个子节点:

    • 如果该节点在close列表中,则跳过;

    • 如果该节点在open列表中,则检查其通过当前节点抵达它的G值是否更小(代表走此条路径更近),如果更小则更新其G和F值,并将其父节点设置为当前节点;

    • 如果该节点不在open列表中,则将其加入到open列表,并计算F值,设置其父节点为当前节点。

用伪代码表示为:

在这里插入图片描述

数据结构说明

在实际实现中,我用了一个最小堆(priority_queue)作为Open列表,存取节点(myPoint型,包含节点坐标和启发函数值),因为该算法会频繁取出当中最小的元素,但是由于算法中可能会更改Open列表中的元素的启发函数F值,如果直接修改最小堆的元素,会导致异常,因此此处我采取了另一种策略:即直接往最小堆中置入更改后F值更小的节点,而更小的节点会更早被取出加入Close列表,在此后的取节点时,只用判断取出的节点是否已经走过(在Close中)即可。

此外,还有一个n*n的OpenNode数组来存贮格点的指针,这也是处于priority_queue不方便进行随机访问的原因考虑的。在算法中,当判断一个相邻节点在Open列表中时,可能需要更改这一节点的启发函数值,此时无法在最小堆中获得,而这样的一个二维数组则可以做到快速的随机访问。

而Close列表,我则用了一个n*n(迷宫尺寸)的bool型数组(代码中为Path)来维护,因为判断一个节点是否是Closed时,需要根据其坐标随机访问,利用二维的bool数组可以达到目的。

总的来说,在最坏情况下,所有节点均进入最小堆,其空间复杂度为 O ( N ) O(N) O(N),而另外几个辅助数组的空间复杂度也是 O ( N ) O(N) O(N),因此可以认为,A*算法的空间复杂度是 O ( N ) O(N) O(N)。(N是格点的数目)
具体的实现可以参见一同上交的代码。

作为对比的Dijkstra算法进行自动寻路

在A*算法出现前,Dijkstra算法曾被运用于自动寻路,Dijstra算法运用贪心的策略,不断找寻代价最小的路径,直到抵达最终目的节点。这是一种有效的算法,但其性能也受到图的规模和边的数量的影响,对于大规模的图或者边数较多的情况下,Dijkstra算法的时间复杂度很高,会导致计算时间较长并且占用空间较高。

实际上,在迷宫的场景中,任意两个相邻格子间的距离相等(权值相等),此时Dijkstra算法退化为广度优先搜索算法(BFS)。此时,这种策略的算法按照节点的层次逐层进行扩展,先扩展距离起点为1的节点,然后是距离起点为2的节点,以此类推,直到找到终点或者扩展完所有可达节点。

由于这种算法不是讨论的重点,此处不再赘述,具体可见代码的Disjkstra()函数。

复杂度分析

A*算法的复杂度取决于几个因素,包括搜索空间的大小、启发函数的选择和存储节点的Open列表等的实现方式。

使用曼哈顿距离作为启发函数来计算A*算法的启发函数值时,复杂度与使用其他启发函数相比可能会有所不同。

基于本例的时间复杂度分析如下:

  • 对于每个节点,需要计算该节点到目标节点的曼哈顿距离作为启发函数值。
    计算曼哈顿距离的时间复杂度为 O ( 1 ) O(1) O(1),因为只需将两个节点的行差和列差相加即可;

  • 在节点扩展过程中,需要根据启发函数值进行优先队列的插入和删除最小值操作,时间复杂度为 O ( l o g N ) O(log N) O(logN),其中N为优先队列中的节点数量,最坏情况下所有格点都会进入优先队列;

  • 在上述的需要为了修改最小堆中数据而采取的策略中,添加F值更小的格点到最小堆的时间复杂度也是 O ( l o g N ) O(log N) O(logN),一个节点最多只可能被重复这样操作4次(分别被来自其上下左右的格点更改F值);

  • 对于判断是否在Close列表或是Open列表,或是从Open列表中取出格点指针,由于采用了二维数组的方式存储,时间复杂度均为 O ( 1 ) O(1) O(1)

空间复杂度方面,在数据结构说明部分已经给出。

综上所述,在每个节点扩展过程中,计算曼哈顿距离和优先队列操作的时间复杂度均不算高,在最坏情况下,可能迷宫中每个节点都会进入优先队列并被扩展,因此总体的时间复杂度为 O ( N l o g N ) O(N log N) O(NlogN),其中N为节点数量。但实际上,由于A*算法有较强的搜索方向性,进入优先队列的节点数会远小于总节点数。总的来说,A*算法总能在合理的时间能找到解。

程序说明与运行

在本次实现的代码中,构建了39*39的迷宫矩阵(可做更改,但由于Prime算法生成迷宫的特殊性,只能设置为奇数),默认起点为(1,1),默认终点为(37,37),可以通过修改代码中endX和endY来更改终点坐标。

样例说明

程序代码中有两个静态的测试迷宫样例:Map_Example1(),Map_Example2(),通过调用函数生成,这两个静态样例用于测试算法是否能够找到最优(最短)的路径。

在这里插入图片描述
在这里插入图片描述

而随机迷宫的动态样例调用Map()即可生成,生成的迷宫中有且仅有一条可行通路,用于检测算法是否能有效快速地找到可行的路径。

运行结果

静态样例结果

运用A*算法得到的结果如下:

在这里插入图片描述
在这里插入图片描述

而作为对比的Dijkstra算法(实际上退化为BFS)运行结果如下:

在这里插入图片描述
在这里插入图片描述

图中蓝色*点表示真实检索走过的格点,绿色*则是最终的路径,用A*算法得到的路径代价与Dijkstra算法得到的代价相同,但是在检索效率方面,这两例中A*分别检索了659,826个节点,而Dijkstra算法检索的点数分别是1333和1295,采用A*算法在减少了检索的节点数和空间、时间开销的同时,也能保证找到最优的路径,提升斐然。

随机迷宫样例测试

在用Prime算法生成的迷宫中,有且仅有一条从默认入口到默认出口的道路,因此不管哪种算法只能找到同一条路线。相同迷宫的测试组1:

在这里插入图片描述
在这里插入图片描述

在这组测试中,A*算法检索了291个格子,而Dijkstra则检索了716个格子,A*算法仅检索了Dijkstra的40.64%;

在这里插入图片描述
在这里插入图片描述

这组测试中,A*检索了255个格子,而Dijkstra检索了718个格子,前者只检索了后者的35.52%。综上可见,没有启发性的Dijkstra在这样起点和终点分距两端的情况下总是要几乎检索完所有格子才可以找到路径,而A*算法则可以快速的找到路径,相比Dijkstra是有巨大优势的。

A*算法的进一步探讨

对A*算法的探讨,更多其实是对其启发函数 f ( n ) f(n) f(n)的探讨。在这方面我做了如下两个尝试:

  1. 为启发函数添加一个参数变为: f ( n ) = g ( n ) + C ∗ h ( n ) f(n) = g(n) + C * h(n) f(n)=g(n)+Ch(n),C的大小将决定最终f(n)更侧重起点到点n的实际代价,还是点n到终点的估计代价。在此前的A*搜索中,搜索了过多远离终点而靠近起点的格点,这是因为 g ( n ) + f ( n ) g(n)+f(n) g(n)+f(n)可能会有此消彼长的情况,可能距离终点很近的点由于远离起点反而不会被优先扩展,在一些只要求路径可行的情况下这是非常影响效率的。尽管这可能违背上述的无过估计原则,但是在迷宫中仅有一条可行路径的情况下,可以探讨这带来的搜索效率的变化。

    增大 C值会使得算法更加倾向于选择估计代价较小的格点。这可能会导致算法更加快速地找到一条路径,但路径可能不是最优,因为它更注重快速接近目标节点而忽视了实际代价;

    减小 C值会使得算法更注重实际代价,倾向于选择实际代价较小的格点,这有助于保证路径的最优性,但可能会导致算法搜索的时间增加,因为它更加细致地探索了路径的各个方向。当C不断减小,甚至逐渐趋近于0,A*算法就会不断靠拢Dijkstra算法(本例中退化为BFS算法,因为各格点间权值相同)。通过对getPriceH()的启发函数进行调整即可达到加入C的效果;

  2. 更换启发函数。此前的A_star()函数中,以曼哈顿距离为启发函数,并且只能在四个方向上拓展,这将有一定的局限性,在起点终点处于对角线等情况时,会导致搜索效率不理想。

    而如果直接采用欧几里得距离也会有弊端:一是浮点数还有开方的运算,将降低性能;二是算法中只能沿对角扩展或平移,不能真正像欧氏距离那样在两点连线上移动,导致欧氏距离下H值永远小于或等于格子n到终点的最短实际距离。因此在这种方案中,采取斜向距离+直线距离的策略,这样折中但更优方式计算启发函数值F:

在这里插入图片描述

如该图中,这样的计算方式保证了不考虑障碍物的情况下,H肯定等于格子n到终点的最短实际距离。我在程序代码中实现了H_O函数采用这种方法计算H值,A_star_diag()函数以这种方式进行A\*搜索,为了对比,我也写了一个可以进行对角扩展的Dijkstra算法,对应代码中Dijkstra_diag()。

测试结果

首先在静态样例中测试是否能找到最短路径,以及搜索效率,经过多次检验后得到结果:

Example1Example2
路径代价检索节点耗时路径代价检索节点耗时
Disjkstra7301333157ms7501295165ms
普通A_star73065965ms75082680ms
C=2的普通A_star7307313ms89044241ms
Disjkstra_diag5621333119ms6121295128ms
A_star_diag56232130ms61259578ms
C=2的A_star_diag5624710ms62826529ms

可以看见,仅仅把C设置为2,就可以使搜索的耗时和检索节点数大大减少,不过,在Example2的例子中,并没能找到最短的路径。而换用另一种启发函数的A_star_diag中,由于更贴合起点与终点的空间情况,能够更快的搜索到最短路径,并且对其也加入等于2的参数C,使得效率还有更高的提升。在实际测试Example1时,将C提升至2.5甚至可以一次性找到最好的路径:

在这里插入图片描述

此外在迷宫中测试各自的搜索效率:

Maze1(设置随机数种子0)Maze2(设置随机数种子1)
路径代价检索节点耗时路径代价检索节点耗时
Disjkstra81072097ms850715103ms
普通A_star81052870ms85053778ms
C=2的普通A_star81017017ms85033329ms
Disjkstra_diag720720104ms700718102ms
A_star_diag72051767ms70049969ms
C=2的A_star_diag72621923ms72433535ms

综上可知,加上参数C后,尽管会违背无过估计原则,使得估计代价会高于实际代价,但是在这样的策略中,比较格点时会更看重距离终点的距离,使得搜索更加快速,在不要求路径最优,或是需要节省计算成本时(如一些小型游戏等等),可以采用这样的策略,能快速有效实现自动寻路。

而更换启发函数后,能够在对角线方向上拓展检索,也能使搜索效率提高。同样的,如果这种方法加上了参数C,也会带来搜索效率的提升,能通过探索更少的格点就找到路线,但是得到的结果可能不是最优。

在本次迷宫的情景中,只有一条可行路径,在这样的情景下探索最短路径是没必要的,因此此时用带有参数C的能对角扩展的A*搜索算法A_star_diag效果最好。

一些其他情况下运行结果

C=2.5时:
在这里插入图片描述
在这里插入图片描述

  • 1
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值