☰
蒙特卡洛树搜索实战:从UCT公式到代码实现与优化
2026/11/4 8:49:32 网站建设 项目流程

第一次让一个只会随机下棋的程序通过蒙特卡洛树搜索在几秒钟内超过我自己手工编写的策略逻辑时,我对这种看起来“碰运气”的算法彻底改观了。后来在棋盘游戏引擎、资源调度和机器人路径规划里反复使用它,我必须说,蒙特卡洛树搜索(Monte Carlo Tree Search,简称MCTS)是我见过最优雅的通用决策算法之一:代码量不大,却能在分支因子极高的搜索空间中,用统计抽样的方式逼近足够好的决策。

这篇文章不打算讲成论文,我会从“为什么要用它”“它的四个阶段到底在干嘛”“UCT公式怎么理解”“怎么从零写出能跑的代码”这几个角度展开,最后聊聊我在工程和竞赛里踩过的一些坑。不管你是刚接触博弈树的新手,还是已经会写minimax但想换一条路的老手,这篇内容都可以直接当参考。

1. 先搞清楚蒙特卡洛树搜索到底在解决什么问题

1.1 搜索树与Minimax的边界

在MCTS出现之前,解决这类“轮流决策”问题,最主流的工具是minimax加alpha-beta剪枝。它的逻辑很漂亮:把我方收益最大化、对方收益最小化,然后沿着博弈树往下搜索。当搜索深度受限时,再用评估函数做静态打分,剪枝则负责砍掉明显不优的分支。

这套方案在象棋、国际跳棋这类游戏里统治了很多年。但有一个硬伤:当每一步的可行动作非常多,或者局面极其开放时,博弈树会以指数级膨胀,剪枝也救不回来。比如围棋,状态空间大到传统搜索完全无法靠深度优先遍历找到好棋。另一个问题是,很多真实决策问题根本没有清晰的“胜负”评估函数,外部环境还有大量随机性,这种情况下手工设计启发式非常痛苦。

蒙特卡洛树搜索的思路完全不同:不追求把整棵树都展开,而是用随机抽样来“估计”一个节点的胜率。它的核心信念是:一个局面好不好,可以通过大量随机模拟的结果来判断。这就把“评估函数设计”这个老大难问题,变成了一把“生成足够多的随机对阵”的锤子。

1.2 MCTS的直观思路:用随机抽样指导选择

我不是第一次见这类想法时就能接受。大多数人听到“随机模拟一万次看谁赢得多”都会觉得不靠谱:单次模拟的方差那么大,怎么可能靠这个选出好棋?早期的蒙特卡洛搜索确实有这个毛病,纯粹随机摆棋,赢了几次就觉得某一步好,经常选到非常浅显的陷阱。

MCTS真正的突破在于它不搞“平均用力”。它会用树结构记住哪些分支已经被验证过,让后续的模拟尽量集中在有希望的分支上,同时仍然保留一部分探索空间。这个思想,本质上就是计算机科学里最经典的“探索与利用”问题:你是继续深挖目前看起来最好的选项,还是花一点资源去看看那些没怎么试过的选项?MCTS把这个权衡变成了一套优雅的树上策略,也就是后面要讲的UCT公式。

理解了这一点,你就抓住了整个算法的灵魂。后面写的代码、调的参数、做的改进,全都是在“探索”和“利用”之间找平衡。

2. 四个阶段:看懂一棵树怎么“长大”

MCTS的每一次迭代可以拆成四个阶段:选择(Selection)、扩展(Expansion)、模拟(Simulation)、回传(Backpropagation)。听起来很正式,但拆开看每一步都很简单,我一个个说。

2.1 Selection:每次从根节点往下选一条“最值得”的路

从一个根节点出发,代表的是当前局面的空树或者小树。Selection的目标是从根节点开始,沿着已建好的子树往下走,走到一个还没有完全展开、但又值得继续深入的节点。

怎么判断“值得”?每个节点都保存了两个统计量:被访问的次数和累计的胜负分。我们用这两个数算出一个“得分”,得分最高的子节点就是当前路径的最佳选择。这个过程一直重复,直到我们到达一个叶子节点,且这个节点还有没展开过的动作,或者它已经是终局。

如果某一步的子树还没建完全,但某些子节点已经被访问过一次,Selection就不会总往同一个方向走,因为那个访问次数多的子节点“探索值”会下降,其他没怎么访问的子节点会得到更高的探索加分,从而被选中。这就是“雨露均沾”的数学实现。

2.2 Expansion:什么时候新增一个节点

走到一个“有潜力但没完全展开”的节点后,算法就会从它的未尝试动作列表里挑一个动作,生成新的状态,再把这个新状态包装成子节点挂到树上。

这里有个细节:每次迭代只展开一个子节点,而不是一下子把所有合法动作全展开。原因很朴素——一下子全展开,计算量太大,而且很多动作可能根本没意义,不如先随机试一个,让统计结果决定哪些分支值得被继续深入。这种“渐进展开”的思想也是MCTS能应对高分支因子的关键。

我当年的误区是觉得“既然是搜索,多展开几个节点总没坏处”。结果发现树的内存和计算开销会迅速失控,而且前期大量展开的节点未必有人访问,浪费非常严重。按需生长,才是MCTS的正确姿势。

2.3 Simulation:到终点最快的方式

从新增的子节点出发,算法会启动一次“快速对局”:不依靠树结构,直接在当前状态下随机选合法动作,一路模拟到终局,并返回结果(胜、负、平)。

Simulation是整个算法里最耗CPU时间但逻辑最简单的部分。因为它要跑很多次,所以性能优化都会盯上这里。一般不会用特别复杂的策略去做随机模拟,因为模拟本身只是给树提供统计样本。你可以用均匀随机,也可以加入一点简单启发式,比如优先走中间、优先吃子,但不要做太重的评估——那样会让“每轮模拟”变慢,收益却不一定更高。

我见过有人把Simulation写成一个深度递归、里面再做一次小搜索的版本,结果一轮迭代要几毫秒,一万次模拟就卡到怀疑人生。这里记住一个原则:模拟要快,快比“聪明”更重要。因为统计数量能弥补单次模拟的粗糙。

2.4 Backpropagation:把结果一层层反馈回树

模拟出了结果,不代表算法就结束了。还要把这个结果从当前节点一路沿着刚才选中路径传回根节点,把路径上每个节点的访问次数都加1,胜负分也累加上去。这样,下一次Selection才能用更新过的统计量做判断。

我觉得这里有一层特别容易被忽略的思想:MCTS并不是每棵树都完整“看出”终局之后才决定走哪步的。它是在不断迭代的过程中,让状态分布越来越偏向“已知较好的分支”,模拟次数越多,统计越稳定,根节点下一步的选择才越可靠。回传就是让信息流动起来的那条血管。

以上四个阶段组成一次完整迭代。通常我们不会设置固定的模拟次数上限,而是给定一个时间预算,比如“每秒迭代2000次”;或者给定迭代次数上限。迭代完成后,选择根节点中访问次数最多(不是胜率最高)的子节点作为最终动作。这个“按访问次数选”的原则,后面我会解释。

3. UCT公式与探索常数:决定走多远、看多细

3.1 数学公式逐项拆解

MCTS最经典的节点打分公式是UCT(Upper Confidence Bound for Trees)。它长这样:

[ UCT = \frac{w_j}{n_j} + C \cdot \sqrt{\frac{\ln N}{n_j}} ]

其中 (w_j) 是节点 (j) 的累计得分,(n_j) 是节点 (j) 被访问的次数,(N) 是当前父节点的访问次数,(C) 是探索常数。

你可以把这个公式拆成两块看。左边 (\frac{w_j}{n_j}) 是平均收益,也就是“利用”部分:历史上越优势,分越高。右边则是一个探索奖励:当某个节点被访问的次数 (n_j) 很小,而父节点访问总数 (N) 很大时,(\sqrt{\frac{\ln N}{n_j}}) 会比较大,相当于给“还没看够”的分支加分。随着 (n_j) 增大,这部分逐渐衰减。

实际操作中,如果某个孩子节点的 (n_j = 0),对数分母会除零,这时候直接返回一个无穷大的值,保证“没试过的子节点优先被摸一遍”。等到所有孩子都被摸过一轮,UCT才真正开始做平衡。

3.2 C值到底怎么调

探索常数 (C) 就是用来控制探索奖励权重的旋钮。调大C,算法更愿意探索看似不佳的分支;调小C,算法更倾向于使用已经验证过的优势分支。标准取值通常在1.0到2.0之间,很多开源实现直接用1.414(即根号2),但不要迷信这个值,它是游戏相关的。

我有一个比较朴素的经验:先在简单棋盘游戏里跑参数扫描,从0.5、0.8、1.0、1.5、2.0各跑一轮,对比测试集上的胜率。真实比赛里有时候你把C从1.2调到1.5,成绩会有几个百分点的变化,这在对抗场景里是非常巨大的优势。

C取太小,树会像“急功近利”的人一样,周围稍微好一点就只往那边钻,一旦估计偏差大就可能漏掉隐藏的优步。C取太大,树会像一个每天到处乱逛、哪里都好奇但样样不精的人,深度永远不够,搜索到后期还在大量尝试明显很差的动作。

3.3 为什么不是纯随机或纯贪心

如果完全随机展开,那得到一个巨大的扁平树,每个节点都被均匀地模拟,等于把“搜索”变成了“抽样”,却丢掉了利用历史信息深度挖掘的能力。如果完全贪心,总是选当前胜率最高的子节点,那只要前几次随机模拟产生偏差,就可能被锁在一个局部最优分支里再也出不来。

MCTS恰好落在中间。它有两层防护:第一层是未访问节点优先,保证每步至少被尝试过一次;第二层是UCT公式里跟父节点访问次数相关的探索项,让长时间没被“翻牌子”的分支有机会被重新考虑。这就是为什么它能在高分支因子环境下仍然保持比较稳定的表现。

4. 从零实现一个可运行的MCTS

4.1 抽象游戏接口设计

如果你想把MCTS用到不同游戏上,第一步应该是抽一个通用状态接口,而不是把游戏逻辑和搜索逻辑焊死在一起。我习惯把一个状态需要的能力定义成四个方法:获取合法动作、执行动作产生新状态、判断是否终局、返回终局结果。

以经典的“拿石子”游戏为例:一堆石子,双方轮流取1到3颗,谁取走最后一颗谁赢。这个游戏规则极简,非常适合拿来看MCTS的运转。

下面是我通常用的界面设计:

class GameState: """抽象游戏状态接口""" def get_legal_actions(self): raise NotImplementedError def apply_action(self, action): raise NotImplementedError def is_terminal(self): raise NotImplementedError def get_result(self): """以先手方视角返回终局结果:1.0 胜,0.0 负,0.5 平""" raise NotImplementedError

你可能会问为什么要统一成“先手方视角”,而不是“当前行动方视角”。因为在Backpropagation时,一条路径上不同深度的节点立场相反,如果每个节点返回不同视角的胜负,累加时还得做符号翻转,很容易出错。固定从某一个视角看终局结果,回传时直接用统一符号累加,代码简单一倍。这是我在第一个自己实现版本里吃过大亏后总结出的经验。

4.2 节点类的核心字段与选择函数

节点保存的信息不必太复杂。我建议至少保留:当前状态、父节点、子节点列表、未尝试动作列表、访问次数、累计得分。不要把“胜率”单独存一个字段,因为它是访问次数和累计得分算出来的,单独存反而容易出现不一致。

class Node: def __init__(self, state, parent=None): self.state = state self.parent = parent self.children = [] self.visits = 0 self.wins = 0.0 self.untried_actions = state.get_legal_actions() def q_value(self): return self.wins / self.visits if self.visits > 0 else 0.0 def uct_value(self, c=1.4): if self.visits == 0: return float('inf') parent_visits = self.parent.visits if self.parent else 1 return self.q_value() + c * math.sqrt(math.log(parent_visits) / self.visits)

这里有一个细节:如果unvisited子节点的uct直接返回无穷大,那么max在选择时就会优先选中它。如果把返回值设为0,可能会被其他访问过的节点压过,导致有些分支永远不被尝试。

Selection函数则负责从根节点开始,沿着UCT最优点下钻:

def select_best_child(node, c=1.4): return max(node.children, key=lambda n: n.uct_value(c))

标准迭代流程可以选择在当前节点没有未尝试动作且还有孩子时一路下钻:

def tree_policy(node, c=1.4): while not node.state.is_terminal() and not node.untried_actions: node = select_best_child(node, c) return node

4.3 主循环与模拟策略

有了节点和选择策略,就能写完整迭代了:

def mcts_iter(root, iterations=1000, c=1.4): for _ in range(iterations): node = tree_policy(root, c) # 扩展:选择一个未被尝试的动作 if node.untried_actions and not node.state.is_terminal(): action = node.untried_actions.pop() child_state = node.state.apply_action(action) child = Node(child_state, parent=node) node.children.append(child) node = child # 模拟 result = rollout(node.state) # 回传 while node is not None: node.visits += 1 node.wins += result node = node.parent

模拟策略可以极简,均匀随机选动作即可:

def rollout(state): while not state.is_terminal(): actions = state.get_legal_actions() action = random.choice(actions) state = state.apply_action(action) return state.get_result()

注意我没有在Rollout里创建新节点,因为Rollout过程不对树做持久化,它只是产生一条模拟轨迹。每轮模拟结束,轨迹上的状态就被丢弃了,只把最终结果反馈给树路径上的节点。

4.4 在“拿石子”游戏上验证

写完后我习惯用一个小游戏快速验证“MCTS到底学没学到东西”。拿石子游戏的终局很简单,所以跑起来特别直观。

我先跑一个对照实验:让MCTS迭代1000次,然后统计根节点下各个子节点的访问次数和胜率,看看它是不是真的偏爱正确的那一步。

假设当前石子数是4,轮到先手方。那正确策略是先拿走3颗,留给对手1颗,这样对手只能拿走最后一颗,稳赢。换成树视角,MCTS应该在经过几百次迭代后,对“取3”这个子节点给出最高的访问次数。

如果访问次数最高的动作不是“取3”,我第一个怀疑方向是UCT的C值设置和模拟策略是否出了问题。对这个只赢不败的简单局面,C取1.4甚至2.0都不该失误。出现失误通常意味着:访问次数还没收敛,或者模拟里产生了不正确的角色胜负统计。我会把迭代次数提到5000再看一次。

把MCTS实际接到游戏循环里还有一个常见习惯是第一手动作固定用最大访问次数,不按最大胜率选。虽然胜率高的节点“看起来更好”,但访问次数多的节点统计更可靠。在高不确定性的搜索场景里,鲁棒性往往比“高均值”更重要。尤其是根节点子节点访问次数差异不大时,只看胜率很容易被前几次模拟噪声带偏。

5. 实战中常见的坑与排查思路

5.1 访问次数为0时的除零问题

这是新手最常见的运行时崩溃。某个子节点还没有被访问过,但它的uct_value里已经做了除法,直接抛ZeroDivisionError。解决办法在上面的代码里已经写了:把未访问节点的UCT设为无穷大,让它必然先于其他节点被选中。这个设计不仅避免崩溃,还能加强探索一致性。

如果是工程化项目,我更建议把无穷大写成float('inf')而不是999999。原因很简单:如果有两个未访问节点,max遇到相同值时会取第一个,这没关系;但如果用固定大数,万一你的得分本身也可能接近这个数量级,比较逻辑就会出问题。用真正的无穷大最安全。

5.2 时间预算 vs 模拟次数

不少新手会问:“到底该迭代多少次?”这个问题没有标准答案,因为它取决于游戏分支因子、模拟速度和硬件性能。我只建议用时间预算而不是固定迭代次数作为终止条件,尤其在比赛场景中,双方都需要在统一时间内给出落子。

比如给每次决策1秒钟,那么在循环开始前记录时间,循环里每迭代一定次数检查一下耗时,超时就停止。以前我在一个象棋类项目里固定8万次迭代,结果换到不同机器上成绩完全不一样;后来改成时间预算,成绩立刻稳定下来。简单说,迭代次数是物理量,时间预算才是用户体验。

5.3 随机性与结果稳定性

MCTS是随机算法,同一个局面跑两次,选的落子可能不同。这很容易让第一次接触它的人焦虑,但其实是它发挥统计优势的前提。如果想调试,可以给随机数生成器设置固定种子,让每次运行结果可复现。

不过生产环境里我建议不要固定种子。因为在竞技场景下,固定种子会让对手通过观察你的历史行为预测你的选择偏好。让每次决策保留随机性反而能增加自身的“不确定性”,让对手难以针对。尤其在在线对战系统里,稳定性不如“不可捉摸性”值钱。

5.4 树内存膨胀与对称状态

树随着迭代不断上涨,节点数可以到百万级,内存开始成为瓶颈。一个常见的补救方法是用“每次决策重建根节点池”的机制:走完一步棋后,把原来代表当前棋盘局面的那个子节点直接提升为新根,丢弃兄弟节点。这样做的意思是,父节点视角里大部分旧树分支已经没用了,他们只会占据内存却没有新的访问机会。

另一个可以显著提升效率的思路是合并对称状态。像棋类游戏里,棋盘经过旋转或镜像后本质上是同一个局面。我见过一个开源框架在生成子状态时同时计算规范化哈希,把同构状态复用同一个节点,节点数量直接下降了接近一半。代价是存取哈希表有额外开销,如果你本来内存不紧张,可以先不急着做。

6. 进阶方向:从随机模拟到神经网络先验

6.1 RAVE与渐进宽化

纯随机模拟的MCTS在很多中小型游戏里已经很强,但面对非常复杂的游戏时,每个模拟都从零开始随机走,噪声太大,收敛变慢。改进方案里最经典的是RAVE(Rapid Action Value Estimation),它基于一个直观假设:在整个模拟过程中,某个动作如果在很多不同局面里都出现过,那么这个动作整体的历史表现,可以通过全局统计来给树节点提供参考。

具体说,RAVE在计算节点得分时,不只是看当前孩子节点的胜率,还会把它与一个“快速估值”混合起来。快速估值反映的是这个动作在所有模拟中被使用时的胜率。这个混合能让树在早期迅速去掉很多明显糟糕的动作。它已经在一些棋类程序中被证明有效。

另一个重要的改进是“渐进宽化”(Progressive Widening)。原始MCTS每次都只展开一个孩子,但在分支因子极高时,你会希望先放开试几个动作,再慢慢根据统计信息决定要不要扩展到第10个、第50个动作。渐进宽化通过控制“当前每个节点的子节点数量上限”与访问次数的关系,让树从“粗看”逐步到“精看”,保持高效的同时避免过早锁定单一分支。

6.2 先验概率与神经网络的结合

如果告诉我可以大刀阔斧改进MCTS,我会直接说:用神经网络替换掉随机模拟。这也是AlphaGo相关技术公开后的核心启发。

常规MCTS在扩展新节点时,会把所有合法动作当作等概率候选项。但神经网络可以被用来估算每个动作的先验概率 (P(a|s)),于是节点在初始化时,不只是把动作放进untried_actions,而是可以把“先验概率”存入节点。Selection时的UCT公式,右侧探索项可以乘以(或至少加权)先验概率。这样一来,算法会更早走向那些“看起来专业”的分支,而不是完全靠随机尝试去发现好棋。

Rollout部分也可以直接用神经网络的策略输出替代随机动作,甚至可以在极少数局面直接调用价值网络评估。这样树可能在很浅的深度就已经有了相当强的判断力,从根本上改变了模拟速度与质量之间的权衡。

在工业级的博弈AI里,这种“神经网络先验+MCTS树搜索”已经成为事实标准。即使你对深度学习不熟悉,也可以先在简单游戏里用一些规则启发式生成“伪先验”,比如优先走中心、优先占据分值更高的格子,验证一下这类先验启发式能提升多少收敛速度。

6.3 多线程和并行模拟的注意点

MCTS天然适合并行化,因为大量迭代之间是相互独立的随机模拟。但并行时需要处理好共享树节点的数据竞争。最简单的方案是:每几个分叉共用一把锁,只在访问次数和累计得分更新时加锁。再激进一点,可以用“虚拟损失”。

虚拟损失的做法是:在选择节点时,暂时把节点的访问次数加一个大数(比如3或5),等该线程完成一次迭代并真正回传时,再减去这部分虚拟损失并按实际结果更新。这样多个线程在模拟时会天然“避开”彼此正在处理的路线,减少冲突。我在多线程环境里用这个技巧后,迭代吞吐量提升很明显,但要小心虚拟损失改得太大,有些分支可能永远不被选中。

7. 什么时候应该用MCTS,什么时候别用

7.1 适合MCTS的场景特征

MCTS不是万能药,但它非常擅长处理这类问题:分支因子大、评估函数难写、搜索深度可以很深,但你有能力进行终局模拟。比如棋牌游戏、拼图规划、资源调度、带随机性的路线规划,都属于典型场景。

它也很适合那些“你知道怎么判断终局,但不知道如何给中间局面打分”的问题。如果你能写出一套可以快速随机模拟的规则,但写不出合适的启发式评估函数,MCTS就是很好的选择。它把“评估中间局面的难度”转移成了“运行足够多次模拟的计算成本”。

7.2 不适合的典型场景

如果分支因子很小、深度也不深,传统的minimax加alpha-beta剪枝会更快更准。MCTS是统计近似算法,它给出的落子带有随机性,在确定性很强的小型博弈里反而不如全量搜索可靠。

如果终局无法自然定义,或者根本没有明确的胜负结果,MCTS的统计框架就会比较尴尬。你需要自己设计“模拟终止条件”和“结果打分函数”,这个过程又变回了“设计评估函数”的老问题,MCTS的免评估优势就消失了。

另外,如果你的动作空间是连续值,比如无人车转弯角度、机械臂关节角度,直接用MCTS枚举离散动作会非常吃力。此时更合适的通常是强化学习或者连续优化算法。MCTS更偏向离散动作空间,强行离散化连续空间会导致分支因子爆炸。

7.3 一个简单的决策清单

我会在动手前问自己几个问题:

  • 能不能在毫秒级内完成一次随机模拟?如果能,MCTS很可能适用。
  • 我有没有一套可靠的终局判胜逻辑?没有的话,就得先补这块。
  • 分支因子是否大到让穷举无法承受?如果只有十几种动作且深度很浅,minimax更靠谱。
  • 中间局面能不能写出有效评分函数?如果写不出来,MCTS的优势更大。

这个清单帮我避免过不少“为了用MCTS而用MCTS”的弯路。技术选型从来不是“越高级越好”,而是匹配问题本身的结构。

根据我个人做这些项目下来的体会,MCTS最让我欣赏的一点就是:它把复杂的决策问题拆成了无数个极其简单的随机实验,再用统计的力量把噪声压下去,得到一个非常抗干扰的决策。你在实际项目里遇到的大部分问题,其实不是算法本身的数学错误,而是节点统计的一致性问题、模拟效率问题和搜索策略过偏的问题。调试的时候,先不要急着改公式,先把树画出来,看看访问次数分布是否已经符合直觉,再动手改代码,这样往往几轮就能定位到病根。

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

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

立即咨询