Avantages et défauts de la récursivité (Bac 🎯)⚓︎
Ce cours est mis à disposition selon les termes de la Licence Creative Commons Attribution - Pas d'Utilisation Commerciale - Partage dans les Mêmes Conditions 4.0 International.
Sources et crédits pour ce cours
Pour préparer ce cours, j'ai utilisé :
- le cours d'Olivier Lécluse
- le manuel NSI chez Ellipses de Balabonski, Conchon, Filliâtre, Nguyen
- le manuel NSI chez Hachette sous la direction de Michel Beaudouin Lafon
- la ressource Eduscol sur la récursivité
- le cour e-nsi sur la récursivité
- le cours de mon collègue Pierre Duclosson
🔖 Synthèse de ce qu'il faut retenir pour le bac
Plusieurs cas de base⚓︎
Exercice 4
Exercice tiré de la ressource ressource Eduscol sur la récursivité
Problème : On dispose de briques de longueur 2 ou 3. Combien de murs de longueur \(n\) distincts peut-on construire avec ces briques ?
Voici par exemple les trois solutions possibles pour des rangées de longueur 7 : (chaque brique est symbolisée par une couleur différente).
Ainsi il y a 3 façons de construire un mur de longueur 7 : 223 232 322
- Déterminez le nombre de façons de construire un mur de longueur \(8\).
- On note
murs(n)
le nombre de façons de construire un mur de longueur \(n\). Comment peut-on réduire le calcul demurs(n)
à des calculs similaires mais pour des murs de longuers inférieures ? - Combien de murs de longueur 0 peut-on construire ? et de longueur 1, 2 ou 3 ?
- Écrire une fonction récursive
murs
qui résout le problème.
def murs_enveloppe(n):
assert isinstance(n, int) and n >= 0, "la longeur n doit être un entier positif"
return murs(n)
def murs(n):
if n <= 1:
return 0
if n == 2 or n == 3:
return 1
return murs(n - 2) + murs(n - 3)
Point de cours 3
Dans l'exercice précédent, on a vu un exemple où la récursivité permet d'exprimer simplement et lisiblement une solution à un problème en ramenant sa résolution à la résolution de problèmes similaires mais plus petits. Cette approche sera déclinée dans les méthodes de résolution :
- Diviser pour régner où les sous-problèmes sont indépendants
- par Programmation dynamique où les sous-problèmes peuvent se chevaucher, comme dans l'exercice 4.
Récursivité multiple⚓︎
Exemple 6
La suite de Fibonacci, est définie récursivement : chaque nouvelle valeur est la somme des deux dernières valeurs calculées. Évidemment il faut partir de valeurs initiales qui sont 0 et 1.
Mathématiquement si on note \((f_{n})\) la suite alors on peut la définir pour tout entier naturel \(n\) par :
- \(f_{0}=0\) et \(f_{1}=1\)
- \(f_{n}=f_{n-1}+f_{n-2}\) pour tout entier \(n \geqslant 2\)
Il est naturel de traduire cette définition sous la forme d'une fonction récursive, pour calculer le terme de rang \(n\) de la suite.
Les deux cas de base \(n=0\) et \(n=1\) peuvent être regroupés dans une seule conditionnelle mais la partie réduction doit comporter deux appels récursifs. On parle dans ce cas de récursivité multiple.
def fibo(n):
"""Fonction enveloppe pour ne vérifier la précondition sur n qu'une seule fois"""
assert isinstance(n, int) and n >= 0
return fibo_rec(n)
def fibo_rec(n):
if n <= 1: # 2 cas de bases n = 0 ou n = 1
return n
return fibo_rec(n - 1) + fibo_rec(n - 2)
Exercice 5
🎯 Analyser le fonctionnement d'un programme récursif
- Représentez l'arbre d'appels pour le calcul de
fibo_rec(3)
puis pour le calcul defibo_rec(5)
. -
Exécutez le code ci-dessous. Quelle conjecture peut-on faire sur l'évolution du temps d'exécution de
fibo_rec(n)
lorsque \(n\) augmente \(1\) ? Est-il raisonnable d'envisager le calcul defibo_rec(20)
?Attention
Si on exécute ce code sur Capytale, on peut calculer des valeurs plus grandes, et le ratio entre les temps de calcul de
fibo_rec(n)
etfibo_rec(n+1)
tend vers \(1,6\) ce qui correspond mieux à la théorie. Pour autant, même avec une technologie ou une machine plus performante, peut-on calculer avec cet algorithme une valeur commefibo_rec(50)
?📋 Texten= 15 f(n)= 610 Temps= 0.0010001659393310547 Ratio= 0.0010001659393310547 n= 16 f(n)= 987 Temps= 0.0019998550415039062 Ratio= 1.999523241954708 n= 17 f(n)= 1597 Temps= 0.002000093460083008 Ratio= 1.0001192179303768 n= 18 f(n)= 2584 Temps= 0.0039997100830078125 Ratio= 1.9997615925616878 n= 19 f(n)= 4181 Temps= 0.006000041961669922 Ratio= 1.5001192179303768 n= 20 f(n)= 6765 Temps= 0.010999917984008789 Ratio= 1.833306842565366 n= 21 f(n)= 10946 Temps= 0.01699995994567871 Ratio= 1.5454624271192319 n= 22 f(n)= 17711 Temps= 0.0280001163482666 Ratio= 1.6470695482658513 n= 23 f(n)= 28657 Temps= 0.045999765396118164 Ratio= 1.6428419376538006 n= 24 f(n)= 46368 Temps= 0.07300019264221191 Ratio= 1.58696880328812 n= 25 f(n)= 75025 Temps= 0.11999988555908203 Ratio= 1.6438297107957607 n= 26 f(n)= 121393 Temps= 0.1919999122619629 Ratio= 1.6000007947293549 n= 27 f(n)= 196418 Temps= 0.3100001811981201 Ratio= 1.6145850148887504 n= 28 f(n)= 317811 Temps= 0.502000093460083 Ratio= 1.6193541936649913 n= 29 f(n)= 514229 Temps= 0.8119997978210449 Ratio= 1.61752917658692 n= 30 f(n)= 832040 Temps= 1.317000150680542 Ratio= 1.6219217716736343
###(Dés-)Active le code après la ligne# Tests
(insensible à la casse)
(Ctrl+I)Tronquer ou non le feedback dans les terminaux (sortie standard & stacktrace / relancer le code pour appliquer)Si activé, le texte copié dans le terminal est joint sur une seule ligne avant d'être copié dans le presse-papier -
Il est frustrant de ne pas pouvoir calculer des valeurs comme
fibo_rec(50)
.a. Écrivez une version itérative
fibo_iter
qui peut calculer à l'aide d'une boucle des valeursfibo_iter(n)
pour des valeurs de \(n\) plus grandes.b. Dans ce cas la lisibilité va-t-elle de pair avec l'efficacité ?
###(Dés-)Active le code après la ligne# Tests
(insensible à la casse)
(Ctrl+I)Tronquer ou non le feedback dans les terminaux (sortie standard & stacktrace / relancer le code pour appliquer)Si activé, le texte copié dans le terminal est joint sur une seule ligne avant d'être copié dans le presse-papier -
À partir des arbres d'appels déjà réalisés et de celui pour
fibo_rec(5)
, quelle explication peut-on donner pour les performances médiocres de la fonction récursivefibo_rec
par rapport à la fonction itérativefibo_iter
?Arbre d'appels pour
fibo_rec(5)
On pose \(c(n)\) le coût en opérations de l'exécution de
fibo_rec(n)
. De plus on pose \(c(0)=c(1)=1\).a. Quelle relation de récurrence permet d'exprimer \(c(n)\) ?
b. D'après la page Wikipedia sur la suite de Fibonacci, en déduire une estimation de \(c(n)\).
c. Comparez avec la complexité de la fonction itérative
fibo_iter
.
Point de cours 4
Si une fonction récursive permet d'exprimer une solution à un problème de façon plus lisible et élégante qu'une fonction itérative, il faut se méfier de la complexité cachée. En général la complexité \(c(n)\) en fonction de la taille \(n\) de l'entrée, peut s'exprimer à l'aide d'une relation de récurrence.
Par exemple, des récurrences de la forme \(c(n+1)= 2 c(n)\) ou \(c(n+2)=c(n)+c(n+1)\) conduisent à des complexité exponentielles en \(c(n) \approx k \times q^{n}\) avec \(q>1\).
Modèle d'exécution d'une fonction récursive⚓︎
Exercice 6
-
Complétez les fonctions
somme_rec
etsomme_iter
dans l'IDE ci-dessous. Ces deux fonctions doivent calculer pour un entier \(n \geqslant 1\), la somme \(\sum_{k=1}^{n}k\) avec une répétition (appels récursifs ou boucle).###(Dés-)Active le code après la ligne# Tests
(insensible à la casse)
(Ctrl+I)Tronquer ou non le feedback dans les terminaux (sortie standard & stacktrace / relancer le code pour appliquer)Si activé, le texte copié dans le terminal est joint sur une seule ligne avant d'être copié dans le presse-papier🐍 Script Pythondef somme_rec(n): """ Renvoie la somme des entiers successifs de 1 à n : 1 + 2 + 3 + ... + n Fonction récursive Parametres: n (_int_): entier naturel """ if n == 1: return 1 return n + somme_rec(n - 1) def somme_iter(n): """ Renvoie la somme des entiers successifs de 1 à n : 1 + 2 + 3 + ... + n Fonction itérative (avec boucle) Parametres: n (_int_): entier naturel """ s = 0 for k in range(1, n + 1): s = s + k return s def test(somme): for n in range(1, 21): assert somme(n) == n * (n + 1) / 2 print("Tests réussis")
-
Cliquez sur le bouton ci-dessous pour exécuter les fonctions
somme_iter
etsomme_rec
sur la plage d'entiers compris entre 980 et 1000 dans Capytale. 1Que remarquez-vous ?
Stack Overflow
On obtient le message d'erreur suivant qui nous indique que la profondeur maximale d'appels récursifs a été atteinte.
🐍 Script Python>>> for n in range(980, 1001): print(n, somme_iter(n), somme_rec(n)) 980 480690 480690 981 481671 481671 982 482653 482653 983 483636 483636 984 484620 484620 985 485605 485605 986 486591 486591 987 487578 487578 988 488566 488566 Traceback (most recent call last): File "<input>", line 28, in <module> File "<input>", line 12, in somme_rec File "<input>", line 12, in somme_rec File "<input>", line 12, in somme_rec [Previous line repeated 985 more times] File "<input>", line 9, in somme_rec RecursionError: maximum recursion depth exceeded in comparison
Point de cours 5
Lorsqu'une fonction est appelée, un contexte est créé qui va contenir les variables locales de cette fonction.
Si une fonction B est appelée au cours de l'exécution de la fonction A, alors l'exécution de la fonction A est interrompue et son contexte est sauvegardé au sommet d'une pile. Il sera restauré dès la fin de l'appel de la fonction B et l'exécution de A pourra reprendre dans son contexte. Si on une fonction récursive, les fonctions A et B sont les mêmes et donc B peut encore s'appeler elle même, jusqu'au cas de base ... Au cours de la phase de descente, les contextes s'empilent, avec toujours le contexte du dernier appel au sommet. Dans la phase de remontée, les contextes sont dépilés et restaurés pour la fin de l'exécution de chaque appel de fonction, jusqu'à ce que la pile soit vide.
Les contextes des appels récursifs sont sauvegardés dans une pile d'appels.
On donne une trace de la pile d'appels lors de l'évaluation de somme_rec(4)
.
Exemple de pile d'appels
Appel de somme_rec(4) => descente
État de la pile :
|Contexte de somme_rec(4,)|
|-------------------------|
Appel de somme_rec(3) => descente
État de la pile :
|Contexte de somme_rec(3,)|
|-------------------------|
|Contexte de somme_rec(4,)|
|-------------------------|
Appel de somme_rec(2) => descente
État de la pile :
|Contexte de somme_rec(2,)|
|-------------------------|
|Contexte de somme_rec(3,)|
|-------------------------|
|Contexte de somme_rec(4,)|
|-------------------------|
Appel de somme_rec(1) => descente
État de la pile :
|Contexte de somme_rec(1,)|
|-------------------------|
|Contexte de somme_rec(2,)|
|-------------------------|
|Contexte de somme_rec(3,)|
|-------------------------|
|Contexte de somme_rec(4,)|
|-------------------------|
Fin de somme_rec(1) <= remontée
État de la pile :
|Contexte de somme_rec(1,)|
|-------------------------|
|Contexte de somme_rec(2,)|
|-------------------------|
|Contexte de somme_rec(3,)|
|-------------------------|
|Contexte de somme_rec(4,)|
|-------------------------|
Fin de somme_rec(2) <= remontée
État de la pile :
|Contexte de somme_rec(2,)|
|-------------------------|
|Contexte de somme_rec(3,)|
|-------------------------|
|Contexte de somme_rec(4,)|
|-------------------------|
Fin de somme_rec(3) <= remontée
État de la pile :
|Contexte de somme_rec(3,)|
|-------------------------|
|Contexte de somme_rec(4,)|
|-------------------------|
Fin de somme_rec(4) <= remontée
État de la pile :
|Contexte de somme_rec(4,)|
|-------------------------|
Chaque appel récursif consomme de la mémoire dans la pile d'appels. Une boucle réalisant le même nombre de répétitions n'a pas besoin d'enregistrer des états antérieurs pour y revenir. Une version récursive d'un algorithme est donc en général plus gourmande en mémoire que la version itérative, c'est pourquoi les langages de programmation comme Python prévoient un mécanisme de limitation de la taille de la pile d'appels. On peut la lire et la modifier avec des
getter
etsetter
.
>>> import sys
>>> sys.getrecursionlimit() # lecture de la taille pile d'appels
1000
>>> sys.setrecursionlimit(3000) # modification de sa taille
>>> sys.getrecursionlimit()
3000
-
Dans ce navigateur, Python est émulé avec Javascript et la limite de la taille de la pile est encore plus petite et ne permet pas des appels récursifs très profonds. La technologie utilisée est basée sur Pyodide, voir Pyodide-mkdocs. ↩
# Tests
(insensible à la casse)(Ctrl+I)