NoGo Bot

不围棋(NoGo)是一种在 \(9 \times 9\) 的棋盘上进行的一种棋类游戏,游戏下子限制为禁止在棋盘上产生围棋中的吃子,最终无法行动的一方失败,另一方获得胜利。详见 Botzone 上的 NoGo

= = 这个东西作为 AI 大作业就意味着要……爆肝……然后我把我的 Documentation 粘在下面了……

还是挺高兴的,第一次写出了 UCT,打爆了上次想写却没写出好的五子棋 AI 的我。

Abstract

Bot 是一个主体采用了 Alpha Beta 优化的最大-最小搜索和上限置信区间蒙特卡洛树搜索(Upper Confidence Boundary Applied to Trees, UCT)的 NoGo Bot。Alpha-Beta 搜索的部分采用了可行步数差来评估局面好坏,应用了 Pattern Match 等评估方法为 Alpha Beta 优化顺序、实现剪枝、作为第二关键字为搜索结果排序,并实现了自适应搜索深度以适应前后期可达深度不同导致的时间利用不充分的问题;UCT 部分使用了经典 UCT 的实现,并且通过估价值限制一定的随机性来提高模拟的有效性,并且每一步初始时利用已有知识初始化了胜率。通过前后期算法的分治,总体胜率能达到第一梯队水平,并使得即使贪心策略被克制的情况下,仍有着不低的胜率。


Algorithm Analysis

在下面的描述中,我们令 \(N = 9 \times 9 = 81\),为棋盘大小。

作为一个完全知识博弈游戏,NoGo 游戏显然存在必胜与必败策略。然而,基于我们的算力远无法遍历 \(O(3 ^ {N})\) 种局面,亦无法遍历 \(O(N!)\) 种合法棋局,我们只能通过剪枝、估价和随机模拟的方法来实现,这在 Bot 中全有体现。

我们通过多局游戏后可以发现开局局势非常简单,此时几乎靠估价函数来进行布局——多产生「眼」,也即最简单的产生可行步数差方式,并且抑制对方产生「眼」,利用 Pattern Match 以及类似的其他方法可以很方便地求得这样的点。当然,不同的估价函数——无论怎么调参——得到的答案可能会存在「循环克制」的问题,Bot 在后面会有针对这种情况进行优化。

到游戏中期时,局面已经变得比较复杂,简单的估价已经无法很好地求得答案了。此时 Alpha-Beta 搜索派上了用场。通过对后几步的预测,Alpha-Beta 可以很轻松地避免因贪心导致的短视,亦可以稳健地在对手出现失误时获得优势。事实上,Bot 的开局也综合估价函数来应用了 Alpha Beta 搜索来避免被估价克制和压制棋力非常弱的对手。

可以发现,随着深度限制 \(k\) 的增长,搜索完第 \(k\) 层的时间 \(t(k)\) 约是 \(O(s ^ k)\) 的,\(s\) 为可行步数。注意到有 \[(\sum_{i = 1}^{k-1} t(i)) < t(k)\] 其中 \(t(k)\) 为搜索完第 \(k\) 层的时间。所以,Bot 采用了迭代加深来进行搜索,充分利用了时间限制。

不过,因为前期通常来说只能搜索出约 \(3\) 层的结果,Bot 前期使用了剪枝,可以在第一步时达到 \(6 \sim 7\) 层。

到游戏偏后期但 Alpha Beta 仍搜索不到游戏终结的时候,由于单纯的评价可行步数差效果很差,搜索里可行步数差相同的不同结果可能会导致不同程度的类似「奇数步赢偶数步输」的情况,单纯应用 Alpha Beta 搜索容易盲目地将自己的优势葬送掉。

UCT 的一次 Simulation 的复杂度为 \(O(Ns)\),其中 \(s\) 为可行步数,前期在 \(1s\) 的时限下只能进行 \(O(10 ^ 2)\) 次模拟,远远不能满足算法准确性需要。然而,注意到中后期后继状态不算多了,Simulation 也能运行约 \(O(10 ^ 3)\) 次,评估胜率也显得比较准确了,因此 Bot 此时采用了通过估价值限制一定的随机性以提高模拟效率的蒙特卡洛搜索来进行 UCT 搜索以在中后期维持并获得优势。

到游戏终局时,胜负已可以轻易判定。为了方便评测,Bot 用 Alpha Beta 来以非常短的时间做出每一步的决策。

这里说一下在 OJ 上的 \(261\) 个版本里出现过的但是最终被抛弃掉的部分尝试:

  1. 在 Alpha Beta 到深度限制时采用多元化估价以替代单一的步数差的估价。这样看起来很优秀,甚至可以避免出现前述缺点,但实际上这样严重依赖于估价函数的准确性以及和其他 bot 的克制性,效果很差。
  2. 在 Alpha Beta 估价时使用 \(O(N ^ 2)\) 的试下策略(Dot Evaluation)来优化贪心函数的短视。这样看起来很棒棒,但是牺牲的复杂度实在是太高,最终极大影响到了搜索的深度,因此最终被舍弃。
  3. 限制 UCT 模拟步数,到达指定深度时用 \[r = \frac {1 + e ^ {-k}} 2 \times D ^ s\] 来预测胜率,其中 \(k\) 为步数差,\(s\) 为任一玩家剩余可行步数,\(D\) 为衰减常数,\(\sqrt[81]{0.1} < D \leq 1\)。
    这样做效果确实不错,OJ 上上传的 IG.TheShy(223) 就是这么做的。然而,为了算法的纯粹性(强迫症),以及为了使 UCT 更稳定,Bot 没有采用这一做法。(然后因为这个原因 Bot 最终被吊打了)

Implementation Introduction

为了尝试使用 OOP 实现程序,Bot 中所有用到的结构体全部进行了封装,模块化实现功能,提高了程序的可拓展性与鲁棒性。这在 Bot 的开发中得到了体现:迭代升级的过程非常流畅,未曾需要对主体程序进行重构。当然,因为存在部分冗余函数,并且需要参数传递,程序相对冗长、常数较大是不可忽视的缺点。

局面使用了 Board 来保存;UFset 是并查集;Point 是用来替代 std::pair<int, int> 的传递坐标的位置,实现了和 pair<int, int> 相互的类型转换;策略保存在 Alpha_BetaUCT 中,两个类都有一个 public 函数 Action()

Board

判断一个局面是否可行的函数 Board::valid() 使用的是随机合并并查集来实现的,复杂度为 \(\Omega(N) \sim O(N \log N )\)。众所周知,判断气数是否合法完全可以使用 Flood Fill 实现,复杂度为 \(O(N)\),看似更优,实则不然。首先,无论是 DFS 还是 BFS 都会用到速度较慢的数据结构,包括栈和队列,常数很大;其次,\(M = 4 N\),边数本身就有大常数。而并查集由于大量的合并操作以及随机合并顺序导致 \(O(N \log N)\) 是非常松的上界,况且其 \(\log N \approx 6 \( 本来就非常小(可看做常数),并且 f[x] = getf(f[f[x]]) 利用寻址比递归快的特性跑得比西方记者快得多。因此,并查集运行的实际效果远优秀于 Flood Fill,甚至优秀于严格 \(O(N \alpha(N))\) 的按秩合并路径压缩并查集。在之后的描述中,我们将 Board::valid() 的复杂度称为 \(O(N)\)。

一个非常常用到的操作是 Board::getValidMoves(),也寻找一个局面下的可行操作。常规的做法是使用 \(O(N ^ 2)\) 的复杂度,进行试下并用 Board::valid() 判断。显然,这个复杂度是不优秀的。Bot 里实现了一个 \(O(N)\) 的 Board::getValidMoves(),使用对联通块的气计数的方法来优化判气复杂度。当然,由于我们两部分搜索都会用到对估价值进行排序,实质上调用一次 Board::getValidMoves() 复杂度是 \(O(N \log N)\) 的,不过由于 \(N\) 很小,并且其余部分函数本身常数就不小,因此这个 \(\log\) 是可以接受的。

Evaluation

对于评分系统,我们主要介绍 Pattern Match。Board::EyeDetect 实现了对 Pattern 的处理,Board::EyeDetect::eval() 通过枚举每一个位置可能成为一口气的方法,来对开局进行比较有效的估价。

我们通常都不会优先去走只有自己能走的点——那样通常会降低自己占气的优势。因此,Bot 针对这种情况进行了判断,对敌人能走的点提高优先级。

以人类智慧设置的参数显然并不一定准确。为了提高评分准确性,Bot 在本地通过模拟退火算法进行了 \(O(10 ^ 4)\) 局左右互搏的游戏,获得了一个效果更好的参数——当然,由于最初退火时没有客观的 UCT 版本对打,最终参数有过拟合的趋势,最终没有采用退火结果。

Alpha Beta

Alpha-Beta 实现部分通过评分系统加扰动来确定搜索顺序,使得搜索有随机化,并使 Alpha-Beta 剪枝更有效,并间接实现了使用贪心值作为二关键字排序,估价相同时默认保留第一个结果,自适应地完成了前中期的交替。

在对选择进行剪枝时,我们可以发现:尽管我们自己的选择从来都是估价函数得到的结果中的前几个,但敌人很可能选择出乎意料的走法。我们知道估价函数是不准确的,所以我们搜索时可以默认敌人比我们更
「聪明」。因此,Bot 的剪枝的限制对于敌人更加宽松,而对于自己的选择则适当收紧,这样在损失极少正确性的情况下 Bot 可以使搜索获得更深的深度。

UCT

UCT 实现很常规,探索常数按论文取到了 \(C= 2\)。说几点优化:
1. 预处理了所有有关 sqrt, log, exp 的函数;
2. 使用估价函数加大扰动来实现一种「总体而言,更倾向于采用估价函数大的一步下的随机方法」的模拟,提高了模拟效率;
3. 每一步初始时利用已有知识初始化了胜率;
4. 在 IG.TheShy 等版本中采用了前述公式来减少模拟步数,预测胜率;
5. 随机数发生器采用了 xor-shift-128+,复杂度低且随机性好。

Algorithm Switcher

当可行选择数在 \((L, R)\) 之间时调用 UCT,否则调用 Alpha Beta。在提交版本中,\(L=9,R=27\)。

One more thing…

有时,搜索程序会返回必败,此时搜索会无返回结果。秉承着永不言败的精神,Bot 将会返回一个最有希望的合法点,以期望对手下错来赢得该局游戏。

Code

调用 init() 进行初始化,调用 GetUpdate() 更新敌方策略,调用 Action() 来进行决策。

 

1 comment

发表评论

电子邮件地址不会被公开。 必填项已用*标注

ˆ Back To Top