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 # 父节点,用于回溯路径
核心逻辑就三步:
- 初始化:把起点放入 open list,g=0,计算 h 和 f。
- 循环搜索:从 open list 中取出 f 值最小的节点。如果它就是终点,结束。否则,扩展它的邻居节点。
- 更新节点:对于每个邻居,计算新的 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 算法,它就是专门解决这个问题的。
嗯,今天就到这里。代码我已经放在课程配套的资源里了,你们自己去跑一跑,改一改。遇到问题,随时在群里问我。