ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Python五子棋人机对战:α-β剪枝算法详解与pygame实现

Python五子棋人机对战:α-β剪枝算法详解与pygame实现

简介:这是一份基于α-β剪枝实现五子棋人机对战的Python完整程序,适合正在学习博弈树搜索、需要完成课程设计或小游戏开发的初学者。程序基于pygame搭建棋盘与交互界面,支持玩家与电脑轮流落子,可自由切换先手/后手,胜负判断、重新开局、退出等操作齐全;电脑走棋核心是极大极小值搜索,并利用α-β剪枝剪除无效搜索分支,配合评估函数评估每个候选点的优劣,较完整地呈现了经典博弈算法在小游戏中的落地方式。压缩包共3个文件,含2个Python脚本和1个说明文本,其中脚本分别承担界面交互与AI下棋决策,包体仅15KB,结构精简,便于在PyCharm 2019.2等环境中直接运行、调试和二次改造。已有4704人学习下载,适合对照代码理解剪枝条件、评估函数设计以及pygame事件循环等关键环节。

1. 用 Python 手写五子棋人机对战:α-β 剪枝才是这套 Demo 的灵魂

如果你也想用 Python 写一个五子棋人机对战,pygame 画棋盘、画棋子只是表面功夫,真正的分水岭在搜索算法。把 α-β 剪枝写进 minimax 搜索树之后,AI 在 4 层搜索深度下依然能秒回落子,靠的不是机器快,而是把几亿个无效节点剪刀一样剪掉。这套基于 α-β 剪枝的 Python 五子棋人机对战源码,适合做课程设计、毕设起步,也适合想搞懂博弈树的人拿来拆着玩。你只要会 Python 基础、函数递归和 pygame 的事件循环,就能在半小时内跑起来,再花一个晚上把剪枝逻辑读透。

2. 极大极小搜索与 α-β 剪枝:先让 AI 学会“想两步”

2.1 五子棋博弈树与胜负判定写法

五子棋的 AI 本质上是在模拟“我走一步,你走一步”的过程。当前局面轮到我方落子时,我方是 MAX 方,要选评分最高的走法;敌方是 MIN 方,会选择让我方评分最低的走法。这就是极大极小搜索。在没有 α-β 剪枝的情况下,一个 15×15 的棋盘开局第一手有 225 个可选点,搜索深度 4 层,节点数大约是 225 × 224 × 223 × 222,数量级在 24 亿以上,Python 完全扛不住。

所以源码里第一步是限定候选点,而不是全盘扫描。常见做法是只搜索“已有棋子周围两格范围内的空点”,这样第一手候选点从 200 多个降到 8 个左右,后面每层也基本控制在 10~30 个点。这个优化和 α-β 剪枝是配套的,少了它剪枝效率再高也白搭。

胜负判定反而是最简单的部分,每次落子后只检查该点出发的横、竖、两条斜线四个方向,连成五子即返回 True:

def check_win(board, row, col, player): directions = [(1, 0), (0, 1), (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 < 15 and 0 <= c < 15 and board[r][c] == player: count += 1 else: break for step in range(1, 5): r, c = row - dr * step, col - dc * step if 0 <= r < 15 and 0 <= c < 15 and board[r][c] == player: count += 1 else: break if count >= 5: return True return False

这段代码的 dir 是从当前位置往外扩散的方向元组。先沿正方向数,再沿反方向数,加起来是连续同色棋子的真实长度。这里有个细节:反向搜索也要做越界判断,棋盘边缘落子时最容易 IndexError。为什么只查 4 步?因为五连是最低要求,正反加起来超过 5 就已经赢了,不需要继续往外数。

2.2 评估函数:把棋型变成分数

搜索树叶子节点的分数必须来自一个静态评估函数。这套源码里没有用复杂的机器学习模型,而是把棋型分了几个等级,用字典直接映射分数:

棋型分值含义
五连100000已经赢了
活四50000两头都通,必胜
冲四10000一头被堵,对方必须挡
活三5000再走一步变活四
眠三1000对方可以堵住
活二500有发展潜力
眠二100潜力有限

评估函数的核心是分别统计棋盘上所有空位周围的棋型,把“我方棋型分”减去“对方棋型分”作为当前局面的总评分。写的时候有个容易出错的点:横竖斜四个方向要分别统计,而且每个方向都要把连续同色棋子的两端一起检查,才能判断是“活”还是“眠”。

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 open_ends = 0 for step in range(1, 5): r, c = row + dr * step, col + dc * step if not (0 <= r < 15 and 0 <= c < 15): break if board[r][c] == player: count += 1 elif board[r][c] == 0: open_ends += 1 break else: break for step in range(1, 5): r, c = row - dr * step, col - dc * step if not (0 <= r < 15 and 0 <= c < 15): break if board[r][c] == player: count += 1 elif board[r][c] == 0: open_ends += 1 break else: break score += shape_score.get((count, open_ends), 0) return score

这里的 shape_score 是把 (连子数, 开放端数) 组合映射成分数的字典。比如 (4, 2) 是活四,(4, 1) 是冲四,(3, 2) 是活三。open_ends 只在遇到空位时加一然后立刻 break,遇到对方棋子就算被封死。这个写法比“把整条线扫完再判断”高效得多,因为每层搜索要调用成千上万次评估,能省一次循环都是赚。

2.3 α-β 剪枝递归实现与参数设置

评估函数定好之后,就是搜索引擎的主场。经典 minimax 会完整展开所有节点,而 α-β 剪枝在递归过程中维护两个值:α 是 MAX 方目前能保证的最低分,β 是 MIN 方目前能承受的最高分。当某个分支的分数让 α ≥ β 时,后续节点直接不看了。

def alphabeta(board, depth, alpha, beta, is_maximizing, ai_player): winner = check_terminal(board) if winner == ai_player: return 100000 + depth if winner == 3 - ai_player: return -100000 - depth if depth == 0: return evaluate_board(board, ai_player) candidates = get_candidates(board) if is_maximizing: best = -float('inf') for r, c in candidates: board[r][c] = ai_player score = alphabeta(board, depth - 1, alpha, beta, False, ai_player) board[r][c] = 0 best = max(best, score) alpha = max(alpha, score) if beta <= alpha: break return best else: best = float('inf') for r, c in candidates: board[r][c] = 3 - ai_player score = alphabeta(board, depth - 1, alpha, beta, True, ai_player) board[r][c] = 0 best = min(best, score) beta = min(beta, score) if beta <= alpha: break return best

关键参数有三处:depth 决定 AI 的“眼光”有多远,depth=4 是速度和棋力的均衡点;alpha 初始化为负无穷,beta 初始化为正无穷,第一层递归传入完整的 (-inf, inf) 区间;返回值里加上 depth 的微调,是为了让 AI 在同样能赢的局面下优先选择更快赢的走法,这个细节能让终端体验明显变好。

递归结束后要恢复棋盘状态,board[r][c] = 0 这行就是撤销落子。很多人第一次写的时候忘了恢复,导致评估全乱,AI 走几步就开始下出莫名其妙的棋。剪枝条件当 beta <= alpha 时直接 break,注意这里不是 return,只是跳出当前循环,因为当前节点的最佳值已经确定,后面的候选点不可能改变结果了。

3. pygame 界面与交互层:把 AI 从控制台拉到窗口里

3.1 棋盘绘制与像素坐标换算

pygame 部分的核心不是画图,而是“业务坐标”和“像素坐标”的换算。逻辑上棋盘是 15×15 的二维数组 board[row][col],row 和 col 都是 0~14 的整数;画图时需要用格子坐标乘以格子边长,再加上边距,得到屏幕上的像素点。

BOARD_SIZE = 15 CELL_SIZE = 40 MARGIN = 30 SCREEN_SIZE = BOARD_SIZE * CELL_SIZE + MARGIN * 2 def board_to_pixel(row, col): x = MARGIN + col * CELL_SIZE y = MARGIN + row * CELL_SIZE return x, y def pixel_to_board(x, y): if x < MARGIN or y < MARGIN: return None col = round((x - MARGIN) / CELL_SIZE) row = round((y - MARGIN) / CELL_SIZE) if not (0 <= row < BOARD_SIZE and 0 <= col < BOARD_SIZE): return None dist = abs(x - board_to_pixel(row, col)[0]) + abs(y - board_to_pixel(row, col)[1]) if dist > CELL_SIZE * 0.6: return None return row, col

pixel_to_board 和 board_to_pixel 互为反函数。pixel_to_board 里的 dist 距离判断很关键:玩家点击位置如果偏离格子中心太远,应该忽略这次点击,而不是强行吸附到最近的格子上。否则玩家想点格子 A,手一抖点到 A 和 B 中间,系统可能把棋子下在完全另一个格点,体验很糟。这个“点在格心附近才算有效点击”的容差逻辑,是所有棋类 GUI 都该有的。

3.2 事件循环与落子流程状态机

pygame 主循环里不能直接调用 AI 搜索,因为 alphabeta 在 depth=4 时也要几十毫秒到几百毫秒,如果放在事件循环的主线程里,窗口会直接卡死,玩家拖拽窗口都没反应。源码里用的方案是“主循环 + 状态机 + 子线程搜索”三件套。

def game_loop(): clock = pygame.time.Clock() state = "PLAYER_TURN" ai_thinking = False ai_result = None while True: for event in pygame.event.get(): if event.type == pygame.QUIT: pygame.quit() return if state == "PLAYER_TURN" and event.type == pygame.MOUSEBUTTONDOWN: pos = pixel_to_board(*event.pos) if pos and board[pos[0]][pos[1]] == 0: board[pos[0]][pos[1]] = HUMAN if check_win(board, pos[0], pos[1], HUMAN): state = "GAME_OVER" else: state = "AI_TURN" ai_thinking = True ai_result = None threading.Thread(target=ai_worker, args=(board, ai_result), daemon=True).start() if ai_thinking and ai_result is not None: r, c = ai_result board[r][c] = AI ai_thinking = False if check_win(board, r, c, AI): state = "GAME_OVER" else: state = "PLAYER_TURN" draw_board() if state == "AI_TURN": draw_thinking_animation() pygame.display.flip() clock.tick(60)

状态机的流转是:PLAYER_TURN → AI_TURN → PLAYER_TURN,中间 AI_TURN 状态里有一个 ai_thinking 标志位和 ai_result 结果元组。子线程里搜索完成后,把结果写进 ai_result,主循环每帧检查一次,发现有结果就落子。这里用 threading.Thread 而不是直接把搜索塞进事件循环,是因为 pygame 的 event.get() 必须在主线程执行,而搜索不涉及任何 GUI 操作,放子线程完全安全。

用 daemon=True 的好处是窗口关闭时子线程自动销毁,不会因为残留线程导致进程退不干净。ai_result 用普通变量在子线程里赋值、在主线程里读取,Python 的 GIL 在这种情况下足够安全,因为赋值操作是原子的。

3.3 落子动画、悔棋与音效的扩展点

基础交互跑通之后,可以加的东西都在 draw_board 函数里。棋子用 pygame.draw.circle 画,黑棋填 (0,0,0),白棋填 (255,255,255),再加一个 2 像素宽的灰色描边,视觉上会精致很多。落子前画一个小圆环跟随鼠标当前位置,实时提示“你按的位置会落到哪个格”,这个功能只需在 draw_board 里读 pygame.mouse.get_pos(),再调用 pixel_to_board 即可。

悔棋功能是源码里的隐藏加分项。实现方式是维护一个 move_stack 列表,每次落子把 (row, col, player) push 进栈,悔棋时连续 pop 两次,一次撤销 AI 的棋,一次撤销玩家的棋。注意必须先撤销 AI 再撤销玩家,顺序颠倒会出现“悔一格却撤掉了玩家自己的棋”的错觉。如果要真正做到“只悔玩家不悔 AI”,在某些模式下也可以,只是对弈逻辑会变成不完全对等,这里按照主流实现做双撤销。

状态机上还有一个容易被忽略的细节:轮到 AI 思考时,玩家点击棋盘不应该有任何反应。上面的代码用 state == "PLAYER_TURN" 条件把点击事件过滤掉了,这是必须的。很多半成品会把“AI 思考期间玩家覆盖落子”当 bug 报出来,本质就是状态没锁住。

4. 搜索加速与难度分级:让 AI 从“傻快”到“稳准”

4.1 候选点生成与搜索范围收敛

上一章提到候选点只取“已有棋子周围两格”,具体实现是遍历棋盘上所有非空点,把它们周围两格内的空点加入一个集合。集合去重很重要,否则同一个空点被多个棋子包围时会被扫描多次,产生重复候选。

def get_candidates(board): candidates = set() for r in range(BOARD_SIZE): for c in range(BOARD_SIZE): if board[r][c] != 0: for dr in range(-2, 3): for dc in range(-2, 3): nr, nc = r + dr, c + dc if 0 <= nr < BOARD_SIZE and 0 <= nc < BOARD_SIZE and board[nr][nc] == 0: candidates.add((nr, nc)) if not candidates: return [(BOARD_SIZE // 2, BOARD_SIZE // 2)] return list(candidates)

最后一行的兜底逻辑是:棋盘全空时返回天元位置。这样 AI 先手第一步就直接下中心,不需要走搜索。候选点范围 2 格是经验值,取 1 格容易漏掉一些“隔空冲三”的棋,取 3 格会让候选点数量爆炸,剪枝效率被白白浪费。2 格在棋力和速度之间是比较好的平衡。

拿到候选点列表之后还有一个优化:在进入 alphabeta 之前先对候选点做一次静态评估排序,分数高的排在前面优先搜索。α-β 剪枝的效率极度依赖走法顺序——如果最优走法排在最前面,剪枝会非常早生效;如果最优走法排在最后,几乎等于白剪。这个排序不增加任何额外的搜索量,就是每个候选点调一次 evaluate_point,然后 sort 一下。

def order_candidates(board, candidates, ai_player): def score_of(pos): r, c = pos my_score = evaluate_point(board, r, c, ai_player) opp_score = evaluate_point(board, r, c, 3 - ai_player) return my_score + opp_score * 0.9 return sorted(candidates, key=score_of, reverse=True)

注意这里把“对对方的威胁”也乘了 0.9 权重加进来。原因是一个位置就算不能直接形成自己的活三,但如果能堵住对方的冲四,优先级也应该靠前。这个 0.9 是调参出来的经验值,你可以改成 1.0 甚至 1.2 对比一下效果,AI 的“防守意识”强弱基本靠这个系数控制。

4.2 搜索深度与时间预算的取舍

depth=4 时每一层的节点数受候选点数量影响很大,棋局中后期候选点可能从 10 个涨到 30 个以上,单步搜索时间可能从 50ms 飙升到 800ms。源码里的应对策略是“动态深度”:

def get_search_depth(board): empty_count = sum(row.count(0) for row in board) if empty_count > 200: return 2 if empty_count > 150: return 3 if empty_count > 50: return 4 if empty_count > 10: return 6 return 8

棋局前段棋子少、候选点密,搜索 2~3 层就够用,因为开局阶段最重要是抢占要点;中盘候选点数量稳定在 10~20,depth=4 能让 AI 看到“活三变活四”的关键手段;残局棋盘空位减少,候选点也少了,这时候加到 6~8 层也不会太慢,而且能精准算杀。这个动态深度策略比固定深度更聪明,它利用了一个规律:搜索耗时约等于候选点数目的 depth 次方,空位越少单层越便宜。

4.3 难度分级:别让 AI 把玩家碾压退游

完整的人机对战 Demo 不能只有“最强模式”。源码里用两个参数组合出三档难度:搜索深度 depth 和“随机失误概率”。简单档 depth=2 加 30% 概率在候选点里随机挑一个下;中等档 depth=3 加 10% 概率随机;困难档 depth=4 无随机。

def ai_move(board, difficulty): candidates = get_candidates(board) candidates = order_candidates(board, candidates, AI_PLAYER) if difficulty == "easy" and random.random() < 0.3: return random.choice(candidates[:5]) depth = {"easy": 2, "medium": 3, "hard": 4}[difficulty] best_score = -float('inf') best_move = candidates[0] alpha = -float('inf') beta = float('inf') for r, c in candidates: board[r][c] = AI_PLAYER score = alphabeta(board, depth - 1, alpha, beta, False, AI_PLAYER) board[r][c] = 0 if score > best_score: best_score = score best_move = (r, c) alpha = max(alpha, best_score) return best_move

随机失误只在 candidates 的前 5 个里随机,这个限制很讲究——它保证 AI 偶尔“笨”一下,但不会蠢到故意去下角落或者送杀棋。实际体感是简单档的新手玩家能赢,但赢得不轻松;如果完全不随机,深度 2 的 AI 对新手来说都有点折磨,因为它至少能看到两步内的冲三。

难度选择做成 GUI 里的一个下拉框或者开局前的按钮,选完后用全局变量传递到 ai_move。源码里默认写死 hard,想改成 easy 只需要改一处配置。

5. 五子棋人机对战常见问题排查:六个亲测翻车点

5.1 剪枝“失灵”:搜索速度慢到怀疑人生

现象:depth=4 时一步棋要等 5 秒以上,而且感觉 AI 水平很菜。

原因:alpha 和 beta 的初始化或者传递写错了。最常见的是在 MAX 层把 beta 参数写死成 float('inf'),在 MIN 层把 alpha 写死成 -float('inf'),导致剪枝条件永不成立。还有就是递归调用时传参顺序写反,把 alpha 和 beta 位置调换了,剪枝逻辑直接失效。

解决:打印搜索节点数对比。在 alphabeta 递归入口加一个计数器,depth=4 时纯 minimax 的节点数在千万级,有效剪枝后应该在几十万级。如果看到的是千万级,回去检查递归函数最后两行和调用处的参数传递。我一般还会在剪枝 break 处加一个变量记录剪掉的节点数,剪枝率低于 80% 基本就是走法排序或者初始化有问题。

5.2 评估函数“偏科”:AI 只会进攻不会防守

现象:AI 明明看到对方已经有冲四了,却不去堵,反而在自己另一边发展。

原因:evaluate_board 汇总分数时用了 my_score - opp_score,但评估的视角不对。如果你的评估函数把“自己棋子形成的棋型”和“对方棋子形成的棋型”分别算完之后,没有把对方的威胁放在足够高的权重上,就会出现这种“近视”。更直接的原因是叶子节点调 evaluate_board 时传入的 ai_player 写成了 1 或 2 的硬编码,而不是参数,导致换边之后评估视角错乱。

解决:在 evaluate_point 里单独统计对方棋型分数,汇总时把对方分数乘以 1.0~1.2 的防守系数再相减。我自己调试时习惯把对方的活三、冲四分数单独 print 出来看,如果对手已经连出三个活二而 AI 还在发展自己的眠二,那一定是权重问题。

5.3 pygame 窗口无响应:AI 搜索把主线程堵死了

现象:玩家点击落子后,整个窗口立刻变白,标题栏显示“未响应”,几秒后才恢复。

原因:ai_move 被直接放在事件循环里调用,搜索期间主线程被递归占用,pygame 的 event queue 无法处理任何消息,操作系统判定窗口假死。

解决:使用前面写的 threading.Thread 方案把搜索丢到子线程。注意线程里不能调用任何 pygame.draw 或 display.flip,这些操作必须在主线程。ai_result 的传递用普通变量即可,但最好在子线程里加一个 thread_lock 保护,或者用 queue.Queue 传递结果,主循环每帧调用 queue.get_nowait() 检查。

5.4 棋子重叠:同一格被下了两颗棋

现象:游戏中途发现某个格子上白棋黑棋叠在一起,逻辑棋盘里那个位置虽然有不同的 player 值,但画图时像素坐标一样,导致覆盖。

原因:落子前没有判断 board[row][col] == 0。pixel_to_board 返回的是格子坐标,但在 PLAYER_TURN 状态里如果玩家快速双击,两次 MOUSEBUTTONDOWN 事件在同一帧被处理,第一次落子后状态机还没切换到 AI_TURN,第二次事件又进来了。

解决:落子函数开头强制加一行 if board[row][col] != 0: return。另外在事件循环里,每次处理完点击后直接用 continue 跳过后面的重复判断。更稳妥的做法是加一个 last_click_time 变量,两次点击间隔小于 150ms 直接忽略,这能同时过滤双击误触。

5.5 悔棋后 AI 策略“崩溃”:越悔越傻

现象:玩家悔棋一步后,AI 下一步棋变得很奇怪,甚至下出必输的走法。

原因:move_stack 里只保存了落子坐标,没有保存落子前后的评估状态。悔棋后用 pop 恢复棋盘没错,但如果你忘了同步更新候选点缓存、或者某个全局变量还记着上一手的位置,搜索时会把已经撤回的棋子当成棋盘上的真实局面去评估。还有一个隐蔽问题是:悔棋后退回 PLAYER_TURN,但 ai_thinking 标志位没有重置,导致 AI 的旧结果在下一轮循环里被再次落子。

解决:悔棋函数里 reset 状态机三件套:state = "PLAYER_TURN"、ai_thinking = False、ai_result = None。并且把 move_stack 里的元素设计成三元组 (row, col, player),悔棋时 pop 出来直接把 board[row][col] = 0,不要用棋子颜色推断。

5.6 搜索越深反而越弱:horizon effect(地平线效应)

现象:AI 从 depth=4 调到 depth=6,不仅速度慢了,而且偶尔会漏掉近在眼前的杀棋。

原因:当最深层附近有一个“冲四变成活四”的棋型变化时,评估函数在叶子节点看到的是还没发生的威胁。深度越深,AI 能看到己方的连招,但也可能被对方的威胁“推出搜索边界”。

解决:这是博弈树搜索的经典问题,不是 bug。常规做法是在叶子评估里额外加一层“杀棋检测”,也就是搜索到 depth=0 时,检查当前局面是否存在一步就能赢的走法,如果存在直接返回极值分数。代码里通常写成:

def evaluate_board(board, ai_player): for r, c in get_candidates(board): board[r][c] = ai_player if check_win(board, r, c, ai_player): board[r][c] = 0 return 10000000 board[r][c] = 0 return my_score - opp_score

这样能消除大部分“差一步获胜却在叶子节点看不到”的情况。代价是每次叶子评估多了一次扫描,但因为只扫空点并且提前 break,实际损耗可以接受。把这段检查放进 depth=0 的 return 分支,比放在 eval 之前更合理。

6. 验证与进阶:怎么确认你的 AI 真的变强了

跑通 Demo 只是第一步,真正有用的是建立一套“AI 变强了没”的验证方法。我习惯了用自对弈来测:让 AI 自己和自己下 20 盘,记录胜负和每步耗时。如果 20 盘里 90% 以上都是先手方赢,说明评估函数的前期优势权重偏大;如果经常出现长对局后突然有一方送杀,说明地平线效应还在发作。把 depth 从 2 提升到 4,如果胜率提升明显而单步耗时增加在可接受范围内,说明你的剪枝生效了。

节点统计是最直接的剪枝验证:

NODES = 0 CUTOFFS = 0 def alphabeta(board, depth, alpha, beta, is_maximizing, ai_player): global NODES, CUTOFFS NODES += 1 ... if beta <= alpha: CUTOFFS += 1 break

搜索结束后打印 NODES 和 CUTOFFS,一个健康的实现里 CUTOFFS 应该占 NODES 的 80% 以上。低于这个数,优先检查走法排序;高于 95%,说明你的评估函数区分度不够,很多局面被判成一样了,剪枝过早。

进阶方向上,这套源码有三个自然的扩展点。第一个是加置换表,用 zobrist 哈希缓存已搜索过的局面分数,对重复局面多的中残局提速非常明显,代码量不大,一张 dict 就能跑。第二个是做“算杀”扩展,在 alphabeta 基础上加一个只搜索冲四和活三的快速分支,残局杀棋的效率能提升一个量级。第三个是把我提到的搜索深度动态调整策略进一步做成自适应:给 AI 一个“单步时间预算”,比如 500ms,搜索中途如果超时就提前返回当前最佳走法,这样不管棋盘多复杂,玩家体验始终流畅。

这套源码我给自己的学生做课程设计时用过几轮,最深的体会是:α-β 剪枝的坑不在算法本身,而在评估函数和走法排序这些“看不见的地方”。从那以后我每次调 AI 棋力,都强制自己先打印节点数和剪枝率,再去看棋谱,这个习惯救了我好多次。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进