Beruflich Dokumente
Kultur Dokumente
programmation générique en particulier, voir cette entrevue avec Alex Stepanov, concepteur de la bibliothèque STL.
Voir aussi ce chouette texte comparant diverses stratégies pour résoudre un problème a priori simple à l'aide d'outils de la bibliothèque
standard de C++.
La complexité d'un algorithme est une mesure du temps[1] requis par l'algorithme pour accomplir sa tâche, en fonction de la taille[2] de l'échantillon
à traiter.
On dira d'un problème qu'il est aussi complexe que le meilleur algorithme connu pour le résoudre.
dans certains cas, par exemple celui des algorithmes de tri, on connaît les algorithmes optimaux, donc pour lesquels il est possible de
démontrer (par l'absurde) qu'il est impossible de faire mieux que le meilleur algorithme connu. Dans la plupart des cas, toutefois, la
recherche de l'algorithme optimal se poursuit.
Cela peut sembler évident a posteriori, mais voilà: pour calculer la complexité d'un algorithme donné, il convient tout d'abord de compter le nombre
d'opérations impliquées par son exécution.
Les exemples donnés ici le sont selon la notation C++, mais l'emploi de pseudocode aurait amplement suffi à la démonstration. La
plupart des bouquins d'algorithmique utilisent une notation se rapprochant de celle du langage Pascal.
Notation O
La notation la plus utilisée pour noter la complexité d'un algorithme est la notation O (pour ordre de...), qui dénote un ordre de grandeur. Par exemple,
on dira d'un algorithme qu'il est O(15) s'il nécessite au plus 15 opérations (dans le pire cas) pour se compléter. En français, on dira qu'il est O de 15 ou
encore de l'ordre de 15.
Souvent, comme dans le cas où un algorithme manipule un tableau de n éléments (on dira un tableau de taille n), la complexité sera notée en fonction
de cette taille. Par exemple, un algorithme de complexité O(2n + 8) prendra dans le pire cas huit (8) opérations, plus deux (2) opérations
supplémentaires par élément du tableau.
La notation O a le mérite de simplifier très facilement. Nous verrons comment y arriver sous peu.
Commençons par un exemple simple, soit celui d'une fonction prenant en paramètre
le rayon d'une sphère, calculant le volume de cette sphère, et retournant cette valeur double volume_sphere (const double rayon)
{
au sous-programme appelant. On aura le code proposé à droite. const double PI = 3.14159; // (0)
double volume;
L'instruction notée (0) est l'affectation d'une valeur à une constante. On peut volume = 4.0 / 3.0 * PI * rayon * rayon * rayon; // (1)
return volume; // (2)
compter celle-ci comme étant une opération (certains l'omettront, ce qui importe }
peu, comme nous le verrons plus bas).
L'instruction notée (1) est composée de cinq opérations arithmétiques et d'une affectation. On peut la compter comme six (6) opérations ou comme
une seule (ce qui importe peu aussi).
L'instruction notée (2), la production de la valeur résultante de l'exécution de la fonction, est aussi une opération.
Si on additionne tout ça, on arrive à deux opérations, si on ne compte pas l'affectation d'une valeur à la constante—opération (0)—et si on compte
l'instruction (1) comme une seule opération, et à huit (8) opérations si on compte les instructions de manière plus rigide. L'algorithme sera donc O(2)
ou O(8), tout dépendant de la manière de compter les opérations.
L'important ici n'est pas la valeur exacte entre les parenthèses suivant le O, mais le fait que cette valeur soit constante.
Lorsqu'un algorithme est O(c) où c est une constante, on dit qu'il s'agit alors d'un algorithme en temps constant.
Une complexité constante est la complexité algorithmique idéale, puisque peu importe la taille de l'échantillon à traiter, l'algorithme prendra toujours un
nombre fixé à l'avance d'opérations pour réaliser sa tâche.
Tous les algorithmes en temps constant font partie d'une classe nommée O(1). En général, qu'un algorithme soit O(3), O(17) ou O(100000), on dira
de lui qu'il est en fait O(1) puisque la différence de performance entre deux algorithmes en temps constant peut être comblée par un simple remplacement
matériel (utiliser un processeur plus rapide, par exemple).
Une autre version, celle-ci de complexité O(4) (donc essentiellement const int INDICE_INVALIDE = -1; // arbitraire
équivalente à l'algorithme précédent, à une affectation près—différence int obtenir_element_v2(const int tab[], const int MAX, const int i)
{
que la compilation fera probablement disparaître) serait return i >= 0 && i < MAX? tab[i]: INDICE_INVALIDE;
obtenir_element_v2(). }
Notez que le recours à une constante interne dans les deux dernières versions est une mauvaise pratique de programmation (après tout, nous voulons que
le code client connaisse la valeur de cette constante!) et que ce sont des approches tellement simples qu'on peut à peine les nommer algorithmes. Cela dit,
il existe des algorithmes O(1) de portée beaucoup plus riche et pertinente...
Non, nous ne calculerons pas de logarithmes ici. N'empêche : la classe de complexité suivante est celle des algorithmes à complexité logarithmique.
Souvent, les algorithmes de ce genre auront la propriété suivante: on leur donne un échantillon de taille «n» à traiter, et ils font en sorte (dans une
répétitive) de diminuer (de moitié, par exemple) à chaque itération[4] la partie de l'échantillon qu'il vaut la peine de traiter. On peut penser, à tout hasard, à
une recherche dichotomique.
du fait que la valeur 4 constitue le deuxième élément du tableau tab[], et se trouve conséquemment à la position 1 dans ce tableau.
La croissance de complexité concrète est assez visible[5]. Mais puisque nous voulons ici présenter la complexité logarithmique, nous procéderons avec une
meilleure solution—une qui sera moins complexe selon notre définition de la complexité.
Le pire cas est donc d'éliminer successivement la moitié des éléments restant à explorer jusqu'à ce qu'il n'en reste plus qu'un seul, qui sera alors le bon ou
encore qui indiquera que l'élément cherché est absent du tableau. Analysons un peu plus ce pire cas :
si on explore un tableau de 10 éléments, on aura besoin au pire de 4 itérations (le tableau débutera à 10 éléments, puis passera à 5, puis à 2,
puis à un seul);
si on explore un tableau de 100 éléments, on aura besoin au pire de 7 itérations (le tableau débutera à 100 éléments, puis passera à 50, puis
à 25, puis à 12, puis à 6, puis à 3, puis à un seul);
si on explore un tableau de 1000 éléments, on aura besoin au pire de 10 itérations (le tableau débutera à 1000 éléments, puis passera à 500,
puis à 250, puis à 125, puis à 62, puis à 31, puis à 15, puis à 7, puis à 3, puis à un seul).
Vous pouvez vous amuser à calculer d'autres valeurs. L'équation générale dit que cet algorithme est de complexité O(b+(i*log2n)) où n est la taille
du tableau, b est le nombre incontournable d'opérations (ici, b==5) et i est la complexité d'une seule itération (ici, i==5 aussi, mais c'est
accidentel).
Le tableau suivant présente les le nombre d'opérations requis selon la complexité, pour différentes tailles de tableaux.
Ce qu'il faut retenir ici, c'est l'évolution de la complexité. Simplement en traçant la courbe des valeurs calculés pour certaines tailles choisies
d'échantillons, on peut souvent voir s'il s'agit ou non d'une complexité logarithmique.
Remarquez que la courbe de l'algorithme logarithmique est tellement peu accentuée par rapport aux deux autres qu'on la voit à peine sur le graphique (et
encore: celui-ci ne présente que des valeurs propres à des tailles de tableau allant jusqu'à 1000, ce qui est bien petit en informatique.
L'avantage de la solution de complexité logarithmique sur les deux autres est très net. En fait, si on avait ajouté au graphique les échantillons plus grands,
la courbe logarithmique serait à toutes fins pratiques disparue de notre champ de vision..
On peut améliorer légèrement l'algorithme de complexité logarithmique en ajoutant quelques variables temporaires. C'est une optimisation locale, mais qui
peut le rendre encore un petit peu plus efficace.
On aurait alors :
const int INDICE_INVALIDE = -1;
int trouver_indice (const int tab[], const int TAILLE, const int val)
{
int ndx, plafond, plancher,
// pour optimiser le traitement dans la boucle
ndx_cur, val_cur;
bool trouve;
//
// Initialisation
//
ndx = INDICE_INVALIDE;
plafond = TAILLE - 1;
plancher = 0;
trouve = false;
//
// Traitement
//
while (!trouve && plancher <= plafond)
{
ndx_cur= (plancher + plafond) / 2; // risqué!
val_cur = tab[ndx_cur];
if (val == val_cur)
{
trouve = true;
ndx = ndx_cur;
} // Valeur == ValeurCur
else if (val < val_cur)
plafond = ndx_cur - 1;
else // Valeur > ValeurCur
plancher = ndx_cur + 1;
}
return Indice;
}
Questions de notation
Nous avons écrit plus haut que notre algorithme était de complexité
O(5+5*(n)).
d'abord, l'addition d'une constante à la complexité est un détail inconséquent sur sa complexité réelle. Plus la taille de l'échantillon à traiter croît,
moins l'addition d'une constante à la complexité de l'algorithme devient importante. Ainsi, on simplifiera la complexité O(5+5*(log2n)) pour
obtenir O(5*(log2n));
ensuite, la multiplication par une constante a elle aussi de moins en moins d'importance sur la complexité réelle de l'algorithme si on regarde son
poids relatif sur des échantillons de grande taille. Ainsi, à moins de faire des optimisations très pointues, on dira souvent que la complexité
O(5*(log2n)) est équivalente à O(log2n). Ceci est moins évident dans le graphique précédent, mais devient visible sur de grandes valeurs de n;
ainsi, notre algorithme, vu de façon générale, entre dans la grande classe des algorithmes à complexité logarithmique.
Par complexité linéaire, ou O(n), on dénotera des algorithmes pour lesquels le nombre d'étapes à effectuer variera en proportion directe de la
taille de l'échantillon à traiter (si l'échantillon croît par un facteur de 100, la complexité sera accrue elle aussi par un facteur de 100).
Pensez à un algorithme qui parcourt chaque élément d'une liste chaînée, ou qui fait la somme des éléments d'un tableau (ici O(3+n*5))...
int somme_elements (const int tab[], const int TAILLE)
{
int somme = 0;
for (int compteur = 0; compteur < TAILLE; ++compteur)
somme += tab[compteur]; // attention aux débordements!
return Somme;
}
La simplification normale s'applique : on élimine l'addition de constantes, puis la multiplication par des constantes, pour réaliser que la croissance de la
complexité dépend directement de la taille de l'échantillon. C'est là la donnée intéressante du calcul.
On dira d'un algorithme de complexité O(n2) qu'il est de complexité quadratique. Vous comprenez sûrement le principe du calcul de la complexité
maintenant, alors nous n'entrerons pas ici dans les détails.
Nous n'irons pas plus loin dans notre description en surface de la complexité algorithmique. Notons seulement qu'il existe plusieurs autres niveaux de
complexité (par exemple, des complexités comme O(n3), O(n4), ou des complexités mettant en relation plusieurs variables comme O(n+m2)).
Ce qui est vraiment à retenir ici est qu'il est possible de calculer la complexité d'un algorithme, et de comparer la complexité relative de deux algorithmes
pour choisir le plus efficace. Certains algorithmes en apparence simples sont extrêmement lents; il convient donc de choisir nos solutions avec prudence.
[1]
Le terme temps doit être pris ici au sens de nombre d'étapes requis pour arriver à une solution—on parle donc d'un temps discret, pas continu. On peut
examiner le temps requis pour exécuter un algorithme donné dans le meilleur cas possible, dans le pire cas possible, et dans le cas moyen.
[2]
Le terme taille doit être pris ici au sens de nombre d'éléments dans l'échantillon à traiter par l'algorithme. Souvent, on utilisera un tableau contenant un
nombre donné d'éléments ou une chaîne de caractères dont on peut connaître le nombre de caractères constitutifs comme échantillon à traiter, pour
faciliter les calculs.
[3]
En serait-il autant d'une fonction accédant au i-ème élément d'une liste chaînée?
[4]
Une itération est un tour de boucle.
[5]
Nous verrons plus loin qu'il s'agit là d'un algorithme de complexité linéaire (O(n), où n est la taille du tableau).