Last time I left off after describing why it would be useful to be able to look at a board position and be able to quickly and easily determine if that position had been seen before. I’m going to devote this post to showing how this can be done.
The obvious solution is to examine every square of the board against a collection of previous positions. As you can imagine, this isn’t going to be quick. It would also involve storing all the previous positions which is something I said right at the start of this series that I didn’t want to do. You might want to give some thought about how you’d solve this problem yourself and imagine how you’d solve the same problem for games like chess (64 squares with different types of pieces on them), or Go (361 squares).
Fortunately other people much brighter than me have had to solve the same problem and come up with some very clever answers. In particular Albert Lyndsey Zobrist came up with a technique called Zobrist Hashing. This has some very nice properties; you don’t have to store the actual board position of previously encountered positions, and it’s very efficient to implement.
So what’s the secret? Well to start with there’s lots of big random numbers. There are two random numbers associated with each square, one for each coloured piece. The idea is to represent the position by a hash value formed by XOR-ing together the random numbers associated with each piece present on the board. The use of XOR has the nice property that you can calculate the hash value of another position created by the addition or removal of a piece by simply XOR-ing the associated random number.
The danger of using hash values is that you may get collisions. For a more complicated game like chess, the consensus seems to be that if you used 32 bit random numbers then the chances of collision would be too great, but 64 bits is enough so long as you code things so that a collision doesn’t cause “bad things” to happen. Being a natural pessimist I prefer to also use an additional check called a lock value to detect potential collisions. Basically this uses a separate set of random numbers to calculate a second hash value (the lock value). A position is only considered to match if both the primary hash and the lock values agree. I guess it’s time to look at some code. First the creation of the random numbers.
import random
random.seed(0)
hashKeyComponents = [[random.randint(0, sys.maxint)
for square in range(42)] for piece in range(2)]
hashLockComponents = [[random.randint(0, sys.maxint)
for square in range(42)] for piece in range(2)]
Well that’s all well and good, but how do you actually use these? The idea is that before you go through the expense of evaluating a position, you check to see if you’ve already seen the same position before. This information is held in a map which I’ve imaginatively called table and which is a class variable of Board. In addition to the map, two other attributes, hash and lock are added to positions.
class Board:
table = {} # hash => (value, iswin, lock)
def __init__(self):
…
self.hash = 0
self.lock = 0
…
As the comment indicates, table is used to map a Zobrist hash of a position to a tuple comprising the static evaluation of the position, whether the position is a winning one or not, and the lock value of the position. How this is used and updated is best seen in the final version of the makeMove function.
def makeMove(self, square):
color = WHITE if self.count & 1 == 0 else BLACK
self.position[square] = color
self.heights[square % 7] += 1
index = (1 - color) >> 1
self.hash ^= hashKeyComponents[index][square]
self.lock ^= hashLockComponents[index][square]
self.count += 1
value, iswin, lock = Board.table.get(self.hash, (None, False, None))
if lock == self.lock:
self.value = value
self.iswin = iswin
return
value = self.value
iswin = False
if color == WHITE:
for four in foursBySquare[square]:
nWhite, nBlack = 0, 0
for s in four:
piece = self.position[s]
if piece == WHITE:
nWhite += 1
elif piece == BLACK:
nBlack += 1
if nBlack == 0:
value += weights2[nWhite]
elif nWhite == 1:
value += weights1[nBlack]
if nWhite == 4: iswin = True
else:
for four in foursBySquare[square]:
nWhite, nBlack = 0, 0
for s in four:
piece = self.position[s]
if piece == WHITE:
nWhite += 1
elif piece == BLACK:
nBlack += 1
if nWhite == 0:
value -= weights2[nBlack]
elif nBlack == 1:
value -= weights1[nWhite]
if nBlack == 4: iswin = True
Board.table[self.hash] = (value, iswin, self.lock)
self.value = value
self.iswin = iswin
Essentially, the difference from the previous version and this one is the calculation of the hash values, using them to check if there is a table entry and if so using the stored values rather than calculating them again. If it’s necessary to do the calculation for the first time then the values are cached for later reference. The only slight complication is the calculation of the index variable. This is used to convert the colour of a piece from -1 or 1 into 1 or 0 so that it can be used to reference the correct array of colour specific random numbers.
Just for completeness I should also include an updated version of the undoMove function which effectively resets the hash and lock values back to what they were prior to the move.
def undoMove(self, square):
self.count -= 1
color = self.position[square]
self.position[square] = EMPTY
self.heights[square % 7] -= 1
index = (1 - color) >> 1
self.hash ^= hashKeyComponents[index][square]
self.lock ^= hashLockComponents[index][square]
Well that’s laid most of the groundwork necessary. Next post I’ll begin to explain about the algorithms behind how the computer chooses which move to make.