☰
运动规划四大算法:A*、RRT*、多项式轨迹与Minimum Snap的完整链路
2026/10/26 14:58:29 网站建设 项目流程

1. 为什么单靠一个算法做不成运动规划:搜索、采样、参数化、优化的链路

做机器人运动规划的人,几乎都经历过这么一遭:拿A*在栅格地图上跑出一条路径,直接把折线点发给底层控制器,结果机器人要么在拐角处刹停、要么抖得跟帕金森似的;换成Minimum Snap这类轨迹优化算法直接规划,又没有全局地图概念,撞障碍物撞得理直气壮。这其实点出了运动规划最核心的一件事——从来不是某一个算法的问题,而是搜索、采样、参数化和优化这条链路的协作问题。

标题里的四大算法,我按这条链路来组织:A解决的是离散栅格上的全局路径搜索,RRT解决的是高维空间里的随机采样搜索,多项式/贝塞尔曲线解决的是把几何路径参数化成关于时间的轨迹,Minimum Snap解决的是在动力学约束下把轨迹打磨平滑。四者不是互相替代的关系,而是层层递进的关系。无论你是刚入行的学生,还是正在维护AGV、机械臂或者无人机系统的工程师,理解这条链路,比单独背熟任何一个算法的公式都要有用。

1.1 从"能到"到"走得好",规划其实分两层

机器人运动规划这个领域,最容易被忽略的一个事实是:规划出路径和规划出轨迹是两回事。路径是一串不含时间的几何点,比如从(0,0)走到(1,1),中间绕过一张桌子;轨迹则是每个时刻机器人应该在的位置、速度、加速度甚至加加速度。底层控制器只认轨迹,不认路径。A和RRT干的是前一半:在状态空间里找一串无碰撞的点,保证"能到"。

但"能到"不等于"走得好"。路径可能有尖角、可能有冗余绕弯、可能不满足最大速度和加速度限制。于是后半段继续处理:多项式参数化把路径变成连续时间函数,Minimum Snap在约束下优化这个函数的形状。理解这个分层逻辑,后面每一个算法都不会白学——你一眼就能看出某个问题是出在"没找到路",还是"路找到了但走不稳",这是排查系统故障最快的路径。

1.2 四种算法在链路中的分工

算法/方法空间类型核心输出优势代价
A*离散栅格无碰撞路径点列全局最优(给定合法启发函数),实现简单栅格分辨率导致内存和计算指数爆炸,高维用不了
RRT*连续/高维C空间无碰撞路径树概率完备且渐进最优,适合高维空间收敛慢,路径抖动明显,不是真正最优解
多项式/贝塞尔轨迹参数空间带时间信息的连续轨迹可控性强,易于求导与多段拼接本身不做避障,需要走廊或路径点约束
Minimum Snap轨迹系数空间平滑且在约束下最优的轨迹显式优化动力学平滑性,可直接发布给控制器依赖初始路径和约束设计,数值处理不当容易病态

这张表看一眼就明白:它们其实不在同一层上打架。A和RRT是"空间搜索层"的候选人,多项式曲线是"表达层"的载体,Minimum Snap是"优化层"的打磨器。很多人争论"A和RRT谁更好",本质上是在争搜索层用哪种策略,完全不影响你对后两层的使用。

1.3 这条链路怎么读最有效

如果你是刚接触机器人运动规划,我的建议是按我上面写的顺序,而不是一上来就啃论文。先用A把离散搜索里的代价函数、启发函数和数据结构吃透,再用RRT去体会高维空间里离散栅格的不可行,接着用多项式轨迹理解时间参数化到底在干什么,最后才是Minimum Snap的QP求解。写代码的工程师反过来可以倒着用:大部分商用系统里,真正发布给控制器的就是Minimum Snap优化后的轨迹,但出问题时你必然得往回查——是初值路径的问题、中间点时间分配的问题,还是约束条件没写对。所以这四条链路都得能读、能调、能debug。

2. A*:栅格地图上的全局搜索,成本藏在细节里

2.1 启发函数怎么选

A的核心公式g(n)+h(n)几乎人人都会背,但工程里真正决定搜索效率的是h(n)的质量。h(n)是到目标点的估计代价,它必须满足admissible(不超过真实代价),否则A会丢最优性;但h(n)越"准",需要扩展的节点就越少。栅格地图上,4邻域用曼哈顿距离、8邻域用切比雪夫距离、允许任意方向用欧氏距离——这个选择直接影响开放列表里堆叠的节点数量。

我见过不少新手在栅格地图上直接套欧氏距离做8邻域搜索,结果因为欧氏距离在8邻域下会高估代价,破坏最优性,跑出来的路径偶尔出现莫名其妙的绕路。理论上应该用max(|dx|,|dy|)这种切比雪夫距离。实际中更常见的做法是把启发函数稍微乘以一个权重,比如1.01到1.5之间,做加权A*——牺牲一点最优性换回好几倍的搜索速度,在机器人全局规划里非常实用,毕竟我们后面还要做轨迹优化,路径本身不需要严格最优。

2.2 开放列表的数据结构与栅格尺度

开放列表用什么结构实现,是很多教程不会提的细节。A*每轮都要找f值最小的节点,用普通数组就是O(n)扫描,地图大了之后每秒几万次扩展,开销非常恐怖。二叉堆能把代价降到O(log n),我通常用Python的heapq或者C++的priority_queue。但注意,堆里可能出现同一个节点的多份记录,因为新路径可能让g值变小,导致同一个节点被推入多次。弹堆时需要跳过过期的旧记录。

另一个藏在细节里的成本是分辨率。假设一个100m×100m的室内地图,用5cm分辨率就是2000×2000,400万个栅格,再开8个邻域扩展,内存轻松上几十MB,搜索时间也感人。全局规划实践中,分辨率不是越细越好:细到比障碍物膨胀半径小很多,只会增加计算量,不会增加安全性。分辨率通常取机器人半径的1/2到1/3,和膨胀半径保持同一个量级,搜索效率和安全性能同时兼顾。

import heapq def astar_search(start, goal, neighbors_fn, heuristic_fn): open_heap = [(heuristic_fn(start, goal), 0.0, start, None)] g_score = {start: 0.0} closed = set() came_from = {} while open_heap: _, g, node, parent = heapq.heappop(open_heap) if node in closed: continue came_from[node] = parent closed.add(node) if node == goal: path = [] while node is not None: path.append(node) node = came_from[node] return path[::-1] for nxt, step_cost in neighbors_fn(node): new_g = g + step_cost if new_g < g_score.get(nxt, float("inf")): g_score[nxt] = new_g heapq.heappush( open_heap, (new_g + heuristic_fn(nxt, goal), new_g, nxt, node), ) return None

这段代码浓缩了A*的完整核心循环。每一步只做两件事:弹出当前f值最小的节点,然后把能改善g值的邻居推入堆中。实际工程里可以在这个骨架上加权重启发函数、加JPS跳点、加双向搜索,但要先保证这个基础版本的行为完全符合你的预期,再往上堆特性。

2.3 工程扩展:JPS、双向搜索与动态场景

A在工程上的变体多到可以单独写一本书,但你需要优先掌握的其实是两个:JPS(Jump Point Search)和双向A。JPS的核心是发现栅格地图上大面积的对称路径,用"跳点"代替逐格扩展,在开阔环境里速度能快一个数量级。代价是它只能用于均匀栅格且代价函数单调的场景,对复杂代价地图支持不好,需要自己权衡。

双向A则是从起点和终点同时扩展,然后在中途碰头。它在起点和终点之间有迷宫式障碍时比单向有明显优势,实际中常和加权启发函数配合使用。如果场景是动态的,比如AGV运行途中突然出现行人,每次都整条重搜的代价可能太高,这时可以走DLite的思路做增量重规划,或者干脆不在A层面硬扛,把局部重规划交给后面要讲的RRT——这也是四大算法能在同一条系统里共存的原因。

3. RRT*:高维空间里用随机采样对抗维度灾难

3.1 为什么高维栅格会爆炸,而采样却可行

机械臂7个自由度,C空间就是7维,每维分成100份就是10的14次方个栅格,A*在这种规模下没有任何机会。RRT系列的方法是另一个思路:不去遍历整个空间,而是在空间里随机撒点,把树一步步往采样点方向生长。只要采样次数足够多,树覆盖到目标点的概率趋近于1,这就是所谓的概率完备性。它的精妙之处在于,计算量和维度没有指数关系,主要取决于空间本身的复杂程度。

随机采样当然也有代价:路径不是最优的,而且很多时候会出现奇怪的大绕弯。我第一次用RRT跑机械臂避障实验,规划出来的路径像喝醉的蛇,后来做了剪枝和shortcut平滑才勉强能用。这就是为什么RRT很少单独发布给控制器,它后面一定要接轨迹优化。越往后做你越能体会:采样规划擅长"把路找出来",但"把路变好看"不是它的职责。

3.2 rewire机制:RRT*相比RRT的关键一步

RRT和RRT在流程上的差别其实很小:RRT每次扩展新节点后就直接挂到最近节点上,RRT则多做了一步rewire。新节点x_new生成后,先找半径r内的所有邻近节点,在其中选一个代价最小的作为父节点;然后再反过来检查,如果以x_new为父节点能让某个邻近节点的代价降低,就把那条边重连过去。正是这"多看一眼",让RRT*具备了渐进最优性——只要时间足够长,解的代价会收敛到最优值。

# RRT* 扩展流程伪代码核心 for i in range(max_iter): x_rand = sample_free() # 随机采样 x_near = nearest(tree, x_rand) # 找最近节点 x_new = steer(x_near, x_rand, step_size) # 按步长生长 if collision_free(x_near, x_new): near_nodes = near(tree, x_new, gamma * (log(len(tree)) / len(tree)) ** (1.0 / d)) x_parent = choose_cheapest_parent(near_nodes, x_new) tree.add_edge(x_parent, x_new) for x_n in near_nodes: # rewire 环节 if cost(x_parent) + edge_cost(x_parent, x_new) < cost(x_n): if collision_free(x_parent, x_n): tree.rewire(x_n, x_parent)

注意这里的邻居半径r不能随便拍脑袋。r太大,每步都要检查一大堆节点,退化成近似全图搜索;r太小,rewire基本发挥不了作用。常见做法是取r = gamma * (log(n)/n)^(1/d),gamma跟空间维度d和体积有关。实操中很多人直接试几组参数看仿真效果,可以,但至少要知道这个公式背后的含义——它保证随着树节点数n增加,搜索半径收缩的速度刚好能维持渐进最优。

3.3 步长、终点偏向与路径后处理

工程上RRT最常调的三个参数:步长、终点偏向采样概率、碰撞检测的步距。步长决定一次扩展多少,太大容易穿越窄缝,太小会让树长得很慢;终点偏向比如每10次采样有1次直接采目标点,能显著加快到达终点的速度,但如果偏向太强又会损失RRT的探索性,容易绕远路。这两个参数相互牵连,得对着具体地图的尺度来调。

还有一个经常被忽略的优化:碰撞检测不是只对两点连线做一次检查,而是沿着路径按小步距多次采样。采样步距通常取机器人半径的1/2到1/3。步距太大可能把穿过障碍物尖角的路径误判成安全,步距太小拖慢整棵树生长。下一步把路径交给Minimum Snap优化前,通常还会先做一次路径点简化或B样条粗平滑,减少轨迹段数,给QP求解降压。很多工程实现里RRT*路径点数量直接决定后面矩阵规模,这个前置简化动作值得养成习惯。

4. 从几何路径到时间轨迹:多项式参数化的介入

4.1 控制器需要的是轨迹不是路径

假设A*给了你一串路径点:先走到(1,0),再走到(1,2)。如果你直接把两点坐标发给电机,会出现什么情况?机器人会在(1,0)处瞬间改变速度方向,对应无穷大的加速度;即使控制器有限幅,实际表现也是一个急停加一个猛启动。真正的运动规划需要在路径点之间补充时间信息:每个时刻的位置、速度、加速度是多少,而且这些量必须连续。

这里的核心认知是:把路径点当成轨迹的约束条件,而不是最终答案。我们找一个时间函数p(t),让它依次经过这些路径点,并满足速度、加速度连续以及执行器限幅。这就是轨迹生成问题。在众多参数化方法里,多项式是最容易上手也最常用的一种,本质就是"用关于时间的幂函数去拟合路径"。贝塞尔曲线、B样条等都可以看成是它的变体或改进,核心逻辑完全一样。

4.2 一段五阶多项式背后的六个约束

以一段轨迹为例:p(t) = a0 + a1t + a2t² + a3t³ + a4t⁴ + a5*t⁵。为什么常用五阶而不是二阶?因为位置、速度、加速度是分子运动里最重要的三个量,每个量在起点和终点各有一个约束,加起来正好6个约束,刚好解出6个系数。这是最经典的解法——给定边界条件,解一个线性方程组。

例如要求起点p(0)=0,v(0)=0,a(0)=0,终点p(T)=5,v(T)=0,a(T)=0,代入多项式及其一二阶导数,就能写出一个6×6的矩阵方程如下。实际工程里很少手算系数,直接调numpy的linalg.solve或者用样条库。但理解"约束数等于系数个数"这个平衡很关键:约束少了轨迹会乱飘,约束多了就退化成硬插值,失去平滑性。多段轨迹拼接时,只需要在连接点同时约束两边位置、速度、加速度相等,就能保证整体光滑。

import numpy as np T = 2.0 # 约束: p(0)=0, v(0)=0, a(0)=0, p(T)=5, v(T)=0, a(T)=0 A = np.array([ [1, 0, 0, 0, 0, 0], [0, 1, 0, 0, 0, 0], [0, 0, 2, 0, 0, 0], [1, T, T**2, T**3, T**4, T**5], [0, 1, 2*T, 3*T**2, 4*T**3, 5*T**4], [0, 0, 2, 6*T, 12*T**2, 20*T**3], ]) b = np.array([0.0, 0.0, 0.0, 5.0, 0.0, 0.0]) coeffs = np.linalg.solve(A, b)

4.3 时间分配:看起来只是一个标量,实际影响全局

这一段我想特别强调时间分配,因为太多人在这里栽过跟头。轨迹是p(t),t的尺度直接决定速度、加速度、加加速度的大小。同一段几何路径,给2秒和给10秒,优化出的轨迹物理性质完全不同。时间给太短,轨迹为了赶时间会冲得很猛,速度加速度都可能超限;时间给太长,机器人磨磨蹭蹭,效率全无。

Minimum Snap的优化结果强烈依赖每段时间T_i。如果初始T_i分配不合理,即使数学上求出了光滑轨迹,实际执行也可能违反加速度限幅。常见做法是先按路径长度和最大速度估算一个粗时间,跑完优化后检查速度加速度曲线,如果某段超限,把对应段的时间拉长再重新优化;更讲究一点的做法是把时间也作为决策变量,做时间最优的轨迹优化——最小snap的变体里就有不少人这么做。记住一句话:不要指望优化器替你解决时间分配,先给它一个合理的时间初值。

5. Minimum Snap:把"平滑"变成有唯一解的QP问题

5.1 直观理解与目标函数

Minimum Snap这个名称直译是"最小化加加速度的导数",snap是位置的四阶导数。在多旋翼控制里有个非常直观的解释:机器人的四个螺旋桨的拉力控制,主要依赖角速度和力矩的变化率,而snap直接对应电机转速变化率的加权组合。最小化snap的轨迹,执行起来电机变化更柔和,机身姿态更稳。对地面机器人而言也一样——snap越小,关节或轮子的加加速度波动越小,机械磨损更小,跟踪效果通常更好。

目标函数写成:J = ∫₀ᵀ (d⁴p/dt⁴)² dt。为什么不是最小化加速度或jerk?因为目标函数阶次越高,轨迹越平滑,但计算和约束复杂度也越高;最小snap是阶次和实际执行效果之间的一个常用平衡点。如果只关心转向力变化率,最小jerk也够用,但对多旋翼领域,学术界和工业界都更习惯用snap。对于地面机器人和机械臂,你也可以根据自己的需求选择最小jerk,公式推导过程完全一致。

5.2 从积分到矩阵:Q矩阵的构造

别被QP吓到。把每段轨迹写成系数向量c,那么对这段轨迹求四阶导后再平方积分,展开后就是cᵀQc,其中Q是一个对称矩阵。Q的每一项对应两个基函数t^i和t^j的四阶导乘积在[0,T]上的积分。只要会用下面这个公式,构造Q其实是机械劳动——

import math def q_element(i, j, T): # p(t) = t^i 和 p(t)=t^j 的四阶导数乘积的积分 if i < 4 or j < 4: return 0.0 ci = math.factorial(i) // math.factorial(i - 4) cj = math.factorial(j) // math.factorial(j - 4) return ci * cj * T ** (i + j - 7) / (i + j - 7) def q_matrix(order, T): return [[q_element(i, j, T) for j in range(order)] for i in range(order)]

整条多段轨迹由多段组成,每一段有自己的系数和时间T_i,所以最终的Q是各段Q_i的块对角拼接。段内系数耦合目标函数,段间则通过连续性约束解耦。理解这个块对角结构对调试很有帮助:某一段轨迹在仿真里出现剧烈抖动,单独看那一小块的Q、时间T_i和约束,比看整条大矩阵更快定位问题。我调试的时候经常把每段的Q打印出来,检查是不是某个T_i写小了。

5.3 等式约束、不等式约束和工程上的取舍

Minimum Snap在数学上是一个带约束的二次规划:等式约束包括起点终点的位置、速度、加速度值,以及中间点的连续性;不等式约束包括走廊边界、速度加速度限幅等。等式约束用QR分解或直接代入把决策变量约掉,不等式约束则交给OSQP这类QP求解器处理。整个优化问题规模通常只有几十到上百个变量,求解速度非常快。

这里我想分享一个工程取舍:无约束的最小snap解起来最快,但轨迹可能在障碍物附近乱飘;全约束又会让QP问题变得很硬,求解失败概率上升。工业界常见做法是分层:先用A或RRT给出无障碍的粗路径,把它膨胀成一个个安全走廊,然后让最小snap在走廊内优化。走廊约束用线性不等式表示,既保证了安全性,又比在障碍物表面加一大堆非线性约束好求解得多。这是把"几何避障"和"轨迹优化"解耦的典型思路,非常值得沿用到你自己的系统里。

5.4 数值病态问题,我踩过的坑

Minimum Snap工程落地里最大的坑不是公式,而是数值病态。轨迹系数动辄跨越好几个数量级:位置是米级的,高阶系数可能小到1e-6,T_i稍大一点,Q矩阵的条件数就高得吓人。我最早用C++的Eigen直接解,经常遇到矩阵近奇异的警告。解决办法是时间归一化:把每段轨迹的时间都缩放到[0,1]区间,把时间T_i作为缩放因子乘到端点约束和Q矩阵里。这个操作能把条件数降低好几个数量级,几乎属于必做项。

另一个坑和初值有关。Minimum Snap是凸优化,二次规划本身能稳定求全局最优,但这个"最优"是目标函数意义上的最优——如果你给的路径初值会穿过障碍物,优化结果也不会帮你神奇地避障。所以前面那几层算法不是摆设:A或RRT给的路径质量,直接决定最小snap输出的轨迹是否可用。松弛变量也值得用起来:把硬约束写成带惩罚的软约束,至少保证求解器任何时候都能吐出轨迹,哪怕有一两个约束轻微违约,也比直接求解失败让系统宕机强。这个取舍在真实机器人上有时候是保命的。

6. 四大算法怎么组合成一套能上线的规划系统

6.1 从地图到控制器的完整管线

现在把前面四大算法串起来看一个完整系统。全局层:加载静态地图,用A在栅格上跑一条从起点到目标点的粗路径;局部层:如果环境存在动态障碍或高维机械臂,用RRT在局部窗口内重规划并rewire;轨迹层:把关键路径点按距离和速度分配时间,生成分段多项式初值;优化层:构造Minimum Snap QP,加上走廊或速度加速度约束,输出最终轨迹给控制器。

很多商用机器人系统里,最终轨迹的横截面上是"前馈+反馈":Minimum Snap给出的位置、速度、加速度是前馈,控制器在此基础上叠加PID修正实际跟踪误差。这也是为什么前馈轨迹必须足够平滑——前馈本身越平滑,反馈修正的压力就越小。反过来,如果前馈轨迹速度跳变,即使反馈控制器再强,跟踪误差也压不下来。下次遇到机器人跟踪抖动的故障,先检查前馈轨迹的加加速度曲线,往往比调PID参数更有效。

6.2 一个具体的组合调试示例

举一个具体到能抄作业的示例。假设一个1m×1m的仿真迷宫,起点(0.1,0.1),终点(0.9,0.9),中间竖着两块障碍物。先用栅格分辨率0.02m建图,A*跑出路径,约30个路径点;然后把路径点合并到只剩关键的5个拐点,用直线可达性判断裁剪;时间按每段空间直线距离除以0.5m/s估算;再构造分段的7阶多项式轨迹,用minimum snap求解,同时把每段的速度限制在0.8m/s、加速度限制在2m/s²。

这个流程我放在几年前的笔记本上跑,整条求解从A到QP输出大概几十毫秒,完全够离线重规划用。如果换成在线场景,A这步会变成增量重规划,或者直接让RRT*从当前树继续生长;Minimum Snap的求解也换成OSQP的稀疏求解器,把求解时间压到个位数毫秒。关键参数不是玄学,是资源预算倒推出来的。先明确你的控制周期是多少毫秒,再决定每个环节能花多少时间。

6.3 部署时最容易翻车的三个地方

最后说三个我实际踩过的部署问题。第一,优化后的轨迹一定要再过一遍碰撞检测。优化器把轨迹拉进安全走廊时,走廊本身是保守的,但走廊交界处有时会出现极小越界,尤其是时间分配比较激进的时候。发布前对整条轨迹逐点做一次膨胀碰撞检查,是成本最低的保命手段。

第二,时间分配不要直接用直线距离除以最大速度,还要乘以一个大于1的系数。真实机器人加速减速都需要时间,从静止启动时以最大速度跑不了多远。我习惯先给1.2到1.5倍时间余量,优化完看速度曲线再微调。第三,所有参数在仿真里调好后,换到真实机器人上至少要把速度和加速度限制降档20%到30%再试,因为实际电机响应、摩擦、传感器延迟都会让理论轨迹显得"太激进"。等真实运行稳定了,再一档一档把限制提上去。

我当年就是没搞懂这几个环节的分工,上来就拿着Minimum Snap的论文硬套,结果在初值路径上卡了一整个星期。后来老老实实把A*、RRT*的代码逐行调通,再回头看才发现,每一步存在都有它的道理。搜索—采样—参数化—优化这套链路,不一定是最新潮的方案,但作为从入门到工程的骨架,它足够扎实。顺着它走一遍,你对运动规划的整体认识基本就补齐了。

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

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

立即咨询