ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

PlantomGo围棋博弈代码解析:蒙特卡洛树搜索与工程实现

PlantomGo围棋博弈代码解析:蒙特卡洛树搜索与工程实现 简介计算机博弈大赛亚军项目“幻影围棋”的完整源码与配套资料主题涉及围棋AI、蒙特卡洛树搜索及对抗博弈算法适合准备参与博弈竞赛、复现棋类AI或希望从零理解MCTS工程实现的开发者与学习者。压缩包共42个文件以C源代码cpp/h、Windows可执行程序exe、编译中间产物obj/pdb和两份概要设计文档为核心可看出从源码、构建配置、中间文件到最终发布程序的标准工程结构整体仅2.22MB轻量易下载。目前已有1385人学习浏览。透过源码可重点研究MCTS在围棋局面下如何选择落子、评估函数如何权衡领地和棋子死活以及“幻影”机制带来的特殊策略配套文档则有助于理解整体模块划分和设计思路无论用于大赛复盘、课程设计还是算法研究都是很有参考价值的实战案例。1. PlantomGo.rar 是什么为什么一份大赛亚军的围棋博弈代码值得你拆开看拿到 PlantomGo.rar 这个压缩包的人多半是被「博弈大赛亚军」和「幻影棋」两个关键词吸引。它是计算机博弈大赛中围棋项目的参赛代码通常包含完整的对弈主程序、蒙特卡洛树搜索实现、评估函数、落子策略和比赛日志。你把它解压后能直接编译出一个会下围棋的程序也能拿来当课设、毕设或下一次比赛的起点。适合谁正在做计算机博弈题、想找一个跳板开发的开发者以及想搞明白「亚军程序到底赢在哪」的棋力爱好者。这份代码的价值不是跑通一盘棋而是让新手绕开从零写引擎的大坑让熟手看清一套可复现的调参路径。2. 先搞懂计算机博弈里围棋的牌局为什么幻影棋押注蒙特卡洛树搜索计算机博弈大赛的项目五花八门象棋里有成熟的 Alpha-Beta 剪枝五子棋有精确的威胁空间搜索但围棋在很长一段时间里是公认的「断头路」。棋盘大、落子点多、棋形千变万化最致命的是没有一个像棋子分值那样可以量化局面的评估函数。幻影棋这种能在赛场上拿名次的程序核心几乎都押在了蒙特卡洛树搜索MCTS上。它不靠算尽所有变化而是靠大量随机模拟把「哪个落子胜率更高」从统计里拎出来。2.1 围棋博弈的搜索空间与评估困境围棋用19路棋盘一共361个交叉点。一盘棋平均要下200多手每一步的合法落子少则几十、多则上百。想用穷举法把整棵博弈树走完状态数比可观测宇宙里的原子还多好几个数量级。传统搜索算法在这里集体失效Alpha-Beta 剪枝需要评估函数来排序着法可围棋里一块棋的死活、外势的厚薄、劫材的多少连业余棋手都要凭感觉判断写死规则只会得到一堆自相矛盾的权值。所以早期围棋程序普遍棋力很差打到业余5级已经是天花板。后来大家发现一条反直觉的路不去精确评估局面而是让两个「随机棋手」从当前局面一路乱下到终局统计哪边赢的次数多。只要模拟次数足够多胜率就能逼近真实优劣。这就是蒙特卡洛方法在围棋里的起源。幻影棋这种大赛代码底层逻辑正是这条路不追求每一步都算得明白只追求统计上不亏。2.2 蒙特卡洛树搜索的四个阶段与围棋适配蒙特卡洛树搜索不是简单的随机模拟它把整局棋组织成一棵树反复生长。树的每个节点是一个盘面根节点是当前局面。每轮模拟分成四步选择从根节点出发按照 UCT 公式逐个挑选孩子节点一路走到某个还未完全展开的节点。 扩展在这个节点上生成一个合法落子长出一个新节点。 模拟从新节点往下用一套快速随机策略猛下到终局得到胜负结果。 回溯把胜负结果从新节点逐层传回根节点路上每个节点的访问次数加一、胜利次数按结果累加。这套循环跑上几千甚至几万次之后根节点下哪些孩子被访问得多哪些胜率高就一目了然。代码结构上每个节点只需要记四样当前盘面、父节点指针、孩子节点列表、访问次数和累计胜率。在比赛引擎里通常还会有哈希表用来快速判定劫争和重复局面否则模拟阶段会撞进死循环。2.3 从随机对弈到 UCT 公式亚军代码里最核心的一行选择逻辑U 是 UCB1Upper Confidence Bound的变体C 是探索常数T 是总访问次数。公式的核心思想是胜率高的孩子值得多走但访问次数少的孩子也需要被照顾防止一个分支还没被探索就被判死刑。在大赛代码里这一行选择逻辑直接决定了棋风探索常数设大了程序会满天乱飞乱点设小了它又会死抱住局部变化不放经常错过更优的外势着法。我一般会把它封装成一个纯函数方便单独调优int selectChild(Node* node) { double bestValue -1e9; int bestIdx -1; int totalVisits node-visits; for (int i 0; i node-childCount; i) { Node* child node-children[i]; if (child-visits 0) { return i; // 没访问过的孩子直接选中 } double winRate child-wins / child-visits; double uct winRate sqrt(log(totalVisits) / child-visits); if (uct bestValue) { bestValue uct; bestIdx i; } } return bestIdx; }代码里log(totalVisits) / child-visits是探索项。访问次数少的孩子会拿到偏高的 uct 值被优先尝试访问多了之后胜率项开始主导搜索重心自然转移。注意第一行if (child-visits 0) return i;这种写法会让所有未访问的孩子按顺序被点一遍实际效果不错但也可能让算法忽略掉初始顺序背后的棋理。更讲究的版本会把winRate附近加上一个很小的平滑系数避免除零和方差震荡。UCT 公式的系数在代码里常被写作一个可配置参数c。默认 1.0 是常见起点但大赛亚军程序往往会把它调到 0.8 到 1.5 之间。系数小了程序更「贪心」局部战斗强系数大了程序更「好奇」布局阶段不容易漏掉大场。怎么调没有理论能告诉你只能靠自对弈批量试。3. 把 PlantomGo.rar 跑起来解压、编译与最小对弈命令压缩包拿到手第一步不是读代码而是把它跑起来。只有看到棋盘能走子、程序会回手你才有资格谈改算法。先别管引擎内部写得多玄学工程上把它从.rar变成一个会下棋的可执行文件你只需要盯住三个东西目录里的入口文件、编译器标准、对弈协议。3.1 压缩包内文件布局与代码入口这类比赛代码的典型布局通常长这样PlantomGo/ ├── src/ │ ├── main.cpp │ ├── mcts.cpp │ ├── board.cpp │ └── eval.cpp ├── include/ │ ├── mcts.h │ ├── board.h │ └── eval.h ├── Makefile └── README.mdsrc/main.cpp是对弈主循环负责读命令行和协议指令mcts.cpp是蒙特卡洛树搜索本体board.cpp管棋盘和落子合法性eval.cpp是静态评估函数。如果你的压缩包没有 Makefile别慌我见过好几个版本都只带.vcxproj到 Linux 上基本用不上。先看 README再看主文件头几行的注释里面通常会写清楚编译方式和参数列表。3.2 编译与运行一个本地对弈的最小命令在 Linux 下我一般这样解压和编译:mkdir -p /tmp/phantom cd /tmp/phantom unrar x PlantomGo.rar cd PlantomGo make clean makeunrar可能需要单独安装如果没有就用7z x PlantomGo.rar。编译产物是一个phantomgo可执行文件。如果 Makefile 缺失或者平台变了直接用 g 手动编译也行g -O2 -stdc11 src/*.cpp -o phantomgo -I include参数说明-O2是优化开关让蒙特卡洛模拟跑得更快-stdc11是为老代码准备的兼容标准。遇到round不是std的成员、random_device找不到头文件这类报错多半是标准没写对。加上-lm链接数学库能解决大部分奇怪的编译错误。编译完成后先跑一个自对弈验证./phantomgo --selfplay --games 2 --maxmoves 200这条命令的意思是程序自己跟自己下两盘每盘最多 200 手。--selfplay不是每个版本都有如果启动参数列表里没有就去代码里找--play、--gtp或--version。硬编码参数的程序通常会在 main 函数里用argv[1]判断直接翻代码比看 README 更可靠。3.3 让两个程序对弈命令行参数与输出协议计算机博弈比赛现场有裁判系统但你在本地验证引擎改没改坏最方便的方式是走 GTPGo Text Protocol。GTP 是围棋程序的公共语言基于标准输入输出一行一条命令。最小指令集只需要三个boardsize 19 play B D4 genmove Wboardsize设置棋盘大小play告诉程序对方落了某子genmove让程序生成己方落子。判断你的程序支持不支持 GTP启动后输入probe如果返回以开头说明它认识这条协议。如果是空行或乱码说明它只支持内部自定义参数这时候你得看源码里的输入解析函数把比赛要用的落子命令格式找出来。我日常调试会先检查坐标转换。GTP 里D4是左上角为 A1 的坐标程序内部很可能用 0 到 18 的行列号。两个系统差一差就会下错子。在play指令入口加一行打印把 GTP 坐标换算成内部坐标再打印一遍比黑盒测试快得多。下面是个常见的转换逻辑int gtpToPoint(const std::string move) { int file move[0] - A (move[0] I ? -1 : 0); int rank std::stoi(move.substr(1)) - 1; return rank * 19 file; }参数说明file计算里move[0] - A之后要跳过 I因为英国规则里没有 I 列。rank从 1 开始减一后才对齐内部数组。这种细节最容易翻车后面避坑章节还会展开。4. 胜负手在哪评估函数、模拟策略与时间分配一个能拿比赛亚军的围棋程序通常不是靠某一个炫技模块而是靠三个部件的咬合模拟阶段的快速落子策略、终局或评估阶段的静态打分、以及控制整个搜索节奏的时间分配。这三个部件每个都有参数参数调得好不好直接决定程序是从「会下」变成「能赢」。4.1 快速落子策略如何让模拟对弈又快又像人MCTS 的模拟阶段不要求高棋力但必须快。完全均匀随机下棋终局胜率噪声太大需要额外增加模拟次数才能收敛。比赛代码几乎都会在模拟阶段加一套快速落子策略让随机棋手「像人一点」优先下在己方气多、能连片、能围空的位置禁止下眼位和自杀点。下面是一个简化的策略实现核心思想是对每个合法落子点打分然后用加权随机选一个int fastMove(Board board) { std::vectorint candidates; for (int x 0; x 19; x) { for (int y 0; y 19; y) { int p x * 19 y; if (!board.isLegal(p)) continue; if (board.isEye(p)) continue; int score 0; int lib board.liberties(p); score std::min(lib, 3); // 气多的地方优先 if (board.capturesAfter(p) 0) score 10; if (board.isSelfAtari(p)) score - 8; candidates.push_back(p * 100 score); // 用随机权重凑出采样 } } int idx rand() % candidates.size(); return candidates[idx] / 100; }参数说明气数lib最多取 3因为围棋里超过三气的棋子在模拟阶段被提掉的风险已经足够低。capturesAfter的奖励设为 10是为了让模拟棋手会提子。isSelfAtari的罚分是 8避免把棋子送进只有一口气的绝地。这些数字都是经验值调太高会让模拟棋手变得过于贪婪调太低又起不到筛选作用。这个函数跑一次要遍历 361 个点在模拟循环里会被调用成千上万次所以性能很重要。常见的优化是只遍历最近变化位置周边的点或者用预计算的neighbor数组减少重复访问。如果这段代码成了性能瓶颈你会发现自对弈一盘要跑好几分钟那时候优先优化它而不是盲目加线程。4.2 评估函数怎么给棋盘打分领地、眼位与气的量化除了随机模拟很多比赛程序还会在搜索末尾加一个静态评估函数。它的用途不是决定每一步怎么走而是给「半目胜负」这种局面判断大致方向。一个能用的评估函数只做三件事算领地归属、确认活棋、扣掉贴目。领地归属用辐射法从每个空点出发向四个方向扫描遇到黑子就记黑方的潜在地盘遇到白子就记白方。活棋确认是判断一块棋的气是否足够多气多且没有被打吃风险就记为活棋。贴目是黑棋给白棋的补偿正式规则里一般是 6.5 目。double evaluate(const Board board) { int blackOwner 0, whiteOwner 0; for (int y 0; y 19; y) { for (int x 0; x 19; x) { int p x * 19 y; if (board.isStone(p)) continue; int near nearestStone(board, p, 3); // 辐射半径 3 if (near 0) blackOwner; else if (near 0) whiteOwner; } } double score blackOwner - whiteOwner - 6.5; return score; }参数说明nearestStone的辐射半径设为 3意味着离任意棋子超过三格的点不记入任何一方地盘从统计上抹平边界噪声。6.5的贴目值按正式规则来但如果你拿它跟别的引擎对弈最好先确认对方用的是不是同一套规则很多开源引擎默认贴目是 7.5 甚至 0。评估函数最大的坑是把「劫」算成活棋。一块棋如果只有一个眼打劫时会被提掉评估函数必须把劫争中的薄弱棋块扣分。我见过一个比赛程序因为在评估里没处理劫官子阶段频繁误判输掉不少半目胜负。加一个isInAtari检测对只有一口气的棋块直接判负能把这类 bug 压下去。4.3 时间控制与搜索深度大赛亚军的参数是怎么试出来的对弈引擎最容易被忽视的不是搜索算法而是时间分配。本地测试时你让它随便想多久都行一站上比赛每步都有时限超时直接判负。很多复现者的程序跑到中盘突然崩盘线程全卡死原因就是忘了给当前那一手设定思考预算。我习惯把思考循环写成这样void think(Board board, double timeLimitSeconds) { auto start std::chrono::steady_clock::now(); int iterations 0; while (elapsedSeconds(start) timeLimitSeconds iterations 20000) { runOneSimulation(board); iterations; } }参数说明timeLimitSeconds是这一步的预算通常由命令行传入比如每步 5 秒。iterations 20000是兜底防止在规则不明的局面里死循环。比赛程序通常还会根据当前手数动态调整预算开局和中盘给 70% 的时间官子阶段给 30%因为官子选择少不需要那么多搜索。调参比写算法更磨人。我的经验是先把参数全部暴露成--开头的命令行选项然后写一个批量对弈脚本让不同参数组合的比赛一次跑完。参数对比表值得单独维护一张参数试过的值自对弈胜率变化结论UCT探索常数 c0.8 / 1.0 / 1.21.0 对 1.2 胜率 52%用 1.0模拟惩罚 selfAtari8 / 12 / 208 反而高 5%别调太高每步时间分配固定 / 动态动态总用时少 15%用动态这张表是我自己复盘时用的模板参数名不保证跟你手里的代码完全一致但思路是对的。一个参数一个参数的动比一把梭哈改成百战百胜靠谱得多。5. 复现 PlantomGo 的避坑指南五个让新手翻车的具体问题代码工程里最磨人的从来不是算法推导而是各种玄学浮层。想跑通 PlantomGo 拿到可复现的结果下面五个坑我基本每次换环境都会踩一遍写出来供你对照。5.1 现象编译报错找不到头文件现象fatal error: algorithm file not found或者一整屏error: round is not a member of std。原因代码里用了 C11 甚至更早的库函数而你的编译命令默认用了 C03 标准。比赛代码通常是在老编译器环境下写的换到现代 GCC/Clang 上标准没对齐就会出现这类报错。解决给编译命令加上-stdc11保险起见再补一个-D_USE_MATH_DEFINES。如果报错集中在数学函数检查有没有#include cmath。我遇到过代码里直接用sqrt、log但不包含头文件的版本属于典型的“本地能编换台机器就炸”。5.2 现象程序一直下一步就停现象程序生成了着法但对局不推进或者裁判判断连续违规落子。原因GTP 坐标没对齐。程序内部用(x, y)且从 0 开始GTP 要求A1从左上角开始并且列号跳过 I。两套坐标差一两个身位前面几步还能走走到角落就必然出界。解决找到坐标转换函数统一用file - A再跳过 Irank减一后做行列反转。我在调试时会在play指令入口打印一次转换结果确认D4落到(3,15)而不是(3,3)。对完一个点整盘棋的坐标体系就稳了。注意有的弱化版本用std::stoi解析rank对A1、J1这种带前缀的字符串解析时会抛异常建议全用字符解析。5.3 现象模拟命中锈死状态现象程序下一步棋直接输掉或者进入“提劫-回提-再提劫”的无限循环。原因没有实现打劫规则。围棋禁止全局同形再现而蒙特卡洛模拟里如果忽略劫某一方会在劫争里无限回提整棵搜索树被一个无效分支污染。解决在isLegal里增加重复局面判断。常见做法是维护一个哈希表把最近几十步的盘面哈希存下来落子后如果发现哈希重复直接判非法。代码层面大概是bool isLegal(Board board, int p) { if (!board.isEmpty(p)) return false; if (board.isSuicide(p)) return false; uint64_t hash board.hashAfterMove(p); if (board.recentHistory.count(hash)) return false; return true; }参数说明recentHistory只存最近一手到之前 30 手左右的哈希不需要存全盘因为劫争只跟最近局面有关。哈希函数用 Zobrist 即可速度快碰撞率足够低。5.4 现象开源库对弈被屠杀现象本地自对弈赢多输少但跟公开引擎比如 GNU Go 打一场一败涂地。原因评估函数和模拟策略里没有按规则贴目或者死子没有从目数里扣除。更隐蔽的是引擎默认 19 路棋盘而你的程序在boardsize 9的测试场景里跑得飞快比赛时裁判偷偷发了boardsize 19。解决先确认规则参数黑棋贴目按 6.5 目。接着检查提子计数棋盘上被提走的子要从对面领地扣掉否则终局目数虚高。最后写一个自检函数给定一个固定棋局让程序输出它认为的胜率方向看看是不是跟常识一致。自检都不做就对弈被虐不冤。5.5 现象本地能跑比赛就掉链子现象在自己机器上每盘都能正常下完比赛现场连续超时、闪退、卡死。原因比赛机器的 CPU 核数更多但主频低代码里线程数写死导致负载不均衡或者模拟循环里有日志打印printf调用在高压环境下把时间全耗在 IO 上。解决把线程数、模拟次数上限、每步时间预算全部做成启动参数。赛前用--bench模式跑一分钟观察单步平均耗时不行就降线程数。日志刷屏在调试时有用比赛时保留--silent开关。这一点是我踩坑最多的调试日志打印一多模拟次数直接掉一个量级棋力肉眼可见地变菜。6. 进阶验证让幻影棋跟开源引擎对弈用胜率和用时挑出值得调的参数单独跑通只能证明代码没死想证明引擎能打你得让它跟外部对手连续对弈。我通常的做法是写一个批量对弈脚本自动拉起两个引擎按 GTP 协议交换着法连下 20 盘统计胜率和平均用时。import subprocess, time def play_game(engine1, engine2, boardsize19, time_limit5): p1 subprocess.Popen(engine1, stdinsubprocess.PIPE, stdoutsubprocess.PIPE, textTrue) p2 subprocess.Popen(engine2, stdinsubprocess.PIPE, stdoutsubprocess.PIPE, textTrue) for p in (p1, p2): p.stdin.write(fboardsize {boardsize}\n) p.stdin.flush() p.stdout.readline() current p1 while True: start time.time() current.stdin.write(genmove B\n if current is p1 else genmove W\n) current.stdin.flush() line current.stdout.readline().strip() if time.time() - start time_limit * 3: return None # 超时判负 if not line.startswith(): return None move line.split()[1] opponent p2 if current is p1 else p1 opponent.stdin.write(fplay {move}\n) opponent.stdin.flush() opponent.stdout.readline() current opponent win sum(1 for _ in range(20) if play_game(...) win)脚本要点每个引擎发完命令之后必须立刻读一行响应否则管道写满会阻塞每个命令都加超时保护因为某些引擎思考时会静默很久。从棋谱上复盘时重点关注中盘是不是频繁脱先、官子阶段点目的边界是否清楚。脱先多说明评估函数对局部价值的敏感性低官子乱点说明搜索深度不够。调参前先定目标。我用默认参数跑 50 盘记录胜率和平均用时然后一次只改一个参数再跑 50 盘对比。探索常数 c、模拟惩罚权重、时间分配比例这三个是我认为最值得调的点。比赛前一周锁死参数不再改动。这一行行调参的过程确实有点玄学但正是这些细节把亚军和参赛奖分开。希望帮到你。本文还有配套的精品资源点击获取
返回列表