Chapitre 1213 minDébutant
Pour aller plus loin : récursivité et complexité
Par Josselyn C., prof de BTS SIO. Mis à jour le .
À la fin de ce chapitre, tu sauras
- écrire une fonction récursive avec son cas de base ;
- suivre la pile d'appels d'une fonction récursive ;
- choisir entre version récursive et version itérative ;
- estimer la complexité d'un algorithme avec la notation O.
4 exercices corrigés1 schéma
Une fonction récursive est une fonction qui s'appelle elle-même sur un problème plus petit, jusqu'à un cas simple résolu directement ; la complexité d'un algorithme mesure comment son temps d'exécution (ou sa mémoire) augmente avec la taille des données. Ces deux notions sont hors du programme de l'épreuve E3 du BTS SIO, mais elles servent en SLAM et dans toute poursuite d'études : ce chapitre en donne l'essentiel, avec des exemples en Python.
Une fonction qui s'appelle elle-même
La factorielle de n vaut n! = n × (n − 1) × … × 1. On remarque que n! = n × (n − 1)! : pour calculer 5!, il suffit de savoir calculer 4!, puis de multiplier par 5. Et 1! vaut 1, sans calcul. C'est une définition récursive : elle se sert d'elle-même, sur un nombre plus petit.
Fonction Factorielle(n : entier) : entier
// Précondition : n ≥ 1
Si n = 1 Alors
Retourner 1 // cas de base
Sinon
Retourner n × Factorielle(n - 1) // appel récursif
FinSi
FinFonction
def factorielle(n):
if n <= 1:
return 1 # cas de base
return n * factorielle(n - 1) # appel récursif
print(factorielle(5), factorielle(20))
120 2432902008176640000
Toute fonction récursive a deux parties, et il en faut absolument deux :
- Un cas de base, résolu sans appel récursif. Le programme dit « nécessité d'un test » et « nécessité de cas particuliers résolus sans appel à la récursivité ».
- Un appel récursif sur un problème plus petit, qui se rapproche du cas de base. C'est ce qui garantit la finitude.
La pile d'appels
Quand factorielle(4) appelle factorielle(3), le premier appel n'est pas fini : il attend le résultat pour faire sa multiplication. Python garde chaque appel en attente, avec ses variables locales, sur une pile d'appels. Les appels s'empilent jusqu'au cas de base, puis se dépilent en renvoyant leurs résultats.
Cette pile a une taille limitée. Par défaut, Python refuse d'empiler plus de 1 000 appels environ : factorielle(5000) lève RecursionError: maximum recursion depth exceeded. Une fonction récursive sans cas de base (ou dont l'appel ne se rapproche pas du cas de base) provoque la même erreur : c'est l'équivalent récursif de la boucle infinie.
Récursif ou itératif ?
Tout algorithme récursif peut s'écrire avec une boucle, et inversement. La factorielle itérative est le produit du chapitre 4. Le programme de maths cite d'ailleurs la « conversion en algorithme non récursif ».
| Récursif | Itératif | |
|---|---|---|
| Lisibilité | Très naturel quand le problème est lui-même récursif (arbres, dossiers, expressions) | Plus naturel pour un simple parcours |
| Mémoire | Un étage de pile par appel en cours | Quelques variables |
| Limite | Profondeur maximale (environ 1 000 en Python) | Aucune |
| Risque | Oubli du cas de base | Boucle infinie |
La récursivité brille quand les données sont imbriquées. Une arborescence de dossiers contient des fichiers et des sous-dossiers, qui contiennent eux-mêmes des fichiers et des sous-dossiers… Calculer sa taille totale avec des boucles est pénible ; en récursif, c'est quatre lignes :
def taille(element):
"""Taille d'un fichier (un entier, en Ko) ou d'un dossier (un dictionnaire)."""
if isinstance(element, int): # cas de base : un fichier
return element
total = 0
for contenu in element.values(): # un dossier : la somme de ses contenus
total = total + taille(contenu)
return total
partage = {
"compta": {"bilan.xlsx": 820, "factures": {"f1.pdf": 120, "f2.pdf": 95}},
"rh": {"contrats": {"modele.docx": 45}},
"lisezmoi.txt": 2,
}
print(taille(partage), "Ko")
1082 Ko
isinstance(element, int) teste si l'élément est un entier. La fonction ne sait pas à l'avance combien de niveaux de dossiers il y a : chaque appel traite un niveau et délègue les sous-dossiers à d'autres appels.
Attention en revanche aux récursions qui recalculent la même chose. La suite de Fibonacci écrite naïvement, fib(n) = fib(n - 1) + fib(n - 2), fait 21 891 appels pour fib(20) et 2 692 537 pour fib(30), car fib(28) est calculé deux fois, fib(27) trois fois, et ainsi de suite. La version itérative du chapitre 4 fait 30 tours de boucle.
La complexité d'un algorithme
Deux programmes peuvent donner le même résultat avec des temps très différents. Mesurer le temps en secondes dépend de la machine ; on compte plutôt le nombre d'opérations significatives (comparaisons, affectations, tours de boucle) en fonction de la taille n des données. C'est ce que tu as fait pour les tris.
On s'intéresse surtout au pire cas, qui donne une garantie, et à la façon dont le coût grandit quand n grandit, pas au nombre exact d'opérations.
La notation O
La notation O (« grand O ») décrit l'ordre de grandeur du nombre d'opérations, en ignorant les constantes et les termes secondaires : n(n − 1)/2 comparaisons s'écrit O(n²), 3n + 2 s'écrit O(n).
| Notation | Nom | Exemple du cours | n = 1 000 | n = 1 000 000 |
|---|---|---|---|---|
| O(1) | Constante | Accéder à t[i], tester la parité | 1 | 1 |
| O(log n) | Logarithmique | Recherche dichotomique | ~10 | ~20 |
| O(n) | Linéaire | Somme, maximum, recherche séquentielle | 1 000 | 1 000 000 |
| O(n log n) | Quasi linéaire | sorted de Python, tri fusion | ~10 000 | ~20 000 000 |
| O(n²) | Quadratique | Tris par sélection, à bulles, par insertion | 1 000 000 | 1 000 000 000 000 |
| O(2ⁿ) | Exponentielle | Fibonacci récursif naïf | inutilisable | inutilisable |
À raison de cent millions d'opérations par seconde (un ordre de grandeur pour un programme simple), O(n²) sur un million d'éléments représente plus de deux heures et demie de calcul ; O(n log n), une fraction de seconde. Le choix de l'algorithme compte bien plus que la vitesse de la machine.
Pour estimer la complexité d'un algorithme, trois règles suffisent :
- des instructions sans boucle coûtent O(1) ;
- une boucle de n tours qui contient un corps en O(1) coûte O(n) ; deux boucles imbriquées de n tours coûtent O(n²) (on multiplie) ; deux boucles successives coûtent O(n) + O(n) = O(n) (on additionne, et on garde le plus grand terme) ;
- une boucle qui divise la taille par 2 à chaque tour coûte O(log n).
On parle aussi de complexité en mémoire (ou spatiale) : la place occupée en plus des données. Trier sur place coûte O(1) en mémoire ; construire une nouvelle liste de n éléments, O(n) ; une récursion de profondeur n, O(n) pour la pile d'appels.
Exercices
Exercice 1 sur 4 Somme des chiffres en récursif Réussi À revoir
Écris une fonction récursive somme_chiffres(n) qui renvoie la somme des chiffres d'un entier n ≥ 0 (2026 donne 2 + 0 + 2 + 6 = 10). Indique le cas de base et l'appel récursif.
Voir la réponseMasquer la réponse de l'exercice 1
La somme des chiffres de n est le dernier chiffre (n % 10) plus la somme des chiffres des autres (n // 10). Un nombre d'un seul chiffre est sa propre somme.
def somme_chiffres(n):
if n < 10: # cas de base
return n
return n % 10 + somme_chiffres(n // 10) # appel sur un nombre plus petit
print(somme_chiffres(2026))
Résultat : 10. Chaque appel enlève un chiffre : le nombre d'appels est le nombre de chiffres de n.
Tu avais trouvé ?
Exercice 2 sur 4 Trace de la pile Réussi À revoir
Écris la suite des appels et des valeurs renvoyées pour puissance(2, 3).
def puissance(x, n):
if n == 0:
return 1
return x * puissance(x, n - 1)
Voir la réponseMasquer la réponse de l'exercice 2
Descente : puissance(2, 3) appelle puissance(2, 2), qui appelle puissance(2, 1), qui appelle puissance(2, 0).
Remontée : puissance(2, 0) renvoie 1 (cas de base) ; puissance(2, 1) renvoie 2 × 1 = 2 ; puissance(2, 2) renvoie 2 × 2 = 4 ; puissance(2, 3) renvoie 2 × 4 = 8.
Quatre appels sont empilés au plus fort de l'exécution.
Tu avais trouvé ?
Exercice 3 sur 4 Quelle complexité ? Réussi À revoir
Donne la complexité en fonction de n de chaque extrait.
# A
total = 0
for i in range(n):
total = total + i
for j in range(n):
total = total - j
# B
nb = 0
for i in range(n):
for j in range(i):
nb = nb + 1
# C
k = n
while k > 1:
k = k // 2
# D
x = t[0] + t[n - 1]
Voir la réponseMasquer la réponse de l'exercice 3
- A : deux boucles successives de n tours : 2n opérations, soit O(n).
- B : boucles imbriquées dont l'interne fait i tours : 0 + 1 + … + (n − 1) = n(n − 1)/2 tours, soit O(n²).
- C : k est divisé par 2 à chaque tour : environ log₂(n) tours, O(log n).
- D : deux accès et une addition, quelle que soit la taille : O(1).
Tu avais trouvé ?
Exercice 4 sur 4 Recherche dichotomique récursive Réussi À revoir
Écris une version récursive de la recherche dichotomique : dicho_rec(t, x, deb, fin) cherche x dans t[deb..fin]. Quel est le cas de base ?
Voir la réponseMasquer la réponse de l'exercice 4
Deux cas de base : la zone est vide (deb > fin, on renvoie −1), ou l'élément du milieu est x (on renvoie son indice). Sinon, on relance la recherche sur la bonne moitié.
def dicho_rec(t, x, deb, fin):
if deb > fin:
return -1
milieu = (deb + fin) // 2
if t[milieu] == x:
return milieu
if t[milieu] < x:
return dicho_rec(t, x, milieu + 1, fin)
return dicho_rec(t, x, deb, milieu - 1)
t = [3, 8, 15, 23, 42, 56, 91]
print(dicho_rec(t, 42, 0, len(t) - 1), dicho_rec(t, 10, 0, len(t) - 1))
Résultat : 4 -1. La profondeur de la récursion est d'environ log₂(n) : 20 appels pour un million d'éléments, bien en dessous de la limite de Python.
Tu avais trouvé ?