Retour au cours

backend / algorithmes-structures-donnees

Arbre couvrant minimum : Kruskal et Prim

Leçon 161 exercice

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.

AlgorithmeStratégieStructure cléIdéal pour
KruskalTrie toutes les arêtes, rejette les cyclesUnion-Find (disjoint set)Graphe creux, peu d'arêtes
PrimFait croître l'arbre depuis un sommetTas (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

python
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érent

Ré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

1 disponible
1

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.

Résoudre l’exercice →