C++五子棋源码拆解:Alpha-Beta剪枝与评估函数实战

发布时间:2026/10/29 14:42:27
C++五子棋源码拆解:Alpha-Beta剪枝与评估函数实战 简介这份C五子棋程序面向C初学者与游戏编程入门者围绕人机对战和人人对战两种模式提供可运行程序与配套源码便于理解棋盘、棋子、玩家等对象的抽象方式以及落子判定、胜负检测和交互响应的实现思路。压缩包共7个文件以3个cpp源码、3个exe可执行程序和1个txt说明为主整体约106KB体积轻量适合在本地快速运行并对照阅读其中源码可观察类结构、AI决策与输入处理说明文档有助于梳理目录与版本差异。已有182人学习说明其作为课程设计、自学练习或小型项目参考具有一定可读性。读者可从中收获人机与双人对战的基本流程、鼠标和键盘交互的不同实现角度以及基于规则或搜索的AI入门思路为后续优化AI水平、完善界面反馈和扩展功能提供可复用的代码骨架。1. 命令行里的一盘棋比管理系统更能练出工程感很多人练 C 的第一反应是堆一个信息管理系统表格、增删改查、文件读写写完了也看不出代码“活”在哪。这份 C 五子棋源码不太一样人人对战和人机对战共用同一个棋盘对象改一个开关对面就从另一个玩家变成搜索算法落子、判胜、悔棋、重开全在一个控制台循环里跑完。对刚学完类和容器的同学它是能一次性吃透“状态 规则 搜索”三段式结构的小工程对想练搜索算法的老手它是一块能随便改评估函数、加剪枝、换界面的试验田。下面按我拆源码的顺序讲先看文件怎么分再看规则怎么写最后看 AI 怎么想。2. 项目结构与技术选型五个文件把界面、规则、搜索切开这套代码的工程约束很轻纯 C17 标准库不依赖任何图形库和第三方库g、clang、MSVC 都能直接编。我第一次拿到它的时候只有一个main.cpp能跑但没法改——判胜逻辑和搜索逻辑缠在一起动一行评估函数就崩。后来我按职责切成五块这也是绝大多数同类五子棋源码最终会走到的形态界面只管输入输出棋盘只管状态规则只管合法性AI 只管搜索。好处是加禁手规则只动 rules换图形界面只动 ui评估函数调参只动 eval互不牵连。2.1 目录与文件职责五个文件把界面、规则、搜索切开拆完之后的文件清单大致长这样每个文件的行数量级我都标了方便你估计阅读成本文件职责大概行数关键点src/main.cpp入口、命令行参数、模式选择80--ai/--depth/--whitesrc/board.h/.cpp棋盘状态、落子、悔棋、判胜15015×15 定长数组src/game.h/.cpp对局流程、回合切换、终局处理180人人 / 人机共用src/ai.h/.cpp候选点、Alpha-Beta 搜索220深度与分支数可配src/eval.h/.cpp棋型评分表、整盘评估120调参入口全在这编译和运行只有两条命令不需要 CMake 也能跑# 在项目根目录把 src 下所有 .cpp 一次编进来 g -stdc17 -O2 -Wall -o gomoku src/*.cpp # 人人对战默认两个人轮流输入坐标 ./gomoku # 人机对战玩家执黑先行AI 搜索 4 层 ./gomoku --ai --depth 4 # 玩家执白让 AI 先走顺便看看它的开局偏好 ./gomoku --ai --white-O2不是可选项AI 的搜索是纯 CPU 密集的递归不开优化在深度 6 时要慢三到五倍体感上就是“敲完坐标程序卡住了”。-Wall建议留着落子函数的返回值如果被忽略编译器会提醒你这个警告后面在避坑章节还会提到。参数解析我一般写成最朴素的一段不引第三方库// src/main.cpp #include string #include game.h int main(int argc, char** argv) { GameOptions opt; opt.vsAI false; opt.depth 4; // 默认搜索深度4 层在普通笔记本上约 100~300ms opt.humanColor BLACK; // 玩家默认执黑先行 for (int i 1; i argc; i) { std::string a argv[i]; if (a --ai) { opt.vsAI true; // 切到人机模式 } else if (a --white) { opt.humanColor WHITE; // 玩家执白AI 先手 } else if (a --depth i 1 argc) { opt.depth std::stoi(argv[i]); // 深度越大越慢超过 8 要谨慎 } } Game g(opt); g.run(); // 主循环直到某一方五连或玩家退出 return 0; }这段的作用就是把“怎么玩”从代码里挪到命令行上避免为了换模式去改源码重新编译。参数含义需要说清楚--depth是搜索树层数每加 2 层耗时大约涨一个数量级--white只影响谁先手不改变 AI 的评估逻辑没有--ai时整个 AI 模块根本不会被调用所以人人对战不会有一丝额外开销。2.2 棋盘的内存表示为什么用定长数组而不是 vector棋盘状态的表示是这套代码里最容易做错、也最容易做对的地方。我见过不少人上来就写vectorvectorint理由是“灵活”结果每走一步都要考虑越界、扩容、传引用还是传值递归搜索时还容易踩到深拷贝的性能坑。五子棋棋盘永远是 15×15这个尺寸在可见的将来不会变用std::array定长二维数组是最省心的选择内存连续、拷贝便宜、grid[r][c]直接寻址递归时按值传一份也就 900 字节。// src/board.h #pragma once #include array #include vector constexpr int N 15; // 15 路棋盘 constexpr int EMPTY 0; constexpr int BLACK 1; // 先手 constexpr int WHITE 2; struct Point { int r -1, c -1; bool valid() const { return r 0 c 0; } }; class Board { public: Board() { reset(); } void reset(); // 全部清成 EMPTY bool inRange(int r, int c) const { return r 0 r N c 0 c N; } bool place(int r, int c, int color); // 落子成功返回 true void undo(int r, int c) { grid_[r][c] EMPTY; } bool isWin(int r, int c, int color) const; // 以落点为中心判五连 int at(int r, int c) const { return grid_[r][c]; } const std::arraystd::arrayint, N, N grid() const { return grid_; } private: std::arraystd::arrayint, N, N grid_; };grid_里存的是 0、1、2不用enum class是因为这套代码大量在数组下标和颜色之间来回比较强类型枚举反而会逼你写一堆static_cast。place里只做两件事范围检查 空位检查都通过才写入返回值直接告诉调用方这步有没有走成功——别小看这个 bool人机对战里 AI 递归搜索时会频繁落子悔子一旦某次落子被静默忽略整个搜索结果的棋力就会莫名其妙地掉下来这类 bug 很难靠肉眼看出来。2.3 控制台渲染与坐标输入从 H8 到 grid[7][7]界面层做两件事把棋盘打印成人能看的样子把H8这种坐标翻译成数组下标。这里有个小设计决定值得说行用数字 1~15列用字母 A~O和围棋棋谱的习惯一致用户输入H8时心理负担最小。渲染用X和O而不是圆点符号原因在避坑章节会讲——Windows 控制台默认代码页对全角字符的宽度处理很坑setw会算错列宽棋盘直接歪掉。// src/board.cpp #include iostream #include iomanip #include board.h void Board::print(std::ostream os) const { os ; for (int c 0; c N; c) os char(A c) ; os \n; for (int r 0; r N; r) { os std::setw(2) (r 1) ; for (int c 0; c N; c) { char ch grid_[r][c] BLACK ? X : grid_[r][c] WHITE ? O : .; os ch ; } os \n; } } // 把 H8 解析成 (r7, c7)失败返回 {-1,-1} Point parseCoord(const std::string s) { if (s.size() 2 || s.size() 3) return {}; int col std::toupper(s[0]) - A; int row std::stoi(s.substr(1)) - 1; // stoi 会抛异常由调用方兜住 if (col 0 || col N || row 0 || row N) return {}; return {row, col}; }print用std::ostream而不是直接std::cout是为了后面能一行改成写文件做残局回放和回归测试时特别方便。parseCoord里的std::stoi会抛std::invalid_argument所以调用方必须包一层try/catch否则用户手滑敲个H进去程序直接终止——这是个很典型的“demo 能跑、给人用就崩”的细节。3. 胜负判定与落子合法性一个五连检测的完整实现规则层是这套代码里最值得逐行读的部分因为它的正确性直接决定了 AI 强不强——判胜写错AI 就会漏杀和误判。核心思路只有一条不需要每次都扫全盘只看刚刚落下的那颗子从它出发向四个方向延伸任意方向连成 5 个及以上同色棋子就是赢。复杂度是常数级的最多看 4 个方向 × 2 侧 × 4 格 32 次比较随便调。3.1 胜负判定以落点为中心的四个方向扫描四个方向分别是横、竖、主对角线右下、副对角线右上每个方向向正负两侧各走一遍把同色计数加起来。这个写法比“从棋盘边缘整行扫描”简洁得多也不会漏掉跨行边界的五连。// src/board.cpp bool Board::isWin(int r, int c, int color) const { // 四个方向横、竖、主对角、副对角 static const int dir[4][2] {{0, 1}, {1, 0}, {1, 1}, {1, -1}}; for (const auto d : dir) { int cnt 1; // 落点自身算一个 for (int s -1; s 1; s 2) { // s-1 向负方向s1 向正方向 int nr r d[0] * s; int nc c d[1] * s; while (inRange(nr, nc) grid_[nr][nc] color) { cnt; nr d[0] * s; nc d[1] * s; } } if (cnt 5) return true; // 长连也算赢无禁手规则 } return false; }逻辑说明s循环两次第一次往一个方向推到底第二次往反方向推到底两次的计数加上落点本身就是这个方向上连续同色棋子的总数。inRange保证不会越界读写while的退出条件同时判断了“出界”和“颜色不同”。参数说明(r, c)必须是刚落下且已经写入grid_的位置传旧位置会得到毫无意义的结论color必须和该位置上的棋子颜色一致。判断条件用 5而不是 5是因为这套实现走的是无禁手规则六连七连同样算赢。如果要支持标准竞技规则里的黑棋禁手三三、四四、长连就得在这里把黑棋的cnt 5拆成cnt 5单独处理并且在落子前额外跑一遍禁手检测那是另一套工作量代码里留了// TODO: 禁手的注释位。3.2 悔棋栈与落子合法性一颗子怎么进怎么出悔棋看着简单实际上是这套代码里最容易出隐蔽 bug 的地方。我的做法是把历史记录和棋盘状态绑在一个结构里落子时同时压栈悔棋时同时出栈绝不允许出现“棋盘上还有子、历史里没有记录”的不一致状态。// src/board.h struct Move { Point p; int color; }; class Board { public: bool place(int r, int c, int color) { if (!inRange(r, c) || grid_[r][c] ! EMPTY) return false; grid_[r][c] color; history_.push_back({{r, c}, color}); // 落子和入栈必须同时发生 return true; } bool undoLast() { if (history_.empty()) return false; const Move m history_.back(); grid_[m.p.r][m.p.c] EMPTY; // 先擦棋盘 history_.pop_back(); // 再弹栈顺序不能反 return true; } const std::vectorMove history() const { return history_; } private: std::vectorMove history_; };place里的两个检查是唯一的落子入口任何绕过它直接改grid_的代码都是给自己埋雷。undoLast里先擦棋盘再弹栈看起来顺序无所谓但如果哪天你把m改成引用并在pop_back之后使用那就是典型的悬垂引用——这种坑我在别的项目里踩过一次报错信息完全指不到真正的位置。人机对战模式下玩家点“悔棋”要连退两步AI 一步 自己一步所以Game层的悔棋封装要判断当前模式这个细节在下一节的主循环里体现。3.3 人人对战的主循环回合、终局与重开主循环负责把所有零件串起来打印棋盘、读坐标、落子、判胜、切换回合。人人对战版本是最干净的一版人机模式只是把“读人类输入”换成“调 AI 思考”其余流程完全复用这也是我坚持把Game和AI拆开的原因。// src/game.cpp节选人人对战分支 void Game::run() { board_.reset(); int cur BLACK; while (true) { board_.print(std::cout); std::cout (cur BLACK ? 黑方 : 白方) 落子如 H8u悔棋q退出; std::string cmd; if (!(std::cin cmd)) break; // EOF 或流错误直接结束 if (cmd q) break; if (cmd u) { board_.undoLast(); cur other(cur); // 悔一步就等于把回合还回去 continue; } Point p; try { p parseCoord(cmd); } catch (...) { std::cout 坐标格式不对示例H8\n; continue; } if (!board_.place(p.r, p.c, cur)) { std::cout 这个位置不能落子换一个\n; continue; } if (board_.isWin(p.r, p.c, cur)) { board_.print(std::cout); std::cout (cur BLACK ? 黑方 : 白方) 五连获胜\n; break; } cur other(cur); } }other(cur)是个两行的小工具函数BLACK返回WHITE反之亦然。悔棋那一段要特别注意cur other(cur)很多人写悔棋只擦棋盘不还回合结果悔一步之后还是同一个人走连点两次悔棋就乱套了。终局处理这里只做了打印和 break实际项目里我一般会问一句“再来一局”用外层while包住棋盘reset()之后历史记录也要一起清空否则新一局第一次悔棋会退到上一局的棋子上这个坑后面还会提。4. 人机对战核心Alpha-Beta 剪枝配棋型评估AI 部分是这套源码真正的技术含量所在也是二次开发最集中的地方。它的骨架只有三层评估函数给局面打分候选点生成把搜索宽度砍下来Alpha-Beta 在深度受限的树里找最优解。三层里任何一层写歪棋力都会立刻掉一个档次——这也是为什么我建议先跑通人人对战确认规则层没问题再去调 AI不然你根本分不清是搜索错了还是判胜错了。4.1 棋型评分表活四、冲四、活三怎么量化评估函数的本质是把“这盘棋我占优还是劣势”变成一个数字。做法是把棋盘上所有可能成五的线15 行 15 列 两个方向各 21 条斜线抽出来逐条转成字符串再用一张棋型表去匹配计分。字符编码是1表示我方、2表示对方、0表示空位这样一条线就是形如001101100的串匹配起来直观。// src/eval.cpp struct Pattern { const char* pat; // 1我方 2对方 0空 int score; }; // 分值量级要拉开成五必须远大于所有棋型分之和 // 否则 AI 会为了凑一个活四而放着一步成五不走 static const Pattern kPatterns[] { {11111, 1000000}, // 成五 {011110, 100000}, // 活四两头都能成五 {011112, 12000}, {211110, 12000}, // 冲四只有一头 {11011, 12000}, {10111, 12000}, {11101, 12000}, {01110, 8000}, // 活三 {010110, 7000}, {011010, 7000}, // 跳活三 {001112, 800}, {211100, 800}, // 眠三 {01100, 500}, {00110, 500}, // 活二 }; int scoreLine(const std::string line) { int total 0; for (const auto p : kPatterns) { size_t pos line.find(p.pat); while (pos ! std::string::npos) { total p.score; pos line.find(p.pat, pos 1); // 从下一位继续找允许重叠 } } return total; }逻辑说明find循环用pos 1而不是pos strlen(pat)是为了让长连能重复计分比如0111110里会同时命中多个成五模式这正好符合“长连更危险”的直觉。参数说明每个分值都是可调的旋钮改这里就是改棋风。活四 10 万、冲四 1.2 万比例大约 8:1意味着 AI 认为一个活四抵得上八个冲四实战里这个比例偏高一点会更稳活三 8000 和活二 500 之间的断层是故意的逼 AI 优先处理三而不是慢悠悠发展二。整盘评估就是把我方所有线得分加起来减去对方得分对方系数乘 1.1 让 AI 稍微偏防守int evaluate(const Board b, int me) { int mine 0, opp 0; for (const std::string line : b.allLines()) { // 行、列、两个斜向 mine scoreLine(toMine(line, me)); // 我方视角我1对手2 opp scoreLine(toMine(line, other(me))); // 对手视角对手1我2 } return mine - opp * 11 / 10; // 整数运算避免浮点误差 }allLines()的实现就是把四种方向的线抽出来拼成std::vectorstd::string长度小于 5 的斜线直接跳过没有别的心机。这套“逐线扫描”的写法比“逐个子周围取 9 格窗口”要慢一些但不会重复计分调参的时候结果更可预期我一般先用它把棋力调顺再考虑要不要换成更快的窗口法。4.2 候选点生成与排序把 225 个格子砍到 12 个15×15 一共 225 个空点如果每层都全展开深度 4 就是 225⁴ ≈ 25 亿个节点跑一晚上也出不来。实际做法是只考虑“已有棋子周围两格以内”的空点——五子棋的任何有效着法都不可能离现有棋子太远这一条就把宽度砍到几十个再按启发式排序取前 12 个深度 4 的分支数就变成 12⁴ ≈ 2 万一两百毫秒内能出结果。// src/ai.cpp std::vectorPoint AI::candidates(const Board b, int color, size_t limit) { std::vectorstd::pairint, Point scored; for (int r 0; r N; r) { for (int c 0; c N; c) { if (b.at(r, c) ! EMPTY) continue; if (!hasNeighbor(b, r, c, 2)) continue; // 距离现有棋子 2 格以外的点直接丢 // 启发式打分在这落子后我方这条线的分数涨了多少 int gain localScore(b, r, c, color); scored.push_back({gain, {r, c}}); } } // 分高的排前面好的着法先搜剪枝效率高一个量级 std::sort(scored.begin(), scored.end(), [](const auto a, const auto b2) { return a.first b2.first; }); std::vectorPoint out; for (size_t i 0; i scored.size() out.size() limit; i) out.push_back(scored[i].second); return out; }逻辑说明hasNeighbor用切比雪夫距离判断半径 2 是经验值取 1 会漏掉一些远处的关键跳点取 3 以上宽度暴涨但棋力提升很小。参数说明limit就是搜索宽度默认 12。这个数字和深度是一对矛盾——深度 6 配宽度 8 往往比深度 4 配宽度 16 更强因为深一层能看到对手的应手而宽度多几个候选点只是多几个平庸选择。我自己的配置是深度 6 时宽度压到 8深度 4 时宽度放到 14。排序那一步不能省Alpha-Beta 的剪枝量高度依赖着法顺序随手排序和启发式排序的效率差能到三倍以上这属于血泪经验。4.3 Alpha-Beta 搜索与必胜必防的短路判断到了搜索本体逻辑反而简单递归展开、我方取最大、对方取最小、越界就剪。真正让 AI 下得像人的是两个前置短路——先看看自己有没有一步成五有就直接下再看看对手有没有一步成五有就直接堵。这两个判断几十行代码能挡掉 90% 以上的低级失误。// src/ai.cpp int AI::search(Board b, int depth, int alpha, int beta, int color, Point best) { if (depth 0) return evaluate(b, me_); // 到达深度上限静态评估 std::vectorPoint moves candidates(b, color, widthFor(depth)); if (moves.empty()) return evaluate(b, me_); const bool maximizing (color me_); int bestScore maximizing ? INT_MIN : INT_MAX; for (const Point p : moves) { b.place(p.r, p.c, color); // 落子即成五直接返回带层数的胜利分 // 层数越浅说明赢得越快AI 会优先选“最快赢”而不是“最晚赢” if (b.isWin(p.r, p.c, color)) { b.undo(p.r, p.c); int s WIN_SCORE - (MAX_DEPTH - depth); return maximizing ? s : -s; } Point dummy; int val search(b, depth - 1, alpha, beta, other(color), dummy); b.undo(p.r, p.c); if (maximizing) { if (val bestScore) { bestScore val; best p; } alpha std::max(alpha, val); } else { if (val bestScore) { bestScore val; best p; } beta std::min(beta, val); } if (beta alpha) break; // 剪枝这个分支对手不会让我走到 } return bestScore; } Point AI::think(Board b, int color) { // 一级短路自己能成五就成五 Point win findImmediateWin(b, color); if (win.valid()) return win; // 二级短路对方能成五就堵 Point block findImmediateWin(b, other(color)); if (block.valid()) return block; Point best{-1, -1}; search(b, depth_, INT_MIN 1, INT_MAX - 1, color, best); return best; }逻辑说明beta alpha那一行是整个函数存在的意义它表示当前节点已经不可能影响祖先的决策直接放弃剩余分支。WIN_SCORE - (MAX_DEPTH - depth)这个写法是为了让 AI 在多个必胜路线里挑最短的那条否则它可能先凑一堆活三再慢慢赢观感上像在戏耍玩家。参数说明alpha和beta初始给INT_MIN 1/INT_MAX - 1而不是极值本身是为了避免和胜利分相减时溢出。me_是 AI 自己的颜色在构造函数里定死整局不变如果写成参数传进来递归时很容易传错。实测在普通笔记本上深度 6、宽度 8 的单步耗时大约 0.3~1 秒深度 8 就要 3 秒以上所以--depth超过 8 时我一般会配合迭代加深先算深度 4 拿个保底着法再往深里搜。5. 避坑与常见问题跑不起来、AI 不下棋、思考像卡死这套代码我前前后后给别人装过七八次新手卡住的地方高度集中基本都是环境、输入、参数三类的变种。把这几条按“现象 → 原因 → 解决”记下来能省掉大量在群里发截图的时间。5.1 编译与环境三分钟能解决的三个报错现象一error: array is not a member of std或者nullptr报未定义。原因基本只有一种编译时没带-stdc17默认按 C98 编array和std::array自然找不到。解决是把编译命令补全g -stdc17 -O2 -Wall -o gomoku src/*.cpp。MSVC 用户注意VS2017 之后默认就支持 C17但项目属性里的“C 语言标准”如果是“默认”老版本会退到 C14手动改成/std:c17即可。现象二undefined reference to Board::isWin(int, int, int) const。原因通常是链接时漏了某个.cpp。用src/*.cpp通配能避免但如果你在 IDE 里新建文件后忘了加入编译列表就会出这个错。解决是检查编译列表里五个.cpp是否都在。顺带说一句board.h里的内联函数比如at、undo不受影响报错的永远是声明在头文件、实现在 cpp 里的那几个。现象三棋盘打印出来是乱码或者列全歪了。原因是控制台代码页和源文件编码不一致。源码存成 UTF-8Windows 控制台默认跑 GBK中文提示会变成问号如果你把棋子符号换成全角的圆点std::setw按字节算宽度全角字符占 3 字节却显示 2 列整个棋盘就错位。解决分两步一是源文件里的字符尽量只用 ASCII棋子用X/O/.中文提示可以留但别参与对齐二是非要用中文就在main开头调用SetConsoleOutputCP(65001)Windows并把控制台字体换成等宽字体。Linux 和 macOS 终端一般默认 UTF-8不会遇到这个问题。5.2 对局逻辑AI 不落子、悔棋错位、输入死循环现象四人机模式下轮到 AI 时程序直接跳过AI 一步都不走。原因在于search返回的Point是{-1, -1}而Game层没检查valid()就直接拿去placeplace因为越界返回 false循环继续看起来就像 AI 罢工。会返回无效点的典型情况是候选点列表为空——棋盘刚开局只有一个子半径 2 内明明有 24 个空点但如果hasNeighbor的半径参数被误写成 0就一个候选都没有。解决办法是双保险think返回前如果best无效就退化成一个随机合法空点Game层落子前一定要判p.valid()无效就打印一行日志再重试。这个日志别省它是唯一能告诉你“AI 到底有没有算”的黑匣子。现象五连点两次悔棋棋盘上的子数和历史记录对不上再走一步就崩。原因是Board::undoLast和Game层的回合切换没有原子化。人机模式下玩家悔棋应该退两步如果只退了一步 AI 的子回合却没切回来下一次 AI 思考时就会在“该对方走”的状态下继续落子越积越乱。解决是把“悔棋”封装成Game::undoForHuman()内部按模式决定退一步还是两步退完统一把cur设回人类颜色并且用一个断言检查board.history().size() 实际棋子数。这个断言在调试期非常值钱能第一时间抓住不一致。现象六输入一个字母程序开始疯狂刷屏或者直接卡死。原因是std::cin cmd读到非法输入后进入失败状态流里那个坏字符一直没被取走下一次读又立刻失败形成死循环。parseCoord里抛的异常如果没被 catch则是直接 terminate。解决很固定cin失败后必须std::cin.clear()清状态再std::cin.ignore(std::numeric_limitsstd::streamsize::max(), \n)把这一行剩下的内容丢掉parseCoord外面必须包try/catch。这两行是控制台程序的标准后悔药缺一不可。5.3 棋力与性能能赢不赢、思考时间失控现象七AI 明明有一步成五却跑去堵对手的活三。原因几乎都出在评估函数的分值量级上。如果你把成五分写成 10000而对手两个活四加起来能到 20 万那么evaluate返回的差值就会告诉 AI“堵这边更划算”。这类 bug 的表现特别有迷惑性看起来像搜索深度不够实际上是分数量级错了。解决是保证成五分 所有棋型分之和 × 2现在这张表里成五 100 万其余加起来不到 20 万留了足够余量。另一个保底手段就是 4.3 节里的findImmediateWin短路它在搜索之前就把必胜和必防处理掉了等于给评估函数兜了个底。现象八--depth 8之后每步要等十几秒玩家以为程序挂了。原因是搜索宽度没有随深度收缩12⁸ 这个量级再怎么剪枝也扛不住而且候选点排序在深层递归里被反复调用std::sort的开销会滚雪球。解决办法有三条按性价比排序一是让宽度随剩余深度递减浅层给 12、深层给 6widthFor(depth)就是干这个的二是加迭代加深先算深度 4 立刻下一步保底棋再在后台往深里算超时就用手上的结果三是把evaluate从“逐线扫全盘”换成增量更新——落子时只重算受影响的 4 条线这一条能把评估开销砍掉一个数量级但改动量也最大建议最后做。经验值是单步超过 1 秒就该动手了玩家的耐心比你想的短。6. 二次开发把评估函数变成可插拔的旋钮把这套代码从头到尾跑通之后最有价值的改造方向不是加功能而是把 AI 的评估函数抽成可注入的接口。原本AI::search里直接调全局的evaluate想对比两套棋型表就得改源码重编做 A/B 测试极其难受。改成std::function注入之后同一份搜索代码能挂不同的评估器调参从“改代码—编译—跑十盘”变成“换个 lambda 跑十盘”。// src/ai.h #include functional // 评估器签名给一个局面和我的颜色返回分数正数对我有利 using Evaluator std::functionint(const Board, int me); class AI { public: AI(int depth, Evaluator ev) : depth_(depth), ev_(std::move(ev)) {} Point think(Board b, int color); private: int depth_; Evaluator ev_; // 搜索过程中只读不修改 // ... search 内部把 evaluate(b, me_) 换成 ev_(b, me_) };注入点就一处search里的evaluate(b, me_)全部替换成ev_(b, me_)。默认评估器直接复用 4.1 节的实现包成一个 lambda 传进去即可。真正需要验证的是“换评估器之后棋力有没有真的变化”我一般的做法是写一组固定残局做回归测试把棋盘状态用字符串表示断言 AI 在给定深度下的落点// tests/ai_regression.cpp struct Case { const char* board; // 15 行每行 15 字符X黑 O白 .空 int me; // AI 执哪一方 int expectR, expectC; }; // 黑棋有活四AI 执黑必须走成五不能去堵白棋的活三 static const Case kCases[] { {............... ............... ....XXX........ ....OOO........ ..............., BLACK, 2, 6}, // 举例把子落在活四的延长线上 }; int main() { for (const Case c : kCases) { Board b Board::fromString(c.board); AI ai(4, defaultEvaluator); Point p ai.think(b, c.me); if (p.r ! c.expectR || p.c ! c.expectC) return 1; // 落点不一致即回归失败 } return 0; }这类测试的价值在于它把“棋力”这个模糊的东西变成了可断言的落点。每次动评估表、动剪枝、动候选点半径先跑一遍这组用例能挡住绝大部分“改完感觉变弱了但说不清哪里弱”的情况。Board::fromString这个辅助函数需要自己写十几行顺便还能拿来做残局回放——把一盘棋的历史记录存成字符串数组回放到某个局面再让 AI 接手调试特定局面时非常好用。同一套注入思路还能继续往外扩想加开局库就在think的最前面查一张哈希表命中就直接返回想加置换表就给Board加一个 Zobrist 哈希在search入口查表剪枝想换图形界面只需要把Board::print和parseCoord换成 Qt 或 SDL 的绘制与鼠标事件Game和AI一行都不用动。这也是我从这个项目里得到的最实在的一条习惯只要一个模块的输入输出是纯数据的它就不该知道外面是控制台还是窗口。最后说个教训。我最早给这套代码加置换表的时候为了省事直接拿棋盘数组算哈希结果两个不同局面偶尔撞到同一个值AI 会突然下一手莫名其妙的棋而且只在长对局后半段出现查了整整两个晚上。从那以后我每次动搜索相关的代码都强制先跑一遍那组残局回归用例再开一局真人对局看观感——机器能判断的和人眼能判断的缺一不可。希望这套拆解能帮你少走一遍我踩过的路把这盘棋改成你自己的版本。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询