简介:这是一套面向计算机相关专业学生与AI入门学习者的毕业设计级五子棋项目,采用Python与PyQt5构建图形界面,核心实现人机博弈,并引入深度优先搜索与α-β剪枝算法优化落子决策,适合作为毕设、课程设计或人工智能博弈算法练手案例。资源包共17个文件,包含4个py源码文件、1个ui界面文件、1个qrc资源文件、1个md说明文档、1个ico图标及9张png图片,压缩包约5.63MB,结构清晰,便于直接运行与二次开发。目前已有213人学习下载。项目代码经过完整测试,运行稳定,答辩评审平均分达96分,读者可据此理解极大极小搜索与剪枝在棋类AI中的落地方式,掌握PyQt5界面与算法逻辑的分离设计,并在此基础上修改评估函数或扩展功能,用于个人毕设、课设或作业演示。
1. 从一盘「下不过电脑」的五子棋说起:Python+PyQt5 多智能体博弈到底在做什么
很多人第一次写五子棋 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 python=3.10 -y conda activate gomoku # 安装界面库和数值计算库 pip install PyQt5==5.15.9 numpy这里PyQt5==5.15.9是我常用的稳定版本,5.15 系列在 Windows 和 macOS 上兼容性最好。numpy不是必须的,但棋盘评估用数组操作会比纯 list 快不少,后面讲评估函数会用到。装完先验证一下,别等到写了几百行才发现界面库根本没进来。
python -c "from PyQt5.QtWidgets import QApplication; print('PyQt5 OK')"如果这条命令报ImportError,八成是环境没激活,或者系统里同时装了多个 Python,pip 装到了另一个解释器里。用which python(Windows 用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), dtype=np.int8)dtype=np.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, radius=2): # 只考虑已有棋子附近 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)radius=2是经验值。取 1 会漏掉一些跳活三的防守点,取 3 候选点太多、剪枝收益被稀释。开局棋盘为空时occupied是空的,候选集为空,所以要单独处理第一步,直接下天元。
3.3 根节点单独处理:返回坐标而不是分数
上面那个minimax返回的是分数,但界面需要的是「下哪」。所以根节点要单独写一层,遍历候选点、记录每个点的分数、取最大。
def find_best_move(board, ai_player, human_player, depth=4): 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 的棋型给正分,对手的棋型给负分,这样极大极小的「最大化」目标就自动包含了进攻和防守。对手活三给 -8000,AI 自己的活三给 +8000,当两者同时存在时,AI 会优先处理分值绝对值更大的那个,符合「先堵再攻」的直觉。
参数说明:这里为了讲清楚逻辑做了简化,真实项目里要区分活四和冲四、活三和眠三,判断方法是看窗口两端相邻格是否为空。完整实现会多几十行,但分值梯度不变。如果你发现 AI 老是「堵错边」,八成是活三和眠三没区分开。
4.3 评估函数的性能陷阱:别在叶子节点做全盘扫描
上面这个evaluate是 O(15×15×4×5),大概几千次操作。单次调用不慢,但搜索树叶子节点数量是几万到几十万,累加起来就是主要耗时。优化方向有两个:一是增量评估,落子时只更新受影响的四个方向窗口,不重扫全盘;二是把评估结果缓存起来,相同局面直接查表。
from functools import lru_cache @lru_cache(maxsize=100000) 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 明明能赢却去堵对手的活二
原因:评估函数里对手活二给了 -300,AI 自己冲四给了 +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, key=neighbor_score, reverse=True)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_limit=2.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 神之一手」骗过去,以为调好了,结果换个开局就原形毕露。棋力这东西,靠的是统计,不是感觉。希望帮到你。
本文还有配套的精品资源,点击获取