Dijkstra générique
L’algorithme de Dijkstra permet de calculer, pour un graphe donné, les plus courts chemins entre une source (un des sommets du graphe) et tous les autres sommets.
On peut se servir de cet algorithme pour résoudre le problème du plus court chemin si on connaît le départ et l’arrivée par exemple.
Ici, on va chercher à implémenter de manière efficace et générique l’algorithme de manière à pouvoir s’en servir plus tard pour résoudre des problèmes de labyrinthe (trouver un chemin entre l’entrée et la sortie du labyrinthe).
Notre implémentation va être générique, donc à la fin elle sera simple, mais on doit commencer par mettre en place toute une machinerie.
On va commencer par étudier deux choses :
- les arbres dits “tournois” ;
- les files de priorité.
Les concepts de base
A - Tournois
Un arbre de tournoi, c’est tout simplement un arbre dans lequel toutes les feuilles sont les concurrents et à chaque noeud, les concurrents qui ont remporté les tournois des sous-arbres s’affrontent.
Tournois dans la vraie vie
Vous avez forcément déjà vu un arbre tournoi dans votre vie. Toutes les compétitions sportives à élimination directe fonctionnent comme ceci. Par exemple, voici l’arbre tournoi de la dernière coupe du monde de football :
On représente en Haskell un tournoi avec le type suivant :
Les feuilles sont donc les joueurs, et les noeuds internes contiennent
le vainqueur du match entre les joueurs qui ont gagné les tournois
des sous-arbres du noeud. Ici, on ajoute une fonction vainqueur
même
aux feuilles pour des questions de simplification de l’écriture du
code par la suite.
1. Construire un Match
On va chercher à écrire une fonction joueMatch
qui va prendre
en paramètre une fonction de priorité et deux tournois et qui va
construire un Match
dont le vainqueur est le vainqueur qui a la
plus faible priorité entre les vainqueurs des deux tournois donnés.
Dans notre cas, on va considérer que le gagnant est celui qui a la plus faible priorité.
2. Construire un tournoi à partir d’une liste
Si on a une liste de joueurs de type a
, on veut construire le
tournoi (équilibré) qui les fait jouer ensemble.
Pour ça, on va transformer les éléments de la liste en Tournoi
,
en utilisant le constructeur Joueur
, puis jouer des tours jusqu’à
ce qu’il ne reste qu’un seul tournoi.
Jouer un tour, ça va consister à appliquer jouerMatch
aux éléments
de la liste deux par deux. Ça va nous produire une nouvelle liste
avec deux fois moins de tournois à chaque fois.
On veut maintenant faire ça jusqu’à ce qu’il n’y ai plus qu’un seul
tournoi. Si on a une liste vide, ça va échouer, donc on va encapsuler
tout ça dans un Maybe
.
Comme on divise par deux la taille de la liste à chaque itération de
jouerTour
, on se retrouve avec un tournoi dont la hauteur finale
est de \(\mathsf{log}(n)\).
B - Superposer un ABR aux tournois
Pour qu’on puisse retrouver facilement un joueur dans un tournoi, et notamment la feuille qui lui correspond, on va supposer que les éléments n’ont pas qu’une priorité, mais également une clé. Du coup, on va aussi stocker dans chaque noeud la clé maximale de chaque sous-arbre. On va aussi supposer que les joueurs sont maintenant initialement ordonnés par clé, la plus petite à gauche, la plus grande à droite.
Le modèle qu’on a est donc maintenant le suivant :
Les joueurs contiennent maintenant une clé de type k
et une
priorité de type p
.
1. Refaire les tournois
On va devoir réécrire nos fonctions précédentes pour qu’elles prennent en considération ce nouveau type.
Les changements vont être légers, mais vont permettre de conserver les invariants des ABR, à savoir que les clés du sous-arbre gauche doivent être plus petites que les clés du sous-arbre droit.
On n’a notamment plus besoin d’une fonction de priorité, puisqu’elle est désormais contenue dans les éléments.
2. Modifier la priorité d’un élément
Maintenant qu’on a un arbre tournoi qui est aussi un ABR, on peut retrouver efficacement un joueur en fonction de sa clé.
Pour modifier la priorité d’un joueur en connaissant la clé, on descend dans l’arbre, on change la valeur de la priorité de la feuille, et on rejoue uniquement les matchs nécessaires en remontant.
On a donc une fonction qui a cette tête :
1
2
3
4
5
6
7
8
ajuste :: (Ord p, Ord k) => (p -> p) -- la modification de priorité
-> k -- la clé du joueur à modifier
-> TABR p k -- l'arbre dans lequel on modifie
-> TABR p k -- l'arbre modifié
ajuste f k j@(Joueur cp) | k == cle cp = Joueur $ cp{priorite = f (priorite cp)}
| otherwise = j
ajuste f k (Match _ cg _ tg td) | k <= cg = jouerMatch (ajuste f k tg) td
| otherwise = jouerMatch tg (ajuste f k td)
Il est important de noter qu’en utilisant la syntaxe d’enregistrement on peut modifier la valeur de la priorité sans reconstruire l’élément complet (ligne 5).
3. Enlever l’élément de priorité minimale
Pour enlever l’élément de priorité minimale, il suffit de regarder le vainqueur qui se trouve à la racine. Pour l’enlever, il suffit de descendre jusqu’à sa feuille la supprimer, et rejouer le tournoi en remontant.
La fonction pourra échouer si l’arbre est vide notamment, donc on va encapsuler
tout ça dans un Maybe
pour éviter les problèmes.
Dijkstra (enfin)
Pour simplifier un peu notre code, on va ajouter un champ dans notre
structure de donnée CP
. On va lui donner un attribut actif
, qui permettra
de simplement désactiver une feuille quand on la retire du tournoi, sans la
supprimer de l’arbre.
Ça va changer légèrement deux fonctions :
-
jouerMatch
fera toujours gagner un joueur actif face à un inactif -
supprMin
bascule simplement le champ àFalse
et renvoie l’arbre modifié
De ce fait, la fonction de suppression sera maintenant totale : elle n’a plus besoin de Maybe
.
Les types qu’on utilise sont donc désormais :
Par ailleurs, au lieu de considérer que les priorités dans un TABR sont
instances de la classe Ord
, nous allons considérer qu’elles sont instances
d’une classe PreOrd
.
Un type qui implémente la classe PreOrd
est totalement ordonnée par une
relation qui vérifie toutes les propriétés d’un ordre total
(toute paire de valeurs est comparable, réflexivité, transitivité),
mais pas forcément l’anti-symétrie : si nous avons \(x \preceq y\) et \(y \preceq x\),
on ne peut pas en déduire que \(x = y\).
Voici cette classe, nous ajoutons également le type Pre
qui permet de transformer
une instance de la classe Ord
en instance de la classe PreOrd
.
Nous ajoutons aussi les instances de foncteurs applicatifs afin de pouvoir implémenter plus
élégamment nos instances et notamment de pouvoir transporter une instance de
Num
pour un type a
sur le type Pre a
.
Implémentation de PreOrd
Après cette variation, les fonctions qu’on a définies précédemment ont maintenant les types suivants :
A - Dijkstra
Les graphes peuvent être représentés par un modèle très simple :
L’algorithme de Dijkstra permet de calculer les meilleures distances entre un sommet source et tous les sommets d’un graphe. Il permet également de calculer un chemin ou encore tous les chemins de meilleure longueur pour chaque sommet du graphe à partir du sommet source.
Afin de pouvoir représenter les distances le long d’une arête, on définit
la classe de types des graphes pondérés
WGraph g s p
où le type g
est le type des graphes de sommets de type s
et dont les arêtes sont étiquetées avec des valeurs de type p
.
Pour finir, on va ajouter un nouveau type de donnée pour représenter les
distances. On va définir le type Infini a
, qui sera une instance
de Num, Ord, PreOrd
quand a
est lui même une instance de Num, Ord
ou
PreOrd
.
En d’autres mots, on va encapsuler la possibilité d’avoir des distances infinies également, ce qui va nous servir à représenter le fait qu’on ne connaît pas la distance entre un sommet du graphe et la source (pour le moment).
Implémentation de Infini a
1. Être infini ?
On va s’écrire une petite fonction utile qui va nous dire
si une valeur de type Infini a
est infinie ou non
2. Graphes avec étiquettes Ord
Souvent, les graphes qu’on va utiliser seront étiquetés avec des
valeurs qui implémentent Ord
et pas PreOrd
. Comme on veut
imposer l’utilisation de PreOrd
pour les priorités et qu’on sait
que si on a un Ord
, on a facilement un PreOrd
, on va montrer qu’une
instance de graphe pondéré par Ord
l’est aussi par PreOrd
.