Quelques points et des traits pour les relier suffisent pour crĂ©er un graphe. Cette grande simplicitĂ© est pourtant Ă l’origine d’un foisonnement mathĂ©matiques impressionnant.
L’acte de naissance de la thĂ©orie des graphes date d’une petite Ă©nigme Ă laquelle s’attelaient sans succĂšs les habitants de Königsberg. Comment un voyageur pouvait traverser les sept ponts sans jamais passer deux fois sur le mĂȘme pont ?
Euler rĂ©sout le problĂšme et fonda du mĂȘme coup la thĂ©orie des graphes !

Un graphe permet d’extraire l’essence du problĂšme : les arĂȘtes sont les ponts et les sommets (ou nĆuds) sont les zones accessibles depuis ces ponts (sĂ©parĂ©es par les bras de riviĂšre).
La forme prĂ©cise des lignes reliant les points n’a pas d’importance : elles ne font qu’indiquer l’existance de laison entre ces points (cela illustre le caractĂšre topologique et non gĂ©omĂ©trique du problĂšme).

Euler compris alors que ce qu’on appelerait ensuite un chemin eulĂ©rien (un chemin reliant chaque sommet en ne passant qu’une fois par chaque arĂȘte) n’est possible que si le graphe ne compte pas plus de deux sommets d’oĂč partent un nombre impair d’arĂȘtes. Or dans le cas de Königsberg, il part un nombre impair d’arĂȘtes de chacun des sept sommets ! Un chemin eulĂ©rien y est donc impossible.
Lorsqu’on trace un chemin eulĂ©rien sur un graphe, trois types de sommets se prĂ©sentent : un sommet peut ĂȘtre soit un point de dĂ©part, soit un point d’arrivĂ©e, soit un point traversĂ© (on y arrive puis on en repart). Et pour ces derniers, on aura toujours un nombre pair d’arĂȘtes (autant d’arrivĂ©es que de dĂ©parts)…
Ce premier pas d’Euler eut lieu en 1737, il fallut attendre ensuite plus de cent ans pour que Kirchhoff rĂ©utilise des graphes pour dĂ©terminer les intensitĂ©s circulant dans les diffĂ©rentes branches d’un circuit Ă©lectrique ; il met alors au point la notion d’arbre, des graphes sans boucle, en 1847.
Dix ans plus tard, c’est au tour de la chimie de s’attaquer aux graphes. En 1857, Cayley s’intĂ©resse aux diffĂ©rentes structures possibles (isomĂšres) d’une molĂ©cule ayant $n$ atomes de carbone et $2n+2$ atomes d’hydrogĂšne (un alcane). Cela revient Ă trouver tous les arbres Ă $3n+2$ Ă©lĂ©ments tels que de chaque Ă©lĂ©ment (chaque sommet) partent exactement une ou quatre arĂȘtes (symbolisant les liaisons chimiques).
En 1869, enfin, les mathématiciens redécouvrent les graphes, par la voix de Jordan qui, sans connaßtre les travaux de Cayley, retrouve ses résultats.
Les jeux ne sont pas en reste : en 1859, le mathĂ©maticien et physicien irlandais William Hamilton invente “The Icosian Game”, dont le but est de visiter une et une seule fois tous les sommets d’un dodĂ©caĂšdre rĂ©gulier. Un tel chemin est depuis appelĂ© hamiltonien.
Apparemment voisin du problĂšme du chemin eulĂ©rien (visiter une et une seule fois chaque arĂȘte), le problĂšme du chemin hamiltonien est en rĂ©alitĂ© beaucoup plus difficile.
Pour ce qui est du jeu que vous pouvez tester ci-dessous, une rĂšgle supplĂ©mentaire impose que la fin du chemin soit adjacente Ă son dĂ©but. En rejoignant le point de dĂ©part, on formerait alors un cycle et on appelle ainsi cycle hamiltonnien un cycle passant par tous les sommets (pour pimenter les choses, un premier joueur choisit 5 sommets voisins les uns des autres et le deuxiĂšme joueur doit alors trouver les 15 restants ; c’est ça le jeu original d’Hamilton1 , et il a prouvĂ© que la victoire est alors toujours possible pour le deuxiĂšme joueur).
Le deuxiĂšme moitiĂ© du 20e siĂšcle et l’avĂšnement de l’informatique voit la thĂ©orie des graphes prendre son vĂ©ritable essor : c’est en effet l’outil idĂ©al pour dĂ©crire les rĂ©seaux complexes modernes.
Les réseaux sont partout : les réseaux sociaux et les réseaux de communication, mais on trouve aussi des réseaux dans des champs scientifiques trÚs différents comme en biologie, logistique, linguistique, économie, etc.
La théorie des graphes donne un langage commun à la description de ces réseaux.
 Â
Un graphe $G$ est un ensemble de sommets (ou nĆuds) S (notĂ©s $s_i$) et d’arĂȘtes A (notĂ©es $\{s_i,s_j\}$) reliant deux Ă deux ces sommets. On note un tel graphe : $G = (S,A)$.
Quelques exemples dans des champs variés :
| RĂ©seau | Sommets | ArĂȘtes |
|---|---|---|
| transport aérien | aéroports | vols |
| plans routiers | carrefours | tronçons de routes |
| réseau génétique | gÚnes | facteurs de transcription |
| cerveau | neurones | synapses |
| colonie de fourmis | jonctions | traces de phéromones |
| appels téléphoniques | numéro | appel |
| réseau de citation | auteur | citation |
Est représenté ci-dessus le graphe $G=(S,A)$ avec $S=\{1,2,3,4,5,6\}$ et $A=\{\{1,2\},\{1,5\},\{2,5\},\{3,3\},\{4,6\}\}$
Une boucle est une arĂȘte reliant un sommet Ă lui-mĂȘme.
Exemple : il y a une boucle sur le sommet $3$.
L’ensemble des sommets adjacents (joints par une arĂȘte) au sommet $s_i$, autrement dit les voisins du sommet $s_i$, se note : $Adj(s_i)=\{s_j \in S,\{ s_i,s_j \}\in A\}$.
Exemple : $Adj(2)=\{1,5\}$
Un graphe non orientĂ© est dit simple s’il ne comporte pas de boucle et jamais plus d’une arĂȘte entre deux sommets.
On appelle ordre d’un graphe le nombre de ses sommets ($card(S)$ ou plus simplement $|S|$).
Exemple : pour le graphe ci-dessus $|S|=6$
On appelle taille d’un graphe le nombre de ses arĂȘtes ($card(A)$ ou $|A|$).
Exemple : pour le graphe ci-dessus $|A|=5$
 Â
Les arĂȘtes d’un graphe non orientĂ© sont symĂ©triques, elles se parcourent indiffĂ©remment dans les deux sens, mais ce n’est pas toujours trĂšs pertinent. ConsidĂ©rons les exemples suivants :
Pour modéliser ce type de situation, on utilise des graphes orientés.
Dans un graphe orientĂ©, les sommets sont reliĂ©s par des arcs que l’on peut identifier Ă des couples de sommets (un couple a un ordre) : au couple $(a,b)$ correspond un arc d’origine $a$ et d’extrĂ©mitĂ© $b$.

L’arc $a = (s_i,s_j)$ est dit sortant en $s_i$ et incident ou entrant en $s_j$, et $s_j$ est un successeur de $s_i$, tandis que $s_i$ est un prĂ©dĂ©cesseur de $s_j$.
L’ensemble des successeurs d’un sommet $s_i \in S$ est notĂ© $Succ(s_i) = \{s_j \in S,(s_i,s_j) \in A\}$.
L’ensemble des prĂ©dĂ©cesseurs d’un sommet $s_i \in S$ est notĂ© $Pred(s_i) = \{s_j \in S,(s_j,s_i) \in A\}$.
Un graphe orientĂ© permet, par exemple, de rĂ©sumer les relations dans un chi-fou-mi (ici dans la variante puits montrant bien que jouer “pierre” est sans intĂ©rĂȘt, ou encore dans la variante pierre-feuille-ciseaux-lĂ©zard-spock de The Big Bang Theory).

Pour “pierre-feuille-ciseaux-puits”, le graphe $G(S,A)$ a pour sommets :
$$S=\{pierre,feuille,ciseaux,puits\}$$
Et pour arĂȘtes :
 Â
Dans un graphe non-orientĂ©, le degrĂ© d’un sommet $s$ est le nombre d’arĂȘtes incidentes Ă ce sommet (une boucle comptant pour 2).
Dans le cas d’un graphe simple, on aura $d(s) = |Adj(s)|$ (nombre de sommets adjacents).
Dans un graphe orientĂ©, le degrĂ© sortant d’un sommet $s$, notĂ© $d_+(s)$, est le nombre d’arcs partant de $s$ (de la forme $(s,v)$ avec $s,v \in S$).
Dans le cas d’un graphe simple, on aura $d_+(s) = |Succ(s)|$ (nombre de successeurs).
De mĂȘme, le degrĂ© entrant d’un sommet $s$, notĂ© $d_â(s)$, est le nombre d’arcs arrivant en $s$ (de la forme $(v, s)$ avec $s,v \in S$).
Dans le cas d’un graphe simple, on aura $d_â(s) = |Pred(s)|$ (nombre de prĂ©dĂ©cesseurs).
Le degrĂ© d’un sommet $s$ d’un graphe orientĂ© est donc la somme des degrĂ© entrant et sortant : $d(s) = d_+(s) + d_â(s)$.
Exemple : $d_+(puits)=2$ et $d_-(puits)=1$, d’oĂč $d(puits) = d_+(puits) + d_â(puits)=2+1=3$
Pour tout graphe, la somme des degrĂ©s de chaque sommet est le double du nombre d’arĂȘtes. $$\sum_{s \in S} d(s) = 2*|A|$$
Et pour un graphe orientĂ©, la somme des degrĂ©s entrant vaut la somme des degrĂ©s sortants et est aussi Ă©gal au nombre d’arĂȘtes. $$\sum_{s \in S} d_+(s) = \sum_{s \in S} d_-(s) = |A|$$
| $d_+$ | $d_-$ | |
|---|---|---|
| puits | 2 | 1 |
| feuille | 2 | 1 |
| ciseaux | 1 | 2 |
| pierre | 1 | 2 |
$\sum_{s \in S} d_+(s) =\sum_{s \in S} d_-(s) =|A|=6$
On en déduit que pour tout graphe, il y a un nombre pair de sommets à degré impair.
Le degrĂ© d’un sommet est un concept simple, mais fĂ©cond, utilisĂ© dans des contextes trĂšs diffĂ©rents. Dans un rĂ©seau social, le degrĂ© d’un sommet traduit l’importance d’une personne dans le groupe. Dans un rĂ©seau de communication comme Internet, on apprend beaucoup sur l’organisation rĂ©elle du rĂ©seau Ă partir de la distribution obtenue en ordonnant les sommets par leurs degrĂ©s.
 Â
Soit $G = (S, A)$ un graphe orienté.
Un chemin d’un sommet $u$ vers un sommet $v$ est une sĂ©quence $< s_0,s_1,s_2,…,s_k >$ de sommets tels que $u = s_0$, $v = s_k$ et $(s_{iâ1},s_i) \in A$ pour tout $i \in \{1,…,k\}$.
On dira que le chemin contient les sommets $s_0,s_1,…,s_k$ et les arcs $(s_0,s_1),(s_1,s_2),…,(s_{kâ1},s_k)$.
La longueur du chemin est le nombre d’arcs dans le chemin, c’est-Ă -dire $k$.
S’il existe un chemin de $u$ Ă $v$, on dira que $v$ est accessible Ă partir de $u$.
Un chemin $< s_0,s_1,…,s_k >$ forme un circuit si $s_0 = s_k$ et si le chemin comporte au moins un arc ($k â„ 1$).
Une boucle est un circuit de longueur $1$.

Si $G = (S, A)$ est un graphe non orienté, on parlera de chaßne au lieu de chemin, et de cycle au lieu de circuit.
Dans le cas d’un cycle, toutes les arĂȘtes doivent ĂȘtre distinctes.
Un graphe sans cycle est dit acyclique.
Un arbre est est un graphe acyclique et connexe.
La ligne A du RER forme un arbre, mais pas la ligne C. Â
La notion de longueur de chemin nous permet ensuite de définir la notion de distance dans un graphe.
Soit un graphe $G=(S,A)$.
La distance d’un sommet Ă un autre est la longueur du plus court chemin/chaĂźne entre ces deux sommets, ou $\infty$ s’il n’y a pas de tel chemin/chaĂźne :
$
\forall x,y \in S,d(x,y)=\left\lbrace
\begin{array}{ll} k \; &\text{longueur du plus court chemin s’il existe}\\ \infty &\text{sinon}\end{array}\right.
$
Le diamĂštre d’un graphe est la plus grande distance entre deux sommets.
Exemple : dans le graphe orienté ci-dessus $d(2,3)=2$, $d(3,2)=1$, $d(6,1)=3$ et $d(3,6)=\infty$.

Dans ce graphe taureau, le diamĂštre vaut 3 (distance entre les deux cornes).
 Â
Un graphe non orientĂ© est connexe si chaque sommet est accessible Ă partir de n’importe quel autre (pour tout couple de sommets distincts $(s_i,s_j) \in S^2$, il existe une chaĂźne entre $s_i$ et $s_j$).
Le graphe comportant les sommets $1,2,3,4$ n’est pas connexe mais celui comportant les sommets $5,6,7,8$ l’est !
 Â
Soit le graphe $G = (S,A)$ d’ordre $n$. On suppose que les sommets de $S$ sont numĂ©rotĂ©s de $1$ Ă $n$.
La reprĂ©sentation par listes d’adjacence de $G$ consiste en un tableau $T$ de $n$ listes (un par sommet) :
Pour chaque sommet $s_i \in S$, la liste d’adjacence $T[s_i]$ est une liste de tous les sommets $s_j$ tels qu’il existe un arc $(s_i,s_j) \in A$ ou une arĂȘte $\{s_i,s_j\} \in A$.
Autrement dit, pour chaque sommet, on liste ses voisins accessibles.
Dans chaque liste d’adjacence, les sommets sont gĂ©nĂ©ralement ordonnĂ©s arbitrairement.
Pour l’implĂ©mentation Python, on peut soit utiliser des listes imbriquĂ©es, soit un dictionnaire.
Exemple :

# Avec un dictionnaire
T = {1:[1,3],2:[1,3,4],3:[],4:[1,2,3,4]}
# Avec des listes imbriquées
T = [[1,3],[1,3,4],[],[1,2,3,4]]
L’avantage du dictionnaire est qu’il n’impose pas d’avoir une correspondance entre le numĂ©ro du sommet et la position dans la liste d’adjacence (dans le cas de listes imbriquĂ©es T[0] sera toujours la liste correspondant au premier sommet, T[1], celle du deuxiĂšme, etc.).
Taille mĂ©moire nĂ©cessaire: si le graphe G est orientĂ©, la somme des longueurs des listes d’adjacence est Ă©gale au nombre d’arcs de $A$, puisque l’existence d’un arc $(s_i,s_j)$ se traduit par la prĂ©sence de $s_j$ dans la liste d’adjacence de $T[s_i]$.
En revanche, si le graphe n’est pas orientĂ©, la somme des longueurs de toutes les listes d’adjacence est Ă©gale Ă deux fois le nombre d’arĂȘtes du graphe, puisque si $\{s_i,s_j\}$ est une arĂȘte, alors $s_i$ appartient Ă la liste d’adjacence de $T[s_j]$, et vice versa.
Par consĂ©quent, la liste d’adjacence d’un graphe ayant $n$ sommets et $m$ arcs ou arĂȘtes nĂ©cessite de l’ordre de $O(n + m)$ emplacements mĂ©moire.
OpĂ©rations sur les listes d’adjacence : pour tester l’existence d’un arc $(s_i, s_j)$ ou d’une arĂȘte $\{s_i, s_j \}$, on doit parcourir la liste d’adjacence de $T[s_i]$ jusqu’Ă trouver $s_j$.
En revanche, le calcul du degrĂ© d’un sommet, ou l’accĂšs Ă tous les successeurs d’un sommet, est trĂšs efficace : il suffit de parcourir la liste d’adjacence associĂ©e au sommet.
D’une façon plus gĂ©nĂ©rale, le parcours de l’ensemble des arcs/arĂȘtes nĂ©cessite le parcours de toutes les listes d’adjacence, et prendra un temps de l’ordre de $m$, oĂč $m$ est le nombre d’arcs/arĂȘtes.
Le calcul des prĂ©dĂ©cesseurs d’un sommet n’est pas pratique avec cette reprĂ©sentation. Il nĂ©cessite le parcours de toutes les listes d’adjacences de $T$.
Si l’on a besoin de connaĂźtre les prĂ©dĂ©cesseurs d’un sommet, une solution est de maintenir, en plus de la liste d’adjacence des successeurs, la liste d’adjacence des prĂ©dĂ©cesseurs.
 Â
Soit le graphe $G = (S,A)$ d’ordre $n$. On suppose que les sommets de $S$ sont numĂ©rotĂ©s de $1$ Ă $n$.
La reprĂ©sentation par matrice d’adjacence de $G$ consiste en une matrice boolĂ©enne $M=(m_{i,j})$ de taille $n\times n$ telle que $m_{i,j} = 1$ si $ (i,j) \in A$, et $m_{i,j} = 0$ sinon.

La matrice d’adjacence d’un graphe non orientĂ© sera toujours symĂ©trique, mais pas nĂ©cessairement celle d’un graphe orientĂ©.
Implémentation Python :
M = [[1,0,1,0],[1,0,1,1],[0,0,0,0],[1,1,1,1]]
# pour savoir si un arc joint le sommet 1 au sommet 3
M[0][2]
# pour savoir si un arc joint le sommet 3 au sommet 1
M[2][0]
Taille mémoire nécessaire :
La matrice d’adjacence d’un graphe ayant $n$ sommets nĂ©cessite de l’ordre de $O(n^2)$ emplacements mĂ©moire.
Si le nombre d’arcs est trĂšs infĂ©rieur Ă $n^2$ (on parle alors de graphe creux), cette reprĂ©sentation est loin d’ĂȘtre optimale.
OpĂ©rations sur les matrices d’adjacence :
le test de l’existence d’un arc ou d’une arĂȘte avec une reprĂ©sentation par matrice d’adjacence est immĂ©diat (il suffit de tester directement la case correspondante de la matrice).
En revanche, connaĂźtre le degrĂ© d’un sommet nĂ©cessite le parcours de toute une ligne (ou toute une colonne) de la matrice.
D’une façon plus gĂ©nĂ©rale, le parcours de l’ensemble des arcs/arĂȘtes nĂ©cessite la consultation de la totalitĂ© de la matrice, et prendra un temps de l’ordre de $n^2$.
Application : Combien y a-t-il de chemins menant d’un sommet Ă un autre en exactement $n$ coups ?
On cherche donc les chemins de longueur $n$ entre deux sommets $i$ et $j$.
Soit $M = (m_{i,j})$ la matrice d’adjacence d’un graphe $G(S,A)$. $M$ est donc aussi le nombre de chemin de $i$ Ă $j$ de longueur $1$ (une seul arĂȘte), que l’on va noter $m_{i,j}(1)$.
L’idĂ©e est alors de dĂ©couper le chemin de longueur $n$ en un chemin de longueur $n-1$ suivi d’un chemin de longueur $1$. Le nombre $m_{i,j}(n)$ de chemins de longueur $n$ est ainsi donnĂ© par :
$$ m_{i,j}(n)=\sum_{k=1}^{|S|}m_{i,k}(n-1)\times m_{k,j}(1)$$
Pour $ m_{i,j}(2) $, on obtient $m_{i,j}(n)=\sum_{k=1}^{|S|}m_{i,k}(1)\times m_{k,j}(1)$ qui n’est autre que $M^2$ (on reconnaĂźt en effet la formule du produit matriciel).
Et par une récurrence immédiate, pour des chemins de longueur $n$, il suffit de calculer $M^n$.
Exemple : combien y a-t-il de chemins de longueur 4 entre les sommets 1 et 3 du graphe représenté ci-dessous.
La matrice d’adjacence du graphe vaut $M = \begin{pmatrix}1&1&0\\0&0&1\\1&1&0\end{pmatrix}$.
Utilisons Python pour calculer $M^4$ :
import numpy as np # librairie trĂšs utile pour les calculs sur matrices
from numpy.linalg import matrix_power
M = [[1,1,0],[0,0,1],[1,1,0]]
M = np.array(M) # on convertit M en tableau numpy
M4 = matrix_power(M,4)
print(f"Il y a {M4[2][0]} chemins de longueur 4 du sommet 3 au sommet 1.")
Il y a 3 chemins de longueur 4 du sommet 3 au sommet 1.

| liste d’adjacence | matrice d’adjacence | |
|---|---|---|
| DĂ©terminer le degrĂ©s d’un sommet (graphe non orientĂ©) | O(1) | O(n) |
| Determiner liste des successeurs / degrés sortant | O(1) | O(n) |
| Determiner liste des prédécesseurs / degrés entrant | O(m) | O(n) |
| Déterminer si un sommet S est relié directement à un autre | O(|Succ(S)|) | O(1) |
 Â
Pour dĂ©terminer si un sommet est accessible depuis un autre sommet, il faut pouvoir parcourir mĂ©thodiquement l’ensemble du graphe.
Une premiĂšre mĂ©thode, l’algorithme de parcours en largeur (breadth-first BFS), consiste Ă partir d’un nĆud, d’explorer tous ses successeurs, puis les successeurs de chacun de ses successeurs, etc., jusqu’Ă ce qu’il n’y ait plus de sommets.
Cela revient Ă inspecter le graphe par couche concentrique de plus en plus Ă©loignĂ©es du nĆud source.

Pour implémenter un tel algorithme, la structure de données adaptée est la file.
Les files (queues en anglais) sont des structures dynamiques (les Ă©lĂ©ments sont enfilĂ©s ou dĂ©filĂ©s) oĂč, Ă l’instar d’une file d’attente Ă une caisse, c’est le premier arrivĂ© qui est le premier retirĂ© (FIFO pour “first in first out”). Les files sont utilisĂ©es par exemple lorsqu’il y a une possibilitĂ© d’encombrement (pour une imprimante partagĂ©e par exemple).

L’idĂ©e est de placer chaque nouveau successeur au bout d’une file (enfiler), puis de retirer un Ă un (dĂ©filer) les premiers arrivĂ©s (donc les plus proches du sommet de dĂ©part) lorsqu’ils sont Ă leur tour inspectĂ©s.
Pour l’implĂ©mentation des files, on pourrait utiliser des listes python en ajoutant toujours les Ă©lĂ©ments Ă la fin et les retirant au dĂ©but, mais ce n’est pas trĂšs efficace. En effet, l’ajout d’un Ă©lĂ©ment en dĂ©but de liste Ă un coĂ»t linĂ©aire (proportionnel Ă la taille de la liste). On aimerant pourtant tirer partie de la structure particuliĂšrement simple des files oĂč seule deux positions (premiĂšre et derniĂšre) nous intĂ©ressent…
Comme souvent en python, un module dédié, ici collecions.deque, va nous venir en aide. Il implément efficacement les files en permettant un enfilage et un défilage en temps constant.

G = {"Bob" : ["Alice","Dave","Charlie"],
"Alice" : ["Elisa"],
"Charlie" : ["Elisa","Hector"],
"Dave" : ["Farid","Gus"],
"Elisa" : [],
"Farid" : [],
"Gus" : [],
"Hector" : []
}
from collections import deque
# préconditions: on a besoin d'un graphe G(S,A) représenté par une liste d'adjacence implémentée par un dictionnaire et d'un sommet s de S
# postconditions : un sommet est accessible depuis s si et seulement si il est marqué comme "vu"
def parcours_largeur(G,depart):
file = deque()
file.append(depart)
Vus = {s : False for s in G}
Sommets = []
while file: # tant que la file n'est pas vide
sommet = file.popleft() # méthode de la classe deque permettant de défiler (équivaut à pop(0) sur une liste)
if not Vus[sommet]: # Ă©vite ici d'avoir 2 Elisa, mais ça peut ĂȘtre bien pire
file += G[sommet]
Vus[sommet] = True
Sommets.append(sommet)
return Sommets
parcours_largeur(G,"Bob") renvoie
['Bob', 'Alice', 'Dave', 'Charlie', 'Elisa', 'Farid', 'Gus', 'Hector'].
Si on ne marque pas les sommets vus (ici grĂące au dictionnaire Vus), on se retrouve avec une boucle infinie dĂšs qu’il y a un circuit ou un cycle (le simple graphe đŠâđ par exemple).
 Â
Exemples d’applications du parcours en largeur :
 Â
L’idĂ©e de l’algorithme de parcours en profondeur (depth-first search DFS) est d’explorer jusqu’au bout chaque chaĂźne de successeurs du nĆud source avant de passer Ă la suivante.
On n’explore alors plus par couches concentriques mais par branches.

La structure de données dynamique adaptée est cette fois-ci la pile.
Les piles (stacks en anglais) sont des structures dynamiques (des Ă©lĂ©ments sont ajoutĂ©s = empilĂ©s, ou retirĂ©s = dĂ©pilĂ©s) ayant la propriĂ©tĂ© que lâĂ©lĂ©ment extrait est celui qui y a Ă©tĂ© introduit le plus rĂ©cemment (“dernier entrĂ©, premier sortie” ou LIFO “last in first out” en anglais). C’est l’Ă©quivalent informatique d’une pile d’assiettes. Cette structure est par exemple utilisĂ©e dans la fonction “annuler” (CTR-Z) d’un logiciel ou encore dans le traitement des fonctions rĂ©cursives.

def parcours_profondeur(G,depart):
pile = deque()
pile.append(depart)
Vus = {s : False for s in G}
Sommets = []
while pile:
sommet = pile.pop()
if not Vus[sommet]:
pile += G[sommet]
Vus[sommet] = True
Sommets.append(sommet)
return Sommets
parcours_profondeur(G,"Bob") renvoie
['Bob', 'Charlie', 'Hector', 'Elisa', 'Dave', 'Gus', 'Farid', 'Alice'].
Â
Exemples d’applications du parcours en profondeur :
La complexitĂ© des deux algorithmes est en $O(n+m)$ oĂč $n =|S|$ et $m = |A|$.
En effet, la pile ou la file voit passer tous les successeurs ou adjoints de chaque sommet, ce qui correspond aux m arcs pour les successeurs et 2m pour les arĂȘtes qui sont parcourues dans les deux sens. Comme chaque ajout et enlĂšvement de sommet se fait en $O(1)$, l’enfilement et dĂ©filement complet ainsi que l’empilement et dĂ©pilement complet sont en $O(m)$.
En plus de ces opérations, on modifie Vus et on construit Sommets, ce qui représente $O(n)$ nouvelles opérations.
if sommet not in Sommets plutĂŽt que if not Vus[sommet]) ?deque fournissant une vraie file/pile ?Â
On remarque que les deux algorithmes de parcours d’un graphe (BFS et DFS) ne diffĂšrent que par la structure de donnĂ©es utilisĂ©e. Et loin d’ĂȘtre anodin, ce passage de la file Ă la pile change complĂštement le principe du parcours !
Cela illustre bien que la conception d’un algorithme est intimement liĂ©e aux structures de donnĂ©es envisagĂ©es.
Une structure de donnĂ©e est une façon d’organiser les donnĂ©es de telle sorte que certaines opĂ©rations sur ces donnĂ©es (les primitives) soient trĂšs rapides. Une structure de donnĂ©es est donc spĂ©cialisĂ©e dans ces quelques opĂ©rations.
Lors de la conception d’un algorithme, l’identification des diffĂ©rentes opĂ©rations Ă effectuer va guider le choix de la structure de donnĂ©es adaptĂ©e.
Par exemple, l’algorithme de recherche en largeur doit gĂ©rer un ensemble oĂč le premier Ă©lĂ©ment ajoutĂ© doit toujours ĂȘtre le premier retirĂ© (logique FIFO) $\rightarrow$ utilisation d’une file qui gĂšre l’ajout d’un Ă©lĂ©ment Ă la fin d’une file d’attente et l’extraction au dĂ©but en temps constant (elle est optimisĂ©e pour ça, mais en contrepartie, elle ne sait faire que ça).
L’algorithme de recherche en profondeur suit lui la logique LIFO $\rightarrow$ utilisation d’une pile.
Dernier exemple : l’algorithme de Dijkstra (dĂ©crit plus loin) a besoin Ă chaque itĂ©ration d’ajouter un Ă©lĂ©ment Ă un ensemble et d’en retirer le plus petit Ă©lĂ©ment $\rightarrow$ la file de prioritĂ© est la structure spĂ©cialisĂ©e dans ses opĂ©rations (dans quels autres algorithmes le tas pourrait-il ĂȘtre utilisĂ© avantageusement ?).
Nous n’avions pas rĂ©ellement besoin de la classe deque pour implĂ©menter efficacement la pile. En effet, si insĂ©rer un Ă©lĂ©ment au dĂ©but d’une liste python de taille $n$ a bien un coĂ»t linĂ©aire ($O(n)$) et ralentit donc l’exĂ©cution par rapport Ă l’utilisation d’une file, retirer un Ă©lĂ©ment Ă la fin (via pop) se fait en temps constant ($O(1)$).
En pratique, on a utilisĂ© dans les deux codes exactement le mĂȘme objet, prĂ©sent dans le module standard collections : deque().deque() (qui se prononce comme deck) est l’implĂ©mentation d’une file d’attente Ă double extrĂ©mitĂ© (double-ended queue). Elle permet d’ajouter et retirer efficacement (en temps constant) des Ă©lĂ©ments aux deux extrĂ©mitĂ©s. On peut donc bien Ă la fois s’en servir comme une file ou comme une pile.
On peut aussi utiliser deque() comme une liste python normale, mais l’accĂšs d’un Ă©lĂ©ment via son indice est alors inefficace (il ne se fait pas en temps constant). C’est la contrepartie de l’optimisation des opĂ©rations sur les extrĂ©mitĂ©s…
Pour optimiser l’ajout et l’enlĂšvement aux extrĂ©mitĂ©s, l’objet deque() utilise une liste doublement chaĂźnĂ©e (chaque Ă©lĂ©ment est stockĂ© en mĂ©moire avec deux pointeurs : un vers l’Ă©lĂ©ment prĂ©cĂ©dent et un vers le suivant).
La derniĂšre note permet de pointer la distinction
entre une structure de données abstraite (ou plutÎt type de données abstrait TDA) et son implémentation :
Une liste doublement chaßnée peut implémenter à la fois le TDA file ou pile.
Un tas (arbre binaire presque complet ordonnĂ©) permet d’implĂ©menter le TDA file de prioritĂ©.
 Â
 Â
Dans de nombreuses situations, les arĂȘtes d’un graphe ne sont pas toutes Ă©quivalentes. On ajoute alors l’information du “coĂ»t” que cela reprĂ©sente d’emprunter telle ou telle arĂȘte. On appelle poids ces valeurs ajoutĂ©es aux arĂȘtes/arcs.
Par exemple, pour modĂ©liser un rĂ©seau ferroviaire, on peut attribuer Ă chaque arĂȘte modĂ©lisant les jonctions entre deux gares la distance correspondante. Et pour un rĂ©seau de communication, le poids d’une arĂȘte correspondra plutĂŽt au temps nĂ©cessaire pour transfĂ©rer un message de taille Ă©lĂ©mentaire.
On obtient alors un graphe pondéré.
On utilise par exemple des graphes pondĂ©rĂ©s, plus particuliĂšrement des arbres de probabilitĂ© (oĂč chaque branche est affublĂ©e d’une probabilitĂ©) pour calculer des probabilitĂ©s conditionnelles.
Exemple :

TrĂšs souvent (particuliĂšrement pour les rĂ©seaux de communication), l’information ajoutĂ©e au graphe est un temps ou une distance et se pose alors le problĂšme de l’optimisation d’un trajet entre deux sommets.
Le poids d’un chemin est la somme des poids des arcs empruntĂ©s.
La distance entre deux sommets (dans un graphe pondéré) correspond au chemin de poids minimum entre ces deux sommets.
 Â
Pas au programme de TSI mais plus prudent d’en avoir entendu parler pour l’Ă©preuve de Centrale qui jusqu’Ă maintenant Ă©tait commune aux autres sections.
Si le graphe considĂ©rĂ© n’est pas pondĂ©rĂ©, l’algorithme de parcours en largeur, moyennant quelques adaptations, est tout Ă fait capable de faire le travail.
Comme l’algorithme de parcours en largeur examine le graphe en couches concentriques depuis le sommet de dĂ©part, lorsqu’il parvient au sommet cible, on est sĂ»r que le nombre d’arcs est minimal.
Il suffit alors de joindre à la liste des sommets examinés, la liste des chemins permettant de parvenir à chacun de ces sommets (en incrémentant à chaque tour chacun des chemins du sommet correspondant de la nouvelle couche).
def recherche_largeur(G,depart,arrivee):
file = [(depart,[depart])] # on remplace la file des sommets par une file de tuples (sommet,chemin)
Vus = {s : False for s in G}
while file:
sommet,chemin = file.pop(0) # pop(0) fait la mĂȘme chose que le popleft des deque
if sommet == arrivee:
return chemin # si l'arrivée est atteinte, on retourne le chemin correspondant
if not Vus[sommet]:
for s in G[sommet]:
nv_chemin = chemin + [s]
file.append((s,nv_chemin))
Vus[sommet] = True
return False # si l'arrivée n'est pas atteinte, on renvoie Faux
G = {"Minimes" : {"Tasdon","HĂŽpital"},
"HĂŽpital" : {"Verdun"},
"Verdun" : {"Stade"},
"Tasdon" : {"Cognehors", "Lafond"},
"Cognehors" : {"Verdun"},
"Lafond" : {"Mireuil"},
"Mireuil" : {"Stade"},
"Stade" : {}
}

recherche_largeur(G,"Minimes","Stade") retourne bien le chemin comportant le moins d’arĂȘtes : ['Minimes', 'HĂŽpital', 'Verdun', 'Stade'].
Mais si on ajoute des poids, l’algorithme de recherche en largeur devient inefficace puisqu’il se borne Ă donner la mĂȘme rĂ©ponse (les pondĂ©rations sont nulle part prises en compte !).
G_pond = {"Minimes" : {"Tasdon": 5,"HĂŽpital": 4},
"HĂŽpital" : {"Verdun": 21},
"Verdun" : {"Stade": 4},
"Tasdon" : {"Cognehors": 7, "Lafond": 7},
"Cognehors" : {"Verdun": 8},
"Lafond" : {"Mireuil": 5},
"Mireuil" : {"Stade": 3},
"Stade" : {}
}

recherche_largeur(G_pond,"Minimes","Stade") retourne Ă nouveau
['Minimes', 'HĂŽpital', 'Verdun', 'Stade'] alors qu’il y a maintenant des chemins plus rapides !
 Â
L’algorithme de Dijkstra va permettre de dĂ©passer les limites du parcours en largeur grĂące Ă un score attribuĂ© Ă chaque sommet (au dĂ©part, tous les scores sont fixĂ©s Ă l’infini sauf celui du sommet de dĂ©part, fixĂ© Ă zĂ©ro). En sortie de l’algorithme, chaque score vaudra la distance entre le sommet et le sommet de dĂ©part.
Comme avec les parcours en largeur et en profondeur, la distinction principale rĂ©side dans la sĂ©lection du prochain sommet ajoutĂ© Ă Vus ; il s’agit maintenant de celui ayant le plus petit score parmi les sommets non dĂ©jĂ inspectĂ©s.
On appelle algorithme glouton un algorithme dont la logique consiste à choisir à chaque itération un objet de valeur maximale ou minimale.
Dijkstra est donc un exemple d’algorithme glouton.
Ă chaque sommet ajoutĂ© $s$, on met Ă jour les scores de chaque successeur $s_+$ en gardant la valeur minimale entre le score qu’il avait prĂ©cĂ©demment et la somme entre le score de $s$ et le poids de l’arc $(s,s_+)$ (c’est l’Ă©tape de relaxation des arĂȘtes) : $\text{score}(s_+)=\min\left(\text{score}(s_+),\text{score}(s)+p((s,s_+))\right)$
Et en gardant à chaque mise-à -jour du score la trace du nouveau prédécesseur, on obtient un algorithme capable de fournir les distances et les plus courts chemins entre le sommet de départ et tous les autres.
IdĂ©e clĂ© de l’algo : si un chemin entre deux sommets est le plus court alors tout chemin intermĂ©diaire entre des sommets prĂ©sents sur le chemin principal est aussi le plus court entre ces sommets. Cela permet de construire le chemin le plus court de proche en proche.
Ce n’est plus vrai s’il y a une arĂȘte nĂ©gative : un plus court chemin n’a alors plus Ă ĂȘtre composĂ© des tronçons les plus courts entre les sommets intermĂ©diaires.
MoralitĂ©, la correction partielle de l’algorithme de Dijkstra suppose que tous les poids des arĂȘtes du graphe sont positifs.
# prĂ©conditions : un graphe orientĂ© ïżŒpondĂ©rĂ© G(S,A) avec des poids positifs pour chaque arc reprĂ©sentĂ© par une liste dâadjacence grĂące Ă un dictionnaire, un sommet s_0 de S
# postcondition : pour chaque sommet ïżŒs_i de S le score trouvĂ© correspond bien Ă la distance entre s_0 et s_i (d(s_0,s_i))
def sommet_suivant(scores,nonvus):
"""
retourne le sommet de nonvus au plus bas score
"""
plus_bas_score = float("inf")
sommet_choisi = None
for sommet in nonvus:
score = scores[sommet]
if score < plus_bas_score:
plus_bas_score = score
sommet_choisi = sommet
return sommet_choisi
def Dijkstra(G,depart):
# on construit Scores et Preds dans lesquels on mettra à jour les scores calculés et les prédecesseurs des sommets examinés
Scores = {}
Preds = {}
# initialisation
for s in G:
Scores[s] = float("inf")
Preds[s] = None
Scores[depart] = 0
NonVus = list(G.keys()) # liste pour stocker les sommets examinés
sommet = depart
while sommet is not None:
score = Scores[sommet]
Succ = G[sommet]
for n in Succ:
nv_score = score + Succ[n]
if Scores[n] > nv_score:
Scores[n] = nv_score
Preds[n] = sommet
NonVus.remove(sommet)
sommet = sommet_suivant(Scores,NonVus)
return Preds,Scores
preds,scores = Dijkstra(G_pond,"Minimes")
print(preds)
print(scores)
# Ce qui s'affiche :
{'Minimes': None, 'HĂŽpital': 'Minimes', 'Verdun': 'Cognehors', 'Tasdon': 'Minimes', 'Cognehors': 'Tasdon', 'Lafond': 'Tasdon', 'Mireuil': 'Lafond', 'Stade': 'Mireuil'}
{'Minimes': 0, 'HĂŽpital': 4, 'Verdun': 20, 'Tasdon': 5, 'Cognehors': 12, 'Lafond': 12, 'Mireuil': 17, 'Stade': 20}
La complexitĂ© de cette implĂ©mentation de l’algorithme de Dijkstra est en $O(n^2)$ oĂč $n=|S|$ est le nombre de sommets du graphe.
En effet, deux actions sont réalisées dans la boucle principale qui parcourt chaque sommet :
sommet_suivant qui est en $O(n)$.On obtient :
Â
Comme évoqué plus haut, on peut améliorer la complexité en utilisant une structure de données adaptée au problÚme : la file de priorité implémentée par un tas.
L’idĂ©e est d’optimiser le choix du prochain sommet inspectĂ©, celui au plus bas score parmi les sommets pas encore validĂ©s. En effet, rĂ©inspecter systĂ©matiquement toute la liste des sommets restants (c’est ce que fait la fonction sommet_suivant) gaspille de l’information. C’est sur ce point que le tas vient Ă la rescousse :
le tas est une structure de donnĂ©es de type arbre qui permet de retrouver directement l’Ă©lĂ©ment que l’on veut traiter en prioritĂ©. Le tas garde en permanence le sommet de plus bas score en son sommet avec un coĂ»t logarithmique.
ConsĂ©quence : si on remplace la fonction sommet_suivant par un tas et ses opĂ©rations dĂ©diĂ©es d’ajout et d’extraction, on passe d’une complexitĂ© linĂ©aire Ă une complexitĂ© logarithmique pour cette opĂ©ration.
import heapq # module implémentant un tas (heap en anglais)
def Dijkstra_tas(G, depart):
Scores = {sommet: float('infinity') for sommet in G}
Preds = {sommet: None for sommet in G}
Scores[depart] = 0
tas = [(0, depart)]
# liste de tuples contenant le score et le sommet associé
# le score correspond alors à la priorité du tas
while tas:
score_actuel, sommet_actuel = heapq.heappop(tas)
# on retire le sommet prioritaire du tas
if score_actuel == Scores[sommet_actuel]:
# permet de vérifier que le sommet correspond bien à la derniÚre mise-à -jour
score_voisins = G[sommet_actuel]
for voisin in score_voisins:
score = score_actuel + score_voisins[voisin]
if score < Scores[voisin]:
Scores[voisin] = score
Preds[voisin] = sommet_actuel
heapq.heappush(tas, (score, voisin))
# on ajoute le score et le sommet au tas
# le mĂȘme sommet peut ĂȘtre ajoutĂ© plusieurs fois avec des scores diffĂ©rents
return Preds,Scores
GrĂące au tas, la complexitĂ© de Dijkstra est maintenant en $O(m\log n)$ oĂč $n =|S|$ et $m = |A|$.
En effet, le tas contient $O(m)$ éléments (les voisins mis-à -jour), donc les opérations heappop et heappush sont en $O(\log m)=O(\log n)$.
Comme un graphe simple contient au plus $\binom{n}{2}=\frac{n(n-1)}{2}$ arĂȘtes et le double pour les arcs, on en dĂ©duit que $O(m)=O(n^2)$.
Et donc $O(\log m)=O(2\log n)=O(\log n)$.
On entre au pire $m$ fois dans la boucle while, mais la boucle for n’est exĂ©cutĂ©e que $n$ fois car la condition score_actuel <= Scores[sommet_actuel] nous assure de ne traiter qu’une seule fois chaque sommet.
Cette implĂ©mentation n’est rĂ©ellement avantageuse que si le graphe est creux (pour ne pas avoir $|A|$ de l’ordre de $|S|^2$). Mais mĂȘme si le tas contient au pire de l’ordre de $|A|$ Ă©lĂ©ments, c’est en pratique quasiment jamais le cas puisqu’il faudrait qu’Ă chaque arĂȘte parcourue lors de l’inspection des voisins, il y ait mise Ă jour du score d’un sommet (pour provoquer son ajout au tas).

On verra dans le TP comment encore amĂ©liorer les choses grĂące Ă l’utilisation d’une heuristique. On passe alors de l’algorithme de Dijkstra Ă l’algorithme A* (A star ou A Ă©toile).
Dans une lettre de 1856, Hamilton Ă©crit : “I have found that some young persons have been much amused by trying a new mathematical game which the Icosion furnishes, one person sticking five pins in any consectutive points […] and the other player then aiming to insert, which by the theory in this letter can always be done, fifteen other pins, in cyclical succession, so as to cover all the other points, and to end in immediate proximity to the pin wherewith his antagonist had begun.” ↩︎