backend / algorithmes-structures-donnees
Optimisation avancée : complexité en pratique
Explication
Ce que vous allez apprendre
- Repérer les pièges de performance cachés dans du code Python d'apparence innocente
- Remplacer une concaténation de chaînes en boucle par une accumulation en liste suivie de
"".join() - Remplacer un test d'appartenance sur une liste par un
setquand il est répété dans une boucle - Expliquer ce que signifie "O(1) amorti" pour
list.append()sans le confondre avec le pire cas - Auditer un bout de code existant en identifiant, pour chaque boucle, la complexité réelle de ce qu'elle contient
Dans quel contexte ?
Un développeur reçoit un rapport de bug : « l'export CSV met 8 minutes sur 50 000 lignes, c'est inacceptable ». En lisant le code, il découvre une boucle qui fait resultat += ligne_csv à chaque itération : chaque concaténation recopie l'intégralité de la chaîne déjà construite, transformant silencieusement un travail linéaire en O(n²). En remplaçant cette ligne par un .append() dans une liste suivi d'un "".join() final, l'export passe de 8 minutes à moins d'une seconde, sans changer une seule ligne de logique métier.
D'abord, un rappel avant de passer à la pratique
Tu connais maintenant la notation Big O pour décrire la complexité théorique d'un algorithme. Cette leçon fait le pont avec la réalité concrète du code Python que tu écris tous les jours.
Prérequis
Cette leçon est une synthèse pratique : elle suppose acquises la notation Big O (leçon 1), les tables de hachage (leçon 8) et les piles/files (leçon 3).
Étape 1 : un premier piège caché, la concaténation de chaînes
En Python, une chaîne de caractères est immuable : chaque resultat += texte ne modifie rien sur place, il recrée une toute nouvelle chaîne en recopiant tout ce qui existait déjà.
Pourquoi ce piège est dangereux
Répété dans une boucle, ce comportement transforme silencieusement un travail qui semble linéaire en un travail quadratique, O(n²), sans que le code ait l'air suspect.
| Piège Python | Coût réel dans une boucle | Solution |
|---|---|---|
resultat += texte (chaîne) | O(n²) au total | Accumuler dans une liste puis "".join() |
x in ma_liste | O(n) par test, O(n²) répété en boucle | Utiliser un set pour O(1) en moyenne |
ma_liste.insert(0, x) | O(n) par insertion | Utiliser collections.deque |
La solution à ce premier piège
Accumuler les morceaux dans une liste, puis tout assembler en une seule fois à la fin, redonne un travail réellement linéaire.
Étape 2 : un second piège, chercher dans une liste
Tester x in ma_liste doit, dans le pire cas, parcourir toute la liste : c'est déjà linéaire à un seul test. Répété dans une boucle, la complexité totale devient quadratique.
La solution à ce second piège
Le même test sur un set est quasi instantané en moyenne, car un set est organisé en interne précisément pour ce cas d'usage : choisir la bonne structure de données change tout, sans toucher à l'algorithme.
Astuce
Le réflexe à prendre en revue de code : pour chaque boucle, demandez-vous « quelle est la complexité de CHAQUE opération effectuée à l'intérieur ? », pas seulement « combien de tours fait la boucle ? ». Une boucle O(n) contenant une opération O(n) donne un total O(n²), même si le code ne contient qu'une seule boucle visible.
Un concept à bien comprendre pour ne pas se méfier à tort
list.append() est dit "O(1) amorti" : certains appels individuels sont coûteux, quand la liste doit être réagrandie en mémoire, mais répartis sur un grand nombre d'appels, le coût moyen reste constant.
La règle d'or à retenir avant tout le reste
L'intuition humaine sur "ce qui est lent" est souvent fausse. Avant d'optimiser quoi que ce soit, il faut mesurer pour identifier le véritable goulot d'étranglement, sous peine de gaspiller du temps sur du code qui n'était pas le problème.
Vers la suite
Ces pièges concernent le code "générique". La prochaine leçon regroupe plutôt les techniques transversales les plus utiles pour reconnaître rapidement quelle approche appliquer face à un problème inconnu, notamment en entretien technique.
Commandes & code
Optimisation avancée : complexité en pratique
import time
from functools import lru_cache
# --- Compromis temps/espace : mémoïsation contre recalcul ---
def puissance_naive(base: float, exposant: int) -> float:
# O(exposant) -- multiplication répétée
resultat = 1.0
for _ in range(exposant):
resultat *= base
return resultat
def puissance_rapide(base: float, exposant: int) -> float:
# O(log exposant) -- exponentiation rapide par carrés successifs (diviser pour régner)
if exposant == 0:
return 1.0
if exposant % 2 == 0:
moitie = puissance_rapide(base, exposant // 2)
return moitie * moitie
return base * puissance_rapide(base, exposant - 1)
assert abs(puissance_rapide(2, 20) - 2**20) < 1e-6
# --- Éviter la complexité cachée : concaténation de chaînes en boucle ---
def construire_chaine_lente(n: int) -> str:
# O(n^2) : chaque "+=" sur une string recrée un NOUVEL objet immutable de taille croissante
resultat = ""
for i in range(n):
resultat += str(i)
return resultat
def construire_chaine_rapide(n: int) -> str:
# O(n) : accumule dans une liste, une SEULE concaténation finale
morceaux = [str(i) for i in range(n)]
return "".join(morceaux)
# --- Éviter la complexité cachée : recherche répétée dans une liste vs un set ---
def intersection_lente(a: list, b: list) -> list:
# O(n * m) : "in" sur une liste est O(m) à chaque itération
return [x for x in a if x in b]
def intersection_rapide(a: list, b: list) -> list:
# O(n + m) : "in" sur un set est O(1) en moyenne
ensemble_b = set(b)
return [x for x in a if x in ensemble_b]
# --- Complexité amortie : pourquoi list.append() est O(1) amorti malgré des réallocations ---
# CPython double la capacité du tableau interne à chaque réallocation.
# Sur n append(), le coût total des copies est borné par une série géométrique -> O(n) au total,
# donc O(1) EN MOYENNE par appel, même si CERTAINS appels individuels coûtent O(n).
# --- Mesurer réellement plutôt que supposer ---
def comparer_performances(n: int = 20000) -> dict:
resultats = {}
debut = time.perf_counter()
construire_chaine_lente(n)
resultats["concat_lente"] = time.perf_counter() - debut
debut = time.perf_counter()
construire_chaine_rapide(n)
resultats["concat_rapide"] = time.perf_counter() - debut
return resultats # sur n=20000, la version "rapide" est généralement 10-50x plus rapide
# --- Structures adaptées au problème : le bon choix de structure DOMINE l'algorithme ---
# Besoin -> Structure adaptée
# Accès par index -> list / array (O(1))
# Test d'appartenance fréquent -> set / frozenset (O(1) moyen)
# Paires clé -> valeur -> dict (O(1) moyen)
# FIFO -> collections.deque (O(1) aux extrémités)
# Toujours le min/max -> heapq (O(log n) insertion/extraction)
# Préfixes de chaînes -> Trie (O(longueur))
# Ensembles disjoints évolutifs -> Union-Find (quasi O(1) amorti)
# Comptage de fréquences -> collections.Counter (O(1) moyen par incrément)
# --- Limiter la mémoire : générateurs au lieu de listes complètes ---
def carres_liste(n: int) -> list[int]:
return [i * i for i in range(n)] # matérialise TOUTE la liste en mémoire -- O(n) espace
def carres_generateur(n: int):
for i in range(n):
yield i * i # produit une valeur à la fois -- O(1) espace, utile pour de très grands n
# Sommer un milliard de carrés sans jamais les stocker tous en mémoire simultanément
somme = sum(carres_generateur(1_000_000))
# --- Profiler avant d'optimiser : ne jamais deviner le goulot d'étranglement ---
import cProfile
def fonction_a_profiler():
return sum(i * i for i in range(100_000))
# cProfile.run("fonction_a_profiler()") # affiche le temps passé dans chaque fonction appeléeRésumé
- L'exponentiation rapide illustre comment "diviser pour régner" transforme O(n) en O(log n) sur un problème inattendu.
- La concaténation de chaînes en boucle et le test
insur une liste sont des pièges classiques de complexité cachée en Python. - La complexité amortie (ex:
list.append) autorise des opérations individuellement coûteuses tant que la moyenne reste bonne. - Toujours mesurer (
time.perf_counter,cProfile) avant d'optimiser : l'intuition sur le goulot d'étranglement est souvent fausse. - Le choix de la STRUCTURE de données est souvent plus déterminant pour la performance que le raffinement de l'algorithme lui-même.
Exercices pratiques
Mission : sauver l'export CSV qui met 8 minutes sur 50 000 lignes
Objectif : Auditer un extrait de code pour repérer une complexité cachée, la corriger, et mesurer le gain.
Contexte
Le rapport de bug dit : « l'export CSV met 8 minutes sur 50 000 lignes ». En lisant le code, tu trouves cette fonction : elle construit une chaîne de sortie avec resultat += ligne à chaque itération, ET vérifie if id_ligne in ids_deja_exportes où ids_deja_exportes est une simple liste Python remplie au fur et à mesure. Tu dois identifier et corriger les deux pièges avant de valider le correctif.