经典15 - Puzzle问题的简单优化尝试
引言
15 - Puzzle问题是一个经典的滑动拼图游戏,在计算机科学和人工智能领域具有重要地位。其目标是将一个4x4棋盘上的15个编号方块从初始状态移动到目标状态,玩家通过移动空白方块周围的方块来实现这一目标。本文将深入探讨使用 A* 和 ID A* 算法解决该问题,并对算法进行简单优化。
文章结构总览
传统 A* 算法 及 ID A* 算法
A* 算法
A* 算法是一种启发式搜索算法,用于在图中寻找从起始节点到目标节点的最短路径。它结合了 Dijkstra 算法的最优路径搜索和贪心最佳优先搜索的启发式搜索。其核心是使用一个评估函数 f ( n ) = g ( n ) + h ( n ) f(n) = g(n) + h(n) f(n)=g(n)+h(n) 来评估每个节点的优先级,其中:
- g ( n ) g(n) g(n) 是从起始节点到节点 n n n 的实际代价。
- h ( n ) h(n) h(n) 是从节点 n n n 到目标节点的启发式估计代价。
算法维护两个列表:开放列表和关闭列表。开放列表存储待扩展的节点,关闭列表存储已扩展的节点。算法不断从开放列表中选择 f ( n ) f(n) f(n) 值最小的节点进行扩展,直到找到目标节点或开放列表为空。
function A*(start, goal):
open_list = priority_queue() // 优先队列,按 f 值排序
closed_list = set() // 存储已扩展的节点
open_list.push(start, f(start)) // 将起始节点加入开放列表
while open_list is not empty:
current = open_list.pop() // 取出 f 值最小的节点
if current == goal:
return reconstruct_path(current) // 找到目标节点,返回路径
closed_list.add(current) // 将当前节点加入关闭列表
for neighbor in get_neighbors(current): // 遍历当前节点的邻居
if neighbor in closed_list:
continue // 如果邻居已在关闭列表中,跳过
tentative_g = g(current) + movement_cost(current, neighbor) // 计算从起始节点到邻居的临时代价
if neighbor not in open_list or tentative_g < g(neighbor):
parent(neighbor) = current // 更新邻居的父节点
g(neighbor) = tentative_g // 更新邻居的 g 值
h(neighbor) = heuristic(neighbor, goal) // 计算邻居的 h 值
f(neighbor) = g(neighbor) + h(neighbor) // 计算邻居的 f 值
if neighbor not in open_list:
open_list.push(neighbor, f(neighbor)) // 如果邻居不在开放列表中,加入开放列表
return failure // 未找到路径
ID A* 算法
ID A* 算法是一种迭代加深的 A* 算法,结合了深度优先搜索的空间效率和 A* 算法的启发式搜索能力。它通过不断增加深度限制来搜索解。在每次迭代中,算法进行深度优先搜索,直到达到当前的深度限制。如果没有找到解,则增加深度限制并重新进行搜索。
function ID_A*(start, goal):
bound = heuristic(start, goal) // 初始深度限制为起始节点的启发式估计代价
path = [start] // 初始化路径
while true:
t = search(path, 0, bound) // 进行深度优先搜索
if t == 0:
return path // 找到解,返回路径
if t == infinity:
return failure // 无解
bound = t // 更新深度限制
function search(path, g, bound):
node = path.last() // 获取当前路径的最后一个节点
f = g + heuristic(node, goal) // 计算当前节点的 f 值
if f > bound:
return f // 如果 f 值超过深度限制,返回 f 值
if node == goal:
return 0 // 找到目标节点,返回 0
min_bound = infinity // 初始化最小深度限制为无穷大
for neighbor in get_neighbors(node): // 遍历当前节点的邻居
if neighbor not in path:
path.push(neighbor) // 将邻居加入路径
t = search(path, g + movement_cost(node, neighbor), bound) // 递归搜索邻居
if t == 0:
return 0 // 找到解,返回 0
if t < min_bound:
min_bound = t // 更新最小深度限制
path.pop() // 回溯
return min_bound // 返回最小深度限制
⚠️ 传统算法的性能瓶颈
A* 问题
-
状态空间爆炸,扩展节点冗余;
-
启发式不准确,如曼哈顿距离未考虑冲突;
-
内存开销大,开放/关闭列表维护耗资源;
-
重复计算 heuristic,缺乏缓存机制。
ID A* 问题
-
重复搜索严重,每轮从头递归;
-
边界值调整策略粗糙,效率低;
-
栈递归开销大;
-
启发式不准时易走弯路。
优化思路分析
1️⃣转换为 64 位整数
在 15 - Puzzle 问题中,我们需要频繁地比较和存储不同的拼图状态。如果使用二维数组来表示状态,比较操作的时间复杂度较高,而且存储多个状态会占用较多的内存。
❇️通过 puzzle_to_int
和 int_to_puzzle
函数将 4x4 的拼图状态转换为 64 位整数。每个数字用 4 位表示,这样一个 4x4 的拼图状态可以用一个 64 位整数表示。
def puzzle_to_int(puzzle):
value = 0
for row in puzzle:
for num in row:
value = (value << 4) | num # 每个数字占 4 位
return value
def int_to_puzzle(value):
puzzle = []
for _ in range(4):
row = []
for _ in range(4):
row.insert(0, value & 0xF) # 取低 4 位
value >>= 4
puzzle.insert(0, row) # 从底部插入,恢复顺序
return puzzle
优点🚀
- ✅比较效率提高:整数比较操作的时间复杂度为 O ( 1 ) O(1) O(1),而二维数组比较的时间复杂度为 O ( n 2 ) O(n^2) O(n2),其中 n n n 是拼图的边长。
- ✅内存使用减少:整数的存储比二维数组更紧凑,减少了内存开销。
2️⃣启发式函数优化
在搜索算法的初始阶段,通常仅使用 曼哈顿距离 作为启发式函数。
曼哈顿距离是一种简单且有效的启发式度量,它衡量了在网格中从一个位置到另一个位置,在水平和垂直方向上移动的步数之和。 对于一个 n × n n \times n n×n的拼图问题,假设当前拼图状态中某个方块的位置为 ( x 1 , y 1 ) (x_1, y_1) (x1,y1),其目标位置为 ( x 2 , y 2 ) (x_2, y_2) (x2,y2),那么该方块的曼哈顿距离计算公式为: d manhattan = ∣ x 1 − x 2 ∣ + ∣ y 1 − y 2 ∣ d_{\text{manhattan}} = |x_1 - x_2| + |y_1 - y_2| dmanhattan=∣x1−x2∣+∣y1−y2∣ 整个拼图状态的曼哈顿距离启发式值 h manhattan h_{\text{manhattan}} hmanhattan 就是所有方块的曼哈顿距离之和。
然而,仅依靠曼哈顿距离作为启发式函数,可能无法充分利用拼图状态的所有信息,导致搜索效率存在一定的局限性。
❇️为了进一步提升搜索效率,经过多轮优化,引入了 逆序数 和 线性冲突 这两个启发式信息,并将它们与曼哈顿距离进行加权组合,形成 混合启发式函数。
- 逆序数:在一个序列中,如果前面的元素大于后面的元素,则称这两个元素构成一个 逆序对,逆序数就是序列中逆序对的总数。在拼图问题中,逆序数反映了拼图的混乱程度。例如,在一个一维的拼图序列中,逆序数越大,说明拼图越混乱,到达目标状态所需的移动步数可能就越多。
def count_inversions(puzzle):
flat_puzzle = [num for row in puzzle for num in row if num != 0]
inversions = sum(1 for i in range(len(flat_puzzle)) for j in range(i + 1, len(flat_puzzle)) if flat_puzzle[i] > flat_puzzle[j])
return inversions
- 线性冲突:在线性冲突中,两个方块在同一行或同一列,并且它们的目标位置也在同一行或同一列,但它们的相对位置与目标状态不一致。这种情况下,为了将这些方块移动到正确的位置,需要额外的移动步数。
def linear_conflict(puzzle):
conflicts = 0
for i in range(4):
row_values = [(puzzle[i][j], j) for j in range(4) if puzzle[i][j] != 0]
sorted_values = sorted(row_values, key=lambda x: x[0])
for j in range(len(row_values)):
if row_values[j][1] > sorted_values[j][1]:
conflicts += 1
for j in range(4):
col_values = [(puzzle[i][j], i) for i in range(4) if puzzle[i][j] != 0]
sorted_values = sorted(col_values, key=lambda x: x[0])
for i in range(len(col_values)):
if col_values[i][1] > sorted_values[i][1]:
conflicts += 1
return 2 * conflicts
- 混合启发式函数:将上述三种启发式函数结合起来,通过加权求和得到更准确的估计值(加权所用参数仅为初步设计,可进一步设计算法寻找最优权重组合)。
def mixed_heuristic(puzzle, alpha=1.0, beta=0.4, gamma=0.8):
return alpha * manhattan_distance(puzzle) + beta * count_inversions(puzzle) + gamma * linear_conflict(puzzle)
优点🚀
- ✅准确的估计:混合启发式函数综合考虑了多个因素,能够更准确地估计节点到目标节点的代价,减少了不必要的节点扩展。
- ✅提高搜索效率:由于启发式函数更准确,算法能够更快地找到最优解,提高了搜索效率。
3️⃣@lru_cache
的使用
在搜索过程中,会多次计算相同状态的启发式值。如果每次都重新计算,会浪费大量的时间。
❇️ functools.lru_cache
是 Python 提供的内置缓存机制,可以用于优化函数的性能,特别适用于纯函数(即相同输入总是返回相同输出,并且没有副作用)。
使用 @lru_cache
装饰器来缓存启发式函数的计算结果。当再次需要计算相同状态的启发式值时,直接从缓存中获取,避免了重复计算。
@lru_cache(maxsize=None)
def heuristic(state_int):
return mixed_heuristic(int_to_puzzle(state_int))
优点🚀
- ✅减少计算量:适用于启发式函数,避免重复计算相同状态的 heuristic 值,访问相同状态时,直接返回缓存的值,而不用重新计算。
- ✅自动管理缓存:lru_cache 只会缓存最近使用的 maxsize 个结果,自动删除旧数据,避免无限增长占用内存。
- ✅简单易用:
@lru_cache
是 Python 标准库中的装饰器,无需手动维护 heuristic_cache 字典,直接用 lru_cache 装饰器即可。
⚠️ lru_cache 的局限‼️
-
❌ 只能用于不可变对象(tuple/frozenset),不能直接用于 list/dict
需要转换 list → tuple,否则会报错:
@lru_cache(maxsize=None)
def heuristic(state):
return mixed_heuristic(tuple(map(tuple, state))) # 先转换
-
❌ 只适用于单进程
multiprocessing 不能共享 lru_cache,如果是并行计算,仍需 Manager().dict() 共享缓存。
📌 为什么不推荐线程池/进程池
❌ 线程池受 GIL 限制
-
Python 的 全局解释器锁(GIL) 会限制 CPU 并行性能。
-
调用 启发式函数、状态扩展 等是 纯计算任务,多线程提升不明显。
❌ 进程池通信代价高
-
每个状态需要通过 pickle 序列化传入子进程,序列化和反序列化非常耗时。
-
启发式函数计算快但调用频繁,用多进程反而拖慢整体效率。
4️⃣ IDA* 算法边界值更新策略优化
传统 ID A* 算法通常通过固定步长增加边界值,但这种方法在实际应用中可能存在效率问题。具体来说,固定步长可能导致在搜索过程中产生大量无效的扩展节点,尤其是在问题空间较大或启发式函数不够精确的情况下。这种情况下,算法需要反复扩展许多不必要的节点,从而显著降低了整体的搜索效率。
❇️采用 bound = max(1, t * 1.2)
的更新策略,避免了边界值增长过快,减少了不必要的搜索。
while True:
visited.clear()
t = search(path, 0, bound, visited)
if t == 0:
return [int_to_puzzle(state) for state in path]
if t == float('inf'):
return None
# 调整边界值更新策略(避免过快增长)
bound = max(1, t * 1.2)
优点🚀
- ✅减少不必要的搜索:通过避免边界值增长过快,减少了不必要的节点扩展,提高了算法的效率。
- ✅自适应调整:根据搜索结果动态调整边界值,使算法能够更快地收敛到最优解。
实验结果与分析
通过实验发现,在一些简单的拼图问题中,优化可能只会带来微小的时间提升;而在复杂的拼图问题中,时间提升较为显著,达到 50% 甚至更多,并且扩展的节,点数也明显减少。但对于一些极端复杂的初始状态,算法的求解时间仍然较长,需要进一步优化。
总结
本文通过对传统的 A* 和 ID A* 算法进行简单优化,提高了15 - Puzzle问题的求解效率。有兴趣的同学可以考虑使用更复杂的启发式函数、并行计算等方法进一步优化算法,以应对更复杂的问题。同时,还可以对算法的性能进行更深入的分析和评估,为算法的优化提供更有力的依据。