Partie III - Algorithmique · Chapitre 14

La Récursivité

3 sections de cours · fiche d'exercices corrigee

1Principe, pile d'appels et recursivite terminale

Definition

Une fonction est recursive si elle s'appelle elle-meme, directement ou indirectement. C'est la traduction algorithmique du raisonnement par recurrence : on resout un probleme en le ramenant a une ou plusieurs instances plus petites du meme probleme.

Les deux ingredients obligatoires
  1. Un ou plusieurs cas de base, resolus directement, sans appel recursif.
  2. Un cas recursif qui se ramene a des instances strictement plus petites, et qui progresse necessairement vers un cas de base.

Oublier le cas de base, ou ne pas decroitre, produit une recursion infinie : RecursionError: maximum recursion depth exceeded.

def factorielle(n):
    """n! pour n >= 0."""
    if n <= 1:              # CAS DE BASE
        return 1
    return n * factorielle(n - 1)      # CAS RECURSIF : n-1 < n

assert factorielle(0) == 1
assert factorielle(5) == 120

Le deroulement d'un appel

factorielle(4)
= 4 * factorielle(3)
= 4 * (3 * factorielle(2))
= 4 * (3 * (2 * factorielle(1)))
= 4 * (3 * (2 * 1))            <- cas de base atteint : la remontee commence
= 4 * (3 * 2)
= 4 * 6
= 24

Deux phases : la descente (les appels s'empilent) puis la remontee (les resultats se combinent). Tant que la descente n'a pas atteint un cas de base, aucun calcul de la multiplication n'est effectue : les 4 multiplications attendent sur la pile.

Pile d'appels recursifs : deroulement pas a pas
Choisir une fonction et cliquer sur « Initialiser ».

Recursivite et pile d'execution

Cout en memoire

Chaque appel en attente occupe un bloc sur la pile d'execution. La memoire consommee est donc proportionnelle a la profondeur maximale de recursion, et non au nombre total d'appels. Pour factorielle(n) : profondeur \(n\), donc \(\Theta(n)\) memoire, la ou la version iterative n'utilise que \(\Theta(1)\).

import sys
sys.getrecursionlimit()          # 1000 par defaut
factorielle(3000)                # RecursionError

La limite protege contre le debordement de la pile systeme, qui provoquerait un plantage brutal du processus.

Recursivite terminale

Definition

Un appel recursif est terminal si sa valeur est renvoyee telle quelle, sans aucun calcul apres le retour. Le bloc d'activation de l'appelant devient alors inutile : certains langages (OCaml, Scheme) l'eliminent automatiquement, ramenant la memoire a \(\Theta(1)\).

def factorielle(n):
    """NON terminale : il reste une multiplication apres l'appel."""
    if n <= 1:
        return 1
    return n * factorielle(n - 1)

def factorielle_terminale(n, acc=1):
    """TERMINALE : l'appel recursif est la derniere operation."""
    if n <= 1:
        return acc
    return factorielle_terminale(n - 1, acc * n)      # rien apres l'appel
Python n'optimise pas la recursion terminale

Contrairement a OCaml, CPython ne realise pas l'elimination des appels terminaux : factorielle_terminale(3000) leve toujours RecursionError. Guido van Rossum a explicitement refuse cette optimisation, qui rendrait les traces d'erreur illisibles. En Python, une recursion terminale se transforme donc mecaniquement en boucle while :

def factorielle_iterative(n):
    acc = 1
    while n > 1:
        acc *= n
        n -= 1
    return acc

2Prouver, analyser, memoiser

Prouver une fonction recursive

Methode

Terminaison : exhiber une quantite entiere positive (un variant) qui decroit strictement a chaque appel recursif. Les cas de base doivent etre atteints pour les valeurs minimales de ce variant.

Correction : par recurrence sur le variant. On suppose la fonction correcte pour toutes les valeurs strictement plus petites (hypothese de recurrence forte) et on montre qu'elle l'est pour la valeur courante.

Exemple redige — puissance rapide
def puissance(x, n):
    """x^n pour n entier >= 0. Theta(log n) multiplications."""
    if n == 0:
        return 1
    if n % 2 == 0:
        y = puissance(x, n // 2)
        return y * y
    return x * puissance(x, n - 1)

Terminaison. Variant : \(n\), entier naturel. Dans le cas pair, l'appel porte sur \(n/2 < n\) (car \(n \ge 2\) puisque \(n \ne 0\) et \(n\) pair). Dans le cas impair, il porte sur \(n-1 < n\). Le variant decroit strictement et le cas de base \(n=0\) est atteint : la fonction termine.

Correction. Par recurrence forte sur \(n\).

  • Base : \(n=0\), la fonction renvoie 1 \(= x^0\). Correct.
  • Heredite : soit \(n \ge 1\), supposons le resultat vrai pour tout \(k < n\).
    • Si \(n\) est pair, \(n = 2p\) avec \(p = n/2 < n\). Par hypothese, puissance(x, p) vaut \(x^p\), donc la fonction renvoie \((x^p)^2 = x^{2p} = x^n\).
    • Si \(n\) est impair, \(n-1 < n\), donc puissance(x, n-1) vaut \(x^{n-1}\) et la fonction renvoie \(x \cdot x^{n-1} = x^n\).

Complexite. Notons \(M(n)\) le nombre de multiplications. Un cas impair est toujours suivi d'un cas pair, donc deux appels consecutifs au plus divisent \(n\) par 2 : \(M(n) \le 2\log_2 n + 1 = \Theta(\log n)\). Pour \(n = 10^6\) : une quarantaine de multiplications au lieu d'un million.

Recursivite multiple et explosion combinatoire

def fib(n):
    """Fibonacci naif. ATTENTION : exponentiel."""
    if n < 2:
        return n
    return fib(n - 1) + fib(n - 2)
Nombre d'appels

Notons \(A(n)\) le nombre total d'appels. On a \(A(0)=A(1)=1\) et \(A(n) = 1 + A(n-1) + A(n-2)\). On montre par recurrence que \(A(n) = 2F_{n+1} - 1\), ou \(F\) est la suite de Fibonacci. Comme \(F_n \sim \frac{\varphi^n}{\sqrt5}\) avec \(\varphi = \frac{1+\sqrt5}{2} \approx 1{,}618\), on obtient \[ A(n) = \Theta(\varphi^n) : \text{croissance EXPONENTIELLE}. \]

\(n\)AppelsTemps
2021 891< 0,01 s
302 692 5370,4 s
40331 160 28150 s
504 x 1010> 1 heure

La cause : fib(n-2) est recalcule des milliards de fois. L'arbre des appels contient d'enormes redondances.

La solution : memoisation
def fib_memo(n, cache=None):
    """Fibonacci memoise : Theta(n) appels, Theta(n) memoire."""
    if cache is None:
        cache = {}
    if n < 2:
        return n
    if n not in cache:
        cache[n] = fib_memo(n-1, cache) + fib_memo(n-2, cache)
    return cache[n]

from functools import lru_cache
@lru_cache(maxsize=None)
def fib_cache(n):
    return n if n < 2 else fib_cache(n-1) + fib_cache(n-2)

def fib_iteratif(n):
    """Theta(n) temps, Theta(1) memoire : la meilleure version."""
    a, b = 0, 1
    for _ in range(n):
        a, b = b, a + b
    return a

assert fib_memo(100) == fib_iteratif(100) == 354224848179261915075

Chaque valeur n'est calculee qu'une fois : on passe de \(\Theta(\varphi^n)\) a \(\Theta(n)\). C'est le principe de la programmation dynamique.

Diviser pour regner

Le schema

Trois etapes : diviser le probleme en sous-problemes independants, regner (les resoudre recursivement), combiner les solutions.

La complexite verifie alors une recurrence \(T(n) = a\,T(n/b) + f(n)\), ou \(a\) est le nombre de sous-problemes et \(n/b\) leur taille.

AlgorithmeRecurrenceComplexite
Recherche dichotomique\(T(n) = T(n/2) + \Theta(1)\)\(\Theta(\log n)\)
Tri fusion\(T(n) = 2T(n/2) + \Theta(n)\)\(\Theta(n\log n)\)
Puissance rapide\(T(n) = T(n/2) + \Theta(1)\)\(\Theta(\log n)\)
Fibonacci naif\(T(n) = T(n-1) + T(n-2) + \Theta(1)\) \(\Theta(\varphi^n)\)
Tours de Hanoi\(T(n) = 2T(n-1) + \Theta(1)\)\(\Theta(2^n)\)

Retenir la difference cruciale : diviser la taille (\(n/2\)) donne des logarithmes ; la decrementer (\(n-1\)) avec deux appels donne des exponentielles.

3Grands schemas recursifs

Exemples fondamentaux

Tours de Hanoi

Deplacer \(n\) disques de la tige \(A\) vers \(C\) en utilisant \(B\), sans jamais poser un disque sur un plus petit.

def hanoi(n, depart="A", arrivee="C", intermediaire="B", mouvements=None):
    """Deplace n disques. Renvoie la liste des mouvements."""
    if mouvements is None:
        mouvements = []
    if n == 0:
        return mouvements
    hanoi(n-1, depart, intermediaire, arrivee, mouvements)   # 1. les n-1 du dessus
    mouvements.append((depart, arrivee))                     # 2. le plus grand
    hanoi(n-1, intermediaire, arrivee, depart, mouvements)   # 3. on les remet dessus
    return mouvements

m = hanoi(3)
print(len(m), m)      # 7 mouvements
assert len(hanoi(10)) == 2**10 - 1

Complexite. \(T(n) = 2T(n-1) + 1\) avec \(T(0)=0\). En posant \(u_n = T(n)+1\), il vient \(u_n = 2u_{n-1}\), donc \(u_n = 2^n\) et \[ T(n) = 2^n - 1. \] C'est optimal : on montre par recurrence que tout deplacement du plus grand disque exige d'avoir prealablement deplace les \(n-1\) autres. Pour \(n = 64\) (la legende), a raison d'un disque par seconde, il faudrait environ 585 milliards d'annees.

Parcours d'arbre

Un arbre binaire est None (vide) ou un triplet (valeur, gauche, droite) : la definition est elle-meme recursive, les algorithmes le sont donc naturellement.

def taille(a):
    """Nombre de noeuds."""
    return 0 if a is None else 1 + taille(a[1]) + taille(a[2])

def hauteur(a):
    """Hauteur : -1 pour l'arbre vide, 0 pour une feuille."""
    return -1 if a is None else 1 + max(hauteur(a[1]), hauteur(a[2]))

def infixe(a):
    """Parcours gauche - racine - droite."""
    return [] if a is None else infixe(a[1]) + [a[0]] + infixe(a[2])

def prefixe(a):
    return [] if a is None else [a[0]] + prefixe(a[1]) + prefixe(a[2])

def miroir(a):
    """Arbre symetrique."""
    return None if a is None else (a[0], miroir(a[2]), miroir(a[1]))

A = (1, (2, (4, None, None), (5, None, None)), (3, None, None))
assert taille(A) == 5 and hauteur(A) == 2
assert infixe(A) == [4, 2, 5, 1, 3]
assert prefixe(A) == [1, 2, 4, 5, 3]
assert infixe(miroir(A)) == [3, 1, 5, 2, 4]

Chaque fonction suit le meme moule : cas de base pour l'arbre vide, puis combinaison des resultats des deux sous-arbres. La complexite est \(\Theta(n)\) pour taille et hauteur, mais \(\Theta(n^2)\) au pire pour infixe a cause des concatenations de listes (a corriger en accumulant dans une liste passee en parametre).

Backtracking : les \(n\) reines

Placer \(n\) reines sur un echiquier \(n\times n\) sans qu'aucune n'en menace une autre.

def n_reines(n):
    """Nombre de solutions au probleme des n reines."""
    def compatible(colonnes, c):
        """La reine en (ligne len(colonnes), colonne c) est-elle sure ?"""
        ligne = len(colonnes)
        for l, col in enumerate(colonnes):
            if col == c or abs(col - c) == abs(l - ligne):    # colonne ou diagonale
                return False
        return True

    def place(colonnes):
        if len(colonnes) == n:
            return 1                          # une solution complete
        total = 0
        for c in range(n):
            if compatible(colonnes, c):
                total += place(colonnes + [c])   # on essaie, on explore
        return total                             # ... puis on revient (backtrack)

    return place([])

assert [n_reines(k) for k in range(1, 9)] == [1, 0, 0, 2, 10, 4, 40, 92]

Le retour sur trace (backtracking) explore un arbre de choix, en elaguant des qu'une contrainte est violee. Sans elagage, il faudrait examiner \(n^n\) placements ; l'elagage reduit considerablement l'exploration, meme si le probleme reste exponentiel.

Recursivite ou iteration ?

RecursiviteIteration
Lisibiliteproche de la definition mathematiqueplus verbeuse sur les structures arborescentes
Memoire\(\Theta(\text{profondeur})\) sur la pile\(\Theta(1)\) souvent
Vitessesurcout d'appel (environ 1 µs par appel en Python) plus rapide
Limite1000 appels par defautaucune
Naturelle pourarbres, diviser pour regner, backtracking parcours lineaires, accumulations
Regle pratique

Utiliser la recursivite quand la structure du probleme est recursive (arbre, division en sous-problemes). Preferer l'iteration pour les parcours lineaires. Toujours memoiser des qu'un meme sous-probleme peut etre rencontre plusieurs fois.

L'essentiel du chapitre

  • Toute fonction recursive doit avoir un cas de base et progresser vers lui.
  • La memoire consommee est proportionnelle a la profondeur, pas au nombre d'appels.
  • Terminaison : un variant entier positif strictement decroissant. Correction : recurrence forte sur ce variant.
  • Python n'optimise pas la recursion terminale : la transformer en boucle.
  • La recursivite multiple sans memoisation est exponentielle (fib : \(\Theta(\varphi^n)\)).
  • Diviser la taille donne des logarithmes ; la decrementer avec deux appels donne des exponentielles.
  • Diviser pour regner et backtracking sont les deux grands schemas recursifs.
Fiche d'exercices — chapitre 14Enonces gradues, du calcul d'application au niveau concours, avec corriges detailles.
Ouvrir la fiche →