backend / algorithmes-structures-donnees
Tableaux et listes chaînées
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ération | Tableau (list) | Liste chaînée simple |
|---|---|---|
| Accès par index | O(1) | O(n) |
| Insertion en tête | O(n) (décalage de tout) | O(1) |
| Insertion en fin | O(1) amorti | O(n) sans pointeur de queue |
| Recherche d'une valeur | O(n) | O(n) |
| Mémoire par élément | Compacte (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
# --- 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 = nouveauRé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
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.