第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算法和它的变种,到时候你会发现,很多优化思路是相通的。
记住我的一句话:算法是死的,场景是活的。没有最好的算法,只有最适合场景的算法。