评估函数
与人类的思维相同,程序在决策每一步棋时应该评估所有落点的优劣,选择最优的一步棋,因此我们需要为程序制定一套评价标准(评估函数)。每当程序执子时,它将依据这个评估函数做出决策。设计评估函数并没有固定的方法,这里采用一种比较简单的方法:五元组评价。
对于每一个落点,它只会影响包含它的五个连续的位置,因此我们将棋盘行、列、斜线方向分成若干个五元组。五元组的个数等于所有五子连珠情况的个数。五元组被表示为一个长度为5的坐标数组,其中坐标为row * BOARD_SIZE + col
。
在一个五元组中,当己方棋子越多且连续,胜率越大,我们为五元组的多种情况做以下规定:
- 五元组中有任意个对方棋子,得分为 0
- 五元组中没有己方棋子,得分为 0
- 五元组中有 1 个己方棋子,得分为 1
- 五元组中有 2 个连续己方棋子,得分为 20
- 五元组中有 3 个连续己方棋子,得分 600
- 五元组中有 4 个连续己方棋子,得分 4000
- 五元组中有 5 个连续己方棋子,得分 1000000
计算单个五元组中棋子chessType
的评分如下:
1 | std::array<int, 6> shape_score{0, 1, 20, 600, 4000, 1000000}; |
对每一个落子进行评价时,有两种思路:
- 对落子后的单个落点进行评分。即在该落点落子后,计算包含该落点的所有五元组的评分。
- 对落子后的整个棋局进行评分。即在该落点落子后,计算整个棋局所有五元组的评分。
显然第二种方式更合理,因为它更具有全局意识。另外,在评估棋局时我们要攻防兼顾,因此将全局的评估函数设为己方评分与对方评分之差。 $H$ 表示全局评分值,$S(x, y)$ 表示 $(x, y)$ 处棋盘的评分,即五元组评分之和。
在计算所有五元组的评分时,我们不应该遍历每一个位置的五元组,因为一个五元组包含五个位置,这样会导致4倍多余的计算。本程序实现的思路是将所有五元组放在一个容器中,而每一个位置仅保存所有包含该位置的五元组的索引(哈希)。在计算评估函数值时,遍历容器中的五元组;检查落子是否形成连珠时,也能方便的搜索落点的五元组,并有利于后续优化。
标识单个五元组的索引类似于向量表示,将五元组两端的坐标移位拼接成一个数。
1 | int Gobang::fiveTuple_index(const FiveTuple& fiveTuple){ |
贪心搜索
设计完评估函数,我们可以实现一个基于贪心搜索的初级版本。其思想是搜索所有空余落点,找到评价函数值最大的落点,在该处落子。
1 | int Gobang::greedy_select(){ |
该算法可以计算出下一步所有落点的评估函数值,但在现实对弈中,仅考虑当前一步时远远不够的,有下棋经验的人都会或多或少向后推演几步,因此,我们需要增加它的搜索层数。
局部搜索
考虑新算法前,先进行一次必要的剪枝操作。在选择最佳落点时,玩家往往只用考虑已下棋子周围的落点,而非遍历整个棋盘。我们维护一个openlist
,用于存储已下棋子周围的位置,下完一步棋,就将其继续扩展。
openlist
被定义为std::set<int>
,该函数扩展openlist
,并返回扩展前的openlist
,用于后续的回溯。
1 | std::set<int> Gobang::extend_openlist(int xpos, int ypos){ |
MinMax算法
贪心搜索具有一层的搜索深度,如果要搜索多层,需要考虑和模拟对方的落子,即搜索对方可能落点,并依据评估函数进行决策,但在考虑己方和对方的落子时存在区别:
- 当己方落子时,总是考虑局面对自己最有利的情况,即选择评估函数值最大的落点
- 当对方落子时,总是考虑局面对自己最不利的情况,即选择评估函数值最小的落点
如果把每一个可能的落子情况视为一个树节点,树的根节点为开局(或搜索开始时)棋局,那么当游戏参与者交替行棋,树的每一层节点选择子节点的行为不同:当前层为我方落子时,选择子节点中评估函数值最大的;当前层为对方落子时,选择子节点中评估函数值最小的。这种树称为博弈树,也称为Min-Max树。
以井字棋(“三子棋”)为例,假如评估函数被设置为“X”(我方)胜利为1,“O”(对方)胜利为-1,下面详细展示了一棵三层的博弈树状态。
Max层节点的值取子节点的最大值,Min层节点的值取子节点最小值,叶子节点的值为评估函数值。树搜索过程为DFS,一旦到达搜索深度或者已经出现胜利(或失败)棋局则返回上层,停止搜索。另一个详细的MinMax搜索过程如下图:
实现MinMax算法后,程序能够做到向下搜索3层,但随着搜索层数的增加以及棋局向后发展,棋局状态数即节点数是呈指数增长的,程序运行时间也会越来越久。
alpha-beta剪枝
为了提高搜索效率,MinMax算法总是离不开一种剪枝策略,即 $\alpha-\beta$ 剪枝。仔细观察博弈树,可以发现在搜索过程中我们不需要遍历所有节点,当无法从某条分支上获取更优的值时进行剪枝。
如图,根节点为Max节点,它会选择子节点中的最大值,因此当搜索完左分支后可以确定根节点的值至少为 -13,即下限 $\alpha$ 为 -13。当右子节点搜索完它的左分支后已经获取了一个值-75,由于右子节点为Min节点,它会选择子节点中的最小值,那么右子节点的值小于 -75,即上限 $\beta$ 为 -75。此时右子树已经确定不会被选择,所以不会再去搜索它的右分支。
$\alpha-\beta$ 剪枝算法有多种形式化表述,这是我认为最详细的一种:
- 初始根节点 $\alpha = -\infty$ ,$\beta = +\infty$
- Max层节点 $\alpha = max(\alpha, \text{所有子节点评价值})$ , $\beta$ 等于父节点 $\beta$
- Min层节点 $\alpha = min(\beta, \text{所有子节点评价值})$ , $\alpha$ 等于父节点 $\alpha$
- 当某个节点 $\alpha > \beta$ ,停止搜索该节点的其它子节点
实现 $\alpha-\beta$ 剪枝算法后的MinMax搜索如下:
1 |
|
剪枝后,搜索时间已经大大减少,VS优化代码后,搜索3层所用时间基本在200ms以内。至此,我们已经实现了一个具有一定棋力的五子棋AI,但是和有一定水平的玩家相比,仅搜索3层是远远不够的。后续的内容将继续围绕如何优化搜索以减少搜索时间、提高搜索层数展开。