Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

Introducción a la Recursividad

Universidad Nacional de Río Negro

Introducción

La recursividad es una técnica de programación fundamental en la que una función se llama a sí misma para resolver un problema. Este enfoque se basa en la idea de descomponer un problema complejo en subproblemas más pequeños y de la misma naturaleza.


Desarrollo

Definición Matemática

Desde una perspectiva matemática, una definición recursiva tiene dos partes esenciales:

  1. Caso Base: Es una o más condiciones terminales que no requieren de una nueva llamada a la función para ser resueltas. Es la solución explícita para el caso más simple del problema.

  2. Paso Recursivo (o Relación de Recurrencia): Es la regla que reduce el problema a una versión más simple de sí mismo. Define cómo se resuelve el problema para un caso n en términos de uno o más casos “menores” (por ejemplo, n-1).

Un ejemplo clásico es la función factorial, n!n!, que se define de la siguiente manera:

n!={1si n=0 (Caso Base)n×(n1)!si n>0 (Paso Recursivo)n! = \begin{cases} 1 & \text{si } n = 0 \text{ (Caso Base)} \\ n \times (n-1)! & \text{si } n > 0 \text{ (Paso Recursivo)} \end{cases}

Esta definición establece que el factorial de 0 es 1 (caso base) y que el factorial de cualquier otro número natural n es n multiplicado por el factorial de n-1.

Construcción de Algoritmos Recursivos en C

Para implementar un algoritmo recursivo en C, debés seguir la estructura de la definición matemática.

Componentes Clave

Un algoritmo recursivo siempre debe tener:

Ejemplo: Función Factorial en C

Veamos cómo se traduce la definición matemática del factorial a una función en C.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#include <stdio.h>

// Declaración de la función factorial
long int factorial(int n);

int main(void) {
    int numero = 5;
    long int resultado = factorial(numero);
    if (resultado == -1) {
        printf("Error: no se puede calcular el factorial de un número negativo.\n");
    } else {
        printf("El factorial de %d es %ld\n", numero, resultado);
    }
    return 0;
}

// Definición de la función recursiva
long int factorial(int n) {
    // Validación de precondición (robustez ante valores inválidos, regla {ref}`0x2001h`)
    if (n < 0) {
        return -1;
    }
    // Caso Base: si n es 0, el factorial es 1.
    if (n == 0) {
        return 1;
    } 
    // Paso Recursivo: n * factorial(n-1)
    return n * factorial(n - 1);
}
Análisis del Código
  1. Caso Base: La línea if (n == 0) comprueba la condición de parada. Si n es 0, la función retorna 1 y la cadena de llamadas recursivas comienza a resolverse.

  2. Paso Recursivo: En la última sentencia, la función retorna el resultado de n multiplicado por el valor devuelto por la llamada a factorial(n - 1). Esta llamada opera sobre un subproblema de menor tamaño (n-1), garantizando la convergencia hacia el caso base.

A continuación se muestra de forma gráfica el estado del Call Stack durante el cálculo recursivo de factorial(3) hasta alcanzar el caso base, y cómo se desapilan los marcos de pila para resolver la multiplicación:

Evolución del Call Stack en la ejecución recursiva de factorial(3). Los marcos
se apilan secuencialmente hasta el caso base y se desapilan propagando el
resultado.

Figure 1:Evolución del Call Stack en la ejecución recursiva de factorial(3). Los marcos se apilan secuencialmente hasta el caso base y se desapilan propagando el resultado.

El Peligro de la Recursividad: Stack Overflow y la Paradoja del Factorial

El tamaño total disponible para la pila de llamadas (call stack) es finito (típicamente entre 1 y 8 megabytes en sistemas Unix/Linux). Si el consumo de pila excede dicho límite físico, se produce un desbordamiento catastrófico de pila o stack overflow, lo cual interrumpe inmediatamente el programa con un fallo de segmentación.

Las causas principales de este fallo son:

  1. Ausencia o fallo en el Caso Base (Recursión Infinita): Si la condición de parada no se cumple o los parámetros no convergen al caso base.

  2. Recursión Demasiado Profunda: Aún si el algoritmo es lógicamente correcto, si la profundidad de llamadas es excesiva, la pila se agotará.

La Paradoja del Factorial: Límites del Tipo de Dato vs. Límites de la Pila


Ejercicios de Autoevaluación

Definición Matemática

Solution to Exercise 1

La sucesión de Fibonacci F(n)F(n) para un entero n0n \geq 0 se define matemáticamente como:

F(n)={0si n=0 (Caso Base 1)1si n=1 (Caso Base 2)F(n1)+F(n2)si n>1 (Paso Recursivo)F(n) = \begin{cases} 0 & \text{si } n = 0 \text{ (Caso Base 1)} \\ 1 & \text{si } n = 1 \text{ (Caso Base 2)} \\ F(n-1) + F(n-2) & \text{si } n > 1 \text{ (Paso Recursivo)} \end{cases}

Esta definición es recursiva porque el valor de la función para un caso n>1n > 1 se expresa en términos de la misma función evaluada en instancias menores (n1n-1 y n2n-2). Los dos casos base garantizan que el desglose de llamadas finalice y retorne valores conocidos y definidos de manera directa.

Solution to Exercise 2

La multiplicación a×ba \times b se puede definir de manera recursiva acumulando sumas del primer término aa y decrementando el segundo término bb:

a×b={0si b=0 (Caso Base)a+a×(b1)si b>0 (Paso Recursivo)a \times b = \begin{cases} 0 & \text{si } b = 0 \text{ (Caso Base)} \\ a + a \times (b-1) & \text{si } b > 0 \text{ (Paso Recursivo)} \end{cases}

En cada paso recursivo se acumula aa y se reduce el multiplicador bb en una unidad, convergiendo linealmente hacia el caso base (b=0b = 0).

Solution to Exercise 3

La cantidad de dígitos D(n)D(n) de un entero n>0n > 0 se define de forma recursiva como:

D(n)={1si n<10 (Caso Base)1+D(n/10)si n10 (Paso Recursivo)D(n) = \begin{cases} 1 & \text{si } n < 10 \text{ (Caso Base)} \\ 1 + D(\lfloor n / 10 \rfloor) & \text{si } n \geq 10 \text{ (Paso Recursivo)} \end{cases}

Cada paso recursivo realiza la división entera por 10 (eliminando el último dígito del número) y suma 1 al contador acumulado, repitiendo el proceso hasta que el número sea menor que 10, donde se alcanza el caso base.

Construcción de Algoritmos en C

Solution to Exercise 4

Versión Recursiva:

Aplica la regla de robustez Regla 0x2001h: Las funciones deben usar cláusulas de guarda y retornos anticipados para evitar la anidación profunda para validar precondiciones y el uso de llaves:

1
2
3
4
5
6
7
8
9
10
11
double potencia_recursiva(double a, int b) {
    if (b < 0) {
        return -1.0; 
    }
    // Caso Base
    if (b == 0) {
        return 1.0;
    }
    // Paso Recursivo
    return a * potencia_recursiva(a, b - 1);
}

Versión Iterativa (con lazo for):

1
2
3
4
5
6
7
8
9
10
double potencia_iterativa(double a, int b) {
    if (b < 0) {
        return -1.0;
    }
    double resultado = 1.0;
    for (int i = 0; i < b; i++) {
        resultado *= a;
    }
    return resultado;
}
Solution to Exercise 5

Siguiendo las buenas prácticas, la cadena de entrada se declara como constante (const char *) según la regla Regla 0x3007h: Los argumentos de tipo puntero deben ser const siempre que la función no los modifique y se retorna el tipo size_t de acuerdo a la regla Regla 0x3010h: Las variables que representan tamaños o índices de arreglos deben ser de tipo size_t:

1
2
3
4
5
6
7
8
9
#include <stddef.h>

size_t contar_caracter(const char *cadena, char c) {
    if (*cadena == '\0') {
        return 0;
    }
    size_t coincide = (*cadena == c) ? 1 : 0;
    return coincide + contar_caracter(cadena + 1, c);
}
Solution to Exercise 6

Para cumplir con las directivas de tipo, los índices se manejan con size_t (regla Regla 0x3010h: Las variables que representan tamaños o índices de arreglos deben ser de tipo size_t):

1
2
3
4
5
6
7
8
9
10
11
#include <stddef.h>

void invertir_arreglo(int arr[], size_t ini, size_t fin) {
    if (ini >= fin) {
        return;
    }
    int aux = arr[ini];
    arr[ini] = arr[fin];
    arr[fin] = aux;
    invertir_arreglo(arr, ini + 1, fin - 1);
}

Diagnóstico y Estabilidad

Solution to Exercise 7

El error reside en que la condición de parada if (n == 0) solo se alcanza si el argumento inicial n es un entero no negativo. Si la función se invoca con un valor negativo (por ejemplo, sumar_hasta_cero(-1)), la llamada recursiva realiza n - 1, decrementando el valor hacia -\infty (-2, -3, -4, etc.). Como nunca se cumple la condición n == 0, la función continúa apilando marcos de pila en el Call Stack de forma infinita hasta agotar el límite físico de memoria del stack, provocando un stack overflow.

Para resolver esta vulnerabilidad de parada, de acuerdo a la regla de robustez Regla 0x2001h: Las funciones deben usar cláusulas de guarda y retornos anticipados para evitar la anidación profunda, la guarda del caso base debe generalizarse para cubrir todos los números menores o iguales a cero:

1
2
3
4
5
6
int sumar_hasta_cero_robusta(int n) {
    if (n <= 0) {
        return 0;
    }
    return n + sumar_hasta_cero_robusta(n - 1);
}
Solution to Exercise 8

Para estimar el tamaño mínimo de un marco de pila en la arquitectura x86_64 bajo el estándar de llamada System V AMD64 ABI, se analiza cada componente del registro del marco:

  1. Dirección de Retorno: Requiere 8 bytes para almacenar el puntero de instrucción del llamador.

  2. Puntero de Marco Anterior (Saved Frame Pointer): Almacena el registro rbp previo, consumiendo 8 bytes.

  3. Variables Locales: El arreglo long int variables_locales[4] ocupa 4×8 bytes=32 bytes4 \times 8 \text{ bytes} = 32 \text{ bytes}.

  4. Parámetros de Entrada: El argumento int n (4 bytes) inicialmente se transfiere vía registro (edi). Sin embargo, el compilador debe respaldarlo en la pila para preservar su valor a lo largo de las activaciones sucesivas. Por cuestiones de alineación en arquitectura de 64 bits, este campo consume 8 bytes.

Sumando los componentes:

Taman˜o del marco=8 B (retorno)+8 B (frame pointer anterior)+32 B (variables locales)+8 B (paraˊmetro alineado)=56 bytes\text{Tamaño del marco} = 8\text{ B (retorno)} + 8\text{ B (frame pointer anterior)} + 32\text{ B (variables locales)} + 8\text{ B (parámetro alineado)} = 56 \text{ bytes}

Bajo la convención x86_64 ABI, la pila debe estar alineada a límites de 16 bytes. Por lo tanto, el compilador redondea el tamaño de este marco a un múltiplo de 16, resultando en un tamaño real de 64 bytes por marco.

Para calcular el límite teórico de llamadas:

  • El límite de la pila del sistema operativo es de 8 MB=8.388.608 bytes8 \text{ MB} = 8.388.608 \text{ bytes}.

  • Número máximo de marcos permitidos:

    Profundidad Maˊxima=8.388.608 bytes64 bytes/marco=131.072 llamadas\text{Profundidad Máxima} = \frac{8.388.608 \text{ bytes}}{64 \text{ bytes/marco}} = 131.072 \text{ llamadas}
Solution to Exercise 9

Un entero unsigned long long en C (64 bits) tiene un valor máximo representable de 26411.84×10192^{64} - 1 \approx 1.84 \times 10^{19}.

  1. Desbordamiento aritmético: El número de Fibonacci F(93)=12.200.160.415.121.876.738F(93) = 12.200.160.415.121.876.738 entra dentro del límite. Sin embargo, F(94)=19.740.274.219.868.223.167>2641F(94) = 19.740.274.219.868.223.167 > 2^{64}-1, por lo cual el desbordamiento aritmético ocurre en n=94n = 94.

  2. Desbordamiento de pila (Stack Overflow): La profundidad máxima de la pila para la versión de Fibonacci con recursión doble F(n)=F(n1)+F(n2)F(n) = F(n-1) + F(n-2) es lineal respecto de nn (la profundidad máxima es nn). Para n=94n = 94, la pila albergará como máximo 94 marcos activos simultáneamente en la rama más profunda, consumiendo menos de 6 KB6 \text{ KB} de memoria, lo cual es despreciable.

Por lo tanto, la implementación de Fibonacci fallará primero debido a un desbordamiento aritmético en n=94n = 94 mucho antes de aproximarse a un stack overflow.


Glosario

Recursión
Técnica de programación y diseño algorítmico donde una función se define e invoca en términos de sí misma.
Caso base
Condición lógica terminal en un algoritmo recursivo que detiene la recursión y retorna un resultado de forma directa sin realizar nuevas llamadas.
Paso recursivo
Sentencia lógica en una función recursiva donde se realiza una nueva invocación a la propia función sobre un subproblema de menor tamaño.
Stack Overflow
Desbordamiento físico de la pila de llamadas del sistema provocado por el consumo excesivo de memoria física asignada al stack.
Desbordamiento aritmético
Situación física en la cual el resultado numérico de una operación excede los límites representables por el tipo de dato físico de la variable.

Síntesis y Resumen

En este capítulo analizaste los principios de la recursión y su comportamiento en memoria:


Referencias y Lecturas Complementarias