3- Arbres
I. Arbres binaires
a) Définitions
Définition : arbre binaire
Un arbre binaire stockant des données d'un ensemble
- soit l'arbre vide ;
- soit une feuille contenant un élément de
; - soit un nœud interne contenant un ou deux arbres et un élément de
.
On appelle nœud soit un nœud interne, soit une feuille.
Les éléments de
Par exemple, voici un arbre binaire de recherche stockant les nombres 1, 2, 3, 4, 5, 6, 9, 10 :
Voici un arbre préfixe représentant les onomatopées "hop", "hoh", "hip", "hips" et "hih" :
Et voici l'arbre d'opération de
Pour ces trois exemples, la structure de l'arbre est identique, mais les données stockées sont de nature différente.
Définition : arbre binaire strict
Un arbre binaire strict est un arbre binaire où tous les nœuds internes ont exactement deux fils.
Attention aux définitions : parfois, on appelle "arbre binaire" des arbres binaires stricts.
b) Propriétés des arbres
On prendra pour exemple l'arbre binaire de recherche ci-dessus (de racine 4).
Définition : racine
La racine d'un arbre est son nœud d'origine (le plus en haut dans la représentation).
Ici, 4 est la racine de l'arbre.
Définition : profondeur
La profondeur d'un nœud est sa distance à la racine.
Ici, 4 est à une profondeur de 0, et 5 est à une profondeur de 2.
Définition : taille
La taille d'un arbre est son nombre de nœuds.
Ici, la taille est 8.
Définition : hauteur
La hauteur d'un arbre est la profondeur maximum des nœuds de l'arbre.
Par définition, l'arbre vide a une hauteur de
Ici, la hauteur est 3, car 6 est à une profondeur de 3.
Définition : père, fils, descendant, sous-arbre enraciné
- Un nœud est le fils d'un autre si c'est son fils droit ou son fils gauche. Ce dernier est alors le père de son fils.
- Les descendants d'un nœud sont ses fils, et les descendants de ses fils.
- Le sous-arbre enraciné en un nœud est composé de ce nœud et de tous ses descendants.
c) Et en OCaml ?
On peut définir des arbres binaires de la manière suivante :
Loading editor...
type arbre_binaire =
| Vide
| N of arbre_binaire * int * arbre_binaire;;Un nœud avec un seul fils peut être représenté par un fils vide. Une feuille peut être représentée par deux fils vides.
Les arbres binaires stricts non vides sont le plus souvent représentés par :
Loading editor...
type arbre_binaire_strict =
| F of int
| N of arbre_binaire_strict * int * arbre_binaire_strict;;C'est la forme la plus courante d'arbres binaires.
Les fonctions sur les propriétés les plus courantes peuvent être définies de la manière suivante :
Loading editor...
let rec taille a = match a with
| F _ -> 1
| N (fg, _, fd) -> 1 + (taille fg) + (taille fd)
;;
let rec hauteur a = match a with
| F _ -> 0
| N (fg, _, fd) -> 1 + max (hauteur fg) (hauteur fd)
;;Par exemple, avec l'arbre binaire strict dont la racine est 1, et dont les fils sont la feuille 2 et le nœud 3 (de fils 4 et 5) :
Loading editor...
let exemple = N (F 2, 1, N (F 4, 3, F 5));; taille exemple;; hauteur exemple;;
Pour dessiner un arbre, le site fournit une fonction afficher_arbre. Comme elle ne connaît pas notre type, on lui donne une fonction qui "découpe" un arbre : elle renvoie None pour l'arbre vide, et sinon Some du couple (étiquette sous forme de chaîne de caractères, liste des fils).
Loading editor...
let decoupe_strict a = match a with
| F x -> Some (string_of_int x, [])
| N (fg, x, fd) -> Some (string_of_int x, [fg; fd]);;
afficher_arbre decoupe_strict exemple;;d) Preuves sur les arbres binaires
Propriété : taille et hauteur
Soit
Preuve
On fait une preuve par récurrence forte :
- (cas de base) si
est une feuille, ; - (hérédité) soit
un arbre de hauteur . Comme et sont de hauteur au plus , on a par récurrence : et .
Donc :
Cela conclut la preuve.
Propriété : feuilles et hauteur
Soit
Preuve
- (initialisation) si
est une feuille, ; - (hérédité) soit
un arbre de hauteur . Comme et sont de hauteur au plus , on a par récurrence : et .
Donc :
Cela conclut la preuve.
II. Arbres généraux
a) Définition
Définition : arbre général
Un arbre général, ou simplement "arbre", stockant des données d'un ensemble
- soit l'arbre vide ;
- soit un nœud contenant un élément de
, ainsi qu'un nombre quelconque d'arbres non vides (ses fils).
Un nœud avec aucun fils est une feuille. Un nœud avec au moins un fils est un nœud interne.
Le vocabulaire sur les arbres est identique à celui des arbres binaires.
Par exemple, un arbre de décision :
b) Implémentation
On utilise le type suivant :
Loading editor...
type arbre =
| Vide
| N of int * arbre list;;Cela complique un petit peu toutes les fonctions ! La dernière fois, on traitait explicitement chacun des fils de notre arbre. Ici, ce n'est pas possible !
Deux solutions :
- une fonction auxiliaire ;
- être astucieux sur la récurrence.
Par exemple, pour calculer la somme des étiquettes d'un arbre général, avec une fonction auxiliaire :
Loading editor...
let rec somme (arb: arbre): int =
(*calcule la somme des sommes des arbres dans une liste d'arbres*)
let rec aux (l: arbre list) : int = match l with
| [] -> 0
| a::q -> (somme a) + (aux q)
in
match arb with
| Vide -> 0
| N (x, l) -> x + (aux l)
;;Ou bien en étant astucieux sur la récurrence :
Loading editor...
let rec somme (arb: arbre): int =
match arb with
| Vide -> 0
| N (x, []) -> x
| N (x, a::q) -> (somme a) + (somme (N (x, q)))
;;Loading editor...
(* un nœud 1, de fils 2 (lui-même de fils 4 et 5) et 3 *) let arbre_exemple = N (1, [N (2, [N (4, []); N (5, [])]); N (3, [])]);; somme arbre_exemple;;
III. Les parcours d'arbre
Un parcours d'arbre, c'est un ordre dans lequel on va visiter les nœuds de l'arbre. Il en existe deux principaux.
Cette partie est valable autant pour les arbres binaires que pour les arbres généraux. On traitera uniquement les concepts généraux des deux parcours principaux dans ce cours ; ils seront revus plus en détail lorsqu'on parlera de graphes. Voir visualgo.
a) Parcours en profondeur
Le parcours en profondeur est celui que vous avez utilisé avec des fonctions récursives, par exemple la fonction taille d'un arbre binaire. Il s'agit d'aller le plus loin possible, et de ne faire demi-tour que lorsqu'on arrive à une feuille.
Voici l'ordre dans lequel les nœuds sont visités, sur un exemple (la fonction animer_parcours_arbre du site anime un parcours, avec la même fonction de découpage que afficher_arbre) :
b) Parcours en largeur
Au contraire, le parcours en largeur consiste à parcourir d'abord tous les nœuds de profondeur 0, puis de profondeur 1, 2, etc.
Il est très compliqué à implémenter de manière récursive, et de même, de manière impérative, ce n'est pas simple.
c) Utilité
Globalement, le parcours en profondeur a l'avantage principal d'être plus simple à implémenter, et c'est donc celui qu'on utilise quand l'ordre des sommets parcourus n'a pas d'importance.
Le parcours en largeur a en revanche des avantages, en particulier sur des arbres très grands : si notre arbre a une hauteur extrêmement grande, mais qu'on ne veut parcourir que les sommets proches de la racine, c'est lui qu'on utilisera.
d) Ordres préfixe, infixe et postfixe
Dans un parcours en profondeur, on peut traiter la racine d'un sous-arbre à différents moments par rapport à ses fils :
- ordre préfixe : la racine, puis les fils (de gauche à droite) ;
- ordre postfixe : les fils, puis la racine ;
- ordre infixe (pour les arbres binaires) : le fils gauche, puis la racine, puis le fils droit.
Loading editor...
(* on redéfinit le type des arbres binaires : les constructeurs Vide et N
désignent pour l'instant ceux du type arbre (général) défini plus haut *)
type arbre_binaire =
| Vide
| N of arbre_binaire * int * arbre_binaire;;
let rec prefixe (a: arbre_binaire) : int list = match a with
| Vide -> []
| N (g, x, d) -> [x] @ prefixe g @ prefixe d;;
let rec infixe (a: arbre_binaire) : int list = match a with
| Vide -> []
| N (g, x, d) -> infixe g @ [x] @ infixe d;;
let rec postfixe (a: arbre_binaire) : int list = match a with
| Vide -> []
| N (g, x, d) -> postfixe g @ postfixe d @ [x];;
(* l'arbre binaire de recherche du début du chapitre *)
let a = N (N (N (Vide, 1, Vide), 2, N (Vide, 3, Vide)), 4,
N (N (Vide, 5, N (Vide, 6, Vide)), 9, N (Vide, 10, Vide)));;
prefixe a;;
infixe a;;
postfixe a;;L'ordre postfixe est celui dans lequel on termine le traitement des nœuds : c'est l'ordre naturel pour calculer quelque chose à partir des résultats des fils (la taille, la hauteur...). Pour l'arbre d'une expression, l'ordre postfixe donne la notation polonaise inverse : pour x y × x ² z ÷ +.
IV. Dictionnaire et arbre binaire de recherche
a) Définition
Définition : arbre binaire de recherche
Un arbre binaire de recherche est un arbre binaire étiqueté par un ensemble ordonné
- pour tout nœud descendant du fils gauche de
, d'étiquette , on a ; - pour tout nœud descendant du fils droit de
, d'étiquette , on a .
Par exemple, un arbre binaire de recherche contenant les entiers
b) Implémentation OCaml
Loading editor...
(*le type ocaml d'un arbre binaire de recherche sur les entiers*)
type abr =
| Vide
| N of abr * int * abr;;
(*recherche d'une valeur dans un arbre binaire de recherche*)
let rec recherche (a: abr) (x: int): bool = match a with
| Vide -> false
| N (ag, y, ad) -> if x = y then true
else if x < y then recherche ag x
else recherche ad x;;
(*ajout d'une valeur dans un abr*)
let rec ajout (a: abr) (x: int): abr = match a with
| Vide -> N (Vide, x, Vide)
| N (ag, y, ad) -> if x = y then a
else if x < y then N (ajout ag x, y, ad)
else N (ag, y, ajout ad x);;Loading editor...
(* construction de l'arbre de l'exemple par ajouts successifs *)
let rec ajout_liste (a: abr) (l: int list): abr = match l with
| [] -> a
| x::q -> ajout_liste (ajout a x) q;;
let exemple_abr = ajout_liste Vide [7; 5; 12; 1; 6; 10; 34; 8; 11; 28];;
recherche exemple_abr 11;;
recherche exemple_abr 9;;Pour les arbres binaires (avec un constructeur Vide), la fonction afficher_arbre_binaire prend une fonction de découpage qui renvoie Some (gauche, étiquette, droit) : un fils gauche vide est alors bien dessiné à gauche.
Loading editor...
let decoupe_abr a = match a with
| Vide -> None
| N (g, x, d) -> Some (g, string_of_int x, d);;
afficher_arbre_binaire decoupe_abr exemple_abr;;En parcourant l'arbre dans l'ordre infixe (fils gauche, puis la racine, puis le fils droit), on obtient les éléments dans l'ordre croissant :
On peut également construire un arbre binaire de recherche à partir d'une liste en réalisant des ajouts successifs à partir de l'arbre vide.
c) Complexité des opérations
Les opérations de recherche et d'ajout s'appellent récursivement une fois pour chaque niveau de l'arbre. Si on note
Cas 1 : l'arbre est complet. Supposons qu'on ait un arbre binaire strict
Exprimez la hauteur
Preuve par récurrence :
- si
, alors , et la relation est vérifiée ; - on suppose la propriété vraie pour les arbres complets de hauteur plus petite que
. Soit un arbre de hauteur , et et ses sous-arbres, de tailles et . Ils sont complets, et de hauteur . On a donc :
Ce qui conclut la récurrence.
Dans ce cas, les opérations sur l'arbre binaire de recherche se font en
Cas 2 : l'arbre est une branche. Reprenons l'exemple précédent, mais en insérant simplement les entiers dans l'ordre croissant :
Dans ce cas, la hauteur de l'arbre est égale à sa taille moins 1. On a des opérations en temps linéaire : pas mieux qu'une simple liste !
Cas 3 : et en général ? En pratique, avec des éléments aléatoires, on va souvent se retrouver dans un cas proche du cas 1. Mais pour le garantir, il existe des algorithmes avancés, qui permettent de toujours avoir un arbre équilibré, et d'avoir des complexités en
Exercices
Exercice 1
Écrire une fonction nb_feuilles : arbre_binaire -> int qui compte les feuilles d'un arbre binaire (les nœuds dont les deux fils sont vides).
Loading editor...
let rec nb_feuilles (a: arbre_binaire) : int = match a with
| Vide -> 0
| N (Vide, _, Vide) -> 1
| N (g, _, d) -> nb_feuilles g + nb_feuilles d;;Exercice 2
Écrire une fonction minimum : abr -> int qui renvoie le plus petit élément d'un arbre binaire de recherche non vide. Quelle est sa complexité ?
Loading editor...
Le minimum est le nœud le plus à gauche :
let rec minimum (a: abr) : int = match a with
| Vide -> failwith "arbre vide"
| N (Vide, x, _) -> x
| N (g, _, _) -> minimum g;; Complexité
Exercice 3
Quel arbre binaire a pour parcours préfixe [1; 2; 4; 5; 3] et pour parcours infixe [4; 2; 5; 1; 3] ?
La racine est le premier élément du préfixe : 1. Dans l'infixe, ce qui est à gauche de 1 ([4; 2; 5]) est le fils gauche, ce qui est à droite ([3]) le fils droit. On recommence sur le fils gauche : sa racine est 2, de fils 4 et 5.
N (N (N (Vide, 4, Vide), 2, N (Vide, 5, Vide)), 1, N (Vide, 3, Vide))