资讯详情

资讯详情

五子棋AI禁手规则实现:从棋盘表示到搜索评估的完整指南

简介这是一份基于C实现的五子棋游戏源码面向具备一定C基础、希望深入理解博弈算法与规则实现的开发者。项目支持人机对战与人人对战电脑AI具备一定智能水平并完整实现了先手三三、四四禁手及长连判负规则适合作为算法设计与面向对象编程的实战练习素材。压缩包共22个文件约681KB以cpp源文件与h头文件为核心分别承载棋盘逻辑、AI决策与胜负判定等模块另含obj、pdb、ilk等编译中间文件及exe可执行程序方便直接运行与调试。资源已有1335人学习下载。通过研读源码读者可掌握二维数组表示棋盘、Minimax结合Alpha-Beta剪枝的搜索策略、评价函数设计以及禁手规则的检查逻辑同时了解图形界面事件处理与工程编译部署流程为后续游戏开发与算法进阶打下基础。1. 五子棋 AI 与禁手规则为什么你的第一个版本总在长连上翻车写一个能下五子棋的程序不难难的是让它下得像个懂规则的人。很多人第一次动手棋盘画好了、落子逻辑通了、甚至加了个基于评分的简单 AI结果一测就发现两个致命问题先手在特定局面下走出长连被判负AI 却浑然不觉或者 AI 只顾自己连五完全不管对手已经三三、四四成杀。这两个坑本质上都指向同一件事——你写的不是五子棋而是一个“连五就赢”的简化模型。带 AI 且先手禁手的五子棋源码核心要解决三件事棋盘状态的高效表示、禁手规则的准确判定、以及 AI 在规则约束下的搜索与评估。它适合已经会 C 基础语法、想通过一个完整项目打通“规则引擎 搜索算法 评估函数”这条链路的开发者。你不需要先成为棋类 AI 专家但需要愿意把禁手那套判定逻辑一行行抠清楚因为后面 AI 的每一步决策都建立在它之上。2. 棋盘表示与禁手判定先把规则引擎写对再谈 AI2.1 用一维数组还是二维数组选型背后的取舍棋盘表示看似简单但选错了后面会一直别扭。常见做法有两种二维数组int board[15][15]和一维数组int board[225]。二维数组直观调试时打印出来一眼就能看懂局面一维数组在做方向偏移和缓存友好性上更顺尤其是 AI 搜索时频繁访问相邻点位一维加偏移量的写法能少一层索引计算。我一般会选一维数组但保留一个二维打印函数用于调试。原因是禁手判定和 AI 评估都需要沿四个方向扫描一维数组配合方向偏移数组写起来更统一。下面是一个最小可用的棋盘定义#include array #include cstring constexpr int SIZE 15; constexpr int DIRS[4][2] {{0,1},{1,0},{1,1},{1,-1}}; // 横、竖、正斜、反斜 struct Board { std::arrayint, SIZE * SIZE cells{}; // 0空 1黑 2白 int moveCount 0; int at(int r, int c) { return cells[r * SIZE c]; } int at(int r, int c) const { return cells[r * SIZE c]; } bool inBounds(int r, int c) const { return r 0 r SIZE c 0 c SIZE; } };这段代码里DIRS的顺序很重要后面禁手判定和连子计数都依赖它。at提供两个版本const 版本给只读评估函数用非 const 版本给落子用。moveCount用来判断是否为空盘或开局阶段某些禁手判定在盘面棋子很少时可以跳过避免误判。2.2 禁手判定的三个核心三三、四四、长连先手禁手规则里黑棋不能走三三、四四和长连。白棋无禁手。判定顺序建议是先判长连再判四四最后判三三。因为长连优先级最高一旦形成长连直接判负不需要再算其他。长连判定最简单落子后沿四个方向数连续同色棋子任一方向达到 6 个及以上即为长连。注意是“连续”中间不能隔空。四四判定稍复杂落子后统计该点形成的“四”的数量。这里的“四”指的是再下一子就能成五的棋型。一个方向可能同时形成多个四比如活四算一个四但冲四也算一个四。如果总数大于等于 2就是四四禁手。三三判定最容易写错统计该点形成的“活三”数量。活三的定义是再下一子能变成活四的棋型。关键点在于有些三看起来是活三但因为边界或对方棋子阻挡实际上只能变成冲四这种不算活三。很多初版代码在这里翻车把眠三当成活三导致禁手误判。下面是一个方向扫描的辅助函数用来数连续同色棋子// 从(r,c)沿方向(dr,dc)数连续同色棋子不包括(r,c)本身 int countDir(const Board b, int r, int c, int dr, int dc, int color) { int cnt 0; int nr r dr, nc c dc; while (b.inBounds(nr, nc) b.at(nr, nc) color) { cnt; nr dr; nc dc; } return cnt; }这个函数是后面所有棋型识别的基础。参数color传当前落子方颜色dr/dc来自DIRS。注意它不检查起点本身因为起点是刚落的子调用方已经知道颜色。2.3 活三与眠三的区分一个容易写错的边界活三的严格定义落子后形成的三在两端至少有一端能继续延伸成活四。换句话说你需要检查这个三的两端是否被堵死。如果一端被对方棋子或边界堵住另一端虽然空但延伸后只能形成冲四那它就不是活三。实操中我一般写一个isOpenThree函数先找到这个三的两端空位然后模拟在空位落子看是否能形成活四。活四的定义是两端都空、再下一子能成五。这个模拟过程不需要真的修改棋盘可以用临时变量或复制一个小范围状态来判断。// 判断在(r,c)落color后沿(dr,dc)方向是否形成活三 bool isOpenThree(const Board b, int r, int c, int dr, int dc, int color) { // 简化版统计该方向连续子数及两端空位情况 int left countDir(b, r, c, -dr, -dc, color); int right countDir(b, r, c, dr, dc, color); int total left right 1; if (total ! 3) return false; // 检查两端是否至少有一端能延伸 int lr r - (left 1) * dr, lc c - (left 1) * dc; int rr r (right 1) * dr, rc c (right 1) * dc; bool leftOpen b.inBounds(lr, lc) b.at(lr, lc) 0; bool rightOpen b.inBounds(rr, rc) b.at(rr, rc) 0; return leftOpen || rightOpen; }这段代码是简化版真实判定还需要考虑“跳三”的情况即三中间有空位。完整实现会更长但思路一致先定位棋型再模拟延伸最后判断延伸结果是否满足活四条件。参数dr/dc决定扫描方向color是落子方颜色。注意total ! 3直接返回 false因为只有恰好三个连续子才可能是三。2.4 把禁手判定接进落子流程禁手判定不能只在 AI 里做玩家落子时也要做。常见做法是写一个isForbidden(Board b, int r, int c)函数返回该点对黑棋是否禁手。流程是先临时落子然后依次判长连、四四、三三最后撤销落子。撤销这一步很关键否则棋盘状态会被污染。bool isForbidden(Board b, int r, int c) { if (b.at(r, c) ! 0) return false; b.at(r, c) 1; // 假设黑棋 bool forbidden false; // 长连 for (auto d : DIRS) { if (countDir(b, r, c, d[0], d[1], 1) countDir(b, r, c, -d[0], -d[1], 1) 1 6) { forbidden true; break; } } // 四四和三三判定略逻辑类似统计数量 b.at(r, c) 0; // 撤销 return forbidden; }参数说明b是棋盘引用r/c是落子点。临时落子后必须撤销这是血泪经验早期我忘了撤销导致 AI 搜索时棋盘状态错乱表现就是 AI 突然下出莫名其妙的位置。DIRS四个方向都要扫不能只扫两个因为斜向也可能形成长连。3. AI 搜索与评估在禁手约束下找最优解3.1 极小化极大搜索的骨架与深度选择五子棋 AI 的经典做法是极小化极大搜索加 Alpha-Beta 剪枝。搜索深度一般设 4 到 6 层再深了在普通机器上会明显卡顿。深度 4 能覆盖两步攻防深度 6 能覆盖三步但分支因子大需要配合候选点筛选。候选点筛选是提速的关键不要对棋盘上所有空位都搜索只考虑已有棋子周围两格内的空位。这个范围能覆盖绝大多数有意义的落子同时把分支数从 200 多降到 20 到 40。std::vectorstd::pairint,int genMoves(const Board b) { std::vectorstd::pairint,int moves; std::arraybool, SIZE*SIZE used{}; for (int r 0; r SIZE; r) { for (int c 0; c SIZE; c) { if (b.at(r, c) 0) continue; for (int dr -2; dr 2; dr) { for (int dc -2; dc 2; dc) { int nr r dr, nc c dc; if (!b.inBounds(nr, nc) || b.at(nr, nc) ! 0) continue; int idx nr * SIZE nc; if (!used[idx]) { used[idx] true; moves.emplace_back(nr, nc); } } } } } return moves; }genMoves返回候选落子列表used数组去重。范围参数-2到2是经验值再大收益不明显再小可能漏掉关键防守点。如果盘面为空需要特判返回中心点。3.2 评估函数棋型分值怎么定才不玄学评估函数决定 AI 的“棋感”。常见做法是给不同棋型打分成五最高活四次之冲四再次活三、眠三、活二依次递减。防守分和进攻分要分开算最后加权求和。一个可用的分值表棋型分值成五100000活四10000冲四1000活三1000眠三100活二100眠二10注意活三和冲四分值接近因为两者都能迫使对方回应。实际调参时如果 AI 太激进就提高防守权重如果太保守就提高进攻权重。这个表不是固定的需要根据实测调整。int evaluate(const Board b, int color) { int score 0; for (int r 0; r SIZE; r) { for (int c 0; c SIZE; c) { if (b.at(r, c) ! color) continue; for (auto d : DIRS) { // 统计该点沿方向的棋型并累加分值 // 具体棋型识别略逻辑与禁手判定中的扫描类似 } } } return score; }评估函数里最容易翻车的是重复计分同一个棋型被多个起点重复统计。解决办法是只从棋型的起始点开始统计或者用方向上的“已访问”标记。我一般会在扫描时判断前一个位置是否同色如果是就跳过保证每个棋型只算一次。3.3 把禁手判定嵌进搜索AI 不能走出禁手AI 搜索时如果当前是黑棋且某候选点是禁手必须直接跳过不能让它进入评估。这一步要在生成候选点之后就过滤掉而不是等到评估阶段再扣分因为禁手是直接判负不是分值高低的问题。std::vectorstd::pairint,int legalMoves(Board b, int color) { auto moves genMoves(b); if (color ! 1) return moves; // 白棋无禁手 std::vectorstd::pairint,int filtered; for (auto m : moves) { if (!isForbidden(b, m.first, m.second)) { filtered.push_back(m); } } return filtered; }legalMoves对黑棋过滤禁手点白棋直接返回。注意isForbidden内部会临时落子再撤销所以传入的b必须是非 const 引用。这个函数在搜索树的每一层都会被调用性能敏感可以考虑缓存禁手判定结果但初版先保证正确性。4. 避坑与排查禁手和 AI 联调时最容易翻车的五个点4.1 禁手误判活三被当成眠三AI 疯狂走禁手现象AI 执黑时频繁走出三三或四四被判定禁手后直接判负。原因活三判定函数没有正确处理跳三和边界情况把一些实际是活三的棋型判成了眠三导致禁手漏判。解决写一组固定的测试局面每个局面手动标注是否为禁手跑单元测试。重点覆盖跳三、边界三、以及被对方棋子部分阻挡的三。4.2 搜索深度增加后 AI 变慢候选点没有剪枝现象深度从 4 调到 6 后AI 思考时间从不到一秒涨到十几秒。原因候选点生成没有限制范围或者 Alpha-Beta 剪枝没有正确实现导致搜索树爆炸。解决确认genMoves只返回已有棋子周围两格内的空位检查 Alpha-Beta 的剪枝条件确保在 beta alpha 时及时返回。另外可以加一个简单的迭代加深先搜浅层再搜深层配合时间限制。4.3 评估函数正负号搞反AI 帮对手下棋现象AI 不仅不堵对手的活三反而在自己的活三旁边下子看起来像在帮对手。原因评估函数里进攻分和防守分的符号搞反了或者极小化极大搜索里层和层的颜色切换逻辑写错。解决打印搜索树前几层的分值确认当前层是最大化还是最小化。一个简单验证方法让 AI 执黑对随机走子的白棋如果 AI 能稳定赢说明评估方向基本正确。4.4 长连判定漏掉斜向只扫了横竖现象黑棋在斜向形成六连但程序没有判负。原因长连判定只遍历了DIRS的前两个方向漏掉了两个斜向。解决确认DIRS四个方向都参与长连扫描。这个坑很隐蔽因为横竖长连容易被测到斜向容易被忽略。建议写一个专门测试斜向长连的局面。4.5 临时落子后忘记撤销棋盘状态污染现象AI 搜索几层后棋盘上出现不该有的棋子或者某些空位变成非空。原因禁手判定或评估函数里临时落子后没有撤销导致状态泄漏。解决所有临时修改棋盘的操作必须成对出现落子和撤销。可以用 RAII 封装一个TempMove类构造时落子析构时撤销避免手动遗漏。struct TempMove { Board b; int r, c, old; TempMove(Board board, int row, int col, int val) : b(board), r(row), c(col), old(board.at(row, col)) { b.at(r, c) val; } ~TempMove() { b.at(r, c) old; } };这个类构造时保存旧值并落子析构时恢复。用它替代手动落子撤销能大幅减少状态污染问题。注意拷贝构造要禁用避免意外复制导致多次撤销。5. 进阶技巧用置换表和历史启发让 AI 快起来5.1 置换表避免重复搜索相同局面搜索树里很多局面会重复出现尤其是不同落子顺序到达同一盘面。置换表用哈希值缓存已搜索过的局面及其分值下次遇到相同局面直接查表。五子棋的 Zobrist 哈希实现简单给每个位置每种颜色分配一个随机数落子时异或撤销时再异或回来。#include random #include unordered_map uint64_t zobrist[SIZE][SIZE][3]; std::unordered_mapuint64_t, int transTable; void initZobrist() { std::mt19937_64 rng(12345); for (int r 0; r SIZE; r) for (int c 0; c SIZE; c) for (int k 0; k 3; k) zobrist[r][c][k] rng(); } uint64_t hashBoard(const Board b) { uint64_t h 0; for (int r 0; r SIZE; r) for (int c 0; c SIZE; c) h ^ zobrist[r][c][b.at(r, c)]; return h; }initZobrist在程序启动时调用一次hashBoard计算当前盘面哈希。实际使用时落子后增量更新哈希比全量计算快得多落子时异或旧状态和新状态。置换表里存深度和分值查表时如果缓存深度大于等于当前剩余深度直接返回。5.2 历史启发让好棋先被搜到Alpha-Beta 剪枝的效率高度依赖候选点的顺序。如果先搜好棋剪枝会更早发生。历史启发维护一个表记录每个位置历史上导致剪枝的次数搜索时按次数从高到低排序候选点。int history[SIZE][SIZE] {0}; void sortMoves(std::vectorstd::pairint,int moves) { std::sort(moves.begin(), moves.end(), [](auto a, auto b) { return history[a.first][a.second] history[b.first][b.second]; }); }每次某个点导致剪枝就增加它的历史分值。注意历史分值会随时间累积可以定期衰减避免旧数据主导排序。这个技巧实现简单但对搜索速度提升明显尤其是深度 6 以上时。5.3 一个验证 AI 是否真的懂禁手的小测试写完禁手和 AI 后怎么确认 AI 真的在规避禁手而不是碰巧没走到我一般会构造一个局面黑棋如果走某个点会形成三三禁手但那个点看起来能形成活三对 AI 很有吸引力。如果 AI 正确避开说明禁手过滤生效。再构造一个局面黑棋走某点形成四四禁手但能立刻成五看 AI 是否因为禁手而放弃。这两个测试能覆盖大部分禁手联调问题。另外可以写一个自动对弈脚本让 AI 执黑对随机白棋跑 100 局统计禁手判负次数。如果禁手判负次数为 0说明过滤逻辑基本可靠如果出现禁手判负打印局面复盘定位是哪个判定环节漏了。我自己的习惯是每次改完禁手判定或评估函数先跑这组测试再跑完整对弈。早期偷懒跳过测试结果在一次评估函数调整后AI 开始频繁走禁手排查了半天才发现是活三判定里一个边界条件写反了。希望帮到你。本文还有配套的精品资源点击获取
觉得有用,分享给同行:

为您的企业打造数字门面

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

立即咨询 →