经典15-puzzle问题的简单优化尝试

经典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_intint_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=x1x2+y1y2 整个拼图状态的曼哈顿距离启发式值 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问题的求解效率。有兴趣的同学可以考虑使用更复杂的启发式函数、并行计算等方法进一步优化算法,以应对更复杂的问题。同时,还可以对算法的性能进行更深入的分析和评估,为算法的优化提供更有力的依据。

附 完整代码📚请到作者个人Github仓库:👉🔜 https://github.com/Yodeesy/AI-lab.git
如果你觉得本文对你有所帮助,欢迎 👍点赞、⭐收藏、📌订阅支持 ~
如有建议、疑问,欢迎评论交流!💬😊💕
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值