资讯详情

资讯详情

C++实现不围棋AI:MCTS算法与OpenGL界面开发实战

简介这是一份基于C实现的不围棋NoGo完整游戏源码融合蒙特卡洛树搜索MCTSAI与OpenGL/glut图形界面支持人机对战适合有一定C基础、希望研究博弈树搜索或桌面游戏开发的学习者。压缩包共57个文件包含14个cpp与14个h源码、17个bmp贴图以及VC解决方案/工程文件整体约2.2MB结构清晰便于直接编译运行或参考改造。目前已有298人学习下载作为期末大作业项目具备一定参考价值。项目完整实现了9×9棋盘不围棋规则包括禁自杀、禁空手、吃子判负等特殊胜负判定以及黑棋首手禁中心等附加规则AI部分提供MCTS和Minmax双版本对比并附带Botzone_MCTS提交模块可配合jsoncpp适配在线对弈平台。图形界面采用OpenGL的glut工具库配套菜单、按钮、棋盘贴图与存档管理SaveManager还包含Minmax实现与游戏规则模块适合用于算法实验、课程设计或进一步二次开发。1. 计算概论期末大作业用MCTS和GLUT把「不围棋」做成能玩的AI小游戏不围棋是围棋的「反向规则」普通围棋吃子赢不围棋只要提掉对方一片棋落子一方立刻判负。于是整盘棋变成一场「让子博弈」——你要把自己的棋走薄、做成对手不敢碰的形状同时避开一提就输的陷阱。这个期末大作业正好把 C 的核心语法、蒙特卡洛树搜索MCTS选点、OpenGL 的 glut 工具库界面三件事串成一体非常适合想从语法练习跨到完整项目的学生。两周内能跑出一个既支持双人对战、又支持人机对战的棋盘程序答辩时既有算法深度又有可视化效果。2. 不围棋规则与棋盘核心先让「吃子者判负」在代码里立住不围棋的规则逻辑和普通围棋只有几行之差但差之毫厘谬以千里。普通围棋是「提子禁自杀」不围棋是「禁自杀提子者判负」因此判断落子是否合法、什么时候终局就成了整个程序最基础也最容易翻车的部分。棋盘选 9 路而非 19 路是因为不围棋的合法着法天然比围棋少一大截9 路能让 MCTS 在同样的计算时间里深挖更多步期末演示时 AI 反应也更快。2.1 棋盘表示与「气」的计算用洪泛法找出一整块棋的边界棋盘用固定大小的二维数组存最省心。9 路就是 9x9状态用 0 表示空、1 表示黑、2 表示白。判断一块棋有没有气本质是从某个棋子出发做洪泛搜索找到整块连通棋子的全部邻接空点。这里的经典误区是气数按「空点去重」算而不是按「邻接次数」数。如果 BFS 时不记录哪些空点已经被访问过一块棋明明只有 3 口气代码可能数出 5 口。只判断「是否为 0」时问题不大但后面 MCTS 启发式要用气数排序数错就会影响棋力。#include array #include queue #include utility constexpr int BOARD_SIZE 9; constexpr int EMPTY 0, BLACK 1, WHITE 2; using BoardState std::arraystd::arrayint, BOARD_SIZE, BOARD_SIZE; const int kDirs[4][2] {{1, 0}, {-1, 0}, {0, 1}, {0, -1}}; // 统计 (x, y) 所在整块同色棋子的气数空点去重 int countLiberties(const BoardState board, int x, int y) { bool visited[BOARD_SIZE][BOARD_SIZE] {false}; bool libVisited[BOARD_SIZE][BOARD_SIZE] {false}; int color board[x][y]; int libs 0; std::queuestd::pairint, int q; q.push({x, y}); visited[x][y] true; while (!q.empty()) { auto [cx, cy] q.front(); q.pop(); for (auto [dx, dy] : kDirs) { int nx cx dx, ny cy dy; if (nx 0 || nx BOARD_SIZE || ny 0 || ny BOARD_SIZE) continue; if (board[nx][ny] color !visited[nx][ny]) { visited[nx][ny] true; q.push({nx, ny}); } else if (board[nx][ny] EMPTY !libVisited[nx][ny]) { libVisited[nx][ny] true; libs; } } } return libs; }代码思路是两层 visitedvisited 标记棋块内的点保证每个棋子只入队一次libVisited 标记已经被算过的气点保证气数不重复。逻辑上落子后再调这个函数返回 0 就说明这颗子所在的棋块已经没有气了。参数上整套逻辑只关心 board 和坐标不依赖外部全局状态所以后面 MCTS 模拟时可以直接传临时棋盘副本不用改全局数组。2.2 合法落子判断为什么「先提对方再判自杀」是绕不过去的顺序不围棋的规则细化成代码只有两条落子后己方无气是禁手落子后对方无气是终局落子方判负。但有一个隐蔽的交叉情况你落子后己方块无气同时这个无气块把对方的某个无气块挤住了——按照围棋规则对方无气块要先被提掉提掉之后你的块可能又有了气。所以「先判断己方气」会把你自己的合法着法误判成自杀而「先提对方」则能让判定结果和真实规则一致。// 把 color 方所有无气棋块从棋盘上移除返回是否发生过提子 bool captureDeadStones(BoardState board, int color) { bool captured false; for (int x 0; x BOARD_SIZE; x) { for (int y 0; y BOARD_SIZE; y) { if (board[x][y] color countLiberties(board, x, y) 0) { // 整块置空用 BFS 清掉这一整块同色棋 std::queuestd::pairint, int q; q.push({x, y}); board[x][y] EMPTY; while (!q.empty()) { auto [cx, cy] q.front(); q.pop(); for (auto [dx, dy] : kDirs) { int nx cx dx, ny cy dy; if (nx 0 || nx BOARD_SIZE || ny 0 || ny BOARD_SIZE) continue; if (board[nx][ny] color) { board[nx][ny] EMPTY; q.push({nx, ny}); } } } captured true; } } } return captured; } // 判断在 (x, y) 落 player 的棋子是否合法不禁手即可吃子也算合法 bool isLegalMove(const BoardState board, int player, int x, int y) { if (board[x][y] ! EMPTY) return false; BoardState tmp board; tmp[x][y] player; // 先提掉对方所有无气块释放潜在的气 captureDeadStones(tmp, 3 - player); // 再检查己方这手棋有没有气 return countLiberties(tmp, x, y) 0; }captureDeadStones 里用连消带打的方式遍历遇到无气同色块时不只清单点而是 BFS 清整块。原因是气数为 0 的棋块必然是整块同死只清一个角落会让剩下的同色子变成全新的「无气块」下一轮遍历虽然也会清掉但可能影响提子标记和性能。isLegalMove 的思路是拷贝一份棋盘做临时判断绝不动真实棋盘这样 MCTS 里反复调用也不会污染局面。参数唯一的注意点是 player 取值 1 或 23 - player 就是对手颜色这个写法在黑白交替时最不容易出错。2.3 落子与终局判定一提子就结束棋盘下满比谁剩得少对真实对弈来说只要 applyMove 过程中发生了提子本局就结束了胜负是「提子方负」。如果双方都没提子一直下到棋盘填满或双方都找不到合法着法就按棋盘上剩余棋子数量判定剩得少的一方获胜。这个终局规则对应不围棋「主动失子」的理念你方的棋越薄却还活着说明你对「不敢吃你」的局面控制得越成功。struct Move { int x, y; }; // 返回值0未终局 1黑胜 2白胜 3平局 int applyMove(BoardState board, int player, Move mv, bool gameOver) { board[mv.x][mv.y] player; // 先提对方再查己方 bool captured captureDeadStones(board, 3 - player); if (captured) { gameOver true; return 3 - player; // 提子方判负 } // 未提子若己方也无气理论上不该发生isLegalMove 已过滤禁手 if (countLiberties(board, mv.x, mv.y) 0) { gameOver true; return 3 - player; // 兜底把自杀也按落子方负处理 } gameOver false; return 0; } // 终局按剩余棋子数判定少者胜 int finalWinnerByStones(const BoardState board) { int black 0, white 0; for (const auto row : board) { for (int cell : row) { if (cell BLACK) black; if (cell WHITE) white; } } if (black white) return BLACK; if (white black) return WHITE; return 3; }applyMove 里故意留了一个兜底分支万一上层调用没做禁手检查直接落了一手自杀棋局面不该继续下去。把这种异常输入当成「落子方负」既符合规则直觉又防止 MCTS 在极端情况下死循环。真正的吃子终局判断在 captureDeadStones 返回 true 时立刻 return避免继续往下走棋。3. 蒙特卡洛树搜索不围棋为什么是 MCTS 的天然主场蒙特卡洛树搜索不是暴力穷举而是「用随机模拟的统计结果指导选择」。普通围棋合法着法几百个纯 MCTS 想达到强棋力需要海量模拟不围棋因为自杀禁手天然砍掉大量着法9 路棋盘上每步合法选择通常只有个位数到几十个MCTS 几百次迭代就能看出明显的「会下棋」的迹象。这是选题的关键MCTS 不是唯一选择但对不围棋这种分支少、局面小的游戏它是实现量最小、效果最可感知的方案。3.1 四步走选择、扩展、模拟、回溯节点里到底该存什么MCTS 的每个节点代表一个棋盘局面。节点需要记录当前轮到谁走、访问次数、胜场数、父节点、子节点列表和尚未尝试的着法。棋盘本身直接以值拷贝的方式存在节点里虽然看着笨重但 9x9 的 std::array 拷贝一次只有 324 字节一局模拟几千次拷贝完全没有性能压力。#include memory #include vector #include cmath #include limits struct MCTSNode { BoardState board; int playerToMove; // 轮到谁走1 或 2 int visitCount 0; double winScore 0.0; // 以本节点轮到的一方为视角的胜率 MCTSNode* parent nullptr; Move moveFromParent; // 从父节点走到本节点的那手棋 std::vectorMove untriedMoves; std::vectorstd::unique_ptrMCTSNode children; MCTSNode(BoardState b, int p, MCTSNode* par) : board(b), playerToMove(p), parent(par) {} };winScore 的视角是全程序最容易写乱的地方。我采用的约定是每个节点的 winScore 都表示「轮到 playerToMove 走棋的这一方」的累计胜场。这样从根节点往下黑子节点和白子节点交替出现回溯时每上一层结果视角翻转一次子节点胜率天然是黑白各自的胜率选点逻辑因此变得直白——根节点是黑方时选白子节点中胜率最低的那个就是黑方的最好着法。3.2 选择与扩展UCB1 公式怎么平衡「试试新招」和「走熟路」选择阶段从根节点开始反复用 UCB1 公式在兄弟节点里挑一个走直到走到一个还没完全展开的节点。UCB1 的经典形式是子节点平均胜率加上一个探索项C * sqrt(log(父节点访问次数) / 子节点访问次数)。平均胜率项保证「已知的好招」会被多走探索项保证「没怎么试过的招」也有机会被翻牌。C 是探索常数我一般不调太大不围棋分支少C 设 0.9 到 1.2 就够用太大 AI 会像多动症一样乱试。double ucb1(const MCTSNode* child, int totalVisits, double C) { if (child-visitCount 0) { return std::numeric_limitsdouble::infinity(); // 未访问的节点优先级最高 } double exploit child-winScore / child-visitCount; double explore C * std::sqrt(std::log(totalVisits) / child-visitCount); return exploit explore; } MCTSNode* selectBestChild(MCTSNode* node, double C) { MCTSNode* best nullptr; double bestValue -std::numeric_limitsdouble::infinity(); for (auto childPtr : node-children) { MCTSNode* child childPtr.get(); double value ucb1(child, node-visitCount, C); if (value bestValue) { bestValue value; best child; } } return best; }这里把「未访问节点返回无穷大」放在 ucb1 里作用等价于每个孩子至少先被尝试一次。常见误用是把 C 开很大试图增加探索——那样的话早期看起来乱走后期又因为 log 增长太慢很快收敛回纯贪心。一个稳妥做法是调参时只看结果不看过程用 CLI 自对弈让不同 C 值互博谁赢用谁。3.3 模拟阶段纯随机能把棋下「像人」启发式采样才能让棋下「像棋」模拟playout是从叶子节点出发双方快速落子直到终局。完全随机的模拟对不围棋来说效果很差不围棋的棋理是「把自己的棋走薄」但纯随机会均匀地往所有空位下单模拟结果几乎跟棋力无关。一个非常简单的启发式改动就能大幅提升棋力——落子后己方棋块气数越少的着法越优先选。这个启发式的直觉是气少的棋块对对手构成「不能碰」的心理威慑同时也在消耗棋盘空间符合不围棋「主动失子」的目标。#include algorithm #include random std::mt19937 g_rng(std::random_device{}()); // 从合法着法里按气数升序采样优先选「把自己走薄」的点 Move pickHeuristicMove(const BoardState board, int player, const std::vectorMove legalMoves) { std::vectorstd::pairint, Move scored; scored.reserve(legalMoves.size()); for (Move mv : legalMoves) { BoardState tmp board; tmp[mv.x][mv.y] player; int libs countLiberties(tmp, mv.x, mv.y); scored.push_back({libs, mv}); } std::sort(scored.begin(), scored.end()); // 只从前 1/3 的低气数着法里随机选保持一定多样性又不至于乱走 int limit std::max(1, (int)scored.size() / 3); std::uniform_int_distributionint dist(0, limit - 1); return scored[dist(g_rng)].second; }注意排序的对象 pair 默认先按 libs 排再按坐标排所以 limit 取前三分之一就是「气数最薄的一批着法」。这个启发式不会把 AI 变成死板机器因为选点仍有随机性只是整体概率偏向低气数区域。如果你觉得棋力还不够可以进一步把采样分布从均匀改成指数权重但期末作业用前三分之一均匀采样已经能打出「敢于送吃、善于包围」的风格。逻辑上要记住气数最小不代表最好气数为 1 的棋块是「对手一提你就输」的诱饵AI 在模拟时反倒是安全的——对手不敢提这手棋就相当于占了个对方不敢碰的位置。3.4 主循环与超时控制把「思考时间」卡在 500 毫秒内MCTS 主循环就是重复「选择 → 扩展 → 模拟 → 回溯」四步跑够指定迭代次数后就从根节点的子节点里挑一个胜率最高的着法。实际对弈里更实用的是按时间停止用户落子后给 AI 最多几百毫秒思考到点就停。实现上直接用std::chrono每次迭代前检查一次时间到点跳出循环。#include chrono Move mctsGetMove(const BoardState board, int player, int iterations) { MCTSNode root(board, player); root.untriedMoves generateLegalMoves(board, player); auto deadline std::chrono::steady_clock::now() std::chrono::milliseconds(500); for (int i 0; i iterations; i) { if (std::chrono::steady_clock::now() deadline) break; MCTSNode* node root; // 选择一路走到需要展开或已经终局的节点 while (node-untriedMoves.empty() !node-children.empty()) { node selectBestChild(node, C_UCB); } // 扩展还有没尝试的着法就展开一个孩子 if (!node-untriedMoves.empty()) { node expand(node); } // 模拟从展开出的局面跑到底 int winner simulatePlayout(node-board, node-playerToMove); // 回溯从叶子向根更新胜率和访问次数 backpropagate(node, winner); } // 选根节点下胜率最高对根玩家的子节点 return chooseBestMove(root, player); }主循环参数上有三个自由量迭代次数上限、时间上限、模拟步数上限。时间上限是硬约束迭代次数只是防止死循环的保险。9 路棋盘上我一般设定 500 毫秒对应几百次到上千次迭代视觉效果是「AI 似乎想了想但没有明显卡顿」。如果跑 13 路同样时间迭代次数会掉一半因为这时的合法着法更多、每步模拟更长。想让 AI 变强优先加时间而不是加迭代次数上限因为迭代次数只是结果不是目标。4. OpenGL glut 界面把棋盘从控制台搬到窗口里glut 是很老的工具库但胜在简单初始化窗口、注册回调、进入消息循环三个步骤就把 OpenGL 上下文和事件系统搭起来了。界面层的设计目标是「纯展示」——棋盘状态仍然由前面的 C 逻辑维护渲染函数只负责把数组画到窗口上鼠标回调负责把点击位置换算成棋盘坐标。4.1 glut 初始化与 OpenGL 上下文的作用窗口创建后 GL 函数才有意义OpenGL 的函数调用依赖一个「当前上下文」。glutCreateWindow 之前任何 glClear、glColor 调用都没有实际效果因为 GPU 不知道你在为哪个窗口画东西。glutCreateWindow 成功执行后这个窗口的 OpenGL 上下文被自动设为当前上下文后面所有绘制函数才进入有效状态。这个上下文的作用还包括维护深度缓冲、模板缓冲和版本信息OpenGL 版本不同能用的函数集合也不同。#include GL/glut.h constexpr int WINDOW_W 600; constexpr int WINDOW_H 600; constexpr int MARGIN 30; constexpr float CELL (WINDOW_W - 2.0f * MARGIN) / (BOARD_SIZE - 1); constexpr float STONE_R CELL * 0.42f; void initGLUT(int argc, char** argv) { glutInit(argc, argv); glutInitDisplayMode(GLUT_DOUBLE | GLUT_RGB); glutInitWindowSize(WINDOW_W, WINDOW_H); glutInitWindowPosition(100, 100); glutCreateWindow(No-Go - 不围棋); // 正交投影直接按像素坐标画图y 轴向下对齐鼠标坐标系 glMatrixMode(GL_PROJECTION); glLoadIdentity(); gluOrtho2D(0, WINDOW_W, WINDOW_H, 0); glMatrixMode(GL_MODELVIEW); glLoadIdentity(); glClearColor(0.85f, 0.62f, 0.42f, 1.0f); // 注册回调 glutDisplayFunc(display); glutReshapeFunc(reshape); glutMouseFunc(mouseClick); glutKeyboardFunc(keyboard); }gluOrtho2D(left, right, bottom, top) 里我把 bottom 设成 WINDOW_H、top 设成 0这样客户区的 y 坐标从上向下增长。这个选择和 glut 鼠标回调返回的鼠标 y 坐标方向一致避免渲染坐标和事件坐标之间反复换算。display mode 用了 GLUT_DOUBLE双缓冲模式下绘制完成后必须调用 glutSwapBuffers 才显示否则画面会闪烁或者什么都看不到。GLUT_RGB 指定颜色模式配合 glClearColor 的背景色使用。4.2 画棋盘与画棋子GL_LINES 画线、三角扇近似圆棋盘是九条横线加九条竖线用 GL_LINES 原语一次提交所有线段效率没问题。线段坐标直接由 MARGIN、CELL 和格子索引算出。棋子是圆形OpenGL 没有内置的圆原语最通用的做法是三角扇圆心一个顶点圆周按 30 个小段取点把所有扇形三角形凑成一个圆。30 段对棋子来说边缘已经很平滑再多就浪费顶点。void drawBoard() { glClear(GL_COLOR_BUFFER_BIT); glColor3f(0.1f, 0.1f, 0.1f); glLineWidth(1.5f); glBegin(GL_LINES); for (int i 0; i BOARD_SIZE; i) { float pos MARGIN i * CELL; // 横线 glVertex2f(MARGIN, pos); glVertex2f(WINDOW_W - MARGIN, pos); // 竖线 glVertex2f(pos, MARGIN); glVertex2f(pos, WINDOW_H - MARGIN); } glEnd(); // 星位点缀9 路棋盘画 5 个点 glPointSize(4.0f); glBegin(GL_POINTS); glVertex2f(MARGIN 2 * CELL, MARGIN 2 * CELL); glVertex2f(MARGIN 2 * CELL, MARGIN 6 * CELL); glVertex2f(MARGIN 4 * CELL, MARGIN 4 * CELL); glVertex2f(MARGIN 6 * CELL, MARGIN 2 * CELL); glVertex2f(MARGIN 6 * CELL, MARGIN 6 * CELL); glEnd(); } void drawStone(int gx, int gy, int color) { float cx MARGIN gx * CELL; float cy MARGIN gy * CELL; if (color BLACK) glColor3f(0.1f, 0.1f, 0.1f); else glColor3f(0.95f, 0.95f, 0.95f); glBegin(GL_TRIANGLE_FAN); glVertex2f(cx, cy); // 圆心 const int kSegments 30; for (int i 0; i kSegments; i) { float angle i * 2.0f * 3.1415926f / kSegments; float vx cx STONE_R * cosf(angle); float vy cy STONE_R * sinf(angle); glVertex2f(vx, vy); } glEnd(); }画棋子的颜色区分很简单黑色棋设为深灰白色棋设为浅灰背景是木色三种颜色对比足够清晰。如果你想让黑白棋更有立体感可以在圆心稍偏的位置再画一个小高光圆但这属于纯视觉优化和 AI 逻辑无关期末阶段可以不做。注意 glPointSize 对 GL_POINTS 才生效星位这种散点用它最省事。4.3 鼠标坐标换算与双缓冲刷新点下去的那一瞬间发生了什么glut 鼠标回调返回的坐标是像素值原点在窗口左上角。换算成棋盘坐标只需要把像素坐标减去棋盘起始边距再除以格子间距四舍五入到最近的交叉点。很多人直接做整数除法结果点边缘位置时落子错位。另外AI 落子不能直接在鼠标回调里同步执行因为 MCTS 要跑几百毫秒事件循环会被卡死。正确做法是设一个 timer 回调等本次绘制结束后再让 AI 走棋。bool gameOver false; int currentPlayer BLACK; int gameMode 0; // 0双人 1人机 void mouseClick(int button, int state, int mx, int my) { if (button ! GLUT_LEFT_BUTTON || state ! GLUT_UP) return; if (gameOver) return; int gx static_castint(std::round((mx - MARGIN) / CELL)); int gy static_castint(std::round((my - MARGIN) / CELL)); if (gx 0 || gx BOARD_SIZE || gy 0 || gy BOARD_SIZE) return; if (gameMode 0) { if (tryMove(gx, gy, currentPlayer)) { currentPlayer 3 - currentPlayer; glutPostRedisplay(); } } else if (gameMode 1 currentPlayer BLACK) { // 玩家执黑AI 执白 if (tryMove(gx, gy, BLACK)) { currentPlayer WHITE; glutPostRedisplay(); glutTimerFunc(10, aiMoveTimer, 0); } } } void aiMoveTimer(int) { Move best mctsGetMove(board, WHITE, 0); if (tryMove(best.x, best.y, WHITE)) { currentPlayer BLACK; } glutPostRedisplay(); }mouseClick 里的关键点是先用 round 四舍五入再判断边界。因为玩家点到格子中间偏左半格的位置时它实际上想下在右侧交叉点上floor 会错误地落在左侧。aiMoveTimer 用 long 类型参数是 glutTimerFunc 回调的固定签名即使不用它也得照写。每次落子后调用 glutPostRedisplay 通知 GLUT 重绘这是双缓冲模式下最常见的刷新方式如果忘了调用界面会像死机一样停在旧画面。5. 编译运行全流程从 GLUT 环境配置到 6 个典型报错排查环境配置往往是新手花时间最多的地方。glut 有老牌实现也有 freeglut 这种兼容品链接时稍微搞错一个库名字符串就编译不过。这里给出一套我常用的构建方案以及 6 个我自己或学生实际踩过的坑按「现象 → 原因 → 解决」的顺序写希望你能在出问题时不至于重装系统。5.1 一套能跑的构建命令Windows 下用 MinGW freeglut 最快Windows 上我推荐用 MSYS2 装 MinGW-w64然后用 pacman 装 freeglut 和 opengl32比 Visual Studio 手动配置库路径省心。装完后一条命令就能编译出可执行文件不依赖 IDE 的工程文件。# 安装依赖MSYS2 终端 pacman -S mingw-w64-x86_64-gcc mingw-w64-x86_64-freeglut # 编译把源文件列全链接 freeglut、opengl32、glu32 g -O2 -stdc17 main.cpp board.cpp mcts.cpp render.cpp \ -o nogo.exe \ -lfreeglut -lopengl32 -lglu32命令里的 -O2 是优化开关MCTS 的模拟循环非常吃 CPU开优化后同样时间能多跑一半迭代所以这个参数不能省。-lfreeglut 对应 freeglut 库注意老版 GLUT 在某些发行版里叫 -lglut。如果你的系统同时装了多个 OpenGL 库链接顺序也很讲究把依赖库放在源文件同一行命令的末尾避免链接器找不到符号。生成 nogo.exe 后直接双击运行窗口应该立刻弹出。如果需要发给没装开发环境的同学跑对方机器得装对应架构的 Visual C 2015-2022 Redistributable否则会弹「找不到 DLL 入口点」或直接报缺 vcruntime140.dll这个跟代码无关属于运行库部署常识。5.2 坑一明明合法能吃的棋AI 却永远不下现象棋盘上对方有一块没气的棋AI 却视而不见宁可去别处落子。原因是 isLegalMove 里没有先提对方就查己方气导致把「吃了对方子之后己方有气」的着法误判成自杀禁手。这类错误的表现不是崩溃而是 AI 好像「变笨了」因为它的合法着法集合比真实规则少了一大截。解决方法是严格按「先 captureDeadStones 再 countLiberties」的顺序写判断逻辑可以专门写个单元测试摆一个对方无气块在角落里断言那手能吃子的点 isLegalMove 返回 true。5.3 坑二同一盘棋每次重开都一模一样AI 像个复读机现象固定玩家第一步后AI 后续应对完全可预测甚至每次运行程序开局都一样。原因是 use rand() 没有给随机种子或者给了srand(time(0))但 MCTS 模拟循环里多次快速调用 time(0) 得到相同种子导致整局模拟退化成同一条必然路径。MCTS 的价值就在于用随机性探索不同分支随机源死了算法就退化成贪心搜索。解决方法是直接用 C11 的random库在文件顶部构造一个std::mt19937 g_rng(std::random_device{}())所有模拟采样都用这个 rng。注意随机设备和 mt19937 组合的初始化开销极小不用每次采样时重新构造。5.4 坑三glut 窗口黑屏或报 failed to initialize graphics backend for opengl现象程序启动后窗口存在但画面全黑或者在远程桌面、虚拟机里直接报初始化失败就退出。原因是 OpenGL 上下文创建依赖 GPU 驱动和显示环境远程桌面和部分虚拟机默认不提供硬件加速的 OpenGL 上下文老 GLUT 又不会优雅降级。解决的方法是先确认本机能不能跑其他 OpenGL 程序不能跑就换物理机或者开启虚拟机的 3D 加速代码层面建议加一个--nogui命令行开关跳过所有 GLUT 初始化改用控制台自对弈模式让 MCTS 核心逻辑脱离界面独立可测。这样远程调试算法时不依赖图形环境拷到有 GPU 的机器上再开界面。5.5 坑四MSVC 下 fopen 报错安全函数警告刷屏现象用 Visual Studio 编译时代码里写fopen(mcts_log.txt, w)直接报 C4996提示用 fopen_s。原因是微软默认把 fopen 列为不安全函数编译期强制告警。解决方法是三种任选项目属性预处理器定义_CRT_SECURE_NO_WARNINGS或者在源文件顶部#define _CRT_SECURE_NO_WARNINGS或者直接用fopen_s。期末阶段我推荐第一种因为日志文件用 fopen 写完的可读性和可移植性最好你不想为了消警告改一堆代码。5.6 坑五点棋盘边缘落子错位AI 又迟迟不动现象点在边线上下一格的位置棋子落到相邻交叉点上人机模式里玩家落子后界面卡住几十秒。原因有两个一是坐标换算用了 int 强转而不是 round二是 AI 在鼠标回调里同步跑 MCTS事件循环被阻塞。坐标换算修正办法是把(mx - MARGIN) / CELL用 std::round 处理AI 阻塞的办法是改 glutTimerFunc 异步触发配合双缓冲重绘。卡住几十秒不是死循环而是同步计算期间窗口系统无法响应任何消息看起来很像崩溃实际上鼠标回调一返回画面就恢复了但这种体验对答辩演示是致命的。6. 验证 AI 棋力写一个 CLI 自对弈脚本让不同参数的 AI 自己打一场界面上人工试玩几局只能得到「感觉还行」的模糊结论答辩时评委大概率会问「你怎么知道 MCTS 比随机下棋强」。所以我在主程序里留了一个--nogui参数不初始化 OpenGL直接用命令行跑自对弈。做法是黑方用迭代次数较多的 MCTS白方用迭代次数较少的 MCTS或者纯随机着法统计 20 局里强棋方的胜率。这个过程能在几分钟内给出可量化的对比数据也能帮你调 C 值和迭代次数。void runSelfPlay(int iterationsStrong, int iterationsWeak) { int strongWins 0; for (int g 0; g 20; g) { BoardState board{}; int player BLACK; bool over false; int winner 0; while (!over) { int iter (player BLACK) ? iterationsStrong : iterationsWeak; Move mv mctsGetMove(board, player, iter); applyMove(board, player, mv, over, winner); player 3 - player; if (over) break; } if (winner BLACK) strongWins; } printf(strong(black) win rate: %.0f%%\n, strongWins / 20.0 * 100.0); }自对弈脚本的注意点是不要用固定棋盘初始化每局开始时把棋盘数组清零让 AI 从完全空盘开始下。你可能会发现强 AI 并非必胜因为不围棋的随机性比围棋大而且先手优势不明显这些现象本身就是可以写进报告的内容。我建议对比三组200 次迭代对随机 playout、500 次迭代对 200 次迭代、1000 次迭代对 100 次迭代每组跑 20 局以上再配合单步耗时记录就能画出一张「迭代次数 vs 胜率」的小表。我自己做这个题时第一版把禁手判断顺序写反MCTS 自己提了自己的大龙窗口里黑白两色在棋盘上「互杀」场面一度非常壮观。后来我给程序加了个--debug开关每次模拟结束打印着法和胜负原因才真正看懂 MCTS 在干什么。如果你时间有限先跑通 9 路、200 次迭代、纯随机模拟再加启发式采样不要一上来追求 13 路和超高强度——调参的坑比你想象的多从最小可行版本开始迭代才是最快的路径。希望帮到你。本文还有配套的精品资源点击获取
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →