Table des matières
Récursivité
Qu'est-ce qu'une fonction récursive
Une fonction récursive est une fonction qui s'appelle elle même. En voici quelques exemples :
Exemple 1
Une fonction qui ne sert à rien, elle sert juste à l'explication.
def affichage_decroissant(nombre:int):
print(nombre)
if nombre > 0:
affichage_decroissant(nombre-1)
Exemple 2
Suite récurrente. En mathématiques, on peut définir une suite $(u_n)_{n\geqslant 0}$ par
- $u_0 = 10$
- pour $n\geqslant 1$, $u_{n} = 0,8 u_{n-1} + 25$.
L'utilisation d'une fonction récurrente rend le calcul extrêmement simple :
def u(n):
assert n >= 0
if n == 0:
return 10
return 0.8 * u(n-1) + 25
Cas particulier célèbre : la suite de Fibonacci $(F_n)_{n\geqslant 0}$. On définit :
- $F_0 = 1$
- $F_1 = 1$
- pour $n\geqslant 2$, $F_n = F_{n-1} + F_{n-2}$
À faire : écrire la fonction F(n) en Python.
Exemple 3
La fonction factorielle, grande classique de la récursivité.
Je rappelle que factorielle se note avec $!$ et que par définition : $n! = 1 \times 2 \times 3 \times \cdots \times (n-1) \times n$. Par exemple $3! = 1 \times 2 \times 3 = 6$.
On convient que $0! = 1$.
On pourrait voir la factorielle comme une suite $(f)_{n\geqslant 0}$ définie par
- $f_0 = 1$
- pour $n \geqslant 1$, $f_n = n \times f_{n-1}$.
À faire : écrire la fonction facto(n) en Python.
Exemple 4
Un mauvais exemple de récurrence qui entraîne une séquence infinie d'appels récurrents :
def mauvais(n:int):
print(n)
mauvais(n+1)
Exemple 5
Je vous laisse deviner à quoi sert cette fonction…
Vous pouvez la tester avec quelques valeurs de n.
def somme(n,s):
if n == 0:
return s
return somme(n-1, s+n)
# exemples d'utilisation (on met toujours le 2e argument à 0) :
a = somme(10, 0)
print(a)
b = somme(5, 0)
print(b)
Cas de base
Comme vous pouvez le voir, ces fonctions ont quelque chose de répétitif.
Par exemple pour la suite $u$, je calcule de $u_4$ :
- il faut d'abord calculer $u_3$. Mais pour calculer $u_3$,
- il faut d'abord calculer $u_2$. Mais pour calculer $u_2$,
- il faut d'abord calculer $u_1$. Mais pour calculer $u_1$,
- il faut d'abord calculer $u_0$. On sait que $u_0$ vaut 10.
Cette répétition doit finir par se terminer et donc la récurrence nécessite une condition.
Comme pour une boucle TANT QUE – while – qui peut se répéter indéfiniment, il faut veiller particulièrement à la bonne terminaison des fonctions récurrentes.
L'exemple 4 est donc un exemple à ne pas suivre.
Exemple d'une preuve de terminaison
Dans l'exemple 2, la fonction commence avec $n \geqslant 0$.
Version intuitive
Par exemple, on appelle u(10) qui va appeler u(9) qui va appeler u(8)… jusqu'à appeler u(0). Dans l'appel de u(0), grâce au test if n == 0:, il n'y a pas de nouvelle appel de la fonction u. La succession d'appels récurrents s'arrête. L'appel de u(0) prend fin, ce qui permet à l'appel de u(1) de se finir, ce qui permet… et finalement l'appel u(10) prend fin.
On commence avec $n$, et chaque appel fait passer à un nombre de plus en plus petit jusqu'à atteindre $0$.
Version plus rigoureuse
On peut faire en informatique des raisonnements qui sont en tout point semblables à des démonstrations mathématiques.
On doit faire un raisonnement par récurrence
Soit $P_n$ la proposition “La fonction u termine pour l'argument n.”
- Si
n == 0, la fonction termine et donc $P_0$ est vraie. - On fait l'hypothèse de récurrence : $P_{n-1}$ vraie. Qu'en est-il de $P_n$ ?
Dans l'exécution deu(n), il n'y a que des instructions simples qui terminent. Seul l'appelu(n-1)pose question. Mais par hypothèse de récurrence $P_{n-1}$, l'exécution deu(n-1)termine et doncu(n)termine et $P_n$ vraie. - Le raisonnement par récurrence permet de conclure que $P_n$ vraie pour tout $n \geqslant 0$ et donc
u(n)termine pour tout n.
Cas de base
Le cas n == 0 est le cas de base, celui qui ne nécessite pas de nouvel appel récursif et qui met donc fin à la succession des appels.
En général, une fonction récursive doit prévoir un test qui détecte si on est dans un cas de base ou non.
La succession d'appels doit aboutir à un cas de base, sans quoi on aura une récursion infinie et donc une RecursionError.
Dépassement de pile
Mise en évidence du problème
Comparons les deux possibilités suivantes pour calculer les termes de la suite récurrente u déjà rencontrée :
def u(n):
if n == 0:
return 10
return 0.8 * u(n-1) + 25
def u(n):
u = 10
for i in range(n):
u = 0.8*u + 25
return u
À gauche, dans la version récurrente, nous avons une écriture très élégante et très proche de l'écriture mathématique. On aimerait pouvoir programmer de cette façon.
Mais si vous essayez de calculer u(1000) vous constaterez que la version de gauche finit par provoquer une erreur :
>>> u(1000) RecursionError: maximum recursion depth exceeded in comparison
Au contraire, bien que moins élégante, la version de droite ne pose aucun problème. On pourrait même calculer u(100000) !
Pile d'exécution
Dans un programme, une fonction constitue un contexte particulier. On sait par exemple que dans le cas suivant, le a défini en ligne 2 et le a en ligne 5 n'ont rien à voir avec l'autre.
def f(x):
a = 32
return x + a
a = 17
f(45)
print(a)
f(19)
print(a)
Le a de la ligne 2 n'existe que le temps de la fonction. Il n'est accessible que le temps de l'exécution de la fonction.
De plus la fonction peut-être appelée depuis plusieurs endroits du programme et, après la fin de la fonction, le programme doit continuer là où il en était.
Donc chaque appel de fonction nécessite de créer un contexte lié à la fonction et de mémoriser des informations lié à cet appel particulier. Toutes ces informations devront être conservées jusqu'à la fin de l'exécution de la fonction.
Exemple d'empilement d'appels récursifs
On utilise une pile pour stocker ces informations. Voici un exemple avec l'exécution de facto(4).
On voit que le calcul de facto(4) doit attendre le retour de facto(3) qui lui même attendra le retour de facto(2)…
Quand les appels se terminent, on peut dépiler :
La fin de facto(0) permet de dépiler les informations concernant cet appel dans la pile et de renvoyer le résultat, 1. Ce résultat permet de terminer facto(1) dont l'appel est dépilé, etc.
La taille de la pile est limitée et la limite est basse : environ 1000 en Python. Un appel récursif provoquant plus de 1000 appels emboîtés provoque une RecursionError.
Récursion terminale
Le problème est que l'appel de facto(4) reste suspendu dans l'attente de la fin de facto(3). Il est possible de procéder autrement, c'est ce qu'on appelle la récursion terminale.
Prenons les cas de l'exemple 5. qui est en récursion terminale.
def somme(n,s):
if n == 0:
return s
return somme(n-1, s+n)
Cette fois, soit la fonction n'appelle pas de récurrence, soit la récurrence est ce qu'elle fait en dernier.
- Par exemple, si j'appelle
somme(10, 0). Commen != 0, l'appel se terminera parreturn somme(10 - 1, 0 + 10). Autrement dit, le résultat desomme(10,0)est exactement le même que celui desomme(9, 10). - Quand
somme(10, 0)appellesomme(9,10), il n'est pas nécessaire de garder en mémoire quelque chose desomme(10, 0). On peut terminer l'exécution desomme(10,0)et passer la main àsomme(9,10). Inutile d'encombrer la pile avec une fonctionsomme(10,0)qui attendrait la fin de l'exécution desomme(9,10).
La récursion est terminale quand l'appel récursif est à la toute fin et qu'il n'est pris dans aucun calcul. Si j'écris return 10 + somme(n-1, s+n) ce n'est pas une récursion terminale.
Récursion terminale → TANT QUE
On peut toujours remplacer une récursivité par une boucle TANT QUE – while.
Pour la fonction somme ce serait assez simple :
def somme(n,s):
if n == 0:
return s
return somme(n-1, s+n)
def somme(n,s):
while n != 0:
n, s = n-1, s + n
return s
Dans le cas d'une récursion terminale, ce travail est simple et peut même être fait automatiquement.
Certains langages comme OCaml le font. C'est à dire que si vous leur fournissez une récurrence de type récursion terminale, cette récurrence est automatiquement, à l’exécution, transformée en boucle while.
Voici un exemple de passage automatique.
# cas récurrent terminal
def fonction(arguments):
if condition:
return final
instructions...
return fonction(nouveaux arguments)
# en version while
def fonction(arguments):
while not condition:
instructions...
arguments = nouveaux arguments
return final
On a ainsi les avantages de la récurrence et pas ses défauts :
- avantages : programmation plus intuitive,
- défauts : pile d'appel de fonctions qui déborde si le nombre de récurrence est trop élevé.
Python ne le fait pas. Donc pour Python, passer en récursion terminale n'arrange rien.
Comment faire de la récursion terminale
Si je reprends l'exemple 5, vous avez dû comprendre que la fonction somme réalisait la somme des entiers de 1 à n. l'argument s sert d'accumulateur.
En récursion non terminale on aurait eu tendance à écrire
def somme(n):
if n == 0:
return 0
return somme(n-1) + n
C'est très simple. Mais cette fois le calcul de somme(n-1) n'est pas la dernière chose faite, car après le calcul de somme(n-1), on doit encore faire + n
L'argument s est donc l'astuce qui a permis de rendre la récurrence terminale.
On pourrait présenter les choses ainsi :
- En version non terminale, l'appel parent conserve l'accumulateur
set attend que l'appel enfant donne son résultat pour l'ajouter às. - En version terminale, l'appel parent fournit à l'appel enfant l'accumulateur
set laisse à l'appel enfant le soin de la gestion des. Ainsi l'appel parent n'a plus à attendre, il laisse l'appel enfant poursuivre.
À faire :
- Reformulez la fonction
factopour obtenir une récurrence terminale.
Pour vous aider : dites vous quefactoest commesommemais avec des*au lieu des+. - Reformulez
factoen remplaçant la récursion terminale par une bouclewhile - Reformulez la fonction
uen remplaçant la récursion par une bouclewhile
La récursion terminale est possible mais pas inutilement compliquée. Je vous mets la solution en fin, si vous elle vous intéresse. - Pouvez-vous convertir
Fen récursion terminale ? Avec une bouclewhile?
Conclusion
L'utilisation de fonctions récursives permet dans certains cas une écriture très claire et simple des programmes. Certains problèmes peuvent être difficiles à résoudre sans l'utilisation de la récursivité.
Vous pouvez prendre l'exemple de la fonction longueur dans les listes, ou de la fonction hauteur dans les arbres. Ces fonctions sont assez simple en version récursive. Elles le seraient beaucoup moins en version non récursive.
Mais la récursivité pose deux problèmes :
- comme une boucle tant que, une fonction récursive risque de devenir une boucle infinie. Il faut s'assurer que la récurrence arrive à un terme.
- le fonctionnement de l'appel des fonctions suppose dans le système la mémorisation de toute la séquence d'appels sous forme d'une pile d'exécution. Cette pile est de taille limitée et c'est une limite vite atteinte.
En Python, 1000 appels récursifs saturent la pile.
Dans certains langages – pas Python – l'utilisation de la récursion terminale permet de contourner le second problème.
Solution pour u en version récursive terminale
Normalement, quand je calcule $u_{10}$, je commence par $u_0$ et je remonte $u_1$, $u_2$… jusque $u_{10}$. Mais avec la récurrence, on a plutôt tendance à faire le calcul à l'envers…
Pour trouver l'astuce observons les résultats au différents rangs :
- $u_0 = 10$
- $u_1 = 10 \times 0,8 + 25$
- $u_2 = 10 \times 0,8^2 + 0,8 \times 25 + 25$
- $u_3 = 10 \times 0,8^3 + 0,8^2 \times 25 + 0,8 \times 25 + 25$
- En général $u_n = 10 \times 0,8^n + 25 \sum_{k=0}^{n-1} \times 0,8^k$
On va donc procéder en calculant progressivement les $0,8^k$ et la somme $\sum_{k=0}^{n-1} \times 0,8^k$.
def u(n, p = 1, s = 0):
assert n >= 0
if n == 0:
return 10*p + 25*s
return u(n-1, p*0.8, s + p)
# exemple d'appel
u(10)
Quand on écrit p = 1, s = 0 dans la signature de u, cela permet d'assigner des valeurs par défaut à p et s. Ainsi l'appel u(10) devient équivalent à u(10, 1, 0).



