Queda una forma de resolver problemas con funciones que todavía no has usado a propósito, y es la más desconcertante de todas: una función que se llama a sí misma. Ya te la has cruzado dos veces —la expresión de función con nombre de 03-02 existía en parte para esto, y el RangeError: Maximum call stack size exceeded de 03-05 apareció por una llamada que nunca paraba—. En esta lección le pondrás método. Y no es un ejercicio académico: Nómada Tareas tiene un problema que los bucles resuelven mal y la recursión resuelve de forma natural, porque Marta quiere que una tarea pueda descomponerse en subtareas, y esas subtareas en otras, sin límite de profundidad.
Contenido
- Qué es una función recursiva
- Caso base y caso recursivo
- La pila de llamadas de una recursión
- Calentamiento 1: factorial
- Calentamiento 2: Fibonacci y su coste oculto
- El caso real: subtareas anidadas
- Recorrer el árbol: sumar horas
- Recorrer el árbol: aplanar, contar y buscar
- Recursión frente a iteración
- Recursión de cola y por qué JavaScript no la optimiza
- Memoización aplicada a Fibonacci
- Cuándo NO usar recursión
- Errores Comunes y Consejos
- Ejercicios
- Conclusión
- Qué es una función recursiva
Una función recursiva es aquella que, dentro de su cuerpo, se llama a sí misma con un problema más pequeño, hasta llegar a un caso tan simple que puede resolverse directamente.
function cuentaAtras(n) {
if (n <= 0) { // caso base: se resuelve sin recursión
console.log('¡Listo!');
return;
}
console.log(`Quedan ${n} tareas por revisar…`);
cuentaAtras(n - 1); // caso recursivo: el mismo problema, más pequeño
}
cuentaAtras(3);
// Quedan 3 tareas por revisar…
// Quedan 2 tareas por revisar…
// Quedan 1 tareas por revisar…
// ¡Listo!La idea que hay detrás es una forma de pensar, no un truco de sintaxis:
Para resolver un problema grande, supón que ya sabes resolver el mismo problema un poco más pequeño, y combina ese resultado con lo que te toca hacer aquí.
Esto se llama salto de fe recursivo, y es la parte que más cuesta. Cuando escribes cuentaAtras(n - 1) no tienes que imaginar todas las llamadas encadenadas: solo tienes que confiar en que esa llamada hace bien su trabajo.
- Caso base y caso recursivo
Toda función recursiva correcta tiene exactamente dos partes:
| Parte | Qué hace | Qué pasa si falta o está mal |
|---|---|---|
| Caso base | Resuelve el problema mínimo sin volver a llamarse | Recursión infinita → RangeError |
| Caso recursivo | Se llama a sí misma con un problema más pequeño | La función nunca progresa o no resuelve nada |
Y dos condiciones que hay que verificar siempre:
- El caso base se alcanza. No basta con que exista: el argumento debe acercarse a él en cada llamada.
- El problema se reduce de verdad. Llamarse con el mismo argumento, o con uno mayor, es un bucle infinito con más pasos.
// ✗ Caso base inalcanzable con decimales
function malaCuenta(n) {
if (n === 0) return 'fin';
return malaCuenta(n - 1);
}
// malaCuenta(3.5) → 2.5, 1.5, 0.5, -0.5, -1.5… nunca es exactamente 0
// ✓ Caso base robusto
function buenaCuenta(n) {
if (n <= 0) return 'fin';
return buenaCuenta(n - 1);
}flowchart TD
A["Llamada con n"] --> B{"¿Caso base?"}
B -->|sí| C["Devolver el resultado directo"]
B -->|no| D["Hacer la parte que toca aquí"]
D --> E["Llamarse con un problema menor"]
E --> F["Combinar el resultado y devolver"]
- La pila de llamadas de una recursión
Aquí se retoma la pila de Hoisting y el Contexto de Ejecución. Cada llamada recursiva apila un contexto nuevo que no se libera hasta que la llamada interior termina.
function sumarHasta(n) {
if (n <= 0) return 0;
return n + sumarHasta(n - 1);
}
console.log(sumarHasta(4)); // 10sequenceDiagram
participant G as global
participant A as sumarHasta(4)
participant B as sumarHasta(3)
participant C as sumarHasta(2)
participant D as sumarHasta(1)
participant E as sumarHasta(0)
G->>A: llamada
A->>B: 4 + ?
B->>C: 3 + ?
C->>D: 2 + ?
D->>E: 1 + ?
E-->>D: 0 (caso base)
D-->>C: 1 + 0 = 1
C-->>B: 2 + 1 = 3
B-->>A: 3 + 3 = 6
A-->>G: 4 + 6 = 10
Dos observaciones esenciales:
- La fase de "bajada" apila llamadas sin calcular nada definitivo: cada una queda esperando el resultado de la siguiente.
- La fase de "subida" es donde se producen los cálculos, de dentro hacia fuera.
Por eso una recursión de profundidad 50 000 revienta la pila, como comprobaste en el ejercicio final de 03-05: las 50 000 sumas están todas pendientes a la vez.
- Calentamiento 1: factorial
El factorial de n es el producto de todos los enteros de 1 a n. Su definición matemática ya es recursiva: n! = n × (n-1)!, con 0! = 1.
function factorial(n) {
if (n < 0) throw new RangeError('El factorial no está definido para negativos.');
if (n <= 1) return 1; // caso base: 0! = 1 y 1! = 1
return n * factorial(n - 1); // caso recursivo
}
console.log(factorial(0)); // 1
console.log(factorial(5)); // 120
console.log(factorial(6)); // 720Traza de factorial(5):
| Llamada | Devuelve | Resultado |
|---|---|---|
factorial(5) |
5 * factorial(4) |
5 * 24 = 120 |
factorial(4) |
4 * factorial(3) |
4 * 6 = 24 |
factorial(3) |
3 * factorial(2) |
3 * 2 = 6 |
factorial(2) |
2 * factorial(1) |
2 * 1 = 2 |
factorial(1) |
1 (caso base) |
1 |
Fíjate en el throw para el caso negativo: es el fallo temprano de Manejo de Errores. Sin él, factorial(-1) bajaría hasta -Infinity apilando contextos y acabaría en RangeError, un error que no explica nada.
- Calentamiento 2: Fibonacci y su coste oculto
La sucesión de Fibonacci empieza 0, 1, 1, 2, 3, 5, 8, 13…, donde cada término es la suma de los dos anteriores.
function fibonacci(n) {
if (n < 0) throw new RangeError('n debe ser 0 o mayor.');
if (n === 0) return 0; // caso base 1
if (n === 1) return 1; // caso base 2
return fibonacci(n - 1) + fibonacci(n - 2); // DOS llamadas recursivas
}
console.log(fibonacci(10)); // 55Elegante y correcta. Y desastrosa en rendimiento, porque cada llamada genera dos, y muchas repiten trabajo ya hecho:
flowchart 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)"]
fib(3) se calcula dos veces y fib(2) tres veces, y con n mayor la duplicación explota. El número de llamadas crece exponencialmente:
let llamadas = 0;
function fibonacciContado(n) {
llamadas++;
if (n <= 1) return n;
return fibonacciContado(n - 1) + fibonacciContado(n - 2);
}
llamadas = 0; fibonacciContado(10); console.log(llamadas); // 177
llamadas = 0; fibonacciContado(20); console.log(llamadas); // 21891
llamadas = 0; fibonacciContado(30); console.log(llamadas); // 2692537
llamadas = 0; fibonacciContado(35); console.log(llamadas); // 29860703De 10 a 35, las llamadas pasan de 177 a casi 30 millones. En el apartado 11 lo arreglarás con memoización, y la mejora será espectacular. Guarda el dato de que fibonacci(35) tarda varios segundos: lo compararás.
- El caso real: subtareas anidadas
Hasta ahora Nómada Tareas ha trabajado con una lista plana. Pero Marta ha pedido algo razonable: que una tarea grande pueda descomponerse en subtareas, y que esas subtareas puedan descomponerse a su vez. Así queda la tarea 1 del backlog desglosada:
flowchart TD
A["1 · Rediseñar la sala polivalente"] --> B["11 · Medir y levantar el plano<br/>3 h"]
A --> C["12 · Elegir el mobiliario"]
A --> D["13 · Pintar y montar<br/>5 h"]
C --> E["121 · Pedir presupuestos<br/>2 h"]
C --> F["122 · Visitar a dos proveedores<br/>2 h"]
Esa estructura es un árbol, y en JavaScript se representa con objetos que contienen arrays de objetos:
'use strict';
// Nota: aquí no hay más remedio que usar objetos anidados. Los objetos se estudian
// a fondo en el Módulo 4; por ahora basta con leer propiedades con el punto.
const tareaRediseno = {
id: 1,
titulo: 'Rediseñar la sala polivalente',
responsable: 'Iván',
estado: 'en-curso',
horasEstimadas: 0, // 0 = las horas están en las subtareas
subtareas: [
{
id: 11, titulo: 'Medir y levantar el plano', responsable: 'Iván',
estado: 'hecha', horasEstimadas: 3, subtareas: []
},
{
id: 12, titulo: 'Elegir el mobiliario', responsable: 'Marta',
estado: 'en-curso', horasEstimadas: 0,
subtareas: [
{ id: 121, titulo: 'Pedir presupuestos', responsable: 'Marta',
estado: 'hecha', horasEstimadas: 2, subtareas: [] },
{ id: 122, titulo: 'Visitar a dos proveedores', responsable: 'Marta',
estado: 'pendiente', horasEstimadas: 2, subtareas: [] }
]
},
{
id: 13, titulo: 'Pintar y montar', responsable: 'Iván',
estado: 'pendiente', horasEstimadas: 5, subtareas: []
}
]
};¿Por qué un bucle no basta aquí? Porque no sabes cuántos niveles hay. Con un for recorres el primer nivel; con dos anidados, el segundo; con tres, el tercero. Pero si mañana Iván añade una sub-sub-subtarea, el código deja de funcionar. La recursión, en cambio, no necesita saberlo: cada nivel se trata exactamente igual que el anterior.
Esta es la señal que delata un problema recursivo: la estructura del dato se contiene a sí misma. Una tarea contiene tareas, una carpeta contiene carpetas, un comentario contiene respuestas que son comentarios.
- Recorrer el árbol: sumar horas
El primer cálculo que necesita Marta: cuántas horas suma una tarea contando todas sus subtareas, a cualquier profundidad.
/**
* Suma las horas de una tarea y de todas sus subtareas, recursivamente.
* @param {Object} tarea nodo con horasEstimadas y subtareas
* @returns {number} total de horas del subárbol
*/
function sumarHorasTotales(tarea) {
let total = tarea.horasEstimadas; // lo que toca en este nodo
for (const subtarea of tarea.subtareas) { // caso recursivo
total += sumarHorasTotales(subtarea);
}
return total; // caso base implícito: sin subtareas, el bucle no entra
}
console.log(sumarHorasTotales(tareaRediseno)); // 12Merece la pena analizar por qué funciona:
- El caso base está implícito. Si
subtareases[], elforno da ninguna vuelta y la función devuelve solotarea.horasEstimadas. No hace falta unifexplícito, aunque escribirlo tampoco estaría mal. - Cada nodo hace lo mismo: aporta sus horas y pide a sus hijos que aporten las suyas.
- El resultado, 12 h, coincide con las horas que la tarea 1 tenía en el backlog plano del Módulo 2 (3 + 2 + 2 + 5 = 12). La descomposición no ha cambiado el total.
Y una variante que filtra: horas abiertas (no terminadas) del subárbol.
function sumarHorasAbiertas(tarea) {
let total = tarea.estado !== 'hecha' ? tarea.horasEstimadas : 0;
for (const subtarea of tarea.subtareas) {
total += sumarHorasAbiertas(subtarea);
}
return total;
}
console.log(sumarHorasAbiertas(tareaRediseno)); // 7Comprobación manual: quedan abiertas «Visitar a dos proveedores» (2 h) y «Pintar y montar» (5 h). Total, 7 h. Las de «Medir y levantar el plano» (3 h) y «Pedir presupuestos» (2 h) están hechas.
- Recorrer el árbol: aplanar, contar y buscar
Con el mismo esquema se resuelven todas las operaciones sobre el árbol. Cambia solo qué se hace en cada nodo.
8.1 Aplanar el árbol a una lista
/**
* Devuelve un array plano con todas las tareas del subárbol,
* añadiendo a cada una su nivel de profundidad.
*/
function aplanarTareas(tarea, nivel = 0) {
const resultado = [{ id: tarea.id, titulo: tarea.titulo, nivel: nivel,
horas: tarea.horasEstimadas, estado: tarea.estado }];
for (const subtarea of tarea.subtareas) {
const hijas = aplanarTareas(subtarea, nivel + 1);
for (const h of hijas) resultado.push(h);
}
return resultado;
}
const planas = aplanarTareas(tareaRediseno);
for (const t of planas) {
const sangria = ' '.repeat(t.nivel);
const marca = t.estado === 'hecha' ? '✓' : '○';
console.log(`${sangria}${marca} [${t.id}] ${t.titulo}${t.horas > 0 ? ` — ${t.horas} h` : ''}`);
}Salida:
○ [1] Rediseñar la sala polivalente
✓ [11] Medir y levantar el plano — 3 h
○ [12] Elegir el mobiliario
✓ [121] Pedir presupuestos — 2 h
○ [122] Visitar a dos proveedores — 2 h
○ [13] Pintar y montar — 5 hEl parámetro nivel = 0 con valor por defecto (03-03) es un patrón habitual en recursión: quien llama desde fuera lo omite, y la propia función lo va incrementando hacia dentro.
8.2 Contar tareas y medir la profundidad
function contarTareas(tarea) {
let total = 1; // ella misma
for (const sub of tarea.subtareas) total += contarTareas(sub);
return total;
}
function profundidadMaxima(tarea) {
if (tarea.subtareas.length === 0) return 1; // caso base explícito
let maxima = 0;
for (const sub of tarea.subtareas) {
const p = profundidadMaxima(sub);
if (p > maxima) maxima = p;
}
return 1 + maxima;
}
console.log(contarTareas(tareaRediseno)); // 6
console.log(profundidadMaxima(tareaRediseno)); // 38.3 Buscar una tarea por identificador
/**
* Busca una tarea por id en todo el subárbol.
* @returns {Object|null} la tarea encontrada, o null
*/
function buscarPorId(tarea, id) {
if (tarea.id === id) return tarea; // caso base 1: encontrada
for (const sub of tarea.subtareas) {
const encontrada = buscarPorId(sub, id);
if (encontrada !== null) return encontrada; // corta en cuanto la halla
}
return null; // caso base 2: no está en esta rama
}
const t = buscarPorId(tareaRediseno, 122);
console.log(t.titulo); // Visitar a dos proveedores
console.log(buscarPorId(tareaRediseno, 999)); // nullEl if (encontrada !== null) return encontrada; es importante: sin él, la función seguiría explorando ramas inútiles después de haber encontrado el resultado. Es el equivalente recursivo del break de break, continue y Bucles Anidados.
- Recursión frente a iteración
Todo lo que se puede hacer con recursión se puede hacer con bucles, y al revés. La pregunta es cuál conviene en cada caso.
| Criterio | Recursión | Iteración (bucles) |
|---|---|---|
| Legibilidad con datos anidados | Muy alta: el código imita la estructura | Baja: hay que gestionar una pila a mano |
| Legibilidad con datos planos | Peor: añade ruido | Alta |
| Memoria | Una entrada de pila por llamada | Constante |
| Riesgo de desbordamiento | Real a partir de ~10 000 niveles | Ninguno |
| Velocidad | Algo menor (coste de cada llamada) | Algo mayor |
| Depuración | Más difícil: la pila se llena de marcos iguales | Sencilla |
| Estado | Implícito, en los parámetros | Explícito, en variables |
Compara las dos versiones de la misma tarea, aplanar el árbol. La recursiva ya la has visto. La iterativa necesita gestionar su propia pila:
function aplanarIterativo(raiz) {
const resultado = [];
const pendientes = [{ tarea: raiz, nivel: 0 }]; // pila explícita
while (pendientes.length > 0) {
const actual = pendientes.pop();
resultado.push({ id: actual.tarea.id, titulo: actual.tarea.titulo, nivel: actual.nivel });
// Se apilan en orden inverso para que salgan en el orden natural
for (let i = actual.tarea.subtareas.length - 1; i >= 0; i--) {
pendientes.push({ tarea: actual.tarea.subtareas[i], nivel: actual.nivel + 1 });
}
}
return resultado;
}
console.log(aplanarIterativo(tareaRediseno).length); // 6Funciona, no puede desbordar la pila del motor y es más rápida. Pero fíjate en lo que ha costado: una pila manual, un bucle inverso poco evidente y un objeto auxiliar para llevar el nivel. Esa es la elección real: claridad frente a control.
Regla práctica para el día a día:
- Estructura anidada de profundidad moderada (árboles de menús, comentarios, subtareas, JSON) → recursión.
- Lista plana, o profundidad potencialmente enorme (miles de niveles, ficheros grandes) → iteración.
- Recursión de cola y por qué JavaScript no la optimiza
Se dice que una recursión es de cola (tail recursion) cuando la llamada recursiva es lo último que hace la función: su resultado se devuelve tal cual, sin operaciones pendientes.
// NO es de cola: al volver, todavía hay que multiplicar por n
function factorial(n) {
if (n <= 1) return 1;
return n * factorial(n - 1); // ← queda pendiente la multiplicación
}
// SÍ es de cola: la llamada se devuelve directamente
function factorialCola(n, acumulado = 1) {
if (n <= 1) return acumulado;
return factorialCola(n - 1, n * acumulado); // ← nada pendiente
}
console.log(factorialCola(5)); // 120En teoría, una recursión de cola no necesita apilar contextos: como no queda nada por hacer al volver, el motor podría reutilizar el marco actual. Eso se llama tail call optimization (TCO) y convertiría la recursión en un bucle, con memoria constante.
El problema es que, en la práctica, JavaScript no la aplica:
| Motor / entorno | ¿Optimiza llamadas de cola? |
|---|---|
| Especificación ES2015 | Sí, la exige |
| V8 (Chrome, Edge, Node.js) | No (implementada y retirada) |
| SpiderMonkey (Firefox) | No |
| JavaScriptCore (Safari) | Sí, en modo estricto |
Compruébalo:
function contarCola(n) {
if (n <= 0) return 'fin';
return contarCola(n - 1);
}
try {
console.log(contarCola(100000));
} catch (error) {
console.error(`${error.name}: ${error.message}`);
}
// En Node.js: RangeError: Maximum call stack size exceededConclusión práctica: escribir recursión de cola en JavaScript no te protege del desbordamiento. Es una buena costumbre estilística y funciona en otros lenguajes, pero aquí, si la profundidad puede ser grande, la solución es un bucle.
- Memoización aplicada a Fibonacci
Volvemos al Fibonacci exponencial del apartado 5. El problema era recalcular lo mismo una y otra vez, y ya tienes la herramienta para arreglarlo: la memoización con closure de Ámbito y Closures.
/**
* Devuelve una versión memoizada de Fibonacci.
* La caché vive en el closure: privada y persistente entre llamadas.
*/
function crearFibonacci() {
const cache = {}; // estado privado
let calculos = 0;
let aciertos = 0;
function fib(n) {
if (n < 0) throw new RangeError('n debe ser 0 o mayor.');
if (n <= 1) return n;
if (n in cache) {
aciertos++;
return cache[n];
}
calculos++;
const resultado = fib(n - 1) + fib(n - 2);
cache[n] = resultado;
return resultado;
}
fib.estadisticas = () => `${calculos} cálculos, ${aciertos} aciertos`;
return fib;
}
const fibonacciRapido = crearFibonacci();
let t = Date.now();
console.log(fibonacciRapido(35), `${Date.now() - t} ms`); // 9227465 ~0 ms
console.log(fibonacciRapido.estadisticas()); // 34 cálculos, 33 aciertos
t = Date.now();
console.log(fibonacciRapido(40), `${Date.now() - t} ms`); // 102334155 ~0 ms
console.log(fibonacciRapido(90)); // 2880067194370816000La comparación es demoledora:
n |
Llamadas sin memoizar | Cálculos con memoización | Tiempo aproximado |
|---|---|---|---|
| 10 | 177 | 9 | ~0 ms en ambos |
| 20 | 21 891 | 19 | ~0 ms en ambos |
| 30 | 2 692 537 | 29 | ~30 ms → ~0 ms |
| 35 | 29 860 703 | 34 | ~300 ms → ~0 ms |
| 40 | ~331 000 000 | 39 | varios segundos → ~0 ms |
| 50 | inviable | 49 | — → ~0 ms |
Se pasa de crecimiento exponencial a crecimiento lineal. El coste es la memoria de la caché, que aquí es despreciable, y la restricción de siempre: memoizar solo funciones puras (03-03). fib(n) siempre da lo mismo para el mismo n, así que es seguro.
Dos avisos sobre el resultado de fibonacciRapido(90): ese número supera Number.MAX_SAFE_INTEGER y por tanto es aproximado. Para enteros grandes hay que usar BigInt, el tipo que conociste en Variables y Tipos de Datos. Y la profundidad sigue siendo lineal: fibonacciRapido(20000) desbordaría la pila aunque la caché funcione.
- Cuándo NO usar recursión
Hay tres situaciones donde la recursión es la respuesta equivocada:
| Situación | Por qué | Alternativa |
|---|---|---|
| Recorrer una lista plana | Un bucle es más claro, rápido y seguro | for / for...of |
| Profundidad grande o desconocida sin límite | RangeError en producción, con datos reales |
Iteración con pila explícita |
| Cálculo simple con acumulador | La recursión añade ruido sin aportar nada | Bucle con variable acumuladora |
// ✗ Recursión innecesaria sobre una lista plana
function sumarHorasRec(horas, i = 0) {
if (i >= horas.length) return 0;
return horas[i] + sumarHorasRec(horas, i + 1);
}
// ✓ Bucle: más claro, sin riesgo de pila
function sumarHoras(horas) {
let total = 0;
for (const h of horas) total += h;
return total;
}
console.log(sumarHoras([12, 6, 14, 3, 8, 5])); // 48Y una advertencia de seguridad que importa más de lo que parece: si los datos anidados vienen de fuera (una API, un fichero que sube el usuario), la profundidad la controla quien envía los datos. Un árbol malicioso de 100 000 niveles tumbaría tu función recursiva. En esos casos, o iteras, o pones un límite explícito:
function sumarHorasTotales(tarea, profundidad = 0) {
if (profundidad > 50) {
throw new RangeError('Estructura de subtareas demasiado profunda (máximo 50 niveles).');
}
let total = tarea.horasEstimadas;
for (const sub of tarea.subtareas) {
total += sumarHorasTotales(sub, profundidad + 1);
}
return total;
}Errores Comunes y Consejos
1. Olvidar el caso base. Es el error número uno y siempre produce RangeError: Maximum call stack size exceeded.
2. Caso base inalcanzable. Usa <= en lugar de === cuando el argumento pueda saltárselo (decimales, restas de más de uno).
3. No reducir el problema.
4. Olvidar el return delante de la llamada recursiva.
function buscarMal(tarea, id) {
if (tarea.id === id) return tarea;
for (const sub of tarea.subtareas) {
buscarMal(sub, id); // ✗ el resultado se pierde
}
return null; // siempre null
}Es un fallo silencioso: no da error, simplemente nunca encuentra nada.
5. Confundir un for dentro de una recursión con un bucle anidado. El for recorre los hijos de este nodo; la recursión baja de nivel. Son ejes distintos.
6. Memoizar una función impura. Ya lo sabes desde 03-04: la caché devolvería resultados obsoletos.
7. Confiar en la recursión de cola. No está optimizada en la mayoría de motores.
8. Consejo: dibuja el árbol de llamadas para tres o cuatro niveles. Casi todos los errores de recursión se ven a simple vista en el dibujo y son invisibles leyendo el código.
9. Consejo: pon un console.log con sangría al depurar.
function sumarConTraza(tarea, nivel = 0) {
const sangria = ' '.repeat(nivel);
console.log(`${sangria}→ entra en ${tarea.titulo}`);
let total = tarea.horasEstimadas;
for (const sub of tarea.subtareas) total += sumarConTraza(sub, nivel + 1);
console.log(`${sangria}← sale de ${tarea.titulo} con ${total} h`);
return total;
}Ver la bajada y la subida indentadas hace comprensible cualquier recursión. En Depuración de JavaScript verás cómo hacer lo mismo con puntos de interrupción y la pila en vivo.
Ejercicios
Ejercicio 1 — Operaciones sobre el árbol de subtareas
Sobre tareaRediseno, escribe tres funciones recursivas:
contarPorEstado(tarea, estado): cuántas tareas del subárbol están en ese estado.titulosDeResponsable(tarea, nombre): array con los títulos de las tareas asignadas a esa persona, a cualquier profundidad.estaCompleta(tarea):truesi la tarea y todas sus subtareas están en estado'hecha'.
Ejercicio 2 — Ruta hasta una tarea
Escribe rutaHasta(tarea, id) que devuelva un array con los títulos que van desde la raíz hasta la tarea con ese identificador, o null si no existe. Por ejemplo, para el id 121 debe devolver ['Rediseñar la sala polivalente', 'Elegir el mobiliario', 'Pedir presupuestos'].
Ejercicio 3 — De recursivo a iterativo
La función profundidadMaxima del apartado 8.2 es recursiva. Reescríbela de forma iterativa usando una pila explícita, comprueba que da el mismo resultado (3) y explica en qué caso concreto preferirías cada versión.
Soluciones
Ejercicio 1
function contarPorEstado(tarea, estado) {
let total = tarea.estado === estado ? 1 : 0;
for (const sub of tarea.subtareas) {
total += contarPorEstado(sub, estado);
}
return total;
}
function titulosDeResponsable(tarea, nombre) {
const encontrados = [];
if (tarea.responsable === nombre) encontrados.push(tarea.titulo);
for (const sub of tarea.subtareas) {
const hijos = titulosDeResponsable(sub, nombre);
for (const h of hijos) encontrados.push(h);
}
return encontrados;
}
function estaCompleta(tarea) {
if (tarea.estado !== 'hecha') return false; // guarda: ella misma no lo está
for (const sub of tarea.subtareas) {
if (!estaCompleta(sub)) return false; // corta en la primera que falle
}
return true;
}
console.log(contarPorEstado(tareaRediseno, 'hecha')); // 2
console.log(contarPorEstado(tareaRediseno, 'pendiente')); // 2
console.log(contarPorEstado(tareaRediseno, 'en-curso')); // 2
console.log(titulosDeResponsable(tareaRediseno, 'Marta'));
// [ 'Elegir el mobiliario', 'Pedir presupuestos', 'Visitar a dos proveedores' ]
console.log(estaCompleta(tareaRediseno)); // false
console.log(estaCompleta(buscarPorId(tareaRediseno, 11))); // trueComentario: las tres siguen el mismo esquema —hacer algo con este nodo, después con cada hijo— y solo cambia la operación. estaCompleta incorpora además la salida temprana: en cuanto encuentra una subtarea incompleta deja de mirar el resto, igual que miEvery en Funciones de Orden Superior.
Ejercicio 2
function rutaHasta(tarea, id) {
if (tarea.id === id) return [tarea.titulo]; // caso base: es esta
for (const sub of tarea.subtareas) {
const rutaHija = rutaHasta(sub, id); // salto de fe
if (rutaHija !== null) {
return [tarea.titulo, ...rutaHija]; // añade este nivel delante
}
}
return null; // no está en esta rama
}
console.log(rutaHasta(tareaRediseno, 121));
// [ 'Rediseñar la sala polivalente', 'Elegir el mobiliario', 'Pedir presupuestos' ]
console.log(rutaHasta(tareaRediseno, 13));
// [ 'Rediseñar la sala polivalente', 'Pintar y montar' ]
console.log(rutaHasta(tareaRediseno, 999)); // null
// Y para mostrarla como una miga de pan:
const ruta = rutaHasta(tareaRediseno, 122);
console.log(ruta.join(' › '));
// Rediseñar la sala polivalente › Elegir el mobiliario › Visitar a dos proveedoresComentario: la clave es la línea return [tarea.titulo, ...rutaHija];. El ... es el operador spread, que despliega los elementos de rutaHija dentro del array nuevo; lo estudiarás formalmente en Desestructuración de Objetos, Spread y Rest. Sin él, tendrías que construir el array con un bucle. Fíjate también en que la ruta se monta en la subida: cada nivel añade su título por delante de lo que le devuelve el nivel inferior.
Ejercicio 3
function profundidadMaximaIterativa(raiz) {
let maxima = 0;
const pendientes = [{ tarea: raiz, nivel: 1 }];
while (pendientes.length > 0) {
const actual = pendientes.pop();
if (actual.nivel > maxima) maxima = actual.nivel;
for (const sub of actual.tarea.subtareas) {
pendientes.push({ tarea: sub, nivel: actual.nivel + 1 });
}
}
return maxima;
}
console.log(profundidadMaxima(tareaRediseno)); // 3
console.log(profundidadMaximaIterativa(tareaRediseno)); // 3Cuándo preferir cada una:
| Versión | Cuándo |
|---|---|
| Recursiva | Árboles de subtareas creados por el equipo de Taller Nómada: profundidad de dos o tres niveles, y el código se lee como la definición del problema |
| Iterativa | Datos importados de una API externa, donde la profundidad no está bajo tu control y un árbol muy profundo tumbaría la aplicación |
Observa además una diferencia sutil: la recursiva calcula la profundidad en la subida (1 + máximo de los hijos), mientras que la iterativa la lleva en la bajada, guardando el nivel junto a cada nodo pendiente. Es el mismo cálculo visto del revés.
Conclusión
Con esta lección cierras el Módulo 3. Ya sabes que una función recursiva se llama a sí misma con un problema más pequeño, que necesita siempre un caso base alcanzable y un caso recursivo que reduzca de verdad el problema, y que cada llamada apila un contexto que solo se libera en la subida: por eso una recursión profunda produce el RangeError que conociste en 03-05. Has practicado con factorial y fibonacci, y has visto de primera mano cómo el Fibonacci ingenuo pasa de 177 llamadas con n = 10 a casi treinta millones con n = 35, y cómo la memoización con closure de 03-04 lo devuelve a treinta y cuatro cálculos.
Sobre todo, has visto por qué la recursión existe. Cuando Marta pidió descomponer una tarea en subtareas, y esas en otras, los bucles dejaron de servir: no se puede escribir un for anidado para una profundidad que no conoces. sumarHorasTotales, aplanarTareas, contarTareas, profundidadMaxima, buscarPorId y rutaHasta resuelven ese árbol con el mismo esquema de seis líneas, porque la estructura del código imita la estructura del dato. Y conoces sus límites: la tabla de recursión frente a iteración, la recursión de cola que JavaScript no optimiza, el límite de profundidad para datos que vienen de fuera y las tres situaciones en las que un bucle es sencillamente la respuesta correcta.
Mira lo que has ganado en estas siete lecciones. Empezaste con bloques de código copiados cuatro veces y terminas con pesoDePrioridad(), estaVencida(), describirTarea(), crearTarea(), resumirCarga(), crearGeneradorDeIds(), crearAlmacenDeTareas(), un motor de informes configurable y un recorrido recursivo de subtareas. Sabes definir y llamar funciones, escribirlas como valores y como flechas, diseñar sus parámetros y sus retornos, controlar dónde vive cada variable, encerrar estado en un closure, entender qué prepara el motor antes de ejecutar, pasar funciones a otras funciones y hacer que una función se llame a sí misma. Eso es, con diferencia, el salto más grande del curso hasta ahora.
Pero fíjate en el precio que has ido pagando sin quejarte. Todo este módulo ha trabajado con variables sueltas y arrays paralelos: titulos[i], responsables[i], prioridades[i], estados[i], horas[i], fechasLimite[i]. Has tenido que filtrar índices en vez de tareas, pasar ocho argumentos a describirTarea, arrastrar seis arrays a cada función y confiar en que ninguno se desalinee. Y en cuanto ha aparecido una estructura de verdad —el árbol de subtareas— no ha habido más remedio que usar objetos anidados, avisando de que eso venía después. Ese "después" es ahora. En el Módulo 4: Objetos y Arrays dejarás de simular el modelo de datos y lo escribirás de verdad: cada tarea será un objeto con sus nueve campos, el backlog será un array de objetos, y todas las funciones de este módulo se reescribirán con firmas limpias como describirTarea(tarea, hoy). Empieza con Introducción a los Objetos, y el primer alivio llegará en la primera página.
Curso de JavaScript: De Principiante a Avanzado
Módulo 1: Introducción a JavaScript
- ¿Qué es JavaScript?
- Configuración de tu Entorno de Desarrollo
- Tu Primer Programa en JavaScript
- Sintaxis y Conceptos Básicos de JavaScript
- Variables y Tipos de Datos
- Operadores Básicos
- Conversión de Tipos y Comparaciones
- El Proyecto del Curso: Nómada Tareas
Módulo 2: Estructuras de Control
- Sentencias Condicionales
- Bucles: for, while, do-while
- Sentencias Switch
- Control del Flujo: break, continue y Bucles Anidados
- Manejo de Errores con try-catch
Módulo 3: Funciones
- Definición y Llamada de Funciones
- Expresiones de Función y Funciones Flecha
- Parámetros y Valores de Retorno
- Ámbito y Closures
- Hoisting y el Contexto de Ejecución
- Funciones de Orden Superior
- Recursividad
Módulo 4: Objetos y Arrays
- Introducción a los Objetos
- Métodos de Objeto y la Palabra Clave
this - Arrays: Conceptos Básicos y Métodos
- Iteración sobre Arrays
- Buscar, Ordenar y Agregar Datos: find, sort y reduce
- Desestructuración de Arrays
- Desestructuración de Objetos, Spread y Rest
- JSON y Copias de Objetos
Módulo 5: Objetos y Funciones Avanzadas
- Prototipos y Herencia
- Clases y Programación Orientada a Objetos
- Encapsulación: Getters, Setters y Campos Privados
- Módulos e Importación/Exportación
- JavaScript Asíncrono: Callbacks
- Promesas y Async/Await
- El Bucle de Eventos y la Cola de Microtareas
- Iteradores y Generadores
Módulo 6: El Modelo de Objetos del Documento (DOM)
- Introducción al DOM
- Selección y Manipulación de Elementos del DOM
- Manejo de Eventos
- Propagación, Delegación y Eventos Personalizados
- Creación y Eliminación de Elementos del DOM
- Renderizado de Listas y Plantillas HTML
- Manejo y Validación de Formularios
Módulo 7: APIs del Navegador y Temas Avanzados
- Almacenamiento Local y de Sesión
- Fetch API y AJAX
- Peticiones Robustas: Errores, Timeouts y AbortController
- WebSockets
- Service Workers y Aplicaciones Web Progresivas (PWAs)
- APIs del Navegador Esenciales
- Introducción a WebAssembly
Módulo 8: Pruebas y Depuración
- Depuración de JavaScript
- Calidad de Código: ESLint, Prettier y Convenciones
- Pruebas Unitarias con Jest
- Dobles de Prueba: Mocks, Stubs y Spies
- Pruebas de Integración
- Pruebas de Extremo a Extremo con Cypress
Módulo 9: Rendimiento y Optimización
- Medir Antes de Optimizar: DevTools y Web Vitals
- Optimización del Rendimiento de JavaScript
- Gestión de Memoria
- Manipulación Eficiente del DOM
- Carga Perezosa y División de Código
Módulo 10: Frameworks y Librerías de JavaScript
- Por Qué Existen los Frameworks
- Introducción a React
- Gestión de Estado con Redux
- Conceptos Básicos de Vue.js
- Conceptos Básicos de Angular
- Elegir el Framework Adecuado
