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.

Recursión de Cola y Divide y Vencerás

TCO, optimización de la pila y algoritmos de división recursiva en C

Universidad Nacional de Río Negro

Ventajas y Desventajas

Prerrequisitos: caso base, paso recursivo, stack frame y complejidad temporal/espacial. Este capítulo extiende la recursividad introductoria.

Objetivo: distinguir recursión de cola de divide y vencerás y no depender de una optimización del compilador para garantizar la terminación.

Comprobación de salida: justificá si una llamada es de cola y describí el costo de stack sin asumir TCO.

Introducción

Desarrollo

Recursión de Cola (Tail Recursion) y Optimización TCO

Una llamada recursiva se considera recursiva de cola (tail recursive) si la llamada a sí misma es la última instrucción ejecutada por la función antes de retornar, y su resultado se devuelve directamente sin realizar ninguna operación aritmética o lógica adicional.

Por ejemplo, la función factorial tradicional expuesta en el apunte introductorio no es recursiva de cola porque, tras el retorno de factorial(n - 1), la función debe realizar la multiplicación por n.

Podemos reescribir la función factorial para que sea recursiva de cola utilizando un acumulador:

1
2
3
4
5
6
7
8
9
10
11
12
13
long int factorial_tail_rec(int n, long int acumulador)
{
    if (n < 0)
    {
        return -1;
    }
    if (n == 0)
    {
        return acumulador;
    }
    // La llamada recursiva es la última operación física.
    return factorial_tail_rec(n - 1, n * acumulador);
}
Optimización por parte del compilador (TCO)

Cuando una llamada es recursiva de cola, los compiladores modernos pueden aplicar una optimización llamada Tail Call Optimization (TCO). En lugar de empujar un nuevo marco de pila al call stack, el compilador sobrescribe el marco de pila de la función actual y reutiliza sus registros y variables locales, transformando efectivamente la recursión en un salto incondicional (equivalente a un lazo de control). Esto reduce la complejidad espacial auxiliar del algoritmo de O(n)O(n) a O(1)O(1).

A continuación se presenta una tabla comparativa sobre el uso de recursos entre ambas aproximaciones:

Table 1:Comparación de recursos: Iteración vs. Recursividad

AspectoIteración (Lazos)Recursividad
Uso de Memoria en el StackO(1)O(1) constante. El mismo marco de pila se reutiliza durante todo el lazo.O(d)O(d) donde dd es la profundidad máxima de llamadas (salvo TCO exitoso).
RendimientoMás rápido. Evita la sobrecarga de llamadas y retornos de función.Más lento por la constante asignación y liberación de marcos de pila.
Límite de EjecuciónLimitado solo por el tiempo de procesamiento o valores numéricos.Físicamente limitado por el tamaño máximo del stack del sistema.

Paradigma de Divide y Vencerás

El paradigma de “Divide y Conquista” (Divide and Conquer) es una potente estrategia para el diseño de algoritmos que consiste en resolver un problema complejo descomponiéndolo en subproblemas más pequeños y manejables. Este paradigma aplica naturalmente la recursividad para su implementación. El proceso se puede resumir en tres fases principales:

  1. Dividir: Se descompone el problema principal en un número de subproblemas que son instancias más pequeñas del mismo problema.

  2. Conquistar: Se resuelven los subproblemas de forma recursiva. Si un subproblema es lo suficientemente pequeño (caso base), se resuelve de manera directa.

  3. Combinar: Se combinan las soluciones de los subproblemas para construir la solución del problema original.

Este flujo de trabajo de divide y vencerás se puede visualizar de manera gráfica en el algoritmo de ordenamiento Merge Sort:

Paradigma de Divide y Vencerás aplicado a la ordenación del arreglo [12, 11, 13,
5] mediante Merge Sort.

Figure 1:Paradigma de Divide y Vencerás aplicado a la ordenación del arreglo [12, 11, 13, 5] mediante Merge Sort.

Ejemplo 1: Búsqueda Binaria

La búsqueda binaria es un algoritmo altamente eficiente para localizar un elemento dentro de un arreglo ordenado. Se basa en el paradigma de divide y conquista.

Sin embargo, a menudo se enseña implementado mediante recursividad, lo cual es ineficiente desde la perspectiva del uso de memoria en sistemas reales.

Justificación del Consumo de Pila y Complejidad Espacial

En la versión recursiva, cada paso de división genera un nuevo marco de pila. Como el espacio se reduce a la mitad en cada paso, la profundidad máxima de la pila es de O(log⁡n)O(\log n). Por ende, consume un espacio auxiliar de O(log⁡n)O(\log n) marcos de pila en el stack del sistema.

En contraste, la versión iterativa clásica resuelve el mismo problema utilizando un único lazo de control while y variables locales reescritas, requiriendo un espacio espacial auxiliar de O(1)O(1) (constante) de manera óptima, lo que elimina cualquier riesgo de stack overflow.

Implementaciones en C

Para cumplir con la regla de uso de variables de tipo size_t en índices y tamaños (regla 0x3010h: Las variables que representan tamaños o índices de arreglos deben ser de tipo size_t), debemos prever y evitar el desbordamiento por decremento bajo cero (ya que size_t es un tipo de dato sin signo). Además, declaramos el arreglo de entrada como const dado que la función no modifica sus elementos (regla 0x3007h: Los argumentos de tipo puntero deben ser const siempre que la función no los modifique).

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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
#include <stddef.h>
#include <stdio.h>
// Búsqueda binaria recursiva
int buscar_binario_recursivo(const int arr[], size_t l, size_t r, int x,
                             size_t *indice_encontrado)
{
    if (l <= r)
    {
        size_t mid = l + (r - l) / 2;
        if (arr[mid] == x)
        {
            *indice_encontrado = mid;
            return 1;
        }
        if (arr[mid] > x)
        {
            if (mid > 0)
            {
                return buscar_binario_recursivo(arr, l, mid - 1, x,
                                                indice_encontrado);
            }
        }
        else
        {
            return buscar_binario_recursivo(arr, mid + 1, r, x,
                                            indice_encontrado);
        }
    }
    return 0; // Caso base: no encontrado
}
// Búsqueda binaria iterativa (espacio O(1) óptimo)
int buscar_binario_iterativo(const int arr[], size_t size, int x,
                             size_t *indice_encontrado)
{
    if (size == 0)
    {
        return 0;
    }
    size_t l = 0;
    size_t r = size - 1;
    while (l <= r)
    {
        size_t mid = l + (r - l) / 2;
        if (arr[mid] == x)
        {
            *indice_encontrado = mid;
            return 1;
        }
        if (arr[mid] > x)
        {
            if (mid == 0)
            {
                break; // Evita el desbordamiento inferior de size_t al
                       // decrementar
            }
            r = mid - 1;
        }
        else
        {
            l = mid + 1;
        }
    }
    return 0;
}
Ejemplo 2: Ordenamiento por Fusión (Merge Sort)

Merge Sort representa una aplicación más compleja del paradigma de divide y vencerás que involucra recursión múltiple (dos llamadas recursivas) y una fase de combinación no trivial (la fusión de arreglos ordenados).

Este flujo no lineal de llamadas se puede visualizar detalladamente en la siguiente traza de ejecución:

Árbol de llamadas recursivas y secuencia de fusión para Merge Sort con el
arreglo inicial [5, 2, 7, 3]. Los números en los círculos indican el orden
cronológico de ejecución (DFS).

Figure 2:Árbol de llamadas recursivas y secuencia de fusión para Merge Sort con el arreglo inicial [5, 2, 7, 3]. Los números en los círculos indican el orden cronológico de ejecución (DFS).

Deficiencia del malloc en recursión profunda y optimización de buffer único
Implementación Optimizada en C

A continuación se expone la implementación correcta de Merge Sort. En concordancia con las reglas de estilo de la cátedra, todos los bloques y estructuras de control emplean llaves obligatoriamente (regla 0x1001h: Todas las estructuras de control deben utilizar llaves) y los tamaños e índices se definen utilizando el tipo size_t (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
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
#include <stdio.h>
#include <stdlib.h>
// Combina dos mitades ordenadas arr[l..m] y arr[m+1..r] utilizando el búfer
// auxiliar único aux[]
void fusionar(int arr[], size_t l, size_t m, size_t r, int aux[])
{
    size_t i = l;
    size_t j = m + 1;
    size_t k = l;
    for (size_t idx = l; idx <= r; idx++)
    {
        aux[idx] = arr[idx];
    }
    while ((i <= m) && (j <= r))
    {
        if (aux[i] <= aux[j])
        {
            arr[k] = aux[i];
            i++;
        }
        else
        {
            arr[k] = aux[j];
            j++;
        }
        k++;
    }
    while (i <= m)
    {
        arr[k] = aux[i];
        i++;
        k++;
    }
    while (j <= r)
    {
        arr[k] = aux[j];
        j++;
        k++;
    }
}
// Función recursiva interna
void merge_sort_recursivo(int arr[], size_t l, size_t r, int aux[])
{
    if (l < r)
    {
        size_t m = l + (r - l) / 2;
        merge_sort_recursivo(arr, l, m, aux);
        merge_sort_recursivo(arr, m + 1, r, aux);
        fusionar(arr, l, m, r, aux);
    }
}
// Función envolvente que preasigna el búfer auxiliar único
int ordenar_merge_sort(int arr[], size_t size)
{
    if (size <= 1)
    {
        return 0; // Ya ordenado
    }
    int *aux = (int *)malloc(size * sizeof(*aux));
    // Validar asignación de memoria dinámica (regla {ref}`0x3001h`)
    if (aux == NULL)
    {
        return -1;
    }
    merge_sort_recursivo(arr, 0, size - 1, aux);
    free(aux);
    return 0;
}

Ventajas y Desventajas

Ventajas

Desventajas

Permite resolver problemas complejos de manera eficiente (por ejemplo, con complejidad O(nlog⁡n)O(n \log n)).

La sobrecarga de la recursividad (llamadas a funciones y uso de la pila) puede hacer que sea más lento que un enfoque iterativo para problemas pequeños.

Los algoritmos son naturalmente paralelizables, ya que los subproblemas son independientes.

Puede ser más complejo de implementar correctamente que las soluciones iterativas.

El código puede ser más elegante y fácil de entender, ya que refleja la estructura matemática del problema.

La recursividad profunda puede llevar a un desbordamiento de la pila (stack overflow) si no se maneja con cuidado.

Ejercicios de Autoevaluación

Glosario

Síntesis y Resumen

En este apunte se han presentado los conceptos fundamentales del tema.

Referencias y Lecturas Complementarias

No se especifican lecturas complementarias para este tema.