CHAPITRE 6 : LIRE LA COMPLEXITÉ DANS UN CODE

Vous savez compter, vous savez simplifier, vous connaissez les familles. Il reste à acquérir le réflexe : regarder un code et annoncer sa complexité sans crayon.

Deux règles, et c'est tout

Règle d'addition. Deux blocs qui se suivent : on garde le plus coûteux.

trier(tab)              # O(n log n)
for x in tab:           # O(n)
    print(x)
# total : O(n log n) : le tri écrase le parcours

Règle de multiplication. Un bloc à l'intérieur d'une boucle : on multiplie.

for i in range(n):          # n fois
    dichotomie(tab, i)      # O(log n) chacune
# total : O(n log n)

Reconnaître les boucles

Le nombre de tours ne se lit pas toujours sur la première ligne. Trois schémas à connaître.

# incrément de 1 : n tours        -> O(n)
i = 0
while i < n:
    i += 1

# incrément de 100 : n/100 tours  -> O(n), car 1/100 est une constante
i = 0
while i < n:
    i += 100

# multiplication par 2 : log₂ n tours -> O(log n)
i = 1
while i < n:
    i *= 2

La question à se poser : la variable de boucle s'ajoute-t-elle ou se multiplie-t-elle ? Ajouter donne du linéaire, multiplier donne du logarithmique.

Les boucles imbriquées dépendantes

Quand la boucle intérieure dépend de l'extérieure, il faut sommer.

for i in range(n):
    for j in range(i):     # i tours, pas n
        ...

Total : 0 + 1 + 2 + … + (n−1) = n(n−1)/2, soit O(n²). La moitié du travail de la version carrée, mais la même famille.

Voici un cas plus retors, qu'on croit souvent quadratique :

i = 1
while i < n:
    for j in range(n):     # n tours
        ...
    i *= 2                 # log n tours de la boucle externe

La boucle externe multiplie : log n tours. L'interne en fait n. Total : O(n log n).

Le piège des bibliothèques

Un appel de fonction ne coûte pas une opération : il coûte ce que la fonction fait. Voici le tableau qui sauve le plus de programmes.

Opération PythonComplexitéPourquoi
len(liste)O(1)la taille est stockée
liste[i]O(1)accès direct
liste.append(x)O(1) amortivoir chapitre 8
liste.insert(0, x)O(n)tout décaler
liste.pop(0)O(n)tout décaler
x in listeO(n)parcours
x in ensembleO(1)hachage
x in dicoO(1)hachage
liste.sort()O(n log n)Timsort
min(), max(), sum()O(n)parcours
chaine + chaineO(n)recopie complète

Le classique qui tue. Ce code paraît linéaire :

uniques = []
for x in donnees:            # n tours
    if x not in uniques:     # O(n) a chaque tour !
        uniques.append(x)

Il est en fait O(n²), car le in sur une liste reparcourt tout. Avec un ensemble à la place, on retombe en O(n) :

vus = set()
uniques = []
for x in donnees:
    if x not in vus:         # O(1)
        vus.add(x)
        uniques.append(x)

Sur 100 000 valeurs, la première version met plusieurs minutes, la seconde une fraction de seconde. Une ligne de différence.

La concaténation de chaînes

Même histoire, autre déguisement.

# O(n²) : chaque + recopie toute la chaîne construite jusque-là
texte = ""
for mot in mots:
    texte += mot + " "

# O(n) : on accumule, on assemble une seule fois
texte = " ".join(mots)

En Python, les chaînes sont immuables : texte += mot ne modifie pas texte, il en fabrique une copie neuve. Au tour numéro k, cette copie fait k caractères. Total : 1 + 2 + … + n, encore notre somme en n²/2.

Les fonctions qu'on écrit soi-même

Pour analyser une fonction qui en appelle d'autres, on remplace chaque appel par sa complexité, puis on applique les deux règles.

def analyser(donnees):          # n éléments
    propres = nettoyer(donnees)     # O(n)
    propres.sort()                  # O(n log n)
    doublons = compter_doublons(propres)  # O(n), liste triée
    return resumer(doublons)        # O(1)

Addition de quatre blocs : O(n) + O(n log n) + O(n) + O(1). On garde le plus gros : O(n log n).

La marche à suivre

  1. Qu'est-ce que n ? Le nombre d'éléments ? La longueur d'une chaîne ? La valeur d'un entier ? Toute analyse commence là.
  2. Repérer les boucles, et pour chacune compter les tours.
  3. Vérifier chaque appel : un in, un + sur des chaînes, un insert(0, …) cachent des parcours.
  4. Multiplier l'imbriqué, garder le maximum de ce qui se suit.

À retenir

  • Blocs successifs : on garde le pire. Blocs imbriqués : on multiplie.
  • Variable qui s'ajoute → linéaire. Variable qui se multiplie → logarithmique.
  • Les coûts cachés des bibliothèques transforment un linéaire en quadratique sans qu'une ligne ne l'annonce.
  • in sur une liste : O(n). Sur un ensemble : O(1). Retenez cette seule ligne et vous éviterez la moitié des programmes lents.

Exercices d'application

Exercice 1
Quelle est la complexité de ce code ?
for i in range(n):
    j = 1
    while j < n:
        j *= 3
Voir le corrigeMasquer le corrige

La boucle externe fait n tours. L'interne multiplie j par 3 : elle s'arrête après log₃ n tours.

T(n) = n × log₃ n → O(n log n)

La base 3 disparaît : passer de log₃ à log₂ revient à multiplier par une constante.

Le piège était de voir deux boucles imbriquées et de répondre n². Regardez toujours comment la variable progresse.

Exercice 2
Ce code est censé compter les valeurs communes à deux listes de n éléments. Quelle est sa complexité, et comment faire mieux ?
c = 0
for x in a:
    if x in b:
        c += 1
Voir le corrigeMasquer le corrige

La boucle fait n tours, et chaque x in b parcourt les n éléments de b.

T(n) = n × n → O(n²)

Il suffit de convertir b en ensemble, une seule fois :

vus = set(b)      # O(n), une fois
c = 0
for x in a:       # n tours
    if x in vus:  # O(1) maintenant
        c += 1

# total : O(n)

Sur deux listes de 50 000 éléments, on passe de 2,5 milliards de comparaisons à 100 000. Le code ne change que d'une ligne.

Exercice 3
Pourquoi ces deux fonctions, qui renvoient le même résultat, n'ont-elles pas la même complexité ?
# A
def inverser(s):
    r = ""
    for c in s:
        r = c + r
    return r

# B
def inverser(s):
    return s[::-1]
Voir le corrigeMasquer le corrige

Version A : O(n²). À chaque tour, c + r fabrique une chaîne neuve en recopiant tout r. Au tour k, cela coûte k caractères. Total : 1 + 2 + … + n = n(n+1)/2.

Version B : O(n). Le découpage inversé alloue la chaîne finale une fois et la remplit à l'envers. Une seule passe.

La leçon : en Python, toute accumulation dans une chaîne par + à l'intérieur d'une boucle est quadratique. Accumulez dans une liste, puis "".join(...).

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