从零手写A*寻路:启发式函数与优先队列实战

发布时间:2026/10/16 1:31:03
从零手写A*寻路:启发式函数与优先队列实战 1. 为什么我要从零手写一遍A*寻路第一次接触A是在做一个小型网格地图Demo的时候。当时地图不大几十乘几十的格子用最简单的广度优先搜索也能跑但一旦把地图拉到几百乘几百或者加上不同地形代价BFS和Dijkstra就开始明显卡顿。后来换成A同样的地图搜索的节点数直接少了一个数量级那种“原来启发式这么香”的感觉至今记得很清楚。这篇内容就是把我自己从零实现A的完整过程整理出来。核心关键词是**A寻路**、启发式函数、开放列表与关闭列表、网格地图、路径代价。我会讲清楚A*到底在解决什么问题、为什么它比盲目搜索快、启发式函数怎么选、代码怎么写、踩过哪些坑。适合刚学完基础图搜索、想动手实现一个能跑起来的寻路算法的朋友也适合已经用过现成库、但想搞明白内部机制的开发者。A本质上是一种在带权图上找最短路径的算法。它不像BFS那样一圈一圈均匀扩散而是给每个待扩展节点算一个“总代价估计”优先扩展看起来最有希望通向终点的节点。这个“估计”由两部分组成从起点走到当前节点已经花掉的真实代价加上从当前节点到终点还需要花多少的估计代价。前者保证不走回头路后者引导搜索方向。两者相加就是A的灵魂公式。我见过不少人第一次写A*代码能跑但路径绕远、效率低甚至在某些地图上找不到路。问题往往不在算法本身而在几个细节启发式函数选得不对、开放列表用数组导致每次找最小值都很慢、没有正确处理“找到更优路径后更新节点”的逻辑。这些我都会在下面逐一拆开讲。2. A*的核心思路与方案选型2.1 从BFS到A*搜索策略的进化逻辑要理解A*得先知道它站在谁的肩膀上。BFS按层扩展保证在无权图上找到最短路径但它对方向毫无感知像水波一样向四周蔓延。Dijkstra在带权图上按累计代价从小到大扩展能保证最短路径但同样是“盲人摸象”终点在右边它左边也要探一大片。A*的改进点在于引入了一个启发式函数h(n)用来估计从节点n到终点的代价。每次从开放列表里取节点时不再只看已经走过的代价g(n)而是看f(n)g(n)h(n)。这样那些“看起来离终点更近”的节点会被优先处理搜索范围自然收窄。这里有个关键前提h(n)不能高估真实代价。如果高估了A可能错过最优路径。这个性质叫可采纳性。只要h(n)不高估A就能保证找到最短路径。如果h(n)还满足一致性单调性那实现上会更简单每个节点第一次被取出时就是最优的。我选择用网格地图来演示因为网格最直观每个格子就是一个节点上下左右或八方向就是边。实际项目中可能是路网、导航网格、状态空间但核心逻辑完全一样。2.2 启发式函数怎么选曼哈顿、欧几里得与对角距离启发式函数的选择直接决定A*的表现。常见的有三种曼哈顿距离h |dx| |dy|。适合只能上下左右移动的网格。它不会高估因为实际走法至少需要这么多步。欧几里得距离h sqrt(dx² dy²)。适合可以任意方向移动的场景。它是直线距离也不会高估。对角距离h (dx dy) (sqrt(2) - 2) * min(dx, dy)。适合八方向移动其中直走代价1斜走代价sqrt(2)。选错启发式函数会怎样举个例子如果地图只能上下左右走你却用欧几里得距离虽然不会高估但估计值偏小搜索会退化成接近Dijkstra效率下降。反过来如果八方向移动却用曼哈顿距离它会高估斜走能省下的代价可能找不到最优路径。我一般会先明确移动规则再选对应的启发式。网格四方向用曼哈顿八方向用对角距离连续空间用欧几里得。这是最稳妥的组合。2.3 数据结构选型为什么优先队列是标配A*每次都要从开放列表里取f值最小的节点。如果用普通数组每次查找最小值要遍历一遍时间复杂度O(n)。地图一大这个开销非常可观。标准做法是用优先队列最小堆。插入和取出最小值的复杂度都是O(log n)。在Python里可以用heapq在C里用priority_queue在Java里用PriorityQueue。但要注意很多语言的标准优先队列不支持“降低已有元素的优先级”这个操作。当发现一条更优路径到达某个已在开放列表中的节点时需要更新它的f值。常见处理方式有两种一是允许重复插入取出时判断该节点是否已经关闭或已有更优记录跳过过期条目二是自己实现一个支持索引的堆。前者实现简单代价是堆里可能有冗余元素但实际影响不大。我通常用第一种代码简洁不容易出错。关闭列表用来记录已经扩展过的节点避免重复处理。可以用哈希表或布尔数组。网格地图里用二维数组最省事。3. 手写A*的完整实操过程3.1 地图表示与节点定义我用一个二维列表表示网格0表示可走1表示障碍。起点和终点用坐标表示。每个节点需要记录坐标、g值、h值、f值、父节点。在Python里可以用一个简单的类也可以用元组加字典。为了代码清晰我用类class Node: def __init__(self, x, y): self.x x self.y y self.g float(inf) self.h 0 self.f float(inf) self.parent None地图和起终点grid [ [0, 0, 0, 0, 0], [0, 1, 1, 1, 0], [0, 0, 0, 1, 0], [0, 1, 0, 0, 0], [0, 0, 0, 0, 0], ] start (0, 0) goal (4, 4)这里有个细节节点对象在开放列表和关闭列表里会被多次引用所以不要每次重新创建而是用字典缓存保证同一个坐标只有一个节点对象。否则更新g值时会出现两个对象不一致的问题。3.2 启发式函数与代价计算四方向移动代价为1。启发式用曼哈顿距离def heuristic(a, b): return abs(a[0] - b[0]) abs(a[1] - b[1])八方向移动时代价和对角距离def heuristic_diag(a, b): dx abs(a[0] - b[0]) dy abs(a[1] - b[1]) return (dx dy) (1.414 - 2) * min(dx, dy)注意斜走代价是sqrt(2)约1.414。如果斜走代价设为1启发式也要相应调整否则不一致。3.3 主循环开放列表、关闭列表与路径回溯完整的主循环逻辑import heapq def a_star(grid, start, goal): rows, cols len(grid), len(grid[0]) open_list [] heapq.heappush(open_list, (0, start)) nodes {} start_node Node(*start) start_node.g 0 start_node.h heuristic(start, goal) start_node.f start_node.h nodes[start] start_node closed set() while open_list: _, current_pos heapq.heappop(open_list) if current_pos in closed: continue current nodes[current_pos] if current_pos goal: path [] while current: path.append((current.x, current.y)) current current.parent return path[::-1] closed.add(current_pos) for dx, dy in [(0,1),(0,-1),(1,0),(-1,0)]: nx, ny current.x dx, current.y dy if not (0 nx rows and 0 ny cols): continue if grid[nx][ny] 1: continue if (nx, ny) in closed: continue tentative_g current.g 1 neighbor nodes.get((nx, ny)) if neighbor is None: neighbor Node(nx, ny) neighbor.h heuristic((nx, ny), goal) nodes[(nx, ny)] neighbor if tentative_g neighbor.g: neighbor.g tentative_g neighbor.f neighbor.g neighbor.h neighbor.parent current heapq.heappush(open_list, (neighbor.f, (nx, ny))) return None这段代码有几个关键点。第一堆里存的是(f, pos)元组取出后要判断pos是否已在closed里因为同一个位置可能被多次插入。第二只有当新路径的g值更小时才更新父节点和f值并重新入堆。第三路径回溯从终点沿parent一路回到起点最后反转。3.4 路径平滑与可视化输出找到路径后如果是对角移动路径可能贴着障碍物拐角走实际移动时可能撞墙。这时需要做路径平滑比如检查两个不相邻节点之间是否有直线可通有就跳过中间点。可视化可以用简单的字符打印def print_path(grid, path): for i in range(len(grid)): row for j in range(len(grid[0])): if (i, j) start: row S elif (i, j) goal: row G elif (i, j) in path: row * elif grid[i][j] 1: row # else: row . print(row)这样能直观看到路径是否合理。我习惯在调试时把每次扩展的节点也打印出来观察搜索范围是否符合预期。4. 常见问题与排查技巧实录4.1 路径不是最短启发式高估与代价不一致这是最常见的问题。表现是A*找到了一条路但明显绕远。原因通常是启发式函数高估了真实代价。比如八方向移动却用曼哈顿距离斜走一步实际代价1.414曼哈顿估计却是2高估了算法就可能提前锁定一条非最优路径。排查方法把启发式函数暂时改成返回0看结果是否变短。如果变短了说明原来的启发式高估了。解决就是换成可采纳的启发式或者把启发式乘以一个小于1的系数但这样就不再保证最优只是近似。另一个原因是移动代价和启发式不匹配。比如斜走代价设为1但启发式按1.414算低估了不会导致非最优但效率会下降。反过来就会高估。4.2 搜索太慢开放列表实现与重复节点处理如果发现A*扩展了大量节点甚至比Dijkstra还慢先检查开放列表是不是用了普通列表。每次min()遍历是O(n)节点一多就崩。换成heapq后通常立竿见影。其次检查是否重复创建节点对象。如果每次遇到邻居都new一个Node那么同一个坐标可能有多个对象g值更新只影响其中一个另一个还在堆里导致重复扩展。用字典缓存节点可以避免。还有一点关闭列表的判断要在取出堆元素之后做而不是在插入之前。因为一个节点可能先以较大的f值入堆后来找到更优路径又以较小f值入堆。如果插入前就判断关闭会漏掉更优路径。4.3 找不到路径边界、障碍与开放列表耗尽找不到路径时先确认起点和终点是否本身就在障碍上。然后检查边界判断特别是负索引。Python里grid[-1]是最后一行如果不做0 nx rows判断会错误地绕到地图另一边。开放列表耗尽还没到终点说明终点不可达。这时可以返回None或者返回一个空路径。实际项目中可能还需要区分“不可达”和“搜索超时”。4.4 内存占用过高节点缓存与堆冗余优化当地图很大时nodes字典会保存所有访问过的节点内存占用上升。如果只需要路径可以在找到终点后释放。堆里的冗余条目也会占内存但通常不是瓶颈。如果确实需要优化可以用更紧凑的数据结构比如用一维数组代替字典用整数索引代替坐标元组。但在大多数应用场景下标准实现已经够用。问题现象可能原因排查方法解决方式路径绕远启发式高估将h设为0对比换可采纳启发式搜索慢开放列表用数组检查取最小值方式改用优先队列重复扩展节点对象重复创建打印节点id用字典缓存节点找不到路边界或障碍判断错误检查坐标范围修正边界条件内存高节点缓存过大统计节点数优化数据结构注意启发式函数返回0时A*退化为Dijkstra可以用来验证最短路径是否正确。5. 进阶优化与实战经验5.1 权重启发式速度与最优的权衡如果对路径最优不是绝对要求可以把启发式乘以一个大于1的权重比如1.2或1.5。这样搜索更有方向性扩展节点更少速度更快但可能找不到最短路径。这种叫加权A*在游戏寻路里很常见因为玩家通常感觉不到几个格子的差距但帧率提升很明显。我一般会先跑标准A*如果性能不够再逐步调大权重观察路径质量。权重超过2以后路径可能明显绕远需要谨慎。5.2 双向A*与跳点搜索简介双向A*从起点和终点同时搜索相遇时合并路径。理论上能减少搜索空间但实现复杂且两个方向的启发式要协调。跳点搜索JPS是针对网格的优化利用对称性跳过大量中间节点在规则网格上能大幅提速。但JPS对地图规则有要求障碍物分布太碎时优势不明显。我的建议是先把标准A写对、调优再考虑这些进阶方法。很多场景下标准A加优先队列已经足够。5.3 动态障碍与重新规划如果障碍物会动态出现比如某个格子突然变成不可走已经算好的路径可能失效。简单做法是重新跑一次A*。如果变化不大可以用D* Lite等增量算法但实现复杂度高。实际项目中如果地图不大重新规划通常可以接受。我踩过的一个坑是动态障碍出现后只更新了地图没有清空关闭列表和节点缓存导致A*认为旧路径仍然有效。正确做法是每次重新规划都重置搜索状态。5.4 代码组织与测试建议把A*封装成函数或类输入地图、起点、终点输出路径。测试时至少覆盖直线路径、有障碍绕行、不可达、起点终点相同、边界情况。可以用单元测试固定几个小地图断言路径长度和预期一致。调试时打印搜索过程很有帮助。我习惯用一个二维数组记录每个节点的f值最后打印出来能直观看到搜索的“形状”是否符合启发式的引导方向。提示如果发现搜索范围呈圆形扩散说明启发式太弱如果呈一条窄带说明启发式引导很强。6. 我实际使用中的几点体会A*的代码不长但细节很多。我最初写的时候因为堆里存了重复节点导致同一个格子被扩展好几次路径虽然对但速度慢了一半。后来加上closed判断性能立刻正常。还有一次启发式用了欧几里得距离但移动只能上下左右结果搜索范围比预期大很多换成曼哈顿后节点数少了将近七成。启发式函数的选择没有绝对标准关键是和移动规则匹配。四方向用曼哈顿八方向用对角距离这是最稳的。如果拿不准先把h设为0跑一遍确认最短路径长度再换启发式对比结果。优先队列是A*的标配不要用数组硬扛。Python的heapq虽然不支持降低优先级但重复插入加跳过过期条目的方式足够简单可靠。节点缓存用字典保证同一坐标只有一个对象这个习惯能避免很多诡异bug。最后分享一个小技巧在网格地图上如果障碍物是静态的可以预先计算每个格子到终点的真实距离作为启发式这样A*几乎直接沿最短路径走扩展节点极少。当然预计算本身有开销适合多次查询同一终点的场景。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询