C3 — Structures de données linéairesDans le dialogue d’impression, choisissez « Enregistrer au format PDF » comme destination.
Retour

Licence 1 · Algorithmique 2

Cours 3Structures de données linéaires

Cesser de tout mettre dans un tableau : choisir une structure d'après les opérations qu'on va lui demander, et payer le bon prix.

2 chapitres · 12 h de travail estimé

  1. 1. Listes chaînées7 h
  2. 2. Piles et files5 h

Chapitre 1 · 7 h

Listes chaînées

Cellule et chaînage, listes simplement et doublement chaînées, insertion, suppression, parcours ; coûts comparés au tableau ; listes circulaires.

Insérer une valeur en tête d'un tableau d'un million d'éléments demande de décaler un million de cases. L'opération est simple, correcte, et coûte O(n)O(n) — refaite dans une boucle, elle donne un algorithme quadratique là où l'on attendait du linéaire.

La même insertion dans une liste chaînée coûte trois affectations, quelle que soit la taille de la liste.

C'est le premier chapitre du semestre où l'on ne conçoit plus un algorithme mais où l'on choisit une structure. Le tableau d'Algorithmique 1 n'était pas un mauvais choix : c'était le seul disponible. À partir d'ici, chaque structure se juge sur le profil des opérations qu'on va lui demander.

La cellule et le chaînage

Une liste chaînée est faite de cellules dispersées en mémoire, chacune contenant une valeur et l'adresse de la suivante.

tête┌────┬───┐   ┌────┬───┐   ┌────┬───┐   ┌────┬─────┐│ 12 │ ●─┼──►│  7 │ ●─┼──►│ 43 │ ●─┼──►│  5 │ nul │└────┴───┘   └────┴───┘   └────┴───┘   └────┴─────┘

Deux différences essentielles avec le tableau, et tout en découle.

Les cellules ne sont pas contiguës. Chacune est allouée séparément, n'importe où en mémoire ; seul le chaînage les relie. On ne peut donc pas calculer l'adresse du ii-ième élément — il faut suivre les liens depuis la tête.

La taille n'est pas fixée. Ajouter un élément, c'est allouer une cellule ; en retirer un, c'est libérer la sienne. Aucun redimensionnement, aucune recopie.

Un point mérite d'être souligné, parce qu'il relie ce chapitre au bloc I : une liste est un objet récursif. Une liste est vide, ou bien une cellule suivie d'une liste. Toutes ses opérations s'écrivent donc naturellement de deux façons, itérative et récursive — et la version récursive coûte O(n)O(n) de pile, ce qui la disqualifie sur les longues listes.

Le coût des opérations

Voici le tableau que le chapitre doit installer. Chaque ligne se justifie par le schéma ci-dessus.

OpérationTableauListe simplement chaînée
Accès au ii-ièmeO(1)O(1)O(n)O(n)
Insertion en têteO(n)O(n)O(1)O(1)
Insertion en queueO(1)O(1) amortiO(n)O(n), ou O(1)O(1) avec pointeur de queue
Insertion après une cellule connueO(n)O(n)O(1)O(1)
Suppression d'une cellule connueO(n)O(n)O(1)O(1), si l'on a la précédente
Recherche d'une valeurO(n)O(n)O(n)O(n)
Mémoire par élémentla valeurla valeur et un pointeur

L'insertion en tête tient en deux lignes, et il faut les écrire dans cet ordre :

nouvelle.suivant ← tête        ← d'abord raccrocher la suitetête ← nouvelle                ← puis déplacer la tête

Inverser les deux lignes perd toute la liste : tête pointerait sur la nouvelle cellule, dont le champ suivant pointerait sur… elle-même ou sur rien, et les anciennes cellules deviendraient inatteignables. C'est la faute la plus fréquente du chapitre, et le remède est toujours le même : dessiner les flèches avant d'écrire le code.

Deux lignes du tableau méritent un commentaire.

Suppression « si l'on a la précédente ». Retirer une cellule demande de modifier le champ suivant de celle qui la précède. Or dans une liste simplement chaînée, on ne peut pas remonter : disposer de la cellule à supprimer ne suffit pas, il faut avoir gardé la précédente pendant le parcours. C'est ce qui justifie la liste doublement chaînée.

Mémoire par élément. Un pointeur coûte 8 octets sur une machine 64 bits. Une liste d'entiers de 4 octets consomme donc trois fois la mémoire du tableau équivalent, alignement compris. Ce n'est pas un détail sur de gros volumes.

Listes contre tableaux : la vraie réponse

Le tableau de complexités suggère un partage net. La pratique le corrige, et c'est un des points où l'analyse asymptotique seule induit en erreur.

Le chapitre 7 d'architecture a montré pourquoi. Un tableau est contigu : le parcourir exploite parfaitement la localité spatiale, une ligne de cache rapportant seize entiers d'un coup. Les cellules d'une liste sont dispersées : chaque saut est potentiellement un défaut de cache, soit des dizaines de cycles perdus.

Le résultat est contre-intuitif et solidement mesuré : pour parcourir, chercher ou même insérer au milieu, un tableau bat souvent une liste, y compris quand la complexité annonce le contraire. Le décalage de mémoire d'un tableau est une opération séquentielle que le processeur exécute très vite ; la traversée d'une liste pour trouver le point d'insertion est une suite d'accès aléatoires.

La liste garde trois territoires où elle est indiscutable. Quand on insère et supprime beaucoup à des positions déjà connues — sans avoir à les chercher. Quand les éléments sont gros, car les déplacer coûte alors cher et le pointeur devient négligeable. Et quand les éléments doivent être partagés entre plusieurs structures sans être copiés.

La conclusion à retenir n'est pas « la liste est dépassée », c'est : la complexité asymptotique départage les ordres de grandeur, pas les constantes — et sur les petites et moyennes tailles, ce sont les constantes qui décident.

Quiz · 1 question

Pourquoi la suppression d'une cellule dont on connaît l'adresse coûte-t-elle O(n) dans une liste simplement chaînée, alors qu'elle ne demande qu'une affectation ?

  • Parce qu'il faut libérer la mémoire, ce qui est une opération linéairelibération mémoire
  • Parce que l'affectation porte sur le champ suivant de la cellule PRÉCÉDENTE, qu'on ne peut pas atteindre depuis la cellule à supprimer : il faut la retrouver en repartant de la têteon ne remonte pas
  • Parce qu'il faut décaler toutes les cellules suivantes d'un crandécalage

Réponse : Retirer une cellule, c'est faire pointer la précédente sur la suivante : une seule affectation. Le problème est d'ATTEINDRE cette précédente. Dans une liste simplement chaînée, les flèches ne vont que dans un sens : depuis la cellule à supprimer, il est impossible de remonter. Il faut donc reparcourir depuis la tête, d'où le O(n). Deux remèdes : garder la précédente pendant le parcours qui a mené à la cellule — c'est ce qu'on fait toujours en pratique — ou passer à une liste DOUBLEMENT chaînée, où chaque cellule connaît sa précédente et où la suppression redevient O(1). Le décalage, lui, n'existe pas dans une liste : c'est le mécanisme du tableau.

Doublement chaînée, circulaire, avec sentinelle

Trois variantes répondent chacune à une gêne précise.

La liste doublement chaînée ajoute à chaque cellule un pointeur vers la précédente. On peut alors parcourir dans les deux sens et surtout supprimer une cellule connue en O(1)O(1). Le prix : un pointeur de plus par cellule, et deux fois plus de liens à maintenir — chaque insertion et chaque suppression met à jour quatre champs au lieu de deux, ce qui multiplie les occasions de se tromper.

La liste circulaire fait pointer la dernière cellule sur la première. Il n'y a plus de fin, seulement un point d'entrée. C'est la structure des files d'attente cycliques et de l'ordonnancement en tourniquet du cours de systèmes : le parcours revient naturellement au premier après le dernier, sans test de fin.

La sentinelle est la plus utile en pratique et la moins connue des étudiants. On ajoute une cellule bidon en tête, qui ne contient aucune donnée et qu'on ne supprime jamais. Son intérêt est de supprimer tous les cas particuliers : la liste n'est jamais vide, il existe toujours une cellule précédente, et l'insertion en tête devient une insertion ordinaire au milieu. Le code perd ses si liste est vide et ses si c'est le premier élément — soit l'essentiel de ses bogues.

Ce qu'une liste rend possible

Deux usages, qui annoncent la suite du semestre.

Une liste est le support naturel d'une pile et d'une file : c'est le chapitre suivant, et l'implémentation y tient en quelques lignes puisque toutes les opérations portent sur les extrémités.

Et le chaînage se généralise. Une cellule qui porte deux pointeurs au lieu d'un n'est plus une liste mais un arbre binaire — c'est le chapitre 7. Une cellule qui en porte un nombre quelconque donne un graphe, au chapitre 9. Les trois blocs qui restent ne font que faire varier le nombre de flèches sortantes.

Quiz · 1 question

Un programme parcourt un million d'entiers stockés soit dans un tableau, soit dans une liste chaînée. Les deux parcours sont en O(n). Lequel est le plus rapide en pratique, et pourquoi ?

  • Ils sont équivalents : la complexité est la même, donc le temps aussiéquivalents
  • Le tableau, nettement : ses éléments sont contigus, donc une ligne de cache en rapporte seize d'un coup, tandis que chaque cellule de liste est un accès potentiellement disperséle tableau, par le cache
  • La liste, car elle n'a pas besoin de calculer d'indice à chaque étapela liste

Réponse : La complexité asymptotique compte les opérations, pas leur coût unitaire — et ici les coûts unitaires diffèrent d'un ordre de grandeur. Le tableau est contigu : le principe de localité spatiale du chapitre 7 d'architecture joue à plein, et une seule ligne de cache de 64 octets rapporte seize entiers. Les cellules d'une liste sont allouées séparément et se retrouvent dispersées : chaque saut peut être un défaut de cache, soit des dizaines de cycles. En pratique, l'écart va couramment de 3 à 10 fois sur un simple parcours. Ce n'est pas une objection à la complexité — les deux restent O(n), et sur un milliard d'éléments les deux échouent également — mais un rappel que Θ départage les ORDRES DE GRANDEUR, pas les constantes.

À vous

L'exercice construit une liste chaînée complète, puis attaque l'exercice d'entretien classique : inverser une liste, d'abord récursivement, ensuite itérativement avec trois pointeurs. Le second est difficile la première fois, et il est formateur pour une raison précise — il ne se résout pas en réfléchissant au code, il se résout en dessinant les trois flèches et en se demandant laquelle déplacer d'abord.

Le squelette contient une insertion en tête dont les deux lignes sont dans le mauvais ordre, et le test le fera voir immédiatement : la liste ne contiendra plus qu'un élément.

Exercice de code

Réparez l'insertion en tête, écrivez la suppression, puis inversez la liste de deux façons.

Point de départ

// Une cellule : une valeur, un lien. Rien d'autre.
const cellule = (valeur, suivant = null) => ({ valeur, suivant });

function creerListe() {
  return { tete: null, taille: 0 };
}

// ── Insertion en tête ─────────────────────────────────────────────────────
function insererEnTete(L, valeur) {
  const n = cellule(valeur);
  // ← ces deux lignes sont dans le MAUVAIS ordre : la liste sera perdue
  L.tete = n;
  n.suivant = L.tete;
  L.taille++;
  return L;
}

function versTexte(L) {
  const bouts = [];
  let c = L.tete, garde = 0;
  while (c !== null && garde++ < 50) { bouts.push(c.valeur); c = c.suivant; }
  return (bouts.length ? bouts.join(" -> ") : "(vide)") + " -> nul";
}

// ── Suppression de la première occurrence ─────────────────────────────────
function supprimer(L, valeur) {
  let precedente = null, c = L.tete;
  while (c !== null && c.valeur !== valeur) { precedente = c; c = c.suivant; }
  if (c === null) return false;
  // ← à écrire : décrocher c, en distinguant le cas où c est la tête
  L.taille--;
  return true;
}

// ── Inversion récursive ───────────────────────────────────────────────────
// Une liste est vide, ou une cellule suivie d'une liste : l'inversion
// s'écrit donc récursivement, au prix de O(n) de pile.
function inverserRec(c) {
  if (c === null || c.suivant === null) return c;
  const nouvelleTete = inverserRec(c.suivant);
  c.suivant.suivant = c;   // la suivante pointe désormais sur moi
  c.suivant = null;        // et moi sur rien : je deviens la dernière
  return nouvelleTete;
}

// ── Inversion itérative, trois pointeurs ──────────────────────────────────
function inverserIter(L, tracer) {
  let precedente = null, courante = L.tete;
  while (courante !== null) {
    // ← à écrire. Dessinez d'abord : precedente <- courante  suivante
    //   Quelle flèche déplacer en premier sans perdre la suite ?
    break;
  }
  L.tete = precedente;
  return L;
}

// ── À VOUS ────────────────────────────────────────────────────────────────
// 1. Remettez insererEnTete dans le bon ordre.
// 2. Écrivez supprimer, sans oublier le cas où la cellule est la tête.
// 3. Écrivez inverserIter, et faites afficher l'état à chaque tour.

const L = creerListe();
for (const v of [5, 43, 7, 12]) insererEnTete(L, v);
console.log("liste :", versTexte(L), "| taille", L.taille);

Solution

const cellule = (valeur, suivant = null) => ({ valeur, suivant });
function creerListe() { return { tete: null, taille: 0 }; }

function insererEnTete(L, valeur) {
  const n = cellule(valeur);
  // D'ABORD raccrocher la suite, ENSUITE déplacer la tête. Dans l'autre
  // ordre, l'ancienne liste devient inatteignable.
  n.suivant = L.tete;
  L.tete = n;
  L.taille++;
  return L;
}

function versTexte(L) {
  const bouts = [];
  let c = L.tete, garde = 0;
  while (c !== null && garde++ < 50) { bouts.push(c.valeur); c = c.suivant; }
  return (bouts.length ? bouts.join(" -> ") : "(vide)") + " -> nul";
}

function supprimer(L, valeur) {
  let precedente = null, c = L.tete;
  while (c !== null && c.valeur !== valeur) { precedente = c; c = c.suivant; }
  if (c === null) return false;
  // Deux cas, et c'est exactement ce qu'une sentinelle ferait disparaître.
  if (precedente === null) L.tete = c.suivant;   // c'était la tête
  else precedente.suivant = c.suivant;            // cas général
  L.taille--;
  return true;
}

function inverserRec(c) {
  if (c === null || c.suivant === null) return c;
  const nouvelleTete = inverserRec(c.suivant);
  c.suivant.suivant = c;
  c.suivant = null;
  return nouvelleTete;
}

function inverserIter(L, tracer) {
  let precedente = null, courante = L.tete;
  while (courante !== null) {
    // L'ordre est imposé par une contrainte : dès qu'on écrit dans
    // courante.suivant, la suite est perdue. On la SAUVE donc d'abord.
    const suivante = courante.suivant;   // 1. sauver la suite
    courante.suivant = precedente;       // 2. retourner la flèche
    precedente = courante;               // 3. avancer les deux repères
    courante = suivante;
    if (tracer) {
      console.log("   inversé jusqu'ici : " +
        versTexte({ tete: precedente }) + "   reste : " + versTexte({ tete: courante }));
    }
  }
  L.tete = precedente;
  return L;
}

const L = creerListe();
for (const v of [5, 43, 7, 12]) insererEnTete(L, v);
console.log("liste       :", versTexte(L), "| taille", L.taille);

console.log("supprimer 7 :", supprimer(L, 7), "->", versTexte(L));
console.log("supprimer 12:", supprimer(L, 12), "->", versTexte(L), " (c'était la tête)");
console.log("supprimer 99:", supprimer(L, 99), "->", versTexte(L), " (absente)");

console.log("");
console.log("— inversion itérative, pas à pas —");
const M = creerListe();
for (const v of [4, 3, 2, 1]) insererEnTete(M, v);
console.log("   départ : " + versTexte(M));
inverserIter(M, true);
console.log("   arrivée : " + versTexte(M));

console.log("");
const N = creerListe();
for (const v of [4, 3, 2, 1]) insererEnTete(N, v);
N.tete = inverserRec(N.tete);
console.log("inversion récursive :", versTexte(N), " (O(n) de pile, contre O(1))");

// Les deux inversions font le même travail. La récursive est plus courte à
// écrire et consomme un cadre par élément : sur une liste d'un million de
// cellules, elle fait déborder la pile. L'itérative tient dans trois
// variables — et c'est le cas typique où l'itération l'emporte, la liste
// étant un objet récursif mais de profondeur LINÉAIRE (chapitre 1).

En travaux pratiques

Travaux pratiques 5 · 3 h

La première structure de votre bibliothèque

Implémenter une liste chaînée complète, en subir les bogues classiques, et mesurer précisément là où elle bat le tableau et là où elle perd.

Avant de commencer

  • Les TP 7 et 8 de Programmation : pointeurs et allocation
  • valgrind

Énoncé

  1. Le type et les basesDéfinissez le maillon et écrivez création, insertion en tête, affichage, longueur, libération. Vérifiez à valgrind qu'aucun octet ne fuit.
  2. Insérer à la finÉcrivez l'insertion en queue. Comparez son coût à celui de l'insertion en tête, en nombre de maillons parcourus.
  3. SupprimerÉcrivez la suppression d'une valeur. Traitez explicitement les trois cas : liste vide, valeur en tête, valeur au milieu. Testez les trois. Indice : La suppression en tête est le cas qu'on oublie, parce qu'il faut modifier le pointeur de l'appelant.
  4. Le double pointeurRéécrivez la suppression avec un pointeur sur pointeur, de façon à supprimer les trois cas particuliers. Comparez les deux versions.
  5. InverserInversez la liste sur place, sans allouer. Faites-le en itératif, puis en récursif, et vérifiez les deux sur une liste vide et à un élément.
  6. Le cycleCréez volontairement une liste circulaire et lancez votre affichage. Écrivez ensuite la détection de cycle avec deux parcours de vitesse différente.
  7. Mesurer contre le tableauComparez liste et tableau dynamique sur : insertion en tête, insertion en queue, accès au n sur deux, parcours complet. Cent mille éléments.
  8. Le résultat qui surprendSur le parcours complet, la liste est nettement plus lente malgré une complexité identique. Expliquez, en vous appuyant sur un TP d'Architecture.

C'est réussi quand

  • valgrind annonce zéro fuite après votre libération
  • Votre suppression par double pointeur n'a aucun cas particulier
  • Vous détectez un cycle sans allouer de mémoire supplémentaire
  • Vous expliquez pourquoi le parcours d'une liste est plus lent que celui d'un tableau

Correction

Les basesliste.c
typedef struct Maillon { int valeur; struct Maillon *suivant; } Maillon;

Maillon *inserer_tete(Maillon *tete, int v) {
  Maillon *m = malloc(sizeof *m);
  if (!m) return NULL;             /* le retour est TESTÉ */
  m->valeur = v;
  m->suivant = tete;
  return m;                        /* la nouvelle tête */
}

void liberer(Maillon *tete) {
  while (tete) {
      Maillon *suivant = tete->suivant;   /* AVANT le free */
      free(tete);
      tete = suivant;
  }
}

La ligne qui compte dans liberer est la sauvegarde du suivant AVANT la libération : lire tete->suivant après free est une utilisation après libération, que valgrind détecte et que le programme fait souvent semblant de tolérer. C'est le bogue numéro un de ce TP.

Le double pointeur
/* version classique : trois cas */
if (!*tete) return;
if ((*tete)->valeur == v) { Maillon *m = *tete; *tete = m->suivant;
                          free(m); return; }
Maillon *p = *tete;
while (p->suivant && p->suivant->valeur != v) p = p->suivant;
if (p->suivant) { Maillon *m = p->suivant; p->suivant = m->suivant; free(m); }

/* version double pointeur : AUCUN cas particulier */
void supprimer(Maillon **p, int v) {
  while (*p) {
      if ((*p)->valeur == v) {
          Maillon *m = *p;
          *p = m->suivant;
          free(m);
          return;
      }
      p = &(*p)->suivant;
  }
}

p pointe sur le CHAMP à modifier, qu'il s'agisse de la variable tete ou du champ suivant d'un maillon. La distinction entre « le premier » et « les autres » disparaît, parce qu'elle n'existait que dans notre façon de nommer. C'est l'idiome le plus élégant du C sur les listes, et il vaut la peine d'être compris ligne à ligne.

L'inversion
Maillon *inverser(Maillon *tete) {
  Maillon *precedent = NULL;
  while (tete) {
      Maillon *suivant = tete->suivant;   /* sauvegarder */
      tete->suivant = precedent;          /* retourner */
      precedent = tete;                   /* avancer */
      tete = suivant;
  }
  return precedent;
}

Trois pointeurs, et l'ordre des quatre lignes ne souffre aucune permutation : intervertir les deux premières perd le reste de la liste. Testez systématiquement sur la liste vide et sur un seul élément — ce sont les deux cas que la boucle traite correctement par construction, et que l'on casse dès qu'on ajoute un cas particulier inutile.

Détecter un cycle
int a_un_cycle(Maillon *tete) {
  Maillon *lent = tete, *rapide = tete;
  while (rapide && rapide->suivant) {
      lent = lent->suivant;
      rapide = rapide->suivant->suivant;
      if (lent == rapide) return 1;
  }
  return 0;
}

Algorithme du lièvre et de la tortue : en O(n) de temps et O(1) de mémoire. S'il y a un cycle, le rapide finit toujours par rattraper le lent, puisqu'il gagne une position par tour. C'est la solution de référence, et la question la plus posée en entretien sur les listes — parce qu'elle vérifie qu'on sait raisonner sur un invariant plutôt que mémoriser du code.

Les mesures
100 000 éléments        liste       tableau dynamique
insertion en tête       0,004 s     2,8 s      ← décalage de tout
insertion en queue      12,4 s      0,003 s    ← parcours complet
                      (0,004 s avec pointeur de queue)
accès à l'élément n/2   0,9 ms      < 1 ns
parcours complet        0,0031 s    0,0004 s   ← MÊME complexité

La liste gagne massivement sur l'insertion en tête, perd sur l'accès indexé, et un simple pointeur de queue gardé à jour supprime son seul autre défaut. Le tableau des complexités prédit correctement les trois premières lignes — et pas du tout la quatrième.

Pourquoi le parcours est huit fois plus lent
tableau : éléments CONTIGUS
une ligne de cache de 64 octets = 16 entiers d'un coup
→ 1 défaut de cache pour 16 éléments

liste : maillons DISPERSÉS dans le tas
chaque maillon est ailleurs, imprévisible pour le préchargeur
→ 1 défaut de cache par élément, et 16 octets par maillon
  dont 8 pour le pointeur

Même complexité O(n), huit fois plus lent : c'est le TP 7 d'Architecture qui revient, et c'est la raison pour laquelle la liste chaînée, omniprésente dans les cours, est rare dans le code performant. On la choisit pour ses garanties — insertion en O(1) sans réallocation, pointeurs stables — pas pour sa vitesse de parcours.

Ce que la suite en fait

Le chapitre 6 pose deux structures qui ne sont que des listes bridées : une pile et une file n'autorisent les opérations qu'aux extrémités, et cette restriction est précisément ce qui les rend utiles — elle garantit un coût constant et donne à la structure une sémantique claire.

Le chapitre 8 du cours de programmation implémentera tout cela en C, la même semaine : la cellule y devient une struct avec un champ pointeur, et l'allocation d'une cellule un appel à malloc. C'est là que le chaînage cesse d'être un schéma au tableau pour devenir des adresses réelles — et que le pointeur pendant guette celui qui libère une cellule avant d'avoir lu son champ suivant.

À retenir

Flashcards · 5 cartes

Quelles sont les deux différences fondamentales entre une liste chaînée et un tableau ?
Les cellules NE SONT PAS CONTIGUËS : chacune est allouée n'importe où, seul le chaînage les relie, donc on ne peut pas calculer l'adresse du i-ième élément — il faut suivre les liens depuis la tête, d'où l'accès en O(n). Et la TAILLE N'EST PAS FIXÉE : ajouter, c'est allouer une cellule ; retirer, c'est en libérer une. Aucun redimensionnement, aucune recopie.
Écrivez l'insertion en tête, et dites ce qui arrive si l'on inverse les deux lignes.
nouvelle.suivant ← tête, PUIS tête ← nouvelle. Dans cet ordre : on raccroche d'abord la suite, on déplace ensuite la tête. Inversées, tête pointerait sur la nouvelle cellule dont le champ suivant ne pointerait plus sur l'ancienne liste : toutes les cellules deviennent inatteignables, la liste est perdue. Remède systématique : dessiner les flèches avant d'écrire le code.
Pourquoi supprimer une cellule connue coûte-t-il O(n) en simplement chaîné, et quels sont les remèdes ?
L'affectation à faire porte sur le champ suivant de la cellule PRÉCÉDENTE, et les flèches ne vont que dans un sens : on ne peut pas remonter, il faut reparcourir depuis la tête. Remèdes : garder la précédente pendant le parcours qui a mené à la cellule (ce qu'on fait toujours), ou passer en DOUBLEMENT chaînée, où la suppression redevient O(1) au prix d'un pointeur de plus et de quatre champs à mettre à jour au lieu de deux.
À quoi sert une cellule sentinelle ?
C'est une cellule bidon placée en tête, sans donnée et jamais supprimée. Elle SUPPRIME TOUS LES CAS PARTICULIERS : la liste n'est jamais vide, il existe toujours une cellule précédente, et l'insertion en tête devient une insertion ordinaire au milieu. Le code perd ses « si la liste est vide » et ses « si c'est le premier élément » — c'est-à-dire l'essentiel de ses bogues.
Quand une liste bat-elle vraiment un tableau, et pourquoi le tableau gagne-t-il souvent ?
Le tableau gagne souvent parce qu'il est CONTIGU : le parcours exploite la localité spatiale, une ligne de cache rapportant seize entiers, alors que chaque cellule de liste est un accès dispersé — d'où un écart de 3 à 10 fois sur un parcours. La liste reste indiscutable quand on insère et supprime beaucoup à des positions DÉJÀ CONNUES, quand les éléments sont GROS (les déplacer coûterait cher), et quand ils doivent être partagés sans copie.

Chapitre 2 · 5 h

Piles et files

LIFO et FIFO, implémentation par tableau et par liste ; évaluation d'expressions, parenthésage, pile d'appels, files d'attente.

Le bouton « annuler » d'un traitement de texte, le bouton « précédent » d'un navigateur, la pile d'appels du chapitre 1 : trois mécanismes qui font la même chose. Ils gardent une suite d'éléments et n'en rendent qu'un seul, le dernier arrivé.

À l'autre bout, la file d'impression, la file des processus prêts du cours de systèmes, la file d'attente d'un guichet : mêmes éléments gardés, mais c'est le premier arrivé qui sort.

Ces deux structures sont des listes volontairement bridées : elles n'autorisent les opérations qu'aux extrémités. La restriction paraît appauvrissante ; c'est elle qui fait toute leur valeur, pour deux raisons — elle garantit un coût constant, et elle donne à la structure une sémantique que le lecteur du code comprend immédiatement.

La pile

Une pile (stack) obéit à la règle LIFO : dernier entré, premier sorti. Trois opérations, toutes en O(1)O(1).

empiler(x)    pose x au sommetdépiler()     retire et rend l'élément du sommetsommet()      rend l'élément du sommet sans le retirerestVide()

L'image est celle d'une pile d'assiettes : on ne prend que celle du dessus, et il n'existe aucune opération pour atteindre le milieu. Toute tentative de contourner cette limite est le signe qu'on avait besoin d'une autre structure.

Deux implémentations, et le choix est moins évident qu'il n'y paraît.

Par tableau : un tableau et un indice sommet. Empiler écrit en T[sommet] et incrémente ; dépiler décrémente. Extrêmement rapide — accès contigus, aucune allocation — mais la capacité est bornée, sauf à redimensionner en doublant la taille, ce qui donne un coût amorti constant.

Par liste chaînée : la tête de liste est le sommet. Empiler est une insertion en tête, dépiler une suppression en tête — les deux opérations à O(1)O(1) du chapitre 5. Aucune limite de capacité, mais un pointeur par élément et des accès dispersés.

En pratique on choisit le tableau, pour la raison de cache du chapitre précédent, sauf si la taille maximale est réellement imprévisible.

La file

Une file (queue) obéit à la règle FIFO : premier entré, premier sorti.

enfiler(x)    ajoute x en queuedéfiler()     retire et rend l'élément de tête

L'implémentation par liste chaînée est immédiate à condition de garder deux pointeurs, tête et queue : on défile en tête et on enfile en queue, les deux en O(1)O(1). Sans pointeur de queue, enfiler redeviendrait O(n)O(n).

L'implémentation par tableau, elle, pose un problème instructif. Si l'on défile en avançant un indice de tête, l'espace libéré au début du tableau n'est jamais réutilisé : la file « dérive » vers la droite et finit par déborder alors que le tableau est presque vide.

La solution est le tampon circulaire : les indices reviennent à zéro après la dernière case, par un modulo.

capacité 6, tête = 4, nombre = 4        0     1     2     3     4     5    ┌─────┬─────┬─────┬─────┬─────┬─────┐    │  C  │  D  │     │     │  A  │  B  │    └─────┴─────┴─────┴─────┴─────┴─────┘                             ▲tête    enfiler(E)  →  position (4 + 4) mod 6 = 2

Reste un piège classique : avec les seuls indices de tête et de queue, une file pleine et une file vide donnent la même configuration — tête et queue confondues. Deux parades : maintenir un compteur d'éléments, ce qui est le plus simple et le plus clair ; ou sacrifier une case en déclarant la file pleine quand il en reste une libre.

C'est exactement le tampon du tube du chapitre 2 du cours de systèmes, et le tampon producteur-consommateur de son chapitre 5. Les sémaphores vide et plein y comptaient précisément ce que ce compteur compte ici.

Quiz · 1 question

Une file implémentée par tableau avance simplement un indice de tête à chaque défilement. Après quelques milliers d'opérations, elle déborde alors qu'elle ne contient que trois éléments. Pourquoi ?

  • Les éléments défilés ne sont pas effacés et continuent d'occuper la mémoiremémoire non libérée
  • L'espace libéré au début du tableau n'est jamais réutilisé : la file dérive vers la droite jusqu'à la dernière case. Il faut un tampon CIRCULAIRE, où les indices reviennent à zéro par un modulodérive vers la droite
  • Le tableau doit être trié après chaque défilement, ce qui n'est pas faittri manquant

Réponse : C'est le défaut structurel de l'implémentation naïve. Enfiler avance l'indice de queue, défiler avance l'indice de tête : les deux ne font que MONTER, et l'espace laissé derrière la tête devient inaccessible. Après capacité opérations, la queue atteint le bout et l'on croit la file pleine alors que 99 % du tableau est libre. Le tampon circulaire corrige cela en calculant les positions modulo la capacité, ce qui referme le tableau sur lui-même. Il faut alors distinguer file pleine et file vide, qui donnent la même configuration d'indices : soit en maintenant un compteur d'éléments — le plus clair —, soit en sacrifiant une case. C'est exactement la structure du tampon d'un tube Unix.

Ce qu'on en fait

Quatre applications, et chacune éclaire une propriété.

Vérifier un parenthésage. Chaque symbole ouvrant est empilé ; chaque fermant doit correspondre au sommet, qu'on dépile. À la fin, la pile doit être vide. Trois erreurs distinctes se lisent alors : un fermant alors que la pile est vide (fermeture orpheline), un fermant qui ne correspond pas au sommet (mauvais appariement), une pile non vide à la fin (ouvertures non refermées). C'est ce que fait un éditeur pour signaler une accolade manquante, et c'est la première étape de tout analyseur syntaxique.

Évaluer une expression postfixée. En notation polonaise inverse, 3 4 2 × + s'évalue sans la moindre parenthèse et sans aucune règle de priorité : on empile les nombres, et chaque opérateur dépile ses deux opérandes et empile le résultat. La simplicité de l'algorithme explique que cette notation ait été celle des calculatrices HP et qu'elle reste celle des machines virtuelles.

La pile d'appels. Le chapitre 1 l'a décrite comme un mécanisme ; c'est cette structure. Un appel empile un cadre, un retour dépile — et la remontée en ordre inverse de la descente est simplement la règle LIFO. Vous l'avez d'ailleurs réimplémentée à la main dans l'exercice du chapitre 1.

Les files d'attente. Toute ressource partagée servie dans l'ordre d'arrivée est une file : processus prêts, requêtes disque, paquets réseau, travaux d'impression. Le chapitre 4 du cours de systèmes n'a fait qu'ordonner cette file autrement — FIFO, c'est le nom de la structure autant que celui de l'algorithme.

Une cinquième application arrive au chapitre 9, et elle mérite d'être annoncée : le parcours d'un graphe en profondeur utilise une pile, le parcours en largeur utilise une file. Le code est le même à une ligne près ; c'est le choix de la structure qui décide de l'ordre de visite.

Quiz · 1 question

On veut évaluer l'expression postfixée « 5 1 2 + 4 × + 3 − ». Que valent la pile après le premier « + », et le résultat final ?

  • Pile [5, 3] puis résultat 14 : le + additionne 1 et 2, on empile 3 ; ensuite 3 × 4 = 12, puis 5 + 12 = 17, puis 17 − 3 = 14deux opérandes dépilées
  • Pile [8] puis résultat 5 : chaque opérateur s'applique à toute la piletoute la pile
  • Pile [5, 1, 2] puis résultat 8 : les opérateurs sont appliqués de gauche à droite sur l'expression d'originede gauche à droite

Réponse : La règle est unique : un nombre s'empile, un opérateur dépile SES DEUX opérandes et empile le résultat. Déroulé : 5 → [5] ; 1 → [5,1] ; 2 → [5,1,2] ; + dépile 1 et 2, empile 3 → [5,3] ; 4 → [5,3,4] ; × dépile 3 et 4, empile 12 → [5,12] ; + dépile 5 et 12, empile 17 → [17] ; 3 → [17,3] ; − dépile 17 et 3, empile 14 → [14]. Résultat 14. Remarquez ce que la notation postfixée supprime : aucune parenthèse, aucune règle de priorité, et un algorithme de dix lignes. Attention toutefois à l'ORDRE des opérandes pour les opérations non commutatives — le premier dépilé est celui de DROITE, ce qui fait de la soustraction un piège classique.

À vous

L'exercice implémente les deux structures, puis les met au travail.

D'abord une pile par tableau et une file en tampon circulaire — avec le piège du plein contre vide, que le squelette laisse ouvert : la file acceptera d'enfiler par-dessus des éléments non défilés tant que vous n'aurez pas ajouté le compteur.

Ensuite deux applications : le vérificateur de parenthésage, qui doit distinguer les trois erreurs possibles et pas seulement dire « incorrect », et l'évaluateur postfixé, dont un jeu de tests contient volontairement une soustraction et une division pour faire tomber l'inversion des opérandes.

Exercice de code

Complétez la file circulaire, écrivez le vérificateur de parenthésage et l'évaluateur postfixé.

Point de départ

// ── Pile par tableau ──────────────────────────────────────────────────────
function creerPile() {
  const T = [];
  return {
    empiler: (x) => T.push(x),
    depiler: () => T.pop(),
    sommet: () => T[T.length - 1],
    estVide: () => T.length === 0,
    taille: () => T.length,
    contenu: () => [...T],
  };
}

// ── File par tampon circulaire ────────────────────────────────────────────
function creerFile(capacite) {
  const T = new Array(capacite).fill(null);
  let tete = 0, queue = 0;
  // ← il manque un compteur : sans lui, plein et vide sont indiscernables
  return {
    enfiler(x) {
      T[queue] = x;
      queue = (queue + 1) % capacite;
      return true;   // ← devrait refuser si la file est pleine
    },
    defiler() {
      const x = T[tete];
      tete = (tete + 1) % capacite;
      return x;      // ← devrait rendre undefined si la file est vide
    },
    estVide: () => false,   // ← à écrire
    estPleine: () => false, // ← à écrire
    contenu: () => T.map((v, i) => (v === null ? "." : v)).join(" "),
  };
}

// ── Application 1 : parenthésage ──────────────────────────────────────────
const PAIRES = { ")": "(", "]": "[", "}": "{" };

function verifier(texte) {
  const p = creerPile();
  for (let i = 0; i < texte.length; i++) {
    const c = texte[i];
    if ("([{".includes(c)) p.empiler({ c, i });
    else if (c in PAIRES) {
      // ← à écrire : trois cas d'erreur distincts à distinguer
    }
  }
  return { ok: true };
}

// ── Application 2 : expression postfixée ──────────────────────────────────
function evaluer(expression) {
  const p = creerPile();
  for (const jeton of expression.split(" ")) {
    if (!isNaN(Number(jeton))) { p.empiler(Number(jeton)); continue; }
    const a = p.depiler();
    const b = p.depiler();
    // ← attention à l'ORDRE : lequel de a et b est l'opérande de gauche ?
    if (jeton === "+") p.empiler(a + b);
    if (jeton === "-") p.empiler(a - b);
    if (jeton === "*") p.empiler(a * b);
    if (jeton === "/") p.empiler(a / b);
  }
  return p.depiler();
}

// ── À VOUS ────────────────────────────────────────────────────────────────
// 1. Ajoutez le compteur à la file et faites-la refuser proprement.
// 2. Écrivez verifier() en distinguant les trois erreurs.
// 3. Corrigez l'ordre des opérandes dans evaluer().

for (const e of ["5 1 2 + 4 * + 3 -", "7 3 -", "20 4 /"]) {
  console.log(e.padEnd(22) + "= " + evaluer(e));
}

Solution

function creerPile() {
  const T = [];
  return {
    empiler: (x) => T.push(x),
    depiler: () => T.pop(),
    sommet: () => T[T.length - 1],
    estVide: () => T.length === 0,
    taille: () => T.length,
    contenu: () => [...T],
  };
}

function creerFile(capacite) {
  const T = new Array(capacite).fill(null);
  let tete = 0, queue = 0, nombre = 0;   // le compteur qui manquait
  return {
    enfiler(x) {
      if (nombre === capacite) return false;   // refuser plutôt qu'écraser
      T[queue] = x;
      queue = (queue + 1) % capacite;
      nombre++;
      return true;
    },
    defiler() {
      if (nombre === 0) return undefined;
      const x = T[tete];
      T[tete] = null;
      tete = (tete + 1) % capacite;
      nombre--;
      return x;
    },
    estVide: () => nombre === 0,
    estPleine: () => nombre === capacite,
    nombre: () => nombre,
    contenu: () => T.map((v) => (v === null ? "." : v)).join(" "),
  };
}

const PAIRES = { ")": "(", "]": "[", "}": "{" };

function verifier(texte) {
  const p = creerPile();
  for (let i = 0; i < texte.length; i++) {
    const c = texte[i];
    if ("([{".includes(c)) p.empiler({ c, i });
    else if (c in PAIRES) {
      // Erreur 1 : un fermant alors que rien n'est ouvert.
      if (p.estVide()) return { ok: false, quoi: "fermeture orpheline " + c, position: i };
      const ouvert = p.depiler();
      // Erreur 2 : un fermant qui ne correspond pas au sommet.
      if (ouvert.c !== PAIRES[c]) {
        return { ok: false, quoi: ouvert.c + " fermé par " + c, position: i };
      }
    }
  }
  // Erreur 3 : des ouvertures jamais refermées.
  if (!p.estVide()) {
    const reste = p.sommet();
    return { ok: false, quoi: reste.c + " jamais refermé", position: reste.i };
  }
  return { ok: true };
}

function evaluer(expression) {
  const p = creerPile();
  for (const jeton of expression.split(" ")) {
    if (!isNaN(Number(jeton))) { p.empiler(Number(jeton)); continue; }
    // Le PREMIER dépilé est l'opérande de DROITE : c'est le dernier empilé.
    const droite = p.depiler();
    const gauche = p.depiler();
    if (jeton === "+") p.empiler(gauche + droite);
    if (jeton === "-") p.empiler(gauche - droite);
    if (jeton === "*") p.empiler(gauche * droite);
    if (jeton === "/") p.empiler(gauche / droite);
  }
  return p.depiler();
}

console.log("— file circulaire —");
const f = creerFile(6);
for (const x of ["A", "B", "C", "D"]) f.enfiler(x);
console.log("   après 4 enfilages :", f.contenu(), "| nombre", f.nombre());
f.defiler(); f.defiler(); f.defiler(); f.defiler();
for (const x of ["E", "F", "G"]) f.enfiler(x);
console.log("   après 4 défilages puis 3 enfilages :", f.contenu(),
            "  (les positions 0 et 1 ont été RÉUTILISÉES)");
for (const x of ["H", "I", "J", "K"]) {
  if (!f.enfiler(x)) console.log("   file pleine : " + x + " refusé, rien n'est écrasé");
}

console.log("");
console.log("— parenthésage —");
for (const t of ["(a[b]{c})", "(a]b)", "a)b", "(a[b)", "((a)"]) {
  const r = verifier(t);
  console.log("   " + t.padEnd(12) + (r.ok ? "correct" : "INCORRECT : " + r.quoi + " (position " + r.position + ")"));
}

console.log("");
console.log("— expressions postfixées —");
for (const [e, attendu] of [["5 1 2 + 4 * + 3 -", 14], ["7 3 -", 4], ["20 4 /", 5], ["2 3 4 * +", 14]]) {
  const v = evaluer(e);
  console.log("   " + e.padEnd(22) + "= " + String(v).padStart(4) +
              (v === attendu ? "  ok" : "  X attendu " + attendu));
}
// « 7 3 - » est le test qui compte : en inversant les opérandes on obtient
// −4 au lieu de 4. L'addition et la multiplication ne l'auraient pas révélé,
// ce qui rend l'erreur facile à laisser passer.

En travaux pratiques

Travaux pratiques 6 · 3 h

Piles et files, et ce qu'elles décident

Implémenter les deux, puis constater que remplacer l'une par l'autre dans un parcours change complètement le résultat — sans changer une ligne d'algorithme.

Avant de commencer

  • Le TP 5 : liste chaînée
  • Le TP 1 : la pile d'appels

Énoncé

  1. La pile, deux foisImplémentez une pile sur tableau dynamique, puis sur liste chaînée. Même interface. Comparez les temps sur un million d'opérations.
  2. La file, et le piègeImplémentez une file sur tableau simple, en avançant les deux indices. Faites tourner un million d'enfilages et défilages et observez la consommation mémoire.
  3. Le tampon circulaireCorrigez avec un tableau circulaire. Trouvez comment distinguer une file pleine d'une file vide, et implémentez votre choix. Indice : Les deux situations donnent les mêmes indices ; il faut une information de plus.
  4. Vérifier un parenthésageÉcrivez la vérification d'une expression contenant trois sortes de délimiteurs. Testez sur des cas corrects, des cas mal fermés, et des cas mal imbriqués.
  5. Évaluer une expression postfixéeÉcrivez l'évaluateur, puis la conversion de l'infixe vers le postfixe. Testez sur une expression avec priorités et parenthèses.
  6. L'échange qui change toutÉcrivez un parcours de labyrinthe en utilisant une pile. Puis remplacez la pile par une file, sans rien changer d'autre. Comparez les chemins trouvés.
  7. Comprendre l'écartSur un labyrinthe où plusieurs chemins mènent à la sortie, dites laquelle des deux versions trouve le plus court, et pourquoi.
  8. Au fil rougeAjoutez pile et file à votre bibliothèque, avec un en-tête propre et des tests. Vous les utiliserez telles quelles au TP 9.

C'est réussi quand

  • Votre file circulaire tourne un million de fois sans croître en mémoire
  • Votre évaluateur postfixé donne le bon résultat sur une expression à parenthèses
  • Le passage pile → file change le chemin trouvé, et vous savez dire lequel est optimal

Correction

La file naïve, et sa fuite
int file[1000000]; int debut = 0, fin = 0;
enfiler(v)  { file[fin++] = v; }
defiler()   { return file[debut++]; }

/* après un million d'opérations, debut = fin = 1 000 000
 la file est VIDE et le tableau est PLEIN :
 tout l'espace avant debut est perdu */

L'erreur est de traiter un tableau comme s'il était infini vers la droite. Elle passe tous les tests courts et échoue en production après quelques heures — profil typique du bogue qu'un test unitaire ne trouve jamais et qu'une mesure de mémoire trouve immédiatement.

Le tampon circulaire
typedef struct { int *t; size_t cap, debut, taille; } File;

void enfiler(File *f, int v) {
  f->t[(f->debut + f->taille) % f->cap] = v;
  f->taille++;
}
int defiler(File *f) {
  int v = f->t[f->debut];
  f->debut = (f->debut + 1) % f->cap;
  f->taille--;
  return v;
}

Garder la TAILLE plutôt qu'un indice de fin résout le problème de l'étape 3 : pleine et vide donnent les mêmes indices, mais des tailles différentes. L'alternative classique — sacrifier une case pour distinguer les deux — économise un champ et coûte une case ; garder la taille est plus lisible, et c'est ce qui compte dans une bibliothèque qu'on relira.

Le parenthésage
int equilibre(const char *s) {
  Pile p; pile_init(&p);
  for (; *s; s++) {
      if (strchr("([{", *s)) empiler(&p, *s);
      else if (strchr(")]}", *s)) {
          if (pile_vide(&p)) return 0;              /* ferme sans ouvrir */
          char o = depiler(&p);
          if ((*s == ')' && o != '(') ||
              (*s == ']' && o != '[') ||
              (*s == '}' && o != '{')) return 0;    /* mal imbriqué */
      }
  }
  return pile_vide(&p);                             /* reste ouvert ? */
}

Trois échecs possibles, trois tests distincts — et le troisième, la vérification finale que la pile est vide, est celui qu'on oublie : « ((( » passerait sans lui. La pile est la structure naturelle de tout ce qui est IMBRIQUÉ, et c'est pourquoi elle est au cœur de tout analyseur syntaxique.

L'évaluation postfixée
"3 4 + 2 *"
3    → empiler 3            pile : 3
4    → empiler 4            pile : 3 4
+    → dépiler 4 et 3, empiler 7    pile : 7
2    → empiler 2            pile : 7 2
*    → dépiler 2 et 7, empiler 14   pile : 14
résultat : 14

/* attention à l'ordre pour les opérateurs non commutatifs */
int b = depiler(&p), a = depiler(&p);   /* a AVANT b */
empiler(&p, a - b);

Aucune parenthèse, aucune priorité, aucune ambiguïté : la notation postfixée porte toute la structure dans l'ORDRE. C'est pourquoi les machines virtuelles à pile — celle de Java, celle de Python — compilent vers cette forme. L'inversion des deux dépilements est le bogue à ne pas manquer, et il ne se voit ni sur + ni sur ×.

Pile ou file : le même code, deux algorithmes
/* le seul changement */
Pile a_visiter;   →   File a_visiter;

avec une PILE : parcours en PROFONDEUR
suit un chemin jusqu'au bout avant d'essayer une autre branche
chemin trouvé : le premier, pas forcément le plus court
mémoire : la profondeur

avec une FILE : parcours en LARGEUR
explore par distance croissante depuis le départ
chemin trouvé : le PLUS COURT en nombre d'étapes — garanti
mémoire : la largeur, souvent bien plus grande

Une ligne change, et la propriété de l'algorithme change avec elle. C'est le résultat le plus important du TP : la structure de données ne stocke pas seulement, elle DÉCIDE de l'ordre du traitement. La largeur garantit le plus court chemin parce qu'elle n'atteint jamais un sommet à distance k+1 avant d'avoir épuisé tous ceux à distance k — vous le prouverez au TP 9.

Ce que la suite en fait

Le bloc IV passe au non linéaire. Un arbre est ce qu'on obtient en donnant deux successeurs à une cellule au lieu d'un, et ses parcours emploient exactement les deux structures de ce chapitre — pile pour la profondeur, file pour la largeur.

Le lien le plus fort est avec le chapitre 7 : le parcours infixe d'un arbre d'expression rend la notation usuelle, le parcours suffixe rend la notation postfixée que vous venez d'évaluer. La pile et l'arbre d'expression sont les deux faces d'un même objet, et c'est précisément ce qui permet à un compilateur de transformer l'un en l'autre.

À retenir

Flashcards · 5 cartes

Qu'apporte le fait de brider une liste en pile ou en file ?
Les opérations ne sont autorisées qu'aux extrémités. Cette restriction garantit d'abord un coût CONSTANT sur toutes les opérations, et donne surtout une SÉMANTIQUE lisible : voir une pile dans un code dit immédiatement que le dernier arrivé sera traité en premier. Vouloir atteindre le milieu d'une pile est le signe qu'on avait besoin d'une autre structure.
Comment implémente-t-on une file par tableau, et quel piège guette ?
Par un TAMPON CIRCULAIRE : les indices reviennent à zéro après la dernière case, par un modulo — sans quoi l'espace libéré en tête n'est jamais réutilisé et la file déborde alors que le tableau est presque vide. Piège : avec les seuls indices de tête et de queue, une file PLEINE et une file VIDE donnent la même configuration. Parades : maintenir un compteur d'éléments (le plus clair), ou sacrifier une case.
Comment vérifie-t-on un parenthésage, et quelles erreurs distingue-t-on ?
Chaque symbole ouvrant est empilé ; chaque fermant doit correspondre au SOMMET, qu'on dépile ; à la fin la pile doit être vide. Trois erreurs distinctes : un fermant sur pile vide (fermeture orpheline), un fermant qui ne correspond pas au sommet (mauvais appariement), une pile non vide à la fin (ouvertures non refermées). C'est ce que fait un éditeur pour signaler une accolade manquante.
Comment évalue-t-on une expression postfixée, et quel piège sur les opérations non commutatives ?
Un nombre s'empile ; un opérateur dépile SES DEUX opérandes et empile le résultat. Aucune parenthèse, aucune règle de priorité, dix lignes de code. Piège : le PREMIER dépilé est l'opérande de DROITE. Pour « 7 3 − » il faut calculer 7 − 3 et non 3 − 7 : inverser donne un résultat juste sur l'addition et faux sur la soustraction et la division, ce qui échappe aux tests superficiels.
Quel lien entre pile, file et parcours de graphe ?
Le parcours en PROFONDEUR utilise une pile, le parcours en LARGEUR une file — et le code est le même à une ligne près : seule la structure des sommets en attente change. C'est le meilleur exemple du chapitre : ce n'est pas l'algorithme qui décide de l'ordre de visite, c'est le choix de la structure de données.