Université Libre de Bruxelles Faculté des Sciences Département d’Informatique Arbres binaires de recherche optimaux et quasi-optimaux Mémoire présenté par Gabriel Kalyon en vue de l’obtention du grade de Licencié en Informatique. Année académique 2005-2006. Table des matières 1 Introduction 5 I 7 Fondements et définitions 2 Arbres binaires et complexité 2.1 Arbres binaires . . . . . . . . . . . . . . . . . 2.1.1 Définitions . . . . . . . . . . . . . . . . 2.1.2 Profondeur d’un nœud . . . . . . . . . 2.1.3 Hauteur d’un nœud . . . . . . . . . . . 2.1.4 Rotations . . . . . . . . . . . . . . . . 2.2 Arbres binaires de recherche . . . . . . . . . . 2.2.1 Modèle . . . . . . . . . . . . . . . . . . 2.2.2 Classes d’arbres binaires de recherche . 2.3 Performances asymptotiques . . . . . . . . . . 2.3.1 Notation O(.) . . . . . . . . . . . . . . 2.3.2 Notation Ω(.) . . . . . . . . . . . . . . 2.3.3 Notation Θ(.) . . . . . . . . . . . . . . 2.3.4 Lien entre les notations asymptotiques 2.4 Complexité . . . . . . . . . . . . . . . . . . . 2.4.1 Complexité au pire cas . . . . . . . . . 2.4.2 Complexité moyenne . . . . . . . . . . 2.4.3 Complexité amortie . . . . . . . . . . . 2.5 Conclusion . . . . . . . . . . . . . . . . . . . . 3 Propriétés des arbres binaires de 3.1 Relations entre les propriétés . . 3.2 Static finger . . . . . . . . . . . 3.3 Optimalité statique . . . . . . . 3.4 Working set . . . . . . . . . . . 3.5 Dynamic finger . . . . . . . . . 1 recherche . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 8 8 8 9 9 9 11 12 13 14 15 15 15 15 16 16 16 17 18 . . . . . 19 19 19 21 21 22 3.6 Unifiée . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 23 3.7 Optimalité dynamique . . . . . . . . . . . . . . . . . . . . . . 24 3.8 Conclusion . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 25 II Arbres binaires de recherche particuliers 4 Arbres Binaires de Recherche 4.1 Généralités . . . . . . . . . 4.2 Algorithme exhaustif . . . . 4.3 Algorithme de Knuth . . . . 4.4 Conclusion . . . . . . . . . . Optimaux . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5 Arbres binaires de recherche équilibrés 5.1 Définition . . . . . . . . . . . . . . . . 5.2 Arbres rouges-noirs . . . . . . . . . . . 5.2.1 Définition . . . . . . . . . . . . 5.2.2 Recherche . . . . . . . . . . . . 5.2.3 Insertion . . . . . . . . . . . . . 5.2.4 Successeur . . . . . . . . . . . . 5.2.5 Prédécesseur . . . . . . . . . . . 5.2.6 Analyse des performances . . . 5.3 Conclusion . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 6 Arbres binaires de recherche auto-ajustables 6.1 Définition . . . . . . . . . . . . . . . . . . . . 6.2 Splay trees . . . . . . . . . . . . . . . . . . . . 6.2.1 Splaying . . . . . . . . . . . . . . . . . 6.2.2 Recherche . . . . . . . . . . . . . . . . 6.2.3 Insertion . . . . . . . . . . . . . . . . . 6.2.4 Suppression . . . . . . . . . . . . . . . 6.2.5 Analyse des performances . . . . . . . 6.3 Comparaisons . . . . . . . . . . . . . . . . . . 6.4 Conclusion . . . . . . . . . . . . . . . . . . . . III . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 26 . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 27 27 29 29 31 . . . . . . . . . 32 32 32 33 33 33 34 35 35 36 . . . . . . . . . 38 38 38 39 39 39 39 40 44 45 Optimalité dynamique 7 Bornes inférieures sur le coût des arbres binaires de recherche 7.1 Modèle . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7.2 La borne inférieure de couverture de rectangle . . . . . . . . . 7.3 Applications de la borne inférieure de couverture de rectangle 2 46 47 47 48 53 7.3.1 La borne inférieure d’entrelacement . . . . . . . . . . . 54 7.3.2 La seconde borne de Wilber . . . . . . . . . . . . . . . 56 7.4 Conclusion . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 59 8 Tango 8.1 Structures de données . . . . . . 8.2 Algorithme de l’arbre de référence 8.3 Arbres auxiliaires . . . . . . . . . 8.3.1 Définition . . . . . . . . . 8.3.2 Concaténation . . . . . . . 8.3.3 Éclatement . . . . . . . . 8.3.4 Cutting . . . . . . . . . . 8.3.5 Joining . . . . . . . . . . . 8.4 Algorithme Tango . . . . . . . . . 8.5 Analyse des performances . . . . 8.6 Conclusion . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 9 Multi-splay tree 9.1 Structures de données . . . . . . . . . . 9.2 Algorithmes . . . . . . . . . . . . . . . . 9.2.1 Algorithme de l’arbre de référence 9.2.2 Algorithme multi-splay tree . . . 9.3 Analyse des performances . . . . . . . . 9.3.1 Fonction potentiel . . . . . . . . . 9.3.2 Access Lemma Généralisé . . . . 9.3.3 Multi-Splay Access Lemma . . . . 9.3.4 Propriétés . . . . . . . . . . . . . 9.4 Conclusion . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 60 60 61 66 66 67 67 69 72 75 77 81 . . . . . . . . . . . . . . . . . . . . 82 82 83 83 83 89 90 90 91 98 100 . . . . . . . . . . . 10 Optimalité de recherche dynamique 10.1 Définition . . . . . . . . . . . . . . 10.2 Intérêt . . . . . . . . . . . . . . . . 10.3 Structure . . . . . . . . . . . . . . 10.4 Conclusion . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 102 . 102 . 102 . 103 . 108 11 Expérimentations 11.1 Jeux de tests . . . . . . 11.2 Distribution aléatoire . . 11.3 Distribution working set 11.4 Conclusion . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3 . . . . . . . . . . . . 109 109 110 111 112 12 Conclusion 13 Annexe 13.1 Implémentation . . . . . . 13.1.1 Multi-splay trees . 13.1.2 Splay trees . . . . . 13.1.3 Arbres rouges-noirs 13.2 Jeux de tests . . . . . . . 114 . . . . . . . . . . 4 . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 116 116 116 137 148 158 Chapitre 1 Introduction Lorsque nous recherchons un élément dans un ensemble, nous pouvons effectuer deux types de recherche : une recherche linéaire et une recherche dichotomique. La recherche linéaire consiste à examiner un à un les éléments de l’ensemble, ce qui ne donne pas des performances intéressantes. La recherche dichotomique consiste à éliminer à chaque étape la moitié des éléments de l’ensemble. Cette méthode donne des résultats intéressants, car la recherche se fait en temps logarithmique et non plus en temps linéaire. Les arbres binaires de recherche sont une application de la recherche dichotomique. Ce type de structure organise les éléments sous forme d’arbre pour tenter d’éliminer à chaque étape de la recherche le plus d’éléments possibles (dans le meilleur des cas la moitié des éléments sont éliminés) de manière à localiser le plus rapidement possible un élément. Depuis que ce type de structure a été développé dans les années 1960, il y a eu énormément de travaux réalisés sur le sujet. En général, le but de ces travaux était d’essayer de développer une structure qui atteigne l’optimalité dynamique, c’est-à-dire qui donne le meilleur coût de recherche et cela quelle que soit la séquence d’éléments à rechercher. Ce mémoire consiste en un travail de recherche bibliographique et le but est de présenter les principaux travaux réalisés dans le domaine des arbres binaires de recherche et de l’optimalité dynamique, ainsi que les principales étapes franchies dans ces domaines. Cela va en général consister à présenter des structures et à analyser leurs performances. La présentation de ces résultats se fera de manière plus détaillée et plus didactique que dans les articles dans lesquels ils sont présentés. Le second objectif de ce mémoire est d’implémenter quelques structures de données pour vérifier si en pratique nous obtenons des résultats aussi intéressants qu’en théorie. En effet, il se peut que pour des structures trop complexes les résultats obtenus en pratique soient moins bons que ceux auxquels nous aurions pu nous attendre à avoir 5 par la théorie. Ceci s’explique notamment par le fait que dans les analyses des coûts d’une structure, nous omettrons les constantes multiplicatives et les termes d’ordre inférieur. Dans les chapitres 2 et 3, nous allons définir un certain nombre de notions. En fait, dans le chapitre 2, nous allons définir des notions de base dans le domaine des arbres binaires de recherche et dans le chapitre 3, nous allons définir des propriétés permettant de caractériser le coût d’exécution d’une séquence d’accès dans un arbre binaire de recherche. Parmi ces propriétés, nous pouvons notamment citer l’optimalité statique et l’optimalité dynamique. Dans le chapitre 4, nous allons étudier les arbres binaires de recherche optimaux. Cette structure se base sur une connaissance préalable de la distribution des clés à accéder pour atteindre l’optimalité statique. Dans les chapitres 5 et 6, nous allons présenter deux catégories d’arbres binaires de recherche : les arbres binaires de recherche équilibrés et les arbres binaires de recherche auto-ajustables. Chacune de ces catégories sera illustrée par une structure de données : les arbres rouges-noirs pour la première et les splay trees pour la seconde. Nous détaillerons les algorithmes de ces structures et nous analyserons leurs performances. Dans le chapitre 7, nous présenterons trois bornes inférieures sur le coût d’exécution d’une séquence d’accès dans un arbre binaire de recherche. Une de ces trois bornes est la borne inférieure d’entrelacement. Celle-ci est à la base de la structure Tango étudiée dans le chapitre 8. Cette structure présente un grand intérêt, parce qu’elle approche l’optimalité dynamique à un facteur log(log n) près. Dans le chapitre 9, nous étudierons la structure des multi-splay trees, qui approche également l’optimalité dynamique à un facteur log(log n) près. Cette structure présente comme principal intérêt d’être plus facile à implémenter que Tango. À l’heure actuelle, aucune structure de données n’atteint l’optimalité dynamique, mais nous présenterons dans le chapitre 10 un cas particulier où cette propriété est atteinte. Nous montrerons dans ce chapitre, que nous pouvons atteindre l’optimalité dynamique en changeant le modèle de coût et en permettant à la structure d’effectuer un nombre quelconque d’opérations de réorganisation entre chaque accès sans que cela soit comptabilisé dans le coût d’exécution. Pour analyser les performances en pratique des structures étudiées, nous en avons implémenté certaines pour y exécuter des jeux de tests. Dans le chapitre 11, nous fournirons les résultats des jeux de tests exécutés, ainsi que les conclusions que nous pouvons en tirer. L’implémentation des structures, accompagnée d’explications, est fournie en annexe. 6 Première partie Fondements et définitions 7 Chapitre 2 Arbres binaires et complexité Dans ce chapitre, nous allons définir toute une série de notions de base dans le domaine des arbres binaires de recherche et dans le domaine de la complexité. Ces rappels seront utiles au lecteur de manière à poser ces notions, qui seront sans cesse utilisées. 2.1 Arbres binaires Dans cette section, nous allons donner la définition d’un arbre binaire, ainsi que de la terminologie associée à ce type de structure. 2.1.1 Définitions Cette section est basée sur les notes d’un cours dispensé par Olivier Markowitch [Mar02]. Un arbre est une structure de données permettant de stocker un ensemble d’éléments et il consiste en une collection de nœuds et d’arêtes (on utilisera aussi le terme arc pour désigner une arête). Un nœud permet de stocker un élément de cet ensemble et une arête (arc) est un lien entre deux nœuds. L’organisation de l’arbre est telle que deux nœuds quelconques de l’arbre sont reliés entre eux par un chemin unique. Un chemin est une suite de nœuds distincts dans laquelle deux nœuds successifs sont reliés par une arête. Un nœud peut posséder des fils. Un nœud y est le fils d’une nœud x si y se situe en-dessous de x dans l’arbre et s’il est relié à ce nœud par une arête. Un nœud x possède également un père ; il s’agit du nœud ayant x comme fils. Les nœuds qui n’ont pas de fils sont appelés des feuilles. Le nœud au sommet de l’arbre est appelé la racine et il ne possède pas de père. 8 Un arbre binaire est un arbre, où chaque nœud possède au plus deux fils appelés fils gauche et fils droit. Les ancêtres d’un nœud x sont les nœuds qui appartiennent au chemin reliant la racine de l’arbre à x. Les descendants d’un nœud x sont les nœuds qui ont x comme ancêtre. Le sous-arbre d’un nœud x dans un arbre A est la partie de A contenant x, ainsi que ses descendants. Le sous-arbre gauche d’un nœud x est le sousarbre du fils gauche de x. Le sous-arbre droit d’un nœud x est le sous-arbre du fils droit de x. Un sous-arbre est vide s’il ne contient pas d’éléments ; par exemple si x n’a pas de fils droit, alors le sous-arbre droit de x est vide. 2.1.2 Profondeur d’un nœud Cette section est basée sur les notes d’un cours dispensé par Olivier Markowitch [Mar02]. La profondeur d’un nœud x, notée d(x), est le nombre d’arcs sur le chemin allant de la racine de l’arbre au nœud x plus 1. De manière plus formelle : 1. d(x) = 1 si x est la racine de l’arbre 2. d(x) = 1 + d(parent) sinon où parent est le nœud père de x. 2.1.3 Hauteur d’un nœud Cette section est basée sur les notes d’un cours dispensé par Olivier Markowitch [Mar02]. La hauteur d’un nœud x, notée h(x), est le nombre d’arcs sur le chemin allant de x à la feuille la plus profonde du sous-arbre de x. De manière plus formelle : 1. h(x) = 0 si x est feuille 2. h(x) = 1 + max{h(f g), h(f d)} sinon où fg et fd sont, respectivement, les fils gauche et droit de x. 2.1.4 Rotations Il existe trois types de rotations possibles sur les arbres binaires : 1. une rotation simple 2. une rotation double 3. une rotation zig-zig 9 Fig. 2.1 – Rotation simple entre x et y . 2.1.4.1 Rotation simple Cette opération a été décrite pour la première fois dans l’article d’Adel’son-Vel’skii et Landis [AVL62]. Une rotation simple entre un nœud x et un nœud y (avec d(x) > d(y), où d(x) est la profondeur de x et d(y) est la profondeur de y) consiste à permuter la profondeur de x et de y comme décrit à la figure 2.1. Une rotation simple est dite droite si x est le fils gauche de y et elle dite gauche si x est le fils droit de y. La rotation à la figure 2.1 correspond à une rotation droite. La rotation gauche est déduite de manière symétrique par rapport à la rotation droite. 2.1.4.2 Rotation double Une rotation double entre un nœud x, un nœud y et un nœud z (avec d(x) > d(y) > d(z)) consiste à effectuer une rotation simple entre x et y, puis une rotation simple entre x et z (cfr figure 2.2). Pour pouvoir effectuer une rotation double, il faut que x soit un fils droit (respectivement fils gauche) et y soit un fils gauche (respectivement fils droit). 10 Fig. 2.2 – Rotation double entre x, y et z. 2.1.4.3 Rotation zig-zig Cette opération a été décrite pour la première fois dans l’article de Sleator et Tarjan [ST85]. Une rotation zig-zig entre un nœud x, un nœud y et un nœud z (avec d(x) > d(y) > d(z)) consiste à permuter la profondeur de x et de z comme décrit à la figure 2.3. Pour pouvoir effectuer une rotation zig-zig, il faut que x et y soient tous deux des fils gauches ou des fils droits. 2.2 Arbres binaires de recherche Le point principal dans cette section consiste à définir le modèle des arbres binaires de recherche, qui sera étudié. Il faut noter, qu’il existe plusieurs modèles d’arbres binaires de recherche. En général, ces variantes sont équivalentes (à un facteur constant près) en terme de performances au modèle décrit ci-dessous. 11 Fig. 2.3 – Rotation zig-zig entre x, y et z. 2.2.1 Modèle Nous considérons le modèle défini par Demaine, Harmon, Iacono et Patrascu [DHIP04]. Ils ont défini ce modèle en se basant sur celui de Wilber [Wil89], qui correspond également à celui utilisé par Sleator et Tarjan [ST85]. Un ABR(Arbre Binaire de Recherche) est un arbre binaire qui maintient un ensemble de clés selon une règle précise : pour chaque nœud x de l’arbre, la clé de x est plus grande que toutes les clés contenues dans le sous-arbre gauche de x et elle est plus petite que toutes les clés contenues dans le sousarbre droit de x. Dans cette définition, nous considérons qu’un ABR ne supporte qu’une seule opération : la recherche d’un élément. Cette recherche se fait sur un ensemble statique de n clés. Nous considérons seulement des recherches réussies, appelées accès. Nous supposons également, sans nuire à la généralité, que chaque clé d’un ABR prend une valeur unique dans l’ensemble {1, 2, . . . , n}. Un ABR est utilisé pour servir une séquence d’accès, qui consiste en une suite de clés X = x1 , x2 , . . . , xm . L’algorithme d’accès de l’ABR permet, en fait, de servir chaque accès xi de cette séquence. Pour accéder à un nœud xi , l’algorithme d’accès ne peut utiliser qu’un seul pointeur P durant le parcours de l’arbre. Au début de l’accès, ce pointeur est initialisé à la racine de l’arbre. 12 L’algorithme d’accès peut, ensuite, réaliser les opérations de coût unitaire suivantes : 1. déplacer le pointeur P vers son fils gauche 2. déplacer le pointeur P vers son fils droit 3. déplacer le pointeur P vers son père 4. effectuer une rotation simple entre le pointeur P et son père L’algorithme d’accès réalise une séquence des opérations ci-dessus jusqu’à ce que le pointeur P atteigne le nœud xi . Le coût d’accès au nœud xi est le nombre d’opérations de coût unitaire réalisées pour l’atteindre. Dès lors, le coût !m exigé par un ABR pour exécuter une séquence X = x1 , x2 , . . . , xm vaut i=1 cout(xi ). Nous désignerons ce modèle par le terme modèle dynamique. Comme autre modèle pour les arbres binaires de recherche, nous pouvons citer le modèle standard. Ce modèle est défini dans les articles [DSW05] et [BCK03] et il sera abordé dans le chapitre 10. 2.2.2 Classes d’arbres binaires de recherche Il existe deux grandes classes d’ABR, basées sur la manière de choisir l’opération de coût unitaire suivante à réaliser lors d’un accès xi : 1. les ABR online 2. les ABR offline 2.2.2.1 ABR online Un ABR online [DHIP04] est une structure de données ABR dans laquelle chaque nœud peut contenir des données supplémentaires. Lors de chaque opération de coût unitaire, les données contenues dans le nœud pointé par le pointeur P peuvent être modifiées. Les données contenues dans les nœuds permettent, en général, de garder de l’information sur les clés déjà accédées, de manière à améliorer le coût d’accès aux clés devant encore être accédées. Lors d’un accès, l’algorithme d’accès choisit l’opération de coût unitaire suivante à réaliser en fonction de la clé à accéder et des informations supplémentaires contenues dans le nœud pointé par le pointeur P . La quantité d’informations supplémentaires contenues dans un nœud doit être la plus petite possible de manière à avoir une incidence négligeable sur le temps d’exécution du programme. En effet, si cette quantité est trop grande, la mise à jour de ces informations après une opération risque de coûter cher. 13 Comme exemples d’ABR online, nous pouvons citer : les arbres rougesnoirs (qui nécessitent un bit de couleur par nœud), les arbres AVL (qui nécessitent un compteur par nœud) et les splay trees (qui ne nécessitent pas d’informations supplémentaires). 2.2.2.2 ABR offline Un ABR offline est une structure de données ABR, qui a une connaissance préalable de la séquence X à exécuter. En fait, il s’agit d’une structure utopique, qui avant même de servir X, connaı̂t déjà les nœuds qui devront être accédés dans cette séquence. De par cette connaissance, une telle structure n’a pas besoin de garder des informations supplémentaires dans ses nœuds. Lors d’un accès xi , le choix de l’opération de coût unitaire suivante à réaliser est basé sur la connaissance de toute la séquence X. La différence entre un ABR online et un ABR offline est que, pour choisir l’opération de coût unitaire suivante à réaliser lors d’un accès xi , ce dernier peut se baser sur les accès qui suivent xi dans la séquence X. 2.3 Performances asymptotiques Cette section est basée sur l’ouvrage de Flajolet et Sedgewick [FS98]. Pour caractériser l’efficacité d’un algorithme, nous étudions ses performances asymptotiques. Ceci consiste à étudier la façon dont varie le temps d’exécution d’un algorithme quand la taille de l’entrée tend vers l’infini. Plusieurs raisons justifient l’usage des méthodes asymptotiques : 1. les performances asymptotiques d’un algorithme sont plus faciles à calculer que son temps d’exécution exact. 2. calculer le temps d’exécution exact d’un algorithme est en général inutile. En effet, pour des entrées suffisamment grandes, les effets des constantes multiplicatives et des termes d’ordre inférieur dans le temps d’exécution exact sont négligeables. 3. les méthodes asymptotiques fournissent de bons critères de comparaison entre les algorithmes. En effet, un algorithme, qui est asymptotiquement meilleur qu’un autre, est plus efficace que ce dernier pour des entrées suffisamment longues. Ci-dessous suivent trois notations asymptotiques permettant de décrire le temps d’exécution asymptotique d’un algorithme. 14 2.3.1 Notation O(.) Soit N la taille de l’entrée d’un algorithme. Nous disons que le temps d’exécution T (N) d’un algorithme est en O(g(N)) (dit grand o de g de N ) s’il existe des constantes positives c1 et n0 telles que : ∀n ≥ n0 , 0 ≤ T (n) ≤ c1 .g(n) (2.1) Nous constatons, que pour n suffisamment grand, T (n) est borné supérieurement par c1 · g(n). Intuitivement, la notation O(.) sert à majorer la fonction T (N). 2.3.2 Notation Ω(.) Soit N la taille de l’entrée d’un algorithme. Nous disons que le temps d’exécution T (N) d’un algorithme est en Ω(g(N)) (dit grand omega de g de N ) s’il existe des constantes positives c1 et n0 telles que : ∀n ≥ n0 , 0 ≤ c1 .g(n) ≤ T (n) (2.2) Nous constatons, que pour n suffisamment grand, T (n) est borné inférieurement par c1 · g(n). Intuitivement, la notation Ω(.) sert à minorer la fonction T (N). 2.3.3 Notation Θ(.) Soit N la taille de l’entrée d’un algorithme. Nous disons que le temps d’exécution T (N) d’un algorithme est en Θ(g(N)) (dit grand theta de g de N ) s’il existe des constantes positives c1 , c2 et n0 telles que : ∀n ≥ n0 , 0 ≤ c1 .g(n) ≤ T (n) ≤ c2 .g(n) (2.3) Nous constatons, que pour n suffisamment grand, T (n) est borné inférieurement par c1 · g(n) et supérieurement par c2 · g(n). Intuitivement, la notation Θ(.) fournit une valeur approchée de la fonction T (N). 2.3.4 Lien entre les notations asymptotiques Une fois les trois définitions ci-dessus présentées, il est assez aisé d’établir un lien entre elles. Ce lien est explicité par le théorème suivant. Théorème 1. Pour deux fonctions quelconques g(n) et f(n), nous avons que f(n) = Θ(g(n)) si et seulement si f(n) = O(g(n)) et f(n) = Ω(g(n)). 15 Ce théorème peut facilement être démontré en prouvant d’abord l’implication du théorème dans un sens, puis en prouvant son implication dans l’autre sens et cela en se basant à chaque fois sur la définition de O(.), Θ(.) et Ω(.) 2.4 Complexité Cette section est basée sur l’ouvrage de Jean Cardinal [Car04] et l’article de Tarjan [Tar85]. Il existe principalement trois manières d’analyser la complexité d’une structure : 1. effectuer une analyse au pire cas 2. effectuer une analyse amortie 3. effectuer une analyse du cas moyen Dans cette section, nous allons définir ces trois analyses et développer de manière plus précise l’analyse amortie. 2.4.1 Complexité au pire cas L’analyse au pire cas consiste à caractériser la complexité d’une opération dans le pire des cas. Ce pire cas peut se produire de manière répétée ou non. Si ce pire cas peut se produire de manière répétée, la complexité au pire cas donne une bonne indication des performances en pratique de la structure. Par contre, si ce pire cas ne peut pas se produire souvent, elle ne donne pas une indication concrète sur les performances en pratique. Ceci peut être illustré par l’exemple suivant : la complexité au pire cas de l’algorithme de Knuth-Morris-Pratt (pour la recherche de motifs) est bien meilleure que la complexité au pire cas de l’algorithme de Boyer-Moore, alors qu’en pratique ce deuxième algorithme est plus efficace que le premier. 2.4.2 Complexité moyenne L’analyse de la complexité moyenne est en général délicate. La complexité moyenne est calculée sur la base d’une distribution de probabilités sur l’ensemble des entrées de l’algorithme, pour laquelle des hypothèses ont été posées. La source de l’aléatoire vient alors des entrées. Cette complexité donne des informations assez significatives concernant les performances en pratique si les pire cas sont rares. 16 Il existe une seconde manière de calculer une complexité moyenne, dans laquelle la source de l’aléatoire ne vient pas des entrées, mais de l’algorithme qui fait des choix aléatoires. La complexité est calculée sur ces choix et on parle alors de complexité moyenne randomisée. 2.4.3 Complexité amortie 2.4.3.1 Définition La complexité amortie est la complexité au pire cas d’une séquence de M opérations divisée par M. La complexité amortie donne des informations significatives concernant les performances en pratique d’une structure, tout en se basant sur les performances au pire cas. Cette analyse apparaı̂t, donc, comme un bon intermédiaire entre la complexité moyenne et la complexité au pire cas. Elle permet, mieux que les deux analyses précédentes, de concevoir des structures efficaces en pratique. Il existe plusieurs méthodes pour effectuer une analyse amortie d’une structure. La méthode qui sera développée dans le point suivant est la méthode du potentiel. 2.4.3.2 Méthode du potentiel La méthode du potentiel nécessite de définir une fonction potentiel. Cette fonction peut être comparée à un compte bancaire. Celui-ci ne peut pas descendre en dessous d’une certaine valeur (par exemple 0). Lorsqu’un achat nécessite un coût moindre que prévu, l’argent restant est remis dans le compte et celui-ci augmente. Par contre, lorsque le coût d’un achat est supérieur à celui prévu, il faut aller puiser dans le compte pour payer le reste et celui-ci diminue donc. Cette analogie permet de donner une première idée de ce qu’est une fonction potentiel. Une définition plus précise est donnée ci-dessous. La fonction potentiel est une valeur qui est bornée inférieurement par une constante. Cette borne dépend de la structure, mais vaut en général 0. La fonction potentiel va augmenter, lorsque le temps réel pour effectuer une opération est inférieur à son temps amorti. Et elle va diminuer, si le temps réel pour effectuer une opération est supérieur à son temps amorti. En d’autres termes, lorsque le coût effectif d’une opération est inférieur à son coût amorti, la fonction potentiel sauvegarde la différence entre ces deux coûts, et lorsque le coût d’une opération est supérieur à son coût amorti, la fonction potentiel puise dans sa réserve pour compenser ce surcoût. 17 Pour prouver une complexité amortie CA , il faut établir l’équation suivante, qui utilise une fonction potentiel P : CA = CE + $P, (2.4) où CE est la complexité effective de l’opération et $P est la variation de la fonction potentiel entre le début et la fin de l’opération. Pour établir l’équation ci-dessus, il faut prouver, qu’à la ième (∀ 1 ≤ i ≤ M) opération d’une séquence de M opérations, l’égalité suivante est vérifiée : CA = CE (i) + $P (i), (2.5) CA = CE (i) + P (i) − P (i − 1) (2.6) où CE (i) est le coût effectif de la ième opération et $P (i) est la variation de potentiel à la ième étape, c’est-à-dire la différence entre le potentiel après la ième étape (= P (i)) et le potentiel avant la ième étape (= P (i − 1)). Cette équation peut être réécrite de la manière suivante : 2.5 Conclusion Dans ce chapitre, nous avons d’abord défini la notion d’arbre binaire, ainsi que la terminologie associée à ce type de structure. Ensuite, nous avons défini le modèle dynamique des arbres binaires de recherche. Et finalement, nous avons présenté plusieurs outils (les performances asymptotiques et les différents types de complexité) permettant de caractériser les performances d’une structure ABR et permettant de comparer les performances de telles structures. 18 Chapitre 3 Propriétés des arbres binaires de recherche Dans ce chapitre, nous allons présenter plusieurs propriétés caractérisant le coût d’exécution d’une séquence d’accès dans des ABR. Ces propriétés sont également valables pour certaines structures qui ne sont pas des ABR. 3.1 Relations entre les propriétés Nous allons décrire dans ce chapitre six propriétés : 1. Static Finger 2. Optimalité Statique 3. Working Set 4. Dynamic Finger 5. Unifiée 6. Optimalité Dynamique La figure 3.1 illustre la relation d’implication entre ces propriétés. La propriété static finger est la plus faible de toutes, car elle est une conséquence de toutes les autres propriétés. De plus, les propriétés working set et dynamic finger sont indépendantes. Les définitions des propriétés ci-dessus sont basées sur deux articles de Iacono [Iac01] et [Iac05]. 3.2 Static finger Considérons un ensemble de n clés {1, 2, . . . , n}, une séquence d’accès X = x1 , x2 , . . . , xm et un nœud spécifique f , appelé le finger, appartenant à 19 Fig. 3.1 – Relation d’implication entre les propriétés. l’ensemble {1, 2, . . . , n}. Une structure a la propriété static finger si le coût total pour exécuter la séquence X vaut : $ " m # log (1 + |xi − f |) (3.1) O i=1 L’expression |xi −f | est la distance dans l’ordre symétrique entre le finger f et le nœud xi . En d’autres termes, la distance |xi − f | est le nombre d’éléments situés entre xi et f dans la séquence triée des nœuds plus 1. Une fois fixé, le finger reste le même durant toute l’exécution de la séquence X. Les Finger Search Trees [BLM+ 03] possèdent cette propriété, mais cette structure ne répond pas à la définition du modèle dynamique des ABR (cfr point 2.2.1), car son opération de recherche peut débuter à partir d’une feuille quelconque de l’arbre. Les splay trees [ST85] possèdent également cette propriété. De l’équation (3.1), nous constatons que plus la distance entre le finger f et le nœud xi est petite, plus le coût sera petit et inversement. Par conséquent, pour une structure ayant la propriété static finger, le coût total pour exécuter une séquence X sera déterminé par la distance dans l’ordre symétrique entre le finger f et chaque nœud de la séquence X à exécuter. 20 3.3 Optimalité statique Considérons un ensemble de n clés {1, 2, . . . , n} et une séquence d’accès X = x1 , x2 , . . . , xm . Une structure a la propriété d’optimalité statique, si elle exécute X avec un coût de : " n % &$ # m f (i) · log O , (3.2) f (i) i=1 où f (i) est le nombre d’accès à i dans la séquence X. Supposons que nous ayons une variable aléatoire discrète qui puisse prendre n valeurs ( {1, 2, . . . , n} ), l’entropie de cette variable est la quantité : H =− n # i=1 pi · log (pi ) = n # i=1 % & 1 pi · log , pi où pi est la probabilité que la variable aléatoire soit égale à i. Prenons l’équation (3.2) et réécrivons-la de la manière suivante : " % &$ n # f (i) m O m· · log m f (i) i=1 En posant pi = f (i)/m , nous obtenons : " % &$ n # 1 pi · log = O(m · H) O m· p i i=1 (3.3) (3.4) (3.5) Une structure, qui atteint l’optimalité statique, exécute donc la séquence X avec un coût égal à O(m · H). De plus, elle exécute un accès avec un coût amorti O(H). Les Arbres Binaires de Recherche Optimaux [Knu73] et les splay trees [ST85] sont deux structures ABR ayant cette propriété d’optimalité statique. Ces deux structures feront l’objet d’explications plus détaillées, respectivement, dans les chapitres 4 et 6. 3.4 Working set Considérons un ensemble de n clés {1, 2, . . . , n} et une séquence d’accès X = x1 , x2 , . . . , xm . Soit l(i, x) égal à j si xj est le dernier accès à x dans la séquence x1 , x2 , . . . , xi−1 . Si x n’est pas accédé dans cette séquence, alors l(i, x) vaut 1. Nous définissons w(i, x) comme étant le nombre d’éléments distincts accédés dans 21 la séquence xl(i,x)+1 , xl(i,x)+2 , . . . , xi . Dès lors, w(i, xi ) est le nombre d’éléments distincts accédés dans la séquence xl(i,xi )+1 , xl(i,xi )+2 , . . . , xi . Il s’agit donc du nombre d’éléments distincts accédés entre l’accès xi et le précédent accès à cet élément. Notons w(i, xi ) par w(xi ). Une structure a la propriété working set, si elle exécute une séquence d’accès X avec un coût : $ " m # log (w (xi )) (3.6) O i=1 De cette propriété, nous déduisons que plus il y a d’éléments différents accédés entre deux accès successifs d’un nœud xi , plus le coût d’accès à ce nœud xi sera élevé et inversement. De plus, nous constatons que, si les accès d’une séquence X ne se font que sur un sous-ensemble de k éléments parmi les n, la complexité d’un accès à une clé xi de cette séquence sera d’au plus O(log k), car w(xi ) ne pourra jamais être supérieur à k. Par conséquent, pour une structure ayant la propriété working set, le coût total pour exécuter une séquence X dépend de deux critères : 1. le nombre de clés différentes dans la séquence X. 2. le nombre d’éléments différents accédés entre deux accès successifs d’une même clé dans la séquence X. Plus la première quantité sera petite, plus elle aura d’importance dans le coût d’exécution de la séquence X. En fait, cette propriété est basée sur la localité temporelle : plus la localité temporelle entre deux accès successifs d’une même clé est petite (c’est-à-dire que le temps écoulé entre ces deux accès est petit), plus le coût d’accès à ce nœud est petit et inversement. La Working Set Data Structure proposée par Iacono [Iac01] atteint un pire cas de O(log(w(xi ))) pour un accès xi . Cependant, cette structure ne répond pas à la définition du modèle dynamique des ABR, car elle utilise des pointeurs et des files additionnels. Les splay trees [ST85] possèdent également cette propriété. 3.5 Dynamic finger Considérons un ensemble de n clés {1, 2, . . . , n} et une séquence d’accès X = x1 , x2 , . . . , xm . Une structure a la propriété dynamic finger si le coût total pour exécuter la séquence X vaut : 22 O "m−1 # i=1 log (1 + |xi+1 − xi |) $ (3.7) Cette propriété est analogue à la propriété static finger. La différence étant que pour cette dernière, le finger f est fixe, tandis que pour la propriété dynamic finger, le finger est le dernier élément accédé. Les Level-Linked Trees [BT80] possèdent cette propriété, mais cette structure ne répond pas à la définition du modèle dynamique des ABR, car elle utilise des pointeurs additionnels. Les level-linked trees atteignent un pire cas de O(log (1 + |xi+1 − xi |)) pour un accès. Les splay trees [ST85] possèdent également la propriété dynamic finger. De l’équation (3.7), nous déduisons que l’accès à un nœud est d’autant plus rapide que la distance entre l’élément auquel on doit accéder et le dernier élément auquel on a accédé est petite. En fait, cette propriété est basée sur la localité spatiale : plus la distance entre deux accès adjacents est petite, plus le coût d’accès au nœud est petit et inversement. Par conséquent, pour une structure ayant la propriété dynamic finger, le coût total pour exécuter une séquence X sera déterminé par la distance entre les accès adjacents de la séquence. Plus la distance entre les accès adjacents de la séquence sera grande, plus le coût pour exécuter X sera grand et inversement. 3.6 Unifiée Considérons un ensemble de n clés {1, 2, . . . , n} et une séquence d’accès X = x1 , x2 , . . . , xm . Une structure a la propriété unifiée si elle exécute X avec un coût : $ " m # min (log (w(i, j) + |xi − j| + 1)) (3.8) O i=1 j∈X L’Unified Structure [Iac01] possède cette propriété, mais elle ne répond pas à la définition du modèle dynamique des ABR. Les splay trees [ST85] sont conjecturés avoir la propriété unifiée. À l’heure actuelle, il n’existe aucune structure ABR qui atteigne cette borne. Une structure ayant cette propriété exécutera une séquence avec un coût faible si les accès sont proches en terme de distance à des éléments accédés récemment et inversement. Cette propriété est donc basée sur la localité temporelle et la localité spatiale des accès. 23 La propriété unifiée implique la propriété working set et la propriété dynamic finger. L’intérêt de la propriété unifiée est d’unifier les propriétés working set et dynamic finger ; ces deux propriétés étant indépendantes, car aucune des deux n’implique l’autre. 3.7 Optimalité dynamique Considérons une séquence quelconque d’accès X = x1 , x2 , . . . , xm . Certains ABR peuvent l’exécuter de manière optimale. Notons par OP T (X) ce coût minimal pour exécuter X. Ce coût OP T (X) est le coût de l’ABR offline le plus rapide qui puisse exécuter X. En effet, dans le modèle ABR offline, il n’y a aucune contrainte dans l’algorithme d’accès sur la manière de choisir la prochaine opération de coût unitaire à réaliser. Et en particulier, ce choix peut dépendre des futurs accès à servir dans la séquence. Une structure de données ABR a la propriété d’optimalité dynamique, si elle exécute une quelconque séquence d’accès X avec un coût O(OP T (X)). On dit qu’une structure de données A est α − compétitive (ou a un taux de compétitivité de α) si pour toute séquence X : COUTA (X) ≤ α · OP T (X), (3.9) où COUTA (X) est le coût nécessaire à la structure A pour exécuter X. La conjecture d’optimalité dynamique, énoncée par Sleator et Tarjan [ST85], affirme que les splay trees sont O(1) − compétitif s. Cependant, aucune démonstration n’est venue étayer cette conjecture. Jusqu’il y a peu, le meilleur taux de compétitivité était le taux trivial de O(log(n)), atteint par exemple par les arbres rouges-noirs. Ce n’est qu’en 2004, que ce taux a pu être amélioré par une structure de données appelée Tango [DHIP04]. Cette structure atteint un taux de compétitivité de O(log(log n)). Par après, une autre structure de données, les Multi-Splay Trees ([SW04] et [DSW06]), a été développée sur base de Tango pour atteindre également un taux de compétitivité de O(log(log n)). Ces deux structures seront détaillées dans les chapitres 8 et 9. La propriété d’optimalité dynamique a une signification profonde : connaı̂tre le futur ne permet d’améliorer le temps d’exécution d’une séquence d’accès que d’un facteur constant. En effet, si nous parvenons à développer un ABR online, qui atteint l’optimalité dynamique, cela signifierait que nous sommes parvenus à créer une structure, qui en se basant sur le passé de la séquence d’accès X à exécuter, donne des résultats équivalents, à un facteur constant près, à la meilleure structure de données offline, qui se base sur la 24 connaissance du futur pour exécuter de manière optimale la séquence d’accès X. 3.8 Conclusion Dans ce chapitre, nous avons défini toute une série de propriétés permettant de caractériser le coût d’exécution d’une séquence d’accès dans un ABR. La propriété la plus forte étant bien évidemment l’optimalité dynamique. De plus, nous avons présenté la relation d’implication entre ces propriétés de manière à spécifier celles qui sont les plus fortes et celles qui sont les plus faibles. 25 Deuxième partie Arbres binaires de recherche particuliers 26 Chapitre 4 Arbres Binaires de Recherche Optimaux La rédaction de ce chapitre est basée sur l’ouvrage de Knuth [Knu73]. La structure des ABRO (= Arbres Binaires de Recherche Optimaux) a été développée par Knuth [Knu73]. Le but de cette structure est d’atteindre l’optimalité statique et elle nécessite pour cela de connaı̂tre la distribution des clés à accéder. Pour rappel, une structure a la propriété d’optimalité statique (cfr point 3.3), si elle exécute une séquence d’accès X = x1 , x2 , . . . , xm sur un arbre de n clés {1, 2, . . . , n} avec un coût de : " n % &$ # m f (i) · log O , (4.1) f (i) i=1 où f (i) est le nombre d’accès à i dans la séquence X. 4.1 Généralités Pour construire l’ABRO d’un ensemble de nœuds P = {p1 , p2 , ... , pn }, nous disposons, en plus de ces nœuds, de leur fréquence relative d’accès. Pour tenir compte des recherches infructueuses, nous avons également besoin d’un ensemble de nœuds externes Q = {q0 , q1 , q2 , ... , qn }. Ces nœuds externes représentent les pointeurs nuls des nœuds de l’arbre. Chaque nœud de Q représente un intervalle de nœuds pour lesquels la recherche est infructueuse : q0 représente l’intervalle (−∞, p1 ), qi représente l’intervalle (pi , pi+1 ) (∀ 0 < i < n) et qn représente l’intervalle (pn , ∞). Les nœuds de Q sont accompagnés de leur fréquence relative d’accès. 27 Fig. 4.1 – Arbre dont le coût de recherche est de 2.2 Le coût d’une recherche dans l’arbre T construit sur les clés de P est donné par la quantité : c(T ) = " # 1≤j≤n f (pj ) · prof ondeur(pj ) $ + " # 0≤j≤n f (qj ) · (prof ondeur(qj ) − 1) , où f (pj ) est la fréquence relative d’accès au nœud pj et où f (qj ) est la fréquence relative d’accès au nœud externe qj , c’est-à-dire la probabilité que la clé recherchée appartienne à l’intervalle (qj , qj+1 ). La notion de profondeur est définie au point 2.1.2. Considérons l’arbre T de la figure 4.1 et l’assignation de fréquences suivante : f (p1 ) = 0.15, f (p2 ) = 0.1, f (p3 ) = 0.25, f (q0 ) = 0.05, f (q1 ) = 0.2, f (q2 ) = 0.15, f (q3 ) = 0.1. Le coût d’une recherche dans l’arbre T est le suivant : c(T ) = (0.15) · 3 + (0.1) · 2 + (0.25) · 1 + (0.05) · 3 +(0.2) · 3 + (0.15) · 2 + (0.1) · 1 = 2.2 Pour déterminer l’ABRO de P , il suffit de trouver l’arbre T , dont le coût de recherche c(T ) est le minimum parmi les coûts de recherche de tous les arbres possibles. À noter qu’il peut exister plusieurs arbres, dont le coût de recherche est minimal. 28 $ 4.2 Algorithme exhaustif Pour déterminer l’ABRO de l’ensemble P de nœuds, il suffit de construire tous les arbres possibles sur P , de calculer leur coût de recherche et de retenir celui dont le coût est minimal. Cependant, la complexité d’un tel algorithme est exponentielle. En effet, avec n nœuds il est possible de construire Cn+1 (= le (n + 1)ème nombre de Catalan) arbres binaires de recherche. Or, '2n( n Cn+1 = (n + 1) 4n ∼ π 1/2 · n3/2 Clairement un tel algorithme pour construire l’ABRO est à exclure. 4.3 Algorithme de Knuth L’algorithme de Knuth est un algorithme de programmation dynamique. Il se base sur la propriété suivante : tous les sous-arbres d’un ABRO sont optimaux. En effet, une amélioration du coût de recherche d’un quelconque sous-arbre d’un ABRO mène à une amélioration du coût de recherche de l’ABRO. L’idée est de considérer le problème de manière récursive (cfr figure 4.2). Un ABRO T est constitué d’une racine pk , d’un sous-arbre gauche (qui est un ABRO) et d’un sous-arbre droit (qui est un ABRO). On construit de manière récursive le sous-arbre gauche et le sous-arbre droit de pk avant de construire T . Pour construire l’ABRO avec l’algorithme de programmation dynamique, nous avons besoin de trois tables : 1. la table C, où C(i, j) (∀ 0 ≤ i ≤ j ≤ n) est le coût de l’ABRO sur les nœuds {pi+1 , ... , pj , qi , ... , qj }. 2. la table r, où r(i, j) (∀ 0 ≤ i ≤ j ≤ n) est l’indice k de la racine pk de l’ABRO sur les nœuds {pi+1 , ... , pj , qi , ... , qj }. 3. la table W , où W (i, j) = f (pi+1 ) + . . . + f (pj ) + f (qi ) + . . . + f (qj ) (∀ 0 ≤ i ≤ j ≤ n). Le remplissage de la table C commence à la ligne 0 et se termine à la ligne n. Il est basé sur la formule suivante : 29 Fig. 4.2 – ABRO d’un point de vue récursif C(i, j) = ) 0 si i ≥ j W (i, j) + min (C(i, k − 1) + C(k, j)) si i < j (4.2) i<k≤j L’idée de cette formule est de déterminer pour chaque indice k le coût de l’arbre construit avec pk comme racine, un ABRO de coût C(i, k − 1) comme sous-arbre gauche et un ABRO de coût C(k, j) comme sous-arbre droit ; il faut retenir celui qui minimise la quantité C(i, k − 1) + C(k, j). Une fois k déterminé (si plusieurs indices donnent un coût minimal, il faut en choisir un arbitrairement), il faut stocker cet indice dans r(i, j). Il faut noter que dans le calcul de C(i, j), il faut rajouter le terme W (i, j). En effet, lorsque le sousarbre gauche de coût C(i, k − 1) et le sous-arbre droit de coût C(k, j) sont rattachés à la racine pk , la profondeur de chacun des nœuds dans ces sousarbres augmente de 1. Ceci implique qu’il faut rajouter dans le coût C(i, j) la fréquence de chacun de ces nœuds. Comme il faut également rajouter la fréquence de pk , cela revient finalement à rajouter W (i, j) au coût C(i, j). Il est possible d’optimiser la formule 4.2 en se basant sur l’inégalité suivante démontrée par Knuth dans [Knu73] : r(i, j − 1) ≤ r(i, j) ≤ r(i + 1, j) 30 (4.3) La formule devient alors : C(i, j) = ) 0 W (i, j) + si i ≥ j (C(i, k − 1) + C(k, j)) si i < j min r(i,j−1)≤r(i,j)≤r(i+1,j) (4.4) La construction de l’ABRO en utilisant la formule 4.4 nécessite un coût en O(n2 ) (si n est la taille de l’arbre), alors que la construction avec la formule 4.2 nécessite un coût en O(n3 ) [Knu73]. De plus, le coût d’une recherche dans un tel arbre se fait en O(H) [Knu73] (où H est l’entropie). 4.4 Conclusion Dans cette section, nous avons présenté un algorithme de programmation dynamique, qui construit avec un coût en O(n2) une structure qui atteint l’optimalité dynamique. Cette structure est statique et elle requière la connaissance de la fréquence des nœuds. En 1979, Bitner [Bit79] a présenté et analysé les Arbres Monotones Dynamiques. Cette structure est un ABR dynamique (un ABR qui permet les insertions et les suppressions), qui essaie d’approximer l’ABRO. 31 Chapitre 5 Arbres binaires de recherche équilibrés Dans ce chapitre, nous allons d’abord définir la notion d’arbre binaire de recherche équilibré. Ensuite, nous allons illustrer ce concept par la présentation des arbres rouges-noirs. Le choix de cette structure plutôt qu’une autre se justifie par le fait qu’elle fait partie des plus connues et des plus efficaces de sa catégorie. 5.1 Définition Un arbre binaire de recherche équilibré est un ABR, qui maintient des informations supplémentaires dans ses nœuds, de manière à ce qu’il reste équilibré. Le but de cet équilibrage est qu’à tout moment, la hauteur de l’arbre soit logarithmique, à un facteur constant près, en le nombre d’éléments dans l’arbre. Ceci permet d’avoir des opérations sur la structure en temps logarithmique en le nombre d’éléments dans l’arbre. 5.2 Arbres rouges-noirs Dans la littérature, il existe deux manières de présenter les arbres rougesnoirs : soit de les décrire comme une implémentation des arbres 2-3-4 (une description de cette structure-ci est présentée dans l’ouvrage [Sed04]), soit de les décrire comme une structure vérifiant un certain nombre de conditions, qui permettent d’équilibrer l’arbre. Les deux méthodes sont bien évidemment équivalentes. La première méthode est plus intéressante, car elle permet de comprendre le sens des propriétés imposées aux arbres rouges-noirs. Elle permet, de plus, de comprendre aisément les algorithmes d’insertion et de 32 suppression des arbres rouges-noirs, ainsi que leur preuve de correction. La deuxième méthode permet de présenter la structure de manière plus brève. Nous utiliserons la deuxième méthode pour présenter la structure. Cette description est basée sur les ouvrages de Jean Cardinal [Car04] et de Sedgewick [Sed04]. 5.2.1 Définition Les arbres rouges-noirs ont deux types d’arcs : les arcs rouges et les arcs noirs. Un seul bit par nœud x est nécessaire comme information supplémentaire. Ce bit indique si l’arc pointant vers ce nœud x est rouge ou noir. Un arbre, pour répondre à la définition d’arbres rouges-noirs, doit vérifier les propriétés suivantes : 1. dans un chemin de la racine à une feuille, il n’y a jamais deux arcs rouges successifs 2. le nombre d’arcs noirs dans un chemin de la racine à un sous-arbre vide est le même quel que soit le chemin 3. le bit de couleur de la racine est noir Ces conditions permettent d’équilibrer l’arbre, comme il le sera démontré plus loin dans ce chapitre, de manière à ce que les opérations sur les arbres rouges-noirs se fassent en temps logarithmique au pire cas en le nombre d’éléments dans l’arbre. 5.2.2 Recherche L’algorithme de recherche pour les arbres rouges-noirs est identique à l’algorithme d’accès des ABR défini pour le modèle dynamique (cfr point 2.2.1). 5.2.3 Insertion L’algorithme d’insertion pour les arbres rouges-noirs est le suivant. En commençant le traitement à la racine, il faut effectuer les opérations suivantes : 1. Désignons par x le nœud courant. Si x a deux arcs sortants rouges, il faut traiter ce nœud de la manière suivante : (a) colorier les deux arcs sortants de x en noir (b) colorier l’arc liant x à p (où p est le père de x) en rouge 33 (c) si l’arc liant p à g (où g est le père de p ) est rouge, il faut effectuer une rotation. Cette rotation est simple (rotation entre p et g) si x et p sont tous deux des fils gauches ou s’ils sont tous deux des fils droits ; elle est double sinon. Après cet éventuel traitement, il faut déplacer le pointeur courant sur le fils gauche ou le fils droit de x suivant que la clé à insérer soit, respectivement, plus petite ou plus grande que x. Cette première étape est à itérer tant que le nœud courant ne pointe pas sur un sous-arbre vide. 2. À ce niveau, le pointeur courant pointe sur un sous-arbre vide. Il ne suffit, dès lors, qu’à insérer le nouveau nœud dans ce sous-arbre vide en coloriant son bit de couleur en rouge. Après cela, si l’arc liant p (où p est le père du nœud inséré) à g (où g est le père de p) est rouge, il faut effectuer une rotation. Cette rotation est simple (rotation entre p et g) si le nœud inséré et p sont tous deux des fils gauches ou s’ils sont tous deux des fils droits ; elle est double sinon. Les notions de rotation simple et de rotation double sont définies au point 2.1.4. Certains lecteurs pourraient être sceptiques quant à la correction de cet algorithme, mais si nous considérons les arbres rouges-noirs comme une implémentation des arbres 2-3-4, alors cette preuve peut facilement être établie. Les utilisateurs intéressés par cela peuvent consulter la référence [Car04] ou [Sed04]. 5.2.4 Successeur L’opération successeur pour un nœud x donne l’élément, qui suit x dans l’ordre symétrique. L’algorithme suivant permet de déterminer le successeur de x. Supposons que le pointeur courant pointe sur le nœud x (si ce n’est pas le cas, il suffit de réaliser une recherche sur cet élément). Ensuite, deux cas sont à envisager, selon que x ait un fils droit ou non : 1. Si x a un fils droit, noté y , il faut se positionner sur le fils droit de x et suivre le chemin des fils gauches à partir de y . Le successeur de x est alors le dernier nœud rencontré sur ce chemin. 2. Si x n’a pas de fils droit, il faut remonter dans l’arbre, tant que x appartient au sous-arbre droit du nœud courant. Le successeur de x est, alors, le premier nœud dans cette remontée, qui contient x dans son sous-arbre gauche. S’il n’existe pas de tel nœud, alors x n’a pas de successeur. 34 5.2.5 Prédécesseur L’opération prédécesseur pour un nœud x donne l’élément, qui précède x dans l’ordre symétrique. L’algorithme suivant permet de déterminer le prédécesseur de x. Supposons que le pointeur courant pointe sur le nœud x (si ce n’est pas le cas, il suffit de réaliser une recherche sur cet élément). Ensuite, deux cas sont à envisager, selon que x ait un fils gauche ou non : 1. Si x a un fils gauche, noté y , il faut se positionner sur le fils gauche de x et suivre le chemin des fils droits à partir de y . Le prédécesseur de x est alors le dernier nœud rencontré sur ce chemin. 2. Si x n’a pas de fils gauche, il faut remonter dans l’arbre, tant que x appartient au sous-arbre gauche du nœud courant. Le prédécesseur de x est, alors, le premier nœud dans cette remontée, qui contient x dans son sous-arbre droit. S’il n’existe pas de tel nœud, alors x n’a pas de prédécesseur. 5.2.6 Analyse des performances Les deux démonstrations présentées dans cette partie sont basées sur l’ouvrage de Sedgewick [Sed04] Lemme 1. Soient un arbre rouge-noir et un nœud x appartenant à cet arbre. Le sous-arbre A de x contient au moins 2bh(x) −1 nœuds (bh(x) est la hauteur noire de x, c’est-à-dire le nombre de nœuds ayant un bit noir entre x (inclus) et la feuille la plus profonde de x moins 1. On impose que bh(x) ≥ 0). Démonstration : La démonstration de ce lemme se fait par récurrence sur la hauteur de x : 1. Initialisation de la récurrence : Si la hauteur de x vaut 0, alors bh(x) vaut 0. Dès lors, le sous-arbre A de x doit contenir au moins 2bh(x) −1 = 20 − 1 = 0 nœud. Or, si x a une hauteur de 0, cela signifie que le sousarbre A consiste en la seule feuille x. Le sous-arbre A contient, donc, un élément et le lemme est, alors, vérifié. 2. Pas de récurrence : Supposons que le lemme soit vrai pour une hauteur h − 1 et montrons qu’il l’est aussi pour une hauteur h. Supposons que le nœud x ait une hauteur h et une hauteur noire bh(x). Considérons le pire cas, où x a deux enfants ; en effet, on augmente ainsi le nombre d’éléments dans le sous-arbre de x. Chaque enfant de x a une hauteur noire qui vaut bh(x) ou bh(x) − 1, selon que son bit de 35 couleur soit respectivement rouge ou noir. Étant donné que la hauteur de chacun de ces enfants vaut h − 1, on peut appliquer l’hypothèse de récurrence sur ces nœuds, et ainsi déduire que le sous-arbre de chacun de ces éléments contient au moins 2bh(x)−1 − 1 nœuds. Finalement, le sous-arbre de x doit contenir au minimum (2bh(x)−1 − 1) + (2bh(x)−1 − 1) + 1 = 2bh(x) − 1 nœuds, ce qui prouve bien le lemme. ! Théorème 2. Un arbre rouge-noir T ayant n nœuds a une hauteur au plus égale à 2 · lg(n + 1) + 2. Démonstration : Supposons que la hauteur de l’arbre rouge-noir T vaut h. Étant donné que dans un chemin allant de la racine à un sous-arbre vide il ne peut y avoir deux arcs rouges successifs et que le bit de la racine est noir, il doit y avoir au moins h/2 nœuds ayant un bit noir sur ce chemin. Dès lors, bh(y) (où y est la racine de T ) vaut au moins h/2 − 1. Par le lemme 1, nous déduisons que T contient au moins 2bh(y) − 1 = 2h/2−1 − 1 nœuds. Sachant que T contient exactement n nœuds, nous en déduisons l’inégalité suivante : n ≥ 2h/2−1 − 1 ⇔ n + 1 ≥ 2h/2−1 En prenant le logarithme des deux membres, nous obtenons : h −1 2 h ⇔ log(n + 1) + 1 ≥ 2 ⇔ 2 · log(n + 1) + 2 ≥ h log(n + 1) ≥ ! Ainsi, par cette propriété, nous constatons que la hauteur d’un arbre rouge-noir à n nœuds vaut O(log n), ce qui implique que les opérations de recherche, d’insertion, de successeur et de prédécesseur se font en O(log n) au pire cas. 5.3 Conclusion Dans ce chapitre, nous avons détaillé la structure des arbres rouges-noirs et analysé ses performances. Celle-ci fournit des performances intéressantes 36 pour une opération : un coût au pire cas de O(log n). Cependant, cette structure n’atteint pas l’optimalité dynamique, car elle est O(log n)−compétitive. Un inconvénient de cette structure est qu’elle n’est pas capable de s’adapter à la séquence d’accès à traiter. Si par exemple une séquence d’accès consiste à accéder plusieurs fois à un même nœud et que celui-ci se trouve au bas de l’arbre, la structure effectuera chacun des accès à ce nœud avec un coût au pire cas de O(log n), alors qu’en le remontant à la racine, elle aurait pu améliorer les accès à ce nœud. 37 Chapitre 6 Arbres binaires de recherche auto-ajustables Dans ce chapitre, nous allons d’abord définir la notion d’arbre binaire de recherche auto-ajustable. Ensuite, nous allons illustrer ce concept par la présentation des splay trees. Et nous terminerons par une comparaison entre les arbres rouges-noirs et les splay trees. 6.1 Définition Un arbre binaire de recherche auto-ajustable est un ABR sur lequel nous appliquons une règle après chaque opération dans le but d’équilibrer l’arbre. En général, le but de cette règle n’est pas d’avoir, à tout moment, une hauteur pour l’arbre qui soit logarithmique en le nombre d’éléments. Par contre, nous nous attendons à ce que, pour une séquence suffisamment longue, la règle équilibre l’arbre de manière à avoir une complexité amortie (cfr point 2.4.3) logarithmique en la taille de l’arbre. 6.2 Splay trees Cette partie contient une présentation des splay trees avec ses principales opérations et l’analyse de ses performances. Le choix de cette structure autoajustable, plutôt qu’une autre, se justifie par le fait que cette structure est sans conteste l’une des plus intéressantes et l’une des plus impressionnantes, qui ait jamais été développée. Elle possède un éventail de propriétés très large, bien qu’elle soit définie sur une idée assez simple. La présentation de cette structure est basée sur l’ouvrage de Jean Cardinal [Car04] et sur l’article de Sleator et Tarjan [ST85]. 38 6.2.1 Splaying L’opération de splaying sur un nœud x consiste à le remonter à la racine par une série de rotations. Trois types de rotations peuvent être employés : 1. la rotation simple (cfr point 2.1.4) : nous l’appliquons si le nœud à splayer n’a pas de grand-père. 2. la rotation zig-zag : nous l’appliquons si le nœud à splayer est un fils gauche (respectivement fils droit) et que son père est un fils droit (respectivement fils gauche). Cette rotation est en fait une rotation double (cfr point 2.1.4). 3. la rotation zig-zig (cfr point 2.1.4) : nous l’appliquons si le nœud à splayer est un fils gauche (respectivement fils droit) et que son père est un fils gauche (respectivement fils droit). 6.2.2 Recherche L’algorithme de recherche dans un splay tree est identique à l’algorithme d’accès des ABR défini pour le modèle dynamique (cfr point 2.2.1), hormis qu’après avoir atteint le nœud désiré, il faut effectuer un splaying sur ce nœud. 6.2.3 Insertion L’algorithme d’insertion pour les splay trees est le suivant. En commençant le traitement à la racine, il faut effectuer les opérations suivantes : 1. Désignons par x le nœud courant. Il faut déplacer le pointeur courant sur le fils gauche ou le fils droit de x suivant que la clé à insérer soit, respectivement, plus petite ou plus grande que x. Cette première étape est à itérer tant que le nœud courant ne pointe pas sur un sous-arbre vide. 2. À ce niveau, le pointeur courant pointe sur un sous-arbre vide. Il ne suffit, dès lors, qu’à insérer le nouveau nœud dans ce sous-arbre vide et à effectuer un splaying sur ce nœud. 6.2.4 Suppression L’algorithme de suppression pour les splay trees est le suivant : 1. Rechercher l’élément à supprimer en utilisant l’algorithme de recherche. 39 2. Lorsque l’élément est repéré, il faut le splayer. Par ce splaying, cet élément devient la racine de l’arbre. 3. Enlever la racine de l’arbre. Cette suppression a pour conséquence de créer deux sous-arbres Tg et Td (Tg est le sous-arbre gauche de la racine enlevée et Td est son sous-arbre droit). 4. Rechercher le plus grand élément de Tg . Pour cela, il faut partir de la racine de l’arbre et suivre le chemin des fils droits. Le dernier nœud rencontré sur ce chemin est l’élément maximal de Tg . 5. Réaliser un splaying du plus grand élément de Tg . Cet élément devient alors la racine de Tg et a un sous-arbre droit vide, car il est le plus grand élément de Tg . 6. Fusionner Tg et Td en accrochant Td comme sous-arbre droit de la racine de Tg . 6.2.5 Analyse des performances L’analyse des performances des splay trees se fera par une analyse amortie. Cette analyse nécessite donc d’introduire une fonction potentiel (cfr point 2.4.3). Le choix de cette fonction est crucial, car de cette fonction dépendent les résultats que nous obtiendrons. Si nous utilisons une mauvaise fonction potentiel, nous ne parviendrons pas à démontrer les résultats escomptés. La fonction potentiel utilisée dans cette analyse est celle introduite par Sleator et Tarjan [ST85]. Elle est décrite dans le paragraphe ci-dessous. Supposons que chaque nœud x d’un arbre T ait un poids w $ (x) ; il s’agit d’une valeur arbitraire positive, mais fixée. La taille s(x) d’un nœud x est la somme des poids de tous les nœuds appartenant au sous-arbre de x (w $ (x) est inclus dans cette somme). Le rang r(x) d’un nœud x est le logarithme en base 2 de sa taille. Finalement, le potentiel d’un arbre T est la somme des rangs de tous ses nœuds. En résumé : w $(x) ∈ R+ , ∀x ∈ T ! s(x) = j∈Tx w $ (j) , où Tx est le sous-arbre de x. r(x) = log(s(x)) , par log nous entendons logarithme en base 2. ! P (T ) = x∈T r(x) En se basant sur cette fonction potentiel, nous pouvons démontrer le lemme suivant, qui est la propriété de base permettant de caractériser les performances amorties des splay trees. Nous nous limiterons pour chaque 40 propriété à l’idée de la démonstration. 6.2.5.1 Access Lemma Le coût amorti nécessaire pour effectuer un splaying sur un nœud x dans un arbre de racine t est d’au plus 3(r(t) - r(x)) + 1. La démonstration de ce lemme est décrite, notamment, dans l’ouvrage de Weiss [Wei99]. L’idée de la démonstration, est de calculer le coût d’une rotation simple, d’une rotation zig-zig et d’une rotation zig-zag en se basant sur la fonction potentiel définie ci-dessus et utilisant l’équation fondamentale pour définir une complexité amortie (CA = CE + $P , cfr point 2.4.3). Le splaying étant une séquence de rotations simple, zig-zig et zig-zag, son coût peut alors être calculé à partir du coût de ces rotations. En partant de cette propriété, il est alors possible de démontrer toute une série de propriétés sur les splay trees en assignant des poids aux nœuds de l’arbre. Les propriétés, ainsi déduites, sont présentées ci-dessous. 6.2.5.2 Théorème d’équilibre Dans un arbre de taille n, le coût total de m opérations vaut O(m · log n). La complexité amortie d’une opération vaut alors O(log n). Pour démontrer ce théorème, il suffit d’utiliser l’Access Lemma et d’assigner un poids de 1/n à chaque nœud de l’arbre. Le théorème d’équilibre est, donc, atteint en imposant une distribution uniforme sur les clés. Nous constatons, donc, que si la distribution sur les clés est uniforme, les splay trees ont un coût de recherche amorti égal au coût de recherche au pire cas d’arbres équilibrés comme les arbres AVL et les arbres rouges-noirs et cela sans utiliser la moindre information de rééquilibrage. 6.2.5.3 Théorème d’optimalité statique La notion d’optimalité statique est définie au point 3.3 et est rappelée ci-dessous. Soient un arbre de taille n et une séquence d’accès X = x1 , x2 , . . . , xm . Le temps d’accès total de cette séquence de m opérations vaut : " n &$ % # m O f (i) · log , (6.1) f (i) i=1 41 où f (i) est la fréquence d’accès du nœud i. Dès lors, f (i)/m est sa fréquence relative. Pour démontrer ce théorème, il suffit d’utiliser l’Access Lemma et d’assigner à chaque nœud sa fréquence relative. En d’autres termes, pour un nœud xi , son poids w $(xi ) vaut f (xi )/m. Les ABRO [Knu73] (cfr chapitre 4) atteignent la propriété d’optimalité statique, mais ils requièrent pour cela de connaı̂tre préalablement la fréquence de chaque nœud. Les splay trees atteignent l’optimalité statique sans cette connaissance préalable de la fréquence de chaque nœud . 6.2.5.4 Théorème du static finger La notion de static finger est définie au point 3.2 et est rappelée ci-dessous. Soient un arbre de taille n, un nœud spécifique f appelé le finger et une séquence d’accès X = x1 , x2 , . . . , xm . Le coût pour traiter cette séquence de m accès vaut : $ " m # log(1 + |xi − f |) , (6.2) O i=1 où |xi − f | est la distance dans l’ordre symétrique entre le finger f et le nœud xi . Donc, |xi − f | est le nombre d’éléments situés entre xi et f dans la séquence triée des nœuds de l’arbre. Pour démontrer ce théorème, il suffit d’utiliser l’Access Lemma et d’assigner à chaque nœud xi de l’arbre un poids w $(xi ) égal à (1+|x1i −f |)2 . Il est important de noter que le finger f est un nœud quelconque de l’arbre, mais une fois choisi, ce finger doit rester le même durant toute l’exécution de la séquence d’accès. Ce qui est le plus remarquable est que les splay trees atteignent la propriété static finger pour un quelconque finger possible. En effet, il suffit d’utiliser ce finger dans l’assignation des poids aux nœuds. 6.2.5.5 Théorème du working set La notion de working set est définie au point 3.4 et est rappelée ci-dessous. Soient un arbre de taille n et une séquence d’accès X = x1 , x2 , . . . , xm . Le coût pour traiter cette séquence de m accès vaut : " m $ # O log(w(xi )) , (6.3) i=1 42 où w(xi ) est le nombre d’éléments distincts entre l’accès xi et le précédent accès à cet élément. L’assignation des poids pour ce théorème est plus compliquée, car il faut modifier le poids des nœuds durant l’exécution de la séquence. 6.2.5.6 Théorème du dynamic finger La notion de dynamic finger est définie au point 3.5 et est rappelée cidessous. Soient un arbre de taille n et une séquence X = x1 , x2 , . . . , xm . Le coût pour traiter cette séquence de m accès vaut : "m−1 $ # O log (|xj+1 − xj | + 1) (6.4) j=1 En 1985, époque de la publication de l’article de Sleator et Tarjan [ST85] , ce théorème n’avait pas pu être démontré. Ce n’est qu’en 1995, que Cole [Col00] y est parvenu, mais la démonstration de ce théorème reste très complexe. Le splay tree est la seule structure ayant cette propriété et répondant à la définition du modèle dynamique des ABR (cfr point 2.2.1), mais la démonstration de cette propriété est complexe. Un problème intéressant (proposé par Stefan Langerman) serait d’élaborer une structure relativement simple et ayant la propriété dynamic finger avec une démonstration de cette propriété plus simple que celle des splay trees. 6.2.5.7 Théorème du scanning Soient un arbre de taille n et une séquence d’accès X = 1, 2, . . . , n. Le coût pour traiter ces n accès vaut : O(n) (6.5) Ce théorème est en fait un corollaire du théorème dynamic finger. En effet, comme les nœuds sont accédés par ordre croissant sur les clés, nous avons que |xj+1 − xj | = 1 (∀ j ∈ [1, n − 1]). Et par conséquent, la complexité pour traiter X vaut O(n). 43 6.2.5.8 Conjecture d’unifiée La notion d’unifiée est définie au point 3.6 et est rappelée ci-dessous. Soient un arbre de taille n et une séquence d’accès X = x1 , x2 , . . . , xm . Le coût pour traiter cette séquence de m accès vaut : " m $ # O min (log (w(i, j) + |xi − j| + 1)) (6.6) i=1 j∈X Cette propriété n’a pas encore pu être démontrée pour les splay trees et elle reste donc une conjecture. 6.2.5.9 Conjecture d’optimalité dynamique La notion d’optimalité dynamique est définie au point 3.7 et est rappelée ci-dessous. Soient un arbre de taille n et une séquence d’accès X. Le coût pour exécuter cette séquence vaut : O(OP T (X)) (6.7) Si cette conjecture est démontrée, cela signifierait que les splay trees peuvent exécuter n’importe quelle séquence d’accès avec un coût optimal et cela, par une simple règle de réarrangement et sans garder la moindre information supplémentaire. Ce serait comme si les splay trees étaient capables de prévoir la séquence à exécuter, de manière à pouvoir s’y adapter et l’exécuter de manière optimale. Cette propriété n’est malheureusement pas encore démontrée pour les splay trees. Et à l’heure actuelle, cette structure est l’une des rares à encore pouvoir prétendre atteindre l’optimalité dynamique. 6.3 Comparaisons Nous allons comparer les arbres rouges-noirs et les splay trees. Ces comparaisons sont en général valables pour les ABR équilibrés et les ABR autoajustables. Pour le temps d’exécution au pire cas, les arbres rouges-noirs présentent des performances plus intéressantes que les splay trees. Les splay trees fournissent des performances intéressantes sur de longues séquences d’accès, alors 44 que les arbres rouges-noirs ont pour but de fournir des résultats intéressants sur une seule opération. L’inconvénient des splay trees est que les opérations de réorganisation peuvent être nombreuses, et ainsi coûter cher. Quant aux arbres rouges-noirs, les opérations de rééquilibrage restent limitées, et ne nuisent donc pas aux performances. Au niveau de l’espace mémoire, les arbres rouges-noirs nécessitent de stocker un bit de couleur, alors que les splay trees n’ont pas besoin d’informations supplémentaires. Les arbres rouges-noirs nécessitent donc plus d’espace mémoire, mais ce surplus reste limité (n bits pour un arbre de taille n). En ce qui concerne les propriétés atteintes par les structures, nous avons que les splay trees ont un éventail très large de propriétés, ce qui n’est pas du tout le cas pour les arbres rouges-noirs. Nous remarquons, notamment grâce à ces propriétés, que les splay trees ont la capacité de s’adapter à une séquence d’accès. Ceci est dû au fait que les splay trees gardent près de la racine les nœuds fréquemment accédés. Les arbres rouges-noirs n’ont pas cette capacité de s’adapter à une séquence d’accès, ce qui peut influer sur le temps d’exécution de la séquence. Supposons par exemple, qu’une séquence d’accès consiste à accéder plusieurs fois à un même nœud x situé dans le bas de l’arbre. Les arbres rouges-noirs effectueront chacun des accès à x avec un coût O(log n), alors que pour les splay trees, le premier accès à x se fera avec un coût égal à la profondeur de x et les accès suivants à x se feront en temps constant. 6.4 Conclusion Si la distribution sur les clés est uniforme (théorème d’équilibre), la recherche pour les splay trees se fait avec une complexité amortie en O(log n). Mais pour une distribution qui n’est pas uniforme (théorème d’optimalité statique, théorème du static finger et théorème du working set), les résultats obtenus pour la recherche sont meilleurs que celui obtenu pour la distribution uniforme. On peut comprendre cela de manière intuitive. Si la distribution n’est pas uniforme, certains éléments seront accédés plus souvent que d’autres. Les éléments souvent accédés auront tendance à se situer en haut de l’arbre, alors que les éléments moins fréquemment accédés se situeront dans le bas de l’arbre. Et comme les éléments du haut sont plus souvent accédés, on aura plus d’accès à faible coût, ce qui aura pour conséquence d’améliorer le coût total de l’exécution de la séquence d’accès. 45 Troisième partie Optimalité dynamique 46 Chapitre 7 Bornes inférieures sur le coût des arbres binaires de recherche Ce chapitre est consacré au problème de borner inférieurement le coût d’exécution d’une séquence d’accès par un ABR. L’intérêt d’avoir de telles bornes est double. D’une part, il est possible de développer de nouvelles structures à partir d’une borne inférieure (cela a par exemple été le cas pour la structure Tango que nous étudierons dans le chapitre 8). D’autre part, il serait possible de prouver l’optimalité dynamique grâce à une borne inférieure. En effet, si nous parvenons à démontrer que, pour un ABR, le coût d’exécution d’une quelconque séquence d’accès est égal à la borne inférieure, alors nous en déduirons immédiatement que la structure est optimale dynamiquement. Dans ce chapitre, nous allons présenter trois bornes inférieures sur le temps d’exécution d’une séquence d’accès : La Borne Inférieure de Couverture de Rectangle, La Borne Inférieure d’Entrelacement et La Seconde Borne de Wilber. Les deux dernières bornes seront en fait dérivées de la première. Ce chapitre est basé sur l’article de Derryberry, Sleator et Wang [DSW05], sur l’article de Wilber [Wil89] et sur l’article de Demaine, Harmon, Iacono et Patrascu [DHIP04]. 7.1 Modèle Pour prouver la borne inférieure de couverture de rectangle, nous devons passer au modèle standard. La différence entre ce modèle et le modèle dynamique (cfr point 2.2.1) est que le nœud à accéder doit d’abord être ramené à la racine par une série de rotations. Ensuite, seulement, l’accès au nœud se fait à la racine. Après l’accès au nœud, l’algorithme peut effectuer des rotations pour améliorer le coût des futurs accès. 47 Le coût d’accès est défini comme le nombre de rotations plus 1. Comme une rotation est inversible, le coût dans le modèle standard vaut au plus deux fois celui dans le modèle dynamique. Par conséquent, si nous parvenons à déterminer une borne inférieure sur le coût d’exécution d’une séquence d’accès dans le modèle standard, alors il suffit de prendre cette borne et de la diviser par deux pour obtenir une borne dans le modèle dynamique. 7.2 La borne inférieure de couverture de rectangle Considérons une séquence d’accès comme un ensemble de points dans l’espace à deux dimensions, où la coordonnée x correspond au temps et la coordonnée y correspond à l’espace des clés. Pour une séquence X = x1 , x2 , . . . , xm , chaque accès xi correspond au point (i, xi ). Notons P l’ensemble des points générés à partir de X. Une boı̂te est un rectangle, dont deux coins opposés sont des points de P . Nous considérons deux types de boı̂tes : les boı̂tes hautes et les boı̂tes basses. Une boı̂te est haute si ses coins inférieur gauche et supérieur droit sont dans P . Et une boı̂te est basse si ses coins supérieur gauche et inférieur droit sont dans P . Chaque boı̂te contient un diviseur, qui consiste en une ligne horizontale traversant la boı̂te d’une extrémité à l’autre. L’ordonnée du diviseur est choisie de manière arbitraire, mais elle doit cependant être différente de l’ordonnée de tous les points de P et elle doit être comprise entre l’ordonnée minimale et l’ordonnée maximale de la boı̂te. Deux boı̂tes sont dites en conflit si elles sont toutes deux des boı̂tes hautes ou des boı̂tes basses et si leur intersection contient une partie ou l’entièreté des deux diviseurs. Pour illustrer ces définitions, considérons le schéma de la figure 7.1 représentant les points correspondant à la séquence X = 3(x1 ), 5(x2 ), 1(x3 ), 7(x4 ), 10(x5 ), 2(x6 ). Trois boı̂tes sont illustrées sur ce schéma (les diviseurs sont représentés en pointillés). Les boı̂tes A et B sont hautes, tandis que la boı̂te C est basse. De plus, les boı̂tes A et B sont en conflit, alors que les boı̂tes B et C ne le sont pas. Avant d’énoncer et de démontrer la borne inférieure de couverture de rectangle, nous prouvons le lemme qui suit. Nous notons par LCA(x, y) l’ancêtre commun le plus bas des nœuds x et y. Les démonstrations du lemme 2 et du théorème qui suit ce lemme sont basées sur l’article [DSW05]. La contribution personnelle dans ces deux démonstrations a consisté à aller plus dans le 48 Fig. 7.1 – Représentation graphique de la séquence X = 3, 5, 1, 7, 10, 2. 49 détail en éclaircissant certains points de la démonstration. Lemme 2. Soient a et b deux nœuds distincts dans un ABR T avec a < b. Soient p et c deux nœuds de T avec p parent de c. Si une rotation est effectuée sur les nœuds c et p et qu’elle implique que LCA(a, b) change, alors les quatre propriétés suivantes sont vérifiées : 1. a ≤ c ≤ b 2. a ≤ p ≤ b 3. LCA(a, b) = p avant la rotation 4. LCA(a, b) = c après la rotation Démonstration : Considérons une permutation Π des clés de T , où ces clés sont triées par ordre croissant de profondeur. Les cas d’égalité pour des nœuds ayant la même profondeur sont gérés arbitrairement. On impose une contrainte : les nœuds p et c doivent être adjacents (p précède alors c, car il a une profondeur plus petite que celle de c). Comme les nœuds sont triés par ordre croissant de profondeur, l’arbre obtenu en insérant de manière successive les clés de Π à partir d’un arbre vide est T . Notons que LCA(a, b) est le nœud le moins profond ayant une valeur comprise entre a et b, car tous les autres nœuds ayant une valeur comprise entre a et b appartiennent au sous-arbre de LCA(a, b). Par conséquent, LCA(a, b) est le premier nœud dans Π ayant une valeur comprise entre a et b. Si nous permutons les nœuds p et c dans Π, l’arbre obtenu à partir de cette séquence est celui que nous obtenons en effectuant une rotation sur c et p dans T , et cela parce que la permutation de p et c dans Π implique que le nœud c est inséré avant p. La séquence Π est donc une autre manière de représenter une rotation sur p et c, ainsi que LCA(a, b). Supposons que nous réalisons une rotation sur p et c, et que cela ait comme conséquence que LCA(a, b) change. Cela signifie, qu’au niveau de Π, le fait de permuter p et c implique que le premier nœud dans Π ayant une valeur entre a et b change. Nous en déduisons alors que les nœuds p et c appartiennent à l’intervalle [a, b], car sinon la permutation de p et c dans Π n’aurait pas modifié le premier élément de Π appartenant à [a, b]. De plus, comme le premier élément de Π appartenant à [a, b] est modifié par la permutation de p et c, nous en déduisons que LCA(a, b) était égal à p avant la rotation et que LCA(a, b) vaut c après la rotation. ! 50 Théorème 3 (Théorème de la borne inférieure de couverture de rectangle). Soient une séquence d’accès X = x1 , x2 , . . . , xm et un ensemble P de points correspondant à X. Soit B un ensemble de boı̂tes pour P, qui prises deux à deux, ne sont pas en conflit. Le nombre de rotations nécessaires à un ABR (répondant à la définition du modèle standard) pour traiter X vaut au moins |B|. Démonstration : Nous allons associer à chaque boı̂te Bk de B une rotation Rk réalisée par l’ABR pour traiter X. À deux boı̂tes ne sera pas associée la même rotation, ce qui permettra de déduire que le nombre de rotations effectuées par l’ABR pour traiter X vaut au moins le nombre de boı̂tes dans B. Considérons une boı̂te haute Bk . Disons que le point (i, xi ) est son coin inférieur gauche et que le point (j, xj ) est son coin supérieur droit. Après l’accès xi , nous avons que ce nœud devient la racine de l’arbre. De même, après l’accès xj , nous avons que ce nœud-ci devient la racine de l’arbre. L’intervalle de temps couvert par la boı̂te vaut [i, j]. Au début de cet intervalle, LCA(xi , xj ) vaut xi , car ce nœud est la racine de l’arbre et à la fin de l’intervalle, LCA(xi , xj ) vaut xj , car ce nœud-ci est la racine de l’arbre. Par conséquent, durant l’intervalle [i, j], nous avons que LCA(xi , xj ) a dû passer d’une valeur inférieure au diviseur de Bk à une valeur supérieure au diviseur de Bk . La rotation Rk associée à Bk est la première, qui implique que LCA(xi , xj ) traverse le diviseur de Bk de bas en haut. Par le lemme 2, si LCA(xi , xj ) change à cause d’une rotation, alors cette rotation est appliquée sur deux éléments p et c appartenant à l’intervalle [xi , xj ]. Les points correspondant à ces deux clés ont donc une ordonnée comprise entre xi et xj . La rotation Rk est représentée par une ligne verticale traversant le diviseur de la boı̂te Bk (cfr figure 7.2). Une rotation Rk ne peut pas être associée à deux boı̂tes hautes différentes. En effet, supposons que Rk soit associée à deux boı̂tes différentes Bl et Bk . Dès lors, Rk doit traverser le diviseur de Bl et de Bk . L’intersection de Bl et de Bk doit contenir Rk et par conséquent les diviseurs de Bl et de Bk . Or, cela implique que Bl et Bk soient en conflit, ce qui est impossible car ces deux boı̂tes appartiennent à B et ne sont par définition pas en conflit. De manière analogue, nous pouvons montrer qu’une rotation Rk ne peut pas être associée à deux boı̂tes basses différentes. Une rotation Rk ne peut pas être associée à une boı̂te basse et à une boı̂te haute. Nous prouvons ceci en montrant que la rotation associée à une boı̂te haute est une rotation gauche et la rotation associée à une boı̂te basse est une rotation droite. En effet, considérons la boı̂te haute Bk et la rotation Rk 51 Fig. 7.2 – Rotation Rk représentée par une flèche. 52 p c p c Fig. 7.3 – Avant la rotation on a que p est le parent de c et que p < c qui y est associée et qui porte sur p et c (avec p parent de c). Comme cette rotation entre p et c implique que LCA(xi , xj ) passe d’une valeur inférieure au diviseur de Bk à une valeur supérieure au diviseur de Bk , alors nous avons que p < c par le lemme 2, car LCA(xi , xj ) = p avant la rotation Rk et LCA(xi , xj ) = c après cette rotation. Et comme p est le père de c, alors la rotation entre p et c est une rotation gauche (cfr figure 7.3). Nous pouvons montrer de manière analogue que la rotation associée à une boı̂te basse est une rotation droite. En conclusion, nous avons montré qu’une rotation ne peut pas être associée à deux boı̂tes différentes, ce qui prouve bien la borne inférieure de couverture de rectangle. ! 7.3 Applications de la borne inférieure de couverture de rectangle Dans cette section, nous allons dériver deux bornes à partir de la borne inférieure de couverture de rectangle : 1. la borne inférieure d’entrelacement. Cette borne a été développée par Demaine, Harmon, Iacono et Patrascu [DHIP04]. 2. la seconde borne de Wilber. Cette borne a été développée par Wilber [Wil89] Les démonstrations dans cette section sont basées sur l’article [DSW05]. La contribution personnelle a consisté à détailler les démonstrations en y éclaircissant certains points. 53 7.3.1 La borne inférieure d’entrelacement Soient un ensemble de n clés {1, 2, . . . , n} et une séquence d’accès X = x1 , x2 , . . . , xm . La région gauche d’un nœud xi correspond à xi plus son sousarbre gauche et la région droite de xi correspond à son sous-arbre droit. Nous définissons ci-dessous la borne inférieure d’entrelacement en expliquant la manière de la calculer. Le calcul de la borne inférieure d’entrelacement nécessite de maintenir un arbre parfait P sur les clés {1, 2, . . . , n}. Pour calculer l’entrelacement IB(X, y) d’un nœud y , il faut étiqueter chaque accès xi de X par left si xi appartient à la région gauche de y ou par right si xi appartient à la région droite de y ; si xi n’appartient à aucune des deux régions, il faut l’exclure de la séquence étiquetée. IB(X, y) correspond alors au nombre d’alternances entre les labels left et right dans la séquence étiquetée créée. La borne inférieure d’entrelacement IB(X) de la séquence X correspond à la somme des entrelacements IB(X, y) de chaque nœud y dans P . Nous avons donc : # IB(X) = IB(X, y) (7.1) y∈P Théorème 4 (Théorème de la borne inférieure d’entrelacement). Soient un ensemble de n clés {1, 2, . . . , n} et une séquence d’accès X = x1 , x2 , . . . , xm . Dans le modèle standard, nous avons la borne inférieure suivante sur le coût optimal OPT(X) de l’algorithme offline pour traiter X : OP T (X) ≥ IB(X) + m (7.2) Démonstration : Pour prouver ce théorème, il faut montrer que nous pouvons placer une boı̂te haute pour chaque alternance left-right et une boı̂te basse pour chaque alternance right-left, de sorte que les boı̂tes prises deux à deux ne sont pas en conflit. Comme les boı̂tes hautes et les boı̂tes basses ne peuvent pas être en conflit, il suffit de démontrer que deux boı̂tes hautes ne peuvent pas être en conflit et que deux boı̂tes basses ne peuvent pas être en conflit. Supposons que pour un nœud v, il y ait une alternance left-right dans la séquence étiquetée au temps r. La boı̂te haute, correspondant à cette alternance, est construite de la manière suivante. Soit xl le dernier accès ayant lieu dans le sous-arbre gauche de v strictement avant le temps r. Disons que cet accès ait eu lieu au temps l (l < r). Et soit xr l’accès ayant provoqué l’alternance left-right au temps r. Nous créons la boı̂te correspondant à cette alternance avec (l, xl ) comme coin inférieur gauche, (r, xr ) comme coin 54 supérieur droit et v − ε comme diviseur (avec ε très petit). Nous dénotons cette boı̂te par le triplet ((l, xl ), (r, xr ), v − ε). Considérons deux boı̂tes hautes : B = ((l, xl ), (r, xr ), v − ε) et B $ = ((l$ , x$l ), (r $, x$r ), v $ − ε). Quatre cas sont à envisager : 1. si v = v $ , alors les boı̂tes B et B $ ne peuvent pas se chevaucher, car les intervalles [l, r] et [l$ , r $ ] sont soit disjoints, soit adjacents. Et donc, les boı̂tes ne peuvent pas être en conflit. 2. si v n’est pas un ancêtre de v $ et si v $ n’est pas un ancêtre de v, alors l’espace des clés [xl , xr ] correspondant à l’espace des ordonnées de la boı̂te B et l’espace de clés [x$l , x$r ] correspondant à l’espace des ordonnées de la boı̂te B $ sont disjoints. Ces deux boı̂tes ne peuvent donc pas être en conflit. 3. si v $ est un ancêtre de v et si v < v $ , alors v appartient au sous-arbre gauche de v $ . Et donc, tous les éléments dans l’espace des clés [xl , xr ] de B sont strictement plus petits que v $ . De plus, comme B est une boı̂te haute, nous avons que xl < xr . Tout ceci nous permet de déduire que xl < xr ≤ v $ − 1 < v $ − ε. Ainsi, B ne peut pas contenir le diviseur de B $ et les boı̂tes B et B $ ne peuvent donc pas être en conflit. 4. si v $ est un ancêtre de v et si v > v $ , alors v appartient au sous-arbre droit de v $ . Et donc, tous les éléments dans l’espace des clés [xl , xr ] de B sont strictement plus grands que v $ . De plus, comme B est une boı̂te haute, nous avons que xl < xr . Tout ceci nous permet de déduire que xr > xl ≥ v $ + 1 > v $ − ε. Ainsi, B ne peut pas contenir le diviseur de B $ et les boı̂tes B et B $ ne peuvent donc pas être en conflit. Les cas où v est un ancêtre de v $ sont similaires au cas 3 et 4. Et les cas où B et B $ sont des boı̂tes basses sont analogues aux quatre cas ci-dessus. Nous avons donc prouvé que dans tous les cas possibles B et B $ ne peuvent pas être en conflit. Par conséquent, nous avons prouvé que IB(X) est une borne inférieure sur le nombre de rotations nécessaires à un algorithme pour traiter une séquence X = x1 , x2 , . . . , xm . Comme le coût d’un algorithme pour traiter la séquence X vaut le nombre de rotations effectuées plus m (un coût de 1 est nécessaire pour accéder au nœud ramené à la racine), alors nous pouvons rajouter m à la borne inférieure IB(X). Finalement, nous avons que : OP T (X) ≥ IB(X) + m (7.3) ! Corollaire 1. Soient un ensemble de n clés {1, 2, . . . , n} et une séquence d’accès X = x1 , x2 , . . . , xm . Dans le modèle dynamique, nous avons la borne 55 inférieure suivante sur le coût optimal OPT(X) de l’algorithme offline pour traiter X : 1 (7.4) OP T (X) ≥ · (IB(X) + m) 2 7.3.2 La seconde borne de Wilber Soient un ensemble de n clés {1, 2, . . . , n} et une séquence d’accès X = x1 , x2 , . . . , xm . De manière informelle, nous pouvons définir cette borne de la manière suivante. Pour chaque accès xi de X, nous commençons la traversée de la séquence à partir de xi−1 en reculant jusqu’à x1 . Nous gardons à chaque étape une trace des deux accès les plus proches de xi ; l’un devant être plus petit que xi et l’autre plus grand que xi . La seconde borne de Wilber pour un accès xi est le nombre de fois qu’un record de proximité s’est produit sur un côté différent du dernier record. La seconde borne de Wilber pour la séquence X est égale à la somme des secondes bornes de Wilber de chaque accès de X. De manière formelle, pour déterminer la seconde borne de Wilber pour l’accès xi , il faut trouver la sous-séquence de taille maximale constituée d’accès traversants xc1 , . . . , xcK(i)+1 (K(i) est alors égal à la seconde borne de Wilber pour l’accès xi ) (si nous prenons deux accès adjacents de cette séquence, alors ils représentent une amélioration du record de proximité, mais sur des côtés différents par rapport à xi ) et une sous-séquence d’accès intérieurs xb1 , . . . , xbK(i) telles que : 1. Pour s’assurer que les accès traversants et les accès intérieurs reculent dans le temps, il faut que : cj+1 < bj ≤ cj ≤ c1 = i − 1 (7.5) 2. Pour s’assurer que deux accès traversants successifs se trouvent sur deux côtés différents de xi , il faut que ∀j ∈ [1, K(i)] : (xcj+1 − xi ) · (xcj − xi ) ≤ 0 (7.6) 3. bj est défini comme suit ∀j ∈ [1, K(i)] : * + Sj = k | (cj+1 < k < i) ∧ ((xcj − xi ) · (xk − xi ) > 0) bj = min |xk − xi | k∈Sj Si le minimum est atteint pour plusieurs k ∈ Sj , alors par convention, nous prenons celui dont la valeur est la plus petite. Cette définition de 56 bj assure que xbj est l’élément de l’ensemble M (M est l’ensemble des éléments de X qui ont un indice dans l’intervalle (cj+1 , i)) qui est le plus proche de xi et qui se trouve du même côté que xcj . 4. Pour s’assurer que xcj+2 (∀j ∈ [1, K(i) − 1]) est un nouveau record, il faut que : |xcj+2 − xi | < |xbj − xi | (7.7) Tous les éléments qui appartiennent à l’ensemble M (M est l’ensemble des éléments de X qui ont un indice dans l’intervalle (cj+1, i)) et qui sont du même côté que xcj+2 (par rapport à xi ) sont plus éloignés de xi que l’est xcj+2 . La seconde borne de Wilber pour la séquence X est égale à : m # K(i) (7.8) i=1 Pour illustrer cette définition, prenons la séquence X = 7(x1 ), 15(x2 ), 1(x3 ), 2(x4 ), 5(x5 ), 20(x6 ), 23(x7 ), 10(x8 ). La seconde borne de Wilber pour l’accès 10 vaut 3, car il s’agit du nombre de fois qu’un record de proximité est amélioré sur un côté différent par rapport à 10 (cfr figure 7.4). Calculons cette borne en utilisant la définition formelle. Nous calculons donc la valeur de K(8). Pour cela, prenons les valeurs suivantes pour les indices des accès traversants : 1. c1 = 7 (donc xc1 = 23) 2. c2 = 5 (donc xc2 = 5) 3. c3 = 2 (donc xc3 = 15) 4. c4 = 1 (donc xc4 = xcK(8)+1 = 7) Nous pouvons alors calculer la valeur de Sj (pour j = 1, 2, 3 et 4) définie à la propriété 3 pour en déduire que : 1. b1 = 6 (donc xb1 = 20), car S1 = {6, 7} 2. b2 = 5 (donc xb2 = 5), car S2 = {3, 4, 5} 3. b3 = 2 (donc xb3 = 15), car S3 = {2, 6, 7} Les sous-séquences d’accès traversants 23, 5, 15, 7 et d’accès internes 20, 5, 15 vérifient les quatre propriétés et sont maximales. Nous avons donc que la seconde borne de Wilber pour l’accès 10 vaut 3, car K(8) = 3. 57 Fig. 7.4 – La seconde borne de Wilber pour l’accès 10 vaut 3. 58 Théorème 5 (Théorème de la seconde borne de Wilber). Soient un ensemble de n clés {1, 2, . . . , n} et une séquence d’accès X = x1 , x2 , . . . , xm . Le nombre de rotations nécessaires dans le modèle standard pour traiter la !m séquence X vaut au moins i=1 K(i). Et donc, OP T (X) ≥ m + m # K(i) (7.9) i=1 Ce théorème n’est pas démontré ici, mais une démonstration est disponible dans l’article [DSW05]. 7.4 Conclusion Dans ce chapitre, nous avons présenté trois bornes inférieures sur le temps d’exécution d’une séquence d’accès. La première borne, la borne inférieure de couverture de rectangle, nous a permis d’en dériver deux autres : la borne inférieure d’entrelacement et la seconde borne de Wilber. La borne inférieure d’entrelacement sera utilisée pour élaborer une structure ABR dans le chapitre suivant. Cette borne est en fait une simplification de la Première Borne de Wilber [Wil89]. Et la seconde borne de Wilber est utilisée dans un concept particulier appelé Key-Independent Optimality [Iac05]. Un problème [DSW05] concernant la borne inférieure de couverture de rectangle est de savoir si cette borne est égale à l’optimalité dynamique (à un facteur constant près). Un autre problème [DSW05] concernant cette borne est de savoir si elle peut être calculée en temps polynomial, car la valeur de cette borne est égale à |B|, c’est-à-dire au nombre de boı̂tes qui prises deux à deux ne sont pas en conflit, mais ce nombre dépend de la manière dont on place les diviseurs des boı̂tes. 59 Chapitre 8 Tango Tango est une structure de données, qui atteint un taux de compétitivité de O(log(log n)). Il s’agit de la première structure de données, qui a amélioré le taux de compétitivité trivial de O(log n). Elle a été élaborée en 2004 par Demaine, Harmon, Iacono et Patrascu [DHIP04] et constitue une très grande avancée dans le domaine de l’optimalité dynamique. Dans ce chapitre, nous commencerons par présenter Tango selon une première approche, qui ne vérifie pas la définition du modèle dynamique des ABR (cfr point 2.2.1). Cette approche est nécessaire pour comprendre les fondements du véritable algorithme. Ensuite, nous montrerons comment réaliser cet algorithme avec une structure ABR. Et pour finir, nous analyserons les performances de Tango. 8.1 Structures de données Les sections présentant l’algorithme Tango sont basées sur l’article [DHIP04]. Cet article présente les grandes lignes de l’algorithme. La contribution personnelle a été de présenter cet algorithme en détaillant tous les sous-algorithmes qui le constituent. Supposons que nous ayons un ensemble {1, 2, . . . , n} de n clés et une séquence X = x1 , x2 , . . . , xm . Nous notons par Ti l’état de Tango après avoir effectué les accès x1 , x2 , . . . , xi . À côté de cette structure, nous maintenons également un arbre parfait P , appelé arbre de référence, construit sur les mêmes clés que celles de T . Chaque nœud de l’arbre P contient une information supplémentaire : son fils préféré. Le fils préféré d’un nœud x est son fils gauche si le dernier accès dans le sous-arbre de x dans P a eu lieu dans le sous-arbre gauche de x ou s’il consistait à atteindre x. Le fils préféré de x est son fils droit si le 60 dernier accès dans le sous-arbre de x dans P a eu lieu dans le sous-arbre droit de x. Dans le cas où aucun accès n’a eu lieu dans le sous-arbre de x, alors x n’a pas de fils préféré. L’arbre P est fixe durant toute l’exécution de la séquence X ; seul le fils préféré des nœuds peut changer. Ainsi, l’état Pi de l’arbre P au temps i (l’état de P après avoir exécuté les accès x1 , x2 , . . . , xi ) est seulement déterminé par la séquence d’accès. 8.2 Algorithme de l’arbre de référence L’état Ti de Tango est déterminé à partir de l’état Pi en appliquant l’algorithme suivant : 1. Suivre le fils préféré de la racine, puis le fils préféré de ce fils, et ainsi de suite jusqu’à atteindre une feuille. Nous obtenons, alors, un chemin allant de la racine à une feuille, qu’on appelle le chemin préféré. 2. Compresser ce chemin en un arbre auxiliaire R. À ce stade-ci, nous pouvons nous contenter d’admettre qu’un arbre auxiliaire est un arbre rouge-noir. Des informations supplémentaires seront fournies plus loin dans ce chapitre sur les arbres auxiliaires. 3. Supprimer ce chemin préféré de Pi . Cela a pour conséquence de partitionner cet arbre en plusieurs pièces. 4. Procéder de manière récursive sur chacune de ces pièces. Nous obtenons, alors, un arbre auxiliaire pour chaque pièce. Il ne reste dès lors qu’à accrocher chacun de ces arbres obtenus à l’arbre auxiliaire R, tout en respectant l’ordre ABR sur les nœuds de l’arbre. Pour rappel, l’ordre ABR signifie que, pour chaque nœud x de l’arbre, les éléments dans le sous-arbre gauche de x sont plus petits que x et les éléments dans le sous-arbre droit de x sont plus grands que x. Nous déduisons de cette construction, que l’arbre Ti est un arbre d’arbres auxiliaires, dans lequel l’ordre ABR est vérifié. Nous allons illustrer l’algorithme présenté ci-dessus avec un exemple. Supposons que nous ayons l’arbre Pi de la figure 8.1 et que nous désirons construire l’arbre Ti . Le chemin préféré de l’arbre de la figure 8.1 est constitué des nœuds 3, 1 et 2. Il faut, alors, compresser ce chemin en un arbre auxiliaire. L’article [DHIP04] ne précise pas comment compresser ce chemin. Nous allons , alors, choisir de réaliser cette compression en insérant, à l’aide de l’algorithme d’insertion des arbres rouges-noirs (cfr point 5.2.3), de manière successive les nœuds du chemin préféré en partant d’un arbre vide et en commençant les 61 3 = fils non préféré = fils préféré 1 0 5 2 6 4 Fig. 8.1 – Arbre Pi . insertions avec le nœud racine. Ainsi, pour compresser le chemin préféré de la figure 8.1, il suffit d’effectuer l’opération d’insertion sur les nœuds 3, 1 et 2. Le résultat de ces opérations donne l’arbre auxiliaire de la figure 8.2, que nous désignons par Ri Maintenant, que le chemin préféré de Pi est compressé, il faut le supprimer de l’arbre Pi . Ceci partitionne Pi en deux pièces (Pi1 et Pi2 ) présentées à la figure 8.3. Et il ne reste qu’à effectuer les mêmes opérations sur chaque pièce. Nous traitons, d’abord, la pièce Pi1 constituée du nœud 0. Sa transformation en arbre auxiliaire est triviale et consiste en le nœud 0. Nous désignons cet arbre auxiliaire par Ri1 . Nous traitons, maintenant, la dernière pièce Pi2 constituée des nœuds 4, 5 et 6. Le chemin préféré de cet arbre est constitué des nœuds 5 et 6. La compression de ce chemin, notée R3 , est donnée à la figure 8.4. Après cette compression, il faut retirer le chemin préféré de Pi2 , ce qui donne une unique pièce consistant en le nœud 4. La transformation de cette pièce en arbre auxiliaire est triviale. Nous désignons par R4 cet arbre auxiliaire. Maintenant, il faut rassembler chaque arbre auxiliaire obtenu. On commence par placer R4 comme fils gauche de la racine de R3 , pour obtenir l’arbre auxiliaire relatif à Pi2 . L’agencement de R3 et R4 est réalisé de la sorte, de manière à respecter l’ordre ABR. Ce nouvel arbre auxiliaire est représenté à la figure 8.5 et est noté Ri2 . À présent, il ne reste plus qu’à attacher Ri1 et Ri2 à Ri pour obtenir l’arbre auxiliaire Ti correspondant à Pi . L’arbre Ri1 est placé comme fils gauche du 62 = arc rouge 2 = arc noir = l’isroot bit est activé 1 3 = l’isroot bit est désactivé Fig. 8.2 – Arbre Ri correspondant à la compression du chemin préféré 3, 1, 2 5 4 0 6 Fig. 8.3 – À gauche, l’arbre Pi1 et à droite, l’arbre Pi2 . 63 5 6 Fig. 8.4 – Arbre R3 correspondant à la compression du chemin préféré 5, 6 5 4 6 Fig. 8.5 – Arbre Ri2 obtenu en attachant R4 à R3 . 64 2 1 3 5 0 4 6 Fig. 8.6 – Arbre Ti obtenu en attachant Ri1 et Ri2 à Ri . nœud 1 de Ri et l’arbre Ri2 est placé comme fils droit du nœud 3 de Ri , de manière à vérifier l’ordre ABR. L’arbre résultant Ti est présenté à la figure 8.6 et correspond à la transformation de Pi par l’algorithme Tango. Il existe deux principaux inconvénients avec cette première approche de Tango : 1. la transformation de Pi en Ti est fort coûteuse en temps d’exécution, ce qui nuit aux performances de l’algorithme. 2. cette approche ne vérifie pas la définition du modèle ABR, car deux arbres sont utilisés par l’algorithme. Cependant, cette première approche permet d’avoir une idée globale des principes de l’algorithme Tango et aidera à la compréhension du véritable 65 algorithme. 8.3 Arbres auxiliaires Avant de présenter l’algorithme Tango, nous avons besoin de définir les arbres auxiliaires, ainsi que quatre opérations supportées par cette structure : 1. la concaténation (cet algorithme provient de l’article [Sah05]) 2. l’éclatement (cet algorithme provient de l’article [Sah05]) 3. le cutting (cet algorithme provient de l’article [DHIP04]) 4. le joining (cet algorithme provient de l’article [DHIP04]) Les deux premières opérations sont définies pour être utilisées par les deux dernières. 8.3.1 Définition Un arbre auxiliaire est une structure, qui contient un sous-chemin d’un chemin allant de la racine à une feuille de P (les nœuds de ce sous-chemin sont reliés par des arêtes fils préféré dans P ). Les nœuds d’un arbre auxiliaire sont organisés en fonction de la valeur de leur clé. Chaque nœud x d’un arbre auxiliaire contient des informations supplémentaires : 1. la profondeur du nœud x dans l’arbre parfait P . Cette donnée est fixe durant toute l’exécution d’une séquence et est comprise dans l’intervalle [0, log(n + 1)). Cette information sera désignée par le terme prof. 2. la profondeur maximale dans P des nœuds appartenant au sous-arbre de x dans T . Cette information sera désignée par le terme profondeurMax. 3. la profondeur minimale dans P des nœuds appartenant au sous-arbre de x dans T . Cette information sera désignée par le terme profondeurMin. 4. un bit, nommé isroot, pour indiquer si x est la racine de l’arbre auxiliaire, auquel il appartient. Tango étant un arbre d’arbres auxiliaires, il arrivera, donc, qu’une feuille d’un arbre auxiliaire ait comme fils la racine d’un autre arbre auxiliaire. L’isroot bit permet, donc, d’indiquer s’il y a eu un passage d’un arbre auxiliaire à un autre. Il est important de noter qu’un arbre auxiliaire est implémenté comme un arbre rouge-noir. 66 8.3.2 Concaténation Cette opération porte sur un arbre D tel que la racine est x, le sous-arbre gauche A de x est un arbre rouge-noir et le sous-arbre droit B de x est un arbre rouge-noir ; l’arbre D n’étant pas nécessairement un arbre rouge-noir. Le but de la concaténation est de construire à partir de A, B et x un arbre rouge-noir. Pour réaliser cette opération de manière efficace, il est nécessaire que chaque nœud x de l’arbre stocke une information supplémentaire : sa hauteur noire bh(x) (pour rappel bh(x) est le nombre de nœuds ayant un bit noir entre x (inclus) et la feuille la plus profonde de x moins 1). L’opération de concaténation se réalise par l’algorithme suivant : 1. Si la hauteur noire de la racine de A est égale à la hauteur noire de la racine de B, nous construisons l’arbre rouge-noir C avec x comme racine, A comme sous-arbre gauche de x et B comme sous-arbre droit de x. On colorie les arcs liant x à A et x à B en noir. La hauteur noire de x est alors définie comme la hauteur noire de la racine de A plus 1. 2. Si la hauteur noire de la racine de A est plus grande que la hauteur noire de la racine de B, il faut suivre le chemin des fils droits de A jusqu’au premier nœud y ayant une hauteur noire égale à celle de la racine de B. Il faut remarquer que la hauteur noire du père p de y doit être égale à bh(B) − 1 (où bh(B) correspond à la hauteur noire de la racine de B), car sinon il aurait été le premier nœud sur le chemin des fils droits à avoir une hauteur noire égale à bh(B). Désignons par Y le sous-arbre de y. Nous retirons Y de l’arbre A pour placer le nœud x à la place de Y . Pour que le nœud x ait une hauteur noire égale à bh(B) − 1, nous colorions l’arc entre x et p en rouge. Si l’arc entre g (le père de p) et p est rouge, alors il faut effectuer une rotation simple entre g et p. Cette rotation laisse x sans fils, car il n’en avait pas avant la rotation entre g et p (cfr figure 8.7). Ensuite, il faut attacher Y comme fils gauche de x en coloriant l’arc en noir et l’arbre B comme fils droit de x en coloriant l’arc en noir. 3. Si la hauteur noire de la racine de A est plus petite que la hauteur noire de la racine de B, nous procédons de manière symétrique au cas 2 sur l’arbre B. 8.3.3 Éclatement L’opération d’éclatement porte sur un arbre auxiliaire C et sur un nœud x de cet arbre. Elle réorganise C, de manière à ramener x à la racine de 67 Fig. 8.7 – Rotation simple sur les nœuds g et p. l’arbre avec comme sous-arbre gauche de x un arbre rouge-noir, noté A, et comme sous-arbre droit de x un arbre rouge-noir, noté B. Pour réaliser l’éclatement, il faut d’abord construire les arbres A et B à partir de C avec l’algorithme suivant. Au début les arbres A et B sont vides. 1. Rechercher le nœud x dans C. Lorsque x est localisé, il faut rajouter dans A le sous-arbre gauche de x et dans B le sous-arbre droit de x. 2. Ensuite, il faut remonter le chemin de x à la racine et réaliser l’étape, qui suit, jusqu’à ce que la racine soit atteinte. 3. Soit p le nœud courant. Deux cas doivent être envisagés, selon que x se situe dans le sous-arbre gauche ou dans le sous-arbre droit de p : (a) si x est dans le sous-arbre gauche de p, alors le sous-arbre droit de p contient des nœuds ayant des valeurs plus grandes que celle de x. Ces nœuds doivent, donc, se situer dans l’arbre B. Dès lors, nous effectuons une concaténation avec B, p et le sous-arbre droit de p. Cette concaténation donne un arbre rouge-noir, qui devient B. (b) si x est dans le sous-arbre droit de p, alors le sous-arbre gauche de p contient des nœuds ayant des valeurs plus petites que celle de x. Ces nœuds doivent, donc, se situer dans l’arbre A. Dès lors, nous effectuons une concaténation avec A, p et le sous-arbre gauche de p. Cette concaténation donne un arbre rouge-noir, qui devient A. 68 4. Lorsque tous les éléments sur le chemin de la remontée sont traités, nous obtenons que A forme un arbre rouge-noir avec des éléments ayant des valeurs plus petites que celle de x et que B forme un arbre rouge-noir avec des éléments ayant des valeurs plus grandes que celle de x. Une fois A et B créés, il suffit d’attacher A comme sous-arbre gauche de x avec un arc noir et B comme sous-arbre droit de x avec un arc noir. Bien que cela ne soit pas trivialement vérifiable, la complexité de cette opération est logarithmique en le nombre d’éléments dans l’arbre [Sah05]. 8.3.4 Cutting Le cutting à une profondeur d sur un arbre auxiliaire coupe cet arbre en deux arbres auxiliaires. L’un stockant les nœuds, dont l’information prof est plus petite que d. L’autre stockant les nœuds, dont l’information prof est plus grande que d. Un arbre auxiliaire A stocke un sous-chemin C d’un chemin allant de la racine à une feuille de P . Par conséquent, l’opération de cutting à une profondeur d sur l’arbre A consiste, au niveau de l’arbre P , à scinder C en deux parties. La première partie part du début du chemin C jusqu’au nœud de profondeur d − 1 et la seconde partie part du nœud de profondeur d jusqu’à la fin du chemin C. Ces deux parties correspondent aux deux arbres auxiliaires créés par le cutting. Les nœuds d’un arbre auxiliaire A, qui ont une information prof plus grande que d, forment un intervalle I dans l’espace des clés de cet arbre. Ceci est dû au fait que les nœuds de cet arbre auxiliaire appartiennent à un même sous-chemin d’un chemin allant de la racine à une feuille dans P . L’opération de cutting se base sur cette propriété. Pour réaliser un cutting à une profondeur d, il faut d’abord déterminer les éléments minimal et maximal de l’intervalle I. Il faut, donc, trouver l’élément minimal de A ayant une profondeur plus grande que d (ou égale à d) et l’élément maximal de A ayant une profondeur plus grande que d (ou égale à d). Nous désignerons, respectivement, ces éléments par l et r. Le nœud l est l’élément de l’arbre A le plus à gauche et ayant une profondeur plus grande que d. Déterminer l peut se faire par l’algorithme suivant : 1. Commencer le traitement à la racine de A. 2. Analyser l’information prof du nœud courant x : (a) Si l’information prof est supérieure à d, alors il faut analyser l’information profondeurMax du fils gauche de x : 69 i. Si l’information profondeurMax du fils gauche de x est plus grande que d, alors il existe dans le sous-arbre du fils gauche de x un nœud plus petit que le nœud courant x et ayant une information prof plus grande que d. Dès lors, il faut se déplacer sur le fils gauche de x et recommencer l’étape 2. ii. Si l’information profondeurMax du fils gauche de x est strictement plus petite que d, alors il n’existe pas de nœud plus petit que x et ayant une information prof plus grande que d. Le nœud x correspond, alors, à l et l’algorithme est arrêté. (b) Si l’information prof est strictement plus petite que d, alors il faut analyser l’information profondeurMax des fils de x : i. Si l’information profondeurMax du fils gauche de x est plus grande que d, alors il faut se déplacer sur le fils gauche de x, car le nœud l se situe dans le sous-arbre gauche de x. ii. Si l’information profondeurMax du fils droit de x est plus grande que d, alors il faut se positionner sur le fils droit de x, car le nœud l se situe dans le sous-arbre droit de x. iii. Si l’information profondeurMax du fils gauche et du fils droit de x est plus grande que d, alors il faut se positionner sur le fils gauche de x, plutôt que le fils droit. En effet, comme le sous-arbre gauche de x contient des clés plus petites que celles du sous-arbre droit de x, on est certain que l se situe dans le sous-arbre gauche de x, et non pas dans le sous-arbre droit de x. Cet algorithme n’est correct que s’il existe un nœud l dans l’arbre auxiliaire A. Pour traiter le cas, où il n’existe pas un tel nœud dans A, il suffit d’examiner l’information profondeurMax de la racine de A. Si cette information est strictement plus petite que d, alors il n’existe pas de nœud l dans A. Par contre, si elle est plus grande que d, le nœud l existe et l’algorithme ci-dessus peut être appliqué. Quant au nœud r, il s’agit de l’élément de A le plus à droite et ayant une profondeur plus grande que d. Déterminer r se fait de manière symétrique à l’algorithme pour déterminer l. Une fois l et r déterminés, il faut déterminer le prédécesseur l$ de l et le successeur r $ de r en appliquant, respectivement, les algorithmes prédécesseur (cfr point 5.2.5) et successeur (cfr point 5.2.4) des arbres rouges-noirs. Étant donné que les nœuds qui ont une information prof supérieure à d forment un intervalle I dans l’espace des clés de A, nous allons utiliser 70 cette propriété pour effectuer le cutting. En effet, l’intervalle I correspond à [l, r] ou de manière équivalente à (l$ , r $). Il ne reste, dès lors, qu’à isoler les éléments de cet intervalle dans un sous-arbre, de manière à pouvoir, ensuite, les séparer du reste de l’arbre. L’algorithme, qui suit, se base sur ce principe pour effectuer le cutting (voir figure 8.8) : 1. Effectuer un éclatement sur A en l$ . De par cette opération, l$ devient la racine de l’arbre, ce qui a pour conséquence que les éléments du sous-arbre gauche de l$ ont des clés appartenant à l’intervalle (−∞, l$ ) et que les éléments du sous-arbre droit de l$ ont des clés appartenant à l’intervalle (l$ , ∞). Notons par B et C, respectivement, le sous-arbre gauche de l$ et le sous-arbre droit de l$ . 2. Effectuer sur C un éclatement en r $ . Cette opération fait de r $ la racine du sous-arbre C. Dès lors, le sous-arbre gauche de r $ , noté D, contient des clés appartenant à l’intervalle (l$ , r $) et le sous-arbre droit de r $ , noté E, contient des clés dans l’intervalle (r $ , ∞). Ainsi, par les deux éclatements sur l$ et r $ , on est parvenu à isoler les nœuds, dont l’information prof est supérieure à d, dans un sous-arbre de A. Ce sous-arbre étant D. 3. Activer l’isroot bit de la racine D. Cette opération sépare, de manière effective, le sous-arbre D du reste de l’arbre A et fait de D un arbre auxiliaire. La partie restante de A, obtenue après avoir enlevé D, sera dorénavant désignée par A$ . Une opération d’éclatement ou de concaténation sur A$ ne portera que sur les nœuds de cet arbre auxiliaire ; elle n’affectera pas les nœuds de l’arbre auxiliaire D. À cette étape-ci, D contient les nœuds de A, dont l’information prof est supérieure à d et A$ contient les nœuds de A, dont l’information prof est inférieure à d. Cependant, le fait d’avoir ainsi séparé de r $ son sous-arbre gauche D a pour conséquence, que le sous-arbre de r $ n’est plus forcément un arbre rouge-noir, ce qui implique que l’arbre A$ , également, n’en est plus forcément un. Les deux opérations, qui suivent, ont pour but de transformer A$ en un arbre rouge-noir, et par la même occasion en un arbre auxiliaire. 4. Effectuer une concaténation sur r $ . Le nœud r $ n’ayant pas de sousarbre gauche, l’opération de concaténation forme un arbre rouge-noir à partir du nœud r $ et des nœuds dans le sous-arbre droit de r $ . Notons par C $ cet arbre rouge-noir, ainsi, créé. 5. Effectuer une concaténation sur l$ . Cette opération crée un arbre rougenoir à partir de tous les nœuds de A$ . Ainsi, par ces deux concaténations successives, l’arbre A$ a été réordonné, de manière à devenir un arbre 71 répondant à la définition d’arbre auxiliaire. Il est important de noter que deux concaténations sont nécessaires pour transformer A$ en un arbre auxiliaire. En effet, on pourrait penser à effectuer qu’une seule concaténation (portant sur l$ ), de manière à transformer A$ en un arbre auxiliaire. Mais cette solution aurait été erronée, car la concaténation sur un nœud nécessite que le sous-arbre gauche et le sous-arbre droit de ce nœud soient des arbres rouges-noirs. Or, le sous-arbre droit de l$ , c’est-à-dire le sous-arbre de r $ , n’est plus forcément un arbre rouge-noir, après que son sous-arbre gauche D lui ait été retiré. D’où la nécessité, d’abord, de transformer le sous-arbre de r $ en arbre rouge-noir, avant de réaliser la même opération sur le sous-arbre de l$ . Deux cas particuliers sont à gérer : 1. le nœud l$ n’existe pas (l n’a pas de prédécesseur dans son arbre auxiliaire) : il ne faut alors réaliser des étapes ci-dessus que les étapes 2, 3 et 4. 2. le nœud r $ n’existe pas (r n’a pas de successeur dans son arbre auxiliaire) : il ne faut alors réaliser des étapes ci-dessus que les étapes 1, 3 et 5. 8.3.5 Joining L’opération de joining porte sur deux arbres auxiliaires A et B, et consiste à les réunir pour n’en former qu’un seul. Une précondition à cette opération consiste, au niveau de l’arbre parfait P , à ce que les deux chemins, qui correspondent aux arbres A et B, forment un sous-chemin continu C1 d’un chemin allant de la racine à une feuille de P s’ils sont réunis. Par conséquent, le joining sur A et B consiste à réunir les chemins, qu’ils stockent, pour former le chemin C1 , qui sera stocké dans un nouvel arbre auxiliaire C. Du fait, que le chemin A1 , stocké par A, est adjacent au chemin B1 , stocké par B, nous en déduisons deux propriétés : 1. un des deux arbres A et B est tel que l’information prof de chacun de ses nœuds est plus grande que celle de tous les nœuds de l’autre arbre. 2. l’espace des clés de l’arbre, ayant les informations prof plus grandes, forme un intervalle dans l’espace des clés de l’arbre ayant les informations prof plus petites. Une seconde précondition est nécessaire pour cette opération : l’arbre, ayant les informations prof plus grandes, est attaché à un nœud du second arbre. L’algorithme du joining se base sur tout ceci et est décrit ci-dessous. 72 Fig. 8.8 – Réalisation d’un cutting avec des éclatements et des concaténations. 73 D’abord, il faut déterminer lequel des arbres A et B stocke les nœuds ayant les informations prof plus grandes. Pour cela, il suffit de comparer l’information prof de la racine de A et de la racine de B, et de déterminer laquelle est la plus grande. Supposons, quitte à renommer les arbres, que B stocke les nœuds ayant les informations prof plus grandes. Ensuite, il faut rechercher les nœuds l$ et r $ (posons l$ < r $ ) de A entre lesquels l’espace de clés de B est compris. La recherche de ces éléments se fait par l’algorithme suivant : 1. Rechercher dans A la clé de la racine de B. Étant donné que la racine de B est le fils d’un nœud p de A, cette opération permet d’atteindre ce nœud p. 2. Si la racine de B est le fils gauche de p, alors ce nœud-ci correspond à r $ et comme l$ est le prédécesseur de r $ dans A, ce nœud l$ peut être déterminé en appliquant l’algorithme prédécesseur (cfr point 5.2.5) sur r $ . Ainsi, nous sommes parvenus à identifier l$ et r $ dans ce premier cas. 3. Par contre, si la racine de B est le fils droit de p, alors ce nœud-ci correspond à l$ et comme r $ est le successeur de l$ dans A, ce nœud r $ peut être déterminé en appliquant l’algorithme successeur (cfr point 5.2.4) sur l$ . Ainsi, nous sommes parvenus à identifier l$ et r $ dans ce second cas. Une fois l$ et r $ déterminés, il faut appliquer l’algorithme, qui suit, de manière à réaliser le joining (voir figure 8.9) : 1. Effectuer sur A un éclatement en l$ . Ceci ramène l$ à la racine de l’arbre. Notons par, respectivement, A$ et C le sous-arbre gauche et le sous-arbre droit de l$ . Les clés de A$ appartiennent à l’intervalle (−∞, l$ ) et les clés de C appartiennent à l’intervalle (l$ , ∞) 2. Effectuer sur C un éclatement en r $. Par cette opération, r $ devient la racine du sous-arbre C. Comme les nœuds l$ et r $ sont adjacents, alors r $ est le plus petit élément de C. Dès lors, étant ramené à la racine de C par le second éclatement, r $ contient dans son sous-arbre droit, noté E, tous les éléments de C. Comme r $ ne contient aucun élément de C dans son sous-arbre gauche, on est, alors, certain de n’avoir dans le sous-arbre gauche de r $ que l’arbre auxiliaire B. 3. Désactiver l’isroot bit de la racine de B. Cette opération réunit de manière effective l’arbre A et l’arbre B. L’arbre résultant de cette réunification sera désigné par Z. Le fait de rajouter, ainsi, l’arbre B a pour conséquence que le sous-arbre de r $ n’est plus forcément un arbre rouge-noir, ce qui implique que Z, également, n’en est plus forcément 74 un. Les deux opérations, qui suivent, ont pour but de résoudre ce problème. 4. Effectuer une concaténation sur r $ . Cette opération de concaténation réarrange le sous-arbre de r $ , de manière à le transformer en un arbre rouge-noir. Notons par C $ l’arbre résultant de cette concaténation. 5. Effectuer une concaténation sur l$ . Cette opération réarrange l’arbre Z pour le transformer en un arbre rouge-noir. Ainsi, Z est un arbre auxiliaire, qui contient les éléments de A et B. Il s’agit, donc, de l’arbre résultant du joining de A et B. 8.4 Algorithme Tango Dans la première approche, l’état Ti de l’arbre Tango T est construit à partir de l’état Pi de l’arbre parfait P . Cependant, cette manière de procéder posait plusieurs inconvénients. L’algorithme Tango se base sur les fondements de la première approche, mais il construit différemment l’arbre Ti , de sorte à résoudre les problèmes de la première approche. En fait, l’état Ti est construit à partir de l’état Ti−1 et du prochain accès xi de la séquence X. Il faut remarquer que l’accès au nœud xi , au niveau de l’arbre parfait P , change nécessairement les fils préférés, de manière à avoir un chemin préféré allant de la racine de P à xi et ne change pas d’autres fils préférés. Par convention, nous décidons que pour un nœud accédé son fils gauche doit devenir son fils préféré. Par conséquent, les seuls nœuds, dont les fils préférés peuvent changer à cause de l’accès à xi , sont ceux se trouvant sur le chemin menant de la racine de P à xi (ainsi que le fils droit de xi ). Au niveau de l’arbre Tango T , il est possible, lors de la recherche de xi , de déterminer les nœuds, dont les fils préférés changent, car un changement de fils préféré (excepté pour le changement de fils préféré de xi ) correspond au passage entre deux arbres auxiliaires (disons de A à B). En effet, comme un arbre auxiliaire stocke un chemin préféré, alors le point de passage entre deux arbres auxiliaires correspond à choisir le fils non préféré d’un des nœuds de A, car si on avait choisi son fils préféré, on serait resté dans le même arbre auxiliaire. En se basant sur cette constatation, il est inutile de construire Ti à partir de Pi . Il suffit de partir de Ti−1 , de rechercher le nœud xi dans cet arbre et de changer durant cette recherche le fils préféré des nœuds, pour lesquels l’accès à xi provoque un changement de fils préféré. L’algorithme suivant se base sur cette idée pour construire l’état Ti à partir de Ti−1 et de xi : 1. Commencer la recherche de xi à la racine de Ti−1 . 75 Fig. 8.9 – Réalisation d’un joining avec des éclatements et des concaténations. 76 2. Se déplacer vers le sous-arbre gauche ou le sous-arbre droit du nœud courant x selon que xi soit, respectivement, plus petit ou plus grand que x. Une fois le déplacement effectué, disons sur le nœud y , il faut analyser l’isroot bit de ce nœud : (a) Si l’isroot bit est désactivé, cela signifie que x et y appartiennent au même arbre auxiliaire. Dès lors, l’accès à xi ne modifie pas à ce niveau un fils préféré et aucun traitement additionnel n’est alors nécessaire. (b) Si l’isroot bit est activé, cela signifie qu’on est passé d’un arbre auxiliaire à un autre (disons de A à B). Dès lors, l’accès à xi modifie le fils préféré d’un nœud z de A. Il faut alors effectuer le traitement qui suit pour changer le fils préféré de z . Notons d’abord que le nouveau fils préféré de z est le nœud de B ayant la plus petite information prof : i. Effectuer sur l’arbre auxiliaire A contenant x un cutting à une profondeur d égale à l’information profondeurMin de y , de manière à faire du fils préféré de z son fils non préféré ii. Effectuer un joining entre l’arbre auxiliaire A et l’arbre auxiliaire B, de manière à ce que z ait son nouveau fils préféré. L’étape 2 doit être réitérée tant que le nœud xi n’est pas atteint. 3. Lorsque le nœud xi est atteint, il faut encore faire de son fils gauche son fils préféré. Pour cela, il faut effectuer un cutting sur l’arbre auxiliaire contenant xi à une profondeur d égale à la profondeur de xi plus 1. Ensuite, il faut rechercher le nœud p. Ce nœud est atteint en recherchant le prédécesseur de xi , mais en s’arrêtant sur le premier nœud dont l’isroot bit est activé. Ce nœud p est en fait la racine de l’arbre auxiliaire contenant le fils gauche de xi dans P . En effet, le prédécesseur de xi appartient au sous-arbre du fils gauche de xi dans P , ce qui implique qu’en recherchant dans T le prédécesseur de xi , nous devons obligatoirement passer par l’arbre auxiliaire contenant le fils gauche de xi dans P . Et finalement, il faut effectuer un joining entre l’arbre auxiliaire contenant xi et l’arbre auxiliaire contenant p . 8.5 Analyse des performances Cette section est basée sur l’article [DHIP04]. La contribution personnelle a été de détailler les démonstrations des lemmes et des théorèmes énoncés. 77 Soient un arbre Tango T construit sur les clés {1, 2, . . . , n} et une séquence d’accès X = x1 , x2 , . . . , xm . L’analyse des performances de Tango se base sur la borne inférieure d’entrelacement (cfr point 7.3.1) et nous avons besoin de définir la notion qui suit. La borne d’entrelacement IBi (X) d’un accès xi est la borne inférieure d’entrelacement de la séquence x1 , x2 , . . . , xi , à laquelle on soustrait la borne inférieure d’entrelacement de la séquence x1 , x2 , . . . , xi−1 . Par conséquent, IBi (X) est le nombre d’alternances left-right et right-left introduites en plus par l’accès xi . Durant l’analyse des performances de Tango, nous ne tiendrons pas compte des changements de fils préférés qui font du fils gauche de chaque nœud accédé son fils préféré. Pour une séquence de m accès, nous omettrons donc m changements de fils préférés. Cependant, ceci n’est pas problématique, car nous pouvons nous rendre compte que ces m changements de fils préférés n’influent pas sur les performances de Tango. Lemme 3. Supposons que durant un accès xi , il y ait k nœuds, dont le fils préféré change (de gauche à droite ou de droite à gauche) dans P. Ce nombre k est égal à la borne d’entrelacement IBi (X) de l’accès xi . Démonstration : Considérons que les éléments x1 , x2 , . . . , xi−1 aient été accédés et que le prochain élément à accéder soit xi . Pour démontrer ce lemme, il suffit de prouver l’équivalence suivante : l’accès xi change le fils préféré d’un nœud x si et seulement si l’accès xi induit une alternance left-right ou right-left supplémentaire dans la séquence étiquetée de x sur les clés x1 , x2 , . . . , xi par rapport à la séquence étiquetée de x sur les clés x1 , x2 , . . . , xi−1 (entre d’autres termes IBi (X) augmente). Ceci se fait en deux étapes : 1. Lors d’un accès xi , le fils préféré du nœud x passe de gauche à droite si et seulement si le dernier accès dans le sous-arbre de x était dans sa région gauche et que xi se situe dans la région droite de x. Ce dernier événement correspond exactement au cas où une alternance left-right supplémentaire est induite par l’accès xi dans la séquence étiquetée de x sur les clés x1 , x2 , . . . , xi par rapport à la séquence étiquetée de x sur les clés x1 , x2 , . . . , xi−1 . 2. Lors d’un accès xi , le fils préféré du nœud x passe de droite à gauche si et seulement si le dernier accès dans le sous-arbre de x était dans sa région droite et que xi se situe dans la région gauche de x. Ce dernier événement correspond exactement au cas où une alternance right-left 78 supplémentaire est induite par l’accès xi dans la séquence étiquetée de x sur les clés x1 , x2 , . . . , xi par rapport à la séquence étiquetée de x sur les clés x1 , x2 , . . . , xi−1 . Ces deux cas permettent de démontrer l’équivalence citée ci-dessus et par conséquent le lemme. ! Lemme 4. Soit k le nombre de nœuds, dont les fils préférés changent durant un accès xi . Le coût d’accès à xi vaut O ((k + 1) · (1 + log(log n))). Démonstration : Le coût d’accès au nœud xi consiste, d’une part, au coût pour rechercher xi dans l’arbre, et d’autre part, au coût pour réarranger la structure, de manière à la faire passer de l’état Ti−1 à l’état Ti . Nous analysons d’abord le coût pour rechercher xi . Pour rappel, si durant la recherche de xi , on passe d’un arbre auxiliaire à un autre(disons de A à B), cela signifie que l’accès xi change le fils préféré d’un des nœuds de l’arbre auxiliaire A. Étant donné que la recherche de xi induit k changements de fils préférés, cela signifie que k + 1 arbres auxiliaires devront être visités pour la recherche de xi . Un arbre auxiliaire contient au plus log(n) éléments, car il stocke un sous-chemin d’un chemin allant de la racine à une feuille dans P . Par conséquent, le coût d’une recherche dans un arbre auxiliaire nécessite un coût au pire cas de O(log(log n)). Pour être plus précis, la complexité vaut O (max {1, log(log n)}). Ceci permet de tenir compte du cas, où log(log n) est négatif, car la recherche se fait, alors, en temps constant. Dans la notation O(.), l’expression O (max {1, log(log n)}) est équivalente à O(1 + log(log n)). En effet, dans la notation O(.), en présence d’une somme, on prend le terme maximal de cette somme. Finalement, comme la recherche de xi visite k + 1 arbres auxiliaires, le coût pour la réaliser vaut O ((k + 1) · (1 + log(log n))). Maintenant que le coût de la recherche est analysé, nous nous occupons du coût nécessaire pour transformer Ti−1 en Ti . Quand nous passons d’un arbre auxiliaire à un autre, il faut effectuer un cutting et un joining. Comme ces opérations portent chacune sur au plus log(n) éléments, elles nécessitent chacune un coût de O(1 + log(log n)). Comme nous visitons k + 1 arbres auxiliaires, nous avons alors k passages entre des arbres auxiliaires, ce qui implique d’effectuer k cuttings et k joinings pour lesquels nous payons un coût de O(k · (1 + log(log n))). En conclusion, le coût de l’accès à xi (= recherche de xi + réorganisation de l’arbre) vaut O (max {(k + 1) · (1 + log(log n)) , k · (1 + log(log n))}). En simplifiant ce résultat, nous obtenons finalement O ((k + 1) · (1 + log(log n))). ! 79 Corollaire 2. Soient un arbre constitué des clés {1, 2, . . . , n} et une séquence X = x1 , x2 , . . . , xm . Le coût pour servir X vaut O ((K + m) · (1 + log(log n))), où K est le nombre de changements de fils préférés durant l’exécution de la séquence X. Théorème 6. Soient un arbre constitué des clés {1, 2, . . . , n} et une séquence X = x1 , x2 , . . . , xm . Le coût pour servir X avec l’algorithme Tango vaut O ((OP T (X) + n) · (1 + log(log n))). Démonstration : Pour démontrer ce résultat, nous allons utiliser le corollaire 2. En fait, nous allons compter le nombre de changements de fils préférés durant l’exécution de la séquence X pour remplacer le terme K dans le corollaire 2 par ce nombre. ! Par le lemme 3, nous avons que IB(X) ( car m i=1 IBi (X) = IB(X)) est le nombre de fois qu’un fils préféré change de gauche à droite ou de droite à gauche lors de l’exécution de la séquence X. Cependant, il y a également au plus n initialisations de fils préférés (une initialisation d’un fils préféré consiste à choisir pour un nœud son fils gauche ou son fils droit comme fils préféré, alors qu’il n’avait pas encore de fils préféré). Par conséquent, le nombre total de changements de fils préférés durant l’exécution de la séquence X vaut IB(X) + n. En combinant ceci avec le corollaire 2, nous obtenons que le coût pour servir X vaut : O ((IB(X) + n + m) · (1 + log(log n))) (8.1) Dans le chapitre 7, nous avons démontré le résultat, qui suit : 1 · (IB(X) + m) (8.2) 2 En utilisant ce résultat, nous déduisons que le coût pour servir X vaut O((2·OP T (X)−m+n+m)·(1+log(log n))). En simplifiant ce résultat, nous obtenons finalement, que le coût pour servir la séquence X avec l’algorithme Tango vaut : OP T (X) ≥ O ((OP T (X) + n) · (1 + log(log n))) (8.3) ! Corollaire 3. Lorsque m = Ω(n), le temps d’exécution de Tango pour servir la séquence X vaut O(OP T (X) · (1 + log(log n))). 80 Démonstration : Si la séquence d’accès X est constituée de plus de n clés, on a que OP T (X) = Ω(n). Par conséquent, en se basant sur le théorème précédent (théorème 6), on a que le coût pour servir X avec l’algorithme Tango vaut : O (OP T (X) · (1 + log(log n))) (8.4) ! Si nous avions tenu compte des changements de fils préférés, qui font du fils gauche de chaque nœud accédé son fils préféré, alors il aurait fallu rajouter le terme m· (1 + log(log n)) au résultat de l’équation 8.4. Cependant, ce terme peut être négligé, car OP T (X) ≤ m. Et donc le fait d’omettre ces changements de fils préférés n’est pas problématique. Théorème 7. Le coût au pire cas pour la recherche d’un nœud xi vaut O ((log n) · (1 + log(log n))). Démonstration : Comme l’arbre P est un arbre parfait, sa hauteur est égale à log n. Par conséquent, le nombre maximal de nœuds, dont les fils préférés peuvent changer à cause de l’accès xi vaut log n. Dès lors, par le lemme 4, le coût au pire cas de l’accès xi vaut O ((log n) · (1 + log(log n))). ! 8.6 Conclusion Dans ce chapitre, nous avons présenté une structure, qui atteint un taux de compétitivité de O(log(log n)). Ce taux est atteint au prix d’un algorithme assez compliqué et lourd à implémenter. De plus, les nombreuses opérations nécessaires pour réaliser l’algorithme pourraient porter préjudice à ses performances en pratique. Notons qu’en utilisant la borne inférieure d’entrelacement, nous ne pouvons pas améliorer le taux de O(log(log n)). En effet, chaque arbre auxiliaire contient au pire cas O(log n) éléments. Et donc quelle que soit la manière dont sont organisés les arbres auxiliaires, chaque arbre doit avoir une profondeur de Ω(log(log n)), ce qui se traduit par un coût d’accès de Ω(log(log n)) dans chaque arbre auxiliaire. 81 Chapitre 9 Multi-splay tree Le multi-splay tree est une structure de données qui est basée sur Tango et qui atteint un taux de compétitivité de O(log(log n)). Elle a été élaborée en 2004 par Sleator et Wang [SW04]. Le principal avantage des multi-splay trees par rapport à Tango est que l’algorithme des multi-splay trees est moins compliqué et plus simple à implémenter, ce qui peut favoriser les performances en pratique de la structure, car l’implémentation peut être plus facilement optimisée. Dans ce chapitre, nous allons d’abord présenter l’algorithme des multisplay trees et ensuite, nous allons procéder à l’analyse amortie des performances de cette structure. 9.1 Structures de données Les sections présentant l’algorithme des multi-splay trees sont basées sur les articles [SW04] et [DSW06]. La contribution personnelle a été de détailler le plus possible l’algorithme et d’essayer de le présenter de la manière la plus claire possible. Nous disposons comme pour Tango d’un arbre de référence P (cfr point 8.1), auquel est lié un arbre T , qui est ici appelé un multi-splay tree. La différence est que le multi-splay tree est un arbre de splay trees et non pas un arbre d’arbres rouges-noirs. Chaque nœud x dans T possède plusieurs informations : 1. sa profondeur dans P , dénotée par prof. Cette valeur est constante. 2. la profondeur minimale dans P de tous les nœuds appartenant au splay subtree de x. Le splay subtree de x est l’ensemble des nœuds appartenant au même splay tree que x et qui ont x comme ancêtre (à noter que x y est inclus). Cette information est dénotée par mindepth. 82 3. l’isroot bit qui indique si l’arête reliant x à son père est solide ou discontinue. Deux nœuds reliés par une arête solide appartiennent au même splay tree et deux nœuds reliés par une arête discontinue appartiennent à des splay trees différents 9.2 Algorithmes Nous allons d’abord expliquer l’algorithme du point de vue de l’arbre de référence P et, ensuite, nous allons expliquer comment le réaliser au niveau de T . 9.2.1 Algorithme de l’arbre de référence L’algorithme d’accès à un nœud xi est le suivant : 1. localiser le nœud xi , en commençant la recherche à la racine de P . 2. suivre le chemin allant de xi à la racine, en changeant les fils préférés adéquats, de manière à ce que xi fasse partie du même chemin préféré que celui de la racine de P . Cet algorithme est identique à celui de Tango (cfr point 8.2), hormis que les changements de fils préférés se font en remontant de xi à la racine. Ceci facilite, en fait, l’algorithme dans T . 9.2.2 Algorithme multi-splay tree Pour réaliser l’algorithme ci-dessus sans utiliser l’arbre de référence, nous allons nous baser sur l’accès xi et sur l’arbre Ti−1 (l’arbre T avant l’accès xi ) pour le transformer de manière à tenir compte des modifications causées par l’accès xi . L’idée de l’algorithme est donc la même que celle de Tango. Cependant, les changements de fils préférés devront se faire différemment puisque un multi-splay tree est constitué de splay trees et non pas d’arbres rouges-noirs. Les changements de fils préférés peuvent être réalisés dans un multi-splay tree de manière plus souple par rapport à Tango, car un splay tree n’impose pas de conditions sur sa structure, contrairement aux arbres rouges-noirs (cfr point 5.2.1). L’algorithme d’accès au nœud xi est le suivant : 1. localiser le nœud xi , en commençant la recherche à la racine de T . 83 2. suivre le chemin menant de xi à la racine de T . Pour chaque nœud x, rencontré durant la remontée, il faut analyser son isroot bit. Si ce bit est activé, cela signifie que le passage de x à son père p correspond au passage d’un splay tree à un autre. Il faut alors effectuer un switch (un changement de fils préféré). Ce switch doit porter sur le nœud y du splay tree, auquel p appartient, et dont l’information prof est égale à l’information mindepth de x moins 1. Si nous nous basons sur l’arbre parfait P , nous constatons que dans P ce nœud y est l’ancêtre des nœuds appartenant au splay tree de x. De ce fait, le splay tree, auquel x appartient, doit être un descendant de y dans T (voir figure 9.1). Et donc, le nœud y doit se situer sur le chemin menant de p à la racine du splay tree de p. Le nœud y peut donc être déterminé en suivant ce chemin. Une fois y trouvé, il faut effectuer un switch sur ce nœud, c’est-à-dire changer son fils préféré. Ce changement de fils préféré de y , au niveau de l’arbre T , consiste à réunir le splay tree, auquel appartient y et le splay tree auquel appartient x, pour n’en faire plus qu’un seul splay tree. 3. une fois les switches effectués et la racine atteinte, il faut splayer le nœud xi pour le ramener à la racine de T . Pour cela, on peut utiliser l’algorithme usuel de splaying (cfr point 6.2.1), car après tous les switches, xi appartient au même splay tree que celui de la racine de T . Nous allons, maintenant, expliquer comment réaliser l’opération de switch. En fait, il y a deux switches à considérer : le switch gauche-droite et le switch droite-gauche, selon qu’il faille, respectivement, changer le fils préféré de gauche à droite ou de droite à gauche. Nous allons détailler l’algorithme du switch gauche-droite. L’autre switch peut être déduit de manière analogue. Considérons un switch sur y . Nous définissons alors quatre ensembles : 1. L, qui est l’ensemble des nœuds dans le sous-arbre gauche de y dans P appartenant au même chemin préféré que celui du fils gauche de y. 2. R, qui est l’ensemble des nœuds dans le sous-arbre droit de y dans P appartenant au même chemin préféré que celui du fils droit de y. 3. U, qui est l’ensemble des nœuds au-dessus de y dans P appartenant au même chemin préféré que celui de y. 4. S = L ∪ R ∪ {y} ∪ U. Si nous considérons les éléments de S par ordre croissant, nous avons que les clés de L forment un sous-intervalle dans l’intervalle des clés de S. Et idem pour R. De plus, il n’y a que le nœud y qui se situe entre l’intervalle de L et l’intervalle de R. 84 Fig. 9.1 – Le splay tree auquel x appartient doit être un descendant de y. Dans ce cas, le nœud x est plus petit que y. 85 Le splay tree A dans T , qui contient y est constitué des nœuds L∪U ∪{y}. Après le switch, il doit être constitué des nœuds R ∪ U ∪ {y}. Il faut donc retirer L de A et y rajouter R. Cela peut se faire par l’algorithme suivant (voir figure 9.2) : 1. splayer le nœud y pour qu’il devienne la racine de A. 2. déterminer z . Le nœud z est le plus grand nœud, qui a une information prof plus petite que celle de y et qui est plus petit que y. 3. splayer le nœud z , jusqu’à ce qu’il devienne le fils gauche de y . Dès lors, le sous-arbre droit de z est constitué de nœuds ayant une valeur dans l’intervalle (z, y) et ayant une information prof plus grande que celle de y. En effet, comme z est le plus grand nœud ayant une information prof plus petite que celle de y , nous en déduisons que les nœuds dans le sous-arbre droit de z ont une information prof plus grande que celle de y . Comme l’ensemble L est constitué de nœuds ayant une valeur plus petite que celle de y et ayant une information prof plus grande que celle de y , nous en déduisons que le sous-arbre droit de z correspond à L. 4. activer l’isroot bit de la racine du sous-arbre droit de z pour le séparer de A. Cette opération retire de manière effective L de A. De cette manière, le fils gauche de y dans P devient son fils non préféré. 5. Déterminer x. Le nœud x est le plus petit nœud, qui a une information prof plus petite que celle de y et qui est plus grand que y. 6. splayer le nœud x, jusqu’à ce qu’il devienne le fils droit de y . Dès lors, le sous-arbre gauche de x est constitué de nœuds ayant une valeur dans l’intervalle (y, x) et ayant une information prof plus grande que celle de y. En effet, comme x est le plus petit nœud ayant une information prof plus petite que celle de y , nous en déduisons que les nœuds dans le sous-arbre gauche de x ont une information prof plus grande que celle de y . Comme l’ensemble R est constitué de nœuds ayant une valeur plus grande que celle de y et ayant une profondeur plus grande que celle de y , nous en déduisons que le sous-arbre gauche de x correspond à R. 7. désactiver l’isroot bit de la racine du sous-arbre gauche de x, de manière à l’incorporer dans A. De cette manière, le fils droit de y dans P devient son fils préféré. La souplesse des multi-splay trees par rapport à Tango est illustrée dans les étapes 3 et 7 de l’algorithme ci-dessus. Nous pouvons activer ou désactiver un isroot bit, c’est-à-dire retirer un sous-arbre d’un splay tree ou attacher un 86 Fig. 9.2 – Les étapes d’un switch gauche-droite sur le nœud y. 87 sous-arbre à un splay tree sans devoir réorganiser le splay tree, car après de telles opérations l’arbre reste toujours un splay tree. Pour Tango, après de telles opérations il faut réorganiser les arbres rouges-noirs (cfr points 8.3.4 et 8.3.5) pour qu’ils répondent à nouveau à la définition d’arbres rouges-noirs. Deux cas particuliers doivent être considérés : 1. le nœud z n’existe pas. Dans ce cas, il n’existe pas de nœuds, qui soient plus petits que y et qui aient une information prof plus petite que celle de y . Le sous-arbre gauche de y est alors constitué exclusivement de nœuds ayant une information prof plus grande que celle de y . Il correspond donc à L. 2. le nœud x n’existe pas. Dans ce cas, il n’existe pas de nœuds, qui soient plus grands que y et qui aient une information prof plus petite que celle de y . Le sous-arbre droit de y est alors constitué exclusivement de nœuds ayant une information prof plus grande que celle de y . Il correspond donc à R. Il ne reste plus qu’à détailler les étapes 2 et 5 de l’algorithme précédent, c’est-à-dire comment déterminer z et x. Nous allons présenter l’algorithme pour déterminer z ; celui pour x peut être déduit par symétrie. Pour déterminer z , il faut trouver le plus grand nœud, qui a une information prof plus petite que celle de y et qui est plus petit que y , sachant que y a déjà été ramené à la racine de A (étape 1 de l’algorithme précédent). L’algorithme est alors le suivant : 1. commencer le traitement sur le fils gauche de y , pour ne considérer que des nœuds plus petits que y. 2. analyser l’information prof du nœud courant x : (a) si l’information prof de x est plus petite que l’information prof de y , alors il faut analyser l’information mindepth du fils droit de x : i. si l’information mindepth du fils droit de x est plus petite que l’information prof de y , alors il existe dans le sous-arbre droit de x un nœud qui est plus grand que x et qui a une information prof plus petite que celle de y . Dès lors, x n’est pas z et il faut, par conséquent, se déplacer sur le fils droit de x et recommencer l’étape 2. ii. si l’information mindepth du fils droit de x est plus grande que l’information prof de y , alors il n’existe aucun nœud dans le sous-arbre droit de x, qui ait une information prof plus petite que celle de y . Par conséquent, x est le nœud z. 88 (b) si l’information prof de x est plus grande que l’information prof de y , alors il faut analyser l’information mindepth des fils de x : i. si l’information mindepth du fils droit de x est plus petite que l’information prof de y , alors il faut se déplacer sur le fils droit de x et recommencer l’étape 2. ii. si l’information mindepth du fils gauche de x est plus petite que l’information prof de y , alors il faut se déplacer sur le fils gauche de x et recommencer l’étape 2. iii. si l’information mindepth du fils gauche et du fils droit de x est plus petite que l’information prof de y , alors il faut se déplacer sur le fils droit de x plutôt que le fils gauche, car le sous-arbre du fils droit de x a des clés plus grandes que celles du sous-arbre du fils gauche de x. Nous sommes donc certains que z se situera dans le sous-arbre droit de x et non pas dans le sous-arbre gauche de x. Ensuite, il faut recommencer l’étape 2. Cet algorithme n’est correct que si le nœud z existe dans A. Pour traiter le cas où z n’existe pas, il suffit d’examiner l’information mindepth du fils gauche de y . Si cette information est plus grande que l’information prof de y , alors il n’existe aucun nœud plus petit que y et ayant une information prof plus petite que celle de y . Dans ce cas, le nœud z n’existe donc pas. Par contre, si l’information mindepth du fils gauche de y est plus petite que l’information prof de y , alors il existe un nœud plus petit que y et ayant une information prof plus petite que celle de y . Dans ce cas, le nœud z existe et l’algorithme ci-dessus peut être appliqué. Le dernier point à éclaircir est la manière de déterminer le type de switch (gauche-droite ou droite-gauche) qu’il faut appliquer à un nœud pour lequel cette opération doit être réalisée. Supposons que nous passons du splay tree A au splay tree B et qu’il faut effectuer un switch sur le nœud y de B. Si la racine de A a une valeur qui est plus grande que celle de y , alors les éléments de A appartiennent au sous-arbre droit de y dans P (car ils sont plus grands que y). Le switch à effectuer est alors un switch gauche-droite. Par contre, si la valeur de la racine de A est plus petite que celle de y , alors il faut effectuer un switch droite-gauche. 9.3 Analyse des performances Dans cette section, nous allons démontrer que les multi-splay trees atteignent un taux de compétitivité de O(log(log n)), ainsi qu’une complexité 89 amortie de O(log n) pour l’opération de recherche. Nous allons également montrer que la complexité au pire cas d’une recherche vaut O((log n)2 ). Cette section est basée sur les articles [SW04] et [DSW06]. La contribution personnelle a été de détailler les démonstrations et de justifier dans les démonstrations des points qui ne l’avaient pas été. 9.3.1 Fonction potentiel La fonction potentiel utilisée pour analyser les multi-splay trees est la suivante. Soit T le multi-splay tree. Nous assignons à chaque nœud x de T un poids arbitraire positif, noté w(x). Pour un nœud x, nous définissons sa taille s(x) comme la somme des poids de tous les nœuds appartenant au splay subtree de x (tous les nœuds appartenant au même splay tree que celui de x et ayant x comme ancêtre). Le rang r(x) d’un nœud x vaut log(s(x)). Finalement, nous définissons le potentiel d’un arbre T comme la somme des rangs de tous ses nœuds. En résume : w(x) ∈ R+ , ∀x ∈ T ! s(x) = a∈Tx w(a) , où Tx est le splay subtree de x r(x) = log(s(x)) ! P (T ) = a∈T r(a) Il est possible de donner une autre définition équivalente pour le potentiel d’un multi-splay tree. Pour cela, il faut considérer la fonction potentiel définie par Sleator et Tarjan [ST85] pour un simple splay tree (cfr point 6.2.5). Comme un multi-splay tree est une collection de splay trees, il suffit de calculer le potentiel de chacun de ces splay trees et de sommer ces potentiels pour obtenir le potentiel de T . En comparant ces deux définitions de potentiel pour T , nous pouvons constater qu’elles sont équivalentes. 9.3.2 Access Lemma Généralisé Soit un nœud x, la complexité pour splayer ce nœud jusqu’à un ancêtre a appartenant au même splay tree est d’au plus 3(r(a) − r(x)) + 1 = O(log(s(a)/s(x))). 90 La démonstration de l’Access Lemma Généralisé découle immédiatement de la démonstration de l’Access Lemma pour les splay trees (cfr point 6.2.5), parce que cette démonstration-ci ne nécessite pas que le nœud à splayer soit remonté à la racine. La différence entre l’Access Lemma et l’Access Lemma Généralisé est que, pour ce dernier, le splaying peut s’arrêter à un quelconque ancêtre a de x. 9.3.3 Multi-Splay Access Lemma Soit P un arbre de référence de racine t constitué de n nœuds et f ≥ 2 un multiplicateur. Considérons une assignation de poids, qui attribue à chaque nœud x de P un poids positif w(x) et qui satisfait les deux conditions suivantes : 1) w(x) ≥ max w(v) (9.1) v∈d(x,P ) 2) f · w(x) ≥ max t∈p(x,P ) # w(v) (9.2) v∈t où p(x, P ) est l’ensemble des chemins dans P allant de x à un descendant de x sans enfant et d(x, P ) est l’ensemble des descendants de x dans P . La valeur du multiplicateur f n’est pas constante. Elle peut dépendre de n (le nombre d’éléments dans l’arbre). Le fait que la valeur de f puisse être choisie arbitrairement (mais en vérifiant les conditions ci-dessus) est intéressant, car cela nous laisse une marge de manœuvre plus grande pour déterminer des propriétés à partir de l’équation 9.3 et d’une assignation de poids aux nœuds. Alors le temps d’exécution amorti d’une séquence X = x1 , x2 , . . . , xm vaut : "" m $ % &$ # w(t) log O + (log f ) · (IB(X) + m) (9.3) w(xi ) i=1 Démonstration : La démonstration est composée de trois étapes : 1. calculer le coût d’un switch 2. calculer le coût d’accès à un élément 3. calculer le temps d’exécution d’une séquence Supposons que pour l’accès xj , il y ait k switches qui soient réalisés sur les nœuds Y = y1 , y2 , . . . , yk . L’ordre dans lequel les switches sont effectués est yk , . . . , y2 , y1 . Notons par ti la racine du splay tree contenant yi avant l’accès xj et par tk+1 la racine du splay tree contenant xj . 91 yi z x cx cz Fig. 9.3 – Relation entre x, yi , z , cx et cz dans l’arbre T . Il s’agit de la représentation de T après 3 splayings, mais avant l’activation de l’isroot bit de cz et la désactivation de l’isroot bit de cx . Pour calculer le coût d’un switch yi , il faut d’abord calculer la variation de potentiel dû à ce switch. Le switch yi est composé de deux changements d’isroot bit et d’au plus trois splayings (sur yi, x et z, où x et z sont les nœuds comme définis pour l’algorithme des multi-splay trees au point 9.2.2). Les changements d’isroot bit porteront sur le fils droit de z , noté cz , et le fils gauche de x, noté cx . Ces changements d’isroot bit n’affecteront que le potentiel des nœuds yi , x et z . Si nous notons par r(v) et s(v) le rang et la taille d’un nœud v avant les changements d’isroot bit et par r $ (v) et s$ (v) le rang et la taille du nœud v après les changements d’isroot bit, nous constatons les propriétés suivantes (voir figure 9.3) pour les nœuds yi, x et z: 1. s$ (z) = s(z) − s(cz), car l’activation de l’isroot bit de cz sépare son sous-arbre du reste du splay tree, ce qui diminue la taille de z de s(cz). Cette égalité sera désignée par (A). 2. s$ (x) = s(x) + s(cx), car la désactivation de l’isroot bit de cx rajoute le sous-arbre de cx au splay tree de yi, ce qui augmente la taille de x de s(cx). Cette égalité sera désignée par (B). 3. s$ (yi ) = s(yi ) + s(cx) − s(cz), car la taille de z diminue de s(cz) et la taille de x augmente de s(cx). Cette égalité sera désignée par (C). 92 Pour calculer la variation de potentiel due au switch yi , il est nécessaire de déduire encore deux résultats : 1. s(x) ≥ w(yi) 2. f ≥ s(cx)/w(yi) Le premier résultat est déduit de la manière suivante. Au niveau de P , nous avons que x et yi appartiennent au même chemin préféré, car ils font partie du même splay tree. De plus, nous avons que x a une profondeur plus petite que celle de yi ; ceci est dû à la définition de x dans l’algorithme des multi-splay trees (cfr point 9.2.2). De ceci, nous concluons que x est l’ancêtre de yi dans P . Comme x est l’ancêtre de yi , nous obtenons de la condition 9.1, que w(x) ≥ w(yi). Et comme s(x) ≥ w(x), nous obtenons finalement que s(x) ≥ w(yi). Le deuxième résultat est déduit de la manière suivante. Par l’algorithme des multi-splay trees, nous avons que les nœuds (en particulier cx ) dans le sous-arbre gauche de x au niveau de T appartiennent au sous-arbre du fils non préféré de yi dans P . Nous avons donc que yi est l’ancêtre de cx dans P . Considérons l’ensemble C des chemins dans P , qui commencent en yi, qui passent par cx et qui se terminent en une feuille. Nous pouvons utiliser la condition 9.2 pour déduire que, pour un chemin quelconque appartenant à C, la somme des poids des nœuds de ce chemin est plus petite que f · w(yi). Or, dans T le sous-arbre gauche de x, c’est-à-dire le splay subtree de cx , contient des nœuds plus grands que yi et ayant des informations prof plus grandes que celle de yi . Ces nœuds forment donc dans P un sous-chemin d’un des chemins de l’ensemble C. Par conséquent, la somme des poids des nœuds dans le splay subtree de cx , qui vaut s(cx), est plus petite que f · w(yi). Nous avons donc déduit que s(cx) ≤ f · w(yi), d’où f ≥ s(cx)/w(yi). En se basant sur ces constatations, nous pouvons calculer la variation de potentiel dû au switch yi : 93 $P = (r $ (x) − r(x)) + (r $ (yi) − r(yi)) + (r $ (z) − r(z)) , car un switch modifie le potentiel des nœuds yi , x et z < (r $ (x) − r(x)) + (r $ (yi) − r(yi)) , car r $ (z) − r(z) < 0 % $ & % $ & s (yi) s (x) + log , car log(A) − log(B) = log(A/B) = log s(x) s(yi) % & % & s(x) + s(cx) s(yi) + s(cx) − s(cz) = log + log , s(x) s(yi ) par les égalités (A) et (B) & % & % s(yi) + s(cx) s(x) + s(cx) + log < log s(x) s(yi ) & % & % s(cx) s(cx) + log 1 + = log 1 + s(x) s(yi ) % & % & s(cx) s(cx) ≤ log 1 + + log 1 + , car s(x) ≥ w(yi) w(yi) s(yi) % & % & s(cx) s(cx) ≤ log 1 + + log 1 + , w(yi) w(yi) car s(yi ) ≥ w(yi) par définition de s(yi ) % & s(cx) = 2 · log 1 + w(yi) s(cx) ≤ 2 · log (1 + f ) , car f ≥ w(yi) = O(log f ) Nous avons donc déduit que $P < O(log f ). Le calcul de la complexité amortie pour le switch de yi se base sur l’équation suivante : CAswitch = CEswitch + $P (cfr point 2.4.3), c’est-à-dire que la complexité amortie du switch est égale à la complexité effective du switch plus la variation de potentiel due à l’opération. La complexité effective du switch est égale au coût pour splayer yi, x et z . Comme les changements d’isroot bit se font en temps constant, leur coût peut être négligé . Avant de calculer la complexité amortie du switch de yi, il est utile de prouver les trois résultats suivants : 1. w(x) ≥ w(yi) (ce résultat sera désigné par (D)) 2. w(z) ≥ w(yi) (ce résultat sera désigné par (E)) 3. w(yi) ≥ s(ti+1 )/f (ce résultat sera désigné par (F )) 94 Le premier résultat a déjà été prouvé pour le calcul de $P . Le deuxième résultat peut être prouvé de manière similaire. Le troisième résultat est prouvé de la manière suivante. Pour rappel, lors d’un accès xj , il y a k switches qui sont effectués sur les nœuds y1 , y2, . . . , yk dans l’ordre yk , . . . , y2 , y1 . Et tj représente la racine du splay tree contenant yj . Comme nous effectuons un switch sur yi+1 suivi d’un switch sur yi, cela signifie que nous sommes passés du splay tree contenant yi+1 au splay tree contenant yi par une arête discontinue. Au niveau de P , l’ensemble des nœuds dans le splay tree de yi+1 forme un chemin t allant du fils non préféré de yi à une feuille. Or, t est un sous-chemin de d(yi , P ). En se basant sur! cela, nous pouvons utiliser la condition 9.2 pour déduire que f · w(yi) ≥! v∈t w(v). Comme ti+1 est la racine du splay tree de yi+1 , nous avons que v∈t w(v) = s(ti+1 ). Finalement, nous obtenons que f · w(yi ) ≥ s(ti+1 ), d’où w(yi) ≥ s(ti+1 )/f . En se basant sur ce résultat, nous pouvons calculer la complexité amortie d’un switch : Cout(switch(yi )) = Cout(splay(yi)) + Cout(splay(x)) + Cout(splay(z)) + $P % % && % % && % % && s(ti ) s(ti ) s(ti ) < O log + O log + O log s(yi ) s(x) s(z) + O(log f ) , où s(ti ) est la taille du splay tree de yi % % && % % && % % && s(ti ) s(ti ) s(ti ) < O log + O log + O log w(yi) w(x) w(z) + O(log f ) && % % && % % && % % s(ti ) s(ti ) s(ti ) + O log + O log ≤ O log w(yi) w(yi) w(yi) + O(log f ) , par les résultats (D) et (E) && % % s(ti ) + O(log f ) = O log w(yi) && % % s(ti ) ≤ O log + O(log f ) , par le résultat (F ) s(ti+1 )/f && % % s(ti ) ·f + O(log f ) = O log s(ti+1 ) % % && s(ti ) = O log + O(log f ) + O(log f ) s(ti+1 ) && % % s(ti ) + O(log f ) = O log s(ti+1 ) 95 Pour calculer le coût d’accès à un nœud xj , il est nécessaire de prouver les deux résultats suivants : 1. s(tk+1 ) ≥ w(xj ) (tk+1 est la racine du splay tree contenant xj ) (ce résultat sera désigné par (G)) 2. f · w(t) ≥ s(t1 ) (t est la racine de P ) (ce résultat sera désigné par (H)) La preuve du premier résultat découle immédiatement du fait que tk+1 est la racine du splay tree contenant xj . Le deuxième résultat est prouvé de la manière suivante. Le nœud t1 , qui est la racine du splay tree contenant y1 , est également la racine de T . Donc, le splay tree de t1 correspond au chemin C dans P allant de t à une feuille en 9.2, nous avons que !suivant les fils préférés. Par la condition ! f · w(t) ≥ v∈C w(v) (car C ∈ p(t, P )). Or, v∈C w(v) = s(t1 ), ce qui implique que f · w(t) ≥ s(t1 ). L’accès au nœud xj consiste à effectuer k switches et à splayer xj . Le coût d’accès à xj est alors calculé comme suit : 96 Cout(Acces(xj )) = k # Cout(switch(yi)) + Cout(splay(xj )) i=1 k # % % % % && # && k s(ti ) s(t1 ) = O log O(log f ) + O log + s(t ) s(xj ) i+1 i=1 i=1 % % % % && # && k s(t1 ) s(t1 ) O(log f ) + O log = O log + , s(tk+1) s(x ) j i=1 = ≤ ≤ = = = la simplification du premier terme découle du fait que log(A) + log(B) = log(A · B) && % % && % % s(t1 ) s(t1 ) + O(k · log f ) + O log O log s(tk+1) s(xj ) % % && % % && s(t1 ) s(t1 ) O log + O(k · log f ) + O log , w(xj ) w(xj ) par le résultat (G) % % && % % && f · w(t) f · w(t) O log + O(k · log f ) + O log , w(xj ) s(xj ) par le résultat (H) % % && f · w(t) O log + O(k · log f ), w(xj ) && % % w(t) + O(k · log f ), O(log f ) + O log w(xj ) && % % w(t) + O((k + 1) · (log f )), O log w(xj ) Le nombre k de switches effectués lors de l’accès xj correspond au nombre de nœuds dans P , dont les fils préférés changent à cause de cet accès. Durant l’analyse des performances de Tango, il a été démontré que ce nombre k est égal à la borne d’entrelacement IBj (X) de l’accès xj (cfr point 8.5). Le coût nécessaire à un multi-splay tree pour exécuter une séquence X = x1 , x2 , . . . , xm vaut : 97 Cout(executer(X)) = = m # i=1 m # i=1 = m # i=1 = m # i=1 = m # i=1 car = O Cout(acces(xi )) % O log % O log % O log % O log m # % % % % w(t) w(xi ) w(t) w(xi ) w(t) w(xi ) w(t) w(xi ) && && && && + m # i=1 +O " O((IBi(X) + 1) · (log f )) m # " i=1 (IBi (X) + 1) · (log f ) + O (log f ) · m # (IBi (X) + 1) i=1 + O ((log f ) · (IB(X) + m)) , IBi (X) = IB(X) ""i=1m # log i=1 % w(t) w(xi ) &$ + (log f ) · (IB(X) + m) $ ! 9.3.4 Propriétés En se basant sur le multi-splay tree access lemma, il est possible de définir trois propriétés sur les multi-splay trees. Théorème 8. Le multi-splay tree est O(log(log n))-compétitif. Démonstration : Soit P un arbre de référence à n nœuds. La profondeur de P vaut (log n) + 1. On pose w(v) = 1 pour tout nœud v de P et on choisit f = 2 · log n. La condition 9.1 est vérifiée, car le poids maximal d’un ensemble quelconque de nœuds vaut 1. La condition 9.2 est vérifiée, car la longueur maximale d’un chemin vaut (log n) + 1, ce qui est bien inférieur à f . Avec l’assignation de poids choisie, le multi-splay tree access lemma vaut : "" m $ % &$ # 1 log O + (log(2 · log n)) · (IB(X) + m) (9.4) 1 i=1 98 $ $ Comme O(log(2 · log n) = O((log 2) + (log(log n))) = O(log(log n)), nous obtenons finalement : O((log(log n)) · OP T (X)) (9.5) ! Théorème 9. Un multi-splay tree de n nœuds a une complexité amortie de O(log n) pour un accès. Démonstration : Soient P un arbre de référence à n nœuds et une séquence d’accès X = x1 , x2 , . . . , xm . La profondeur de P vaut (log n) + 1. Soit h(v), la hauteur d’un nœud v (la hauteur d’une feuille vaut 0 ; cfr point 2.1.3). On pose w(v) = 2h(v) pour tout nœud v de P et on choisit f = 2 . Notons que le poids de la racine t de P vaut : w(t) = 2h(t) = 2(log n)+1 = 2log n · 2 = n · 2 = O(n) (9.6) La condition 9.1 est vérifiée, car un descendant a une hauteur plus petite que celle de son ancêtre. ! j La condition 9.2 est vérifiée, car 2i ≥ i−1 j=0 2 . Avec l’assignation de poids choisie, le multi-splay tree access lemma vaut : Cout(executer(X)) ≤ O ≤ O "" m # "" i=1 m # log % n w(xi ) $ log (n) i=1 &$ + (log 2) · (IB(X) + m) + (IB(X) + m) $ $ , car ∀ 1 ≤ i ≤ m : w(xi ) ≥ 1 = O(m · log n + OP T (X)) = O(m · log n) , car OP T (X) ≤ m · log n ! Théorème 10. Pour une séquence d’accès X, le coût nécessaire à un multisplay tree pour traiter X vaut : O(min (log(log n) · OP T (X) , m · log n)) 99 (9.7) Démonstration : Ce résultat découle des deux théorèmes précédents (les théorèmes 8 et 9). ! Nous avons encore deux propriétés concernant les multi-splay trees. Théorème 11. Un multi-splay tree de n nœuds a une complexité au pire cas de O((log n)2 ) pour un accès xi . Démonstration : Comme la hauteur de l’arbre de référence vaut log n, nous avons que le nombre de switches pour un accès xi vaut au pire cas log n. De plus, comme chaque splay tree contient au plus log n éléments, nous avons que la complexité au pire cas pour une opération sur un splay tree vaut log n (car la complexité au pire cas d’une opération sur un splay tree est linéaire sur la taille de l’arbre). Ainsi, le coût pour effectuer un switch sur un splay tree vaut O(log n) au pire cas, car on effectue 3 splayings pour réaliser cette opération. Par conséquent, le coût pour accéder au nœud xi vaut O((log n) · (log n)) = O((log n)2 ). ! Théorème 12. Soient un arbre à n nœuds et une séquence X consistant à accéder à ces nœuds dans l’ordre symétrique. Le coût pour traiter X vaut : O(n) (9.8) Ce théorème ne sera pas démontré ici, mais une preuve est disponible dans l’article [DSW06]. 9.4 Conclusion Nous venons de présenter une structure qui atteint un taux de compétitivité de O(log(log n)), mais qui est plus simple que l’algorithme Tango. Nous avons également montré qu’un accès se fait avec un coût au pire cas de O((log n)2 ) et avec un cout amorti de O(log n). Nous avons également constaté que cette structure présente des similarités avec Tango. En fait, le principe fondamental est le même, à savoir qu’il faut effectuer des switches après chaque accès pour changer le fils préféré de certains nœuds. Seul diffère la manière d’effectuer les switches, car un multi-splay tree est constitué de splay trees et Tango est constitué d’arbres rouges-noirs. Nous avons également constaté que cette différence fournit une plus grande souplesse aux multi-splay trees. 100 Grâce à cette souplesse, il est possible d’étendre cette structure pour qu’elle puisse supporter les opérations d’insertion et de suppression tout en gardant au taux de compétitivité de O(log(log n)). Comme les multi-splay trees présentent des similitudes avec les splay trees, nous pouvons nous demander si les multi-splay trees possèdent également les propriétés d’optimalité statique, de static finger, de working set et de dynamic finger [DSW06]. De par cette similitude, un autre problème, que nous pourrions nous poser, serait de savoir si les splay trees sont O(log(log n)) − compétitif s [DSW06]. Nous avons montré que les multi-splay trees et Tango ont un taux de compétitivité de O(log(log n)), mais il se peut que ces deux structures atteignent l’optimalité dynamique [DSW06]. Pour prouver cela, il faudrait un borne inférieure sur OP T (X) qui soit meilleure que la borne inférieure d’entrelacement, mais le grand problème est que de telles bornes sont rares. Ce manque de bornes inférieures est l’un des principaux obstacles pour avoir une structure optimale dynamiquement. 101 Chapitre 10 Optimalité de recherche dynamique La rédaction de ce chapitre est basée sur l’article de Blum, Chawla et Kalai [BCK03]. Dans cette section, nous allons montrer qu’il existe une structure, qui atteint l’optimalité dynamique, si nous ne tenons pas compte du coût des rotations qu’elle effectue après chaque accès. 10.1 Définition Dans le modèle dynamique (cfr point 2.2.1) des ABR que nous considérons, le coût d’accès à un élément xi est égal au coût de localisation de xi plus le coût des rotations effectuées après avoir atteint xi . Une structure a la propriété d’optimalité de recherche dynamique si, pour une quelconque séquence X, le coût nécessaire à la localisation des éléments de X est égal à O(OP T (X)), où OP T (X) est le coût d’accès aux éléments de X pour l’algorithme offline optimal. En d’autres termes, une structure a l’optimalité de recherche dynamique si son coût pour traiter une séquence (sans compter les rotations) est égal au coût de l’algorithme offline optimal. 10.2 Intérêt Un argument en défaveur de l’existence d’un algorithme online optimal serait le suivant. Pour qu’un algorithme soit optimal, il faut qu’il puisse garder près de la racine les nœuds qui doivent encore être accédés. Mais le problème d’un algorithme online est qu’il ne peut pas deviner les accès à venir et que par conséquent il a trop de nœuds à maintenir près de la racine. 102 L’intérêt de la propriété d’optimalité de recherche dynamique est de rejeter cet argument, car elle montre qu’il est possible pour une structure online de garder les nœuds à accéder suffisamment proches de la racine pour avoir un coût optimal. 10.3 Structure Ici, le but est de prouver qu’il existe une structure ABR online qui atteint l’optimalité de recherche dynamique. L’élaboration de la structure se fait à l’aide de distributions de probabilités sur les séquences d’accès. Nous verrons qu’il est possible de transformer une distribution de probabilités en un arbre binaire de recherche et un arbre binaire de recherche en une distribution de probabilités. Ces transformations sont telles que les nœuds proches de la racine sont ceux dont la probabilité d’accès est la plus grande. Avant de présenter la structure, il est nécessaire de présenter quelques résultats. Toutes les démonstrations présentées ci-dessous sont basées sur l’article [BCK03]. La contribution personnelle a consisté à détailler ces démonstrations et à justifier dans ces démonstrations des points qui ne l’avaient pas été. Théorème 13. Le nombre de séquences d’accès pouvant être exécutées avec un coût optimal k est de tout au plus 212k , pour tout k ≥ 0. Ce théorème n’est pas démontré ici, mais une preuve peut être consultée dans [BCK03]. Théorème 14. Il existe une distribution de probabilités sur les séquences d’accès, qui assigne une probabilité d’au plus 2−13k à une séquence d’accès dont le coût optimal est k. Démonstration : On choisit le nombre k dans l’intervalle [1, ∞] selon la distribution 1/2k . Ensuite, on choisit une séquence d’accès X parmi toutes les séquences dont le coût optimal est k. Comme il existe au plus 212k séquences de coût optimal k, la probabilité de choisir X parmi toutes les séquences de coût k est de 2−12k . Finalement, la probabilité de choisir X vaut au plus : 1 1 1 · = 2k 212k 213k (10.1) ! Théorème 15. Un arbre binaire de recherche T peut être transformé en une distribution de probabilités p telle que pour tout nœud j appartenant à T : 103 p(j) ≥ 3−prof ondeur(j) (10.2) Démonstration : À chaque nœud j de T , on associe une probabilité p(j) qui est égale à la probabilité d’atteindre j si on commence la recherche à la racine. Cette probabilité est déterminée de la manière suivante : 1. Commencer à la racine de l’arbre. 2. Soit x le nœud courant : (a) Si x a deux fils, alors la probabilité d’aller à gauche vaut 1/3, celle d’aller à droite vaut 1/3 et celle de s’arrêter vaut également 1/3. (b) Si x a un fils (disons le fils gauche), alors la probabilité d’aller à gauche vaut 1/2 et celle de s’arrêter vaut également 1/2. Le cas du fils droit est similaire. (c) Si x n’a pas de fils, alors la probabilité de s’arrêter vaut 1. Ce traitement est à itérer jusqu’au nœud j. La probabilité p(j) correspond donc à la probabilité d’atteindre le nœud j et de s’y arrêter. De cette construction, nous déduisons immédiatement que p(j) ≥ −prof ondeur(j) 3 . ! Il ne reste plus qu’à démontrer que j∈T p(j) = 1, de manière à vérifier que p soit bien une distribution de probabilités. Ceci peut se faire par récurrence sur la hauteur h(T ) de T : 1. Cas de base : la hauteur de T vaut 0. Dans ce cas, l’arbre est constitué d’un seul nœud dont la probabilité associée vaut 1. ! 2. Cas d’induction : considérons que i∈T p(i) = 1 pour un arbre T de hauteur i et montrons que c’est également le cas pour un arbre de hauteur i + 1. Soit T un arbre de hauteur i+1 et de racine t. Deux cas sont possibles : soit t a deux fils, soit il n’en a qu’un seul. Traitons le premier cas (le deuxième cas peut être résolu de manière similaire). La probabilité de s’arrêter en t vaut 1/3, celle d’aller dans le sous-arbre gauche de t vaut 1/3 et celle d’aller dans le sous-arbre droit de t vaut 1/3. Comme le sous-arbre gauche de t a une hauteur i, nous pouvons y appliquer l’hypothèse d’induction. Mais comme ce sous-arbre est attaché à la racine de t comme fils gauche, nous avons que la probabilité de chaque nœud du sous-arbre doit être multipliée par 1/3. On obtient alors que : 104 # i∈SAG(t) p(i) = 1 · 1 1 = 3 3 (10.3) Finalement, nous avons que : # i∈T # p(i) = i∈SAG(t) p(i) + 1 1 1 + + 3 3 3 = 1 = # i∈SAD(t) p(i) + p(t) ! Théorème 16. Pour une distribution de probabilités sur n accès, nous pouvons créer un arbre binaire de recherche T tel que pour tout nœud a de T : prof ondeur(a) ≤ 1 − log(p(a)) (10.4) Démonstration : La construction de T se fait de!manière récursive. i−1 Pour j=1 p(j) ≤ 1/2 et !n la racine de T , on choisit le premier i tel que j=i+1 p(j) ≤ 1/2. Ensuite, on travaille de manière récursive sur les éléments strictement plus petits que i pour construire le sous-arbre gauche de i. Pour !i−1 cela, il faut normaliser p, de manière à ce que j=1 p(j) = 1. Après cela, il faut effectuer un travail similaire sur les éléments strictement plus grands que i pour construire le sous-arbre droit de i. De la manière dont on construit T , nous constatons que pour tout nœud i de T , la somme des probabilités des nœuds appartenant au sous-arbre de i est d’au plus 1/2d−1 , où d est la profondeur de i. Grâce à cette observation, nous pouvons montrer que la profondeur d’un nœud i de probabilité p(i) ne peut pas être plus grande que − log(p(i)). En effet, supposons que le nœud i de profondeur d soit tel que d−1 > − log(p(i)) (dans ce cas d est plus grand que − log(p(i))). Nous en déduisons que : 1 2d−1 < 1 2− log(p(i)) = 2log(p(i)) = p(i) 105 Et donc, 1/2d−1 < p(i). Nous savons également que p(Ti ) ≤ 1/2d−1, où Ti est le sous-arbre de i. En combinant ces deux résultats, nous obtenons que : p(Ti ) ≤ 1 2d−1 < p(i) (10.5) Or, ceci est impossible, car p(Ti ) ≥ p(i). En faisant l’hypothèse que d−1 > − log(p(i)), nous sommes arrivés à une contradiction. On en déduit donc que : d − 1 ≤ − log(p(i)) d ≤ 1 − log(p(i)) ! Théorème 17. Pour une quelconque distribution de probabilités p sur une séquence d’accès, nous pouvons créer un algorithme online dont le coût pour traiter la séquence vaut tout au plus m−log(p(x1 x2 . . . xm )) et cela quelle que soit la séquence x1 , x2 , . . . , xm . Nous notons par p(x1 x2 . . . xm ) la probabilité d’avoir x1 , x2 , . . . , xm comme premier, deuxième, . . . , mème accès. Démonstration : Considérons les distributions de probabilités p1 , p2 , . . . , pm . Nous définissons la probabilité pi (xj ) comme la probabilité p d’avoir xj comme ième accès sachant que x1 était le premier accès, x2 était le deuxième accès, et ainsi de suite jusque xi−1 le (i − 1)ème accès. La probabilité pi (xj ) est définie en termes de probabilités conditionnelles de la manière suivante : pi (xj ) = p(xj |x1 x2 . . . xi−1 ) (10.6) où p(xj |x1 x2 . . . xi−1 ) est la probabilité d’avoir xj comme ième accès sachant que x1 , x2 , . . . , xi−1 étaient respectivement le premier, deuxième, . . . , (i − 1)ème accès. Par définition des probabilités conditionnelles, nous avons que : p(A ∩ B) p(A) En se basant sur ce résultat, nous obtenons que : p(B |A) = p(xj ∩ (x1 x2 . . . xi−1 ) p(x1 x2 . . . xi−1 ) p(x1 x2 . . . xi−1 xj ) = p(x1 x2 . . . xi−1 ) p(xj |x1 x2 . . . xi−1 ) = 106 (10.7) Pour la probabilité p(x1 x2 . . . xi−1 xj ), nous n’avons aucune restriction quant à la valeur que peut prendre le (i + 1)ème , (i + 2)ème , . . . , mème accès. Nous avons donc que : p(xj |x1 x2 . . . xi−1 ) = !xm !xm bi+1 =x1 . . . bm =x1 p(x1 . . . xi−1 xj bi+1 . . . bm ) ! xm ! xm bi =x1 . . . bm =x1 p(x1 . . . xi−1 bi . . . bm ) (10.8) La valeur de pi (xj ) peut alors être calculée algorithmiquement grâce au résultat ci-dessus. Soit le lemme suivant : p(A1 ∩ A2 ∩ . . . ∩ An ) = p(A1 ) · p(A2 |A1 ) . . . p(An |A1 ∩ . . . ∩ An−1 ) (10.9) En se basant sur ce lemme, nous avons que : p(x1 x2 . . . xm ) = p(x1 ) · p(x2 |x1 ) . . . p(xm |x1 . . . xm−1 ) = p1 (x1 ) · p2 (x2 ) . . . pm (xm ) m 0 pi (xi ) = i=1 L’algorithme online procède de la manière suivante. Pour le ième accès, on calcule la distribution de probabilités pi grâce à la formule 10.8. Ensuite, on convertit la distribution de probabilités pi en un ABR en utilisant le théorème 16. De par cette construction, la profondeur d’un accès xi est d’au plus 1 − log(pi (xi )). Le coût des m accès (en ne tenant compte que du coût pour localiser les éléments) est d’au plus : m # i=1 (1 − log(pi (xi ))) = m − log(p(x1 . . . xm )) (10.10) ! Théorème 18. Il existe un algorithme online, qui a la propriété d’optimalité de recherche dynamique. Démonstration : Considérons la distribution de probabilités du théorème 14, qui assigne une probabilité de 2−13k à une séquence d’accès dont le coût optimal est k. Combinons cette distribution de probabilités avec le théorème 17 pour obtenir un algorithme online, dont le coût d’accès à une séquence 107 X = x1 , . . . , xm (en ne tenant compte que du coût pour localiser les éléments) est d’au plus : ( ' m − log 2−13·OP T (x1...xm ) = m + 13 · OP T (x1 . . . xm ) ≤ 14 · OP T (x1 . . . xm ), car m ≤ OP T (x1 . . . xm ) L’algorithme online nécessite un coût d’au plus 14 fois le coût optimal pour localiser les éléments d’une séquence d’accès. Cet algorithme a donc la propriété d’optimalité de recherche dynamique. ! 10.4 Conclusion Dans cette section, nous avons présenté une structure qui atteint l’optimalité de recherche dynamique. L’existence d’une telle structure a un grand intérêt, parce qu’il s’agit d’un argument en faveur de l’existence d’une structure atteignant l’optimalité dynamique. Un problème ouvert concernant l’optimalité de recherche dynamique [BCK03] serait d’élaborer une structure efficace, qui puisse atteindre cette propriété. 108 Chapitre 11 Expérimentations Dans ce chapitre, nous allons exécuter un certain nombre de jeux de tests pour analyser et comparer les performances en pratique des arbres rougesnoirs, des splay trees et des multi-splay trees. Le but est de surtout constater si les multi-splay trees fournissent des résultats intéressants en pratique et si l’algorithme des multi-splay trees, qui est plus complexe que celui des arbres rouges-noirs et des splay trees, ne porte pas préjudice aux performances en pratique des multi-splay trees. Nous allons d’abord expliquer le type de jeux de tests qui seront exécutés et ensuite, nous allons présenter les résultats de ces jeux de tests pour essayer d’en tirer des constatations. 11.1 Jeux de tests Nous avons effectué plusieurs tests en considérant des tailles différentes pour la structure. Nous faisons varier la taille de la structure de 28 à 216 en multipliant à chaque pas la taille de la structure par 2. Pour chaque taille n, nous faisons varier la taille de la séquence de n à 10 · n par pas de log n. Pour chaque séquence, nous calculons le coût nécessaire à la structure pour l’exécuter en prenant comme modèle de coût celui défini par le modèle dynamique (cfr point 2.2.1). Nous considérons deux types de distribution pour générer une séquence d’accès : 1. distribution aléatoire 2. distribution working set 109 80000 MST8 ST8 RB8 70000 60000 50000 40000 30000 20000 10000 0 0 500 1000 1500 2000 2500 Fig. 11.1 – Coûts d’un arbre de taille 28 avec une distribution aléatoire 11.2 Distribution aléatoire Supposons que la taille de l’arbre soit égale à n. Une séquence d’accès est générée en choisissant de manière aléatoire les éléments qui la composent parmi les n éléments possibles. Les résultats de ces tests sont illustrés par les graphes des figures 11.1 et 11.2 (d’autres graphes sont fournis en annexe). Ces résultats nous permettent de constater que le coût des arbres rouges-noirs est plus petit que celui des splay trees ; lui-même plus petit que celui des multi-splay trees. Ceci est vérifié sur chacun des graphiques cités ci-dessus. De plus , nous constatons sur chaque graphique que la différence de coût entre les trois structures s’amplifie à mesure que la taille de la séquence augmente. Dès lors, au vue de ces résultats, on peut penser que les arbres rouges-noirs ont un meilleur comportement que les splay trees et que cette structure a un meilleur comportement que les multi-splay trees pour le type de distribution que nous considérons dans cette section. Le fait que les multi-splay trees aient de moins bonnes performances s’expliquent par la complexité de l’algorithme de cette structure et par le grand nombre d’opérations de réorganisation qu’elle nécessite. 110 3000 5e+07 MST16 ST16 RB16 4.5e+07 4e+07 3.5e+07 3e+07 2.5e+07 2e+07 1.5e+07 1e+07 5e+06 0 0 100000 200000 300000 400000 500000 600000 Fig. 11.2 – Coûts d’un arbre de taille 216 avec une distribution aléatoire 11.3 Distribution working set Supposons que la taille de la structure soit égale à n. Une séquence d’accès est générée en choisissant de manière aléatoire les éléments qui la composent parmi un sous-ensemble de l’ensemble {1, . . . , n}. Ce sous-ensemble a une taille de n/5 et correspond à l’ensemble {n/2 − n/10, . . . , n/2 + n/10}. Le but est de tirer profit de la propriété working set des splay trees pour analyser son comportement dans ce cas. Les résultats de ces tests sont illustrés par les graphes des figures 11.3 et 11.4 (d’autres graphes sont fournis en annexe). Si nous analysons le comportement des splay trees, nous remarquons qu’au début il est similaire à leur comportement pour le premier type de distribution analysé. Ensuite, le comportement diffère , car nous assistons à une diminution du coût d’exécution des séquences d’accès. Cette diminution s’explique par le fait que les splay trees ont la propriété working set. En effet, comme nous n’accédons qu’à un sous-ensemble des n éléments possibles, nous avons que de plus en plus d’éléments de ce sous-ensemble ont tendance à se retrouver près de la racine, ce qui implique que nous avons de plus en plus d’accès à faible coût. Et cela se traduit par une diminution du coût total de la séquence d’accès. Après la phase de décroissance, nous avons une phase où le coût augmente, mais de 111 700000 façon légère. Cette augmentation s’explique par le fait que tous les éléments du sous-ensemble des éléments accédés ont été ramenés près de la racine, ce qui implique que nous ne pouvons plus espérer une amélioration du coût d’exécution des séquences d’accès. Dans cette phase, l’augmentation du coût est légère, car les éléments accédés sont proches de la racine, ce qui implique que leur coût d’accès est faible. Le comportement des arbres rouges-noirs diffère peu de leur comportement pour le premier type de distribution analysé, parce que les arbres rouges-noirs n’ont pas la propriété working set et qu’ils ne sont pas capables de s’adapter à une séquence d’accès. En ce qui concerne les multi-splay trees, nous constatons que leur comportement présente des analogies avec celui des splay trees, ce qui pourrait laisser suggérer que cette structure a la propriété working set. Ceci est à prendre avec précaution, car nous tirons une constatation sur quelques résultats, mais il n’existe aucune preuve qui le démontre. Dans la dernière phase de croissance, nous constatons que le coût des multi-splay trees a tendance à croı̂tre plus vite que celui des splay trees. Ceci s’explique par le fait que l’algorithme des multi-splay trees exige plus d’opérations de réorganisation que celui des splay trees. Nous pouvons également constater que le coût des multi-splay trees est plus petit que celui des arbres rouges-noirs. Au début de chaque graphique les arbres rouges-noirs ont un coût qui est meilleur que celui des splay trees et cette structure a un coût qui est meilleur que celui des multi-splay trees. Mais de par le type de distribution sur laquelle nous travaillons, la tendance s’inverse rapidement. Les splay trees ont alors un coût meilleur que celui des multi-splay trees et ceux-ci ont un coût meilleur que celui des arbres rouges-noirs. 11.4 Conclusion Les différents jeux de tests exécutés nous ont permis de constater que les multi-splay trees souffrent de la complexité de leur algorithme et du grand nombre d’opérations de réorganisation qu’il nécessite. Nous avons également obtenu des résultats qui pourraient laisser suggérer que les multi-splay trees ont la propriété working set. Les tests effectués sur le deuxième type de distribution illustrent bien la capacité des splay trees à s’adapter à une séquence d’accès et à l’incapacité des arbres rouges-noirs à faire de même. 112 40000 MST8 ST8 RB8 35000 30000 25000 20000 15000 10000 5000 0 0 500 1000 1500 2000 2500 3000 Fig. 11.3 – Coûts d’un arbre de taille 28 avec une distribution working set 2.2e+07 MST16 ST16 RB16 2e+07 1.8e+07 1.6e+07 1.4e+07 1.2e+07 1e+07 8e+06 6e+06 4e+06 2e+06 0 0 100000 200000 300000 400000 500000 600000 Fig. 11.4 – Coûts d’un arbre de taille 216 avec une distribution working set 113 700000 Chapitre 12 Conclusion Nous avons étudié comme première structure les ABRO et nous avons détaillé l’algorithme qui permet à cette structure d’atteindre l’optimalité statique en se basant sur la connaissance préalable de la distribution des clés à accéder. Puis, nous avons étudié les arbres rouges-noirs et les splay trees. En analysant les performances de ces structures, nous avons constaté que les arbres rouges-noirs fournissent des résultats intéressants sur un accès, alors que les splay trees ont besoin de séquences d’accès plus grandes pour fournir de bonnes performances. Nous avons également constaté que les arbres rougesnoirs ne sont pas capables de s’adapter aux particularités d’une séquence d’accès, alors que les splay trees y parviennent. C’est cette qualité qui fait la force des splay trees. Et enfin, nous avons constaté que les arbres rouges-noirs sont O(log n) − compétitif s, alors que les splay trees sont conjecturés être O(1) − compétitif s. Ensuite, nous avons énoncé et démontré trois bornes inférieures sur le coût d’exécution d’une séquence d’accès dans des ABR. L’intérêt de telles bornes est grand, car elles permettent de développer de nouvelles structures. À l’heure actuelle, le principal obstacle pour développer une structure qui puisse atteindre l’optimalité dynamique, est le manque de bornes inférieures de qualité. La structure Tango a été développée sur base de l’une des trois bornes étudiées : la borne inférieure d’entrelacement. Cette structure est la première qui ait amélioré le taux trivial de compétitivité de O(log n). Cette structure atteint un taux de O(log(log n)). Le problème avec cette structure est qu’elle est complexe, ce qui fait que son implémentation est assez lourde. Les multi-splay trees sont une structure de données ABR. Cette structure est basée sur les mêmes principes que Tango et elle atteint également un taux de compétitivité de O(log(log n)). Cette structure est plus simple 114 à implémenter que Tango. De plus, elle peut être étendue pour supporter les opérations d’insertion et de suppression tout en gardant le taux de compétitivité de O(log(log n)). Ensuite, nous avons étudié une structure, qui atteint l’optimalité de recherche dynamique. Nous avons également montré que l’existence d’une telle structure est importante, car il s’agit d’un argument en faveur de l’existence d’une structure optimale dynamiquement. Et enfin, nous avons exécuté des jeux de tests sur les arbres rouges-noirs, les splay trees et les multi-splay trees. Le but de ces tests était d’analyser les performances en pratique de ces structures. Cela nous a permis de constater que les arbres rouges-noirs sont performants sur des séquences d’accès, où les nœuds à accéder sont choisis de manière aléatoire parmi l’ensemble des nœuds de l’arbre. Nous avons également remarqué que les splay trees sont performants sur des séquences d’accès, où les nœuds à accéder sont choisis de manière aléatoire parmi un sous-ensemble de l’ensemble des nœuds de l’arbre. Ceci est dû au fait que les splay trees ont la propriété working set. L’analyse des performances des multi-splay trees sur ce type de séquences peut nous laisser suggérer que cette structure a également la propriété working set. Concernant les multi-splay trees, nous avons également constaté que l’algorithme complexe de cette structure a une incidence sur ses performances en pratique. Ce mémoire a consisté en un travail de recherche bibliographique et il a consisté à synthétiser les grandes avancées dans le domaine des arbres binaires de recherche et de l’optimalité dynamique. En général, les articles étudiés présentent les algorithmes et les démonstrations de manière assez succincte. Un objectif de ce mémoire a donc été de présenter ces algorithmes et ces démonstrations de manière didactique en les décortiquant et en les détaillant le plus possible. 115 Chapitre 13 Annexe 13.1 Implémentation De manière à analyser et comparer les performances en pratique des multi-splay trees, des splay trees et des arbres rouges-noirs, nous avons implémenté l’algorithme des multi-splay trees et des splay trees. Pour l’algorithme des arbres rouges-noirs, nous avons récupéré une implémentation sur le net [Nie05]. Ces trois implémentations sont fournies en annexe. Le but n’est pas d’avoir une implémentation de ces structures qui puissent faire partie d’une librairie en implémentant par exemple un type générique pour les clés. Le but est de simplement avoir une implémentation permettant d’exécuter des jeux de tests. 13.1.1 Multi-splay trees Les clés sont des éléments de type entier et il faut que la taille de l’arbre soit égale à n = 2k −1 (∀ k > 1). Les valeurs des clés correspond à l’ensemble {1, . . . , n}. Pour l’implémentation de la structure, nous utilisons deux classes : 1. elem : cette classe représente un nœud de l’arbre, ainsi que ses différents champs. 2. splaytree : cette classe représente le multi-splay tree. Nous détaillons ci-dessous les points principaux de l’implémentation. Des explications plus détaillées sont fournies en tant que commentaires dans le code. L’initialisation de la structure se fait à l’aide de la méthode construireP, qui est appelée par le constructeur paramétré de la classe splaytree. Dans cette méthode, nous construisons un arbre parfait de n nœuds et nous initialisons 116 le fils préféré de chaque nœud à son fils gauche. Durant cette construction l’isroot bit du fils gauche et du fils droit de chaque nœud est initialisé, respectivement, à false et true (car pour chaque nœud, son fils droit est son fils non préféré). De plus, le champ mindepth de chaque nœud x est initialisé à sa profondeur dans l’arbre parfait (car tous les nœuds dans le splay subtree de x ont une profondeur plus grande que celle de x). L’arbre parfait ainsi construit est, en fait, également un multi-splay tree et il est utilisé comme état initial de la structure. L’avantage d’une telle construction est qu’elle permet d’initialiser la structure avec un coût O(n). La méthode recherche localise d’abord le nœud i passé en paramètre et réorganise ensuite la structure de manière à avoir un chemin préféré allant de la racine du multi-splay tree au nœud passé en paramètre. La réorganisation de la structure est effectuée dans la boucle principale de la méthode. Le traitement de cette boucle est le suivant. Si le nœud courant est la racine d’un splay tree, alors il faut rechercher dans le splay tree de son père le nœud sur lequel effectuer un switch. Un fois ce nœud déterminé, on applique soit un switch droite-gauche (méthode switchDG), soit un switch gauche-droite (méthode switchGD). Et on recommence la boucle jusqu’à ce que la racine de l’arbre soit atteinte. Et lorsqu’elle est atteinte, on splaye le nœud i pour le ramener à la racine de l’arbre (méthode switchracine). Les méthodes switchGD et switchDG ne sont qu’une implémentation des algorithmes décrits dans le chapitre 9 pour effectuer un switch. L’implémentation est fournie ci-dessous. # include <iostream> # include <limits.h> # include <time.h> # include <math.h> # include <iomanip> # include <stdlib.h> # include <unistd.h> # include <sys/types.h> # include <sys/stat.h> # include <fcntl.h> using namespace std ; class splaytree { private : class elem { 117 friend class splaytree ; int info ;// clé du noeud int profondeur ;// profondeur du noeud dans //l’arbre de référence int mindepth ;// profondeur minimum dans le //splay subtree du noeud bool isroot ; elem ∗ fg ; elem ∗ fd ; elem ∗ pere ; elem(){} elem(int i, int prof=0 , int mind=1, bool isr=false, elem ∗ g = NULL, elem ∗ d = NULL, elem ∗ p = NULL) :info(i),profondeur(prof), mindepth(mind), isroot(isr), fg(g), fd(d), pere(p) {} }; elem ∗ rac ;// racine du multi-splay tree void rotationsimple(elem ∗,bool &) ; void zigzag(elem ∗,bool &) ; void zigzig(elem ∗,bool &) ; void splayingracine(elem ∗) ; void splayingto(elem ∗, elem ∗) ; elem∗ construireP(int,int,double,int, bool,elem∗) ; elem ∗ localisation(int) ; elem∗ determinerZ(elem ∗) ; elem∗ determinerX(elem ∗) ; void switchGD(elem ∗) ; void switchDG(elem ∗) ; return(b) ;} void infixe(elem ∗) ; void destructeur(elem ∗) ; int min(int a, int b) {if(a < b) return(a) ; else public : splaytree(){rac=NULL ;} splaytree(int) ; elem ∗ recherche(int) ; ; 118 }; ∼splaytree() ; splaytree : :splaytree(int n) //Constructeur paramétré du multi-splay tree { elem ∗ p = construireP((n/2)+1,1,(log(n+1.0)/log(2.0)),0, true, NULL) ; rac=p ; } splaytree : :elem∗ splaytree : :construireP(int taille, int prof, double stop, int biais, bool isroot, elem ∗ parent) // Construit l’état initial du multi-splay tree { if(prof <= stop) { elem ∗ p = new elem(taille+biais, prof, prof, isroot, NULL, NULL, parent) ; // la variable biais contient la valeur de la clé // du père de p, ce qui permet d’initialiser la clé // de p à la bonne valeur p→fg = construireP(taille/2, prof+1,stop,biais, false, p) ; p→fd = construireP(taille/2, prof+1, stop, taille+biais, true, p) ; // Construction récursive pour le sous-arbre gauche // et le sous-arbre droit de p return(p) ; } else return(NULL) ; } void splaytree : :rotationsimple(elem ∗ p, bool & stop) // Cette méthode effectue une rotation simple. La variable ”stop” //indique à l’appelant si le noeud p est devenu la racine du //splay tree auquel il appartient (stop = true) ou non (stop = false) { elem ∗ q = p→pere ; int a=INT MAX, b=INT MAX ; 119 if(q→isroot) // Dans ce cas, il faut changer l’isroot bit de p et q, //car p devient la racine du splay tree auquel il //appartient et q ne l’est plus { q→isroot=false ; p→isroot=true ; stop=true ; } if(q→pere) { // Mise à jour des pointeurs du père de q pour // qu’il devienne le père de p if(q == q→pere→fd) q→pere→fd = p ; else q→pere→fg = p ; } // Les opérations ci-dessous sont des mises à jour de //pointeurs pour effectuer la rotation simple p→pere = q→pere ; q→pere = p ; if(p == q→fg) { q→fg = p→fd ; if(q→fg) q→fg→pere = q ; p→fd = q ; } else { q→fd = p→fg ; if(q→fd) q→fd→pere = q ; p→fg = q ; } 120 // Les opérations ci-dessous mettent à jour les champs //”mindepth”, qui changent à cause de la rotation p→mindepth = q→mindepth ; if(q→fg) a = q→fg→mindepth ; if(q→fd) b = q→fd→mindepth ; q→mindepth = min(q→profondeur, min(a,b)) ; } void splaytree : :zigzag(elem ∗ p, bool & stop) // Cette méthode effectue une rotation zig-zag. La variable ”stop” //indique à l’appelant si le noeud p est devenu la racine du splay //tree auquel il appartient (stop = true) ou non (stop = false) { elem ∗ q = p→pere ; elem ∗ z = q→pere ; int a=INT MAX, b=INT MAX ; if(z→isroot) // Dans ce cas, il faut changer l’isroot bit de p et z, //car p devient la racine du splay tree auquel il //appartient et z ne l’est plus { // Mise à jour des pointeurs du père de z pour //qu’il devienne le père de p z→isroot=false ; p→isroot=true ; stop=true ; } // Les opérations ci-dessous sont des mises à jour de //pointeurs pour effectuer la rotation zig-zag p→pere = z→pere ; z→pere = p ; q→pere = p ; if(p == q→fd) 121 { } else { q→fd = p→fg ; if(q→fd) q→fd→pere = q ; z→fg = p→fd ; if(z→fg) z→fg→pere = z ; p→fg = q ; p→fd = z ; q→fg = p→fd ; if(q→fg) q→fg→pere = q ; z→fd = p→fg ; if(z→fd) z→fd→pere = z ; p→fg = z ; p→fd = q ; } if(p→pere) { if(p→pere→fg == z) p→pere→fg = p ; else p→pere→fd = p ; } // Les opérations ci-dessous mettent à jour les champs //”mindepth”, qui changent à cause de la rotation p→mindepth = z→mindepth ; if(z→fg) a = z→fg→mindepth ; if(z→fd) b = z→fd→mindepth ; 122 z→mindepth = min(z→profondeur, min(a,b)) ; a = INT MAX ; b = INT MAX ; if(q→fg) a = q→fg→mindepth ; if(q→fd) b = q→fd→mindepth ; q→mindepth = min(q→profondeur, min(a,b)) ; } void splaytree : :zigzig(elem ∗ p, bool & stop) // Cette méthode effectue une rotation zig-zig. La variable //”stop” indique à l’appelant si le noeud p est devenu la //racine du splay tree auquel il appartient (stop = true) //ou non (stop = false) { elem ∗ q = p→pere ; elem ∗ z = q→pere ; int a = INT MAX, b = INT MAX ; if(z→isroot) // Dans ce cas, il faut changer l’isroot bit de p et z, //car p devient la racine du splay tree auquel il //appartient et z ne l’est plus { // Mise à jour des pointeurs du père de z pour //qu’il devienne le père de p z→isroot=false ; p→isroot=true ; stop=true ; } // Les opérations ci-dessous sont des mises à jour de //pointeurs pour effectuer la rotation zig-zig p→pere = z→pere ; q→pere = p ; z→pere = q ; 123 if(p == q→fg) { q→fg = p→fd ; if(q→fg) q→fg→pere = q ; z→fg = q→fd ; if(z→fg) z→fg→pere = z ; p→fd = q ; q→fd = z ; p→mindepth = z→mindepth ; if(z→fg) a = z→fg→mindepth ; if(z→fd) b = z→fd→mindepth ; z→mindepth = min(z→profondeur, min(a,b)) ; a = INT MAX ; if(q→fg) a = q→fg→mindepth ; q→mindepth = min(q→profondeur, min(a,z→mindepth)) ; } else { q→fd = p→fg ; if(q→fd) q→fd→pere = q ; z→fd = q→fg ; if(z→fd) z→fd→pere = z ; p→fg = q ; q→fg = z ; 124 p→mindepth = z→mindepth ; if(z→fg) a = z→fg→mindepth ; if(z→fd) b = z→fd→mindepth ; z→mindepth = min(z→profondeur, min(a,b)) ; a = INT MAX ; if(q→fd) a = q→fd→mindepth ; q→mindepth = min(q→profondeur, min(a,z→mindepth)) ; } if(p→pere) { if(p→pere→fg == z) p→pere→fg = p ; else p→pere→fd = p ; } } void splaytree : :splayingracine(elem ∗ p) // Cette méthode splaye le noeud p jusqu’à ce qu’il devienne //la racine du multi-splay tree auquel il appartient { elem ∗ q =p→pere ; bool stop = false ; short int tester=0 ; // On vérifie que le noeud à splayer ne soit pas la //racine du multi-splay tree if( !p→isroot) { while( !stop && q) 125 { if(q→isroot) { rotationsimple(p,stop) ; break ; } else { if(p == q→fg) //si p est un fils gauche, on //incrémente test de 1 ++tester ; else //sinon test est décrémenté de 1 – –tester ; if(q == q→pere→fg) //si q est un fils gauche, on //incrémente test de 1 ++tester ; else //sinon test est décrémenté de 1 – –tester ; if(tester) //si test est différent de 0, cela //signifie qu’on est remonté par deux //sens différents pour aller du //noeud x à son grand-père. Il faut //donc effectuer une rotation zig-zig { zigzig(p,stop) ; tester=0 ; } else //sinon on est remonté deux fois par le //même sens pour aller du //noeud x à son grand-père. Il faut //donc effectuer une rotation zig-zag { zigzag(p,stop) ;} 126 q = p→pere ; } } } } if( !p→pere)//si p n’a pas de père, alors il // est la racine du multi-splay tree { rac = p ; // on met à jour la racine du multi-splay tree } void splaytree : :splayingto(elem ∗ p, elem ∗ arret) // Cette méthode splaye le noeud p jusqu’à ce qu’il devienne //le fils du noeud arret { elem ∗ q =p→pere ; short int test=0 ; bool stop=false ; while(q != arret) { if(q→pere == arret) { rotationsimple(p,stop) ; break ; } else { if(p == q→fg) //si p est un fils gauche, on //incrémente test de 1 ++test ; else //sinon test est décrémenté de 1 – –test ; 127 if(q == q→pere→fg) //si q est un fils gauche, on //incrémente test de 1 ++test ; else //sinon test est décrémenté de 1 – –test ; if(test) //si test est different de 0, cela //signifie qu’on est remonté par deux //sens différents pour aller du //noeud x à son grand-père. Il faut //donc effectuer une rotation zig-zig { zigzig(p,stop) ; test=0 ; } else //sinon on est remonté deux fois par le //même sens pour aller du //noeud x à son grand-père. Il faut //donc effectuer une rotation zig-zag zigzag(p,stop) ; q = p→pere ; } } } splaytree : :elem∗ splaytree : :localisation(int i) // Cette méthode localise le noeud i dans le multi-splay tree { elem ∗ p = rac ; elem ∗ q=NULL ; if(p) // On vérifie que l’arbre ne soit pas vide { do 128 { q = p; if(i == p→info) { return(p) ; } else { if(i < p→info) p = p→fg ; else p = p→fd ; } } while(p) ; //si on sort du while, c’est que l’élément i //n’est pas présent dans l’arbre. } return(NULL) ; } splaytree : :elem∗ splaytree : :recherche(int i) //Cette fonction recherche l’élément i et réorganise la structure //pour tenir compte des changements causés par l’accès i. { elem ∗ p = localisation(i) ;//localisation de i elem ∗ save = p ; //On vérifie que le noeud i ne soit pas la racine du //multi-splay tree if(p) { while(p != rac) { if(p→isroot) // Dans ce cas, un changement de fils //préféré doit être effectué 129 { //recherche du noeud a switcher, //qui sera stocké dans p à la fin //de la boucle while elem ∗ rac splay bas = p ; p=p→pere ; while(p→profondeur != (rac splay bas→mindepth)-1) { p=p→pere ; } //après que le noeud à switcher //ait été localisé, il faut déterminer //le type de switch à effectuer if(rac splay bas→info > p→info) switchGD(p) ; else switchDG(p) ; } else { p=p→pere ; } } splayingracine(save) ;// le noeud i est remonté //jusqu’à la racine } else { } return(NULL) ; } void splaytree : :switchGD(elem ∗ y) // Cette méthode effectue un switch gauche-droite sur le noeud y { elem ∗ z = NULL ; elem ∗ x = NULL ; int mingauche = INT MAX, mindroite = INT MAX ; 130 splayingracine(y) ;// on ramène y à la racine du splay tree //auquel il appartient z = determinerZ(y) ; // on localise le noeud z. Il s’agit du plus grand noeud, //qui a une information prof plus petite que celle //de y et qui est plus petit que y if(z) { splayingto(z, y) ; // on splaye z jusqu’à ce qu’il devienne le fils //gauche de y if(z→fd) { // on fait du fils gauche de y dans P //son fils non préféré z→fd→isroot = true ; int a = INT MAX ; if(z→fg) a = z→fg→mindepth ; z→mindepth = min(z→profondeur, a) ; mingauche = z→mindepth ; } } else // z n’existe pas { // on fait du fils gauche de y dans P son fils //non préféré if(y→fg) { y→fg→isroot = true ; } } x = determinerX(y) ; // on localise le noeud x. Il s’agit du plus petit noeud, //qui a une information prof plus petite que celle 131 //de y et qui est plus grand que y if(x) { splayingto(x, y) ; // on splaye x jusqu’à ce qu’il devienne le fils //droit de y if(x→fg) { // on fait du fils droit de y dans P son //fils préféré x→fg→isroot = false ; if(x→fg→mindepth < x→mindepth) x→mindepth = x→fg→mindepth ; mindroite = x→mindepth ; } } else // x n’existe pas { // on fait du fils droit de y dans P son fils préféré if(y→fd) { y→fd→isroot = false ; } } y→mindepth = min(y→profondeur, min(mingauche,mindroite)) ; } void splaytree : :switchDG(elem ∗ y) // Cette méthode effectue un switch gauche-droite sur le noeud y { elem ∗ z = NULL ; elem ∗ x = NULL ; int mingauche = INT MAX, mindroite = INT MAX ; splayingracine(y) ;// on ramène y à la racine du splay tree //auquel il appartient z = determinerZ(y) ; 132 // on localise le noeud z. Il s’agit du plus grand noeud, //qui a une information prof plus petite que celle //de y et qui est plus petit que y if(z) { splayingto(z, y) ; // on splaye z jusqu’à ce qu’il devienne le fils //gauche de y if(z→fd) { // on fait du fils gauche de y dans P son //fils préféré z→fd→isroot = false ; if(z→fd→mindepth < z→mindepth) z→mindepth = z→fd→mindepth ; mingauche = z→mindepth ; } } else // z n’existe pas { // on fait du fils gauche de y dans P son fils //préféré if(y→fg) { y→fg→isroot = false ; } } x = determinerX(y) ; // on localise le noeud x. Il s’agit du plus petit noeud, //qui a une information prof plus petite que celle //de y et qui est plus grand que y if(x) { splayingto(x, y) ; // on splaye x jusqu’à ce qu’il devienne le fils //droit de y 133 if(x→fg) { // on fait du fils droit de y dans P son // fils non préféré x→fg→isroot = true ; int a = INT MAX ; if(x→fd) a = x→fd→mindepth ; x→mindepth = min(x→profondeur, a) ; mindroite = x→mindepth ; } } else // x n’existe pas { // on fait du fils droit de y dans P son fils non //préféré if(y→fd) { y→fd→isroot = true ; } } y→mindepth = min(y→profondeur, min(mingauche,mindroite)) ; } splaytree : :elem∗ splaytree : :determinerZ(elem ∗ y) // Cette méthode renvoie le plus grand noeud, qui a une information //prof plus petite que celle de y et qui est plus petit que y. { elem ∗ x = y →fg ; if(x && x→mindepth < y→profondeur) { while(true) { if(x→profondeur < y→profondeur) { if(x→fd && x→fd→mindepth < y→profondeur) 134 x = x→fd ; else return(x) ; } else { if(x→fd && x→fd→mindepth < y→profondeur) x = x→fd ; else x = x→fg ; } } else { } } return(NULL) ; } splaytree : :elem∗ splaytree : :determinerX(elem ∗ y) // Cette méthode renvoie le plus petit noeud, qui a une information //prof plus petite que celle de y et qui est plus grand que y { elem ∗ x = y →fd ; if(x && x→mindepth < y→profondeur) { while(true) { if(x→profondeur < y→profondeur) { if(x→fg && x→fg→mindepth < y→profondeur) x = x→fg ; else return(x) ; } else 135 { if(x→fg && x→fg→mindepth < y→profondeur) x = x→fg ; else x = x→fd ; } else { } } } return(NULL) ; } splaytree : :∼splaytree() { destructeur(rac) ; } void splaytree : :destructeur(elem ∗ p) { if(p) { destructeur(p→fg) ; destructeur(p→fd) ; delete p ; } } int main() { return(0) ; } 136 13.1.2 Splay trees Les clés sont des éléments de type entier. Les valeurs des n clés de l’arbre correspondent à l’ensemble {1, . . . , n}. Pour l’implémentation de la structure, nous utilisons deux classes : 1. elem : cette classe représente un nœud de l’arbre. 2. splaytree : cette classe représente le splay tree. Nous détaillons ci-dessous les points principaux de l’implémentation. Des explications plus détaillées sont fournies en tant que commentaires dans le code. L’initialisation de la structure se fait dans le constructeur paramétré de la classe splaytree. Elle consiste à insérer de manière successive les n éléments de l’arbre dans l’ordre croissant. Elle fait appel pour cela à la méthode insertion. La méthode insertion consiste à insérer l’élément passé en paramètre dans l’arbre. Cette méthode n’est qu’une implémentation de l’algorithme d’insertion de splay trees décrit dans le chapitre 6. La méthode recherche consiste à localiser l’élément passé en paramètre. Après cela, elle invoque la méthode splaying pour remonter l’élément recherché à la racine. La méthode splaying consiste à remonter l’élément passé en paramètre à la racine. La remontée est effectuée dans la boucle principale. Le traitement de cette boucle consiste à déterminer le type de rotation qu’il faut effectuer (soit une rotation simple, soit une rotation zig-zig, soit une rotation zig-zag) et à invoquer, ensuite, la méthode adéquate pour l’effectuer. La boucle est recommencée jusqu’à ce que la racine soit atteinte. L’implémentation est fournie ci-dessous. # include <iostream> # include <limits.h> # include <time.h> # include <math.h> # include <iomanip> # include <stdlib.h> # include <unistd.h> # include <sys/types.h> # include <sys/stat.h> # include <fcntl.h> using namespace std ; class splaytree 137 { private : class elem { friend class splaytree ; int info ; elem ∗ fg ; elem ∗ fd ; elem ∗ pere ; elem(){} elem(int i, elem ∗ g = NULL, elem ∗ d = NULL, elem ∗ p = NULL) :info(i), fg(g), fd(d), pere(p) {} }; elem ∗ rac ; void rotationsimple(elem ∗) ; void zigzag(elem ∗ ) ; void zigzig(elem ∗ ) ; void splaying(elem ∗ ) ; void destructeur(elem ∗) ; public : splaytree(){rac=NULL ;} splaytree(int) ; elem ∗ recherche(int) ; void insertion(int) ; void suppression(int) ; ∼splaytree() ; }; splaytree : :splaytree(int i) //constructeur du splay tree { rac=new elem(1) ; for(int j=2 ; j <= i ; j++) { insertion(j) ; } 138 } void splaytree : :rotationsimple(elem ∗ p) // si l’élément à splayer n’a pas de grand-père on appelle // cette fonction pour réaliser la rotation { elem ∗ q = p→pere ; p→pere = NULL ; q→pere = p ; if(p == q→fg) { q→fg = p→fd ; if(q→fg) q→fg→pere = q ; p→fd = q ; } else { q→fd = p→fg ; if(q→fd) q→fd→pere = q ; p→fg = q ; } } void splaytree : :zigzag(elem ∗ p) //si le noeud à splayer est le fils droit(gauche) d’un fils //gauche(droit), on appelle cette fonction pour //effectuer la rotation { elem ∗ q = p→pere ; elem ∗ z = q→pere ; p→pere = z→pere ; z→pere = p ; 139 q→pere = p ; if(p == q→fd) { q→fd = p→fg ; if(q→fd) q→fd→pere = q ; z→fg = p→fd ; if(z→fg) z→fg→pere = z ; p→fg = q ; p→fd = z ; } else { q→fg = p→fd ; if(q→fg) q→fg→pere = q ; z→fd = p→fg ; if(z→fd) z→fd→pere = z ; p→fg = z ; p→fd = q ; } if(p→pere) { if(p→pere→fg == z) p→pere→fg = p ; else p→pere→fd = p ; } } void splaytree : :zigzig(elem ∗ p) // si le noeud à splayer est le fils gauche(droit) d’un //fils gauche(droit), on appelle cette fonction pour //effectuer la rotation { 140 elem ∗ q = p→pere ; elem ∗ z = q→pere ; p→pere = z→pere ; q→pere = p ; z→pere = q ; if(p == q→fg) { q→fg = p→fd ; if(q→fg) q→fg→pere = q ; z→fg = q→fd ; if(z→fg) z→fg→pere = z ; p→fd = q ; q→fd = z ; } else { q→fd = p→fg ; if(q→fd) q→fd→pere = q ; z→fd = q→fg ; if(z→fd) z→fd→pere = z ; p→fg = q ; q→fg = z ; } if(p→pere) { if(p→pere→fg == z) p→pere→fg = p ; else p→pere→fd = p ; } } 141 void splaytree : :splaying(elem ∗ p) //fonction qui réalise un splaying sur un noeud. Ce splaying //remontera ce noeud jusqu’à la racine de l’arbre par //une série de rotations. Cette fonction déterminera //le type de rotation à réaliser sur le noeud //à chaque étape du splaying { elem ∗ q =p→pere ; short int test=0 ; while(q) //si p est la racine, alors son pointeur père q est nul { if( !q→pere) { rotationsimple(p) ; break ; } else { if(p == q→fg) //si p est un fils gauche, on //incrémente test de 1 ++test ; else //sinon test est décrémenté de 1 – –test ; if(q == q→pere→fg) //si q est un fils gauche, on //incrémente test de 1 ++test ; else //sinon test est décrémenté de 1 – –test ; if(test) //si test est différent de 0, cela //signifie qu’on est remonté par deux //sens différents pour aller du 142 //noeud x à son grand-père. Il faut //donc effectuer une rotation zig-zig { zigzig(p) ; test=0 ; } else //sinon on est remonté deux fois par le //même sens pour aller du //noeud x à son grand-père. Il faut //donc effectuer une rotation zig-zag zigzag(p) ; q = p→pere ; } } rac = p ; } splaytree : :elem∗ splaytree : :recherche(int i) //Cette fonction recherche un élément dans un splay tree. Si //l’élément est présent dans l’arbre, un pointeur vers le noeud //recherché est renvoyé. Sinon le pointeur NULL est renvoyé pour //signaler que l’élément n’est pas présent dans l’arbre { elem ∗ p = rac ; elem ∗ q=NULL ; if(p) { do { q = p; if(i == p→info) { splaying(p) ; 143 return(p) ; } else { if(i < p→info) p = p→fg ; else p = p→fd ; } } while(p) ; //si on sort du while, c’est que l’élément n’est //pas dans l’arbre. On réalise donc un splaying //sur le dernier élément rencontré pendant //la recherche. splaying(q) ; } return(NULL) ; } void splaytree : :insertion(int i) //Cette fonction insère un élément dans l’arbre, puis y applique //un splaying { elem ∗ p = rac ; if(p) { elem ∗ q = NULL ; do { q = p; if( i <= p→info) p = p→fg ; else p = p→fd ; } 144 while(p) ; if( i <= q→info) { q→fg = new elem(i) ; q→fg→pere = q ; splaying(q→fg) ; } else { q→fd = new elem(i) ; q→fd→pere = q ; splaying(q→fd) ; } } else rac = new elem(i) ; } void splaytree : :suppression(int i) //Pour supprimer un élément, on doit d’abord appeler la //fonction rechercher en passant comme paramètre l’élément //à supprimer. Cet élément devient alors la racine //de l’arbre. Puis, on supprime cette racine et on obtient //deux sous-arbres que l’on doit fusionner. Pour ce faire, //il suffit de prendre le plus grand élément dans le sous-arbre //gauche(le fils droit le plus à droite dans le sous-arbre //gauche) et de le remonter jusqu’à la racine (par la fonction //de splaying). La racine du sous-arbre gauche ne possédant plus //de fils droit, il suffit de rattacher la racine du sous-arbre //droit comme fils droit de la racine du sous-arbre gauche. { elem ∗ p ; if((p = recherche(i))) { splaying(p) ; // l’élément à effacer est devenu la racine //de l’arbre 145 elem∗ ssarbreG = p→fg ; if (p→fg) { rac = p→fg ; p→fg→pere= 0 ; } elem ∗ ssarbreD = p→fd ; delete p ; // suppression de la racine de l’arbre if( ssarbreD && ssarbreG) { ssarbreD→pere= 0 ; elem ∗ q = 0 ; for( p = rac ; p ; p= p→fd) // recherche du noeud le plus à droite // du sous-arbre gauche { q = p; } if(q) { splaying(q) ; q→fd = ssarbreD ; ssarbreD→pere = q ; } } else { if(ssarbreD) { rac = ssarbreD ; ssarbreD→pere= 0 ; 146 } else { if( !ssarbreG) { rac = 0 ; } } } } } splaytree : :∼splaytree() { destructeur(rac) ; } void splaytree : :destructeur(elem ∗ p) { if(p) { destructeur(p→fg) ; destructeur(p→fd) ; delete p ; } } int main() { return(0) ; } 147 13.1.3 Arbres rouges-noirs L’implémentation est fournie ci-dessous. # # # # # # # # # # include include include include include include include include include include <stdio.h> <stdlib.h> <string.h> <stdarg.h> <time.h> <math.h> <unistd.h> <sys/types.h> <sys/stat.h> <fcntl.h> /∗ implementation independent declarations ∗/ /∗ Red-Black tree description ∗/ typedef enum { BLACK, RED } nodeColor ; typedef struct nodeTag { struct nodeTag ∗left ; /∗ left child ∗/ struct nodeTag ∗right ; /∗ right child ∗/ struct nodeTag ∗parent ; /∗ parent ∗/ nodeColor color ; /∗ node color (BLACK, RED) ∗/ int key ; /∗ key used for searching ∗/ /∗ user data ∗/ } nodeType ; #define NIL &sentinel /∗ all leafs are sentinels ∗/ static nodeType sentinel = { NIL, NIL, 0, BLACK, 0} ; /∗ last node found, optimizes find/delete operations ∗/ static nodeType ∗lastFind ; static nodeType ∗root = NIL ; /∗ root of Red-Black tree ∗/ static void rotateLeft(nodeType ∗x) { /∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗ ∗ rotate node x to left ∗ ∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗/ 148 nodeType ∗y = x→right ; /∗ establish x->right link ∗/ x→right = y→left ; if (y→left != NIL) y→left→parent = x ; /∗ establish y->parent link ∗/ if (y != NIL) y→parent = x→parent ; if (x→parent) { if (x == x→parent→left) x→parent→left = y ; else x→parent→right = y ; } else { root = y ; } /∗ link x and y ∗/ y→left = x ; if (x != NIL) x→parent = y ; } static void rotateRight(nodeType ∗x) { /∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗ ∗ rotate node x to right ∗ ∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗/ nodeType ∗y = x→left ; /∗ establish x->left link ∗/ x→left = y→right ; if (y→right != NIL) y→right→parent = x ; /∗ establish y->parent link ∗/ if (y != NIL) y→parent = x→parent ; if (x→parent) { 149 if (x == x→parent→right) x→parent→right = y ; else x→parent→left = y ; } else { root = y ; } /∗ link x and y ∗/ y→right = x ; if (x != NIL) x→parent = y ; } static void insertFixup(nodeType ∗x) { /∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗ ∗ maintain Red-Black tree balance ∗ ∗ after inserting node x ∗ ∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗/ /∗ check Red-Black properties ∗/ while (x != root && x→parent→color == RED) { /∗ we have a violation ∗/ if (x→parent == x→parent→parent→left) { nodeType ∗y = x→parent→parent→right ; if (y→color == RED) { /∗ uncle is RED ∗/ x→parent→color = BLACK ; y→color = BLACK ; x→parent→parent→color = RED ; x = x→parent→parent ; } 150 else { /∗ uncle is BLACK ∗/ if (x == x→parent→right) { /∗ make x a left child ∗/ x = x→parent ; rotateLeft(x) ; } /∗ recolor and rotate ∗/ x→parent→color = BLACK ; x→parent→parent→color = RED ; rotateRight(x→parent→parent) ; } } else { /∗ mirror image of above code ∗/ nodeType ∗y = x→parent→parent→left ; if (y→color == RED) { /∗ uncle is RED ∗/ x→parent→color = BLACK ; y→color = BLACK ; x→parent→parent→color = RED ; x = x→parent→parent ; } else { /∗ uncle is BLACK ∗/ if (x == x→parent→left) 151 { x = x→parent ; rotateRight(x) ; } x→parent→color = BLACK ; x→parent→parent→color = RED ; rotateLeft(x→parent→parent) ; } } } root→color = BLACK ; } void insert(int key) { nodeType ∗current, ∗parent, ∗x ; /∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗ ∗ allocate node for data and insert in tree ∗ ∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗/ /∗ find future parent ∗/ current = root ; parent = 0 ; while (current != NIL) { /∗if (compEQ(key, current->key)) return STATUS DUPLICATE KEY ;∗/ parent = current ; current = (key < current→key) ? current→left : current→right ; } /∗ setup new node ∗/ 152 x =(nodeType∗) malloc (sizeof(∗x)) ; x→parent = parent ; x→left = NIL ; x→right = NIL ; x→color = RED ; x→key = key ; /∗ insert node in tree ∗/ if(parent) { if((key < parent→key)) parent→left = x ; else parent→right = x ; } else { root = x ; } insertFixup(x) ; lastFind = NULL ; } static void deleteFixup(nodeType ∗x) { /∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗ ∗ maintain Red-Black tree balance ∗ ∗ after deleting node x ∗ ∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗/ while (x != root && x→color == BLACK) { if (x == x→parent→left) { nodeType ∗w = x→parent→right ; if (w→color == RED) 153 { w→color = BLACK ; x→parent→color = RED ; rotateLeft (x→parent) ; w = x→parent→right ; } if (w→left→color == BLACK && w→right→color == BLACK) { } else { w→color = RED ; x = x→parent ; if (w→right→color == BLACK) { w→left→color = BLACK ; w→color = RED ; rotateRight (w) ; w = x→parent→right ; } w→color = x→parent→color ; x→parent→color = BLACK ; w→right→color = BLACK ; rotateLeft (x→parent) ; x = root ; } else { } nodeType ∗w = x→parent→left ; if (w→color == RED) { w→color = BLACK ; x→parent→color = RED ; rotateRight (x→parent) ; w = x→parent→left ; } if (w→right→color == BLACK && w→left→color == 154 BLACK) { } else { w→color = RED ; x = x→parent ; if (w→left→color == BLACK) { w→right→color = BLACK ; w→color = RED ; rotateLeft (w) ; w = x→parent→left ; } w→color = x→parent→color ; x→parent→color = BLACK ; w→left→color = BLACK ; rotateRight (x→parent) ; x = root ; } } } x→color = BLACK ; } void deleter(int key) { nodeType ∗x, ∗y, ∗z ; /∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗ ∗ delete node z from tree ∗ ∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗/ z = root ; while(z != NIL) { if((key == z→key)) 155 break ; else z = (key < z→key) ? z→left : z→right ; } /∗if (z == NIL) return STATUS KEY NOT FOUND ;∗/ if (z→left == NIL || z→right == NIL) { /∗ y has a NIL node as a child ∗/ y = z; } else { /∗ find tree successor with a NIL node as a child ∗/ y = z→right ; while (y→left != NIL) { y = y→left ; } } /∗ x is y’s only child ∗/ if (y→left != NIL) x = y→left ; else x = y→right ; /∗ remove y from the parent chain ∗/ x→parent = y→parent ; if (y→parent) { if (y == y→parent→left) y→parent→left = x ; else 156 y→parent→right = x ; } else root = x ; if (y != z) { z→key = y→key ; } if (y→color == BLACK) deleteFixup (x) ; free (y) ; lastFind = NULL ; } int find(int key) { /∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗ ∗ find node containing data ∗ ∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗/ nodeType ∗current = root ; while(current != NIL) { if((key == current→key)) { lastFind = current ; return 1 ; } else { current = (key < current→key) ? current→left : current→right ; } 157 } return 0 ; } void destructeur(nodeType∗ p) { if(p != NIL) { destructeur(p→left) ; destructeur(p→right) ; free(p) ; } } int main() { return(0) ; } 13.2 Jeux de tests Nous fournissons ci-dessous le reste des diagrammes des jeux de tests pour montrer que les résultats obtenus sont bien consistants. 158 200000 MST9 ST9 RB9 180000 160000 140000 120000 100000 80000 60000 40000 20000 0 500 1000 1500 2000 2500 3000 3500 4000 4500 5000 5500 Fig. 13.1 – Coûts d’un arbre de taille 29 avec une distribution aléatoire 450000 MST10 ST10 RB10 400000 350000 300000 250000 200000 150000 100000 50000 0 1000 2000 3000 4000 5000 6000 7000 8000 9000 10000 Fig. 13.2 – Coûts d’un arbre de taille 210 avec une distribution aléatoire 159 11000 1e+06 MST11 ST11 RB11 900000 800000 700000 600000 500000 400000 300000 200000 100000 0 2000 4000 6000 8000 10000 12000 14000 16000 18000 20000 22000 Fig. 13.3 – Coûts d’un arbre de taille 211 avec une distribution aléatoire 2.5e+06 MST12 ST12 RB12 2e+06 1.5e+06 1e+06 500000 0 0 5000 10000 15000 20000 25000 30000 35000 40000 Fig. 13.4 – Coûts d’un arbre de taille 212 avec une distribution aléatoire 160 45000 5e+06 MST13 ST13 RB13 4.5e+06 4e+06 3.5e+06 3e+06 2.5e+06 2e+06 1.5e+06 1e+06 500000 0 0 10000 20000 30000 40000 50000 60000 70000 80000 90000 Fig. 13.5 – Coûts d’un arbre de taille 213 avec une distribution aléatoire 1.1e+07 MST14 ST14 RB14 1e+07 9e+06 8e+06 7e+06 6e+06 5e+06 4e+06 3e+06 2e+06 1e+06 0 0 20000 40000 60000 80000 100000 120000 140000 160000 Fig. 13.6 – Coûts d’un arbre de taille 214 avec une distribution aléatoire 161 180000 2.5e+07 MST15 ST15 RB15 2e+07 1.5e+07 1e+07 5e+06 0 0 50000 100000 150000 200000 250000 300000 350000 Fig. 13.7 – Coûts d’un arbre de taille 215 avec une distribution aléatoire 90000 MST9 ST9 RB9 80000 70000 60000 50000 40000 30000 20000 10000 0 500 1000 1500 2000 2500 3000 3500 4000 4500 5000 Fig. 13.8 – Coûts d’un arbre de taille 29 avec une distribution working set 162 5500 200000 MST10 ST10 RB10 180000 160000 140000 120000 100000 80000 60000 40000 20000 0 1000 2000 3000 4000 5000 6000 7000 8000 9000 10000 11000 Fig. 13.9 – Coûts d’un arbre de taille 210 avec une distribution working set 450000 MST11 ST11 RB11 400000 350000 300000 250000 200000 150000 100000 50000 0 2000 4000 6000 8000 10000 12000 14000 16000 18000 20000 Fig. 13.10 – Coûts d’un arbre de taille 211 avec une distribution working set 163 22000 1e+06 MST12 ST12 RB12 900000 800000 700000 600000 500000 400000 300000 200000 100000 0 0 5000 10000 15000 20000 25000 30000 35000 40000 45000 Fig. 13.11 – Coûts d’un arbre de taille 212 avec une distribution working set 2.5e+06 MST13 ST13 RB13 2e+06 1.5e+06 1e+06 500000 0 0 10000 20000 30000 40000 50000 60000 70000 80000 Fig. 13.12 – Coûts d’un arbre de taille 213 avec une distribution working set 164 90000 4.5e+06 MST14 ST14 RB14 4e+06 3.5e+06 3e+06 2.5e+06 2e+06 1.5e+06 1e+06 500000 0 0 20000 40000 60000 80000 100000 120000 140000 160000 180000 Fig. 13.13 – Coûts d’un arbre de taille 214 avec une distribution working set 1e+07 MST15 ST15 RB15 9e+06 8e+06 7e+06 6e+06 5e+06 4e+06 3e+06 2e+06 1e+06 0 0 50000 100000 150000 200000 250000 300000 Fig. 13.14 – Coûts d’un arbre de taille 215 avec une distribution working set 165 350000 Bibliographie [AVL62] George M. Adel’son-Vel’skii and Evgenii M. Landis. An algorithm for the organisation of information. Soviet Mathematics Doklay, 3 :1259–1262, 1962. [BCK03] Avrim Blum, Shuchi Chawla, and Adam Kalai. Static optimality and dynamic search optimality in lists and trees. Algorithmica, 36(3) :249–260, 2003. [Bit79] James R. Bitner. Heuristics that dynamically organize data structures. SIAM Journal of Computing, 8 :82–110, 1979. [BLM+ 03] Gerth S. Brodal, George Lagogiannis, Christos Makris, Athanasios Tsakalidis, and Kostas Tsichlas. Optimal finger search trees in the pointer machine. Journal of Computer and System Sciences, 67(2) :381–418, 2003. [BT80] Mark R. Brown and Robert E. Tarjan. Design and analysis of a data structure for representing sorted lists. SIAM Journal Computer, 9 :594–614, 1980. [Car04] Jean Cardinal. Structures de l’information. Presses Universitaire de Bruxelles, 3ème edition, 2004. [Col00] Richard Cole. On the dynamic finger conjecture for splay trees. SIAM Journal of Computing, 30 :44–85, 2000. [DHIP04] Erik D. Demaine, Dion Harmon, John Iacono, and Mihai Patrascu. Dynamic optimality-almost. In Proceedings of the 45th Annual IEEE Symposium on Fonudations of Computer Science, pages 484–490, 2004. [DSW05] Jonathan Derryberry, Daniel D. Sleator, and Chengwen C. Wang. A lower bound framework for binary search trees with rotations. Technical report, Carnegie Mellon University, 2005. [DSW06] Jonathan Derryberry, Daniel D. Sleator, and Chengwen C. Wang. (log log n)-competitive dynamic binary search trees. pages 374– 383, 2006. 166 [FS98] Philippe Flajolet and Robert Sedgewick. Introduction l’analyse des algorithmes. International Thomson publishing, 1998. [Iac01] John Iacono. Alternatives to splay trees with o(log n) worst-case access times. Symposium on Discrete Algorithms, pages 516–522, 2001. [Iac05] John Iacono. Key-independent optimality. Algorithmica, 42(1) :3– 10, 2005. [Knu73] Donald E. Knuth. Computing Programming, Sorting and Searching, volume 3. Addison-Wesley, 2ème edition, 1973. [Mar02] Olivier Markowitch. Université libre de bruxelles, INFO 090 : Algorithmique général 1, 2002. http ://www.markowitch.net. [Nie05] Tom Niemann, 2005. http ://epaperpress.com/sortsearch/txt/ rbt.txt. [Sah05] Sartaj Sahni. Efficient binary search trees, University of Florida, COP 5536 : Advanced Data Structures, 2005. http ://www.cise.ufl.edu/∼sahni/cop5536. [Sed04] Robert Sedgewick. Algorithmes en C++. Pearson Education, 2004. [ST85] Daniel D. Sleator and Robert E. Tarjan. Self-adjusting binary search trees. Journal of the ACM, 32 :652–686, 1985. [SW04] Daniel D. Sleator and Chengwen C. Wang. Dynamic optimaltity and multi-splay trees. Technical report, Carnegie Mellon University, 2004. [Tar85] Robert E. Tarjan. Amortized computational complexity. SIAM Journal on Algebraic and Discrete Methods, 6 :306–318, 1985. [Wei99] Mark A. Weiss. Data Structures and Algorithm Analysis in Java. Addison-Wesley, 1999. [Wil89] Robert Wilber. Lower bounds for accessing binary search trees with rotations. SIAM Journal of Computing, 18 :56–67, 1989. 167