Al comenzar a aprender lisp, me he encontrado con el término recursivo de cola . ¿Qué significa exactamente?
Al comenzar a aprender lisp, me he encontrado con el término recursivo de cola . ¿Qué significa exactamente?
Respuestas:
Considere una función simple que agrega los primeros N números naturales. (por ejemplo sum(5) = 1 + 2 + 3 + 4 + 5 = 15
)
Aquí hay una implementación simple de JavaScript que utiliza la recursividad:
function recsum(x) {
if (x === 1) {
return x;
} else {
return x + recsum(x - 1);
}
}
Si llamó recsum(5)
, esto es lo que evaluaría el intérprete de JavaScript:
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
Observe cómo cada llamada recursiva debe completarse antes de que el intérprete de JavaScript comience a hacer el trabajo de calcular la suma.
Aquí hay una versión recursiva de la cola de la misma función:
function tailrecsum(x, running_total = 0) {
if (x === 0) {
return running_total;
} else {
return tailrecsum(x - 1, running_total + x);
}
}
Aquí está la secuencia de eventos que ocurrirían si llamaras tailrecsum(5)
, (lo que sería efectivamente tailrecsum(5, 0)
, debido al segundo argumento predeterminado).
tailrecsum(5, 0)
tailrecsum(4, 5)
tailrecsum(3, 9)
tailrecsum(2, 12)
tailrecsum(1, 14)
tailrecsum(0, 15)
15
En el caso recursivo de cola, con cada evaluación de la llamada recursiva, running_total
se actualiza.
Nota: La respuesta original utiliza ejemplos de Python. Estos se han cambiado a JavaScript, ya que los intérpretes de Python no admiten la optimización de llamadas de cola . Sin embargo, aunque la optimización de la cola de llamadas es parte de la especificación ECMAScript 2015 , la mayoría de los intérpretes de JavaScript no la admiten .
tail recursion
se puede lograr en un lenguaje que no optimiza las llamadas de cola.
En la recursividad tradicional , el modelo típico es que primero realiza sus llamadas recursivas y luego toma el valor de retorno de la llamada recursiva y calcula el resultado. De esta manera, no obtiene el resultado de su cálculo hasta que haya regresado de cada llamada recursiva.
En la recursividad de cola , primero realiza sus cálculos y luego ejecuta la llamada recursiva, pasando los resultados de su paso actual al siguiente paso recursivo. Esto da como resultado que la última declaración tenga la forma de (return (recursive-function params))
. Básicamente, el valor de retorno de cualquier paso recursivo dado es el mismo que el valor de retorno de la próxima llamada recursiva .
La consecuencia de esto es que una vez que esté listo para realizar su próximo paso recursivo, ya no necesita el marco de pila actual. Esto permite cierta optimización. De hecho, con un compilador de manera apropiada por escrito, nunca debe tener un desbordamiento de pila risita con una llamada recursiva de cola. Simplemente reutilice el marco de pila actual para el siguiente paso recursivo. Estoy bastante seguro de que Lisp hace esto.
Un punto importante es que la recursión de la cola es esencialmente equivalente al bucle. No es solo una cuestión de optimización del compilador, sino un hecho fundamental sobre la expresividad. Esto va en ambos sentidos: puede tomar cualquier bucle del formulario
while(E) { S }; return Q
donde E
y Q
son expresiones y S
es una secuencia de enunciados, y convertirlo en una función recursiva de cola
f() = if E then { S; return f() } else { return Q }
Por supuesto, E
, S
, y Q
tienen que ser definidas para calcular un valor interesante sobre algunas variables. Por ejemplo, la función de bucle
sum(n) {
int i = 1, k = 0;
while( i <= n ) {
k += i;
++i;
}
return k;
}
es equivalente a las funciones recursivas de cola
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);
}
(Este "ajuste" de la función recursiva de la cola con una función con menos parámetros es un idioma funcional común).
else { return k; }
se puede cambiar areturn k;
Este extracto del libro Programación en Lua muestra cómo hacer una recursión de cola adecuada (en Lua, pero también debería aplicarse a Lisp) y por qué es mejor.
Una llamada de cola [recursión de cola] es una especie de goto vestido como una llamada. Una llamada de cola ocurre cuando una función llama a otra como su última acción, por lo que no tiene nada más que hacer. Por ejemplo, en el siguiente código, la llamada a
g
es una llamada de cola:function f (x) return g(x) end
Después de las
f
llamadasg
, no tiene nada más que hacer. En tales situaciones, el programa no necesita volver a la función de llamada cuando finaliza la función llamada. Por lo tanto, después de la llamada de cola, el programa no necesita mantener ninguna información sobre la función de llamada en la pila. ...Debido a que una llamada de cola adecuada no utiliza espacio de pila, no hay límite en el número de llamadas de cola "anidadas" que puede hacer un programa. Por ejemplo, podemos llamar a la siguiente función con cualquier número como argumento; nunca desbordará la pila:
function foo (n) if n > 0 then return foo(n - 1) end end
... Como dije antes, una llamada de cola es una especie de goto. Como tal, una aplicación bastante útil de llamadas de cola adecuadas en Lua es para programar máquinas de estado. Dichas aplicaciones pueden representar cada estado por una función; cambiar de estado es ir a (o llamar) una función específica. Como ejemplo, consideremos un simple juego de laberinto. El laberinto tiene varias habitaciones, cada una con hasta cuatro puertas: norte, sur, este y oeste. En cada paso, el usuario ingresa una dirección de movimiento. Si hay una puerta en esa dirección, el usuario va a la sala correspondiente; de lo contrario, el programa imprime una advertencia. El objetivo es pasar de una habitación inicial a una habitación final.
Este juego es una máquina de estado típica, donde la sala actual es el estado. Podemos implementar dicho laberinto con una función para cada habitación. Usamos llamadas de cola para movernos de una habitación a otra. Un pequeño laberinto con cuatro habitaciones podría verse así:
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
Entonces, cuando haces una llamada recursiva como:
function x(n)
if n==0 then return 0
n= n-2
return x(n) + 1
end
Esto no es recursivo de cola porque todavía tiene cosas que hacer (agregar 1) en esa función después de que se realiza la llamada recursiva. Si ingresa un número muy alto, probablemente provocará un desbordamiento de la pila.
Usando la recursividad regular, cada llamada recursiva empuja otra entrada en la pila de llamadas. Cuando se completa la recursividad, la aplicación tiene que abrir cada entrada completamente hacia abajo.
Con la recursividad de cola, dependiendo del idioma, el compilador puede colapsar la pila a una entrada, por lo que ahorra espacio de pila ... Una consulta recursiva grande en realidad puede causar un desbordamiento de pila.
Básicamente, las recursiones de cola pueden optimizarse en iteración.
En lugar de explicarlo con palabras, aquí hay un ejemplo. Esta es una versión de esquema de la función factorial:
(define (factorial x)
(if (= x 0) 1
(* x (factorial (- x 1)))))
Aquí hay una versión de factorial que es recursiva de cola:
(define factorial
(letrec ((fact (lambda (x accum)
(if (= x 0) accum
(fact (- x 1) (* accum x))))))
(lambda (x)
(fact x 1))))
Notará en la primera versión que la llamada recursiva al hecho se alimenta a la expresión de multiplicación y, por lo tanto, el estado debe guardarse en la pila al hacer la llamada recursiva. En la versión recursiva de cola no hay otra expresión S esperando el valor de la llamada recursiva, y como no hay más trabajo por hacer, el estado no tiene que guardarse en la pila. Como regla general, las funciones recursivas de cola de Scheme usan espacio de pila constante.
list-reverse
procedimiento de recursivo de cola y mutación de cola se ejecutará en un espacio de pila constante, pero creará y ampliará una estructura de datos en el montón. Un recorrido de árbol podría usar una pila simulada, en un argumento adicional. etc.
La recursividad de cola se refiere a que la llamada recursiva es la última en la última instrucción lógica en el algoritmo recursivo.
Por lo general, en la recursión, tiene un caso base que es lo que detiene las llamadas recursivas y comienza a reventar la pila de llamadas. Para usar un ejemplo clásico, aunque más C-ish que Lisp, la función factorial ilustra la recursividad de la cola. La llamada recursiva ocurre después de verificar la condición del caso base.
factorial(x, fac=1) {
if (x == 1)
return fac;
else
return factorial(x-1, x*fac);
}
La llamada inicial a factorial sería factorial(n)
donde fac=1
(valor predeterminado) yn es el número para el que se calculará el factorial.
else
es el paso que podría llamar un "caso base" pero abarca varias líneas. ¿Te estoy malentendiendo o mi suposición es correcta? ¿La recursión de la cola solo es buena para un revestimiento?
factorial
ejemplo es solo el clásico ejemplo simple, eso es todo.
Significa que, en lugar de tener que presionar el puntero de instrucciones en la pila, simplemente puede saltar a la parte superior de una función recursiva y continuar la ejecución. Esto permite que las funciones se repitan indefinidamente sin desbordar la pila.
Escribí una publicación de blog sobre el tema, que tiene ejemplos gráficos de cómo se ven los marcos de la pila.
Aquí hay un fragmento de código rápido que compara dos funciones. El primero es la recursión tradicional para encontrar el factorial de un número dado. El segundo usa la recursión de la cola.
Muy simple e intuitivo de entender.
Una manera fácil de saber si una función recursiva es una cola recursiva es si devuelve un valor concreto en el caso base. Lo que significa que no devuelve 1 o verdadero o algo así. Lo más probable es que devuelva alguna variante de uno de los parámetros del método.
Otra forma es saber si la llamada recursiva está libre de cualquier adición, aritmética, modificación, etc., lo que significa que no es más que una llamada recursiva pura.
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 mejor manera para que yo entienda tail call recursion
es un caso especial de recursión donde la última llamada (o la llamada de cola) es la función misma.
Comparando los ejemplos proporcionados en Python:
def recsum(x):
if x == 1:
return x
else:
return x + recsum(x - 1)
^ RECURSIÓN
def tailrecsum(x, running_total=0):
if x == 0:
return running_total
else:
return tailrecsum(x - 1, running_total + x)
^ RECURSIÓN DE LA COLA
Como puede ver en la versión recursiva general, la última llamada en el bloque de código es x + recsum(x - 1)
. Entonces, después de llamar al recsum
método, hay otra operación que es x + ..
.
Sin embargo, en la versión recursiva de cola, la llamada final (o la llamada de cola) en el bloque de código es lo tailrecsum(x - 1, running_total + x)
que significa que la última llamada se realiza al método en sí y no se realiza ninguna operación después de eso.
Este punto es importante porque la recursión de cola como se ve aquí no está haciendo crecer la memoria porque cuando la VM subyacente ve una función que se llama a sí misma en una posición de cola (la última expresión que se evaluará en una función), elimina el marco de pila actual, que se conoce como Tail Call Optimization (TCO).
NÓTESE BIEN. Tenga en cuenta que el ejemplo anterior está escrito en Python cuyo tiempo de ejecución no admite TCO. Este es solo un ejemplo para explicar el punto. TCO es compatible con idiomas como Scheme, Haskell, etc.
En Java, aquí hay una posible implementación recursiva de la cola de la función 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);
}
Compare esto con la implementación recursiva estándar:
public int recursive(final int n) {
if (n <= 2)
return 1;
return recursive(n - 1) + recursive(n - 2);
}
iter
a acc
cuándo iter < (n-1)
.
No soy un programador de Lisp, pero creo que esto ayudará.
Básicamente es un estilo de programación tal que la llamada recursiva es lo último que haces.
Aquí hay un ejemplo de Common Lisp que hace factoriales usando la recursión de cola. Debido a la naturaleza sin pila, uno podría realizar cálculos factoriales increíblemente grandes ...
(defun ! (n &optional (product 1))
(if (zerop n) product
(! (1- n) (* product n))))
Y luego, por diversión, podrías probar (format nil "~R" (! 25))
En resumen, una recursión de cola tiene la llamada recursiva como la última instrucción en la función para que no tenga que esperar la llamada recursiva.
Entonces, esta es una recursión de cola, es decir, N (x - 1, p * x) es la última declaración en la función en la que el compilador es inteligente para descubrir que puede optimizarse para un bucle for (factorial). El segundo parámetro p lleva el valor del producto intermedio.
function N(x, p) {
return x == 1 ? p : N(x - 1, p * x);
}
Esta es la forma no recursiva de escribir la función factorial anterior (aunque algunos compiladores de C ++ pueden optimizarla de todos modos).
function N(x) {
return x == 1 ? 1 : x * N(x - 1);
}
pero esto no es:
function F(x) {
if (x == 1) return 0;
if (x == 2) return 1;
return F(x - 1) + F(x - 2);
}
Escribí una larga publicación titulada " Comprender la recursión de la cola - Visual Studio C ++ - Vista de ensamblaje "
Aquí hay una versión de Perl 5 de la tailrecsum
función mencionada anteriormente.
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
}
Este es un extracto de Estructura e Interpretación de Programas de Computadora sobre la recursividad de la cola.
Al contrastar la iteración y la recursión, debemos tener cuidado de no confundir la noción de un proceso recursivo con la noción de un procedimiento recursivo. Cuando describimos un procedimiento como recursivo, nos estamos refiriendo al hecho sintáctico de que la definición del procedimiento se refiere (directa o indirectamente) al procedimiento mismo. Pero cuando describimos un proceso como siguiendo un patrón que es, por ejemplo, linealmente recursivo, estamos hablando de cómo evoluciona el proceso, no de la sintaxis de cómo se escribe un procedimiento. Puede parecer inquietante que nos refiramos a un procedimiento recursivo como el hecho de generar un proceso iterativo. Sin embargo, el proceso realmente es iterativo: su estado es capturado completamente por sus tres variables de estado, y un intérprete necesita hacer un seguimiento de solo tres variables para ejecutar el proceso.
Una razón por la cual la distinción entre proceso y procedimiento puede ser confusa es que la mayoría de las implementaciones de lenguajes comunes (incluidos Ada, Pascal y C) están diseñadas de tal manera que la interpretación de cualquier procedimiento recursivo consume una cantidad de memoria que crece con el número de llamadas a procedimientos, incluso cuando el proceso descrito es, en principio, iterativo. Como consecuencia, estos lenguajes pueden describir procesos iterativos solo recurriendo a "construcciones de bucle" de propósito especial como hacer, repetir, hasta, para y mientras. La implementación de Scheme no comparte este defecto. Ejecutará un proceso iterativo en espacio constante, incluso si el proceso iterativo se describe mediante un procedimiento recursivo. Una implementación con esta propiedad se llama tail-recursive. Con una implementación recursiva de cola, la iteración se puede expresar utilizando el mecanismo de llamada a procedimiento ordinario, de modo que las construcciones de iteración especiales son útiles solo como azúcar sintáctica.
La función recursiva es una función que llama por sí misma
Permite a los programadores escribir programas eficientes utilizando una cantidad mínima de código .
La desventaja es que pueden causar bucles infinitos y otros resultados inesperados si no se escriben correctamente .
Explicaré tanto la función recursiva simple como la función recursiva de cola
Para escribir una función recursiva simple
Del ejemplo dado:
public static int fact(int n){
if(n <=1)
return 1;
else
return n * fact(n-1);
}
Del ejemplo anterior
if(n <=1)
return 1;
Es el factor decisivo cuando salir del ciclo
else
return n * fact(n-1);
¿Se debe realizar el procesamiento real?
Permítanme romper la tarea uno por uno para una fácil comprensión.
Veamos qué sucede internamente si corro fact(4)
public static int fact(4){
if(4 <=1)
return 1;
else
return 4 * fact(4-1);
}
If
el bucle falla, por lo que pasa al else
bucle y regresa4 * fact(3)
En la memoria de pila, tenemos 4 * fact(3)
Sustituyendo n = 3
public static int fact(3){
if(3 <=1)
return 1;
else
return 3 * fact(3-1);
}
If
lazo de falla por lo que va a else
bucle
entonces regresa 3 * fact(2)
Recuerde que llamamos `` `4 * fact (3)` `
La salida para fact(3) = 3 * fact(2)
Hasta ahora la pila tiene 4 * fact(3) = 4 * 3 * fact(2)
En la memoria de pila, tenemos 4 * 3 * fact(2)
Sustituyendo n = 2
public static int fact(2){
if(2 <=1)
return 1;
else
return 2 * fact(2-1);
}
If
lazo de falla por lo que va a else
bucle
entonces regresa 2 * fact(1)
Recuerda que llamamos 4 * 3 * fact(2)
La salida para fact(2) = 2 * fact(1)
Hasta ahora la pila tiene 4 * 3 * fact(2) = 4 * 3 * 2 * fact(1)
En la memoria de pila, tenemos 4 * 3 * 2 * fact(1)
Sustituyendo n = 1
public static int fact(1){
if(1 <=1)
return 1;
else
return 1 * fact(1-1);
}
If
el bucle es verdadero
entonces regresa 1
Recuerda que llamamos 4 * 3 * 2 * fact(1)
La salida para fact(1) = 1
Hasta ahora la pila tiene 4 * 3 * 2 * fact(1) = 4 * 3 * 2 * 1
Finalmente, el resultado del hecho (4) = 4 * 3 * 2 * 1 = 24
La recursión de la cola sería
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
el bucle falla, por lo que pasa al else
bucle y regresafact(3, 4)
En la memoria de pila, tenemos fact(3, 4)
Sustituyendo n = 3
public static int fact(3, running_total=4) {
if (x==1) {
return running_total;
} else {
return fact(3-1, 4*3);
}
}
If
lazo de falla por lo que va a else
bucle
entonces regresa fact(2, 12)
En la memoria de pila, tenemos fact(2, 12)
Sustituyendo n = 2
public static int fact(2, running_total=12) {
if (x==1) {
return running_total;
} else {
return fact(2-1, 12*2);
}
}
If
lazo de falla por lo que va a else
bucle
entonces regresa fact(1, 24)
En la memoria de pila, tenemos fact(1, 24)
Sustituyendo n = 1
public static int fact(1, running_total=24) {
if (x==1) {
return running_total;
} else {
return fact(1-1, 24*1);
}
}
If
el bucle es verdadero
entonces regresa running_total
La salida para running_total = 24
Finalmente, el resultado del hecho (4,1) = 24
La recursión de la cola es la vida que estás viviendo ahora. Recicla constantemente el mismo marco de pila, una y otra vez, porque no hay razón ni medios para volver a un marco "anterior". El pasado ha terminado y terminado para que pueda ser descartado. Obtienes un cuadro, siempre avanzando hacia el futuro, hasta que tu proceso inevitablemente muera.
La analogía se rompe cuando considera que algunos procesos pueden utilizar marcos adicionales, pero aún se consideran recursivos si la pila no crece infinitamente.
Una recursividad de cola es una función recursiva donde la función se llama a sí misma al final ("cola") de la función en la que no se realiza ningún cálculo después del retorno de la llamada recursiva. Muchos compiladores optimizan para cambiar una llamada recursiva a una cola recursiva o una llamada iterativa.
Considere el problema de calcular el factorial de un número.
Un enfoque directo sería:
factorial(n):
if n==0 then 1
else n*factorial(n-1)
Supongamos que llama factorial (4). El árbol de recursión sería:
factorial(4)
/ \
4 factorial(3)
/ \
3 factorial(2)
/ \
2 factorial(1)
/ \
1 factorial(0)
\
1
La profundidad máxima de recursión en el caso anterior es O (n).
Sin embargo, considere el siguiente ejemplo:
factAux(m,n):
if n==0 then m;
else factAux(m*n,n-1);
factTail(n):
return factAux(1,n);
El árbol de recursión de hecho Cola (4) sería:
factTail(4)
|
factAux(1,4)
|
factAux(4,3)
|
factAux(12,2)
|
factAux(24,1)
|
factAux(24,0)
|
24
Aquí también, la profundidad máxima de recursión es O (n) pero ninguna de las llamadas agrega ninguna variable adicional a la pila. Por lo tanto, el compilador puede eliminar una pila.
La recursión de la cola es bastante rápida en comparación con la recursividad normal. Es rápido porque la salida de la llamada de los antepasados no se escribirá en la pila para mantener la pista. Pero en la recursividad normal, todos los ancestros llaman a la salida escrita en la pila para mantener la pista.
Una función recursiva de cola es una función recursiva donde la última operación que realiza antes de regresar es realizar la llamada a la función recursiva. Es decir, el valor de retorno de la llamada de función recursiva se devuelve inmediatamente. Por ejemplo, su código se vería así:
def recursiveFunction(some_params):
# some code here
return recursiveFunction(some_args)
# no code after the return statement
Los compiladores e intérpretes que implementan la optimización de llamadas de cola o la eliminación de llamadas de cola pueden optimizar el código recursivo para evitar desbordamientos de pila. Si su compilador o intérprete no implementa la optimización de llamadas de cola (como el intérprete CPython), no hay ningún beneficio adicional al escribir su código de esta manera.
Por ejemplo, esta es una función factorial recursiva estándar 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)
Y esta es una versión recursiva de la función factorial:
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))
(Tenga en cuenta que aunque se trata de código Python, el intérprete de CPython no realiza la optimización de las llamadas de cola, por lo que organizar su código de esta manera no confiere ningún beneficio en tiempo de ejecución).
Es posible que tenga que hacer que su código sea un poco más ilegible para utilizar la optimización de la llamada de cola, como se muestra en el ejemplo factorial. (Por ejemplo, el caso base ahora es poco intuitivo y el accumulator
parámetro se usa efectivamente como una especie de variable global).
Pero el beneficio de la optimización de llamadas de cola es que evita errores de desbordamiento de pila. (Notaré que puede obtener este mismo beneficio utilizando un algoritmo iterativo en lugar de uno recursivo).
Los desbordamientos de la pila se producen cuando la pila de llamadas ha introducido demasiados objetos de marco. Un objeto de marco se inserta en la pila de llamadas cuando se llama a una función, y se saca de la pila de llamadas cuando la función regresa. Los objetos de marco contienen información como variables locales y a qué línea de código volver cuando regrese la función.
Si su función recursiva realiza demasiadas llamadas recursivas sin regresar, la pila de llamadas puede exceder su límite de objeto de marco. (El número varía según la plataforma; en Python son 1000 objetos de marco por defecto). Esto causa un error de desbordamiento de pila . (¡Hola, de ahí viene el nombre de este sitio web!)
Sin embargo, si lo último que hace su función recursiva es hacer la llamada recursiva y devolver su valor de retorno, entonces no hay razón para que deba mantener el objeto de marco actual para permanecer en la pila de llamadas. Después de todo, si no hay código después de la llamada a la función recursiva, no hay razón para aferrarse a las variables locales del objeto de marco actual. Por lo tanto, podemos deshacernos del objeto de marco actual inmediatamente en lugar de mantenerlo en la pila de llamadas. El resultado final de esto es que su pila de llamadas no crece en tamaño y, por lo tanto, no puede apilar el desbordamiento.
Un compilador o un intérprete debe tener la optimización de la llamada de cola como una característica para poder reconocer cuándo se puede aplicar la optimización de la llamada de cola. Incluso entonces, es posible que tenga que reorganizar el código en su función recursiva para hacer uso de la optimización de la cola de llamadas, y depende de usted si esta disminución potencial en la legibilidad vale la pena.
Para comprender algunas de las principales diferencias entre la recursividad de llamada de cola y la recursión de llamada no de cola, podemos explorar las implementaciones .NET de estas técnicas.
Aquí hay un artículo con algunos ejemplos en C #, F # y C ++ \ CLI: Adventures in Tail Recursion en C #, F # y C ++ \ CLI .
C # no se optimiza para la recursividad de llamadas de cola mientras que F # sí.
Las diferencias de principio implican bucles versus cálculo de Lambda. C # está diseñado con bucles en mente, mientras que F # está construido a partir de los principios del cálculo Lambda. Para un libro muy bueno (y gratuito) sobre los principios del cálculo Lambda, vea Estructura e interpretación de programas de computadora, por Abelson, Sussman y Sussman .
Con respecto a las llamadas de cola en F #, para un artículo introductorio muy bueno, vea Introducción detallada a las llamadas de cola en F # . Por último, aquí está un artículo que cubre la diferencia entre la recursividad no cola y la repetición de llamada final (en Fa #): Cola-recursividad frente a no recursión de cola en fa sostenido .
Si desea leer acerca de algunas de las diferencias de diseño de la recursividad de llamada de cola entre C # y F #, consulte Generar código de operación de llamada de cola en C # y F # .
Si le interesa lo suficiente como para querer saber qué condiciones impiden que el compilador de C # realice optimizaciones de llamada de cola, consulte este artículo: Condiciones de llamada de cola JIT CLR .
Hay dos tipos básicos de recursiones: recidiva de la cabeza y recidiva de la cola.
En la recursividad principal , una función realiza su llamada recursiva y luego realiza algunos cálculos más, tal vez utilizando el resultado de la llamada recursiva, por ejemplo.
En una función recursiva de cola , todos los cálculos suceden primero y la llamada recursiva es lo último que sucede.
Tomado de esta publicación súper increíble. Por favor considere leerlo.
Recursión significa una función que se llama a sí misma. Por ejemplo:
(define (un-ended name)
(un-ended 'me)
(print "How can I get here?"))
La recursión de cola significa la recursividad que concluye la función:
(define (un-ended name)
(print "hello")
(un-ended 'me))
Mira, lo último que hace la función sin terminar (procedimiento, en la jerga de Scheme) es llamarse a sí mismo. Otro ejemplo (más útil) es:
(define (map lst op)
(define (helper done left)
(if (nil? left)
done
(helper (cons (op (car left))
done)
(cdr left))))
(reverse (helper '() lst)))
En el procedimiento auxiliar, lo ÚLTIMO que hace si la izquierda no es nula es llamarse a sí mismo (DESPUÉS de contras algo y cdr algo). Esto es básicamente cómo mapear una lista.
La recursividad de cola tiene una gran ventaja de que el intérprete (o compilador, dependiendo del idioma y el proveedor) puede optimizarla y transformarla en algo equivalente a un ciclo while. De hecho, en la tradición de Scheme, la mayoría de los bucles "for" y "while" se realizan de manera recursiva (no hay un for y while, que yo sepa).
Esta pregunta tiene muchas respuestas excelentes ... pero no puedo evitar intervenir con una versión alternativa de cómo definir "recursión de cola", o al menos "recursión de cola adecuada". A saber: ¿debería uno verlo como una propiedad de una expresión particular en un programa? ¿O debería uno verlo como una propiedad de una implementación de un lenguaje de programación ?
Para más información sobre este último punto de vista, hay un artículo clásico de Will Clinger, "Proper Tail Recursion and Space Efficiency" (PLDI 1998), que definió la "adecuada recursión de la cola" como una propiedad de una implementación de lenguaje de programación. La definición se construye para permitir que uno ignore los detalles de implementación (como si la pila de llamadas está realmente representada a través de la pila de tiempo de ejecución o mediante una lista de tramas vinculadas asignadas al montón).
Para lograr esto, utiliza el análisis asintótico: no del tiempo de ejecución del programa como generalmente se ve, sino del uso del espacio del programa . De esta manera, el uso de espacio de una lista vinculada asignada en el montón frente a una pila de llamadas en tiempo de ejecución termina siendo asintóticamente equivalente; así que uno puede ignorar ese detalle de implementación del lenguaje de programación (un detalle que ciertamente importa bastante en la práctica, pero puede enturbiar las aguas un poco cuando se intenta determinar si una implementación dada cumple con el requisito de ser "recursiva de propiedad" )
El trabajo merece un estudio cuidadoso por varias razones:
Ofrece una definición inductiva de las expresiones de cola y las llamadas de cola de un programa. (Tal definición, y por qué tales llamadas son importantes, parece ser el tema de la mayoría de las otras respuestas dadas aquí).
Aquí están esas definiciones, solo para dar una idea del texto:
Definición 1 Las expresiones de cola de un programa escrito en Core Scheme se definen inductivamente de la siguiente manera.
- El cuerpo de una expresión lambda es una expresión de cola
- Si
(if E0 E1 E2)
es una expresión de la cola, entonces ambosE1
yE2
son expresiones de la cola.- Nada más es una expresión de cola.
Definición 2 Una llamada de cola es una expresión de cola que es una llamada de procedimiento.
(una llamada recursiva de cola, o como dice el documento, "llamada de cola propia" es un caso especial de una llamada de cola donde se invoca el procedimiento).
Proporciona definiciones formales para seis "máquinas" diferentes para evaluar el Esquema Central, donde cada máquina tiene el mismo comportamiento observable, excepto la clase de complejidad espacial asintótica en la que se encuentra cada una.
Por ejemplo, después de dar definiciones para máquinas con, respectivamente, 1. gestión de memoria basada en pila, 2. recolección de basura pero sin llamadas de cola, 3. recolección de basura y llamadas de cola, el documento continúa con estrategias de administración de almacenamiento aún más avanzadas, como 4. "evlis tail recursion", donde el entorno no necesita ser preservado a través de la evaluación del último argumento de subexpresión en una llamada de cola, 5. reduciendo el entorno de un cierre a solo las variables libres de ese cierre, y 6. La llamada semántica "segura para el espacio" tal como la definen Appel y Shao .
Para demostrar que las máquinas realmente pertenecen a seis clases distintas de complejidad espacial, el documento, para cada par de máquinas en comparación, proporciona ejemplos concretos de programas que expondrán la explosión espacial asintótica en una máquina pero no en la otra.
(Leyendo mi respuesta ahora, no estoy seguro si logré capturar realmente los puntos cruciales del artículo de Clinger . Pero, por desgracia, no puedo dedicar más tiempo a desarrollar esta respuesta en este momento).
Muchas personas ya han explicado la recursividad aquí. Me gustaría citar un par de reflexiones sobre algunas ventajas que ofrece la recursividad del libro "Concurrencia en .NET, patrones modernos de programación concurrente y paralela" de Riccardo Terrell:
“La recursión funcional es la forma natural de iterar en FP porque evita la mutación de estado. Durante cada iteración, se pasa un nuevo valor al constructor del bucle para que se actualice (mute). Además, se puede componer una función recursiva, lo que hace que su programa sea más modular, así como también presenta oportunidades para explotar la paralelización ".
Aquí también hay algunas notas interesantes del mismo libro sobre la recursividad de la cola:
La recursividad de llamada de cola es una técnica que convierte una función recursiva regular en una versión optimizada que puede manejar entradas grandes sin riesgos ni efectos secundarios.
NOTA La razón principal para una llamada de cola como optimización es mejorar la localidad de datos, el uso de memoria y el uso de caché. Al hacer una llamada de cola, la persona que llama utiliza el mismo espacio de pila que la persona que llama. Esto reduce la presión de la memoria. Mejora marginalmente el caché porque la misma memoria se reutiliza para las personas que llaman posteriormente y puede permanecer en el caché, en lugar de desalojar una línea de caché anterior para dejar espacio para una nueva línea de caché.