Alpha-Beta剪枝算法在直棋中的运用

游戏说明

详见本人的项目描述页面:https://gitee.com/liuweilhy/NineChess/blob/master/Readme.md
我做了个简单的GUI如下图。
Alpha-Beta剪枝算法在直棋中的运用
点击这里可以下载运行程序,有Windows x64和Linux x64两个并行版本。注意自行挑选最新的发行版。

棋类游戏的特点

像黑白棋、五子棋、象棋、围棋等棋类游戏,是典型的零和博弈:

  1. 信息完备,过程和结果都是确定性的,没有随机运气成分(不同于大部分牌类游戏);
  2. 双方对弈,轮流行动;
  3. 此消彼长,对一方有利的即对另一方有害,量化后的双方分数之和始终为0。

这类零和博弈,如果采用传统的递归回溯算法,其计算量是十分巨大的。如果对不必要计算的分支进行剪除,可以减少计算量。本文采用Alpah Beta剪枝算法来求解。

Alpha-Beta剪枝算法

关于Alpha-Beta剪枝算法,网上的教程很多,不再赘述。可以参照:
http://www.cnblogs.com/speeding/archive/2012/09/20/2694704.html
其实这个算法跟人类的下棋思路是接近一致的:

  1. 轮到我走,我肯定要走对我最有利的一步(但我不确定这一步对我是否有利);
  2. 如果我走这一步的话,对手肯定要走对我最不利的一步;
  3. 如果对手走了对我最不利的一步,我再走一步扳回优势的一步;
  4. …………
  5. 在演算过程中,那些我跟对手都不会走的“昏招”要排除掉;
  6. 假设我能算到第5步,我就知道第4步怎么走最合适,进而知道第3步、第2步,最终决定接下来第1步怎么走。

算法要点(认真看,就懂了)

  1. 节点node:代表选择出的最下层局面的评价(叶子节点值);
  2. 连线move:代表可选择的招法;
  3. 深度depth:将底层设为0,向上递增(当然也可以用顶层根节点为0,向下递增),越深则越容易得出优质的解,但更耗费时间;
  4. 节点和节点下的连线(招法)处于同一层中;
  5. 招法是自上而下的,但节点评估是自下而上的,所以只能深度优先;
  6. 对叶子节点的评价值,有利于先手的为正数,有利于后手的为负数;
  7. 每个节点都有一个alpha值和beta值,分别表示节点所在层可以最终选出的最大和最小评价值;
  8. alpha初始设为-∞,beta初始设为+∞;
  9. 对于Max层,不用计算beta值,因为先手不会选最小评价值;
  10. 同样,对于Min层,不用计算alpha值,因为后手不会选最大评价值;
  11. 对于Max层的节点,取值是所有子节点中的最大值(即先手要选择最有利于自己的局面);
  12. 同样,对于Min层的节点,取值是所有子节点中的最小值(即后手要选择最不利于先手的局面);
  13. Max层的节点在遍历子节点找alpha的时候,如果发现子节点中有大于beta值的节点,则放弃遍历。因为它的父节点在Min层,Min层节点不会选择一个大于已有节点的节点(即对手不会选择更有利于先手的局面),所有没有必要继续找下去,在此剪枝。这一步是剪枝算法的核心!
  14. 同样,Min层的节点在遍历子节点查找beta的时候,如果发现子节点中有小于alpha值的节点,则放弃遍历,在此剪枝。这一步同样是剪枝算法的核心!
  15. 简单来讲,alpha和beta值在初始化的时候,是父节点直接传递给子节点的;而子节点回传时,Min层节点将自己的beta值传给父节点,而Max层节点将自己的alpha值传给父节点;Min层父节点挑选回传值中最小的一个,跟当前层beta值比较,用较小值重新设置beta;Max层父节点挑选回传值中最大的一个,跟当前层alpha值比较,用较大值重新设置alpha;如果任一节点发现当前alpha>beta,则放弃遍历。

GitHub上有个算法过程演示,很容易理解:
https://github.com/aykamko/abTreePractice
Alpha-Beta剪枝算法在直棋中的运用

MiniMax搜索和NegaMax搜索

上面提到的算法,双方评估函数相同(利于先手为正、利于后手为负),但区分Max层和Min层,在Max层取alpha值,在Min层取Beta值,我们称之为MiniMax搜索。这种搜索方法比较容易理解。

value = alphaBetaPruning(p, alpha, beta, !who); //取子节点的值

另外有还有一种NegaMax搜索方式,双方评价函数不同(利于自己为正、利于对手为负),不区分Maxx层和Min层,全部取alpha值。这种搜索方法写起来更简洁,网上很多Alpha-Beta剪枝算法采用它,但它需要修改评价函数,使评价函数根据当前选手来定(先手不变,后手取反)。

value = -alphaBetaPruning(p, -beta, -alpha, !who); //取子节点的值

直棋中需要额外注意的要点

对于直棋(九连棋、成三棋、莫里斯棋)而言,有不同于常规Alpha-Beta算法的地方:

Max层和Min层不一定是严格交替分布的:
一方行棋成三后可能要继续执行去子,甚至还有九连棋这样闷死让行的,这会形成连续两层以上的Max或Min,也会有在同一层既有Max也有Min的情况
不过这不影响搜索算法,我们只需要在取值前确定这是谁的回合就好。

排序函数与出招的随机化

Alpha-Beta剪枝算法是从前向后深度优先搜索,越早发现最优节点,后面就会越早发生剪枝,进而节省大量时间。
如何将可能出现最优节点的招法放在前面,这里就需要对子节点进行排序了。(我的算法中,没有具体实现优化排序)
另外,排序的随机化,会使出招随机化,表现为AI不会每一局都出相同的招。

节点层次评估与迭代加深

在代码测试阶段,我发现当一方胜券在握的情况下,偶尔会进入循环僵局,类似于象棋中的“长将”。
分析原因:假设胜券在握的一方检索6层深度,它在第3层和第5层都发现了决胜招,不考虑随机出招的情况,算法会走它发现第一个决胜招法,而第一个决胜招法可能会不断加深,形成循环。
我们有两种方案解决这个问题:

节点层次评估

常规算法中,第3层和第5层的决胜局面评分是相同的。我们对评估值进行一个优化,减去深度值,使第3层的评分高于第5层的评分。这样,算法就会选择更短的决胜出招路径了。

迭代加深

不直接将检索深度设置为预置深度,而是从1层算起,然后2、3、4、5层……依次加深,如果找到决胜招数,就不再计算更深的层次了。
这个招法会有额外的计算开销(算第2层时,1层的局面要重新生成),但在终局阶段,由于其不再进行更深度的无谓检索,实际开销往往反而低于固定深度搜索。

等价局面与置换表

  1. 对当前局面的评估,仅仅与局面本身有关,与形成此局面的招法顺序无关,如下面的两种招法形成的局面是一致的:
    Alpha-Beta剪枝算法在直棋中的运用
    Alpha-Beta剪枝算法在直棋中的运用
  2. 对任一局面,它的镜像局面跟它的评价值是相同的,如:
    Alpha-Beta剪枝算法在直棋中的运用
  3. 对任一局面,它的内外层翻转局面跟它的评价值是相同的,如:
    Alpha-Beta剪枝算法在直棋中的运用
  4. 对任一局面,它的3个旋转局面跟它的评价值是相同的,如:
    Alpha-Beta剪枝算法在直棋中的运用
    对这类等价局面,重复搜索会浪费大量的时间。
    我们建立一个特定大小的保存了局面信息的Hash表,每次查找新局面时,都在表中查找有无等价局面,有的话直接取其值即可。这样就节省了重复计算的时间。
    Hash表使用C++11里的unordered_map。Hash的计算有待完善。

算法的缺点

  1. 计算量的大小严重依赖于着法的寻找顺序,但并没有足够好的排序算法能在很小的开销下实现最优排序。
  2. 非最终局面时的评价函数,往往是编程者的主观评价,并不严谨。评价函数直接决定了剪枝,有可能把最优的那一枝剪掉了。
  3. 因层深限制不能遍历最终局面时,Alpha-Beta剪枝算法并不能得到最优解。

对于围棋,如果采用Alpha-Beta剪枝算法,计算量是巨大的,尤其是开局和中局这种空位置很多的阶段(不考虑开局库),所以能算到的深度很小(恐怕连10层都没有)。
人类在这种大广度的游戏中,往往可以靠经验而非计算,来剪掉大量的“枝”。在这种情况下,经验(开局库)往往比算法更具优势。
在《天龙八部》中,无崖子所创珍珑棋局,天下高手无人可破,却被虚竹随便**。为什么?因为虚竹下的是明显的“昏招”,评价函数返回了一个很大的负数,所以高手们都不会选的。只有计算的深度再增加几层,算法才会发现这步“昏招”其实是评分为正数的“妙招”。
这就是在神经网络算法的AlphaGo面世前,所有采用Alpha-Beta剪枝算法的AI都难以在围棋上战胜人类大师的根本原因。

我的算法实现

作为一个非IT界的外行人,我的Alpha-Beta剪枝算法写得一般般,勉强能用吧。
源码相见我的NineChess项目

  • NineChess:棋的模型类,用标准C++编写,适用于各个编译平台;
  • NineChessAi_ab:Alpha-Beta剪枝算法类,也用标准C++编写,适用于各个编译平台;

我的剪枝算法暂未实现招法优化排序,置换表因效率问题取消掉了,所以算力有限,耗时较长。以后有有时间再继续改善吧。