Les graphes

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.

Un peu d’histoire

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.

   

Vocabulaire

Graphes non orientés

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Ă©seauSommetsArĂȘtes
transport aérienaéroportsvols
plans routierscarrefourstronçons de routes
réseau génétiquegÚnesfacteurs de transcription
cerveauneuronessynapses
colonie de fourmisjonctionstraces de phéromones
appels téléphoniquesnuméroappel
réseau de citationauteurcitation

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.

Le graphe ci-dessus n’est donc pas simple.

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$

   

Graphes orientés

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 :

  • Supposons que l’on veuille modĂ©liser un plan routier. On associe naturellement un carrefour Ă  un sommet et une rue Ă  une arĂȘte. Mais on a besoin en plus d’une notion de direction pour reprĂ©senter les rues Ă  sens unique.
  • Pour modĂ©liser des relations sociales, une arĂȘte entre Alice et Bob modĂ©lise un lien, mais comment reprĂ©senter le fait que Bob connaisse Alice, mais que l’inverse soit faux ?
  • Dans un rĂ©seau d’ordinateur, en particulier sans fil, le lien entre deux nƓuds est gĂ©nĂ©ralement non symĂ©trique dans le sens oĂč un message peut ĂȘtre envoyĂ© de A vers B, mais pas l’inverse.

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 :

$$A= \{{(ciseaux,feuille),(feuille,puits),(feuille,pierre),(puits,pierre),(puits,ciseaux),(pierre,ciseaux)\}}$$

   

DegrĂ© d’un sommet

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|$$

$d(puits)=d(feuille)=d(ciseaux)=d(pierre)=3$ d’oĂč $\sum_{s \in S} d(s) = 12$. Et on a bien $|A|=6$.

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_-$
puits21
feuille21
ciseaux12
pierre12

$\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.

   

Chemin, chaĂźne, cycle et circuit

Cas des graphes orienté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$.

  • $<6,3,2,1>$ est un chemin du graphe.
  • $<1,5,3,2,1>$ est un circuit.

Cas des graphes non orientés

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.

   

Distance dans un graphe

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).

   

Connexité

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 !

   

ReprĂ©sentation d’un graphe

Listes d’adjacence

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.

   

Matrice d’adjacence

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.

Quelques comparaisons

liste d’adjacencematrice d’adjacence
DĂ©terminer le degrĂ©s d’un sommet (graphe non orientĂ©)O(1)O(n)
Determiner liste des successeurs / degrés sortantO(1)O(n)
Determiner liste des prédécesseurs / degrés entrantO(m)O(n)
Déterminer si un sommet S est relié directement à un autreO(|Succ(S)|)O(1)

   

Parcours d’un graphe

Pour dĂ©terminer si un sommet est accessible depuis un autre sommet, il faut pouvoir parcourir mĂ©thodiquement l’ensemble du graphe.

Algorithme de parcours en largeur (BFS)

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 :

  • utilisĂ© par les robots d’exploration des moteurs de recherche pour construire l’index des pages web,
  • recherche dans les rĂ©seaux sociaux,
  • recherche d’un nƓud voisin accessible dans les rĂ©seaux peer-to-peer.

   

Algorithme de parcours en profondeur (DFS)

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 :

  • trouver un chemin entre deux sommets,
  • dĂ©tection de cycles dans un graphe,
  • utilisĂ© dans le tri topologique,
  • trouver la sortie d’un labyrinthe.

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.

  • Que deviendrait la complexitĂ© si on remplaçait l’utilisation d’un dictionnaire par celle d’une liste pour vĂ©rifier qu’un sommet a dĂ©jĂ  Ă©tĂ© traĂźtĂ© (on utiliserait alors if sommet not in Sommets plutĂŽt que if not Vus[sommet]) ?
  • Que deviendrait la complexitĂ© sans l’utilisation de la classe deque fournissant une vraie file/pile ?

 

Structures de données

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Ă©.

   

   

Graphes pondérés

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.

   

ProblĂšme du plus court chemin

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.

Algorithme de parcours en largeur

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 !

   

Algorithme de Dijkstra

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 :

  • La recherche du sommet non validĂ© ayant un score minimal et sa validation. Elle se fait via la fonction sommet_suivant qui est en $O(n)$.
  • La mise Ă  jour des scores des sommets. Elle se fait en temps constant mais concerne tous les successeurs du sommet Ă©tudiĂ©.

On obtient :

$$\sum_{u\in S}\left(T(\text{obtenir le sommet min et le retirer})+\left(\sum_{v\in Succ(u)}T(\text{mettre Ă  jour le score})\right)\right) = O(n^2+m) = O(n^2)$$

 

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.

$$\sum_{O(m)}T(\text{retirer la racine du tas})+\sum_{u \in S}\left(\sum_{v\in Succ(u)}\left(T(\text{mettre Ă  jour le score})+T(\text{placer dans le tas})\right)\right)= O(m\log n+m\log n) = O(m \log n)$$

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).


  1. 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.” ↩︎