Activité pratique

Durée2h30

Consignes globales

Cette activité prolonge la précédente : vous allez programmer un nouvel algorithme de parcours, l’algorithme de Dijkstra, qui permet de trouver des plus courts chemins dans un graphe pondéré. Nous allons travailler avec la configuration suivante :

  • Un seul joueur.
  • Un seul morceau de fromage à attraper.
  • On ajoute de la boue dans le labyrinthe (20-30% des arêtes).

Autant que possible, vous repartirez du code factorisé à la fin de la séance 2 : vous verrez que l’algorithme de Dijkstra s’y intègre très naturellement. Vous comparerez ensuite ce nouveau joueur aux précédents.

Cette séance sera moins guidée que la précédente, et ce sera de plus en plus le cas à mesure que vous avancerez dans le projet. Nous restons néanmoins disponibles pour vous aider si vous avez des questions, en classe ou sur Discord.

Important

Le code produit lors de cette séance (et des précédentes) fera partie du premier livrable évalué. Consultez les détails sur la page de la séance.

Contenu de l’activité

Constater le problème

L’algorithme BFS trouve le chemin qui comporte le moins de cases. Dans un labyrinthe sans boue, c’est aussi le chemin le plus rapide. Voyons ce qu’il en est quand on ajoute de la boue.

À faire

À l’aide des scripts développés lors de la séance précédente (par exemple visualize_factorized_bfs.py, ou visualize_bfs.py), visualisez le comportement d’un joueur BFS dans un labyrinthe avec de la boue. Quel est le souci ?

Algorithme de Dijkstra

Partir de la version factorisée

Lors de la séance précédente, vous avez factorisé les parcours en profondeur et en largeur dans une classe abstraite Traversal. Ses sous-classes BFSTraversal et DFSTraversal ne diffèrent que par la structure de données qui stocke les sommets à explorer : une file pour l’une, une pile pour l’autre.

Information

Vous n’avez pas terminé la factorisation de la séance 2 ? Ce n’est pas grave : créez plutôt un nouveau joueur Dijkstra, dans un fichier players/dijkstra.py, en partant d’une copie de votre fichier bfs.py. Vous réfléchirez à la factorisation des codes après.

Comme vous l’avez vu dans l’article sur l’algorithme de Dijkstra, cet algorithme suit exactement le même schéma. Seule la structure change : c’est une file de priorité (un tas min), qui renvoie toujours le sommet le plus proche de la source parmi ceux qui restent à explorer.

À faire

Avant d’écrire du code, reprenez le diagramme UML de la séance 2, et réfléchissez aux questions ci-dessous.

  • Quelle(s) classe(s) faut-il créer ou modifier pour ajouter l’algorithme de Dijkstra à cette organisation ? Complétez le diagramme en conséquence (sur papier, ça suffit).
  • Quels attributs et méthodes doivent être créés ?
  • La bibliothèque heapq, présentée dans l’article sur les tas min, vous sera utile. Comment pouvez-vous l’utiliser ?
  • Votre méthode traversal(...) utilise-t-elle les poids des arêtes pour calculer les distances ? Si vous devez la modifier, cela change-t-il quelque chose pour BFSTraversal et DFSTraversal ?

Préparation

À faire

Créez les fichiers nécessaires dans votre workspace PyRat en suivant les instructions ci-dessous. Pour le moment, ne vous préoccupez pas du comportement de votre joueur, créez juste le strict minimum pour pouvoir lancer une partie sans que ça crashe.

  • utils/traversal/dijkstra_traversal.py — Créez une classe DijkstraTraversal héritant de Traversal, en vous inspirant de bfs_traversal.py.

    Si vous n’avez pas factorisé à la séance précédente, vous pouvez commencer par créer directement une classe Dijkstra dans players/dijkstra.py en vous inspirant de players/bfs.py.

  • players/traversal_player.py — Rien à modifier : le joueur reçoit son parcours à sa création. Vous pouvez éventuellement faire de DijkstraTraversal son parcours par défaut, utilisé quand aucun parcours n’est donné.

    Si vous n’avez pas factorisé à la séance précédente, vous n’avez rien à faire ici.

  • games/visualize_factorized_dijkstra.py — Créez un script qui lancera une partie avec ce joueur, dans un labyrinthe respectant les critères listés plus haut. Vous pouvez éventuellement définir une random_seed (pour toujours travailler avec le même labyrinthe) et une trace_length (pour suivre la trajectoire de votre joueur).

    Si vous n’avez pas factorisé à la séance précédente, nommez plutôt votre script games/visualize_dijkstra.py.

  • tests/test_dijkstra_traversal.py — Créez un fichier qui contiendra les tests unitaires de votre algorithme. Pour le moment, créez juste le fichier avec une classe de tests copiée-collée de test_bfs_traversal.py (ou de test_bfs.py), en changeant le nom de la classe et en enlevant les méthodes de test.

    Si vous n’avez pas factorisé à la séance précédente, nommez plutôt votre fichier tests/test_dijkstra.py.

Implémentation

À faire

Complétez la classe DijkstraTraversal (ou le joueur Dijkstra), en vous appuyant sur vos réponses aux questions plus haut.

Tests

Pour programmer et tester votre algorithme, vous pouvez choisir entre deux approches :

  • Approche classique — La méthode la plus courante pour développer un programme est de programmer le corps d’une méthode, puis de la tester. Une fois validée, passez à la méthode suivante, etc., jusqu’à ce que toutes les méthodes soient programmées.

  • Développement piloté par les tests — Si vous préférez, avant de coder le contenu de vos méthodes, vous pouvez déjà écrire les tests unitaires. Cela vous permettra de tester vos méthodes plus fréquemment pendant leur développement, et vous pourrez être convaincu(e)s de votre travail lorsqu’elles passeront tous les tests que vous avez conçus.

Que vous les écriviez avant ou après le code, il est important de concevoir des tests qui vous convainquent que votre programme fonctionne comme prévu !

Par exemple, supposons que vous souhaitiez concevoir un test pour vous assurer que Dijkstra trouve le chemin le plus court dans un graphe pondéré, contrairement à un BFS. Intuitivement, vous voulez créer un graphe fixe, identifier le chemin le plus court à la main, et vérifier que la sortie de Dijkstra correspond à ce chemin. Cependant, le choix du graphe utilisé a une grande importance :

  • Si vous choisissez un labyrinthe dans lequel il n’y a qu’un seul chemin entre votre position initiale et le morceau de fromage, alors les algorithmes BFS et Dijkstra trouveront tous deux le chemin correct.

  • De même, s’il n’y a pas de boue le long du chemin le plus court entre votre position initiale et le morceau de fromage (ou s’il est toujours plus rapide de traverser cette boue que de l’éviter), alors encore une fois, les algorithmes BFS et Dijkstra trouveront le chemin correct.

À faire

Prenez un moment pour réfléchir et lister les tests unitaires que vous souhaitez effectuer. Pour chaque test, déterminez s’il correspond à une utilisation normale, à un cas limite (une entrée valide mais inhabituelle), ou à une erreur possible.

Programmez ensuite ces tests, et exécutez-les fréquemment pour vous assurer que votre code fonctionne comme prévu.

À quoi ça ressemble ?

À présent, utilisez votre script de visualisation pour observer votre joueur. La vidéo suivante montre un exemple de ce que vous devriez observer :

Notez que le rat a volontairement évité la boue car le nombre de tours nécessaires pour l’esquiver est inférieur au nombre de tours nécessaires pour la traverser.

Comparer avec les autres parcours

Lors des séances précédentes, vous avez comparé vos joueurs en les faisant jouer un grand nombre de parties, puis en comparant leurs nombres de tours moyens. Maintenant que vous avez un joueur de plus, il est tentant de recommencer en l’ajoutant simplement à la liste. Mais une comparaison n’est utile que si elle répond à une question précise, et une bonne comparaison se prépare.

À faire

Avant d’écrire votre script, réfléchissez aux questions suivantes. Écrivez ensuite un script qui met en œuvre la comparaison que vous avez choisie.

  • Quels joueurs comparer ? Est-il utile de comparer à nouveau Dijkstra aux joueurs aléatoires, ou au DFS ? Quelle est la comparaison qui vous apprendra vraiment quelque chose ?

  • Que sait-on déjà du résultat ? D’après le cours, certaines propriétés doivent être vraies sur chaque labyrinthe, et pas seulement en moyenne. Lesquelles ? Comment vérifier qu’elles sont bien respectées, plutôt que de le supposer ?

  • Que mesurer ? Le nombre de tours est-il la seule mesure intéressante ? Que gagne-t-on en utilisant Dijkstra, et que perd-on ? Pour compter les cases traversées, notez que stats["players"][name]["actions"] donne le nombre de déplacements effectués dans chaque direction.

  • Sur quels labyrinthes ? Que gagne-t-on à faire jouer tous les joueurs sur les mêmes labyrinthes ? Quel paramètre du jeu faire varier pour voir quand Dijkstra fait vraiment la différence ?

Pour aller plus loin

Comparer deux implémentations de la file de priorité

La complexité de l’algorithme de Dijkstra dépend de la file de priorité utilisée (voir la section sur la complexité dans l’article sur l’algorithme de Dijkstra). Avec un tas min comme celui de heapq, elle est de $O(n \cdot log(n))$ dans un labyrinthe PyRat. Avec une simple liste non triée, dans laquelle on cherche le minimum à chaque extraction, elle est de $O(n^2)$ dans le pire des cas.

Grâce à la factorisation, essayer une autre structure de données revient simplement à créer une nouvelle sous-classe de Traversal. Profitons-en pour vérifier en pratique ce que dit la théorie !

À faire
  • Créez une classe ListDijkstraTraversal, héritant de Traversal, dont la structure est une liste non triée. L’ajout d’un élément se fait à la fin de la liste, et l’extraction parcourt toute la liste pour trouver l’élément de plus petite distance. Vous pouvez vous inspirer de la classe ListBasedMinHeap présentée dans l’article sur les tas min.

  • Écrivez un script qui mesure, pour des labyrinthes de taille croissante, le temps moyen d’un parcours complet avec DijkstraTraversal et avec ListDijkstraTraversal. Vérifiez au passage que les deux versions trouvent bien les mêmes distances.

  • Tracez ces temps en fonction du nombre de cases, en échelle log-log. Dans cette échelle, la pente d’une courbe donne l’exposant $k$ d’une durée en $O(n^k)$. Les pentes obtenues correspondent-elles à la théorie ?

Information

Quelques indications :

  • La fonction time.perf_counter() permet de mesurer précisément une durée.
  • La génération de grands labyrinthes est lente. Plutôt que de générer beaucoup de labyrinthes, générez-en un par taille, et répétez plusieurs fois le parcours pour en moyenner la durée.
  • La classe RandomMaze de PyRat permet de créer un labyrinthe rectangulaire sans lancer de partie. Son constructeur attend les arguments width, height, wall_percentage et mud_percentage (ces deux derniers étant des nombres réels), ainsi que mud_range lorsqu’il y a de la boue (par exemple, mud_range=[4, 9]). Un argument random_seed optionnel permet de toujours obtenir le même labyrinthe.

Pour aller encore plus loin

L’algorithme de Floyd-Warshall

L’algorithme de Dijkstra est ce que nous appelons un algorithme « 1-to-* ». Cela signifie qu’il trouvera les chemins les plus courts d’une position initiale unique à toutes les autres positions du graphe.

L’algorithme de Floyd-Warshall est ce que nous appelons un algorithme « *-to-* ». Il calculera simultanément tous les chemins les plus courts de tous les sommets à tous les sommets.

À faire

Renseignez-vous sur cet algorithme. Devriez-vous l’utiliser dans le projet ?

À faire

Essayez d’implémenter cet algorithme (ou cherchez une bibliothèque qui le fait).

L’algorithme de Bellman-Ford

L’algorithme de Bellman-Ford est également un algorithme « 1-to-* ». Cependant, contrairement à l’algorithme de Dijkstra, il a la propriété de pouvoir trouver le chemin le plus court si certains arcs ont des poids négatifs. De plus, il peut être utilisé pour détecter un cycle absorbant dans le graphe.

À faire

Renseignez-vous sur cet algorithme. Devriez-vous l’utiliser dans le projet ?

À faire

Essayez d’implémenter cet algorithme (ou cherchez une bibliothèque qui le fait).

L’algorithme de Johnson

L’algorithme de Johnson est un algorithme « *-to-* », qui exploite l’algorithme de Bellman-Ford pour trouver tous les chemins les plus courts dans le graphe.

À faire

Renseignez-vous sur cet algorithme. Devriez-vous l’utiliser dans le projet ?

À faire

Essayez d’implémenter cet algorithme (ou cherchez une bibliothèque qui le fait).