Fundamentos

Tutorial Fundamentos: Recursividad

Descubre cómo aplicar la recursividad en programación para resolver problemas complejos de manera eficiente con ejemplos de factorial y Fibonacci.

Aprende Fundamentos GRATIS y certifícate

Concepto de recursividad y su base matemática

La recursividad es un concepto fundamental en programación y matemáticas que se refiere a la capacidad de una función para llamarse a sí misma. Esta técnica permite resolver problemas complejos descomponiéndolos en subproblemas más simples del mismo tipo.

En términos matemáticos, la recursividad se basa en definir una entidad en función de sí misma, estableciendo una relación entre un caso general y uno o varios casos base. Por ejemplo, la función factorial se define recursivamente de la siguiente manera:

  • Caso base:       0! = 1
  • Caso recursivo:  n! = n * (n - 1)! para n > 0

En programación, una función recursiva debe cumplir con dos elementos esenciales:

  1. Condición base: una condición que detiene las llamadas recursivas, evitando un ciclo infinito.
  2. Llamada recursiva: la función se invoca a sí misma con argumentos que progresan hacia la condición base.

Veamos un ejemplo de una función recursiva para calcular el factorial de un número utilizando Pseint:

Funcion factorial1 <- factorial(n)
    Definir resultado Como Real
    
    Si n == 0 Entonces
        // Asignar 1 al nombre de la función como caso base
        factorial1 <- 1
    Sino
        // Calcular el factorial recursivamente
        factorial1 <- n * factorial(n - 1)
    Fin Si
Fin Funcion

Y su equivalente en Python:

def factorial(n):
    if n == 0:
        return 1
    else:
        return n * factorial(n - 1)

La recursividad es especialmente útil para problemas que se pueden dividir en subproblemas similares. Un ejemplo clásico es la secuencia de Fibonacci, donde cada término es la suma de los dos anteriores:

Funcion fibonacci <- fibonacci1(n)
    Definir resultado Como Real
    
    Si n == 0 Entonces
        // Asignar 0 al nombre de la función como caso base
        fibonacci <- 0
    Fin Si
    Si n == 1 Entonces
    // Asignar 1 al nombre de la función como caso base
    fibonacci <- 1
    Sino
    // Calcular la suma de los dos términos anteriores recursivamente
    fibonacci <- fibonacci1(n - 1) + fibonacci1(n - 2)
    Fin Si
Fin Funcion

En Python, esta función se implementaría así:

def fibonacci(n):
    if n == 0:
        return 0
    elif n == 1:
        return 1
    else:
        return fibonacci(n - 1) + fibonacci(n - 2)

Es importante destacar que, sin una condición base bien definida, una función recursiva puede causar un desbordamiento de pila debido a llamadas infinitas. Por ello, al utilizar recursividad, debemos asegurarnos de que cada llamada recursiva nos acerque a la condición base.

La comprensión de la recursividad y su fundamento matemático es esencial para diseñar algoritmos eficientes y elegantes. Aunque en algunos casos la recursividad puede no ser la solución más óptima en términos de rendimiento, proporciona una forma clara y concisa de representar soluciones a problemas inherentemente recursivos.

Casos de uso comunes: factorial, Fibonacci y algoritmos de búsqueda/ordenación

La recursividad es una técnica ampliamente utilizada para resolver problemas que pueden dividirse en subproblemas más simples del mismo tipo. Además de los ejemplos clásicos como el factorial y la secuencia de Fibonacci, la recursividad es fundamental en algoritmos de búsqueda y ordenación, que son esenciales en la informática.

En el caso de la búsqueda binaria, se utiliza recursividad para encontrar un elemento en una lista ordenada de manera eficiente. Este algoritmo reduce el espacio de búsqueda a la mitad en cada llamada recursiva, logrando una complejidad de O(log n). A continuación, se presenta la implementación en Python:

def busqueda_binaria(lista, inicio, fin, objetivo):
    if inicio > fin:
        return -1  # No encontrado
    medio = (inicio + fin) // 2
    if lista[medio] == objetivo:
        return medio  # Índice encontrado
    elif lista[medio] > objetivo:
        return busqueda_binaria(lista, inicio, medio - 1, objetivo)
    else:
        return busqueda_binaria(lista, medio + 1, fin, objetivo)

Otro uso común de la recursividad es en algoritmos de ordenación, como el Quick Sort. Este algoritmo selecciona un pivote y particiona el arreglo en subarreglos menores y mayores al pivote, aplicando recursividad para ordenar cada subarreglo. Su eficiencia promedio es de O(n log n). En  Python se implementa de la siguiente forma:

def quick_sort(lista, inicio, fin):
    if inicio < fin:
        pivote_indice = particion(lista, inicio, fin)
        quick_sort(lista, inicio, pivote_indice - 1)
        quick_sort(lista, pivote_indice + 1, fin)

def particion(lista, inicio, fin):
    pivote = lista[fin]
    indice = inicio - 1
    for i in range(inicio, fin):
        if lista[i] <= pivote:
            indice += 1
            lista[indice], lista[i] = lista[i], lista[indice]
    lista[indice + 1], lista[fin] = lista[fin], lista[indice + 1]
    return indice + 1

El Merge Sort es otro algoritmo recursivo que divide el arreglo en mitades, ordena cada mitad recursivamente y luego las fusiona. Este método garantiza una complejidad de O(n log n) en todos los casos. La implementación en Python:

def merge_sort(lista):
    if len(lista) > 1:
        medio = len(lista) // 2
        izquierda = lista[:medio]
        derecha = lista[medio:]
        
        merge_sort(izquierda)
        merge_sort(derecha)
        
        i = j = k = 0
        while i < len(izquierda) and j < len(derecha):
            if izquierda[i] <= derecha[j]:
                lista[k] = izquierda[i]
                i += 1
            else:
                lista[k] = derecha[j]
                j += 1
            k += 1
        while i < len(izquierda):
            lista[k] = izquierda[i]
            i += 1
            k += 1
        while j < len(derecha):
            lista[k] = derecha[j]
            j += 1
            k += 1

La recursividad también es clave en la manipulación de estructuras de datos como árboles y grafos. Por ejemplo, los recorridos preorden, inorden y postorden en árboles binarios se implementan naturalmente mediante funciones recursivas. Un recorrido preorden en Python, sería:

def pre_orden(nodo):
    if nodo is not None:
        print(nodo.valor)
        pre_orden(nodo.izquierdo)
        pre_orden(nodo.derecho)

En problemas de búsqueda en grafos, como la búsqueda en profundidad (DFS), la recursividad facilita la exploración de todos los nodos alcanzables desde un nodo inicial.

Identificación y manejo de la condición base

En la recursividad, la condición base es fundamental para garantizar que una función recursiva finalice correctamente. La condición base define el caso más sencillo del problema, aquel que se resuelve sin necesidad de llamar nuevamente a la función. Sin una condición base adecuada, la recursividad podría generar un ciclo infinito, agotando los recursos del sistema.

Para identificar la condición base en un problema recursivo, es necesario:

  1. Comprender plenamente el problema: analizar qué casos no requieren descomposición adicional.
  2. Determinar el estado final: establecer los valores o condiciones en las que el problema se considera resuelto.
  3. Asegurar que cada llamada recursiva avance hacia la condición base: los parámetros deben modificarse de manera que eventualmente cumplan la condición base.

Consideremos el ejemplo de calcular la potencia de un número entero positivo. La potencia a^b puede definirse recursivamente:

  • Condición base: si b == 0, entonces a^0 = 1.
  • Paso recursivo: a^b = a * a^(b - 1).

Implementación en PseInt:

Funcion potencia1 <- potencia(a, b)
    Definir resultado Como Entero
    
    Si b == 0 Entonces
        // Asignar 1 al nombre de la función como condición base
        potencia1 <- 1
Sino
    // Calcular la potencia recursivamente y asignarla al nombre de la función
    potencia1 <- a * potencia(a, b - 1)
Fin Si
Fin Funcion

Y en Python:

def potencia(a, b):
    if b == 0:
        return 1
    else:
        return a * potencia(a, b - 1)

En este ejemplo, la condición base se alcanza cuando b es cero. Cada llamada recursiva reduce el exponente b, acercándose a la condición base.

Es común cometer errores al manejar la condición base, como no definirla correctamente o no avanzar hacia ella en cada llamada. Estos errores pueden llevar a una recursión infinita. Por ejemplo, una implementación incorrecta podría ser:

Funcion potenciaIncorrecta1 <- potenciaIncorrecta(a, b)
    Definir resultado Como Entero
    
    // Añadir condición base para evitar recursión infinita
    Si b == 0 Entonces
        potenciaIncorrecta1 <- 1
    Sino
        potenciaIncorrecta1 <- a * potenciaIncorrecta(a, b - 1)
    Fin Si
Fin Funcion

En este caso, no hay condición base, y la variable b nunca cambia, lo que provoca un ciclo infinito.

Otro aspecto crucial es manejar condiciones base múltiples si el problema lo requiere. Tomemos el ejemplo de la función Ackermann, una función recursiva que crece rápidamente y tiene múltiples condiciones base:

Funcion ackermann1 <- ackermann(m, n)
    Definir resultado Como Entero
    
    Si m == 0 Entonces
        // Asignar n + 1 al nombre de la función como condición base
        ackermann1 <- n + 1
    Fin Si 
    Si m > 0 Y n == 0 Entonces
        // Asignar el resultado de la llamada recursiva al nombre de la función
        ackermann1 <- ackermann(m - 1, 1)
    Sino
        // Asignar el resultado de la llamada recursiva anidada al nombre de la función
        ackermann1 <- ackermann(m - 1, ackermann(m, n - 1))
    Fin Si
Fin Funcion

En Python, sería:

def ackermann(m, n):
    if m == 0:
        return n + 1
    elif m > 0 and n == 0:
        return ackermann(m - 1, 1)
    else:
        return ackermann(m - 1, ackermann(m, n - 1))

Aquí, la función tiene dos condiciones base diferentes, dependiendo de los valores de m y n. Es esencial evaluar correctamente estas condiciones para evitar llamadas recursivas innecesarias.

Para asegurar un manejo adecuado de la condición base:

  • Verificar que la condición base cubra todos los casos de finalización.
  • Probar la función con valores que correspondan a la condición base.
  • Confirmar que los parámetros progresen apropiadamente hacia la condición base.

Un ejemplo más complejo es el cálculo del máximo común divisor (MCD) utilizando el algoritmo de Euclides. La recursión se basa en el hecho de que el MCD de dos números también es el MCD del segundo número y el resto de la división entre el primero y el segundo.

En Python:

def mcd(a, b):
    if b == 0:
        return a
    else:
        return mcd(b, a % b)

La condición base se alcanza cuando b es cero, momento en el cual el MCD es a. En cada llamada recursiva, el valor de b disminuye hasta llegar a cero, garantizando que la función finalice.

Es importante también considerar problemas donde la profundidad recursiva puede ser grande. En tales casos, el manejo eficiente de la condición base y de las llamadas recursivas es vital para evitar errores de desbordamiento de pila. Por ejemplo, en el cálculo de los números de Catalán, que aparecen en combinatoria.

En Python:

def catalan(n):
    if n <= 1:
        return 1
    else:
        c = 0
        for i in range(n):
            c += catalan(i) * catalan(n - i - 1)
        return c

Aquí, la condición base es n <= 1, asegurando que las llamadas recursivas terminen. Sin embargo, debido a la naturaleza del problema, el número de llamadas recursivas crece exponencialmente, lo que puede causar problemas de rendimiento.

Para mitigar estos problemas, se pueden emplear técnicas como la memoización, que almacena resultados ya calculados para evitar cálculos redundantes. Aunque la optimización se aborda en detalle en otra sección, es relevante mencionar que un correcto manejo de la condición base es el primer paso para escribir funciones recursivas eficientes.

En resumen, la identificación y el manejo adecuado de la condición base son esenciales para el correcto funcionamiento de las funciones recursivas. Una condición base bien definida garantiza que la recursión finalice y que la función produzca resultados correctos. Al diseñar una función recursiva, siempre debemos:

  • Definir claramente la condición base.
  • Asegurar que los parámetros cambien en cada llamada para avanzar hacia la condición base.
  • Comprobar exhaustivamente la función con diferentes casos para validar su correcto funcionamiento.

Aplicando estas prácticas, podremos utilizar la recursividad de manera efectiva para resolver problemas complejos en programación.

Optimización de funciones recursivas: recursión de cola y memoización

En la programación recursiva, es común enfrentar problemas de eficiencia y consumo de recursos. Dos técnicas importantes para optimizar funciones recursivas son la recursión de cola y la memoización. Estas estrategias permiten mejorar el rendimiento y prevenir errores como el desbordamiento de pila.

La recursión de cola se refiere a una función recursiva donde la llamada recursiva es la última operación que se ejecuta. Esto implica que no hay trabajo pendiente después de la llamada recursiva, lo que permite a algunos compiladores o intérpretes optimizar la ejecución reutilizando el mismo marco de pila. Aunque PseInt y Python no soportan esta optimización de forma nativa, es una buena práctica estructurar las funciones recursivas de esta manera para mejorar su comprensión y facilitar su transformación en algoritmos iterativos.

Consideremos la función factorial implementada con recursión de cola en PseInt:

Funcion factorialTail1 <- factorialTail(n, acumulador)
    Definir resultado Como Entero
    
    Si n == 0 Entonces
        // Asignar el acumulador al nombre de la función como condición base
        factorialTail1 <- acumulador
    Sino
        // Asignar el resultado de la llamada recursiva al nombre de la función
        factorialTail1 <- factorialTail(n - 1, acumulador * n)
    Fin Si
Fin Funcion

Para calcular el factorial de un número n, se llama a factorialTail(n, 1). Aquí, el acumulador almacena el resultado parcial, y la llamada recursiva es la última operación de la función.

El equivalente en Python sería:

def factorial_tail(n, acumulador=1):
    if n == 0:
        return acumulador
    else:
        return factorial_tail(n - 1, acumulador * n)

Aunque Python no realiza optimización de recursión de cola, esta estructura permite entender el concepto y facilita la reescritura en forma iterativa si es necesario.

Por otro lado, la memoización es una técnica que consiste en almacenar los resultados de las llamadas a una función para evitar cálculos redundantes. Es especialmente útil en funciones recursivas que realizan múltiples llamadas con los mismos argumentos, como en la secuencia de Fibonacci.

Implementemos Fibonacci con memoización en Python:

memo = {}

def fibonacci_memo(n):
    if n <= 1:
        return n
    if n in memo:
        return memo[n]
    resultado = fibonacci_memo(n - 1) + fibonacci_memo(n - 2)
    memo[n] = resultado
    return resultado

La memoización reduce drásticamente el número de llamadas recursivas, mejorando la eficiencia de la función. En el caso de Fibonacci, se pasa de una complejidad exponencial a una lineal.

Además, en Python se puede utilizar el decorador lru_cache de la biblioteca functools para simplificar la memoización:

from functools import lru_cache

@lru_cache(maxsize=None)
def fibonacci_memo(n):
    if n <= 1:
        return n
    return fibonacci_memo(n - 1) + fibonacci_memo(n - 2)

Aunque en PseInt no existen decoradores, es posible implementar la memoización utilizando estructuras de datos como diccionarios o arreglos para almacenar resultados.

Es relevante destacar que la recursión de cola y la memoización abordan problemáticas diferentes:

  • La recursión de cola se enfoca en reducir el consumo de memoria al optimizar el uso de la pila de llamadas.
  • La memoización mejora el tiempo de ejecución al evitar cálculos repetidos y almacenar resultados previos.

En resumen, la recursión de cola y la memoización son herramientas fundamentales para optimizar funciones recursivas. La práctica en su aplicación ayuda a identificar situaciones donde estas técnicas aportan beneficios significativos en términos de eficiencia y claridad del código.

Para seguir leyendo hazte Plus

¿Ya eres Plus? Accede a la app

Plan mensual

19.00 € /mes

Precio normal mensual: 19 €
47 % DE DESCUENTO

Plan anual

10.00 € /mes

Ahorras 108 € al año
Precio normal anual: 120 €
Aprende Fundamentos GRATIS online

Todas las lecciones de Fundamentos

Accede a todas las lecciones de Fundamentos y aprende con ejemplos prácticos de código y ejercicios de programación con IDE web sin instalar nada.

Accede GRATIS a Fundamentos y certifícate

En esta lección

Objetivos de aprendizaje de esta lección

  • Comprender el concepto de recursividad y su aplicación en matemáticas.
  • Identificar y formular la condición base en funciones recursivas.
  • Implementar recursividad en algoritmos comunes (factorial, Fibonacci).
  • Aplicar recursividad en búsqueda binaria y ordenación rápida.
  • Reconocer y corregir problemas de desbordamiento de pila.
  • Optimizar funciones recursivas usando técnicas avanzadas como memoización.