Fonctions récursives
Cours complet · informatique (tronc commun des prépas scientifiques), chapitre 6 · prépas scientifiques, tronc commun
Travailler ce chapitre sur Adloun Exercices corrigés de ce chapitre
<i class="fa-solid fa-compass mr-2" style="color:#9A563B"></i>6.1 Introduction et motivation
Pour décrire un escalier, deux options : « une suite de marches » — c'est la boucle — ou bien « une marche, suivie d'un escalier plus petit » — c'est la récursivité. Une fonction récursive est une fonction qui s'appelle elle-même sur une entrée plus petite, jusqu'à atteindre un cas si simple qu'il se résout sans appel. Loin d'être un gadget, c'est une seconde manière de penser les algorithmes, souvent plus proche de la définition mathématique du problème : la dichotomie du chapitre 5 (« chercher dans la moitié »), les figures qui se contiennent elles-mêmes (les fractales), l'énumération de tous les sous-ensembles ou de toutes les permutations — autant de problèmes dont la solution récursive tient en quelques lignes là où la version à boucles demanderait des contorsions.
Le programme met en garde : on ne se cantonne pas aux suites mathématiques (factorielle et compagnie), qui font croire que la récursivité n'est qu'une boucle déguisée. On l'emploie là où elle brille — et on apprend son coût caché : chaque appel empile un contexte, et la pile d'appels peut déborder. Comme toujours, les outils du chapitre 1 suivent : la terminaison d'une récursion est un variant sur l'argument, sa correction une récurrence.
6.2 Le principe
6.2.1 Cas de base, cas récursif
Une fonction est récursive si son corps contient un appel à elle-même. Toute définition récursive correcte comporte :
- un (ou plusieurs) cas de base : des entrées résolues directement, sans appel récursif ;
- un (ou plusieurs) cas récursifs : la solution s'exprime à partir de la solution du même problème sur une entrée strictement plus petite.
def somme_jusqua(n: int) -> int:
"""Renvoie 0 + 1 + ... + n. Précondition : n >= 0."""
if n == 0: # cas de base
return 0
return n + somme_jusqua(n - 1) # cas récursif : le problème en taille n - 1
Méthode : Concevoir une fonction récursive
Trois questions, toujours les mêmes :
- Le cas de base : pour quelles entrées la réponse est-elle immédiate ? (Souvent : , liste vide, chaîne vide.)
- La décomposition : si quelqu'un me donnait la solution pour une entrée plus petite — , la moitié du tableau, la liste privée de sa tête — comment en déduirais-je la mienne ?
- La décroissance : chaque appel récursif rapproche-t-il strictement d'un cas de base ?
Le point 2 est le saut conceptuel : on fait confiance à l'appel récursif, comme on fait confiance à l'hypothèse de récurrence en mathématiques — sans chercher à dérouler mentalement les appels.
6.2.2 Ce que fait vraiment la machine : la pile d'appels
À chaque appel de fonction, l'interpréteur empile un contexte (les paramètres et variables locales de cet appel) ; au return, le contexte est dépilé et l'exécution reprend chez l'appelant. Pour somme_jusqua(3) :
somme_jusqua(3) en attente de somme_jusqua(2)
somme_jusqua(2) en attente de somme_jusqua(1)
somme_jusqua(1) en attente de somme_jusqua(0)
somme_jusqua(0) -> 0 cas de base : on redescend
-> 1 + 0 = 1
-> 2 + 1 = 3
-> 3 + 3 = 6
La descente empile, le cas de base fait demi-tour, la remontée calcule. À tout instant, la pile contient la chaîne des appels en attente — sa hauteur maximale est la profondeur de récursion.
La pile n'est pas infinie : Python limite la profondeur à environ appels. somme_jusqua(10**5) lève RecursionError: maximum recursion depth exceeded — et une récursion sans cas de base atteignable (le bogue récursif par excellence) lève la même erreur après une seconde de silence :
def perdu(n):
return perdu(n - 1) # aucun cas de base : 1000 appels, puis RecursionError
Règle pratique : une profondeur en (dichotomies) ne pose jamais problème ( niveaux pour ) ; une profondeur en interdit les grandes entrées — on convertira alors en boucle.
6.3 Prouver une fonction récursive
Méthode : Terminaison et correction
Les outils du chapitre 1 se transposent :
- Terminaison : exhiber un variant — une quantité entière positive qui décroît strictement à chaque appel récursif ( pour
somme_jusqua, la largeur de fenêtre pour la dichotomie). Une suite strictement décroissante d'entiers positifs est finie : la récursion atteint un cas de base. - Correction : raisonner par récurrence forte sur le variant — « si la fonction est correcte pour toutes les entrées plus petites, alors elle l'est pour l'entrée courante » ; les cas de base sont l'initialisation.
La récurrence mathématique n'est pas une analogie : c'est exactement la preuve — la récursivité est la récurrence rendue exécutable.
Terminaison : l'argument est un variant — entier, positif (précondition, conservée car on n'appelle qu'avec quand ), strictement décroissant d'un appel au suivant. ✓
Correction, par récurrence sur . Base : pour , la fonction renvoie , qui est bien la somme vide. Hérédité : soit ; supposons somme_jusqua() correcte, c'est-à-dire égale à . Alors la fonction renvoie . ✓ (La confiance du point 2 de la méthode est ici justifiée a posteriori : c'est l'hypothèse de récurrence.)
6.4 Les dichotomies, version récursive
La structure « comparer au milieu, recommencer dans une moitié » est récursive par nature :
def dichotomie_rec(v, t: list, g: int, d: int):
"""Renvoie i dans [g, d] tel que t[i] == v, ou None.
Préconditions : t trié croissant ; 0 <= g et d <= len(t) - 1."""
if g > d: # cas de base 1 : fenêtre vide
return None
m = (g + d) // 2
if t[m] == v: # cas de base 2 : trouvé
return m
elif t[m] < v:
return dichotomie_rec(v, t, m + 1, d)
else:
return dichotomie_rec(v, t, g, m - 1)
def dichotomie(v, t: list):
return dichotomie_rec(v, t, 0, len(t) - 1)
La fenêtre , qui était l'état de la boucle au chapitre 5, devient les paramètres de l'appel ; la fonction d'enrobage dichotomie cache cette cuisine à l'utilisateur. Variant : , identique à la version itérative ; profondeur de pile : — inoffensive même pour .
La définition par parité du chapitre 5 se transcrit littéralement :
def puissance(x: float, n: int) -> float:
"""Renvoie x**n. Précondition : n >= 0."""
if n == 0:
return 1
y = puissance(x, n // 2) # UN SEUL appel récursif...
if n % 2 == 0:
return y * y # ... dont le résultat sert deux fois
return x * y * y
Le piège classique est d'écrire puissance(x, n // 2) puissance(x, n // 2) : les deux appels recalculent la même chose, et le nombre total d'appels explose de à — tout le bénéfice est perdu. Mémoriser le résultat dans y restaure le coût logarithmique. (Compter les appels avant de se réjouir d'une récursion élégante : l'arbre des appels peut être un chemin… ou un buisson.)*
6.5 Figures et fractales
6.5.1 Figures alphanumériques
Afficher un triangle de lignes d'étoiles, c'est afficher un triangle de lignes, puis une ligne de étoiles :
def triangle(n: int) -> None:
if n == 0:
return
triangle(n - 1)
print("*" * n)
triangle(4)
# *
# **
# ***
# ****
Échanger les deux lignes du cas récursif (print avant l'appel) renverse le triangle : l'ordre entre le travail local et l'appel récursif est un paramètre de conception à part entière — avant l'appel, pendant la descente ; après l'appel, pendant la remontée.
def sablier(n: int) -> None:
if n == 0:
return
print("*" * n) # à la descente : lignes décroissantes
sablier(n - 1)
print("*" * n) # à la remontée : lignes croissantes
sablier(3)
# ***
# **
# *
# *
# **
# ***
La symétrie du dessin est exactement la symétrie descente-remontée de la pile : ce que la récursion empile dans un ordre, elle le dépile dans l'ordre inverse.
6.5.2 Fractales
Une figure est fractale (du latin fractus, brisé) lorsqu'elle se compose de copies réduites d'elle-même. La récursivité est son langage naturel : « la courbe de niveau est faite de quatre courbes de niveau » — le cas de base étant un simple trait.
Avec le module turtle (la « tortue » trace ce qu'on lui ordonne : avancer, tourner), la courbe de Koch remplace chaque segment par quatre segments trois fois plus courts, en formant une pointe :
import turtle
def koch(longueur: float, n: int) -> None:
"""Trace la courbe de Koch de niveau n sur la longueur donnée."""
if n == 0:
turtle.forward(longueur) # cas de base : un trait
return
koch(longueur / 3, n - 1)
turtle.left(60)
koch(longueur / 3, n - 1)
turtle.right(120)
koch(longueur / 3, n - 1)
turtle.left(60)
koch(longueur / 3, n - 1)
for _ in range(3): # trois côtés : le flocon de Koch
koch(300, 4)
turtle.right(120)
Au niveau , la tortue trace segments de longueur : la longueur totale tend vers l'infini alors que la figure reste bornée — le périmètre infini d'une surface finie, paradoxe que le cours de mathématiques retrouvera avec les séries géométriques. Le nombre d'appels, , est exponentiel : niveau ou , pas davantage, sous peine d'attendre longtemps.
La profondeur de pile de koch n'est que — minuscule. Coût exponentiel et pile profonde sont deux choses indépendantes : l'arbre des appels de Koch est large ( feuilles) mais peu profond ( niveaux) ; somme_jusqua est l'inverse. Toujours distinguer le nombre total d'appels (le temps) de la profondeur (la pile).
6.6 Énumérations
6.6.1 Toutes les sous-listes
Chaque sous-liste de contient le premier élément, ou pas — et le reste est une sous-liste de la queue :
def sous_listes(t: list) -> list:
"""Renvoie la liste des 2**len(t) sous-listes de t."""
if t == []:
return [[]] # une seule sous-liste : la vide
sans = sous_listes(t[1:]) # celles qui ne prennent pas t[0]
avec = [[t[0]] + s for s in sans] # celles qui le prennent
return sans + avec
assert sous_listes([1, 2]) == [[], [2], [1], [1, 2]]
assert len(sous_listes([1, 2, 3, 4, 5])) == 32
Correction par récurrence : toute sous-liste de soit omet (elle est alors sous-liste de , comptée dans sans), soit le contient (elle s'écrit suivi d'une sous-liste de la queue : avec) — et les deux familles sont disjointes. Dénombrement : et , donc sous-listes — le résultat est exponentiel par nature : aucun algorithme ne peut faire mieux, puisqu'il faut bien écrire les réponses.
6.6.2 Toutes les permutations
Une permutation de commence par l'un quelconque des éléments, suivi d'une permutation des autres :
def permutations(t: list) -> list:
"""Renvoie la liste des len(t)! permutations de t (éléments distincts)."""
if t == []:
return [[]]
resultat = []
for i in range(len(t)):
reste = t[:i] + t[i+1:] # t privé de son i-ième élément
for p in permutations(reste):
resultat.append([t[i]] + p)
return resultat
assert permutations([1, 2, 3]) == [[1, 2, 3], [1, 3, 2], [2, 1, 3],
[2, 3, 1], [3, 1, 2], [3, 2, 1]]
Dénombrement : choix de tête, puis permutations du reste — soit au total, conformément au cours de dénombrement. Pour , c'est déjà listes : les énumérations exhaustives sont des outils de petite taille — précieuses pour vérifier un algorithme malin sur tous les cas possibles d'une instance réduite, impraticables au-delà.
Complexité : Le mur exponentiel
et croissent plus vite que toute puissance de :
| — |
Un algorithme exponentiel n'est utilisable que pour () ou (). Quand l'énumération complète est hors de portée, il faut une idée — le chapitre 7 en proposera une : le glouton.
<i class="fa-solid fa-dumbbell mr-2" style="color:#2E7559"></i>6.7 Exercices résolus
Niveau (Application directe du cours)
Dérouler à la main l'exécution de puissance(2, 10) (version récursive du cours) : donner la chaîne des appels, la profondeur de pile maximale et le nombre de multiplications.
Démonstration (Solution)
Les appels descendent par divisions de l'exposant : , qui renvoie . La remontée calcule : (impair) : ; (pair) : ; (impair) : ; (pair) : . ✓ Profondeur maximale : contextes empilés (). Multiplications : (deux par étage impair, une par étage pair, zéro au cas de base). (Tracer une exécution récursive, c'est dessiner la descente puis remonter en calculant — l'exercice qui rend la pile concrète.)
Écrire en récursif, avec cas de base, variant et preuve esquissée : renverse(s) (le miroir d'une chaîne) et est_palindrome(s).
Démonstration (Solution)
def renverse(s: str) -> str:
if len(s) <= 1: # vide ou un caractère : son propre miroir
return s
return renverse(s[1:]) + s[0] # le miroir de la queue, puis la tête
def est_palindrome(s: str) -> bool:
if len(s) <= 1:
return True
return s[0] == s[-1] and est_palindrome(s[1:-1])
assert renverse("radar") == "radar" and renverse("info") == "ofni"
assert est_palindrome("kayak") and not est_palindrome("kayaks")
Variant commun : len(s), qui décroît de (resp. ) par appel. Correction de est_palindrome par récurrence forte : une chaîne de longueur est un palindrome ; une chaîne plus longue en est un si et seulement si ses extrémités coïncident et que l'intérieur en est un — c'est la définition même. (Noter le court-circuit de and : si les extrémités diffèrent, l'appel récursif n'a pas lieu — la récursion s'arrête au premier désaccord, comme l'aurait fait une boucle.)
Écrire pyramide(n) qui affiche une pyramide centrée de étages :
*
***
*****
(pour : l'étage , compté de , porte étoiles précédées de espaces).
Démonstration (Solution)
La pyramide de étages est la pyramide des premiers étages (avec le même décalage !) suivie de sa grande ligne. Il faut donc transmettre la largeur totale — un paramètre d'enrobage :
def etages(k: int, n: int) -> None:
"""Affiche les étages 1 à k d'une pyramide de n étages."""
if k == 0:
return
etages(k - 1, n)
print(" " * (n - k) + "*" * (2 * k - 1))
def pyramide(n: int) -> None:
etages(n, n)
Le piège de l'exercice : écrire pyramide(n - 1) dans le cas récursif — la sous-pyramide serait alors centrée pour la largeur , donc décalée d'un espace vers la gauche : tout l'édifice penche. La récursion porte sur (combien d'étages restent à dessiner), pas sur (la géométrie globale, qui ne change jamais). (Distinguer le paramètre qui décroît du paramètre de contexte : c'est le rôle de la fonction d'enrobage, déjà vue pour la dichotomie récursive.)
Niveau (Application avec raisonnement intermédiaire)
La suite de Fibonacci (, , ) se code naïvement en récursif. Écrire cette version, compter ses appels pour , montrer que le nombre d'appels croît exponentiellement, et donner la version itérative en .
Démonstration (Solution)
def fib(n: int) -> int:
if n <= 1:
return n
return fib(n - 1) + fib(n - 2)
Pour : fib(5) appelle fib(4) et fib(3) ; fib(4) appelle fib(3) et fib(2)… Le compte donne appels, dont fib(2) calculé trois fois et fib(1) cinq fois. En notant le nombre d'appels : , qui croît comme Fibonacci lui-même, donc exponentiellement (environ ) : fib(40) dépasse les millions d'appels. La cause : l'arbre des appels recalcule sans cesse les mêmes valeurs — exactement le piège des deux appels de l'exponentiation rapide, en pire.
Version itérative :
def fib_iter(n: int) -> int:
a, b = 0, 1
for _ in range(n):
# Invariant : (a, b) == (F_k, F_{k+1}) au début du tour k
a, b = b, a + b
return a
additions, profondeur de pile nulle. (La leçon n'est pas « la récursivité est lente » — la dichotomie récursive est excellente — mais : compter les appels ; quand l'arbre recalcule, soit on mémorise (la « mémoïsation », hors programme du semestre), soit on itère. Fibonacci est précisément l'exemple que le programme suggère de ne pas prendre pour modèle de la récursivité.)
Le triangle de Sierpinski de niveau est fait de trois triangles de Sierpinski de niveau , posés en triangle. Écrire son tracé avec turtle (le cas de base : un triangle plein ou simple), et compter triangles élémentaires et appels au niveau .
Démonstration (Solution)
import turtle
def sierpinski(longueur: float, n: int) -> None:
"""Trace le triangle de Sierpinski de niveau n (côté = longueur)."""
if n == 0:
for _ in range(3): # cas de base : un triangle équilatéral
turtle.forward(longueur)
turtle.left(120)
return
sierpinski(longueur / 2, n - 1) # coin en bas à gauche
turtle.forward(longueur / 2)
sierpinski(longueur / 2, n - 1) # coin en bas à droite
turtle.backward(longueur / 2)
turtle.left(60)
turtle.forward(longueur / 2)
turtle.right(60)
sierpinski(longueur / 2, n - 1) # coin du haut
turtle.left(60)
turtle.backward(longueur / 2)
turtle.right(60)
Chaque niveau triple les triangles : triangles élémentaires au niveau , et appels — exponentiel, comme Koch. Le point délicat du code n'est pas la récursion mais la remise en place : après chaque sous-triangle, la tortue doit revenir exactement à sa position et son cap d'origine, sinon les erreurs s'accumulent d'appel en appel. (Invariant géométrique : « chaque appel laisse la tortue là où il l'a trouvée » — un contrat de restitution d'état, qu'on retrouvera chaque fois qu'une récursion partage une ressource commune.)
Écrire sommes_possibles(t, cible) : la liste des sous-listes de t dont la somme vaut cible. En déduire existe_somme(t, cible) (un booléen), plus économe : il s'arrête à la première solution.
Démonstration (Solution)
On reprend la structure avec/sans de sous_listes, en filtrant à l'arrivée — ou mieux, en propageant la cible :
def sommes_possibles(t: list, cible: float) -> list:
if t == []:
return [[]] if cible == 0 else []
sans = sommes_possibles(t[1:], cible)
avec = [[t[0]] + s for s in sommes_possibles(t[1:], cible - t[0])]
return sans + avec
assert sommes_possibles([2, 3, 5], 5) == [[5], [2, 3]]
def existe_somme(t: list, cible: float) -> bool:
if t == []:
return cible == 0
return (existe_somme(t[1:], cible)
or existe_somme(t[1:], cible - t[0]))
La version booléenne profite du court-circuit de or : dès qu'une branche répond True, l'autre n'est pas explorée — sur les instances favorables, une fraction seulement de l'arbre des feuilles est visitée. Le pire cas reste exponentiel : ce problème (« somme de sous-ensemble ») est l'un des grands problèmes difficiles de l'informatique, et l'énumération intelligente est, à ce jour, essentiellement ce qu'on sait faire. (Propager cible - t[0] plutôt que recalculer des sommes : l'argument transporte le travail déjà fait — le même réflexe d'accumulation qu'au chapitre 3.)
La fonction nb_chiffres du chapitre 1 se récrit récursivement. Donner cette version ; puis, inversement, convertir en boucle la fonction récursive somme_chiffres(n) (somme des chiffres de ). Dans quels cas la conversion est-elle nécessaire, et pourquoi est-elle toujours possible ici ?
Démonstration (Solution)
def nb_chiffres(n: int) -> int: # récursif
"""Précondition : n >= 1."""
if n < 10:
return 1
return 1 + nb_chiffres(n // 10)
def somme_chiffres(n: int) -> int: # version boucle de la récursion évidente
"""Précondition : n >= 0."""
s = 0
while n > 0:
s += n % 10
n //= 10
return s # s vaut 0 pour n = 0 : somme vide, correct
(Noter le cas : la boucle ne tourne pas et la fonction rend , la somme vide — un cas que la version récursive doit aussi trancher explicitement.) Ces récursions sont terminales en esprit : un seul appel récursif, et le travail s'accumule linéairement — la boucle avec accumulateur en est la transcription mécanique : le paramètre devient une variable, l'appel devient un tour, le cas de base devient la condition d'arrêt. La conversion est nécessaire quand la profondeur menace la pile (entiers à plus de mille chiffres ici — rares, mais possibles en Python) ; elle est toujours possible pour une récursion à un seul appel, et c'est un exercice de traduction sans risque dès lors qu'on transporte l'invariant. (Les récursions à plusieurs appels — Koch, sous-listes — se convertissent aussi, mais au prix d'une pile gérée à la main : on y reviendra avec les structures de données, au second semestre.)
Niveau (Raisonnement subtil ou plusieurs étapes)
disques de tailles décroissantes sont empilés sur une tige A ; il faut les déplacer sur la tige C via la tige B, un disque à la fois, sans jamais poser un disque sur un plus petit. Écrire hanoi(n, depart, via, arrivee) qui affiche la suite des déplacements, prouver qu'elle effectue déplacements, et montrer que ce nombre est optimal.
Démonstration (Solution)
Pour déplacer disques de A vers C : déplacer les du dessus vers B (récursivement), transférer le grand disque vers C, ramener les de B vers C :
def hanoi(n: int, depart: str, via: str, arrivee: str) -> None:
if n == 0:
return
hanoi(n - 1, depart, arrivee, via)
print(depart, "->", arrivee)
hanoi(n - 1, via, depart, arrivee)
hanoi(3, "A", "B", "C") # 7 déplacements
Compte : , ; en posant , on a et , donc et .
Optimalité : notons le nombre minimal de déplacements. Pour bouger le plus grand disque, les autres doivent être tous sur la tige restante : cela coûte au moins avant, puis , puis au moins pour les remettre dessus — d'où , et par récurrence . Notre algorithme atteint cette borne : il est optimal, et la difficulté est dans le problème, pas dans notre solution. (La légende veut que des moines déplacent disques d'or : déplacements — à un par seconde, près de six cents milliards d'années. Le mur exponentiel, version monastique ; et un deuxième exemple, après la dichotomie, de borne inférieure prouvée par un argument de structure.)
Énumérer les mots binaires de longueur de sorte que deux mots consécutifs ne diffèrent que d'un seul bit (code de Gray). Construction récursive : la liste de niveau s'obtient en préfixant par la liste de niveau , puis par cette même liste renversée. Implémenter, vérifier la propriété, et prouver la construction.
Démonstration (Solution)
def gray(n: int) -> list:
"""Les 2**n mots binaires, voisins à un bit près."""
if n == 0:
return [""]
g = gray(n - 1)
return ["0" + m for m in g] + ["1" + m for m in reversed(g)]
assert gray(2) == ["00", "01", "11", "10"]
for n in range(1, 10): # vérification de la propriété
mots = gray(n)
assert len(mots) == 2 ** n and len(set(mots)) == 2 ** n
for a, b in zip(mots, mots[1:]):
assert sum(1 for x, y in zip(a, b) if x != y) == 1
Preuve par récurrence. Pour : un seul mot, rien à vérifier. Supposons gray() correcte (tous les mots, chacun une fois, voisins à un bit). Dans la première moitié, les mots préfixés par héritent de la propriété ; de même dans la seconde (renverser une liste préserve la voisinage des consécutifs). Au point de jonction, les deux mots sont et pour le même (dernier de , premier de renversée) : ils diffèrent du seul bit de tête. ✓ Et tous les mots de longueur sont obtenus une fois chacun (, sans collision entre les deux moitiés). (Le code de Gray est utilisé dans les codeurs angulaires matériels : entre deux positions voisines, un seul capteur change — toute lecture pendant la transition reste à un bit de la vérité. L'astuce du renversement, dite « réflexion », est le geste récursif à retenir : recoller deux copies dont l'une est retournée pour que la couture soit propre.)
Pour chacune des fonctions du chapitre — somme_jusqua, dichotomie_rec, puissance (récursive), fib (naïve), sous_listes, koch — donner la profondeur de pile maximale en fonction de l'entrée, dire lesquelles risquent RecursionError en Python (limite ) et pour quelles tailles, et proposer la parade dans chaque cas problématique.
Démonstration (Solution)
| Fonction | Profondeur | Danger à ? | Parade |
|---|---|---|---|
| `somme_jusqua()` | oui, dès | boucle (ou `sum`) | |
| `dichotomie_rec` | non | aucune nécessaire | |
| `puissance()` | non | aucune nécessaire | |
| `fib()` | oui (le temps d'abord) | itérer ( appels !) | |
| `sous_listes()` | oui en théorie | mémoire () dès | |
| `koch()` | non ( segments avant) | aucune |
Le tableau enseigne une hiérarchie des ressources : pour fib et sous_listes, le temps ou la mémoire du résultat explosent bien avant la pile — la profondeur n'est le facteur limitant que pour les récursions linéaires et rapides comme somme_jusqua. D'où la règle de conception : profondeur , toujours licite ; profondeur , licite si reste petit (chaînes, listes courtes) et à convertir en boucle sinon ; et avant toute conversion, vérifier que ce n'est pas le nombre total d'appels qui condamne l'approche. (Estimer les trois ressources — temps, pile, mémoire du résultat — avant d'exécuter : c'est l'analyse de complexité élargie, et elle évite la séance de débogage la plus frustrante qui soit, celle d'un programme correct qui ne peut simplement pas finir.)
- Définition : cas de base (réponse directe) cas récursifs (appels sur des entrées strictement plus petites) ; concevoir en trois questions — base ? décomposition (faire confiance à l'appel) ? décroissance ?
- Pile d'appels : chaque appel empile un contexte, le
returndépile ; descente / cas de base / remontée ; profondeur limitée ( en Python) —RecursionError; de profondeur toujours sûr, à surveiller, et distinguer profondeur (pile) et nombre total d'appels (temps). - Preuves : terminaison variant sur l'argument ; correction récurrence forte (« correcte sur les entrées plus petites correcte ici ») — la récursivité est la récurrence exécutable.
- Dichotomies récursives : la fenêtre devient paramètres, fonction d'enrobage pour l'utilisateur ; exponentiation rapide : un seul appel mémorisé dans une variable — deux appels recalculent et ruinent le (même piège que
fibnaïf, appels : itérer ou mémoriser). - Figures : travail avant l'appel descente, après remontée (triangle, sablier, pyramide avec paramètre de contexte) ; fractales (Koch segments, Sierpinski triangles) : coût exponentiel, profondeur ; invariant de restitution d'état de la tortue.
- Énumérations : sous-listes par avec/sans (), permutations par choix de tête (), code de Gray par réflexion ; résultats exponentiels par nature — outils de vérification en petite taille ; mur exponentiel : praticable jusqu'à , jusqu'à .
- Hanoï : déplacements, et c'est optimal (borne inférieure par structure) — la difficulté peut être dans le problème, pas dans l'algorithme.
6.8 Exercices d'entraînement
Cette banque d'exercices, classée par thème, couvre l'intégralité du chapitre. La numérotation prolonge celle des dix exercices résolus. Légende : application directe, raisonnement intermédiaire, approfondissement ; le symbole signale un classique incontournable.
A. Premiers pas récursifs
- () Écrire en récursif :
produit(t)(produit des éléments),maximum(t)(maximum d'une liste non vide),appartient(v, t)— cas de base, variant, et un jeu de tests chacun. - () Que calcule
def f(n): return 0 if n == 0 else f(n - 1) + 2 * n - 1? Le prouver par récurrence. - () Trouver le bogue :
def g(n): return 1 if n == 1 else n * g(n - 2)appelé surg(6)— diagnostiquer, corriger de deux façons (cas de base supplémentaire ; précondition). - () Écrire
somme_chiffres_rec(n)puis la racine numérique (appliquer la somme des chiffres jusqu'à obtenir un seul chiffre) — deux récursions emboîtées, deux variants distincts à exhiber.
B. Dichotomies et diviser pour mieux régner
- () Écrire la version récursive de
racine_entiere(chapitre 5) et vérifier qu'elle effectue le même nombre d'appels que la boucle faisait de tours. - ( ) Écrire
maximum_dpr(t, g, d): le maximum par découpage en deux moitiés (« diviser pour régner »). Compter les comparaisons (, comme la boucle — le prouver par récurrence sur la taille). - () Écrire
compte_occurrences_rec(v, t, g, d)sur tableau trié, en combinant première et dernière occurrence récursives (chapitre 5, exercice 5). - () Le pic d'un tableau « montagne » (croissant puis décroissant) : le trouver en récursif — invariant : le pic est dans la fenêtre, en comparant et .
C. Figures et fractales
- () Écrire
compte_a_rebours(n)qui affiche puisdecollage(n)qui affiche puis (descente et remontée dans la même fonction). - () Tracer un escalier d'étoiles de profil triangulaire inversé (le triangle du cours,
printavant l'appel) et un losange centré (combiner pyramide et pyramide renversée). - ( ) La courbe du dragon : niveau niveau , virage à gauche, niveau parcouru en sens inverse avec virages inversés. L'implémenter avec deux fonctions mutuellement récursives
dragonetnogard, tracer le niveau . - () L'arbre fractal : un tronc, puis deux branches inclinées de degrés portant chacune un arbre de niveau , raccourci d'un facteur . Tracer, et compter les segments.
- () Le tapis de Sierpinski (un carré divisé en , le centre vide, récursion sur les autres) : tracer au niveau et donner le nombre de carrés pleins au niveau .
D. Énumérations et structures
- () Écrire
mots_binaires(n): les chaînes de0/1de longueur , dans l'ordre lexicographique ; vérifier le compte et l'ordre. - () Adapter
sous_listesenparties_de_taille(t, k)(les sous-listes de longueur exactement ) et vérifier que leur nombre est — le triangle de Pascal surgit de la récursion avec/sans. - ( ) Écrire
permutations_distinctes(t)pour une liste avec répétitions ( n'a que permutations distinctes) — éviter les doublons à la source (ne choisir chaque valeur de tête qu'une fois) plutôt qu'en filtrant après coup, et comparer les coûts. - () Énumérer les chemins d'une grille du coin haut-gauche au coin bas-droit (pas vers la droite ou vers le bas uniquement) ; compter et confronter à .
- ( ) Les parenthésages équilibrés à paires : les énumérer récursivement (ajouter
(si possible,)si licite), vérifier les comptes (nombres de Catalan), et prouver que la construction n'engendre que des mots équilibrés — invariant sur le nombre de parenthèses ouvertes. - () Mesurer expérimentalement la limite de pile de Python : écrire une fonction
profondeur_maxqui la détermine par essais, en capturant l'erreurRecursionError(HP : le rattrapage d'exceptions n'est pas au programme — culture) ; vérifier ensuite la cohérence du résultat avecsomme_jusqua. Discuter : pourquoi cette limite existe-t-elle, alors que la mémoire de la machine permettrait bien davantage ?