Récursivité#
Principe de la récursivité#
La récursivité est une technique de conception d’algorithmes dans laquelle une fonction résout un problème en se rappelant elle-même sur des instances plus petites du même problème. C’est l’application directe du principe de diviser pour régner : si l’on peut exprimer la solution d’un problème en fonction de solutions à des sous-problèmes de même nature, alors la récursivité offre une formulation naturelle et élégante.
La récursivité repose sur deux composantes obligatoires.
Définition 19 (Structure d’une fonction récursive)
Toute fonction récursive correcte doit comporter :
Un ou plusieurs cas de base (base cases) : des instances du problème si petites qu’on peut les résoudre directement, sans appel récursif. Sans cas de base, la récursion est infinie.
Un ou plusieurs cas récursifs (recursive cases) : des cas où la fonction s’appelle elle-même sur une instance strictement plus petite, se rapprochant ainsi du cas de base. La décroissance de la taille garantit la terminaison.
Remarque 6
Le lien entre récursivité et induction mathématique est profond. Une preuve par récurrence prouve une propriété \(P(n)\) en montrant :
Base : \(P(0)\) est vraie.
Hérédité : \(P(n-1)\) vraie implique \(P(n)\) vraie.
Une fonction récursive calcule \(f(n)\) en supposant que \(f(n-1)\) est déjà calculé correctement. Prouver la correction d’une fonction récursive revient exactement à faire une récurrence : on suppose que les appels récursifs retournent la bonne valeur (hypothèse d’induction), et on montre que l’appel courant retourne la bonne valeur.
La pile d’appels#
Pour comprendre comment s’exécute une fonction récursive, il faut comprendre la pile d’appels (call stack).
Définition 20 (Pile d’appels)
La pile d’appels est une structure de données en mémoire qui mémorise les appels de fonctions en cours d’exécution. Chaque appel de fonction crée un cadre de pile (stack frame) contenant :
les paramètres de la fonction,
les variables locales,
l’adresse de retour (où reprendre l’exécution après retour).
Lors d’un appel récursif, un nouveau cadre est empilé par-dessus le cadre courant. Lorsqu’une fonction retourne, son cadre est dépilé et l’exécution reprend dans le cadre précédent.
La conséquence directe est que chaque niveau de récursion occupe de l’espace en mémoire. Pour une récursion de profondeur \(d\), la pile occupe \(O(d)\) cadres simultanément. En Python, la profondeur maximale est limitée par défaut.
import sys
print(f"Limite de récursion par défaut : {sys.getrecursionlimit()}")
# On peut l'augmenter, mais avec prudence :
# sys.setrecursionlimit(10_000)
def profondeur_de_recursion_actuelle():
"""Renvoie une estimation de la profondeur de récursion actuelle."""
frame = sys._getframe()
depth = 0
while frame is not None:
frame = frame.f_back
depth += 1
return depth
print(f"Profondeur de pile actuelle : {profondeur_de_recursion_actuelle()}")
Limite de récursion par défaut : 3000
Profondeur de pile actuelle : 23
Exemples classiques#
Factorielle#
La factorielle est l’exemple canonique de la récursivité. Sa définition mathématique est elle-même récursive.
Définition 21 (Factorielle)
La factorielle de \(n \in \mathbb{N}\) est définie par :
Cette définition a exactement la forme d’une fonction récursive : un cas de base (\(0! = 1\)) et un cas récursif (\(n! = n \cdot (n-1)!\)).
def factorielle_recursive(n: int) -> int:
"""
Factorielle récursive.
Complexité : O(n) en temps, O(n) en espace (pile d'appels de profondeur n).
"""
if n < 0:
raise ValueError("La factorielle n'est définie que pour les entiers positifs.")
if n == 0: # cas de base
return 1
return n * factorielle_recursive(n - 1) # cas récursif
def factorielle_iterative(n: int) -> int:
"""
Factorielle itérative.
Complexité : O(n) en temps, O(1) en espace.
"""
if n < 0:
raise ValueError("La factorielle n'est définie que pour les entiers positifs.")
resultat = 1
for i in range(2, n + 1):
resultat *= i
return resultat
# Tests
for n in range(10):
r = factorielle_recursive(n)
i = factorielle_iterative(n)
assert r == i, f"Divergence pour n={n}: {r} vs {i}"
print(f"{n}! = {r}")
0! = 1
1! = 1
2! = 2
3! = 6
4! = 24
5! = 120
6! = 720
7! = 5040
8! = 40320
9! = 362880
Suite de Fibonacci#
La suite de Fibonacci illustre les dangers de la récursivité naïve : une définition mathématique élégante peut donner naissance à un algorithme exponentiellement lent.
Définition 22 (Suite de Fibonacci)
La suite de Fibonacci \((F_n)_{n \geq 0}\) est définie par :
Les premiers termes sont : \(0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, \ldots\)
def fibonacci_naif(n: int) -> int:
"""
Fibonacci naïf : deux appels récursifs par appel.
Complexité : O(2^n) en temps, O(n) en espace (hauteur de la pile).
ATTENTION : inutilisable pour n > 35 environ.
"""
if n <= 1:
return n
return fibonacci_naif(n - 1) + fibonacci_naif(n - 2)
def fibonacci_iteratif(n: int) -> int:
"""
Fibonacci itératif : O(n) en temps, O(1) en espace.
"""
if n <= 1:
return n
a, b = 0, 1
for _ in range(n - 1):
a, b = b, a + b
return b
# Comptage des appels pour fibonacci_naif
appels = [0]
def fibonacci_compte(n: int) -> int:
appels[0] += 1
if n <= 1:
return n
return fibonacci_compte(n - 1) + fibonacci_compte(n - 2)
print(f"{'n':>4} {'F(n)':>10} {'Appels (naïf)':>15} {'Appels théoriques (~2^n)':>25}")
print("-" * 58)
for n in range(1, 20):
appels[0] = 0
val = fibonacci_compte(n)
print(f"{n:>4} {val:>10} {appels[0]:>15} {2**n:>25,}")
n F(n) Appels (naïf) Appels théoriques (~2^n)
----------------------------------------------------------
1 1 1 2
2 1 3 4
3 2 5 8
4 3 9 16
5 5 15 32
6 8 25 64
7 13 41 128
8 21 67 256
9 34 109 512
10 55 177 1,024
11 89 287 2,048
12 144 465 4,096
13 233 753 8,192
14 377 1219 16,384
15 610 1973 32,768
16 987 3193 65,536
17 1597 5167 131,072
18 2584 8361 262,144
19 4181 13529 524,288
Tours de Hanoï#
Les tours de Hanoï constituent l’exemple le plus pur du paradigme récursif : le problème n’a pas de solution itérative simple et naturelle.
Définition 23 (Problème des tours de Hanoï)
Le problème des tours de Hanoï consiste à déplacer \(n\) disques de tailles décroissantes d’une tige source vers une tige destination, en utilisant une tige auxiliaire, selon les règles suivantes :
On ne peut déplacer qu’un seul disque à la fois (le disque du dessus d’une tige).
Un disque ne peut jamais être posé sur un disque plus petit.
La solution récursive est : pour déplacer \(n\) disques de la tige source vers destination via auxiliaire :
Déplacer les \(n-1\) disques supérieurs de
sourceversauxiliaireviadestination.Déplacer le disque du bas de
sourceversdestination.Déplacer les \(n-1\) disques de
auxiliaireversdestinationviasource.
def hanoi(n: int, source: str, destination: str, auxiliaire: str,
mouvements: list | None = None) -> list:
"""
Résout les tours de Hanoï pour n disques.
Retourne la liste des mouvements (source -> destination).
Complexité : O(2^n) mouvements, O(n) en espace (pile).
"""
if mouvements is None:
mouvements = []
if n == 1:
mouvements.append((source, destination))
return mouvements
hanoi(n - 1, source, auxiliaire, destination, mouvements)
mouvements.append((source, destination))
hanoi(n - 1, auxiliaire, destination, source, mouvements)
return mouvements
for n in range(1, 7):
mvts = hanoi(n, 'A', 'C', 'B')
print(f"n={n} : {len(mvts):>3} mouvements (2^n - 1 = {2**n - 1})")
print("\nDétail pour n=3 :")
for i, (src, dst) in enumerate(hanoi(3, 'A', 'C', 'B'), 1):
print(f" Mouvement {i:2} : {src} → {dst}")
n=1 : 1 mouvements (2^n - 1 = 1)
n=2 : 3 mouvements (2^n - 1 = 3)
n=3 : 7 mouvements (2^n - 1 = 7)
n=4 : 15 mouvements (2^n - 1 = 15)
n=5 : 31 mouvements (2^n - 1 = 31)
n=6 : 63 mouvements (2^n - 1 = 63)
Détail pour n=3 :
Mouvement 1 : A → C
Mouvement 2 : A → B
Mouvement 3 : C → B
Mouvement 4 : A → C
Mouvement 5 : B → A
Mouvement 6 : B → C
Mouvement 7 : A → C
Remarque 7
Le nombre minimal de mouvements pour déplacer \(n\) disques est \(2^n - 1\), ce qui se prouve par récurrence. Pour \(n = 64\) disques (la légende des moines), cela fait \(2^{64} - 1 \approx 1,8 \times 10^{19}\) mouvements. À raison d’un mouvement par seconde, cela prendrait environ 585 milliards d’années — soit 42 fois l’âge de l’univers.
Récursion terminale#
Définition 24 (Récursion terminale (tail recursion))
Un appel récursif est dit terminal (tail call) si c’est la dernière opération effectuée par la fonction : la valeur retournée par l’appel récursif est immédiatement retournée sans calcul supplémentaire. Une fonction entièrement structurée sur des appels terminaux est dite récursive terminale (tail recursive).
Exemple 8 (Factorielle récursive terminale)
La factorielle récursive classique n’est pas terminale : après l’appel récursif factorielle(n-1), on effectue encore une multiplication par n. Pour la rendre terminale, on introduit un accumulateur :
def factorielle_terminale(n: int, accumulateur: int = 1) -> int:
if n == 0:
return accumulateur
return factorielle_terminale(n - 1, n * accumulateur) # appel terminal
Ici, l’appel récursif est la dernière chose que fait la fonction. La valeur finale est portée par l’accumulateur.
Remarque 8
En théorie, un compilateur reconnaissant les appels terminaux peut les optimiser en une simple itération (tail call optimization, TCO) : au lieu d’empiler un nouveau cadre, il réutilise le cadre courant. Cela transforme une récursion de profondeur \(O(n)\) en espace en une boucle en espace \(O(1)\).
Cependant, Python n’implémente pas cette optimisation, et ce de façon délibérée. Guido van Rossum a exprimé sa préférence pour que les traces de pile restent lisibles lors du débogage. En Python, une récursion terminale présente les mêmes risques de dépassement de pile qu’une récursion non terminale. Pour les programmes nécessitant une très grande profondeur, il faut toujours préférer la version itérative.
Mémoïsation#
La mémoïsation est la technique qui consiste à mémoriser les résultats déjà calculés pour éviter de les recalculer. Elle transforme un algorithme récursif de complexité exponentielle en algorithme polynomial.
Définition 25 (Mémoïsation)
La mémoïsation (memoization) est une technique d’optimisation qui consiste à stocker dans un cache les résultats des appels de fonctions pour des arguments donnés, de manière à ne pas recalculer un résultat déjà obtenu. La mémoïsation est applicable lorsque la fonction est pure : ses sorties dépendent uniquement de ses entrées (pas d’effets de bord).
from functools import lru_cache, cache
# Mémoïsation manuelle avec un dictionnaire
def fibonacci_memo_manuel(n: int, memo: dict | None = None) -> int:
"""
Fibonacci avec mémoïsation manuelle.
Complexité : O(n) en temps et en espace.
"""
if memo is None:
memo = {}
if n in memo:
return memo[n]
if n <= 1:
return n
memo[n] = fibonacci_memo_manuel(n - 1, memo) + fibonacci_memo_manuel(n - 2, memo)
return memo[n]
# Mémoïsation avec functools.lru_cache
@lru_cache(maxsize=None)
def fibonacci_lru(n: int) -> int:
"""
Fibonacci avec @lru_cache. Syntaxe plus concise.
maxsize=None signifie un cache illimité.
"""
if n <= 1:
return n
return fibonacci_lru(n - 1) + fibonacci_lru(n - 2)
# Mémoïsation avec @cache (Python 3.9+, équivalent à lru_cache(maxsize=None))
@cache
def fibonacci_cache(n: int) -> int:
"""Fibonacci avec @cache (Python 3.9+)."""
if n <= 1:
return n
return fibonacci_cache(n - 1) + fibonacci_cache(n - 2)
# Comparaison
import timeit
n_test = 35
print(f"Calcul de F({n_test}) :")
t_naif = timeit.timeit(lambda: fibonacci_naif(n_test), number=1)
t_memo = timeit.timeit(lambda: fibonacci_memo_manuel(n_test), number=100) / 100
t_lru = timeit.timeit(lambda: fibonacci_lru(n_test), number=100) / 100
print(f" Naïf O(2^n) : {t_naif:.4f} s")
print(f" Mémo manuelle : {t_memo:.8f} s")
print(f" @lru_cache : {t_lru:.8f} s")
print(f" Rapport naïf/mémo : {t_naif/t_memo:.0f}x")
print(f"\nF(50) = {fibonacci_lru(50)}")
print(f"F(100) = {fibonacci_lru(100)}")
print(f"Taille du cache lru : {fibonacci_lru.cache_info()}")
Calcul de F(35) :
Naïf O(2^n) : 2.4540 s
Mémo manuelle : 0.00001205 s
@lru_cache : 0.00000040 s
Rapport naïf/mémo : 203719x
F(50) = 12586269025
F(100) = 354224848179261915075
Taille du cache lru : CacheInfo(hits=199, misses=101, maxsize=None, currsize=101)
Récursion mutuelle#
Définition 26 (Récursion mutuelle)
Deux fonctions \(f\) et \(g\) sont en récursion mutuelle si \(f\) s’appelle \(g\) et \(g\) s’appelle \(f\). Plus généralement, un ensemble de fonctions \(\{f_1, \ldots, f_k\}\) est en récursion mutuelle si leurs graphes d’appel forment un cycle.
def est_pair(n: int) -> bool:
"""
Détermine si n est pair par récursion mutuelle.
Exemple pédagogique — non recommandé en pratique.
"""
if n == 0:
return True
return est_impair(n - 1)
def est_impair(n: int) -> bool:
"""Détermine si n est impair par récursion mutuelle."""
if n == 0:
return False
return est_pair(n - 1)
# Test sur de petits entiers (profondeur de récursion = n)
for n in range(10):
assert est_pair(n) == (n % 2 == 0), f"Erreur pour n={n}"
print("Tests de récursion mutuelle réussis pour n = 0 à 9.")
# Exemple plus sérieux : la suite de Hofstadter (récursion mutuelle non triviale)
@cache
def hofstadter_f(n: int) -> int:
"""Suite de Hofstadter F : F(n) = n - M(F(n-1)) pour n > 0, F(0) = 1."""
if n == 0:
return 1
return n - hofstadter_m(hofstadter_f(n - 1))
@cache
def hofstadter_m(n: int) -> int:
"""Suite de Hofstadter M : M(n) = n - F(M(n-1)) pour n > 0, M(0) = 0."""
if n == 0:
return 0
return n - hofstadter_f(hofstadter_m(n - 1))
print("\nSuite de Hofstadter F(n) :")
print([hofstadter_f(n) for n in range(20)])
print("Suite de Hofstadter M(n) :")
print([hofstadter_m(n) for n in range(20)])
Tests de récursion mutuelle réussis pour n = 0 à 9.
Suite de Hofstadter F(n) :
[1, 1, 2, 2, 3, 3, 4, 5, 5, 6, 6, 7, 8, 8, 9, 9, 10, 11, 11, 12]
Suite de Hofstadter M(n) :
[0, 0, 1, 2, 2, 3, 4, 4, 5, 6, 6, 7, 7, 8, 9, 9, 10, 11, 11, 12]
Quand préférer l’itération à la récursion ?#
La récursivité est un outil puissant, mais elle n’est pas toujours la meilleure approche en Python.
Remarque 9
Préférer l’itération à la récursion dans les cas suivants :
Profondeur potentiellement grande. Si la profondeur de récursion peut dépasser quelques milliers, le risque de
RecursionErrorest réel. L’itération ne souffre pas de cette limitation.Récursion linéaire simple. Factorielle, somme d’un tableau, maximum : toutes ces fonctions se transforment trivialement en boucle, avec une complexité spatiale réduite de \(O(n)\) à \(O(1)\).
Performance critique. En Python, chaque appel de fonction engendre un coût de création de cadre de pile non négligeable. Pour une récursion profonde sur des données volumineuses, la version itérative peut être plusieurs fois plus rapide.
Langage sans TCO. Puisque Python n’optimise pas les appels terminaux, les récursions profondes se traduisent toujours par une pile proportionnelle à la profondeur.
Préférer la récursion dans les cas suivants :
Structure naturellement récursive. Arbres, graphes, expressions arithmétiques : leur structure est récursive, et l’algorithme récursif est souvent le plus lisible et le plus facile à prouver correct.
Diviser pour régner. Tri fusion, recherche binaire, transformation de Fourier rapide (FFT) : ces algorithmes s’expriment naturellement comme une récursion avec une réduction géométrique de la taille.
Profondeur logarithmique. Si la profondeur est \(O(\log n)\), le risque de dépassement est négligeable (\(\log_2(10^9) < 30\)), et la version récursive est souvent plus claire.
Implémentation Python et Rust#
# Factorielle et Fibonacci : versions itératives vs récursives
import time
def benchmark_fibonacci():
"""Compare les différentes implémentations de Fibonacci."""
valeurs_n = list(range(0, 36))
# Fibonacci itératif
def fib_iter(n):
if n <= 1:
return n
a, b = 0, 1
for _ in range(n - 1):
a, b = b, a + b
return b
# Fibonacci avec mémoïsation
@cache
def fib_memo(n):
if n <= 1:
return n
return fib_memo(n - 1) + fib_memo(n - 2)
print(f"{'n':>4} {'Itératif':>12} {'Mémoïsé':>12}")
print("-" * 30)
for n in valeurs_n:
assert fib_iter(n) == fib_memo(n)
if n <= 15:
print(f"{n:>4} {fib_iter(n):>12} {fib_memo(n):>12}")
print(" ...")
benchmark_fibonacci()
n Itératif Mémoïsé
------------------------------
0 0 0
1 1 1
2 1 1
3 2 2
4 3 3
5 5 5
6 8 8
7 13 13
8 21 21
9 34 34
10 55 55
11 89 89
12 144 144
13 233 233
14 377 377
15 610 610
...
Implémentation Rust#
/// Factorielle itérative en Rust.
/// Utilise u128 pour supporter de grands nombres (jusqu'à 34!).
fn factorielle_iterative(n: u32) -> u128 {
(1..=n as u128).product()
}
/// Factorielle récursive en Rust.
fn factorielle_recursive(n: u32) -> u128 {
if n == 0 {
1
} else {
n as u128 * factorielle_recursive(n - 1)
}
}
/// Fibonacci itératif : O(n) temps, O(1) espace.
fn fibonacci_iteratif(n: u64) -> u128 {
if n <= 1 {
return n as u128;
}
let (mut a, mut b) = (0u128, 1u128);
for _ in 1..n {
(a, b) = (b, a + b);
}
b
}
/// Fibonacci mémoïsé avec un vecteur.
/// Cette version itérative avec cache est équivalente à la mémoïsation récursive.
fn fibonacci_memo(n: usize) -> u128 {
if n <= 1 {
return n as u128;
}
let mut memo = vec![0u128; n + 1];
memo[0] = 0;
memo[1] = 1;
for i in 2..=n {
memo[i] = memo[i - 1] + memo[i - 2];
}
memo[n]
}
/// Tours de Hanoï récursives.
fn hanoi(n: u32, source: char, destination: char, auxiliaire: char) -> Vec<(char, char)> {
if n == 0 {
return vec![];
}
let mut mouvements = hanoi(n - 1, source, auxiliaire, destination);
mouvements.push((source, destination));
let mut reste = hanoi(n - 1, auxiliaire, destination, source);
mouvements.append(&mut reste);
mouvements
}
fn main() {
// Factorielle
for n in 0..=20u32 {
assert_eq!(factorielle_iterative(n), factorielle_recursive(n));
}
println!("10! = {}", factorielle_iterative(10));
println!("20! = {}", factorielle_iterative(20));
// Fibonacci
for n in 0..=50u64 {
assert_eq!(fibonacci_iteratif(n), fibonacci_memo(n as usize));
}
println!("F(50) = {}", fibonacci_iteratif(50));
// Hanoï
let mouvements = hanoi(3, 'A', 'C', 'B');
println!("Hanoï(3) : {} mouvements", mouvements.len());
for (src, dst) in &mouvements {
println!(" {} → {}", src, dst);
}
}
Visualisation : arbre des appels de Fibonacci#
Résumé#
Dans ce chapitre, nous avons exploré en profondeur la récursivité :
Une fonction récursive comporte toujours un cas de base (résolution directe) et un cas récursif (appel sur une instance strictement plus petite). La preuve de correction se fait par récurrence structurelle.
La pile d’appels empile un cadre par niveau de récursion. La profondeur maximale en Python est de 1 000 par défaut (modifiable via
sys.setrecursionlimit). Une récursion profonde risque unRecursionError.La récursion naïve de Fibonacci est de complexité \(O(2^n)\) à cause des sous-problèmes recalculés. La mémoïsation (
@lru_cache,@cache) la réduit à \(O(n)\) en mémorisant les résultats.La récursion terminale place l’appel récursif en dernière position. Python ne l’optimise pas, contrairement à Haskell, Scheme ou Rust (dans certains cas). En pratique, pour les récursions profondes, on préfère l’itération.
La récursion mutuelle entre plusieurs fonctions est utile pour les grammaires formelles et certains problèmes de combinatoire.
Les tours de Hanoï illustrent un problème dont la solution récursive est naturelle et dont la complexité \(O(2^n)\) est optimale (le problème exige \(2^n - 1\) mouvements).
Dans le chapitre suivant, nous étudierons les premières structures de données linéaires : tableaux et listes chaînées, leurs propriétés mémoire, et les compromis entre accès et modification.