多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

A*寻路算法核心实现与工程实践:从网格建模到八方向扩展

A*寻路算法核心实现与工程实践:从网格建模到八方向扩展 写这篇东西的起因是最近在项目里要做一个网格地图的寻路模块。同事甩过来一堆参考代码要么只给了伪代码要么讲了一堆理论却没法直接跑。我干脆自己整理了一份传统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里最微妙也最容易被忽视的地方。很多人知道“H0时A退化成Dijkstra”但不知道H选错了会导致路径不是最优。我见过一个很典型的面试翻车场景地图允许八方向移动有人却用曼哈顿距离当启发函数。曼哈顿距离假设只能横竖走和斜向移动的真实代价不一致。在2x2地图上终点在(1,1)起点在(0,0)斜走一步真实代价约1.414但曼哈顿距离算出H2这就比真实剩余代价要大了。因为启发函数高估了剩余代价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一旦大于1A就不保证最优路径了。如果你在做物流调度、自动驾驶这种对路径有强需求的场景建议保持w1。想提速可以先从双向A优化到极致再考虑带权。我在实际项目里的习惯是任何新地图上第一次写A*一定先用四方向加曼哈顿距离跑通一条最小路径再把八方向、穿墙判断、权重调整这些特性一步步加进来。因为这样能把坐标约定、堆和回溯逻辑先跑对后面加功能才不会把问题混在一起。如果你也有类似的寻路需求不妨把上面的代码直接复制到本地换一张自己的地图跑一跑。多数时候算法的坑都不是出在搜索本身而是出在地图建模和代价定义这些容易忽略的地方。
返回列表