资讯动态

C++五子棋AI源码实战:极小化极大搜索与Alpha-Beta剪枝详解

发布时间:2026/10/21 14:14:16 来源:尧图企业网站定制
简介这是一份面向C初学者与游戏编程爱好者的五子棋实战源码同时支持人机对战与人人对战两种模式适合用来理解面向对象设计、AI决策与图形交互等核心概念。压缩包共7个文件约106KB包含3个可执行程序、3份cpp源码以及1份说明文档分别对应鼠标版、键盘版与人对机、人对人等多种实现方便对照不同交互方式与对战逻辑的代码差异。目前已有180人学习下载。源码中棋盘与棋子通过类与对象抽象人机模式涉及基于规则或搜索的落子决策人人模式则侧重输入校验与状态更新界面部分展示了图形库的绘制与事件响应思路。读者可借此梳理类设计、AI算法、输入处理与界面渲染四条主线并在此基础上尝试提升AI智能或改进交互体验。1. 从一份 C 五子棋源码说起为什么它值得你花一个下午拆一遍很多人第一次接触游戏 AI都是从五子棋开始的。它规则简单到用一张草稿纸就能讲明白但背后藏着的搜索、评估、剪枝逻辑和那些商业棋类引擎的核心思路是一脉相承的。这份 C 五子棋源码同时支持人人对战和人机对战把棋盘绘制、落子判定、胜负检测、AI 决策这几块拆得比较清楚适合拿来当「第一个能跑起来的博弈程序」来读。我拿到这份代码的第一反应是它没有依赖任何图形库纯控制台输出这意味着你不需要配 SDL、Qt 或者 EasyX 就能编译运行。对于想专注算法本身、不想被环境配置拖住的人来说这一点很关键。它适合三类人刚学完 C 基础语法想找个完整项目练手的想理解极小化极大搜索和 Alpha-Beta 剪枝到底怎么落地的以及想拿一份能改的代码做课程设计或自娱自乐的。2. 编译与运行把源码跑起来需要几步2.1 文件结构与编译命令拿到源码包后先别急着打开 IDE。我习惯先在终端里把文件列一遍确认入口在哪、有没有多余依赖。常见的组织方式是一个主文件加若干头文件或者干脆全部塞进一个.cpp里。下面以单文件为例多文件的情况把编译命令里的文件名依次列上即可。# 查看源码目录结构 ls -la # 假设主文件为 gomoku.cpp使用 g 编译 g -stdc11 -O2 -o gomoku gomoku.cpp # 运行 ./gomoku这里-stdc11是保险起见老代码可能用了auto或范围 for 循环-O2开启优化AI 搜索层数稍深时能明显感觉到响应变快。如果你在 Windows 上用 MinGW把./gomoku换成gomoku.exe即可。编译报错先看第一条通常是缺cstdlib或ctime这类头文件补上就行。2.2 棋盘表示与落子交互跑起来之后你会看到一个用字符拼出来的棋盘行列坐标一般从 0 或 1 开始编号。输入坐标落子程序会刷新棋盘并判断胜负。这里有个细节值得注意棋盘的数据结构通常是一个二维数组空位用 0 表示玩家用 1AI 用 2。落子前必须检查该位置是否为空否则会出现覆盖棋子的 bug。// 典型的棋盘定义与落子检查 const int SIZE 15; int board[SIZE][SIZE] {0}; // 0 空1 玩家2 AI bool placeStone(int x, int y, int player) { if (x 0 || x SIZE || y 0 || y SIZE) return false; // 越界 if (board[x][y] ! 0) return false; // 已有子 board[x][y] player; return true; }参数说明x和y是数组下标不是棋盘上的「第几行第几列」这种人类习惯的编号输入时要做一次转换。很多新手在这里翻车输入 8 结果落到了第 9 行。建议在输入函数里统一做-1处理或者直接在提示里写清楚「请输入 0 到 14 之间的坐标」。2.3 人人对战模式的流程人人对战本质就是一个循环打印棋盘、读入当前玩家坐标、落子、判断胜负、切换玩家。逻辑直白但有两个容易忽略的点。一是平局判定当棋盘下满且无人获胜时要能退出循环否则程序会一直卡在输入状态。二是输入缓冲区的清理如果玩家输入了非数字字符cin会进入失败状态后续所有读取都会失效必须cin.clear()加cin.ignore()才能恢复。// 人人对战主循环片段 int currentPlayer 1; while (true) { printBoard(); int x, y; cout 玩家 currentPlayer 请输入坐标(行 列): ; if (!(cin x y)) { // 输入非数字 cin.clear(); // 清除错误状态 cin.ignore(10000, \n); // 丢弃缓冲区 cout 输入无效请重新输入。 endl; continue; } if (!placeStone(x, y, currentPlayer)) { cout 该位置不能落子请重试。 endl; continue; } if (checkWin(x, y, currentPlayer)) { printBoard(); cout 玩家 currentPlayer 获胜 endl; break; } if (isFull()) { printBoard(); cout 平局。 endl; break; } currentPlayer 3 - currentPlayer; // 1 变 22 变 1 }3 - currentPlayer是个小技巧比写 if-else 简洁。checkWin只需要从刚落子的位置向四个方向延伸计数不需要全盘扫描这是效率上的常见优化。3. 人机对战的核心极小化极大搜索与 Alpha-Beta 剪枝3.1 评估函数怎么给棋型打分AI 要选点就得知道哪个点「好」。评估函数的作用就是把一个局面转换成一个分数AI 执子时希望分数越高越好玩家执子时希望分数越低越好。五子棋的评估通常基于棋型活四、冲四、活三、眠三、活二等等。活四基本等于必胜冲四和活三需要立即应对活二和眠三价值递减。// 简化的棋型评分表分数为经验值可按需调整 const int SCORE_FIVE 100000; // 五连 const int SCORE_LIVE_FOUR 10000; // 活四 const int SCORE_RUSH_FOUR 1000; // 冲四 const int SCORE_LIVE_THREE 1000; // 活三 const int SCORE_SLEEP_THREE100; // 眠三 const int SCORE_LIVE_TWO 100; // 活二 const int SCORE_SLEEP_TWO 10; // 眠二这些分数不是随便写的它们之间要保持数量级差距否则 AI 会为了一个活二放弃堵活三。常见做法是让活四比冲四高一个数量级冲四和活三同档但根据轮次微调。我一般会先把五连设成 100000活四 10000冲四和活三 1000这样搜索时优先级比较清晰。3.2 极小化极大搜索的递归框架极小化极大搜索的思路是AI 走一步假设玩家会走一步最不利于 AI 的棋然后 AI 再走一步如此交替推演到指定深度把叶子节点的评估值往回传。AI 层取最大值玩家层取最小值。下面是一个带 Alpha-Beta 剪枝的框架。int minimax(int depth, int alpha, int beta, bool isMax) { if (depth 0 || checkGameOver()) { return evaluateBoard(); // 到达深度限制或终局返回评估分 } if (isMax) { int best -INF; for (每个空位 pos) { board[pos] AI; int val minimax(depth - 1, alpha, beta, false); board[pos] 0; best max(best, val); alpha max(alpha, best); if (beta alpha) break; // Beta 剪枝 } return best; } else { int best INF; for (每个空位 pos) { board[pos] PLAYER; int val minimax(depth - 1, alpha, beta, true); board[pos] 0; best min(best, val); beta min(beta, best); if (beta alpha) break; // Alpha 剪枝 } return best; } }alpha是 AI 层当前能保证的最小值beta是玩家层当前能保证的最大值。当beta alpha时说明后续分支不可能影响最终决策直接剪掉。这个剪枝能把搜索量砍掉一大半是让 AI 在可接受时间内搜到 4 层甚至 6 层的关键。3.3 候选点生成与搜索深度控制如果每个空位都搜15×15 的棋盘第一层就有 225 个分支搜 4 层就是 225 的四次方完全跑不动。实际做法是只考虑已有棋子周围两格内的空位这样候选点通常只有十几个到几十个。另外搜索深度要设上限深度越深 AI 越强但耗时越长一般 4 层是响应速度和棋力的平衡点。// 生成候选点只取已有棋子周围 2 格内的空位 vectorpairint,int generateMoves() { vectorpairint,int moves; for (int i 0; i SIZE; i) { for (int j 0; j SIZE; j) { if (board[i][j] ! 0) continue; if (hasNeighbor(i, j, 2)) { // 周围 2 格内有棋子 moves.push_back({i, j}); } } } return moves; }hasNeighbor遍历以(i,j)为中心、半径为 2 的方形区域只要发现非空就返回 true。这个策略在开局阶段候选点很少中盘会增多但相比全盘搜索已经是数量级的优化。如果还想再快可以对候选点按评估分排序先搜高分点让剪枝更早生效。4. 避坑与排查这份源码里最容易翻车的几个地方4.1 胜负判断只检查落子点导致漏判现象明明已经五连了程序却没反应继续让下。原因通常是checkWin写成了全盘扫描但扫描方向或边界条件写错或者只检查了落子点的一个方向。解决从落子点出发分别向水平、垂直、主对角线、副对角线四个方向统计连续同色棋子数任一方向达到 5 就返回 true。注意统计时要包含落子点本身所以初始计数为 1。4.2 AI 第一步卡死或乱下现象人机模式下 AI 第一步思考很久或者直接下在角落。原因一般是候选点生成时棋盘为空hasNeighbor对所有位置都返回 false导致候选点列表为空AI 没有可走的位置。解决在generateMoves里加一个判断如果棋盘为空直接返回中心点。另外评估函数在空盘时所有位置分数相同AI 可能随机选加个中心偏好能改善开局。4.3 递归搜索栈溢出现象程序运行到 AI 回合直接崩溃报段错误。原因通常是搜索深度设得太大或者递归终止条件写错导致无限递归。解决先把深度降到 2 确认能跑通再逐步加到 4。检查depth 0的判断是否在递归调用之前以及checkGameOver是否真的能在终局时返回 true。如果棋盘接近下满候选点很少但递归层数没减也可能出问题。4.4 输入坐标越界导致数组访问异常现象输入 15 或 -1 时程序崩溃或行为异常。原因board[x][y]没有做边界检查直接越界访问。解决在placeStone里先判断x和y是否在[0, SIZE-1]范围内越界直接返回 false。这个检查要放在所有数组访问之前包括checkWin里从落子点向四周延伸时每一步都要判断是否越界。4.5 评估函数正负号搞反导致 AI 帮玩家下棋现象AI 不仅不堵玩家的棋还主动把玩家连成五子。原因评估函数返回的分数是站在玩家角度算的但 AI 层取最大值时没有取反。解决统一评估视角要么始终站在 AI 角度AI 棋型加分玩家棋型减分要么在玩家层取反。我一般习惯让evaluateBoard返回「AI 分数减去玩家分数」这样 AI 层取最大、玩家层取最小就自然对应了。5. 进阶技巧让 AI 从「能下」变成「难缠」5.1 用置换表缓存已搜索局面Alpha-Beta 剪枝虽然快但同一局面可能通过不同落子顺序到达重复搜索很浪费。置换表就是一个哈希表把局面的哈希值映射到搜索深度和评估值。下次遇到相同局面直接查表省掉整棵子树。实现时用 Zobrist 哈希比较常见给每个位置的每种棋子状态分配一个随机数局面哈希就是所有棋子对应随机数的异或值。// Zobrist 哈希的初始化与更新 unsigned long long zobrist[15][15][3]; // [行][列][0空/1玩家/2AI] unsigned long long currentHash 0; void initZobrist() { for (int i 0; i 15; i) for (int j 0; j 15; j) for (int k 0; k 3; k) zobrist[i][j][k] rand64(); } // 落子时更新哈希异或掉旧状态异或上新状态 void updateHash(int x, int y, int oldState, int newState) { currentHash ^ zobrist[x][y][oldState]; currentHash ^ zobrist[x][y][newState]; }rand64是自己实现的 64 位随机数生成函数标准rand()位数不够。置换表用unordered_map或固定大小的数组加索引都行注意要存深度只有当前搜索深度不小于表中深度时才能直接用。5.2 迭代加深与时间控制固定搜索深度有个问题中盘候选点多的时候 4 层可能要好几秒开局候选点少的时候 4 层又太浅。迭代加深的思路是先搜 1 层再搜 2 层直到时间用完或达到最大深度。这样任何时候中断都能拿到一个完整深度的结果而且浅层搜索的结果可以给深层搜索做排序参考让剪枝更高效。// 迭代加深框架 pairint,int findBestMove(int timeLimitMs) { auto start chrono::steady_clock::now(); pairint,int bestMove {-1, -1}; for (int depth 1; depth MAX_DEPTH; depth) { auto elapsed chrono::duration_castchrono::milliseconds( chrono::steady_clock::now() - start).count(); if (elapsed timeLimitMs) break; // 超时放弃当前深度 bestMove searchRoot(depth); } return bestMove; }searchRoot是根节点的搜索函数返回当前深度下的最佳落子。timeLimitMs一般设 1000 到 3000 毫秒根据机器性能调。注意每次加深前检查时间如果已经超时就保留上一深度的结果不要用半截的搜索结果。5.3 验证 AI 是否真的变强了改完 AI 之后怎么知道有没有效果我一般用两个办法。一是固定几个典型局面比如「玩家活三 AI 必须堵」「AI 有冲四机会必须冲」手动摆盘看 AI 的落子是否符合预期。二是让新旧版本互相对弈若干局统计胜率。对弈时要注意随机化开局否则同一局面反复出现没有统计意义。测试局面预期 AI 行为检查方式玩家活三堵住活三一端手动摆盘观察落子AI 冲四形成冲四手动摆盘观察落子空盘开局下在中心附近直接运行看第一步玩家双活三堵其中一个手动摆盘看是否崩溃最后说个习惯每次改完评估函数或搜索深度我都会先把深度降到 2 跑一遍确认没有崩溃和明显逻辑错误再升到 4 做对弈测试。直接上深度 4 调试一旦出问题很难判断是搜索写错了还是评估函数写错了。希望这份拆解能帮你少走点弯路把这份源码真正用起来。本文还有配套的精品资源点击获取

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

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

免费获取报价 →
↑