本文说明了游戏开发中的寻路算法。从BFS到Dijkstra到A*以及A*的变体等。
大部分参考这篇由浅入深的文章[1]
BFS
寻路算法大部分都是从图论算法中发展而来。其中最简单的就是BFS。这也是在学习寻路算法时最先遇到的算法。
BFS的优点:
- 写法简单
- 给定起点,可以同时算得图中所有点到起点的最短路径
缺点:
- 性能不够好。要遍历整个图中所有的点
O(mn) - 只能在无权图中生效。这包含两个条件:
- 任意格子到相邻格子的耗时必须一样(一般都记为1)
- 对于Tilemap这种格子都是方的来说,只能四向移动不能八向移动(因为斜角的格子耗时是$\sqrt{2}$倍,导致含有额外权重)
这些缺点导致BFS的应用场景极少。但BFS是所有寻路算法的基石。其他算法都是从BFS上扩展出来的。
我们都知道,BFS其实是通用的图搜索算法,具体用这个算法做什么是看具体场景。所以我们有一个通用BFS实现:
| |
这就是纯粹的BFS的实现逻辑:通过将某个节点的临近节点推入队列中来实现。已经推入过的节点要标记其被访问了(m_visited = true)以防止其某个邻居节点再次将其推入造成死循环。
这里一般会在do_something_with_node(node)里对当前弹出的node做一些操作。
而对于寻路算法,do_something_with_node的实现是直接将当前节点指向其父节点:
| |
显然,随着BFS的“洪水”逐渐淹没整个图,所有的节点都会有自己的父节点。父节点则是洪水淹过来时的方向。
下面是一个 BFS 寻路的交互式演示,你可以左键格子设置起点/终点,右键设置墙壁,然后逐步观察 BFS 的扩散过程:
Dijkstra算法
BFS只能处理无权图。带有权重的图需要使用Dijkstra算法处理。
具体可看参考[2],很直观。
Dijkstra的唯一缺点是不能处理Cost为负数的节点。
Dijkstra算法本质是贪心算法,其在BFS上进行扩展,使用优先队列而不是普通队列。优先队列每次弹出的是到起点路径最短的节点。
所有节点记录自己到起点的Cost。所有节点初始的Cost为$+\infty$,只有起点$S$的Cost是0。
具体的算法是:
- 遍历当前点$A$周围的4(或者8)个点$B_i$,计算他们到起点的$Cost_i = Cost_{S\rightarrow A} + Cost_{A \rightarrow B_i}$,如果$Cost_i$比当前$B_i$的Cost小,那么更新$Cost_{B_i} = Cost_i$并将$B_i$的父节点指向$A$,然后将$B_i$放入优先队列中
- 重复上述步骤直到优先队列为空
他的核心思路就是用优先队列替换原来BFS的普通队列,以贪心的方式每次都找到最近的路径。如果新路径比旧路径更短就用新路径替换旧路径。
伪代码(只展示思路不是最优内存)为:
| |
这里和BFS寻路的区别为:
- 使用优先队列而不是普通队列
- 不需要
m_visited,使用cost比较替代了m_visited的功能
下面是一个 Dijkstra 寻路的交互式演示,地图上有三种不同 Cost 的地形(颜色越深 Cost 越高),你可以观察 Dijkstra 如何优先探索低 Cost 区域:
斜角穿墙的处理
如果Dijkstra存在8方向寻路,那么可能存在斜角穿墙的问题:

解决方法是在计算cost的时候看一下是不是正在斜着走。如果是,看一下旁边有没有墙,如果有墙就标记斜着走的这个块的Cost为$+\infty$。这样Dijkstra可以从旁边绕过去(或者两边都有墙就不从这里走,此路不通了):

使用Heap代替PriorityQueue
在Dijkstra算法中,可能存在同一个节点压入两次queue中:
| |
如果想要去重,可以考虑使用Heap数据结构。
C++本身提供Heap的支持,但比较小众,我放在参考[3]了。
注意:去重并不是理论上必做的事情。因为后压入的节点的Cost一定比之前的小,所以即使弹出之前的节点他也不会更新任何Cost,相当于没做事情。去重只是为了防止又找一遍老节点的nearby节点。
提前退出
Dijkstra是从BFS上扩展而来。BFS会找到图中所有点到起点的最短路径。Dijkstra也是如此。
但在游戏开发中,我们经常只需要找到单点的最短路径。在使用Dijkstra时,如果已经找到目标点的最短路径,那么直接终止算法即可。这就是提前退出。
注意和BFS的提前退出条件不一样:
- BFS:当发现某个点的邻居为目标点时就可以直接退出了(此时算法会设置目标点的父,然后直接退出,目标点不用压入队列)
- Dijkstra:当从queue中弹出的点为目标点时可直接退出(也就是说目标点压入过队列)
贪婪最佳优先搜索
对于单源最短路径。我们希望能尽可能地朝向目标点搜索以减少其他不必要的搜索。这时,我们可以给Dijkstra算法一个启发式函数(Heuristic Function),这个启发式函数告诉Dijkstra当前搜索的点离目标点有多近。在Tilemap地图上可以使用曼哈顿距离:
| |
然后将此启发函数的返回值作为节点的Cost,堆/优先队列 每次弹出的是Cost最小的那个。
这样就等于告诉Dijkstra:永远朝着距离目标最近的点进行贪婪搜索。
但这种算法不一定能找到真的最短路径。但优点就是快。
下面是一个贪婪最佳优先搜索的演示:
下面是一个反例,此时无法找到最短的路径:
A*算法
在基于贪婪最佳优先搜索的Dijkstra算法的基础上,通过修改启发式函数,我们可以直接得到A*算法。
A*算法的启发式函数由三部分组成:
- g:当前节点到起点的确定Cost(即初始Dijkstra算法中的Cost)
- h(heuristic):当前节点到终点的启发式搜索算法(一般是到终点的曼哈顿距离)
- f:$f = g + h$是节点的总Cost,我们最后使用的Cost就是f
当$h$总是0时,A*退回到Dijkstra算法。
当$g$总是0是,A*退回到基于贪婪最佳优先搜索的Dijkstra算法。
编码过程和Dijkstra没区别。