五子棋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

 

posted @ 2012-07-23 16:49  zcsor~流浪dè风  Views(3431)  Comments(10Edit  收藏  举报