Module 2 · Les structures de base

Les structures linéaires

Cinq structures, et une seule question posée cinq fois : qu'est-ce que je suis prêt à payer, et pour quoi ? Aucune n'est meilleure que les autres. Chacune achète une opération rapide en en vendant une autre, et savoir laquelle est exactement ce que l'examen demande.

💡 Le concept

« Linéaire » veut dire une chose précise : chaque élément a au plus un voisin de chaque côté. Pas de branches, pas de cycles — une file d'attente, pas un arbre généalogique. Tout ce module vit dans cette contrainte, et les structures qui la brisent viendront après.

À l'intérieur de cette contrainte, il ne reste que deux façons de ranger des éléments en mémoire, et tout le reste en découle : soit ils sont collés les uns aux autres, soit chacun garde l'adresse du suivant. Deux dispositions. Cinq structures. Le module tient dans ce rapport.

Un seul voisin de chaque côté

Le module précédent s'est terminé sur une distinction qu'il faut avoir en tête avant de commencer, parce qu'elle organise tout ce qui suit. Un type abstrait de données décrit les opérations et ce qu'elles promettent ; une implémentation décrit la structure interne qui les réalise. « Pile » est un TAD ; « pile faite avec un tableau » est une implémentation. Si ce couple est encore flou, il est détaillé en clôture des Les éléments de C++.

Ce module fait le chemin inverse du précédent : il prend les TAD un par un et les construit pour de bon. Et à chaque fois, la même mécanique apparaît — on choisit une des deux dispositions mémoire, et ce choix décide de toutes les complexités d'un coup.

Voici les deux dispositions, énoncées une fois pour toutes. Le tableau range les éléments côte à côte, dans une zone mémoire d'un seul tenant : on connaît l'adresse du premier et la taille de chaque élément, donc on peut calculer l'adresse de n'importe lequel. La liste chaînée disperse les éléments où il y a de la place, et donne à chacun l'adresse du suivant : on ne calcule rien, on suit.

Tout le reste du module découle de cette phrase. Calculer coûte toujours pareil ; suivre coûte proportionnellement à la distance. En sens inverse : insérer au milieu d'une zone d'un seul tenant oblige à décaler tous les suivants, alors que rebrancher deux adresses ne coûte rien. C'est un échange, pas une hiérarchie — et c'est pour ça que les deux existent encore.

Le tableau : contigu, donc indexable

Commençons par la structure la plus simple, et par ce qu'elle a de remarquable : l'accès à n'importe quel élément coûte la même chose, que le tableau contienne dix éléments ou dix millions.

🧭 Les casiers numérotés

Une rangée de cent casiers identiques, alignés, numérotés de 0 à 99. Si on te demande d'ouvrir le casier 47, tu ne cherches pas : tu marches jusqu'à 47 fois la largeur d'un casier depuis le début, et tu y es. Tu n'as regardé aucun des 46 précédents.

Ce qui rend ça possible tient à deux conditions, et elles vont ensemble : les casiers sont alignés sans trou, et ils font tous la même taille. Enlève l'une des deux, et le calcul ne marche plus.

En mémoire, le calcul est exactement celui-là : l'adresse de tableau[i] vaut l'adresse de tableau[0], plus i fois la taille d'un élément. Une multiplication, une addition, et c'est fini — le même travail quel que soit i. C'est du Θ(1), et c'est le seul endroit de tout le cours où on obtient un accès direct sans rien payer d'autre.

Reste que la vraie vie demande des tableaux qui grandissent. On distingue donc le tableau statique, dont la taille est fixée une fois pour toutes à l'écriture du programme, et le tableau dynamique, alloué dans le tas et capable de s'agrandir. C'est le second qui nous intéresse, et il oblige à tenir deux nombres au lieu d'un.

⚠️ Piège fréquent

Confondre la taille et la capacité. La capacité est le nombre de cases allouées ; la taille est le nombre de cases réellement occupées. Un tableau de capacité 8 qui contient 5 éléments a cinq cases utiles et trois cases allouées mais vides — dont le contenu est quelconque, pas zéro.

Le réflexe de diagnostic : devant une boucle sur un tableau dynamique, regarde sur lequel des deux nombres elle s'arrête. Une boucle qui va jusqu'à capacite lit des cases jamais écrites ; le dernier élément valide est toujours à l'indice taille - 1, jamais capacite - 1.

Le code ci-dessous existe pour une seule raison : montrer se produit l'agrandissement et ce qu'il coûte. Le reste de la classe est banal — regarde la méthode agrandir et la première ligne d'ajouter, tout est là.

tableau.h
template<typename T>
class Tableau {
private:
    T*  donnees;
    int taille;        // cases OCCUPÉES
    int capacite;      // cases ALLOUÉES

    void agrandir() {                       // coûte O(n) : on recopie tout
        capacite *= 2;                      // ← doubler, jamais « + 2 »
        T* nouveau = new T[capacite];
        for (int i = 0; i < taille; i++) nouveau[i] = donnees[i];
        delete[] donnees;                   // on rend l'ancienne zone
        donnees = nouveau;
    }

public:
    Tableau(int cap = 8) : taille(0), capacite(cap) { donnees = new T[capacite]; }
    ~Tableau() { delete[] donnees; }

    void ajouter(const T& v) {              // O(1) AMORTI
        if (taille == capacite) agrandir();  // la case manque : on s'agrandit
        donnees[taille++] = v;
    }

    T& operator[](int i) { return donnees[i]; }     // O(1), sans vérification
    int getTaille() const { return taille; }
};

Un ajout est presque toujours instantané ; de loin en loin, il déclenche une recopie complète. Toute la question est : à quelle fréquence ?

Le mot amorti désigne cette moyenne, et il mérite mieux qu'une définition. Reprenons le calcul, parce qu'il explique aussi pourquoi on double au lieu d'ajouter une constante — la question tombe régulièrement, et la mauvaise réponse est celle qui paraît raisonnable.

En doublant. Partant d'une capacité de 1, les recopies ont lieu quand la taille atteint 1, 2, 4, 8, … et elles coûtent respectivement 1, 2, 4, 8, … Le coût total des recopies pour arriver à n éléments est donc 1 + 2 + 4 + … + n, une somme que tu connais : elle vaut à peu près 2n. Réparti sur n ajouts, cela fait deux opérations par ajout en moyenne — une constante. D'où le Θ(1) amorti.

En ajoutant une constante, disons deux cases à chaque fois. Les recopies ont lieu aux tailles 2, 4, 6, 8, … et coûtent 2, 4, 6, 8, … Le coût total est cette fois 2 + 4 + … + n, c'est-à-dire une somme arithmétique, qui vaut de l'ordre de n². Réparti sur n ajouts, cela fait n opérations par ajout : le remplissage entier passe de Θ(n) à Θ(n²). La stratégie d'agrandissement n'est donc pas un réglage, c'est un choix d'ordre de croissance.

🔗 Pont — une somme déjà rencontrée

Le raisonnement ci-dessus n'a rien inventé : il applique un résultat du premier module. L'analyse asymptotique établit que la somme des puissances de deux jusqu'à un rang donné est à peine plus grande que la dernière d'entre elles : la série géométrique 1 + 2 + 4 + … + n vaut environ 2n, et non quelque chose de quadratique comme l'intuition le voudrait.

C'est exactement ce qui rend le doublement gratuit en moyenne. Et le module d'analyse l'annonçait déjà, dans sa dernière ligne : quand une bibliothèque promet une insertion « en temps constant amorti », voilà le calcul qu'il y a derrière la promesse.

🧠 Quiz éclair

Cette boucle affiche des valeurs aberrantes après le cinquième nombre. Pourquoi ?

Tableau<int> t;          // capacité 8 par défaut, taille 0
t.ajouter(10); t.ajouter(23); t.ajouter(7);
t.ajouter(42); t.ajouter(15);

for (int i = 0; i < 8; i++)
    cout << t[i] << " ";

La boucle s'arrête sur la capacité au lieu de la taille. Cinq éléments ont été ajoutés, donc taille vaut 5 ; mais huit cases ont été allouées dès la construction. Les indices 5, 6 et 7 désignent des cases qui existent — elles sont bien à nous — et dans lesquelles personne n'a jamais rien écrit. Elles contiennent ce que la mémoire contenait avant.

L'écriture correcte est i < t.getTaille(). Et note que operator[] ne pouvait rien signaler : les indices demandés étaient dans les bornes du tableau alloué. Il n'y a pas d'erreur de mémoire ici, seulement une lecture de cases sans contenu.

La liste chaînée : le prix de l'accès

Le tableau est excellent pour lire, et mauvais pour insérer ailleurs qu'à la fin : glisser un élément au milieu oblige à décaler d'un cran tout ce qui suit. La liste chaînée renverse exactement ce compromis.

🧭 Le wagon plat et le train

Un tableau, c'est un wagon plat avec des sièges numérotés : aller au siège 47 est immédiat. Une liste chaînée, c'est un train classique — chaque wagon est attelé au suivant. Pour atteindre le 47e wagon, il faut traverser les 46 premiers.

Mais pour ajouter un wagon entre le cinquième et le sixième, tu dételles une fois et tu attelles deux fois. C'est le même travail que tu sois au début ou au milieu du convoi, et surtout : aucun autre wagon ne bouge. Sur le wagon plat, il aurait fallu déplacer tous les passagers d'un siège.

La brique est le nœud : une valeur, et l'adresse du nœud suivant. On garde un seul point d'entrée, la tête, et le dernier nœud pointe vers nullptr — c'est ainsi qu'on sait qu'on est arrivé au bout. La figure met les deux dispositions l'une sous l'autre, avec le même contenu.

Le même contenu, rangé de deux façons
tableau — une seule zone, cases collées
[0]10
[1]23
[2]7
[3]42
liste chaînée — quatre nœuds dispersés
tête0x1A0 · valeur 10 · suivant 0x2C4
nœud0x2C4 · valeur 23 · suivant 0x3F8
nœud0x3F8 · valeur 7 · suivant 0x4B0
nœud0x4B0 · valeur 42 · suivant nullptr
Les quatre valeurs sont les mêmes dans les deux dispositions. Le tableau ne porte que les valeurs, et leur position dans la zone suffit à les retrouver ; chaque nœud de la liste porte en plus l'adresse du suivant, et cette adresse est le seul chemin qui y mène. C'est tout l'échange : une adresse de plus par élément, contre la liberté de les placer n'importe où.

Le code qui suit est le plus important du module. Il contient trois choses : le patron de parcours, l'insertion en tête, et la suppression — et c'est la suppression qui révèle la vraie contrainte d'une liste simplement chaînée.

liste_chainee.h
template<typename T>
class ListeChainee {
private:
    struct Noeud {
        T      valeur;
        Noeud* suivant;
        Noeud(const T& v) : valeur(v), suivant(nullptr) { }
    };

    Noeud* tete;
    int    taille;

public:
    ListeChainee() : tete(nullptr), taille(0) { }

    ~ListeChainee() {                        // libère CHAQUE nœud, pas juste la tête
        while (tete != nullptr) {
            Noeud* aSupprimer = tete;
            tete = tete->suivant;            // on avance AVANT de détruire
            delete aSupprimer;
        }
    }

    void insererDebut(const T& v) {          // O(1) : aucun parcours
        Noeud* n = new Noeud(v);
        n->suivant = tete;                   // 1. le neuf pointe vers l'ancienne tête
        tete = n;                            // 2. le neuf devient la tête
        taille++;
    }
};

L'ordre des deux lignes d'insererDebut n'est pas négociable : écrire tete = n d'abord perdrait l'adresse de l'ancienne tête, et toute la liste avec.

Le destructeur mérite un regard, parce qu'il illustre une règle du module précédent dans le cas le plus fréquent du cours. Détruire l'objet ListeChainee ne détruit aucun nœud : les nœuds sont dans le tas, et rien ne les touche sans un delete. Il faut donc parcourir et détruire un par un — et retenir l'adresse du suivant avant de détruire le nœud courant, faute de quoi on détruirait le seul chemin vers le reste de la liste.

📖 La formule

Le parcours du fil

// Visiter tous les nœuds, du premier au dernier.
for (Noeud* courant = tete; courant != nullptr; courant = courant->suivant) {
    // ... traiter courant->valeur
}

// La VARIANTE de suppression : on s'arrête sur le nœud d'AVANT,
// parce qu'une liste simple ne sait pas revenir en arrière.
Noeud* courant = tete;
while (courant->suivant != nullptr && courant->suivant->valeur != v)
    courant = courant->suivant;
// ici, courant->suivant est le nœud à retirer (ou nullptr : absent)

Le patron le plus réutilisé du reste du cours. On le reconnaît à sa condition d'arrêt — != nullptr, jamais un indice — et à sa mise à jour, qui suit un pointeur au lieu d'incrémenter un compteur. Chaque fois qu'une opération demande de trouver quelque chose dans une liste, c'est cette boucle qui coûte le Θ(n).

Une dernière chose avant de quitter la liste simple, et c'est celle qui coûte le plus de points quand on l'oublie : toute opération sur une liste a trois cas limites, et ils se vérifient dans cet ordre. La liste vide, où tete vaut nullptr et où il n'y a rien à parcourir. La liste d'un seul élément, où la tête est aussi le dernier nœud, donc où toute mise à jour de fin touche la tête. Et surtout le cas où l'élément visé est la tête elle-même — qui se traite à part, puisqu'il n'a pas de prédécesseur dont on pourrait modifier le champ : c'est l'attribut tete de la liste qu'il faut déplacer, et non le suivant d'un nœud.

Prends l'habitude de te poser les trois questions avant d'écrire le corps d'une méthode, plutôt qu'après avoir vu le programme planter : et si la liste est vide ? et s'il n'y a qu'un élément ? et si c'est la tête ? Un correcteur les cherche, et une copie qui les traite explicitement se distingue immédiatement.

⚠️ Piège fréquent

Vouloir supprimer un nœud dont on n'a que le nœud lui-même. Pour retirer un nœud d'une liste simplement chaînée, il faut modifier le champ suivant de son prédécesseur — et un nœud ne connaît pas son prédécesseur. Avoir un pointeur sur le nœud à supprimer ne suffit donc pas : il faut repartir de la tête pour retrouver celui d'avant, ce qui coûte Θ(n).

Le réflexe de diagnostic vaut pour tout le module : devant une opération sur une liste, demande-toi quels pointeurs il faut avoir en main pour l'exécuter, et non ce qu'elle fait. C'est la question qui distingue le coût de trouver de celui de faire, et la suite du module y revient trois fois.

✍️ Exercice de lecture

Cette méthode ajoute un élément à la fin de la liste. Quelle est sa complexité, et qu'est-ce qui la détermine ? Que faudrait-il changer pour la ramener à Θ(1) ?

void insererFin(const T& v) {
    Noeud* n = new Noeud(v);
    if (tete == nullptr) { tete = n; }
    else {
        Noeud* courant = tete;
        while (courant->suivant != nullptr)
            courant = courant->suivant;
        courant->suivant = n;
    }
    taille++;
}
Voir le corrigé

Θ(n) — et c'est le parcours qui coûte, pas l'insertion. Décomposons, parce que c'est exactement la distinction que l'examen teste. Fabriquer le nœud  : Θ(1). Le raccrocher, une fois qu'on tient le dernier nœud : Θ(1), une seule affectation. Mais trouver le dernier nœud demande de suivre tous les pointeurs depuis la tête, soit n − 1 pas. Le parcours domine.

Le remède : garder un second pointeur, une queue qui désigne en permanence le dernier nœud. L'insertion en fin devient alors trois affectations, sans aucun parcours — donc Θ(1). C'est ce que font toutes les implémentations réelles, et c'est aussi ce qui rend possible la file du module.

Le prix à payer est la cohérence : à partir du moment où queue existe, chaque méthode qui touche à la fin de la liste doit penser à le mettre à jour — y compris la suppression du dernier élément, et y compris le cas où la liste devient vide. C'est la contrepartie habituelle : un pointeur de plus achète une complexité, et coûte une invariante à maintenir.

Ce que le second pointeur achète

On vient de voir qu'une liste simplement chaînée ne sait pas revenir en arrière, et que ça lui coûte cher sur deux opérations : supprimer un nœud qu'on tient, et atteindre l'avant-dernier. La liste doublement chaînée règle les deux d'un coup, en donnant à chaque nœud un second pointeur — vers le précédent.

Le gain se raconte en une phrase : si tu tiens un pointeur sur le nœud à supprimer, tu tiens aussi ses deux voisins, donc tu peux les rebrancher l'un sur l'autre sans parcourir quoi que ce soit. La suppression passe de Θ(n) à Θ(1) — à condition, toujours, d'avoir déjà le pointeur.

Le code ci-dessous ne montre que la suppression, parce que c'est la seule méthode où le double chaînage change quelque chose de visible. Les quatre lignes de rebranchement suivent toutes le même schéma : on traite chaque voisin, et le cas où ce voisin n'existe pas.

liste_double.h
struct Noeud {
    T      valeur;
    Noeud* precedent;
    Noeud* suivant;
    Noeud(const T& v) : valeur(v), precedent(nullptr), suivant(nullptr) { }
};

Noeud* tete;
Noeud* queue;

// Retirer un nœud dont on a DÉJÀ le pointeur : O(1), aucun parcours.
void supprimerNoeud(Noeud* n) {
    if (n->precedent) n->precedent->suivant = n->suivant;
    else              tete = n->suivant;      // n était la tête

    if (n->suivant)   n->suivant->precedent = n->precedent;
    else              queue = n->precedent;   // n était la queue

    delete n;
    taille--;
}

Les deux else sont les cas de bord : quand le nœud retiré était à une extrémité, il n'y a pas de voisin à rebrancher — c'est la liste elle-même qu'il faut corriger.

Ce que ce second pointeur coûte est aussi net que ce qu'il achète, et il faut savoir le chiffrer : une adresse de plus par nœud. Sur une machine courante, une adresse occupe huit octets. Une liste doublement chaînée d'entiers — quatre octets de donnée utile — dépense donc seize octets de liens pour quatre octets de contenu : cinq fois la place qu'un tableau aurait prise pour le même contenu. Sur des objets volumineux, la proportion devient négligeable ; sur des entiers, elle est décisive.

Le second coût est plus insidieux : chaque opération doit maintenir deux fois plus d'invariantes. Une insertion touche quatre pointeurs au lieu de deux, et en oublier un seul produit une liste qui se parcourt correctement dans un sens et se perd dans l'autre. C'est un bogue difficile à voir, parce que la moitié des tests passent.

🧠 Quiz éclair

Ce destructeur de liste doublement chaînée compile et s'exécute sans erreur apparente. Que laisse-t-il derrière lui ?

~ListeDouble() {
    delete tete;
    delete queue;
}

Tous les nœuds du milieu. Un delete détruit un nœud, pas une chaîne : détruire la tête et la queue laisse intacts tous ceux qui étaient entre les deux — et plus aucune adresse ne mène à eux, puisque leurs deux voisins d'extrémité viennent de disparaître. Sur une liste de mille éléments, ce destructeur en libère deux et en perd neuf cent quatre-vingt-dix-huit.

Pire : sur une liste d'un seul élément, tete et queue désignent le même nœud, et les deux lignes le libèrent deux fois. On tombe alors sur la double libération du module précédent, avec le plantage qui va avec.

Le destructeur correct est celui de la liste simple : parcourir, retenir le suivant, détruire, avancer. Le double chaînage ne change rien à cette obligation.

La pile : dernier entré, premier sorti

À partir d'ici, on ne construit plus des dispositions mémoire : on construit des disciplines. Une pile et une file contiennent les mêmes éléments et se construisent avec les mêmes briques ; ce qui les sépare est une règle sur qui a le droit de sortir.

La pile suit la règle LIFO — last in, first out, dernier entré, premier sorti. On ajoute au sommet, on retire au sommet, et on ne touche à rien d'autre.

🧭 La pile d'assiettes

Dans une cafétéria, tu poses l'assiette propre sur le dessus, et le client suivant prend celle du dessus. Personne ne tire l'assiette du milieu — ce n'est pas interdit, c'est simplement impraticable.

Les trois opérations portent des noms qui se retiennent avec l'image : empiler pose une assiette, depiler en enlève une, sommet regarde celle du dessus sans y toucher.

Le TAD Pile admet les deux dispositions du module, et c'est le meilleur exemple de la distinction entre un type abstrait et son implémentation : les deux versions ci-dessous ont exactement la même interface, et elles n'ont pas une ligne en commun.

pile.h — les deux implémentations du même TAD
// AVEC UN TABLEAU — le sommet est la dernière case occupée.
template<typename T>
class PileTableau {
    T*  donnees;
    int taille, capacite;
public:
    void empiler(const T& v) { donnees[taille++] = v; }   // O(1)
    T    depiler()           { return donnees[--taille]; } // O(1)
    T    sommet() const      { return donnees[taille - 1]; }
    bool estVide() const     { return taille == 0; }
};

// AVEC UNE LISTE — le sommet est la TÊTE, jamais la fin :
// insérer et retirer en tête coûtent O(1), en fin O(n).
template<typename T>
class PileListe {
    struct Noeud {
        T      valeur;
        Noeud* suivant;
        Noeud(const T& v) : valeur(v), suivant(nullptr) { }
    };
    Noeud* sommetNoeud;
public:
    void empiler(const T& v) {                            // O(1)
        Noeud* n = new Noeud(v);
        n->suivant = sommetNoeud;
        sommetNoeud = n;
    }
};

Le détail qui compte dans la seconde : on empile en tête. Une pile bâtie sur la fin d'une liste simplement chaînée serait en Θ(n) à chaque opération.

Ces deux implémentations ont la même complexité partout — du Θ(1) sur les trois opérations. Comment choisit-on, alors ? Sur ce que le Θ ne dit pas. Le tableau range ses éléments côte à côte, et un processeur qui lit une case charge automatiquement ses voisines dans sa mémoire rapide ; les accès suivants sont alors quasi gratuits. Les nœuds d'une liste sont dispersés dans le tas, et chaque saut est une nouvelle recherche. À complexité égale, la version tableau gagne en pratique, et c'est d'ailleurs ce que fait la bibliothèque standard.

Reste la faute que le module précédent annonçait, et qu'on retrouve mot pour mot ici : ni empiler ni depiler ne vérifient quoi que ce soit.

⚠️ Piège fréquent

Dépiler une pile vide. taille vaut 0, donc --taille le met à −1 et on lit une case située avant le début du tableau. Rien ne plante forcément : la valeur renvoyée est simplement quelconque, et taille reste à −1, ce qui abîme toutes les opérations suivantes.

Le réflexe de diagnostic est le même à l'autre bout : empiler sur une pile pleine écrit après la dernière case. La règle qui couvre les deux : toute méthode qui déplace un indice doit dire ce qu'elle fait aux deux extrémités. En pratique, un estVide() en tête de depiler et un test de capacité en tête d'empiler.

Il vaut la peine de savoir les piles servent, parce que les énoncés d'examen décrivent souvent un problème sans nommer la structure. Quatre usages reviennent : la vérification de parenthèses équilibrées, l'annulation d'actions — la dernière faite est la première défaite —, le parcours en profondeur d'un graphe ou d'un arbre, et la pile d'appels du processeur, qui est littéralement la structure de ce module : chaque appel de fonction empile son contexte, chaque retour le dépile. C'est aussi ce qui explique qu'une récursion sans fin provoque un débordement de pile.

La file : premier entré, premier sorti

La file suit la règle inverse, FIFO — first in, first out. Le premier arrivé est le premier servi. On ajoute d'un côté, on retire de l'autre.

🧭 La caisse du supermarché

Tu te mets au bout de la file, tu passes en tête. Les deux extrémités ont des rôles différents et fixes : on entre par l'arrière, on sort par l'avant. C'est toute la différence avec la pile, où les deux opérations se font au même endroit.

Cette différence a une conséquence directe sur l'implémentation, et c'est elle qui rend la file plus intéressante que la pile. Une pile n'a qu'un point de travail ; une file en a deux, aux extrémités opposées. Or aucune des deux dispositions ne rend les deux extrémités rapides gratuitement.

Les mêmes quatre éléments, deux ordres de sortie
ce qui entre, dans cet ordre
1er10
2e23
3e7
4e42
pile — ce qui sort
1er sorti42
7
23
dernier sorti10
file — ce qui sort
1er sorti10
23
7
dernier sorti42
Les quatre valeurs entrent dans le même ordre — 10, puis 23, puis 7, puis 42 — et les deux structures les rendent dans des ordres opposés : la pile commence par 42, la file commence par 10. Rien d'autre ne les distingue : mêmes éléments, mêmes entrées, deux disciplines de sortie.

Voyons pourquoi le tableau naïf échoue ici. Si on enfile à la fin et qu'on défile par le début, chaque défilement laisse un trou en tête : il faut décaler tous les éléments d'un cran pour le combler. Ce décalage coûte Θ(n), à chaque défilement, ce qui rend la structure inutilisable dès que la file s'allonge.

La liste chaînée, elle, s'en sort — à condition de garder ce second pointeur dont l'exercice de la section sur la liste parlait déjà. Le code ci-dessous n'a d'intérêt que pour une chose : montrer que les deux extrémités sont tenues en permanence, et que c'est ce qui rend les deux opérations constantes.

file_liste.h
template<typename T>
class FileListe {
private:
    struct Noeud {
        T      valeur;
        Noeud* suivant;
        Noeud(const T& v) : valeur(v), suivant(nullptr) { }
    };

    Noeud* tete;      // par où l'on DÉFILE
    Noeud* queue;     // par où l'on ENFILE
    int    taille;

public:
    FileListe() : tete(nullptr), queue(nullptr), taille(0) { }

    void enfiler(const T& v) {                    // O(1)
        Noeud* n = new Noeud(v);
        if (queue) queue->suivant = n;             // on attache derrière
        else       tete = n;                       // file vide : n est aussi la tête
        queue = n;
        taille++;
    }

    T defiler() {                                  // O(1)
        T v = tete->valeur;
        Noeud* aSupprimer = tete;
        tete = tete->suivant;
        if (!tete) queue = nullptr;                // la file s'est vidée
        delete aSupprimer;
        taille--;
        return v;
    }
};

Les deux lignes de cas limite — else tete = n et if (!tete) queue = nullptr — traitent la même situation vue des deux bouts : la file passe de vide à pleine, ou de pleine à vide.

Les usages de la file se reconnaissent à un mot dans l'énoncé : équité, ou ordre d'arrivée. Le parcours en largeur d'un graphe, qui reviendra dans un module ultérieur, en est le cas central. Viennent ensuite les files de messages ou d'événements, la file d'impression, et la communication entre deux parties d'un programme qui ne vont pas à la même vitesse.

Le modulo qui recycle les cases

On aimerait quand même une file bâtie sur un tableau — pour la localité mémoire dont on parlait à propos de la pile. Le problème était le décalage à chaque défilement ; l'astuce consiste à ne pas décaler du tout, et à laisser les deux extrémités se promener dans le tableau.

🧭 Le carrousel

Un manège à six places. Les gens entrent par une porte et sortent par une autre. Quand la place 5 se libère, la personne suivante ne va pas déplacer tout le monde d'un cran : elle monte à la place 0, et le manège continue de tourner.

La file circulaire fait exactement cela. Les deux indices avancent, et quand ils dépassent la dernière case, ils repassent à zéro. Le tableau n'est pas réellement circulaire — c'est le déplacement des indices qui l'est.

L'outil qui fait tourner un indice est le modulo : écrire indice = (indice + 1) % capacite avance d'une case, sauf à la dernière, où l'opération ramène à 0. Une seule expression pour les deux cas — c'est toute l'astuce, et elle tient sur une ligne.

La figure montre la même file avant et après deux enfilages qui franchissent le tour. Ce qu'il faut y suivre est la position des deux indices, pas le contenu des cases.

La même file, avant et après le passage par zéro
capacité 6 · taille 3 · début 3 · fin 0
[0] finlibre
[1]libre
[2]libre
[3] début10
[4]23
[5]7
après enfiler(42) puis enfiler(15) — taille 5 · début 3 · fin 2
[0]42
[1]15
[2] finlibre
[3] début10
[4]23
[5]7
Aucun élément n'a bougé de case entre les deux états : 10, 23 et 7 sont restés aux indices 3, 4 et 5. Ce sont les deux indices qui se déplacent — fin est passé de 0 à 2 en écrivant dans les cases 0 puis 1, et debut n'a pas changé puisqu'on n'a rien défilé. Les éléments occupent donc les indices 3, 4, 5, 0, 1, dans cet ordre de lecture.

Le code tient en deux méthodes de quatre lignes, et la ligne qui compte est la même dans les deux. Regarde uniquement les deux %.

file_circulaire.h
template<typename T>
class FileCirculaire {
private:
    T*  donnees;
    int capacite;
    int taille;       // INDISPENSABLE : voir plus bas
    int debut;        // le plus ancien élément
    int fin;          // la prochaine case LIBRE

public:
    void enfiler(const T& v) {                   // O(1)
        donnees[fin] = v;
        fin = (fin + 1) % capacite;               // ← le tour se fait ici
        taille++;
    }

    T defiler() {                                 // O(1)
        T v = donnees[debut];
        debut = (debut + 1) % capacite;           // ← et ici
        taille--;
        return v;
    }

    bool estVide()   const { return taille == 0; }
    bool estPleine() const { return taille == capacite; }
};

La bibliothèque standard nomme ces deux extrémités front et back ; le cours les nomme debut et fin. Ce sont les mêmes notions.

L'attribut taille mérite une explication, parce qu'il a l'air redondant — on pourrait croire que debut et fin suffisent à tout savoir. Ils ne suffisent pas, et voici pourquoi. Quand la file est vide, l'indice de début a rattrapé celui de fin. Quand elle est pleine, la prochaine case libre est justement celle du plus ancien élément : les deux indices coïncident encore. La même configuration décrit donc deux situations opposées, et aucun test sur les indices seuls ne peut les distinguer.

Garder un compteur tranche la question en une comparaison. Ce n'est pas la seule solution — on peut aussi sacrifier une case pour que les deux situations cessent de se ressembler —, mais c'est la plus lisible, et c'est celle que le cours emploie.

Enfin, la méthode pour dérouler une file circulaire à la main, parce que c'est un exercice classique et qu'il se rate en allant trop vite. Trois gestes. Un : partir de debut, qui est toujours le plus ancien élément — jamais de fin. Deux : avancer en ajoutant 1 modulo la capacité, autant de fois qu'il y a d'éléments, c'est-à-dire taille fois. Trois : vérifier — la dernière position obtenue doit être celle qui précède fin. Si elle ne l'est pas, on a compté un cran de trop ou de trop peu.

⚠️ Piège fréquent

Croire que fin désigne le dernier élément. Il désigne la prochaine case libre, c'est-à-dire une case dont le contenu ne veut rien dire. Lire donnees[fin] pour obtenir le dernier élément renvoie donc n'importe quoi.

Le réflexe de diagnostic : le dernier élément est à l'indice (fin - 1 + capacite) % capacite, et le terme + capacite n'est pas décoratif — sans lui, quand fin vaut 0, on calcule -1 % capacite, qui ne donne pas ce qu'on espère. Chaque fois que tu recules un indice modulo quelque chose, ajoute la capacité avant de prendre le reste.

✍️ Exercice de lecture

Une file circulaire de capacité 8 — et non 6, cette fois — porte debut = 6, fin = 2 et taille = 4. Quels sont les indices physiques de ses quatre éléments, du plus ancien au plus récent ? Puis : que valent les trois attributs après deux défilements ?

Voir le corrigé

Les indices 6, 7, 0, 1. On applique les trois gestes. On part de debut = 6. On avance quatre fois, puisque la taille vaut 4 : 6, puis (6+1) % 8 = 7, puis (7+1) % 8 = 0, puis (0+1) % 8 = 1. Vérification : la dernière position obtenue est 1, et fin vaut 2, donc elle précède bien fin. Le compte est juste.

Après deux défilements : debut = (6 + 2) % 8 = 0, fin = 2 — inchangé, puisque défiler ne touche pas à l'autre extrémité — et taille = 2. Les deux éléments restants occupent les indices 0 et 1.

Le détail qui trompe : on est tenté de croire que la file « commence » à l'indice 0 parce que c'est le début du tableau. Elle commence à debut, où qu'il soit — et le tableau, lui, n'a ni début ni fin privilégiés une fois que les indices se sont mis à tourner.

Quand l'ordre n'est plus l'arrivée

Dernière discipline du module, et la seule qui n'a pas de rapport avec l'ordre d'arrivée. Dans une file avec priorité, chaque élément porte une importance, et c'est elle — et elle seule — qui décide de qui sort le premier.

🧭 Le triage aux urgences

Aux urgences, on n'est pas servi dans l'ordre d'arrivée. Quelqu'un qui arrive en détresse vitale passe avant quelqu'un qui attend depuis deux heures pour une entorse. Le critère est la gravité, pas l'heure d'arrivée — et c'est exactement ce qu'implémente une file avec priorité.

Le TAD a deux opérations essentielles : inserer, qui ajoute un élément avec sa priorité, et extraireMax, qui retire l'élément de plus forte priorité — quel que soit le moment où il est entré. Certains problèmes veulent au contraire le plus petit ; c'est la même structure, avec la comparaison retournée.

Trois implémentations sont possibles, et les comparer est un exercice d'examen à lui seul, parce que le raisonnement est toujours le même : qui fait le travail, celui qui insère ou celui qui extrait ?

L'implémentationinsererextraireMaxLe raisonnement
liste non triée Θ(1) Θ(n) on entasse sans réfléchir, donc il faut tout examiner pour extraire
liste triée Θ(n) Θ(1) on paie l'insertion pour que le maximum soit toujours au bout
monceau binaire Θ(log n) Θ(log n) on partage le travail entre les deux, et aucune n'est mauvaise

Les deux premières lignes sont un choix de qui paie : si un problème insère énormément et extrait rarement, la liste non triée est excellente, et l'inverse vaut pour la liste triée. Mais dès que les deux opérations sont fréquentes — ce qui est le cas général —, aucune des deux ne convient, puisque chacune a un Θ(n) quelque part.

Le monceau binaire est la réponse à ce cas général, et il est en dehors de ce module : c'est un arbre, donc il brise la contrainte « un seul voisin de chaque côté » sur laquelle tout le reste repose. Le module à venir sur les arbres et les monceaux le construira. Ce qu'il faut en retenir ici tient en une ligne : une file avec priorité efficace se fait avec un monceau, et les deux opérations y coûtent Θ(log n).

Un mot sur les usages, parce qu'ils reviendront : l'ordonnancement de tâches, la simulation à événements discrets, et surtout l'algorithme de plus court chemin de Dijkstra, dont la file avec priorité est le moteur.

🧠 Quiz éclair

Un ordonnanceur reçoit environ mille tâches par seconde et en exécute une dizaine. Quelle implémentation de file avec priorité choisis-tu, et pourquoi le monceau n'est-il pas la bonne réponse ici ?

La liste non triée. Le raisonnement se fait en pondérant les deux opérations par leur fréquence, et pas en comparant les complexités isolément. Ici on insère environ cent fois plus qu'on extrait : le coût total est dominé par les insertions.

Avec une liste non triée, les mille insertions coûtent Θ(1) chacune et les dix extractions Θ(n) chacune. Avec un monceau, les mille insertions coûtent Θ(log n) chacune — donc mille logarithmes contre mille constantes. Le monceau est meilleur par opération sur l'extraction, et perd sur le total.

La leçon est celle du dernier module d'analyse : une complexité ne se lit jamais seule. Elle se pondère par le nombre de fois où l'opération est réellement appelée.

Choisir, et savoir dire pourquoi

Le module se termine par ce que l'examen demande vraiment : non pas réciter des complexités, mais justifier un choix. Le tableau ci-dessous rassemble tout ce qui précède, et il se lit en colonnes — chacune est une structure, et ses forces et ses faiblesses se répondent.

L'opérationTableau dynamiqueListe simpleListe double
Accès à l'élément iΘ(1)Θ(n)Θ(n)
Insertion en têteΘ(n)Θ(1)Θ(1)
Insertion en finΘ(1) amortiΘ(1) avec queueΘ(1)
Insertion au milieu, pointeur en mainΘ(n)Θ(1)Θ(1)
Suppression en têteΘ(n)Θ(1)Θ(1)
Suppression en finΘ(1)Θ(n)Θ(1)
Recherche d'une valeurΘ(n)Θ(n)Θ(n)
Mémoire par élémentla valeurla valeur + 1 adressela valeur + 2 adresses

Trois lignes de ce tableau sont des pièges de lecture, et il vaut mieux les avoir vues avant l'examen que pendant.

« Pointeur en main » n'est pas une formule de style. L'insertion au milieu d'une liste est en Θ(1) à condition qu'on tienne déjà le nœud d'avant. Si on ne le tient pas, il faut le trouver, et trouver coûte Θ(n). Une question d'examen qui dit « insérer après le ke élément » décrit donc un Θ(n), alors que la même opération « insérer après ce nœud » décrit un Θ(1). C'est la distinction entre trouver et faire, et c'est elle qui départage les copies.

La recherche est en Θ(n) partout. Aucune structure de ce module n'aide à chercher — et c'est précisément le manque que les modules suivants viendront combler, avec les arbres et les tables de dispersion. Si un problème est dominé par la recherche, la bonne réponse n'est dans aucune colonne de ce tableau.

La dernière ligne se chiffre. Une adresse pèse huit octets sur une machine courante. Pour des entiers de quatre octets, une liste simple triple donc l'occupation mémoire et une liste double la quintuple. C'est un argument recevable en examen quand on justifie un choix — au même titre qu'une complexité.

Voici enfin la règle de décision, qui condense tout le module en cinq lignes. Elle s'applique dans l'ordre : la première condition qui correspond donne la réponse.

Ce que le problème demandeLa structure
accéder souvent par indicetableau dynamique
insérer et supprimer souvent en têteliste chaînée
dernier entré, premier sortipile — sur tableau de préférence
premier entré, premier sortifile — circulaire, ou liste avec queue
servir par importance, pas par arrivéefile avec priorité, sur monceau

Reste ce que la bibliothèque standard fournit déjà, parce que les travaux pratiques demandent d'implémenter à la main mais que les énoncés nomment ces types-là. Les complexités sont les mêmes que celles qu'on vient d'établir — ce sont les mêmes structures.

Le conteneurCe qu'il estSes opérations, et leur coût
vector tableau dynamique push_back Θ(1) amorti, [] et at Θ(1), size Θ(1)
list liste doublement chaînée push_front et push_back Θ(1), accès indexé impossible
stack pile push, pop, top Θ(1)
queue file push, pop, front Θ(1)
priority_queue file avec priorité, sur monceau push et pop Θ(log n), top Θ(1)

Deux détails de vocabulaire, qui font trébucher. stack::pop et queue::pop retirent l'élément sans le renvoyer : pour l'obtenir, il faut appeler top ou front avant. Et list n'offre pas d'accès par indice du tout — pas de [] —, ce qui est cohérent avec le tableau ci-dessus : fournir une opération en Θ(n) sous une syntaxe qui suggère du Θ(1) serait un piège permanent.

✍️ Exercice de lecture

Cette fonction cherche à savoir si une liste chaînée contient un cycle — un nœud dont le champ suivant pointe vers un nœud déjà visité au lieu de nullptr. Elle n'utilise aucune mémoire supplémentaire. Comment fonctionne-t-elle, et pourquoi est-elle correcte ?

bool aUnCycle(Noeud* tete) {
    Noeud* lent   = tete;
    Noeud* rapide = tete;
    while (rapide != nullptr && rapide->suivant != nullptr) {
        lent   = lent->suivant;              // un pas
        rapide = rapide->suivant->suivant;   // deux pas
        if (lent == rapide) return true;
    }
    return false;
}
Voir le corrigé

C'est l'algorithme de Floyd, dit « de la tortue et du lièvre ». Deux pointeurs parcourent la liste à des vitesses différentes : l'un avance d'un nœud par tour, l'autre de deux.

S'il n'y a pas de cycle, le rapide atteint la fin de la liste et la condition de boucle devient fausse — d'où le false. Note qu'on teste deux choses, rapide et rapide->suivant : c'est nécessaire, puisque le rapide fait deux pas et pourrait tomber dans le vide au second.

S'il y a un cycle, ni l'un ni l'autre n'atteint jamais la fin. Les deux tournent alors indéfiniment dans la boucle, et comme le rapide gagne exactement un nœud d'écart par tour, il finit nécessairement par tomber sur le lent — il ne peut pas le sauter, puisqu'il ne le dépasse que d'un cran à la fois. C'est cette impossibilité de sauter qui rend l'algorithme correct.

Le coût : Θ(n) en temps, et Θ(1) en mémoire — deux pointeurs, quelle que soit la taille de la liste. C'est ce dernier point qui fait sa réputation : la solution naïve mémorise les nœuds déjà vus, ce qui coûte Θ(n) de mémoire en plus.

🎓 À l'examen

Cette matière est celle des semaines 3 et 4 et elle est évaluée à l'intra. C'est le morceau le plus directement examinable du premier tiers de la session : on te donne un problème en français, tu réponds par une structure et une justification chiffrée.

Sur la feuille manuscrite que l'examen autorise, le tableau comparatif des complexités de ce module est la première chose à recopier : il est long, il se consulte en trois secondes, et rien ne s'y redémontre.

« Vous devez mémoriser les cent dernières actions d'un utilisateur pour permettre de les annuler, la plus ancienne étant oubliée au-delà. Quelle structure choisissez-vous ? »

L'ordre d'annulation est LIFO — la dernière action faite est la première défaite — donc une pile. Mais une pile ordinaire ne sait pas oublier son élément le plus ancien : cette contrainte-là est celle d'une file.

La réponse attendue combine les deux : un tableau circulaire de capacité 100, utilisé en discipline de pile. On ajoute et on retire par le même bout, et quand la capacité est atteinte, l'indice tourne et écrase naturellement la case du plus ancien. Toutes les opérations restent en Θ(1), sans aucun décalage.

« Quelle est la complexité de la suppression de l'élément en position i d'une liste simplement chaînée ? »

Θ(n) en pire cas, et la réponse complète explique pourquoi : le rebranchement lui-même est en Θ(1) — deux affectations —, mais atteindre la position i demande de suivre i pointeurs depuis la tête, et il faut même s'arrêter sur le nœud précédent. Le pire cas est i = n − 1.

Le complément qui rapporte : avec une liste doublement chaînée et un pointeur déjà en main sur le nœud à supprimer, on tombe à Θ(1). C'est exactement la distinction entre le coût de trouver et celui de faire.

« Pourquoi un tableau dynamique double-t-il sa capacité au lieu de l'augmenter d'une constante ? »

Parce que la stratégie décide de l'ordre de croissance du remplissage entier. En doublant, le coût cumulé des recopies pour n ajouts est une série géométrique qui vaut environ 2n : réparti sur n ajouts, cela donne un coût constant en moyenne, d'où le Θ(1) amorti.

Avec un pas constant, le coût cumulé est une série arithmétique en Θ(n²), donc Θ(n) par ajout en moyenne. Le remplissage passe de linéaire à quadratique.

À retenir

Deux dispositions mémoire seulement, et tout le module en découle. Le tableau range ses éléments côte à côte, donc il calcule l'adresse de n'importe lequel — accès Θ(1), insertion en tête Θ(n). La liste les disperse et les enchaîne par des adresses, donc elle suit — accès Θ(n), insertion Θ(1) là où on est déjà. Les cinq structures du module ne sont que des disciplines posées sur ces deux dispositions : la pile sort par où elle entre, la file sort par l'autre bout, la file avec priorité sort par importance.

Deux réflexes valent mieux que le tableau de complexités lui-même. Le premier : séparer le coût de trouver de celui de faire — presque toutes les questions d'examen tiennent dans cet écart. Le second : pondérer chaque complexité par la fréquence réelle de l'opération, parce qu'une structure meilleure par opération peut être la mauvaise sur le total.

Et ailleurs : une ligne de ce module reste vide, et c'est elle qui appelle la suite — la recherche coûte Θ(n) dans toutes les structures linéaires. Les arbres, les monceaux et les tables de dispersion existent pour combler ce trou, et chacun le fera en brisant la contrainte « un seul voisin de chaque côté ». Hors du cours, ces cinq structures sont le vocabulaire de base de tous les langages : la pile d'appels d'un débogueur, la file de messages d'un serveur, le tableau dynamique qui se cache derrière la liste de Python et le tableau de JavaScript.

🗂️ L'aide-mémoire
Le coût d'un ajout en fin de tableau dynamique
Θ(1) amorti — presque toujours instantané, avec une recopie complète en Θ(n) de loin en loin
Ce qui arrive si on agrandit d'un pas constant au lieu de doubler
le remplissage entier passe de Θ(n) à Θ(n²) : la somme des recopies devient arithmétique au lieu de géométrique
L'indice du dernier élément valide d'un tableau dynamique
taille - 1, jamais capacite - 1
Ce qu'il faut en main pour supprimer un nœud d'une liste simple
le nœud précédent. Ne tenir que le nœud à supprimer oblige à repartir de la tête, donc Θ(n)
Ce qu'un second pointeur par nœud achète, et ce qu'il coûte
il achète le parcours arrière et la suppression en Θ(1) ; il coûte 8 octets par nœud et deux fois plus de pointeurs à tenir à jour
Ce que fin désigne dans une file circulaire
la prochaine case libre, pas le dernier élément. Celui-ci est à (fin - 1 + capacite) % capacite
Pourquoi une file circulaire garde un compteur de taille
parce que debut == fin décrit à la fois la file vide et la file pleine : les indices seuls ne les distinguent pas
La formule qui fait tourner un indice
(indice + 1) % capacite pour avancer ; en reculant, ajouter la capacité avant le modulo
L'ordre de sortie d'une pile, et celui d'une file
pile : LIFO, dernier entré premier sorti. File : FIFO, premier entré premier sorti
Les deux complexités d'une file avec priorité sur monceau
Θ(log n) pour insérer et pour extraire le maximum — contre un Θ(n) quelque part dans les deux implémentations par liste
La complexité d'une recherche par valeur, dans toutes les structures de ce module
Θ(n), sans exception — c'est le trou que les arbres et les tables de dispersion viendront combler
Ce que pop renvoie sur une stack ou une queue
rien : il retire seulement. On lit la valeur avec top ou front avant d'appeler pop