ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

A*算法寻路实战:启发函数、实现与工程调优

A*算法寻路实战:启发函数、实现与工程调优

网格地图上找一条从起点到终点的路,这件事我在不同项目里反复做过很多次。第一次写 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,也不用纯贪心

选型这件事,我一般拿一张表对着看。下面这张是我自己常用的对照,代价列里的数字是抽象单位,具体到你的场景要重新测。

算法出队依据是否保证最短路扩展节点量级典型适用场景
广度优先搜索入队顺序(层数)保证(等权图)很大,几乎铺满半径圆无权图、状态空间极小
Dijkstrag 最小保证大,铺满等代价圆单源多目标、需要全图代价
贪心最佳优先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.0100(基准)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算法 能稳定跑很多年;这三件事糊弄过去,再花哨的优化也救不回来。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进