Chapitre 8 sur 8

Complexité algorithmique

Laisser un commentaire

Nous utilisons les algorithmes informatique pour résoudre des problèmes. À chaque problème correspond en général plusieurs solutions. Toutes n’ont cependant pas forcément la même efficacité.

Cette efficacité, c’est le temps que mettra l’algorithme à résoudre le problème pour un input de taille n. Ce temps d’exécution se mesure mathématiquement, on parle de complexité dans le temps ou time complexity.

Évaluer le temps d’exécution

Peu importe le temps d’exécution réel, lequel dépend du langage, de la machine, de l’OS etc. L’objectif est d’évaluer l’efficacité d’un algorithme indépendamment du langage et du système sur lequel il est exécuté.

Pour cela, nous raisonnons de manière abstraite en ne considérant que l’algorithme et la valeur d’entrée n. Nous notons la complexité algorithmique T(n), soit une fonction exprimant le temps d’exécution T, pour l’input de taille n. Selon le contexte, la taille de n peut se référer au nombre d’éléments d’un tableau, à la longueur d’une chaine de caractères, à la taille d’un buffer etc.

Prenons un exemple : trouver si une valeur x est dans un tableau a. Pour résoudre ce problème, l’algorithme le plus simple est de faire une boucle et pour chacune des valeurs de l’array, vérifier s’il s’agit de x.

function findNeedle(needle, haystack) {
    for (let i = 0; i < haystack.length; i++) {
        if (needle === haystack[i]) return true;
    }

    return false;
}

findNeedle(5, [1, 2, 3, 4, 5, 6, 7, 8, 9]); // 4

Ainsi, pour évaluer le temps d’exécution d’un algorithme, on compte le nombre d’opérations effectuées pour arriver au résultat. Comme nous n’avons pas connaissance de l’input, on évalue le pire scénario.

Dans l’exemple ci-dessus, à chaque tour de boucle, deux comparaisons sont effectuées (i < haystack.length et needle === haystack[i]), nous avons une assignation (i++) ainsi qu’un return en fin de fonction où dès que le match est trouvé.

Dans le meilleur des cas, needle est le premier élément de l’array et il n’y a qu’un seul tour de boucle, soit un temps d’exécution T(n) = comparaison + comparaison + assignation + return. Cependant, dans le pire des cas, la valeur n’est pas présente dans l’array et T(n) = comparaison x (n + 1) + assignation x n + comparaison x n + return.

Dans la mesure de complexité, nous retenons en général la valeur pour le cas d’exécution le plus long. Ainsi, la complexité en temps traduit le temps maximum que l’algorithme peut mettre à s’exécuter.

Compter le temps

Nous avons dit compter les différentes opérations réalisées par l’algorithme. Cependant, nous ne prenons jamais en compte le temps d’exécution réel des opérations, et partons du présupposé qu’elles ont toutes la même durée. Dès lors, on peut regrouper les variables en utilisant seulement l’input et une constante.

T(n) = comparaison x (n + 1) + assignation x n + comparaison x n + return
     = c x (n + 1) + c x n + c x n + c

Aussi, comme la durée de c n’est pas vraiment définie et qu’elle influe peu sur l’augmentation du temps d’exécution relativement à l’augmentation en taille de l’input, on peut tout simplement ignorer les constantes. Dès lors, notre temps d’exécution T(n) est égal à 3n + 1.

Par ailleurs, pour simplifier les calculs, la boucle en elle-même est souvent comptée comme une seule opération, voir ignorée, auquel cas, notre complexité n’est plus que T(n) = n + 1. Nous verrons que dès lors qu’il s’agit de mesurer le comportement de T(n) pour des valeurs de n très grandes, ces simplifications n’ont que peu d’importance.

Prenons un autre exemple. Une fonction permettant de trier un tableau dans l’ordre croissant, communément appelé selection sort.

function selectionSort(list) {
    // on loop sur chaque élément du tableau
    for (let i = 0; i < list.length; i++) {
        let minJ = i; // on note l'index courant

        // pour tous les éléments restants du tableau, trouver le plus petit
        for (let j = i + 1; j < list.length; j++) {
            // si l'élément à l'index i + 1 est inférieur à l'élément à i, on le mémorise
            if (list[j] < list[minJ]) {
                minJ = j;
            }
        }

        const oldVal = list[i];
        const newVal = list[minJ];

        // échange des valeurs si différentes
        if (oldVal !== newVal) {
            list[i] = newVal;
            list[minJ] = oldVal;
        }
    }

    return list;
}

Si vous passez un array à cette fonction, l’array retourné contiendra les valeurs triées. Le premier for tourne n fois et exécute six opérations par boucle : cinq assignations et une comparaison, soit T(n) = 6n.

Le second for exécute une comparaison et une assignation, soit deux opérations. À chaque tour de la première boucle, la seconde est exécutée, mais elle loop à chaque fois sur un élément de moins. On peut donc représenter le nombre total par la somme des éléments suivants :

La formule permettant de représenter une somme de un à n est ((n - 1) x n) / 2 = (n² - n) / 2. Vous trouverez plus de détail concernant cette formule sur Wikipedia, le but n’est pas ici d’en apporter la preuve mathématique.

La seconde boucle a donc T(n) = ((n² - n) / 2) x 2 = n² - n. La complexité T(n) de notre fonction selectionSort est donc T(n) = n² - n + 6n - 6 = n² + 5n - 6.

Grâce à notre formule, nous pouvons déjà concrètement évaluer l’augmentation relative du temps d’exécution en fonction de notre input. Il s’agit d’une simple division.

Estimer l’augmentation du temps

Si nous donnons une valeur à n, la valeur absolue résultant du calcul de l’équation n’a pas vraiment de sens. Cependant, cela nous permet d’évaluer l’augmentation relative du temps de calcul nécessaire pour deux sets de données. Par exemple, pour T(10) = 10² + 5 * 10 - 6 = 144.

144 n’a pas vraiment de sens en soit, mais on peut le comparer à T(10000) = 10000² + 5 x 10000 - 6 = 100 050 006. T(10000) / T(10) = 100050006 / 144 = 694 791,70833 et on peut affirmer que le temps d’exécution de notre fonction augmente considérablement lorsque l’input augmente.

Ainsi, pour pour un input de taille 10000, la fonction mettra presque 700 000 fois plus de temps à s’exécuter que pour un input de taille 10.

Regardons les valeurs pour différentes tailles d’input :

8² + 5 x 8 - 6 = 98
16² + 5 x 16 - 6 = 330
–> T(16) / T(8) = 3,3673

32² + 5 x 32 - 6 = 1178
64² + 5 x 64 - 6 = 4410
–> T(64) / T(32) = 3,7436

Ici, nous doublons la valeur de n, plus les valeurs sont grandes, plus nous nous rapprochons de 4. Ainsi, nous pouvons estimer que la temps d’exécution est quatre fois plus important lorsque nous passons d’un input de cinquante mille à cent mille ou de un million à deux millions.

Nous réalisons que plus la valeur de l’input est grand, moins les constantes ont d’impact sur le résultat. Par ailleurs, pour simplifier encore l’équation, on peut approximer la résultat en ne conservant que son terme dominant, c’est à dire le terme qui a le facteur de croissance le plus important.

Dans notre exemple, l’équation simplifiée revient à dire T(n) ≈ n². Cette valeur est bien entendu une approximation, mais lorsque l’ordre de grandeur de n est important, approchant de l’asymptote, l’impact des autres termes de l’équation est négligeable.

Notation Big-O

La notation Big-O permet de représenter la complexité algorithmique en ne prenant en compte que le terme dominant. Techniquement, on étudie le comportement asymptotique de l’équation, dès lors, seul le terme dominant importe.

Cette notation permet de facilement ranger les algorithmes dans différentes classes de complexité et ainsi de facilement être en mesure de les comparer.

Un peu de maths

Avant d’expliquer dans le détail chacune des complexités couramment rencontrées, je pense qu’il peut être judicieux de rappeler quelques notions mathématiques.

En mathématiques, le logarithme de base b d’un nombre réel strictement positif est la puissance à laquelle il faut élever la base b pour obtenir ce nombre.

Wikipedia

Le logarithme de base b s’écrit donc log.b(n). Voyons quelques exemples :

  • log.10(10) = 1, car 10¹ = 10
  • log.10(1000) = 3, car 10³ = 1000
  • log.2(256) = 8, car 2⁸ = 256

Dans le calcul de complexité algorithmique, la plupart du temps la base n’est pas précisée mais il s’agit de base binaire, donc log.2(n).

Il y a une autre notation que vous ignorez peut-être : n! ; il s’agit de la factorielle.

En mathématiques, la factorielle d’un entier naturel n est le produit des nombres entiers strictement positifs inférieurs ou égaux à n.

Wikipedia

Voici quelques exemples afin que vous puissiez bien vous représenter de quoi il s’agit.

  • 1! = 1
  • 2! = 1 × 2 = 2
  • 3! = 1 × 2 × 3 = 6
  • 4! = 1 × 2 × 3 × 4 = 24
  • 10! = 1 × 2 × 3 × 4 × 5 × 6 × 7 × 8 × 9 × 10 = 3 628 800

Complexités usuelles

Voici un petit graphique des classes de complexité les plus courantes.

courbes des complexités algorithmiques

Maintenant que nous comprenons ces différentes formules, l’ordre du moins efficace au plus efficace est évident, nous avons ici :

Les complexités factorielle et exponentielle sont à éviter à tout prix ! Mis à part quelques algorithmes bien spécifiques pour lesquels il n’est pas possible de faire autrement, comme le problème du voyageur de commerce, ces complexités ne sont pas acceptables. Pour les problèmes dont la complexité est telle qu’ils ne sont résolubles que par un algorithme factoriel ou exponentiel, on parle de problème NP-complet.

Nous avons vu ici les complexités les plus communes, la Wikipedia anglaise en liste quelques autres. Vous pouvez aussi vous référer à cet article [EN], lequel référence les complexités d’algorithmes courants ainsi que des opérations sur diverses structures de données.

Complexité en espace

Nous avons jusqu’ici parlé de complexité dans le temps. C’est en effet l’indicateur de performance le plus couramment utilisé pour évaluer la performance d’un algorithme. Cependant, tout algorithme utilise deux ressources :

De ce fait, on peut également mesurer la complexité en espace d’un algorithme. On exprime cette complexité comme fonction de la taille d’entrée. On comptera ici les appels de fonctions, les déclarations de variables, etc. En résumé, tout ce qui équivaut à prendre de l’espace mémoire.

Ainsi, notre fonction selectionSort est O(1) car la taille de la mémoire utilisée est fixe et ne dépend pas de la taille d’entrée. En effet, let minJ = i est de taille constante, de même que oldVal et newval et leurs tailles ne dépendent pas de la taille de list.

La plupart du temps, on se focalise plus sur la complexité en temps que la complexité en espace. Cela est du au fait que la mémoire est assez abondante et peut être réutilisée, tandis que le temps n’est pas compressible.

Cependant, sur certains types de problèmes où le besoin en espace mémoire est important, il arrive de devoir faire des compromis. C’est à dire, opter pour un algorithme moins rapide mais ayant une complexité en espace plus faible.

En résumé

La notation Big-O permet de facilement évaluer la performance – dans le temps ou dans l’espace – d’un algorithme. Cela importe peu si vous savez que vous n’aurez pas à traiter d’importants volumes de données, mais dès que vous devez faire face à un volume un temps soit peu plus important, nous avons vu que des complexité de type factorielle ou exponentielle peuvent rapidement s’avérer inutilisable.

Vous êtes donc maintenant en mesure d’évaluer et de comparer différents algorithmes et de choisir le plus performante pour la résolution d’un problème donné.

Commentaires

Rejoignez la discussion !

Vous pouvez utiliser Markdown pour les liens [ancre de lien](url), la mise en *italique* et en **gras**. Enfin pour le code, vous pouvez utiliser la syntaxe `inline` et la syntaxe bloc

```
ceci est un bloc
de code
```