backend / algorithmes-structures-donnees
Récursivité
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ère | Récursion | Itération |
|---|---|---|
| Lisibilité sur un problème "diviser pour régner" | Souvent plus naturelle | Demande parfois une pile explicite |
| Mémoire utilisée | O(profondeur) sur la pile d'appels | O(1) dans la plupart des cas |
| Limite en Python | ~1000 appels imbriqués par défaut | Aucune limite structurelle |
| Risque principal | RecursionError sur une entrée profonde | Boucle 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é
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_elementRé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
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.
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.