C++博弈树与α-β剪枝实现五子棋AI:四层搜索的实战解析
简介:基于C++实现的AI五子棋人机对战源码,核心采用博弈树建模棋局,并以α-β剪枝优化搜索效率,目前AI可推算四层局面。博弈树中每个节点代表一个棋局状态,分支对应着法;α-β剪枝通过动态裁剪对当前搜索无意义的分支,显著减少搜索节点,提升决策速度。压缩包共47个文件、约2.64MB,主要包含C++源文件(.cpp/.h)、Visual Studio工程配置文件(.sln/.vcxproj)以及编译调试产物(.tlog/.pdb/.obj等);三个核心.cpp文件分别对应棋盘、AI算法与主程序,三个.h文件用于结构体、哈希表等声明,下载解压后可直接用VS打开编译运行,同时附有已编译的exe便于快速体验。已有79人学习下载,适合C++初学者或算法爱好者研究博弈树评估与alpha-beta剪枝的具体实现;通过阅读AI.cpp可以掌握局面评分、生成着法、递归搜索和剪枝判断等关键代码细节,进而尝试扩展搜索深度、优化评估函数或引入更丰富的启发式规则,让AI的战斗力逐步提升。
1. 基于C++博弈树与α-β剪枝的五子棋AI:四层推算能把人机对战做到什么程度?
如果你写过五子棋小游戏,大概率会卡在同一个问题上:电脑怎么选点?随便下一步,玩家两步就赢了;老老实实全盘枚举,又慢又蠢。这套基于C++博弈树、用α-β剪枝改进的AI五子棋源码,给出的答案是「往下推算四层局面」:AI每次落子前,把自己可能的落点、对手可能的反击、自己再跟进的落点全部展开成一棵博弈树,在叶子节点评估局面分数,再通过α-β剪枝把无效分支砍掉,最终在几百毫秒内挑出一手棋。四层不是职业水准,但对普通玩家来说,它的棋力已经接近「看过棋谱、知道堵活三冲四」的业余熟手,足够让一个对战游戏变得难赢。适合两类人:一是想搞明白棋类AI「思考过程」的C++学习者,二是想给自己的小游戏项目加一个不依赖任何外部AI接口的本地对战模块。接下来我把这条路径从头拆开,从原理讲到编译,再讲坑在哪。
2. 从minimax到α-β剪枝:为什么四层搜索能算出「较好的一手」?
五子棋的AI决策本质上是一个搜索问题:当前棋盘状态是根节点,AI每下一步就产生一个子节点,对手应对后又产生下一层子节点。程序要做的事,就是把这棵树的某一个深度内的所有节点展开,给每个末端局面打分,然后反向回溯,选出对自己最有利的一条路径。这套源码里用的是minimax框架,并在其上叠加了α-β剪枝,两层结构合起来就是一个「能往下推算四层」的决策器。
2.1 博弈树与「谁受益」:minimax如何决定一手棋的分数
minimax的核心假设是:AI和对手都绝对理性,AI永远选对自己分数最高的走法,对手永远选让AI分数最低的走法。在博弈树上,奇数层是AI的回合,取子节点的最大值;偶数层是对手的回合,取子节点的最小值。这样往根节点回溯,根节点得到的就是「在当前深度下,双方都走最优解时,AI能拿到的最大利益」。
实际工程里很少有人直接写minimax,而是写成负极大值(negamax)形式,省去区分MAX层和MIN层的麻烦。我在类似棋类项目中一般这么写核心搜索:
// 负极大值 + alpha-beta剪枝,返回当前局面的评估分数 // depth: 剩余搜索深度;alpha/beta: 剪枝窗口;player: 当前轮到的玩家(1=AI,-1=对手) int negamax(GomokuBoard& board, int depth, int alpha, int beta, int player) { // 检查已有胜负:当前局面已经分出输赢则直接返回 int winner = board.checkWinner(); if (winner != 0) { // player胜返回高分,对手胜返回低分,这里用极大值避免搜索再往下走 return winner == player ? 1000000 + depth : -1000000 - depth; } if (depth == 0) { // 到达叶子节点,改用评估函数打分,分数越正越利于AI return board.evaluate(player); } // 生成候选落点,按启发式分数从高到低排序,保证剪枝效率 vector<Move> candidates = board.generateCandidates(); int best = -INT_MAX; // 当前节点能取得的最佳分数,负极大值下从最小开始 for (const Move& m : candidates) { board.place(m.x, m.y, player); // 试落一子 int score = -negamax(board, depth - 1, -beta, -alpha, -player); board.undo(m.x, m.y); // 撤销,恢复现场 if (score > best) best = score; if (best > alpha) alpha = best; // 更新下界 if (alpha >= beta) break; // 剪枝:这个分支对上层已经无意义 } return best; }调用时,根节点传入depth=4,alpha=-INT_MAX,beta=INT_MAX,player=1。注意负极大值的两个关键写法:递归调用时把alpha和beta取反并交换位置,返回值也取反,这保证了「对手的极大」在上一层自动变成「自己的极小」;剪枝条件alpha >= beta一定放在alpha更新之后,放错位置会导致该剪的没剪,搜索速度直接差出两三倍。depth=4意味着AI考虑「我下、你下、我下、你下」共四步后的局面,这就是标题里「推算四层局面」的来源。
2.2 α-β剪枝为什么能省下九成节点:参数与搜索顺序的关系
α-β剪枝的原理并不复杂:搜索过程中维护两个值,alpha表示AI目前能保证的最低分数,beta表示对手能让AI拿到的最高分数上界。当某个节点的alpha大于等于beta,说明无论这个分支下面是什么结果,上层都不会选它,整棵子树可以放弃。开局阶段走法多,剪枝率低;中盘以后每层候选点变少,配合走法排序,剪枝率能到80%到90%。
剪枝率几乎完全由候选点的遍历顺序决定。如果每次都先搜最差的分支,alpha和beta迟迟不收敛,四层搜索等于退化回原始minimax,数据量还是几万到十几万个节点;如果先搜最强的分支,很快就能把根节点的alpha抬得很高,后续大量分支在浅层就被砍掉。所以这套源码里排序逻辑比搜索函数本身更值钱。我在做这类AI时,对候选点排序会用两层启发:优先选择「离中心近的点」,其次选择「周围已有棋子密度高」的点。五子棋的棋理决定了越靠近战斗区域的位置越可能成为关键手,越靠近棋盘中心的位置越能同时控制多个方向。
3. 评估函数与走法生成:决定四层AI棋力上限的两个核心细节
搜索只负责「在给定评分标准下找最优路径」,真正决定AI「觉得哪边好」的是评估函数。四层深度下,AI能看到四步棋的局部攻防,但它看不到更远的连绵杀招,因此叶子节点的打分必须足够贴近真实棋理。同时,走法生成决定每层展开多少个分支,直接决定四层搜索能否在几百毫秒内跑完。这两块是一套五子棋AI源码里最值得反复调的部分。
3.1 五子棋棋型评分表:活四、冲四、活三的分数怎么排
评估函数的基本单元是「棋型」:在横、竖、左斜、右斜四个方向上,把连续的同色棋子与其两端空隙组合起来,识别成五连、活四、冲四、活三、眠三、活二等。每种棋型打一个分,累加得到整个棋盘的评估值。我常用的评分表长这样:
| 棋型 | 示例 | 分数 | 说明 |
|---|---|---|---|
| 五连 | XXXXX | 1000000 | 已经赢了,分数给到最高 |
| 活四 | XXXX | 100000 | 两端都没堵,怎么都赢 |
| 冲四 | XXXXX或 _XXX_X | 10000 | 只有一端能连五,需要马上堵 |
| 活三 | XXX_ | 5000 | 再下一步能变活四,威胁极大 |
| 眠三 | XX_X或 X_XXX | 500 | 威胁受限,但可能组合成杀招 |
| 活二 | XX_ | 100 | 发展潜力,中盘布局用 |
| 眠二 | X_X | 10 | 低优先级,聊胜于无 |
分数之间必须拉开量级差距,这是这套评估里最大的设计点。如果活三只给300、冲四给400,AI可能为了贪一个冲四组合放弃堵对方的活三,结果反被连杀。量级拉开后,「对方已经活三必须堵」这类棋理才能被搜索自然学出来。另外我习惯给AI自己棋型的分数乘一个1.1到1.2的系数,让AI在「攻」和「守」评分相当时略微偏攻,否则AI会一直补防守,棋风非常被动,玩家垫几步就赢了。
评估的方向扫描是性能关键。15路棋盘上每个点最多检查四个方向,每层递归数百个叶子节点,评估函数每多一次重复扫描,总耗时就会翻倍。我实现时会单独写一个evaluatePoint函数,只围绕最新落子点增量更新分数,而不是每次全盘重扫。全盘重扫写起来简单,但四层搜索在开局阶段可能慢到几秒一步,对局体验会被拖垮。
3.2 不扫全盘的候选点生成:为什么只看已有棋子周围两格
如果每层递归都把15×15共225个空位全部展开为候选,四层搜索的分支数是225的4次方量级,任何剪枝都救不回来。正常人机对战的思路是:能影响局面的落点,只可能出现在已有棋子附近。更具体地说,距离任何已有棋子两格以内的空点,基本覆盖了所有的活三、冲四、做杀点位。
生成函数我一般这样写:
vector<Move> GomokuBoard::generateCandidates() { set<pair<int,int>> seen; vector<Move> result; // 遍历棋盘上所有己方和对方棋子,收集其两格范围内的空点 for (int x = 0; x < BOARD_SIZE; x++) { for (int y = 0; y < BOARD_SIZE; y++) { if (board[x][y] == EMPTY) continue; for (int dx = -2; dx <= 2; dx++) { for (int dy = -2; dy <= 2; dy++) { int nx = x + dx, ny = y + dy; if (nx < 0 || nx >= BOARD_SIZE || ny < 0 || ny >= BOARD_SIZE) continue; if (board[nx][ny] != EMPTY) continue; if (seen.count({nx, ny})) continue; seen.insert({nx, ny}); // 启发式分:离中心越近分越高,周边棋子越多分越高 int centerDist = abs(nx - 7) + abs(ny - 7); int density = countNeighborPieces(nx, ny, 2); result.push_back({nx, ny, -centerDist * 10 + density * 20}); } } } } // 按启发式分降序排列,保证alpha-beta剪枝优先搜最强分支 sort(result.begin(), result.end(), [](const Move& a, const Move& b) { return a.score > b.score; }); return result; }这个函数有四个实际影响:第一,候选数量从225降到大约10到30个,四层搜索的总节点数被压到几千到几万个,普通PC能跑得动;第二,排序逻辑直接决定剪枝率,density参数(两格范围内已有棋子的数量)能迅速把战斗中心的点排到前面;第三,centerDist保证棋局早期AI会往中间靠,避免出现在边角做无用功;第四,用set去重是必须的,否则同一个点被多个棋子重复收集,候选列表会膨胀且排序错乱。第二和第三个点是可调的,density权重调太高会让AI过于激进地扎堆,调太低则剪枝效率下降。我一般先把density权重设为20、centerDist系数设为10跑一局看效果,再按AI的「粘人程度」微调。
4. 编译与跑通:Visual Studio和VSCode+g++两种落地方式
拿到这套zip源码后,第一个门槛不是看懂博弈树,而是把工程跑起来。五子棋这类C++控制台/窗口程序最常见的开发环境是Windows上的Visual Studio,但很多人机器上只有VSCode。这里把两条路都讲清楚。源码里一般会有一个.vcxproj工程文件或一组.cpp/.h文件,没有预编译的exe,所以得自己建工程编译。
4.1 Visual Studio下运行:工程结构与主循环
拿到源码先确认文件组成,正常会看到几个核心模块:棋盘类(棋盘存储、落子、撤销、胜负判断)、AI类(走法生成、评估函数、搜索函数)、主程序(输入输出和人机回合循环)。如果你拿到的是VS工程,直接双击.sln文件打开,然后需要把项目属性里的「字符集」设为「使用多字节字符集」或统一改成UTF-8,否则中文字符串在旧版工程里可能乱码。
主循环是所有棋类程序的中枢,结构大致是:
int main() { GomokuBoard board; AI ai; cout << "先手(1)还是后手(2)?"; int side; cin >> side; // side=1玩家先手,side=2AI先手 while (!board.isGameOver()) { if ((side == 1 && board.currentTurn() == PLAYER) || (side == 2 && board.currentTurn() == AI)) { // 玩家回合:读入坐标,检查合法性,落子 int x, y; cin >> x >> y; if (!board.isValid(x, y)) { cout << "非法落点,重输\n"; continue; } board.place(x, y, PLAYER); } else { // AI回合:调用搜索函数选点,搜索深度为4 Move aiMove = ai.searchBestMove(board, 4); board.place(aiMove.x, aiMove.y, AI); cout << "AI落子: " << aiMove.x << "," << aiMove.y << "\n"; } board.printBoard(); // 打印当前棋盘 } board.printResult(); // 输出胜负 return 0; }这里需要注意:searchBestMove(board, 4)内部返回的是「AI认为最好的那个落点」。实现时不能把负极大值函数直接当接口用,因为搜索函数返回的是分数,而AI真正需要的是「能取得这个分数的位置」。常见做法是在根节点单独做一层循环,遍历每个候选点调用negamax,记录分数最高的那个Move。我习惯在AI类里维护一个bestMove成员变量,在搜索到改进时同步更新它。四层深度下这一步通常耗时一百到几百毫秒,不需要异步处理,玩家能接受这个等待。
4.2 没有VS?g++命令行编译与Linux/macOS跑法
不是每个人都装了Visual Studio,尤其用macOS或Linux做C++练习的人更多。这套源码如果只依赖标准库(iostream、vector、set这类),完全没有Windows API或MFC代码,那用g++一条命令就能编:
# 将源码目录下所有.cpp文件编译成可执行文件 gomoku g++ -std=c++17 -O2 main.cpp board.cpp ai.cpp -o gomoku # 运行 ./gomoku-O2优化对搜索类程序是必不可少的。调试阶段用-g生成调试信息,但正式对局一定要开优化,否则四层搜索在未开优化时可能慢三到五倍。-std=c++17是按源码里用的语法特性调整的,如果源码用的是老式C++98,改成-std=c++11更稳妥。如果编译报错说找不到windows.h这类头文件,说明这份源码写死了Windows API,那在Linux/macOS下就需要自己把界面层替换成标准输入输出,逻辑层(棋盘和AI)一般可以原样保留。另外,如果zip里带的是.vcxproj而不带.cpp的平铺文件,先在里面找源文件挪出来,再按上面的命令编译。
4.3 人机对战的回合流程:先手、坐标输入、胜负判定
回合流程看起来简单,但有两个细节常被新手改坏。第一个是先手权的处理:side=1表示玩家执黑先手,AI执白后手;side=2则反过来。这看起来只是初始回合不同,但棋力体验差别很大。五子棋先手优势明显,AI执黑时胜率会高很多;AI执白时需要更依赖防守反击。这套源码里如果AI后手很难赢,不见得是剪枝写错,而是评估函数的攻防倾向偏保守,进攻权重不够。
第二个是胜负判定必须在每次落子后立即执行,而且要判断的是「当前棋盘上是否存在五连」,不是「最后落下的那一子是否形成五连」。有些实现只检查最后一子所在行,但一条五连可能由不同时刻落下的棋子共同组成,局部检查会漏判。这里我直接用全局扫描:
// 每次落子后调用,检查是否出现五连 // 返回值:0=无胜负,1=黑胜,2=白胜 int GomokuBoard::checkWinner() { int dirs[4][2] = {{1,0},{0,1},{1,1},{1,-1}}; // 横、竖、左斜、右斜 for (int x = 0; x < BOARD_SIZE; x++) { for (int y = 0; y < BOARD_SIZE; y++) { if (board[x][y] == EMPTY) continue; for (int d = 0; d < 4; d++) { int count = 1; // 正方向延伸 int nx = x + dirs[d][0], ny = y + dirs[d][1]; while (nx >= 0 && nx < BOARD_SIZE && ny >= 0 && ny < BOARD_SIZE && board[nx][ny] == board[x][y]) { count++; nx += dirs[d][0]; ny += dirs[d][1]; } // 反方向延伸 nx = x - dirs[d][0]; ny = y - dirs[d][1]; while (nx >= 0 && nx < BOARD_SIZE && ny >= 0 && ny < BOARD_SIZE && board[nx][ny] == board[x][y]) { count++; nx -= dirs[d][0]; ny -= dirs[d][1]; } if (count >= 5) return board[x][y]; } } } return 0; }四个方向向量写成数组比写四段重复代码好维护得多。count >= 5而不是count == 5,是为了兼容长连(六连、七连也算获胜)。这套判断放在main循环的isGameOver里,配合当前回合的玩家标记,就能区分黑胜和白胜。
5. 四层搜索的避坑笔记:剪枝失效、后手被动、评估重复这类问题怎么解
标题里的「加以改进」说明这源码不是教科书里那种最朴素的α-β剪枝,绕开常见坑才能把四层搜索真正跑出效果。下面这几条都是我推断这套源码最可能出现问题的地方,也是自己动手改时最容易踩的坑。
坑一:剪枝写了等于没写,节点数完全没降。现象:加上α-β剪枝后,搜索耗时和原始minimax几乎一样,一盘对局落子慢得让人想砸键盘。原因:候选点不是按启发式分数排序的,最差的分支先被搜索,alpha和beta窗口迟迟不收敛,剪枝条件几乎永远不会触发。解决:先检查generateCandidates里sort的排序依据,确认是落在「中心距离+周边密度」这类启发式分上,而不是按坐标字典序。按字典序排序是最容易出的错,编译器不会报错,但剪枝率会从90%掉到10%。
坑二:四层深度跑进5秒,局面却看不出多聪明。现象:中盘每次AI落子都要等好几秒,而且落点经常让人摸不着头脑。原因:评估函数里每次都对全盘225个点乘四个方向做棋型扫描,递归层数一深,重复计算爆炸。另一个原因是候选点生成没有去重,同一个位置被收集了多次,搜索树被无意义地扩粗。解决:把评估改成增量式的——棋盘的静态分数在开局算一次,之后每次落子和撤销时只更新受影响的行列和斜线;候选点生成用set去重后再排序。
坑三:AI执白(后手)时几乎赢不了,只会被动堵。现象:玩家执黑随便摆几个子,AI全程跟着堵,最后被双三带走。原因:评估函数对己方棋型的加分系数太低,AI在攻防分数相当时永远选防守。解决:把AI棋型分数乘上1.1到1.2的偏攻系数,或增加「活三」的权重。还有一种情况是中心距离权重太高,后手AI开局就在中心附近分散落子,没有形成集中攻势,这时把centerDist的系数从10降到6左右。
坑四:棋盘上明明有三四步连杀的棋,AI却视而不见。现象:AI已经形成了「冲四+活三」的组合杀,但它不走这边,偏去堵对手一个眠二。原因:评估函数只独立计算每一条线,没有统计「同一个交叉点同时参与多个棋型」的组合价值。双活三、四三禁手这类棋,单型分数分开算都不高,但组合起来就是必胜形。解决:在评估函数里加一个组合检测——如果某个空点同时是两个活三的延伸点,给这个点额外加组合分,或直接把它对应的候选点排序权重提高。
坑五:同一边界条件下,AI每局棋的落点摇摆不定。现象:同样的开局,AI这次走A点,下次走B点,但最后胜率差不多。原因:搜索深度为4时,不同路径的叶子分数非常接近,评估函数分辨率不够,导致排序不稳定。解决:检查评估函数的分值间隔,五连、活四、冲四、活三之间必须拉开至少一个数量级的差距,否则微小的打分误差会被搜索层放大,棋风就飘。这也是为什么评分表里分数不取5、8、11这种等差数,而是取100、500、5000这种等比数。
6. 把四层推算再往前推一步:PVS、置换表与迭代加深
如果四层已经跑通,下一步可以朝三个方向改进,按性价比排序:首先是PVS(主要变例搜索),它比α-β剪枝更激进。核心思路是:先完整搜索第一个(最强的)候选分支,得到准确的alpha值之后,对后续分支不重新全量搜索,而是先用一个很窄的窗口(alpha, alpha+1)去试探。如果试探结果落在窗口内,说明需要重新搜索;如果落在窗口外,直接剪掉。在五子棋这种候选点排序较好的棋类里,PVS通常能再压掉30%到50%的节点。
其次是置换表(transposition table),把已经搜索过的局面及其分数缓存起来。五子棋虽然不像国际象棋那样有大量局面重复,但在搜索同一层时,不同的落子顺序可能到达完全相同的棋盘状态。用哈希表保存「棋盘状态哈希值 -> 搜索深度、分数、最佳走法」,下次遇到相同局面直接命中。注意哈希碰撞问题,至少用64位Zobrist哈希,否则出错时棋力会断崖式下跌。最后是迭代加深——先搜深度1,记录最佳走法;再搜深度2,用上一层的走法作为第一候选排序;如此递增到目标深度。它不直接提升棋力,但能让你在限时模式里随时有棋可下——搜索超时就用上一层的落子,不会超时无响应。
这套四层α-β剪枝的源码,把博弈树、评估函数、启发式排序三件事裹在一起,每件事都不难,但组合起来就是能跟普通人下得有来有回的AI。我自己第一次实现时就在走法排序上翻过车,剪枝率从85%掉到不到10%,肉眼可见地变卡。后来养成一个习惯:每次改完评估或排序,先打印搜索节点的统计数量,再实际对局验证,两道关都过了才认定是改进。如果你照着这篇把四层跑通,再顺手加上PVS和置换表,八成会发现胜率其实没变多少——但每一步的响应时间明显缩短了,那种「電腦在快速思考」的交互体验,才是人机对战真正该有的样子。希望帮到你。
本文还有配套的精品资源,点击获取