Objectifs du chapitre
À la fin de ce chapitre, vous serez capable de:
- définir un arbre couvrant et un arbre couvrant minimal, dire combien d'arêtes ils comptent et reconnaître les problèmes concrets qui s'y ramènent;
- énoncer et démontrer la propriété de coupe, énoncer sa jumelle, la propriété de cycle, et vous en servir pour justifier qu'une arête donnée appartient — ou n'appartient pas — à un arbre couvrant minimal;
- dérouler Kruskal et Prim à la main sur un graphe pondéré, en montrant les arêtes refusées autant que les arêtes retenues, et démontrer la correction de Kruskal à partir de la propriété de coupe;
- implémenter la structure union-find avec union par rang et compression de chemin, et démontrer la borne que le rang seul garantit;
- énoncer le coût de Kruskal, , et celui de Prim avec un tas, , dire ce que chacun facture, et choisir entre les deux selon que le graphe est creux ou dense;
- dire ce que l'unicité d'un arbre couvrant minimal implique, ce qu'elle n'implique pas, et pourquoi deux algorithmes différents tombent ici sur le même arbre.
Le problème de l'arbre couvrant minimal
Relier tout le monde, au moindre coût
Sept sites doivent être reliés par un réseau. Une entreprise de télécommunications tire de la fibre entre des centraux, un distributeur d'électricité pose des lignes entre des villages, un ingénieur en hydraulique raccorde des réservoirs: le problème a la même forme à chaque fois. On connaît les liaisons possibles — toutes ne le sont pas, une montagne, une frontière ou un lac en interdisent certaines — et le coût de chacune. On cherche un ensemble de liaisons qui rende l'ensemble des sites communicants, et dont le coût total soit le plus petit possible.
Deux remarques suffisent à transformer cette question en un problème de graphes bien posé. La première: la solution n'a aucune raison de contenir un cycle. Si un ensemble de liaisons contient un cycle, on peut en retirer n'importe quelle arête du cycle — tous les sites restent reliés, puisque le tour du cycle offrait déjà un chemin de rechange — et le coût diminue strictement dès que les poids sont strictement positifs. La seconde: une solution doit toucher tous les sites, sans quoi certains resteraient hors du réseau. Un sous-graphe connexe, sans cycle, contenant tous les sommets: c'est exactement ce qu'on appelle un arbre couvrant.
Le chapitre 6, Graphes: représentations et parcours, a posé le vocabulaire dont nous nous servirons sans le redéfinir: graphe non orienté , avec sommets et arêtes, degré d'un sommet, chemin, cycle, connexité, composante connexe, liste et matrice d'adjacence. Il a aussi introduit le graphe témoin du cours, celui que les chapitres 6, 7 et 8 se partagent pour que vous n'ayez à apprendre qu'une seule image; nous le reprenons tel quel, avec ses onze poids, et c'est sur lui que tout ce chapitre calcule.
Trois précisions, chacune source d'une erreur classique.
D'abord, l'ACM n'est défini que pour un graphe connexe. Sur un graphe à plusieurs composantes, il n'existe aucun arbre couvrant, et ce qu'on cherche alors est une forêt couvrante minimale: un arbre minimal par composante. Les deux algorithmes de ce chapitre la produisent sans aucune modification; ils s'arrêtent simplement avec moins de arêtes, et c'est d'ailleurs le moyen le plus économique de tester la connexité tout en calculant le réseau.
Ensuite, rien n'exige que les poids soient positifs. Un poids négatif ne casse rien ici, contrairement à ce qui se passe pour les plus courts chemins au chapitre 7: l'ACM minimise une somme sur un ensemble fixé de arêtes, et non un chemin dont la longueur pourrait diverger en tournant dans un cycle négatif. C'est une différence de fond entre les deux problèmes, et elle mérite d'être notée dès maintenant.
Enfin, «minimal» ne veut pas dire «unique». Un graphe peut posséder plusieurs ACM, tous de même poids; nous y reviendrons longuement quand Kruskal et Prim auront produit le même arbre et qu'il faudra dire pourquoi.
Le chapitre 6 a énoncé, dans sa définition d'un arbre, qu'un arbre à sommets possède exactement arêtes. Ce chapitre-ci en dépend de bout en bout — c'est ce fait qui transforme «trouver le réseau le moins cher» en «choisir six arêtes sur onze» —, alors démontrons-le.
Démonstration. Par récurrence sur . Pour , l'arbre est réduit à un sommet et n'a aucune arête: .
Supposons la propriété vraie pour tous les arbres à sommets, avec , et soit un arbre à sommets. possède au moins une feuille, c'est-à-dire un sommet de degré 1. En effet, partons d'un sommet quelconque et marchons sans jamais revenir immédiatement sur l'arête qu'on vient d'emprunter: comme est fini et sans cycle, on ne peut pas marcher indéfiniment — repasser deux fois par un même sommet fermerait un cycle —, donc la marche s'arrête, et elle ne peut s'arrêter que sur un sommet dont la seule arête est celle par laquelle on est arrivé. Notons cette feuille. Le graphe , obtenu en retirant et son unique arête, est encore sans cycle, et il est encore connexe: tout chemin entre deux sommets de ne pouvait pas passer par , qui est de degré 1 et n'aurait pas eu d'issue. C'est donc un arbre à sommets, qui par hypothèse de récurrence a arêtes. En remettant et son arête, en a .
La conséquence est plus forte qu'elle n'en a l'air: tous les arbres couvrants d'un graphe donné ont le même nombre d'arêtes. Chercher le moins cher n'est donc pas chercher le plus petit — le nombre d'arêtes est imposé —, c'est choisir lesquelles des arêtes forment ce paquet de . Sur le graphe témoin, : tout arbre couvrant compte exactement six arêtes, et le problème est de choisir six arêtes parmi onze, en évitant celles qui créeraient un cycle.
Le graphe témoin, et pourquoi on n'énumère pas
Voici les données que tout ce chapitre utilise. Ce sont exactement celles du chapitre 6: le graphe est non orienté, il a sept sommets à et onze arêtes.
| arête | AB | AC | BC | BD | CD | CE | DE | DF | EF | EG | FG |
|---|---|---|---|---|---|---|---|---|---|---|---|
| poids | 4 | 2 | 1 | 5 | 8 | 10 | 2 | 6 | 3 | 7 | 4 |
La somme des onze poids vaut 52. Les degrés sont 2, 3, 4, 4, 4, 3, 2, et leur somme vaut bien . Ces sept sites sont fictifs, comme le tableau témoin du chapitre 1: leur seule vertu est d'être toujours les mêmes.
La méthode la plus naïve consiste à énumérer tous les arbres couvrants et à garder le plus léger. Elle fonctionne, et elle est sans espoir. Un programme d'énumération exhaustive sur le graphe témoin — on essaie chacun des choix de six arêtes et on garde ceux qui sont acycliques — trouve 144 arbres couvrants, dont exactement un de poids 17. Cent quarante-quatre pour sept sommets: c'est encore praticable. Mais la formule de Cayley donne le nombre d'arbres couvrants du graphe complet à sommets,
soit pour sept sommets déjà, et pour cent sommets. L'énumération n'est pas une méthode: c'est la description du problème. Il nous faut un algorithme qui construise directement le bon arbre, et pour le construire sans jamais revenir sur ses pas, il nous faut un théorème qui dise, à chaque étape, qu'une arête est sûre.
Notez, sur la figure 8.1, une chose qui surprend souvent: l'arbre retenu contient l'arête de poids 5, alors que le graphe possède des arêtes plus légères qui ont été écartées — de poids 4, ... non, y est. Mais , de poids 4, est bel et bien refusée alors que , de poids 5, est prise. Un algorithme qui se contenterait de prendre les arêtes les plus légères sans autre précaution se tromperait. Ce qui rend inutile, c'est que et sont reliés, par le chemin de poids , moins cher que l'arête directe. Reconnaître ce «déjà reliés» est tout le travail, et il occupera la seconde moitié du chapitre.
Un graphe connexe a 40 sommets et 200 arêtes. Combien d'arêtes compte un arbre couvrant minimal de ce graphe?
La propriété de coupe
Couper le graphe en deux
Toute la théorie tient dans une idée, et cette idée est une coupe: on sépare les sommets en deux camps et on regarde les arêtes qui passent d'un camp à l'autre.
Un graphe à sommets possède coupes si l'on distingue de son complémentaire, et la moitié sinon: pour le graphe témoin, parties non triviales, soit 63 coupes au sens géométrique. C'est peu, et nous vérifierons plus loin la propriété de coupe sur les 126, par programme.
Prenons-en une, et prenons-la nommée, parce qu'une propriété illustrée sur «une coupe quelconque» n'est illustrée sur rien. Posons
Trois arêtes traversent cette coupe, et trois seulement: de poids 5, de poids 8 et de poids 10. Les arêtes , et ont leurs deux extrémités dans ; les arêtes , , , et ont leurs deux extrémités hors de . Aucune de ces huit-là ne traverse. La plus légère des traversantes est , de poids 5, et la figure 8.2 la met en évidence.
Démonstration. Démontrons la seconde forme, qui contient la première — il suffit d'y prendre , ensemble contenu dans tout arbre couvrant minimal, et n'importe quelle coupe le respecte.
Soit donc un arbre couvrant minimal contenant . Si , il n'y a rien à faire: et convient. Supposons donc , avec et , et construisons à partir de un autre arbre couvrant minimal qui, lui, contient . C'est un : on ajoute l'arête voulue, on constate qu'un cycle apparaît, et on retire de ce cycle une arête bien choisie.
Un cycle apparaît. Puisque est un arbre couvrant, il contient un unique chemin reliant à — l'existence vient de la connexité, l'unicité de l'absence de cycle: deux chemins distincts entre les mêmes extrémités se recolleraient en un cycle. Le graphe contient donc exactement un cycle, à savoir suivi de l'arête .
Ce cycle traverse la coupe au moins deux fois. Le chemin part de et arrive à : il quitte donc au moins une fois. Il existe par conséquent sur au moins une arête avec et , c'est-à-dire une arête de traversant la coupe. Fixons-en une.
Cette arête n'est pas dans . C'est ici que sert l'hypothèse «la coupe respecte »: aucune arête de ne traverse la coupe, et la traverse, donc .
L'échange ne coûte rien. Posons . C'est encore un arbre couvrant: retirer une arête d'un cycle de laisse un graphe connexe — les deux morceaux séparés par la disparition de restent reliés par le reste du cycle —, et ce graphe a de nouveau arêtes, donc il est sans cycle. Son poids vaut
Or et traversent toutes deux la coupe, et est de poids minimal parmi les arêtes traversantes: donc , d'où . Comme est un arbre couvrant , on a aussi , donc et est lui aussi un arbre couvrant minimal.
Conclusion. contient par construction, et il contient : toutes les arêtes de étaient dans , et la seule que nous ayons retirée, , n'appartenait pas à . Donc avec arbre couvrant minimal.
Relisez la démonstration en repérant où chaque hypothèse a servi, c'est le meilleur usage qu'on puisse en faire. L'hypothèse « de poids minimal traversant» sert une seule fois, dans l'inégalité . L'hypothèse «la coupe respecte » sert une seule fois aussi, pour garantir que l'arête sacrifiée n'était pas une de celles qu'on avait promis de garder. Retirez l'une ou l'autre et l'argument tombe — l'exercice 8.4 vous fait construire le contre-exemple correspondant.
La propriété de cycle, son miroir
La propriété de coupe dit quelles arêtes on peut prendre. Sa jumelle dit lesquelles on peut jeter, et elle s'obtient par le même échange, lu à l'envers.
Démonstration. Supposons par l'absurde qu'un arbre couvrant minimal contienne cette arête , strictement la plus lourde du cycle . Retirons de : le graphe se scinde en exactement deux composantes connexes, celle qui contient , appelons-la , et celle qui contient . La paire est une coupe, et la traverse.
Le cycle part de , passe par et revient en . Le morceau de qui relie à sans emprunter commence dans et se termine hors de : il contient donc au moins une arête traversant la coupe, et puisque ce morceau évite . Comme appartient à et que est strictement la plus lourde de , on a .
Posons . L'arête relie les deux composantes que la disparition de avait séparées, donc est connexe; il a arêtes, donc c'est un arbre couvrant. Et
ce qui contredit la minimalité de . Donc aucun arbre couvrant minimal ne contient .
Sur le graphe témoin, le triangle a pour poids 4, 1 et 2: l'arête , de poids 4, est strictement la plus lourde de ce cycle, donc elle n'appartient à aucun ACM. Voilà démontré, sans exécuter le moindre algorithme, ce que la figure 8.1 montre. De même, le triangle a pour poids 8, 2 et 10: l'arête (10) est éliminée. Et le triangle avec (6), (2), (3) élimine .
La propriété de cycle exige la stricte supériorité, et l'exercice 8.4 vous montrera pourquoi: avec une égalité, l'arête peut appartenir à un ACM sans que l'autre cesse d'y appartenir aussi, tout simplement parce qu'il y a alors plusieurs ACM.
Remettez dans l'ordre les cinq étapes de l'argument d'échange qui démontre la propriété de coupe, pour une arête de poids minimal traversant une coupe respectant .
Glissez les éléments pour les mettre dans le bon ordre
- Comme la coupe respecte , cette arête n'appartient pas à : on peut la sacrifier sans trahir la promesse.
- On échange: est un arbre couvrant de poids , donc minimal, et il contient .
- Ce cycle part d'un côté de la coupe et rejoint l'autre, donc il contient une arête de qui traverse la coupe.
- On ajoute à : comme contenait déjà un unique chemin entre les extrémités de , un unique cycle apparaît.
- On se donne un arbre couvrant minimal contenant , et on suppose que n'est pas dans .
Kruskal: trier les arêtes, refuser les cycles
L'idée et le code
L'algorithme de Joseph Kruskal, publié en 1956, est d'une simplicité qui frise l'insolence: triez les arêtes par poids croissant, puis prenez-les une par une dans cet ordre, en refusant celles dont les deux extrémités sont déjà reliées.
Il construit donc une forêt qui grossit. Au départ, chaque sommet est seul dans sa composante — composantes, aucune arête. Chaque arête acceptée fusionne deux composantes et fait donc baisser leur nombre d'une unité; chaque arête refusée le laisse inchangé. On s'arrête quand il n'en reste qu'une, ce qui, d'après le théorème 8.1, arrive après exactement acceptations.
Tout repose donc sur la question: «ces deux sommets sont-ils déjà dans la même composante?» Nous allons d'abord l'implémenter naïvement, pour voir l'algorithme tourner, puis lui consacrer toute une section.
def kruskal(n, aretes):
"""ACM d'un graphe a n sommets 0..n-1. aretes : liste de (u, v, poids)."""
composante = list(range(n)) # composante[v] : etiquette de la composante de v
arbre = []
for u, v, poids in sorted(aretes, key=lambda e: e[2]):
if composante[u] != composante[v]:
ancienne, nouvelle = composante[v], composante[u]
for s in range(n): # on reetiquette toute une composante
if composante[s]
Comptons, comme le chapitre 1 l'exige, avant de parler de classe. Le tri coûte comparaisons. La boucle examine au plus arêtes; pour chacune, le test coûte deux lectures, et une acceptation coûte un réétiquetage en . Comme il y a acceptations, le réétiquetage total coûte . Le coût est donc — acceptable ici, catastrophique sur un grand graphe creux, où écrase largement . La section sur union-find remplacera ce par un terme quasi linéaire, et c'est le seul endroit où l'algorithme a besoin d'être amélioré.
La trace, sur le graphe témoin
Pourquoi c'est correct
Démonstration. Notons l'ensemble des arêtes retenues après l'examen des premières arêtes de la liste triée, avec . Nous démontrons par récurrence sur l'invariant:
est un ensemble d'arêtes sans cycle, contenu dans au moins un arbre couvrant minimal de .
Initialisation. est sans cycle et contenu dans n'importe quel arbre couvrant minimal, et il en existe au moins un puisque est connexe et fini.
Hérédité. Supposons l'invariant vrai pour et examinons la -ième arête, .
Premier cas: et sont dans la même composante de la forêt . L'algorithme refuse , donc et l'invariant est trivialement conservé. (Il est utile de noter que ce refus est justifié: contiendrait un cycle, et ne pourrait donc être inclus dans aucun arbre.)
Second cas: et sont dans deux composantes différentes de . L'algorithme accepte, et . Cet ensemble est encore sans cycle, puisque l'arête ajoutée relie deux composantes jusqu'ici disjointes. Reste à montrer qu'il est contenu dans un arbre couvrant minimal, et c'est la propriété de coupe qu'il faut invoquer — encore faut-il exhiber la bonne coupe.
Prenons pour l'ensemble des sommets de la composante de dans la forêt . C'est une partie non vide de ( y est), et stricte ( n'y est pas). La coupe : une arête de qui la traverserait aurait une extrémité dans la composante de et l'autre ailleurs, ce qui est impossible puisque les arêtes de relient des sommets d'une même composante.
Il reste à voir que est de poids minimal parmi les arêtes traversant cette coupe. Soit une arête quelconque traversant . Ses deux extrémités sont dans des composantes différentes de , donc n'appartient pas à , donc n'a pas été retenue; et comme est la -ième de la liste triée, deux cas se présentent: ou bien vient dans la liste, et alors par construction du tri; ou bien vient , mais alors elle a déjà été examinée et refusée, ce qui signifie que ses deux extrémités étaient déjà dans la même composante au moment de son examen — donc, les composantes ne faisant que fusionner, elles y sont encore, et ne traverse pas la coupe. Contradiction. Donc toute arête traversante a un poids au moins égal à : est bien minimale sur cette coupe.
Le théorème 8.2 s'applique: est contenu dans un arbre couvrant minimal. L'invariant est conservé.
Terminaison et conclusion. La boucle examine les arêtes, donc elle termine. À la fin, est sans cycle et contenu dans un arbre couvrant minimal . Il reste à voir que , c'est-à-dire que l'algorithme n'a rien oublié. Supposons et soit : cette arête a été examinée et refusée, donc ses deux extrémités étaient déjà reliées dans ; mais , donc elles étaient déjà reliées dans , et ajouter à y créerait un cycle — or . Contradiction. Donc , et l'algorithme renvoie un arbre couvrant minimal.
Ce que cette démonstration illustre dépasse Kruskal. C'est le patron de toutes les preuves de correction d'algorithmes gloutons, que le chapitre 9 systématisera: on ne montre pas que le choix glouton est «le bon» — notion sans contenu —, on montre qu'il conserve la possibilité d'atteindre un optimum. L'invariant «ce que j'ai construit jusqu'ici est contenu dans au moins une solution optimale» est la formulation exacte de cette idée, et l'argument d'échange est l'outil qui l'établit à chaque pas.
Écrivez Kruskal. Complétez la fonction pour qu'elle renvoie le couple (poids total, nombre d'arêtes retenues) de l'arbre couvrant minimal. Le programme affiche ces deux nombres pour le graphe témoin du cours, dont les sommets sont ici numérotés de 0 à 6 pour A à G. Attention: les vérifications appellent ensuite votre fonction sur un second graphe, que vous n'avez jamais vu.
Prim: faire grossir un seul arbre
L'idée et le code
L'algorithme de Prim — publié par Robert Prim en 1957, redécouvert par Edsger Dijkstra en 1959, mais déjà donné par Vojtěch Jarník en 1930 — procède tout autrement. Au lieu de faire grossir une forêt un peu partout, il fait grossir un seul arbre à partir d'un sommet de départ.
On part d'un sommet quelconque, disons , et on maintient un ensemble des sommets déjà reliés. À chaque tour, on regarde toutes les arêtes qui traversent la coupe et on prend la plus légère; le sommet qu'elle atteint rejoint . On répète fois.
Dit ainsi, le lien avec la propriété de coupe est immédiat, et c'est ce qui rend Prim si facile à démontrer: à chaque tour, l'ensemble des arêtes déjà retenues est exactement l'arbre construit sur , la coupe le respecte par construction — aucune arête de l'arbre ne sort de —, et l'arête choisie est de poids minimal parmi les traversantes. Le théorème 8.2 s'applique tel quel à chaque tour, et l'invariant « est contenu dans un arbre couvrant minimal» est conservé du début à la fin. La correction de Prim tient donc en trois lignes une fois la propriété de coupe démontrée, et c'est une bonne illustration de ce qu'un bon théorème fait gagner.
Reste le problème pratique: trouver la plus légère arête traversante sans les réexaminer toutes à chaque tour. La réponse est la file de priorité du chapitre 5. On y dépose les arêtes candidates au fur et à mesure qu'un sommet rejoint , et on en extrait le minimum.
import heapq
def prim(n, adjacence, depart=0):
"""ACM par croissance d'un seul arbre. adjacence[v] : liste de (voisin, poids)."""
dans_arbre = [False] * n
dans_arbre[depart] = True
tas = [(poids, depart, voisin) for voisin, poids in adjacence[depart]]
heapq.heapify(tas)
arbre, total = [], 0
while tas and len(arbre) < n - 1
Comptons. Chaque arête est insérée dans le tas au plus une fois par extrémité, donc au plus insertions — en pratique moins, puisqu'on n'insère pas les arêtes menant à un sommet déjà pris. Chaque insertion et chaque extraction coûtent comparaisons, et , donc . Le coût total est . C'est ce qu'on appelle la variante : on laisse dans le tas des arêtes devenues inutiles et on les jette au moment de les extraire, ce que fait le . La variante garde une seule entrée par sommet et demande une file de priorité avec diminution de clé; elle est plus économe en mémoire, pas en classe de complexité.
Le même arbre — et ce que cela prouve, ou non
Deux algorithmes, deux logiques opposées, un seul résultat. Il est tentant d'en conclure que l'ACM est toujours unique, et c'est faux. Voici ce qu'on peut affirmer.
La démonstration est l'objet de l'exercice 8.5, et elle est courte: on suppose deux ACM distincts, on considère l'arête de poids minimal qui appartient à l'un et pas à l'autre, et l'argument d'échange du théorème 8.2 produit une contradiction stricte.
Sur le graphe témoin, les poids ne sont pas deux à deux distincts: et valent toutes deux 2, et valent toutes deux 4. Le théorème 8.5 ne s'applique donc pas, et pourtant l'énumération exhaustive des 144 arbres couvrants montre qu'il y en a exactement un de poids 17. Retenez la leçon logique, qui vaut bien au-delà de ce chapitre: poids distincts unicité, mais la réciproque est fausse. Un graphe peut avoir des poids répétés et un ACM unique, comme celui-ci; il peut aussi avoir des poids répétés et plusieurs ACM.
Que se passe-t-il exactement, alors, quand l'unicité tombe? Deux choses distinctes, qu'il faut séparer.
- Le poids de l'ACM est toujours unique. C'est la définition d'un minimum: si et sont tous deux minimaux, alors . Aucun algorithme correct ne peut donc rendre un poids différent d'un autre algorithme correct, et c'est cette valeur-là, 17, qui est la vraie réponse au problème.
Un exemple concret, à faire vous-même avec l'explorateur ci-dessous: donnez à l'arête le poids 2 au lieu de 4. Il y a alors trois arêtes de poids 2 (, , ), le tri les départage par ordre alphabétique et prend avant ; est alors refusée, et l'arbre obtenu est — , de poids , . Descendez à 1 et tout change: est prise avant sans la moindre égalité, l'ACM devient de poids , et il est de nouveau unique. Remontez à 5, 6, 7 ou 8: rien ne bouge, l'ACM reste celui de 17. C'est cohérent avec la propriété de cycle, qui élimine dès qu'elle est strictement plus lourde que , donc dès .
Le premier curseur avance d'une arête examinée: la bande du bas est la liste des onze arêtes triée par poids croissant, celles qui ont été refusées y sont barrées, et le poids cumulé se lit sous la figure. Le second curseur change le poids de la seule arête AB, dont la valeur du cours est 4: regardez où le refus se déplace quand vous la descendez à 1, et observez qu'à 2 l'arbre obtenu n'est plus le même alors que son poids, lui, n'a pas changé. Un affichage ne bouge jamais, quel que soit le réglage: le nombre d'arêtes retenues à la fin, toujours n − 1 = 6.
Sur un graphe connexe, Kruskal et Prim sont lancés sur les mêmes données et rendent deux ensembles d'arêtes différents. Qu'en conclure?
Sur le graphe témoin, on remplace le poids de l'arête CE, qui vaut 10, par la valeur 1. Quel est le poids du nouvel arbre couvrant minimal?
Union-find: la structure qui dit «déjà reliés»
Le problème, isolé
Revenons au réétiquetage en de notre première version de Kruskal. Ce n'est pas une maladresse d'implémentation, c'est un vrai problème algorithmique, et il mérite d'être posé pour lui-même.
Kruskal s'écrit en six lignes avec cette structure, et c'est la version qu'on écrit vraiment:
def kruskal(n, aretes):
uf = UnionFind(n)
arbre = []
for u, v, poids in sorted(aretes, key=lambda e: e[2]):
if uf.unir(u, v): # unir renvoie False si deja unis
arbre.append((u, v, poids))
if len(arbre) == n - 1:
break
return arbre
Le décompte qui compte, ici, n'est plus celui des comparaisons mais celui des opérations de la structure: Kruskal effectue appels à unir au plus — un par arête examinée — et donc au plus appels à trouver, pour fusions effectives. Sur le graphe témoin, en s'arrêtant dès la sixième arête retenue: sept arêtes examinées, quatorze trouver, six fusions.
La forêt
La représentation naturelle est une forêt: chaque élément pointe vers un parent, chaque classe est un arbre, et le représentant de la classe est la racine de cet arbre, c'est-à-dire l'unique élément qui est son propre parent. Tout tient dans un tableau parent de taille .
class UnionFind:
def __init__(self, n):
self.parent = list(range(n)) # chacun est sa propre racine
def trouver(self, x):
while self.parent[x] != x:
x = self.parent[x]
return x
def unir(self, x, y):
a, b = self.trouver(x), self.trouver(y)
if a ==
unir coûte deux trouver et une affectation; tout le coût est donc dans trouver, et le coût de trouver est la profondeur de l'élément dans son arbre. Dans le pire des cas, cette version est catastrophique: la suite unir(0,1), unir(1,2), …, unir(n-2, n-1) construit un peigne, une chaîne de longueur , et un trouver sur la dernière feuille remonte arêtes. On a remplacé un réétiquetage en par une remontée en : aucun progrès.
Deux idées, indépendantes et cumulables, corrigent cela.
Union par rang
La première idée est de ne pas accrocher n'importe quelle racine sous n'importe quelle autre. Quand on fusionne, l'arbre le plus «plat» doit passer sous le plus «profond», puisque cela ne change pas la hauteur du résultat; l'inverse l'augmenterait d'une unité. On mémorise donc pour chaque racine un entier, le rang, qui majore la hauteur de son arbre.
class UnionFind:
def __init__(self, n):
self.parent = list(range(n))
self.rang = [0] * n
def trouver(self, x):
while self.parent[x] != x:
x = self.parent[x]
return x
def unir(self, x, y):
a, b = self
Le rang n'est pas le nombre d'éléments de la classe, et ce n'est pas non plus exactement la hauteur dès qu'on ajoutera la compression de chemin: c'est un majorant de la hauteur, qu'on maintient à peu de frais. Sa vertu tient dans un seul lemme.
Démonstration. Par récurrence sur le nombre d'opérations unir effectuées depuis la création.
Initialisation. Après creer(n), chaque élément est une racine de rang 0, à la tête d'un arbre d'un élément, et .
Hérédité. Supposons la propriété vraie avant une opération unir(x, y), et examinons ce qu'elle change. Soient et les deux racines concernées, de rangs et , l'algorithme ayant échangé les noms de sorte que . Les arbres autres que ceux de et de ne sont pas touchés, et leurs racines gardent leur rang: la propriété y reste vraie. L'élément cesse d'être une racine, il n'y a donc rien à vérifier pour lui. Seule la racine demande un examen, et il y a deux cas.
Si , le rang de ne change pas et reste , tandis que son arbre ne fait que grossir: il contenait au moins éléments par hypothèse de récurrence, il en contient encore au moins autant. La propriété est conservée.
Si , le rang de passe à . Mais son nouvel arbre est la réunion des deux anciens, qui comptaient chacun au moins éléments par hypothèse de récurrence, et qui étaient disjoints. Le nouvel arbre compte donc au moins
éléments, ce qui est exactement ce qu'exige son nouveau rang. La propriété est conservée.
Conséquences. Un arbre de la structure ne peut pas contenir plus de éléments. Si une racine avait un rang tel que , elle contredirait le lemme; donc , c'est-à-dire , et comme est entier, . Enfin, sans compression de chemin, une récurrence immédiate sur les mêmes cas montre que le rang d'une racine est à la hauteur de son arbre: la hauteur est donc elle aussi au plus , et , qui remonte de la profondeur de jusqu'à 0, effectue au plus remontées.
La borne est atteinte, et c'est important: elle n'est pas pessimiste par précaution. Avec huit éléments, la suite d'unions
produit le tableau de parents [0, 0, 0, 2, 0, 4, 4, 6], de rangs [3, 0, 1, 0, 2, 0, 1, 0], et d'une hauteur de . C'est cette forêt-là que dessine la figure 8.3.
Compression de chemin
La seconde idée est plus jolie encore, et elle ne coûte rien: puisqu'on vient de remonter tout un chemin jusqu'à la racine, autant en profiter pour le raccourcir. trouver(x) remonte de à la racine; en redescendant, on accroche directement à la racine tous les nœuds rencontrés. Le prochain trouver sur l'un d'eux coûtera une seule remontée.
def trouver(self, x):
racine = x
while self.parent[racine] != racine:
racine = self.parent[racine]
while self.parent[x] != racine: # seconde passe : on recable
self.parent[x], x = racine, self.parent[x]
return racine
La ligne self.parent[x], x = racine, self.parent[x] mérite un regard: le membre de droite est évalué entièrement avant la moindre affectation, donc x reçoit bien l'ancien parent et non la racine. C'est le même mécanisme que l'échange a, b = b, a du chapitre 6 d'Introduction à la programmation, et c'est ici qu'il évite une variable temporaire.
L'effet cumulé des deux idées est spectaculaire, et il est difficile à démontrer. Le résultat s'énonce avec la fonction , l'inverse de la fonction d'Ackermann.
Ce théorème est admis, et il faut dire pourquoi plutôt que de faire comme si l'omission allait de soi. La démonstration, due à Robert Tarjan en 1975, repose sur une analyse par fonction de potentiel dont les classes de rang sont définies par des tours d'exponentielles; elle occupe une dizaine de pages denses dans Cormen et al. et n'apprend, sur les arbres couvrants, strictement rien. Tarjan a de plus montré que la borne est optimale: aucune implémentation d'union-find fondée sur des pointeurs ne peut faire mieux que dans le pire des cas amorti. Ce qu'il faut retenir de l'énoncé, c'est ce que est et n'est pas.
Implémentez union-find. Complétez les deux méthodes: trouver doit remonter à la racine puis comprimer le chemin, et unir doit faire l'union par rang. Convention du cours, indispensable pour que la trace soit reproductible: en cas d'égalité des rangs, c'est la racine de x qui devient le parent, et son rang augmente de 1. Le programme affiche le tableau des parents après les sept unions, puis après un unique appel à trouver(7).
Ce que chaque algorithme coûte
Les trois termes de Kruskal, les deux de Prim
Reprenons Kruskal avec union-find, et comptons chaque phase séparément — c'est la seule manière de savoir ce qu'on peut espérer améliorer.
- Le tri des arêtes: comparaisons, et le chapitre 3 a démontré qu'aucun tri par comparaisons ne peut faire mieux.
- Les appels à
trouveret les fusions: au total, d'après le théorème 8.7. - La construction de la structure: .
Total: . Comme est majoré par 4 en pratique et, de toute façon, par , le premier terme domine et l'on écrit
Le tri domine, et c'est tout ce qu'il y a à retenir. Une conséquence pratique immédiate: si les arêtes arrivent déjà triées — elles sortent d'une base de données indexée par le coût, ou d'un calcul précédent —, ou si les poids sont de petits entiers permettant un tri par dénombrement en (chapitre 3), alors Kruskal devient , c'est-à-dire quasi linéaire. C'est un cas fréquent et c'est un vrai gain.
Une simplification s'impose au passage. Comme , on a , donc
et les deux écritures sont interchangeables. Ce n'est pas une coquetterie: cela montre que Kruskal et Prim avec un tas binaire sont dans la même classe de complexité, , et que le choix entre les deux ne se décide donc pas sur la classe.
Pour Prim, le décompte a été fait plus haut: au plus insertions et extractions dans un tas, chacune en , plus d'initialisation, soit
Deux autres variantes de Prim méritent d'être connues, parce qu'elles gagnent là où la version au tas perd.
- Prim avec un simple tableau. On stocke pour chaque sommet hors de le poids de la plus légère arête qui l'y relie, et on cherche le minimum par un balayage linéaire. Chaque tour coûte pour le balayage et l'on fait tours, plus pour les mises à jour: total , puisque . Aucun logarithme.
Creux ou dense: qui gagne
Prenons sommets, et deux régimes. Un graphe creux, avec arêtes — c'est l'ordre de grandeur d'un réseau routier, d'un réseau électrique, d'un graphe de voisinage. Un graphe dense, avec arêtes, c'est-à-dire complet — c'est le cas d'un problème de partitionnement où tout couple de points a une distance.
| Algorithme | Coût | Creux, | Dense, |
|---|---|---|---|
| Kruskal (tri comparatif) | 34 652 | 9 455 598 | |
| Prim, tas binaire | 29 897 | 4 977 909 | |
| Prim, tableau |
La lecture est nette, et elle tient en deux phrases.
Sur un graphe creux, Kruskal et Prim au tas se valent — 34 652 contre 29 897, un rapport de 1,16, entièrement dans l'épaisseur des constantes — et tous deux écrasent la version à tableau d'un facteur 30, parce que celle-ci paie même quand il n'y a que arêtes. C'est le régime où les deux algorithmes sont interchangeables, et où le choix se fait sur d'autres critères: Kruskal si les arêtes sont déjà triées ou triables linéairement, si le graphe risque d'être non connexe (il rend alors la forêt couvrante minimale sans une ligne de plus), ou si l'on veut le trier une fois pour plusieurs requêtes; Prim si le graphe est donné par listes d'adjacence et qu'on ne veut pas matérialiser la liste des arêtes.
Sur un graphe dense, c'est Prim avec un tableau qui gagne, et de loin: 1 000 000 contre 4 977 909 pour Prim au tas et 9 455 598 pour Kruskal, soit un facteur 5 et un facteur 9. La raison est structurelle et vaut d'être comprise: quand est de l'ordre de , tout algorithme qui paie un logarithme par arête paie , alors que le balayage linéaire paie tout court. Le tas, qui était une bonne idée pour éviter de relire les arêtes à chaque tour, devient une dépense inutile quand on doit de toute façon toutes les lire. Une structure de données sophistiquée n'est un progrès que si la structure du problème le permet, et c'est l'un des rares endroits de ce cours où le tableau plat bat le tas.
Ajoutons deux remarques honnêtes sur ce tableau. La ligne Fibonacci donne les meilleurs nombres des deux colonnes et reste, en pratique, le plus mauvais choix des quatre pour des graphes de cette taille: ses constantes cachées sont grandes et sa structure maltraite les caches, ce que le modèle RAM du chapitre 1 ne voit pas. Et la frontière entre «creux» et «dense» n'est pas à : en égalant et , on trouve que le tableau devient préférable au tas autour de , soit environ 100 000 arêtes pour — donc bien au-delà du régime creux, et un peu en deçà du graphe complet.
Vérifiez la propriété de coupe, sur toutes les coupes. Le graphe témoin a 7 sommets, donc 126 parties S qui ne sont ni vides ni égales à V tout entier. Pour chacune, trouvez une arête de poids minimal qui traverse la coupe, et vérifiez qu'elle appartient bien à l'arbre couvrant minimal ACM. Le programme doit afficher le nombre de coupes examinées puis le nombre de contre-exemples trouvés.
Un graphe a 100 000 sommets et 300 000 arêtes, et ses poids sont des entiers compris entre 1 et 50. Quelle affirmation est correcte?
Synthèse
- Un arbre couvrant d'un graphe connexe à sommets est un sous-graphe connexe et sans cycle qui touche tous les sommets; le théorème 8.1 lui impose exactement arêtes. Chercher l'arbre couvrant minimal n'est donc pas chercher le plus petit — la taille est imposée — mais choisir lesquelles des arêtes forment ce paquet. Sur le graphe témoin, et : six arêtes à choisir parmi onze, parmi 144 arbres couvrants possibles, et l'énumération n'est une méthode pour aucune taille sérieuse, puisque le graphe complet en compte .
Exercices
Vous pouvez afficher le corrigé directement sous chaque énoncé après avoir cherché la solution.
Soit le graphe non orienté à cinq sommets , , , , et huit arêtes suivantes:
| arête | PQ | PR | QR | QS | QT | RS | RT | ST |
|---|---|---|---|---|---|---|---|---|
| poids | 6 | 1 | 5 | 3 | 8 | 7 | 9 | 4 |
- Dressez la table de la trace de Kruskal: pour chaque arête examinée dans l'ordre croissant des poids, indiquez la décision, le poids cumulé et le nombre de composantes.
- Donnez l'arbre couvrant minimal et son poids.
- Combien d'arêtes l'algorithme examine-t-il avant de pouvoir s'arrêter?
Solution
Sur le graphe témoin du cours, déroulez l'algorithme de Prim en partant du sommet au lieu de .
- Donnez la suite des arêtes retenues, dans l'ordre où Prim les prend.
- Comparez cette suite à celle obtenue depuis , puis les deux arbres obtenus.
- Le sommet de départ peut-il changer le poids de l'arbre obtenu? Peut-il en changer les arêtes?
Solution
1. On part de . Les arêtes sortantes sont (7) et (4); la plus légère est , on la prend et rejoint .
On dispose de huit éléments numérotés 0 à 7, chacun seul dans sa classe. On applique, dans cet ordre, unir(0,1), unir(2,3), unir(0,2), unir(4,5), unir(6,7), unir(4,6), unir(0,4), avec union par rang et la convention du cours: en cas d'égalité des rangs, la racine du premier argument devient le parent.
- Donnez le tableau
parentet le tableaurangaprès ces sept unions, ainsi que la hauteur de l'arbre obtenu. - On appelle ensuite
trouver(7)avec compression de chemin. Donnez le nouveau tableauparentet dites quels rangs ont changé. - Combien de remontées coûtent
trouver(7)puistrouver(5), dans cet ordre, avec compression? Et sans? - La hauteur 3 obtenue en 1 est-elle un hasard, ou le pire que l'union par rang autorise sur huit éléments?
Solution
1. Suivons pas à pas. unir(0,1): rangs égaux à 0, donc parent[1] = 0 et rang[0] = 1. unir(2,3): parent[3] = 2, rang[2] = 1. unir(0,2): les deux racines, 0 et 2, ont le rang 1; égalité, donc parent[2] = 0 et rang[0] = 2. unir(4,5): parent[5] = 4, rang[4] = 1. unir(6,7): parent[7] = 6, rang[6] = 1. unir(4,6): rangs égaux à 1, donc parent[6] = 4 et rang[4] = 2. unir(0,4): rangs égaux à 2, donc parent[4] = 0 et rang[0] = 3.
Cet exercice porte sur les hypothèses des théorèmes 8.2 et 8.3.
- La propriété de coupe demande que la coupe respecte l'ensemble déjà construit. Construisez un graphe à quatre sommets, un ensemble contenu dans un ACM et une coupe qui ne respecte pas , tels que la conclusion soit fausse: l'arête minimale traversante ne peut pas être ajoutée à sans quitter tout ACM.
- La propriété de cycle exige que l'arête soit strictement la plus lourde du cycle. Montrez sur un exemple que l'énoncé devient faux si l'on remplace «strictement» par «au sens large».
- Sur le graphe témoin, quel est le poids du deuxième meilleur arbre couvrant, c'est-à-dire du plus léger des arbres couvrants différents de l'ACM? Quelles arêtes le composent?
Solution
1. Prenons quatre sommets , , , et quatre arêtes, : , , et .
Cet exercice demande une démonstration complète.
Soit un graphe connexe dont les poids des arêtes sont deux à deux distincts. Démontrez que possède un unique arbre couvrant minimal (théorème 8.5).
Indication: supposez deux ACM distincts et , et considérez l'arête de poids parmi celles qui appartiennent à l'un des deux et pas à l'autre.
Références
- Cormen, Leiserson, Rivest & Stein, Introduction to Algorithms, 4ᵉ éd., MIT Press — chapitre 21 pour les arbres couvrants minimaux, la propriété de coupe et les deux algorithmes; chapitre 19 pour union-find, dont l'analyse complète en amorti.
- Cormen, Leiserson, Rivest & Stein, Algorithmique, 3ᵉ éd., Dunod — la traduction française des mêmes chapitres, utile pour fixer le vocabulaire: «coupe», «arête sûre», «ensembles disjoints».
- Kleinberg & Tardos, Algorithm Design, Pearson — chapitre 4, qui présente la propriété de coupe sous le nom de cut property et la propriété de cycle sous celui de cycle property, avec une discussion particulièrement claire de l'unicité sous poids distincts.
- Sedgewick & Wayne, Algorithms, 4ᵉ éd., Addison-Wesley — section 4.3, pour les variantes paresseuse et gloutonne de Prim et pour une comparaison expérimentale des implémentations.
- Dasgupta, Papadimitriou & Vazirani, Algorithms, McGraw-Hill — chapitre 5, qui déduit les deux algorithmes d'un unique énoncé de coupe, dans l'esprit de ce chapitre.
- Tarjan, Efficiency of a Good But Not Linear Set Union Algorithm, Journal of the ACM, 1975 — l'article original de l'analyse en et de la borne inférieure correspondante; à consulter pour un point précis, pas comme lecture.