简介:基于Python与PyQt5打造的“多智能体博弈AI五子棋游戏”毕业设计项目,核心涵盖人机对战、深度优先搜索(DFS)与α-β剪枝算法,通过完整工程展示了博弈树搜索在棋类AI中的实际应用。资源面向计算机类毕业设计、课程设计和期末大作业场景,也适合希望借助完整代码学习PyQt5界面开发与博弈算法的读者。压缩包共含17个文件,包括4个核心Python源码(如AI算法模块、gobang界面主程序)、Qt界面定义文件(ui/qrc)、1份README说明文档,以及多张运行效果截图与应用图标,整体大小约5.39MB。目前已有338人学习了解。通过该项目可获取可直接运行的源代码、项目结构说明与详细代码注释,便于新手快速上手、二次开发与答辩展示;系统功能完善、界面美观、操作流畅,对完成毕业设计或深入理解多智能体博弈与剪枝优化具有很高的参考价值。
1. 用PyQt5和α-β剪枝做五子棋AI:这道题真正难在哪
很多人选“五子棋AI”这类题目做课设,算法课上学过minimax,课设却卡在把算法变成能演示的桌面程序。这个标题给了一条完整路径:Python写逻辑,PyQt5画界面和接收鼠标事件,深搜与α-β剪枝做搜索加速,再配一份说明文档。它能解决的不只是“AI会下棋”,而是把数据结构、搜索算法、GUI事件驱动这几个零散知识点串成一件能演示、能答辩的作品。适合本科毕业设计,也适合想把搜索算法应用到实际项目的开发者照着复现。一个容易误判的点:决定AI强弱的往往不是搜索深度,而是评估函数写得好不好,后面会展开说。
2. 让AI“看见”棋盘:评估函数、深搜与α-β剪枝的落地选型
2.1 多智能体博弈在五子棋里指什么:人机对战与AI自战两种模式
标题里的“多智能体博弈”在毕设语境下,通常不是学术论文里那种复杂的多智能体系统,而是指棋盘上同时存在两个决策实体。第一种是玩家和AI各执黑白,轮流落子,这是最基本的人机博弈;第二种是做成“AI自战”模式,让两个AI实例使用不同搜索深度或不同评估参数互相博弈,用来验证剪枝效果、量化调优收益。这两种模式共用同一套15路棋盘和搜索代码,差别只在谁去调用搜索、搜索完结果往哪里写。
这个设计是文档里可以写清楚的亮点:同样的核心代码,一种玩法打人,一种玩法打自己。答辩老师如果追问“多智能体博弈体现在哪”,你可以明确回答——人机和自战模式里,每个棋手都是一个独立的博弈智能体,在共同棋盘环境下追求自身收益最大化。强烈建议代码里把“人机对战”和“AI自战”做成两个入口,自战模式不只是演示功能,它还是第5章里做回归测试的“测试夹具”。
2.2 评估函数怎么打分:棋型计分表与四方向扫描
搜索树决定的是“AI能想到哪一步”,评估函数决定的是“AI怎么看清楚当前局面”。五子棋不像国际象棋,没有车、马、兵的物质优势,只能靠棋型判断。常见做法是给“成五、活四、冲四、活三、眠三、活二”等棋型设置梯度分数,AI落子时会优先构造高价值棋型。
SCORE_TABLE = { "five": 1000000, # 成五,直接赢了 "live_four": 100000, # 活四,两端都空,下一步必胜 "rush_four": 20000, # 冲四,只有一端开放 "live_three": 20000, # 活三,有发展成活四的空间 "sleep_three": 3000, # 眠三,被对方限制了一端 "live_two": 3000, # 活二,早期布子用 "sleep_two": 200, "one": 10, } DIRECTIONS = [(1, 0), (0, 1), (1, 1), (1, -1)] def shape_at(board, x, y, dx, dy, stone): # 以(x, y)为中心,沿(dx, dy)两个方向数连续同色棋子 count = 1 blank_l = blank_r = False px, py = x - dx, y - dy while 0 <= px < 15 and 0 <= py < 15 and board[px][py] == stone: count += 1 px, py = px - dx, py - dy if 0 <= px < 15 and 0 <= py < 15 and board[px][py] == 0: blank_l = True px, py = x + dx, y + dy while 0 <= px < 15 and 0 <= py < 15 and board[px][py] == stone: count += 1 px, py = px + dx, py + dy if 0 <= px < 15 and 0 <= py < 15 and board[px][py] == 0: blank_r = True return count, blank_l, blank_r def evaluate(board, stone): # 只让“方向上第一个同色子”负责统计,避免一条线被重复计分 score = 0 for x in range(15): for y in range(15): if board[x][y] != stone: continue for dx, dy in DIRECTIONS: px, py = x - dx, y - dy if 0 <= px < 15 and 0 <= py < 15 and board[px][py] == stone: continue count, blank_l, blank_r = shape_at(board, x, y, dx, dy, stone) if count >= 5: score += SCORE_TABLE["five"] elif count == 4: ends = int(blank_l) + int(blank_r) score += SCORE_TABLE["live_four"] if ends == 2 else SCORE_TABLE["rush_four"] if ends == 1 else 0 elif count == 3: ends = int(blank_l) + int(blank_r) score += SCORE_TABLE["live_three"] if ends == 2 else SCORE_TABLE["sleep_three"] if ends == 1 else 0 elif count == 2: ends = int(blank_l) + int(blank_r) score += SCORE_TABLE["live_two"] if ends == 2 else SCORE_TABLE["sleep_two"] if ends == 1 else 0 elif count == 1: score += SCORE_TABLE["one"] return scoreshape_at 以某个棋子为中心向两个方向延伸,返回连续同色数和两端是否为空。evaluate 遍历棋盘时跳过那些“前一个位置已经是同色”的格子,保证一条连续线段只会被统计一次,避免同一条线上的五个棋子被重复算成五个活二。
参数说明:DIRECTIONS 四个方向覆盖了横、竖、两个对角线,这是五子棋连线的所有可能方向;评分表的分数可以按实际棋感调整,但梯队次序不能乱——成五必须最大,活四必须明显高于冲四和活三,否则AI会为了堵一个眠三而放弃必赢的活四。这里用的是最简单的逐点扫描评估,想让性能更好,可以改成增量评估:每次落子后只更新受影响方向上的那几个交叉点。
2.3 深搜加α-β剪枝:minimax主流程与剪枝条件
minimax 假设双方都走最优,AI 层取最大收益,玩家层取最小收益。没有剪枝时搜索量是“候选点 × 候选点 × …”,15 路棋盘全盘展开根本算不完。α-β剪枝的核心是维护两个值:alpha 表示极大方已经确保的最佳收益,beta 表示极小方已经确保的最小损失;当某个分支不可能再改变父节点的选择时,直接放弃剩余子节点。
def alpha_beta(board, depth, alpha, beta, is_max, ai_stone, human_stone): winner = check_winner(board) if winner == ai_stone: return 1000000 + depth * 10000 # 越早赢分越高 if winner == human_stone: return -(1000000 + depth * 10000) if depth == 0: return evaluate(board, ai_stone) - evaluate(board, human_stone) moves = candidate_moves(board, ai_stone) if is_max: best = -float("inf") for x, y in moves: board[x][y] = ai_stone val = alpha_beta(board, depth - 1, alpha, beta, False, ai_stone, human_stone) board[x][y] = 0 # 撤销落子,恢复局面 best = max(best, val) alpha = max(alpha, val) if beta <= alpha: # 极大方已经不可能超过 beta,剪掉 break return best else: best = float("inf") for x, y in moves: board[x][y] = human_stone val = alpha_beta(board, depth - 1, alpha, beta, True, ai_stone, human_stone) board[x][y] = 0 best = min(best, val) beta = min(beta, val) if beta <= alpha: # 极小方已经不可能低于 alpha,剪掉 break return best def candidate_moves(board, ai_stone): # 只在已有棋子周围搜索,不枚举整个空棋盘 moves = set() for x in range(15): for y in range(15): if board[x][y] != 0: continue if any(0 <= x + dx < 15 and 0 <= y + dy < 15 and board[x + dx][y + dy] != 0 for dx, dy in NEIGHBORS): moves.add((x, y)) # 按评估分从高到低排序,剪枝效率依赖这一步 return sorted(moves, key=lambda p: evaluate_point(board, p[0], p[1], ai_stone), reverse=True)[:10] def ai_search(board, ai_stone, human_stone, depth): best_move = None best_val = -float("inf") for x, y in candidate_moves(board, ai_stone): board[x][y] = ai_stone val = alpha_beta(board, depth - 1, -float("inf"), float("inf"), False, ai_stone, human_stone) board[x][y] = 0 if val > best_val: best_val = val best_move = (x, y) return best_movedepth 表示剩余搜索深度,毕设级别取 4 到 6,再高就要配合更好的评估增量和更强的剪枝。alpha、beta 初始为负无穷和正无穷,递归时极大层更新 alpha,极小层更新 beta,剪枝条件统一是 beta <= alpha。胜负判断放在搜索函数最前面,返回“大数 + depth 小奖励”是为了让AI偏向选择能更早获胜的路径。
为什么不枚举整个空棋盘?15×15 共 225 个空位,深度 4 时节点数仍然是灾难。只在已有棋子周围找候选点,把分支宽度压到几十个以内,再用评估分排序后取前 10 个,搜索规模才真正可控。剪枝是否生效,强烈依赖走法排序质量:如果走法从坏到好排,alpha 上升慢,剪枝率很低,这一点在第4章会展开讲。
顺带说一个答辩容易混淆的概念:这里的剪枝和模型剪枝完全是两回事。搜索树的 α-β剪枝在递归时提前砍掉不会影响结果的分支,属于算法层面的剪枝;resnet34 剪枝量化里的权重剪枝、非结构化剪枝,是砍掉网络中不重要的权重和通道,那是深度学习部署里的操作。两个都叫“剪枝”,但解决问题的对象不同,文档里最好单独写一节区分。
3. 用PyQt5把博弈过程画到界面上:棋盘绘制、落子事件与AI线程分离
3.1 棋盘组件与QPainter绘制:15路棋盘的计算参数
PyQt5 在毕设项目里主要承担两件事:控件布局和 QPainter 自绘。五子棋棋盘用 QPainter 自绘是更省事、也更可控的做法。常用参数为 board_size=15,格子间距 cell=36 像素,边距 margin=30 像素。窗口边长等于 margin×2 + cell×(board_size-1),也就是 30×2 + 36×14 = 564 像素。
class BoardWidget(QWidget): def __init__(self, board_size=15, cell=36, margin=30): super().__init__() self.board_size = board_size self.cell = cell self.margin = margin self.board = [[0] * board_size for _ in range(board_size)] size = margin * 2 + cell * (board_size - 1) self.setFixedSize(size, size) def paintEvent(self, event): painter = QPainter(self) painter.setRenderHint(QPainter.Antialiasing) edge = self.margin + (self.board_size - 1) * self.cell for i in range(self.board_size): start = self.margin + i * self.cell painter.drawLine(self.margin, start, edge, start) # 水平线 painter.drawLine(start, self.margin, start, edge) # 垂直线 for x in range(self.board_size): for y in range(self.board_size): if self.board[x][y] != 0: cx = self.margin + x * self.cell cy = self.margin + y * self.cell painter.setBrush(QColor("black" if self.board[x][y] == 1 else "white")) painter.drawEllipse(cx - 13, cy - 13, 26, 26)drawEllipse 的四个参数是左上角坐标、宽、高,所以画棋子时要用 cx-13、cy-13、26、26,圆心才落在交叉点上。cell=36、margin=30 不是随便定的,棋子半径 13 像素时要保证相邻棋子有合适的视觉间距;如果调整 cell,棋子半径也要跟着调。
为什么不直接贴一张棋盘图片?因为自绘能精确把鼠标像素坐标映射回棋盘格,还能在最后一手棋上加高亮标记,这些用静态图片做起来很别扭。paintEvent 里只用 self.board 这个二维数组绘制,数据和显示分离,后续 AI 搜索直接操作同一个 board 数据源。
3.2 落子事件流转:从鼠标点击到触发AI搜索
鼠标事件要做三步:像素坐标换算成棋盘坐标、检查合法性、落子后触发 AI。
def mouseReleaseEvent(self, event): if self.is_ai_thinking: return px = event.pos().x() - self.margin py = event.pos().y() - self.margin x = round(px / self.cell) y = round(py / self.cell) if not (0 <= x < self.board_size and 0 <= y < self.board_size): return if self.board[x][y] != 0: return self.board[x][y] = HUMAN_STONE self.update() if check_winner(self.board, HUMAN_STONE): QMessageBox.information(self, "结果", "玩家胜") return self.start_ai()坐标换算用 round 而不是 int 强转,因为鼠标点在两个交叉点中间时应该落到最近的格点上。x 对应横向(行索引),y 对应纵向(列索引),后续 check_winner 和 AI 搜索里必须统一这个约定,否则复盘时会出现“明明五子连珠却判错方”的麻烦。
落子顺序有个细节:先落子、先重绘、先判胜,再启动 AI。AI 搜索期间把 is_ai_thinking 置 True,玩家连点也不会产生第二个事件。QMessageBox 是模态对话框,弹窗期间事件循环仍在派发鼠标事件,所以弹窗之前要先把状态置好,避免关窗后触发重复落子。
3.3 AI搜索必须放后台:QThread信号槽的最小实现
如果直接在 start_ai() 里调用 alpha_beta,递归会占住 GUI 线程,窗口在这个时间段内完全不刷新,拖动窗口时系统甚至会提示“未响应”。depth=5、候选点 10 个时,单步几千万次的棋盘访问在 Python 里很吃力。常见做法是把 AI 搜索放进 QThread 工作线程,只把落子坐标通过信号发回主线程。
class AIWorker(QThread): move_ready = pyqtSignal(int, int) def __init__(self, board, ai_stone, human_stone, depth): super().__init__() self.board = board self.ai_stone = ai_stone self.human_stone = human_stone self.depth = depth def run(self): x, y = ai_search(self.board, self.ai_stone, self.human_stone, self.depth) self.move_ready.emit(x, y) # 主窗口里的调用 def start_ai(self): self.is_ai_thinking = True self.worker = AIWorker(self.board, AI_STONE, HUMAN_STONE, self.depth) self.worker.move_ready.connect(self.on_ai_move) self.worker.start() def on_ai_move(self, x, y): self.board[x][y] = AI_STONE self.is_ai_thinking = False self.update() if check_winner(self.board, AI_STONE): QMessageBox.information(self, "结果", "AI胜") return if is_board_full(self.board): QMessageBox.information(self, "结果", "平局")QThread 里不要直接操作任何控件,把 QWidget 传给 worker 是反模式。这里只传 board 和棋子颜色两个纯数据,move_ready 信号跨线程连接,主线程槽函数里再改 board、再 update(),落子的唯一写入口在主线程,不会和 paintEvent 抢数据。
用 PyQt5 做界面设计时,整体结构建议分层:BoardWidget 只管绘制和鼠标事件,MainWindow 管按钮、棋谱显示和模式切换,AIWorker 管搜索。在 pycharm 里跑这个项目时,把解释器指向项目独立的虚拟环境,断点打在 alpha_beta 的 beta <= alpha 那一行,能直接看到剪枝如何把右边整棵子树跳过去,这是理解剪枝最快的方式。
4. 避坑:从pyqt5装不上到剪枝失效,最常见的几个坑
4.1 环境与安装:python版本、镜像源和labelme的依赖冲突
坑1:pip install pyqt5 卡住或报错。
现象:新建环境里执行 pip install pyqt5,进度条长时间不动,或 Python 3.12 下报 “Building wheel for PyQt5-sip ... error”。
原因:默认源下载慢;另外 Python 3.12 之后 PyQt5 对 sip 依赖的版本兼容更苛刻,安装更容易翻车。
解决:用清华镜像源加指定版本安装,例如pip install pyqt5==5.15.9 -i https://pypi.tuna.tsinghua.edu.cn/simple;更省心的组合是 Python 3.9 或 3.10 配 PyQt5 5.15 系列,装完立刻执行from PyQt5.QtWidgets import QApplication验证。
坑2:之前装过 labelme,再装 PyQt5 后程序起不来。
现象:import PyQt5 不报错,但一运行就弹 “This application failed to start because no Qt platform plugin could be initialized”,或者界面字体异常。
原因:labelme 这类标注工具依赖 PySide2 或 PyQt5 的特定版本,和毕设项目装在同一套环境里,两个 Qt 绑定互相覆盖插件文件。
解决:写完代码后用python -m venv venv建一套干净环境,激活 venv 再装 pyqt5,和 labelme 彻底分开;在 pycharm 的项目设置里给当前项目单独指定这个 venv 的解释器。这个习惯能省掉大量“之前还能跑,今天突然不行”的玄学问题。
4.2 搜索与界面:界面假死、剪枝没效果怎么定位
坑3:AI 思考时窗口假死,标题栏出现“未响应”。
现象:落子后界面立刻卡住,转几秒到几十秒,期间窗口不能拖动和关闭。
原因:搜索函数直接在 GUI 线程里递归,递归期间 Qt 事件循环得不到执行,窗口消息无法处理。
解决:把 alpha_beta 放到 AIWorker 工作线程,主线程只负责重绘和接收 move_ready。有人想在递归中间用 QApplication.processEvents() 强制刷界面,这是反模式,搜索不结束界面照样卡,而且会拖慢搜索速度。
坑4:加上 α-β剪枝后,运行时长反而更长。
现象:剪枝条件照抄,depth=5 还是算到天荒地老。
原因:剪枝效率取决于走法顺序。候选点按 (0,0) 到 (14,14) 顺序排列时,alpha 很难快速逼近真实解,剪枝几乎不触发;候选点范围太大,分支宽度也压不下来。
解决:candidate_moves 只返回已有棋子周围,且先按 evaluate_point 从高到低排序,截取前 10 到 12 个点。可以加一个全局节点计数器,在 alpha_beta 每次进入时 +1,对比排序前后的总节点数——排序后通常能差一个数量级。这种在递归前判断“还值不值得展开”的剪枝,相当于决策树里的预剪枝;先展开再回溯时判断“有没有必要继续”是后剪枝,α-β属于前者,文档里别把两个概念混着写。
坑5:depth 调大后 AI 反而变傻。
现象:depth 从 4 调到 6,AI 在某个残局里放着一步赢不走,去走了别的棋。
原因:评估函数精度有限,越深的搜索会把视野放到远期的模糊局面上,评估误差被反向放大;另外胜负返回的 depth 奖励在 depth=6 时权重过大,会扭曲选择。
解决:搜到第 5、6 层时,把“越早赢加分”的系数调低,比如 1000000 + depth * 100,或者干脆不加时间奖励,只保证“赢了就行”。这个坑在答辩演示时最容易翻车,务必用固定棋谱回归一遍再上台。
4.3 规则与判定:胜负判断、坐标约定与禁手处理
坑6:连成五子不判赢,或者判到对方那边去。
现象:棋盘上明明横向五个同色棋,程序没弹胜利;有时 AI 落子后反而把玩家判输。
原因:坐标轴约定不一致。绘制坐标系里 x 是横向、y 是纵向,check_winner 里却按 x 当行、y 当列去扫;或者只扫了行和列,漏了两个对角线;鼠标坐标换算用 int() 而不是 round(),边界处会落错格子。
解决:统一用 board[x][y]、x 为行号、y 为列号,从最后落子点出发沿四个方向双向统计连续同色数,count >= 5 即返回该方赢。写一个 check_direction(board, x, y, dx, dy, stone) 复用四条方向,避免复制粘贴改错一处。
坑7:AI 下出“三三”“四四”禁手位置。
现象:用比赛规则对弈时,AI 在“三三禁手”位置落子,规则上属于黑棋禁手,被判违规。
原因:默认实现只做了“连五验证”,没有做黑棋禁手检测。
解决:如果毕设定位是通用五子棋,在文档里明确写“无禁手规则”即可;如果想让 AI 遵守正式比赛规则,需额外实现黑棋禁手检测,落子后同时形成两个活三或两个活四以上时判非法。这是一个可以写进“后续改进”的加分项,但不建议在代码里硬塞,否则评估函数和搜索逻辑都会跟着复杂化。
5. 让AI“变聪明”的最后一公里:评分表调参、自战验证与棋谱化演示
5.1 评分表先定梯度,再谈深度
调参方向只有一个原则:棋型之间的分差必须拉开。活四要明显大于冲四与活三之和,因为一旦形成活四,对方怎么堵都堵不住;如果分数太平,AI 会放弃活四去追两个眠三,看起来是在“稳守”,实际上是漏掉了必胜手。改分时一次只动一个值,动完跑同一组残局,否则两个参数同时变,你根本分不清是谁在起作用。
5.2 用AI自战模式当回归测试
自战模式是最实用的调试工具。给 AI-A 和 AI-B 设置不同深度,统计胜率和单步耗时,验证剪枝排序是否真的降低了节点数。
def self_play(depth_a, depth_b, rounds=10): for i in range(rounds): board = [[0] * 15 for _ in range(15)] turn = 1 # 1 表示 AI-A 执黑,-1 表示 AI-B 执白 while True: if turn == 1: x, y = ai_search(board, 1, -1, depth_a) else: x, y = ai_search(board, -1, 1, depth_b) board[x][y] = turn winner = check_winner(board) if winner != 0 or is_board_full(board): break turn = -turn # 记录胜负、步数、单步耗时,落盘到 CSV记录字段包括:depth_a、depth_b、先手方、胜负、整局步数、平均单步耗时。先手一方有明显的开局优势,10 局时最好双方轮流执黑各 5 局,胜率才公平。同一个评估函数下,深度 5 对深度 4 通常会在胜率上明显占优,但单步耗时也可能翻倍,这个权衡是文档里很有说服力的实验数据。
5.3 把对弈过程写成HTML棋谱
每步落子记录成 (x, y, 棋子颜色),结束后生成一个简单 HTML 文件,用 QTextBrowser 在窗口右侧展示,这也是 pyqt5 里显示 html 比较常用的做法。
html = ["<html><body><h3>对弈记录</h3><ol>"] for i, (x, y, stone) in enumerate(move_list): color = "黑" if stone == 1 else "白" html.append(f"<li>第{i + 1}手:{color} ({x}, {y})</li>") html.append("</ol></body></html>") self.text_browser.setHtml("".join(html))这个棋谱文件可以直接导出进毕设文档的附录,答辩时比口头说“我这 AI 挺强”有说服力得多。
我自己的习惯是先把界面放一边,在纯 Python 脚本里把自战模式跑通,再开 PyQt5 人机对战——界面容易让人分心,搜索和评估函数的逻辑 bug 在纯脚本下更容易暴露。评分表、搜索深度、候选点数量这三个参数每次只改一个,改完就跑固定棋谱回归。如果你打算拿这题做毕业设计,把调参记录和自战数据留在项目里,答辩时就是一份实打实的工作量,希望帮到你。
本文还有配套的精品资源,点击获取