计算机博弈大赛亚军围棋程序拆解:UCT搜索与调参实战
发布时间:2026/9/25 6:41:00来源:尧图网络
简介这是一份计算机博弈大赛亚军项目“幻影围棋”的完整源码包面向对围棋人工智能、博弈算法和蒙特卡洛树搜索感兴趣的学习者与开发者既适合入门研读也适合作为课程设计与竞赛备赛参考。压缩包共42个文件体积约2.22MB包含C源码与头文件、工程和解决方案文件、可执行程序、编译产生的中间文件以及概要设计文档目录结构清晰可对照设计文档逐模块学习。目前已有1385人学习下载。代码以蒙特卡洛树搜索为核心配合局面评估、并行搜索与剪枝优化来解决围棋巨大的搜索空间同时可能融入模仿学习或神经网络等幻影策略结合源码中的调试信息、性能优化技巧和大量对局训练思路可以完整还原亚军程序的设计脉络是研究计算机博弈、人工智能算法和程序优化技巧的难得实例。1. 从 PlantomGo.rar 看计算机博弈大赛亚军围棋程序押在哪PlantomGo.rar 解压出来是一整套围棋博弈代码文件名里的 lowiu7 是迭代版本标签项目代号叫幻影棋出处是某届计算机博弈大赛围棋项目的亚军。这类压缩包在网上流传很久不少人以为“解压就能跑”实际真正值钱的是它的模块拆分和参数边界。今天不逐行解读某个源码而是把这类亚军程序通用的实现路线摊开棋盘、UCT 搜索、评估函数、时间控制四条线怎么搭怎么调哪些地方会翻车。这个方案适合三类人准备参加计算机博弈大赛的学生想用围棋做 AI 课程设计的开发者以及想评估经典蒙特卡洛方法在围棋上还有多少性价比的从业者。先给一个反直觉结论比赛里稳定发挥、永远在时限内落子、不犯错比局部棋力更重要。亚军和十几名的差距往往不是一两条搜索技巧而是二十盘里少崩两三盘。2. 围棋博弈程序的基本盘棋盘、搜索与评估三件套2.1 为什么比赛里的亚军不靠深度神经网络拆开这类代码包最先看到的通常是几个核心文件棋盘表示、蒙特卡洛树搜索、评估函数、时间控制。整个程序就是一个循环接收对方落子更新棋盘计算自己的候选着法用搜索树分配模拟次数最后在时间截止前返回一步棋。这个结构十年没变过比赛名次比的也不是谁的网络更炫而是谁在同样的时间里把搜索树利用得更充分。为什么亚军不靠深度神经网络因为大赛环境里多数队伍没有稳定的 GPU 算力训练数据也凑不齐。围棋的状态空间决定了纯枚举无效但比赛只要在有限时间内做出合理决策就行。MCST 加一个不差的评估函数跑满时间片棋力就能稳定在业余中段附近。对大多数参赛队来说这已经足够进决赛。神经网络方案在训练阶段投入过多一旦自对弈数据量不够比赛时反而出现开局崩盘、时间不够用的问题。这一章讲三件套棋盘模块负责回答“这一步合不合法、提不提子、是不是劫”搜索模块负责分配模拟资源评估模块负责给局面打分。三者解耦之后你可以单独替换任何一个不影响另外两个。比赛代码的维护成本大部分来自这个边界划分。2.2 棋盘与合法着法生成一眼能看懂却是性能第一道坎棋盘模块看起来简单实际上最容易被低估。围棋的落子规则包括空点才能落子、落子后要提掉对方无气棋串、不能自杀、不能立刻提回劫。任何一条没处理好搜索树里就会出现非法状态程序会在中盘走出“灵异棋”。实现时先把棋盘做成不可变快照每走一步返回一个新棋盘这样搜索树里的每个节点可以安全持有自己的局面。下面这段代码是围棋提子和自杀判定的最小核心参团队伍里一般会先把它跑通再往上搭搜索N 19 EMPTY, BLACK, WHITE 0, 1, 2 DIRS [(1, 0), (-1, 0), (0, 1), (0, -1)] def neighbors(x, y): 返回棋盘上 (x, y) 的四个相邻点越界的过滤掉。 return [(x dx, y dy) for dx, dy in DIRS if 0 x dx N and 0 y dy N] def group_and_liberties(board, x, y): 返回 (x, y) 所在棋串的成员列表和该棋串的气集合。 color board[x][y] stack [(x, y)] group [] visited set() libs set() while stack: gx, gy stack.pop() if (gx, gy) in visited: continue visited.add((gx, gy)) group.append((gx, gy)) for nx, ny in neighbors(gx, gy): if board[nx][ny] EMPTY: libs.add((nx, ny)) elif board[nx][ny] color and (nx, ny) not in visited: stack.append((nx, ny)) return group, libs def apply_move(board, x, y, player): 落子并提子返回新棋盘和提子数非法落子返回 None。 if board[x][y] ! EMPTY: return None nb [row[:] for row in board] nb[x][y] player opp WHITE if player BLACK else BLACK removed 0 for nx, ny in neighbors(x, y): if nb[nx][ny] opp: g, libs group_and_liberties(nb, nx, ny) if not libs: for gx, gy in g: nb[gx][gy] EMPTY removed 1 g, libs group_and_liberties(nb, x, y) if not libs: return None # 自杀禁手 return nb, removed这段代码的关键点在于提子顺序先提对方再检查自己。很多实现把顺序写反结果自己落子后气被对方堵死却先被判成自杀。比赛现场最怕这种底层错误搜索层越强错误被放大得越严重。group_and_liberties 里用栈做棋串遍历避免递归深度过大导致 Python 栈溢出。性能上19 路棋盘每次落子最多影响一个棋串最坏情况也就遍历全盘常数很小搜几千次模拟完全够用。如果后续要提速常见做法是把棋盘压成两个 64 位整数黑子一个位棋盘、白子一个位棋盘用位运算算气。但比赛阶段不建议一上来就做位棋盘调试成本高先把功能跑对更重要。只要保证 apply_move 是纯函数不修改外部状态后面接搜索树和并发都很方便。2.3 UCT 搜索把“棋感”变成可计算的置信上界搜索模块是整套代码的心脏。围棋的分支因子很高19 路棋盘一着手平均有两三百个合法点穷举不可行。蒙特卡洛树搜索的思路是从根节点出发反复做“选择-扩展-模拟-回传”让搜索资源自动流向胜率更高的分支。选子用的公式是 UCT它在“利用已知高胜率分支”和“探索未被充分评估的分支”之间做平衡。UCT 公式写出来很简单[ UCT \frac{wins}{visits} C \times \sqrt{\frac{\ln(parent_visits)}{visits}} ]前一项是节点胜率后一项是探索项。C 越大越倾向探索模拟次数少的分支C 越小越倾向走已经验证过的棋。经典围棋实现里 C 常见取值在 0.4 到 1.2 之间具体要配合评估函数和模拟次数一起调。有的代码包把胜率换成“白方视角的得分”本质一样只是正负号要对齐。实现选子逻辑时每个节点只需要存四样东西对应的棋盘快照、子节点列表、累计访问次数、累计胜利次数。搜索从根开始往下走每层选 UCT 最大的子节点直到叶子节点然后做一次随机模拟再把结果沿原路回传。这段逻辑是通用的和五子棋、国际象棋没有本质区别换游戏只需要换 apply_move 和评估函数。3. 从零跑通一个最小可用的围棋 UCT核心代码与运行顺序3.1 数据结构与落子模块封装第 2 章的 apply_move 是裸函数真正比赛程序会把它包成类方便搜索树调用。我一般会把棋盘和搜索拆成两个文件go_board.py 只管规则uct.py 只管搜索。这样自对弈脚本、开局库生成器、比赛主程序都能复用同一套棋盘逻辑避免各写一份导致规则不一致。# go_board.py import copy from board_core import apply_move, N, EMPTY, BLACK, WHITE class GoBoard: def __init__(self, sizeN): self.size size self.board [[EMPTY] * size for _ in range(size)] self.turn BLACK # 黑先 def legal_moves(self): 返回所有合法落子点空点且不自杀、不违反规则。 moves [] for i in range(self.size): for j in range(self.size): if self.board[i][j] EMPTY: res apply_move(self.board, i, j, self.turn) if res is not None: moves.append((i, j)) return moves def move(self, x, y): 执行落子并切换行棋方返回新棋盘对象。 res apply_move(self.board, x, y, self.turn) if res is None: return None new_board, removed res nb GoBoard(self.size) nb.board new_board nb.turn WHITE if self.turn BLACK else BLACK return nb def pass_move(self): 模拟一手停棋用于终局或跳过。 nb GoBoard(self.size) nb.board copy.deepcopy(self.board) nb.turn WHITE if self.turn BLACK else BLACK return nb这里需要注意 move 返回的是一个新对象不是原地修改。搜索树每次扩展都会生成新棋盘看起来浪费内存实际上每盘模拟只保留路径上的节点GC 能及时回收。如果在 move 里直接改 board那么多个搜索分支就会互相污染棋力会断崖式下降。pass_move 是必须的实战中一方无棋可走时不能报错要能停一手。比赛规则里双方连续停两手就终局这个逻辑要在主循环里处理。3.2 评估函数不追求棋力追求稳定评估函数决定了“模拟”的质量。最简单的评估是随机下到终局然后数子但随机模拟一盘 19 路围棋要几百手速度太慢。比赛里常用的折中方案是不模拟到终局只模拟固定步数比如 20 到 40 手然后用一个静态函数打分。这个静态函数不需要很准但要稳定不能剧烈震荡。# evaluate.py def evaluate(board, turn): 极简局面评估子数差 简单眼位奖励。 返回正值表示黑方优势负值表示白方优势。 score 0 black_stones 0 white_stones 0 for i in range(len(board)): for j in range(len(board)): if board[i][j] BLACK: black_stones 1 elif board[i][j] WHITE: white_stones 1 score black_stones - white_stones # 对已形成的眼位给少量奖励减少随机棋乱填眼 for i in range(len(board)): for j in range(len(board)): if board[i][j] EMPTY: around 0 for nx, ny in [(i-1,j),(i1,j),(i,j-1),(i,j1)]: if 0 nx len(board) and 0 ny len(board) and board[nx][ny] ! EMPTY: around 1 if around 4: # 近似眼位黑方视野多给 0.5 分 score 0.5 if turn BLACK else -0.5 return score这个评估函数有很多问题它分不清活棋死棋对劫材没有感知也看不到外势。但比赛时它的优点是快且无波动每次调用不依赖随机数这让 UCT 的胜率统计方差变小。搜索树里大量节点共享一个静态评分评估一致性比准确性还重要。如果评估函数本身带随机性同样的局面两次打分差很多UCT 就会误判。有队伍尝试用更复杂的评估计算每块棋的气数、判断是否面临被吃、对中央和边线给不同权重。这些都能提升棋力但每多一个特征就多一组参数要调。新手入门不要一上来就堆特征先把三行代码的静态评估接进搜索跑通后再逐步加。加一个特征就跑一轮自对弈对比这是控制复杂度的唯一办法。3.3 主循环与比赛时间控制搜索层的核心函数是 uct_search它负责在给定的时间窗口内尽量多跑模拟然后返回胜率最高的落子。比赛时时间控制放在这一层而不是放在评估函数里。每一手棋开始前记录当前时间设置 deadline搜索循环内每模拟一次检查一次时间超时就立刻截断。# uct.py import math import time import random class Node: def __init__(self, board, parentNone, moveNone): self.board board self.parent parent self.move move self.children [] self.visits 0 self.wins 0 def is_terminal(self): # 双方连续 pass 才终局这里简化成空点很少时结束 empty sum(row.count(EMPTY) for row in self.board) return empty 2 def select_child(node, c0.8): 从子节点中选出 UCT 值最大的一个。 total_visits sum(ch.visits for ch in node.children) return max( node.children, keylambda ch: ch.wins / ch.visits c * math.sqrt(math.log(total_visits) / ch.visits) ) def expand(node): 给叶子节点生成合法子节点。 for x, y in node.board.legal_moves(): nb node.board.move(x, y) if nb is not None: child Node(nb, parentnode, move(x, y)) node.children.append(child) # 也允许停一手 nb node.board.pass_move() node.children.append(Node(nb, parentnode, moveNone)) return node def simulate(node): 从节点局面开始做固定步数随机走子返回评估值。 board node.board for _ in range(20): moves board.legal_moves() if not moves: break x, y random.choice(moves) nb board.move(x, y) if nb is None: break board nb return evaluate(board, board.turn) def back_propagate(node, result): while node is not None: node.visits 1 if result 0: node.wins 1 node node.parent def uct_search(root_board, time_limit5.0, c0.8): root Node(root_board) deadline time.time() time_limit simulations 0 while time.time() deadline: leaf root path [] while leaf.children: leaf select_child(leaf, c) path.append(leaf) if leaf.visits 0: expand(leaf) if not leaf.children: break result simulate(leaf) back_propagate(leaf, result) simulations 1 if not root.children: return None best max(root.children, keylambda ch: ch.visits) return best.move这段代码是整套程序的骨架。逻辑顺序看明白先创建根节点然后循环选择-扩展-模拟-回传。select_child 里 total_visits 取父节点的子节点访问总和如果某个子节点 visits 为 0公式里的除零会让它获得无限大的探索值所以每个合法子节点至少会被访问一次。simulate 只跑 20 步随机棋这是为了控制单次模拟耗时20 步在 19 路棋盘上约等于局部接触战足够暴露吃子与被吃的大致趋势。time_limit 是比赛时间控制的关键参数。比赛规则常见每方 30 分钟包干但不用每手都花满 30 秒。我习惯在中盘给 8 秒开局和收官给 3 秒。这样一组时间分配写在主程序里根据当前手数切换。uct_search 返回 None 表示无棋可走主程序此时应该调用 pass_move而不是报错。还有一个细节搜索结束选择子节点时我按 visits 而不是 wins 来选因为 wins 高的分支可能只被访问过几次统计意义不够visits 最高代表搜索资源分配最多更可靠。4. 调参决定名次四组参数与它们的失败边界4.1 UCT 常数 C从 0.3 到 1.5 的变化UCT 常数 C 是整套代码里最出名的玄学参数。它控制探索项权重调小了程序只走眼前高胜率分支容易被对手的冷招带偏调大了程序什么棋都试中盘会把大量时间浪费在明显不行的大场。经验区间如下C 取值表现典型问题0.3局势判断偏实利棋风很贪对陌生布局应变差容易中埋伏0.8攻守平衡多数队伍落在这个区间没有明显短板但也不出彩1.5探索多中盘常走出“灵机一动”优势局面下可能主动送掉大龙我一般从 0.8 开始然后固定其他参数让两个版本自对弈 50 盘。如果 0.8 版本胜率稳定高于 55%说明探索还不够往 1.0 方向调如果一直在 45% 以下说明探索过度往 0.6 方向调。C 的变化对棋力影响不是线性的经常从 1.0 调到 1.05 看不出差别但 1.0 到 1.4 会有质变。记录每组 C 值对应的胜率比靠眼睛看棋谱靠谱得多。4.2 模拟次数与时间片大赛环境下按时间分配模拟次数决定搜索深度但比赛是限时制真正要调的不是“每步模拟多少次”而是“每步给多少秒”。模拟一次的平均耗时由棋盘模块和评估函数决定代码优化后再看次数。常见错误是写死一个常量比如每步模拟 5000 次导致开局 3 秒下完、中盘 12 秒才落子整盘棋节奏失衡。比赛程序里我会这样切时间段前 30 手每步 5 秒中盘 60 手每步 10 秒收官阶段每步 4 秒。时间分配写在主循环里用一个接口查询当前手数再决定调用 uct_search 的 time_limit。还要给整盘棋设一个总时间预算比如 30 分钟包干程序记录已用时间剩余时间不足 2 分钟时所有手数统一降为 2 秒防止超时判负。模拟次数和棋力的关系是边际递减的从 1000 次涨到 3000 次棋力提升明显从 8000 次涨到 10000 次胜率可能只涨 1%。所以一旦单步时间超过 10 秒应该去优化评估函数和棋盘模块而不是干等搜索多跑几千次。比赛现场机器性能未知程序要自带一个基准测试开局前跑 20 次模拟测出当前 CPU 速度动态调整每步时间。4.3 并行线程与树复用有团队试图用多线程加速搜索把 UCT 搜索拆成多个线程共享同一棵搜索树。这个思路说起来简单做起来容易翻车多个线程同时修改节点统计数要么加锁要么用原子操作。锁粒度大了线程都在抢锁加速比不到 1.2锁粒度小了代码复杂到没人敢改。大多数亚军代码其实是单线程靠的是高效的棋盘实现和紧凑的节点存储。比并行更划算的是树复用。对手落子后不要销毁整棵搜索树而是从根节点往下找到对手落子对应的子节点把它作为新一轮搜索的根。这样上一轮搜索积累的统计信息全部保住了相当于开局就有一棵半热树。实现时要保证每个节点保存的是落子后的完整局面才能安全跳转。这个技巧通常能带来 20% 到 40% 的棋力提升代码量不大优先级高于多线程。4.4 开局库胜负手围棋开局变化多纯搜索会在前 30 手浪费大量时间在常见定式上。比赛队伍几乎都会带开局库常见做法是把前 10 到 20 手的常见变化离线生成好存成一张表比赛时命中了直接落子不进入搜索。开局库的生成用自对弈自动积累让程序自己跟自己下把胜率高的开局序列记录下来后续再碰到同样局面就优先走库里胜率最高的那手。开局库不用很大几十个常见着法组合就够。重点是要防止库里有“假胜率”如果开局库是在低模拟次数下生成的里面很多高胜率分支其实站不住脚。比赛前要单独跑一轮高模拟次数的验证把胜率低于 60% 的开局分支清掉。有些队伍在开局库里还存了“惩罚分”专门针对对手的冷门开局这个属于进阶玩法新手先把基础库跑通。5. 博弈大赛踩坑排查五个血泪现场与修复方法5.1 超时判负搜索循环里混入了 IO 操作现象程序在自己机器上每步稳定 3 秒落子换到比赛机器后经常超时甚至在中盘连续超时两次直接被判负。排查时发现搜索循环里某个分支写了日志每模拟一次就写一行文件开发环境是 SSD 看不出来比赛机器上磁盘 IO 很慢日志成了性能黑洞。原因搜索主循环里有 file.write、print 到控制台、网络请求这类 IO 操作。IO 的耗时比内存操作高几个数量级哪怕其中只有 1% 的概率触发也会把单步时间拖长。解决把所有日志全部移到搜索循环外只在每步结束时统一输出一行。print 也要谨慎控制台输出在 Windows 下尤其慢。标准做法是程序带一个 verbose 开关调试开、比赛关。这个坑几乎每年都有队伍踩检查一遍代码里搜索路径上所有外部调用比调任何参数都重要。5.2 劫争死循环搜索树把合法棋算成了循环现象中盘出现劫争时程序在同一个位置来回提子导致局面反复被裁判判定为重复局面无效甚至直接判负。复盘时发现搜索树确实知道“不能立刻提回劫”但树的根节点换到对手视角后劫状态没有一起切换。原因棋盘模块用独立状态记录劫但在 move 返回新棋盘时劫状态没有复制到新对象。第一层搜索避开劫但搜索树更深层换了子节点后劫标记错乱程序认为自己可以立刻提回。解决把劫状态作为棋盘的一部分每次 apply_move 都同步更新。具体规则是如果白棋刚提了黑棋一子且提子后棋盘只剩这一处劫争那么黑棋下一步不能在这个位置落子这个禁着点要存到棋盘状态里。实现时给 GoBoard 增加一个 ko 属性move 时根据提子情况设置或清空。自对弈脚本里专门加一个劫争联调用例连续下三十手确认不重复再放回比赛主程序。5.3 叶子评估把死子当活子现象程序在一条大龙被包围时仍然认为自己是优势搜索给出的落子点全是逃窜但最后大龙还是死了。逐局核对发现评估函数只统计棋子数量不看每块棋的气和眼位一只还剩两气的死棋被当成两分实空计入总分。原因评估函数对棋串死活没有建模纯子数差在局部博弈中严重失真。搜到中盘以后死子对胜率的影响远大于外势评估偏差会直接导引搜索往错误方向分配资源。解决先做一步廉价改进评估函数里对每块棋计算气数如果一块棋只剩一口气给它打一个负分惩罚剩两口气减半。这不需要完整判别死活只惩罚“明显快死的棋”。第二种方案是模拟到更接近终局比如从 20 步加到 50 步让吃子事件更大概率发生。两种方案都有代价第一种加特征第二种加时间。比赛里我一般先用第二种简单直接效果肉眼可见。5.4 随机模拟质量太低开局就崩现象程序前 30 手经常下出明显是废棋的着法比如在自己眼里填子、往对方重兵区送子导致开局胜率一路下跌。但中盘搜索的棋力又正常说明问题不在搜索而在模拟环节。原因simulate 函数用完全均匀随机选点没有做任何启发式过滤。围棋里大量空点明显不值得走随机模拟会频繁采样到差到离谱的着法让评估结果偏向混乱。解决给随机选点加一个很轻的启发式优先选周围 8 格内有己方棋子的点其次是靠近棋盘边星位的点完全空旷的中心区域降低采样概率。这个过滤不用很精细只是把明显不合理的点排除掉就能显著提升模拟质量。还有一个常见做法是限制随机模拟不能连续在同一个 3×3 区域落子超过三次减少“原地打转”的废棋。5.5 调参后棋力倒退没有对照胜率现象调了一个参数觉得局部棋力明显增强比如 UCT 常数从 0.8 改成 1.2观望了几盘觉得中盘更敢走但整场比赛战绩反而下滑输给了旧版本轻松拿下的对手。原因单盘对局有巨大随机性肉眼观察的“更强”往往只是运气好。围棋博弈代码是一个强耦合系统改一个参数可能提升局部但同时破坏全局时间分配或探索节奏。没有量化对照所有判断都是主观记忆。解决建立自对弈回归脚本每改一个参数就固定随机种子让新版本和旧版本对战 20 盘统计新版本胜率。胜率大于 60% 才保留改动低于 45% 直接回滚。这个脚本要能在无人值守时跑完输出胜率、平均步时、超时次数三项指标。养成这个习惯之后调参基本不会越调越差比赛前一周也不会因为过度调参而自乱阵脚。6. 把亚军代码往前再推一步自对弈与胜率验证自对弈脚本不是附加功能它是整个调参体系的验证工具。很多队伍在比赛前一周疯狂改代码越改越慌就是因为没有一套快速反馈的对比机制。一个可用的自对弈框架至少要支持两个引擎实例互相下棋自动交换黑白记录胜负和步时最后汇总胜率。下面是最小可用的对弈循环# selftest.py import random from uct import uct_search def play_game(player_a, player_b, rounds30): wins {A: 0, B: 0} for g in range(rounds): random.seed(g) if g % 2 0: black, white player_a, player_b else: black, white player_b, player_a board GoBoard() result None while result is None: move black if board.turn BLACK else white chosen move(board) if chosen is None: board board.pass_move() else: board board.move(*chosen) # 简化终局双方都停手或棋盘接近填满 winner A if g % 2 0 else B wins[winner] 1 return wins[A], wins[B]这个循环把参数对比变成了简单的数字游戏。我现在的习惯是任何改动先跑 30 局如果新版本不能在 30 局里赢到 18 局以上就直接回滚。跑完还要看平均步时防止某个改动让单步时间悄悄变长影响比赛节奏。这个脚本在最后一周每天都跑每次改动留下一个对局记录文件方便回看哪些参数组合效果最好。验证通过之后再往前推一步就是 PUCT。UCB 公式里的探索项换成策略先验概率本质上是让开局阶段优先访问策略认为合理的着法。这个升级不需要神经网络用简单特征表就能给定先验概率棋力提升比盲目堆模拟次数更快。另一个方向是生成更高质量的开局库用自对弈积累高胜率开局序列比赛时减少前 20 手的搜索压力。最后说一句血泪经验比赛现场永远不要跑一个没验证过的新参数。把冠亚军和十几名的对局录像放在一起看差距从来不是某一步棋的强弱而是整盘棋的稳定性。我见过太多队伍在决赛前夜调出一个“感觉很强”的版本第二天开局手抖超时十几手后又走出搜索树里的非法着法。把这些翻车现场理清楚代码自然能往前再走一步。希望帮到你。本文还有配套的精品资源点击获取
网站建设高端定制企业官网