¿Siempre es bueno tener una solución recursiva que una solución iterativa?

Hay bastantes casos en los que una solución recursiva es peor que una iterativa. Incluso un lenguaje funcional puro como Haskell admite soluciones iterativas en forma de comprensión de listas.

Considere los siguientes ejemplos en C y un tono de medidas.

Factorial :

doble fact_recur (int n)
{
volver n> 1? n * fact_recur (n-1): 1;
}

vs.

doble fact_iter (int n)
{
int i;
doble resultado = 1;
para (i = 1; i <= n; ++ i)
resultado * = i;
resultado de retorno;
}

Ambas variantes se compilan con la optimización -O3 usando gcc. Esto fue tan rápido que fue difícil de medir. Estas funciones tuvieron que colocarse en un archivo C separado, sin optimización de todo el programa, para que el compilador no pueda eliminar todo el bucle:

recursiva: 280 nanosegundos. (Esto es con gcc 5.4 – más reciente es más lento).
iterativo: 180 nanosegundos.

Clang no es muy diferente a gcc.

La iterativa es un poco más rápida, pero la recursiva es mucho más legible. Entonces iría por la versión recursiva en este caso debido a la legibilidad.

Próximo.

Fibonacci

doble fib_recur (int n)
{
si (n <= 1)
volver n;
más
devuelve fib_recur (n-2) + fib_recur (n-1);
}

vs.

doble fib_iter (int n)
{
resultados dobles *;
doble resultado;
int i;
si (n <= 1)
volver n;
resultados = (doble *) malloc (tamaño de (doble) * (n + 1));
resultados [0] = 0.0;
resultados [1] = 1.0;
para (i = 2; i <= n; ++ i)
resultados [i] = resultados [i-1] + resultados [i-2];
resultado = resultados [n];
libre (resultados);
resultado de retorno;
}

Claramente, al menos para n pequeña, fib_iter debería ser más lento debido al uso de malloc. Así que vamos a medirlo.

Para n = 2:
iterativo: 41 nanosegundos
recursivo: 5.4 nanosegundos

Como era de esperar, el recursivo es mucho más rápido, debido a malloc. Así que vamos a deshacernos de malloc:

doble fib_iter2 (int n)
{
doble resultado = 1;
doble anterior = 0;
int i;
si (n <= 1)
volver n;
para (i = 2; i <= n; ++ i) {
doble siguiente = anterior + resultado;
prev = resultado;
resultado = siguiente;
}
resultado de retorno;
}

fib_iter2: 2.5 nanosegundos

Esto es dos veces más rápido que la versión recursiva. Todavía no estoy impresionado.

Probemos con n = 10.

Iterativo: 14 nanosegundos
Recursivo: 234 nanosegundos (mucho peor que iterativo)

Seamos audaces e intentemos con n = 30:
Iterativo: 39 nanosegundos (como se esperaba)
Recursivo: 3.6 milisegundos (¡eso es 100,000 veces más lento!)


¿Entonces qué está pasando? La versión recursiva es muy derrochadora. Se recalcula lo mismo una y otra vez. Por ejemplo, con fib (10), llama a fib (8) + fib (9). Fib (9), a su vez, llama a fib (8) nuevamente.

Su complejidad es exponencial, pero con una base de exponente pequeña, es por eso que no crece tan mal. Está alrededor de [matemáticas] O (1.6 ^ n) [/ matemáticas].

Las cosas no son tan malas para el recursivo de los fibonacci recursivos, ya que hay algunas soluciones:

Memoization donde la función recordará algunos de los últimos valores devueltos.

doble fib_mem (int n)
{
resultados dobles * estáticos = NULL;
tamaño int estático = 0;
si (n <= 1)
volver n;
if (n + 1> tamaño) {
si (resultados)
libre (resultados);
resultados = calloc (n * 2, sizeof (doble));
tamaño = n * 2;
}
if (resultados [n]! = 0.0)
devolver resultados [n];
resultados [n] = fib_mem (n-2) + fib_mem (n-1);
devolver resultados [n];
}

Esto es significativamente más complicado y no es la implementación más óptima.

recursiva repetida con memorización : 4.7 nanosegundos.
Aquí el punto de referencia llama al mismo fib_mem (30) repetidamente, y los 4,7 nanosegundos es el promedio. Esto es más rápido que la alternativa iterativa, porque tuvo que calcular el resultado solo una vez y simplemente recuperarlo en todas las llamadas posteriores de fib_mem (). La memorización del algoritmo iterativo tendrá el mismo efecto.

solo recursivo con memorización : 107 nanosegundos.
Esta ejecución borra los datos guardados antes de cada llamada. Esto muestra que una sola llamada es más del doble de lenta con la recursividad, lo que no es tan malo como sin la memorización.

Desafortunadamente, la recursividad con la memorización no es tan legible como la versión iterativa, por lo que tanto la legibilidad como los aspectos de velocidad son peores con la recursividad.


Experiencia del mundo real:

Una vez tuve que volver a implementar el bucle principal de un solucionador de restricciones. Básicamente es un DFS que repasa los posibles estados del solucionador, con algunas podas.

Después de implementar la solución iterativa y recursiva, decidí mantener la variante recursiva que era mucho más legible.

Un año después lamenté mi decisión. Hubo dos problemas:

  1. Algunos problemas de restricción triviales, que tenían miles de variables no relacionadas, darían como resultado una recursión DFS muy profunda. Me estaba quedando sin pila.
    Solución fácil: use algunas opciones de enlazador para aumentar la pila.
  2. Quería implementar un algoritmo de backjumping. Es decir, volver al nivel K en la pila. Esto es trivial con algoritmos iterativos, pero muy feo y propenso a errores con recursividad. El resultado final, el código terminó el doble de largo y el doble de feo.

Por supuesto, no puedo generalizar a partir de un mal caso, pero casi nunca vi ninguna ventaja en la recursividad en C o C ++. Con lenguajes funcionales puros, las cosas deberían ser diferentes porque están orientadas a la recursividad.


EDITAR:

Joe Zbiciak me recordó que C ++ está ejecutando un DFS recursivo en su nombre. La ejecución recursiva de destructores de C ++ es muy intuitiva, por un lado, pero a veces desborda la pila, por otro. Un caso trivial, que podría probar, es la destrucción de una lista vinculada:

#include
Nodo de estructura {
Nodo (int data_, std :: unique_ptr && next_)
: data (data_), next (std :: move (next_))
{}

datos int;
std :: unique_ptr siguiente;
};

Lista de clase {
público:
vacío push_back (datos int) {
root = std :: make_unique (datos, std :: move (root));
}
privado:
std :: unique_ptr raíz;
};

Esto se ve bien, hasta que construyas una lista realmente grande. Lo siguiente se bloquea al destruir lst.

int main ()
{
Lista lst;
para (int i = 0; i <100000; i ++)
lst.push_back (i);
}

En este caso particular, de una estructura profundamente anidada, podemos evitar la recurrencia definiendo un destructor especializado:

Nodo :: ~ Nodo () {
while (siguiente) {
auto old_next = std :: move (siguiente);
next = std :: move (old_next-> next);
}
}

Este destructor iterativo no está limitado por la profundidad de la lista.

Con un árbol binario desequilibrado , es un poco más complicado. Tener un std :: vector con nodos no controlados. En el bucle, pop_back () un nodo, empuja () sus hijos al vector y destruye el nodo. Pare cuando el vector esté vacío. Esto se parece a BFS, pero no del todo.

Es frustrante que incluso con destructores, la recursión generada automáticamente es peor que la iteración.

La mayoría del código que escribes en el trabajo será leído por otros, por lo que debe ser fácil de entender. Esto significa que el código debe seguir las estructuras de datos, que a menudo se dan como parte del problema.

La recursión se convierte en la opción natural para tratar con datos en estructuras de árbol, como directorios en un sistema de archivos.

La iteración se convierte en la opción natural para las listas de artículos. Este es el caso más común.

Nota 1: Todo lo anterior proviene de mi experiencia personal con el software empresarial. La computación científica o matemática puede ser diferente.

Nota 2: Imagen de “recursión

No. Tenía una pregunta cuando me entrevisté en Amazon que decía escribir código que asigna 1s y 0s de acuerdo con alguna regla en una matriz que se inicializó para contener aleatoriamente 1s y 0s. La solución que se me ocurrió usó la recursividad y, en el peor de los casos, introdujo muchas llamadas a funciones en la pila y luego las llamó sucesivamente después de que todas las celdas de la matriz habían sido visitadas. El entrevistador luego me mostró una solución iterativa que usaba una cola y me pidió que evaluara su complejidad. Ambos eran O (n) en pasos porque visitaron cada una de las células una vez. La versión recursiva fue O (n ^ 2) en general debido al espacio de pila requerido, mientras que la versión iterativa fue O (n) en general. Desearía poder recordar más detalles del problema real, pero fue hace unos 8 años.

En implementaciones reales? No, definitivamente no siempre es bueno. Cada nivel de recursión usa más y más pila como se llama a la función interna. Eso puede (y lo hace) sumar una gran cantidad de gastos generales, potencialmente una cantidad asombrosa en ciertos sistemas y ciertos algoritmos.

No. A veces una solución recursiva es más clara, a veces es menos clara. Así que a primera vista, úselo cuando sea más claro.

Pero hay otras consideraciones, como el rendimiento y el espacio de pila. Cada llamada recursiva necesita otro trozo de espacio de pila para almacenar todo el estado en el nivel actual. Eso puede ser una ventaja, especialmente si el código se ejecuta en un hilo; La mayoría de los sistemas operativos en estos días solo asignan una cantidad modesta de espacio a la pila del hilo. Si lo supera, tendrá un bloqueo que puede ser difícil de rastrear. Esto es particularmente molesto si la profundidad de recursión depende de los datos, porque es posible que nunca vea este bloqueo hasta que el código salga a la naturaleza. (Tuve este problema exacto recientemente, por eso es nuevo en mi mente. La alteración del código de una solución recursiva a una solución iterativa solucionó el problema).

La mayoría de los problemas que tienen soluciones recursivas tienen soluciones iterativas casi equivalentes. No hay razón para favorecer la recursividad. Como algunos han señalado, las soluciones iterativas a menudo se prestan mejor a la optimización que las recursivas.

Los algoritmos recursivos generalmente requieren mucho menos que el código que una alternativa iterativa, sin embargo, en muchos casos, la recursión puede causar problemas de optimización / rendimiento debido a las muchas llamadas del método recursivo en la pila.

Siempre elegiría la recursividad para cosas como Fibonacci, sin embargo, personalmente me resulta más fácil diseñar una solución iterativa para muchos casos del mundo real, incluso si requiere más código.

No.

Algunas implementaciones de lenguaje no hacen la recursión de manera eficiente, por lo que es posible que si tiene muchos datos, el programa recursivo no funcione (explotará con un error de desbordamiento de pila). Incluso en los que tienen una optimización de llamadas de cola, a veces escribir código de una manera que permita que TCO intervenga puede ser molesto.

Sin embargo, si no tiene muchos datos … entonces, de hecho, las soluciones recursivas a menudo pueden ser más fáciles de encontrar, más fáciles de escribir y leer, por lo que probablemente deberían preferirse. Siempre puede reescribirlos a iterativo más tarde, si surge la necesidad.

Aunque la recursividad es a menudo más elegante, es mejor usar código iterativo, que se traduce en un mejor código ejecutable. Por otro lado, en los casos en que el usuario no verá las optimizaciones, o escribir una solución iterativa equivalente genera un código innecesariamente complicado, mantener el código recursivo está bien.

Vea esto sobre cómo convertir código recursivo a código iterativo:

Reemplace la recursión con iteración

Cuando se requiere la prueba formal de una pieza de software, la recursión se presta mucho mejor para este ejercicio. Pero cuando se ha demostrado que el software es correcto, simplemente traduzca al equivalente iterativo para tener un ejecutable de grado de producción. (El espacio de la pila puede ser escaso y no debe desperdiciarse)

Como todos los demás han dicho, definitivamente no. El ejemplo del libro de texto sería el aumento de la eficiencia del uso de la programación dinámica sobre la recurrencia en muchas aplicaciones. Mi ejemplo favorito es la distancia de Levenshtein. Debido a que calcula los mismos valores una y otra vez, es mucho más rápido almacenar los cálculos en una matriz y almacenar la posición, la base de la programación dinámica.

More Interesting

¿Qué algoritmo debo usar para codificar un solucionador de Sudoku usando la teoría de grafos?

¿Cuál es el enfoque algorítmico para encontrar el tamaño de la submatriz rectangular máxima que tiene todos los 1 de una matriz que contiene 0 y 1?

Como estudiante de primer año de una sucursal que no es CS en un IIT, ¿cómo domino las estructuras de datos, los algoritmos y el aprendizaje automático por mi cuenta?

¿Es posible que el modelo de espacio vectorial que utilizamos para entrenar algoritmos de aprendizaje automático sea inadecuado para la representación del aprendizaje humano?

¿Es realmente necesario que un programador aprenda estructuras de datos y algoritmos?

¿Cuál es el algoritmo hash más popular para almacenar contraseñas?

¿Qué algoritmos pueden detectar si dos imágenes / objetos son similares o no?

¿Cuáles son las ventajas y desventajas de la búsqueda A * y el algoritmo de Dijkstra? ¿Cuándo se debe usar cada uno?

¿Qué algoritmo se usa para contar la cantidad de personas en un video?

¿Cuáles son los mejores algoritmos para el análisis de sentimientos?

¿Qué es mejor para eliminar números específicos de una matriz, Java o C ++?

¿Alguna vez tiene que programar sus propias estructuras de datos para una programación competitiva?

¿Qué es la generación procesal y la generación aleatoria? ¿Cuál es la diferencia y cómo se logra cada uno?

¿Hay algún sitio web para encontrar la complejidad del tiempo de diferentes algoritmos?

¿Cuál es el significado o las aplicaciones del algoritmo KNN?