Atlasingeniería

Algoritmos y programaciónRecursiónTema 3

Recursión o iteración, cómo elegir

Recursión e iteración expresan los mismos procesos, pero guardan el trabajo pendiente en lugares distintos. La elección depende de la forma del problema, de la profundidad y de quién controla el límite.

Para este tema conviene tener claro:Pila de llamadas y qué pasa en memoria

Casi toda recursión se puede escribir con un ciclo, y todo ciclo se puede simular con recursión. Entonces la pregunta útil no es cuál puede resolver el problema, sino dónde queda guardado el trabajo pendiente y quién controla cuánto puede crecer.

El mismo proceso, dos formas

Estas dos funciones calculan lo mismo:

def factorial_recursive(n):
    return 1 if n <= 1 else n * factorial_recursive(n - 1)

def factorial_iterative(n):
    result = 1
    for value in range(2, n + 1):
        result *= value
    return result

Las dos hacen O(n)O(n) multiplicaciones. La diferencia está en el estado: la versión recursiva lo reparte entre nn marcos de la pila, cada uno con su n y una multiplicación pendiente; la iterativa lo concentra en dos variables, result y value.

Qué paga cada una

Antes de seguir, predecí

En Python, llamás a las dos versiones con n = 100.000. ¿Qué pasa?
RecursivaIterativa
MultiplicacionesO(n)O(n)
Espacio auxiliarO(n) marcos de pilaO(1)
Límite en CPythonUnos 1.000 nivelesNinguno propio
Costo por pasoUna llamada a funciónUna vuelta de ciclo

Eso no vuelve “mala” a la recursión. Muestra que dos programas con la misma complejidad temporal pueden tener perfiles de memoria muy distintos. Para procesos lineales largos, un ciclo suele ser la representación más directa y más segura.

Cuando los datos son recursivos

En árboles, grafos y estructuras anidadas la cosa cambia: la recursión sigue la forma de los datos. Un recorrido en profundidad visita un nodo y repite lo mismo con cada hijo, y el código lo dice casi literal:

def depth_first(node, visited):
    visited.append(node)
    for child in tree[node]:
        depth_first(child, visited)
    return visited

Un ciclo, en cambio, tiene que guardar por su cuenta qué nodos faltan. No elimina la pila: la convierte en una estructura explícita. Eso agrega código, pero también permite inspeccionarla, limitarla, pausarla o guardarla.

La pila, a la vista

Esta es la misma búsqueda en profundidad sin recursión. pending hace el papel que antes tenía la pila de llamadas.

Antes de seguir, predecí

Con el árbol de la escena, ¿en qué orden visita los nodos la versión iterativa?

Escena 1 — La pila de llamadas, convertida en una lista

paso a paso

Cargando la escena…

La pila de llamadas nunca pasa de dos marcos. Todo el trabajo pendiente está en pending, que se puede leer en cada paso.

Profundidad que no controlás

Si la profundidad está acotada y es chica, como un árbol balanceado o un menú de tres niveles, la recursión es clara y segura. Si viene de usuarios, archivos o cadenas de dependencias sin límite conocido, una pila explícita evita depender del máximo del runtime.

La estructura explícita sigue ocupando memoria proporcional a lo pendiente, pero vive en el heap, que es mucho más grande que la pila, y cuando se agota se puede manejar el error en vez de caer.

Convertir cualquier recursión

El DFS de arriba fue fácil de convertir porque el trabajo se hace antes de bajar a los hijos. Cuando hay trabajo después de la llamada recursiva, la pila explícita tiene que recordar también en qué punto quedó cada nodo, que es exactamente lo que guarda la dirección de retorno de un marco.

Más a fondo · nivel seniorPostorden iterativo: guardar dónde retomar

En un postorden cada nodo se procesa después de sus hijos. La pila guarda pares (nodo, expandido): la primera vez que sale un nodo se vuelve a apilar marcado, junto con sus hijos; la segunda vez, ya con los hijos procesados, recién se visita.

def postorder(root):
    pending = [(root, False)]
    order = []
    while pending:
        node, expanded = pending.pop()
        if expanded:
            order.append(node)
        else:
            pending.append((node, True))
            pending.extend((child, False) for child in reversed(tree[node]))
    return order

Con el árbol de la escena devuelve D, E, B, F, C, A. El booleano cumple el papel de la dirección de retorno: dice si hay que bajar o si hay que seguir desde después de la llamada. Con más de un punto de retorno, como en un algoritmo que hace algo entre dos llamadas recursivas, el booleano pasa a ser un número de fase. Es la receta general para pasar cualquier recursión a un ciclo, y es lo que hacen a mano los compiladores y los intérpretes que no quieren depender de la pila nativa.

Cómo elegir

SituaciónConvienePor qué
Proceso lineal largo: sumar, contar, recorrer una listaCicloEspacio constante y sin límite de pila
Árbol o grafo con profundidad acotada y chicaRecursiónEl código copia la definición y se prueba fácil
Profundidad que depende de datos externosPila explícitaEl límite lo controlás vos, no el runtime
Hay que pausar, guardar o inspeccionar el recorridoPila explícitaEl trabajo pendiente es un dato que se puede persistir

No decidas por cantidad de líneas. Compará espacio pico, límites del entorno y facilidad para probar. La versión más corta puede esconder justo el riesgo que importa en producción.

Cierre

Autoevaluación

¿Lo entendiste?

factorial_iterative usa O(1) espacio auxiliar y factorial_recursive usa O(n). ¿De dónde sale esa diferencia?
Convertiste un DFS recursivo en uno con pila explícita. ¿Qué pasa con la memoria?
Sin el reversed, ¿en qué orden visita el árbol de la escena?
Un parser recibe JSON de clientes externos, con anidamiento sin límite. ¿Qué conviene?