Programação Dinâmica Fernando Lobo Algoritmos e Estrutura de Dados 1 / 56 Programação Dinâmica Outra técnica de concepção de algoritmos, tal como Divisão e Conquista. O termo Programação Dinâmica é um bocado infeliz. Programação sugere programação de computadores. Dinâmica sugere valores que mudam ao longo do tempo. A técnica de Programação Dinâmica não tem que ver com uma coisa nem outra. 2 / 56
O que é então a Programação Dinâmica? É uma técnica de resolução de problemas. A ideia é resolver subproblemas pequenos e armazenar os resultados. Esses resultados são depois utilizados para resolver subproblemas maiores (e armazenando novamente os resultados). E assim sucessivamente até se resolver o problema completo. 3 / 56 Comparação com Divisão e Conquista Semelhanças Para resolver um problema combinamos as soluções de subproblemas. Diferenças Divisão e Conquista é eficiente quando os subproblemas são todos distintos. Se tivermos que resolver várias vezes o mesmo subproblema, a Divisão e Conquista torna-se ineficiente. Com Programação Dinâmica cada subproblema é resolvido apenas uma vez. 4 / 56
Exemplos de Programação Dinâmica A melhor maneira de aprender Programação Dinâmica é ver alguns exemplos. Exemplo simples: Calcular o n-ésimo número da sequência de Fibonacci. F n = 0, se n = 0 1, se n = 1 F n 1 + F n 2, se n > 1 5 / 56 Pseudocódigo Fib-Rec(n) if n == 0 return 0 if n == 1 return 1 return Fib-Rec(n 1) + Fib-Rec(n 2) Este algoritmo é muito mau. Porquê? 6 / 56
Vejamos o que acontece com n = 5 7 / 56 Fibonacci: Algoritmo de Divisão e Conquista Estamos a calcular a mesma coisa várias vezes! Pode-se provar que F n+1 /F n 1+ 5 2 1.62 = F n > 1.6 n Qual a complexidade do algoritmo? Fn resulta da soma das folhas da árvores. Fn > 1.6 n = árvore tem pelo menos 1.6 n folhas. Logo, o algoritmo tem complexidade Ω(1.6 n ), o que é muito mau. Experimentem programá-lo e usar n = 50. 8 / 56
Fibonacci: Algoritmo de Divisão e Conquista No exemplo com n = 5, calculamos: F 4 1 vez F3 2 vezes F2 3 vezes F 1 5 vezes F0 3 vezes É trabalho desnecessário. Só deveríamos calcular cada F i uma e uma só vez. Podemos fazê-lo usando Programação Dinâmica. 9 / 56 Fibonacci: Algoritmo de Programação Dinâmica A ideia é resolver o problema de baixo para cima, começando pelos casos base e armazenando as soluções dos subproblemas. Fib-PD(n) F [0] = 0 F [1] = 1 for i = 2 to n F [i] = F [i 1] + F [i 2] return F [n] Complexidade temporal? Θ(n). Complexidade espacial? Θ(n). 10 / 56
Fibonacci: Algoritmo de Programação Dinâmica Para calcular F i basta ter armazenado as soluções dos dois subproblemas F i 1 e F i 2. Logo, podemos reduzir a complexidade espacial de Θ(n) para Θ(1). Fib-PD-v2(n) if n == 0 return 0 if n == 1 return 1 back2 = 0 back1 = 1 for i = 2 to n next = back1 + back2 back2 = back1 back1 = next return next 11 / 56 Outro exemplo: Coeficientes binomiais Ck n = ( ) n k = n! k!(n k)! ( n ) k é o número de combinações de n elementos k a k. Por palavras mais simples: número de maneiras distintas de escolher grupos de k elementos a partir de um conjunto de n elementos. Exemplo: Dado um conjunto de 10 alunos, quantos grupos distintos de 3 alunos se podem fazer? Resp: ( ) 10 3 = 10! 3!7! = 120 A aplicação directa da fórmula pode facilmente dar um overflow aritmético por causa dos factoriais, mesmo que o resultado final caiba perfeitamente num inteiro. 12 / 56
Coeficientes binomiais (cont.) Podemos definir ( n k) de modo recursivo. ( n ( k) = n 1 ) ( k 1 + n 1 ) k 1 a parcela: k-ésimo elemento pertence ao grupo é necessário escolher k 1 dos restantes n 1 elementos. 2 a parcela: k-ésimo elemento não pertence ao grupo é necessário escolher k dos restantes n 1 elementos. Casos base: k = 0, n = k ( n k) = 1 13 / 56 Algoritmo naive (de força bruta) Comb(n, k) if k == 0 or n == k return 1 else return Comb(n 1, k 1) + Comb(n 1, k) 14 / 56
Solução com Programação Dinâmica Comb-PD(n, k) for i = 0 to n for j = 0 to min(i, k) if j == 0 or j == i A[i, j] = 1 else A[i, j] = A[i 1, j 1] + A[i 1, j] return A[n, k] 15 / 56 Exemplo de execução: Comb-PD(5,3) i j 0 1 2 3 0 1 1 1 1 2 1 2 1 3 1 3 3 1 4 1 4 6 4 5 1 5 10 10 16 / 56
Memoization É uma técnica semelhante à Programação Dinâmica. Mantém o algoritmo recursivo na forma top-down. A ideia é inicializar as soluções dos subproblemas com o valor Desconhecido. Depois, quando queremos resolver um subproblema, verificamos primeiro se já foi resolvido. Se sim, retornamos a solução previamente armazenada. Se não, resolvemos o subproblema e armazenamos a solução. Cada subproblema só é resolvido uma vez. 17 / 56 Versão memoized de Comb Comb-Memoized(n, k) for i = 0 to n for j = 0 to min(i, k) C[i, j] = unknown return M-Comb(n, k) M-Comb(i, j) if C[i, j] == unknown if j == 0 or j == i C[i, j] = 1 else C[i, j] = M-Comb(i 1, j 1) + M-Comb(i 1, j) return C[i, j] 18 / 56
Outro exemplo: cortar tubos (rod cutting) Problema: Dado um tubo de comprimento n e uma tabela com preços p i para pedaços de tubo de comprimento i (i = 1, 2,.. n), determinar o valor máximo de receita r n que se pode obter se podermos cortar o tubo em pedaços e vendê-los separadamente, assumindo que os cortes são gratuitos e os comprimentos dos pedaços de tubo são números inteiros. Exemplo: n = 4 length i 1 2 3 4 price p i 1 5 8 9 19 / 56 8 maneiras de cortar 20 / 56
Melhor solução: cortar em 2 pedaços de comprimento 2. Receita total é 5 + 5 = 10. Para cada i = 1.. n 1, cortamos ou não cortamos = 2 n 1 maneiras de cortar o tubo. 21 / 56 Sub-estrutura óptima Vamos tentar definir a solução óptima em termos de soluções óptimas de subproblemas. Seja r i a receita máxima para um tubo de comprimento i. Então r n será o máximo de: p n r 1 + r n 1 r2 + r n 2... rn 1 + r 1 22 / 56
Uma decomposição mais simples Toda a solução óptima tem um pedaço mais à esq. (potencialmente de comprimento n no caso de não haver qualquer corte). A receita total vai ser o custo desse pedaço mais o custo da melhor receita que se consegue obter com cortes no pedaço de tubo que sobrar. r n é o máximo de: p 1 + r n 1 p 2 + r n 2... pn + r 0 23 / 56 Pseudocódigo Cut-Rod(p, n) if n == 0 return 0 q = for i = 1 to n q = max(q, p[i] + Cut-Rod(p, n i)) return q É muito ineficiente, tal como nos algoritmos de força-bruta para os números de Fibonacci e para as Combinações. 24 / 56
Árvore de chamadas recursivas de Cut-Rod com n = 4 Calcula o mesmo subproblema vezes sem fim. Complexidade: Θ(2 n ). 25 / 56 Abordagem com programação dinâmica Resolver cada subproblema apenas uma vez, e armazenar o resultado para uso futuro. Fazer uma abordagem bottom-up: resolver primeiro os subproblemas mais pequenos. Quando necessitamos de resolver um subproblema maior, usamos os resultados (já calculados) dos subproblemas mais pequenos. 26 / 56
Algoritmo de programação dinâmica Bottom-Up-Cut-Rod(p, n) Let r[0.. n] be a new array r[0] = 0 for j = 1 to n q = for i = 1 to j q = max(q, p[i] + r[j i]) r[j] = q return r[n] 27 / 56 28 / 56
Complexidade temporal Dois ciclos encadeados que dependem linearmente de n, e tempo constante em cada iteração. Complexidade temporal é Θ(n 2 ). Passamos de tempo exponencial para tempo polinomial. 29 / 56 Reconstrução da solução O algoritmo retorna o valor da solução óptima, mas não a solução. Podemos obter a solução com a seguinte modificação: s[i] guarda o tamanho do pedaço de tubo mais à esquerda da solução óptima de um problema rod-cut de comprimento i. Extended-Bottom-Up-Cut-Rod(p, n) Let r[0.. n] and s[0.. n] be new arrays r[0] = s[0] = 0 for j = 1 to n q = for i = 1 to j if p[i] + r[j i] > q q = p[i] + r[j i] s[j] = i r[j] = q return r and s 30 / 56
Reconstrução da solução i 0 1 2 3 4 r[i] 0 1 5 8 10 s[i] 0 1 2 3 2 Print-Cut-Rod-Solution(p, n) (r, s) = Extended-Bottom-Up-Cut-Rod(p, n) while n > 0 print s[n] n = n s[n] 31 / 56 Memoization Uma técnica semelhante à Programação Dinâmica. Mantém o algoritmo na forma recursiva (top-down). A ideia é usar uma flag para indicar se um subproblema já está resolvido. Depois, para resolver um subproblema temos de primeiro verificar se já foi resolvido. Se sim, basta retornar a solução previamente armazenada. Se não, resolvemos o subproblema e guardamos a solução para uso futuro. Cada subproblema só é resolvido uma vez. 32 / 56
Verão memoized de Cut-Rod Memoized-Cut-Rod(p, n) Let r[0.. n] be a new array for i = 0 to n r[i] = return Memoized-Cut-Rod-Aux(p, n, r) Memoized-Cut-Rod-Aux(p, n, r) if r[n] 0 return r[n] if n == 0 q = 0 else q = for i = 1 to n q = max(q, p[i] + Memoized-Cut-Rod-Aux(p, n i, r)) r[n] = q return q 33 / 56 Complexidade temporal Cada subproblema só é resolvido uma vez. Os subproblems têm tamanho 0, 1,..., n, e requerem um ciclo for sobre o seu tamanho Complexidade também é Θ(n 2 ). 34 / 56
Outro exemplo: Longest Common Subsequence (LCS) Dadas duas sequências, X = x 1 x 2... x m e Y = y 1 y 2... y n, encontrar uma subsequência comum a X e Y que seja o mais longa possível. Exemplo: X = n o c t u r n o Y = m o s q u i t e i r o LCS(X, Y ) = o t r o (também podia ser o u r o) 35 / 56 Algoritmo de força bruta Gerar todas as subsequências de X e verificar se também é subsequência de Y, e ir guardando a subsequência mais longa vista até ao momento. Complexidade? Θ(2 m ) para gerar todas as subsequências de X. Θ(n) para verificar se uma subsequência de X é subsequência de Y. Total: Θ(n 2 m ) É exponencial. Muito mau! 36 / 56
Será que podemos aplicar Programação Dinâmica? Se sim teremos de conseguir definir o problema recursivamente em termos de subproblemas. O n o de subproblemas tem de ser relativamente pequeno (polinomial em n e m) para que a Programação Dinâmica seja útil. Depois de definir o problema em termos de subproblemas, podemos resolver o problema de baixo para cima, começando pelos casos base e armazenando as soluções dos subproblemas. 37 / 56 Subestrutura óptima Vamos olhar para prefixos de X e Y. Seja X i o prefixo dos i primeiros elementos de X. Exemplo: X = n o c t u r n o X 4 = n o c t X0 = X3 = n o c X 8 = n o c t u r n o 38 / 56
Subestrutura óptima Seja X = x 1 x 2... x m e Y = y 1 y 2... y n. Seja Z = z 1 z 2... z k uma LCS entre X e Y. Três casos: 1 Se x m = y n, então z k = x m = y n e Z k 1 é uma LCS entre X m 1 e Y n 1. 2 Se x m y n e z k x m, então Z é uma LCS entre X m 1 e Y n. 3 Se x m y n e z k y n, então Z é uma LCS entre X m e Y n 1. 39 / 56 Demonstração do caso 1 Caso 1: Se x m = y n, então z k = x m = y n e Z k 1 é uma LCS entre X m 1 e Y n 1. Teremos de provar que z k = x m = y n. Suponhamos que tal não é verdade. Então a subsequência Z = z 1 z 2... z k x m é uma subsequência comum a X e Y e tem comprimento k + 1. Contradiz o facto de Z ser uma LCS entre X e Y. 40 / 56
Demonstração do caso 1 (cont.) Agora temos de provar que Z k 1 é uma LCS entre X m 1 e Y n 1. Suponhamos que existe uma subsequência W comum a X m 1 e Y n 1 que é mais longa que Z k 1. comprimento de W k. A subsequência W = W x m é comum a X e Y e tem comprimento k + 1. Contradiz o facto de Z ser uma LCS entre X e Y. 41 / 56 Demonstração dos casos 2 e 3 Caso 2: Se x m y n e z k x m, então Z é uma LCS entre X m 1 e Y n. Suponhamos que existe uma subsequência W comum a X m 1 e Y n com comprimento > k. Então W é uma subsequência comum entre X e Y. = Contradiz o facto de Z ser uma LCS entre X e Y. Caso 3: Se x m y n e z k y n, então Z é uma LCS entre X m e Y n 1. A demonstração do caso 3 é análoga à do caso 2. 42 / 56
Resumindo Podemos definir LCS(X m, Y n ) em termos de subproblemas., se m = 0 ou n = 0 LCS(X m, Y n ) = LCS(X m 1, Y n 1 ) x m, se x m = y n LCS(X m 1, Y n ) ou LCS(X m, Y n 1 ), se x m y n 43 / 56 Comprimento de LCS(X, Y ) Vamos tentar primeiro resolver um problema mais simples: Obter LCS(X, Y ) o comprimento de LCS(X, Y ) Seja c[i, j] = LCS(X i, Y j ) Queremos obter c[m, n] 44 / 56
Definição recursiva de c[i, j] c[i, j] = 0, se i = 0 ou j = 0 c[i 1, j 1] + 1, se i, j > 0 e x i = y j max(c[i 1, j], c[i, j 1]), se i, j > 0 e x i y j 45 / 56 Algortimo recursivo LCS-Length-Rec(X, Y, i, j) if i == 0 or j == 0 return 0 elseif X [i] == Y [j] return LCS-Length-Rec(X, Y, i 1, j 1) + 1 else a = LCS-Length-Rec(X, Y, i 1, j) b = LCS-Length-Rec(X, Y, i, j 1) return max(a, b) Chamada inicial: LCS-Length-Rec(X, Y, m, n) Tal como em Fib-Rec e Comb-Rec, a árvore dá origem a muitos subproblemas repetidos. O algoritmo é exponencial. Mas o número de subproblemas distintos = m n. 46 / 56
Podemos usar Programação Dinâmica LCS-Length-DP(X, Y ) m = X.length n = Y.length for i = 1 to m c[i, 0] = 0 for j = 0 to n c[0, j] = 0 for i = 1 to m for j = 1 to n if X [i] == Y [j] c[i, j] = c[i 1, j 1] + 1 elseif c[i 1, j] c[i, j 1] c[i, j] = c[i 1, j] else c[i, j] = c[i, j 1] return c[m, n] 47 / 56 Demo c[i, j] é preenchida linha a linha, da esquerda para a direita. 48 / 56
Como obter a LCS própriamente dita? O nosso algoritmo apenas obteve o comprimento da LCS. A ideia é alterar o código de LCS-Length-DP e, de cada vez que obtemos um c[i, j], registamos como é que ele foi obtido. Isso permite-nos reconstruir a solução. 49 / 56 Aqui vai o código alterado LCS-Length-DP-v2(X, Y ). for i = 1 to m for j = 1 to n if X [i] == Y [j] c[i, j] = c[i 1, j 1] + 1 b[i, j] = elseif c[i 1, j] c[i, j 1] c[i, j] = c[i 1, j] b[i, j] = else c[i, j] = c[i, j 1] b[i, j] = return c[m, n], b 50 / 56
Demo As setas, e são armazenadas em b[i, j]. b[i, j] indica o subproblema escolhido para obter c[i, j]. 51 / 56 Uma vez tendo a informação em b, podemos obter uma LCS entre X e Y. A chamada inicial é Print-LCS(b, X, m, n) Print-LCS(b, X, i, j) if i == 0 or j == 0 return // Não faz nada if b[i, j] == Print-LCS(b, X, i 1, j 1) print X [i] elseif b[i, j] == Print-LCS(b, X, i 1, j) else Print-LCS(b, X, i, j 1) 52 / 56
Complexidade A complexidade é Θ(m n) A Programação Dinâmica reduziu a complexidade de exponencial para polinomial. No livro têm mais exemplos de problemas resolvidos com Programação Dinâmica. 53 / 56 Versão memoized de LCS-Length LCS-Length-Memoized(X, Y ) m = X.length n = Y.length for i = 0 to m for j = 0 to n c[i, j] = unknown return M-LCS-Length(X, Y, m, n) 54 / 56
M-LCS-Length(X, Y, i, j) if c[i, j] == unknown if i == 0 or j == 0 c[i, j] = 0 elseif X [i] == Y [j] c[i, j] = M-LCS-Length(X, Y, i 1, j 1) + 1 else a = M-LCS-Length(X, Y, i 1, j) b = M-LCS-Length(X, Y, i, j 1) c[i, j] = max(a, b) return c[i, j] 55 / 56 Como aplicar a Programação Dinâmica? Para aplicarmos Programação Dinâmica ou Memoization para resolver um problema, temos de fazer 4 coisas: 1 Caracterizar a estrutura de uma solução óptima. 2 Definir o valor da solução óptima recursivamente em termos de subsoluções óptimas. 3 Calcular o valor de uma solução óptima de baixo para cima (no caso de P.D.) ou de cima para baixo (no caso de Memoization). 4 Obter a solução óptima através da informação calculada e armazenada no passo 3. 56 / 56