Université du Québec École de technologie supérieure Département de génie de la production automatisée Auteur : Tony Wong Ph.D., ing. Département de génie de production automatisée École de technologie supérieure Courriel : wong.wong@ etsmtl.ca Algorithme de Dijsktra Chemins minimaux à partir d’une source Section 1 Algorithme de Dijsktra Les chemins minimaux à partir d’une seule source Présentation À partir d’un graphe, l’algorithme de Dijsktra utilise le parcours en « largeur d’abord » et l’approche « gourmande » (greedy) pour trouver les chemins les plus courts entre une source et toutes les destinations du graphe. Puisqu’un graphe est composé de nœuds et d’arêtes, l’algorithme de Dijsktra peut trouver les chemins les plus courts liant un nœud quelconque à tous les autres noeuds du graphe en une seule exécution. Conditions d’applicabilité L’algorithme de Dijsktra s’opère dans l’univers des graphes. Les graphes acceptés peuvent être orientés ou non. La figure 1a) montre un graphe non orienté alors que la figure 1b) montre un graphe orienté. 1a) 1b) • Figure 1 Graphe non orienté (a), graphe orienté (b). Un graphe peut également être pondéré. Dans ce cas, la valeur de pondération est indiquée sur les arêtes du graphe. La figure 2 montre un graphe orienté et pondéré. 2 700 5 600 6 700 300 700 4 3 1700 400 700 1 7 1200 8 • Figure 2 Graphe orienté et pondéré. De cette façon, nous pouvons représenter un nombre de situations réelles par des graphes. Par exemple, le trafic aérien où les nœuds sont des villes et les pondérations sont le coût des billets d’embarquement reliant deux villes. Dans le domaine de la production industrielle, les nœuds peuvent représenter différentes étapes d’un processus de fabrication alors que les pondérations peuvent représenter le temps requis pour passer d’une étape à l’autre. L’application de l’algorithme de Dijsktra exige un graphe dont les caractéristiques sont : Orienté ou non orienté. Pondéré ou non pondéré. Valeur de la pondération non négative (c’est à dire, ≥ 0). Enfin, il doit exister au moins un chemin partant du nœud de départ vers tous les autres nœuds du graphe. Évidemment, cette condition ne s’applique pas pour le nœud de départ. Il est donc important de s’assurer que les valeurs inscrites sur les arêtes des graphes soient non négatives. Nature des graphes Cette section est une brève introduction, pour les non initiés, de la théorie des graphes. Les lecteurs qui ne s’intéressent qu’aux applications de l’algorithme de Dijkstra sont priés de passer à la section suivante. Un graphe G est représenté par la notation 3 G = (N, A) (1) où N est l’ensemble des nœuds et A est l’ensemble des arêtes et chaque arête est un pair (u, v) où u, v ∈ N. Si l’ordre des nœuds dans le pair (u, v) est important alors le graphe est orienté. Dans le cas contraire, le graphe est non orienté. Dans un graphe orienté, on dit qu’un nœud v est adjacent au nœud u si et seulement si le pair (u, v) ∈ A. C’est-à-dire, l’arête (u, v) existe dans le graphe. Pour exprimer la même notion dans un graphe non orienté, on dit qu’un nœud v est adjacent au nœud u (et par le fait même u est adjacent à v) si (u, v) ∈ A et (v, u) ∈ A. Lorsqu’un graphe est pondéré, on ajoute un troisième composant dans l’équation (1). C’est-à-dire, G = (N, A, c) (2) où c : N × N → ℜ+ est une fonction donnant le coût de l’arête reliant deux nœuds. Le domaine de la fonction c(· ) est l’ensemble des nœuds d’où le produit cartésien N × N. Rappelons que le coût doit être non négative pour permettre l’application de l’algorithme de Dijsktra, c’est pour cette raison que l’image de la fonction est ℜ+. Un chemin dans un graphe est une séquence de nœuds u1, u2, …, uW tel que (ui , ui+1) ∈ A pour 1 ≤ i ≤ W – 1. La longueur d’un chemin est alors simplement W – 1. Également, un nœud u peut avoir un chemin vers lui-même. Si ce chemin n’a pas d’arêtes alors la longueur de ce chemin est nulle (zéro). Par contre, si ce chemin possède une arête (u, u) alors le chemin joignant le nœud u est appelé une boucle et sa longueur demeure nulle (zéro). Un chemin est un chemin simple si tous les nœuds du chemin sont distincts excepté le nœud de départ et le nœud d’arrivée. Soit un nœud de départ u1 et un nœud d’arrivée uW. Dans un graphe orienté, un chemin de longueur ≥ 1 avec u1 = uW est appelé un cycle. Un cycle est un cycle simple si le chemin composant le cycle est un chemin simple. Dans un graphe non orienté, on impose une contrainte supplémentaire dans la formation d’un cycle : il faut que les nœuds du chemin soient tous des nœuds distincts. La raison de cette contrainte supplémentaire est évidente si l’on considère que le chemin u1, u2, u1 dans un graphe non orienté est en fait la même arête. C’est-à-dire (u1, u2) = (u2, u1) pour un graphe non orienté. Donc, nous avons la nomenclature suivante : Graphe orienté Graphe non orienté Cycle simple Cycle non simple où les nœuds du chemin ne sont pas tous distincts Cycle N/A Un graphe orienté sans cycle est un graphe acyclique. Ces graphes ont beaucoup d’applications dans la pratique. Notamment dans la représentation et l’analyse des 4 programmes parallèles. Nous les avons donnés un nom particulier, le DAG. L’acronyme DAG signifie tout simplement « Direct Acyclic Graphs ». S’il existe un chemin reliant tous les nœuds du graphe, ce dernier est appelé un graphe connecté pour un graphe non orienté. Pour un graphe orienté, il est appelé graphe fortement connecté. Si on enlève la direction des arêtes d’un graphe orienté et que le graphe non orienté résultant est connecté alors on appelé le graphe orienté, un graphe orienté faiblement connecté. Le tableau suivant résume cette propriété des graphes. Graphe orienté Fortement connecté Faiblement connecté – éliminer l’orientation des arêtes. Le graphe résultant est connecté. Graphe non orienté Connecté Connecté Enfin, s’il existe au moins une arête reliant tous les pairs de nœuds dans le graphe, le graphe est un graphe complet. On peut constater que ce type de graphes peut représenter un grand nombre de situations. Par exemple, un ordinateur parallèle à mémoire commune, les chemins de communication entre ordinateurs reliés par un réseau Ethernet. Lorsqu’un graphe est pondéré (voir équation 2), nous pouvons associer un coût aux différents chemins contenus dans le graphe. Ainsi, le coût du chemin u1, u2, …, uW tel que (ui , ui+1) ∈ A pour 1 ≤ i ≤ W – 1 est donné par W −1 C = ∑ c(ui , ui +1 ). (3) i =1 Un grand nombre de problèmes consistent à trouver un chemin entre u1 et uW et en même temps minimiser le coût associé. Dans la théorie des graphes, nous faisons souvent l’abstraction de la façon dont les graphes sont représentés. En informatique, dont le but consiste à trouver des solutions aux problèmes mathématiques, la représentation des graphes est une question primordiale. L’une des représentation la plus utilisée est la liste des nœuds adjacents. Dans cette représentation, nous dressons une liste de nœuds contenus dans le graphe et on les lie à d’autres listes énumérant les nœuds adjacents. La figure 3 est une représentation figurative d’une liste de nœuds adjacents du graphe orienté de la est utilisé ici pour représenter le vide ou la fin d’une liste. figure 2. Le symbole L’avantage de cette représentation réside dans sa simplicité et dans l’économie de son implantation. En fait, l’espace mémoire nécessaire pour emmagasiner la liste de nœuds adjacents est proportionnel au nombre des nœuds et au nombre d’arêtes du graphe (C’est-à-dire, | N | + | A | où |· | est le nombre cardinal de l’ensemble). 5 4 5 7 5 6 6 7 3 8 8 1 400 2 700 4 300 5 600 3 1700 3 6 700 2 3 700 2 4 700 1 7 1200 8 8 • Figure 3 a) Liste des nœuds adjacents. (b) Graphe orienté de la figure 2. Il est évident que la liste des nœuds adjacents peut avoir des nœuds non distincts pour les graphes orientés (exemple : dans la figure 3, le nœud 8 apparaît deux fois parmi les nœuds adjacents). Pour les graphes non orientés, les nœuds de la liste peuvent être tous distincts. Il arrive que deux nœuds soient reliés entre eux par plus d’une arête pour indiquer différentes situations concrètes. Un graphe admet ce genre de liens à condition qu’il existe des caractéristiques différentes. Dans le cas des graphes orientés et pondérés, on peut lier deux nœuds par plus d’une arête si la direction ou la pondération est différente. Par exemple, le graphe de la figure 4 montre un petit quadrilatère des rues du centre-ville de Montréal. Certaines de ces rues sont de sens unique alors d’autres sont de double sens de circulation. 700 5 7 600 2 Ste-Catherine - McGill 3 Ste-Catherine - Université 4 Cathcart - Mansfield 6 5 Cathcart - McGill 6 Cathcart - Université 7 René Lévesque - Mansfield 700 300 700 4 1 Ste-Catherine - Mansfield 3 1700 400 2 700 1 1200 8 8 René Lévesque - Université • Figure 4 représentation particulière d'un graphe orienté. On constate dans le graphe orienté de la figure 4 que les rues à double sens de circulation sont simplement représentées par deux arêtes d’orientation opposée. La figure 5 donne la liste des nœuds adjacents du graphe de la figure 4. 6 4 1 5 7 5 4 6 6 7 5 3 8 4 8 8 7 1 400 2 700 4 300 5 600 3 1700 1 3 2 6 700 2 3 700 2 4 700 1 7 1200 8 • Figure 5 Liste des nœuds adjacents du graphe de la figure 4. Attention : Certains algorithmes de graphe n’acceptent pas ce genre de modification. Vous devez alors ajouter des nœuds au graphe de base pour permettre l’ajout des liens (ou des pondérations) supplémentaires. Application de l’algorithme de Dijsktra Cet algorithme accepte un graphe de nature illustré dans les figures 1, 2 et 4. Pour les besoins de ce document, nous appliquerons l’algorithme de Dijsktra aux graphes de type illustré dans la figure 4. L’algorithme de Dijsktra nécessite également une table pour mémoriser les nœuds visités. Cette table peut être réalisée de multiple façon (liste de priorité, heaps, etc.). La figure 6 donne le contenu de cette table. Nœud Traité Coût Chemin • Figure 6 Table utilisée par l'algorithme de Dijkstra pour mémoriser les informations utiles. La colonne « Nœud » sert à identifier les nœuds du graphe. La colonne « Traité » indique si le nœud correspondant est traité (ou non). La colonne « Coût » indique le coût minimal du chemin passant par ce nœud. Enfin, la colonne « Chemin » donne le nœud prédécesseur du nœud identifié dans la colonne « Nœud ». Par exemple, dans le graphe de la figure 4, le nœud 3 peut avoir comme nœuds prédécesseurs le nœud 2 ou le nœud 6. 7 Voici l’algorithme de Dijsktra donné sous forme de pseudo-code. En pratique, cet algorithme comprend trois parties : i) réglage initial de la table des données; ii) parcours de la table pour la formation des chemins à coûts minimaux; iii) la fouille des chemins minimaux. Définition des paramètres de l’algorithme typedef int NŒUD; // un nœud const int MARQUEUR_ARRÊT = -1; // marqueur spécial LISTE_NŒUD_ADJACENT lna; // liste des nœuds adjacents typedef struct _TABLE { // Table des données (voir figure 6) bool traité; int coût; NŒUD chemin; } TABLE // MAX_NŒUD est le nombre de nœuds dans le graphe TABLE table[MAX_NŒUD]; Réglage initial de la table // source est le nœud de départ void InitTable(NŒUD source, TABLE T[]) { // construire la liste des nœuds adjacents à partir du // graphe Construire(lna); // Réglage initial de la table for (i=0; i<MAX_NŒUD; i++) { T[i].traité = false; T[i].coût = ∞; // infini T[i].chemin = MARQUEUR_ARRÊT; // pas de prédécesseur } // Le coût pour atteindre le nœud de départ est toujours zéro // Ici on indique le nœud de départ pour la fouille des chemins // à coût minimaux T[source].coût = 0; } Parcours de la table // Cette fonction imprime le chemin à coût minimal à partir // du nœud de départ vers un nœud quelconque du graphe // v est le nœud de destination void ParcoureTable(NŒUD v, Table T[]) { if (T[v].chemin != MARQUEUR_ARRÊT) { ParcoureTable(T[v].chemin, T); cout << " à"; } printf(" %d : coût du chemin %d", v, T[v].coût); } 8 Identification des chemins minimaux void Dijkstra(LISTE_NŒUD_ADJACENT lna, TABLE T[]) { 1 NŒUD u, v; 2 while ( true) { 3 // cherche le nœud à traiter 4 u = nœud avec le plus petit coût et non traité dans la 5 table T 6 ou 7 MARQUEUR_ARRÊT si ce nœud n’existe pas 8 if (u == MARQUEUR_ARRÊT) 9 return; 10 T[u].traité = true; 11 // pour chaque nœud adjacent de u faire … 12 while ((v = adjacent(u, lna)) != vide) { 13 if (!T[v].traité) 14 // Est-ce un coût minimal ? 15 // c(u, v) est la valeur de l’arête reliant les 16 // les nœuds u et v 17 if (T[u].coût + c(u, v) < T[v].coût) { 18 // ajuster le coût et indique le nœud prédécesseur 19 T[v].coût = T[u].coût + c(u, v); 20 T[v].chemin = u; 21 } 22 } 23 } } Le réglage initial de la table des données ne doit pas poser de problème. Le seul point important est d’assigner au nœud de départ un coût zéro et pour tous les autres nœuds un coût infini (ou très grand). La fonction ParcoureTable() est simplement une fonction récursive qui imprime un chemin à coût minimal à partir des nœuds prédécesseurs contenus dans la table. Cette fonction est utilisée une fois tous les chemins minimaux auront été identifiés par l’algorithme de Dijsktra. L’algorithme de Dijsktra consiste en une boucle infinie (ligne 2). On doit toujours traiter le nœud dont le coût (estimé) est le plus petit et qui n’est pas encore traité (ligne 4 à 7). Le nœud sélectionné est marqué comme traité (ligne 10). S’il n’y a pas de nœuds de cette nature alors l’algorithme doit terminer son exécution puisque tous les chemins à coûts minimaux ont été identifiés. L’algorithme utilise le parcours systématique en « largeur d’abord » d’un graphe pour identifier tous les chemins dont le coût est minimal. Le parcours en « largeur d’abord » consiste à traiter tous les nœuds adjacents d’un nœud avant de traiter les nœuds adjacents des nœuds adjacents (et ainsi de suite). Les chemins à coûts minimaux sont donc construits en ajoutant les arêtes bouts à bouts à partir du nœud de départ (source). Durant ce parcours systématique du graphe (ligne 12), l’algorithme prend note du coût du chemin de chacun des nœuds rencontrés. Il est également 9 « gourmand » puisque à chaque rencontre d’un nœud, l’algorithme sélectionne toujours l’arête qui donne le plus petit coût au chemin courant (ligne 4). Le reste des étapes de l’algorithme consiste à réajuster, au besoin, le coût des chemins et les nœuds prédécesseurs (lignes 17 à 20). Nous allons présenter un exemple montrant le principe d’opération de l’algorithme de Dijkstra. Cet exemple utilise le graphe de la figure 4 et la liste de nœuds adjacents de la figure 5. Rappelons que ce graphe représente un petit quadrilatère centre-ville. 4 1 5 7 5 6 4 6 5 3 8 7 4 8 8 7 1 Ste-Catherine - Mansfield 2 Ste-Catherine - McGill 3 Ste-Catherine - Université 4 Cathcart - Mansfield 2 700 4 300 5 600 3 1700 2 400 6 700 3 1 700 2 4 1 3 700 1 2 7 1200 8 5 Cathcart - McGill 6 Cathcart - Université 7 René Lévesque - Mansfield 8 René Lévesque - Université • Figure 7 Quadrilatère des rues utilisés pour cet exemple. L’objectif ici est d’obtenir les chemins minimaux entre le nœud 1 qui joue le rôle du nœud de départ et les nœuds 5 et 8. D’abord initialisons la table des données. 1) Réglage initial de la table : Nœud Traité Coût Chemin MARQUEUR_ARRÊT 1 false 0 MARQUEUR_ARRÊT 2 false ∞ MARQUEUR_ARRÊT 3 false ∞ MARQUEUR_ARRÊT 4 false ∞ MARQUEUR_ARRÊT 5 false ∞ MARQUEUR_ARRÊT 6 false ∞ MARQUEUR_ARRÊT 7 false ∞ MARQUEUR_ARRÊT 8 false ∞ Note : À l’état initial seulement le nœud de départ a un coût initial de zéro. 10 2) Exécution de l’algorithme de Dijkstra : ► i) Sélectionner le nœud 1, u = 1 puisque son coût est le plus petit parmi les nœuds pas encore traités. T[1].coût est 0. T[1].traité = true ii) prendre un nœud v qui est adjacent à u. v = 2. a) T[1].coût + c(1, 2) < T[2].coût ? 0 + 400 < ∞ → vrai alors T[2].coût = 400, T[2].chemin = 1. prendre un nœud v qui est adjacent à u. v = 4 b) T[1].coût + c(1, 4) < T[4].coût ? 0 + 700 < ∞ → vrai alors T[4].coût = 700, T[4].chemin = 1. iii) L’état actuel de la table des données Nœud 1 2 3 4 5 6 7 8 Traité true false false false false false false false Coût 0 400 ∞ 700 ∞ ∞ ∞ ∞ Chemin MARQUEUR_ARRÊT 1 MARQUEUR_ARRÊT 1 MARQUEUR_ARRÊT MARQUEUR_ARRÊT MARQUEUR_ARRÊT MARQUEUR_ARRÊT ► i) Sélectionner le nœud 2, u = 2 puisque son coût est le plus petit parmi les nœuds pas encore traités. T[2].coût est 400. T[2].traité = true ii) prendre un nœud v qui est adjacent à u. v = 1. a) nœud 1 est déjà traité passe au nœud adjacent suivant. prendre un nœud v qui est adjacent à u. v = 3. 11 b) T[2].coût + c(2, 3) < T[3].coût ? 400 + 700 < ∞ → vrai alors T[3].coût = 1100, T[3].chemin = 2. iii) L’état actuel de la table des données Nœud 1 2 3 4 5 6 7 8 Traité true true false false false false false false Coût 0 400 1100 700 ∞ ∞ ∞ ∞ Chemin MARQUEUR_ARRÊT 1 2 1 MARQUEUR_ARRÊT MARQUEUR_ARRÊT MARQUEUR_ARRÊT MARQUEUR_ARRÊT ► i) Sélectionner le nœud 4, u = 4 puisque son coût est le plus petit parmi les nœuds pas encore traités. T[4].coût est 700. T[4].traité = true ii) prendre un nœud v qui est adjacent à u. v = 1. a) nœud 1 est déjà traité. prendre un nœud v qui est adjacent à u. v = 5. b) T[4].coût + c(4, 5) < T[5].coût ? 700 + 300 < ∞ → vrai alors T[5].coût = 1000, T[5].chemin = 4. prendre un nœud v qui est adjacent à u. v = 7. b) T[4].coût + c(4, 7) < T[7].coût ? 700 + 700 < ∞ → vrai alors T[7].coût = 1400, T[7].chemin = 4. iii) L’état actuel de la table des données Nœud 1 2 3 4 5 Traité true true false true false Coût 0 400 1100 700 1000 12 Chemin MARQUEUR_ARRÊT 1 2 1 4 6 7 8 false false false ∞ 1400 ∞ MARQUEUR_ARRÊT 4 MARQUEUR_ARRÊT ► i) Sélectionner le nœud 5, u = 5 puisque son coût est le plus petit parmi les nœuds pas encore traités. T[5].coût est 1000. T[5].traité = true ii) prendre un nœud v qui est adjacent à u. v = 4. a) nœud 4 est déjà traité. prendre un nœud v qui est adjacent à u. v = 6. b) T[5].coût + c(5, 6) < T[6].coût ? 5. iii) 1000 + 600 < ∞ → vrai alors T[6].coût = 1600, T[6].chemin = L’état actuel de la table des données Nœud 1 2 3 4 5 6 7 8 Traité true true false true true false false false Coût 0 400 1100 700 1000 1600 1400 ∞ Chemin MARQUEUR_ARRÊT 1 2 1 4 5 4 MARQUEUR_ARRÊT ► i) Sélectionner le nœud 3, u = 3 puisque son coût est le plus petit parmi les nœuds pas encore traités. T[3].coût est 1100. T[3].traité = true ii) prendre un nœud v qui est adjacent à u. v = 2. a) nœud 2 est déjà traité. iii) L’état actuel de la table des données 13 Nœud 1 2 3 4 5 6 7 8 Traité true true true true true false false false Coût 0 400 1100 700 1000 1600 1400 ∞ Chemin MARQUEUR_ARRÊT 1 2 1 4 5 4 MARQUEUR_ARRÊT ► i) Sélectionner le nœud 7, u = 7 puisque son coût est le plus petit parmi les nœuds pas encore traité. T[7].coût est 1400. T[7].traité = true ii) prendre un nœud v qui est adjacent à u. v = 4. a) nœud 4 est déjà traité. prendre un nœud v qui est adjacent à u. v = 8. b) T[7].coût + c(7, 8) < T[8].coût ? 7. iii) 1400 + 1200 < ∞ → vrai alors T[8].coût = 2600, T[8].chemin = L’état actuel de la table des données Nœud 1 2 3 4 5 6 7 8 Traité true true true true true false true false Coût 0 400 1100 700 1000 1600 1400 2600 Chemin MARQUEUR_ARRÊT 1 2 1 4 5 4 7 ► i) Sélectionner le nœud 6, u = 6 puisque son coût est le plus petit parmi les nœuds pas encore traités. T[6].coût est 1600. T[6].traité = true 14 ii) prendre un nœud v qui est adjacent à u. v = 3. b) nœud 3 est déjà traité. prendre un nœud v qui est adjacent à u. v = 8. b) T[6].coût + c(6, 8) < T[8].coût ? 1600 + 700 < 2600 → vrai alors T[8].coût = 2300, T[8].chemin = 6. iii) L’état actuel de la table des données Nœud 1 2 3 4 5 6 7 8 Traité true true true true true true true false Coût 0 400 1100 700 1000 1600 1400 2300 Chemin MARQUEUR_ARRÊT 1 2 1 4 5 4 6 ► i) Sélectionner le nœud 8, u = 8 puisque son coût est le plus petit parmi les nœuds pas encore traités. T[8].coût est 2300. T[8].traité = true ii) prendre un nœud v qui est adjacent à u. v = 7 a) nœud 7 est déjà traité. iii) L’état actuel de la table des données Nœud 1 2 3 4 5 6 7 8 Traité true true true true true true true true Coût 0 400 1100 700 1000 1600 1400 2300 15 Chemin MARQUEUR_ARRÊT 1 2 1 4 5 4 6 ► i) Il n’y a plus de nœud non traité. L’algorithme doit terminer son exécution. Note : La dernière table des données donne le résultat de l’algorithme de Dijsktra. 3) Affichage des chemins minimaux : Nœud de départ 1, nœuds d’arrivée 5 et 8. ParcoureTable(5, T[]); ► T[5].chemin → nœud 4 → coût 1000 T[4].chemin → nœud 1 T[1].chemin → MARQUEUR_ARRÊT Affichage : 1 à 4 à 5 : coût du chemin 1000 ParcoureTable(8, T[]); ► T[8].chemin → nœud 6 → coût 2300 T[6].chemin → nœud 5 T[5].chemin → nœud 4 T[4].chemin → nœud 1 T[1].chemin → MARQUEUR_ARRÊT Affichage : 1 à 4 à 5 à 6 à 8 : coût du chemin 2300 16