Previous Next Contents

Chapitre 4    Arbres

Nous avons déjà vu la notion de fonction récursive dans le chapitre 2. Considérons à présent son équivalent dans les structures de données: la notion d'arbre. Un arbre est soit un arbre atomique (une feuille), soit un noeud et une suite de sous-arbres. Graphiquement, un arbre est représenté comme suit




Figure 4.1 : Un exemple d'arbre

Le noeud n1 est la racine de l'arbre, n5, n6, n7, n9, n10, n11 sont les feuilles, n1, n2, n3, n4, n8 les noeuds internes. Plus généralement, l'ensemble des noeuds est constitué des noeuds internes et des feuilles. Contrairement à la botanique, on dessine les arbres avec la racine en haut et les feuilles vers le bas en informatique. Il y a bien des définitions plus mathématiques des arbres, que nous éviterons ici. Si une branche relie un noeud ni à un noeud nj plus bas, on dira que ni est un ancêtre de nj. Une propriété fondamentale d'un arbre est qu'un noeud n'a qu'un seul père. Enfin, un noeud peut contenir une ou plusieurs valeurs, et on parlera alors d'arbres étiquetés et de la valeur (ou des valeurs) d'un noeud. Les arbres binaires sont des arbres tels que les noeuds ont au plus 2 fils. La hauteur, on dit aussi la profondeur d'un noeud est la longueur du chemin qui le joint à la racine, ainsi la racine est elle même de hauteur 0, ses fils de hauteur 1 et les autres noeuds de hauteur supérieure à 1.

Un exemple d'arbre très utilisé en informatique est la représentation des expressions arithmétiques et plus généralement des termes dans la programmation symbolique. Nous traiterons ce cas dans le chapitre sur l'analyse syntaxique, et nous nous restreindrons pour l'instant au cas des arbres de recherche ou des arbres de tri. Toutefois, pour montrer l'aspect fondamental de la structure d'arbre, on peut tout de suite voir que les expressions arithmétiques calculées dans la section 3.4 se représentent simplement par des arbres comme dans la figure 4.2 pour (* (+ 5 (* 2 3)) (+ (* 10 10) (* 9 9))). Cette représentation contient l'essence de la structure d'expression arithmétique et fait donc abstraction de toute notation préfixée ou postfixée.




Figure 4.2 : Représentation d'une expression arithmétique par un arbre

4.1  Files de priorité

Un premier exemple de structure arborescente est la structure de tas (heap1 )utilisée pour représenter des files de priorité. Donnons d'abord une vision intuitive d'une file de priorité.

On suppose, comme au paragraphe 3.2, que des gens se présentent au guichet d'une banque avec un numéro écrit sur un bout de papier représentant leur degré de priorité. Plus ce nombre est élevé, plus ils sont importants et doivent passer rapidement. Bien sûr, il n'y a qu'un seul guichet ouvert, et l'employé(e) de la banque doit traiter rapidement tous ses clients pour que tout le monde garde le sourire. La file des personnes en attente s'appelle une file de priorité. L'employé de banque doit donc savoir faire rapidement les 3 opérations suivantes: trouver un maximum dans la file de priorité, retirer cet élément de la file, savoir ajouter un nouvel élément à la file. Plusieurs solutions sont envisageables.

La première consiste à mettre la file dans un tableau et à trier la file de priorité dans l'ordre croissant des priorités. Trouver un maximum et le retirer de la file est alors simple: il suffit de prendre l'élément de droite, et de déplacer vers la gauche la borne droite de la file. Mais l'insertion consiste à faire une passe du tri par insertion pour mettre le nouvel élément à sa place, ce qui peut prendre un temps O(n) où n est la longueur de la file.

Une autre méthode consiste à gérer la file comme une simple file du chapitre précédent, et à rechercher le maximum à chaque fois. L'insertion est rapide, mais la recherche du maximum peut prendre un temps O(n), de même que la suppression.




Figure 4.3 : Représentation en arbre d'un tas

Une méthode élégante consiste à gérer une structure d'ordre partiel grâce à un arbre. La file de n éléments est représentée par un arbre binaire contenant en chaque noeud un élément de la file (comme illustré dans la figure 4.3). L'arbre vérifie deux propriétés importantes: d'une part la valeur de chaque noeud est supérieure ou égale à celle de ses fils, d'autre part l'arbre est quasi complet, propriété que nous allons décrire brièvement. Si l'on divise l'ensemble des noeuds en lignes suivant leur hauteur, on obtient en général dans un arbre binaire une ligne 0 composée simplement de la racine, puis une ligne 1 contenant au plus deux noeuds, et ainsi de suite (la ligne i contenant au plus 2i noeuds). Dans un arbre quasi complet les lignes, exceptée peut être la dernière, contiennent toutes un nombre maximal de noeuds (soit 2i). De plus les feuilles de la dernière ligne se trouvent toutes à gauche, ainsi tous les noeuds internes sont binaires, excepté le plus à droite de l'avant dernière ligne qui peut ne pas avoir de fils droit. Les feuilles sont toutes sur la dernière et éventuellement l'avant dernière ligne.

On peut numéroter cet arbre en largeur d'abord, c'est à dire dans l'ordre donné par les petits numéros figurant au dessus de la figure 4.3. Dans cette numérotation on vérifie que tout noeud i a son père en position ë i/2 û, le fils gauche du noeud i est 2i, le fils droit 2i + 1. Formellement, on peut dire qu'un tas est un tableau a contenant n entiers (ou des éléments d'un ensemble totalement ordonné) satisfaisant les conditions:

2 £ 2i £ n     Þ     a[2i] ³ a[i]
3£ 2i+1 £ n     Þ     a[2i+1] ³ a[i]

Ceci permet d'implémenter cet arbre dans un tableau a (voir figure 4.4) où le numéro de chaque noeud donne l'indice de l'élément du tableau contenant sa valeur.




Figure 4.4 : Représentation en tableau d'un tas

L'ajout d'un nouvel élément v à la file consiste à incrémenter n puis à poser a[n] = v. Ceci ne représente plus un tas car la relation a[n/2] ³ v n'est pas nécessairement satisfaite. Pour obtenir un tas, il faut échanger la valeur contenue au noeud n et celle contenue par son père, remonter au père et réitérer jusqu'à ce que la condition des tas soit vérifiée. Ceci se programme par une simple itération (cf. la figure 4.5).

static void ajouter (int v)  {

  ++nTas;
  int i = nTas - 1;
  while (i > 0 && a [(i - 1)/2] <= v) {
      a[i] = a[(i - 1)/2]; 
      i = (i - 1)/2;
  }
  a[i] = v;
}



Figure 4.5 : Ajout dans un tas




Figure 4.6 : Suppression dans un tas

On vérifie que, si le tas a n éléments, le nombre d'opérations n'excédera pas la hauteur de l'arbre correspondant. Or la hauteur d'un arbre binaire complet de n noeuds est log n. Donc Ajouter ne prend pas plus de O(log n) opérations.

On peut remarquer que l'opération de recherche du maximum est maintenant immédiate dans les tas. Elle prend un temps constant O(1).

static int maximum ()  {

  return a[0];
}
Considérons l'opération de suppression du premier élément de la file. Il faut alors retirer la racine de l'arbre représentant la file, ce qui donne deux arbres! Le plus simple pour reformer un seul arbre est d'appliquer l'algorithme suivant: on met l'élément le plus à droite de la dernière ligne à la place de la racine, on compare sa valeur avec celle de ses fils, on échange cette valeur avec celle du vainqueur de ce tournoi, et on réitère cette opération jusqu'à ce que la condition des tas soit vérifiée. Bien sûr, il faut faire attention, quand un noeud n'a qu'un fils, et ne faire alors qu'un petit tournoi à deux. Le placement de la racine en bonne position est illustré dans la figure 4.6.

static void supprimer () {

  int i, j;
  int v;

  a[0] = a[nTas - 1];
  --nTas;
  i = 0; v = a[0];
  while (2*i + 1 < nTas) {
      j = 2*i + 1;
      if (j + 1 < nTas) 
          if (a[j + 1] > a[j]) 
              ++j;
      if (v >= a[j]) 
          break;
      a[i] = a[j]; i = j;
  }
  a[i] = v;
}
A nouveau, la suppression du premier élément de la file ne prend pas un temps supérieur à la hauteur de l'arbre représentant la file. Donc, pour une file de n éléments, la suppression prend O(log n) opérations. La représentation des files de priorités par des tas permet donc de faire les trois opérations demandées: ajout, retrait, chercher le plus grand en log n opérations. Ces opérations sur les tas permettent de faire le tri HeapSort. Ce tri peut être considéré comme alambiqué, mais il a la bonne propriété d'être toujours en temps n log n (comme le Tri fusion, cf page X).

HeapSort se divise en deux phases, la première consiste à construire un tas dans le tableau à trier, la seconde à répéter l'opération de prendre l'élément maximal, le retirer du tas en le mettant à droite du tableau. Il reste à comprendre comment on peut construire un tas à partir d' un tableau quelconque. Il y a une méthode peu efficace, mais systématique. On remarque d'abord que l'élément de gauche du tableau est à lui seul un tas. Puis on ajoute à ce tas le deuxième élément avec la procédure Ajouter que nous venons de voir, puis le troisième, .... A la fin, on obtient bien un tas de N éléments dans le tableau a à trier. Le programme est

static void heapSort ()  {

  int      i;
  int      v;

  nTas = 0;
  for (i = 0; i < N; ++i)
      ajouter (a[i]);
  for (i = N - 1; i >= 0; --i) { 
      v = maximum();
      supprimer();
      a[i] = v;
   }
}
Si on fait un décompte grossier des opérations, on remarque qu'on ne fait pas plus de N log N opérations pour construire le tas, puisqu'il y a N appels à la procédure Ajouter. Une méthode plus efficace, que nous ne décrirons pas ici, qui peut être traitée à titre d'exercice, permet de construire le tas en O(N) opérations. De même, dans la deuxième phase, on ne fait pas plus de N log N opérations pour défaire les tas, puisqu'on appelle N fois la procédure Supprimer. Au total, on fait O(N log N) opérations quelle que soit la distribution initiale du tableau a, comme dans le tri fusion. On peut néanmoins remarquer que la constante qui se trouve devant N log N est grande, car on appelle des procédures relativement complexes pour faire et défaire les tas. Ce tri a donc un intérêt théorique, mais il est en pratique bien moins bon que Quicksort ou le tri Shell.

4.2  Borne inférieure sur le tri

Il a été beaucoup question du tri. On peut se demander s'il est possible de trier un tableau de N éléments en moins de N log N opérations. Un résultat ancien de la théorie de l'information montre que c'est impossible si on n'utilise que des comparaisons.

En effet, il faut préciser le modèle de calcul que l'on considère. On peut représenter tous les tris que nous avons rencontrés par des arbres de décision. La figure 4.7 représente un tel arbre pour le tri par insertion sur un tableau de 3 éléments. Chaque noeud interne pose une question sur la comparaison entre 2 éléments. Le fils de gauche correspond à la réponse négative, le fils droit à l'affirmatif. Les feuilles représentent la permutation à effectuer pour obtenir le tableau trié.


Théorème 1   Le tri de N éléments, fondé uniquement sur les comparaisons des éléments deux à deux, fait au moins O(N log N) comparaisons.


Démonstration
Tout arbre de décision pour trier N éléments a N! feuilles représentant toutes les permutations possibles. Un arbre binaire de N! feuilles a une hauteur de l'ordre de log N! » N log N par la formule de Stirling.



Corollaire 1  HeapSort et le tri fusion sont optimaux (asymptotiquement).


En effet, ils accomplissent le nombre de comparaisons donné comme borne inférieure dans le théorème précédent. Mais, nous répétons qu'un tri comme Quicksort est aussi très bon en moyenne. Le modèle de calcul par comparaisons donne une borne inférieure, mais peut-on faire mieux dans un autre modèle? La réponse est oui, si on dispose d'informations annexes comme les valeurs possibles des éléments ai à trier. Par exemple, si les valeurs sont comprises dans l'intervalle [1, k], on peut alors prendre un tableau b annexe de k éléments qui contiendra en bj le nombre de ai ayant la valeur j. En une passe sur a, on peut remplir le tableau k, puis générer le tableau a trié en une deuxième passe en ne tenant compte que de l'information rangée dans b. Ce tri prend O(k + 2 N) opérations, ce qui est très bon si k est petit.




Figure 4.7 : Exemple d'arbre de décision pour le tri

4.3  Implémentation d'un arbre

Jusqu'à présent, les arbres sont apparus comme des entités abstraites ou n'ont été implémentés que par des tableaux en utilisant une propriété bien particulière des arbres complets. On peut bien sûr manipuler les arbres comme les listes avec des enregistrements et des pointeurs. Tout noeud sera représenté par un enregistrement contenant une valeur et des pointeurs vers ses fils. Une feuille ne contient qu'une valeur. On peut donc utiliser des enregistrements avec variante pour signaler si le noeud est interne ou une feuille. Pour les arbres binaires, les deux fils seront représentés par les champs filsG, filsD et il sera plus simple de supposer qu'une feuille est un noeud dont les fils gauche et droit ont une valeur vide.

class Arbre {

  int      contenu;
  Arbre    filsG;
  Arbre    filsD;

  Arbre (int v, Arbre a, Arbre b) {
      contenu = v;
      filsG = a;
      filsD = b;
  }
}
Pour les arbres quelconques, on peut gagner plus d'espace mémoire en considérant des enregistrements variables. Toutefois, en Pascal, il y a une difficulté de typage à considérer des noeuds n-aires (ayant n fils). On doit considérer des types différents pour les noeuds binaires, ternaires, ... ou un gigantesque enregistrement avec variante. Deux solutions systématiques sont aussi possibles: la première consiste à considérer le cas n maximum (comme pour les arbres binaires)

class Arbre {

  int      contenu;
  Arbre[]  fils;

  Arbre (int v, int n) {
      contenu = v;
      fils = new Arbre[n];
  }
}
la deuxième consiste à enchaîner les fils dans une liste

class Arbre {

  int           contenu;
  ListeArbres   fils;
}

class ListeArbres {

  Arbre         contenu;
  ListeArbres   suivant;
}
Avec les tailles mémoire des ordinateurs actuels, on se contente souvent de la première solution. Mais, si les contraintes de mémoire sont fortes, il faut se rabattre sur la deuxième. Dans une bonne partie de la suite, il ne sera question que d'arbres binaires, et nous choisirons donc la première représentation avec les champs filsG et filsD.

Considérons à présent la construction d'un nouvel arbre binaire c à partir de deux arbres a et b. Un noeud sera ajouté à la racine de l'arbre et les arbres a et b seront les fils gauche et droit respectivement de cette racine. La fonction correspondante prend la valeur du nouveau noeud, les fils gauche et droit du nouveau noeud. Le résultat sera un pointeur vers ce noeud nouveau. Voici donc comment créer l'arbre de gauche de la figure 4.8.

class Arbre {

  int      contenu;
  Arbre    filsG;
  Arbre    filsD;

  Arbre (int v, Arbre a, Arbre b) {
      contenu = v;
      filsG = a;
      filsD = b;
  }

  public static void main(String args[]) {

    Arbre a5, a7;
    a5 = new Arbre (12, new Arbre (8, new Arbre (6, null, null), null),
                        new Arbre (13, null, null));
    a7 = new Arbre (20, new Arbre (3, new Arbre (3, null, null), a5),
                        new Arbre (25, new Arbre (21, null, null),
                                       new Arbre (28, null, null)));
    imprimer (a7);
}
Une fois un arbre créé, il est souhaitable de pouvoir l'imprimer. Plusieurs méthodes sont possibles. La plus élégante utilise les fonctions graphiques du Macintosh drawString, moveTo, lineTo. Une autre consiste à utiliser une notation linéaire avec des parenthèses. C'est la notation infixe utilisée couramment si les noeuds internes sont des opérateurs d'expressions arithmétique. L'arbre précédent s'écrit alors

((3 3 ((6 8 nil) 12 13)) 20 (21 25 28))
Utilisons une méthode plus rustique en imprimant en alphanumérique sur plusieurs lignes. Ainsi, en penchant un peu la tête vers la gauche, on peut imprimer l'arbre précédent comme suit

20    25    28
      21
 3    12    13
       8
       6
 3
La procédure d'impression prend comme argument l'arbre à imprimer et la tabulation à faire avant l'impression, c'est à dire le nombre d'espaces. On remarquera que toute la difficulté de la procédure est de bien situer l'endroit où on effectue un retour à la ligne. Le reste est un simple parcours récursif de l'arbre en se plongeant d'abord dans l'arbre de droite.

static void imprimer (Arbre a) {
  imprimer (a, 0);
  System.out.println ();
}

static void imprimer1 (Arbre a, int tab) {

  if (a != null) {
    System.out.print (leftAligned (3, a.contenu + "") + "     "); 
      imprimer1 (a.filsD, tab + 8);
      if (a.filsG != null) {
          System.out.println ();
          for (int i = 1; i <= tab; ++i)
              System.out.print (" ");
      }
      imprimer1 (a.filsG, tab);
  }
}

static String leftAligned (int size, String s) {

  StringBuffer t = new StringBuffer (s);
  for (int i = s.length(); i < size; ++i)
      t = t.append(" ");
  return new String (t);
}
Nous avons donc vu comment représenter un arbre dans un programme, comment le construire, et comment l'imprimer. Cette dernière opération est typique: pour explorer une structure de donnée récursive (les arbres), il est naturel d'utiliser des procédures récursives. C'est à nouveau une manière non seulement naturelle, mais aussi très efficace dans le cas présent.

Comme pour les listes (cf. page X), la structure récursive des programmes manipulant des arbres découle de la définition des arbres, puisque le type des arbres binaires vérifie l'équation:

Arbre = {Arbre_vide}  È   Arbre × Element × Arbre
Comme pour l'impression, on peut calculer le nombre de noeuds d'un arbre en suivant la définition récursive du type des arbres:

static int taille (Arbre a) {

  if (a == null)    (* a = Arbre_vide *)
      return 0;
  else              (* a Î Arbre × Element × Arbre *)
      return 1 + taille (a.filsG) + taille (a.filsD);
}
L'écriture itérative dans le cas des arbres est en général impossible sans utiliser une pile. On vérifie que, pour les arbres binaires qui ne contiennent pas de noeuds unaires, la taille t, le nombre de feuilles Nf et le nombre de noeuds internes Nn vérifient t = Nn + Nf et Nn = 1 + Nf.

4.4  Arbres de recherche

La recherche en table et le tri peuvent être aussi traités avec des arbres. Nous l'avons vu implicitement dans le cas de Quicksort. En effet, si on dessine les partitions successives obtenues par les appels récursifs de Quicksort, on obtient un arbre. On introduit pour les algorithmes de recherche d'un élément dans un ensemble ordonné la notion d'arbre binaire de recherche celui-ci aura la propriété fondamentale suivante: tous les noeuds du sous-arbre gauche d'un noeud ont une valeur inférieure (ou égale) à la sienne et tous les noeuds du sous-arbre droit ont une valeur supérieure (ou égale) à la valeur du noeud lui-même (comme dans la figure 4.8). Pour la recherche en table, les arbres de recherche ont un intérêt quand la table évolue très rapidement, quoique les méthodes avec hachage sont souvent aussi bonnes, mais peuvent exiger des contraintes de mémoire impossibles à satisfaire. (En effet, il faut connaître la taille maximale a priori d'une table de hachage). Nous allons voir que le temps d'insertion d'un nouvel élément dans un arbre de recherche prend un temps comparable au temps de recherche si cet arbre est bien agencé. Pour le moment, écrivons les procédures élémentaires de recherche et d'ajout d'un élément.

static Arbre recherche (int v, Arbre a) {

  if (a == null || v == a.contenu)
       return a;
  else if (v < a.contenu)
       return recherche (v, a.filsG);
  else
       return recherche (v, a.filsD);
}

static Arbre ajouter (int v, Arbre a) {

  if (a == null)
      a = new Arbre (v, null, null);
  else if (v <= a.contenu)
      a.filsG = ajouter (v, a.filsG);
  else
      a.filsD = ajouter (v, a.filsD);
  return a;
}



Figure 4.8 : Ajout dans un arbre de recherche

A nouveau, des programmes récursifs correspondent à la structure récursive des arbres. La procédure de recherche renvoie un pointeur vers le noeud contenant la valeur recherchée, null si échec. Il n'y a pas ici d'information associée à la clé recherchée comme au chapitre 1. On peut bien sûr associer une information à la clé recherchée en ajoutant un champ dans l'enregistrement décrivant chaque noeud. Dans le cas du bottin de téléphone, le champ contenu contiendrait les noms et serait du type String; l'information serait le numéro de téléphone.

La recherche teste d'abord si le contenu de la racine est égal à la valeur recherchée, sinon on recommence récursivement la recherche dans l'arbre de gauche si la valeur est plus petite que le contenu de la racine, ou dans l'arbre de droite dans le cas contraire. La procédure d'insertion d'une nouvelle valeur suit le même schéma. Toutefois dans le cas de l'égalité des valeurs, nous la rangeons ici par convention dans le sous arbre de gauche. La procédure ajouter modifie l'arbre de recherche. Si nous ne voulons pas le modifier, nous pouvons adopter comme dans le cas des listes la procédure suivante, qui consomme légèrement plus de place, laquelle place peut être récupérée très rapidement par le glaneur de cellules:

static Arbre ajouter (int v, Arbre a) {

  if (a == null)
      return new Arbre (v, null, null);
  else if (v <= a.contenu)
      return new Arbre (v, ajouter (v, a.filsG), a.filsD);
  else
      return new Arbre (v, a.filsG, ajouter (v, a.filsG));
}
Le nombre d'opérations de la recherche ou de l'insertion dépend de la hauteur de l'arbre. Si l'arbre est bien équilibré, pour un arbre de recherche contenant N noeuds, on effectuera O(log N) opérations pour chacune des procédures. Si l'arbre est un peigne, c'est à dire complètement filiforme à gauche ou à droite, la hauteur vaudra N et le nombre d'opérations sera O(N) pour la recherche et l'ajout. Il apparaît donc souhaitable d'équilibrer les arbres au fur et à mesure de l'ajout de nouveaux éléments, ce que nous allons voir dans la section suivante.

Enfin, l'ordre dans lequel sont rangés les noeuds dans un arbre de recherche est appelé ordre infixe. Il correspond au petit numéro qui se trouve au dessus de chaque noeud dans la figure 4.8. Nous avons déjà vu dans le cas de l'évaluation des expressions arithmétiques (cf page X) l'ordre préfixe, dans lequel tout noeud reçoit un numéro d'ordre inférieur à celui de tous les noeuds de son sous-arbre de gauche, qui eux-mêmes ont des numéros inférieurs aux noeuds du sous-arbre de droite. Finalement, on peut considérer l'ordre postfixe qui ordonne d'abord le sous-arbre de gauche, puis le sous-arbre de droite, et enfin le noeud. C'est un bon exercice d'écrire un programme d'impression correspondant à chacun de ces ordres, et de comparer l'emplacement des différents appels récursifs.

4.5  Arbres équilibrés

La notion d'arbre équilibré a été introduite en 1962 par deux russes Adel'son-Vel'skii et Landis, et depuis ces arbres sont connus sous le nom d'arbres AVL. Il y a maintenant beaucoup de variantes plus ou moins faciles à manipuler. Au risque de paraître classiques et vieillots, nous parlerons principalement des arbres AVL. Un arbre AVL vérifie la propriété fondamentale suivante: la différence entre les hauteurs des fils gauche et des fils droit de tout noeud ne peut excéder 1. Ainsi l'arbre de gauche de la figure 4.8 n'est pas équilibré. Il viole la propriété aux noeuds numérotés 2 et 7, tous les autres noeuds validant la propriété. Les arbres représentant des tas, voir figure 4.5 sont trivialement équilibrés.

On peut montrer que la hauteur d'un arbre AVL de N noeuds est de l'ordre de log N, ainsi les temps mis par la procédure Recherche vue page X seront en O(log N).

Il faut donc maintenir l'équilibre de tous les noeuds au fur et à mesure des opérations d'insertion ou de suppression d'un noeud dans un arbre AVL. Pour y arriver, on suppose que tout noeud contient un champ annexe bal contenant la différence de hauteur entre le fils droit et le fils gauche. Ce champ représente donc la balance ou l'équilibre entre les hauteurs des fils du noeud, et on s'arrange donc pour maintenir -1 £ a.bal £ 1 pour tout noeud pointé par a.

L'insertion se fait comme dans un arbre de recherche standard, sauf qu'il faut maintenir l'équilibre. Pour cela, il est commode que la fonction d'insertion retourne une valeur représentant la différence entre la nouvelle hauteur (après l'insertion) et l'ancienne hauteur (avant l'insertion). Quand il peut y avoir un déséquilibre trop important entre les deux fils du noeud où l'on insère un nouvel élément, il faut recréer un équilibre par une rotation simple (figure 4.9) ou une rotation double (figure 4.10). Dans ces figures, les rotations sont prises dans le cas d'un rééquilibrage de la gauche vers la droite. Il existe bien sûr les 2 rotations symétriques de la droite vers la gauche. On peut aussi remarquer que la double rotation peut se réaliser par une suite de deux rotations simples. Dans la figure 4.10, il suffit de faire une rotation simple de la droite vers la gauche du noeud A, suivie d'une rotation simple vers la droite du noeud B. Ainsi en supposant déjà écrites les procédures de rotation rotD vers la droite et rotG vers la gauche, la procédure d'insertion s'écrit




Figure 4.9 : Rotation dans un arbre AVL

static Arbre ajouter (int v, Arbre a) {
  return ajouter1 (v, a).p2;
}

static Paire ajouter1 (int v, Arbre a) {

  int        incr, r;
  Paire      p;

  r = 0;
  if (a == null) {
      a = new Arbre (v, null, null);
      a.bal = 0;
      r = 1;
  } else {
     if (v <= a.contenu) {
          p = ajouter1 (v, a.filsG);
          incr = -p.p1;
          a.filsG = p.p2;
     } else {
          p = ajouter1 (v, a.filsD);
          incr = p.p1;
          a.filsD = p.p2;
    }
      a.bal = a.bal + incr;
      if (incr != 0 && a.bal != 0) 
          if (a.bal < -1)
              /* La gauche est trop grande */
              if (a.filsG.bal < 0)
                  a = rotD (a);
              else {
                  a.filsG = rotG (a.filsG); 
                  a = rotD (a);
              }
          else
          if (a.bal > 1)
              /* La droite est trop grande */
              if (a.filsD.bal > 0)
                  a = rotG (a);
              else {
                  a.filsD = rotD (a.filsD); 
                  a = rotG (a);
              }
          else
             r = 1;
  }
  return new Paire (r, a);
}

static class Paire {
  int      p1;
  Arbre    p2;

  Paire (int r, Arbre a) {
     p1 = r;
     p2 = a;
  }
}



Figure 4.10 : Double rotation dans un arbre AVL

Clairement cette procédure prend un temps O(log N). On vérifie aisément qu'au plus une seule rotation (éventuellement double) est nécessaire lors de l'insertion d'un nouvel élément. Il reste à réaliser les procédures de rotation. Nous ne considérons que le cas de la rotation vers la droite, l'autre cas s'obtenant par symétrie.

static Arbre rotD (Arbre a) {

  Arbre  b;
  int    bA, bB, bAnew, bBnew;

  b = a;
  a = a.filsG;
  bA = a.bal; bB = b.bal;
  b.filsG = a.filsD;
  a.filsD = b;
  // Recalculer le champ a.bal
  bBnew = 1 + bB - Math.min(0, bA);
  bAnew = 1 + bA + Math.max(0, bBnew);
  a.bal = bAnew;
  b.bal = bBnew;
  return a;
}
Il y a un petit calcul savant pour retrouver le champ représentant l'équilibre après rotation. Il pourrait être simplifié si nous conservions toute la hauteur du noeud dans un champ. La présentation avec les champs bal permet de garder les valeurs possibles entre -2 et 2, de tenir donc sur 3 bits, et d'avoir le reste d'un mot machine pour le champ contenu. Avec la taille des mémoires actuelles, ce calcul peut se révéler surperflu. Toutefois, soient h(a), h(b) et h(c) les hauteurs des arbres a, b et c de la figure 4.9. En appliquant la définition du champ bal, les nouvelles valeurs b'(A) et b'(B) de ces champs aux noeuds A et B se calculent en fonction des anciennes valeurs b(A) et b(B) par

b'(B)=h(c) - h(b)
 =h(c) - 1 - é h(a), h(b) ù + 1 + é h(a), h(b) ù - h(b)
 =b(B) +1 + é h(a) - h(b), 0 ù
 =1 + b(B) - ë 0, b(A) û
   
b'(A)=1 + é h(b), h(c) ù - h(a)
 =1 + h(b) - h(a) + é 0, h(c) - h(b) ù
 =1 + b(A) + é 0, b'(B) ù

Les formules pour la rotation vers la gauche s'obtiennent par symétrie. On peut même remarquer que le champ bal peut tenir sur 1 bit pour signaler si le sous-arbre a une hauteur égale ou non à celle de son sous-arbre ``frère''. La suppression d'un élément dans un arbre AVL est plus dure à programmer, et nous la laissons en exercice. Elle peut demander jusqu'à O(log N) rotations.





Les arbres AVL sont délicats à programmer à cause des opérations de rotation. On peut montrer que les rotations deviennent inutiles si on donne un peu de flexibilité dans le nombre de fils des noeuds. Il existe des arbres de recherche 2-3 avec 2 ou 3 fils. L'exemple le plus simple est celui des arbres 2-3-4 amplement décrits dans le livre de Sedgewick [47]. Un exemple d'arbre 2-3-4 est décrit dans la figure 4.11. La propriété fondamentale d'un tel arbre de recherche est la même que pour les noeuds binaires: tout noeud doit avoir une valeur supérieure ou égale à celles contenues dans ses sous-arbres gauches, et une valeur inférieure (ou égale) à celles de ses sous-arbres droits. Les noeuds ternaires contiennent 2 valeurs, la première doit être comprise entre les valeurs des sous-arbres gauches et du centre, la deuxième entre celles des sous-arbres du centre et de droite. On peut deviner aisément la condition pour les noeuds à 4 fils.

L'insertion d'un nouvel élément dans un arbre 2-3-4 se fait en éclatant tout noeud quaternaire que l'on rencontre comme décrit dans la figure 4.12. Ces opérations sont locales et ne font intervenir que le nombre de fils des noeuds. Ainsi, on garantit que l'endroit où on insère la nouvelle valeur n'est pas un noeud quaternaire, et il suffit de mettre la valeur dans ce noeud à l'endroit désiré. (Si la racine est quaternaire, on l'éclate en 3 noeuds binaires). Le nombre d'éclatements maximum peut être log N pour un arbre de N noeuds. Il a été mesuré qu'en moyenne très peu d'éclatements sont nécessaires.

Les arbres 2-3-4 peuvent être programmés en utilisant des arbres binaires bicolores. On s'arrange pour que chaque branche puisse avoir la couleur rouge ou noire (en trait gras sur notre figure 4.13). Il suffit d'un indicateur booléen dans chaque noeud pour dire si la branche le reliant à son père est rouge ou noire. Les noeuds quaternaires sont représentés par 3 noeuds reliés en noir. Les noeuds ternaires ont une double représentation possible comme décrit sur la figure 4.13. Les opérations d'éclatement se programment alors facilement, et c'est un bon exercice d'écrire la procédure d'insertion dans un arbre bicolore.




Figure 4.11 : Exemple d'arbre 2-3-4




Figure 4.12 : Eclatement d'arbres 2-3-4




Figure 4.13 : Arbres bicolores

4.6  Programmes en Caml

(* Ajouter à un tas, avec une structure enregistrement *)
type 'a tas =
  { mutable cardinal: int;
    tas: 'a vect };;

let ajouter v t =
  t.cardinal <- t.cardinal + 1;
  let a = t.tas in
  let nTas = t.cardinal in
  let i = ref (nTas - 1) in
  if !i >= vect_length a
  then failwith "tas plein" else
  while !i > 0 && a.((!i - 1) / 2) <= v do
    a.(!i) <- a.((!i - 1) / 2);
    i := (!i - 1) / 2
  done;
  a.(!i) <- v;;

(* Ajouter à un tas, voir page X *)
let nTas = ref 0;;

let ajouter v a =
  incr nTas;
  let i = ref (!nTas - 1) in
  while !i > 0 && a.((!i - 1) / 2) <= v do
    a.(!i) <- a.((!i - 1) / 2);
    i := (!i - 1) / 2
  done;
  a.(!i) <- v;;
 
(* Maximum d'un tas, voir page X *)
let maximum t = t.tas.(0);;

(* Supprimer dans un tas, 
 voir page X *)
let supprimer t =
  t.cardinal <- t.cardinal - 1;
  let a = t.tas in
  let nTas = t.cardinal in
  a.(0) <- a.(nTas);
  let i = ref 0
  and v = a.(0)
  and j = ref 0 in
  begin
    try
      while 2 * !i + 1 < nTas do
        j := 2 * !i + 1;
        if !j + 1 < nTas && a.(!j + 1) > a.(!j)
        then j := !j + 1;
        if v >= a.(!j) then raise Exit;
        a.(!i) <- a.(!j);
        i := !j
      done
    with Exit -> ()
  end;
  a.(!i) <- v;;

(* HeapSort, voir page X *)
let heapsort a =
  let n = vect_length a - 1 in
  let t = {cardinal = 0; tas = a} in
  for i = 0 to n do ajouter a.(i) t done;
  for i = n downto 0 do
    let v = maximum t in
    supprimer t;
    a.(i) <- v
  done;;

(* Déclaration d'un arbre binaire, voir page X *)
type 'a arbre = Vide | Noeud of 'a noeud
and 'a noeud =
  {contenu: 'a; filsG: 'a arbre; filsD: 'a arbre};;

(* Déclaration d'un arbre n-aire, voir page X *)

type 'a arbre = Vide | Noeud of 'a noeud
and 'a noeud = {contenu: 'a; fils: 'a arbre vect};;

(* Cas n-aire, les fils sont
 implémentés par une liste d'arbres. *)

type 'a arbre = Vide | Noeud of 'a noeud
and 'a noeud = {contenu: 'a; fils: 'a arbre list};;

(* Ajouter dans un arbre *)
let nouvel_arbre v a b =
  Noeud {contenu = v; filsG = a; filsD = b};;

let main () =
  let a5 =
    nouvel_arbre 12
      (nouvel_arbre 8 (nouvel_arbre 6 Vide Vide) Vide)
      (nouvel_arbre 13 Vide Vide) in
  nouvel_arbre 20
    (nouvel_arbre 3 (nouvel_arbre 3 Vide Vide) a5)
    (nouvel_arbre 25
      (nouvel_arbre 21 Vide Vide)
      (nouvel_arbre 28 Vide Vide));;

(* Impression d'un arbre, voir page X *)
#open "printf";;

let imprimer_arbre a =
  let rec imprimer1 a tab =
  match a with
    Vide -> ()
  | Noeud {contenu = c; filsG = fg; filsD = fd} ->
     printf "%3d     " c; imprimer1 fd (tab + 8);
     if fg <> Vide then
      printf "\n%s" (make_string tab ` `);
     imprimer1 fg tab;;
  in
    imprimer1 a 0; print_newline();;

(* Taille d'un arbre, voir page X *)
let rec taille = function
    Vide -> 0
  | Noeud {filsG = fg; filsD = fd; _} ->
     1 + taille fg + taille fd;;

(* Arbre de recherche, 
 voir page X *)
let rec recherche v a =
  match a with
    Vide -> Vide
  | Noeud
     {contenu = c; filsG = fg; filsD = fd} ->
     if c = v then a else
     if c < v then recherche v fg
     else recherche v fd;;

(* Ajouter, purement fonctionnel, 
 voir page X *)
let rec ajouter v a =
  match a with
    Vide -> nouvel_arbre v Vide Vide
  | Noeud
     {contenu = c; filsG = fg; filsD = fd} ->
     if v <= c
     then nouvel_arbre c (ajouter v fg) fd
     else nouvel_arbre c (ajouter v fd) fg;;

(* Ajouter avec effet de bord, 
   voir page  \pageref{prog:recherche-arb-ajouter-fonctionnel}} *)
type 'a arbre = Vide | Noeud of 'a noeud
and 'a noeud =
  {mutable contenu: 'a;
   mutable filsG: 'a arbre;
   mutable filsD: 'a arbre};;

let nouvel_arbre v a b =
 Noeud {contenu = v; filsG = a; filsD = b};;

let rec ajouter v a =
  match a with
    Vide -> nouvel_arbre v Vide Vide
  | Noeud
     ({contenu = c; filsG = fg; filsD = fd}
       as n) ->
     if v <= c
     then n.filsG <- ajouter v fg
     else n.filsD <- ajouter v fd;
     a;;

(* Ajouter, purement fonctionnel avec un autre type d'arbres *)
type 'a arbre = Vide | Noeud of 'a arbre * 'a * 'a arbre;;

let nouvel_arbre v a b =
  Noeud (a, v, b);;

let rec ajouter v a =
  match a with
    Vide -> nouvel_arbre v Vide Vide
  | Noeud (fg, c, fd) ->
     if v <= c
     then nouvel_arbre c (ajouter v fg) fd
     else nouvel_arbre c (ajouter v fd) fg;;

(* Arbres AVL, voir page X *)

type 'a avl = Vide | Noeud of 'a noeud

and 'a noeud =
  { mutable balance: int;
    mutable contenu: 'a;
    mutable filsG: 'a avl;
    mutable filsD: 'a avl };;

let nouvel_arbre bal v a b =
  Noeud
   {balance = bal; contenu = v;
    filsG = a; filsD = b};;

#open "format";;

let rec print_avl = function
   Vide -> ()
 | Noeud
    {balance = bal; contenu = v;
     filsG = a; filsD = b} ->
    open_box 1; print_string "(";
    print_int bal; print_string ": ";
    print_int v; print_space();
    print_avl a; print_space(); print_avl b;
    print_cut(); print_string ")";
    close_box();;
install_printer "print_avl";;

(* Rotation dans un AVL, voir page X *)
let rotD a = 
  match a with
    Vide -> failwith "rotD"
  | Noeud
     ({balance = bB; contenu = v;
       filsG = A; filsD = c} as nB) as B ->
     match A with
       Vide -> failwith "rotD"
     | Noeud
        ({balance = bA; contenu = v;
          filsG = a; filsD = b} as nA) ->
         nA.filsD <- B;
         nB.filsG <- b;
         let bBnew = bB + 1 - min 0 bA in
         let bAnew = bA + 1 + max 0 bBnew in
         nA.balance <- bAnew;
         nB.balance <- bBnew;
         A;;

(* Rotation dans un AVL, voir page X *)
let rotG a = 
  match a with
    Vide -> failwith "rotG"
  | Noeud
     ({balance = bA; contenu = v;
       filsG = c; filsD = B} as nA) as A ->
     match B with
     | Vide -> failwith "rotG"
     | Noeud
        ({balance = bB; contenu = v;
          filsG = a; filsD = b} as nB) ->
         nA.filsD <- a;
         nB.filsG <- A;
         let bAnew = bA - 1 - max 0 bB in
         let bBnew = bB - 1 + min 0 bAnew in
         nA.balance <- bAnew;
         nB.balance <- bBnew;
         B;;

(* Ajout dans un AVL, voir page X *)

let rec ajouter v a =
 match a with
   Vide -> (nouvel_arbre 0 v Vide Vide, 1)
 | Noeud
    ({balance = bal; contenu = c;
      filsG = fg; filsD = fd} as noeud) ->
    let diff =
      if v <= c then begin
        let (a, incr) = ajouter v fg 
        in
        noeud.balance <- noeud.balance - incr;
        noeud.filsG <- a;
        incr
      end else begin
         let (a, incr) = ajouter v fd 
         in
         noeud.balance <- noeud.balance + incr;
         noeud.filsD <- a;
         incr
       end in
    if diff <> 0 && noeud.balance <> 0 then
      if noeud.balance < -1 then begin
        match fg with
          Vide -> failwith "Vide"
        | Noeud {balance = b; _} ->       
           if b < 0 then (rotD a, 0)
           else begin
             noeud.filsG <- rotG fg; (rotD a, 0)
           end
      end else
      if noeud.balance > 1 then begin 
        match fd with
          Vide -> failwith "Vide"
        | Noeud {balance = b; _} ->       
          if b > 0 then (rotG a, 0)
          else begin
            noeud.filsD <- rotD fd; (rotG a, 0)
          end
      end
      else (a, 1)
    else (a, 0);;

1
Le mot heap a malheureusement un autre sens en Pascal: c'est l'espace dans lequel sont allouées les variables dynamiques référencées par un pointeur après l'instruction new. Il sera bien clair d'après le contexte si nous parlons de tas au sens des files de priorité ou du tas de Pascal pour allouer les variables dynamiques.

Previous Next Contents