CP-UPV Bootcamp - Capítulo 6

Capítulo 6: 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í.

Introducción

La recursión es uno de los conceptos fundamentales y más poderosos en la programación. Se trata de una técnica donde una función se llama a sí misma para resolver un problema dividiéndolo en subproblemas más pequeños y manejables. Entender y dominar la recursión no solo te permitirá resolver problemas de forma más elegante, sino que también te abrirá la puerta a una amplia gama de algoritmos y soluciones, desde la exploración de grafos hasta la optimización de cadenas.

Además, introduciremos otros conceptos que, aparte de ser utilizados en otros contextos, también son relevantes para entender mejor la recursión y aplicarla de manera correcta.

Índice de contenidos

Punteros

Los punteros son unas variables especiales que nos sirven para almacenar la dirección en memoria de otra variable. De esta forma, podemos manejar y manipular los datos de una forma más eficiente.

Operadores

Para manejar punteros, tenemos a nuestra disposición dos operadores especiales:

int a = 10;
int* puntero = &a; // Se utiliza '*' en la declaración para indicar que es puntero de un int
// & obtiene la dirección de memoria y la asigna a la variable `puntero`

cout << puntero << endl;
// 0x7ffc12345678  -  Una dirección 'aleatoria'

cout << *puntero << endl;
// 10  -  Devuelve el valor que hay en esa dirección, tratado como un int

*puntero = 20; // Usando punteros, podemos modificar esos datos, que son persistentes
// "Valor en 'puntero'" = 20
cout << *puntero << endl;
// 20

cout << a << endl;
// 20
// Como modificamos usando el puntero, su valor es diferente ahora

La utilidad de los punteros viene del hecho de que nos pueden servir para realizar diferentes operaciones de manera más eficiente, más directa, como podremos ver más adelante pasando argumentos por referencia.

Ventajas

Funciones

Call Stack

Antes de pasar a la recursión debemos tener claro qué es el Call Stack.

El Call Stack es, como su nombre indica, un stack, pero este se diferencia de los que creamos nosotros en que es usado para almacenar información sobre las variables y funciones que son usadas en un programa. Esto lo hace añadiendo al stack una entrada, o stack frame, por cada recurso que se utiliza, por ejemplo:

int funcion(int a, int b) {
    int c = a + b;
    // (2)
    return c;
}

void main() {
    int a = 10, b = 20;
    // (1)
    cout << funcion(b, a) << endl;
    // (3)
}

Como podemos observar, saltandonos bastantes detalles, se va añadiendo una entrada por cada llamada a una función, alojando información sobre las variables y argumentos que son usados en esta función. Con esta información guardada, es que podemos mantener o pasar información con cada instrucción de nuestro código con la seguridad de que tendremos esa información de manera accesible.

Argumentos por valor

Cuando pasamos argumentos por valor, lo que hacemos es darle una copia del valor de la variable que pasamos como argumento, es decir, si por ejemplo tenemos este código:

void cambio(int a, int b) {
    int aux = a;
    a = b;
    b = aux;
}

void main() {
    int num = 3, num1 = 8;
    cambio(num, num1);
    cout << num << ", " << num1 << endl;
}

> 3, 8

La función recibirá como argumentos los valores 3 y 8, y en las variables locales que se crean(a y b), asignará los valores correspondientes. De esta forma, podemos usar los valores iniciales de unas variables y modificarlos pero después mantener esos valores iniciales. Sin embargo, hay ocasiones en las que sí que queremos modificar el valor de las variables iniciales, esto lo podemos hacer pasando los argumentos por referencia.

Argumentos por referencia

Al pasar argumentos por referencia, lo que hacemos es proveer a la función de la dirección en memoria donde empiezan los datos de la variable que hemos pasado, esto nos permite poder realizar cambios en esta variable inicial, y que estos se apliquen cuando ejecutamos la función.

void cambio(int &a, int &b) {
    int aux = a;
    a = b;
    b = aux;
}

void main() {
    int num = 3, num1 = 8;
    cambio(num, num1);
    cout << num << ", " << num1 << endl;
}

> 8, 3

Con este ejemplo, vemos como el valor de las variables num y num1 sí que se han intercambiado, al contrario que en el ejemplo anterior.

En muchas ocasiones, no habrá mucha diferencia entre usar o no una técnica u otra, pero shay aspectos que debemos tener en cuenta, ya que nos permitirán decidir si utilizar una o la otra.

Return

return es una instrucción especial que no solo nos sirve para devolver el resultado de una función. Habrás visto que en la sintáxis de una función, lo primero que se indica es un tipo de dato. Este tipo de dato es lo que se espera que devuelva obligatoriamente la función:

// La función main devuelve un 'int', como se indica aquí
int main() {
    // OBLIGATORIAMENTE debe haber una instrucción 'return' que devuelva un 'int' en alguna parte de la función
    ...

    return 0;  // <-- 0 es de tipo `int`
}

// Caso excepcional, si no se va a devolver ningún valor, se utiliza 'void' como tipo de dato
void diHola() {
    cout << "Hola" << endl;

    // Se puede escribir 'return;' sin ningún dato para parar la ejecución de una función, pero como no se espera devolver un valor de la función ('void'), esta instrucción es opcional
}

En el caso de la recursión, la instrucción return se puede utilizar para propagar resultados hacia atrás en el call stack hasta la llamada inicial del método, esto nos será útil para diseñar funciones que vayan calculando el resultado final realizando varias llamadas a si mismo, como ya veremos más adelante con el DFS y otras funciones básicas.

Además, otra de las ventajas de utilizar return correctamente es el tener seguridad de que la recursión tiene un límite y se alcanzará un resultado antes de llegar a un stack overflow, uno de los errores más comunes en cuanto a recursión respecta, producido cuando se crean demasiadas entradas en el call stack, dando lugar a un desborde.

Algunos ejemplos de uso de la instrucción return, son:

Recursión básica

1 a N

Queremos enumerar todos los números desde el 1 hasta N, para ello, podemos diseñar una función recursiva de la siguiente forma:

void funcion(int n) {
    if(n == 1) {
        cout << 1 << ' ';
    } else if (n > 1){
        funcion(n - 1);
        cout << n << ' ';
    }
}

Por ejemplo, con la llamada funcion(3), una posible traza del call stack sería:

Paso Llamada Estado del stack Acción Texto en pantalla
1 funcion(3) funcion(3) Llamada inicial
2 funcion(2) funcion(3) → funcion(2) func(3) llama a func(2)
3 funcion(1) funcion(3) → funcion(2) → funcion(1) func(2) llama a func(1).
Caso base 1
4 Se vuelve a la llamada funcion(2) funcion(3) → funcion(2) Imprime en el camino atrás 1 2
5 Se vuelve a llamada inicial funcion(3) Imprime 1 2 3
6 El stack se vacía 1 2 3

Fibonacci

Queremos obtener el numero en la posición n dentro la secuencia de Fibonacci, esto lo podemos conseguir teniendo en cuenta lo siguiente:

int fibonacci(int n) {
    if (n <= 1) return n;
    return fibonacci(n-1) + fibonacci(n-2); 
}

Por ejemplo, con la llamada fibonacci(4);, obtenemos el siguiente call stack:

Paso Llamada Estado del stack Acción
1 fibonacci(4) fibonacci(4) Llamada inicial
2 fibonacci(3) fibonacci(4) → fibonacci(3) fib(4) llama a fib(3)
3 fibonacci(2) fibonacci(4) → fibonacci(3) → fibonacci(2) fib(3) llama a fib(2)
4 fibonacci(1) fibonacci(4) → fibonacci(3) → fibonacci(2) → 1 + fibonacci(0) fib(2) llama a fib(1).
Se llega al caso base
5 fibonacci(0) fibonacci(4) → fibonacci(3) → fibonacci(2) → 1 + 0 fib(2) llama a fib(0).
Se llega a caso base
6 fibonacci(1) fibonacci(4) → fibonacci(3) → 1 + 1 fib(3) llama a fib(1).
Se llega a caso base
7 fibonacci(2) fibonacci(4) → 2 + fibonacci(2) fib(4) llama a fib(2)
8 fibonacci(1) fibonacci(4) → 2 + (fibonacci(2) → 1 + fibonacci(0)) fib(2) llama a fib(1).
Caso base
9 fibonacci(0) fibonacci(4) → 2 + (fibonacci(2) → 1 + 0) fib(2) llama a fib(0).
Caso base
10 fibonacci(4), el inicial fibonacci(4) → 2 + 1 Resultado de fib(4) = 3
11 El stack se vacía

Factorial

Para calcular el factorial de un número debemos considerar:

long long fac(long long a) {
    if(a == 0) return 1;
    return a * fac(a - 1);
}

Por ejemplo, con la llamada fac(4);, podemos obtener una traza del call stack similar a esta:

Paso Llamada Estado del stack Acción
1 fac(4) fac(4) Llamada inicial
2 fac(3) fac(4) → 4 * fac(3) fac(4) llama a fac(3)
3 fac(2) fac(4) → 4 * 3 * fac(2) fac(3) llama a fac(2)
4 fac(1) fac(4) → 4 * 3 * 2 * fac(1) fac(2) llama a fac(1)
5 fac(0) fac(4) → 4 * 3 * 2 * 1 * fac(0) fac(1) llama a fac(0)
6 Se vuelve a la llamada inicial fac(4) → 24 Combinar soluciones

DFS

Depth-First Search es un algoritmo de exploración recursivo de grafos basado en llegar hasta el final del camino, para después explorar el resto de ramas que se han ido encontrando. Veamos el ejemplo:

Dado el grafo:

 0
/ \
1  2 - 4
|   
3   
|
5

Y el código:

void DFS(int node, vector<vector<int>>& adj, vector<bool>& visited) {
    // Marcar nodo como visitado
    visited[node] = true;
    doSomething(node);
    
    // Visitar recursivamente los nodos adyacentes a este
    for (int neighbor : adj[node]) {
        if (!visited[neighbor]) {
            DFS(neighbor, adj, visited);
        }
    }
}

// node: nodo actual del grafo
// adj: Lista de adyacencia, utilizada para representar el grafo, se verá más en el capítulo de grafos
// visited: Array auxiliar para marcar los caminos visitados

Podemos obtener la siguiente traza con la llamada DFS(0, adj, visited):

Nodo actual: 0 - Llamada #0
    - visited[0] = true;
    - adj[0] = 1, 2
    
Nodo: 1 - #1
    - visited[1] = true;
    - adj[1] = 3
    
Nodo: 3 - #2
    - visited[3] = true;
    - adj[3] = 5
    
Nodo: 5 - #3
    - visited[5] = true;
    - adj[5] = none
Ahora volvemos atrás, a la llamada 0, ya que nos queda por visitar 2,
que se ha quedado en el call stack esperando a ser usado
    
Nodo: 2 - #4
- visited[2] = true;
- adj[2] = 4

Nodo: 4 - #5
    - visited[4] = true;
    - adj[4] = none;
Como ya hemos acabado todo, el call stack se vacía y todos los nodos han sido
visitados.