CP-UPV Bootcamp - Capítulo 4

Capítulo 4: Recursión

PROBLEMAS DEL CAPÍTULO
Para acceder a los problemas de este capítulo, accede con tu usuario y contraseña a cpupv.contest.codeforces.com. Si no tienes usuario y contraseña, inscríbete aquí.

Índice de contenidos

Definición de recursión

La recursión es una técnica de programación en la que una función se llama a sí misma para resolver un problema. La idea central es dividir un problema complejo en subproblemas más pequeños de la misma naturaleza.

Para que una función recursiva sea correcta y no se ejecute infinitamente, debe cumplir dos condiciones esenciales:

Funcionamiento (Call Stack)

Cuando una función se ejecuta, el sistema operativo utiliza una estructura de datos interna llamada Call Stack (Pila de llamadas). Cada vez que una función se llama a sí misma, se añade una nueva entrada (un stack frame) en el tope de la pila, congelando la ejecución de la función anterior.

Si la recursión no alcanza nunca el caso base o hace demasiadas llamadas consecutivas, la memoria asignada al Call Stack se agota, provocando el famoso error de desbordamiento de pila o Stack Overflow.

Tipos de recursión

Dependiendo de cómo esté estructurado el flujo del algoritmo, de cuántas veces se invoque a sí misma la función y del lugar exacto donde ocurra la llamada, podemos clasificar la recursión en tres categorías críticas para el rendimiento:

1. Recursión Lineal

Es el tipo más simple. La función realiza, como máximo, una única llamada recursiva por cada ejecución del caso general. El estado del problema avanza paso a paso de forma secuencial.

Impacto en memoria: Genera un Call Stack completamente vertical. Si la función procesa una entrada de tamaño $N$, la pila de llamadas crecerá linealmente hasta alcanzar una profundidad de $O(N)$ elementos antes de empezar a liberar memoria.

// Ejemplo clásico: Cálculo del Factorial N!
long long factorial(int n) {
    if (n <= 1) return 1; // Caso base
    return n * factorial(n - 1); // Una sola llamada lineal
}

2. Recursión Múltiple

Ocurre cuando el caso general de una función contiene dos o más llamadas recursivas independientes. En lugar de procesar el problema en una línea recta, el flujo de ejecución se ramifica constantemente.

Impacto en complejidad: Esta ramificación transforma la traza de ejecución en un árbol de recursión. El peligro principal aquí es la redundancia de cálculos: un algoritmo de recursión múltiple ingenuo (como calcular el número de Fibonacci sin programación dinámica) repite subproblemas idénticos una y otra vez, disparando la complejidad temporal a un coste exponencial de $O(2^N)$.

// Ejemplo clásico: Serie de Fibonacci ingenua
int fibonacci(int n) {
    if (n <= 1) return n; // Casos base
    // Dos llamadas recursivas independientes -> Ramificación binaria
    return fibonacci(n - 1) + fibonacci(n - 2); 
}

3. Recursión de Cola (Tail Recursion)

La recursión de cola es un caso especial sumamente codiciado en optimización. Decimos que una función es recursiva de cola si la llamada recursiva es la última instrucción absoluta que ejecuta la función.

Fíjate bien en la diferencia con la recursión lineal clásica: en el ejemplo del factorial, al volver de la llamada factorial(n-1), el ordenador todavía tiene que multiplicar ese resultado por n. Por lo tanto, el sistema no puede destruir el stack frame actual porque aún le queda trabajo por hacer. En cambio, en la recursión de cola, al no quedar operaciones pendientes, el valor devuelto por la subllamada es directamente el resultado final.

La magia del compilador (Tail Call Optimization - TCO): Compiladores modernos (como g++ con flags de optimización como -O2 o -O3) detectan esta propiedad. Al ver que la función actual ya no tiene tareas pendientes, en lugar de empujar un nuevo bloque a la pila y arriesgarse a un Stack Overflow, el compilador reutiliza el mismo stack frame matemático modificando únicamente las variables locales. Esto reduce mágicamente el coste espacial del Call Stack de $O(N)$ a un coste constante de $O(1)$ en espacio, igualando la eficiencia de un bucle iterativo.

// Versión optimizada de Factorial usando un acumulador para lograr Recursión de Cola
long long factorialCola(int n, long long acumulador = 1) {
    if (n <= 1) return acumulador; // Caso base: devolvemos directamente el resultado acumulado
    
    // La llamada recursiva es la última acción. No hay operaciones pendientes al regresar.
    return factorialCola(n - 1, n * acumulador); 
}

Coste temporal y espacial

Analizar la complejidad de una función recursiva requiere entender cómo se expanden las llamadas:

El Teorema Maestro

Para algoritmos de tipo Divide y Vencerás que dividen el problema en subproblemas del mismo tamaño, el Teorema Maestro proporciona una solución matemática directa para calcular la complejidad temporal sin necesidad de dibujar todo el árbol de llamadas.

Se aplica a ecuaciones de recurrencia que siguen la forma:

$$T(n) = aT\left(\frac{n}{b}\right) + f(n)$$

Donde:

Para determinar la complejidad final, comparamos el crecimiento de $f(n)$ con el término límite $n^{\log_b a}$:

Ejemplo 1: Tetris Espacial

El problema Tetris Espacial es una variante original desarrollada por nosotros, basada íntegramente en la lógica fundamental del clásico rompecabezas de las Torres de Hanói. En esta versión, disponemos de una base espacial con tres módulos: el Taller (1), el Hangar (2) y el Módulo de Ataque (3). Un conjunto de $N$ naves de combate se encuentran apiladas inicialmente en el Taller ordenadas rígidamente por tamaño, de forma que una nave grande nunca puede colocarse encima de una más pequeña para evitar destrucciones.

El objetivo es trasladar la pila completa de naves desde el Taller (1) hasta el Módulo de Ataque (3), cumpliendo estrictamente que solo se puede mover una única nave superior por movimiento y respetando siempre la restricción de tamaños. El programa debe calcular el número mínimo total de movimientos indispensables y detallar la secuencia exacta de pasos (módulo de origen y módulo de destino) para efectuar el traslado con éxito.

¿Se te ocurre la solución?

Intenta modelar este escenario de forma inductiva. Si dominas la estrategia para desplazar un bloque de $N-1$ naves empleando una función recursiva, ¿cómo estructurarías las llamadas para mover el lote completo de $N$ elementos? Analiza minuciosamente cuál debe ser tu caso base para detener la recursión de forma segura y cómo redefinir dinámicamente los roles de los módulos (origen, destino y auxiliar) en cada subpaso del flujo de ejecución.

ENLACE AL PROBLEMA
Ponte a prueba antes de desplegar el código: Resolver Tetris Espacial en Codeforces
Ver Solución (C++, Python, Java)
#include <bits/stdc++.h>
using namespace std;

void torres(int n, int origen, int destino, int auxiliar) {
    if (n == 0) return;  // Es el caso base necesario en la recursión!!

    torres(n - 1, origen, auxiliar, destino); // Movemos las n - 1 naves al hangar auxiliar

    cout << origen << " " << destino << "\n";

    torres(n - 1, auxiliar, destino, origen); // Movemos las n - 1 naves del auxiliar al destino final
}

int main() {
    // Primero tenemos las líneas de optimización para la programación competitiva
    ios::sync_with_stdio(0);
    cin.tie(0);

    int n;
    cin >> n;

    int k = (1 << n) - 1; // El total de movimientos es 2^n - 1
    cout << k << '\n';

    // Ahora mostramos el recorrido con la función recursiva
    torres(n, 1, 3, 2);

    return 0;
}
import sys

def torres(n, origen, destino, auxiliar):
    if n == 0: return # Caso base de la recursión

    torres(n - 1, origen, auxiliar, destino) # Movemos las n - 1 naves al auxiliar

    print(origen, " ", destino)

    torres(n - 1, auxiliar, destino, origen) # Movemos las n - 1 naves del auxiliar al destino

# Entrada rápida de datos en python
data = sys.stdin.read().split()
it = iter(data)

n = int(next(it))
print((1 << n) - 1)

torres(n, 1, 3, 2)
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.io.IOException;
import java.util.StringTokenizer;

public class Main {
    
    public static void torres(int n, int origen, int destino, int auxiliar, PrintWriter out) {
        if (n == 0) return;  // Es el caso base

        torres(n - 1, origen, auxiliar, destino, out); // Movemos las n - 1 naves al hangar auxiliar

        out.println(origen + " " + destino);

        torres(n - 1, auxiliar, destino, origen, out); // Movemos las n - 1 naves del auxiliar al destino final
    }

    public static void main(String[] args) throws IOException {
        // Líneas de optimización para la programación competitiva
        BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
        PrintWriter out = new PrintWriter(System.out);
        StringTokenizer st = null;

        String line = br.readLine();
        if (line != null) {
            st = new StringTokenizer(line);
            int n = Integer.parseInt(st.nextToken());

            int k = (1 << n) - 1; // El total de movimientos es 2^n - 1
            out.println(k);

            // Ahora mostramos el recorrido con la función recursiva
            torres(n, 1, 3, 2, out);
        }
        
        // Es fundamental vaciar el buffer al final para asegurar que todo se imprima correctamente
        out.flush();
    }
}

Ejemplo 2: El Juez de Rubén

El gran dictador Rubén Nieto está intentando desarrollar su propio juez de programación competitiva. Durante el proceso ha descubierto que si altera el orden de las operaciones matemáticas escribiéndolas en notación polaca (por ejemplo, * + 7 4 5 en lugar de (7 + 4) * 5), el juez pasa a ser un 200% más eficiente.

Como su trabajo en MercaJoja apenas le deja tiempo, nos ha delegado la tarea de evaluar correctamente estas expresiones. El formato garantiza que los únicos operadores son binarios (+, -, *, /) y que la división debe comportarse como la división entera de C++ (truncada hacia cero).

¿Se te ocurre la solución?

Mucha gente intenta resolver este tipo de problemas de manera iterativa utilizando una estructura de datos de pila (std::stack). Sin embargo, la notación prefija es inherentemente recursiva. Si lees un operador, necesitas evaluar inmediatamente sus dos operandos siguientes. ¿Cómo puedes delegar esa lectura secuencial al propio Call Stack del sistema?

ENLACE AL PROBLEMA
Ponte a prueba antes de desplegar el código: Resolver El Juez de Rubén en Codeforces
Ver Solución (C++, Python, Java)
#include <iostream>
#include <string>

using namespace std;

// Leemos elemento a elemento directamente del stream estándar (cin)
long long resolver() {
    string s;
    if (!(cin >> s)) return 0;

    // Si encontramos operador, llamamos recursivamente para ambos operandos
    if (s == "+" || s == "-" || s == "*" || s == "/") {
        long long izq = resolver();
        long long der = resolver();

        if (s == "+") return izq + der;
        if (s == "-") return izq - der;
        if (s == "*") return izq * der;
        if (s == "/") return izq / der; // En C++ la división entera ya trunca hacia cero
    }

    // Si es un número lo convertimos a entero de 64 bits
    return stoll(s);
}

int main() {
    // Optimización estándar de I/O para CP
    ios_base::sync_with_stdio(0);
    cin.tie(0);

    cout << resolver() << "\n";

    return 0;
}
import sys

# Aumentamos el límite por si la expresión viniera muy descompensada en profundidad
sys.setrecursionlimit(2000)

def resolver(elementos, idx):
    cad = elementos[idx[0]]
    idx[0] += 1
    
    # Si es un operador, evaluamos primero la parte izquierda y luego la derecha
    if cad in ("+", "-", "*", "/"):
        izq = resolver(elementos, idx)
        der = resolver(elementos, idx)
        
        if cad == "+": return izq + der
        if cad == "-": return izq - der
        if cad == "*": return izq * der
        if cad == "/": return int(izq / der)  # int() para truncar a cero estilo C++
    
    # Si no es operador, es un número
    return int(cad)

def main():
    linea = sys.stdin.read().strip()
    if not linea:
        return
    
    elementos = linea.split()
    idx = [0]  # Pasamos un índice por referencia usando una lista
    print(resolver(elementos, idx))

if __name__ == "__main__":
    main()
import java.util.Scanner;

public class Main {

    // Pasamos el Scanner para ir consumiendo la entrada en orden de forma recursiva
    private static long resolver(Scanner sc) {
        if (!sc.hasNext()) return 0;
        
        String s = sc.next();

        if (s.equals("+") || s.equals("-") || s.equals("*") || s.equals("/")) {
            long izq = resolver(sc);
            long der = resolver(sc);

            if (s.equals("+")) return izq + der;
            if (s.equals("-")) return izq - der;
            if (s.equals("*")) return izq * der;
            if (s.equals("/")) return izq / der; // En Java también trunca hacia cero
        }

        return Long.parseLong(s);
    }

    public static void main(String[] args) {
        Scanner sc = new Scanner(System.in);
        if (sc.hasNext()) {
            System.out.println(resolver(sc));
        }
        sc.close();
    }
}

Problemas de práctica

La única forma de dominar la recursión es escribiendo código. Te recomendamos resolver la lista de problemas que encontrarás en nuestro bootcamp en codeforces.