354 lines
12 KiB
Markdown
354 lines
12 KiB
Markdown
# Récursivité
|
||
|
||
|
||
|
||
### Le programme
|
||
|
||
|
||
|
||

|
||
|
||
|
||
|
||
> La récursivité est un style de programmation permettant de simplifier l'écriture de nombreux problèmes.
|
||
|
||
|
||
|
||
### Un peu de culture
|
||
|
||
La récursivité, en informatique, est une pratique qui permet à une fonction de s'appeler elle-même.
|
||
On peut comparer cela à la [mise en abyme](https://fr.wikipedia.org/wiki/Mise_en_abyme), procédé littéraire qui décrit une œuvre incrustée dans elle-même.
|
||
|
||
<img src="assets/vache_qui_rit.gif" alt="vache qui rit" style="zoom: 67%;" />
|
||
|
||
**La fonction récursive s'appelle elle-même dans sa définition.**
|
||
|
||
-------------------------------
|
||
|
||
### Mauvais exemple
|
||
|
||
|
||
|
||
Rien de tel qu'un mauvais usage pour mieux comprendre un concept.
|
||
|
||
Voici une fonction récursive :
|
||
|
||
```python
|
||
def fct_recursive() :
|
||
print("Je fais un appel de la fonction")
|
||
fct_recursive()
|
||
```
|
||
|
||
Cette fonction s'appelle elle-même, mais elle présente un problème.
|
||
|
||
Si nous l'appelons, le résultat sera celui-ci :
|
||
|
||
```python
|
||
>>> fct_recursive()
|
||
"Je fais un appel de la fonction"
|
||
"Je fais un appel de la fonction"
|
||
"Je fais un appel de la fonction"
|
||
"Je fais un appel de la fonction"
|
||
"Je fais un appel de la fonction"
|
||
"Je fais un appel de la fonction"
|
||
"Je fais un appel de la fonction"
|
||
...
|
||
```
|
||
|
||
Tout comme les boucles, il faut créer une instruction permettant d'arrêter l'exécution du code, en récursif nous l'appelons **le cas de base** (ou cas d'arrêt). Il permettra d'éviter les appels **infinis**.
|
||
|
||
|
||
|
||
### Le cas de base
|
||
|
||
Afin de stopper l'exécution du code il est donc essentiel de créer une instruction n'appelant pas notre fonction.
|
||
|
||
Par exemple :
|
||
|
||
Voici la fonction somme(n) se définissant comme :
|
||
|
||
$$
|
||
somme(n) = \begin{cases}
|
||
0 & \text{si } n = 0 \text{ (cas de base)}\\
|
||
n + somme(n-1) & \text{sinon (cas récursif)}
|
||
\end{cases}
|
||
$$
|
||
|
||
```python
|
||
def somme(n) :
|
||
if n == 0 :
|
||
return 0 # Cas de base
|
||
else :
|
||
return n + somme(n-1) # Cas récursif
|
||
```
|
||
|
||
Cette fonction s'appelle donc elle-même jusqu'à avoir n égal à 0.
|
||
|
||
Mais quel sera le résultat de *somme(3)* ?
|
||
|
||
Pour obtenir cette réponse nous allons dérouler *à la main* le code :
|
||
|
||
```
|
||
somme(3) = 3 + somme(2)
|
||
somme(2) = 2 + somme(1)
|
||
somme(1) = 1 + somme(0)
|
||
somme(0) = 0
|
||
```
|
||
|
||
Voici les différents appels de fonctions pour somme(3). Mais là, le résultat n'est pas encore obtenu.
|
||
|
||
Pour cela il faut reprendre les appels de fonctions pour revenir jusqu'à somme(3) :
|
||
|
||
```
|
||
somme(0) = 0
|
||
somme(1) = 1 + 0 = 1
|
||
somme(2) = 2 + 1 = 3
|
||
somme(3) = 3 + 3 = 6
|
||
```
|
||
|
||
Ici nous observons donc 4 appels de fonctions pour résoudre somme(3).
|
||
|
||
-----------
|
||
|
||
### La pile d'appels
|
||
|
||
Lorsqu'une fonction récursive s'exécute, Python utilise une structure de données appelée **pile d'appels** (ou *call stack*) pour gérer les différents appels.
|
||
|
||
#### Principe de fonctionnement
|
||
|
||
À chaque appel de fonction :
|
||
1. Python **empile** un nouveau *contexte d'exécution* contenant les paramètres et variables locales
|
||
2. Lorsque le cas de base est atteint, les appels sont **dépilés** un par un
|
||
3. Chaque résultat remonte vers l'appel précédent
|
||
|
||
#### Visualisation avec somme(3)
|
||
|
||
```
|
||
EMPILEMENT DÉPILEMENT
|
||
(descente) (remontée)
|
||
|
||
┌─────────────┐ ┌─────────────┐
|
||
│ somme(0) │ ← cas de base │ somme(0) │ → retourne 0
|
||
├─────────────┤ ├─────────────┤
|
||
│ somme(1) │ attend somme(0) │ somme(1) │ → retourne 1+0 = 1
|
||
├─────────────┤ ├─────────────┤
|
||
│ somme(2) │ attend somme(1) │ somme(2) │ → retourne 2+1 = 3
|
||
├─────────────┤ ├─────────────┤
|
||
│ somme(3) │ attend somme(2) │ somme(3) │ → retourne 3+3 = 6
|
||
└─────────────┘ └─────────────┘
|
||
BASE BASE
|
||
```
|
||
|
||
> La pile fonctionne selon le principe **LIFO** (Last In, First Out) : le dernier appel empilé est le premier à être résolu.
|
||
|
||
-----------
|
||
|
||
### Limite de récursion en Python
|
||
|
||
En Python il existe une limite au nombre d'appels de fonction que l'on peut faire pour une fonction récursive.
|
||
|
||
En effet, avec notre exemple nous voyons que pour obtenir somme(3) nous faisons 4 appels de fonction.
|
||
|
||
Ces appels de fonction **s'empilent** en espace mémoire. Python peut stocker environ **1000 appels** par défaut.
|
||
|
||
Si cette limite est dépassée alors l'erreur **RecursionError** apparaîtra :
|
||
|
||
```python
|
||
>>> somme(1001)
|
||
...
|
||
RecursionError: maximum recursion depth exceeded in comparison
|
||
```
|
||
|
||
> Cette limite existe pour protéger la mémoire de l'ordinateur. On peut la modifier avec `sys.setrecursionlimit()`, mais ce n'est généralement pas recommandé.
|
||
|
||
-----------
|
||
|
||
### Terminaison d'une fonction récursive
|
||
|
||
Une question essentielle se pose : **comment être sûr qu'une fonction récursive s'arrête ?**
|
||
|
||
#### Le variant de boucle
|
||
|
||
Pour prouver qu'une fonction récursive termine, on utilise un **variant** : une quantité entière qui :
|
||
- est **positive ou nulle** à chaque appel
|
||
- **décroît strictement** à chaque appel récursif
|
||
|
||
Quand le variant atteint 0 (ou une valeur minimale), on est dans le cas de base.
|
||
|
||
#### Exemple avec somme(n)
|
||
|
||
Pour la fonction `somme(n)` :
|
||
- **Variant** : la valeur de `n`
|
||
- À chaque appel récursif, on appelle `somme(n-1)`, donc `n` décroît de 1
|
||
- Quand `n = 0`, on atteint le cas de base
|
||
|
||
| Appel | Valeur du variant (n) |
|
||
|-------|----------------------|
|
||
| somme(3) | 3 |
|
||
| somme(2) | 2 |
|
||
| somme(1) | 1 |
|
||
| somme(0) | 0 → cas de base |
|
||
|
||
Le variant décroît strictement et atteint 0 : **la fonction termine**.
|
||
|
||
#### Attention aux erreurs
|
||
|
||
```python
|
||
def mauvaise_somme(n):
|
||
if n == 0:
|
||
return 0
|
||
else:
|
||
return n + mauvaise_somme(n + 1) # n augmente !
|
||
```
|
||
|
||
Ici, le paramètre **augmente** au lieu de diminuer : la fonction ne terminera jamais (jusqu'à la `RecursionError`).
|
||
|
||
-----------
|
||
|
||
### Écrire une fonction récursive : méthodologie
|
||
|
||
Pour écrire correctement une fonction récursive, il faut identifier **trois éléments** :
|
||
|
||
| Élément | Question à se poser | Exemple avec `somme(n)` |
|
||
|---------|---------------------|------------------------|
|
||
| **Cas de base** | Quel est le cas le plus simple, qui ne nécessite pas d'appel récursif ? | `n == 0` → retourne `0` |
|
||
| **Cas récursif** | Comment décomposer le problème en un sous-problème plus petit ? | `somme(n) = n + somme(n-1)` |
|
||
| **Variant** | Quelle quantité décroît à chaque appel ? | La valeur de `n` |
|
||
|
||
#### Exemple : la factorielle
|
||
|
||
On souhaite écrire `factorielle(n)` qui calcule `n! = n × (n-1) × ... × 2 × 1`.
|
||
|
||
1. **Cas de base** : `factorielle(0) = 1` (par convention, 0! = 1)
|
||
2. **Cas récursif** : `factorielle(n) = n × factorielle(n-1)`
|
||
3. **Variant** : `n` décroît de 1 à chaque appel
|
||
|
||
```python
|
||
def factorielle(n):
|
||
if n == 0:
|
||
return 1 # Cas de base
|
||
else:
|
||
return n * factorielle(n - 1) # Cas récursif
|
||
```
|
||
|
||
Vérification :
|
||
```
|
||
factorielle(4) = 4 × factorielle(3)
|
||
= 4 × 3 × factorielle(2)
|
||
= 4 × 3 × 2 × factorielle(1)
|
||
= 4 × 3 × 2 × 1 × factorielle(0)
|
||
= 4 × 3 × 2 × 1 × 1
|
||
= 24
|
||
```
|
||
|
||
-----------
|
||
|
||
### Récursif vs Itératif
|
||
|
||
Toute fonction récursive peut être réécrite de manière **itérative** (avec des boucles), et inversement.
|
||
|
||
#### Comparaison sur l'exemple de la somme
|
||
|
||
**Version récursive :**
|
||
```python
|
||
def somme_recursive(n):
|
||
if n == 0:
|
||
return 0
|
||
else:
|
||
return n + somme_recursive(n - 1)
|
||
```
|
||
|
||
**Version itérative :**
|
||
```python
|
||
def somme_iterative(n):
|
||
resultat = 0
|
||
for i in range(1, n + 1):
|
||
resultat += i
|
||
return resultat
|
||
```
|
||
|
||
#### Avantages et inconvénients
|
||
|
||
| Critère | Récursif | Itératif |
|
||
|---------|----------|----------|
|
||
| **Lisibilité** | Souvent plus élégant et proche de la définition mathématique | Peut être plus verbeux |
|
||
| **Mémoire** | Utilise la pile d'appels (risque de débordement) | Utilise peu de mémoire |
|
||
| **Performance** | Peut être plus lent (overhead des appels) | Généralement plus rapide |
|
||
| **Cas d'usage** | Structures récursives (arbres, graphes), diviser pour régner | Calculs simples, itérations |
|
||
|
||
> En pratique, on choisit la récursivité quand elle rend le code **plus clair** ou quand le problème est **naturellement récursif** (parcours d'arbres, fractales, etc.).
|
||
|
||
-----------
|
||
|
||
### Autres types de récursivité
|
||
|
||
Il existe divers types de récursivité :
|
||
|
||
- La **récursivité simple** : un seul appel récursif (comme `somme`)
|
||
- La **récursivité multiple** : plusieurs appels récursifs dans la fonction
|
||
- La **récursivité mutuelle** : deux fonctions qui s'appellent mutuellement
|
||
- La **récursivité terminale** : l'appel récursif est la dernière opération
|
||
|
||
Ces différents types de récursivité sont quelque peu différents de ce que l'on vient de voir. Mais nous pourrions les rencontrer dans la suite du programme.
|
||
|
||
### Récursivité multiple : la suite de Fibonacci
|
||
|
||
À la différence de la récursivité dite **simple**, il y aura ici **plusieurs appels de fonctions** dans une même instruction.
|
||
|
||
> En mathématiques, la suite de Fibonacci est une suite de nombres entiers dont chaque terme successif représente la somme des deux termes précédents, et qui commence par 0 puis 1. Ainsi, les dix premiers termes qui la composent sont 0, 1, 1, 2, 3, 5, 8, 13, 21 et 34.
|
||
|
||
$$
|
||
fib(n) = \begin{cases}
|
||
0 & \text{si } n = 0\\
|
||
1 & \text{si } n = 1\\
|
||
fib(n-1) + fib(n-2) & \text{sinon}
|
||
\end{cases}
|
||
$$
|
||
|
||
```python
|
||
def fibonacci(n):
|
||
if n == 0:
|
||
return 0
|
||
elif n == 1:
|
||
return 1
|
||
else:
|
||
return fibonacci(n-1) + fibonacci(n-2)
|
||
```
|
||
|
||
#### Arbre des appels pour fibonacci(4)
|
||
|
||
```
|
||
fibonacci(4)
|
||
/ \
|
||
fibonacci(3) fibonacci(2)
|
||
/ \ / \
|
||
fibonacci(2) fibonacci(1) fibonacci(1) fibonacci(0)
|
||
/ \ | | |
|
||
fibonacci(1) fibonacci(0) 1 1 0
|
||
| |
|
||
1 0
|
||
```
|
||
|
||
On remarque que `fibonacci(2)` est calculé **deux fois**, et `fibonacci(1)` **trois fois**. Cette redondance rend l'algorithme très inefficace pour de grandes valeurs de `n`.
|
||
|
||
> Ce problème sera résolu grâce à la **programmation dynamique**, que nous verrons dans un prochain chapitre.
|
||
|
||
-----------------
|
||
|
||
### À retenir
|
||
|
||
- Une fonction **récursive** s'appelle elle-même
|
||
- Elle doit avoir un **cas de base** pour s'arrêter
|
||
- On prouve la **terminaison** avec un **variant** (quantité qui décroît)
|
||
- Les appels s'empilent dans la **pile d'appels**
|
||
- Python limite le nombre d'appels récursifs (~1000)
|
||
- Récursif ≠ toujours meilleur : choisir selon le problème
|
||
|
||
-----------------
|
||
|
||
Auteurs : Florian Mathieu, Timothée Decoster, Enzo Frémeaux
|
||
|
||
Licence CC BY NC
|
||
|
||
<a rel="license" href="http://creativecommons.org/licenses/by-nc-sa/4.0/"><img alt="Licence Creative Commons" style="border-width:0" src="https://i.creativecommons.org/l/by-nc-sa/4.0/88x31.png" /></a> <br />Ce cours est mis à disposition selon les termes de la <a rel="license" href="http://creativecommons.org/licenses/by-nc-sa/4.0/">Licence Creative Commons Attribution - Pas d'Utilisation Commerciale - Partage dans les Mêmes Conditions 4.0 International</a>.
|