xxxxxxxxxx
## I : Introduction
L'analyse de la complexité d'un algorithme consiste en l'étude de la «quantité de ressources nécessaire» à l'exécution de cet algorithme. On distingue pour cela :
- les ressources en temps : la «quantité de ressources nécessaire» est alors le nombre d'étapes de calcul,
- les ressources en espace : la «quantité de ressources nécessaire» est alors le nombre de cases mémoire utilisées.
**En NSI, nous nous intéresserons essentiellement à la complexité en temps.**
### I.1 : Un premier exemple
Supposons que le problème posé soit de trouver un nom dans un annuaire téléphonique qui consiste en une liste triée alphabétiquement de $30 \ 000$ noms. On peut s'y prendre de plusieurs façons différentes. En voici deux :
- *Recherche linéaire* : parcourir les pages dans l'ordre (alphabétique) jusqu'à trouver le nom cherché.
- *Recherche dichotomique* : ouvrir l'annuaire au milieu, si le nom qui s'y trouve est plus loin alphabétiquement que le nom cherché, regarder avant, sinon, regarder après. Refaire l'opération qui consiste à couper les demi-annuaires (puis les quarts d'annuaires, puis les huitièmes d'annuaires, etc.) jusqu'à trouver le nom cherché.
Pour chacune de ces méthodes il existe un pire des cas et un meilleur des cas :
*Recherche linéaire* :
- Le meilleur des cas est celui où le nom est le premier dans l'annuaire, le nom est alors trouvé dès la première étape de l'algorithme.
- Le pire des cas est celui où le nom est le dernier dans l'annuaire, le nom est alors trouvé après avoir parcouru les $30 \ 000$ noms, soit $30 \ 000$ étapes.
*Recherche dichotomique* :
- Le meilleur des cas est celui où le nom est situé au milieu de l'annuaire, le nom est alors trouvé dès la première étape de l'algorithme.
- Le pire des cas est celiu où il faut séparer en deux l'annuaire, puis séparer à nouveau cette sous-partie en deux, ainsi de suite jusqu'à n'avoir qu'un seul nom. Le nombre d'étapes nécessaire sera alors $\log_2(30 \ 000) \approx 15$.
Sur cet exemple nous avons étudié la complexité de deux algorithmes dans le meilleur des cas et dans le pire des cas. Les informaticiens étudient aussi la complexité «en moyenne», c'est à dire en analysant la complexité «moyenne» pour un grand nombre de cas différents choisis aléatoirement.
**En NSI, nous nous intéresserons essentiellement à la complexité dans le pire des cas.**
### I.2 : Complexité en temps dans le pire des cas
La complexité en temps est une mesure du temps utilisé par un algorithme. Le temps compte le nombre d'étapes de calcul avant d'arriver à un résultat. Pour un même algorithme, ce nombre d'étapes peut varier en fonction de la taille des entrées. Ainsi pour les deux algorithmes l'exemple précédent, le nombre d'étapes dans le pire des cas est différent si la liste de noms comporte $n = 30 \ 000$ noms ou si la liste de noms en comporte $n = 500 \ 000$.
- *Recherche linéaire* : la complexité dans le pire des cas est proportionnelle au nombre $n$ de noms dans la liste. On dit que la complexité est en $\mathcal{O}(n)$.
- *Recherche dichotomique* : la complexité dans le pire des cas est proportionnelle au logarithme du nombre $n$ de noms dans la liste. On dit que la complexité est en $\mathcal{O}(\log_2(n))$
**La notation $\mathcal{O}(...)$ nous servira désormais pour indiquer quelle est la complexité d'une instruction ou d'une séquence d'instructions. Cette notation indique que la complexité est proportionnelle à ce qui est indiqué entre parenthèses.**
### I.3 : Qu'est-ce qu'une «étape» de calcul ?
Nous avons dit que la complexité en temps compte le nombre d'étapes de calcul. Mais qu'est-ce qu'une «étape» de calcul ?
Il y a plusieurs façons de définir ces étapes, par exemple le nombre d'opérations dans une machine RAM, ou des mesures plus théoriques comme le nombre de comparaisons dans le cas d'un algorithme de tri ou le nombre de pas d'une machine de Turing.
Plus généralement, ce que l'on entend par «étape» de calcul est une instruction ou une séquence d'instructions qui prend un temps constant pour être exécutée, indépendamment de la taille de l'entrée de l'algorithme.
Dit autrement, une «étape» de calcul est une instruction ou une séquence d'instructions dont la complexité ne dépend pas de la taille $n$ de l'entrée, c'est à dire dont la complexité est constante, c'est à dire dont la complexité est en $\mathcal{O}(1)$.
Voici quatre exemples d'instructions ou séquences d'instructions dont la complexité est en $\mathcal{O}(1)$ et qui sont donc des «étapes» de calcul :
- effectuer l'affectation de variable `a = 2*6`
- effectuer l'affectation de variable `a = (2*7+3)//(3*9-1) + (7-4)**2 + (7+4)**3`
- modifier la valeur d'une case d'un tableau `tab[142] = 17984`
- effectuer une comparaison `a <= 43`
Attention toutefois, certaines instructions qui semblent être des «étapes» de calcul de complexité $\mathcal{O}(1)$ n'en sont pas. Par exemple en Python, insérer un élement en tête d'un tableau `tab.insert(0, 74)` a une complexité en $\mathcal{O}(n)$ où $n$ est la taille du tableau `tab` (en effet cette instruction demande de décaler toutes les $n$ valeurs du tableau vers la droite, soit $n$ étapes de calcul).
**Une «étape» de calcul sera désormais une instruction ou une séquence d'instructions dont la complexité est en $\mathcal{O}(1)$, c'est à dire qui s'exécute en un nombre d'étapes constant.**
L'analyse de la complexité d'un algorithme consiste en l'étude de la «quantité de ressources nécessaire» à l'exécution de cet algorithme. On distingue pour cela :
les ressources en temps : la «quantité de ressources nécessaire» est alors le nombre d'étapes de calcul,
les ressources en espace : la «quantité de ressources nécessaire» est alors le nombre de cases mémoire utilisées.
En NSI, nous nous intéresserons essentiellement à la complexité en temps.
Supposons que le problème posé soit de trouver un nom dans un annuaire téléphonique qui consiste en une liste triée alphabétiquement de $30 \ 000$ noms. On peut s'y prendre de plusieurs façons différentes. En voici deux :
Pour chacune de ces méthodes il existe un pire des cas et un meilleur des cas :
Recherche linéaire :
Recherche dichotomique :
Sur cet exemple nous avons étudié la complexité de deux algorithmes dans le meilleur des cas et dans le pire des cas. Les informaticiens étudient aussi la complexité «en moyenne», c'est à dire en analysant la complexité «moyenne» pour un grand nombre de cas différents choisis aléatoirement.
En NSI, nous nous intéresserons essentiellement à la complexité dans le pire des cas.
La complexité en temps est une mesure du temps utilisé par un algorithme. Le temps compte le nombre d'étapes de calcul avant d'arriver à un résultat. Pour un même algorithme, ce nombre d'étapes peut varier en fonction de la taille des entrées. Ainsi pour les deux algorithmes l'exemple précédent, le nombre d'étapes dans le pire des cas est différent si la liste de noms comporte $n = 30 \ 000$ noms ou si la liste de noms en comporte $n = 500 \ 000$.
Recherche linéaire : la complexité dans le pire des cas est proportionnelle au nombre $n$ de noms dans la liste. On dit que la complexité est en $\mathcal{O}(n)$.
Recherche dichotomique : la complexité dans le pire des cas est proportionnelle au logarithme du nombre $n$ de noms dans la liste. On dit que la complexité est en $\mathcal{O}(\log_2(n))$
La notation $\mathcal{O}(...)$ nous servira désormais pour indiquer quelle est la complexité d'une instruction ou d'une séquence d'instructions. Cette notation indique que la complexité est proportionnelle à ce qui est indiqué entre parenthèses.
Nous avons dit que la complexité en temps compte le nombre d'étapes de calcul. Mais qu'est-ce qu'une «étape» de calcul ?
Il y a plusieurs façons de définir ces étapes, par exemple le nombre d'opérations dans une machine RAM, ou des mesures plus théoriques comme le nombre de comparaisons dans le cas d'un algorithme de tri ou le nombre de pas d'une machine de Turing.
Plus généralement, ce que l'on entend par «étape» de calcul est une instruction ou une séquence d'instructions qui prend un temps constant pour être exécutée, indépendamment de la taille de l'entrée de l'algorithme.
Dit autrement, une «étape» de calcul est une instruction ou une séquence d'instructions dont la complexité ne dépend pas de la taille $n$ de l'entrée, c'est à dire dont la complexité est constante, c'est à dire dont la complexité est en $\mathcal{O}(1)$.
Voici quatre exemples d'instructions ou séquences d'instructions dont la complexité est en $\mathcal{O}(1)$ et qui sont donc des «étapes» de calcul :
a = 2*6
a = (2*7+3)//(3*9-1) + (7-4)**2 + (7+4)**3
tab[142] = 17984
a <= 43
Attention toutefois, certaines instructions qui semblent être des «étapes» de calcul de complexité $\mathcal{O}(1)$ n'en sont pas. Par exemple en Python, insérer un élement en tête d'un tableau tab.insert(0, 74)
a une complexité en $\mathcal{O}(n)$ où $n$ est la taille du tableau tab
(en effet cette instruction demande de décaler toutes les $n$ valeurs du tableau vers la droite, soit $n$ étapes de calcul).
Une «étape» de calcul sera désormais une instruction ou une séquence d'instructions dont la complexité est en $\mathcal{O}(1)$, c'est à dire qui s'exécute en un nombre d'étapes constant.
xxxxxxxxxx
## II : Calculs de complexité
### II.1 : Les règles de calcul
Les calculs de complexité sont basés sur quelques règles simples :
- **Règle 1** : effectuer successivement deux instructions ou deux séquences d'instructions de complexité $\mathcal{O}(f(n))$ conduit à une complexité en $\mathcal{O}(f(n))$,
- **Règle 2** : effectuer $n$ fois une instruction ou une séquence d'instruction de complexité $\mathcal{O}(f(n))$ conduit à une complexité en $\mathcal{O}(n \times f(n))$,
- **Règle 3** : effectuer successivement deux instructions ou séquences d'instructions de complexité $\mathcal{O}(f(n))$ et $\mathcal{O}(g(n))$ conduit à une complexité en $\mathcal{O}(h(n))$ où $h$ est la plus complexe des deux complexités parmi $f$ et $g$.
La règle 1 se justifie par le fait que :
- puisque la première instruction est de complexité $\mathcal{O}(f(n))$, son nombre d'«étapes» est proportionnel à $f(n)$, c'est à dire de la forme $a \times f(n)$ ;
- puisque la seconde instruction est de complexité $\mathcal{O}(f(n))$, son nombre d'«étapes» est proportionnel à $f(n)$, c'est à dire de la forme $b \times f(n)$ ;
- par conséquent les deux instructions ou séquences d'instructions effectuées successivement ont un nombre d'«étapes» de la forme $a \times f(n) + b \times f(n) = (a+b) \times f(n)$, donc proportionnel à $f(n)$, c'est à dire de complexité $\mathcal{O}(f(n))$.
La règle 2 est évidente.
La règle 3 se justifie en effectuant un raisonnement comme pour la règle 1, mais en écrivant que le nombre d'étapes est «moins que proportionnel à $h(n)$».
Les calculs de complexité sont basés sur quelques règles simples :
La règle 1 se justifie par le fait que :
La règle 2 est évidente.
La règle 3 se justifie en effectuant un raisonnement comme pour la règle 1, mais en écrivant que le nombre d'étapes est «moins que proportionnel à $h(n)$».
xxxxxxxxxx
### II.2 : Un premier exemple : algorithme de la recherche du maximum dans un tableau
On note $n$ la taille du tableau non vide d'entiers passé en paramètre à l'algorithme. On peut analyser la complexité en commençant par les commentaires ci-dessous :
On note $n$ la taille du tableau non vide d'entiers passé en paramètre à l'algorithme. On peut analyser la complexité en commençant par les commentaires ci-dessous :
xxxxxxxxxx
def maximum(tab):
val_max = tab[0] # lecture d'un élément dans un tableau : O(1) puis affectation : O(1)
for val in tab:
if val > val_max: # comparaison : O(1)
val_max = val # affectation : O(1)
return val_max # renvoi : O(1)
xxxxxxxxxx
maximum([8, 5, 6, 3, 9, 2, 5, 7, 8, 4, 1, 7])
xxxxxxxxxx
D'après la règle 1, on en déduit que la complexité d'**un seul** passage dans la boucle est en $\mathcal{O}(1)$.
D'après la règle 2, puisqu'on effectue $n$ passages dans la boucle, la complexité de la boucle est $\mathcal{O}(n \times 1) = \mathcal{O}(n)$.
D'après la règle 1, la complexité de la première instruction est en $\mathcal{O}(1)$.
Ce qui donne pour l'instant :
```python
def maximum(tab):
val_max = tab[0] # O(1)
for val in tab: # \
if val > val_max: # } O(n)
val_max = val # /
return val_max # O(1)
```
D'après le règle 3, la complexité de l'algorithme est en $\mathcal{O}(n)$ car la complexité en $\mathcal{O}(n)$ est plus complexe que celles en $\mathcal{O}(1)$.
Finalement, on en déduit que le nombre d'«étapes» nécessaire à l'exécution de cet algorithme est - pour de grands tableaux - proportionnel à la taille du tableau. On peut le constater en chronométrant l'algorithme sur trois grands tableaux de tailles différentes :
D'après la règle 1, on en déduit que la complexité d'un seul passage dans la boucle est en $\mathcal{O}(1)$.
D'après la règle 2, puisqu'on effectue $n$ passages dans la boucle, la complexité de la boucle est $\mathcal{O}(n \times 1) = \mathcal{O}(n)$.
D'après la règle 1, la complexité de la première instruction est en $\mathcal{O}(1)$.
Ce qui donne pour l'instant :
def maximum(tab):
val_max = tab[0] # O(1)
for val in tab: # \
if val > val_max: # } O(n)
val_max = val # /
return val_max # O(1)
D'après le règle 3, la complexité de l'algorithme est en $\mathcal{O}(n)$ car la complexité en $\mathcal{O}(n)$ est plus complexe que celles en $\mathcal{O}(1)$.
Finalement, on en déduit que le nombre d'«étapes» nécessaire à l'exécution de cet algorithme est - pour de grands tableaux - proportionnel à la taille du tableau. On peut le constater en chronométrant l'algorithme sur trois grands tableaux de tailles différentes :
xxxxxxxxxx
from random import randint
from time import perf_counter
def chronometrer(algorithme, tab):
'''Renvoie le temps d'exécution - en secondes - de la fonction algorithme avec le tableau tab.'''
debut = perf_counter()
m = algorithme(tab)
fin = perf_counter()
return fin - debut
taille_1 = 5000000
taille_2 = 2 * taille_1
taille_3 = 3 * taille_1
tab_1 = [randint(1, 1000000000) for _ in range(taille_1)] #tableau aléatoire de taille 3000000
tab_2 = [randint(1, 1000000000) for _ in range(taille_2)] #tableau aléatoire deux fois plus grand
tab_3 = [randint(1, 1000000000) for _ in range(taille_3)] #tableau aléatoire trois fois plus grand
xxxxxxxxxx
Utilisons la fonction `chronometrer` pour chronométrer notre algorithme :
Utilisons la fonction chronometrer
pour chronométrer notre algorithme :
xxxxxxxxxx
temps_1 = chronometrer(maximum, tab_1)
temps_2 = chronometrer(maximum, tab_2)
temps_3 = chronometrer(maximum, tab_3)
xxxxxxxxxx
print(temps_1, ', ', temps_2, ', ', temps_3)
xxxxxxxxxx
On remarque qu'approximativement on a : `temps_2 = 2 * temps_1` et `temps_3 = 3 * temps_1` car les tableaux `tab_2` et `tab_3` sont respectivement deux et trois fois plus grands que `tab_1` :
On remarque qu'approximativement on a : temps_2 = 2 * temps_1
et temps_3 = 3 * temps_1
car les tableaux tab_2
et tab_3
sont respectivement deux et trois fois plus grands que tab_1
:
xxxxxxxxxx
print(temps_2/temps_1)
xxxxxxxxxx
print(temps_3/temps_1)
xxxxxxxxxx
### II.3 : Un second exemple : algorithme de la recherche d'un élément dans un tableau
On note $n$ la taille du tableau non vide d'entiers passé en paramètre à l'algorithme. On peut analyser la complexité en commençant par les commentaires ci-dessous :
On note $n$ la taille du tableau non vide d'entiers passé en paramètre à l'algorithme. On peut analyser la complexité en commençant par les commentaires ci-dessous :
xxxxxxxxxx
def recherche(tab, element):
for val in tab:
if val == element: # comparaison : O(1)
return True # renvoi : O(1)
return False # renvoi : O(1)
xxxxxxxxxx
recherche([8, 5, 6, 3, 9, 2, 5, 7, 8, 4, 1, 7], 6)
xxxxxxxxxx
D'après la règle 1, on en déduit que la complexité d'**un seul** passage dans la boucle est en $\mathcal{O}(1)$.
D'après la règle 2, puisqu'on effectue **au plus** $n$ passages dans la boucle, la complexité de la boucle est **dans le pire des cas** $\mathcal{O}(n \times 1) = \mathcal{O}(n)$.
Ce qui donne pour l'instant :
```python
def recherche(tab, element):
for val in tab: # \
if val == element: # } O(n)
return True # /
return False # renvoi : O(1)
```
D'après le règle 3, la complexité de l'algorithme est en $\mathcal{O}(n)$ car la complexité en $\mathcal{O}(n)$ est plus complexe que celle en $\mathcal{O}(1)$.
Finalement, on en déduit que **dans le pire des cas** le nombre d'«étapes» nécessaire à l'exécution de cet algorithme est - pour de grands tableaux - proportionnel à la taille du tableau.
Notons que le temps d'exécution de cet algorithme est très variable d'une fois à l'autre. En effet lorsque l'élément cherché est présent au début du tableau, le temps d'exécution est très court. Pour s'en rendre compte, il suffit d'exécuter plusieurs fois la cellule de code ci-dessous :
D'après la règle 1, on en déduit que la complexité d'un seul passage dans la boucle est en $\mathcal{O}(1)$.
D'après la règle 2, puisqu'on effectue au plus $n$ passages dans la boucle, la complexité de la boucle est dans le pire des cas $\mathcal{O}(n \times 1) = \mathcal{O}(n)$.
Ce qui donne pour l'instant :
def recherche(tab, element):
for val in tab: # \
if val == element: # } O(n)
return True # /
return False # renvoi : O(1)
D'après le règle 3, la complexité de l'algorithme est en $\mathcal{O}(n)$ car la complexité en $\mathcal{O}(n)$ est plus complexe que celle en $\mathcal{O}(1)$.
Finalement, on en déduit que dans le pire des cas le nombre d'«étapes» nécessaire à l'exécution de cet algorithme est - pour de grands tableaux - proportionnel à la taille du tableau.
Notons que le temps d'exécution de cet algorithme est très variable d'une fois à l'autre. En effet lorsque l'élément cherché est présent au début du tableau, le temps d'exécution est très court. Pour s'en rendre compte, il suffit d'exécuter plusieurs fois la cellule de code ci-dessous :
xxxxxxxxxx
from random import shuffle
from time import perf_counter
tab = [i for i in range(4000000)]
shuffle(tab)
debut = perf_counter()
recherche(tab, 777777)
fin = perf_counter()
print('Temps de recherche de 777777 : ', fin - debut, ' secondes.')
xxxxxxxxxx
### II.4 : Un troisième exemple : algorithme du décompte des éléments d'un tableau
On note $n$ la taille du tableau non vide d'entiers passé en paramètre à l'algorithme. On peut analyser la complexité en commençant par les commentaires ci-dessous :
On note $n$ la taille du tableau non vide d'entiers passé en paramètre à l'algorithme. On peut analyser la complexité en commençant par les commentaires ci-dessous :
xxxxxxxxxx
def decompte(tab):
dico = {} # création d'un dictionnaire vide : O(1)
for val in tab:
if val in dico.keys(): # test de la présence d'une clef : O(1)
dico[val] = dico[val] + 1 # lecture d'une valeur : O(1), addition : O(1), écriture d'une valeur : O(1)
else:
dico[val] = 1 # écriture d'une valeur : O(1)
return dico # renvoi : O(1)
xxxxxxxxxx
decompte([1, 2, 2, 1, 4, 4, 4, 4, 4, 4, 8, 8, 8, 8, 4, 2, 8])
xxxxxxxxxx
D'après la règle 1, on en déduit que la complexité d'**un seul** passage dans la boucle est en $\mathcal{O}(1)$.
D'après la règle 2, puisqu'on effectue $n$ passages dans la boucle, la complexité de la boucle est $\mathcal{O}(n \times 1) = \mathcal{O}(n)$.
Ce qui donne pour l'instant :
```python
def decompte(tab):
dico = {} # O(1)
for val in tab: # \
if val in dico.keys(): # \
dico[val] = dico[val] + 1 # } O(n)
else: # /
dico[val] = 1 # /
return dico # O(1)
```
D'après le règle 3, la complexité de l'algorithme est en $\mathcal{O}(n)$ car la complexité en $\mathcal{O}(n)$ est plus complexe que celles en $\mathcal{O}(1)$.
Finalement, on en déduit que le nombre d'«étapes» nécessaire à l'exécution de cet algorithme est - pour de grands tableaux - proportionnel à la taille du tableau.
Pour cet algorithme, il convient de remarquer quelque chose de surprenant : le test de la présence d'une clef dans un dictionnaire est en $\mathcal{O}(1)$. C'est effectivement surprenant car même si le dictionnaire devient de plus en plus gros au fur et à mesure qu'il se remplit, ce test reste en $\mathcal{O}(1)$ ; alors que pour un tableau la recherche d'un élément dans le tableau est en $\mathcal{O}(n)$. Ceci s'explique par l'implémentation des dictionnaires Python avec des tables de hachage (tables de hachage qui expliquent également pourquoi des containers mutables tels que des tableaux ne peuvent pas être utilisés comme clefs d'un dictionnaire).
**On retiendra de cet exemple que le test d'existence d'une clef dans un dictionnaire a une complexité en $\mathcal{O}(1)$ ce qui est contre-intuitif mais s'explique par l'utilisation de tables de hachage (qui empêchent l'utilisation de clefs telles que des tableaux).**
D'après la règle 1, on en déduit que la complexité d'un seul passage dans la boucle est en $\mathcal{O}(1)$.
D'après la règle 2, puisqu'on effectue $n$ passages dans la boucle, la complexité de la boucle est $\mathcal{O}(n \times 1) = \mathcal{O}(n)$.
Ce qui donne pour l'instant :
def decompte(tab):
dico = {} # O(1)
for val in tab: # \
if val in dico.keys(): # \
dico[val] = dico[val] + 1 # } O(n)
else: # /
dico[val] = 1 # /
return dico # O(1)
D'après le règle 3, la complexité de l'algorithme est en $\mathcal{O}(n)$ car la complexité en $\mathcal{O}(n)$ est plus complexe que celles en $\mathcal{O}(1)$.
Finalement, on en déduit que le nombre d'«étapes» nécessaire à l'exécution de cet algorithme est - pour de grands tableaux - proportionnel à la taille du tableau.
Pour cet algorithme, il convient de remarquer quelque chose de surprenant : le test de la présence d'une clef dans un dictionnaire est en $\mathcal{O}(1)$. C'est effectivement surprenant car même si le dictionnaire devient de plus en plus gros au fur et à mesure qu'il se remplit, ce test reste en $\mathcal{O}(1)$ ; alors que pour un tableau la recherche d'un élément dans le tableau est en $\mathcal{O}(n)$. Ceci s'explique par l'implémentation des dictionnaires Python avec des tables de hachage (tables de hachage qui expliquent également pourquoi des containers mutables tels que des tableaux ne peuvent pas être utilisés comme clefs d'un dictionnaire).
On retiendra de cet exemple que le test d'existence d'une clef dans un dictionnaire a une complexité en $\mathcal{O}(1)$ ce qui est contre-intuitif mais s'explique par l'utilisation de tables de hachage (qui empêchent l'utilisation de clefs telles que des tableaux).
xxxxxxxxxx
<div style="border:1pt solid slateblue; border-radius:5pt; color:slateblue; padding:3px; background-color: lightcyan">
### Résumé :
Pour un algorithme, la complexité en temps dans le pire des cas établit un lien entre :
- d'une part la taille $n$ des données en entrée de l'algorithme (souvent la taille d'un tableau ou d'un dictionnaire),
- d'autre part le nombre d'«étapes» nécessaire pour que l'algorithme se termine dans le pire des cas.
Cette complexité se note $\mathcal{O}(f(n))$ :
- lorsque $f(n) = n$, le nombre d'«étapes» dans le pire des cas est proportionnel à $n$ (pour de grandes valeurs de $n$),
- lorsque $f(n) = 1$, le nombre d'«étapes» dans le pire des cas est constant et ne dépend pas de $n$.
Pour calculer la complexité d'un algorithme, après avoir étudié la complexité de chacune des instructions élémentaires, on utilise les trois règles suivantes :
- **Règle 1** : effectuer successivement deux instructions ou deux séquences d'instructions de complexité $\mathcal{O}(f(n))$ conduit à une complexité en $\mathcal{O}(f(n))$,
- **Règle 2** : effectuer $n$ fois une instruction ou une séquence d'instruction de complexité $\mathcal{O}(f(n))$ conduit à une complexité en $\mathcal{O}(n \times f(n))$,
- **Règle 3** : effectuer successivement deux instructions ou séquences d'instructions de complexité $\mathcal{O}(f(n))$ et $\mathcal{O}(g(n))$ conduit à une complexité en $\mathcal{O}(h(n))$ où $h$ est la plus complexe des deux complexités parmi $f$ et $g$.
Pour étudier la complexité des instructions élémentaires, il faut savoir que le bon sens permet souvent de savoir s'il s'agit de complexité $\mathcal{O}(1)$ ou $\mathcal{O}(n)$ mais qu'il y a de nombreuses exceptions dès qu'on utilise des fonctions Python opérant sur des containers (`list`, `dict`, `tuple`, `str` etc.). Ces exceptions seront l'objet d'un paragraphe plus bas dans ce notebook.
Pour un algorithme, la complexité en temps dans le pire des cas établit un lien entre :
Cette complexité se note $\mathcal{O}(f(n))$ :
Pour calculer la complexité d'un algorithme, après avoir étudié la complexité de chacune des instructions élémentaires, on utilise les trois règles suivantes :
Pour étudier la complexité des instructions élémentaires, il faut savoir que le bon sens permet souvent de savoir s'il s'agit de complexité $\mathcal{O}(1)$ ou $\mathcal{O}(n)$ mais qu'il y a de nombreuses exceptions dès qu'on utilise des fonctions Python opérant sur des containers (list
, dict
, tuple
, str
etc.). Ces exceptions seront l'objet d'un paragraphe plus bas dans ce notebook.
xxxxxxxxxx
## III : Exercices
### Exercice 1 :
On considère généralement (voir l'article sur [l'érosion](https://fr.wikipedia.org/wiki/%C3%89rosion) de Wikipédia) que l'érosion moyenne à la surface de la terre est de $0,05$ mm/an.
On s'intéresse à une île isolée au milieu d'un lac dont la hauteur par rapport à la surface du lac est de $200$ m lors d'une certaine année de référence. On admet que la hauteur de cette île diminue de $0,05$ mm/an. Soit $H_n$ la hauteur de cette île $n$ années après l'année de référence. On a donc :
- $H_{0} = 200$
- $H_{1} = 199,99995$
- $H_{2} = 199,99990$
- $H_{3} = 199,99985$
- $H_{4} = 199,99980$
- $ \ldots $
- $H_n = 200 - 0,00005 \times n$
Les deux fonctions ci-dessous prennent en paramètre un entier $n \geq 0$ et renvoient la hauteur $H$ de la colline l'année $n$ (c'est à dire $n$ années après l'année de référence).
Déterminer la complexité de ces deux fonctions.
```python
def hauteur_v1(n):
h = 200
for i in range(n):
h = h - 0.00005
return h
def hauteur_v2(n):
h = 200 - n * 0.00005
return h
```
On considère généralement (voir l'article sur l'érosion de Wikipédia) que l'érosion moyenne à la surface de la terre est de $0,05$ mm/an.
On s'intéresse à une île isolée au milieu d'un lac dont la hauteur par rapport à la surface du lac est de $200$ m lors d'une certaine année de référence. On admet que la hauteur de cette île diminue de $0,05$ mm/an. Soit $H_n$ la hauteur de cette île $n$ années après l'année de référence. On a donc :
Les deux fonctions ci-dessous prennent en paramètre un entier $n \geq 0$ et renvoient la hauteur $H$ de la colline l'année $n$ (c'est à dire $n$ années après l'année de référence).
Déterminer la complexité de ces deux fonctions.
def hauteur_v1(n):
h = 200
for i in range(n):
h = h - 0.00005
return h
def hauteur_v2(n):
h = 200 - n * 0.00005
return h
xxxxxxxxxx
<details>
<summary style="border:1pt solid slateblue; border-radius:5pt; width:15%; color:slateblue; padding:3px; background-color: lightcyan"> Solution </summary>
<div style="border:1pt solid slateblue; border-radius:5pt; color:slateblue; padding:3px; background-color: lightcyan">
#### Fonction `hauteur_v1`
On a tout d'abord cette première analyse :
```python
def hauteur_v1(n):
h = 200 # affectation : O(1)
for i in range(n):
h = h - 0.00005 # soustraction puis affectation : O(1)
return h # renvoi : O(1)
```
Dans un second temps, en prenant en compte la boucle qui est exécutée `n` fois (règle n°2), on obtient :
```python
def hauteur_v1(n):
h = 200 # O(1)
for i in range(n): # la boucle est O(n)
h = h - 0.00005 # (car O(n * 1) = O(n))
return h # O(1)
```
Finalement, compte-tenu de la règle n°3, on en déduit que cette fonction a une complexité en $\mathcal{O}(n)$.
#### Fonction `hauteur_v2`
On a tout d'abord cette première analyse :
```python
def hauteur_v2(n):
h = 200 - n * 0.00005 # multiplication, soustraction et affectation : O(1)
return h # renvoi : O(1)
```
Compte-tenu de la règle n°1, on en déduit que cette fonction a une complexité en $\mathcal{O}(1)$. Cette implémentation `hauteur_v2` est donc moins complexe que `hauteur_v1`.
</div>
</details>
hauteur_v1
¶On a tout d'abord cette première analyse :
def hauteur_v1(n):
h = 200 # affectation : O(1)
for i in range(n):
h = h - 0.00005 # soustraction puis affectation : O(1)
return h # renvoi : O(1)
Dans un second temps, en prenant en compte la boucle qui est exécutée n
fois (règle n°2), on obtient :
def hauteur_v1(n):
h = 200 # O(1)
for i in range(n): # la boucle est O(n)
h = h - 0.00005 # (car O(n * 1) = O(n))
return h # O(1)
Finalement, compte-tenu de la règle n°3, on en déduit que cette fonction a une complexité en $\mathcal{O}(n)$.
hauteur_v2
¶On a tout d'abord cette première analyse :
def hauteur_v2(n):
h = 200 - n * 0.00005 # multiplication, soustraction et affectation : O(1)
return h # renvoi : O(1)
Compte-tenu de la règle n°1, on en déduit que cette fonction a une complexité en $\mathcal{O}(1)$. Cette implémentation hauteur_v2
est donc moins complexe que hauteur_v1
.
xxxxxxxxxx
### Exercice 2 :
Dans cet exercice la complexité sera exprimée en fonction de $n$, taille du tableau `tab`.
**Question 1 :**
La fonction `comptage` ci-dessous prend en paramètres un tableau d'entiers `tab` ainsi qu'un nombre entier `k` ; et renvoie le nombre d'occurences de l'entier `k` dans le tableau `tab`.
```python
def comptage(tab, k):
compteur = 0
for val in tab:
if val == k:
compteur = compteur + 1
return compteur
```
Déterminer sa complexité.
Dans cet exercice la complexité sera exprimée en fonction de $n$, taille du tableau tab
.
Question 1 :
La fonction comptage
ci-dessous prend en paramètres un tableau d'entiers tab
ainsi qu'un nombre entier k
; et renvoie le nombre d'occurences de l'entier k
dans le tableau tab
.
def comptage(tab, k):
compteur = 0
for val in tab:
if val == k:
compteur = compteur + 1
return compteur
Déterminer sa complexité.
xxxxxxxxxx
<details>
<summary style="border:1pt solid slateblue; border-radius:5pt; width:15%; color:slateblue; padding:3px; background-color: lightcyan"> Solution </summary>
<div style="border:1pt solid slateblue; border-radius:5pt; color:slateblue; padding:3px; background-color: lightcyan">
On a tout d'abord cette première analyse :
```python
def comptage(tab, k):
compteur = 0 # affectation : O(1)
for val in tab:
if val == k: # test d'égalité : O(1)
compteur = compteur + 1 # somme et affectation : O(1)
return compteur # renvoi : O(1)
```
Dans un second temps, en prenant en compte la boucle qui est exécutée `n` fois (règle n°2), on obtient :
```python
def comptage(tab, k):
compteur = 0 # O(1)
for val in tab: # \
if val == k: # } O(n) (car O(n * 1) = O(n))
compteur = compteur + 1 # /
return compteur # O(1)
```
Finalement, compte-tenu de la règle n°3, on en déduit que cette fonction a une complexité en $\mathcal{O}(n)$.
</div>
</details>
On a tout d'abord cette première analyse :
def comptage(tab, k):
compteur = 0 # affectation : O(1)
for val in tab:
if val == k: # test d'égalité : O(1)
compteur = compteur + 1 # somme et affectation : O(1)
return compteur # renvoi : O(1)
Dans un second temps, en prenant en compte la boucle qui est exécutée n
fois (règle n°2), on obtient :
def comptage(tab, k):
compteur = 0 # O(1)
for val in tab: # \
if val == k: # } O(n) (car O(n * 1) = O(n))
compteur = compteur + 1 # /
return compteur # O(1)
Finalement, compte-tenu de la règle n°3, on en déduit que cette fonction a une complexité en $\mathcal{O}(n)$.
xxxxxxxxxx
**Question 2 :**
La fonction `doublon_v1` ci-dessous prend en paramètre un tableau d'entiers `tab` ; et renvoie `True` si `tab` contient au moins un doublon (c'est à dire contient au moins deux fois la même valeur) et `False` sinon.
```python
def doublon_v1(tab):
for val in tab:
if comptage(tab, val) >= 2:
return True
return False
```
Déterminer sa complexité dans le pire des cas puis dans le meilleur des cas.
Question 2 :
La fonction doublon_v1
ci-dessous prend en paramètre un tableau d'entiers tab
; et renvoie True
si tab
contient au moins un doublon (c'est à dire contient au moins deux fois la même valeur) et False
sinon.
def doublon_v1(tab):
for val in tab:
if comptage(tab, val) >= 2:
return True
return False
Déterminer sa complexité dans le pire des cas puis dans le meilleur des cas.
xxxxxxxxxx
<details>
<summary style="border:1pt solid slateblue; border-radius:5pt; width:15%; color:slateblue; padding:3px; background-color: lightcyan"> Solution </summary>
<div style="border:1pt solid slateblue; border-radius:5pt; color:slateblue; padding:3px; background-color: lightcyan">
On a tout d'abord cette première analyse :
```python
def doublon_v1(tab):
for val in tab:
if comptage(tab, val) >= 2: # appel à comptage et test de comparaison : O(n) et O(1)
return True # renvoi : O(1)
return False # renvoi : O(1)
```
Dans un second temps, en prenant en compte la règle n°3, on obtient :
```python
def doublon_v1(tab):
for val in tab:
if comptage(tab, val) >= 2: # O(n)
return True # O(1)
return False # O(1)
```
Dans un troisième temps et d'après la règle n°2, en prenant en compte la boucle qui **dans le pire des cas** est exécutée `n` fois, on obtient :
```python
def doublon_v1(tab):
for val in tab: # \
if comptage(tab, val) >= 2: # } O(n**2) (car O(n*n) = O(n**2)
return True # /
return False # O(1)
```
Finalement, compte-tenu de la règle n°3, on en déduit que cette fonction a une complexité **dans le pire des cas** en $\mathcal{O}(n^{2})$ ce qui est assez mauvais.
<br/>
Et **dans le meilleur des cas** on passe une seule fois dans la boucle (lorsque la première valeur du tableau est un doublon) : on a alors une complexité en $\mathcal{O}(n)$ (en appliquant la règle n°3).
</div>
</details>
On a tout d'abord cette première analyse :
def doublon_v1(tab):
for val in tab:
if comptage(tab, val) >= 2: # appel à comptage et test de comparaison : O(n) et O(1)
return True # renvoi : O(1)
return False # renvoi : O(1)
Dans un second temps, en prenant en compte la règle n°3, on obtient :
def doublon_v1(tab):
for val in tab:
if comptage(tab, val) >= 2: # O(n)
return True # O(1)
return False # O(1)
Dans un troisième temps et d'après la règle n°2, en prenant en compte la boucle qui dans le pire des cas est exécutée n
fois, on obtient :
def doublon_v1(tab):
for val in tab: # \
if comptage(tab, val) >= 2: # } O(n**2) (car O(n*n) = O(n**2)
return True # /
return False # O(1)
Finalement, compte-tenu de la règle n°3, on en déduit que cette fonction a une complexité dans le pire des cas en $\mathcal{O}(n^{2})$ ce qui est assez mauvais.
Et dans le meilleur des cas on passe une seule fois dans la boucle (lorsque la première valeur du tableau est un doublon) : on a alors une complexité en $\mathcal{O}(n)$ (en appliquant la règle n°3).
xxxxxxxxxx
**Question 3 :**
La fonction `doublon_v2` ci-dessous prend également en paramètre un tableau d'entiers `tab` ; et renvoie également `True` si `tab` contient au moins un doublon (c'est à dire contient au moins deux fois la même valeur) et `False` sinon.
Pour cela elle utilise la fonction `decompte` rencontrée au II.4 et dont la complexité est $\mathcal{O}(n)$.
```python
def doublon_v2(tab):
dico = decompte(tab)
for val in dico.values():
if val >= 2:
return True
return False
```
Déterminer sa complexité dans le pire des cas puis dans le meilleur des cas.
Question 3 :
La fonction doublon_v2
ci-dessous prend également en paramètre un tableau d'entiers tab
; et renvoie également True
si tab
contient au moins un doublon (c'est à dire contient au moins deux fois la même valeur) et False
sinon.
Pour cela elle utilise la fonction decompte
rencontrée au II.4 et dont la complexité est $\mathcal{O}(n)$.
def doublon_v2(tab):
dico = decompte(tab)
for val in dico.values():
if val >= 2:
return True
return False
Déterminer sa complexité dans le pire des cas puis dans le meilleur des cas.
xxxxxxxxxx
<details>
<summary style="border:1pt solid slateblue; border-radius:5pt; width:15%; color:slateblue; padding:3px; background-color: lightcyan"> Solution </summary>
<div style="border:1pt solid slateblue; border-radius:5pt; color:slateblue; padding:3px; background-color: lightcyan">
On a tout d'abord cette première analyse :
```python
def doublon_v2(tab):
dico = decompte(tab) # appel à la fonction decompte : O(n)
for val in dico.values():
if val >= 2: # test de comparaison : O(1)
return True # renvoi : O(1)
return False # renvoi : O(1)
```
Dans un second temps d'après la règle n°2, en prenant en compte la boucle qui **dans le pire des cas** est exécutée `n` fois car le dictionnaire peut comporter autant de paires clef:valeur que d'éléments dans le dictionnaire, on obtient :
```python
def doublon_v2(tab):
dico = decompte(tab) # O(n)
for val in dico.values(): # \
if val >= 2: # } O(n)
return True # /
return False # O(1)
```
Finalement, compte-tenu de la règle n°3, on en déduit que cette fonction `doublon_v2` a une complexité **dans le pire des cas** en O(n) ce qui est meilleur que pour la fonction `doublon_v1`.
<br/>
Et **dans le meilleur des cas** on passe une seule fois dans la boucle (lorsque la première valeur du dictionnaire prise en compte correspond à une clef qui est un doublon) : on a alors une complexité en O(n) (en appliquant la règle n°3).
</div>
</details>
On a tout d'abord cette première analyse :
def doublon_v2(tab):
dico = decompte(tab) # appel à la fonction decompte : O(n)
for val in dico.values():
if val >= 2: # test de comparaison : O(1)
return True # renvoi : O(1)
return False # renvoi : O(1)
Dans un second temps d'après la règle n°2, en prenant en compte la boucle qui dans le pire des cas est exécutée n
fois car le dictionnaire peut comporter autant de paires clef:valeur que d'éléments dans le dictionnaire, on obtient :
def doublon_v2(tab):
dico = decompte(tab) # O(n)
for val in dico.values(): # \
if val >= 2: # } O(n)
return True # /
return False # O(1)
Finalement, compte-tenu de la règle n°3, on en déduit que cette fonction doublon_v2
a une complexité dans le pire des cas en O(n) ce qui est meilleur que pour la fonction doublon_v1
.
Et dans le meilleur des cas on passe une seule fois dans la boucle (lorsque la première valeur du dictionnaire prise en compte correspond à une clef qui est un doublon) : on a alors une complexité en O(n) (en appliquant la règle n°3).
xxxxxxxxxx
**Question 4 :**
Vérifier expérimentalement les résultats des questions 2 et 3 en utilisant les tableaux aléatoires de taille $n$ générés par la fonction `tableau_aleatoire(n)` ci-dessous (un tableau renvoyé par cette fonction a une chance sur deux de contenir un doublon).
Question 4 :
Vérifier expérimentalement les résultats des questions 2 et 3 en utilisant les tableaux aléatoires de taille $n$ générés par la fonction tableau_aleatoire(n)
ci-dessous (un tableau renvoyé par cette fonction a une chance sur deux de contenir un doublon).
xxxxxxxxxx
from random import shuffle, randint
############
# fonctions de détection de doublons
############
def decompte(tab):
dico = {}
for val in tab:
if val in dico.keys():
dico[val] = dico[val] + 1
else:
dico[val] = 1
return dico
def comptage(tab, k):
compteur = 0
for val in tab:
if val == k:
compteur = compteur + 1
return compteur
def doublon_v1(tab):
for val in tab:
if comptage(tab, val) >= 2:
return True
return False
def doublon_v2(tab):
dico = decompte(tab)
for val in dico.values():
if val >= 2:
return True
return False
############
# fonction renvoyant des tableaux aléatoires
############
def tableau_aleatoire(n):
'''Renvoie un tableau aléatoire de taille n ayant une chance sur deux de contenir un doublon.'''
tab = list(range(1, n+1))
shuffle(tab)
if randint(0,1) == 0:
a, b = randint(0,n-1), randint(0, n-1)
while a == b:
a, b = randint(0,n-1), randint(0, n-1)
tab[a] = tab[b]
return tab
xxxxxxxxxx
# coder ci-dessous des instructions permettant de constater l'amélioration de complexité de v1 à v2
...
xxxxxxxxxx
# coder ci-dessous des instructions permettant de constater l'amélioration de complexité de v1 à v2
...
xxxxxxxxxx
# coder ci-dessous des instructions permettant de constater l'amélioration de complexité de v1 à v2
...
xxxxxxxxxx
<details>
<summary style="border:1pt solid slateblue; border-radius:5pt; width:15%; color:slateblue; padding:3px; background-color: lightcyan"> Solution </summary>
<div style="border:1pt solid slateblue; border-radius:5pt; color:slateblue; padding:3px; background-color: lightcyan">
Il suffit d'exécuter **dans trois cellules de code distinctes** les instructions suivantes.
**Cellule n°1 :**
```python
n = 100
t = tableau_aleatoire(n)
```
**Cellule n°2 :**
```python
doublon_v1(t)
```
**Cellule n°3 :**
```python
doublon_v2(t)
```
<br/>
<br/>
Avec `n = 100` ou `n = 1000`, les appels aux fonctions `doublon_v1` et `doublon_v2` sont très rapides.
En revanche avec `n = 10000` ou `n=20000`, les appels à la fonction `doublon_v1` commencent à prendre beaucoup de temps ; alors que les appels à al fonction `doublon_v2` restent très rapides.
Cela est lié au fait que la fonction `doublon_v1` a une complexité moins performante (en $\mathcal{O}(n^{2})$) que `doublon_v2` (en $\mathcal{O}(n)$).
</div>
</details>
Il suffit d'exécuter dans trois cellules de code distinctes les instructions suivantes.
Cellule n°1 :
n = 100
t = tableau_aleatoire(n)
Cellule n°2 :
doublon_v1(t)
Cellule n°3 :
doublon_v2(t)
Avec n = 100
ou n = 1000
, les appels aux fonctions doublon_v1
et doublon_v2
sont très rapides.
En revanche avec n = 10000
ou n=20000
, les appels à la fonction doublon_v1
commencent à prendre beaucoup de temps ; alors que les appels à al fonction doublon_v2
restent très rapides.
Cela est lié au fait que la fonction doublon_v1
a une complexité moins performante (en $\mathcal{O}(n^{2})$) que doublon_v2
(en $\mathcal{O}(n)$).
xxxxxxxxxx
## IV : Des résultats de complexité à connaître
### IV.1 : Opérations sur les tableaux et dictionnaires
##### Sur les tableaux
|| Opération | Complexité en moyenne | Remarques |
|:---:|---:|:---:|:---|
|| `len(tab)` | O(1) | |
|| `val = tab[index]` | O(1) | |
|| `tab[index] = val` | O(1) | |
|| `tab.append(val)` | O(1) | |
|| `tab.pop()` | O(1) | |
|⚠️| `tab.pop(index)` | O(n) | Le coût dépend de l'endroit de la suppression |
|⚠️| `tab.insert(index, val)` | O(n) | Le coût dépend de l'endroit de l'insertion |
|⚠️| `del tab[index]` | O(n) | Le coût dépend de l'endroit de la suppression |
|| `val in tab` | O(n) | |
|| `min(tab)` | O(n) | |
|| `max(tab)` | O(n) | |
|| `tab2 = copy(tab1)` | O(n) | |
|| `tab3 = tab2 + tab1` | O(k) | k est la longueur de `tab1` |
|| `tab2.extend(tab1)` | O(k) | k est la longueur de `tab1` |
|| `tab.sort()` ou `sorted(tab)` | O(n log(n) ) | |
##### Sur les dictionnaires
|| Opération | Complexité en moyenne | Remarques |
|:---:|---:|:---:|:---|
|| `len(dico)` | O(1) | |
|| `val = dico[clef]` | O(1) | |
|| `dico[clef] = val` | O(1) | |
|| `del dico[clef]` | O(1) | |
|⚠️| `clef in dico` ou `clef in dico.keys()`| O(1) | Propriété qui justifie parfois l'utilisation de dictionnaires|
### IV.2 : algorithmes usuels
| Algorithme | Complexité | Remarques |
|:---|:---:|:---|
| Recherche naïve | O(n) | |
| Recherche par dichotomie | O(log(n)) | nécessite un tableau trié|
| Maximum | O(n) | |
| Minimum | O(n) | |
| Somme ou Moyenne | O(n) | |
| Tri par insertion ou sélection | O(n$^2$) | |
| Tri fusion | O(n log(n)) | |
### IV.3 : exemples de complexités
| Complexité | Dénomination | n = 5 | n = 10 | n = 20 | n = 50 | n = 250 | n = 1000 | n = 10000 | n = 1000000 | Exemple |
|:---|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---|
|$O(1)$ | constante | 10 ns | 10 ns | 10 ns | 10 ns | 10 ns | 10 ns | 10 ns | 10 ns | accès à une cellule de tableau |
| $O(log(n))$ | logarithmique | 10 ns | 10 ns | 10 ns | 20 ns | 30 ns | 30 ns | 40 ns | 60 ns | recherche dichotomique |
| $O(\sqrt{n})$ | racinaire | 22 ns | 32 ns | 45 ns | 71 ns | 158 ns | 316 ns | 1 µs | 10 µs | test de primalité naïf |
| $O(n)$ | linéaire | 50 ns | 100 ns | 200 ns | 500 ns | 2.5 µs | 10 µs | 100 µs | 10 ms | parcours de liste |
| $O(n log(n))$ | linéarithmique | 40 ns | 100 ns | 260 ns | 850 ns | 6 µs | 30 µs | 400 µs | 60 ms | tri fusion |
| $O(n^{2})$ | quadratique | 250 ns | 1 µs | 4 µs | 25 µs | 625 µs | 10 ms | 1 s | 2.8 h | parcours de matrice, tri par insertion ou par sélection |
| $O(2^{n})$ | exponentielle | 320 ns | 10 µs | 10 ms | 130 jours | 10$^{59}$ ans | | | | Problème du sac à dos par force brute |
| $O(n!)$ | factorielle | 1.2 µs | 36 ms | 770 ans | 10$^{48}$ ans | | | | | Problème du voyageur de commerce avec une approche naïve |
Il est important de connaître l'ordre croissant de ces complexités : constante, logarithmique, linéaire, linéarithmique, quadratique, exponentielle.
Opération | Complexité en moyenne | Remarques | |
---|---|---|---|
len(tab) |
O(1) | ||
val = tab[index] |
O(1) | ||
tab[index] = val |
O(1) | ||
tab.append(val) |
O(1) | ||
tab.pop() |
O(1) | ||
⚠️ | tab.pop(index) |
O(n) | Le coût dépend de l'endroit de la suppression |
⚠️ | tab.insert(index, val) |
O(n) | Le coût dépend de l'endroit de l'insertion |
⚠️ | del tab[index] |
O(n) | Le coût dépend de l'endroit de la suppression |
val in tab |
O(n) | ||
min(tab) |
O(n) | ||
max(tab) |
O(n) | ||
tab2 = copy(tab1) |
O(n) | ||
tab3 = tab2 + tab1 |
O(k) | k est la longueur de tab1 |
|
tab2.extend(tab1) |
O(k) | k est la longueur de tab1 |
|
tab.sort() ou sorted(tab) |
O(n log(n) ) |
Opération | Complexité en moyenne | Remarques | |
---|---|---|---|
len(dico) |
O(1) | ||
val = dico[clef] |
O(1) | ||
dico[clef] = val |
O(1) | ||
del dico[clef] |
O(1) | ||
⚠️ | clef in dico ou clef in dico.keys() |
O(1) | Propriété qui justifie parfois l'utilisation de dictionnaires |
Algorithme | Complexité | Remarques |
---|---|---|
Recherche naïve | O(n) | |
Recherche par dichotomie | O(log(n)) | nécessite un tableau trié |
Maximum | O(n) | |
Minimum | O(n) | |
Somme ou Moyenne | O(n) | |
Tri par insertion ou sélection | O(n$^2$) | |
Tri fusion | O(n log(n)) |
Complexité | Dénomination | n = 5 | n = 10 | n = 20 | n = 50 | n = 250 | n = 1000 | n = 10000 | n = 1000000 | Exemple |
---|---|---|---|---|---|---|---|---|---|---|
$O(1)$ | constante | 10 ns | 10 ns | 10 ns | 10 ns | 10 ns | 10 ns | 10 ns | 10 ns | accès à une cellule de tableau |
$O(log(n))$ | logarithmique | 10 ns | 10 ns | 10 ns | 20 ns | 30 ns | 30 ns | 40 ns | 60 ns | recherche dichotomique |
$O(\sqrt{n})$ | racinaire | 22 ns | 32 ns | 45 ns | 71 ns | 158 ns | 316 ns | 1 µs | 10 µs | test de primalité naïf |
$O(n)$ | linéaire | 50 ns | 100 ns | 200 ns | 500 ns | 2.5 µs | 10 µs | 100 µs | 10 ms | parcours de liste |
$O(n log(n))$ | linéarithmique | 40 ns | 100 ns | 260 ns | 850 ns | 6 µs | 30 µs | 400 µs | 60 ms | tri fusion |
$O(n^{2})$ | quadratique | 250 ns | 1 µs | 4 µs | 25 µs | 625 µs | 10 ms | 1 s | 2.8 h | parcours de matrice, tri par insertion ou par sélection |
$O(2^{n})$ | exponentielle | 320 ns | 10 µs | 10 ms | 130 jours | 10$^{59}$ ans | Problème du sac à dos par force brute | |||
$O(n!)$ | factorielle | 1.2 µs | 36 ms | 770 ans | 10$^{48}$ ans | Problème du voyageur de commerce avec une approche naïve |
Il est important de connaître l'ordre croissant de ces complexités : constante, logarithmique, linéaire, linéarithmique, quadratique, exponentielle.
xxxxxxxxxx
## V : Questions de cours
**Question 1 :**
L'instruction `instruction_3` est constituée de deux instructions :
- `instruction_1` de complexité $O(n)$,
- `instruction_2` également de complexité $O(n)$.
Quelle est la complexité de `instruction_3` ?
Question 1 :
L'instruction instruction_3
est constituée de deux instructions :
instruction_1
de complexité $O(n)$,instruction_2
également de complexité $O(n)$.Quelle est la complexité de instruction_3
?
xxxxxxxxxx
<details>
<summary style="border:1pt solid slateblue; border-radius:5pt; width:15%; color:slateblue; padding:3px; background-color: lightcyan"> Solution </summary>
<div style="border:1pt solid slateblue; border-radius:5pt; color:slateblue; padding:3px; background-color: lightcyan">
Linéaire, c'est à dire $O(n)$ d'après la règle n°1.
</div>
</details>
Linéaire, c'est à dire $O(n)$ d'après la règle n°1.
xxxxxxxxxx
**Question 2 :**
L'instruction `instruction_3` est constituée de deux instructions :
- `instruction_1` de complexité $O(n)$,
- `instruction_2` également de complexité $O(n^{2})$.
Quelle est la complexité de `instruction_3` ?
Question 2 :
L'instruction instruction_3
est constituée de deux instructions :
instruction_1
de complexité $O(n)$,instruction_2
également de complexité $O(n^{2})$.Quelle est la complexité de instruction_3
?
xxxxxxxxxx
<details>
<summary style="border:1pt solid slateblue; border-radius:5pt; width:15%; color:slateblue; padding:3px; background-color: lightcyan"> Solution </summary>
<div style="border:1pt solid slateblue; border-radius:5pt; color:slateblue; padding:3px; background-color: lightcyan">
Quadratique, c'est à dire $O(n^{2})$ d'après la règle n°3.
</div>
</details>
Quadratique, c'est à dire $O(n^{2})$ d'après la règle n°3.
xxxxxxxxxx
**Question 3 :**
L'instruction `instruction_3` est constituée d'une boucle qui exécute $log(n)$ fois une instruction de complexité $O(n)$.
Quelle est la complexité de `instruction_3` ?
Question 3 :
L'instruction instruction_3
est constituée d'une boucle qui exécute $log(n)$ fois une instruction de complexité $O(n)$.
Quelle est la complexité de instruction_3
?
xxxxxxxxxx
<details>
<summary style="border:1pt solid slateblue; border-radius:5pt; width:15%; color:slateblue; padding:3px; background-color: lightcyan"> Solution </summary>
<div style="border:1pt solid slateblue; border-radius:5pt; color:slateblue; padding:3px; background-color: lightcyan">
Linéarithmique c'est à dire $O(log(n)*n)$ d'après la règle n°2.
</div>
</details>
Linéarithmique c'est à dire $O(log(n)*n)$ d'après la règle n°2.
xxxxxxxxxx
**Question 4 :**
Un algorithme de complexité $O(n)$ est exécuté en $20$ ms sur un tableau de taille $5000$.
Quel sera approximativement son temps d'exécution sur un tableau de taille $1 \ 000 \ 000$ ?
Question 4 :
Un algorithme de complexité $O(n)$ est exécuté en $20$ ms sur un tableau de taille $5000$.
Quel sera approximativement son temps d'exécution sur un tableau de taille $1 \ 000 \ 000$ ?
xxxxxxxxxx
<details>
<summary style="border:1pt solid slateblue; border-radius:5pt; width:15%; color:slateblue; padding:3px; background-color: lightcyan"> Solution </summary>
<div style="border:1pt solid slateblue; border-radius:5pt; color:slateblue; padding:3px; background-color: lightcyan">
La taille du tableau $n$ est multipliée par $200$. Donc le temps de calcul va être multiplié approximativement par $200$, ce qui donne $20 * 200 = 4000 \ ms = 4 \ s$.
</div>
</details>
La taille du tableau $n$ est multipliée par $200$. Donc le temps de calcul va être multiplié approximativement par $200$, ce qui donne $20 * 200 = 4000 \ ms = 4 \ s$.
xxxxxxxxxx
**Question 5 :**
Un algorithme de complexité $O(n^2)$ est exécuté en $20$ ms sur un tableau de taille $5000$.
Quel sera approximativement son temps d'exécution sur un tableau de taille $1 \ 000 \ 000$ ?
Question 5 :
Un algorithme de complexité $O(n^2)$ est exécuté en $20$ ms sur un tableau de taille $5000$.
Quel sera approximativement son temps d'exécution sur un tableau de taille $1 \ 000 \ 000$ ?
xxxxxxxxxx
<details>
<summary style="border:1pt solid slateblue; border-radius:5pt; width:15%; color:slateblue; padding:3px; background-color: lightcyan"> Solution </summary>
<div style="border:1pt solid slateblue; border-radius:5pt; color:slateblue; padding:3px; background-color: lightcyan">
La taille du tableau $n$ est multipliée par $200$. Donc le temps de calcul va être multiplié approximativement par $200^{2}$, ce qui donne $20 * 200^{2} = 800000 \ ms = 800 \ s = 13 \ min \ 20 \ s$.
</div>
</details>
La taille du tableau $n$ est multipliée par $200$. Donc le temps de calcul va être multiplié approximativement par $200^{2}$, ce qui donne $20 * 200^{2} = 800000 \ ms = 800 \ s = 13 \ min \ 20 \ s$.
xxxxxxxxxx
**Question 6 :**
Un algorithme de complexité $O(log(n))$ est exécuté en $20$ ms sur un tableau de taille $5000$.
Quel sera approximativement son temps d'exécution sur un tableau de taille $1 \ 000 \ 000$ ?
Question 6 :
Un algorithme de complexité $O(log(n))$ est exécuté en $20$ ms sur un tableau de taille $5000$.
Quel sera approximativement son temps d'exécution sur un tableau de taille $1 \ 000 \ 000$ ?
xxxxxxxxxx
<details>
<summary style="border:1pt solid slateblue; border-radius:5pt; width:15%; color:slateblue; padding:3px; background-color: lightcyan"> Solution </summary>
<div style="border:1pt solid slateblue; border-radius:5pt; color:slateblue; padding:3px; background-color: lightcyan">
Puisque $log(5000) \approx 3.70$ correspond à $20 \ ms$.
Alors $log(1000000) \approx 6.00$ correspondra à $\frac{6.00}{3.70} \times 20 \approx 32 \ ms$.
<br/>
Remarque : ci-dessus a été utilisé le logarithme base 10 (celui des physicien·nes). <br/>
Mais si vous utilisez le logarithme népérien (celui des mathématicien·nes) vous trouverez $ln(5000) \approx 8.52$ et $ln(1000000) \approx 13.82$ qui conduiront également à $\frac{13.82}{8.52} \times 20 \approx 32 \ ms$. <br/>
Et si vous utilisez le logarithme base 2 (celui des informaticien·nes) vous trouverez $log_2(5000) \approx 12.29$ et $log_2(1000000) \approx 19.93$ qui conduiront également à $\frac{19.93}{12.29} \times 20 \approx 32 \ ms$. <br/>
</div>
</details>
Puisque $log(5000) \approx 3.70$ correspond à $20 \ ms$. Alors $log(1000000) \approx 6.00$ correspondra à $\frac{6.00}{3.70} \times 20 \approx 32 \ ms$.
Remarque : ci-dessus a été utilisé le logarithme base 10 (celui des physicien·nes).
Mais si vous utilisez le logarithme népérien (celui des mathématicien·nes) vous trouverez $ln(5000) \approx 8.52$ et $ln(1000000) \approx 13.82$ qui conduiront également à $\frac{13.82}{8.52} \times 20 \approx 32 \ ms$.
Et si vous utilisez le logarithme base 2 (celui des informaticien·nes) vous trouverez $log_2(5000) \approx 12.29$ et $log_2(1000000) \approx 19.93$ qui conduiront également à $\frac{19.93}{12.29} \times 20 \approx 32 \ ms$.
xxxxxxxxxx
**Question 7 :**
Pourquoi, en Python, évite-t-on d'insérer ou de supprimer des éléments situés à gauche d'un tableau ?
Question 7 :
Pourquoi, en Python, évite-t-on d'insérer ou de supprimer des éléments situés à gauche d'un tableau ?
xxxxxxxxxx
<details>
<summary style="border:1pt solid slateblue; border-radius:5pt; width:15%; color:slateblue; padding:3px; background-color: lightcyan"> Solution </summary>
<div style="border:1pt solid slateblue; border-radius:5pt; color:slateblue; padding:3px; background-color: lightcyan">
Parce que c'est une opération coûteuse de complexité $O(n)$ (en effet cette opération nécessite de «décaler d'un cran» toutes les valeurs suivantes dans le tableau).
</div>
</details>
Parce que c'est une opération coûteuse de complexité $O(n)$ (en effet cette opération nécessite de «décaler d'un cran» toutes les valeurs suivantes dans le tableau).
xxxxxxxxxx
**Question 8 :**
En Python, le test de présence d'une clef dans un dictionnaire est de complexité $O(1)$. Ce fait remarquable est lié à l'utilisation de tables de hachage. Néanmoins cela a un inconvénient concernant les clefs qui peuvent être utilisées dans un dictionnaire. Quel est cet inconvénient ?
Question 8 :
En Python, le test de présence d'une clef dans un dictionnaire est de complexité $O(1)$. Ce fait remarquable est lié à l'utilisation de tables de hachage. Néanmoins cela a un inconvénient concernant les clefs qui peuvent être utilisées dans un dictionnaire. Quel est cet inconvénient ?
xxxxxxxxxx
<details>
<summary style="border:1pt solid slateblue; border-radius:5pt; width:15%; color:slateblue; padding:3px; background-color: lightcyan"> Solution </summary>
<div style="border:1pt solid slateblue; border-radius:5pt; color:slateblue; padding:3px; background-color: lightcyan">
On ne peut pas utiliser de containers mutables tels que les dictionnaires (`dict`) ou les tableaux (`list`).
</div>
</details>
On ne peut pas utiliser de containers mutables tels que les dictionnaires (dict
) ou les tableaux (list
).
xxxxxxxxxx
**Question 9 :**
Donner la complexité dans le pire des cas de chacun de ces algorithmes, rencontrés en première NSI :
- recherche naïve d'un élément dans un tableau non trié,
- recherche du maximum ou minimum d'un tableau,
- calcul de la somme des éléments d'un tableau d'entiers,
- tri par insertion d'un tableau d'entiers,
- tri par sélection d'un tableau d'entiers,
- recherche par dichotomie d'un élément dans un tableau trié.
Question 9 :
Donner la complexité dans le pire des cas de chacun de ces algorithmes, rencontrés en première NSI :
xxxxxxxxxx
<details>
<summary style="border:1pt solid slateblue; border-radius:5pt; width:15%; color:slateblue; padding:3px; background-color: lightcyan"> Solution </summary>
<div style="border:1pt solid slateblue; border-radius:5pt; color:slateblue; padding:3px; background-color: lightcyan">
- recherche naïve d'un élément dans un tableau non trié : $O(n)$,
- recherche du maximum ou minimum d'un tableau : $O(n)$,
- calcul de la somme des éléments d'un tableau d'entiers : $O(n)$,
- tri par insertion d'un tableau d'entiers : $O(n^{2})$,
- tri par sélection d'un tableau d'entiers : $O(n^{2})$,
- recherche par dichotomie d'un élément dans un tableau trié : $O(log(n))$.
</div>
</details>