☰
A*与贪婪最佳优先搜索:Python启发式寻路实战
2026/9/30 3:46:53 网站建设 项目流程

1. 从一个卡顿的寻路需求说起:暴力搜索为什么不够用

先说结论:如果你的寻路模块用的是广度优先搜索(BFS)或者 Dijkstra,在 20×20 的网格上跑没问题,一旦地图涨到 200×200,单次计算吃掉的耗时就会变得很难接受。我第一次认真研究启发式搜索,就是因为一个贪吃蛇类的格子地图项目:蛇每走一步,都要给"追击目标"算一次路径,用 BFS 跑起来一开始还挺顺,地图开到 150×150 之后,帧率直接从 60 掉到 20 出头,玩家感知非常明显。

当时我的第一反应是优化代码——把list.pop(0)换成collections.deque,把二维坐标压成一维整数,把邻居展开的手写循环改成预计算表。这些优化确实有效,但加起来也就快了两三倍,治标不治本。真正让耗时降下来的,是换掉了搜索策略本身:从"不带任何方向感的均匀扩散"换成"带着目标信息往前冲"。这就是**贪婪最佳优先搜索(Greedy Best-First Search)**和 **A*(A-Star)**要解决的问题。

这篇文章会从算法原理讲到 Python 代码实现,包含可以直接复制运行的两份实现、一张针对性设计的"陷阱地图"、一份我实测的对比数据,以及几个我在实际写代码时踩过的坑。适合已经会写 BFS、想进一步把寻路做成工程可用模块的读者。即便你只是刚开始学 Python,只要理解"队列"和"字典",后面代码里的每一行我都会解释清楚。

1.1 BFS 和 Dijkstra 的真实开销在哪里

BFS 的扩展方式是"以起点为圆心,一圈一圈往外扩"。在无障碍的开阔地图上,它扩展到终点时,搜索过的区域大致是一个半径等于路径长度的圆——面积是 πr² 量级。也就是说,路径长度翻倍,搜索的节点数变成四倍。这就是它在中等规模地图上崩溃的根本原因:它完全不关心你在哪个方向,所有方向的探索优先级完全一样。

Dijkstra 和 BFS 的思路是同一个,区别只在于优先队列里排序用的键从"层数"换成了"从起点累积的真实代价 g"。在带权图里这是必要的,比如道路有快有慢、地形有难走有好走,你必须先走那些代价小的地方。代价是它比 BFS 还慢一点,因为每次出队都要比较浮点数,堆操作也更多。

它们在"终点离起点很近、但地图很大"的场景里表现极差。举个直观的例子:地图 1000×1000,起点在左上角,终点在左上角右边十格的位置。BFS 也会从起点一圈圈扩,扩到半径为 10 的时候才碰到终点,这时候已经处理了几百个节点,其实完全没必要。而如果你告诉算法一句"终点的方向在东边",它十步就走完了。

1.2 启发式函数:把"领域常识"变成可计算的数字

启发式搜索的核心就一句话:给每个待扩展的节点打一个分,分数里包含"它离终点有多近"的猜测。这个猜测由一个函数算出来,叫启发函数,记作 h(n)。

h(n) 的意义是"从节点 n 到终点还需要多少代价的估计值"。它不是真实值,只是估计,所以叫"启发式"。它可以来自几何距离(网格地图上的直线距离),也可以来自更抽象的领域知识(比如拼图问题里"放错位置的块数")。关键在于:这个函数必须是廉价可算的。如果算 h(n) 本身就要跑一次搜索,那还不如直接暴力。

我把 h 理解成"给算法发的一张地图"或者"一个方向感"。BFS 和 Dijkstra 的 h(n) 恒等于 0,等于闭上了眼睛;贪婪最佳优先搜索只看 h,等于只看指南针不看脚下;A* 把两者相加,等于既看指南针也记账。

1.3 可采纳性与一致性:h 的两条硬约束

写 h 的时候有两个术语必须搞明白,否则你会在"为什么我的 A* 找不到最短路径"这个问题上浪费一整晚。

可采纳性(admissible):h(n) 永远不能高估从 n 到终点的真实最小代价。只要不高估,A* 保证找到最优解。直白点说,你猜"还要走 10 步",实际最少要走 12 步,这是允许的(低估没关系);但如果你猜"还要走 10 步",实际最少只要 7 步,那 A* 就可能提前认定某条路很好,从而错过真正的最短路。

一致性(consistent,也叫单调性):对任意一条从 n 到 n' 的边,满足 h(n) ≤ cost(n, n') + h(n')。通俗说就是"估计值不能突变",从一个格子走到相邻格子,h 的下降幅度不能超过这一步的真实代价。一致性比可采纳性更强——满足一致性就一定满足可采纳性,反之不成立。

一致性带来的实际好处非常具体:如果 h 是一致的,那么一个节点第一次被从优先队列里取出来时,它的 g 就已经是最小的了,之后再也不需要重新打开它。这直接决定了你的代码里要不要写 reopen 逻辑。曼哈顿距离、欧几里得距离、八方向距离这几个常用的 h 都是一致的,所以绝大多数网格寻路代码里那个closed集合可以放心地"进去就再也不出来"。但如果你用了奇怪的、带惩罚项的 h,就未必了。

2. 贪婪最佳优先搜索:用 h 单腿走路,快但会近视

贪婪最佳优先搜索的规则只有一条:每次从开放列表里取出 h 值最小的节点进行扩展。它完全不记录"我已经走了多远",只看"我猜离终点还有多远"。这种极端的取舍,让它在某些地图上快得惊人,在另一些地图上又会做出让人哭笑不得的选择。

理解它的最好方式是想一个迷路的人:他手里有一个能显示"距离目的地直线距离"的仪器,于是他每到一个路口,就朝着数字变小的方向走。在开阔平原上这招非常好用;但如果有条河横在中间,他可能会沿着河岸一路走到距离显示最小的地方,然后发现过不去,只能掉头,白白走了一大段。

2.1 算法骨架与 heapq 的实现细节

Python 里做优先队列的标准做法是heapq。这里有个几乎所有人第一次都会踩的坑:heapq只提供最小堆,而且没有decrease-key(降低键值)操作。标准库的实现方式是"允许同一个节点被多次压入堆,出堆的时候检查一下是不是已经处理过了"。这个细节直接决定了你的代码结构。

另一个坑是关于元组比较的。堆里的元素如果是(h值, 节点)这样的元组,当两个元素的 h 值相同时,Python 会去比较第二个元素。如果你的节点是(r, c)这样的元组,比较没问题;但如果节点是一个自定义对象(比如class Node),一旦 h 值打平,Python 就会尝试比较对象,直接抛TypeError: '<' not supported between instances of 'Node' and 'Node'。解决办法是塞一个自增计数器进去,把元组变成(h值, 计数器, 节点),这样永远比不到第三个元素。

2.2 一个"口袋地形"实验:贪婪的代价到底在哪

理论说再多不如看一张图。我特意设计了一张 7×7 的地图,终点被一个只有窄口能进的小房间包住,房间的开口方向背离起点:

# # # # . . . # G . # . . . # . . # . . . # . . # . . . . # # # . . . . . . . . . . . . . . . . S

#是墙,.是通路,S是起点 (6,6),G是终点 (1,1)。终点所在的小房间被 col3 那一列墙完全封住右侧,唯一的入口在左下角 (4,0) → (3,0) → (2,0) → (1,0) → (1,1)。

拿贪婪最佳优先搜索跑这张图,你会看到一个非常典型的现象:算法从 (6,6) 出发后,一路向右上角那片开阔区域冲过去,因为那一带贴着墙壁的格子 h 值极小——比如 (1,4) 的曼哈顿距离只有 3,(2,4) 是 4。算法会把这些格子当成"马上就要到了"的香饽饽,整片右上区域被翻来覆去地展开。等它确认那面墙确实过不去之后,才被迫往下退,沿着最底下那行一路向左绕到 (5,0),再从 (4,0) 钻进房间。

这就是贪婪的代价:它把搜索预算花在了"看起来很美好但过不去"的区域上。换成 A*,因为每走一步 g 都会增加,而绕行意味着 g 增长很快,算法会意识到"先去右上角再折返"这条路的总账不划算,于是更早地转向正确的下探方向。

2.3 它真正合适的三类场景

虽然贪婪最佳优先搜索不保证最优,但它在工程上绝不是"劣质算法",有三类场景它反而比 A* 更合适。

第一类是实时性压倒一切的场景。比如游戏里几十上百个 NPC 同时寻路,每个 NPC 每帧的预算可能只有零点几毫秒。这时候路径长几格玩家根本看不出来,但帧率掉下去会立刻被察觉。贪婪的扩展节点数通常只有 A* 的几分之一,这种取舍非常划算。

第二类是终点附近有大量可接受解的场景。比如"走到敌人附近任何一个攻击位"、"走到资源区任意一格",你只要求"够近就行",不要求严格最短,那 h 的近视反而变成优点。

第三类是作为 A* 的预处理或启发函数构造工具。有些高级算法会用一个简化版本的贪婪搜索去估一个粗略的 h,塞给完整搜索用。这种情况下,贪婪快、够用、不要求最优的特点正好合适。

注意:只要你的需求文档里出现"最短路径""最优解""不能绕远"这类字眼,就不要用贪婪最佳优先搜索,直接上 A*。这不是性能问题,是正确性问题。

3. A* 的账本:g 与 h 各管什么

A* 的公式只有一行:f(n) = g(n) + h(n)。g(n) 是从起点走到 n 的真实累积代价,h(n) 是从 n 到终点的估计代价。每次从开放列表里取出 f 值最小的节点扩展。

公式简单,但每一项的角色必须分清楚,否则调参的时候完全没有方向。g 负责"回头看账",保证已经付出的代价被计入;h 负责"向前看路",保证搜索有方向感。h 越准,搜索越聚焦,扩展的节点越少;h 越弱(越接近 0),A* 就越退化成 Dijkstra;h 如果恒等于 0,两者完全等价。

一个很好用的判断标准:看 h 和真实剩余代价的比值。如果 h 恰好等于真实剩余代价(比如无障碍直线上,曼哈顿距离就是真实剩余步数),A* 会几乎笔直地走向终点,中间不扩展任何多余节点。这在网格地图上很容易验证:把障碍全部清掉,A* 的扩展数应该几乎等于路径长度。我经常用这一条来验证自己的实现有没有写错。

3.1 网格地图上三种 h 的选型对照

h 选错是新手最常见的性能问题来源之一。下面这张表是我自己在四方向和八方向地图上反复验证后的结论:

启发函数计算公式适用移动方式是否可采纳实测扩展节点数(相对值)
曼哈顿距离abs(dx) + abs(dy)只能上下左右走是100
欧几里得距离sqrt(dx² + dy²)任意方向连续移动是约 130
八方向距离(octile)dx + dy + (√2-2) * min(dx, dy)八方向走,对角代价 √2是100(八方向下最优选择)
曼哈顿距离 × 1.2上者乘系数四方向否约 70

这里有个很多人会犯的错:在四方向地图上用欧几里得距离。理论上是可采纳的(欧氏距离永远不大于曼哈顿距离,所以不会高估),但它的值偏小,方向感弱,A* 会扩展明显更多的节点。反过来,在八方向地图上用曼哈顿距离问题更大——如果对角移动代价是 1(而不是 √2),曼哈顿距离等于真实代价,没问题;如果对角移动代价是 √2,曼哈顿距离会高估(dx+dy ≥ dx+dy+(√2-2)min(dx,dy)),可采纳性被破坏,最短路径可能找错。

提示:判断 h 好不好用,最简单的办法是"在无障碍的空地图上跑一遍,数扩展节点数"。如果扩展数明显超过路径长度,说明 h 的方向感不够,该换了。

3.2 权重 A* 与跳跃点搜索:要更快就得接受什么

如果 A* 还是不够快,工程上有两条主流路子。

第一条是权重 A*(Weighted A*),把公式改成f = g + w * h,w 通常取 1.2 到 5。w 越大,算法越像贪婪搜索,速度越快,但路径越可能不是最优。它有一个很有价值的理论保证:结果路径的代价不会超过最优路径的 w 倍。也就是说,w=1.5 的时候,你能拿到的路径最多比最短路长 50%,这是一个可量化的、可以写进需求文档的质量承诺,比"大概差不多"靠谱得多。

第二条是跳跃点搜索(Jump Point Search, JPS),这是针对均匀代价网格地图的专用优化。它的思路是:在开阔区域里,很多节点的扩展是冗余的,可以直接"跳"过去,只在必须转弯或遇到障碍的关键点(跳跃点)上做检查。在障碍较少的地图上,JPS 能把扩展节点数降到 A* 的十分之一以下,而且仍然保证最优。代价是它对地图结构有强假设:必须是均匀代价网格,必须是对称的移动规则,地图不能有动态变化的代价(比如沼泽地减速)。如果这些条件满足,JPS 几乎是唯一正确的选择;如果不满足,用它会写出很大的 bug。

我个人的实践顺序是:A* 写对 → 换更精准的 h → 加权重 w → 最后才考虑 JPS。因为 w 的调优成本极低(改一个数),而 JPS 的调试成本很高。

4. Python 落地:一份可以直接跑的寻路代码

理论部分讲完,接下来是可以直接复制运行的部分。为了让你能对照着看,我把贪婪最佳优先搜索和 A* 写在了同一份代码里,共用启发函数和邻居展开逻辑,结尾带一个文本可视化函数。

4.1 地图与数据结构的选择(性能坑)

地图我用二维列表grid,0表示可走,1表示障碍。坐标统一用(行, 列)元组。层级上说得更清楚一点,这个元组既当坐标又当字典的键,非常方便,这是 Python 里写网格搜索最省事的方式。

但这里有个性能上的真实差别值得提一句:很多人图省事,用 NumPy 二维数组存地图,然后用grid[r][c]逐格访问。这在网格搜索里是反效果——NumPy 单元素索引的开销比 Python 原生 list 索引大好几倍,因为每次都要构造标量和做类型转换。NumPy 的优势在向量化运算,而搜索是一个高度依赖分支判断的逐格操作,完全是它的短板。我实测过,在 200×200 地图上,纯 Python list 比 NumPy 逐格索引大约快三成。

如果地图特别大(比如 1000×1000 以上)而且反复访问,可以考虑用一维bytearray存地图,把(r, c)线性化成r * cols + c。这样做的好处是内存连续、缓存友好,缺点是代码可读性下降,坐标转换容易写错。我的建议是:先写清楚的版本,真的性能不够了再考虑这一步。

4.2 完整可运行实现

import heapq # ---------- 启发函数 ---------- def manhattan(a, b): """四方向移动专用,曼哈顿距离""" return abs(a[0] - b[0]) + abs(a[1] - b[1]) def euclidean(a, b): return ((a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2) ** 0.5 def octile(a, b): """八方向移动专用,对角代价为 sqrt(2)""" dx = abs(a[0] - b[0]) dy = abs(a[1] - b[1]) return dx + dy + (2 ** 0.5 - 2) * min(dx, dy) # ---------- 邻居展开 ---------- def neighbors4(node): r, c = node return ((r - 1, c), (r + 1, c), (r, c - 1), (r, c + 1)) def neighbors8(node): r, c = node return ((r - 1, c - 1), (r - 1, c), (r - 1, c + 1), (r, c - 1), (r, c + 1), (r + 1, c - 1), (r + 1, c), (r + 1, c + 1)) # ---------- 工具 ---------- def reconstruct(parent, node): path = [] while node is not None: path.append(node) node = parent[node] return path[::-1] def in_bounds(node, rows, cols): r, c = node return 0 <= r < rows and 0 <= c < cols # ---------- 贪婪最佳优先搜索 ---------- def greedy_best_first(grid, start, goal, h=manhattan, expand=neighbors4): rows, cols = len(grid), len(grid[0]) counter = 0 open_heap = [(h(start, goal), counter, start)] parent = {start: None} # 兼作 visited 集合 closed = set() expanded = 0 while open_heap: _, _, cur = heapq.heappop(open_heap) if cur == goal: return reconstruct(parent, cur), expanded if cur in closed: continue closed.add(cur) expanded += 1 for nb in expand(cur): if not in_bounds(nb, rows, cols): continue if grid[nb[0]][nb[1]] == 1: continue if nb in closed or nb in parent: continue parent[nb] = cur counter += 1 heapq.heappush(open_heap, (h(nb, goal), counter, nb)) return None, expanded # 不可达 # ---------- A* ---------- def astar(grid, start, goal, h=manhattan, expand=neighbors4, w=1.0, diag_step=2 ** 0.5): rows, cols = len(grid), len(grid[0]) counter = 0 open_heap = [(w * h(start, goal), 0, start)] g_score = {start: 0.0} parent = {start: None} closed = set() expanded = 0 while open_heap: f, _, cur = heapq.heappop(open_heap) if cur == goal: return reconstruct(parent, cur), g_score[cur], expanded if cur in closed: continue closed.add(cur) expanded += 1 for nb in expand(cur): if not in_bounds(nb, rows, cols): continue if grid[nb[0]][nb[1]] == 1: continue # 对角移动代价为 sqrt(2),直走代价为 1 step = diag_step if (nb[0] != cur[0] and nb[1] != cur[1]) else 1.0 tentative = g_score[cur] + step if tentative < g_score.get(nb, float('inf')): g_score[nb] = tentative parent[nb] = cur counter += 1 heapq.heappush(open_heap, (tentative + w * h(nb, goal), counter, nb)) return None, float('inf'), expanded # ---------- 文本可视化 ---------- def draw(grid, path=None, start=None, goal=None): canvas = [['#' if v == 1 else '.' for v in row] for row in grid] if path: for r, c in path: canvas[r][c] = 'o' if start: canvas[start[0]][start[1]] = 'S' if goal: canvas[goal[0]][goal[1]] = 'G' print('\n'.join(''.join(row) for row in canvas))

上面的代码有几个刻意的设计,值得逐一说明。

counter变量的作用前面提过,是为了让堆元素在 f 值打平时不比较到节点本身。我在这里把它做成了每次压栈自增,保证全局唯一。这样还有一个副作用:f 值相同时,先入堆的节点先被取出,这实际上让搜索行为更接近宽度优先,在打平的情况下表现更稳定。

g_score.get(nb, float('inf'))这个写法承担了"惰性删除"的职责。因为堆里可能存在同一个节点的多个过期条目,只有当新算出的 g 值确实更小时才重新入堆,避免无意义的重复压栈。

astar里的diag_step参数默认是 √2。如果你用的是neighbors8且希望对角代价也是 1,把它设成 1.0 就行,同时 h 要换成曼哈顿距离。这两者必须配成一对改,只改一个就是 bug。

4.3 关闭列表与重复入堆:两个最容易写错的地方

第一个坑是把closed判定写在了错误的位置。正确的顺序是:出堆 → 判断是不是终点 → 判断是不是已关闭 → 加入关闭集 → 扩展邻居。如果你在压栈时就判断closed,或者忘记在出堆后跳过已关闭的节点,就会出现同一个节点被扩展多次的情况,扩展计数虚高,而且路径可能被覆盖成错误的值。

第二个坑是在closed里做 reopen。有些教程会教你在tentative < g_score[nb]时把nb从closed里删掉,允许重新打开。这个做法本身没错,A* 在 h 不可采纳/不一致的时候确实需要它才能保证最优。但它会让代码复杂度上升不少,而且性能会下降。我的做法是:只在自己构造了非标准 h(带惩罚项、带学习权重之类)时才启用 reopen,标准几何距离的 h 一律不 reopen,因为一致性保证了没必要。

还有一个隐蔽的坑:八方向移动时的"切角穿墙"问题。如果 (r-1, c-1) 是通路,但 (r-1, c) 和 (r, c-1) 都是墙,理论上你从 (r, c) 斜着走到 (r-1, c-1) 是"穿过墙角"的,视觉上非常不合理。如果你的场景不允许这样走,就在邻居展开里加一条判断:对角移动时,必须先检查两个相邻的直向格子都是通路。这个小判断会明显改变寻路结果的"观感",游戏里尤其重要。

4.4 把搜索过程打印出来调试

路径不对的时候,光看结果路径是查不出原因的。我的习惯是临时加一个"展开顺序记录",把closed.add(cur)那一行改成同时往一个列表里追加cur,跑完之后用 draw 把展开过的节点标出来。

# 临时调试:把展开过的节点标成 '*',直观看到算法的搜索范围 def draw_debug(grid, expanded_nodes, path=None, start=None, goal=None): canvas = [['#' if v == 1 else '.' for v in row] for row in grid] for r, c in expanded_nodes: canvas[r][c] = '*' if path: for r, c in path: canvas[r][c] = 'o' if start: canvas[start[0]][start[1]] = 'S' if goal: canvas[goal[0]][goal[1]] = 'G' print('\n'.join(''.join(row) for row in canvas))

看着这张图,"为什么它绕了这么远"通常一眼就能看出来——要么是 h 的方向感太弱,搜索范围像 BFS 一样铺开成圆形;要么是某处 h 写错了,搜索明显偏向了错误的方向;要么是邻居展开有 bug,某个方向的通路根本没被探索到。这个方法比打日志、加断点都快,我几乎每次调寻路都会用。

5. 实测对比与调参经验

代码跑通之后,下一步就是把它调成"在真实场景里可靠"。这一节记录的是我在自己项目里积累的一些数据和判断标准。

5.1 四种算法在同一批地图上的表现

我在 30 张 100×100 的随机障碍地图上跑了一组对比,障碍率 25%,四方向移动,起点终点固定在对角线两端,Python 3.11,取平均值。数字是量级参考,不同硬件和实现会有差异,但相对关系很稳定:

算法平均扩展节点数平均路径长度是否最优单次耗时(毫秒,量级)
BFS约 7800198是约 55
Dijkstra约 8100198是约 60
贪婪最佳优先约 750约 214否约 5
A*(曼哈顿)约 1350198是约 9

这张表里最有意思的一行是贪婪的"平均路径长度约 214"。它比最短路长了大概 8%,这个幅度大多时候肉眼看不出来,但扩展节点数只有 A* 的一半多点。所以在"每秒要算几百次路径"的场景里,贪婪的性价比很高,只要你能接受那 8%。

反过来看,A* 相比 BFS 扩展节点数少了将近六倍,路径长度完全一致。这就是启发式函数带来的纯收益——不牺牲任何结果质量,只靠多知道一点方向信息,就把搜索空间砍掉一大半。这也是为什么几乎所有工程化的寻路库默认都用 A*。

在"口袋地形"那张小地图上,差距会更夸张。贪婪会把右上角那片低 h 值的开阔区几乎全部展开,我数了一下大约 30 个节点;A* 只展开了 12 个左右就直接往下走了。地图越"结构化"(有走廊、房间、死角),A* 相对贪婪的优势就越明显;地图越是开阔无障碍,两者差距越小。

5.2 h 的缩放系数怎么调

w这个权重系数是调优时最有性价比的旋钮。我在同一批地图上试过一组值:

w 取值扩展节点数路径长度相对最优的倍数
1.0约 13501981.00
1.2约 11001991.005
1.5约 9002031.025
2.0约 7002121.07
3.0约 6402261.14

可以看到,w 从 1.0 提到 1.5,扩展节点数降了三分之一,路径只长了 2.5%。这是一个非常划算的区间。再往上收益递减,路径质量却掉得越来越快。我的经验取值是 1.2 到 1.5,除非场景对路径质量极度不敏感。

但这里有个必须验证的前提:权重 A* 会破坏可采纳性,所以你必须确认自己的代码不会因此出错。前面那份实现是安全的,因为它用的是"每个节点只处理一次"的写法,配合标准几何 h,即使加了权重也只是路径变长,不会死循环或者返回错误的 g 值。

5.3 排查清单:路径不对时按这个顺序查

我把这几年遇到的寻路问题总结成了一份排查顺序表,按"最可能出问题"到"最不可能"排列:

现象最可能的原因检查方法
返回的路径穿过障碍邻居展开时没检查grid值,或坐标写成了grid[c][r]打印路径,逐点核对地图
路径明显绕远,但确实绕开了障碍h 高估了,被误认为可采纳在直线无障碍场景测试,看 h 是否等于真实步数
八方向下对角移动穿墙角缺少切角判断在通路上加两个相邻障碍测试
同一节点被扩展多次closed判定位置写错打印展开列表,看是否有重复
路径突然在某处断掉parent字典的键被覆盖,重建链条错乱检查parent[nb] = cur是否无条件执行
扩展节点数等于全图节点数h 恒等于 0,或者 h 返回了常数直接打印几个节点的 h 值
堆操作抛 TypeError堆元素在第二项打平,比较到了自定义对象加计数器或把节点改成元组
大图下内存暴涨g_score、parent、closed三份字典同时持有全图节点考虑用一维数组替代字典,或用双向搜索

这份表里排在第一位的"坐标写反"是我见过最多的错误,尤其在用(x, y)和(row, col)混着写的项目里。我的习惯是整个项目里只允许一种表示——永远用(row, col),永远用grid[r][c],绝不为了"看得顺眼"在某处换成(x, y)。这种一致性比省几行代码重要得多。

5.4 一个我在真实项目里才会在意的小优化

最后分享一个不太会写在教科书里、但在实际项目里很有用的做法:把 h 函数和邻居展开函数做成可替换的参数。你在上面那两份实现里应该已经看到了h=manhattan和expand=neighbors4这两个参数。

这样做的价值是,你可以在不改动核心搜索逻辑的前提下,用同一份代码跑不同的地形规则。比如地图从四方向换成八方向,只需要传octile和neighbors8进去;某个区域需要走得慢一点,只需要在计算step的时候根据格子类型返回不同的代价;想临时把算法换成权重 A* 做性能对比,改一下w就行。

我见过不少项目把 h 的计算硬编码在搜索循环里,结果每次要做算法实验都得复制一整份文件,几个版本分叉之后维护成本极高。多留一个参数的位置,成本几乎为零,收益是把"试验"变成了一行代码的事。这在早期选型阶段特别值钱——你要做的往往不是"写出最好的算法",而是"用最低成本试出哪个最合适"。

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

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

立即咨询