C4 — ArbresDans le dialogue d’impression, choisissez « Enregistrer au format PDF » comme destination.
Retour

Licence 1 · Algorithmique 2

Cours 4Arbres

Passer du linéaire au hiérarchique, et obtenir en log n ce qui coûtait n — à condition que l'arbre reste équilibré.

2 chapitres · 11 h de travail estimé

  1. 1. Arbres binaires6 h
  2. 2. Arbres de recherche et tas5 h

Chapitre 1 · 6 h

Arbres binaires

Racine, nœud, feuille, hauteur ; représentations ; parcours préfixe, infixe, suffixe et en largeur ; arbre d'expression.

Comment un compilateur sait-il que 3 + 4 * 2 vaut 11 et non 14 ? Pas en appliquant des règles de priorité au moment du calcul : en construisant un arbre où la multiplication est plus bas que l'addition. Une fois l'arbre bâti, la priorité n'existe plus — elle est devenue de la structure, et l'évaluation se fait par une récursion de trois lignes.

Les arbres sont partout dès qu'on modélise une hiérarchie : arborescence de fichiers du chapitre 7 du cours de systèmes, page web, arbre de décision de la borne inférieure des tris, arbre d'appels du chapitre 4. Ce chapitre les définit et donne les quatre façons de les parcourir ; le suivant montrera ce qu'ils font gagner.

Vocabulaire

Un arbre est un ensemble de nœuds reliés par des arêtes, tel qu'un nœud unique — la racine — n'a pas de parent, et que tout autre nœud a exactement un parent. Cette seconde condition est ce qui distingue un arbre d'un graphe quelconque, et elle interdit les cycles.

                    ●  racine, profondeur 0                 ┌──┴──┐                 ●     ●         profondeur 1              ┌──┴─┐   │              ●    ●   ●         profondeur 2                        └─┐                          ●      profondeur 3  →  hauteur de l'arbre = 3
TermeDéfinition
Feuillenœud sans enfant
Nœud internenœud ayant au moins un enfant
Profondeur d'un nœudnombre d'arêtes depuis la racine
Hauteur de l'arbreprofondeur maximale
Taillenombre de nœuds
Sous-arbreun nœud et toute sa descendance

Un arbre binaire est un arbre où chaque nœud a au plus deux enfants, distingués comme gauche et droit — et l'ordre compte : un enfant unique à gauche ne donne pas le même arbre qu'à droite.

Ici encore, la définition est récursive : un arbre binaire est vide, ou bien une racine portant deux arbres binaires. Toutes les fonctions du chapitre s'écriront donc en trois lignes, selon le patron du bloc I — cas de base sur l'arbre vide, appel sur chaque sous-arbre.

Hauteur et taille : l'inégalité qui fonde tout

Un arbre binaire de hauteur hh contient au moins h+1h+1 nœuds — le cas d'une chaîne, un enfant par niveau — et au plus 2h+112^{h+1} - 1 — le cas de l'arbre parfait, tous les niveaux remplis.

En inversant, la hauteur d'un arbre de nn nœuds vérifie :

log2(n+1)1    h    n1\log_2(n+1) - 1 \;\le\; h \;\le\; n - 1

Tout le bloc IV tient dans cet encadrement. Un arbre équilibré a une hauteur en O(logn)O(\log n) ; un arbre dégénéré a une hauteur en O(n)O(n), et il n'est plus qu'une liste chaînée déguisée. Comme la plupart des opérations coûtent O(h)O(h), l'écart entre les deux est exactement celui qui séparait le bon et le mauvais pivot du chapitre 3.

Deux représentations

Par chaînage. Un nœud est une cellule avec une valeur et deux pointeurs. C'est la cellule du chapitre 5, avec un lien de plus — et c'est bien ainsi qu'il faut voir la progression du semestre : un lien donne une liste, deux donnent un arbre, un nombre quelconque donnera un graphe.

Par tableau. Pour un arbre complet — tous les niveaux remplis sauf peut-être le dernier, comblé de gauche à droite — on peut se passer de pointeurs :

indice        0     1     2     3     4     5     6           ┌─────┬─────┬─────┬─────┬─────┬─────┬─────┐           │  A  │  B  │  C  │  D  │  E  │  F  │  G  │           └─────┴─────┴─────┴─────┴─────┴─────┴─────┘ enfants de i :  2i+1  et  2i+2          parent de i :  (i−1) / 2

Aucun pointeur, une mémoire parfaitement contiguë — donc les bénéfices de cache du chapitre 5 — et la navigation par arithmétique. La contrepartie est stricte : cela ne fonctionne que si l'arbre reste complet, faute de quoi le tableau se troue. C'est exactement la représentation du tas au chapitre suivant, et c'est ce qui explique son efficacité.

Les quatre parcours

Un parcours visite tous les nœuds une fois. Il y en a quatre, et il faut les connaître par ce qu'ils produisent, pas seulement par leur nom.

Les trois premiers sont des parcours en profondeur, et leur code est identique à une ligne près :

préfixe(A)              infixe(A)               suffixe(A)    si A vide, sortir       si A vide, sortir       si A vide, sortir    VISITER(A)              infixe(A.gauche)        suffixe(A.gauche)    préfixe(A.gauche)       VISITER(A)              suffixe(A.droit)    préfixe(A.droit)        infixe(A.droit)         VISITER(A)

C'est la position de la visite qui change, et rien d'autre. Sur cet arbre :

                +             ┌──┴──┐             3     ×                ┌──┴──┐                4     2
ParcoursRésultatCe que c'est
Préfixe+ 3 × 4 2notation polonaise
Infixe3 + 4 × 2notation usuelle
Suffixe3 4 2 × +notation postfixée du chapitre 6

Le quatrième est le parcours en largeur : on visite niveau par niveau, de gauche à droite, soit + 3 × 4 2 ici. Il ne s'écrit pas récursivement — il emploie une file, exactement celle du chapitre 6 : on enfile la racine, puis tant que la file n'est pas vide on défile un nœud, on le visite, et on enfile ses deux enfants.

Et si l'on remplace cette file par une pile, on obtient le parcours en profondeur préfixe. Le code est le même à un mot près, et c'est la remarque annoncée au chapitre précédent : la structure choisie décide de l'ordre de visite.

Quiz · 1 question

Un parcours infixe d'un arbre binaire de recherche produit une suite triée. Pourquoi, et qu'obtient-on par un parcours suffixe du même arbre ?

  • Parce que l'infixe visite les nœuds par valeur croissante ; le suffixe donne la même suite en ordre inverseordre croissant puis inverse
  • Parce que l'infixe visite tout le sous-arbre gauche — dont les valeurs sont plus petites — puis le nœud, puis le sous-arbre droit ; le suffixe ne donne AUCUN ordre particulier sur les valeurs, il rend les enfants avant leurs parentsconséquence de l'invariant
  • Parce que l'arbre a été construit dans l'ordre d'insertion, qui était triéordre d'insertion

Réponse : C'est une conséquence directe de l'invariant du chapitre suivant : tout le sous-arbre gauche est inférieur au nœud, tout le sous-arbre droit lui est supérieur. Visiter gauche, puis le nœud, puis droite énumère donc les valeurs dans l'ordre croissant — quel que soit l'ordre d'insertion, et sans aucun tri. Le suffixe, lui, visite les deux enfants AVANT leur parent : il ne produit aucun ordre sur les valeurs, mais il garantit qu'un nœud n'est traité qu'une fois sa descendance traitée. C'est exactement ce qu'il faut pour libérer un arbre en mémoire — on ne peut pas libérer un nœud avant ses enfants, sous peine de perdre les pointeurs qui y menaient — et pour évaluer un arbre d'expression, dont les opérandes doivent être calculés avant l'opération. Pour obtenir l'ordre décroissant, on inverse l'infixe : droite, nœud, gauche.

L'arbre d'expression

C'est l'application qui justifie le chapitre, et elle referme la boucle avec le chapitre 6.

Dans un arbre d'expression, les feuilles portent les opérandes et les nœuds internes les opérateurs. Sa construction résout la question des priorités une fois pour toutes : dans 3 + 4 × 2, la multiplication devient un sous-arbre de l'addition, donc elle sera évaluée avant, sans qu'aucune règle de priorité n'intervienne à l'évaluation.

L'évaluation elle-même est une récursion suffixe de quatre lignes :

fonction évaluer(A)    si A est une feuille alors retourner sa valeur    g ← évaluer(A.gauche)    d ← évaluer(A.droit)    retourner appliquer(A.opérateur, g, d)

Les deux appels doivent précéder l'application : c'est un parcours suffixe, et c'est pourquoi la notation postfixée du chapitre 6 s'évaluait si simplement à la pile. Les deux mécanismes sont le même : la pile de l'évaluateur postfixé est la pile d'appels de cette récursion.

Un compilateur fait exactement ce chemin. Il lit le texte, construit l'arbre — c'est l'analyse syntaxique — puis le parcourt pour produire les instructions machine du chapitre 6 d'architecture, en émettant les opérandes avant l'opération. La suite d'instructions add $t0, $t1, $t2 que vous y écriviez à la main est la sortie d'un parcours suffixe.

Quiz · 1 question

On veut afficher les nœuds d'un arbre niveau par niveau, de gauche à droite. Quelle structure employer, et pourquoi le parcours récursif habituel ne convient-il pas ?

  • Une pile : c'est ce qu'utilise déjà la récursion, il suffit de la rendre expliciteune pile
  • Une FILE : on enfile la racine, puis on défile un nœud, on le visite et on enfile ses enfants. La récursion descend en profondeur avant de passer au frère, elle ne peut donc pas produire un ordre par niveauxune file
  • Un tableau trié par profondeur, calculé en deux passesdeux passes

Réponse : La récursion suit la pile d'appels : elle plonge dans le sous-arbre gauche jusqu'aux feuilles avant de toucher au sous-arbre droit. C'est exactement l'inverse d'un parcours par niveaux, qui demande de traiter tous les nœuds de profondeur k avant ceux de profondeur k+1. La FILE donne ce comportement gratuitement : quand on défile un nœud de profondeur k, ses enfants sont ajoutés DERRIÈRE tous les nœuds de profondeur k encore en attente, donc ils ne seront traités qu'après eux. Remplacer cette file par une pile dans le même code produit un parcours en profondeur préfixe — c'est le meilleur exemple du semestre de ce que le choix d'une structure décide à lui seul.

À vous

L'exercice construit un arbre d'expression, puis met en évidence que les trois parcours en profondeur ne diffèrent que d'une ligne : vous écrirez une seule fonction paramétrée par la position de la visite, et vous obtiendrez les trois notations.

Ensuite l'évaluation, puis le parcours en largeur à la file — et enfin la manipulation qui convainc : remplacer la file par une pile dans le même code, et constater que le parcours devient un parcours en profondeur.

Le squelette contient aussi le calcul de la hauteur et de la taille, deux récursions de deux lignes qui servent d'échauffement, et dont vous vérifierez qu'elles respectent l'encadrement posé plus haut.

Exercice de code

Écrivez les trois parcours en une fonction, évaluez l'arbre, puis échangez file et pile.

Point de départ

const noeud = (valeur, gauche = null, droit = null) => ({ valeur, gauche, droit });

// L'arbre de  3 + 4 * 2  : la multiplication est PLUS BAS que l'addition,
// donc elle sera évaluée avant. La priorité est devenue de la structure.
const EXPR = noeud("+", noeud(3), noeud("*", noeud(4), noeud(2)));

// ── Les trois parcours en profondeur, en une seule fonction ───────────────
// position vaut "prefixe", "infixe" ou "suffixe".
function profondeur(A, position, sortie = []) {
  if (A === null) return sortie;
  if (position === "prefixe") sortie.push(A.valeur);
  profondeur(A.gauche, position, sortie);
  // ← à écrire : la visite infixe se place ICI
  profondeur(A.droit, position, sortie);
  // ← et la visite suffixe LÀ
  return sortie;
}

// ── Hauteur et taille ─────────────────────────────────────────────────────
function hauteur(A) {
  if (A === null) return -1;   // un arbre vide a une hauteur de −1
  return 0;   // ← à écrire : 1 + le max des hauteurs des deux sous-arbres
}

function taille(A) {
  if (A === null) return 0;
  return 1 + taille(A.gauche) + taille(A.droit);
}

// ── Évaluation ────────────────────────────────────────────────────────────
function evaluer(A) {
  if (A.gauche === null && A.droit === null) return A.valeur;
  const g = evaluer(A.gauche);
  const d = evaluer(A.droit);
  const ops = { "+": (a, b) => a + b, "-": (a, b) => a - b,
                "*": (a, b) => a * b, "/": (a, b) => a / b };
  return ops[A.valeur](g, d);
}

// ── Parcours en largeur, et son jumeau ────────────────────────────────────
// Une SEULE différence entre les deux : on retire par le début ou par la fin.
function parcours(A, parLeDebut) {
  const attente = [A], sortie = [];
  while (attente.length > 0) {
    const n = parLeDebut ? attente.shift() : attente.pop();
    sortie.push(n.valeur);
    if (n.gauche) attente.push(n.gauche);
    if (n.droit) attente.push(n.droit);
  }
  return sortie;
}

// ── À VOUS ────────────────────────────────────────────────────────────────
// 1. Complétez profondeur() et hauteur().
// 2. Vérifiez l'encadrement : log2(n+1) − 1 ≤ h ≤ n − 1.
// 3. Comparez parcours(A, true) et parcours(A, false). Lequel est en
//    largeur ? Lequel retrouve un parcours en profondeur, et lequel ?

console.log("préfixe :", profondeur(EXPR, "prefixe").join(" "));
console.log("évaluation :", evaluer(EXPR), "(et non 14)");

Solution

const noeud = (valeur, gauche = null, droit = null) => ({ valeur, gauche, droit });

const EXPR = noeud("+", noeud(3), noeud("*", noeud(4), noeud(2)));
// (1 + 2) * (8 - 3) : les parenthèses de l'écriture usuelle disparaissent,
// elles sont dans la forme de l'arbre.
const EXPR2 = noeud("*", noeud("+", noeud(1), noeud(2)), noeud("-", noeud(8), noeud(3)));

function profondeur(A, position, sortie = []) {
  if (A === null) return sortie;
  if (position === "prefixe") sortie.push(A.valeur);
  profondeur(A.gauche, position, sortie);
  if (position === "infixe") sortie.push(A.valeur);
  profondeur(A.droit, position, sortie);
  if (position === "suffixe") sortie.push(A.valeur);
  return sortie;
}

function hauteur(A) {
  if (A === null) return -1;
  return 1 + Math.max(hauteur(A.gauche), hauteur(A.droit));
}

function taille(A) {
  if (A === null) return 0;
  return 1 + taille(A.gauche) + taille(A.droit);
}

function evaluer(A) {
  if (A.gauche === null && A.droit === null) return A.valeur;
  // Les DEUX appels précèdent l'application : c'est un parcours suffixe, et
  // c'est pourquoi la notation postfixée s'évalue si simplement à la pile.
  const g = evaluer(A.gauche);
  const d = evaluer(A.droit);
  const ops = { "+": (a, b) => a + b, "-": (a, b) => a - b,
                "*": (a, b) => a * b, "/": (a, b) => a / b };
  return ops[A.valeur](g, d);
}

function parcours(A, parLeDebut) {
  const attente = [A], sortie = [];
  while (attente.length > 0) {
    // shift() = défiler une FILE (le plus ancien) -> parcours en largeur.
    // pop()   = dépiler une PILE (le plus récent) -> parcours en profondeur.
    const n = parLeDebut ? attente.shift() : attente.pop();
    sortie.push(n.valeur);
    if (n.gauche) attente.push(n.gauche);
    if (n.droit) attente.push(n.droit);
  }
  return sortie;
}

for (const [nom, A] of [["3 + 4 * 2", EXPR], ["(1 + 2) * (8 - 3)", EXPR2]]) {
  console.log("— " + nom + " —");
  console.log("   préfixe (polonaise)  : " + profondeur(A, "prefixe").join(" "));
  console.log("   infixe  (usuelle)    : " + profondeur(A, "infixe").join(" "));
  console.log("   suffixe (postfixée)  : " + profondeur(A, "suffixe").join(" "));
  console.log("   évaluation           : " + evaluer(A));
  const n = taille(A), h = hauteur(A);
  const bas = Math.log2(n + 1) - 1;
  console.log("   taille " + n + ", hauteur " + h +
    "  | encadrement " + bas.toFixed(2) + " <= " + h + " <= " + (n - 1) +
    (bas <= h && h <= n - 1 ? "  ok" : "  X"));
  console.log("");
}

console.log("— même code, deux structures d'attente —");
console.log("   avec une FILE  (shift) : " + parcours(EXPR2, true).join(" ") + "   -> en largeur, niveau par niveau");
console.log("   avec une PILE  (pop)   : " + parcours(EXPR2, false).join(" ") + "   -> en profondeur");
// Une seule ligne change, et l'ordre de visite bascule complètement. C'est
// la leçon du bloc III : la structure de données ne sert pas seulement à
// ranger, elle décide de l'algorithme.

console.log("");
console.log("— arbre dégénéré : le pire cas —");
// Une chaîne : chaque nœud n'a qu'un enfant droit.
let chaine = noeud(5);
for (let i = 4; i >= 1; i--) chaine = noeud(i, null, chaine);
console.log("   taille " + taille(chaine) + ", hauteur " + hauteur(chaine) +
            "  -> h = n − 1, l'arbre EST une liste chaînée");

En travaux pratiques

Travaux pratiques 7 · 3 h

Quatre parcours, quatre usages

Implémenter un arbre binaire et ses parcours, puis constater que l'ORDRE de visite n'est pas un détail : chaque parcours résout un problème que les autres ne résolvent pas.

Avant de commencer

  • Le TP 6 : pile et file
  • Le TP 1 : récursivité et trace indentée

Énoncé

  1. Construire à la mainDéfinissez le nœud et construisez un arbre de sept nœuds explicitement, sans fonction d'insertion. Dessinez-le sur papier à côté.
  2. Les trois parcours en profondeurÉcrivez préfixe, infixe et suffixe. Exécutez les trois sur votre arbre et notez les trois suites de valeurs. Reliez chaque suite à votre dessin.
  3. Le parcours en largeurÉcrivez le parcours par niveaux avec la file du TP 6. Vérifiez qu'il donne bien l'ordre de lecture de votre dessin, ligne par ligne.
  4. Hauteur et tailleÉcrivez le calcul de la hauteur, du nombre de nœuds et du nombre de feuilles. Chacune tient en trois lignes récursives.
  5. Choisir le bon parcoursPour chacune de ces tâches, dites lequel des quatre parcours convient, et pourquoi : libérer l'arbre, l'afficher indenté, le recopier, afficher une expression arithmétique dans l'ordre naturel. Indice : Pour libérer, réfléchissez à ce qu'on ne peut pas faire avant d'avoir traité les enfants.
  6. Un arbre d'expressionConstruisez l'arbre de l'expression correspondant à trois plus quatre, le tout multiplié par deux. Exécutez les trois parcours et reliez chacun à une notation connue.
  7. L'évaluerÉcrivez l'évaluation récursive de l'arbre d'expression. Comparez avec l'évaluateur postfixé du TP 6.
  8. Sans récursionRéécrivez le parcours infixe avec une pile explicite. Comparez la longueur du code et la profondeur maximale supportée.

C'est réussi quand

  • Vos trois suites correspondent exactement à votre dessin
  • Vous justifiez le parcours suffixe pour la libération, sans hésiter
  • Le parcours infixe de votre arbre d'expression redonne la notation habituelle

Correction

Les quatre parcoursarbre.c
void prefixe(Noeud *n)  { if(!n) return; visiter(n); prefixe(n->g);  prefixe(n->d); }
void infixe(Noeud *n)   { if(!n) return; infixe(n->g);  visiter(n); infixe(n->d); }
void suffixe(Noeud *n)  { if(!n) return; suffixe(n->g); suffixe(n->d); visiter(n); }

void largeur(Noeud *racine) {
  File f; file_init(&f); enfiler(&f, racine);
  while (!file_vide(&f)) {
      Noeud *n = defiler(&f);
      visiter(n);
      if (n->g) enfiler(&f, n->g);
      if (n->d) enfiler(&f, n->d);
  }
}

Les trois premiers ne diffèrent que par la POSITION de l'appel à visiter : avant, entre, après. Une seule ligne déplacée, trois algorithmes. Le quatrième n'est pas récursif du tout — il utilise la file du TP 6, et c'est exactement le même code que le parcours en largeur du labyrinthe.

À quoi sert chacun
préfixe  : racine d'abord
→ recopier un arbre, l'afficher indenté, le sérialiser

infixe   : gauche, racine, droite
→ sur un ABR, donne les valeurs TRIÉES (TP 8)
→ sur un arbre d'expression, donne la notation infixe

suffixe  : enfants d'abord
→ LIBÉRER (on ne peut pas free un nœud avant ses enfants :
  on perdrait les pointeurs)
→ calculer une valeur qui dépend des sous-arbres (hauteur, taille)

largeur  : par niveaux
→ plus court chemin, affichage par étage, sérialisation compacte

Le cas de la libération est le plus parlant : libérer la racine d'abord détruit les seuls pointeurs menant aux enfants, et tout le sous-arbre fuit. Le parcours suffixe n'est donc pas une préférence de style, c'est le seul correct — et ce raisonnement « les enfants d'abord » vaut pour tout calcul remontant.

L'arbre d'expression
        (*)
     /   \
   (+)    2
  /   \
 3     4

préfixe : * + 3 4 2      notation POLONAISE (préfixée)
infixe  : 3 + 4 * 2      notation habituelle — mais SANS parenthèses !
suffixe : 3 4 + 2 *      notation POLONAISE INVERSE (postfixée)

Les trois notations arithmétiques sont les trois parcours du même arbre. Et le parcours infixe perd de l'information : « 3 + 4 * 2 » relu naïvement donne 11, pas 14. L'arbre porte la structure, la notation infixe ne la porte qu'avec des parenthèses — c'est pourquoi les compilateurs travaillent sur l'arbre et non sur le texte.

Hauteur, taille, feuilles
int hauteur(Noeud *n) {
  if (!n) return -1;                      /* -1 : arbre vide */
  int hg = hauteur(n->g), hd = hauteur(n->d);
  return 1 + (hg > hd ? hg : hd);
}

int taille(Noeud *n)  { return n ? 1 + taille(n->g) + taille(n->d) : 0; }
int feuilles(Noeud *n) {
  if (!n) return 0;
  if (!n->g && !n->d) return 1;
  return feuilles(n->g) + feuilles(n->d);
}

Trois lignes chacune, et toutes trois sont des parcours SUFFIXES : la valeur du nœud dépend de celles de ses enfants, donc on descend d'abord. La convention hauteur d'un arbre vide égale −1 fait que la hauteur d'une feuille vaut 0 ; l'autre convention est valable aussi, à condition de s'y tenir partout.

L'infixe sans récursion
void infixe_iteratif(Noeud *racine) {
  Pile p; pile_init(&p);
  Noeud *n = racine;
  while (n || !pile_vide(&p)) {
      while (n) { empiler(&p, n); n = n->g; }   /* descendre à gauche */
      n = depiler(&p);
      visiter(n);
      n = n->d;                                  /* puis à droite */
  }
}

Neuf lignes contre une, pour le même résultat. La pile explicite remplace exactement la pile d'appels du TP 1 — c'est la dérécursivation du TP 2 appliquée à un arbre. On ne l'écrit que si la profondeur peut être grande, autrement dit sur un arbre potentiellement dégénéré, ce que le TP 8 va justement produire.

Ce que la suite en fait

Le chapitre 8 impose un invariant sur les valeurs — un ordre pour l'arbre binaire de recherche, une priorité pour le tas — et c'est cet invariant qui transforme une structure de rangement en structure de recherche. On y verra que l'accès en O(logn)O(\log n) est conditionnel : il suppose que la hauteur reste logarithmique, ce que l'encadrement de ce chapitre ne garantit pas du tout.

La représentation par tableau y trouvera son emploi : le tas est précisément l'arbre complet pour lequel elle a été inventée, et c'est ce qui permet un tri en nlognn \log n sur place, la combinaison que ni le tri fusion ni le tri rapide n'offraient.

À retenir

Flashcards · 5 cartes

Quel encadrement relie hauteur et taille d'un arbre binaire, et pourquoi est-il central ?
Un arbre de hauteur h a au moins h+1 nœuds (une chaîne) et au plus 2^(h+1) − 1 (arbre parfait). Donc pour n nœuds : log₂(n+1) − 1 ≤ h ≤ n − 1. Central parce que la plupart des opérations coûtent O(h) : un arbre ÉQUILIBRÉ donne O(log n), un arbre DÉGÉNÉRÉ est une liste chaînée déguisée en O(n). C'est le même écart qu'entre bon et mauvais pivot au chapitre 3.
Comment représenter un arbre binaire par tableau, et à quelle condition ?
Les enfants du nœud d'indice i sont en 2i+1 et 2i+2, son parent en (i−1)/2. Aucun pointeur, mémoire contiguë (donc bénéfices de cache) et navigation par arithmétique. Condition stricte : l'arbre doit rester COMPLET — tous les niveaux remplis sauf le dernier, comblé de gauche à droite — sans quoi le tableau se troue. C'est la représentation du tas.
Quelle est la seule différence entre les parcours préfixe, infixe et suffixe ?
La POSITION DE LA VISITE dans le corps de la fonction, les deux appels récursifs restant identiques. Préfixe : visiter, gauche, droite — donne la notation polonaise. Infixe : gauche, visiter, droite — donne la notation usuelle, et l'ordre croissant sur un ABR. Suffixe : gauche, droite, visiter — donne la notation postfixée, et garantit qu'un nœud est traité après sa descendance (indispensable pour libérer un arbre ou évaluer une expression).
Comment fait-on un parcours en largeur, et que se passe-t-il si l'on change la structure ?
Avec une FILE : on enfile la racine, puis tant qu'elle n'est pas vide on défile un nœud, on le visite et on enfile ses enfants. Les enfants d'un nœud de profondeur k passent derrière tous les nœuds de profondeur k encore en attente, d'où l'ordre par niveaux. Remplacer la file par une PILE dans le même code donne un parcours en profondeur préfixe : c'est la structure, pas l'algorithme, qui décide de l'ordre de visite.
Qu'est-ce qu'un arbre d'expression, et que résout-il ?
Les feuilles portent les opérandes, les nœuds internes les opérateurs. Il résout les PRIORITÉS une fois pour toutes : dans 3 + 4 × 2, la multiplication devient un sous-arbre de l'addition, donc elle est évaluée avant sans qu'aucune règle n'intervienne. L'évaluation est une récursion suffixe — évaluer les deux enfants, puis appliquer l'opérateur — et c'est pourquoi la notation postfixée s'évalue si simplement à la pile : les deux mécanismes sont le même.

Chapitre 2 · 5 h

Arbres de recherche et tas

ABR : insertion, recherche, suppression, dégénérescence et équilibrage en survol ; tas binaire, file de priorité et tri par tas.

Le chapitre 7 a donné une structure de rangement. Il lui manque ce qui fait l'intérêt d'un arbre : un invariant sur les valeurs. Deux invariants différents produisent deux structures aux usages opposés, et c'est tout ce chapitre.

L'arbre binaire de recherche ordonne de gauche à droite : il répond à « cette valeur est-elle là ? » en O(logn)O(\log n). Le tas ordonne de haut en bas : il répond à « quelle est la plus petite valeur ? » en O(1)O(1), et permet de la retirer en O(logn)O(\log n).

Aucun des deux ne fait le travail de l'autre, et c'est le point à retenir : un tas ne sait pas chercher, un ABR ne sait pas donner son minimum rapidement.

L'arbre binaire de recherche

L'invariant d'ABR porte sur tout nœud, sans exception : toutes les valeurs de son sous-arbre gauche lui sont inférieures, toutes celles de son sous-arbre droit lui sont supérieures.

                 8              ┌──┴──┐              3     10           ┌──┴─┐     └──┐           1    6        14              ┌─┴┐     ┌─┘              4  7    13

L'erreur classique est de croire qu'il suffit de comparer un nœud à ses enfants immédiats. L'invariant porte sur tout le sous-arbre : placer un 9 comme enfant gauche du 10 violerait la propriété, puisque 9 est supérieur à 8 et se trouverait dans son sous-arbre gauche.

La recherche exploite l'invariant pour éliminer la moitié de l'arbre à chaque comparaison : si la valeur cherchée est inférieure au nœud, elle ne peut être qu'à gauche. C'est la dichotomie du chapitre 4, appliquée à une structure chaînée, et son coût est O(h)O(h).

L'insertion suit le même chemin et accroche la nouvelle valeur là où la recherche a échoué, c'est-à-dire toujours comme feuille. C'est ce qui la rend simple : aucune restructuration.

La suppression est la seule opération délicate, avec ses trois cas :

  • Feuille : on la détache, c'est tout.
  • Un seul enfant : on remplace le nœud par cet enfant, qui remonte avec son sous-arbre.
  • Deux enfants : on ne peut détacher ni remplacer directement. On cherche le successeur — la plus petite valeur du sous-arbre droit, obtenue en descendant tout à gauche —, on copie sa valeur dans le nœud à supprimer, et l'on supprime le successeur, qui par construction a au plus un enfant. On se ramène donc toujours à l'un des deux premiers cas.

Le successeur convient parce qu'il est la plus petite valeur supérieure au nœud : le placer là préserve exactement l'invariant, à gauche comme à droite.

La dégénérescence

Toutes ces opérations coûtent O(h)O(h), et le chapitre 7 a montré que hh va de log2n\log_2 n à n1n-1. Il reste à savoir dans quel cas on tombe.

Or le pire cas n'a rien d'exotique. Insérer des données déjà triées produit une chaîne : chaque valeur étant supérieure à toutes les précédentes, elle part systématiquement à droite, et l'arbre devient une liste chaînée avec un pointeur inutilisé par nœud.

insertion de 1, 2, 3, 4, 5 dans cet ordre    1    └─ 2        └─ 3            └─ 4                └─ 5        hauteur 4 pour 5 nœuds : recherche en O(n)

C'est exactement le pire cas du tri rapide du chapitre 3, et pour la même raison de fond : une structure qui repose sur une division en deux moitiés s'effondre quand la division est systématiquement déséquilibrée. Et dans les deux cas, les données triées — le cas le plus fréquent en pratique — sont précisément celles qui déclenchent le désastre.

La parade porte un nom : l'équilibrage automatique. Les arbres AVL et rouge-noir maintiennent une hauteur en O(logn)O(\log n) garantie, en effectuant après chaque insertion ou suppression des rotations — des réarrangements locaux de trois nœuds qui préservent l'invariant tout en réduisant la hauteur.

        3                          2       ╱          rotation        ╱ ╲      2          ─────────►      1   3    1

Le principe suffit à ce niveau : le coût est une constante ajoutée à chaque modification, en échange d'une garantie qui transforme un O(n)O(n) possible en O(logn)O(\log n) certain. C'est ce que font les TreeMap de Java, les map de C++ et les index de bases de données — sous une variante à plus de deux enfants, l'arbre B, conçue pour minimiser les accès disque du chapitre 7 du cours de systèmes.

Quiz · 1 question

On insère les entiers de 1 à 1000 dans l'ordre croissant dans un arbre binaire de recherche non équilibré, puis on cherche la valeur 1000. Combien de comparaisons ?

  • Environ 10, soit log₂(1000) : c'est la promesse d'un arbre de recherchelog n
  • 1000 : chaque valeur étant supérieure à toutes les précédentes, l'arbre est une chaîne descendant à droite, et la recherche parcourt tous les nœudsn
  • Environ 500, la moitié des nœuds en moyennen/2

Réponse : C'est la dégénérescence, et le cas est loin d'être théorique — les données triées sont extrêmement fréquentes. Chaque nouvelle valeur étant supérieure à toutes les précédentes, l'insertion part à droite à chaque comparaison et se pose comme enfant droit du dernier inséré. L'arbre obtenu est une CHAÎNE de hauteur 999, c'est-à-dire une liste chaînée dont chaque nœud gaspille un pointeur gauche. La recherche de 1000 descend donc les mille niveaux. La promesse en log n n'est PAS une propriété de l'ABR : c'est une propriété des arbres ÉQUILIBRÉS, et il faut un mécanisme — rotations AVL ou rouge-noir — pour l'obtenir. C'est la même leçon qu'au chapitre 3 avec le pivot du tri rapide.

Le tas binaire

Le tas (heap) répond à un autre besoin : non pas « où est cette valeur ? » mais « quelle est la plus prioritaire ? ».

Son invariant est vertical. Dans un tas min, tout nœud est inférieur ou égal à ses deux enfants. Rien n'est imposé entre frères, ni entre branches — c'est un ordre beaucoup plus faible que celui de l'ABR, et c'est ce qui le rend bon marché à maintenir.

Deux conséquences immédiates. Le minimum est à la racine, donc accessible en O(1)O(1). Et comme aucun ordre n'est imposé horizontalement, on peut exiger en plus que l'arbre soit complet — ce qui autorise la représentation par tableau du chapitre 7, sans un seul pointeur.

        2                    indice   0   1   2   3   4   5      ┌─┴─┐                          ┌───┬───┬───┬───┬───┬───┐      4   3                          │ 2 │ 4 │ 3 │ 9 │ 7 │ 5 │    ┌─┴┐  └┐                         └───┴───┴───┴───┴───┴───┘    9  7   5                enfants de i : 2i+1 et 2i+2

Deux opérations suffisent, et elles sont symétriques.

Insérer : on place la valeur à la première case libre — la fin du tableau —, ce qui garde l'arbre complet mais casse peut-être l'invariant. On la fait alors remonter tant qu'elle est inférieure à son parent. Au plus h=log2nh = \log_2 n échanges.

Extraire le minimum : on prend la racine, on met le dernier élément à sa place — pour garder l'arbre complet —, puis on le fait descendre en l'échangeant à chaque étape avec le plus petit de ses enfants, tant qu'il est plus grand. Au plus log2n\log_2 n échanges là aussi.

C'est la file de priorité : une file où l'on ne sort pas le plus ancien mais le plus prioritaire. Le cours de systèmes en avait besoin sans la nommer — l'ordonnancement par priorités du chapitre 4 est exactement cela — et le chapitre 9 s'en servira pour l'algorithme de Dijkstra.

Le tri par tas

De là découle un troisième tri en nlognn \log n, et il complète le tableau du chapitre 3.

On construit un tas avec les nn valeurs, puis on extrait le minimum nn fois : les valeurs sortent triées. Chaque extraction coûte O(logn)O(\log n), d'où O(nlogn)O(n \log n).

Sa singularité est d'être sur place. On construit le tas dans le tableau lui-même, et chaque valeur extraite se range à la place libérée à la fin — d'où l'usage d'un tas max pour obtenir un ordre croissant.

FusionRapidePar tas
Pire casnlognn \log nn2n^2nlognn \log n
MémoireO(n)O(n)O(logn)O(\log n)O(1)O(1)
Stableouinonnon

Le tri par tas est donc le seul à être à la fois garanti en nlognn \log n et sans mémoire supplémentaire. Pourquoi n'est-il pas le tri par défaut ? Parce qu'il est plus lent en pratique que le tri rapide : ses accès sautent d'un indice ii à 2i+12i+1, donc à travers tout le tableau, ce qui ruine la localité spatiale du chapitre 7 d'architecture. Le tri rapide, lui, avance séquentiellement.

D'où sa place réelle, annoncée au chapitre 3 : c'est le filet de sécurité d'introsort. On trie rapide, et si la profondeur de récursion dérape — signe d'un mauvais pivot — on bascule sur le tri par tas, qui n'a pas de pire cas. On obtient la vitesse de l'un et la garantie de l'autre.

Quiz · 1 question

Vous devez maintenir en permanence l'élément le plus prioritaire d'un ensemble où l'on insère et retire sans cesse. Un ABR équilibré ou un tas ?

  • Un ABR équilibré : il donne aussi le minimum, et il sait en plus rechercher une valeur quelconqueABR équilibré
  • Un tas : le minimum est à la racine en O(1) et son invariant, beaucoup plus faible, est bien moins coûteux à maintenir — l'ABR n'est utile que si l'on cherche aussi des valeurs quelconquestas
  • Un tableau trié : l'insertion y est certes O(n), mais le minimum est immédiattableau trié

Réponse : Les deux structures font le travail, mais le tas le fait moins cher. Son invariant est purement VERTICAL — un nœud est inférieur à ses enfants, rien n'est imposé entre frères — donc une insertion ne demande qu'une remontée le long d'une branche, sans rotation ni restructuration. L'ABR, lui, impose un ordre total lisible par parcours infixe, ce qui coûte des rotations à chaque modification pour rester équilibré. Le tas gagne aussi sur la mémoire : arbre complet, donc tableau contigu, aucun pointeur. Le seul argument en faveur de l'ABR serait de devoir AUSSI chercher une valeur quelconque — chose qu'un tas fait en O(n), puisque rien n'oriente la descente. Le tableau trié, lui, paie O(n) à chaque insertion par le décalage.

À vous

L'exercice construit les deux structures, et l'essentiel est dans la mesure.

Pour l'ABR : insérer mille valeurs aléatoires, mesurer la hauteur, puis insérer les mêmes valeurs triées et mesurer à nouveau. L'écart entre une dizaine et mille est l'argument le plus convaincant du chapitre en faveur de l'équilibrage.

Pour le tas : écrire la remontée et la descente, vérifier l'invariant après chaque opération — une fonction de vérification est fournie, servez-vous-en, c'est ainsi qu'on débogue une structure — puis en tirer un tri par tas et compter ses comparaisons.

Exercice de code

Mesurez la dégénérescence d'un ABR, puis écrivez la remontée d'un tas et le tri par tas.

Point de départ

// ── Arbre binaire de recherche ────────────────────────────────────────────
const noeud = (v) => ({ v, g: null, d: null });

function inserer(A, v) {
  if (A === null) return noeud(v);
  if (v < A.v) A.g = inserer(A.g, v);
  else if (v > A.v) A.d = inserer(A.d, v);
  return A;   // les doublons sont ignorés
}

function chercher(A, v, comparaisons = { n: 0 }) {
  while (A !== null) {
    comparaisons.n++;
    if (v === A.v) return comparaisons.n;
    A = v < A.v ? A.g : A.d;
  }
  return -comparaisons.n;   // négatif : absent, après n comparaisons
}

function hauteur(A) { return A === null ? -1 : 1 + Math.max(hauteur(A.g), hauteur(A.d)); }

// L'invariant porte sur TOUT le sous-arbre, pas sur les enfants immédiats :
// on transmet donc un intervalle autorisé, qui se resserre à la descente.
function estUnAbr(A, min = -Infinity, max = Infinity) {
  if (A === null) return true;
  if (A.v <= min || A.v >= max) return false;
  return estUnAbr(A.g, min, A.v) && estUnAbr(A.d, A.v, max);
}

function infixe(A, sortie = []) {
  if (A === null) return sortie;
  infixe(A.g, sortie); sortie.push(A.v); infixe(A.d, sortie);
  return sortie;
}

// ── Tas min, par tableau ──────────────────────────────────────────────────
function creerTas() {
  const T = [];
  const parent = (i) => Math.floor((i - 1) / 2);

  function remonter(i) {
    // ← à écrire : tant que T[i] est inférieur à son parent, échanger
  }

  function descendre(i) {
    while (true) {
      const g = 2 * i + 1, d = 2 * i + 2;
      let plusPetit = i;
      if (g < T.length && T[g] < T[plusPetit]) plusPetit = g;
      if (d < T.length && T[d] < T[plusPetit]) plusPetit = d;
      if (plusPetit === i) return;
      [T[i], T[plusPetit]] = [T[plusPetit], T[i]];
      i = plusPetit;
    }
  }

  return {
    inserer(v) { T.push(v); remonter(T.length - 1); },
    extraireMin() {
      if (T.length === 0) return undefined;
      const min = T[0];
      const dernier = T.pop();
      if (T.length > 0) { T[0] = dernier; descendre(0); }
      return min;
    },
    taille: () => T.length,
    contenu: () => [...T],
    // L'outil de débogage d'une structure : vérifier l'invariant.
    invariantOk() {
      for (let i = 1; i < T.length; i++) if (T[parent(i)] > T[i]) return false;
      return true;
    },
  };
}

// ── À VOUS ────────────────────────────────────────────────────────────────
// 1. Écrivez remonter().
// 2. Mesurez la hauteur d'un ABR de 1000 valeurs aléatoires, puis triées.
// 3. Écrivez triParTas(tableau) : tout insérer, tout extraire.

let A = null;
for (const v of [8, 3, 10, 1, 6, 14, 4, 7, 13]) A = inserer(A, v);
console.log("infixe :", infixe(A).join(" "), "| est un ABR :", estUnAbr(A));
console.log("hauteur :", hauteur(A));

Solution

const noeud = (v) => ({ v, g: null, d: null });

function inserer(A, v) {
  if (A === null) return noeud(v);
  if (v < A.v) A.g = inserer(A.g, v);
  else if (v > A.v) A.d = inserer(A.d, v);
  return A;
}

function chercher(A, v) {
  let n = 0;
  while (A !== null) {
    n++;
    if (v === A.v) return n;
    A = v < A.v ? A.g : A.d;
  }
  return -n;
}

function hauteur(A) { return A === null ? -1 : 1 + Math.max(hauteur(A.g), hauteur(A.d)); }

function estUnAbr(A, min = -Infinity, max = Infinity) {
  if (A === null) return true;
  if (A.v <= min || A.v >= max) return false;
  return estUnAbr(A.g, min, A.v) && estUnAbr(A.d, A.v, max);
}

function infixe(A, sortie = []) {
  if (A === null) return sortie;
  infixe(A.g, sortie); sortie.push(A.v); infixe(A.d, sortie);
  return sortie;
}

function creerTas() {
  const T = [];
  const parent = (i) => Math.floor((i - 1) / 2);

  function remonter(i) {
    // Symétrique exacte de descendre : tant que l'invariant est violé avec
    // le parent, on échange et on remonte d'un niveau. Au plus log2(n) tours.
    while (i > 0 && T[i] < T[parent(i)]) {
      const p = parent(i);
      [T[i], T[p]] = [T[p], T[i]];
      i = p;
    }
  }

  function descendre(i) {
    while (true) {
      const g = 2 * i + 1, d = 2 * i + 2;
      let plusPetit = i;
      if (g < T.length && T[g] < T[plusPetit]) plusPetit = g;
      if (d < T.length && T[d] < T[plusPetit]) plusPetit = d;
      if (plusPetit === i) return;
      [T[i], T[plusPetit]] = [T[plusPetit], T[i]];
      i = plusPetit;
    }
  }

  return {
    inserer(v) { T.push(v); remonter(T.length - 1); },
    extraireMin() {
      if (T.length === 0) return undefined;
      const min = T[0];
      const dernier = T.pop();
      if (T.length > 0) { T[0] = dernier; descendre(0); }
      return min;
    },
    taille: () => T.length,
    contenu: () => [...T],
    invariantOk() {
      for (let i = 1; i < T.length; i++) if (T[parent(i)] > T[i]) return false;
      return true;
    },
  };
}

function triParTas(tableau) {
  const tas = creerTas();
  for (const v of tableau) tas.inserer(v);
  const sortie = [];
  while (tas.taille() > 0) sortie.push(tas.extraireMin());
  return sortie;
}

let A = null;
for (const v of [8, 3, 10, 1, 6, 14, 4, 7, 13]) A = inserer(A, v);
console.log("infixe  :", infixe(A).join(" "), "  (trié, sans avoir trié)");
console.log("est ABR :", estUnAbr(A), "| hauteur", hauteur(A));

console.log("");
console.log("— l'argument du chapitre : hauteur selon l'ordre d'insertion —");
const N = 1000;
const valeurs = Array.from({ length: N }, (_, i) => i);
const melange = [...valeurs].sort(() => Math.random() - 0.5);

for (const [nom, source] of [["aléatoire", melange], ["déjà triée", valeurs]]) {
  let R = null;
  for (const v of source) R = inserer(R, v);
  const h = hauteur(R);
  const c = Math.abs(chercher(R, N - 1));
  console.log("   insertion " + nom.padEnd(12) + " -> hauteur " + String(h).padStart(4) +
    " | chercher " + (N - 1) + " coûte " + String(c).padStart(4) + " comparaisons" +
    "   (log2(" + N + ") ≈ " + Math.round(Math.log2(N)) + ")");
}
// Un facteur cent sur la même structure et les mêmes valeurs : seul l'ordre
// d'insertion change. C'est pourquoi les bibliothèques n'exposent jamais
// d'ABR nu, mais des arbres auto-équilibrés.

console.log("");
console.log("— tas —");
const tas = creerTas();
for (const v of [9, 4, 7, 1, 8, 3, 2]) {
  tas.inserer(v);
  if (!tas.invariantOk()) console.log("   INVARIANT CASSÉ après insertion de " + v);
}
console.log("   contenu du tableau :", tas.contenu().join(" "), " (pas trié, et ce n'est pas le but)");
console.log("   invariant tenu     :", tas.invariantOk());
const extraits = [];
while (tas.taille() > 0) extraits.push(tas.extraireMin());
console.log("   extractions        :", extraits.join(" "), " (triées, elles)");

console.log("");
console.log("tri par tas :", triParTas([5, 2, 9, 1, 7, 3]).join(" "));

En travaux pratiques

Travaux pratiques 8 · 3 h

L'arbre qui dégénère, et le tas qui n'en a pas le droit

Constater qu'un arbre de recherche peut perdre tout son intérêt sur une entrée ordinaire, puis implémenter le tas, qui garantit sa forme par construction.

Avant de commencer

  • Le TP 7 : arbres et parcours
  • Le TP 3 : mesures comparatives

Énoncé

  1. L'arbre de rechercheImplémentez insertion, recherche et parcours infixe. Insérez mille valeurs aléatoires et vérifiez que le parcours infixe donne une suite triée.
  2. Mesurer la hauteurMesurez la hauteur après insertion de 1000, 10 000 et 100 000 valeurs aléatoires. Comparez au logarithme en base deux de n.
  3. Le faire dégénérerInsérez les mêmes valeurs, mais TRIÉES. Mesurez à nouveau la hauteur et le temps de recherche. Dessinez l'arbre obtenu pour cinq valeurs. Indice : Vous venez de construire une liste chaînée avec deux fois plus de pointeurs.
  4. La suppressionÉcrivez la suppression, en traitant les trois cas : feuille, un enfant, deux enfants. Le troisième est le seul difficile.
  5. Le tasImplémentez un tas binaire dans un TABLEAU, avec insertion et extraction du minimum. Vérifiez l'invariant après chaque opération.
  6. Pourquoi un tableauÉcrivez les formules donnant père, fils gauche et fils droit à partir d'un indice. Expliquez pourquoi elles ne fonctionnent que sur un arbre complet.
  7. Le tri par tasÉcrivez le tri par tas et comparez-le au tri rapide du TP 3, sur entrée aléatoire ET sur entrée triée.
  8. La file de prioritéEmballez votre tas dans une file de priorité avec une valeur et une priorité. Vous l'utiliserez telle quelle au TP 9.

C'est réussi quand

  • Votre parcours infixe donne une suite triée sur mille insertions aléatoires
  • Vous exhibez l'entrée qui rend votre arbre de hauteur n − 1
  • Votre tas maintient son invariant, vérifié automatiquement après chaque opération
  • Le tri par tas ne dégénère PAS sur l'entrée triée, contrairement au tri rapide

Correction

La hauteur mesurée
n         aléatoire   log2(n)   TRIÉE
1 000        21          10          999
10 000       31          13        9 999
100 000      42          17       99 999

recherche sur 100 000, entrée triée : 100 000 comparaisons
au lieu de 17

Sur entrée aléatoire, la hauteur vaut environ 2,2 fois log2(n) : le comportement est bon sans aucune garantie. Sur entrée triée, l'arbre devient une liste — et l'entrée triée n'est pas un cas tordu, c'est le cas le plus courant qui soit : des identifiants croissants, des dates, une reprise de sauvegarde.

La suppression, et son cas difficile
Noeud *supprimer(Noeud *n, int v) {
  if (!n) return NULL;
  if (v < n->valeur)      n->g = supprimer(n->g, v);
  else if (v > n->valeur) n->d = supprimer(n->d, v);
  else {
      if (!n->g) { Noeud *d = n->d; free(n); return d; }   /* 0 ou 1 fils */
      if (!n->d) { Noeud *g = n->g; free(n); return g; }
      /* DEUX fils : remplacer par le successeur infixe */
      Noeud *s = n->d;
      while (s->g) s = s->g;          /* le plus petit du sous-arbre droit */
      n->valeur = s->valeur;
      n->d = supprimer(n->d, s->valeur);
  }
  return n;
}

Le cas à deux fils est le seul délicat : on ne peut pas simplement raccrocher, il faut choisir un remplaçant qui préserve la propriété d'ordre. Le successeur infixe convient parce qu'il est, par construction, plus grand que tout le sous-arbre gauche et plus petit que le reste du droit. Le prédécesseur conviendrait tout aussi bien.

Le tas dans un tableau
pour un nœud d'indice i (à partir de 0) :
  père      = (i - 1) / 2
  fils gauche = 2i + 1
  fils droit  = 2i + 2

t = [1, 3, 5, 7, 9, 8]
              1
            /   \
           3     5
          / \   /
         7   9 8

Aucun pointeur, aucune allocation : la structure de l'arbre est dans l'ARITHMÉTIQUE des indices. Cela n'est possible que parce qu'un tas est un arbre COMPLET — aucun trou —, ce qui est garanti par construction : on insère toujours à la première place libre. Un arbre de recherche, lui, a des trous, et ne peut pas être rangé ainsi.

Insertion et extraction
void inserer(Tas *t, int v) {
  int i = t->n++;
  t->d[i] = v;
  while (i > 0 && t->d[(i-1)/2] > t->d[i]) {     /* remonter */
      echanger(&t->d[i], &t->d[(i-1)/2]);
      i = (i-1)/2;
  }
}

int extraire_min(Tas *t) {
  int min = t->d[0];
  t->d[0] = t->d[--t->n];
  int i = 0;
  while (1) {                                     /* redescendre */
      int g = 2*i+1, d = 2*i+2, p = i;
      if (g < t->n && t->d[g] < t->d[p]) p = g;
      if (d < t->n && t->d[d] < t->d[p]) p = d;
      if (p == i) break;
      echanger(&t->d[i], &t->d[p]);
      i = p;
  }
  return min;
}

Les deux opérations parcourent une seule branche : O(log n) garanti, sans cas moyen ni pire cas distincts. L'invariant du tas est plus FAIBLE que celui de l'ABR — il n'ordonne qu'entre père et fils, pas entre frères — et c'est exactement ce qui permet de le maintenir en gardant l'arbre complet. Moins de garanties, mais garanties toujours.

Le tri par tas, et pourquoi il ne dégénère pas
n = 1 000 000      aléatoire   TRIÉE
tri rapide          0,10 s      24 s (ou plantage)
tri par tas         0,18 s      0,18 s
tri fusion          0,14 s      0,12 s

le tri par tas est O(n log n) dans TOUS les cas,
sur place, sans mémoire supplémentaire

Presque deux fois plus lent que le tri rapide en moyenne — mauvaise localité, beaucoup de sauts dans le tableau — et jamais catastrophique. C'est pourquoi la bibliothèque standard du C++ utilise l'introsort : tri rapide par défaut, bascule vers le tri par tas si la profondeur dépasse 2 log n. On obtient la vitesse du premier avec la garantie du second.

La file de priorité
typedef struct { int sommet; int priorite; } Element;

/* même tas, comparaison sur .priorite */
void   fp_inserer(FilePrio *f, int sommet, int priorite);
Element fp_extraire_min(FilePrio *f);
int     fp_vide(const FilePrio *f);

C'est la structure dont Dijkstra a besoin au TP 9 : extraire à chaque étape le sommet non traité le plus proche. Avec un tas, chaque extraction coûte log n au lieu de n — ce qui fait passer l'algorithme de O(n²) à O((n+m) log n), et rend calculable un réseau de plusieurs centaines de milliers de sommets.

Ce que la suite en fait

Le bloc V généralise une dernière fois. Un arbre est un graphe sans cycle et avec une racine ; retirer ces deux contraintes donne l'objet du chapitre 9, où les parcours du chapitre 7 reviennent — mais avec une difficulté nouvelle, puisqu'un graphe peut ramener sur un sommet déjà visité et qu'il faut marquer.

Le tas y jouera un rôle précis. L'algorithme de Dijkstra a besoin, à chaque étape, du sommet non traité le plus proche : c'est une extraction de minimum, et c'est le passage d'une recherche linéaire à une file de priorité qui fait descendre son coût.

À retenir

Flashcards · 6 cartes

Énoncez l'invariant d'un ABR, et l'erreur classique.
Pour TOUT nœud : toutes les valeurs de son sous-arbre gauche lui sont inférieures, toutes celles de son sous-arbre droit lui sont supérieures. L'erreur classique est de ne comparer qu'aux enfants IMMÉDIATS : l'invariant porte sur tout le sous-arbre. Conséquence utile : un parcours infixe énumère les valeurs dans l'ordre croissant, quel que soit l'ordre d'insertion.
Quels sont les trois cas de la suppression dans un ABR ?
FEUILLE : on la détache. UN ENFANT : on remplace le nœud par cet enfant, qui remonte avec son sous-arbre. DEUX ENFANTS : on cherche le SUCCESSEUR — la plus petite valeur du sous-arbre droit, en descendant tout à gauche —, on copie sa valeur dans le nœud, et on supprime le successeur, qui a au plus un enfant par construction. Le successeur convient parce qu'il est la plus petite valeur supérieure au nœud : l'invariant est préservé des deux côtés.
Pourquoi un ABR dégénère-t-il, et quelle est la parade ?
Parce que les opérations coûtent O(h) et que h va de log₂ n à n−1. Insérer des données DÉJÀ TRIÉES produit une chaîne : chaque valeur part à droite, l'arbre devient une liste avec un pointeur perdu par nœud. C'est le pire cas du tri rapide, pour la même raison — une division systématiquement déséquilibrée. Parade : l'équilibrage automatique (AVL, rouge-noir) par ROTATIONS, réarrangements locaux qui préservent l'invariant et garantissent O(log n).
Quel est l'invariant d'un tas min, et qu'autorise sa faiblesse ?
Tout nœud est inférieur ou égal à ses DEUX ENFANTS. Rien n'est imposé entre frères ni entre branches : c'est un ordre bien plus faible que celui de l'ABR. Conséquences : le minimum est à la racine en O(1), et comme aucun ordre horizontal n'est requis, on peut exiger en plus que l'arbre soit COMPLET — d'où la représentation par tableau, sans un seul pointeur, avec les enfants de i en 2i+1 et 2i+2.
Décrivez insertion et extraction dans un tas.
INSÉRER : placer la valeur à la première case libre (la fin du tableau) pour garder l'arbre complet, puis la faire REMONTER tant qu'elle est inférieure à son parent — au plus log₂ n échanges. EXTRAIRE LE MINIMUM : prendre la racine, y mettre le DERNIER élément pour rester complet, puis le faire DESCENDRE en l'échangeant avec le plus petit de ses enfants tant qu'il est plus grand — au plus log₂ n échanges. C'est la file de priorité.
Pourquoi le tri par tas n'est-il pas le tri par défaut, alors qu'il est garanti n log n et sur place ?
Parce qu'il est plus lent en pratique que le tri rapide : ses accès sautent de l'indice i à 2i+1, donc à travers tout le tableau, ce qui ruine la localité spatiale, alors que le tri rapide avance séquentiellement. Sa place réelle est celle de FILET DE SÉCURITÉ dans introsort : on trie rapide, et si la profondeur de récursion dérape — signe d'un mauvais pivot — on bascule sur le tri par tas, qui n'a pas de pire cas.