多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

五子棋AI实战:MCTS与α-β剪枝博弈搜索算法解析与调优

五子棋AI实战:MCTS与α-β剪枝博弈搜索算法解析与调优 简介一套基于Python实现的五子棋AI算法项目融合蒙特卡洛树搜索MCTS与极大极小α-β剪枝两种经典博弈搜索策略面向毕业设计、课程设计及算法学习场景可在此基础上延申开发。压缩包内含19个文件以5个Python源码模块为主覆盖棋盘逻辑、评估函数、MCTS搜索与α-β剪枝等核心实现另附docx实验报告、md说明文档、txt辅助说明、10张png运行效果图及1个pyc编译文件整体体积约996KB目录结构清晰便于按模块阅读和定位代码。项目源码已经过严格测试可直接运行并二次扩展实验报告与md文档进一步梳理了算法原理、参数设置与测试结果帮助读者理解两种搜索策略的取舍适合需要完整五子棋博弈项目参考、撰写课程设计或毕业论文的学习者。目前已有40人学习下载。1. 五子棋 AI 的两种搜索算法蒙特卡洛树搜索与极大极小α-β 剪枝怎么选第一次用 Python 写五子棋 AI 时我踩的最大认识误差是这个游戏规则简单但搜索空间一点都不小。15×15 的棋盘如果每步只考虑 30 个候选点深度 6 层也要面对近 7 亿个局面单纯靠人脑经验写棋型规则根本覆盖不完。这份基于 Python 的源码与项目文档把蒙特卡洛树搜索MCTS和极大极小α-β 剪枝两套思路同时实现并打通了适合做毕业设计、课程设计也适合想搞懂两种博弈搜索算法实现差异的入门开发者。你拿到的不是一个只能跑界面的成品而是一套能改参数、能换算法、能解释每一步为什么这么走的可复现工程。2. 算法拆解从局面评估到博弈树搜索的工程取舍2.1 极大极小搜索的博弈树结构与评估函数设计极大极小搜索的核心是构建一棵博弈树当前局面对应根节点每一次合法落子对应一条边AI 在自己的回合选分数最大的分支对手回合选分数最小的分支。理论上只要树够深就能找到必胜路径但五子棋的天文分支数让“无限深”不现实。以空棋盘开局为例不考虑禁手时第一手有 225 个落点第二手 224 个就算用候选点过滤把平均分支收敛到 30 个左右深度 4 层也要评估约 81 万个局面深度 6 层就接近 7.3 亿个。所以工程实现必须配套两件事候选点过滤和评估函数。候选点过滤的做法很直白只考虑“棋盘上已有棋子周围的空位”。一局中后期真正有价值的落点不会离现有棋子太远这个剪枝能砍掉 80% 以上的无关空位。评估函数则决定叶子节点“值多少钱”常见做法是对黑白双方的棋型分别计数后加权相减。我拆这类项目时见过最实用的权重表大概是下面这张棋型权重含义五连100000已经赢了直接给最高分活四10000两端都能成五对手挡不住冲四1000只有一端能成五可以被堵但能制造压力活三500再走一步变活四眠三100再走一步成冲四活二50潜在的发展资源权重比例比绝对数值更重要。冲四 1000 和活三 500 的差距如果设反AI 就会在能赢的局反复去堵而不是进攻五连 100000 必须远大于一切进攻棋型之和否则评估函数会认为“对手有两个活三”比“我已经五连”更严重导致必胜局面下不下赢棋。把这一层想明白之后看极大极小搜索就不再是看递归代码而是看“深度、候选点、评估函数”三者怎么平衡。2.2 α-β 剪枝从全遍历到可落地的搜索α-β 剪枝是在极大极小搜索之上加的两个边界值α 表示 MAX 节点已经确保能拿到的最大下界β 表示 MIN 节点已经确保不会超过的最小上界。MAX 节点负责更新 αMIN 节点负责更新 β一旦某一层出现 α ≥ β说明这个分支已经不会影响最终决策直接截断返回。它的存在价值不是让单节点算得更快而是帮整棵博弈树排除“不需要看”的分支。这里有个容易被误解的点剪枝的收益高度依赖子节点顺序。如果每次递归都从最强手开始看第一个子节点就能把 α 顶得很高后续大量弱手会在边界判断中被提前丢弃如果落点顺序是乱序的α 和 β 更新缓慢剪枝可能退化回全遍历。我见过不少同学把 α-β 剪枝写成“在递归末尾统一判断”结果运行时间不降反升原因就是判断太晚、开销白花。正确的落点顺序应该由评估函数驱动先把能形成活三、冲四、直接赢棋的位置排在前面再进入递归。对五子棋场景α-β 剪枝的常用档位是深度 4~6。开局候选点多深度 6 也常会卡在 3~5 秒这时可以让程序根据候选点数量动态降深度到了残局棋盘空旷点变少深度 6 反而跑得很快。这个“深度不是固定写死”的细节是源码里很值得看的地方。2.3 蒙特卡洛树搜索用随机模拟代替精确枚举蒙特卡洛树搜索走的是另一条路不追求枚举整棵博弈树而是用大量随机模拟来逼近每个落点的胜率。每轮迭代分为选择、扩展、模拟、回传四个阶段。选择阶段从根节点出发沿着“最有价值”的子节点往下走价值用 UCB1 公式算UCB w_i / n_i C × sqrt(ln N / n_i)其中 w_i 是子节点累计胜利次数n_i 是子节点访问次数N 是父节点访问次数C 是探索常数。C 偏小会让策略更倾向于利用已知高胜率分支C 偏大则鼓励探索访问少的分支常见取值在 0.7 到 2.0 之间源码里常给 1.41 或 1.5。扩展阶段在未完全展开的节点上新增子节点模拟阶段用随机落子快速跑到终局或达到步数上限回传阶段把模拟胜负结果沿路径传到根节点。MCTS 相比极大极小搜索最大的工程优势是时间可控给一个 2 秒的时间预算它就跑 2 秒的迭代不用像固定深度搜索那样担心“这步计算量突然爆炸”。缺点是随机模拟的本质让它对终局附近的精确棋型不敏感可能出现“再走一步就赢却不走”的经典误判。把两套算法放在同一个项目里互补正是这份资源比较有价值的地方。3. 源码包结构拿到文件后先看哪几个模块3.1 目录与模块职责拿到这类源码包我一般不会先去看 AI 核心文件而是先建一个“功能地图”。合理的目录划分大致是下面这张表如果你的包内命名不同按职责去对就行文件/目录职责阅读优先级board.py棋盘数据结构、落子、胜负判定高evaluate.py棋型评估、候选点生成、落点排序高ai_minimax.py极大极小α-β 剪枝实现中ai_mcts.py蒙特卡洛树搜索实现中game.py人机/机机对弈主循环低README.md运行方式与参数说明低项目文档算法推导、复杂度分析、测试结果低真正会卡住人的边界问题几乎都集中在 board.py 和 evaluate.py棋盘坐标怎么索引、胜负怎么判定、候选点怎么排优先级。AI 文件只是在这两个模块之上组织搜索过程所以我会按“board → evaluate → 两个 AI → game”的顺序读代码。项目文档里通常会有算法伪代码和复杂度分析毕业设计答辩时这段内容才是拿分项别只盯着运行截图看。3.2 棋盘与胜负判定的数据结构五子棋棋盘用一个 15×15 的二维列表表示最直接0 表示空1 表示黑棋2 表示白棋。开局时生成全 0 列表落子就是给对应坐标赋值判定胜负只需要检查最近落子点周围四个方向是否连成五子。EMPTY, BLACK, WHITE 0, 1, 2 BOARD_SIZE 15 def new_board(): return [[EMPTY] * BOARD_SIZE for _ in range(BOARD_SIZE)] def is_win(board, row, col): if board[row][col] EMPTY: return False player board[row][col] directions ((1, 0), (0, 1), (1, 1), (1, -1)) for dr, dc in directions: count 1 r, c row dr, col dc while 0 r BOARD_SIZE and 0 c BOARD_SIZE and board[r][c] player: count 1 r dr c dc r, c row - dr, col - dc while 0 r BOARD_SIZE and 0 c BOARD_SIZE and board[r][c] player: count 1 r - dr c - dc if count 5: return True return False方向数组((1, 0), (0, 1), (1, 1), (1, -1))分别对应水平、垂直、右下对角、右上对角四条轴每个方向向正负两端延伸计数最后判断 count 是否大于等于 5。写成count 5会漏掉长连情况正规规则里六连以上同样算胜所以建议用。胜负判定是整套项目的地基两个 AI 模块都要复用这同一个函数如果这里定了“五连才算赢”后面 MCTS 模拟阶段的提前终止条件就能少走弯路。3.3 对弈主循环与接口设计主循环的职责是把玩家输入、AI 计算、胜负判断串起来。好一点的实现里AI 的对外接口通常是同一个签名传入棋盘、返回落点坐标这样你可以在同一局中随时切换 MCTS 和 α-β 剪枝。def main(): board new_board() players [human, ai_mcts] turn 0 while True: render(board) if players[turn] human: move get_human_move(board) else: move get_ai_move(board, players[turn]) if not move: break row, col move board[row][col] turn 1 if is_win(board, row, col): print(fwinner: {players[turn]}) break turn 1 - turnturn 1直接对应 BLACK1、WHITE2 的常量避免黑白颜色和数据值混用。get_ai_move内部根据算法名分发到 minimax 或 mcts 入口调用者不关心具体实现。这个解耦设计在调试时很好用怀疑 AI 算法有问题就单独把game.py的主循环抽出来跑机机对战怀疑界面有问题就把渲染部分单独测。很多课程设计项目输在“界面和 AI 耦合成一团”不是算法不行而是拆不开、没法定位。4. 把 AI 跑起来核心代码实现与参数调优4.1 极大极小与 α-β 剪枝的 Python 实现极大极小搜索的完整实现分两层递归计算分数外层遍历候选点选最高分。递归函数里要同时处理 MAX 和 MIN 两种状态通过is_maximizing标志区分是 AI 回合还是对手回合。def minimax(board, depth, alpha, beta, is_maximizing, ai_player): score evaluate_board(board, ai_player) if depth 0 or abs(score) WIN_SCORE: return score moves get_candidate_moves(board) moves sorted(moves, keylambda m: evaluate_point(board, m, ai_player), reverseTrue) if is_maximizing: best -float(inf) for row, col in moves: board[row][col] ai_player best max(best, minimax(board, depth - 1, alpha, beta, False, ai_player)) board[row][col] EMPTY alpha max(alpha, best) if alpha beta: break return best else: best float(inf) opponent 3 - ai_player for row, col in moves: board[row][col] opponent best min(best, minimax(board, depth - 1, alpha, beta, True, ai_player)) board[row][col] EMPTY beta min(beta, best) if alpha beta: break return best外层的选点函数负责把每个候选点轮流落子再调用递归得到该分支的分数def get_best_move_minimax(board, depth4): ai_player BLACK moves get_candidate_moves(board) best_move, best_score None, -float(inf) for row, col in moves: board[row][col] ai_player score minimax(board, depth - 1, -float(inf), float(inf), False, ai_player) board[row][col] EMPTY if score best_score: best_score, best_move score, (row, col) return best_move这里有三处容易被改坏的关键逻辑。第一胜负提前截断放在评估函数里通过abs(score) WIN_SCORE返回否则 AI 已经赢棋还会继续往后搜浪费时间在无意义的扩展上。第二候选点排序不是可选项而是 α-β 剪枝能提速的前提如果把这行 sorted 去掉剪枝效率会明显下滑。第三opponent 3 - ai_player是利用黑 1 白 2 的数值关系快速换算对手身份不要写成固定的 2否则 AI 执白时对手变成黑棋逻辑会出错。4.2 MCTS 节点定义与 UCB 选择策略MCTS 实现里最核心的是节点类。每个节点保存棋盘快照、访问次数和胜利次数UCB 计算公式负责在子节点之间做选择。class MCTSNode: def __init__(self, board, parentNone, moveNone): self.board board self.parent parent self.move move self.children [] self.visits 0 self.wins 0 def ucb(self, total_visits, c1.41): if self.visits 0: return float(inf) exploitation self.wins / self.visits exploration c * (2 * math.log(total_visits) / self.visits) ** 0.5 return exploitation exploration搜索入口按时间预算控制迭代次数到点后从子节点里挑访问次数最多的落点作为最终走法def mcts_search(board, time_limit2.0, c1.41): start time.monotonic() root MCTSNode(copy.deepcopy(board)) while time.monotonic() - start time_limit: node root while node.children: node max(node.children, keylambda n: n.ucb(node.visits, c)) if node.visits 0: for move in get_candidate_moves(node.board)[:8]: child_board copy.deepcopy(node.board) player current_player(child_board) child_board[move[0]][move[1]] player node.children.append(MCTSNode(child_board, parentnode, movemove)) if not node.children: break node random.choice(node.children) winner simulate_random_game(node.board) while node: node.visits 1 if winner BLACK: node.wins 1 node node.parent return max(root.children, keylambda n: n.visits).moveUCB 里访问次数为 0 的子节点直接返回无穷大保证每个新扩展节点至少被尝试一次不会被冷落。扩展阶段只取候选点前 8 个是为了控制子节点数量否则每次扩展几十个分支访问次数被稀释胜率估计反而不稳定。simulate_random_game是随机落子加is_win判定的组合最多跑 40 步没有胜负就当平局返回避免单局模拟拖垮整体迭代次数。提示MCTS 和极大极小搜索必须共用同一套is_win否则容易出现“AI 认为自己赢了界面却显示还没结束”的现象这个问题定位起来非常折磨人。4.3 难度档位与先后手参数源码里通常会把难度做成参数组合而不是写死一个函数。常见的映射关系如下难度minimax 深度MCTS 时间预算UCB C适用场景简单20.5s1.41演示、低配机器中等41.0s1.41默认档困难62.0s1.5课程设计展示疯狂85.0s1.7机机对战测试先手 AI 如果默认执黑胜率会明显高于执白这是五子棋先手优势在 AI 身上的自然体现。测试算法强弱时不能只用黑棋跑 10 局就下结论我一般会固定同一算法黑白各跑 15 局再比较综合胜率。很多从网上下载的源码会在这一点上偷懒导致你换白棋后感觉 AI“变笨了”其实不是算法退化而是评估函数只按黑棋视角写死了。5. 五子棋 AI 常见坑超时、误判与剪枝失效5.1 落子卡顿十几秒搜索预算没有上限现象AI 在开局或中盘每步要算 8 到 15 秒人在界面旁等到怀疑人生。原因极大极小搜索把深度固定成 6没考虑候选点数量随棋局变化MCTS 写成了固定迭代次数而不是固定时间预算。开局 30 个候选点、深度 6 的耗时和残局 8 个候选点、深度 6 的耗时完全不是一个量级。解决minimax 按当前候选点数量动态调整深度MCTS 改用time.monotonic()控制预算。deadline time.monotonic() time_limit depth 6 if len(get_candidate_moves(board)) 20 else 4把这段逻辑放在搜索入口比在递归内部做各种超时判断简单得多也基本不会引入状态错乱。5.2 α-β 剪枝开了反而更慢走子顺序决定剪枝效率现象在极大极小搜索里加了 alpha、beta 边界判断后运行时间不降反升。原因剪枝本身是额外开销如果子节点顺序乱几乎每个节点都要做边界判断但很少能真正截断等于白付判断成本。这属于典型的“算法原理对工程落地顺序错”。解决递归前按评估分数对候选点降序排列把活三、冲四这类强手排在最前面。排序本身也有代价但相比剪枝省下的节点量收益非常可观。5.3 MCTS 终局误判随机模拟不够收敛现象AI 已经能连成五却在最后一步选了别的位置或者在对方差一步取胜时没有去堵。原因模拟阶段随机落子走到关键终局分支的概率低如果恰好走到和终局无关的分支胜负信息就传不回来。这是 MCTS 的随机性导致的不是代码逻辑完全崩溃。解决模拟时接入is_win提前结束候选点排序时把能直接赢和能直接堵的位置提到最前。有的实现还会在扩展阶段单独检测活四和冲四让必杀节点提前成为高优先级子节点减少依赖运气。5.4 先手胜率远高于后手评估函数视角不对称现象自测黑棋胜率 70%白棋胜率只有 30%AI 看起来“只会进攻不会防守”。原因评估函数只算固定玩家的棋型分AI 执白时返回的分数没有按当前回合换算导致防守侧的威胁被低估。解决minimax 中把is_maximizing和正负号对应起来MCTS 中让回传目标跟随当前实际玩家。最简单的检查方法把 AI 分别放在黑白双方跑 20 局胜率差如果超过 15 个百分点优先查评估视角。5.5 评估权重改坏缺少回归验证现象单独看 AI 走出的几步棋感觉比之前更强了但完整对局下来胜率反而下降。原因棋型权重是全局变量一个数值变化会连锁影响几十个分支的排序。主观观察很容易被某一次漂亮进攻带偏忽略整体稳定性。解决每次改动权重前把旧的权重组合记录成一个配置快照每次改动后跑 30 局固定对拍以胜率、平均步数、平均耗时为回归指标而不是靠“感觉”。6. 验证 AI 强度让两个 AI 互搏的胜率统计法6.1 自动对拍脚本怎么写人工下棋验证太慢也看不出参数的边际效应。我一般会写一个自动对拍脚本让同一个 AI 先后执黑执白各跑若干局统计胜率和平均步数。def run_match(ai_black, ai_white, rounds30): from collections import Counter results Counter() total_steps [] for _ in range(rounds): board new_board() turn 0 while True: move (ai_black if turn 0 else ai_white)(board, 2.0) board[move[0]][move[1]] turn 1 total_steps.append(sum(1 for row in board for cell in row if cell ! EMPTY)) if is_win(board, move[0], move[1]): results[black if turn 0 else white] 1 break turn 1 - turn print(results)看结果时重点看三个指标胜率、平均步数、单步耗时。胜率是最终结论平均步数反映 AI 的进攻效率如果调参后胜率不变但平均步数从 28 降到 24说明决策更直接单步耗时则决定这个参数组合能不能放到交互界面里用。黑白各跑一半的目的是消除先手优势避免把“执黑强”误判成“算法强”。6.2 调参习惯与回归流程调参时每次只改一个变量。先固定 MCTS 时间预算为 2 秒单独调 UCB 的 C 值再固定 C 为 1.41单独调时间预算。不要同时改深度和权重否则出现结果变化时你分不清是哪个因素造成的。对极大极小搜索优先调深度和候选点数量上限评估权重放到最后调因为权重的影响最隐蔽、最难定位。有一回我把活三权重从 500 调到 800单看几步棋觉得 AI 进攻果断了不少结果 30 局互搏下来胜率反而掉了 6 个百分点原因是活三被高估后AI 在防守时会优先处理对方的活三苗头忽略了自己更紧迫的冲四威胁。从那以后我每次改评估权重都强制跑一遍 30 局快速互搏先看胜率再看平均步数确认没有回退才敢继续往下走。希望帮到你。本文还有配套的精品资源点击获取
返回列表