ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

八数码难题搜索算法进化:从BFS到双向BFS与A*启发式搜索

八数码难题搜索算法进化:从BFS到双向BFS与A*启发式搜索

1. 从一道题看搜索算法的进化逻辑

八数码难题算是搜索算法里最经典的练手题了,没有之一。3×3的棋盘上摆着1到8八个数字和一个空格,每次只能把空格上下左右相邻的数字挪进来,问最少多少步能还原成目标状态。这道题在各大算法练习平台上都有收录,但很多人刷完BFS就跑了,其实这道题真正的价值在于它是一条完整的算法进化链——从最朴素的BFS,到双向BFS加哈希去重,再到A*启发式搜索,每一步都在解决前一步暴露出来的性能瓶颈。

我当初第一次写这道题的时候,上来就是一个裸BFS,队列里塞状态字符串,用set判重,样例过了就提交,结果直接超时。后来一步步优化,才真正理解了搜索算法里“状态空间”和“搜索方向”这两个核心概念的分量。这篇文章就把我踩过的坑和优化过程完整梳理一遍,从最基础的BFS开始,逐步推进到双向BFS+哈希,最后落到A*启发式搜索。每一层都会讲清楚为什么需要进化、怎么实现、实测效果差多少。适合已经会写基础BFS但想进一步理解搜索优化的读者,也适合刚接触算法竞赛想找一道题串起多个知识点的朋友。

2. 八数码难题的问题建模与状态空间分析

2.1 状态怎么表示才不拖后腿

八数码的状态表示看起来很简单,一个3×3的矩阵嘛,但实际写代码的时候,表示方式直接决定了后续判重和状态转移的效率。常见的表示方式有三种:二维数组、一维字符串、整数编码。

二维数组最直观,但作为队列元素和哈希键的时候非常笨重,每次判重都要逐元素比较,状态转移也要处理坐标映射。一维字符串是把九个数拼成一个长度为9的字符串,空格用字符'0'或者'x'表示,判重直接用字符串比较,状态转移通过找到'0'的位置然后和相邻位置交换字符来实现。整数编码则是把九个数按顺序拼成一个九位数,比如状态{1,2,3,4,5,6,7,8,0}编码为123456780,判重和比较都是整数操作,速度最快。

我实测下来,整数编码在判重和队列操作上比字符串快不少,尤其是在状态量大的时候差距明显。但整数编码有个小坑:前导零的问题。如果空格恰好在第一位,编码后前导零会丢失,导致不同状态编码相同。解决办法是统一用九位处理,或者在编码时把空格映射为9而不是0,保证每一位都非零。我一般习惯把空格映射为9,这样编码出来一定是九位数,不会出现前导零的歧义。

# 整数编码示例:空格用9表示 def encode(board): # board是长度为9的列表,空格用0表示 code = 0 for x in board: code = code * 10 + (9 if x == 0 else x) return code

2.2 状态空间到底有多大

八数码的状态空间是9! = 362880个排列,但并不是所有排列都能从初始状态到达。根据逆序数的奇偶性,只有一半的排列是可达的,也就是大约181440个状态。这个数字看起来不大,但BFS最坏情况下要遍历所有可达状态,每个状态又要扩展最多4个方向,总操作量在七十万次左右。如果判重用的是低效的数据结构,比如线性查找的列表,那时间开销就会爆炸。

这里有个关键点:八数码问题中,不是所有给定的初始状态都有解。判断有解的方法是比较初始状态和目标状态的逆序数奇偶性是否一致。把九个数按行展开成一维序列,忽略空格,计算逆序对的个数,如果两个状态的逆序数奇偶性相同则有解,不同则无解。这个判断在做题时非常重要,因为如果无解,BFS会遍历完整个可达状态空间后返回无解,白白浪费时间。提前判断可以直接输出-1,省掉大量无效搜索。

注意:逆序数判断的时候要忽略空格,只计算数字之间的逆序对。空格的位置不影响可解性判断,但会影响逆序数的计算方式,所以一定要把空格剔除后再算。

2.3 目标状态与搜索方向的确定

八数码的目标状态通常是{1,2,3,4,5,6,7,8,0},也就是空格在右下角。但有些题目会指定其他目标状态,这时候逆序数判断就要用指定的目标状态来算。搜索方向方面,BFS是从初始状态向目标状态单向搜索,双向BFS是从初始状态和目标状态同时向中间搜索,A*则是用启发函数引导搜索方向。这三种方式的搜索方向不同,效率差异也很大,后面会逐一展开。

3. 朴素BFS的实现与性能瓶颈

3.1 BFS的核心思路与代码框架

BFS的思路很直接:把初始状态入队,然后不断出队,对每个状态尝试四个方向的移动,如果移动后的状态没访问过就入队,直到找到目标状态或者队列为空。用队列保证按层扩展,第一次遇到目标状态时的步数就是最少步数。

from collections import deque def bfs(start, target): start_code = encode(start) target_code = encode(target) if start_code == target_code: return 0 visited = set() visited.add(start_code) q = deque() q.append((start_code, 0)) while q: code, step = q.popleft() board = decode(code) zero_pos = board.index(0) for dx, dy in [(0,1),(0,-1),(1,0),(-1,0)]: nx = zero_pos // 3 + dx ny = zero_pos % 3 + dy if 0 <= nx < 3 and 0 <= ny < 3: new_board = board[:] new_pos = nx * 3 + ny new_board[zero_pos], new_board[new_pos] = new_board[new_pos], new_board[zero_pos] new_code = encode(new_board) if new_code not in visited: if new_code == target_code: return step + 1 visited.add(new_code) q.append((new_code, step + 1)) return -1

这个框架是所有后续优化的基础,双向BFS和A*都是在它的思路上做改进。但裸BFS的问题也很明显:它像水波扩散一样从起点均匀地向四周扩展,不管目标在哪个方向,都要一层一层地推进。对于八数码这种状态空间不算特别大的问题,裸BFS勉强能过,但如果初始状态离目标状态比较远,队列里会堆积大量中间状态,时间和空间开销都不小。

3.2 判重结构的选择与性能对比

判重是BFS里最影响性能的环节之一。我试过三种判重方式:列表线性查找、set集合、布尔数组。

列表线性查找是最慢的,每次判重都要遍历已访问状态,时间复杂度O(n),状态量一大就完全不可用。set集合基于哈希表,平均查找时间O(1),是Python里最常用的判重方式,但常数比较大,每个状态都要计算哈希值。布尔数组是最快的,如果能把状态编码映射到一个连续的整数区间,直接用数组下标访问,速度极快。但八数码的整数编码最大到987654321,直接开这么大的数组不现实,需要做哈希映射或者康托展开。

康托展开是一种将排列映射为连续整数的技巧,对于n个元素的排列,可以把排列映射到0到n!-1之间的一个整数。八数码有9个位置,9! = 362880,开一个大小为362880的布尔数组完全可行。用康托展开做判重,速度比set快很多,而且内存占用也可控。

# 康托展开 def cantor(board): n = len(board) fact = [1] * n for i in range(1, n): fact[i] = fact[i-1] * i result = 0 for i in range(n): cnt = 0 for j in range(i+1, n): if board[j] < board[i]: cnt += 1 result += cnt * fact[n-1-i] return result

康托展开的计算过程是:对于排列中的第i个元素,统计它后面有多少个比它小的元素,乘以(n-1-i)的阶乘,然后累加。这样得到的整数是唯一的,且范围在0到n!-1之间。用这个整数作为数组下标,判重就是一次数组访问,速度极快。

3.3 裸BFS的实测表现与瓶颈定位

我拿几组不同的初始状态做了测试。对于步数在10步以内的状态,裸BFS基本秒出结果。步数在20步左右的时候,队列峰值能到几万个状态,时间在几百毫秒到一秒之间。步数超过25步的状态,队列峰值能到十几万,时间明显变长,有时候要好几秒。最坏情况下,比如初始状态离目标状态有31步(八数码的最远步数),裸BFS要遍历几乎所有可达状态,时间开销相当可观。

瓶颈主要在两个地方:一是搜索方向单一,从起点向终点单向扩展,中间状态大量堆积;二是判重和状态转移的常数开销,每次出队都要解码、找空格、尝试四个方向、编码、判重,操作链条很长。双向BFS主要解决第一个问题,A*主要解决第二个问题中的搜索方向引导问题。

4. 双向BFS加哈希的优化实践

4.1 双向BFS的核心思想与终止条件

双向BFS的思路是:从初始状态和目标状态同时开始BFS,两边轮流扩展一层,当某个状态同时被两边访问到时,就找到了一条从初始到目标的路径。因为BFS是按层扩展的,两边相遇时的步数之和就是最短路径长度。

这个思路的直观理解是:单向BFS像一个人从起点出发找终点,双向BFS像两个人分别从起点和终点出发,在中间某处碰头。如果搜索空间是一棵树,单向BFS要遍历的节点数大约是b^d(b是分支因子,d是深度),双向BFS要遍历的节点数大约是b^(d/2) + b^(d/2),当d比较大的时候,双向BFS的优势非常明显。

但双向BFS有个关键问题:终止条件比单向BFS复杂。单向BFS第一次遇到目标状态就可以返回,因为BFS保证按层扩展,第一次遇到就是最短路径。双向BFS不能简单地“一边遇到另一边访问过的状态就返回”,因为两边扩展的层数可能不一样,需要保证两边扩展的总层数是最小的。常见的做法是:每次选择队列较小的那一侧扩展一层,当某个状态同时出现在两边的visited集合中时,返回两边步数之和。但这样得到的步数不一定是最小的,因为可能还有更短的路径在后续扩展中出现。

更严谨的做法是:维护一个全局最优步数,每次两边扩展时,如果新状态在另一边的visited中,更新最优步数,但不立即返回,而是继续扩展直到当前层的步数之和已经大于等于最优步数为止。这样可以保证找到的是最短路径。

def bidirectional_bfs(start, target): start_code = encode(start) target_code = encode(target) if start_code == target_code: return 0 visited_start = {start_code: 0} visited_target = {target_code: 0} q_start = deque([start_code]) q_target = deque([target_code]) best = float('inf') while q_start and q_target: # 扩展较小的一侧 if len(q_start) <= len(q_target): best = expand(q_start, visited_start, visited_target, best) else: best = expand(q_target, visited_target, visited_start, best) if best != float('inf'): # 检查是否还有更短的路径可能 if len(q_start) + len(q_target) == 0: break return best if best != float('inf') else -1

4.2 哈希表在双向BFS中的关键作用

双向BFS需要维护两个visited字典,分别记录从起点和从终点出发到达每个状态的步数。这里用字典而不是集合,是因为需要记录步数,以便在两边相遇时计算总步数。字典的键是状态编码,值是步数。

哈希表的选择上,Python的dict底层是哈希表,平均查找时间O(1),完全够用。但如果追求极致性能,可以用康托展开把状态映射为连续整数,然后用两个数组来记录步数,数组访问比字典快很多。不过数组需要初始化,如果状态空间是362880,开两个大小为362880的数组,内存占用大约是几MB,完全可以接受。

提示:双向BFS中,两边visited字典的步数记录方式要一致。从起点出发的步数是正向步数,从终点出发的步数是反向步数,相遇时总步数 = 正向步数 + 反向步数。注意反向步数在计算时不要搞反方向。

4.3 双向BFS的扩展策略与步数计算

扩展策略上,我试过两种:轮流扩展一层和每次扩展较小的一侧。轮流扩展一层实现简单,但两边队列大小可能差异很大,导致某一侧扩展过多。每次扩展较小的一侧可以平衡两边的搜索量,通常效率更高。

步数计算方面,每次从队列中取出当前层的所有状态,对每个状态尝试四个方向,如果新状态在另一边的visited中,更新最优步数。注意,这里不能立即返回,因为可能还有更短的路径。要等到当前层的步数之和已经大于等于最优步数时,才能确定最优步数就是最短路径。

实测下来,双向BFS比单向BFS快很多。对于步数在20步左右的状态,单向BFS要几百毫秒,双向BFS只要几十毫秒。步数在25步以上的状态,双向BFS的优势更明显,有时候能快一个数量级。但双向BFS的代码复杂度比单向BFS高不少,需要仔细处理终止条件和步数计算,否则容易出错。

5. A*启发式搜索的进阶实现

5.1 启发函数的设计与选择

A搜索的核心是启发函数f(n) = g(n) + h(n),其中g(n)是从起点到当前状态的实际步数,h(n是从当前状态到目标状态的估计步数。A搜索每次从优先队列中取出f(n)最小的状态进行扩展,这样搜索会优先朝着目标方向前进,而不是像BFS那样均匀扩散。

启发函数h(n)的设计非常关键。h(n)必须满足两个条件:一是h(n) <= 实际从n到目标的最短步数(可采纳性),二是h(n)满足三角不等式(一致性)。满足可采纳性可以保证A*找到的是最短路径,满足一致性可以保证每个状态只被扩展一次。

八数码中常用的启发函数有两种:曼哈顿距离和逆序数距离。曼哈顿距离是每个数字当前位置到目标位置的曼哈顿距离之和,计算简单且可采纳。逆序数距离是当前状态的逆序数与目标状态的逆序数之差,也可以作为启发函数,但通常比曼哈顿距离弱。我一般用曼哈顿距离,因为它的估计更准确,搜索效率更高。

def manhattan(board): # 目标状态:1在(0,0),2在(0,1),...,8在(2,1),0在(2,2) target_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) } dist = 0 for i in range(9): if board[i] != 0: x, y = i // 3, i % 3 tx, ty = target_pos[board[i]] dist += abs(x - tx) + abs(y - ty) return dist

5.2 优先队列的实现与性能考量

A*搜索需要用优先队列来存储待扩展的状态,每次取出f(n)最小的状态。Python中可以用heapq模块实现优先队列,把(f(n), g(n), 状态编码)作为堆元素。注意,堆元素比较时如果f(n)相同,会比较g(n),如果g(n)也相同,会比较状态编码。为了避免状态编码比较出错,可以在堆元素中加入一个自增的计数器作为第三关键字。

import heapq def astar(start, target): start_code = encode(start) target_code = encode(target) if start_code == target_code: return 0 open_set = [] counter = 0 heapq.heappush(open_set, (manhattan(start), 0, counter, start_code)) g_score = {start_code: 0} closed_set = set() while open_set: f, g, _, code = heapq.heappop(open_set) if code in closed_set: continue if code == target_code: return g closed_set.add(code) board = decode(code) zero_pos = board.index(0) for dx, dy in [(0,1),(0,-1),(1,0),(-1,0)]: nx = zero_pos // 3 + dx ny = zero_pos % 3 + dy if 0 <= nx < 3 and 0 <= ny < 3: new_board = board[:] new_pos = nx * 3 + ny new_board[zero_pos], new_board[new_pos] = new_board[new_pos], new_board[zero_pos] new_code = encode(new_board) new_g = g + 1 if new_code not in g_score or new_g < g_score[new_code]: g_score[new_code] = new_g h = manhattan(new_board) counter += 1 heapq.heappush(open_set, (new_g + h, new_g, counter, new_code)) return -1

优先队列的性能主要取决于堆操作的开销。每次push和pop都是O(log n),n是堆中元素个数。A搜索中,堆的大小通常比BFS的队列小很多,因为搜索被引导向目标方向,不会像BFS那样均匀扩散。但A搜索也有个问题:如果启发函数不够强,堆中可能会堆积大量f(n)相同的状态,导致堆操作变慢。

5.3 A*搜索的实测效果与调优经验

实测下来,A搜索比双向BFS还要快。对于步数在20步左右的状态,A通常只要几毫秒到几十毫秒,比双向BFS快一个数量级。步数在25步以上的状态,A的优势更明显,有时候能快两个数量级。但A的性能高度依赖启发函数的质量,曼哈顿距离虽然可采纳,但估计值偏小,导致搜索仍然会扩展不少状态。如果能把启发函数设计得更准确,比如用模式数据库或者更复杂的启发函数,搜索效率还能进一步提升。

调优经验方面,有几个点值得注意。一是启发函数的计算开销要控制,曼哈顿距离每次计算要遍历9个位置,如果计算太慢,会抵消搜索效率的提升。可以在状态转移时增量更新曼哈顿距离,而不是每次重新计算。二是优先队列的堆元素要避免比较状态编码,用计数器作为第三关键字可以避免这个问题。三是closed_set的判重要及时,避免重复扩展同一个状态。

注意:A*搜索中,如果一个状态被扩展后,又通过另一条路径以更小的g值到达,需要更新g_score并重新入堆。但有了closed_set后,已经扩展过的状态不再扩展,这要求启发函数满足一致性。曼哈顿距离满足一致性,所以可以安全使用closed_set。

6. 三种算法的对比与选型建议

6.1 时间与空间复杂度对比

三种算法的时间复杂度和空间复杂度差异很大。裸BFS的时间复杂度是O(b^d),空间复杂度也是O(b^d),其中b是分支因子(八数码中最多4),d是最短路径长度。双向BFS的时间复杂度是O(b^(d/2)),空间复杂度也是O(b^(d/2)),理论上比裸BFS快很多。A*搜索的时间复杂度取决于启发函数的质量,如果启发函数完美,时间复杂度接近O(d),空间复杂度也是O(b^d),但实际中堆的大小通常比BFS队列小。

算法时间复杂度空间复杂度实测20步耗时实测25步耗时
裸BFSO(b^d)O(b^d)几百毫秒几秒
双向BFSO(b^(d/2))O(b^(d/2))几十毫秒几百毫秒
A*O(b^d)但常数小O(b^d)但堆较小几毫秒几十毫秒

6.2 不同场景下的算法选型

选型方面,如果只是做一道题,步数不超过15步,裸BFS完全够用,代码简单不容易出错。如果步数在15到25步之间,双向BFS是更好的选择,代码复杂度适中,性能提升明显。如果步数超过25步,或者需要处理大量测试用例,A*搜索是最优选择,但代码复杂度最高,需要仔细处理启发函数和优先队列。

另外,如果题目要求输出路径而不仅仅是步数,双向BFS和A都需要额外记录路径信息,代码会更复杂。裸BFS记录路径最简单,每个状态记录父状态即可。双向BFS需要从两边分别记录父状态,相遇时拼接路径。A搜索也需要记录父状态,但因为有closed_set,路径记录要小心处理。

6.3 从八数码延伸出去的通用搜索优化思路

八数码的优化思路可以延伸到很多其他搜索问题。双向BFS适用于任何状态空间可逆的问题,比如迷宫最短路径、单词接龙等。A*搜索适用于任何可以设计启发函数的问题,比如路径规划、游戏AI等。哈希去重和康托展开适用于任何需要判重的搜索问题。

我个人的经验是,搜索算法的优化没有银弹,需要根据具体问题的特点来选。状态空间小、步数少的问题,裸BFS就够了。状态空间大、步数多的问题,双向BFS和A是更好的选择。启发函数的设计是A的核心,好的启发函数能让搜索效率提升几个数量级。判重结构的选择也很关键,康托展开加数组判重是八数码的最优解,但其他问题可能需要不同的判重方式。

7. 常见问题与排查技巧实录

7.1 无解判断与边界情况处理

八数码最常见的坑就是无解判断。如果初始状态无解,BFS会遍历完所有可达状态后返回-1,浪费时间。提前用逆序数判断可以避免这个问题。逆序数的计算方法是:把九个数按行展开成一维序列,忽略空格,计算逆序对的个数。如果初始状态和目标状态的逆序数奇偶性相同则有解,不同则无解。

边界情况方面,如果初始状态就是目标状态,直接返回0。如果初始状态只有一个数字,那肯定无解。如果初始状态中有重复数字,那输入就是非法的,但一般题目不会给这种输入。

7.2 状态编码与解码的常见错误

状态编码和解码是八数码中最容易出错的地方。整数编码时,空格映射为9还是0要统一,否则会出现前导零问题。解码时,要把整数拆成九个数,注意顺序要和编码时一致。字符串编码时,空格用'0'还是'x'要统一,否则判重会出错。

还有一个常见的错误是状态转移时坐标计算错误。空格的位置是zero_pos,它在棋盘中的行是zero_pos // 3,列是zero_pos % 3。移动后的新位置是nx * 3 + ny,注意nx和ny的范围是0到2。如果坐标计算错误,会导致状态转移出错,搜索结果不正确。

7.3 性能调优的实用技巧汇总

性能调优方面,有几个实用技巧。一是用康托展开加数组判重,比set快很多。二是增量更新启发函数,避免每次重新计算。三是用整数编码代替字符串编码,减少比较和哈希的开销。四是双向BFS中每次扩展较小的一侧,平衡两边的搜索量。五是A*搜索中用计数器作为堆元素的第三关键字,避免比较状态编码。

提示:如果时间限制很紧,可以把所有可能的初始状态预处理出来,记录每个状态到目标状态的最短步数,然后直接查表。八数码的可达状态只有181440个,预处理一次后,后续查询都是O(1)。这个技巧在需要处理大量测试用例的时候非常有用。

7.4 常见问题速查表

问题现象可能原因解决方法
结果步数偏大状态转移时坐标计算错误检查zero_pos的行列计算
判重失效编码方式不统一统一空格映射和编码顺序
无解时超时没有提前判断可解性用逆序数奇偶性判断
A*结果不是最短启发函数不可采纳检查h(n)是否小于等于实际步数
双向BFS结果错误终止条件处理不当维护全局最优步数,不立即返回
内存超限判重结构占用太大用康托展开加布尔数组

8. 实操复盘与个人经验总结

回过头来看,八数码这道题的价值不在于题目本身,而在于它串起了搜索算法的完整进化链。从裸BFS到双向BFS加哈希,再到A启发式搜索,每一步优化都有明确的动机和可量化的效果。我当初刷这道题的时候,一开始只写了裸BFS,后来看到别人用双向BFS,就跟着改了一版,再后来了解到A,又改了一版。每次改完都跑一遍测试,对比耗时,那种性能提升的直观感受比看任何理论分析都来得深刻。

如果让我给刚接触这道题的朋友一个建议,我会说:先把裸BFS写熟,理解状态空间和判重的基本概念;然后尝试双向BFS,理解双向搜索的终止条件和步数计算;最后挑战A*,理解启发函数的设计和优先队列的使用。每一步都不要跳过,因为每一步暴露的问题都是下一步优化的动机。踩过的坑越多,对搜索算法的理解就越深。

最后分享一个小技巧:如果实在搞不定A的启发函数,可以用双向BFS加康托展开判重,这个组合在八数码上已经足够快,代码复杂度也比A低不少。我实测下来,双向BFS加康托展开在大多数测试用例上都能在几十毫秒内出结果,对于一般的算法练习平台来说完全够用。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进