五子棋 AI:算杀层与搜索层怎么分工

纯 alpha-beta 在五子棋上会漏掉必杀。我把决策拆成三层——成五、堵五、算杀各司其职,剩下的才交给迭代加深加置换表,快棋也能下出像样的棋。

五子棋 的 AI 一开始只有一个 alpha-beta 搜索,深度开到 4 层,表现却很糟:对手摆出一个活三它不管,自己有成五的机会有时候也不走。原因是搜索深度不够时,估值函数把「三步之后必输」看成了普通局面。

解决办法不是把深度加上去,而是把可以确定的事情从搜索里拿出来。

三层决策,顺序不能换

每走一步,AI 按固定顺序过三道闸:

顺序 判断 处理
1 我能成五吗 直接走,不搜
2 对手下一步能成五吗 堵,且优先选「堵了之后对手造不出活四」的点
3 我能造活四吗 / 对手能造活四吗 走 / 破,不搜
4 以上都不成立 交给 alpha-beta

前三条叫算杀层。它们的共同点是:结论由棋形唯一确定,跟后面怎么走无关。这类手交给搜索是浪费——搜索还要花节点去「验证」一个已经确定的事实,而且深度不够时验证不了,反而出错。

搜索该用来处理不确定性,不该用来处理已经确定的棋形。

活四为什么必须单独判

「活四」是两头都能成五的四连(.XXXX.)。一旦有一方摆出活四,另一方就输了——对手只有一个子,堵不住两头。

第三层如果只看「我有四连了吗」,会把冲四(一头被堵的四连)也算进去。冲四对手一堵就没了,走它等于白送一手。所以判据是棋形字符串:011110 是活四,011112 或 211110 是冲四。

我对每一条直线(横、竖、两个斜向)取 9 格窗口,转成 0/1/2 的串再匹配:

const PATTERN = {
  FIVE: 1e7,
  OPEN_FOUR: 3e5,
  FOUR: 2.6e4,
  OPEN_THREE: 2e4,
  THREE: 1100,
  OPEN_TWO: 600,
  TWO: 90,
  ONE: 6,
};

这些数字不是随便定的。它们要满足两条:成五压倒一切(1e7 比所有别的加起来还大),以及活四必须高于「两个活三」的和(3e5 大于 2e4 × 2)——否则 AI 会拿一个必杀的活四去换两个活三。

搜索层的三件常规武器

第 4 层才是真正的搜索,用了三个标准件:

  • 迭代加深:从深度 1 往上加,每层都留一份结果。时间到就用最后一层搜完的结论,不会半途而废。
  • 置换表:局面用 Zobrist 哈希做键,存深度、分值和一个界限标志(精确值 / 只知上界 / 只知下界)。不同路径走到同一个局面时直接取用。
  • 候选裁剪:只搜已有棋子附近的位置。棋盘 225 格,真正值得考虑的一手通常不到 20 个。

还有一个容易忽略的细节:时间预算要在搜索内部检查,不是在外面设 setTimeout。我每搜 1024 个节点看一次时钟,超时就置中止标志,逐层原路返回。否则一层搜到一半被打断,拿到的分值是不完整的。

难度就是时间预算

五档难度不是五套代码,是同一个引擎的五组参数:

难度 深度 时间 扰动 候选宽度
1 新手 1 90ms 0.9 8
2 入门 2 200ms 0.5 10
3 进阶 4 450ms 0.18 12
4 困难 6 900ms 0.04 14
5 大师 8 1800ms 0 16

低难度的「扰动」是在靠前的候选里随机挑一个——纯粹的弱化靠减深度效果不好,因为算杀层还在,新手档照样能一步成五。加噪声才有「这档子会犯错」的感觉。

让 AI 把想法说出来

调优时最麻烦的是「它为什么走这一步」。加搜索迹不难:搜完把每层的深度、最佳手、分值记下来,根节点再存前几个候选的明细。

真正花了点功夫的是分值。alpha-beta 返回的大部分是边界值——「不会比 X 好」——直接拿来展示,一排候选会显示成一模一样的数字,看着就像坏了。所以用户主动点开工具树时,根层每个候选改用全窗口搜一遍,换到真实分。代价是慢一截,但只在有人真想看的时候付。

给用户看的数字必须是真数字。用边界值凑出来的「过程」比没有过程更糟。

一点体会

这个 AI 现在的棋力大概到「会把活三当回事」的程度,离真正的强引擎还差得远(没有 VCF/VCT 搜索,没有启发式评估的学习)。但对一个网页小游戏来说,算杀层带来的提升远比把深度从 4 加到 8 明显——因为决定胜负的往往就是那几步一口气。

← 返回文章列表

评论

…