井字棋AI不灵?Minimax算法和Alpha-Beta剪枝调试指南
2025-03-20 02:02:10
解决井字棋 Minimax 算法 AI 无法做出正确选择的问题
碰到一个让人头疼的问题,我用 Minimax 算法和 Alpha-Beta 剪枝给井字棋游戏写了个 AI,但它好像不太灵光,总是从左到右依次选择下一个空格。 看来代码里有些小 bug, 让我们一起来看看问题可能出在哪里吧。
问题原因分析
这个 AI 表现出的行为很典型,它只选择第一个可用的位置,说明 Minimax 算法的核心逻辑或者说决策部分出了问题。 具体可能的原因有:
- 返回值问题: Minimax 函数可能没有正确返回最佳选择对应的 位置,而是只返回了最佳选择的 分数。
- 递归逻辑错误: 递归调用 Minimax 函数时,参数传递可能有问题,比如玩家(HUMAN/AI)标记错误,导致 AI 没有模拟对手的走法。
- Alpha-Beta 剪枝错误: 虽然问题中说用到了 Alpha-Beta 剪枝,但是具体实现可能存在问题,导致不正确的剪枝发生,跳过了某些可能更好的选择。
- 状态更新问题 :在模拟走棋的过程之中, 临时修改棋盘状态之后, 没有在递归后复原, 造成污染。
解决方案
针对上述可能原因,我们可以逐一排查并修复。下面是具体的解决方案,包括修改后的代码和原理说明。
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: 在最大化层(
maximizingPlayer
为true
)更新alpha
,在最小化层更新beta
。
额外建议:调整搜索深度
在井字棋中,由于棋盘较小,Minimax 算法通常可以搜索到完整的游戏树。但在更复杂的游戏中(例如五子棋、围棋),搜索整个游戏树是不现实的。这时候限制 Minimax 算法的搜索深度是很关键的,需要在AI的性能和响应时间两者之间寻找一个平衡点。 一般会使用迭代加深的方法去获得最佳结果。
如果仍有问题, 建议将AI获取最佳位置的核心逻辑,即如何获取best_row
与best_col
这一逻辑, 单独分离成小函数进行调试, 会更便于排查错误。