☰
A*算法详解:启发式搜索与路径规划的完整实现
2026/10/17 5:59:14 网站建设 项目流程

如果只让我推荐一个搜索算法,无论是面试、竞赛、做游戏AI还是搞机器人导航,我都会毫不犹豫地说:A*。它不算难,但总有人学完就忘、用到就错——要么在最简单的网格地图上绕远路,要么把启发函数当成玄学,随便拍一个数进去就以为万事大吉。我见过不少人把BFS改一改就当A*用,结果搜索方向完全靠运气;也见过有人把h值设得比实际代价还大,跑出来的路径直接从“最优”变成了“看着终点猛冲、撞上墙再回头”。这篇文章我想把它讲透:从它到底解决了什么问题、每一步运行逻辑是什么,到手写一个可用版本,再到那些容易被忽略的工程细节。适合刚接触搜索算法的同学,也适合那些用过现成路径库但从未自己撸过一遍的人。

1. 为什么绕不开A*:从两个“不够聪明”的搜索方案说起

1.1 广度优先搜索:地毯式扫荡的代价

先说最基础的广度优先搜索(BFS)。它的核心思路很朴素:从起点出发,一层一层往外扩散,先把所有距离为1的点走完,再走距离为2的点,以此类推。如果每条边的代价一样,BFS确实能找到最短路径,因为它按“层数”严格推进,第一次碰到终点时,步数一定最省。

问题在于它完全没有任何方向感。哪怕终点就在起点斜对面三个格子,BFS也会把起点周围一整圈、再一整圈全部扩展掉,才会碰触到终点。想象你在一个400×400格的网格地图上搜索一条路径,目标只隔了三十个格子,但BFS会把起点周围所有半径内的可达点几乎都塞进搜索队列里。实测下来,这种“地毯式扫荡”动辄扩展几万个节点,而A*可能只探几百个节点就收工了。

BFS适用场景是那些“边权都相等、目标又足够近”的简单问题。一旦地图变大、路径变长,它就是效率灾难。

1.2 Dijkstra:有代价意识,却看不见终点

Dijkstra可以看作BFS的加权版本。它不再按层数排序,而是按“从起点走到当前节点已经付出的代价g”排序,每次从待处理列表里挑g最小的节点继续扩展。这个改进让它能处理不同代价的边,比如山地地图里爬山更耗时、河流穿越代价更高。

但它依然缺少对“终点在哪里”的任何感知。Dijkstra只知道“我走到这一步花了多少”,不知道“我还剩多远”。结果就是:如果起点附近有一片低代价区域,它会兴致勃勃地把那片区域几乎扫干净,因为那些节点的g值实在太小了。可它们很可能和终点方向完全相反。

换句话说,Dijkstra是一台“只低头看路,不抬头看天”的机器。它能保证最短路径,但为了这个保证,付出了大量无效探索的代价。

1.3 三种策略的直观对比

把BFS、Dijkstra和A*放在一起看,差异非常清楚:

算法节点扩展顺序由什么决定是否利用终点位置信息典型表现
BFS层数(距起点步数)否同心圆式扩散,稳健但臃肿
Dijkstra距起点累计代价g否围绕低代价区域蔓延,方向感弱
A*g + h,h是对终点代价的估计是从起点朝终点方向压缩搜索走廊

A*最核心的变化,就是在排序依据里多塞了一个h项。这个h是“对当前节点到终点还要花多少代价的估计”。有了它,搜索就有了方向感:算法不再像无头苍蝇一样四面八方铺开,而是倾向于先处理那些“既离起点不远、又离终点不远”的节点。

这也是A*被称为启发式搜索的原因。“启发”指的就是h这个估计值,它并不来自确定计算,而是来自你对问题结构的经验判断——对地图来说,最常见的启发就是“直线距离还剩多远”。

2. A*的核心机制:f = g + h 背后的搜索哲学

2.1 估值函数的拆解

A*里有且只有一个核心公式:

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

三个字母分别代表:

  • g(n):从起点走到节点n,已经付出的实际代价。这个值每个节点都不同,由搜索过程逐步累加得到。
  • h(n):从节点n到终点,还需要付出代价的估计值。它不用算准,但必须“合理”。
  • f(n):经过节点n的完整路径的总代价估计。A*每次扩展时,从待处理节点里选f最小的那个先下手。

可以用赶早班高铁来类比。假设你从家出发去高铁站,g就是你已经走完的路程,h是你看地图估摸着“应该还有多远”,f就是你判断“走这条路全程大概要多少”。你不会先跑去和火车站反方向的商场蹭一圈——除非你误以为那条路更近。A*做的事情本质上就是:永远优先尝试那些全程预估距离最短的方案。

2.2 open列表与close列表:搜索过程在维护什么

大多数A*实现里都有两个核心容器,一个是open表,一个是close表,虽然代码里不一定直接这样命名。

open表里装的是“已经被发现,但还没来得及扩展”的节点。它们按f值排序,每次取出f最小的那个出来扩展——这就是“待办清单”。close表里装的是“已经完成扩展”的节点,代表已经考察完毕,不会再动它们。

不过在现代实现里,close表通常不是一张显式的表,而是依靠g_score这样的字典来隐式判断:如果某个节点已经扩展结束,那它的g值已经确定是最优,后续再被其他路径发现时,新g值大概率不会比它还小,自然会被丢弃。

关键点在于:A扩展的是open表里f最小的节点,而不是g最小或h最小的节点。f值兼顾了过去和未来,所以A的行为既不像Dijkstra那样只向低代价区域倾斜,也不会像贪婪搜索那样完全被h牵着鼻子走。

2.3 一个5×5网格的完整推演

拿一个最简单的5×5无障网格说事。起点S在左上角(0,0),终点G在右下角(4,4),四方向移动,每格代价为1,启发函数用曼哈顿距离。

曼哈顿距离的计算方式:

h(n) = |nx - gx| + |ny - gy|

起点(0,0)的h是8,f=0+8=8。扩展它,得到两个邻居(1,0)和(0,1),它们的f都等于8(g=1,h=7)。所以A*会优先扩展其中任意一个,然后又会生成新的邻居。整个过程非常有意思:在无障碍的矩形网格里,绝大多数被扩展节点的f值都保持为8,直到真正抵达终点。

这说明A正在沿着“从起点到终点自然的方形走廊”推进。它没有把周围无用区域全部扫掉,而是紧紧贴着那条直线走廊展开。如果地图左下方有一大片空地,BFS会迫不及待地把它填满,A却只会象征性地看一眼,然后继续朝终点走。

这里还有一个细节值得注意:当f值相同的节点有多个时,A*的下一步扩展可能是任意的。不同实现可能选择g更小的、h更小的,或者干脆按坐标顺序取。这个“平局策略”对搜索效率影响很大,后面工程部分我会单独讲。

3. 启发函数的设计:决定“聪明程度”的那一半

3.1 可采纳性与一致性:最优性从哪来

很多教程会直接甩出两个数学名词:可采纳性和一致性。听起来吓人,其实不难理解。

可采纳性说的是:h(n)必须不大于从n到终点的真实最短代价。也就是说,你可以低估距离,但不能高估。只要h永远不超估,A*就能保证找到最短路径。

用打车类比,这就是“司机预估里程时只许说短了,不许说长了”。如果司机说“还有20公里”,但实际只要5公里,你可能听了他的话直接换路线,反而绕远。A*同理,h一旦超估,搜索就会过早相信某些看似f很小的节点,反而错过真正的捷径。

一致性是更强的条件:对任意节点n和它的后继节点n',必须满足

h(n) <= cost(n, n') + h(n')

这个条件保证“从起点看过去,每走一步,f值都不会回退”。一致性比可采纳性更强,满足一致性的h通常也满足可采纳性,而且实现起来更省心,因为不需要处理f值下降导致的重复更新问题。

3.2 曼哈顿、欧几里得、对角线:不同网格模型的h选择

h不是随便写的,它必须和你的移动模型匹配。我见过太多人把三种距离函数混着用,结果搜索效率一塌糊涂。

移动模型推荐h说明
四方向网格(上下左右)曼哈顿距离单格代价固定,h永远小于等于真实代价,可采纳
八方向网格(含斜对角)对角线距离对角移动代价约1.414,不能再用曼哈顿
连续空间/任意方向欧氏距离直线距离是真实路径的下界,天然可采纳

四方向网格里,真实移动一步至少需要横纵坐标差之和那么多次,所以曼哈顿距离就是可采纳的。八方向网格里,斜着走比横竖快,真实代价可能更多依赖对角线距离。如果此时仍用曼哈顿距离,h会比真实代价大,路径就会失去最优性。

网格路径规划中常见的“对角距离”公式:

dx = abs(nx - gx) dy = abs(ny - gy) h = max(dx, dy) + (sqrt(2) - 1) * min(dx, dy)

这个公式的含义是:尽量多走斜线,剩下的用直线补齐。它精确匹配八方向移动模型,是最贴近真实代价的可采纳启发之一。

3.3 h设置过高或过低,分别会发生什么

h设得太小,比如h恒等于0,A就退化成Dijkstra,搜索面积剧烈膨胀,效率优势完全消失。h设得太大,甚至超过真实代价,A虽然跑得飞快,但得到的路径不再是最优路径。也就是说,h的取值其实是效率和最优性之间的一个开关。

如果你带着“必须要最优解”的心态,就不要让h超估。如果你做的是游戏AI寻路,玩家根本不在乎你绕了0.3格的路,那就可以放权重A*,把h乘上一个1.2到3的系数。实测中这个技巧能大幅减少搜索节点数,路径代价稍有增加,但视觉上几乎看不出区别。

在代码里实现权重A*只需改一行:

f = g + w * h # 比如 w = 1.5

如果你用启发式搜索处理“大世界地图”或“实时寻路”,这个改动比任何花哨优化都立竿见影。

4. 手写一个能用的A*:地图路径规划的完整实现

4.1 场景定义与数据结构

我拿一个最经典的地图寻路场景来写完整实现。地图用二维数组表示,0代表可通行,1代表障碍,四方向移动,每步代价为1。

需要准备的数据结构其实很少:

  • 一个优先队列(用Python里的heapq),按f值排序。
  • 一个g_score字典,记录起点到每个节点的最小代价。
  • 一个came_from字典,记录每个节点的前驱,用于最后回溯路径。

这些就够了,不需要额外维护close表。

4.2 完整代码实现

import heapq def manhattan(a, b): """四方向网格的启发函数:曼哈顿距离""" return abs(a[0] - b[0]) + abs(a[1] - b[1]) def a_star(grid, start, goal): rows = len(grid) cols = len(grid[0]) # 优先队列里存 (f, 节点坐标),f 小的先弹出 open_heap = [] heapq.heappush(open_heap, (manhattan(start, goal), start)) # 起点到节点的最小代价 g_score = {start: 0} # 记录路径前驱 came_from = {} # 四方向移动 directions = [(1, 0), (-1, 0), (0, 1), (0, -1)] while open_heap: cur_f, cur = heapq.heappop(open_heap) # 处理优先队列里的过期节点 if cur_f != g_score[cur] + manhattan(cur, goal): continue if cur == goal: # 回溯路径 path = [] while cur in came_from: path.append(cur) cur = came_from[cur] path.append(start) path.reverse() return path for dx, dy in directions: nx, ny = cur[0] + dx, cur[1] + dy if 0 <= nx < rows and 0 <= ny < cols and grid[nx][ny] == 0: neighbor = (nx, ny) new_g = g_score[cur] + 1 if new_g < g_score.get(neighbor, float('inf')): came_from[neighbor] = cur g_score[neighbor] = new_g f = new_g + manhattan(neighbor, goal) heapq.heappush(open_heap, (f, neighbor)) return None # 无路径 if __name__ == "__main__": grid = [ [0, 0, 0, 0, 0], [0, 1, 0, 1, 0], [0, 1, 0, 0, 0], [0, 0, 1, 1, 0], [0, 0, 0, 0, 0], ] start = (0, 0) goal = (4, 4) path = a_star(grid, start, goal) print(path)

运行这段代码,会输出类似:

[(0, 0), (1, 0), (2, 0), (3, 0), (4, 0), (4, 1), (4, 2), (4, 3), (4, 4)]

4.3 关键代码段说明

判断过期节点的那一行特别重要:

if cur_f != g_score[cur] + manhattan(cur, goal): continue

因为优先队列不会自动删除旧记录。同一个节点可能因为g值被更新而多次入队,旧记录的f比当前最优f大。如果不加这个判断,A*会重复扩展已经过时的节点,严重拖慢搜索速度,甚至在某些实现里可能造成逻辑错误。

路径回溯的部分也很基础。从终点开始,不断通过came_from向前找前驱,直到回到起点,然后把列表反转,就是完整路径。注意我用的是闭区间,起点和终点都包含在里面。

这个版本虽然简单,但已经能应对大部分二维网格寻路需求。如果你想支持地图的出入点、动态代价、加权地图,只需修改两个地方:一是邻居遍历的代价,二是g_score的累加方式。框架完全不用动。

5. 换个场景:八数码问题里的A*

5.1 为什么状态空间搜索更像“解题”

网格寻路里,“状态”是坐标,转移是上下左右移动一步。但并非所有搜索问题都有这么直观的坐标体系。八数码是另一个经典例子:一个3×3的棋盘上有1到8共八个数字方块加上一个空格,你可以把空格和相邻方块交换,最终目标是让数字按顺序排列。

这里的“地图节点”不是坐标,而是整个棋盘排列。一个状态就是一个tuple,比如:

(1, 2, 3, 4, 5, 6, 7, 8, 0)

0表示空格。每一步滑动都对应一次“状态转移”。

八数码的状态空间非常大,全部排列数接近36万个。用BFS硬搜也能出结果,但效率满足不了多步难题。A*在这里重新体现了价值:只要给每个状态设计一个合理的h,搜索就能被极大地压缩。

5.2 八数码的状态表示与启发函数

八数码最常用的启发函数,是所有数字各自当前位置与目标位置的曼哈顿距离之和。

对状态中的每个数字val,它在棋盘里的位置索引是pos,目标位置索引是val - 1。比如数字5的目标索引是4,对应第2行第2列。然后累加两者的行差和列差:

def h8(state): dist = 0 for pos, val in enumerate(state): if val == 0: continue target_pos = val - 1 dist += abs(pos // 3 - target_pos // 3) + abs(pos % 3 - target_pos % 3) return dist

这个h是不是可采纳的?是的。因为每一步只能把某个数字移动一格,空格不参与计数。所有数字当前位置到目标位置的行列差之和,已经是最低必要步数的下界。真实步数一定不会小于这个值。

5.3 完整求解代码与实现细节

八数码A*的整体框架和地图寻路完全一样,区别只在于:

  • 节点状态是tuple。
  • 邻居生成逻辑变成“找空格位置,尝试交换上下左右”。
  • 每步代价固定为1。

还得多做一件事:检查初始状态是否可解。八数码不是所有排列都能还原,存在逆序数的奇偶性判定。跳过这一步,可能出现循环无解的搜索,白白跑到超时。

import heapq def solvable(state): tiles = [x for x in state if x != 0] inversions = 0 for i in range(len(tiles)): for j in range(i + 1, len(tiles)): if tiles[i] > tiles[j]: inversions += 1 return inversions % 2 == 0 def get_neighbors(state): neighbors = [] zero = state.index(0) row, col = zero // 3, zero % 3 for dr, dc in [(-1, 0), (1, 0), (0, -1), (0, 1)]: nr, nc = row + dr, col + dc if 0 <= nr < 3 and 0 <= nc < 3: npos = nr * 3 + nc lst = list(state) lst[zero], lst[npos] = lst[npos], lst[zero] neighbors.append(tuple(lst)) return neighbors def h8(state): dist = 0 for pos, val in enumerate(state): if val == 0: continue target_pos = val - 1 dist += abs(pos // 3 - target_pos // 3) + abs(pos % 3 - target_pos % 3) return dist def solve_8_puzzle(start): if not solvable(start): return None target = (1, 2, 3, 4, 5, 6, 7, 8, 0) heap = [] heapq.heappush(heap, (h8(start), start)) g_score = {start: 0} came_from = {} while heap: cur_f, state = heapq.heappop(heap) if cur_f != g_score[state] + h8(state): continue if state == target: path = [] while state in came_from: path.append(state) state = came_from[state] path.append(start) path.reverse() return path for nxt in get_neighbors(state): new_g = g_score[state] + 1 if new_g < g_score.get(nxt, float('inf')): came_from[nxt] = state g_score[nxt] = new_g heapq.heappush(heap, (new_g + h8(nxt), nxt)) return None

如果拿一个初始乱序作为输入,这段代码会返回从初始状态到目标状态的滑动序列。每个状态是一个排列,配合你的打印函数把每一步棋盘画出来,就能看到清晰的解题过程。八数码的A*实现几乎可以直接平移去做十五数码或类似滑动拼图问题,只需把尺寸参数和可解性判断稍作调整。

6. 工程落地时容易踩的坑

6.1 优先队列里的“过期节点”问题

这是首次手写A*最常踩的坑。因为g_score中的值会被更新,同一个节点可能被压入堆中多次。堆里堆外留下了大量“过时记录”,如果不加判断,搜索时间可能成倍增长。

我的做法很固定:取出堆顶节点时,重新计算它当前应有f值,如果和堆中记录的f不一致,直接跳过。这个操作代价极低,却能保证每个节点最多真正扩展一次。网格寻路和八数码的代码里我都加了这个判断,建议你无论如何都别删。

6.2 移动方向变了,启发函数却没跟上

八方向网格里如果沿用曼哈顿距离,要么得到非最优路径,要么搜索效率反而下降。解决方法是改用对角线距离。我再强调一次:启发函数必须和移动模型匹配,这是h设计的第一原则。

同样的问题也会出现在图中带权边的情况。如果地图里道路的代价不是恒为1,曼哈顿距离很可能高估真实代价,因为真实路径可能通过代价更低的“高速路”。这种场景更稳妥的h是把地图预处理得到的距离表缓存下来,但那是更高级的玩法,初级阶段先把距离函数选对就足够。

6.3 大型地图的搜索空间控制

地图一旦超过几千×几千,即使A*也有点吃力。实测中网格地图主要瓶颈在open表,堆里的节点可能有数十万个,内存和排序开销都很大。

有两个实用优化:一是双向搜索,从起点和终点同时扩展,相遇即结束,能显著缩小搜索空间;二是跳点搜索(JPS),专门适用于无权重网格地图,它会在直线方向上直接跳过同质区域,一次跳跃替代几十步扩展。JPS本质上是A*的加速变体,如果游戏地图特别大,值得花时间研究。

如果地图是静态的,还可以提前把所有关键路口的距离表算好,运行时只做“点到路口”的局部A*。这类预处理思路在做服务器端路径规划时很常见。

6.4 动态场景下的实时重规划思路

真实项目里,地图往往不是静态的。怪物会移动、车辆会变道、施工区会突然出现。每帧都从零跑一遍A*是不现实的。

常见做法是分层处理:先用A在静态地图上算出一条粗路径,角色沿着走;当遇到临时障碍时,只在障碍周围局部区域重新规划,绕过障碍后再回到原路径。整个体系里A依然承担最重要的全局规划职责,但不再是每帧都全量运行。

另外,当世界很大而起点终点变化频繁时,可以提前做一点“贪心预处理”:定期给地图打标记,把大量不可通行区域从搜索空间里剔除,让A只在有效区域工作。这些小技巧比单纯优化A代码本身更能提升整体表现。

6.5 平局策略的隐藏影响

当两个节点f值相同时,先扩展哪一个其实很讲究。简单实现往往按入队顺序来,但实测中这会带来很大的搜索面积浪费。

我常用的平局策略是“f相同优先h小”,即优先扩展看起来更接近终点的节点。这样搜索会更快推往终点方向,搜索面积更集中。在网格寻路里还有一个等价做法:比较g值,优先扩展g更大的节点,因为这代表它已经走了更多实际路程,离起点更远,更容易接近终点。

改动只要一行,但对性能的影响非常可观。在复杂迷宫地图上,好的平局策略能让扩展节点数减少百分之三十以上。我在上面给的网格代码里用的是默认的比较,实际调优时加上这个规则即可。


如果让我只推荐一个值得亲手实现一遍的搜索算法,我仍然会选A*。把它的代码从零写通之后,你会突然发现很多更“高级”的算法其实都是它的变体:JPS是加速版A*,D是动态环境下的A,一些路径规划算法的采样策略里也有它的影子。项目里遇到搜索问题,先想清楚状态怎么定义、h怎么设计,再开始写代码,通常不会跑偏。花一个下午把这篇里的两份代码跑通,比囫囵吞枣背十遍原理有用得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询