Ein Maze Solver in Java

Ein Labyrinth-Löser in Java

1. Einführung

In diesem Artikel werden mögliche Möglichkeiten zum Navigieren in einem Labyrinth mithilfe von Java untersucht.

Betrachten Sie das Labyrinth als ein Schwarzweißbild, wobei schwarze Pixel Wände und weiße Pixel einen Pfad darstellen. Zwei weiße Pixel sind etwas Besonderes, eines ist der Eingang zum Labyrinth und ein anderes der Ausgang.

In einem solchen Labyrinth wollen wir einen Weg vom Eingang zum Ausgang finden.

2. Das Labyrinth modellieren

Wir betrachten das Labyrinth als ein 2D-Integer-Array. Die Bedeutung der numerischen Werte im Array entspricht der folgenden Konvention:

  • 0 → Straße

  • 1 → Wand

  • 2 → Labyrintheintrag

  • 3 → Labyrinthausgang

  • 4 → Zellenteil des Pfades vom Eingang zum Ausgang

We’ll model the maze as a graph. Ein- und Ausstieg sind die beiden speziellen Knoten, zwischen denen der Weg bestimmt werden soll.

Ein typisches Diagramm hat zwei Eigenschaften: Knoten und Kanten. Eine Kante bestimmt die Konnektivität eines Graphen und verbindet einen Knoten mit einem anderen.

Daher nehmen wir vier implizite Kanten von jedem Knoten an, die den angegebenen Knoten mit seinem linken, rechten, oberen und unteren Knoten verbinden.

Definieren wir die Methodensignatur:

public List solve(Maze maze) {
}

Die Eingabe für die Methode istmaze,, die das 2D-Array enthält, wobei die Namenskonvention oben definiert ist.

Die Antwort der Methode ist eine Liste von Knoten, die einen Pfad vom Eingangsknoten zum Ausgangsknoten bilden.

3. Rekursiver Backtracker (DFS)

3.1. Algorithmus

Ein ziemlich naheliegender Ansatz besteht darin, alle möglichen Pfade zu untersuchen, die letztendlich einen Pfad finden, wenn er existiert. Ein solcher Ansatz ist jedoch exponentiell komplex und lässt sich nicht gut skalieren.

Es ist jedoch möglich, die oben erwähnte Brute-Force-Lösung anzupassen, indem besuchte Knoten zurückverfolgt und markiert werden, um in angemessener Zeit einen Pfad zu erhalten. Dieser Algorithmus wird auch alsDepth-first search bezeichnet.

Dieser Algorithmus kann wie folgt beschrieben werden:

  1. Wenn wir uns an der Wand oder an einem bereits besuchten Knoten befinden, wird ein Fehler zurückgegeben

  2. Andernfalls, wenn wir der Exit-Knoten sind, geben Sie den Erfolg zurück

  3. Fügen Sie andernfalls den Knoten in der Pfadliste hinzu und bewegen Sie sich rekursiv in alle vier Richtungen. Wenn ein Fehler zurückgegeben wird, entfernen Sie den Knoten aus dem Pfad und geben Sie den Fehler zurück. Die Pfadliste enthält einen eindeutigen Pfad, wenn der Exit gefunden wird

Wenden wir diesen Algorithmus auf das in Abbildung 1 (a) gezeigte Labyrinth an, wobei S der Startpunkt und E der Ausgang ist.

Für jeden Knoten durchlaufen wir jede Richtung in der Reihenfolge: rechts, unten, links, oben.

In 1 (b) erkunden wir einen Pfad und stoßen an die Wand. Dann fahren wir zurück, bis ein Knoten gefunden wurde, der keine Nachbarn an der Wand hat, und erkunden einen anderen Pfad, wie in 1 (c) gezeigt.

Wir schlagen erneut gegen die Wand und wiederholen den Vorgang, um endlich den Ausgang zu finden, wie in 1 (d) gezeigt:

imageimage imageimage

3.2. Implementierung

Sehen wir uns nun die Java-Implementierung an:

Zuerst müssen wir die vier Richtungen definieren. Wir können dies in Koordinaten definieren. Wenn diese Koordinaten zu einer bestimmten Koordinate hinzugefügt werden, wird eine der benachbarten Koordinaten zurückgegeben:

private static int[][] DIRECTIONS
  = { { 0, 1 }, { 1, 0 }, { 0, -1 }, { -1, 0 } };

Wir brauchen auch eine Utility-Methode, die zwei Koordinaten hinzufügt:

private Coordinate getNextCoordinate(
  int row, int col, int i, int j) {
    return new Coordinate(row + i, col + j);
}

Wir können jetzt die Methodensignatursolve.The logic here is simple definieren. Wenn es einen Pfad vom Eingang zum Ausgang gibt, geben Sie den Pfad zurück, andernfalls geben Sie eine leere Liste zurück:

public List solve(Maze maze) {
    List path = new ArrayList<>();
    if (
      explore(
        maze,
        maze.getEntry().getX(),
        maze.getEntry().getY(),
        path
      )
      ) {
        return path;
    }
    return Collections.emptyList();
}

Definieren wir die oben genannteexplore-Methode. Wenn es einen Pfad gibt, geben Sie true mit der Liste der Koordinaten im Argumentpath zurück. Diese Methode hat drei Hauptblöcke.

Zuerst verwerfen wir ungültige Knoten, d.h. die Knoten, die sich außerhalb des Labyrinths befinden oder Teil der Wand sind. Danach markieren wir den aktuellen Knoten als besucht, damit wir nicht immer wieder denselben Knoten besuchen.

Schließlich bewegen wir uns rekursiv in alle Richtungen, wenn der Ausgang nicht gefunden wird:

private boolean explore(
  Maze maze, int row, int col, List path) {
    if (
      !maze.isValidLocation(row, col)
      || maze.isWall(row, col)
      || maze.isExplored(row, col)
    ) {
        return false;
    }

    path.add(new Coordinate(row, col));
    maze.setVisited(row, col, true);

    if (maze.isExit(row, col)) {
        return true;
    }

    for (int[] direction : DIRECTIONS) {
        Coordinate coordinate = getNextCoordinate(
          row, col, direction[0], direction[1]);
        if (
          explore(
            maze,
            coordinate.getX(),
            coordinate.getY(),
            path
          )
        ) {
            return true;
        }
    }

    path.remove(path.size() - 1);
    return false;
}

Diese Lösung verwendet die Stapelgröße bis zur Größe des Labyrinths.

4. Variante - kürzester Weg (BFS)

4.1. Algorithmus

Der oben beschriebene rekursive Algorithmus findet den Pfad, ist jedoch nicht unbedingt der kürzeste Pfad. Um den kürzesten Weg zu finden, können wir einen anderen Graph-Traversal-Ansatz verwenden, der alsBreadth-first search bekannt ist.

In der DFS wurden ein Kind und alle seine Enkelkinder zuerst untersucht, bevor sie zu einem anderen Kind übergingen. Whereas in BFS, we’ll explore all the immediate children before moving on to the grandchildren. Dadurch wird sichergestellt, dass alle Knoten in einem bestimmten Abstand vom übergeordneten Knoten gleichzeitig untersucht werden.

Der Algorithmus kann wie folgt umrissen werden:

  1. Fügen Sie den Startknoten in der Warteschlange hinzu

  2. Führen Sie die folgenden Schritte aus, während die Warteschlange nicht leer ist:

    1. Wenn wir die Wand erreichen oder der Knoten bereits besucht ist, fahren Sie mit der nächsten Iteration fort

    2. Wenn der Ausgangsknoten erreicht ist, fahren Sie vom aktuellen Knoten bis zum Startknoten zurück, um den kürzesten Weg zu finden

    3. Andernfalls fügen Sie alle unmittelbaren Nachbarn in den vier Richtungen in die Warteschlange ein

One important thing here is that the nodes must keep track of their parent, i.e. from where they were added to the queue. Dies ist wichtig, um den Pfad zu finden, sobald der Exit-Knoten gefunden wird.

Die folgende Animation zeigt alle Schritte beim Erkunden eines Labyrinths mit diesem Algorithmus. Wir können beobachten, dass alle Knoten in der gleichen Entfernung zuerst erforscht werden, bevor wir zur nächsten Ebene übergehen:

image

4.2. Implementierung

Implementieren wir diesen Algorithmus jetzt in Java. Wir werden die im vorherigen Abschnitt definierte VariableDIRECTIONSwiederverwenden.

Definieren Sie zunächst eine Dienstprogrammmethode, um von einem bestimmten Knoten zu seinem Stamm zurückzukehren. Dies wird verwendet, um den Pfad zu verfolgen, sobald ein Exit gefunden wurde:

private List backtrackPath(
  Coordinate cur) {
    List path = new ArrayList<>();
    Coordinate iter = cur;

    while (iter != null) {
        path.add(iter);
        iter = iter.parent;
    }

    return path;
}

Definieren wir nun die Kernmethodesolve.. Wir werden die drei in der DFS-Implementierung verwendeten Blöcke wiederverwenden, d. H. Knoten validieren, besuchten Knoten markieren und benachbarte Knoten überqueren.

Wir werden nur eine geringfügige Änderung vornehmen. Anstelle einer rekursiven Durchquerung verwenden wir eine FIFO-Datenstruktur, um Nachbarn zu verfolgen und diese zu durchlaufen:

public List solve(Maze maze) {
    LinkedList nextToVisit
      = new LinkedList<>();
    Coordinate start = maze.getEntry();
    nextToVisit.add(start);

    while (!nextToVisit.isEmpty()) {
        Coordinate cur = nextToVisit.remove();

        if (!maze.isValidLocation(cur.getX(), cur.getY())
          || maze.isExplored(cur.getX(), cur.getY())
        ) {
            continue;
        }

        if (maze.isWall(cur.getX(), cur.getY())) {
            maze.setVisited(cur.getX(), cur.getY(), true);
            continue;
        }

        if (maze.isExit(cur.getX(), cur.getY())) {
            return backtrackPath(cur);
        }

        for (int[] direction : DIRECTIONS) {
            Coordinate coordinate
              = new Coordinate(
                cur.getX() + direction[0],
                cur.getY() + direction[1],
                cur
              );
            nextToVisit.add(coordinate);
            maze.setVisited(cur.getX(), cur.getY(), true);
        }
    }
    return Collections.emptyList();
}

5. Fazit

In diesem Tutorial haben wir zwei Hauptgraphenalgorithmen beschrieben: Tiefensuche und Breitensuche, um ein Labyrinth zu lösen. Wir haben auch angesprochen, wie BFS den kürzesten Weg vom Eingang zum Ausgang bietet.

Weitere Informationen finden Sie in anderen Methoden zum Lösen eines Labyrinths, z. B. A * - und Dijkstra-Algorithmus.

Wie immer kann der vollständige Codeover on GitHub gefunden werden.