Algorithmique 2 · C2 Diviser pour régner · Chapitre 2 · 4 h
Analyse des algorithmes récursifs
É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 donnent , 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 du chapitre précédent, et il démontre que ce 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 , 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ù :
Trois quantités seulement gouvernent ce genre d'équation, et il faut les repérer avant de calculer : combien d'appels récursifs (), sur quelle fraction de l'entrée (), et combien coûte le travail non récursif ().
| Algorithme | Équation |
|---|---|
| Recherche dichotomique | |
| Tri fusion | |
| Tri rapide, cas moyen | |
| Tri rapide, pire cas | |
| Factorielle | |
| Fibonacci naïf |
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 et 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 par sa définition, plusieurs fois, jusqu'à voir le motif.
Le motif apparaît à la troisième ligne : après déroulements, .
Reste à savoir où s'arrêter : quand l'argument atteint le cas de base, c'est-à-dire quand , donc . En substituant :
Le est le nombre de fois qu'on peut couper 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 = n ⋮niveau k 2^k appels de taille n/2^k travail : n ⋮niveau log n n appels de taille 1 travail : nChaque niveau coûte — 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 niveaux. Total : .
Cette lecture rend immédiats les trois régimes possibles, qu'il suffit de reconnaître.
Le travail domine à la racine. Si décroît plus vite que le nombre d'appels ne croît, la somme est dominée par le premier niveau : . Exemple : donne .
Le travail est également réparti. Chaque niveau coûte pareil, et l'on multiplie par le nombre de niveaux : . C'est le tri fusion.
Le travail domine aux feuilles. Si le nombre d'appels croît plus vite que ne décroît, la somme est dominée par le dernier niveau, qui compte feuilles. Exemple : donne — le travail se fait dans les feuilles.
Ces trois cas constituent, sous une forme informelle, le théorème principal (master theorem). Le comparant à retenir est simple : confronter à .
Une équation ne relève pas de ce schéma et mérite d'être traitée à part : , le pire cas du tri rapide. Ici l'argument ne se divise pas, il décroît de 1 : il y a donc niveaux, et le travail total vaut , soit . Le contraste avec tient tout entier dans la façon de réduire l'entrée.
Quiz · 1 question
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 ?
- En log n, car il y a log n niveaux et le travail par niveau est constant — log n
- En n : le nombre d'appels double à chaque niveau tandis que le travail par appel reste à 1, donc le dernier niveau coûte à lui seul n et domine tout le reste — n
- En n log n, comme le tri fusion, puisque la structure de la récursion est la même — n log n
Réponse : C'est le troisième régime : le travail domine aux feuilles. Le niveau k contient 2^k appels coûtant 1 chacun, donc 2^k au total — cette quantité DOUBLE à chaque niveau au lieu de rester constante. Le dernier niveau en compte n/2^0… soit n feuilles, et la somme 1 + 2 + 4 + … + n vaut 2n − 1, dominée par son dernier terme. Total Θ(n). La différence avec le tri fusion tient au seul f(n) : à n par appel, la décroissance de la taille compense exactement le doublement du nombre d'appels et chaque niveau coûte n ; à 1 par appel, elle ne compense plus rien. La première réponse compte les niveaux en oubliant que chacun contient de plus en plus d'appels.
La dichotomie, et pourquoi elle est si rapide
Un seul appel par niveau, et un travail constant : l'arbre d'appels est une ligne de é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.
Graphique
Nombre de comparaisons pour traiter 100 000 valeurs
- Tri par insertion, n²/4 : 2 500 000 0002500000000
- Tri fusion, n log₂ n : 1 700 0001700000
- Recherche dichotomique, log₂ n : 1717
Pourquoi est une limite
Reste la question de fond : les deux tris du chapitre 3 s'arrêtent tous deux à . 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 feuilles — une par permutation. Or un arbre binaire de hauteur a au plus feuilles. Il faut donc , c'est-à-dire .
La formule de Stirling donne . La hauteur de l'arbre étant le nombre de comparaisons dans le pire cas :
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 entiers d'un intervalle borné en , en les rangeant directement dans des cases. Il ne contredit pas le théorème, il n'entre pas dans son cadre.
Quiz · 1 question
Un étudiant annonce un tri par comparaison en O(n) dans le pire cas. Que peut-on affirmer sans même lire son algorithme ?
- Qu'il faut l'examiner attentivement : rien n'interdit une telle découverte — à examiner
- Qu'il est faux, ou qu'il n'est pas un tri par comparaison : l'arbre de décision doit avoir n! feuilles, donc une hauteur d'au moins log₂(n!) = Θ(n log n) — impossible par construction
- Qu'il est correct mais seulement sur des données déjà presque triées — cas particulier
Réponse : La borne inférieure ne dépend d'aucun algorithme particulier : elle porte sur le MODÈLE. Un tri qui n'a que des comparaisons pour s'informer doit pouvoir aboutir à chacune des n! permutations, donc son arbre de décision a au moins n! feuilles ; un arbre binaire de hauteur h en ayant au plus 2^h, il faut h ≥ log₂(n!) = Θ(n log n). Aucune astuce ne franchit cela. Deux échappatoires seulement, et elles sortent du cadre : sortir du modèle, comme le tri par comptage qui range les clés dans des cases sans jamais les comparer et trie en O(n) sur un intervalle borné ; ou parler du MEILLEUR cas, où le tri par insertion est déjà en O(n) sur des données triées — mais ce n'est pas le pire cas.
À 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 pour le tri fusion et 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 à pour constater que la borne inférieure est bien du même ordre que ce que le tri fusion atteint.
Exercice de code
Résolvez numériquement quatre récurrences, puis comparez log2(n!) à n log2 n.
Point de départ
// 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");
Solution
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;
}
const fusion = resoudre((n, T) => 2 * T(Math.floor(n / 2)) + n, 1);
const rapidePire = resoudre((n, T) => T(n - 1) + n, 1);
const dicho = resoudre((n, T) => T(Math.floor(n / 2)) + 1, 1);
// Deux appels, travail constant : le nombre d'appels double sans que le
// travail par appel diminue. Le dernier niveau domine, et il compte n
// feuilles : c'est le troisième régime, donc Θ(n).
const feuilles = resoudre((n, T) => 2 * T(Math.floor(n / 2)) + 1, 1);
// Travail quadratique hors récursion : la racine domine, Θ(n²).
const racine = resoudre((n, T) => 2 * T(Math.floor(n / 2)) + n * n, 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);
console.log(" n = " + String(n).padStart(5) +
" | T(n) = " + String(mesure).padStart(12) +
" | " + formule.padEnd(14) + " = " + String(Math.round(conjecture)).padStart(12) +
" | rapport " + (mesure / conjecture).toFixed(2));
}
console.log("");
}
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");
comparer("T(n) = T(n/2) + 1 (dichotomie)", dicho, (n) => Math.log2(n), "log2 n");
comparer("T(n) = 2T(n/2) + 1 (travail aux feuilles)", feuilles, (n) => n, "n");
comparer("T(n) = 2T(n/2) + n^2 (travail à la racine)", racine, (n) => n * n, "n²");
// Dans les cinq cas le rapport tend vers une constante : c'est exactement ce
// que « du même ordre » veut dire. Le rapport ne tend PAS vers 1 — les
// constantes multiplicatives et les termes d'ordre inférieur subsistent — et
// c'est pourquoi on écrit Θ et non « égale ».
console.log("— la borne inférieure des tris par comparaison —");
// log2(n!) se calcule sans jamais construire n! : log2(a·b) = log2 a + log2 b.
function logFactorielle(n) {
let s = 0;
for (let k = 2; k <= n; k++) s += Math.log2(k);
return s;
}
for (const n of [16, 64, 256, 1024, 4096]) {
const borne = logFactorielle(n);
const atteint = n * Math.log2(n);
console.log(" n = " + String(n).padStart(5) +
" | log2(n!) = " + String(Math.round(borne)).padStart(7) +
" | n log2 n = " + String(Math.round(atteint)).padStart(7) +
" | rapport " + (borne / atteint).toFixed(3));
}
// Le rapport se stabilise autour de 0,9 : log2(n!) et n log2 n sont du même
// ordre, comme l'annonce la formule de Stirling. Autrement dit la borne
// inférieure et ce que le tri fusion atteint ne diffèrent que d'une constante
// — le tri fusion est optimal, et il ne reste aucune marge asymptotique.
En travaux pratiques
Travaux pratiques 4 · 2 h
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.
Avant de commencer
- Le TP 3 : les tris implémentés et mesurés
- De quoi tracer une courbe
Énoncé
- 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.
- Prédire — Pour chacune, prédisez le facteur multiplicatif du temps quand n double. Notez vos quatre prédictions avant toute mesure. Indice : Un algorithme en n log n ne double pas exactement : il fait un peu plus.
- Mesurer — Mesurez les quatre en doublant n plusieurs fois. Comparez au tableau de prédictions et calculez l'écart.
- 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.
- 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.
- 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.
- 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
Correction
dichotomie T(n) = T(n/2) + O(1) → O(log n) tri fusion T(n) = 2 T(n/2) + O(n) → O(n log n) tri rapide T(n) = 2 T(n/2) + O(n) → O(n log n) en moyenne pire cas T(n) = T(n-1) + O(n) → O(n²) Fibonacci T(n) = T(n-1) + T(n-2) → O(1,618^n) théorème général : T(n) = a T(n/b) + f(n) compare n^(log_b a) à f(n) ; ici a=2, b=2 → n, égal à f(n) → O(n log n)
La forme de la récurrence se lit directement dans le code : combien d'appels récursifs (a), sur quelle fraction de l'entrée (b), plus quel travail local (f). Savoir écrire la récurrence en regardant une fonction est plus utile que savoir réciter le théorème.
quand n double : prédit mesuré dichotomie O(log n) +1 étape +1 étape ✓ tri fusion O(n log n) ×2,1 ×2,3 ✓ insertion O(n²) ×4 ×4,1 ✓ Fibonacci O(1,618^n) ×2,6 ×2,6 ✓ /* mais entre n = 2^20 et n = 2^22, le tri fusion fait ×2,9 */
Trois prédictions sur quatre tombent juste immédiatement. La quatrième dérive au-delà d'une certaine taille — et ce n'est pas la théorie qui est fausse, c'est le modèle de coût qui suppose un accès mémoire à prix constant.
tri fusion, facteur mesuré quand n double : n < 2^18 (< cache L2) : ×2,1 n = 2^20 : ×2,3 n > 2^22 (> cache L3) : ×2,9 la complexité n'a pas changé ; le COÛT D'UN ACCÈS a changé (TP 7 d'Architecture : 1 ns en L1, 100 ns en mémoire centrale)
La complexité compte les OPÉRATIONS en supposant qu'elles coûtent toutes pareil. La machine réelle ne respecte pas cette hypothèse. La théorie reste valable — elle prédit correctement l'ordre de grandeur — mais elle ne suffit pas à choisir entre deux algorithmes de même complexité. C'est pourquoi on mesure toujours.
tri fusion, comparaisons mesurées : n comparaisons n log2 n 1 000 8 704 9 966 10 000 120 464 132 877 100 000 1 536 371 1 660 964 rapport comparaisons / (n log2 n) : 0,87, 0,91, 0,92 — STABLE
Le comptage d'opérations valide la théorie avec une précision que le chronomètre n'atteint jamais, parce qu'il ne dépend ni du cache, ni de l'ordonnanceur, ni de la fréquence du processeur. C'est la bonne façon de vérifier une analyse ; le chronomètre, lui, répond à une autre question — celle de la performance réelle.
tri rapide sur 100 000 éléments : meilleur cas (pivot toujours médian) : 0,008 s cas moyen (entrée aléatoire) : 0,009 s pire cas (entrée triée) : 24 s garantie contractuelle → le PIRE cas
Le cas moyen décrit ce qui se passe d'habitude, le pire cas ce qu'on peut PROMETTRE. Un service exposé sur le réseau ne raisonne jamais en moyenne, parce que l'entrée n'est pas tirée au hasard : elle est choisie par quelqu'un. C'est aussi pourquoi le tri rapide de la bibliothèque standard bascule vers le tri par tas quand la profondeur dérape — pour garantir un pire cas en n log n.
en échelle log-log, une complexité en n^k donne une DROITE de pente k insertion : pente 2,0 → O(n²) fusion : pente 1,05 → O(n log n), presque linéaire dichotomie: plate → O(log n)
C'est l'outil de diagnostic le plus rapide devant un programme lent : quelques mesures, une échelle logarithmique, et la pente donne la complexité réelle du code — celle qui s'exécute, pas celle qu'on croit avoir écrite. Une pente de 2 là où on attendait 1 signale presque toujours une boucle imbriquée oubliée, ou un appel à strlen dans une condition de boucle.
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 pour compter les nœuds d'un arbre binaire complet.
Le reviendra surtout comme promesse conditionnelle. Un arbre binaire de recherche donne des opérations en si sa hauteur reste logarithmique, et en 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 · 5 cartes
- Comment lit-on l'équation de récurrence d'un algorithme récursif ?
- Ligne à ligne, en repérant trois quantités : COMBIEN d'appels récursifs (a), SUR QUELLE FRACTION de l'entrée (n/b), et le COÛT DU TRAVAIL NON RÉCURSIF f(n). Le tri fusion donne T(n) = 2T(n/2) + n ; le pire cas du tri rapide donne T(n) = T(n−1) + n. Même algorithme, deux équations selon le pivot — et tout l'écart entre n log n et n² se lit avant le moindre calcul : diviser par deux contre retirer un.
- Décrivez la résolution par déroulement, sur T(n) = 2T(n/2) + n.
- On remplace T par sa définition jusqu'à voir le motif : 2T(n/2)+n, puis 4T(n/4)+2n, puis 8T(n/8)+3n, d'où après k déroulements 2^k·T(n/2^k) + k·n. On s'arrête quand l'argument atteint le cas de base, soit n/2^k = 1 donc k = log₂ n, ce qui donne n·T(1) + n log₂ n = O(n log n). Le log₂ n est simplement le NOMBRE DE FOIS qu'on peut couper n en deux avant d'arriver à 1.
- Quels sont les trois régimes que révèle l'arbre d'appels ?
- On calcule le travail par NIVEAU, puis on somme. 1) Le travail domine À LA RACINE : la somme est dominée par le premier niveau, T = Θ(f(n)) — ex. 2T(n/2)+n² donne Θ(n²). 2) Le travail est ÉGALEMENT RÉPARTI : chaque niveau coûte pareil, T = Θ(f(n)·log n) — c'est le tri fusion. 3) Le travail domine AUX FEUILLES : le dernier niveau l'emporte, T = Θ(n^(log_b a)) — ex. 2T(n/2)+1 donne Θ(n). C'est le théorème principal, et le comparant est f(n) contre n^(log_b a).
- Démontrez que tout tri par comparaison exige Ω(n log n) comparaisons.
- Un tel tri ne peut que comparer et se brancher : son exécution est un ARBRE DE DÉCISION binaire dont chaque feuille est une permutation atteignable. Comme il doit trier n'importe quelle entrée, l'arbre a au moins n! feuilles. Or un arbre binaire de hauteur h en a au plus 2^h, donc h ≥ log₂(n!) = Θ(n log n) par Stirling. La hauteur étant le nombre de comparaisons au pire cas, la borne est démontrée — et le tri fusion l'atteint, donc il est optimal dans sa catégorie.
- Comment le tri par comptage peut-il trier en O(n) sans contredire cette borne ?
- Parce qu'il n'entre pas dans le modèle. La borne suppose que l'algorithme ne s'informe QUE par des comparaisons. Le tri par comptage exploite la structure des clés : il range directement chaque entier dans la case correspondante d'un tableau de compteurs, sans jamais comparer deux éléments entre eux. Il trie donc n entiers d'un intervalle borné en O(n) — il ne contredit pas le théorème, il en sort.