Al comenzar a aprender lisp, me he encontrado con el término recursivo de cola . ¿Qué significa exactamente?
1697
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:
Si llamó
recsum(5)
, esto es lo que evaluaría el intérprete de JavaScript: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:
Aquí está la secuencia de eventos que ocurrirían si llamaras
tailrecsum(5)
, (lo que sería efectivamentetailrecsum(5, 0)
, debido al segundo argumento predeterminado).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 .
fuente
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.
fuente
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
donde
E
yQ
son expresiones yS
es una secuencia de enunciados, y convertirlo en una función recursiva de colaPor supuesto,
E
,S
, yQ
tienen que ser definidas para calcular un valor interesante sobre algunas variables. Por ejemplo, la función de buclees equivalente a las funciones recursivas de cola
(Este "ajuste" de la función recursiva de la cola con una función con menos parámetros es un idioma funcional común).
fuente
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.
Entonces, cuando haces una llamada recursiva como:
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.
fuente
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.
fuente
El archivo de la jerga tiene esto que decir sobre la definición de recursión de cola:
recursión de cola /n./
Si aún no está harto de esto, vea la recursión de la cola.
fuente
En lugar de explicarlo con palabras, aquí hay un ejemplo. Esta es una versión de esquema de la función factorial:
Aquí hay una versión de factorial que es recursiva de cola:
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.
fuente
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.
La llamada inicial a factorial sería
factorial(n)
dondefac=1
(valor predeterminado) yn es el número para el que se calculará el factorial.fuente
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.
fuente
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.
fuente
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:
^ RECURSIÓN
^ 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 alrecsum
método, hay otra operación que esx + ..
.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).
EDITAR
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.
fuente
En Java, aquí hay una posible implementación recursiva de la cola de la función Fibonacci:
Compare esto con la implementación recursiva estándar:
fuente
iter
aacc
cuándoiter < (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.
fuente
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 ...
Y luego, por diversión, podrías probar
(format nil "~R" (! 25))
fuente
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.
Esta es la forma no recursiva de escribir la función factorial anterior (aunque algunos compiladores de C ++ pueden optimizarla de todos modos).
pero esto no es:
Escribí una larga publicación titulada " Comprender la recursión de la cola - Visual Studio C ++ - Vista de ensamblaje "
fuente
Aquí hay una versión de Perl 5 de la
tailrecsum
función mencionada anteriormente.fuente
Este es un extracto de Estructura e Interpretación de Programas de Computadora sobre la recursividad de la cola.
fuente
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:
Del ejemplo anterior
Es el factor decisivo cuando salir del ciclo
¿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)
If
el bucle falla, por lo que pasa alelse
bucle y regresa4 * fact(3)
En la memoria de pila, tenemos
4 * fact(3)
Sustituyendo n = 3
If
lazo de falla por lo que va aelse
bucleentonces 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
If
lazo de falla por lo que va aelse
bucleentonces 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
If
el bucle es verdaderoentonces 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
If
el bucle falla, por lo que pasa alelse
bucle y regresafact(3, 4)
En la memoria de pila, tenemos
fact(3, 4)
Sustituyendo n = 3
If
lazo de falla por lo que va aelse
bucleentonces regresa
fact(2, 12)
En la memoria de pila, tenemos
fact(2, 12)
Sustituyendo n = 2
If
lazo de falla por lo que va aelse
bucleentonces regresa
fact(1, 24)
En la memoria de pila, tenemos
fact(1, 24)
Sustituyendo n = 1
If
el bucle es verdaderoentonces regresa
running_total
La salida para
running_total = 24
Finalmente, el resultado del hecho (4,1) = 24
fuente
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.
fuente
Considere el problema de calcular el factorial de un número.
Un enfoque directo sería:
Supongamos que llama factorial (4). El árbol de recursión sería:
La profundidad máxima de recursión en el caso anterior es O (n).
Sin embargo, considere el siguiente ejemplo:
El árbol de recursión de hecho Cola (4) sería:
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.
fuente
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.
fuente
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í:
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:
Y esta es una versión recursiva de la función factorial:
(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.
fuente
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 .
fuente
Hay dos tipos básicos de recursiones: recidiva de la cabeza y recidiva de la cola.
Tomado de esta publicación súper increíble. Por favor considere leerlo.
fuente
Recursión significa una función que se llama a sí misma. Por ejemplo:
La recursión de cola significa la recursividad que concluye la función:
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:
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).
fuente
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:
(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).
fuente
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:
Aquí también hay algunas notas interesantes del mismo libro sobre la recursividad de la cola:
fuente