Complexité algorithmique
Cours · Python (école d'ingénieur), chapitre 6 · école d'ingénieur, informatique · le début du chapitre, la suite sur Adloun
6.1 Introduction et Motivation
Déterminer la complexité d'un algorithme (aussi appelée son coût) consiste à évaluer les ressources nécessaires à son exécution : essentiellement le temps de calcul et, dans une moindre mesure, la quantité de mémoire requise. Ces besoins dépendent de nombreux paramètres matériels (puissance du processeur, vitesse d'accès à la mémoire) qui sortent du domaine théorique de l'informatique.
Il est donc impossible d'attribuer une valeur absolue (en secondes ou en octets) à un algorithme. En revanche, nous pouvons évaluer son ordre de grandeur de croissance en fonction de la taille de l'entrée. Cela permet d'identifier l'algorithme le plus efficace parmi plusieurs résolvant le même problème.
Considérons un exemple concret : la recherche du nombre de diviseurs d'un entier naturel .
|
|
|---|
Dans diviseurs1, la boucle s'exécute fois. Le temps d'exécution est proportionnel à . Si on multiplie par 100, le temps de calcul est multiplié par 100 (complexité linéaire).
Dans diviseurs2, on exploite le fait que si divise , alors divise aussi . L'un de ces deux diviseurs est inférieur ou égal à . Il suffit donc de chercher les diviseurs inférieurs ou égaux à , la boucle s'exécutant fois. Le temps d'exécution est proportionnel à . Si on multiplie par 100, le temps n'est multiplié que par 10 (complexité en racine carrée).
6.2 Modèle de Calcul et Instructions Élémentaires
Pour évaluer la complexité d'un algorithme de manière uniforme, on définit un modèle de calcul (généralement la machine RAM à processeur unique) où certaines opérations de base, dites instructions élémentaires, ont un coût constant (noté ) indépendant de leurs opérandes. Parmi elles :
- Les opérations arithmétiques basiques (
+,-,*,/,%,//). - Les comparaisons (
==,!=,<,>,<=,>=). - Les opérations logiques (
and,or,not). - Les transferts de données (lecture/écriture dans une case mémoire ou une variable).
- Les instructions de contrôle (branchements conditionnels, retours de fonctions).
En Python, certaines opérations syntaxiquement simples cachent des coûts élevés :
L = list(range(1000))
x = L[500] # O(1) : accès indexé (tableau dynamique)
x = L.index(500) # O(n) : parcourt la liste jusqu'à trouver 500
print(500 in L) # O(n) : parcourt toute la liste dans le pire cas
copie = L[100:200] # O(k) : crée une nouvelle liste de taille k (copie!)
L.sort() # O(n log n) : tri en place
De même, la comparaison de deux chaînes de caractères de longueur a une complexité dans le pire des cas.
6.3 Notations de Landau (, , , )
Soit le nombre d'instructions élémentaires exécutées par un algorithme pour une entrée de taille . Pour exprimer son comportement asymptotique (quand devient grand), on utilise les notations suivantes :
- Grand O () : Borne supérieure asymptotique. Le coût de l'algorithme ne dépasse pas un certain multiple de pour les grandes valeurs de :
- Grand Oméga () : Borne inférieure asymptotique (meilleur des cas) :
- Grand Thêta () : Encadrement asymptotique serré. L'ordre de grandeur est exactement proportionnel à :
- Petit o () : Négligeabilité. Le rapport tend vers 0 quand :
Pour :
- (car pour )
- (car )
- (car encadrement serré)
- (car )
- (car le rapport tend vers 3, pas vers 0)
6.3.1 Échelles de Complexité
Le tableau suivant illustre la différence d'échelle colossale du temps de calcul théorique requis pour différents types de complexité, sur une base matérielle de opérations par seconde (1 GHz) :
| Taille | ||||||
|---|---|---|---|---|---|---|
| 7 ns | 100 ns | 0.7 s | 10 s | 1 ms | ans | |
| 10 ns | 1 s | 10 s | 1 ms | 1 s | ans | |
| 13 ns | 10 s | 133 s | 100 ms | 17 s | Indéfini | |
| 17 ns | 100 s | 2 ms | 10 s | 11.6 jours | Indéfini | |
| 20 ns | 1 ms | 20 ms | 17 min | 32 ans | Indéfini |
On qualifie classiquement les complexités de la façon suivante :
| Complexité | Qualification | Exemple d'algorithme |
|---|---|---|
| Constant | Accès à un élément de liste par index | |
| Logarithmique | Recherche dichotomique | |
| Racine | Recherche des diviseurs optimisée | |
| Linéaire | Parcours de liste, recherche linéaire | |
| Quasi-linéaire | Tri fusion, tri rapide (moyenne) | |
| Quadratique | Tri à bulles, double boucle pleine | |
| Cubique | Produit matriciel naïf | |
| Exponentielle | Fibonacci récursif, sous-ensembles | |
| Factorielle | Problème du voyageur de commerce (force brute) |
6.3.2 Complexités des Structures de Données Python
Le tableau suivant récapitule les complexités temporelles des opérations courantes sur les structures de données natives de Python :
| Opération | list | dict | set |
|---|---|---|---|
| Accès par index/clé | --- | ||
| Affectation par index/clé | --- | ||
Recherche (x in S) | |||
Insertion à la fin (append) | amorti | --- | --- |
| Insertion au début / milieu | --- | --- | |
| Ajout d'une clé/valeur | --- | ||
| Suppression par valeur/clé | |||
| Parcours | |||
Tri (sort()) | --- | --- |
La complexité amortie est le coût moyen d'une opération sur une séquence d'opérations. Par exemple, l'ajout append() sur une liste Python a un coût amorti car la sur-allocation périodique du tableau sous-jacent est rare (le coût de réallocation est réparti sur toutes les insertions).
La suite du cours est sur Adloun
Le chapitre en entier, formules et figures comprises, avec le tuteur, les fiches, les QCM et les exercices.
Lire la suite du coursEn accès libre, sans inscription.