智慧 + 毅力 = 无所不能

正确性、健壮性、可靠性、效率、易用性、可读性、可复用性、兼容性、可移植性...

导航

利用Red Blob游戏介绍A*算法

Posted on 2017-07-17 17:33  Bill Yuan  阅读(830)  评论(0编辑  收藏  举报

转自:http://gad.qq.com/program/translateview/7194337

在游戏中,我们经常想要找到从一个位置到另一个位置的路径。我们不只是想要找到最短距离,同时也要考虑旅行时间。如下图中从星星(起点)到叉号(终点)的最短路径。

为了找到这样的路径,我们可以使用一种图搜索算法,它需要将地图表示为一张图。A *算法是图形搜索的热门选择。宽度优先搜索是图形搜索算法中最简单的一种,所以让我们从这个算法开始,慢慢扩展到A*算法。

地图的表示

研究算法时,首先要了解数据。输入是什么?输出是什么?

输入:图形搜索算法(包括A *)都是以“图形”为输入。一个图就是一组的位置(“点”)和他们之间的连接关系(“边”)。下面就是A*算法的输入:

A*算法不会在意其他任意的东西,它只能理解这样的图。它不会知道某个东西是在室内还是户外,它是一个房间还是一个门,或者它的面积有多大。它只

能看懂图中的点和线。它也不会知道上一幅图和下一幅图的区别。

输出: A *找到的路径是由图中节点和边组成(如上图)。边是抽象的数学概念。A *能够告诉你从一个位置移动到另一个位置的路径,但不会告诉你怎么移动。注意:它不知道任何关于房间或门的信息,它只知道图形中的点和线。你必须自己决定A *返回的图形路线是从Tile移动到Tile,还是以直线行走或是打开门,或者沿着弯曲的路径游泳或跑步。

权衡:对于任意给定的游戏地图,都有许多不同的方法来制作寻路图给A *。上述地图大多数使用门做为点,如果我们把门道作为边呢?如果我们使用寻路网格呢?

以门道为边

寻路网格

寻路图并不是必须和你使用的游戏地图相同。网格游戏地图可以使用非网格寻路图,反之亦然。图中的点越少A*运行速度就越快。通常网格在使用时更容易,但会产生大量节点。本文主要讲解A*算法,并不包括图形设计; 有关图形的更多信息,请参阅我的其他文章。文章剩余部分的讲解中,我将使用网格,因为它更容易将概念可视化。

算法

在图上寻找路径有很多的算法。我要介绍下面这些:

度优先搜索在各个方向上都是相同的。这是一个非常有用的算法,不仅适用于常规路径查找,还可用于过程图生成,流域寻路,距离图和其他类型的地图分析。

Dijkstras算法(也称为统一成本搜索-UniformCost Search)我们会优先考虑对哪些路径进行探索,而不是平等地探索所有可能的路径,它有利于寻找花费代价更小的路径。我们可以分配较低的代价来鼓励在道路上移动、更高的代价来避免森林、更高的代价阻止靠近敌人等等。当图中移动代价是变化时,我们使用这种方式而不是宽度优先搜索。

A *是Dijkstra’s算法的变形,为单一目的地做了优化。Dijkstra’s的算法可以找到通往任意位置的路径; A *是为了找到通往一个位置的路径。它优先考虑似乎更接近目标的路径。

我将从最简单的“宽度优先搜索”开始讲解,并一次添加一个功能逐渐将其转换为A *

宽度优先搜索

所有这些算法的关键思想是我们持续跟踪一个称为frontier的扩展环。在网格上,这个过程有时被称为“flood fill”,但这个的技术同样适用于非网格图。点击开始,看看frontier是如何扩展:(点击查看原文动图)下面只是一个截图

这个过程我们如何实现呢?重复这些步骤直到frontier为空:

      1、从frontier选择并移除一个位置。

      2、将该位置标记为已访问,以便我们知道不用再处理它。

      3、通过查看其邻居来扩展 frontier。将所有的还没有访问过的的邻居,加入到frontier上。

我们来看看整个过程。Tile会按照我们访问的顺序进行编号。

逐步浏览扩展过程:(点击查看原文动图)下面只是截图

它只有十行(Python)代码:

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

这个循环是本文图形路径搜索算法的精髓,包括对于A *算法而言。但是我们如何找到最短的路径?事实上这个循环并不能构造出路,它只是告诉了我们如何访问地图上的所有事物。这是因为宽度优先搜索的作用不仅仅是用来寻找路径,我在在另一篇文章中介绍了如何在塔防游戏中使用这个算法,但它也可以用于地图测距,程序地图生成和许多其他事情。在这里我们使用它来寻找路径,所以我们对循环进行修改使之能够从我们访问过的每个位置追踪到我们的出发点,并重命名visited为came_from:

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

现在came_from能够从任意一个点指向我们的出发点。这些就像“面包屑”。它们足以重建整个路径。将鼠标悬停在地图上的任何位置,然后就可以看到箭头会给出一个回到起始位置的反向路径。(点击查看原文动图)

重建路径的代码很简单:向后追溯箭头就可以得到终点到起点的路径。路径是一系列边的集合,但是通常只存储节点会更容易:

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

这是最简单的寻路算法。正如上面的展示,它不仅可以在网格上工作,而是对任何形式的图形结构都有效。在地牢中,图中的点可以是房间,图中的边将各个门口连接起来。在平台中,图中的点可以是位置,图中的边是可能的动作,例如向左移动,向右移动,向上跳,向下跳。总的来说,将图视为状态和改变状态的动作。我在其他文章中写了更多的代表性的地图。在本文的其余部分,我将继续使用网格图形作为例子,并探索为什么你可能需要使用宽度优先搜索的变体。

提前退出

我们已经能够找到从一个位置到所有其他位置的路径。但是通常我们并不需要所有的路径; 我们只需从一个位置到另外一个位置的一条路径。一旦我们找到目标,我们就可以停止扩大Frontier。在下面的图中拖动X,看看在到达X时Frontier如何停止扩展。(点击链接进行操作)

代码很简单:

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

移动成本

目前为止,我们已经找到了基于相同的“成本”的路。在某些寻路方案中,不同的移动方式可能会有不同的成本。例如在文明中,在平原或沙漠上移动可能花费1个移动点,但在森林或丘陵中移动可能花费5个移动点。在本文顶部的地图上,涉水前进花费的成本是穿越草地的10倍。另一个例子是网格上的对角线运动,它的花费的不只是轴向运动。我们希望寻路算法考虑这些成本。我们来比较从出发点到目的地的步数与它们之间的距离

在这里,我们会使用Dijkstra’s算法(也称为统一成本搜索)。它与宽度优先搜索的不同是什么呢?Dijkstra’s算法中我们需要跟踪运动成本,所以让我们添加一个新变量cost_so_far来跟踪从起始位置开始的总运动成本。我们在决定如何评估一个位置时要将运动成本纳入考虑,我们把队列变成一个优先队列。隐含的是我们可能会多次访问同一个地点但基于不同成本,所以我们需要轻微的改变一下算法逻辑。如果一个位置从未被访问过,就把它加入Frontier,如果到达该位置的新路径优于前面的最佳路径,更新它在Frontier中的值。

frontier = PriorityQueue()
frontier.put(start, 0)
came_from = {}
cost_so_far = {}
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

这里我们使用优先级队列替代普通队列来改变Frontier扩展的方式。轮廓线会很好的表明这一点。通过动画就能看出边界在通过森林时会扩展的很慢,将会找到一条围绕森林的最短路径,而不是穿过它:(点击查看原文动画)

超过1的运动成本使我们能够探索更有趣的图形,而不仅仅是网格。在本文开始介绍的地图上,运动成本用是基于房间到房间的距离。运动成本也可以用来避免或偏好邻近敌人或贴近盟友的区域。

实现细节:常规优先级队列支持插入和删除操作,但Dijkstra’s算法的一些演示文稿还使用第三个操作来修改已经在优先级队列中的元素的优先级。我没有用该操作,原因在实现说明页面有讲述。

 

启发式搜索

利用宽度优先搜索和Dijkstra’s算法,边界可以在所有的方向扩展。如果你尝试找到到达所有地点或许多地点的路径,这会是一个合理的选择。然而,常见的情况是只需要找到到达某一个位置的路径。这需要让边界的扩展朝着目标的方向,而不是向四周扩展。首先,我们将定义一个 heuristic函数,来明确我们与目标之间的距离:

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

在Dijkstra’s算法中,我们使用了距离起点的实际距离的优先级队列排序。相反,在Greedy BestFirst Search算法中,我们将使用到目标的评估距离作为优先级队列的排序依据。最接近目标的位置会最先被探索。下面的代码使用了宽度优先搜索中的优先级队列,但但没有使用Dijkstra算法中的cost_so_far方法:

frontier = PriorityQueue()
frontier.put(start, 0)
came_from = {}
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 *算法

Dijkstra’s算法可以很好的找到最短路径,但是会在没有价值的方向上花费时间去探索。GreedyBest First Search 算法只在正确的方向上探索,但它可能找不到最短的路径。A *算法中同时使用了从起点的实际距离和到目标的估计距离。

代码与Dijkstra’s的算法非常相似:

frontier = PriorityQueue()
frontier.put(start, 0)
came_from = {}
cost_so_far = {}
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’s算法计算距离起点的距离。Greedy Best-First Search估计到目标点的距离。A *使用这两个距离的总和。(点击查看原图)

算法会尝试下在墙(深色的格子)上打一个洞(点击链接,查看动画效果)。你会发现探索同一个地区,当Greedy Best-First Search算法找到正确的答案时,A *也能发现它。当Greedy Best-First Search算法得到错误的答案(较长的路径)时,A*还是可以找到正确的答案,就像Dijkstra’s的算法,但比Dijkstra’s的算法探索的少。

A *是二者的完美结合。只要heuristic函数不高估距离,A *就不会使用heuristic函数得到一个近似的答案。它找到一个最佳的路径,就像Dijkstra’s’s算法一样。A *使用 heuristic函数重新排序节点,这样可以让它更早的遇到目标节点。

 好吧...就是这样!这就是A *算法。

 

补充

你准备好实现它了吗?考虑使用现有的库吧。如果你准备自己实现它,我有配套指南,逐步展示如何在Python,C ++和C#中实现图形,队列和寻路算法。

你应该使用哪种算法来在游戏地图上查找路径呢?

如果要找到通往所有位置的路径,请使用宽度优先搜索或Dijkstra’s算法。运动成本相同时,使用宽度优先搜索; 运动成本不同,则使用Dijkstra’s算法。

如果您想查找到某一个位置的路径,请使用Greedy BestFirst Search或A *算法。大多数情况下A *会表现更好。所以当你试图使用Greedy Best First Search算法时,请考虑使用“inadmissible” heuristic的 A* 算法。

如何得到最佳路径呢?宽度优先搜索和Dijkstra’s算法保证找到基于给定输入图的最短路径。而Greedy BestFirst Search算法做不到。如果heuristic函数得到的距离永远小于真实距离,则A *算法保证找到最短路径。随着heuristic函数距离变小,A *算法逐渐变成Dijkstra’s的算法。随着heuristic函数距离变得越来越大,A *算法会变成Greedy Best First Search算法。

如何提升算法性能呢?最好的办法是消除图中不必要的位置。如果使用网格图,请参阅。缩小图形大小对所有的图形搜索算法都是有益的。此外,尽可能的使用最简单的算法; 最简单的队列,算法的运行速度才会更快。Greedy Best First Search算法通常比Dijkstra’s算法运行速度更快,但它不能产生最佳路径。A *是大多数寻路问题的好选择。

如果图形不是地图呢?我在这里用地图举例,因为我认为通过使用地图更容易了解算法的工作原理。实际上,这些图形搜索算法可以用于任何类型的图形,而不仅仅是游戏地图,并且我已经尝试以独立于2d网格的方式呈现算法代码。地图上的运动成本成为图形边缘上的任意权重。heuristics函数并不能轻易地转换到任意地图上,你必须为每种类型的图形设计对应的heuristics函数。对于平面图,距离是一个不错的选择,这就是我在这里使用它的原因。

在这里写了很多关于寻路的文章。请记住,图形搜索只是你需要的一部分。A *算法本身不处理合作运动,移动障碍,地图变更,危险区域评估,编队,转弯半径,物体大小,动画,路径平滑等很多其他话题。