4. 全局路径规划算法(A*):A*算法原理、启发函数设计、栅格地图实现

各位同学,今天我们来啃一块硬骨头——A*算法。说实话,我在刚入行那会儿,觉得路径规划就是“找条路走过去”,直到第一次在产线上调试AGV,车子在货架中间转圈圈,我才意识到:没有好的全局路径规划,局部控制再牛也白搭。

A*算法,说白了就是“有脑子的广度优先”。它不像Dijkstra那样傻傻地四面八方扩散,也不像贪心算法那样只顾眼前。它结合了实际代价和预估代价,每次扩展都挑最有可能通往终点的节点。嗯,这个思路很符合我们工程师的直觉——干活前先评估一下,哪条路性价比最高。

4.1 A*算法核心原理

A*算法的核心公式就一个:

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

  • f(n):节点n的总评估代价
  • g(n):从起点到节点n的实际代价
  • h(n):从节点n到终点的启发式预估代价

我习惯把A*的搜索过程想象成“带着指南针的盲人”。g(n)是已经走过的路,h(n)是指南针指的方向。盲人每走一步,都会摸摸地面(实际代价),再抬头看看指南针(预估代价),然后决定下一步往哪走。

算法维护两个关键列表:

  • Open List:待评估的节点集合。每次从中取出f值最小的节点进行扩展。
  • Closed List:已经评估过的节点集合。已经处理过的节点不再重复考虑。

基本流程是这样的:

  1. 把起点加入Open List
  2. 从Open List中取出f值最小的节点,记为当前节点
  3. 如果当前节点是终点,搜索结束,回溯路径
  4. 否则,将当前节点移入Closed List,扩展它的邻居节点
  5. 对每个邻居,计算g、h、f值,更新Open List
  6. 重复步骤2-5,直到找到终点或Open List为空

我的经验:Open List的实现方式直接影响性能。小地图用普通列表+遍历没问题,大地图(比如1000x1000的栅格)一定要用优先队列(最小堆)。我曾经在一个项目里用列表硬扛,结果搜索时间从毫秒级变成了秒级,被客户当场吐槽。

4.2 启发函数设计——A*的灵魂

启发函数h(n)的设计,直接决定了A*算法的搜索效率和最优性。说白了,h(n)就是你对终点的“直觉”。直觉越准,搜索越快;直觉太离谱,要么找不到最优解,要么搜索范围爆炸。

常用的启发函数有三种:

启发函数 公式 适用场景 特点
曼哈顿距离 |dx| + |dy| 四方向移动(上下左右) 计算快,可采纳,最常用
欧几里得距离 √(dx² + dy²) 八方向移动或自由移动 更精确,但计算开销大
切比雪夫距离 max(|dx|, |dy|) 八方向移动(对角代价相同) 介于两者之间

避坑指南:我曾经在一个AGV项目里用了欧几里得距离做启发函数,结果搜索出来的路径全是斜线,AGV在货架通道里根本走不了。后来换成曼哈顿距离,路径规规矩矩地沿着通道走。记住:启发函数必须与机器人的实际运动模型匹配。

关于启发函数的可采纳性,这里有个关键点:如果h(n) 永远不大于从节点n到终点的实际最小代价,那么A*算法保证能找到最优解。这样的启发函数称为可采纳的。

你想想看,如果h(n)高估了距离,算法可能会错过最优路径,因为它觉得某条路“看起来太远”就不去探索了。嗯,这就像你凭直觉觉得某条路堵车就不走,结果那条路其实一路畅通。

4.3 栅格地图实现

在实际项目中,我们通常用栅格地图来离散化环境。每个栅格要么是可通行(0),要么是障碍物(1)。

我习惯用二维数组来表示栅格地图:

// 栅格地图定义
// 0: 可通行, 1: 障碍物
int map[10][10] = {
    {0, 0, 0, 0, 0, 0, 0, 0, 0, 0},
    {0, 0, 0, 0, 0, 0, 0, 0, 0, 0},
    {0, 0, 0, 0, 0, 1, 1, 0, 0, 0},
    {0, 0, 0, 0, 0, 1, 1, 0, 0, 0},
    {0, 0, 0, 0, 0, 0, 0, 0, 0, 0},
    {0, 0, 0, 0, 0, 0, 0, 0, 0, 0},
    {0, 0, 0, 0, 0, 0, 0, 0, 0, 0},
    {0, 0, 0, 0, 0, 0, 0, 0, 0, 0},
    {0, 0, 0, 0, 0, 0, 0, 0, 0, 0},
    {0, 0, 0, 0, 0, 0, 0, 0, 0, 0}
};

节点结构体设计:

typedef struct {
    int x, y;           // 栅格坐标
    int g;              // 起点到当前节点的代价
    int h;              // 当前节点到终点的启发式代价
    int f;              // 总代价 f = g + h
    struct Node* parent; // 父节点指针,用于回溯路径
} Node;

邻居扩展时,需要考虑四方向还是八方向:

  • 四方向:上下左右,代价为1
  • 八方向:上下左右+四个对角,直行代价1,对角代价√2≈1.414

我的建议:对于大多数工业AGV场景,用四方向+曼哈顿距离就够了。八方向虽然路径更短,但AGV在狭窄通道里走对角线容易撞到货架。除非你的机器人是全向移动的,否则别贪图那点路径长度。

下面我用SVG画一张A*算法的完整流程图,方便大家理解整个搜索过程:

A*算法搜索流程图 开始:起点加入Open List Open List 是否为空? 是 搜索失败,无可行路径 否 取出f值最小的节点n 节点n是否为终点? 是 回溯路径,输出结果 否 扩展节点n的邻居 循环 Open List用优先队列实现 可大幅提升搜索效率

最后,我给大家一个完整的A*算法伪代码,可以直接照着实现:

function AStar(start, goal, map):
    openList = PriorityQueue()  // 按f值排序
    closedList = Set()
    
    start.g = 0
    start.h = heuristic(start, goal)
    start.f = start.g + start.h
    openList.add(start)
    
    while openList is not empty:
        current = openList.pop()  // 取出f值最小的节点
        
        if current == goal:
            return reconstructPath(current)
        
        closedList.add(current)
        
        for each neighbor in getNeighbors(current, map):
            if neighbor in closedList:
                continue
            if neighbor is obstacle:
                continue
            
            tentative_g = current.g + distance(current, neighbor)
            
            if neighbor not in openList:
                neighbor.g = tentative_g
                neighbor.h = heuristic(neighbor, goal)
                neighbor.f = neighbor.g + neighbor.h
                neighbor.parent = current
                openList.add(neighbor)
            elif tentative_g < neighbor.g:
                // 找到更优路径,更新
                neighbor.g = tentative_g
                neighbor.f = neighbor.g + neighbor.h
                neighbor.parent = current
                // 更新优先队列中的位置
    
    return null  // 无路径

关键点总结:

  • 启发函数h(n)必须可采纳(不高估),才能保证最优解
  • Open List用优先队列实现,别用列表硬扛
  • 邻居扩展方式(四方向/八方向)要与机器人运动模型匹配
  • 栅格地图的分辨率直接影响路径质量——太粗路径不精确,太细计算量爆炸

嗯,A*算法就讲到这里。说实话,这个算法看起来简单,但真正用好它,需要在启发函数设计和工程实现上多下功夫。我当年第一次在嵌入式平台上跑A*,内存爆了三次才调好。你们回去后,建议先在小地图上手动模拟一遍搜索过程,把Open List和Closed List的变化画出来。相信我,画一遍比看十遍代码都管用。