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:
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.
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
nen términos de uno o más casos “menores” (por ejemplo,n-1).
Un ejemplo clásico es la función factorial, , que se define de la siguiente manera:
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:
Caso Base: Una estructura condicional (normalmente un
if) que verifica si se ha alcanzado la condición de parada. Sin un caso base, la función se llamaría a sí misma infinitamente, resultando en un error de desbordamiento de pila (stack overflow).Paso Recursivo: La parte del código donde la función se invoca a sí misma, pero con argumentos que la acercan progresivamente al caso base. Es crucial que cada llamada recursiva opere sobre un subproblema más pequeño.
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¶
Caso Base: La línea
if (n == 0)comprueba la condición de parada. Sines 0, la función retorna1y la cadena de llamadas recursivas comienza a resolverse.Paso Recursivo: En la última sentencia, la función retorna el resultado de
nmultiplicado por el valor devuelto por la llamada afactorial(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:
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:
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.
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 para un entero se define matemáticamente como:
Esta definición es recursiva porque el valor de la función para un caso se expresa en términos de la misma función evaluada en instancias menores ( y ). 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 se puede definir de manera recursiva acumulando sumas del primer término y decrementando el segundo término :
En cada paso recursivo se acumula y se reduce el multiplicador en una unidad, convergiendo linealmente hacia el caso base ().
Solution to Exercise 3
La cantidad de dígitos de un entero se define de forma recursiva como:
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 11double 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 10double 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 (-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 6int 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:
Dirección de Retorno: Requiere 8 bytes para almacenar el puntero de instrucción del llamador.
Puntero de Marco Anterior (Saved Frame Pointer): Almacena el registro
rbpprevio, consumiendo 8 bytes.Variables Locales: El arreglo
long int variables_locales[4]ocupa .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:
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 .
Número máximo de marcos permitidos:
Solution to Exercise 9
Un entero unsigned long long en C (64 bits) tiene un valor máximo
representable de .
Desbordamiento aritmético: El número de Fibonacci entra dentro del límite. Sin embargo, , por lo cual el desbordamiento aritmético ocurre en .
Desbordamiento de pila (Stack Overflow): La profundidad máxima de la pila para la versión de Fibonacci con recursión doble es lineal respecto de (la profundidad máxima es ). Para , la pila albergará como máximo 94 marcos activos simultáneamente en la rama más profunda, consumiendo menos de de memoria, lo cual es despreciable.
Por lo tanto, la implementación de Fibonacci fallará primero debido a un desbordamiento aritmético en 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:
Estructuración recursiva: Todo algoritmo recursivo requiere al menos de un caso base (para detener la ejecución) y un paso recursivo que reduzca el problema de forma convergente.
Funcionamiento en hardware: Cada invocación apila un marco de pila (stack frame) con su propia copia de parámetros y variables locales automáticas en el Call Stack.
Riesgos y límites: La recursión mal controlada o excesivamente profunda provoca fallas de segmentación (stack overflow). Sin embargo, en ciertos algoritmos el desbordamiento numérico del tipo de dato se alcanza mucho antes de saturar físicamente la pila.
Referencias y Lecturas Complementarias¶
Roberts, E. (2006). Thinking Recursively in C. Wiley.
Cormen, T. H., Leiserson, C. E., Rivest, R. L., & Stein, C. (2009). Introduction to Algorithms (3rd ed.). MIT Press. Chapter 4: Divide-and-Conquer.
ISO/IEC 9899 Language Standard Section 6.5.2.2: Function calls.