Estructura de datos para juegos de mesa bidimensionales en lenguajes funcionales.

9

Estoy creando una implementación simple de MiniMax en el lenguaje de programación funcional Elixir. Debido a que hay muchos juegos de conocimiento perfecto (tic tac toe, connect-four, damas, ajedrez, etc.), esta implementación podría ser un marco para crear IA de juegos para cualquiera de estos juegos.

Sin embargo, un problema al que me enfrento es cómo almacenar correctamente un estado de juego en un lenguaje funcional. Estos juegos tratan principalmente de tableros de juego bidimensionales, donde las siguientes operaciones son frecuentes:

  • Leer el contenido de una ubicación específica del tablero
  • Actualice el contenido de una ubicación específica del tablero (cuando devuelva una nueva posibilidad de movimiento)
  • Considerando el contenido de una o más ubicaciones que están conectadas a la ubicación actual (es decir, las ubicaciones horizontales, verticales o diagonales siguientes o anteriores)
  • Teniendo en cuenta los contenidos de múltiples ubicaciones conectadas en cualquier dirección.
  • Considerando el contenido de archivos completos, rangos y diagonales.
  • Girar o reflejar el tablero (para verificar las simetrías que proporcionan el mismo resultado que algo ya calculado).

La mayoría de los lenguajes funcionales usan Listas vinculadas y Tuplas como bloques de construcción básicos de estructuras de datos de elementos múltiples. Sin embargo, estos parecen muy mal hechos para el trabajo:

  • Las listas vinculadas tienen un tiempo de búsqueda O (n) (lineal). Además, como no podemos "escanear y actualizar el tablero" de una sola vez, el uso de listas parece poco práctico.
  • Las tuplas tienen un tiempo de búsqueda O (1) (constante). Sin embargo, representar el tablero como una tupla de tamaño fijo hace que sea muy difícil iterar sobre rangos, archivos, diagonales u otros tipos de cuadrados consecutivos. Además, tanto Elixir, y Haskell (que son los dos lenguajes funcionales que conozco) sintaxis falta para leer el n -ésimo elemento de una tupla. Esto haría imposible escribir una solución dinámica que funcione para tableros de un tamaño arbitrario.

Elixir tiene una estructura de datos de mapa incorporada (y Haskell Data.Map) que permite el acceso O (log n) (logarítmico) a los elementos. En este momento uso un mapa, con x, ytuplas que representan la posición como claves.

Esto 'funciona' pero se siente mal abusar de los mapas de esta manera, aunque no sé exactamente por qué. Estoy buscando una mejor manera de almacenar un tablero de juego bidimensional en un lenguaje de programación funcional.

Qqwy
fuente
1
No puedo hablar de praxis, pero Haskell me viene a la mente dos cosas: cremalleras , que permiten "pasos" de tiempo constante sobre estructuras de datos, y comonads, que están relacionadas con las cremalleras según alguna teoría que no recuerdo ni entiendo correctamente; )
phipsgabler
¿Qué tan grande es este tablero de juego? Big O caracteriza cómo se escala un algoritmo, no qué tan rápido es. En un tablero pequeño (digamos, menos de 100 en cada dirección), es poco probable que O (1) vs.O (n) importe mucho, si solo tocas cada cuadrado una vez.
Robert Harvey
@RobertHarvey Va a variar. Pero para dar un ejemplo: en Ajedrez, tenemos un tablero de 64x64, pero todos los cálculos para verificar qué movimientos son posibles y para determinar el valor heurístico de la posición actual (diferencia en material, rey en jaque o no, peones pasados, etc.) Todos necesitan acceder a las casillas del tablero.
Qqwy
1
Tienes un tablero de 8x8 en ajedrez. En un lenguaje mapeado en memoria como C, puede hacer un cálculo matemático para obtener la dirección exacta de una celda, pero eso no es cierto en los lenguajes administrados en memoria (donde el direccionamiento ordinal es un detalle de implementación). No me sorprendería si saltar (un máximo de) 14 nodos toma aproximadamente la misma cantidad de tiempo que abordar un elemento de matriz en un lenguaje administrado por memoria.
Robert Harvey
Ver también stackoverflow.com/q/9611904/124319
coredump

Respuestas:

6

A Mapes precisamente la estructura de datos base correcta aquí. No estoy seguro de por qué te pondría incómodo. Tiene buenos tiempos de búsqueda y actualización, es de tamaño dinámico y es muy fácil crear estructuras de datos derivados. Por ejemplo (en Haskell):

filterWithKey (\k _ -> (snd k) == column) -- All pieces in given column
filterWithKey (\k _ -> (fst k) == row)    -- All pieces in given row
mapKeys (\(x, y) -> (-x, y))              -- Mirror

La otra cosa que con frecuencia es difícil de comprender para los programadores cuando comienzan a programar con total inmutabilidad es que no es necesario atenerse a una sola estructura de datos. Por lo general, elige una estructura de datos como su "fuente de verdad", pero puede hacer tantos derivados como desee, incluso derivados de derivados, y sabe que permanecerán sincronizados todo el tiempo que los necesite.

Esto significa que puede usar una Mapen el nivel superior, pasando después a una Listso Arrayspara el análisis de la fila, a continuación, Arraysindexada a la inversa para el análisis de la columna, a continuación, máscaras de bits para el análisis de patrones, a continuación, Stringspara su visualización. Los programas funcionales bien diseñados no pasan una sola estructura de datos. Son una serie de pasos que toman una estructura de datos y emiten una nueva que es adecuada para el siguiente paso.

Siempre que pueda salir del otro lado con un movimiento en un formato que el nivel superior pueda entender, no necesita preocuparse de cuánto reestructura los datos intermedios. Es inmutable, por lo que es posible trazar un camino de regreso a la fuente de la verdad en el nivel superior.

Karl Bielefeldt
fuente
4

Hice esto recientemente en F # y terminé usando una lista unidimensional (en F #, esa es una lista de enlace único). En la práctica, la velocidad del indexador de lista O (n) no es un cuello de botella para los tamaños de tableros utilizables por el hombre. Experimenté con otros tipos como la matriz 2d, pero al final, fue una compensación de escribir mi propio código de verificación de igualdad de valores o una traducción de rango y archivo a índice y viceversa. Este último era más simple. Yo diría que primero funcione, y luego optimice su tipo de datos más tarde si es necesario. No es probable que haga una diferencia lo suficientemente grande como para importar.

Al final, su implementación no debería importar tanto, siempre y cuando las operaciones de su placa estén correctamente encapsuladas por tipo de placa y operaciones. Por ejemplo, así es como pueden verse algunas de mis pruebas para configurar un tablero:

let pos r f = {Rank = r; File = f} // immutable record type
// or
let pos r f = OnBoard (r, f) // algebraic type
...
let testBoard =
    Board.createEmpty ()
    |> Board.setPiece p (pos 1 2)
    |> ...

Para este código (o cualquier código de llamada), no importaría cómo se representara el tablero. El tablero está representado por las operaciones en él más que su estructura subyacente.

Kasey Speakman
fuente
Hola, ¿tienes el código publicado en alguna parte? Actualmente estoy trabajando en un juego de ajedrez en F # (un proyecto divertido), y aunque estoy usando un Mapa <Cuadrado, Pieza> para representar el tablero, me encantaría ver cómo lo encapsulaste en un Tablero tipo y módulo.
asibahi
No, no está publicado en ningún lado.
Kasey Speakman
Entonces, ¿ le importaría echar un vistazo a mi implementación actual y aconsejarme cómo podría mejorarla?
asibahi
Echando un vistazo a los tipos, me decidí por una implementación muy similar hasta que llegué al tipo Posición. Haré un análisis más exhaustivo sobre la Revisión del Código más adelante.
Kasey Speakman