Retour au cours

backend / algorithmes-structures-donnees

Optimisation avancée : complexité en pratique

Leçon 231 exercice

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 set quand 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 PythonCoût réel dans une boucleSolution
resultat += texte (chaîne)O(n²) au totalAccumuler dans une liste puis "".join()
x in ma_listeO(n) par test, O(n²) répété en boucleUtiliser un set pour O(1) en moyenne
ma_liste.insert(0, x)O(n) par insertionUtiliser 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

python
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ée

Ré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 in sur 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

1 disponible
1

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

Résoudre l’exercice →