INF431 Arbres Binaires Équilibrés Sujet proposé par Antoine Rauzy Version: 1989:2075M L’objectif de cette série d’exercices est de vous entrainer à manipuler des structures de données récursives, à écrire des procédures sur ces structures et à les prouver. 1 Arbres de Braun Nous allons commencer par nous intéresser à la mise en œuvre d’une file de priorité à l’aide d’arbres de Braun. On considère des arbres constitués d’une part de nœuds binaires, qui portent chacun un élément, et d’autre part de feuilles, qui ne portent rien. Les éléments sont munis d’un ordre total. Un arbre est dit « de Braun » s’il vérifie les trois propriétés suivantes : 1. Si la racine est un nœud binaire, alors l’élément qu’elle porte est inférieur ou égal à tous les éléments situés dans les sous-arbres gauche et droit (propriété du tas) ; 2. Si la racine est un nœud binaire, si s désigne la taille du sous-arbre gauche et t la taille du sousarbre droit, alors on a t ≤ s ≤ t + 1 (propriété d’équilibrage) ; 3. Si la racine est un nœud binaire, alors ses sous-arbres gauche et droit sont eux-mêmes des arbres de Braun. Pour choisir de bonnes structures de données pour mettre en œuvre les arbres de Braun, il faut prendre en compte les deux remarques suivantes : – Les arbres de Braun sont un containeur (d’objets). Un principe général de bonne programmation des containeurs est de ne pas donner accès directement aux structures permettant de stocker les objets. On interpose toujours une classe entre ces structures et l’utilisateur. Toutes les interactions se font via cette classe (et si besoin des itérateurs). Par exemple, si l’on veut mettre en œuvre une liste, on créera une classe List qui encapsulera les structures ListCell qui porteront effectivement les éléments de la liste. – Les feuilles de l’arbre ne portent pas d’éléments. Elles ne servent en fait qu’à représenter l’arbre vide. Il est donc inutile de les dupliquer. Une seule feuille suffira. Dans le cadre de ce cours, nous allons utiliser des structures persistentes. Ce n’est pas forcément le choix que l’on ferait dans un » vrai« programme (pour des raisons d’efficacité). Question 1 Proposer une structure de données pour mettre en œuvre les arbres de Braun. On supposera que les éléments stockés dans l’arbre sont des objets de type KeyedObject possédant une méthode getKey qui retourne un entier. Les objets seront comparés via cette valeur. Question 2 Écrire une méthode permettant de vérifier qu’un arbre stocké est équilibré. Pour insérer un objet dans un arbre de Braun, on applique l’idée suivante : – Si l’objet a inséré est plus grand que l’objet b stocké à la racine de l’arbre < T1 , b, T2 >, on l’insère a dans le sous-arbre droit (T2 ). On retourne un nœud portant b, dont le sous-arbre gauche est l’arbre ainsi obtenu et le sous-arbre droit est T1 . – Sinon, on opère de même mais en inversant le rôle de a et b. 1 Question 3 Écrire une méthode permettant d’insérer un objet dans un arbre. Quelle est la complexité de cette méthode ? Question 4 Que se passe-t-il si on insère les entiers 3, 4, 1, 2, 5, 6. Extraire le plus petit objet d’un arbre de Braun est plus délicat qu’il peut y paraître au premier abord. Nous allons procéder en plusieurs étapes. Question 5 Écrire une méthode efficace permettant d’extraire un objet d’un arbre (n’importe lequel). L’idée est : – d’extraire l’objet porté par la racine si l’arbre est constitué d’un nœud unique. – d’extraire un objet du sous-arbre gauche et de permuter les sous-arbres dans le cas contraire. Donner la complexité de cette méthode. Question 6 Écrire une méthode efficace permettant de remplacer le plus petit objet d’un arbre par un objet donné. Donner la complexité de cette méthode. Question 7 Écrire une méthode efficace permettant de fusionner deux arbres A et B tels que la taille de A est égale à la taille de B, ou à la taille de B plus 1. On supposera que ces deux arbres sont des sous-arbres d’un même arbre et qu’on peut donc les modifier. On utilisera de plus les deux méthodes précédentes. Donner la complexité de cette méthode. On a maintenant toutes les briques algorithmiques nécéssaires pour extraire le plus petit objet de notre file de priorité. Question 8 Écrire une méthode efficace permettant d’extraire le plus petit élément d’un arbre. Donner la complexité de cette méthode. 2 Skew Heaps On a essentiellement la même structure de données d’arbres binaires que précédemment. Un arbre est soit une feuille <>, soit un nœud < T1 , a, T2 > ou T1 et T2 sont des arbres et a un entier. La notion de tas (être partiellement ordonné) reste la même. On va utiliser une notion d’équilibrage plus lâche mais efficace. Prouver son efficacité demandera d’utiliser une complexité amortie. Le but de cette exercice est de voir encore des invariants récursifs astucieux, et aussi la complexité amortie. Les algorithmes d’insertion d’un élément et d’extraction du plus petit élément du tas sont décrits par les équations récursives suivantes : extract(< T1 , a, T2 >) insert(a, T ) merge(T, <>) merge(<>, T ) merge(T, U ) merge(T, U ) join(< T1 , a, T2 >, U ) = = (a, merge(T1 , T2 )) merge(T, <<>, a, <>>) = T = T = = join(T, U ) join(U, T ) si la racine de T est plus petite que la racine de U sinon = < T2 , a, merge(T1 , U ) > Question 9 Montrer que ces algorithmes sont corrects. Question 10 On insère successivement dans l’arbre vide les entiers 1, 2, 3, 4. Quel est l’arbre obtenu ? Est-il équilibré ? Que se passe-t-il quand on insère successivement 4, 3, 2, 1 ? 2 On va maintenant analyser la complexité amortie d’une suite quelconque d’insertions et d’extractions dans l’arbre. L’idée générale est qu’une insertion ou une extraction individuelle peut prendre plus de O(log n) opérations (où n est la taille de l’arbre), mais que la suite elle ne prendra jamais plus que O(n log n) opérations. Pour cela, on va définir une notion de budget : chaque fois que l’on effectue une opération, on s’alloue un budget de O(log n) opérations élémentaires (comparaison de deux nœuds ou création d’un nœud). Si on ne consomme pas ce budget, on met de côté ce qu’on n’a pas consommé pour plus tard. On va prouver qu’en agissant de la sorte, on n’est jamais à découvert. On dit qu’un nœud est bon si son sous-arbre droit est au moins aussi gros que son sous-arbre gauche et qu’il est mauvais sinon. Question 11 Donner les équations récursives permettant de compter le nombre de mauvais nœuds d’un arbre. On prend la convention que la construction d’un nouveau nœud et une comparaison d’entiers prend un temps 1 et on note |T | la taille de l’arbre T et t(op) le nombre d’opérations élémentaires nécessaires pour effectuer l’opération op. Question 12 Montrez que t(merge(T, U )) est borné par : 2 · (log(|T |) + log(|U |)) + Pt(T ) + Pt(U ) − Pt(merge(T, U )) Question 13 En déduire que la complexité d’une suite de n d’insertions est en O(n log n). Discussion : On trouve des arguments de complexité amortie dans les structures de données que l’on redimensionne en fonction du nombre d’objets stockés, typiquement les tables de hachage ou les modules de gestion de la mémoire. L’opération de redimensionnement est faite de temps en temps et elle est relativement coûteuse (typiquement linéraire), mais ce coût est amorti sur le nombre d’opérations. Un algorithme dont la complexité amortie est satisfaisante peut malgré tout ne pas l’être dans un contexte temps réel. 3