简介:这是一份面向Python初学者与棋类AI爱好者的五子棋人机对战实现方案,核心解决电脑走步的智能选取问题。程序采用极大极小值搜索,并引入α-β剪枝提升搜索效率,同时实现候选落点获取与评估函数设计,评估函数直接决定电脑棋力强弱。资源包共3个文件,包含2个py源码与1个txt说明,压缩后约15KB,源码涵盖棋盘绘制、按钮交互与AI逻辑,说明文件便于快速理解结构。运行环境为PyCharm 2019.2,支持先后手切换、胜负判定与棋局信息显示,玩家可点击按钮设置先手或后手,胜负出现后显示YOU WIN或YOU LOSE并锁定棋盘,通过Replay或Quit控制流程。目前已有4705人学习下载,适合想理解α-β剪枝在博弈搜索中落地方式的读者参考,可据此掌握评估函数与搜索优化的基本思路。
1. 从被电脑连赢七把说起:这个五子棋项目到底能干什么
周末翻出一个五子棋小项目,本来想随手赢两把找找自信,结果被电脑连赢七局。复盘时才发现,它每一步落子背后都跑了一遍 α-β 剪枝搜索——不是随便堵你一下,而是真的在算后面好几步的棋路。这个项目用 Python 加 pygame 实现了一个完整的人机对战五子棋,棋盘绘制、落子判定、胜负检测、AI 决策全部包含在内,核心算法就是 α-β 剪枝。它适合两类人:一类是刚学完 Python 基础、想找一个能跑起来又有算法含量的练手项目;另一类是想搞明白博弈树搜索到底怎么落地到具体棋类游戏里的开发者。整个项目不依赖复杂框架,装好 pygame 就能跑,代码量适中,改起来也不费劲。
2. 项目结构拆解:棋盘、规则、AI 三层怎么分
2.1 目录结构与模块职责
拿到源码后先别急着跑,花两分钟看一眼目录结构,后面改代码会顺很多。这个项目的组织方式比较典型,按职责拆成几个文件:
gomoku/ ├── main.py # 入口,初始化 pygame、主循环、事件分发 ├── board.py # 棋盘数据结构、落子、胜负判定 ├── ai.py # α-β 剪枝搜索、评估函数、候选点生成 ├── render.py # 绘制棋盘、棋子、提示信息 └── config.py # 棋盘大小、颜色、搜索深度等常量main.py负责把 pygame 的事件循环和游戏逻辑串起来,board.py管棋盘状态和规则,ai.py是算法核心,render.py只管画,config.py放配置。这种拆法的好处是改 AI 不用动渲染,调界面不用碰搜索逻辑。
常见做法是把棋盘状态用一个二维列表表示,0 表示空位,1 表示玩家,2 表示 AI。胜负判定就是每次落子后检查四个方向有没有连成五子。
2.2 棋盘与胜负判定的实现
棋盘模块是整个项目的地基,AI 搜索时也会频繁调用它的落子、撤销、胜负检测。先看核心代码:
# board.py class Board: def __init__(self, size=15): self.size = size # 0 空位,1 玩家,2 AI self.grid = [[0] * size for _ in range(size)] self.history = [] # 记录落子顺序,方便撤销 def place(self, row, col, player): """落子,返回是否合法""" if self.grid[row][col] != 0: return False self.grid[row][col] = player self.history.append((row, col, player)) return True def undo(self): """撤销最后一步,AI 搜索回溯时用""" if self.history: row, col, _ = self.history.pop() self.grid[row][col] = 0 def check_win(self, row, col, player): """检查刚落下的这步是否形成五连""" directions = [(0, 1), (1, 0), (1, 1), (1, -1)] for dr, dc in directions: count = 1 # 正方向延伸 for step in range(1, 5): r, c = row + dr * step, col + dc * step if 0 <= r < self.size and 0 <= c < self.size and self.grid[r][c] == player: count += 1 else: break # 反方向延伸 for step in range(1, 5): r, c = row - dr * step, col - dc * step if 0 <= r < self.size and 0 <= c < self.size and self.grid[r][c] == player: count += 1 else: break if count >= 5: return True return Falseplace方法先判断目标位置是否为空,合法才写入并记录到history。undo是给 AI 搜索用的——α-β 剪枝在递归过程中需要不断落子和撤销来模拟不同走法,没有撤销能力就没法回溯。check_win只检查最后落子点的四个方向,不需要全盘扫描,这样每次判定是 O(1) 级别的开销。
参数方面,size默认 15,对应标准五子棋棋盘。directions四个方向覆盖了横、竖、两条对角线,这是五子棋胜负判定的标准做法。注意check_win接收的是刚落子的坐标,不是全盘检查,这个设计在 AI 搜索里很关键,因为搜索过程中会频繁调用。
2.3 pygame 渲染与事件循环
渲染层不复杂,但有几个细节不注意会翻车。先看主循环:
# main.py import pygame from board import Board from ai import AIPlayer from render import Renderer from config import BOARD_SIZE, CELL_SIZE, MARGIN def main(): pygame.init() window_size = BOARD_SIZE * CELL_SIZE + 2 * MARGIN screen = pygame.display.set_mode((window_size, window_size)) pygame.display.set_caption("Gomoku") clock = pygame.time.Clock() board = Board(BOARD_SIZE) ai = AIPlayer(player=2, depth=4) renderer = Renderer(screen, BOARD_SIZE, CELL_SIZE, MARGIN) running = True game_over = False while running: for event in pygame.event.get(): if event.type == pygame.QUIT: running = False elif event.type == pygame.MOUSEBUTTONDOWN and not game_over: x, y = event.pos col = round((x - MARGIN) / CELL_SIZE) row = round((y - MARGIN) / CELL_SIZE) if 0 <= row < BOARD_SIZE and 0 <= col < BOARD_SIZE: if board.place(row, col, 1): if board.check_win(row, col, 1): game_over = True else: # AI 回合 ai_row, ai_col = ai.get_move(board) board.place(ai_row, ai_col, 2) if board.check_win(ai_row, ai_col, 2): game_over = True renderer.draw(board, game_over) pygame.display.flip() clock.tick(30) pygame.quit() if __name__ == "__main__": main()鼠标点击坐标转棋盘坐标用的是round((x - MARGIN) / CELL_SIZE),这里用round而不是整除,是因为玩家点击的位置不会刚好在交叉点上,取最近交叉点更符合直觉。clock.tick(30)限制帧率,避免 CPU 空转。
Renderer类负责画网格线、棋子、最后落子标记和胜负提示。棋盘线从MARGIN开始,每条线间隔CELL_SIZE。棋子用pygame.draw.circle画,玩家黑色、AI 白色,最后落子加一个小红点方便辨认。
注意:pygame 的坐标原点在左上角,棋盘交叉点的坐标是
MARGIN + col * CELL_SIZE,别搞反行列。
3. α-β 剪枝核心:AI 到底怎么算出那一步
3.1 极大极小搜索与 α-β 剪枝原理
AI 决策的本质是在博弈树上找最优走法。假设 AI 走一步、玩家走一步交替进行,AI 要最大化自己的优势,玩家会最小化 AI 的优势,这就是极大极小搜索。但纯极大极小搜索的节点数是指数级的,15×15 棋盘搜索四层就是天文数字。α-β 剪枝的作用是在搜索过程中砍掉那些不可能影响最终决策的分支。
具体来说,α 记录当前 MAX 节点已知的最好值,β 记录 MIN 节点已知的最好值。搜索过程中如果发现某个节点的值已经不可能比已知的更好,就直接剪掉。理想情况下能把搜索量降到平方根级别。
# ai.py import math from config import BOARD_SIZE class AIPlayer: def __init__(self, player=2, depth=4): self.player = player self.opponent = 1 if player == 2 else 2 self.depth = depth def get_move(self, board): """返回 AI 选定的落子位置""" _, move = self._alphabeta(board, self.depth, -math.inf, math.inf, True) return move def _alphabeta(self, board, depth, alpha, beta, is_max): """α-β 剪枝搜索,返回 (评估值, 最佳落子)""" # 终止条件:达到深度限制 if depth == 0: return self._evaluate(board), None candidates = self._gen_candidates(board) if not candidates: return self._evaluate(board), None best_move = candidates[0] if is_max: value = -math.inf for row, col in candidates: board.place(row, col, self.player) if board.check_win(row, col, self.player): board.undo() return 100000 + depth, (row, col) # 越快赢分越高 score, _ = self._alphabeta(board, depth - 1, alpha, beta, False) board.undo() if score > value: value = score best_move = (row, col) alpha = max(alpha, value) if alpha >= beta: break # β 剪枝 return value, best_move else: value = math.inf for row, col in candidates: board.place(row, col, self.opponent) if board.check_win(row, col, self.opponent): board.undo() return -100000 - depth, (row, col) score, _ = self._alphabeta(board, depth - 1, alpha, beta, True) board.undo() if score < value: value = score best_move = (row, col) beta = min(beta, value) if alpha >= beta: break # α 剪枝 return value, best_moveget_move是入口,调用_alphabeta并返回最佳落子。is_max为 True 表示 AI 回合,要最大化评估值;False 表示玩家回合,要最小化。alpha和beta是剪枝边界,初始为负无穷和正无穷。
关键点在剪枝条件:MAX 节点里如果alpha >= beta,说明当前分支的结果已经不可能被 MIN 层接受,直接break。MIN 节点同理。这个剪枝不影响最终结果,但能大幅减少搜索节点数。
depth参数控制搜索深度,默认 4 层。深度越大 AI 越强,但耗时也越长。实际跑的时候可以试试 depth=2 和 depth=4 的区别,感受一下搜索深度对棋力的影响。
3.2 评估函数与候选点生成
α-β 剪枝本身只是搜索框架,真正决定 AI 棋力的是评估函数。评估函数负责给一个棋盘局面打分,分数越高对 AI 越有利。
# ai.py 续 def _evaluate(self, board): """从 AI 视角评估当前局面""" score = 0 score += self._score_all_lines(board, self.player) * 1.0 score -= self._score_all_lines(board, self.opponent) * 1.1 # 防守权重略高 return score def _score_all_lines(self, board, player): """扫描所有行、列、对角线,统计棋型得分""" total = 0 size = board.size directions = [(0, 1), (1, 0), (1, 1), (1, -1)] visited = set() for r in range(size): for c in range(size): if board.grid[r][c] != player: continue for dr, dc in directions: # 只从连续段的起点开始统计,避免重复 prev_r, prev_c = r - dr, c - dc if 0 <= prev_r < size and 0 <= prev_c < size and board.grid[prev_r][prev_c] == player: continue count = 0 cr, cc = r, c while 0 <= cr < size and 0 <= cc < size and board.grid[cr][cc] == player: count += 1 cr += dr cc += dc total += self._pattern_score(count) return total def _pattern_score(self, count): """根据连子数量给分""" if count >= 5: return 100000 elif count == 4: return 10000 elif count == 3: return 1000 elif count == 2: return 100 elif count == 1: return 10 return 0_evaluate分别统计 AI 和玩家的棋型得分,玩家得分乘以 1.1 的防守权重,让 AI 稍微偏向堵棋。_score_all_lines扫描四个方向,用prev判断是否是连续段起点来避免重复计数。_pattern_score是简化的棋型评分,连五给最高分,连四次之,依次递减。
这个评估函数比较粗糙,没有区分活四、冲四、活三这些精细棋型。想提升 AI 棋力,最直接的方向就是细化_pattern_score,把活四和冲四分开,活三和眠三分开。
候选点生成决定了搜索的宽度:
# ai.py 续 def _gen_candidates(self, board): """只生成已有棋子附近两格内的空位,减少搜索宽度""" candidates = [] size = board.size has_stone = False for r in range(size): for c in range(size): if board.grid[r][c] != 0: has_stone = True for dr in range(-2, 3): for dc in range(-2, 3): nr, nc = r + dr, c + dc if 0 <= nr < size and 0 <= nc < size and board.grid[nr][nc] == 0: if (nr, nc) not in candidates: candidates.append((nr, nc)) if not has_stone: return [(size // 2, size // 2)] # 空盘下天元 return candidates_gen_candidates只考虑已有棋子周围两格内的空位,这是五子棋 AI 的常规优化。全盘 225 个点如果都搜,四层就是 225^4 级别,根本跑不动。限制在已有棋子附近,候选点通常只有几十个,搜索量降了好几个数量级。
提示:候选范围从两格改成三格,AI 会变强但明显变慢。可以先从两格跑通,再根据机器性能调整。
4. 跑起来再说:环境、启动与参数调整
4.1 环境准备与启动步骤
这个项目的依赖很少,主要就是 pygame。建议用虚拟环境,避免和系统里的包冲突。
# 创建虚拟环境 python -m venv venv # 激活(Windows) venv\Scripts\activate # 激活(macOS/Linux) source venv/bin/activate # 安装依赖 pip install pygame # 启动游戏 python main.pyPython 版本建议 3.8 以上,pygame 用pip install pygame装最新版就行。如果安装时报编译错误,常见原因是缺少系统级依赖,Linux 下可能需要先装python3-dev和libsdl2-dev。
启动后会出现一个 15×15 的棋盘窗口,玩家执黑先手,鼠标点击交叉点落子,AI 执白自动应手。窗口标题栏显示 "Gomoku",棋盘上最后落子位置有红点标记。
4.2 关键参数怎么调
项目里几个参数直接影响体验,都在config.py和AIPlayer初始化处:
| 参数 | 位置 | 默认值 | 作用 | 调整建议 |
|---|---|---|---|---|
| BOARD_SIZE | config.py | 15 | 棋盘大小 | 改成 19 就是 19 路棋盘,但评估函数要跟着调 |
| CELL_SIZE | config.py | 40 | 格子像素 | 屏幕小就调小,别小于 25 |
| depth | ai.py | 4 | 搜索深度 | 2 快但弱,6 强但慢,4 是平衡点 |
| 候选范围 | ai.py | 2 | 搜索宽度 | 改成 3 会变强但明显变慢 |
| 防守权重 | ai.py | 1.1 | 攻守倾向 | 调大更偏防守,调小更偏进攻 |
搜索深度是最值得调的参数。depth=2 时 AI 基本只堵眼前,depth=4 能算两步,depth=6 能算三步但每步可能要等好几秒。我一般先用 depth=4 跑通,确认没问题再往上加。
改棋盘大小要注意:BOARD_SIZE改了之后,_gen_candidates里的天元位置会自动跟着变,但评估函数的棋型评分是针对五子棋设计的,改成 19 路后连五的难度变了,评分权重需要重新调。
4.3 常见问题排查
问题一:点击落子位置偏移。现象是点交叉点但棋子落在旁边。原因是坐标转换时MARGIN和CELL_SIZE没对齐。检查render.py里画线的起始位置和main.py里坐标转换用的MARGIN是不是同一个值。
问题二:AI 落子后卡死。现象是 AI 回合窗口无响应。原因是搜索深度太大或候选点太多,递归层数爆炸。先把depth降到 2 试试,如果能跑就逐步加。另外检查_gen_candidates有没有去重,重复候选点会让搜索量翻倍。
问题三:AI 不堵棋。现象是玩家快连五了 AI 还在下自己的。原因是评估函数里防守权重太低,或者搜索深度不够看不到玩家的威胁。把防守权重从 1.1 调到 1.5 试试,或者把depth加到 6。
问题四:胜负判定漏判。现象是明明连五了但游戏没结束。检查check_win的方向数组是不是四个方向都覆盖了,特别是两条对角线容易写错。另外确认check_win传的是刚落子的坐标,不是遍历全盘。
问题五:pygame 窗口闪退。现象是启动后窗口一闪就没了。大概率是main.py里主循环没有正确处理pygame.QUIT事件,或者pygame.display.flip()没调用。检查事件循环里有没有if event.type == pygame.QUIT: running = False。
5. 二次开发:让 AI 更强、界面更好用的几个改造方向
5.1 评估函数精细化
当前评估函数只按连子数量给分,没有区分活四和冲四。活四两头都能连五,冲四只有一头能连,威胁程度差很多。改造思路是在_score_all_lines里不仅统计连续段长度,还检查两端是否被堵:
def _score_line_with_ends(self, board, r, c, dr, dc, player): """统计连续段长度和两端开放情况""" size = board.size count = 0 cr, cc = r, c while 0 <= cr < size and 0 <= cc < size and board.grid[cr][cc] == player: count += 1 cr += dr cc += dc # 检查两端是否为空 open_ends = 0 for er, ec in [(r - dr, c - dc), (cr, cc)]: if 0 <= er < size and 0 <= ec < size and board.grid[er][ec] == 0: open_ends += 1 return count, open_ends拿到count和open_ends后重新设计评分表:活四(count=4, open_ends=2)给 50000,冲四(count=4, open_ends=1)给 10000,活三(count=3, open_ends=2)给 5000,眠三(count=3, open_ends=1)给 500。这样 AI 就能识别出真正的威胁,而不是简单地数连子。
5.2 加入置换表减少重复搜索
α-β 剪枝在搜索过程中会重复访问相同局面,尤其是不同落子顺序到达同一棋盘状态时。置换表用哈希值缓存已搜索过的局面和对应评分,下次遇到直接查表:
class TranspositionTable: def __init__(self): self.table = {} def get(self, board, depth): key = self._hash(board) entry = self.table.get(key) if entry and entry['depth'] >= depth: return entry['score'] return None def put(self, board, depth, score): key = self._hash(board) self.table[key] = {'depth': depth, 'score': score} def _hash(self, board): """把棋盘状态转成可哈希的元组""" return tuple(tuple(row) for row in board.grid)在_alphabeta开头查表,命中就直接返回;搜索完把结果存表。置换表能显著减少节点数,尤其在搜索深度大的时候效果明显。注意哈希用棋盘完整状态,不要用 Zobrist 哈希的话简单元组就够了,这个项目规模不大。
5.3 界面与交互改进
界面方面有几个低成本高收益的改动。一是加落子音效,pygame.mixer.Sound加载一个短音频,落子时播放。二是加悔棋按钮,利用board.undo()撤销两步(玩家和 AI 各一步)。三是显示 AI 思考耗时,在get_move前后打时间戳,渲染到窗口角落。
悔棋功能的实现要注意:撤销一步只撤 AI 的落子,玩家还是没落子状态,所以要连续撤两步。如果历史记录不足两步就只撤一步。代码大概是这样:
def undo_two_steps(board): """悔棋:撤销玩家和 AI 各一步""" if len(board.history) >= 2: board.undo() # 撤 AI board.undo() # 撤玩家 elif len(board.history) == 1: board.undo()5.4 一个验证改造效果的小技巧
改完 AI 之后怎么知道变强了?最直接的办法是让新旧两个 AI 对战。写一个自动对弈脚本,两个 AI 轮流落子,跑 100 局统计胜率。如果新 AI 胜率明显高于 50%,说明改造有效。
def self_play(ai_black, ai_white, board, max_moves=225): """两个 AI 自动对弈一局,返回胜者""" for _ in range(max_moves): current = ai_black if len(board.history) % 2 == 0 else ai_white row, col = current.get_move(board) board.place(row, col, current.player) if board.check_win(row, col, current.player): return current.player return 0 # 平局跑自动对弈时记得把渲染关掉,不然 100 局要跑到天荒地老。把renderer.draw那行注释掉,只跑逻辑,速度会快很多。
从那以后我每次改完 AI 参数,都强制跑一遍自动对弈再手动试玩,不然光靠感觉根本判断不出改动是变强还是变弱。希望这个项目能帮到你,不管是拿来练手还是当二次开发的底子,跑起来改起来都不亏。
本文还有配套的精品资源,点击获取