Pr. EL HADIQ Zouhair
Une structure de données hiérarchique fondamentale : terminologie, implémentation, parcours en profondeur et en largeur, arbres binaires de recherche et tas.
Un arbre est une structure de données qui organise l'information de façon hiérarchique. On le retrouve partout : arborescence de fichiers, arbre généalogique, arbre syntaxique d'une expression, taxonomie en biologie… Un arbre binaire est un arbre dans lequel chaque nœud possède au plus deux fils : un fils gauche et un fils droit.
| Terme | Définition |
|---|---|
| Nœud | Élément de l'arbre contenant une valeur. |
| Racine | L'unique nœud sans parent ; point d'entrée de l'arbre. |
| Feuille | Nœud sans fils (degré 0). |
| Nœud interne | Nœud possédant au moins un fils (tout nœud qui n'est pas une feuille). |
| Degré | Nombre de fils d'un nœud. Dans un arbre binaire, le degré vaut 0, 1 ou 2. |
| Père / fils | Relation directe entre un nœud et celui qu'il porte juste en dessous. |
| Chemin | Suite de nœuds reliés de père en fils. Entre deux nœuds, le chemin est unique. |
| Sous-arbre | Un nœud et tous ses descendants forment eux-mêmes un arbre. |
Deux mesures décrivent la « forme verticale » d'un arbre.
Pour un arbre binaire de hauteur h, le nombre de nœuds n vérifie l'encadrement :
La borne inférieure correspond à un arbre filiforme (chaque nœud n'a qu'un fils), la borne supérieure à un arbre parfait (tous les niveaux sont pleins). On en déduit, pour un arbre équilibré à n nœuds, une hauteur de l'ordre de log2 n.
# Hauteur d'un arbre (convention : arbre vide -> -1)
def hauteur(a):
if a is None: # arbre vide
return -1
return 1 + max(hauteur(a[1]), hauteur(a[2]))
La hauteur se calcule donc « de bas en haut » : on suppose connaître la hauteur des deux sous-arbres, on prend la plus grande et on ajoute 1 pour le niveau de la racine.
Le programme demande de définir un arbre binaire à l'aide d'une liste. La représentation la plus directe en Python suit la définition récursive : un nœud est une liste à trois cases.
# Un arbre = None (vide) OU [valeur, sous_arbre_gauche, sous_arbre_droit]
def feuille(v):
return [v, None, None]
def noeud(v, g, d):
return [v, g, d]
# Exemple : 4
# / \
# 2 6
arbre = noeud(4, feuille(2), feuille(6))
# arbre = [4, [2, None, None], [6, None, None]]
a[0] est la valeur de la racine, a[1] le sous-arbre gauche et a[2] le sous-arbre droit. L'arbre vide est représenté par None.
On compte facilement les nœuds avec la même structure récursive :
def nb_noeuds(a):
if a is None:
return 0
return 1 + nb_noeuds(a[1]) + nb_noeuds(a[2])
def nb_feuilles(a):
if a is None:
return 0
if a[1] is None and a[2] is None: # aucun fils
return 1
return nb_feuilles(a[1]) + nb_feuilles(a[2])
Une autre représentation, utile pour les arbres complets, range les valeurs dans une seule liste où le fils gauche du nœud d'indice i est en 2i+1 et le fils droit en 2i+2. Cette représentation « par tableau » est précisément celle des tas (section 7).
Parcourir un arbre, c'est visiter chacun de ses nœuds une et une seule fois. Les parcours en profondeur explorent un sous-arbre entièrement avant de passer au suivant. Ils diffèrent par le moment où l'on traite la racine par rapport à ses sous-arbres.
| Parcours | Ordre | Usage typique |
|---|---|---|
| Préfixe | Racine → Gauche → Droite | Copie / sérialisation d'un arbre |
| Infixe | Gauche → Racine → Droite | Renvoie un ABR trié |
| Postfixe | Gauche → Droite → Racine | Évaluation d'expressions, suppression |
def prefixe(a):
if a is None:
return []
return [a[0]] + prefixe(a[1]) + prefixe(a[2])
def infixe(a):
if a is None:
return []
return infixe(a[1]) + [a[0]] + infixe(a[2])
def postfixe(a):
if a is None:
return []
return postfixe(a[1]) + postfixe(a[2]) + [a[0]]
[4, [2, [1,N,N], [3,N,N]], [6, [5,N,N], [7,N,N]]] (où N = None) :Le parcours en largeur visite les nœuds niveau par niveau, de gauche à droite. On ne peut plus utiliser la récursivité naturelle : on s'appuie sur une file (FIFO, « premier entré, premier sorti »). On enfile la racine, puis on défile un nœud, on le traite et on enfile ses fils.
from collections import deque
def largeur(a):
if a is None:
return []
file = deque([a]) # file initialisée avec la racine
ordre = []
while file:
n = file.popleft() # on défile (FIFO)
ordre.append(n[0])
if n[1] is not None:
file.append(n[1]) # on enfile le fils gauche
if n[2] is not None:
file.append(n[2]) # puis le fils droit
return ordre
Un arbre binaire de recherche est un arbre binaire qui respecte, en chaque nœud, la propriété d'ordre suivante :
Cette invariant rend la recherche très efficace : à chaque nœud, on élimine la moitié de l'arbre, exactement comme dans une recherche dichotomique. Le coût d'une recherche, d'une insertion ou d'une suppression est en \(O(h)\), donc \(O(\log n)\) si l'arbre est équilibré, mais \(O(n)\) dans le pire des cas (arbre filiforme).
def recherche(a, x):
if a is None:
return False
if x == a[0]:
return True
if x < a[0]:
return recherche(a[1], x) # on descend à gauche
else:
return recherche(a[2], x) # on descend à droite
def inserer(a, x):
if a is None:
return [x, None, None] # on crée une nouvelle feuille
if x < a[0]:
a[1] = inserer(a[1], x)
elif x > a[0]:
a[2] = inserer(a[2], x)
return a # x déjà présent : on ne fait rien
Construire un ABR par insertions successives, puis appliquer le parcours infixe, fournit une méthode de tri : c'est le principe du tri par arbre.
Un tas est un arbre binaire complet (tous les niveaux remplis sauf éventuellement le dernier, rempli de gauche à droite) vérifiant une propriété de tas. Dans un tas-max, la valeur de chaque nœud est supérieure ou égale à celles de ses fils ; la racine contient donc le maximum.
t : la racine est en t[0], et pour le nœud d'indice i, le fils gauche est en t[2i+1], le fils droit en t[2i+2], et le père en t[(i-1)//2]. Aucun pointeur n'est nécessaire.
# Insertion dans un tas-max (remontée / "percolate up")
def inserer_tas(t, x):
t.append(x) # on ajoute en dernière position
i = len(t) - 1
while i > 0 and t[(i-1)//2] < t[i]:
pere = (i-1)//2
t[i], t[pere] = t[pere], t[i] # on échange avec le père
i = pere
return t
Le tas est la structure idéale d'une file de priorité : insertion et extraction du maximum coûtent \(O(\log n)\). Il est au cœur du tri par tas (heapsort).
[valeur, gauche, droite] ou liste indexée (fils en 2i+1 et 2i+2).CPGE MP / PSI · Informatique — Les arbres binaires