第4章 A*算法实战:启发函数、网格实现与性能优化

各位同学,今天我们来啃一块硬骨头——A*算法。说实话,我在做路径规划的头两年,总觉得A*就是个教科书上的玩具。直到有一次在量产项目中,车在停车场里绕了3分钟没找到出口,我才意识到:纸上谈兵的A*和工程可用的A*,中间差着十万八千里

这一章,我会把我在三个量产项目中踩过的坑、总结的经验,全部倒给你们。咱们从启发函数设计讲起,再到网格地图下的实现,最后聊聊那些让算法跑得飞快的优化技巧。

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

A*算法的核心公式很简单:f(n) = g(n) + h(n)。但就是这个h(n),决定了你的路径规划是「聪明」还是「智障」。

启发函数必须满足两个条件:

  • 可采纳性(Admissible):h(n) ≤ 实际代价。说白了,你不能高估到终点的距离。
  • 一致性(Consistent):h(n) ≤ c(n, m) + h(m)。这个保证了你不会走回头路。

我踩过的坑:有一次在仓库AGV项目中,我用了曼哈顿距离作为启发函数。结果车在货架间反复横跳,走了很多冤枉路。后来发现,因为货架布局是斜的,曼哈顿距离严重高估了实际路径长度——违反了可采纳性。

三种常用启发函数对比:

类型 公式 适用场景 我的评价
曼哈顿距离 |dx| + |dy| 四方向移动(上下左右) 简单但容易高估
欧几里得距离 √(dx² + dy²) 八方向移动 精度高但计算慢
对角线距离 max(|dx|, |dy|) 允许对角线移动 我项目中最常用

我的经验:在自动驾驶中,我习惯用加权欧几里得距离。比如在高速场景,纵向权重给1.2,横向给0.8。为什么?因为车在高速上更倾向于直行,转弯代价大。这个技巧让我在高速场景的规划效率提升了30%。

4.2 网格地图下的A*实现

好,理论讲完了,咱们直接上代码。这是我在一个园区无人配送项目中用过的实现,经过了多次迭代优化。

class AStarPlanner:
    def __init__(self, grid_map):
        self.grid = grid_map  # 0: 可通行, 1: 障碍物
        self.width = len(grid_map[0])
        self.height = len(grid_map)
        
    def heuristic(self, a, b):
        # 对角线距离,我习惯用这个
        dx = abs(a[0] - b[0])
        dy = abs(a[1] - b[1])
        return max(dx, dy) * 1.0
    
    def get_neighbors(self, node):
        # 八方向邻居
        neighbors = []
        for dx, dy in [(-1,0),(1,0),(0,-1),(0,1),
                       (-1,-1),(-1,1),(1,-1),(1,1)]:
            nx, ny = node[0] + dx, node[1] + dy
            if 0 <= nx < self.width and 0 <= ny < self.height:
                if self.grid[ny][nx] == 0:  # 可通行
                    # 对角线移动代价设为1.414
                    cost = 1.414 if dx != 0 and dy != 0 else 1.0
                    neighbors.append(((nx, ny), cost))
        return neighbors
    
    def plan(self, start, goal):
        open_set = []
        heapq.heappush(open_set, (0, start))
        came_from = {}
        g_score = {start: 0}
        f_score = {start: self.heuristic(start, goal)}
        
        while open_set:
            current = heapq.heappop(open_set)[1]
            
            if current == goal:
                return self.reconstruct_path(came_from, current)
            
            for neighbor, move_cost in self.get_neighbors(current):
                tentative_g = g_score[current] + move_cost
                
                if neighbor not in g_score or tentative_g < g_score[neighbor]:
                    came_from[neighbor] = current
                    g_score[neighbor] = tentative_g
                    f_score[neighbor] = tentative_g + self.heuristic(neighbor, goal)
                    heapq.heappush(open_set, (f_score[neighbor], neighbor))
        
        return None  # 无路径

注意:这段代码里有个细节——对角线移动的代价是1.414而不是1.0。我曾经在这个问题上吃过亏。如果对角线代价设成1.0,算法会倾向于走对角线,路径看起来像锯齿。你想想看,车在停车场里走锯齿路线,乘客不晕才怪。

4.3 工程中的性能优化技巧

说实话,上面那段代码在100x100的网格上跑没问题。但到了1000x1000的停车场地图,或者需要实时重规划的场景,你就得动点脑筋了。我总结了四个实战技巧:

4.3.1 使用二叉堆优化Open Set

Python的heapq就是二叉堆实现,插入和删除都是O(log n)。但要注意:不要频繁更新堆中的元素。我见过有人每次更新g_score就把整个堆重建一遍——那还不如用线性搜索。

4.3.2 引入Jump Point Search (JPS)

这个技巧我强烈推荐。JPS的核心思想是:在开阔区域,很多节点是冗余的。比如一条直道上,你只需要检查起点和终点,中间的点都可以跳过。我在一个物流仓库项目中,用JPS把规划时间从200ms降到了15ms。

JPS的核心规则:

  • 如果当前节点是直线移动,只检查方向上的第一个障碍物或跳点
  • 如果当前节点是对角线移动,需要检查两个直线方向
  • 跳点就是那些「不得不转弯」的位置

4.3.3 分层规划(Hierarchical Planning)

嗯,这个技巧说白了就是「大事化小」。先把地图分成若干区域,规划出区域间的路径,再在每个区域内做精细规划。我在一个园区项目中,地图有2000x2000网格,用了三层分层后,规划时间从秒级降到了毫秒级。

4.3.4 缓存与预计算

对于固定障碍物的场景,可以预计算所有节点对之间的路径。但注意:不要全量存储,那会吃掉你的内存。我习惯的做法是:

  • 只缓存热点区域(比如停车场出入口)的路径
  • 使用LRU淘汰策略,保留最近最常用的路径
  • 对于对称路径,只存储单向,反向时直接反转

我的一个实战技巧:在动态障碍物场景中,不要每次重新规划整条路径。我习惯用局部重规划——只重新规划障碍物附近的一段路径,其他部分保持不变。这样既保证了实时性,又避免了路径抖动。我曾经在一个项目中,用这个技巧把重规划频率从10Hz提到了50Hz。

4.4 避坑指南

最后,分享几个我亲身踩过的坑:

  • 启发函数权重过大:我曾经把h(n)的权重设成2.0,结果路径直接穿墙而过。记住,权重超过1.0就违反了可采纳性。
  • 忽略地图边界:有一次车规划路径到了地图外面,因为我没有检查邻居节点的边界。嗯,这个bug让我debug了一整天。
  • Open Set重复插入:同一个节点可能被多次插入堆中,如果不做去重,堆会越来越大。我习惯用closed_set来标记已处理的节点。
  • 浮点数精度问题:在比较f_score时,直接用==可能会出问题。我习惯用abs(a - b) < 1e-6来比较。

好了,这一章的内容就到这里。A*算法看似简单,但真正用好它,需要你在工程实践中不断打磨。下一章我们会讲Dijkstra算法和它的变种,到时候你会发现,很多优化思路是相通的。

记住我的一句话:算法是死的,场景是活的。没有最好的算法,只有最适合场景的算法。