ARTICLE DETAIL

资讯详情

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

Python+PyQt5五子棋AI:极小极大搜索与α-β剪枝实战

Python+PyQt5五子棋AI:极小极大搜索与α-β剪枝实战 简介这是一套面向计算机相关专业学生与AI入门学习者的毕业设计级五子棋项目采用Python与PyQt5构建图形界面核心实现人机博弈并引入深度优先搜索与α-β剪枝算法优化落子决策适合作为毕设、课程设计或人工智能博弈算法练手案例。资源包共17个文件包含4个py源码文件、1个ui界面文件、1个qrc资源文件、1个md说明文档、1个ico图标及9张png图片压缩包约5.63MB结构清晰便于直接运行与二次开发。目前已有213人学习下载。项目代码经过完整测试运行稳定答辩评审平均分达96分读者可据此理解极大极小搜索与剪枝在棋类AI中的落地方式掌握PyQt5界面与算法逻辑的分离设计并在此基础上修改评估函数或扩展功能用于个人毕设、课设或作业演示。1. 从一盘「下不过电脑」的五子棋说起PythonPyQt5 多智能体博弈到底在做什么很多人第一次写五子棋 AI都会经历同一个瞬间界面能落子胜负能判断但电脑走棋像在掷骰子。这篇要讲的东西就是把这个「掷骰子」变成「会算棋」的完整路径——用 Python 做算法内核用 PyQt5 做交互界面把多智能体博弈的思路落到一个能跑、能玩、能改的五子棋程序里核心算法是极小极大搜索加 α-β 剪枝。它解决的问题很具体让机器在有限时间内选出「当前看起来最优」的落子点并且随着搜索深度增加棋力肉眼可见地变强。适合两类人一类是正在做毕业设计、需要一份结构完整、算法有说服力、界面能演示的项目另一类是想真正搞懂博弈树搜索、而不是只会调库的 Python 学习者。下面从环境搭建一路讲到剪枝调参中间该踩的坑我都标出来。2. 环境与工程骨架PyQt5 装不上、界面跑不起来怎么破2.1 用 conda 还是 venv先把 Python 环境钉死五子棋这个项目对 Python 版本不挑3.8 到 3.11 都能跑但 PyQt5 对版本和系统位数比较敏感。我一般用 conda 建独立环境原因是它把 Qt 相关的底层库一起管了比纯 pip 少很多「DLL load failed」的玄学问题。如果你习惯 venv 也行但要保证 pip 是最新的否则装 PyQt5 时容易卡在编译阶段。# 建一个独立环境避免污染系统 Python conda create -n gomoku python3.10 -y conda activate gomoku # 安装界面库和数值计算库 pip install PyQt55.15.9 numpy这里PyQt55.15.9是我常用的稳定版本5.15 系列在 Windows 和 macOS 上兼容性最好。numpy不是必须的但棋盘评估用数组操作会比纯 list 快不少后面讲评估函数会用到。装完先验证一下别等到写了几百行才发现界面库根本没进来。python -c from PyQt5.QtWidgets import QApplication; print(PyQt5 OK)如果这条命令报ImportError八成是环境没激活或者系统里同时装了多个 Pythonpip 装到了另一个解释器里。用which pythonWindows 用where python确认当前解释器路径再pip show PyQt5看装到哪去了两个路径对不上就是这个问题。提示如果你在 PyCharm 里跑记得把项目解释器切到刚建的 conda 环境否则 IDE 里能补全、命令行却报错来回折腾很浪费时间。2.2 工程目录怎么分算法和界面必须解耦毕业设计最容易翻车的地方是把所有逻辑塞进一个main.py界面回调里直接写搜索算法。这样写前期爽后期改一个评估权重就要动界面代码演示前一改就崩。我的做法是三层分离界面层只管画棋盘和收鼠标事件游戏逻辑层管落子、判胜、轮次AI 层只管根据当前棋盘返回一个坐标。gomoku/ ├── main.py # 程序入口启动 QApplication ├── ui/ │ └── board_widget.py # 棋盘绘制与鼠标交互 ├── core/ │ ├── game.py # 棋盘状态、落子、胜负判断 │ └── evaluator.py # 棋型评估函数 └── ai/ └── search.py # 极小极大 α-β 剪枝这样分的好处是AI 层可以脱离界面单独测试。写搜索算法时我经常在命令行里直接构造一个棋盘数组喂给搜索函数看它返回的坐标合不合理不用每次都启动图形界面。调试效率差好几倍。2.3 棋盘数据结构为什么用二维数组而不是位棋盘棋盘用 15×15 的二维 list 或 numpy 数组表示0 表示空1 表示黑棋2 表示白棋。有人会问要不要上位棋盘bitboard加速我的建议是毕业设计阶段不要。位棋盘确实快但可读性差评估函数和搜索逻辑写起来容易出错而且 15×15 的规模下普通数组加 α-β 剪枝已经能搜到 6 到 8 层够用了。import numpy as np BOARD_SIZE 15 EMPTY, BLACK, WHITE 0, 1, 2 def create_board(): # 15x15 全零棋盘dtype 用 int8 省内存 return np.zeros((BOARD_SIZE, BOARD_SIZE), dtypenp.int8)dtypenp.int8是因为棋盘上只有 0、1、2 三个值用默认的 int64 纯属浪费。搜索过程中会频繁复制棋盘小数据类型复制更快。这个细节在浅层搜索时感觉不到搜到 7 层以上就有区别了。3. 多智能体博弈的落点极小极大搜索怎么写成能跑的代码3.1 博弈树、局面评估、搜索深度三个概念先对齐多智能体博弈这个词听起来大落到五子棋上其实就一件事把对局看成一棵树根节点是当前局面每一层代表一方走一步叶子节点是某个未来局面。极小极大搜索就是从叶子往上推假设双方都走最优算出根节点该选哪一步。α-β 剪枝是在这个过程中砍掉不可能被选中的分支不改变结果只减少计算量。局面评估是整棵树的「价值观」。搜索本身不判断好坏它只是比较真正决定棋力的是评估函数给每个局面打的分。所以你会看到一个现象评估函数写得烂搜得再深也下得臭评估函数写得好搜 4 层就能压着人打。这也是为什么后面要花大篇幅讲棋型打分。搜索深度是时间和棋力的权衡。深度每加 1节点数大概翻 5 到 10 倍。15×15 棋盘上搜 4 层大概几百毫秒搜 6 层可能几秒搜 8 层不加优化会卡到界面无响应。所以实际项目里深度要配合剪枝和候选点筛选一起调。3.2 极小极大 α-β 剪枝的核心实现先给一个能直接跑的最小实现再逐段解释。这个版本假设评估函数evaluate(board, player)已经写好下一节补上。import math def minimax(board, depth, alpha, beta, maximizing, ai_player, human_player): # 到达叶子或分出胜负返回当前局面的评估分 if depth 0 or is_terminal(board): return evaluate(board, ai_player) if maximizing: best -math.inf for (r, c) in get_candidates(board): board[r][c] ai_player score minimax(board, depth - 1, alpha, beta, False, ai_player, human_player) board[r][c] EMPTY # 回溯撤销落子 best max(best, score) alpha max(alpha, best) if beta alpha: # β 剪枝对手不会让这个分支发生 break return best else: best math.inf for (r, c) in get_candidates(board): board[r][c] human_player score minimax(board, depth - 1, alpha, beta, True, ai_player, human_player) board[r][c] EMPTY best min(best, score) beta min(beta, best) if beta alpha: # α 剪枝 break return best逻辑说明maximizing为 True 时是 AI 走棋它要最大化分数为 False 时是玩家走棋玩家会最小化 AI 的分数这就是「极小极大」名字的来源。alpha记录 AI 已经能保证拿到的下界beta记录玩家能把 AI 压到的上界一旦beta alpha说明这个分支对双方都没有意义直接砍掉。参数说明depth是剩余搜索层数每递归一次减 1减到 0 就调评估函数。get_candidates(board)返回候选落子点这是性能关键——不要遍历全部 225 个空位只取已有棋子周围两格内的空位能把分支因子从 200 多降到 20 以内。def get_candidates(board, radius2): # 只考虑已有棋子附近 radius 格内的空位 candidates set() occupied np.argwhere(board ! EMPTY) for r, c in occupied: for dr in range(-radius, radius 1): for dc in range(-radius, radius 1): nr, nc r dr, c dc if 0 nr BOARD_SIZE and 0 nc BOARD_SIZE and board[nr][nc] EMPTY: candidates.add((nr, nc)) return list(candidates)radius2是经验值。取 1 会漏掉一些跳活三的防守点取 3 候选点太多、剪枝收益被稀释。开局棋盘为空时occupied是空的候选集为空所以要单独处理第一步直接下天元。3.3 根节点单独处理返回坐标而不是分数上面那个minimax返回的是分数但界面需要的是「下哪」。所以根节点要单独写一层遍历候选点、记录每个点的分数、取最大。def find_best_move(board, ai_player, human_player, depth4): best_score -math.inf best_move None alpha, beta -math.inf, math.inf for (r, c) in get_candidates(board): board[r][c] ai_player score minimax(board, depth - 1, alpha, beta, False, ai_player, human_player) board[r][c] EMPTY if score best_score: best_score score best_move (r, c) alpha max(alpha, best_score) # 根节点也要更新 alpha return best_move注意根节点这层alpha的更新不能省。很多人只在内层更新根节点不更新结果剪枝效果大打折扣搜 6 层慢得离谱。这个 bug 很隐蔽因为结果是对的只是慢不对比节点数根本发现不了。注意board[r][c] EMPTY这行回溯必须紧跟递归之后中间不能有 return 或异常跳过否则棋盘会被污染后面所有搜索都基于错误局面。我调试时习惯在递归后加一句断言确认棋盘恢复原样。4. 评估函数才是棋力命门五子棋棋型打分怎么设计4.1 从「连成五个」倒推活四、冲四、活三的分值梯度搜索只是比较评估才是判断。五子棋的评估逻辑是扫描棋盘上所有可能形成五连的线段识别出棋型按威胁程度给分。核心棋型就那么几种但分值梯度设错AI 就会做出「有活三不堵、去堵一个死二」这种让人血压升高的操作。棋型说明建议分值成五已经五连10000000活四两端都空下一手必成五100000冲四一端被堵下一手成五10000活三两端空下一手可成活四8000眠三一端被堵的三500活二两端空的二300眠二一端被堵的二50这张表的关键是量级差。活四和冲四差一个数量级因为活四无法防守冲四可以堵。活三给 8000 而不是 1000是因为活三的威胁接近冲四给低了 AI 会忽视对手的活三。这些数字不是理论推导出来的是反复对局调出来的你可以按自己的手感微调但量级关系别乱。4.2 用滑动窗口扫描棋型实现上我一般用「滑动窗口」对每个方向横、竖、两条斜线取长度为 5 的连续窗口统计窗口内双方棋子数判断棋型。def evaluate(board, ai_player): human_player BLACK if ai_player WHITE else WHITE score 0 # 四个方向横、竖、主对角、副对角 directions [(0, 1), (1, 0), (1, 1), (1, -1)] for r in range(BOARD_SIZE): for c in range(BOARD_SIZE): for dr, dc in directions: # 窗口越界就跳过 er, ec r dr * 4, c dc * 4 if not (0 er BOARD_SIZE and 0 ec BOARD_SIZE): continue window [board[r dr * i][c dc * i] for i in range(5)] score score_window(window, ai_player, human_player) return scorescore_window负责把长度为 5 的窗口映射成分值。这里有个常见误区只统计「窗口内全是 AI 棋子」的情况忽略了「窗口内 AI 有 4 子、1 空」这种更常见的威胁。正确做法是按棋子数量分档再结合空位位置判断是活还是眠。def score_window(window, ai, human): ai_count window.count(ai) human_count window.count(human) if ai_count and human_count: return 0 # 双方都有子这个窗口无价值 if ai_count 5: return 10000000 if ai_count 4: return 100000 # 简化处理实际要区分活四冲四 if ai_count 3: return 8000 if ai_count 2: return 300 if human_count 4: return -100000 # 对手威胁取负AI 会主动去堵 if human_count 3: return -8000 if human_count 2: return -300 return 0逻辑说明AI 的棋型给正分对手的棋型给负分这样极大极小的「最大化」目标就自动包含了进攻和防守。对手活三给 -8000AI 自己的活三给 8000当两者同时存在时AI 会优先处理分值绝对值更大的那个符合「先堵再攻」的直觉。参数说明这里为了讲清楚逻辑做了简化真实项目里要区分活四和冲四、活三和眠三判断方法是看窗口两端相邻格是否为空。完整实现会多几十行但分值梯度不变。如果你发现 AI 老是「堵错边」八成是活三和眠三没区分开。4.3 评估函数的性能陷阱别在叶子节点做全盘扫描上面这个evaluate是 O(15×15×4×5)大概几千次操作。单次调用不慢但搜索树叶子节点数量是几万到几十万累加起来就是主要耗时。优化方向有两个一是增量评估落子时只更新受影响的四个方向窗口不重扫全盘二是把评估结果缓存起来相同局面直接查表。from functools import lru_cache lru_cache(maxsize100000) def evaluate_cached(board_tuple, ai_player): # 把 numpy 数组转成 tuple 才能哈希缓存 board np.array(board_tuple).reshape(BOARD_SIZE, BOARD_SIZE) return evaluate(board, ai_player)lru_cache要求参数可哈希numpy 数组不行所以要先转 tuple。这个缓存命中率在搜索中相当高因为不同分支经常回到相似局面。实测能省 30% 到 50% 的时间代价是内存maxsize别设太大10 万条够用。提示缓存和 α-β 剪枝一起用时要注意剪枝会跳过一些分支导致某些局面没被评估、缓存里没有这是正常的不影响正确性。5. 避坑与排查搜索慢、AI 犯傻、界面卡死的真实原因5.1 现象AI 第一步想了十几秒才落子原因开局棋盘为空get_candidates返回空列表某些实现会退化成遍历全部 225 个空位每个位置又展开完整搜索树节点数爆炸。解决开局单独处理棋盘为空直接返回天元(7, 7)或者限制第一步只考虑中心 5×5 区域。这个判断放在find_best_move最前面一行代码省十几秒。5.2 现象AI 明明能赢却去堵对手的活二原因评估函数里对手活二给了 -300AI 自己冲四给了 10000但如果 AI 的冲四被对手先成五搜索深度不够时看不到这一步就会误判。本质是搜索深度不足评估函数「短视」。解决把搜索深度从 4 提到 6或者在评估函数里对「对手下一手能成五」的局面给极大负分强制 AI 优先防守。我一般两个都做深度保底 4关键局面用迭代加深临时加层。5.3 现象点一下棋子界面卡住两三秒才刷新原因搜索在主线程里跑PyQt5 的事件循环被阻塞界面无法重绘。这是 PyQt5 项目最典型的翻车点。解决把搜索放到QThread里算完通过信号把坐标传回主线程落子。不要在子线程里直接操作界面控件Qt 不允许跨线程操作 UI。from PyQt5.QtCore import QThread, pyqtSignal class AIThread(QThread): move_ready pyqtSignal(int, int) # 算好后发信号给主线程 def __init__(self, board, ai_player, depth): super().__init__() self.board board.copy() self.ai_player ai_player self.depth depth def run(self): move find_best_move(self.board, self.ai_player, 3 - self.ai_player, self.depth) if move: self.move_ready.emit(move[0], move[1])逻辑说明run里做耗时搜索move_ready信号由主线程的槽函数接收并落子。self.board.copy()很重要子线程用副本避免和主线程共享可变对象导致数据竞争。5.4 现象α-β 剪枝加了反而变慢原因候选点没有排序。剪枝的效果高度依赖搜索顺序先搜好棋能快速收紧 α 和 β差棋就能被大量砍掉。如果候选点随机顺序剪枝几乎不起作用。解决对候选点按「启发式分数」排序比如先算每个点周围棋子密度密度高的先搜。这一步叫 move ordering是 α-β 剪枝能不能发挥威力的关键。def order_candidates(board, candidates): # 周围棋子越多越可能是好点优先搜索 def neighbor_score(pos): r, c pos cnt 0 for dr in range(-1, 2): for dc in range(-1, 2): nr, nc r dr, c dc if 0 nr BOARD_SIZE and 0 nc BOARD_SIZE and board[nr][nc] ! EMPTY: cnt 1 return cnt return sorted(candidates, keyneighbor_score, reverseTrue)5.5 现象AI 偶尔下在已经有棋子的位置原因回溯时board[r][c] EMPTY写成了board[r][c] 0但棋盘用的是其他空值表示或者递归中途异常导致回溯没执行。解决统一用常量EMPTY别写裸 0在find_best_move返回前加一句断言确认返回坐标处确实是空的。这个 bug 在图形界面里表现为「棋子叠在一起」肉眼能看出来但定位到具体哪次递归出错要靠日志。6. 让 AI 更强一点迭代加深与置换表的组合技巧搜索深度固定有个尴尬简单局面搜 6 层很快复杂局面搜 6 层要好几秒体验不一致。我一般用迭代加深——从深度 2 开始搜搜完再搜 4、6每层记录最佳走法下一层优先搜这个走法。这样简单局面很快返回复杂局面也能在时间预算内给出当前最优。import time def iterative_deepening(board, ai_player, human_player, time_limit2.0): start time.time() best_move None for depth in range(2, 10, 2): if time.time() - start time_limit: break move find_best_move(board, ai_player, human_player, depth) if move: best_move move return best_movetime_limit是软限制每层搜完检查一次超时就停。配合置换表transposition table效果更好——把搜过的局面和它的分数存起来不同分支到达同一局面时直接复用。五子棋里局面重复率不低置换表能省 20% 到 40% 的节点。置换表的键用棋盘的哈希Python 里可以直接用hash(board.tobytes())简单够用。表的大小控制在几十万条太大反而因为内存访问慢而拖后腿。优化手段典型收益实现成本候选点筛选分支因子降 80%低move ordering剪枝效率翻倍低迭代加深时间可控中置换表节点数降 30%中增量评估评估耗时降 50%高这张表是我自己项目里的体感排序。如果时间有限先做前两个收益最大、代码最少。增量评估留到最后因为它要求评估函数和落子逻辑深度耦合改起来容易引入 bug。最后说个我自己的习惯每次调完评估权重不要只看一两盘至少和同一个对手下 20 盘记录胜负和平均搜索节点数。单盘的结果随机性太大很容易被一盘「AI 神之一手」骗过去以为调好了结果换个开局就原形毕露。棋力这东西靠的是统计不是感觉。希望帮到你。本文还有配套的精品资源点击获取
返回列表