🎄 Arbres

Sommaire

Exemples introductifs

Arbre syntaxique

expression corps_1 Corps affectation_1 Affectation corps_1->affectation_1 boucle_for_1 Boucle for corps_1->boucle_for_1 Fonction print Fonction print corps_1->Fonction print y_1 y affectation_1->y_1 1337 1337 affectation_1->1337 i i boucle_for_1->i range_1 Itérable range boucle_for_1->range_1 corps_2 Corps boucle_for_1->corps_2 42 42 range_1->42 affectation_2 Affectation corps_2->affectation_2 y_2 y affectation_2->y_2 × × affectation_2->× y_3 y ×->y_3 2 2 ×->2 y y Fonction print->y

Cet arbre pourrait représenter le programme suivant :

y = 1337
for i in range(42):
    y = y * 2
print(y)

L'interpréteur Python, quand il lit un programme, commence par le traduire en arbre syntaxique en suivant la grammaire du langage. Il peut détecter à cette occasion une erreur de syntaxe avant même le début de l'exécution du programme.

Exercice 1 : Chercher lesquels parmi les formats de fichiers suivants peuvent être lus et représentés par un arbre syntaxique.

Expression littérale

expression Nil_1 nil Nil_2 nil Nil_3 nil Nil_4 nil Nil_5 nil Nil_6 nil Nil_7 nil Nil_8 nil Nil_9 nil + + × × +->× ÷ ÷ +->÷ - - ×->- y y ×->y x x ÷->x 2 2 ÷->2 -->Nil_1 3 3 -->3 3->Nil_2 3->Nil_3 y->Nil_4 y->Nil_5 x->Nil_6 x->Nil_7 2->Nil_8 2->Nil_9

Cet arbre binaire est une représentation de l'expression 3y+x2-3y + \frac x 2.

Exercice 2 : Dessiner un arbre représentant l'expression (x+7)×(y2)+(5x2)(-x+7)×(y-2) + (5-x^2).

On pourra ne pas dessiner nil.

Définitions

Arbre enraciné

Nœud

Un nœud contient un élément et des enfants, qui sont d'autres nœuds : zéro, un ou plusieurs nœuds.

Arbre enraciné

Un arbre enraciné est un ensemble fini non vide de nœuds, avec un nœud en particulier : la racine de l'arbre. Les nœuds sont organisés de manière hiérarchique dans un graphe :

Arbre binaire

Nœud

Dans un arbre binaire, un nœud contient un élément et deux enfants, qui sont d'autres arbres binaires.

Dans l'exemple précédent, +, ×, 3, x, et les autres sont des nœuds, sauf les nil qui représentent l'absence de nœud.

Arbre binaire

Un arbre binaire est un ensemble fini de nœuds qui ont exactement deux enfants, un à gauche, un à droite ; les enfants sont des nœuds ou éventuellement nil (une absence de nœud) et que l'on pourra alors omettre.

Une définition récursive est alors :

L'arbre binaire ci-dessous est un sous-arbre de l'exemple précédent. On a, cette fois, omis les nil.

expression ÷ ÷ x x ÷->x 2 2 ÷->2

Les arbres binaires sont presque des cas particuliers d'arbres enracinés.

Un arbre binaire est aussi un graphe connexe, sans cycle.

Feuille

Les feuilles sont les extrémités de l'arbre.

Dans le premier exemple, 3, y, x, et 2 sont des feuilles.

Taille d'un arbre

La taille d'un arbre est son nombre de nœuds.

L'exemple de l'arbre de l'expression littérale est un arbre de taille 88, dont 44 feuilles. Il y a donc 848-4 nœuds intérieurs.

Hauteur d'un arbre

⚠️ La définition de hauteur n'est pas la même partout. Vérifier celle du document que vous lisez.

L'arbre précédent donné en exemple est de hauteur 44 ; la branche la plus profonde possède les nœuds +, ×, -, 3.

Dans l'autre définition très commune, un arbre vide a pour hauteur 1-1, et s'il est non vide, c'est le nombre maximal de liens pour joindre la racine à une feuille.

Même si l'autre est assez répandue, nous préférerons la définition où l'arbre vide est de hauteur nulle. Cette définition et l'autre ne diffère que de 11.

La hauteur hh est une notion importante pour le calcul de la complexité d'un algorithme. De nombreux algorithmes ont une complexité en O(h)\mathcal O(h), et ainsi, un décalage de 11 dans la définition est absolument sans importance.

Arbre peigne

Un arbre binaire peigne est un cas particulier extrême d'arbre binaire, tous les nœuds intérieurs ont un seul enfant qui est non vide, et toujours du même côté. Techniquement c'est une liste chainée.

expression Arbre peigne gauche de hauteur 4 0 1 0->1 1d 0->1d 2 1->2 2d 1->2d 3 2->3 3d 2->3d 4 3->4 4d 3->4d

Un arbre peigne montre une situation extrême où certains algorithmes que nous verrons ne seront pas efficaces. Avec un arbre peigne, la hauteur est égale à la taille ; on ne peut pas obtenir une plus grande hauteur à taille fixée.

Arbre parfait

Un arbre binaire parfait possède des nœuds intérieurs qui ont tous exactement deux enfants non vides. C'est l'arbre idéal pour certains algorithmes... Une taille maximale pour une hauteur minimale.

expression Arbre parfait de hauteur 3 1 2 1->2 3 1->3 4 2->4 5 2->5 6 3->6 7 3->7 8 4->8 9 4->9 10 5->10 11 5->11 12 6->12 13 6->13 14 7->14 15 7->15

Arbre équilibré

Un arbre binaire est équilibré si pour chaque nœud, son sous-arbre gauche et son sous-arbre droit ont une hauteur qui ne diffère que de 11 au plus.

Concrètement, un arbre est équilibré quand tous les nœuds intérieurs ont deux enfants non vides, sauf les plus éloignés qui en ont un ou deux. Techniquement, les nœuds intérieurs forment un arbre parfait, et l'arbre peut devenir parfait uniquement en complétant avec des feuilles au dernier niveau.

expression Arbre équilibré 1 2 1->2 3 1->3 4 2->4 5 2->5 6 3->6 7 3->7 8 4->8 9 4->9 12 6->12 13 6->13 14 7->14 15 7->15

Arbre presque complet (à gauche)

Un arbre binaire est presque complet (à gauche) s'il est équilibré et qu'à la profondeur maximale les feuilles sont entassées du même côté (à gauche).

expression Arbre presque complet 1 2 1->2 3 1->3 4 2->4 5 2->5 6 3->6 7 3->7 8 4->8 9 4->9 10 5->10 11 5->11

⚠️ les définitions de (presque) complet et équilibré varient parfois, de même en anglais. Vérifier avec le document que vous utilisez.

Représentation en Python

Prenons par exemple, l'expression numérique A=6×5(3+7+6)A = 6×5 - (3+7+6) qui peut être représentée par l'arbre binaire suivant. (nil non dessinés.)

expression six_1 6 - - × × -->× +_1 + -->+_1 ×->six_1 5 5 ×->5 3 3 +_1->3 + + +_1->+ 7 7 +->7 six_2 6 +->six_2

Avec un tuple à trois éléments

Un arbre binaire peut être représenté par un ensemble de nœuds de la forme (enfant_gauche, élément, enfant_droit) et avec None pour désigner nil.

Notre exemple peut être alors représenté par :

six_1 = (None, "6", None)
cinq_1 = (None, "5", None)
produit_1 = (six_1, "×", cinq_1)

trois_1 = (None, "3", None)

sept_1 = (None, "7", None)
six_2 = (None, "6", None)
somme_1 = (sept_1, "+", six_2)

somme_2 = (trois_1, "+", somme_1)
expr_A = (produit_1, "-", somme_2)

Remarque : on notera la numérotation des nœuds afin d'avoir deux nœuds étiquetés "six_?" différents. De même, plusieurs sommes le composent, l'objectif étant de sécuriser l'éventuel partage de données, même si c'est possible en faisant très attention...

Avec cette représentation on peut donner le code de certaines fonctions.

def est_vide(arbre):
    return arbre is None

def somme(arbre):
    """On suppose ici que l'arbre binaire ne comporte
    que des valeurs numériques."""
    if est_vide(arbre):
        return 0
    else:
        return somme(arbre[0]) + arbre[1] + somme(arbre[2])

def taille(arbre):
    if est_vide(arbre):
        return 0
    else:
        return taille(arbre[0]) + 1 + taille(arbre[2])

def hauteur(arbre):
    if est_vide(arbre):
        return 0
    else:
        return 1 + max(hauteur(arbre[0]), hauteur(arbre[2]))

def est_peigne_gauche(arbre):
    if est_vide(arbre):
        return True
    else:
        return est_vide(arbre[2]) and est_peigne_gauche(arbre[0])
    # variante une ligne
    return est_vide(arbre) or (est_vide(arbre[2]) and est_peigne_gauche(arbre[0]))
    # l'évaluation paresseuse permet de rester efficace, on traîte le cas difficile en dernier !


if __name__ == '__main__':
    # test
    six_1 = (None, "6", None)
    cinq_1 = (None, "5", None)
    produit_1 = (six_1, "×", cinq_1)

    trois_1 = (None, "3", None)

    sept_1 = (None, "7", None)
    six_2 = (None, "6", None)
    somme_1 = (sept_1, "+", six_2)

    somme_2 = (trois_1, "+", somme_1)
    expr_A = (produit_1, "-", somme_2)

    assert taille(expr_A) == 9
    assert hauteur(expr_A) == 4, hauteur(expr_A)

    assert not est_peigne_gauche(expr_A) # Il y a un not !!!
    assert est_peigne_gauche(trois_1) # comme toute feuille

On remarquera que ce code est bien moins lisible que les suivants. Nous n'utiliserons plus ici les tuples avec les indices pour accéder aux champs de données !

Avec une classe Nœud

class Nœud:
    def __init__(self, gauche, élément, droite):
        self.gauche = gauche
        self.élément = élément
        self.droite = droite


def est_vide(arbre):
    return arbre is None

def somme(arbre):
    """On suppose ici que l'arbre binaire ne comporte
    que des valeurs numériques."""
    if est_vide(arbre):
        return 0
    else:
        return somme(arbre.gauche) + arbre.élément + somme(arbre.droite)

def taille(arbre):
    if est_vide(arbre):
        return 0
    else:
        return taille(arbre.gauche) + 1 + taille(arbre.droite)

def hauteur(arbre):
    if est_vide(arbre):
        return 0
    else:
        return 1 + max(hauteur(arbre.gauche), hauteur(arbre.droite))

def est_peigne_gauche(arbre):
    if est_vide(arbre):
        return True
    else:
        return est_vide(arbre.droite) and est_peigne_gauche(arbre.gauche)
    # variante une ligne
    return est_vide(arbre) or (est_vide(arbre.droite) and est_peigne_gauche(arbre.gauche))
    # l'évaluation paresseuse permet de rester efficace, on traîte le cas difficile en dernier !
    
if __name__ == '__main__':
    # test
    six_1 = Nœud(None, "6", None)
    cinq_1 = Nœud(None, "5", None)
    produit_1 = Nœud(six_1, "×", cinq_1)

    trois_1 = Nœud(None, "3", None)

    sept_1 = Nœud(None, "7", None)
    six_2 = Nœud(None, "6", None)
    somme_1 = Nœud(sept_1, "+", six_2)

    somme_2 = Nœud(trois_1, "+", somme_1)
    expr_A = Nœud(produit_1, "-", somme_2)

    assert taille(expr_A) == 9
    assert hauteur(expr_A) == 4, hauteur(expr_A)

    assert not est_peigne_gauche(expr_A) # Il y a un not !!!
    assert est_peigne_gauche(trois_1) # comme toute feuille

On remarque un code bien plus lisible et extensible à volonté. Très bon choix.

Avec un tuple nommé

Le type tuple nommé est peu connu en Python, c'est pourtant un type recommandé au sein du programme de NSI ; il permet d'avoir la légèreté du tuple en ayant une part de l'expressivité de la POO.

Lire la documentation officielle ; On constatera ici, qu'après la redéfinition de Nœud, la suite du code est inchangée !

import collections
Nœud = collections.namedtuple('Nœud', ['gauche', 'élément', 'droite'])


def est_vide(arbre):
    return arbre is None

def somme(arbre):
    """On suppose ici que l'arbre binaire ne comporte
    que des valeurs numériques."""
    if est_vide(arbre):
        return 0
    else:
        return somme(arbre.gauche) + arbre.élément + somme(arbre.droite)

def taille(arbre):
    if est_vide(arbre):
        return 0
    else:
        return taille(arbre.gauche) + 1 + taille(arbre.droite)

def hauteur(arbre):
    if est_vide(arbre):
        return 0
    else:
        return 1 + max(hauteur(arbre.gauche), hauteur(arbre.droite))

def est_peigne_gauche(arbre):
    if est_vide(arbre):
        return True
    else:
        return est_vide(arbre.droite) and est_peigne_gauche(arbre.gauche)
    # variante une ligne
    return est_vide(arbre) or (est_vide(arbre.droite) and est_peigne_gauche(arbre.gauche))
    # l'évaluation paresseuse permet de rester efficace, on traîte le cas difficile en dernier !
    
if __name__ == '__main__':
    # test
    six_1 = Nœud(None, "6", None)
    cinq_1 = Nœud(None, "5", None)
    produit_1 = Nœud(six_1, "×", cinq_1)

    trois_1 = Nœud(None, "3", None)

    sept_1 = Nœud(None, "7", None)
    six_2 = Nœud(None, "6", None)
    somme_1 = Nœud(sept_1, "+", six_2)

    somme_2 = Nœud(trois_1, "+", somme_1)
    expr_A = Nœud(produit_1, "-", somme_2)

    assert taille(expr_A) == 9
    assert hauteur(expr_A) == 4, hauteur(expr_A)

    assert not est_peigne_gauche(expr_A) # Il y a un not !!!
    assert est_peigne_gauche(trois_1) # comme toute feuille

On remarque un code aussi clair, moins extensible mais plus léger en empreinte mémoire. À utiliser en cas de volonté d'optimisation temporelle mais surtout mémoire. (Ou alors pour préparer une initiation à la POO ???)

Nous utiliserons la POO pour la suite, pour sa lisibilité, son extensibilité. Nous ne cherchons pas ici les performances.

Parcours en profondeur d'un arbre binaire

Les fonctions que nous avons découvertes utilisaient des opérateurs commutatifs, en effet on a max(a, b) == max(b, a) et 1 + x + y == x + 1 + y == y + x + 1. Que se passe-t-il avec des fonctions non commutatives ?

Si on conserve une action à gauche avant celle de droite, alors il reste trois positions pour appliquer l'action sur le nœud en cours.

Exercice 3 : Appliquer les fonctions suivantes. Vous ferez sur papier les parcours avec action définie comme « afficher le nœud en cours », sur l'arbre de notre exemple.

def parcours_préfixe(arbre, action):
    if not est_vide(arbre):
        action(arbre)
        parcours_préfixe(arbre.gauche)
        parcours_préfixe(arbre.droite)

def parcours_infixe(arbre, action):
    if not est_vide(arbre):
        parcours_infixe(arbre.gauche)
        action(arbre)
        parcours_infixe(arbre.droite)

def parcours_postfixe(arbre, action):
    if not est_vide(arbre):
        parcours_postfixe(arbre.gauche)
        parcours_postfixe(arbre.droite)
        action(arbre)

Ceci est un dessin humoristique lié aux parcours d'arbre.

https://xkcd.com/2407/

Exercices sur les arbres binaires

Exercice 4 - Dessiner des arbres

Dessiner à la main tous les arbres binaires ayant trois ou quatre nœuds.

Facultatif : Pour dessiner des arbres dans un document écrit en Markdown, on regardera le code source de ce document...

Exercice 5 - Dénombrement des arbres binaires

Combien y a-t-il d'arbres binaires ayant 55 nœuds. (Ne pas tous les dessiner ; trouver juste le moyen de tous les dénombrer.)

Exercice 6 - Parcours infixe

On considère l'arbre binaire suivant :

expression exemple_6 A A B B A->B D D A->D Bg B->Bg C C B->C Dg D->Dg Dd D->Dd Cg C->Cg Cd C->Cd

  1. Construire sa représentation interne en Python à l'aide de la classe Nœud.
  2. Écrire une fonction affichage définie par :
    • sur un arbre vide, ne rien faire ;
    • sur un arbre non vide,
      • afficher (, puis
      • afficher (récursivement) le sous-arbre gauche, puis
      • afficher la racine, puis
      • afficher (récursivement) le sous-arbre droite, puis
      • afficher )
  3. Vérifier que affichage(exemple_6) produit '((B(C))A(D))'.
  4. Dessiner un arbre dont affichage(arbre) produit '(1((2)3))'.
  5. De manière générale, expliquer comment retrouver un arbre dont l'affichage est donné.

Exercice 7 - Parcours en largeur

  1. Écrire une fonction de parcours en largeur.
  2. Tester votre fonction.
  3. Vérifier qu'elle consiste à lire l'arbre étage par étage en commençant par la racine, puis de gauche à droite pour chaque étage.
  4. Modifier votre fonction pour qu'une étiquette soit affichée avec son niveau. La racine est au niveau 0.

Exercice 8 - Reconstruire un arbre

Un arbre binaire est étiqueté avec des lettres.

  1. Reconstruire l'arbre qui a produit ces affichages.
  2. Qu'obtient-on avec un parcours en largeur ?
  3. Qu'obtient-on avec un parcours postfixe ?