Graphes et parcours
Tu veux aller de Perrache à Part-Dieu en changeant le moins possible de ligne. Ton application de transports résout ce problème en une fraction de seconde. Comment un ordinateur « voit »-il un réseau de métro ? Et comment trouve-t-il le meilleur trajet ? La réponse tient en un mot : graphe.
Vocabulaire et représentations
Un graphe sert à modéliser des objets (les sommets) et des liens entre eux : stations et lignes de métro, routeurs et câbles, élèves et amitiés, modules d'un programme et dépendances.
Voici un graphe non orienté à 5 sommets.
Quelques mots à connaître :
-
deux sommets reliés par une arête sont voisins (ou adjacents) ;
-
le degré d'un sommet est le nombre d'arêtes qui y aboutissent : ici, le sommet 1 a pour degré 3 ;
-
un chemin est une suite de sommets où chacun est relié au suivant ; sa longueur est le nombre d'arêtes (ou d'arcs) parcourus : $0 - 1 - 3 - 4$ est un chemin de longueur 3 ;
-
un cycle est un chemin qui revient à son point de départ (sans réutiliser une même arête, ou un même arc) ;
-
un graphe non orienté est connexe si l'on peut aller de n'importe quel sommet à n'importe quel autre.
Comment représenter un graphe en machine ?
Il existe deux représentations classiques.
La matrice d'adjacence. Pour $n$ sommets numérotés de $0$ à $n-1$, c'est un tableau $n \times n$ où M[i][j] = 1 s'il existe une arête (ou un arc) de $i$ vers $j$, et 0 sinon.
Les listes d'adjacence (ou listes de successeurs). Pour chaque sommet, on mémorise la liste de ses voisins (ses successeurs dans un graphe orienté). En Python, on utilise un dictionnaire : la clef est le sommet, la valeur est la liste des voisins.
Pour le graphe ci-dessus :
M = [[0, 1, 0, 0, 0],
[1, 0, 1, 1, 0],
[0, 1, 0, 0, 0],
[0, 1, 0, 0, 1],
[0, 0, 0, 1, 0]]
g = {0: [1], 1: [0, 2, 3], 2: [1], 3: [1, 4], 4: [3]}
Un graphe non orienté a pour sommets A, B, C, D et pour arêtes : A–B, A–C, B–C, C–D. a) Donner la matrice d'adjacence (sommets dans l'ordre A, B, C, D). b) Donner la…
Un graphe non orienté a pour sommets A, B, C, D et pour arêtes : A–B, A–C, B–C, C–D.
- a) Donner la matrice d'adjacence (sommets dans l'ordre A, B, C, D).
- b) Donner la liste d'adjacence de chaque sommet.
- c) Donner le degré de chaque sommet.
Un graphe orienté a pour sommets 0, 1, 2, 3 et pour arcs : 0→1, 0→2, 1→3, 2→3, 3→0. a) Donner la liste des successeurs de chaque sommet. b) Donner la liste des…
Un graphe orienté a pour sommets 0, 1, 2, 3 et pour arcs : 0→1, 0→2, 1→3, 2→3, 3→0.
- a) Donner la liste des successeurs de chaque sommet.
- b) Donner la liste des prédécesseurs de chaque sommet.
- c) Écrire la matrice d'adjacence et expliquer pourquoi elle n'est pas symétrique.
Soit le graphe orienté défini par le dictionnaire Python g = {0: [1, 2], 1: [2], 2: [0, 3], 3: []}. a) Dessiner (décrire) ce graphe. b) Indiquer si les chemins 0→1→2→3…
Soit le graphe orienté défini par le dictionnaire Python g = {0: [1, 2], 1: [2], 2: [0, 3], 3: []}.
- a) Dessiner (décrire) ce graphe.
- b) Indiquer si les chemins 0→1→2→3 et 3→2→0 existent.
- c) Citer un cycle de ce graphe.
Parcours en largeur
Parcourir un graphe, c'est visiter ses sommets un par un, chacun une seule fois, en suivant les liaisons à partir d'un sommet de départ. Le parcours en largeur (BFS, breadth-first search) visite d'abord tous les voisins du départ, puis les voisins des voisins, etc. : on avance « par couches », comme une onde qui s'étend.
Pour savoir quel sommet traiter ensuite, on utilise une file (structure FIFO : premier entré, premier sorti). On y met les sommets découverts, et on les traite dans l'ordre de leur découverte.
from collections import deque
def parcours_largeur(g, s):
vus = [s]
file = deque([s])
while file:
u = file.popleft()
for v in g[u]:
if v not in vus:
vus.append(v)
file.append(v)
return vus
On utilise deque car popleft() est une opération rapide ; liste.pop(0) oblige à décaler tous les éléments.
Distances et plus courts chemins
Comme on visite les sommets par couches, le parcours en largeur découvre chaque sommet par un chemin comptant le moins d'arêtes possible. Il suffit de mémoriser la distance de chaque sommet au départ : celle de $v$ est celle de $u$ plus 1, où $u$ est le sommet qui a permis de découvrir $v$.
def distances(g, s):
dist = {s: 0}
file = deque([s])
while file:
u = file.popleft()
for v in g[u]:
if v not in dist:
dist[v] = dist[u] + 1
file.append(v)
return dist
Sur l'exemple : {S: 0, A: 1, B: 1, C: 2, D: 3}. Pour retrouver le chemin lui-même, on mémorise aussi le sommet « parent » de chaque sommet découvert.
Un réseau de métro simplifié à Lyon relie les stations Bellecour (Be), Perrache (Pe), Cordeliers (Co), Hôtel de Ville (Ho) et Part-Dieu (Pa) : Be–Pe, Be–Co, Co–Ho,…
Un réseau de métro simplifié à Lyon relie les stations Bellecour (Be), Perrache (Pe), Cordeliers (Co), Hôtel de Ville (Ho) et Part-Dieu (Pa) : Be–Pe, Be–Co, Co–Ho, Be–Ho, Ho–Pa.
- a) Représenter ce réseau par une matrice d'adjacence.
- b) Donner le nombre minimal de liaisons (arêtes) à parcourir pour aller de Pe à Pa et un chemin correspondant.
- c) Écrire une fonction Python
degre(matrice, i)qui renvoie le degré du sommeti.
Écrire en Python une fonction parcours_largeur(g, s) qui prend un graphe g sous forme de dictionnaire de listes d'adjacence et un sommet s, et renvoie la liste des…
Écrire en Python une fonction parcours_largeur(g, s) qui prend un graphe g sous forme de dictionnaire de listes d'adjacence et un sommet s, et renvoie la liste des sommets visités dans l'ordre d'un parcours en largeur.
- a) Préciser la structure de données utilisée et justifier ce choix.
- b) Écrire la fonction.
- c) Montrer que chaque sommet est visité au plus une fois et donner le coût en fonction du nombre de sommets $n$ et d'arêtes $m$.
- d) Expliquer comment adapter la fonction pour obtenir la distance minimale (en nombre d'arêtes) entre
set chaque sommet.
Parcours en profondeur et cycles
Le parcours en profondeur (DFS, depth-first search) fait l'inverse : on s'enfonce le plus loin possible dans une direction, et on ne revient en arrière que lorsqu'on est dans une impasse. C'est le principe du labyrinthe avec un fil d'Ariane.
Il utilise une pile (LIFO), le plus souvent de façon implicite grâce à la récursivité.
def parcours_profondeur(g, u, vus=None):
if vus is None:
vus = []
vus.append(u)
for v in g[u]:
if v not in vus:
parcours_profondeur(g, v, vus)
return vus
Détecter un cycle dans un graphe non orienté
Si pendant le parcours en profondeur on rencontre un sommet déjà visité qui n'est pas le parent du sommet courant, c'est qu'on a trouvé un second chemin vers ce sommet : il y a un cycle. On exclut le parent, car l'arête qu'on vient d'emprunter mène forcément vers lui.
def a_un_cycle(g, u, parent, vus):
vus.add(u)
for v in g[u]:
if v not in vus:
if a_un_cycle(g, v, u, vus):
return True
elif v != parent:
return True
return False
On l'appelle avec a_un_cycle(g, s, None, set()) pour la composante connexe de s. Pour un graphe non connexe, on relance depuis chaque sommet non visité.
Graphes orientés : dépendances et interblocage
Dans un graphe orienté, un arc $X \to Y$ peut signifier « $X$ doit être fait avant $Y$ » (chargement de modules, étapes d'une recette, prérequis de cours). Un ordre valide place chaque sommet avant tous ses successeurs. Un tel ordre existe si et seulement si le graphe ne contient aucun cycle orienté.
Cette situation ressemble à un interblocage (deadlock) entre processus : le processus P1 attend une ressource détenue par P2, P2 attend P3, et P3 attend P1. Aucun ne peut avancer. Un graphe d'attente (arc $P_i \to P_j$ : « $P_i$ attend $P_j$ ») avec un cycle orienté signale un interblocage. Détecter le problème revient donc à chercher un cycle par un parcours en profondeur : dans un graphe orienté, on a un cycle si l'on retombe sur un sommet qui est encore « en cours de visite », c'est-à-dire sur le chemin courant.
Soit le graphe non orienté défini par g = {"A": ["B", "C"], "B": ["A", "D"], "C": ["A", "D", "E"], "D": ["B", "C"], "E": ["C"]}. a) Donner le parcours en largeur à…
Soit le graphe non orienté défini par g = {"A": ["B", "C"], "B": ["A", "D"], "C": ["A", "D", "E"], "D": ["B", "C"], "E": ["C"]}.
- a) Donner le parcours en largeur à partir de A (en visitant les voisins dans l'ordre alphabétique).
- b) Donner le parcours en profondeur à partir de A (même convention).
- c) Donner un chemin de A à E de longueur minimale.
On souhaite détecter un cycle dans un graphe non orienté à l'aide d'un parcours en profondeur. a) Expliquer le principe : que signifie rencontrer un sommet déjà visité…
On souhaite détecter un cycle dans un graphe non orienté à l'aide d'un parcours en profondeur.
- a) Expliquer le principe : que signifie rencontrer un sommet déjà visité qui n'est pas le parent du sommet courant ?
- b) Appliquer ce principe au graphe d'arêtes 1–2, 2–3, 3–4, 4–2, 4–5.
- c) Le graphe d'arêtes 1–2, 2–3, 2–4, 4–5 possède-t-il un cycle ? Justifier.
Un graphe orienté représente des dépendances entre modules d'un programme : un arc $X \to Y$ signifie que $X$ doit être chargé avant $Y$. Les arcs sont : M1→M2, M1→M3,…
Un graphe orienté représente des dépendances entre modules d'un programme : un arc $X \to Y$ signifie que $X$ doit être chargé avant $Y$. Les arcs sont : M1→M2, M1→M3, M2→M4, M3→M4, M4→M5.
- a) Donner un ordre de chargement valide.
- b) On ajoute l'arc M5→M2. Montrer qu'il n'existe plus d'ordre valide.
- c) Expliquer en quoi cette situation est analogue à un interblocage entre processus, et comment détecter ce problème par un parcours de graphe.
Graphes pondérés, routage et algorithme de Dijkstra
Dans un réseau, toutes les liaisons ne se valent pas : une fibre rapide n'a pas le même « prix » qu'une vieille liaison lente. On associe alors à chaque arête un nombre, son poids (ou coût) : on obtient un graphe pondéré. Le coût d'un chemin est la somme des poids de ses arêtes.
Les protocoles de routage permettent aux routeurs de choisir la route d'un paquet :
-
RIP compte le nombre de sauts (c'est un parcours en largeur : tous les poids valent 1) ;
-
OSPF minimise le coût total des liaisons.
Le parcours en largeur ne suffit plus pour OSPF : le chemin avec le moins d'arêtes n'est pas forcément le moins coûteux. On utilise l'algorithme de Dijkstra (poids positifs).
Le chemin $A - B - C - D$ compte 3 sauts. Les chemins $A - B - D$ (coût 8) et $A - C - D$ (coût 7) n'en comptent que 2 : RIP en choisirait un, alors qu'OSPF préfère la route de coût 5.
Dans un réseau, les routeurs R1 à R5 sont reliés ainsi, avec le coût de chaque liaison (OSPF) : R1–R2 : 1 ; R1–R3 : 4 ; R2–R3 : 2 ; R2–R4 : 7 ; R3–R5 : 3 ; R4–R5 : 1. a)…
Dans un réseau, les routeurs R1 à R5 sont reliés ainsi, avec le coût de chaque liaison (OSPF) : R1–R2 : 1 ; R1–R3 : 4 ; R2–R3 : 2 ; R2–R4 : 7 ; R3–R5 : 3 ; R4–R5 : 1.
- a) Déterminer le chemin de coût minimal de R1 à R4 en détaillant les étapes de l'algorithme de Dijkstra.
- b) Expliquer en quoi RIP (qui compte le nombre de sauts) choisirait éventuellement un autre chemin ; donner ce chemin.
- c) Expliquer le lien entre protocole de routage et recherche d'un chemin dans un graphe pondéré.
Reviens au début : l'application de transports modélise le réseau par un graphe (stations = sommets, tronçons = arêtes). Elle cherche le trajet avec le moins de lignes par un parcours en largeur, et le trajet le plus rapide par Dijkstra, en pondérant chaque tronçon par sa durée.
L'essentiel
-
Un graphe est formé de sommets reliés par des arêtes (non orienté) ou des arcs (orienté). On parle de degré, de chemin, de longueur et de cycle.
-
On le représente par une matrice d'adjacence (tableau $n \times n$, symétrique si le graphe est non orienté) ou par des listes d'adjacence, par exemple un dictionnaire Python.
-
Le parcours en largeur utilise une file : il explore par couches et donne les distances minimales en nombre d'arêtes.
-
Le parcours en profondeur utilise une pile (ou la récursivité) : il sert à détecter un cycle (sommet déjà visité qui n'est pas le parent) et à vérifier qu'un ordre de dépendances existe.
-
Un graphe pondéré se traite avec l'algorithme de Dijkstra : RIP compte les sauts, OSPF minimise le coût.