Recursión
"Para entender la recursión, primero hay que entender la recursión." — chiste clásico de programadores.
Qué vas a aprender en este capítulo
La recursión es el arte de resolver un problema definiéndolo en términos de una versión más chica de sí mismo. Es una de las ideas más elegantes y más confusas de la programación. Vas a aprender a reconocer problemas recursivos, a diseñar soluciones recursivas correctas (con su famoso caso base), a medir el costo (de tiempo y de pila), y a optimizar con memoización. Al final vas a poder resolver problemas que con bucles convencionales son pesadilla.
3.1 La idea: pelar la cebolla
💡 Intuición
Imaginá que querés saber cuántas pupusas hay en una caja que contiene cajas más chicas, que a su vez contienen cajas, hasta llegar a cajas individuales con pupusas. ¿Cómo contás?
Solución recursiva: "Para contar las pupusas en una caja, sumá las pupusas de cada cosa que hay adentro. Si lo que hay adentro es una caja, aplicá la misma regla recursivamente. Si es una pupusa suelta, contá 1."
Ese "aplicá la misma regla" es recursión. El procedimiento se invoca a sí mismo con un problema más simple, hasta llegar a algo trivial (la pupusa individual).
Una función recursiva es una función que se llama a sí misma. Para que termine, necesita un caso base — un escenario simple en el que devuelve sin volver a llamarse.
Sin caso base: llamadas infinitas → RecursionError (la pila se llena).
Con caso base: problema se reduce hasta el caso simple → respuesta.
La recursión es el lenguaje natural para problemas que tienen estructura recursiva: árboles (DOM, archivos), grafos (rutas), expresiones matemáticas, fractales, divide-y-vencerás, backtracking. En todos esos, recursión vale más que mil bucles.
3.2 Anatomía de una función recursiva
📐 Fundamento
Toda función recursiva tiene dos partes:
- Caso base. Condición(es) en las que la función devuelve directamente, sin recursar.
- Caso recursivo. Llamadas a sí misma con un problema estrictamente más chico.
Ejemplo canónico — factorial.
def factorial(n):
if n == 0: # caso base
return 1
return n * factorial(n - 1) # caso recursivo
Cómo "ver" la ejecución de factorial(4):
factorial(4)
= 4 * factorial(3)
= 4 * (3 * factorial(2))
= 4 * (3 * (2 * factorial(1)))
= 4 * (3 * (2 * (1 * factorial(0))))
= 4 * (3 * (2 * (1 * 1))) ← caso base
= 4 * (3 * (2 * 1))
= 4 * (3 * 2)
= 4 * 6
= 24
Cada llamada se apila sobre la anterior. La pila crece hasta el caso base, después se "resuelven" en orden inverso.
Las tres preguntas para verificar una recursión:
- ¿Hay caso base? Sin él, no termina.
- ¿El caso recursivo se acerca al caso base? Sin esto, tampoco termina.
- ¿La función es correcta asumiendo que las llamadas internas son correctas? Esto se llama inducción.
Si las tres se cumplen, la función es correcta y termina.
3.3 El costo: pila y profundidad
⚠️ Trampa común
Cada llamada recursiva ocupa un frame en la pila de ejecución. La pila tiene tamaño limitado (típicamente unos cientos de KB → ~1000 frames en Python).
def cuenta(n):
if n == 0:
return
cuenta(n - 1)
cuenta(2000) # RecursionError: maximum recursion depth exceeded
Python tiene un límite por defecto de 1000 niveles. Podés subirlo con sys.setrecursionlimit(N), pero ojo: una pila demasiado profunda puede agotar memoria del sistema.
Implicación: problemas con tamaño grande () no podés resolverlos con recursión directa en Python. Necesitás:
- Convertir a iteración (bucle).
- Usar pila explícita (lista que actúa como pila).
- Recursión de cola (que Python NO optimiza, pero sí algunos lenguajes).
3.4 Recursión vs iteración
📐 Fundamento
Toda función recursiva se puede convertir en iterativa, y viceversa. La pregunta es cuál es más natural para el problema.
Factorial — iterativo:
def factorial(n):
r = 1
for i in range(2, n + 1):
r *= i
return r
Más simple, no agota pila. Iterativo gana acá.
Suma de árbol — recursivo:
def suma(arbol):
"""arbol = (valor, [hijos])"""
valor, hijos = arbol
return valor + sum(suma(h) for h in hijos)
Iterativo sería un horror (necesitás simular pila). Recursivo gana porque la estructura del problema es recursiva (un árbol contiene subárboles).
Regla práctica
| Problema | Mejor enfoque |
|---|---|
| Lineal (una secuencia) | Iterativo |
| Estructuras anidadas (árboles, archivos, JSON) | Recursivo |
| Divide y vencerás (mergesort, búsqueda binaria) | Recursivo |
| Backtracking (sudoku, laberintos, n-reinas) | Recursivo |
| Cálculo numérico simple | Iterativo |
No es ideológico — usá lo que se lea mejor.
3.5 Ejemplos clásicos
🛠️ En la práctica
Fibonacci
, con .
Versión recursiva (lenta):
def fib(n):
if n < 2:
return n
return fib(n - 1) + fib(n - 2)
Problema: repetición exponencial. fib(5) calcula fib(3) dos veces, fib(2) tres veces, etc. Tiempo . fib(40) tarda segundos; fib(100) no termina.
Solución 1: memoización. Recordar resultados ya calculados.
cache = {}
def fib(n):
if n in cache:
return cache[n]
if n < 2:
return n
r = fib(n - 1) + fib(n - 2)
cache[n] = r
return r
fib(100) ahora es instantáneo. Pasamos de a .
Solución 2: decorador lru_cache.
from functools import lru_cache
@lru_cache(maxsize=None)
def fib(n):
if n < 2:
return n
return fib(n - 1) + fib(n - 2)
@lru_cache agrega memoización automáticamente. Una línea, mismo efecto.
Solución 3: iterativo.
def fib(n):
a, b = 0, 1
for _ in range(n):
a, b = b, a + b
return a
tiempo, memoria. Lo más eficiente.
Búsqueda binaria
Buscar un elemento en una lista ordenada mirando el medio y reduciendo el espacio a la mitad.
def buscar(lista, x, lo=0, hi=None):
if hi is None:
hi = len(lista) - 1
if lo > hi:
return -1
mid = (lo + hi) // 2
if lista[mid] == x:
return mid
if lista[mid] > x:
return buscar(lista, x, lo, mid - 1)
return buscar(lista, x, mid + 1, hi)
Tiempo . Para 1 millón de elementos, ~20 comparaciones.
Torres de Hanoi
3 varillas, discos en la primera (orden de tamaños). Mover todos a la tercera respetando: solo de a uno, nunca un disco grande sobre uno chico.
def hanoi(n, origen, destino, auxiliar):
if n == 0:
return
hanoi(n - 1, origen, auxiliar, destino)
print(f"Mover disco {n}: {origen} → {destino}")
hanoi(n - 1, auxiliar, destino, origen)
hanoi(3, "A", "C", "B")
Salida:
Mover disco 1: A → C
Mover disco 2: A → B
Mover disco 1: C → B
Mover disco 3: A → C
Mover disco 1: B → A
Mover disco 2: B → C
Mover disco 1: A → C
Inducción: para mover discos, mové los de arriba a la varilla auxiliar, mové el último al destino, mové los a destino. Total movimientos. Ningún algoritmo lo hace mejor.
Hanoi sin recursión es pesadilla. Con recursión: 4 líneas.
3.6 Divide y vencerás
📐 Fundamento
Estrategia: dividí el problema, resolvé las partes recursivamente, combiná las soluciones. La búsqueda binaria es un ejemplo.
Mergesort
Ordenar una lista dividiéndola y mezclando.
def mergesort(lista):
if len(lista) <= 1:
return lista
mitad = len(lista) // 2
izq = mergesort(lista[:mitad])
der = mergesort(lista[mitad:])
return merge(izq, der)
def merge(a, b):
"""Mezcla dos listas ordenadas en una ordenada."""
r = []
i = j = 0
while i < len(a) and j < len(b):
if a[i] <= b[j]:
r.append(a[i]); i += 1
else:
r.append(b[j]); j += 1
r.extend(a[i:])
r.extend(b[j:])
return r
print(mergesort([5, 2, 8, 1, 9, 3])) # [1, 2, 3, 5, 8, 9]
Tiempo: . Es el algoritmo más usado en bibliotecas estándar (Java, Python sort usa una variante llamada Timsort).
Quicksort (esbozo)
def quicksort(lista):
if len(lista) <= 1:
return lista
pivote = lista[0]
izq = [x for x in lista[1:] if x < pivote]
der = [x for x in lista[1:] if x >= pivote]
return quicksort(izq) + [pivote] + quicksort(der)
Promedio , peor caso . Es in-place y rápido en práctica con buena elección de pivote.
3.7 Backtracking
🛠️ En la práctica
Backtracking = "probar opciones, retroceder si no funciona". Recursión ideal.
Permutaciones
Generar todas las permutaciones de una lista.
def permutaciones(lista):
if len(lista) <= 1:
return [lista]
resultado = []
for i in range(len(lista)):
rest = lista[:i] + lista[i+1:]
for p in permutaciones(rest):
resultado.append([lista[i]] + p)
return resultado
print(permutaciones([1, 2, 3]))
# [[1,2,3], [1,3,2], [2,1,3], [2,3,1], [3,1,2], [3,2,1]]
N-reinas
Ubicar reinas en un tablero de modo que ninguna ataque a otra.
def reinas(n, fila=0, posiciones=None):
if posiciones is None:
posiciones = []
if fila == n:
return [posiciones[:]]
soluciones = []
for col in range(n):
if all(c != col and abs(c - col) != fila - i
for i, c in enumerate(posiciones)):
posiciones.append(col)
soluciones.extend(reinas(n, fila + 1, posiciones))
posiciones.pop()
return soluciones
print(len(reinas(8))) # 92 soluciones para 8 reinas
Laberintos, sudoku, generación de palabras…
Todos siguen el mismo esqueleto:
def resolver(estado):
if estado es solución:
registrar
para cada movimiento posible:
aplicar movimiento (modifica estado)
resolver(estado)
deshacer movimiento (rollback)
La clave es el rollback: deshacer cambios al regresar de la recursión.
3.8 Recursión de cola y por qué Python no la optimiza
📐 Fundamento
Una llamada de cola es cuando lo último que hace una función es llamar a otra (sin operaciones después). Algunos lenguajes la optimizan reusando el mismo frame (no crece la pila).
# Recursión de cola (en lenguajes con TCO):
def factorial(n, acc=1):
if n == 0:
return acc
return factorial(n - 1, n * acc)
En Lisp, Scheme, Erlang, Scala, Haskell — TCO (tail call optimization) convierte eso en bucle. En Python no: cada llamada sigue ocupando frame. Por eso seguís topándote contra el límite.
Guido van Rossum (creador de Python) explicó que omitió TCO a propósito: facilita el debugging (ves el call stack) y prefiere la sintaxis explícita de bucles.
Conclusión: en Python, si querés profundidad arbitraria, convertí a iterativo.
3.9 Memoización general
📐 Fundamento
Memoización = recordar resultados. Aplicable a cualquier función pura (mismo input → mismo output).
Manual:
def memoizar(func):
cache = {}
def wrapper(*args):
if args in cache:
return cache[args]
r = func(*args)
cache[args] = r
return r
return wrapper
@memoizar
def fib(n):
if n < 2:
return n
return fib(n - 1) + fib(n - 2)
@memoizar es un decorador — modifica fib envolviéndola.
Built-in: functools.lru_cache y functools.cache (Python 3.9+).
from functools import cache
@cache
def fib(n):
return n if n < 2 else fib(n - 1) + fib(n - 2)
Una línea, problema resuelto.
Cuándo usar memoización:
- Función pura (sin side-effects).
- Inputs hashables (los args van a un dict interno).
- Mismo cálculo se hace varias veces.
Si la función tiene side-effects (escribe a disco, modifica globales), memoización los suprime silenciosamente — bug grave.
3.10 Proyecto: descuentos por niveles
🏗️ Avance del proyecto
La pupusería tiene un sistema de descuentos por niveles: si pedís más de N pupusas, cada bloque adicional te da descuento progresivo. Implementado recursivamente:
# pupuseria_v3.py - descuentos recursivos
PRECIOS = {"Q": 0.50, "R": 0.60, "F": 0.55, "CH": 0.60}
IVA = 0.13
def descuento_acumulado(cantidad: int) -> float:
"""
Retorna el descuento (fracción) según la cantidad.
Niveles:
- Pupusas 1-9: sin descuento.
- Pupusas 10-19: 5% sobre las que están en este rango.
- Pupusas 20-49: 10% sobre las que están en este rango.
- Pupusas 50+: 15% sobre las que están en este rango.
Ejemplo: 25 pupusas →
9 al 0%, 10 al 5%, 6 al 10%
Devolvemos el descuento total como número de pupusas "ahorradas".
"""
if cantidad <= 0:
return 0
if cantidad <= 9:
return 0
if cantidad <= 19:
return (cantidad - 9) * 0.05
if cantidad <= 49:
return 10 * 0.05 + (cantidad - 19) * 0.10
return 10 * 0.05 + 30 * 0.10 + (cantidad - 49) * 0.15
def precio_con_descuento(codigo: str, cantidad: int) -> tuple[float, float]:
"""Devuelve (subtotal, descuento_dinero)."""
if codigo not in PRECIOS:
raise ValueError(f"Sabor desconocido: {codigo}")
p = PRECIOS[codigo]
subtotal = cantidad * p
pupusas_descontadas = descuento_acumulado(cantidad)
descuento = pupusas_descontadas * p
return subtotal, descuento
def factura(pedido: dict[str, int]) -> dict:
"""Calcula la factura para un pedido {codigo: cantidad}."""
lineas = []
subtotal_total = 0
descuento_total = 0
for codigo, cant in pedido.items():
sub, desc = precio_con_descuento(codigo, cant)
lineas.append((codigo, cant, sub, desc))
subtotal_total += sub
descuento_total += desc
base = subtotal_total - descuento_total
iva = base * IVA
total = base + iva
return {
"lineas": lineas,
"subtotal": subtotal_total,
"descuento": descuento_total,
"base": base,
"iva": iva,
"total": total,
}
def imprimir_factura(f):
print("\n=== FACTURA ===")
for codigo, cant, sub, desc in f["lineas"]:
print(f" {cant:3}× {codigo:4} subtotal=<span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mrow><mi>s</mi><mi>u</mi><mi>b</mi><mo>:</mo><mn>.2</mn><mi>f</mi></mrow><mo separator="true">,</mo><mi>d</mi><mi>e</mi><mi>s</mi><mi>c</mi><mo>=</mo></mrow><annotation encoding="application/x-tex">{sub:.2f}, desc=</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.8889em;vertical-align:-0.1944em;"></span><span class="mord"><span class="mord mathnormal">s</span><span class="mord mathnormal">u</span><span class="mord mathnormal">b</span><span class="mspace" style="margin-right:0.2778em;"></span><span class="mrel">:</span><span class="mspace" style="margin-right:0.2778em;"></span><span class="mord">.2</span><span class="mord mathnormal" style="margin-right:0.1076em;">f</span></span><span class="mpunct">,</span><span class="mspace" style="margin-right:0.1667em;"></span><span class="mord mathnormal">d</span><span class="mord mathnormal">esc</span><span class="mspace" style="margin-right:0.2778em;"></span><span class="mrel">=</span></span></span></span>{desc:.2f}")
print(f" Subtotal ${f['subtotal']:.2f}")
print(f" Descuento -${f['descuento']:.2f}")
print(f" IVA ${f['iva']:.2f}")
print(f" TOTAL ${f['total']:.2f}")
# Test
pedido = {"Q": 25, "R": 12}
imprimir_factura(factura(pedido))
Lo que aprendiste aplicado:
-
Aunque
descuento_acumuladoes por niveles (no recursivo en el sentido estricto), la estructura mental "primero el rango 1-9, después el 10-19, ..." se mapea naturalmente a una función con varios casos. Si quisiéramos generalizar a niveles arbitrarios, una función recursiva los recorrería. -
Las type hints (
-> tuple[float, float],dict[str, int]) hacen el código autodocumentado. -
Devolver un dict con la factura completa es un patrón limpio — el caller decide qué mostrar.
3.11 Resumen visual
| Concepto | Una línea |
|---|---|
| Recursión | Una función se llama a sí misma con un input más chico |
| Caso base | Condición de salida; la recursión termina ahí |
| Caso recursivo | Reduce el problema y llama a la función |
| Pila de llamadas | Cada llamada ocupa un frame; tiene límite |
| Memoización | Recordar resultados para no recalcular |
| Divide y vencerás | Dividí, resolvé partes, combiná |
| Backtracking | Probar, recursar, retroceder al fallar |
| Recursión de cola | Llamada al final; Python no la optimiza |
3.12 Ejercicios
✏️ Ejercicio 3.1 — Suma recursiva
Escribí suma(lista) recursivamente. Sin usar sum().
Solución
def suma(lista):
if not lista:
return 0
return lista[0] + suma(lista[1:])
print(suma([1, 2, 3, 4, 5])) # 15
(Detalle de eficiencia: lista[1:] crea una copia. En lenguajes serios pasarías un índice.)
✏️ Ejercicio 3.2 — Potencia
Implementá recursivamente, sin **. Hacé una versión y otra .
Solución
:
def pot(a, n):
if n == 0:
return 1
return a * pot(a, n - 1)
(exponenciación rápida):
def pot_rapido(a, n):
if n == 0:
return 1
if n % 2 == 0:
m = pot_rapido(a, n // 2)
return m * m
return a * pot_rapido(a, n - 1)
print(pot_rapido(2, 10)) # 1024
Idea clave: si par. Reducimos a la mitad cada paso, en lugar de uno menos.
✏️ Ejercicio 3.3 — Recorrer JSON
Dado un dict / lista anidada como JSON, devolvé todos los strings que contiene (en cualquier nivel).
Solución
def strings(obj):
if isinstance(obj, str):
return [obj]
if isinstance(obj, list):
return [s for x in obj for s in strings(x)]
if isinstance(obj, dict):
return [s for v in obj.values() for s in strings(v)]
return []
ejemplo = {"nombre": "ana", "edad": 25, "amigos": [{"nombre": "beto"}, "carla"]}
print(strings(ejemplo)) # ['ana', 'beto', 'carla']
✏️ Ejercicio 3.4 — Mochila (knapsack 0/1)
Tenés una mochila de capacidad y items con peso y valor. Maximizá el valor total sin pasar la capacidad, sin partir items.
Solución
from functools import cache
items = [(2, 3), (3, 4), (4, 5), (5, 6)] # (peso, valor)
W = 7
@cache
def mochila(i, w):
if i == len(items) or w == 0:
return 0
peso, valor = items[i]
no_tomar = mochila(i + 1, w)
if peso > w:
return no_tomar
tomar = mochila(i + 1, w - peso) + valor
return max(no_tomar, tomar)
print(mochila(0, W)) # 9 (items 1 y 2)
Sin @cache, sería . Con cache, .
Es un problema clásico de programación dinámica, que es memoización aplicada con disciplina.
3.13 Para profundizar
- Sedgewick & Wayne, Algorithms. Capítulos sobre divide-y-vencerás.
- CLRS, Introduction to Algorithms. Estándar de referencia.
- Visualizaciones recursivas: https://recursion.vercel.app/
- Próximo capítulo: Archivos y excepciones — guardar datos persistentemente y manejar errores como pro.
Definiciones nuevas: recursión, caso base, caso recursivo, pila de llamadas, memoización, decorador, divide y vencerás, backtracking, mergesort, quicksort, recursión de cola, exponenciación rápida.