Séance 4 — Arbres et tas

Chercher en \(\mathcal{O}(\log n)\) et toujours extraire le minimum

Dr. El Hadji Bassirou TOURÉ

Département de Mathématiques et Informatique, Faculté des Sciences et Techniques, Université Cheikh Anta Diop de Dakar

Objectifs de la séance

Ce que la séance construit

Des structures qui gardent l’ordre des clés : l’arbre binaire de recherche (ABR), l’arbre AVL qui garantit une hauteur en \(\mathcal{O}(\log n)\), et le tas binaire qui donne toujours le minimum. Chaque opération est montrée sur le code Java du cours, pas à pas.

  • Dérouler la recherche, l’insertion et les trois cas de la suppression dans un ABR, en comptant les comparaisons.
  • Produire les parcours infixe, préfixe et postfixe, et expliquer pourquoi l’infixe sort les clés triées.
  • Relier le coût des opérations à la hauteur et reconnaître l’ordre d’insertion qui fait dégénérer l’arbre.
  • Calculer les facteurs d’équilibre d’un AVL, nommer le cas (LL, RR, LR, RL) et appliquer les rotations.
  • Ranger un tas dans un tableau (parent \(\lfloor(i-1)/2\rfloor\), enfants \(2i+1\) et \(2i+2\)) et dérouler les percolations.
  • Trier par tas et confronter les coûts mesurés aux formules du cours.

Ce que le hachage ne sait pas faire

Le carnet de contacts de la Séance 3, rangé une fois par hachage, une fois dans un arbre (TreeSet, de la bibliothèque standard) :

List<String> noms = List.of("Moussa Ndiaye", "Awa Diop", "Fatou Sall",
    "Cheikh Ba", "Coumba Faye", "Ibrahima Sy");
Set<String> h = new HashSet<>(noms);
TreeSet<String> t = new TreeSet<>(noms);
System.out.println(h);                       // ordre des cases
System.out.println(t);                       // ordre des cles
System.out.println(t.higher("Fatou Sall"));  // la premiere apres
System.out.println(t.subSet("A", "D"));      // les cles de [A, D[

Résultat \(\rightarrow\)

[Awa Diop, Cheikh Ba, Coumba Faye, Ibrahima Sy, Moussa Ndiaye, Fatou Sall]
[Awa Diop, Cheikh Ba, Coumba Faye, Fatou Sall, Ibrahima Sy, Moussa Ndiaye]
Ibrahima Sy
[Awa Diop, Cheikh Ba, Coumba Faye]

Lecture. Le HashSet suit l’ordre de ses cases : Fatou Sall sort en dernier. Le TreeSet sort les noms triés, donne le premier nom après Fatou Sall et les noms de l’intervalle \([\texttt{A}, \texttt{D}[\) sans tout parcourir.

Ce que l’on cherche

Trois propriétés à la fois

  1. get, put et remove en \(\mathcal{O}(\log n)\) ;
  2. des clés gardées dans l’ordre : minimum, successeur, liste triée ;
  3. un parcours d’intervalle \([a,b]\) en temps proportionnel aux clés rencontrées.
structure (1) opérations (2) ordre (3) intervalle
table de hachage \(\mathcal{O}(1)\) en moyenne non non (tout parcourir)
tableau trié get \(\mathcal{O}(\log n)\), put \(\Theta(n)\) oui oui
arbre binaire de recherche \(\mathcal{O}(h)\), \(h\) la hauteur oui oui

Lecture. L’arbre réunit les trois, à condition que sa hauteur \(h\) reste en \(\mathcal{O}(\log n)\) : c’est l’objet des AVL. Le tas, en fin de séance, ne garde qu’une partie de l’ordre : le minimum.

L’arbre binaire de recherche

Propriété d’ABR

Un arbre binaire est un arbre binaire de recherche {(binary search tree)} si, en tout nœud \(x\), les clés du sous-arbre gauche sont \(<x.\mathrm{cle}\) et celles du sous-arbre droit \(>x.\mathrm{cle}\). Les clés sont distinctes : un put sur une clé présente remplace sa valeur.

Vocabulaire

Profondeur : arêtes depuis la racine. Hauteur : la plus grande profondeur (vide : \(-1\), feuille seule : \(0\)).

private static class Noeud<K, V> {
    K cle;
    V valeur;
    Noeud<K, V> gauche;
    Noeud<K, V> droite;

    Noeud(K cle, V valeur) {
        this.cle = cle;
        this.valeur = valeur;
    }
}

private Noeud<K, V> racine;  // null : arbre vide
private int n;               // nombre de noeuds

Lecture. Un nœud = une clé, une valeur, deux liens. K extends Comparable<K> : les clés se comparent par compareTo.

L’insertion dans un ABR, pas à pas

Sortie \(\rightarrow\) 

7 2
10

Lecture. Ligne 1 : un ABR vide : racine vaut null, n vaut 0.

Lecture. Ligne 2 : l’arbre est vide : 50 devient la racine, sans comparaison.

Lecture. Ligne 3 : \(30<50\) : put descend à gauche, trouve null et y accroche la nouvelle feuille.

Lecture. Ligne 4 : \(70>50\) : à droite de la racine. Une comparaison par nœud traversé.

Lecture. Ligne 5 : \(20<50\), puis \(20<30\) : deux comparaisons, feuille de profondeur 2.

Lecture. Ligne 6 : \(40 < 50\) puis \(40 > 30\) : Ziguinchor se range en feuille de profondeur 2.

Lecture. Ligne 7 : \(60 > 50\) puis \(60 < 70\) : Louga se range en feuille de profondeur 2.

Lecture. Ligne 8 : \(80 > 50\) puis \(80 > 70\) : Matam se range en feuille de profondeur 2.

Lecture. Ligne 9 : 7 nœuds et une hauteur de 2 : chaque niveau est plein.

Lecture. Ligne 10 : 10 comparaisons au total : insérer une clé coûte la profondeur où elle atterrit.

put en Java

public void put(K cle, V valeur) {
    racine = put(racine, cle, valeur);
}

private Noeud<K, V> put(Noeud<K, V> x, K cle, V valeur) {
    if (x == null) {         // place vide : nouvelle feuille
        n++;
        return new Noeud<>(cle, valeur);
    }
    comparaisons++;
    int c = cle.compareTo(x.cle);
    if (c < 0) x.gauche = put(x.gauche, cle, valeur);
    else if (c > 0) x.droite = put(x.droite, cle, valeur);
    else x.valeur = valeur;  // deja presente : remplacer
    return x;
}

Lecture. put est récursif (Sedgewick et Wayne) : chaque appel rend la racine du sous-arbre, que le parent raccroche à la place de l’ancienne. Le seul nœud créé est une feuille, au bout du chemin ; on y arrive après une comparaison par nœud traversé.

La recherche dans un ABR, pas à pas

Sortie \(\rightarrow\) 

v60 3
null 3

Lecture. Ligne 1 : un ABR vide.

Lecture. Ligne 2 : les sept clés, dans l’ordre d’insertion de l’exemple précédent.

Lecture. Ligne 3 : le même arbre ; chaque valeur est "v" suivi de la clé.

Lecture. Ligne 4 : le compteur d’observation est remis à zéro.

Lecture. Ligne 5 : get descend comme put : 50, 70, puis 60, trouvée. Seul le chemin est examiné.

Lecture. Ligne 6 : la valeur et 3 comparaisons, pour 7 clés.

Lecture. Ligne 7 : remise à zéro.

Lecture. Ligne 8 : 65 suit 50, 70, 60, puis tombe sur le fils droit de 60, vide : la clé est absente.

Lecture. Ligne 9 : null 3 : une recherche coûte au plus \(h+1\) comparaisons, présente ou absente.

get en Java

public V get(K cle) {
    Noeud<K, V> x = racine;
    while (x != null) {
        comparaisons++;                   // 1 comparaison par noeud
        int c = cle.compareTo(x.cle);
        if (c == 0) return x.valeur;
        x = c < 0 ? x.gauche : x.droite;  // descendre
    }
    return null;                          // absente
}

Lecture. get descend en boucle, sans récursion : une comparaison compareTo par nœud examiné, jusqu’à la clé ou jusqu’à null. contains suit le même chemin et rend un booléen.

La suppression en Java

private Noeud<K, V> remove(Noeud<K, V> x, K cle) {
    if (x == null) return null;                         // cle absente
    int c = cle.compareTo(x.cle);
    if (c < 0) {
        x.gauche = remove(x.gauche, cle);
    } else if (c > 0) {
        x.droite = remove(x.droite, cle);
    } else if (x.gauche == null || x.droite == null) {  // cas 1 et 2
        n--;
        return x.gauche != null ? x.gauche : x.droite;  // feuille : null
    } else {                                            // cas 3
        Noeud<K, V> s = min(x.droite);                  // successeur infixe
        x.cle = s.cle;
        x.valeur = s.valeur;
        x.droite = remove(x.droite, s.cle);             // s : 0 ou 1 enfant
    }
    return x;
}

Lecture. Cas 3 : la clé du successeur \(s\) remplace celle du nœud, puis \(s\), sans enfant gauche, est retiré.

Les trois cas de la suppression, pas à pas

Sortie \(\rightarrow\) 

50(30(-,40),70(60,80))
50(40,70(60,80))
60(40,70(-,80))

Lecture. Ligne 1 : un ABR vide.

Lecture. Ligne 2 : les sept clés de l’exemple.

Lecture. Ligne 3 : l’arbre des sept clés.

Lecture. Ligne 4 : 20 est une feuille : remove rend null à son parent, qui la détache.

Lecture. Ligne 5 : l’état écrit : - marque le sous-arbre gauche vide de 30.

Lecture. Ligne 6 : 30 n’a qu’un enfant, 40 : remove rend 40 au parent, qui l’accroche à la place de 30.

Lecture. Ligne 7 : 40, désormais feuille, s’écrit seule.

Lecture. Ligne 8 : 50 a deux enfants : son successeur infixe est le minimum du sous-arbre droit, trouvé en descendant à gauche depuis 70 : 60.

Lecture. Ligne 8 (suite) : la clé et la valeur de 60 sont recopiées dans le nœud de 50, puis 60 est retirée du sous-arbre droit, où elle n’a pas d’enfant gauche (cas 1 ou 2).

Lecture. Ligne 9 : 60(40,70(-,80)) : l’ordre infixe 40, 60, 70, 80 est conservé.

Les trois parcours

Parcours en profondeur

Pour un nœud \(x\) :

  • infixe {(inorder)} : gauche, \(x\), droite ;
  • préfixe {(preorder)} : \(x\), gauche, droite ;
  • postfixe {(postorder)} : gauche, droite, \(x\).

Infixe d’un ABR = clés triées

Par récurrence sur la hauteur : le sous-arbre gauche sort ses clés triées, toutes \(<x\) ; puis \(x\) ; puis les clés du sous-arbre droit, triées, toutes \(>x\).

public List<K> infixe() {
    List<K> l = new ArrayList<>();
    infixe(racine, l);
    return l;
}

private void infixe(Noeud<K, V> x, List<K> l) {
    if (x == null) return;
    infixe(x.gauche, l);  // 1. gauche
    l.add(x.cle);         // 2. le noeud
    infixe(x.droite, l);  // 3. droite
}

Lecture. Chaque nœud est visité une fois : un parcours coûte \(\Theta(n)\). prefixe et postfixe ne diffèrent que par la place de l.add(x.cle).

Les parcours, pas à pas

Sortie \(\rightarrow\) 

[20, 30, 40, 50, 60, 70, 80]
[50, 30, 20, 40, 70, 60, 80]
[20, 40, 30, 60, 80, 70, 50]
[40, 50, 60]

Lecture. Ligne 1 : un ABR vide.

Lecture. Ligne 2 : les sept clés de l’exemple.

Lecture. Ligne 3 : l’arbre des sept clés.

Lecture. Ligne 4 : infixe : un nœud est visité après tout son sous-arbre gauche et avant tout son sous-arbre droit : les clés sortent triées.

Lecture. Ligne 5 : préfixe : le nœud d’abord. Réinsérer les clés dans cet ordre redonne le même arbre.

Lecture. Ligne 6 : postfixe : le nœud en dernier, après ses deux sous-arbres ; la racine sort à la fin.

Lecture. Ligne 7 : cles(35, 65) ne descend à gauche que si \(35<\) clé, à droite que si clé \(<65\) : 5 nœuds visités, 3 retenus (fond orangé), dans l’ordre.

Exercice : un ABR de huit clés

Insertions dans un ABR vide

On insère, dans cet ordre : 41, 22, 67, 15, 30, 58, 80, 26.

  1. Profondeur et comparaisons de chaque insertion.
  2. L’arbre obtenu et sa hauteur.
  3. Comparaisons de get(26), puis de get(50).
  4. Supprimer 22 : cas, clé de remplacement, arbre.
  5. Le parcours infixe après la suppression.
clé 41 22 67 15 30 58 80 26
profondeur 0 1 1 2 2 2 2 3

Correction

  1. Autant de comparaisons que la profondeur : 26 passe par 41, 22, 30.
  2. 41(22(15,30(26,-)),67(58,80)), hauteur 3.
  3. 26 : 41, 22, 30, 26, soit 4 ; 50 : 41, 67, 58, puis vide, soit 3.
  4. Deux enfants : successeur = min du sous-arbre droit 30(26,-) : 26. 41(26(15,30),67(58,80)).
  5. 15, 26, 30, 41, 58, 67, 80 : toujours trié.

Le coût d’une opération est la hauteur

Coût de get, put, remove

\[ T(n)=\Theta(h)\qquad\text{avec}\qquad \lfloor\log_2 n\rfloor\;\leq\; h\;\leq\; n-1 \]

Chaque opération suit un seul chemin depuis la racine : au plus \(h+1\) comparaisons. Un arbre binaire de hauteur \(h\) a au plus \(2^{h+1}-1\) nœuds, d’où \(h\geq\lfloor\log_2 n\rfloor\) ; une chaîne atteint \(h=n-1\).

L’ordre d’insertion décide

Les mêmes clés donnent un arbre de hauteur 2 ou 6 selon l’ordre d’arrivée. Or les données arrivent souvent triées : par date, par matricule croissant, par ordre alphabétique.

Lecture. Sur des clés en ordre aléatoire, la recherche réussie coûte en moyenne \(2(1+1/n)H_n-3\sim 2\ln n\approx1{,}39\log_2 n\) comparaisons (\(H_n\) : nombre harmonique).

L’arbre dégénéré, pas à pas

Sortie \(\rightarrow\) 

6 21
7

Lecture. Ligne 1 : un ABR vide ; les clés vont arriver dans l’ordre croissant.

Lecture. Ligne 2 : 20 devient la racine.

Lecture. Ligne 3 : \(30>20\) : à droite.

Lecture. Ligne 4 : 40 est plus grande que toutes les clés présentes : elle descend toujours à droite, 2 comparaisons.

Lecture. Ligne 5 : 50 traverse les 3 nœuds déjà présents : 3 comparaisons, profondeur 3.

Lecture. Ligne 6 : 60 traverse les 4 nœuds déjà présents : 4 comparaisons, profondeur 4.

Lecture. Ligne 7 : 70 traverse les 5 nœuds déjà présents : 5 comparaisons, profondeur 5.

Lecture. Ligne 8 : 80 traverse les 6 nœuds déjà présents : 6 comparaisons, profondeur 6.

Lecture. Ligne 9 : hauteur 6 pour 7 nœuds : l’arbre est une liste chaînée, et les insertions ont coûté \(0+1+\dots+6=21\) comparaisons.

Lecture. Ligne 10 : remise à zéro du compteur.

Lecture. Ligne 11 : get(80) traverse les 7 nœuds, comme une recherche dans une liste.

Lecture. Ligne 12 : 7 comparaisons, contre 3 dans l’arbre équilibré des mêmes clés.

La hauteur mesurée selon l’ordre

for (int n : new int[] {100, 1000, 10000}) {  // java -Xss16m : pile agrandie
    List<Integer> cles = new ArrayList<>();
    for (int i = 1; i <= n; i++) cles.add(i);
    SimpleBST<Integer, Integer> trie = new SimpleBST<>();
    for (int k : cles) trie.put(k, k);        // ordre croissant
    Collections.shuffle(cles, new Random(42));
    SimpleBST<Integer, Integer> alea = new SimpleBST<>();
    for (int k : cles) alea.put(k, k);        // ordre aleatoire
    alea.comparaisons = 0;
    for (int k : cles) alea.get(k);           // n recherches reussies
    System.out.println(n + " : " + trie.hauteur() + " " + alea.hauteur()
        + " " + alea.comparaisons / (double) n);
}

Résultat \(\rightarrow\)

100 : 99 13 7.76
1000 : 999 26 13.401
10000 : 9999 29 16.1003

Lecture. Triées : \(h=n-1\) ; aléatoires : 13, 26, 29 ; \(16{,}10\) comparaisons par get (formule \(16{,}58\)).

Hauteur et coût d’un ABR aléatoire

{.t-scriptsize}SimpleBST, \(n=16\) à \(16\,384\) clés mélangées (Collections.shuffle, graine 221), 100 arbres par taille.

Lecture. Le coût mesuré suit la formule à 1 % près (\(17{,}74\) contre \(17{,}56\) pour \(16\,384\)). La hauteur moyenne, \(32{,}7\), croît aussi en \(\log n\) et tend lentement vers \(2{,}99\log_2 n\) (Reed, 2003).

Exercice : hauteur et ordre d’insertion

Quinze clés, deux ordres

A : 40, 20, 60, 10, 30, 50, 70, 5, 15, 25, 35, 45, 55, 65, 75.

B : 5, 10, 15, …, 75 (croissant).

  1. La hauteur de chaque arbre.
  2. Les comparaisons de get(75) et de get(5).
  3. Compléter : get coûte \(\Theta(\dots)\) ; au pire \(\Theta(\dots)\) ; équilibré \(\Theta(\dots)\).
ordre hauteur get(75) get(5)
A 3 4 4
B 14 15 1

Correction

  1. A remplit les niveaux un à un : \(1+2+4+8=15\) nœuds, \(h=3=\lfloor\log_2 15\rfloor\). B est une chaîne : \(h=14=n-1\).
  2. A : 40, 60, 70, 75 et 40, 20, 10, 5 : 4 et 4. B : les 15 nœuds pour 75, la racine seule pour 5.
  3. \(\Theta(h)\) ; au pire \(\Theta(n)\) ; équilibré \(\Theta(\log n)\).

L’invariant AVL

Facteur d’équilibre et invariant

\(h(x)\) : hauteur du sous-arbre de racine \(x\) (vide : \(-1\), feuille : \(0\)). Le facteur d’équilibre est

\[ \mathrm{bf}(x)=h(\mathrm{gauche}(x))-h(\mathrm{droite}(x)) \]

Un ABR est un AVL (Adelson-Velsky et Landis, 1962) si \(|\mathrm{bf}(x)|\leq1\) en tout nœud \(x\).

Garantie de hauteur

\[ h\;\leq\;1{,}44\,\log_2(n+2)-0{,}328\;=\;\mathcal{O}(\log n) \]

Le pire AVL de hauteur \(h\) a \(N_h=N_{h-1}+N_{h-2}+1\) nœuds (un sous-arbre de hauteur \(h-1\), l’autre \(h-2\)) : \(N_h\) croît comme Fibonacci, en \(\varphi^h\), \(\varphi\approx1{,}618\), d’où \(h\leq\log_\varphi n\approx1{,}44\log_2 n\).

Lecture. get est celui de l’ABR ; seuls put et remove doivent rétablir l’invariant, par des rotations.

Le nœud AVL en Java

private static class Noeud<K, V> {
    final K cle;
    V valeur;
    int hauteur;                        // feuille : 0
    Noeud<K, V> gauche;
    Noeud<K, V> droite;
}

private int h(Noeud<K, V> x) {
    return x == null ? -1 : x.hauteur;  // arbre vide : -1
}

private int equilibre(Noeud<K, V> x) {
    return h(x.gauche) - h(x.droite);   // facteur d'equilibre
}

Lecture. Constructeur non montré. La hauteur mémorisée donne equilibre en \(\mathcal{O}(1)\).

La rotation

{.t-scriptsize}\(T_1\), \(T_2\), \(T_3\) : sous-arbres quelconques, peut-être vides.

Une réorganisation locale

Une rotation change trois liens : \(b\) monte, \(a\) descend, et le sous-arbre du milieu \(T_2\) passe de \(b\) à \(a\). Elle conserve la propriété d’ABR (l’ordre infixe ne change pas), change les hauteurs (\(T_1\) remonte d’un niveau, \(T_3\) descend d’un niveau) et coûte \(\mathcal{O}(1)\). La rotation gauche est symétrique.

Les rotations en Java

private Noeud<K, V> rotationDroite(Noeud<K, V> a) {
    Noeud<K, V> b = a.gauche;  // b monte
    a.gauche = b.droite;       // T2 sous a
    b.droite = a;              // a descend
    majHauteur(a);             // a, sous b
    majHauteur(b);
    rotations++;
    return b;                  // la racine
}
private Noeud<K, V> rotationGauche(Noeud<K, V> a) {
    Noeud<K, V> b = a.droite;  // b monte
    a.droite = b.gauche;       // T2 sous a
    b.gauche = a;              // a descend
    majHauteur(a);
    majHauteur(b);
    rotations++;
    return b;
}
private void majHauteur(Noeud<K, V> x) {
    x.hauteur = 1 + Math.max(h(x.gauche), h(x.droite));
}

Lecture. a doit être mis à jour avant b, puisque a est désormais l’enfant de b. Chaque méthode rend la nouvelle racine du sous-arbre : l’appelant la raccroche à la place de a.

Les quatre cas de déséquilibre

Après une insertion, on remonte le chemin ; au premier nœud \(a\) avec \(|\mathrm{bf}(a)|=2\), le cas se lit sur le chemin qui descend de \(a\) vers la clé insérée :

cas signes chemin depuis \(a\) rééquilibrage
LL \(\mathrm{bf}(a)=+2\), \(\mathrm{bf}(\mathrm{gauche})\geq0\) gauche, gauche rotation droite sur \(a\)
RR \(\mathrm{bf}(a)=-2\), \(\mathrm{bf}(\mathrm{droite})\leq0\) droite, droite rotation gauche sur \(a\)
LR \(\mathrm{bf}(a)=+2\), \(\mathrm{bf}(\mathrm{gauche})=-1\) gauche, droite gauche sur l’enfant, puis droite sur \(a\)
RL \(\mathrm{bf}(a)=-2\), \(\mathrm{bf}(\mathrm{droite})=+1\) droite, gauche droite sur l’enfant, puis gauche sur \(a\)

Le nom du cas

Le cas nomme le chemin vers la clé insérée, pas le sens de la rotation : LL se répare par une rotation droite. Dans LR, une seule rotation droite sur \(a\) redonne un arbre déséquilibré, de l’autre côté.

Le rééquilibrage en Java

private Noeud<K, V> reequilibrer(Noeud<K, V> a) {
    majHauteur(a);
    int bf = equilibre(a);
    if (bf > 1) {                       // trop haut a gauche
        if (equilibre(a.gauche) < 0) {  // cas LR
            a.gauche = rotationGauche(a.gauche);
        }
        return rotationDroite(a);       // cas LL
    }
    if (bf < -1) {                      // trop haut a droite
        if (equilibre(a.droite) > 0) {  // cas RL
            a.droite = rotationDroite(a.droite);
        }
        return rotationGauche(a);       // cas RR
    }
    return a;                           // |bf| <= 1
}

Lecture. put est celui de l’ABR, à une ligne près : au retour de chaque appel récursif, return reequilibrer(x); au lieu de return x;. Le rééquilibrage a donc lieu en remontant, du bas vers la racine.

L’AVL, cas RR et RL, pas à pas

Sortie \(\rightarrow\) 

20(10,35(30,40))
3 2

Lecture. Ligne 1 : un AVL vide. Chaque nœud porte sa hauteur ; le facteur d’équilibre \(\mathrm{bf}=h(g)-h(d)\) est affiché à droite des nœuds du chemin.

Lecture. Ligne 2 : une feuille : hauteur 0, \(\mathrm{bf}=-1-(-1)=0\).

Lecture. Ligne 3 : 20 à droite de 10 : \(\mathrm{bf}(10)=-1\), toléré.

Lecture. Ligne 4 : 30 à droite de 20 : en remontant, \(\mathrm{bf}(20)=-1\), puis \(\mathrm{bf}(10)=-1-1=-2\). Le chemin descend deux fois à droite : cas RR.

Lecture. Ligne 4 (suite) : rotation gauche sur 10 : 20 monte, 10 devient son fils gauche. L’ordre infixe 10, 20, 30 est inchangé, la hauteur repasse à 1.

Lecture. Ligne 5 : 40 sous 30 : \(\mathrm{bf}(30)=-1\), \(\mathrm{bf}(20)=0-1=-1\) : aucun nœud hors de \([-1,1]\).

Lecture. Ligne 6 : 35 à gauche de 40 : \(\mathrm{bf}(40)=+1\), \(\mathrm{bf}(30)=-1-1=-2\). Droite puis gauche : cas RL.

Lecture. Ligne 6 (suite) : première rotation, droite sur 40 : 35 monte au-dessus de 40. Le cas RL devient un cas RR.

Lecture. Ligne 6 (suite) : seconde rotation, gauche sur 30 : 35 prend la place de 30, entre 30 et 40.

Lecture. Ligne 7 : 20(10,35(30,40)) : cinq clés en hauteur 2.

Lecture. Ligne 8 : 3 rotations (une pour RR, deux pour RL) et une hauteur de 2 : un ABR, avec les mêmes insertions, aurait la hauteur 4.

L’AVL, cas LL et LR, pas à pas

Sortie \(\rightarrow\) 

20(5(3,8(7,10)),35(30,40))
3 3

Lecture. Ligne 1 : un AVL vide.

Lecture. Ligne 2 : les cinq clés de l’exemple précédent.

Lecture. Ligne 3 : l’arbre 20(10,35(30,40)).

Lecture. Ligne 4 : le compteur de rotations repart de 0.

Lecture. Ligne 5 : 5 sous 10 : \(\mathrm{bf}(10)=+1\), \(\mathrm{bf}(20)=1-1=0\).

Lecture. Ligne 6 : 3 sous 5 : \(\mathrm{bf}(5)=+1\), \(\mathrm{bf}(10)=1-(-1)=+2\). Gauche puis gauche : cas LL.

Lecture. Ligne 6 (suite) : rotation droite sur 10 : 5 monte, 10 devient son fils droit ; \(\mathrm{bf}(20)\) redevient \(+1\).

Lecture. Ligne 7 : 7 sous 10 : \(\mathrm{bf}(10)=+1\), \(\mathrm{bf}(5)=-1\), \(\mathrm{bf}(20)=2-1=+1\) : équilibré.

Lecture. Ligne 8 : 8 à droite de 7 : \(\mathrm{bf}(7)=-1\), \(\mathrm{bf}(10)=1-(-1)=+2\). Gauche puis droite : cas LR.

Lecture. Ligne 8 (suite) : rotation gauche sur 7 : 8 monte au-dessus de 7 ; le cas LR devient un cas LL.

Lecture. Ligne 8 (suite) : rotation droite sur 10 : 8 prend la place de 10, entre 7 et 10.

Lecture. Ligne 9 : neuf clés, hauteur 3.

Lecture. Ligne 10 : 3 rotations depuis la remise à zéro : une pour LL, deux pour LR. Chaque insertion a demandé au plus un rééquilibrage.

Clés croissantes : l’ABR contre l’AVL

int n = 10000;                               // java -Xss16m : pile agrandie
SimpleBST<Integer, Integer> abr = new SimpleBST<>();
AVLTree<Integer, Integer> avl = new AVLTree<>();
for (int k = 1; k <= n; k++) abr.put(k, k);  // cles croissantes
for (int k = 1; k <= n; k++) avl.put(k, k);
abr.comparaisons = 0;
avl.comparaisons = 0;
for (int k = 1; k <= n; k++) abr.get(k);     // n recherches reussies
for (int k = 1; k <= n; k++) avl.get(k);
System.out.println("ABR " + abr.hauteur() + " " + abr.comparaisons / (double) n);
System.out.println("AVL " + avl.hauteur() + " " + avl.comparaisons / (double) n
    + " " + avl.rotations);

Résultat \(\rightarrow\)

ABR 9999 5000.5
AVL 13 12.3631 9986

Lecture. ABR : hauteur \(9\,999\), \(5\,000{,}5\) comparaisons par get. AVL : hauteur 13 \(=\lfloor\log_2 10\,000\rfloor\), le minimum, et \(12{,}36\), pour \(9\,986\) rotations.

Quoi, pourquoi, quand ça échoue : l’AVL

Quoi

Un ABR dont chaque nœud garde sa hauteur ; après chaque insertion ou suppression, une ou deux rotations par nœud déséquilibré rétablissent \(|\mathrm{bf}|\leq1\).

Pourquoi c’est logarithmique

L’invariant borne la hauteur par \(1{,}44\log_2(n+2)\) ; une insertion demande au plus un rééquilibrage (une ou deux rotations), car il rend au sous-arbre sa hauteur d’avant.

Quand ça échoue

Une suppression peut demander une rotation à chaque niveau, \(\Theta(\log n)\). java.util.TreeMap utilise un arbre rouge-noir : hauteur jusqu’à \(2\log_2(n+1)\), mais au plus 2 rotations par insertion et 3 par suppression.

Exercice : une insertion dans un AVL

L’AVL 50(30(20,40),70), on insère 45

  1. L’arbre juste après l’insertion, avant rotation.
  2. Les facteurs d’équilibre de 40, 30 et 50.
  3. Le nœud déséquilibré, le cas, les rotations.
  4. L’arbre final et ses facteurs d’équilibre.

Départ : 50, de fils 30 (enfants 20 et 40) et 70.

nœud 40 30 50
bf après insertion \(-1\) \(-1\) \(+2\)
bf final 0 \(+1\) 0

Correction

  1. \(45<50\), \(45>30\), \(45>40\) : fils droit de 40.
  2. \(\mathrm{bf}(40)=-1-0\) ; \(\mathrm{bf}(30)=0-1\) ; \(\mathrm{bf}(50)=2-0=+2\).
  3. 50, chemin gauche puis droite : cas LR. Rotation gauche sur 30, puis droite sur 50.
  4. 40(30(20,-),50(45,70)) : 40 monte à la racine ; hauteur 2.

Un nouveau besoin : la file de priorité

Le type abstrait file de priorité

insert(x) ; extractMin() : retirer et rendre le plus petit ; peekMin() ; size(). Aucune recherche d’une clé quelconque : Dijkstra (Séance 5) extrait le sommet le plus proche.

PriorityQueue<Integer> f = new PriorityQueue<>();
for (int p : new int[] {5, 1, 4, 2, 3}) f.add(p);
System.out.println(f.peek() + " " + f);  // f : son tableau interne
StringBuilder s = new StringBuilder();
while (!f.isEmpty()) s.append(f.poll()).append(" ");
System.out.println(s);

Résultat \(\rightarrow\)

1 [1, 2, 4, 5, 3]
1 2 3 4 5 

Lecture. java.util.PriorityQueue est un tas binaire en tableau (champ queue, siftUp, siftDown) : l’affichage montre ce tableau, non trié ; poll rend les clés dans l’ordre.

La propriété du tas

Tas binaire min {(min-heap)}

Un arbre binaire complet (tous les niveaux pleins, sauf peut-être le dernier, rempli de gauche à droite) tel que, pour tout nœud \(x\) autre que la racine :

\[ \mathrm{cle}(\mathrm{parent}(x))\;\leq\;\mathrm{cle}(x) \]

Le minimum est à la racine : peekMin coûte \(\mathcal{O}(1)\). Un tas de \(n\) clés a la hauteur \(\lfloor\log_2 n\rfloor\).

Un tas n’est pas un ABR

ABR : gauche \(<x<\) droite, l’arbre est trié. Tas : parent \(\leq\) enfants, l’arbre est partiellement ordonné : entre deux frères, aucun ordre. On n’y cherche pas une clé quelconque, et son parcours infixe ne sort pas les clés triées : [2, 5, 3, 10, 7, 8, 6] donne 10, 5, 7, 2, 8, 3, 6.

Le tas rangé dans un tableau

{.t-scriptsize}Le tas [2, 5, 3, 10, 7, 8, 6] : case \(i\) = nœud \(i\), niveau par niveau, de gauche à droite.

Indices (tableau indexé à partir de 0)

\[ \mathrm{parent}(i)=\Big\lfloor\frac{i-1}2\Big\rfloor\qquad\mathrm{gauche}(i)=2i+1\qquad\mathrm{droite}(i)=2i+2 \]

Un arbre complet n’a pas de trou : les cases \(0\) à \(n-1\), sans aucun lien. Indexé à partir de 1 (CLRS) : \(2i\) et \(2i+1\).

insert et la percolation vers le haut

public void insert(K cle) {
    if (size == data.length) resize();
    data[size] = cle;                // en queue : premiere case libre
    percolateUp(size);
    size++;
}

private void percolateUp(int i) {
    while (i > 0) {
        int parent = (i - 1) / 2;
        if (plusPetit(i, parent)) {  // plus petit que son parent :
            swap(i, parent);         // il monte d'un niveau
            i = parent;
        } else {
            return;                  // ordre du tas respecte
        }
    }
}

Lecture. La clé prend la première case libre, puis remonte : au plus \(\lfloor\log_2 n\rfloor\) échanges.

L’insertion dans un tas, pas à pas

Sortie \(\rightarrow\) 

[2, 5, 3, 10, 7, 8, 6]
[1, 2, 3, 5, 7, 8, 6, 10] 3

Lecture. Ligne 1 : un tas vide : un tableau de 8 cases, size vaut 0.

Lecture. Ligne 2 : sept clés, déjà dans l’ordre d’un tas.

Lecture. Ligne 3 : chaque insert range sa clé en queue, sans échange ici : chaque parent est plus petit que ses enfants. La case \(i\) est le nœud \(i\), niveau par niveau.

Lecture. Ligne 4 : le tableau, dans l’ordre des indices : le minimum 2 est en case 0.

Lecture. Ligne 5 : le compteur d’échanges est remis à zéro.

Lecture. Ligne 6 : 1 est rangé en queue, case 7 (fils gauche de la case 3). \(1<10\) : l’ordre du tas est violé.

Lecture. Ligne 6 (suite) : percolateUp : 1 et 10 sont échangés ; 1 est en case 3, son parent est la case 1, qui contient 5.

Lecture. Ligne 6 (suite) : \(1<5\) : nouvel échange ; 1 monte en case 1, sous la racine.

Lecture. Ligne 6 (suite) : \(1<2\) : dernier échange ; 1 atteint la racine et la boucle s’arrête (\(i=0\)).

Lecture. Ligne 7 : 3 échanges : la clé est remontée de la profondeur 3 à la racine, \(\lfloor\log_2 8\rfloor=3\) niveaux au plus.

extractMin et la percolation vers le bas

public K extractMin() {
    K min = data[0];
    size--;
    data[0] = data[size];                  // le dernier monte a la racine
    data[size] = null;
    if (size > 0) percolateDown(0);
    return min;
}

private void percolateDown(int i) {
    while (2 * i + 1 < size) {             // i a au moins un enfant
        int petit = 2 * i + 1;             // enfant gauche
        if (petit + 1 < size && plusPetit(petit + 1, petit)) petit++;
        if (!plusPetit(petit, i)) return;  // ordre du tas respecte
        swap(i, petit);                    // il descend d'un niveau
        i = petit;
    }
}

Lecture. Le dernier comble la racine, puis descend vers le plus petit enfant (test du tas vide non montré).

L’extraction du minimum, pas à pas

Sortie \(\rightarrow\) 

2 [3, 5, 6, 10, 7, 8]
3 [5, 7, 6, 10, 8]

Lecture. Ligne 1 : un tas vide.

Lecture. Ligne 2 : les sept clés de l’exemple précédent.

Lecture. Ligne 3 : le tas [2, 5, 3, 10, 7, 8, 6] ; le minimum est en case 0.

Lecture. Ligne 4 : extractMin garde 2, puis range le dernier élément, 6, à la racine : le tableau compte 6 cases, mais 6 est plus grand que ses enfants.

Lecture. Ligne 4 (suite) : percolateDown compare les deux enfants, 5 et 3, et échange 6 avec le plus petit, 3. Avec 5, l’ordre serait violé entre 5 et 3.

Lecture. Ligne 4 (suite) : en case 2, 6 n’a plus qu’un enfant (\(2\times2+2=6=\) size) : \(6<8\), la percolation s’arrête.

Lecture. Ligne 5 : 2 [3, 5, 6, 10, 7, 8] : le nouveau minimum, 3, est à la racine.

Lecture. Ligne 6 : second extractMin : 3 sort, le dernier élément, 8, monte à la racine.

Lecture. Ligne 6 (suite) : le plus petit enfant est 5 : échange avec la case 1.

Lecture. Ligne 6 (suite) : en case 1, les enfants sont 10 et 7 : échange avec 7. En case 4, \(2\times4+1=9\geq5\) : une feuille, fin.

Lecture. Ligne 7 : 3 [5, 7, 6, 10, 8] : deux extractions ont rendu 2 puis 3, dans l’ordre croissant.

Le coût des opérations

Random r = new Random(221);
for (int n : new int[] {1000, 10000, 100000, 1000000}) {
    BinaryMinHeap<Integer> tas = new BinaryMinHeap<>();
    for (int i = 0; i < n; i++) tas.insert(r.nextInt());
    double ins = tas.echanges / (double) n;  // par insert
    tas.echanges = 0;
    for (int i = 0; i < n; i++) tas.extractMin();
    double ext = tas.echanges / (double) n;  // par extractMin
    double lg = Math.log(n) / Math.log(2);
    System.out.printf(Locale.ROOT, "%7d : %.3f %.3f %.2f%n", n, ins, ext, lg);
}

Résultat \(\rightarrow\)

   1000 : 1.280 7.366 9.97
  10000 : 1.275 10.680 13.29
 100000 : 1.275 14.009 16.61
1000000 : 1.280 17.307 19.93

Lecture. Pire cas : \(\lfloor\log_2 n\rfloor\) échanges. Clés aléatoires : insert, \(1{,}28\) échange en moyenne quel que soit \(n\) ; extractMin, \(17{,}31\) pour \(\log_2 n=19{,}93\).

Quoi, pourquoi, quand ça échoue : le tas

Quoi

Un arbre complet rangé dans un tableau, parent \(\leq\) enfants ; insert percole vers le haut, extractMin vers le bas.

Pourquoi c’est logarithmique

L’arbre complet a la hauteur \(\lfloor\log_2 n\rfloor\), et chaque percolation suit un seul chemin : \(\mathcal{O}(1)\) pour peekMin, \(\mathcal{O}(\log n)\) pour insert et extractMin, sans aucun pointeur.

Quand ça échoue

Chercher ou retirer une clé quelconque : \(\Theta(n)\), il faut parcourir le tableau. Changer la priorité d’une clé déjà rangée demande de connaître sa case.

Exercice : un tas en tableau

Le tas [4, 9, 6, 12, 10, 8, 15]

  1. Parent et enfants de la case 2, feuilles, hauteur.
  2. insert(5) : le tableau à chaque étape.
  3. Puis extractMin() : valeur et tableaux.

Correction

  1. Parent 0, enfants 5 et 6 ; feuilles 3 à 6 ; \(h=\lfloor\log_2 7\rfloor=2\).
  2. \(5<12\), \(5<9\), \(5>4\) : 2 échanges.
  3. Rend 4 ; 12 descend : échangé avec 5, puis 9 ; en case 3, une feuille.
case 0 1 2 3 4 5 6 7
départ 4 9 6 12 10 8 15
en queue 4 9 6 12 10 8 15 5
échange 7, 3 4 9 6 5 10 8 15 12
échange 3, 1 4 5 6 9 10 8 15 12
12 monte 12 5 6 9 10 8 15
échange 0, 1 5 12 6 9 10 8 15
échange 1, 3 5 9 6 12 10 8 15

Le tri par tas

Integer[] a = {14, 9, 17, 11, 6, 15, 12, 8};
BinaryMinHeap<Integer> tas = new BinaryMinHeap<>();
for (Integer x : a) tas.insert(x);  // n insertions
for (int i = 0; i < a.length; i++) a[i] = tas.extractMin();
System.out.println(Arrays.toString(a));
int n = 1000000;
Random r = new Random(221);
BinaryMinHeap<Integer> grand = new BinaryMinHeap<>();
for (int i = 0; i < n; i++) grand.insert(r.nextInt());
for (int i = 0; i < n; i++) grand.extractMin();
double nlgn = n * Math.log(n) / Math.log(2);
System.out.println(grand.comparaisons + " " + grand.comparaisons / nlgn);

Résultat \(\rightarrow\)

[6, 8, 9, 11, 12, 14, 15, 17]
37195773 1.8661738974847382

Lecture. Tri par tas {(heapsort)} : \(n\) insertions puis \(n\) extractions, \(\mathcal{O}(n\log n)\) au pire ; mesuré : \(1{,}87\,n\log_2 n\) comparaisons pour \(n=10^6\). Sedgewick et Wayne (§ 2.4) : le même tri en place.

Les structures côte à côte

structure get put minimum mesuré, \(n=10\,000\) : get
aléatoire / croissant
table de hachage (S3) \(\Theta(1)\) moyen \(\Theta(1)\) amorti \(\Theta(n)\) — (voir ci-dessous)
ABR \(\Theta(h)\), pire \(\Theta(n)\) \(\Theta(h)\) \(\Theta(h)\) \(15{,}33\) / \(5\,000{,}5\)
AVL \(\Theta(\log n)\) pire \(\Theta(\log n)\) \(\Theta(\log n)\) \(12{,}61\) / \(12{,}36\)
tas binaire — \(\mathcal{O}(\log n)\) \(\Theta(1)\), retrait \(\mathcal{O}(\log n)\) —

Lecture. Comparaisons par recherche réussie ; clés \(2, 4, \dots, 20\,000\) mélangées puis croissantes ; hauteurs : ABR 26 / \(9\,999\), AVL 15 / 13. \(15{,}33\) au lieu des \(16{,}10\) de la hauteur mesurée : autre tirage (graine 221 au lieu de 42). Table de hachage : \(1{,}37\) en S3 (\(n=7\,500\), \(\alpha=0{,}75\)). Sans ordre : HashMap ; avec ordre : TreeMap ; le minimum : PriorityQueue.

À retenir

À retenir

  • ABR : gauche \(<x<\) droite en tout nœud ; recherche, insertion, suppression suivent un chemin : \(\Theta(h)\).
  • Suppression : feuille détachée ; un enfant remonté ; deux enfants : la clé du successeur infixe (minimum du sous-arbre droit) remplace celle du nœud.
  • Infixe d’un ABR = clés triées ; préfixe : nœud d’abord ; postfixe : nœud en dernier.
  • Hauteur : entre \(\lfloor\log_2 n\rfloor\) et \(n-1\) ; des clés croissantes donnent une chaîne ; aléatoires, \(\sim2\ln n\) comparaisons par recherche.
  • AVL : \(|\mathrm{bf}|\leq1\), \(h\leq1{,}44\log_2(n+2)\) ; LL, RR : une rotation ; LR, RL : deux.
  • Tas : arbre complet, parent \(\leq\) enfants, dans un tableau : parent \(\lfloor(i-1)/2\rfloor\), enfants \(2i+1\), \(2i+2\) ; percolations en \(\mathcal{O}(\log n)\), minimum en \(\mathcal{O}(1)\).
  • Tri par tas : \(n\) insertions puis \(n\) extractions, \(\mathcal{O}(n\log n)\).

Prochaine séance

Séance 5 — Graphes

Les structures de cette séance rangent des données isolées. Les graphes représentent des relations : Dakar est reliée à Thiès, Thiès à Touba. La Séance 5 construit :

  • les représentations d’un graphe (matrice et listes d’adjacence) ;
  • les parcours en largeur et en profondeur, la détection de cycles, le tri topologique ;
  • les plus courts chemins de Dijkstra, avec un tas, et l’arbre couvrant minimal de Kruskal.

À préparer : le TP de la Séance 4 (SimpleBST, AVLTree et BinaryMinHeap, tests JUnit et mesures).

Références

  • Sedgewick, Wayne — Algorithms, 4e éd., Addison-Wesley, 2011 (§ 3.2 : arbres binaires de recherche ; § 2.4 : files de priorité et tri par tas).
  • Cormen, Leiserson, Rivest, Stein — Introduction to Algorithms, 4e éd., MIT Press, 2022 (ch. 6 : tas ; ch. 12 : ABR ; ch. 13 : arbres rouge-noir).
  • Knuth — The Art of Computer Programming, vol. 3 : Sorting and Searching, 2e éd., Addison-Wesley, 1998 (§ 6.2.2 et 6.2.3 : arbres équilibrés).
  • Skiena — The Algorithm Design Manual, 3e éd., Springer, 2020 (§ 3.4 : arbres binaires de recherche ; § 4.3 : tri par tas).
  • Reed — « The height of a random binary search tree », Journal of the ACM, 50(3), 2003.
  • OpenJDK — code de java.util.TreeMap (arbre rouge-noir) et de java.util.PriorityQueue (tas en tableau), JDK 21.