Programação dinâmica
Estados, recorrências, ordem de cálculo e reconstrução na mochila, trocos, subconjuntos e subvetor máximo.
Na mochila de capacidade 7 com objetos , , e , a decisão sobre separa dois subproblemas: incluir deixa capacidade 2 para os outros objetos, excluí-lo deixa capacidade 7. Cada par indica peso e valor. A programação dinâmica guarda a resposta a cada subproblema de objetos e capacidade, para não recalcular a mesma escolha muitas vezes.
O estado precisa de informação suficiente para determinar as continuações permitidas. Uma solução ótima contém soluções ótimas desses subproblemas: substituir uma parte por outra melhor tem de conservar as restrições da solução completa. É essa propriedade de substituição que justifica cada recorrência desta lição.
Definir antes de preencher
Para derivar uma tabela, escreve o significado exato de uma célula, os casos base e as alternativas que levam até ela. Decide a ordem em que as dependências ficam disponíveis. Por fim, guarda informação para reconstruir uma solução, quando o enunciado pede mais do que o valor.
Memoização calcula estados por chamadas e guarda-os na primeira visita. Tabulação calcula-os por uma ordem pré-definida, normalmente crescente num índice. A primeira pode evitar estados inalcançáveis; a segunda evita parte do custo das chamadas e torna a ordem explícita. Nenhuma é sempre mais rápida.
A complexidade resulta do número de estados multiplicado pelo trabalho por estado, mais inicialização e reconstrução.
Vídeo complementar em inglês: Programação dinâmica, Fibonacci e caminhos mínimos, aula 19 de 6.006, 2011 (MIT OpenCourseWare). Para passar de recursão repetida a estados guardados, identifica o significado de cada subproblema e conta os estados distintos, em vez das chamadas repetidas.
Mochila 0-1
Define como o valor máximo usando os primeiros objetos, com capacidade disponível . Não exige preencher a mochila. Assumimos pesos inteiros positivos e valores não negativos.
Se o objeto tem peso e valor , ou o excluímos, ou o incluímos uma vez:
Os casos base são e . A linha anterior impede usar o mesmo objeto outra vez. As duas alternativas cobrem todas as soluções; dentro de cada alternativa, substituir o subconjunto restante por um melhor conserva a capacidade. Isto prova a recorrência.
Usamos os objetos , , , e :
Na última célula, excluir dá ; incluí-lo dá . O valor máximo é 11.
Reconstruir objetos
Começa em . Como , escolhemos e passamos para . As linhas de e não melhoraram a capacidade 2, por isso excluímos esses objetos. A linha de melhora de zero para 3, portanto escolhemos . A solução tem peso 7 e valor 11.
Se as duas alternativas empatam, excluir o objeto é uma regra válida para devolver um ótimo. Minimizar também o peso ou a quantidade de objetos exige guardar e comparar esse segundo critério.
#include <algorithm>
#include <iostream>
#include <vector>
int main() {
int n, W;
if (!(std::cin >> n >> W) || n < 0 || n > 100 || W < 0 || W > 2000) {
std::cout << "entrada invalida\n";
return 0;
}
std::vector<int> peso(n + 1);
std::vector<long long> valor(n + 1);
for (int i = 1; i <= n; ++i) {
if (!(std::cin >> peso[i] >> valor[i]) || peso[i] < 1 || peso[i] > 2000 ||
valor[i] < 0 || valor[i] > 1000000) {
std::cout << "entrada invalida\n";
return 0;
}
}
std::vector<std::vector<long long>> f(n + 1, std::vector<long long>(W + 1));
for (int i = 1; i <= n; ++i)
for (int w = 0; w <= W; ++w) {
f[i][w] = f[i - 1][w];
if (peso[i] <= w)
f[i][w] = std::max(f[i][w], valor[i] + f[i - 1][w - peso[i]]);
}
std::vector<int> escolhidos;
for (int i = n, w = W; i > 0; --i) {
if (f[i][w] == f[i - 1][w]) continue;
escolhidos.push_back(i);
w -= peso[i];
}
std::reverse(escolhidos.begin(), escolhidos.end());
std::cout << "valor " << f[n][W] << "\nobjetos";
for (int i : escolhidos) std::cout << ' ' << i;
std::cout << '\n';
}Executa o código para ver a saída e os gráficos.
O programa lê n W, seguido de peso valor, e devolve valor 11 e objetos 1 4. Os objetos são numerados a partir de 1. Aceita até 100 objetos, capacidade até 2000, pesos positivos até 2000 e valores de 0 a um milhão.
Há estados, com trabalho constante: tempo e espaço. A reconstrução custa . Se só precisamos do valor, podemos conservar um vetor e percorrer capacidades por ordem decrescente. Assim F[w-p_i] ainda pertence à iteração anterior. Percorrer por ordem crescente permitiria repetir o objeto e resolveria a mochila ilimitada.
O que significa pseudopolinomial
é polinomial em e no valor de , mas a entrada representa com bits. Se , a tabela tem colunas para uma capacidade representada com cerca de bits. Chamamos a este custo pseudopolinomial.
Não há contradição com a dificuldade NP-completa da versão de decisão da mochila: para capacidades pequenas, a tabela pode ser prática; para valores enormes, força bruta em poucos objetos ou outro método pode usar menos trabalho.
Trocos ilimitados e com stock
Para denominações inteiras positivas e quantidade ilimitada, define como o mínimo de moedas para pagar exatamente usando as primeiras denominações. Temos e para .
ignorando a segunda alternativa se . A mesma linha permite reutilizar a denominação. Uma célula infinita significa impossível, não zero moedas. O tempo é e o espaço é , ou para o valor.
Com moedas 1, 3 e 4, os valores mínimos para alvos de 0 a 6 são . Para 6, a recorrência prefere ao guloso .
Se o stock de é , o estado deve respeitá-lo:
Aqui a linha anterior é necessária: escolhemos toda a quantidade dessa denominação uma vez.
Conservamos as moedas , stocks e alvo 6 usados na força bruta. Com apenas duas moedas de 1, . Na linha da moeda 3, as alternativas para o alvo 6 são:
| Quantidade de moedas de 3 | Resto a pagar com moedas de 1 | Custo |
|---|---|---|
| 0 | 6 | Impossível |
| 1 | 3 | Impossível, stock de 1 igual a 2 |
| 2 | 0 |
Na linha da moeda 4, não a usar conserva 2. Usar uma deixa alvo 2, com custo . Vence a primeira alternativa. Para reconstruir, guardamos na linha de 4, na linha de 3 e na linha de 1. Obtemos , as mesmas duas moedas encontradas por enumeração.
A formulação direta testa até quantidades por célula, para stock máximo , e custa tempo e espaço. Otimizações da mochila limitada podem reduzir o trabalho, preservando o significado do estado.
Soma de subconjuntos
Com inteiros não negativos , define como “há um subconjunto dos primeiros elementos com soma ”. Inicializa a verdadeiro e os outros a falso:
É a mesma decisão incluir/excluir, agora com existência em vez de máximo. O custo é . Para reconstruir, se a célula da linha anterior já é verdadeira, podemos excluir; caso contrário incluímos e reduzimos . Elementos zero exigem uma regra de desempate, mas não tornam a existência incorreta.
O subvetor máximo em tempo linear
Define como a maior soma de um intervalo não vazio que termina em . Esse intervalo ou começa em , ou prolonga o melhor terminado em :
O resultado global é . Esta é a forma dinâmica do algoritmo de Kadane. Guarda o início do intervalo atual: reinicia em quando escolher sozinho; atualiza os extremos globais quando melhora o máximo.
Para [-4,3,-1,5,-6,2], compara o melhor intervalo terminado em cada posição com o melhor já encontrado:
| Máximo até | |||
|---|---|---|---|
| 0 | |||
| 1 | 3 | 3 | 3 |
| 2 | 2 | 3 | |
| 3 | 5 | 7 | 7 |
| 4 | 1 | 7 | |
| 5 | 2 | 3 | 7 |
No índice 4, o melhor intervalo terminado ali vale 1, mas o máximo já encontrado continua a ser 7. Só um intervalo terminado no índice anterior pode ser prolongado sem saltar elementos. O máximo é 7 e termina no índice 3, com início 1. Se todos os valores forem negativos, conserva o maior deles. A recorrência tem estados e trabalho constante, portanto tempo e memória auxiliar para valor e extremos.
Caminhos por etapas e combinações
Num DAG com destino , define e
Para , processa em ordem topológica inversa; um vértice sem caminho até mantém infinito. Não atualizes pelas suas saídas: o caminho procurado já terminou em . Os subcaminhos repetidos ficam guardados e o custo é . Um estado que omitisse o vértice atual não distinguiria continuações diferentes.
Para combinações com , , com e para . As parcelas representam incluir ou excluir um elemento fixo. Uma tabela até usa operações; os valores podem exigir inteiros grandes. Ao reduzir a memória a uma linha, percorre por ordem decrescente para não misturar linhas.
Exercícios
Valor e seleção numa mochila
Capacidade W=6, com objetos A=(2,4), B=(3,5), C=(4,8). Cada par é peso e valor. Calcula o valor ótimo e reconstrói os objetos através das alternativas incluir/excluir.
Pista
Compara incluir C, que deixa capacidade 2, com excluir C.
Mais uma pista
Sem C, A+B dá valor 9. Com C, ainda cabe A e dá 8+4.
Solução
O valor ótimo é 12 com A e C, peso 6. Na célula F[3,6], incluir C dá 8+F[2,2]=12, superior a F[2,6]=9. Recuamos para (2,2), excluímos B porque pesa 3, e incluímos A. O objeto C não volta a aparecer, porque cada inclusão recua uma linha.
Confere a tua resposta:
- Obtive valor 12, objetos A e C.
- Comparei as duas alternativas na última célula.
- Reconstruí recuando uma linha por objeto.
Erros frequentes
Usar F[i,w-p_i] em vez de F[i-1,w-p_i] permite repetir um objeto. O ótimo não exige ocupar exatamente a capacidade em todas as instâncias.
Um alvo possível no ilimitado e impossível com stock
As denominações são 2 e 5 e o alvo é 8. Com stocks 1 e 1, existe solução? Explica o que muda na recorrência em relação ao stock ilimitado.
Pista
As quantidades permitidas são apenas zero ou um de cada moeda.
Mais uma pista
As somas possíveis são 0,2,5,7. No ilimitado, quatro moedas de 2 pagam 8.
Solução
Com stock 1 para cada denominação não há solução. A célula de alvo 8 fica infinita. A recorrência limitada escolhe q entre 0 e o stock e usa a linha anterior C[i-1,t-qc_i], impedindo reutilizar a mesma denominação para lá da quantidade escolhida. Na versão ilimitada, C[i,t-c_i] usa a mesma linha e permite quatro moedas de 2.
Confere a tua resposta:
- Listei as somas 0,2,5,7.
- Declarei o alvo impossível com stock.
- Distingui linha anterior e mesma linha.
Erros frequentes
Devolver quatro moedas de 2 viola o stock. Zero na célula impossível significaria falsamente pagar 8 sem moedas.
Calcular a melhor soma sem alterar o vetor
Define maior_soma(valores). Para uma lista não vazia de inteiros,
devolve a maior soma de um intervalo contíguo não vazio. Para uma lista
vazia, devolve None. Não alteres a lista nem imprimas na função.
Procura uma solução com tempo O(n) e memória auxiliar O(1).
def maior_soma(valores):
passVerifica o código para comparar o resultado com os testes.
Testes
Intervalo interior
Código do testeprint(maior_soma([-4, 3, -1, 5, -6, 2]))Esperado:7
Só negativos
Código do testeprint(maior_soma([-8, -2, -5]))Esperado:-2
Um elemento
Código do testeprint(maior_soma([4]))Esperado:4
Zeros
Código do testeprint(maior_soma([0, 0, 0]))Esperado:0
Recomeçar depois de uma perda
Código do testeprint(maior_soma([5, -10, 6, 2]))Esperado:8
Conservar os dados
Código do testea = [2, -1, 3] print(maior_soma(a)) print(a)Esperado:4 [2, -1, 3]
Lista vazia
Código do testeprint(maior_soma([]))Esperado:None
print(maior_soma([-4, 3, -1, 5, -6, 2]))7
print(maior_soma([-8, -2, -5]))-2
print(maior_soma([4]))4
print(maior_soma([0, 0, 0]))0
print(maior_soma([5, -10, 6, 2]))8
a = [2, -1, 3]
print(maior_soma(a))
print(a)4 [2, -1, 3]
print(maior_soma([]))None
Pista
Separa o melhor intervalo terminado na posição atual do melhor de todas as posições já tratadas.
Mais uma pista
Inicializa ambos com o primeiro elemento. Para cada seguinte, compara recomeçar nesse elemento com prolongar o intervalo anterior.
Solução
def maior_soma(valores):
if not valores:
return None
atual = melhor = valores[0]
for i in range(1, len(valores)):
atual = max(valores[i], atual + valores[i])
melhor = max(melhor, atual)
return melhorAntes de atualizar o máximo global, atual é a melhor soma de um intervalo que termina em i. Cada índice é visitado uma vez e só guardamos dois acumuladores. A verificação automática cobre os resultados e a conservação da lista nos casos públicos; a complexidade e o invariante exigem esta justificação. Enumerar intervalos pode passar os casos, mas não cumpre O(n).
Erros frequentes
Inicializar a zero aceita um intervalo vazio nas listas só com negativos. Somar todos os positivos ignora a contiguidade. Usar valores[1:] copia parte da lista e já não usa memória auxiliar constante.