简介:基于蒙特卡洛树搜索(MCTS)实现的黑白棋(奥赛罗)对弈程序,是面向计算机、人工智能、通信工程、自动化等专业学生与初学者的完整毕设源码包,适用于毕业设计、课程设计或AI入门实战。压缩包内共10个文件,涵盖5个Python脚本(MCTS核心算法、棋盘状态逻辑、人机对弈交互、演示入口)、3个Markdown文档(项目结构说明、运行指引、实验与结果记录)、1张程序运行示意图和开源许可文件,整体约69KB,模块划分清晰,便于按需查阅和二次开发。目前已有274人学习/浏览,具备一定参考借鉴价值。源码均经过运行验证,可直接复现黑白棋人机对弈流程,也可作为学习蒙特卡洛树搜索、博弈树剪枝、局面评估函数与Python工程组织的实践素材;配套文档还提供了具体运行方式与实验记录,从算法原理到代码落地均有覆盖,方便在此基础上扩展功能、撰写毕业设计论文或课程报告。
1. 为什么 MCTS 适合做黑白棋 AI:一份能跑、能改的 Python 毕设源码
围棋的 AlphaGo 论文里,被复用得最多的不是神经网络,而是蒙特卡洛树搜索那一整套搜索框架。黑白棋恰好是 MCTS 最容易出效果的小棋种:8×8 棋盘、博弈树规模适中、局面评估又极度反直觉。这份 Python 毕设源码把 MCTS 和黑白棋规则完整串了起来——棋盘类、合法落子、翻转逻辑、节点选择、模拟回传、人机对弈入口都有,还附文档说明。它不是一个 demo 级玩具,而是一个能改参数、能换评估函数、能接界面的骨架。适合三类人:课程设计要交代码的学生,想把 MCTS 从公式变成可调试代码的算法学习者,以及拿黑白棋做策略实验的入门研究者。下面按我拆包时的顺序讲:选型理由、代码拆解、调参、避坑、进阶改造。
2. 黑白棋的局面评估难题:为什么这里适合上蒙特卡洛树搜索
2.1 黑白棋的「局面评估」为什么难做
很多人第一次接触黑白棋时,第一反应是「胜负不就看谁子多吗?评估函数不是很好写?」但真写一次就知道,黑白棋的静态评估非常容易翻车。黑白棋的棋子数量在终局前根本不等于最终实力,边缘位置有时候是陷阱,角点一旦被占几乎不会丢。某个局面黑棋 30 子、白棋 20 子,子力差 10,但往终局推 20 手后白棋翻盘的概率可能比黑棋赢还要高。所以用单纯的「棋子数差」做评估函数,在黑白棋上是典型的错误示范,这在相关论文和讲义里被反复提过,不是玄学,而是这棋的底层规则决定的。
那能不能用「稳定子」「机动性」「翻转潜力」加权求和?能做,但中小项目里调这套权重非常耗时,而且很难调得通用。MCTS 换了一条路:它不显式给局面打分,而是从当前节点开始反复随机对弈到终局,用大量随机对局的胜率作为节点价值。黑白棋终局棋子总数固定、胜负判定明确,所以「谁赢」这个信号特别清晰。随机模拟听起来很玄学:一个只会乱走的 AI,多跑几遍怎么会变强?关键在于统计规律——当一个着法真的能带来更多胜局时,从该着法延伸出去的随机对局里赢的比例会偏高。模拟策略越接近均匀随机,噪声越大;模拟次数多到一定程度,信噪比就上来了。
这是选 MCTS 的第一个理由:不需要费神设计静态评估函数,只要模拟随机策略不要偏得离谱,搜索次数够了,棋力自然上来。而且搜索是 Any-time 的,随时可以拿当前最优结果走棋。答辩场景下这很讨喜——你可以在 1 秒限制下说「给我更多时间就跑更多次模拟」,而不是像 minimax 那样超时后什么都给不出来。
2.2 UCB1 公式:搜索树靠什么引导
MCTS 的「选择」这一步通常用 UCB1,公式拆开就两项:
score = 平均回报 + C × sqrt( ln(父节点访问次数) / 本节点访问次数 )
第一项是「利用」,让高胜率节点被优先选;第二项是「探索」,让那些没怎么被访问的节点也有机会被翻牌。C 用来调节两者比例,常见取 sqrt(2) ≈ 1.41。这份源码里的实现长这样:
def ucb1(self, c=1.41): if self.visits == 0: return float('inf') return (self.value / self.visits + c * math.sqrt(math.log(self.parent.visits) / self.visits))逻辑说明:visits 为 0 的节点直接返回正无穷,保证每个新扩展出来的子节点都会被至少完整模拟一次,否则它永远得不到被探索的机会。父节点访问次数放在常数位置,本节点访问次数出现在分母,所以一个新节点被选中一次后,探索项会立刻变小。c 值调大,搜索会更均匀地尝试不同着法;调小,则很快集中到当前最优分支上。黑白棋里 c 取 1.41 是安全起点。
另外有个容易忽略的细节:公式里的 ln 是自然对数,代码里用 math.log 而不是 math.log10。原因不外乎数学推导里的渐进性质更干净,实际刷逻辑时直接照抄就行。我见过有人为了省事把 math.log 换成 math.log10,导致探索项整体小了一倍不止,棋力掉得莫名其妙。这种细节属于典型的「书上看不出来,跑一遍才后悔」的地方。
2.3 为什么不用 minimax 或者 alpha-beta
既然黑白棋博弈树比围棋小得多,为什么不直接上 minimax + alpha-beta?原因主要有三点。第一,分支因子:黑白棋开盘合法着法几个到十几个,中盘更多,纯 Python 的 alpha-beta 想搜到足够深度,计算量很快就上来。第二,评估函数:minimax 依赖局面评估,而 2.1 说了黑白棋的静态评估不好写,写错一点整棵搜索树的比较就全偏。第三,Any-time:MCTS 可以在任意时间点返回当前访问次数最高的着法,minimax 在超时后给不出部分结果。
| 方案 | 评估函数 | 深度限制 | 超时处理 | 这份代码里的用法 |
|---|---|---|---|---|
| minimax + alpha-beta | 必须写 | 固定深度 | 超时无结果 | 未采用 |
| MCTS + 随机模拟 | 不需要 | 不锁深度 | 随时返回 | 默认策略 |
| MCTS + 轻量评估 | 可选 | 不锁深度 | 随时返回 | 进阶改造预留 |
另外,这份源码做的是朴素 vanilla MCTS:选择、扩展、模拟、回传四步齐全,但没有 RAVE、没有启发式评估、没有开局库。这反而适合当作毕设学习模板——你可以在文档说明里把它作为基础框架,再在代码里做一到两个改进点,课程设计的「工作量」和「创新点」就都成立了。接下来拆代码,先看棋盘,再看搜索循环。
3. 源码结构拆解:棋盘类、MCTS 节点与对弈主循环
3.1 棋盘类:二维数组加八个方向的翻转判定
核心文件是 board.py。棋盘用 8×8 二维列表,1 代表黑子,-1 代表白子,0 代表空。选数字而不是字符,是为了方便用 -player 取对手,翻转时直接取反。标准开局四子位置是 d4、e4、d5、e5:
class Board: def __init__(self): self.board = [[0] * 8 for _ in range(8)] # 标准开局:1 黑子,-1 白子 self.board[3][3] = 1 self.board[3][4] = -1 self.board[4][3] = -1 self.board[4][4] = 1 self.player = 1 # 黑方先手逻辑说明:列表推导 [[0] * 8 for _ in range(8)] 生成的是「八行互相独立」的棋盘。如果图省事写成 [[0] * 8] * 8,八行会指向同一个列表对象,改一个位置会导致整列联动,局面直接错乱。这个坑在网上一堆 MCTS 代码里出现过,抄的时候留意一眼。
合法落子和翻转判定共用一个函数_flips。它不直接修改棋盘,而是返回「如果落在 (x, y),会被翻转的棋子坐标列表」。列表非空说明落子合法,列表内容就是之后要翻转的棋子:
DIRS = [(1,0),(-1,0),(0,1),(0,-1),(1,1),(1,-1),(-1,1),(-1,-1)] def _flips(self, x, y, player): opp = -player flips = [] for dx, dy in DIRS: nx, ny = x + dx, y + dy if not self.on_board(nx, ny) or self.board[nx][ny] != opp: continue tmp = [] while self.on_board(nx, ny) and self.board[nx][ny] == opp: tmp.append((nx, ny)) nx += dx ny += dy if self.on_board(nx, ny) and self.board[nx][ny] == player: flips.extend(tmp) return flips逻辑说明:八个方向里,沿一个方向连续收集对手棋子到 tmp,遇到空格或边界就中断;只有当末尾棋子是自己的棋子时,才把 tmp 计入 flips。这个写法把「合法性判断」和「翻转计算」统一成一次遍历,避免两套逻辑各写一遍导致不一致。落子时调用 move 生成新棋盘:
def move(self, x, y): flips = self._flips(x, y, self.player) if not flips: raise ValueError(f"illegal move: {(x, y)}") board = [row[:] for row in self.board] board[x][y] = self.player for fx, fy in flips: board[fx][fy] = self.player new_state = Board() new_state.board = board new_state.player = -self.player return new_state这里 move 返回的是一个全新 Board 对象,而不是原地改动。这是给 MCTS 做树搜索时很关键的设计:每个节点持有一个不变的局面,模拟回传时不用担心别的分支把棋盘改坏。代价是每次落子复制一遍 8×8 棋盘,在黑白棋这个规模下完全可忽略。
3.2 MCTS 节点与选择-扩展-模拟-回传
mcts.py 里有两个类:MCTSNode 记录一个局面节点的统计价值,MCTS 负责跑主循环。节点初始化时把「当前局面所有合法着法」放进 untried 列表。这个列表很关键:只要 untried 非空,说明该节点还没完全展开,选择阶段就优先从中随机挑一个着法扩展,而不是急着用 UCB1 比较。
class MCTSNode: def __init__(self, state, parent=None, move=None): self.state = state self.parent = parent self.move = move self.children = {} self.untried = state.legal_moves() self.visits = 0 self.value = 0.0主循环是标准的四步:
class MCTS: def __init__(self, c=1.41): self.c = c def best_move(self, state, time_limit=1.0): root = MCTSNode(state) start_time = time.time() while time.time() - start_time < time_limit: node = self._select(root) outcome = self._simulate(node) self._backprop(node, outcome) best = max(root.children.values(), key=lambda n: n.visits) return best.move def _select(self, node): while not node.untried and node.children: node = max(node.children.values(), key=lambda n: n.ucb1(self.c)) if node.untried: move = random.choice(node.untried) return self._expand(node, move) return node def _simulate(self, node): state = node.state player = state.player while True: moves = state.legal_moves() if moves: state = state.move(*random.choice(moves)) elif state.legal_moves(-state.player): state = state.pass_turn() else: break winner = state.winner() if winner == 0: return 0.0 return 1.0 if winner == player else -1.0 def _backprop(self, node, outcome): while node: node.visits += 1 node.value += outcome node = node.parent outcome = -outcome需要讲透三个点。第一,_select的循环条件是「未完全展开且还有子节点」才继续往下走;完全展开但没有子节点,说明这个节点是终局,直接返回它做模拟。第二,_simulate里state.legal_moves()默认取当前玩家;为空时检查对方,对方还能走就pass_turn()切换玩家,双方都无着法才结束。黑白棋规则里「一方无棋可走就跳过」这一条,如果实现漏了,模拟阶段就会死循环。第三,_simulate返回的结果是相对node.state.player视角的:赢 1、输 -1、平 0。_backprop每向上回传一层就取反一次,因为父节点的 player 和子节点的 player 必然相反。
3.3 对弈入口:人机、机机与命令行参数
game.py 把上面的类串起来。核心是一个 run_game 函数,接受两个策略函数,交替取 best_move,直到双方都无合法着法,再按盘面棋子数定胜负。命令行支持两种对战模式:
python game.py # 人机对弈,轮到你就输入行列坐标 python game.py --mode ai # 两台 MCTS 互博,方便批量测试 python game.py --mode ai --time-limit 0.5代码里用 argparse 解析参数,这是标准库,不需要额外安装。文件结构很简单,文档说明里也给了同样的组织方式:
| 文件 | 职责 |
|---|---|
| board.py | Board 类:棋盘、合法落子、翻转、move/pass、胜负判定 |
| mcts.py | MCTSNode 与 MCTS:选择、扩展、模拟、回传 |
| game.py | 对弈入口:人机 / AI 互博、命令行参数、局面打印 |
| README.md | 环境要求、运行方式、算法说明、改造建议 |
到这里,一套完整的 MCTS 黑白棋已经能跑起来了。下一章说怎么把参数调到自己能用。
4. 把参数调到自己能用:时间预算、C 值与运行环境
4.1 时间预算怎么定:先理解 Any-time 特性
MCTS 的棋力跟思考时间几乎是单调相关的。时间越长,节点展开越多,棋力越强。不同 time_limit 下的实际节奏大概是:
| 每步思考时间 | 一局耗时 | 适合场景 |
|---|---|---|
| 0.2s | 2-3 分钟 | 快速验证规则、跑回归 |
| 0.5s | 5 分钟上下 | 课程演示 / 答辩现场 |
| 1.0s | 8-10 分钟 | 人机对弈默认值 |
| 2.0s | 15 分钟以上 | 需要明显棋力提升时 |
我的习惯是:答辩现场用 0.5s 到 1.0s,手感最好。再短棋力弱,再长等待时间太久,观众会无聊。这里有个细节值得说:MCTS 是 Any-time 算法,时间到了直接返回当前最优着法,不会像局部搜索那样出现「算一半被迫中断」的尴尬。这也意味着 time_limit 本质上是一个「每步允许跑多少次 rollout」的换算器,你不需要手动指定迭代次数。
4.2 运行环境与依赖:其实不需要 numpy
这份源码的环境要求很低:Python 3.8 以上就够,没有任何第三方依赖。不少同学在其他项目里装过 numpy,就条件反射地想在这里也 import numpy,实际上棋盘复制用列表推导,数学计算用 math 库,argparse 也是标准库。未必要特别配置,常见的 Python 安装教程里的标准环境就能直接跑。
真正需要留意的是 Python 版本带来的行为差异。f-string 语法要求 3.6+,list 类型注解建议 3.8+。代码里如果有list[str]这类写法,放到 Python 3.7 会直接语法报错,但黑匣子是:很多人只想到「语法与版本兼容」,没想到「标准库行为差异」。比如 random.choice 对空列表报 IndexError,这在 4.3 里也会涉及。
4.3 值得动的参数:C 值、随机种子与模拟次数
除了 time_limit,源码里另一个高频参数是 UCB1 的 C 值。C 取 1.41 时探索和利用比较平衡;C 调到 0.5,搜索更快收敛到当前高胜率分支,但面对少见着法时容易被骗;C 调到 2.5 以上,搜索变得很「散」,棋力会明显下降。调整方法很简单,改 MCTS 构造函数里默认值即可。
mcts = MCTS(c=1.41) # 默认:平衡 mcts = MCTS(c=0.8) # 更激进利用,开局阶段或许更强 mcts = MCTS(c=2.0) # 更多探索,适合用来观察搜索多样性参数说明:c 值直接影响ucb1里第二项的系数。c 越小,选择越集中在平均回报最高的节点上;c 越大,低访问次数节点被选中的概率越高。黑白棋这类终局信号明确的棋,c 在 0.8 到 2.0 之间都能跑,但有明显手感差异。我自己实验下来,1.41 到 1.6 之间最稳。
随机种子则不是棋力参数,而是实验参数。随机模拟的引入让 MCTS 每次运行结果都有波动,固定 seed 才能复现实验结论。代码里模拟走子用的是random.choice,所以只要在入口调用random.seed(2024),整局对弈的随机过程就可复现。
4.4 验证棋力:先能复现,再谈改进
改完参数,怎么确定变强了?单靠「跟电脑下两局感受一下」是不行的。我一般会写一个对照脚本,让 A、B 两个配置各执黑执白交替下 50 局,统计胜率。下面这段是我在验证参数时常用的脚本骨架:
import random random.seed(2024) wins = {"A": 0, "B": 0, "draw": 0} for i in range(50): # 交替先行,抵消先手偏差 if i % 2 == 0: winner = run_game(mcts_A, mcts_B) else: winner = run_game(mcts_B, mcts_A) if winner == "A": wins["A"] += 1 elif winner == "B": wins["B"] += 1 else: wins["draw"] += 1 print(wins)逻辑说明:50 局里双方轮流执黑,避免「黑棋先天优势」干扰判断;胜负计数只记 1 或 -1 的返回值,终局平局单独统计。每局内部 random 已经被 seed 固定,所以这 50 局是确定性的,跑多少次都一样。有了这套脚本,后面所有改动都能用胜率说话。
5. 常见问题避坑:搜索卡死、内存暴涨与结果不可复现
5.1 模拟阶段卡死:pass 处理错了
现象:对弈到中盘,某一步开始长时间无输出,程序像卡死在循环里。
原因:_simulate里对「一方无合法落子」的处理有漏洞。常见错误写法是只判断当前玩家moves为空就结束,完全没考虑对方还能走。更隐蔽的问题是pass_turn()在原来的棋盘对象上原地修改 player,导致后续模拟的 state 被重复翻转,永远到不了结束条件。
解决:按 3.2 的写法,当前玩家无着法时,先检查对方是否有合法着法;对方能走就pass_turn()生成新状态并切换玩家;双方都无着法才 break。判断结束条件的核心是「双方都无合法落子」,不是「一方无棋」。
5.2 time_limit 太短导致 max() 空序列
现象:设置time_limit=0.05时,程序抛ValueError: max() arg is an empty sequence。
原因:best_move进入循环后,由于时间检查太激进,一次 rollout 都没跑完就退出了,root.children为空,最后max()自然崩溃。
解决:改成「至少先执行一次完整 rollout,再进入按时间循环」。更稳妥的做法是取最优着法时加保护:children为空就random.choice(state.legal_moves())兜底。实际项目中我两种都加,兼顾稳定性和可读性。
5.3 UCB1 里除零和 math domain error
现象:运行一段后报ZeroDivisionError: float division by zero,或者ValueError: math domain error。
原因:visits为 0 时直接参与除法;math.log(parent_visits)里parent_visits为 0 时取对数会报 domain error。前者发生在新扩展节点未赋初始visits,后者发生在某些实现里根节点没有父节点却仍调用parent.visits。
解决:visits == 0时直接返回float('inf');log里对父节点访问次数加一保护,写成math.log(self.parent.visits + 1)。加一不会改变渐进性质,但能杜绝边界崩溃。这个改动不影响棋力,属于纯粹的防御性写法。
5.4 内存持续上涨:旧搜索树没有释放
现象:跑 AI 对自己 20 局以上,内存占用肉眼可见地上涨,最后整个进程变卡。
原因:MCTS 每次best_move都会构建一棵新的搜索树,树里每个节点持有一个棋盘对象。如果在run_game里把每一步的 root 或 Board 历史都存进列表,旧树就永远不会被 GC 回收。对局记录里保存整个 Board 对象尤其致命。
解决:对局记录只存落子坐标,不存状态对象。每步best_move返回后,root 的引用只要不保留在外部数据结构里,下个循环就会被覆盖释放。如果想进一步压内存,可以在节点里不持有 state,改用「从初始局面重放到当前节点」的增量表示,但黑白棋 8×8 规模下这一步不是必须。
5.5 实验结果不可复现:忘了固定随机种子
现象:同一组参数、同样 50 局,两次跑出来的胜率差了 15 个百分点以上。
原因:random 模块的全局状态没有被固定。MCTS 的扩展选中随机着法、模拟阶段随机走子都依赖全局随机数,只要入口没设 seed,每次运行整个随机序列都不一样。
解决:验证脚本入口先random.seed(2024),再跑 50 局以上。固定种子后,同一套代码同一套参数必然给出同样结果,这时候改任何一个参数都可以确信是参数本身带来的变化,而不是随机噪声。
6. 进阶改造:PUCT 提升棋力与自我对弈验证
6.1 给选择函数加 PUCT
UB C1 的探索项里没有先验信息,每个节点被一视同仁。PUCT 的改动是引入先验概率prior,让搜索优先探索你认为更有希望的着法。在黑白棋里,最简单的先验是「角位和下步能吃子更多的着法给更高权重」。改起来很轻量:
def puct(self, c=1.0): if self.visits == 0: return float('inf') prior = self.state.prior_score(self.move) # 可评价的着法先验 return (self.value / self.visits + c * prior * math.sqrt(math.log(self.parent.visits + 1) / self.visits))逻辑说明:prior_score不在源码里,需要自己实现。最省力的版本是给角位置权重 1.2、边位置权重 1.0,其余 0.8——它能明显改变中盘搜索方向。改完_select里的 key 函数换成puct即可。
6.2 用自我对弈验证这次改造
改完不能凭感觉说变强了,我习惯立刻跑 50 局 self-play 回归:
random.seed(2024) scores = {"base": 0, "puct": 0, "draw": 0} for i in range(50): if i % 2 == 0: winner = run_game(mcts_base, mcts_puct) else: winner = run_game(mcts_puct, mcts_base) scores[winner] += 1 print(scores)逻辑说明:base 用默认 UCB1,puct 用改造后的版本;双方交替执黑抵消先手偏差。如果 puct 版本胜率低于 50%,不用怀疑,就是先验权重设错了,回去调prior_score的参数。我以前改过一次边位置权重设成 1.5,结果胜率掉了 8 个点,后来每次都先跑一遍这个回归脚本再谈其他改进。从那以后我每次动搜索策略的代码,第一件事就是跑 50 局 self-play 做基线回归,不改这个流程我不放心。希望帮到你。
本文还有配套的精品资源,点击获取