书接上回,上一篇博客我们介绍了A算法最基本的原理,本篇博客我们来手把手的教会大家A算法的C++实现!原文链接在此,大佬分别用Python、C++、C#进行了算法的实现orz
本文是我对A *的介绍的辅助指南,在此我将解释算法的工作原理。
在此页面上,我将展示如何实现广度优先搜索,Dijkstra的算法,贪婪的最佳优先搜索和A *。 我尝试使代码保持简单。
图搜索有一系列相关算法。算法有很多变体,实现上也有很多变体。将本文展示的代码视为一个起点,而不是适用于所有情况的最终版本。
注意:一些示例代码需要 redblobgames/pathfinding/a-star/implementation.cpp才能执行。我使用 C++ 11 编写这些代码,如果你使用的是更老版本的 C++标准,有些代码可能需要更改。
这里的代码仅供教程参考,不具备正式使用质量。本文最后一节会给出一些提示来优化它。
让我们在 C++ 中实现广度优先搜索。以下是我们需要用到的组件(与 Python 实现相同):
图(Graph)
一种数据结构:可以告诉我图中每个位置的相邻位置(参考这篇文章)。加权图还可以告诉我沿着边移动的成本。
位置(Locations)
一个简单值(整数、字符串、元组等),用于标记图中的位置。它们不一定是具体地图上的某些位置。根据解决的问题,它们还可能包含其他信息,例如方向、燃料、库存等。
搜索(Search)
一种算法:它接受一个图、一个起始位置以及一个目标位置(可选),并为图中的某些位置甚至所有位置计算出一些有用的信息(是否可访问,它的父指针,之间的距离等)。
队列(Queue)
搜索算法中用来确定处理图中位置顺序的数据结构。
在之前的介绍文章中,我聚焦在搜索上。在这篇文章中,我会填充余下的细节,来使程序完整。让我们从图(Graph)这个数据结构开始,其中位置(节点)为char类型:
struct SimpleGraph {
std::unordered_map<char, std::vector<char> > edges;
std::vector<char> neighbors(char id) {
return edges[id];
}
};
SimpleGraph example_graph {{
{'A', {'B'}},
{'B', {'A', 'C', 'D'}},
{'C', {'A'}},
{'D', {'E', 'A'}},
{'E', {'B'}}
}};
C++ 标准库中已经包含了队列类,因此,我们现在已经有了图(SimpleGraph
)、位置(char
)以及队列(std::queue
)。那么让我们来尝试一下广度优先搜索:
#include "redblobgames/pathfinding/a-star/implementation.cpp"
void breadth_first_search(SimpleGraph graph, char start) {
std::queue<char> frontier;
frontier.push(start);
std::unordered_set<char> reached;
reached.insert(start);
while (!frontier.empty()) {
char current = frontier.front();
frontier.pop();
std::cout << "Visiting " << current << '\n';
for (char next : graph.neighbors(current)) {
if (reached.find(next) == reached.end()) {
frontier.push(next);
reached.insert(next);
}
}
}
}
int main() {
breadth_first_search(example_graph, 'A');
}
运行结果:
Visiting A
Visiting B
Visiting C
Visiting D
Visiting E
栅格/网格(Grid)也可以表示为图。现在,我将定义一个名为 SquareGrid 的图类,其中的位置数据结构为两个整数。在此地图中,图中的位置(“状态”)与游戏地图上的位置相同,但在许多问题中,图中的位置与地图上的位置不同。我将不显式存储边数据,而是使用neighbors函数来计算它们。不过,在其他许多问题中,最好将它们明确存储:
struct GridLocation {
int x, y;
};
namespace std {
/* implement hash function so we can put GridLocation into an unordered_set */
template <> struct hash<GridLocation> {
typedef GridLocation argument_type;
typedef std::size_t result_type;
std::size_t operator()(const GridLocation& id) const noexcept {
return std::hash<int>()(id.x ^ (id.y << 4));
}
};
}
struct SquareGrid {
static std::array<GridLocation, 4> DIRS;
int width, height;
std::unordered_set<GridLocation> walls;
SquareGrid(int width_, int height_)
: width(width_), height(height_) {}
bool in_bounds(GridLocation id) const {
return 0 <= id.x && id.x < width
&& 0 <= id.y && id.y < height;
}
bool passable(GridLocation id) const {
return walls.find(id) == walls.end();
}
std::vector<GridLocation> neighbors(GridLocation id) const {
std::vector<GridLocation> results;
for (GridLocation dir : DIRS) {
GridLocation next{id.x + dir.x, id.y + dir.y};
if (in_bounds(next) && passable(next)) {
results.push_back(next);
}
}
if ((id.x + id.y) % 2 == 0) {
// aesthetic improvement on square grids
std::reverse(results.begin(), results.end());
}
return results;
}
};
std::array<GridLocation, 4> SquareGrid::DIRS =
{GridLocation{1, 0}, GridLocation{0, -1}, GridLocation{-1, 0}, GridLocation{0, 1}};
在辅助文件implementation.cpp
我定义了一个函数来生成网格:
#include "redblobgames/pathfinding/a-star/implementation.cpp"
int main() {
SquareGrid grid = make_diagram1();
draw_grid(grid, 2);
}
运行结果:
. . . . . . . . . . . . . . . . . . . . . ####. . . . . . .
. . . . . . . . . . . . . . . . . . . . . ####. . . . . . .
. . . . . . . . . . . . . . . . . . . . . ####. . . . . . .
. . . ####. . . . . . . . . . . . . . . . ####. . . . . . .
. . . ####. . . . . . . . ####. . . . . . ####. . . . . . .
. . . ####. . . . . . . . ####. . . . . . ##########. . . .
. . . ####. . . . . . . . ####. . . . . . ##########. . . .
. . . ####. . . . . . . . ####. . . . . . . . . . . . . . .
. . . ####. . . . . . . . ####. . . . . . . . . . . . . . .
. . . ####. . . . . . . . ####. . . . . . . . . . . . . . .
. . . ####. . . . . . . . ####. . . . . . . . . . . . . . .
. . . ####. . . . . . . . ####. . . . . . . . . . . . . . .
. . . . . . . . . . . . . ####. . . . . . . . . . . . . . .
. . . . . . . . . . . . . ####. . . . . . . . . . . . . . .
. . . . . . . . . . . . . ####. . . . . . . . . . . . . . .
让我们再实现一遍广度优先搜索,这次带上came_from
,对路径持续跟踪:
#include "redblobgames/pathfinding/a-star/implementation.cpp"
template<typename Location, typename Graph>
std::unordered_map<Location, Location>
breadth_first_search(Graph graph, Location start) {
std::queue<Location> frontier;
frontier.push(start);
std::unordered_map<Location, Location> came_from;
came_from[start] = start;
while (!frontier.empty()) {
Location current = frontier.front();
frontier.pop();
for (Location next : graph.neighbors(current)) {
if (came_from.find(next) == came_from.end()) {
frontier.push(next);
came_from[next] = current;
}
}
}
return came_from;
}
int main() {
SquareGrid grid = make_diagram1();
auto parents = breadth_first_search(grid, GridLocation{7, 8});
draw_grid(grid, 2, nullptr, &parents);
}
运行结果:
> > > > v v v v v v v v v v v v < < < < < ####v v v v v v v
> > > > > v v v v v v v v v v < < < < < < ####v v v v v v v
> > > > > v v v v v v v v v < < < < < < < ####> v v v v v v
> > ^ ####v v v v v v v v < < < < < < < < ####> > v v v v v
> ^ ^ ####v v v v v v v < ####^ < < < < < ####> > > v v v v
^ ^ ^ ####v v v v v v < < ####^ ^ < < < < ##########v v v <
^ ^ ^ ####> v v v v < < < ####^ ^ ^ < < < ##########v v < <
v v v ####> > v v < < < < ####^ ^ ^ ^ < < < < < < < < < < <
v v v ####> > * < < < < < ####^ ^ ^ ^ ^ < < < < < < < < < <
v v v ####> ^ ^ ^ < < < < ####^ ^ ^ ^ ^ ^ < < < < < < < < <
v v v ####^ ^ ^ ^ ^ < < < ####^ ^ ^ ^ ^ ^ ^ < < < < < < < <
> v v ####^ ^ ^ ^ ^ ^ < < ####^ ^ ^ ^ ^ ^ ^ ^ < < < < < < <
> > > > > ^ ^ ^ ^ ^ ^ ^ < ####^ ^ ^ ^ ^ ^ ^ ^ ^ < < < < < <
> > > > ^ ^ ^ ^ ^ ^ ^ ^ ^ ####^ ^ ^ ^ ^ ^ ^ ^ ^ ^ < < < < <
> > > ^ ^ ^ ^ ^ ^ ^ ^ ^ ^ ####^ ^ ^ ^ ^ ^ ^ ^ ^ ^ ^ < < < <
一些实现使用内部存储(internal storage
),创建一个节点(Node
)对象来表示图中的节点,通过它来保存came_from
和一些其他信息。相反,我选择使用外部存储(external storage
),创建一个单独的std::unordered_map
来存储图中所有节点的came_from
数据。如果你能确定你的图只使用整数来索引节点,那么你还可以使用一维或二维数组 array
(或向量 vector
)来存储came_from
或其他信息。
广度优先搜索和 Dijkstra 算法默认会探索整张地图。如果我们只是寻找某一个目标点,我们可以增加一个判断if (current == goal)
,一旦我们找到目标点,便立即退出循环。
#include "redblobgames/pathfinding/a-star/implementation.cpp"
template<typename Location, typename Graph>
std::unordered_map<Location, Location>
breadth_first_search(Graph graph, Location start, Location goal) {
std::queue<Location> frontier;
frontier.push(start);
std::unordered_map<Location, Location> came_from;
came_from[start] = start;
while (!frontier.empty()) {
Location current = frontier.front();
frontier.pop();
if (current == goal) {
break;
}
for (Location next : graph.neighbors(current)) {
if (came_from.find(next) == came_from.end()) {
frontier.push(next);
came_from[next] = current;
}
}
}
return came_from;
}
int main() {
GridLocation start{8, 7};
GridLocation goal{17, 2};
SquareGrid grid = make_diagram1();
auto came_from = breadth_first_search(grid, start, goal);
draw_grid(grid, 2, nullptr, &came_from);
}
运行结果:
. > > > v v v v v v v v v v v v < . . . . ####. . . . . . .
> > > > > v v v v v v v v v v < < < . . . ####. . . . . . .
> > > > > v v v v v v v v v < < < < . . . ####. . . . . . .
> > ^ ####v v v v v v v v < < < < < < . . ####. . . . . . .
. ^ ^ ####> v v v v v v < ####^ < < . . . ####. . . . . . .
. . ^ ####> > v v v v < < ####^ ^ . . . . ##########. . . .
. . . ####> > > v v < < < ####^ . . . . . ##########. . . .
. . . ####> > > * < < < < ####. . . . . . . . . . . . . . .
. . . ####> > ^ ^ ^ < < < ####. . . . . . . . . . . . . . .
. . v ####> ^ ^ ^ ^ ^ < < ####. . . . . . . . . . . . . . .
. v v ####^ ^ ^ ^ ^ ^ ^ < ####. . . . . . . . . . . . . . .
> v v ####^ ^ ^ ^ ^ ^ ^ ^ ####. . . . . . . . . . . . . . .
> > > > > ^ ^ ^ ^ ^ ^ ^ ^ ####. . . . . . . . . . . . . . .
> > > > ^ ^ ^ ^ ^ ^ ^ ^ ^ ####. . . . . . . . . . . . . . .
. > > ^ ^ ^ ^ ^ ^ ^ ^ ^ ^ ####. . . . . . . . . . . . . . .
从结果来看可以发现,现在算法并没有探索整张地图,而是提前结束了。
下面要开始增加图搜索算法的复杂度了,因为我们将使用比“先进先出(FIFO)”更合适的顺序来处理图中的位置。那么我们需要改变什么呢?
一个普通的图告诉我们每个节点的邻居。一个加权图还告诉我们沿着每条边进行移动的成本。我将添加一个cost(from_node, to_node)函数,该函数告诉我们从位置from_node到to_node之间的成本。在此文使用的图中,为了简化,我选择使运动成本仅依赖于to_node,但是还有其他类型,它们的移动成本依赖于from_node和to_node。另一种实现是将移动成本合并到neighbors函数中。这里是一个网格图,其中有一些节点的移动成本是5:
struct GridWithWeights: SquareGrid {
std::unordered_set<GridLocation> forests;
GridWithWeights(int w, int h): SquareGrid(w, h) {}
double cost(GridLocation from_node, GridLocation to_node) const {
return forests.find(to_node) != forests.end()? 5 : 1;
}
};
我们需要优先队列。C++ 提供了一个priority_queue类,它使用了二叉堆,因此不能为元素重新设置优先级。我将使用 pair(priority,item)作为队列元素,进行排序。C++ 优先队列默认返回优先级最大的元素,使用的是std::less比较符,但是,我们需要的是最小的元素,因此,使用std::greater比较符。
template<typename T, typename priority_t>
struct PriorityQueue {
typedef std::pair<priority_t, T> PQElement;
std::priority_queue<PQElement, std::vector<PQElement>,
std::greater<PQElement>> elements;
inline bool empty() const {
return elements.empty();
}
inline void put(T item, priority_t priority) {
elements.emplace(priority, item);
}
T get() {
T best_item = elements.top().second;
elements.pop();
return best_item;
}
};
在上面的代码中,我对 C++ std::priority_queue
封装了一层,但我认为直接使用该类也很合理。
这里可以查看地图
template<typename Location, typename Graph>
void dijkstra_search
(Graph graph,
Location start,
Location goal,
std::unordered_map<Location, Location>& came_from,
std::unordered_map<Location, double>& cost_so_far)
{
PriorityQueue<Location, double> frontier;
frontier.put(start, 0);
came_from[start] = start;
cost_so_far[start] = 0;
while (!frontier.empty()) {
Location current = frontier.get();
if (current == goal) {
break;
}
for (Location next : graph.neighbors(current)) {
double new_cost = cost_so_far[current] + graph.cost(current, next);
if (cost_so_far.find(next) == cost_so_far.end()
|| new_cost < cost_so_far[next]) {
cost_so_far[next] = new_cost;
came_from[next] = current;
frontier.put(next, new_cost);
}
}
}
}
成本变量的类型应完全与图中的类型匹配。如果你使用int,则你可以将int作为成本变量类型以及优先级队列中的优先级;如果你使用double,则成本变量类型和优先级队列中的优先级应相应使用double,诸如此类。在这段代码中,我使用了double,但我也可以使用int,它们的工作原理是相同的。但是,如果你的边成本是浮点型,或者你的启发函数是浮点型,那么在这里你也必须使用浮点型。
最终,在搜素完成后,我们需要构建路径:
template<typename Location>
std::vector<Location> reconstruct_path(
Location start, Location goal,
std::unordered_map<Location, Location> came_from
) {
std::vector<Location> path;
Location current = goal;
while (current != start) {
path.push_back(current);
current = came_from[current];
}
path.push_back(start); // optional
std::reverse(path.begin(), path.end());
return path;
}
尽管最好将路径视为由一系列边组成,不过这里将它们存储为一系列节点更为方便。为了构建路径,我们需要从目标节点开始,依据存储在came_from里的数据——指向该节点的前一个节点,不断回溯。当我们到达开始节点时,我们就完成了。不过可以发现,这样构建的路径是反向的(从目标节点到开始节点),因此,如果你想把数据改成正向的,需要在reconstruct_path函数最后调用reverse()方法,将路径反转。有时反向存储更有利,有时将开始节点也加入队列中更有利。
让我们试试看:
#include “redblobgames/pathfinding/a-star/implementation.cpp”
int main() {
GridWithWeights grid = make_diagram4();
GridLocation start{1, 4};
GridLocation goal{8, 5};
std::unordered_map<GridLocation, GridLocation> came_from;
std::unordered_map<GridLocation, double> cost_so_far;
dijkstra_search(grid, start, goal, came_from, cost_so_far);
draw_grid(grid, 2, nullptr, &came_from);
std::cout << '\n';
draw_grid(grid, 3, &cost_so_far, nullptr);
std::cout << '\n';
std::vector<GridLocation> path = reconstruct_path(start, goal, came_from);
draw_grid(grid, 3, nullptr, nullptr, &path);
}
运行结果:
v v < < < < < < < <
v v < < < ^ ^ < < <
v v < < < < ^ ^ < <
v v < < < < < ^ ^ <
> * < < < < < > ^ <
^ ^ < < < < . v ^ .
^ ^ < < < < < v < .
^ ######^ < v v < .
^ ######v v v < < <
^ < < < < < < < < <
5 4 5 6 7 8 9 10 11 12
4 3 4 5 10 13 10 11 12 13
3 2 3 4 9 14 15 12 13 14
2 1 2 3 8 13 18 17 14 15
1 0 1 6 11 16 21 20 15 16
2 1 2 7 12 17 . 21 16 .
3 2 3 4 9 14 19 16 17 .
4 #########14 19 18 15 16 .
5 #########15 16 13 14 15 16
6 7 8 9 10 11 12 13 14 15
. @ @ @ @ @ @ . . .
. @ . . . . @ @ . .
. @ . . . . . @ @ .
. @ . . . . . . @ .
. @ . . . . . . @ .
. . . . . . . . @ .
. . . . . . . . . .
. #########. . . . . .
. #########. . . . . .
结果与 Python 版本并不完全相同,因为我使用的都是它们内置的优先级队列,而 C++ 和 Python 可能会对相同优先级的节点进行不同的排序。
A* 算法几乎和 Dijkstra 算法一样,只不过我们添加了一个启发函数。请注意,该算法并不只针对网格地图。关于网格的信息在图类型(SquareGrids)、位置类(Location结构)以及heuristic函数中。将它们3个替换掉,你可以将 A* 算法与任意其他图数据结构结合在一起使用。
inline double heuristic(GridLocation a, GridLocation b) {
return std::abs(a.x - b.x) + std::abs(a.y - b.y);
}
template<typename Location, typename Graph>
void a_star_search
(Graph graph,
Location start,
Location goal,
std::unordered_map<Location, Location>& came_from,
std::unordered_map<Location, double>& cost_so_far)
{
PriorityQueue<Location, double> frontier;
frontier.put(start, 0);
came_from[start] = start;
cost_so_far[start] = 0;
while (!frontier.empty()) {
Location current = frontier.get();
if (current == goal) {
break;
}
for (Location next : graph.neighbors(current)) {
double new_cost = cost_so_far[current] + graph.cost(current, next);
if (cost_so_far.find(next) == cost_so_far.end()
|| new_cost < cost_so_far[next]) {
cost_so_far[next] = new_cost;
double priority = new_cost + heuristic(next, goal);
frontier.put(next, priority);
came_from[next] = current;
}
}
}
}
priority值的类型,包括优先级队列中使用的类型,必须最够大,大到可以包含图中移动成本(cost_t)和启发函数值。例如,如果图成本为整数,且启发函数返回双精度,则优先级队列也必须接受双精度值。在上述示例代码中,我对这三个值(成本费用,启发式函数和优先级)统一使用了double,不过我也可以使用int,因为我的费用成本和启发式函数都是整数值。
小提示:写frontier.put(start, heuristic(start, goal))比写frontier.put(start, 0)更正确,但是这里没有区别,因为起始节点的优先级无关紧要。开始时,它是优先级队列中唯一的节点,一定会被优先访问并从队列中删除。
#include "redblobgames/pathfinding/a-star/implementation.cpp"
int main() {
GridWithWeights grid = make_diagram4();
GridLocation start{1, 4};
GridLocation goal{8, 5};
std::unordered_map<GridLocation, GridLocation> came_from;
std::unordered_map<GridLocation, double> cost_so_far;
a_star_search(grid, start, goal, came_from, cost_so_far);
draw_grid(grid, 2, nullptr, &came_from);
std::cout << '\n';
draw_grid(grid, 3, &cost_so_far, nullptr);
std::cout << '\n';
std::vector<GridLocation> path = reconstruct_path(start, goal, came_from);
draw_grid(grid, 3, nullptr, nullptr, &path);
}
运行结果
v v v v < < < < < <
v v v v < ^ ^ < < <
v v v v < < ^ ^ < <
v v v < < < . ^ ^ <
> * < < < < . > ^ <
> ^ < < < < . . ^ .
^ ^ ^ < < < . . . .
^ ######^ . . . . .
^ ######. . . . . .
^ . . . . . . . . .
5 4 5 6 7 8 9 10 11 12
4 3 4 5 10 13 10 11 12 13
3 2 3 4 9 14 15 12 13 14
2 1 2 3 8 13 . 17 14 15
1 0 1 6 11 16 . 20 15 16
2 1 2 7 12 17 . . 16 .
3 2 3 4 9 14 . . . .
4 #########14 . . . . .
5 #########. . . . . .
6 . . . . . . . . .
. . . @ @ @ @ . . .
. . . @ . . @ @ . .
. . . @ . . . @ @ .
. . @ @ . . . . @ .
. @ @ . . . . . @ .
. . . . . . . . @ .
. . . . . . . . . .
. #########. . . . . .
. #########. . . . . .
. . . . . . . . . .
如果你在自己的项目中实现这些代码,可能会发现某些路径并不如你所愿。这是正常的。当使用网格(grid)时,特别是在移动成本都相同的网格中,最终会遇到一些局限:很多条路径的成本是完全相同的。A* 最终选择了这些路径中的一条,但这通常对你来说并不是最合适的路径。有个快速技巧可以打破这个局限,但并不能达到完美。更好的方式是改变地图的表现形式,这不仅可以使 A* 运行的更快,还可以产生更直接、美观的路径。然而,这些仅适用于移动成本相同的大部分静态地图。对于上文的演示,我使用了快速技巧,但也仅适用于优先级较低的队列。如果你使用的是优先级较高的队列,那么你需要使用其他的技巧。
上面展示的 C++ 代码经过简化,可以更轻松地展示算法和数据结构。然而,在实践中,你需要做很多不同的事情:
以下函数展示了上述这些变化(但不是全部):
template<typename Graph>
void a_star_search
(Graph graph,
typename Graph::Location start,
typename Graph::Location goal,
std::function<typename Graph::cost_t(typename Graph::Location a, typename Graph::Location b)> heuristic,
std::unordered_map<typename Graph::Location, typename Graph::Location>& came_from,
std::unordered_map<typename Graph::Location, typename Graph::cost_t>& cost_so_far)
{
typedef typename Graph::Location Location;
typedef typename Graph::cost_t cost_t;
PriorityQueue<Location, cost_t> frontier;
std::vector<Location> neighbors;
frontier.put(start, cost_t(0));
came_from[start] = start;
cost_so_far[start] = cost_t(0);
while (!frontier.empty()) {
typename Location current = frontier.get();
if (current == goal) {
break;
}
graph.get_neighbors(current, neighbors);
for (Location next : neighbors) {
cost_t new_cost = cost_so_far[current] + graph.cost(current, next);
if (cost_so_far.find(next) == cost_so_far.end()
|| new_cost < cost_so_far[next]) {
cost_so_far[next] = new_cost;
cost_t priority = new_cost + heuristic(next, goal);
frontier.put(next, priority);
came_from[next] = current;
}
}
}
}
我希望本文代码更多展示数据结构和算法,而不是展示 C++ 优化,所以我努力简化代码,不是写运行更快或更抽象的代码。
本文中的 Dijkstra 算法和 A* 算法与常见 AI 课本上的算法略有不同。
纯粹的 Dijkstra 算法会将全部节点添加到优先级队列开始,并且没有提前退出逻辑。它在队列中使用“减小键(decrease-key)”的操作。理论上没问题,但是在实践中…
这个变体有时被称为“统一成本搜索(Uniform Cost Search)”。请参阅维基百科以查看伪代码,或阅读Felner的论文[pdf]来查看这些更改的依据。
我的版本与你可能在其他地方找到的版本之间还有三个不同之处。这些变化适用于 Dijkstra 算法和 A* 算法:
如果你有更多建议可以简化并保持性能,请让我知道!