☰
基于蒙特卡洛树搜索的黑白棋AI实战:原理、实现与调参
2026/10/27 20:09:41 网站建设 项目流程

简介:这套源码以蒙特卡洛树搜索(MCTS)为核心,实现黑白棋(奥赛罗/Reversi)人机对弈系统,面向计算机、人工智能、通信工程等专业的毕设、课设与AI算法入门学习者。代码结构清晰,核心模块分别承担树搜索决策、棋局规则与合法性判断、对弈流程组织和AI落子接口,另有演示脚本可快速启动项目、直接体验人机对战。包内共10个文件,以5个Python源码、3个Markdown说明文档为主,附带效果预览图与许可文件,压缩包整体仅69KB,轻量且便于阅读和二次修改。目前已有274人学习浏览,项目经测试运行成功,毕设答辩评审平均分96分。下载后除完整源码外,还包含中文说明文档与效果预览图,既可用于课程设计、毕业设计的基础改造,也可在此基础上扩展棋力评估、界面可视化、多局统计等功能,是学习MCTS算法与博弈编程的实用参考。

1. 黑白棋对弈里的蒙特卡洛树搜索:为什么毕设选它最划算

每到毕业季,总有人卡在选题上:要算法有深度、又要代码量可控、还得能演示。基于蒙特卡洛树搜索(MCTS)实现的黑白棋对弈,几乎是为这个需求量身定做的组合。它不需要神经网络、不需要训练数据,靠模拟走子和胜率统计就能让AI“学会”下棋,原理能讲透,代码又足够轻量。这篇笔记就按我实际做过的路子,从棋盘表示、MCTS四阶段到调参踩坑,给你一条能复现的完整链路。新手照着写能跑,熟手也能在参数和边界上少走弯路。

2. 黑白棋的局面表示与落子规则:先让棋盘会说话

黑白棋(Reversi/Othello)的规则很简单:8×8棋盘,黑先白后,落子必须夹住至少一个对方棋子,被夹住的棋子全部翻转变成己方。但“简单”是给人类说的,落到代码里要处理三件事:棋盘怎么存、合法落子怎么判、翻转怎么算。这三件事错了,后面MCTS再漂亮也白搭。

2.1 棋盘数据结构:二维数组的选型与坐标约定

黑白棋棋盘常见三种存法:一维数组带索引计算、二维数组、位棋盘(两个64位整数)。毕设场景我推荐二维数组——不是位棋盘性能不好,而是mcts_search要大量深拷贝局面,二维列表的deepcopy和调试输出都直观得多,位棋盘虽然快,但位运算出错了很难一眼看穿,写文档也不太好解释。

坐标约定我用(row, col),左上角是(0,0),右下角是(7,7)。棋盘值用1表示黑、-1表示白、0表示空。为什么用-1而不是2?因为翻转棋子只需要乘-1,而且玩家轮换也只需要把current乘-1,省一堆if-else。

class Board: def __init__(self): self.grid = [[0 for _ in range(8)] for _ in range(8)] self.grid[3][3] = self.grid[4][4] = 1 # 黑棋 self.grid[3][4] = self.grid[4][3] = -1 # 白棋 self.current = 1 # 黑先 def has_legal_move(self, player): old = self.current self.current = player has = len(self.legal_moves()) > 0 self.current = old return has

初始四子不能放错:标准开局是d4/e5黑、e4/d5白,对应坐标就是(3,3)/(4,4)黑、(3,4)/(4,3)白。has_legal_move这个辅助方法后面pass判定和终局判定都要用,先写在这里。它临时切换current再切回来,比复制整个棋盘高效。

2.2 合法落子与翻转逻辑:八方向夹逼的实现

合法落子的核心是一个“八方向扫描”:从落子点出发,沿每个方向走,如果第一步就是对方棋子,就继续走,直到遇到己方棋子或者走出棋盘;如果最终能停在己方棋子上,说明这个方向夹住了对方,整个方向上一串对方棋子都要翻转。

def is_legal(self, r, c): if not (0 <= r < 8 and 0 <= c < 8) or self.grid[r][c] != 0: return False opponent = -self.current for dr, dc in ((-1,-1), (-1,0), (-1,1), (0,-1), (0,1), (1,-1), (1,0), (1,1)): nr, nc = r + dr, c + dc if not (0 <= nr < 8 and 0 <= nc < 8) or self.grid[nr][nc] != opponent: continue while (0 <= nr < 8 and 0 <= nc < 8 and self.grid[nr][nc] == opponent): nr += dr nc += dc if 0 <= nr < 8 and 0 <= nc < 8 and self.grid[nr][nc] == self.current: return True return False

is_legal只返回布尔值,不返回翻转列表。原因是MCTS的展开阶段会频繁调用它来收集所有合法步,真正的翻转动作放在make_move里单独做,避免每次合法性检查都创建列表。

def make_move(self, r, c): if not self.is_legal(r, c): raise ValueError(f"非法落子: ({r}, {c})") opponent = -self.current flips = [(r, c)] for dr, dc in ((-1,-1), (-1,0), (-1,1), (0,-1), (0,1), (1,-1), (1,0), (1,1)): nr, nc = r + dr, c + dc candidates = [] while (0 <= nr < 8 and 0 <= nc < 8 and self.grid[nr][nc] == opponent): candidates.append((nr, nc)) nr += dr nc += dc if 0 <= nr < 8 and 0 <= nc < 8 and self.grid[nr][nc] == self.current: flips.extend(candidates) for sr, sc in flips: self.grid[sr][sc] = self.current self.current = opponent

这里有个细节容易翻车:make_move开头调用is_legal做二次校验,会重复扫描一次八方向。在纯Python的毕设项目里,这个开销可以接受,换来的是API安全——外部直接传一个非法坐标也不会把棋盘改成脏状态。如果你追求性能,可以把is_legal改成“返回翻转列表或None”的单遍实现,但可读性会差不少。

2.3 无子可下的pass规则:搜树之前先把规则闭环

黑白棋有个特殊规则:当前玩家如果没有合法落子,必须跳过(pass),由对方继续下;如果双方都没有合法落子,棋局结束。这个规则在MCTS的模拟阶段特别容易漏,漏了就直接死循环。

def game_over(self): return (not self.has_legal_move(1)) and (not self.has_legal_move(-1))

game_over写起来一行,但含义要掰开:黑白棋终局不是“棋盘满了”,而是“双方都无处可下”。棋盘满只是它的一个特例。早期版本如果忘了判断黑方无子,会出现黑棋连续pass、白棋连下两手的局面,搜索树里全是非法状态。

一个建议:在写MCTS之前,先单独为Board类写一段自测代码——随机落子直到game_over为True,看会不会卡死、会不会非法翻转。规则没闭环就上搜索树,后面所有胜率统计都是脏数据。棋盘层做的每个方法,后面MCTS都会高频调用,这里的每一行省下来的时间都会在rollout阶段滚雪球。

3. 蒙特卡洛树搜索的四个阶段:UCB1公式与黑白棋的模拟策略

MCTS不是一种具体的搜索算法,而是一个框架,四个阶段串起来:选择(Selection)、扩展(Expansion)、模拟(Simulation)、回溯(Backpropagation)。黑白棋正好是它的典型应用场景——分支较多但模拟成本低,局终胜负容易判定。核心思路是:用大量随机对局的结果,反向修正树上每个节点的“下这里有多好”的估计值。

3.1 选择与扩展:从根节点到叶子节点的UCT公式

选择阶段要从根节点出发,沿着树往下走到一个“还没完全展开”的节点。每一步选哪个孩子,靠的是UCB1公式:

UCT = wins/visits + C * sqrt(ln(parent_visits) / visits)

前半部分是胜率,是“利用”;后半部分是探索项,越少被访问的孩子越有机会被选中,是“探索”。C是探索常数,控制两者平衡。这个公式用在两玩家零和博弈时,有几个视角陷阱,我放到第5章讲,先把代码写对。

import math, random, copy, time class MCTSNode: def __init__(self, board, ai_color, parent=None, move=None): self.board = board # 深拷贝后的只读局面 self.ai_color = ai_color # 统一用AI视角记胜率 self.parent = parent self.move = move # 从父节点走到本节点的落子 self.children = [] self.visits = 0 self.wins = 0.0 self.untried = board.legal_moves() def is_fully_expanded(self): return len(self.untried) == 0 def best_child(self, c_param=1.41): best = None best_val = -float('inf') for child in self.children: uct = (child.wins / child.visits + c_param * math.sqrt(math.log(self.visits) / child.visits)) if uct > best_val: best_val = uct best = child return best def expand(self): move = self.untried.pop() new_board = copy.deepcopy(self.board) new_board.make_move(*move) child = MCTSNode(new_board, self.ai_color, parent=self, move=move) self.children.append(child) return child

best_child只从self.children里选,而children里的节点都是经历过一次完整模拟的,visits至少是1,所以这里不会出现除零。math.log(self.visits)要求父节点visits大于0,根节点只要进过一次主循环就大于0,没问题。真正会除零的是另一种写开放法——直接在全量children里算UCT,那些还没被访问过的孩子会让分母变0。

3.2 模拟(rollout)策略:随机走子和启发式走子的取舍

模拟阶段从刚扩展出的节点开始,用一套快速策略把棋局走到终局。最朴素的做法是均匀随机选合法步。对黑白棋来说,纯随机有一个小问题:它完全不考虑边角价值,导致模拟里AI频繁送角,胜率估计偏向“消极方”。

一个低成本改进是在模拟里做加权选择:边角(四个角)权重大,邻角位置权重小,其他位置权重中等。这个不改变MCTS框架,只改rollout里的采样逻辑。

def rollout(self): board = copy.deepcopy(self.board) corners = {(0,0), (0,7), (7,0), (7,7)} near_corners = {(0,1), (1,0), (1,1), (0,6), (1,6), (1,7), (6,0), (6,1), (7,1), (6,6), (6,7), (7,6)} while True: moves = board.legal_moves() if not moves: if not board.has_legal_move(-board.current): break board.current = -board.current continue weights = [] for m in moves: if m in corners: weights.append(5.0) elif m in near_corners: weights.append(0.5) else: weights.append(1.0) move = random.choices(moves, weights=weights, k=1)[0] board.make_move(*move) diff = board.score(self.ai_color) if diff > 0: return 1.0 if diff < 0: return 0.0 return 0.5

角落权重给5.0不是玄学,是基于黑白棋“角一旦占据就不会被翻转”这个稳定子特性。邻角位置为什么给0.5?因为下在邻角很容易让对方占角,属于典型的陷阱位。注意random.choices是Python 3.6+的接口,老环境用random.choice配合手动构造加权列表。

3.3 回溯更新:胜率统计的视角统一

模拟结束后,要把结果从当前节点一路传回根节点。最容易出错的是“这个wins到底是谁的胜率”。我见过不少初版实现把wins记成“当前节点玩家视角”,结果父节点和子节点统计口径相反,回溯时符号乱套,AI越搜索越差。

def backpropagate(self, result): self.visits += 1 self.wins += result if self.parent: self.parent.backpropagate(result)

我的惯例是:全树统一用根节点AI视角。result只有三种值:AI赢1.0、AI输0.0、平局0.5。这样回溯路径上每个节点都加上同一个数值,不需要按玩家切换正负号。缺点是无法直接看出“当前局面轮到谁有利”,但MCTS只关心“最终谁赢”,这个口径完全够用。

def mcts_search(board, ai_color, time_budget=2.0, max_iter=5000, c_param=1.41): root = MCTSNode(copy.deepcopy(board), ai_color) start = time.time() iterations = 0 while (time.time() - start) < time_budget and iterations < max_iter: node = root while not node.is_fully_expanded(): node = node.best_child(c_param) if node.untried: node = node.expand() result = node.rollout() node.backpropagate(result) iterations += 1 if not root.children: return root.untried[0] best = max(root.children, key=lambda ch: ch.visits) return best.move

我一般选“被访问次数最多”的孩子,而不是“胜率最高”的孩子。这是MCTS里的常用稳健策略:访问次数多的孩子说明在大量模拟中都被认为值得探索,方差更小。纯按胜率选,容易被某一次rollout的偶然高胜率带偏。

4. 把MCTS接进黑白棋:搜索主循环、时间预算与源码结构

Board类和MCTSNode类写完之后,剩下的是把它们拼成能下棋的程序。这一步看起来简单,但“搜索多久”“什么时候pass”“源码怎么组织才能写进文档说明”这些问题,决定了它是个能跑的demo还是一个能答辩的项目。

4.1 搜索主循环:固定时间还是固定模拟次数

MCTS主循环的终止条件有两种:固定模拟次数、固定时间预算。我的建议是双条件都要——time_budget保证响应及时,max_iter防止时间切片异常时无限循环。在CLI人机对弈里,时间预算2秒左右体验合适;在自对弈调参时,我一般改成固定迭代次数,保证对比公平。

c_param默认给1.41在理论上是sqrt(2),但这个值不是拍脑袋定的,它只在博弈树深度和分支数相对均衡时效果才好。黑白棋前期分支数可以到20+,后期只有个位数,固定C值会导致前期探索过多、后期探索过少。实际工程里我更习惯把C设为0.7~1.2之间,第5章会细讲这个坑。

4.2 人机对弈的最小工程:CLI、AI执棋与输入解析

终端版对弈足够用于演示和答辩,不要一上来就上图形界面。GUI会消耗大量调参时间,而且MCTS的搜索耗时会让界面看起来“卡死”,反而影响观感。

def main(): board = Board() ai_color = -1 # AI执白,人类执黑先走 while True: if board.legal_moves(): if board.current == ai_color: move = mcts_search(board, ai_color, time_budget=2.0) print("AI落子:", move) board.make_move(*move) else: try: r, c = map(int, input("输入行列(如 3 2): ").split()) except ValueError: print("格式错误,输入两个空格分隔的数字") continue if not board.is_legal(r, c): print("非法落子,重新输入") continue board.make_move(r, c) else: if not board.has_legal_move(-board.current): break print("当前玩家无子可下,跳过") board.current = -board.current diff = board.score(ai_color) if diff > 0: print("AI胜") elif diff < 0: print("人类胜") else: print("平局")

board.make_move(*move)这里的*move是把元组展开成两个参数,make_move(r, c)的实际签名。AI落子前,legal_moves已经保证非空,mcts_search不会返回空。人类输入解析用try-except兜底,实际答辩时评委可能会故意输错格式,这个细节能体现健壮性。

4.3 文档说明写什么:从核心类图到测试用例的组织

毕设的文档说明常被当成“事后补交的作业”,但代码里很多设计决策恰恰要靠文档解释清楚,尤其是MCTS这种带随机性的算法。我写文档会固定放四样东西:整体架构(Board管理规则、MCTSNode管理搜索树、mcts_search串主流程、main管对弈)、关键公式推导(UCB1以及为什么用访问次数选最佳落子)、参数表、自测记录。

参数取值影响
time_budget1.0 ~ 3.0 秒越大棋力越强,但交互卡顿
max_iter3000 ~ 10000防止异常卡死,保底上限
c_param0.7 ~ 1.41探索强度,需配合预算调整
角落权重5.0rollout阶段保护角,减少无谓送角

文档里还要写清“怎么验证AI真的变强了”——单盘对弈有随机性,必须用自对弈统计胜率,这部分我在第6章给出脚本。源代码管理上,我习惯把board.py、mcts.py、main.py、test_selfplay.py四个文件分开,文档里画类图时也按这个结构陈述,答辩时翻代码比找一个上千行的单文件从容得多。

5. 黑白棋MCTS的5个常见翻车点:现象、原因与解决

这个项目我前后调过两轮,掉过的坑基本都集中在下面这五个地方。写出来是想让你别在同样的地方浪费两周时间,每条都按“现象→原因→解决”拆开。

5.1 模拟阶段死循环:pass规则只做了一次轮换

现象:程序运行到某一步就卡死,CPU占用拉满,控制台不再输出任何信息。把game_over打印出来看,发现棋局根本没结束但循环出不来。

原因:rollout里我只写了“当前玩家无子可下就切换玩家”,然后继续while True。问题出在切换后没有检查对方也无子可下,棋盘变成双方交替pass的永久状态。黑白棋的pass不是简单的换人,而是“双方连续无子才算终局”。

解决:在swtich player之后再调用一次has_legal_move,如果对方也没有合法落子才break。代码见3.2的rollout实现,核心判断就是if not board.has_legal_move(-board.current): break。这个坑最容易在搜索接近终局时触发,因为越到后期无子可下的情况出现越频繁。

5.2 搜索过程中棋盘被串改:深拷贝和引用混用

现象:AI走着走着会下出明显违反规则的棋,甚至落子的格子已经有棋子。debug时发现某个节点的board状态在模拟过程中被修改了。

原因:MCTSNode初始化时直接保存了传入的board引用,rollout()里用的也是这个引用,不存在真的“深拷贝副本”。于是模拟走子把父节点的棋盘改掉了。MCTS要求搜索树的每个节点都对应一个独立的局面快照,节点创建后不能被后续模拟污染。

解决:凡是从一个局面派生新局面,都必须copy.deepcopy。具体位置有两处:expand()里创建子节点时要深拷贝父棋盘;rollout()开始前也要先深拷贝当前节点棋盘。代价是Python深拷贝8×8嵌套列表有几微秒开销,但换来的是状态隔离。如果你后面想优化性能,可以改成“棋盘编码成整数”的方式,但不要在一个还没有跑通的项目里提前做这种优化。

5.3 UCB1除零与log(0):未访问节点的处理差异

现象:运行几盘后忽然报ZeroDivisionError或ValueError: math domain error,栈指向best_child里的math.log(...)。

原因:best_child在计算UCT时,如果某个孩子的visits是0(刚被expand但还没rollout),或者父节点visits是0(根节点第一次搜索),分母和log都会出问题。有些实现会把“未被访问的孩子”也放进Selection的候选里一起算公式,这就必然触发除零。

解决:两条线分开走——Selection的best_child只在self.children里选,而self.children里的节点一定都经历过一次完整的rollout+backpropagate,visits必然大于0;根节点进主循环后就visits>=1。至于“未访问孩子”该不该选,答案是每次迭代都从根向下先走Selection,走到一个untried非空的节点就立刻expand,根本不需要让未访问孩子参与UCT计算。

5.4 胜率统计视角混乱:wins到底算谁的

现象:AI搜索自己明明占优的局面,wins却是负数;或者在几乎必胜的终局前,best_child选了一个明显送角的落子。

原因:wins的统计口径在节点之间不一致。我早期写成“当前轮到谁,wins就算谁的胜率”,结果父节点视角和子节点视角互相矛盾:黑子节点统计黑胜率,白子节点统计白胜率,回溯后根节点的wins/visits根本不是一个连贯的估值。

解决:全树统一用AI视角。初始化MCTSNode时把ai_color传到底,backpropagate的result只有1.0/0.0/0.5三种值,不做任何按玩家翻转的运算。这样虽然丢失了“当前局面轮到谁”的信息,但UCB1公式只关心最终胜率,这个口径足够。

5.5 探索常数失配:C=1.41不是万能

现象:同样的代码,搜索时间给2秒时AI棋力还行,给0.5秒时AI明显变“浪”,总走在边角附近的危险位置。

原因:C=1.41在模拟次数充足时表现良好,但时间预算压缩后,每节点平均访问次数大幅下降,探索项的占比会被相对放大,AI在“探索”上浪费了太多采样次数。搜索越浅,越应该偏向利用,也就是调小C。

解决:按预算调C。预算2秒我常用1.0~1.2;预算0.5秒压到0.5~0.7;自对弈固定迭代次数时,max_iter=3000以下建议C不超过0.8。这个可以暴力试,比如第6章的自对弈脚本跑一轮就能看出差异。不要迷信论文里的默认值,论文里的收益是几万盘模拟下的平均表现,跟你的交互场景不是一回事。

6. 从能下棋到下得好:自对弈调参与三个进阶技巧

到了这一步,项目已经能跑,但能不能过导师那关,取决于你有没有证明“AI真的在变强”。单盘赢人类算不上数,黑白棋的人类对手水平参差不齐,得用自对弈做对照实验。

def self_play(c_param, games=30, time_budget=1.0): black_wins = 0 for _ in range(games): b = Board() while not b.game_over(): moves = b.legal_moves() if not moves: b.current = -b.current continue move = mcts_search(b, b.current, time_budget=time_budget, c_param=c_param) b.make_move(*move) if b.score(1) > 0: black_wins += 1 return black_wins / games

这段代码用同一个C值让AI自己执黑执白各下30盘,统计黑方胜率。对比不同C_param的胜率,就能看出哪个参数在当前预算下更合适。注意自对弈里时间预算要固定,MCTS有随机性,单盘结果不可信,至少30盘以上再下结论。我当年就吃过这个亏:某个C值跑5盘赢了4盘,换成50盘才发现胜率只有四成,方差比想象的大得多。

三个进阶技巧按性价比排序。第一,开局库:前四步用固定开局的应对表,避开明显的陷阱位,可以省掉搜索初期大量无意义的模拟。第二,时间自适应:早期剩余棋子多、局面更开放时给长预算;残局棋子少时用短预算,因为莫须有的探索反而浪费时间。第三,模拟策略加权:除了边角权重,还可以给“翻子数多”的位置加权,让rollout更接近人类直觉。

做这个项目最大的教训是:不要一上来就调参数。先把rollout的pass逻辑和深拷贝修干净,再谈棋力。参数调不动的时候,先怀疑代码有没有跑对,再怀疑公式——哪怕把C从0.5调到3.0,在bug面前都没有意义。希望帮到你。

本文还有配套的精品资源,点击获取

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

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

立即咨询