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 nComplexité tolérableCe qu'on peut se permettre
n ≤ 10O(n!)toutes les permutations
n ≤ 25O(2ⁿ)tous les sous-ensembles
n ≤ 500O(n³)triple boucle
n ≤ 5 000O(n²)double boucle
n ≤ 1 000 000O(n log n)tri, découpage
n > 10 000 000O(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ômelent seulement sur les grosses données lent partout, proportionnellement
Doubler les données×4, ×8, ou pire×2
Remèdechanger d'algorithme ou de structure profiler, enlever le gaspillage
Gain possiblefacteur 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érationListeEnsemble / dict.Liste triéeFile à priorité
Accès par positionO(1)-O(1)-
Chercher une valeurO(n)O(1)O(log n)O(n)
InsérerO(1) au boutO(1)O(n)O(log n)
Supprimer une valeurO(n)O(1)O(n)O(log n)
Le minimumO(n)O(n)O(1)O(1)
Parcourir dans l'ordreO(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) pour liste.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

Exercice 1
Un service reçoit 50 000 requêtes par jour, chacune déclenchant une recherche dans un catalogue de 200 000 articles par parcours de liste. Chiffrez le problème et proposez un remède.
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 jour

Remè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 requete

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

Exercice 2
Vous devez choisir entre un algorithme en O(n²) avec une petite constante et un algorithme en O(n log n) compliqué. Que demandez-vous avant de décider ?
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à.

Exercice 3
Ce code met 40 secondes sur 100 000 lignes. Trouvez les deux défauts de complexité et corrigez-les.
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 total

Les 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

Aucune question. Soyez le premier à poser une question.
★ ★ ★ ★ ★
2 votes. Moyenne 5 sur 5.

Ajouter un commentaire

Anti-spam