Retour au cours

backend / algorithmes-structures-donnees

Récursivité

Leçon 41 exercice

Explication

Ce que vous allez apprendre

  • Identifier les deux ingrédients obligatoires de toute fonction récursive : cas de base et cas récursif
  • Justifier pourquoi une récursion fonctionne grâce au raisonnement par induction, sans avoir à "dérouler" tous les appels mentalement
  • Expliquer pourquoi Python n'optimise pas la récursion terminale et ce que cela implique en mémoire
  • Convertir une fonction récursive profonde en version itérative avec une pile explicite quand c'est nécessaire
  • Repérer à l'avance les signes qu'une récursion va dépasser la limite par défaut de Python

Dans quel contexte ?

Un développeur teste une fonction récursive qui additionne les éléments d'une liste, et elle fonctionne parfaitement sur ses listes de test de 20 éléments. En production, elle reçoit un lot de 5 000 factures à additionner et plante avec RecursionError: maximum recursion depth exceeded. Comprendre que chaque appel récursif consomme un cadre sur la pile d'appels, limité à environ 1000 par défaut en Python, permet de diagnostiquer le problème immédiatement et de choisir entre sys.setrecursionlimit, une réécriture itérative, ou une structure "diviser pour régner" qui limite la profondeur à O(log n).

D'abord, une idée qui semble impossible

La récursivité, c'est accepter qu'une fonction puisse résoudre un problème en s'appuyant sur sa propre capacité à résoudre une version plus petite du même problème. Comment une fonction peut-elle "s'appeler elle-même" sans partir dans une boucle infinie ?

Les deux ingrédients obligatoires

La réponse tient en deux éléments. Il faut un cas de base, qui arrête la récursion sans appel supplémentaire. Et il faut un cas récursif, qui réduit strictement le problème à chaque appel, en le rapprochant du cas de base.

Piège fréquent

Une fonction récursive sans cas de base — ou dont le cas récursif ne se rapproche jamais du cas de base — atteint toujours la limite de récursion de Python et lève une RecursionError. C'est l'équivalent récursif d'une boucle while True sans condition d'arrêt.

Pourquoi ça marche vraiment : l'induction

Si on sait résoudre le cas de base, et qu'on sait transformer une solution du problème "n-1" en solution du problème "n", alors on sait résoudre n'importe quelle taille, exactement comme l'induction mathématique. Il n'est jamais nécessaire de "dérouler" mentalement tous les appels : il suffit de faire confiance à l'hypothèse que l'appel récursif fait correctement son travail sur une instance plus petite.

Astuce

Pour vérifier qu'une récursion progresse bien vers son cas de base, demandez-vous : « est-ce que l'argument passé à l'appel récursif est strictement plus proche du cas de base qu'avant ? » Si la réponse n'est pas évidente, la fonction risque de boucler indéfiniment.

Un coût réel à connaître en Python

Contrairement à certains langages, Python ne pratique pas l'optimisation de la récursion terminale : chaque appel récursif consomme un vrai cadre sur la pile d'appels, limitée par défaut à environ 1000 niveaux.

CritèreRécursionItération
Lisibilité sur un problème "diviser pour régner"Souvent plus naturelleDemande parfois une pile explicite
Mémoire utiliséeO(profondeur) sur la pile d'appelsO(1) dans la plupart des cas
Limite en Python~1000 appels imbriqués par défautAucune limite structurelle
Risque principalRecursionError sur une entrée profondeBoucle infinie si la condition d'arrêt est mal posée

La conséquence pratique

La récursivité n'est donc pas gratuite en mémoire, et une récursion très profonde doit parfois être réécrite en itératif avec une pile explicite, plutôt que de simplement augmenter la limite de récursion.

Vers la suite

La récursivité est le langage naturel du "diviser pour régner" et du backtracking, deux familles de techniques qui reviendront tout au long de ce cours. Comprendre profondément ce mécanisme conditionne la compréhension de la moitié des algorithmes qui suivent, à commencer par la recherche binaire, dans la prochaine leçon.

Commandes & code

Récursivité

python
import sys

# Anatomie d'une fonction récursive : cas de base + cas récursif
def factorielle(n: int) -> int:
    if n <= 1:            # cas de base : arrête la récursion
        return 1
    return n * factorielle(n - 1)   # cas récursif : réduit le problème

# Récursivité sur une structure de données (liste chaînée, arbre) plutôt qu'un compteur
def somme_liste(liste: list) -> int:
    if not liste:
        return 0
    return liste[0] + somme_liste(liste[1:])   # attention : liste[1:] copie -> O(n) par appel, O(n^2) total

def somme_liste_efficace(liste: list, index: int = 0) -> int:
    if index == len(liste):
        return 0
    return liste[index] + somme_liste_efficace(liste, index + 1)   # O(n) total, pas de copie

# Récursion multiple : Fibonacci naïf (arbre d'appels exponentiel)
def fib(n: int) -> int:
    if n <= 1:
        return n
    return fib(n - 1) + fib(n - 2)   # deux appels récursifs par niveau -> O(2^n)

# Récursion terminale (tail recursion) : Python ne l'optimise PAS, mais le pattern reste utile
def factorielle_terminale(n: int, accumulateur: int = 1) -> int:
    if n <= 1:
        return accumulateur
    return factorielle_terminale(n - 1, n * accumulateur)   # l'appel récursif est la DERNIÈRE opération

# Limite de récursion en Python (pas de tail-call optimization -> pile d'appels réelle)
print(sys.getrecursionlimit())   # 1000 par défaut
# sys.setrecursionlimit(5000)   # à utiliser avec prudence : risque de crash (stack overflow) natif

# Conversion récursif -> itératif avec une pile explicite (évite la limite de récursion)
def factorielle_iterative(n: int) -> int:
    resultat = 1
    for i in range(2, n + 1):
        resultat *= i
    return resultat

# Récursion mutuelle : deux fonctions qui s'appellent l'une l'autre
def est_pair(n: int) -> bool:
    if n == 0:
        return True
    return est_impair(n - 1)

def est_impair(n: int) -> bool:
    if n == 0:
        return False
    return est_pair(n - 1)

# Diviser pour régner : la tour de Hanoï, illustration classique de récursivité
def hanoi(n: int, source: str, auxiliaire: str, destination: str, mouvements: list | None = None) -> list[str]:
    if mouvements is None:
        mouvements = []
    if n == 1:
        mouvements.append(f"Déplacer disque 1 de {source} vers {destination}")
        return mouvements
    hanoi(n - 1, source, destination, auxiliaire, mouvements)   # déplace n-1 disques hors du chemin
    mouvements.append(f"Déplacer disque {n} de {source} vers {destination}")
    hanoi(n - 1, auxiliaire, source, destination, mouvements)   # replace les n-1 disques sur la destination
    return mouvements

assert len(hanoi(3, "A", "B", "C")) == 2**3 - 1   # 2^n - 1 mouvements nécessaires

# Backtracking simple par récursion : générer tous les sous-ensembles d'un tableau
def sous_ensembles(tableau: list, index: int = 0, courant: list | None = None) -> list[list]:
    if courant is None:
        courant = []
    if index == len(tableau):
        return [courant[:]]   # copie car "courant" sera modifié par les appels suivants
    sans_element = sous_ensembles(tableau, index + 1, courant)
    courant.append(tableau[index])
    avec_element = sous_ensembles(tableau, index + 1, courant)
    courant.pop()   # backtrack : retire l'élément avant de remonter
    return sans_element + avec_element

Résumé

  • Toute fonction récursive a besoin d'un cas de base (arrêt) et d'un cas récursif qui réduit le problème.
  • Python n'optimise pas la récursion terminale : une récursion profonde consomme réellement la pile d'appels (limite ~1000).
  • Slicer une liste (liste[1:]) dans un appel récursif coûte O(n) par appel : préférer un index explicite.
  • Le pattern "diviser pour régner" (Hanoï, tri fusion) et le "backtracking" (sous-ensembles) s'expriment naturellement en récursif.

Exercices pratiques

1 disponible
1

Mission : sauver la facturation qui plante avec RecursionError

Objectif : Diagnostiquer un crash de récursion profonde en production et corriger la fonction pour qu'elle passe à l'échelle.

Contexte

La fonction somme_liste ci-dessous fonctionne parfaitement sur les listes de test de 20 factures, mais plante en production avec RecursionError: maximum recursion depth exceeded dès qu'un lot dépasse environ 1000 factures.

python
def somme_liste(liste: list) -> int:
    if not liste:
        return 0
    return liste[0] + somme_liste(liste[1:])

Tu dois comprendre la cause exacte du crash, proposer une correction, puis en évaluer les limites.

Résoudre l’exercice →