资讯详情

C++五子棋源码实战:人人对战与人机对战AI搜索优化

📅 2026/10/30 12:29:36 | 华诺云谱 👁 阅读
C++五子棋源码实战:人人对战与人机对战AI搜索优化
简介这是一份面向C初学者与游戏编程爱好者的五子棋实战源码同时支持人机对战与人人对战两种模式适合用来理解面向对象设计、AI决策与图形交互等核心概念。压缩包共7个文件约106KB包含3个cpp源码文件、3个可执行程序及1份说明文档源码按鼠标版、键盘版、人对人版、人对机版分别组织便于对照不同交互方式与对战逻辑的实现差异。已有180人学习下载。读者可从中掌握棋盘与棋子类的抽象方式、人机模式下基于规则或搜索的落子决策思路、玩家输入的捕获与校验流程以及界面渲染与点击事件响应机制。对于希望提升C编程技巧、了解游戏开发基本流程的开发者而言这份源码可作为动手分析与二次优化的实践素材。1. 从一份 C 五子棋源码说起人机对战和双人对战到底差在哪很多人第一次搜「C五子棋源码」心里想的其实是两件事一是想找一个能直接编译运行的完整工程二是想搞清楚人机对战那部分到底怎么写的。我当年也是这么入的坑下载了一堆源码结果发现大部分只做了人人对战棋盘画得挺漂亮一到 AI 落子就露馅——要么随机下要么只会堵活三连冲四都看不出来。所以这篇笔记我打算把这两条线都讲透人人对战怎么把规则和界面搭稳人机对战怎么从零写一个能打的评估函数以及这两套逻辑怎么共用同一个棋盘内核。先说清楚适用人群。如果你刚学完 C 基础语法想找一个不大不小、能一口气写完的项目练手五子棋是极好的选择它比贪吃蛇多一层博弈逻辑又比完整棋类引擎轻得多。如果你已经能写类和多态想理解「搜索 评估」这套 AI 骨架那五子棋同样合适因为它的状态空间小到可以在本地跑出肉眼可见的棋力提升。整篇内容我会按「棋盘内核 → 人人对战 → 人机对战 → 搜索优化 → 避坑 → 进阶」的顺序推进代码全部用标准 C不依赖任何图形库也能跑想加界面再自己套 Qt 或 EasyX 都行。2. 棋盘内核与胜负判定先把数据结构定死2.1 用一维数组还是二维数组存棋盘这是第一个要拍板的事。新手最容易随手写int board[15][15]写起来直观但后面做搜索、做哈希、做镜像对称优化时会很别扭。我一般用一维数组std::vectorint board长度 225索引idx y * 15 x。好处有三个一是缓存友好遍历时是一段连续内存二是做 Zobrist 哈希时直接按索引异或不用嵌套循环三是传给评估函数时只需要传一个指针加长度接口干净。棋盘上每个点的状态用三个值表示0 空、1 黑、2 白。不要用 char 存 X O也不要搞枚举类再转来转去搜索里每个节点都要读棋盘任何一次多余转换都会被放大成性能问题。#include vector #include cstdint constexpr int BOARD_SIZE 15; constexpr int BOARD_CELLS BOARD_SIZE * BOARD_SIZE; // 0 空 1 黑 2 白 using Board std::vectorint8_t; inline int toIndex(int x, int y) { return y * BOARD_SIZE x; } inline int toX(int idx) { return idx % BOARD_SIZE; } inline int toY(int idx) { return idx / BOARD_SIZE; } inline bool inBoard(int x, int y) { return x 0 x BOARD_SIZE y 0 y BOARD_SIZE; }这段代码没什么玄学但inBoard一定要写成内联因为胜负判定和评估函数里会调用几十万次。int8_t而不是int是为了让整个棋盘只占 225 字节能塞进 CPU 一级缓存搜索深度上去之后这个差别很明显。2.2 胜负判定的四个方向与边界处理五子棋判定连五只需要检查刚落子那个点的四个方向横、竖、主对角线、副对角线。每个方向往两边各延伸最多 4 格累计同色数量达到 5 就赢。这里有个血泪经验不要每次判定都从棋盘左上角全盘扫描那样人人对战还能忍人机对战里每走一步都全扫会直接拖垮搜索。bool checkWin(const Board b, int x, int y, int color) { static const int dx[4] {1, 0, 1, 1}; static const int dy[4] {0, 1, 1, -1}; for (int d 0; d 4; d) { int cnt 1; for (int step 1; step 4; step) { int nx x dx[d] * step, ny y dy[d] * step; if (!inBoard(nx, ny) || b[toIndex(nx, ny)] ! color) break; cnt; } for (int step 1; step 4; step) { int nx x - dx[d] * step, ny y - dy[d] * step; if (!inBoard(nx, ny) || b[toIndex(nx, ny)] ! color) break; cnt; } if (cnt 5) return true; } return false; }dx/dy四个方向覆盖了所有连五可能step从 1 到 4 是因为五子棋最多只需要看四格外的同色子。注意副对角线方向是(1, -1)写反了会导致斜向判定失效这种 bug 在界面上表现为「明明连了五个却不判赢」非常隐蔽。判定函数只认「刚落子点」所以调用时机必须是在落子之后立刻调用不能延迟。2.3 落子、悔棋与状态回滚人人对战需要悔棋人机对战需要搜索时反复落子撤销这两件事本质是同一个操作记录每一步的坐标和颜色撤销时把对应格子清空。我一般维护一个std::vectorstd::pairint,int history落子时 push悔棋时 pop 并把棋盘置 0。struct GameState { Board board; std::vectorstd::pairint,int history; int current 1; // 1 黑 2 白 bool place(int x, int y) { if (!inBoard(x, y)) return false; int idx toIndex(x, y); if (board[idx] ! 0) return false; board[idx] current; history.emplace_back(x, y); current 3 - current; // 1-2 切换 return true; } void undo() { if (history.empty()) return; auto [x, y] history.back(); history.pop_back(); board[toIndex(x, y)] 0; current 3 - current; } };current 3 - current是切换黑白最省事的写法比 if-else 干净。搜索里会频繁调用place和undo所以这两个函数不要做任何多余的事比如打印日志、检查胜负胜负检查交给外层。把状态变更和规则判定分开是后面能顺利加搜索的前提。3. 人人对战输入循环、界面刷新与规则收口3.1 控制台版的最小可玩循环人人对战的核心就是一个 while 循环打印棋盘、读入坐标、落子、判胜、切换玩家。控制台版不需要任何图形库适合先把规则跑通。#include iostream void printBoard(const Board b) { std::cout ; for (int x 0; x BOARD_SIZE; x) std::cout (x % 10) ; std::cout \n; for (int y 0; y BOARD_SIZE; y) { std::cout (y % 10) ; for (int x 0; x BOARD_SIZE; x) { int v b[toIndex(x, y)]; std::cout (v 0 ? . : (v 1 ? X : O)) ; } std::cout \n; } } int main() { GameState gs; gs.board.assign(BOARD_CELLS, 0); while (true) { printBoard(gs.board); std::cout (gs.current 1 ? 黑方 : 白方) 落子 (x y): ; int x, y; if (!(std::cin x y)) break; if (!gs.place(x, y)) { std::cout 非法落子\n; continue; } if (checkWin(gs.board, x, y, 3 - gs.current)) { printBoard(gs.board); std::cout (gs.current 1 ? 白方 : 黑方) 获胜\n; break; } } return 0; }注意判胜时用的颜色是3 - gs.current因为place已经把 current 切到对方了刚落下的那手棋颜色是切换前的。这个细节写错会导致「赢了不报」或者「对方赢了报自己赢」是人人对战里最常见的逻辑 bug。输入用std::cin直接读两个整数非法输入时退出循环避免死循环刷屏。3.2 用 Qt 或 EasyX 加界面时要注意什么如果你想让棋盘好看一点Qt 的QPainter画格子和棋子是最省事的EasyX 在 Windows 上更快上手。但不管用哪个有一条原则界面层只负责画和收鼠标事件所有规则判定必须回到GameState里做。我见过太多人把胜负判断写进鼠标点击回调结果人机对战时 AI 走子不触发回调胜负就漏判了。界面刷新时不要每帧重画整个棋盘只重画变化的那一格。Qt 里用update(QRect)指定区域EasyX 里用BeginBatchDraw批量绘制。棋子用抗锯齿圆半径取格子边长的 0.4 倍左右太大挤在一起太小看着空。鼠标坐标转棋盘坐标时记得减去边距再除以格宽并且要做四舍五入而不是直接截断否则点格子边缘会落到相邻格。3.3 规则收口禁手、长连和先手优势标准五子棋无禁手里黑棋先手优势很大如果只是自己玩不加禁手完全没问题。但如果你想让对局公平一点可以加「黑棋三三禁手、四四禁手、长连禁手」。实现方式是在黑棋落子后除了判连五还要统计该点形成的活三数量、冲四数量、连子长度超过阈值就判黑棋负。// 简化版统计某点某方向的连子长度和两端是否开放 struct LineInfo { int count; bool openLeft; bool openRight; }; LineInfo scanLine(const Board b, int x, int y, int dx, int dy, int color) { LineInfo info{1, false, false}; int nx x dx, ny y dy; while (inBoard(nx, ny) b[toIndex(nx, ny)] color) { info.count; nx dx; ny dy; } info.openRight inBoard(nx, ny) b[toIndex(nx, ny)] 0; nx x - dx; ny y - dy; while (inBoard(nx, ny) b[toIndex(nx, ny)] color) { info.count; nx - dx; ny - dy; } info.openLeft inBoard(nx, ny) b[toIndex(nx, ny)] 0; return info; }禁手判定是五子棋里最容易写错的部分因为「活三」的定义涉及递归一个三如果落一子能变成活四才算活三。新手建议先不做禁手把人人对战和人机对战跑通再回头补。真要做就单独写一个isForbidden函数输入棋盘和落子点输出是否禁手不要和胜负判定混在一起。4. 人机对战评估函数、极小化极大与 Alpha-Beta 剪枝4.1 棋型打分活四、冲四、活三怎么给分人机对战的核心不是搜索而是评估函数。搜索只是帮你多看几步评估函数决定了 AI 觉得什么棋「好」。五子棋的棋型可以按威胁程度排序连五 活四 冲四 活三 眠三 活二 眠二。我一般给的分值是连五 10000000活四 1000000冲四 100000活三 10000眠三 1000活二 100眠二 10。int scoreLine(int count, bool openLeft, bool openRight) { if (count 5) return 10000000; if (count 4) { if (openLeft openRight) return 1000000; // 活四 if (openLeft || openRight) return 100000; // 冲四 return 0; } if (count 3) { if (openLeft openRight) return 10000; // 活三 if (openLeft || openRight) return 1000; // 眠三 return 0; } if (count 2) { if (openLeft openRight) return 100; if (openLeft || openRight) return 10; return 0; } return 0; }评估整个棋盘时遍历所有空点或者所有已落子点的四个方向累加己方得分减去对方得分。注意要同时评估双方否则 AI 会只顾进攻不防守。一个常见做法是对每个点分别以黑棋和白棋视角扫描四个方向得到两个分数己方分数累加对方分数乘以一个略大于 1 的系数比如 1.1后减去这样 AI 会稍微偏向防守。4.2 极小化极大搜索的递归骨架有了评估函数就可以做搜索。极小化极大Minimax的思路是AI 走一步假设对手也走最优的一步如此交替直到达到搜索深度返回评估值。AI 选使评估值最大的走法对手选使评估值最小的走法。constexpr int MAX_DEPTH 4; int minimax(GameState gs, int depth, int alpha, int beta, bool isMax) { // 先检查是否已经连五 if (!gs.history.empty()) { auto [lx, ly] gs.history.back(); int lastColor gs.board[toIndex(lx, ly)]; if (checkWin(gs.board, lx, ly, lastColor)) { return lastColor 1 ? -10000000 (MAX_DEPTH - depth) : 10000000 - (MAX_DEPTH - depth); } } if (depth 0) return evaluate(gs.board, 1) - evaluate(gs.board, 2); int best isMax ? -100000000 : 100000000; for (int idx 0; idx BOARD_CELLS; idx) { if (gs.board[idx] ! 0) continue; int x toX(idx), y toY(idx); gs.place(x, y); int val minimax(gs, depth - 1, alpha, beta, !isMax); gs.undo(); 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; // Alpha-Beta 剪枝 } return best; }这段代码里有几个关键点。第一连五检查放在递归开头用history.back()拿到最后一手避免全盘扫描。第二返回胜负分时加上MAX_DEPTH - depth让 AI 优先选择更快获胜的走法否则它会赢就行不管几步。第三Alpha-Beta 剪枝的条件是beta alpha写在循环内、递归返回之后剪掉的分支不影响结果。4.3 候选点生成别对 225 个空点全搜上面那段代码对每个空点都递归15x15 棋盘第一层 225 个点深度 4 就是 225^4 约 25 亿个节点根本跑不完。实际工程里必须做候选点裁剪只考虑已有棋子周围 2 格内的空点。这样第一层通常只有 20 到 40 个候选深度 4 也能在秒级出结果。std::vectorint genMoves(const Board b, int radius 2) { std::vectorint moves; std::vectorbool used(BOARD_CELLS, false); for (int idx 0; idx BOARD_CELLS; idx) { if (b[idx] 0) continue; int cx toX(idx), cy toY(idx); for (int dy -radius; dy radius; dy) { for (int dx -radius; dx radius; dx) { int nx cx dx, ny cy dy; if (!inBoard(nx, ny)) continue; int nidx toIndex(nx, ny); if (b[nidx] ! 0 || used[nidx]) continue; used[nidx] true; moves.push_back(nidx); } } } if (moves.empty()) moves.push_back(toIndex(7, 7)); // 空棋盘走天元 return moves; }radius取 2 是经验值取 1 会漏掉一些跳活三的防守点取 3 候选太多影响速度。空棋盘时返回天元保证 AI 第一手有棋可走。生成候选后最好再按「该点周围棋子密度」或「该点评估分」排序把好棋排在前面Alpha-Beta 剪枝效率会高很多。5. 人机对战避坑搜索跑不动、AI 乱下、胜负判错的排查清单5.1 现象AI 思考超过十秒还不落子原因通常是候选点没裁剪或者搜索深度设太大或者评估函数里对每个节点都全盘扫描。解决方法是先确认genMoves是否生效把深度降到 2 看是否能秒出再逐步加深度。评估函数改成增量更新落子时只更新该点四个方向的分数撤销时恢复不要每次递归都重算全盘。5.2 现象AI 明明能赢却去堵对方原因是评估函数里对方分数权重过高或者胜负分没有正确返回。检查minimax里连五检查是否在depth 0之前如果放在后面达到深度限制时胜负分会被评估分覆盖。另外确认返回胜负分时颜色判断正确黑赢返回负、白赢返回正写反了 AI 会主动送死。5.3 现象AI 第一手落在角落或者乱走原因是空棋盘时genMoves返回空或者评估函数对空棋盘所有点打分相同搜索退化成随机。解决方法是空棋盘直接返回天元或者在评估函数里给中心点加一个位置分越靠近中心分越高这样开局不会太离谱。5.4 现象人人对战正常切到人机就崩溃原因是搜索过程中修改了GameState但没有正确撤销导致history和board不一致。检查place和undo是否成对出现递归里每个place后面必须跟一个undo包括剪枝break之前也要撤销。建议用 RAII 封装一个ScopedPlace构造时落子析构时撤销避免漏写。5.5 现象AI 偶尔下在已经有棋子的位置原因是候选点生成时没有过滤非空点或者坐标转换toIndex和toX/toY用混了。检查genMoves里b[nidx] ! 0的判断以及所有坐标转换是否统一用同一套函数。这种 bug 在界面上表现为棋子重叠控制台版会直接覆盖对方棋子非常隐蔽。6. 进阶技巧用 Zobrist 哈希加置换表把搜索深度翻倍前面那套 Minimax 加 Alpha-Beta深度 4 在普通笔记本上大概几百毫秒到一秒深度 6 就明显卡顿。想再往上走最划算的优化是置换表Transposition Table。五子棋搜索里不同的落子顺序可能到达同一个棋盘状态置换表把「棋盘状态 → 搜索结果」缓存下来下次遇到相同状态直接查表省掉整棵子树。棋盘状态的哈希用 Zobrist为每个格子、每种颜色预生成一个随机 64 位数棋盘哈希就是所有已落子对应随机数的异或。落子时异或一次撤销时再异或一次天然支持增量更新。#include random #include unordered_map uint64_t zobrist[BOARD_CELLS][3]; // [idx][color] void initZobrist() { std::mt19937_64 rng(20240501); for (int i 0; i BOARD_CELLS; i) for (int c 1; c 2; c) zobrist[i][c] rng(); } uint64_t boardHash(const Board b) { uint64_t h 0; for (int i 0; i BOARD_CELLS; i) if (b[i]) h ^ zobrist[i][b[i]]; return h; } struct TTEntry { int depth; int value; int flag; }; // flag: 0 精确 1 下界 2 上界 std::unordered_mapuint64_t, TTEntry tt;搜索时先算当前棋盘哈希查tt如果命中且缓存深度不小于当前剩余深度直接返回缓存值。否则正常搜索搜完把结果写回。flag用来区分精确值、下界和上界Alpha-Beta 里如果返回值落在 alpha 和 beta 之间就是精确值小于等于 alpha 是上界大于等于 beta 是下界。置换表配合候选点排序深度 6 通常能压到一秒以内深度 8 在开局阶段也能接受。注意unordered_map在节点量大时会有哈希冲突和内存分配开销追求极致可以换成固定大小的数组加取模或者用std::vector加开放寻址。我一般先用unordered_map跑通确认棋力提升后再换底层容器。还有一个容易被忽略的点置换表里的值依赖搜索深度同一个棋盘在深度 4 和深度 6 下的评估值可能不同所以查表时必须比较深度缓存深度不够就重新搜。另外胜负分不要直接存绝对值存相对于当前搜索层的分值否则不同深度查表会出错。这些细节我第一次写的时候全踩过AI 查表后棋力反而下降排查了半天才发现是深度没比较。最后说个习惯每次改完搜索参数不要只看 AI 赢不赢要固定几个典型局面比如活三防守、冲四进攻、双三单独测确认 AI 的选择符合预期。棋力提升是渐进的靠感觉调参很容易越调越乱。希望帮到你。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑