搜索算法
皮卡鱼不会把所有变化都算到同样深。它会先试更有希望的走法,把关键变化算得更深;对希望较小的分支少算,或提前停下。NNUE 负责给局面打分,搜索负责比较“走下去会怎样”。
本文以 official Pikafish 的提交 1c66b9b 为核对基准。下文列出的方法均能在这份代码中找到;具体阈值会随版本调整,因此重点解释用途,不把调参常数当成固定规则。文末附源码入口及 Chessprogramming Wiki 等延伸资料。
相关内容:源码导读、局面评估与 NNUE、局面表示与规则、引擎运行与通信。
先看整体:一次搜索在做什么
可以把搜索理解为不断重复下面几件事:
- 看当前局面是否已经触发胜负、和棋或重复局面等规则。
- 查缓存,看看以前是否算过,能否复用结果或好走法。
- 生成候选走法,优先试更有希望的,排除走后露将等非法着法。
- 走一步,换对方思考,再把结果传回来;算完后撤销这一步。
- 根据结果继续深算、补搜,或停止某些分支。
这只是帮助理解的流程概括,不是源码的逐行执行顺序。实际实现还会交错进行评估、剪枝和深度调整。
| 需要解决的问题 | Pikafish 使用的方法 |
|---|---|
| 双方都选对自己有利的棋,怎么比较? | Negamax 形式的 Alpha-Beta、主要变例搜索(PVS) |
| 时间有限,先算多深? | 迭代加深、期望窗口(Aspiration Windows)、时间管理 |
| 先看哪步,算过的能否复用? | 走法排序、历史启发、静态交换评估(SEE)、置换表(TT) |
| 深度到了,但吃子还没算完怎么办? | 静态搜索(Quiescence Search) |
| 哪些地方少算,哪些地方多算? | 空步剪枝、ProbCut、无望剪枝、LMR、IIR、奇异延伸等 |
| 象棋走法与棋规怎么处理? | 位棋盘与攻击表、合法性检查、将军/长捉/重复与限招判定 |
Minimax 与 Negamax:换个角度看分数
先假设所有分数都从红方看:越大越利红,越小越利黑。红方走时选最大值,黑方走时选最小值,这就是 Minimax(极小化极大)。引擎不能只看“我能吃到什么”,还要考虑对手会选最让自己难受的回应。
Negamax 把两套处理统一起来:分数始终从当前行棋方看,换一方就取负号。 如果走完一步后,对手看到的分数是 -80,对自己就是 +80。于是双方都只需“取最大值”,递归时写成 score = -search(...)。
这里的一层(ply)指一方走一步,红黑各走一步是两层。示例分数只用于说明比较关系,不代表某个棋子的固定价值。
Alpha-Beta:已经不会改变选择,就不必算完
假设红方已有一条路线,面对黑方的最佳回应也能得到 +50。再检查另一条路线时,只要发现黑方有一个回应能把分数压到 +30,这条路线就不可能比前者更好。黑方是否还能把它压到 +10,已不影响红方的选择,可以省下后续计算。
Alpha-Beta 用 alpha 和 beta 表示本次搜索关心的分数窗口:
alpha:需要超过的门槛;不超过它,就不能改善当前选择。beta:达到它便已足以让上层排除这条路线,无须继续求精确分数。
窗口是搜索要回答的问题,不是实际分数必然落入的范围。 在基本 Alpha-Beta 中,结果不高于原来的 alpha,叫 fail-low,得到的是上界;结果达到或超过 beta,叫 fail-high,得到的是下界。落在窗口内,才得到该深度、该评估方式下的确切值。允许返回窗口外分数的写法通常称为 fail-soft;这个分数仍可能只是界限。
只使用基本 Alpha-Beta,在相同搜索树和叶子评估下,可以得到与完整 Minimax 相同的结果。后面介绍的空步、无望剪枝和减深加入了经验判断,不能一概套用这个保证。
展开:一个只展示基本原理的 Negamax / Alpha-Beta 例子
下面是教学伪代码,不是 Pikafish 源码。terminal_score() 先处理终局与规则,包含象棋中“无合法着法判负”;分数从当前行棋方看。这里在深度用完时直接评估,实际 Pikafish 则会转入后文的静态搜索。
int search(int depth, int alpha, int beta) {
if (auto result = terminal_score(); result.has_value())
return *result;
if (depth <= 0)
return evaluate_side_to_move();
int best = -INF;
for (Move move : ordered_legal_moves()) {
make_move(move);
int score = -search(depth - 1, -beta, -alpha);
undo_move(move);
best = std::max(best, score);
alpha = std::max(alpha, score);
if (alpha >= beta)
break;
}
return best;
}负号不仅作用于返回分数,也会把子节点窗口变成 (-beta, -alpha)。INF 应选用足够大的安全常量,不能直接对最小整数取负,以免溢出。
PVS:其他走法先问“能不能更好”
PVS(Principal Variation Search,主要变例搜索)建立在好走法往往排在前面的基础上:在主变化节点,先用完整窗口搜索第一步,建立一个较可靠的候选;后面的走法先用很窄的窗口试探,问“它能不能超过当前最佳?”有希望时,再按需要用完整窗口补搜。
对于整数分数,(alpha, alpha + 1) 这样的窗口通常叫零窗口。它主要回答是否超过门槛,不能在这个窗口里求出一个严格介于两端的整数分数。
Pikafish 还把 PVS 与 LMR 减深组合起来:后面的走法可能先浅搜,表现好再补深。窗口宽窄决定要问多精确,深度多少决定往后看多远,这是两个不同维度。零窗口也不等于空步:零窗口照样走真实的棋,空步则会临时交换行棋方。
源码:PVS 与 LMR 的递归搜索。
迭代加深与期望窗口:先有答案,再逐步改进
皮卡鱼从较浅的深度开始,一轮轮提高目标深度。这叫迭代加深。浅层搜索得到的最佳走法、主变化、分数和缓存,会帮助下一轮更快找到重点;时间用完时,也有已经得到的结果可用。
在根节点,期望窗口会围绕该候选近期的平均评分先设一个较小范围。如果结果比预想的好得多或差得多,就扩大窗口再搜。当前代码还参考分数波动等信息,并非永远使用“上一轮分数加减某个固定数”。
所以,界面中的 depth 20 不代表每条变化都完整算了 20 层。减深、延伸和静态搜索会让不同分支实际走到不同深度。主变化(PV)是当前搜索给出的主要路线,也不是对手一定会照走的棋谱。
源码:迭代循环与窗口调整。
走法生成:列出候选,再检查能不能真走
生成、合法性检查和排序各管一件事:有哪些候选、能不能走、先算哪步。
生成器可按需要列出吃子(CAPTURES)、非吃子(QUIETS)、应将(EVASIONS)或全部伪合法走法(PSEUDO_LEGAL)。伪合法的意思是基本走法符合要求,但还可能走后露将;主搜索取出候选后,会调用 pos.legal() 检查。需要直接拿到完整合法列表时,LEGAL 生成也会进行过滤。
这里的 quiet 常译作“静着”,但它只是不吃子的走法,仍然可能将军。
象棋中特别需要处理的情况
- 炮:不吃子时沿无阻挡的直线走,吃子时必须隔一个炮架。被炮将军时,移走己方炮架也可能解将。
- 马与象:要处理马腿、象眼和象不过河;兵卒过河后的横走、将帅和士的九宫范围也由对应攻击规则限制。
- 将帅安全:走后不能让己方将帅受攻击,包括将帅照面。
- 多子将军:不能照抄国际象棋的“双将只能动王”。当前实现遇到多个将军棋子,会生成全部伪合法走法,再筛出合法应对。
在候选已符合基本走法的前提下,legal() 主要检查走后将帅是否安全;长将、长捉等依赖历史的判定由后面的规则逻辑处理。
位棋盘与攻击表:一次处理一组格点
位棋盘把一组格点表示成二进制位,例如“所有红子的位置”或“这枚马能攻击的位置”。交、并等位运算就能同时处理一批格点。Pikafish 使用 128 位位棋盘容纳象棋的 90 个格点,同时保留按格点查询棋子的数组。
车、炮、马、象等攻击范围利用预先建立的表来查询。普通编译路径用 magic 乘法和移位计算索引;启用 USE_PEXT 的路径使用 PEXT / PDEP 指令。具体采用哪条路径取决于编译版本,不能据此认定某种指令在所有 CPU 上都更快。
搜索走入一个分支时落子,返回时撤销。局面状态和哈希键随走法增量更新,省去每一步重新扫描整盘的工作。
走法排序与历史启发:先试最有希望的棋
好的排序能更早找到强着,让 Alpha-Beta 更快停止其他分支。常规主搜索中的取着顺序大致为:
- 置换表里留下的候选走法(TT move)。
- 经 SEE 等快速判断较好的吃子。
- 历史评分较好的非吃子走法。
- 较差的吃子。
- 较差的非吃子走法。
这是分阶段取着,并非开头就把所有走法完整排序;应将、静态搜索和 ProbCut 也有各自的流程。“较好”“较差”只是决定先后,并未证明某步成立或不成立;较差吃子仍可能被搜索。当前这份实现没有经典 killer move 的独立排序阶段,也不是“所有吃子都在所有非吃子之前”。
历史启发(History Heuristic)记录的是搜索中的效果。 某类走法经常带来好结果,之后就更早试它;反复表现差则降低优先级。它不是用户的对弈记录,也不是名局数据库。
代码同时使用主历史、吃子历史、连续着历史(结合前面走过什么)、兵卒结构历史,以及靠近根节点的历史。吃子评分会结合被吃子的价值与吃子历史;非吃子还会参考将军、躲避或进入低价值棋子的攻击范围等信息。
SEE:先粗算一下换子是否吃亏
SEE(Static Exchange Evaluation,静态交换评估)快速估计一个落点上的连续换子能否达到收益门槛:我吃过去,对方吃回来,我还能不能再吃,大致是赚是亏?Pikafish 的 see_ge() 回答的是“是否至少达到给定门槛”。
这个估计用于吃子排序,也用于部分剪枝。非吃子走法也能用它筛查“走过去会不会送子”。但 SEE 不是完整战术搜索,不能单凭它否定一个弃子进攻;远处的威胁、后续配合仍要靠搜索判断。
源码:静态交换评估。
置换表:算过的局面尽量复用
不同的走棋顺序可能到达同一局面。置换表(Transposition Table,TT)用哈希键查找先前的搜索记录,保存候选走法、深度、分数、分数的界限类型及静态评估等信息。
以下界限都以当次搜索条件为前提,不是完整博弈的数学证明。
| 记录类型 | 表示什么 |
|---|---|
EXACT | 在那次搜索条件下得到的确切返回值 |
LOWER | 下界:已知至少有这么好,常见于 fail-high |
UPPER | 上界:已知不超过这个值,常见于 fail-low |
只有深度、节点类型、界限等条件合适时,才可直接复用分数并停止搜索;否则,缓存中的走法仍可拿来优先尝试。EXACT 也不代表已经从数学上解出了这盘棋的最终胜负。
象棋的局面价值还会受走棋历史与限招影响。当前实现有相应的规则检查和部分缓存保护,不能简单认为“棋子摆放相同,任何历史下都可无条件使用同一结果”。
源码:TT 数据结构、主搜索读取与截断。设置内存大小可参见 Hash 说明。
静态搜索:不要在换子只算一半时停下
假设搜索刚好在“我吃了对方一个子”后到达目标深度。如果立刻打分,却没看到对方下一步能吃回来,就会误以为赚了子。静态搜索(Quiescence Search,qsearch)在常规深度用完后继续处理部分战术变化,减少这种“停在半截”的误判。
当前 Pikafish 通常继续搜索吃子;被将军时改用应将候选,不能只生成吃子。
- 未被将军时,可把当前静态评估作为 stand pat 的基准,再看值得搜索的吃子能否改善它。这是搜索中的近似,并不是允许实战停着不走。
- 被将军时,不能使用 stand pat,必须考虑应将;若无合法应对,就是败局。
- qsearch 自己也使用置换表、无望剪枝和 SEE 等筛选,并不会穷尽所有吃子或所有将军。
- 象棋中无合法走法,即使没被将军也是困毙判负;当前代码对这种情况也有检查,不能照搬国际象棋的困毙和棋。
“静态搜索”仍然在走棋递归;“静态评估”才是给当前局面打分,两者要区分。
源码:qsearch。
剪枝:把算力留给更可能影响结果的分支
下面这些选择性方法都能在当前 Pikafish 中找到。它们结合静态评估、深度、历史和分数窗口决定哪里可以少算。它们带有经验假设,可能误判;适用条件、余量和补搜用来控制风险。 表中只解释用途,不省略成“满足一句话就总会触发”。
| 方法 | 用直白的话解释 |
|---|---|
| Razoring | 静态分已经远低于 alpha 等条件满足时,转交静态搜索,检查眼前战术,而不继续原来的深层搜索。 |
| 反向无望剪枝(Reverse Futility) | 静态分减去余量仍足够高时,某些节点可提前返回,省去后续搜索。 |
| 空步剪枝(Null-move Pruning) | 内部假设自己让一手,浅搜后若仍够好,就可能无需把真实走法逐个算完。详见下文。 |
| ProbCut | 对有希望的吃子先做静态搜索,再按需做较浅搜索;若越过一个比 beta 更高的门槛,就可能提前停止当前子树。 |
| Small ProbCut | 利用置换表中足够深、明显超过额外门槛的下界,提前返回;不再重复完整的试吃子流程。 |
| 着法数量剪枝(Move-count Pruning / LMP) | 已经试了足够多候选且条件合适时,跳过后续非吃子走法;数量门槛会变化。 |
| 候选走法的无望剪枝(Futility) | 结合静态分、可能吃到的子力、历史和预计深度,跳过看起来难以追上 alpha 的候选。 |
| 历史剪枝(History Pruning) | 某些非吃子走法的连续着/兵卒结构历史太差时,不再投入搜索。 |
| SEE 剪枝 | 快速换子估计损失过大时,跳过某些候选;门槛随深度等因素改变。 |
这些判断会受节点类型、是否被将军、将杀分、子力和历史等条件限制,不能把每一项理解成适用于所有局面。
另外,**杀棋距离剪枝(Mate-distance Pruning)**利用当前距根节点的层数,收紧可能的杀棋分上下界,排除不可能改善杀棋距离的搜索。它属于分数界限推导,应与上面的经验判断区分。
空步为何有用,又会在哪里出错?
直觉是:如果我暂时不走、让对手先走,局面仍然足够好,那么正常走棋大概也不会差。引擎只在搜索内部做这个假设,空步不是象棋的合法走法。
问题在于,有些局面偏偏“轮到自己走就变坏”,也就是 zugzwang(楚茨文克)。所以当前实现限制空步的使用条件,例如未被将军、己方仍有车、马或炮等;较深的搜索还可能回到原局面做验证搜索。它不是固定少算两三层,也不能说验证后就彻底消除了风险。
减深与延伸:关键分支值得算得更深
剪枝是省掉某些搜索;减深是先少算;延伸是多算。 Pikafish 会根据搜索过程动态调整深度。
| 方法 | 当前实现的作用 |
|---|---|
| LMR(Late Move Reductions,后着减深) | 排在后面、希望较小的着法先浅搜;若结果超过 alpha,再视结果补深。减深量参考历史、节点类型等,也可能用于吃子。 |
| IIR(Internal Iterative Reductions,内部迭代减深) | 缺少 TT 候选等条件成立时,降低当前节点的搜索深度。这不是先做一轮浅搜找好棋的 IID。 |
| 奇异延伸(Singular Extension) | 暂时排除一个合格的 TT 候选,浅搜其余走法;如果其他走法都明显差一截,就给这个候选更多深度。 |
| Multi-cut 与负延伸 | 在上述排除候选的检查中,其他走法仍足够好时,可能直接截断;另一些条件下则减少 TT 候选的深度。 |
奇异延伸不是“只有一个合法走法才延伸”,也没有证明这步客观上是唯一好棋。当前实现还包含其他动态调整,不能概括为“每次将军或吃子都固定多算一层”。
NNUE 与评估修正:打分也会影响搜索方向
搜索经常需要一个不继续走棋的局面估分。Pikafish 调用 NNUE 评估,再结合搜索中的修正信息使用这个分数;它既帮助叶子评分,也会影响某些剪枝和深度分配。
**评估修正历史(Correction History)**会记录静态估分与后续搜索结果之间的偏差。代码按兵卒、其他子力布局和前序着法等信息组织这些统计,用于修正相关局面的静态分。它是搜索过程中的统计修正,不是边下棋边训练或改写 NNUE 权重文件。
因此,要把三件事分开:NNUE 提供局面估分,历史表帮助排序或修正,搜索比较后续变化。这几部分配合决定引擎把时间花在哪里。
源码:评估修正的计算与更新、搜索中的评估入口。
象棋规则:相同盘面,不一定是同一个问题
搜索中不仅要检查将军和无合法着法,还要处理长将、长捉、重复局面及自然限招等情况。相同盘面再次出现,不代表必然和棋。
Pikafish 的 rule_judge() 会结合走棋历史判定;实现先用快速过滤减少无关检查,必要时再沿历史追查并做更细的捉子判断。部分搜索中的重复情况还可能只提供分数界限,用来收窄窗口,而非立即结束搜索。
所以不能把这部分压成“重复两次怎样、三次就一律怎样”的通用口诀。棋盘上的走法是否合法、历史是否触发规则、搜索是否可以复用缓存,是相关但不同的问题。
多线程、MultiPV 与时间:怎样分配这次思考
多线程让多个工作线程推进搜索,并共享置换表;部分历史信息按 NUMA 节点共享。它不是简单把每层走法平均分成几份。线程间会有重复工作与信息互助,因此线程翻倍不等于深度或棋力翻倍。
MultiPV需要为多个根候选保留主要变化,涉及额外的根搜索。在相同时间内,显示更多候选可能降低单条主线的深入程度。它适合比较备选棋,但不是免费多显示几行结果。
时间管理先根据剩余时间、加秒、待走步数和 Move Overhead 等计算预算,再参考分数下降、最佳走法是否稳定等情况调整。固定时间、节点数等限制也会影响何时停止。迭代加深让引擎能在这些限制下逐步改进已有答案。
源码:线程启动与结果选择、时间预算、搜索中的时间调整。相关设置见 UCI 选项。
源码与延伸阅读
从哪里读 Pikafish 源码
以下链接全部固定到本文核对的提交,避免以后代码变化后行号对应不上:
search.cpp:迭代加深、PVS、各类剪枝、减深、延伸和静态搜索。movepick.cpp与history.h:取着阶段、排序评分与历史统计。movegen.cpp与attacks.h:走法生成及攻击范围。position.cpp:合法性、落子撤销、SEE 和象棋规则。tt.h、thread.cpp、timeman.cpp:缓存、线程和时间分配。
继续理解算法
Chessprogramming Wiki 适合查术语和通用思路;其中很多例子针对国际象棋。阅读时应区分通用搜索方法与具体棋规,Pikafish 是否实际采用某个方法,以本页对应源码为准。
