在游戏中,我们经常想找到从一个位置到另一个位置的路径。我们不仅试图找到最短的距离;我们还想考虑旅行时间。移动 blob (起点)和交叉点(终点)以查看最短路径。
为了找到这条路径,我们可以使用图形搜索算法,该算法在地图表示为图形时起作用。A*是图搜索的流行选择。广度优先搜索是最简单的图搜索算法,所以让我们从那里开始,我们将逐步达到 A*。
表示地图#
学习算法首先要做的是理解数据。输入是什么?输出是什么?
输入:图搜索算法,包括 A*,将“图”作为输入。图是一组位置(“节点”)和它们之间的连接(“边”)。这是我给 A* 的图表:
A* 没有看到任何其他内容。它只看到图表。它不知道某物是在室内还是室外,或者是房间还是门口,或者面积有多大。它只看到图表!它不知道这张地图和这另一个.
输出: A* 找到的路径是由图节点和边组成. 边缘是抽象的数学概念。A* 会告诉您从一个位置移动到另一个位置,但不会告诉您如何移动。请记住,它对房间或门一无所知;它所看到的只是图表。您必须确定 A* 返回的图形边缘是指从一个块移动到另一个块,还是沿着直线行走或打开一扇门,或者游泳或沿着弯曲的路径奔跑。
权衡:对于任何给定的游戏地图,有许多不同的方法可以制作寻路图以提供给 A*。上图将大多数门道变成了节点;如果我们做门口进入边缘? 如果我们使用寻路网格?
寻路图不必与您的游戏地图使用的相同。网格游戏地图可以使用非网格寻路图,反之亦然。A* 以最少的图节点运行最快;网格通常更易于使用,但会产生大量节点。本页涵盖 A* 算法,但不包括图形设计;有关图表的更多信息,请参见我的其他页面。对于页面其余部分的解释,我将使用网格,因为它更容易可视化概念。
算法#
有很多算法可以在图上运行。我将介绍这些:
广度优先搜索在各个方向上均等地探索。这是一个非常有用的算法,不仅适用于常规路径查找,还适用于程序地图生成、流场寻路、距离地图和其他类型的地图分析。 | |
Dijkstra 算法(也称为统一成本搜索)让我们优先考虑要探索的路径。它不是平等地探索所有可能的路径,而是倾向于成本较低的路径。我们可以分配较低的成本来鼓励在道路上移动,较高的成本来避免森林,较高的成本来阻止靠近敌人等等。当移动成本不同时,我们使用它而不是广度优先搜索。 | |
A*是对 Dijkstra 算法的修改,针对单个目的地进行了优化。Dijkstra 算法可以找到所有位置的路径;A* 查找到一个位置或几个位置中最近的位置的路径。它优先考虑似乎更接近目标的路径。 |
我将从最简单的广度优先搜索开始,一次添加一个特征,将其变成 A*。
广度优先搜索#
所有这些算法的关键思想是我们跟踪一个称为边界的扩展环。在网格上,这个过程有时被称为“洪水填充”,但同样的技术也适用于非网格。启动动画,看看边界是如何扩展的 → →
← 开始动画 →
我们如何实现这一点?重复这些步骤,直到边界为空:
- 从边界中选择并删除一个位置。 →
- 通过查看它的邻居来扩展它 . 跳过墙壁。任何未得的邻居,我们添加到双方的边境和达到集 → .
让我们近距离看看这个。瓷砖按我们访问它们的顺序编号。逐步查看展开过程:
< 后退一步 前进 >
它只有十行(Python)代码:
边界=队列() 边界.put(开始 ) 到达=设置() 到达.add(开始) 而不是前沿.empty(): current = frontier .get() 对于graph.neighbors( current ) 中的下一个: 如果下一个未到达: 边界.put( next ) 到达.add( next )
这个循环是本页图搜索算法的精髓,包括A*。但是我们如何找到最短路径呢?循环实际上并不构建路径;它只告诉我们如何访问地图上的所有内容。那是因为广度优先搜索不仅可以用于查找路径,还可以用于更多用途。在本文中,我展示了它如何用于塔防,但它也可以用于距离地图、程序地图生成和许多其他事情。在这里,虽然我们想用它来寻找路径,所以让我们修改循环以跟踪我们从哪里到达的每个位置,并将reached
集合重命名为came_from
表(表的键是到达的集合):
frontier = Queue() frontier .put( start ) come_from = dict() come_from [ start ] =无 而不是前沿.empty(): current = frontier .get() 对于graph.neighbors( current ) 中的下一个: 如果next不在come_from 中: frontier .put( next ) come_from [ next ] = current
现在came_from
每个位置都指向我们来自的地方。这些就像“面包屑”。它们足以重建整个路径。移动十字以查看跟随箭头如何为您提供返回起始位置的反向路径。
重建路径的代码很简单:沿着箭头从目标向后移动到起点。路径是一系列边,但通常更容易存储节点:
当前=目标 路径= [] while current != start : path .append( current ) current = come_from [ current ] path .append( start ) # 可选 path .reverse() # 可选
这是最简单的寻路算法。它不仅适用于此处所示的网格,而且适用于任何类型的图形结构。在地牢中,图形位置可以是房间,图形边缘是它们之间的门口。在平台游戏中,图形位置可以是位置和图形边缘,可能的动作例如左移、右移、上跳、下跳。通常,将图表视为改变状态的状态和动作。我在这里写了更多关于地图表示的文章。在本文的其余部分中,我将继续使用带有网格的示例,并探讨为什么您可能会使用广度优先搜索的变体。
提前退出#
我们找到了从一个位置到所有其他位置的路径。通常我们不需要所有的路径;我们只需从一个位置到路径一个其它位置。一旦我们找到了目标,我们就可以停止扩展边界。拖动四周,看看边界到达目标后如何停止扩展。
不提前退出 提前退出
代码很简单:
frontier = Queue() frontier .put( start ) come_from = dict() come_from [ start ] = 无 而不是前沿.empty(): current = frontier .get() 如果当前==目标: 中断 对于graph.neighbors( current ) 中的下一个: 如果next不在come_from 中: frontier .put( next ) come_from [ next ] = current
提前退出条件可以做很多很酷的事情。
移动成本#
到目前为止,我们已经使 step 具有相同的“成本”。在某些寻路场景中,不同类型的移动会产生不同的成本。例如在《文明》中,穿越平原或沙漠可能需要 1 个移动点,但穿越森林或丘陵可能需要 5 个移动点。在页面顶部的地图中,在水中行走的成本是在草丛中行走的 10 倍。另一个例子是网格上的对角线移动,其成本高于轴向移动。我们希望探路者将这些成本考虑在内。让我们比较从开始的步数和距离开始的距离:
为此,我们需要Dijkstra 算法(或统一成本搜索)。它与广度优先搜索有何不同?我们需要跟踪移动成本,所以让我们添加一个新变量cost_so_far
来跟踪从起始位置开始的总移动成本。在决定如何评估位置时,我们希望将移动成本考虑在内;让我们把队列变成优先队列。不太明显的是,我们最终可能会以不同的成本多次访问一个位置,因此我们需要稍微改变一下逻辑。如果该位置从未到达过,我们不会将位置添加到边界,而是如果到该位置的新路径优于之前的最佳路径,我们将添加它。
frontier = PriorityQueue() frontier .put( start , 0 ) come_from = dict() cost_so_far = dict() come_from [ start ] = 无 cost_so_far [ start ] = 0 而不是前沿.empty(): current = frontier .get() 如果当前==目标: 休息 对于graph.neighbors( current ) 中的下一个: new_cost = cost_so_far [ current ] + graph.cost( current , next )如果下一个不在cost_so_far或new_cost < cost_so_far [下一个]:cost_so_far [下一个] = new_cost优先级 = new_cost frontier .put (下一个,优先级) come_from [下一个] =当前
使用优先队列而不是常规队列会改变边界扩展的方式。等高线是看到这一点的一种方式。开始动画,看看边界如何通过森林更缓慢地扩展,找到围绕中央森林而不是穿过它的最短路径:
非 1 的移动成本允许我们探索更有趣的图表,而不仅仅是网格。在页面顶部的地图中,移动成本基于房间之间的距离。移动成本也可用于根据与敌人或盟友的接近程度来避开或偏好区域。
实现说明:我们希望这个优先级队列首先返回最低值。在实现页面上,我PriorityQueue
在 Python 中使用heapq
首先返回最小值,在 C++ 中使用std::priority_queue
配置为首先返回最小值。此外,我在此页面上展示的 Dijkstra 算法和 A* 版本与算法教科书中的版本不同。它更接近于所谓的统一成本搜索。我描述了实施页面上的差异。
启发式搜索#
使用广度优先搜索和 Dijkstra 算法,边界向各个方向扩展。如果您试图找到通向所有位置或多个位置的路径,这是一个合理的选择。然而,一种常见的情况是只找到一个位置的路径。让我们让边界向目标扩展多于向其他方向扩展。首先,我们将定义一个启发式函数,告诉我们离目标有多近:
定义启发式(a,b): # 方格上的曼哈顿距离 返回 abs(ax - bx) + abs(ay - by)
在 Dijkstra 算法中,我们使用从开始的实际距离来进行优先级队列排序。相反,在Greedy Best First Search 中,我们将使用到目标的估计距离来进行优先队列排序。最接近目标的位置将首先被探索。该代码使用 Dijkstra 算法中的优先级队列,但没有cost_so_far
:
frontier = PriorityQueue() frontier .put( start , 0) come_from = dict() come_from [ start ] = None 而不是前沿.empty(): current = frontier .get() 如果当前==目标: 休息 对于graph.neighbors( current ) 中的下一个: 如果下一个不在come_from 中: 优先级 = 启发式(目标,下一个) 前沿.put(下一个,优先级) come_from [下一个] =当前
让我们看看它的效果如何:
哇!!很神奇,对吧?但是在更复杂的地图中会发生什么?
这些路径并不是最短的。所以这个算法在没有很多障碍物的情况下运行得更快,但是路径不是很好。我们能解决这个问题吗?是的!
A* 算法#
Dijkstra 算法可以很好地找到最短路径,但它会浪费时间去探索没有希望的方向。Greedy Best First Search 探索有希望的方向,但可能找不到最短路径。A *算法的使用都从一开始的实际距离和目标的估计距离。
该代码与 Dijkstra 的算法非常相似:
frontier = PriorityQueue() frontier .put( start , 0) come_from = dict() cost_so_far = dict() come_from [ start ] = 无 cost_so_far [ start ] = 0 而不是前沿.empty(): current = frontier .get() 如果当前==目标: 休息 对于graph.neighbors( current ) 中的下一个: new_cost = cost_so_far [ current ] + graph.cost( current , next ) 如果next不在cost_so_far或new_cost < cost_so_far [ next ]: cost_so_far [ next ] = new_cost priority = new_cost + heuristic( goal , next ) frontier .put( next , priority) come_from [ next ] = current
比较算法:Dijkstra 算法计算距起点的距离。Greedy Best-First Search 估计到目标点的距离。A* 使用这两个距离的总和。
尝试在墙上的各个地方开一个洞。您会发现,当贪婪的最佳优先搜索找到正确答案时,A* 也会找到它,并探索同一区域。当贪婪的最佳优先搜索找到错误的答案(更长的路径)时,A* 会找到正确的答案,就像 Dijkstra 算法一样,但仍然比 Dijkstra 算法探索得少。
A* 是两全其美的。只要启发式算法没有高估距离,A* 就会找到一条最佳路径,就像 Dijkstra 算法所做的那样。A *使用启发式重新排序节点,以便它更可能的是,目标节点会遇到越快。
而且……就是这样!这就是 A* 算法。
更多的#
你准备好实施了吗?考虑使用现有的库。如果你自己实现它,我有一个配套指南,它逐步展示了如何在 Python、C++ 和 C# 中实现图形、队列和寻路算法。
您应该使用哪种算法在游戏地图上查找路径?
- 如果你想从或寻找路径的所有的所有位置,使用广度优先搜索或Dijkstra算法。如果移动成本都相同,则使用广度优先搜索;如果移动成本不同,请使用 Dijkstra 算法。
- 如果您想查找到一个位置或几个目标中最近的位置的路径,请使用 Greedy Best First Search 或 A*。在大多数情况下首选 A*。当您想使用 Greedy Best First Search 时,请考虑使用带有“不可接受的”启发式的A* 。
那么最优路径呢?广度优先搜索和 Dijkstra 算法保证在给定输入图的情况下找到最短路径。贪婪的最佳优先搜索不是。如果启发式永远不会大于真实距离,则 A* 保证找到最短路径。随着启发式变得更小,A* 变成了 Dijkstra 算法。随着启发式变大,A* 变成贪婪的最佳优先搜索。
性能呢?最好的办法是消除图表中不必要的位置。如果使用网格,请参阅此。减小图的大小有助于所有图搜索算法。之后,使用最简单的算法;更简单的队列运行得更快。Greedy Best First Search 通常比 Dijkstra 算法运行得更快,但不会产生最佳路径。A* 是大多数寻路需求的不错选择。
非地图呢?我在这里展示地图是因为我认为使用地图更容易理解算法的工作原理。然而,这些图搜索算法可以用于任何类型的图,不仅是游戏地图,而且我尝试以独立于二维网格的方式呈现算法代码。地图上的移动成本成为图边上的任意权重。启发式方法不容易转化为任意地图。您必须为每种类型的图设计一个启发式方法。对于平面地图,距离是一个不错的选择,所以这就是我在这里使用的。