返回

井字棋AI不灵?Minimax算法和Alpha-Beta剪枝调试指南

Ai

解决井字棋 Minimax 算法 AI 无法做出正确选择的问题

碰到一个让人头疼的问题,我用 Minimax 算法和 Alpha-Beta 剪枝给井字棋游戏写了个 AI,但它好像不太灵光,总是从左到右依次选择下一个空格。 看来代码里有些小 bug, 让我们一起来看看问题可能出在哪里吧。

问题原因分析

这个 AI 表现出的行为很典型,它只选择第一个可用的位置,说明 Minimax 算法的核心逻辑或者说决策部分出了问题。 具体可能的原因有:

  1. 返回值问题: Minimax 函数可能没有正确返回最佳选择对应的 位置,而是只返回了最佳选择的 分数
  2. 递归逻辑错误: 递归调用 Minimax 函数时,参数传递可能有问题,比如玩家(HUMAN/AI)标记错误,导致 AI 没有模拟对手的走法。
  3. Alpha-Beta 剪枝错误: 虽然问题中说用到了 Alpha-Beta 剪枝,但是具体实现可能存在问题,导致不正确的剪枝发生,跳过了某些可能更好的选择。
  4. 状态更新问题 :在模拟走棋的过程之中, 临时修改棋盘状态之后, 没有在递归后复原, 造成污染。

解决方案

针对上述可能原因,我们可以逐一排查并修复。下面是具体的解决方案,包括修改后的代码和原理说明。

1. 修改 Minimax 函数的返回值

原始的 Minimax 函数只返回了最佳选择的分数。要让 AI 做出正确的选择,我们需要让它返回 最佳选择的位置

原理:

Minimax 算法的核心是递归地模拟所有可能的走法,评估每个走法最终导致的局面(输赢或平局)。 原本的代码仅返回局面分数,而我们需要得到 “如果AI走了哪一步,可以达到当前最好局面分数”,即位置。

修改后的代码:

func minimax(_board, _depth: int, alpha, beta, maximizingPlayer: bool) -> Array: #返回一个Array
    var result = winner_score()
    if _depth == 0 or result != 0:
        return [result, -1, -1]  # [分数, 行, 列]

    if maximizingPlayer:
        var maxEval: int = -100
        var best_row = -1
        var best_col = -1
        for row in range(board.size):
            for col in range(board.size):
                var square = _board[row][col]
                if square.type == null:
                    square.type = AI # 这里改成了AI
                    var eval: Array = minimax(_board, _depth - 1, alpha, beta, false)
                    square.type = null
                    if eval[0] > maxEval:
                        maxEval = eval[0]
                        best_row = row
                        best_col = col
                    alpha = max(alpha, eval[0])
                    if beta <= alpha: break
            if beta <= alpha: break # 双层循环的剪枝
        return [maxEval, best_row, best_col]

    else:
        var minEval = 100
        var best_row = -1
        var best_col = -1
        for row in range(board.size):
            for col in range(board.size):
                var square = _board[row][col]
                if square.type == null:
                    square.type = HUMAN # 这里改成了 HUMAN
                    var eval: Array = minimax(_board, _depth - 1, alpha, beta, true)
                    square.type = null
                    if eval[0] < minEval:
                        minEval = eval[0]
                        best_row = row
                        best_col = col
                    beta = min(beta, eval[0])
                    if beta <= alpha: break
            if beta <= alpha: break # 双层循环的剪枝
        return [minEval, best_row, best_col]

代码说明:

  • Minimax 函数的返回值改为一个数组:[分数, 行, 列]
  • 在每次递归调用 minimax 函数后, 获取的是一个Array, 通过eval[0]获得分数。
  • 通过对比分数 eval[0], 记录下对应最好分数的 best_row, best_col.
  • 修改了AI/HUMAN 的设置,修复了原来弄反的地方。
  • 双层循环的剪枝。
  • 在顶层调用 minimax 函数的地方(例如 AI 的决策函数),就可以直接获取最佳走法的位置。

2. 确保状态正确还原

这是一个非常重要的细节。Minimax算法模拟下棋时, 要确保每个节点的棋盘是干净,独立的。

原理:

在递归中,每一次的棋盘变化都是局部的, 不能干扰上层或同一层的其他模拟分支。 如果不复原, 那么第一次模拟可能把所有空位填满,接下来的兄弟分支无处可走, 模拟失效。

改进技巧(深度复制):
虽然上述代码已进行square.type = null进行了复原, 但考虑到进阶用法及复杂情况, 如果 _board 内对象不是简单的基础类型, 则需要用深度复制:

var copied_board = duplicate_board(_board)

func duplicate_board(_board):
    var new_board = []
    for row in _board:
        var new_row = []
        for square in row:
          #对于自定义类对象, 可能需要自己实现duplicate方法, 而非Godot的 .duplicate()
          new_row.append(square.duplicate())  
        new_board.append(new_row)
    return new_board

每次minimax 调用开头就深度复制一个棋盘副本 copied_board ,用copied_board参与接下来的运算.

3. AI 的决策函数

有了修改后的 Minimax 函数,我们需要一个函数来调用它,并根据返回值做出决策。

代码示例:

func get_ai_move():
    var best_move = minimax(board.grid, 9, -INF, INF, true) # 假设棋盘是 board.grid, 初始深度设大一些
    var row = best_move[1]
    var col = best_move[2]

    if row != -1 and col != -1:
       board.grid[row][col].type = AI
    else:
        print("No valid move found!") # 处理没有合法走法的情况(例如棋盘已满)

代码说明:
调用 minimax, 获取了best_move, 直接应用best_move[1]best_move[2]即可.

4. 关于 Alpha-Beta 剪枝

上述修改后的代码中,Alpha-Beta 剪枝的部分应该能正常工作。 但是要注意一些关键点:

  • Alpha 和 Beta 的初始值: alpha 应该初始化为负无穷大(-INF),beta 应该初始化为正无穷大(INF)。
  • 剪枝条件:beta <= alpha 时,可以进行剪枝。
  • 更新 Alpha 和 Beta: 在最大化层(maximizingPlayertrue)更新 alpha,在最小化层更新 beta

额外建议:调整搜索深度

在井字棋中,由于棋盘较小,Minimax 算法通常可以搜索到完整的游戏树。但在更复杂的游戏中(例如五子棋、围棋),搜索整个游戏树是不现实的。这时候限制 Minimax 算法的搜索深度是很关键的,需要在AI的性能和响应时间两者之间寻找一个平衡点。 一般会使用迭代加深的方法去获得最佳结果。

如果仍有问题, 建议将AI获取最佳位置的核心逻辑,即如何获取best_rowbest_col这一逻辑, 单独分离成小函数进行调试, 会更便于排查错误。