4、A*算法详解:启发式函数设计、网格地图实现、代码实战与优化技巧

各位同学,欢迎来到第四章。前面几章我们把路径规划的基础概念和搜索框架聊透了。今天这一章,是很多人入坑路径规划的第一站——A*算法。

说实话,A* 这玩意儿,教科书上讲得挺玄乎。什么「最优性」、「可采纳性」,听着就头大。但说白了,它就是 Dijkstra 算法的一个「开挂」版本。Dijkstra 像个无头苍蝇一样四面八方乱搜,而 A* 呢,它知道终点大概在哪个方向,所以搜起来更聪明、更快。

我当年刚入行时,第一个项目就是给仓储机器人做导航。那时候不懂,直接用 Dijkstra,结果地图一大,机器人原地愣半天才动。后来换成 A*,那感觉,就像从绿皮火车换成了高铁。嗯,今天我就把当年踩过的坑和积累的经验,一股脑倒给你们。

4.1 启发式函数:A* 的灵魂

A* 的核心公式很简单:f(n) = g(n) + h(n)

  • g(n):从起点到当前节点 n 的实际代价。这个很实在,走一步算一步。
  • h(n):从当前节点 n 到终点的估计代价。这就是「启发式函数」,是 A* 的灵魂。
  • f(n):总估计代价。A* 每次从 open list 里挑 f 值最小的节点来扩展。

你想想看,如果 h(n) = 0,那 A* 就退化成了 Dijkstra。如果 h(n) 恰好等于真实最短距离,那 A* 就一路直奔终点,效率最高。但现实世界中,我们很难知道真实距离,所以只能「估计」。

启发式函数的设计原则:

  • 可采纳性(Admissible):h(n) 必须 ≤ 真实代价。这样才能保证找到最短路径。说白了,你不能太乐观,但可以稍微悲观一点。
  • 一致性(Consistent):也叫单调性。要求 h(n) ≤ 代价(n, n') + h(n')。满足一致性,A* 就不会重复检查同一个节点,效率更高。

常见的启发式函数有哪些?我给你们列个表,一目了然:

移动方式 启发式函数 公式 适用场景
四方向(上下左右) 曼哈顿距离 |dx| + |dy| 城市街区、网格地图
八方向(可斜走) 对角距离 |dx| + |dy| + (√2 - 2) * min(|dx|, |dy|) 游戏地图、机器人
任意方向 欧几里得距离 √(dx² + dy²) 连续空间、无人机

我的个人习惯:在网格地图中,如果允许斜走,我一般用对角距离。它比曼哈顿距离更准,又比欧几里得距离计算快。记住,启发式函数越接近真实代价,A* 搜得越快。

4.2 网格地图实现:从理论到代码

好了,理论讲完了,咱们来点实在的。网格地图是 A* 最经典的应用场景。怎么把一张地图变成 A* 能理解的数据结构?

我习惯用一个二维数组来表示地图。0 表示可通行,1 表示障碍物。每个格子就是一个节点。

# 一个简单的网格地图示例
# 0 = 空地, 1 = 障碍物
grid_map = [
    [0, 0, 0, 0, 1],
    [0, 1, 1, 0, 1],
    [0, 0, 0, 0, 0],
    [0, 1, 1, 1, 0],
    [0, 0, 0, 0, 0]
]

每个节点需要记录哪些信息?我给你们一个结构体:

class Node:
    def __init__(self, x, y):
        self.x = x          # 网格 x 坐标
        self.y = y          # 网格 y 坐标
        self.g = float('inf')  # 从起点到当前节点的代价
        self.h = 0          # 启发式估计代价
        self.f = float('inf')  # 总代价 f = g + h
        self.parent = None  # 父节点,用于回溯路径

核心逻辑就三步:

  1. 初始化:把起点放入 open list,g=0,计算 h 和 f。
  2. 循环搜索:从 open list 中取出 f 值最小的节点。如果它就是终点,结束。否则,扩展它的邻居节点。
  3. 更新节点:对于每个邻居,计算新的 g 值。如果比原来的小,就更新 g、f 和 parent,并加入 open list。

我曾经踩过的坑:有一次我忘了检查邻居节点是否在 closed list 里,结果算法陷入了死循环。记住,已经处理过的节点要放进 closed list,不要再碰它。除非你用了更高级的变种算法。

4.3 代码实战:手写一个 A*

光说不练假把式。咱们直接上代码。这是一个完整的 A* 实现,我加了详细的注释。

import heapq

def astar(grid, start, end):
    rows, cols = len(grid), len(grid[0])
    open_list = []
    closed_set = set()

    # 起点
    start_node = Node(start[0], start[1])
    start_node.g = 0
    start_node.h = manhattan_distance(start, end)
    start_node.f = start_node.g + start_node.h
    heapq.heappush(open_list, (start_node.f, id(start_node), start_node))

    # 方向:上下左右 + 四个斜角
    directions = [(-1,0), (1,0), (0,-1), (0,1), (-1,-1), (-1,1), (1,-1), (1,1)]

    while open_list:
        _, _, current = heapq.heappop(open_list)

        if (current.x, current.y) == end:
            return reconstruct_path(current)

        closed_set.add((current.x, current.y))

        for dx, dy in directions:
            nx, ny = current.x + dx, current.y + dy

            # 边界检查
            if nx < 0 or nx >= rows or ny < 0 or ny >= cols:
                continue
            # 障碍物检查
            if grid[nx][ny] == 1:
                continue
            # 是否已处理
            if (nx, ny) in closed_set:
                continue

            # 计算代价
            move_cost = 1 if dx == 0 or dy == 0 else 1.414  # 斜走代价大一点
            new_g = current.g + move_cost

            neighbor = Node(nx, ny)
            neighbor.g = new_g
            neighbor.h = manhattan_distance((nx, ny), end)
            neighbor.f = neighbor.g + neighbor.h
            neighbor.parent = current

            heapq.heappush(open_list, (neighbor.f, id(neighbor), neighbor))

    return None  # 没找到路径

def manhattan_distance(a, b):
    return abs(a[0] - b[0]) + abs(a[1] - b[1])

def reconstruct_path(node):
    path = []
    while node:
        path.append((node.x, node.y))
        node = node.parent
    return path[::-1]

一个小技巧:我在 open list 里用了 id(neighbor) 作为第二排序键。这是为了防止两个节点的 f 值相等时,heapq 去比较 Node 对象本身而出错。你想想看,如果 f 值一样,谁先谁后?加个 id 就解决了。

4.4 优化技巧:让 A* 飞起来

基础版 A* 能用,但不够快。尤其是在大地图上,open list 里可能塞了几万个节点。怎么优化?我总结了几个实战技巧。

4.4.1 使用二叉堆(Priority Queue)

这个不用多说,上面代码里已经用了 heapq。千万别用列表加排序,那时间复杂度是 O(n log n),而堆是 O(log n)。在大地图上,差距是数量级的。

4.4.2 打破对称性(Tie-Breaking)

你有没有遇到过这种情况?地图上有很多条等长的最短路径,A* 就在那里来回试探,效率极低。为什么会这样?因为它们的 f 值一样,堆不知道该选谁。

解决办法:给启发式函数加一个微小的权重。比如:

h = manhattan_distance(a, b) * (1 + 0.001)

这样,离终点更近的节点会优先被扩展。路径长度几乎不变,但搜索速度能快好几倍。

我个人的经验:打破对称性是我在项目中用得最多的优化。有一次,一个 1000x1000 的地图,不加这个优化,A* 要跑 2 秒。加了之后,0.1 秒就出结果了。你想想看,这差距有多大。

4.4.3 跳点搜索(JPS)

如果你觉得 A* 还不够快,可以试试 JPS。它是 A* 的一种变体,专门针对网格地图。核心思想是:只在「关键点」处扩展,跳过那些「一眼就能看穿」的直线区域。

JPS 的实现比较复杂,我这里不展开。但记住一句话:如果你的地图是均匀的网格,且障碍物不多,JPS 比 A* 快 10 倍以上。

4.4.4 分层搜索

对于超大场景,比如整个城市的导航,别指望一个 A* 搞定。先把地图分块,在高层规划大方向,在低层细化路径。这就是分层 A*(Hierarchical A*)。

避坑指南:我曾经在一个项目中,地图有 5000x5000 个格子。直接用 A*,内存直接爆了。后来改成分层搜索,先粗后细,问题就解决了。记住,算法再好,也要考虑工程可行性。

4.5 总结与思考

好了,这一章的内容就到这里。我们来回顾一下:

  • A* 的核心是启发式函数,它决定了搜索的方向和效率。
  • 网格地图实现时,注意节点的数据结构、open list 和 closed list 的管理。
  • 优化技巧:二叉堆、打破对称性、JPS、分层搜索。这些是实战中真正能提升性能的手段。

最后,留一个思考题给你们:如果地图是动态变化的,比如有移动的障碍物,A* 还能用吗?该怎么改?下一章我们会聊到 D* Lite 算法,它就是专门解决这个问题的。

嗯,今天就到这里。代码我已经放在课程配套的资源里了,你们自己去跑一跑,改一改。遇到问题,随时在群里问我。