En commençant à apprendre le lisp, je suis tombé sur le terme récursif de queue . Qu'est-ce que cela signifie exactement?
En commençant à apprendre le lisp, je suis tombé sur le terme récursif de queue . Qu'est-ce que cela signifie exactement?
Réponses:
Considérons une fonction simple qui ajoute les N premiers nombres naturels. (par exemple sum(5) = 1 + 2 + 3 + 4 + 5 = 15
).
Voici une implémentation JavaScript simple qui utilise la récursivité:
function recsum(x) {
if (x === 1) {
return x;
} else {
return x + recsum(x - 1);
}
}
Si vous avez appelé recsum(5)
, voici ce que l'interpréteur JavaScript évaluerait:
recsum(5)
5 + recsum(4)
5 + (4 + recsum(3))
5 + (4 + (3 + recsum(2)))
5 + (4 + (3 + (2 + recsum(1))))
5 + (4 + (3 + (2 + 1)))
15
Notez comment chaque appel récursif doit se terminer avant que l'interpréteur JavaScript commence réellement à effectuer le travail de calcul de la somme.
Voici une version récursive de la même fonction:
function tailrecsum(x, running_total = 0) {
if (x === 0) {
return running_total;
} else {
return tailrecsum(x - 1, running_total + x);
}
}
Voici la séquence d'événements qui se produirait si vous appeliez tailrecsum(5)
, (ce qui serait effectivement tailrecsum(5, 0)
, en raison du deuxième argument par défaut).
tailrecsum(5, 0)
tailrecsum(4, 5)
tailrecsum(3, 9)
tailrecsum(2, 12)
tailrecsum(1, 14)
tailrecsum(0, 15)
15
Dans le cas récursif de queue, à chaque évaluation de l'appel récursif, le running_total
est mis à jour.
Remarque: La réponse d'origine utilisait des exemples de Python. Ceux-ci ont été modifiés en JavaScript, car les interprètes Python ne prennent pas en charge l' optimisation des appels de queue . Cependant, bien que l'optimisation des appels de queue fasse partie de la spécification ECMAScript 2015 , la plupart des interprètes JavaScript ne la prennent pas en charge .
tail recursion
on peut y parvenir dans un langage qui n'optimise pas les appels distants.
Dans la récursivité traditionnelle , le modèle typique est que vous effectuez d'abord vos appels récursifs, puis vous prenez la valeur de retour de l'appel récursif et calculez le résultat. De cette manière, vous n'obtenez le résultat de votre calcul que lorsque vous êtes revenu de chaque appel récursif.
Dans la récursivité de queue , vous effectuez d'abord vos calculs, puis vous exécutez l'appel récursif, en passant les résultats de votre étape actuelle à l'étape récursive suivante. Il en résulte que la dernière instruction est sous la forme de (return (recursive-function params))
. Fondamentalement, la valeur de retour de toute étape récursive donnée est la même que la valeur de retour du prochain appel récursif .
La conséquence de cela est qu'une fois que vous êtes prêt à effectuer votre prochaine étape récursive, vous n'avez plus besoin du cadre de pile actuel. Cela permet une certaine optimisation. En fait, avec un compilateur correctement écrit, vous ne devriez jamais avoir un snicker de débordement de pile avec un appel récursif de queue. Réutilisez simplement le cadre de pile actuel pour la prochaine étape récursive. Je suis sûr que Lisp fait ça.
Un point important est que la récursivité de la queue est essentiellement équivalente au bouclage. Ce n'est pas seulement une question d'optimisation du compilateur, mais un fait fondamental sur l'expressivité. Cela va dans les deux sens: vous pouvez prendre n'importe quelle boucle du formulaire
while(E) { S }; return Q
où E
et Q
sont des expressions et S
est une séquence d'instructions, et la transformer en une fonction récursive de queue
f() = if E then { S; return f() } else { return Q }
Bien sûr, E
, S
et Q
doivent être définies pour calculer une valeur intéressante sur certaines variables. Par exemple, la fonction de bouclage
sum(n) {
int i = 1, k = 0;
while( i <= n ) {
k += i;
++i;
}
return k;
}
est équivalent à la ou aux fonctions récursives de queue
sum_aux(n,i,k) {
if( i <= n ) {
return sum_aux(n,i+1,k+i);
} else {
return k;
}
}
sum(n) {
return sum_aux(n,1,0);
}
(Cet "encapsulage" de la fonction récursive de queue avec une fonction avec moins de paramètres est un idiome fonctionnel courant.)
else { return k; }
peut être changé enreturn k;
Cet extrait du livre Programming in Lua montre comment faire une récursion de queue appropriée (en Lua, mais devrait également s'appliquer à Lisp) et pourquoi c'est mieux.
Un appel de queue [récursion de queue] est une sorte de goto habillé comme un appel. Un appel de queue se produit lorsqu'une fonction en appelle une autre comme dernière action, elle n'a donc rien d'autre à faire. Par exemple, dans le code suivant, l'appel à
g
est un appel de queue:function f (x) return g(x) end
Après les
f
appelsg
, il n'a plus rien à faire. Dans de telles situations, le programme n'a pas besoin de revenir à la fonction appelante lorsque la fonction appelée se termine. Par conséquent, après l'appel final, le programme n'a pas besoin de conserver d'informations sur la fonction appelante dans la pile. ...Étant donné qu'un appel de queue approprié n'utilise aucun espace de pile, il n'y a pas de limite sur le nombre d'appels de queue "imbriqués" qu'un programme peut effectuer. Par exemple, nous pouvons appeler la fonction suivante avec n'importe quel nombre comme argument; il ne débordera jamais la pile:
function foo (n) if n > 0 then return foo(n - 1) end end
... Comme je l'ai dit plus tôt, un appel de queue est une sorte de goto. En tant que tel, une application très utile des appels de queue appropriés dans Lua est pour la programmation de machines à états. Ces applications peuvent représenter chaque état par une fonction; changer d'état, c'est aller à (ou appeler) une fonction spécifique. À titre d'exemple, considérons un simple jeu de labyrinthe. Le labyrinthe a plusieurs pièces, chacune avec jusqu'à quatre portes: nord, sud, est et ouest. À chaque étape, l'utilisateur entre dans une direction de mouvement. S'il y a une porte dans cette direction, l'utilisateur se rend dans la pièce correspondante; sinon, le programme imprime un avertissement. L'objectif est de passer d'une salle initiale à une salle finale.
Ce jeu est une machine d'état typique, où la salle actuelle est l'état. Nous pouvons implémenter un tel labyrinthe avec une fonction pour chaque pièce. Nous utilisons les appels de queue pour passer d'une pièce à l'autre. Un petit labyrinthe de quatre pièces pourrait ressembler à ceci:
function room1 () local move = io.read() if move == "south" then return room3() elseif move == "east" then return room2() else print("invalid move") return room1() -- stay in the same room end end function room2 () local move = io.read() if move == "south" then return room4() elseif move == "west" then return room1() else print("invalid move") return room2() end end function room3 () local move = io.read() if move == "north" then return room1() elseif move == "east" then return room4() else print("invalid move") return room3() end end function room4 () print("congratulations!") end
Vous voyez donc, lorsque vous effectuez un appel récursif comme:
function x(n)
if n==0 then return 0
n= n-2
return x(n) + 1
end
Ce n'est pas récursif de queue parce que vous avez encore des choses à faire (ajouter 1) dans cette fonction après l'appel récursif. Si vous entrez un nombre très élevé, cela entraînera probablement un débordement de pile.
En utilisant la récursivité régulière, chaque appel récursif pousse une autre entrée sur la pile d'appels. Lorsque la récursivité est terminée, l'application doit ensuite supprimer chaque entrée tout en bas.
Avec la récursivité de la queue, en fonction de la langue, le compilateur peut réduire la pile en une seule entrée, ce qui vous permet d'économiser de l'espace de pile ... Une grande requête récursive peut en fait provoquer un débordement de pile.
Fondamentalement, les récursions de queue peuvent être optimisées en itération.
Au lieu de l'expliquer avec des mots, voici un exemple. Il s'agit d'une version Scheme de la fonction factorielle:
(define (factorial x)
(if (= x 0) 1
(* x (factorial (- x 1)))))
Voici une version de factorielle récursive:
(define factorial
(letrec ((fact (lambda (x accum)
(if (= x 0) accum
(fact (- x 1) (* accum x))))))
(lambda (x)
(fact x 1))))
Vous remarquerez dans la première version que l'appel récursif aux faits est introduit dans l'expression de multiplication, et donc l'état doit être enregistré sur la pile lors de l'appel récursif. Dans la version tail-recursive, aucune autre expression S n'attend la valeur de l'appel récursif, et comme il n'y a plus de travail à faire, l'état n'a pas besoin d'être enregistré sur la pile. En règle générale, les fonctions récursives de la queue Scheme utilisent un espace de pile constant.
list-reverse
procédure de mutation liste-queue récursive-queue s'exécutera dans un espace de pile constant mais créera et augmentera une structure de données sur le tas. Une traversée d'arbre pourrait utiliser une pile simulée, dans un argument supplémentaire. etc.
La récursivité de queue fait référence à l'appel récursif en dernier dans la dernière instruction logique de l'algorithme récursif.
Généralement en récursivité, vous avez un cas de base qui est ce qui arrête les appels récursifs et commence à faire apparaître la pile d'appels. Pour utiliser un exemple classique, bien que plus C-ish que Lisp, la fonction factorielle illustre la récursivité de la queue. L'appel récursif se produit après avoir vérifié l'état du cas de base.
factorial(x, fac=1) {
if (x == 1)
return fac;
else
return factorial(x-1, x*fac);
}
L'appel initial à la factorielle serait factorial(n)
où fac=1
(valeur par défaut) et n est le nombre pour lequel la factorielle doit être calculée.
else
est l'étape que vous pourriez appeler un «cas de base», mais s'étend sur plusieurs lignes. Suis-je mal compris ou mon hypothèse est-elle correcte? La récursivité de la queue n'est bonne que pour une doublure?
factorial
exemple est juste l'exemple simple classique, c'est tout.
Cela signifie qu'au lieu de devoir pousser le pointeur d'instruction sur la pile, vous pouvez simplement sauter en haut d'une fonction récursive et continuer l'exécution. Cela permet aux fonctions de récurser indéfiniment sans déborder la pile.
J'ai écrit un article de blog sur le sujet, qui contient des exemples graphiques de l'apparence des cadres de pile.
Voici un extrait de code rapide comparant deux fonctions. La première est la récursion traditionnelle pour trouver la factorielle d'un nombre donné. Le second utilise la récursivité de queue.
Très simple et intuitif à comprendre.
Un moyen simple de savoir si une fonction récursive est une récursive de queue consiste à renvoyer une valeur concrète dans le cas de base. Cela signifie qu'il ne renvoie pas 1 ou vrai ou quelque chose comme ça. Il retournera très probablement une variante de l'un des paramètres de la méthode.
Une autre façon est de savoir si l'appel récursif est exempt de tout ajout, arithmétique, modification, etc ... ce qui signifie que ce n'est qu'un appel récursif pur.
public static int factorial(int mynumber) {
if (mynumber == 1) {
return 1;
} else {
return mynumber * factorial(--mynumber);
}
}
public static int tail_factorial(int mynumber, int sofar) {
if (mynumber == 1) {
return sofar;
} else {
return tail_factorial(--mynumber, sofar * mynumber);
}
}
La meilleure façon pour moi de comprendre tail call recursion
est un cas spécial de récursivité où le dernier appel (ou le dernier appel) est la fonction elle-même.
Comparaison des exemples fournis en Python:
def recsum(x):
if x == 1:
return x
else:
return x + recsum(x - 1)
^ RÉCURSION
def tailrecsum(x, running_total=0):
if x == 0:
return running_total
else:
return tailrecsum(x - 1, running_total + x)
^ RÉCURSION DE QUEUE
Comme vous pouvez le voir dans la version récursive générale, l'appel final dans le bloc de code est x + recsum(x - 1)
. Donc, après avoir appelé la recsum
méthode, il y a une autre opération qui l'est x + ..
.
Cependant, dans la version récursive de queue, l'appel final (ou l'appel de queue) dans le bloc de code est tailrecsum(x - 1, running_total + x)
ce qui signifie que le dernier appel est fait à la méthode elle-même et aucune opération après cela.
Ce point est important car la récursivité de la queue, comme on le voit ici, ne fait pas augmenter la mémoire car lorsque la machine virtuelle sous-jacente voit une fonction s'appelant dans une position de queue (la dernière expression à évaluer dans une fonction), elle élimine le cadre de pile actuel, qui est connu comme Tail Call Optimization (TCO).
NB. Gardez à l'esprit que l'exemple ci-dessus est écrit en Python dont le runtime ne prend pas en charge TCO. Ceci est juste un exemple pour expliquer le point. TCO est pris en charge dans des langues comme Scheme, Haskell, etc.
En Java, voici une implémentation récursive possible de la fonction Fibonacci:
public int tailRecursive(final int n) {
if (n <= 2)
return 1;
return tailRecursiveAux(n, 1, 1);
}
private int tailRecursiveAux(int n, int iter, int acc) {
if (iter == n)
return acc;
return tailRecursiveAux(n, ++iter, acc + iter);
}
Comparez cela avec l'implémentation récursive standard:
public int recursive(final int n) {
if (n <= 2)
return 1;
return recursive(n - 1) + recursive(n - 2);
}
iter
à acc
quand iter < (n-1)
.
Je ne suis pas un programmeur Lisp, mais je pense que cela vous aidera.
Fondamentalement, c'est un style de programmation tel que l'appel récursif est la dernière chose que vous faites.
Voici un exemple Common Lisp qui fait des factorielles en utilisant la récursion de queue. En raison de la nature sans pile, on pourrait effectuer des calculs factoriels incroyablement grands ...
(defun ! (n &optional (product 1))
(if (zerop n) product
(! (1- n) (* product n))))
Et puis pour le plaisir, vous pouvez essayer (format nil "~R" (! 25))
En bref, une récursion de queue a l'appel récursif comme dernière instruction de la fonction afin qu'elle n'ait pas à attendre l'appel récursif.
Il s'agit donc d'une récursion de queue, c'est-à-dire que N (x - 1, p * x) est la dernière instruction de la fonction où le compilateur est intelligent pour comprendre qu'il peut être optimisé en une boucle for (factorielle). Le deuxième paramètre p porte la valeur de produit intermédiaire.
function N(x, p) {
return x == 1 ? p : N(x - 1, p * x);
}
C'est la façon non récursive d'écrire la fonction factorielle ci-dessus (bien que certains compilateurs C ++ puissent de toute façon l'optimiser).
function N(x) {
return x == 1 ? 1 : x * N(x - 1);
}
mais ce n'est pas:
function F(x) {
if (x == 1) return 0;
if (x == 2) return 1;
return F(x - 1) + F(x - 2);
}
J'ai écrit un long article intitulé " Comprendre la récursivité de la queue - Visual Studio C ++ - Vue d'assemblage "
voici une version Perl 5 de la tailrecsum
fonction mentionnée plus haut.
sub tail_rec_sum($;$){
my( $x,$running_total ) = (@_,0);
return $running_total unless $x;
@_ = ($x-1,$running_total+$x);
goto &tail_rec_sum; # throw away current stack frame
}
Ceci est un extrait de Structure and Interpretation of Computer Programs about tail recursion.
En opposant itération et récursivité, il faut faire attention à ne pas confondre la notion de processus récursif avec la notion de procédure récursive. Lorsque nous décrivons une procédure comme récursive, nous faisons référence au fait syntaxique que la définition de la procédure se réfère (directement ou indirectement) à la procédure elle-même. Mais lorsque nous décrivons un processus comme suivant un modèle qui est, par exemple, linéairement récursif, nous parlons de la façon dont le processus évolue, et non de la syntaxe de la façon dont une procédure est écrite. Il peut sembler troublant que nous parlions d'une procédure récursive telle que fact-iter comme générant un processus itératif. Cependant, le processus est vraiment itératif: son état est entièrement capturé par ses trois variables d'état, et un interprète n'a besoin de garder trace que de trois variables pour exécuter le processus.
L'une des raisons pour lesquelles la distinction entre processus et procédure peut prêter à confusion est que la plupart des implémentations de langages courants (y compris Ada, Pascal et C) sont conçues de telle manière que l'interprétation de toute procédure récursive consomme une quantité de mémoire qui croît avec le nombre d'appels de procédure, même lorsque le processus décrit est, en principe, itératif. Par conséquent, ces langages ne peuvent décrire des processus itératifs qu'en recourant à des «constructions en boucle» spéciales telles que do, repeat, until, for et while. La mise en œuvre de Scheme ne partage pas ce défaut. Il exécutera un processus itératif dans un espace constant, même si le processus itératif est décrit par une procédure récursive. Une implémentation avec cette propriété est appelée tail-recursive. Avec une implémentation récursive, l'itération peut être exprimée en utilisant le mécanisme d'appel de procédure ordinaire, de sorte que les constructions d'itération spéciales ne sont utiles qu'en tant que sucre syntaxique.
La fonction récursive est une fonction qui appelle par elle-même
Il permet aux programmeurs d'écrire des programmes efficaces en utilisant une quantité minimale de code .
L'inconvénient est qu'ils peuvent provoquer des boucles infinies et d'autres résultats inattendus s'ils ne sont pas écrits correctement .
Je vais expliquer à la fois la fonction récursive simple et la fonction récursive de queue
Pour écrire une fonction récursive simple
De l'exemple donné:
public static int fact(int n){
if(n <=1)
return 1;
else
return n * fact(n-1);
}
De l'exemple ci-dessus
if(n <=1)
return 1;
Est le facteur décisif quand sortir de la boucle
else
return n * fact(n-1);
Le traitement réel doit-il être effectué
Permettez-moi de rompre la tâche une par une pour une compréhension facile.
Voyons ce qui se passe en interne si je cours fact(4)
public static int fact(4){
if(4 <=1)
return 1;
else
return 4 * fact(4-1);
}
If
la boucle échoue alors elle passe en else
boucle pour revenir4 * fact(3)
Dans la mémoire de la pile, nous avons 4 * fact(3)
Remplaçant n = 3
public static int fact(3){
if(3 <=1)
return 1;
else
return 3 * fact(3-1);
}
If
la boucle échoue donc elle passe en else
boucle
donc ça revient 3 * fact(2)
N'oubliez pas que nous avons appelé `` `` 4 * fact (3) ''
La sortie pour fact(3) = 3 * fact(2)
Jusqu'à présent, la pile a 4 * fact(3) = 4 * 3 * fact(2)
Dans la mémoire de la pile, nous avons 4 * 3 * fact(2)
Remplaçant n = 2
public static int fact(2){
if(2 <=1)
return 1;
else
return 2 * fact(2-1);
}
If
la boucle échoue donc elle passe en else
boucle
donc ça revient 2 * fact(1)
N'oubliez pas que nous avons appelé 4 * 3 * fact(2)
La sortie pour fact(2) = 2 * fact(1)
Jusqu'à présent, la pile a 4 * 3 * fact(2) = 4 * 3 * 2 * fact(1)
Dans la mémoire de la pile, nous avons 4 * 3 * 2 * fact(1)
Remplaçant n = 1
public static int fact(1){
if(1 <=1)
return 1;
else
return 1 * fact(1-1);
}
If
la boucle est vraie
donc ça revient 1
N'oubliez pas que nous avons appelé 4 * 3 * 2 * fact(1)
La sortie pour fact(1) = 1
Jusqu'à présent, la pile a 4 * 3 * 2 * fact(1) = 4 * 3 * 2 * 1
Enfin, le résultat de fait (4) = 4 * 3 * 2 * 1 = 24
La récursivité de la queue serait
public static int fact(x, running_total=1) {
if (x==1) {
return running_total;
} else {
return fact(x-1, running_total*x);
}
}
public static int fact(4, running_total=1) {
if (x==1) {
return running_total;
} else {
return fact(4-1, running_total*4);
}
}
If
la boucle échoue alors elle passe en else
boucle pour revenirfact(3, 4)
Dans la mémoire de la pile, nous avons fact(3, 4)
Remplaçant n = 3
public static int fact(3, running_total=4) {
if (x==1) {
return running_total;
} else {
return fact(3-1, 4*3);
}
}
If
la boucle échoue donc elle passe en else
boucle
donc ça revient fact(2, 12)
Dans la mémoire de la pile, nous avons fact(2, 12)
Remplaçant n = 2
public static int fact(2, running_total=12) {
if (x==1) {
return running_total;
} else {
return fact(2-1, 12*2);
}
}
If
la boucle échoue donc elle passe en else
boucle
donc ça revient fact(1, 24)
Dans la mémoire de la pile, nous avons fact(1, 24)
Remplaçant n = 1
public static int fact(1, running_total=24) {
if (x==1) {
return running_total;
} else {
return fact(1-1, 24*1);
}
}
If
la boucle est vraie
donc ça revient running_total
La sortie pour running_total = 24
Enfin, le résultat de fait (4,1) = 24
La récursivité de la queue est la vie que vous vivez en ce moment. Vous recyclez constamment le même cadre de pile, encore et encore, car il n'y a aucune raison ou moyen de revenir à un cadre "précédent". Le passé est révolu pour qu'il puisse être jeté. Vous obtenez une image, vous vous déplaçant à jamais vers l'avenir, jusqu'à ce que votre processus meure inévitablement.
L'analogie tombe en panne lorsque vous considérez que certains processus peuvent utiliser des trames supplémentaires, mais sont toujours considérés comme récursifs si la pile ne croît pas à l'infini.
Une récursion de queue est une fonction récursive où la fonction s'appelle à la fin ("queue") de la fonction dans laquelle aucun calcul n'est effectué après le retour de l'appel récursif. De nombreux compilateurs optimisent pour changer un appel récursif en un appel récursif de queue ou un appel itératif.
Considérons le problème du calcul factoriel d'un nombre.
Une approche simple serait:
factorial(n):
if n==0 then 1
else n*factorial(n-1)
Supposons que vous appeliez factorielle (4). L'arbre de récursivité serait:
factorial(4)
/ \
4 factorial(3)
/ \
3 factorial(2)
/ \
2 factorial(1)
/ \
1 factorial(0)
\
1
La profondeur de récursivité maximale dans le cas ci-dessus est O (n).
Cependant, considérez l'exemple suivant:
factAux(m,n):
if n==0 then m;
else factAux(m*n,n-1);
factTail(n):
return factAux(1,n);
L'arbre de récursivité pour factTail (4) serait:
factTail(4)
|
factAux(1,4)
|
factAux(4,3)
|
factAux(12,2)
|
factAux(24,1)
|
factAux(24,0)
|
24
Ici aussi, la profondeur de récursivité maximale est O (n) mais aucun des appels n'ajoute de variable supplémentaire à la pile. Par conséquent, le compilateur peut supprimer une pile.
La récursion de queue est assez rapide par rapport à la récursivité normale. C'est rapide car la sortie de l'appel des ancêtres ne sera pas écrite dans la pile pour garder la trace. Mais dans la récursivité normale, tous les ancêtres appellent la sortie écrite dans la pile pour garder la trace.
Une fonction récursive de queue est une fonction récursive où la dernière opération qu'elle effectue avant de retourner est de faire l'appel de la fonction récursive. Autrement dit, la valeur de retour de l'appel de fonction récursive est immédiatement renvoyée. Par exemple, votre code ressemblerait à ceci:
def recursiveFunction(some_params):
# some code here
return recursiveFunction(some_args)
# no code after the return statement
Les compilateurs et les interprètes qui implémentent l' optimisation des appels de queue ou l' élimination des appels de queue peuvent optimiser le code récursif pour éviter les débordements de pile. Si votre compilateur ou interprète n'implémente pas l'optimisation des appels de queue (comme l'interpréteur CPython), il n'y a aucun avantage supplémentaire à écrire votre code de cette façon.
Par exemple, il s'agit d'une fonction factorielle récursive standard en Python:
def factorial(number):
if number == 1:
# BASE CASE
return 1
else:
# RECURSIVE CASE
# Note that `number *` happens *after* the recursive call.
# This means that this is *not* tail call recursion.
return number * factorial(number - 1)
Et ceci est une version récursive de l'appel de queue de la fonction factorielle:
def factorial(number, accumulator=1):
if number == 0:
# BASE CASE
return accumulator
else:
# RECURSIVE CASE
# There's no code after the recursive call.
# This is tail call recursion:
return factorial(number - 1, number * accumulator)
print(factorial(5))
(Notez que même s'il s'agit de code Python, l'interpréteur CPython ne fait pas d'optimisation des appels de queue, donc organiser votre code comme celui-ci ne confère aucun avantage à l'exécution.)
Vous devrez peut-être rendre votre code un peu plus illisible pour utiliser l'optimisation des appels de queue, comme le montre l'exemple factoriel. (Par exemple, le scénario de base est maintenant un peu peu intuitif et le accumulator
paramètre est effectivement utilisé comme une sorte de variable globale.)
Mais l'avantage de l'optimisation des appels de queue est qu'elle empêche les erreurs de débordement de pile. (Je noterai que vous pouvez obtenir ce même avantage en utilisant un algorithme itératif au lieu d'un algorithme récursif.)
Les débordements de pile sont provoqués lorsque la pile d'appels a reçu trop d'objets frame. Un objet frame est poussé sur la pile d'appels lorsqu'une fonction est appelée et sauté hors de la pile d'appels lorsque la fonction revient. Les objets Frame contiennent des informations telles que les variables locales et la ligne de code à retourner lorsque la fonction revient.
Si votre fonction récursive effectue trop d'appels récursifs sans retour, la pile d'appels peut dépasser sa limite d'objet de trame. (Le nombre varie selon la plate-forme; en Python, il s'agit de 1000 objets de trame par défaut.) Cela provoque une erreur de dépassement de pile . (Hé, c'est de là que vient le nom de ce site!)
Cependant, si la dernière chose que fait votre fonction récursive est de faire l'appel récursif et de renvoyer sa valeur de retour, il n'y a aucune raison pour qu'elle garde l'objet frame actuel pour rester dans la pile des appels. Après tout, s'il n'y a pas de code après l'appel de fonction récursive, il n'y a aucune raison de s'accrocher aux variables locales de l'objet cadre actuel. Nous pouvons donc nous débarrasser de l'objet cadre actuel immédiatement plutôt que de le conserver dans la pile des appels. Le résultat final est que votre pile d'appels n'augmente pas en taille et ne peut donc pas déborder.
Un compilateur ou un interpréteur doit avoir l'optimisation des appels de queue comme une fonctionnalité pour pouvoir reconnaître quand l'optimisation des appels de queue peut être appliquée. Même alors, vous pouvez avoir réorganisé le code dans votre fonction récursive pour utiliser l'optimisation des appels de queue, et c'est à vous de décider si cette diminution potentielle de la lisibilité vaut l'optimisation.
Pour comprendre certaines des principales différences entre la récursivité d'appel de queue et la récursion sans appel de queue, nous pouvons explorer les implémentations .NET de ces techniques.
Voici un article avec quelques exemples en C #, F # et C ++ \ CLI: Adventures in Tail Recursion en C #, F # et C ++ \ CLI .
C # n'optimise pas pour la récursivité d'appel de queue alors que F # le fait.
Les différences de principe impliquent des boucles par rapport au calcul Lambda. C # est conçu avec des boucles à l'esprit tandis que F # est construit à partir des principes du calcul Lambda. Pour un très bon (et gratuit) livre sur les principes du calcul lambda, voir Structure and Interpretation of Computer Programs, par Abelson, Sussman et Sussman .
Concernant les appels de queue en F #, pour un très bon article d'introduction, voir Introduction détaillée aux appels de queue en F # . Enfin, voici un article qui couvre la différence entre la récursion non-queue et la récursivité appel-queue (en F #): récursion-queue vs récursion non-queue en F sharp .
Si vous souhaitez en savoir plus sur certaines des différences de conception de la récursivité des appels de queue entre C # et F #, consultez Génération de l'opcode Tail-Call en C # et F # .
Si vous vous souciez suffisamment de vouloir savoir quelles conditions empêchent le compilateur C # d'effectuer des optimisations d'appel de fin, consultez cet article: Conditions d'appel de fin JIT CLR .
Il existe deux types de récursions de base: la récursion de tête et la récursion de queue.
Dans la récursivité de la tête , une fonction effectue son appel récursif puis effectue quelques calculs supplémentaires, en utilisant peut-être le résultat de l'appel récursif, par exemple.
Dans une fonction récursive de queue , tous les calculs se produisent en premier et l'appel récursif est la dernière chose qui se produit.
Tiré de ce post super génial. Veuillez envisager de le lire.
La récursivité signifie une fonction qui s'appelle elle-même. Par exemple:
(define (un-ended name)
(un-ended 'me)
(print "How can I get here?"))
Tail-Recursion signifie la récursion qui conclut la fonction:
(define (un-ended name)
(print "hello")
(un-ended 'me))
Vous voyez, la dernière chose que la fonction non terminée (procédure, dans le jargon du schéma) fait est de s'appeler. Un autre exemple (plus utile) est:
(define (map lst op)
(define (helper done left)
(if (nil? left)
done
(helper (cons (op (car left))
done)
(cdr left))))
(reverse (helper '() lst)))
Dans la procédure d'aide, la DERNIÈRE chose qu'elle fait si la gauche n'est pas nulle est de s'appeler (après quelque chose contre et quelque chose cdr). Voici essentiellement comment mapper une liste.
La récursivité de queue a un grand avantage que l'interpréteur (ou le compilateur, dépendant de la langue et du fournisseur) peut l'optimiser et le transformer en quelque chose d'équivalent à une boucle while. En fait, dans la tradition de Scheme, la plupart des boucles "for" et "while" se font de manière récursive (il n'y en a pas pour et pendant, pour autant que je sache).
Cette question a beaucoup de bonnes réponses ... mais je ne peux pas m'empêcher de donner un coup de pouce avec une alternative sur la façon de définir la "récursivité de la queue", ou au moins "la récursion de la queue appropriée". A savoir: faut-il la regarder comme une propriété d'une expression particulière dans un programme? Ou faut-il la considérer comme une propriété d'une implémentation d'un langage de programmation ?
Pour en savoir plus sur ce dernier point de vue, il existe un article classique de Will Clinger, "Proper Tail Recursion and Space Efficiency" (PLDI 1998), qui définit la "bonne queue récursivité" comme une propriété d'une implémentation de langage de programmation. La définition est construite pour permettre d'ignorer les détails d'implémentation (par exemple, si la pile d'appels est réellement représentée via la pile d'exécution ou via une liste liée de trames allouée par segment).
Pour ce faire, il utilise une analyse asymptotique: non pas du temps d'exécution du programme comme on le voit habituellement, mais plutôt de l'utilisation de l'espace du programme . De cette façon, l'utilisation de l'espace d'une liste liée allouée par segment de mémoire par rapport à une pile d'appels d'exécution finit par être équivalente asymptotiquement; donc on arrive à ignorer ce détail d'implémentation du langage de programmation (un détail qui importe certainement un peu en pratique, mais peut brouiller les eaux un peu quand on essaie de déterminer si une implémentation donnée satisfait à l'exigence d'être "propriété récursive") )
Le document mérite une étude approfondie pour un certain nombre de raisons:
Il donne une définition inductive des expressions de queue et des appels de queue d'un programme. (Une telle définition, et pourquoi de tels appels sont importants, semble faire l'objet de la plupart des autres réponses données ici.)
Voici ces définitions, juste pour donner un aperçu du texte:
Définition 1 Les expressions de queue d'un programme écrit dans Core Scheme sont définies de manière inductive comme suit.
- Le corps d'une expression lambda est une expression de queue
- Si
(if E0 E1 E2)
est une expression de queue, alors les deuxE1
etE2
sont des expressions de queue.- Rien d'autre n'est une expression de queue.
Définition 2 Un appel de queue est une expression de queue qui est un appel de procédure.
(Un appel récursif de queue, ou comme le dit l'article, "l'appel de self-tail" est un cas spécial d'un appel de queue où la procédure est invoquée elle-même.)
Il fournit des définitions formelles pour six "machines" différentes pour évaluer le schéma de base, où chaque machine a le même comportement observable à l' exception de la classe de complexité d'espace asymptotique dans laquelle chacune se trouve.
Par exemple, après avoir donné des définitions pour les machines avec respectivement: 1. la gestion de la mémoire basée sur la pile, 2. le garbage collection mais pas d'appels de queue, 3. le garbage collection et les appels de queue, le papier continue avec des stratégies de gestion du stockage encore plus avancées, telles que 4. "evlis tail recursion", où l'environnement n'a pas besoin d'être préservé tout au long de l'évaluation du dernier argument de sous-expression dans un appel de queue, 5. réduire l'environnement d'une fermeture aux seules variables libres de cette fermeture, et 6. la sémantique dite «sûre pour l'espace» telle que définie par Appel et Shao .
Afin de prouver que les machines appartiennent en fait à six classes de complexité d'espace distinctes, l'article, pour chaque paire de machines comparées, fournit des exemples concrets de programmes qui exposeront l'explosion d'espace asymptotique sur une machine mais pas sur l'autre.
(En lisant ma réponse maintenant, je ne sais pas si j'ai réussi à saisir les points cruciaux du document Clinger . Mais, hélas, je ne peux pas consacrer plus de temps à l'élaboration de cette réponse pour le moment.)
Beaucoup de gens ont déjà expliqué la récursivité ici. Je voudrais citer quelques réflexions sur certains avantages que la récursivité donne du livre "Concurrency in .NET, Modern patterns of concurrent and parallel programming" de Riccardo Terrell:
«La récursion fonctionnelle est le moyen naturel d'itérer en PF car elle évite la mutation d'état. Au cours de chaque itération, une nouvelle valeur est transmise au constructeur de boucle à la place pour être mise à jour (mutée). En outre, une fonction récursive peut être composée, ce qui rend votre programme plus modulaire, ainsi que des opportunités d'exploitation de la parallélisation. "
Voici également quelques notes intéressantes du même livre sur la récursivité de la queue:
La récursivité d'appel est une technique qui convertit une fonction récursive régulière en une version optimisée qui peut gérer de grandes entrées sans aucun risque ni effet secondaire.
REMARQUE La raison principale d'un appel final en tant qu'optimisation est d'améliorer la localisation des données, l'utilisation de la mémoire et l'utilisation du cache. En effectuant un appel de queue, l'appelé utilise le même espace de pile que l'appelant. Cela réduit la pression de la mémoire. Il améliore légèrement le cache car la même mémoire est réutilisée pour les appelants suivants et peut rester dans le cache, plutôt que d'expulser une ancienne ligne de cache pour faire de la place pour une nouvelle ligne de cache.