Datenstruktur für zweidimensionale Brettspiele in funktionalen Sprachen

9

Ich erstelle eine einfache MiniMax- Implementierung in der funktionalen Programmiersprache Elixir. Da es viele Spiele mit perfektem Wissen gibt (Tic Tac Toe, Connect-Four, Dame, Schach usw.), könnte diese Implementierung ein Framework für die Erstellung von Spiel-AIs für jedes dieser Spiele sein.

Ein Problem, mit dem ich konfrontiert bin, ist jedoch, wie ein Spielstatus in einer funktionalen Sprache richtig gespeichert wird. Diese Spiele befassen sich hauptsächlich mit zweidimensionalen Spielbrettern, bei denen die folgenden Operationen häufig sind:

  • Lesen Sie den Inhalt einer bestimmten Platinenposition
  • Aktualisieren Sie den Inhalt eines bestimmten Board-Standorts (wenn Sie eine neue Verschiebungsmöglichkeit zurückgeben).
  • Berücksichtigung des Inhalts eines oder mehrerer Standorte, die mit dem aktuellen Standort verbunden sind (dh des nächsten oder vorherigen horizontalen, vertikalen oder diagonalen Standorts)
  • Berücksichtigung des Inhalts mehrerer verbundener Standorte in eine beliebige Richtung.
  • Berücksichtigung des Inhalts ganzer Dateien, Ränge und Diagonalen.
  • Drehen oder Spiegeln der Platine (um nach Symmetrien zu suchen, die das gleiche Ergebnis liefern wie bereits berechnete).

Die meisten funktionalen Sprachen verwenden verknüpfte Listen und Tupel als Grundbausteine ​​für Datenstrukturen mit mehreren Elementen. Diese scheinen jedoch sehr schlecht für den Job gemacht zu sein:

  • Verknüpfte Listen haben eine O (n) (lineare) Suchzeit. Da wir das Board nicht in einem einzigen Durchlauf scannen und aktualisieren können, erscheint die Verwendung von Listen sehr unpraktisch.
  • Tupel haben eine O (1) (konstante) Suchzeit. Die Darstellung der Tafel als Tupel mit fester Größe macht es jedoch sehr schwierig, über Ränge, Dateien, Diagonalen oder andere Arten aufeinanderfolgender Quadrate zu iterieren. Außerdem fehlt sowohl Elixir als auch Haskell (die beiden mir bekannten Funktionssprachen) die Syntax zum Lesen des n- ten Elements eines Tupels. Dies würde es unmöglich machen, eine dynamische Lösung zu schreiben, die für Boards beliebiger Größe funktioniert.

Elixir verfügt über eine integrierte Map- Datenstruktur (und Haskell Data.Map), die O (log n) (logarithmisch) Zugriff auf Elemente ermöglicht. Im Moment verwende ich eine Karte mit x, yTupeln, die die Position als Schlüssel darstellen.

Das funktioniert, aber es fühlt sich falsch an, Karten auf diese Weise zu missbrauchen, obwohl ich nicht genau weiß, warum. Ich suche nach einer besseren Möglichkeit, ein zweidimensionales Spielbrett in einer funktionalen Programmiersprache zu speichern.

Qqwy
quelle
1
Ich kann nicht über die Praxis sprechen, aber Haskell fällt mir zwei Dinge ein: Reißverschlüsse , die zeitlich konstante "Schritte" über Datenstrukturen ermöglichen, und Comonaden, die nach einer Theorie, an die ich mich weder erinnere noch richtig verstehe, mit Reißverschlüssen verwandt sind; )
Phipsgabler
Wie groß ist dieses Spielbrett? Big O kennzeichnet, wie ein Algorithmus skaliert und nicht wie schnell er ist. Auf einem kleinen Brett (z. B. weniger als 100 in jede Richtung) ist es unwahrscheinlich, dass O (1) gegen O (n) eine große Rolle spielt, wenn Sie jedes Quadrat nur einmal berühren.
Robert Harvey
@ RobertHarvey Es wird variieren. Aber um ein Beispiel zu geben: Im Schach haben wir ein 64x64-Brett, aber alle Berechnungen, um zu überprüfen, welche Bewegungen möglich sind, und um den heuristischen Wert der aktuellen Position zu bestimmen (Materialunterschied, König in Schach oder nicht, übergebene Bauern usw.) Alle müssen auf die Felder der Tafel zugreifen.
Qqwy
1
Sie haben ein 8x8-Brett im Schach. In einer speicherabgebildeten Sprache wie C können Sie eine mathematische Berechnung durchführen, um die genaue Adresse einer Zelle zu erhalten. Dies gilt jedoch nicht für speicherverwaltete Sprachen (bei denen die ordinale Adressierung ein Implementierungsdetail ist). Es würde mich nicht überraschen, wenn das Springen über (maximal) 14 Knoten ungefähr so ​​lange dauert wie das Adressieren eines Array-Elements in einer speicherverwalteten Sprache.
Robert Harvey

Antworten:

6

A Mapist hier genau die richtige Basisdatenstruktur. Ich bin mir nicht sicher, warum es dich unruhig machen würde. Es hat gute Such- und Aktualisierungszeiten, eine dynamische Größe und es ist sehr einfach, abgeleitete Datenstrukturen daraus zu erstellen. Zum Beispiel (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

Die andere Sache, die für Programmierer häufig schwer zu verstehen ist, wenn sie zum ersten Mal mit voller Unveränderlichkeit programmieren, ist, dass Sie sich nicht nur an eine Datenstruktur halten müssen. Normalerweise wählen Sie eine Datenstruktur als "Quelle der Wahrheit", aber Sie können so viele Ableitungen erstellen, wie Sie möchten, sogar Ableitungen von Ableitungen, und Sie wissen, dass sie so lange synchronisiert bleiben, wie Sie sie benötigen.

Das heißt, Sie können a Mapauf der obersten Ebene verwenden, dann zu Listsoder Arraysfür Arraysdie Zeilenanalyse wechseln und dann für die Spaltenanalyse in die andere Richtung indizieren, dann Bitmasken für die Musteranalyse und dann Stringsfür die Anzeige. Gut gestaltete Funktionsprogramme geben keine einzige Datenstruktur weiter. Es handelt sich um eine Reihe von Schritten, bei denen eine Datenstruktur aufgenommen und eine neue ausgegeben wird, die für den nächsten Schritt geeignet ist.

Solange Sie mit einer Verschiebung in einem Format, das die oberste Ebene verstehen kann, auf die andere Seite kommen können, müssen Sie sich keine Sorgen machen, wie stark Sie die Daten dazwischen umstrukturieren. Es ist unveränderlich, so dass es möglich ist, einen Weg zurück zur Quelle der Wahrheit auf der obersten Ebene zu verfolgen.

Karl Bielefeldt
quelle
4

Ich habe dies kürzlich in F # getan und am Ende eine eindimensionale Liste verwendet (in F # ist das eine einfach verknüpfte Liste). In der Praxis ist die Geschwindigkeit des O (n) -Listenindexers kein Engpass für vom Menschen verwendbare Boardgrößen. Ich habe mit anderen Typen wie 2d-Arrays experimentiert, aber am Ende war es der Kompromiss, entweder meinen eigenen Code zur Überprüfung der Wertgleichheit zu schreiben oder eine Übersetzung von Rang und Datei in Index und zurück. Letzteres war einfacher. Ich würde sagen, dass es zuerst funktioniert und dann später Ihren Datentyp bei Bedarf optimiert wird. Es wird wahrscheinlich keinen großen Unterschied machen, um eine Rolle zu spielen.

Am Ende sollte Ihre Implementierung nicht so wichtig sein, solange Ihre Board-Operationen ordnungsgemäß nach Board-Typ und -Operationen gekapselt sind. So sehen einige meiner Tests zum Beispiel aus, um ein Board einzurichten:

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

Für diesen Code (oder einen aufrufenden Code) wäre es egal, wie die Tafel dargestellt wird. Der Vorstand wird durch die Operationen auf ihm mehr vertreten als durch seine zugrunde liegende Struktur.

Kasey Speakman
quelle
Hallo, hast du den Code irgendwo veröffentlicht? Ich arbeite derzeit an einem schachartigen Spiel in F # (ein Projekt zum Spaß), und obwohl ich eine Karte <Quadrat, Stück> verwende, um das Brett darzustellen, würde ich gerne sehen, wie Sie es in ein Brett eingekapselt haben Typ und Modul.
Asibahi
Nein, es wird nirgendwo veröffentlicht.
Kasey Speakman
Würde es Ihnen etwas ausmachen, einen Blick auf meine aktuelle Implementierung zu werfen und zu beraten, wie ich sie verbessern könnte?
Asibahi
Als ich mir die Typen ansah, entschied ich mich für eine sehr ähnliche Implementierung, bis ich zum Positionstyp kam. Ich werde später eine gründlichere Analyse der Codeüberprüfung durchführen.
Kasey Speakman