- Accueil
- TUTORIELS
- COMPLEXITE DES ALGORITHMES
- Optimiser sans se tromper de combat
CHAPITRE 10 : OPTIMISER SANS SE TROMPER DE COMBAT
La complexité n'est pas un exercice scolaire : c'est un outil de décision. Ce dernier chapitre dit comment s'en servir, et quand ne pas s'en servir.
Le réflexe : combien d'éléments ?
Avant d'ouvrir le code, posez la question de la taille. Elle dicte à elle seule la famille de complexité acceptable.
| Taille de n | Complexité tolérable | Ce qu'on peut se permettre |
|---|---|---|
| n ≤ 10 | O(n!) | toutes les permutations |
| n ≤ 25 | O(2ⁿ) | tous les sous-ensembles |
| n ≤ 500 | O(n³) | triple boucle |
| n ≤ 5 000 | O(n²) | double boucle |
| n ≤ 1 000 000 | O(n log n) | tri, découpage |
| n > 10 000 000 | O(n) ou O(log n) | une passe, pas plus |
Ce tableau se lit dans les deux sens. Si vous devez traiter dix millions d'enregistrements, vous savez d'emblée qu'aucune double boucle n'est permise. Et si n ne dépasse jamais 20, inutile de chercher une solution élégante : la force brute suffira.
Reconnaître la vraie cause
Quand un programme est lent, deux causes très différentes, et un seul symptôme.
| Mauvaise complexité | Mauvaise constante | |
|---|---|---|
| Symptôme | lent seulement sur les grosses données | lent partout, proportionnellement |
| Doubler les données | ×4, ×8, ou pire | ×2 |
| Remède | changer d'algorithme ou de structure | profiler, enlever le gaspillage |
| Gain possible | facteur 100, 1 000, ou plus | facteur 2 à 10 |
Le test qui tranche tient en une minute : mesurez sur n, puis sur 2n. Si le temps double, votre algorithme est bon et c'est l'implémentation qu'il faut soigner. S'il quadruple, aucune micro-optimisation ne vous sauvera.
N'optimisez pas ce qui ne pèse rien. Dans la plupart des programmes, l'essentiel du temps se passe dans une toute petite partie du code. Rendre deux fois plus rapide une fonction qui consomme 2 % du temps total fait gagner 1 %. Mesurez d'abord où le temps part, avec un profileur, pas à l'intuition, puis travaillez là et nulle part ailleurs.
Changer de structure, souvent, suffit
La plupart des gains spectaculaires ne viennent pas d'un algorithme astucieux mais du bon choix de structure de données.
| Opération | Liste | Ensemble / dict. | Liste triée | File à priorité |
|---|---|---|---|---|
| Accès par position | O(1) | - | O(1) | - |
| Chercher une valeur | O(n) | O(1) | O(log n) | O(n) |
| Insérer | O(1) au bout | O(1) | O(n) | O(log n) |
| Supprimer une valeur | O(n) | O(1) | O(n) | O(log n) |
| Le minimum | O(n) | O(n) | O(1) | O(1) |
| Parcourir dans l'ordre | O(n log n) | O(n log n) | O(n) | O(n log n) |
Les trois réflexes qui règlent le plus de problèmes :
- on cherche souvent une valeur → ensemble ou dictionnaire ;
- on redemande le minimum → file à priorité ;
- on retire toujours en tête →
collections.deque, oùpopleft()est O(1) contre O(n) pourliste.pop(0).
Un exemple complet
Problème : parmi n entiers, trouver deux valeurs dont la somme vaut une cible.
# version 1 : toutes les paires : O(n²)
def paire_v1(tab, cible):
for i in range(len(tab)):
for j in range(i + 1, len(tab)):
if tab[i] + tab[j] == cible:
return (i, j)
return None
# version 2 : trier puis deux index : O(n log n), espace O(1)
def paire_v2(tab, cible):
t = sorted(tab)
g, d = 0, len(t) - 1
while g < d:
s = t[g] + t[d]
if s == cible: return (t[g], t[d])
if s < cible: g += 1
else: d -= 1
return None
# version 3 : un dictionnaire : O(n), espace O(n)
def paire_v3(tab, cible):
vus = {}
for i, x in enumerate(tab):
if cible - x in vus: # O(1)
return (vus[cible - x], i)
vus[x] = i
return None
Sur un million d'entiers : la version 1 demande 5 × 10¹¹ comparaisons, soit plusieurs jours. La version 3 fait une passe, et répond en une fraction de seconde. Même problème, même langage, trois idées différentes.
Remarquez que la version 3 n'est pas gratuitement meilleure : elle réclame O(n) de mémoire, là où la version 2 travaille en espace constant. C'est encore l'échange du chapitre 9.
Ce que la complexité ne dit pas
Pour finir, les limites de l'outil : les connaître évite de le mal employer.
- Les constantes. Deux algorithmes en n log n peuvent différer d'un facteur 5.
- La mémoire cache. Un parcours de tableau contigu est plusieurs fois plus rapide qu'un parcours de liste chaînée, à complexité identique, parce que le processeur lit la mémoire par blocs.
- Les entrées-sorties. Un accès disque coûte des dizaines de milliers de fois une opération en mémoire. Notre modèle fait semblant que tout coûte pareil.
- Les petites données. Sous quelques dizaines d'éléments, la complexité ne décide de rien.
- Le parallélisme. Un O(n²) réparti sur mille cœurs peut battre un O(n log n) séquentiel.
La complexité reste pourtant le premier outil à sortir, parce qu'elle est la seule qui prédise ce qui arrivera quand les données grossiront. Les autres considérations n'interviennent qu'ensuite : une fois qu'on a écarté les algorithmes qui ne tiendront jamais la charge.
À retenir
- La taille des données décide de la complexité qu'on peut se permettre.
- Mesurez sur n puis sur 2n : le temps double → bonne complexité, mauvaise constante ; il quadruple → mauvaise complexité.
- Le plus grand gain vient souvent du choix de la structure de données, pas d'un algorithme savant.
- Optimisez d'abord l'endroit où le temps passe réellement, mesuré, pas supposé.
- La complexité prédit la montée en charge. Pour le reste, il faut encore mesurer.
Exercices d'application
Voir le corrigeMasquer le corrige
Situation actuelle. Chaque requête parcourt en moyenne la moitié du catalogue, soit 100 000 comparaisons.
50 000 x 100 000 = 5 milliards de comparaisons par jourRemède. Construire une fois un dictionnaire indexé par la clé de recherche : O(n) au démarrage, puis O(1) par requête.
index = {a.reference: a for a in catalogue} # une fois, 200 000 operations
...
article = index.get(reference) # O(1) par requeteOn passe de 5 milliards d'opérations quotidiennes à 50 000, plus une construction de 200 000 au démarrage. Le facteur est de l'ordre de 100 000.
Coût à accepter : la mémoire de l'index, et sa mise à jour quand le catalogue change. C'est le compromis du chapitre 9, et ici il est évidemment favorable.
Voir le corrigeMasquer le corrige
Quatre questions, dans cet ordre.
1. Quelle est la taille maximale de n, aujourd'hui et dans deux ans ? C'est la question décisive. Jusqu'à quelques milliers d'éléments, le quadratique simple gagne souvent en pratique.
2. Cette taille peut-elle exploser ? Si les données viennent des utilisateurs, elles croîtront. Un quadratique qui tient aujourd'hui sera la panne de l'an prochain.
3. Combien de fois l'appelle-t-on ? Une fois par nuit dans un traitement par lots, ou à chaque clic ?
4. Qui maintiendra ce code ? Un algorithme compliqué est un algorithme qu'on finira par casser.
Décision raisonnable : si n reste plafonné et petit, le simple ; si n peut grandir sans borne, le n log n, même compliqué, mais alors on le teste sérieusement et on écrit un commentaire expliquant pourquoi il est là.
resultat = ""
vus = []
for ligne in lignes:
cle = ligne.split(";")[0]
if cle not in vus:
vus.append(cle)
resultat += ligne + "\n"Voir le corrigeMasquer le corrige
Défaut 1 : cle not in vus sur une liste parcourt tout ce qui a déjà été vu. Coût O(n) à chaque tour → O(n²).
Défaut 2 : resultat += ligne recopie toute la chaîne accumulée à chaque ajout → encore O(n²).
Deux quadratiques dans six lignes. Correction :
vus = set() # au lieu d'une liste
morceaux = [] # on accumule, on assemble a la fin
for ligne in lignes:
cle = ligne.split(";")[0]
if cle not in vus: # O(1) maintenant
vus.add(cle)
morceaux.append(ligne)
resultat = "\n".join(morceaux) # une seule allocation
# O(n) au totalLes 40 secondes tombent sous la demi-seconde. Aucun algorithme n'a changé : seulement deux structures de données.
Cherchez avant d'ouvrir le corrige : c'est en butant sur les difficultes que l'on progresse.
Questions / Réponses
Ajouter un commentaire