Aller au contenu
Topos Uranos

Résumé

Jusqu’ici, le cours a jugé les algorithmes sur une seule question : font-ils ce qu’ils disent ? Cette leçon ajoute la seconde, qui décide si un programme correct est utilisable en pratique : combien coûte-t-il ? Le coût se mesure d’abord sans horloge, en comptant combien de fois s’exécute l’opération dominante, et il se révèle être une fonction de la taille nn des données. Ce qui importe dans cette fonction n’est pas sa valeur pour un nn donné, mais la manière dont elle croît : une table de nn, nlog⁡nn \log n, n2n^2 et 2n2^n montre que les différences entre algorithmes, invisibles avec dix données, deviennent des abîmes avec un million. La notation OO est présentée comme une abréviation de ces ordres de croissance, sans définition formelle, avec des exemples déjà vus dans le cours : Euclide, logarithmique ; la somme d’un vecteur, linéaire ; comparer toutes les paires, quadratique ; Fibonacci naïf, exponentiel. Ensuite, on mesure avec l’horloge de <chrono>, std::chrono::steady_clock, et l’on apprend pourquoi une seule mesure trompe, pourquoi on mesure avec optimisation et sans détecteurs, et comment on confronte une mesure à une prédiction : quand nn double, un algorithme linéaire double son temps et un algorithme quadratique le quadruple.

Objectifs d’apprentissage

  1. Compter les opérations dominantes d’un algorithme à une ou deux boucles en fonction de la taille nn des données.
  2. Classer un algorithme comme constant, logarithmique, linéaire, nlog⁡nn \log n ou quadratique, et prédire comment son temps change quand nn double.
  3. Mesurer le temps d’exécution avec std::chrono::steady_clock et comparer la mesure à la prédiction.
  4. Choisir entre deux algorithmes pour une taille de données donnée, en justifiant le choix par l’ordre de croissance.

Compter les opérations

La seconde question

Les leçons Concevoir des boucles correctes et Récursivité ont appris à prouver qu’un algorithme est correct : qu’il termine et que son résultat satisfait la postcondition. Or la leçon Algorithmes numériques a montré deux tests de primalité également corrects, l’un avec environ nn divisions et l’autre avec environ n\sqrt{n}, et la leçon Récursivité, deux manières correctes de calculer F30F_{30}, l’une avec 2 692 5372\,692\,537 appels et l’autre avec 29 additions. S’il en est ainsi, la correction ne suffit pas pour choisir : entre deux algorithmes corrects, l’un peut répondre en un instant et l’autre ne pas terminer avant que la réponse ait cessé d’importer. La seconde question que l’on pose à un algorithme est donc : combien coûte-t-il ?

On pourrait mesurer le coût avec une horloge, et la quatrième section le fera. Mais le temps d’une exécution dépend de la machine, du compilateur, de ses options et de ce que l’ordinateur fait au même moment ; on n’en tire rien sur l’algorithme sur une autre machine. Ce qui ne dépend de rien de tout cela, c’est le nombre de pas que fait l’algorithme. Il s’ensuit la première décision de cette leçon : mesurer le coût en comptant, et réserver l’horloge à la vérification du compte.

La taille et l’opération dominante

Compter tous les pas (chaque addition, chaque comparaison, chaque incrémentation de l’indice, chaque accès à la mémoire) serait fastidieux et inutile, car presque tous accompagnent un pas principal dans une proportion fixe. On choisit donc l’opération dominante : celle qui s’exécute au moins autant de fois que n’importe quelle autre, à un facteur constant près, et qui pour cette raison les représente toutes. Dans la somme des éléments d’un vecteur, c’est l’addition ; dans la recherche d’un maximum, la comparaison ; dans un test de primalité, la division. Le nombre de fois qu’elle s’exécute s’exprime en fonction de la taille de l’entrée, que l’on appelle nn : le nombre d’éléments du vecteur, le nombre que l’on teste, le nombre de chiffres.

Une boucle simple qui parcourt un vecteur de nn éléments et fait avec chacun une quantité fixe de travail exécute son opération dominante nn fois. Le maximum d’un vecteur non vide, en revanche, commence avec le premier élément comme candidat et compare chacun des autres : n−1n - 1 comparaisons. La différence entre nn et n−1n - 1 est réelle, mais, comme on le verra dans la troisième section, elle ne change rien à ce qui importe.

Le pire cas

Certains algorithmes n’exécutent pas toujours le même nombre d’opérations sur des données de même taille. La recherche d’une valeur dans un vecteur, en le parcourant jusqu’à la trouver, fait une seule comparaison si la valeur est en première position et nn si elle n’y est pas. Pour parler de son coût, on choisit l’une des deux situations, et le choix habituel est le pire cas : le nombre maximal d’opérations parmi toutes les entrées de taille nn. La raison est la même que celle pour laquelle un contrat s’écrit pour toutes les entrées qui satisfont la précondition : le pire cas est une garantie, et le meilleur cas, un espoir. De sorte que la recherche linéaire coûte nn comparaisons dans le pire cas, et quand cette leçon dit « le coût » sans plus, elle désigne celui du pire cas.

Boucles imbriquées

La leçon Boucles a appris à compter les itérations de deux boucles imbriquées. Si la boucle intérieure fait toujours mm itérations pour chacune des nn de la boucle extérieure, le corps intérieur s’exécute n⋅mn \cdot m fois. Si la boucle intérieure dépend de l’extérieure, on additionne ligne par ligne. Le cas qui apparaît le plus souvent quand on travaille avec des collections est celui de toutes les paires : comparer chaque élément à chacun de ceux qui le suivent, comme lorsqu’on cherche si un vecteur contient des doublons.

Programme en C++
#include <print>​int main(){    for (int n{10}; n <= 10'000; n *= 10) {        long long comparisons{0};        for (int i{0}; i < n; ++i) {            for (int j{i + 1}; j < n; ++j) {                ++comparisons;            }        }        std::println("n = {:>6} : {:>10} comparaisons ; n(n - 1)/2 = {}",                     n, comparisons, static_cast<long long>(n) * (n - 1) / 2);    }}
Sortie
n =     10 :         45 comparaisons ; n(n - 1)/2 = 45n =    100 :       4950 comparaisons ; n(n - 1)/2 = 4950n =   1000 :     499500 comparaisons ; n(n - 1)/2 = 499500n =  10000 :   49995000 comparaisons ; n(n - 1)/2 = 49995000

Le compteur tient la place de la comparaison que ferait l’algorithme réel. À l’itération ii de la boucle extérieure, la boucle intérieure va de i+1i + 1 à n−1n - 1 et fait n−1−in - 1 - i itérations ; de sorte que le total est (n−1)+(n−2)+⋯+1+0(n - 1) + (n - 2) + \cdots + 1 + 0, la somme de la leçon Boucles :

∑i=0n−1(n−1−i)=n(n−1)2\sum_{i=0}^{n-1} (n - 1 - i) = \frac{n(n - 1)}{2}

La sortie confirme la formule pour chaque taille. Le compteur est un long long parce qu’à partir d’un nn d’environ soixante-cinq mille le nombre de paires dépasse le maximum de int (de 32 bits sur cette plateforme), et la formule multiplie après la conversion pour la même raison : le compteur d’un algorithme quadratique déborde bien avant les données.

On remarquera déjà ce que la troisième section généralisera : quand on multiplie nn par 10, les comparaisons sont multipliées par environ 100. Avec dix données, 45 comparaisons ; avec dix mille, presque cinquante millions.