Rebroussement et labyrinthes

CSC_41011 · TD4

 Login :  Mot de passe :

Ce TD traite de la résolution et la génération de labyrinthes dans une grille rectangulaire. Nous utiliserons la technique du rebroussement (cours 4, chapitre 12 du poly).

Avant de commencer :

Le but de ce TP est de compléter la classe Maze, de sorte à faire fonctionner l’interface graphique src/MazeGUI.java (que vous pouvez déjà lancer) et le fichier test src/Test.java.

1 Contexte

On décrit ici la structure de données utilisée par src/Maze.java pour représenter un labyrinthe.

1.1 Grille et indexation

Exemple de labyrinthe

1.2 Codes de cellule

Chacune des cases de la grille a un état parmi les valeurs suivantes.

enum Cell {
  VOID, WALL, REMOVABLE, RIGHT, UP, LEFT, DOWN, MARK
}
État des cases dans un labyrinthe 3×3 (grille augmentée 7×7), avec utilisation des marques directionnelles pour indiquer un chemin.

Les cases d’entrée (bleu) et de sortie (jaune) sont spécifiées par start et exit et ne correspondent pas à un état.

1.3 Voisinage d’une cellule

Pour itérer sur les voisins accessibles de la cellule à l’indice int cell, on peut donc écrire

for (int shift: dirs) {
  int passage = cell + shift;
  int next = cell + 2*shift; // indice de la cellule voisine
  if (grid[passage] == WALL || grid[passage]==REMOVABLE)
    continue; // Il y a un mur: on ne passe pas
  
  // ...
}

La condition sur grid[passage] peut bien sûr être adaptée selon la manière dont on veut traiter les murs amovibles.

1.4 Itération sur toutes les cellules

Les cellules sont les cases (i,j) avec i et j impairs. Donc on peut itérer sur toutes les cellules comme suit :

for (int i = 1; i < R; i += 2) {
  for (int j = 1; j < C; j += 2) {
    int cell = i + R*j;
    // ...
  }
}

1.5 Interface graphique

Les méthodes que vous developperez seront accessibles depuis l’interface MazeGUI.java.

La classe Maze fournit aussi toString(), ce qui permet d’afficher l’état du labyrinthe dans la console.

2 Résolution

Dans cette première question, nous voulons trouver un chemin entre deux cellules dans un labyrinthe donné, comme dans l’image ci-contre.

Exemple de labyrinthe résolu

Résoudre un labyrinthe entre une cellule de départ (en bleu) et une cellule d’arrivée (en jaune) signifie marquer un chemin avec les marqueurs de direction. La cellule d’arrivée n’est pas marquée. Les cellules en dehors du chemin ne sont pas marquées.

NB:

Implémentez la méthode boolean solve(int from, int to), où from et to sont des indices de cellules, de sorte que :

  1. elle renvoie true s’il existe entre from et to un chemin ne passant par aucune case marquée
    • dans ce cas, la méthode marque les cellule d’un tel chemin un utilisant les marques directionnelles;
  2. elle renvoie false sinon,
    • dans ce cas, grid doit être inchangé.

Déposez Maze.java.

Le nom du fichier à déposer
Il faut se connecter avant de pouvoir déposer

La méthode solve s’écrit naturellement de manière récursive. Pour transformer un décalage (1, -R, -1, R) en marque directionnelle (DOWN, LEFT, UP, RIGHT), on peut utiliser la méthode Maze.Cell markOfShift(int shift).

Testez votre code avec Test.java et MazeGUI.java.

Vous remarquez sans doute que la méthode commence avec l’appel step(). C’est utilisé pour ralentir l’animation de la recherche de chemin pour qu’elle soit observable à l’œil nu.

3 Génération

Dans le reste du TD, nous allons travailler sur la génération de labyrinthe. Ils doivent être parfaits, c’est-à-dire qu’il y un et un seul chemin entre chaque paire de cellules.

3.1 Génération par rebroussement récursif

Lors de la génération, on partira d’un labyrinthe dont toutes les cellules sont isolées, c’est-à-dire entourées de quatre murs, et leur marque est VOID. Les murs internes sont amovibles. La génération de labyrinthe consiste à supprimer des murs internes jusqu’à obtenir un labyrinthe parfait. On supprime le mur d’une cellule cell dans la direction shift avec grid[cell + shift] = VOID. On pourra marquer certaines cellules avec la marque neutre MARK:

Tout au long de la génération, on maintiendra les invariants suivants :

  1. Les cellules non marquées sont isolées.
  2. Toute cellule marquée est reliée à la cellule start par un chemin.

Et la suppression des murs est soumise à la règle :

  1. Seul un mur entre une cellule marquée et une cellule non marquée peut être supprimé.
État d’un labyrinthe pendant la génération. Les contraintes 1 à 3 ont été respectées.

Démontrer que :

  • si les contraintes 1 à 3 sont respectées,
  • et si toutes les cellules sont marquées,

alors le labyrinthe est parfait.

Complétez la méthode void generateRec(int cell). La méthode doit marquer toutes les cellules accessibles depuis cell par des cellules non marquées uniquement (en passant au travers des murs amovibles). Elle doit respecter les contraintes ci-dessus.

Déposez Maze.java.

Le nom du fichier à déposer
Il faut se connecter avant de pouvoir déposer

Pour ne pas toujours aboutir au même labyrinthe, on peut vouloir parcourir les voisins dans un ordre aléatoire. On peut, par exemple, procéder ainsi:

int[] random_dirs = dirs.clone();
shuffle(random_dirs);
for (int shift : random_dirs) {
  int next = cell + 2 * shift;
  //...
}

Testez votre code avec Test.java et MazeGUI.java.

3.2 Génération par rebroussement itératif

Nous allons maintenant traduire l’algorithme de génération récursif en une version itérative. Cela veut dire que nous échangeons la pile d’appels contre un conteneur abstrait Bag<Integer> bag. Ce conteneur ne fournit que trois méthodes:

On impose la règle supplémentaire suivante:

  1. Toute cellule marquée jouxtant une cellule non marquée est dans bag.

Démontrer que :

  • si les contraintes 1 à 4 sont respectées,
  • si start est marquée,
  • et si bag est vide,

alors le labyrinthe est parfait.

Complétez la méthode void generateIter(Bag bag). Elle prend en argument un bag vide et génère un labyrinthe parfait.

Déposez Maze.java.

Le nom du fichier à déposer
Il faut se connecter avant de pouvoir déposer

Labyrinthe engendré avec max.

Testez votre code avec Test.java et MazeGUI.java. Dans l’interface graphique, un menu permet de sélectionner le comportement de bag.pop():

N’oubliez pas d’appeler step() à chaque itération.

3.3 Génération uniforme (algorithme de Wilson)

Nous venons d’implémenter des algorithmes de génération de labyrinthes aléatoires. La forme des labyrinthes générés par le rebroussement itératif dépendait fortement du choix de la méthode de sélection de la prochaine case à traiter. En particulier, les labyrinthes générés ne sont pas uniformément distribués dans l’ensemble de tous les labyrinthes parfaits de la taille donnée.

Dans cet exercice nous allons donc implémenter un algorithme de génération uniforme. La méthode de génération choisie s’appelle l’algorithme de Wilson. Il procède comme suit :

  1. Marquer la cellule start avec MARK.

  2. Tant qu’il y a une cellule non marquée, choisir une cellule non marquée uniformément au hasard, et

    1. générer une marche aléatoire uniforme à partir de la cellule choisie jusqu’à ce qu’une cellule marquée soit rencontrée (on ignore les murs pour générer la marche), supprimer les boucles de la marche ;
    2. marquer toutes les cellules et supprimer tous les murs le long de la marche.
État du labyrinthe pendant l’algorithme de Wilson

Implémentez l’algorithme de Wilson au sein de la méthode void generateWilson().

Déposez Maze.java.

Le nom du fichier à déposer
Il faut se connecter avant de pouvoir déposer

L’algorithme est beaucoup plus simple à implémenter qu’il n’y paraît ! Pour tirer des cellules uniformes aléatoires successivement, il suffit de mettre toutes les cellules dans un tableau et de le mélanger avec shuffle.

Pour effacer les boucles d’une marche aléatoire, il suffit d’enregistrer dans grid la direction prise par la marche à chaque étape. On utilise pour cela les marques directionnelles (RIGHT, UP, LEFT, DOWN) et on écrase les valeurs précédente. Ainsi, quand la marche est terminée, on la reprend depuis son point de départ et on suit les flèches. Elles amènent jusqu’à l’arrivée sans faire de boucles.

Pour transformer un décalage (1, -R, -1, R) en marque directionnelle (DOWN, LEFT, UP, RIGHT), on peut utiliser la méthode Maze.Cell markOfShift(int shift). Pour la transformation inverse, on peut utiliser int shiftOfMark(Maze.Cell mark).

Testez votre code avec Test.java et MazeGUI.java.