Adloun

Les arbres

Cours complet · NSI (terminale), chapitre 3 · terminale, spécialité numérique et sciences informatiques

Travailler ce chapitre sur Adloun Exercices corrigés de ce chapitre

Les structures linéaires comme les listes, les piles ou les files organisent les données les unes à la suite des autres. Mais de nombreuses informations du monde réel possèdent une organisation hiérarchique : l'arborescence des fichiers d'un disque dur, l'arbre généalogique d'une famille, le sommaire d'un livre, ou encore les coups possibles dans une partie d'échecs. Pour modéliser ces situations, on utilise une structure de données fondamentale : l'arbre.

Dans ce chapitre, nous définissons précisément le vocabulaire des arbres, nous étudions le cas particulier très important des arbres binaires, leurs différents parcours, puis nous nous concentrons sur les arbres binaires de recherche (ABR), une structure qui permet de rechercher, d'insérer et de supprimer efficacement des valeurs. Toutes les implémentations seront réalisées en Python à l'aide d'une classe et de la récursivité, omniprésente dès que l'on manipule des arbres.

3.1 Vocabulaire des arbres

Un arbre est une structure composée de nœuds reliés par des liens. Chaque nœud peut posséder des « enfants », mais possède au plus un « parent ».

Définition 3.1Arbre

Un arbre est un ensemble de nœuds organisé hiérarchiquement de la façon suivante :

  • soit l'arbre est vide ;
  • soit il possède un nœud particulier appelé racine, auquel sont rattachés des sous-arbres, eux-mêmes des arbres.

Cette définition est récursive : un arbre est défini à partir d'arbres plus petits.

On adopte le vocabulaire suivant, partiellement emprunté à la généalogie et à la botanique (mais l'arbre informatique est traditionnellement dessiné la racine en haut !).

Définition 3.2Vocabulaire des nœuds
  • La racine est l'unique nœud sans parent (le sommet de l'arbre).
  • Un nœud interne possède au moins un enfant.
  • Une feuille (ou nœud externe) est un nœud sans enfant.
  • Le parent d'un nœud est le nœud situé juste au-dessus de lui ; ses enfants (ou fils) sont les nœuds situés juste en dessous.
  • Deux nœuds ayant le même parent sont des nœuds frères.
  • Un sous-arbre est l'arbre formé par un nœud et tous ses descendants.

Considérons l'arbre suivant, représenté textuellement par indentation (chaque niveau d'indentation correspond à une descente d'un niveau dans l'arbre) :

Exemple 3.3Un arbre et son vocabulaire

A                <- racine
|-- B            <- enfant de A
|   |-- D        <- feuille
|   |-- E        <- feuille
|-- C            <- enfant de A
    |-- F        <- feuille

Ici : A est la racine ; B et C sont ses enfants (et sont frères) ; D, E et F sont des feuilles ; A, B et C sont des nœuds internes. Le nœud B avec ses descendants D et E forme un sous-arbre.

On définit ensuite plusieurs grandeurs numériques qui mesurent la « taille » et la « forme » d'un arbre.

Définition 3.4Profondeur, hauteur, taille

Soit un arbre non vide.

  • La profondeur d'un nœud est le nombre de liens qui le séparent de la racine. La racine est à la profondeur ; ses enfants à la profondeur , etc.
  • La hauteur de l'arbre est la profondeur maximale atteinte par une feuille, c'est-à-dire la longueur du plus long chemin de la racine vers une feuille. Un arbre réduit à sa racine a une hauteur de .
  • La taille de l'arbre est son nombre total de nœuds.
iRemarque

La convention sur la hauteur (et la profondeur de la racine) varie selon les ouvrages : certains comptent la racine à la profondeur et donnent la hauteur en nombre de niveaux. Dans ce manuel, nous adoptons la convention « racine à la profondeur », qui est la plus répandue en NSI. Il faut toujours vérifier la convention utilisée dans un énoncé.

Exemple 3.5Calcul des grandeurs

Reprenons l'arbre précédent.

  • A est à la profondeur , B et C à la profondeur , D, E et F à la profondeur .
  • La hauteur de l'arbre vaut (la feuille la plus profonde est à la profondeur ).
  • La taille vaut (les nœuds A, B, C, D, E, F).

3.2 Les arbres binaires

Parmi tous les arbres, un cas particulier est essentiel : celui où chaque nœud possède au plus deux enfants.

Définition 3.6Arbre binaire

Un arbre binaire est un arbre tel que chaque nœud possède au plus deux enfants, distingués comme enfant gauche et enfant droit. De façon récursive, un arbre binaire est :

  • soit l'arbre vide ;
  • soit un nœud (la racine) muni d'un sous-arbre gauche et d'un sous-arbre droit, tous deux des arbres binaires.
iRemarque

La distinction gauche/droite est importante : deux arbres binaires ayant les mêmes nœuds mais où les sous-arbres gauche et droit sont échangés sont considérés comme différents.

On distingue quelques formes particulières d'arbres binaires.

Définition 3.7Arbres binaires particuliers
  • Un arbre binaire est complet (ou parfait) lorsque tous les niveaux sont entièrement remplis : chaque nœud interne possède exactement deux enfants et toutes les feuilles sont à la même profondeur.
  • Un arbre binaire est dégénéré (ou filiforme) lorsque chaque nœud interne possède un seul enfant : l'arbre se comporte alors comme une liste chaînée.
Proposition 3.8Nombre de nœuds et hauteur

Pour un arbre binaire de hauteur :

  • le nombre maximal de nœuds est (cas de l'arbre complet) ;
  • le nombre maximal de feuilles est ;
  • un arbre binaire de taille a une hauteur comprise entre (arbre équilibré) et (arbre dégénéré).

Ainsi un arbre binaire bien équilibré contenant nœuds a une hauteur de l'ordre de , ce qui est la clé de l'efficacité des structures que nous verrons plus loin.

3.2.1 Implémentation en Python

Pour représenter un arbre binaire, on définit une classe Noeud. Chaque nœud contient une valeur et deux références (gauche et droit) vers ses sous-arbres, qui valent None en l'absence d'enfant. L'arbre vide est représenté par None.

Méthode : Définir la classe Noeud

On crée une classe dont le constructeur prend la valeur du nœud et, optionnellement, ses deux enfants.


class Noeud:
    def __init__(self, valeur, gauche=None, droit=None):
        self.valeur = valeur
        self.gauche = gauche      # sous-arbre gauche (Noeud ou None)
        self.droit = droit        # sous-arbre droit (Noeud ou None)

# Construction de l'arbre :
#        1
#       / \
#      2   3
#     / \
#    4   5
arbre = Noeud(1,
              Noeud(2, Noeud(4), Noeud(5)),
              Noeud(3))

Grâce à la définition récursive de l'arbre, la plupart des opérations s'écrivent naturellement par récursivité : on traite la racine, puis on rappelle la fonction sur les sous-arbres gauche et droit. Le cas de base est l'arbre vide (None).

Méthode : Calculer taille et hauteur récursivement


def taille(arbre):
    if arbre is None:          # cas de base : arbre vide
        return 0
    return 1 + taille(arbre.gauche) + taille(arbre.droit)

def hauteur(arbre):
    if arbre is None:          # arbre vide : hauteur -1 par convention
        return -1              # ainsi une feuille aura une hauteur 0
    return 1 + max(hauteur(arbre.gauche), hauteur(arbre.droit))

La fonction taille compte la racine () plus les nœuds de chaque sous-arbre. La fonction hauteur renvoie pour l'arbre vide, de sorte qu'une feuille (deux sous-arbres vides) obtienne . Ces deux fonctions ont un coût de car elles visitent chaque nœud une fois.

3.3 Les parcours d'arbres

Parcourir un arbre, c'est visiter tous ses nœuds dans un certain ordre. On distingue deux grandes familles de parcours : les parcours en profondeur (on descend le plus loin possible avant de revenir en arrière) et le parcours en largeur (on visite les nœuds niveau par niveau).

3.3.1 Les parcours en profondeur

Définition 3.9Parcours en profondeur

Les parcours en profondeur d'un arbre binaire se distinguent par le moment où l'on « traite » (par exemple où l'on affiche) la racine par rapport à ses sous-arbres :

  • Parcours préfixe (ou préordre) : racine, puis sous-arbre gauche, puis sous-arbre droit (R-G-D).
  • Parcours infixe (ou ordre symétrique) : sous-arbre gauche, puis racine, puis sous-arbre droit (G-R-D).
  • Parcours suffixe (ou postordre) : sous-arbre gauche, puis sous-arbre droit, puis racine (G-D-R).

Méthode : Implémenter les parcours en profondeur

Chaque parcours s'écrit en trois lignes récursives, en plaçant le traitement de la racine (ici un append) au bon endroit.


def prefixe(arbre, resultat):
    if arbre is not None:
        resultat.append(arbre.valeur)   # 1. racine
        prefixe(arbre.gauche, resultat) # 2. gauche
        prefixe(arbre.droit, resultat)  # 3. droit

def infixe(arbre, resultat):
    if arbre is not None:
        infixe(arbre.gauche, resultat)  # 1. gauche
        resultat.append(arbre.valeur)   # 2. racine
        infixe(arbre.droit, resultat)   # 3. droit

def suffixe(arbre, resultat):
    if arbre is not None:
        suffixe(arbre.gauche, resultat) # 1. gauche
        suffixe(arbre.droit, resultat)  # 2. droit
        resultat.append(arbre.valeur)   # 3. racine

On appelle par exemple prefixe(arbre, res) avec une liste res initialement vide, qui contiendra l'ordre de visite à la fin.

Exemple 3.10Les trois parcours sur un exemple

Reprenons l'arbre construit plus haut :


#        1
#       / \
#      2   3
#     / \
#    4   5
  • Parcours préfixe : 1, 2, 4, 5, 3
  • Parcours infixe : 4, 2, 5, 1, 3
  • Parcours suffixe : 4, 5, 2, 3, 1

On remarque que le parcours préfixe affiche toujours la racine 1 en premier, et que le parcours suffixe l'affiche en dernier.

3.3.2 Le parcours en largeur

Définition 3.11Parcours en largeur

Le parcours en largeur (en anglais BFS, Breadth-First Search) visite les nœuds par niveaux croissants de profondeur : d'abord la racine (niveau ), puis tous les nœuds de profondeur de gauche à droite, puis ceux de profondeur , etc.

Méthode : Implémenter le parcours en largeur avec une file

Le parcours en largeur ne s'écrit pas naturellement par récursivité : il utilise une file (FIFO). On y place la racine, puis tant que la file n'est pas vide on en retire un nœud, on le traite et on enfile ses enfants.


from collections import deque

def largeur(arbre):
    if arbre is None:
        return []
    resultat = []
    file = deque([arbre])           # on enfile la racine
    while file:
        noeud = file.popleft()      # on defile (FIFO)
        resultat.append(noeud.valeur)
        if noeud.gauche is not None:
            file.append(noeud.gauche)
        if noeud.droit is not None:
            file.append(noeud.droit)
    return resultat

Sur l'arbre précédent, ce parcours renvoie [1, 2, 3, 4, 5] : niveau par niveau. Comme chaque nœud est enfilé et défilé une seule fois, le coût est .

3.4 Les arbres binaires de recherche (ABR)

Les arbres binaires deviennent particulièrement utiles lorsqu'on impose une contrainte d'ordre sur les valeurs : on obtient alors une structure permettant des recherches très rapides.

Définition 3.12Arbre binaire de recherche

Un arbre binaire de recherche (ABR) est un arbre binaire dont chaque nœud vérifie la propriété suivante :

  • toutes les valeurs du sous-arbre gauche sont strictement inférieures à la valeur du nœud ;
  • toutes les valeurs du sous-arbre droit sont supérieures (ou égales, selon les conventions) à la valeur du nœud.

Cette propriété doit être vraie pour tous les nœuds de l'arbre, pas seulement pour la racine.

Exemple 3.13Un arbre binaire de recherche

#         8
#        / \
#       3   10
#      / \    \
#     1   6    14
#        / \   /
#       4   7 13

Vérifions la racine 8 : à gauche on trouve 3, 1, 6, 4, 7, tous inférieurs à ; à droite 10, 14, 13, tous supérieurs à . La propriété est aussi vraie localement, par exemple pour le nœud 6 : 4 à gauche () et 7 à droite ().

Proposition 3.14Parcours infixe d'un ABR

Le parcours infixe d'un arbre binaire de recherche renvoie les valeurs triées par ordre croissant. Sur l'exemple précédent, le parcours infixe donne 1, 3, 4, 6, 7, 8, 10, 13, 14. Cette propriété fournit un moyen simple de trier des données : les insérer dans un ABR puis effectuer un parcours infixe.

3.4.1 Recherche dans un ABR

Méthode : Rechercher une valeur

La propriété d'ordre permet de diviser l'espace de recherche par deux à chaque étape : si la valeur cherchée est plus petite que celle du nœud courant, on descend à gauche ; sinon à droite. On n'explore jamais les deux sous-arbres.


def recherche(arbre, x):
    if arbre is None:           # arbre vide : valeur absente
        return False
    if x == arbre.valeur:
        return True
    elif x < arbre.valeur:
        return recherche(arbre.gauche, x)   # on descend a gauche
    else:
        return recherche(arbre.droit, x)    # on descend a droite
Proposition 3.15Coût de la recherche

Le coût de la recherche dans un ABR est proportionnel à la hauteur de l'arbre.

  • Si l'arbre est équilibré, sa hauteur est de l'ordre de : la recherche est très efficace.
  • Si l'arbre est dégénéré (filiforme), sa hauteur vaut et la recherche coûte , soit aussi mal qu'une liste chaînée.

En moyenne, sur des insertions de valeurs aléatoires, la hauteur reste de l'ordre de , ce qui fait tout l'intérêt de la structure.

3.4.2 Insertion dans un ABR

Méthode : Insérer une valeur

Insérer revient à descendre dans l'arbre comme pour une recherche, jusqu'à trouver une place vide (None) où accrocher le nouveau nœud. On respecte ainsi automatiquement la propriété d'ordre.


def inserer(arbre, x):
    if arbre is None:                  # place libre trouvee
        return Noeud(x)
    if x < arbre.valeur:
        arbre.gauche = inserer(arbre.gauche, x)
    elif x > arbre.valeur:
        arbre.droit = inserer(arbre.droit, x)
    # si x == arbre.valeur, on ne l'insere pas (pas de doublon)
    return arbre

# Construction d'un ABR a partir d'une liste :
racine = None
for v in [8, 3, 10, 1, 6, 14, 4, 7, 13]:
    racine = inserer(racine, v)

La fonction renvoie la racine du sous-arbre (éventuellement nouveau) : c'est le motif récursif classique qui permet de « recoller » l'arbre modifié. L'insertion a le même coût que la recherche, soit où est la hauteur, c'est-à-dire en moyenne et dans le pire cas.

iRemarque

L'ordre d'insertion des valeurs détermine la forme de l'ABR. Insérer 1, 2, 3, 4, 5 dans cet ordre produit un arbre dégénéré (chaque valeur va systématiquement à droite), alors qu'un ordre mieux choisi donne un arbre équilibré. Des structures avancées (arbres AVL, arbres rouge-noir, hors programme) rééquilibrent automatiquement l'arbre pour garantir une hauteur .

Continuer sur Adloun : animation, QCM, fiches, exercices