Un labyrinthe en Java

Un labyrinthe en Java

1. introduction

Dans cet article, nous allons explorer les moyens possibles de naviguer dans un labyrinthe à l'aide de Java.

Considérez le labyrinthe comme une image en noir et blanc, avec des pixels noirs représentant des murs et des pixels blancs représentant un tracé. Deux pixels blancs sont spéciaux, l'un étant l'entrée du labyrinthe et l'autre sortie.

Compte tenu de ce labyrinthe, nous voulons trouver un chemin d’entrée à la sortie.

2. Modéliser le labyrinthe

Nous considérerons le labyrinthe comme un tableau d'entiers 2D. La signification des valeurs numériques dans le tableau sera conforme à la convention suivante:

  • 0 → Route

  • 1 → Mur

  • 2 → Entrée du labyrinthe

  • 3 → Sortie du labyrinthe

  • 4 → Partie de cellule du chemin de l'entrée à la sortie

We’ll model the maze as a graph. Entrée et sortie sont les deux nœuds spéciaux entre lesquels le chemin doit être déterminé.

Un graphique typique a deux propriétés, des nœuds et des arêtes. Un bord détermine la connectivité du graphique et relie un nœud à un autre.

Par conséquent, nous supposerons quatre arêtes implicites de chaque nœud, reliant le nœud donné à ses nœuds gauche, droit, supérieur et inférieur.

Définissons la signature de la méthode:

public List solve(Maze maze) {
}

L'entrée de la méthode est unmaze, qui contient le tableau 2D, avec la convention de dénomination définie ci-dessus.

La réponse de la méthode est une liste de noeuds, qui forme un chemin du noeud d'entrée au noeud de sortie.

3. Backtracker récursif (DFS)

3.1. Algorithme

Une approche assez évidente consiste à explorer tous les chemins possibles, qui finiront par trouver un chemin s’il existe. Mais une telle approche aura une complexité exponentielle et ne sera pas bien adaptée.

Cependant, il est possible de personnaliser la solution de force brute mentionnée ci-dessus, en faisant marche arrière et en marquant les nœuds visités, pour obtenir un chemin dans un délai raisonnable. Cet algorithme est également appeléDepth-first search.

Cet algorithme peut être décrit comme suit:

  1. Si nous sommes au mur ou à un nœud déjà visité, retournez échec

  2. Sinon, si nous sommes le nœud de sortie, alors renvoyez le succès

  3. Sinon, ajoutez le nœud dans la liste des chemins et parcourez-le de manière récursive dans les quatre directions. Si un échec est renvoyé, supprimez le nœud du chemin et renvoyez échec. La liste des chemins contiendra un chemin unique quand une sortie est trouvée

Appliquons cet algorithme au labyrinthe illustré à la figure 1 (a), où S est le point de départ et E la sortie.

Pour chaque nœud, nous parcourons chaque direction dans l'ordre: droite, bas, gauche, haut.

En 1 (b), nous explorons un chemin et heurtons le mur. Ensuite, nous revenons en arrière jusqu'à trouver un nœud qui a des voisins non muraux et explorons un autre chemin, comme indiqué dans 1 (c).

Nous heurtons de nouveau le mur et répétons le processus pour finalement trouver la sortie, comme indiqué en 1 (d):

imageimage imageimage

3.2. la mise en oeuvre

Voyons maintenant l'implémentation Java:

Premièrement, nous devons définir les quatre directions. Nous pouvons définir cela en termes de coordonnées. Ces coordonnées, lorsqu'elles sont ajoutées à une coordonnée donnée, renverront l'une des coordonnées voisines:

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

Nous avons également besoin d'une méthode utilitaire qui ajoutera deux coordonnées:

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

Nous pouvons maintenant définir la signature de méthodesolve.The logic here is simple - s'il y a un chemin entre l'entrée et la sortie, alors retourner le chemin, sinon, retourner une liste vide:

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

Définissons la méthodeexplore référencée ci-dessus. S'il y a un chemin, alors renvoie true, avec la liste des coordonnées dans l'argumentpath. Cette méthode a trois blocs principaux.

Tout d’abord, nous éliminons les nœuds non valides, c’est-à-dire les nœuds qui sont en dehors du labyrinthe ou font partie du mur. Après cela, nous marquons le nœud actuel comme visité afin de ne pas visiter le même nœud encore et encore.

Enfin, nous nous déplaçons récursivement dans toutes les directions si la sortie n'est pas trouvée:

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;
}

Cette solution utilise une taille de pile allant jusqu'à la taille du labyrinthe.

4. Variante - Chemin le plus court (BFS)

4.1. Algorithme

L’algorithme récursif décrit ci-dessus trouve le chemin, mais ce n’est pas nécessairement le chemin le plus court. Pour trouver le chemin le plus court, nous pouvons utiliser une autre approche de traversée de graphe connue sous le nom deBreadth-first search.

Dans DFS, un enfant et tous ses petits-enfants ont d'abord été explorés, avant de passer à un autre enfant. Whereas in BFS, we’ll explore all the immediate children before moving on to the grandchildren. Cela garantira que tous les nœuds à une distance particulière du nœud parent sont explorés en même temps.

L'algorithme peut être décrit comme suit:

  1. Ajouter le noeud de départ dans la file d'attente

  2. Alors que la file d'attente n'est pas vide, ouvrez un nœud, procédez comme suit:

    1. Si nous atteignons le mur ou si le nœud est déjà visité, passez à la prochaine itération

    2. Si le noeud de sortie est atteint, revenir du noeud actuel au noeud de départ pour trouver le chemin le plus court

    3. Sinon, ajouter tous les voisins immédiats dans les quatre directions de la file d'attente

One important thing here is that the nodes must keep track of their parent, i.e. from where they were added to the queue. Il est important de trouver le chemin une fois le nœud de sortie rencontré.

L’animation suivante montre toutes les étapes de l’exploration d’un labyrinthe à l’aide de cet algorithme. Nous pouvons observer que tous les nœuds situés à la même distance sont d'abord explorés avant de passer au niveau suivant:

image

4.2. la mise en oeuvre

Permet maintenant d'implémenter cet algorithme en Java. Nous réutiliserons la variableDIRECTIONS définie dans la section précédente.

Commençons par définir une méthode d’utilité pour revenir d’un nœud donné à sa racine. Ceci sera utilisé pour suivre le chemin une fois que l'exit est trouvé:

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

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

    return path;
}

Définissons maintenant la méthode principalesolve. Nous allons réutiliser les trois blocs utilisés dans l'implémentation DFS, c'est-à-dire valider le noeud, marquer le noeud visité et traverser les noeuds voisins.

Nous allons juste faire une légère modification. Au lieu d'un parcours récursif, nous utiliserons une structure de données FIFO pour suivre les voisins et les parcourir:

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

Dans ce didacticiel, nous avons décrit deux algorithmes de graphe majeurs, la recherche en profondeur d'abord et la recherche en largeur d'abord pour résoudre un labyrinthe. Nous avons également abordé la manière dont BFS donne le chemin le plus court entre l’entrée et la sortie.

Pour en savoir plus, recherchez d’autres méthodes pour résoudre un labyrinthe, comme les algorithmes A * et Dijkstra.

Comme toujours, le code complet peut être trouvéover on GitHub.