backend / algorithmes-structures-donnees
Arbre couvrant minimum : Kruskal et Prim
Explication
Ce que vous allez apprendre
- Distinguer le problème de l'arbre couvrant minimum (connecter tous les sommets) du plus court chemin entre deux sommets
- Décrire l'algorithme de Kruskal : tri des arêtes, rejet des cycles via une structure Union-Find
- Décrire l'algorithme de Prim : croissance locale de l'arbre depuis un sommet de départ
- Justifier pourquoi la "propriété de coupe" garantit l'optimalité de ces deux approches gloutonnes
- Choisir entre Kruskal et Prim selon la densité du graphe (creux ou dense)
Dans quel contexte ?
Un opérateur télécom doit relier 200 nouvelles antennes 5G au réseau de fibre existant, en minimisant le coût total de câblage et sans jamais créer de boucle redondante. C'est exactement le problème de l'arbre couvrant minimum : Kruskal ou Prim garantissent tous les deux le câblage total le moins cher possible qui connecte toutes les antennes, en un temps raisonnable même sur un réseau de plusieurs milliers de connexions possibles.
D'abord, un problème différent de Dijkstra
Imagine devoir relier plusieurs villes par des câbles électriques, en minimisant la longueur totale de câble posée, sans créer de boucle inutile. Contrairement à Dijkstra, qui cherche le chemin le moins coûteux entre DEUX sommets, il s'agit ici de connecter TOUS les sommets entre eux au coût total minimal.
Étape 1 : la première stratégie, Kruskal
Kruskal trie TOUTES les arêtes du graphe par poids croissant, puis les examine une par une dans cet ordre. Il n'accepte une arête que si elle relie deux composantes encore séparées, sinon elle créerait un cycle inutile et plus coûteux.
Prérequis
Kruskal s'implémente efficacement avec une structure Union-Find, présentée en détail à la leçon 22. À ce stade, retenez simplement qu'elle répond en quasi O(1) à la question « ces deux sommets sont-ils déjà connectés ? »
Pourquoi cette approche gloutonne fonctionne
La moins chère des arêtes restantes qui ne crée pas de cycle fait TOUJOURS partie d'au moins une solution optimale. C'est ce qu'on appelle la "propriété de coupe", et elle garantit qu'aucun choix glouton précoce ne pourra jamais s'avérer une erreur plus tard.
Étape 2 : une deuxième stratégie, Prim
Prim adopte une perspective différente mais tout aussi gloutonne : on part d'un seul sommet, et à chaque étape, on ajoute à l'arbre en construction l'arête la moins chère qui le relie à un sommet encore extérieur. L'arbre grandit organiquement, un sommet à la fois.
Pourquoi ces deux stratégies opposées donnent le même résultat
Que l'on trie globalement (Kruskal) ou que l'on fasse croître localement (Prim), la même propriété de coupe garantit l'optimalité dans les deux cas.
| Algorithme | Stratégie | Structure clé | Idéal pour |
|---|---|---|---|
| Kruskal | Trie toutes les arêtes, rejette les cycles | Union-Find (disjoint set) | Graphe creux, peu d'arêtes |
| Prim | Fait croître l'arbre depuis un sommet | Tas (file de priorité) | Graphe dense |
Comment choisir entre les deux en pratique
Kruskal excelle sur un graphe creux, où il y a peu d'arêtes à trier. Prim est souvent préféré sur un graphe dense, où faire croître l'arbre localement évite de trier un très grand nombre d'arêtes.
Astuce
Pour choisir rapidement : comparez le nombre d'arêtes E au nombre de sommets V. Si E est proche de V (graphe creux), Kruskal est généralement plus simple et rapide. Si E est proche de V² (graphe dense), Prim avec un tas prend l'avantage.
Vers la suite
Ces algorithmes optimisent des problèmes de graphes en faisant, à chaque étape, le choix qui semble le meilleur sur le moment. La prochaine leçon généralise cette idée avec la programmation dynamique, une autre façon de construire une solution optimale à partir de sous-problèmes plus petits.
Commandes & code
Arbre couvrant minimum (MST) : Kruskal et Prim
import heapq
# Un arbre couvrant minimum (Minimum Spanning Tree) relie tous les sommets d'un graphe
# NON ORIENTÉ pondéré avec le poids total minimal, sans former de cycle.
# --- Kruskal : trie toutes les arêtes, ajoute la moins chère si elle ne crée pas de cycle ---
class UnionFind:
def __init__(self, elements: list[str]):
self.parent = {e: e for e in elements}
self.rang = {e: 0 for e in elements}
def trouver(self, x: str) -> str:
# compression de chemin : aplatit l'arbre pour accélérer les appels futurs
if self.parent[x] != x:
self.parent[x] = self.trouver(self.parent[x])
return self.parent[x]
def union(self, x: str, y: str) -> bool:
racine_x, racine_y = self.trouver(x), self.trouver(y)
if racine_x == racine_y:
return False # déjà dans le même ensemble -> ajouter cette arête créerait un cycle
# union par rang : accroche le plus petit arbre sous le plus grand
if self.rang[racine_x] < self.rang[racine_y]:
racine_x, racine_y = racine_y, racine_x
self.parent[racine_y] = racine_x
if self.rang[racine_x] == self.rang[racine_y]:
self.rang[racine_x] += 1
return True
def kruskal(sommets: list[str], aretes: list[tuple[float, str, str]]) -> tuple[list, float]:
# Complexité : O(E log E) dominé par le tri des arêtes
aretes_triees = sorted(aretes) # tri par poids croissant
uf = UnionFind(sommets)
mst = []
poids_total = 0.0
for poids, a, b in aretes_triees:
if uf.union(a, b): # ajoute l'arête seulement si elle ne crée pas de cycle
mst.append((a, b, poids))
poids_total += poids
if len(mst) == len(sommets) - 1:
break # un MST a exactement V-1 arêtes -- on peut s'arrêter
return mst, poids_total
sommets = ["A", "B", "C", "D"]
aretes = [(1, "A", "C"), (4, "A", "B"), (2, "C", "B"), (5, "C", "D"), (1, "B", "D")]
mst, poids = kruskal(sommets, aretes)
assert poids == 4 # A-C (1) + B-D (1) + C-B (2) = 4
# --- Prim : fait croître un arbre depuis un sommet de départ, ajoute toujours l'arête la moins chère ---
def prim(graphe: dict[str, list[tuple[str, float]]], depart: str) -> tuple[list, float]:
# Complexité : O(E log V) avec un tas binaire -- généralement plus rapide sur graphe DENSE
visites = {depart}
file_priorite = [(poids, depart, voisin) for voisin, poids in graphe[depart]]
heapq.heapify(file_priorite)
mst = []
poids_total = 0.0
while file_priorite and len(visites) < len(graphe):
poids, origine, destination = heapq.heappop(file_priorite)
if destination in visites:
continue # les deux extrémités sont déjà dans l'arbre -- créerait un cycle
visites.add(destination)
mst.append((origine, destination, poids))
poids_total += poids
for voisin, w in graphe.get(destination, []):
if voisin not in visites:
heapq.heappush(file_priorite, (w, destination, voisin))
return mst, poids_total
graphe_non_oriente = {
"A": [("C", 1), ("B", 4)],
"B": [("A", 4), ("C", 2), ("D", 1)],
"C": [("A", 1), ("B", 2), ("D", 5)],
"D": [("B", 1), ("C", 5)],
}
mst_prim, poids_prim = prim(graphe_non_oriente, "A")
assert poids_prim == 4 # même poids total optimal que Kruskal, chemin de construction différentRésumé
- Kruskal : trie TOUTES les arêtes globalement et les ajoute une à une via une structure Union-Find pour éviter les cycles ; O(E log E).
- Prim : fait croître un arbre connecté depuis un sommet de départ, en choisissant toujours l'arête la moins chère sortant de l'arbre ; O(E log V).
- Les deux algorithmes produisent un poids total identique (le MST peut différer en arêtes si des poids sont égaux) grâce à la propriété de coupe (cut property).
- Kruskal est souvent préféré sur un graphe creux (peu d'arêtes), Prim sur un graphe dense.
Exercices pratiques
Mission : câbler 5 nouvelles antennes au coût minimal
Objectif : Tracer Kruskal à la main sur un petit réseau et argumenter le choix entre Kruskal et Prim selon la densité.
Contexte
Un opérateur télécom doit relier 5 antennes (A, B, C, D, E) par de la fibre, avec les coûts de câblage possibles suivants : A-B (6), A-C (2), B-C (3), B-D (5), C-D (4), C-E (7), D-E (1). L'objectif est de connecter les 5 antennes en minimisant le coût total de câblage, sans jamais créer de boucle redondante et coûteuse.