资讯动态

C++五子棋AI源码实战:极小极大搜索与禁手规则实现

发布时间:2026/10/14 18:13:58 来源:尧图企业网站定制
简介这是一份基于C实现的五子棋游戏源码面向具备一定C基础、希望深入理解棋类AI与规则实现的开发者。项目支持人机对战与人人对战电脑AI具备一定智能水平并完整实现了先手禁手规则包括三三禁手、四四禁手以及长连判负适合作为算法设计与面向对象编程的练手项目。压缩包共22个文件约681KB以cpp源文件、h头文件为主辅以obj、pdb、ilk等编译中间文件及exe可执行程序另含dsw、dsp等工程配置覆盖源码、调试与运行所需。资源已有1335人学习下载。通过研读可掌握棋盘二维数组建模、合法落子判断、基于Minimax与Alpha-Beta剪枝的AI决策、评价函数设计以及禁手检查逻辑同时了解图形界面事件处理、异常调试与工程编译部署思路为游戏开发与算法实践打下基础。1. 从一份 C 五子棋源码说起AI 与禁手规则到底怎么落地前阵子有个做嵌入式的朋友找我说想找个带 AI 的五子棋源码练手要求很具体C 写的、电脑会自己下、还得支持先手禁手。他之前翻到过几个版本要么 AI 就是随机落子要么禁手规则只写在注释里根本没实现跑起来该禁的不禁对局直接失真。这份 C 五子棋源码正好把这几块都补齐了——棋盘逻辑、胜负判定、基于棋型评分的 AI 搜索、以及先手禁手判定是一个能编译、能对弈、能改参数的完整工程。它适合两类人一类是想拿它当 C 课程设计或练手项目的逻辑分层清楚改起来不费劲另一类是想研究棋类 AI 评估函数和极小极大搜索的源码里搜索深度、评分权重都是可调参数能直接做对照实验。下面我按「先跑起来、再看 AI 怎么想、最后说禁手和坑」的顺序拆一遍参数和边界都会给到。2. 编译与棋盘数据结构先把工程跑起来2.1 目录结构与编译方式拿到源码包后常见做法是先看目录再动手。典型结构大致是这样gomoku/ ├── include/ │ ├── board.h // 棋盘类与胜负判定声明 │ ├── ai.h // AI 搜索与评估接口 │ └── rule.h // 禁手规则判定 ├── src/ │ ├── board.cpp │ ├── ai.cpp │ ├── rule.cpp │ └── main.cpp // 入口控制台对弈循环 └── Makefile如果包里带 Makefile直接make就行没有的话用 g 手动编# 在源码根目录执行-O2 对 AI 搜索速度影响明显 g -stdc17 -O2 -Iinclude src/*.cpp -o gomoku # 运行默认玩家先手 ./gomoku这里有两个参数值得注意。-stdc17是因为部分实现用了std::optional或结构化绑定降到 c11 可能编不过-O2不是可选项AI 搜索是纯计算密集型不开优化在中低端机器上每步要等好几秒开了之后通常能压到一秒内。如果编译报「undefined reference」八成是src/*.cpp没匹配全手动把每个 cpp 列出来再编一次。2.2 棋盘用一维数组还是二维数组棋盘表示看着简单其实直接影响 AI 的搜索效率。这份源码用的是定长二维数组常见写法是 15×15// board.h constexpr int BOARD_SIZE 15; constexpr int EMPTY 0; constexpr int BLACK 1; // 先手 constexpr int WHITE 2; // 后手 class Board { public: int grid[BOARD_SIZE][BOARD_SIZE]; Board() { clear(); } void clear(); bool place(int x, int y, int color); // 落子越界/占用返回 false bool isWin(int x, int y, int color) const; // 只检查最后落子点 bool isFull() const; };为什么胜负判定只查最后落子点因为五子连珠必然经过刚下的那颗子全盘扫描是浪费。isWin的标准做法是沿四个方向横、竖、两条斜线各向两侧延伸计数任一方向达到 5 就赢bool Board::isWin(int x, int y, int color) const { // 四个方向右、下、右下、左下 const int dx[4] {1, 0, 1, -1}; const int dy[4] {0, 1, 1, 1}; for (int d 0; d 4; d) { int cnt 1; // 正方向延伸 for (int s 1; s 5; s) { int nx x dx[d] * s, ny y dy[d] * s; if (nx 0 || nx BOARD_SIZE || ny 0 || ny BOARD_SIZE) break; if (grid[nx][ny] ! color) break; cnt; } // 反方向延伸 for (int s 1; s 5; s) { int nx x - dx[d] * s, ny y - dy[d] * s; if (nx 0 || nx BOARD_SIZE || ny 0 || ny BOARD_SIZE) break; if (grid[nx][ny] ! color) break; cnt; } if (cnt 5) return true; } return false; }参数说明dx/dy四个方向覆盖了所有五连可能不用写八个方向因为正反延伸已经对称处理。cnt 5而不是 5是为了兼容长连六子及以上——标准五子棋里长连是否算赢要看规则禁手规则下黑棋长连反而是禁手这点在第 4 章会展开。提示如果你把BOARD_SIZE改成 19 或 13记得同步检查 AI 里的搜索范围和评分表有些实现把边界写死成 15改尺寸会越界。2.3 落子合法性检查别漏了「已占用」place函数看着平平无奇但它是 AI 搜索里调用最频繁的函数之一写不严谨会直接导致 AI 走出「覆盖已有棋子」的鬼棋bool Board::place(int x, int y, int color) { if (x 0 || x BOARD_SIZE || y 0 || y BOARD_SIZE) return false; if (grid[x][y] ! EMPTY) return false; // 关键占用检查 grid[x][y] color; return true; }血泪经验是AI 在递归搜索时会先落子再撤销如果place没做占用检查搜索树里会出现同一格被反复覆盖评估分数全乱。撤销操作对应地把grid[x][y]置回EMPTY即可但一定要保证落子和撤销成对出现否则棋盘状态会「漏子」。3. AI 是怎么想的棋型评分与极小极大搜索3.1 评估函数把棋型翻译成分数AI 的核心不是「聪明」而是「会打分」。这份源码的评估思路是扫描棋盘上每个空点统计该点周围四个方向的棋型再按棋型给分。常见棋型和对应分值大致如下棋型说明常见分值成五已有四子落子即五连100000活四两端开放的四连10000冲四一端被封的四连1000活三两端开放的三连1000眠三一端被封的三连100活二两端开放的二连100眠二一端被封的二连10分值不是随便定的原则是「成五 活四 冲四/活三 眠三/活二」。活四和冲四的分差要拉大因为活四几乎必胜冲四还能被挡。实际代码里通常用一个评分表数组按连子数和两端开放状态查表// ai.cpp 片段单方向棋型评分 int scoreLine(int count, bool openLeft, bool openRight) { if (count 5) return 100000; if (count 4) { if (openLeft openRight) return 10000; // 活四 if (openLeft || openRight) return 1000; // 冲四 return 0; } if (count 3) { if (openLeft openRight) return 1000; // 活三 if (openLeft || openRight) return 100; // 眠三 return 0; } if (count 2) { if (openLeft openRight) return 100; if (openLeft || openRight) return 10; return 0; } return 0; }参数说明count是某方向上连续同色棋子数openLeft/openRight表示两端是否为空。评估总分通常是「己方得分 - 对方得分 × 权重」对方权重一般取 0.81.0取太高 AI 会过于保守取太低会只顾进攻不防守。这个权重是调参时最该动的旋钮。3.2 极小极大搜索与 Alpha-Beta 剪枝光有评估函数还不够AI 得往前看几步。这份源码用的是极小极大Minimax配合 Alpha-Beta 剪枝搜索深度常见默认 4 层// ai.cpp 片段带剪枝的搜索 int minimax(Board b, int depth, int alpha, int beta, bool isMax) { if (depth 0) return evaluate(b); // 到达深度返回静态评估 int best isMax ? -INF : INF; for (auto mv : generateMoves(b)) { // 候选落子 b.place(mv.x, mv.y, isMax ? AI_COLOR : HUMAN_COLOR); int val minimax(b, depth - 1, alpha, beta, !isMax); b.undo(mv.x, mv.y); // 撤销恢复棋盘 if (isMax) { best std::max(best, val); alpha std::max(alpha, best); } else { best std::min(best, val); beta std::min(beta, best); } if (beta alpha) break; // 剪枝 } return best; }逻辑说明isMax为真表示轮到 AI取最大值为假表示轮到玩家取最小值假设玩家也走最优。alpha是当前能保证的最大下界beta是上界一旦beta alpha说明这条分支不可能影响最终决策直接剪掉。generateMoves是关键优化点——不要遍历全部 225 个空点只取已有棋子周围 12 格内的空点候选数能从两百多降到几十速度差好几倍。参数怎么调搜索深度每加 1耗时大约翻 35 倍。深度 4 在普通笔记本上每步约 0.31 秒深度 6 可能到 5 秒以上。如果嫌慢优先优化generateMoves的候选范围而不是降深度因为深度直接决定棋力。3.3 候选点排序让剪枝更狠Alpha-Beta 的效率极度依赖搜索顺序先搜好棋能剪掉更多分支。常见做法是先对候选点做一次快速评估排序把高分点排前面std::vectorMove generateMoves(Board b) { std::vectorMove moves; for (int x 0; x BOARD_SIZE; x) for (int y 0; y BOARD_SIZE; y) { if (b.grid[x][y] ! EMPTY) continue; if (!hasNeighbor(b, x, y, 2)) continue; // 周围 2 格内有子 moves.push_back({x, y, quickScore(b, x, y)}); } // 按启发分数降序好棋先搜 std::sort(moves.begin(), moves.end(), [](const Move a, const Move b){ return a.score b.score; }); return moves; }hasNeighbor的半径参数取 1 还是 2 是个权衡取 1 候选更少更快但可能漏掉跳活三这类稍远的点取 2 更稳但慢一些。我一般默认取 2只有在低配设备上才降到 1。quickScore不用做完整评估粗略统计该点四方向的连子数即可目的是排序不是精确打分。4. 先手禁手规则判定比想象中容易翻车4.1 禁手到底禁什么标准五子棋里黑棋先手有优势所以对黑棋设了禁手三三、四四、长连都判负。白棋没有禁手。这份源码的rule.h就是干这个的核心是判断某个落子点是否同时形成两个活三三三禁手、两个四四四禁手或者形成六子及以上长连。判定逻辑分两步先假设黑棋在该点落子然后分别统计四个方向上形成的「三」和「四」的数量// rule.cpp 片段禁手判定 bool isForbidden(Board b, int x, int y) { // 临时落子 b.grid[x][y] BLACK; int threeCount 0, fourCount 0; const int dx[4] {1, 0, 1, -1}; const int dy[4] {0, 1, 1, 1}; for (int d 0; d 4; d) { int cnt 1; bool openL false, openR false; // 正反方向统计连子与开放端略 // ... if (cnt 3 openL openR) threeCount; // 活三 if (cnt 4) fourCount; // 四 if (cnt 6) { b.grid[x][y] EMPTY; return true; } // 长连禁手 } b.grid[x][y] EMPTY; return (threeCount 2) || (fourCount 2); }参数说明threeCount 2对应三三禁手fourCount 2对应四四禁手。注意「四」的判定不区分活四冲四只要形成四就算一个。长连单独判cnt 6直接禁。4.2 禁手判定的三个常见错误第一个错误是忘了临时落子后要还原。上面代码里每个return前都得把grid[x][y]置回EMPTY漏一处棋盘就永久多一颗子后续全乱。第二个错误是活三判定太宽松把「眠三」也算进threeCount导致 AI 频繁误判禁手、该下的点不敢下。第三个错误是没考虑「四三」——一个四加一个活三不是禁手只有双三或双四才是很多新手实现会把任意两个「三或四」相加就判禁这是错的。注意禁手判定只在黑棋落子时调用白棋永远不判。如果你发现 AI 执白时也触发禁手检查调用处是不是漏了颜色判断。4.3 AI 搜索里怎么处理禁手AI 在搜索时如果生成一个黑棋禁手点必须把这个点从候选里剔除否则 AI 会「主动送死」。常见做法是在generateMoves之后、进入递归之前过滤for (auto mv : moves) { if (aiColor BLACK isForbidden(b, mv.x, mv.y)) continue; // 跳过禁手 // ... 正常搜索 }这里有个性能坑isForbidden内部有临时落子和四方向扫描如果在搜索树每个节点都调用开销不小。优化思路是只在候选点本身已经形成三或四的时候才做完整禁手判定其余点直接跳过能省掉大部分调用。5. 避坑与排查几个真会卡住人的问题5.1 AI 第一步想太久或直接卡死现象开局第一手 AI 思考超过十秒甚至无响应。原因通常是搜索深度设太高而开局棋盘空旷、候选点多剪枝效果差。解决给开局单独设浅深度比如前两手用深度 2或者限制候选点数量上限如只取启发分前 20 个。5.2 AI 明明能赢却去防守现象AI 有四连不冲反而去堵玩家的三。原因多半是评估函数里对方权重设得过高或者己方成五的分值没拉开。解决确认成五分值远大于其他棋型十万级对方权重降到 0.8 左右再试。5.3 禁手判定把正常好棋判成禁手现象黑棋一个明显的活三被 AI 拒绝。原因活三判定把边界外当成「开放端」或者把眠三误算成活三。解决检查开放端判定时是否正确处理了棋盘边界边界外应视为「被封」而非「开放」。5.4 编译通过但对弈时崩溃现象下到中盘程序闪退。原因数组越界常见于isWin或评估函数里方向延伸没做边界检查。解决所有x dx * s这类计算后都要先判0 nx BOARD_SIZE再访问数组别图省事。5.5 撤销落子后棋盘状态不对现象AI 搜索完棋盘上多了不该有的子。原因递归里place和undo没成对某个提前return分支漏了撤销。解决把落子/撤销包成一个 RAII 小对象或者用「先落子、算完立刻撤销」的固定写法别在中间插return。6. 进阶玩法把 AI 调强的一个具体技巧想让这份源码的 AI 明显变强最划算的改动不是加深度而是给评估函数加「位置权重」。棋盘中心和边角的战略价值完全不同中心点周围棋型更容易扩展成多方向威胁。做法是在静态评估里给每个棋子按位置加一个小的位置分// 位置权重表中心高、边角低15x15 int posWeight[BOARD_SIZE][BOARD_SIZE]; void initPosWeight() { int c BOARD_SIZE / 2; for (int x 0; x BOARD_SIZE; x) for (int y 0; y BOARD_SIZE; y) { int dist std::abs(x - c) std::abs(y - c); posWeight[x][y] std::max(0, 14 - dist); // 越靠中心分越高 } }然后在evaluate里对每个己方棋子累加posWeight对方棋子减去。这个位置分要设得比棋型分小一个量级比如棋型分是千级位置分是十级否则 AI 会为了占中心而放弃成五那就本末倒置了。调完之后怎么验证有没有变强我的习惯是固定一个测试集让改动前后的 AI 各执黑执白对弈 20 局记录胜负。如果新版胜率没有明显提升说明位置权重给太大或太小回去调量级。别凭感觉说「好像聪明了」对局数据才是后悔药。从那以后我每次改评估函数都强制先跑一遍这个 20 局对照确认胜率再合并代码。希望这份拆解帮到你拿到源码后先编译跑通再按上面的参数一点点调比一上来就大改要稳得多。本文还有配套的精品资源点击获取

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

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

免费获取报价 →
↑