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émentsgrande_liste =list(range(100_000))debut = time.time()for _ inrange(1000): # on fait 1000 recherches _ =99_999in grande_listeduree = time.time() - debutprint(f"Recherche dans une liste : {duree*1000:.0f} ms")
Recherche dans une liste : 497 ms
Version 2 — même chose avec un set
import timegrand_set =set(range(100_000))debut = time.time()for _ inrange(1000): _ =99_999in grand_setduree = time.time() - debutprint(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 inrange(1_000_000)}# Accès à un élément : O(1) — aussi rapide que pour 10 élémentsprint(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 =0for x in liste: total += x# Rechercher un élément dans une liste : O(n)print(999_999in 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 dequed = 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 inenumerate(liste):for j, b inenumerate(liste):if i < j and a == b and a notin doublons: doublons.append(a)return doublonsprint(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)returnlist(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 =0while gauche <= droite: iterations +=1 milieu = (gauche + droite) //2if liste_triee[milieu] == cible:return milieu, iterationselif liste_triee[milieu] < cible: gauche = milieu +1else: droite = milieu -1return-1, iterations# Sur un million d'élémentsgrande =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 randomrandom.seed(42)liste = [random.randint(1, 1000) for _ inrange(100_000)]import timedebut = time.time()trie =sorted(liste)duree = time.time() - debutprint(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 timea =list(range(10_000))b =list(range(5_000, 15_000))debut = time.time()communs =0for x in a:if x in b: # chaque test : O(m) dans une liste communs +=1duree = time.time() - debutprint(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 timea =list(range(10_000))b =list(range(5_000, 15_000))debut = time.time()b_set =set(b) # conversion : O(m)communs =0for x in a:if x in b_set: # test : O(1) communs +=1duree = time.time() - debutprint(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éesfor 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.
Le module timeitrépète l’opération et renvoie la durée moyenne — plus fiable.
import timeitduree = 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) ?
O(1)
O(log n)
O(n)
O(n²)
🔍 Réponse
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 ?
O(1)
O(log n)
O(n)
O(n²)
🔍 Réponse
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 million
~20 millions
~1 000 milliards
Impossible à estimer
🔍 Réponse
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)
O(1)
O(n)
O(n²)
O(n log n)
🔍 Réponse
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 ?
Une liste
Un tuple
Un set
Une chaîne
🔍 Réponse
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 ?
liste[i] (accès)
liste.append(x)
liste.insert(0, x)
len(liste)
🔍 Réponse
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 ?
1024
10
100
1
🔍 Réponse
b) 10 — log₂(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 ?
10 secondes
100 secondes (≈ 1,5 minute)
1 seconde
Imprévisible
🔍 Réponse
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 =0for x in liste_a:if x in liste_b: compteur +=1return compteur
Quelle est sa complexité ?
Proposez une version optimisée et donnez sa complexité.
Comparez les temps pour a = b = list(range(10_000)).
import timedef compter_communs_naif(a, b): compteur =0for x in a:if x in b: compteur +=1return compteurdef compter_communs_opti(a, b): ...# Benchmarka =list(range(10_000))b =list(range(5_000, 15_000))# Mesurer les deux versions
import timedef compter_communs_naif(a, b): compteur =0for x in a:if x in b: # O(m) à chaque itération compteur +=1return compteurdef compter_communs_opti(a, b): b_set =set(b) # O(m) une seule fois compteur =0for x in a:if x in b_set: # O(1) ! compteur +=1return 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() - debutdebut = time.perf_counter()r2 = compter_communs_opti(a, b)t2 = time.perf_counter() - debutprint(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 1def f1(liste): total =0for x in liste: total += xreturn total# Code 2def f2(liste):for x in liste:for y in liste:print(x, y)# Code 3def f3(liste):returnsorted(liste)# Code 4def f4(liste):return liste[0]# Code 5def f5(liste): i =0while i <len(liste): i *=2if i else1 i +=1return 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 :
Comment éviter O(n²) ?
Utiliser un set pour détecter les doublons en O(n) :
Chaque
x in vusest O(1), donc l’ensemble du code est O(n) — linéairement plus rapide.