Types sommes et arbres
Cours complet · OCaml (option informatique), chapitre 3 · prépas MPSI et MP, option informatique
Travailler ce chapitre sur Adloun Exercices corrigés de ce chapitre
<i class="fa-solid fa-compass mr-2" style="color:#9A563B"></i>3.1 Introduction et motivation
Jusqu'ici nous avons utilisé les types qu'OCaml fournit : entiers, booléens, n-uplets, listes, option. La puissance du langage vient de ce qu'on peut définir les siens, taillés sur mesure pour le problème traité. Modéliser un jeu de cartes, une expression arithmétique, un arbre généalogique : à chaque fois, un type bien choisi rend le code plus clair, et surtout permet au compilateur de vérifier qu'on a traité tous les cas.
Ce chapitre présente les types sommes (aussi appelés types énumérés, ou unions) : un type dont une valeur est l'une parmi plusieurs formes, chacune introduite par un constructeur. Le filtrage du chapitre précédent s'y applique directement et y prend tout son sens : un match sur un type somme est exhaustif exactement quand il couvre tous les constructeurs — et le compilateur le vérifie. On découvrira enfin les types récursifs, qui se définissent à partir d'eux-mêmes : les arbres, structure aussi fondamentale que la liste, et qui se traite par le même réflexe de récursion.
3.2 Déclarer un type
Le mot-clé type introduit un nouveau nom de type. Dans le cas le plus simple, un synonyme pour un type existant, qui documente l'intention :
type point = float * float
type chemin = point list
Un type peut être polymorphe : il dépend d'un (ou plusieurs) type(s) paramètre(s), notés 'a, 'b, …, écrits avant le nom.
type 'a paire = 'a * 'a (* paire homogène *)
3.3 Les types sommes
3.3.1 Types énumérés
La forme la plus simple d'un type somme énumère un nombre fini de constructeurs constants :
type couleur = Trefle | Carreau | Coeur | Pique
Les noms de constructeurs commencent obligatoirement par une majuscule (Trefle, Coeur), ce qui les distingue d'un coup d'œil des identifiants (variables, fonctions), qui commencent par une minuscule. Le séparateur entre constructeurs est la barre verticale |.
Une valeur de type couleur s'examine par filtrage : un match est exhaustif s'il couvre les quatre constructeurs.
let est_rouge c =
match c with
| Coeur | Carreau -> true
| Trefle | Pique -> false
Un même résultat peut regrouper plusieurs motifs avec | (« motif ou »). Si l'on ajoutait plus tard un constructeur au type couleur, le compilateur signalerait aussitôt que ce match n'est plus exhaustif : une aide précieuse pour faire évoluer un programme sans rien oublier.
3.3.2 Constructeurs porteurs de données
Un constructeur peut transporter des valeurs, déclarées après le mot-clé of :
type forme =
| Point
| Cercle of float (* rayon *)
| Rectangle of float * float (* largeur, hauteur *)
Ainsi Cercle 2.0 et Rectangle (3.0, 4.0) sont deux valeurs du même type forme. Le filtrage récupère au passage les données portées par chaque constructeur :
let aire f =
match f with
| Point -> 0.0
| Cercle r -> 3.14159 *. r *. r
| Rectangle (l, h) -> l *. h
Un type somme rend certains états impossibles à représenter : une forme est un point, un cercle ou un rectangle, jamais « un cercle avec une largeur et une hauteur ». Bien choisir ses constructeurs, c'est interdire à la racine les combinaisons absurdes — une grande source de robustesse.
3.3.3 Deux vieilles connaissances
Les types du chapitre précédent ne sont, en réalité, que des types sommes. Le type option est défini dans la bibliothèque standard par :
type 'a option = None | Some of 'a
None est un constructeur constant, Some porte une valeur de type 'a : c'est exactement le type somme polymorphe à deux cas qu'on filtrait au chapitre 2.
3.4 Les types récursifs : les arbres
Un type somme peut se référer à lui-même : il est alors récursif. C'est ainsi qu'on définit les structures de taille non bornée. La liste en est un exemple ; on pourrait la redéfinir nous-mêmes :
type 'a maliste = Vide | Cons of 'a * 'a maliste
Cons porte une tête et… une maliste (la queue) : la définition s'appuie sur le type en cours de définition. C'est exactement [] / ::, sans le sucre syntaxique.
3.4.1 Les arbres binaires
La structure récursive reine, après la liste, est l'arbre binaire : un arbre est vide, ou bien un nœud portant une valeur (l'étiquette) et deux sous-arbres.
type 'a arbre =
| Vide
| Noeud of 'a arbre * 'a * 'a arbre (* sous-arbre gauche, étiquette, droit *)
Vide est l'arbre sans nœud ; Noeud (g, x, d) a pour racine l'étiquette x, pour fils gauche g et pour fils droit d, tous deux des arbres. Une feuille est un nœud à deux fils vides : Noeud (Vide, x, Vide).
let a =
Noeud (Noeud (Vide, 1, Vide),
2,
Noeud (Vide, 3, Vide))
Cet arbre a pour racine 2, et pour fils les deux feuilles 1 et 3.
3.4.2 Récursion sur les arbres
Le réflexe est le même que sur les listes — un filtrage sur les deux formes — mais le cas récursif comporte deux appels, un par sous-arbre.
Méthode : Traiter un arbre par récursion
On filtre selon les deux constructeurs :
- cas de base
Vide: que vaut le résultat sur l'arbre vide ? - cas récursif
Noeud (g, x, d): comment combiner l'étiquettexavec les résultats des appels récursifs surget surd?
La terminaison est garantie : les sous-arbres sont strictement plus petits.
let rec taille a =
match a with
| Vide -> 0
| Noeud (g, _, d) -> 1 + taille g + taille d
On compte 1 pour le nœud courant, plus les tailles des deux sous-arbres. L'étiquette n'intervient pas (d'où _).
<i class="fa-solid fa-dumbbell mr-2" style="color:#2E7559"></i>3.5 Exercices résolus
Niveau (application directe du cours)
Déclarer un type énuméré jour (les sept jours), puis écrire est_weekend : jour -> bool.
Démonstration
type jour = Lundi | Mardi | Mercredi | Jeudi | Vendredi | Samedi | Dimanche
let est_weekend j =
match j with
| Samedi | Dimanche -> true
| _ -> false
Le motif _ regroupe les cinq jours ouvrés. On pourrait aussi tous les énumérer ; le _ est ici justifié car le résultat est le même pour tous, mais attention : avec _, le compilateur ne préviendra plus si l'on ajoute un jour. À doser.
Avec le type forme (Point, Cercle of float, Rectangle of float * float), écrire aire puis est_plus_grande f g qui teste si f a une aire strictement plus grande que g.
Démonstration
let aire f =
match f with
| Point -> 0.0
| Cercle r -> 3.14159 *. r *. r
| Rectangle (l, h) -> l *. h
let est_plus_grande f g = aire f > aire g
On réutilise aire dans est_plus_grande : une fonction bien nommée devient une brique. Les opérateurs sont flottants (*.), la comparaison > polymorphe.
Écrire taille et l'appliquer à l'arbre Noeud (Noeud (Vide, 1, Vide), 2, Noeud (Vide, 3, Vide)).
Démonstration
let rec taille a =
match a with
| Vide -> 0
| Noeud (g, _, d) -> 1 + taille g + taille d
Sur l'arbre proposé : 1 + taille (feuille 1) + taille (feuille 3) 1 + 1 + 1 = 3, chaque feuille valant 1 + 0 + 0.
Niveau (raisonnement intermédiaire)
Écrire hauteur a : pour l'arbre vide, et de plus que la hauteur du plus haut des deux sous-arbres sinon.
Démonstration
let rec hauteur a =
match a with
| Vide -> 0
| Noeud (g, _, d) ->
let hg = hauteur g and hd = hauteur d in
1 + (if hg > hd then hg else hd)
On nomme les deux hauteurs (let ... and ...) pour ne les calculer qu'une fois chacune, puis on prend la plus grande. L'arbre vide a hauteur 0, une feuille hauteur 1.
Écrire nb_feuilles a, le nombre de feuilles (nœuds à deux sous-arbres vides).
Démonstration
let rec nb_feuilles a =
match a with
| Vide -> 0
| Noeud (Vide, _, Vide) -> 1
| Noeud (g, _, d) -> nb_feuilles g + nb_feuilles d
On distingue trois cas par filtrage : l'arbre vide ( feuille), la feuille (Noeud (Vide, _, Vide), qui en est une), et le nœud interne (somme des feuilles des deux sous-arbres). L'ordre importe : le motif feuille, plus particulier, doit précéder le motif général Noeud (g, _, d).
Écrire miroir a qui renvoie l'arbre symétrique (fils gauche et droit échangés, récursivement).
Démonstration
let rec miroir a =
match a with
| Vide -> Vide
| Noeud (g, x, d) -> Noeud (miroir d, x, miroir g)
Le miroir de l'arbre vide est vide ; sinon on garde l'étiquette et on échange les deux sous-arbres après les avoir eux-mêmes mis en miroir. La fonction renvoie un nouvel arbre, sans modifier l'original (les valeurs sont immuables).
On modélise une expression par le type
type expr =
| Const of int
| Plus of expr * expr
| Fois of expr * expr
Écrire evalue : expr -> int et l'appliquer à Plus (Const 2, Fois (Const 3, Const 4)).
Démonstration
let rec evalue e =
match e with
| Const n -> n
| Plus (a, b) -> evalue a + evalue b
| Fois (a, b) -> evalue a * evalue b
Une expression est un arbre : les feuilles sont les constantes, les nœuds les opérations. L'évaluation suit la structure : evalue (Plus (Const 2, Fois (Const 3, Const 4))) 2 + (3 * 4) = 14. C'est le cœur d'un interpréteur.
Niveau (approfondissement)
Un arbre binaire de recherche (ABR) est un 'a arbre tel que, en tout nœud, toutes les étiquettes du sous-arbre gauche sont inférieures à l'étiquette, et celles du sous-arbre droit supérieures. Écrire appartient x a qui exploite cette propriété pour ne descendre que d'un côté.
Démonstration
let rec appartient x a =
match a with
| Vide -> false
| Noeud (g, v, d) ->
if x = v then true
else if x < v then appartient x g
else appartient x d
La propriété d'ABR permet, à chaque nœud, d'éliminer la moitié de l'arbre : si x < v, inutile de chercher à droite. Sur un arbre équilibré de nœuds, la recherche coûte — bien mieux que le parcours linéaire d'une liste.
Écrire insere x a qui renvoie un nouvel ABR contenant x (sans modifier a), en préservant la propriété d'ABR. Une valeur déjà présente laisse l'arbre inchangé.
Démonstration
let rec insere x a =
match a with
| Vide -> Noeud (Vide, x, Vide)
| Noeud (g, v, d) ->
if x = v then a
else if x < v then Noeud (insere x g, v, d)
else Noeud (g, v, insere x d)
On insère à la place du premier Vide rencontré en suivant la règle de comparaison. La reconstruction Noeud (insere x g, v, d) ne recopie que le chemin de la racine au point d'insertion : le reste de l'arbre est partagé, non dupliqué. C'est la version persistante (immuable) d'un ABR.
Écrire infixe : 'a arbre -> 'a list qui liste les étiquettes en ordre gauche – racine – droite. Que vaut ce parcours sur un ABR ? Discuter le coût.
Démonstration
let rec infixe a =
match a with
| Vide -> []
| Noeud (g, x, d) -> infixe g @ [x] @ infixe d
Sur un ABR, le parcours infixe renvoie les étiquettes triées par ordre croissant — c'est une propriété caractéristique. Côté coût, les @ répétés rendent ce parcours quadratique dans le pire cas ; une variante à accumulateur (aux g (x :: aux d acc)) le ramène en — même idée que le renverse du chapitre 2.
typedéclare un nom de type : synonyme, polymorphe ('a), ou somme.- Type somme : plusieurs constructeurs séparés par
|, en majuscule ; constants (Trefle) ou porteurs de données viaof(Cercle of float). - On examine un type somme par filtrage : exhaustif ssi tous les constructeurs sont couverts ; ajouter un constructeur fait signaler tous les
matchdevenus incomplets. option(None | Some of 'a) et la liste (Vide | Cons of 'a ...) sont* des types sommes.- Type récursif : un constructeur référence le type en cours — d'où les structures non bornées. Arbre binaire :
type 'a arbre = Vide | Noeud of 'a arbre 'a 'a arbre; feuilleNoeud (Vide, x, Vide). - Récursion sur arbre : cas
Videet casNoeud (g, x, d)avec deux appels (surgetd) ; terminaison par décroissance des sous-arbres. - ABR : gauche racine droite recherche/insertion en si équilibré ; parcours infixe étiquettes triées.
3.6 Exercices d'entraînement
Légende : application directe, raisonnement intermédiaire, approfondissement ; signale un classique incontournable. La numérotation prolonge celle des dix exercices résolus.
Thème A — Types énumérés et sommes simples.
- [11.] Déclarer
type feu = Vert | Orange | Rougeet écriresuivant : feu -> feu(le cycle d'un feu tricolore). - [12.] Avec
forme, écrireperimetre : forme -> float. - [13.] Déclarer
type 'a resultat = Ok of 'a | Erreur of stringet écriredivision_sure : int -> int -> int resultat(Erreursi le diviseur est nul).
Thème B — Mesures sur les arbres.
- [14.] Écrire
somme_arbre : int arbre -> int(somme des étiquettes). - [15.] Écrire
appartient_general x a(sans hypothèse d'ABR : parcourir tout l'arbre). - [16.] Écrire
est_equilibre a: en tout nœud, les hauteurs des deux sous-arbres diffèrent d'au plus .
Thème C — Transformer des arbres.
- [17.] Écrire
incremente_arbre : int arbre -> int arbre(chaque étiquette , nouvel arbre). - [18.] Sur le type
expr, écrirenb_operations : expr -> int(nombre dePlusetFois). - [19.] Étendre
expravecMoins of expr * expret adapterevalue; vérifier que le compilateur signale lematchdevenu non exhaustif avant correction.
Thème D — Arbres binaires de recherche.
- [20.] Écrire
de_liste : 'a list -> 'a arbrequi insère successivement les éléments d'une liste dans un ABR (initialementVide). - [21.] En déduire
trie : 'a list -> 'a list(construire un ABR puis le parcourir en infixe). Quel tri reconnaît-on ? - [22.] Écrire
minimum_abr : 'a arbre -> 'a optionrenvoyant la plus petite étiquette d'un ABR (descendre toujours à gauche).