Programación dinámica Problemas, algoritmos y programación 31 de Agosto 2011 Problemas, algoritmos y programación Programación dinámica Problema: diff El comando diff diff es un comando de Linux que muestra las diferencias en el contenido de dos textos (equivalente a fc.exe de Windows/DOS) ¿Cúando se usa? Se usa, entre otras cosas, para comparar ediciones (versiones) de un mismo archivo ¿Cómo compara? El criterio por defecto es buscar lı́neas comunes a ambos archivos. Problemas, algoritmos y programación Programación dinámica Problema: diff - Ejemplo Nueva versión Versión “original” /** * main class of diff program */ */ class Diff { /* calculate common lines */ int commonLines(File a, File b) { String contentA = read(a); //fixed String contentB = read(b); ParsedText pA = parse(contentA); ParsedText pB = parse(contentB); int result = LCS.calculate(pA, pB); return result; } } class Diff { int commonLines(File a, File b) { String contentA = read(b); String contentB = read(b); String[] pA = split(contentA); String[] pB = split(contentB); int result = LCS.calculate(pA, pB); return result; } } Referencia de colores lı́neas sin cambios lı́neas nuevas lı́neas eliminadas Problemas, algoritmos y programación Programación dinámica Problema: diff Operaciones de moficiación Sin operación : las lı́neas comunes Inserción : las lı́neas no comunes del nuevo Borrado : las lı́neas no comunes del “original” ¿Cuál es el problema? Para hacer una buena comparación de archivos, hay que tener un algoritmo que encuentre la mayor cantidad de lı́neas comunes entre ambos. Además, las lı́neas comunes tienen que aparecer con el mı́smo orden en ambos archivos. Problemas, algoritmos y programación Programación dinámica Problema: diff Generalización A este problema se lo conoce como encontrar la sub-secuencia común de mayor longitud (o Longest Common Subsequence) Longest Common Subsequence Entrada: dos sequencias (vectores, arreglos) A y B Salida: la longitud de la sub-secuencia común a A y a B mas larga posible LCS : (T [], T []) → int Ejemplos LCS([X AABBDZ ], [AX AC BDY ]) = 4 LCS([], [XXYY ]) = 0 LCS([123456], [123456]) = 6 LCS([♠♠♠♥], [♥♠♠♠]) = 3 Problemas, algoritmos y programación Programación dinámica Problema: diff - ¿Cómo se puede resolver? Más problemas Se calculan las LCS de todos los prefijos de A contra todos los prefijos de B Entre estos resultados está la LCS de A y B Si n y m son el tamaño de A y B, la cantidad de LCS que se van a calcular son (n + 1)(m + 1) Cada prefijo de A se puede identificar con su longitud, que es un número del 0 al n. De la misma forma, cada prefijo de B se identifica con los números del 0 al m. LCS2 : (int, int, T [], T []) → int Problemas, algoritmos y programación Programación dinámica Problema: diff - ¿Cómo se puede resolver? Ejemplo con los archivos Prefijo 8 /** * main class of diff program */ */ class Diff { /* calculate common lines */ int commonLines(File a, File b) { String contentA = read(a); //fixed String contentB = read(b); Prefijo 5 class Diff { int commonLines(File a, File b) { String contentA = read(b); String contentB = read(b); String[] pA = split(contentA); Algunos valores de LCS2 LCS2 (8, 5, nuevo, original) = 3 LCS2 (4, 1, nuevo, original) = 1 LCS2 (5, 0, nuevo, original) = 0 LCS2 (5, 1, nuevo, original) = 1 LCS2 (6, 2, nuevo, original) = 2 Problemas, algoritmos y programación Programación dinámica Problema: diff - ¿Cómo se puede resolver? Caso 1/3 - Base Alguno de los dos prefijos tiene longitud 0, entonces la LCS es 0 LCS2 (0, , , ) = 0 LCS2 ( , 0, , ) = 0 Problemas, algoritmos y programación Programación dinámica Problema: diff - ¿Cómo se puede resolver? Caso 2/3 - Terminan igual Los dos prefijos terminan con el mismo elemento, entonces hay una LCS para estos prefijos que termina en este elemento común. Ejemplo con los archivos Prefijo 6 /** * main class of diff program */ */ class Diff { /* calculate common lines */ int commonLines(File a, File b) { Prefijo 2 class Diff { int commonLines(File a, File b) { ¿Cuánto vale LCS2 ? A[i − 1] = B[j − 1] ⇒ LCS2 (i, j, A, B) = 1 + LCS2 (i − 1, j − 1, A, B) Problemas, algoritmos y programación Programación dinámica Problema: diff - ¿Cómo se puede resolver? Caso 3/3 - Terminan distinto Los dos prefijos terminan con distintos elementos, entonces hay una LCS para estos prefijos que no usa, al menos, una terminación. Ejemplo con los archivos Prefijo 6 /** * main class of diff program */ */ class Diff { /* calculate common lines */ int commonLines(File a, File b) { Prefijo 3 class Diff { int commonLines(File a, File b) { String contentA = read(b); ¿Cuánto vale LCS2 ? A[i − 1] 6= B[j − 1] ⇒ LCS2 (i, j, A, B) = LCS2 (i, j − 1, A, B) max LCS2 (i − 1, j, A, B) Problemas, algoritmos y programación Programación dinámica Problema: diff - ¿Cómo se puede resolver? Definición partida de LCS2 LCS2 (i, j, A, B) = 0 1 + LCS2 (i − 1, j − 1, A, B) LCS2 (i, j − 1, A, B) max LCS2 (i − 1, j, A, B) i =0∨j =0 A[i − 1] = B[j − 1] A[i − 1] 6= B[j − 1] LCS en base a LCS2 LCS(A, B) = LCS2 ( largo(A), largo(B), A, B ) Problemas, algoritmos y programación Programación dinámica Problema: diff - ¿Cómo se puede programar? Primeras consideraciones Si se dispone de la función LCS2 , la función LCS se programa muy simplemente La función LCS2 se puede programar recursivamente Los argumentos A y B de LCS2 son siempre los mismos Entonces LCS2 se puede programar como una función recursiva de dos variables (los prefijos i y j). Los arreglos A y B se pueden considerar como variables globales El caso base de la recursión es cuando i = 0 o j = 0 Problemas, algoritmos y programación Programación dinámica Problema: diff - ¿Cómo se puede programar? Una cosa más: el “arbol” de llamadas Para calcular LCS2 (i, j) se necesita el valor de LCS2 (i − 1, j), LCS2 (i, j − 1) y LCS2 (i − 1, j − 1). i − 3, j ··· i − 2, j i − 1, j i − 2, j − 1 ··· i − 1, j − 1 i, j i, j − 1 i − 1, j − 2 ··· i, j − 2 i, j − 3 ¡Hay múltiples llamadas iguales! La función LCS2 recorre todas las sub-sequencias comunes. No hacer la misma llamada dos veces es fundamental para resolver el problema con una complejidad temporal óptima Problemas, algoritmos y programación Programación dinámica Problema: diff - ¿Cómo se puede programar? Primer programa: recursión “memorizada” (memoization) T[] A, B; int mem[][]; int lcs(T[] a, T[] b) { A = a; B = b; int n = length(A), m = length(B); init(mem, n+1, m+1, -1); return lcs2(n, m); } int lcs2(int i, int j) { if( mem[i][j] == -1 ) { if( i == 0 || j == 0 ) { mem[i][j] = 0; } else if( A[i-1] == B[j-1] ) { mem[i][j] = 1 + lcs2(i - 1, j - 1); } else { mem[i][j] = max(lcs2(i - 1, j), lcs2(i, j - 1)); } } return mem[i][j]; } Observación Este programa calcula todos los valores de la función LCS2 en la matriz mem Problemas, algoritmos y programación Programación dinámica Problema: diff - ¿Cómo se puede programar? Recursión vs. Cálculo de valores de la matriz mem 0, 0 0, 1 0, 2 0, 3 0, 4 0, 5 0, 6 ··· 0, m 1, 0 1, 1 1, 2 1, 3 1, 4 1, 5 1, 6 ··· 1, m 2, 0 2, 1 2, 2 2, 3 2, 4 2, 5 2, 6 ··· 2, m . . . . . . . . . . . . . . . . . . . . . n, 0 n, 1 n, 2 n, 3 n, 4 n, 5 n, 6 Recursión (“dependencia”) . .. ··· . . . n, m Cálculo de mem i − 1, j − 1 i − 1, j 0, 0 0, m i, j − 1 i, j n, 0 n, m Problemas, algoritmos y programación Programación dinámica Problema: diff - ¿Cómo se puede programar? Observación Se puede eliminar la recursión calculando los valores de mem directamente. El orden en que se vayan calculando los valores debe satisfacer todas las “dependencias”. Segundo programa: sin recursión int mem[][]; int lcs(T[] A, T[] B) { int n = length(A), m = length(B); init(mem, n+1, m+1, -1); for(i : 0 ... n) { for(j : 0 ... m) { if( i == 0 || j == 0 ) { mem[i][j] = 0; } else if( A[i-1] == B[j-1] ) { mem[i][j] = 1 + mem[i - 1][j - 1]; } else { mem[i][j] = max(mem[i - 1, j], mem[i, j - 1]); } } } return mem[n][m]; } Problemas, algoritmos y programación Programación dinámica Problema: diff - ¿Cómo se puede programar? Segundo programa: sin recursión La complejidad temporal es O(n.m) La complejidad espacial es O(n.m). ¿Se puede hacer mejor? Se puede hacer en complejidad espacial O(n + m) (óptimo). Observación: La comparación de elementos La complejidad temporal O(n.m) asume que la comparación de elementos es O(1) (constante) Si son elementos complejos se puede hacer un hashing inicial de los mismos para facilitar la comparación Observación: Obtener la sub-sequencia El algoritmo presentado calcula sólo la longitud, pero siguiendo la tabla de atrás hacia adelante se puede obtener una sub-sequencia común de mayor longitud Problemas, algoritmos y programación Programación dinámica Programación dinámica Introducción “Programación Dinámica” es una técnica para resolver cierto tipo de problemas (como LCS) Problemas, algoritmos y programación Programación dinámica Programación dinámica: Definiciones Problema Un problema define un valor buscado (resultado) sobre ciertos parámetros genéricos (entrada) Ejemplo: LCS Entrada: Dos arreglos Resultado: La longitud de la sub-secuencia común mas larga Ejemplo: Ordenamiento Entrada: Un arreglo Resultado: Un arreglo con los elementos ordenados Ejemplo: SAT Entrada: Un sistema de ecuaciones booleanas Resultado: La satisfacibilidad del sistema Problemas, algoritmos y programación Programación dinámica Programación dinámica: Definiciones Instancia de un problema Es un problema con parámetros de entrada concretos (no genéricos). Toda instancia tiene un resultado definido. Ejemplo: LCS LCS de “XAABBDZ” y “AXACBDY” → 4 LCS de “123456” y “123456” → 6 Ejemplo: Ordenamiento Ordernar: [“perro”,“casa”,“gato”] → [“casa”,“gato”,“perro”] Ordernar: [0, 3, 0, 3, 4, 5, 6] → [0, 0, 3, 3, 4, 5, 6] Ejemplo: SAT a ∨ ¬b ¿El sistema es satisfacible? → Sı́ b ∧ (¬a ⇒ b) Problemas, algoritmos y programación Programación dinámica Programación dinámica: Definiciones Problema recursivo En un problema recurisvo el resultado de una instancia se puede obtener en base a otros resultados de instancias “mas chicas“ del mismo problema (sub-problemas / sub-instancias) Ejemplo: LCS El resultado se puede obtener combinando resultados de los prefijos. Ejemplo: Ordenamiento El resultado se puede obtener encontrando el menor, intercambiarlo con el primero y ordenando el resto (Selection sort) El resultado se puede obtener ordenando dos mitades del arreglo y combinando ambos resultados (Merge sort) Problemas, algoritmos y programación Programación dinámica Programación dinámica: Aplicaciones Sub-problemas La programación dinámica es útil si la cantidad de sub-problemas es abaracable computacionalmente Ejemplos: LCS y Ordenamiento Mal ejemplo: SAT (por algo es NP completo) Sub-problemas superpuestos La programación dinámica es útil si distintos sub-problemas se pueden resolver en base a sub-sub-problemas comunes Ejemplo: LCS (ası́ se ve en el “arbol de llamadas”) Mal ejemplo: Merge sort Principio de optimalidad La solución de un problema esta formada por soluciones de sub-problemas (sub-soluciones) Problemas, algoritmos y programación Programación dinámica Programación dinámica: Aplicaciones Principio de optimalidad - Ejemplos Camino mı́nimo: Si el camino mas corto de A a B pasa por C, ese camino está formado por un camino mı́nimo de A a C y otro de C a B LCS: La solución contiene pedazos que son LCS de sus prefijos Principio de optimalidad y recursión Un problema que cumpla con el principio de optimalidad se puede abaracar como problema recursivo sub-problemas Problemas, algoritmos y programación sub-soluciones Programación dinámica Programación dinámica: Resumen Problemas Con sub-problemas Que cumplan el principio de optimalidad Superpuestos Algoritmos Enfoque recursivo Inducción / Inducción estructural Divide & Conquer Programación Memoization (función recursiva memorizada) Llenado de tabla Problemas, algoritmos y programación Programación dinámica Problema: Distancia de edicón (Edit Distance) Problema Encontrar la menor cantidad de ediciones para llegar de una palabra a otra. Operaciones de edición Eliminación de una letra Inserción de una letra Substitución de una letra Ejemplo: gato → blanco gato (g → b) bato (+ n) banto (+ l) blanto (t → c) blanco La distancia de edición es 4 Problemas, algoritmos y programación Programación dinámica Problema: Distancia de edicón (Edit Distance) Variantes del problema Las variantes del problema se dan al cambiar las operaciones posibles Solo con eliminación e inserción se reduce a LCS Eliminación, inserción y substitución: distancia de Levenshtein Agregando “transposición” de letras: distancia de Levenshtein-Damerau Todas esta variantes se pueden resolver usando programación dinámica Usos comunes Corregir errores de tipeo Detección de fraude Encontrar variaciones en ADN Problemas, algoritmos y programación Programación dinámica Problema: Distancia de edicón (Edit Distance) Edit distance Entrada: dos cadenas A y B Salida: la distancia de Levenshtein entre A y a B LEV : (String , String ) → int Una solución con programación dinámica Enfoque similar al usado en LCS Se calculan las distancias de todos los prefijos de A a todos los prefijos de B LEV2 : (int, int, String , String ) → int Problemas, algoritmos y programación Programación dinámica Problema: Distancia de edicón (Edit Distance) Determinar LEV2 (similar a LCS2 ) Caso base: alguno de los prefijos tiene longitud 0, la distancia es la longitud del otro Terminan igual: lo óptimo es que esas letras se “correspondan” y no se haga ninguna operación al final de A LEV ( “gato”, “blanco” ) = LEV ( “gat”, “blanc” ) Terminan distinto: se tiene que aplicar alguna operación al final de A LEV ( “gat”, “blanc” ) es el mı́nimo de: (insertar c) 1 + LEV ( “gatc”, “blanc” ) (insertar c) 1 + LEV ( “gat”, “blan” ) (borrar t) 1 + LEV ( “ga”, “blanc” ) (t → c) 1 + LEV ( “gac”, “blanc” ) (t → c) 1 + LEV ( “ga”, “blan” ) Problemas, algoritmos y programación Programación dinámica Problema: Distancia de edicón (Edit Distance) Definición partida de LEV2 LEV2 (i, j, A, B) = i +j LEV2 (i − 1, j − 1, A, B) i =0∨j =0 A[i − 1] = B[j − 1] LEV2 (i, j − 1, A, B) LEV2 (i − 1, j, A, B) 1 + min LEV2 (i − 1, j − 1, A, B) Problemas, algoritmos y programación A[i − 1] 6= B[j − 1] Programación dinámica Problema: Distancia de edicón (Edit Distance) Posible implementación (sin recursión) int mem[][]; int lev(T[] A, T[] B) { int n = length(A), m = length(B); init(mem, n+1, m+1, -1); for(i : 0 ... n) { for(j : 0 ... m) { if( i == 0 || j == 0 ) { mem[i][j] = i + j; } else if( A[i-1] == B[j-1] ) { mem[i][j] = mem[i - 1][j - 1]; } else { mem[i][j] = 1 + min(mem[i - 1, j], mem[i, j - 1], mem[i - 1, j - 1]); } } } return mem[n][m]; } Observaciones El orden de llenado satisface las “dependencias” Complejidad temporal: O(n.m), espacial: O(n.m) Se puede lograr complejidad espacial O(n + m) Problemas, algoritmos y programación Programación dinámica Otros problemas de cadenas Transformar en palı́ndromo Problema: Dada una cadena, encontrar la mı́nima cantidad de ediciones para transformarla en palı́ndromo (capicúa) Sub-problemas a considerar: todas las sub-cadenas (recortes) de la original. Complejidad temporal: O(n2 ) Compresión RLE (run-length encoding) Problema: Dada una cadena, encontrar una compresión RLE de tamaño mı́nimo. Un ejemplo de compresión RLE de la cadena “XAABCBCBCAABCBCBCX” es “X2(2A3(BC))X”. Sub-problemas a considerar: todas las sub-cadenas (recortes) de la original. También hay que detectar cuales sub-cadenas son “repeticiones”. AABCBCBCAABCBCBC → 2(AABCBCBC) Complejidad temporal: O(n3 ) Problemas, algoritmos y programación Programación dinámica Problema: Mayor sub-sequencia creciente Descripción Dada una sequencia de números, encontrar el máximo largo de una sub-sequencia con sus elementos en orden estrictamente creciente LIS : (int[]) → int Ejemplos LIS([0, 8, 4, 12, 2, 10, 6, 14, 6, 9, 5]) → 4 LIS([2, 3, 7, 10, 15]) → 5 LIS([]) → 0 LIS([15, 10, 7, 3, 2]) → 1 Solución O(n2 ) usando LCS Una sub-sequencia creciente de A es una sub-sequencia ~~ ~ ~ LIS(A) = LCS(A, A) común entre A y A ordenada (A). Problemas, algoritmos y programación Programación dinámica Problema: Mayor sub-sequencia creciente Considerando otros sub-problemas y sub-soluciones De todas las sub-secuencias de un mismo tamaño siempre es “más útil” la que termina en lo menor posible Para cada prefijo Ai y cada tamaño j, se busca la terminación “óptima” de una sub-sequencia creciente. j ∈ [1, LIS(Ai )] Si Ti [j] son estos valores, entonces Ti es creciente y su tamaño es LIS(Ai ) Definición recursiva de T [i] T1 [1] = A[0] (caso base) Todos los valores de Ti+1 son iguales a los de Ti excepto: Si A[i] ≤ Ti [1] ⇒ Ti+1 [1] = A[i] Si A[i] > Ti [LIS(Ai )] ⇒ Ti+1 [LIS(Ai ) + 1] = A[i] Si Ti [j] < A[i] ≤ Ti [j + 1]] ⇒ Ti+1 [j + 1] = A[i] El valor que cambia se puede encontrar con búsqueda binaria Problemas, algoritmos y programación Programación dinámica Problema: Mayor sub-sequencia creciente Posible implementación int lis(int[] A) { if( length(A) == 0 ) return 0; int[] T = [ A[0] ]; for(i : 1 ... length(A)) { if ( A[i] <= T[0] ) { T[0] = A[i]; } else { int j = binary_search(T, A[i]); if(j == length(T) - 1) { T.add(A[i]); } else { T[j+1] = A[i]; } } } return length(T); } // mayor j tal que T[j] < A[i] Observaciones Complejidad temporal: O(n.log (n)) Complejidad espacial: O(n) Se puede aplicar sobre cualquier conjunto con orden total Problemas, algoritmos y programación Programación dinámica Problema: Cálculo de probabilidades Calcular la problabilidad de: tirar un dado 6 veces y sumar 20 tirar una moneda 20 veces y sacar 11 caras Algunos cálculos de probabilidad se pueden plantear recursivamente Ejemplo La probabiliad de tirar un dado 6 veces y sumar 20 es la suma de + 1/6× la probabilidad de tirar un dado 5 veces y sumar 19 + 1/6× la probabilidad de tirar un dado 5 veces y sumar 18 + 1/6× la probabilidad de tirar un dado 5 veces y sumar 17 + 1/6× la probabilidad de tirar un dado 5 veces y sumar 16 + 1/6× la probabilidad de tirar un dado 5 veces y sumar 15 + 1/6× la probabilidad de tirar un dado 5 veces y sumar 14 Problemas, algoritmos y programación Programación dinámica Problema: Cálculo de probabilidades Generalización Los problemas: tirar un dado 6 veces y sumar 20 tirar una moneda 20 veces y sacar 11 caras se pueden generalizar a calcular la probabilidad de sumar s tirando n veces un dado de d caras. P(s, n, d) → [0, 1] Definición recursiva P(0, 0, ) = 1 y P( , 0, ) = 0 P P(s, n, d) = di=1 d1 P(s − i, n − 1, d) Observaciones Hay sub-problemas superpuestos d es siempre el mismo Complejidad temporal: O(s.n.d) y espacial: O(s.n) Problemas, algoritmos y programación Programación dinámica Programación dinámica y juegos Juegos Algunos juegos pueden ser analizados con programación dinámica. En especial aquellos que sean: por turnos finitos y sin empate ¿Qué se puede analizar? Si un estado del juego es ganador o perdedor Si existe alguna estrategia ganadora En cuántas jugadas termina ¿Cómo se pueden analizar estos juegos recursivamente? Los estados terminales del juego son los casos bases Un estado no terminal es ganador si existe una jugada que lleva a un estado perdedor (no ganador) Problemas, algoritmos y programación Programación dinámica Programación dinámica sobre subconjuntos Problema: el viajante de comercio Entrada: un conjunto de n ciudades y los costos cij de viajar de la ciudad i a la j. 0 ≤ i, j < n Salida: el costo del itinerario mas barato que visita todas las ciudades una sola vez C (int, int[][]) → int Sub-problemas El itinerario más barato para todos los sub-conjuntos de ciudades (sub-grafo inducido) y todas sus terminaciones. La cantidad de sub-grafos es 2n La cantidad de sub-problemas es O(2n .n) C2 (int, int[][], SubConjunto, int) → int C (n, c) = min0≤i<n {C2 (n, c, S, i)} siendo S el sub-conjunto de todas las ciudades (impropio) Problemas, algoritmos y programación Programación dinámica Programación dinámica sobre subconjuntos Definición recursiva de C2 C2 ( , , {t}, t) = 0 C2 (n, c, S, t) = mini∈S−{t} {c[i][t] + C2 (n, c, S − {t}, i)} Manipulación de sub-conjuntos (máscara de bits) Los sub-conjuntos de un conjunto de n elementos se pueden asociar, uno a uno, con los números del 0 al 2n El sub-conjunto S se representa con el número P m(S) = i∈S 2i El número s representa al sub-conjunto S = {i ∈ [0, n − 1]/biti (s) = 1} Si T ⊂ S ⇒ m(T ) < m(S) Si t ∈ S ⇔ (m(S)&2t ) = 2t (& es el “and” de bits) Si t ∈ S ⇒ m(S − {t}) = m(S) − 2t Problemas, algoritmos y programación Programación dinámica Programación dinámica sobre subconjuntos Nueva definición recursiva de C2 C2 (int, int[][], int, int) → int C2 ( , , 2t , t) = 0 C2 (n, c, m, t) = minm&2i =2i ∧i6=t {c[i][t] + C2 (n, c, m − 2t , i)} Dependencias de C2 El valor de C2 (n, c, m(S), t) depende de los valores de C2 para todos los sub-conjuntos de S Los sub-conjuntos de S se representan con un número menor a m(S) C2 se puede calcular en orden creciente de m(S) Problemas, algoritmos y programación Programación dinámica Programación dinámica sobre subconjuntos Posible implementación int C(int n, int[][] c) { int mem[1 << n][n]; // 1 << n = 2^n for(m : 1 ... (1 << n) ) { for(t : 0 ... (n - 1) ) { if(bit_count(m) == 1) { mem[m][t] = 0; } else { mem[m][t] = INF; for(i : 0 ... (n - 1)) if ( (m & (1 << i) == (1 << i)) && i != t) { mem[m][t] = min(mem[m][t], mem[m-(1<<t)][i]); } } } } int r = INF; for(i : 0 ... (n - 1)) r = min(r, mem[1<<n - 1][i]; return r; } Observaciones Complejidad temporal: O(2n .n2 ), espacial: O(2n .n) Es mejor que la fuerza bruta O(n!), para n = 25 es ∼ 1015 veces más rápido y necesita ∼ 1G de memoria. Problemas, algoritmos y programación Programación dinámica