cursus.

Cours 3 · Structures de données linéairesLeçon 1 sur 2

Listes chaînées

7 h de lecture9 sections Version PDF

À la fin de cette leçon, vous saurez

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 · vérifiez votre compréhension Sans réponse

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 ?

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 · vérifiez votre compréhension Sans réponse

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 ?

À 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 · JavaScript · à vous de jouer

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

En attente
// 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);

Console de sortie
Le résultat s'affiche dans la console

En travaux pratiques

Travaux pratiques 5 · sur machine

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.

3 h
Avant de commencer
  • Les TP 7 et 8 de Programmation : pointeurs et allocation
  • valgrind
  1. 1. Le type et les bases

    Définissez le maillon et écrivez création, insertion en tête, affichage, longueur, libération. Vérifiez à valgrind qu'aucun octet ne fuit.

  2. 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. 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.

  4. 4. Le double pointeur

    Réécrivez la suppression avec un pointeur sur pointeur, de façon à supprimer les trois cas particuliers. Comparez les deux versions.

  5. 5. Inverser

    Inversez 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. 6. Le cycle

    Créez volontairement une liste circulaire et lancez votre affichage. Écrivez ensuite la détection de cycle avec deux parcours de vitesse différente.

  7. 7. Mesurer contre le tableau

    Comparez 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. 8. Le résultat qui surprend

    Sur 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

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 · 1 / 5Toucher pour retourner
Fin de la leçon

Vous avez parcouru les 9 sections.

Marquez-la terminée pour faire avancer votre parcours, ou revenez sur un point avant de passer à la suite.