����������� ���������� ����������������� ����������������������������� 2e édition 2003 © Groupe Eyrolles, 1994, 2003, ISBN : 2-212-11385-4 CHAPITRE 6 Problèmes de chemins optimaux 6.1 Introduction Nous commençons, avec ce chapitre consacré aux problèmes de chemins, l’étude des principaux problèmes d’optimisation dans les graphes. Les problèmes de chemins optimaux sont très fréquents dans les applications pratiques. On les rencontre dès qu’il s’agit d’acheminer un objet entre deux points d’un réseau, de façon à minimiser un coût, une durée, etc. Ils apparaissent aussi en sous-problèmes de nombreux problèmes combinatoires, notamment les flots dans les graphes et les ordonnancements. Tout ceci a motivé très tôt la recherche d’algorithmes efficaces. Les méthodes qui font l’objet de ce chapitre sont celles présentées sur la figure 6–1. T_GRAPHE_LISTE - HEAD: THEAD; - SUCC: TSUCC; - W: PTArcCost; - M: ARCNUM; + Bellman (var W:TArcCost; s:Node; var V:TNodeCost; var P:TNodeInfo; var NegCirc:Boolean); + Dijkstra (var W:TArcCost; s,t:Node; var V:TNodeCost; var P:TNodeInfo); + Floyd (var W:TArcCost; var V:CostMatrix; var P:NodeMatrix; var NegCirc:Boolean); + Schedule (var W:TArcCost; Alpha,Omega:Node;var V:TNodeCost; var P:TNodeInfo); + DijHeap (var W:TArcCost; s,t:Node; var V:TNodeCost; var P:TNodeInfo); + Bucket (var W:TArcCost; s,t:Node; var V:TNodeCost; var P:TNodeInfo); + ESOPO (var W:TArcCost; s:Node; var V:TNodeCost; var P:TNodeInfo); + FIFO (var W:TArcCost; s:Node; var V:TNodeCost; var P:TNodeInfo); Figure 6–1. Les méthodes de cheminement optimal de la classe T_GRAPHE_LISTE 176 ______________________________________________________ Algorithmes de graphes Dans une première partie, nous définissons les principaux problèmes de chemins optimaux, nous précisons la distinction entre algorithmes à fixation et à correction d’étiquettes, puis nous donnons la liste des principaux algorithmes. Nous terminons cette partie par quelques exemples d’utilisation. La seconde partie présente un ensemble choisi d’algorithmes. Elle est consacrée aux problèmes de loin les plus fréquents, où le coût d’un chemin, à optimiser, est la somme des coûts de ses arcs. Pour ce groupe de problèmes, nous présentons : des algorithmes à fixation d’étiquettes ; des algorithmes à correction d’étiquettes. Le chapitre se conclut par deux applications. La première concerne les problèmes d’ordonnancement à contraintes de précédence, popularisés par la gestion de projets. La seconde est une évaluation comparative des algorithmes du chapitre sur des graphes de tailles et densités variables. 6.2 Les problèmes de chemins optimaux 6.2.1 Les grands types de problèmes Considérons un graphe orienté valué G = (X,A,W). X désigne un ensemble de N sommets (ou nœuds) et A un ensemble de M arcs. W(i,j), aussi noté Wij, est la valuation (aussi appelée poids ou coût) de l’arc (i,j), par exemple une distance, un coût de transport, ou un temps de parcours. Pour la fonction économique la plus répandue, le coût d’un chemin entre deux sommets est la somme des coûts de ses arcs. Les problèmes associés consistent à calculer des chemins de coût minimal (en abrégé chemin minimaux, ou plus courts chemins). Ils ont un sens si G n’a pas de circuit de coût négatif, sinon on pourrait diminuer infiniment le coût d’un chemin en tournant dans un tel circuit, appelé pour cette raison circuit absorbant. Bien entendu, on peut aussi chercher des chemins de valeur maximale, problème n’ayant de sens qu’en l’absence de circuits positifs. En l’absence de circuit absorbant, on peut restreindre la recherche des plus courts chemins aux seuls chemins élémentaires, c’est-à-dire ne passant pas deux fois par un même sommet. En effet, si un chemin emprunte un circuit, on peut enlever la portion passant par le circuit sans augmenter le coût. Le problème de recherche d’un chemin optimal en présence de circuits absorbants existe, mais il est NP-difficile [Gare79]. Il existe d’autres fonctions économiques que la somme des coûts des arcs, mais elles sont moins répandues dans les applications. Ainsi, si on interprète les valuations comme des capacités, on peut définir la capacité d’un chemin comme le minimum des valeurs de ses arcs et chercher des chemins de capacité maximale [Gon95]. On peut aussi s’intéresser à la valeur moyenne d’un chemin, somme des coûts de ses arcs divisée par le nombre d’arcs, et calculer un chemin de valeur moyenne minimale [Gon95,Ahu93]. Nous étudions dans ce chapitre uniquement les problèmes où le coût d’un chemin est la somme des coûts de ses arcs. La littérature distingue trois types de problèmes, que nous notons A, B et C : Chapitre 6 – Problèmes de chemins optimaux _________________________________ 177 Problème A : Etant donné deux sommets s et t, trouver un plus court chemin de s à t. Problème B : Etant donné un sommet de départ s, trouver un plus court chemin de s vers tout autre sommet. Problème C : Trouver un plus court chemin entre tout couple de sommets, c’est-à-dire calculer une matrice N × N appelée distancier. Ces problèmes sont liés. Un algorithme pour A peut bien sûr être appliqué plusieurs fois pour résoudre B ou C. Un algorithme pour B peut construire un distancier si on l’applique à chaque sommet de départ possible s : une exécution calculera en fait la ligne n°s du distancier. Certains algorithmes pour B ont la propriété de traiter définitivement un sommet de destination à chaque itération. Ils peuvent donc résoudre A en étant stoppés dès que x = t. Bien entendu, on ne gagne rien si t est traité en dernier ! A part certains algorithmes conçus spécifiquement pour calculer les k meilleurs chemins entre deux sommets, les algorithmes pour les problèmes A, B et C ne construisent qu’un des chemins possibles parmi ceux de coût minimal joignant deux sommets. La raison est qu’il existe dans le pire des cas un nombre énorme de chemins de même coût : il serait impossible de les donner tous. Par conséquent, pour le problème A, le seul chemin élémentaire optimal conservé peut être stocké dans une liste d’au plus N sommets. En fait, comme pour l’exploration de graphe vue dans le chapitre 2, un seul tableau de N sommets suffit pour stocker N-1 chemins élémentaires optimaux de s vers tout autre sommet (problème B). Il suffit de stocker pour tout sommet x son prédécesseur unique P[x] (appelé aussi père de x), le long du chemin optimal de s à x. P code, en fait, “à l’envers” l’arborescence des plus courts chemins développée par l’algorithme (anti-arborescence). Cette technique peut être appliquée à un distancier (problème C). Pour chaque ligne s du distancier, un tableau de N éléments stocke les chemins optimaux de s vers tout autre sommet. On peut donc stocker un chemin optimal entre tout couple de sommets avec une matrice N × N de même taille que le distancier. 6.2.2 Les deux familles d'algorithmes Sauf deux exceptions (l’algorithme de Sedgewick-Vitter pour le problème A et celui de Floyd-Warshall pour le problème C), les algorithmes de ce chapitre concernent le problème B. Tous calculent pour chaque sommet x une étiquette (label) V [x], valeur des plus courts chemins du sommet de départ au sommet x. Cette valeur représente au début une estimation par excès (majorant) de la valeur des plus courts chemins. Comme indiqué en 6.2.1, certains algorithmes traitent définitivement un sommet à chaque itération : ils sélectionnent un sommet x et calculent la valeur définitive de V[x]. Ces algorithmes sont dits à fixation d’étiquettes (label setting algorithms) et sont représentés par l’algorithme de Dijkstra et ses dérivés. Ils sont étudiés en 6.3. D’autres algorithmes peuvent affiner jusqu’à la dernière itération l’étiquette de chaque sommet. On les appelle algorithmes à correction d’étiquettes (label correcting algorithms). Voici la structure générale d’un algorithme à correction d’étiquettes. Le sommet de départ est noté s. Le tableau V des étiquettes donne à la fin le coût du plus court chemin trouvé de 178 ______________________________________________________ Algorithmes de graphes s vers tout autre sommet. P[x] stocke le père de x le long de ce chemin. Par convention, P[x] = 0 signifie que x n’a pas été atteint par un chemin d’origine s. L’ensemble E désigne les sommets dits ouverts, dont l’étiquette vient d’être améliorée. Ces sommets sont en attente de balayage de leurs successeurs, pour essayer de propager l’amélioration de V[x]. {Initialisation} Initialiser le tableau V à +∞ Initialiser le tableau P à 0 V[s] := 0 P[s] := s E := {s} {Boucle principale} Tant que E ≠ ∅ Enlever un sommet quelconque i de E Pour tout successeur j de i tel que V[i] + W(i,j) < V[j] V[j] := V[i] + W(i,j) P[j] := i Ajouter j à E (s'il n'y est pas déjà) FP FP. 6.2.3 Liste des principaux algorithmes Problème A Le problème A est en général résolu par un algorithme à fixation d’étiquettes pour le problème B, qu’on stoppe dès que le sommet de destination choisi est traité définitivement. Il existe cependant un algorithme conçu spécifiquement pour le problème A, celui de Sedgewick et Vitter [Mch90,Sed86]. Cet algorithme en O(M.log N) est valable pour les graphes dits euclidiens, où les sommets sont des points de l’espace euclidien et où le coût d’un arc est la distance euclidienne entre ses extrémités. Problème B Cas W = constante. Le problème revient à trouver des plus courts chemins en nombre d’arcs. Il peut se résoudre par une exploration de graphe en largeur, implémentable en O(M). Ce problème a déjà été vu au chapitre 2. Cas W ≥ 0. On peut le résoudre en O(N2) par un algorithme à fixation d'étiquettes dû à Dijkstra [Gon95]. Avec une structure de tas, on obtient une variante en O(M.log N), intéressante si G est peu dense [Cor01,Mch90,Gon95]. Si les coûts sont entiers et si leur valeur maximale U n'est pas trop grande, une structure de données appelée bucket permet un comportement moyen en O(M + U), la complexité au pire étant O(N2 + U) [Div90,Hun88,Den79,Dia79]. Il existe enfin une structure hybride entre tas et buckets, le tas redistributif, donnant un algorithme en O(M + N.log U) [Ahu90]. W quelconque. L’algorithme à correction d'étiquettes de Bellman [Gon95] est une méthode très connue, de type programmation dynamique, résolvant ce cas général en O(N.M). Il peut aussi servir à détecter la présence d'un circuit de coût négatif. Il existe une implémentation connue sous le nom de FIFO et basée sur une file de sommets. D'Esopo et Pape ont proposé une version ayant un très bon comportement moyen sur les graphes peu denses [Pap80]. Chapitre 6 – Problèmes de chemins optimaux _________________________________ 179 W quelconque, G sans circuit. L'algorithme de Bellman peut alors se simplifier grâce à une décomposition de G en niveaux [Gon95]. Sa complexité chute alors à O(M) et il devient aussi valable pour calculer des plus longs chemins. Il est utilisé à ce titre dans les ordonnancements de projet (méthode PERT, etc.). Problème C Il existe un algorithme à correction d’étiquettes très simple, dû à Floyd, calculant en O(N3) un distancier [Gon95,Ahu93]. Nécessitant une représentation matricielle du graphe, il consomme cependant trop de temps de calcul et de mémoire sur des graphes de faible densité. Dans ce cas, il vaut mieux calculer chaque ligne du distancier avec un algorithme pour le problème B. L’algorithme de Dijkstra avec tas permet, par exemple, d’obtenir le distancier en O(NM.log N). 6.2.4 Exemples d’applications Transports Les applications des chemins optimaux aux transports sont les premières à venir à l’esprit. Les logiciels d’optimisation de tournées de véhicules ont ainsi besoin de répondre des milliers de fois à des requêtes du genre “quelle est la distance à parcourir en kilomètres (ou le temps de parcours en minutes), pour aller d’une ville x à une ville y ?”. Il est alors rentable de calculer au préalable un distancier, matrice donnant la valeur des plus courts chemins pour tout couple de villes. Dans le graphe G = (X,A,W) du réseau routier, les sommets correspondent aux villes, les arcs aux tronçons routiers entre villes, et les valuations à des distances ou à des temps de parcours. Ce graphe est disponible sous forme de bases de données géographiques, commercialisées en France par l’Institut géographique national (IGN) et Michelin. Un algorithme de plus court chemin permet d’en déduire le distancier. Par exemple, l’algorithme de Dijkstra pour le problème B, appliqué à chaque ville de départ possible, peut construire ligne par ligne la matrice-distancier. Les fournisseurs des bases de données peuvent calculer à la demande ces distanciers, mais les utilisateurs préfèrent en général les calculer eux-mêmes au jour le jour. Ils peuvent ainsi gérer les imprévus susceptibles de modifier le distancier, tels que des travaux sur certaines routes, des accidents, des barrières de dégel, etc. Notons un effet bénéfique de la structure des graphes routiers sur la complexité des algorithmes de chemins. Pour les graphes planaires, un théorème de théorie des graphes [Mch90] stipule que M ≤ 3.N - 6. Même si un graphe de réseau routier n’est pas toujours planaire (si des petites routes passent sur ou sous une autoroute), on a M ≤ 4.N en pratique, et dans tous les cas on a M = O(N). Par exemple, l’algorithme de Dijkstra avec tas devient alors en O(N.log N) ! Récréations mathématiques Les récréations mathématiques ont été les premières applications de la théorie des graphes et en particulier des chemins optimaux. Ces jeux incluent, par exemple, des problèmes de labyrinthes et des taquins. Les taquins sont des puzzles composés d’une boîte et de pièces 180 ______________________________________________________ Algorithmes de graphes rectangulaires. Les pièces occupent tout le fond de la boîte, sauf un espace de manœuvre ayant la surface d’une ou deux pièces. Partant d’une configuration initiale, le but du jeu consiste à atteindre une configuration finale donnée, en faisant glisser les pièces sans les soulever. Voici le taquin célèbre de “l’âne rouge” [Gard79]. Le but est de déplacer le grand carré (l’âne rouge) vers le bas du tableau, pour le faire sortir par l’ouverture (figure 6–2). Il n’existe pas de solution demandant moins de 81 coups ! L'âne rouge Figure 6–2. Exemple de taquin Le problème de résolution de ce genre de jeu peut être ramené à un problème de plus court chemin dans un graphe d’état G = (X,A). Chaque sommet de X décrit un état du jeu, c’est-àdire une configuration des pièces. On distingue dans X un sommet s pour la configuration de départ, et un ensemble T de configurations finales. Un arc (i,j) existe dans A si un mouvement permet de passer de la configuration i à la j. Le problème revient à trouver un chemin µ de s vers un des sommets de T. Si on demande une solution avec un nombre minimal de coups, µ est un plus court chemin en nombre d’arcs. Les graphes de jeux sont souvent énormes et ne peuvent être générés explicitement. On utilise alors une variante de l’algorithme de Dijkstra, l’algorithme A* [Min83]. Cet algorithme n’a pas besoin du graphe complet. Il nécessite une fonction B(x) donnant une estimation par défaut (minorant) de la valeur d’un plus court chemin de x à T, ainsi qu’une procédure énumérant pour tout sommet l’ensemble de ses successeurs. Chemins de fiabilité maximale Dans une région en proie à un conflit ou à une catastrophe naturelle, un convoi doit être acheminé d’une ville s à une ville t. Le réseau routier de la région est donné par un graphe valué G = (X,A,P). X désigne l’ensemble des villes et A l’ensemble des tronçons de route entre villes. Pour tout arc (i,j), la valuation p(i,j) est la probabilité de parcourir sans dommage la route de i à j. On souhaite déterminer un itinéraire maximisant la probabilité pour le convoi d’arriver sans dommage en t, c’est-à-dire un chemin de fiabilité maximale. Ici, on a affaire à une fonction-objectif exotique : la valeur d’un chemin est le produit des Chapitre 6 – Problèmes de chemins optimaux _________________________________ 181 valeurs de ses arcs. Remarquons que le logarithme de la fiabilité d’un chemin est la somme des logarithmes des fiabilités de ses arcs. On peut donc utiliser un algorithme pour les valuations additives en remplaçant les fiabilités des arcs par leurs logarithmes ! Le problème du caboteur Une compagnie maritime veut définir une ligne côtière desservant cycliquement N ports. On connaît pour chaque voyage d’un port x à un port y le bénéfice b(x,y) et la durée du trajet d(x,y). La compagnie souhaite maximiser le bénéfice par unité de temps, égal pour un parcours µ au bénéfice cumulé en suivant µ, divisé par la durée de µ. Le problème consiste donc à trouver un cycle µ maximisant ce rapport. Il appartient à une classe de problèmes de chemins où la fonction-objectif est la valeur moyenne ou moyenne pondérée des chemins [Gon95, Ahu93]. 6.2.5 Graphe–exemple et notations Pour illustrer les algorithmes, nous utilisons le graphe valué à huit sommets de la figure 6– 3, stocké dans un fichier de nom “GV8SOM.GRA”. 1 2 3 3 1 1 0 1 8 5 4 1 5 7 7 2 3 4 2 6 2 9 4 7 8 1 Figure 6–3. Graphe du fichier GV8SOM.GRA Dans les algorithmes en pseudo-code, le graphe à traiter est noté G = (X,A,W), il a N sommets et M arcs. U désigne le coût maximal des arcs. Le sommet de départ est noté s. Le tableau des N étiquettes est noté V. V[x] est la valeur des plus courts chemins de s à x, initialisée à l’infini, sauf V[s] = 0. On conserve pour chaque sommet accessible à partir de s un des chemins optimaux grâce à un tableau de pères P, initialisé à 0. En fin d’algorithme, un sommet x est inaccessible si V[x] = ∞ (ou si P[x] = 0). En remontant les pères, on peut reconstituer le chemin optimal. Les instructions relatives à P peuvent être supprimées si vous voulez uniquement la valeur des chemins optimaux, sans leurs détails. 182 ______________________________________________________ Algorithmes de graphes 6.3 Algorithmes à fixation d’étiquettes 6.3.1 Algorithme de Dijkstra Version de base sans tas a) Enoncé de l’algorithme L’algorithme de Dijkstra n’est valable que pour les graphes à valuations positives ou nulles, qui ne contiennent donc pas de circuits négatifs. A chaque itération, un sommet x reçoit son étiquette définitive, nous dirons qu’il est fixé. Un tableau de booléens Done indique les sommets fixés. L’itération principale sélectionne le sommet x d’étiquette minimale parmi ceux déjà atteints par un chemin provisoire d’origine s. La démonstration d’optimalité donnée plus loin montre en effet que l’étiquette de x ne peut plus décroître. Pour tout successeur y de x, on regarde si le chemin passant par x améliore le chemin déjà trouvé de s à y : si oui, on remplace V[y] par Min (V[y], V[x] + W(x,y)) et on mémorise qu’on parvient en y via x en posant P[y] := x. Si tous les sommets sont accessibles au départ de s, l’algorithme déroule N itérations. En pratique, des sommets peuvent ne pas être accessibles. On s’en aperçoit quand l’itération principale ne peut plus trouver de sommet accessible, c’est-à-dire tel que V[x] < +∞. Voici le texte de l’algorithme en pseudo-code (voir la section d plus loin, pour le détail des itérations sur le graphe-exemple). {Initialisation} Initialiser le tableau V à +∞ Initialiser le tableau P à 0 Initialiser le tableau Done à Faux V[s] := 0 P[s] := s Répéter {Cherche sommet non fixé de V minimal} VMin := +∞ Pour y := 1 à N Si (non Done[y]) et (V[y] < VMin) alors x := y VMin := V[y] FP Si VMin < +∞ alors {Si x existe} Done[x] := Vrai {On le fixe} Pour k := TETE[x] à TETE[x+1] -1 {Mise à jour des successeurs} y := TSUC[k] Si V[x] + W[k] < V[y] alors V[y] := V[x]+W[k] P[y] := x FS FP FS Jusqu'à VMin = +∞. Si on veut calculer seulement un plus court chemin de s vers un autre sommet t (problème A), il suffit d’arrêter l’algorithme dès que t est fermé, par exemple en modifiant le test de fin en “Jusqu’à (VMin = +∞) ou (x = t)”. Attention : t peut être atteint une première fois Chapitre 6 – Problèmes de chemins optimaux _________________________________ 183 sans que V[t] soit définitif, et un test d’arrêt du type “Jusqu’à (VMin = +∞) ou (P[x] ≠ 0)” serait incorrect. b) Preuve et complexité Chaque itération du Répéter fixe un sommet x d’étiquette minimale. Comme Done[x] est mis à Vrai, x ne pourra plus être sélectionné à nouveau. L’algorithme converge donc en N itérations au plus. Pour l’optimalité, considérons les invariants suivants à chaque itération du Répéter (un invariant de boucle est une propriété qui reste vraie à toute itération de la boucle). L’algorithme est optimal si l’invariant a) est vrai en fin d’algorithme. Si Done[x] = Vrai, alors V[x] est le coût définitif des plus courts chemins de s à x. Si Done[x] = Faux, alors V[x] est le coût des plus courts chemins de s à x dont tous les sommets, sauf x, sont fixés. Ces invariants sont vrais à la fin de la première itération. En effet, s est alors le seul sommet fixé. Sa valeur V [s] = 0 est bien la valeur des plus courts chemins de s à s ! Les seuls chemins dont tous les sommets, sauf le dernier, sont fixés se voient réduits aux arcs de la forme ( s, x) et V [x] est bien le coût optimal d’un tel chemin. Supposons les invariants vrais au début de l’itération n°k. Le sommet non fixé x de plus petite valeur peut être fixé car V [x] ne peut plus décroître. Supposons en effet le contraire : il existe un meilleur chemin de s à x. Soit y son dernier sommet fixé et notons L(y,x) la valeur des plus courts chemins de y à x. On a alors V[y] + L(y,x) < V[x] et V[y] ≤ V[x] car y a été fixé lors d’une itération précédente. Ces conditions impliquent que L(y,x) < 0, ce qui est interdit dans l’algorithme de Dijkstra où les valuations doivent être non négatives. L’invariant a) est donc encore vrai à l’itération suivante k + 1 . Comme la valeur des sommets non fixés qui restent peut éventuellement décroître en passant par x, la boucle de mise à jour des successeurs de x rétablit l’invariant b) pour l’itération k + 1 . Nous avons donc montré par récurrence l’optimalité. Considérons maintenant la complexité. Rappelons qu’il y a au plus N itérations principales. L’initialisation coûte O(N). A chaque itération du Répéter, on sélectionne x en O(N). A toute itération, sauf la dernière, on met à jour les V des successeurs de x en O(d+(x)) (d+(x) désigne le nombre de successeurs de x). La complexité de l’algorithme est donc la suivante, la somme des d+ étant égale au nombre d’arcs, M : N O( N + ∑ ( N + d + ( x))) = O( N 2 ) x =1 Par définition, la complexité d’un algorithme concerne le pire cas. Elle est donc inchangée pour le problème A où le sommet-cible t peut n’être fixé qu’en dernier. En moyenne, on peut cependant s’attendre à fixer t beaucoup plus tôt. c) La méthode Dijkstra La méthode Dijkstra implémente l’algorithme précédent, en ajoutant le traitement optionnel du problème A (listing 6-1). Le paramètre t désigne le sommet de destination désiré. Si t = 0, la méthode résout le problème B. 184 ______________________________________________________ Algorithmes de graphes Listing 6-1. Code de la méthode Dijkstra Procedure T_GRAPHE_LISTE.Dijkstra (var W:TArcCost; s,t:Node; var V:TNodeCost; var P:TNodeInfo); Var x,y : Node; k : ArcNum; Free: TNodeBool; VMin: Cost; Begin For x := 1 to NX+NY do begin P[x] := 0; V[x] := MaxCost; Free[x] := True End; V[s] := 0; P[s] := s; Repeat VMin := MaxCost; For y := 1 to NX+NY do If Free[y] and (V[y] < VMin) then begin x := y; VMin := V[y] End; If VMin < MaxCost then begin Free[x] := False; For k := Head[x] to Head[x+1]-1 do begin y := Succ[k]; If VMin+W[k] < V[y] then begin V[y] := VMin + W[k]; P[y] := x End End End Until (VMin = MaxCost) or (x = t); End; d) Exemple d'utilisation Le programme du listing 6-2 teste la méthode Dijkstra sur le graphe-exemple GV8Som.Gra. Le fichier-graphe est lu puis est affiché. La méthode est appelée, ensuite le programme affiche pour chaque sommet de destination la valeur du plus court chemin et la liste des sommets du chemin. Il utilise la méthode GetPath (décrite au chapitre 5) pour extraire le chemin de l’arborescence de plus courts chemins renvoyé dans P par Dijkstra. Listing 6-2. Test de la méthode Dijkstra procedure Utilise; Var G : T_GRAPHE_LISTE; {Graphe-liste G} W : TArcCost; {Couts sur les arcs} s,t : Node; {Sommets de depart et d'arrivee} V : TNodeCost; {Etiquettes de chaque sommet} P : TNodeInfo; {Tableau de l'arborescence des plus courts chemins} Path : TNodeInfo; {Tableau pour recuperer les chemins} Last : Node; {Indice dans Path du dernier sommet} x,y : Node; {Variables-sommets de travail} ll : string; Chapitre 6 – Problèmes de chemins optimaux _________________________________ 185 begin Memo2.Clear; Memo2.Lines.Add('Test des méthodes de Dijkstra'); Memo2.Lines.Add ('Méthode Dijkstra, chapitre 6, listing 6.2'); Memo1.Clear; G:=T_GRAPHE_LISTE.CREATE; G.ReadGraph ('GV8SOM.GRA',@W,Nil,Nil); G.WriteGraph (Memo1,@W,Nil,Nil,Nil,'Graphe a traiter',78,99); s := 1; {On part du sommet 1} t := 0; {On veut les plus courts chemins vers tous les sommets} G.Dijkstra (W,s,t,V,P); {Affichage des chemins trouves et de leurs valeurs} Memo1.Lines.Add(''); ll:=''; For x := 1 to G.p_NX do If P[x] <> 0 then begin ll:='Chemin de '+IntToStr(s)+' à '+IntToStr(x)+': '; ll:=ll+ 'Cout='+IntToStr(V[x])+'. Sommets:'; G.GetPath (s,x,P,Path,Last); For y := 1 to Last do ll:=ll+ IntToStr(Path[y]); Memo1.Lines.Add(ll); End; G.Pause ('Cliquer pour continuer...'); G.DESTROY; end; Le tableau 6-1 donne les contenus successifs de V au début de chaque itération du Répéter et en fin d’algorithme. Le sommet fixé à chaque itération est celui d’étiquette minimale (entre tirets), et il est donné dans la dernière colonne. Les étiquettes des sommets déjà fixés sont indiquées en italique. Remarquez qu’une étiquette peut décroître plusieurs fois. Tableau 6-1. Contenu de la variable V au cours des itérations V[1] V[2] V[3] V[4] V[5] V[6] -0- ∞ ∞ 0 -1- 4 0 1 0 1 0 V[7] V[8] ∞ ∞ ∞ ∞ ∞ 1 5 ∞ 7 ∞ ∞ 2 -4- 4 ∞ 7 ∞ 12 3 4 4 5 7 13 12 4 1 4 4 -5- 7 9 12 5 0 1 4 4 5 -7- 9 12 6 0 1 4 4 5 7 -9- 11 7 0 1 4 4 5 7 9 - 11 - 8 0 1 4 4 5 7 9 11 FIN La méthode affiche à l’écran les résultats suivants. Chemin Chemin Chemin Chemin Chemin Chemin Chemin Chemin de de de de de de de de 1 1 1 1 1 1 1 1 à à à à à à à à 1: 2: 3: 4: 5: 6: 7: 8: Coût= 0. Coût= 1. Coût= 4. Coût= 4. Coût= 5. Coût= 7. Coût= 9. Coût=11. Sommets: Sommets: Sommets: Sommets: Sommets: Sommets: Sommets: Sommets: 1 1 1 1 1 1 1 1 2 2 2 2 6 2 2 3 3 4 3 4 5 3 4 5 7 3 4 5 7 8 Sommet fixé 186 ______________________________________________________ Algorithmes de graphes Version avec tas a) Enoncé de l'algorithme L’inconvénient majeur de l’algorithme de Dijkstra est qu’il est insensible à la densité du graphe. Le nombre d’itérations du Répéter, au plus N, ne peut pas être amélioré par construction de l’algorithme. En revanche, l’essentiel du travail est dû à la boucle interne trouvant le prochain sommet i à fixer. Cette boucle coûte O(N) tandis que la mise à jour des successeurs de x coûte seulement O(d+(x)). Ceci suggère d’utiliser un tas H pour avoir x plus rapidement (voir le chapitre 4 pour cette structure de données). {Initialisations} Initialiser le tableau V à +∞ Initialiser le tableau P à 0 V[s] := 0 P[s] := s ClearHeap (H) HeapInsert (H,V,s) {Boucle principale} Répéter HeapMin (H,V,x); Pour k := TETE[x] à TETE[x+1]-1 y := TSUC[k] Si V[x] + W[k] < V[y] alors V[y] := V[x]+W[k] P[y] := x Si non InHeap (H,y) alors HeapInsert (H,V,y) Sinon MoveUp (H,V,y) FS FS FP Jusqu'à HeapIsEmpty (H) ou (x = t). {Initialise le tas à vide} {Et y insère s} {Enlève sommet de V minimal, x} {Développe les successeurs de x} {Améliore V[y] en passant par x} {y non dans le tas,on l'ajoute} {y dans H,on le fait monter} Le tas H contient à toute itération les sommets non fixés de valeur non infinie. On l’initialise avec le seul sommet s. La boucle d’extraction du prochain sommet x est remplacée par un HeapMin, qui trouve x à la racine du tas, l’enlève, et reforme le tas en O(log N). Pour tout successeur y dont on peut améliorer l’étiquette, on distingue deux cas. Si V[y] devient non infini (équivalent à “y n’est pas dans le tas”, ou encore P[y] = 0) on insère y dans le tas avec HeapInsert. Si V[y] est déjà dans le tas (InHeap(H,y) = Vrai, équivalent à P[y] > 0), on fait un MoveUp puisque sa valeur diminue. Dans les deux cas l’opération coûte O(log N). L’algorithme est formulé pour traiter les problèmes A et B. Pour le problème B, il faut appeler l’algorithme avec t = 0. Notez que le tableau Done est devenu inutile. b) La méthode DijHeap La méthode DijHeap traduit fidèlement l’algorithme. Elle utilise les services de la classe T_HEAP décrite au chapitre 4 (voir figure 6–4). Chapitre 6 – Problèmes de chemins optimaux _________________________________ 187 Classe T_HEAP ClearHeap HeapInsert DijHeap HeapMin InHeap MoveUp HeapIsEmpty Figure 6–4. Relations de la méthode DijHeap avec les autres méthodes Nous ne donnons pas d’exemple d’utilisation : il suffit de remplacer l’appel à Dijkstra par DijHeap dans le listing 6-2 et on obtient le même résultat final. Listing 6-3. Code de la méthode DijHeap Procedure T_GRAPHE_LISTE.DijHeap (var W:TArcCost; s,t:Node; var V:TNodeCost; var P:TNodeInfo); Var x,y: Node; H : T_Heap; k : ArcNum; Begin H:=T_Heap.CREATE; For x := 1 to NX+NY do begin P[x] := 0; V[x] := MaxCost End; H.ClearHeap; V[s] := 0; P[s] := s; H.HeapInsert (V,s); Repeat H.HeapMin (V,x); For k := Head[x] to Head[x+1]-1 do begin y := Succ[k]; If V[x] + W[k] < V[y] then begin V[y] := V[x] + W[k]; P[y] := x; If H.InHeap (y) then H.MoveUp (V,y) else H.HeapInsert (V,y) End End Until H.HeapIsEmpty; H.DESTROY; End; 188 ______________________________________________________ Algorithmes de graphes c) Preuve et complexité Convergence et optimalité sont préservées dans cette version qui n’est qu’une adaptation de l’algorithme de Dijkstra avec une structure de tas. Un sommet fixé est maintenant un sommet qui est allé dans le tas et qui n’y est plus. Quant à la complexité, au pire, tous les sommets sont accessibles et peuvent être fixés en N itérations au plus. HeapMin reforme le tas en O(log N) ; le traitement d’un successeur y de x coûte O(log N) par HeapInsert ou MoveUp, d’où la complexité totale : N O( N + ∑ (log 2 N + d + ( x). log 2 N )) = O( N . log 2 N + M . log 2 N ) x =1 Dans le cas de graphes connexes, les plus courants en pratique, on a M ≥ N - 1. L’algorithme de Dijkstra avec tas est alors en O(M.log N) et il est donc avantageux pour les graphes peu denses. Si tous les arcs possibles existent (M = N2), il est en revanche plus coûteux que la version sans tas. Notez qu’on pourrait se passer de HeapInsert en mettant en vrac tous les sommets dans H au début : s à la racine avec la valeur 0, les autres sommets à la suite avec une valeur infinie. Des évaluations numériques montrent cependant que cette solution est en moyenne 30% plus coûteuse en temps d’exécution. 6.3.2 Algorithme de Sedgewick et Vitter Cet algorithme a été conçu pour des graphes euclidiens, c’est-à-dire des graphes non orientés dont les sommets sont des points d’un espace euclidien, et les arêtes des segments entre points valués par la longueur euclidienne du segment (distance entre points) [Sed86,Mch90]. Contrairement aux algorithmes vus auparavant, il est conçu pour le problème A, c’est-à-dire le calcul d’un plus court chemin entre deux sommets s et t. Pour chaque sommet ouvert i, l’algorithme maintient une évaluation par défaut (minorant) du coût B[i] d’un plus court chemin de s à t passant par i : B[i] = V[i] + D(i,t). D(i,t) désigne la distance euclidienne de i à t. Pour la calculer, il faut connaître les coordonnées Xi et Yi de tout point i : D(i, j ) = ( X i − X j ) 2 + (Yi − Y j ) 2 La structure générale est similaire à celle de l’algorithme de Dijkstra, sauf qu’on traite définitivement à chaque itération le sommet d’évaluation par défaut minimale. Des preuves détaillées sont données dans [Sed86]. Nous donnons ci-dessous une version déduite de l’algorithme de Dijkstra avec tas. B est un tableau de réels. Notez que le tas H est classé selon les valeurs de B, et non plus selon celles de V. La complexité (pire cas) est inchangée : O(M.log N). En moyenne, l’algorithme est réputé très rapide. Les sommets fixés à la fin forment un fuseau plus ou moins étroit entre s et t et ne représentent qu’une petite partie des sommets de G. Voici l’algorithme de Sedgewick et Vitter en pseudo-code. En raison de ses applications peu fréquentes et de sa similitude avec l’algorithme de Dijkstra, nous ne donnons pas d’implémentation. Chapitre 6 – Problèmes de chemins optimaux _________________________________ 189 Initialiser le tableau V à +∞ Initialiser le tableau P à 0 V[s] := 0 P[s] := s B[s] := D(s,t) ClearHeap (H); HeapInsert (H,B,s) Répéter HeapMin (H,B,i) Pour k := TETE[i] à TETE[i+1] -1 j := TSUC[k] Si V[i] + W[k] < V[j] alors V[j] := V[i]+W[k] P[j] := i B[j] := V[j] + D(j,t) Si non InHeap (H,j) alors HeapInsert (H,B,j) Sinon MoveUp (H,B,j) FS FS FP Jusqu'à HeapIsEmpty (H) ou (i = t). {Initialise le tas à vide} {Et y insère s} {Enlève sommet d'éval. minimale,i} {Développe successeurs de i} {Améliore V[j] en passant par i} {Rafraichit l'évaluation} {j non dans le tas, on l'ajoute} {j déjà dans H, on le fait monter} 6.3.3 Algorithmes à buckets Principes généraux Les algorithmes à buckets sont des variantes de l’algorithme de Dijkstra intéressantes quand les coûts des arcs sont entiers et leur maximum U n’est pas trop grand. On partitionne l’intervalle des valeurs des étiquettes en B intervalles de largeur commune L, numérotés de 0 à B-1, et on associe à chacun d’eux un ensemble de sommets appelé bucket. Ce système est codable comme un tableau Buck de B listes. Buck[k] stocke des sommets d’étiquettes entre k.L et (k + 1).L – 1. Pour trouver le sommet x à fixer dans un algorithme de Dijkstra à buckets, on cherche d’abord le bucket non vide de plus petit indice k. On balaie ensuite Buck[k] pour localiser et extraire le sommet x d’étiquette minimale. Pour chaque successeur y dont on peut améliorer l’étiquette : on cherche le bucket de y, Buck[V[y] div L], on le balaie pour localiser et enlever y, on modifie l’étiquette de y avec V[y] := V[x] + W(x,y), on localise le nouveau bucket de y, Buck[V[y] div L], et enfin, on insère y en tête de ce bucket. Les sommets sont en pratique bien répartis et les listes-buckets sont courtes, ce qui donne de très bonnes performances moyennes. Au pire, les N sommets vont dans le même bucket, et on serait tenté de déclarer B tableaux de N éléments pour le système de buckets. En fait, il suffit d’un seul tableau Next de N éléments, partagé par les buckets. Next[i] indique le suivant du sommet i dans le même bucket et vaut 0 si i est dernier de son bucket. Le tableau Buck sert alors à indiquer le premier sommet de chaque bucket. En pratique, il faut aussi définir le prédécesseur de chaque sommet dans son bucket, avec un tableau Prev, de façon à pouvoir réaliser en O(1) l’opération d’enlèvement. 190 ______________________________________________________ Algorithmes de graphes Pour un coût maximal U des arcs, un chemin optimal peut avoir N-1 arcs, et la plage de valeurs des étiquettes peut atteindre une largeur U(N-1). En fait, on peut montrer qu’à toute itération, les étiquettes sont confinées dans un intervalle de largeur 1 + U. On peut économiser de la mémoire avec un système de buckets ne couvrant qu’un intervalle de cette largeur, mais dont le début VMin augmente en cours d’algorithme. On peut ainsi réutiliser circulairement le tableau de buckets comme dans l’algorithme suivant. Algorithme à buckets de largeur 1 Dial [Dia69] et Denardo et Fox [Den79] ont présenté les premiers et les plus simples des algorithmes à buckets, avec une largeur L = 1. Les buckets contiennent des nœuds de même valeur, et on accède au bucket d’un sommet x par simple indiçage : V[x]. Comme tous les sommets d’un bucket ont même valeur, il n’est pas nécessaire de balayer le bucket pour trouver le sommet d’étiquette minimale : on prend le premier. D’après la remarque à la fin du 6.3.3.1, B = 1 + U buckets suffisent s’ils sont réutilisés circulairement. Au pire, le temps de calcul est en O(U+N2). Si peu de sommets ont les mêmes étiquettes en cours d’algorithme, on peut espérer un temps de calcul imbattable en O(U+M), c’est-à-dire O(U+N) dans le cas de graphes de type routier où M = O(N). En pratique, évidemment, l’algorithme peut exiger trop de mémoire si U est grand. Nous proposons une implémentation assez sophistiquée de cet algorithme. Les buckets sont numérotés de 0 à UMax et sont implémentés par des listes circulaires codées par tableaux. Le système de buckets est formé d’un grand record, Buckets, comprenant un tableau First indicé de 0 à UMax et donnant le premier sommet de chaque bucket, et deux tableaux Next et Prev indicés de 1 à N et donnant respectivement le successeur et le prédécesseur de chaque sommet. Next et Prev sont partagés par les UMax+1 buckets. L’algorithme est précédé par des primitives de buckets. Initialize (Buckets) initialise le système de buckets en mettant les têtes de liste à 0. PushInto (Buckets,b,x) ajoute un sommet x en tête du bucket b. PopFrom (Buckets,b,x) enlève le premier sommet du bucket b et le renvoie dans x. RemoveFrom (Buckets,b,x) enlève un sommet donné x du bucket b, ce sommet n’étant pas forcément en tête de bucket. La boite à outils utilise une valeur maximale de U égale à UMax = 1023. Nous choisissons une puissance de 2, moins 1, pour accélérer l’adressage et la réutilisation circulaire des buckets en évitant l’opération mod. L’indice du bucket actuel est CB (current bucket), cet indice part de 0, croît, et repasse à 0 quand il atteint UMax. Le passage de CB au bucket suivant s’effectue avec l’instruction : CB := (CB + 1) and UMax. Ce and bit à bit entre deux entiers, permis en Delphi, équivaut à CB := (CB+1) mod (UMax+1) si UMax+1 est une puissance de 2, mais est beaucoup plus rapide. En effet, pour des entiers i et j codés en binaire avec j = 2k, l’opération i mod j revient à conserver les k bits de poids faible de i. L’entier j-1 étant formé de k bits à 1, l’expression i and (j - 1) produit le résultat escompté. Au début, le sommet de départ est placé dans le bucket 0 et le numéro de bucket actuel CB (current bucket) vaut 0. La variable LB (last bucket) va mémoriser le dernier numéro de bucket consulté. La fin de l’algorithme est détectée quand CB fait un tour complet et repasse par LB sans avoir trouvé de bucket non vide. Chapitre 6 – Problèmes de chemins optimaux _________________________________ 191 L’itération principale commence par passer au prochain bucket non vide CB si le bucket actuel est vide. On prend comme sommet i à fixer le premier du bucket CB. Pour tout successeur j dont on peut améliorer la marque, on regarde si j est déjà stocké dans un bucket (c’est le cas si P[j] > 0). Si oui, ce bucket a le numéro (CB + V[j] - V[i]) and UMax (ce calcul est nécessaire pour prendre en compte la réutilisation circulaire du pool de buckets). On enlève alors j de ce bucket. Que j soit déjà dans un bucket ou pas, on diminue son étiquette V[j], on calcule l’indice de son nouveau bucket, et on l’insère en tête de ce bucket. Voici d’abord le détail des primitives de gestion des buckets. Buckets est en pratique un record comprenant les trois tableaux First, Next et Prev. {Initialisation d'un système de buckets Buckets: vide les listes} Procedure Initialize (Buckets) Pour b := 0 to UMax First[b] := 0 FP Fin {Enlève le sommet en tête du bucket b et le RENVOIE dans x} Procedure PopFrom (Buckets,b,x) x := First[b] Si Next[x] = x alors First[b] := 0 Sinon Next[Prev[x]] := Next[x] Prev[Next[x]] := Prev[x] First[b] := Next[x] FS Fin {Insère un sommet x en tête du bucket b} Procedure PushInto (Buckets,b,x:Node) Si First[b] = 0 alors Next[x] := x; Prev[x] := x Sinon Next[x] := First[b] Prev[x] := Prev[First[b]] Next[Prev[x]] := x Prev[Next[x]] := x FS First[b] := x Fin {Enlève un sommet x, même non en tête, du bucket b} Procedure RemoveFrom (Buckets,b,x) Si Next[x] = x alors First[b] := 0 Sinon Next[Prev[x]] := Next[x] Prev[Next[x]] := Prev[x] Si x = First[b] alors First[b] := Next[x] FS FS Fin 192 ______________________________________________________ Algorithmes de graphes Voici le détail de l’algorithme de Dijkstra avec buckets de largeur 1 et réutilisation circulaire de la liste de buckets. Initialize (Buckets); Initialiser le tableau V à +∞ Initialiser le tableau P à 0 V[s] := 0 P[s] := s PushInto (Buckets,0,s); {Met s dans le bucket 0} LB := 0 CB := 0 Done := Faux Répéter Si First[CB] = 0 alors {Cherche bucket non vide} Répéter CB := (CB + 1) and UMax Jusqu'à (First[CB] > 0) ou (CB = LB) Si CB = LB alors Done := Vrai {Tour complet: fin} FS Si First[CB] > 0 alors LB := CB PopFrom (Buckets,CB,x) {Enlève 1er sommet bucket CB} Pour k := Head[x] to Head[x+1]-1 y := Succ[k] Si V[x] + W[k] < V[y] alors Si P[y] > 0 alors {Enlève y de son bucket actuel} RemoveFrom (Buckets,(CB+V[y]-V[x]) and UMax,y) FS {Insère y en tête de son nouveau bucket} PushInto (Buckets,(CB + W[k]) and UMax,y) V[y] := V[x] + W[k] P[y] := x FS FP FS Jusqu'à Done ou (x = t). La méthode Bucket La méthode Bucket implémente l’algorithme précédent en utilisant les services de la classe T_BuckSpace décrite au chapitre 4 (figure 6–5). Classe T_BuckSpace PushInto P_First Bucket PopFrom RemoveFrom Figure 6–5. Relations de la méthode Bucket avec les autres méthodes Chapitre 6 – Problèmes de chemins optimaux _________________________________ 193 Par rapport au pseudo-code, la méthode alloue dynamiquement le pool de buckets et le détruit à la fin. Elle est écrite pour les problèmes A et B (mettre t à 0 pour B). Elle peut planter si le coût maximal dans W dépasse UMax + 1. Il faut dans ce cas augmenter UMax, déclaré dans U_TYPE_GRAPHES, en prenant une puissance de 2 moins 1 (2047, 4095). Listing 6-4. Code de la méthode Bucket Procedure T_GRAPHE_LISTE.Bucket (var W:TArcCost; s,t:Node; var V:TNodeCost; var P:TNodeInfo); Var Buckets : PBuckSpace; x,y : Node; LB,CB,MB: BuckNo; k : ArcNum; Done : Boolean; Begin New (Buckets); Buckets^:=T_BuckSpace.CREATE; For x := 1 to p_NX+p_NY do begin V[x] := MaxCost; P[x] := 0 End; V[s] := 0; P[s] := s; Buckets.PushInto (0,s); LB := 0; CB := 0; Done := False; Repeat If Buckets.p_First[CB] = 0 then begin Repeat CB := (CB + 1) and UMax Until (Buckets.p_First[CB] > 0) or (CB = LB); If CB = LB then Done := True End; If Buckets.p_First[CB] > 0 then begin LB := CB; Buckets.PopFrom (CB,x); For k := Head[x] to Head[x+1]-1 do begin y := Succ[k]; If V[x] + W[k] < V[y] then begin If P[y] > 0 Then Buckets.RemoveFrom ((CB+V[y]-V[x]) and UMax,y); Buckets.PushInto ((CB + W[k]) and UMax,y); V[y] := V[x] + W[k]; P[y] := x; End End End Until Done or (x = t); Buckets^.DESTROY; End; En remplaçant dans le programme du listing 6-2 l’appel à Dijkstra par un appel à Buckets, on obtient évidemment les mêmes labels. L’excellente performance de Bucket sur des graphes peu denses est confirmée par le comparatif des algorithmes en fin de chapitre. 194 ______________________________________________________ Algorithmes de graphes Aperçu sur d'autres algorithmes Hung et Divoky [Hun88,Div90] utilisent un nombre de buckets B fixé une fois pour toutes et calculent la largeur L au début de l’algorithme en s’assurant que B.L ≥ 1 + U. L’algorithme correspondant aurait une durée d’exécution stable entre B = 100 et B = 500. Son avantage est un besoin en mémoire indépendant de U, contrairement au cas L = 1. Nous ne donnons pas le texte de cet algorithme, qui se déduit facilement du précédent. La principale différence est que plusieurs sommets de valeurs différentes peuvent coexister dans le même bucket. Le sommet i à développer est donc celui de valeur minimale dans le bucket CB, et il faut l’enlever avec un RemoveFrom. Le bucket stockant un sommet x est celui d’indice V[x] div L. Ahuja, Melhorn, Orlin et Tarjan [Ahu90] ont imaginé une structure de données tenant à la fois du tas et du bucket, appelée tas redistributif (r-heap). Contrairement aux algorithmes précédents, il y a O(log U) buckets, et leur largeur double quand on passe d’un bucket à celui d’indice immédiatement supérieur. Le bucket 0 contient les sommets de valeur minimale. Quand il est vide, on change dynamiquement les fenêtres des buckets et on redistribue les sommets du premier bucket non vide i dans les buckets 0 à i-1. Ce système s’apparente à un tas dans la mesure où ses buckets “tamisent” de plus en plus finement les valeurs quand on descend vers le bucket 0. L’intérêt est un temps de calcul au pire amélioré par rapport aux buckets ordinaires : O(M + N.log U) dans le cas général, et donc O(N.log U) dans le cas des graphes routiers ou planaires où M = O(N). Les temps de calcul moyens sont en revanche comparables dans la pratique. Le r-tas que nous avons vu est dit à un niveau. Avec un r-tas dit à deux niveaux dans lequel les buckets se composent de sousbuckets, on obtient un algorithme plus compliqué en O(M + N.log U / (log log U)). 6.4 Algorithmes à correction d’étiquettes 6.4.1 Algorithme de Bellman Enoncé de l’algorithme Cet algorithme à correction d’étiquettes a été conçu dans les années 50 par Bellman et Moore [Gon95]. Il est prévu pour des valuations quelconques et peut être adapté pour détecter un circuit de coût négatif. Il s’agit d’une méthode de programmation dynamique, c’est-à-dire d’optimisation récursive, décrite par les relations de récurrence suivante. V0 ( s) = 0 V0 ( y ) = +∞, y ≠ s V ( y ) = min {V ( x) + W ( x, y )}, k > 0 k −1 k x ∈ Γ −1 ( y ) Vk(x) désigne la valeur des plus courts chemins d’au plus k arcs entre le sommet s et le sommet x. Les deux premières relations servent à stopper la récursion. Le sommet s peut être considéré comme un chemin de 0 arc et de coût nul. La troisième relation signifie qu’un chemin optimal de k arcs de s à y s’obtient à partir des chemins optimaux de k-1 arcs Chapitre 6 – Problèmes de chemins optimaux _________________________________ 195 de s vers tout prédécesseur x de y. En effet, tout chemin optimal est formé de portions optimales, sinon on pourrait améliorer le chemin tout entier en remplaçant une portion non optimale par une portion plus courte. La formulation récursive étant peu efficace, on calcule en pratique le tableau V itérativement, pour les valeurs croissantes de k. Nous donnons ci-après un algorithme simple, qui sera raffiné lors de la traduction en Delphi. Les étiquettes en fin d’étape k sont calculées dans un tableau VNew à partir du tableau V des étiquettes disponibles en début d’étape. Pour tout sommet y, on regarde si V[y] est améliorable en venant d’un prédécesseur de y. En fin d’étape on écrase V par VNew et on passe à l’étape suivante. Quand arrêter l’algorithme ? En l’absence de circuit absorbant, on peut se restreindre aux chemins élémentaires pour trouver un plus court chemin de s vers tout autre sommet. Or, un tel chemin n’a pas plus de N-1 arcs. Les étiquettes sont donc stabilisées en au plus N-1 itérations. En pratique, elles peuvent se stabiliser plus tôt, et un meilleur test de fin est quand Vk = Vk – 1. La complexité est en O(N.M) : il y a au plus N-1 itérations principales, consistant à consulter les prédécesseurs de tous les sommets, c’est-à-dire les M arcs. Pour détecter un circuit négatif, on fait une N-énième itération : si alors VN ≠ VN – 1, il y a un circuit. En fait, l’algorithme ne peut détecter un circuit que dans la descendance de s. Pour trouver un circuit négatif quelconque, tout sommet doit être accessible au départ de s. Un bon moyen de se ramener à ce cas est d’ajouter un arc (s,x) de coût infini pour tout sommet x inaccessible. Si on ne veut pas modifier G, on peut aussi détecter un circuit négatif quelconque avec l’algorithme de Floyd présenté plus loin en 6.4.4. Initialiser V à +∞ et P à 0 V[s] := 0 P[s] := s k := 0 Répéter k := k + 1 Initialiser VNew à + ∞ Stable := Vrai Pour y := 1 à N Pour tout x prédécesseur de y Si (V[x] ≠ ∞) et (V[x]+W(x,y) < VNew[y]) alors VNew[y] := V[x] + W(x,y) P[y] := x Stable := Faux FS FP V := VNew FP Jusqu'à Stable ou (k = N). Dans cet algorithme, le booléen Stable est mis à faux quand on détecte une différence entre V et VNew, ce qui signifie que les étiquettes ne sont pas encore stabilisées. Le test V[x] ≠ ∞ n’a aucune signification mathématique : il sert seulement à éviter les débordements de capacité dans les langages de programmation. En fin d’algorithme, il y a un circuit négatif dans la descendance de s si (k = N) et (Stable = Faux). Sinon, V[x] contient la valeur d’un chemin optimal de s à x V[x] = +∞ signale que x n’est pas accessible au départ de s). Notez que la valeur finale de k donne la longueur maximale en arcs des chemins trouvés. 196 ______________________________________________________ Algorithmes de graphes La méthode Bellman La méthode Bellman nécessite quelques explications. D’abord, l’algorithme est simplifié et notablement accéléré en travaillant dans le seul tableau V, c’est-à-dire que le tableau VNew de l’algorithme conceptuel n’est pas utilisé. L’accélération vient du fait qu’un prédécesseur x de y (dans G) peut être amélioré plusieurs fois avant d’être utilisé par y. La méthode utilise les méthodes GraphOrder et BuildPreds décrites au chapitre 4 (figure 6–6). Classe T_GRAPHE GraphOrder Bellman Classe T_GRAPHE_LISTE BuildPreds Figure 6–6. Relations de la méthode Belman avec les autres méthodes Le listing 6-5 donne le code de la méthode Bellman. Listing 6-5. Code de la méthode Bellman Procedure T_GRAPHE_LISTE.Bellman (var W:TArcCost; s:Node; var V:TNodeCost; var P:TNodeInfo; var NegCirc:Boolean); Var Step,x,y: Node; k : ArcNum; H : PTR_T_GRAPHE_LISTE; Inv : PTInverse; Stable : Boolean; Begin New (H); H^ := T_GRAPHE_LISTE.CREATE; New (Inv); BuildPreds (H^,Inv); For x := 1 to GraphOrder do begin P[x] := 0; V[x] := MaxCost End; V[s] := 0; P[s] := s; Step := 0; Repeat Inc (Step); Stable := True; For y := 1 to NX+NY do begin For k := Head[y] to Head[y+1]-1 do begin x := Succ[k]; If (V[x] < MaxCost) and (V[x] + W[Inv^[k]] < V[y]) then begin V[y] := V[x] + W[Inv^[k]]; P[y] := x; Chapitre 6 – Problèmes de chemins optimaux _________________________________ 197 Stable := False End End End; Until Stable or (Step = GraphOrder); NegCirc := (not Stable) and (Step = GraphOrder); H^.DESTROY; Dispose (H); Dispose (Inv) End; On passe en paramètre un graphe G, mais la méthode a besoin des prédécesseurs. Elle construit donc le graphe inverse H avec la méthode BuildPreds. BuildPreds renvoie aussi un tableau de correspondance Inv entre arcs de H et de G : si un nœud y est rangé parmi les successeurs de x dans H.Succ[k], alors x est rangé parmi les successeurs de y dans G.Succ[Inv[k]]. Ainsi, H peut utiliser indirectement le tableau de coûts W de G. Le graphe H et le tableau Inv sont alloués dynamiquement en début de méthode et détruits à la fin. Exemple d’exécution Si on exécute le programme du listing 6-2 en appelant Bellman à la place de Dijkstra, on trouve bien sûr les mêmes valeurs de chemins optimaux. Le tableau 6-2 donne le contenu initial du tableau V, puis les contenus à la fin de chaque itération. Grâce au travail avec un seul tableau, l’algorithme stabilise les étiquettes en seulement trois itérations. Tableau 6-2. Évolution du tableau V avec l’algorithme de Bellman Fin d’itération n° V[1] V[2] V[3] V[4] V[5] V[6] V[7] V[8] Tableau initial 0 ∞ ∞ ∞ ∞ ∞ ∞ ∞ 1 0 1 5 7 4 6 10 12 2 0 1 4 7 4 5 9 11 3 0 1 4 7 4 5 9 11 6.4.2 Algorithme FIFO Enoncé de l’algorithme Initialiser V à +∞ et P à 0 V[s] := 0; P[s] := s; ClearSet (Q); EnQueue (Q,s); Répéter Pour Iter := 1 à CardOfSet(Q) DeQueue (Q,x) Pour k := TETE[x] à TETE[x+1] -1 y := TSUC[k]; Si V[x] + W[k] < V[y] alors V[y] := V[x]+W[k] P[y] := x; EnQueue (Q,y) {Ne fait rien si y est déjà dans Q} FS FP FP Jusqu'à SetIsEmpty (Q). 198 ______________________________________________________ Algorithmes de graphes Cet algorithme simple à correction d’étiquettes [Bea92,Ahu93] examine les sommets dans l’ordre FIFO grâce à une file Q de sommets. Au début, seul s est dans Q. Une itération principale traite tous les sommets présents dans Q au début de l’itération, balaye leurs successeurs, et place les successeurs d’étiquette améliorée en fin de Q. L’algorithme se termine quand Q est vide. Dans l’algorithme suivant, CardOfSet est appelée seulement une fois à l’initialisation du Pour, conformément à la gestion des boucles Pour dans les langages de programmation modernes. En fait, l’algorithme FIFO est un dérivé de l’algorithme de Bellman, très intéressant car n’utilisant pas les prédécesseurs. En effet, on montre facilement par récurrence que les deux algorithmes ont le même invariant : à la fin de la k-énième itération du Répéter, V contient les valeurs des plus courts chemins d’origine s et d’au plus k arcs. Pour détecter un circuit négatif, il suffit de compter ces itérations : il y a un circuit négatif si Q n’est pas vide en fin de la N-énième itération. La complexité est la même que celle de l’algorithme de Bellman, O(N.M). Il est à noter qu’une stratégie LIFO (remplacement de Q par une pile) donne un algorithme non polynomial sur certains graphes, bien qu’étant aussi rapide en moyenne. La méthode FIFO La méthode utilise la classe T_PILE_FILE décrites au chapitre 4 (figure 6–7). Classe T_PILE_FILE Clear EnQueue FIFO DeQueue InSet SetIsEmpty Figure 6–7. Relations de la méthode FIFO avec les autres méthodes Le code de la méthode est donnée sur le listing 6-6. Listing 6-6. Code de la méthode FIFO Procedure T_GRAPHE_LISTE.FIFO (var W:TArcCost; s:Node; var V:TNodeCost; var P:TNodeInfo); Var x,y: Node; k : ArcNum; Q : T_PILE_FILE; Begin Q:=T_PILE_FILE.CREATE; For x := 1 to NX+NY do begin P[x] := 0; V[x] := MaxCost End; Chapitre 6 – Problèmes de chemins optimaux _________________________________ 199 V[s] := 0; P[s] := s; Q.Clear; Q.EnQueue (s); Repeat Q.DeQueue (x); For k := Head[x] to Head[x+1]-1 do begin y := Succ[k]; If V[x] + W[k] < V[y] then begin V[y] := V[x] + W[k]; P[y] := x; If not Q.InSet (y) then Q.EnQueue (y) End End Until Q.SetIsEmpty; Q.DESTROY; End; La méthode FIFO est une implémentation simplifiée de l’algorithme : la boucle Pour, qui ne servait qu’à mettre en évidence l’invariant, a été supprimée. Le test d’existence de y dans Q avec InSet ne sert qu’à améliorer la lisibilité. En effet, nos primitives du chapitre 4 considèrent piles et files comme des ensembles de sommets au sens mathématique, ne pouvant contenir de sommets répétés. Il n’y a pas de programme de test pour FIFO, on peut utiliser le code du listing 6-2. 6.4.3 Algorithme de D’Esopo et Pape Enoncé de l'algorithme L’algorithme de D’Esopo et Pape [Pap80] utilise une pile-file Next, comme présentée au chapitre 4 (type NodeSet). Rappelons qu’il s’agit d’une file où on peut ajouter un élément à la fin (EnQueue) ou en tête (Push). Comme dans FIFO, un sommet atteint la première fois est mis en fin de file. Par contre, si on le revisite, on l’insère en tête de file (à condition qu’il ne soit pas déjà en file). Ce critère heuristique de gestion peut s’expliquer intuitivement. Quand on atteint pour la première fois un sommet, il n’est pas urgent de développer ses successeurs car les chemins obtenus risquent d’être mauvais dans un premier temps. En revanche, un sommet déjà visité et dont l’étiquette vient de diminuer doit être développé en priorité, pour propager l’amélioration. Comme l’algorithme LIFO, cet algorithme n’est pas polynomial. Il a un comportement exponentiel en O(min(NMU,M2N)) sur certaines configurations pathologiques, heureusement improbables en pratique [Ahu93]. Il est cependant très rapide en moyenne sur les graphes peu denses. N.B. : si on ne veut pas conserver les chemins optimaux, le tableau P peut être supprimé. Le test “n’est jamais allé dans Next” est alors remplaçable par V[y] = +∞. Initialiser V à +∞ Initialiser P à 0 V[s] := 0 P[s] := s ClearSet (NEXT) 200 ______________________________________________________ Algorithmes de graphes EnQueue (NEXT,s) Répéter DeQueue (NEXT,x) {Prélève sommet de tête} Pour k := TETE[x] à TETE[x+1]-1 y := TSUC[k] Si V[x] + W[k] < V[y] alors V[y] := V[x] + W[k] Si P[y] = 0 alors {y n'a jamais été dans NEXT} EnQueue (NEXT,y) {Ajoute y en fin de file} Sinon Si non InSet(NEXT,y) alors {y n'est plus dans NEXT} Push (NEXT,y) {Ajoute y en tête} FS P[y] := x FS FP Jusqu'à SetIsEmpty (NEXT). La méthode Esopo La méthode Esopo dont le code est donné sur le listing 6-7 est une traduction directe de l’algorithme théorique. Pour l’implémentation, on utilise les services de la classe T_PILE_FILE (figure 6–8). Classe T_PILE_FILE Clear EnQueue ESOPO DeQueue InSet SetIsEmpty Push Figure 6–8. Relations de la méthode ESOPO avec les autres méthodes Listing 6-7. Code de la méthode ESOPO Procedure T_GRAPHE_LISTE.ESOPO (var W:TArcCost; s:Node; var V:TNodeCost; var P:TNodeInfo); Var x,y : Node; k : ArcNum; Q : T_PILE_FILE; Begin Q:=T_PILE_FILE.CREATE; For x := 1 to NX+NY do begin P[x] := 0; V[x] := MaxCost End; V[s] := 0; P[s] := s; Q.Clear; Chapitre 6 – Problèmes de chemins optimaux _________________________________ 201 Q.EnQueue (s); Repeat Q.DeQueue (x); For k := Head[x] to Head[x+1]-1 do begin y := Succ[k]; If V[x] + W[k] < V[y] then begin V[y] := V[x] + W[k]; If P[y] = 0 Then Q.EnQueue (y) Else If not Q.InSet(y) then Q.Push (y); P[y] := x End End Until Q.SetIsEmpty; Q.DESTROY; End; 6.4.4 Algorithme de Floyd Enoncé de l’algorithme L’algorithme de Floyd [Gon95, Ahu93] calcule un distancier N × N donnant les valeurs des plus courts chemins entre tout couple de sommets (problème C). Pour cet algorithme, le tableau V des étiquettes devient une matrice N × N, V[i,j] désignant le coût des plus courts chemins de i à j. Notons V0 la matrice au début, initialisée comme suit : Vij0 = 0 , si i = j (les boucles sont sans effet et peuvent être ignorées) 0 Vij = W (i, j ), si (i, j ) ∈ A 0 Vij = +∞ , si (i, j ) ∉ A Calculons la matrice V1 par les formules suivantes: ( ∀(i, j ) ∈ A, V ij1 = min Vij0 , Vi10 + V10j ) Il est clair que V1 contient les coûts des plus courts chemins ayant le seul sommet 1 comme sommet intermédiaire. On peut construire une suite de matrices Vk avec le même schéma : ( ∀(i, j ) ∈ A, Vijk = min Vijk −1 , Vikk −1 + Vkjk −1 ) Vk donne les coûts des plus courts chemins dont tous les sommets intermédiaires sont dans l’ensemble {1,2,3,…,k}. Il est alors clair que la matrice VN fournit le distancier désiré. Voici un algorithme détaillé, en pseudo-code, pour un graphe G donné sous forme de listes d’adjacence. On peut constater sa simplicité, surtout par rapport au travail important effectué. La complexité est due aux trois boucles Pour emboîtées : O(N3). L’algorithme est donc insensible à la densité de G. 202 ______________________________________________________ Algorithmes de graphes {Initialisations} Initialiser la matrice V à +∞ Initialiser la diagonale de V à 0 Initialiser la matrice P à 0 Pour i := 1 to N P[i,i] := i Pour a := Head[i] à Head[i+1]-1 tel que Succ[a] ≠ i j := Succ[a] V[i,j] := W[a] P[i,j] := i FP FP {Calcul des matrices successives V(k)} Pour k := 1 à N Pour i := 1 à N Pour j := 1 à N Si (V[i,k] ≠ +∞) et (V[k,j] ≠ +∞) et (V[i,k]+V[k,j] < V[i,j]) Alors V[i,j] := V[i,k] + V[k,j]; P[i,j] := P[k,j] FS FP FP FP. Les tests sur les valeurs infinies de V[i,k] et V[k,j] ne servent qu’à éviter des débordements de capacité dans le calcul de V[i,k] + V[k,j]. Ces détails importants sont passés sous silence dans les exposés de l’algorithme que vous trouverez dans la littérature. Le tableau P usuel pour stocker les chemins devient ici une matrice P, N × N. Ici, on initialise P[i,j] à i si l’arc (i,j) existe ou si i = j, et à 0 sinon. La mise à jour de P dans la boucle centrale peut sembler étrange, mais s’explique d’après la figure 6–9. k V[k,j] V[i,k] q i j V[i,j] p Figure 6–9. Mise à jour de P dans la boucle centrale Une amélioration de V[i,j] signifie que la concaténation des chemins de i à k et de k à j est meilleure que le chemin déjà trouvé de i à j. Avant la mise à jour, j a un père q = P[k,j] sur le chemin de k à j, et un père p = P[i,j] sur le chemin de i à j. Il est clair que le prédécesseur de j à retenir pour le meilleur chemin de i à j est maintenant q ! Avec ces traitements, on a à la fin pour tout (i,j) : P[i,j] = 0 s’il n’y a pas de chemin de i à j, et P[i,j] = le prédécesseur immédiat de j sur le plus court chemin de i à j sinon. Suivant la convention usuelle, un sommet i est considéré comme relié à lui-même par un chemin de 0 arc et de longueur 0 : P[i,j] = i. Chapitre 6 – Problèmes de chemins optimaux _________________________________ 203 La méthode Floyd La méthode Floyd (listing 6-8) traduit l’algorithme théorique avec deux raffinements. Le premier consiste à passer au sommet i suivant sans exécuter la boucle en j si V[i,k] = +∞ : ceci améliore le temps de calcul pour les graphes peu denses, bien que l’algorithme reste en O(N3). Le second est la détection d’un circuit négatif, soit à l’initialisation si on tombe sur une boucle de coût négatif, soit dans les boucles principales si V[i,k]+ V[k,i]< 0. Le paramètre booléen NegCirc indique si un tel circuit a été trouvé. Ici, un circuit négatif est toujours détecté, alors que l’algorithme de Bellman ne le trouve que dans la descendance de s. Listing 6-8. Code de la méthode Floyd Procedure T_GRAPHE_LISTE.Floyd (var W:TArcCost; var V:CostMatrix; var P:NodeMatrix; var NegCirc:Boolean); Var i,j,k: Node; a : ArcNum; Begin NegCirc := True; For i := 1 to NX + NY do For j := 1 to NX + NY do begin If i <> j then begin V[i,j] := MaxCost; P[i,j] := 0 End Else begin V[i,i] := 0; P[i,i] := i End End; For i := 1 to NX + NY do For a := Head[i] to Head[i+1]-1 do begin j := Succ[a]; If i <> j then begin V[i,j] := W[a]; P[i,j] := i End Else If W[a] < 0 then Exit End; For k := 1 to NX + NY do begin For i := 1 to NX + NY do If V[i,k] < MaxCost then begin If (V[k,i] < MaxCost) and (V[i,k] + V[k,i] < 0) then Exit; For j := 1 to NX + NY do If (V[k,j] < MaxCost) and (V[i,j] > V[i,k] + V[k,j]) then begin V[i,j] := V[i,k] + V[k,j]; P[i,j] := P[k,j] End End End; NegCirc := False; End; 204 ______________________________________________________ Algorithmes de graphes Exemple Dans le programme du listing 6-9, on lit le graphe-exemple GV8Som.Gra et on lui applique l’algorithme de Floyd qui renvoie le distancier MV et la matrice des prédécesseurs MP. Pour chaque ligne i de MV, on vérifie le résultat en appelant l’algorithme de Dijkstra au départ du sommet i. Le tableau d’étiquettes V renvoyé par Dijkstra doit être égal à la ligne i de MV. La méthode Compare de la classe T_GRAPHE compare les deux tableaux V et MV[i] et stoppe le programme en cas d’erreur. Notez que la comparaison est facilitée car le type CostMatrix a été défini comme un Array[Node] of TNodeCost. MV[i] est donc la ligne i de MV. Quant à l’élément n°j de la ligne i, on peut l’écrire en Delphi (et cela est peu connu) MV[i,j] ou MV[i][j], au choix. Le programme se termine en permettant à l’utilisateur d’entrer un couple (i,j) de sommets, et en affichant le plus court chemin trouvé de i à j, s’il existe. La récupération du chemin s’effectue avec la méthode GetPath héritée de classe T_GRAPHE et décrite au chapitre 5. Listing 6-9. Exemple d’utilisation de la méthode de Floyd procedure Utilise; Var G : PTR_T_GRAPHE_LISTE; {Graphe-liste G} W : PTArcCost; {Couts sur les arcs} MV : CostMatrix; {Distancier} MP : NodeMatrix; {Matrice des predecesseurs} V : TNodeCost; {Etiquettes de chaque sommet (Dijkstra)} P : TNodeInfo; {Arborescence des plus courts chemins (Dijkstra)} Path: TNodeInfo; {Tableau pour extraction des chemins} Last: Node; {Nombre de sommets dans Path} i,j : Node; {Variables-sommets de travail} Circ: Boolean; {Indicateur de circuit negatif} ll : string; Begin Memo2.Clear; Memo2.Lines.Add('Test de la méthode de Floyd'); Memo2.Lines.Add ('Méthode Floyd, chapitre 6, listing 6.9'); Memo1.Clear; New (G); New (W); G^:=T_GRAPHE_LISTE.CREATE; G^.readGraph ('GV8SOM.GRA',W,Nil,Nil); G^.WriteGraph (Memo1,W,Nil,Nil,Nil,'Graphe a traiter',78,99); Memo1.Lines.Add(''); G^.Floyd(W^,MV,MP,Circ); If Circ Then Memo1.Lines.Add ('Circuit negatif detecte') Else begin Memo1.Lines.Add ('Matrice-distancier'); For i := 1 to G^.p_NX do begin ll:=''; For j := 1 to G^.p_NX do If MV[i,j]=MaxCost then Chapitre 6 – Problèmes de chemins optimaux _________________________________ 205 ll:=ll+' Inf' else ll:=ll+IntToStr(MV[i,j]); Memo1.Lines.Add(ll); ll:=''; End; For i := 1 to G^.p_NX do begin G^.Dijkstra (W^,i,0,V,P); G^.Compare (TNodeCost(MV[i]),V,G^.p_NX) End End; Memo1.Lines.Add('impression chemin 1-4'); i:=1; If i <> 0 then begin j:=4; G^.GetPath (i,j,MP[i],Path,Last); If Last = 0 Then Memo1.Lines.Add ('Pas de chemin !') Else begin ll:= ('Chemin trouve:'); ll:=''; For j := 1 to Last do ll:=ll+' '+IntToStr(Path[j]); Memo1.Lines.Add(ll); End; Memo1.Lines.Add(''); End; Memo1.Lines.Add('impression chemin 2-8'); i:=2; If i <> 0 then begin j:=8; G^.GetPath (i,j,MP[i],Path,Last); If Last = 0 Then Memo1.Lines.Add ('Pas de chemin !') Else begin ll:= ('Chemin trouve:'); ll:=''; For j := 1 to Last do ll:=ll+' '+IntToStr(Path[j]); Memo1.Lines.Add(ll); End; Memo1.Lines.Add(''); End; G.Pause ('Cliquer pour continuer'); G.Destroy; Dispose (G); // correspond au new de depart Dispose (W); end; 206 ______________________________________________________ Algorithmes de graphes Le tableau 6-3 donne le distancier affiché par l’algorithme. La ligne 1 est identique aux tableaux d’étiquettes V renvoyés par Dijkstra, DijHeap, Bucket, Bellman, FIFO et d’Esopo. Tableau 6-3. Distancier affiché par l’algorithme 1 2 3 4 5 6 7 8 1 0 1 4 4 5 7 9 12 2 +∞ 0 3 3 4 6 8 10 3 +∞ 1 0 0 1 3 5 7 4 +∞ 1 2 0 1 3 5 7 5 +∞ 2 1 3 0 4 4 6 6 +∞ 3 3 2 2 0 4 6 7 +∞ +∞ +∞ +∞ +∞ +∞ 0 2 8 +∞ +∞ +∞ +∞ +∞ +∞ 1 0 6.5 Application en ordonnancement 6.5.1 Algorithme de Bellman et graphes sans circuits Rappelons les relations de récurrence à la base de l’algorithme de Bellman, mais pour un graphe valué sans circuit G = (X,A,W) : V0 ( s ) = 0 V0 ( y ) = +∞, y ≠ s V ( y ) = min {V ( x) + W ( x, y )}, k > 0 k −1 k x∈Γ −1 ( y ) Si on décompose G en niveaux, on peut calculer les étiquettes définitivement par numéro croissant de niveau. En effet, tout sommet y a ses prédécesseurs dans les niveaux inférieurs, et les étiquettes de ces prédécesseurs sont prêtes quand on calcule V[y]. Voici l’algorithme de Bellman simplifié pour une décomposition donnée sous forme d’un tableau Sorted, contenant les N sommets par numéro croissant de niveau. Initialiser le tableau V à +∞ et le tableau P à 0 V[s] := 0 P[s] := s Pour i := 1 à N y := Sorted[i] Pour tout prédécesseur x de y Si (V[x] ≠ ∞) et (V[x] + W(x,y) < V[y]) alors V[y] := V[x] + W(x,y) P[y] := x FS FP FP. Chapitre 6 – Problèmes de chemins optimaux _________________________________ 207 Le test sur la valeur infinie de V[x] ne sert qu’à éviter les débordements de capacité. L’algorithme devient en O(M), puisqu’il revient à balayer les prédécesseurs de chaque nœud. En fait, on n’a même pas besoin des prédécesseurs : pour tout sommet x (par niveau croissant), on peut procéder en améliorant les étiquettes des successeurs. Cette version est aussi en O(M), mais les étiquettes peuvent subir plusieurs diminutions avant d’être fixées. En plus de Sorted, il faut un tableau Layer donnant le numéro de niveau de chaque sommet. Initialiser le tableau V à +∞ et P à 0 V[s] := 0 P[s] := s Pour tout x successeur de s V[x] := W(s,x) FP Pour i := 1 à N x := Sorted[i] Si Layer[x] > Layer[s] alors Pour tout successeur y de x tel que V[x] + W(x,y) < V[y] V[y] := V[x] + W(x,y) P[y] := x FP FS FP Dans les livres sur les graphes, on montre une situation simplifiée où le sommet de départ est dans le premier niveau et où il n’y a pas d’autre sommet dans ce niveau. Les précautions prises dans l’algorithme avec successeurs s’expliquent facilement sur le petit graphe de la figure 6–10 à quatre niveaux, qui ne correspond pas à ce cas standard. 9 0 s 3 c 2 3 5 a e 7 5 2 1 b 3 d 5 Figure 6–10. Mise en œuvre de la méthode avec un sommet de départ n’appartenant pas au premier niveau Supposons que Sorted contienne les nœuds dans l’ordre (a,s,b,c,d,e) et qu’on parte de s. Les étiquettes en fin d’algorithme figurent près des nœuds. V[e] diminue d’abord à 8 grâce à c. Le plus court chemin de s à e est finalement (s,d,e) de coût 7. Si on développait les nœuds à partir du niveau de s, le nœud b changerait l’étiquette de c en 2 et celle de d en 3. Pour avoir uniquement les chemins d’origine s, il faut donc partir du niveau suivant, mais en initialisant au préalable l’étiquette de tout successeur x de s à W(s,x), par exemple V[c] = 3. 208 ______________________________________________________ Algorithmes de graphes 6.5.2 Ordonnancements et plus longs chemins Considérons le problème d’ordonnancement de projet suivant. Un ensemble X de N tâches de durées connues pi (i = 1,2,..., N ) et définissant un projet doit être exécuté en présence de contrainte d’enchaînement, de façon à minimiser la date d’achèvement du projet. Les contraintes d’enchaînement sont données par un graphe valué G = ( X , A, P ) . Un arc (i, j ) relie la tâche i à la tâche j si i doit être terminée avant de démarrer j. De plus, l’arc (i, j ) est valué par pi, durée minimale devant séparer les dates de début des deux tâches. Dans le cas général, les valuations peuvent être quelconques et correspondre, par exemple, à la durée de i, diminuée d’une durée de chevauchement, ou augmentée d’un temps de séchage ou de refroidissement. Un tel graphe est dit potentiels-tâches (AON ou Activities On Nodes en anglais). Le concept a été introduit par Roy en 1960 dans la méthode des potentiels, qu’il a appliquée à la construction du paquebot France [Roy60]. Au même moment, la méthode PERT (Program Evaluation and Review Technique) était développée aux USA pour le programme de développement des missiles Polaris. Cette méthode utilise un graphe équivalent, dit potentiels-étapes (AOA ou Activities On Arcs), dans lequel les arcs correspondent à des tâches, et les sommets à des étapes d’avancement du projet (débuts ou fins de tâches). Les deux méthodes étant équivalentes, nous voyons uniquement la méthode des potentiels. Il est clair que G est sans circuit, une tâche ne pouvant être à la fois ascendante et descendante d’elle-même ! On peut donc le décomposer en niveaux. Il est d’usage d’inclure dans X deux tâches fictives α et ω de durées nulles, désignant le début et la fin du projet. On relie α à toutes les tâches sans prédécesseur par un arc de durée nulle, et on connecte toute tâche i sans successeur à ω par un arc de durée pi . Notez que ω est obligatoire, sinon la durée des tâches du dernier niveau n’est pas prise en compte dans le modèle. Notons t i la date de début au plus tôt de la tâche i (en abrégé date au plus tôt). On pose par convention tα = 0. Le problème d’ordonnancement de projet revient donc à minimiser la date au plus tôt t ω de la tâche fictive de fin de projet. Comment l’obtenir ? On remarque qu’une tâche i ne peut commencer que si tous ses prédécesseurs sont terminés. La date au plus tôt de toute tâche i est donc la durée du plus long enchaînement de tâches de α à i, c’est-à-dire du chemin de durée maximale de α à i. Nous n’avons vu jusqu’à présent que des algorithmes pour des chemins de valeur minimale. Dans le cas de graphe sans circuit, l’algorithme de Bellman avec successeurs vu en 6.5.1 reste valable. Le fait d’avoir un seul sommet sans prédécesseurs (α) et un sans successeurs (ω) permet de le simplifier comme suit. L’initialisation des prédécesseurs à 0 est inutile, puisque tous les sommets vont être atteints par un chemin d’origine α. Initialiser le tableau V à -∞ V[α] := 0 P[α] := α Pour i := 1 à N x := Sorted[i] Pour tout successeur y de x tel que V[x] + W(x,y) > V[y] V[y] := V[x] + W(x,y) P[y] := x FP FP Chapitre 6 – Problèmes de chemins optimaux _________________________________ 209 Le plus long chemin trouvé de α à ω, responsable de la durée totale t ω du projet, est appelé chemin critique. Un retard sur les tâches dites critiques de ce chemin se répercute sur le projet tout entier. Le chemin critique n’est pas forcément unique, mais le stockage des chemins avec le tableau de prédécesseurs P ne permet d’en conserver qu’un, qu’on peut récupérer en remontant les prédécesseurs en partant de ω. Pour toute tâche non critique i, on peut s’offrir un petit retard sans affecter la durée totale, du moins jusqu’à une date de début au plus tard (en abrégé date au plus tard) Ti . Pour toute tâche critique i, on a bien sûr T i = t i . Pour une tâche non critique i, la date au plus tard doit ménager assez de temps pour le plus long enchaînement de tâches de i à ω. Soit L(i, ω ) la durée du plus long chemin de i à ω, on a alors Ti = tω − L(i, ω ) . En pratique, on applique l’algorithme précédent au graphe inverse H = G −1 , en partant du sommet ω. 6.5.3 La méthode Schedule La méthode Schedule calcule les plus longs chemins dans un graphe de projet G. Ce graphe doit être sans circuit et doit comporter un seul sommet sans prédécesseurs, Alpha, et un seul sans successeurs, Oméga. Ces deux sommets ne sont pas nécessairement ceux de plus petit et de plus grand numéro. Les valuations sur les arcs sont données par le tableau W et peuvent être quelconques. La méthode décompose G en niveaux avec la méthode GetLayers de la classe T_GRAPHE_LISTE décrite au chapitre 5 (figure 6–11). Classe T_GRAPHE_LISTE GetLayers Classe T_GRAPHE Schedule Error Figure 6–11. Relations de la méthode Schedule avec les autres méthodes Elle renvoie un tableau V de dates au plus tôt et un tableau de prédécesseurs P. La durée du projet est alors V[Oméga] et on peut récupérer le chemin critique avec la méthode GetPath hérité de la classe T_GRAPHE et décrite au chapitre 5. En appliquant Schedule au graphe inverse (construit avec BuildPreds, méthode de la classe T_GRAPHE_LISTE décrite au chapitre 5), on pourrait obtenir les dates au plus tard. Le code complet de la méthode est donné sur le listing 6-10. Listing 6-10. Code de la méthode Schedule Procedure T_GRAPHE_LISTE.Schedule (var W:TArcCost; Alpha,Omega:Node; var V:TNodeCost; var P:TNodeInfo); Var i,x,y,NLayer: Node; 210 ______________________________________________________ Algorithmes de graphes k : ArcNum; Layer,Sorted: TNodeInfo; Begin If Simple then Error ('Schedule: graphe simple interdit'); GetLayers (NLayer,Layer,Sorted); If (Sorted[1] <> Alpha) or (Layer[Sorted[2]] = 1) Then Error ('Schedule: Alpha doit etre seul dans le niveau 1'); If (Sorted[NX] <> Omega) or (Layer[Sorted[NX-1]] = NLayer) Then Error ('Schedule: Omega doit etre seul dans le dernier niveau'); For x := 1 to NX do V[x] := -MaxCost; P[Alpha] := Alpha; V[Alpha] := 0; For i := 1 to NX do begin x := Sorted[i]; For k := Head[x] to Head[x+1]-1 do begin y := Succ[k]; If V[x] + W[k] > V[y] then begin V[y] := V[x] + W[k]; P[y] := x End End End End; 6.5.4 Exemple L’exemple du tableau 6-4 concerne la construction d’une maison. Le tableau donne pour chaque tâche un code, un libellé, et la liste des prédécesseurs. Tableau 6-4. Exemple de problème d’ordonnancement de tâches : construction d’une maison Code tâche Durée (semaines) Prédécesseurs Maçonnerie 7 aucun 2 Charpente de la toiture 3 1 3 Toiture 1 2 4 Plomberie et électricité 8 1 5 Façade 2 3, 4 6 Fenêtre 1 3, 4 7 Aménagement du jardin 1 3, 4 8 Plafonds 3 6 1 Libellé 9 Peintures 2 8 10 Emménagement 1 5, 7, 9 Après ajout des tâches fictives de début et de fin que nous notons α = 11 et ω = 12, le graphe du projet est présenté sur la figure 6–12. Les dates au plus tôt trouvées en appliquant l’algorithme du 6.5.2 figurent juste au-dessus de chaque sommet. La durée totale du projet est t ω = 22, due au chemin critique (α ,1,4,6,8,9,10, ω ) . Chapitre 6 – Problèmes de chemins optimaux _________________________________ 211 7 2 7 0 α 0 0 1 10 3 3 8 7 7 4 15 8 1 1 7 1 1 15 21 5 2 10 22 1 ω 2 8 15 6 16 1 8 19 3 9 Figure 6–12. Graphe du projet Pour retrouver ces résultats avec la méthode Schedule, on saisit ce graphe dans un fichier de nom “Ordo.Gra”. * ORDO.GRA: Problème d'ordonnancement du chapitre 6 * Tâche de début: 11, de fin: 12 NX = 12, COSTS=1 * x : Succs(x) *---------------------1 : 2(7), 4(7) 2 : 3(3) 3 : 5(1), 6(1), 7(1) 4 : 5(8), 6(8), 7(8) 5 : 10(2) 6 : 8(1) 7 : 10(1) 8 : 9(3) 9 : 10(2) 10 : 12(1) 11 : 1(0) On exécute ensuite le programme du listing 6-11, qui affiche la durée du projet ainsi que la liste des sommets du chemin critique. Listing 6-11. Test de la méthode Schedule procedure Utilise; Var G : PTR_T_GRAPHE_LISTE; {Graphe de projet} W : PTArcCost; {Durees sur les arcs} V : TNodeCost; {Dates au plus tot} P : TNodeInfo; {Arborescence des plus courts chemins} Path: TNodeInfo; {Tableau pour extraction des chemins} Last: Node; {Nombre de sommets dans Path} x : Node; {Variable de travail} ll : String; Begin Memo2.Clear; Memo2.Lines.Add('Test de la méthode Schedule'); Memo2.Lines.Add ('Méthode Schedule, chapitre 6, listing 6.11'); Memo1.Clear; New (G); G^:=T_GRAPHE_LISTE.CREATE; New (W); 212 ______________________________________________________ Algorithmes de graphes G^.ReadGraph ('ORDO.GRA',W,Nil,Nil); G^.WriteGraph (Memo1,W,Nil,Nil,Nil,'Graphe a traiter',78,99); Memo1.Lines.Add(''); {Tache 11=debut de projet, tache 12=fin de projet} G^.Schedule (W^,11,12,V,P); Memo1.Lines.Add('Duree du projet: '+IntToSTr(V[12])); G^.GetPath (11,12,P,Path,Last); Memo1.Lines.Add ('Chemin critique:'); ll:=''; For x := 1 to Last do ll:=ll+' '+IntToStr(Path[x]); Memo1.Lines.Add(ll); G^.Pause ('Un Click pour continuer...'); G^.DESTROY; Dispose (G); Dispose (W); End; 6.6 Evaluation des algorithmes Nous évaluons dans cette section la vitesse des méthodes pour le problème B (ce qui exclut Floyd, qui traite le problème C), grâce au programme du listing 6-12. L’évaluation consiste à exécuter les six méthodes Dijkstra, DijHeap, Bucket, Bellman, Fifo et Esopo sur deux séries de 100 graphes générés aléatoirement avec des coûts entre 0 et 1 000. La première série concerne des graphes complets (densité 1) de 500 nœuds, tandis que la seconde produit des graphes à 1000 nœuds et de densité 0.05 (c’est-à-dire avec 1000 × 0.05 = 50 successeurs en moyenne par nœud). Le code est donné pour la première série : il suffit d’ajuster les constantes NS et Dens pour traiter la seconde. Listing 6-12. Code pour évaluer la performance des algorithmes Procedure Utilise; Const NTest = 100; {Nombre de graphes a tester} NS = 500; {Nombre de sommets} Dens = 1.0; {Densite} Type TName = Array[1..6] of String; {Type pour noms des algorithmes} Var G : PTR_T_GRAPHE_LISTE; {Graphe-liste G, alloue dans le tas} H : T_GRAPHE_LISTE; W : TArcCost; {Tableau de couts sur les arcs} s : Node; {Sommet de depart} V : TNodeCost; {Couts des plus courts chemins} P : TNodeInfo; {Arborescence des PCC} NegCir : Boolean; {Detection circuit negatif} BegT,Dur: TDateTime; {Heure de debut des algos} CumT : Array[1..6] of TDateTime; {Durees cumulees des algos} Test : Integer; {No de test} NumAlg : Integer; {No d'algorithme} Const Name : TName = {Table des noms d'algorithmes} ('Dijkstra','DijHeap', 'Bucket', 'Bellman', 'Fifo' ,'Esopo'); Begin Memo2.Clear; Chapitre 6 – Problèmes de chemins optimaux _________________________________ 213 Memo2.Lines.Add('Test des méthodes de cheminement'); Memo2.Lines.Add ('Méthodes Dijkstra/DijHeap/Bucket/Bellman/ FIFO/Esopo'); Memo2.Lines.Add (' chapitre 6, listing 6.12'); Memo1.Clear; Memo1.Lines.add('Patientez...'+IntToStr(NTest)+' appels aux méthodes'); New(G); G^:=T_GRAPHE_LISTE.CREATE; RandSeed := 345267; {Pour avoir un programme reproductible} For NumAlg := 1 to 6 do CumT[NumAlg] := 0.0; s := 1; For Test := 1 to NTest do begin {Genere un graphe oriente, couts entre 0 et 1000} G^.RandGraph (NS,0,False,True,False,0,1000,Dens,RandSeed,False); BegT := G^.Chrono; {Test de l'algo de Dijkstra} G^.Dijkstra (G^.LIRE_COUTS^,s,0,V,P); CumT[1] := CumT[1]+G^.Chrono-BegT; BegT := G^.Chrono; {Dijkstra avec tas} G^.DijHeap (G^.LIRE_COUTS^,s,0,V,P); CumT[2] := CumT[2]+G^.Chrono-BegT; BegT := G^.Chrono; {Dijkstra avec buckets} G^.Bucket (G^.LIRE_COUTS^,s,0,V,P); CumT[3] := CumT[3]+G^.Chrono-BegT; BegT := G^.Chrono; {Algo de Bellman} G^.Bellman (G^.LIRE_COUTS^,s,V,P,NegCir); CumT[4] := CumT[4]+G^.Chrono-BegT; BegT := G^.Chrono; {Algorithme FIFO} G^.FIFO (G^.LIRE_COUTS^,s,V,P); CumT[5] := CumT[5]+G^.Chrono-BegT; BegT := G^.Chrono; {Algorithme de d'Esopo et Pape} G^.Esopo (G^.LIRE_COUTS^,s,V,P); CumT[6] := CumT[6]+G^.Chrono-BegT; End; For NumAlg := 1 to 6 do begin Dur := CumT[NumAlg]/NTest; Memo1.Lines.Add (Name[NumAlg]+':’ + FormatDateTime('hh:mm:ss:zzz',dur)); end; G^.Pause ('Cliquer Pour continuer...'); G^.DESTROY; Dispose(G); End; 214 ______________________________________________________ Algorithmes de graphes Le tableau 6–5 donne les durées moyennes pour un appel de méthode sur un Pentium III à 800 Mhz. Pour les graphes complets de 500 nœuds, les trois versions de l’algorithme de Dijkstra (Dijkstra, DijHeap et Bucket) sont les plus rapides et présentent des durées moyennes très voisines. En passant à 1000 nœuds, tous les algorithmes sauf Dijkstra tirent parti de la faible densité et deviennent extrêmement rapides. L’algorithme à buckets devient le plus performant, il est suivi par l’algorithme de Dijkstra avec tas. Tableau 6-5. Comparaison des méthodes (durées en millisecondes) Méthode 500 nœuds - densité 1 1000 nœuds - densité 0.05 Dijkstra 14 20 DijHeap 11 4 Bucket 12 3 Bellman 215 41 Fifo 35 7 Esopo 52 9 Des tests complémentaires montrent que les écarts entre algorithmes augmentent encore pour des densités inférieures. Esopo peut battre FIFO, mais pour des densités très faibles, inférieures à 1%. L’algorithme à buckets reste le plus rapide à condition que les coûts restent relativement petits. Pour de grandes valeurs des coûts, l’algorithme de Dijkstra avec tas devient le meilleur choix. On peut se poser la question de l’intérêt de Bellman et de ses dérivés FIFO et Esopo mais il ne faut pas oublier que les coûts sont ici tous positifs. En présence de coûts négatifs, ils seraient les seuls algorithmes utilisables. Les algorithmes étant très rapides, vous ne retrouverez pas les résultats du tableau avec la même précision. Ils ont été obtenus en prenant les précautions décrites dans le paragraphe 5.6.5, notamment en encapsulant chaque appel de méthode dans une boucle de 1000 itérations pour obtenir une durée totale significative, supérieure à la précision de 20 ms des fonctions de mesure de temps. 6.7 Références Des présentations générales des algorithmes de chemins figurent dans plusieurs ouvrages de graphes ou d’algorithmique. Citons ceux de Gondran et Minoux [Gon95], de Cormen et al. [Cor02], de Beauquier et al. [Bea92]. Le livre récent d’Ahuja, Magnanti et Orlin [Ahu93] est consacré aux problèmes de flots mais contient un chapitre très complet sur les algorithmes de chemins. Nous avons cité au passage toutes les références très spécialisées concernant tel ou tel algorithme. L’exemple d’ordonnancement de la construction d’une maison est tiré du livre de Gondran et Minoux, Graphes et Algorithmes, publié aux éditions Eyrolles [Gon95]. Les ordonnancements représentent à eux seuls un domaine important de l’optimisation combinatoire, les livres de French [Fre82] et de Carlier et Chrétienne [Car97] sont de bons points d’entrée pour ce domaine foisonnant.