写这篇东西的起因,是最近在项目里要做一个网格地图的寻路模块。同事甩过来一堆参考代码,要么只给了伪代码,要么讲了一堆理论却没法直接跑。我干脆自己整理了一份传统A*算法的核心实现,从地图建模开始写,一直写到能跑通、能回溯路径、能处理八方向扩展。代码都是可以直接复制粘贴的Python版本,适合项目里做路径规划的工程师、算法刚入门的新手,以及准备算法面试的同学拿来复盘。
A*说起来并不复杂,一句话版本就是:每次从候选节点里挑一个“看起来最有希望”的节点去扩展,而不是像暴力枚举一样把所有路径无脑列一遍。它有代价估计函数,有open列表和close列表,核心就那几行。但越简单的算法,越容易在细节上翻车。这篇文章我把自己踩过的坑也一并写出来了,希望能帮你少走弯路。
1. 先把A*的核心思路说清楚
1.1 A*到底在解决什么问题
先说一个最基础的场景:一张网格地图上,有起点S、终点T,还有一些不可通行的障碍物。我们要找到一条从S到T的可行路径,并且希望这条路径尽可能短。这个场景在游戏AI、机器人导航、地图路径规划里到处都是。
有人第一反应是暴力枚举——把所有从S到T的路径全列出来,然后挑最短的那条。地图一但超过几十个格子,这种做法的计算量就是指数级爆炸,根本扛不住。还有一类做法是无脑发散式搜索,比如BFS广度优先搜索,它能找到最短路径,但搜索方向没有目标感,地图一大也会在无关区域里浪费大量时间。
A*做的是一件事:给搜索过程“装一个导航仪”。它每次扩展节点时,不只考虑已经走过的代价,还会估算“从这里到终点还要走多远”,两者加起来,作为挑选下一个扩展节点的依据。这样搜索会优先朝终点的方向推进,而不是在全图范围内瞎转。
它解决的核心问题,就是在保证能找到最优路径的前提下,大幅减少搜索的节点数量。当然,这里的“最优”是有前提的,后面讲启发函数的时候我会说清楚。
1.2 F = G + H 这三个字母的含义
A*的全部核心,就是下面这个公式:
F(n) = G(n) + H(n)
- G(n):从起点S到达当前节点n,已经付出的实际代价。
- H(n):从当前节点n到终点T,还需要付出的代价的估计值。
- F(n):两者的和,用来衡量一个节点“整体上值不值得优先扩展”。
怎么理解?你可以把寻路想象成开车导航。G就是你已经开过的里程,H是地图上估算还剩多少公里,F就是“预计全程总里程”。每到一个路口,你会优先选择预计总里程最小的那条路。如果路上的实际路况和你估计的差不多,这个策略通常会很聪明。
这里有一个关键点:H函数不能“太离谱”。如果H(n)始终小于等于从n到终点的真实最短代价,A*就一定能找到最优路径,这个性质叫“可采纳性”。换句话说,启发函数可以乐观,但不能悲观地高估。为什么?因为一旦H高估了剩余距离,某些真正优秀的路径可能因为F算出来偏大,就一直排在open列表后面不被扩展,最后算法走一条“看起来更顺但实际更长”的路。
2. 写代码前的地图与数据结构选择
2.1 网格地图怎么建模
传统A*最常见的应用场景就是二维网格地图。我用二维数组表示地图,0表示可以通行的空地,1表示障碍物。坐标我用(row, col)来定义,也就是先写行号,再写列号。这一点必须统一,否则后面写代码很容易把自己的坐标搞晕。
比如下面这张地图:
grid = [ [0, 0, 0, 0, 1, 0], [0, 1, 1, 0, 1, 0], [0, 0, 0, 0, 0, 0], [0, 1, 1, 1, 0, 0], [0, 0, 0, 1, 0, 0], ]起点我设成(0, 0),终点设成(4, 5)。中间的1都是墙,A*要在这堆障碍物之间找出一条从左上角到右下角的通路。这种建模方式简单直接,任何语言都容易实现,也方便后续改成从文件或数据库读地图。
2.2 两个列表和那个关键的最小堆
A*的教科书版本里会有两个列表:open列表和close列表。open列表存放“已经发现但还没扩展”的节点,close列表存放“已经扩展完”的节点。
工程实现里,close列表可以直接用set,判断一个节点是否已处理,查哈希表就行。open列表如果认真选数据结构,强烈建议用最小堆,Python里就是heapq。因为A*每次都要从open列表里取一个F值最小的节点,堆的取最小操作是O(log n),而普通list线性扫描取最小是O(n),地图稍微一大,差别就是秒级和毫秒级的差距。
另外还需要三个辅助容器:
- g_score:记录起点到每个节点的最小代价。
- f_score:记录每个节点的F值,也就是排序依据。
- came_from:记录每个节点的最优父节点,最后用于回溯路径。
这三个容器用字典就能搞定。g_score和f_score在节点未被访问时,默认值是无穷大。came_from只有在节点被更新时才会记录,起点没有父节点,所以回溯到起点就停止。
3. 直接上代码:传统A*的核心实现
3.1 先用最简洁的版本跑通
先给你看一个最直接、最贴近教科书伪代码的版本。它干净、好懂,适合第一次接触A*的人。
import heapq def heuristic(a, b): # 曼哈顿距离:四方向移动时使用 return abs(a[0] - b[0]) + abs(a[1] - b[1]) def a_star_simple(grid, start, goal): rows, cols = len(grid), len(grid[0]) open_heap = [(0, start)] g_score = {start: 0} f_score = {start: heuristic(start, goal)} came_from = {} while open_heap: _, current = heapq.heappop(open_heap) if current == goal: path = [current] while current in came_from: current = came_from[current] path.append(current) path.reverse() return path # 四方向邻居 for dr, dc in [(-1, 0), (1, 0), (0, -1), (0, 1)]: nr, nc = current[0] + dr, current[1] + dc neighbor = (nr, nc) if not (0 <= nr < rows and 0 <= nc < cols): continue if grid[nr][nc] == 1: continue tentative_g = g_score[current] + 1 if tentative_g < g_score.get(neighbor, float('inf')): came_from[neighbor] = current g_score[neighbor] = tentative_g f = tentative_g + heuristic(neighbor, goal) f_score[neighbor] = f heapq.heappush(open_heap, (f, neighbor)) return None grid = [ [0, 0, 0, 0, 1, 0], [0, 1, 1, 0, 1, 0], [0, 0, 0, 0, 0, 0], [0, 1, 1, 1, 0, 0], [0, 0, 0, 1, 0, 0], ] path = a_star_simple(grid, (0, 0), (4, 5)) print(path)这段代码跑起来会输出一条从(0, 0)到(4, 5)的路径。它能在大多数场景下正常工作,但有一个隐藏问题,我后面第4章会专门讲,那就是同一个节点可能被重复放进堆里。如果你只是学习算法流程,这个版本完全够用;如果要在生产环境里用,请务必看4.1的工程版写法。
3.2 主循环的四步操作
上面这段代码看起来短,但每一行都有它的意图。我把主循环拆成四步。
第一步,从堆里弹出一个F值最小的节点,作为当前要扩展的节点。如果当前节点就是终点,直接进入路径回溯流程。这里有个细节:因为min-heap是按F排序的,所以堆顶永远是最有希望的节点。
第二步,遍历当前节点的邻居。这里先用最简单的四方向邻居:上、下、左、右。每个邻居都要经过三层过滤:是否越界、是否是障碍物、以及“从当前节点走到这个邻居是否比之前记录的路更短”。
第三步,计算 tentative_g,也就是从起点经过current走到neighbor的代价。四方向移动时每一步代价是1。如果tentative_g比g_score里记录的旧值更小,就更新这个邻居的父节点、g值和f值,然后把它推入堆中。这里要注意“只有更短才更新”这个条件,很多人写漏了,导致原本已经找到一条好路,后面又用一条坏路去覆盖,路径质量变差。
第四步,反复执行前三步,直到堆为空。堆为空说明open列表里的所有候选节点都被扩展完了,起点和终点之间在现有地图上根本没有通路,这时返回None。
这三层过滤配合“更短才更新”的判断,保证了A*不会无限循环,也保证了每个节点被更新的次数是有限的,算法一定能终止。
3.3 路径回溯的实现
路径回溯在代码里只有几行:
path = [current] while current in came_from: current = came_from[current] path.append(current) path.reverse()逻辑很简单:从终点开始,沿着came_from一路向上找父节点,直到找到起点为止,然后把列表反转,就得到了从起点到终点的完整路径。
came_from的设计非常关键。它只记录“当前已知最优路径上的父节点”,一旦某个节点被更短的路径更新,came_from里对应的父节点就会被覆盖。所以当你回溯时,得到的天然就是A*当前找到的最短路径。
这里有个容易犯的错,有人会忘记处理起点。起点的came_from是空的,所以while循环到起点就停了,这是正确的。但如果你不小心在路径里手动加入了起点,或者终点判断写在更新之前,回溯时可能会多一个或少一个节点,调试时看着路径首尾不对,其实都是这些边界没处理好。
4. 从代码到工程:这几个细节最容易翻车
4.1 open列表重复入堆:最隐蔽的坑
前面3.1的简单版代码,在实际运行中有一个隐患:同一个节点可能被多次推入堆中。原因在于,一个节点可能先被某条较长的路径发现,推入堆;后来又被另一条更短的路径发现,我们更新了它的g值和父节点,又把它推入堆。这时候堆里就有这个节点的两个条目,一个是旧F值,一个是新F值。
在简单版写法里,我们弹出节点后,没有校验“当前弹出的这个条目,是不是这个节点最新的状态”。于是算法可能用旧的g值继续扩展邻居。这会导致两个后果:一是路径可能变成次优解,明明有更短的路,却因为旧条目抢在前面扩展而走了一条绕路;二是节点会被反复处理,性能白白浪费。
工程版修复方式很简单:维护一个closed_set,弹出时先看是否已处理;再对比堆中记录,弹出一条过期的node时直接跳过。完整代码如下。
import heapq import math def a_star_pro(grid, start, goal): rows, cols = len(grid), len(grid[0]) g_score = {start: 0} f_score = {start: abs(start[0] - goal[0]) + abs(start[1] - goal[1])} came_from = {} closed_set = set() open_heap = [(f_score[start], g_score[start], start)] while open_heap: _, current_g, current = heapq.heappop(open_heap) # 过滤已处理节点 if current in closed_set: continue # 过滤堆里残留的过期条目 if current_g != g_score.get(current, -1): continue if current == goal: path = [current] while current in came_from: current = came_from[current] path.append(current) return path[::-1] closed_set.add(current) for dr, dc in [(-1, 0), (1, 0), (0, -1), (0, 1)]: nr, nc = current[0] + dr, current[1] + dc neighbor = (nr, nc) if not (0 <= nr < rows and 0 <= nc < cols): continue if grid[nr][nc] == 1: continue if neighbor in closed_set: continue tentative_g = g_score[current] + 1 if tentative_g < g_score.get(neighbor, float('inf')): came_from[neighbor] = current g_score[neighbor] = tentative_g f = tentative_g + abs(neighbor[0] - goal[0]) + abs(neighbor[1] - goal[1]) heapq.heappush(open_heap, (f, tentative_g, neighbor)) return None这段代码多加了两行判断,但正是这两行,把A*从“大部分时候能跑对”变成了“稳定正确”。我建议所有人在学习阶段就把这两个判断写进去,养成习惯。
4.2 八方向扩展与对角线穿墙问题
如果地图允许斜着走,比如从(0,0)直接走到(1,1),上面代码就要改。首先邻居列表要扩成八个方向:
directions = [ (-1, 0), (1, 0), (0, -1), (0, 1), (-1, -1), (-1, 1), (1, -1), (1, 1) ]代价也要改:横竖方向代价是1,斜方向代价是√2,约等于1.414。因为斜着走比横竖走略远一点点,也更符合几何直觉。
if abs(dr) + abs(dc) == 2: step_cost = math.sqrt(2) else: step_cost = 1但八方向会带来一个经典问题:斜穿墙角。比如从(0,0)斜走到(1,1),如果(0,1)或(1,0)中有任意一个是障碍物,理论上这个斜走动作就不合理——人不能挤着墙角穿过去。如果不加判断,A*会画出穿模路径。
解决方法是加上一个简单的门控函数:
def can_cross_corner(grid, current, dr, dc): if grid[current[0] + dr][current[1]] == 1 or grid[current[0]][current[1] + dc] == 1: return False return True在遍历斜方向邻居时,调用这个函数,过不了就直接continue。这个方法代价极低,但对路径的真实感提升非常明显。
另外还要记得改启发函数。四方向用曼哈顿距离,八方向应该用对角线距离,公式是:
dx = abs(a[0] - b[0]) dy = abs(a[1] - b[1]) return max(dx, dy) + (math.sqrt(2) - 1) * min(dx, dy)这个公式的计算逻辑是:长的方向必须一步步走,短的方向可以尽量用斜走“顺便”解决,剩余部分用横竖补足。它既不会高估真实代价,又能让搜索更快收敛。
4.3 启发函数不能“太高估”
讲到启发函数,这是A里最微妙也最容易被忽视的地方。很多人知道“H=0时A退化成Dijkstra”,但不知道H选错了会导致路径不是最优。
我见过一个很典型的面试翻车场景:地图允许八方向移动,有人却用曼哈顿距离当启发函数。曼哈顿距离假设只能横竖走,和斜向移动的真实代价不一致。在2x2地图上,终点在(1,1),起点在(0,0),斜走一步真实代价约1.414;但曼哈顿距离算出H=2,这就比真实剩余代价要大了。因为启发函数高估了剩余代价,A*就会觉得“终点附近的这条路看起来要花2的代价”,反而不太愿意去扩展那个斜方向邻居,可能最后找出一条长度为2的横竖路径,而不是长度为1.414的最优斜向路径。
所以不同移动方式配什么启发函数,一定要匹配。我做了一个速查表,直接照着用就行:
| 移动方式 | 单步代价 | 推荐启发函数 | 说明 |
|---|---|---|---|
| 四方向 | 1 | 曼哈顿距离 | 严格可采纳,最优 |
| 八方向 | 横竖1,斜向√2 | 对角线距离 | 严格可采纳,最优 |
| 任意角度 | 欧氏距离 | 欧氏距离 | 可采纳,但效率较低 |
| 带地形代价 | 地形相关 | 同类启发函数乘系数 | 需保证 h ≤ 真实代价 |
在实际项目里,你最需要记住一句话:启发函数只能低估,不能高估。低估时最坏情况是搜索变慢,但路径一定最优;高估时路径会变差,而且很难排查出来,因为代码逻辑看着也没问题,就是结果不对。
5. 我在项目里踩过的坑与排查实录
5.1 坐标轴写反,路径横穿边界
有一次我写A*时,把地图定义成grid[row][col],但邻居遍历里却写成grid[nc][nr],结果导致路径在某些区域直接横穿到地图外面。这类问题有个共同点:单独看逻辑什么都不觉得怪,但打印出来的路径坐标和地图结构对不上。
我的排查方法是写一个简单的绘图函数,把地图、起点、终点和路径一起打印到终端。用视觉检查比用坐标比对要快得多。
def print_path(grid, path): g = [row[:] for row in grid] for r, c in path: g[r][c] = '*' g[start[0]][start[1]] = 'S' g[goal[0]][goal[1]] = 'T' for row in g: print(' '.join(str(x) for x in row))路径一旦可视化,坐标轴写反、起点终点写反、穿越障碍物这些问题全部一目了然。我建议所有学A*的人,至少写一次这种打印函数。它花不了两分钟,却是定位问题的神器。
5.2 用list找最小节点,运行时间长到爆炸
有个朋友按教科书抄了一段A*,open列表用普通list,每次取最小F值时执行min(open_list)。小地图跑起来没问题,换成512x512的网格地图后,一次寻路跑了快两秒,完全没法用。
原因很简单:普通list找最小值的复杂度是O(n),open列表里可能有几千上万个节点,而A每扩展一个节点就要做一次min查找,总体复杂度就是O(n²)级别。换成heapq最小堆之后,每次取最小是O(log n),同场景下耗时降到几十毫秒。同样是A,数据结构选对了,性能差几十倍。
如果你正在处理的地图规模超过几百个节点,请毫不犹豫地使用堆。另外,Python的heapq默认按元组第一个元素排序,所以把(f, node)或(f, g, node)直接推入堆里就行,不用自己写比较器。
5.3 找不到路径的时候,不要急着改算法
A*返回None,并不代表算法有问题。最常见的原因是地图里起点或终点被障碍物完全包围,根本没有可行通路。遇到这种情况,我建议按下面的顺序排查:
先打印地图,确认起点和终点的坐标是不是真的在地图里,且对应的值不是1。再检查障碍物定义是否和预期一致,很多地图数据是从Json或者数据库里读进来的,行列顺序经常和代码里假设的不一致。最后把起点和终点附近的邻居全部放开跑一遍BFS,看是否连通。如果BFS都走不到终点,那A*返回None就是正确行为,问题出在地图数据而非搜索逻辑。
还有一个容易被忽略的点:如果地图很大,但A*返回None非常快,可能不是“没有路”,而是起点或终点被误标记成了障碍物,导致第一步扩展就直接过滤掉所有邻居。这个现象很迷惑人,我踩过一次,特此记录。
5.4 常见问题速查表
下面这个表是我在带新人时常用的排错清单,整理成速查表分享出来。
| 现象 | 可能原因 | 解决方式 |
|---|---|---|
| 路径穿墙或斜穿墙角 | 斜向移动未做穿墙判断 | 增加 can_cross_corner 检查 |
| 路径明显绕路 | 启发函数高估剩余代价 | 换成可采纳启发函数 |
| 终点不可达,返回None | 障碍物包围终点/坐标写错 | 打印地图和坐标,做连通性检查 |
| 地图大时非常卡 | open列表用list线性扫描 | 改用heapq最小堆 |
| 同一节点被反复处理 | 未维护closed_set或没过溯过期条目 | 增加已处理集合和过期校验 |
| 路径首尾不对 | 回溯时终点/起点边界处理出错 | 检查came_from初始化和path反转逻辑 |
这条清单看起来简单,但这几个问题至少能覆盖A*开发中九成以上的翻车现场。如果你遇到某种诡异的情况,先对照这张表看一眼,往往能省下好几个小时的断点调试时间。
6. 如果想更快:三个工程化方向
6.1 用版本号优化堆结构
工程版代码里,我同时用current not in closed_set和current_g != g_score.get(current)来过滤过期条目。还有一种做法是给每个节点一个版本号,每次更新时版本号加1,堆里同时保存版本号信息,弹出时检查版本号是否匹配。这种方式比查字典更快,适合对性能极度敏感的场景。
实现思路很简单:
ver = {} def update(node, new_g, new_f, came_from_node): ver[node] = ver.get(node, 0) + 1 g_score[node] = new_g f_score[node] = new_f came_from[node] = came_from_node heapq.heappush(open_heap, (new_f, new_g, node, ver[node]))弹出时判断ver[node]是否等于当前弹出的版本号,不等就跳过。这个方案在处理动态地图或反复寻路时特别好用,因为不需要频繁维护closed_set。
6.2 双向A*与跳点搜索
如果算法速度还不够,可以考虑两个方向:双向A*和JPS。
双向A是同时从起点和终点扩展,两边各维护一个open列表,当两个搜索前沿相遇时,把两条路径拼起来。它的优点是能把搜索空间从“一个圆形区域”缩小到“两个更小的半圆区域”的交汇带,在复杂地图上通常能快上不少。代码量比普通A多不了太多,重点是正确判断两棵搜索树的相遇点。
JPS跳点搜索则是利用网格的对称性,只扩展“跳点”,也就是关键拐弯点,而不是每个格子都处理一遍。在开阔的静态地图上,JPS可以比传统A快一个数量级。但JPS实现复杂,而且遇到动态障碍时会很吃力,需要配合预计算数据。如果你的地图是游戏中的大世界,可以考虑JPS;如果地图不大或经常变化,传统A加堆优化已经足够。
6.3 带权A*:用一点点最优性换速度
最后分享一个我经常用的小技巧:给启发函数乘一个权重,让A*更“贪婪”。
f = tentative_g + w * heuristic(neighbor, goal)当w大于1时,算法会更倾向于优先扩展离终点近的节点,搜索更快,但路径可能不是全局最优。w取1.2到1.5时,路径质量下降通常非常小,但搜索节点数能下降30%到50%。在游戏AI和实时导航里,这个权衡非常值得。
但要注意:w一旦大于1,A就不保证最优路径了。如果你在做物流调度、自动驾驶这种对路径有强需求的场景,建议保持w=1。想提速可以先从双向A优化到极致再考虑带权。
我在实际项目里的习惯是:任何新地图上第一次写A*,一定先用四方向加曼哈顿距离跑通一条最小路径,再把八方向、穿墙判断、权重调整这些特性一步步加进来。因为这样能把坐标约定、堆和回溯逻辑先跑对,后面加功能才不会把问题混在一起。如果你也有类似的寻路需求,不妨把上面的代码直接复制到本地,换一张自己的地图跑一跑。多数时候,算法的坑都不是出在搜索本身,而是出在地图建模和代价定义这些容易忽略的地方。