Promenades dans un labyrinthe

Pour illustrer les différentes façons de parcourir un graphe, nous allons programmer des promenades dans des labyrinthes. Ces promenades sont de difficulté croissante, la plus difficile étant bien entendue celle du touriste consciencieux...

1  Les labyrinthes

On considère des labyrinthes rectangulaires de taille hauteur x largeur. L'entrée du labyrinthe est en haut à gauche, la sortie en bas à droite. Un labyrinthe n'est rien d'autre qu'un graphe. Par exemple, le labyrinthe
correspond au graphe suivant :

Ce labyrinthe, et d'autres, sont disponibles sous forme de fichiers textes. En voici la liste :

Labyrinthes taille Plus court chemin Plus long chemin
petit 4 x 8 1218
grand16 x 27 41>=337
a20 x 25 43>=391
b10 x 12 2882
c50 x 50 98>=2036
d60 x 60 118>=1944
zoo7 x 22 27107
foo7 x 22 27153
bar7 x 22 27153

La première étape consiste à lire ces fichiers pour en construire une représentation interne. Le labyrinthe étant un tableau de cases, on pourra avantageusement utiliser un tableau, chaque case contenant un pointeur vers une structure qui mémorise différentes informations, comme la liste des cases voisines accessibles.

Écrivez les structures de données pour la représentation du labyrinthe. Le labyrinthe lui-même pourra être contenu dans une variable globale.

Pour la lecture du labyrinthe, vous pouvez vous inspirer de la procédure partielle suivante :

void lire_labyrinthe(char *fichier){
  FILE *file;
  char tampon[MAX_TAMPON]; //MAX_TAMPON doit avoir été défini par #define
  int i,j,l;
  file = fopen(fichier,"r");
  fgets(tampon, MAX_TAMPON, file);
  l = strlen(tampon);
  if(tampon[l-1]!='\n'){
    fprintf(stderr,"Labyrinthe trop large.");
    exit(1);
  }
  for(i=0;fgets(tampon, MAX_TAMPON, file);i++); 
  init_labyrinthe(i/2,(l-2)/2);
  fclose(file);
  file=fopen(fichier,"r");
  fgets(tampon, MAX_TAMPON, file);
  for(i=1; fgets(tampon, MAX_TAMPON, file); i++){
    for(j=1; j<lab_larg; j++){//lab_larg est une variable globale initialisée par init_labyrinthe
      if(tampon[j*2]==' ') {
        /* ajouter un voisin aux cases (i,j) et (i,j+1) */
      }
    }
    fgets(tampon, MAX_TAMPON, file);
    for(j=1; j<lab_larg; j++){
      if(tampon[j*2-1]==' ') {
        /* ajouter un voisin aux cases (i,j) et (i+1,j) */
      }
    }
  }
  fclose(file);
}
Vous remarquerez que cette procédure suppose l'existance d'une procédure init_labyrinthe qui initialise le labyrinthe et lui alloue de la mémoire. Il serait donc sage d'écrire aussi une fonction free_labyrinthe qui désalloue cette mémoire.

Pour faciliter les parcours et leur impression, ajoutez à vos structures de case un champ qui indique si une case est dans le parcours et des champs pour indiquer dans quelle direction on va.

Écrivez une fonction qui imprime un labyrinthe et la solution en cours.

Une solution possible

2  Première promenade : chercher la sortie

La première fois qu'on visite un labyrinthe, c'est souvent pour s'amuser à trouver la sortie. Quand on le fait de façon systématique (méthode de la main gauche, qui consiste à toujours tourner à gauche sans revenir sur ses pas), on visite en fait le labyrinthe par un parcours en profondeur.

Écrire cette première promenade (il suffit d'une procédure récursive qui prend en argument un point de départ et un point d'arrivée et retourne 1 ou 0 selon qu'on peut ou non atteindre l'arrivée).

On peut noter que cet algorithme est particulièrement inefficace quand le labyrinthe n'a pas de sortie. Ce n'est d'ailleurs pas la seule situation où cet algorithme est lent. Essayez d'évaluer sa complexité.

3  Deuxième promenade : le visiteur pressé

Cette fois, on essaye de sortir du labyrinthe le plus vite possible. L'algorithme le plus efficace est le parcours en largeur. Pour la file dans laquelle on met les noeuds à visiter, vous pourrez utiliser deux pointeurs, l'un sur le début d'une liste simplement chaînée, et l'autre sur le dernier élément de cette liste. Cette structure permet facilement l'ajout en queue et la suppression en tête.

Écrire la structure de données dans laquelle on range les noeuds à visiter. Chaque élément de la liste doit contenir un noeud de départ et un noeud d'arrivée qui doit être celui à visiter. Écrire aussi les procédures d'insertion d'un couple (départ, arrivée) dans la file, de lecture et suppression en tête dans la file et de libération de mémoire.

L'arbre des plus courts chemins sera représenté par un tableau dont les éléments sont au départ 0, puis si le noeud est dans l'arbre, le numéro de son père (ou lui-même, si le noeud est la racine).

Écrire la recherche du plus court chemin pour les labyrinthes.

4  Troisième promenade : le touriste

Les vraies promenades, ça prend du temps. Dans cet esprit, nous allons chercher le plus long chemin possible (sans cycle, évidemment) entre l'entrée et la sortie.

4.1  Première solution : recherche exhaustive

Le parcours en profondeur explore tous les chemins possibles jusqu'à en trouver un qui arrive à la sortie. Il est facile de le modifier pour qu'il ne s'arrête pas à la première solution mais continue en gardant la solution la plus longue en mémoire.

Tester cette solution.

Cette solution est bien entendu très coûteuse. On peut l'améliorer en évitant une partie du travail inutile. Il suffit de reconnaître si une configuration donnée a une solution ou non. Pour cela, la recherche de chemin le plus court à partir de la configuration est assez efficace.

Modifier cette solution en cherchant à chaque noeud si il existe un chemin vers la sortie (et si non, ne pas faire les recherches dans cette configuration).

4.2  Peut-on faire mieux ?

Puisqu'il existe un algorithme pour trouver le chemin le plus court en un temps raisonnable, on peut se demander si un tel algorithme existe pour le chemin le plus long. En fait, la réponse reste ouverte. Ce problème appartient à la classe des problèmes NP-complets. C'est à dire que si il existe un algorithme pour le résoudre en temps polynomial dans le cas le pire, alors il en existe un aussi pour une série de problèmes classiques (satisfiabilité des formules booléennes, voyageur de commerce...) qu'on ne sait pas à l'heure actuelle résoudre en temps polynomial (notez qu'on ne sait pas non plus si c'est impossible).

Dans de tels cas, on peut trouver des heuristiques qui vont donner le bon résultat dans la plupart des cas, ou des solutions qui s'approchent du meilleur résultat.

Promenade heuristique

On peut obtenir de bons résultats approchés tout simplement en orientant la recherche en profondeur. L'orientation consiste à choisir l'ordre dans lequel on parcourt les arêtes d'un noeud donné. L'idée est de choisir en premier celles qui éloignent de la sortie (selon une distance à préciser : euclidienne, Max, Manhattan...).

Si vous avez stocké le graphe sous forme de tableau de listes d'adjacence vous pouvez trier ces listes en fonction du point de sortie avant de faire le parcours en profondeur classique. Sinon, vous pouvez modifier l'ordre de parcours des arêtes selon la position du noeud courant par rapport à la sortie.

Écrire cette promenade et comparez avec les résultats obtenus avec la méthode précédente. Vous pouvez encore améliorer l'algorithme de la même façon que précédemment, en vérifiant avant de faire la recherche s'il existe un chemin vers la sortie. Testez la différence avec les très gros labyrinthes.

Faire des détours

Le résultat de l'algorithme précédent donne la solution suivante sur le grand labyrinthe :
Un être humain moyennement constitué voit facilement comment améliorer ce chemin. Par exemple, on peut dès le début faire un petit détour qui rajoute 9 cases.

Écrire un algorithme qui, partant d'un chemin (par exemple celui obtenu avec l'heuristique précédente), cherche à l'allonger en y rajoutant des détours. Faire cela de façon récursive jusqu'à ce qu'on ne puisse plus rajouter de détour.

Solution