简介:基于α-β剪枝的Python五子棋人机对战完整实现,适合Python学习者体验搜索算法与游戏界面结合过程。程序以极大极小值搜索为核心,通过α-β剪枝减少无效节点、提高电脑决策效率,同时使用评估函数对棋型优劣进行评分,覆盖先后手选择、胜负判断、棋盘信息展示等完整流程。压缩包共3个文件,内含两个Python源码文件与一个说明文档,整体大小约15KB,代码量适中、逻辑清晰,便于阅读和二次开发。已有4704人学习下载,可重点参考AI落子选点方法、剪枝实现细节,以及pygame交互界面的按钮与状态管理方式。若需快速验证,可直接运行源码,在PyCharm 2019.2环境下按说明操作即可体验人机对战。
1. 五子棋AI从零实现:α-β剪枝让暴力搜索从半分钟缩到眨眼级
如果你的五子棋人机对战还停留在「随机落子」或「看到哪下哪」,那这款基于 Python + pygame 的实现会让你重新认识什么叫「有点会下棋」。整个项目的核心只有一件事:用 α-β 剪枝把传统极大极小搜索的节点数砍掉一大半,让 AI 在普通笔记本上也能在 1~2 秒内完成深度为 3~4 的决策。刚开始我写第一版纯递归搜索时,一步棋跑 20 多秒,棋盘像卡死一样,加了剪枝和排序优化后,单步响应直接缩到毫秒级,这才是人机对战能玩下去的前提。
这个项目适合三类人:拿它当课程设计或毕业设计底稿的学生,想理解博弈树搜索在实际游戏里怎么落地的 Python 开发者,以及纯粹想让自己的五子棋程序「能打一点」的爱好者。项目本身不大,但把棋盘模型、评估函数、搜索剪枝、pygame 事件循环串成了一条完整的工程链路,改起来也不费劲,后面几章我会按这条链路一层层拆开讲。
2. 技术栈与整体架构:pygame 渲染、棋盘模型与 AI 决策层如何分层
2.1 为什么是 Python + pygame:从选型到依赖清单
选 Python 做五子棋不是什么情怀,而是它的表达效率恰好匹配这类博弈搜索任务。α-β 剪枝本质上是递归的深度优先遍历,Python 的列表推导式、切片和递归写法能让搜索逻辑写得非常接近伪代码,调试时也不容易绕晕。至于界面层,pygame 是 Python 生态里最「轻」的 2D 游戏库,它不强制你搭场景图、组件树那套框架,只需要一个主循环里不断处理事件、绘制表面,正好适合五子棋这种状态简单的棋类。
依赖上,项目核心只有pygame一个第三方库,Python 标准库里的copy、time、random分别用于状态复制、性能计时和随机走法兜底。安装命令如下:
pip install pygame如果是在虚拟环境里跑,建议先python -m venv venv再激活,避免污染全局环境。这里有个容易被忽略的点:pygame 在不同 Python 版本下的 wheel 包不太一样,实测 Python 3.8~3.11 都能正常安装,3.12 以上需要看 pygame 官方是否发布了对应 wheel,否则建议直接降到 3.10 或 3.11,省得编译源码翻车。
2.2 工程目录:谁负责下棋、谁负责画棋盘、谁负责想棋
拿到这项目的源码后,第一件事是先认清目录结构,别一头扎进某个文件里。合理的五子棋工程会把「界面渲染」和「棋局逻辑」拆开,这样 AI 算法可以脱离 pygame 单独测试。下面是一个典型的目录划分:
gobang/ ├── main.py # 程序入口,初始化 pygame 并启动主循环 ├── board.py # 棋盘数据结构与落子/悔棋操作 ├── ai.py # α-β剪枝搜索与评估函数 ├── renderer.py # 棋盘、棋子的绘制逻辑 ├── constants.py # 棋盘尺寸、颜色、AI 参数等配置 └── requirements.txtmain.py只做三件事:初始化窗口、创建棋盘对象、创建 AI 对象,然后进入事件循环。board.py不关心棋子长什么样,它只维护一个二维数组和胜负判断逻辑;renderer.py反过来,它只负责把board的状态画到屏幕上。ai.py则完全不 import pygame,这样你可以单独写个脚本调用ai.next_move(board)来测试搜索速度,不用启动图形界面。
这种分层的直接好处是:你想把 AI 改成「更强」或者「更快」,只需要动ai.py和constants.py,不会误伤到渲染逻辑。想换界面框架(比如换成 tkinter),也只要改renderer.py和main.py,AI 代码原封不动。
2.3 棋盘状态的数据结构:15 行 ×15 列与落子记录
五子棋的棋盘是 15×15 交叉点,代码里最直观的表示方式是二维列表。我习惯用0表示空位,1表示黑棋,2表示白棋。board.py里维护一个grid属性,以及一个moves列表用于记录每一步的坐标,悔棋时直接从grid清掉最后一手,同时从moves弹出。
class Board: def __init__(self, size=15): self.size = size self.grid = [[0] * size for _ in range(size)] self.moves = [] # 按顺序记录落子坐标 [(row, col), ...] def place(self, row, col, player): if not (0 <= row < self.size and 0 <= col < self.size): return False if self.grid[row][col] != 0: return False self.grid[row][col] = player self.moves.append((row, col)) return True def undo(self): if not self.moves: return None row, col = self.moves.pop() self.grid[row][col] = 0 return row, col需要注意self.moves.pop()这一步,它是 AI 搜索时回溯的关键。AI 在模拟落子时不能直接调用place然后悔棋,那样会污染真正的棋局记录;正确做法是在ai.py内部用copy.deepcopy(board.grid)复制一份棋盘,在副本上做模拟落子。这也是很多新手踩坑的地方:直接改原棋盘导致搜索完棋局乱了,下一手 AI 完全不知道自己该下在哪。
3. 核心 AI 逻辑拆解:走法生成、评估函数与 α-β 剪枝搜索
3.1 走法生成:候选点不是全盘扫描,而是邻域扩展
如果每次搜索都把 225 个交叉点全部作为候选,即使有剪枝,递归层数稍微加深一点计算量也爆炸。实际工程里的做法是「邻域扩展」:只考虑已有棋子的周围一圈空位。这个思路的前提是,正常棋局里没人会下到离所有棋子都超过两格的地方,那是纯送。
def get_candidate_moves(board, radius=2): """返回距离已有棋子 radius 格以内的空位列表""" size = board.size candidates = set() for row in range(size): for col in range(size): if board.grid[row][col] != 0: for dr in range(-radius, radius + 1): for dc in range(-radius, radius + 1): nr, nc = row + dr, col + dc if 0 <= nr < size and 0 <= nc < size and board.grid[nr][nc] == 0: candidates.add((nr, nc)) if not candidates: # 棋盘为空,默认下中心点 return [(size // 2, size // 2)] return list(candidates)参数radius=2控制候选范围,半径越大 AI 看得越远,但候选集也越大。实际测试中,radius=2 时每一步候选大约 30~60 个点,配合剪枝足够;调成 radius=3 会让搜索时间接近翻倍,但棋力提升有限。另外注意这个函数返回的是list,每次调用后最好对候选点按评估分数排序,让剪枝更早触发,这一步对性能影响极大,后面会再讲。
3.2 评估函数:从连子数到位置权重,评估函数到底在算什么
极大极小搜索的「叶子节点打分」靠的就是评估函数。一个合格的评估函数至少要能识别出活三、冲四、活二等棋型,并给不同棋型不同分数。从工程角度说,评估函数不需要写得像 AI 专业论文那样精细,但必须区分「快赢了」和「远未成型」。
一个简单但有效的实现思路:对棋盘上每个方向(横、竖、两个斜),扫描连续同色棋子的长度,以及两端是否被堵死,然后按棋型计分。
def evaluate_point(board, row, col, player): score = 0 directions = [(1, 0), (0, 1), (1, 1), (1, -1)] for dr, dc in directions: count = 1 block = 0 # 正方向延伸 r, c = row + dr, col + dc while 0 <= r < board.size and 0 <= c < board.size and board.grid[r][c] == player: count += 1 r += dr c += dc if not (0 <= r < board.size and 0 <= c < board.size) or board.grid[r][c] != 0: block += 1 # 反方向延伸 r, c = row - dr, col - dc while 0 <= r < board.size and 0 <= c < board.size and board.grid[r][c] == player: count += 1 r -= dr c -= dc if not (0 <= r < board.size and 0 <= c < board.size) or board.grid[r][c] != 0: block += 1 if block == 0 and count >= 4: score += 100000 elif block == 1 and count >= 4: score += 10000 elif block == 0 and count == 3: score += 5000 elif block == 1 and count == 3: score += 1000 # 更细的分数可以继续加 return score这里block表示该方向的两端是否被堵死:block=0代表两端都开放,block=1代表有一端被堵。分数从 100000 到 1000 递减,目的是让搜索优先保活四、冲四,其次才是活三。整盘评估时把每个空位按双方棋子分别打分,AI 走棋时最大化「自己分数 - 对方分数」的差值。这个评估函数虽然简单,但已经能让 AI 知道「先堵对方的活三」而不是自己闷头连。
3.3 α-β 剪枝:搜索树的剪枝逻辑与代码实现
α-β 剪枝是在极大极小搜索基础上的优化。极大极小搜索里,Max 层取子节点最大值,Min 层取子节点最小值;剪枝就是当某条分支的当前值已经不可能影响父节点决策时,直接砍掉剩余子节点。
def alpha_beta(board, depth, alpha, beta, is_maximizing, ai_player): if depth == 0: return evaluate_board(board, ai_player) candidates = get_candidate_moves(board) # 排序:Max层从高分开始搜,Min层从低分开始搜,让剪枝更早生效 scored = [(evaluate_move(board, r, c, ai_player), r, c) for r, c in candidates] scored.sort(reverse=is_maximizing) if is_maximizing: max_eval = -float('inf') for _, row, col in scored: board.grid[row][col] = ai_player eval = alpha_beta(board, depth - 1, alpha, beta, False, ai_player) board.grid[row][col] = 0 max_eval = max(max_eval, eval) alpha = max(alpha, eval) if beta <= alpha: break return max_eval else: min_eval = float('inf') opponent = 3 - ai_player for _, row, col in scored: board.grid[row][col] = opponent eval = alpha_beta(board, depth - 1, alpha, beta, True, ai_player) board.grid[row][col] = 0 min_eval = min(min_eval, eval) beta = min(beta, eval) if beta <= alpha: break return min_eval代码里有几个关键点需要解释。第一,board.grid[row][col] = 0是回溯清理,因为 AI 模拟落子是在原棋盘对象上直接改。第二,scored.sort(reverse=is_maximizing)是剪枝效率的命脉:Max 层先搜分高的节点,能更快抬高 alpha 下限;Min 层先搜分低的节点,能更快压低 beta 上限,剪枝触发率能提升 50% 以上。第三,opponent = 3 - ai_player是五子棋里的小技巧,因为1和2相加等于3,用减法就能拿到对手棋子编号,比写 if/else 干净。
调深度时建议search_depth=3起步:在普通笔记本上配合剪枝,单步耗时约 0.3~0.8 秒;search_depth=4会升到 2~4 秒,但棋力能明显感觉到变强,能主动做双活三的陷阱。如果深度调到 5,普通机器的等待时间会超过 15 秒,超出「人机对战」的心理承受线,不建议默认开启。
4. pygame 交互层实战:事件循环、落子动画与胜负判定
4.1 主循环:pygame 事件处理与游戏状态机
main.py里的主循环遵循 pygame 的标准模式:不断拉取事件、更新游戏状态、重绘画面、控制帧率。五子棋不需要物理引擎,所以状态机很简单,只有PLAYER_TURN、AI_THINKING、GAME_OVER三个状态。
clock = pygame.time.Clock() game_state = "PLAYER_TURN" while True: for event in pygame.event.get(): if event.type == pygame.QUIT: pygame.quit() sys.exit() if event.type == pygame.MOUSEBUTTONDOWN and game_state == "PLAYER_TURN": row, col = get_grid_pos(event.pos) # 像素坐标转棋盘坐标 if board.place(row, col, PLAYER): game_state = "AI_THINKING" if game_state == "AI_THINKING": row, col = ai.next_move(board) board.place(row, col, AI_PLAYER) game_state = "PLAYER_TURN" renderer.draw(board) pygame.display.flip() clock.tick(60)注意get_grid_pos的坐标转换:棋盘左上角到右下角的像素区域是固定的,用鼠标坐标减去棋盘绘制起点,再除以格子边长,就能落到对应的行列上。这个函数在renderer.py里,像素坐标计算时别忘记棋盘边距,否则点击靠边的交叉点会落到错误的行列。
4.2 渲染细节:棋盘网格、棋子阴影与悔棋按钮
棋盘的绘制用pygame.draw.line画网格线,用pygame.draw.circle画棋子。黑色棋子和白色棋子的外圈可以稍微加一点灰度阴影,视觉上会立体很多,代码量不大但体验提升明显。
# 画棋子:黑色棋子阴影偏深灰,白色棋子阴影偏浅灰 def draw_piece(screen, center, radius, color): shadow_color = (60, 60, 60) if color == (0, 0, 0) else (180, 180, 180) pygame.draw.circle(screen, shadow_color, (center[0] + 2, center[1] + 2), radius) pygame.draw.circle(screen, color, center, radius)悔棋按钮的交互可以用一个矩形区域来判定:在renderer.py里定义undo_button_rect,在主循环的鼠标事件里判断event.pos是否落在该矩形内,是就调用board.undo()。这里有个细节:只能悔两方各一手,否则整个棋局历史会被打乱,AI 思考的依据也会错位。我一般实现为「撤回 AI 最后一手 + 撤回玩家最后一手」,保证轮到玩家时棋盘状态合法。
4.3 胜负判定:五子连珠检测的四方向扫描
胜负判定放在board.py里而不是渲染层,是为了让 AI 搜索时也能复用。每落一子后,以该子为中心向四个方向延伸:水平、垂直、主对角线、副对角线;每个方向数一遍连续同色棋子的数量,大于等于 5 就判胜。这样不需要每步全盘扫描,效率高得多。
def check_win(board, row, col, player): directions = [(1, 0), (0, 1), (1, 1), (1, -1)] for dr, dc in directions: count = 1 r, c = row + dr, col + dc while 0 <= r < board.size and 0 <= c < board.size and board.grid[r][c] == player: count += 1 r += dr c += dc r, c = row - dr, col - dc while 0 <= r < board.size and 0 <= c < board.size and board.grid[r][c] == player: count += 1 r -= dr c -= dc if count >= 5: return True return False这个判定函数要返回 True 时,主循环里就切换到GAME_OVER状态,并显示一条结果提示。注意count >= 5而不是count == 5,因为可能连成六子或更长,虽然五子棋规则里不允许长连,但 AI 模拟搜索时可能产生超过五子的局面,写宽松判断能避免漏判。
5. 运行、调参与避坑:AI 智商从「人工智障」到「有点会下」
5.1 环境搭建:常见依赖安装问题
拿到源码后按pip install -r requirements.txt装依赖,理论上一条命令搞定。实际跑的时候最容易在 pygame 上翻车:如果提示ModuleNotFoundError: No module named 'pygame',先确认当前激活的虚拟环境是否正确,再用pip list看包有没有装上。如果提示pygame安装失败且报错和SDL相关,多半是 Python 版本过高没有预编译的 wheel,降级到 Python 3.10 再用pip install pygame,基本能解决。
启动前建议先跑一遍main.py,如果窗口能弹出但完全不显示棋盘,检查constants.py里的SCREEN_WIDTH、BOARD_TOP_LEFT等参数是否在合理的像素范围。这些参数一旦写死,换不同分辨率的屏幕显示就可能出问题,后面二次开发时最好改成按窗口大小动态计算。
5.2 性能实测:搜索深度、剪枝与响应时间的关系
我把不同搜索深度和剪枝开关的参数跑了一遍,数据记录在下面这张表里,方便你自己调参时有个参照。测试机器是普通 i5 笔记本,候选点半径 radius=2。
| 搜索深度 | 未使用剪枝耗时 | 使用α-β剪枝耗时 | 棋力表现 |
|---|---|---|---|
| 2 | ~1.2s | ~0.05s | 只会挡和连,无策略 |
| 3 | ~28s | ~0.6s | 能主动做活三 |
| 4 | >180s | ~3.2s | 会做双三陷阱,偶尔防守失误 |
| 5 | 无法接受 | ~18s | 棋力强但等待太久 |
可以看到,剪枝加排序后深度 4 的耗时从三分钟级别降到三秒级别,完全在可接受范围内。如果想让深度 4 跑进 1 秒,可以引入置换表缓存,或者把评估函数改成分段计算,但那是进阶玩法,不建议第一次就上。
5.3 避坑记录:三个让我排查到怀疑人生的坑
第一坑:Python 列表的浅拷贝。board.grid.copy()只能复制一层,二维列表拷贝后子列表还是原引用,AI 模拟落子时一改就把真实棋盘改了。解决方法是copy.deepcopy(board.grid),或者手写列表推导式[row[:] for row in board.grid],后者性能更高。第二坑:评估函数没有区分「当前 AI 下」和「对手下」的视角,导致 AI 在 Min 层帮对手走棋。这个问题只有在完整跑一局时才会暴露,肉眼调试极难发现,建议写单测验证:固定一个局面,分别算 AI 视角和对手视角的分数是否对称。第三坑:pygame 的MOUSEBUTTONDOWN事件在AI_THINKING状态下仍然会触发,如果不做状态判断,玩家在 AI 思考时狂点棋盘,就会先把棋子下到别的位置,随后 AI 再下一手,整个局面就乱了。加一层if game_state == "PLAYER_TURN"的判断就能解决,但忘记写的人不在少数。
6. 二次开发思路:把 AI 从「会下」变成「会赢」
6.1 三个改造方向:评估函数调参、深度自适应、开局库
想提升 AI 棋力,最直接的是优化评估函数。现在的版本只考虑了单点单方向,你可以给它加分权重项:比如「同时形成两个冲三点」额外加 50000 分,这样 AI 才会主动做双三陷阱,而不是机械地连自己的五子。另一个方向是搜索深度自适应:前几步棋局空旷时用深度 2 快速落子,中盘切换到深度 4,残局用深度 5 收尾,通过len(board.moves)判断棋局阶段。开局库最简单粗暴,把前 6 手常见定式存成一个字典,AI 直接从库里取,跳过搜索,既快又不失先手。
6.2 验证方法:让 AI 自己和自己对弈十几局
改完评估函数后,肉眼测几局发现 AI 变强了,但这不严谨。我习惯写一个self_play.py脚本,让 AI 自己和自己对弈 20 局,统计胜率分布和平均步数。只要改一个函数入口:ai.next_move(board)分别调用两次,一方用黑棋参数,一方用白棋参数。当你调了一版新的评估函数后,和旧版各执黑白对弈,观察胜负率变化,就能判断改动是正向还是负向。
# 自对弈脚本的核心循环 while not board.check_winner(): player = 1 if len(board.moves) % 2 == 0 else 2 row, col = ai.next_move(board, player) board.place(row, col, player)这里把next_move加了一个player参数,让它支持为指定方搜索。自对弈里经常出现一个现象:A 版本执黑强,执白弱,这种不均衡通常意味着评估函数对先后手没有做区分,可以考虑在开局阶段给黑棋稍高权重。
6.3 一个实战技巧:打开剪枝日志看 AI 的决策路径
α-β 剪枝是一个黑匣子,你不亲眼看到「哪个分支被砍了、alpha 怎么一步步抬高」就很难优化性能。调试时可以加一个debug=True开关,在剪枝触发点打印当前深度、alpha、beta 和走法分数。我之前的血泪经验是:有一次剪枝后 AI 突然不挡对手的活四了,排查半天发现是叶子节点评估返回的分数恒为 0,因为evaluate_board里写反了玩家颜色参数。从那以后我每次改 AI 逻辑都强制走一遍「自对弈 + 日志抽样」的流程,AI 的每个决策路径都能手动回看,定位问题时不用再靠猜。
改动和验证循环跑顺之后,你可以接着把搜索深度调成动态的、给评估函数加更多棋型、甚至尝试结合蒙特卡洛树搜索,但最核心的 α-β 剪枝框架不会变。希望这篇拆解能帮你跑通第一局人机对战,也让你摸清博弈 AI 的基本套路。
本文还有配套的精品资源,点击获取