CPGE 2ᔉ annĂ©e — MP / PSI · Informatique

Introduction à la théorie des graphes

Pr. EL HADIQ Zouhair

Un graphe modĂ©lise un ensemble d'objets (les sommets) reliĂ©s par des relations (les arĂȘtes ou arcs) : rĂ©seaux routiers, rĂ©seaux sociaux, pages web, circuits
 Ce chapitre dĂ©finit le vocabulaire, montre comment reprĂ©senter un graphe en Python, puis prĂ©sente les algorithmes de parcours et de plus court chemin.

🎯 Objectifs du chapitre
Sommaire
  1. Qu'est-ce qu'un graphe ?
  2. Vocabulaire des graphes
  3. Degré d'un sommet
  4. Chemins, chaĂźnes, cycles et circuits
  5. Connexité
  6. Représentation : matrice d'adjacence
  7. Représentation : listes d'adjacence
  8. Manipulation d'un graphe en Python
  9. Parcours en largeur (BFS)
  10. Parcours en profondeur (DFS)
  11. Applications des parcours : cycle et connexité
  12. Graphes pondérés et plus court chemin (Dijkstra)
  13. Pour aller plus loin : A* et Floyd-Warshall
  14. Récapitulatif

1. Qu'est-ce qu'un graphe ?

Un graphe est une structure de donnĂ©es qui modĂ©lise des objets et les liens entre eux. Formellement, un graphe G est un couple G = (S, A) oĂč :

De nombreuses situations se modélisent par un graphe :

Situation Sommets Liens
Réseau routier Villes / carrefours Routes
Réseau social Personnes Relations d'amitié
Web Pages Liens hypertextes (orientés)
Labyrinthe Cases Passages entre cases
⚠ Cadre du programme. On se limite aux graphes simples : pas de boucle (arc/arĂȘte d'un sommet vers lui-mĂȘme) ni de multi-arĂȘte (plusieurs liens entre les deux mĂȘmes sommets).

2. Vocabulaire des graphes

On distingue deux grandes familles selon que les liens ont un sens ou non.

Graphe non orienté

Les liens sont des arĂȘtes : une arĂȘte {u, v} relie u et v sans privilĂ©gier de sens. Si u est reliĂ© Ă  v, alors v est reliĂ© Ă  u (relation symĂ©trique). On dit que u et v sont adjacents (ou voisins) et que l'arĂȘte est incidente Ă  u et Ă  v.

Graphe orienté

Les liens sont des arcs : un arc (u, v) va de u vers v. Le sens compte : l'arc (u, v) n'implique pas l'arc (v, u). On dit que u est le sommet de départ (origine) et v le sommet d'arrivée. v est un successeur de u, et u un prédécesseur de v.

Notion Non orienté Orienté
Lien arĂȘte {u, v} arc (u, v)
Sens aucun (symétrique) de u vers v
Suite de sommets chaĂźne chemin
Boucle fermée cycle circuit

Ordre du graphe. L'ordre d'un graphe est son nombre de sommets, notĂ© n = |S|. On note souvent m = |A| le nombre d'arĂȘtes (ou d'arcs). Pour un graphe simple non orientĂ©, 0 ≤ m ≤ n(n−1)/2 ; pour un graphe simple orientĂ©, 0 ≤ m ≤ n(n−1).

3. Degré d'un sommet

Le degré mesure le nombre de liens attachés à un sommet.

💡 Lemme des poignĂ©es de main. Dans un graphe non orientĂ©, la somme des degrĂ©s vaut deux fois le nombre d'arĂȘtes : ∑u∈S deg(u) = 2m. (Chaque arĂȘte contribue 1 au degrĂ© de ses deux extrĂ©mitĂ©s.) Pour un graphe orientĂ© : ∑ d+(u) = ∑ d(u) = m.

4. Chemins, chaĂźnes, cycles et circuits

Une chaĂźne (graphe non orientĂ©) ou un chemin (graphe orientĂ©) est une suite de sommets s0, s1, 
, sk telle que deux sommets consĂ©cutifs sont reliĂ©s (par une arĂȘte, resp. un arc dans le bon sens). Sa longueur est le nombre de liens parcourus, soit k.

On dit qu'un sommet v est accessible depuis u s'il existe un chemin de u vers v. La distance entre u et v est la longueur du plus court chemin les reliant (∞ s'il n'en existe aucun).

5. Connexité

Un graphe non orientĂ© est connexe s'il existe une chaĂźne entre toute paire de sommets : « on peut aller de n'importe oĂč Ă  n'importe oĂč ». Sinon, le graphe se dĂ©compose en composantes connexes, chacune Ă©tant un sous-ensemble maximal de sommets deux Ă  deux reliĂ©s.

💡 Test de connexitĂ©. Un graphe non orientĂ© est connexe si et seulement si un parcours (BFS ou DFS) lancĂ© depuis un seul sommet atteint tous les sommets. C'est l'application directe des algorithmes de la section 11.

6. Représentation : matrice d'adjacence

NumĂ©rotons les sommets de 0 Ă  n−1. La matrice d'adjacence M est une matrice \(n \times n\) telle que :

M[i][j] = 1 s'il existe un arc/arĂȘte de i vers j,   M[i][j] = 0 sinon.

Exemple — graphe non orientĂ© Ă  4 sommets avec les arĂȘtes {0,1}, {0,2}, {1,2}, {2,3} :

       0  1  2  3
   0 [ 0  1  1  0 ]
   1 [ 1  0  1  0 ]
   2 [ 1  1  0  1 ]
   3 [ 0  0  1  0 ]
# Matrice d'adjacence en Python : liste de listes
M = [
    [0, 1, 1, 0],
    [1, 0, 1, 0],
    [1, 1, 0, 1],
    [0, 0, 1, 0],
]

# Existe-t-il une arĂȘte entre 0 et 2 ?
print(M[0][2] == 1)   # True
Opération Coût (matrice)
Test d'existence d'un arc \(O(1)\)
Liste des voisins d'un sommet \(O(n)\)
Mémoire utilisée \(O(n^{2})\)

La matrice est idĂ©ale pour un graphe dense (beaucoup d'arĂȘtes) ou quand on teste souvent l'existence d'un lien, mais elle gaspille de la mĂ©moire pour un graphe creux.

7. Représentation : listes d'adjacence

Pour chaque sommet, on stocke la liste de ses voisins. On regroupe ces listes dans une liste (sommets numérotés) ou dans un dictionnaire (sommets quelconques).

# Listes d'adjacence dans une LISTE (sommets 0..n-1)
G = [
    [1, 2],     # voisins de 0
    [0, 2],     # voisins de 1
    [0, 1, 3],  # voisins de 2
    [2],        # voisins de 3
]

# Listes d'adjacence dans un DICTIONNAIRE
G = {
    'A': ['B', 'C'],
    'B': ['A', 'C'],
    'C': ['A', 'B', 'D'],
    'D': ['C'],
}
print(G['C'])   # ['A', 'B', 'D'] : voisins de C
Opération Coût (listes d'adj.)
Test d'existence d'un arc \(O(deg(u))\)
Liste des voisins d'un sommet \(O(deg(u))\)
Mémoire utilisée \(O(n + m)\)
💡 Quel choix ? Listes d'adjacence pour un graphe creux (m petit devant n2, cas le plus frĂ©quent) : peu de mĂ©moire et parcours efficace des voisins. Matrice pour un graphe dense ou des tests d'arĂȘtes rĂ©pĂ©tĂ©s.

8. Manipulation d'un graphe en Python

Voici les opérations de base sur une représentation par dictionnaire de listes (graphe non orienté).

def ajouter_sommet(G, u):
    if u not in G:
        G[u] = []

def ajouter_arete(G, u, v):
    ajouter_sommet(G, u)
    ajouter_sommet(G, v)
    if v not in G[u]:
        G[u].append(v)
        G[v].append(u)   # non orienté : on ajoute dans les deux sens

def supprimer_arete(G, u, v):
    if v in G[u]:
        G[u].remove(v)
        G[v].remove(u)

def existe_arete(G, u, v):
    return u in G and v in G[u]

def voisins(G, u):
    return G[u]

Construire la liste des voisins d'un sommet Ă  partir de la matrice d'adjacence est un grand classique :

def voisins_depuis_matrice(M, u):
    return [j for j in range(len(M)) if M[u][j] == 1]

M = [[0,1,1,0],[1,0,1,0],[1,1,0,1],[0,0,1,0]]
print(voisins_depuis_matrice(M, 2))   # [0, 1, 3]

9. Parcours en largeur (BFS)

Le parcours en largeur (Breadth-First Search) explore le graphe « par cercles concentriques » : d'abord le sommet de départ, puis tous ses voisins (distance 1), puis les voisins des voisins (distance 2), etc. Il utilise une file (FIFO : premier entré, premier sorti).

⚠ EfficacitĂ© de la file. Une list Python est lente en tĂȘte (pop(0) est en \(O(n)\)). On utilise deque du module collections : popleft() et append() sont en \(O(1)\).
from collections import deque

def bfs(G, depart):
    vus = {depart}
    file = deque([depart])
    ordre = []
    while file:
        u = file.popleft()        # dĂ©file en tĂȘte (FIFO)
        ordre.append(u)
        for v in G[u]:
            if v not in vus:
                vus.add(v)
                file.append(v)    # enfile en queue
    return ordre

G = {'A':['B','C'], 'B':['A','D'], 'C':['A','D'], 'D':['B','C']}
print(bfs(G, 'A'))   # ['A', 'B', 'C', 'D']

Le BFS visite chaque sommet une fois et examine chaque arĂȘte une fois : sa complexitĂ© est \(O(n + m)\). PropriĂ©tĂ© fondamentale : dans un graphe non pondĂ©rĂ©, le BFS calcule le plus court chemin (en nombre d'arĂȘtes) depuis le sommet de dĂ©part.

10. Parcours en profondeur (DFS)

Le parcours en profondeur (Depth-First Search) explore « le plus loin possible » avant de revenir en arriÚre (backtracking). Il s'écrit naturellement de façon récursive, ou de façon itérative avec une pile (LIFO).

# Version récursive
def dfs(G, u, vus=None):
    if vus is None:
        vus = set()
    vus.add(u)
    ordre = [u]
    for v in G[u]:
        if v not in vus:
            ordre += dfs(G, v, vus)
    return ordre

# Version itérative (pile)
def dfs_iter(G, depart):
    vus = set()
    pile = [depart]
    ordre = []
    while pile:
        u = pile.pop()            # dépile au sommet (LIFO)
        if u not in vus:
            vus.add(u)
            ordre.append(u)
            for v in G[u]:
                if v not in vus:
                    pile.append(v)
    return ordre

Comme le BFS, le DFS est en \(O(n + m)\). La différence est l'ordre de visite : le BFS privilégie la largeur (file), le DFS la profondeur (pile/récursion).

11. Applications des parcours : cycle et connexité

Test de connexité (non orienté)

def est_connexe(G):
    if not G:
        return True
    depart = next(iter(G))        # un sommet quelconque
    atteints = set(bfs(G, depart))
    return len(atteints) == len(G)   # a-t-on atteint TOUS les sommets ?

Détection de cycle (non orienté)

Lors d'un DFS, si on rencontre un voisin déjà visité qui n'est pas le pÚre dont on vient, c'est qu'il existe un cycle.

def a_un_cycle(G):
    vus = set()
    def explore(u, pere):
        vus.add(u)
        for v in G[u]:
            if v not in vus:
                if explore(v, u):
                    return True
            elif v != pere:      # voisin déjà vu, différent du pÚre => cycle
                return True
        return False
    for s in G:
        if s not in vus:
            if explore(s, None):
                return True
    return False
💡 Cas orientĂ©. Pour dĂ©tecter un circuit dans un graphe orientĂ©, on marque les sommets « en cours d'exploration » : rencontrer un sommet dĂ©jĂ  « en cours » rĂ©vĂšle un circuit (arc retour).

12. Graphes pondérés et plus court chemin (Dijkstra)

Un graphe est pondĂ©rĂ© quand chaque arc/arĂȘte porte une Ă©tiquette numĂ©rique (un poids ou coĂ»t) : distance, temps, prix
 Le problĂšme du plus court chemin consiste Ă  minimiser la somme des poids le long du chemin.

En Python, on stocke le poids à cÎté du voisin :

# dictionnaire de listes de couples (voisin, poids)
G = {
    'A': [('B', 4), ('C', 1)],
    'B': [('D', 1)],
    'C': [('B', 2), ('D', 5)],
    'D': [],
}

L'algorithme de Dijkstra calcule les plus courtes distances depuis une source, à condition que tous les poids soient positifs. Idée : à chaque étape, on « fige » le sommet non encore traité de plus petite distance estimée, puis on relùche ses arcs (on met à jour la distance de ses voisins). Une file de priorité (tas, module heapq) sélectionne efficacement ce minimum.

import heapq

def dijkstra(G, source):
    dist = {u: float('inf') for u in G}
    dist[source] = 0
    tas = [(0, source)]               # (distance, sommet)
    while tas:
        d, u = heapq.heappop(tas)     # sommet de plus petite distance
        if d > dist[u]:
            continue                  # entrée périmée
        for v, poids in G[u]:
            if dist[u] + poids < dist[v]:
                dist[v] = dist[u] + poids     # relĂąchement
                heapq.heappush(tas, (dist[v], v))
    return dist

print(dijkstra(G, 'A'))   # {'A': 0, 'B': 3, 'C': 1, 'D': 4}

Avec un tas, Dijkstra s'exĂ©cute en \(O((n + m) \log n)\). Note : le plus court chemin A→B passe par C (1 + 2 = 3) plutĂŽt que par l'arc direct A→B (de poids 4).

⚠ Poids nĂ©gatifs. Dijkstra Ă©choue en prĂ©sence de poids nĂ©gatifs : un sommet figĂ© pourrait encore ĂȘtre amĂ©liorĂ© plus tard. Il faut alors d'autres algorithmes (Bellman-Ford, hors programme principal).

13. Pour aller plus loin : A* et Floyd-Warshall

Algorithme A*. C'est une variante heuristique de Dijkstra. À la distance dĂ©jĂ  parcourue g(v) on ajoute une estimation h(v) du reste Ă  parcourir jusqu'au but (par exemple la distance Ă  vol d'oiseau). On explore en prioritĂ© les sommets de plus petit f(v) = g(v) + h(v). Si l'heuristique ne surestime jamais la distance rĂ©elle (heuristique admissible), A* trouve le plus court chemin tout en explorant moins de sommets que Dijkstra. TrĂšs utilisĂ© en jeux vidĂ©o et GPS.

Floyd-Warshall. Calcule les plus courtes distances entre toutes les paires de sommets, par programmation dynamique. On améliore progressivement la distance d[i][j] en autorisant comme sommets intermédiaires 0, 1, 
, k :

def floyd_warshall(d):     # d : matrice n x n des poids, inf si pas d'arc
    n = len(d)
    for k in range(n):
        for i in range(n):
            for j in range(n):
                if d[i][k] + d[k][j] < d[i][j]:
                    d[i][j] = d[i][k] + d[k][j]
    return d

Sa complexitĂ© est \(O(n^{3})\). La coloration de graphe (attribuer une couleur Ă  chaque sommet pour que deux voisins n'aient jamais la mĂȘme) est un autre classique d'application.

14. Récapitulatif

CPGE MP / PSI · Informatique — Introduction Ă  la thĂ©orie des graphes