Apuntes DAM
Volver al inicio

Recursividad

AlgoritmosTécnicas de diseñoNivel intermedioTambién: recursión, funciones recursivas

Una función que se llama a sí misma con un problema más pequeño hasta llegar a un caso que se resuelve directamente. La base de divide y vencerás, del backtracking y de los recorridos de árboles.

Visualízalo paso a paso

Cambia los datos, dale a reproducir y sigue cada paso en el dibujo, en la línea de Java que se ejecuta y en sus variables.

Recursividad: el árbol de llamadas

Elige n y mira cada llamada de fib(n): cómo baja hasta los casos base, cómo vuelven los resultados y cuántas veces se calcula lo mismo.

  • llamada en curso

Paso 1

fib(5) no es un caso base: necesita fib(4) y fib(3). Se queda esperando y llama primero a fib(4).

1static long fib(int n) {
2    if (n <= 1) return n;
3    return fib(n - 1) + fib(n - 2);  // n = 5, profundidad = 0, llamadas = 1
4}

Variables

n
5
profundidad
0
llamadas
1
repetidas
0

Atajos con el foco dentro del visualizador: ← → paso a paso, Espacio reproducir o pausar, Inicio/Fin ir al principio o al final.

La idea

Una función recursiva es una función que, para resolver un problema, se llama a sí misma con una versión más pequeña del mismo problema. El factorial es el ejemplo de siempre: 5! = 5 · 4!, y 4! = 4 · 3!, y así hasta 1! = 1, que se sabe sin calcular nada.

Toda recursividad bien hecha tiene dos partes. El caso base, que se resuelve directamente y para la recursividad (sin él, la función se llamaría para siempre). Y el caso recursivo, que reduce el problema y confía en que la llamada devolverá la respuesta correcta para el problema pequeño. El truco mental es ese «salto de fe»: no hace falta seguir todas las llamadas, basta con que el caso base sea correcto y que cada paso acerque a él.

Por dentro, cada llamada ocupa un marco en la pila de llamadas con sus parámetros y variables locales. Las llamadas se apilan mientras bajan hacia el caso base y se desapilan al volver, combinando los resultados. Por eso una recursividad muy profunda (decenas de miles de niveles en Java) desborda la pila: StackOverflowError.

No es más rápida que un bucle (suele ser algo más lenta y gasta más memoria), pero hay problemas que son recursivos por naturaleza: recorrer un árbol o una estructura anidada, dividir un problema en mitades, probar todas las combinaciones. Ahí el código recursivo es mucho más corto y claro. El peligro es la recursividad que repite cálculos, como el Fibonacci ingenuo, que se arregla guardando lo calculado (memoización).

Cuándo usarlo

  • Estructuras recursivas: árboles, carpetas con subcarpetas, JSON o HTML anidados, expresiones con paréntesis.
  • Problemas que se definen de forma recursiva (factorial, potencia, Fibonacci, combinaciones) o que se parten en subproblemas iguales (divide y vencerás).
  • Explorar todas las posibilidades: backtracking, permutaciones, laberintos.

Cuándo no

  • Si un bucle sencillo lo hace igual de claro (sumar un array, contar hasta n): la recursividad solo añade llamadas y riesgo de desbordar la pila.
  • Si la profundidad puede ser enorme (una lista enlazada de un millón de nodos).
  • Si repite subproblemas y no se memoiza: el coste se dispara (exponencial).

Paso a paso

  1. Caso base. El caso más pequeño, que se resuelve sin llamarse: n <= 1, una cadena vacía, un nodo null. Siempre se comprueba primero.
  2. Reducir el problema. Expresar la solución usando la misma función con un problema más pequeño: n − 1, la mitad, el resto de la cadena, un subárbol.
  3. Confiar en la llamada. Suponer que la llamada recursiva devuelve la respuesta correcta para el problema pequeño, y combinarla para obtener la del grande.
  4. Comprobar que termina. Cada llamada tiene que acercarse al caso base; si alguna rama no lo hace, la recursividad no termina.

El código

Cuatro funciones recursivas

Todas siguen el mismo esquema: un caso base y un paso que reduce el problema.

Java
1public class Main {
2    static long factorial(int n) {
3        if (n <= 1) return 1;                      // caso base: se resuelve sin llamarse
4        return n * factorial(n - 1);               // caso recursivo: un problema más pequeño
5    }
6
7    static int sumaDigitos(int n) {
8        if (n < 10) return n;                      // un solo dígito
9        return n % 10 + sumaDigitos(n / 10);       // el último + la suma de los demás
10    }
11
12    static long potencia(long base, int exp) {
13        if (exp == 0) return 1;
14        return base * potencia(base, exp - 1);
15    }
16
17    static String invertir(String s) {
18        if (s.length() <= 1) return s;
19        return invertir(s.substring(1)) + s.charAt(0);   // el resto invertido + el primero al final
20    }
21
22    public static void main(String[] args) {
23        System.out.println("5! = " + factorial(5));
24        System.out.println("Suma de los dígitos de 4096 = " + sumaDigitos(4096));
25        System.out.println("2^10 = " + potencia(2, 10));
26        System.out.println("«recursividad» al revés: " + invertir("recursividad"));
27    }
28}

Salida al ejecutarlo (la misma en los 5 lenguajes)

5! = 120
Suma de los dígitos de 4096 = 19
2^10 = 1024
«recursividad» al revés: dadivisrucer

Fibonacci: ingenuo frente a memoización

La misma recursividad, pero guardando cada resultado: de cientos de miles de llamadas a unas pocas decenas.

Java
1import java.util.HashMap;
2import java.util.Map;
3
4public class Main {
5    static long llamadas = 0;
6
7    static long fib(int n) {                       // ingenuo: recalcula lo mismo una y otra vez
8        llamadas++;
9        if (n <= 1) return n;
10        return fib(n - 1) + fib(n - 2);
11    }
12
13    static final Map<Integer, Long> memo = new HashMap<>();
14
15    static long fibMemo(int n) {                   // con memoización: cada fib(k) se calcula una vez
16        llamadas++;
17        if (n <= 1) return n;
18        Long guardado = memo.get(n);
19        if (guardado != null) return guardado;
20        long r = fibMemo(n - 1) + fibMemo(n - 2);
21        memo.put(n, r);
22        return r;
23    }
24
25    public static void main(String[] args) {
26        llamadas = 0;
27        long a = fib(25);
28        System.out.println("fib(25) = " + a + " con " + llamadas + " llamadas");
29        llamadas = 0;
30        long b = fibMemo(25);
31        System.out.println("fibMemo(25) = " + b + " con " + llamadas + " llamadas");
32    }
33}

Salida al ejecutarlo (la misma en los 5 lenguajes)

fib(25) = 75025 con 242785 llamadas
fibMemo(25) = 75025 con 49 llamadas

Traza: factorial(4): las llamadas bajan y los resultados suben

LlamadaQué haceDevuelve
factorial(4)4 · factorial(3)espera
factorial(3)3 · factorial(2)espera
factorial(2)2 · factorial(1)espera
factorial(1)caso base1
factorial(2)2 · 12
factorial(3)3 · 26
factorial(4)4 · 624

Las cuatro llamadas están a la vez en la pila de llamadas hasta que factorial(1) devuelve; después se resuelven en orden inverso.

Complejidad

FunciónLlamadasTiempoMemoria (pila)
factorial(n)nO(n)O(n)
potencia rápida(x, n)log nO(log n)O(log n)
búsqueda binaria recursivalog nO(log n)O(log n)
fib(n) ingenuo≈ 1,6ⁿO(2ⁿ)O(n)
fib(n) con memoización2n − 1O(n)O(n)

La memoria de una recursividad es su profundidad máxima (los marcos que llegan a estar en la pila a la vez), no el número total de llamadas.

0102030405015101520tamaño de la entrada (n)operacionesO(n!)O(2ⁿ)O(n²)O(n log n)O(1)O(log n)O(n)
  • Mejor caso: O(2ⁿ)
  • Caso medio: O(2ⁿ)
  • Peor caso: O(2ⁿ)

El fib recursivo ingenuo hace del orden de 2ⁿ llamadas (en realidad φⁿ ≈ 1,6ⁿ). La recursividad en sí no es lenta: lo es repetir subproblemas. Las curvas grises son las demás clases, para comparar.

En la práctica

  • Recorrer carpetas y subcarpetas (Files.walk), árboles DOM o JSON y estructuras de menús.
  • Los analizadores de los compiladores y de los lenguajes de consulta se escriben como funciones recursivas, una por cada regla de la gramática.
  • Mergesort, quicksort y la búsqueda en árboles son recursivos.
  • En programación funcional (y en Kotlin, Scala o Haskell) la recursividad sustituye a los bucles.

Errores típicos

  • Olvidar el caso base o ponerlo después de la llamada recursiva: recursividad infinita y StackOverflowError.
  • Que el paso recursivo no se acerque al caso base (llamar con n en vez de n − 1, o con n − 2 cuando el caso base solo es n == 0 y n es impar).
  • Ignorar lo que devuelve la llamada (factorial(n − 1); sin usar el resultado).
  • Recursividad que repite subproblemas sin memoizar: el Fibonacci ingenuo con n = 50 hace más de 20.000 millones de llamadas.
  • Usar recursividad para recorrer algo lineal muy largo: un bucle no tiene límite de profundidad.

Ejercicios

Cada ejercicio se corrige solo con sus pruebas (algunas ocultas). Escribe tu solución en el editor y pulsa Ejecutar o Comprobar; la solución explicada está debajo, por si te atascas.

1. Recursividad sin bucles

Completa cuatro funciones recursivas, sin usar ningún bucle: la suma de las cifras de un número, invertir un texto, comprobar si un texto es palíndromo y contar cuántas veces aparece un carácter. El main ya lee las órdenes y escribe los resultados.

  • Órdenes: digitos 4096, invertir hola, palindromo reconocer, cuenta a banana.
  • Salidas: Suma de las cifras de 4096: 19, hola al revés: aloh, reconocer es palíndromo (o no es palíndromo), «a» aparece 3 veces en banana.
  • Otra orden: Orden no válida: «…».
☕JavaRecursividad sin buclesFácil

Ejemplo

Entrada (lo que se escribe por teclado)
digitos 4096
invertir hola
palindromo reconocer
cuenta a banana
Salida esperada
Suma de las cifras de 4096: 19
hola al revés: aloh
reconocer es palíndromo
«a» aparece 3 veces en banana
⏳
Test oculto #3
⏳
Test oculto #4
0/4 tests pasados · pulsa un test para ver su entrada y su salida esperada
Ver la solución explicada
java
1import java.util.*;
2
3public class Main {
4    /** Suma de las cifras de n (n >= 0). */
5    static int sumaDigitos(long n) {
6        if (n < 10) return (int) n;
7        return (int) (n % 10) + sumaDigitos(n / 10);
8    }
9
10    /** El texto al revés. */
11    static String invertir(String s) {
12        if (s.length() <= 1) return s;
13        return invertir(s.substring(1)) + s.charAt(0);
14    }
15
16    /** ¿Se lee igual al derecho que al revés? Compara los extremos y sigue con lo de dentro. */
17    static boolean palindromo(String s) {
18        if (s.length() <= 1) return true;
19        if (s.charAt(0) != s.charAt(s.length() - 1)) return false;
20        return palindromo(s.substring(1, s.length() - 1));
21    }
22
23    /** Cuántas veces aparece el carácter c en s. */
24    static int contar(char c, String s) {
25        if (s.isEmpty()) return 0;
26        return (s.charAt(0) == c ? 1 : 0) + contar(c, s.substring(1));
27    }
28
29    public static void main(String[] args) {
30        Scanner sc = new Scanner(System.in);
31        while (sc.hasNextLine()) {
32            String linea = sc.nextLine().trim();
33            if (linea.isEmpty()) continue;
34            String[] p = linea.split("\\s+");
35            if (p.length == 2 && p[0].equals("digitos") && p[1].matches("\\d{1,18}"))
36                System.out.println("Suma de las cifras de " + p[1] + ": " + sumaDigitos(Long.parseLong(p[1])));
37            else if (p.length == 2 && p[0].equals("invertir"))
38                System.out.println(p[1] + " al revés: " + invertir(p[1]));
39            else if (p.length == 2 && p[0].equals("palindromo"))
40                System.out.println(p[1] + (palindromo(p[1]) ? " es palíndromo" : " no es palíndromo"));
41            else if (p.length == 3 && p[0].equals("cuenta") && p[1].length() == 1)
42                System.out.println("«" + p[1] + "» aparece " + contar(p[1].charAt(0), p[2]) + " veces en " + p[2]);
43            else System.out.println("Orden no válida: «" + linea + "»");
44        }
45    }
46}

Las cuatro siguen la misma plantilla: el caso más pequeño se responde directamente y el resto se reduce a la misma pregunta sobre un problema una posición más pequeño.

Con textos, substring crea una cadena nueva en cada llamada: está bien para aprender, pero con textos largos es más eficiente pasar índices (palindromo(s, i, j)).

2. Listas anidadas

Cada línea es una lista que puede contener números y otras listas, como [1, [2, 3], [[4]], 5]. Escríbela aplanada, la suma de todos sus números y su profundidad máxima (la lista de fuera es la profundidad 1). Hay que leerla con una función recursiva que procesa una lista y se llama a sí misma por cada lista que encuentra dentro. La lectura de números y de caracteres ya está escrita: completa leerLista.

  • Entrada: una lista por línea; los espacios se ignoran.
  • Salida: [1, 2, 3, 4, 5] · suma 15 · profundidad 3; la lista vacía [] da [] · suma 0 · profundidad 1.
  • Si está mal escrita (falta un corchete, sobra una coma, hay algo que no es un número…): Expresión no válida: «…».
☕JavaListas anidadasDifícil

Ejemplo

Entrada (lo que se escribe por teclado)
[1, [2, 3], [[4]], 5]
Salida esperada
[1, 2, 3, 4, 5] · suma 15 · profundidad 3
⏳
Test oculto #3
⏳
Test oculto #4
⏳
Test oculto #5
0/5 tests pasados · pulsa un test para ver su entrada y su salida esperada
Ver la solución explicada
java
1import java.util.*;
2
3public class Main {
4    static String s;                                // la expresión, sin espacios
5    static int pos;                                 // por dónde va la lectura
6    static final List<Integer> plana = new ArrayList<>();
7    static int profundidadMaxima = 0;
8
9    /** Lee un entero (con signo) desde pos. */
10    static void leerNumero() {
11        int ini = pos;
12        if (pos < s.length() && s.charAt(pos) == '-') pos++;
13        while (pos < s.length() && Character.isDigit(s.charAt(pos))) pos++;
14        if (pos == ini || s.substring(ini, pos).equals("-")) throw new IllegalArgumentException();
15        plana.add(Integer.parseInt(s.substring(ini, pos)));
16    }
17
18    /** ¿El carácter de pos es c? Si lo es, lo salta. */
19    static boolean saltar(char c) {
20        if (pos < s.length() && s.charAt(pos) == c) {
21            pos++;
22            return true;
23        }
24        return false;
25    }
26
27    /** Lee una lista que empieza en pos ('[') y está a la profundidad prof: sus elementos son números o
28        listas, separados por comas. Añade los números a plana y apunta la profundidad máxima.
29        Si la expresión está mal escrita, lanza IllegalArgumentException. */
30    static void leerLista(int prof) {
31        if (!saltar('[')) throw new IllegalArgumentException();
32        profundidadMaxima = Math.max(profundidadMaxima, prof);
33        if (saltar(']')) return;                         // lista vacía
34        do {
35            if (pos < s.length() && s.charAt(pos) == '[') leerLista(prof + 1);
36            else leerNumero();
37        } while (saltar(','));
38        if (!saltar(']')) throw new IllegalArgumentException();
39    }
40
41    public static void main(String[] args) {
42        Scanner sc = new Scanner(System.in);
43        while (sc.hasNextLine()) {
44            String linea = sc.nextLine();
45            if (linea.isBlank()) continue;
46            s = linea.replace(" ", "");
47            pos = 0;
48            plana.clear();
49            profundidadMaxima = 0;
50            try {
51                leerLista(1);
52                if (pos != s.length()) throw new IllegalArgumentException();
53                int suma = 0;
54                for (int x : plana) suma += x;
55                System.out.println(plana + " · suma " + suma + " · profundidad " + profundidadMaxima);
56            } catch (IllegalArgumentException e) {
57                System.out.println("Expresión no válida: «" + linea.trim() + "»");
58            }
59        }
60    }
61}

La estructura del código copia la de los datos: una lista contiene elementos y un elemento puede ser una lista. Por eso la función se llama a sí misma exactamente donde aparece una lista dentro de otra. Es un analizador de descenso recursivo, como los que usan los compiladores o los lectores de JSON.

La profundidad no hay que calcularla aparte: cada llamada sabe a qué nivel está porque se lo pasa quien la llama.

Test

Test: Recursividad

0/5 respondidas · 0 aciertos

Elige una respuesta en cada pregunta: verás al momento si es correcta y por qué. Con un 80 % de aciertos se da por superada.

  1. 1.¿Qué pasa si una función recursiva no tiene caso base?

  2. 2.¿Cuánto devuelve f(4) si f(n) = n + f(n − 1) y f(0) = 0?

  3. 3.¿Cuánta memoria de pila usa factorial(n) recursivo?

  4. 4.¿Por qué el Fibonacci recursivo ingenuo es tan lento?

  5. 5.¿Qué técnica arregla una recursividad que repite subproblemas?

Relacionado