devly
Voltar para Algoritmos

O Próximo Nível: Uma Introdução à Programação Dinâmica (DP)

Desmistifique a Programação Dinâmica. Aprenda a reconhecer problemas de DP e a resolvê-los com Memoização (Top-Down) e Tabulação (Bottom-Up).

Atualizado em: 16 de Junho, 2025
Notas do Autor

Se existe um tópico que bota medo em dev, é Programação Dinâmica. O nome parece ter saído de um livro de matemática obscuro. Confesso: eu fugi de DP por muito tempo, achando que era complexo demais.

Até que um dia, um mentor me explicou que DP é só uma forma chique de dizer: "seja esperto, não resolva o mesmo problema duas vezes". É sobre "lembrar" do que você já fez para não ter que recalcular tudo de novo. Simples assim.

A partir daí, o bicho-papão virou uma ferramenta poderosa. Esta aula é pra te mostrar esse "pulo do gato", desmistificar a DP e te dar as duas "receitas" principais para resolver essa classe de problemas.


1. O "Bicho-Papão" das Entrevistas: O que é DP?

Programação Dinâmica é uma técnica de otimização para resolver problemas complexos quebrando-os em subproblemas menores e sobrepostos. A chave é resolver cada subproblema apenas uma vez e armazenar seu resultado. Quando o mesmo subproblema aparece de novo, você simplesmente busca o resultado armazenado em vez de recalculá-lo.

Quando Pensar em DP?

Fique de olho em problemas (geralmente de otimização, como "qual o máximo/mínimo/melhor...") que podem ser resolvidos com recursão, mas cuja solução recursiva ingênua é muito lenta porque calcula a mesma coisa várias vezes.

2. Os Dois Sinais de um Problema de DP

Seu "radar de DP" deve apitar quando um problema exibe duas propriedades:

1. Subproblemas Sobrepostos (Overlapping Subproblems): Ao quebrar o problema principal, você percebe que está resolvendo os mesmos subproblemas menores repetidamente. O exemplo clássico é o cálculo de Fibonacci de forma recursiva.
2. Subestrutura Ótima (Optimal Substructure): A solução ótima para o problema principal pode ser construída a partir das soluções ótimas de seus subproblemas.

Exemplo: O Desastre do Fibonacci Ingênuo

Para calcular `fib(5)`, a recursão simples calcula `fib(4)` e `fib(3)`. Mas para calcular `fib(4)`, ela também calcula `fib(3)`. O subproblema `fib(3)` é resolvido duas vezes. Para `fib(10)`, `fib(5)` é calculado várias vezes! Isso leva a uma complexidade exponencial.

javascript
function fib(n) {
  if (n <= 1) {
    return n;
  }
  // Observe como fib(n-1) e fib(n-2) são chamados,
  // e ambos chamarão fib(n-3), fib(n-4), etc., repetidamente.
  return fib(n - 1) + fib(n - 2);
}
// Complexidade de Tempo: O(2ⁿ) - Exponencial! Terrível.

3. As Duas Receitas para DP: Memoização e Tabulação

Existem duas formas principais de implementar uma solução de DP.

Receita 1: Memoização (Top-Down) - A Preguiça Inteligente

Você mantém a estrutura recursiva original, mas adiciona um "cache" (um mapa ou array) para guardar os resultados. Antes de fazer qualquer cálculo, você verifica se o resultado já está no cache.

Cuidado com o Escopo do Cache!

Em ambientes de teste (como LeetCode), declarar o cache no escopo global pode levar a erros, pois ele não é "resetado" entre os casos de teste. A abordagem mais segura é encapsular o cache, por exemplo, passando-o como um parâmetro na função recursiva.

Analogia: É como anotar o resultado de uma conta difícil em um post-it. Se precisar da mesma conta depois, você só olha o post-it.

javascript
// Passando o cache como parâmetro (mais robusto para testes)
function fibMemo(n, cache = {}) {
  // Se o resultado já está no cache, retorna ele em O(1).
  if (n in cache) {
    return cache[n];
  }

  // Caso Base
  if (n <= 1) {
    return n;
  }

  // Se não está no cache, calcula e GUARDA ANTES de retornar.
  // Passamos o cache nas chamadas recursivas.
  cache[n] = fibMemo(n - 1, cache) + fibMemo(n - 2, cache);
  return cache[n];
}
// Complexidade de Tempo: O(n) - Linear!
// Complexidade de Espaço: O(n) - para o cache.

Receita 2: Tabulação (Bottom-Up) - Construindo a Solução

Você resolve o problema de forma iterativa, "de baixo para cima". Você cria uma tabela (geralmente um array) e a preenche desde os casos base até a solução final.

Analogia: É como construir um muro. Você não começa pelo topo; você assenta os tijolos da base primeiro e vai subindo, camada por camada.

javascript
function fibTab(n) {
  if (n <= 1) return n;

  // 1. Cria uma "tabela" (array) para guardar os resultados.
  const table = Array(n + 1).fill(0);

  // 2. Preenche os casos base.
  table[1] = 1;

  // 3. Itera do começo até o fim, construindo a solução.
  for (let i = 2; i <= n; i++) {
    table[i] = table[i - 1] + table[i - 2];
  }

  // 4. O resultado final está na última posição da tabela.
  return table[n];
}
// Complexidade de Tempo: O(n) - Linear!
// Complexidade de Espaço: O(n) - para a tabela.

Refinamento da Tabulação: Otimizando para Espaço O(1)

Note que para calcular table[i], nós só precisamos dos dois valores anteriores (table[i-1] e table[i-2]). Não precisamos guardar a tabela inteira! Podemos otimizar o espaço usando apenas duas variáveis.

javascript
function fibTabOptimized(n) {
  if (n <= 1) return n;

  let a = 0; // Representa fib(i-2)
  let b = 1; // Representa fib(i-1)
  let c;     // Representa fib(i)

  for (let i = 2; i <= n; i++) {
    c = a + b;
    a = b;
    b = c;
  }

  return b;
}
// Complexidade de Tempo: O(n)
// Complexidade de Espaço: O(1) - UAU!

DP na Prática: O Problema da Escada (Climbing Stairs)

O Problema: Você está subindo uma escada com n degraus. A cada passo, você pode subir 1 ou 2 degraus. De quantas maneiras distintas você pode chegar ao topo?

Análise: Para chegar no degrau n, você só pode ter vindo do degrau n-1 (e dado um passo) ou do degrau n-2 (e dado dois passos). Portanto, maneiras(n) = maneiras(n-1) + maneiras(n-2). Opa! Isso é exatamente a relação de recorrência de Fibonacci!

A Solução com DP

Podemos resolver isso exatamente da mesma forma que Fibonacci, usando tabulação otimizada para O(1) de espaço. A única diferença são os casos base: climbStairs(1) = 1 e climbStairs(2) = 2. Nosso código já reflete isso. O problema que parecia diferente é, no fundo, o mesmo!

javascript
function climbStairs(n) {
  // Casos base:
  // Se n=1, há 1 maneira (1 passo)
  // Se n=2, há 2 maneiras (1+1 ou 2)
  if (n <= 2) return n;

  // Começamos a iterar a partir de n=3.
  // "oneStepBefore" guarda as maneiras de chegar em n-1 (que é 2 para n=3)
  // "twoStepsBefore" guarda as maneiras de chegar em n-2 (que é 1 para n=3)
  let oneStepBefore = 2; // maneiras(2)
  let twoStepsBefore = 1; // maneiras(1)
  let allWays = 0;

  for (let i = 3; i <= n; i++) {
    allWays = oneStepBefore + twoStepsBefore;
    twoStepsBefore = oneStepBefore;
    oneStepBefore = allWays;
  }
  return allWays;
}

4. Memoização vs. Tabulação: Qual Usar?

Ambas as abordagens chegam ao mesmo resultado e, na maioria das vezes, com a mesma complexidade. A escolha depende do problema e da sua preferência.

Guia Rápido de Escolha

  • Use Memoização (Top-Down) se: A solução recursiva natural é óbvia para você. É mais intuitivo "adicionar um cache" a uma função que você já escreveu. Geralmente, o código fica mais parecido com a definição matemática do problema.
  • Use Tabulação (Bottom-Up) se: Você precisa evitar o risco de `stack overflow` em casos de recursão muito profunda. A abordagem iterativa também pode ser um pouco mais performática por não ter o overhead das chamadas de função recursivas.
  • Dica de Entrevista: Saber explicar e implementar qualquer uma das duas é ótimo. Se conseguir discutir os prós e contras de cada uma, é um sinal de conhecimento profundo!

💊 Pílula Devly

Programação Dinâmica não é um bicho de sete cabeças. É simplesmente sobre lembrar do que você já fez para não ter que fazer de novo. Viu um problema com subproblemas que se repetem? Pare e pense: "Como posso guardar esse resultado?". Essa é a chave que destrava a DP e te coloca no próximo nível da resolução de problemas.

Anterior

Padrões para Quebrar a Banca

Voltar

Próximo

O Jogo da Entrevista: Estratégia e Comunicação no Code Challenge

Continuar