☰
C++五子棋AI源码实战:极小化极大搜索与Alpha-Beta剪枝详解
2026/10/21 14:13:57 网站建设 项目流程

简介:这是一份面向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++ -std=c++11 -O2 -o gomoku gomoku.cpp # 运行 ./gomoku

这里-std=c++11是保险起见,老代码可能用了auto或范围 for 循环;-O2开启优化,AI 搜索层数稍深时能明显感觉到响应变快。如果你在 Windows 上用 MinGW,把./gomoku换成gomoku.exe即可。编译报错先看第一条,通常是缺<cstdlib>或<ctime>这类头文件,补上就行。

2.2 棋盘表示与落子交互

跑起来之后,你会看到一个用字符拼出来的棋盘,行列坐标一般从 0 或 1 开始编号。输入坐标落子,程序会刷新棋盘并判断胜负。这里有个细节值得注意:棋盘的数据结构通常是一个二维数组,空位用 0 表示,玩家用 1,AI 用 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 变 2,2 变 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_THREE=100; // 眠三 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 格内的空位 vector<pair<int,int>> generateMoves() { vector<pair<int,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 层,直到时间用完或达到最大深度。这样任何时候中断都能拿到一个完整深度的结果,而且浅层搜索的结果可以给深层搜索做排序参考,让剪枝更高效。

// 迭代加深框架 pair<int,int> findBestMove(int timeLimitMs) { auto start = chrono::steady_clock::now(); pair<int,int> bestMove = {-1, -1}; for (int depth = 1; depth <= MAX_DEPTH; depth++) { auto elapsed = chrono::duration_cast<chrono::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 调试,一旦出问题很难判断是搜索写错了还是评估函数写错了。希望这份拆解能帮你少走点弯路,把这份源码真正用起来。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询