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).
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?
2. Os Dois Sinais de um Problema de DP
Seu "radar de DP" deve apitar quando um problema exibe duas propriedades:
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.
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.
// 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.
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.
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!
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.