La lección anterior terminó con dos deudas: la búsqueda binaria tenía una versión «más elegante» que no podíamos escribir, y el mergesort necesitaba ordenar dos mitades que son, a su vez, listas por ordenar. Las dos piden lo mismo, y es lo que aprenderás aquí: una función que se llama a sí misma.
La recursión desconcierta la primera vez porque parece un truco circular, y no lo es: es una forma distinta de descomponer problemas, complementaria de la descomposición en funciones de 04-04. En vez de partir el problema en subproblemas distintos, lo partes en versiones más pequeñas del mismo problema hasta llegar a un caso tan simple que se resuelve sin pensar. En esta lección la entenderás, la trazarás, descubrirás por qué en Python conviene desconfiar de ella, y la usarás en los tres sitios donde gana de calle.
Contenido
- La idea: cajas dentro de cajas
- Las dos piezas obligatorias
- La pila de llamadas y el
RecursionError - Ejemplos progresivos con su traza
- El árbol de llamadas: factorial y Fibonacci
- Recursión frente a iteración
- El coste oculto de la recursión ingenua
- Memoización: recordar lo ya calculado
- Donde la recursión sí gana
- Búsqueda binaria recursiva y ordenación por mezcla
- TareaFácil v0.14: días de un desglose anidado
- Errores comunes y consejos
- Ejercicios
- Conclusión
- La idea: cajas dentro de cajas
Imagina que en el almacén de Estudio Alba te piden contar cuántos folios hay, y el almacén contiene cajas, algunas de las cuales contienen otras cajas. No necesitas un procedimiento distinto para cada nivel de profundidad. Necesitas una sola regla:
Para contar los folios de una caja: si dentro solo hay folios, cuéntalos. Si dentro hay otras cajas, cuenta los folios de cada una y suma los resultados.
Fíjate en lo que acabas de hacer: has definido «contar los folios de una caja» en términos de sí mismo, aplicado a algo más pequeño. Y funciona porque las cajas no son infinitas: tarde o temprano llegas a una que solo contiene folios y ahí paras. Lo mismo pasa con las muñecas rusas, con las carpetas dentro de carpetas del disco duro o con la definición de «antepasado»: tus padres, y los antepasados de tus padres. Esa es toda la recursión: una función recursiva se llama a sí misma con una versión más pequeña del problema, confiando en que esa llamada le devolverá la respuesta correcta.
- Las dos piezas obligatorias
Toda función recursiva tiene exactamente dos partes, y ninguna de las dos puede faltar:
| Pieza | Qué es | Qué pasa si falta |
|---|---|---|
| Caso base | La situación tan simple que se resuelve sin recursión | La función no para nunca: RecursionError |
| Caso recursivo | La llamada a sí misma con un problema más pequeño | No es recursión: es una función normal |
def cuenta_atras(n):
"""Imprime la cuenta atras desde n hasta 1 y despues Ya."""
if n == 0: # CASO BASE: no queda nada que contar
print("Ya!")
return
print(n) # trabajo de este nivel
cuenta_atras(n - 1) # CASO RECURSIVO: el mismo problema, mas pequenoAl escribir una función recursiva, hazte siempre las tres preguntas en este orden: ¿cuál es el caso más simple que sé resolver sin pensar? (ese es el base), ¿cómo reduzco el problema hacia ese caso? (esa es la llamada recursiva) y ¿qué hago con lo que me devuelva? (ese es el trabajo de este nivel). Y hay un requisito que va más allá de tener las dos piezas: el caso recursivo debe acercarse al base. cuenta_atras(n - 1) se acerca a cero; cuenta_atras(n) no se acerca a nada y se cuelga igual que si no hubiera caso base.
- La pila de llamadas y el
RecursionError
RecursionErrorEn 04-01 viste que cuando una función llama a otra, la primera espera a que la segunda termine. Python guarda cada llamada pendiente en la pila de llamadas, con sus variables y el punto exacto al que hay que volver. La recursión apila llamadas de la misma función:
graph TD
A["cuenta_atras(3) imprime 3"] --> B["cuenta_atras(2) imprime 2"]
B --> C["cuenta_atras(1) imprime 1"]
C --> D["cuenta_atras(0) imprime Ya y vuelve"]
D -.->|"se desapila hasta el principio"| A
La pila crece hasta el caso base y después se desapila en orden inverso. Eso tiene una consecuencia práctica de primer orden: cada llamada pendiente ocupa memoria. Python limita la profundidad a unas 1.000 llamadas y, si se supera, aborta con RecursionError: maximum recursion depth exceeded.
import sys
def sin_base(n):
return sin_base(n - 1) # nunca para: RecursionError en menos de un segundo
print(sys.getrecursionlimit()) # 1000 en la mayoria de instalaciones
sys.setrecursionlimit(3000) # se puede subir... pero casi nunca es la solucionEse RecursionError es, en realidad, una buena noticia: Python te avisa de un fallo lógico —falta el caso base o no te acercas a él— antes de agotar la memoria. Subir el límite existe, pero es casi siempre la respuesta equivocada: si tu recursión necesita más de mil niveles, lo que necesitas es un bucle.
- Ejemplos progresivos con su traza
Factorial. El factorial de n es el producto de los enteros de 1 a n, y su definición matemática ya es recursiva: n! = n × (n-1)!, con 0! = 1.
def factorial(n):
"""Devuelve el factorial de n (producto de 1 a n)."""
if n <= 1: # caso base: 0! y 1! valen 1
return 1
return n * factorial(n - 1) # caso recursivoTraza de factorial(4); las primeras columnas son la ida, apilando llamadas, y la última es la vuelta, cuando cada nivel recibe su resultado y lo multiplica:
| Nivel | Llamada (ida) | Espera a… | Recibe | Devuelve |
|---|---|---|---|---|
| 1 | factorial(4) |
factorial(3) |
6 | 4 * 6 = 24 |
| 2 | factorial(3) |
factorial(2) |
2 | 3 * 2 = 6 |
| 3 | factorial(2) |
factorial(1) |
1 | 2 * 1 = 2 |
| 4 | factorial(1) |
nadie: caso base | — | 1 |
Lee la tabla de abajo arriba en la última columna y verás cómo se construye el resultado: 1, 2, 6, 24. Nada se calcula en la ida; todo el trabajo real ocurre al volver. El mismo esquema sirve sobre colecciones: la suma de una lista es su primer elemento más la suma del resto, y una cadena invertida es el resto invertido más su primer carácter.
def suma(valores):
"""Devuelve la suma de los numeros de la lista."""
if not valores: # caso base: lista vacia
return 0
return valores[0] + suma(valores[1:]) # primero + suma del resto
def invertir(texto):
"""Devuelve el texto del reves."""
if len(texto) <= 1: # caso base: 0 o 1 caracter
return texto
return invertir(texto[1:]) + texto[0] # el resto invertido + el primerovalores[1:] es la rebanada de 05-01: «todos menos el primero». Cada llamada recibe una colección un elemento más corta, así que se acerca al caso base. Con [3, 5, 2] sale 3 + (5 + (2 + 0)), es decir, 10; y con "Alba", invertir("lba") + "A" → ("ab" + "l") + "A" → "ablA". En producción escribirías sum(valores) y texto[::-1]; esto es para ver la mecánica.
- El árbol de llamadas: factorial y Fibonacci
El factorial genera una cadena de llamadas: cada nivel llama una sola vez. Fibonacci —cada número es la suma de los dos anteriores, empezando por 0 y 1— genera un árbol, porque cada nivel llama dos veces:
def fibonacci(n):
"""Devuelve el n-esimo numero de Fibonacci (version ingenua)."""
if n < 2: # casos base: fib(0)=0, fib(1)=1
return n
return fibonacci(n - 1) + fibonacci(n - 2)graph TD
A["fib(5)"] --> B["fib(4)"]
A --> C["fib(3) *"]
B --> D["fib(3) *"]
B --> E["fib(2) **"]
D --> F["fib(2) **"]
D --> G["fib(1)"]
C --> H["fib(2) **"]
C --> I["fib(1)"]
Mira los nodos marcados: fib(3) se calcula dos veces y fib(2) tres veces, con todo su subárbol repetido cada vez. Y eso solo con n = 5. La diferencia entre las dos formas es total: el factorial hace n llamadas, Fibonacci hace aproximadamente el doble por cada unidad que sube n. Esa explosión es el asunto de la sección 7.
- Recursión frente a iteración
Todo lo que se puede escribir con recursión se puede escribir con un bucle, y al revés. La elección es de claridad y coste, no de posibilidad.
| Recursión | Iteración (bucle) | |
|---|---|---|
| Legibilidad | Excelente si el problema es naturalmente recursivo | Excelente en recorridos lineales |
| Memoria | Una entrada en la pila por llamada pendiente | Constante: unas pocas variables |
| Velocidad en Python | Más lenta: cada llamada tiene su coste | Más rápida |
| Límite de tamaño | ~1.000 niveles | Ninguno práctico |
El factorial iterativo cabe en cuatro líneas —resultado = 1, un for i in range(2, n + 1) que hace resultado *= i, y un return— y es más rápido que el recursivo. La regla práctica en Python es sencilla y conviene tomársela en serio: prefiere iterar, salvo que el problema sea naturalmente recursivo. Y lo es cuando sus datos tienen forma de árbol —carpetas dentro de carpetas, diccionarios dentro de diccionarios, subtareas dentro de tareas— o cuando el algoritmo divide el problema en trozos que se resuelven igual, como el mergesort. Para recorrer, contar o acumular, el bucle gana siempre. Otros lenguajes (Scheme, Haskell, Erlang) optimizan cierto tipo de recursión para que no consuma pila, con una técnica llamada tail call optimization; Python no lo hace, deliberadamente, y esa es una razón más para desconfiar aquí de la recursión profunda.
- El coste oculto de la recursión ingenua
El fibonacci de la sección 5 es correcto y es un desastre. Midámoslo con time.perf_counter(), como en 06-02, envolviendo la llamada entre dos lecturas del reloj y restándolas:
import time
for n in (30, 35, 40):
inicio = time.perf_counter()
print(f"fib({n}) = {fibonacci(n):<8} en {time.perf_counter() - inicio:.3f}s")n |
Llamadas realizadas | Tiempo aproximado |
|---|---|---|
| 30 | ≈ 2.700.000 | ≈ 0,35 s |
| 35 | ≈ 30.000.000 | ≈ 4 s |
| 40 | ≈ 330.000.000 | ≈ 45 s |
Cada cinco unidades de n el tiempo se multiplica por diez, y fib(50) tardaría más de una hora en calcular un número que cabe en una línea. El motivo está en el árbol de la sección 5: el algoritmo recalcula una y otra vez lo que ya había calculado; para fib(35) calcula fib(10) decenas de miles de veces obteniendo siempre lo mismo. Ojo, entonces: el problema no es la recursión, es el trabajo repetido, y la solución no es quitar la recursión sino dejar de repetir.
- Memoización: recordar lo ya calculado
Memoizar es guardar el resultado de cada cálculo en un diccionario para no repetirlo. Es el intercambio tiempo-memoria de 06-01 en su forma más pura: gastamos memoria para no gastar tiempo.
def fibonacci_memo(n, memoria=None):
"""Devuelve el n-esimo numero de Fibonacci sin repetir calculos."""
if memoria is None:
memoria = {} # ver el aviso de 04-02 sobre {} por defecto
if n < 2:
return n
if n in memoria: # ya lo calculamos antes: lo devolvemos
return memoria[n]
memoria[n] = fibonacci_memo(n - 1, memoria) + fibonacci_memo(n - 2, memoria)
return memoria[n]El diccionario es la estructura perfecta para esto por lo que aprendiste en 06-01: consultar n in memoria es prácticamente instantáneo gracias a la tabla hash. Y fíjate en el memoria=None: nunca uses un diccionario o una lista vacíos como valor por defecto de un parámetro, porque Python los crea una sola vez al definir la función y se compartirían entre todas las llamadas. El efecto es espectacular: fibonacci_memo(35) pasa de 4 segundos a menos de una diezmilésima, porque cada fib(k) se calcula una sola vez y el árbol se convierte en una cadena. Python trae esto hecho en un decorador de la biblioteca estándar:
from functools import lru_cache
@lru_cache(maxsize=None)
def fibonacci_rapido(n):
"""Fibonacci con memoizacion automatica."""
return n if n < 2 else fibonacci_rapido(n - 1) + fibonacci_rapido(n - 2)
print(fibonacci_rapido(200)) # instantaneo, y con 42 cifrasEsa línea que empieza por @ es un decorador: una anotación que envuelve la función y le añade comportamiento —aquí, la memoria de resultados— sin tocar su código. No los estudiaremos en este curso; quédate con que @lru_cache es la forma profesional de memoizar, con que fibonacci_rapido.cache_info() informa de sus aciertos, y con que solo funciona si los argumentos son inmutables (números, textos, tuplas), porque los usa como claves de un diccionario.
- Donde la recursión sí gana
Hasta aquí la recursión ha quedado como una versión bonita y más lenta del bucle. Cambia por completo cuando los datos tienen forma de árbol, porque entonces un bucle no basta: harían falta tantos bucles anidados como niveles, y no sabes cuántos hay. Retomemos el diccionario de diccionarios de 05-04, ahora con profundidad desconocida:
estudio = {"equipo": {"Marta": {"rol": "coordinadora", "horas": 38},
"Luis": {"rol": "disenador", "horas": 35}},
"clientes": {"Sole": {"activo": True}, "Vidal": {"activo": False}}}
def mostrar_arbol(dato, nivel=0):
"""Imprime una estructura anidada de cualquier profundidad, con sangrado."""
sangria = " " * nivel
if isinstance(dato, dict): # es un diccionario: bajamos un nivel
for clave, valor in dato.items():
print(f"{sangria}{clave}:")
mostrar_arbol(valor, nivel + 1)
else:
print(f"{sangria}{dato}") # caso base: un valor simpleEl caso base es «esto ya no contiene nada dentro»; el caso recursivo es «para cada cosa que hay dentro, repite». El parámetro nivel no dirige la recursión, solo lleva la cuenta del sangrado: es un patrón habitual, pasar información hacia abajo. Y isinstance(x, dict) es imprescindible porque no sabemos qué nos vamos a encontrar en cada nivel. El mismo esquema recorre carpetas de disco; pathlib (05-05) ya trae el recorrido recursivo con rglob, pero escribirlo enseña la estructura:
from pathlib import Path
def espacio_usado(carpeta):
"""Devuelve el tamano total en bytes de una carpeta y todo lo que contiene."""
total = 0
for ruta in carpeta.iterdir():
if ruta.is_dir():
total += espacio_usado(ruta) # caso recursivo: otra carpeta
else:
total += ruta.stat().st_size # caso base: un fichero
return totalAquí la recursión no es una elección estética: es la única forma razonable de hacerlo, porque nadie sabe cuántos niveles de carpetas hay. Y el caso base no es un if al principio, sino la situación natural en que la carpeta no contiene subcarpetas y el for no llama a nadie.
- Búsqueda binaria recursiva y ordenación por mezcla
Lo prometido en 06-01: la búsqueda binaria es naturalmente recursiva, porque «buscar en este trozo» es el mismo problema que «buscar en la mitad de este trozo».
def binaria_recursiva(valores, buscado, izquierda=0, derecha=None):
"""Devuelve la posicion de buscado en la lista ORDENADA valores, o -1."""
if derecha is None:
derecha = len(valores) - 1
if izquierda > derecha: # caso base 1: trozo vacio
return -1
medio = (izquierda + derecha) // 2
if valores[medio] == buscado: # caso base 2: encontrado
return medio
if valores[medio] < buscado:
return binaria_recursiva(valores, buscado, medio + 1, derecha)
return binaria_recursiva(valores, buscado, izquierda, medio - 1)Compárala con la versión iterativa de 06-01 y verás la traducción exacta: el while se ha convertido en la llamada recursiva y la condición de salida izquierda <= derecha en el caso base izquierda > derecha, invertida. Los parámetros por defecto (04-02) permiten llamarla como binaria_recursiva(codigos, 170) sin pasar los límites. Trazando la búsqueda de 170 en [102, 118, 134, 156, 170, 189, 203, 240]:
| Llamada | izquierda |
derecha |
medio |
Valor | Acción |
|---|---|---|---|---|---|
| 1 | 0 | 7 | 3 | 156 | 156 < 170 → llamar con (4, 7) |
| 2 | 4 | 7 | 5 | 189 | 189 > 170 → llamar con (4, 4) |
| 3 | 4 | 4 | 4 | 170 | return 4, que sube por los tres niveles |
¿Cuál es mejor? La iterativa, en Python, porque hace lo mismo sin consumir pila; la recursiva se lee mejor y es la que verás en los libros. Y en producción, ni una ni otra: bisect.
Ordenación por mezcla (mergesort)
Lo prometido en 06-02, y el ejemplo canónico de divide y vencerás: partir la lista por la mitad, ordenar cada mitad recursivamente y fusionar las dos mitades ya ordenadas.
def mezclar(izq, der):
"""Fusiona dos listas YA ordenadas en una sola lista ordenada."""
resultado = []
i = j = 0
while i < len(izq) and j < len(der):
if izq[i] <= der[j]: # <= mantiene la ESTABILIDAD
resultado.append(izq[i]); i += 1
else:
resultado.append(der[j]); j += 1
return resultado + izq[i:] + der[j:] # lo que sobre de una de las dos
def mergesort(valores):
"""Devuelve una lista nueva ordenada, por division y mezcla."""
if len(valores) <= 1: # caso base: 0 o 1 elemento ya esta ordenado
return list(valores)
medio = len(valores) // 2
return mezclar(mergesort(valores[:medio]), mergesort(valores[medio:]))mezclar es la pieza no recursiva y la más importante: recorre las dos listas en paralelo con dos índices, cogiendo siempre el menor de los dos frentes, en una sola pasada. Ese <= en lugar de < es lo que hace estable al algoritmo: ante un empate coge el de la izquierda, que venía antes. Y la última línea añade lo que quede sin recorrer de una de las dos listas, que por construcción ya está ordenado. Traza de mergesort([3, 5, 2, 4]):
| Fase | Qué ocurre |
|---|---|
| División | [3, 5, 2, 4] → [3, 5] y [2, 4], y estas → [3], [5], [2], [4] |
| Caso base | Los cuatro trozos de un elemento se devuelven tal cual, ya ordenados |
| Mezcla | [3] + [5] → [3, 5]; [2] + [4] → [2, 4] |
| Mezcla final | [3, 5] + [2, 4] → [2, 3, 4, 5] |
La lista se parte por la mitad hasta llegar a trozos de un elemento —que están ordenados por definición— y después se reconstruye fusionando. Con 1.000 elementos hay unos 10 niveles de división y cada nivel cuesta una pasada completa: unas 10.000 operaciones, frente al millón de la burbuja. Esa es la diferencia que veías en la tabla de tiempos de 06-02, y Timsort usa exactamente esta función mezclar para unir los tramos que ordena con inserción.
- TareaFácil v0.14: días de un desglose anidado
Marta ha empezado a desglosar los encargos grandes en subtareas, y estas a su vez en subtareas más pequeñas, sin un número fijo de niveles, y necesita saber cuántos días suma un proyecto entero. Es el caso de la sección 9, y solo se resuelve bien con recursión.
# tareafacil.py - Estudio Alba / Version 0.14: desglose de proyectos
OPCIONES = ("1", "2", "3", "4", "5", "6", "7", "8", "9", "10")
# --- Resto de constantes y funciones: sin cambios respecto a la v0.13 ---
PROYECTO_SOLE = {"nombre": "Identidad Panaderia Sole", "dias": 0, "subtareas": [
{"nombre": "Logotipo", "dias": 0, "subtareas": [
{"nombre": "Bocetos", "dias": 2, "subtareas": []},
{"nombre": "Vectorizado", "dias": 1, "subtareas": []}]},
{"nombre": "Papeleria", "dias": 3, "subtareas": []},
{"nombre": "Rotulo del local", "dias": 4, "subtareas": []}]}
def dias_totales(tarea):
"""Suma los dias de una tarea y de todas sus subtareas, a cualquier profundidad."""
total = tarea["dias"] # el trabajo propio de este nivel
for subtarea in tarea["subtareas"]: # sin subtareas, el bucle no se ejecuta
total += dias_totales(subtarea) # caso recursivo
return total
def mostrar_desglose(tarea, nivel=0):
"""Imprime el arbol de subtareas con sangrado y el total de cada rama."""
print(f"{' ' * nivel}{tarea['nombre']:<30}{dias_totales(tarea):>3}d")
for subtarea in tarea["subtareas"]:
mostrar_desglose(subtarea, nivel + 1)
# En main(): la opcion 9 llama a mostrar_desglose(PROYECTO_SOLE) y la salida pasa a la 10.Salida por pantalla, donde cada rama muestra su propio total: Identidad Panaderia Sole 10d, y dentro Logotipo 3d (con Bocetos 2d y Vectorizado 1d sangradas debajo), Papeleria 3d y Rotulo del local 4d. Tres observaciones sobre este código:
- El caso base no es un
if. Cuandosubtareasestá vacía, elforno se ejecuta ninguna vez y la función devuelve directamentetarea["dias"]. Es la forma más limpia de escribir el caso base al recorrer una colección: la lista vacía lo es por sí misma. - La estructura de datos y el algoritmo encajan. Cada nodo tiene la misma forma —
nombre,dias,subtareas—, sea la raíz o la última hoja, y por eso la misma función sirve para todos los niveles: diseñar los datos con esa uniformidad es lo que hace posible la recursión. Y así ninguna de las dos funciones sabe cuántos niveles hay, que es exactamente lo que se pedía; con bucles anidados habría que fijar de antemano una profundidad máxima. Y ojo:mostrar_desgloserecalcula el total de cada rama, repitiendo trabajo igual que Fibonacci. Con tres niveles es irrelevante; si el desglose creciera, tocaría memoizar.
Errores Comunes y Consejos
Olvidar el caso base o no acercarse a él. Los dos síntomas son el mismo RecursionError. Comprueba que existe un if que devuelve sin llamarse y que el argumento de la llamada recursiva está más cerca de ese caso en cada paso. Y olvidar el return delante de la llamada recursiva. factorial(n - 1) sin return calcula el resultado y lo tira; la función devuelve None y al multiplicar salta TypeError. En una función recursiva que devuelve un valor, casi todas las ramas llevan return.
Usar {} o [] como valor por defecto de un parámetro, típico al memoizar. Python crea ese objeto una sola vez y lo comparte entre todas las llamadas: los resultados de una consulta contaminan la siguiente. Usa None y créalo dentro. Y cuidado con la recursión sobre listas con rebanadas: suma(valores[1:]) copia la lista entera en cada llamada, así que sumar 1.000 números copia medio millón de elementos; para colecciones grandes, pasa índices o usa un bucle.
Consejo: confía en la llamada recursiva. El error de aprendizaje más común es intentar seguir mentalmente todos los niveles. No lo hagas: da por bueno que factorial(n - 1) devuelve el factorial correcto y limítate a comprobar el caso base y el paso. Si ambos están bien, la función está bien. Y si dudas, dibuja el árbol: tres niveles en papel bastan para ver si el problema se reduce y si hay trabajo repetido, como el de Fibonacci. Es la prueba de escritorio de 01-05 aplicada a la recursión.
Ejercicios
Ejercicio 1: Trazar y contar
Traza suma([4, 1, 6]) indicando en una tabla cada llamada, a qué espera y qué devuelve. Después, dibuja el árbol de llamadas de fibonacci(4) y cuenta cuántas veces se calcula fibonacci(2). Por último, explica qué imprime cuenta_atras(2) si intercambias las dos últimas líneas del cuerpo de la función.
Ejercicio 2: Contar hojas y encontrar el máximo
Sobre la estructura de subtareas del apartado 11, escribe dos funciones recursivas: contar_hojas(tarea), que devuelve cuántas subtareas sin subtareas propias hay en toda la rama (las hojas del árbol, es decir, el trabajo real); y tarea_mas_larga(tarea), que devuelve el nombre de la hoja con más días de toda la rama.
Ejercicio 3: Potencia rápida
Escribe potencia(base, exponente) recursiva que calcule base ** exponente sin usar el operador **, con esta propiedad: si el exponente es par, base^n = (base^(n/2))²; si es impar, base^n = base × base^(n-1). Compara cuántas llamadas hace, con exponente = 16, frente a la versión que resta uno al exponente cada vez.
Soluciones
Solución 1. Traza de suma([4, 1, 6]):
| Nivel | Llamada | Espera a… | Recibe | Devuelve |
|---|---|---|---|---|
| 1 | suma([4, 1, 6]) |
suma([1, 6]) |
7 | 4 + 7 = 11 |
| 2 | suma([1, 6]) |
suma([6]) |
6 | 1 + 6 = 7 |
| 3 | suma([6]) |
suma([]) |
0 | 6 + 0 = 6 |
| 4 | suma([]) |
nadie: caso base | — | 0 |
En el árbol de fibonacci(4), fibonacci(2) se calcula dos veces: una colgando de fib(3) y otra directamente de fib(4). Y si en cuenta_atras se intercambian el print(n) y la llamada recursiva, la cuenta sale al revés: primero Ya! y después 1, 2. El motivo es el de la sección 4: lo que va antes de la llamada se ejecuta en la ida y lo que va después, en la vuelta.
Solución 2.
def contar_hojas(tarea):
"""Cuenta las subtareas sin subtareas propias de toda la rama."""
if not tarea["subtareas"]: # caso base: es una hoja
return 1
return sum(contar_hojas(sub) for sub in tarea["subtareas"])
def tarea_mas_larga(tarea):
"""Devuelve el nombre de la hoja con mas dias de toda la rama."""
if not tarea["subtareas"]:
return tarea["nombre"], tarea["dias"] # devolvemos par (nombre, dias)
candidatas = [tarea_mas_larga(sub) for sub in tarea["subtareas"]]
return max(candidatas, key=lambda par: par[1])
print(contar_hojas(PROYECTO_SOLE), tarea_mas_larga(PROYECTO_SOLE)[0]) # 4 Rotulo del localcontar_hojas distingue explícitamente la hoja —que vale 1— de la rama, que vale la suma de sus hijas. tarea_mas_larga usa un truco importante: devuelve una tupla (nombre, dias) en vez de solo el nombre, porque para comparar candidatas en el nivel superior hacen falta los días. Es el «devolver varios valores» de 04-02 al servicio de la recursión: cada nivel devuelve lo que el nivel de arriba necesita para decidir.
Solución 3.
def potencia(base, exponente):
"""Calcula base elevado a exponente dividiendo el exponente por dos."""
if exponente == 0: # caso base
return 1
if exponente % 2 == 0: # exponente par: una sola llamada
mitad = potencia(base, exponente // 2)
return mitad * mitad
return base * potencia(base, exponente - 1) # impar: lo convertimos en parLa clave está en mitad = potencia(base, exponente // 2) guardada en una variable: si escribieras potencia(...) * potencia(...) calcularías dos veces lo mismo y volverías al problema de Fibonacci. Con exponente 16 el reparto es 16 → 8 → 4 → 2 → 1 → 0: cinco llamadas, frente a las diecisiete de la versión que resta uno cada vez. Es la misma idea de dividir entre dos de la búsqueda binaria y del mergesort, aplicada a la aritmética; en Python, por supuesto, se escribe base ** exponente.
Conclusión
Una función recursiva se llama a sí misma con una versión más pequeña del mismo problema, y solo funciona si tiene sus dos piezas: un caso base que devuelve sin llamarse y un caso recursivo que se acerca a él. Cada llamada pendiente vive en la pila de llamadas, que Python limita a unos mil niveles antes de lanzar RecursionError; subir el límite con sys.setrecursionlimit casi nunca es la solución correcta. En la ida se apilan las llamadas y en la vuelta se construye el resultado, como muestran las trazas del factorial, la suma de una lista y la inversión de una cadena. El factorial genera una cadena de llamadas y Fibonacci un árbol, y ahí está la trampa: el Fibonacci ingenuo repite tanto trabajo que fib(40) tarda cuarenta y cinco segundos. La cura no es abandonar la recursión sino dejar de repetir, con memoización —un diccionario de resultados ya calculados, o el decorador @lru_cache—, que es el intercambio tiempo-memoria en estado puro. Frente a la iteración, la recursión pierde en memoria y velocidad y gana en claridad cuando los datos tienen forma de árbol (estructuras anidadas, carpetas de disco, desgloses de subtareas) o cuando el algoritmo divide el problema: la búsqueda binaria recursiva prometida en 06-01 y el mergesort prometido en 06-02, cuya función mezclar recorre dos listas ordenadas en una sola pasada y es la misma que usa Timsort. TareaFácil llega a la v0.14 y calcula los días de un proyecto desglosado en subtareas a cualquier profundidad.
Con esto tienes todas las piezas del módulo, y también un montón de afirmaciones sueltas que hemos ido haciendo sin poder demostrarlas: «veinte comparaciones frente a un millón», «doblar los datos cuadruplica el trabajo», «el árbol se convierte en una cadena», «esto es un intercambio tiempo-memoria». Todas describen lo mismo —cuánto cuesta un programa según el tamaño de sus datos— y todas piden un vocabulario preciso. En Eficiencia y notación Big-O lo tendrás por fin, y con él podrás mirar cualquier función y decir en voz alta cuánto va a costar antes de ejecutarla.
Fundamentos de la Programación
Módulo 1: Introducción a la Programación
- ¿Qué es la programación?
- Historia de la programación
- Lenguajes de programación
- Entornos de desarrollo
- Del problema al algoritmo
Módulo 2: Conceptos Básicos
- Variables y tipos de datos
- Operadores y expresiones
- Entrada y salida de datos
- Conversión de tipos y validación de datos
Módulo 3: Estructuras de Control
Módulo 4: Funciones y Procedimientos
- Definición y uso de funciones
- Parámetros y retorno de valores
- Ámbito de variables
- Descomponer un programa en funciones
- Funciones como valores: lambda y orden superior
Módulo 5: Estructuras de Datos
- Listas y arreglos
- Cadenas de caracteres
- Diccionarios y conjuntos
- Tuplas y estructuras anidadas
- Guardar datos en archivos: texto, CSV y JSON
Módulo 6: Algoritmos Básicos
Módulo 7: Objetos y Organización del Código
- De los datos a los objetos: clases e instancias
- Atributos, métodos y constructor
- Colecciones de objetos
- Módulos, paquetes e importaciones
Módulo 8: Buenas Prácticas y Herramientas
- Documentación y comentarios
- Depuración y manejo de errores
- Control de versiones
- Pruebas automatizadas
- Estilo, legibilidad y refactorización
