Guía Práctica de Recursividad y Algoritmos: Lógica, Casos Base y Complejidad

La recursividad es una técnica fundamental para resolver problemas que pueden dividirse en instancias más pequeñas del mismo problema. Una función recursiva se invoca a sí misma modificando sus parámetros hasta alcanzar una condición que permite detener el proceso.

Aunque conceptualmente parece sencilla, la recursividad tiene implicaciones importantes sobre la memoria, el rendimiento y la estabilidad del programa. Cada llamada genera un nuevo registro de activación en la pila de ejecución (call stack), por lo que una función mal diseñada puede provocar un desbordamiento de pila (stack overflow) incluso cuando su lógica aparentemente sea correcta.

Este mecanismo aparece constantemente en software real: recorridos de árboles y grafos, algoritmos de búsqueda, procesamiento de estructuras jerárquicas, análisis sintáctico, divide and conquer, sistemas de archivos y numerosos algoritmos de inteligencia artificial.


1. Introducción técnica y contexto

Un algoritmo recursivo resulta especialmente útil cuando la estructura del problema es naturalmente jerárquica o cuando una solución puede expresarse en términos de una versión más pequeña del mismo problema.

Por ejemplo:

  • Recorrer todos los directorios de una carpeta.
  • Recorrer un árbol binario.
  • Calcular factoriales.
  • Implementar algoritmos de búsqueda.
  • Realizar backtracking.
  • Dividir un problema mediante divide and conquer.
  • Procesar estructuras de datos anidadas.

La ventaja principal es que la implementación puede reflejar directamente la definición matemática o estructural del problema. El inconveniente es que cada llamada pendiente consume recursos del proceso. Por esta razón, no basta con comprobar que una función “funciona”: también es necesario analizar cuántas llamadas genera, cuánto espacio utiliza y si existe una alternativa iterativa o una estrategia de optimización.

2. Anatomía de una función recursiva

Una función recursiva correctamente diseñada debe tener dos componentes fundamentales:

  1. Caso base: Condición que detiene la recursión.
  2. Paso recursivo: Llamada a la propia función utilizando una entrada que acerca el problema al caso base.

La estructura conceptual es la siguiente:

Plaintext

función resolver(problema):
    si problema es suficientemente simple:
        devolver solución
    reducir problema
    devolver resolver(problema reducido)

El punto crítico es garantizar que cada llamada avance hacia el caso base. Una función como:

int contar(int n) {
    return contar(n - 1);
}

No contiene una condición de terminación, por lo que continuará generando llamadas hasta agotar la capacidad de la pila.


3. Requisitos previos

Para ejecutar los ejemplos de este artículo se necesita un entorno compatible:

  • Compilador: Compatible con C++17 o superior (GCC, Clang o Microsoft Visual C++).
  • Editor o IDE: Visual Studio Code, CLion, Visual Studio o equivalente.

Por ejemplo, utilizando GCC desde la terminal:

Bash

g++ -std=c++17 -Wall -Wextra -O2 recursion.cpp -o recursion
  • -std=c++17: Habilita el estándar C++17.
  • -Wall -Wextra: Activa advertencias del compilador.
  • -O2: Habilita optimizaciones de rendimiento.

4. Primer ejemplo: Factorial recursivo

El factorial de un número entero positivo se define como $n! = n \times (n – 1)!$ con $0! = 1$. Esta definición se traduce directamente en código:

#include <iostream>

// Calcula el factorial de n mediante recursividad.
unsigned long long calcularFactorial(int n) {
    // Caso base: 0! y 1! son iguales a 1.
    if (n <= 1) {
        return 1;
    }

    // Paso recursivo: El problema se reduce de n a n - 1.
    return static_cast<unsigned long long>(n) * calcularFactorial(n - 1);
}

int main() {
    int numero = 10;
    std::cout << "Factorial de " << numero << " = " << calcularFactorial(numero) << '\n';
    return 0;
}

¿Qué ocurre internamente?

Si ejecutamos calcularFactorial(4);, las llamadas se apilan de esta forma:

Plaintext

factorial(4)
 └── factorial(3)
      └── factorial(2)
           └── factorial(1)
                └── retorna 1

A partir de ahí, las llamadas comienzan a resolverse en sentido inverso hasta devolver el resultado final (24).

5. El Call Stack y los registros de activación

Cada vez que una función es llamada, el programa almacena información en un registro de activación (stack frame), que incluye parámetros, variables locales y dirección de retorno.

Para calcularFactorial(4);, la pila se organiza conceptualmente así:

Plaintext

┌─────────────────────┐
│ factorial(1)        │
├─────────────────────┤
│ factorial(2)        │
├─────────────────────┤
│ factorial(3)        │
├─────────────────────┤
│ factorial(4)        │
├─────────────────────┤
│ main()              │
└─────────────────────┘

Cuando la recursión es demasiado profunda, el espacio de la pila se agota, provocando un Stack Overflow.

6. Caso base: El componente más importante

El caso base debe ser alcanzable y obligatorio para finalizar la recursión:

int sumarHasta(int n) {
    if (n <= 0) {
        return 0; // Caso base
    }
    return n + sumarHasta(n - 1); // Paso recursivo
}

7. Error crítico: Recursividad que no progresa

Modificar incorrectamente el parámetro de entrada alejándolo del caso base genera bucles infinitos en el stack:

int procesar(int n) {
    if (n == 0) return 0;
    return procesar(n + 1); // ¡Error! Se aleja de 0 si n > 0.
}

8. Fibonacci: Un ejemplo de recursividad costosa

La secuencia de Fibonacci implementada de forma directa genera un árbol de llamadas exponencial:

unsigned long long calcularFibonacci(int n) {
    if (n <= 0) return 0;
    if (n == 1) return 1;

    // Divide el problema en dos subproblemas superpuestos
    return calcularFibonacci(n - 1) + calcularFibonacci(n - 2);
}

9. Complejidad temporal

La implementación clásica de Fibonacci tiene un crecimiento exponencial debido al recálculo constante de nodos:

  • Tiempo: $O(2^n)$ (aproximadamente $O(\phi^n)$ donde $\phi \approx 1.618$).

10. Complejidad espacial

Aunque la cantidad total de llamadas en Fibonacci es exponencial, la profundidad máxima simultánea de la pila en un instante dado es lineal:

  • Espacio del stack: $O(n)$

11. Fibonacci con memoización

Para evitar recalcular valores, almacenamos los resultados previos en un vector auxiliar (memoización):

#include <iostream>
#include <vector>

unsigned long long fibonacciMemo(int n, std::vector<unsigned long long>& memo) {
    if (n <= 0) return 0;
    if (n == 1) return 1;

    // Si ya fue calculado, lo reutilizamos
    if (memo[n] != 0) {
        return memo[n];
    }

    memo[n] = fibonacciMemo(n - 1, memo) + fibonacciMemo(n - 2, memo);
    return memo[n];
}

int main() {
    int numero = 40;
    std::vector<unsigned long long> memo(numero + 1, 0);
    std::cout << "Fibonacci(" << numero << ") = " << fibonacciMemo(numero, memo) << '\n';
    return 0;
}
  • Tiempo: $O(n)$
  • Espacio: $O(n)$

12. Solución iterativa

Podemos eliminar por completo la recursividad utilizando un enfoque iterativo:

unsigned long long fibonacciIterativo(int n) {
    if (n <= 0) return 0;
    if (n == 1) return 1;

    unsigned long long anterior = 0;
    unsigned long long actual = 1;

    for (int i = 2; i <= n; ++i) {
        unsigned long long siguiente = anterior + actual;
        anterior = actual;
        actual = siguiente;
    }
    return actual;
}
  • Tiempo: $O(n)$
  • Espacio: $O(1)$

13. Recursividad y Divide and Conquer

Algoritmos como la búsqueda binaria utilizan este paradigma para descartar mitades del conjunto de datos en cada paso:

int binarySearch(const std::vector<int>& datos, int izquierda, int derecha, int objetivo) {
    if (izquierda > derecha) return -1;

    int medio = izquierda + (derecha - izquierda) / 2;

    if (datos[medio] == objetivo) return medio;
    if (objetivo < datos[medio]) {
        return binarySearch(datos, izquierda, medio - 1, objetivo);
    }
    return binarySearch(datos, medio + 1, derecha, objetivo);
}
  • Tiempo: $O(\log n)$

14. Recursividad aplicada a estructuras reales

El recorrido de estructuras naturalmente jerárquicas, como los árboles binarios, encuentra en la recursividad su aliado más natural:

struct Nodo {
    int valor;
    Nodo* izquierda;
    Nodo* derecha;
};

void recorrerArbol(Nodo* nodo) {
    if (nodo == nullptr) return; // Caso base

    std::cout << nodo->valor << '\n';
    recorrerArbol(nodo->izquierda);
    recorrerArbol(nodo->derecha);
}

15. Errores comunes y Troubleshooting

15.1 Stack Overflow

  • Causas: Ausencia de caso base, caso base inalcanzable o recursión indirecta excesiva.
  • Diagnóstico: Agregar impresiones de depuración (std::cout) para monitorear la reducción del parámetro en cada iteración.

15.2 Recursión indirecta

Ocurre cuando la función A llama a la B, y la B llama a la A, generando un ciclo oculto que también puede desbordar la pila.

16. Resumen de complejidades

AlgoritmoTiempo aproximadoEspacio adicional
Factorial recursivo$O(n)$$O(n)$
Fibonacci recursivo clásico$O(2^n)$$O(n)$
Fibonacci con memoización$O(n)$$O(n)$
Fibonacci iterativo$O(n)$$O(1)$
Búsqueda binaria recursiva$O(\log n)$$O(\log n)$
Recorrido de árbol$O(n)$$O(h)$ (donde h es la altura del árbol)

Conclusión

La recursividad es una herramienta potente que utiliza la pila de ejecución para mantener estados pendientes mientras resuelve subproblemas. Su elección frente a la iteración debe basarse siempre en la claridad estructural, los límites de memoria del sistema y los requisitos estrictos de rendimiento.

Dejá un comentario