Arbre binaire de recherche (ABR)
1. Introduction
Les arbres binaires de recherche (ABR) sont une structure de données qui combine les avantages des tableaux (accès rapide) et des listes chaînées (insertion/suppression efficace).
Pourquoi les ABR ?
Comparons les performances pour rechercher un élément dans différentes structures :
| Structure | Recherche | Insertion | Suppression |
|---|---|---|---|
| Liste non triée | O(n) | O(1) | O(n) |
| Liste triée | O(log n) avec recherche dichotomique | O(n) | O(n) |
| ABR équilibré | O(log n) | O(log n) | O(log n) |
Un ABR bien équilibré offre des performances logarithmiques pour toutes les opérations principales !
2. Définition
Arbre binaire de recherche (ABR)
Un arbre binaire de recherche (ABR) est un arbre binaire vide ou possédant ces propriétés :
- Le max des clés de son sous-arbre gauche (sag) est inférieur à sa clé
- Le min des clés de son sous-arbre droit (sad) est supérieur à sa clé
- Son sag et son sad sont des ABR (propriété récursive)
Définition équivalente : Pour chaque nœud d'un ABR :
- Toutes les clés de son sag sont inférieures ou égales à sa clé
- Toutes les clés de son sad sont supérieures ou égales à sa clé
Clés uniques
Dans ce cours, on considère que les clés sont uniques (pas de doublons), ce qui est le cas habituel. Si on accepte les doublons, on travaille avec des inégalités au sens large (≤ et ≥).
Exemple d'ABR
graph TD;
A[8] --> B[4]
A --> C[12]
B --> D[2]
B --> E[6]
C --> F[10]
C --> G[14]
D --> H[1]
D --> I[3]
E --> J[5]
E --> K[7]
F --> L[9]
F --> M[11]
G --> N[13]
G --> O[15]
Vérification : Prenons le nœud 8 (racine) :
- Toutes les clés à gauche (1, 2, 3, 4, 5, 6, 7) sont < 8 ✓
- Toutes les clés à droite (9, 10, 11, 12, 13, 14, 15) sont > 8 ✓
3. Structure de données en Python
Nous utilisons la représentation fonctionnelle que vous avez créée avec des tuples :
# Type arbre binaire : soit vide (), soit un tuple (clé, sag, sad)
ArbreBin = tuple[()] | tuple[T, 'ArbreBin[T]', 'ArbreBin[T]']
Exemple d'utilisation
# Construire l'arbre:
# 8
# / \
# 4 12
# / \
# 2 6
arbre = creer(
8,
creer(4, creer(2, (), ()), creer(6, (), ())),
creer(12, (), ())
)
print(cle(arbre)) # 8
print(cle(sag(arbre))) # 4
print(est_feuille(creer(5, (), ()))) # True
print(est_vide(())) # True
4. Opérations sur les ABR
4.1 Recherche dans un ABR
La recherche est très efficace : on compare la valeur cherchée avec la racine et on descend à gauche ou à droite.
Algorithme :
- Si l'arbre est vide, la valeur n'est pas présente → renvoyer
False - Si la valeur cherchée est égale à la racine → renvoyer
True - Si la valeur est plus petite, chercher dans le sous-arbre gauche
- Si la valeur est plus grande, chercher dans le sous-arbre droit
Exemple : Rechercher 11 dans l'arbre ci-dessus
8 (11 > 8, aller à droite)
└─> 12 (11 < 12, aller à gauche)
└─> 10 (11 > 10, aller à droite)
└─> 11 (trouvé !)
Seulement 4 comparaisons au lieu de parcourir tous les 15 nœuds !
Implémentation Python :
def recherche_abr[T: Comparable](element: T, arbre: ArbreBin[T]) -> bool:
"""Recherche un élément dans un ABR."""
if est_vide(arbre):
return False
if element == cle(arbre):
return True
elif element < cle(arbre):
return recherche_abr(element, sag(arbre))
else:
return recherche_abr(element, sad(arbre))
4.2 Insertion dans un ABR
L'insertion maintient la propriété d'ABR en plaçant le nouvel élément au bon endroit.
Algorithme :
- Si l'arbre est vide, créer un nouveau nœud avec l'élément
- Si l'élément est plus petit que la racine, insérer dans le sous-arbre gauche
- Si l'élément est plus grand que la racine, insérer dans le sous-arbre droit
- Si l'élément est égal à la racine, ne rien faire (clé déjà présente)
Exemple : Insérer les valeurs 3, 7, 1, 9, 4 dans un arbre vide
Étape 1 : Insérer 3
graph TD
A[3]
Étape 2 : Insérer 7
graph TD
A[3] --> B[ ]
A --> C[7]
style B fill:none,stroke:none
Étape 3 : Insérer 1
graph TD
A[3] --> B[1]
A --> C[7]
Étape 4 : Insérer 9
graph TD
A[3] --> B[1]
A --> C[7]
C --> D[ ]
C --> E[9]
style D fill:none,stroke:none
Étape 5 : Insérer 4 (arbre final)
graph TD
A[3] --> B[1]
A --> C[7]
C --> D[4]
C --> E[9]
Implémentation Python :
def insere_abr[T: Comparable](element: T, arbre: ArbreBin[T]) -> ArbreBin[T]:
"""Insère un élément dans un ABR (crée un nouvel arbre)."""
if est_vide(arbre):
return creer(element, (), ())
if element < cle(arbre):
return creer(cle(arbre), insere_abr(element, sag(arbre)), sad(arbre))
elif element > cle(arbre):
return creer(cle(arbre), sag(arbre), insere_abr(element, sad(arbre)))
else:
return arbre # Élément déjà présent
Exemple d'utilisation :
arbre = ()
arbre = insere_abr(8, arbre)
arbre = insere_abr(4, arbre)
arbre = insere_abr(12, arbre)
arbre = insere_abr(2, arbre)
print(recherche_abr(4, arbre)) # True
print(recherche_abr(10, arbre)) # False
4.3 Vérifier qu'un arbre est un ABR
Il ne suffit pas de vérifier que chaque nœud est plus grand que son fils gauche et plus petit que son fils droit. Il faut vérifier que tous les descendants respectent la propriété.
Contre-exemple (pas un ABR) :
graph TD
A[5] --> B[3]
A --> C[7]
B --> D[1]
B --> E["6 ❌"]
style E fill:#ffcccc
⚠️ 6 est dans le sous-arbre gauche de 5, mais 6 > 5 ! Ce n'est pas un ABR.
Implémentation correcte :
def est_abr[T: Comparable](arbre: ArbreBin[T],
min_val: float = float('-inf'),
max_val: float = float('inf')) -> bool:
"""Vérifie qu'un arbre binaire est un ABR."""
if est_vide(arbre):
return True
# Vérifier que la valeur est dans l'intervalle autorisé
if not (min_val < cle(arbre) < max_val):
return False
# Vérifier récursivement les sous-arbres avec des intervalles ajustés
return (est_abr(sag(arbre), min_val, cle(arbre)) and
est_abr(sad(arbre), cle(arbre), max_val))
5. Complexité et cas dégénérés
5.1 Hauteurs d'arbres particuliers
- Arbre parfait : toutes les feuilles sont au même niveau, \(hauteur = log(n)\)
- Arbre filiforme : tous les nœuds ont au plus un fils, \(hauteur = n\)
5.2 Complexité de la recherche
La complexité dépend de la hauteur de l'arbre :
- Meilleur cas (arbre équilibré) : O(log n)
- Exemple : arbre parfait avec 15 nœuds → hauteur = 3
-
Recherche : au maximum 4 comparaisons
-
Pire cas (arbre filiforme) : O(n)
- Exemple : insérer 1, 2, 3, 4, 5 dans l'ordre croissant
- Résultat : une liste chaînée déguisée !
Arbre parfait (h=3) :
graph TD
A[8] --> B[4]
A --> C[12]
B --> D[2]
B --> E[6]
C --> F[10]
C --> G[14]
Arbre filiforme (h=5) :
graph TD
A[1] --> B[ ]
A --> C[2]
C --> D[ ]
C --> E[3]
E --> F[ ]
E --> G[4]
G --> H[ ]
G --> I[5]
style B fill:none,stroke:none
style D fill:none,stroke:none
style F fill:none,stroke:none
style H fill:none,stroke:none
5.3 Importance de l'équilibrage
Pour garantir O(log n), il existe des arbres auto-équilibrés :
- AVL : maintient la différence de hauteur entre sous-arbres ≤ 1
- Arbre rouge-noir : utilisé dans les implémentations de
TreeMap,TreeSet
Ces structures sont hors programme.
Les index de bases de données utilisent des B-trees (arbres B), qui sont aussi une version évoluée des arbres binaires
Parcours infixe
Le parcours infixe (gauche → racine → droite) d'un ABR donne les éléments triés.
def parcours_infixe(arbre: ArbreBin[T]) -> list[T]:
"""Parcours infixe : retourne les clés triées."""
if est_vide(arbre):
return []
return (parcours_infixe(sag(arbre)) +
[cle(arbre)] +
parcours_infixe(sad(arbre)))
Exemple :
arbre = creer(8,
creer(4, creer(2, (), ()), creer(6, (), ())),
creer(12, creer(10, (), ()), creer(14, (), ())))
print(parcours_infixe(arbre)) # [2, 4, 6, 8, 10, 12, 14]
7. Exercices
Exercice 1 : Construction d'ABR
Dessinez les ABR obtenus en insérant successivement les valeurs suivantes dans un arbre vide :
- 3, 7, 1, 9, 4, 8, 2, 5, 6
- 6, 2, 9, 1, 5, 3, 8, 4, 7
- 9, 5, 3, 7, 2, 6, 1, 8, 4
Solutions
Séquence 1 : 3, 7, 1, 9, 4, 8, 2, 5, 6
graph TD
A[3] --> B[1]
A --> C[7]
B --> D[ ]
B --> E[2]
C --> F[4]
C --> G[9]
F --> H[ ]
F --> I[5]
G --> J[8]
G --> K[ ]
I --> L[ ]
I --> M[6]
style D fill:none,stroke:none
style H fill:none,stroke:none
style K fill:none,stroke:none
style L fill:none,stroke:none
Séquence 2 : 6, 2, 9, 1, 5, 3, 8, 4, 7
graph TD
A[6] --> B[2]
A --> C[9]
B --> D[1]
B --> E[5]
C --> F[8]
C --> G[ ]
E --> H[3]
E --> I[ ]
F --> J[7]
F --> K[ ]
H --> L[ ]
H --> M[4]
style G fill:none,stroke:none
style I fill:none,stroke:none
style K fill:none,stroke:none
style L fill:none,stroke:none
Séquence 3 : 9, 5, 3, 7, 2, 6, 1, 8, 4
graph TD
A[9] --> B[5]
A --> C[ ]
B --> D[3]
B --> E[7]
D --> F[2]
D --> G[ ]
E --> H[6]
E --> I[8]
F --> J[1]
F --> K[ ]
J --> L[ ]
J --> M[4]
style C fill:none,stroke:none
style G fill:none,stroke:none
style K fill:none,stroke:none
style L fill:none,stroke:none
Observation : L'ordre d'insertion a un impact majeur sur la forme de l'arbre !
Exercice 2 : Vérifier qu'un arbre est un ABR
Implémentez la fonction est_abr(arbre: ArbreBin[T]) -> bool qui vérifie qu'un arbre binaire est un ABR.
Exercice 3 : Insertion dans un ABR
Implémentez la fonction insere_abr(element: T, arbre: ArbreBin[T]) -> ArbreBin[T] qui insère un élément dans un ABR.
Exercice 4 : Recherche dans un ABR
Implémentez la fonction recherche_abr(element: T, arbre: ArbreBin[T]) -> bool.
Exercice 5 : Complexité
- Quelle est la complexité de
recherche_abrpour un arbre filiforme de n éléments ? - Quelle est la complexité de
recherche_abrpour un arbre parfait de n éléments ? - Expliquez pourquoi l'ordre d'insertion impacte les performances.
Réponses
- Arbre filiforme : O(n)
- La hauteur est n-1
- Dans le pire cas, on parcourt tous les nœuds
-
Exemple : rechercher 5 dans 1→2→3→4→5 (5 comparaisons)
-
Arbre parfait : O(log n)
- La hauteur est log₂(n)
- Chaque comparaison élimine la moitié des nœuds restants
-
Exemple : rechercher dans un arbre de 15 nœuds → max 4 comparaisons
-
Impact de l'ordre d'insertion :
- Insérer des valeurs triées (1, 2, 3, 4, 5) → arbre filiforme (pire cas)
- Insérer des valeurs aléatoires → arbre relativement équilibré
- Insérer la médiane d'abord puis récursivement → arbre parfait
Exemple : Pour [1, 2, 3, 4, 5, 6, 7] - Ordre trié : 1→2→3→4→5→6→7 (filiforme) - Ordre optimal : 4→2→6→1→3→5→7 (parfait)
Exercice 6 : Minimum et maximum
Implémentez les fonctions min_abr(arbre: ArbreBin[T]) -> T et max_abr(arbre: ArbreBin[T]) -> T.
Exercice 7 : Parcours infixe
Implémentez la fonction parcours_infixe(arbre: ArbreBin[T]) -> list[T] qui retourne les clés dans l'ordre croissant.
Exercice 8 : Taille et hauteur
Implémentez les fonctions taille(arbre: ArbreBin[T]) -> int et hauteur(arbre: ArbreBin[T]) -> int.
7. Version mutable des algorithmes
Jusqu'à présent, nous avons utilisé une approche fonctionnelle immuable avec des tuples. Chaque modification (insertion, suppression) créait un nouvel arbre sans modifier l'ancien.
Dans cette section, nous allons explorer une approche mutable orientée objet où :
- Les arbres sont représentés par des objets modifiables
- Les insertions modifient directement l'arbre existant
- On utilise le pattern Sentinelle pour simplifier le code
Changement de paradigme : Sous-arbres vs Nœuds
Différence conceptuelle fondamentale :
- Approche immuable : On pense en termes de sous-arbres
sag(arbre)etsad(arbre)sont des arbres complets (des entités autonomes)- On manipule des structures récursives où chaque sous-arbre est lui-même un arbre
-
Les primitives (
cle,sag,sad) cachent la représentation interne -
Approche mutable : On pense en termes de nœuds et de liens
noeud.gaucheetnoeud.droitesont des pointeurs vers d'autres nœuds- On manipule des objets qui ont des références entre eux
- On accède directement aux attributs des objets
Illustration de la différence
Immuable : "L'arbre a un sous-arbre gauche qui est lui-même un arbre complet"
Mutable : "Le nœud a un lien vers un autre nœud à gauche"
Cette différence explique pourquoi les algorithmes mutables sont plus impératifs (on modifie des liens) tandis que les algorithmes immuables sont plus fonctionnels (on construit de nouveaux arbres).
Le pattern Sentinelle
Au lieu d'utiliser None pour représenter l'absence de nœud, on utilise un objet sentinelle qui pointe vers lui-même. Cela évite de nombreux tests if noeud is None.
class Noeud[T]:
def __init__(self, cle: T, gauche: "Noeud[T]", droite: "Noeud[T]"):
self.cle = cle
self.gauche = gauche
self.droite = droite
class Sentinelle[T](Noeud[T]):
def __init__(self):
super().__init__(None, self, self) #type: ignore
ARBRE_VIDE = Sentinelle()
Exemple d'utilisation
Vérifier si un nœud est une sentinelle
def est_sentinelle[T](noeud: Noeud[T]) -> bool:
return noeud.gauche is noeud and noeud.droite is noeud
Recherche dans un ABR mutable
Exercice 10 : Recherche mutable
Implémentez la fonction recherche_abr_mut qui recherche un élément dans un ABR mutable.
Indication : Au lieu de tester if est_vide(arbre), testez if est_sentinelle(noeud).
Insertion dans un ABR mutable
L'insertion mutable est plus complexe que la version immuable car on doit modifier l'arbre existant au lieu d'en créer un nouveau.
Exercice 11 : Insertion mutable
Implémentez la fonction insere_abr_mut qui insère un élément dans un ABR mutable.
Différence majeure : Au lieu de retourner un nouvel arbre, on modifie directement les attributs gauche et droite du nœud parent.
Parcours infixe mutable
Exercice 12 : Parcours infixe mutable
Implémentez parcours_infixe_mut qui affiche les éléments d'un ABR mutable dans l'ordre croissant.
Comparaison des deux approches
| Aspect | Immuable (tuples) | Mutable (objets) |
|---|---|---|
| Représentation vide | () |
Sentinelle() |
| Test de vide | est_vide(arbre) |
est_sentinelle(noeud) |
| Accès aux données | cle(arbre), sag(arbre), sad(arbre) |
noeud.cle, noeud.gauche, noeud.droite |
| Insertion | Retourne un nouvel arbre | Modifie l'arbre existant |
| Sûreté | ✅ Immutabilité garantit qu'on ne casse rien | ❌ Modifications peuvent introduire des bugs |
| Performance | ❌ Création de nouveaux tuples à chaque insertion | ✅ Modification en place, pas de copie |
| Complexité du code | ✅ Plus simple, moins de cas particuliers | ❌ Doit gérer les modifications en place |
Quand utiliser quelle approche ?
- Immuable : préférable en programmation fonctionnelle, quand on veut garder l'historique des modifications, dans les systèmes concurrents
- Mutable : préférable quand la performance est critique, quand on fait beaucoup de modifications, dans les implémentations bas niveau
Exercice final
Exercice 13 : Conversion d'un ABR immuable en mutable
Écrivez une fonction qui convertit un ABR immuable (tuple) en ABR mutable (Noeud/Sentinelle).
8. Résumé
- Un ABR est un arbre binaire où chaque nœud est plus grand que tous les nœuds de son sous-arbre gauche et plus petit que tous ceux de son sous-arbre droit
- Représentation : tuples
()(vide) ou(clé, gauche, droit) - Primitives :
creer,est_vide,est_feuille,cle,sag,sad - Recherche : O(log n) pour un arbre équilibré, O(n) pour un arbre filiforme
- Insertion : suit le même principe que la recherche, maintient la propriété d'ABR
- Parcours infixe : donne les éléments triés
- L'ordre d'insertion a un impact majeur sur la structure et les performances
- Les arbres auto-équilibrés (AVL, Rouge-Noir) garantissent O(log n) en toute circonstance