Récursivité
Cours complet · informatique (MP2I/MPI), chapitre 5 · MP2I et MPI
Travailler ce chapitre sur Adloun Exercices corrigés de ce chapitre
5.1 Se définir par soi-même
Pour décrire un escalier, deux phrases sont possibles. « Une suite de marches » : c'est la boucle. « Une marche, puis un escalier plus petit » : c'est la récursivité. Les deux décrivent le même objet ; la seconde le décrit en s'appuyant sur lui-même, et s'arrête parce que l'escalier finit par n'avoir plus de marches.
Le programme place cette idée très haut : « la capacité d'un programme à faire appel à lui-même est un concept primordial en informatique. Historiquement, l'auto-référence est au cœur du paradigme de programmation fonctionnelle. » Et il pose une limite nette : « on se limite à une présentation pratique de la récursivité comme technique de programmation », et « toute théorie générale de la dérécursification est hors programme ». On apprend donc à s'en servir, à la prouver et à en payer le prix — pas à la théoriser.
Le programme est explicite : « on évite de se limiter à des exemples informatiquement peu pertinents (factorielle, suite de Fibonacci, …) ». Ces deux exemples-là s'écrivent mieux avec une boucle, et donnent la fausse impression que la récursivité n'est qu'une boucle déguisée. Ce chapitre les cite pour analyser leur coût, jamais pour les recommander.
5.2 Le principe
Une fonction est récursive si son corps contient un appel à elle-même. Toute définition récursive correcte comporte :
- un ou plusieurs cas de base, résolus sans appel ;
- un ou plusieurs cas récursifs, où la solution s'exprime à partir de la solution du même problème sur une entrée strictement plus petite.
En OCaml, le mot-clé rec est obligatoire pour qu'une fonction se voie elle-même ; en C, rien n'est à déclarer.
(* Écrit n en binaire, du bit de poids fort au bit de poids faible.
Précondition : n >= 0. *)
let rec binaire n =
if n < 2 then string_of_int n
else binaire (n / 2) ^ string_of_int (n mod 2)
La version à boucle existe, mais elle doit construire la chaîne à l'envers puis la retourner : la récursion, elle, produit les bits dans le bon ordre sans effort, parce que la remontée des appels renverse naturellement l'ordre de la descente. C'est cela, un bon exemple de récursivité : un problème où elle simplifie.
Méthode : Concevoir une fonction récursive : trois questions
- Le cas de base. Pour quelles entrées la réponse est-elle immédiate ?
- La décomposition. Si l'on me donnait la solution pour une entrée plus petite, comment en déduirais-je la mienne ?
- La décroissance. Chaque appel rapproche-t-il strictement d'un cas de base ?
Le point 2 est le saut : on fait confiance à l'appel récursif, comme on fait confiance à l'hypothèse de récurrence — sans dérouler mentalement les appels. Le point 3 est le variant du chapitre chap:algo-prog, et c'est lui qui prouve la terminaison.
Deux fonctions peuvent s'appeler l'une l'autre. En OCaml, elles se déclarent ensemble avec and :
(* pair n et impair n disent la parité de n. Précondition : n >= 0. *)
let rec pair n = if n = 0 then true else impair (n - 1)
and impair n = if n = 0 then false else pair (n - 1)
Le variant est le même pour les deux — décroît strictement à chaque appel — et c'est ce qui prouve que le va-et-vient s'arrête.
5.3 Ce que fait la machine : la pile d'exécution
Le programme insiste : « on met l'accent sur la gestion au niveau de la machine, en termes d'occupation mémoire, de la pile d'exécution, et de temps de calcul, en évoquant les questions de sauvegarde et de restauration de contexte ».
À chaque appel, la machine empile un bloc d'activation contenant les paramètres, les variables locales et l'adresse de retour. Au retour, le bloc est dépilé et l'exécution reprend chez l'appelant, dans le contexte restauré.
La pile a une taille bornée, et les deux langages n'ont pas du tout la même marge. Mesuré sur une récursion non terminale, avec la pile par défaut de Mio :
| Profondeur atteinte | Ce qui arrive ensuite | |
|---|---|---|
| C, `-O0` | quelques centaines de milliers | violation de segment, brutale |
| OCaml | quelques dizaines de millions | exception `Stack_overflow` |
Une profondeur de tue donc un programme C et ne gêne pas OCaml, dont la pile s'étend. L'écart tient à la gestion de la mémoire, pas au langage : ne transposez jamais d'un langage à l'autre une limite mesurée sur l'un.
Retenez donc qu'une fonction récursive a un coût en espace égal à sa profondeur de récursion, même si elle n'alloue rien. Une récursion sur un tableau de cases traité un élément à la fois est en espace ; la même en dichotomie est .
Quand une fonction s'appelle plusieurs fois, les activations ne forment plus une chaîne mais un arbre — le programme demande explicitement l'« organisation des activations sous forme d'arbre en cas d'appels multiples ».
Ci-dessus, l'arbre des appels de fib 4. On y voit fib 2 calculé deux fois et fib 1 trois fois : c'est le chevauchement des sous-problèmes, et c'est ce qui rend cette récursion exponentielle. La programmation dynamique du chapitre chap:dynamique n'existe que pour supprimer ce gâchis.
Attention à ne pas confondre : la hauteur de cet arbre est la profondeur de pile (), tandis que son nombre de nœuds est le temps de calcul (). Deux grandeurs, deux coûts.
5.4 Prouver une fonction récursive
Méthode : Terminaison par variant, correction par récurrence
- Terminaison : exhiber une quantité entière positive qui décroît strictement à chaque appel récursif. C'est le variant, transposé de la boucle à l'appel.
- Correction : récurrence sur ce même variant. On suppose la fonction correcte sur toutes les entrées de variant strictement plus petit, et l'on montre qu'elle l'est sur l'entrée courante.
(* puissance x n renvoie x^n. Précondition : n >= 0. *)
let rec puissance x n =
if n = 0 then 1
else
let m = puissance x (n / 2) in
if n mod 2 = 0 then m * m else m * m * x
Démonstration
Terminaison. Variant : . Pour , l'appel se fait sur , et reste positif. La suite des variants est strictement décroissante et minorée : elle est finie.
Correction, par récurrence forte sur . Base : , la fonction rend . Hérédité : soit , et supposons la fonction correcte pour tout . Alors par hypothèse.
- Si est pair, , et .
- Si est impair, , et .
Dans les deux cas la valeur rendue est .
Complexité. Le variant est divisé par deux à chaque appel : la profondeur est , et chaque niveau coûte multiplication. Donc multiplications, contre pour la méthode naïve. Pour : trente multiplications au lieu d'un milliard.
5.5 Les récurrences de complexité
Le programme nomme trois récurrences, « introduites au fur et à mesure de l'étude de la complexité des différents algorithmes rencontrés » :
Et il pose une consigne de méthode : « on utilise des encadrements élémentaires ad hoc afin de les justifier ; on évite d'appliquer un théorème-maître général ». Le théorème-maître est donc hors programme : chaque récurrence se résout à la main.
En déroulant :
Pour — un seul appel récursif, comme la recherche dichotomique — il y a niveaux, chacun coûtant :
Pour — deux appels, comme un parcours d'arbre complet — le nombre d'appels double à chaque niveau : il y en a , d'où .
Avec — le tri par partition-fusion : chaque niveau traite éléments au total, et il y a niveaux :
D'où — le résultat le plus utile de tout ce livre, et il s'obtient en comptant des rectangles.
5.6 Récursion terminale
Un appel récursif est terminal lorsqu'il est la dernière chose que fait la fonction : son résultat est rendu tel quel, sans calcul après lui.
(* NON terminal : il reste « x * ... » à faire APRÈS l'appel. *)
let rec produit = function [] -> 1 | x :: r -> x * produit r
(* Terminal : l'appel est la dernière opération, l'accumulateur porte le calcul. *)
let produit_t l =
let rec aux acc = function [] -> acc | x :: r -> aux (acc * x) r in
aux 1 l
Dans le second cas, rien ne reste à faire au retour : le compilateur OCaml peut réutiliser le bloc d'activation courant au lieu d'en empiler un nouveau. La récursion s'exécute alors en espace , comme une boucle.
La théorie générale de la dérécursification est hors programme, et la récursion terminale n'en est pas : c'est une propriété de la forme d'une fonction, que le compilateur exploite. On la connaît pour deux raisons pratiques : elle explique pourquoi certaines fonctions récursives ne débordent jamais la pile, et elle justifie le style à accumulateur, très fréquent en OCaml.
gcc sait faire cette optimisation, mais rien dans la norme ne l'y oblige, et elle disparaît sans les options d'optimisation.
Et elle va plus loin qu'on ne l'attend, ce qui rend la dépendance plus inquiétante encore : mesuré sur ce livre, une somme récursive non terminale — où il reste un n + à faire au retour — meurt à appels sous -O0 et passe sans broncher sous -O2, l'optimiseur ayant reconnu l'accumulation. Le même source vit ou meurt selon une option de ligne de commande, y compris quand l'appel n'est pas terminal. En C, une récursion profonde reste donc un risque ; on y préfère souvent la boucle explicite.
5.7 Ce qu'il faut retenir
- Un cas de base, atteint par tout appel — c'est le variant qui le garantit.
- Une décomposition en sous-problème strictement plus petit, à laquelle on fait confiance.
- Un coût en espace égal à la profondeur de pile, même sans allocation.
- Un coût en temps donné par le nombre de nœuds de l'arbre des appels — que l'on obtient en résolvant la récurrence à la main, jamais par un théorème-maître.
Le chapitre chap:induction montrera que ces raisonnements se généralisent : le variant devient un ordre bien fondé, et la récurrence une induction structurelle — ce qui permettra de prouver des fonctions sur les arbres et sur les formules logiques exactement comme on vient de le faire sur les entiers.