【五子棋AI循序渐进】——开局库
首先,对前面几篇当中未修复的BUG致歉,在使用代码时请万分小心…………尤其是前面关于VCF\VCT的一些代码和思考,有一些错误。虽然现在基本都修正了,但是我的程序还没有经过非常大量的对局,在这之前,不打算再发整体代码了。简要说一下现在的情况:
就棋力来说,不加开局库我也下不过它了,先别管它到底怎么样,至少说明它的水平比我的提高的多。不过记得有人有这样的论点:棋类AI的水平很大程度上决定于作者棋艺。应该主要指知识方面的代码吧。也不是很同意,个人觉得还是作者对AI工作原理和棋类特点的理解。
就使用的技术来说,主要就是这么几点:
一、局面
0、冲棋点搜索:取代历史表、利用1、2易于判定组合棋型
1、棋盘表示:可成五向量、4进制位棋盘
2、模板表示:从5长度到15长度的模板、并由代码计算全部棋型
3、冲棋值表:记录单独、复合棋型,实现“段排序技术(实现上无需使用排序代码提高效率)“取代历史表排序
4、双层置换表:即双置换表技术
5、局面分段评价:基于冲棋点搜索、”段排序技术“,可以只访问棋盘上空点,效率比较高。
6、招法生成器:这其中包括5种它们都是基于冲棋点搜索、“段排序技术”,只生成“必要”的走法,是提前剪枝的重要手段。
这5种生成器分别用于PVS,VCF进攻/防守,VCT进攻/防守的走法生成。
7、低冲棋值招法找回启发:弥补招法生成器提前剪枝对低冲棋值招法的忽略。这一技术在找回重要的低冲棋值招法时不产生额外计算,所以效率很高。
二、搜索(其实按下面的不是很科学,因为剪枝和VCT/VCF之间存在复杂的相互作用)
1、最底层引擎:
A、超出边界的α-β剪枝
B、置换表启发
C、内部迭代加深启发
D、迭代加深
这里没有使用冲棋延伸、静态搜索等技术。其原因主要是VCT/VCF的作用和速度问题。基于同样的原因,还没有给引擎增加做VCF/VCT的功能,所以现在的攻击性仅限于冲棋点搜索本身的浅深度攻击;而防守方面,仅限于浅层VCF/VCT搜索。这一切的一切都是VCF/VCT尤其是VCT函数做的孽……
2、中层引擎
A、无遗漏的全局VCF搜索
B、有遗漏的全局VCT搜索
这里使用独立的交叉递归函数和走法生成器,VCF进行全搜索,VCT进行部分搜索。即使进行部分搜索,VCT也非常慢,因为空间复杂度太高,后面打算使用类似于五子棋终结者的方式把搜索点按“段排序技术”所得到的段逐步扩大搜索,以期得到更好的效率。
3、开局库搜索
A、压缩存储:约200万局面只存储了1.3m的空间,对等局面采用7种旋转合为一个。
B、VCF/VCT发现:黑方达到可VCT/VCF时调用VCT/VCF搜索函数
C、最强防:白方查找最强防,以期脱谱获胜。(木有办法,地毯普是执黑必胜普,说白了就是延长黑方胜利步数等弱防或者错招从而正确脱谱)
下面才进入这次的主要问题,开局库(额,也许五子棋这么叫不是十分合适了,就沿用这个叫法吧),程序不准备带单独的开局库,因为一方面没有什么装配件就一EXE,更主要的就是现在地毯普网上很好找到全面地毯,而且都已经给搜索到可以VCF/VCT的程度了,根本没有自定义的必要。制作开局库的思路:
1、网上搜集比较全面的地毯普,合并,精简其对我们无用的各种说明,只留下棋盘文字信息并统一格式,一般有123,abc,ABC等表示,自己统一一下就行了。
2、将其保存,因为使用了renlib的文件格式保存,所以接下来的做法有2种,第一种可以另存为文本棋盘,然后解读,这个很好解读,但是需后期统计或人工加工招法好坏;第二种解析.lib文件格式。我使用的是第二种方式,因为在renju的网站上可以下载到这部分的源码(C语言的吧,额看起来还好,只是我打开编译不了……还有很多默认int类型不支持,我去~~~~~),现在应该是3.6的,很容易能够看出.lib文件的组织方式:第一字节是位置,它的横坐标是从1开始(应该是为了代码里面0坐标作为非法坐标,我的程序里是把&HFF作为非法坐标使用),第二字节是说明,在其const中说的很清楚(额,就这么认为吧,注意一下right,down的实际意义也我英语不好也不好妄加评论),简单的看一下那几个Is........函数就明白是用位保存信息了。
3、设计树的保存结构。一个好的结构可以用尽可能少的空间保存尽可能多的信息。其实这一步我没有做什么,程序延续了.lib程序第一字节为招法坐标(但是修改为横坐标0开始),将后续字节压缩为1字节,其中只含有走法好坏程度、是否有右侧节点,是否达到叶节点信息。为了进一步减少程序大小,把得到的开局库用gzip压缩一下,得到了一个1.3M的开局库,其中有近200万局面。
4、树的恢复:因为保存了是否有右侧节点、是否是叶节点信息,所以树很好恢复,只需要准备一个stack,遇到一个有右侧节点的进一个,遇到一个前一个节点是叶节点的出一个并连接。这就可以把树组织好,但是由于这种结构怎么说呢,我用起来不顺手,于是在读的时候,顺便确定了每个节点的父、全部子、左、右节点,搞得四通八达,可后来看来看去,占用内存太多,只保留了全部子节点,其他信息都去掉了,也就是说,在下面的示范代码中,只知道一个节点的全部子节点(其实和没改没啥太大区别,知道一个节点的第一个子节点和子节点的右节点……的右节点………………的右节点是一个事,几种表示和分散表示的差异而已,不过还是这样顺手啊)。
5、树查询:因为我们已经知道一个节点的全部子节点,所以可以很容易的按层查询。
6、对等局面查询:因为开局库当中对旋转0、90、180、270度,X、Y、两对角线对称这8中情况进行合并,统一用旋转0度局面表示,所以查询时需要旋转及对称。而有些不规范的表示方法(这里特指不按下子顺序记录)需要进行列出各种排列情况逐一搜索(这不在我们应该支持的范围内)。然后就是旋转或对称之后查询结果的逆转换,这个应该很简单,旋转的继续,对称的再对称就行了。
下面列出一些源码和其中使用到的小技巧:
首先,解决旋转和对称这里,逐个if 或select比较烦,所使用委托数组:
Private Delegate Function ConvertPoint(p As Byte) As Byte Private ConvertFuns() As ConvertPoint = New ConvertPoint() {New ConvertPoint(AddressOf RotatePoint0), _ New ConvertPoint(AddressOf RotatePoint90), _ New ConvertPoint(AddressOf RotatePoint180), _ New ConvertPoint(AddressOf RotatePoint270), _ New ConvertPoint(AddressOf vFlipPoint), _ New ConvertPoint(AddressOf hFlipPoint), _ New ConvertPoint(AddressOf dlFlipPoint), _ New ConvertPoint(AddressOf drFlipPoint)}
其次,解决转换函数调用优先度问题,因为同一局面而言,一般说来,分支多位于相同转换下,这样有一个优先度设计的话可以更高效。程序的解决办法是让上次适用的转换下次第一个被调用。其实这实现起来很简单:
Private CallSequence As New List(Of Integer)
在查找到某个局面的时候,这样操作:
ElseIf (SearchResults And InVCSPos) = InVCSPos Then '局面进入黑方VCT/VCF '因为找到的是一个index,所以只要记录转换顺序表就可以了。 CallSequence.Remove(j) '把找到结果的转换方式放在最后,下次查找时第一个调用它。 CallSequence.Add(j) '逆转换表也要进行同样操作以保证一致性 InverseConversion.Remove(j) InverseConversion.Add(j) Return SearchResults
于是,上次被调用的就在list的最后一个元素了。。。想先调用它很简单啊,遍历的时候倒着…………
'逐个测试转换 For j As Integer = CallSequence.Count - 1 To 0 Step -1 '按照转换函数优先顺序依次调用它们
上面代码还涉及到了逆转换List,它和转换list进行同样的维护,因为程序只在new函数中初始化它一次:
'初始化逆转换表 InverseConversion.Add(0) '未旋转的逆转换就是自身 InverseConversion.Add(3) '旋转90度的逆转换是继续旋转270 InverseConversion.Add(2) '同上 InverseConversion.Add(1) '同上 InverseConversion.Add(4) '对称的逆转换就是自身 InverseConversion.Add(5) '同上 InverseConversion.Add(6) '同上 InverseConversion.Add(7) '同上
其实单独看,这个逆转换list是不必要的,我们只需要记录一个值就可以了,但程序中它还有其他作用,而且这样逻辑上也更一致。
接下来贴一下degzip的代码,网上有很多不太对头的……而且微软示例里面对+100也没什么解释。
'解压缩文件,因为T14.ZOB是使用GZIP压缩的,所以要把它解压之后才能分析关系树进而初始化开局库。 Private Function zobDeZip(data() As Byte) As Byte() Dim ret() As Byte '返回值 Dim sms As New IO.MemoryStream(data) '未解压的数据流 Dim gzip As New IO.Compression.GZipStream(sms, IO.Compression.CompressionMode.Decompress, True) Dim ms As New IO.MemoryStream '解压后的数据流 Dim DeZipBuffLen As Integer = &HFF '每次解压的字节数 Dim buf(DeZipBuffLen - 1) As Byte, i As Integer = 0 '解压缓冲区 While True '循环解压 i = gzip.Read(buf, 0, DeZipBuffLen) If i = 0 Then Exit While ms.Write(buf, 0, i) End While ret = ms.ToArray '返回值 sms.Close() '清理工作 ms.Close() gzip.Close() Return ret End Function
然后就是扫描,简单的循环嵌套就不说了,无非是从给定走法路线上摘下来一个,找到子,找不到的就是脱谱,找到叶节点的就是正在被黑棋VCT/VCF,处于枝干的就是走法了。对于黑棋根据好坏评价信息随机选取一个走法,对于白棋,最好是找到比较长的局面,因为短的黑棋出错的几率很小啊,VCT/VCF再很强,不如直接认输……但是程序没有这样做,也许知道的太多也不是一件好事。。。。例如对方眼抽筋……手抽筋……脑抽筋……各种抽筋都可能导致翻身嘛…………有兴趣的可以看看关于残局库的一些讨论。所以白棋这里,程序采用了一个小递归,来统计叶节点出现的深度:
Private Function GetLeafDepth(CurNode As BookNode, nDepth As Integer) As Integer If (CurNode.Info And Right) = Right Then Return -nDepth '达到叶节点位置 Dim Depth As Integer For i = 0 To CurNode.Children.Count - 1 Depth = GetLeafDepth(CurNode.Children(i), nDepth + 1) If Depth < 0 Then Exit For Next Return Depth End Function
这样就可以知道某一个节点下,叶节点所处的深度(额,当然,是负的表示)。
最后,罗列一下旋转和对称的代码:(不含旋转0度^_^)
'顺时针旋转90度 Private Function RotatePoint90(p As Byte) As Byte Return ((p And &HF) << 4) Or (&HE - (p >> 4)) End Function '顺时针旋转180度 Private Function RotatePoint180(p As Byte) As Byte Return (&HE - (p And &HF)) Or (&HE0 - (p And &HF0)) End Function '顺时针旋转270度 Private Function RotatePoint270(p As Byte) As Byte Return ((&HE - (p And &HF)) << 4) Or ((p >> 4)) End Function '竖直翻转 Private Function vFlipPoint(p As Byte) As Byte Return (p And &HF) Or (&HE0 - (p And &HF0)) End Function '水平翻转 Private Function hFlipPoint(p As Byte) As Byte Return (&HE - (p And &HF)) Or (p And &HF0) End Function '左上右下对角线 Private Function dlFlipPoint(p As Byte) As Byte Return ((p And &HF) << 4) Or ((p And &HF0) >> 4) End Function '右上左下对角线 Private Function drFlipPoint(p As Byte) As Byte Return (&HE - (p And &HF) << 4) Or (&HE0 - (p And &HF0) >> 4) End Function
E、E0说明了棋盘的宽度和高度为15*15。