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:

  1. Caso base. Condición(es) en las que la función devuelve directamente, sin recursar.
  2. Caso recursivo. Llamadas a sí misma con un problema estrictamente más chico.

Ejemplo canónico — factorial.

n!={1si n=0n(n1)!si n>0n! = \begin{cases} 1 & \text{si } n = 0 \ n \cdot (n-1)! & \text{si } n > 0 \end{cases}

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:

  1. ¿Hay caso base? Sin él, no termina.
  2. ¿El caso recursivo se acerca al caso base? Sin esto, tampoco termina.
  3. ¿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 nn grande (n>1000n > 1000) 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

Fn=Fn1+Fn2F_n = F_{n-1} + F_{n-2}, con F0=0,F1=1F_0 = 0, F_1 = 1.

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 O(2n)O(2^n). 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 O(2n)O(2^n) a O(n)O(n).

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

O(n)O(n) tiempo, O(1)O(1) 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 O(logn)O(\log n). Para 1 millón de elementos, ~20 comparaciones.

Torres de Hanoi

3 varillas, nn 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 nn discos, mové los n1n-1 de arriba a la varilla auxiliar, mové el último al destino, mové los n1n-1 a destino. Total 2n12^n - 1 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: O(nlogn)O(n \log n). 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 O(nlogn)O(n \log n), peor caso O(n2)O(n^2). 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 NN reinas en un tablero N×NN\times N 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:

  1. Aunque descuento_acumulado es 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.

  2. Las type hints (-> tuple[float, float], dict[str, int]) hacen el código autodocumentado.

  3. 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().

✏️ Ejercicio 3.2 — Potencia

Implementá ana^n recursivamente, sin **. Hacé una versión O(n)O(n) y otra O(logn)O(\log n).

✏️ Ejercicio 3.3 — Recorrer JSON

Dado un dict / lista anidada como JSON, devolvé todos los strings que contiene (en cualquier nivel).

✏️ Ejercicio 3.4 — Mochila (knapsack 0/1)

Tenés una mochila de capacidad WW y items con peso y valor. Maximizá el valor total sin pasar la capacidad, sin partir items.

3.13 Para profundizar


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.