这几天更新了一些内容,在现在发布的程序当中存在若干处错误,都被修复了。其中包括模型评价、局面评价、置换表提取等关键部分的错误。程序的基本框架没有太大变化,增加了PV路径记录,从而可以得到除了最佳招法之外的走棋路线,修改了模板当中的冲棋点部分,准备实现VCn搜索、回溯搜索,但是由于思路上还有一点问题所以还没有真正付诸实施。在修复错误并增加了几条知识之后进行了一定的测试,现在和连珠妙手(fiver6、猪八戒级别)进行对战测试,胜率大概在30%-40%,但是棋局下的比较少,数据可能不很准确。不过我不想和这些知名软件做比较,只是为了给程序排查错误和增加一些知识,毕竟我本人的五子棋水平非常低。当实现VCn搜索和回溯搜索之后,会把根节点的搜索单独列出,之后会发布一个版本,这个版本将会作为这一阶段的最终版本,也可能以后就不更新了;当然,如果时间太少,可能暂时放弃VCn和回溯的开发,那么会发布现在的这个版本。2012年8月1日。
今天更新这一版本的程序,主要做了以下修改:
1、将常量放在一个单独类
2、用向量类代替棋盘类并更改记录方式
3、新的评价方法
4、根据棋型生成key
5、下子时只更新被改变的向量
6、实现双置换表
7、使用:历史表、alpha-beta剪裁、空步剪裁、冲棋延伸、主要变例搜索、迭代加深技术。不使用静态搜索等技术。
8、统计相关信息,以便计算置换表命中率、每秒搜索节点数、等等信息。
遗留问题:
1、棋型提取和评价函数。虽然找到了更优的棋型提取方法和评价函数,并且理论上速度可以达到与象棋引擎接近甚至更快的速度,但是代码还没写。
2、更好的剪裁方式。虽然现有的剪裁方式已经不错了,但是只要挖掘就还能找到更好的方法。就像代码中的棋盘剪裁更新一样。
接下来解释一下置换表和更新的这些部分的代码,以便下载源程序后更快的看完它。
‘============================以下更新前内容保留==================================
1、什么是置换表
它记录了一个局面、局面评分等相关信息,用来在搜索过程中用来将评价得分这一系列的运算”置换“为查表得到结果。它的目标是减少运算量,提高速度。
2、置换表都记录什么,如何处理
因为搜索时,很多情况下能遇到相同的局面已经搜索过的现象。所以,如果我们能记录下一个局面、评分、类型、深度,那么,当我们再遇到这个棋型时,只需要知道,若当前深度小于等于记录深度,那么就返回评价,当然还要看记录的局面的节点类型,稍微处理一下。
3、如何实现置换表
我们最好用一个数(key)来记录一个局面,然后,根据这个数,就能找到评分、类型、深度等信息。怎么看都是使用key-value的东西,但是我测试了一下,哈希表速度要比前辈们的方法慢很多。他们把这个key处理了一下:变成下标(key mod len),那么好吧,这样做的速度被证实非常快。而这同时也涉及到一些问题,其中最严重的就是,如果我把长度设置的较小例如10,那么它就无法起到记录局面的作用了(因为它的内容不断的被更新,而我们查找的时候根本找不到过去的局面),可多大行呢,这不好说,除非你设置的置换表和你能够经历的局面相等,呵呵,整个硬盘作为虚拟内存都未必够用,估计初始化置换表就要很久很久……所以这个”适当“的值,很难说,我的做法是:设置一个尽量大的值,这可以减少重复提高效率,而前提是,初始化过程,不超过1秒。我的计算机可以初始化1<<24这么多,而不会超过一秒。所以我就设置了这么大一个置换表。
那么,思路已经很清晰了,你可以先去阅读象棋小巫师的源代码,看看他是如何实现置换表的,当然,如果你感觉它的RC2改成VB.NET让你头疼,那么建议你使用RC4,因为VB.NET已经提供了这种算法,你只需要稍微修改一下原来的代码,因为RC4是一个64位的结果。但是需要注意的是,我们得到局面的dwkey没有那么麻烦,你只需要去修改mbitboard类,在set函数中对dwkey进行更新就可以了。因为局面的变化在这里最容易记录。
’==============================以上更新前内容保留=================================
好了,现在我们介绍一下新程序,当然,它还会被更新几次。幸好今天我测试的时候感觉棋力还可以了,虽然还会出现明显的失误,但可以肯定的是,这些失误是由于分值设置引起的,而没有大量对局参考的情况下,这些数据很难做到较为准确。
1、更新的常量类
我把常量都放在一个类里面,这样修改时可以避免很多麻烦。这没有什么好介绍的,并且源码中注释非常明确。
2、模板类
这个类负责评价一个向量上是否存在这个模板。它有一些成员变量需要解释一下:
Public len As Integer
' 模板含有棋子数
Private pipecount As Integer
' 模板
Private infow As Integer
Private infob As Integer
' 模板返回值
Public value As Integer
' 适用于本模板的信息截断
Private make As Integer
例如,在模板 New mMod({0, 1, 1, 1, 1, 0}, 42)中,
模板长度:6,也就是说,这个模板将检测向量中连续6位。
模板含有棋子数:4
infow和infob这是白棋和黑棋模板,它们被new函数根据传入数组初始化。
value是模板对应的棋型,也就是上面new函数中的42。
make是掩码信息,它用于把向量的棋型当中无用的(前面)部分去掉。
new函数是这样的:
len = bs.Length
pipecount = val \ 10
value = val
For i = 0 To bs.Length - 1
infow = infow << 2
infob = infob << 2
If bs(i) = 1 Then
infow = infow Or CInt( 1)
infob = infob Or CInt( 2)
End If
make = make Or ( CInt( 1) << (i * 2)) ' 遮蔽,把模板中用到的位都置1。所以只需要对信息进行AND操作,就可以去掉信息中无用部分。
make = make Or ( CInt( 1) << (i * 2 + 1))
Next
End Sub
其中pipecount的初始化时根据val这可能要解释一下,因为在程序中定义:长连为60,连5为50,活4为42,冲4为41……所以,分值整除10就是含有的棋子个数。
在循环中,分别记录黑棋和白棋的模板,因为如果记录到同一模板,那么判断黑棋时非常麻烦!并不是>>1就能解决的问题(会遗漏白棋,使得判断结果不准确)。
New mMod({0, 1, 1, 1, 1, 0}, 42) 这个模板的二进制表示看起来是这样的:
infow=00 01 01 01 01 00
infob=00 10 10 10 10 00
make=11 11 11 11 11 11
可以看出,白棋和黑棋他们分别占据每2位中的后一位和前一位,而在向量中记录棋型时,也是这样:
好了,这些代码我们不做更多的解释,在vector类中再详细说明。现在我们只需要知道,在向量和模板中,我们从最右面开始用连续2位记录棋盘上的一个位置,其中后一位为白棋,前一位为黑棋,若没有棋子则这两位均为0,但绝不可能出现两位都为1的情况。
于是,我们的比较函数简单了一些——我们进行了“块”比较,但这不是完整的块,只是integer的比较速度要快于byte()的比较速度,所以比较函数速度比原来快。后来我想到了不用遍历整个向量信息的方法,而且不是比较整个30位(可以想象,比较全部30位将有多少模板……那是难以完成的工作),当然这是以后我们要讨论的。所以,现在的比较函数还存在for循环:
inf0 = inf0 And make ' 将无用信息去掉
If inf0 = infow Then ' 符合模板
vector.value( 0) = value ' 记录模板值
vector.update( 0) = False ' 已符合,无需继续扫描
End If
首先,使用位移运算从向量信息取出一部分,然后用and运算把信息中前面的多余部分去掉,直接和模板比较数值是否相等就可以了。当然,如果相同,我们还需要更新一下向量的相关值和更新标志。
3、模板管理类
这个类负责初始化全部模板,并且,负责比较一个向量符合哪一个模板,它的初始化函数非常简单,我们只介绍一下评价函数:
If vector.info <> 0 Then
Dim i, ilen As Integer
For i = 0 To AllMod.Length - 1
ilen = AllMod(i).len
If vector.len >= ilen Then ' 若向量长度不小于模板长度
AllMod(i).CompareMod(vector)
If vector.update( 0) = False AndAlso vector.update( 1) = False Then
Return
End If
End If
Next
End If
If vector.update( 0) Then
vector.value( 0) = 0
vector.update( 0) = False
End If
If vector.update( 1) Then
vector.value( 1) = 0
vector.update( 1) = False
End If
End Sub
在这段代码中我们统一评价白棋和黑棋:
首先我们检查向量是否为空,若为空,则设置值为0并且更新标志为空;
而后,比较向量长度和模板长度,得出相应的评价;
应该注意的是,vector.update(0) = False AndAlso vector.update(1)这一条件,仅有这个条件是不够的,因为我们的模板不是全部情况都概括了,所以,当评价混合棋型时、棋型变为空时,可能不会更新某些向量的棋型标志和更新标志,这是非常严重的。
4、向量类
Public update( 1) As Boolean
' 转换表
Private table( 224) As Byte
' 向量上的棋型
Public info As Integer
' 向量长度
Public len As Integer
' 向量上白棋、黑棋的个数
Public pipecount( 1) As Byte
' 向量上白棋、黑棋的棋型
Public value( 1) As Byte
' 向量方向
Private Direction As Integer
' 坐标表
Public points() As Byte
Sub New(ps As Byte(), dir As Integer)
points = ps
Direction = dir
len = ps.Length
For i = 0 To ps.Length - 1
table(ps(i)) = i ' 转换表的下标对应着在points的值,而值对应下标在points中的位置。
Next
End Sub
转换表:这个表在new函数中被初始化,可以看出,它的值记录了i,这个i实际上就是它的下标对应的ps当中的位置,简单地说,通过它,我们可以快速的得到一个坐标在ps数组当中的下标。
向量方向:这个值就是用来记录向量是4个方向中的那个方向,只用于更新相应方向上的key。所以,一个局面有4个key。简单的来说,这4个key就是棋型在该方向垂直方向上的投影。
get函数非常简单,我们不做介绍了,解释一下set函数当中的关键代码(以删除一个子为例,下一个子和删除的情况基本一致):
info = info And Not ( CInt( 1) << table(point) * 2) ' 删除白棋
info = info And Not ( CInt( 1) << table(point) * 2 + 1) ' 删除黑棋
mVectorManeger.keys(Direction) = mVectorManeger.keys(Direction) Xor info ' 记录新的记录
pipecount(bp) -= 1 ' 记录白棋或黑棋个数
update( 0) = True ' 记录需要更新,无论白棋变了还是黑棋变了,棋型都变化,所以同时需要更新。
update( 1) = True
mVectorManeger.shapes( 0)(value( 0)) -= 1
mVectorManeger.shapes( 1)(value( 1)) -= 1
mModManager.Evaluate( Me)
mVectorManeger.shapes( 0)(value( 0)) += 1
mVectorManeger.shapes( 1)(value( 1)) += 1
key的更新:
首先,在key中去掉当前棋型
然后,删除白棋和黑棋(当然,bp值=0就是白棋,=1就是黑棋)。删除的方法很简单,就是把1移动到指定位置,然后not,这样其他位都是1,而指定位置为0,接下来and。
最后,在key中记录更新后的棋型
中间的代码更新了棋子个数、更新标志。最后:
评价前:从棋型记录中减去原来的棋型(注意,虽然棋型确实更新了,但是没有经过评价,向量的value是不变的)
评价:按照update来更新棋型信息
评价后:在棋型记录中加上现在的棋型
5、向量管理
这个类的代码非常少。也很容易看懂。其中set函数中 Dim a = mVectorManeger.shapes没有什么实际意义,只是用来在这里检查棋型汇总是否正确,这是我在调试过程中遗留的。
6、局面类
局面类进行了较多更新,例如分离了禁手判断等函数,但基本上还是很容易看懂的。需要介绍的是原来在bitboard类中的GenerateMoves函数:
Function GenerateMoves( ByRef mvs() As Byte, mv As Integer) As Integer ' 生成所有走法
' 临时变量,存储当前局面下每个子周围三格以内的空位
Dim tmp As BitArray = GetGeneratePoints()
Dim offset As Integer = 0
If mv > - 1 Then
tmp.Set(mv, False)
offset = 1
End If
' 统计全部空位
Dim i As Integer = 0, nGenMoves As Integer = offset
For i = 0 To 224
If tmp(i) Then
mvs(offset + nGenMoves) = i
nGenMoves += 1
End If
Next
Return nGenMoves - 1
End Function
函数中的mv是置换表招法,所以函数所做的改动是为了把置换表招法放在mvs(0)这个位置,并且,tmp.set(mv,false)保证了后面的搜索中不会再次找到这个招法。
然后是置换表的覆盖和提取,因为我们使用了2个置换表(横向和纵向,两个斜向没有使用),所以查找和保存逻辑稍微复杂一些。尤其是保存逻辑:
Sub RecordHash(nFlag As Integer, vl As Integer, nDepth As Integer, mv As Integer)
' 被替换的置换表
Dim hshtindex As Integer = - 1
Dim hsh, hsh0, hsh1 As mHashItem
' ===============================置换表覆盖策略=================================
' 0、查找空的,直接覆盖。若没有空的:
' 分别找到置换表0、1当中对应的元素,
' 1、若元素完全符合某一个,则
' 1.1、若深度更深,则直接覆盖
' 1.2、否则,退出
' 2、若不完全符合任何一个,则
' 2.1、若深度比置换表中深度更浅的深,则覆盖这个
' 2.2、否则,退出
hsh0 = hstb0(mVectorManeger.keys( 0) And mConstValue.HASH_SIZE_S1) ' 提取
If hsh0.dwLock_a = 0 Then ' 若为空,直接覆盖
hshtindex = 0
Else ' 不为空
' 若一致
If (hsh0.dwLock_a = mVectorManeger.keys( 1)) AndAlso (hsh0.dwLock_b = mVectorManeger.keys( 2)) AndAlso (hsh0.dwLock_c = mVectorManeger.keys( 3)) Then
If hsh0.ucDepth < nDepth Then ' 若深度更大则更新
hshtindex = 0
Else ' 若深度更小则返回
Return
End If
End If
' 若不一致,查找下一个
End If
If hshtindex = - 1 Then
hsh1 = hstb1(mVectorManeger.keys( 1) And mConstValue.HASH_SIZE_S1)
If hsh1.dwLock_a = 0 Then
hshtindex = 1
Else
If (hsh1.dwLock_a = mVectorManeger.keys( 0)) AndAlso (hsh1.dwLock_b = mVectorManeger.keys( 2)) AndAlso (hsh1.dwLock_c = mVectorManeger.keys( 3)) Then
If hsh1.ucDepth < nDepth Then
hshtindex = 1
Else
Return
End If
End If
End If
End If
' 若没有找到空的、完全符合的,则覆盖深度更小的。
If hshtindex = - 1 Then
If hsh0.ucDepth < hsh1.ucDepth Then
If hsh0.ucDepth < nDepth Then hshtindex = 0
Else
If hsh1.ucDepth < nDepth Then hshtindex = 1
End If
End If
' 若没有深度更小的,那么不记录。
If hshtindex = - 1 Then
Return
End If
If hshtindex = 0 Then hsh = hsh0 Else hsh = hsh1
hsh.ucFlag = nFlag
hsh.ucDepth = nDepth
If vl > mConstValue.WIN_VALUE Then
hsh.svl = vl + nDistance
ElseIf vl < -mConstValue.WIN_VALUE Then
hsh.svl = vl - nDistance
Else
hsh.svl = vl
End If
hsh.wmv = mv
' 保存到置换表
hsh.dwLock_b = mVectorManeger.keys( 2)
hsh.dwLock_c = mVectorManeger.keys( 3)
If hshtindex = 0 Then
hsh.dwLock_a = mVectorManeger.keys( 1)
hstb0(mVectorManeger.keys( 0) And mConstValue.HASH_SIZE_S1) = hsh
Else
hsh.dwLock_a = mVectorManeger.keys( 0)
hstb0(mVectorManeger.keys( 1) And mConstValue.HASH_SIZE_S1) = hsh
End If
c += 1
End Sub
不用关心c这个变量,它是用来记录置换表中一共存储了多少局面的。
这个逻辑是这样的:
A、找到两个置换表中与当前局面一致的局面,能覆盖则覆盖,不能就不记录了
B、如果没有一致局面,则找到空的,记录当前局面
C、如果没有一致局面,也没空的,那就覆盖深度更浅的。
D、如果还没记录下来,那算了吧……
7、扫描类
这个类就是核心了,其中包括各种剪裁技术。但是前面我们已经做过非常多的介绍了,至少,原理是没有什么问题(之所以这么说,是因为我怀疑前面的代码中有一处甚至若干处难以查找的“手误”),所以应该很容易能够看懂这些代码。
下集预告:
下次更新的时候,我希望迭代加深能够超过现在的6——通过优化棋型评价和局面评价(虽然现在是按需更新,但也许我们可以不更新整个局面)。当然,你如果使用更严格的棋盘剪裁,现在就可以达到8。所以,历史表和阶梯式棋盘剪裁结合起来也许效果更好,但是这还没有进入日程。
本集源码:
全部文章和源码整理完成,以后更新也会在下面地址: