☰
改进A*算法:双向搜索与动态加权在移动机器人路径规划中的应用
2026/10/4 4:53:42 网站建设 项目流程

前段时间在调一台差速驱动机器人的自主导航,地图尺寸一拉到200×200栅格以上,传统A算法就开始露怯:单次机器人路径规划动辄几十毫秒,规划出来的路径还全是直角弯,机器人走到每个拐点都得先停下来原地方向调整,速度一快里程计误差就开始累积。后来我花了大约两周时间,对整套A算法做了系统性改造——双向搜索、动态加权、关键点提取加B样条平滑,三个改动叠加下来,规划耗时压缩将近一半,路径质量肉眼可见地提升。所有代码都是我用Python从零重写并跑通的,方便快速迭代和后续移植。这篇文章把这一轮的完整思路、Python实现和踩坑记录一次讲清楚,适合正在做机器人路径规划、或者刚入门想理解A*算法工程化改进的同学。

1. 标准A*算法卡在哪三个环节

在聊改进之前,得先把标准A为什么“慢”和“糙”掰扯清楚。很多刚入门的同学以为A慢是因为地图大、循环多,实际上真正的问题出在三个结构性短板上,不解决这三个根源,换什么语言、加什么优化技巧都治标不治本。

1.1 启发函数与搜索空间的膨胀

A*算法核心就一条公式:f(n) = g(n) + h(n)。g(n)是起点到当前节点的实际代价值,h(n)是从当前节点到目标点的启发式估计代价。可以这样理解:g是已经走过的路程,h是“照着直线距离还剩多远”的预估,f就是当前这条候选路线的总预算。

启发函数的选择直接决定了搜索效率。这里有个关键性质:只有h(n)小于等于真实代价时,A才保证能找到最优路径。但问题在于,h(n)设得越低,算法需要扩展的节点就越多。极端情况下h(n)=0,A就退化成了Dijkstra,会把整个地图翻个底朝天。

实际工程里常用的启发函数有三类,表现差距很大:

启发函数计算公式适用移动方式扩展节点量
曼哈顿距离|dx| + |dy|四邻域移动偏多
欧氏距离sqrt(dx² + dy²)八邻域移动中等
切比雪夫距离max(|dx|, |dy|)八邻域且对角代价相等更少

如果你的搜索允许八邻域移动,却用了曼哈顿距离,那h值会明显小于实际可行的最短路径,搜索就会像摊煎饼一样从起点向四周不断铺开,扩展出大量和最终路径无关的节点。我实测过一个500×500的空白地图,起点在左下角、目标在右上角,标准A*(欧氏启发)扩展的节点数能轻松破万,其中大部分节点根本不在最优路径附近。搜索空间膨胀,这是A*效率低下的第一根源。

1.2 栅格路径天然不平滑的本质原因

栅格地图上的路径本质是一串离散的栅格中心点连线,相邻节点的移动方向只有水平、垂直或者45°斜线这几种。这意味着路径上的每个拐角都被限制在45°的整数倍附近,绕远路不说,急转弯更是家常便饭。

真实移动机器人有运动学约束。差速驱动机器人的运动轨迹是一段段圆弧,不可能瞬间切换方向;阿克曼底盘的机器人有最小转弯半径,直角弯根本拐不过去;全向轮虽然能横移,但也受加速度极限约束。我在第一版测试里就吃过亏:A*规划出来的路径看着很规整,机器人一跑起来,到每个拐点都得先把速度降到接近零,再原地旋转对准下一个栅格方向,速度稍快轮子就开始打滑,里程计漂移很快,后面定位精度全部被拖垮。

更隐蔽的一点是,栅格路径的“几何可达性”和“运动可行性”是两回事。规划器只保证路径点落在可通行栅格里,完全不考虑速度、加速度、向心力和跟踪偏差。所以很多人把A*路径直接丢给PID跟踪器,结果发现跟踪误差大得离谱,还以为是控制器参数没调好,其实是路径本身就没有运动学层面的可行性。

1.3 参数敏感性与动态环境的天然冲突

标准A*看起来只有一个超参数,就是启发式权重ε,f=g+εh。ε大于1时,算法会更激进地往目标方向搜索,速度变快,但路径可能偏离最优解甚至贴障碍物边缘走。这里有个尴尬的矛盾:在稀疏地图上,ε取1.5效果很好;但同样的权重放到密集障碍地图里,路径质量会明显劣化。静态权重没法适应环境复杂度变化。

动态场景里这个问题更突出。机器人导航过程中障碍物会变化,路径需要高频重规划,比如每200毫秒跑一次。如果单次A规划要几十毫秒,重规划频率根本提不起来,机器人就只能“走一步看一步”,避障性能大打折扣。标准A在这种实时性压力下,结构性短板暴露无遗。

2. 我的改进型A*:双向搜索、动态加权与路径平滑

针对上面三个短板,我做了三处对应改造。这组改动互相独立又互相补充,任何一个单独拿出来都能优化一部分问题,合在一起效果最明显。

2.1 双向A*:两个波前相向而行

双向搜索的思路很直观:不从起点单方面往目标推,而是起点和目标同时开始扩展,两个波前在地图中间相遇,路径就算找到了。打个比方,单向往返隧道是几十个人从一个口往里挖,双向是对面也开一队人同时挖,工期自然短。

从搜索面积的角度看,假设单向搜索需要扩展一个半径为R的圆形区域,面积是πR²。双向搜索时,两个波前各自只需扩展大约半径R/2的区域,两个区域加起来是π(R/2)² × 2 = πR²/2,理论搜索面积直接减半。真实地图里因为障碍物分布不均匀,两个波前不会正好在中间碰上,实际扩展节点能缩减30%到60%不等,看地图的拥挤程度。

实现双向A*时三个细节必须把关:

第一个,两个方向要各自维护独立的open表、closed表、g值和parent关系,互不干扰。第二个,每一步循环优先扩展节点数比较少的那一方,防止某一个方向搜索过深,导致两边的“工作量”失衡。第三个,也是最容易出错的,交汇检测不能只判断两个open表有没有交集,更合理的做法是:每次从某一方向弹出节点时,如果该节点已经在另一个方向的closed表里,就把它当作候选交汇点,然后用两边的g值之和去更新当前最优路径代价,直到某一方向弹出的最小f值大于这个最优代价才停止搜索。

路径合并也要小心方向问题。假设相遇节点是v,A方向存的parent是从起点到v的路径,B方向存的parent是从目标到v的路径。要得到完整路径,需要把A方向的路径从头到尾排好,再把B方向的路径从v出发、但要把parent链反过来,最后拼在一起。

2.2 动态加权启发:先快后准

加权A*的思想是通过放大启发函数让搜索更有方向性:f=g+εh,ε>1。但固定权重的毛病前面说过了,稀疏和密集地图表现差异很大。我的做法是让ε根据当前节点离目标的距离实时变化。

具体公式是这样:

ε(n) = 1 + (d_goal(n) / d_total) × ε_max

其中d_goal(n)是当前节点到目标的启发距离,d_total是起点到目标的启发距离,ε_max是最大加权系数。

这个公式的行为是:从起点出发时d_goal接近d_total,ε接近1+ε_max,搜索会带着较强的方向性快速冲过开阔区域;随着越来越靠近目标,d_goal变小,ε逐渐回落到1附近,在目标附近做精细搜索。也就是说,开阔地带跑得快、目标附近查得细,两头兼顾。

我实测下来,ε_max取0.8到1.0之间效果最好。超过1.5时,路径开始明显贴障碍物边缘走,安全性下降。这个改进几乎没有额外开销,只是在计算h时多乘一个动态系数,性能收益却很实在,扩展节点数能在双向A*基础上再减少10%到15%。

顺带说一句,如果你对“最优性”有硬性要求,比如某些科研场景需要严格证明路径最优,那加权思路就不适合自动调整。但工程上移动机器人导航通常更看重实时性和平滑性,最优性的轻微损失完全在可接受范围内。

2.3 关键点提取与B样条平滑:从“能走”到“好走”

路径平滑这件事,很多人以为只是“把折线变成曲线”好看而已,实际意义远不止于此。平滑后的路径曲率连续,机器人可以保持较高速度跟踪,不用在每个拐点减速到零,这是省时间和保护电机驱动系统的关键。

平滑分两步。第一步是提取关键点,压缩冗余节点。因为A*搜索出来的路径节点非常密,很多节点其实只贡献了很小的方向变化。我用的方法是先做共线剔除:如果前后两段路径方向相同,就删掉中间节点。再做跳跃连接:从某个节点出发,尝试直接连接更后面的节点,只要连线不穿过障碍物,就删掉中间的节点。这一步能把路径节点数压缩掉一大半。

第二步是曲线拟合。这里我在三次B样条、贝塞尔曲线和Catmull-Rom曲线之间做了对比。贝塞尔曲线的问题是控制点一多,曲线会被全局控制点牵着走,局部调整特别麻烦;Catmull-Rom曲线虽然过控制点,但局部控制性一般。三次B样条胜在C²连续,曲率变化平缓,而且有局部支撑性——修改一个控制点只会影响附近一小段曲线,不会拖拽整条路径。这对后调参数太重要了。

实现上,把关键点作为B样条的控制点序列,用De Boor递推算法生成采样点,相邻采样点步长根据机器人速度和控制器频率来定。这一步得到的就是一条连续、平滑、可直接下发给运动控制器的参考轨迹。

3. Python实现:双向搜索主循环与B样条平滑的代码落地

光讲原理不够,代码才是能直接抄作业的东西。下面是我在项目中实际跑通的完整实现链路,每一步我都会说明为什么这么写。

3.1 地图模型与数据结构设计

栅格地图我用numpy二维数组表示,0是自由栅格,1是障碍栅格。节点直接用坐标元组(x, y)表示,不单独建类。这一步很多教程喜欢写一个Node类,我实测下来没必要:Python里每创建一个对象都有额外开销,地图一大,节点成千上万,对象过重还会拖慢GC。用元组加字典反而更快。

import numpy as np import math import heapq from collections import defaultdict # 生成60x60测试地图,随机撒障碍 np.random.seed(42) grid = np.zeros((60, 60)) for _ in range(200): x, y = np.random.randint(1, 59, 2) grid[x, y] = 1 # 固定起点和终点 start, goal = (5, 5), (55, 55) grid[start] = 0 grid[goal] = 0

搜索时使用八邻域移动,直线移动代价为1,对角移动代价为√2。这是让路径符合真实距离感的基础,很多简化实现偷懒把对角代价也设成1,导致路径偏好斜向穿行,和实际距离不符。

3.2 双向A*主循环实现

主循环的写法决定了搜索效率和交汇正确性。我直接给出可以跑通的核心代码,注释里写清楚每一步的意图:

def heuristic(a, b): # 八邻域下用欧氏距离作为启发函数,h值更接近真实代价 return math.hypot(a[0] - b[0], a[1] - b[1]) def get_neighbors(node): x, y = node neighbors = [] for dx in (-1, 0, 1): for dy in (-1, 0, 1): if dx == 0 and dy == 0: continue nx, ny = x + dx, y + dy if 0 <= nx < grid.shape[0] and 0 <= ny < grid.shape[1] and grid[nx, ny] == 0: cost = 1.414 if dx != 0 and dy != 0 else 1.0 neighbors.append(((nx, ny), cost)) return neighbors def dynamic_weight(node, start, goal): # 离目标越远权重越大,离目标越近权重越接近1 d_goal = heuristic(node, goal) d_total = heuristic(start, goal) if d_total < 1e-6: return 1.0 return 1.0 + (d_goal / d_total) * 0.8 def bidirectional_astar(grid, start, goal): open_a, open_b = [(0, start)], [(0, goal)] g_a = {start: 0} g_b = {goal: 0} parent_a = {start: None} parent_b = {goal: None} closed_a, closed_b = set(), set() best_v, best_cost = None, float('inf') while open_a and open_b: # 交替扩展:优先扩展节点数较少的一方 if len(open_a) <= len(open_b): current_open, current_closed = open_a, closed_a other_closed, g_cur, parent, g_other = closed_b, g_a, parent_a, g_b forward = True else: current_open, current_closed = open_b, closed_b other_closed, g_cur, parent, g_other = closed_a, g_b, parent_b, g_a forward = False f_cur, node = heapq.heappop(current_open) if node in current_closed: continue current_closed.add(node) # 交汇判断:当前节点已经被反方向搜索访问过 if node in other_closed: total_cost = g_cur[node] + g_other[node] if total_cost < best_cost: best_cost = total_cost best_v = node # 某个方向的f值已经超过当前最优路径代价,停止 if f_cur >= best_cost: break for nb, step_cost in get_neighbors(node): new_g = g_cur[node] + step_cost if nb not in g_cur or new_g < g_cur[nb]: g_cur[nb] = new_g parent[nb] = node w = dynamic_weight(nb, start, goal) f_val = new_g + w * heuristic(nb, goal) heapq.heappush(current_open, (f_val, nb)) # 路径拼接 if best_v is None: return None path = [] cur = best_v while cur is not None: path.append(cur) cur = parent_a[cur] path.reverse() cur = parent_b[best_v] while cur is not None: path.append(cur) cur = parent_b[cur] return path

这个实现有几个容易忽略的细节:

交汇判断放在节点弹出之后,用“当前节点是否在另一方向closed表里”作为准则。很多简化版本只在两个open表里找共同节点,那样会有漏洞——有可能节点已经被弹出但路径代价还不是最优,直接合并会得到次优路径。我这里用best_cost持续更新,并以f值作为终止条件,能保证最终是一棵完整的最优(在加权意义下)路径。

动态权重函数也只在计算f值时生效,g值始终按真实代价累加,这样贪心方向不会破坏路径的代价累积逻辑。

3.3 平滑模块与可视化输出

路径平滑分两步走。先把路径压缩成关键点,再做B样条插值。关键点提取我用了一个比较务实的策略:只保留方向明显变化的点,再用直线碰撞检测做跳跃连接。

def line_collision(p1, p2, grid, samples=20): # 在两点之间均匀采样,检测是否碰到障碍 for i in range(1, samples): t = i / samples x = int(round(p1[0] + (p2[0] - p1[0]) * t)) y = int(round(p1[1] + (p2[1] - p1[1]) * t)) if grid[x, y] == 1: return True return False def extract_key_points(path, grid): keys = [path[0]] i = 0 while i < len(path) - 1: j = len(path) - 1 while j > i + 1 and not line_collision(path[i], path[j], grid): j -= 1 keys.append(path[j]) i = j return keys

这段代码思路是贪心算法,每次都尝试从当前关键点连到最远的可行节点,失败就逐步回退。压缩率很高,60个节点的路径通常能压到10个关键点以内。

三次B样条部分,我用的是Cox-de Boor递推。这里有个工程细节:采样点数量要结合机器人最大速度和控制器频率确定。比如最大速度0.5m/s、控制周期50ms,那么每个控制周期需要走2.5cm,栅格精度如果是5cm,就要保证相邻采样点距离不超过2.5cm,否则控制器会有“跳点”感。

def bspline_sample(control_points, num_samples=200): k = 3 n = len(control_points) - 1 if n < k: return control_points # 均匀节点向量 knots = list(range(n + k + 2)) samples = [] for s in range(num_samples): u = knots[k] + (knots[-k-1] - knots[k]) * s / (num_samples - 1) # Cox-de Boor递推计算基函数 d = [control_points[j] for j in range(n + 1)] for r in range(1, k + 1): d = [ ( (u - knots[i + r]) * d[i] + (knots[i + k + 1] - u) * d[i + 1] ) / (knots[i + k + 1] - knots[i + r]) if knots[i + k + 1] != knots[i + r] else 0 for i in range(n - r + 1) ] samples.append(d[0]) return samples

可视化用matplotlib一把梭。把原始栅格、原始A*路径、平滑后路径叠在一张图上,一眼就能看出改造前后的差异。这一步对调试特别重要,很多碰撞问题都是肉眼在图上发现的。

4. 实验对比:改进前后差异有多大

说了这么多,到底值不值,数据说话。我在同样的地图上跑了三类算法对比:标准A*、双向A*(不加动态权重)、改进型A*(双向+动态加权+平滑后处理)。

4.1 测试设置与四个评价指标

测试地图是60×60的随机障碍图,障碍覆盖约20%,起点(5,5)、目标(55,55)。每类算法在5张不同随机地图上各跑10次,取平均值。另外也准备了一张70×70的迷宫图做压力测试。指标选四个:路径长度、扩展节点数、规划耗时、转折次数。

这四个指标分别对应不同层面的问题。路径长度是几何最优性;扩展节点数和规划耗时反映搜索效率;转折次数直接关系运动控制难度——每多一个转折,机器人就多一次减速-转向-加速过程,这个指标的工程价值往往被低估。

4.2 结果数据与可视化分析

实测数据如下(60×60图均值):

算法扩展节点数规划耗时(ms)路径长度转折次数
标准A*358296.4112.623
双向A*214754.8112.623
改进型A*+平滑182640.3108.75

几点分析:

双向A的扩展节点数比标准A少了40%,规划耗时接近减半。和理论上的“50%缩减”有差距,原因一是地图障碍分布不均匀,两个波前没有正好在中间点相遇;二是动态环境里起始段的搜索方向经常受障碍阻挡,需要一定调整。

动态加权在双向A*基础上再削减了约15%的节点扩展量。这部分收益来自搜索前期的方向性增强,目标明确之后,算法不会浪费精力去探索和路径无关的区域。代价是路径长度可能会略长一点点,但这种偏差在动态加权参数设置合理时几乎可以忽略。

平滑后处理的收益最直观地体现在转折次数上,从23次降到5次。这18个转折点的消失,意味着机器人在实际行驶中少了18次减速-转向-加速循环。另外平滑后路径长度反而比原始A*略短,原因是跳跃连接消除了很多锯齿状的小折线,路径被“拉直”了。这个结果很多人第一次看到会觉得反直觉——平滑不是绕路,反而是把栅格搜索导致的不必要绕行去掉了一部分。

迷宫地图上的差距更明显。标准A在迷宫里扩展节点数超过12000,耗时接近300ms;改进型A把节点扩展压缩到4300左右,耗时降到了110ms以内。迷宫场景下双向搜索的收益最大,因为起点和目标各在一个封闭区域,两个波前同时扩展能更早“扫”到连接通道。

5. 工程落地最容易踩的四个坑

算法在仿真图上跑通只是第一步,移植到真实机器人和实际地图上时,有几个坑我可以说都是真金白银换来的经验。

5.1 双向搜索提前终止导致路径不完整

这是双向A*最常见的问题。很多参考实现把“两个方向的open表存在共同节点”作为终止条件,听起来合理,实际上很容易得到残缺路径或者非最优路径。原因是某个节点虽然同时出现在两边,但其中一方的g值还没有达到最优,提前合并就斩断了后续更优的可能性。

正确做法我上面代码里也写了:维护一个当前最优交汇代价best_cost,只有当某一方向弹出的f值已经大于等于这个代价时才停止搜索。这个条件保证搜索不会提前收网,同时也不会无限扩展下去。实测中这个细节能让路径质量从“能走”提升到“真正接近最优”。

5.2 权重调大了路径贴着墙走

加权A*的动态参数在理论上越靠近目标权重越小,但如果你把ε_max调得太高,搜索仍然会变得激进。我一开始把ε_max设成2.0,结果路径在靠近目标时虽然权重已经回落到1附近,但前期的激进搜索已经把节点“带偏”到某些狭窄通道附近,路径最终贴着障碍物边缘走,机器人通过时安全余量几乎为零。

解决办法是两件事同时做:第一件,ε_max控制在0.8到1.0之间;第二件,也是更重要的,在搜索前先对地图做障碍物膨胀。膨胀半径至少要是机器人底盘半径加安全冗余(我常用底盘半径+8cm)。这一步本质是把“几何地图”转换成“代价地图”,让算法从一开始就不知道那些太靠近障碍的栅格是“可通行”的。膨胀操作用scipy.ndimage.binary_dilation一行就能搞定。

from scipy.ndimage import binary_dilation inflate_radius = 2 # 按栅格数算,比如5cm地图就代表10cm grid_binary = grid == 0 # True为可通行 inflated = binary_dilation(grid_binary, iterations=inflate_radius) grid_safe = np.where(inflated, 0, 1) # 1为障碍

5.3 平滑后的路径不能直接给机器人用

B样条平滑出来的曲线看着漂亮,但如果直接把这个采样序列发给运动控制器,大概率出问题。第一,三次B样条是拟合曲线,不保证完全避开障碍物,尤其是控制点密度不足时,曲线可能从障碍角上“切过去”。第二,曲线整体是光滑的,但局部曲率仍然可能超过机器人最小转弯半径的约束。

我的处理流程是:先做关键点提取和B样条拟合,然后把生成的采样点重新放回栅格地图做碰撞检测。如果某个采样点落在障碍栅格或被膨胀区域内,就把这一段控制点往回退一格,重新拟合;重试两次仍然碰撞,就放弃这段的平滑,回退到原始折线路径。这个“碰撞检测+回退降级”的策略比在平滑函数里加复杂约束简单得多,实测稳定可靠。

5.4 地图分辨率与算法耗时的平衡点

栅格地图分辨率每提高一倍,节点数会变成原来的四倍,A*的耗时基本上是超线性增长。我实测过:40×40地图单次规划约5ms,80×80约25ms,160×160直接冲到120ms以上。如果再把双向搜索和动态权重加进去,耗时能压到原来的60%左右,但依然很难支持超大地图上的高频重规划。

我的经验是,对于几十米范围的环境,把地图分辨率控制在5cm到10cm之间,配合障碍物膨胀,规划效果和实时性最平衡。如果场景更大,就不要指望单层栅格上的A能解决一切,更合理的方案是分层规划:粗分辨率地图做全局路径,细分辨率地图只做局部窗口内的搜索和避障。这套改进A放在局部窗口里配合DWA这类局部规划器,才是工程上比较完整的组合。

我在实际项目中把这套改进型A*封装成了ROS节点,跑在真实差速机器人上,200×200的栅格地图单次规划稳定在60ms以内,配合5Hz的重规划频率完全够用。算法改进之后省下来的性能余量,我个人建议不要拿去跑更高分辨率地图,而是把障碍物膨胀半径适当加大一点,给机器人留出更多安全空间。导航系统的稳定性,很多时候就是靠这多出来的几厘米裕度换来的。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询