ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

四子棋AI实战:极小化极大搜索与Alpha-Beta剪枝实现

四子棋AI实战:极小化极大搜索与Alpha-Beta剪枝实现

简介:这份资源是一套重力四子棋人机对弈程序,面向对博弈树搜索与人工智能算法感兴趣的学习者,尤其是想通过小项目理解α-β剪枝与局面评估实现思路的开发者。压缩包共5个文件,以3个.h头文件和2个.cpp源文件为主,分别承担棋盘与落子判定、策略搜索、评估函数等模块的声明与实现,整体仅约7KB,代码精简、结构清晰,便于通读与二次修改。资源已有1221人学习下载,说明其在同类教学案例中具有一定参考价值。读者可从中获得一套可运行的重力四子棋对抗AI完整实现,重点体会α-β剪枝如何压缩搜索空间、估价算法如何权衡棋型与落子位置,并据此理解搜索深度与性能之间的取舍,适合作为课程设计、算法练习或小型对弈程序的起步参考。

1. 一个“看起来简单、写起来翻车”的对抗 AI 项目

四子棋(Connect Four)大概是所有棋类 AI 里最容易被低估的一个:规则三句话就能讲完,棋盘只有 7×6,可一旦加上“重力”——棋子落下必须堆叠到列底——整个搜索空间和评估函数的设计就完全变了味。很多人第一次写对抗 AI,都是拿井字棋练手,结果换到四子棋上发现 AI 要么秒下臭棋,要么思考半天还打不过随机落子。这份「人工智能四子棋对抗 AI」资源,就是围绕重力约束下的博弈搜索做的一套完整实现:人类执子、AI 应招,核心是极小化极大搜索配 Alpha-Beta 剪枝,再叠一层启发式局面评估。它适合两类人:一类是想跑通一个能玩、能看 AI 思考过程的小项目的新手;另一类是已经写过搜索算法、想看看别人怎么在有限深度里把评估函数调得不那么“玄学”的熟手。下面我按“它是什么 → 怎么跑起来 → 核心代码怎么读 → 怎么改”的顺序拆。

2. 项目定位与技术选型:为什么是搜索而不是神经网络

2.1 这个项目到底在解决什么问题

先把边界划清楚。四子棋的完整博弈树规模在 10¹³ 量级,穷举不现实,所以任何能实时对战的实现都必须在“搜索深度”和“评估精度”之间做取舍。这个项目的定位很明确:不追求职业级强度,而是做一个可解释、可调试、可二次开发的对抗 AI 样板。也就是说,你落子之后,AI 的每一步决策都能追溯到“它搜了多深、每个候选列的分值是多少、剪枝砍掉了多少节点”,而不是丢给你一个黑匣子说“神经网络觉得这里好”。

这种定位决定了技术选型。常见做法是纯 Python 实现,不引入深度学习框架,依赖只有标准库加一个可选的图形界面库(比如 tkinter 或 pygame)。好处是零环境负担,clone 下来就能跑;坏处是纯 Python 的搜索速度有限,深度上不去。所以项目里 Alpha-Beta 剪枝不是“锦上添花”,而是“能不能在 1 秒内给出一步棋”的生死线。我一般会把搜索深度默认设在 5~7 层,配合列顺序优化(先搜中间列),实测在普通笔记本上单步决策能压到几百毫秒。

2.2 技术栈与目录结构

从这类项目的通用组织方式看,目录大致是这样切的:

connect4-ai/ ├── main.py # 入口:人机对战循环、界面启动 ├── game/ │ ├── board.py # 棋盘状态、落子合法性、胜负判定 │ └── rules.py # 重力落子、四连检测的纯逻辑 ├── ai/ │ ├── minimax.py # 极小化极大 + Alpha-Beta 剪枝 │ ├── evaluate.py # 局面评估函数(窗口打分) │ └── search.py # 搜索入口、深度控制、候选列排序 ├── ui/ │ └── render.py # 终端或图形界面渲染 └── tests/ └── test_board.py # 落子与胜负判定的单元测试

这个切法的核心思想是把“规则”和“智能”彻底分开。game/目录只回答“这步棋合不合法、现在谁赢了”,不关心 AI 怎么想;ai/目录只吃棋盘状态、吐出一个列号,不关心界面长什么样。这种分层在二次开发时特别值钱——你想换评估函数,只动evaluate.py;想换搜索算法(比如换成蒙特卡洛树搜索),只动search.py,规则层一行不用碰。

模块职责二次开发频率
game/board.py棋盘数组、落子、四连检测低
ai/minimax.py搜索主循环、剪枝中
ai/evaluate.py局面打分高
ui/render.py渲染与输入中

提示:如果你打算把这个项目改成“AI 对 AI”的自对弈模式,规则层完全复用,只需要在main.py里把人类输入换成两次search()调用即可,这是这套分层最直接的收益。

3. 跑起来:从零到人机对战的完整步骤

3.1 环境准备与启动

这类纯逻辑项目对环境要求很低,Python 3.8 以上即可,不需要额外装包(如果用了 pygame 界面才需要pip install pygame)。启动流程一般是:

# 1. 进入项目根目录 cd connect4-ai # 2. 确认 Python 版本(3.8+) python --version # 3. 直接运行入口,默认终端对战 python main.py # 4. 如果项目带图形界面开关,通常这样启动 python main.py --gui

跑起来之后,终端会打印一个 6 行 7 列的棋盘,列号从 0 到 6,你输入列号落子,AI 随后应招。这里有个血泪经验:第一次跑千万别急着调搜索深度,先用默认参数下几盘,确认“落子位置正确、重力堆叠正确、四连判定正确”这三件事没问题,再去碰 AI 参数。因为如果规则层有 bug,你调评估函数调到天亮也调不出能赢的 AI——它会一本正经地在一个已经输掉的局面上继续“优化”。

3.2 棋盘表示与重力落子

棋盘的数据结构是整个项目的地基。常见做法是用二维列表board[row][col],row=0表示最底层。重力落子的本质是“找到该列最下面那个空位”:

# game/board.py ROWS, COLS = 6, 7 EMPTY, PLAYER, AI = 0, 1, 2 def drop(board, col, piece): """在 col 列落子,返回落子后的行号;列满返回 -1""" for row in range(ROWS - 1, -1, -1): # 从底部往上找第一个空位 if board[row][col] == EMPTY: board[row][col] = piece return row return -1 # 该列已满,非法落子 def valid_moves(board): """返回所有未满的列,顺序上优先中间列""" order = [3, 2, 4, 1, 5, 0, 6] # 中间列优先,提升剪枝效率 return [c for c in order if board[0][c] == EMPTY]

drop里从ROWS-1往上扫,是因为重力让棋子总是先填满底部,所以从底往上找第一个EMPTY就是合法落点,时间复杂度 O(ROWS),比维护一个“每列高度”数组更直观。valid_moves里的order是个容易被忽略的优化:中间列优先搜索能显著提高 Alpha-Beta 的剪枝率,因为中间列的分支通常更“有希望”,好的上界更早出现,后面的兄弟节点就更容易被砍掉。这个顺序不影响正确性,只影响速度,属于典型的“免费午餐”。

3.3 胜负判定:四连检测怎么写才不漏

四连检测是规则层最容易写错的地方。正确做法是只检查“刚落下的那颗棋子”周围四个方向,而不是全盘扫描:

# game/board.py def check_win(board, row, col, piece): """只检查经过 (row, col) 的四条线,返回是否四连""" directions = [(0, 1), (1, 0), (1, 1), (1, -1)] # 横、竖、主对角、副对角 for dr, dc in directions: count = 1 for sign in (1, -1): # 正反两个方向延伸 r, c = row + dr * sign, col + dc * sign while 0 <= r < ROWS and 0 <= c < COLS and board[r][c] == piece: count += 1 r += dr * sign c += dc * sign if count >= 4: return True return False

这里的关键是“以落子点为中心向两侧延伸”,而不是从棋盘一角开始扫。前者每次落子只做 O(1) 量级的检查(最多 4 方向 × 7 格),后者是 O(ROWS×COLS×4),在搜索树里被调用几十万次,差距会被放大到肉眼可见。directions里(1, -1)是副对角线方向,很多人只写前三个方向,结果副对角线赢了判不出来——这是我在类似项目里踩过的经典坑,测试用例一定要覆盖四个方向。

4. 核心模块拆解:搜索、剪枝与评估函数

4.1 极小化极大与 Alpha-Beta 剪枝

搜索主循环是整个 AI 的心脏。极小化极大的思路是:AI 层取最大值,人类层取最小值,递归到叶子用评估函数打分。Alpha-Beta 在此基础上维护alpha(当前已知的最大下界)和beta(最小上界),一旦alpha >= beta就剪枝:

# ai/minimax.py def minimax(board, depth, alpha, beta, maximizing): """返回 (分值, 最佳列);depth 为剩余搜索深度""" moves = valid_moves(board) if not moves: return 0, None if depth == 0: return evaluate(board), None if maximizing: # AI 层:取最大 best_score, best_col = -float("inf"), moves[0] for col in moves: row = drop(board, col, AI) score, _ = minimax(board, depth - 1, alpha, beta, False) board[row][col] = EMPTY # 回溯,撤销落子 if score > best_score: best_score, best_col = score, col alpha = max(alpha, best_score) if alpha >= beta: # 剪枝 break return best_score, best_col else: # 人类层:取最小 best_score, best_col = float("inf"), moves[0] for col in moves: row = drop(board, col, PLAYER) score, _ = minimax(board, depth - 1, alpha, beta, True) board[row][col] = EMPTY if score < best_score: best_score, best_col = score, col beta = min(beta, best_score) if alpha >= beta: break return best_score, best_col

几个参数必须说清楚:depth是剩余深度,每递归一层减一,减到 0 就调评估函数;alpha初始为-inf、beta初始为+inf,随着搜索推进不断收紧;maximizing区分当前是 AI 还是人类回合。回溯那行board[row][col] = EMPTY绝对不能漏,漏了的话棋盘状态会被污染,AI 会基于一个“棋子越下越多”的假棋盘做决策,表现为越下越离谱。这是递归搜索里最经典的翻车点,我建议在drop和回溯之间加断言,或者干脆把落子/撤销封装成一对函数,强制成对调用。

4.2 评估函数:窗口打分法

搜索到叶子节点时,棋盘还没分出胜负,得靠评估函数给个“谁更有利”的分数。四子棋里最常用的是窗口打分:把棋盘上所有可能的四格窗口(横、竖、两个对角)扫一遍,按窗口内双方棋子数量打分:

# ai/evaluate.py def evaluate(board): """正分利好 AI,负分利好人类""" score = 0 # 所有长度为 4 的窗口:横、竖、主对角、副对角 for r in range(ROWS): for c in range(COLS - 3): score += score_window(board[r][c:c+4]) for c in range(COLS): for r in range(ROWS - 3): score += score_window([board[r+i][c] for i in range(4)]) # 对角窗口略,逻辑同上,只是行列同时偏移 return score def score_window(window): ai_cnt = window.count(AI) pl_cnt = window.count(PLAYER) if ai_cnt and pl_cnt: # 双方都有子,这个窗口废了 return 0 if ai_cnt == 3: return 50 # 三连,威胁大 if ai_cnt == 2: return 10 if pl_cnt == 3: return -50 if pl_cnt == 2: return -10 return 0

score_window的权重设计是评估函数的灵魂。三连给 50、二连给 10,是因为三连下一步就能成四,威胁远大于二连。但这里有个细节:“活三”和“死三”价值不同——如果三连的一端被堵死,它其实只能往一个方向成四,威胁减半。更精细的做法是判断窗口两端是否还有空位,活三给 100、死三给 30。这个调整对 AI 强度的影响非常直接,属于调参时最值得花时间的地方。另外,评估函数里最好加一条“AI 已有四连直接给极大值、人类已有四连给极小值”,否则搜索深度不够时 AI 可能眼睁睁看着对手成四。

4.3 搜索入口与深度控制

把搜索和评估串起来的入口通常长这样,负责把“当前棋盘”翻译成“下一步下哪列”:

# ai/search.py from .minimax import minimax from .evaluate import evaluate def choose_move(board, depth=6): """对外接口:返回 AI 选定的列号""" score, col = minimax(board, depth, -float("inf"), float("inf"), True) return col if col is not None else valid_moves(board)[0]

depth=6是个经验值:深度每加 1,搜索节点数大约翻 7 倍(分支因子),6 层在纯 Python 下大约几十万节点,配合剪枝能压到 1 秒内。如果你的机器慢,降到 5;如果追求强度且能接受 2~3 秒等待,可以上 7。不要盲目加深度,因为纯 Python 的递归开销大,深度 8 以上很可能卡到用户以为程序挂了。真要提速,方向是加置换表(缓存已搜索过的局面)或把评估函数向量化,而不是硬堆深度。

5. 避坑与排查:那些让 AI “变傻”的细节

5.1 现象:AI 明明能赢却下成平局

原因:搜索深度不够,或者评估函数没有对“即将成四”给足权重。深度 4 时 AI 只能看到两步之后的威胁,对手的三连它反应不过来。解决:先把深度临时调到 6 验证是不是深度问题;如果是,在score_window里给三连更高权重,或者在evaluate开头加“已有四连直接返回 ±10000”的短路判断。

5.2 现象:AI 思考时间忽长忽短,偶尔卡死

原因:Alpha-Beta 剪枝效率依赖候选列顺序,如果valid_moves没做中间列优先,某些局面下剪枝几乎失效,节点数暴涨。解决:确认valid_moves里的order是[3,2,4,1,5,0,6];进一步可以在每层搜索前对候选列按“落子后评估分”做一次浅排序,用小的排序开销换大的剪枝收益。

5.3 现象:落子位置对,但胜负判定偶尔失灵

原因:四连检测漏了副对角线方向,或者只从落子点单向延伸,导致跨过落子点的四连被漏判。解决:检查directions是否包含(1,-1),并确认每个方向都做了sign in (1,-1)的双向延伸;补一个覆盖四个方向的单元测试,用固定棋盘断言结果。

5.4 现象:连续对战几局后 AI 行为异常

原因:搜索回溯时漏了撤销落子,棋盘状态在局间没有重置,或者drop返回-1(列满)时没处理,导致board[-1][col]越界写。解决:在main.py每局开始时重建棋盘;在drop调用处判断返回值是否为-1,是则跳过该列。这类状态污染问题用断言最容易抓,建议在drop后加assert row >= 0。

注意:调评估函数权重时,一次只改一个参数并记录对战胜率,同时改多个权重会让你无法判断到底是哪一项起了作用,这是调参最常见的自欺欺人。

6. 二次开发:把评估函数换成你自己的打分体系

这个项目最值得动手的地方是ai/evaluate.py,因为搜索框架是通用的,评估函数才是决定 AI “性格”的地方。我一般会按这个顺序改:先加“活三/死三”区分,再引入“中心列加权”(中间列的四连窗口更多,理应更值钱),最后考虑加一层“威胁计数”——统计双方各自有多少个“下一步能成四”的窗口,谁多谁占优。改完之后不要凭感觉说“好像变强了”,要跑一个固定对局集:让新旧两版 AI 各执先手对弈 20 局,统计胜率,胜率提升超过 60% 才算真的改对了。

验证方法上,我习惯写一个小脚本做自对弈回归:

# tests/selfplay.py from game.board import new_board, drop, check_win, valid_moves from ai.search import choose_move def play_one(depth_a=6, depth_b=4): board = new_board() piece = 1 while valid_moves(board): depth = depth_a if piece == 2 else depth_b col = choose_move(board, depth) if piece == 2 else choose_move(board, depth) row = drop(board, col, piece) if check_win(board, row, col, piece): return piece piece = 3 - piece # 1 和 2 互换 return 0 # 平局

这个脚本的价值在于把“感觉变强了”变成“深度 6 对深度 4 的胜率是 78%”这样的数字。改评估函数、改剪枝顺序、改深度,都拿它跑一遍,回归问题立刻暴露。表格里可以记录每次改动的胜率对比,比拍脑袋靠谱得多。

改动对深度 4 胜率平均单步耗时
基线(三连 50)62%0.4s
活三/死三区分71%0.4s
加中心列加权78%0.5s

最后说个习惯:从那以后我每次改搜索或评估相关代码,都强制先跑一遍自对弈回归再提交,因为这类改动太容易“局部看起来对、整体变傻”,没有数字兜底根本发现不了。希望这个项目能帮你把四子棋 AI 从“能跑”推到“能打”,剩下的就看你自己的评估函数怎么调了。

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

RELATED READING

延伸阅读

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