pichegru.net

Physique-Chimie & NSI

Cours complets et originaux de Physique-Chimie & NSI

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 :

1 + f(1) 1 1 + f(2) f(4) = 1 + f(3)

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 :

1 + 1 1 + f(2) f(4) = 1 + f(3)

L’appel à f(2) renvoie alors 2 et l’arbre d’appel devient :

1 + 2 f(4) = 1 + f(3)

L’appel f(3) finit alors de s’exécuter pour donner :

f(4) = 1 + 3
Tout ça pour ça ? 🙁 Mais c’est hyper compliqué pour rien !

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 :

fB fB fB fA i = 3 1 2 3
  1. appel de la fonction fB qui est ajoutée à la pile d’exécution du programme
  2. dans la fonction fB, la fonction fA est appelée, on ajoute cet appel à la pile d’exécution,
  3. la fonction fA a fini d’être exécutée, elle est donc dépilée de la pile d’exécution, la fonction fB 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 :

f x=4 f x=4 f x=4 f x=3 f x=2 f x=4 f x=3 f x=1 f x=2 f x=4 f x=3 f x=2 f x=4 f x=3 f x=3 f x=4 renvoie 1 renvoie 2 renvoie 3 renvoie 4

🖐️ 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.

Lorsqu’on conçoit une fonction récursive, il faut toujours prévoir un cas de base et s’assurer que ce cas de base va être atteint.

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