1-02. Récursivité
On a vu jusqu’à présent que des fonctions pouvaient appeler d’autres fonctions. Il est également possible de programmer une fonction qui s’appelle elle-même. On parle alors de fonction récursive.
En informatique, un algorithme qui contient des appels à lui-même est dit récursif.
Utiliser des fonctions dans des fonctions
Fonction appelant une autre fonction
Activité n°1
Soit le code suivant :
def f(x):
return 2*x + 1
def g(x):
return 2 + f(x)
def h(x):
return f(x/2)
Prévoir ce que vont retourner g(4)
et h(4)
. Décrire les étapes de l’exécution de g(4)
et h(4)
en terme d’appels et de renvois de fonctions.
Il est possible d’utiliser une fonction à l’intérieur d’une autre fonction – bon, j’espère que ça n’est pas un scoop pour vous.
Plus intéressant, en revanche, il est possible d’écrire une fonction qui s’appelle elle-même. Une sorte de mise en abyme algorithmique.
Fonction s’appelant elle-même
Activité n°2
Soit la fonction suivante :
def f(x):
if x == 5: return "OK"
else: return f(5)
Que va retourner f(5) ? Et f(10) ? Décrire les étapes de l’exécution de ces instructions en terme d’appels et de renvois de fonctions.
Bien entendu, la fonction de l’exemple précédent n’a aucun intérêt. Elle sert juste à vous montrer qu’une fonction peut s’appeler elle-même. Et ça, ça nous ouvre de nouveaux horizons !😎
Principes de la récursivité
Définition
Un algorithme récursif est une méthode de résolution de problème où la fonction s'appelle elle-même avec des entrées de plus petite taille jusqu'à atteindre un cas de base (condition d'arrêt), permettant ainsi de résoudre des problèmes complexes en les décomposant en sous-problèmes similaires mais plus simples.
Activité n°3
Voici un exemple de fonction récursive :
def f(x):
print(x) # rappel de la valeur de x
if x <= 1: return 1 # cas de base
else: return 1 + f(x-1) # appel d’une valeur plus petite
print( f"f(4) = {f(4)}")
1. Décrire ce qui se passe au moment de l’appel de f(4)
en terme d’appels et de renvois de f
.
2. Prévoir ce qui va s’afficher dans la console.
3. Vérifier vos prévisions en exécutant ce script.
Arbre d’appels
Reprenons l’exemple de la fonction f(x)
vue dans l’activité précédente. À chaque fois que la variable entière x
est strictement supérieure à 1, la fonction s’appelle elle-même avec f(x-1)
. Cet appel s’appelle un appel récursif. Par contre, lorsque x
est égal à 1, la fonction renvoie 1. On peut alors représenter l’exécution de f(4)
de la façon suivante :
Cette façon de représenter l’exécution d’un programme s’appelle un arbre d’appels. On constate que pour calculer la valeur renvoyée par f(4)
, il faut d’abord appeler f(3) qui va lui-même déclencher un appel à f(2), qui lui-même appelle f(1) qui enfin renvoie 1. Après ce renvoi de la valeur 1, l’arbre d’appel devient :
L’appel à f(2)
renvoie alors 2 et l’arbre d’appel devient :
L’appel f(3)
finit alors de s’exécuter pour donner :
Je vous l’accorde ! ☺️ Cette fonction ne fait rien d’intéressant. Elle renvoie simplement x. Mais il faut toujours des cas simples pour mieux saisir les subtilités sous-jacentes. Et ce n’est pas fini. Maintenant qu’on a présenté la notion d’arbre d’appels, nous allon svoir ce qu’est la pile d’exécution.
Pile d’exécution
On donne ci-dessous un programme et son affichage obtenu dans la console lors de son exécution.
def fA():
print("Début fA")
i = 0
while i < 5:
print("fA", i)
i = i + 1
print("Fin fA")
def fB():
print("Début fB")
i = 0
while i < 5:
if i == 3 :
fA()
print("Retour à fB")
print("fB", i)
i = i + 1
print("Fin fB")
fB()
# Affichage dans la console:
# Début fB
# fB 0
# fB 1
# fB 2
# Début fA
# fA 0
# fA 1
# fA 2
# fA 3
# fA 4
# Fin fA
# Retour à fB
# fB 3
# fB 4
# Fin fB
Dans cet exemple, la fonction fB
appelle une autre fonction fA
. La principale chose à retenir de cet exemple est que l’exécution de fB
est interrompue pendant l’exécution de fA
. Une fois l’exécution de fA
terminée, l’exécution de fB
reprend là où elle avait été interrompue.
Pour gérer ces fonctions qui appellent d’autres fonctions, le système utilise une pile d’exécution. Une pile d’exécution permet d’enregistrer des informations sur les fonctions en cours d’exécution dans un programme. On parle de pile, car les exécutions successives « s’empilent » les unes sur les autres. Si on s’intéresse la pile d’exécution du programme étudié ci-dessus, on obtient le schéma suivant :
- appel de la fonction
fB
qui est ajoutée à la pile d’exécution du programme - dans la fonction
fB
, la fonctionfA
est appelée, on ajoute cet appel à la pile d’exécution, - la fonction
fA
a fini d’être exécutée, elle est donc dépilée de la pile d’exécution, la fonctionfB
termine son exécution.
C’est la fonction située au sommet de la pile d’exécution qui est en cours d’exécution. Toutes les fonctions situées en-dessous sont mises en pause jusqu’au moment où elles se retrouvent au sommet de la pile. Quand une fonction termine son exécution, elle est automatiquement retirée du sommet de la pile (on dit que la fonction est dépilée).
Les variables sont associées à la fonction dans lesquelles elles sont définies. C’est le scope (la portée) de la variable. Ici nous avons une variable i
dans la fonction fA
et une variable i
dans la fonction fB
. La pile d’exécution garde en mémoire la valeur des variables internes à chaque fonction. C’est ce qui permet de conserver la valeur de i référencée dans fB quand fA est exécutée : ainsi on reprend bien l’exécution de fB avec i
qui vaut 3.
L’instruction f(4)
(on parle ici de la fonction récursive vue dans la précédente activité) produit les étapes suivantes d’empilement et dépilement dans la pile d’exécution :
🖐️ La configuration par défaut en Python prévoit de limiter le nombre d’appels récursifs à 1 000.
Cas de base
Il y a une précaution importante à prendre, avec les fonctions récursives. Il faut s’assurer que le cas de base est toujours atteint. Sinon, la récursion est infinie (et votre script plantera).
Dans la pratique, la profondeur de récursion possible est limitée – tout simplement parce que la mémoire de votre ordinateur est elle-même limitée.
Fonction récursive et cas de base
Voici trois fonctions. Pour chacune d’entre elles déterminer, le cas échéant, les valeurs de x
qui ne permettront jamais que le cas de base soit atteint. On se limitera aux cas ou x
est un int
.
def f(x):
if x == 1: return 1
else: return 1 + f(x-2)
def g(x):
if x == 0: return 0
elif x > 0: return g(x+1)
else: return g(x-1)
def h(x):
if x <= 10: return "cas de base atteint"
else: return "x " + h(x-1)
Correction
Pour f(x)
, le cas de base n’est jamais atteint si x
< 1 ou si x
est pair.
Pour g(x)
, le cas de base n’est atteint que si x
vaut zéro.
Pour h(x)
, le cas de base est toujours atteint.
Applications de la récursivité
Un exemple classique de l’utilisation de la récursivité.
Fonction factorielle
L’application classique de la récursivité, c’est la fonction factorielle. Sa définition est la suivante : $* x! = x·(x-1)·(x-2)·…·2·1 *$. De plus, $*0! = 1*$.
Par exemple, 4! (se dit « factorielle 4 ») vaut 4×3×2×1. En informatique, un des moyens de définir cette fonction est d’en faire une fonction récursive.
Proposer une écriture de la fonction factorielle.
Correction
def factorielle(x):
if not isinstance(x, int) or x < 0: return "impossible"
if x <= 1: return 1
return x*factorielle(x-1)
La récursivité n’est pas toujours la meilleure solution.
Suite de Fibonacci
La suite de Fibonacci est définie de la façon suivante :
$µ u_n = u_{n-1} + u_{n-2} µ$
Avec $* u_0 = 0*$ et $* u_1 = 1*$
1. Proposer une fonction récursive fibo_rec
permettant de calculer le n-ième terme de la suite de Fibonacci.
2. Exécuter cette fonction avec n
= 35. Que constate-t-on ?
Vous pouvez visualiser les appels récursifs sur cette animation.
Parfois en effet, la récursivité n’est pas le choix le plus efficace. Regardez ci-dessous la fonction fibo_iter
def fibonacci_iteratif(n):
# Cas de base
if n <= 0: return 0
if n == 1: return 1
# Initialisation des deux premiers termes
fib_prev = 0
fib_current = 1
# Calcul itératif des termes suivants
for i in range(2, n + 1):
# Calcul du terme suivant
fib_next = fib_prev + fib_current
# Mise à jour des valeurs pour l'itération suivante
fib_prev = fib_current
fib_current = fib_next
return fib_current
3. À l’aide de cette fonction, calculer la valeur du 100e terme de la suite de Fibonacci. Que constate-t-on ?
4. Expliquer l’astuce algorithmique rendant cette fonction beaucoup plus rapide.
Correction
1. Fonction récursive
def fibo_rec(x):
if x <= 1: return x
return fibo_rec(x-1) + fibo_rec(x-2)
2. Pour n
= 35, le temps d’exécution est relativement long
3. Le calcul est rapide, même pour n
= 100.
4. Le calcul de chaque terme n’est fait qu’une seule fois, contrairement à la version récursive. Les valeurs intermédiaires sont stockées pour un usage lors de la boucle suivante.
Mini-projet : Équipement porté par un personnage
On souhaite implémenter un système de gestion de l’équipement possédé par un personnage dans un jeu. Ce sytème doit pouvoir calculer le poids total porté par le personnage, afin de calculer son encombrement. Il y a deux localisations « racine » pour chaque objet constituant l’équipement du personnage : « on
» (l’objet est porté par le personnage et compte dans son Encombrement) et « off
» (l’équipement n’est pas porté par le personnage – il est sur son cheval, ou chez lui).
Voici les spécifications :
- Un objet est représenté sous forme de dictionnaire, avec les clés
id
,name
,weight
,location
,isContainer
- Un objet peut être un contenant, c’est-à-dire qu’il peut contenir lui-même d’autres objets (par exemple un sac, une maison… peuvent être des contenants.).
- Un objet peut être porté par le personnage (
location: "on"
), ou pas (location: "off"
) ou encore être dans un objet contenant (location: id_du_contenant
) - Le poids d’un objet de type Contenant est son poids vide. Il ne change jamais.
L’algorithme doit être capable de calculer le poids de l’ensemble des objets que le personnage porte.
Voici un exemple de liste d’équipement d’un personnage :
equipment = [
{ "id": 1, "name": "épée", "weight": 1.25, "location": "on", "isContainer": False },
{ "id": 2, "name": "besace", "weight": 0.5, "location": "on", "isContainer": True },
{ "id": 3, "name": "potion", "weight": 0.15, "location": 2, "isContainer": False },
{ "id": 4, "name": "bourse", "weight": 0, "location": 2, "isContainer": True },
{ "id": 5, "name": "pièces d’or (50)", "weight": 0.5, "location": 4, "isContainer": False },
{ "id": 6, "name": "cheval", "weight": 500, "location": "off", "isContainer": True },
{ "id": 7, "name": "sacoches", "weight": 2.5, "location": 6, "isContainer": True },
{ "id": 8, "name": "couverture", "weight": 2, "location": 7, "isContainer": False },
{ "id": 9, "name": "corde", "weight": 1.5, "location": 7, "isContainer": False },
]
Cette liste donne les informations suivantes :
- Le personnage porte sur lui une épée et une besace qui contient une potion et une bourse. La bourse contient 50 pièces d’or. Cet équipement est porté par le personnage et donc l’ensemble du poids porté doit être pris en compte pour son Encombrement, y compris les pièces d’or et la potion.
- Le personnage possède également un cheval (qu’il ne porte pas ! 😅). Sur ce cheval, il y a des sacoches, qui elles-mêmes contiennent une corde et une couverture. Le poids de ces objets ne doit pas être pris en compte pour le calcul de son Encombrement.
Comme toujours lorsqu’on doit créer un algorithme relativement compliqué, on procède par étapes. Chaque étape doit mettre en évidence une fonction dont on va avoir besoin pour arriver à notre but final.
1. Proposer une série d’étapes pour la création de cet algo.
2. Créer la fonction get_object_by_id(id)
qui renvoie un dictionnaire objet lorsqu’on lui passe un id. Cette fonction doit renvoyer None
si aucun objet n’a cet id. Rappel : un id est unique.
3. Créer la fonction is_carried(id)
qui détermine si un objet donné (défini par son id
) est porté par le personnage ou pas. Cette fonction sera récursive : soit l’objet est directement porté par le personnage, soit il n’est directement « pas porté », soit il est dans un contenant, auquel cas on examine le contenant. Si l’objet n’existe pas, la fonction doit renvoyer False
.
4. Proposer un petit script qui, pour une liste d’équipement donné, calcule le poids porté par le personnage.
5. Question bonus. Créer la fonction transfer_object(id, location)
qui met la liste d’équipement à jour en plaçant l’objet d’id id
dans la localisation location
. Si la localisation est incohérente, la fonction doit afficher un message d’avertissement et ne pas changer la localisation de l’objet.
Correction
1. On va avoir besoin des fonctionnalités suivantes :
- Récupérer un objet à partir de son id.
- Déterminer, pour un objet donné, s’il est porté par le personnage ou pas.
2. La fonction get_object_by_id(id)
.
def get_object_by_id (id: int) -> dict:
for object in equipment:
if object["id"] == id: return object
return None
3. La fonction is_carried(id)
.
def is_carried(id: int) -> bool:
object = get_object_by_id(id)
if not object: return False
if object["location"] == "on": return True
if object["location"] == "off": return False
return is_carried(object["location"])
4. Le script calculant le poids total porté par le personnage.
carried_weight = 0
for object in equipment:
if is_carried(object["id"]): carried_weight += object["weight"]
print(carried_weight)
5. Fonction de transfert d’objet
def transfer_object(id, location):
location_object = get_object_by_id(location) # objet localisation (ou None)
location_is_valid = False # initialisation
transfered_object = get_object_by_id(id) # objet transféré (ou None)
object_is_valid = False # initialisation
if location == "on" or location == "off" or location_object and location_object["isContainer"] == True:
# on vérifie que la localisation est valide
location_is_valid = True
if id != location and transfered_object:
# on vérifie que l’objet existe et qu’il n’est pas transféré dans lui-même
object_is_valid = True
if location_is_valid and object_is_valid:
# on modifie la localisation de l’objet
transfered_object["location"] = location
else: print("données incohérentes")
# cette fonction n’empêche pas un contenant A d’être placé dans un contenant B qui lui-même
# est déjà dans le contenant A. Il faudrait approfondir la validation