Programação dinâmica não é magia negra
(qsantos.fr)- Mesmo problemas com muitos casos especiais, como o Advent of Code 2023 Day 12, podem ser tratados com programação dinâmica quando se identifica uma estrutura que resolve repetidamente os mesmos subproblemas
- A ideia central é dividir o problema com recursão, reduzir cálculos duplicados com memoização e então migrar para um cálculo iterativo que preenche os valores necessários na ordem de dependência
- O exemplo de Fibonacci mostra que a recursão ingênua avalia
f(1)repetidamente, mas, usando cache, basta avaliar apenas n + 1 valores, def(0)af(n) - A distância de Levenshtein e o Advent of Code Day 12 mostram o processo de usar índices de estado, como comprimento de strings e índice de regras, como chaves de cache para transformar chamadas recursivas em preenchimento de arrays
- Ao dominar programação dinâmica, além de melhorar desempenho, você passa a enxergar os estados intermediários e relações de dependência do algoritmo, ficando mais fácil encontrar oportunidades de otimização de memória
O nome é confuso, mas a ideia é simples
- O nome “dynamic programming” não tem relação direta com significados modernos como “estilo de programação” ou “tipagem dinâmica”
- A essência é uma forma de projetar algoritmos que divide um problema em problemas menores semelhantes e reutiliza seus resultados
- Há uma nota editorial acrescentando que a expressão faz sentido quando se considera “programming” em seu significado histórico
- O ponto de partida costuma ser uma forma que decompõe o problema em problemas menores, como uma função recursiva
- Quando o mesmo subproblema aparece várias vezes, surge naturalmente a necessidade de cache para armazenar e reutilizar resultados
Cache e transformação em iteração com Fibonacci
- A função de Fibonacci é definida como
f(n) = f(n - 1) + f(n - 2), e uma implementação recursiva ingênua calcula os mesmos valores repetidamente - Como
f(1)é um valor que de fato é somado ao resultado final, à medida quef(n)cresce, o número de avaliações da recursão ingênua também aumenta rapidamente - Ao usar cache ou memoização dos resultados, não é necessário recalcular
f(4),f(3)ef(2)já calculados - Nesse método, avaliam-se apenas 7 valores no total, de
f(0)af(6); em geral, isso é reduzido para n + 1 avaliações - Indo um passo além, se preenchermos os valores necessários em ordem a partir de
f(0)ef(1), as chamadas recursivas desaparecemF[2] = F[1] + F[0]F[3] = F[2] + F[1]- Da mesma forma, calcula-se até
F[6] = 8
- Em Fibonacci, nem mesmo o array inteiro é necessário: basta manter o valor anterior e o valor antes dele
- Esse fluxo mostra um caminho sistemático que parte da definição matemática e chega a uma implementação iterativa
Expandindo para o exemplo de distância de edição
- A distância de edição entre duas strings é o número mínimo de edições necessárias para transformar uma string em outra
- O problema muda conforme os tipos de edição permitidos
- Se apenas substituição de caracteres for permitida, é a Hamming distance
- Se inserção e remoção também forem permitidas, é a Levenshtein distance
- A distância de Levenshtein pode ser dividida em problemas menores com base no último caractere de duas strings
AeB- Se os últimos caracteres forem iguais, ignoram-se os dois caracteres e usa-se a distância das strings restantes
- Se os últimos caracteres forem diferentes, escolhe-se o menor custo entre substituição, remoção e inserção
- Se
Aestiver vazia, é preciso inserir todos os caracteres deB, então o custo éb - Se
Bestiver vazia, é preciso remover todos os caracteres deA, então o custo éa
- Ao transpor essa definição diretamente para uma recursão em Python, ela fica muito lenta para strings longas e strings com muitas diferenças
- Se Fibonacci crescia em aproximadamente dois ramos a cada nível da árvore de chamadas, esta recursão pode crescer em três ramos, dependendo do caso
- Ao adicionar
functools.cachedo Python, é possível reutilizar os resultados de cálculo das mesmas combinações de substrings - Uma implementação melhor não cria novas strings o tempo todo; ela passa apenas as strings originais
AeBe os comprimentos das substringsaeb - Na etapa final, cria-se diretamente um array bidimensional
cachee ele é preenchido em ordem de modo quecache[a][b] = levenstein(A[:a], B[:b]) - A versão iterativa percorre
aebde 0 até o comprimento das strings, consultando valores da linha anterior e da coluna anterior que já foram preenchidos
Aplicando ao Advent of Code 2023 Day 12
- O problema de 12 de dezembro de 2023 do Advent of Code consiste em resolver um nonogram unidimensional
- Uma entrada de exemplo tem a forma
.??..??...?##. 1,1,3, em que?pode ser.ou# - Uma abordagem por força bruta usa backtracking, mas, se houver
npontos de interrogação, é preciso avaliar 2^n candidatos, crescendo exponencialmente - Aparece uma estrutura em que os mesmos subproblemas se repetem
..#..??...?##. (1),1,3.#...??...?##. (1),1,3- Ao descartar a parte inicial já processada, eles se tornam problemas quase iguais, como
.??...?##. 1,3e..??...?##. 1,3
- A função básica de backtracking recebe
conditionserulese calcula o número de arranjos possíveis- Se não restarem regras, verifica se ainda há
#nas condições restantes - Se não restarem condições, verifica se ainda há regras restantes
- Se o caractere atual for
.ou?, avança uma posição e calcula - Se o caractere atual for
#ou?, verifica o tamanho da próxima regra e a condição do separador, depois passa para o próximo estado
- Se não restarem regras, verifica se ainda há
- Em Python, basta adicionar
@cachepara aplicar memoização - Para transformar em programação dinâmica, em vez de recortar a string e as regras a cada chamada, usa-se o deslocamento da string
ie o deslocamento das regrasjcomo estado - Depois, cria-se diretamente
cache[i][j]e substitui-se a recursão por cálculo iterativo preenchendo os índices em ordem inversa - Um exemplo de implementação em Rust é fornecido no link Rust implementation dentro do artigo
O que fica visível ao preencher o cache diretamente
- A versão com programação dinâmica do Advent of Code Day 12 pode parecer mais lenta do que a versão com memoização
- Essa diferença provavelmente se deve a uma implementação em Python não otimizada
- Ao construir o cache diretamente, fica mais claro quais valores são realmente necessários
- No problema do Day 12, a versão com programação dinâmica permite confirmar que só a coluna anterior é necessária
- Portanto, é possível trocar o array bidimensional por dois arrays unidimensionais, representando a coluna anterior e a coluna atual
Problemas para praticar e conclusão
- Programação dinâmica não é trivial, mas também não é uma técnica inacessível para a maioria dos programadores
- Ao entender como dividir um problema em problemas menores, em muitas situações a memoização por si só já pode melhorar muito uma implementação ingênua
- Com mais prática, é possível compreender uma família de algoritmos, avaliar melhor os trade-offs e encontrar otimizações adicionais
- Os seguintes problemas são sugeridos para prática
- Depois de implementar, não se deve esquecer de fazer benchmarks e profiling
1 comentários
Opiniões do Hacker News
Gostei do ponto em que o texto destaca que algoritmos de programação dinâmica são apenas uma forma inteligente de cachear recursão. Pela minha experiência, encontrar primeiro uma solução recursiva é o melhor ponto de partida para chegar a uma solução de programação dinâmica; uma vez encontrada, a memoização é fácil e pode trazer um grande ganho de velocidade.
Às vezes ela é até mais rápida do que a programação dinâmica bottom-up, porque calcula apenas as soluções realmente necessárias. O ponto central é que tudo bem haver muitos subproblemas na árvore de chamadas, mas o número de subproblemas diferentes precisa ser relativamente pequeno. Não há motivo para cachear um resultado que só é necessário uma vez, e a dificuldade está em dividir o problema original em um número suficientemente pequeno de subproblemas distintos.
Na prática, como a eliminação de chamadas de cauda nem sempre é aplicada, faz sentido fazer assim, mas eu gostaria de ter aprendido primeiro pela perspectiva mais intuitiva de cache recursivo top-down.
Por exemplo, na série “Best Time to Buy and Sell Stock” do LeetCode, em problemas como https://leetcode.com/problems/best-time-to-buy-and-sell-stoc..., não parece muito mais natural preencher um array? Nunca tentei resolver de forma recursiva e nem sei bem se existe uma solução recursiva natural.
O link acima é o III, mas para quem está começando, começar pelo primeiro problema https://leetcode.com/problems/best-time-to-buy-and-sell-stoc... é uma boa introdução à programação dinâmica.
A origem do nome “programação dinâmica” vem de seu inventor, Richard Bellman. Em 1950, na RAND, ele procurava um nome para processos de decisão em múltiplos estágios; na época, o secretário de Defesa Wilson detestava patologicamente a palavra “pesquisa”, e “matemática” era algo que devia ser evitado ainda mais.
Bellman precisava de um nome que ocultasse de Wilson e da Força Aérea o fato de que, dentro da RAND, ele estava na verdade fazendo matemática. Então escolheu “programming”, porque tratava de planejamento, tomada de decisão e pensamento, mas “planning” não era uma boa opção por vários motivos; e acrescentou “dynamic”, que tem um significado preciso na física clássica, para capturar a ideia de múltiplos estágios e variação ao longo do tempo.
Ele também gostou do fato de que “dynamic”, como adjetivo, é difícil de usar com sentido negativo, e por ser um nome ao qual até um congressista teria dificuldade de se opor, passou a usar dynamic programming como um termo abrangente para suas atividades.
Fonte: https://alliance.seas.upenn.edu/~cis520/dynamic/2021/wiki/in...
Gosto de como este texto primeiro expõe o problema recursivamente, depois adiciona cache de forma gradual e, por fim, reduz o tamanho do cache apenas ao necessário.
Muitas vezes tentei ir direto para uma solução de programação dinâmica e fiquei travado, ou precisei forçar demais para fazê-la funcionar. Daqui em diante, pretendo me obrigar a seguir as etapas em ordem.
Uma aplicação interessante de programação dinâmica é o alinhamento par a par de sequências de nucleotídeos/proteínas.
https://en.wikipedia.org/wiki/Sequence_alignment
https://en.wikipedia.org/wiki/Needleman%E2%80%93Wunsch_algor...
https://en.wikipedia.org/wiki/Smith%E2%80%93Waterman_algorit...
Tive um professor de algoritmos muito bom, que havia estudado na UCLA. A aula sobre programação dinâmica foi excelente: ele começava com um problema cuja solução simples tinha complexidade de tempo exponencial, depois dividia o problema em problemas menores para reduzir a complexidade a um nível polinomial e, por fim, aplicava memoização para derrubá-la para linear
Seria bom lembrar quais eram os problemas usados na época
Todos são exemplos clássicos em que a solução ingênua é ineficiente e a programação dinâmica traz uma grande melhoria
Para mais exemplos, veja https://en.wikipedia.org/wiki/Dynamic_programming#Algorithms...
Pelo que sei, se você adiciona restrições especiais como “estas duas disciplinas precisam ser cursadas juntas”, isso fica muito mais complexo e difícil de tratar do que a programação dinâmica comum
Como o site original parece não estar aguentando o tráfego, deixo um link arquivado
https://web.archive.org/web/20240114111200/https://qsantos.f...
Graças à programação dinâmica, foi possível calcular o número de posições legais no Go, e o valor era um número de 171 dígitos
A abordagem ingênua leva tempo 3^(n^2), pois examina todas as posições possíveis em um tabuleiro de Go n×n, mas a programação dinâmica basicamente remove uma dimensão e reduz a complexidade de tempo para O(n^5 * 5.4^n) e a complexidade de espaço para O(n * 5.4^n)
https://tromp.github.io/go/legal.html
https://tromp.github.io/go/gostate.pdf
O nome “Dynamic Programming” pode parecer estranho porque, aqui, programming não se refere à área de programação. Nesse caso, o sentido é mais próximo de otimização, como em programação linear
A programação dinâmica pode ser vista como um método para resolver problemas de decisão em tempo discreto, isto é, escolher a sequência ótima {a_t} que maximiza \sum_t u_t(a_t) sob restrições. Ela define a função de valor V* como V*(t) = max_{a_t}{ u_t(a_t) + V*(t-1) }, reduzindo bastante a dimensionalidade do problema de otimização
Ao ouvir “programação dinâmica”, seria errado pensar simplesmente em memoização? A parte que falta talvez seja decompor o problema de forma inteligente para poder usar memoização
Programação dinâmica está mais próxima de uma memoização sistemática. Ela resolve subproblemas cada vez maiores até chegar à solução do problema inteiro. O termo “algoritmo indutivo” também combina até certo ponto, porque um algoritmo típico de programação dinâmica é, na prática, parecido com uma prova por indução matemática. Infelizmente, esse termo já tem outros significados
Depois, ao observar que há overhead na recursão e na memoização, se você construir a tabela de baixo para cima e eliminar as chamadas recursivas, isso vira programação dinâmica
A etapa 3 é a parte mais característica da programação dinâmica, mas acho que, mesmo parando na etapa 2, ainda dá para chamar de programação dinâmica. Só não será tão eficiente quanto poderia. Dito de outra forma, memoização é caching; a etapa 3 é perguntar se existe uma forma de preencher esse cache antecipadamente
Em geral, se os subproblemas se sobrepõem muito e a solução ótima dos subproblemas precisa fazer parte da solução ótima global, há uma oportunidade para programação dinâmica. Dizer que apenas memoização é programação dinâmica é parecido com dizer que apenas uma tabela hash é um tipo abstrato de dados
Memoização é, basicamente, uma estratégia para tornar um algoritmo mais rápido
Foi divertido terminar o Advent of Code deste ano. Ficou claro que o dia 1, especialmente a parte 2, foi muito mais difícil do que em anos anteriores, e escrevi sobre isso em https://blog.singleton.io/posts/2024-01-02-advent-of-code-20..., mas comparar apenas as estatísticas atuais de 2022 com as estatísticas atuais de 2023 não deixa isso evidente. Isso porque as pessoas tiveram um ano a mais para resolver os puzzles de 2022
Ao buscar as estatísticas de 2022 em 14 de janeiro de 2023 https://web.archive.org/web/20230114172513/https://adventofc..., a diferença ficou bem grande. Ao plotar as estatísticas de conclusão da parte 2 https://blog.singleton.io/static/imgs-aoc23/completion.png, o tamanho do grupo inicial no dia 1 era parecido, mas 2023 parece claramente mais difícil que 2022 até o dia 15
A proporção de pessoas que resolveram a parte 1 mas não conseguiram resolver a parte 2 https://blog.singleton.io/static/imgs-aoc23/ratios.png também foi muito maior em muitos dias de 2023, sugerindo especialmente que o dia 5, o dia 10, o dia 12 e a parte 2 do dia 22 foram difíceis
Ainda assim, fiquei surpreso com o quanto a parte 2 do dia 5 foi difícil. Consegui resolver sem desistir, mas achei que talvez tivesse deixado passar algo óbvio e resolvido de um jeito exageradamente complicado; foi um alívio saber que era mesmo um problema um pouco desafiador
Foram dados como exemplos
two1nine,eightwothree,abcone2threexyz,xtwone3four,4nineeightseven2,zoneight234,7pqrstsixteen, mas faltou um exemplo essencial comooneight. Sem um exemplo assim, fica difícil descobrir exatamente como os valores deveriam ser substituídosEm 2022, a maioria continuou participando nos primeiros dias, a taxa de retenção passou de 80% em muitos dias, e quase todos resolveram as duas partes. Em contraste, no dia 1 de 2023, entre as pessoas que resolveram a parte 1, apenas 76% resolveram também a parte 2, e muita gente desistiu nos dias 3 e 5
Curiosamente, os últimos dias não foram tão baixos, o que pode ser explicado pelo fato de o Advent of Code de 2023 ser mais recente que o de 2022. Minha interpretação é que esse grupo é formado por pessoas que, independentemente da dificuldade, superam todos os desafios até certo ponto, enquanto muitas outras param quando sentem que está tomando tempo demais