Adloun

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

Définition 5.1Fonction 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.

Définition 5.2Cas de base et cas récursif

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.
Exemple 5.3La factorielle

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

Définition 5.4Pile d'appels

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).

Proposition 5.5Empilement des appels récursifs

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.

Exemple 5.6Pile pour `factorielle(3)`

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

Définition 5.7Variant de 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.

◆Théorème 5.8Terminaison

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.

Exemple 5.9Terminaison de la factorielle

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.

Proposition 5.10Preuve de correction par récurrence

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

Exemple 5.11Somme récursive

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

Exemple 5.12Fibonacci, récursivité multiple

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ï

Exemple 5.13Tours 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 depart vers intermediaire ;
  • déplacer le plus grand disque de depart vers arrivee ;
  • déplacer les disques de intermediaire vers arrivee.

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

Exemple 5.14Rendu de monnaie : nombre minimal de pièces

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

Proposition 5.15Équivalence récursivité / itération

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.

Exemple 5.16Factorielle itérative

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é

Définition 5.17Profondeur de récursion

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.

Proposition 5.18Limite de la pile en Python

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.

◆Théorème 5.19Coût de Fibonacci naïf

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 à .

Exemple 5.20Mémoïsation de Fibonacci

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

Continuer sur Adloun : animation, QCM, fiches, exercices