五子棋AI循序渐进【6】置换表


这几天更新了一些内容,在现在发布的程序当中存在若干处错误,都被修复了。其中包括模型评价、局面评价、置换表提取等关键部分的错误。程序的基本框架没有太大变化,增加了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函数是这样的:

 

    Sub New(bs() As Byte, val As Integer)
        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位中的后一位和前一位,而在向量中记录棋型时,也是这样:

 

info = info Or ( CInt( 1) << ((table(point) * 2 + player) Mod &H20))

 

好了,这些代码我们不做更多的解释,在vector类中再详细说明。现在我们只需要知道,在向量和模板中,我们从最右面开始用连续2位记录棋盘上的一个位置,其中后一位为白棋,前一位为黑棋,若没有棋子则这两位均为0,但绝不可能出现两位都为1的情况。

 

于是,我们的比较函数简单了一些——我们进行了“块”比较,但这不是完整的块,只是integer的比较速度要快于byte()的比较速度,所以比较函数速度比原来快。后来我想到了不用遍历整个向量信息的方法,而且不是比较整个30位(可以想象,比较全部30位将有多少模板……那是难以完成的工作),当然这是以后我们要讨论的。所以,现在的比较函数还存在for循环:

 

                inf0 = vector.info >> (i * 2)           ' 逐两位进行比较
                inf0 = inf0 And make                    ' 将无用信息去掉
                If inf0 = infow Then                    ' 符合模板
                    vector.value( 0) = value             ' 记录模板值
                    vector.update( 0) = False            ' 已符合,无需继续扫描
                End If

 

首先,使用位移运算从向量信息取出一部分,然后用and运算把信息中前面的多余部分去掉,直接和模板比较数值是否相等就可以了。当然,如果相同,我们还需要更新一下向量的相关值和更新标志。

 

3、模板管理类

这个类负责初始化全部模板,并且,负责比较一个向量符合哪一个模板,它的初始化函数非常简单,我们只介绍一下评价函数:

 

 

    Public Shared Sub Evaluate( ByRef vector As mVector)
        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函数当中的关键代码(以删除一个子为例,下一个子和删除的情况基本一致):

 

                mVectorManeger.keys(Direction) = mVectorManeger.keys(Direction) Xor info    ' 去掉原来的记录
                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函数:

 

    ' mvs为全部合理招法
    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。所以,历史表和阶梯式棋盘剪裁结合起来也许效果更好,但是这还没有进入日程。

 

本集源码:

 

 /Files/zcsor/清月连珠0601.7z

 

全部文章和源码整理完成,以后更新也会在下面地址:

http://www.vbdevelopers.org

http://www.softos.org

 


免责声明!

本站转载的文章为个人学习借鉴使用,本站对版权不负任何法律责任。如果侵犯了您的隐私权益,请联系本站邮箱yoyou2525@163.com删除。



 
粤ICP备18138465号  © 2018-2025 CODEPRJ.COM