如果你做过棋类游戏,或者写过自动博弈程序,应该对这样一个场景不陌生:棋盘状态多到爆炸,普通搜索根本穷举不完,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 通常叫 UCT(UCB 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.6;B 只访问了 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.429;B 的根号部分 ≈ sqrt(2.3025) ≈ 1.5176,乘以 1.414 ≈ 2.146。所以 A 得分 1.029,B 得分 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, parent=None, action=None): 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_constant=1.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, parent=self, action=action) 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, iterations=1000): 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, key=lambda 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, iterations=500) print(f"MCTS 选择落子位置: {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 次迭代下,也能走出比较合理的前三步,比如优先占中心,堵住对手的连线。这说明算法已经学到了一些基本棋感。
如果迭代次数降到 50,MCTS 的走法会明显变“毛糙”,经常随机选边角;升到 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})。然后做一次简单的对照实验:分别取 C=0.1、1.0、2.0、5.0,在固定模拟次数下让 MCTS 自己跟自己下 100 局,记录胜率。你会看到:
- C=0.1:收敛快,但容易只走已知好棋,可能被随机新招钻空子。
- C=1.0~1.5:平衡较好。
- C=5.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、探索常数、渐进宽化这些名词,就不会再心虚了——你已经知道它们背后都是“带着脑子的随机模拟”在起作用。