Qu'est-ce que la récursivité de la queue?


1697

En commençant à apprendre le lisp, je suis tombé sur le terme récursif de queue . Qu'est-ce que cela signifie exactement?


155
Pour les curieux: tant pendant que tandis que dans la langue depuis très longtemps. Pendant qu'il était utilisé en vieil anglais; while est un développement moyen anglais de while. En tant que conjonctions, elles sont interchangeables dans leur sens, mais n'ont pas survécu dans l'anglais américain standard.
Filip Bartuzi

14
Peut-être qu'il est tard, mais c'est un très bon article sur la récursivité de la queue: programmerinterview.com/index.php/recursion/tail-recursion
Sam003

5
L'un des grands avantages de l'identification d'une fonction récursive de queue est qu'elle peut être convertie en une forme itérative et ainsi revivre l'algorithme à partir de la surcharge de méthode. Pourrait visiter la réponse de @Kyle Cronin et quelques autres ci
KGhatak

Ce lien de @yesudeep est la description la meilleure et la plus détaillée que j'ai trouvée - lua.org/pil/6.3.html
Jeff Fischer

1
Est-ce que quelqu'un pourrait me dire, est-ce que le tri par fusion et le tri rapide utilisent la récursion de queue (TRO)?
majurageerthan

Réponses:


1722

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


32
Puis-je dire qu'avec la récursivité de la queue, la réponse finale est calculée par la dernière invocation de la méthode seule? Si ce n'est PAS la récursivité de queue, vous avez besoin de tous les résultats pour toutes les méthodes pour calculer la réponse.
chrisapotek

2
Voici un addendum qui présente quelques exemples dans Lua: lua.org/pil/6.3.html Peut être utile de le parcourir également! :)
yesudeep

2
Quelqu'un peut-il répondre à la question de chrisapotek? Je ne sais pas comment tail recursionon peut y parvenir dans un langage qui n'optimise pas les appels distants.
Kevin Meredith

3
@KevinMeredith "récursivité de queue" signifie que la dernière instruction d'une fonction est un appel récursif à la même fonction. Vous avez raison, il est inutile de faire cela dans un langage qui n'optimise pas cette récursivité. Néanmoins, cette réponse montre le concept (presque) correctement. Cela aurait été plus clairement un appel de queue, si "else:" avait été omis. Ne changerait pas le comportement, mais placerait l'appel de queue comme une déclaration indépendante. Je vais soumettre cela comme une modification.
ToolmakerSteve

2
Donc, en python, il n'y a aucun avantage car à chaque appel à la fonction tailrecsum, un nouveau cadre de pile est créé - non?
Quazi Irfan

708

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.


17
"Je suis presque sûr que Lisp fait ça" - Scheme le fait, mais Common Lisp ne le fait pas toujours.
Aaron

2
@Daniel "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." - Je ne vois pas cet argument valable pour l'extrait de code publié par Lorin Hochstein. Pouvez-vous s'il vous plaît développer?
Geek

8
@Geek C'est une réponse très tardive, mais c'est en fait vrai dans l'exemple de Lorin Hochstein. Le calcul pour chaque étape se fait avant l'appel récursif, plutôt qu'après. Par conséquent, chaque arrêt renvoie simplement la valeur directement de l'étape précédente. Le dernier appel récursif termine le calcul, puis renvoie le résultat final non modifié tout le long de la pile d'appels.
reirab

3
Scala le fait mais vous avez besoin du @tailrec spécifié pour l'appliquer.
SilentDirge

2
"De cette manière, vous n'obtenez le résultat de votre calcul que lorsque vous êtes revenu de chaque appel récursif." - j'ai peut-être mal compris cela, mais ce n'est pas particulièrement vrai pour les langues paresseuses où la récursivité traditionnelle est le seul moyen d'obtenir réellement un résultat sans appeler toutes les récursions (par exemple, replier une liste infinie de Bools avec &&).
hasufell

206

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

Eet Qsont des expressions et Sest 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, Set Qdoivent ê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.)


Dans la réponse de @LorinHochstein, j'ai compris, sur la base de son explication, que la récursivité de la queue devait être lorsque la partie récursive suit "Retour", mais dans la vôtre, la récursivité de la queue ne l'est pas. Êtes-vous sûr que votre exemple est correctement considéré comme une récursion de queue?
CodyBugstein

1
@Imray La partie récursive de queue est l'instruction "return sum_aux" à l'intérieur de sum_aux.
Chris Conway

1
@lmray: le code de Chris est essentiellement équivalent. L'ordre du if / then et le style du test de limitation ... if x == 0 versus if (i <= n) ... n'est pas quelque chose à accrocher. Le fait est que chaque itération passe son résultat à la suivante.
Taylor

else { return k; }peut être changé enreturn k;
c0der

144

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 à gest un appel de queue:

function f (x)
  return g(x)
end

Après les fappels g, 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.


9
C'est une excellente réponse car elle explique les implications des appels de queue sur la taille de la pile.
Andrew Swan

@AndrewSwan En effet, bien que je pense que le demandeur d'origine et le lecteur occasionnel qui pourrait tomber sur cette question pourraient être mieux servis avec la réponse acceptée (car il pourrait ne pas savoir ce qu'est la pile en réalité.) À propos, j'utilise Jira, gros ventilateur.
Hoffmann

1
Ma réponse préférée aussi en raison de l'implication de la taille de la pile.
njk2015

80

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.


1
"Une grande requête récursive peut en fait provoquer un débordement de pile." devrait être dans le 1er paragraphe, pas dans le 2ème (récursion de la queue)? Le gros avantage de la récursivité de queue est qu'elle peut être (ex: Scheme) optimisée de manière à ne pas "accumuler" d'appels dans la pile, donc elle évitera surtout les débordements de pile!
Olivier Dulac

70

Le fichier jargon a ceci à dire sur la définition de la récursivité de la queue:

récursivité de la queue /n./

Si vous n'en avez pas déjà marre, voyez la récursivité de la queue.


68

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.


4
+1 pour avoir mentionné l'aspect le plus important des récursions de queue qu'elles peuvent être converties en une forme itérative et ainsi la transformer en une forme de complexité de mémoire O (1).
KGhatak

1
@KGhatak pas exactement; la réponse parle correctement d '"espace de pile constant", pas de mémoire en général. ne pas être tatillonne, juste pour s'assurer qu'il n'y a pas de malentendu. Par exemple, la list-reverseprocé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.
Will Ness

45

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)fac=1(valeur par défaut) et n est le nombre pour lequel la factorielle doit être calculée.


J'ai trouvé votre explication plus facile à comprendre, mais s'il s'agit de quelque chose, la récursivité de queue n'est utile que pour les fonctions avec des cas de base à une instruction. Considérez une méthode comme celle-ci postimg.cc/5Yg3Cdjn . Remarque: l'extérieur elseest 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?
Je veux des réponses

2
@IWantAnswers - Non, le corps de la fonction peut être arbitrairement volumineux. Tout ce qui est requis pour un appel final est que la branche dans laquelle elle se trouve appelle la fonction comme la toute dernière chose qu'elle fait, et renvoie le résultat de l'appel de la fonction. L' factorialexemple est juste l'exemple simple classique, c'est tout.
TJ Crowder

28

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.


21

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);
    }
}

3
0! est 1. Donc, "mynumber == 1" devrait être "mynumber == 0".
polerto

19

La meilleure façon pour moi de comprendre tail call recursionest 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 recsummé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).

ÉDITER

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.


12

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);
}

1
Cela renvoie des résultats erronés pour moi, pour l'entrée 8 j'obtiens 36, il doit être 21. Suis-je en train de manquer quelque chose? J'utilise java et je l'ai copié.
Alberto Zaccagni

1
Cela renvoie SUM (i) pour i dans [1, n]. Rien à voir avec Fibbonacci. Pour un Fibbo, vous avez besoin d' un des tests qui Substrats iterà accquand iter < (n-1).
Askolein

10

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.


10

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


9

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 "

entrez la description de l'image ici


1
Comment votre fonction N est-elle récursive?
Fabian Pijcke

N (x-1) 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)
doctorlai

Ma préoccupation est que votre fonction N est exactement la fonction résumée de la réponse acceptée de ce sujet (sauf qu'elle est une somme et non un produit), et que la récsum est dite non récursive de queue?
Fabian Pijcke

8

voici une version Perl 5 de la tailrecsumfonction 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
}

8

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.


1
J'ai lu toutes les réponses ici, et pourtant c'est l'explication la plus claire qui touche le cœur vraiment profond de ce concept. Il l'explique d'une manière si directe qui rend tout si simple et si clair. Pardonnez mon impolitesse s'il vous plaît. Cela me donne l'impression que les autres réponses ne frappent tout simplement pas le clou sur la tête. Je pense que c'est pourquoi le SICP est important.
englealuze

8

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

  1. Le premier point à considérer est quand devez-vous décider de sortir de la boucle qui est la boucle if
  2. La seconde est quel processus faire si nous sommes notre propre fonction

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)

  1. Remplaçant n = 4
public static int fact(4){
  if(4 <=1)
     return 1;
  else 
     return 4 * fact(4-1);
}

Ifla boucle échoue alors elle passe en elseboucle pour revenir4 * fact(3)

  1. 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);
}

Ifla boucle échoue donc elle passe en elseboucle

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)

  1. 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);
}

Ifla boucle échoue donc elle passe en elseboucle

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)

  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

entrez la description de l'image ici

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);
    }
}

  1. Remplaçant n = 4
public static int fact(4, running_total=1) {
    if (x==1) {
        return running_total;
    } else {
        return fact(4-1, running_total*4);
    }
}

Ifla boucle échoue alors elle passe en elseboucle pour revenirfact(3, 4)

  1. 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);
    }
}

Ifla boucle échoue donc elle passe en elseboucle

donc ça revient fact(2, 12)

  1. 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);
    }
}

Ifla boucle échoue donc elle passe en elseboucle

donc ça revient fact(1, 24)

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

entrez la description de l'image ici


7

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.


1
il ne se casse pas sous l' interprétation du trouble de la personnalité fractionnée . :) Une société de l'esprit; un esprit en tant que société. :)
Will Ness

Hou la la! Maintenant, c'est une autre façon d'y penser
sutanu dalui

7

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.


7

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.


6

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 accumulatorparamè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.


Msgstr "Récursivité de la queue (également appelée optimisation des appels de queue ou élimination des appels de queue)". Non; L'élimination ou l'optimisation de l'appel de queue est quelque chose que vous pouvez appliquer à une fonction récursive de queue, mais ce n'est pas la même chose. Vous pouvez écrire des fonctions récursives de queue en Python (comme vous le montrez), mais elles ne sont pas plus efficaces qu'une fonction non récursive de queue, car Python n'effectue pas d'optimisation des appels de queue.
chepner

Cela signifie-t-il que si quelqu'un parvient à optimiser le site Web et à rendre l'appel récursif récursif, nous n'aurions plus de site StackOverflow?! C'est horrible.
Nadjib Mami

5

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 .


4

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.


4

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


3

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.

    1. Le corps d'une expression lambda est une expression de queue
    2. Si (if E0 E1 E2)est une expression de queue, alors les deux E1et E2sont des expressions de queue.
    3. 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.)


1

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.

En utilisant notre site, vous reconnaissez avoir lu et compris notre politique liée aux cookies et notre politique de confidentialité.
Licensed under cc by-sa 3.0 with attribution required.