Adloun

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

Définition 6.1Fonction récursive

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

Définition 6.2Pile 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.

AttentionLe débordement de pile

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.

Exemple 6.3Preuve complète de `somme_jusqua`

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

Exemple 6.4Recherche dichotomique 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 .

Exemple 6.5Exponentiation rapide récursive

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

Exemple 6.6Un triangle qui se construit tout seul

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.

Exemple 6.7Le sablier : travailler à la descente et à 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

Définition 6.8Figure fractale

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.

Exemple 6.9La courbe de Koch

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.

iRemarque

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

Exemple 6.10Les sous-listes d'une liste

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

Exemple 6.11Les permutations d'une liste

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)

Exercice 1 : Tracer une exécution

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.)

Exercice 2 : Récursions sur les chaînes

É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.)

Exercice 3 : La pyramide centrée

É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)

Exercice 4 : Fibonacci, ou l'arbre qui explose

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é.)

Exercice 5 : Le tracé de Sierpinski

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.)

Exercice 6 : Les sous-listes d'une somme donnée

É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.)

Exercice 7 : Convertir une récursion en boucle

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)

Exercice 8 : Les tours de Hanoï

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.)

Exercice 9 : Générer dans l'ordre du code de Gray

É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.)

Exercice 10 : Pile bornée — estimer avant d'exécuter

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)
FonctionProfondeurDanger à ?Parade
`somme_jusqua()`oui, dès boucle (ou `sum`)
`dichotomie_rec`nonaucune nécessaire
`puissance()`nonaucune nécessaire
`fib()`oui (le temps d'abord)itérer ( appels !)
`sous_listes()`oui en théoriemé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.)

Synthèse du chapitre (à retenir)
  • 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 return dé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 fib naï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

B. Dichotomies et diviser pour mieux régner

C. Figures et fractales

D. Énumérations et structures

Continuer sur Adloun : animation, QCM, fiches, exercices