ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

从α-β剪枝到评估函数:Python实现五子棋AI的完整实践

从α-β剪枝到评估函数:Python实现五子棋AI的完整实践 简介这是一份基于 Python 与 pygame 实现的五子棋人机对战程序核心采用 α-β 剪枝优化的极大极小值搜索算法配合自定义评估函数让电脑具备较强决策能力。资源适合正在学习博弈树搜索、希望用游戏项目验证算法效果的开发者也适合高校人工智能课程或毕业设计参考。压缩包共 3 个文件包含 2 个 Python 源码与 1 个说明文本大小约 15KB结构简单清晰主程序负责棋盘绘制、先后手切换、胜负判断与 AI 走棋按钮模块用于控制游戏流程。程序支持玩家先手/后手选择、Replay 重开与 Quit 退出胜负后显示相应提示并锁定落子交互完整。已有 4703 人下载学习。通过这份资源可以快速掌握五子棋 AI 的整体框架重点理解极大极小值搜索与 α-β 剪枝的代码落地方式以及评估函数如何影响走棋智能度对后续开发其他棋类 AI 有直接的迁移价值。 五子棋人机对战这个东西网上教程确实不少但绝大多数实现停留在能跑的阶段——把Minimax一递归四层搜完要几十秒AI棋力还菜得离谱活三不挡冲四不理玩两把就想关。这篇我不打算从零给你讲什么叫树、什么叫递归而是直接说我在完整实现一个可对战的五子棋AI时真正花时间琢磨的地方评估函数怎么设计、α-β剪枝怎么剪才有效、Python的算力瓶颈怎么在pygame这个壳里绕过去。项目用纯Python实现界面用pygame画核心搜索用α-β剪枝整篇内容适合有一定Python基础、想做点拿得出手的小项目的读者。1. 从暴力搜索到α-β剪枝省掉的不是代码是时间1.1 Minimax的对局树到底在算什么东西五子棋和象棋围棋一样本质是一个博弈问题我下一步你下一步每一步都会改变盘面。如果把所有可能的走法展开就会得到一棵树——树的每一层代表一方落子叶子节点是终局或搜索深度到达后的局面从根到叶子的每条路径都是一局棋的不同走法序列。Minimax的核心假设是双方都足够聪明AI落子时选择对自己最有利的局面最大化评估值玩家落子时选择对AI最不利的局面最小化评估值。所以递归时交替取max和min回到根节点时AI选的那个分支就是当前最优解。问题是这个树大得离谱。15×15棋盘第一手有225个空位第二手224个照这个分支因子算下去搜索4层就是225×224×223×222大约25亿个节点。不加任何优化Python跑一遍等于灾难。1.2 α和β两个窗口值怎么省计算α-β剪枝不改变Minimax的搜索结果只是把那些已知不可能影响最终决策的分支直接砍掉。它的原理其实很朴素α代表MAX层AI回合已经找到的最大下界通俗说就是AI最好能拿到的分数底线。β代表MIN层玩家回合已经找到的最小上界就是玩家最低能把AI压到的分数上限。递归过程中一旦某个节点出现α ≥ β的情况就说明MAX方再怎么往下搜也不可能超过当前已有的最优选择这个节点剩余的分支全部剪掉。打个比方你买东西已经在第一家店问到了50元的报价第二家店刚开口说我们所有东西都超过60元你就不用听他详细介绍了——这个信息已经不足以改变你的决策。剪枝干的就是这事儿。同样的道理α-β剪枝在理想顺序下能把搜索量从n缩减到大约√n。对五子棋这种宽分支游戏来说这决定了你的AI在Python里能不能跑得动。2. 评估函数AI棋力的真正分水岭剪枝算法本身是通用的换任何棋类都能用同一套框架。真正让五子棋AI会下棋的是评估函数——也就是给每个盘面打分的函数。我在调试过程中最大的感受是评估函数的质量直接决定AI的棋力剪枝只是决定AI多快能算出棋。2.1 棋型识别活三、冲四这些棋型怎么量化五子棋的棋型判断说穿了就两个维度这一条线上连续同色棋子的数量以及两端的状态通畅还是被堵死。我采用的方案是遍历盘面上所有棋子对每颗棋子在四个方向横、竖、两条对角线上分别统计连续子数和两端空位情况然后查表打分。SCORES { 5: 1000000, # 五连 4: 500000, # 活四 3: 50000, # 活三 2: 500, # 活二 1: 20, # 单子 }具体实现时对每个方向做延伸统计def evaluate_point(board, r, c, dr, dc, player): count 1 # 正向延伸 r1, c1 r dr, c dc while 0 r1 15 and 0 c1 15 and board[r1][c1] player: count 1 r1 dr c1 dc # 反向延伸 r2, c2 r - dr, c - dc while 0 r2 15 and 0 c2 15 and board[r2][c2] player: count 1 r2 - dr c2 - dc open1 (0 r1 15 and 0 c1 15 and board[r1][c1] 0) open2 (0 r2 15 and 0 c2 15 and board[r2][c2] 0) # 根据两端状态区分活型与眠型 if count 5: base SCORES[5] elif count 4: if open1 and open2: base SCORES[4] # 活四基本无解 elif open1 or open2: base 100000 # 冲四 else: base 0 elif count 3: if open1 and open2: base SCORES[3] # 活三下一步变活四 elif open1 or open2: base 1000 # 眠三 else: base 0 else: if open1 and open2: base SCORES[count] elif open1 or open2: base SCORES[count] // 5 else: base 0 return base这套简单评分有两个优点一是方向独立不依赖复杂的模式匹配搜索时调用很快二是便于解释棋理——活三为什么比冲四分高因为活三下一步必然成活四对手无论如何都堵不住两头。2.2 攻防权重为什么AI容易只攻不守评估函数只算己方棋型的AI下的棋往往很独——自己拼命冲完全不管对方的活三。真正可用的评估必须把对方棋型也纳入得分常见的做法是己方得分减去对方得分的加权值def evaluate_board(board, me, opponent): my_score evaluate_for_player(board, me) opp_score evaluate_for_player(board, opponent) return my_score - opp_score * 1.1这个1.1就是防守权重。大于1意味着AI会更倾向于破坏对方的好棋而不是闷头进攻。实际测试时我发现权重设得太高比如1.5以上AI会变得畏手畏脚明明自己活三可以绝杀却跑去堵对方一个活二。这个参数的调优没有标准答案建议从1.0起步打几局再按AI的表现微调。2.3 一套可用的分数表下面是我调了几天之后确定的一套基础分值读者可以直接抄后续再按自己的棋风调整棋型分值说明五连1000000已经赢了活四500000两头均畅通的四连冲四100000一头被堵的四连活三50000下一步成活四的三连眠三1000只有扩张空间的三连活二500有发展潜力的二连眠二100被限制的二连注意这套分数是数量级式的差异目的在于让AI优先处理生死攸关的棋型。如果活三和活二只差两三倍AI经常会做出追逐低价值棋型的蠢棋。3. 搜索核心实现候选点、递归与剪枝判定3.1 候选点生成从225个空位缩减到20个五子棋搜索分支因子太大是性能瓶颈的根源。一个关键优化是不要考虑所有空位只考虑已有棋子附近的点。实战中离所有棋子超过两格的位置在五步以内几乎不可能影响战局。我用的候选点生成逻辑是def get_candidate_moves(board): moves set() for r in range(15): for c in range(15): if board[r][c] ! 0: # 周围的3x3区域全部加入候选 for dr in (-1, 0, 1): for dc in (-1, 0, 1): nr, nc r dr, c dc if 0 nr 15 and 0 nc 15 and board[nr][nc] 0: moves.add((nr, nc)) return list(moves)这一步直接把平均候选数从两百多压缩到二三十搜索量呈数量级下降。实测下来纯α-β剪枝搜4层要8秒加上候选点限制后大约2秒。3.2 主体递归代码α-β剪枝的关键在于剪枝时机剪枝搜索的递归部分核心就这么几十行。难点在于alpha和beta的参数在递归过程中怎么传递。我写了两个版本对比最终用的是在MAX层和MIN层统一维护alpha/beta的写法def alpha_beta(board, depth, alpha, beta, ai_turn, me, opponent): if depth 0: return evaluate_board(board, me, opponent) moves get_candidate_moves(board) # 一个简单的排序中央优先因为五子棋中心位置通常更优 moves.sort(keylambda pos: (pos[0] - 7) ** 2 (pos[1] - 7) ** 2) if ai_turn: value -float(inf) for (r, c) in moves: board[r][c] me value max(value, alpha_beta(board, depth - 1, alpha, beta, False, me, opponent)) board[r][c] 0 alpha max(alpha, value) if alpha beta: break # 剪枝 return value else: value float(inf) for (r, c) in moves: board[r][c] opponent value min(value, alpha_beta(board, depth - 1, alpha, beta, True, me, opponent)) board[r][c] 0 beta min(beta, value) if alpha beta: break # 剪枝 return value根节点调用时alpha取负无穷、beta取正无穷因为此时AI还没有任何已知的最优值。递归过程中MAX层不断抬高alpha的下限MIN层不断压低beta的上限窗口越来越窄剪枝也就越来越频繁。3.3 落子顺序优化让剪枝更早生效α-β剪枝的效果严重依赖走法顺序——如果每次都能先搜到最优分支剪枝效率最高如果先搜到差分支剪枝就很难触发。常用的启发式排序有两个方向中心优先五子棋谱里靠近天元和星位的落子通常能控制更多交叉线排序时优先搜中心区域。上一步较优着法优先把最近一次搜索中评估分最高的几个走法排到前面下次搜索优先尝试。第一个方案实现成本极低在get_candidate_moves返回后按(r-7)^2 (c-7)^2排序即可实测能让搜索时间再缩短30%左右。第二个方案更复杂但对小项目来说收益有限不推荐一开始就做。4. Python性能实测与三个加速手段把项目跑起来之后我做了一轮性能对比数据本身就能说明问题不优化根本没法玩。方案搜索深度平均响应时间纯Minimax无剪枝4层超过60sα-β剪枝全盘候选4层8~10sα-β剪枝 候选点限制4层1~2sα-β剪枝 候选点 排序4层0.6~1.2sα-β剪枝 候选点 排序6层15~30s四层搜索1秒内出棋对局体验就能接受了。要继续挑战六层也不是不行但Python的递归调用开销摆在那里需要引入更多工程手段。第一个加速手段评估函数要轻。评估函数在搜索中被调用的次数远比你想象的多四层搜索里可能被调几万次。别在评估函数里做复杂的模式匹配或正则运算更别动态创建大量对象。我的evaluate_for_player就是双循环加四个方向的延伸统计总共几十行卡在性能和准确性之间的平衡点。第二个加速手段用线程把AI思考和界面刷新解耦。pygame的主循环负责事件响应和界面绘制如果AI搜索是阻塞式的落子后整个窗口会卡死一两秒体验很差。解法是把AI计算丢进后台线程计算完成后把结果放到队列里主循环从队列取落子坐标再执行这样玩家至少能看到AI思考中的界面状态而不是冻结窗口。第三个加速手段限制搜索深度并加入必防点检查。搜索深度设为4但在递归入口先检查当前盘面是否存在一步成五连的走法。如果AI或对手有这种必杀点直接返回该走法不用继续搜索。这相当于在一层深度时就截断了许多分支效率提升立竿见影。5. pygame对战界面让AI真正跑起来5.1 棋盘绘制、落子与胜负判定界面部分不复杂但有几个细节决定体验好坏。棋盘我用15×15的网格逻辑坐标(0,0)到(14,14)映射到像素坐标时统一加一个边距MARGIN 30 CELL 40 RADIUS 16 def grid_to_pixel(r, c): return MARGIN c * CELL, MARGIN r * CELL def pixel_to_grid(x, y): c round((x - MARGIN) / CELL) r round((y - MARGIN) / CELL) return r, c鼠标点击时先判断坐标是否落在有效格子范围内再判断该格是否为空然后落子。这个流程没什么技术含量但坐标转换那步容易出bug——很多人忘记四舍五入导致棋子落在网格线之间体验很怪。胜负判定我建议放在每次落子之后只检查最后落子的那个点而不是全盘扫描。以该点为中心沿横、竖、两条斜线四个方向各自统计连续同色棋子数任一方向达到5即判胜。def check_win(board, player, r, c): directions [(1, 0), (0, 1), (1, 1), (1, -1)] for dr, dc in directions: count 1 for step in range(1, 5): nr, nc r dr * step, c dc * step if 0 nr 15 and 0 nc 15 and board[nr][nc] player: count 1 else: break for step in range(1, 5): nr, nc r - dr * step, c - dc * step if 0 nr 15 and 0 nc 15 and board[nr][nc] player: count 1 else: break if count 5: return True return False5.2 中文显示、界面卡顿与对局状态管理pygame原生的pygame.font.Font用默认字体渲染中文会变成方框我踩过这个坑。正确做法是通过pygame.font.SysFont(simhei, 36)加载系统中文字体Windows下一般有这个黑体Linux下可以换成wqy-microhei或者notosanscjk。这句话值得记下来pygame显示中文的问题很多人卡了半小时以上。对局状态管理我用了一个简单的状态机WAITING_PLAYER、AI_THINKING、GAME_OVER。只有WAITING_PLAYER状态才响应鼠标点击AI_THINKING时只做界面刷新和后台线程轮询GAME_OVER后提示重新开始。这个状态机的价值在于它防止了玩家在AI思考时连续点击导致棋盘状态错乱这是很多小项目做得比较糙的地方。界面上的提示文字比如轮到你了、AI正在思考...、你赢了/你输了都用pygame的screen.blit叠加到画面上字体用上面提到的SysFont方案。整个主循环固定60帧AI搜索结果从队列取出后立即绘制视觉上很流畅。AI的启动线程我采用了守护线程保证主窗口关闭时AI计算线程能自动结束不会出现关不掉窗口的尴尬局面def ai_worker(board, me, opponent, result_queue): best ai_search(board, me, opponent, depth4) result_queue.put(best) if state WAITING_PLAYER and is_ai_turn: state AI_THINKING threading.Thread(targetai_worker, args(board, me, opponent, result_queue), daemonTrue).start()这套代码全部跑通之后整体项目结构其实很简单一个棋盘数据结构、一个评估和搜索模块、一个pygame界面。但核心的价值在于——你亲手把博弈树搜索这个听上去很高深的概念变成了一段能陪你下棋、偶尔还能赢你几局的程序。最后再分享一个我在调试过程中发现的小技巧别总跟AI下到终局才看效果直接在评估函数里打印某个盘面的得分结果。随手摆几个常见棋型活三、冲四双活三观察AI算出来的分数是否符合你的预期偏好比整局对弈排查问题效率高得多。评估函数调得越准剪枝算法搜出来的棋才越像人下的棋这是我个人认为这个项目中最值得反复打磨的部分。本文还有配套的精品资源点击获取
返回列表