1. 先把问题拆开:极小化极大算法与负极大值算法到底在算什么
做棋类 AI 的朋友大概都有过这个阶段:棋盘画好了,走法生成写完了,轮到"电脑怎么落子"这一步就卡住。我最初的做法是给每个空点打个分,挑最高的下,结果被自己的程序反杀——它只顾眼前吃子,完全看不到三步之后被人连成一片。后来才老老实实回到搜索这条路线上,也就是极小化极大算法(Minimax)和它的变体负极大值算法(Negamax)。这两个名字听起来像论文标题,实际上解决的是一个非常朴素的问题:在一个双方轮流出手、你赢就是我输的局面里,怎么算出当前这一步到底值不值。
这篇文章面向的是已经把棋盘数据结构写出来、准备接上搜索逻辑的开发者,也适合刚学完递归、想找个真实项目练手的学生。我会把这两个算法的来龙去脉、代码骨架、评估函数设计、性能瓶颈和实际调参经验全部摊开讲,包括我在符号翻转和剪枝边界上踩过的几个坑。读完你至少能做到三件事:手写一个能跑通的 Minimax 引擎、把它改写成 Negamax 并验证结果一致、在此基础上接上 Alpha-Beta 剪枝把搜索深度提上去。全程用 Python 演示,逻辑换到 C++ 或 Java 也是照搬。
1.1 把一盘棋抽象成一棵搜索树
任何棋类 AI 的第一步都不是搜索,而是状态建模。棋盘上的每一个局面是一个"状态",从当前状态出发,所有合法走法各自生成一个新状态,新状态再往下生成,层层展开就形成一棵树。根节点是当前真实局面,往下第一层是我方所有可能的落子,第二层是对方针对每一种落子的所有回应,第三层又轮到我方。叶子节点是搜索截止的地方——可能是分出胜负的终局,也可能是达到预设深度后被强行截断的中间局面。
这里有个关键点容易被忽略:搜索树的规模是分叉数的深度次方。井字棋平均分叉大约 4 到 5,深度 9 就能穷尽,总节点量在几十万级别,普通笔记本毫秒级跑完。但换成 15×15 的五子棋,中盘阶段每方合法落点动辄上百个,取个保守值 50,深度 6 就是 50 的 6 次方,约 156 亿个节点。假设你的引擎每秒能评估 100 万个节点,跑完这一层需要四个多小时。这个数字不是吓唬人,它直接决定了后面所有优化手段的存在意义:Minimax 给出的是正确性,Alpha-Beta 和各种剪枝给出的是可用性。
状态建模里还有两个必须提前想清楚的设计。第一,状态要不要做拷贝?每走一步就深拷贝整个棋盘,在 Python 里开销极大,五子棋 15×15 的二维列表拷一次就是 225 个元素。常见做法是"落子—递归—撤销"的原地修改模式,只改一个格子,回溯时再改回来,内存几乎零增长。第二,终局判定放在哪一层?我建议放在走法生成之前,每次进入递归先判断是否已经分出胜负,是就直接返回一个带深度修正的极值,避免在已经结束的局面里继续展开。
1.2 Minimax 的底层假设:对手永远不犯错
Minimax 这个名字已经把思路写脸上了:最大化与最小化交替。轮到我方走,我从所有子节点里挑评估值最大的那个;轮到对方走,他一定会挑评估值最小的那个。评估值的定义是"站在我方视角看这个局面有多好",越大对我越有利。于是整棵树的值可以从叶子往上倒推:叶子有静态评分,父节点根据自己的角色取子节点的最大或最小值,一路推到根节点,根节点选中的那条分支就是最优走法。
这个模型成立的前提是零和博弈——我赢一分等于你输一分,没有双赢也没有平局之外的第三种结果。井字棋、五子棋、象棋、围棋基本都符合,所以这套逻辑适用面很广。另一个隐含假设是对手是理性的且信息完全,他知道你所有可能的走法,并且总是选对他最有利的那一个。现实中的对手当然会失误,但工程上必须按最坏情况设计,否则算出来的"最优解"在强手面前一文不值。这个假设带来的副作用是引擎风格偏保守,宁可放弃进攻也要堵住对方的杀招,对新手来说反而更难缠。
代码骨架非常短,短到很多人第一次看完会觉得"就这?":
INF = float('inf') def minimax(state, depth, maximizing): # 到达搜索深度或者分出胜负,返回静态评估 if depth == 0 or state.is_terminal(): return state.evaluate() if maximizing: best = -INF for move in state.legal_moves(): state.apply(move) score = minimax(state, depth - 1, False) state.undo(move) best = max(best, score) return best else: best = INF for move in state.legal_moves(): state.apply(move) score = minimax(state, depth - 1, True) state.undo(move) best = min(best, score) return best仔细看会发现这里有个冗余:maximizing 这个布尔量和"当前该谁走"是同一件事,被存了两份。这个冗余在只有两层的时候无所谓,但一旦引入剪枝、置换表、迭代加深,维护两套逻辑的分支就会到处冒出来——你得在最大化分支里写一套窗口更新,在最小化分支里反过来写另一套,符号一错就是整棵树的值全反。这也是后来 Negamax 被提出来的直接动因,后面第 3 章会详细展开。
1.3 为什么还需要 Negamax:一份代码胜过两份对称代码
把 Minimax 的对称性再往下推一步。我方视角下局面分值是v,那从对方视角看同一个局面,分值必然是-v。同理,对方在他那一层要"最大化他自己的收益",等价于我这一层"最小化我的收益"。既然两边的最优目标可以用一个负号互相转换,那"取最大"和"取最小"这两个操作就能合并成一个。
负极大值算法的做法是:所有节点统一取最大值,但在从子节点返回时把符号翻转。具体来说,函数返回的永远是从"当前轮到走棋的一方"的视角看的分数。子节点返回的是对手视角的分数,取负就变回我方视角,然后跟我方其他候选走法一起比大小。这样整个递归里只剩一个max,代码量直接砍半,而且没有 maximizing 参数这个容易出错的状态。
这个改写看着只是形式上的简化,实际收益比想象中大。第一,出错面变窄。我见过不止一个项目在 Minimax 的 min 分支里写反了比较符,导致引擎主动送子,排查了半天才发现。负极大值没有 min 分支,这类 bug 从结构上就消失了。第二,后续优化的接入成本低。Alpha-Beta 剪枝的标准形式就是基于 Negamax 写的,窗口参数(-beta, -alpha)这种翻转写法在 Minimax 里要维护两套。第三,置换表命中率更高。置换表里存的分值必须统一视角,否则同一个局面在不同深度的路径下可能被存成两个不同的值,命中就等于白存。
不过 Negamax 有一个必须讲清楚的前提:评估函数返回的必须是"当前行动方视角"的分数,而不是固定的某一方视角。这一点如果搞混,棋力会表现得极其诡异——引擎会突然开始自残,或者在必胜局面下走闲棋。第 3 章我会专门用一个案例讲这个坑。
2. Minimax 的落地细节与几个绕不开的取舍
2.1 递归骨架之外,真正决定棋力的是评估函数
Minimax 本身只负责"往前看",看多远、看得准不准,全靠评估函数。评估函数的职责是:给定一个还没分出胜负的中间局面,给出一个标量分数,告诉搜索"这个局面大概有多好"。它是整棵树所有叶子节点的值来源,也是引擎的"棋感"所在。评估函数写得糙,搜索再深也是白搭——深度 8 的搜索配上只看子力数量的评估,下出来的棋还不如深度 4 配一个好评估。
设计评估函数有个基本纪律:特征要可解释、权重要有量级差、量级差要能压住搜索噪声。以五子棋为例,常见的打分体系是先把棋盘上所有横、竖、两条斜线抽出来,逐段匹配棋型,然后按棋型累加:
| 棋型 | 典型分值 | 说明 |
|---|---|---|
| 五连 | 10000000 | 直接终局,权重必须高于所有其他组合 |
| 活四 | 100000 | 两端都空,对方无法同时堵死 |
| 冲四 | 10000 | 一端被堵,只能堵另一头 |
| 活三 | 8000 | 走一步可成活四,威胁大 |
| 眠三 | 1000 | 被堵住的三个子 |
| 活二 | 800 | 有发展空间 |
| 眠二 | 100 | 劣质结构 |
表格里的数值不是玄学,是相对量级。关键是保证"两个活三"的分数要明显高于"一个冲四",因为双活三基本是必胜结构;同时"一个活四"必须高于"任意多个活三",否则引擎会去追三而放过已经成型的四。我在早期版本里把活四和冲四设成了同一量级,结果引擎在能直接连五的局面下选择去做三三,因为它觉得"两个活三"分更高。后来把分值改成 10 倍递进关系,这个问题立刻消失。
评估函数还要处理攻守不对称。同一个局面,我方有一个活四和对方有一个活四,分值是截然不同的。最直接的处理方式是在评估时区分"我的棋型"和"对手的棋型",对手的棋型按负分计入,并且给防守一个略高的权重系数——比如对手活四按 -120000 算,比我方活四的 100000 更重。这个系数不要调得太激进,超过 1.5 倍引擎就会变得畏首畏尾,明明自己有杀招却先去堵一个无关紧要的点。
2.2 深度不是越深越好,先算清楚预算
很多人调参的第一反应是"把深度加上去"。理论上深度每加 2,棋力会有明显提升,但代价是指数级的。这里必须做一次实打实的预算计算。假设某开发者写的引擎在 Python 里每秒能跑 30 万个节点(原地落子撤销 + 简单评估,这个数字是合理的),五子棋中盘分叉按 50 算:
| 搜索深度 | 理论节点量 | 估算耗时 |
|---|---|---|
| 2 | 2500 | 约 0.01 秒 |
| 4 | 6250000 | 约 20 秒 |
| 6 | 156 亿 | 约 14 小时 |
| 8 | 3.9×10¹³ | 不可接受 |
深度 4 就已经到 20 秒了,这在实战里完全不能用。所以纯 Minimax 的现实使用范围基本限于井字棋、四子棋这类小分支游戏,或者五子棋的深度 3 到 4 加一个粗糙的静态搜索。真正能用的是后面要讲的 Alpha-Beta——在最优走法排序的前提下,它能把有效分叉从 b 降到 √b,节点量从 b^d 降到约 b^(d/2)。还是那个例子,深度 6 从 156 亿降到 50³=12.5 万,耗时从 14 小时变成不到 1 秒。
这里有个实践经验值得单独拎出来:深度要取偶数。因为搜索的层数是"我走一步、对方走一步"成对推进的,奇数深度意味着最后一步只算了我方的行动、没算对方的回应,评估出来的局面是"我打了一拳但对方还没还手",引擎会系统性地高估进攻收益,表现得特别鲁莽。我做过对比,同一个评估函数下,深度 5 的引擎对战深度 6 的引擎,胜率大概只有三成,输的局大多是开局贪攻被人反打。
2.3 终局分值的深度修正:越早赢越好,越晚输越好
单纯返回+10000000表示五连,会带来一个隐蔽的问题:引擎不在乎什么时候赢。如果能三步赢,它也可能选一条七步赢的路径,因为两条路径的终局分值一模一样。这在有思考时间限制的对局里是致命的——你多花四步就多给了对手四次翻盘机会,而且白白消耗了思考时间。
解决办法是给终局分值加上深度修正:终局分加上当前剩余深度乘以一个系数。写成return WIN_SCORE - (MAX_DEPTH - depth)或者return WIN_SCORE + depth,具体符号取决于你的深度是往下递减还是递增。这样越早到达的胜利分值越高,越晚发生的失败分值越高(因为输得晚也算一种"相对较好"的结果)。系数取 1 到 100 之间都行,只要保证所有终局分都远大于任何静态评估值,同时系数本身不破坏终局分之间的相对顺序。
这个技巧在实战里效果非常直观。改之前引擎经常在胜局里"散步",明明可以三步连成,它非要绕一圈;改之后它会毫不犹豫地走最短路径。同样的思路也可以用在"避免失败"上:如果某条分支注定要输,那就尽量拖长,说不定对手会失误,或者逼出一个平局。
3. 负极大值算法:把两份代码压成一份,顺便把剪枝的门打开
3.1 数学等价性的推导,不接受"背下来"
Negamax 的合法性可以用一句话概括:对任意实数 a、b,max(a, b) = -min(-a, -b)。这个恒等式是显然的,但它在搜索里的应用需要一次视角切换。设f(state)表示"从轮到走棋的一方视角出发,这个局面对他有多好"。那么轮到我方时,我希望f最大;轮到对方时,他也希望f最大,只不过他的f和我的f符号相反。
于是递归关系可以统一写成:
f(state, depth) = max over all moves m of ( -f(state.apply(m), depth-1) )我方走一步,生成新状态,新状态轮到对方,f从对方视角算出来之后取负就变回我方视角,再在所有候选走法里取最大。整个过程没有 min,也不需要知道当前是谁的回合——f的定义里已经包含了"轮到谁"这个信息。
第一次看这个式子容易绕晕,我用一个具体例子走一遍。假设某个局面轮到我方,有两个候选走法 A 和 B。走 A 之后对方的视角分值是 +30(对对方有利),走 B 之后对方的视角分值是 -10(对我方有利)。按 Negamax,我方取max(-30, -(-10)) = max(-30, 10) = 10,选 B。换成 Minimax 的说法:轮到对方时他要最小化我的收益,A 分支他选完只剩 -30,B 分支他选完我能拿到 +10,我自然选 B。两条路结果一致。
3.2 代码实现与符号翻转的三个真实坑
标准实现大概长这样:
INF = float('inf') def negamax(state, depth): if depth == 0 or state.is_terminal(): # evaluate 必须返回"当前行动方视角"的分数 return state.evaluate() best = -INF for move in state.legal_moves(): state.apply(move) score = -negamax(state, depth - 1) state.undo(move) if score > best: best = score return best比 Minimax 短了将近一半,maximizing参数彻底消失。但简洁是有代价的,下面三个坑我都亲自踩过。
第一个坑:评估函数视角搞错。如果evaluate()老老实实返回"红方视角"的分数,然后在黑方回合调用 Negamax,整个结果就反了。因为 Negamax 假设evaluate()返回的是"当前该走棋的那一方"的收益。修法有两种:要么让evaluate()接收一个side参数并据此翻转,要么在函数入口处乘一个color系数(红方 +1、黑方 -1)。我推荐后者,因为它在递归里天然传递,不需要每个节点都去查"现在轮到谁"。
def negamax(state, depth, color): if depth == 0 or state.is_terminal(): return color * state.evaluate_static() best = -INF for move in state.legal_moves(): state.apply(move) score = -negamax(state, depth - 1, -color) state.undo(move) best = max(best, score) return best注意-color这个传参,它是符号翻转的源头。evaluate_static()固定返回红方视角,color负责把它换算成当前行动方的视角。
第二个坑:根节点选走法时忘了翻回去。Negamax 的返回值是"当前行动方视角",在根节点正好就是我方视角,所以根节点选最大值的逻辑是对的。但如果你的根节点代码是复用的 Minimax 那套、写成了选最小值,那就全反了。这种 bug 的表现是引擎每一步都在"帮对手下棋",肉眼可辨,但如果只是某一条分支写反,就很难发现。
第三个坑:终局分值的符号。Minimax 里"我方赢"就返回一个很大的正数,很直观。Negamax 里,如果终局判定发生在对方回合,返回的应该是正数(因为对对方有利),而我方赢的终局是在我方回合结束后被检测到的,返回的正数经过一层翻转变成负数传上来——这看起来像是 bug,实际上是对的。判断标准很简单:终局分值也必须是"当前行动方视角"。如果你在终局直接返回WIN_SCORE不区分是谁赢了,那所有终局都会被当成"当前行动方获胜",引擎会疯狂送人头。
调试这三个坑有个笨但有效的办法:用井字棋写一个穷举验证。井字棋状态空间小,可以从任意局面全量搜索到终局,得出精确的胜负结果。然后拿 Negamax 的结果去对,只要差一个符号或者一个数量级,立刻能定位。我当年就是把 Minimax 和 Negamax 两个版本同时跑,逐节点比对返回值,才把第三个坑挖出来的。
3.3 从 Negamax 到 Alpha-Beta:只加四行,速度翻几倍
Negamax 最大的价值在于它是 Alpha-Beta 剪枝的天然载体。剪枝的核心思想是:如果我已经找到了一条能拿到 10 分的路,而另一条分支在探索过程中发现对手能把它压到 5 分以下,那这条分支剩下的部分就没必要看了——因为理性的对手不会让我拿到超过 5 分,而 5 分已经输给已知的 10 分。
def negamax_ab(state, depth, alpha, beta, color): if depth == 0 or state.is_terminal(): return color * state.evaluate_static() best = -INF for move in state.legal_moves(): state.apply(move) score = -negamax_ab(state, depth - 1, -beta, -alpha, -color) state.undo(move) if score > best: best = score if best > alpha: alpha = best if alpha >= beta: break # 剪枝:这条分支对手不会让我走到 return best(-beta, -alpha)这个翻转写法是整段代码里唯一需要动脑的地方。它的由来是:子节点是对方回合,对方的搜索窗口在我方视角下是[alpha, beta],转换到对方视角就要变成[-beta, -alpha]。想不通的时候可以先按 Minimax 的写法推一遍,再机械替换。
剪枝效率极度依赖走法排序。如果最优走法排在最前面,剪枝效果拉满,节点量降到理论下限;如果排最后,几乎剪不掉什么,甚至因为多维护两个变量而比裸 Negamax 慢一点。实践中常用的排序启发有几条:吃子走法优先(按被吃子力排序)、置换表里记录过的最佳走法优先、历史启发(某个走法在别的分支里剪枝成功过就加分)。五子棋这类落子型游戏没有吃子,主要靠"评估函数对单点的快速打分"做排序,也就是先算一遍每个候选点的局部价值,按分数从高到低排。
实测数据能说明问题。我让某开发者在本地用同一台机器跑五子棋引擎,分叉控制在 60 左右,统计深度 4 和深度 6 的节点访问量:
| 版本 | 深度 4 节点数 | 深度 6 节点数 | 深度 6 耗时 |
|---|---|---|---|
| 裸 Negamax | 约 1296 万 | 约 466 亿 | 不可行 |
| Negamax + Alpha-Beta(随机排序) | 约 300 万 | 约 20 亿 | 约 2 小时 |
| Negamax + Alpha-Beta(启发排序) | 约 8 万 | 约 45 万 | 约 1.5 秒 |
从 20 亿降到 45 万,两个数量级的差距全靠排序。这也解释了一个常见困惑:为什么我的 Alpha-Beta 加上了却没变快——大概率是走法生成顺序完全随机,剪枝触发率极低。
4. 完整实操:从零跑通一个可用的搜索引擎
4.1 棋盘表示与落子撤销
棋盘用一维数组存比二维列表快,索引写成row * N + col。落子撤销就是两个赋值,不需要任何拷贝:
class Board: def __init__(self, size=15): self.size = size self.grid = [0] * (size * size) # 0 空,1 黑,2 白 self.history = [] def apply(self, idx, player): self.grid[idx] = player self.history.append(idx) def undo(self): idx = self.history.pop() self.grid[idx] = 0走法生成要加一层过滤:只考虑已有棋子周围两格以内的空点。五子棋第一手下天元之后,空棋盘上有 224 个候选点,但真正有意义的不超过周围一圈。这个剪枝几乎不损失棋力,却能把根节点分叉从 200 多降到 30 到 50,效果立竿见影。实现方式是维护一个"候选点集合",每次落子后把周围两格的空点加进去,撤销时按引用计数清理。
终局判定要在落子后立即做,只检查落子点的四个方向(横、竖、两条斜线),每个方向向两边各延伸四个格子,数连续同色数量。这比全盘扫描快一个数量级,而且逻辑简单不易错。
4.2 评估函数的具体写法
评估函数我采用"逐线抽取 + 模式匹配"的方案。先把棋盘上所有可能的线(15 行、15 列、29 条主对角、29 条副对角)预计算成索引列表,评估时遍历这些线,把每段连续的非空区域转成字符串,然后匹配棋型:
PATTERNS = [ ("11111", 10000000), # 五连 ("011110", 100000), # 活四 ("011112", 10000), ("211110", 10000), # 冲四 ("01110", 8000), # 活三 ("010110", 8000), ("011010", 8000), # 跳活三 ("001112", 1000), ("211100", 1000), # 眠三 ("00110", 800), ("01100", 800), # 活二 ]这里的 1 表示我方棋子,2 表示对方或边界,0 表示空。匹配时按分值从高到低扫,一条线上一旦匹配到高分棋型就把这一段标记为已消耗,避免"五连"被重复算成好几个"活三"。评估结果是我方总分减对方总分,最后按当前行动方视角返回。
这套写法有一个性能陷阱:每条线都要做字符串拼接和多次模式匹配,在 15×15 的棋盘上一次评估大概要几毫秒,搜索几万个节点就是几十秒。优化方向有两个:一是把模式匹配改成滑动窗口的整数编码(三位二进制表示一个格子,窗口内做位运算比较),速度能提十倍以上;二是只评估落子点附近的四条线,而不是全盘所有线。我最终采用的是第二种,配合"增量评估"——每次落子只更新受影响的四条线的分数,撤销时还原。这样单次评估的开销降到微秒级。
4.3 迭代加深与时间控制
固定深度的搜索有个致命问题:你无法预知这一层要跑多久。深度 6 在开局可能 0.5 秒跑完,到了中盘复杂局面可能 30 秒。解决办法是迭代加深:从深度 2 开始搜,搜完再搜 4,再搜 6,每完成一层就记录当前最佳走法,一旦下一层超时就放弃,用上一层的成果。
这个做法听起来浪费——深度 6 的搜索把深度 2 和 4 又跑了一遍。但实际上由于 Alpha-Beta 的存在,浅层搜索的开销占比极小,深度 4 的耗时通常不到深度 6 的十分之一。而且浅层搜索的结果可以作为深层的走法排序依据,让剪枝效率更高,总耗时反而可能下降。
时间控制的实现细节:在搜索函数里定期检查time.time() - start > limit,一旦超时抛一个自定义异常,在根节点捕获,返回上一层的结果。检查频率不能太高(每次检查系统调用本身有开销),也不能太低(可能超时几百毫秒),我一般每 1024 个节点检查一次。还有个细节是根节点的最佳走法必须在一开始就设置一个兜底值,否则第一次搜索就超时会导致没有走法可下。
4.4 置换表:同一局面不要算两遍
不同走法顺序可能到达同一个局面,这在棋类里非常常见(先走 A 再走 B,和先走 B 再走 A 结果相同)。置换表就是用一个哈希表把这些重复局面缓存起来,键用 Zobrist 哈希——给每个格子的每种棋子状态分配一个随机 64 位数,局面的哈希值就是所有已落子位置对应随机数的异或。落子时异或一次,撤销时再异或一次,天然可逆。
表项存四样东西:key(校验用,防止哈希碰撞)、depth(这个值是在多深的搜索下算出来的)、value、flag(EXACT 表示精确值,LOWER 表示下界,UPPER 表示上界)。查询时只有当缓存深度大于等于当前需求深度时才可用,否则值不够精确。flag 的作用是处理剪枝带来的"值被截断"情况,如果直接拿一个被截断的值当精确值用,会导致搜索结果出错。
置换表在五子棋上的命中率大概在 20% 到 40% 之间,取决于开局库和走法排序的质量,实测能带来 30% 左右的提速。它更大的价值是给走法排序提供依据——表里记录的最佳走法优先尝试,剪枝效率直接提升。
5. 常见问题与排查实录
5.1 问题速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 引擎主动送子、自杀式走法 | Negamax 符号翻转错误,或评估函数视角不统一 | 用井字棋穷举结果对拍,逐节点打印返回值 |
| 明明能赢却绕路 | 终局分值没有深度修正,或终局分不够大 | 检查终局分是否远大于静态评估,加上深度奖励 |
| 加了 Alpha-Beta 反而变慢 | 走法排序随机,剪枝触发率低 | 统计剪枝次数,加上启发式排序 |
| 搜索深度一加深就超时 | 没有迭代加深和时间控制 | 引入超时异常,从深度 2 逐层加 |
| 引擎总是高估进攻 | 搜索深度取了奇数 | 改成偶数深度对比测试 |
| 同一局面走出不同结果 | 置换表命中后取值未校验深度,或哈希碰撞 | 加 key 校验,检查 depth 比较条件 |
| 开局阶段引擎发呆 | 候选点生成范围太大,根节点分叉过多 | 只保留已有棋子周围两格的空点 |
| 中盘突然变慢十倍 | 评估函数全盘扫描,或走法生成未剪枝 | 改成增量评估,限制候选点数量 |
5.2 几条从实战里换来的经验
第一条:先做对拍,再做优化。我在优化之前一定会保留一个"慢但正确"的参考实现——比如井字棋的全量穷举,或者小棋盘上的无剪枝 Negamax。任何一次改动之后,用随机生成的几百个局面跑一遍对拍,结果一致才继续往下走。这个习惯救过我很多次,因为搜索算法的 bug 往往是"大部分局面正确,少数局面错得离谱",靠肉眼看棋谱根本发现不了。
第二条:日志要打节点数和剪枝数。不要只打最终走法和耗时,一定要统计"访问了多少节点""剪枝触发了多少次""置换表命中多少"。这三个数字是性能问题的体温计。比如你发现搜索深度 6 只用了 500 个节点就跑完了,那不是你写得好,大概率是剪枝出错了,把不该剪的分支剪掉了。正常的五子棋深度 6 应该在几十万到几百万节点量级。
第三条:评估函数的调参要有对照实验。改一个权重,跑一百盘自对弈,看胜率变化。单看某一盘棋觉得"这步走得挺好啊"完全没有说服力。我一般会让新旧两个版本各执黑白对下 200 盘,胜率差在 5% 以内就认为没有显著差异,不值得为此增加复杂度。权重调整最容易犯的错是"为了修一个局面而调一个参数",结果修好了这个局面、弄坏了另外十个。
第四条:注意递归深度和 Python 的栈限制。Python 默认递归深度是 1000,Negamax 每层递归会占用若干个栈帧,深度 8 加上走法生成和评估的嵌套调用,很容易撞到限制。解决办法是sys.setrecursionlimit(10000),但更根本的做法是把搜索深度控制在合理范围,或者用显式栈改写。我见过有人把深度设到 12 然后报RecursionError,以为是算法写错了,其实是语言限制。
第五条:不要迷信深度,静态搜索更重要。在五子棋、象棋这类"吃子/成型的战术组合很密集"的游戏里,搜索在深度边界处被强行截断,评估出的局面可能是"我正在被将军但还没走",这种"地平线效应"会让引擎做出灾难性决策。解决办法是在叶子节点前加一层静态搜索,只展开强制性的走法(比如连续冲四、吃子),直到局面平静下来再评估。这个改动的收益往往比加深两层还大。
第六条:怀疑一切"看起来太好的结果"。如果你的引擎在某个局面算出了"必胜",先别高兴,去验证那条路径。我遇到过引擎算出必胜,实际走三步就被反杀,原因是评估函数把对手的冲四漏算了——它只扫描了以落子点为中心的四个方向,但对手的冲四是在另一条线上成型的。搜索算法给出的答案质量,上限就是评估函数的质量。
最后分享一个我自己常用的调试套路:把搜索树的前三层打印出来,每个节点的走法、返回值、alpha/beta 窗口都打上,人工检查一遍。节点不多的时候(几百个),这比任何断点调试都直观,符号错误、窗口传递错误、终局判定遗漏,基本一眼就能看出来。等确认前三层没问题,再关掉日志跑完整深度。这个笨办法我用了很多年,至今没找到更快的替代方案。