Arbres
Cours complet · informatique (MP2I/MPI), chapitre 10 · MP2I et MPI
Travailler ce chapitre sur Adloun Exercices corrigés de ce chapitre
10.1 Une structure qui se définit par elle-même
Les structures du chapitre chap:sequentielles étaient linéaires : chaque élément avait au plus un suivant. L'arbre rompt avec cela — un nœud peut avoir plusieurs fils — et c'est ce qui lui donne son pouvoir : une recherche dans une liste de éléments coûte , dans un arbre bien formé .
C'est aussi le premier objet vraiment inductif du livre. Sa définition tient en deux règles, sa preuve en deux cas, son code en deux motifs. Tout ce qui a été posé au chapitre chap:induction s'applique ici sans adaptation.
10.2 Définition et vocabulaire
L'ensemble des arbres binaires étiquetés par est le plus petit ensemble tel que :
type 'a arbre = Vide | Noeud of 'a arbre * 'a * 'a arbre
typedef struct noeud_s {
int etiquette;
struct noeud_s* gauche; /* NULL joue le rôle de Vide */
struct noeud_s* droit;
} noeud;
- nœud : un Nœud de la définition ; racine : celui du sommet ;
- feuille : un nœud dont les deux fils sont vides ; nœud interne : les autres ;
- fils et père : et sont les fils de , qui est leur père ;
- étiquette : la valeur portée par un nœud ;
- sous-arbre : tout arbre atteint en descendant ;
- profondeur d'un nœud : le nombre d'arcs qui le séparent de la racine — celle-ci est à la profondeur ;
- hauteur de l'arbre : la plus grande profondeur atteinte.
C'est une convention, et le programme la fixe explicitement. Elle n'est pas arbitraire : elle est la seule qui rende vraie l'identité
dans tous les cas, y compris pour une feuille — dont les deux fils sont vides, et dont la hauteur vaut alors . Avec la convention , il faudrait un cas particulier pour les feuilles, et toutes les formules du chapitre porteraient une exception.
10.3 Les fonctions de base, et leurs preuves
(* Nombre de nœuds de a. *)
let rec taille = function
| Vide -> 0
| Noeud (g, _, d) -> 1 + taille g + taille d
(* Hauteur de a ; celle de l'arbre vide vaut -1. *)
let rec hauteur = function
| Vide -> -1
| Noeud (g, _, d) -> 1 + max (hauteur g) (hauteur d)
Terminaison : les appels portent sur et , strictement plus petits pour l'ordre induit, qui est bien fondé. Complexité : chaque nœud est visité exactement une fois, donc pour un arbre de nœuds — et en espace de pile.
Pour un arbre binaire de hauteur et de taille :
Démonstration
Par induction structurelle. Pour , avec et , l'encadrement s'écrit : il tient, et c'est pourquoi l'énoncé part de et non de .
Soit , de hauteur et de taille .
Minoration. Supposons , donc . Par hypothèse d'induction , et . Donc .
Majoration. Par hypothèse d'induction, puisque , et de même pour . Donc
En inversant la majoration : . La hauteur d'un arbre à nœuds vaut donc au mieux , et au pire — le cas du peigne, où chaque nœud n'a qu'un fils, et qui n'est rien d'autre qu'une liste chaînée déguisée.
Comme toutes les opérations de recherche coûteront , tout l'enjeu du chapitre chap:tas sera d'empêcher l'arbre de dégénérer.
Le programme demande de « mentionner la représentation d'un arbre complet dans un tableau ». Si l'arbre est complet — tous les niveaux remplis, le dernier tassé à gauche — on peut se passer de pointeurs : en rangeant la racine à l'indice , les fils du nœud d'indice sont aux indices et , et son père à . Aucun pointeur, aucune allocation, une mémoire contiguë : c'est la représentation qu'utilisera le tas au chapitre chap:tas.
10.4 Arbres d'arité quelconque
Un arbre (sans qualificatif) autorise un nombre quelconque de fils par nœud.
type 'a arbre_general = N of 'a * 'a arbre_general list
Méthode : Conversion « fils gauche, frère droit »
Le programme demande la « conversion d'un arbre d'arité quelconque en un arbre binaire ». Le procédé est classique et se dit en une phrase : le fils gauche du binaire est le premier fils du général ; le fils droit est le frère suivant.
let rec vers_binaire (N (e, fils)) = Noeud (freres fils, e, Vide)
and freres = function
| [] -> Vide
| N (e, f) :: reste -> Noeud (freres f, e, freres reste)
La conversion est bijective, et elle permet de n'écrire qu'une fois les algorithmes sur les arbres. En revanche elle allonge : trois frères deviennent une chaîne de hauteur trois.
Le programme laisse les illustrations « au choix du professeur » et en cite plusieurs : expressions arithmétiques, arbres préfixes (trie), arbres de décision, dendrogrammes, arbres de classification. On les retrouvera : l'arbre de décision au chapitre chap:apprentissage, le dendrogramme de la classification hiérarchique au même endroit, l'arbre syntaxique au chapitre chap:grammaires, et l'arbre de preuve au chapitre chap:deduction.
type expr =
| Cst of int
| Plus of expr * expr
| Fois of expr * expr
(* Évalue e. La structure de la fonction EST la structure du type. *)
let rec evalue = function
| Cst n -> n
| Plus (a, b) -> evalue a + evalue b
| Fois (a, b) -> evalue a * evalue b
L'arbre porte la priorité des opérateurs dans sa forme : et sont deux arbres différents, et aucune parenthèse n'est nécessaire. C'est précisément ce qu'un analyseur syntaxique construit — chapitre chap:grammaires.
10.5 Parcours
Un parcours visite chaque nœud une fois. Pour un arbre binaire, la question est quand traiter la racine par rapport à ses sous-arbres :
- préfixe : racine, puis gauche, puis droit ;
- infixe : gauche, puis racine, puis droit ;
- postfixe : gauche, puis droit, puis racine.
let rec prefixe = function
| Vide -> []
| Noeud (g, e, d) -> e :: (prefixe g @ prefixe d)
let rec infixe = function
| Vide -> []
| Noeud (g, e, d) -> infixe g @ (e :: infixe d)
let rec postfixe = function
| Vide -> []
| Noeud (g, e, d) -> postfixe g @ postfixe d @ [e]
| préfixe | 8, 3, 1, 6, 4, 9, 7 |
|---|---|
| infixe | 1, 3, 4, 6, 8, 7, 9 |
| postfixe | 1, 4, 6, 3, 7, 9, 8 |
La concaténation @ recopie sa liste de gauche (chapitre chap:langage-ocaml). Sur un peigne gauche, le coût devient quadratique. La bonne écriture passe un accumulateur :
(* Renvoie la liste infixe de a, suivie de acc. Complexité : O(n). *)
let rec infixe_acc a acc =
match a with
| Vide -> acc
| Noeud (g, e, d) -> infixe_acc g (e :: infixe_acc d acc)
Chaque nœud n'entraîne qu'un ::, en : le parcours redevient . C'est une illustration exacte de la mise en garde du chapitre chap:langage-ocaml : une complexité peut être ruinée par une seule opération mal choisie.
Le programme suggère d'« évoquer le lien avec l'empilement de blocs d'activation lors de l'appel à une fonction récursive ». Le parcours préfixe empile la racine avant de descendre, le postfixe la traite après être remonté : ce sont exactement la descente et la remontée de la pile d'exécution du chapitre chap:recursivite. Un parcours en profondeur écrit avec une pile explicite et le même parcours écrit récursivement font littéralement la même chose — l'un avec la pile du programme, l'autre avec celle de la machine.
C'est le résultat le plus utile de la section, et il sera démontré au chapitre suivant. Il donne déjà l'intuition : dans l'arbre dessiné plus haut, la ligne infixe est — presque triée, mais pas tout à fait, car cet arbre n'est pas un arbre binaire de recherche. Un arbre où elle le serait exactement mérite un nom, et c'est le sujet du chapitre chap:tas.
10.6 Ce qu'il faut retenir
- Le type est inductif : deux constructeurs, donc deux motifs, donc deux cas de preuve. La terminaison est acquise par l'ordre induit.
- , et : la hauteur est entre et .
- Toute opération de recherche coûtera : équilibrer, c'est tout gagner.
- Trois parcours en profondeur — préfixe, infixe, postfixe — et l'on écrit avec un accumulateur, jamais avec
@.