ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

极大极小值算法与Alpha-Beta剪枝:棋类博弈搜索核心解析

极大极小值算法与Alpha-Beta剪枝:棋类博弈搜索核心解析

写一个会下棋的 AI 难吗?如果只靠暴力枚举,哪怕是一盘井字棋,顶多 9! 种走法还能忍,换到五子棋、国际象棋,分支因子一下子爆炸,普通程序根本算不完。这时候就得回到博弈树搜索的老套路:极大极小值算法是骨架,Alpha-Beta 剪枝是让骨架跑得动的核心优化。这两个概念是很多棋类 AI、策略游戏 AI、甚至决策系统的基础,网上资料不少,但大多只给一段伪代码,没讲清楚“为什么这样做”“参数怎么传”“剪枝到底剪掉了什么”。这篇文章我会从头到尾把思路、伪代码、解题流程拆开揉碎讲一遍,还会附上我自己实际写博弈程序时踩过的坑,希望对刚接触博弈树搜索的同学有帮助,也适合想复习算法细节的人。

1. 初始思路:先想清楚搜索要解决什么问题

1.1 博弈树建模是第一步

博弈类问题天然适合用树来建模。任何一个局面都是一个节点,从当前局面出发,当前玩家每一步合法操作都会产生一个子节点,这个子节点又轮到对手操作,继续往下展开,直到分出胜负或者达到人为设定的搜索深度。这棵树就是博弈树。

我最早接触极大极小值算法时,第一反应是“这不就是暴力搜索所有可能走法嘛”,确实如此,但它的关键不在于暴力,而在于如何从叶子节点往回倒推。叶子节点是终局,可以直接打分:我赢就是正分,我输就是负分,平局是零分。如果还没到终局,就需要一个评估函数,把一个局面换算成一个数值,这个数值越正,代表对我越有利。

这里有个容易懵的地方:博弈双方立场相反。极大极小值算法就是假设“我”永远是极大化玩家,也就是每一层都试图让评估值最大;“对手”是极小化玩家,每一层都试图让评估值最小。也就是说,走一步棋的时候,不光要考虑我这步能得到多高的分数,还要考虑对手下一步会想办法把这个分数压下去。

1.2 为什么评估函数决定了算法上限

评估函数是整个搜索算法的“视力”。搜索再深,如果评估函数是瞎的,最后选出来的棋也不会好。初学的时候很容易把注意力全放在剪枝上,结果换了一个评估函数,胜率反而大幅波动,其实就是评估函数没设计好。

评估函数本质上是对局面优势的量化。比如在棋类里,最常见的做法是给每个棋子设定基础价值,然后加上位置性的附加分,比如控制中心格子加几分、吃掉对方一个活跃棋子减几分等。对于非棋类场景,评估函数可以是任何能刻画“当前状态下谁占优”的指标,比如资源数量差、距离目标的剩余步数等。

需要注意,评估函数必须和搜索深度配合。深度浅的时候,评估函数必须更敏锐,否则在一个平静的局面下看不出优劣;深度深的时候,评估函数反而不需要太精细,因为搜索本身就能看到更远的战术变化。

1.3 理想的剪枝目标:把无用分支砍掉

极大极小值搜索的问题在于,分支因子太大往往导致计算量爆炸。Alpha-Beta 剪枝的意义就是,在不影响最终决策的前提下,把那些不可能影响根节点选择的子树整个砍掉。说直白点,有一个分支已经能确定当前节点不可能是最优选择,那它下面的子树完全没必要继续搜。

理解剪枝的关键,是意识到搜索顺序决定剪枝效率。如果每个节点都能先检查最可能产生最优结果的分支,剪枝率会接近理论极限,也就是可以把有效搜索范围压缩到原来的平方根量级。如果顺序很差,剪枝几乎不发生,Alpha-Beta 剪枝退化成完整极大极小值搜索。这也是为什么正经棋类引擎都要配合启发式排序:先把吃子、将军、关键位置变化这类走法排在前面。

2. 核心机制解析:极大极小值算法与 Alpha-Beta 剪枝原理

2.1 极大极小值算法的递推逻辑

极大极小值算法的核心逻辑是一个自底向上的递推过程。叶子节点的值来自评估函数,内部节点的值由它的所有子节点值决定:如果是 MAX 节点,当前玩家是最大化者,节点值取所有子节点值的最大值;如果是 MIN 节点,对手是最小化者,节点值取所有子节点值的最小值。

为什么必须轮流转置 max/min?因为棋类游戏是回合制的。我在这一回合拥有主动权,肯定选让自己优势最大的走法;但一旦我走完,主动权到了对手手里,他会选让我优势最小的走法。这种交替取最大、取最小的过程,正好刻画了真实博弈中的攻防转换。

实现极大极小值算法时,常见做法是在递归函数里传一个布尔值is_maximizing,根据它判断当前节点取 max 还是 min。初始调用从根节点开始,根节点的角色由当前轮到谁走决定。如果是程序自己走,根节点就是 MAX 节点;如果是对手走,根节点就是 MIN 节点。

从复杂度上看,完整极大极小值搜索的复杂度是 O(b^d),b 是平均分支因子,d 是搜索深度。比如说五子棋平均分支因子约 20 到 30,搜索深度 6 层,那就是 20^6,约 6400 万个节点,单机跑起来已经比较吃力。这个复杂度说明,在真实项目里极大极小值算法很少单用,必须配合剪枝和优化。

2.2 Alpha 和 Beta 到底在传递什么

Alpha-Beta 剪枝的核心是两个边界值:alpha 是当前 MAX 节点已经确保可以拿到的最低分数,初始是负无穷;beta 是当前 MIN 节点已经确保可以给出的最高分数,初始是正无穷。搜索过程中,alpha 只能不断变大,表示 MAX 玩家的保底分数在抬高;beta 只能不断变小,表示 MIN 玩家的封顶分数在压低。

当 alpha >= beta 时,当前节点已经没有任何继续探索的价值,因为 MAX 玩家至少能拿到 alpha 分,而 MIN 玩家最多只愿意给出 beta 分,且 beta 小于 alpha,说明这条路径对任何一方都没有吸引力,再往下搜只会浪费时间,直接剪掉。

我见过不少初学者把 alpha 和 beta 理解成“窗口”,这个类比其实很贴切。每一步搜索都相当于在一个不断缩小的数值窗口里找答案。窗口没关闭时继续搜,窗口一旦关闭,也就是 alpha >= beta,就立即停手。这个窗口的初始大小决定了剪枝效率:窗口越大,剪枝越晚;窗口越小,剪枝越早,但窗口小到把正确答案也排除在窗口外,结果就会出错。

2.3 标准伪代码逐行拆解

下面这份伪代码是最常见的 Alpha-Beta 剪枝实现,采用递归形式,返回当前局面的评估值。它同时也是一个完整的解题模板,可以直接改造成实际代码。

def alphabeta(node, depth, alpha, beta, maximizing_player): if depth == 0 or node.is_terminal(): return evaluate(node) if maximizing_player: value = float("-inf") for child in node.get_children(): value = max(value, alphabeta(child, depth - 1, alpha, beta, False)) alpha = max(alpha, value) if alpha >= beta: break return value else: value = float("inf") for child in node.get_children(): value = min(value, alphabeta(child, depth - 1, alpha, beta, True)) beta = min(beta, value) if alpha >= beta: break return value

这段代码有几个细节必须说清楚。第一,alpha和beta在递归传递时是不对称的:MAX 节点更新 alpha,MIN 节点更新 beta,但两个分支都会把当前最新的 alpha 和 beta 继续传给下一层。原因在于下一层的搜索需要知道本层上下文的边界信息,也就是说,MAX 节点在搜索某个子节点时,已经知道了其他兄弟节点的保底分数,它可以用这个信息提前判断是否应该继续搜下去。

第二,break语句只在两处出现,一处在 MAX 节点,一处在 MIN 节点,但判断条件都是alpha >= beta。MAX 节点里这个叫 beta 剪枝,因为当前节点值已经大于等于 beta 了,再搜下去 MIN 玩家不会选择这条路;MIN 节点里这个叫 alpha 剪枝,因为当前节点值已经小于等于 alpha 了,再搜下去 MAX 玩家也不会选择这条路。

第三,返回值是局面评估值,不是走法本身。实际落地时需要另写一个包装函数,在根节点遍历所有合法走法,调用这次递归函数拿到每个走法对应的评估值,然后从中选出最大值对应的走法。这个过程我会在后面的解题流程部分详细展开。

2.4 负极大值表示法:一种更简洁的等价写法

在真正的博弈引擎里,很多人不用上面的双分支写法,而是用负极大值算法,它利用了一个数学性质:MAX 节点取最大值等价于 MIN 节点取负值再取最大值。这样代码可以合并成一个统一的分支,结构更简洁,也更容易维护。

def negamax(node, depth, alpha, beta): if depth == 0 or node.is_terminal(): return evaluate(node) value = float("-inf") for child in node.get_children(): score = -negamax(child, depth - 1, -beta, -alpha) value = max(value, score) alpha = max(alpha, score) if alpha >= beta: break return value

负极大值版本的写法里,alpha 和 beta 的传参是(-beta, -alpha),注意顺序颠倒了。原因在于子节点的视角是对方,原本是最大值玩家的 alpha 边界,在对方看来就要取负并变成 beta 边界。这个旋转逻辑初看很难接受,但只要记住一句话:子节点评估值的相反数就是父节点视角下的分数。这个版本在工程上特别实用,因为它天然支持多玩家博弈的扩展,代码量也少很多。

3. 完整解题流程:从题目到可运行的博弈搜索程序

3.1 解题步骤概览

用 Alpha-Beta 剪枝解决一个具体博弈问题,通常分五个步骤:定义局面状态、实现状态生成器和终结判断、设计评估函数、编写递归搜索、包装决策函数。很多人直接在第四步开始写递归,前面几步很随意,后面调试时才发现状态生成器漏了合法走法,或者评估函数符号搞反,排查起来非常痛苦。

先从状态定义说起。状态必须能完整刻画当前局面,并支持两个操作:得到当前玩家的所有合法走法,以及判断是否终结。如果状态设计不好,后面每个环节都要跟着返工。一个实用的原则是,用不可变数据或者深拷贝来传递状态,避免递归过程中子节点互相污染。

3.2 一个完整示例:简化井字棋的决策流程

我用一个简化版的井字棋来演示这个过程。井字棋棋盘是 3x3,玩家分别是 X 和 O,最先连成三点一线的一方获胜。给定一个局面,要做的事就是选出最佳落子位置。

状态表示用一维数组,长度为 9,每个位置有 0、1、2 三种状态,0 代表空,1 代表 X,2 代表 O。状态生成器遍历数组,把所有值为 0 的位置作为可落子位置,每个位置生成一个新数组,把当前位置改为当前玩家编号。终结判断检查八条胜利线,如果任意一条线上是同一个非零玩家,则返回获胜者;如果数组中没有 0,则返回平局。

评估函数在这个例子里可以非常简单:如果当前节点已经分出胜负,返回一个固定的大分数,比如获胜返回 10,失败返回 -10,平局返回 0。由于深度较浅,不需要额外的位置评估。实际使用时,为了鼓励快速取胜,还可以给获胜分数减去当前深度,让程序优先选择更快的胜法,这个技巧叫深度惩罚。

决策函数在根节点遍历所有合法走法,对每个走法调用负极大值递归,记录分数最高的走法并返回。这一步要注意,程序视角和评估函数视角必须保持一致。如果评估函数是从 X 的角度打分,那么轮到 O 搜索时,就要通过负极大值的符号反转来体现立场。

3.3 逐层理解递归调用过程

假设当前轮到 X,搜索深度设为 3。根节点调用negamax(node0, 3, -inf, +inf),它会遍历 9 个空位。

第一个空位落子后,生成子节点 node1,递归调用negamax(node1, 2, -inf, +inf),注意此时视角翻转,轮到 O 走。node1 内部遍历 O 的合法走法,生成 node2,再递归调用negamax(node2, 1, -inf, +inf),轮到 X 走。node2 内部继续遍历 X 的走法,到达深度 0,返回评估值。

假设 node2 的第一个走法让 X 直接连成三线,评估值返回 10。回到 node2 的循环里,score = -10,取最大后 value 是 -10,这会更新 alpha 为 -10,然后 break 条件判断alpha >= beta,此时 alpha 是 -10,beta 是正无穷,不剪枝,继续搜下一个走法。如果最后一个走法也都是 X 获胜,value 保持 -10。

接着回到 node1,它是 O 的视角,拿到 node2 的返回值 10,取负后 score 是 -10,value 更新为 -10,alpha 也更新为 -10。由于 beta 还是正无穷,继续看其他 O 的走法。这个例子中,由于其中一边深度只有 3,很多剪枝触发不了,但整个流程的逻辑是清晰的:每层的返回值都会经过符号翻转,层数越多,立场交替越明显。

3.4 把伪代码改造成真代码的要点

伪代码到实际代码之间的沟壑,往往比想象中要大。第一个坑是递归深度限制。Python 默认递归深度约 1000,如果搜索深度超过这个值,必须手动设置sys.setrecursionlimit或者改写成迭代式搜索,否则程序会在某次递归时直接崩溃。

第二个坑是状态拷贝开销。如果每次生成子节点都深拷贝整个局面,搜索速度快不起来。井字棋这种小棋盘问题不大,但应用到围棋、黑白棋这类棋盘较大的游戏,就必须用“落子-撤销”的增量更新方案,也就是在原状态上临时落子、递归、然后撤销,避免反复拷贝。

第三个坑是评估函数的调用频率。评估函数在叶子节点被大量调用,它的性能直接影响整体速度。比如说,如果在评估函数里加了一个复杂的启发式算法,每次搜索叶子节点都要算一遍,速度会断崖式下降。我自己的习惯是,评估函数只做简单算术,不在里面做搜索或排序。

4. 常见问题与排查技巧:把踩过的坑一次说清

4.1 剪枝从未触发,搜索慢到无法接受

这是 Alpha-Beta 刚开始跑时最常遇到的问题。如果你发现运行时间和完整极大极小值搜索一模一样,说明剪枝一次都没触发。原因几乎总是搜索顺序问题,程序每次都在搜索一个价值极差的分支,导致 alpha 和 beta 边界始终无法收敛。

解决办法是引入走法排序。在棋类程序里,可以用静态启发:先把吃子、将军、中心控制、上一步有响应价值的走法排在前面。即便排序不完全准确,剪枝效率也能大幅提升。我见过一个简单的排序策略,给每种走法预先设定一个权重值,从高到低排序,效果立竿见影,搜索时间直接缩短了 60% 以上。

还有一个容易被忽略的原因,是 alpha、beta 初始值传错了。如果 MAX 节点初始 alpha 被设成一个很大的正数,或者 MIN 节点初始 beta 被设成一个很小的负数,窗口一开始就是关闭的,第一层就直接剪光所有分支,返回结果必然错误。确定初始值的方法是,MAX 用负无穷,MIN 用正无穷。

4.2 搜索深度增加,棋力反而下降

这个现象在初学者项目里出现过很多次。理论上搜索越深,棋力应该越强,但实际测试时,深度 4 比深度 2 胜率高的同时,深度 5 可能反而输多赢少。这通常叫水平效应,是搜索深度截断造成的。

水平效应的本质是:搜索在不稳定的局面处突然截止,评估函数又无法识别潜在的后续威胁,于是程序以为当前的估计已经足够好,实际是片面的。典型例子是井字棋深一层能看到被双三连击,浅一层只看到自己连成三线,于是死守一个空位,反而忽略对方的威胁。

缓解方案有三种:一是搜索到深度边界附近时,额外做静态搜索,只搜索吃子或将军相关的走法;二是使用延伸搜索,当出现关键走法时,自动加深几层;三是评估函数不要只看静态棋子价值,要加入对威胁格子的敏感度。

4.3 程序视角反了,总在帮对手走棋

如果程序屡屡走出“送分棋”,优先怀疑视角问题。负极大值算法里,视角翻转是靠返回值取负完成的,但这里涉及的“立场”只有在递归各层之间保持一致才有效。

排查方法是选一个简单终局,比如井字棋只剩一步可以获胜的局面,手动模拟程序搜索过程,查看返回分数和选择的落子位置。如果程序在 MAX 层选了最小值对应的走法,说明取 max 的逻辑被写成了取 min;如果在返回时少了负号,说明符号翻转失效。

我建议在调试时临时打印每个合法走法的分数,对照手工计算。一旦确认视角正确,这类问题很快就能定位。

4.4 搜索深度太深导致栈溢出

递归式搜索遇到深度 20 以上,或者搜索树规模极大时,栈溢出风险很高。除了调高递归深度限制,更根本的解决方法是改用迭代深化的方式,让深度逐层增加,每层都重新搜索一遍。这样做看似浪费时间,但结合置换表缓存,实际效率非常高。

迭代加深还有一个额外好处:可以随时用“当前已搜索完的最深结果”作为答案,方便控制每步思考时间。很多棋类引擎都采用固定的思考时间限制,时间到了立即返回当前最佳走法,这就是迭代加深带来的灵活性。

我提一下置换表。置换表是一个哈希表,以局面哈希作为 key,缓存该局面的搜索深度、评估值和对应走法。它解决的是不同路径可能到达相同局面的问题,也就是所谓的“转换”。迭代加深配合置换表,可以让不同深度之间的搜索结果互相复用,大幅减少重复搜索。

5. 工程实践中的进阶技巧:让算法真正跑得更快

5.1 启发式走法排序的常用套路

Alpha-Beta 剪枝的效率高度依赖走法排序,排序越好,剪枝越早,搜索树越窄。棋类程序里常见几种排序策略:吃子走法优先,用“被吃棋子价值 - 吃子棋子价值”做个简单贪心;上次迭代加深搜索时的最佳走法优先,这个叫“杀手走法”;将军走法优先,因为将军通常会给对手造成压力;最后才是普通走法。

到五子棋这类分支很多的游戏,走法排序还可以加入对中心区域的偏好、对已有连子方向的延伸偏好。实测下来,排序只要不太离谱,搜索时间可以差一个数量级。为什么这么明显?因为排在前面的走法一旦让 alpha 快速抬高,后续大量分支会在很浅的深度就被剪掉。

5.2 迭代加深与时间控制

真实对局里,每步棋允许思考的时间是有限的。用固定深度搜索容易超时,用浅深度搜索又可能计算不足。解决办法是迭代加深:从深度 1 开始搜,搜完一层提高深度,直到时间耗尽。

迭代加深每层都从头开始,看似浪费,实际上配合置换表和杀手走法缓存后,上一层的结果可以为下一层提供很好的走法排序,所以总耗时比一次性搜到最大深度要节省很多。而且时间控制极其自然:哪怕只来得及搜到深度 4,也有一个“当前最优解”;如果时间充裕,继续搜到深度 5、6。

我自己的实践经验是,迭代加深写好后,几乎不需要手动调搜索深度。设定一个时间阈值,比如 1 秒或 3 秒,程序自动决定搜索深度,非常省心。

5.3 评估函数设计的常见误区

评估函数不是越复杂越好。评估设计得过于复杂,又会引入噪声,导致搜索在不同深度下对同一局面的判断不稳定。一个稳定的评估函数,应该满足单调性:同一局面的同一视角,不会因为搜索深度的微小变化出现截然相反的价值判断。

在设计评估函数时,要明确它服务的对象是谁。常见误区包括:忘了评估函数是给叶子节点打分,不是给整条路径打分;把搜索深度或到达该节点花掉的步数错误地混入评估值;以及忽略了防守端的价值,只奖励进攻,导致程序只顾着进攻、忽视防守。

还有一个很多人忽视的点:评估函数的取值范围要和 alpha、beta 的初始值匹配。如果评估值理论上最大值才 100,但初始 alpha 用了负 10000,虽然在正确性上没影响,但会让边界更新缓慢,剪枝变得迟钝。把边界设置成贴近真实评估范围的上下限,能让剪枝更快触发。

5.4 从棋盘游戏到通用决策系统的迁移

Alpha-Beta 剪枝不只适用于棋类。任何可以用博弈树建模的决策问题,都可以套用这个框架。典型例子包括:商业竞价的模拟,两方轮流出价,需要判断怎么出价能让最终收益最大;资源分配问题,多个决策者轮流选择资源,需要考虑对手的反应;甚至一些扑克类简化版 AI,在限制信息完全可见时,也可以用搜索方法来决策。

迁移的关键是三个抽象接口:状态表示、合法动作生成、评估函数。只要这三个接口定义清楚,极大极小值和 Alpha-Beta 剪枝就能直接复用。这个抽象思维比算法本身更重要,因为在实际工程中,更多时间都花在接口设计和状态建模上。

6. 几个值得动手实践的小项目

如果看完上面的内容还是觉得“纸上得来终觉浅”,我强烈建议挑下面几个项目动手练一练。

第一个是井字棋,它是最简博弈模型,状态空间小到可以直接枚举所有局面,适合验证算法正确性和剪枝逻辑。写完之后,可以在节点计数上对比有剪枝和无剪枝的搜索节点数量,直观感受剪枝威力。

第二个是简化版黑白棋或五子棋,这类游戏分支因子更大,棋力差距更明显,适合实践走法排序、迭代加深、置换表等进阶优化。五子棋的评估函数设计空间也很大,可以用机器学习方法来学习评估值,不过那是另一个话题。

第三个是自写一个双人卡牌对战的简化版,比如双方各有几张手牌,轮流出牌比点数,这种问题概念上和棋盘类完全一致,但更能训练抽象建模能力,因为它没有现成的棋盘结构可以参考。

我个人在带新人入门时,经常用井字棋起步,然后要求把它扩展到 4x4 连珠棋,靠这个方式强迫大家理解“如何将小棋盘改造成大棋盘而不破坏搜索框架”。经过这个台阶,再上五子棋或其他博弈题就毫无压力。

7. 最后分享一点个人经验

我最初写 Alpha-Beta 剪枝时,犯过最严重的错误是没有写节点计数,导致完全无法判断剪枝是否生效。后来养成了习惯:只要写递归搜索,就在函数入口加一行全局计数器自增,然后在根节点打印最终计数。这个习惯陪我排查了无数个蠢问题。

如果你现在正在调试两三个小时没跑出结果的棋类程序,我建议先别急着优化剪枝,先用一个很小的搜索深度跑通全部流程,确认每个返回值和手动计算一致,再逐步加大深度。基础不稳,后面每一层优化都会变成垃圾堆上的劣质补丁。

动手写一个自己的棋类 AI,是理解这类搜索算法最快的路。等你的程序能够在固定时间内给出稳定且合理的走法,回看这段代码,能发现你已经把递归、搜索、估值、优化这些概念全串起来了。能串起来,才算真正掌握。

RELATED READING

延伸阅读

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