6.2 Les problèmes de chemins optimaux

publicité
�����������
����������
�����������������
�����������������������������
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.
Téléchargement