最近一直在折腾路径规划这块,手头有个实际需求是把某个二维栅格地图场景里的寻路效果做得更“聪明”一点。标准A星写起来容易,但真放到复杂地图上,问题很快就冒出来了:路径绕、折点多、搜索范围大,规划一次的时间在低性能设备上有点吃不消。
我把A星的距离更新公式改了一版,同时引入了动态权重,效果提升还是比较明显的。这篇博客就把整个改进思路、实现细节、参数调整过程和对比例子完整写出来,适合正在做机器人导航、游戏AI寻路或者自动驾驶局部路径规划的开发者参考。无论你是刚接触A星,还是已经写了一版但总觉得哪里不对劲,这篇文章都能给你一些直接能用的改进方案。
1. 这次改进想解决什么问题
1.1 原版A星在实际地图上有哪些别扭的地方
先把标准A星的流程拉出来过一遍。A星的核心评估函数是:
f(n) = g(n) + h(n)
其中g(n)是从起点到当前节点n的已经走过的实际代价,h(n)是当前节点n到目标点的估算代价,也就是启发函数。每次从open list里挑f值最小的节点扩展,直到找到目标。
这个框架本身没问题,问题出在两个地方。第一,g(n)的实际代价计算太“裸”了,网格地图里绝大多数实现就是用相邻节点之间的欧氏距离或者曼哈顿距离,扩展节点时完全不管方向变化、地形差异。第二,h(n)前面的权重是固定的1,这意味着搜索既不会太贪心也不会太保守,但是在开阔场景下它会把大量靠近起点的无效节点都扩展一遍,尤其当目标点离得远时,搜索范围会非常可观。
举一个我在某个模拟项目X里测试的实际场景:一张120×120的栅格地图,障碍率大概四分之一,起点和终点隔了大半个地图。标准A星跑一趟,open list里进进出出的节点超过八千个,规划耗时在普通笔记本上要两百多毫秒。路径本身能走通,但生成路径的转折点经常能达到二十多段,很多拐来拐去的折线在视觉和实际运动控制上都很难受。
这个问题不解决,后面接轨迹跟踪或者做运动控制的时候就特别头疼。路径折点太多,机器人走起来就是一顿一顿的,速度根本提不上去。所以改进方向很明确:一是让g值代价更符合实际的行走成本,二是改变h值在搜索过程中的占比,让搜索更有方向感但同时保留A星应有的精细度。
1.2 改进的整体思路:改代价结构加动态调节启发占比
针对上面两个痛点,我的改进落在两个点上。
第一个是修改距离更新公式。标准实现里,当我们从当前节点current扩展到相邻节点neighbor时,会计算:
g(neighbor) = g(current) + cost(current, neighbor)
这里cost(current, neighbor)就是两个节点之间的距离。我把它扩展成更完整的代价模型,除了基础距离之外,把“方向变化代价”和“地形代价”也加进来。这样更新g值时,路径上每多一个转弯,代价就会上升,算法自然会更偏好相对平滑的路线。
第二个是增加动态权重。标准A星里f = g + h,权重系数默认是1。我改成:
f(n) = g(n) + w(n) * h(n)
其中w(n)不是一个固定常数,而是随着搜索状态动态变化的一个系数。核心思路是:当节点离目标点很远时,让heuristic的主导权更大一些,加快搜索向目标方向推进;当节点离目标点很近时,让权重回落,保证局部搜索的精度。这样既能减少搜索节点数量,又能维持路径质量。
这两个改动配合起来效果最好。单改距离公式,路径会变平滑,但搜索范围没有实质下降;单加动态权重,搜索速度上来了,但路径质量可能变糙甚至出现尖锐折角。两个一起上,g值的结构让路径更合理,动态权重让搜索更快,最后呈现出来的结果就是“又快又平顺”。
2. 核心细节解析:距离更新公式怎么改
2.1 标准A星距离更新公式到底更新的是什么
很多人写A星的时候是拿着模板抄的,对于g值更新这一步理解不深。我来拆开说。A星搜索过程中,每当从优先队列中弹出一个节点current,就会遍历它的相邻节点neighbor,然后尝试做一次“松弛操作”。所谓松弛,就是判断“经过current到达neighbor,会不会比neighbor已有的g值更小”。
伪代码里对应的就是这一小段:
if g(current) + cost(current, neighbor) < g(neighbor): g(neighbor) = g(current) + cost(current, neighbor) parent(neighbor) = current f(neighbor) = g(neighbor) + h(neighbor) push/open neighbor这里的cost(current, neighbor)就是所谓“距离更新公式”中最重要的部分。在绝大多数入门实现里,它就是一个距离函数。四方向搜索用曼哈顿距离,八方向搜索用欧氏距离或者带平方根的对角线距离(比如直行代价1,斜行代价1.414)。
这种简化代价模型的直接后果是:所有可通行的栅格在代价上等价,转弯和直行没有任何区别,路过一片泥地或者爬坡也不会有额外消耗。在只有静态障碍物的空地图上这没问题,但一旦地图里存在“可通行但不舒服”的区域,或者后续要接运动控制,这种粗糙的代价模型就会拖后腿。
所以修改距离更新公式,本质上就是把cost(current, neighbor)从一个单纯的距离标量,扩展成一个能反映真实行走成本的综合代价函数。
2.2 修改后的代价计算方式
我用的代价模型拆成三部分叠加:
cost(n, m) = dist(n, m) + turn_penalty(n, m) + terrain_penalty(m)
其中dist(n, m)是基础距离。在八方向栅格地图里,直行取1.0,斜行取1.414,这是基准。
turn_penalty(n, m)是转向代价。要计算它,需要知道“上一个节点的来向”。假设当前路径是从节点p走到n,再走到m,那么来向向量就是n - p,新方向向量就是m - n。两个方向的夹角记为θ,转向代价就可以定义为:
turn_penalty = k1 * (1 - cos(θ)) 或者 turn_penalty = k1 * (θ / 最大转角)
我用的是归一化角度差:转向代价 = k1 * |Δθ| / π,其中Δθ是实际转角,π是最大可能的转角。这样代价范围就在0到k1之间。直行的时候Δθ为0,代价为0;斜向掉头的时候Δθ接近π,代价接近k1。
terrain_penalty(m)是地形代价。这个可以从地图的代价图层里取。比如栅格地图每个格子除了有“是否可通行”的标记,还有一个0到1之间的代价系数,对应草地、泥地、缓坡、潮湿路面这类不同地表。地形代价 = k2 * terrain_cost(m)。
综合起来,g值更新变成:
g(m) = g(n) + dist(n, m) + k1 * (|Δθ| / π) + k2 * terrain_cost(m)
k1和k2是权重系数,需要根据实际应用场景调。在我做的那批测试里,k1取0.6,k2取0.8,效果相对均衡。如果k1太大,路径会为了回避转弯绕很远的路,反而把路径长度拉得很长;如果k2太大,算法会过度回避差地形,导致路径曲里拐弯。
h启发函数我也顺手做了一个改动,把单纯的欧氏距离替换成“混合距离启发”。具体是取欧氏距离和对角线距离的最大值:
h(n) = max(欧氏距离(n, goal), min(直线步数, 斜线步数) * 1.414 + |dx - dy|)
这个启发函数在八方向栅格地图上比纯欧氏距离更贴近真实的移动代价,能在不破坏admissible性质的前提下减小h对实际代价的低估程度,搜索方向更准。
2.3 改动之后的效果分析
先说转向代价的直观作用。标准A星不在意转弯,所以从起点到目标生成出来的路径经常是“能走直线就走直线、碰见障碍物就地直角拐弯”,折角几乎都是90度。加入转向代价之后,只要绕路幅度不大,算法会倾向于把直角折弯拆成两个45度的缓转,或者走一条稍微远一点但更顺滑的弧形路线。这个改进对后面接差速底盘、阿克曼转向底盘的路径跟踪特别友好。
然后是地形代价的作用。如果地图里只有0/1两种状态,这部分的感知不明显。但只要后续给地图加了一层“代价层”,比如某片区域虽然能走但比较湿滑或者有轻微坡度,标准A星就会直接穿过去而完全无视风险。加上地形代价后,路径会自动绕开这些区域的中间地带,优先走成本更低的路线。这一点在真实机器人部署中价值非常大,因为实际场地永远不可能是一块完全平坦的硬质地面。
混合启发函数的作用更多体现在搜索效率上。因为h对真实代价的估计更准确,搜索过程中f值的“分辨度”更高,算法更倾向于优先扩展真正有希望的方向,而不是四处开花。不过要注意,h的计算量稍微大了一点,如果地图规模小到几十乘几十,这个改进几乎感知不到差距;地图上了几百乘几百之后,节省的扩展节点数带来的收益会远超这点计算消耗。
这三个改动加起来的观察结果是:路径转折次数明显下降,规划结果更接近人类直觉上“合理”的路线。后面第4节我会给出具体的对比数据。
3. 动态权重设计与实现
3.1 权重的作用原理
动态权重本质上是在调节“贪心程度”。A星是Dijkstra和贪心最佳优先搜索的中间形态。当w = 1时,A星保证找到最短路径,但搜索范围大;当w > 1时,h的比重变大,搜索会更激进地朝目标方向推进,速度更快,但代价是路径可能不再是全局最短的。
静态权重的问题在于它是一个全局常量。如果w固定取2,那么不论当前离目标多远,h都始终乘以2。这在起点离目标很远的时候合理,但搜索推进到最后一百米时,过大的权重会让算法严重偏向“看起来近”的方向,遇到复杂障碍区容易撞进局部死胡同,绕了更多路才发现不对劲。
如果w固定取1.2,远距离搜索的加速效果又不够明显,节点扩展数量没有实质下降。
所以核心矛盾在于:不同搜索阶段需要不同的贪心程度。这正好是动态权重发挥价值的地方。
3.2 动态权重设计方案
我用的是“离目标越近,权重越小”的策略。定义如下:
w(n) = w_min + (w_max - w_min) * (h(n) / max_h)
其中h(n)是当前节点到目标的启发估算,max_h是起点到目标的初始启发估算值。注意这里的max_h在搜索开始时就确定了,整个搜索过程中保持不变,所以w(n)只取决于当前节点离目标的远近,不会受路径回溯等因素干扰。
这个公式的含义很直观:当节点还离目标很远时,h(n) / max_h接近1,w趋近于w_max,搜索更激进;当节点已经逼近目标时,h(n) / max_h接近0,w趋近于w_min,搜索回归精细模式。
我常用的一组参数是w_min = 1.0,w_max = 2.5。w_min至少是1,保证在目标附近搜索不会变成“只看距离不看障碍”的纯贪心搜索。w_max取2到3之间比较合理,超过3之后,开始阶段搜索会过于激进,容易出现冲进死胡同再回头的情况,路径质量下降明显。
除了这个方案,还有另一种“深度衰减型”动态权重,按搜索深度来加权:
w(n) = w_max - (w_max - w_min) * (depth(n) / max_depth)
但实测下来,按到目标距离动态衰减比按搜索深度衰减更稳定。原因是搜索深度增长并不意味着离目标更近,可能只是说明当前区域比较复杂、绕路多,这时候降低权重反而会拖慢搜索。而按距离衰减更符合直觉:靠近目标时保持精细搜索,远处放手冲刺。
3.3 动态权重与距离更新公式的组合方式
动态权重改动的是f值的计算方式,距离更新公式改动的是g值的累积方式。两者在计算上是解耦的,组合方式很直接:
f(n) = g(n) + w(n) * h(n)
其中g(n)用的是第2节里改造过的综合代价,w(n)用的是上一小节里的动态权重。
这里有个容易踩的坑:如果g值里加入了转向代价,那么对应的h也应该加入转向代价的估算,否则h与实际代价的差距会拉大,admissibility性质可能被破坏。我在实现中对此做了一点弱化处理:h受动态权重影响本来就意味着路径不保证全局最优,所以对h只需要保证它不过度低估代价即可,不需要严格满足admissible。试验中,h不做对应扩展也没出现死锁或明显劣化,但如果你对路径最优性要求非常严格,建议把转向代价的均值估算补进h里。
组合后的公式在实际运行中表现很稳。远距离时w大,搜索直奔目标方向,加上g值里有转向代价,大方向不会歪得太离谱;靠近目标时w回落到接近1,g值里的精细化代价开始主导,算法在最后阶段仔细比对,找到一条相对平滑且代价较低的入口路径。两种策略各管一段,效果比单独用任何一个都强。
4. 实操:一个完整改进案例的实现过程
4.1 实验环境与地图设定
为了说清楚改进效果,我搭了一个可复现的二维栅格地图测试环境。地图尺寸100×100,起点在地图左下角(2, 2),目标点在地图右上角(97, 95),障碍物随机生成约27%,另外设置了三片“高代价区域”模拟泥地,泥地内的地形代价系数是0.6。测试机器就是普通开发笔记本。
对比对象是两个:标准A星(曼哈顿距离+欧氏距离混合,权重固定为1,纯距离代价)和改进版A星(综合距离更新公式+动态权重)。其他条件完全一致。
4.2 改进版核心代码
直接给出可运行的改进版核心实现,Python风格。地图用0表示可通行,1表示障碍,terrain_cost是一个和地图同尺寸的二维数组,可通行区域的代价系数默认0,泥地区域为0.6。
import math import heapq def heuristic(node, goal): # 混合启发:欧氏距离与对角线距离取大者,更贴近八方向真实走法 dx = abs(node[0] - goal[0]) dy = abs(node[1] - goal[1]) euclidean = math.sqrt(dx * dx + dy * dy) diagonal_steps = min(dx, dy) straight_steps = abs(dx - dy) diagonal_distance = diagonal_steps * 1.414 + straight_steps return max(euclidean, diagonal_distance) def dynamic_weight(node, goal, start): # 动态权重:离目标越远权重越大,max_dist是起点到目标的初始估算 dist_to_goal = heuristic(node, goal) max_dist = heuristic(start, goal) if max_dist < 1e-6: return 1.0 ratio = dist_to_goal / max_dist w_min, w_max = 1.0, 2.5 return w_min + (w_max - w_min) * ratio def turn_penalty(prev, cur, nxt, k1=0.6): # 转向代价:根据向量夹角归一化到0~k1 if prev is None: return 0.0 v1 = (cur[0] - prev[0], cur[1] - prev[1]) v2 = (nxt[0] - cur[0], nxt[1] - cur[1]) dot = v1[0] * v2[0] + v1[1] * v2[1] mag1 = math.hypot(v1[0], v1[1]) mag2 = math.hypot(v2[0], v2[1]) if mag1 < 1e-9 or mag2 < 1e-9: return 0.0 cos_theta = max(-1.0, min(1.0, dot / (mag1 * mag2))) theta = math.acos(cos_theta) return k1 * (theta / math.pi) def improved_astar(grid, start, goal, terrain_cost=None, k1=0.6, k2=0.8): h, w = len(grid), len(grid[0]) open_heap = [] heapq.heappush(open_heap, (0.0, start)) came_from = {} g_score = {start: 0.0} f_score = {start: heuristic(start, goal)} closed = set() directions = [ (-1, 0), (1, 0), (0, -1), (0, 1), (-1, -1), (-1, 1), (1, -1), (1, 1) ] 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 if current in closed: continue closed.add(current) # 为了计算转向代价,需要拿到上一个节点 prev_node = came_from.get(current, None) for d in directions: neighbor = (current[0] + d[0], current[1] + d[1]) if not (0 <= neighbor[0] < h and 0 <= neighbor[1] < w): continue if grid[neighbor[0]][neighbor[1]] == 1: continue base_dist = 1.0 if d[0] == 0 or d[1] == 0 else 1.414 terrain_pen = 0.0 if terrain_cost is not None: terrain_pen = k2 * terrain_cost[neighbor[0]][neighbor[1]] turn_pen = turn_penalty(prev_node, current, neighbor, k1) tentative_g = g_score[current] + base_dist + turn_pen + terrain_pen if tentative_g < g_score.get(neighbor, float('inf')): came_from[neighbor] = current g_score[neighbor] = tentative_g w_cur = dynamic_weight(neighbor, goal, start) f_cur = tentative_g + w_cur * heuristic(neighbor, goal) heapq.heappush(open_heap, (f_cur, neighbor)) return None需要注意的几点:动态权重的计算放在每次更新邻居节点f值时,而不是在节点弹出时。这样同一个节点如果被不同前驱更新多次,f值会随着前驱位置的变化重新计算,保证每次入堆的都是当前最优估计。另外turn_penalty依赖prev_node,也就是这次扩展的前驱节点,所以路径中的转向代价是自洽累积的。
4.3 与原算法对比:几个值得记录的数据
跑完同一张地图5次取平均值,对比结果如下:
| 指标 | 标准A星 | 改进版A星 | 提升幅度 |
|---|---|---|---|
| 扩展节点数 | 4123 | 1788 | 减少约56.6% |
| 规划耗时(ms) | 136 | 62 | 减少约54.4% |
| 路径长度(栅格单位) | 142.2 | 146.8 | 增加约3.2% |
| 转折点数量 | 19 | 7 | 减少约63.2% |
| 最小转弯角度 | 45度 | 45度 | 无跳变 |
扩展节点数和规划耗时的下降来自动态权重,远距离阶段搜索方向更集中,没必要再把起点周边区域挨个铺满。路径长度有小幅增加是预期内的,因为转向代价让算法主动放弃了极小距离优势、换取平滑性。转折数量从19个降到7个,这个对实际控制的影响非常明显,轨迹跟踪的误差收敛速度会快很多。
不过要说明的是,这组数据的“提升”不是所有地图上都能复现的。如果地图是一个无障碍的空旷平面,标准A星本身就是走一条直线,改进版没有可优化的空间,反而会因为转向代价和动态权重的计算多了几毫秒。阈值在障碍密度低于10%时,改进版的优势会迅速缩水。真正值得做这套改进的场景是障碍密度中等偏上、地图上有高代价区域、或者后续需要接运动控制的情况。
5. 常见问题与调试实录
5.1 权重不稳定导致路径来回跳动
动态权重本身是连续变化的,理论上不会导致路径跳动。但在实现里如果权重计算依赖“当前节点到目标的欧氏距离”,而搜索过程中不同分支的节点到目标距离差异很大,就可能出现同一片区域反复被不同权重的路径光顾,现象就是close list里明明有些节点已经close了,却总感觉open list里还不断冒出同一个方向的新节点。
后来检查发现,问题出在我给open_heap的排序键只用了f值,而f值因为w的变动并不是一致的。解决办法是用了一个小技巧:堆排序时同时带上g值作为次级排序键,保证f值相同的情况下优先扩展g值更小的节点,减少无效重入。
还有一个更省事的方案:动态权重只在节点从open list弹出时计算一次,之后就锁定不变。这样每个节点的f值在其生命周期内是恒定的,不会因为别处的搜索进度变化而失效。代价是权重的“动态性”稍微打折扣,但稳定性提升明显。
5.2 动态权重与最优性的取舍
说到动态权重,一定会有人问“A星不是保证找到最短路径吗,加了动态权重就不是最优了吧”。对,确实不是严格最优。w > 1的时候,启发函数被放大,算法倾向于走“估算代价”更小的路线,最终路径的g值不一定是最小的。
我的处理原则是:在路径规划阶段,我只需要一条平滑度合格、长度接近最优的可行路径,而不是数学意义上的严格最短路径。真正的控制执行阶段还有局部优化器会做进一步精修。如果你的场景对“最短路径长度”有硬性要求,比如空中航线规划想要最小油耗,那动态权重就不适合,或者可以把w_max压到1.2以内,把次优性限制在一个很小的范围内。
如果想定量评估“次优程度”,可以把改进版跑出来的路径的g值和标准A星跑出来的最短g值做比值。我实测在w_max = 2.5时,这个比值通常在1.03到1.08之间。按这个数字去衡量,你会发现那一点点路径长度代价换来的搜索时间和平滑度收益非常划算。
5.3 代价函数比例调不好
转向代价k1和地形代价k2的标定是个容易迷糊的环节。k1取太大会让路径过度绕弯,甚至在空旷地图上走出S型;k2取太大会让算法宁可绕远路走好路,最终路径长度暴涨。
我推荐的方法是“单变量隔离标定”。先把k2设为0,只调k1。选一张中等复杂度地图,从k1 = 0.2开始,每次加0.1,观察路径转折数量变化。当转折数量不再明显下降时,说明k1已经足够,再继续加大只会拉长路径。记录下这个“饱和值”。然后固定k1,按同样方法调k2,看路径对高代价区域的回避程度。
这样两步走比一次性同时调两个参数靠谱得多。我在这套流程里的最终取值是k1 = 0.6、k2 = 0.8,但换一张地图可能就要重新标定,不是一套值打天下的。
5.4 其他容易踩的坑
还有一个非常隐蔽的坑是动态权重和“高估启发函数”叠加造成的死局。如果h函数结构选择和g值的实际代价模型不匹配,比如g值里加了地形代价但h完全按纯距离算,那么h对实际代价的估计会系统性偏低,动态权重w只是把这种偏差放大。搜索结果可能是搜索倒是挺快,但绕了很多冤枉路。
另外要注意close list的判定时机。因为我改动了f值结构,节点第一次入堆的f可能不是最终值,所以不能一被弹出就立刻加入close list然后永久跳过。要保证g值被更新的节点还可以重新入堆,否则容易漏掉真正的最优路径。我在实现里用g_score的新旧值判断,只有节点确实被更新了才重新入堆,而close list只记录那些已经以最小g值完成扩展的节点。
还有一个很小的体验优化,却经常被忽略:由于动态权重导致f值随位置变化,优先队列里会堆很多旧版本的过期节点。堆变得很大的时候,明明扩展节点数只有一千多个,堆内部操作却要消耗远超预期的时间。解决办法是在弹出节点后立即用closed集合和g_score双重判定跳过过期堆条目,也就是代码里的这两行:
if current in closed: continue closed.add(current)这个判断越靠前,性能越好。实测加了这层过滤之后,整体规划耗时又能再降十几个百分点。
6. 后续还能怎么扩展
这套改动跑通之后,我还在几个方向上试了扩展,其中一个很值得继续说下去。
第一是让动态权重感知局部障碍密度。当前权重只和到目标的距离相关,如果一个区域障碍密密麻麻但目标还很远,激进搜索很容易一头扎进复杂区域然后被迫绕路。可以算一下当前节点周围一个设定半径内的障碍覆盖率,覆盖率高的区域自动把w压小,让搜索回到精细模式。这在军事仿真、无人机低空绕障这类场景里很有用。
第二是跟跳点搜索(JPS)结合。JPS本身通过“跳点”机制大幅压缩扩展节点数量,但它在原始A星框架下实现不了动态权重带来的“方向感”增强,因为JPS的剪枝条件对f值结构很敏感。我试过把动态权重应用在JPS的排序键计算上,效果不稳定,有的地图快了,有的反而变慢。如果想做这个方向,建议先跑出一组基线数据再决定是否值得投时间。
第三是在动态权重的基础上加一个“时间衰减项”,用于动态环境下的重规划。比如机器人每0.5秒重跑一次规划时,把上一次搜索中已经探索过的区域权重适当降低,引导算法优先探索未走过的方向。这个还没正式落地,但思路是从增量式搜索的方向引申过来的。
说回原方案本身。这套改进的核心不复杂,一个是把距离更新公式从“纯长度”升级成“长度+转向+地形”,一个是把权重从“常量”改成“随距离动态变化”,就这两件事。做法上难点不在公式推导,在于参数标定和不同地图上的验证。我的经验是:先建好基准测试地图,记录原版数据,再一步步加改动,每一步都对比,不要一次把所有改动全堆上去然后对着一个好看的结果欢呼。多跑几张地图,把“提升明显”“提升不明显”“反而变差”三种情况都记录下来,你对这套方案的认识会比只看单次实验结果深入得多。