Probleme – Kruskal : la propriété de la coupe, le code, et un piège d'OCaml
Exercice · niveau 3 (difficile) · informatique (MP2I/MPI), chapitre 23 — Unir & trouver, arbres couvrants
Énoncé
- Démontrer la propriété de la coupe : pour toute partition des sommets en deux parties non vides, l'arête de poids minimal traversant la partition appartient à un arbre couvrant minimal.
- En déduire directement la correction de Kruskal.
- Écrire le code complet, avec détection de non-connexité.
- Le code du chapitre place un effet de bord dans le prédicat d'un
List.filter. Pourquoi est-ce fragile ?
Corrigé
1. La propriété de la coupe. Soit une partition des sommets en deux parties non vides, et une arête de poids minimal parmi celles ayant une extrémité de chaque côté. Soit un arbre couvrant minimal. Si , c'est fini. Sinon, contient un unique cycle ; ce cycle traverse la coupe un nombre pair de fois, donc au moins deux, donc il contient une autre arête traversant la coupe. Par minimalité de , . Alors est encore un arbre couvrant — on a cassé le seul cycle — et
Comme est minimal, : est un arbre couvrant minimal contenant .
2. La correction de Kruskal en découle en une ligne. Quand Kruskal examine une arête et décide de la prendre, et sont dans deux classes différentes. Prenons pour la classe de . Aucune arête déjà examinée ne traverse cette coupe (une telle arête aurait fusionné les deux classes), donc toutes les arêtes traversantes ont un poids puisque le tri est croissant : est bien de poids minimal sur la coupe. La propriété de la coupe s'applique. Une récurrence sur le nombre d'arêtes prises conclut : à chaque instant, l'ensemble construit est inclus dans un arbre couvrant minimal.
C'est la même preuve que celle du chapitre, dite autrement — et sous cette forme elle sert aussi pour l'algorithme de Prim, qui fait exactement le même choix sur une autre coupe.
3. Le code complet.
(* Arbre couvrant de poids minimal.
Entrees : n sommets numerotes de 0 a n-1, une liste d'aretes (u, v, poids).
Sortie : Some (aretes, poids total) si le graphe est connexe, None sinon.
Complexite : O(m log m) pour le tri, puis m operations quasi constantes. *)
let kruskal n aretes =
let triees = List.sort (fun (_,_,p1) (_,_,p2) -> compare p1 p2) aretes in
let u = creer n in
let prises = ref [] and total = ref 0 and k = ref 0 in
(* INVARIANT : prises est inclus dans un arbre couvrant minimal, k = |prises|,
et les classes de u sont les composantes du graphe (V, prises). *)
List.iter (fun (a, b, p) ->
if !k < n - 1 && unir u a b then begin
prises := (a, b, p) :: !prises; total := !total + p; incr k
end) triees;
if !k = n - 1 then Some (List.rev !prises, !total) else None
Terminaison : la liste triée est finie. Complexité : pour le tri, puis appels à unir, quasi constants ; le tri domine. Détection de non-connexité : à la sortie, si le nombre d'arêtes prises est , c'est qu'il restait plusieurs classes. Sur le graphe à sept sommets du chapitre, le code rend ; sur le graphe à quatre sommets et deux arêtes et , il rend None.
4. Le piège du List.filter. Le chapitre écrit :
List.filter (fun (u, v, _) ->
if trouver uf u = trouver uf v then false
else begin unir uf u v; true end) triees
Le prédicat n'est pas une fonction : c'est une procédure, qui modifie uf au passage. L'algorithme n'est donc correct que si List.filter applique le prédicat aux éléments dans l'ordre de la liste — sinon les arêtes seraient examinées dans le désordre, et le tri ne servirait à rien.
Or le manuel d'OCaml ne le garantit pas. Il dit seulement : « filter f l returns all the elements of the list l that satisfy the predicate f. The order of the elements in the input list is preserved » — c'est l'ordre du résultat qui est promis, pas celui des appels. Pour List.map, la documentation ne promet même pas cela. Une mesure sous OCaml 5.4.1 montre que List.filter applique bien le prédicat de gauche à droite ; mais c'est une observation, pas un contrat, et une version future pourrait la démentir sans rien casser d'autre.
Le rappel est utile : dans une expression comme (f 1) + (f 2), OCaml évalue de droite à gauche (mesuré : f 2 d'abord). L'ordre d'évaluation n'est pas spécifié dans le langage, et le supposer est une source de fautes silencieuses.
La correction est celle du code de la question 3 : un List.iter, dont la documentation garantit l'ordre (« applies f in turn to a1; ...; an »), et qui dit franchement qu'on fait des effets de bord. Un effet de bord ne se cache pas dans un prédicat : on le met là où le lecteur l'attend.
Les autres exercices de ce chapitre Le cours du chapitre
Un blocage sur cet exercice ? Le tuteur d'Adloun guide par questions, sans donner la réponse.