Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 24

Récursivité terminale vers itération

Recommended resources

Bonjour,

Dans la leçon précédente, nous avons étudié les piles et les files, et analysé comment le choix de l'implémentation (une list ou un deque) impacte radicalement leur performance. Nous avons conclu en évoquant que la pile est le mécanisme implicite qui gère les appels de fonction dans la plupart des langages de programmation.

Aujourd'hui, nous allons explorer cette connexion plus en profondeur. L'objectif est d'apprendre à transformer une fonction récursive terminale en une version itérative équivalente. C'est une compétence fondamentale pour tout informaticien, car elle permet d'optimiser le code pour la performance et d'éviter les limitations de la pile d'appels, un point crucial dans des langages comme Python qui ne disposent pas d'optimisation automatique pour ce type de récursion.

1. Qu'est-ce que la récursivité terminale ?

Une fonction est dite récursive terminale (ou tail-recursive) si l'appel récursif est la toute dernière opération exécutée par la fonction. Il ne doit y avoir aucune opération en attente (multiplication, addition, etc.) qui serait effectuée sur la valeur de retour de l'appel récursif.

Prenons l'exemple classique de la fonction factorielle :




# Version non-terminale
def factorielle_non_terminale(n):
    if n < 2:
        return 1



    # Opération en attente : la multiplication par n
    return n * factorielle_non_terminale(n - 1) 

Ici, après le retour de factorielle_non_terminale(n - 1), il reste une multiplication à effectuer. L'appel n'est donc pas terminal. Chaque appel doit attendre le résultat du suivant, ce qui conduit à un empilement de contextes d'exécution dans la pile d'appels.

La vidéo suivante illustre parfaitement ce problème de consommation mémoire.

Tail Recursion Explained - Computerphile

Cette première partie de la vidéo "Tail Recursion Explained" de Computerphile explique pourquoi la version naïve de la factorielle récursive est inefficace en termes de mémoire.

Regardez de 00:00 à 04:26. Concentrez-vous sur la visualisation de l'expression qui grandit avant de pouvoir être évaluée. C'est la représentation de l'accumulation des opérations en attente sur la pile d'appels.

La technique de l'accumulateur

Pour transformer une fonction en version récursive terminale, la technique la plus courante est d'utiliser un paramètre supplémentaire appelé accumulateur. Cet accumulateur transporte le résultat partiel au fil des appels récursifs, éliminant ainsi le besoin d'opérations en attente.




# Version terminale avec un accumulateur
def factorielle_terminale(n, acc=1):
    if n < 2:
        return acc



    # L'appel récursif est la dernière action. Rien n'est fait avec son résultat.
    return factorielle_terminale(n - 1, acc * n)

Dans cette nouvelle version, le calcul acc * n est effectué avant l'appel récursif. Le résultat de cet appel est directement retourné, sans modification. L'appel est bien terminal.

La suite de la vidéo montre cette transformation.

Tail Recursion Explained - Computerphile

La seconde partie de la vidéo montre comment réécrire la fonction factorielle de manière récursive terminale en utilisant un accumulateur, et explique pourquoi cette forme est bien plus efficace.

Regardez de 04:26 à 09:17. Observez comment la forme de l'évaluation change : au lieu de grandir puis de rétrécir, elle reste de taille constante. C'est la clé de l'efficacité.

Pour formaliser cette technique, je vous propose une lecture qui décrit la transformation en récursion terminale comme l'ajout d'une "fonctionnalité secrète" (le calcul via l'accumulateur) à la fonction.

Recursion to Iteration, Part 1: The Simple Method, secret features ...

Cet article de blog, "Recursion to Iteration, Part 1", propose une explication détaillée et mécanique de la transformation vers la récursion terminale. C'est une excellente lecture pour solidifier votre compréhension.

Lisez la section "Bonus Explainer: Using secret features for tail-call conversion" à la fin de l'article. L'auteur y détaille une méthode systématique pour introduire un accumulateur. Cette approche est très rigoureuse et vous sera utile.


2. La transformation mécanique en boucle while

Une fois qu'une fonction est sous forme récursive terminale, sa conversion en une fonction itérative devient purement mécanique. Un appel récursif terminal est conceptuellement équivalent à un GOTO (saut inconditionnel) qui met à jour ses paramètres. En programmation structurée, cela se traduit naturellement par une boucle while.

L'article de blog que vous venez de consulter propose une méthode simple en 5 étapes.

Recursion to Iteration, Part 1: The Simple Method, secret features ...

Nous allons maintenant étudier la méthode de transformation elle-même, décrite dans le même article.

Lisez les sections "The Simple Method" et "Example: factorial". Suivez attentivement les 5 étapes appliquées à la fonction factorielle. C'est le cœur de la leçon d'aujourd'hui.

Résumons la méthode :

  1. Étudier la fonction (et s'assurer qu'elle est récursive terminale).
  2. Introduire une boucle infinie (while True:) autour du corps de la fonction.
  3. Transformer l'appel récursif terminal en une mise à jour des paramètres et une instruction continue. Par exemple, return f(x-1, y+1) devient (x, y) = (x-1, y+1); continue.
  4. Ajuster les cas de base pour qu'ils retournent la valeur finale (ils deviennent la condition de sortie de la boucle).
  5. Nettoyer le code pour le rendre plus idiomatique (par exemple, transformer while True: if condition: break en while not condition:).

L'image suivante généralise ce patron de transformation. Une fonction récursive terminale de la forme F(x) = si P(x) alors G(x) sinon F(H(x)) est directement équivalente à une boucle while.

Converting Tail Recursive to Iterative
Cette image illustre le patron général de conversion. La condition du `if` (P(x)) devient la condition d'arrêt de la boucle. La branche `then` (G(x)) est ce qui est retourné après la boucle. La branche `else` (F(H(x))) correspond à la mise à jour des variables (`x = H(x)`) à l'intérieur de la boucle.

3. Analyse et justification : le "Pourquoi"

Pourquoi se donner la peine de cette transformation, surtout si la version récursive est plus simple à écrire ? La raison est la gestion de la mémoire, et plus précisément de la pile d'appels (call stack).

Chaque appel de fonction, récursif ou non, ajoute une "frame" (un contexte) sur la pile d'appels. Cette frame contient les paramètres de la fonction, ses variables locales, et l'adresse de retour.

  • Dans une récursion non-terminale, chaque appel doit attendre que le suivant se termine. La pile grandit donc avec la profondeur de la récursion, consommant une mémoire proportionnelle à n (complexité spatiale en ). Si n est grand, cela peut mener à une erreur de Stack Overflow.
  • Dans une version itérative, il n'y a qu'un seul appel de fonction. La boucle s'exécute dans un seul contexte, utilisant une quantité de mémoire constante (complexité spatiale en ).

Certains langages (notamment ceux de la famille fonctionnelle comme OCaml) implémentent une optimisation appelée Tail-Call Optimization (TCO). Le compilateur détecte la récursion terminale et la transforme automatiquement en une boucle, nous offrant le meilleur des deux mondes : un code lisible et une exécution efficace. Python n'implémente pas la TCO. Il est donc de votre responsabilité, en tant que programmeur, d'effectuer cette transformation manuellement lorsque la performance ou la profondeur de la récursion sont critiques.

La vidéo suivante visualise parfaitement la différence de consommation mémoire sur la pile d'appels.

What are Tail-Recursive Methods? | Tail-Recursion Made Simple | Tail-Call Optimization | Geekific

La vidéo "What are Tail-Recursive Methods?" de Geekific offre une visualisation claire de l'utilisation de la pile d'appels pour les deux types de fonctions.

Regardez les deux segments : De 01:32 à 02:26 pour l'explication du bénéfice de la récursion terminale (Tail-Call Optimization). De 05:25 à 06:44 pour la visualisation comparative de l'empreinte mémoire. Observez comment la pile reste de taille constante (O(1)) pour la version itérative/terminale, alors qu'elle grandit (O(n)) pour la version non-terminale.


4. Mise en pratique : Algorithme d'Euclide

Appliquons maintenant cette méthode à un autre algorithme fondamental : le calcul du plus grand commun diviseur (PGCD) par l'algorithme d'Euclide.

Voici sa définition récursive classique en Python :

def pgcd_rec(a, b):
    if b == 0:
        return a
    else:



        # L'appel est-il terminal ? Oui, car son résultat est directement retourné.
        return pgcd_rec(b, a % b)

Exercice :
En utilisant la méthode en 5 étapes, transformez la fonction pgcd_rec(a, b) en une fonction itérative pgcd_iter(a, b).

Prenez quelques minutes pour le faire sur papier ou dans un éditeur.

Cliquez ici pour voir la solution détaillée.
  1. Fonction de départ (récursive terminale) :

    def pgcd_rec(a, b):
        if b == 0:
            return a
        else:
            return pgcd_rec(b, a % b)
    
  2. Introduire while True :

    def pgcd_iter_step2(a, b):
        while True:
            if b == 0:
                return a
            else:
                return pgcd_rec(b, a % b) # Placeholder
    
  3. Remplacer l'appel récursif par la mise à jour des variables + continue :

    def pgcd_iter_step3(a, b):
        while True:
            if b == 0:
                return a
            else:
                (a, b) = (b, a % b) # Affectation simultanée en Python
                continue 
    

    Note : l'affectation simultanée a, b = b, a % b est idiomatique en Python et évite d'utiliser une variable temporaire.

  4. Nettoyer le code : la condition if b == 0: est la condition d'arrêt. La boucle doit donc continuer while b != 0.

    def pgcd_iter_final(a, b):
        while b != 0:
            a, b = b, a % b
    
    
    
        # Lorsque la boucle se termine, b est 0, et la bonne valeur est dans a.
        return a
    

Cette version itérative est exactement l'implémentation standard de l'algorithme d'Euclide, qui s'exécute en espace constant .

Ce type d'algorithme est un classique de l'Agrégation. Pour aller plus loin, vous pouvez consulter la ressource suivante, qui présente une version itérative de l'algorithme d'Euclide étendu (pour l'identité de Bézout), un sujet tout à fait dans l'esprit du concours.

[PDF] Illustrations informatiques et algorithmes pour l'agrégation interne

Le document de Bernard Parisse, destiné à la préparation à l'agrégation, contient des implémentations rigoureuses de ces algorithmes.

Consultez la sous-section titrée "Algorithme de Bézout itératif en syntaxe Xcas" et sa version Python juste après (page 21). Reconnaissez-vous le cœur de l'algorithme que nous venons de dériver ? Cela vous montre comment notre transformation s'applique à des algorithmes fondamentaux.

Conclusion

Cette leçon vous a doté d'une technique puissante pour optimiser le code récursif. La transformation de la récursion terminale en itération est plus qu'une simple astuce ; c'est une démonstration de votre compréhension du fonctionnement de la pile d'appels et de votre capacité à contrôler finement l'efficacité de vos algorithmes.

Points clés à retenir :

  • Une fonction est récursive terminale si l'appel récursif est sa dernière action.
  • On peut souvent rendre une fonction terminale en utilisant un accumulateur pour transporter le résultat partiel.
  • Toute fonction récursive terminale peut être transformée mécaniquement en une boucle while qui s'exécute en espace constant .
  • Cette transformation est essentielle en Python, qui ne pratique pas l'optimisation des appels terminaux (TCO).

La question naturelle qui se pose maintenant est : que faire lorsque la récursion n'est pas terminale et ne peut pas être facilement transformée avec un accumulateur ? C'est précisément l'objet de notre prochaine leçon. Nous verrons qu'il nous faudra alors simuler la pile d'appels système avec une structure de pile explicite, ce qui nous ramènera directement à la première leçon de ce module.