
如果你做过棋类游戏或者写过自动博弈程序应该对这样一个场景不陌生棋盘状态多到爆炸普通搜索根本穷举不完Minimax 加上 Alpha-Beta 剪枝也镇不住局面。这时候蒙特卡洛树搜索Monte Carlo Tree Search简称 MCTS就登场了。它能在海量决策空间里快速找到一条“还行”乃至“相当好”的路径核心就一句话带着脑子的随机模拟。一边随机积累数据一边动态调整关注重点最终在棋盘上挖出一手好棋。这篇文章就是写给第一次接触 MCTS 的新手看的。我会用大白话拆解它的四个阶段把 UCB1 公式的来龙去脉讲清楚再给出一份可以直接跑起来的 Python 版井字棋实现最后说说我实际调参踩过的坑。全文不依赖任何高深数学基础只要懂点递归和概率就能跟上。1. 先搞清楚 MCTS 是什么从直觉到定义1.1 蒙特卡洛方法到底在干嘛蒙特卡洛这个名词听起来很高大上其实本质就是“用随机采样估算答案”。经典例子是估算圆周率在正方形里随机撒一把点统计落在内切圆里的点占比这个比例乘以 4 就逼近 π。撒的点越多结果越准。MCTS 里的蒙特卡洛成分就是沿用了这个思想让程序从某个棋盘状态出发随机地一口气玩到分出胜负然后记录“这局赢没赢”。单次随机模拟毫无意义但如果重复几千次、几万次胜率的统计规律就会涌现出来哪个动作后续赢的多哪个动作就是好动作。注意这里的随机不是完全没脑子。它只在“模拟”阶段用随机策略快速推进而在选择下一步该考察哪里时用的是另一套评分机制。这就把它和纯蒙特卡洛采样区分开了。1.2 MCTS 就是给随机模拟加了棵树如果只是每次从根节点随机模拟到游戏结束确实也能估算每个动作的胜率但要模拟的次数太多效率很低。MCTS 的聪明之处在于它在内存里维护一棵“搜索树”树的每个节点代表一个棋盘状态节点之间的连线表示一步动作。这棵树不是平衡地生长而是呈现出一种“重点倾斜”越是胜率高的分支越会被反复访问树也长得越深而明显差的分支很快就会被冷落。所以搜索树的形状基本反映了当前局面的优劣分布。就像追一部剧口碑好的赶快追到最新口碑差的看两集就弃了。这种不对称生长是 MCTS 高效的关键。它把算力集中在“有希望”的区域而不是均匀撒在整棵树上。1.3 为什么不能直接穷举以井字棋为例拿井字棋来说总状态数不算大大约 5,478 个状态节点算上对称约 765 个穷举完全可行。但换到国际象棋、围棋这种棋类状态空间就指数爆炸了。围棋棋盘是 19×19合法落子位置数量巨大穷举到宇宙毁灭也枚举不完。传统博弈搜索如 Minimax依赖深度限制和评估函数。可围棋的评估函数极难写而 MCTS 用“从当前状态随机玩到底”这个朴素手段绕开了“静态评估局面好坏”这个难题。它不需要人对棋盘打分只需要一种能判断终局胜负的规则就能从零开始构建搜索树。这也是面向初学者的一个巨大优点实现简单通用性强。2. MCTS 四阶段一台精密的决策流水线MCTS 每进行一次“迭代”或者叫一次模拟都会依次走完四个阶段选择Selection、扩展Expansion、模拟Simulation、回溯Backpropagation。下面我用井字棋现场逐步演示。2.1 Selection从根节点出发挑选最值得探索的边假设现在棋盘是空白的MCTS 已经跑过几百轮搜索树上已经有不少节点。每次迭代从根节点开始按照一个评分标准往下走每次走到一个孩子节点都挑当前“性价比最高”的动作移动。这个评分标准通常是 UCB1后面我会专门讲。Selection 会一直进行直到到达一个叶子节点——也就是还没有生成完整孩子节点的节点。如果走到中途发现某个节点还没有展开过它所有可能的孩子就可以在这里停下来。换句话说选择的过程就是“找一棵树上还没有被完全开发但又值得继续开发的位置”。这里要注意选择的目标不是挑“当前局部最优”的分支而是综合“以前赢得多”和“被考察得少”两个因素。只按胜率选会导致只走老路只选没走过的又变成随机游走。两者的平衡全靠后面的公式。2.2 Expansion给新分支开一扇门如果当前选到的叶子节点不是一个终局节点而且还有没尝试过的合法动作那么就从这些动作中挑一个通常随机在当前节点下创建一个新的子节点代表走完这个动作后的新棋盘状态。这一步为搜索树增加了一个新成员。新节点初始统计量为零访问次数为 0累计胜局为 0。它就像一张白纸等着后续迭代来给它积累数据。为什么不一次性把所有动作的子节点全建好因为大多数动作很快就会被证明没前途提前建一堆废节点纯属浪费内存。渐进式的扩展方式只在需要的时候创建一个节点既省内存又能让树的生长更贴合搜索重点。2.3 Simulation从新节点开始随机玩到结束扩展完成后从刚刚创建的新节点出发如果扩展不是发生在选择终点那么就从选择终点继续用一套“默认策略”快速模拟对局直到分出胜负。默认策略可以纯随机也可以带简单启发式。它的核心要求是快快快。因为模拟阶段不会被记录进搜索树只是产生一个胜负结果所以它没必要太聪明。很多实现直接使用随机落子直到棋盘填满或者有一方获胜。模拟次数越多统计结果越稳定所以速度至关重要。模拟结束后的结果就是一个布尔值当前玩家视角下这局是赢1还是输0。有些复杂博弈还可以返回带权重的分数比如下的越少赢越好可以设置更细的奖励不过入门阶段用 0/1 就足够了。2.4 Backpropagation把战果一五一十汇报给所有父辈模拟结果拿到后要从我们刚才扩展出来的那个新节点一路向上回溯到根节点。沿途经过的每一个节点其访问次数visits都要加 1如果模拟结果是当前玩家获胜那么对应获胜方路径上的节点累计胜局wins也要加 1。为什么这么做因为一个节点代表“某个玩家走到这个局面”当基于这个节点继续模拟赢了说明这个局面是“有利的”那它的父节点即导致这个局面的动作也应该沾光。回溯操作把模拟结果传播给了所有祖先让父节点、祖节点的胜率统计得以更新。经过这四个阶段一轮迭代结束。整个算法就是不断重复“选择-扩展-模拟-回溯”直到达到预设时间或迭代次数。最终决策时不是选胜率最高的孩子而是选“被访问次数最多”的孩子。因为访问次数多说明这个孩子被反复验证过统计置信度高。这一个反直觉的细节也是实操中的常见误区。3. 核心公式和关键参数UCB1 背后的权衡既然选择阶段需要一个打分规则MCTS 里最常用的是 UCB1配合这个公式的 MCTS 通常叫 UCTUCB applied to Trees。3.1 探索与利用吃饭选餐厅的博弈要理解 UCB1先想一个生活场景你常去公司楼下那家拉面馆每次都好吃这就是“利用”你已知的好选择。但附近最近新开了一家川菜馆没人试过要不要去试这可能踩雷但也可能比拉面更好吃。一次两次去新店是“探索”。但如果一直探索新店你就可能错过多吃几次拉面带来的稳定快乐一直吃老店又可能错过新神店。MCTS 的选择阶段也面临同样的问题到底走胜率最高的老分支还是去碰碰几乎没访问过的新分支UCB1 给出的答案很聪明每个孩子节点的打分由两部分组成一个是“利用”项当前胜率一个是“探索”项从未访问次数中推导出的不确定性奖励。访问次数越少探索项越大越有机会被选中。3.2 UCB1 公式拆解均值加不确定性红利形式上对于节点 j其 UCB1 值为[ UCB1_j \frac{W_j}{N_j} C \times \sqrt{\frac{\ln N_{parent}}{N_j}} ]公式里各符号含义如下( \frac{W_j}{N_j} )节点 j 的累计胜率或平均奖励代表这个节点“目前看起来有多好”。( N_{parent} )父节点的总访问次数。( N_j )节点 j 的访问次数。( C )探索常数控制探索项的权重。我拿一个具体数字算给你看。假设父节点访问了 100 次有两个孩子 A 和 B。A 访问 50 次、赢了 30 次胜率 0.6B 只访问了 2 次、赢了 1 次胜率 0.5。取 ( C \sqrt{2} \approx 1.414 )A 的 UCB1 0.6 1.414 × sqrt( ln(100) / 50 )B 的 UCB1 0.5 1.414 × sqrt( ln(100) / 2 )计算一下ln(100) ≈ 4.605。A 的根号部分 ≈ sqrt(0.0921) ≈ 0.3035乘以 1.414 ≈ 0.429B 的根号部分 ≈ sqrt(2.3025) ≈ 1.5176乘以 1.414 ≈ 2.146。所以 A 得分 1.029B 得分 2.646。即使 B 的胜率略低但因为访问次数太少不确定性大它反而会被优先选中刺激算法去尝试新方向。当 B 被反复试过之后它的胜率会逐渐回归真实水平如果表现不佳访问次数涨上去后探索项会迅速下降下次就轮到访问次数不多但胜率不错的新分支了。3.3 探索常数 C 怎么调大则激进小则保守C 是 UCB1 公式里唯一需要手动调的参数。它决定“探索项”相对“利用项”有多重要。如果 C 设得很大探索项权重高MCTS 会频繁尝试冷门分支搜索树铺得很开但深度挖不够决策会偏向随机。如果 C 设得很小MCTS 会很快锁定当前胜率高的分支减少探索可能错过隐藏在低访问次数里的好棋。常见的 C 经验值是 (\sqrt{2})也有不少项目直接用 1.4 或者 1.0。实际应用中C 可以随着搜索深度动态调整前期大一点鼓励探索后期小一点加快收敛。但在入门阶段固定 C 1.4 常常够用。还有一个容易被忽略的点UCB1 公式里的 ln(N_parent) 是全局共享的所以对于同一层级的兄弟节点探索项只和各自访问次数 N_j 相关。访问次数越少的孩子获得的“不确定性红利”越大这保证了所有合法动作一开始都有机会被探索。4. 从零实现一个可运行的 Python 版井字棋 MCTS理论讲再多不如直接跑代码。下面我用一个简单且完整的 Python 实现一步步带你把井字棋的 MCTS 写出来。代码不需要任何第三方库Python 3.6 以上即可。4.1 整体设计和两个核心类我们按职责划分成两个类TicTacToe维护棋盘状态、当前玩家、动作生成、胜负判断。MCTSNode搜索树节点记录 parent、children、visits、wins以及尚未尝试的动作列表。搜索逻辑写在mcts_search()函数里和节点类分离方便阅读。先看游戏状态类class TicTacToe: def __init__(self): self.board [ ] * 9 # 0-8 代表九宫格 self.current_player X def get_legal_moves(self): return [i for i, cell in enumerate(self.board) if cell ] def make_move(self, pos): # 返回新的状态对象而不是修改原状态 new_game TicTacToe() new_game.board self.board.copy() new_game.board[pos] self.current_player new_game.current_player O if self.current_player X else X return new_game def is_terminal(self): return self.is_winner(X) or self.is_winner(O) or len(self.get_legal_moves()) 0 def is_winner(self, 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,b,c in lines: if self.board[a] self.board[b] self.board[c] player: return True return False注意make_move没有原地修改棋盘而是返回新副本。这样做的好处是搜索树中每个节点都能保留独立的局面状态回溯时不会互相污染。代价是有些内存开销但井字棋状态小无所谓。4.2 节点类的实现import math import random class MCTSNode: def __init__(self, state: TicTacToe, parentNone, actionNone): self.state state self.parent parent self.action action # 到达该节点所走的动作 self.children [] self.visits 0 self.wins 0 # 尚未展开的合法动作列表 self.untried_actions state.get_legal_moves() def is_fully_expanded(self): return len(self.untried_actions) 0 def best_child(self, exploration_constant1.414): best None best_score -float(inf) for child in self.children: # UCB1胜率 探索项 win_rate child.wins / child.visits if child.visits else 0 exploration math.sqrt(2 * math.log(self.visits) / child.visits) if child.visits else float(inf) score win_rate exploration_constant * exploration if score best_score: best_score score best child return best def expand(self): action random.choice(self.untried_actions) next_state self.state.make_move(action) child MCTSNode(next_state, parentself, actionaction) self.children.append(child) self.untried_actions.remove(action) return child def simulate(self): state self.state current_player state.current_player while not state.is_terminal(): legal_moves state.get_legal_moves() move random.choice(legal_moves) state state.make_move(move) if state.is_winner(current_player): return 1 else: return 0 def backpropagate(self, result): self.visits 1 self.wins result if self.parent: self.parent.backpropagate(result)这里有个陷阱simulate中把state重新赋值成state.make_move(move)但make_move返回的是新对象所以每次模拟都是独立局面不会影响搜索树。current_player在开始时固定用来判断“从该节点出发的模拟是否赢”这是 MCTS 的标准投注逻辑。best_child中如果child.visits为 0会直接给无限大让所有从未访问过的孩子都有机会被选中但因为untried_actions的存在这里通常不会出现 visits 为 0 的孩子。保留这个判断是为了健壮性。4.3 搜索主循环与 MCTS 决策有了节点类主搜索就非常简单了def mcts_search(root_state, iterations1000): root MCTSNode(root_state) for _ in range(iterations): node root # selection: 一路向下走直到遇到未完全展开的节点 while not node.is_terminal(): if not node.is_fully_expanded(): break node node.best_child() # expansion: 如果当前节点还有未尝试的动作就扩展一个 if not node.is_terminal() and not node.is_fully_expanded(): node node.expand() # simulation: 随机模拟得到结果 result node.simulate() # backpropagation: 回溯更新 node.backpropagate(result) # 决策选择访问次数最多的孩子不是最高胜率 best_child max(root.children, keylambda c: c.visits) return best_child.action这里node.is_terminal()直接调用的是TicTacToe的终止判断。在真实代码中建议把终止判断也加在 MCTSNode 的状态判断里。simulate()返回 1 或 0但回溯的时候注意结果要传给所有祖先。由于井字棋没有平局之外的第三态我返回了 1/0如果要让平局也有意义可以返回 0.5 或者 0这取决于你想给平局多大权重。入门阶段直接用 0/1 就够了。4.4 调用示例让 MCTS 替玩家落子接下来模拟一个五步以内的对局看看 MCTS 怎么工作if __name__ __main__: game TicTacToe() # 玩家 X 使用 MCTS 决策 while not game.is_terminal(): print(f当前玩家: {game.current_player}) print_board(game.board) if game.current_player X: # MCTS 决策 move mcts_search(game, iterations500) print(fMCTS 选择落子位置: {move}) else: # 玩家 O 随机落子做对手 move random.choice(game.get_legal_moves()) print(f随机对手落子: {move}) game game.make_move(move) print(游戏结束)print_board就是个简单格式化函数把 9 个格子按 3×3 打印。实际跑几次会发现MCTS 即使在 500 次迭代下也能走出比较合理的前三步比如优先占中心堵住对手的连线。这说明算法已经学到了一些基本棋感。如果迭代次数降到 50MCTS 的走法会明显变“毛糙”经常随机选边角升到 5000它会花一些时间井字棋也就几百毫秒但决策质量更高。这种可扩展性正是 MCTS 的特点。5. 常见问题与调参避坑实录5.1 为什么我写的 MCTS 有时像随机走子最可能的原因模拟次数太少或者best_child的visits为 0 孩子太多导致无限大分数干扰。迭代次数太少井字棋至少需要 200~500 次迭代才能看到明显棋感。如果只有 10 次每个动作只被尝试一两次统计噪声巨大自然像随机。visits为 0 的孩子在实现best_child时如果直接给无限大分数且选择阶段没有在上层把未扩展分支拦下来算法会反复选择同一个没访问过的孩子导致其他分支被无视。解决方法是确保在selection时遇到not is_fully_expanded()就停止扩展逻辑避免一棵空分支被连续选中。我在早期实现中犯过第二个错误处理方式是强制要求best_child只从visits 0的孩子里选若所有孩子都未访问过就随机选一个。这样能避免初始阶段的某种“偏食”。5.2 搜索树爆炸内存吃不消怎么办MCTS 的节点数量和迭代次数线性相关每个迭代最多增加一个新节点。如果每步决策做 10 万次迭代几十步下来节点数可能到百万级每个节点保存一个棋盘副本内存压力不小。有几个缓解思路设置迭代上限或时间上限别无限跑。在每步决策完成后丢到根节点只从当前局面重新建树。前一步搜索树里的经验无法直接迁移但这在多数入门项目里没关系。对于更复杂的棋类可以使用“渐进宽化”每个节点最多保留前 K 个孩子按某种启发式排序来限制兄弟数量。对井字棋来说500 次迭代完全够用完全不需要担心内存。5.3 探索常数 C 到底设多少我建议初学者直接用默认值 1.414也就是 (\sqrt{2})。然后做一次简单的对照实验分别取 C0.1、1.0、2.0、5.0在固定模拟次数下让 MCTS 自己跟自己下 100 局记录胜率。你会看到C0.1收敛快但容易只走已知好棋可能被随机新招钻空子。C1.0~1.5平衡较好。C5.0全局搜索铺得开但关键分支深度不够往往开局平庸。这个实验很有价值能让你直观理解“探索-利用”平衡如何影响棋力。5.4 随机模拟太耗时间如何加速如果你的棋类游戏状态很大比如围棋模拟阶段就成了性能瓶颈。几个办法默认策略用启发式随机只从几个“看着合理”的动作里随机选而不是全局均匀随机。比如井字棋可以先取边角、中心等更有策略的位置。提前终止在模拟过程中如果发现局面一方大幅领先就用简单评估函数直接截断不模拟到底。并行化每轮迭代之间没有依赖关系可以在多线程或多进程上并行跑多次 MCTS然后把搜索结果汇总到一棵共享树上。5.5 常见问题速查表症状可能原因解决方法MCTS 胜率低且忽高忽低模拟次数过少统计噪声大增加迭代次数到 1000 以上搜索树迅速膨胀节点数量无上限内存吃紧设置迭代上限、丢根重建、限制孩子数量首选动作经常变化探索常数太大还未收敛减小 C 值或设置更长时间总是选同一个动作不再尝试新招探索常数太小陷入局部最优增大 C 值或动态调整模拟阶段表现异常胜负判断写错或玩家视角混淆单测make_move、is_winner和模拟胜负所有孩子胜率都接近 0.5对手随机导致结果无区分度改用固定策略的对手测试初学者最容易搞混的就是“模拟阶段的视角”问题。记住simulate返回的 1/0 是相对于“从当前节点出发时轮到谁”而言。如果当前节点是玩家 X 的落子状态那么模拟结果是 X 赢就返回 1如果是玩家 O 的节点那么 O 赢才返回 1。很多 bug 都是因为视角错位导致回溯胜率颠倒棋力瞬间归零。我在实际写 MCTS 时还有一个习惯先写一个只做 100 次迭代的傻瓜版把它放到井字棋里和随机策略对打确认没有视角错误、胜负判断正确后再加参数调优。这样一步步来排错速度快得多。MCTS 的迷人之处在于它不需要领域知识就能在复杂决策空间中找到像样的答案。你给它简单的胜负规则它自己会学会中心开局、封堵连线这些基本战术。对于初学者来说这份井字棋代码是一个很舒服的起步点它足够小小到你可以在一个晚上读完每一行它又足够完整包含了 MCTS 的所有核心机制。下一回当你在某篇文章里看到 UCT、探索常数、渐进宽化这些名词就不会再心虚了——你已经知道它们背后都是“带着脑子的随机模拟”在起作用。