De la recursión al bucle
En resumen: Una recursión puede convertirse en un bucle: una llamada de cola pasa a ser un while, y el recorrido de un árbol pasa a ser un bucle con su propia pila, que no se agota con datos profundos.
Problema
El árbol viene de fuera
En la lección «La pila de llamadas por dentro» el recorrido del catálogo falló en dos sitios: la cadena de la importación tenía 100 000 niveles de profundidad, y otra importación cerró un ciclo. Una pila grande no es una solución. sys.setrecursionlimit solo traslada el límite: la siguiente importación puede ser más profunda, y la pila real del proceso se agota sin mensaje.
El árbol viene de fuera: de un proveedor, de una API, de una base de datos. No puedes prometer lo profundo que es. Necesitas un recorrido cuya memoria no dependa de la pila de llamadas.
La idea
Recursión de cola
Empieza con una tarea más sencilla. Sobre un producto la tienda muestra el nivel de su categoría. El nivel se cuenta subiendo por los padres hasta llegar a la raíz:
def level_of(category, level): if category.parent is None: return level return level_of(category.parent, level + 1)Aquí la llamada recursiva es lo último que hace el la función: su resultado se devuelve directamente, sin + 1 y sin bucle después. Una llamada así es una llamada de cola (tail call), y la recursión es recursión de cola. Después el marco no tiene nada más que recordar. Un lenguaje que lo sepa puede reutilizar el marco en lugar de añadir uno nuevo: la pila no crece.
Pero:
- Java no lo hace. La JVM conserva cada marco.
- El compilador de C# no lo hace. El compilador JIT a veces lo hace en código optimizado, pero no está prometido: en nuestra prueba con .NET 10 en Release la pila aun así se desbordó.
- Python no lo hace a propósito: Guido van Rossum está en contra, porque el traceback perdería marcos.
- JavaScript debe hacerlo según el estándar (ES2015, en modo estricto). En la práctica solo lo hace Safari. V8 (Chrome, Node) y Firefox no.
Por eso la recursión de cola falla también con la cadena de la importación:
last = chain(100_000)while last.children: last = last.children[0]try: print("Recursión: nivel " + str(level_of(last, 0)))except RecursionError as e: print(f"Recursión: RecursionError: {e}")print("Bucle: nivel " + str(level_of_loop(last)))Recursión: RecursionError: maximum recursion depth exceededBucle: nivel 99999La idea
La llamada de cola se convierte en while
La recursión de cola se convierte en un bucle de forma mecánica, con tres reglas:
- los parámetros pasan a ser variables:
categoryylevel; - la llamada pasa a ser una asignación: en lugar de llamarte con los valores nuevos, los pones en las variables y empiezas de nuevo;
- el caso base pasa a ser la condición final del bucle: «sin padre» detiene el
while.
def level_of_loop(category): level = 0 while category.parent is not None: category = category.parent level += 1 return levelEsta es la línea de abajo de la salida de arriba: el bucle llega al nivel 99 999 con un marco. La recursión y el bucle dan el mismo número de pasos, uno por nivel. La única diferencia es dónde está la memoria.
La idea
El recorrido no es una llamada de cola
count_products de la lección anterior no es recursión de cola. Después de cada llamada hay trabajo: total += y el siguiente hijo del bucle. Por eso hacen falta los marcos. ¿Qué guardaban en realidad? Las categorías a las que aún no ha llegado.
Eso lo puede guardar un bucle por sí mismo, en su propia pila: una lista de las categorías que aún faltan. Tomas la de arriba, sumas sus productos y pones sus hijos encima. La suma es una variable, porque sumar no depende del orden.
def count_products(root): total = 0 stack = [root] while stack: category = stack.pop() total += category.products stack.extend(category.children) return total
print("Productos: " + str(count_products(shop)))Productos: 20Una lista es una pila: append y extend ponen al final, pop() toma del final. Cómo funcionan por dentro está en la lección «Pila, cola y lista enlazada».
Míralo
Tu propia pila, paso a paso
def count_products(root): total = 0 stack = [root] while stack: category = stack.pop() total += category.products stack.extend(category.children) return total
print("Productos: " + str(count_products(shop)))Paso a paso, en palabras
- La pila es una lista en la memoria de objetos (el heap), no la pila de llamadas. Primero entra la raíz.
- pop toma el de arriba: Tienda. Sus hijos entran encima: Electrónica, luego Hogar.
- Sigue Hogar, no Electrónica: una pila devuelve lo último que entró. total = 4, y entra Cocina.
- Cocina no tiene hijos: no entra nada. total = 10.
- Solo ahora Electrónica: total = 12. Entran Teléfonos y Portátiles.
- Portátiles (15), luego Teléfonos (20).
- La pila está vacía: el bucle se detiene y devuelve 20. En ella nunca hubo más de 2 categorías. El orden, sin embargo, es Tienda, Hogar, Cocina, Electrónica, Portátiles, Teléfonos: no el de la recursión.
Tu pila propia es un objeto corriente en la memoria de objetos (el heap). Crece según haga falta y está limitada por toda la memoria, no por 1 MB. Y los números del árbol muestran que el orden es distinto.
Pruébalo
Predice el orden
El mismo recorrido, pero imprime el nombre de cada categoría en cuanto la toma de la pila:
def print_order(root): stack = [root] while stack: category = stack.pop() print(category.name) stack.extend(category.children)
print_order(shop)Tienda entra primero y sale primero. Luego entran Electrónica y Hogar, en ese orden, así que Hogar queda arriba y sale antes que Electrónica. Lo mismo pasa con Teléfonos y Portátiles. Lo último que entró sale primero: la pila invierte el orden de los hijos en cada nivel.
La idea
El orden de la recursión: los hijos al revés
Para la suma el orden no importa. Para el menú sí: «Hogar» no debe ir antes que «Electrónica». La recursión visita primero al padre, luego a los hijos de izquierda a derecha (pre-orden). Con una pila propia sale el mismo orden si metes los hijos al revés: el último hijo entra primero y sale último.
El menú también necesita sangría. Con recursión el nivel sería un parámetro, es decir, estaría en el marco. Ahora no hay marco, así que la pila guarda pares: la categoría y su nivel. Lo que guardaba el marco, ahora lo guarda tu pila.
def print_menu(root): stack = [(root, 0)] while stack: category, level = stack.pop() print(" " * level + category.name) for child in reversed(category.children): stack.append((child, level + 1))
print_menu(shop)Tienda Electrónica Teléfonos Portátiles Hogar Cocina(category, level). reversed() lee la lista desde atrás, sin copiarla.Míralo
El árbol profundo pasa
La cadena de la importación, 100 000 niveles, con el recorrido de la lección:
deep = chain(100_000)print(f"Profundidad 100000: {count_products(deep)} productos")Profundidad 100000: 100000 productosEn los cuatro lenguajes, sin un hilo con una pila grande y sin tocar ningún límite.
Aquí no hay Bench, a propósito. La recursión y el bucle hacen el mismo número de operaciones: una por categoría, O(n). Las curvas serían iguales. La diferencia está en la memoria: la recursión guarda hasta profundidad marcos en la pila pequeña, el bucle guarda las categorías que esperan en el heap. En un árbol ancho tu pila propia puede hacerse grande (todos los hermanos que esperan), pero su espacio es toda la memoria.
La idea
Un ciclo necesita memoria de lo visto
Tu pila propia no te salva de un ciclo. Si «Hogar» está en «Cocina» y «Cocina» está en «Hogar», las dos se turnan en la pila para siempre. La pila ni siquiera crece, así que nada se desborda y nada se lanza: la página simplemente se cuelga. Eso es peor que el error.
La solución es un conjunto de las categorías ya vistas. Antes de recorrer una categoría, compruebas si ya está en el conjunto:
def count_products(root): total = 0 seen = set() stack = [root] while stack: category = stack.pop() if category in seen: print("Omito " + category.name + ": ya se visitó") continue seen.add(category) total += category.products stack.extend(category.children) return total
kitchen.add(home)print("Productos: " + str(count_products(shop)))Omito Hogar: ya se visitóProductos: 20Un objeto sin __eq__ ni __hash__ se compara por identidad: el set guarda las propias categorías, no sus nombres. El mismo conjunto salva también la recursión: count_products con un parámetro «vistas» detiene el ciclo. Contra la profundidad, sin embargo, solo ayuda el bucle.
Pruébalo
Pon las líneas en orden
Pon en orden el recorrido con pila propia:
category = stack.pop()while stack:return totaldef count_products(root):stack = [root]total = 0total += category.productsstack.extend(category.children)
Pruébalo
Completa
¿Cuándo se detiene el bucle?
def count_products(root):
total = 0
stack = [root]
while :
category = stack.pop()
total += category.products
stack.extend(category.children)
return totalEl bucle se ejecuta mientras haya categorías esperando en la pila. Una pila vacía significa que todo se ha recorrido. En Python una lista vacía es falsa, así que basta while stack.
Ejercicio
El nivel más profundo
Resuélvelo en tu editor.
Qué tan profundo es el catálogo. La importación cuelga su cadena bajo «Cocina». Lee cuántos niveles tiene la cadena, engánchala e imprime cuántas categorías hay en total y cuál es el nivel más profundo (Tienda está en el nivel 0). Sin recursión: una pila de pares de categoría y nivel, como en el menú.
100000Categorías: 100006Nivel más profundo: 100002Más tareas
- El camino de migas sin recursión. Reescribe
pathde la lección anterior con un bucle subiendo por los padres. La lista se llena desde atrás. - El menú con las cantidades de productos. Imprime el menú, pero junto a cada categoría la suma con sus subcategorías. Pista: la suma del padre está lista solo después de sus hijos, así que con un bucle cada categoría entra dos veces en la pila: una «abierta» y otra «suma».
Más precisamente
La pila en cada lenguaje
Una pila se escribe distinto en cada lenguaje, y no todas las pilas son una buena elección.
Más precisamente, para curiosos
- Python: una lista es una pila rápida (
appendypop()al final son O(1)). Para una cola usacollections.deque:pop(0)en una lista es O(n). - Un generador con
yield fromhace corto el recorrido recursivo, pero debajo siguen existiendo marcos: la profundidad sigue chocando con el límite de 1000.
Trampolín. La función no se llama a sí misma, sino que devuelve «qué hay que llamar a continuación», y un bucle externo lo llama. Así la recursión de cola se ejecuta sin pila creciente incluso en lenguajes sin llamadas de cola. Funciona, pero un bucle escrito directamente es más claro.
Una cola en lugar de una pila. Si usas una cola (FIFO) en lugar de una pila, el recorrido pasa por el árbol nivel a nivel: Tienda, luego Electrónica y Hogar, luego Teléfonos, Portátiles y Cocina. Es la búsqueda en anchura (BFS). Encuentra el camino más corto en un grafo, y te la encontrarás en el módulo «Grafos».
Test
Ponte a prueba
Lecciones relacionadas
Sección «Lecciones relacionadas»- Se basa en: Complejidad en tiempo: contar pasos · Big-O: cómo crece el trabajo · Complejidad en espacio · La pila de llamadas por dentro
- Continúa en: Exponenciación rápida · DFS: islas y ciclos · Subproblemas superpuestos
- Ejercicios sobre el tema: Recursión