网格地图上找一条从起点到终点的路,这件事我在不同项目里反复做过很多次。第一次写 A算法 的时候,我以为它就是个"比 BFS 聪明一点的搜索";后来在一个模拟项目里被帧率按在地上摩擦,才真正理解 A算法 的设计哲学:它不是更快地遍历,而是有方向地少遍历。A算法 的核心价值在于,它把"已经走过的距离"和"到目标大概还有多远"这两件事合成一个打分,让搜索始终朝着有希望的方向推进。这篇文章适合三类人:刚接触寻路、想搞懂 A算法 为什么能保证最短路的初学者;写过一版能跑但跑不快的实现,想搞清楚瓶颈在哪的开发者;以及需要在机器人、游戏、导航类场景里选型的技术负责人。我会从原理拆到代码,再从代码拆到调优和踩坑,尽量把每一步"为什么这么做"讲清楚。
1. 先把 A* 看成"带导航的 Dijkstra"
1.1 一个被帧率教育过的场景
最早那版实现用的是 Dijkstra,思路很朴素:从起点往外一圈圈扩散,谁先碰到终点谁赢。在小地图上它跑得很好,一旦地图变成 400×400 的网格,加上障碍物,单次寻路要扩展几万个节点。当时的场景是几十个单位同时移动、每帧都要重算,结果就是主循环里最贵的一行永远是寻路。
换成 A算法 之后,扩展节点数直接掉了一个数量级。这件事让我意识到:Dijkstra 的问题不是算法本身不好,而是它"太公平"——它对所有方向一视同仁地扩展,哪怕目标就在东边,它也会先把西边的节点认真算完。A算法 做的事很简单,给每个待扩展节点加一个"离目标还有多远"的预估分,让搜索有个偏好。这个改动看起来很小,但本质上是把"无信息搜索"变成了"有信息搜索"。
所以理解 A*算法,第一步不是看代码,而是接受一个观念:搜索的效率来自信息,而不是来自更聪明的数据结构。数据结构只决定常数,信息决定数量级。
1.2 g、h、f 三件套到底各管什么
A*算法 里每个节点都有三个值,我用记账的方式理解它们:
- g(n):从起点走到 n 已经花掉的真实代价。这个值是确定的、可累加的,走一步就加一步的代价。类比一下,它是你这趟行程已经花掉的钱。
- h(n):从 n 到终点还需要的代价估计值。注意是估计,它不一定准,甚至可能不准得离谱,但只要方向对就有用。类比是打车软件在你上车前给出的"预计还要多少钱"。
- f(n) = g(n) + h(n):总账单的预估。搜索每次都挑 f 最小的节点扩展,意思就是"当前看起来总花费最小的路,先走下去看看"。
三个值里,g 是硬事实,h 是软猜测,f 是两者的加权合并。A*算法 的全部精妙之处,都压在 h 这个猜测上:猜得准,搜索几乎直线冲到终点;猜得离谱,性能崩盘或者结果出错。后面我会专门用一整节讲 h 怎么设计。
这里先埋一个关键点:h 只能"低估"不能"高估",高估会破坏最优性。原因在 1.4 节展开。
1.3 为什么不用 BFS,也不用纯贪心
选型这件事,我一般拿一张表对着看。下面这张是我自己常用的对照,代价列里的数字是抽象单位,具体到你的场景要重新测。
| 算法 | 出队依据 | 是否保证最短路 | 扩展节点量级 | 典型适用场景 |
|---|---|---|---|---|
| 广度优先搜索 | 入队顺序(层数) | 保证(等权图) | 很大,几乎铺满半径圆 | 无权图、状态空间极小 |
| Dijkstra | g 最小 | 保证 | 大,铺满等代价圆 | 单源多目标、需要全图代价 |
| 贪心最佳优先 | h 最小 | 不保证 | 很小,但容易钻死胡同 | 只要"能到"、不要求最优 |
| A* | f = g + h 最小 | 在 h 可采纳时保证 | 介于两者之间 | 单次点到点寻路、地图固定 |
这张表能解释一个常见误会:很多人以为 A算法 是"更快的 Dijkstra",其实它更像 Dijkstra 和贪心最佳优先的插值。当 h 恒等于 0,A算法 退化成 Dijkstra;当 h 远大于 g 的权重,A*算法 退化成贪心,速度快但路径可能绕远。这个连续性非常重要,因为它意味着你有一个可以拧的旋钮,而不是非此即彼的选择。
顺带说一个实际经验:如果你的地图是等权网格、目标只有一个、调用频率很高,别犹豫,直接上 A*算法;如果你的需求是"从仓库出发到任意卸货点,要一次算出到所有点的距离",那 Dijkstra 反而更划算,因为你一次搜索就得到了全部答案。
1.4 可采纳性与一致性:最优性的两道门槛
这两个词听起来学术,实际含义很朴素。
可采纳(admissible):对任意节点 n,h(n) 永远不超过从 n 到终点的真实最小代价。翻译成人话就是"预估不能吹牛"。只要 h 可采纳,A*算法 第一次把终点从优先队列里取出来时,拿到的路径一定是全局最优的。直觉上的解释是:如果存在一条更短的路,那条路上的某个节点 f 值会更小,它会先被扩展,所以终点不可能被更差的路先"抢"走。
一致性(consistent / 单调):对任意相邻节点 n 和 n',满足 h(n) ≤ cost(n, n') + h(n')。这个条件的实际意义是"预估值的下降速度不能超过实际移动的代价",等价于三角不等式。一致性比可采纳更强,它带来一个非常实用的性质:节点一旦被扩展(进入 closed 表),它的 g 值就不会再被改小,所以你可以放心地不再回头检查它。
工程上的取舍很清楚:
- 如果你的 h 一致,闭表可以永久关闭节点,代码简单、速度快。
- 如果你的 h 只是可采纳但不一致(少见,但构造得出来),节点可能需要"重开",也就是从 closed 表里拿出来重新放回 open 表。不做这一步,结果可能不是最优。
我在实际项目里几乎只用满足一致性的 h(距离度量天然满足三角不等式),所以闭表一直是"一进不出"。这条规则写进代码注释里,能省掉后来者很多困惑。
2. 启发函数怎么选:决定成败的那一行
2.1 三种距离度量,对应三种移动规则
h 的设计必须和移动规则严格匹配,这是最容易出错的地方。移动规则决定了一步能走哪几个方向、每步代价多少;h 必须"低估"这个真实代价。
| 移动规则 | 可选方向 | 单步代价 | 匹配的 h | 公式(dx、dy 为横纵格差绝对值) |
|---|---|---|---|---|
| 四邻域 | 上下左右 | 1 | 曼哈顿距离 | h = dx + dy |
| 八邻域,斜走与直走同价 | 八方向 | 1 | 切比雪夫距离 | h = max(dx, dy) |
| 八邻域,斜走代价 √2 | 八方向 | 1 或 1.414 | 对角距离(octile) | h = (dx + dy) + (1.414 - 2) × min(dx, dy) |
| 任意方向(连续空间) | 全向 | 欧氏距离 | 欧几里得距离 | h = √(dx² + dy²) |
对角距离那个公式第一次看会懵,拆开就明白了:假设 dx ≥ dy,那么最优走法是先斜着走 dy 步,再直着走 (dx - dy) 步,总代价是 1.414×dy + (dx - dy) = dx + dy×(1.414 - 1)。整理成统一形式就是 (dx + dy) + (1.414 - 2)×min(dx, dy)。它的几何意义是"允许斜着走的最短距离",比曼哈顿距离小、比欧几里得距离大,正好卡在真实代价上。
注意:八邻域地图里用曼哈顿距离当 h 是安全的但很松,因为它高估不了,只是保守;用欧几里得距离当 h 就不安全了,因为斜走的真实代价是 1.414,而欧氏距离给出的估计在纯斜走时是 1.414×步数,两者相等还算勉强,但混合走法下欧氏距离会大于真实可达到的代价,导致非最优路径。这个错误我在别人的代码里见过不止一次,症状是"路径看起来没毛病,但总比最优长一点",极难发现。
2.2 加权 A*:拿最优性换速度的旋钮
有时候你根本不关心是不是最短,只关心"看起来别太傻、而且要快"。这时候把 f 改成 g + w×h,w 取大于 1 的值。
w 的作用是把搜索往贪心方向推。w 越大,越倾向于往目标方向冲,扩展节点越少,但路径可能绕远。下面这组数据是我在一组随机生成的 400×400 网格(约 25% 障碍率)上压测出来的相对值,只用来展示趋势,你自己的地图必须重测:
| 权重 w | 相对扩展节点数 | 路径长度相对最优 | 适用场景 |
|---|---|---|---|
| 1.0 | 100(基准) | 100% | 需要严格最优 |
| 1.2 | 约 65 | 约 101% | 大多数实时场景的甜点 |
| 1.5 | 约 40 | 约 104% | 单位多、帧预算紧 |
| 2.0 | 约 25 | 约 110% | 只要大致方向对 |
| 5.0 | 约 12 | 明显变差 | 几乎等于贪心,慎用 |
经验上 w 在 1.1 到 1.3 之间是个很舒服的区间,肉眼几乎看不出路径变长,但扩展节点数掉得很明显。超过 1.5 之后,路径开始出现"明明可以直着走却绕了个弯"的观感问题,尤其是在有大块凹形障碍的地图上。
还有个小技巧:w 不必固定。可以在搜索初期用大权重快速逼近目标,接近目标后再把权重收回到 1.0 做一次局部修正。这种"两段式"做法实现成本不高,收益在开阔地图上很可观。
2.3 启发函数写错的四种典型症状
h 写错的表现往往不是崩溃,而是"看起来怪"。我把见过的症状列一下,方便对照:
- 路径比最优长一点点:h 高估了。常见原因是移动规则里有斜走但 h 用了欧氏距离,或者斜走代价设成 1 却用对角距离公式。
- 速度莫名其妙慢:h 低估太多,比如八邻域地图用了"0"当 h,等于退化成 Dijkstra。另一种可能是 h 加了太多安全余量,把它压得过低。
- 路径紧贴障碍物拐角:不是 h 的问题,是"禁止斜穿墙角"的逻辑没写,或者写了但判断条件反了。
- 同一张图每次跑出来的路径不一样:f 值相同节点的出队顺序不稳定。优先队列里存了可变对象、或者比较函数只比 f 不比 tie-breaker,都会导致这个现象。
第二种症状特别值得说。很多人为了"保险",把 h 乘个 0.9,觉得低估一点总没错。低估确实不会破坏最优性,但会实实在在地拖慢搜索。0.9 看起来只损失 10%,实际扩展节点可能多出三四成。如果单位换算导致 h 的单位和 g 不一致,那问题就不是 10% 的量级了,可能直接慢十倍。
2.4 tie-breaking:那些 f 值一样的节点怎么办
网格地图有个讨厌的特性:大量节点的 f 值完全相同。想象一片开阔地,从起点到终点有一整片"等代价带",这些节点的 f 值一模一样。优先队列怎么排它们,直接决定了搜索是"一条线冲过去"还是"把整片地铺满"。
最有效的做法是在 f 相同时,优先取 h 更小的节点。因为 h 小意味着离目标更近,直觉上更值得先探。实现上把堆的比较键改成(f, h)二元组就行,一行改动,实测扩展节点能少两三成。
另一种做法是给 h 加一个极小的扰动,比如h * 1.001,打破平局。这招在需要严格可复现的场景里要慎用,因为浮点扰动可能让不同运行之间的结果不一致,调试时会怀疑人生。
还有一种更彻底的做法是方向优先:给每个节点记录"父节点方向",在 f 和 h 都相同时,优先选和父节点同方向的邻居。这样路径会自然地被"拉直",观感好很多。代价是要多存一个方向字段,内存换观感,值不值看你。
3. 手写一版能跑的 A*:数据结构与完整实现
3.1 open 表为什么必须是优先队列
A*算法 每轮要做的事只有一件:从待处理集合里取出 f 最小的节点。这个操作决定了数据结构的选择。
- 普通数组 + 每次线性扫描:取最小 O(n),节点一多直接崩。只适合几十个节点的玩具场景。
- 二叉堆:取最小 O(log n),插入 O(log n),实现简单,绝大多数场景够用。Python 的 heapq、C++ 的 priority_queue 都是这个。
- 桶排序 / 基数堆:当地图代价是整数且范围很小(比如每步代价只能是 1 到 10),可以按 f 分桶,取最小近似 O(1)。f 范围大时不适用,因为桶的数量会爆炸。
- 配对堆 / 斐波那契堆:理论上有更好的 decrease-key 复杂度,但常数大、实现复杂。除非你的 profile 明确指向这里,否则别碰。
我的建议很直接:先用二叉堆把功能跑通,再谈优化。绝大多数项目的瓶颈不在堆,而在扩展的节点数量本身。
这里有个细节必须处理:二叉堆不支持"修改堆内元素的键值"。当某个节点以更小的 g 值再次入队时,你不能原地更新,只能再插一条新记录。旧记录还躺在堆里,等它被弹出来时,g 值已经过期了。这叫懒惰删除,处理方式是弹出时对比当前记录的 g 和节点表里的 g,不一致就丢弃。
提示:懒惰删除会让堆的体积膨胀,最坏情况下是节点数的若干倍。如果你的场景内存吃紧,可以给堆大小设个上限并做告警,或者改用支持 decrease-key 的堆实现。我在一个嵌入式场景里就因为堆无限增长吃过亏,最后是给每个节点加了"在堆中"标记才压住的。
3.2 一份可以直接跑的 Python 实现
下面这版是八邻域网格、斜走代价 √2、禁止斜穿墙角、满足一致性的对角距离启发。我把它写成可以独立运行的形式,输入是一个二维列表,0 表示可走,1 表示障碍。
import heapq import math # 八方向:上下左右 + 四个斜角 DIRS = [ (1, 0, 1.0), (-1, 0, 1.0), (0, 1, 1.0), (0, -1, 1.0), (1, 1, math.sqrt(2)), (1, -1, math.sqrt(2)), (-1, 1, math.sqrt(2)), (-1, -1, math.sqrt(2)), ] SQRT2 = math.sqrt(2) def octile(dx, dy): # 对角距离:允许斜走时的最短几何距离,天然满足三角不等式 return (dx + dy) + (SQRT2 - 2.0) * min(dx, dy) def astar(grid, start, goal): h_grid = len(grid) w_grid = len(grid[0]) sx, sy = start gx, gy = goal # 用一维数组代替 dict,索引 = y * 宽度 + x,省内存也更快 size = h_grid * w_grid g_score = [float('inf')] * size closed = bytearray(size) # 0 未扩展,1 已扩展 parent = [-1] * size # 记录前驱,用于回溯路径 def idx(x, y): return y * w_grid + x start_id = idx(sx, sy) goal_id = idx(gx, gy) g_score[start_id] = 0.0 # 堆元素:(f, h, id);h 作为第二键,用于平局时优先靠近目标 h0 = octile(abs(sx - gx), abs(sy - gy)) open_heap = [(h0, h0, start_id)] while open_heap: f, h, cur = heapq.heappop(open_heap) if cur == goal_id: # 回溯路径 path = [] node = cur while node != -1: path.append((node % w_grid, node // w_grid)) node = parent[node] path.reverse() return path, g_score[goal_id] if closed[cur]: continue closed[cur] = 1 cx, cy = cur % w_grid, cur // w_grid g_cur = g_score[cur] for dx, dy, step in DIRS: nx, ny = cx + dx, cy + dy if nx < 0 or ny < 0 or nx >= w_grid or ny >= h_grid: continue if grid[ny][nx] == 1: continue # 关键:斜走时禁止穿墙角,两个相邻正交格都必须可走 if dx != 0 and dy != 0: if grid[cy][nx] == 1 or grid[ny][cx] == 1: continue nid = idx(nx, ny) if closed[nid]: continue tentative_g = g_cur + step if tentative_g < g_score[nid]: g_score[nid] = tentative_g parent[nid] = cur hn = octile(abs(nx - gx), abs(ny - gy)) heapq.heappush(open_heap, (tentative_g + hn, hn, nid)) return None, float('inf') # 无解这段代码里有几个点值得单独拎出来讲。
第一,闭表判断放在弹出时而不是入队时。很多人习惯在入队前就标记"已访问",那样写会导致节点拿到一个次优的 g 值之后再也不会被更新,路径就不是最优的了。正确做法是入队时只更新 g,弹出时才判断是否已扩展。
第二,堆元素用(f, h, id)三元组。Python 比较元组是逐项比较,f 相同时自动比 h,h 还相同就比 id。id 作为最后兜底保证了不会有"不可比较对象"的报错,同时让结果可复现。
第三,斜走穿墙检查。如果只判断目标格是否可走,路径就会从两个障碍物的缝隙里斜穿过去,视觉上非常明显。加上grid[cy][nx]和grid[ny][cx]的双重检查,就能堵住这个漏洞。
第四,用一维数组代替字典。400×400 就是 16 万个节点,用 dict 存 g 值,每个键值对的内存开销在 Python 里是很可观的,而且哈希查找慢于数组下标。改成bytearray加list,内存和速度都会好一截。
3.3 内存和参数的粗算过程
做工程估算是个好习惯。拿 400×400 的网格举例:
- 节点总数 160000。
- g_score 用 Python 的 float list,每个 float 对象加引用大约几十字节,粗算 5 到 8 MB。
- closed 用 bytearray,160 KB,可以忽略。
- parent 用 int list,同样几 MB。
- open 堆在最坏情况下(懒惰删除堆积)可能达到节点数的几倍,每条记录是个三元组元组,开销不小。
合计下来几十 MB 的量级。如果是单个寻路实例,完全没问题;如果几十个单位各自维护一套这样的结构,就得上共享内存或者复用数组了。这也是为什么很多引擎里会把 g_score 换成一维的int32数组,代价乘以 1000 转成整数存储,用精度换内存和比较速度。
这里还有一个常被忽略的点:堆里的三元组会长期占用内存。如果搜索在超大图上跑几分钟,堆可能膨胀到几十万条记录。我一般会在压测时打印堆的峰值大小,这个数字上去了,说明要么 h 太松,要么地图结构不适合直接 A*。
3.4 用整数代价换掉浮点比较
浮点比较在寻路里是个隐形地雷。0.1 + 0.2 != 0.3这件事放到 g 值累加上,会导致本应相等的两条路径被判成不等,进而影响闭表判断和平局处理。
稳妥做法是把代价整数化:直走代价 10,斜走代价 14(√2 ≈ 1.414,取 14 误差 0.1%),h 也用同一套整数单位计算。这样所有比较都是整数比较,既快又不会出现浮点抖动。误差 0.1% 在实践中完全可以接受,只要 g 和 h 用的是同一套单位,最优性不受影响。
如果确实需要更高精度,可以用 1000 倍缩放(直走 1000,斜走 1414),代价范围大一点但仍在 int32 之内。
4. 从能跑到跑得快:工程优化清单
4.1 跳点搜索 JPS:专门欺负网格地图的优化
A*算法 在开阔区域会扩展出大量"无用"节点——这些节点的邻居里,除了来路方向,其他方向都已经被更优或等价的路径覆盖了。JPS(Jump Point Search)的思路就是:与其一格一格走,不如沿着一个方向"跳"到下一个有意义的位置,只在遇到障碍或强制转向时才停。
它的核心概念是强制邻居:当你沿着某方向前进时,如果侧后方出现了障碍,导致你必须在这里拐弯才能到达某些区域,这个位置就是一个跳点,必须保留。其余位置都可以被剪掉。
实测收益很夸张:在空旷度高的网格地图上,JPS 扩展的节点数常常只有普通 A*算法 的十分之一甚至更少。代价是实现复杂度高出一大截,而且强依赖"网格 + 均匀代价"这个前提。地图改成不规则代价(比如沼泽、道路有不同移动成本),JPS 就用不了了。
我的建议是:如果你的地图是规则网格、代价均匀、并且寻路是热点,值得投入时间做 JPS;如果地图本来就不规则,别折腾,把精力放到下面两条上。
4.2 导航网格与层次化:减少搜索空间本身
比"搜得更快"更有效的是"少搜点东西"。网格地图的节点数天然是格子数,而实际可行走区域的拓扑复杂度远低于格子数。导航网格(NavMesh)就是把可行走区域抽象成若干个凸多边形,在多边形层面寻路,节点数可能只有格子数的百分之一。
层次化路径(HPA*)是另一个思路:把地图切成若干区块,先在区块级别算一条粗路径,再在每个区块内部算细路径。这样搜索空间从"全图格子"降到"区块 + 局部格子",对超大地图效果显著。
这两条路的共同点是:改变问题规模,而不是优化常数。它们带来的收益通常远大于代码层面的微调,但前期建模成本也高。是否值得,取决于你的地图是否固定、寻路调用是否频繁。
4.3 双向搜索与增量重规划
双向 A算法 从起点和终点同时出发,两边各跑一个 A算法,相遇就合并。理论上能把搜索半径减半,实际收益取决于 h 的质量和相遇判定的效率。它有个坑:双向搜索的终止条件比单向复杂,不能简单地"第一次相遇就停",必须保证两侧的 f 值之和大于等于当前最优路径长度才能停,否则可能得到次优解。这一点不注意,双向版本反而更容易出错。
增量重规划解决的是另一个问题:地图基本不变,但每次只改动一点点(比如一个单位走过、一扇门打开)。这时候从头搜索很浪费,D* Lite 这类算法会复用上一次搜索的信息,只修改变动的部分。在单位持续移动、地图动态变化的场景里,这类算法的收益是数量级的。
4.4 Any-Angle:让路径不再是锯齿状
标准 A*算法 在八邻域网格上出来的路径是"台阶状"的,因为只允许八个方向。观感上不好看,实际行进距离也更长。
Theta* 的思路是放宽父节点的限制:不要求父节点必须是相邻格,只要从某个祖先节点到当前节点有直线视野(line of sight),就可以直接把父节点设成那个祖先。这样路径会被"拉直",变成任意角度。
实现上要做视线检查,常用的是 Bresenham 直线算法逐格判断中间有没有障碍。代价是每次检查都要遍历一串格子,计算量上升。我的经验是:在需要平滑路径展示的场景(比如单位移动动画),Theta* 值得上;在只需要路径长度做决策的场景,加个路径后处理(拉直 + 视线检查)就够了,不必改搜索本身。
5. 常见问题与排查技巧实录
5.1 一份能直接照着查的问题速查表
| 现象 | 最可能的原因 | 排查动作 |
|---|---|---|
| 路径明显不是最短,但看起来合理 | h 高估了,或浮点精度导致比较出错 | 把 h 强制设为 0 跑一次,对比长度;检查单位是否统一 |
| 速度极慢,扩展节点数爆炸 | h 太松或为 0;权重 w 设得过小;tie-breaking 缺失 | 打印扩展节点数曲线,看是否呈圆形扩散 |
| 返回无解,但图上明明有路 | 障碍判定把起点或终点包住了;斜走穿墙检查写反 | 单独测试起点、终点的邻居是否全被判定为障碍 |
| 路径斜穿两个障碍物的夹角 | 缺少斜走时的正交邻居检查 | 补上grid[cy][nx]和grid[ny][cx]的判断 |
| 每次运行结果不一样 | 平局处理依赖了不稳定顺序;遍历顺序受哈希影响 | 给堆元素加唯一 id 作为最后比较键 |
| 内存持续增长 / 崩溃 | 懒惰删除导致堆无限膨胀;容器没有复用 | 打印堆峰值大小,考虑加"在堆中"标记 |
| 路径紧贴墙走,观感差 | 代价没考虑贴墙惩罚;缺少平滑后处理 | 给靠近障碍的格子加一点额外代价(仅影响观感,不影响可行性) |
| 单位多时整体卡顿 | 每个单位独立全量搜索 | 考虑路径缓存、分层搜索、分帧错峰计算 |
5.2 几个我真实踩过的坑
第一个坑是"看起来对但不对"的路径。有一次测试反馈说路径比预期长,我盯着可视化看了半天觉得没问题。后来把 h 强制设成 0(退化成 Dijkstra)跑了一次,长度少了 8%。原因是我在八邻域地图上用了欧氏距离当 h,斜走时 h 会轻微高估,导致某些本应更优的路径被剪掉了。这个错误极其隐蔽,因为路径依然"能走通",只是不是最优。教训是:怀疑最优性时,永远用 h=0 做一次基准对比。
第二个坑是斜穿墙角。早期版本只判断了目标格是否可走,结果单位从两个对角障碍之间"穿"了过去,视觉上像瞬移。补上双正交检查之后问题解决,但紧接着又出现了新问题:某些本该通的窄通道被判成了不通,原因是检查条件写成了"或"而不是"与"。这种布尔条件反了的 bug,用单元测试两分钟能测出来,靠肉眼看可视化要半小时。
第三个坑是堆里的过期数据。有段时间发现寻路结果偶尔比预期差一截,日志里看不出异常。最后在弹出节点时加了一行断言——比较堆里的 f 和重新计算的 f 是否一致——才发现有大量过期条目在被处理。虽然代码里有closed判断兜住了正确性,但过期条目拖慢了堆操作。加上"弹出时比对 g 值"的懒惰删除判断之后,性能回来了。
第四个坑是起点和终点重合。这在单元测试里出现得莫名其妙:返回了空路径,调用方以为是失败。后来在函数开头加了一行特判,start == goal直接返回单点路径。这种边界情况在真实调用里出现频率不低,尤其是点击同一个格子的时候。
第五个坑是地图坐标系搞反。网格用grid[y][x]存储,但外部传进来的是(x, y)。一次重构里我把两处搞混了,结果是路径整体沿对角线翻转,看起来"能走但很怪"。这个 bug 找了一下午,最后是靠打印坐标对照表才定位的。现在我的习惯是:所有对外接口用 (x, y),所有内部数组访问用 [y][x],转换只在一个函数里做,其他地方绝不手工拼下标。
5.3 调试手段:埋点和可视化比打印日志有用
纯靠print调寻路很痛苦,因为信息量太大。我一般会埋三类统计:
- 扩展节点数:最重要的指标。它直接反映 h 的质量。同一个起点终点,扩展节点数突然翻倍,八成是地图或 h 变了。
- open 表峰值大小:反映内存压力,也间接反映 h 的松紧程度。
- 单次搜索耗时:要分位数,不要只看平均值。寻路的耗时分布很偏,P99 往往是平均值的十几倍。
可视化方面,把open(待扩展)、closed(已扩展)和最终路径用不同颜色画出来,一眼就能看出问题:健康的 A*算法 扩展区域应该是"从起点向终点逐渐收窄的水滴形";如果扩展区域是个完整的圆,说明 h 没起作用;如果扩展区域贴着障碍物乱窜,说明代价函数有问题。
我在项目里留了一个开关,按下去就把每一次搜索的 open/closed 快照存成图片序列。虽然占空间,但排查"某条特定路径为什么不对"时,翻几帧对比比读一整天代码有效得多。
5.4 关于"要不要自己写"的一点判断
市面上现成的寻路库不少,很多引擎也内置了。要不要自己写一版,我的判断标准是三条:
- 需求简单(规则网格、静态障碍、单目标),用现成的,别造轮子。
- 需求有特殊约束(不规则代价、动态地图、内存受限、需要确定性可复现),自己写更省事,因为改别人的库往往比自己写还费劲。
- 需要深度调优(要压到毫秒级、要支持几万个单位),必须自己写,因为你得掌控每一个内存分配。
自己写的时候,我强烈建议先写一个"绝对正确但很慢"的版本当基准,后面所有优化都拿它做对照。这个基准版本就是你之后所有怀疑的裁判。
最后分享一个我在实际项目里养成的小习惯:给 A*算法 的每次搜索都记录一份"搜索指纹"——起点、终点、扩展节点数、路径长度、耗时。这些数据攒下来,任何一次性能回归都能在一张表里定位到是哪天哪个改动引入的。寻路这种依赖地图数据的算法,性能波动特别容易被误判成"机器慢",有了历史数据就不会瞎猜。
后来我把这套 A算法 的实现在几个不同场景里复用了一遍,最大的感受是:算法本身早就定型了,真正的功夫全在数据结构的选型、启发函数的匹配、边界条件的处理上。把这三件事做扎实,一个几百行的 A算法 能稳定跑很多年;这三件事糊弄过去,再花哨的优化也救不回来。