《对弈程序基本技术》专题
置换表
Bruce Moreland / 文
一个多功能的数据结构 国际象棋的搜索树可以用图来表示,而置换结点可以引向以前搜索过的子树上。置换表可以用来检测这种情况,从而避免重复劳动。如果“
1. e4 d6 2. d4”以后的局面已经搜索过了,那就没有必要再搜索“
1. d4 d6 2. e4”以后的局面了。 这个原因可能鼓舞着早期的电脑国际象棋程序的设计师们,而现在事实上这还是置换表的次要用途。在某些局面,例如在没有通路兵的王兵残局中,检查到的置换的数量是惊人的,以至于搜索可以在短达时间内达到很深的深度。 省去重复的工作,这是置换表的一大特色,但是在一般的中局局面里,置换表的另一个作用更为重要。每个散列项里都有局面中最好的着法,我在“
迭代加深”这一章里解释过,首先搜索好的着法可以大幅度提高搜索效率。因此如果你在散列项里找到最好的着法,那么你首先搜索这个着法,这样会改进你的着法顺序,减少分枝因子,从而在短的时间内搜索得更深。
实现 主置换表是一个散列数组,每个散列项看上去像这样: #define hashfEXACT 0 #define hashfALPHA 1 #define hashfBETA 2 typedef struct tagHASHE { U64 key; int depth; int flags; int value; MOVE best; } HASHE; 这个散列数组是以“
Zobrist键值”为指标的。你求得局面的键值,除以散列表的项数得到余数,这个散列项就代表该局面。由于很多局面都有可能跟散列表中同一项作用,因此散列项需要包含一个校验值,它可以用来确认该项就是你要找的。通常校验值是一个
64位的数,也就是上面那个例子的第一个域。 你从搜索中得到结果后,要保存到散列表中。如果你打算用散列表来避免重复工作,那么重要的是记住搜索有多深。如果你在一个结点上搜索了
3层,后来又打算做
10层搜索,你就不能认为散列项里的信息是准确的。因此子树的搜索深度也要记录。 在
Alpha-Beta搜索中,你很少能得到搜索结点的准确值。
Alpha和
Beta的存在有助你裁剪掉没有用的子树,但是用
Alpha-Beta有个小的缺点,你通常不会知道一个结点到底有多坏或者有多好,你只是知道它足够坏或足够好,从而不需要浪费更多的时间。 当然,这就引发了一个问题,散列项里到底要保存什么值,并且当你要获取它时怎样来做。答案是储存一个值,另加一个标志来说明这个值是什么含义。在我上面的例子中,比方说你在评价域中保存了
16,并且在标志域保存了“
hashfEXACT”,这就意味着该结点的评价是准确值
16;如果你在标志域中保存了“
hashfALPHA”,那么结点的值最多是
16;如果保存了“
hashfBETA”,这个值就至少是
16。 当你在搜索中遇到特定情况时,很容易决定评价和标志应该保存哪些内容。然而避免错误是非常重要的,散列表是非常容易犯错误的,而且一旦犯下错误就很难捕捉出来。 我的散列项的最后一个域,保存着上次搜索到这个局面时的最佳着法。有时我没有得到最佳着法,比如任何低出边界的情况
(返回一个小于或等于
Alpha的值
),而其他情况必定有最佳着法,比如高出边界的情况
(返回一个大于或等于
Beta的值
)。
【译注:只有叶子结点才没有最佳着法,即便是
Alpha
结点,所有的着法都是差的,也应该从中找一个最好的着法,它对更深一层的搜索会带来很大的好处。】 如果找到最佳着法,那么它应该首先被搜索。 下面是示范程序,是根据
Alpha-Beta函数修改的,改动的地方用醒目的字标出: int AlphaBeta(int depth, int alpha, int beta) {
int hashf = hashfALPHA;
if ((val = ProbeHash(depth, alpha, beta)) != valUNKNOWN) {
//
【valUNKNOWN必须小于-INFINITY或大于INFINITY,否则会跟评价值混淆。】 return val; } if (depth == 0) { val = Evaluate();
RecordHash(depth, val, hashfEXACT); return val; } GenerateLegalMoves(); while (MovesLeft()) { MakeNextMove(); val = -AlphaBeta(depth - 1, -beta, -alpha); UnmakeMove(); if (val >= beta) {
RecordHash(depth, beta, hashfBETA); return beta; } if (val > alpha) {
hashf = hashfEXACT; alpha = val; } }
RecordHash(depth, alpha, hashf); return alpha; } 以下就是两个新的函数的代码: int ProbeHash(int depth, int alpha, int beta) { HASHE *phashe = &hash_table[ZobristKey() % TableSize()]; if (phashe->key == ZobristKey()) { if (phashe->depth >= depth) { if (phashe->flags == hashfEXACT) { return phashe->val; } if ((phashe->flags == hashfALPHA) && (phashe->val <= alpha)) { return alpha; } if ((phashe->flags == hashfBETA) && (phashe->val >= beta)) { return beta; } } RememberBestMove(); } return valUNKNOWN; } void RecordHash(int depth, int val, int hashf) { HASHE *phashe = &hash_table[ZobristKey() % TableSize()]; phashe->key = ZobristKey(); phashe->best = BestMove(); phashe->val = val; phashe->hashf = hashf; phashe->depth = depth; } 你所看到的代码,并不像航天科学一样准确,而是很可能有错误的,而且细节上的问题我还没有讨论。如果你的程序中有错误,或许就是很严重的错误。
【以上代码有个速度上的瓶颈,即“
ZobristKey() % TableSize()
”这个表达式。由于“电脑一做除法就成了傻瓜”,所以“
TableSize
”最好是一个
2n
的常量,只有当除数是
2n
时除法才可以由右移指令取代。最好的方法是设一个“
TableSizeMask
”的变量:
int TableSizeMask = TableSize() - 1;
HASHE *phashe = &hash_table[ZobristKey() & TableSizeMask];
而这里“
TableSize()
”也必须是
2n
。正是这个道理,在很多可以设定置换表大小的国际象棋程序中,允许的设定值总是呈倍数增长的,要么是
3M
、
6M
、
12M
、
24M
等等
(
如果每个散列项有
12
字节
)
,要么是
4M
、
8M
、
16M
、
32M
等等
(
如果每个散列项有
16
字节
)
。】
替换策略 最主要的细节就包括,什么时候该覆盖散列项。在上面的例子中,我用了“始终替换”的策略,即简单地覆盖已经存在的值。这或许不是最好的策略,事实上已经有大量的工作试图找出哪个策略是最好的。 另一个策略是“同样深度或更深时替换”。除非新局面的深度大于或等于散列表中已经有的值,否则已经存在的结点将被保留。 还有很多试验的余地。
1994年我在
Usenet(新闻组网络系统
)的新闻组
rec.games.chess(如今是
rec.games.chess.computer)上问了这个问题,得到了
Ken Thompson的答复。 他的回答是使用两个散列表。一个使用“始终替换”策略,另一个使用“同样深度或更深时替换”。当你做试探时,两个散列表都去试探,如果其中一个可以产生 截断,那就可以了。如果两者都不能产生截断,那么你可能至少得到一个最佳着法,实际上更多的可能是得到两个不同的着法,两者都应该首先
(或第二个
)尝试。 记录的时候,你只要简单地根据替换策略来执行。 如果你使用“同样深度或更深时替换”的策略,那么你的散列表可能最终会被过期的但很深的结点所占满。解决方案就是每次你走棋时都清除散列表,或者在散列项中加入“顺序”这个域,从而使这个策略变成变成“同样深度,或更深,或原来是旧的搜索,才替换”。 我在我的程序
Ferret中使用了
Thompson的策略,并且运行得很好。另一个程序
Gerbil也使用这个策略,你可以去看它的源代码。
【根据译者研究的结果,只用“深度优先覆盖”策略
(
即“同样深度或更深时替换”
)
,效果会比“始终替换”好得多,而代码则并不复杂,只有醒目的部分是新增的:
void RecordHash(int depth, int val, int hashf) {
HASHE *phashe = &hash_table[ZobristKey() & (TableSize() - 1)];
if (phashe->hashf != hashfEMPTY && phashe->depth > depth) {
return;
}
phashe->key = ZobristKey();
phashe->best = BestMove();
phashe->val = val;
phashe->hashf = hashf;
phashe->depth = depth;
}
如果使用这个代码,那么每走一步以前都必须把散列表中所有的标志项置为“
hashfEMPTY
”。】
不稳定性的问题 当你用置换表时,如果你允许搜索过程根据散列项来截断,那就会产生另一个问题,你的搜索会受“
不稳定性”的捆扰。 不稳定性至少是由以下因素引起的:
1. 你可能在做
6层的搜索,但是如果你在散列项中得到
10层搜索的结果,就可能根据这个值来截断。在后来的搜索中,这个散列项被覆盖了,因此你在这个结点上得到了两个不同的值。
2. Zobrist键值无法记录到达结点的线路,这个结点上不是每条线路都有相同结果的。如果某条线路遇到重复局面,那么散列项的值就会跟路线有关。因为重复局面会导致和局的分值,或者至少不一样的分值。 就我所知,还没有什么办法能处理这些问题。
【另外,如果搜索过程中找到杀棋,那么评价值会接近“
INFINITY
”或“
-
INFINITY
”,此时记录散列表时不能简单地记录这些评价值,在后面介绍的“
胜利局面
”的处理中,会谈到这个问题。】 原文:
http://www.seanet.com/~brucemo/topics/hashing.htm 译者:黄晨
(
![](https://i-blog.csdnimg.cn/blog_migrate/b7134b277d382bd7b079ada4c5dfd98c.png)
) 类型:全译加译注
上一篇 基本搜索方法——迭代加深
下一篇 高级搜索方法——简介(一)
返 回 象棋百科全书——电脑象棋
转自:http://www.elephantbase.net/computer/search_hashing.htm