Prérequis : programmation orientée objet , récursivité
À l’issue de ce chapitre, vous saurez :
- utiliser le vocabulaire des arbres (racine, feuille, hauteur, taille, sous-arbre) ;
- implémenter un arbre binaire en Python à l’aide de classes ;
- écrire des algorithmes récursifs sur les arbres (taille, hauteur, nombre de feuilles) ;
- distinguer et implémenter les parcours en profondeur (préfixe, infixe, suffixe) et en largeur ;
- définir un arbre binaire de recherche et effectuer une recherche en \(O(\log n)\).
Introduction aux arbres
Un arbre est une structure de données hiérarchique composée de nœuds reliés par des arêtes. Mathématiquement, un arbre est un graphe connexe et acyclique (sans cycle).
On retrouve des arbres dans de nombreuses situations :
- l’arborescence d’un système de fichiers ;
- un arbre généalogique ;
- la structure d’un document HTML (DOM) ;
- l’organisation hiérarchique d’une entreprise.
Vocabulaire des arbres
Nœuds particuliers
Dans un arbre, on distingue trois types de nœuds selon leur position :
- la racine : unique nœud sans parent, situé au sommet de l’arbre ;
- les feuilles : nœuds sans fils, situés aux extrémités ;
- les nœuds internes : nœuds ayant au moins un fils.
Relations entre nœuds
Les relations s’expriment avec un vocabulaire familial :
- le père (ou parent) d’un nœud est le nœud directement au-dessus de lui ;
- les fils (ou enfants) d’un nœud sont les nœuds directement en dessous.
Caractéristiques chiffrées
Plusieurs mesures permettent de caractériser la structure :
- le degré d’un nœud est son nombre de fils ;
- la profondeur d’un nœud est le nombre d’arêtes entre la racine et ce nœud (la racine a une profondeur de 0) ;
- la hauteur d’un arbre est la profondeur maximale de ses feuilles (un arbre vide a une hauteur de \(-1\), un arbre réduit à sa racine a une hauteur de 0) ;
- la taille d’un arbre est son nombre total de nœuds.
Exercice : analyse d’un arbre
Soit l’arbre suivant :
A
/ | \
B C D
/ \ \
E F G
/ \
H I
Identifier les nœuds particuliers :
- Racine : …
- Feuilles : …
- Nœuds internes : …
Identifier les relations :
- Père de E : …
- Fils de B : …
Calculer les caractéristiques :
- Degré de A : …
- Degré de B : …
- Profondeur de G : …
- Profondeur de I : …
- Hauteur : …
- Taille : …
Solution
Nœuds particuliers :
- Racine : A
- Feuilles : H, I, F, G, D (nœuds sans fils)
- Nœuds internes : A, B, C, E (nœuds avec au moins un fils)
Relations :
- Père de E : B
- Fils de B : E, F
Caractéristiques :
- Degré(A) = 3
- Degré(B) = 2
- Profondeur(G) = 2
- Profondeur(I) = 3
- Hauteur = 3
- Taille = 9
Vérifiez votre compréhension
- Quelle est la différence entre la hauteur et la taille d’un arbre ?
Réponse
La taille est le nombre total de nœuds. La hauteur est la longueur du plus long chemin de la racine à une feuille (nombre d'arêtes). - Un arbre peut-il avoir plusieurs racines ?
Réponse
Non. Par définition, un arbre possède exactement une racine (le seul nœud sans parent). S'il y avait plusieurs racines, ce serait une forêt. - Quelle relation lie le nombre de feuilles et le nombre de nœuds internes dans un arbre binaire complet ?
Réponse
Dans un arbre binaire complet (chaque nœud a 0 ou 2 fils), le nombre de feuilles est égal au nombre de nœuds internes + 1.
Arbres binaires
Un arbre binaire est un arbre où chaque nœud possède au plus deux fils, appelés fils gauche et fils droit.
C’est une structure définie de manière récursive. Un arbre binaire peut être :
- vide (aucun nœud) ;
- non vide : composé d’une racine et de deux sous-arbres (gauche et droit), eux-mêmes des arbres binaires.
Interface d’un arbre binaire
L’interface décrit les opérations disponibles sur un arbre binaire a, indépendamment de la façon dont il est programmé ; l’implémentation ci-dessous les réalise avec une classe Arbre dont les attributs sont valeur, gauche et droit, l’arbre vide étant représenté par None.
| Opération | Description | Dans l’implémentation ci-dessous |
|---|---|---|
est_vide(a) | Teste si l’arbre est vide | a is None |
racine(a) | Renvoie la valeur de la racine | a.valeur |
gauche(a) | Renvoie le sous-arbre gauche | a.gauche |
droit(a) | Renvoie le sous-arbre droit | a.droit |
est_feuille(a) | Teste si la racine est une feuille | a.est_feuille() |
Précondition : les opérations racine, gauche, droit et est_feuille ne s’appliquent qu’à un arbre non vide. Une autre implémentation (par exemple une liste de listes, ou une classe avec un objet « arbre vide ») garderait la même interface : les algorithmes écrits avec ces opérations resteraient valables.
Implémentation en Python
class Arbre:
def __init__(self, valeur, gauche=None, droit=None):
self.valeur = valeur
self.gauche = gauche
self.droit = droit
def __repr__(self):
if self.gauche is None and self.droit is None:
return str(self.valeur)
return str(self.valeur) + "-(" + str(self.gauche) + "," + str(self.droit) + ")"
def est_vide(self):
return False
def est_feuille(self):
return self.gauche is None and self.droit is None
Remarque : dans cette implémentation, un arbre vide est représenté par
None, et un arbre non vide par un objetArbre. La méthodeest_vide()renvoie toujoursFalsecar elle n’est appelée que sur un objet existant.
Exercice : construction d’un arbre binaire
- Écrire le code Python pour construire l’arbre binaire suivant :
5
/ \
3 8
/ \ / \
1 4 7 9
- Que renvoient les expressions suivantes ?
a.valeura.gauche.valeura.droit.gauche.valeura.gauche.est_feuille()a.gauche.gauche.est_feuille()
Solution
- Construction de l’arbre :
a = Arbre(5,
Arbre(3, Arbre(1), Arbre(4)),
Arbre(8, Arbre(7), Arbre(9)))
- Valeurs retournées :
a.valeur→5(racine)a.gauche.valeur→3(fils gauche de la racine)a.droit.gauche.valeur→7(fils gauche du fils droit)a.gauche.est_feuille()→False(le nœud 3 a deux fils)a.gauche.gauche.est_feuille()→True(le nœud 1 n’a pas de fils)
Algorithmes récursifs sur les arbres
La structure récursive des arbres binaires permet d’écrire des algorithmes élégants. Pour chaque fonction, on traitera d’abord le cas de base (arbre vide), puis l’étape de récurrence.
Exercice : implémentation de la taille
Compléter la fonction taille(arbre) :
def taille(arbre):
if arbre is None:
return ...
return ...
Solution
def taille(arbre):
if arbre is None:
return 0
return 1 + taille(arbre.gauche) + taille(arbre.droit)
Exercice : implémentation de la hauteur
Compléter la fonction hauteur(arbre) :
def hauteur(arbre):
if arbre is None:
return ...
return ...
Solution
def hauteur(arbre):
if arbre is None:
return -1
return 1 + max(hauteur(arbre.gauche), hauteur(arbre.droit))
Exercice : nombre de feuilles
Écrire une fonction nb_feuilles(arbre) qui renvoie le nombre de feuilles.
Solution
def nb_feuilles(arbre):
if arbre is None:
return 0
if arbre.gauche is None and arbre.droit is None:
return 1
return nb_feuilles(arbre.gauche) + nb_feuilles(arbre.droit)
Parcours d’arbres binaires
Un parcours permet de visiter tous les nœuds d’un arbre dans un certain ordre.
Parcours en profondeur (DFS)
On distingue trois types de parcours selon l’ordre de traitement de la racine :
- préfixe : nœud, gauche, droit — on traite le nœud avant ses fils ;
- infixe : gauche, nœud, droit — on traite le nœud entre ses fils ;
- suffixe : gauche, droit, nœud — on traite le nœud après ses fils.
def prefixe(arbre):
if arbre is not None:
print(arbre.valeur, end=' ')
prefixe(arbre.gauche)
prefixe(arbre.droit)
def infixe(arbre):
if arbre is not None:
infixe(arbre.gauche)
print(arbre.valeur, end=' ')
infixe(arbre.droit)
def suffixe(arbre):
if arbre is not None:
suffixe(arbre.gauche)
suffixe(arbre.droit)
print(arbre.valeur, end=' ')
Exercice : parcours en profondeur
Donner l’ordre de visite pour chaque parcours de l’arbre suivant :
5
/ \
3 8
/ \ / \
1 4 7 9
- Préfixe : …
- Infixe : …
- Suffixe : …
Solution
- Préfixe (N, G, D) : 5, 3, 1, 4, 8, 7, 9
- Infixe (G, N, D) : 1, 3, 4, 5, 7, 8, 9 (notez l’ordre croissant !)
- Suffixe (G, D, N) : 1, 4, 3, 7, 9, 8, 5
Parcours en largeur (BFS)
Le parcours en largeur visite les nœuds niveau par niveau, de gauche à droite. Il utilise une file (FIFO : First In, First Out).
def largeur(arbre):
if arbre is None:
return
file = [arbre]
while file:
noeud = file.pop(0)
print(noeud.valeur, end=' ')
if noeud.gauche is not None:
file.append(noeud.gauche)
if noeud.droit is not None:
file.append(noeud.droit)
Exercice : trace du parcours en largeur
Compléter la trace d’exécution du parcours en largeur sur l’arbre précédent :
| Étape | Contenu de la file | Nœud traité |
|---|---|---|
| 0 | [5] | — |
| 1 | 5 | |
| 2 | ||
| 3 | ||
| 4 | ||
| 5 | ||
| 6 | ||
| 7 |
Solution
| Étape | Contenu de la file | Nœud traité |
|---|---|---|
| 0 | [5] | — |
| 1 | [3, 8] | 5 |
| 2 | [8, 1, 4] | 3 |
| 3 | [1, 4, 7, 9] | 8 |
| 4 | [4, 7, 9] | 1 |
| 5 | [7, 9] | 4 |
| 6 | [9] | 7 |
| 7 | [] | 9 |
Ordre de visite : 5, 3, 8, 1, 4, 7, 9 (niveau par niveau)
Vérifiez votre compréhension
- Dans un parcours infixe d’un ABR, dans quel ordre obtient-on les valeurs ?
Réponse
Dans l'ordre croissant. C'est d'ailleurs une propriété caractéristique des ABR : le parcours infixe produit une séquence triée. - Quelle structure de données utilise-t-on pour implémenter un parcours en largeur ?
Réponse
Une file (FIFO). On enfile la racine, puis on défile un nœud, on traite sa valeur et on enfile ses fils. - Le parcours préfixe d’un arbre donne
5, 3, 1, 4, 8, 7, 9. Quel est le premier nœud visité ? Quel est la racine ?Réponse
Le premier nœud visité est 5, et c'est aussi la racine : en parcours préfixe, la racine est toujours visitée en premier.
Arbres binaires de recherche (ABR)
Un ABR est un arbre binaire tel que pour tout nœud :
- toutes les valeurs du sous-arbre gauche sont strictement inférieures à la valeur du nœud ;
- toutes les valeurs du sous-arbre droit sont strictement supérieures à la valeur du nœud.
Une propriété fondamentale est que le parcours infixe d’un ABR donne les valeurs dans l’ordre croissant.
Exercice : reconnaissance d’ABR
Parmi ces arbres, lesquels sont des ABR ? Justifier.
Arbre 1 :
6
/ \
4 9
/ \ / \
2 5 7 10
Arbre 2 :
6
/ \
4 9
/ \ \
2 8 10
Arbre 3 :
6
/ \
3 8
/ \ \
1 4 9
Solution
- Arbre 1 : OUI, c’est un ABR.
- Arbre 2 : NON. Le nœud 8 est à gauche de 6, or \(8 > 6\).
- Arbre 3 : OUI. Parcours infixe = 1, 3, 4, 6, 8, 9 (trié).
Recherche dans un ABR
def rechercher(arbre, x):
if arbre is None:
return False
if x == arbre.valeur:
return True
elif x < arbre.valeur:
return rechercher(arbre.gauche, x)
else:
return rechercher(arbre.droit, x)
Complexité
- Cas moyen (arbre équilibré) : \(O(\log n)\) — on divise par 2 à chaque étape.
- Pire cas (arbre dégénéré) : \(O(n)\) — l’arbre est comme une liste.
Exercice : insertion dans un ABR
Écrire la fonction inserer(arbre, x) qui insère l’élément x dans l’ABR arbre.
Solution
def inserer(arbre, x):
if arbre is None:
return Arbre(x)
if x < arbre.valeur:
arbre.gauche = inserer(arbre.gauche, x)
elif x > arbre.valeur:
arbre.droit = inserer(arbre.droit, x)
return arbre
Exercice : construction d’ABR
On insère successivement les valeurs 5, 2, 8, 1, 3, 7, 9 dans un ABR initialement vide.
Dessiner l’ABR obtenu après chaque insertion.
Quelle serait la forme de l’arbre si on insérait les valeurs dans l’ordre 1, 2, 3, 5, 7, 8, 9 ?
Comparer les hauteurs des deux arbres et expliquer la conséquence sur les performances.
Solution
Arbre final : 5 (racine), fils gauche 2 (fils 1, 3), fils droit 8 (fils 7, 9). Hauteur = 2 (équilibré).
Avec l’ordre trié : 1 (racine), fils droit 2, fils droit 3, etc. Arbre peigne (dégénéré) vers la droite. Hauteur = 6.
L’arbre dégénéré se comporte comme une liste (\(O(n)\)), perdant l’avantage logarithmique (\(O(\log n)\)) de l’arbre équilibré.
- Un arbre est une structure hiérarchique composée de nœuds reliés par des arêtes, avec une racine unique et sans cycle.
- Un arbre binaire limite chaque nœud à deux fils au maximum ; on l’implémente en Python avec une classe contenant
valeur,gaucheetdroit. - Les algorithmes sur les arbres sont naturellement récursifs : la taille, la hauteur et le comptage des feuilles s’expriment chacun en quelques lignes.
- On distingue quatre parcours : préfixe (racine-gauche-droite), infixe (gauche-racine-droite), suffixe (gauche-droite-racine) et en largeur (niveau par niveau, à l’aide d’une file).
- Un ABR ordonne ses valeurs (gauche < racine < droite), permettant une recherche en \(O(\log n)\) dans le cas équilibré, mais \(O(n)\) dans le cas dégénéré (arbre peigne).