1Principe, pile d'appels et recursivite terminale
Une fonction est recursive si elle s'appelle elle-meme, directement ou indirectement. C'est la traduction algorithmique du raisonnement par recurrence : on resout un probleme en le ramenant a une ou plusieurs instances plus petites du meme probleme.
- Un ou plusieurs cas de base, resolus directement, sans appel recursif.
- Un cas recursif qui se ramene a des instances strictement plus petites, et qui progresse necessairement vers un cas de base.
Oublier le cas de base, ou ne pas decroitre, produit une recursion infinie :
RecursionError: maximum recursion depth exceeded.
def factorielle(n):
"""n! pour n >= 0."""
if n <= 1: # CAS DE BASE
return 1
return n * factorielle(n - 1) # CAS RECURSIF : n-1 < n
assert factorielle(0) == 1
assert factorielle(5) == 120
Le deroulement d'un appel
factorielle(4)
= 4 * factorielle(3)
= 4 * (3 * factorielle(2))
= 4 * (3 * (2 * factorielle(1)))
= 4 * (3 * (2 * 1)) <- cas de base atteint : la remontee commence
= 4 * (3 * 2)
= 4 * 6
= 24
Deux phases : la descente (les appels s'empilent) puis la remontee (les resultats se combinent). Tant que la descente n'a pas atteint un cas de base, aucun calcul de la multiplication n'est effectue : les 4 multiplications attendent sur la pile.
Recursivite et pile d'execution
Chaque appel en attente occupe un bloc sur la pile d'execution. La memoire consommee est donc
proportionnelle a la profondeur maximale de recursion, et non au nombre total
d'appels. Pour factorielle(n) : profondeur \(n\), donc \(\Theta(n)\) memoire, la ou
la version iterative n'utilise que \(\Theta(1)\).
import sys
sys.getrecursionlimit() # 1000 par defaut
factorielle(3000) # RecursionError
La limite protege contre le debordement de la pile systeme, qui provoquerait un plantage brutal du processus.
Recursivite terminale
Un appel recursif est terminal si sa valeur est renvoyee telle quelle, sans aucun calcul apres le retour. Le bloc d'activation de l'appelant devient alors inutile : certains langages (OCaml, Scheme) l'eliminent automatiquement, ramenant la memoire a \(\Theta(1)\).
def factorielle(n):
"""NON terminale : il reste une multiplication apres l'appel."""
if n <= 1:
return 1
return n * factorielle(n - 1)
def factorielle_terminale(n, acc=1):
"""TERMINALE : l'appel recursif est la derniere operation."""
if n <= 1:
return acc
return factorielle_terminale(n - 1, acc * n) # rien apres l'appel
Contrairement a OCaml, CPython ne realise pas l'elimination des appels terminaux :
factorielle_terminale(3000) leve toujours RecursionError. Guido van
Rossum a explicitement refuse cette optimisation, qui rendrait les traces d'erreur illisibles.
En Python, une recursion terminale se transforme donc mecaniquement en boucle
while :
def factorielle_iterative(n):
acc = 1
while n > 1:
acc *= n
n -= 1
return acc2Prouver, analyser, memoiser
Prouver une fonction recursive
Terminaison : exhiber une quantite entiere positive (un variant) qui decroit strictement a chaque appel recursif. Les cas de base doivent etre atteints pour les valeurs minimales de ce variant.
Correction : par recurrence sur le variant. On suppose la fonction correcte pour toutes les valeurs strictement plus petites (hypothese de recurrence forte) et on montre qu'elle l'est pour la valeur courante.
def puissance(x, n):
"""x^n pour n entier >= 0. Theta(log n) multiplications."""
if n == 0:
return 1
if n % 2 == 0:
y = puissance(x, n // 2)
return y * y
return x * puissance(x, n - 1)
Terminaison. Variant : \(n\), entier naturel. Dans le cas pair, l'appel porte sur \(n/2 < n\) (car \(n \ge 2\) puisque \(n \ne 0\) et \(n\) pair). Dans le cas impair, il porte sur \(n-1 < n\). Le variant decroit strictement et le cas de base \(n=0\) est atteint : la fonction termine.
Correction. Par recurrence forte sur \(n\).
- Base : \(n=0\), la fonction renvoie 1 \(= x^0\). Correct.
- Heredite : soit \(n \ge 1\), supposons le resultat vrai pour tout \(k < n\).
- Si \(n\) est pair, \(n = 2p\) avec \(p = n/2 < n\). Par hypothese,
puissance(x, p)vaut \(x^p\), donc la fonction renvoie \((x^p)^2 = x^{2p} = x^n\). - Si \(n\) est impair, \(n-1 < n\), donc
puissance(x, n-1)vaut \(x^{n-1}\) et la fonction renvoie \(x \cdot x^{n-1} = x^n\).
- Si \(n\) est pair, \(n = 2p\) avec \(p = n/2 < n\). Par hypothese,
Complexite. Notons \(M(n)\) le nombre de multiplications. Un cas impair est toujours suivi d'un cas pair, donc deux appels consecutifs au plus divisent \(n\) par 2 : \(M(n) \le 2\log_2 n + 1 = \Theta(\log n)\). Pour \(n = 10^6\) : une quarantaine de multiplications au lieu d'un million.
Recursivite multiple et explosion combinatoire
def fib(n):
"""Fibonacci naif. ATTENTION : exponentiel."""
if n < 2:
return n
return fib(n - 1) + fib(n - 2)
Notons \(A(n)\) le nombre total d'appels. On a \(A(0)=A(1)=1\) et \(A(n) = 1 + A(n-1) + A(n-2)\). On montre par recurrence que \(A(n) = 2F_{n+1} - 1\), ou \(F\) est la suite de Fibonacci. Comme \(F_n \sim \frac{\varphi^n}{\sqrt5}\) avec \(\varphi = \frac{1+\sqrt5}{2} \approx 1{,}618\), on obtient \[ A(n) = \Theta(\varphi^n) : \text{croissance EXPONENTIELLE}. \]
| \(n\) | Appels | Temps |
|---|---|---|
| 20 | 21 891 | < 0,01 s |
| 30 | 2 692 537 | 0,4 s |
| 40 | 331 160 281 | 50 s |
| 50 | 4 x 1010 | > 1 heure |
La cause : fib(n-2) est recalcule des milliards de fois. L'arbre des appels
contient d'enormes redondances.
def fib_memo(n, cache=None):
"""Fibonacci memoise : Theta(n) appels, Theta(n) memoire."""
if cache is None:
cache = {}
if n < 2:
return n
if n not in cache:
cache[n] = fib_memo(n-1, cache) + fib_memo(n-2, cache)
return cache[n]
from functools import lru_cache
@lru_cache(maxsize=None)
def fib_cache(n):
return n if n < 2 else fib_cache(n-1) + fib_cache(n-2)
def fib_iteratif(n):
"""Theta(n) temps, Theta(1) memoire : la meilleure version."""
a, b = 0, 1
for _ in range(n):
a, b = b, a + b
return a
assert fib_memo(100) == fib_iteratif(100) == 354224848179261915075
Chaque valeur n'est calculee qu'une fois : on passe de \(\Theta(\varphi^n)\) a \(\Theta(n)\). C'est le principe de la programmation dynamique.
Diviser pour regner
Trois etapes : diviser le probleme en sous-problemes independants, regner (les resoudre recursivement), combiner les solutions.
La complexite verifie alors une recurrence \(T(n) = a\,T(n/b) + f(n)\), ou \(a\) est le nombre de sous-problemes et \(n/b\) leur taille.
| Algorithme | Recurrence | Complexite |
|---|---|---|
| Recherche dichotomique | \(T(n) = T(n/2) + \Theta(1)\) | \(\Theta(\log n)\) |
| Tri fusion | \(T(n) = 2T(n/2) + \Theta(n)\) | \(\Theta(n\log n)\) |
| Puissance rapide | \(T(n) = T(n/2) + \Theta(1)\) | \(\Theta(\log n)\) |
| Fibonacci naif | \(T(n) = T(n-1) + T(n-2) + \Theta(1)\) | \(\Theta(\varphi^n)\) |
| Tours de Hanoi | \(T(n) = 2T(n-1) + \Theta(1)\) | \(\Theta(2^n)\) |
Retenir la difference cruciale : diviser la taille (\(n/2\)) donne des logarithmes ; la decrementer (\(n-1\)) avec deux appels donne des exponentielles.
3Grands schemas recursifs
Exemples fondamentaux
Deplacer \(n\) disques de la tige \(A\) vers \(C\) en utilisant \(B\), sans jamais poser un disque sur un plus petit.
def hanoi(n, depart="A", arrivee="C", intermediaire="B", mouvements=None):
"""Deplace n disques. Renvoie la liste des mouvements."""
if mouvements is None:
mouvements = []
if n == 0:
return mouvements
hanoi(n-1, depart, intermediaire, arrivee, mouvements) # 1. les n-1 du dessus
mouvements.append((depart, arrivee)) # 2. le plus grand
hanoi(n-1, intermediaire, arrivee, depart, mouvements) # 3. on les remet dessus
return mouvements
m = hanoi(3)
print(len(m), m) # 7 mouvements
assert len(hanoi(10)) == 2**10 - 1
Complexite. \(T(n) = 2T(n-1) + 1\) avec \(T(0)=0\). En posant \(u_n = T(n)+1\), il vient \(u_n = 2u_{n-1}\), donc \(u_n = 2^n\) et \[ T(n) = 2^n - 1. \] C'est optimal : on montre par recurrence que tout deplacement du plus grand disque exige d'avoir prealablement deplace les \(n-1\) autres. Pour \(n = 64\) (la legende), a raison d'un disque par seconde, il faudrait environ 585 milliards d'annees.
Un arbre binaire est None (vide) ou un triplet
(valeur, gauche, droite) : la definition est elle-meme recursive, les algorithmes le
sont donc naturellement.
def taille(a):
"""Nombre de noeuds."""
return 0 if a is None else 1 + taille(a[1]) + taille(a[2])
def hauteur(a):
"""Hauteur : -1 pour l'arbre vide, 0 pour une feuille."""
return -1 if a is None else 1 + max(hauteur(a[1]), hauteur(a[2]))
def infixe(a):
"""Parcours gauche - racine - droite."""
return [] if a is None else infixe(a[1]) + [a[0]] + infixe(a[2])
def prefixe(a):
return [] if a is None else [a[0]] + prefixe(a[1]) + prefixe(a[2])
def miroir(a):
"""Arbre symetrique."""
return None if a is None else (a[0], miroir(a[2]), miroir(a[1]))
A = (1, (2, (4, None, None), (5, None, None)), (3, None, None))
assert taille(A) == 5 and hauteur(A) == 2
assert infixe(A) == [4, 2, 5, 1, 3]
assert prefixe(A) == [1, 2, 4, 5, 3]
assert infixe(miroir(A)) == [3, 1, 5, 2, 4]
Chaque fonction suit le meme moule : cas de base pour l'arbre vide, puis combinaison des
resultats des deux sous-arbres. La complexite est \(\Theta(n)\) pour taille et
hauteur, mais \(\Theta(n^2)\) au pire pour infixe a cause des
concatenations de listes (a corriger en accumulant dans une liste passee en parametre).
Placer \(n\) reines sur un echiquier \(n\times n\) sans qu'aucune n'en menace une autre.
def n_reines(n):
"""Nombre de solutions au probleme des n reines."""
def compatible(colonnes, c):
"""La reine en (ligne len(colonnes), colonne c) est-elle sure ?"""
ligne = len(colonnes)
for l, col in enumerate(colonnes):
if col == c or abs(col - c) == abs(l - ligne): # colonne ou diagonale
return False
return True
def place(colonnes):
if len(colonnes) == n:
return 1 # une solution complete
total = 0
for c in range(n):
if compatible(colonnes, c):
total += place(colonnes + [c]) # on essaie, on explore
return total # ... puis on revient (backtrack)
return place([])
assert [n_reines(k) for k in range(1, 9)] == [1, 0, 0, 2, 10, 4, 40, 92]
Le retour sur trace (backtracking) explore un arbre de choix, en elaguant des qu'une contrainte est violee. Sans elagage, il faudrait examiner \(n^n\) placements ; l'elagage reduit considerablement l'exploration, meme si le probleme reste exponentiel.
Recursivite ou iteration ?
| Recursivite | Iteration | |
|---|---|---|
| Lisibilite | proche de la definition mathematique | plus verbeuse sur les structures arborescentes |
| Memoire | \(\Theta(\text{profondeur})\) sur la pile | \(\Theta(1)\) souvent |
| Vitesse | surcout d'appel (environ 1 µs par appel en Python) | plus rapide |
| Limite | 1000 appels par defaut | aucune |
| Naturelle pour | arbres, diviser pour regner, backtracking | parcours lineaires, accumulations |
Utiliser la recursivite quand la structure du probleme est recursive (arbre, division en sous-problemes). Preferer l'iteration pour les parcours lineaires. Toujours memoiser des qu'un meme sous-probleme peut etre rencontre plusieurs fois.
L'essentiel du chapitre
- Toute fonction recursive doit avoir un cas de base et progresser vers lui.
- La memoire consommee est proportionnelle a la profondeur, pas au nombre d'appels.
- Terminaison : un variant entier positif strictement decroissant. Correction : recurrence forte sur ce variant.
- Python n'optimise pas la recursion terminale : la transformer en boucle.
- La recursivite multiple sans memoisation est exponentielle
(
fib: \(\Theta(\varphi^n)\)). - Diviser la taille donne des logarithmes ; la decrementer avec deux appels donne des exponentielles.
- Diviser pour regner et backtracking sont les deux grands schemas recursifs.