第4章 图搜索算法(下):A*算法原理、启发函数设计、工程优化技巧

好,咱们接着聊图搜索。上一章我们把 Dijkstra 和 BFS 掰开揉碎讲了一遍。这一章,我来聊聊真正让机器人动起来的核心——A* 算法。

说实话,我最早接触 A* 是在做扫地机器人的时候。那时候我还在想,这玩意儿不就是个带方向的 Dijkstra 吗?后来真上手了才发现,嗯,没那么简单。

4.1 A* 算法的核心思想

A* 算法本质上是一种启发式搜索。说白了,就是给搜索过程装了个「指南针」。

它的核心公式很简单:

f(n) = g(n) + h(n)

其中:

  • g(n):从起点到当前节点 n 的实际代价
  • h(n):从当前节点 n 到终点的估计代价(启发函数)
  • f(n):综合代价,用来决定优先搜索哪个节点

你想想看,Dijkstra 只考虑 g(n),所以它像个无头苍蝇一样四面八方乱撞。而 A* 多了个 h(n),就有了方向感。

关键点:h(n) 的设计决定了 A* 的行为。h(n) = 0 时,A* 退化为 Dijkstra。h(n) 恰好等于真实距离时,A* 能最快找到最优路径。

4.2 启发函数设计——这才是灵魂

我在项目中遇到过最头疼的问题,就是启发函数选错了。选错了会怎样?要么路径不是最优,要么搜索效率低到令人发指。

常见的启发函数有几种:

启发函数 适用场景 特点
曼哈顿距离 四方向网格(上下左右) 计算快,但可能高估
欧几里得距离 八方向或连续空间 更精确,但计算稍慢
对角线距离 八方向网格 兼顾曼哈顿和欧几里得
自定义启发函数 特殊约束场景 需要领域知识

注意:启发函数必须满足「可采纳性」,即 h(n) ≤ 真实代价。否则 A* 可能找不到最优解。我曾经在自动驾驶项目中用过一次不可采纳的启发函数,结果路径规划出来绕了个大圈……那次教训挺深刻的。

4.3 A* 算法的完整流程

我习惯用伪代码来理解算法。你看这个流程:

初始化 open_set = {起点}
初始化 closed_set = {}

while open_set 不为空:
    当前节点 = open_set 中 f 值最小的节点
    
    if 当前节点 == 终点:
        返回路径
    
    将当前节点从 open_set 移除,加入 closed_set
    
    for 每个邻居节点:
        if 邻居节点在 closed_set 中:
            跳过
        
        计算 g(邻居) = g(当前) + 代价(当前, 邻居)
        计算 h(邻居) = 启发函数(邻居, 终点)
        计算 f(邻居) = g(邻居) + h(邻居)
        
        if 邻居节点不在 open_set 中:
            加入 open_set
            记录父节点
        else if 新 g 值更小:
            更新 g 值和父节点

这个流程看起来简单,但实际工程中坑不少。我一个个说。

4.4 工程优化技巧——实战经验

好,到了我最想聊的部分。理论谁都会,但真正让 A* 跑得快、跑得稳,靠的是这些优化技巧。

4.4.1 优先队列的实现

open_set 需要频繁取出最小 f 值的节点。用普通数组?那效率太低了。我建议用二叉堆(Binary Heap)。

在 C++ 里,我习惯用 std::priority_queue。在 Python 里,用 heapq。但要注意一点——当你更新某个节点的 g 值时,优先队列不会自动调整。怎么办?

  • 方法一:直接把新节点 push 进去,旧节点留在里面。取出时检查是否过期。
  • 方法二:用 Fibonacci Heap,支持 decrease-key 操作。但实现复杂,我一般不用。

我的习惯:用方法一。简单粗暴,性能损失不大。你想想看,大多数情况下节点被更新的次数远少于被取出的次数。

4.4.2 网格地图的预处理

在自动驾驶中,我们经常处理栅格地图。如果每个节点都去计算邻居,那太慢了。

我做过一个优化:提前计算好每个节点的邻居索引。比如一个 1000×1000 的网格,提前算好邻居关系,搜索时直接查表。速度能提升 3-5 倍。

4.4.3 跳跃点搜索(JPS)

这个技巧我得重点说说。JPS 是 A* 在均匀网格上的一种加速变体。

它的核心思想是:在开阔区域,很多节点其实没必要探索。你只需要找到「跳跃点」——也就是方向必须改变的地方。

举个例子:

起点 → → → → → → → → → 终点
                ↑
                障碍物

在这个场景里,起点到障碍物之间的所有格子,其实路径都是一样的直线。JPS 会直接「跳」过去,而不是一格一格走。

我在一个仓储机器人项目中用过 JPS,搜索时间从 200ms 降到了 15ms。效果非常明显。

4.4.4 权重 A* 与动态加权

有时候我们不需要绝对最优路径,只要「足够好」就行。这时候可以用权重 A*。

公式变成:

f(n) = g(n) + w * h(n)

其中 w > 1。w 越大,算法越「贪心」,搜索越快,但路径可能不是最优。

我常用的一个技巧是动态加权:

  • 搜索初期,w 设大一点(比如 2.0),快速逼近目标区域
  • 接近目标时,w 逐渐降到 1.0,保证最终路径质量

经验之谈:动态加权 A* 在自动驾驶的全局路径规划中非常实用。你不需要每次都找到理论最优路径,只要路径足够平滑、安全就行。

4.5 避坑指南

做 A* 这么多年,我踩过的坑不少。挑几个典型的说说:

  • 启发函数高估:我曾经在三维空间用欧几里得距离做启发函数,结果因为障碍物绕行,实际路径比直线长很多,导致搜索效率极低。后来改用「考虑障碍物密度的启发函数」,效果好多了。
  • 内存爆炸:在超大网格(比如 10000×10000)上跑 A*,如果不做任何优化,open_set 可能存几百万个节点。我建议用分层搜索——先粗粒度搜一遍,再在局部细粒度搜索。
  • 死循环:如果启发函数设计不当,或者地图有负权边,A* 可能陷入死循环。一定要加 visited 标记,并且限制最大迭代次数。

4.6 本章知识体系

下面这张图是我自己画的,把 A* 的核心知识点串起来了:

A* 算法 核心原理 f(n) = g(n) + h(n) g(n): 实际代价 h(n): 启发估计 启发函数设计 曼哈顿距离 欧几里得距离 对角线距离 可采纳性约束 工程优化技巧 优先队列(二叉堆) 网格预处理 跳跃点搜索(JPS) 动态加权 应用场景:自动驾驶路径规划 / 机器人导航

4.7 小结

A* 算法看起来简单,但真正用好它,需要你对启发函数、数据结构、应用场景都有深刻理解。

我个人觉得,学 A* 最好的方式就是动手写一遍。别用现成的库,自己从零实现。写完之后,再试着加 JPS 优化、加动态加权。你会发现,原来那些论文里说的东西,其实没那么玄乎。

嗯,这一章就到这里。记住一句话:好的启发函数,比好的硬件更重要。

可可精品资料库