Algorithmes Des Graphes
Algorithmes Des Graphes
a b a b a b
c d c d c d
Figure 1 – Un graphe non orienté (à gauche), un graphe orienté (au milieu) et un graphe non orienté simple
(à droite). Écrire à la main l’ensemble X et l’ensemble E pour chacun d’eux.
Le vocabulaire sur les graphes orientés diffère légèrement de celui applicables aux graphes non orientés
mais nous ne chercherons pas la rigueur sémantique. Dans la suite de ce paragraphe, les dénominations
particulières aux graphes orientés sont simplement indiquées entre parenthèses après leur analogue pour les
graphes non orientés.
— boucle : arrête de type {a, a}
— arrête double ou multiple : répétition de l’arête {a, b} dans E.
— graphe simple : graphe sans boucle ni arête multiple
Nous travaillerons surtout avec des graphes simples non orientés.
— degré d(x) d’un sommet x : nombre d’arêtes ayant x pour extrémité. Pour un graphe orienté, on peut
compter séparément
— le nombre d’arêtes qui arrivent sur x, on le nomme degré entrant (ou degré intérieur) et on le
note d− (x) ;
— le nombre d’arêtes qui sortent de x, on le nomme degré sortant (ou degré extérieur) et on le note
d+ (x).
— chaîne (ou chemin) : suite de la forme x0 , e0 , x1 , e1 , ..., xk−1 , ek−1 , xk avec xi ∈ X et ei ∈ E. Le nombre
d’arêtes k est appelé longueur de la chaîne. Dans le cas d’un graphe simple, on peut omettre les ei
dans cette énumération puisqu’il existe au plus une arête d’un sommet à un autre.
— Une chaîne est dite simple si elle ne passe pas deux fois par la même arête.
— On dit qu’une chaîne est fermée lorsque xk = x0 .
— On appelle cycle (ou circuit) une chaîne simple et fermée.
1
TD d’informatique en PC* Algorithmes sur les graphes
à b, etc, et on travaille avec ces indices plutôt qu’avec les lettres auxquelles ils correspondent. La méthode
naïve qui consisterait à manipuler directement les ensembles X et E, sous forme de deux listes par exemple,
n’est jamais utilisée pour des raisons d’efficacité. On lui préfère d’autres représentations parmi lesquelles
nous retenons deux approches.
1. Représentation d’un graphe par une matrice d’adjacence
On définit une matrice M de taille n × n dont l’élément Mij est égal au nombre d’arêtes allant du
nœud xi au nœud xj . Pour un graphe simple, la matrice ne contient que des zéros et des uns. Elle est
symétrique pour un graphe non orienté. La complexité spatiale d’une telle représentation est en O(n2 )
ce qui n’est pas toujours très efficace. En particulier, pour les graphes comportant beaucoup de nœuds
et peu d’arêtes, on stocke inutilement une grande quantité de zéros.
2. Représentation d’un graphe par des listes d’adjacence
On décrit le graphe en se donnant, pour chaque nœud, la liste des nœuds auxquels il est relié par une
arête. On construit donc une liste L de n listes, la liste L[i] contenant les indices des nœuds voisins
du nœud xi . La complexité spatiale d’une telle représentation est en O(m).
Écrire les matrices d’adjacence et les listes d’adjacence associées aux graphes dessinés plus haut
2 Coloration de graphe
La coloration d’un graphe consiste à colorier chacun de ses sommets de telle manière que deux nœuds
adjacents quelconques présentent des couleurs distinctes. On ignore a priori le nombre minimal de couleurs
nécessaires à ce travail, bien qu’il existe divers encadrement de cet entier, appelé nombre chromatique du
graphe et noté γ. Il est par exemple inférieur ou égal au plus haut degré des sommets du graphe, augmenté
de un.
La coloration de graphe constitue un cadre théorique permettant de traiter de nombreuses situations
concrètes dans lesquelles interviennent des incompatibilités ou des conflits : planification de tâches s’excluant
2
TD d’informatique en PC* Algorithmes sur les graphes
1
2
0
3
5
4
6 9
8
7
mutuellement, attribution de fréquences à des antennes relais en évitant les interférences, résolution de
Sudoku, etc. Voici un exemple.
0 7
1
6
0 1 2 3 4 5 6 7
0 6 6 6 6 6 2
1 6 6 6 6 5
2 6 6 6 6 6 3
3 6 6 6 6 4
4 6 6 6 6
5 6 6 6
6 6 6 6 6
7 6 6 6
Les incompatibilités entre produits sont indiquées dans le tableau de la figure 3. Considérons le graphe
dont les sommets correspondent aux produits chimiques, numérotés de 0 à 7, et dont le tableau ci-dessus
s’apparente à la matrice d’adjacence : on place une arête entre deux sommets lorsque le stockage conjoint
des deux produits chimiques associés est interdit. On obtient ainsi le graphe d’incompatibilité de la figure 3.
Maintenant, associons une couleur à chaque armoire : la première armoire est bleue, la seconde rouge, etc.
Déterminer le rangement des produits dans les armoires équivaut à colorier chacun des sommets du graphe
d’incompatibilité. Par exemple, colorier le sommet 0 en bleu équivaut à ranger le produit 0 dans la première
armoire.
Algorithmes de coloration
Les couleurs sont codées comme des entiers positifs 0, 1, 2, etc. Une manière naïve de colorier un graphe
consiste à utiliser l’algorithme suivant. On traite les nœuds un par un, dans un ordre quelconque. Pour chacun
d’eux, on essaye toutes les couleurs dans l’ordre 0, 1, 2, etc, et on retient la première couleur compatible
qui n’entre pas en conflit avec ses voisins déjà coloriés. Il s’agit d’un algorithme glouton : une fois qu’on a
attribué une couleur à l’un des sommets, on ne revient plus sur ce choix même si on s’aperçoit plus tard que
3
TD d’informatique en PC* Algorithmes sur les graphes
2 2
3 3
1 1
4 4
0 0
5 5
8 8
6 6
7 7
4
TD d’informatique en PC* Algorithmes sur les graphes
a f g
b
c h
j
d
e i
Figure 5 – Exemple de graphe non connexe présentant trois composantes connexes. Entourez-chacune
d’elles au crayon comme à l’école maternelle.
La difficulté d’un tel parcours exploratoire réside dans la nécessité de ne visiter qu’une seule fois chaque
nœud tout en étant certain de n’en oublier aucun. Pour éviter les doubles visites, il suffit de tenir à jour
un tableau de booléens indiquant, pour chaque nœud, s’il a déjà été visité ou non. Quant à l’exploration
elle-même, on peut l’effectuer selon l’algorithme du parcours en profondeur d’abord dont le principe est le
suivant : partant d’un nœud, on visite un par un tous ses voisins. Chacune de ces visites inclut la visite des
voisins du voisin visité. Cette idée appelle naturellement une écriture récursive dont le cas d’arrêt correspond
à l’absence de voisin.
À titre d’exemple de parcours en profondeur d’un graphe, on peut citer le langage de description de mo-
lécules SMILES qui permet de représenter une molécule par une chaîne de caractères obtenue en parcourant
en profondeur d’abord sa formule développée, considérée comme un graphe, et en appliquant certains règles
conventionnelles.
1. Coder la fonction parcours_profondeur_rec_0(L, i, marques) qui réalise le parcours en profondeur.
Les paramètres sont la liste de listes d’adjacence L, le nœud de départ i et un tableau de booléens
marques qui sera initialisé avant l’appel à la fonction et indiquant si chaque nœud a été visité. Cette
fonction ne renvoie rien mais modifie le tableau marques qui, à l’issue de l’exécution, contiendra l’in-
formation voulue.
Pour suivre visuellement l’exécution, vous pouvez placer à l’endroit approprié une instruction affichant
le message « début de l’exploration à partir du sommet ... » ou bien « fin de l’exploration pour le sommet
... ». Ces deux options correspondent à deux ordres d’affichage des sommets parcourus que l’on appelle
respectivement ordre préfixe et ordre postfixe.
Procéder à quelques essais sur les graphes fournis en exemples. Réfléchir au choix du vocabulaire :
« parcours en profondeur d’abord ».
2. Quelle est la complexité de ce parcours en profondeur dans le pire des cas ? On compte comme unité
de coût les affectations et les tests.
3. Coder la fonction est_connnexe(L) qui renvoie un booléen indiquant si le graphe de listes d’adjacence
L est connexe. La tester.
4. Coder la fonction parcours_profondeur_rec_1(L, i, marques, L_atteignables) qui, en plus d’attri-
buer les marques, construit la liste L_atteignables des nœuds que l’on peut atteindre depuis le nœud
i (lui-même compris). Cette fonction, fortement inspirée de
parcours_profondeur_rec_0, ne renvoie rien non plus mais modifie la liste de booléens marques et celle
L_atteignables qui énumère les sommets atteignables depuis i. Ces deux listes seront initialisées avant
5
TD d’informatique en PC* Algorithmes sur les graphes
l’appel de la fonction et contiendront la réponse après son exécution. Je vous impose ici d’opter pour
un énumération postfixe des sommets, c’est à dire de placer chacune d’eux dans la liste L_atteignables
après la fin de l’exploration de ses descendants.
5. La structure choisie ci-dessus n’est pas très pratique : on doit créer avant l’appel de la fonction des listes
marques et L_atteignables que la fonction modifiera. Pour plus de commodité, nous allons encapsuler
la fonction parcours_profondeur_rec_1 et l’initialisation de ces deux listes dans une autre fonction
parcours_profondeur(L, i). Coder cette fonction pour qu’elle renvoie la composante connexe à laquelle
i appartient. L’appliquer à quelques exemples.
6. Écrire la fonction composantes_connexes(L) qui renvoie les composantes connexes d’un graphe de
listes d’adjacences L sous forme d’une liste de listes, chacune d’elle rassemblant les nœuds d’une même
composante connexe. Il suffit pour cela de parcourir successivement tous les nœuds : chaque fois qu’on
en découvre un qui n’a pas été déjà visité, on « ouvre » une nouvelle composante connexe que l’on
construit en réalisant le parcours en profondeur depuis le nœud en question.
Remarque
Nous savons que l’exécution d’une fonction récursive s’effectue au moyen d’une pile de récursivité. De fait,
il est possible de « dérécursifier » l’algorithme de parcours en profondeur en manipulant explicitement une
pile des sommets à visiter. Chacun des voisins à visiter est empilé, puis dépilé lors de sa visite qui comprend
l’empilement de ses propres voisins. Ce n’est pas très difficile à coder, mais je doute que nous en prenions le
temps, d’autant que l’écriture récursive s’avère plus confortable
3 3
2 1 4 5
7 4 1 2 4
1
3 7 1
6 4 2 5 2 3
0 10 0 1
2 3 2 7
5 5
Figure 6 – Exemples de graphes valués. Écrire leur matrice d’adjacence et leurs listes d’adjacence.
Considérons maintenant deux sommets xi et xj reliés par une chaîne. On peut attribuer à cette chaîne
une longueur égale à la somme des longueurs des arêtes qui la constituent. Il existe en général plusieurs
chemins menant de xi à xj et, dans de nombreuses applications, on souhaite trouver celui qui est le plus
1. Ce mot prend ici un sens différent de celui qu’il avait dans la première partie
6
TD d’informatique en PC* Algorithmes sur les graphes
court. C’est ce problème que résolvent par exemple les logiciels de navigation routière ainsi que le protocole
OSPF de transport d’information sur internet. Cette résolution se fait selon l’algorithme de Dijkstra, célèbre
informaticien et mathématicien néerlandais. En voici le principe.
Nous supposons le graphe connexe et cherchons le plus court chemin d’un nœud de départ x0 vers chacun
des nœuds du graphe. On attribue à chaque sommet x une étiquette λ(x) qui va évoluer au fil des itérations
de l’algorithme pour progressivement atteindre la longueur minimale cherchée entre x0 et x. Simultanément,
l’ensemble S des nœuds x pour lesquels λ(x) a atteint sa valeur minimale (et pour lesquels le chemin optimal
est donc connu) grossit peu à peu.
Au départ, le seul nœud vers lequel on connaît trivialement le chemin minimal est x0 : comme il est à
distance nulle de lui-même, on pose λ(x0 ) = 0 et cette étiquette n’aura pas à être modifiée. Pour les autres
sommets on pose λ(x) = ∞, signifiant par là que le chemin optimal qui y mène est au départ inconnu. Après
cette initialisation, l’algorithme réalise des itérations qui se poursuivent tant que S n’est pas égal à X ou,
de manière équivalente, tant que S n’est pas vide. Chaque itération met en jeu un « sommet actif » t0 , au
départ égal à x0 , et se déroule en deux temps.
1. révision des étiquettes
Pour tout sommet x de S voisin de t0 , calculer λ(t0 ) + `t0 x . Si cette somme est plus petite que λ(x),
modifier λ(x) en lui attribuant cette nouvelle valeur.
2. choix d’un sommet d’étiquette minimale
Choisir dans S un sommet t0 d’étiquette minimale et le placer dans l’ensemble S. Il joue le rôle de
sommet actif pour la prochaine itération.
La situation est représentée sur la figure 7. Le professeur fournira peut-être, selon le temps disponible, une
preuve de terminaison et de correction de cet algorithme. La correction met en jeu l’invariant de boucle
suivant.
« Pour tous les sommets x de S, λ(x) est la longueur du chemin minimal de x0 à x. »
t0
x0
S S
étiquettes définitives étiquettes provisoires
En pratique, on souhaite trouver non seulement la longueur minimale de x0 à x mais aussi le chemin
optimal qu’il faut suivre du nœud de départ au nœud d’arrivée. Dans ce but, on attribue un père à chaque
sommet x : c’est le sommet qui précède x dans ce chemin optimal de x0 à x, c’est à dire le sommet actif t0
par lequel on a calculé l’étiquette définitive de x sous la forme λ(t0 ) + `t0 x . Lors de l’étape de révision des
étiquettes, on révise donc également les pères.
Pour nous familiariser avec l’algorithme, appliquons-le à la main sur les deux exemples de la figure 6
en partant du sommet A = 0 en remplissant les tableaux placés plus bas. Les sommets sont désignés par
une lettre plutôt que par un numéro afin de ne pas les confondre avec les longueurs des chemins. À chaque
itération de l’algorithme, lors de la révision des étiquettes, on écrit dans la colonne portant le sommet x en
en-tête, les valeurs de λ(x). On note aussi dans cette colonne à partir de quel sommet t0 le sommet x a été
atteint, c’est à dire son père provisoire.
Lorsqu’on sélectionne t0 , on souligne l’étiquette correspondante sur cette ligne. Elle devient alors défini-
tive, t0 entre dans S et, comme on ne modifiera plus λ(t0 ), on trace un trait vertical dans la partie restante
7
TD d’informatique en PC* Algorithmes sur les graphes
de sa colonne. De même le père provisoire de t0 reçoit le statut de père définitif et on l’indique dans la
colonne la plus à droite. Le point de départ A n’a pas de père et nous le qualifions d’orphelin.
On utilise aussi :
— une liste etiquette de longueur n dont l’élément d’indice i est λ(xi ) ;
— une liste pere de longueur n dont l’élément pere[i] est le père du nœud xi ;
— une liste S_barre de longueur variable contenant les éléments de S.
1. Coder la fonction revision(t_0, L, etiquette, S_barre, pere) qui révise les étiquettes à partir d’un
sommet actif t0 et met à jour la liste des pères. On pourra utiliser l’expression a in Liste qui renvoie
un booléen indiquant si l’élément a figure dans la liste Liste. Cette fonction ne renvoie rien mais
modifie les listes etiquette et pere.
2. Écrire la fonction choix_sommet_etiquette_minimale(etiquette, S_barre) qui renvoie le sommet de S
dont l’étiquette est minimale.
3. Écrie la fonction Dijkstra(L, x_0) qui met en place l’algorithme. Elle renvoie la liste des étiquettes
définitives et celle des pères définitifs. Par souci de simplicité, on utilise la méthode remove applicable
aux listes : Liste.remove(x) supprime l’élément x de la liste L contenant x.
4. Écrire la fonction construit_chemin(pere, depart, arrivee) qui utilise le tableau des pères relatifs à
un nœud de départ pour reconstruire le chemin optimal vers un nœud d’arrivée. Ce chemin est renvoyé
comme la liste ordonnée des nœuds à parcourir. Un écriture avec une boucle while est possible, mais
le choix d’un codage récursif est ici tout à fait naturel.
5. Appliquer l’algorithme aux graphes de la figure 6 ou à d’autres plus compliqués que le professeur vous
fournira.
8
TD d’informatique en PC* Algorithmes sur les graphes
Codage
1. Pour coder l’algorithme de Kruskal, on a besoin de la liste des arêtes du graphe. Écrire la fonction
liste_adjacence_vers_liste_aretes(L) qui prend en paramètres les listes d’adjacence d’un graphe
non orienté et renvoie la liste de ses arêtes. Une arête est représentée un triplet de la forme (xi , xj , `ij )
typé comme tuple.
2. Écrire la fonction trouve_arete_mini(F) qui prend en argument une liste d’arêtes et en renvoie une de
longueur minimale, sous forme de tuple comme décrit dans la question précédente.
b 2
1 d
a e
g f i 9
c
h 3
3. Avant d’insérer une arête dans A, il faut s’assurer que cela ne créera pas de cycle dans le graphe.
Les deux extrémités xi et xj de cette arête doivent donc se trouver dans des composantes connexes
distinctes de G0 , c’est à dire ne pas être déjà reliées par un chemin. Pour s’en assurer, on attribue à
chaque nœud un entier que j’appelle « marqueur » et qui désigne le numéro de la composante connexe
à laquelle il appartient. Au départ, lorsque A est vide, tous les nœuds portent des marqueurs distincts.
Dès que deux nœuds sont mis en relation par une arête, les composantes connexes auxquelles ils
appartiennent fusionnent. On donne donc à tous ces nœuds le même marqueur, arbitrairement choisi
égal à celui de la première extrémité xi de l’arête. Sur la figure 8 par exemple, on a quatre composantes
connexes portant les numéros 1, 2, 3 et 9. Si on place l’arête en pointillés entre (c, g), les trois sommets
2. On l’aurait deviné puisqu’un cycle correspond à l’existence de deux chemins distincts entre deux nœuds. On peut faire
des économies en en supprimant un
3. un graphe est dit complet si chaque nœud est voisin de tout autre nœuds.
9
TD d’informatique en PC* Algorithmes sur les graphes
a, d et g se voient attribuer le marqueur 3. Les marqueurs des sommets sont stockés dans une liste de
longueur n : marqueur[i] est le marqueur du sommet xi .
Écrire la fonction introduit_un_cycle_ou_fusion(arete, marqueur) qui analyse l’appartenance éven-
tuelle des deux extrémités d’une arête à une même composante connexe et renvoie la réponse dans
un booléen. Dans le cas négatif, on fusionne les deux composantes connexes en mettant à jour la liste
marqueur passée par référence.
4. Écrire enfin la fonction Kruskal(L_adjacence) qui prend comme seul argument les listes d’adjacence
d’un graphe connexe valué et qui renvoie la liste des arêtes constituant l’arbre couvrant minimal. Par
souci de simplicité, on utilisera la méthode remove sur les listes.
Lille
725 522
222
Paris
Brest
Strasbourg
596 490
583
804
932
857
Bordeaux
451 Nice
476
Perpignan
10
TD d’informatique en PC* Algorithmes sur les graphes
0 j N −1
0
N −1
1. Dans le réseau carré, un nœud est repéré par ses indices (i, j) alors que dans les graphes manipulés
jusqu’ici, un nœud est associé à un entier. Pour utiliser les fonctions précédentes, nous devons assurer
le passage d’un type de repérage à l’autre. On numérote les nœuds du réseau carré de 0 à N 2 − 1 selon
l’ordre de lecture (on parcourt la première ligne de gauche à droite, puis la seconde, etc). Écrire la
fonction numero(i, j, N) qui renvoie le numéro du nœud d’indices (i, j).
2. Écrire la fonction indices(k, N) qui fait le travail inverse, c’est à dire qui renvoie les indices (i, j) du
nœud numéro k.
3. Écrire la fonction ajoute_arete_alea(L_adj, p, q) qui, prenant en argument la liste de listes d’ad-
jacence d’un graphe, ajoute un arc allant du nœud d’indice p vers celui d’indice q.
4. Écrire la fonction reseau_carre_alea(N) qui renvoie la liste de listes d’adjacence d’un graphe pondéré
aléatoire dont les nœuds sont les points d’un réseau carré de taille N × N . On utilisera la fonction
random() du module random pour obtenir des flottants compris entre 0 et 1.
5. Écrire enfin la fonction produit_labyrinthe_alea(N) qui produit le labyrinthe. Il doit être renvoyé sous
la forme d’une liste d’arêtes, une arête étant ici un tuple de deux couples d’indice (i, j). Par exemple,
((3, 4), (3, 5)) signifie qu’on peut circuler entre les points (3, 4) et (3, 5).
6. Je vous fournirai une fonction pour représenter graphiquement le labyrinthe.
11
TD d’informatique en PC* Algorithmes sur les graphes
2
3
1
4
0
5
8
6
7
2
3
1
4
0
5
8
6
7
12
TD d’informatique en PC* Algorithmes sur les graphes
3 3
2 1 4 5
7 4 1 2 4
1
3 7 1
6 4 2 5 2 3
0 10 0 1
2 3 2 7
5 5
Figure 10 – Exemples de graphes valués. Écrire leur matrice d’adjacence et leurs listes d’adjacence.
13
TD d’informatique en PC* Algorithmes sur les graphes
Lille
725 522
222
Paris
Brest
Strasbourg
596 490
583
804
932
857
Bordeaux
451 Nice
476
Perpignan
14