Algorithmique 2 · C3 Structures de données linéaires · 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 .
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 à 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êteL'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 . Sans pointeur de queue, enfiler redeviendrait .
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 = 2Reste 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émoire — mé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 modulo — dérive vers la droite
- Le tableau doit être trié après chaque défilement, ce qui n'est pas fait — tri 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 = 14 — deux opérandes dépilées
- Pile [8] puis résultat 5 : chaque opérateur s'applique à toute la pile — toute la pile
- Pile [5, 1, 2] puis résultat 8 : les opérateurs sont appliqués de gauche à droite sur l'expression d'origine — de 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é
- La pile, deux fois — Implémentez une pile sur tableau dynamique, puis sur liste chaînée. Même interface. Comparez les temps sur un million d'opérations.
- La file, et le piège — Implé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.
- Le tampon circulaire — Corrigez 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.
- 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.
- É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.
- 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.
- Comprendre l'écart — Sur un labyrinthe où plusieurs chemins mènent à la sortie, dites laquelle des deux versions trouve le plus court, et pourquoi.
- Au fil rouge — Ajoutez 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
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.
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.
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.
"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 ×.
/* 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.