Retour au cours

backend / algorithmes-structures-donnees

Tableaux et listes chaînées

Leçon 21 exercice

Explication

Ce que vous allez apprendre

  • Expliquer pourquoi un tableau offre un accès en O(1) alors qu'une liste chaînée est en O(n)
  • Choisir entre tableau et liste chaînée selon le type d'opération dominant (lecture par position vs insertion en tête)
  • Implémenter une liste chaînée simple (ajout, suppression, recherche, inversion) et comprendre le rôle du pointeur suivant
  • Appliquer l'algorithme de Floyd (tortue et lièvre) pour détecter un cycle sans mémoire additionnelle
  • Anticiper le coût caché du redimensionnement d'un tableau dynamique

Dans quel contexte ?

Un développeur reçoit un script Python qui insère des milliers d'éléments en tête d'une liste (ma_liste.insert(0, x)) dans une boucle, et le traitement d'un fichier de 200 000 lignes qui devrait prendre une seconde en prend dix minutes. Le problème : list.insert(0, x) décale tous les éléments existants à chaque appel, ce qui coûte O(n) par insertion et O(n²) au total sur la boucle entière. En remplaçant la structure par une collections.deque (pensée comme une liste chaînée des deux côtés), l'insertion en tête redevient O(1) et le script termine en une fraction de seconde.

D'abord, deux façons différentes de ranger une collection

Un tableau, c'est une rue avec des maisons numérotées consécutivement : connaître le numéro suffit pour trouver la maison instantanément. Une liste chaînée, c'est une chasse au trésor où chaque indice ne révèle que l'emplacement du suivant.

Prérequis

Cette leçon suppose que vous êtes à l'aise avec les bases de Python (listes, boucles, classes) et que vous avez lu la leçon précédente sur la notation Big O : chaque complexité annoncée ici (O(1), O(n)...) s'appuie directement dessus.

Étape 1 : pourquoi le tableau est si rapide à lire

Un tableau stocke ses éléments dans des cases mémoire contiguës : connaître l'index, c'est connaître l'adresse exacte par un simple calcul, d'où l'accès en O(1).

Le prix caché de cette rapidité

Cette contiguïté a un coût : insérer un élément au milieu oblige à décaler tout ce qui suit, et la taille doit parfois être réallouée entièrement quand le tableau grandit trop.

Étape 2 : la liste chaînée fait un choix inverse

Une liste chaînée abandonne complètement la contiguïté : chaque élément (noeud) ne connaît que son voisin suivant. Ajouter un élément en tête ne coûte donc qu'une seule opération, peu importe la taille de la liste.

Le prix caché, mais inversé cette fois

En échange, retrouver le 500e élément oblige à parcourir les 499 premiers un par un, car il n'existe aucun raccourci vers une position donnée, contrairement au tableau.

La bonne question à se poser

Le bon réflexe n'est donc pas "quelle structure est meilleure ?" mais "quelles opérations vais-je faire le plus souvent ?". Beaucoup de lectures par position : tableau. Beaucoup d'insertions en début de séquence sans accès direct nécessaire : liste chaînée.

OpérationTableau (list)Liste chaînée simple
Accès par indexO(1)O(n)
Insertion en têteO(n) (décalage de tout)O(1)
Insertion en finO(1) amortiO(n) sans pointeur de queue
Recherche d'une valeurO(n)O(n)
Mémoire par élémentCompacte (valeur seule)Valeur + pointeur(s)

Piège fréquent

ma_liste.insert(0, x) et ma_liste.pop(0) sont en O(n) sur une list Python, car tous les éléments suivants doivent être décalés. Si votre code répète ce genre d'opération en tête à l'intérieur d'une boucle, préférez une collections.deque, conçue pour offrir de l'O(1) des deux côtés.

Un classique d'entretien, pour aller plus loin

L'algorithme de Floyd (tortue et lièvre) illustre une idée puissante : deux pointeurs avançant à des vitesses différentes finissent par se rencontrer si et seulement s'il existe un cycle, sans jamais avoir besoin de mémoriser tous les noeuds déjà visités.

Vers la suite

Cette intuition de "pointeurs à vitesses différentes" reviendra dans d'autres contextes. La prochaine leçon introduit deux structures qui restreignent volontairement l'accès aux données, les piles et les files, et montre pourquoi cette restriction est en réalité une force.

Commandes & code

Tableaux et listes chaînées

python
# --- Tableau dynamique (comme list en Python) : accès O(1), insertion en fin amortie O(1) ---
tableau = [1, 2, 3]
tableau.append(4)          # O(1) amorti (réallocation occasionnelle en O(n))
tableau.insert(0, 0)       # O(n) : décale tous les éléments suivants
valeur = tableau[2]        # O(1) : accès direct par index (calcul d'adresse mémoire)
tableau.pop()               # O(1) : retire le dernier élément
tableau.pop(0)               # O(n) : retire le premier, décale tout le reste

# --- Liste chaînée simple : implémentation complète ---
class Noeud:
    __slots__ = ("valeur", "suivant")
    def __init__(self, valeur):
        self.valeur = valeur
        self.suivant: "Noeud | None" = None

class ListeChainee:
    def __init__(self):
        self.tete: Noeud | None = None
        self.taille = 0

    def ajouter_en_tete(self, valeur) -> None:
        # O(1) : pas besoin de parcourir la liste
        nouveau = Noeud(valeur)
        nouveau.suivant = self.tete
        self.tete = nouveau
        self.taille += 1

    def ajouter_en_queue(self, valeur) -> None:
        # O(n) sans pointeur de queue : il faut parcourir toute la liste
        nouveau = Noeud(valeur)
        if self.tete is None:
            self.tete = nouveau
        else:
            courant = self.tete
            while courant.suivant is not None:
                courant = courant.suivant
            courant.suivant = nouveau
        self.taille += 1

    def supprimer(self, valeur) -> bool:
        # O(n) : recherche puis suppression par re-chaînage
        if self.tete is None:
            return False
        if self.tete.valeur == valeur:
            self.tete = self.tete.suivant
            self.taille -= 1
            return True
        courant = self.tete
        while courant.suivant is not None:
            if courant.suivant.valeur == valeur:
                courant.suivant = courant.suivant.suivant  # saute le noeud à supprimer
                self.taille -= 1
                return True
            courant = courant.suivant
        return False

    def contient(self, valeur) -> bool:
        courant = self.tete
        while courant is not None:
            if courant.valeur == valeur:
                return True
            courant = courant.suivant
        return False

    def inverser(self) -> None:
        # O(n) en temps, O(1) en espace : ré-chaîne chaque noeud dans l'autre sens
        precedent = None
        courant = self.tete
        while courant is not None:
            suivant = courant.suivant
            courant.suivant = precedent
            precedent = courant
            courant = suivant
        self.tete = precedent

    def vers_liste_python(self) -> list:
        resultat = []
        courant = self.tete
        while courant is not None:
            resultat.append(courant.valeur)
            courant = courant.suivant
        return resultat

    def __len__(self) -> int:
        return self.taille

# --- Détection de cycle : algorithme de Floyd (tortue et lièvre) ---
def contient_cycle(tete: Noeud | None) -> bool:
    # O(n) en temps, O(1) en espace -- classique d'entretien technique
    lent = rapide = tete
    while rapide is not None and rapide.suivant is not None:
        lent = lent.suivant
        rapide = rapide.suivant.suivant
        if lent is rapide:   # les deux pointeurs se rencontrent -> il y a un cycle
            return True
    return False

# --- Liste doublement chaînée : navigation dans les deux sens ---
class NoeudDouble:
    __slots__ = ("valeur", "precedent", "suivant")
    def __init__(self, valeur):
        self.valeur = valeur
        self.precedent: "NoeudDouble | None" = None
        self.suivant: "NoeudDouble | None" = None

class ListeDoublementChainee:
    def __init__(self):
        self.tete: NoeudDouble | None = None
        self.queue: NoeudDouble | None = None

    def ajouter_en_queue(self, valeur) -> None:
        # O(1) grâce au pointeur de queue maintenu à jour
        nouveau = NoeudDouble(valeur)
        if self.queue is None:
            self.tete = self.queue = nouveau
        else:
            nouveau.precedent = self.queue
            self.queue.suivant = nouveau
            self.queue = nouveau

Résumé

  • Tableau : accès O(1) par index, insertion/suppression en milieu O(n).
  • Liste chaînée simple : insertion en tête O(1), accès par position O(n), pas d'accès direct par index.
  • Liste doublement chaînée : navigation bidirectionnelle, insertion/suppression O(1) si on a déjà le noeud.
  • L'algorithme de Floyd détecte un cycle en O(n) temps et O(1) espace, sans structure auxiliaire.

Exercices pratiques

1 disponible
1

Mission : réparer un script qui met dix minutes au lieu d'une seconde

Objectif : Diagnostiquer un coût caché d'insertion en tête de liste Python et corriger le choix de structure de données.

Contexte

Un collègue a écrit un script qui traite un fichier de 200 000 lignes. Pour chaque ligne lue, il insère la nouvelle valeur en tête d'une list Python avec ma_liste.insert(0, x), afin de garder les lignes dans l'ordre inverse de lecture. Ce traitement, censé prendre une seconde, en prend dix minutes.

Tu dois expliquer précisément pourquoi, puis proposer et vérifier une correction.

Résoudre l’exercice →