Cours 1 · RécursivitéLeçon 1 sur 2
Principe et mécanisme de la récursivité
7 h de lecture9 sections Version PDF
Cas de base et cas récursif, déroulé de la pile d'exécution, preuve de terminaison ; récursivité simple, multiple, croisée et terminale ; coût mémoire des appels.
Écrivez, avec les seuls outils d'Algorithmique 1, un algorithme qui affiche tous les fichiers d'un répertoire et de ses sous-répertoires. Une boucle parcourt le répertoire. Une boucle imbriquée parcourt chaque sous-répertoire. Une troisième les sous-sous-répertoires. Et il en faut une de plus dès qu'un utilisateur crée un dossier un cran plus bas.
Le problème n'est pas la difficulté : c'est que la profondeur n'est pas connue à l'écriture. Aucun nombre fixe de boucles imbriquées ne convient. Il manque un outil, et c'est celui de ce chapitre.
Prévenons tout de suite : la récursivité est le point qui coince de ce semestre, sans concurrence. Elle ne résiste pas parce qu'elle est compliquée — sa définition tient en deux lignes — mais parce qu'elle demande de suivre un mécanisme, l'empilement des appels, que le code ne montre pas. Tant que ce mécanisme n'est pas visualisé, le bloc II restera opaque. D'où le poids de ce chapitre, et l'insistance sur le déroulé pas à pas.
Deux clauses, et pas une de plus
Une fonction récursive est une fonction qui s'appelle elle-même. Pour qu'elle termine, elle doit comporter exactement deux sortes de clauses.
Le cas de base rend un résultat sans appel récursif. C'est la seule chose qui arrête la descente, et c'est la première ligne à écrire.
Le cas récursif se ramène au même problème sur une entrée strictement plus proche du cas de base, et combine le résultat obtenu.
fonction fact(n) si n ≤ 1 alors ← cas de base retourner 1 sinon ← cas récursif retourner n × fact(n − 1)Ce qui rend cette écriture légitime n'est pas une astuce mais une définition mathématique. se définit par et ; le code ne fait que transcrire la définition. C'est la vraie force de la récursivité : quand un objet se définit récursivement, l'algorithme qui le traite s'écrit presque sans réfléchir.
Et beaucoup d'objets se définissent ainsi. Une liste est vide, ou bien un élément suivi d'une liste. Un arbre est une feuille, ou bien un nœud portant des sous-arbres. Un répertoire contient des fichiers et des répertoires. Les chapitres 5, 7 et 9 exploiteront tous ce fait ; l'introduction de ce chapitre en était déjà un cas.
La pile d'exécution
Voici le mécanisme, et c'est sur lui qu'il faut passer du temps.
Chaque appel de fonction — récursif ou non — crée un cadre d'appel (stack frame) sur la pile d'exécution : une zone contenant les paramètres, les variables locales et l'adresse de retour, c'est-à-dire l'endroit exact où reprendre. C'est la région « pile » de l'image mémoire décrite au chapitre 3 du cours de systèmes, et elle croît vers le bas.
Le point que la lecture du code ne montre pas : dans retourner n × fact(n − 1), la
multiplication ne peut pas être faite au moment de l'appel. Son second facteur n'existe pas
encore. L'appel courant est donc suspendu au milieu de son calcul, son cadre reste en place, et
un nouveau cadre s'empile par-dessus.
Déroulons fact(4) en entier.
Un cadre est empilé : il contient n = 4 et l'endroit où revenir. Le résultat est noté « ? » — il n'existe pas encore, et il n'existera qu'au retour.
Trois observations à tirer de ce déroulé, et ce sont elles qui débloquent tout le reste.
Il y a quatre variables n en mémoire simultanément, valant 4, 3, 2 et 1. Chaque appel a les siennes. C'est le contresens le plus fréquent : croire qu'il n'existe qu'un n qui changerait de valeur.
Rien ne se calcule à la descente. Les quatre multiplications ont lieu à la remontée, dans l'ordre inverse des appels : , puis , puis . Un algorithme récursif fait souvent son travail au retour, pas à l'aller.
Le dernier empilé est le premier dépilé. La pile est une structure LIFO, celle du chapitre 6 — et ce n'est pas une coïncidence de vocabulaire, c'est le même objet.
Dans retourner n × fact(n − 1), à quel moment la multiplication est-elle effectuée ?
Prouver que ça s'arrête
Une fonction récursive mal écrite ne boucle pas indéfiniment : elle épuise la pile et le
programme est tué. Sous Linux, le message est Segmentation fault, et il est trompeur — il
suggère un problème de mémoire alors que c'est un problème de terminaison.
La preuve d'arrêt suit toujours le même schéma, et il faut prendre l'habitude de l'écrire.
On exhibe une mesure — un variant — qui est un entier positif ou nul, et l'on montre deux choses : cette mesure décroît strictement à chaque appel récursif, et le cas de base est atteint lorsqu'elle ne peut plus décroître. Une suite d'entiers positifs strictement décroissante étant finie, la descente s'arrête.
Pour fact(n), le variant est lui-même : il passe de à , et le cas de base
répond dès .
Trois fautes classiques se détectent avec ce schéma en main, et il vaut mieux les connaître par leur nom.
Le cas de base absent. fact sans le test s'appellerait indéfiniment.
Le cas de base inatteignable. fact avec si n = 0 au lieu de si n ≤ 1 fonctionne pour
les entiers positifs et part à l'infini pour fact(-3), puisque le variant décroît sans jamais
franchir 0. Le cas de base doit couvrir toutes les entrées où l'on cesse de descendre, pas
seulement celles auxquelles on pense.
Le variant qui ne décroît pas. Appeler fact(n) au lieu de fact(n − 1), ou récurser sur
une liste dont on n'a pas retiré la tête, produit exactement la même boucle.
Quatre formes de récursivité
Simple : un seul appel récursif par exécution du cas récursif. C'est fact. La pile croît
linéairement, et le déroulé est une simple descente puis une remontée.
Multiple : plusieurs appels. C'est Fibonacci — fib(n) = fib(n−1) + fib(n−2) — et les tris
du chapitre 3. Le déroulé n'est plus une ligne mais un arbre d'appels, ce qui change
radicalement le coût : le chapitre suivant montrera que Fibonacci naïf recalcule des milliers
de fois les mêmes valeurs.
Croisée (ou mutuelle) : deux fonctions s'appellent l'une l'autre. L'exemple d'école est le
couple pair / impair, où pair(n) rend impair(n − 1). Le raisonnement de terminaison est
inchangé — il faut simplement un variant qui décroisse sur le cycle complet.
Terminale : l'appel récursif est la toute dernière opération, sans aucun calcul en attente après lui.
fonction factAcc(n, acc) ← version terminale si n ≤ 1 alors retourner acc sinon retourner factAcc(n − 1, n × acc) ← rien après l'appelLa différence est capitale. Dans la version classique, le cadre doit survivre pour effectuer la
multiplication au retour. Ici, l'appelant n'a plus rien à faire : la multiplication a déjà
eu lieu, son résultat voyage dans l'accumulateur. Le cadre peut donc être réutilisé au lieu
d'être empilé, ce qui ramène la consommation mémoire à une constante. C'est
l'optimisation d'appel terminal, que les compilateurs de langages fonctionnels garantissent
et que gcc applique souvent en -O2 — sans le promettre, ce que le cours de programmation
rappellera.
Ce que la récursion coûte
Il faut être précis sur ce point, parce que c'est là qu'on décide de l'employer ou non.
En temps, un appel de fonction coûte quelques nanosecondes : empiler le cadre, sauvegarder des registres, brancher, revenir. Négligeable une fois, sensible sur des millions d'appels.
En espace, la récursion coûte un cadre par niveau de profondeur. Une récursion de
profondeur occupe de pile — pour fact(4), quatre cadres ; pour fact(100000),
cent mille, et la pile déborde. C'est la vraie limite, et elle est brutale : la pile d'un fil
d'exécution fait typiquement 8 Mio, ce qui plafonne la profondeur autour de quelques dizaines
ou centaines de milliers d'appels.
D'où la règle de choix qui vaudra pour tout le semestre : la récursion est excellente quand la profondeur est logarithmique — diviser pour régner, parcours d'arbre équilibré — et dangereuse quand elle est linéaire en la taille des données. Parcourir un tableau d'un million d'éléments récursivement est une faute ; le trier récursivement ne descend qu'à une profondeur de vingt.
Une fonction récursive écrite pour des entiers positifs plante par débordement de pile quand on lui passe −3, alors qu'elle fonctionne parfaitement de 1 à 1000. Quel est le diagnostic ?
À vous
L'exercice matérialise ce que l'animation a montré. Vous écrivez d'abord deux fonctions récursives simples, puis vous les réécrivez avec une pile explicite — un tableau que vous empilez et dépilez à la main.
C'est l'exercice qui débloque le plus d'étudiants, pour une raison précise : il rend visible ce que la machine faisait de toute façon. Une fois qu'on a écrit soi-même le tableau des cadres en attente, la version récursive cesse d'être magique.
Le squelette instrumente aussi la profondeur maximale atteinte, et l'un des cas de test est volontairement mal terminé : vous verrez la garde se déclencher avant le débordement réel.
Écrivez deux récursions, refaites-en une avec une pile explicite, et mesurez la profondeur.
// Un compteur de profondeur, pour voir la pile monter. let profondeur = 0, maximum = 0, appels = 0; function entrer() { appels++; profondeur++; maximum = Math.max(maximum, profondeur); } function sortir(v) { profondeur--; return v; } function remettreAZero() { profondeur = 0; maximum = 0; appels = 0; } // ── 1. Récursion simple ─────────────────────────────────────────────────── function fact(n) { entrer(); if (n <= 1) return sortir(1); return sortir(n * fact(n - 1)); } // La somme des chiffres d'un nombre : 1234 -> 1 + 2 + 3 + 4. function sommeChiffres(n) { entrer(); return sortir(0); // ← à écrire : cas de base n < 10, sinon n % 10 + somme(n / 10) } // ── 2. La MÊME chose avec une pile explicite ────────────────────────────── // On empile les valeurs de n à la descente, on dépile en multipliant. function factParPile(n) { const pile = []; // Descente : on n'a rien à calculer, on ne fait qu'empiler. for (let k = n; k > 1; k--) pile.push(k); // Remontée : on dépile dans l'ordre INVERSE, comme le fait la machine. let resultat = 1; while (pile.length > 0) resultat = resultat * pile.pop(); return resultat; } // ── 3. Une descente mal terminée ────────────────────────────────────────── // Le cas de base teste une ÉGALITÉ : il est franchi par 3, 2, 1, 0 mais // jamais par -3. Une garde évite ici le vrai débordement de pile. function factFragile(n, garde = 0) { if (garde > 200) throw new Error("descente infinie : cas de base jamais atteint"); if (n === 0) return 1; return n * factFragile(n - 1, garde + 1); } // ── À VOUS ──────────────────────────────────────────────────────────────── // 1. Écrivez sommeChiffres, et vérifiez la profondeur atteinte. // 2. Écrivez hanoi(n, de, vers, via) : affichez les déplacements, et // comptez-les. Combien pour n = 10 ? Quelle est la profondeur maximale ? // 3. Corrigez factFragile pour qu'elle refuse proprement une entrée négative. remettreAZero(); console.log("fact(6) =", fact(6), "| profondeur max", maximum, "| appels", appels); console.log("factParPile(6) =", factParPile(6)); remettreAZero(); console.log("sommeChiffres(1234) =", sommeChiffres(1234), "| profondeur max", maximum); try { factFragile(-3); } catch (e) { console.log("factFragile(-3) :", e.message); }
En travaux pratiques
Voir la pile
Rendre visible ce qu'un appel récursif fait réellement, en traçant la pile à la main puis en la faisant déborder — parce que la récursivité ne s'apprend pas en la croyant sur parole.
- Le TP 4 de Programmation : appels de fonction et pile
- Un compilateur C et gdb
- 1. Tracer à la main
Écrivez la factorielle récursive. Pour n = 5, dessinez la pile complète : un cadre par appel, avec la valeur de n. Marquez la descente, puis la remontée avec les valeurs rendues.
- 2. Vérifier la trace
Ajoutez un affichage à l'entrée et à la sortie de la fonction, avec une indentation proportionnelle à la profondeur. Comparez à votre dessin.
- 3. Le cas de base oublié
Retirez le cas de base et exécutez. Notez le message, puis lisez la pile sous gdb : combien de cadres avant l'arrêt ?
- 4. La récursion double
Écrivez Fibonacci récursif naïf, et faites-lui compter ses propres appels. Relevez le nombre d'appels pour n = 10, 20, 30, 35. Tracez la courbe.
- 5. Dessiner l'arbre
Pour n = 5, dessinez l'arbre des appels de Fibonacci. Entourez les sous-arbres identiques calculés plusieurs fois, et comptez les redondances.
- 6. Les tours de Hanoï
Écrivez la résolution récursive, affichez les déplacements pour trois disques, et comptez-les pour n de 1 à 20. Trouvez la formule.
- 7. Trouver la limite
Écrivez une récursion linéaire simple et cherchez la profondeur maximale avant le plantage. Modifiez la taille de pile autorisée et refaites la mesure.
- 8. La récursivité terminale
Réécrivez la factorielle avec un accumulateur, de sorte que l'appel récursif soit la dernière opération. Compilez avec et sans optimisation, et cherchez jusqu'où chaque version tient.
- Votre trace affichée correspond exactement à votre dessin
- Vous savez dire combien d'appels fait Fibonacci pour n = 30, à l'ordre de grandeur
- Vous mesurez la profondeur maximale de votre machine et vous la reliez à ulimit
- Votre version terminale tient beaucoup plus profond en -O2, et vous savez pourquoi
Ce que la suite en fait
Le chapitre 2 met la récursion en balance avec l'itération : quand elle clarifie, quand elle coûte, et comment passer de l'une à l'autre. Il ouvrira surtout le dossier Fibonacci, dont l'arbre d'appels justifie à lui seul le chapitre 4 et la programmation dynamique du chapitre 10.
Tout le bloc II en dépend directement. Le tri fusion et le tri rapide sont deux récursions multiples, et leur analyse consiste à compter les niveaux de l'arbre d'appels que vous venez de dérouler. Enfin, la pile de ce chapitre reviendra comme structure de données au chapitre 6 : vous l'aurez alors rencontrée deux fois, comme mécanisme puis comme objet.
À retenir
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.