Bitboards, operadores bitwise y
engines de ajedrez
“Eso de que el número
de casillas del tablero de ajedrez sea una potencia de 2 tiene que
ser aprovechable desde el punto de vista de la programación”
Un engine de ajedrez se
compone de tres partes principales:
- Un generador de movimientos
- Una función de búsqueda que nos permite movernos por el árbol de variantes usando el output del generador de movimientos
- Una función de evaluación que nos dé un criterio para asignar una puntuación a una posición
De modo que el primer paso
es el generador de movimientos. Como una primera aproximación, el
generador de movimientos se puede separar en:
- GenMove: la función/es que, dada una cierta posición, crea la lista de movimientos posibles, y por otro lado
- MakeMove: la función/es que recoge esa lista de movimientos y los ejecuta de hecho sobre el tablero
Por ejemplo, es habitual
codificar un movimiento en un entero de 16 bits (en los 6 primeros se
guarda la casilla inicial, en los 6 siguientes la casilla de destino
y en el resto otro tipo de información, como por ejemplo si se trata
o no de una captura). De modo que que nuestra función GenMove
devolvería un array de enteros de 16 bits y de longitud el número
de movimientos legales (suponiendo que nuestro generador de
movimientos devolviera sólo movimientos, lo que suele ser habitual,
pero no necesario). Luego la función MakeMove iría ejecutando estos
movimientos uno a uno para moverse por el árbol de variantes.
A la hora de desarrollar un
engine de ajedrez uno de las principales decisiones que se han de
tomar es la de cómo representar la posición que tenemos en el
tablero. Y en concreto, cómo representar la información sobre cada
pieza.
Los bitboards son
básicamente la representación de la información de la posición
del tablero por medio de enteros de 64 bits. Por ejemplo, podemos
usar un entero de 64 bits para representar la posición de los
caballos blancos en la posición inicial, usando un bit por cada
casilla; el bit que representa una casilla vale 0, y vale y 1 si en
la casilla hay un caballo blanco:
|
→
|
|
Es decir, la posición inicial de los
caballos blancos viene representada por el entero 1000010. O puesto
en forma de entero de 64 bits:
00000000 00000000 00000000 00000000
00000000 00000000 00000000 01000010
y si ahora lo ordenamos
convenientemente para que se asemeje a un tablero de ajedrez:
00000000
00000000
00000000
00000000
00000000
00000000
00000000
01000010
Evidentemente, habría que hacer lo
mismo para cada pieza. De este modo, nuestro programa podría tener
una clase/estructura Tablero, con los miembros peonesBlancos,
caballosBlancos, etc. O podemos preferir guardar esta información en
un array de enteros de 64 bits de dimensiones [2][6]: 2 bandos con 6
piezas diferentes cada uno.
Bien, hasta ahora, aparte de que hemos
almacenado la información de la posición del tablero de una manera
bastante compacta y económica (lo que por otro lado en los
ordenadores actuales tampoco es de gran utilidad), no parece que
hayamos hecho gran cosa, de modo que vamos a ver qué nos ofrece el
uso de nuestros amigos los bitboards, en concreto en lo relacionado
con la generación de movimientos.
Pongamos que tenemos la posición
inicial y queremos saber a qué casillas se puede mover el caballo
blanco de b1. En nuestro entero de 64 bits caballosBlancos tenemos la
información sobre la posición de nuestros caballos, y queremos
calcular los posibles destinos de esta pieza, que en principio serían
las casillas a3, c3 y d2. Aquí nos podemos dar cuenta de una cosa;
cuando un caballo blanco (o para el caso de cualquier color) se
encuentra en la casilla b1, las casillas a las que potencialmente
puede saltar son siempre las mismas: a3, c3 y d2, de modo que ¿por
qué no crear un array de 64 enteros de 64 bits (al que podríamos
llamar algo así como uint64 saltosCaballos[64]) que en cada elemento
tenga un bitboard que represente las casillas a las que puede saltar
un caballo desde dicha casilla? Es decir, el elemento que se
corresponde con la casilla b1 (saltosCaballos[b1]) puesto en binario
y bien ordenadito sería:
00000000
00000000
00000000
00000000
00000000
10100000
00010000
00000000
Excelente; ahora se presenta un
problema. En la posición inicial, el caballo de b1 no puede saltar a
d2, ya que ahí se encuentra un peón blanco (si fuera un peón negro
no habría problema: el hecho de que se tratara de una captura en vez
de un movimiento “normal”, de momento lo dejaríamos de lado:
añadiríamos esta información en el entero de 16 bits que codifica
el movimiento y luego el MakeMove se encargaría de gestionar esa
información a la hora de hacer el movimiento sobre el tablero). Muy
bien, ahora es cuando entran en juego nuestros amigos los operadores
bitwise. Si generamos un entero de 64 bits (al que podríamos llamar
piezasBlancas) en el que cada bit valga 1 si en la casilla que
representa hay una pieza blanca y un cero en caso contrario, es
decir, para la posición inicial:
00000000
00000000
00000000
00000000
00000000
00000000
11111111
11111111
entonces
ya casi lo tenemos: sólo tenemos que hacer una operación AND a
nivel de bits entre el bitboard saltosCaballos[b1] y el
complementario del entero de 64
bits que hemos llamado piezasBlancas, que en jerga de operadores
bitwise se obtendría como “~piezasBlancas” y tendría la pinta
11111111
11111111
11111111
11111111
11111111
11111111
00000000
00000000
Ahora
sí que lo tenemos:
si hacemos la operación saltosCaballos[c1]
& (~piezasBlancas) obtendríamos las casillas a las que podría
saltar un caballo en la posición inicial desde b1. Puesto en forma
de binarios ordenaditos:
-
saltosCaballos[c1]&~piezasBlancas=casillasDestino0000000000000000000000000000000000000000101000000001000000000000AND1111111111111111111111111111111111111111111111110000000000000000EQUAL0000000000000000000000000000000000000000101000000000000000000000
Por
cierto, en este momento puede ser interesante comentar que, por lo
que dicen los sabios del lugar, una de las ventajas de trabajar con
operaciones bitwise es que estas son ultra-rápidas.
Ahora
debemos recordar que nuestra finalidad es la de crear una lista de
movimientos, de modo que ahora tendríamos que ingeniárnoslas para,
con la información obtenida en los pasos anteriores, crear dos
movimientos que irían a nuestra lista de movimientos posibles para
la posición actual:
- La pieza que está en b1 se mueve a a3
- La pieza que está en b1 se mueve a c3
Una
pista: para esta parte de la generación de movimientos es necesario
tener una función que encuentre el “least significant bit” de un
entero. Pero esta es otra historia y, quizás, será contada en otro
momento.
¿Y
qué ocurre con el resto de las piezas?
Para
el caso del movimiento de los reyes la cosa es prácticamente
idéntica: simplemente se pueden mover a cualquier posición de
destino con tal de que no esté ocupada por una pieza del mismo
bando.
Con
las denominadas “slide pieces”, la cosa se complica algo, ya que
las reglas no son tan sencillas como para el caso de los caballos y
los reyes, pero con ayuda de los operadores bitwise se acaba
obteniendo un generador de movimientos muy rápido.
Hasta
aquí sobre los bitboards en lo relacionado a la generación de
movimientos, pero hay otra parte del diseño de un engine de ajedrez
en la que estos animalitos son la mar de útiles: la evaluación.
Los
bitboards son una manera muy manejable de almacenar la información
de lo que tenemos en el tablero, y además las operaciones lógicas
con operadores bitwise son muy rápidas. Esto nos permite responder
con una gran eficiencia a preguntas tales como “¿está esta torre
en una columna abierta?”, “¿este es un peón pasado?”, “¿tiene
el blanco peones en e4 y d4?”.
Hay
que tener en cuenta que una vez que tenemos un engine de ajedrez
funcionando, el 99% del tiempo de cpu se gasta en la función de
evaluación, de modo que el uso de bitboards mata dos pájaros de un
tiro: nos proporciona un generador de movimientos muy rápido (que
evidentemente es el primer cuello de botella a la hora de desarrollar
un engine: lo más rápido a lo que puede “ir” nuestro engine
está limitado por la velocidad a la que puede hacer movimientos sin
evaluación), y nos da una estructura de datos que nos permite llevar
a cabo operaciones muy rápidas en la función de evaluación.