CategoryResourceRepost/极客时间专栏/数据结构与算法之美/高级篇/49 | 搜索:如何用A*搜索算法实现游戏中的寻路功能?.md
louzefeng d3828a7aee mod
2024-07-11 05:50:32 +00:00

148 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<audio id="audio" title="49 | 搜索如何用A*搜索算法实现游戏中的寻路功能?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/f8/39/f8a8f468e8fb01aaf325815fee737e39.mp3"></audio>
魔兽世界、仙剑奇侠传这类MMRPG游戏不知道你有没有玩过在这些游戏中有一个非常重要的功能那就是人物角色自动寻路。**当人物处于游戏地图中的某个位置的时候,我们用鼠标点击另外一个相对较远的位置,人物就会自动地绕过障碍物走过去。玩过这么多游戏,不知你是否思考过,这个功能是怎么实现的呢?**
## 算法解析
实际上,这是一个非常典型的搜索问题。人物的起点就是他当下所在的位置,终点就是鼠标点击的位置。我们需要在地图中,找一条从起点到终点的路径。这条路径要绕过地图中所有障碍物,并且看起来要是一种非常聪明的走法。所谓“聪明”,笼统地解释就是,走的路不能太绕。理论上讲,最短路径显然是最聪明的走法,是这个问题的最优解。
不过,在[第44节](https://time.geekbang.org/column/article/76468)最优出行路线规划问题中我们也讲过如果图非常大那Dijkstra最短路径算法的执行耗时会很多。在真实的软件开发中我们面对的是超级大的地图和海量的寻路请求算法的执行效率太低这显然是无法接受的。
实际上,像出行路线规划、游戏寻路,这些真实软件开发中的问题,一般情况下,我们都不需要非得求最优解(也就是最短路径)。在权衡路线规划质量和执行效率的情况下,我们只需要寻求一个次优解就足够了。那**如何快速找出一条接近于最短路线的次优路线呢?**
这个快速的路径规划算法,就是我们今天要学习的**A*算法**。实际上A*算法是对Dijkstra算法的优化和改造。如何将Dijkstra算法改造成A*算法呢为了更好地理解接下来要讲的内容我建议你先温习下第44节中的Dijkstra算法的实现原理。
Dijkstra算法有点儿类似BFS算法它每次找到跟起点最近的顶点往外扩展。这种往外扩展的思路其实有些盲目。为什么这么说呢我举一个例子来给你解释一下。下面这个图对应一个真实的地图每个顶点在地图中的位置我们用一个二维坐标xy来表示其中x表示横坐标y表示纵坐标。
<img src="https://static001.geekbang.org/resource/image/11/dd/11840cc13071fe2da67675338e46cadd.jpg" alt="">
在Dijkstra算法的实现思路中我们用一个优先级队列来记录已经遍历到的顶点以及这个顶点与起点的路径长度。顶点与起点路径长度越小就越先被从优先级队列中取出来扩展从图中举的例子可以看出尽管我们找的是从s到t的路线但是最先被搜索到的顶点依次是123。通过肉眼来观察这个搜索方向跟我们期望的路线方向s到t是从西向东是反着的路线搜索的方向明显“跑偏”了。
之所以会“跑偏”那是因为我们是按照顶点与起点的路径长度的大小来安排出队列顺序的。与起点越近的顶点就会越早出队列。我们并没有考虑到这个顶点到终点的距离所以在地图中尽管123三个顶点离起始顶点最近但离终点却越来越远。
如果我们综合更多的因素,把这个顶点到终点可能还要走多远,也考虑进去,综合来判断哪个顶点该先出队列,那是不是就可以避免“跑偏”呢?
当我们遍历到某个顶点的时候从起点走到这个顶点的路径长度是确定的我们记作g(i)i表示顶点编号。但是从这个顶点到终点的路径长度我们是未知的。虽然确切的值无法提前知道但是我们可以用其他估计值来代替。
这里我们可以通过这个顶点跟终点之间的直线距离也就是欧几里得距离来近似地估计这个顶点跟终点的路径长度注意路径长度跟直线距离是两个概念。我们把这个距离记作h(i)i表示这个顶点的编号专业的叫法是**启发函数**heuristic function。因为欧几里得距离的计算公式会涉及比较耗时的开根号计算所以我们一般通过另外一个更加简单的距离计算公式那就是**曼哈顿距离**Manhattan distance。曼哈顿距离是两点之间横纵坐标的距离之和。计算的过程只涉及加减法、符号位反转所以比欧几里得距离更加高效。
```
int hManhattan(Vertex v1, Vertex v2) { // Vertex表示顶点后面有定义
return Math.abs(v1.x - v2.x) + Math.abs(v1.y - v2.y);
}
```
原来只是单纯地通过顶点与起点之间的路径长度g(i)来判断谁先出队列现在有了顶点到终点的路径长度估计值我们通过两者之和f(i)=g(i)+h(i)来判断哪个顶点该最先出队列。综合两部分我们就能有效避免刚刚讲的“跑偏”。这里f(i)的专业叫法是**估价函数**evaluation function
从刚刚的描述我们可以发现A*算法就是对Dijkstra算法的简单改造。实际上代码实现方面我们也只需要稍微改动几行代码就能把Dijkstra算法的代码实现改成A*算法的代码实现。
在A*算法的代码实现中顶点Vertex类的定义跟Dijkstra算法中的定义稍微有点儿区别多了xy坐标以及刚刚提到的f(i)值。图Graph类的定义跟Dijkstra算法中的定义一样。为了避免重复我这里就没有再贴出来了。
```
private class Vertex {
public int id; // 顶点编号ID
public int dist; // 从起始顶点到这个顶点的距离也就是g(i)
public int f; // 新增f(i)=g(i)+h(i)
public int x, y; // 新增顶点在地图中的坐标x, y
public Vertex(int id, int x, int y) {
this.id = id;
this.x = x;
this.y = y;
this.f = Integer.MAX_VALUE;
this.dist = Integer.MAX_VALUE;
}
}
// Graph类的成员变量在构造函数中初始化
Vertex[] vertexes = new Vertex[this.v];
// 新增一个方法,添加顶点的坐标
public void addVetex(int id, int x, int y) {
vertexes[id] = new Vertex(id, x, y)
}
```
A*算法的代码实现的主要逻辑是下面这段代码。它跟Dijkstra算法的代码实现主要有3点区别
<li>
优先级队列构建的方式不同。A*算法是根据f值也就是刚刚讲到的f(i)=g(i)+h(i)来构建优先级队列而Dijkstra算法是根据dist值也就是刚刚讲到的g(i))来构建优先级队列;
</li>
<li>
A*算法在更新顶点dist值的时候会同步更新f值
</li>
<li>
循环结束的条件也不一样。Dijkstra算法是在终点出队列的时候才结束A*算法是一旦遍历到终点就结束。
</li>
```
public void astar(int s, int t) { // 从顶点s到顶点t的路径
int[] predecessor = new int[this.v]; // 用来还原路径
// 按照vertex的f值构建的小顶堆而不是按照dist
PriorityQueue queue = new PriorityQueue(this.v);
boolean[] inqueue = new boolean[this.v]; // 标记是否进入过队列
vertexes[s].dist = 0;
vertexes[s].f = 0;
queue.add(vertexes[s]);
inqueue[s] = true;
while (!queue.isEmpty()) {
Vertex minVertex = queue.poll(); // 取堆顶元素并删除
for (int i = 0; i &lt; adj[minVertex.id].size(); ++i) {
Edge e = adj[minVertex.id].get(i); // 取出一条minVetex相连的边
Vertex nextVertex = vertexes[e.tid]; // minVertex--&gt;nextVertex
if (minVertex.dist + e.w &lt; nextVertex.dist) { // 更新next的dist,f
nextVertex.dist = minVertex.dist + e.w;
nextVertex.f
= nextVertex.dist+hManhattan(nextVertex, vertexes[t]);
predecessor[nextVertex.id] = minVertex.id;
if (inqueue[nextVertex.id] == true) {
queue.update(nextVertex);
} else {
queue.add(nextVertex);
inqueue[nextVertex.id] = true;
}
}
if (nextVertex.id == t) { // 只要到达t就可以结束while了
queue.clear(); // 清空queue才能推出while循环
break;
}
}
}
// 输出路径
System.out.print(s);
print(s, t, predecessor); // print函数请参看Dijkstra算法的实现
}
```
**尽管A*算法可以更加快速地找到从起点到终点的路线但是它并不能像Dijkstra算法那样找到最短路线。这是为什么呢**
要找出起点s到终点t的最短路径最简单的方法是通过回溯穷举所有从s到达t的不同路径然后对比找出最短的那个。不过很显然回溯算法的执行效率非常低是指数级的。
<img src="https://static001.geekbang.org/resource/image/38/4a/38ebd9aab387669465226fc7f644064a.jpg" alt="">
Dijkstra算法在此基础之上利用动态规划的思想对回溯搜索进行了剪枝只保留起点到某个顶点的最短路径继续往外扩展搜索。动态规划相较于回溯搜索只是换了一个实现思路但它实际上也考察到了所有从起点到终点的路线所以才能得到最优解。
<img src="https://static001.geekbang.org/resource/image/ca/77/caad286fc67333b77e8ed5c85ce2e377.jpg" alt="">
A*算法之所以不能像Dijkstra算法那样找到最短路径主要原因是两者的while循环结束条件不一样。刚刚我们讲过Dijkstra算法是在终点出队列的时候才结束A*算法是一旦遍历到终点就结束。对于Dijkstra算法来说当终点出队列的时候终点的dist值是优先级队列中所有顶点的最小值即便再运行下去终点的dist值也不会再被更新了。对于A*算法来说一旦遍历到终点我们就结束while循环这个时候终点的dist值未必是最小值。
A*算法利用贪心算法的思路每次都找f值最小的顶点出队列一旦搜索到终点就不在继续考察其他顶点和路线了。所以它并没有考察所有的路线也就不可能找出最短路径了。
搞懂了A*算法,我们再来看下,**如何借助A*算法解决今天的游戏寻路问题?**
要利用A*算法解决这个问题我们只需要把地图抽象成图就可以了。不过游戏中的地图跟第44节中讲的我们平常用的地图是不一样的。因为游戏中的地图并不像我们现实生活中那样存在规划非常清晰的道路更多的是宽阔的荒野、草坪等。所以我们没法利用44节中讲到的抽象方法把岔路口抽象成顶点把道路抽象成边。
实际上我们可以换一种抽象的思路把整个地图分割成一个一个的小方块。在某一个方块上的人物只能往上下左右四个方向的方块上移动。我们可以把每个方块看作一个顶点。两个方块相邻我们就在它们之间连两条有向边并且边的权值都是1。所以这个问题就转化成了在一个有向有权图中找某个顶点到另一个顶点的路径问题。将地图抽象成边权值为1的有向图之后我们就可以套用A*算法,来实现游戏中人物的自动寻路功能了。
## 总结引申
我们今天讲的A*算法属于一种**启发式搜索算法**Heuristically Search Algorithm。实际上启发式搜索算法并不仅仅只有A*算法还有很多其他算法比如IDA*算法、蚁群算法、遗传算法、模拟退火算法等。如果感兴趣,你可以自行研究下。
启发式搜索算法利用估价函数避免“跑偏”贪心地朝着最有可能到达终点的方向前进。这种算法找出的路线并不是最短路线。但是实际的软件开发中的路线规划问题我们往往并不需要非得找最短路线。所以鉴于启发式搜索算法能很好地平衡路线质量和执行效率它在实际的软件开发中的应用更加广泛。实际上在第44节中我们讲到的地图App中的出行路线规划问题也可以利用启发式搜索算法来实现。
## 课后思考
我们之前讲的“迷宫问题”是否可以借助A*算法来更快速地找到一个走出去的路线呢?如果可以,请具体讲讲该怎么来做;如果不可以,请说说原因。
欢迎留言和我分享,也欢迎点击“请朋友读”,把今天的内容分享给你的好友,和他一起讨论、学习。