← 目录 / 算法文档 · 模块十四 搜索进阶 / 14.4 A* 算法

14.4 A* 算法

BFS 不管方向、往四面八方均匀扩展——A* 多了一个"感觉离终点还有多远"的估计,优先朝着这个方向探索,能少走很多冤枉路。

本页目录
① 为什么需要 A*:BFS 不知道"哪个方向更接近终点"

14.1 节的 BFS 像一圈圈扩散的水波——不管终点在哪个方向,它都会均匀地往四面八方探索,一层一层扩大范围,直到扩散到终点为止。如果终点其实就在正前方不远处,BFS 依然会把"背对终点"的方向也探索个遍,才轮到终点所在的方向——这些背离终点方向的探索,其实是白费的。

A* 算法在 BFS(严格说是 Dijkstra,见 ⑥)的基础上,多引入了一条信息:每个位置"大概"离终点还有多远(不要求精确,只要是一个合理的估计)。有了这条信息,搜索就能优先朝着"感觉更接近终点"的方向扩展,像手里多了一个指南针,不再是无差别地往四周探索。

② 核心思想:f(n) = g(n) + h(n)

A* 给每个待探索的节点算一个分数 f(n),由两部分相加组成:

符号含义
g(n)从起点实际走到 n 已经花费的代价(这部分是精确值,不是估计)
h(n)从 n 估计还要多少代价才能到达终点(启发式函数,Heuristic)
f(n) = g(n) + h(n)"如果经过 n",从起点到终点的总代价估计

A* 用一个优先队列(小顶堆)代替 BFS 的普通队列,每次弹出 f(n) 最小的节点来扩展——也就是"当前看起来最有希望、总代价最小"的那个节点优先探索,而不是像 BFS 那样按入队顺序(也就是纯粹按距离远近)挨个处理。

③ 启发式函数怎么定义:曼哈顿距离与"不能高估"

在网格寻路里,最常用的启发式函数是曼哈顿距离(只能上下左右移动时):h(n) = |n.x - T.x| + |n.y - T.y|——横纵坐标差的绝对值之和,也就是"不考虑障碍物,直着走过去需要几步"。

启发式函数 h(n) 有一条必须遵守的规则:永远不能高估真实代价(这样的 h 称为"可采纳的",admissible)。如果 h(n) 估得比实际需要的步数还多,A* 可能会因为"觉得这条路太远"而提前放弃一条其实更优的路径,导致算出的答案不是最短路。曼哈顿距离在只能上下左右移动的网格里,天然满足"不高估"——真实路径可能因为障碍物绕远,但绝不会比直线距离更短。

⚠️
h(n) = 0 也是合法的(只是没有意义):如果 h(n) 恒为 0,A* 就退化成了普通的 Dijkstra/BFS——没有方向指引,纯粹按已走代价排序。h 估计得越准(同时不高估),A* 能"提前排除"的无关方向就越多,效率提升越明显;估得越不准,效率提升就越有限。
④ 图解:同一张网格,BFS 和 A* 各自访问的范围

在一张 5×7 的空网格上,起点 S 在最左列正中间,终点 T 在最右列正中间,两者相距 4 步(无障碍物)。对比 BFS 和 A*(曼哈顿距离启发)各自访问过的格子:

BFS 访问范围(23 / 35 格)
S
T
像水波一样往上下左右均匀扩散,直到第 4 层才碰到 T——上下两个方向也探索了一大片
A* 访问范围(5 / 35 格)
S
T
只沿着 S、T 所在的这一行直接探索,上下方向的格子因为 f 值更大,根本没被碰过
差距是怎么来的?以偏离直线一行的格子 (col=1, row=2) 为例:g(从 S 走到这里)需要 2 步,h(曼哈顿估计到 T)是 3+1=4f = 2+4 = 6;而直线上的格子 (col=1, row=3)g=1h=3f=1+3=4。优先队列每次弹出 f 最小的节点,f=4 的这一整行会被优先处理完,等不到 f=6 的格子被拿出来,S 到 T 之间的最短路已经找到了。
⑤ 模板代码
C++ · A* 网格寻路模板
1int n, m, er, ec; // 网格大小、终点坐标
2int g[MAXN][MAXN]; // g[r][c]:从起点到 (r,c) 的实际步数,-1 表示未访问
3int dx[] = {0, 0, 1, -1}, dy[] = {1, -1, 0, 0};
4
5int H(int r, int c) // 曼哈顿距离估价
6{
7 return abs(r - er) + abs(c - ec);
8}
9
10// 队列里存 (f, r, c);pair 默认按 f 从小到大比较,用 greater 做小顶堆
11int AStar(int sr, int sc)
12{
13 memset(g, -1, sizeof(g));
14 priority_queue<tuple<int,int,int>, vector<tuple<int,int,int>>, greater<>> pq;
15 g[sr][sc] = 0;
16 pq.push({H(sr, sc), sr, sc}); // f = g(0) + h
17
18 while (!pq.empty())
19 {
20 auto [f, r, c] = pq.top(); pq.pop(); // ★ 每次取 f 最小的节点
21 if (r == er && c == ec) return g[r][c]; // 第一次弹出终点,g 就是最短距离
22 if (f > g[r][c] + H(r, c)) continue; // 过时的队列项,跳过(见 ⑦ 陷阱)
23
24 for (int k = 0; k < 4; k++)
25 {
26 int nr = r + dx[k], nc = c + dy[k];
27 if (nr < 0 || nr >= n || nc < 0 || nc >= m) continue;
28 int ng = g[r][c] + 1;
29 if (g[nr][nc] == -1 || ng < g[nr][nc]) // 没访问过,或者找到了更短的路
30 {
31 g[nr][nc] = ng;
32 pq.push({ng + H(nr, nc), nr, nc}); // f = 新的 g + h
33 }
34 }
35 }
36 return -1;
37}
💡
和 BFS 相比,改动集中在两处:优先队列(按 f 排序)代替普通队列;每次入队时多加一项 H(nr, nc) 算出 f。除此之外的骨架——记录已走距离、四方向扩展、越界检查——和 14.1 节的 BFS 完全一样。
⑥ 特殊情况:h(n) = 0 时就是 Dijkstra

观察 f(n) = g(n) + h(n):如果把 h(n) 恒定设为 0,A* 的优先队列就完全按 g(n)(实际已走代价)排序——这正是 Dijkstra 算法的做法。再进一步,如果每条边的代价都固定是 1(无权图),按 g(n) 排序又和"先入队的先处理"的普通队列(BFS)等价。可以说,BFS ⊂ Dijkstra ⊂ A*——A* 是在 Dijkstra 的基础上加上了"目标方向感",Dijkstra 又是 BFS 在带权图上的推广。

⑦ 适用条件与常见陷阱
启发式函数高估,导致算出的不是最短路:比如把网格的曼哈顿距离误用成"斜线直线距离"(欧几里得距离)却只允许上下左右移动,会导致 h 有时比真实代价小、有时因为四舍五入等问题反而更大。只要 h 存在高估的可能,A* 就不再保证找到最短路——设计启发式函数时,宁可估得保守一些(偏小),也不能有高估的风险。
同一个格子被多次以不同的 g 值入队:代码第 29 行允许"找到更短的路就更新 g 并重新入队",这意味着同一个坐标可能同时有好几条记录躺在优先队列里,其中大部分已经"过时"(后来被更小的 g 覆盖了)。第 22 行 if (f > g[r][c] + H(r,c)) continue; 就是用来识别并跳过这些过时记录的——如果漏掉这一行判断,程序不会出错,但会重复处理很多已经不需要再处理的节点,白白浪费时间。
h(n) 设计得太"贴合"具体地图,换个场景就失效:启发式函数只应该利用"坐标"这类通用信息(比如曼哈顿距离、欧几里得距离),不应该依赖某一张具体地图才成立的巧合规律。地图和移动规则变了(比如允许斜着走、或者移动代价不再是 1),原来的 h 函数可能不再满足"不高估",需要重新设计(比如允许斜走时改用切比雪夫距离)。
🏆
接下来:本节的 A* 使用优先队列,内存开销和 BFS 类似(需要保存整个搜索前沿)。14.5 节的 IDA* 会把 14.3 节 IDDFS 的"深度限制、反复加深"思路搬过来,把限制的对象从"深度"换成"f 值",用 DFS 的低内存开销实现和 A* 一样的启发式剪枝效果。