38  Complexité algorithmique

Pourquoi un programme rapide sur 100 éléments peut mettre 10 minutes sur 100 000 ? La réponse est la complexité algorithmique — comment le temps de calcul grandit avec la taille des données. Le TOSA Avancé teste cette compétence parce qu’elle sépare ceux qui écrivent du code qui marche de ceux qui écrivent du code qui tient la charge.

38.1 Pourquoi la complexité ?

Deux algorithmes peuvent résoudre le même problème, mais avec des performances radicalement différentes. Regardons un exemple concret : chercher si un élément est dans une collection.

Version 1 — recherche dans une liste

import time

# Liste de 100 000 éléments
grande_liste = list(range(100_000))

debut = time.time()
for _ in range(1000):         # on fait 1000 recherches
    _ = 99_999 in grande_liste
duree = time.time() - debut
print(f"Recherche dans une liste : {duree*1000:.0f} ms")
Recherche dans une liste : 497 ms

Version 2 — même chose avec un set

import time

grand_set = set(range(100_000))

debut = time.time()
for _ in range(1000):
    _ = 99_999 in grand_set
duree = time.time() - debut
print(f"Recherche dans un set : {duree*1000:.2f} ms")
Recherche dans un set : 0.00 ms

Le set est des milliers de fois plus rapide pour cette opération. Pourquoi ? Parce que leurs complexités sont différentes. Comprendre ça change la façon d’écrire du code.

38.2 La notation Big O

La notation Big O exprime comment le temps (ou la mémoire) d’un algorithme grandit en fonction de la taille n des données.

On ignore les constantes et les termes de moindre ordre — on s’intéresse à l’ordre de grandeur.

Les complexités les plus courantes

Notation Nom Exemple
O(1) Constant Accès à liste[i], dict[clé], x in set
O(log n) Logarithmique Recherche dichotomique
O(n) Linéaire Parcourir une liste, x in liste
O(n log n) Linéarithmique Tri efficace (sorted)
O(n²) Quadratique Boucles imbriquées, tri à bulles
O(2ⁿ) Exponentielle Sous-ensembles, certains problèmes combinatoires
O(n!) Factorielle Permutations complètes

Illustration concrète des ordres

Pour n = 1_000_000 (un million d’éléments), voici les durées approximatives (opérations élémentaires à 10⁹/s) :

Complexité Opérations Durée estimée
O(1) 1 instantané
O(log n) 20 instantané
O(n) 10⁶ 1 ms
O(n log n) 2×10⁷ 20 ms
O(n²) 10¹² 15 minutes
O(2ⁿ) 10³⁰¹⁰³⁰ bien au-delà de l’âge de l’univers

La leçon : les algorithmes O(n²) deviennent insupportables à partir de quelques dizaines de milliers d’éléments.

38.3 O(1) — complexité constante

Le temps d’exécution ne dépend pas de la taille des données.

d = {f"clé_{i}": i for i in range(1_000_000)}

# Accès à un élément : O(1) — aussi rapide que pour 10 éléments
print(d["clé_500000"])

# Test d'appartenance : O(1)
print("clé_500000" in d)
500000
True

Exemples d’opérations O(1) :

  • d[clé], d[clé] = v (dict)
  • x in set, set.add(x), set.remove(x)
  • liste[i] (accès par indice)
  • liste.append(x), liste.pop() (fin de liste)
  • len(collection)

38.4 O(n) — complexité linéaire

Le temps est proportionnel à la taille des données.

liste = list(range(1_000_000))

# Parcourir : O(n)
total = 0
for x in liste:
    total += x

# Rechercher un élément dans une liste : O(n)
print(999_999 in liste)

# sum, max, min : O(n)
print(sum(liste))
True
499999500000

Exemples d’opérations O(n) :

  • x in liste ou x in chaîne
  • Boucle simple sur toute la collection
  • sum, min, max, len sur un générateur
  • liste.copy(), list(iterable)
  • liste.insert(0, x) — insertion au début (tout décaler !)

Piège des listes : in et insert(0)

⚠️ Piège TOSA

Certaines opérations sur les listes semblent simples mais sont O(n) :

  • x in liste : doit parcourir toute la liste.
  • liste.insert(0, x) : doit décaler tous les éléments.
  • del liste[0] : idem, décalage coûteux.

Pour ces cas, utilisez collections.deque (file à deux bouts, O(1) aux deux extrémités) :

from collections import deque

d = deque([1, 2, 3])
d.appendleft(0)           # O(1) — pas de décalage !
print(d)
deque([0, 1, 2, 3])

38.5 O(n²) — complexité quadratique

Le temps grandit au carré de la taille. Typique des boucles imbriquées sur les mêmes données.

# Trouver tous les doublons dans une liste : approche naïve O(n²)
def doublons_naif(liste):
    doublons = []
    for i, a in enumerate(liste):
        for j, b in enumerate(liste):
            if i < j and a == b and a not in doublons:
                doublons.append(a)
    return doublons

print(doublons_naif([1, 2, 3, 1, 4, 2, 5]))
[1, 2]

Pour n = 10_000, on fait déjà 100 millions de comparaisons.

Comment éviter O(n²) ?

Utiliser un set pour détecter les doublons en O(n) :

def doublons_efficace(liste):
    vus = set()
    doublons = set()
    for x in liste:
        if x in vus:
            doublons.add(x)
        vus.add(x)
    return list(doublons)

print(doublons_efficace([1, 2, 3, 1, 4, 2, 5]))
[1, 2]

Chaque x in vus est O(1), donc l’ensemble du code est O(n)linéairement plus rapide.

38.6 O(log n) — complexité logarithmique

Typique des algorithmes qui divisent le problème par 2 à chaque étape.

Exemple : recherche dichotomique (rappel)

Sur une liste triée de 1 million d’éléments, 20 itérations suffisent (log₂(1_000_000) ≈ 20).

def dicho(liste_triee, cible):
    gauche, droite = 0, len(liste_triee) - 1
    iterations = 0
    while gauche <= droite:
        iterations += 1
        milieu = (gauche + droite) // 2
        if liste_triee[milieu] == cible:
            return milieu, iterations
        elif liste_triee[milieu] < cible:
            gauche = milieu + 1
        else:
            droite = milieu - 1
    return -1, iterations

# Sur un million d'éléments
grande = list(range(1_000_000))
indice, iters = dicho(grande, 999_999)
print(f"Trouvé en {iters} itérations (sur {len(grande)} éléments)")
Trouvé en 20 itérations (sur 1000000 éléments)

38.7 O(n log n) — complexité linéarithmique

C’est la complexité des algorithmes de tri efficaces comme Timsort (celui de Python).

import random
random.seed(42)

liste = [random.randint(1, 1000) for _ in range(100_000)]

import time
debut = time.time()
trie = sorted(liste)
duree = time.time() - debut
print(f"Tri de {len(liste):,} éléments en {duree*1000:.0f} ms")
Tri de 100,000 éléments en 11 ms

C’est beaucoup plus rapide qu’un tri à bulles (O(n²)) qui prendrait des minutes sur la même liste.

38.8 Complexité des opérations Python courantes

Tableau à connaître pour le TOSA :

Listes

Opération Complexité
liste[i] (accès) O(1)
liste[i] = x O(1)
liste.append(x) O(1) amorti
liste.pop() (fin) O(1)
liste.insert(0, x) O(n)
liste.pop(0) O(n)
x in liste O(n)
liste.sort() / sorted(liste) O(n log n)
liste + autre O(n+m)
len(liste) O(1)

Sets et dicts

Opération Complexité
x in set, x in dict O(1)
set.add(x), dict[k] = v O(1)
set.remove(x), del dict[k] O(1)
Parcours complet O(n)
len O(1)

Chaînes

Opération Complexité
chaine[i] O(1)
c in chaine (sous-chaîne de taille m) O(n·m)
chaine1 + chaine2 O(n+m)
".".join(liste) O(n)
len(chaine) O(1)
À retenir
  • Les sets et dicts ont des opérations en O(1) pour la recherche/insertion.
  • Les listes ont certaines opérations coûteuses (in, insert(0), pop(0)).
  • Le tri Python est en O(n log n) — efficace.
  • Les boucles imbriquées sur les mêmes données risquent le O(n²).

38.9 Cas pratique : optimiser un algorithme

Prenons un problème : compter combien d’éléments de la liste A sont aussi dans la liste B.

Version naïve (O(n×m))

import time

a = list(range(10_000))
b = list(range(5_000, 15_000))

debut = time.time()
communs = 0
for x in a:
    if x in b:           # chaque test : O(m) dans une liste
        communs += 1
duree = time.time() - debut
print(f"Version naïve : {communs} communs en {duree*1000:.0f} ms")
Version naïve : 5000 communs en 304 ms

Pour chaque élément de a (n), on parcourt b (m) → complexité O(n×m).

Version optimisée (O(n+m))

import time

a = list(range(10_000))
b = list(range(5_000, 15_000))

debut = time.time()
b_set = set(b)           # conversion : O(m)
communs = 0
for x in a:
    if x in b_set:       # test : O(1)
        communs += 1
duree = time.time() - debut
print(f"Version optimisée : {communs} communs en {duree*1000:.0f} ms")
Version optimisée : 5000 communs en 1 ms

On convertit b en set une seule fois, puis chaque test d’appartenance est O(1). Total : O(n+m)gain énorme !

Version ultra-concise avec les opérateurs ensemblistes

a = list(range(10_000))
b = list(range(5_000, 15_000))

communs = len(set(a) & set(b))
print(f"Version ensembliste : {communs} communs")
Version ensembliste : 5000 communs

38.10 Analyser la complexité d’un algorithme

Règles de composition

  • Séquence (un bloc après l’autre) : on prend le max des complexités.
  • Boucle imbriquée sur la même taille : on multiplie.
  • Si/sinon : on prend le pire des deux cas.
def exemple(liste):
    # O(n)
    for x in liste:
        print(x)

    # O(n log n)
    liste.sort()

    # Total : max(O(n), O(n log n)) = O(n log n)
def exemple2(liste):
    # O(n²) — boucles imbriquées
    for i in liste:
        for j in liste:
            print(i, j)

Focus sur les structures de contrôle

Une règle utile :

  • Un for sur n éléments = O(n) (s’il ne contient que des opérations O(1)).
  • Deux for imbriqués sur n éléments = O(n²).
  • Dichotomie (while qui divise par 2) = O(log n).
  • Chaque opération dans la boucle se multiplie.

38.11 Quand se soucier de la complexité ?

Règles pratiques

Vous pouvez l’ignorer si :

  • Les données sont petites (moins de 1000 éléments) et ça reste rapide.
  • Le code est exécuté rarement (script unique, pas de boucle externe).

Vous devez y faire attention si :

  • Les données peuvent grandir (utilisateurs, logs, mesures).
  • Le code est dans une boucle chaude (appelé très souvent).
  • Vous voyez un ralentissement à mesure que les données augmentent.

Optimisations prématurées

Le fameux adage : « Premature optimization is the root of all evil » (Knuth).

Écrivez d’abord du code clair qui marche. Mesurez si c’est trop lent. Ensuite optimisez, en commençant par les goulots d’étranglement.

38.12 Mesurer avec time ou timeit

time.time() (simple)

import time

debut = time.time()
total = sum(range(1_000_000))
duree = time.time() - debut
print(f"Durée : {duree*1000:.2f} ms")
Durée : 11.02 ms

timeit (plus précis)

Le module timeit répète l’opération et renvoie la durée moyenne — plus fiable.

import timeit

duree = timeit.timeit(
    "sum(range(1000))",
    number=10_000        # on répète 10_000 fois
)
print(f"Durée moyenne : {duree/10_000*1e6:.2f} µs par appel")
Durée moyenne : 8.36 µs par appel

🧩 Quiz 9.1 — Complexité algorithmique

Question 1

Quelle est la complexité de x in liste (liste Python classique) ?

  1. O(1)
  2. O(log n)
  3. O(n)
  4. O(n²)

c) O(n) — Python doit parcourir la liste dans le pire cas. Pour des recherches fréquentes, convertir en set donne O(1).

Question 2

Quelle est la complexité de x in set ?

  1. O(1)
  2. O(log n)
  3. O(n)
  4. O(n²)

a) O(1) — les sets utilisent une table de hachage, l’accès est (en moyenne) instantané. C’est l’atout principal des sets et dicts.

Question 3

Combien d’opérations pour trier 1 million d’éléments avec sorted() ?

  1. ~1 million
  2. ~20 millions
  3. ~1 000 milliards
  4. Impossible à estimer

b) ~20 millions — Timsort est en O(n log n) = 10⁶ × log₂(10⁶) ≈ 10⁶ × 20. Sur une machine moderne, cela prend quelques centaines de millisecondes.

Question 4

Quelle est la complexité de cette fonction ?

def f(liste):
    for x in liste:
        for y in liste:
            print(x, y)
  1. O(1)
  2. O(n)
  3. O(n²)
  4. O(n log n)

c) O(n²) — boucles imbriquées sur la même taille. Pour n = 10_000, on fait 100 millions de print. À éviter pour de grandes données.

Question 5

Que préférer pour vérifier si un élément existe souvent dans une collection ?

  1. Une liste
  2. Un tuple
  3. Un set
  4. Une chaîne

c) Un set — recherche O(1) au lieu de O(n). Astuce classique : s = set(liste) avant de faire beaucoup de x in s.

Question 6

Quelle opération est O(n) sur une liste Python ?

  1. liste[i] (accès)
  2. liste.append(x)
  3. liste.insert(0, x)
  4. len(liste)

c) liste.insert(0, x) — insérer au début nécessite de décaler tous les éléments (O(n)). Préférez collections.deque ou appendleft si vous insérez beaucoup au début.

Question 7

Combien d’itérations pour une recherche dichotomique dans 1024 éléments ?

  1. 1024
  2. 10
  3. 100
  4. 1

b) 10log₂(1024) = 10. Chaque itération divise la plage par 2. La dichotomie est extrêmement rapide.

Question 8

Si un algorithme en O(n²) met 1 seconde pour 10 000 éléments, combien pour 100 000 ?

  1. 10 secondes
  2. 100 secondes (≈ 1,5 minute)
  3. 1 seconde
  4. Imprévisible

b) 100 secondes — l’entrée est multipliée par 10, le temps par 10² = 100. C’est pourquoi O(n²) est vite insupportable.


✏️ Exercice 9.1 — Optimiser un algorithme

On veut savoir combien de valeurs de liste_a sont dans liste_b. Voici une version naïve :

def compter_communs_naif(liste_a, liste_b):
    compteur = 0
    for x in liste_a:
        if x in liste_b:
            compteur += 1
    return compteur
  1. Quelle est sa complexité ?
  2. Proposez une version optimisée et donnez sa complexité.
  3. Comparez les temps pour a = b = list(range(10_000)).
import time

def compter_communs_naif(a, b):
    compteur = 0
    for x in a:
        if x in b:
            compteur += 1
    return compteur

def compter_communs_opti(a, b):
    ...

# Benchmark
a = list(range(10_000))
b = list(range(5_000, 15_000))

# Mesurer les deux versions
import time

def compter_communs_naif(a, b):
    compteur = 0
    for x in a:
        if x in b:              # O(m) à chaque itération
            compteur += 1
    return compteur

def compter_communs_opti(a, b):
    b_set = set(b)              # O(m) une seule fois
    compteur = 0
    for x in a:
        if x in b_set:          # O(1) !
            compteur += 1
    return compteur

# Benchmark — on utilise perf_counter() (précision µs)
# plutôt que time.time() (résolution ~15 ms sur Windows)
a = list(range(10_000))
b = list(range(5_000, 15_000))

debut = time.perf_counter()
r1 = compter_communs_naif(a, b)
t1 = time.perf_counter() - debut

debut = time.perf_counter()
r2 = compter_communs_opti(a, b)
t2 = time.perf_counter() - debut

print(f"Version naïve    : {r1} en {t1*1000:.1f} ms")
print(f"Version optimisée : {r2} en {t2*1000:.3f} ms")
print(f"Gain : {t1/t2:.0f}× plus rapide")
Version naïve    : 5000 en 313.2 ms
Version optimisée : 5000 en 0.400 ms
Gain : 783× plus rapide

Complexités :

  • Naïve : O(n × m) — pour chaque élément de a, on parcourt b.
  • Optimisée : O(n + m) — conversion en set puis test O(1).
a = list(range(10_000))
b = list(range(5_000, 15_000))

r = len(set(a) & set(b))
print(f"Intersection : {r}")
Intersection : 5000

✏️ Exercice 9.2 — Estimer une complexité

Pour chacun de ces codes, donnez la complexité en fonction de n = len(liste).

# Code 1
def f1(liste):
    total = 0
    for x in liste:
        total += x
    return total

# Code 2
def f2(liste):
    for x in liste:
        for y in liste:
            print(x, y)

# Code 3
def f3(liste):
    return sorted(liste)

# Code 4
def f4(liste):
    return liste[0]

# Code 5
def f5(liste):
    i = 0
    while i < len(liste):
        i *= 2 if i else 1
        i += 1
    return i

Code 1 : O(n) — une boucle sur liste.

Code 2 : O(n²) — deux boucles imbriquées, chacune de n itérations → n × n.

Code 3 : O(n log n)sorted utilise Timsort.

Code 4 : O(1) — accès par indice, ne dépend pas de la taille.

Code 5 : O(log n)i double à chaque itération, donc on atteint n en environ log₂(n) étapes.


✏️ Exercice 9.3 — Mesure pratique

Comparez empiriquement les temps de ces 3 approches pour dédupliquer une liste :

  1. Boucle avec if x not in resultat.
  2. set(liste).
  3. dict.fromkeys(liste) (préserve l’ordre).
import time
import random

random.seed(42)
liste = [random.randint(1, 1000) for _ in range(50_000)]

# Version 1 : boucle naïve
def dedup_naif(l):
    resultat = []
    for x in l:
        if x not in resultat:
            resultat.append(x)
    return resultat

# Version 2 : set
def dedup_set(l):
    return list(set(l))

# Version 3 : dict.fromkeys (ordre préservé)
def dedup_dict(l):
    return list(dict.fromkeys(l))

# Mesurer chacune
import time
import random

random.seed(42)
liste = [random.randint(1, 1000) for _ in range(50_000)]

def dedup_naif(l):
    resultat = []
    for x in l:
        if x not in resultat:
            resultat.append(x)
    return resultat

def dedup_set(l):
    return list(set(l))

def dedup_dict(l):
    return list(dict.fromkeys(l))


for fonction in [dedup_naif, dedup_set, dedup_dict]:
    debut = time.time()
    r = fonction(liste)
    duree = time.time() - debut
    print(f"{fonction.__name__:<12} : {duree*1000:>7.1f} ms, taille résultat = {len(r)}")
dedup_naif   :   115.0 ms, taille résultat = 1000
dedup_set    :     0.0 ms, taille résultat = 1000
dedup_dict   :     1.5 ms, taille résultat = 1000

Analyse :

  • dedup_naif : O(n²)x not in resultat est O(n), multiplié par n éléments.
  • dedup_set : O(n) — construction du set en O(n), mais l’ordre n’est pas préservé.
  • dedup_dict : O(n) — construction du dict en O(n), l’ordre est préservé.

Leçon : sur de grosses listes, dedup_naif devient ingérable. Toujours préférer set ou dict.fromkeys selon qu’on veut l’ordre ou non.


À retenir

Points clés du chapitre
  1. Big O exprime l’évolution du temps/mémoire avec la taille des données.
  2. Complexités importantes : O(1) (constant) < O(log n) (dicho) < O(n) (parcours) < O(n log n) (tri) < O(n²) (boucles imbriquées).
  3. Sets et dicts : opérations en O(1) (recherche, insertion). Listes : certaines en O(n).
  4. Astuce classique : convertir une liste en set si on fait beaucoup de in.
  5. insert(0) et pop(0) sur liste sont O(n). Utilisez deque pour des insertions/suppressions en début.
  6. Le tri Python (Timsort) est O(n log n).
  7. Mesurez avec timeit avant d’optimiser — ne spéculez pas.
  8. N’optimisez pas prématurément : clarté d’abord, performance ensuite si besoin.

← Chapitre précédent : pip et l’écosystèmeChapitre suivant : Pattern matching →