资讯动态

基于PyQt5的多智能体博弈五子棋:深搜与α-β剪枝实践

发布时间:2026/10/26 8:13:57 来源:尧图企业网站定制
简介基于Python与PyQt5实现的多智能体博弈AI五子棋项目是一份面向计算机相关专业学生毕业设计、课程设计或期末大作业的完整源码资料。项目覆盖人机博弈、深度搜索与α-β剪枝等核心AI算法代码附有注释适合新手阅读与二次开发也方便快速部署体验。压缩包共17个文件包含4个Python源码文件、1个PyQt界面文件(.ui)、1个资源文件(.qrc)以及9张PNG图片、1个ICO图标和1份Markdown文档整体大小约5.39MB。Python源码承载游戏逻辑与AI决策UI和资源文件负责界面呈现文档说明有助于理解项目结构项目结构清晰适合展示博弈树搜索过程。目前已有338人学习/浏览。通过这份资料读者可以获得完整的项目源代码、界面与图标素材、启动入口和README说明既能直接运行体验人机对战也能依据注释和文档梳理性剪枝、深搜等AI实现思路为毕业答辩或课程汇报提供扎实参考。1. 多智能体博弈五子棋一个能落在简历上的毕设题目如果你在找毕业设计题目大概率会盯着“基于PythonPyQt5实现多智能体博弈AI五子棋游戏人机博弈深搜α-β剪枝”这种长标题琢磨它是把界面、AI、搜索算法串成一个能玩的程序还是给外行看的包装词我的判断是这个题目很适合做课设和毕设因为每个词都能拆成独立章节最后又能合起来交付一个交互项目。项目核心价值不在“会下五子棋”而在一条完整的落地链路用Python和PyQt5搭建图形界面用深搜实现AI决策再用α-β剪枝把性能拉到可玩范围最后把算法分析写进文档。它同时覆盖人机博弈、双AI对弈和算法对比适合有Python基础但没写过GUI、希望在搜索算法上补课的人。2. 用PyQt5把棋盘画出来最小可交互界面2.1 为什么用PyQt5而不是控制台或网页做五子棋的第一版我见过不少同学用控制台打印棋盘逻辑照样能跑但答辩时总觉得少点东西。PyQt5的优势在于它和Python环境配合非常直接先确认python安装和pip正常然后执行pip install pyqt5就能开始写不需要像前端那样再装一套Node或调跨域问题。如果没装成功多数是源太慢换国内pip镜像即可装完之后要重启一下IDE否则vscode的python环境可能识别不到新的第三方包。另一个我看中的点是PyQt5的信号槽机制。棋盘点击、AI落子、线程通信都可以用信号和槽串起来比tkinter的command回调更清晰。QPainter绘制15路棋盘和棋子很快整个窗口重绘一次毫秒级别不会成为后续深搜的性能瓶颈。所以我的项目结构从一开始就分成三层Board类只管棋盘二维数组和胜负判断AI搜索类只读棋盘并返回落子坐标MainWindow负责鼠标事件和重绘。这样“人机博弈”和“多智能体博弈”共用一个Board接口后面双AI对弈时不需要为窗口操作费心。代码上最大程度避免在UI里直接操作棋盘数据。2.2 棋盘绘制与落子坐标换算下面是一段可以直接运行的PyQt5最小界面。它不包含AI只是用来验证环境和确认坐标映射代码里有注释。import sys from PyQt5.QtWidgets import QApplication, QMainWindow from PyQt5.QtGui import QPainter, QColor from PyQt5.QtCore import Qt class GobangWindow(QMainWindow): def __init__(self, n15): super().__init__() self.n n self.cell 40 self.margin 30 self.board [[0] * n for _ in range(n)] # 0空地 1黑子 2白子 self.size self.margin * 2 self.cell * (self.n - 1) self.setWindowTitle(PyQt5 五子棋) self.setFixedSize(self.size, self.size) def cx(self, c): return self.margin c * self.cell def cy(self, r): return self.margin r * self.cell def paintEvent(self, event): painter QPainter(self) painter.fillRect(self.rect(), QColor(235, 176, 89)) painter.setPen(QColor(0, 0, 0)) for i in range(self.n): y self.cy(i) painter.drawLine(self.margin, y, self.size - self.margin, y) painter.drawLine(y, self.margin, y, self.size - self.margin) for r in range(self.n): for c in range(self.n): if self.board[r][c] 1: painter.setBrush(QColor(0, 0, 0)) painter.drawEllipse(self.cx(c) - 13, self.cy(r) - 13, 26, 26) elif self.board[r][c] 2: painter.setBrush(QColor(255, 255, 255)) painter.setPen(QColor(0, 0, 0)) painter.drawEllipse(self.cx(c) - 13, self.cy(r) - 13, 26, 26) def mousePressEvent(self, event): if event.button() ! Qt.LeftButton: return x, y event.x(), event.y() # 像素坐标换算成棋盘坐标round让点击点自动吸附到最近的交叉点 c round((x - self.margin) / self.cell) r round((y - self.margin) / self.cell) if 0 r self.n and 0 c self.n: self.board[r][c] 1 self.update() if __name__ __main__: app QApplication(sys.argv) win GobangWindow() win.show() sys.exit(app.exec_())我对代码里的几个参数说明一下。n15是棋盘路数对应标准五子棋cell40是每格边长窗口尺寸是3024014620像素在笔记本上刚好完整显示。margin30控制棋盘到窗口边缘的距离如果想做截图再调到40。注意setFixedSize而不是setGeometry它能让窗口不可拉伸避免棋盘被拖动变形。点击事件的坐标换算是最容易错的地方。鼠标x、y是相对于窗口左上角的像素值而棋盘落子要用行列号。公式是“先减边距再除以格子宽度”也就是c(x-margin)/cell取整要用round而不是int。原因很简单如果用户点在第7列中心偏右一点int会把0.8变成0落子唰地跳到上一格round则吸附到最近的交叉点。边缘的落子手感全靠这一步。这段代码里的board二维数组就是后面深搜要操作的数据。界面归界面数据归数据后面写AI时你不需要在这个类里动任何绘图代码。2.3 从“人”这一侧接出博弈流程光能落子还不够鼠标点击后要能转入AI决策。下面只列出新增字段和方法替换2.2里的同名方法即可class GobangWindow(QMainWindow): def __init__(self, n15): # 棋盘、尺寸、paintEvent等初始化见2.2节 self.current_turn 1 self.locked False def mousePressEvent(self, event): if self.locked: return if event.button() ! Qt.LeftButton: return x, y event.x(), event.y() c round((x - self.margin) / self.cell) r round((y - self.margin) / self.cell) if 0 r self.n and 0 c self.n and self.board[r][c] 0: self.board[r][c] 1 # 玩家先手 self.update() self.locked True self.ai_move() def ai_move(self): # 先放一个占位用QTimer延迟300毫秒避免事件循环卡顿 from PyQt5.QtCore import QTimer QTimer.singleShot(300, self.place_ai_stone) def place_ai_stone(self): # 占位逻辑找第一个空位放白子后续替换成AI搜索结果 for r in range(self.n): for c in range(self.n): if self.board[r][c] 0: self.board[r][c] 2 self.locked False self.update() return这个流程里的locked标志很关键。如果没有它玩家会在AI思考时连续点击给同一个棋盘同时写入黑白子坐标全部错乱。我这里用全局的lockedtrue锁住界面等AI通过place_ai_stone落子后再解锁。QTimer.singleShot(300, ...)是演示时最常见的做法它的作用是让界面先刷新一次再执行AI逻辑。真正做答辩版本建议换成第4.1里的QThread方案因为深搜耗时时让主线程同步等待会直接把事件循环堵死。这段代码跑通后你已经完成了人机博弈的“人”侧。接下来把place_ai_stone里的循环换成真正的深搜搜索结果就是完整的AI落子链路。3. 深搜与α-β剪枝从枚举全盘到秒出结果3.1 多智能体博弈先建立一个零和搜索模型多智能体博弈听上去复杂五子棋其实是最适合拆开的例子。这里的“智能体”不是指深度学习模型而是指能独立读棋盘、做决策的单元玩家是一个智能体AI是另一个。双AI对战时黑棋和白棋各是一个智能体。它们共享同一个棋盘轮流改变环境状态目标完全相反所以这是一个典型零和博弈。在搜索的角度看当前局面是根节点AI落子的每个空位都产生一个分支对手的每个回应又继续产生分支最终长成一棵博弈树。深搜要做的是从根一直往深处探索到搜索深度上限或分出胜负然后一层一层回传评价值。因为双方目标相反一层取最大下一层取最小这就是极大极小搜索也是α-β剪枝算法的基础。需要提醒一点这里的“深搜”和“图的深度优先遍历”不一样。图遍历只要访问过节点就可以不再访问博弈搜索里每个节点状态不同不能简单去重所以递归返回后必须恢复现场否则兄弟分支会看到被修改过的棋盘。3.2 深搜的递归核心不剪枝版本先算对我一般先写一个不剪枝的minimax确认评估函数和恢复逻辑没错再加入剪枝。评估函数可以先用最简单的连续子计数让AI先“有反应”def evaluate_board(board, player): direction [(1, 0), (0, 1), (1, 1), (1, -1)] score 0 for r in range(15): for c in range(15): if board[r][c] ! player: continue for dr, dc in direction: cnt 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: cnt 1 else: break if cnt 2: score cnt * cnt return score请不要迷信这个评分。它的作用是“把棋盘状态映射成数值”如果棋形没识别搜索再深也很难走出活三。但它足以跑通深搜等整体流程稳定后再升级。然后是minimax核心def minimax(board, depth, is_maximizing, ai_color, human_color): winner check_winner(board) if winner ai_color: return 100000 depth if winner human_color: return -100000 - depth if depth 0: return evaluate_board(board, ai_color) - evaluate_board(board, human_color) candidates get_candidates(board) if not candidates: return 0 if is_maximizing: best -float(inf) for r, c in candidates: board[r][c] ai_color best max(best, minimax(board, depth - 1, False, ai_color, human_color)) board[r][c] 0 return best else: best float(inf) for r, c in candidates: board[r][c] human_color best min(best, minimax(board, depth - 1, True, ai_color, human_color)) board[r][c] 0 return best注意几个边界。胜负判断要优先于depth0否则“已经赢了但还没到深度”会继续搜索返回100000depth而不用固定100000是为了让更早获胜的分支得分更高AI不会在胜局里故意拖延。递归尝试落子后必须撤销这是深搜最容易翻车的位置如果漏掉board[r][c]0后面的候选点会基于脏棋盘计算轻则算出错误着法重则递归栈溢出。参数方面搜索深度我一般从2开始调。深度2是最基础的反应深度4配合后面要说的候选点限制已经能走出像样的防守深度6则要依赖α-β剪枝和合理评分。深度不是越大越好因为每多一层时间会变得更长AI落子超过5分钟会让演示翻车。3.3 加α-β剪枝两个阈值让深搜不再全遍历不剪枝的minimax在空棋盘上几乎不可用。以深度4、候选点10个为例最坏要搜10^4个局面虽然这只是理论上限已经能把Python跑出明显卡顿。α-β剪枝算法的思路是在深搜过程中维护两个阈值alpha是MAX层目前已经能保证的下界beta是MIN层目前能接受的上界。当某个分支让betaalpha成立说明后面的搜索不会影响根节点决策可以整棵跳过。在minimax上改成带剪枝版本def minimax_ab(board, depth, alpha, beta, is_maximizing, ai_color, human_color): winner check_winner(board) if winner ai_color: return 100000 depth if winner human_color: return -100000 - depth if depth 0: return evaluate_board(board, ai_color) - evaluate_board(board, human_color) candidates get_candidates(board) if not candidates: return 0 if is_maximizing: best -float(inf) for r, c in candidates: board[r][c] ai_color val minimax_ab(board, depth - 1, alpha, beta, False, ai_color, human_color) board[r][c] 0 best max(best, val) alpha max(alpha, best) if beta alpha: break return best else: best float(inf) for r, c in candidates: board[r][c] human_color val minimax_ab(board, depth - 1, alpha, beta, True, ai_color, human_color) board[r][c] 0 best min(best, val) beta min(beta, best) if beta alpha: break return best代码只比原版多了两个参数和两个if break但性能差距可能非常大。这里最关键的是“谁更新谁”alpha只能MAX节点更新beta只能MIN节点更新。如果我在MAX里误写了beta剪枝条件会因为边界值错乱而提前剪掉正确分支AI会突然下出莫名其妙的棋。接下来是剪枝的性价比候选点顺序。同一个局面先搜一个“较好的”位置还是先搜一个“明显差”的位置alpha/beta收敛速度完全不同。我习惯在生成候选点时就排序顺便把候选列表限制在一个小集合里def get_candidates(board, max_candidates10): points set() for r in range(15): for c in range(15): if board[r][c] 0: continue for dr in range(-2, 3): for dc in range(-2, 3): nr, nc r dr, c dc if 0 nr 15 and 0 nc 15 and board[nr][nc] 0: points.add((nr, nc)) if not points: return [] scored [] for r, c in points: # 简单启发式优先落在棋子密集的附近 score 0 for dr in range(-1, 2): for dc in range(-1, 2): nr, nc r dr, c dc if 0 nr 15 and 0 nc 15: if board[nr][nc] 1: score 50 elif board[nr][nc] 2: score 30 scored.append((score, r, c)) scored.sort(reverseTrue) return [(r, c) for _, r, c in scored[:max_candidates]]这函数做的事有两件第一只考虑现有棋子两格范围内的空位把225个空位收敛到几十个第二按简单启发式倒序排列保证α-β先搜索有价值的分支。max_candidates是候选点数量上限我常用10深度大时可以缩到8。如果你发现AI总在边角落子多半是把max_candidates放太宽或启发式分值没区分黑白只看自己棋子的权重却忘了考虑对手。这样搜索层用minimax_ab候选点用get_candidates评估函数用evaluate_board三者串起来就是深搜α-β剪枝的完整闭环。第4章我会梳理这几部分各自最常踩的坑。4. 避坑与常见问题从“能跑”变成“能答辩”4.1 界面卡死AI搜索不能放在主线程现象点击后窗口白块鼠标拖动无反应过几秒才恢复。原因在mousePressEvent里直接调用minimax_ab递归计算把PyQt5事件循环占满了。解决用QThread。代码class AIWorker(QThread): move_done pyqtSignal(int, int) def __init__(self, board_snapshot, depth, parentNone): super().__init__(parent) self.board_snapshot board_snapshot self.depth depth def run(self): r, c ai_search(self.board_snapshot, self.depth) self.move_done.emit(r, c)窗口侧连接信号worker AIWorker([row[:] for row in self.board], depth) worker.move_done.connect(self.on_ai_move) worker.start()注意复制board。为什么UI线程还会继续paintEvent它读到的棋盘对象如果被AI线程修改绘制内容会跳变。我会把“搜索用的快照”做成只读AI线程只输出一个坐标再由主线程更新正式棋盘。如果你偷懒直接传self.board偶尔会出现棋子和棋盘点错位而且很难复现。4.2 加了剪枝却没提速节点顺序比剪枝本身更重要现象把minimax换成minimax_ab后时间几乎没降节点数也没变。原因候选点没排序alpha/beta很难触发或者剪枝条件写错例如在MAX层却比较beta。解决先在get_candidates里按启发式排序再在递归入口放一个计数器验证。剪枝的正确性有个很实用的验证方法同一局面跑“不剪枝”和“剪枝”两版两者最佳落子坐标必须完全一致。如果出现不一致说明剪枝代码边界写错了而不是“剪枝改变了策略”。计数器代码search_cnt 0 def minimax_ab(...): global search_cnt search_cnt 1 # 其余逻辑保持不变在递归函数入口自增跑一个中盘局面分别统计剪枝前后次数。如果次数没掉一个数量级回去改搜索顺序如果时间降了几十倍而节点数没降多少可能是评估函数耗时小、minimax_ab递归外开销占主导这时优先减少候选点数。4.3 候选点全遍历225的空位会把深搜拖死现象棋盘只下了两三子AI就已经想得满头汗深度调到4直接等不出结果。原因get_candidates未做约束每次递归都扫描全部225个空位。解决只在已有棋子的2格范围内找空位并取前10~15个。搜一个固定的中盘局面候选点通常会降到30个以内再截断到10个深度4最多一万次叶节点评估配合剪枝实际会少很多。这张取舍直接决定AI速度比优化评估函数更立竿见影。如果棋盘上一枚棋子都没有get_candidates会返回空列表。这时我一般让AI直接下天元附近而不是强行搜索。这种边界要写进文档否则评审试一下AI先手却不下棋印象分很受影响。4.4 评估函数只看“连续子数”AI没有进攻意识现象AI会应“马上要成五的点”但不会主动制造活三评优时总选一个看似安稳但没威胁的点。原因评分只用了连子长度的平方没有区分活形和眠形。比如一个被两边堵死的眠三和两边都空的活三对局意义完全不同。解决给棋形加权。常见做法是棋形权重成五100000活四8000冲四2000活三800眠三100活二20把上面evaluate_board里的连续计数换成统计每种棋形的数量乘以不同权重。答辩时把这张权重表放进文档说明“深搜负责找路线评估函数负责判断路线价值”比空谈算法更有说服力。4.5 “多智能体”被追问题目里每个词都要有落点现象演示时只打开人机对战评委问“多智能体体现在哪”答不上来。原因题目里的多智能体博弈没有对应可玩功能。解决在菜单里加一个“AI对战”模式黑棋用深搜剪枝白棋用随机或贪心策略让两个智能体自行下完一整局并在界面下方打印落子数、搜索深度、剪枝前后节点数。这样既能把“多智能体”落地还能顺带收集胜率数据。文档里就写两个独立决策单元共享棋盘状态各自优化自身收益构成一个零和博弈系统。这一条回答能直接把题目里的三个关键词全部引出来。5. 验证剪枝效果用节点数让答辩眼前一亮5.1 用节点数和双AI胜率验证算法代码from copy import deepcopy board load_midgame_board() # 读一个中盘局面 cnt_no_prune search_stats(deepcopy(board), depth4, use_pruneFalse) cnt_prune search_stats(deepcopy(board), depth4, use_pruneTrue) print(no prune:, cnt_no_prune) print(prune:, cnt_prune)search_stats是一个把minimax包了一层计数的函数。注意两版必须用同一个board对象深拷贝不能先跑一版再跑另一版时棋盘已经被改掉另外只统计叶节点还是所有节点要在文档里说明否则数据对不上。我的习惯是统计“进入递归的总次数”因为这个数最直观且不受机器波动影响。第二个验证是双AI胜率。让黑棋AI用剪枝白棋AI用随机落子连续跑20局看黑棋胜率。如果剪枝实现有问题黑棋胜率不会比随机高多少如果棋力正常胜率应该接近100%。比较两件事剪枝前后节点数显著下降剪枝后的棋力不下降这才算有效优化。5.2 把参数表写进文档文档说明别照抄代码要写清楚四个数搜索深度、候选点数、评分权重、剪枝开关。我用过一个最简单的方式写一个CONFIG.py集中管理这些参数文档里放“depth4max_candidates10成五100000活四8000……”表格并说明不同参数组合在演示机上耗时约多少秒。答辩时直接展示这一页比讲十分钟原理更有效。我现在做AI相关项目第一件事就是给搜索加一个节点计数开关。没有计数剪枝效果全靠感觉就是玄学有了计数你能一眼看出优化是不是真的发生了。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑