cursus.

Cours 2 · Diviser pour régnerLeçon 2 sur 2

Analyse des algorithmes récursifs

4 h de lecture9 sections Version PDF

À la fin de cette leçon, vous saurez

Équations de récurrence, résolution par déroulement et par arbre d'appels ; pourquoi n log n est la borne des tris par comparaison ; dichotomie récursive.

Algorithmique 1 comptait les opérations d'une boucle : deux boucles imbriquées sur nn donnent n2n^2, et l'affaire est entendue. Cette méthode s'effondre devant une fonction qui s'appelle elle-même — pour compter ses opérations, il faudrait déjà connaître le coût de l'appel récursif, c'est-à-dire la réponse cherchée.

Ce chapitre donne l'outil qui débloque la situation : on écrit le coût en fonction de lui-même, puis on résout. Il explique enfin d'où sort le logn\log n du chapitre précédent, et il démontre que ce nlognn \log n n'est pas un hasard mais une limite infranchissable.

Poser l'équation

Le coût d'un algorithme récursif s'écrit comme une équation de récurrence : le coût pour une entrée de taille nn, exprimé à partir du coût pour des entrées plus petites.

La lecture est mécanique, et il faut prendre l'habitude de la faire ligne à ligne.

fonction triFusion(T)                   T(n) = ...    si taille(T) ≤ 1 alors retourner T      cas de base : T(1) = 1    couper T en G et D                      découpe : O(n), parfois O(1)    G ← triFusion(G)                        un appel sur n/2 : T(n/2)    D ← triFusion(D)                        un autre : T(n/2)    retourner fusion(G, D)                  fusion linéaire : O(n)

D'où :

T(n)=2T ⁣(n2)+nT(1)=1T(n) = 2\,T\!\left(\frac{n}{2}\right) + n \qquad\qquad T(1) = 1

Trois quantités seulement gouvernent ce genre d'équation, et il faut les repérer avant de calculer : combien d'appels récursifs (aa), sur quelle fraction de l'entrée (n/bn/b), et combien coûte le travail non récursif (f(n)f(n)).

AlgorithmeÉquation
Recherche dichotomiqueT(n)=T(n/2)+1T(n) = T(n/2) + 1
Tri fusionT(n)=2T(n/2)+nT(n) = 2\,T(n/2) + n
Tri rapide, cas moyenT(n)=2T(n/2)+nT(n) = 2\,T(n/2) + n
Tri rapide, pire casT(n)=T(n1)+nT(n) = T(n-1) + n
FactorielleT(n)=T(n1)+1T(n) = T(n-1) + 1
Fibonacci naïfT(n)=T(n1)+T(n2)+1T(n) = T(n-1) + T(n-2) + 1

Remarquez la deuxième et la quatrième ligne : le même algorithme, deux équations différentes selon la qualité du pivot. C'est exactement l'écart entre nlognn \log n et n2n^2 du chapitre 3, et il se lit ici avant tout calcul — diviser par deux contre retirer un.

Résoudre par déroulement

La première méthode consiste à remplacer TT par sa définition, plusieurs fois, jusqu'à voir le motif.

T(n)=2T(n/2)+n=2[2T(n/4)+n/2]+n=4T(n/4)+2n=4[2T(n/8)+n/4]+2n=8T(n/8)+3n\begin{aligned} T(n) &= 2\,T(n/2) + n \\ &= 2\,[\,2\,T(n/4) + n/2\,] + n = 4\,T(n/4) + 2n \\ &= 4\,[\,2\,T(n/8) + n/4\,] + 2n = 8\,T(n/8) + 3n \end{aligned}

Le motif apparaît à la troisième ligne : après kk déroulements, T(n)=2kT(n/2k)+knT(n) = 2^k\,T(n/2^k) + k\,n.

Reste à savoir où s'arrêter : quand l'argument atteint le cas de base, c'est-à-dire quand n/2k=1n/2^k = 1, donc k=log2nk = \log_2 n. En substituant :

T(n)=nT(1)+nlog2n=n+nlog2n=O(nlogn)T(n) = n\,T(1) + n \log_2 n = n + n \log_2 n = O(n \log n)

Le log2n\log_2 n est le nombre de fois qu'on peut couper nn en deux avant d'arriver à 1. Ce n'est pas une formule à retenir, c'est ce que la division par deux fait.

Résoudre par arbre d'appels

La seconde méthode est plus visuelle, et c'est celle qu'il faut avoir en tête pour raisonner vite. On dessine l'arbre des appels, on calcule le travail effectué à chaque niveau, puis on somme sur les niveaux.

niveau 0        n                                   travail : n              ┌─┴─┐niveau 1     n/2  n/2                               travail : 2 × n/2 = n            ┌─┴┐  ┌┴─┐niveau 2   n/4 n/4 n/4 n/4                          travail : 4 × n/4 = nniveau k   2^k appels de taille n/2^k               travail : nniveau log n    n appels de taille 1                travail : n

Chaque niveau coûte nn — c'est le fait remarquable du tri fusion : le nombre d'appels double pendant que leur taille est divisée par deux, donc le produit reste constant. Il y a log2n\log_2 n niveaux. Total : nlog2nn \log_2 n.

Cette lecture rend immédiats les trois régimes possibles, qu'il suffit de reconnaître.

Le travail domine à la racine. Si f(n)f(n) décroît plus vite que le nombre d'appels ne croît, la somme est dominée par le premier niveau : T(n)=Θ(f(n))T(n) = \Theta(f(n)). Exemple : T(n)=2T(n/2)+n2T(n) = 2T(n/2) + n^2 donne Θ(n2)\Theta(n^2).

Le travail est également réparti. Chaque niveau coûte pareil, et l'on multiplie par le nombre de niveaux : T(n)=Θ(f(n)logn)T(n) = \Theta(f(n) \log n). C'est le tri fusion.

Le travail domine aux feuilles. Si le nombre d'appels croît plus vite que ff ne décroît, la somme est dominée par le dernier niveau, qui compte alogbn=nlogbaa^{\log_b n} = n^{\log_b a} feuilles. Exemple : T(n)=2T(n/2)+1T(n) = 2T(n/2) + 1 donne Θ(n)\Theta(n) — le travail se fait dans les nn feuilles.

Ces trois cas constituent, sous une forme informelle, le théorème principal (master theorem). Le comparant à retenir est simple : confronter f(n)f(n) à nlogban^{\log_b a}.

Une équation ne relève pas de ce schéma et mérite d'être traitée à part : T(n)=T(n1)+nT(n) = T(n-1) + n, le pire cas du tri rapide. Ici l'argument ne se divise pas, il décroît de 1 : il y a donc nn niveaux, et le travail total vaut n+(n1)++1=n(n+1)/2n + (n-1) + \dots + 1 = n(n+1)/2, soit Θ(n2)\Theta(n^2). Le contraste avec 2T(n/2)+n2T(n/2) + n tient tout entier dans la façon de réduire l'entrée.

Quiz · vérifiez votre compréhension Sans réponse

Un algorithme récursif satisfait T(n) = 2 T(n/2) + 1 : deux appels sur des moitiés, et un travail constant hors récursion. Quel est son coût total ?

La dichotomie, et pourquoi elle est si rapide

T(n)=T ⁣(n2)+1T(n)=log2nT(n) = T\!\left(\frac{n}{2}\right) + 1 \qquad\Longrightarrow\qquad T(n) = \log_2 n

Un seul appel par niveau, et un travail constant : l'arbre d'appels est une ligne de log2n\log_2 n éléments. C'est le premier régime, dégénéré — il n'y a qu'un appel par niveau, donc rien ne s'accumule.

Le chiffre mérite d'être posé, parce qu'il est difficile à croire tant qu'on ne l'a pas écrit : sur un million de valeurs, la recherche séquentielle demande jusqu'à un million de comparaisons, la dichotomie vingt. Sur un milliard, trente.

Les deux dernières barres sont invisibles, et c'est le propos : face aux deux milliards et demi de comparaisons du tri quadratique, le tri fusion en demande 1 700 000 — mille cinq cents fois moins — et une recherche dichotomique dix-sept. Ce ne sont pas des optimisations, ce sont des changements de régime.

Pourquoi nlognn \log n est une limite

Reste la question de fond : les deux tris du chapitre 3 s'arrêtent tous deux à nlognn \log n. Coïncidence, ou obstacle ?

C'est un obstacle, et il se démontre en trois lignes.

Un tri par comparaison ne peut faire qu'une chose : comparer deux éléments et se brancher selon le résultat. Son exécution se décrit donc par un arbre de décision binaire, dont chaque nœud est une comparaison et chaque feuille une permutation possible du tableau de départ.

Comme l'algorithme doit pouvoir trier n'importe quelle entrée, l'arbre doit posséder au moins n!n! feuilles — une par permutation. Or un arbre binaire de hauteur hh a au plus 2h2^h feuilles. Il faut donc 2hn!2^h \ge n!, c'est-à-dire hlog2(n!)h \ge \log_2(n!).

La formule de Stirling donne log2(n!)=Θ(nlogn)\log_2(n!) = \Theta(n \log n). La hauteur de l'arbre étant le nombre de comparaisons dans le pire cas :

tout tri par comparaison exige Ω(nlogn) comparaisons.\text{tout tri par comparaison exige } \Omega(n \log n) \text{ comparaisons.}

Le tri fusion atteint donc l'optimum de sa catégorie, et aucun algorithme plus astucieux ne descendra sous cette barre.

L'hypothèse « par comparaison » est essentielle, et c'est elle qui laisse une porte ouverte. Un tri qui exploite la structure des clés au lieu de les comparer échappe au raisonnement : le tri par comptage trie nn entiers d'un intervalle borné en O(n)O(n), en les rangeant directement dans des cases. Il ne contredit pas le théorème, il n'entre pas dans son cadre.

Quiz · vérifiez votre compréhension Sans réponse

Un étudiant annonce un tri par comparaison en O(n) dans le pire cas. Que peut-on affirmer sans même lire son algorithme ?

À vous

L'exercice résout les récurrences numériquement, ce qui est le meilleur moyen de vérifier une résolution faite à la main : on programme la récurrence telle qu'elle est écrite, on la tabule, et on compare à la forme close conjecturée.

Trois choses à obtenir. Retrouver nlog2nn \log_2 n pour le tri fusion et n(n+1)/2n(n+1)/2 pour le pire cas du tri rapide. Compter les niveaux et le travail par niveau d'un arbre d'appels, et voir lequel des trois régimes s'applique. Enfin, comparer log2(n!)\log_2(n!) à nlog2nn \log_2 n pour constater que la borne inférieure est bien du même ordre que ce que le tri fusion atteint.

Exercice · JavaScript · à vous de jouer

Résolvez numériquement quatre récurrences, puis comparez log2(n!) à n log2 n.

En attente
// Une récurrence se programme littéralement, avec mémoïsation pour que la
// tabulation reste possible (ch. 2 : ne jamais répondre deux fois).
function resoudre(equation, base) {
  const table = new Map();
  const T = (n) => {
    if (n <= 1) return base;
    if (table.has(n)) return table.get(n);
    const v = equation(n, T);
    table.set(n, v);
    return v;
  };
  return T;
}

// T(n) = 2 T(n/2) + n            — tri fusion
const fusion = resoudre((n, T) => 2 * T(Math.floor(n / 2)) + n, 1);

// T(n) = T(n-1) + n              — tri rapide, pire cas
const rapidePire = resoudre((n, T) => T(n - 1) + n, 1);

// T(n) = T(n/2) + 1              — dichotomie
const dicho = resoudre((n, T) => T(Math.floor(n / 2)) + 1, 1);

// T(n) = 2 T(n/2) + 1            — ← à écrire : quel régime ?
const feuilles = resoudre((n, T) => 0, 1);

function comparer(nom, T, forme, formule) {
  console.log(nom);
  for (const n of [16, 64, 256, 1024, 4096]) {
    const mesure = T(n);
    const conjecture = forme(n);
    const ecart = (mesure / conjecture).toFixed(2);
    console.log("   n = " + String(n).padStart(5) +
      " | T(n) = " + String(mesure).padStart(10) +
      " | " + formule.padEnd(14) + " = " + String(Math.round(conjecture)).padStart(10) +
      " | rapport " + ecart);
  }
}

// ── À VOUS ────────────────────────────────────────────────────────────────
// 1. Écrivez l'équation de « feuilles » et devinez son ordre AVANT de lancer.
// 2. Vérifiez que le rapport T(n) / conjecture tend vers une constante :
//    c'est ce que signifie « du même ordre ».
// 3. Comparez log2(n!) à n·log2(n) : la borne inférieure des tris est-elle
//    bien du même ordre que ce que le tri fusion atteint ?

comparer("T(n) = 2T(n/2) + n   (tri fusion)", fusion, (n) => n * Math.log2(n), "n log2 n");
comparer("T(n) = T(n-1) + n    (rapide, pire cas)", rapidePire, (n) => n * (n + 1) / 2, "n(n+1)/2");

Console de sortie
Le résultat s'affiche dans la console

En travaux pratiques

Travaux pratiques 4 · sur machine

Confronter la théorie à la mesure

Prédire la complexité d'un algorithme récursif par le calcul, puis vérifier la prédiction sur la machine — et regarder ce qui se passe quand les deux divergent.

2 h
Avant de commencer
  • Le TP 3 : les tris implémentés et mesurés
  • De quoi tracer une courbe
  1. 1. Poser les récurrences

    Écrivez la relation de récurrence de la recherche dichotomique, du tri fusion, du tri rapide en moyenne, et de Fibonacci naïf. Résolvez-les.

  2. 2. Prédire

    Pour chacune, prédisez le facteur multiplicatif du temps quand n double. Notez vos quatre prédictions avant toute mesure.

  3. 3. Mesurer

    Mesurez les quatre en doublant n plusieurs fois. Comparez au tableau de prédictions et calculez l'écart.

  4. 4. L'écart

    Là où mesure et prédiction divergent, cherchez la cause. Vérifiez notamment ce que fait la courbe quand le tableau dépasse la taille du cache.

  5. 5. Compter au lieu de chronométrer

    Instrumentez vos tris pour compter les comparaisons plutôt que le temps. Comparez les courbes obtenues, et dites laquelle valide la théorie.

  6. 6. Le pire, le meilleur, le moyen

    Pour le tri rapide, mesurez les trois cas sur la même taille. Écrivez ce que chacun signifie et lequel doit figurer dans une garantie contractuelle.

  7. 7. Lire une courbe

    Tracez vos mesures en échelle logarithmique sur les deux axes. Mesurez la pente de chaque droite et déduisez-en l'exposant.

C'est réussi quand
  • Vos quatre prédictions sont posées avant les mesures, et trois au moins se vérifient
  • Vous expliquez l'écart restant sans invoquer le hasard
  • Le comptage de comparaisons colle à la théorie mieux que le chronomètre
  • Vous lisez l'exposant d'un algorithme sur la pente de sa droite

Ce que la suite en fait

Le bloc III change de sujet mais garde l'outil. Le coût d'une insertion en tête de liste chaînée, d'un empilement, d'un parcours d'arbre : tous s'écrivent en récurrences, et le chapitre 7 posera T(h)=2T(h1)+1T(h) = 2T(h-1) + 1 pour compter les nœuds d'un arbre binaire complet.

Le logn\log n reviendra surtout comme promesse conditionnelle. Un arbre binaire de recherche donne des opérations en O(logn)O(\log n) si sa hauteur reste logarithmique, et en O(n)O(n) s'il dégénère en liste — exactement le même écart qu'entre le bon et le mauvais pivot du chapitre 3. Le chapitre 8 montrera ce que coûte de le garantir.

À retenir

Flashcards · 1 / 5Toucher pour retourner
Fin de la leçon

Vous avez parcouru les 9 sections.

Marquez-la terminée pour faire avancer votre parcours, ou revenez sur un point avant de passer à la suite.