Struttura dei dati per giochi da tavolo bidimensionali in linguaggi funzionali


9

Sto creando una semplice implementazione MiniMax nel linguaggio di programmazione funzionale Elixir. Poiché esistono molti giochi a conoscenza perfetta (tic tac toe, connect-four, pedine, scacchi, ecc.), Questa implementazione potrebbe essere un framework per la creazione di II di giochi per uno di questi giochi.

Un problema che sto affrontando, tuttavia, è come memorizzare correttamente uno stato di gioco in un linguaggio funzionale. Questi giochi hanno principalmente a che fare con schede di gioco bidimensionali, dove sono frequenti le seguenti operazioni:

  • Leggi i contenuti di una posizione specifica della scheda
  • Aggiorna il contenuto di una posizione specifica della scheda (quando restituisci una nuova possibilità di spostamento)
  • Considerando il contenuto di una o più posizioni collegate alla posizione corrente (ovvero le posizioni orizzontale, verticale o diagonale successive o precedenti)
  • Considerando il contenuto di più posizioni connesse in qualsiasi direzione.
  • Considerando il contenuto di interi file, gradi e diagonali.
  • Rotazione o mirroring della scheda (per verificare la presenza di simmetrie che forniscono lo stesso risultato di qualcosa già calcolato).

La maggior parte dei linguaggi funzionali utilizza liste e tuple collegate come elementi di base delle strutture dati multi-elemento. Tuttavia, questi sembrano fatti molto male per il lavoro:

  • Gli elenchi collegati hanno un tempo di ricerca O (n) (lineare). Inoltre, dato che non possiamo "scansionare e aggiornare la scheda" in una sola passata sulla scheda, l'utilizzo degli elenchi sembra molto poco pratico.
  • Le tuple hanno tempo di ricerca O (1) (costante). Tuttavia, rappresentare la scheda come una tupla di dimensioni fisse rende molto difficile iterare gradi, file, diagonali o altri tipi di quadrati consecutivi. Inoltre, sia Elisir, e Haskell (che sono i due linguaggi funzionali che conosco) la sintassi mancanza di leggere la n -esimo elemento di una tupla. Ciò renderebbe impossibile scrivere una soluzione dinamica che funzionerebbe per schede di dimensioni arbitrarie.

L'elisir ha una struttura di dati cartografica integrata (e Haskell ha Data.Map) che consente l'accesso O (log n) (logaritmico) agli elementi. In questo momento uso una mappa, con x, ytuple che rappresentano la posizione come chiavi.

"Funziona" ma sembra sbagliato abusare delle mappe in questo modo, anche se non so esattamente perché. Sto cercando un modo migliore per memorizzare un tabellone di gioco bidimensionale in un linguaggio di programmazione funzionale.


1
Non posso parlare di prassi, ma da Haskell mi vengono in mente due cose: le cerniere lampo , che consentono "passaggi" a tempo costante sulle strutture di dati e le caratteristiche comuni, che sono collegate alle cerniere da una teoria che non ricordo né capisco correttamente; )
phipsgabler,

Quanto è grande questo tavolo da gioco? Big O caratterizza la scala di un algoritmo, non la sua velocità. Su una lavagna piccola (diciamo, meno di 100 in ogni direzione), è improbabile che O (1) vs. O (n) contino molto, se tocchi un solo quadrato una volta.
Robert Harvey,

@RobertHarvey Varia. Ma per fare un esempio: in Chess abbiamo una scacchiera 64x64, ma tutti i calcoli per verificare quali mosse sono possibili e per determinare il valore euristico della posizione corrente (differenza nel materiale, re in check o no, pedine passate, ecc.) tutti devono accedere ai quadrati del tabellone.
Qqwy,

1
Hai una scacchiera 8x8 negli scacchi. In un linguaggio mappato in memoria come C, è possibile effettuare un calcolo matematico per ottenere l'indirizzo esatto di una cella, ma ciò non è vero nei linguaggi gestiti in memoria (dove l'indirizzamento ordinale è un dettaglio di implementazione). Non mi sorprenderebbe se saltare su (un massimo di) 14 nodi impiegasse all'incirca lo stesso tempo impiegato per indirizzare un elemento array in un linguaggio gestito dalla memoria.
Robert Harvey,

Risposte:


6

A Mapè esattamente la giusta struttura di dati di base qui. Non sono sicuro del motivo per cui ti metterebbe a disagio. Ha una buona ricerca e tempi di aggiornamento, è di dimensioni dinamiche ed è molto facile creare strutture di dati derivati. Ad esempio (in 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

L'altra cosa che è spesso difficile da comprendere per i programmatori quando iniziano a programmare per la prima volta con la massima immutabilità è che non è necessario attenersi a una sola struttura di dati. Di solito scegli una struttura di dati come "fonte di verità", ma puoi ricavare tutti i derivati ​​che desideri, anche i derivati ​​di derivati, e sai che rimarranno sincronizzati finché ne avrai bisogno.

Ciò significa che è possibile utilizzare Mapa al livello più alto, quindi passare a Listso Arraysper l'analisi delle righe, quindi Arraysindicizzare l'altro modo per l'analisi delle colonne, quindi maschere di bit per l'analisi del modello, quindi Stringsper la visualizzazione. Programmi funzionali ben progettati non trasmettono una singola struttura di dati. Sono una serie di passaggi che accettano una struttura di dati e ne emettono una nuova adatta al passaggio successivo.

Finché puoi uscire dall'altra parte con una mossa in un formato che il livello superiore può capire, non devi preoccuparti di quanto ristrutturi i dati tra di loro. È immutabile, quindi è possibile tracciare un percorso alla fonte della verità ai massimi livelli.


4

L'ho fatto di recente in F # e ho finito per usare un elenco unidimensionale (in F #, che è un elenco a link singolo). In pratica, la velocità dell'indicizzatore O (n) list non è un collo di bottiglia per dimensioni di schede utilizzabili dall'uomo. Ho sperimentato altri tipi come l'array 2d, ma alla fine è stato il compromesso di scrivere il mio codice di controllo dell'uguaglianza di valore o una traduzione di rango e file da indicizzare e tornare indietro. Quest'ultimo è stato più semplice. Direi di farlo funzionare prima, quindi di ottimizzare il tipo di dati in seguito, se necessario. Non è probabile che faccia una differenza abbastanza grande per la materia.

Alla fine l'implementazione non dovrebbe avere importanza se le operazioni del consiglio di amministrazione sono correttamente incapsulate dal tipo e dalle operazioni del consiglio di amministrazione. Ad esempio, ecco come possono apparire alcuni dei miei test per configurare una scheda:

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)
    |> ...

Per questo codice (o qualsiasi altro codice chiamante), non importava come fosse rappresentata la scheda. Il consiglio è rappresentato dalle operazioni su di esso più della sua struttura sottostante.


Ciao, hai pubblicato il codice da qualche parte? Attualmente sto lavorando a una partita a scacchi in F # (un progetto divertente), e anche se sto usando una mappa <Square, Piece> per rappresentare il tabellone, mi piacerebbe vedere come lo hai incapsulato in un tabellone tipo e modulo.
asibahi,

No, non è pubblicato da nessuna parte.
Kasey Speakman,

quindi ti dispiacerebbe dare un'occhiata alla mia attuale implementazione e suggerire come potrei migliorarla?
asibahi,

Dando un'occhiata ai tipi, ho optato per un'implementazione molto simile fino a quando non sono arrivato al tipo Posizione. Farò un'analisi più approfondita sulla revisione del codice in seguito.
Kasey Speakman,
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.