Vous êtes sur la page 1sur 11

Pour une discussion intéressante de l'importance de la complexité algorithmique dans l'algorithmique en général et dans la

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++.

Voici une petite introduction (informelle) à la joie du calcul de la complexité algorithmique.

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.

Algorithmes à complexité constante

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).

Autres exemples O(1)


On peut penser au cas (presque trop banal pour être vrai) d'une fonction qui permette
d'accéder au i-ème élément d'un tableau. Peu importe la valeur de i, peu importe la int obtenir_element_v0 (const int tab[], const int i)
{
taille du tableau, le temps d'accès sera identique[3]. Une version naïve est celle proposée return tab[i];
par la fonction obtenir_element_v0() proposée à droite. }

L'algorithme précédent semble vraiment O(1) (et l'est à peu de détails


près : le passage par un indice implique une addition et une indirection de const int INDICE_INVALIDE = -1; // arbitraire
int obtenir_element_v1 (const int tab[], const int MAX, const int i)
pointeur). Si on veut rédiger un algorithme qui fait la même tâche tout en {
validant que l'indice i se trouve entre les bornes minimales et int resultat;
if (i >= 0 && i < MAX)
maximales du tableau à accéder, on pourrait avoir resultat = tab[i];
obtenir_element_v1(), aussi en temps constant, mais O(5). else
resultat = INDICE_INVALIDE;
return resultat;
}

Notez que la valeur de la constante INDICE_INVALIDE ne


doit pas faire partie des valeurs admises dans le tableau pour que cet algorithme soit correct.

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...

Algorithmes à complexité logarithmique

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.

Exemple de recherche dichotomique

Supposons qu'on nous donne un tableau d'entiers triés (en ordre


croissant, pour simplifier). Le fait que le tableau soit trié est important #include <iostream>
using std::cout;
dans ce cas—l'algorithme n'a pas de sens sinon. using std::endl;
const int INDICE_INVALIDE = -1;
Supposons qu'on nous demande à quelle position de ce tableau se int trouver_indice (const int tab[], const int TAILLE, const int val);
int main ()
trouve un élément d'une valeur bien précise, et que le rôle de notre {
algorithme soit de retourner l'index où se trouve ladite valeur—ou de const int MAX = 9;
int Tableau [MAX] = // le contenu est trié en ordre croissant
retourner un indicateur d'erreur quelconque si l'élément ne s'y trouve {
pas. 3, 4, 6, 8, 17, 22, 199, 201, 202
};
cout << "L'élément de valeur " << 4
Ainsi, si on pense à un programme comme celui-ci (à droite)... << " se trouve à l'indice "
<< trouver_indice (Tableau, MAX, 4)
...on devrait obtenir à l'exécution l'affichage suivant: << endl;
}

L'élément de valeur 4 se trouve à l'indice 1

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 solution simple mais inefficace

On peut facilement imaginer la solution simple (mais relativement


inefficace) proposée à droite. int trouver_indice (const int tab[], const int TAILLE, const int val)
{
int indice,
Elle a la qualité d'être simple à rédiger et à comprendre. Elle a toutefois compteur;
le défaut d'être complexe au sens où nous définissons la complexité compteur = 0;
while (compteur < TAILLE && tab[compteur] != val)
algorithmique : ++compteur;
if (compteur < TAILLE) // si la valeur a été trouvée
indice = compteur;
il y a un certain nombre d'opérations incontournables qui else
devront toujours être considérées quoiqu'il arrive. On en compte indice = INDICE_INVALIDE;
en fait ici quatre (4), soit—avant la boucle—l'initialisation de la return indice;
}
variable compteur, et—après la boucle—la comparaison de
compteur avec TAILLE, l'affectation d'une valeur à la
variable indice, et la production de la valeur de la fonction (énoncé return);
dans le meilleur cas (si val se trouve à l'élément 0 de tab[]), il nous faut seulement 2+4 opérations pour produire la solution, où le 2
constitue les deux conditions à évaluer au début de la boucle, et le 4 est le nombre d'opérations incontournables à faire;
dans le pire cas (si val se trouve à l'élément TAILLE-1 de tab[], ou si Valeur n'est pas du tout présente dans tab[]), il nous faut par
contre 3*TAILLE+4 opérations pour produire la solution (deux comparaisons et une incrémentation de compteur par tour de boucle, pour
TAILLE itérations, donc 3*TAILLE, ce à quoi s'ajoutent les 4 opérations de base);
dans le cas moyen (qui est ici le plus pertinent, puisque si on présume que la fonction pourra être appelée de manière uniforme pour des éléments
parfois près du début de tab[], et parfois près de la fin de tab[]), il nous faudra par contre 3*(TAILLE/2)+4 opérations pour produire la
solution. Je vous laisse réfléchir à celle-là.

Cela signifie qu'en moyenne, cet algorithme nécessitera :


19 (3*(10/2)+4) opérations pour un tableau de 10 éléments;
154 (3*(100/2)+4) opérations pour un tableau de 100 éléments;
1504 (3*(1000/2)+4) opérations pour un tableau de 1000 éléments;
15004 (3*(10000/2)+4) opérations pour un tableau de 10000 éléments;

Notez toutefois que dans le pire cas, cet algorithme nécessitera :

34 (3*(10)+4) opérations pour un tableau de 10 éléments;


304 (3*100+4) opérations pour un tableau de 100 éléments;
3004 (3*1000+4) opérations pour un tableau de 1000 éléments;
30004 (3*10000+4) opérations pour un tableau de 10000 éléments;

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é.

La solution de complexité logarithmique

Examinons maintenant une solution plus efficace, et analysons


rapidement le nombre d'opérations impliquées (en fonction de const int INDICE_INVALIDE = -1;
int trouver_indice (const int tab[], const int TAILLE, const int val)
TAILLE) : {
int indice, plafond, plancher;
bool trouve;
On y compte quatre opérations avant le coeur du traitement, et une //
autre tout juste après, ce qui fait en sorte que nous ayons cinq // Initialisation
opérations à faire quoi qu'il arrive. //
indice = INDICE_INVALIDE;
plafond = TAILLE - 1;
Le traitement en tant que tel peut se décomposer comme suit : plancher = 0;
trouve = false;
//
deux opérations dans le teste de la condition de poursuite de la // Traitement
boucle; //
trois opérations si, à l'intérieur de la boucle, le premier test de while (!trouve && plancher <= plafond)
{
condition est un succès (si on entre dans le if); const int MILIEU = (plancher + plafond) / 2; // risqué!
trois opérations si, à l'intérieur de la boucle, le premier test de if (val == tab[MILIEU])
{
condition échoue mais le second est un succès (si on entre dans le trouve = true;
else if); et indice = MILIEU;
trois opérations si, à l'intérieur de la boucle, le premier test de }
else if (val < tab[MILIEU])
condition échoue et qu'il en va de même pour le second (si on plafond = MILIEU - 1;
entre dans le else). else // val > tab[MILIEU]
plancher = MILIEU + 1;
}
Quoiqu'il arrive, on devra répéter 2+3, donc cinq opérations un //
certain nombre de fois. La grande question ici est : combien de fois? // Production du résultat
//
return indice;
Nous n'entrerons pas dans les calculs—pas qu'ils soient inintéressants, }
mais simplement parce qu'ils sont un peu plus mathématiques que ce que
ce cours est en droit de viser—sinon pour faire remarquer qu'à chaque itération, il peut se produire une de trois choses suivantes :

ou on trouve la valeur recherchée (cas du if), ce qui complète la tâche;


ou on réduit la taille de l'échantillon de moitié pour n'en explorer que la moitié inférieure, ayant convenu que la valeur recherchée ne peut pas se
trouve au-dessus (cas du else if);
ou on réduit la taille de l'échantillon de moitié pour n'en explorer que la moitié supérieure, ayant convenu que la valeur recherchée ne peut pas se
trouve en-dessous (cas du else).

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.

Taille O(3*TAILLE+4) O(3*(TAILLE/2)+4) O(5+5*log2(TAILLE))


10 34 19 25

100 304 154 40

1000 3004 1504 55

10000 30004 15004 75

100000 300004 150004 90

1000000 3000004 1500004 105

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)).

On trouve donc, dans le calcul de sa complexité, une addition, une


multiplication, et un calcul de logarithme.

Pourquoi dit-on alors qu'il s'agit là d'une complexité logarithmique?

Le raisonnement est le suivant :

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.

Algorithmes à complexité linéaire

Nous avons déjà vu un algorithme de complexité linéaire un peu plus haut.

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;
}

...ce qui donnerait, avec une liste chaînée (O(2+n*5))...

int somme_elements (Noeud *p)


{
int somme = 0;
while (p)
{
somme += p->valeur; // mettons; attention aux débordements!
p = p-> successeur;
}
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.

Algorithmes à complexité quadratique

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.

Exemple d'algorithme à complexité quadratique : un algorithme de tri à bulles, comme dans le


programme ci-après (qui met en ordre de manière inefficace les éléments d'un tableau). L'algorithme void echanger(int &a, int &b)
{
proposé à droite est de complexité O((2+n)*(2+n)) dans le meilleur cas, et O((2+n)*(5+n)) int temp = a;
dans le pire cas; on parle donc après simplification d'un algorithme O(n*n), ou plus simplement a = b;
b = temp;
O(n2). }
void tri_bulle (int tab[], const int TAILLE)
{
for (int i = 0; i < TAILLE; i++)
for (int j = i+ 1; j < TAILLE; j++)
En terminant, il existe des horreurs algorithmiques (dites « non polynomiales », dont la if (tab[i] > tab[j])
echanger (tab[i], tab[j]);
complexité ne s'exprime pas sous la forme d'un polynôme) qu'il faut éviter si on en a le }
choix. On parle par exemple d'algorithmes de complexité O(2n). Vous pouvez vous amuser
à tracer la courbe dénotant le nombre d'opérations pour ce genre d'algorithme... ça fait carrément peur! Pour en savoir plus, lisez ce
petit texte.

[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).