Le carnet On est
Chapitre 5 · encre sienne

La complexité algorithmique

Quantifier la performance d'un algorithme. Notations O, Ω, Θ. Analyse des algorithmes itératifs, récursifs, et "diviser pour régner". Cas Fibonacci, tri fusion, théorème Master.

~ 45 min Source : Cours théorique (3 chapitres) + série 1 12 flashcards · 7 QCM

📄 Ressources du cours

📥 Télécharger les slides  ·  🔗 Ouvrir dans un nouvel onglet  ·  📝 Ouvrir le TD

🎥 Vidéos recommandées

1. Généralités sur la complexité

Une mesure de complexité estime le temps de calcul ou l'espace mémoire utilisé par un algorithme.

Complexité temporelle Nombre d'opérations élémentaires qui composent l'algorithme. Une op élémentaire a un coût constant : affectation, addition, multiplication, comparaison… Toutes les ops élémentaires sont considérées à égalité de coût.
Complexité spatiale Taille de la mémoire nécessaire pour stocker les différentes structures de données utilisées pendant l'exécution.
🔑 Indépendance
Le calcul de complexité se fait indépendamment de :
  • la machine sur laquelle s'exécute l'algorithme
  • le langage de programmation utilisé
On compte les opérations en fonction de la taille du problème, notée n.

Notations

  • A : un algorithme
  • D(n) : l'ensemble des données de taille n
  • P(d) : probabilité de la donnée d
  • Coût_A(d) : coût de l'algorithme pour la donnée d

2. Calcul — algorithmes non récursifs

Sans structures de contrôle

Dénombrer simplement les opérations successives :

def celsius_fahrenheit(C):
   F = C * 1.8 + 32     # 2 op arithmetiques + 1 affectation
   return F            # T(n) = 3, complexite constante

Structures conditionnelles

La complexité dépend des données. Trois formes de complexité :

CasDéfinitionFormule
Meilleursituation la plus favorableT(n) = mind∈D(n) {Coût_A(d)}
Piresituation la plus défavorableT(n) = maxd∈D(n) {Coût_A(d)}
Moyenneselon une loi de proba pT(n) = Σd∈D(n) {p(d) · Coût_A(d)}
📌 Convention
On calcule le plus souvent la complexité dans le pire des cas — c'est elle qui est pertinente pour garantir une borne. Toujours envisager le pire.

Exemple — structure conditionnelle

def f(x, y):
   if x > y:
      h = x * x          # 1 op arith + 1 affectation
   else:
      h = y * y + 1      # 2 op arith + 1 affectation
   return h
# Comparaison : 1
# Pire des cas (else) : 1 + 3 = 4
# Meilleur cas (if)   : 1 + 2 = 3
# Moyen (proba 0.5)   : 1 + 0.5×2 + 0.5×3 = 3.5

Structures itératives

Temps total = somme du coût du test + corps de la boucle + gestion du compteur, multiplié par le nombre d'itérations.

def somme_1(n):
   S = 0
   for i in range(n):
      S = S + i            # n iterations -> O(n)
   return S

def somme_2(n):
   S = n * (n + 1) // 2    # formule directe -> O(1)
   return S

Deux fonctions équivalentes en résultat, mais somme_1 est linéaire et somme_2 est constante. Le choix d'algorithme prime sur l'optimisation locale.

3. Comportement asymptotique — O, Ω, Θ

Trois notations pour comparer le comportement à l'infini de deux fonctions définies sur ℕ à valeurs dans ℝ⁺.

Borne supérieure asymptotique — grand O
O(g(n)) = {f : ℕ → ℝ⁺ | ∃ k > 0 et n₀ ≥ 0 tels que ∀ n ≥ n₀, 0 ≤ f(n) ≤ k · g(n)}
Si f(n) ∈ O(g(n)), on dit que g(n) est une borne supérieure asymptotique de f(n)g domine f.
Borne inférieure asymptotique — grand Omega
Ω(g(n)) = {f : ℕ → ℝ⁺ | ∃ k > 0 et n₀ ≥ 0 tels que ∀ n ≥ n₀, 0 ≤ k · g(n) ≤ f(n)}
Borne asymptotique — grand Theta
Θ(g(n)) = {f : ℕ → ℝ⁺ | ∃ k₁ > 0, k₂ > 0, n₀ ≥ 0 tels que ∀ n ≥ n₀, 0 ≤ k₁·g(n) ≤ f(n) ≤ k₂·g(n)}
Encadre f à un facteur constant près.
📌 Lien fondamental
f(n) ∈ Θ(g(n))   ⇔   f(n) ∈ O(g(n)) ET f(n) ∈ Ω(g(n)).
Aussi : f(n) ∈ O(g(n))   ⇒   g(n) ∈ Ω(f(n)) (la borne supérieure de l'un est la borne inférieure de l'autre).

Classes de complexité usuelles

O(·)ClasseExemple typiquePour n=100 et 10⁻⁶s/op
1constanteaccès tableau, hash~1 µs
log(n)logarithmiquerecherche dichotomique~7 µs
nlinéaireparcours simple~100 µs
n·log(n)quasi-linéairetris efficaces (fusion, rapide)~660 µs
quadratiquetri à bulles, sélection~10 ms
cubiquemultiplication de matrices naïve~1 s
2ⁿexponentielleHanoï, Fibonacci naïf~10²² années
n!factorielleénumération de permutationsincalculable

À partir de la complexité quadratique, on quitte le « scalable ». Au-delà du cubique, on est dans l'algorithmique de recherche, pas en pratique.

4. Algorithmes récursifs simples

Un algorithme récursif décompose le problème en sous-problèmes de même nature. Sa complexité est une suite définie par récurrence.

Cas 1 — Factorielle

def factorielleRecursive(n):
   if n == 0:
      return 1
   else:
      return n * factorielleRecursive(n - 1)
Équation récurrente
  • Si n = 0 : 1 comparaison ⇒ T(0) = 1
  • Si n > 0 : 1 comparaison + 1 multiplication + 1 soustraction + l'appel récursif ⇒ T(n) = T(n-1) + 3
Suite arithmétique : T(n) = 3n + 1T(n) = Θ(n), linéaire.

Cas 2 — Tours de Hanoï

def hanoi(de, vers, via, n):
   if n == 1:
      print(de, "vers", vers)
   else:
      hanoi(de, via, vers, n - 1)
      print(de, "vers", vers)
      hanoi(via, vers, de, n - 1)
Équation récurrente
  • T(1) = 1
  • T(n) = 2 · T(n-1) + 1
Résolution : T(n) = Σk=0n-1 2k = 2ⁿ - 1T(n) = Θ(2ⁿ), exponentiel.

5. Suites récurrentes linéaires

Définition (ordre 2) Une suite un est récurrente linéaire d'ordre 2 s'il existe a et b tels que pour tout n :
un+2 = a · un+1 + b · un

Équation caractéristique

On résout x² − a·x − b = 0 :

CasSolutionForme générale de un
2 racines distinctes x₁, x₂réellesun = λ·x₁ⁿ + μ·x₂ⁿ
1 racine double xréelleun = λ·xⁿ + μ·n·xⁿ
Pas de solutionNe se présente pas en calcul de complexité

Cas Fibonacci

fib(n) = if n == 0 : 0
         else if n == 1 : 1
         else : fib(n-1) + fib(n-2)

Équation récurrente (en ignorant les constantes) :

T(n) = T(n-1) + T(n-2)

Avec l'équation caractéristique x² − x − 1 = 0, on obtient le nombre d'or φ = (1+√5)/2 :

T(n) = (1/√5) · (φⁿ − (1−φ)ⁿ)

Donc T(n) = Θ(φⁿ) ≈ Θ(1.618ⁿ), exponentiel.

PGCD — un cas plus subtil

def pgcd(x, y):
   if y == 0:
      return x
   else:
      return pgcd(y, x mod y)

Pas d'équation récurrente directe — on s'appuie sur Fibonacci. On montre par récurrence que si pgcd(x, y) fait k divisions avec x > y > 0, alors x ≥ Fk+2 (le (k+2)-ième terme de Fibonacci).

En passant au log avec φ : k ≤ logφ(√5 + 1) − 3.

Conclusion : T(n) = O(log(x)) pour l'algorithme d'Euclide. Logarithmique.

6. Diviser pour régner

Principe en 3 étapes
  • Diviser le problème en sous-problèmes de même nature
  • Régner : résoudre récursivement les sous-problèmes
  • Combiner les solutions pour former la solution finale

Tri par fusion

  1. Diviser le tableau en deux moitiés
  2. Trier récursivement les deux moitiés
  3. Fusionner les deux tableaux triés
  4. Coût de la fusion : Θ(n)

Équation récurrente : T(n) = 2·T(n/2) + Θ(n) avec T(1) = 1.

Théorème Master

Énoncé Soient a ≥ 1, b ≥ 1, et T une équation récurrente T(n) = a · T(n/b) + f(n).
Dans le cas où f(n) = Θ(nd) :
T(n) = ⎧ Θ(nd)              si a < bd
       ⎨ Θ(nd · log(n))      si a = bd
       ⎩ Θ(nlog_b(a))        si a > bd

Interprétation :

  • n = taille du problème
  • a = nombre de sous-problèmes
  • n/b = taille de chaque sous-problème
  • f(n) = coût de la subdivision et de la combinaison

Application : tri par fusion

Tri par fusion : T(n) = 2 · T(n/2) + Θ(n)a=2, b=2, d=1. Donc bd = 2 = aT(n) = Θ(n · log(n)), quasi-linéaire.

Application : multiplication de matrices (Strassen)

Décomposition en sous-matrices : T(n) = 8·T(n/2) + Θ(n²)a=8, b=2, d=2. bd=4 < a=8T(n) = Θ(nlog₂(8)) = Θ(n³). Pas d'amélioration par rapport à l'algorithme naïf. (Strassen avec 7 récursions au lieu de 8 donne Θ(nlog₂(7)) ≈ Θ(n2.81) — sous-cubique).

7. Correction d'un algorithme — invariants

Invariant de boucle Une propriété logique sur les valeurs des variables, qui caractérise tout ou partie de l'état interne du programme et qui doit être toujours vraie.

Démarche pour prouver un algorithme itératif

  1. Analyser les boucles, en commençant par la boucle la plus interne
  2. Concevoir un invariant de boucle
  3. Prouver l'invariant par récurrence :
    • Montrer qu'il est vrai avant la première itération
    • Montrer que s'il est vrai à l'itération k, il l'est à l'itération k+1
  4. Utiliser l'invariant pour prouver la terminaison
  5. Utiliser l'invariant pour prouver que le résultat est correct

Exemple — somme d'un tableau

def somme(t):
   S = 0
   n = len(t)
   for i in range(n):
      S += t[i]
   return S

Invariant proposé : à la fin de l'itération i, S contient la somme des i premiers éléments de t :

Si = Σk=0i-1 t[k]

Preuve par récurrence :

  • Base : S₁ = S_initial + t[0] = t[0]. La propriété est vraie pour i = 1.
  • Hérédité : si Si = Σ t[k] jusqu'à l'index i-1, alors Si+1 = Si + t[i] = Σ jusqu'à i. ✓

L'algorithme termine après n itérations. La complexité est Θ(n).

Validité d'un algorithme récursif

Trois choses à prouver :

  1. Cas d'arrêt : le résultat produit est correct
  2. Hérédité : sous hypothèse que les appels récursifs sont corrects, le résultat l'est aussi
  3. Convergence : toute séquence d'appels conduit à un cas d'arrêt

Réviser le chapitre

🃏 Flashcards

Différence complexité temporelle / spatiale ?
Temporelle = nombre d'opérations élémentaires. Spatiale = taille mémoire pour les structures de données.
Pourquoi calcule-t-on le pire des cas ?
C'est la borne garantie. Plus pertinent en pratique : il vaut mieux toujours envisager le pire pour dimensionner.
Différence O, Ω, Θ ?
O = borne supérieure. Ω = borne inférieure. Θ = encadre exactement (les deux à la fois).
Complexité de la dichotomie ?
O(log n) — on divise la taille par 2 à chaque itération.
Complexité Fibonacci récursif naïf ?
Exponentielle. T(n) = T(n-1) + T(n-2)T(n) = Θ(φⁿ) avec φ = nombre d'or ≈ 1.618.
Complexité des tours de Hanoï ?
Exponentielle : T(n) = 2·T(n-1) + 1 = 2ⁿ - 1 = Θ(2ⁿ).
Complexité du PGCD (Euclide) ?
Logarithmique : O(log x). Démonstration via la suite de Fibonacci.
Principe « diviser pour régner » ?
Diviser en sous-problèmes ② Régner récursivement ③ Combiner. Exemple : tri par fusion.
Théorème Master — quels paramètres ?
Pour T(n) = a·T(n/b) + Θ(nd) : compare a à bd. Trois cas selon <, =, >.
Complexité du tri par fusion ?
Θ(n · log n). Master : a=2, b=2, d=1a = bdn · log n.
Qu'est-ce qu'un invariant de boucle ?
Une propriété logique sur les variables, toujours vraie, qui caractérise l'état interne. Sert à prouver la correction de l'algorithme.
3 conditions à prouver pour un algo récursif ?
Cas d'arrêt correct ② Hérédité : si les appels récursifs sont corrects, l'algo l'est ③ Convergence vers un cas d'arrêt.

✎ Quiz éclair — classer les complexités

1.Quelle complexité pour for i in range(n): for j in range(n): tab[i][j] = i*j ?
  • O(1)
  • O(n)
  • O(n²)
  • O(n log n)
Voir la section du chapitre pour la justification détaillée.
2.Classer n!, 2ⁿ, , log n par ordre croissant asymptotique :
  • n² < log n < 2ⁿ < n!
  • log n < n² < n! < 2ⁿ
  • log n < n² < 2ⁿ < n!
  • n! < 2ⁿ < n² < log n
Voir la section du chapitre pour la justification détaillée.
3.T(n) = T(n-1) + 5 est :
  • O(log n)
  • O(n) (suite arithmétique)
  • O(2ⁿ)
  • O(n²)
Voir la section du chapitre pour la justification détaillée.
4.T(n) = 3·T(n/2) + n. Avec a=3, b=2, d=1 : bd=2 < a=3. Théorème Master :
  • Θ(n)
  • Θ(n log n)
  • Θ(n²)
  • Θ(nlog₂3) ≈ Θ(n1.58)
Voir la section du chapitre pour la justification détaillée.
5.Pour vérifier la correction d'un algo itératif, on utilise :
  • Un invariant de boucle prouvé par récurrence
  • Un compteur d'exécution
  • Une preuve d'absurde
  • Le théorème Master
Voir la section du chapitre pour la justification détaillée.
6.f(n) = 3n² + 5n + 7. Quelle borne minimale satisfait f(n) ∈ O(g(n)) ?
  • g(n) = n
  • g(n) = log n
  • g(n) = √n
  • g(n) = n²
Voir la section du chapitre pour la justification détaillée.
7.Complexité de la recherche dichotomique dans un tableau de n = 2ᵏ éléments :
  • O(2ᵏ)
  • O(k) = O(log₂ n)
  • O(k²)
  • O(1)
Voir la section du chapitre pour la justification détaillée.

Score : 0 / 7 ·

❓ Q/R

Comment résoudre T(n) = 2·T(n/2) + n² ?
Théorème Master avec a=2, b=2, d=2. On compare a à bd = 4 : a = 2 < 4.
Cas 1 de Master ⇒ T(n) = Θ(n²). Le coût du combine domine la récursion.
Pourquoi un fold récursif est tail-recursive pour foldLeft mais pas foldRight ?
foldLeft : l'accumulateur progresse à chaque appel, l'appel récursif est la dernière opération dans le corps. Le compilateur élimine la pile d'appels.
foldRight : on doit attendre le résultat de l'appel récursif (sur la queue) avant de combiner avec l'élément courant. L'appel récursif n'est pas la dernière opération.
Conséquence : foldLeft sur 1 million d'éléments est OK, foldRight peut faire StackOverflowError.
Comment prouver la correction d'un algo récursif ?
Trois choses à vérifier :
  1. Cas d'arrêt : la valeur retournée dans le cas de base est correcte. Ex : fact(0) = 1.
  2. Hérédité : sous hypothèse de récurrence (les appels récursifs renvoient le bon résultat), montrer que la combinaison donne le bon résultat. Ex : si fact(n-1) = (n-1)!, alors n · fact(n-1) = n!.
  3. Convergence : toute séquence d'appels mène à un cas d'arrêt. Ex : n décroît strictement et est borné inférieurement par 0.
À quoi sert le calcul de complexité dans la vraie vie ?
Plusieurs raisons :
  • Choisir entre algorithmes — un tri quasi-linéaire (n log n) bat un quadratique (n²) dès quelques milliers d'éléments.
  • Détecter les goulots — un O(n²) dans une boucle qui tourne sur 1 M d'éléments est suspect.
  • Dimensionner — pour traiter 10⁹ requêtes/jour, on ne peut pas se permettre du linéaire sur 10⁶ items à chaque requête.
  • Communiquer — annoncer « algo O(n log n) » est plus précis que « rapide ».