A*寻路算法

编程 · 2024-04-26 · 23 人浏览
A*寻路算法

今天看了一篇文章《Introduction to the A* Algorithm》讲A*算法,讲的很通透,虽然篇幅较长,不过这个算法的来源和延伸都讲得比较透彻。所以强烈建议观看原网站跟着走一遍,原网站的动画演示十分直观。
如果你不喜欢大段英文,那么这篇文章是对该博客的总结和转述,希望对各位看官有所帮助。

表达地图

首先我们要了解整个算法要处理一些什么,又会输出些什么。
对于算法来说,它不会在意你的地图上有什么元素,它唯一在意的东西就是图的位置(节点)和连接(边界)。
当你把图的顶点和边告诉它,它就会告诉你一条通往目标位置的“路”。
当然,这个“路”只是一个抽象概念,实际上只是返回一个节点接着另一个节点的数组。
所以本质上算法只会告诉你从原点到终点会经过哪些点,如何反应到游戏里那就是另外的事情了(比如通过门要开门、下水要游泳、走直线还是曲线,这些东西还得另外考虑)。
是的,你的寻路图不一定都是规则网格状的,也可能是不规则的图,虽然对于算法来说都是一样的,但是游戏里的角色穿门而过那就是影响游戏质量的BUG了。

算法

接下来是算法时间。
首先总结一下涉及到的算法:
广度优先算法 -> 迪科斯彻算法(Dijkstra’s Algorithm) -> 贪心最优搜索(Greedy Best First Search)-> A*
“->” 是原文章介绍的顺序,一步步的探索算法的演变过程,可以清晰的理解算法之间的差异和特点。

广度优先(Breadth First Search)

开始的开始,最原始的查找,往往都是遍历所有元素。
广度优先就是这样一种算法,它在做什么:

  1. 找到一个相邻点。
  2. 将它标记为已访问,扩展它的邻居(记得跳过墙,或者说障碍物)。
    重复以上过程,直到没有一个未访问的相邻点。
    这是最原始的广度优先算法,它会像涟漪一样泛起,然后扩散到整个地图。
    所以那一圈涟漪被叫做“frontier”,边界或者前线。

    frontier = Queue()
    frontier.put(start)
    reached = set()
    reached.add(start)
    
    while not frontier.empty():
    current = frontier.get()
    for next in graph.neighbors(current):
       if next not in reached:
          frontier.put(next)
          reached.add(next)


    这样的查找实际上并不能告诉我们最短的路径在哪,它只是自由的泛滥。

    广度优先算法不只是能查找路径,实际上还有很多其他的应用存在,比如距离地图、程序化地图生成等。

所以我们需要构建一个关联(came_from),以知道来时的方向。

frontier = Queue()
frontier.put(start )
came_from = dict() # 通路 A->B 存储为 came_from[B] == A 的形式
came_from[start] = None

while not frontier.empty():
   current = frontier.get()
   for next in graph.neighbors(current):
      if next not in came_from:
         frontier.put(next)
         came_from[next] = current

这只是相当于在全地图记录了一下起点方向信息,想要知道通路,我们只需要从终点倒推回起点。

current = goal path
path = []
while current != start: 
   path.append(current)
   current = came_from[current]
path.append(start) # optional
path.reverse() # optional

不过就算我们获得了想要的通路,算法依然会持续下去,直到遍历完所有的节点。
而我们想要在获取目标路径之后停止循环,这样能节省很多资源。

frontier = Queue()
frontier.put(start )
came_from = dict()
came_from[start] = None

while not frontier.empty():
   current = frontier.get()

   if current == goal:  #停止循环
      break           

   for next in graph.neighbors(current):
      if next not in came_from:
         frontier.put(next)
         came_from[next] = current

Dijkstra’s Algorithm

对于广度优先算法来说,所有的节点他们的“代价”是相同的,就像如履平地一般。
但是如果我想表示地图上不同的状态(比如山川、河流与平原),可能会需要不同的值来表示通过的难度。
那就需要引入“代价”这个概念了。

使用cost_so_far来记录从起点到当前节点的代价(也叫“距离场(distance field)”)
现在需要把我们记录边界节点的队列变成优先队列,以便找到下一个更优节点(代价值最小的节点)。

frontier = PriorityQueue() #优先队列(最低代价)
frontier.put(start, 0)
came_from = dict()
cost_so_far = dict()
came_from[start] = None
cost_so_far[start] = 0

while not frontier.empty():
   current = frontier.get()

   if current == goal:
      break
   
   for next in graph.neighbors(current):
      new_cost = cost_so_far[current] + graph.cost(current, next) # 获取新代价
      if next not in cost_so_far or new_cost < cost_so_far[next]: # 更新节点代价
         cost_so_far[next] = new_cost
         priority = new_cost
         frontier.put(next, priority)
         came_from[next] = current
graph.cost(current, next) 这里只是返回了next的代价值。有的时候判断会用到current, next两个值。


图中绿色的地块代表高代价值的森林。
你会发现路过森林的部分走的很慢,最终最佳路线绕着森林走了过去,而广度优先直接穿过了森林。
这是因为每次找出边缘的最优节点的时候,都会在该节点的相邻节点里找到一个局部代价最优结点。
所以循环往复找到的通路也一定是最优解。

不过这样的算法边界还是往四方蔓延,所以寻找全部目标比较适用。
一般来说,目标点只有一个,如果我只往目标节点方向走,岂不是节省很多时间。

启发式搜索/贪心最佳优先搜索(Greedy Best First Search)

为了向目标方向迈进,这里引入了“启发函数”

def heuristic(a, b):
   # 方格地图的曼哈顿距离
   return abs(a.x - b.x) + abs(a.y - b.y)
启发函数有很多种:
对于四方移动(上下左右)来说,曼哈顿距离是最适合的。
八方向移动可以用切比雪夫距离。
任意方向移动一般用路点和导航网络来描述地图,比较适用欧几里得距离(勾股距离)
开方开销比较大,所以可以使用Octile距离代替

启发函数的作用是为了估算两点间距离。
在Dijkstra算法中,使用最优节点与起始节点实际距离来作为优先级。
而在启发式搜索,改用启发函数对最优节点与目标节点估算距离来作为节点的优先级。
不参考cost_so_far参数,而是一路向着离目标最近的节点前进。

frontier = PriorityQueue()
frontier.put(start, 0)
came_from = dict()
came_from[start] = None

while not frontier.empty():
   current = frontier.get()

   if current == goal:
      break
   
   for next in graph.neighbors(current):
      if next not in came_from:
         priority = heuristic(goal, next) # 估算距离
         frontier.put(next, priority)
         came_from[next] = current


这样的好处是更快的接近目标节点

坏处是在复杂情况无法做到最优解

A*

显然最优解是很重要的,这也是为什么要使用A*而不是启发式搜索。
Dijkstra算法告诉了我们最短路线,却无法控制走向。
启发式搜索让我们更快找到目标点,却不是最短路线。
每当面临两难结局,总有人会跳出来说我全都要,这个“人”就是A*。

A*结合了Dijkstra的真实距离和启发式搜索的估计距离。
这段代码和Dijkstra的几乎一样:

frontier = PriorityQueue()
frontier.put(start, 0)
came_from = dict()
cost_so_far = dict()
came_from[start] = None
cost_so_far[start] = 0

while not frontier.empty():
   current = frontier.get()

   if current == goal:
      break
   
   for next in graph.neighbors(current):
      new_cost = cost_so_far[current] + graph.cost(current, next)
      if next not in cost_so_far or new_cost < cost_so_far[next]:
         cost_so_far[next] = new_cost
         priority = new_cost + heuristic(goal, next) # 差别:优先级结合真实距离和估计距离
         frontier.put(next, priority)
         came_from[next] = current



由以上两图可以看出,当Dijkstra算法找出最佳通路的时候A*也找到了,还访问了更少的节点。
启发式搜索访问了少量的节点,但是结果却不是最优解。
只要启发函数没有高估距离,A*就能在使用启发函数排序以便快速找到方向的同时,像Dijkstra算法一样找到最优通路。

问题

寻路算法应该用哪个?

如果寻找多个目标,应该使用Dijkstra算法和广度优先算法,Dijkstra算法用于有代价的状况,广度优先适用于没有代价的状况。
如果寻找单个目标,或者最短路径,应该考虑A*算法。

关于最优解

广度优先和Dijkstra保证找出最优解。A*在估值小时倾向于Dijkstra,估值大的时候倾向于启发式搜索。

关于性能

最好的办法是减少图中不必要的位置点,减小寻路图的大小。使用最简单的算法;更简单的队列运行得更快。贪婪最佳优先搜索通常比 Dijkstra 算法运行得更快,但不会产生最佳路径。 A* 是满足大多数寻路需求的不错选择

关于非地图

这些图搜索算法可以用于任何类型的图,而不仅仅是游戏地图。地图上的移动成本可以变成图连接上的任意权重。启发式方法并不容易转化为任意地图;你必须为每种类型的图表设计一个启发式方法。

寻路算法 A* 算法 游戏制作
Theme Jasmine by Kent Liao