简介:这份资源面向学习博弈搜索、人工智能算法与课程设计的Python开发者,提供一套基于pygame的五子棋人机对战实现,重点演示极大极小值搜索与α-β剪枝在落子决策中的应用。压缩包共3个文件,包含2个Python源码和1个txt说明文本,整体约15KB,体量轻便,便于直接阅读、运行和二次修改。代码覆盖候选落点生成、棋局评估函数、先后手切换、胜负判断、棋盘显示以及Replay、Quit等按钮交互,其中评估函数设计直接影响电脑走步是否“智能”,而α-β剪枝用于减少无效搜索、提升决策效率。已有4707人学习下载,适合对照源码梳理搜索流程、调整评估参数观察AI棋力变化,也可从中拆解候选点筛选、评估函数加权与剪枝边界处理等细节,作为人工智能或算法课程作业的参考方案。
1. 从一盘会“思考”的五子棋说起:α-β 剪枝源码能跑出什么
把 python 五子棋 人机对战 pygame α-β剪枝 这几个词摆在一起,很多人第一反应是“网上源码一抓一大把”,真正跑起来才发现:界面能点,AI 却只会堵眼前一步;搜索加到两层,落子要等五六秒;评估函数里活三和冲四分不清,棋力全看运气。这套源码要解决的不是画一个棋盘,而是把 pygame 交互、棋型评估、α-β 剪枝搜索串成一条能调参、能复现、能继续改的链路。
它适合刚学完 minimax、想找一个能落地的博弈搜索项目的人,也适合做课程设计、想拿五子棋练 pygame 事件循环和启发式评估的开发者。你拿到的不只是一个能下棋的窗口,而是一套可以换评估表、改搜索深度、加限时策略的骨架。
如果你只想要一个成品可执行文件,它偏重;如果你想弄清楚“为什么这步棋 AI 会这么下”,这套东西的价值在代码结构和参数上,不在界面皮肤。
2. 把棋盘先跑起来:pygame 窗口、落子与胜负判定的最小闭环
2.1 棋盘数据结构与坐标映射:15×15 用二维列表还是一维数组
棋盘表示我一般先用二维列表,而不是一上来追求一维数组。15×15 的搜索里,board[y][x]读起来和坐标一致,调试时打印也直观;一维数组在热点路径上确实快一点,但坐标换算写错一次,后面评估和胜负判定会一起翻车。落子栈单独存(x, y, player),悔棋和搜索回溯都用得上。
# board.py BOARD_SIZE = 15 EMPTY, BLACK, WHITE = 0, 1, 2 DIRECTIONS = ((1, 0), (0, 1), (1, 1), (1, -1)) def create_board(): return [[EMPTY for _ in range(BOARD_SIZE)] for _ in range(BOARD_SIZE)] def in_bounds(x, y): return 0 <= x < BOARD_SIZE and 0 <= y < BOARD_SIZE def is_legal(board, x, y): return in_bounds(x, y) and board[y][x] == EMPTY def place(board, x, y, player): if not is_legal(board, x, y): return False board[y][x] = player return True def undo(board, x, y): board[y][x] = EMPTY这段代码的逻辑是:所有模块共用同一套常量,x是列、y是行,place先判合法再落子,搜索里必须成对调用undo。参数上BOARD_SIZE固定 15,EMPTY/BLACK/WHITE用整数而不是字符串,是为了在搜索里比较和赋值都便宜。DIRECTIONS只保留四个正方向,胜负判定时再向正负两边展开,避免重复扫描。
这里有个小选择:一维数组board[y * 15 + x]在热点路径上更快,但可读性差。我的做法是先用二维列表把逻辑跑通,等搜索时间真的压不住,再把棋盘表示换成一维,并且只改get/put两个函数,不让评估和规则代码感知底层变化。
2.2 pygame 窗口与坐标映射:鼠标点在哪一格
pygame 里最容易翻车的地方不是画子,而是坐标。棋盘逻辑坐标是 0 到 14 的格点,鼠标给的是像素;round和int的差别就是“点交叉点”和“点偏一格”的差别。窗口尺寸按MARGIN * 2 + CELL * (BOARD_SIZE - 1)算,因为 15 个格点之间只有 14 段间距。
# view.py import pygame from board import BOARD_SIZE, EMPTY, BLACK CELL, MARGIN, RADIUS = 40, 30, 16 WIDTH = MARGIN * 2 + CELL * (BOARD_SIZE - 1) HEIGHT = WIDTH def grid_to_pixel(x, y): return MARGIN + x * CELL, MARGIN + y * CELL def pixel_to_grid(pos): px, py = pos x = round((px - MARGIN) / CELL) y = round((py - MARGIN) / CELL) return x, y def draw_board(screen, board): screen.fill((222, 184, 135)) for i in range(BOARD_SIZE): start = grid_to_pixel(0, i) end = grid_to_pixel(BOARD_SIZE - 1, i) pygame.draw.line(screen, (40, 40, 40), start, end, 2) start = grid_to_pixel(i, 0) end = grid_to_pixel(i, BOARD_SIZE - 1) pygame.draw.line(screen, (40, 40, 40), start, end, 2) for y in range(BOARD_SIZE): for x in range(BOARD_SIZE): if board[y][x] == EMPTY: continue color = (0, 0, 0) if board[y][x] == BLACK else (255, 255, 255) pygame.draw.circle(screen, color, grid_to_pixel(x, y), RADIUS)逻辑说明:pixel_to_grid用round把鼠标位置吸到最近的交叉点,grid_to_pixel是它的逆运算;画线时先横后竖,最后画棋子,棋子才能压住线。参数上CELL=40是格距,MARGIN=30是棋盘边距,RADIUS=16要小于CELL/2,否则黑子会糊在一起。窗口尺寸不要写死 600×600,按公式算,改CELL时窗口会跟着变。
提示:棋盘逻辑坐标统一用
(x, y),x是列、y是行;pygame 的鼠标坐标是像素,两者不要混。每次改完坐标函数,先在控制台打印pixel_to_grid(event.pos)看是否落在 0 到 14 之间。
2.3 事件循环与胜负判定:落子后立刻判,不要等下一帧
胜负判定要独立成规则函数,不要塞在绘制里。五子棋只需要从最后落子点向四个方向展开,数连续同色棋子,数量到 5 就结束;平局用棋盘是否填满判断。事件循环里只处理人类回合,AI 回合用状态标志控制,避免每帧重复搜索。
# rules.py from board import BOARD_SIZE, EMPTY, DIRECTIONS, in_bounds def check_win(board, x, y, player): for dx, dy in DIRECTIONS: count = 1 for sign in (1, -1): nx, ny = x + sign * dx, y + sign * dy while in_bounds(nx, ny) and board[ny][nx] == player: count += 1 nx += sign * dx ny += sign * dy if count >= 5: return True return False def is_full(board): return all(cell != EMPTY for row in board for cell in row)# main.py 的最小事件循环 import pygame from board import create_board, is_legal, place, BLACK, WHITE from view import WIDTH, HEIGHT, pixel_to_grid, draw_board from rules import check_win, is_full def main(): pygame.init() screen = pygame.display.set_mode((WIDTH, HEIGHT)) pygame.display.set_caption("五子棋 人机对战") clock = pygame.time.Clock() board = create_board() turn = BLACK game_over = False while True: for event in pygame.event.get(): if event.type == pygame.QUIT: pygame.quit() return if event.type == pygame.MOUSEBUTTONDOWN and turn == BLACK and not game_over: x, y = pixel_to_grid(event.pos) if is_legal(board, x, y): place(board, x, y, BLACK) if check_win(board, x, y, BLACK) or is_full(board): game_over = True else: turn = WHITE # AI 回合:真实代码里用一个 ai_thinking 标志,避免每帧重复搜索 # if turn == WHITE and not game_over: # move = choose_move(board, WHITE) # ... draw_board(screen, board) pygame.display.flip() clock.tick(60) if __name__ == "__main__": main()逻辑说明:check_win以最后落子点为中心,沿四个方向向正负两边数,避免全盘扫描;count从 1 开始,因为落子点本身算一个。事件里只允许turn == BLACK时接受鼠标,AI 回合用注释里的状态机接入,搜索一次落一子,不要放在while里无条件调用。参数上clock.tick(60)控制界面帧率,搜索耗时由第 4 章的限时逻辑控制。
这一段跑通后,你应该能看到棋盘、能落黑子、能判五连。先别急着接 AI,把pixel_to_grid的边界情况测一遍:点棋盘外、点边线、点四个角,确认is_legal不会越界。后面搜索再快,界面坐标错了也是白搭。
3. 评估函数决定棋力上限:活三、冲四与候选点怎么折成分数
3.1 棋型识别:为什么不能只数连续棋子
只数连续棋子会把活三和眠三算成一样,AI 就会拿活三去换一个死四。我一般把落点周围四个方向各取 9 格,转成字符串:1表示己方,0表示空,2表示对手或墙。然后用模式表匹配,取每个方向的最大分再累加。这样“01110”是活三,“001100”是眠三,“011112”是冲四,分值拉开后搜索才知道该攻还是该守。
| 模式 | 含义 | 分值 |
|---|---|---|
11111 | 连五 | 10000000 |
011110 | 活四 | 1000000 |
011112/211110 | 冲四 | 300000 |
01110 | 活三 | 100000 |
010110 | 跳活三 | 100000 |
001100 | 眠三 | 10000 |
001010 | 活二 | 5000 |
000100 | 单子 | 500 |
# evaluate.py PATTERN_SCORES = [ ("11111", 10_000_000), ("011110", 1_000_000), ("011112", 300_000), ("211110", 300_000), ("01110", 100_000), ("010110", 100_000), ("001100", 10_000), ("001010", 5_000), ("000100", 500), ]这张表的顺序要从高到低,匹配时先命中大分就不再往下比。分值不是绝对的,作用是让搜索在“活四”和“活三”之间做选择;如果你把冲四调得比活四还高,AI 就会去追冲四,棋力反而下降。参数上 9 格窗口足够覆盖活四两端,再长会引入更多噪声,再短会漏掉跳三。
3.2 评分函数与整盘评估:入口只认 root_player
评分函数分两层:score_point算一个空点对某一方的价值,evaluate_board算整盘对root_player的价值。搜索递归里会换当前玩家,但评估视角不能跟着换,否则对手的分数会被加成正分,AI 就会下出“帮对手做棋”的怪棋。
# evaluate.py from board import BOARD_SIZE, EMPTY, BLACK, WHITE, DIRECTIONS, in_bounds def line_string(board, x, y, dx, dy, player): chars = [] for step in range(-4, 5): nx, ny = x + dx * step, y + dy * step if not in_bounds(nx, ny): chars.append("2") else: v = board[ny][nx] if v == player: chars.append("1") elif v == EMPTY: chars.append("0") else: chars.append("2") return "".join(chars) def score_point(board, x, y, player): total = 0 for dx, dy in DIRECTIONS: s = line_string(board, x, y, dx, dy, player) best = 0 for pat, val in PATTERN_SCORES: if pat in s and val > best: best = val total += best return total def evaluate_board(board, root_player): opp = BLACK if root_player == WHITE else WHITE my_score = 0 opp_score = 0 for y in range(BOARD_SIZE): for x in range(BOARD_SIZE): if board[y][x] == root_player: my_score += score_point(board, x, y, root_player) elif board[y][x] == opp: opp_score += score_point(board, x, y, opp) return my_score - int(opp_score * 1.05)逻辑说明:line_string把越界也记成2,这样边界不会被误判成空位;score_point对四个方向各取最大模式分,再累加,避免同一方向多个模式重复加分;evaluate_board用my_score - opp_score * 1.05,给防守略高权重。参数上1.05是保守系数,调到1.2会更偏防守,调到0.8会更偏进攻,但不要低于0.7,否则 AI 容易漏防活三。
这套评估是“点评估”,搜索叶子调用整盘评估会慢,实战里可以只评估最后落子周围的变化点,或者缓存每个方向字符串。不要一上来做增量评估,先把正确性跑通,再谈性能。
3.3 候选点生成:只搜“有邻居”的空位
五子棋搜索爆炸的主因不是深度,而是宽度。全盘 225 个空点都进搜索,4 层就是 225 的四次方;实际上有价值的点只在已有棋子周围。常见做法是取半径 2 的邻居,再用攻防分排序,只保留前 20 个。
# movegen.py from board import BOARD_SIZE, EMPTY, BLACK, WHITE, in_bounds from evaluate import score_point def gen_moves(board, player, radius=2, limit=20): candidates = set() for y in range(BOARD_SIZE): for x in range(BOARD_SIZE): if board[y][x] == EMPTY: continue for dy in range(-radius, radius + 1): for dx in range(-radius, radius + 1): nx, ny = x + dx, y + dy if in_bounds(nx, ny) and board[ny][nx] == EMPTY: candidates.add((nx, ny)) if not candidates: return [(BOARD_SIZE // 2, BOARD_SIZE // 2)] opp = BLACK if player == WHITE else WHITE scored = [] for x, y in candidates: attack = score_point(board, x, y, player) defend = score_point(board, x, y, opp) scored.append((attack + defend * 0.9, x, y)) scored.sort(reverse=True) return [(x, y) for _, x, y in scored[:limit]]逻辑说明:先扫全盘找已有棋子,再把周围半径 2 的空点放进集合,自动去重;空盘返回天元;排序分数把进攻和防守加在一起,0.9表示防守略低于进攻。参数上radius=2能覆盖跳活三的落点,limit=20是宽度上限,深度 4 时够用,深度 6 建议降到 12 到 15。limit不是越大越好,排序不准时多出来的候选只是拖慢搜索。
4. α-β 剪枝搜索落地:深度、排序与限时迭代加深
4.1 极小极大与 α-β 的剪枝条件:窗口怎么传
α-β 剪枝不是独立算法,它是在极小极大上加两个边界:α 是当前 MAX 层已经找到的最好值,β 是 MIN 层已经找到的最好值。MAX 层更新 α,MIN 层更新 β,一旦alpha >= beta就剪掉后面的兄弟节点。根节点传-INF, INF,递归里只换玩家,不换评估视角。
# search.py import time from board import BLACK, WHITE, EMPTY from evaluate import evaluate_board from movegen import gen_moves from rules import check_win INF = 10**12 WIN_SCORE = 10**9 def alpha_beta(board, depth, alpha, beta, player, root_player, deadline): if time.time() > deadline: raise TimeoutError if depth == 0: return evaluate_board(board, root_player), None moves = gen_moves(board, player) if not moves: return evaluate_board(board, root_player), None best_move = moves[0] if player == root_player: best_value = -INF for x, y in moves: board[y][x] = player if check_win(board, x, y, player): value = WIN_SCORE - (10 - depth) else: value, _ = alpha_beta( board, depth - 1, alpha, beta, 3 - player, root_player, deadline ) board[y][x] = EMPTY if value > best_value: best_value, best_move = value, (x, y) alpha = max(alpha, best_value) if alpha >= beta: break return best_value, best_move else: best_value = INF for x, y in moves: board[y][x] = player if check_win(board, x, y, player): value = -WIN_SCORE + (10 - depth) else: value, _ = alpha_beta( board, depth - 1, alpha, beta, 3 - player, root_player, deadline ) board[y][x] = EMPTY if value < best_value: best_value, best_move = value, (x, y) beta = min(beta, best_value) if alpha >= beta: break return best_value, best_move逻辑说明:root_player固定评估视角,player只决定当前是 MAX 还是 MIN;3 - player依赖BLACK=1, WHITE=2;落子后立刻check_win,成五给WIN_SCORE - (10 - depth),越早赢分越高。参数上INF要大于任何评估分,WIN_SCORE要远大于棋型分;deadline用时间戳控制,超时抛异常,外层保留上一层结果。
4.2 搜索深度与候选宽度:4 层、6 层、8 层的现实取舍
| 参数 | 建议起点 | 调大后的代价 |
|---|---|---|
max_depth | 4 | 每加 2 层,节点数量级上升 |
limit | 20 | 候选越多越慢,排序不准时更明显 |
time_limit | 1.5s | 超时截断,保证界面不卡 |
radius | 2 | 覆盖跳点,过大导致候选爆炸 |
| 防守系数 | 0.9~1.1 | 太高偏保守,太低漏防 |
4 层是能跑出“会堵活三”的起点,6 层开始能看两步以上的交换,8 层对普通笔记本就偏重。我的血泪经验是:不要先加深度,先把候选宽度和走法排序调好;同样 4 层,候选从 40 降到 20,耗时可能差三倍,棋力未必下降。
迭代加深适合配限时:从 2 层开始搜,逐步加 2 层,超时就用上一层的完整结果。这样界面不会因为一次深搜卡死,参数也好调。
# search.py 的限时入口 def search_best_move(board, player, max_depth=4, time_limit=1.5): deadline = time.time() + time_limit best_move = None for depth in range(2, max_depth + 1, 2): try: _, move = alpha_beta( board, depth, -INF, INF, player, player, deadline ) if move is not None: best_move = move except TimeoutError: break return best_move逻辑说明:只搜偶数层,避免叶子落在对手层导致评分偏向;超时直接break,保留上一层的最佳走法;time_limit从 1.5 秒起,界面交互还能接受。参数上max_depth=4是保守值,机器快可以设 6;time_limit不要卡得太死,留 0.2 秒余量给绘制和事件循环。
4.3 走法排序与杀棋优先:先查一步成五,再进搜索
α-β 的效率高度依赖走法顺序。除了gen_moves里的攻防排序,入口还要先做两个廉价检查:自己有没有一步成五,对手有没有一步成五。这两个检查比深搜便宜得多,却能避免“能赢不赢、该堵不堵”的低级错误。
# tactics.py from board import BOARD_SIZE, EMPTY from rules import check_win from search import search_best_move def find_win_move(board, player): for y in range(BOARD_SIZE): for x in range(BOARD_SIZE): if board[y][x] != EMPTY: continue board[y][x] = player win = check_win(board, x, y, player) board[y][x] = EMPTY if win: return x, y return None def choose_move(board, player, max_depth=4, time_limit=1.5): win = find_win_move(board, player) if win: return win block = find_win_move(board, 3 - player) if block: return block return search_best_move(board <p> <a href="https://download.csdn.net/download/cumina/12183095" style="color:#ec7500;font-size:14px;"> 本文还有配套的精品资源,点击获取 </a> <img alt="menu-r.4af5f7ec.gif" src="https://csdnimg.cn/release/wenkucmsfe/public/img/menu-r.4af5f7ec.gif" style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;"> </p>