Skip to main content Informatique en prépa - Lycée Malherbe 

3- Arbres

I. Arbres binaires

a) Définitions

Définition : arbre binaire

Un arbre binaire stockant des données d'un ensemble est défini par induction comme :

  • 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 sont appelés étiquettes d'un nœud, et les deux arbres associés à un nœud interne sont communément appelés fils gauche et fils droit.

Par exemple, voici un arbre binaire de recherche stockant les nombres 1, 2, 3, 4, 5, 6, 9, 10 :

4 4 2 2 4--2 9 9 4--9 1 1 2--1 3 3 2--3 5 5 9--5 10 10 9--10 6 6 5--6

Voici un arbre préfixe représentant les onomatopées "hop", "hoh", "hip", "hips" et "hih" :

h1 h o o h1--o i i h1--i p1 p o--p1 h2 h o--h2 p2 p i--p2 h3 h i--h3 s s p2--s

Et voici l'arbre d'opération de :

plus + fois × plus--fois div ÷ plus--div x1 x fois--x1 y y fois--y carre ² div--carre z z div--z x2 x carre--x2

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 :

OCaml

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 :

OCaml

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 :

OCaml

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) :

OCaml

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).

OCaml

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 un arbre binaire. On note sa taille et sa hauteur. Alors .

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 un arbre binaire. On note son nombre de feuilles et sa hauteur. Alors .

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 , est défini par induction comme :

  • 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 :

sport Je veux faire du sport Soleil Soleil sport--Soleil Pluie Pluie sport--Pluie Neige Neige sport--Neige footing1 Footing footing2 Footing tres Très motivée tres--footing2 moyen Moyennement motivée Escalade Escalade moyen--Escalade pas Pas motivée Sieste Sieste pas--Sieste Soleil--footing1 Pluie--tres Pluie--moyen Pluie--pas Ski Ski Neige--Ski

b) Implémentation

On utilise le type suivant :

OCaml

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 :

Par exemple, pour calculer la somme des étiquettes d'un arbre général, avec une fonction auxiliaire :

OCaml

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 :

OCaml

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)))
;;
OCaml

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;;
OCaml
code masqué : pas besoin de le lire pour suivre, exécutez-le simplement

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) :

OCaml
code masqué : pas besoin de le lire pour suivre, exécutez-le simplement

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.

OCaml
code masqué : pas besoin de le lire pour suivre, exécutez-le simplement

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 :

OCaml

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;;
OCaml
code masqué : pas besoin de le lire pour suivre, exécutez-le simplement

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 , on obtient 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é tel que, si est l'étiquette d'un nœud :

  • 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 :

7 7 5 5 7--5 12 12 7--12 1 1 5--1 6 6 5--6 10 10 12--10 34 34 12--34 8 8 10--8 11 11 10--11 28 28 34--28

b) Implémentation OCaml

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);;
OCaml

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.

OCaml

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 :

OCaml
code masqué : pas besoin de le lire pour suivre, exécutez-le simplement

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 la hauteur de l'arbre, elles sont donc en .

Cas 1 : l'arbre est complet. Supposons qu'on ait un arbre binaire strict , tel que toutes les feuilles soient à la même profondeur. On dit qu'il est complet.

Exprimez la hauteur de l'arbre en fonction de sa taille .

.

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 , où est la taille de l'arbre. C'est efficace !

Cas 2 : l'arbre est une branche. Reprenons l'exemple précédent, mais en insérant simplement les entiers dans l'ordre croissant :

suite ... 1 1 5 5 1--5 6 6 5--6 7 7 6--7 8 8 7--8 8--suite

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 !

OCaml
code masqué : pas besoin de le lire pour suivre, exécutez-le simplement

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 quoi qu'il arrive.

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).

OCaml

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é ?

OCaml

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é , où est la hauteur de l'arbre.

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))