Programação dinâmica

Estados, recorrências, ordem de cálculo e reconstrução na mochila, trocos, subconjuntos e subvetor máximo.

TLDR

Perguntar sobre esta página

ChatGPTClaudePerplexityGrokGeminiCopiar e abrirDeepSeekCopiar e abrir

Na mochila de capacidade 7 com objetos A=(2,3)A=(2,3), B=(3,4)B=(3,4), C=(4,5)C=(4,5) e D=(5,8)D=(5,8), a decisão sobre DD separa dois subproblemas: incluir DD 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 F[i,w]F[i,w] como o valor máximo usando os primeiros ii objetos, com capacidade disponível ww. Não exige preencher a mochila. Assumimos pesos inteiros positivos e valores não negativos.

Se o objeto ii tem peso pip_i e valor viv_i, ou o excluímos, ou o incluímos uma vez:

F[i,w]={F[i−1,w],pi>w,max⁡(F[i−1,w], vi+F[i−1,w−pi]),pi≤w.F[i,w]=\begin{cases} F[i-1,w],&p_i>w,\\ \max(F[i-1,w],\ v_i+F[i-1,w-p_i]),&p_i\le w. \end{cases}

Os casos base são F[0,w]=0F[0,w]=0 e F[i,0]=0F[i,0]=0. 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 A=(2,3)A=(2,3), B=(3,4)B=(3,4), C=(4,5)C=(4,5), D=(5,8)D=(5,8) e W=7W=7:

Na última célula, excluir DD dá F[3,7]=9F[3,7]=9; incluí-lo dá 8+F[3,2]=8+3=118+F[3,2]=8+3=11. O valor máximo é 11.

Reconstruir objetos

Começa em (i,w)=(4,7)(i,w)=(4,7). Como F[4,7]≠F[3,7]F[4,7]\ne F[3,7], escolhemos DD e passamos para (3,2)(3,2). As linhas de CC e BB não melhoraram a capacidade 2, por isso excluímos esses objetos. A linha de AA melhora de zero para 3, portanto escolhemos AA. 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.

Mochila 0-1 com reconstrução
#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';
}
Resultado

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á (n+1)(W+1)(n+1)(W+1) estados, com trabalho constante: Θ(nW)\Theta(nW) tempo e Θ(nW)\Theta(nW) espaço. A reconstrução custa O(n)O(n). 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

O(nW)O(nW) é polinomial em nn e no valor de WW, mas a entrada representa WW com O(log⁡(W+1))O(\log(W+1)) bits. Se W=2kW=2^k, a tabela tem 2k2^k colunas para uma capacidade representada com cerca de kk 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 c1,…,cnc_1,\ldots,c_n e quantidade ilimitada, define C[i,t]C[i,t] como o mínimo de moedas para pagar exatamente tt usando as primeiras ii denominações. Temos C[i,0]=0C[i,0]=0 e C[0,t]=+∞C[0,t]=+\infty para t>0t>0.

C[i,t]=min⁡(C[i−1,t], 1+C[i,t−ci]),C[i,t]=\min(C[i-1,t],\ 1+C[i,t-c_i]),

ignorando a segunda alternativa se t<cit<c_i. A mesma linha ii permite reutilizar a denominação. Uma célula infinita significa impossível, não zero moedas. O tempo é O(nT)O(nT) e o espaço é O(nT)O(nT), ou O(T)O(T) para o valor.

Com moedas 1, 3 e 4, os valores mínimos para alvos de 0 a 6 são 0,1,2,1,1,2,20,1,2,1,1,2,2. Para 6, a recorrência prefere 3+33+3 ao guloso 4+1+14+1+1.

Se o stock de cic_i é sis_i, o estado deve respeitá-lo:

C[i,t]=min⁡0≤q≤min⁡(si,⌊t/ci⌋)(q+C[i−1,t−qci]).C[i,t]=\min_{0\le q\le\min(s_i,\lfloor t/c_i\rfloor)}\big(q+C[i-1,t-qc_i]\big).

Aqui a linha anterior é necessária: escolhemos toda a quantidade qq dessa denominação uma vez.

Conservamos as moedas 1,3,41,3,4, stocks 2,2,12,2,1 e alvo 6 usados na força bruta. Com apenas duas moedas de 1, C[1,6]=+∞C[1,6]=+\infty. Na linha da moeda 3, as alternativas para o alvo 6 são:

Quantidade qq de moedas de 3Resto a pagar com moedas de 1Custo
06Impossível
13Impossível, stock de 1 igual a 2
202+C[1,0]=22+C[1,0]=2

Na linha da moeda 4, não a usar conserva 2. Usar uma deixa alvo 2, com custo 1+C[2,2]=31+C[2,2]=3. Vence a primeira alternativa. Para reconstruir, guardamos q=0q=0 na linha de 4, q=2q=2 na linha de 3 e q=0q=0 na linha de 1. Obtemos 3+33+3, as mesmas duas moedas encontradas por enumeração.

A formulação direta testa até S+1S+1 quantidades por célula, para stock máximo SS, e custa O(nT(S+1))O(nT(S+1)) tempo e O(nT)O(nT) espaço. Otimizações da mochila limitada podem reduzir o trabalho, preservando o significado do estado.

Soma de subconjuntos

Com inteiros não negativos aia_i, define B[i,t]B[i,t] como “há um subconjunto dos primeiros ii elementos com soma tt”. Inicializa B[0,0]B[0,0] a verdadeiro e os outros a falso:

B[i,t]=B[i−1,t]∨(t≥ai ∧ B[i−1,t−ai]).B[i,t]=B[i-1,t]\lor\big(t\ge a_i\ \land\ B[i-1,t-a_i]\big).

É a mesma decisão incluir/excluir, agora com existência em vez de máximo. O custo é O(nT)O(nT). Para reconstruir, se a célula da linha anterior já é verdadeira, podemos excluir; caso contrário incluímos aia_i e reduzimos tt. Elementos zero exigem uma regra de desempate, mas não tornam a existência incorreta.

O subvetor máximo em tempo linear

Define E[i]E[i] como a maior soma de um intervalo não vazio que termina em ii. Esse intervalo ou começa em ii, ou prolonga o melhor terminado em i−1i-1:

E[0]=A[0],E[i]=max⁡(A[i],E[i−1]+A[i]).E[0]=A[0],\qquad E[i]=\max(A[i],E[i-1]+A[i]).

O resultado global é max⁡iE[i]\max_i E[i]. Esta é a forma dinâmica do algoritmo de Kadane. Guarda o início do intervalo atual: reinicia em ii quando escolher A[i]A[i] 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:

iiA[i]A[i]E[i]E[i]Máximo até ii
0−4-4−4-4−4-4
1333
2−1-123
3577
4−6-617
5237

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 nn estados e trabalho constante, portanto O(n)O(n) tempo e O(1)O(1) memória auxiliar para valor e extremos.

Caminhos por etapas e combinações

Num DAG com destino tt, define D[t]=0D[t]=0 e

D[u]=min⁡u→v(w(u,v)+D[v]).D[u]=\min_{u\to v}\big(w(u,v)+D[v]\big).

Para u≠tu\ne t, processa em ordem topológica inversa; um vértice sem caminho até tt mantém infinito. Não atualizes D[t]D[t] pelas suas saídas: o caminho procurado já terminou em tt. Os subcaminhos repetidos ficam guardados e o custo é O(n+m)O(n+m). Um estado que omitisse o vértice atual não distinguiria continuações diferentes.

Para combinações com 0≤k≤n0\le k\le n, (nk)=(n−1k−1)+(n−1k)\binom nk=\binom{n-1}{k-1}+\binom{n-1}k, com (n0)=(nn)=1\binom n0=\binom nn=1 e (ij)=0\binom ij=0 para j>ij>i. As parcelas representam incluir ou excluir um elemento fixo. Uma tabela até (n,k)(n,k) usa O(nk)O(nk) operações; os valores podem exigir inteiros grandes. Ao reduzir a memória a uma linha, percorre kk 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.

Voltar à explicação
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.

Voltar à explicação
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).

Python
def maior_soma(valores):
    pass
Testes e resultado

Verifica o código para comparar o resultado com os testes.

Testes
  1. Intervalo interior
    Código do teste
    print(maior_soma([-4, 3, -1, 5, -6, 2]))
    Esperado:
    7
  2. Só negativos
    Código do teste
    print(maior_soma([-8, -2, -5]))
    Esperado:
    -2
  3. Um elemento
    Código do teste
    print(maior_soma([4]))
    Esperado:
    4
  4. Zeros
    Código do teste
    print(maior_soma([0, 0, 0]))
    Esperado:
    0
  5. Recomeçar depois de uma perda
    Código do teste
    print(maior_soma([5, -10, 6, 2]))
    Esperado:
    8
  6. Conservar os dados
    Código do teste
    a = [2, -1, 3]
    print(maior_soma(a))
    print(a)
    Esperado:
    4
    [2, -1, 3]
  7. Lista vazia
    Código do teste
    print(maior_soma([]))
    Esperado:
    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 melhor

Antes 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.

Voltar à explicação

À tua maneira

Aparência

Modo de cor
Tema
Cor de destaque

Leitura

Fonte de leitura

Álgebra, lógica e uma ideia de cada vez.

Código

Fonte do código

Navegação

Cursor de leitura, sem edição. h, j, k, l movem; / pesquisa nesta página. v seleciona caracteres; V seleciona linhas; y copia. Tab permite destacar ou comentar. Espaço ou Enter abre blocos recolhíveis. Nos cartões, h, j, k, l mudam o foco.

CSS personalizado

Ativa uma sugestão ou guarda o teu CSS. Só muda este navegador.

Usa --page, --text e --accent para acompanhar o tema.

Seletores e recuperação

.site-header, .prose, .course-card, [data-reading-history], [data-playground] e .page-actions.

Abre a aparência com a tecla vírgula ou no rodapé. Se o CSS esconder os controlos, abre esta página sem CSS personalizado.

Pesquisar

Escreve para pesquisar em todo o site.

para escolher · Enter para abrir · Esc para fechar

Copiar fórmula

A cópia automática não está disponível. Copia o LaTeX abaixo.

Pesquisar nesta página

Enter procura · n / N repetem · Esc cancela. Ctrl / Cmd K pesquisa todo o site.

Atalhos de teclado

Clica numa tecla para a mudar. Esc cancela. Backspace desativa.

PesquisarCtrl / Cmd K

Os atalhos não interferem enquanto escreves. Tab e Enter funcionam sempre.