Blog

Programação dinâmica em C#: cinco formas que valem a pena memorizar

Programação dinâmica tem fama de ser difícil de reconhecer. Em maratonas quase nunca é: um número pequeno de formas se repete, e reconhecer a forma é quase todo o trabalho.

Todo programa abaixo está completo, rodou no .NET 10, e a saída está colada da execução.

Padrão 41 — DP linear, e o array rolante

Casas em fila, cada uma com dinheiro dentro, e você não pode roubar duas vizinhas.

Em cada casa há duas opções, e as duas já foram respondidas por entradas anteriores: pular a casa e ficar com dp[i-1], ou pegar e somar em dp[i-2].

int[] money = [2, 7, 9, 3, 1];

// Full table: dp[i] is the best from the first i houses.
int[] dp = new int[money.Length + 1];
dp[1] = money[0];
for (int i = 2; i <= money.Length; i++)
{
    int skip = dp[i - 1];                  // do not rob house i-1
    int take = dp[i - 2] + money[i - 1];   // rob it, so house i-2 is the last one allowed
    dp[i] = Math.Max(skip, take);
    Console.WriteLine($"house {i - 1} (${money[i - 1]})  skip={skip,2}  take={take,2}  ->  dp[{i}]={dp[i]}");
}
Console.WriteLine($"\nfull table : [{string.Join(", ", dp)}]  best = {dp[^1]}");

// Only the last two entries are ever read, so keep two ints.
int prev2 = 0, prev1 = 0;
foreach (int m in money)
{
    int cur = Math.Max(prev1, prev2 + m);
    (prev2, prev1) = (prev1, cur);
}
Console.WriteLine($"two ints   : best = {prev1}");
Console.WriteLine($"memory     : {money.Length + 1} ints -> 2");

Ele imprime:

house 1 ($7)  skip= 2  take= 7  ->  dp[2]=7
house 2 ($9)  skip= 7  take=11  ->  dp[3]=11
house 3 ($3)  skip=11  take=10  ->  dp[4]=11
house 4 ($1)  skip=11  take=12  ->  dp[5]=12

full table : [0, 2, 7, 11, 11, 12]  best = 12
two ints   : best = 12
memory     : 6 ints -> 2

A segunda metade é o hábito que vale a pena criar. Só dp[i-1] e dp[i-2] são lidos, então a tabela inteira vira dois ints. Aqui seis células viram duas; com n = 10⁶ são 4MB virando 8 bytes, e isso às vezes é a diferença entre caber no limite de memória e não caber.

Escreva primeiro a versão com tabela, acerte ela, e depois veja quanto ela realmente olha para trás. Se a resposta for “dois”, role a tabela.

Custo: O(n) de tempo, O(1) de espaço depois de rolar.

Use quando cada posição depender de um número fixo de posições anteriores — subir escadas, o ladrão de casas, formas de decodificar, subarray de soma máxima.

Padrão 42 — Mochila, e o laço que muda a pergunta

Itens com pesos e valores, uma mochila com capacidade, e cada item pode ser levado uma vez.

Esse é o padrão que tem a armadilha, e a armadilha é uma palavra só.

crescente 0 1 2 3 4 5 6 ao escrever dp[4], ele lê dp[2] — que ESTE item já atualizou. O item é contado duas vezes. Essa é a mochila ilimitada. decrescente 0 1 2 3 4 5 6 ao escrever dp[4], ele lê dp[2] — ainda o valor do item ANTERIOR. Cada item é usado no máximo uma vez. Essa é a mochila 0/1.

O mesmo array, a mesma expressão, os mesmos itens. Um for conta para cima e o outro conta para baixo, e eles respondem duas perguntas diferentes. Nada no código diz qual delas você queria.

int[] weight = [2, 3, 4];
int[] value  = [3, 4, 5];
int capacity = 6;

// The two versions differ by ONE loop direction. Nothing else.
static int Knapsack(int[] weight, int[] value, int capacity, bool forwards)
{
    int[] dp = new int[capacity + 1];
    for (int i = 0; i < weight.Length; i++)
    {
        if (forwards)
            for (int c = weight[i]; c <= capacity; c++)
                dp[c] = Math.Max(dp[c], dp[c - weight[i]] + value[i]);
        else
            for (int c = capacity; c >= weight[i]; c--)
                dp[c] = Math.Max(dp[c], dp[c - weight[i]] + value[i]);
    }
    return dp[capacity];
}

Console.WriteLine($"items: {string.Join(", ", weight.Zip(value, (w, v) => $"w={w} v={v}"))}");
Console.WriteLine($"capacity: {capacity}\n");

Console.WriteLine($"capacity descending : {Knapsack(weight, value, capacity, false)}   each item used AT MOST ONCE  (0/1)");
Console.WriteLine($"capacity ascending  : {Knapsack(weight, value, capacity, true)}   items reusable               (unbounded)");

Console.WriteLine("\nascending reads dp[c - w] AFTER this same item already updated it,");
Console.WriteLine("so the item gets picked again. 2 + 2 + 2 fills the bag for 9.");
Console.WriteLine("descending reads a cell this item has not touched yet, so it stays 0/1: 2 + 4 for 8.");

Ele imprime:

items: w=2 v=3, w=3 v=4, w=4 v=5
capacity: 6

capacity descending : 8   each item used AT MOST ONCE  (0/1)
capacity ascending  : 9   items reusable               (unbounded)

ascending reads dp[c - w] AFTER this same item already updated it,
so the item gets picked again. 2 + 2 + 2 fills the bag for 9.
descending reads a cell this item has not touched yet, so it stays 0/1: 2 + 4 for 8.

Duas respostas diferentes. O mesmo array, a mesma expressão, os mesmos itens — a única diferença é se o laço da capacidade conta para cima ou para baixo.

Indo para cima, dp[c - w] já foi atualizado por este item nesta mesma passada. Então o item é somado em cima de si mesmo, e 2 + 2 + 2 enche uma mochila de capacidade 6 por 9. Essa é a mochila ilimitada.

Indo para baixo, dp[c - w] ainda guarda o valor de antes de este item ser considerado. Cada item contribui no máximo uma vez, e a resposta é 8. Essa é a mochila 0/1.

Os dois laços compilam, os dois rodam, os dois produzem um número plausível. Nada no código registra qual problema você quis resolver.

Se você só lembrar de uma coisa desta parte: 0/1 conta para baixo. E quando uma resposta de mochila sai alta demais, confira a direção do laço antes de conferir qualquer outra coisa.

Custo: O(itens × capacidade) de tempo, O(capacidade) de espaço.

Use quando você estiver escolhendo um subconjunto dentro de um orçamento — soma de subconjunto, partição em metades iguais, troco de moedas, soma alvo.

Padrão 43 — Subsequência crescente mais longa em O(n log n)

A versão O(n²) — para cada elemento, olhar tudo que veio antes dele — é fácil e muitas vezes lenta demais.

A versão rápida mantém um array onde tails[k] é o menor valor que pode terminar uma subsequência crescente de tamanho k+1. Cada valor novo ou estende o array ou substitui a primeira entrada que é pelo menos tão grande quanto ele.

tails 2 3 7 101 tam 1tam 2 tam 3tam 4 18 chega. depois 2 3 7 18 Agora um trecho de tamanho 4 termina em 18, não 101, o que deixa mais espaço para o que vier depois. O TAMANHO nunca mudou. Só o teto para estendê-lo baixou.

Cada casa guarda o menor valor que pode terminar um trecho desse tamanho. Substituir nunca encurta nada — só facilita as extensões futuras — e é por isso que o tamanho do array é a resposta mesmo que o conteúdo dele não seja uma subsequência real.

// tails[k] = the SMALLEST value that can end an increasing subsequence of
// length k+1. Only its LENGTH is meaningful; see the second example.
static List<int> Lis(int[] a, bool trace)
{
    List<int> tails = [];
    foreach (int x in a)
    {
        int pos = tails.BinarySearch(x);
        if (pos < 0) pos = ~pos;                   // insertion point

        if (pos == tails.Count)
        {
            tails.Add(x);
            if (trace) Console.WriteLine($"{x,3}  bigger than everything  append      tails=[{string.Join(",", tails)}]");
        }
        else
        {
            int old = tails[pos];
            tails[pos] = x;
            if (trace) Console.WriteLine($"{x,3}  replaces {old,3} at index {pos}   tails=[{string.Join(",", tails)}]");
        }
    }
    return tails;
}

int[] a = [10, 9, 2, 5, 3, 7, 101, 18];
var t = Lis(a, true);
Console.WriteLine($"\nlongest increasing subsequence length: {t.Count}");

// Now the one that shows tails is not an answer, only a length.
int[] b = [3, 4, 5, 1, 2];
var t2 = Lis(b, false);
Console.WriteLine($"\nb = [{string.Join(", ", b)}]");
Console.WriteLine($"tails  = [{string.Join(", ", t2)}]   length {t2.Count}  <- correct length");
Console.WriteLine($"but 1 and 2 appear at indices 3 and 4, while 5 is at index 2.");
Console.WriteLine($"so [1, 2, 5] is not a subsequence of b at all. An actual LIS is 3, 4, 5.");

Ele imprime:

 10  bigger than everything  append      tails=[10]
  9  replaces  10 at index 0   tails=[9]
  2  replaces   9 at index 0   tails=[2]
  5  bigger than everything  append      tails=[2,5]
  3  replaces   5 at index 1   tails=[2,3]
  7  bigger than everything  append      tails=[2,3,7]
101  bigger than everything  append      tails=[2,3,7,101]
 18  replaces 101 at index 3   tails=[2,3,7,18]

longest increasing subsequence length: 4

b = [3, 4, 5, 1, 2]
tails  = [1, 2, 5]   length 3  <- correct length
but 1 and 2 appear at indices 3 and 4, while 5 is at index 2.
so [1, 2, 5] is not a subsequence of b at all. An actual LIS is 3, 4, 5.

Substituir nunca encurta nada. Isso baixa o teto para estender um trecho desse tamanho, o que só ajuda depois. É por isso que tails.Count é a resposta.

O segundo exemplo está aí porque a ressalva é fácil de dizer e fácil de não acreditar. Para [3, 4, 5, 1, 2], tails termina como [1, 2, 5] — o tamanho certo, mas não é subsequência nenhuma da entrada, já que 5 aparece antes de 1 e 2. Se você precisa da subsequência de verdade, guarde um índice de predecessor por elemento e volte por ele.

List<T>.BinarySearch devolve ~insertionPoint quando não acha, que é o padrão 16 da parte 4 fazendo trabalho de verdade aqui.

Custo: O(n log n) de tempo, O(n) de espaço.

Use quando o problema for sobre trechos crescentes ou decrescentes — empilhar caixas, bonecas russas, patience sorting. Para crescente não estrito, troque por uma busca por limite superior.

Padrão 44 — DP em grade

Duas sequências, uma tabela, e cada célula responde “quanto custa conciliar estes dois prefixos”.

string s = "kitten", t = "sitting";

// dp[i,j] = edits to turn the first i of s into the first j of t.
int[,] dp = new int[s.Length + 1, t.Length + 1];
for (int i = 0; i <= s.Length; i++) dp[i, 0] = i;   // delete everything
for (int j = 0; j <= t.Length; j++) dp[0, j] = j;   // insert everything

for (int i = 1; i <= s.Length; i++)
for (int j = 1; j <= t.Length; j++)
{
    dp[i, j] = s[i - 1] == t[j - 1]
        ? dp[i - 1, j - 1]                                   // same letter, free
        : 1 + Math.Min(dp[i - 1, j - 1],                     // substitute
              Math.Min(dp[i - 1, j],                         // delete from s
                       dp[i, j - 1]));                       // insert into s
}

Console.Write("       ");
foreach (char c in t) Console.Write($"{c,4}");
Console.WriteLine();
for (int i = 0; i <= s.Length; i++)
{
    Console.Write(i == 0 ? "   " : $" {s[i - 1]} ");
    for (int j = 0; j <= t.Length; j++) Console.Write($"{dp[i, j],4}");
    Console.WriteLine();
}

Console.WriteLine($"\nedit distance(\"{s}\", \"{t}\") = {dp[s.Length, t.Length]}");
Console.WriteLine("k->s substitute, e->i substitute, insert g. Three edits.");

Ele imprime:

          s   i   t   t   i   n   g
      0   1   2   3   4   5   6   7
 k    1   1   2   3   4   5   6   7
 i    2   2   1   2   3   4   5   6
 t    3   3   2   1   2   3   4   5
 t    4   4   3   2   1   2   3   4
 e    5   5   4   3   2   2   3   4
 n    6   6   5   4   3   3   2   3

edit distance("kitten", "sitting") = 3
k->s substitute, e->i substitute, insert g. Three edits.

A primeira linha e a primeira coluna são os casos base e carregam significado real: transformar algo na string vazia custa uma remoção por caractere. Erre nelas e todas as outras células herdam o erro.

Cada célula interna olha exatamente três vizinhas — a diagonal para substituir, a de cima para remover, a da esquerda para inserir — e a diagonal é de graça quando os caracteres batem. A resposta é o canto inferior direito, e o caminho de volta pela tabela é o roteiro de edições, se você precisar dele.

Só a linha anterior é lida, então isso rola para duas linhas do mesmo jeito que o padrão 41 rolou para dois ints.

Custo: O(n × m) de tempo, O(min(n, m)) de espaço depois de rolar.

Use quando duas sequências estiverem sendo comparadas ou alinhadas — distância de edição, subsequência comum mais longa, ou caminhos por uma grade.

Padrão 45 — DP com bitmask

Quando o estado é “qual subconjunto eu já usei”, e o conjunto é pequeno, o subconjunto é o índice do array.

Um int tem 32 bits. O bit i ligado quer dizer que o item i foi usado. Então dp[mask][i] indexa direto, sem dicionário e sem hashing.

int[,] d =
{
    {  0, 10, 15, 20 },
    { 10,  0, 35, 25 },
    { 15, 35,  0, 30 },
    { 20, 25, 30,  0 },
};
int n = 4;

// dp[mask, i] = cheapest route that starts at 0, visits exactly the cities in
// mask, and is currently standing at i. The mask IS the memo key — that is the
// whole idea, and it only works because n is small.
int[,] dp = new int[1 << n, n];
for (int m = 0; m < (1 << n); m++)
    for (int i = 0; i < n; i++) dp[m, i] = int.MaxValue / 2;
dp[1, 0] = 0;                                    // started at city 0, only 0 visited

for (int mask = 1; mask < (1 << n); mask++)
for (int i = 0; i < n; i++)
{
    if ((mask & (1 << i)) == 0 || dp[mask, i] >= int.MaxValue / 2) continue;
    for (int j = 0; j < n; j++)
    {
        if ((mask & (1 << j)) != 0) continue;    // already visited
        int next = mask | (1 << j);
        int cost = dp[mask, i] + d[i, j];
        if (cost < dp[next, j]) dp[next, j] = cost;
    }
}

int full = (1 << n) - 1;
int best = int.MaxValue;
int bestEnd = -1;
for (int i = 1; i < n; i++)
{
    int total = dp[full, i] + d[i, 0];           // and home again
    Console.WriteLine($"visit everything, end at {i}: {dp[full, i],3} + {d[i, 0],3} home = {total}");
    if (total < best) { best = total; bestEnd = i; }
}

Console.WriteLine($"\nbest tour: {best}  (last city before home is {bestEnd})");
Console.WriteLine($"\ntable size 2^{n} x {n} = {(1 << n) * n} entries");
// (n-1)! distinct tours, because the starting city is fixed.
Console.WriteLine($"{"n",4}  {"(n-1)! routes",24}  {"2^n * n table",16}");
foreach (int k in new[] { 4, 8, 12, 16, 20 })
{
    double fact = 1; for (int i = 2; i < k; i++) fact *= i;
    Console.WriteLine($"{k,4}  {fact,24:N0}  {(long)(1L << k) * k,16:N0}");
}

Ele imprime:

visit everything, end at 1:  70 +  10 home = 80
visit everything, end at 2:  65 +  15 home = 80
visit everything, end at 3:  75 +  20 home = 95

best tour: 80  (last city before home is 1)

table size 2^4 x 4 = 64 entries
   n             (n-1)! routes     2^n * n table
   4                         6                64
   8                     5,040             2,048
  12                39,916,800            49,152
  16         1,307,674,368,000         1,048,576
  20   121,645,100,408,832,000        20,971,520

A tabela no fim é a justificativa. Com n = 12, conferir todas as rotas dá 40 milhões; a tabela dá 49 mil. Com n = 20 são cento e vinte quatrilhões contra vinte milhões.

Repare também na velocidade com que 2^n × n cresce. Essa é uma técnica para n até uns 20. Com 25 já são 800 milhões de entradas, e não tem jeito esperto de escapar — o exponencial mudou de lugar, ele não sumiu.

As três operações de bit são tudo que você precisa: mask & (1 << i) testa, mask | (1 << i) adiciona, e (1 << n) - 1 é o conjunto completo.

Custo: O(2ⁿ × n²) de tempo, O(2ⁿ × n) de espaço.

Use quando n ≤ 20 e o estado for um subconjunto — caixeiro-viajante, problemas de atribuição, “cobrir tudo com custo mínimo”. Um n pequeno nas restrições, quando n conta coisas entre as quais escolher, é quase um anúncio.

O que lembrar

  • Escreva a tabela inteira, depois role. Acerte, veja quanto ela olha para trás, e encolha. Rolar primeiro é como você acaba depurando duas coisas ao mesmo tempo.

  • A mochila 0/1 conta a capacidade PARA BAIXO. Contar para cima reusa o item e resolve, em silêncio, a versão ilimitada. Esse é o bug de DP mais comum e ele produz um número plausível.

  • tails no algoritmo de LIS é um tamanho, não uma resposta. O conteúdo dele não precisa ser subsequência nenhuma da entrada.

  • Linhas e colunas base carregam significado. Na distância de edição elas são o custo de remover ou inserir tudo. Errar nelas envenena a tabela inteira.

  • Um bitmask é um índice de array, não um objeto de conjunto. mask & (1 << i), mask | (1 << i), (1 << n) - 1.

  • DP com bitmask funciona até uns n = 20 e não além. Ela converte um fatorial em um exponencial, o que é progresso, não cura.

A parte 10 é a última, e é a parte com que você só se importa depois que uma solução correta já estourou o limite de tempo: entrada rápida, Span<T>, overflow, aritmética modular, e saída com buffer.

How useful was this post?

Click on a heart to rate it!

Average rating 0 / 5. Vote count: 0

No votes so far! Be the first to rate this post.