La récursivité
Cours complet · NSI (terminale), chapitre 5 · terminale, spécialité numérique et sciences informatiques
Travailler ce chapitre sur Adloun Exercices corrigés de ce chapitre
La récursivité est une technique de programmation fondamentale dans laquelle une fonction s'appelle elle-même pour résoudre un problème. Elle constitue souvent une traduction directe et élégante de définitions mathématiques ou de raisonnements par récurrence. Pour de nombreux problèmes (parcours d'arbres, algorithmes « diviser pour régner », combinatoire), une solution récursive est plus lisible et plus naturelle qu'une solution itérative.
Dans ce chapitre, nous étudions le principe de la récursivité (cas de base et cas récursif), le mécanisme d'exécution à travers la pile d'appels, la question essentielle de la terminaison, ainsi que de nombreux exemples classiques. Nous comparerons enfin l'approche récursive à l'approche itérative et discuterons de ses coûts et de ses limites.
5.1 Principe de la récursivité
5.1.1 Définition et structure d'une fonction récursive
Une fonction est dite récursive lorsqu'elle s'appelle elle-même, directement ou indirectement, au cours de son exécution. La résolution d'un problème est alors ramenée à la résolution d'une ou plusieurs instances plus petites du même problème.
Une fonction récursive correcte est construite autour de deux éléments :
- le cas de base : une (ou plusieurs) situation(s) suffisamment simple(s) pour être résolue(s) directement, sans nouvel appel récursif. Le cas de base arrête la récursion.
- le cas récursif : la fonction se rappelle elle-même sur une instance plus petite du problème, en se rapprochant du cas de base, puis combine le résultat obtenu.
Méthode : Construire une fonction récursive
Pour écrire une fonction récursive, on procède en trois étapes :
- Identifier le cas de base : la plus petite instance, dont la réponse est immédiate.
- Exprimer la solution du problème en fonction de la solution d'une instance plus petite (le cas récursif).
- Vérifier que tout appel récursif se rapproche du cas de base, garantissant ainsi la terminaison.
La factorielle se définit mathématiquement par et pour . Cette définition se traduit directement en code récursif :
def factorielle(n):
if n == 0: # cas de base
return 1
else: # cas recursif
return n * factorielle(n - 1)
print(factorielle(5)) # affiche 120
Le cas de base est , le cas récursif appelle factorielle(n - 1), dont l'argument est strictement plus petit.
5.2 La pile d'appels
5.2.1 Mécanisme d'exécution
La pile d'appels (en anglais call stack) est la structure de données qui mémorise, au cours de l'exécution d'un programme, les appels de fonctions en cours. À chaque appel, un enregistrement d'activation (paramètres, variables locales, point de retour) est empilé ; à chaque retour de fonction, il est dépilé. Elle fonctionne selon le principe LIFO (Last In, First Out).
Lors d'un appel récursif, chaque appel non encore terminé reste dans la pile en attendant le résultat de l'appel qu'il a déclenché. La profondeur maximale atteinte par la pile correspond au nombre maximal d'appels imbriqués simultanément actifs.
L'évaluation de factorielle(3) provoque l'empilement suivant. On empile d'abord les appels, puis on les dépile en remontant les résultats :
factorielle(3)
= 3 * factorielle(2)
= 3 * (2 * factorielle(1))
= 3 * (2 * (1 * factorielle(0)))
= 3 * (2 * (1 * 1)) # factorielle(0) renvoie 1 (cas de base)
= 3 * (2 * 1)
= 3 * 2
= 6
À la profondeur maximale, quatre appels (factorielle(3), (2), (1) et (0)) coexistent dans la pile.
5.3 Terminaison et preuve de correction
5.3.1 Garantir l'arrêt de la récursion
Un variant est une quantité, dépendant des arguments de la fonction, qui prend des valeurs entières positives et qui décroît strictement à chaque appel récursif. Son existence assure que le cas de base est atteint en un nombre fini d'étapes.
Si à chaque appel récursif on peut associer un variant entier positif qui décroît strictement, et si le ou les cas de base sont atteints lorsque le variant ne peut plus décroître, alors la fonction termine pour toute entrée valide.
Pour factorielle(n) avec , le variant est l'entier lui-même. À chaque appel, l'argument passe de à : il décroît strictement et reste positif jusqu'à atteindre , qui est le cas de base. La fonction termine donc pour tout .
Attention : si on appelle factorielle(-1), le cas de base n'est jamais atteint et la fonction provoque un dépassement de pile. Il est donc essentiel de vérifier le domaine de validité des arguments.
La correction d'une fonction récursive se démontre par récurrence sur le variant :
- Initialisation : on vérifie que la fonction renvoie le bon résultat pour le ou les cas de base.
- Hérédité : on suppose que les appels récursifs (sur des instances plus petites) renvoient le résultat correct, et on en déduit que la combinaison effectuée dans le cas récursif donne le résultat attendu.
5.4 Exemples classiques
5.4.1 Somme des premiers entiers
La somme vérifie et .
def somme(n):
if n == 0:
return 0
return n + somme(n - 1)
print(somme(100)) # affiche 5050
5.4.2 La suite de Fibonacci
La suite de Fibonacci est définie par , et pour . Ici chaque appel en déclenche deux : on parle de récursivité multiple (ou arborescente).
def fibonacci(n):
if n < 2: # cas de base : F(0)=0, F(1)=1
return n
return fibonacci(n - 1) + fibonacci(n - 2)
print(fibonacci(10)) # affiche 55
Cette version est très lisible mais inefficace : de nombreux sous-problèmes identiques sont recalculés (voir la section sur le coût).
5.4.3 Les tours de Hanoï
Le problème des tours de Hanoï consiste à déplacer une tour de disques d'un piquet depart vers un piquet arrivee, à l'aide d'un piquet intermediaire, sans jamais poser un disque sur un disque plus petit. La solution récursive est remarquable :
- déplacer les disques supérieurs de
departversintermediaire; - déplacer le plus grand disque de
departversarrivee; - déplacer les disques de
intermediaireversarrivee.
def hanoi(n, depart, arrivee, intermediaire):
if n == 0:
return
hanoi(n - 1, depart, intermediaire, arrivee)
print("Deplacer disque", n, "de", depart, "vers", arrivee)
hanoi(n - 1, intermediaire, arrivee, depart)
hanoi(3, "A", "C", "B")
Le nombre de déplacements nécessaires pour disques est .
5.4.4 Le rendu de monnaie
On cherche le nombre minimal de pièces nécessaires pour rendre une somme donnée à partir d'un système de valeurs. Une formulation récursive essaie chaque pièce et conserve le meilleur résultat :
def rendu(somme, pieces):
if somme == 0:
return 0
meilleur = float("inf")
for p in pieces:
if p <= somme:
essai = rendu(somme - p, pieces)
if essai + 1 < meilleur:
meilleur = essai + 1
return meilleur
print(rendu(11, [1, 2, 5])) # affiche 3 (5 + 5 + 1)
Cette version exhaustive donne toujours la solution optimale, mais son coût croît rapidement avec la somme à rendre.
5.5 Récursivité et itération
5.5.1 Deux approches équivalentes
Tout calcul exprimable de façon récursive peut l'être de façon itérative (à l'aide de boucles), et réciproquement. Le choix entre les deux relève de la lisibilité, de l'efficacité et de la nature du problème.
La même factorielle écrite avec une boucle, sans pile d'appels supplémentaire :
def factorielle_iter(n):
resultat = 1
for k in range(2, n + 1):
resultat = resultat * k
return resultat
print(factorielle_iter(5)) # affiche 120
Méthode : Choisir entre récursivité et itération
- Privilégier la récursivité lorsque la structure du problème est elle-même récursive (arbres, « diviser pour régner », définitions inductives) : le code est plus court et plus clair.
- Privilégier l'itération lorsque le problème est simple et linéaire, ou lorsque la profondeur de récursion risque d'être grande, afin d'éviter le coût mémoire de la pile.
5.6 Coût et limites
5.6.1 Profondeur de récursion et complexité
La profondeur de récursion est le nombre maximal d'appels récursifs imbriqués actifs simultanément. Elle détermine la taille maximale de la pile d'appels, donc la mémoire consommée.
La taille de la pile d'appels est bornée. En Python, une profondeur excessive provoque l'erreur RecursionError: maximum recursion depth exceeded (limite par défaut de l'ordre de 1000). Une récursion trop profonde ou infinie épuise la pile.
La version récursive naïve de Fibonacci a une complexité temporelle exponentielle, en où , car le même sous-problème est recalculé un grand nombre de fois. La version itérative (ou mémoïsée) ramène ce coût à .
En mémorisant les valeurs déjà calculées, on évite les recalculs et le coût devient linéaire :
def fibonacci_memo(n, memo=None):
if memo is None:
memo = {}
if n < 2:
return n
if n not in memo:
memo[n] = fibonacci_memo(n - 1, memo) + fibonacci_memo(n - 2, memo)
return memo[n]
print(fibonacci_memo(50)) # rapide : 12586269025