A*算法

来源:互联网 发布:c语言switch case用法 编辑:程序博客网 时间:2024/06/06 13:44

翻译自:http://www.redblobgames.com/pathfinding/a-star/introduction.html  感谢作者

在游戏中我们常喜爱那个寻找一条从一个点到另一个点的路径。我们不仅尝试寻找最短路径,有时候我们还想把路径消耗也计算到代价中在寻找路径。在map图中,穿过水路很明显要慢一下,所以,如果可以的话,我们想找一条可以避开水路的路径。下面是一个交互式的图表。点击图中的某个单元格可以在地板,草丛,沙子,水,墙/树之间切换。移动blob(起始点)以及“X”(终点)去查看最短路径。


具体操作见:http://www.redblobgames.com/pathfinding/a-star/introduction.html.


怎样计算这样一条路径呢?A* 算法在游戏开发中最常用的算法。它是图搜索算法中遵循相同结构的一种算法。这些算法将地图市委一个数据结构中的图结构,然后在图结构中寻找路径。如果你之前没有了解过node-and-edge图(节点-边缘图),这里有介绍的文章。对于这个文章,图节点将对应于map地图。广度优先搜索是最简单的图搜索算法,从这里开始,我们将沿着我们的方式实现A*算法。

这些算法的关键思想是 : 跟踪一个称为前驱(frontier)扩展环。启动动画去观察前驱(frontier)是怎么扩张的:


这个扩张前驱可以被看做一个停在墙边的轮廓线;这个过程有时候被称为“泛洪”:


怎么实现这个算法呢?重复这些步骤直到前驱队列(frontier)为空:

  1. 从前驱队列中挑一个位置,并且将其移除出队列。
  2. 标记这个位置为 “已访问”,让我们之后可以知道不再处理这个位置点。
  3. 根据当前操作位置的邻居坐标点 扩展 前驱队列。任何我们没有访问过的邻居节点都要添加到前驱队列中。

让我们仔细看一下这个过程。我们处理过程中访问过的单元格瓷砖块都被打上了序号。一步一步观察扩张过程:


It’s only ten lines of (Python) code:

实现代码只有十行(Python):


frontier = Queue()frontier.put(start)visited = {}visited[start] = Truewhile not frontier.empty():   current = frontier.get()   for next in graph.neighbors(current):      if next not in visited:         frontier.put(next)         visited[next] = True


这个循环在这页的图搜索算法都是必要的,包括A*算法。但是我们怎么去寻找最短路径呢?这个循环事实上并不能构造出这样的路径;它只能告诉我们怎样去遍历图中的所有节点。那是因为广度优先搜索算法可以被用在很多领域,不仅仅是路径查找,在这个文章中我将展示广度优先算法在塔防游戏中的应用,但是它也可以被用在距离图,程序地图生成,以及其他很多领域。这里我们想用它去寻找路径,所以让我们来修改这个循环去跟踪每个已访问的位置的来源(came from),将“已访问”改为“来源”:


frontier = Queue()frontier.put(start)came_from = {}came_from[start] = Nonewhile 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

现在 “came_from来源” 对于每个位置点来说都是 来自于哪里。那是足够的信息了对于去构造整个路径。鼠标在地图中的任何位置上移动,然后观察下面的箭头们给你逆向路径引导到起始位置点。



这段代码去构造路径是简单的:跟着箭头指示走


current = goalpath = [current]while current != start:   current = came_from[current]   path.append(current)path.reverse()

That’s the simplest pathfinding algorithm. It works not only on grids as shown here but on any sort of graph structure. In a dungeon, graph locations could be rooms and graph edges the doorways between them. In a platformer, graph locations could be locations and graph edges the possible actions such as move left, move right, jump up, jump down. In general, think of the graph as states and actions that change state. I have more written about map representation here. In the rest of the article I’ll continue using examples with grids, and explore why you might use variants of breadth first search.
那是最简单的路径查找算法。它不仅可以在展示的表格上工作,也可以在任何已排序的图结构上应用。在一个地牢(dungeon)中,图坐标可以被包围或者修整的在门路之间。在一个平台上,图坐标可以被定位并且图可以边缘化出像左移,右移,跳上,跳下这些可能的活动。通常,可以将图看做是改变状态后的状态或者活动。我在这里已经写过一些关于图的特征。在剩下的篇幅,我将用表格的例子,探索为什么你可能使用广度优先搜索算法的变种算法。

Early exit  早点退出

We’ve found paths from one location to all other locations. Often we don’t need all the paths; we only need a path from one location to one other location. We can stop expanding the frontier as soon as we’ve found our goal. Drag the X around see how the frontier stops expanding as soon as it reaches the X.

我们将找到从一个点到其他位置的所有路径。事实上我们通常是不需要所有路径的;我们只需要从一点到另一点的一条路径就可以了。所以我们可以在查找找到目标节点的时候就可以停止前驱队列的扩张了。四处拖拽X观察前驱队列在找到目标X后就停止扩张的情景。

代码是简洁明了的:

  1. frontier = Queue()
  2. frontier.put(start)
  3. came_from = {}
  4. came_from[start] = None
  5. while not frontier.empty():
  6.   current = frontier.get()
  7.   if current == goal:
  8.      break          
  9.   for next in graph.neighbors(current):
  10.      if next not in came_from:
  11.         frontier.put(next)
  12.         came_from[next] = current

Movement costs 移动代价

到目前位置,我们都是在假定所有的移动消耗的代价是一样的。在一些路径查找场景中在不同类型的移动下小号的代价往往是不同的。例如在城市中,在平原或者沙漠上移动的代价可能只有1点,而在森林或者山上移动却可能消耗5点。在地图中,在水上移动消耗可能是在草地上移动消耗代价的10倍。另一个例子是在表格中对角线上移动的代价可能也是比在沿着坐标轴方向移动消耗的能量要多一些的。我们想要在寻找路径的过程中将这些移动消耗也放在考虑的范围之内。让我们来比较一下number of steps 和 distance 中从起始位置开始的区别吧:

我们想用Dijkstra算法实现这个目的。它跟广度优先搜索算法有怎样的区别呢?我们需要跟踪移动的消耗,所以我们添加了一个新的变量cost_so_far ,用来跟踪记录从起始位置起的总共的移动代价。我们想把移动消耗也考虑进来再评估坐标点;转换常规队列为优先队列。不太明显的,我们可能访问一个坐标点多次,因为不同的消耗,所以我们需要修改一点点算法的逻辑。不再是在位置点没有访问过就直接添加到前驱队列中了,现在改为在到坐标点的新的路径是比之前的路径更好的情况下才把坐标点加入到队列中。

  1. frontier = PriorityQueue()
  2. frontier.put(start, 0)
  3. came_from = {}
  4. cost_so_far = {}
  5. came_from[start] = None
  6. cost_so_far[start] = 0
  7. while not frontier.empty():
  8.   current = frontier.get()
  9.   if current == goal:
  10.      break
  11.  
  12.   for next in graph.neighbors(current):
  13.      new_cost = cost_so_far[current] + graph.cost(current, next)
  14.      if next not in cost_so_far or new_cost < cost_so_far[next]:
  15.         cost_so_far[next] = new_cost
  16.         priority = new_cost
  17.         frontier.put(next, priority)
  18.         came_from[next] = current

使用优先队列代替常规的队列改变了前驱队列扩张的方式。等高线是看这种结果的一种方式。启动动画观察前驱队列在森林中扩张要慢些,找到最短路径是绕着森林而不是直接通过它。

移动消耗不再是1允许我们探索更加有趣的图,而不仅仅是表格。这里有个例子,其中移动消耗是基于点跟点之间的距离的来的:

移动消耗也可以被用来去避免或者偏爱某片区域,譬如说靠近敌人或者联盟区域。

一个实现细节:一个规则的优先级队列支持插入和移除操作,但是有的一些,像Dijkstra算法,要用到第三种操作,去修改队列中已经存在的某个元素的优先级。我不需要这个操作,实现笔记页解释了为什么。

Heuristic search   启发式搜索

在广度优先搜索算法和Dijkstra算法中,前驱队列扩张向所有方向。如果你想要查找一条到所有位置或者许多位置的路径时,这个算法的扩张方向思想倒是情有可原。然而,一个更常见的案例是去查找一条到一个目标节点的路径。让我们使得前驱队列扩张的方向是朝向目标节点,而不是其他方向。首先,我们定义一个启发式函数,让它告诉我们我们距离目标节点的接近程度:

  1. def heuristic(a, b):
  2.   # Manhattan distance on a square grid
  3.   return abs(a.x - b.x) + abs(a.y - b.y)

在Dijkstra算法中我们使用了到起始位置的确切距离作为进入优先级队列的比较参数出入队列。这里,在贪心搜索算法中,我们将使用到目标节点的评估距离作为进入优先级队列的比较参数出入队列。越接近目标的位置将先被探索到。代码中同样使用了优先级队列,但不是cost_so_far变量了:

  1. frontier = PriorityQueue()
  2. frontier.put(start, 0)
  3. came_from = {}
  4. came_from[start] = None
  5. while not frontier.empty():
  6.   current = frontier.get()
  7.   if current == goal:
  8.      break
  9.  
  10.   for next in graph.neighbors(current):
  11.      if next not in came_from:
  12.         priority = heuristic(goal, next)
  13.         frontier.put(next, priority)
  14.         came_from[next] = current

让我们看一下它工作的有多漂亮哇:

Wow!很神奇,对不对?但是在更复杂的图中会怎样呢?

那些路径不是最短的了。所以说当又不多的障碍时这个算法跑起来挺快的,但是路径却不是最好的了。我们可以改进它么?答案当然是Yes咯。

The A* algorithm  A*算法

Dijkstra算法在查找最短路径方面确实工作的很棒,但是它会在没有希望的方向上浪费时间。贪心搜索算法是都在看起来有希望的方向上了,但是它却有可能找不到最短路径。A*算法用了他们两个共同的优点,起始位置到当前位置的距离以及到目标节点的评估距离值。

让我们看一下Dijkstra算法以及贪心搜索算法的等高线图来感受一下吧。在Dijkstra算法中,我们从起始点为中心开始朝着目标前进。我们不确定目标在哪里,所以不得不去检查所有方向上的所有节点。在贪心搜索算法中,我们从终点为中心出发寻找目标。我们知道目标在哪里,所以只要朝着目标移动,就是好的。

A*结合两者的优点。等高线不再是显示距离起始点或者终点的距离了,在A*算法中等高线表现出了路径的长度。内部区域拥有着最短路径。A*算法从内部区域开始探索,只有在它找不到路径的情况下才会向外扩张。尝试拖拽一些墙壁,然后观察A*怎样跳出最内部区域去查找路径的。

代码跟Dijkstra算法是非常相似的:

  1. frontier = PriorityQueue()
  2. frontier.put(start, 0)
  3. came_from = {}
  4. cost_so_far = {}
  5. came_from[start] = None
  6. cost_so_far[start] = 0
  7. while not frontier.empty():
  8. current = frontier.get()
  9. if current == goal:
  10. break
  11. for next in graph.neighbors(current):
  12. new_cost = cost_so_far[current] + graph.cost(current, next)
  13. if next not in cost_so_far or new_cost < cost_so_far[next]:
  14. cost_so_far[next] = new_cost
  15. priority = new_cost + heuristic(goal, next)
  16. frontier.put(next, priority)
  17. came_from[next] = current

比较算法们:

你可以看到A*算法尝试去尽可能的保持在相同的评估路径长度上。为什么它会比Dijkstra算法快呢?两个算法探索的都是相同的坐标。然后,启发式函数让我们以不同的顺序访问位置,这样会是的我们会更早的遇到目标节点。

蒽。。。那就是它。那就是我们说的A*算法。

在一个游戏地图中,我们应该使用哪一个搜索路径算法呢?

  • 如果你想要从或者到所有的位置,就用广度优先搜索或者Dijkstra算法吧。如果消耗代价都是相同的,那就选择用广度优先算法;如果移动消耗是变化的那就用Dijkstra算法吧。

  • 如果你想要找到一个目标位置的路径,就用贪心搜索算法或者A*。大多数情况下就直接用A*吧。当你想要尝试使用贪心搜索算法时,考虑下用带有“允许范围内的启发”的A*算法。

最佳路径是什么鬼?广度优先搜索算法跟Dijkstra算法保证在给定的输入图中找到一条最短路径。贪心搜索算法不是。如果启发式函数不比真实的距离大的情况下,A*算法可以保证找到一条最短路径。当启发式函数足够小,A*算法就变成Dijkstra算法了。当启发式函数值变大时,A*算法就变成了贪心搜索算法了。

性能怎么样呢?最好是消除在你的图中的非必须的位置节点。如果用表格的话,看这里。消除图的大小可以帮助所有图搜索算法。那之后,用你可以用的最简单的算法;简单的队列运行起来特别快哟。贪心搜索算法一般比Dijkstra算法要运行的快,但是不产生最优路径。A*算法是大多数路径搜索算法中最好的选择。

用在不是图结构的地方效果如何?我使用图是因为我认为在图上运行A*算法是最容易理解的。然而,这些图搜索算法也可以用于任何排序的图中,而不仅仅是游戏地图中,我曾经尝试呈现这个算法的代码在2d表格中。地图中的移动消耗在图边缘上表现为很明显的权重值。启发式函数对于带权图是不需要翻译的容易理解;你不得不设计一个启发式函数对于每一种图。对于平面图,距离是个很好的选择,所以那也是我曾经用到时的选择。

我有大量的文章在这里关于路径查找方面的。记得 图搜索仅仅是一部分你需要的。A*并不能处理像协作移动,移动过障碍物,地图改变,评估危险区域,编队,转弯,对象尺寸,动画,路径平滑,或者大量其他的主题。


0 0