简介:这份PDF文档是卡内基梅隆大学Dave Ferguson等人撰写的《A Guide to Heuristic-based Path Planning》,面向机器人学、自动化与人工智能方向的研究生、工程师及科研人员,系统梳理了启发式路径规划算法家族。内容从静态环境下的A算法切入,延伸至动态重规划的D、限时优化的ARA以及兼顾实时性与适应性的AD,逐一分析其核心特性、适用场景与优缺点,并给出算法选型指南,涵盖环境静态性、计算资源、路径质量与响应时间等权衡因素。资源包共1个PDF文件,大小约1.72MB,便于随身查阅与打印研读。目前已有131人学习下载。读者可借此建立从经典搜索到动态重规划的完整知识框架,理解启发式函数选择与环境建模对规划效果的影响,为实际机器人系统设计提供理论依据与选型参考。
1. 从一份 2005 年的路径规划指南说起:A*、D*、ARA*、AD* 到底怎么选
如果你正在做移动机器人或者自动驾驶的路径规划模块,大概率遇到过这样的场景:全局路径用 A* 跑得好好的,一旦传感器发现前方多了一块障碍物,整个路径就得从头算一遍,CPU 直接飙满,控制周期被拖垮。这份由 Ferguson、Likhachev 和 Stentz 合著的A Guide to Heuristic-based Path Planning就是专门解决这类问题的——它不是单纯讲 A* 怎么写的教科书,而是把静态规划、增量重规划、 anytime 规划、以及 anytime 增量重规划这四类算法放在同一个框架下做对比,告诉你什么场景该用哪个。适合已经写过基础 A*、但在动态环境或算力受限场景下反复翻车的从业者。我拆完这份材料最大的感受是:很多人不是不会写 A*,而是不知道 D* Lite 的 rhs 值和优先队列 key 到底在干什么,结果一遇到环境变化就退化成“每帧重规划”,白白浪费算力。
2. 静态环境下的 A*:启发式函数与 OPEN 列表的工程细节
2.1 A* 的核心机制与伪代码拆解
A* 是这一族算法的起点。它维护一个从起点到每个状态 s 的路径代价估计 g(s),以及一个从 s 到目标的启发式估计 h(s, s_goal)。OPEN 列表按 g(s) + h(s, s_goal) 排序,每次弹出最小值状态进行扩展。原文给出的伪代码里,ComputeShortestPath 的循环条件是argmin_{s∈OPEN}(g(s) + h(s, s_goal)) ≠ s_goal,也就是说,当目标状态成为 OPEN 中代价最小的节点时,搜索终止。
这里有一个容易被忽略的工程细节:h 函数必须满足可采纳性(admissible),即不能高估从任意状态到目标的真实代价。常见做法是用欧几里得距离或曼哈顿距离。如果你在栅格地图上把 h 设成“忽略障碍物的直线距离”,那它天然可采纳;但如果你为了加速把 h 乘上一个大于 1 的系数,就变成了加权 A*,虽然更快,但不再保证最优。我一般会在调试阶段先用纯 A* 跑通,确认路径合理后再逐步调权重。
import heapq def a_star(grid, start, goal, h_func): # grid: 二维栅格,0 可通行,1 障碍 # h_func: 启发式函数,输入两个坐标,返回估计代价 open_list = [] heapq.heappush(open_list, (h_func(start, goal), 0, start)) g_score = {start: 0} came_from = {} visited = set() while open_list: f, g, current = heapq.heappop(open_list) if current == goal: # 回溯路径 path = [] while current in came_from: path.append(current) current = came_from[current] path.append(start) return path[::-1] if current in visited: continue visited.add(current) for dx, dy in [(1,0), (-1,0), (0,1), (0,-1)]: neighbor = (current[0]+dx, current[1]+dy) if not (0 <= neighbor[0] < len(grid) and 0 <= neighbor[1] < len(grid[0])): continue if grid[neighbor[0]][neighbor[1]] == 1: continue tentative_g = g_score[current] + 1 # 假设每步代价为 1 if neighbor not in g_score or tentative_g < g_score[neighbor]: g_score[neighbor] = tentative_g came_from[neighbor] = current f_neighbor = tentative_g + h_func(neighbor, goal) heapq.heappush(open_list, (f_neighbor, tentative_g, neighbor)) return None # 无路径这段代码里,g_score对应原文的 g(s),f_neighbor对应 g(s') + h(s', s_goal)。注意我用了一个visited集合来跳过已经扩展过的节点,这是工程上防止重复扩展的常见做法,但严格来说,如果启发式不一致(inconsistent),跳过已访问节点可能丢失最优解。原文强调 h 需要可采纳且一致(consistent),即满足三角不等式 h(s, s'') ≤ h(s, s') + c*(s', s''),这样才能保证每个状态只被扩展一次。
2.2 启发式函数怎么选:欧几里得、曼哈顿还是对角距离
启发式函数的选择直接决定搜索效率。在四连通栅格上,曼哈顿距离是可采纳的;在八连通栅格上,如果你允许对角移动且对角代价为 1,那切比雪夫距离更合适;如果对角代价为 √2,那欧几里得距离才是可采纳的。我见过不少项目在八连通地图上直接用曼哈顿距离,结果路径明显绕远,因为 h 低估太多,搜索空间膨胀。
一个实用的调参习惯是:先统计 A* 扩展的节点数,再对比不同 h 函数下的扩展数。如果扩展数接近全图节点数,说明 h 太弱;如果路径偶尔出现明显非最优,说明 h 可能高估了。原文提到 A* 可以反向搜索,即从目标向起点规划,这在 D* Lite 里会用到,因为反向搜索让机器人移动时只需更新启发式值,而不必重新计算所有状态的 g 值。
提示:如果你的地图是 3D 或者有非均匀代价,建议先用 Dijkstra 跑一遍得到真实距离场,再把它作为 h 的查表值,这样既保证可采纳性,又比几何距离紧致得多。
3. 动态环境下的 D* Lite:rhs 值、优先队列与增量修复
3.1 D* Lite 如何用 rhs 值检测不一致状态
D* Lite 是 D* 家族的简化版,原文明确说它比 D* 更简单且在某些导航任务中略高效。它的核心是维护两个值:g(s) 和 rhs(s)。rhs(s) 是一步前瞻代价,定义如下:
- 如果 s 是目标,rhs(s) = 0;
- 否则 rhs(s) = min_{s'∈Succ(s)} (c(s, s') + g(s'))。
一个状态如果 g(s) = rhs(s),称为一致(consistent);如果 g(s) > rhs(s),称为过一致(overconsistent);如果 g(s) < rhs(s),称为欠一致(underconsistent)。OPEN 列表里只放不一致的状态,按 key(s) = [min(g(s), rhs(s)) + h(s_start, s), min(g(s), rhs(s))] 排序。这个双键设计是为了保证沿当前路径的状态优先处理。
当机器人移动或者传感器发现新障碍时,只有受影响的边的代价发生变化,D* Lite 只更新这些边端点状态的 rhs 值,把不一致的状态重新入队,然后调用 ComputeShortestPath 进行局部修复。原文提到,这种增量方式可以比从零开始用 A* 重规划快两个数量级。
def update_state(s, g, rhs, open_list, succ_func, goal, h_func, start): if s != goal: rhs[s] = min(c(s, sp) + g[sp] for sp in succ_func(s)) if s in open_list: open_list.remove(s) if g[s] != rhs[s]: key = (min(g[s], rhs[s]) + h_func(start, s), min(g[s], rhs[s])) heapq.heappush(open_list, (key, s))这段是 D* Lite 里 UpdateState 的简化实现。succ_func(s)返回 s 的后继状态集合,c(s, sp)是边代价。注意 rhs 的计算依赖后继状态的 g 值,所以当某条边的代价变化时,需要从该边的起点开始向上传播。实际工程中,我一般会把open_list封装成一个支持删除和重新插入的优先队列,Python 的 heapq 不直接支持删除,可以用一个字典记录状态是否有效,弹出时跳过无效项。
3.2 从零重规划 vs 增量修复:什么时候 D* Lite 会翻车
D* Lite 并非万能。如果环境变化剧烈,比如机器人前方突然出现一大片新障碍,导致大量边的代价同时变化,那增量修复的代价可能接近甚至超过从零重规划。原文也提到,D* Lite 适用于环境变化不太频繁的场景。我自己的经验是:如果每次更新的变化边数量超过总边数的 10%,就该考虑直接跑一次 A* 或者用 anytime 版本。
另一个坑是启发式函数的一致性要求。D* Lite 要求 h 是可采纳且反向一致的,即 h(s, s'') ≤ h(s, s') + c*(s', s'')。如果你用了一个不一致的 h,优先队列的排序可能失效,导致路径非最优甚至死循环。常见做法是直接用欧几里得距离,它在连续空间里天然满足一致性。
注意:D* Lite 的终止条件是 OPEN 中没有 key 小于 start 状态 key 的节点,且 start 的 rhs 等于 g。如果你在调试时发现算法不退出,先检查 start 状态的 rhs 和 g 是否被正确初始化——原文的 Main 函数里,g(s_start) 和 rhs(s_start) 初始为无穷大,rhs(s_goal) 设为 0,这个初始化顺序不能错。
4. Anytime 规划:ARA* 与 AD* 如何在有限时间内给出可行解
4.1 ARA* 的膨胀因子与代价上界
ARA*(Anytime Repairing A*)解决的是“时间有限但想要尽快拿到一个可行路径”的问题。它的做法是用一个膨胀因子 ε > 1 去乘启发式函数,即使用 ε * h(s, s_goal) 作为排序依据。这样搜索更贪心,扩展节点更少,能快速得到一个次优解,代价上界是 ε 倍最优代价。然后 ARA* 逐步减小 ε,复用上一次搜索的 OPEN 列表和 g 值,继续优化路径。
原文把 ARA* 归为 anytime 算法,意思是它可以在任何时候被中断并返回当前最优解。工程上这非常实用:比如机器人的控制周期是 100ms,你可以先让 ARA* 用 ε=3 跑 20ms 拿到一条路径,剩下的 80ms 继续用 ε=1.5 优化,如果优化没完成就直接用当前解。
def ara_star(grid, start, goal, h_func, epsilons): # epsilons: 递减的膨胀因子列表,如 [3.0, 2.0, 1.5, 1.0] g_score = {start: 0} open_list = [] heapq.heappush(open_list, (h_func(start, goal) * epsilons[0], 0, start)) best_path = None for eps in epsilons: # 用当前 eps 继续搜索,直到找到目标或 OPEN 为空 while open_list: f, g, current = heapq.heappop(open_list) if current == goal: best_path = reconstruct_path(came_from, current) break # ... 扩展邻居,更新 g_score 和 OPEN # 减小 eps 后,需要重新调整 OPEN 中节点的 key # 实际实现中通常重建 OPEN 或使用可更新优先队列 return best_path这段伪代码展示了 ARA* 的骨架。关键点是:每次减小 ε 后,OPEN 列表里已有节点的优先级需要重新计算,因为 f 值变了。常见做法是用一个支持 decrease-key 的优先队列,或者干脆把 OPEN 里所有节点重新入队。后者简单但效率低,适合节点数不多的场景。
4.2 AD* 如何同时处理动态环境和 anytime 需求
AD*(Anytime D*)是 D* Lite 和 ARA* 的结合体。它既能像 D* Lite 那样增量修复路径,又能像 ARA* 那样在时间允许时逐步优化。原文说 AD* 适用于需要在未知或快速变化环境中运行的系统。它的核心是在 D* Lite 的框架上引入膨胀因子 ε,并且维护一个“当前解”和“优化解”的双层结构。
实际使用 AD* 时,我一般会设置两个阈值:一个是最大允许代价上界,一个是最大计算时间。如果时间到了但路径还没优化到上界以内,就直接返回当前解。AD* 的伪代码比 D* Lite 复杂不少,但如果你已经理解了 rhs 值和优先队列,剩下的就是多了一个 ε 的衰减逻辑。
提示:AD* 在机器人操作系统(ROS)的 navigation 栈里有开源实现,但参数调优比较玄学。建议先用小规模栅格地图验证逻辑,再迁移到真实机器人上。如果发现路径抖动严重,检查 ε 的衰减步长是否太大。
5. 避坑与排查:启发式路径规划里最容易翻车的五个点
5.1 启发式函数高估导致路径非最优
现象:A* 跑出来的路径明显绕远,或者在某些地图上直接找不到路径。原因:h 函数高估了真实代价,破坏了可采纳性。比如在八连通地图上用曼哈顿距离,对角移动的代价被低估,但如果你把对角代价设成 1 而 h 用欧几里得距离,又可能高估。解决:先用 Dijkstra 生成真实距离场,对比 h 值和真实值的差异,确保 h(s) ≤ 真实代价。
5.2 D* Lite 的 rhs 值未正确初始化
现象:算法不收敛,或者第一次规划就返回空路径。原因:Main 函数里 g(s_start) 和 rhs(s_start) 的初始化顺序错了,或者 rhs(s_goal) 没有设为 0。解决:严格按照原文的 Main 函数初始化,g 全部设为无穷大,rhs(s_goal) = 0,然后把 s_goal 入队。
5.3 优先队列的 key 比较出现浮点误差
现象:OPEN 列表里两个状态的 key 几乎相等,但排序结果不稳定,导致路径偶尔非最优。原因:浮点数比较没有加容差。解决:在 key 比较时加一个 epsilon,比如 1e-6,或者把代价放大成整数再比较。
5.4 增量更新时遗漏了前驱状态的 rhs 更新
现象:环境变化后,D* Lite 修复的路径仍然撞上障碍。原因:UpdateState 只更新了受影响边的起点,但没有递归更新其前驱。解决:在 ComputeShortestPath 里,弹出过一致状态后,要遍历其所有前驱并调用 UpdateState。
5.5 Anytime 算法的 ε 衰减过快导致优化不充分
现象:ARA* 或 AD* 返回的路径代价远高于最优。原因:ε 从 3 直接跳到 1,中间没有足够迭代。解决:用递减序列如 [3.0, 2.0, 1.5, 1.2, 1.0],并且每次减小后给足搜索时间。
6. 从伪代码到可运行模块:我的验证习惯与一个实用技巧
把这份指南里的算法落地时,我最大的教训是:不要一上来就写完整机器人系统。我一般会先在一个 20x20 的栅格地图上把 A*、D* Lite、ARA* 各实现一遍,用同一组起终点和障碍物配置,对比扩展节点数、路径代价和运行时间。这个习惯帮我省了很多后悔药——有一次我在 D* Lite 里把 rhs 的 min 写成了 max,结果路径直接穿墙,但在小地图上跑的时候因为障碍少没暴露,直到换了一张复杂地图才翻车。
一个实用的验证技巧是:用随机地图生成器批量跑 100 次,统计每个算法的成功率和平均代价。如果某个算法在 10% 的案例里失败,先检查边界条件,比如起点或终点在障碍物上、地图全封闭等。另外,D* Lite 的增量修复效果可以通过“变化边比例”来量化:我一般会记录每次更新时受影响的边数占总边数的比例,如果超过 15%,就切换到从零重规划,避免增量修复的开销超过收益。
def benchmark(algorithms, map_generator, num_trials=100): results = {name: {"success": 0, "avg_cost": 0, "avg_expansions": 0} for name in algorithms} for _ in range(num_trials): grid, start, goal = map_generator() for name, algo in algorithms.items(): path, expansions = algo(grid, start, goal) if path: results[name]["success"] += 1 results[name]["avg_cost"] += path_cost(path) results[name]["avg_expansions"] += expansions for name in results: results[name]["avg_cost"] /= max(1, results[name]["success"]) results[name]["avg_expansions"] /= max(1, results[name]["success"]) return results这个 benchmark 函数是我每次实现新算法后必跑的。map_generator返回随机栅格、起点和终点,algo返回路径和扩展节点数。跑完 100 次后,如果某个算法的成功率低于 90%,我会先排查地图生成器是否产生了非法起终点,再检查算法本身的边界处理。从那以后我每次集成新的规划算法,都强制走一遍这个小 benchmark,确认没有低级错误再上真机。希望帮到你。
本文还有配套的精品资源,点击获取