五子棋AI循序渐进【3】基石——超出边界的alpha-beta剪裁
注:今天更新了评价方法,主要是增加了一组变量来记录每个向量上有多少个黑子或白子,从而选择性的使用相应模板来进行匹配,这导致pos类也有更改。另外,向量评价代码中发现一处错误,导致黑白混淆;界面代码中忘记使用addpipe来添子,已经修正,但是哎pcgo函数那里忘记改了,不重新上传了。源码已经重新上传。现在基本可以稳定在迭代加深4、5,不会出现假死的情况了。因为修正评价的问题而置换表代码还存在一些问题(主要是测试使用数组快还是哈希表快的问题),所以没有完成。估计明天或后天会完成这部分代码。然后进行评价函数的优化,准备采取“查表”的方法来进行向量评分而不是匹配。当然,现在优化过的评价函数使用性能评价工具进行评价时已经不会突出占用处理时间了(占用处理时间最突出的应该是pcgo函数——alpha-beta剪裁函数,测试结果也是如此)。
首先说明一下,源码很多都是借鉴于开源象棋软件的源码,主要参考了象棋小巫师网站上提供的一些下载地址上的开源软件源码。框架基本相同,所以没有什么新意。在此首先感谢各位前辈无私的贡献!后面的一些技术实现代码中会有一些不同甚至很大差别,但是基本框架都是完全一样的。
刚刚完善了置换表也就是第六个程序,但是程序被我删了,因为有些东西写得还是很乱,看起来不清晰,后面再重写。从置换表的测试结果来看,我们的评价函数确实存在很大的问题,需要一个更快,而且是快非常多的局面评价函数!一般来说,迭代加深达到7就能比较令人满意,当然6也勉强可以。但是我最后的测试结果还是4,甚至是3,2。虽然程序中进行了冲棋延伸,但是这可以让置换表更丰富,虽然也用了静态评价,但不使用静态评价时,速度也不怎么样,虽然用了空步剪裁,但棋力的提升相对于它的耗时要重要得多,所以,速度慢全部要“归功于”我们的评价函数了。不过我还是决定用现在的评价函数把整个连载写完。也许在第六集之后,解决这个令人恼怒的问题。那么,我们还是继续讨论如何让程序会走棋吧,这将是非常令人兴奋的时刻!
1、怎么让程序动起来?
程序是基于得分高低,来判断走哪步棋的。换句话说,我们评价走哪步时,总是考虑假如走这里会怎么样,假如走那里会怎么样,程序也是如此。只是实现起来没有那么简单。
2、alpha-beta剪裁是如何达到目的的?
这基于一个思想,如果局面评价是基于得分的,那么轮到谁走谁都要把这个得分弄得更高,程序如此,程序猜想对手走棋也是如此。所以,局面评价函数如果是:己方得分-对方得分,那么,这个分值越正,说明我们越接近胜利了,而这个分值越负,说明我们越接近输棋了。于是,我们总是寻找得分更高的走法,而得分低于一定程度(如对方成5)的走法,被剪裁(所谓剪裁掉,在程序中体现出来就是不继续扫描——exit for)。
3、如何实现这个递归函数?
设计递归的方式不止一种,但无疑都是要实现自己的目的。我们的目的是找到得分更高的走法,这里有这样几个关键点:
A、评价得分并比较
B、最高得分点需要记录
C、要交替查找可以走的点走棋
很明显,递归语句就在遍历点的循环中;最高得分的点可以用全局变量记录。接下来讨论一下评价函数放在哪里的问题:
无非是递归前或递归后,我们要的不是无限递归——那是一个可不完成的工作(玩家会等的花儿也谢了,或许花儿谢了之前他气愤的关闭了程序甚至计算机),所以递归要有深度。当递归达我们要求的深度递归深度之后,评价结果这是很好的做法。实际上,alpha-beta剪裁把每下一个子都评价这件事情交给了”迭代加深“,换句话说,我们把每一步都评价这件事情,分离出去,形成了”迭代加深“。这样做的好处是明显的,回想一下,alpha-beta剪裁是怎样一个循环?它遍历树的过程是前序遍历,所以在他完成之前,我们无法对每一层的走法进行排序来获取这层中的最佳走法,而迭代加深解决了一个很重要的问题:它让alpha-beta剪裁先运行1层,然后1、2层,让后1、2、3层……当然你可能认为这多浪费时间啊,实际上,相对遍历第N层前面N-1层的耗时都是毛线,更何况我们还有”置换表“。这意味着,我们可以更好的对上一次的搜索进行排序从而更容易发生截断,并且当我们达到规定时间时,完全可以不搜索完当前层而返回现在得到的最好结果,因为这个结果不会比上一层的最好结果坏——也许是一样好、更好,但不会更坏。当然,迭代加深和置换表是后话了。好了,那么我们的结论是,alpha-beta剪裁长得就是这么蹩脚:
‘传入alpha值、beta值、深度。
function alpha-beta (vlAlpha As Integer, vlBeta As Integer, nDepth As Integer) As Integer
’达到深度返回评价
if nDepth<=0 then return Evaluate
‘获取全部合理招法
GenerateMoves
’遍历招法
For i = 1 To 招法个数
pos.AddPiece(mvs(i)) ‘添子
vl = -SearchFull(-vlBeta, -vlAlpha, nDepth - 1) ’递归。这一句后面都是出栈了,开始统计吧。
pos.DelPiece(mvs(i)) ‘删子
记录更大的alpha并记录相应的走法,进行beta截断。
next
返回得分
end function
这就是整个alpha-beta递归函数的框架了。
那么,我们整个的alpha-beta函数,看起来会非常眼熟:
Public Function SearchFull(vlAlpha As Integer, vlBeta As Integer, nDepth As Integer) As Integer
'循环变量,走法数组最大下标(走法个数-1)
Dim i, nGenMoves As Integer
'分值,最高分值,最佳走法
Dim vl, vlBest, mvBest As Integer
'生成的全部走法(定长,具体有多少个走法由nGenMoves决定)
Dim mvs(MAX_GEN_MOVES) As Byte
'一个Alpha-Beta完全搜索分为以下几个阶段
'1. 到达水平线,则返回局面评价值
If nDepth = 0 Then
Return pos.Evaluate()
End If
'2. 初始化最佳值和最佳走法
vlBest = -MATE_VALUE '这样可以知道,是否一个走法都没走过(杀棋)
mvBest = 0 '这样可以知道,是否搜索到了Beta走法或PV走法,以便保存到历史表
'3. 生成全部走法,并根据历史表排序
nGenMoves = pos.GenerateMoves(mvs)
Array.Sort(mvs, 0, nGenMoves, mCompare)
'4. 逐一走这些走法,并进行递归
For i = 0 To nGenMoves
pos.AddPiece(mvs(i))
vl = -SearchFull(-vlBeta, -vlAlpha, nDepth - 1)
pos.DelPiece(mvs(i))
' 5. 进行Alpha-Beta大小判断和截断
If (vl > vlBest) Then '找到最佳值(但不能确定是Alpha、PV还是Beta走法)
vlBest = vl '"vlBest"就是目前要返回的最佳值,可能超出Alpha-Beta边界
If (vl >= vlBeta) Then '找到一个Beta走法
mvBest = mvs(i) 'Beta走法要保存到历史表
Exit For 'Beta截断
End If
If (vl > vlAlpha) Then '找到一个PV走法
mvBest = mvs(i) 'PV走法要保存到历史表
vlAlpha = vl '缩小Alpha-Beta边界
End If
End If
Next
'5. 所有走法都搜索完了,把最佳走法(不能是Alpha走法)保存到历史表,返回最佳值
If vlBest = -MATE_VALUE Then
'如果是杀棋,就根据杀棋步数给出评价
Return pos.nDistance - MATE_VALUE
End If
If mvBest <> 0 Then
'如果不是Alpha走法,就将最佳走法保存到历史表
pos.nHistoryTable(mvBest) += nDepth ^ 2
If pos.nDistance = 0 Then
'搜索根节点时,总是有一个最佳走法(因为全窗口搜索不会超出边界),将这个走法保存下来
pos.mvResult = mvBest
End If
End If
'返回最高分
Return vlBest
End Function
这里面有一个非常有必要解释的问题,就是历史表。都知道它是用来排序的,经过历史表排序能更快地截断,事实也是这样,如果注释掉这句:
pos.nHistoryTable(mvBest) += nDepth ^ 2
那么示例代码中的深度3,也会非常耗时,惨不忍睹……所以来解释一下历史表是如何发挥作用的:
看看历史表的结构:
public nHistoryTable(224) as integer
那也就是说,nvBest是最佳走法坐标,那么每个元素的值就是这个坐标过去搜索时,如果是一个最佳走法,那么他的分值就比较高。这个分值怎么对排序起到作用的呢?让我们看看排序器:
Class mvsCompare
Implements IComparer
'这个数组是给走法排序的依据,是历史表的引用。
Public Shared ms() As Integer
Public Function Compare(x As Object, y As Object) As Integer Implements System.Collections.IComparer.Compare
Return ms(y) - ms(x)
End Function
End Class
其中的ms数组就是历史表的引用。我把它直接传过去了。这样,结论就非常明显了:
历史表中第最佳走法个元素的分值越高,那么以后生成的走法进行排序后越靠前。也就是说,以后搜索时,这个走这个位置的招法被更先搜索。先到什么程度呢,那就与深度有关(确切的说是深度平方的累计),越深层得到的最佳招法,越被先搜索。
其中涉及到的pos类,无非就是一些辅助的函数:
'72成棋向量
Public Vectors As New mVectors
'轮到谁走,0=红方,1=黑方
Public sdPlayer As Integer
'棋盘上的棋子,0=红方,1=黑方,2=无子
Public ucpcSquares As mBitBoard
'距离根节点的步数
Public nDistance As Integer
'禁手玩家
Public RtPlayer As Integer = 2
'电脑走的棋
Public mvResult As Integer
'历史表
Public nHistoryTable(224) As Integer
'初始化棋盘类
Sub New()
ucpcSquares = New mBitBoard()
End Sub
'清空历史表
Public Sub ClearnHistoryTable()
mvResult = 0
Array.Clear(nHistoryTable, 0, 225)
End Sub
'交换走棋者
Sub ChangeSide()
sdPlayer = 1 - sdPlayer
End Sub
'在棋盘上放一枚棋子
Sub AddPiece(sq As Integer)
'更新棋盘
ucpcSquares.Set(sq, sdPlayer)
'更新更新标志和向量上棋子个数
For Each v As mVector In Vectors.hs(sq)
v.pipecount(ucpcSquares.Get(sq)) += 1
v.update = True
Next
'交换走棋方
ChangeSide()
'更新步数
nDistance += 1
End Sub
'从棋盘上拿走一枚棋子
Sub DelPiece(sq As Integer)
For Each v As mVector In Vectors.hs(sq)
v.pipecount(ucpcSquares.Get(sq)) -= 1
v.update = True
Next
ucpcSquares.Set(sq, 2)
ChangeSide()
nDistance -= 1
End Sub
'局面评价函数
Function Evaluate() As Integer
Return Vectors.Evaluate(ucpcSquares, sdPlayer, RtPlayer)
End Function
Sub Startup() '初始化棋盘
sdPlayer = 1
nDistance = 0
For i As Integer = 0 To 224 '没有使用bitarray的setall函数。
ucpcSquares.Set(i, 2)
Next
End Sub
'mvs为全部合理招法
Function GenerateMoves(mvs() As Byte) As Integer '生成所有走法
Dim GenBoard = ucpcSquares.GetGeneratePoints
Dim i As Integer = 0, nGenMoves As Integer = 0
For i = 0 To 224
If GenBoard(i) Then
mvs(nGenMoves) = i
nGenMoves += 1
End If
Next
Return nGenMoves - 1
End Function
End Class
嗯,这里可以解释一下为什么白棋是0、黑棋是1了,当然,黑0白1也行啊,其实再改改也行……呵呵。就是为了交换玩家函数简化一些,操作数组时方便一些…………其实好处也很大的。
转载请注明出处:
更新后的源码:
下集预告:
原本计划是棋盘剪裁、迭代加深、空步剪裁、冲棋延伸这四个。但是出了一点状况,减少到3个了。因为棋盘剪裁已经在这一篇的源码当中了。用了循环嵌套,找全部棋子周围的3格以内的全部空格作为生成依据。
全部文章和源码整理完成,以后更新也会在下面地址: