rev4: Um comentário muito eloqüente do usuário Sammaron observou que, talvez, essa resposta tenha anteriormente confundido de cima para baixo e de baixo para cima. Embora originalmente esta resposta (rev3) e outras respostas dissessem que "de baixo para cima é memoização" ("assuma os subproblemas"), pode ser o inverso (ou seja, "de cima para baixo" pode ser "assuma os subproblemas" e " de baixo para cima "pode ser" compor os subproblemas "). Anteriormente, eu li que a memoização é um tipo diferente de programação dinâmica, em oposição a um subtipo de programação dinâmica. Eu estava citando esse ponto de vista, apesar de não assinar. Reescrevi esta resposta como independente da terminologia até que as referências apropriadas possam ser encontradas na literatura. Também converti esta resposta em um wiki da comunidade. Por favor, prefira fontes acadêmicas. Lista de referências:} {Literatura: 5 }
Recapitular
A programação dinâmica é sobre ordenar seus cálculos de uma maneira que evite recalcular o trabalho duplicado. Você tem um problema principal (a raiz da sua árvore de subproblemas) e subproblemas (subárvores). Os subproblemas geralmente se repetem e se sobrepõem .
Por exemplo, considere seu exemplo favorito de Fibonnaci. Esta é a árvore completa dos subproblemas, se fizermos uma chamada recursiva ingênua:
TOP of the tree
fib(4)
fib(3)...................... + fib(2)
fib(2)......... + fib(1) fib(1)........... + fib(0)
fib(1) + fib(0) fib(1) fib(1) fib(0)
fib(1) fib(0)
BOTTOM of the tree
(Em alguns outros problemas raros, essa árvore pode ser infinita em alguns ramos, representando a não terminação e, portanto, o fundo da árvore pode ser infinitamente grande. Além disso, em alguns problemas, talvez você não saiba como é a árvore inteira antes de Assim, você pode precisar de uma estratégia / algoritmo para decidir quais subproblemas revelar.)
Memoização, Tabulação
Existem pelo menos duas técnicas principais de programação dinâmica que não são mutuamente exclusivas:
Memoização - Essa é uma abordagem de laissez-faire: você assume que já calculou todos os subproblemas e que não tem idéia de qual é a ordem de avaliação ideal. Normalmente, você faria uma chamada recursiva (ou algum equivalente iterativo) a partir da raiz e espera que você chegue perto da ordem de avaliação ideal ou obtenha uma prova de que o ajudará a chegar à ordem de avaliação ideal. Você garantiria que a chamada recursiva nunca recalcule um subproblema porque você armazena em cache os resultados e, portanto, as subárvores duplicadas não são recalculadas.
- exemplo: Se você está calculando a sequência de Fibonacci
fib(100)
, basta chamar isso, e chamaria fib(100)=fib(99)+fib(98)
, o que chamaria fib(99)=fib(98)+fib(97)
... etc ..., o que chamaria fib(2)=fib(1)+fib(0)=1+0=1
. Finalmente fib(3)=fib(2)+fib(1)
, ele resolveria , mas não precisará recalcular fib(2)
, porque o armazenamos em cache.
- Isso começa no topo da árvore e avalia os subproblemas das folhas / subárvores de volta à raiz.
Tabulação - Você também pode pensar na programação dinâmica como um algoritmo de "preenchimento de tabela" (embora geralmente multidimensional, essa 'tabela' possa ter geometria não euclidiana em casos muito raros *). Isso é como memorização, mas mais ativo, e envolve uma etapa adicional: você deve escolher, com antecedência, a ordem exata em que fará seus cálculos. Isso não deve implicar que o pedido deve ser estático, mas que você tem muito mais flexibilidade do que a memorização.
- exemplo: Se você estiver executando Fibonacci, você pode optar por calcular os números nesta ordem:
fib(2)
, fib(3)
, fib(4)
... cache todos os valores para que possa calcular os próximos mais facilmente. Você também pode pensar nisso como preencher uma tabela (outra forma de armazenamento em cache).
- Pessoalmente, não ouço muito a palavra 'tabulação', mas é um termo muito decente. Algumas pessoas consideram essa "programação dinâmica".
- Antes de executar o algoritmo, o programador considera a árvore inteira e depois escreve um algoritmo para avaliar os subproblemas em uma ordem específica em relação à raiz, geralmente preenchendo uma tabela.
- * nota de rodapé: Às vezes, a 'tabela' não é uma tabela retangular com conectividade semelhante a uma grade, por si só. Em vez disso, pode ter uma estrutura mais complicada, como uma árvore ou uma estrutura específica para o domínio do problema (por exemplo, cidades a uma distância de vôo em um mapa) ou até mesmo um diagrama de treliça que, embora parecido com uma grade, não possui uma estrutura de conectividade de cima para baixo, esquerda, direita etc. Por exemplo, o usuário3290797 vinculou um exemplo de programação dinâmica de localização do conjunto independente máximo em uma árvore , o que corresponde ao preenchimento de espaços em branco em uma árvore.
(Em geral, em um paradigma de "programação dinâmica", eu diria que o programador considera a árvore inteira, entãoescreve um algoritmo que implementa uma estratégia para avaliar subproblemas que podem otimizar as propriedades desejadas (geralmente uma combinação de complexidade de tempo e complexidade de espaço). Sua estratégia deve começar em algum lugar, com algum subproblema específico, e talvez possa se adaptar com base nos resultados dessas avaliações. No sentido geral de "programação dinâmica", você pode tentar armazenar em cache esses subproblemas e, mais geralmente, evitar revisitar subproblemas com uma distinção sutil, talvez seja o caso de gráficos em várias estruturas de dados. Muitas vezes, essas estruturas de dados são essenciais como matrizes ou tabelas. As soluções para os subproblemas podem ser descartadas se não precisarmos mais delas.)
[Anteriormente, essa resposta fazia uma declaração sobre a terminologia de cima para baixo versus de baixo para cima; existem claramente duas abordagens principais chamadas Memoização e Tabulação que podem estar em desuso com esses termos (embora não inteiramente). O termo geral que a maioria das pessoas usa ainda é "Programação Dinâmica" e algumas pessoas dizem "Memoização" para se referir a esse subtipo específico de "Programação Dinâmica". Esta resposta se recusa a dizer qual é de cima para baixo e de baixo para cima até que a comunidade encontre referências apropriadas em trabalhos acadêmicos. Por fim, é importante entender a distinção e não a terminologia.]
Prós e contras
Facilidade de codificação
Memoização é muito fácil de codificar (geralmente você pode * escrever uma anotação "memoizer" ou uma função de invólucro que faz isso automaticamente por você) e deve ser sua primeira linha de abordagem. A desvantagem da tabulação é que você precisa criar um pedido.
* (isso é realmente fácil apenas se você estiver escrevendo a função e / ou codificando em uma linguagem de programação impura / não-funcional ... por exemplo, se alguém já escreveu uma fib
função pré-compilada , ela necessariamente faz chamadas recursivas para si mesma e você não pode memorizar magicamente a função sem garantir que as chamadas recursivas chamem sua nova função memorizada (e não a função não memorizada original))
Recursividade
Observe que de cima para baixo e de baixo para cima podem ser implementadas com recursão ou preenchimento iterativo de tabela, embora possa não ser natural.
Preocupações práticas
Com a memorização, se a árvore for muito profunda (por exemplo fib(10^6)
), você ficará sem espaço na pilha, porque cada cálculo atrasado deve ser colocado na pilha e você terá 10 ^ 6 deles.
Optimalidade
Qualquer uma das abordagens pode não ter o tempo ideal se a ordem em que você acontecer (ou tentar) visitar subproblemas não for ideal, especificamente se houver mais de uma maneira de calcular um subproblema (normalmente o cache resolveria isso, mas é teoricamente possível que o cache possa em alguns casos exóticos). Memoização geralmente adiciona complexidade de tempo à complexidade do espaço (por exemplo, com tabulação, você tem mais liberdade para jogar fora os cálculos, como usar tabulação com Fib permite usar o espaço O (1), mas a memoização com Fib usa O (N) espaço de pilha).
Otimizações avançadas
Se você também estiver enfrentando problemas extremamente complicados, poderá não ter outra opção a não ser fazer tabulação (ou pelo menos assumir um papel mais ativo na orientação da memorização para onde deseja que ela vá). Além disso, se você estiver em uma situação em que a otimização é absolutamente crítica e você deve otimizar, a tabulação permitirá que você faça otimizações que a memorização não permitiria de maneira sadia. Na minha humilde opinião, na engenharia de software normal, nenhum desses dois casos é apresentado, então eu usaria a memorização ("uma função que armazena em cache suas respostas"), a menos que algo (como o espaço da pilha) torne a tabulação necessária ... tecnicamente, para evitar uma explosão da pilha, você pode 1) aumentar o limite de tamanho da pilha nos idiomas que a permitem, ou 2) consumir um fator constante de trabalho extra para virtualizar sua pilha (ick),
Exemplos mais complicados
Aqui listamos exemplos de interesse particular, que não são apenas problemas gerais de DP, mas distinguem de forma interessante memoização e tabulação. Por exemplo, uma formulação pode ser muito mais fácil que a outra, ou pode haver uma otimização que requer basicamente tabulação:
- o algoritmo para calcular a distância de edição [ 4 ], interessante como um exemplo não trivial de um algoritmo bidimensional de preenchimento de tabela