☰
从博弈树到Minimax:用Python实现一个永赢的井字棋AI
2026/10/17 5:07:24 网站建设 项目流程

大学时有一门课讲到博弈树,当时我盯着黑板上的树状图,脑子里就冒出一个很简单的想法:能不能写一个“棋类 AI”出来?只要它会下棋、能挡我的棋、偶尔赢我一盘,我就满足了。后来因为项目忙、算法没吃透,这个想法一直停在“等以后有空再说”的备忘录里。最近整理旧代码时又翻到这条备忘,索性用一晚上把它落地了。

最终做出来的东西,其实就是一个经典井字棋(Tic-Tac-Toe)的 Minimax 命令行版本。没有图形界面,没有花哨框架,核心决策逻辑只有二十来行 Python。它能做到的事情是:无论你先手还是后手,只要 AI 先走或按最优策略走,你就永远赢不了它;反过来,只要玩家走出任何一个破绽,AI 会立刻锁定胜局。这个项目很适合想弄懂“决策树”“递归回溯”“极小化极大”的人入门,哪怕你只会基础语法,也能跟着下面的思路一步步复现出来。

1. 选题复盘:为什么“最简单”反而是最优解

1.1 井字棋到底简单在哪

井字棋大概是所有棋类里棋盘最小的那个:3×3 一共 9 个格子。从空盘到终局,完整走棋序列最多也就是 9! 种,还要提前去掉大量分胜负的分支,全部展开后整个博弈树也就几万个节点。在个人电脑上,不用任何优化,几十毫秒就能扫完。这个“小”对新手极其友好:你可以把整棵博弈树画在纸上,也可以随手在代码里加打印语句来观察每一步决策。

相比之下,五子棋的棋盘是 15×15,围棋是 19×19,它们的搜索空间大到不可能完整枚举。所以“最简单棋类 AI”这个题目,选井字棋不是偷懒,而是把研究重点从“如何搜索完整空间”转移到“如何正确建立搜索逻辑”上。空间一旦扩大到无法穷举,你还得学评估函数、剪枝、启发式搜索,那对新手来说就是灾难。井字棋刚好把“AI 到底是怎么做决策的”这件事暴露在阳光下,一眼就能看穿。

1.2 我在三种候选方案里怎么取舍

大学时设想过三种实现思路,这次实际操作后,我把它们放在一起做了个对比:

  • 方案A:记忆库。把常见的局面存成一张表,局面对应走法。理论上可行,但井字棋去掉旋转镜像也有几百种有意义的局面,若要覆盖全分支就得手工录入大量数据,工程量大且很难保证不漏。

  • 方案B:规则权重。给每个格子打分,比如中心权重高、角权重次之、边权重最低,AI 每次都去抢最高分位置。它能玩,但本质上是一堆 if 的堆砌,规则稍微有漏洞就会肉眼可见地变笨。

  • 方案C:Minimax 极小化极大。遍历对手所有可能的回应,在“最坏情况”下选出对自己最有利的一步。这也是大多数棋类 AI 的通用框架。

我最终选了方案C。原因很直接:代码量最少,逻辑可解释,而且不受规则表束缚。你要给 AI 换一种棋,只需要改棋盘大小和胜负判定,决策核心完全不用动。大学时我觉得 Minimax 这个名字很神秘,真正写完之后发现,它就是“站在对方的角度想问题”的递归版本,没有任何玄学成分。

2. 动手前先把 Minimax 核心思路捋顺

2.1 让电脑站在最坏局面上做决定

大多数人第一次听说 Minimax,会被“极大极小”这四个字绕晕。我用一个生活化的例子解释:你想买一台二手相机,卖家会抬价(想让交易价尽可能高),你会压价(想让交易价尽可能低)。作为买家,你不会天真地假设卖家会给你让步,而是假设对方一定会把价格抬到最高,然后再在这个“对自己最不利的价格”里,挑一个自己能接受的最低值。这个决策方式,就是 Minimax。

把同样的逻辑搬到棋类上:AI 落子之后,一定会轮到玩家行动;玩家目标是让 AI 的收益最小。所以 AI 在思考某个走法好不好时,不能只看这一步,还要继续往下推演——假设玩家会走他所有可行走法里对 AI 最不利的那一步,而玩家走完后 AI 又会选择最有利的……如此递归,直到棋盘分出胜负或者填满。

举个例子,假设轮到 AI 落子,局面是:

X O _ X _ _ _ _ O

AI 如果把某个格子填成自己的棋子,可能引发三种终态结果:有些走法会让玩家直接赢(AI 输),有些走法最终平局,还有些走法 AI 能赢。Minimax 会这样评估:如果某走法之后,无论玩家怎么应对,AI 的最坏结果只是平局,那这个走法比“某些分支能赢、但某些分支会输”的走法更稳。它追求的不是一场漂亮的胜利,而是先保证不输,再用机会抓对方失误。

2.2 赢、输、平的评估值怎么定才不坑自己

Minimax 递归到底返回什么?最简单可靠的方案是固定三个分值:

  • AI 胜利返回10
  • AI 失败返回-10
  • 平局返回0

这三个值必须始终站在 AI 视角来打分,不能中途换视角。AI 自己的回合是最大化分支,玩家回合是最小化分支。你可能会问:为什么不用更大的数,比如 100 和 -100?在井字棋里没区别,因为终局只有三种,不存在“赢得多漂亮”的差异。真正有区分度的时候是五子棋、国际象棋这类需要评估局面优劣的游戏,那时才需要更复杂的评分函数。

有一个细节我特别想提醒:如果你想让 AI 不仅赢,还想赢得快,可以给分值加深度权重,比如胜利返回10 - depth,失败返回depth - 10。意思是同样能赢,越早赢分数越高;同样会输,越晚输分数越高。但作为“最简单版本”,先不加深度权重,固定 10 / -10 / 0 最好,逻辑容易验证,调 bug 也不费劲。

3. 代码落地:写一个能玩的命令行井字棋

3.1 棋盘用一个列表,胜负判定只用 8 个组合

我用长度为 9 的列表表示棋盘,索引 0 到 8 对应九宫格。为什么不用二维数组?因为一维列表遍历空位、做循环、判断胜负都更省事,打印时再按三行输出就行,没必要把一个简单项目复杂化。

board = [' '] * 9 def display_board(b): for i in range(0, 9, 3): print(' | '.join(b[i:i + 3])) if i < 6: print('-' * 9) def check_winner(b, player): lines = [ (0, 1, 2), (3, 4, 5), (6, 7, 8), # 三行 (0, 3, 6), (1, 4, 7), (2, 5, 8), # 三列 (0, 4, 8), (2, 4, 6) # 两条对角线 ] for a, c, d in lines: if b[a] == b[c] == b[d] == player: return True return False def is_full(b): return ' ' not in b def available_moves(b): return [i for i, v in enumerate(b) if v == ' ']

胜负判定是所有逻辑的地基。井字棋的胜利条件一共就 8 种组合:3 行加 3 列加 2 条对角线。我把所有组合写成一个元组列表,循环判断即可。这里有个顺序上的铁律:先判断胜负,再判断平局,反过来会出大问题。如果一个玩家已经连成三子且棋盘正好填满,先判平局就会把胜利吞掉,这种 bug 往往要跑很多局才能发现。

3.2 Minimax 递归与最佳走法选出来

核心逻辑来了。minimax函数接收当前棋盘状态和“当前轮到谁决策”两个参数:

AI = 'X' HUMAN = 'O' def minimax(b, is_maximizing): if check_winner(b, AI): return 10 if check_winner(b, HUMAN): return -10 if is_full(b): return 0 if is_maximizing: best_value = -float('inf') for move in available_moves(b): b[move] = AI best_value = max(best_value, minimax(b, False)) b[move] = ' ' return best_value else: best_value = float('inf') for move in available_moves(b): b[move] = HUMAN best_value = min(best_value, minimax(b, True)) b[move] = ' ' return best_value def best_move(b): best_score = -float('inf') move_to_play = None for move in available_moves(b): b[move] = AI score = minimax(b, False) b[move] = ' ' if score > best_score: best_score = score move_to_play = move return move_to_play

minimax的递归终止条件有三个:AI 赢了返回 10,玩家赢了返回 -10,棋盘满了返回 0。AI 回合取最大值,玩家回合取最小值。这里的关键技巧是回溯:在递归尝试落子后,一定要立刻把格子恢复成空格' ',否则棋盘状态会越走越乱。best_move就是 AI 的决策入口:遍历所有空位,模拟 AI 落子,然后调用minimax(b, False)让玩家做出最坏回应,最后取分数最高的那步棋。

没有递归基础的人可能会困惑:为什么best_move里调用minimax要传False?因为 AI 模拟落子后,下一步行动权交到了玩家手里,玩家是“最小化者”,所以要进入最小化分支。这个视角不要搞混,否则 AI 的决策会反着来。

3.3 玩家输入与主循环

最后写主流程,让游戏能真正跑起来:

def play(): while True: display_board(board) if is_full(board): print("平局") return move = input("输入 1-9 选择落子位置:") if not move.isdigit() or int(move) not in range(1, 10): print("输入无效,请重新输入 1-9 之间的数字") continue move = int(move) - 1 if board[move] != ' ': print("这个位置已经有棋子了") continue board[move] = HUMAN if check_winner(board, HUMAN): display_board(board) print("你赢了") return if is_full(board): display_board(board) print("平局") return ai_move = best_move(board) board[ai_move] = AI display_board(board) print("AI 落子在:", ai_move + 1) if check_winner(board, AI): print("AI 赢了") return play()

输入校验很容易被忽略。如果不检查输入是否为数字、是否在 1 到 9 范围内、格子是否为空,程序在用户随手敲一个q或者0时就会直接崩溃。这种命令行小工具,健壮性比界面美观更重要。主循环的出口有三个:玩家赢、AI 赢、平局,每一个出口都要放在判定之后立刻处理,不要拖到下一轮循环。

4. 五次报错换来的避坑记录

这个项目虽然简单,但我调试过程中踩的坑一点不少。写下来,给后来人省点时间。

4.1 递归没有出口,程序直接卡死

第一次跑的时候,程序在几秒内没有任何输出,然后 CPU 开始狂转。我以为是公平的“思考”,实际上是minimax陷入了无限递归。原因是我把终止条件写成了“只有出现胜负才返回”,漏掉了平局分支。只要棋盘一直没分胜负,递归就会一层层调用下去,直到 Python 抛RecursionError。

排查办法很简单:在函数开头打印b和is_maximizing,观察递归过程中棋盘是否在正常推进。正常情况下每次递归都会减少一个空位,最多 9 层就该结束。只要看到同样的棋盘反复出现,基本就是终止条件或回溯逻辑出错了。我建议新手写完后先跑一次“空棋盘上直接调用best_move”的测试,这一步能验证大部分问题。

4.2 平局被漏判:检测顺序出问题

修好递归以后,又遇到一个隐蔽问题:AI 在下第 9 手棋时,明明对方已经无法连成三子,程序却一直不宣布平局。排查后发现问题出在检测顺序上——我把满盘判断放在胜负判断之前。当一个局面既满足“玩家连成三子”又满足“棋盘已满”时,程序会误报平局。井字棋的规则里,连成三子的瞬间游戏就结束,哪怕棋盘此刻也满了。

所以检测顺序必须是:先check_winner,再is_full。我甚至建议每局结束后把终局棋盘打出来看一眼,确认结果是否符合预期。这个方法排查类似逻辑错误特别有效。

4.3 AI 主动送分:评分视角混用的教训

这个坑最有意思。有一版代码跑起来,AI 不仅不堵我,还不停往我即将连成三子的地方送棋子,仿佛在帮我赢。后来发现是评分视角混了:我在最大化分支里返回的是“玩家视角”的分数,在最小化分支里又用“AI 视角”的分数,两边一对比,AI 以为送分才是最优解。

解决方案很简单:全项目统一用“AI 视角”评分。AI 赢是正数,玩家赢是负数。无论递归到哪一层,这个约定都不能变。如果哪天你又想调整 AI 强弱,只需要重新定义评分函数,核心递归结构不用动。

4.4 常见问题速查表

现象常见原因解决办法
程序卡死不输出递归缺少终止条件检查是否漏了is_full平局分支
明明有赢局却报平局满盘检测写在胜负检测之前调整顺序,先判输赢再判平局
AI 帮玩家堵自己评分视角不一致全局固定为 AI 视角,AI 赢为正、玩家赢为负
同分走法随机变化Minimax 对多个棋步给出同分取第一个即可,或者在同分集合里随机选择一个增加趣味
输入字母或 0 直接崩溃缺少输入校验使用isdigit()和范围判断后再转整数

5. 做完这版,我还想留两个升级方向

5.1 给 Minimax 加速:Alpha-Beta 剪枝怎么写

井字棋不需要加速,但如果你准备把这个逻辑搬到更大的棋类上,Alpha-Beta 剪枝是第一个要学的优化。它的核心思想很直白:如果当前最高分已经到 10,后面任何分支都不需要再算了,因为不可能有更好的结果。

一个简单的实现思路是给minimax增加alpha和beta两个参数。alpha表示当前最大化者已知的最佳值,beta表示最小化者已知的最佳值。在最大化分支里,一旦当前分数超过beta,就可以剪掉后面的分支;在最小化分支里,一旦当前分数低于alpha,同样可以直接返回。这个优化不会改变决策结果,但能把搜索量砍掉一截。作为练习,你可以先在井字棋上加剪枝,再用一个局面计数器对比有无剪枝的展开节点数,会非常有成就感。

5.2 从井字棋走向五子棋:评估函数才是分水岭

很多人做完井字棋后,第一反应是“我要做个五子棋 AI”。这里必须先打个预防针:如果直接把minimax套到五子棋上,程序会在“思考”几秒后卡死,因为五子棋的博弈树膨胀得太快,完整搜索根本不可能。

真正的分水岭在于评估函数。你不再只返回 10 / -10 / 0,而是要给每个终局或中间局面打分,比如 AI 有活三加 500 分,玩家有活三减 500 分,连二、冲四各自对应不同分值。这样 AI 才能在不穷举全部搜索的情况下“感觉”哪一步更有利。我自己尝试五子棋版本时,最大的感受就是:评估函数的平衡比递归本身难调十 倍,因为它掺杂了太多棋感。这也是为什么我会建议新手先用井字棋把 Minimax 的递归骨架抓牢,再去碰评估函数,否则很容易被一堆调参劝退。

最后分享一个调试小技巧。无论是井字棋还是后续的五子棋,我都会先写一个“随机乱走”的对手,然后让 AI 和它对战几百局,统计胜负比例。AI 如果连随机对手都会输,说明基础逻辑还有问题;只有先保证稳定不败,再谈“聪明”。代码写完以后不要急于展示,先去战斗里检验,这是做所有棋类 AI 都适用的一条经验。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询