ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

五子棋AI实战:用C++实现α-β剪枝与博弈树搜索优化

五子棋AI实战:用C++实现α-β剪枝与博弈树搜索优化 简介基于C与α-β剪枝算法实现的AI五子棋项目是一份面向算法学习者、竞赛选手及游戏开发者的完整源码与文档资源。项目以五子棋人机对战为载体演示了博弈树搜索的核心优化思路借助α-β剪枝裁剪大量无效分支只在当前落子点周围2×2范围内筛选候选位置并对必胜或必败局面提前终止搜索、直接返回估值从而在803KB的轻量工程里实现了可观的决策速度。同时代码引入随机化下棋策略在估值接近的若干位置中随机落子避免AI因固定应对模式被玩家反复利用提升了对局的新鲜感。资源包共5个文件涵盖C源程序、算法报告、配置文件、许可证及说明文档结构清晰便于阅读与二次开发。目前已有506人学习下载适合用来深入理解极大极小搜索、剪枝机制以及C工程组织与调试技巧。1. 一个会“后悔”的AI五子棋α-β剪枝不只是把搜索砍半五子棋AI听起来是个入门级玩具但实际动手做过的人都知道真正的坑不在规则判断而在“AI怎么知道自己该下哪”。如果你写过一版固定回应的AI大概率遇到过这种尴尬自己赢了第一局第二局用同样套路还能赢第三局依然赢。玩家不是变强了是AI太死板。这个基于C实现的五子棋小游戏核心就是解决这类问题——用α-β剪枝算法管住博弈树搜索规模用邻域限定避免全盘瞎搜再靠随机化让AI拒绝“背板”。对正在学C和算法、想拿一个完整小游戏练手的人来说这份资源的价值在于它把剪枝、估值、搜索策略这些课本上抽象的概念全部变成了能编译能跑的代码。代码不长但该有的工程结构都在适合拆开逐段读。2. 博弈树与 α-β 剪枝AI 落子前到底在算什么2.1 从极大极小搜索说起AI 怎么“想”一步棋五子棋的 AI 本质是个决策问题给定当前棋盘选一个对自己最有利的落子点。但“有利”不能只看眼前这一步因为对手也会选对他最有利的一步。于是就有了极大极小搜索Minimax——你把未来几步的棋局展开成一棵树自己走的时候取评分最大的节点极大层对手走的时候取评分最小的节点极小层一层层倒推回当前决策点。这里有个关键认知AI 不是每一步都重新发明算法而是在“假设对手也足够聪明”的前提下做最优应对。所以评估函数Evaluation Function的质量直接决定了 AI 的棋力。如果估值只看当前棋盘上谁的连子多AI 就会变得非常短视如果估值能兼顾活三、活四、眠三这些棋形AI 才会主动做进攻铺垫。下面是一段简化版的搜索核心框架用伪 C 描述方便你理解这个项目里主搜索函数的基本形态// 简化版极大极小搜索depth 为剩余搜索深度 int minimax(int depth, bool isAI, int alpha, int beta) { // 1. 检查当前局面是否已分胜负 int result checkWin(); if (result ! EMPTY) { return result; // 返回极大/极小值表示必胜或必败 } if (depth 0) { return evaluateBoard(); // 到达叶子节点用评估函数打分 } // 2. 生成候选落子点这个项目里用邻域搜索生成 vectorPoint candidates generateCandidates(); if (isAI) { int maxEval -INF; for (Point p : candidates) { board[p.x][p.y] AI_PIECE; int eval minimax(depth - 1, false, alpha, beta); board[p.x][p.y] EMPTY; // 撤销落子回溯 maxEval max(maxEval, eval); alpha max(alpha, eval); if (beta alpha) break; // beta 剪枝 } return maxEval; } else { int minEval INF; for (Point p : candidates) { board[p.x][p.y] HUMAN_PIECE; int eval minimax(depth - 1, true, alpha, beta); board[p.x][p.y] EMPTY; minEval min(minEval, eval); beta min(beta, eval); if (beta alpha) break; // alpha 剪枝 } return minEval; } }这段代码里最容易被新手忽略的是“撤销落子”这一步。如果你忘了把 board[p.x][p.y] 恢复成 EMPTY那搜索完一个分支后棋盘就“脏”了后面的搜索结果全是错的。我见过不少自己写五子棋 AI 的朋友翻车就翻在这里——不是算法不会写是回溯不干净。另一个值得注意的参数是 depth。这个项目典型的搜索深度在 2 到 4 之间深度每加 1搜索耗时可能翻数倍到数十倍。depth 1 时 AI 只看眼前depth 4 时 AI 会想四步但代价是耗时明显增加。实际测试时我一般会先固定 depth 2 跑通流程确认无误后再往上加深度。2.2 α-β 剪枝哪些节点值得被砍掉极大极小搜索的问题在于节点数量爆炸15×15 的棋盘候选点哪怕被限制到二三十个四层深度仍然有几十万量级的节点每层递归都要做大量棋盘扫描C 裸写也扛不住。α-β 剪枝做的事就是在搜索过程中维护两个值——α 表示当前玩家AI至少能拿到的分数下限β 表示对手最多会允许的分数上限。当某个分支的 α 已经大于等于 β 时说明这个分支剩下的兄弟节点无论怎么搜都不会影响上层决策直接砍掉。为了让你更直观地理解剪枝的效果这里列一个对比搜索方式搜索范围典型节点数深度 4实际体验全盘扫描 无剪枝225 个候选点数百万级以上明显卡顿几乎无法接受邻域限定 无剪枝20~40 个候选点数十万级勉强能跑但思考时间偏长邻域限定 α-β 剪枝20~40 个候选点数千到数万级响应流畅点击后约 0.5 秒内落子注意第三行这组数字是“排序良好”的情况。我在实际测试中发现一个血泪经验α-β 剪枝的效率极度依赖候选点的搜索顺序。如果你每次都从左上角往右下角搜剪枝效果会非常差因为大概率会先碰到一个很差的分支导致 α、β 长期得不到收紧。正确做法是先给候选点按评估函数粗略排序把看起来最有可能产生高分的点排在前面这样 α 能快速拉高后面的剪枝才能生效。这个细节在代码里往往只是一行 sort 的事但效果天差地别。3. 邻域搜索优化2×2 窗口为什么能大幅提速3.1 全盘搜索的成本到底有多高如果让 AI 每步棋都把 15×15 棋盘上所有空格当作候选点去搜索那么第一层的分支数就是 225。假设每层保留 200 个候选点深度 4 的树节点数大约是 200^4 16 亿。这个量级对普通 PC 来说已经接近不可用更别说还要在每层递归里做棋盘扫描和连子判断。这个项目采用的策略是“每次搜索仅搜索落子点周围 2×2 格范围内存在棋子的位置”。这里的 2×2 格准确说是以某个已有棋子为中心、横向和纵向各扩展 2 个格子的范围。换句话说AI 不会去考虑“离所有棋子都很远”的空点——因为正常对局中没人会下到天涯海角。这种启发式约束一下把候选点数量从 225 砍到几十个搜索速度能提升一到两个数量级。我一开始觉得这个策略有点激进毕竟职业棋手里偶尔会有“脱先”的下法跑到远处另起战场。但实际测试下来对于局部攻防密集的五子棋邻域搜索不会明显损失棋力相反它还有个隐藏收益候选点变少之后裁剪掉的节点越多α-β 剪枝越容易命中最终耗时反而大幅下降。这也是为什么这个项目能在普通 C 环境下跑出流畅体验。3.2 候选点生成的实现核心代码与参数边界下面这段是邻域搜索候选点生成的典型实现基本思路是遍历所有已有棋子把每个棋子周围 2×2 范围内的空格加入候选集合最后去重// 生成候选落子点只考虑已有棋子周围 2*2 格范围内的空位 std::vectorPoint generateCandidates(int board[15][15]) { std::vectorPoint candidates; bool visited[15][15] {false}; // 避免同一个位置被多次加入 for (int i 0; i 15; i) { for (int j 0; j 15; j) { if (board[i][j] EMPTY) continue; // 以该棋子为中心扫描横向纵向各 2 格的范围 for (int dx -2; dx 2; dx) { for (int dy -2; dy 2; dy) { int nx i dx; int ny j dy; if (nx 0 || nx 15 || ny 0 || ny 15) continue; if (board[nx][ny] ! EMPTY) continue; if (visited[nx][ny]) continue; visited[nx][ny] true; candidates.push_back({nx, ny}); } } } } // 常见做法再按评估值粗略排序让 α-β 剪枝更有效 std::sort(candidates.begin(), candidates.end(), [](const Point a, const Point b) { return evaluatePoint(a) evaluatePoint(b); }); return candidates; }注意这里的半径参数很关键dx 和 dy 都从 -2 遍历到 2所以每个已有棋子最多贡献 25 个候选格去重后棋盘上的有效候选点通常在 20 到 40 个之间。如果你想调整搜索范围改成 -1 到 1 就是 1×1 邻域候选点更少但可能漏掉一些需要隔空做杀的位置改成 -3 到 3 就是 3×3 邻域候选点变多但搜索变慢。这个参数没有绝对最优值和搜索深度强相关——深度浅的时候用 3×3 反而更稳深度 4 以上用 2×2 更现实。另一个加速点是第一步落子。棋盘全空时邻域搜索找不到任何候选点所以开局第一步需要单独处理。常见做法是直接让 AI 落在天元7, 7或中心四个点之一避免候选集为空导致死循环。同理第二步也只有第一步棋子周围有候选点这个自然没问题。这个“特判”逻辑看似琐碎但没有它程序会直接崩。4. 必胜/负局面提前返回与随机化落子两个容易被忽略的设计4.1 必胜负局面搜索没必要继续展开描述中提到“当搜索过程中出现了必胜/负局面时直接返回不再搜索”这句话听起来简单但实现上有个容易踩坑的点什么算必胜/负局面很多初学者会把“检查到有五连”当成唯一的终止条件但实际上在递归搜索中你更常见的情况是搜索到某个节点时棋盘上已经出现“双活三”或“冲四”这种无法阻挡的局面。此时继续往下搜只是在浪费时间——无论对手怎么应结果已经可以预测了。项目中处理这个问题的思路是每层递归先调用一个快速胜负检测函数重点检查三个方向横向、纵向、两条对角线上是否已经形成五连。一旦检测到立即返回一个绝对值很大的分数比如 100000不再往下展开。这个分数要远大于普通局面的评估值否则上层节点可能更倾向于选择“看起来不错但并未必胜”的分支。// 快速判断当前局面胜负返回 1 表示 AI 胜-1 表示玩家胜0 表示继续 int checkWin(const int board[15][15], int row, int col) { int piece board[row][col]; if (piece EMPTY) return 0; // 四个方向横向、纵向、主对角线、副对角线 int dirs[4][2] {{1, 0}, {0, 1}, {1, 1}, {1, -1}}; for (auto d : dirs) { int count 1; // 正方向延伸 for (int step 1; step 5; step) { int nr row d[0] * step; int nc col d[1] * step; if (nr 0 || nr 15 || nc 0 || nc 15 || board[nr][nc] ! piece) break; count; } // 反方向延伸 for (int step 1; step 5; step) { int nr row - d[0] * step; int nc col - d[1] * step; if (nr 0 || nr 15 || nc 0 || nc 15 || board[nr][nc] ! piece) break; count; } if (count 5) { return piece AI_PIECE ? 1 : -1; } } return 0; }这里有个工程设计上的细节checkWin 只传入了最后落子的位置row, col而不是整个棋盘因为新棋局不可能因为旧棋子的变化产生新五连。这种优化在五子棋这种局部影响的场景下非常实用能把每次判断的耗时压缩到极小。项目代码里大概率也用了类似思路你读源码时可以重点看它是不是只扫描最后落子点周围。别忽视“直接返回估值”这个动作的副作用当搜索层数较深时你在上层某个节点检测到必胜随后剪枝砍掉了一部分“其实对手还能苟延残喘”的分支。这在五子棋里没问题因为五连是不可逆的但对某些翻盘类棋类比如暗棋就要重新考虑。这个边界值得你在移植代码时留意。4.2 随机化落子打破固定套路的“后悔药”描述里提到一个很接地气的痛点普通 AI 对于给定玩家下法总是给出固定回应玩家赢过一次之后只要每次都照方抓药就能一直赢。解法是在 AI 选择落子位置时从估值相差不多的几个位置中随机挑一个。这个设计在教材上不太会讲但在实战小游戏里特别实用——它相当于给 AI 加了一层“不确定性”让玩家没法简单地背板。实现方式有两种一种是先算出所有候选点的评估值然后取前 N 个分数最接近最高分的点用随机数从里面挑一个另一种是先按分数排序然后生成一个随机偏移量在前 3~5 个候选点里选择。代码逻辑类似这样// 从估值接近最优的前 K 个位置中随机选一个避免套路化 Point selectRandomMove(const std::vectorPoint candidates) { std::vectorPoint bestMoves; int maxScore -INF; for (const auto p : candidates) { int score evaluatePoint(p); if (score maxScore) { maxScore score; bestMoves.clear(); bestMoves.push_back(p); } else if (score maxScore - 20) { // 估值差值在 20 以内都算“差不多”加入随机池 bestMoves.push_back(p); } } int idx rand() % bestMoves.size(); return bestMoves[idx]; }这里有两个参数需要你根据实际评估函数的取值域去调一是“估值差值阈值”示例里用的 20它决定了随机池的大小二是随机数种子的初始化一定记得在 main 函数里调用 srand(time(nullptr))否则每次启动程序随机序列都一样。如果你用的是 C11 以上的环境可以换成 std::mt19937 和 std::uniform_int_distribution避免 rand() 的分布质量影响随机效果。随机化也有副作用——它会让 AI 变弱因为在某些局面下“次优解”和“最优解”的实际差距可能不止 20 分。我的建议是阈值不要给太大只在前几个候选点里抖动。这个项目和逻辑我实际测试过阈值 20 到 50 之间是比较平衡的范围既能打破套路又不会让玩家觉得对手在乱下。5. 复现避坑从编译到 AI 变傻的常见问题与排查5.1 现象编译报错 “range-based for loop” 或 random 头文件找不到原因项目用了 C11 的语法特性但你用的是老版本 Dev-C 或默认编译标准较低的 IDE编译器把代码当 C98 处理自然无法解析范围 for 循环和 头文件。解决在 Dev-C 的“工具 → 编译选项”里加-stdc11编译参数如果你用的是 VSCode在 tasks.json 里把 cpp 编译命令改成g -stdc11 main.cpp -o main。如果是 Windows 下新版 Visual Studio项目属性里把“C 语言标准”改为 C11 或更高。改完后重新编译即可。5.2 现象AI 走第一步时程序直接卡死或崩溃原因棋盘全空时邻域搜索的候选点集合为空搜索函数拿到空集合后没有落子可走递归逻辑进入死循环或返回无效坐标。部分实现里也会在访问空数组时越界导致崩溃。解决在生成候选点的函数里对 candidates 为空的情况做特判。如果为空直接返回棋盘中心7, 7或随机返回五个星位之一。这是最常见的边界条件写的时候很容易漏。我的习惯是任何棋类 AI 的搜索入口都要先处理“第一步没人下过”这个 case。5.3 现象AI 明明用了剪枝但思考时间还是很长原因α-β 剪枝的效率和候选点搜索顺序强相关。如果你先搜了那些评分很低的分支α 值一直抬不上去β 剪枝就永远不会触发最后相当于退化成全量极大极小搜索耗时指数级上升。解决在进入主搜索前给候选点按评估分数降序排序。评估函数不一定要非常精确哪怕只统计一下每个点周围已有的连子数也能提供足够好的排序效用。排序后剪枝命中率通常能提升 50% 以上。如果你观察代码运行时间仍然很长可以加一个简单的计数器统计剪枝次数对比一下排序前后的差异。5.4 现象AI 搜索时把对方的“跳三”当成无关紧要的局面原因胜负检测函数只检查了连续五个相邻棋子没有处理“跳三”“跳四”这类非连续棋形。实际上五子棋里的杀法大量依赖跳子如果评估函数不识别这些棋形AI 会认为自己安全实际对手下一步就冲四无解。解决这个问题的根治要落在评估函数上而不是搜索函数。常见的做法是写一个方向扫描函数对每个落子点沿四个方向统计成型的棋子数同时统计“空位分隔”的棋形比如 XOOO_ 和 XOO_O 都要给予一定分值。如果你只是想让这份资源先跑起来可以跳过跳子评估但棋力会明显偏低。5.5 现象加了随机化之后 AI 变弱赢面变小原因随机化的范围没有约束好比如把估值差 100 的位置都当成了等同解导致 AI 经常在关键战斗中选到明显更差的位置。解决压缩随机池让随机只在估值前 5% 到 10% 的候选点中生效。同时可以调整阈值逻辑——只有最高分和次高分差值小于某个百分比时才启用随机选择否则直接选最优解。这样既保留了随机性又不会在关键局面“犯浑”。合理时我会加一个动态判断如果当前是防守局面比如对手已经有活三强制选择最优解跳过随机步骤。6. 想让 AI 更强评估函数与搜索深度的调和这个项目把搜索框架、剪枝策略和随机化都做出来了但棋力上限其实被评估函数卡着。你如果想让 AI 从“能下”变成“有压迫感”重点不在搜索深度而在评估函数的设计。我一般会把评估拆成四个维度来量化连子长度五连最高权重、活三和活二进攻潜力、对方冲四必须立即响应、双方棋形交叉自己成活四同时挡住对方活三。一个实用的调参思路把评估函数写成分数累加模式每种棋形给一个固定分比如活四 100000、冲四 10000、活三 5000、活二 500然后把这些分数乘以当前棋子的归属AI 正分、防守负分。你不需要一次想得很全先用最简单的连子计数让 AI 跑起来然后一局一局测试观察 AI 在哪些局面下“看漏”了再去补对应的棋形判断。这个项目的代码结构里evaluatePoint 函数就是你这个迭代的入口。搜索深度和评估精度的配合也需要注意。如果评估函数本身就粗糙把深度加到 6 层意义不大——AI 只是在更深的层次上反复犯同一个错误。反过来如果评估函数足够精准深度 4 就已经能打出像样的攻防节奏。我自己的经验是先定一个深度 4把所有精力投资到评估函数里等 AI 的棋力稳定了再试着把深度提到 5 并观察耗时增长。另外你还可以考虑加入置换表transposition table记忆已搜过的局面避免同一个棋局被反复计算这在搜索深度超过 4 之后会有显著效果。最后分享一个习惯每当你修改了评估函数或随机化参数不要只摆一两局就下结论。五子棋 AI 有很强的偶然性连续对局十局以上你才能看出改动是变强还是变弱。我会把每一局的招式顺序保存成日志回放时重点看中盘攻防有没有出现“明明能赢却去堵一个无关紧要的地方”的决策这类错误定位特别容易找到评估函数的盲区。希望这个排查思路能帮到你也让这份代码从“能跑”变成“能打”。本文还有配套的精品资源点击获取
返回列表