ARTICLE · 1111311
围棋 AI:极小化极大、Alpha-Beta 剪枝与经验学习的源码实现解析
一、引言
在一个 Python + tkinter 写的小游戏合集中,我实现了一个支持人机对战和电脑对战的围棋模块。它使用 19×19 标准棋盘,完整实现了气、提子、禁入点、打劫、数子等中国规则,并提供简单(深度 2)、普通(深度 4)、困难(深度 6)三档难度。
围棋的状态空间极其庞大——19×19 棋盘上每个交叉点有黑、白、空三种状态,理论局面数约为 10 的 170 次方,远超可观测宇宙的原子总数。因此,围棋 AI 不可能穷举所有变化,必须通过搜索剪枝、启发式评估和经验学习来逼近最优解。
本文将从源码层面,完整拆解这个围棋 AI 的三大核心模块:(1)极小化极大搜索与 Alpha-Beta 剪枝;(2)局面评估函数;(3)基于 SQLite 的 AI 经验学习系统——让电脑在每一局对弈后积累经验,越下越聪明。
二、核心搜索算法
2.1 极小化极大(Minimax):博弈树的基本思想
围棋是零和博弈——一方的收益等于另一方的损失。极小化极大算法的核心思路是:假设双方都采取最优策略,AI 方选择使自己收益最大化的走法,对手方选择使 AI 收益最小化的走法。
在代码中,_minimax 方法通过 maximizing 参数区分当前是哪一方:maximizing 为 True 时遍历所有候选点,取最大评估值;为 False 时取最小评估值。递归深度每减 1,就模拟一手棋,直到 depth 为 0 时调用评估函数返回局面分数。
def _minimax(self, board, depth, alpha, beta, maximizing, ko_point, cand_limit, best_prev=None): if depth == 0: score = self._evaluate(board)# 叶子节点:评估局面 return score, None color = self.ai_color if maximizing else self.human_color cands = self._candidates(board, color, cand_limit) if maximizing: for r, c in cands:# AI 方:最大化 ... if ev > max_eval: max_eval = ev alpha = max(alpha, ev) if beta <= alpha: break# Alpha-Beta 剪枝 return max_eval, best else: for r, c in cands:# 对手方:最小化 ... if ev < min_eval: min_eval = ev beta = min(beta, ev) if beta <= alpha: break# Alpha-Beta 剪枝 return min_eval, best
2.2 Alpha-Beta 剪枝:砍掉一半的搜索量
纯极小化极大搜索的节点数随深度指数增长——深度 6 时,即使每步只有 10 个候选点,也需要搜索 100 万个节点。Alpha-Beta 剪枝通过维护两个边界值来避免无效搜索:
alpha:当前最大化方已经能保证的最低收益;
beta:当前最小化方已经能保证的最高收益。
当某个分支的搜索结果表明 alpha >= beta 时,说明这个分支不可能影响最终决策,可以直接 break 跳过。在走法排序良好的情况下,Alpha-Beta 剪枝能将搜索节点数减少到原来的平方根级别——原本需要 100 万节点的搜索,可能只需要 1000 节点就能得到相同结果。
2.3 迭代加深:时间有限时的最优策略
固定深度搜索有一个问题:如果时间不够,搜索到一半被强制中断,可能连一个合法走法都返回不了。迭代加深的做法是从深度 1 开始,逐层加深搜索,每完成一层就更新当前最优解。一旦时间预算耗尽,立即返回上一层完整搜索得到的最优解。
def _ai_search(self, max_depth, cand_limit): best_move = None self._deadline = time.time() + self._time_limit for depth in range(1, max_depth + 1): self._search_timeout = False score, move = self._minimax(board, depth, ...) if self._search_timeout: break# 本层超时,结果不可靠,沿用上一层 if move: best_move = move return best_move
这种设计保证了 AI 永远能在规定时间内返回一个“至少经过深度 1 搜索”的走法,而时间充裕时则会搜索到更深的层数。
2.4 候选点限制:19×19 棋盘不能全搜
19×19 棋盘有 361 个交叉点,如果每一步都考虑所有空点,搜索量将完全不可控。围棋的一个重要启发式是:有效落子几乎都出现在已有棋子的附近。因此,_candidates 方法只收集已有棋子周围 2 格范围内的空点,然后按启发式评分排序,只取前 N 个(简单 20 个、普通 12 个、困难 8 个)。
def _candidates(self, board, color, limit): cands = set() for r in range(BOARD_N): for c in range(BOARD_N): if board[r][c] != EMPTY: for dr in range(-2, 3):# 周围2格 for dc in range(-2, 3): ... cands.add((nr, nc)) # 静态启发式排序 + 经验库加权,取前 limit 个 scored.sort(reverse=True) return [(r, c) for _, r, c in scored[:limit]]
2.5 走法排序:好的排序让剪枝效率翻倍
Alpha-Beta 剪枝的效率高度依赖走法顺序——如果最优走法排在前面,后续分支更容易被剪掉。代码中使用了两层排序策略:
(1)上一层搜索的最佳走法优先(best_prev 参数),因为迭代加深时浅层最优往往也是深层最优;
(2)启发式评分排序:_move_heuristic 模拟落子后评估提子数、气数、威胁对方程度和位置偏好。
此外,候选点排序时还加入了经验库加权(get_move_bonus),历史胜率高的走法会被优先搜索,这部分将在第三节详细展开。
2.6 in-place 落子与撤销:避免每秒复制几万次棋盘
这是一个关键的性能优化。最初的实现每次模拟落子都深拷贝整个 19×19 棋盘(new_board = [row[:] for row in board]),后盘候选点 200-300 个时,深度 4 约 2 万节点,累计几十亿次列表复制操作,导致 AI 在大劣局面下单步思考长达几十秒。
优化方案是使用 in-place 落子:_apply_move 直接修改棋盘数组,返回被提的棋子列表;递归返回后用 _undo_move 恢复棋盘。这样整个搜索过程只需要一份棋盘副本,节点开销从 O(361) 降到 O(1)。
def _apply_move(self, board, r, c, color, ko_point): """in-place落子,返回 (被提棋子列表, None) 或 None(非法)""" board[r][c] = color # 检查并提取对方无气棋子 # 禁入点检查 return captured, None def _undo_move(self, board, r, c, color, captured_stones): """恢复落子""" board[r][c] = EMPTY for gr, gc in captured_stones: board[gr][gc] = opponent
配合候选点生成时使用轻量静态打分(_static_heuristic,只看邻子和位置,O(候选×4)),后盘均衡局面单步思考从几十秒降到 0.77 秒,大劣局面从卡死降到 1.54 秒。
2.7 时间硬预算:防止 AI 卡死
即使有了上述优化,某些极端局面(如大劣局面下候选点多、提子频繁)仍可能导致搜索超时。为此,_minimax 每处理 256 个节点就检查一次时钟,如果超过时间预算就设置 _search_timeout 标志并立即返回。同时还有节点预算(node_limit)作为第二道防线。
if self._node_count & 255 == 0 and time.time() > self._deadline: self._search_timeout = True return self._evaluate(board), None# 提前截断
三档难度的时间预算分别为:简单 1 秒、普通 2.5 秒、困难 5 秒。电脑对战模式使用更短的时间预算(0.8/1.5/3.0 秒)以保证对弈流畅。
三、评估函数:AI 如何判断局面好坏
搜索的叶子节点需要一个评估函数来量化局面优劣。围棋的评估远比象棋复杂——象棋可以直接算子力价值,而围棋需要考虑地盘、外势、棋子死活等多种因素。本实现采用了一个简化但有效的评估模型。
3.1 单个棋子的价值
_stone_value 方法计算每个棋子的综合价值:
基础分 10 分(每颗棋子都有价值);
气:最多算 4 气,每气 2 分(气多的棋子更安全、更有活力);
位置:角部加 5 分、边部加 3 分(金角银边草肚皮);
危险棋子扣分:仅 1 气扣 8 分、2 气扣 3 分(濒临被提的棋子价值打折)。
3.2 地盘估算
除了棋子本身,还需要估算双方控制的地盘。简化做法是:遍历所有空点,如果一个空点的四邻只有一种颜色的棋子,就将该点判给那一方,每点 2 分。这是一个粗略的领地估算,不做复杂的区域填充,但在浅层搜索中足以引导 AI 走向围空。
3.3 综合评分
最终评估值 = AI 方总分 - 对手方总分。正数表示 AI 优势,负数表示对手优势。这个评估函数虽然简单,但配合 4-6 层搜索,已经能下出有模有样的围棋——会主动围空、会切断对方、会在被围攻时逃跑或弃子。
四、AI 经验学习:让电脑越下越聪明
4.1 为什么需要经验学习
纯搜索型 AI 有一个天花板:它每一局都从零开始思考,不会从过去的对局中学习。同一个局面,它可能反复犯同样的错误。而人类棋手会通过复盘积累经验——某个局面下某步棋胜率高,下次遇到就优先考虑。
我们为 AI 设计了一套基于 SQLite 的经验学习系统,包含两个核心机制:(1)对局经验库——记录“局面→落点→胜负”统计,选点时按历史胜率加权;(2)搜索缓存持久化——将搜索过的局面评分保存到磁盘,跨局复用,加速思考。
4.2 棋盘指纹:如何把 19×19 棋盘压缩成一个数字
要存储“某个局面下某步棋的胜率”,首先需要用一个紧凑的标识来代表棋盘局面。19×19 = 361 个交叉点,每点 3 种状态,如果直接存储需要 361 个字节。我们使用指纹函数 fingerprint_grid 将棋盘序列化为字节流后,取 SHA256 哈希的前 8 个字节,得到一个 64 位整数作为局面指纹。
def fingerprint_grid(board, n): buf = bytearray(n * n) i = 0 for r in range(n): for c in range(n): buf[i] = board[r][c] i += 1 return int.from_bytes(hashlib.sha256(bytes(buf)).digest()[:8], "big")
SHA256 的 64 位截断碰撞概率约为 2 的 64 次方分之一,在实际使用中可以忽略。使用哈希而非直接编码的另一个好处是:指纹值均匀分布在 64 位整数空间,作为 SQLite 的 INTEGER 主键和索引时查询效率极高。
这里有一个工程细节:SQLite 的 INTEGER 是有符号 64 位(最大值 2^63-1),而 64 位无符号哈希有 50% 的概率超过这个上限。因此存储前需要通过 _s64 函数将无符号 64 位转换为有符号 64 位(最高位为 1 时减去 2^64)。

4.3 对局经验库:experience 表
每局棋结束后,AI 会将本局中每一步的(局面指纹、落点、执棋方)记录下来,然后根据本局胜负更新统计表。experience 表的结构如下:
字段 | 类型 | 说明 |
game | TEXT | 游戏标识(go/gomoku/xiangqi),三游戏物理隔离 |
board_hash | INTEGER | 局面指纹(64 位有符号整数) |
move_key | TEXT | 落点坐标,如 10,3 |
win | INTEGER | 该局面下走这步棋最终获胜的次数 |
lose | INTEGER | 该局面下走这步棋最终失败的次数 |
total | INTEGER | 总出现次数 |
updated_at | TEXT | 最后更新时间 |
记录逻辑很简单:一局结束后,胜方的每一步 win+1,负方的每一步 lose+1,total 都 +1。使用 INSERT ... ON CONFLICT DO UPDATE 实现 upsert,避免重复插入。
需要注意的是,黑方和白方的经验是分开记录的——因为同一局面下,黑方的好棋和白方的好棋完全不同。_record_experience 方法按颜色分组后分别调用 record_game。
4.4 经验加权:历史胜率如何影响选点
有了经验数据后,AI 在生成候选点时会查询每个落点的历史胜率,并将胜率转化为启发式加分,叠加到候选点排序中。
def get_move_bonus(self, board_hash, move_key): """单落点经验加权分:胜率高加分,样本不足返回 0""" row = c.execute("SELECT win, total FROM experience ...").fetchone() if not row or row[1] < MIN_SAMPLES:# 至少3局样本才生效 return 0.0 return (row[0] / row[1]) * EXPERIENCE_BONUS# 胜率 x 300
这里有两个关键参数:
MIN_SAMPLES = 3:某个落点至少出现 3 次以上才启用经验加权。这是为了避免小样本误导——如果某步棋只下过 1 次且恰好赢了,100% 胜率并不代表它真的好。
EXPERIENCE_BONUS = 300:胜率 100% 时加 300 分。这个值与启发式评分(通常几十到几百)在同一量级,既能让经验影响选点,又不会完全压倒搜索评估。
经验加权发生在两个位置:一是 _candidates 候选点筛选时(决定哪些点进入搜索),二是 _minimax 内的走法排序时(决定搜索顺序,影响剪枝效率)。随着对局数增加,AI 会越来越倾向于选择历史上胜率高的走法。
4.5 搜索缓存持久化:TT 表跨局复用
除了对局经验,搜索过程中产生的大量局面评估结果也值得保存。在博弈算法中,这被称为置换表(Transposition Table,TT)——不同的走法顺序可能到达同一个局面,TT 表可以避免重复搜索。
传统的 TT 表只存在于内存中,程序退出就丢失了。我们将 TT 表持久化到 SQLite,实现跨局复用:上一局搜索过的局面,下一局遇到时可以直接命中缓存。
tt 表的结构如下:
字段 | 类型 | 说明 |
game | TEXT | 游戏标识 |
board_hash | INTEGER | 局面指纹 |
depth | INTEGER | 搜索深度(更深的缓存可用于浅请求) |
score | REAL | 局面评估分 |
tt_type | INTEGER | 缓存类型:精确/下界/上界 |
best_move | TEXT | 该局面下的最佳走法 |
version | INTEGER | schema 版本,评估函数大改后旧缓存自动失效 |
TT 表采用两级缓存架构:搜索过程中先写内存(_tt_mem dict,O(1) 查找),搜索完成后批量 flush 到 SQLite。查询时先查内存,未命中再查 SQLite 并回填内存。这样既保证了搜索时的性能,又实现了持久化。
缓存命中有一个重要条件:缓存深度 >= 请求深度。因为深度 6 的搜索结果比深度 2 更可靠,可以用深缓存回答浅请求;反之则不行。此外还有 version 字段——当评估函数或搜索逻辑发生重大变更时,递增 SCHEMA_VERSION,旧缓存自动失效。
为防止数据库无限膨胀,tt_flush 时会检查记录数,超过 20 万条时删除最旧的 20%。
4.6 SQLite 存储设计
三个游戏(围棋、五子棋、象棋)各使用一个独立的 .db 文件,物理完全隔离:
~/Documents/GameCenterData/ai_experience/ ├── go.db(围棋) ├── gomoku.db(五子棋) └── xiangqi.db(象棋)
每个文件内包含 experience 和 tt 两张表,表内还有 game 字段做逻辑隔离(即使以后合并文件也不会串)。数据库使用 WAL 模式(Write-Ahead Logging)提升并发读写性能,synchronous=NORMAL 在性能和安全之间取平衡。
选择 SQLite 而非 JSON 的原因:
(1)SQLite 支持索引,按局面指纹查询是 O(log n),JSON 需要全量遍历;
(2)SQLite 支持原子事务,不会因为程序崩溃导致数据损坏;
(3)SQLite 是 Python 标准库自带的,打包 EXE 零额外依赖;
(4)支持 upsert、批量写入等高级操作,经验更新更高效。
五、围棋规则的工程实现
5.1 气与提子
气是围棋的核心概念。_get_group 方法使用 BFS(广度优先搜索)找到一个棋子所在的整个连通块,同时收集所有相邻空点作为气。如果一块棋的气数为 0,就被对方提掉。
5.2 禁入点
如果落子后己方这块棋没有气,且没有提到对方的子,那么这个点是禁入点,不允许落子。_apply_move 在落子后检查己方气数,如果为 0 且未提子则回退并返回 None。
5.3 打劫
打劫规则禁止立即回提刚被提走的单子。_calc_ko 方法在提子后检查:如果恰好提了一个子,且己方落子后只有一气,那么被提子的位置就是劫点,下一手对方不能立即在这个点落子回提。
5.4 虚着与自动数子
当 AI 没有合适的落子点时,会视为虚着(跳过一手)。双方连续虚着(pass_count >= 2)时,棋局自动结束并调用 _count_score 数子。数子采用中国规则:
黑棋得分 = 黑子数 + 黑方控制的空点数;
白棋得分 = 白子数 + 白方控制的空点数;
黑贴 3.75 子(即 7.5 目),最终比较双方得分。
地盘归属通过区域填充判断:遍历所有空点区域,如果一个空白区域只与一种颜色的棋子相邻,则该区域全部归该方所有。
5.5 两套难度配置
代码中维护了两套难度配置:DIFFICULTY 用于人机对战(保持原棋力,候选点较宽),AI_VS_AI_DIFFICULTY 用于电脑对战(缩小候选点、加节点预算、缩短时间,保证对弈流畅)。_ai_turn 根据 self.mode 自动选择对应配置。
难度 | 搜索深度 | 候选点上限 | 时间预算(人机) | 时间预算(电脑对战) |
简单 | 2 | 20 / 12 | 1.0 秒 | 0.8 秒 |
普通 | 4 | 12 / 8 | 2.5 秒 | 1.5 秒 |
困难 | 6 | 8 / 6 | 5.0 秒 | 3.0 秒 |
六、总结与展望
这个围棋 AI 的核心是一套“搜索 + 评估 + 学习”的组合拳:
搜索层:极小化极大 + Alpha-Beta 剪枝 + 迭代加深 + 候选点限制 + 走法排序,在有限时间内尽可能深地搜索;
评估层:棋子价值(气、位置、安全性)+ 地盘估算,量化局面优劣;
学习层:对局经验库(胜率加权选点)+ 搜索缓存持久化(跨局复用),让 AI 从历史中学习。
实测效果:普通难度下,后盘均衡局面单步思考约 0.77 秒,大劣局面约 1.54 秒(优化前为几十秒甚至卡死)。随着经验库的积累,AI 在常见局面下会越来越倾向于选择人类棋手公认的好点。
当然,这个 AI 与职业水平还有巨大差距。未来可以改进的方向包括:
(1)引入蒙特卡洛树搜索(MCTS):AlphaGo 的核心算法,通过随机模拟评估局面,更适合围棋这种评估函数难设计的游戏;
(2)接入神经网络:用策略网络和价值网络替代手写评估函数,大幅提升棋力;
(3)更精细的评估函数:加入外势、模样、死活判断等高级特征;
(4)经验库共享:支持导入专业棋谱,让 AI 从人类大师的对局中学习。
但作为一个纯 Python、零外部 AI 依赖的小游戏 AI,它已经证明了:经典博弈算法 + 巧妙的工程优化 + 简单的经验学习,足以让电脑下出一盘有模有样的围棋。
—— 全文完 ——