五子棋这个项目,我前前后后做过两版。第一版用穷举和简单打分,被同学随便两步就赢了,后来重写核心,才真正落到“基于博弈搜索算法的智能五子棋设计”上。这个标题听起来像课程设计,但实际做完你会发现,它就是迷你版的棋类AI引擎:决策树、剪枝、局面评估、搜索深度权衡,一个都不少。这篇文章就围绕我重写这版智能五子棋的过程,把核心思路、踩过的坑、实测下来有用的经验一次性写透,给同样在做博弈搜索项目的同学一个能直接参照的版本。
1. 项目核心与整体方案设计
1.1 为什么选五子棋做博弈搜索
五子棋算是所有棋类里最适合入门博弈搜索的。棋盘是15×15固定大小,落子规则简单,胜负判定就是五连,但搜索空间并不小。第一步有225个位置,第二步有224个,光前两步就有五万多种组合,深度一深,搜索树膨胀速度非常快。这就逼着你必须用极小极大搜索加Alpha-Beta剪枝,而不是单纯靠枚举。
我第一版就是吃了“枚举不够用”的亏。当时棋力几乎为零,只会在当前局面附近找最长连子落子,结果对手一旦在下边缘或者隔子阻截,整条线就被打散了。后来我把架构重构成标准的博弈搜索循环:AI在每一步都假设对方会全力阻挡自己,先算计对手最狠的回应,再挑选自己能承受的方案。这就是“博弈”的本质,不是谁先连五谁就赢,而是每一步都要预判对手的下一步。
1.2 系统流程与决策主循环
我实现的主循环其实很短,核心步骤只有四步:
- 收到当前棋盘快照,先判定是否已有胜负。
- 生成候选落子位置,按启发式排序,快速筛选出值得往下搜的点。
- 进入递归搜索,对每个候选点计算局面评估分数,同时用Alpha-Beta剪枝砍掉无用分支。
- 选择评估分值最高的落子,返回坐标,更新棋盘。
这个流程里最耗时的就是第三步。搜索深度我最终设置为4层,也就是AI和对手各走两步后再打分。有些人会开6层,但在15×15棋盘上常规深度6层的搜索树数量非常大,没有好的剪枝策略根本跑不动。我试过深度4和深度6对决,深度4配合较好的评估函数,反而能赢深度6但评估粗糙的对手,因为前者对棋形的判断更准确,不是单纯看得远就能赢。
1.3 评估对象的比选
做这个项目前,我在“局面胜率计算”和“局部棋形打分”之间犹豫过一阵。前者是从概率角度估计当前局面优劣,后者是直接把棋盘拆成横竖斜四条线,把连子、冲三、活三、活四这些棋形分别计分。最后我选了棋形打分,原因是五子棋没有吃子,局面评估主要依据“连子潜力”,棋形打分计算量小还给每步棋解释了理由。对十五路棋盘来说,棋形打分的信息量已经足够支撑一个能赢过普通对手的AI。
2. 极小极大搜索与Alpha-Beta剪枝的落地实现
2.1 极小极大搜索的核心逻辑
极小极大搜索的本质是轮流选择最大和最小收益。轮到AI时,AI会选择分值最高的落子;轮到对手时,默认对手会选分值最低的落子。这个“最低”不是字面负分数,而是从AI视角看对AI最不利的落子。
我第一个版本在这里犯了个典型错误:把评估函数设计成“当前棋盘上AI的得分减去对手的得分”,然后递归时每次都最大化这个差值。这样看似乎合理,但会导致AI只考虑自己怎么连五,完全忽略了对手已经形成活四的情况。正确的动手方式是把搜索拆成两层语义:
- AI层:让自己得分最大化
- 对手层:让自己的得分最小化(即对手得分最大化)
对应的Python伪代码如下:
def minimax(board, depth, maximizing_player): if depth == 0 or check_winner(board) is not None: return evaluate(board) positions = generate_candidates(board) if maximizing_player: best = -INF for pos in positions: board.put_stone(pos, AI_COLOR) score = minimax(board, depth - 1, False) board.remove_stone(pos) best = max(best, score) return best else: best = INF for pos in positions: board.put_stone(pos, HUMAN_COLOR) score = minimax(board, depth - 1, True) board.remove_stone(pos) best = min(best, score) return best这个递归逻辑写成递归没问题,但每次都要恢复棋盘状态,我用的是放子后戳掉再放下一颗,必须保证恢复无误,否则搜索越深棋盘越乱。这是我在调试时排查了很久才发现的问题,属于典型的“状态污染”。
2.2 Alpha-Beta剪枝的正确姿势
Alpha-Beta剪枝是在极小极大基础上加两个边界值,含义非常直观:
- Alpha:AI目前最好的保底分值。
- Beta:对手目前最多能承受的分值,超过这个分对手就会选更差分支避免你得分。
递归往下传的时候,如果某个分支的分数已经小于等于Alpha,说明对手根本不会选这条分支,可以剪掉;同理如果分数大于等于Beta,AI也不会选,对应节点也可以停掉。
我落地时遇到一个经典困惑:剪枝条件写反。剪枝代码一旦写错,往往还不会报错,只是结果变差甚至直接让AI输棋。我的排查方法是固定棋盘局面,手动跑一遍搜索和加剪枝后对比,看最终返回的评估值是否与原极小极大一致。
def alphabeta(board, depth, alpha, beta, maximizing_player): if depth == 0 or check_winner(board) is not None: return evaluate(board) positions = generate_candidates(board) if maximizing_player: value = -INF for pos in positions: board.put_stone(pos, AI_COLOR) value = max(value, alphabeta(board, depth - 1, alpha, beta, False)) board.remove_stone(pos) alpha = max(alpha, value) if alpha >= beta: break return value else: value = INF for pos in positions: board.put_stone(pos, HUMAN_COLOR) value = min(value, alphabeta(board, depth - 1, alpha, beta, True)) board.remove_stone(pos) beta = min(beta, value) if alpha >= beta: break return value剪枝逻辑写对后,速度提升非常明显。我的实际测试数据是:不剪枝的深度4搜索大概要跑几秒钟,加剪枝后降到几十毫秒级别,中间还包含走法生成的耗时。这意味着Alpha-Beta剪枝在五子棋这类搜索空间里,绝对不只是锦上添花,而是决定AI能否实时落子的关键。
2.3 评估函数的设计:棋形是灵魂
评估函数是整个AI的“棋感来源”,它直接用分数告诉你当前局面好坏。我最终采用的方案是四方向扫描棋形,横、竖、两条斜线各扫一遍,然后落到每个位置上先算“我方的棋形分”,再算“对手的棋形分”,最后用“我方分减对手分”得到该位置的价值。
棋形打分表我调了很多版,最终大致如下:
| 棋形 | 分值 |
|---|---|
| 成五(赢棋) | 1000000 |
| 活四(冲四成型) | 100000 |
| 冲四(差一步成五) | 50000 |
| 活三 | 5000 |
| 眠三(被动三,被堵一头) | 200 |
| 活二 | 400 |
| 眠二 | 50 |
这里有个很容易踩的坑:冲四和活三这两个分数会牵动全局。第一版我把活三定得比冲四还高,导致AI面对对手冲四时只顾自己活三,结果被对手先一步成五。后来把冲四分数压得比活三低一点,但成五依然是绝对最高分,AI才学会防守对方的活四和冲四。
评估函数还涉及连续性的问题。同样的一个“三”,如果一边被堵死,攻击力大减,我给的分值要打折扣。具体实现里,我是把每一行变化成一个连续位置串,判断是否满足XX, X.X, XXX这样的模式,再套上面的分表。
注意:评估函数的分数直接决定AI的防守行为。宁可防守稳健一点,也不能把进攻分数调太高。如果AI总是不理对手的活三,一定是评估函数里防守类棋形分值偏低。
3. 走法生成、搜索优化与工程实现
3.1 棋盘表示与候选位置生成
棋盘我直接用二维数组表示,0为空、1为AI、2为对手。15×15的标准国际规则尺寸,数组访问一次,判断逻辑简单,性能也够。很多人用位棋盘,对五子棋来说有点杀鸡用牛刀,二维数组配合坐标偏移完全够用。
候选位置生成,最朴素的做法是遍历所有空位,但225个位置全部拿去搜索肯定超时。我采用的是“邻域拓展开算法”:先找出所有已有棋子的周围半径2以内的空位作为候选,再加上没有邻子的开局棋盘中心点。经过这一步,候选数量从225个压缩到通常是12-25个,搜索量直接少一个数量级。
这个裁剪有个代价:如果对手在很远的地方偷偷搭起了棋形,邻域搜索可能忽略。我的对策是在生成候选时额外加入一步全局“威胁检测”,扫描到对手已经出现活三或冲四时,把这个位置单独加入候选列表,并给予较高优先级。这样AI既不会被邻域裁剪拖累视野,又不会让搜索规模爆炸。
3.2 搜索深度与性能折中
深度4是我调参后的甜点值,深度6在低危盘面下偶尔也能用,但一旦候选点多,单步耗时很容易超过1秒,比赛场景里就显得迟钝。我的经验做法是“自适应深度”:
- 候选位置少(小于8个)时,用深度6。
- 候选位置多(8-20个)时,用深度4。
- 对手存在明显威胁(活三或冲四)时,强制深度5并优先搜索防守点位。
这和“先看急所,再看大局”的棋理完全一致。急所位置比普通点更早搜索,Alpha-Beta剪枝效率也更高,剪掉的分支越多,实际搜索树的节点数越少。
我还做了一个“顺序启发”:把候选点按“进攻分+防守分”的总和从大到小排序,再进入递归。这样Alpha-Beta会更快接触到高分分支,剪枝率更高,实测能再节省约30%的时间。
3.3 核心实现代码示例
我把AI落子的主函数贴出来,这是整个项目最关键的部分,其他都是围绕它服务的。
def ai_move(board): candidates = generate_candidates(board) if not candidates: return None candidates.sort(key=lambda pos: heuristic_score(board, pos), reverse=True) alpha, beta = -INF, INF best_move = candidates[0] depth = get_dynamic_depth(board, len(candidates)) for pos in candidates[:12]: board.put_stone(pos, AI_COLOR) score = alphabeta(board, depth - 1, alpha, beta, False) board.remove_stone(pos) if score > alpha: alpha = score best_move = pos if alpha >= beta: break return best_move这块的细节是我调试了很久才稳定的。候选点最多取12个,这等于把最后一层搜索树范围限制下来,避免开局阶段225个空位全要搜。一旦候选排序做得够好,被砍掉的候选点很难影响最终质量。
根节点的剪枝条件我写成了alpha >= beta直接跳出,这样会让AI在某些极端局面下快速落子,不至于因为搜索太慢而让玩家等到超时。剪枝分数就是上限,AI的每步棋不是“全盘最优”,而是“在有限计算时间和足够好的候选集内的近似最优”,这个认知能帮你少走很多弯路。
4. 实战调参与常见问题实录
4.1 评估函数“近视”导致的守不住
这是整个项目里最气人的一个Bug:AI明明看到对手已经冲四了,却优先去补自己的活三。我用日志把评估函数分值打出来才发现,对手冲四的那一点分值确实很高,但AI自己的活三分值也高,而搜索深度只有4层,AI觉得“只要我这步活三成型,后面两步还能追上”,这个判断在深度4下是成立的,但真实对局中对手根本不会等你追。
解决办法有两个方向:一是上调防守分值,让对手活四、冲四对应的评估值大幅度压制AI自己的进攻值;二是把“是否已经出现死局”作为递归终止条件,一旦检测到对手已经冲四并且没有可阻挡的交叉点,直接返回负极大值,不让搜索继续误判。
我实际采用的是两者结合。这样做之后,AI面对对手的活三和冲四,大部分时候能正确防守。但代价是AI在均势局面下会偏保守,进攻欲望不强,后来我又加了“如果潜在连五分超过防守分,则提前进攻”的规则,才让AI在领先时不至于太怂。
4.2 状态恢复不干净与重复搜索
状态污染这个坑极其隐蔽。当时AI偶尔会出现明明赢了却落子在远处的诡异行为,我排查很久,最后发现是递归回溯时只清掉了刚放下的棋子,但之前走的临时抹掉没做干净,导致在某条分支上多了一颗不该存在的子。五子棋对局一旦出现多子少子,整个评估逻辑全部失效。
我后来专门写了一个单元测试,用固定的几步棋跑多轮深度搜索,每次都对比棋盘终态,确保搜索过程中状态始终一致。这个测试帮我抓到了至少三次类似的隐性Bug。
另一个问题是重复搜索相同局面。深度搜索中很多分支是镜像对称的,例如开局第一手落在(7,7),搜索到(7,8)和(8,7)其实是同一种铺法的镜像,但评估函数会算两遍。我给候选位置加了一步坐标规范化预处理,让搜索方向统一从左上到右下按序展开,重复节点减少了不少,速度又提了一截。
4.3 性能数据记录与调参经验
我把实测跑过的数据整理了一份表,方便调整参数前做参照:
| 深度 | 平均候选数 | 单步耗时(普通配置) | 胜率表现(对深度4) |
|---|---|---|---|
| 2 | 15 | 约10ms | 明显偏弱 |
| 3 | 15 | 约80ms | 可以下但不稳定 |
| 4 | 15 | 约250ms | 基准水平 |
| 5 | 15 | 约1.2s | 略强但偏慢 |
| 6 | 15 | 约4s | 强但基本没法实时对战 |
这里的耗时是在普通笔记本上跑出来的,剪枝顺序做好后能显著提升。深度4和深度6的差距在多数局面下并不大,反而是评估函数的棋形分值设定影响更直接,说明工程上优先把评估质量做扎实,比无脑加深搜索要划算得多。
我还试过使用置换表,把已经算过的局面共得分值直接缓存,这对重复出现局面有效。五子棋棋局重复率不算特别高,置换表带来的提速有限,但代码量也不大,属于性价比尚可的优化,后续扩展时可以保留。
5. 从Minimax到更智能的扩展方向
5.1 MCTS蒙特卡洛搜索的引入思路
用Minimax加Alpha-Beta做完后,我试过对比蒙特卡洛树搜索的效果。MCTS的核心思路是模拟对局,用随机落子推演胜率,再根据胜率决定落子。它的优点是不需要手写评估函数,缺点是需要大量模拟,单步响应速度会慢很多。
我在深度4的Minimax和1000次模拟的MCTS之间做了对战测试,结果是Minimax稳定占优,原因是五子棋搜索深度不需要太深,局部棋形判断已经足够强,而MCTS在有限模拟次数下随机性太强,容易在关键攻防上失误。但如果换成更复杂棋类,MCTS就会有更硬的场景优势。这个项目如果把MCTS作为中期扩展,我和你说句实在话:除非你做的是面向科研的毕业课题,否则Minimax加良好评估函数的性价比已经足够高。
5.2 与神经网络结合的探索
我还试过引入轻量级神经网络做局面评估,让几千条带搜索标签的棋谱训练网络,然后用网络输出替代手写评估函数的数值。这样做的效果很惊艳,深度3的“神经网络评估+Minimax”已经可以和深度5的“手工评估+Minimax”打成平手。
不过这个扩展对数据量和训练流程的要求明显更高,你需要先准备大量高质量棋谱,棋谱质量决定网络上限。对普通课程设计来说,手写评估函数已经够用,神经网络更多是实验性质。如果将来要做更完整的AI项目,可以把这一步作为历届版本的升级方向。
5.3 我的复盘笔记与几点建议
做完这个项目,我对“博弈搜索”的理解从背概念到了真正会应用。以下几点是我最想记下来的:
- 评估函数的优先级永远高于搜索深度。一个看得深但判断差的AI,大概率输给一个看得浅但判断准的AI。
- Alpha-Beta剪枝的顺序启发非常关键。如果不做排序,剪枝效果会大打折扣;排序做对,很多时候同样的深度能快近一倍。
- 候选位置裁剪必须兼顾“局部重点”和“全局威胁”,只做邻域裁剪会让AI在关键攻防中短视。
- 测试时多和自己的AI对弈,而不是只看自动化测试结果。很多棋感问题,只有亲手和他下几盘才能发现。
做这个“基于博弈搜索算法的智能五子棋设计”最累的不是算法本身,而是调评估函数那段日子。我现在看到AI能稳稳地防守住对手的活四,再慢慢围出双三,那种成就感比跑通一个框架要实在得多。如果你也在做类似的项目,我建议先把评估函数打磨到让自己满意,再考虑堆搜索深度或者上花哨的扩展。一套扎实的基线方案,永远是所有后续优化的起点。