无论你是想给自己的五子棋小程序加点智能,还是拿它练手算法基础,这篇文章都值得读完。我用了大量业余时间调过好几个版本的五子棋AI,从只会乱走的随机落子,到能和你打得有来有回的小高手,期间踩了不少坑,也积累了非常多实用经验。下面我会把C++实现五子棋AI的完整思路、关键算法、具体代码和调试技巧一次性讲清楚。就算你之前没写过棋类AI,只要能跟上递归搜索那段思路,也可以把代码跑起来。
五子棋AI的核心其实就两件事:评估局面和搜索棋路。评估局面向AI展示“哪些位置对我有利、哪些位置对对手有利”,搜索棋路则是让AI向前看几步,找到能最大化自己优势、同时压制对方的落子。两者缺一不可,只有评估函数而不会搜索,AI就是个一步棋的“近视眼”;只会搜索而不会评估,搜索再深也分不清好局面和坏局面。
1. 架构设计与数据结构选型
1.1 为什么用二维数组而不是位棋盘
五子棋棋盘是15×15的格子,总共225个位置。因为格子少,我见过很多人用类似国际象棋的位棋盘来加速,也就是用一个64位整数表示一条线。但实践下来,二维数组反而更直接可靠。原因有三:
- 五子棋的棋型判断(连五、活四、冲四等)需要频繁检查横、竖、两条斜线四个方向,二维数组的坐标计算和自然语言描述完全一致,不容易出错。
- C++的二维数组访问极快,225个格子遍历一次的性能损耗几乎可以忽略不计。
- 位棋盘虽然能加速某些位运算,但引入的转换逻辑和调试复杂度往往会浪费更多时间。
我推荐用最朴素的定义:
const int BOARD_SIZE = 15; enum class Piece { Empty = 0, Black = 1, White = 2 }; Piece board[BOARD_SIZE][BOARD_SIZE];这个枚举定义很重要,它让整个棋盘状态的读写都变得可读。注意不要在初始化时直接把board清零后就用,很多新手容易忽略每局开始要重置,建议写一个resetBoard()函数,把每个格子的值赋成Piece::Empty。
1.2 坐标系统与“下一手棋”的封装
为了方便后续的搜索和评估,我习惯封装一个moveToXY和坐标生成器。这里有一个非常关键的设计决定:AI的落子位置应当生成在一个“候选列表”里,而不是每次都扫全棋盘。后者的复杂度是固定的225,看起来不大,但如果你在Alpha-Beta剪枝的递归中频繁扫描,每层搜索的常数会明显拖慢速度。
候选列表的生成逻辑很简单:只需扫描那些“离已有棋子2格以内”的空位。这样可以大幅缩小搜索分支数,从最初的225缩小到通常几十个甚至十几个,这对剪枝效率提升是决定性的。
有一个细节值得提:棋盘坐标走斜线是最容易出现评估错误的点。我早期版本的评估函数只计算了横向和纵向,结果AI完全不会防守斜向活三,被一个简单双三打死。所以坐标遍历时,一定要把两种斜向(左上到右下、右上到左下)都算全。
2. 棋型识别与评估函数设计
2.1 从“零”到评估:哪些棋型值钱
评估函数是AI的“审美观”,它决定了AI认为什么局面好、什么局面差。五子棋没有国际象棋那么复杂的子力差异,核心就是看棋型连接情况。我把棋型按价值从高到低排了个表,这个表是整个评估体系的基础:
| 棋型 | 典型示例 | 估值参考 |
|---|---|---|
| 连五 | XXXXX | 1000000 |
| 活四 | XXXX | 100000 |
| 冲四 | XXX_X 或 XXXX | 10000 |
| 活三 | XXX | 5000 |
| 眠三 | XX_X或 X_XX_ | 800 |
| 活二 | XX | 500 |
| 眠二 | X_X | 80 |
这里每个“_”代表一个空位。要注意实际判定时并不是看见“XXX”就一定是活三,得看两端是否为空。如果一端被堵死,那就是眠三,价值会掉一个档次。这就是棋型识别里最琐碎但最重要的部分——必须同时看两端。
我建议把所有棋型判断写成独立的辅助函数,比如isLiveThree、isRushFour,不要全都堆在一个大函数里。这样单个函数逻辑简单,后面调试也轻松。
2.2 方向聚合与攻防平衡
评估函数要分别对黑方和白方给出分数,然后合成一个总分。常用公式是:
int evaluate() { int blackScore = evaluateFor(Piece::Black); int whiteScore = evaluateFor(Piece::White); return blackScore - whiteScore; }这样做假设AI总是“黑方”,分数越高说明黑方越有利。对于白方AI,你只要把落子角色换掉,或者直接把公式改成whiteScore - blackScore即可。
evaluateFor的实现思路是遍历所有位置,对每个位置沿四个方向检测棋型。但要注意一个效率问题:同样的棋型会被重复计算四次。比如一行五个黑子,横向检测时会被头尾不同的起点各算一遍,分数爆表。解决办法是在检测棋型时约定一个方向规则,例如只有“从当前点向左/上/左上/右上方向延伸”时才开始计算,避免重复计数。
攻防平衡是我花了很多时间调的点:AI如果只看自己怎么进攻,它会漏掉对方的活三甚至冲四。所以我最终采用的方式是,同时计算己方和对方的评估分数,并在搜索时综合考虑。也就是说评估函数并不只返回一个绝对分数,而是返回双方分数的加权差。
我常用的评估代码骨架长这样:
int evaluateFor(Piece side) { int score = 0; for (int r = 0; r < BOARD_SIZE; ++r) { for (int c = 0; c < BOARD_SIZE; ++c) { if (board[r][c] != side) continue; // 水平方向 score += evaluateLine(r, c, 0, 1, side); // 垂直方向 score += evaluateLine(r, c, 1, 0, side); // 对角线方向(右下) score += evaluateLine(r, c, 1, 1, side); // 对角线方向(右上) score += evaluateLine(r, c, -1, 1, side); } } return score; }注意这里的evaluateLine内部需要防止重复统计。我个人的经验是:让每个棋型在“最左上角”的那个棋子处开始计算,这样一整条线只会被计入一次。比如水平方向的连续五个黑子,只在最左边那个黑子触发加分成,右边的四个点由于左边还有棋子,不会重复统计。
3. 搜索算法核心:极小化极大与Alpha-Beta剪枝
3.1 递归搜索框架:让AI“想”好几步
只靠评估函数下棋的AI很弱,因为它只看了一步:选个当前评估分数最高的位置,然后落子。真正的对抗性搜索要用极小化极大算法:AI假设自己走一步后,对手会在新局面上选对他最有利的一步,然后AI再回应……这样你来我往,直到达到设定的搜索深度。
用递归伪代码表达就是:
int minimax(int depth, bool isMaximizing, Piece currentTurn) { if (depth == 0) return evaluate(); // 检查胜负等终止条件 if (isMaximizing) { int best = -INT_MAX; for (auto move : generateCandidates()) { board[move.r][move.c] = currentTurn; best = max(best, minimax(depth - 1, false, opponent(currentTurn))); board[move.r][move.c] = Piece::Empty; } return best; } else { int best = INT_MAX; for (auto move : generateCandidates()) { board[move.r][move.c] = currentTurn; best = min(best, minimax(depth - 1, true, opponent(currentTurn))); board[move.r][move.c] = Piece::Empty; } return best; } }这个递归极其直观,但直接这么写会很慢。原因很简单:每个节点都生成几十个候选,每层节点数量指数级爆炸。比如深度4,平均每个节点40个候选,那就是40的4次方,256万次评估。依然很吃力。所以必须加剪枝。
3.2 Alpha-Beta剪枝的落地细节
Alpha-Beta剪枝的核心思想是:既然搜索树在某一层已经发现了足够好的选项,那么比它更差的分支就不用继续探索了。
实现的本质是给递归函数传递两个参数:alpha和beta。alpha表示当前已知的对执棋者最好的选择,beta表示对手能接受的最坏结果。当某一分支的返回值已经让alpha >= beta,就可以直接剪掉,不再搜索。
用负极大值形式写,代码会简洁得多:
int negamax(int depth, int alpha, int beta, Piece curPlayer) { if (depth == 0) return evaluateFor(curPlayer) - evaluateFor(opponent(curPlayer)); for (auto move : generateCandidates()) { board[move.r][move.c] = curPlayer; int score = -negamax(depth - 1, -beta, -alpha, opponent(curPlayer)); board[move.r][move.c] = Piece::Empty; if (score > alpha) { alpha = score; if (alpha >= beta) break; // 剪枝 } } return alpha; }这里的-negamax和参数交换(-beta, -alpha)看起来有点绕,但这是最标准的写法。关键在于:evaluateFor(curPlayer) - evaluateFor(opponent(curPlayer))这个公式,从当前玩家的视角看,分数永远是“我比对手好多少”,所以轮到对手时分数取反。
剪枝的效率高度依赖候选排序。如果搜索先看的是好棋,那么alpha会快速变大,后面的垃圾棋很快触发alpha >= beta,剪枝效果就特别好。如果先看的是烂棋,alpha一直不涨,那几乎等于没剪枝。所以下面一节讲的启发式排序,不是优化项,而是必须项。
4. 启发式排序与候选点生成
4.1 缩小候选范围:不要扫全棋盘
我一开始犯过傻,每次递归都遍历全部225个格子生成候选。15×15的棋盘看似不大,但在搜索深度高时,多出来的每一分都是一层分支乘数。正确做法是只生成紧邻已有棋子的空位。
具体实现:维护一个lastMoves列表或直接扫描棋盘,找到那些“周围八格内有棋子”的空格。这个操作每层递归都要做,但每次只产生很少的空位,通常在10~40之间。
std::vector<Move> generateCandidates() { std::vector<Move> cands; bool nearPiece[BOARD_SIZE][BOARD_SIZE] = {false}; for (int r = 0; r < BOARD_SIZE; ++r) { for (int c = 0; c < BOARD_SIZE; ++c) { if (board[r][c] == Piece::Empty) continue; for (int dr = -2; dr <= 2; ++dr) { for (int dc = -2; dc <= 2; ++dc) { int nr = r + dr, nc = c + dc; if (inBoard(nr, nc) && board[nr][nc] == Piece::Empty) nearPiece[nr][nc] = true; } } } } for (int r = 0; r < BOARD_SIZE; ++r) for (int c = 0; c < BOARD_SIZE; ++c) if (nearPiece[r][c]) cands.push_back({r, c}); return cands; }这里距离选择2格而不是1格,是为了捕捉跳二这类间隔棋型。代价是候选点数稍多,但评估阶段能识别更多有潜力的形状,综合收益是正的。
4.2 排序策略:先按“评估分”从高到低
Alpha-Beta的剪枝效果和候选顺序强相关。最朴素的排序法是:先算每个候选位置在当前局面下的临时评估分,然后从高到低排序。这个排序不需太准,只要大致把好棋放前面就行。实现时可以用std::sort,评分用单步评估函数。
int scoreMove(Move m, Piece curPlayer) { board[m.r][m.c] = curPlayer; int score = evaluateFor(curPlayer) - evaluateFor(opponent(curPlayer)); board[m.r][m.c] = Piece::Empty; return score; }排序后递归调用:
std::vector<Move> cands = generateCandidates(); std::sort(cands.begin(), cands.end(), [&](Move a, Move b) { return scoreMove(a, curPlayer) > scoreMove(b, curPlayer); });实测下来这个简单的排序就能让搜索缩小好几倍耗时,推荐优先实现。等基础版本跑通后,你可以进阶使用杀手启发——记录每层剪枝时导致剪枝的那手棋,在下一次搜索同一深度时优先尝试它。这个技巧能显著提升深层的剪枝率,代码量也不大。
另外要注意,搜索过程中你会在board上反复做临时落子和撤销。为了让排序评分不影响后续搜索状态,每次scoreMove里落完子后必须立即恢复,这虽然频繁,但代价很小。
5. 代码实现与调优经验
5.1 完整搜索与主循环实现
我这里的核心搜索函数采用“迭代加深”的办法,而不是一次性固定深度。迭代加深的意思是:先搜索深度1,得出最佳着法;再搜索深度2,再得出更佳着法……直到到达时间限制或最大深度。好处是每个时间片段都有一个可用的结果,不会因为某次搜索过深导致卡死。
主循环参考这段:
int maxDepth = 6; // 可以按需调整 Move bestMove; for (int d = 1; d <= maxDepth; ++d) { int alpha = -INT_MAX, beta = INT_MAX; int bestScore = -INT_MAX; std::vector<Move> cands = generateCandidates(); sortCandidates(cands); for (auto m : cands) { board[m.r][m.c] = curPlayer; int score = -negamax(d - 1, -beta, -alpha, opponent(curPlayer)); board[m.r][m.c] = Piece::Empty; if (score > bestScore) { bestScore = score; bestMove = m; } if (bestScore > alpha) alpha = bestScore; } }这段代码里有个细节:bestMove每层都会更新,但只有更深层的搜索结果才会作为最后返回值。这意味着如果你时间紧,可以在某层搜索未完成时强制终止,也会有一个相对不错的着法。
我用实际测试告诉你不同深度的效果差异:搜索深度1~2的AI基本是新手水平;深度4能挡住大部分简单陷阱,但会漏掉长线双活三;深度6已经能轻松打赢普通娱乐玩家;深度8则几乎不会犯低级错误,开局和中盘表现接近强手。普通机器跑深度6已经满足绝大多数对局需求,再往上升级主要是优化剪枝和评估速度。
5.2 胜负判断的边界处理
在negamax中如果你只依赖depth==0作为终止条件,会有个大问题:棋盘上明明已经出现连五,搜索还会继续往下算。正确做法是在进入递归前,先检查当前落子是否能和已有棋子形成连五。一旦检测到连五,就返回一个极大值或极小值,提前结束该分支。
bool checkWin(int r, int c, Piece player) { int dirs[4][2] = {{0,1},{1,0},{1,1},{1,-1}}; for (auto &dir : dirs) { int cnt = 1; for (int k = 1; ; ++k) { // 正方向 int nr = r + dir[0]*k, nc = c + dir[1]*k; if (!inBoard(nr, nc) || board[nr][nc] != player) break; ++cnt; } for (int k = 1; ; ++k) { // 反方向 int nr = r - dir[0]*k, nc = c - dir[1]*k; if (!inBoard(nr, nc) || board[nr][nc] != player) break; ++cnt; } if (cnt >= 5) return true; } return false; }注意这个函数里的双循环,我故意没有把正方向和反方向合并,因为合并容易在坐标加减上出错。反方向用减号,正方向用加号,分开写逻辑更清楚。
在搜索函数里,落子后应立刻检查:
if (checkWin(move.r, move.c, curPlayer)) { board[move.r][move.c] = Piece::Empty; return WIN_SCORE - depth; // 深度越浅获胜越好 }WIN_SCORE - depth是为了让AI在保证赢的前提下尽量赢得更快,而不是绕一大圈赢,这样棋风更自然。
5.3 评估函数权重调参的独家经验
评估权重的选择是整个AI效果的分水岭。网上很多代码直接把连五、活四、冲四等分数写成一成不变的大数,但实战中你会发现它会非常“贪杀”,明明该防守时却跑去进攻。我在调参时总结出了几个原则:
- 防守分和进攻分不能相等,通常进攻分要略高于防守分,但要控制比例。我最终用的比例大约是1.1~1.2倍进攻优势,太大会导致见死不救。
- 活三和冲四的分数差不要拉得太大。如果活三分是5000,冲四分是50000,AI会一直想冲四而忽略活三,导致错过更隐蔽的连续进攻。
- 评估函数里禁止负分叠加过多。很多实现会陷入“把所有眠二累加”导致的虚高,我建议大量压缩低价值棋型的权重,宁可不加分,也不要让它们干扰高价值棋型的判断。
我的主力权重表长这样:
| 棋型 | 进攻权重 | 防守权重 |
|---|---|---|
| 连五 | 1000000 | 1000000 |
| 活四 | 100000 | 120000 |
| 冲四 | 20000 | 25000 |
| 活三 | 8000 | 7000 |
| 眠三 | 1500 | 1800 |
| 活二 | 600 | 400 |
| 眠二 | 100 | 120 |
注意活四防守权重高于进攻权重,是因为当对手出现活四时,AI必须优先应对而不是自己进攻;但连五出现时输赢已定,两者都是满分。每次改动权重表后,我都建议跑大量对局验证,不要只看几个典型局面。
6. 常见问题与调试心得
6.1 AI在优势局面下乱走
这个问题十有八九出在评估函数的单向性。如果你只计算了AI自己棋型的分数,AI就会觉得自己赢定了,然后到处进攻,却完全无视对手已经准备形成的连五。修复方法就是上一节说的:评估函数必须同时计算双方分数并做差。当你看到AI明明领先却还在乱走,优先检查它是否“看不到”对手的好棋。
6.2 剪枝导致漏掉必胜手
Alpha-Beta剪枝在正确实现下是不会漏掉最优解的,但如果你把剪枝条件写错位置就会出问题。我踩过的一个典型错误是:在排序后的候选循环里,先更新bestScore,再更新alpha,但剪枝判断却用了旧的alpha值,导致某个分支被误剪。正确写法是每算完一着都更新alpha,再判断alpha >= beta。
更隐蔽的一个坑是:负极大值形式中评估函数的正负号必须一致。我调试时发现AI的同一手棋在不同深度结果完全不同,后来定位到是评估函数在返回时说成“当前玩家优势”,但递归时没有正确取反,导致搜索树评估方向混乱。
6.3 速度慢得离谱
如果深度4就已经卡顿,先检查两个地方:候选点是否真的限定在棋子附近;评估函数里是否做了重复扫描。我见过一个现场是从全棋盘每个点出发沿四个方向做棋型扫描,然后每步都要遍历一遍225个点,算下来每层就是900次扫描,速度当然上不去。
90%的速度问题都能靠“缩小候选+排序+剪枝”解决。剩下10%可以考虑用置换表缓存相同局面的评估结果。不过在五子棋里棋盘状态切换非常频繁,置换表的命中率通常不像国际象棋那样高,所以我个人认为基础玩家可以先不做。
6.4 调试技巧:用日志树而不是猜
我调试搜索函数时最常用的办法是打印搜索树。在递归函数开头输出当前深度、坐标、alpha、beta和落子玩家,然后手动验证某一步的分数是否合理。一开始觉得打印太多看不过来,后来发现只需在可疑深度(比如深度1、2)打印就有奇效。
另一个技巧是写一个简单的局面回放工具:把AI认为的最佳路线按落子顺序展示在控制台棋盘上,这样你能直观看到AI眼中的“进攻路线”和“防守路线”。当AI棋路很蠢时,你往往能一眼看出它漏了什么形状。
6.5 从命令行到图形界面的扩展
许多开发者(包括我早期)都只做控制台版本,黑底白字跑一步打印一行。如果你后续想接图形界面,核心搜索代码完全不用改,只需要把用户输入从键盘读取换成鼠标点击坐标映射,把棋盘渲染换成任意画框库。数据格式保持(row, col)即可。
我给一个扩展建议:把当前AI的棋力、搜索深度、耗时统计用简单的文本面板展示出来。这在你调参时极有价值,因为你能直观看到深度增加带来耗时爆炸,或者某些权重设置导致AI突然变莽。
最后的个人体会
按这套方案,我从零写出的第一个能正常对局的五子棋AI大概花了两天时间。现在回头想,最大的瓶颈不是算法本身,而是评估函数里那些无声的权重选择。你可能会花好几个晚上反复调权重,只为了让AI不再“莫名其妙送死”。
我想劝你一句:先别追求一步到位的完美AI,先把最小可用版本跑起来。哪怕AI第一步只会下在棋盘正中央,第二步只会在周围随机落子,只要它能完成“评估+搜索+落子”的循环,后面所有优化都有落脚点。后续可以再从杀手的优化、局面的预剪枝、甚至简单的开局库开始,慢慢把AI变强。
调试中最大的快乐其实不是AI赢了,而是你亲眼看着一个只会傻下中心的程序,逐渐能和你纠缠、反击、设陷阱。等它终于能连续做几个活三逼你防守时,你会觉得那些反复修改权重的夜晚全都值了。