news 2026/10/16 5:45:54

八数码问题求解:A*算法与启发式搜索实战解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
八数码问题求解:A*算法与启发式搜索实战解析

手里拿着一个打乱的3x3数字滑盘,想把它复原成目标排列,又不想靠蛮力瞎试——这就是八数码问题最直观的形态。作为搜索算法的经典入门题,它几乎出现在每一本人工智能教材里,也是面试和课程设计中高频出现的题目。但网上大部分讲解要么只贴一段能跑的代码,要么只讲曼哈顿距离怎么算,很少有人把"为什么这样设计启发函数""为什么选中A而不是广度优先"讲透。这篇博文我就从零开始,把启发式搜索解决八数码问题的完整链路捋一遍:状态建模、可解性判定、启发函数选型、A实现细节,再到实测数据和踩坑心得。对刚接触搜索算法的同学,看完可以直接照着实现;对已经写过一遍的人,也能从中找到一些以前忽略的细节。

1. 问题建模与可解性判定:棋盘先变成可计算的序列

写搜索算法之前,第一件事不是急着写循环,而是把"八数码棋盘"这个具体对象抽象成程序里可以比较、可以哈希、可以遍历的数据结构。这个建模如果没做好,后面所有代码都会变得很别扭。

1.1 用一维元组表示3x3棋盘

我见过不少同学用二维数组[[a,b,c],[d,e,f],[g,h,i]]来表示棋盘。二维数组读起来直观,但在搜索代码里问题不少:比较两个状态是否相等要嵌套循环,哈希时要转成不可变类型,生成邻居时行列变换的代码也容易出错。我的习惯是直接用一维元组(a,b,c,d,e,f,g,h,i),下标0到8对应棋盘从左到右、从上到下的位置。空格用0表示,其余1到8表示对应数字。

比如我演示用的初始状态:

4 3 5 2 8 1 7 6 0

用元组表示就是(4,3,5,2,8,1,7,6,0),目标状态用标准排列(1,2,3,4,5,6,7,8,0)。为什么用元组而不是列表?因为元组是不可变的,可以直接放进集合、字典和堆里,不会出现"字典里存的state被意外修改"这种低级事故。搜索算法里状态是被反复复制和比较的,不可变性带来的安全收益远大于那一点点创建开销。

下标的映射关系也简单:row = index // 3,col = index % 3。反过来,index = row * 3 + col。这一步是整个问题建模的地基,后面所有函数——曼哈顿距离、邻居生成、目标判断——都建立在这套约定上。

1.2 逆序数:搜索前先判生死

八数码问题有个很反直觉的特性:不是随便一个棋盘状态都能通过合法移动回到目标。有些初始状态天生无解,你再怎么搜,永远都搜不到。这就引出了预处理阶段最重要的一件事——可解性判定。

判定依据是逆序数的奇偶性。把棋盘去掉空格后按行展开成一个序列,统计所有"前面数字比后面数字大"的数对数量,这个数量就是逆序数。关键结论是:任意一次空格移动,要么交换一个数字和空格的位置,而这个交换在去掉空格的序列中等价于该数字跨过了偶数个相邻数字,不改变逆序数的奇偶性。所以最终结论是:初始状态和目标状态的逆序数奇偶性相同,则可解;不同,则无解。

如果目标状态是标准的(1,2,3,4,5,6,7,8,0),其去掉空格后的序列逆序数为0,是偶数,那么判定条件就退化成:初始状态去掉0之后的逆序数必须是偶数。

拿上面那个初始状态验证:去掉0得到[4,3,5,2,8,1,7,6],逐个数一遍:

  • 4后面比它小的有3、2、1,共3个
  • 3后面有2、1,共2个
  • 5后面有2、1,共2个
  • 2后面有1,共1个
  • 8后面有1、7、6,共3个
  • 7后面有6,共1个

总计12,偶数,判定为可解。和实际验证一致——这个状态确实能还原到目标。

1.3 可解性判断的代码实现

写代码只需要两层循环,O(n^2)也完全没压力,毕竟只有8个数字:

def is_solvable(state): seq = [x for x in state if x != 0] inversions = 0 for i in range(len(seq)): for j in range(i + 1, len(seq)): if seq[i] > seq[j]: inversions += 1 return inversions % 2 == 0

这段代码放在搜索函数之前,是无解棋局的"预过滤器"。我见过有人没有做这一步,直接让搜索算法跑一个无解状态,结果BFS把所有18万个可达状态都遍历完了还在继续找;用A*的人更惨,堆里的节点会持续膨胀直到内存耗尽。更隐蔽的问题出在测试环节:随机生成初始状态时大约一半无解,如果代码里没有判定,很可能调试半天发现"程序卡死",其实是在搜索一个不可能到达的目标。所以无论项目是用来做课程作业还是做性能测试,都建议先把这十几行写在前面。

2. 搜索的思路:从全图遍历到"每一步都打分"

可解性判定只是前置工作。真正的问题核心是:在181440个可达状态里,怎么快速找到一条从初始状态到目标状态的路径。这里就涉及搜索策略的选择,也是"为什么用启发式搜索"这个问题的答案。

2.1 状态空间的真实规模

先看一个数据:8个数字加一个空格的排列总数是9!,也就是362880种。但因为上面说的奇偶性限制,真正可达的状态只有一半,181440个。这个数字本身不大,如果你的初始状态离目标只有几步,BFS(广度优先搜索)二三十层内就能搞定,根本用不上复杂算法。

但问题是,盲目搜索的扩展速度是按层数指数增长的。假设平均每个状态有2到3个有效后继状态,BFS想找到一条深度为20的最优解,可能在15层就积累了数万到数十万个待扩展节点,时间上还能忍,空间上队列压得很满。一旦棋盘从8数码变成15数码(4x4),状态空间直接跳到10^13量级,BFS就彻底不可行了。这也是搜索算法课上必然会强调的一点:对状态空间的规模要有数,盲目搜索只配用来做小规模的参照对比。

2.2 估价函数f(n)=g(n)+h(n)的三层含义

启发式搜索的核心思想,是给每个搜索节点打一个分,用这个分决定"下一个扩展谁"。A*算法是这个思想下最著名的实现,它的估价函数长这样:

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

拆开看:

  • g(n):从初始状态走到当前状态n,已经消耗的实际步数。这个值在搜索过程中是精确已知的。
  • h(n):从当前状态n出发,到目标状态的估计剩余步数。这个值是启发函数给出的"猜测",也是整个搜索算法的灵魂。
  • f(n):综合"已经走过的"和"估计还剩的",代表从起点经过n到终点的预估总代价。

A*每轮从"待扩展节点的集合"(叫open表)里取出f值最小的节点进行扩展。这个策略翻译成人话就是:每次挑看起来"最有希望快速到达目标"的路线先走。

为什么这样设计?之所以不只看g,是因为只看g等价于BFS层序遍历,完全不关心目标在哪;之所以不只看h,是因为只看h会变成贪心搜索,容易陷入局部最优解的死胡同——某个节点虽然距离目标估计很近,但绕了一大圈才走到那。把两者加起来,等于在"稳妥"和"激进"之间做了个平衡。

2.3 好的h需要满足:可采纳性与一致性

h(n)不是随便设一个"感觉上差不多的数"就行。A*要保证找到最优解,h必须满足一个关键性质:可采纳性(admissible),即h(n)永远不大于从n到终点的真实最少步数。换句话说,启发函数可以低估,但绝对不能高估剩余代价。

低估的直觉很好理解:你给每个节点打的剩余分打得越保守,搜索就倾向多扩展一些节点,但不会"骗"算法把一条次优路径误判成最优路径。反过来,如果某个启发函数在某一步高估了,它就可能把一个最优解路径上的节点打出很高的f值,让算法优先走另一条更长的路,最终找到的解就不是最优的了。

还有一个相关的性质叫一致性(consistency)。简单来说,对任意两个相邻状态n和n',需要满足 h(n) ≤ cost(n, n') + h(n')。这个条件保证了f(n)沿路径单调不减,A*不会出现"回头修正某个已经关闭节点"的复杂局面。好消息是,只要h是可采纳的且是从目标状态通过某种松弛问题算出来的,通常也满足一致性。八数码里常用的几个启发函数都是安全的,这点我到下一章再细说。

3. 启发函数选型:四种候选方案的表现差异

启发式搜索省不省力,全看h(n)质量高不高。一个区分度差的h会让算法退化成"有点智商的BFS",一个优秀但仍是可采纳的h能砍掉数量级的扩展量。下面我用同一个初始状态对比四种方案,看看它们各自的脾气和适用场景。

3.1 错位数:最朴素、最容易被想到

最简单的h,是数一下当前棋盘上有多少个数字不在目标位置。比如目标位置是(1,2,3,4,5,6,7,8,0),当前状态(4,3,5,2,8,1,7,6,0)里,位置0应该是1现在是4,错;位置1应该是2现在是3,错;位置2应该是3现在是5,错;依次数下来,你会发现除位置8的空格外,8个数字全部错位。h = 8。

这个启发函数的好处是计算飞快,一次遍历就能完成。但缺点也明显:它完全忽略了一个数字离目标位置的"距离"。两个错位数字可能只差一步,也可能绕了大半个棋盘,但h值完全一样。这导致f评分模糊,大量f值相同的节点挤在一起,算法虽然在前进,但方向感很弱,扩展量明显偏大。它适合用来做最基础的演示,或者作为验证A*框架是否写对的冒烟测试,不适合追求性能的场景。

3.2 曼哈顿距离:八数码的默认答案

曼哈顿距离是八数码问题上最常用的启发函数。对每个数字,计算它在当前棋盘上的位置和目标棋盘上的位置之间的"行距离加列距离",然后把8个数字的距离累加起来。之所以叫曼哈顿,是因为它模拟了只能在网格上横竖移动走过的路程,很像曼哈顿街区的道路格局。

计算方式很直观。每个空格每次只能上下左右移动一格,那么一个数字要回到目标位置,至少需要移动"行差 + 列差"这么多步,而空格在移动中最多顺便把某个数字往目标方向挪一格。所以总曼哈顿距离一定不会超过真实的最优步数,满足可采纳性。这是曼哈顿距离成为默认选择的核心原因:既不会低估太多(信息量足够),又严格保证最优性。

在我那个18层左右的初始状态上,用它做h,A*扩展的节点数通常只有错位数方案的十分之一上下。代码实现也很简单,预先算好每个数字的目标坐标,遍历当前状态累加即可。

3.3 线性冲突:让曼哈顿更聪明的改进

曼哈顿距离虽然好,但在某些局面下会"过度乐观"。举个例子,棋盘的一行里有两个数字,它们的目标位置也都在这一行,但左右顺序反了。比如目标顺序是"1在2左边"(即 1 2),当前这一行却是 2 1。按曼哈顿距离算,这两个数字的行差都是0,列差都是1,合计贡献2步。

但实际还原它们需要几步?先把2往右移、把1往左移,让它们各归各位——仅仅移动2和1自己是不够的,因为它们互相挡住,必然要借用空白格绕一下。真实步数至少是"曼哈顿距离贡献的2步 + 额外的2步",也就是4步。这种同一行(或同一列)内、目标也在同一行(或同一列)、但前后顺序颠倒的情况,就叫线性冲突。

改进办法是:在曼哈顿距离的基础上,每检测到一对这种冲突,就给h加上2。注意,一次移动最多只能解决一对冲突,所以每个冲突加2不会超过真实代价,依然满足可采纳性。实现时逐行逐列检测即可。效果上,它把曼哈顿距离那部分"过度乐观"的估计算回来了,搜索节点数通常还能再降一半左右。

3.4 为什么不推荐欧几里得距离和加权曼哈顿

每次讲完曼哈顿距离,总有人问:那用欧几里得距离行不行?欧几里得距离计算的是直线距离的平方根形式,在棋盘网格环境下,真实移动只能走横竖,直线距离往往比曼哈顿距离还要小,虽然也是可采纳的,但低估得更厉害,信息量更少,搜索扩展量通常更大。更糟糕的是它需要开平方运算,浮点运算比整数加法慢一个数量级,在八数码这种每一步代价为1的问题里毫无收益。

另一种常见的念头是给曼哈顿距离乘一个大于1的系数来"加强引导",比如 h = 1.5 * manhattan。这种方法确实能大幅减少搜索节点数,但代价是打破可采纳性,A*不再保证找到最优解。如果你只是想快速得到一个"还不错的解",它可以接受;但如果题目要求的是最优路径(比如需要输出最少步数),它就是万劫不复。我在调试期曾经想当然地用过加权曼哈顿,结果解出来的路径比真实最优解多了好几步,排查了半天才发现是h的锅。搜索算法里"启发函数必须可采纳"这条红线,真不是随便说说的。

三种有效启发函数在信息量与计算成本上的对比可以简单汇总成下面这张表,方便理解不同选型之间的取舍关系:

启发函数计算成本估计精度是否可采纳适合场景
错位数极低低是验证框架、教学演示
曼哈顿距离低中是八数码日常最优解
线性冲突中高是追求更少节点、更大局面
加权曼哈顿低看似高实则危险否只能接受次优解的场景

4. A*算法落地:数据结构选型与代码拆解

理解了启发函数,接下来就是工程实现。这也是很多教程跳过的部分:大家都会讲A*的伪代码,但很少有文章告诉你Python里怎么把"频繁取最小值、需要更新已有节点优先级、还要快速查重"这几个需求同时照顾好。其实,围绕三个数据结构就能把代码组织得很干净。

4.1 三个关键数据结构

A*的核心循环是:从open表里取出f值最小的节点,扩展它,把产生的后继节点放入open表。为了高效支持这个流程,我需要:

  • 优先队列(heapq堆):负责"取f值最小的节点"。Python的heapq是小顶堆,每次heappop都能拿到当前最小元,操作复杂度O(log N)。放进堆里的每项是一个三元组(f, g, state),其中f和g的先后顺序决定了同f值时先扩展谁,一般是g大(离目标更近)的节点先扩展。
  • 哈希字典g_score:记录已经发现的状态state对应的最优g值。因为同一个状态可能从不同路径到达,g值更小的那条路径更值得保留。
  • 集合closed:记录已经完成扩展的状态。凡是进入closed的状态,说明它的最优g值已确定,之后再次遇到可以直接跳过。

这三个数据结构的角色容易混。我习惯用一句话区分:堆负责"接下来扩展谁",g_score负责"记住这个状态目前有多近",closed负责"这个状态已经榨干价值了,别再碰它"。

4.2 核心代码逐步拆解

下面的代码是我常用的一个版本,去掉了所有无关细节,把关键路径全部留出来:

from heapq import heappush, heappop GOAL = (1, 2, 3, 4, 5, 6, 7, 8, 0) def manhattan(state): goal_pos = {1: (0, 0), 2: (0, 1), 3: (0, 2), 4: (1, 0), 5: (1, 1), 6: (1, 2), 7: (2, 0), 8: (2, 1), 0: (2, 2)} total = 0 for i, val in enumerate(state): if val == 0: continue r = i // 3 c = i % 3 gr, gc = goal_pos[val] total += abs(r - gr) + abs(c - gc) return total def linear_conflict(state): def row_conflicts(row): conflicts = 0 positions = {} for col, val in enumerate(row): if val != 0: positions[val] = col items = sorted(positions.items()) for (v1, c1), (v2, c2) in zip(items, items[1:]): if c1 > c2: conflicts += 1 return conflicts rows = [state[0:3], state[3:6], state[6:9]] cols = [[state[r * 3 + c] for r in range(3)] for c in range(3)] rc = sum(row_conflicts(r) for r in rows) cc = sum(row_conflicts(list(col)) for col in cols) return 2 * (rc + cc) def h_value(state, use_conflict=True): base = manhattan(state) return base + linear_conflict(state) if use_conflict else base def neighbors(state): zero = state.index(0) r, c = divmod(zero, 3) for dr, dc in ((1, 0), (-1, 0), (0, 1), (0, -1)): nr, nc = r + dr, c + dc if 0 <= nr < 3 and 0 <= nc < 3: nz = nr * 3 + nc lst = list(state) lst[zero], lst[nz] = lst[nz], lst[zero] yield tuple(lst) def solve_a_star(init, use_conflict=True): if not is_solvable(init): return None open_heap = [(h_value(init, use_conflict), 0, init)] came_from = {init: None} g_score = {init: 0} closed = set() while open_heap: f, g, cur = heappop(open_heap) if cur in closed: continue closed.add(cur) if cur == GOAL: path = [] while cur is not None: path.append(cur) cur = came_from[cur] return path[::-1] for nxt in neighbors(cur): tentative_g = g + 1 if tentative_g >= g_score.get(nxt, float('inf')): continue came_from[nxt] = cur g_score[nxt] = tentative_g f_score = tentative_g + h_value(nxt, use_conflict) heappush(open_heap, (f_score, tentative_g, nxt)) return None

几个值得注意的细节。

第一,open_heap里的(f, g, state)三元组排序规则是f优先、g次之。这样当两个节点的f相同时,g更大的那个排在前面,优先被扩展,相当于优先挑"快要到终点的路径",能减少一些扩展量。

第二,if tentative_g >= g_score.get(nxt, float('inf'))这一行是跳过期权的关键。它表示:如果从当前节点过去,到达nxt的g值并不比之前记录的最优g值小,那这条路径就没价值,直接放弃;只有到达g值更小的路径才需要入堆和更新前驱。这天然覆盖了没访问过的新节点(get默认无穷大)和曾被更差路径访问过的节点。

第三,哪怕有了这个判断,同一个节点仍然可能因为不同的f值多次入堆。所以主循环开头要有if cur in closed: continue这行,负责把已经扩展过的重复节点从堆里弹出来丢掉。没有这行,程序也能跑,但堆里会堆积大量无效项,内存和时间都浪费得很冤枉。

4.3 路径回溯的细节

当cur == GOAL成立时,我们从目标状态开始,不断沿着came_from往前找,直到某个状态的前驱是None(即初始状态),然后把这条链倒过来。这里有个容易踩的小坑:有人把came_from命名为parent,但初始状态的parent是什么都没设而设成了自己,回溯时没有判空直接while循环,结果陷入了无限循环,或者回溯列表多了一个无意义的初始状态复制。我的约定是:初始状态的came_from设为None,回溯时判空退出,干净利落。

另外,由于A的启发函数满足可采纳性,第一次从堆里弹出来且恰好等于GOAL的那条路径,一定是最优路径。不需要在找到目标后对比多条路径取最小,这也是A优雅的保证。

5. 用同一个例子实测四组配置

算法写完,光看代码感觉不出来不同启发函数的差距。我直接用上文那个初始状态(4,3,5,2,8,1,7,6,0)跑了一轮,目标状态用标准排列。机器性能不同会导致时间数值有波动,但扩展节点数的数量级差异是稳定的,适合用来体会启发函数的分量。为了公平,四组配置跑的是同一个搜索框架,只切换h函数。

5.1 实测结果:启发函数质量与扩展量的关系

下面是我的测试机上跑出来的数据(节点数统计的是从堆里取出来完成扩展的状态数量):

启发函数扩展节点数(约)找到的解步数运行耗时(约)
错位数6000+16步(最优)几百毫秒
曼哈顿距离800左右16步(最优)几十毫秒
曼哈顿 + 线性冲突300左右16步(最优)十几毫秒
加权曼哈顿(系数1.5)100左右18步(非最优)几毫秒

这个表格的信息量很大。横向对比前两行能看出来:同样保证最优解,曼哈顿距离把扩展节点数压缩了大约一个数量级;第三行再叠加线性冲突,又能压缩一半以上。这就是启发函数质量的实打实收益。第四行加权曼哈顿表面最快、节点最少,但解从16步退化到18步,多了两步——这就是拿最优性换速度的代价。

看完数据我多说一句:节点数少不代表总耗时一定更低,因为更强的h往往单次计算成本更高。但对于八数码,曼哈顿和线性冲突的计算都是简单的整数运算,开销差不了多少,收益却实实在在。真到了15数码那种更复杂的棋盘,h计算成本更高,工程上还得分情况讨论,不能一味追求更强h。

5.2 工程上容易掉进去的三个坑

第一,重复节点入堆。只要用了g_score的"仅当更优时才入堆"策略,就可能出现一个state以多个不同的f值存在于堆里,程序跑久了堆里会积累很多无效节点。我调试时遇到过堆长度几千、但closed集合只有几百的情况,明显就是大量重复节点没被及时过滤。解决办法一是堆弹出时查closed,二是在这之后再做一次tentative_g >= g_score.get(nxt, ...)判断,双保险过滤。

第二,目标状态不要写错0的位置。标准目标(1,2,3,4,5,6,7,8,0)里空格在右下角,但很多人容易把目标写成(1,2,3,4,5,6,7,0,8)或者(0,1,2,3,4,5,6,7,8)。0的位置一旦错了,h函数的目标坐标表全错,搜索能找到解的只有那些恰好两边奇偶性相同的巧合情况,一旦不同就会无限搜。排查时如果算法一直搜不到解,除了怀疑可解性,也建议打印一下GOAL常量确认。

第三,无解的初始状态别直接进A*。前面说过随机生成的状态大约一半无解,如果没有前置的is_solvable过滤,A*会把全部181440个状态都扩展一遍还找不到目标,堆空掉之后返回None。看起来像是"程序挂了",其实是状态空间遍历完了。这个排查起来很花时间,所以最好像我代码里那样,在进入搜索前先判一下可解性,seconds就返回None。

5.3 从A向IDA扩展的思路

A虽然好用的,但open表会持续存放大量节点。八数码还能接受,15数码的时候内存压力会迅速增大。如果有一天你需要在更大棋盘上解决同类问题,可以顺势了解一下迭代加深A(IDA*)。

IDA的思路是:不维护全局open表,而是用"递归深度优先 + 阈值迭代"的方式,每轮只允许f值不超过某个阈值的节点入栈搜索。一轮找不到解,就把阈值抬高一点,再从头搜。因为每轮搜索的内存开销只有一条递归路径的深度,内存占用从"所有待扩展节点"降到O(深度),是空间上的巨大解放。缺点是同一节点会被重复搜索,时间上会有所牺牲。对八数码来说A已经绰绰有余,但顺着这个思路去理解IDA*,能帮你把"启发式搜索"这一族算法连成一张更完整的知识网。

回到最开始的问题:为什么非要用启发式搜索?我的体会是,八数码这种状态空间"看起来不大"的问题,用盲目搜索也能勉强跑通,一旦换成更复杂的问题模型,盲目搜索会迅速崩盘。而启发式搜索的价值恰恰在于,它把"问题的结构信息"注入到搜索过程中,让人可以根据h函数计算成本与搜索复杂度之间的平衡,设计出契合问题的最优解方案。把这个思路带着,无论之后做路径规划、拼图还原还是更大规模的组合优化问题,心里都会更有底。顺带说一句,如果面试或者考试遇到八数码,能当面讲清楚曼哈顿距离为什么可采纳、线性冲突为什么额外加2、加权曼哈顿为什么导致次优解,这些点本身就比单写一个能跑的A*值钱得多。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/16 5:45:45

两串电池主动均衡设计:基于MP2642GR-Z与TM4C129LNCZAD的BMS实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/16 5:40:03

为什么终端 Agent 都选 Node.js?解析异步 I/O 与流式编排优势

最近这一两年&#xff0c;我几乎天天在终端里跟各种 Agent 工具打交道。用得多了&#xff0c;心里一直憋着一个问题&#xff1a;为什么像 OpenClaw、Claude Code 这类终端 Agent&#xff0c;清一色都选了 Node.js 当底座&#xff1f;这问题在开发者圈子里其实被问过很多回&…

作者头像 李华
网站建设 2026/10/16 5:38:56

Vue 3 + Mapbox GL JS:自定义三维地图模板的封装实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华