CP-UPV Bootcamp - Capítulo 10

Capítulo 10: Programación dinámica

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 programación dinámica es una de las estrategias más comunes de resolución de problemas en programación competitiva, principalmente debido a la eficiencia que puede llegar a proporcionar si es utilizada de manera correcta al aprovechar la superposición de subproblemas y las soluciones calculadas. Estos problemas, en general, buscan una optimización (camino más corto, forma más óptima de hacer x, etc.) y cumplen el principio de optimalidad, esto es, el problema posee una solución óptima que contiene soluciones óptimas para todas las sub-instancias de este problema.

Esto quiere decir que, si somos capaces de dividir de manera apropiada nuestro problema en pequeños sub-problemas y somos capaces de encontrar una solución eficiente a estos sub-problemas, y su relación con la solución del problema general, podemos solucionar aplicando programación dinámica nuestro problema.

Este tema es especialmente complicado, quizás el más difícil del bootcamp. Por eso requiere completar muchos problemas para entender bien el concepto.

Índice de Contenidos

Estrategias

Top-down (Memoización)

Basado en dirigirse primero a las sub-instancias del problema para después formar la solución del problema utilizando las soluciones previamente calculadas. Este enfoque es bastante usado en el contexto de la recursión, especialmente cuando dichas sub-instancias se solapan, ya que, si somos capaces de guardar las soluciones a medida que las vamos calculando, nos podemos ahorrar una parte considerable de los cálculos al no tener que calcular la solución de las sub-instancias solapadas, optimizando así el tiempo de ejecución.

En resumen: el enfoque top-down se basa en separar el problema en subproblemas, si el subproblema se ha resuelto, reutiliza la solución, en otro caso, resuelve el subproblema y almacena el resultado en memoria(generalmente, un array).

Es crucial tener en cuenta cuando diseñamos una solución con este enfoque el posible espacio adicional para las soluciones de los subproblemas y la definición de las sub-instancias.

Bottom-up (Tabulación)

A diferencia del enfoque Top-down, la tabulación actúa comenzando por el sub-problema más pequeño, para ir construyendo la solución final. Para ello, se irán guardando los resultados de los sub-problemas ya resueltos en una tabla, para así evitar cálculos redundantes que incrementarían el coste de nuestro algoritmo. Como podemos observar, ambos enfoques son similares en el hecho de almacenar soluciones para construir la solución, pero este segundo, por ejemplo, es mucho más activo y requiere de un paso adicional: elegir previamente el orden exacto en el que se realizarán los cálculos, ya que de otra forma esta estrategia no tendría efecto alguno. Además, este tipo de algoritmo suele implementarse generalmente de manera iterativa, ahorrando así los posibles problemas que nos pueda causar la recursividad.

Esta forma de afrontar un problema de programación dinámica suele estar más relacionada con problemas más complejos, que envuelvan operaciones más complicadas o que cuyas soluciones a los sub-problemas no se solapen.

Ejemplos

Qué mejor para entender cómo aplicar programación dinámica que varios ejemplos? Pasemos a ver unos cuantos de ellos:

Fibonacci

Primero de todo, veremos la implementación recursiva de este algoritmo, como ya sabemos, no es muy eficaz, y no nos podría servir en una competición de programación.

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

A continuación, utilizando memoización o tabulación, podemos crear dos implementaciones bastantes más eficientes que la anterior:

long long fib_mem(int n, vector<long long> &memo) {
    if (memo[n] != -1)
        return memo[n];
    if (n <= 1)
        memo[n] = n;
    else
        memo[n] = fib_mem(n - 1, memo) + fib_mem(n - 2, memo);
    return memo[n];
}

int main() {
    int n;
    cin >> n;
    vector<long long> memo(n + 1, -1);
    cout << fib_mem(n, memo);
}

En esta ocasión, el enfoque top-down sería crear un array auxiliar que utilizaremos para ir guardando el valor del $i$-ésimo número de la secuencia de Fibonacci en su correspondiente casilla del array. De esta forma, podemos aprovechar ese solapamiento entre un número en la secuencia y sus dos anteriores para ahorrarnos cálculos innecesarios. Además, si guardamos las soluciones calculadas, en ciertos problemas podremos mejorar aún más la eficiencia del algoritmo, ya que para más queries, la solución ya estará calculada.

int fibonacci(int n) {
    if (n <= 1) return n;
    else {
        std::vector<int> table(n + 1, 0);
        table[0] = 0;
        table[1] = 1;
        for (int i = 2; i <= n; i++) {
            table[i] = table[i - 1] + table[i - 2];
        }
        return table[n];
    }
}

Si utilizamos la estrategia de tabulación, podemos llegar a la conclusión de que, si llenamos el array de manera iterativa, desde la posición 0 a la que consideremos necesaria, con los números de la secuencia de Fibonacci, calculados de la forma apropiada($a_n=a_{n-1}+a_{n-2}$), podemos tener construida una tabla(en este caso, un array), que tiene almacenadas las soluciones a todos los sub-problemas($n^{th}$ número de la secuencia).

Subsecuencia Común más Larga (Longest Common Subsequence)

Dadas dos strings, nuestra tarea es encontrar la subsecuencia común con la mayor longitud posible.

La subsecuencia común más larga (en inglés, Longest Common Subsequence, o LCS) entre dos secuencias ($a$ y $b$) es la subsecuencia más larga que aparece en ambos conjuntos de forma ordenada, pero no necesariamente consecutiva. Es decir, para dos arrays de elementos $a$ y $b$, eliminar el mínimo número de elementos cualesquiera de los arrays tal que $a = b$ y $longitud(a)$ sea máxima.

Por ejemplo:

string s1 = “ABCDEF”, s2 = "ACBDEF";
cout << longestCommonSubsequence(s1, s2);
> ABDEF o ACDEF

Una solución que se nos podría ocurrir sería comenzar por un extremo e ir comparando de manera recursiva hacia el otro extremo de los strings en búsqueda de esas subsecuencias y sus longitudes, pero eso podría conllevar costes demasiado elevados si las strings que nos proporcionan son de mayor longitud. La complejidad de este algoritmo sería de $O(2^{min(m,n)})$.

int lcs(string &S1, string &S2, int m, int n){
    // Caso base: una string está vacía
    if (m == 0 || n == 0)
        return 0;

    if (S1[m - 1] == S2[n - 1])
        // Incluir char en la cuenta y continuar recursivamente
        return 1 + lcs(S1, S2, m - 1, n - 1);

    else {
        // Si los dos últimos carácteres no concuerdan:
        // 1. Excluir el último carácter de S1
        // 2. Excluir el último carácter de S2 
        // Usar el maximo de ambas llamadas
        return max(lcs(S1, S2, m, n - 1), lcs(S1, S2, m - 1, n));
    }
}

int main() {
    string S1 = "AGGTAB";
    string S2 = "GXTXAYB";
    int m = S1.size();
    int n = S2.size();

    cout << lcs(S1, S2, m, n);

    return 0;
}

Por otra parte, si nos fijamos más en el problema, podemos darnos cuenta de que en ciertas ocasiones se pueden llegar a realizar comparaciones adicionales. Por ejemplo:

string s1 = "AXYT", s2 = "AYZX";

En este, se comprobaría el subproblema conformado por s1 = “AXY” y S2 = “AYZ” múltiples veces, cosa que no es eficiente. Entonces, sabiendo que las soluciones se solapan, podemos crear un array para almacenar esas posiciones:

// Devuelve longitud de subsecuencia
int lcs(string &S1, string &S2, int m, int n, vector<vector<int>> &memo) {

    // Caso Base
    if (m == 0 || n == 0)
        return 0;

    // Ya existe en la tabla
    if (memo[m][n] != -1)
        return memo[m][n];

    // Match
    if (S1[m - 1] == S2[n - 1])
        return memo[m][n] = 1 + lcs(S1, S2, m - 1, n - 1, memo);

    // No match
    return memo[m][n] = max(lcs(S1, S2, m, n - 1, memo), lcs(S1, S2, m - 1, n, memo));
}

int main() {
    string S1 = "AGGTAB";
    string S2 = "GXTXAYB";

    int m = S1.length();
    int n = S2.length();
    vector<vector<int>> memo(m + 1, vector<int>(n + 1, -1));

    cout << "Length of LCS is " << lcs(S1, S2, m, n, memo) << endl;

    return 0;
}

Otra forma de hacerlo es utilizando tabulación, podríamos crear un array vacío e ir llenando con el resultado de nuestros cálculos.

int lcs(string &S1, string &S2) {
    int m = S1.size();
    int n = S2.size();

    // Initializando tabla (m+1)*(n+1) con 0
    vector<vector<int>> dp(m + 1, vector<int>(n + 1, 0));

    // Llenando tabla
    for (int i = 1; i <= m; ++i) {
        for (int j = 1; j <= n; ++j) {
            if (S1[i - 1] == S2[j - 1])
                dp[i][j] = dp[i - 1][j - 1] + 1;
            else
                dp[i][j] = max(dp[i - 1][j], dp[i][j - 1]);
        }
    }

    // dp[m][n] contiene longitud de subsecuencia común más larga
    return dp[m][n];
}

int main() {
    string S1 = "AGGTAB";
    string S2 = "GXTXAYB";
    cout << "Length of LCS is " << lcs(S1, S2) << endl;

    return 0;
}

Subsecuencia Creciente más Larga (Longest Increasing Subsequence)

Basado en encontrar la secuencia creciente más larga en una lista, existen diversas formas de solucionar este problema, ya sea utilizando recursión, búsqueda binaria o programación dinámica.

La subsecuencia creciente más larga (en inglés, Longest Increasing Subsequence, o LIS) es la subsecuencia más larga en una secuencia de elementos que es estrictamente creciente. Es decir para una secuencia $a=[a_1, a_2, a_3, \dots, a_n]$ debemos quitar el mínimo número de elementos tal que $a$ sea creciente y su longitud sea la máxima.

Si la secuencia es: $a=[10,22,9,33,21,50,41,60,80]$, la subsecuencia creciente más larga es: $[10,22,33,50,60,80]$. La longitud de esta subsecuencia es $6$.

A diferencia de la subsecuencia común más larga (LCS), la subsecuencia creciente más larga se encuentra dentro de una sola secuencia.

Veamos un ejemplo de cómo podríamos hacerlo con solamente programación dinámica:

int lis(vector<int> const& a) {
    int n = a.size();
    vector<int> d(n, 1); // d[i] = longitud de LIS terminando en a[i]
    for (int i = 0; i < n; i++) {
        for (int j = 0; j < i; j++) {
            if (a[j] < a[i])
                d[i] = max(d[i], d[j] + 1);
        }
    }

    int ans = d[0];
    for (int i = 1; i < n; i++) {
        ans = max(ans, d[i]);
    }
    return ans;
}

Este ejemplo que utiliza memoización calcula usando un doble bucle la longitud de todas las subsecuencias crecientes, utilizando un array auxiliar que almacenará las longitudes que se calculen, para después buscar la mayor dentro del array. Pese a que es sencillo de implementar, no es tan eficiente como podríamos desear, teniendo un coste exponencial respecto al input. Por ello, debemos buscar una alternativa más eficiente, que puede ser, por ejemplo, utilizando una mezcla entre programación dinámica y búsqueda binaria:

Esta vez, el elemento del array auxiliar d[l] no corresponderá al elemento i del array ni a ningún prefijo, sino que será el elemento más pequeño en el que la subsecuencia creciente de longitud l termina, de esta forma, asignamos d[l] = a[i] solamente cuando no hay una secuencia creciente más larga que acabe en a[i], ni hay ninguna secuencia creciente de longitud l que acabe en un número más pequeño. La implementación de este algoritmo sería:

int lis(vector<int> const& a) {
    int n = a.size();
    const int INF = 1e9;
    // d[i] almacena el mínimo último elemento de una subsecuencia 
    // de longitud i + 1
    vector<int> d(n+1, INF);
    d[0] = -INF;

    for (int i = 0; i < n; i++) {
        int l = upper_bound(d.begin(), d.end(), a[i]) - d.begin();
        if (d[l-1] < a[i] && a[i] < d[l])
            d[l] = a[i];
    }

    int ans = 0;
    for (int l = 0; l <= n; l++) {
        if (d[l] < INF)
            ans = l;
    }
    return ans;
}

De esta forma, logramos conseguir optimizar el algoritmo y pasar de un coste exponencial a un coste linearitmico($O(n \log n)$).

Knapsack (El Algorítmo de la Mochila)

Para este problema, nos basaremos en el problema de 0/1 knapsack, que es la versión más básica del problema, existen otras cuyas soluciones se pueden obtener partiendo de la que obtenemos en este problema

Dados N elementos donde cada uno tiene un peso wt[i] y un valor asociado val[i], y dada una mochila con capacidad para un peso W, tenemos el objetivo de meter tantos elementos en la mochila como podamos, maximizando el valor que estos elementos tengan en total.

Podemos enfrentar este problema con un algoritmo recursivo que considere todos los subsets de elementos, calcular el peso y valor total de estos y comprobar de entre los que tengan peso menor a W, el que más valor tenga:

int knapSack(int W, int wt[], int val[], int n) {
    // Caso base
    if (n == 0 || W == 0)
        return 0;

    // Si el peso supera el límite, no incluir
    if (wt[n - 1] > W)
        return knapSack(W, wt, val, n - 1);

    // Devolver dependiendo de si añadimos o no el elemento
    else
        return max(knapSack(W, wt, val, n - 1), 
        val[n - 1] + knapSack(W - wt[n - 1], wt, val, n - 1));
}

int main() {
    int profit[] = { 30, 180, 120 };
    int weight[] = { 90, 10, 60 };
    int W = 100;
    int n = sizeof(profit) / sizeof(profit[0]);
    cout << knapSack(W, weight, profit, n);
    return 0;
}

Este algoritmo tiene una complejidad temporal de $O(2^N)$, que no es lo que buscamos, por ende, intentemos solucionarlo de otra forma, tal vez mediante tabulación:

Crearemos una matriz en la que las columnas representan la capacidad de la mochila en orden ascendente, y las filas serán el índice del elemento que queremos añadir.

Imaginemos que tenemos tres elementos $e1: w=1, v=20, e2: w=2, v=15, e3: w=3, v=25$ y una mochila con una capacidad máxima de $3$.

0 1 2 3
0 0 0 0 0
1 0 20 20 20
2 0 20 20 35
3 0 20 20 35

Debemos ordenar las filas por pesos de menor a mayor

LLenamos la matriz de la siguiente forma, para cada casilla de la matriz, comprobaremos cuál es el valor máximo que se puede almacenar, si cabe lo mismo que en la casilla anterior, o se puede añadir un elemento que sumando su valor nos da un valor mayor.

int knapSack_tab(int W, int wt[], int val[], int n) {
    // Crear una tabla de (n+1) x (W+1)
    vector<vector<int>> dp(n + 1, vector<int>(W + 1, 0));

    // Construir la tabla de forma iterativa
    for (int i = 1; i <= n; i++) {
        for (int w = 1; w <= W; w++) {
            if (wt[i - 1] <= w)
                dp[i][w] = max(val[i - 1] + dp[i - 1][w - wt[i - 1]], dp[i - 1][w]);
            else
                dp[i][w] = dp[i - 1][w];
        }
    }

    // dp[n][W] contiene el valor máximo que se puede obtener
    return dp[n][W];
}

int main() {
    int profit[] = {30, 180, 120};
    int weight[] = {90, 10, 60};
    int W = 100;
    int n = sizeof(profit) / sizeof(profit[0]);
    cout << knapSack_tab(W, weight, profit, n);
    return 0;
}

Distancia de Levenshtein

La Distancia de Levenshtein (o edit distance) es el número mínimo de operaciones necesarias para transformar una cadena en otra. Las operaciones permitidas son:

Ejemplo:

Supongamos que queremos transformar la cadena cpupv en puppy. La distancia de Levenshtein es 3:

c p u p v
0 1 2 3 4 5
p 1 1 1 2 3 4
u 2 2 2 1 2 3
p 3 3 2 2 1 2
p 4 4 3 3 2 2
y 5 5 4 4 3 3
int levenshtein(const string &s1, const string &s2) {
    int n = s1.size();
    int m = s2.size();
    vector<vector<int>> dp(n + 1, vector<int>(m + 1));
    for (int i = 0; i <= n; i++) dp[i][0] = i;
    for (int j = 0; j <= m; j++) dp[0][j] = j;
    
    for (int i = 1; i <= n; i++) {
        for (int j = 1; j <= m; j++) {
            if (s1[i - 1] == s2[j - 1]) dp[i][j] = dp[i - 1][j - 1];
            else dp[i][j] = 1 + min({
                dp[i - 1][j],    // eliminar
                dp[i][j - 1],    // insertar
                dp[i - 1][j - 1] // sustituir
            });
        }
    }
    return dp[n][m];
}

Esta solución utiliza tabulación para calcular la distancia de forma eficiente, evitando recálculos innecesarios mediante el almacenamiento de subresultados en la matriz dp.