资讯动态

不围棋AI实战:C++实现MCTS与Minimax源码解析

发布时间:2026/10/26 15:52:42 来源:尧图企业网站定制
简介一套基于C的不围棋游戏完整源码使用蒙特卡洛树搜索MCTS实现AI落子界面基于OpenGL的glut工具库可直接运行于Windows 10。项目源自计算概论期末大作业规则实现完整9×9棋盘、黑先轮落子、吃子判负、禁自杀与禁空手并保留黑棋首手禁占天元这一额外规则吃子判定覆盖围棋“气”的计算与提子流程同时提供minmax对照AI与botzone适配版本便于比较不同搜索策略的优劣。压缩包共57个文件、约2.2MB包含14个cpp与14个h源码、17个bmp棋盘棋子及界面素材另有vcxproj、sln、filters等工程配置以及save存档、README说明与git管理文件结构清晰可用Visual Studio直接打开编译也适合按模块研读AI决策、图形绘制和游戏规则逻辑。已有298人学习浏览无论是完成课程设计还是研究MCTS在实际博弈中的工程落地这套源码都能提供不错的参考。1. 不围棋与MCTS先搞清楚这份源码能帮你解决什么不围棋和围棋正好相反吃对方棋子的一方直接判负自杀判负连 pass 都判负。这意味着博弈树里大量“正常围棋好手”的招法都是禁区AI 的落子策略必须绕开吃子和自杀搜索空间比传统围棋更别扭。这份源码是一个完整的计算概论期末大作业C 实现、9×9 棋盘、AI 主力是蒙特卡洛树搜索MCTS跑在 GameAI_MCTS.cpp 里界面用 OpenGL 的 glut 工具库绘制同时附带一个基于 MinMax 的对照实现和一个 Botzone 提交专用工程。如果你想做不围棋 AI、需要一份能编译能跑的 MCTS 参考实现或者课程设计要交“算法 界面”的完整项目这份源码可以直接作为脚手架。后面几章我会把规则判定、MCTS 搜索、双 AI 结构、界面渲染一条线拆开讲。2. 规则落地坐标约定、气的计算与吃子判定不围棋的规则判定全部集中在 GameRule.cpp 里而判定吃的“气”是所有逻辑的地基。9×9 棋盘本身不大但坐标先 x 后 y、原点在左上角这个约定如果不提前统一后面读存档、画界面、提交 Botzone 都会错位。2.1 棋盘表示与坐标约定这份源码里棋盘用二维数组表示坐标约定是“先 x 后 y原点在左上角”。也就是说 (0,0) 是左上角(8,8) 是右下角x 是水平方向y 是垂直方向。我习惯把它映射成board[x][y]而不是常见的board[y][x]这样遍历时外层循环 x、内层循环 y拿到坐标后和 OpenGL 的屏幕坐标对齐起来比较直观。存档文件 1.save、2.save 也是按这个约定存的SaveManager.cpp 负责读写。如果你自己写加载逻辑最容易翻车的就是把行列顺序写反导致棋子位置整体转置。const int BOARD_SIZE 9; int board[9][9]; // 0 空, 1 黑, 2 白 // 把 (x, y) 转成存档字符串先x后y std::string coordToString(int x, int y) { return std::to_string(x) std::to_string(y); } // 解析存档行注意先读x再读y bool parseCoord(const std::string line, int x, int y) { std::istringstream iss(line); if (!(iss x y)) return false; return x 0 x 9 y 0 y 9; }这里的参数说明coordToString用于日志和存档写入parseCoord用于读取。两个函数如果不统一自回放对局时会发现黑棋第一手坐标明明是 (4,4)打印出来却落在 (4,4) 的另一侧这种错位问题排查起来非常隐蔽因为棋盘恰好是正方形转置后很多局面看起来仍然“合理”。2.2 气的计算BFS 数气与连通块判定吃子定义里最关键的一句是一个棋子在棋盘上与它直线紧邻的空点是这个棋子的“气”同色棋子相互连接成一个整体气合并计算无气状态的棋子被提子。实现上就是对每个棋子做 BFS收集同色连通块再统计所有邻接空点。#include queue #include set #include utility // 计算 (x,y) 所在同色连通块的气数 int countLiberty(int board[9][9], int x, int y, int color) { bool visited[9][9] {false}; std::queuestd::pairint,int q; q.push({x, y}); visited[x][y] true; std::setstd::pairint,int liberties; const int dx[4] {1, -1, 0, 0}; const int dy[4] {0, 0, 1, -1}; while (!q.empty()) { auto [cx, cy] q.front(); q.pop(); for (int i 0; i 4; i) { int nx cx dx[i]; int ny cy dy[i]; if (nx 0 || nx 9 || ny 0 || ny 9) continue; if (board[nx][ny] color !visited[nx][ny]) { visited[nx][ny] true; q.push({nx, ny}); } else if (board[nx][ny] 0) { liberties.insert({nx, ny}); } } } return (int)liberties.size(); }逻辑说明BFS 只向同色格子扩展遇到空点就记入liberties。集合天然去重不用担心同一个空点被多个同色棋子重复计入。这个函数在 MCTS 模拟和 Minimax 评估里都会被高频调用是性能热点。9×9 棋盘上这个实现足够快不用优化如果改成 19×19建议用并查集维护连通块气数。2.3 落子合法性吃子判负、自杀判负、禁止 pass不围棋的落子判定和围棋最大的差异在于吃子不是合法收益而是导致本方判负的禁手。所以isLegalMove里需要同时检查三种情况——是否吃对方棋子、是否自杀、是否空手。核心方法是先在临时棋盘上落子再检查双方气的变化最后还原棋盘。bool isLegalMove(int board[9][9], int x, int y, int color) { if (board[x][y] ! 0) return false; // 落子位置必须为空 board[x][y] color; // 先落子 // 检查是否吃对方棋子任何相邻对方连通块气为0则本方判负 const int dx[4] {1, -1, 0, 0}; const int dy[4] {0, 0, 1, -1}; for (int i 0; i 4; i) { int nx x dx[i]; int ny y dy[i]; if (nx 0 || nx 9 || ny 0 || ny 9) continue; if (board[nx][ny] 3 - color) { if (countLiberty(board, nx, ny, 3 - color) 0) { board[x][y] 0; // 还原棋盘 return false; // 吃子判负 } } } // 检查自杀落子后自己连通块气为0 if (countLiberty(board, x, y, color) 0) { board[x][y] 0; return false; } board[x][y] 0; return true; }参数说明color是当前落子方3 - color恰好是对方颜色1 黑、2 白。注意两个检查都发生在临时落子之后、真正提交之前还原棋盘这一步不能漏。如果漏掉后续 MCTS 扩展节点时棋盘状态就是脏的AI 会出现“上一手吃子的局面还能继续走”的幻觉。实际对局里真正合法的落子不会触发提子流程但本地测试时把提子逻辑写在注释里能帮你快速验证边界局面。附加规则要单独处理黑棋第一手禁止走棋盘中心 (4,4)这个规则只在本地对局生效Botzone 平台不启用。源码里应该有对应的开关我的做法是给 GameRule 加一个bool botzoneMode标志本地模式开局时拦截一次中心点。提示不围棋里 pass 判负是很容易被忽略的规则。很多 AI 在无路可走时会自然选择 pass但这在源码规则下直接判输。MCTS 模拟时必须保证每一步都强制落子否则模拟胜率会失真。3. MCTS 蒙特卡洛树搜索从 UCT 选择到四阶段模拟MCTS 是这份源码的 AI 核心实现在 GameAI_MCTS.cpp 里。9×9 不围棋每步最多 81 个落子点Minimax 想靠暴力搜索看到 5 步以后就很吃力而 MCTS 用随机模拟评估局面搜索深度天然不受限制只是精度靠迭代次数堆出来。3.1 为什么选 MCTS 而不是纯 Minimax不围棋的禁手规则导致评估函数很难写子多不一定好吃子反而直接输气数多也不是决定性的。Minimax 需要人工设计一个局面评估函数而且剪枝深度有限。MCTS 的优势是不需要领域知识完全靠“大量随机对局”统计出哪个候选落子胜率高。我用一个不太严谨的类比Minimax 像憋着一股劲往下算的“深算型选手”MCTS 像撒网打鱼的“概率型选手”。9×9 棋盘规模不大MCTS 单步跑 1000 到 3000 次模拟就能有可用的棋力代码量和调试成本都比 Minimax 低。这份源码同时提供 GameAI_Minmax.cpp 作为对照正好可以验证这个结论。3.2 节点结构与 safe_queueMCTS 节点需要记录局面、候选着法、访问次数和胜局数。源码里 safe_queue.h 是一个线程安全队列用途是把并行模拟任务分发给多个工作线程。如果你的机器是多核开 4 线程跑随机对局单步耗时能降到单线程的三分之一左右。struct MCTSNode { int board[9][9]; // 当前局面快照 int player; // 当前轮到谁落子 int x, y; // 到达该节点的落子坐标根节点为 -1 int visitCount; int winCount; std::vectorMCTSNode* children; bool expanded; double winRate() const { return visitCount 0 ? 0.0 : (double)winCount / visitCount; } };参数说明winCount的视角要和player对齐否则 UCT 公式会反向引导搜索。我的约定是winCount统计“轮到player落子的局面下player最终获胜”的次数。回溯更新时要根据当前节点和父节点的视角做一次翻转这是 MCTS 实现里最常见的“玄学 bug”来源之一。3.3 UCT 选择与回溯更新选择阶段用 UCT 公式平衡“探索”和“利用”。公式是winRate C * sqrt(log(parentVisits) / childVisits)其中 C 是探索常数通常取 0.7 到 1.4 之间。C 越大越鼓励探索冷门着法C 越小越倾向走当前胜率最高的着法。#include cmath const double C 1.2; // 探索常数可按棋力测试调整 MCTSNode* uctSelect(MCTSNode* node) { MCTSNode* best nullptr; double bestScore -1e9; for (MCTSNode* child : node-children) { if (child-visitCount 0) { // 未访问子节点直接返回先扩展再选择 return child; } double uct child-winRate() C * std::sqrt(std::log((double)node-visitCount) / child-visitCount); if (uct bestScore) { bestScore uct; best child; } } return best; }逻辑说明访问次数为 0 的子节点直接选中保证每个候选着法至少被完整模拟一次。log(parentVisits)会随父节点访问次数增长让长时间未被选中的子节点获得探索补偿。这个逻辑和围棋 MCTS 没有区别不围棋只是多了禁着点过滤。回溯更新时从叶子节点一路向上逐层翻转胜局归属void backpropagate(MCTSNode* node, int winner) { while (node ! nullptr) { node-visitCount; // 如果 winner 是 node 的当前 player则记一胜 if (node-player winner) node-winCount; node node-parent; } }这里有个关键细节winner是最终对局获胜方的颜色1 或 2但每个节点记录的player是“轮到谁落子”。回溯时需要判断获胜方和节点视角是否一致不一致就不记胜。如果不做这个翻转根节点的统计会变成“当前局面下黑棋胜率”而不是“当前落子方的胜率”AI 会集体跑偏。3.4 模拟阶段随机落子怎么避开禁手模拟阶段从扩展出的节点开始双方交替随机落子直到一方无合法着法。不围棋里 pass 判负所以“无合法着法”的一方直接判输。// 随机模拟一局返回获胜方颜色1 黑或 2 白 int simulate(int board[9][9], int currentPlayer) { int player currentPlayer; while (true) { std::vectorstd::pairint,int legalMoves; for (int x 0; x 9; x) { for (int y 0; y 9; y) { if (isLegalMove(board, x, y, player)) { legalMoves.push_back({x, y}); } } } if (legalMoves.empty()) { return 3 - player; // 无合法着法player 判负 } auto [x, y] legalMoves[rand() % legalMoves.size()]; board[x][y] player; player 3 - player; } }逻辑说明legalMoves每轮都要重新生成因为不围棋的禁手是动态的上一轮合法着法这一轮可能变成吃子禁手。isLegalMove内部有临时落子和还原所以模拟里直接落子不会污染局面。这里随机策略是纯粹均匀随机你也可以加一点倾向性比如优先走靠近已有棋子的位置但源码给的随机方案胜在简单调试 MCTS 主循环逻辑时少一层变量。4. Minimax 对照与 Botzone 接口双 AI 源码怎么用这份源码有意思的点在于它同时提供了两套 AIGameAI_MCTS 和 GameAI_Minmax还有单独一个 Botzone_MCTS 工程。对做课程设计或者准备算法竞赛的人来说这是很好的对照素材。4.1 MinMaxNode 与 Alpha-Beta 剪枝MinMaxNode.h/MinMaxNode.cpp 实现的是经典极大极小搜索节点。不围棋里 Minimax 的评估函数设计比较纠结这份源码的做法是统计“合法着法数量差”和“气数差”的加权组合。// 评估函数正值表示黑方有利 double evaluate(const int board[9][9]) { double score 0.0; // 合法着法数量差有着法优势的一方更主动 int blackMoves countLegalMoves(board, 1); int whiteMoves countLegalMoves(board, 2); score 0.6 * (blackMoves - whiteMoves); // 气数差气多意味着更安全 int blackLiberty countTotalLiberty(board, 1); int whiteLiberty countTotalLiberty(board, 2); score 0.4 * (blackLiberty - whiteLiberty); return score; }参数说明权重 0.6 和 0.4 是我跑了几十局对照后觉得比较稳的组合你可以改成 0.5/0.5 试试棋力差别不大。Minimax 的搜索深度受限于分支因子9×9 棋盘每步约 60 到 80 个候选点搜索深度到 3 层就已经需要 Alpha-Beta 剪枝否则单步响应时间会飙到十几秒。4.2 Botzone_MCTS 工程JSON 协议与标准输入输出Botzone_MCTS 是一个独立工程核心逻辑复用 GameAI_MCTS只把界面层换成标准输入输出。Botzone 平台每回合给程序发一行 JSON里面包含所有历史 requests 和 responses程序需要解析后回传本回合落子坐标。#include iostream #include json/json.h int main() { std::string line; while (std::getline(std::cin, line)) { Json::Value input; Json::Reader reader; if (!reader.parse(line, input)) continue; // 重建棋盘requests 是对手着法responses 是自己着法 int board[9][9] {0}; auto requests input[requests]; auto responses input[responses]; for (int i 0; i (int)responses.size(); i) { int x responses[i][x].asInt(); int y responses[i][y].asInt(); board[x][y] 1; // 假设自己是黑方需按实际身份调整 } for (int i 0; i (int)requests.size(); i) { int x requests[i][x].asInt(); int y requests[i][y].asInt(); board[x][y] 2; } // 调用 MCTS 获取着法 int bestX, bestY; getBestMove(board, bestX, bestY); Json::Value output; output[x] bestX; output[y] bestY; std::cout output.toStyledString() std::endl; } return 0; }逻辑说明Botzone 的 JSON 里requests是对手历史着法responses是自己的历史着法按顺序回放就能重建棋盘。getBestMove内部跑 MCTS 时要注意时间控制平台一般有单步时限我建议把 MCTS 迭代次数和时限挂钩比如在 0.9 秒内不停模拟到点返回当前最优着法。4.3 本地版与 Botzone 版 main.cpp 的差异本地版 main.cpp 负责 glut 初始化和鼠标键盘事件Botzone 版没有图形界面整个程序生命周期就是“读一行、算一手、回一行”。两个工程共用 GameAI_MCTS.cpp 和 GameRule.cpp差异只在入口层。如果你要把自己的算法改成 Botzone 提交版只需要保留核心搜索代码删掉 Graphic.cpp、Button.cpp、MenuBoard.cpp 这些界面文件即可。提示本地版有黑棋第一手禁走中心点的附加规则Botzone 没有。提交前要把这个开关关掉否则黑棋在中心开局时你的 AI 会无故弃着一手平台直接判负。5. OpenGL/glut 渲染与编译避坑从 glutInit 到 exe 跑不起来界面层用的是 OpenGL 经典 glut 工具库代码结构分两块main.cpp 负责窗口初始化和事件循环Graphic.cpp 和 CheckerBoard.cpp 负责绘制棋盘和棋子。5.1 glut 初始化流程与回调注册glut 的初始化顺序很固定先初始化库、设置显示模式、创建窗口再注册回调和进入主循环。这份源码的 main.cpp 基本就是这个套路#include GL/glut.h void onDisplay() { /* 调用绘制函数 */ } void onMouse(int button, int state, int x, int y) { /* 处理落子 */ } void onKeyboard(unsigned char key, int x, int y) { /* 处理快捷键 */ } int main(int argc, char** argv) { glutInit(argc, argv); glutInitDisplayMode(GLUT_DOUBLE | GLUT_RGBA); glutInitWindowSize(720, 720); glutCreateWindow(No-Go - MCTS vs Minimax); glutDisplayFunc(onDisplay); glutMouseFunc(onMouse); glutKeyboardFunc(onKeyboard); glutMainLoop(); return 0; }参数说明GLUT_DOUBLE是双缓冲避免绘制时画面闪烁glutMouseFunc的回调参数里有鼠标坐标需要把屏幕像素转成 0 到 8 的棋盘坐标转换公式是int bx x * 9 / winWidth。这里如果窗口的 top-left 原点和 OpenGL 坐标不一致点击落子会整体偏移。5.2 棋盘与棋子的绘制逻辑Graphic.cpp 里用 GL_LINES 画九路网格线棋子用圆形填充。菜单按钮在 MenuBoard.cpp 里绘制点击事件在 onMouse 里按矩形区域判断。void drawCheckerBoard() { glColor3f(0.9f, 0.8f, 0.6f); // 木纹底色 glBegin(GL_QUADS); glVertex2f(0, 0); glVertex2f(720, 0); glVertex2f(720, 720); glVertex2f(0, 720); glEnd(); glColor3f(0.1f, 0.1f, 0.1f); glLineWidth(1.0f); glBegin(GL_LINES); for (int i 0; i 9; i) { // 横线和竖线坐标映射到像素 glVertex2f(40 i * 80, 40); glVertex2f(40 i * 80, 680); glVertex2f(40, 40 i * 80); glVertex2f(680, 40 i * 80); } glEnd(); }这段代码的作用是把 9×9 网格均匀映射到 720×720 窗口四周留 40 像素边距。棋子的绘制用 GL_TRIANGLE_FAN 画实心圆黑白颜色分别填充。5.3 编译依赖Visual Studio 与 VC 运行库本地工程是 NoGo.sln用 Visual Studio 打开直接编译。工程属性里需要链接 opengl32.lib、glu32.lib 和 freeglut.lib运行 exe 时需要把 freeglut.dll 放到可执行文件目录或者把依赖路径加到系统环境变量。如果你在别的机器上运行报错missing freeglut.dll直接下载 x64 版 freeglut 放到 exe 旁边就行。这里有一条重要的血泪经验工程默认是 Debug x64 配置换到 Release 后 OpenGL 窗口能创建但点击按钮时偶发崩溃最后发现是 Release 版没带对应的 VC 运行库。装了Microsoft Visual C Redistributable后问题消失。5.4 四条实战踩坑记录坑 1运行弹 “failed to initialize graphics backend for opengl”现象编译通过双击 exe 弹窗报错窗口起不来。原因glutInit 创建 OpenGL 上下文失败常见于远程桌面、虚拟机或显卡驱动受限的环境。解决换回物理机本地会话跑如果必须用虚拟机给虚拟机启用 3D 加速或换用 Mesa 软件渲染版 OpenGL。坑 2Release 版点按钮无响应Debug 正常现象Debug 跑得好好的切 Release 后界面出来了但点击落子完全没反应。原因Release 配置下 VC 运行库没装上或者 freeglut.dll 用的是 Debug 版链接到错误的 CRT。解决先装vc_redist.x64.exe再把 Release 版 freeglut.dll 覆盖到 exe 目录两个条件缺一不可。坑 3MCTS 计算期间界面卡死现象点击落子后整个窗口无响应要等好几秒才恢复。原因AI 搜索直接在回调里同步执行阻塞了 glutMainLoop 的事件处理。解决把 MCTS 丢到子线程里跑算完通过 safe_queue 把结果传回主线程主线程只在收到结果后才刷新棋盘。这也是源码里 safe_queue.h 的主要用途。坑 4Botzone 提交后第一手就崩现象平台反馈程序运行错误没有任何输出。原因Botzone 版禁手开关没关黑棋第一手在中心时本地规则判非法AI 返回无着法直接退出。解决确认botzoneMode配置为 true跳过附加规则判断如果还崩检查 jsoncpp 解析是否把 initial 字段漏了。6. OpenGL 绘制小技巧与对局验证的实用姿势图形界面除了“能看”更重要是能帮你验证规则和 AI 逻辑。我最后补两个实用技巧第一个是用 SaveManager 的 1.save、2.save 做对局回放每次改完 GameRule.cpp 都强制把存档跑一遍确认新规则没有破坏旧对局第二个是写一个快速自对弈脚本不启动 OpenGL直接让两份 AI 在命令行里互下统计黑白胜负比。# 自对弈命令输出每步坐标和最终胜负 ./NoGoAI --selfplay --games 20 --sims 2000我用这个方式验证 MCTS 迭代次数对棋力的影响单步 500 次模拟对战 2000 次模拟20 局里 2000 次版本只输 3 局。这个测试同时暴露了一个隐藏 bug——某一版黑棋胜率异常高查到最后是 MCTS 回溯时没做视角翻转导致黑棋 AI 在模拟中“主观偏袒”自己。从那以后我每次改完规则或搜索逻辑都强制走一遍“存档回放 命令行自对弈”这两步确认没有规则回归才敢上 Botzone。界面绘制反而排到最后因为棋力不行时画面再好看也骗不过胜率统计。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑