Programação Dinâmica (Dynamic Programming)
Introdução
Programação Dinâmica (DP) é uma técnica de otimização introduzida por Richard Bellman na década de 1950. O nome "programação" não se refere a código, mas sim ao planejamento matemático de decisões — um termo comum na época. A técnica resolve problemas complexos dividindo-os em subproblemas menores e armazenando os resultados para evitar cálculos repetidos.
No encontro anterior, aprendemos Backtracking — uma técnica que explora todas as soluções possíveis, construindo caminhos e retrocedendo quando necessário. Backtracking é poderoso para enumerar soluções, mas quando queremos apenas contar quantas soluções existem ou encontrar a solução ótima (mínimo/máximo), podemos usar uma abordagem mais eficiente.
A Programação Dinâmica transforma algoritmos exponenciais em algoritmos polinomiais através de uma ideia simples: nunca calcule a mesma coisa duas vezes.
Quando usar Programação Dinâmica?
DP requer duas propriedades fundamentais:
| Propriedade | Significado | Exemplo |
|---|---|---|
| Subproblemas sobrepostos | O mesmo subproblema é resolvido múltiplas vezes | Fibonacci: f(3) é calculado várias vezes ao calcular f(5) |
| Subestrutura ótima | A solução ótima é construída a partir de soluções ótimas dos subproblemas | Caminho mínimo: o menor caminho de A→C passa pelo menor caminho de A→B |
Backtracking vs Recursão vs Programação Dinâmica
Antes de avançar, é importante entender a diferença entre essas técnicas:
| Técnica | O que faz | Quando usar |
|---|---|---|
| Backtracking | Constrói soluções explicitamente, desfaz escolhas | Enumerar TODAS as soluções (subsets, permutações) |
| Recursão | Resolve problema chamando a si mesmo com entrada menor | Definição matemática direta (Fibonacci) |
| Programação Dinâmica | Recursão + cache de resultados | CONTAR soluções ou encontrar ÓTIMO (min/max) |
Exemplo prático: Climbing Stairs
Problema: Você está subindo uma escada com n degraus. Pode subir 1 ou 2 degraus por vez. De quantas formas distintas você pode chegar ao topo?
Com Backtracking (enumerar todos os caminhos):
function climbStairs(n) {
const allPaths = [];
function backtrack(currentStep, path) {
if (currentStep === n) {
allPaths.push([...path]); // armazena solução completa
return;
}
if (currentStep > n) return; // inválido, podar
for (const step of [1, 2]) {
path.push(step); // adiciona escolha
backtrack(currentStep + step, path); // recursão
path.pop(); // remove escolha (backtrack)
}
}
backtrack(0, []);
return allPaths; // retorna [[1,1,1], [1,2], [2,1]] para n=3
}
Com Recursão (apenas contar):
function climbStairs(n) {
if (n <= 1) return 1;
return climbStairs(n - 1) + climbStairs(n - 2);
}
Perceba: quando só precisamos contar, não precisamos construir os caminhos explicitamente. Mas essa recursão tem um problema...
O Problema da Recursão Ingênua
Vamos visualizar a árvore de recursão para climbStairs(5):
graph TD
A["f(5)"] -->|"1 step"| B["f(4)"]
A -->|"2 steps"| C["f(3)"]
B -->|"1 step"| D["f(3)"]
B -->|"2 steps"| E["f(2)"]
C -->|"1 step"| F["f(2)"]
C -->|"2 steps"| G["f(1)"]
D -->|"1 step"| H["f(2)"]
D -->|"2 steps"| I["f(1)"]
style C fill:#ffcccc
style D fill:#ffcccc
style E fill:#ffffcc
style F fill:#ffffcc
style H fill:#ffffcc
Note que f(3) é calculado 2 vezes, f(2) é calculado 3 vezes. Para valores maiores de n, essa repetição explode exponencialmente.
| n | Chamadas sem cache | Chamadas com cache |
|---|---|---|
| 10 | 177 | 10 |
| 20 | 21.891 | 20 |
| 30 | 2.692.537 | 30 |
| 40 | 331.160.281 | 40 |
Complexidade sem cache: O(2ⁿ) — exponencial
Complexidade com cache: O(n) — linear
As Duas Abordagens de DP
Top-Down (Memoização)
A abordagem Top-Down mantém a estrutura recursiva original e adiciona um cache para armazenar resultados já calculados.
Intuição: "Começo do problema original e vou descendo. Se já calculei um subproblema, apenas retorno o resultado salvo."
function climbStairs(n, memo = new Map()) {
if (n <= 1) return 1;
if (memo.has(n)) return memo.get(n); // já calculamos isso!
const result = climbStairs(n - 1, memo) + climbStairs(n - 2, memo);
memo.set(n, result); // salva para uso futuro
return result;
}
Tempo: O(n) — cada subproblema calculado uma vez Espaço: O(n) — memo + call stack
Bottom-Up (Tabulação)
A abordagem Bottom-Up inverte a direção: começamos dos casos base e construímos a solução iterativamente.
Intuição: "Se eu já sei a ordem dos subproblemas, por que usar recursão? Posso simplesmente preencher uma tabela do início ao fim."
function climbStairs(n) {
if (n <= 1) return 1;
const dp = new Array(n + 1);
dp[0] = 1;
dp[1] = 1;
for (let i = 2; i <= n; i++) {
dp[i] = dp[i - 1] + dp[i - 2];
}
return dp[n];
}
Tempo: O(n) Espaço: O(n) — mas sem overhead de recursão
Otimização de Espaço
Observe que para calcular dp[i], precisamos apenas de dp[i-1] e dp[i-2]. Por que manter o array inteiro?
function climbStairs(n) {
if (n <= 1) return 1;
let prev2 = 1;
let prev1 = 1;
for (let i = 2; i <= n; i++) {
const current = prev1 + prev2;
prev2 = prev1;
prev1 = current;
}
return prev1;
}
Tempo: O(n) Espaço: O(1) — apenas duas variáveis!
Top-Down vs Bottom-Up
| Fator | Top-Down (Memoização) | Bottom-Up (Tabulação) |
|---|---|---|
| Estrutura | Recursiva | Iterativa |
| Overhead de recursão | Sim (custo de chamadas) | Não |
| Risco de stack overflow | Sim (recursão profunda) | Não |
| Localidade de cache | Ruim (pula na memória) | Boa (acesso sequencial) |
| Calcula todos os subproblemas | Não (apenas necessários) | Sim (preenche tabela inteira) |
| Otimização de espaço | Mais difícil | Mais fácil |
| Facilidade de implementação | Geralmente mais intuitiva | Requer entender ordem dos subproblemas |
| Melhor quando | Espaço de estados esparso, terminação antecipada | Espaço denso, performance crítica |
Problema Clássico: Coin Change
Problema: Dado um array de moedas coins e um valor amount, retorne o menor número de moedas necessárias para formar esse valor. Se não for possível, retorne -1.
Exemplo: coins = [1, 3, 4], amount = 6 → Resposta: 2 (3 + 3)
Backtracking (Exponencial)
function coinChange(coins, amount) {
if (amount === 0) return 0;
if (amount < 0) return Infinity;
let minCoins = Infinity;
for (const coin of coins) {
const result = coinChange(coins, amount - coin);
minCoins = Math.min(minCoins, result + 1);
}
return minCoins;
}
Tempo: O(kⁿ) onde k = número de moedas
Top-Down com Memoização
function coinChange(coins, amount, memo = new Map()) {
if (amount === 0) return 0;
if (amount < 0) return Infinity;
if (memo.has(amount)) return memo.get(amount);
let minCoins = Infinity;
for (const coin of coins) {
const result = coinChange(coins, amount - coin, memo);
minCoins = Math.min(minCoins, result + 1);
}
memo.set(amount, minCoins);
return minCoins;
}
// Uso:
const result = coinChange([1, 3, 4], 6);
console.log(result === Infinity ? -1 : result); // 2
Tempo: O(amount × coins) Espaço: O(amount)
Bottom-Up com Tabulação
function coinChange(coins, amount) {
const dp = new Array(amount + 1).fill(Infinity);
dp[0] = 0;
for (let i = 1; i <= amount; i++) {
for (const coin of coins) {
if (coin <= i) {
dp[i] = Math.min(dp[i], dp[i - coin] + 1);
}
}
}
return dp[amount] === Infinity ? -1 : dp[amount];
}
Tempo: O(amount × coins) Espaço: O(amount)
DP 1D vs DP 2D
A "dimensão" do DP refere-se a quantas variáveis definem um subproblema.
| Dimensão | Estado | Exemplos |
|---|---|---|
| 1D | dp[i] — uma variável |
Fibonacci, Climbing Stairs, Coin Change, House Robber |
| 2D | dp[i][j] — duas variáveis |
Unique Paths, LCS, Edit Distance, Knapsack |
| 3D+ | dp[i][j][k]... — três ou mais |
Knapsack com múltiplas restrições |
Problema 2D: Unique Paths
Problema: Um robô está em um grid m x n, começando no canto superior esquerdo. Ele só pode se mover para baixo ou para a direita. De quantas formas distintas ele pode chegar ao canto inferior direito?
Exemplo: m = 3, n = 3 → Resposta: 6
┌───┬───┬───┐
│ S │ → │ │
├───┼───┼───┤
│ ↓ │ │ │
├───┼───┼───┤
│ │ │ E │
└───┴───┴───┘
Backtracking (Exponencial)
function uniquePaths(m, n) {
function backtrack(row, col) {
if (row === m - 1 && col === n - 1) return 1; // chegou!
if (row >= m || col >= n) return 0; // fora do grid
const goDown = backtrack(row + 1, col);
const goRight = backtrack(row, col + 1);
return goDown + goRight;
}
return backtrack(0, 0);
}
Tempo: O(2^(m+n)) — cada célula ramifica em duas escolhas
Top-Down com Memoização
function uniquePaths(m, n) {
const memo = new Map();
function backtrack(row, col) {
if (row === m - 1 && col === n - 1) return 1;
if (row >= m || col >= n) return 0;
const key = `${row},${col}`;
if (memo.has(key)) return memo.get(key);
const result = backtrack(row + 1, col) + backtrack(row, col + 1);
memo.set(key, result);
return result;
}
return backtrack(0, 0);
}
Tempo: O(m × n) — cada célula calculada uma vez Espaço: O(m × n) memo + O(m + n) call stack
Bottom-Up com Tabulação
function uniquePaths(m, n) {
const dp = Array(m).fill(null).map(() => Array(n).fill(0));
// Primeira linha: só uma forma de chegar (indo para direita)
for (let j = 0; j < n; j++) dp[0][j] = 1;
// Primeira coluna: só uma forma de chegar (indo para baixo)
for (let i = 0; i < m; i++) dp[i][0] = 1;
// Preencher o resto
for (let i = 1; i < m; i++) {
for (let j = 1; j < n; j++) {
dp[i][j] = dp[i - 1][j] + dp[i][j - 1]; // de cima + da esquerda
}
}
return dp[m - 1][n - 1];
}
Tempo: O(m × n) Espaço: O(m × n)
Visualização do preenchimento:
┌───┬───┬───┬───┐
│ 1 │ 1 │ 1 │ 1 │
├───┼───┼───┼───┤
│ 1 │ 2 │ 3 │ 4 │
├───┼───┼───┼───┤
│ 1 │ 3 │ 6 │ 10│
└───┴───┴───┴───┘
Otimização de Espaço (2D → 1D)
function uniquePaths(m, n) {
const dp = Array(n).fill(1);
for (let i = 1; i < m; i++) {
for (let j = 1; j < n; j++) {
dp[j] = dp[j] + dp[j - 1]; // dp[j] (de cima) + dp[j-1] (da esquerda)
}
}
return dp[n - 1];
}
Tempo: O(m × n) Espaço: O(n) — apenas uma linha por vez!
Tabela de Evolução: 1D vs 2D
Problemas 1D
| Abordagem | Climbing Stairs | Coin Change |
|---|---|---|
| Backtracking | climb(n-1) + climb(n-2) |
min(change(amt - coin) + 1) |
| Top-Down | + memo.set(n, result) |
+ memo.set(amount, result) |
| Bottom-Up | dp[i] = dp[i-1] + dp[i-2] |
dp[i] = min(dp[i], dp[i-coin] + 1) |
| Otimizado | curr = prev1 + prev2 |
Não possível (precisa de todos os estados anteriores) |
| Tempo | O(2ⁿ) → O(n) | O(kⁿ) → O(n × k) |
| Espaço | O(n) → O(1) | O(n) |
Problemas 2D
| Abordagem | Unique Paths |
|---|---|
| Backtracking | paths(row+1, col) + paths(row, col+1) |
| Top-Down | + memo.set(\${row},${col}`, result)` |
| Bottom-Up | dp[i][j] = dp[i-1][j] + dp[i][j-1] |
| Otimizado | dp[j] = dp[j] + dp[j-1] (1D array) |
| Tempo | O(2^(m+n)) → O(m × n) |
| Espaço | O(m × n) → O(n) |
Outros Problemas Clássicos de DP
| Problema | Dimensão | Transição de Estado | Aplicações Reais |
|---|---|---|---|
| Fibonacci | 1D | f(n) = f(n-1) + f(n-2) |
Modelagem de crescimento, padrões na natureza |
| Climbing Stairs | 1D | dp[i] = dp[i-1] + dp[i-2] |
Contagem de combinações em decisões sequenciais |
| House Robber | 1D | dp[i] = max(dp[i-1], dp[i-2] + nums[i]) |
Alocação de recursos com restrições, agendamento de jobs não-conflitantes |
| Coin Change | 1D | dp[amt] = min(dp[amt - coin] + 1) |
Sistemas de troco, provisionamento de recursos |
| Longest Common Subsequence | 2D | Match: dp[i-1][j-1] + 1, No match: max(dp[i-1][j], dp[i][j-1]) |
Git diff, alinhamento de DNA, detecção de plágio |
| Edit Distance | 2D | Match: dp[i-1][j-1], No match: 1 + min(replace, delete, insert) |
Corretor ortográfico, autocomplete, OCR |
| Unique Paths | 2D | dp[i][j] = dp[i-1][j] + dp[i][j-1] |
Navegação de robôs, roteamento de pacotes |
| 0/1 Knapsack | 2D | max(skip, take) |
Alocação de orçamento, otimização de portfólio, carregamento de carga |
| Minimum Path Sum | 2D | dp[i][j] = grid[i][j] + min(dp[i-1][j], dp[i][j-1]) |
GPS/navegação, otimização de latência de rede |
Complexidade Assintótica
O que significa cada complexidade?
| Nome | Notação | Padrão de Crescimento | Aceitável? |
|---|---|---|---|
| Constante | O(1) | Mesmo tempo independente da entrada | ✓ Ideal |
| Logarítmico | O(log n) | Divide pela metade a cada passo | ✓ Excelente |
| Linear | O(n) | Proporcional à entrada | ✓ Ótimo |
| Linearítmico | O(n log n) | Linear × logarítmico | ✓ Bom |
| Quadrático | O(n²) | Loops aninhados | ⚠️ Ok para n pequeno |
| Cúbico | O(n³) | Três loops aninhados | ⚠️ Apenas n pequeno |
| Exponencial | O(2ⁿ) | Dobra a cada passo | ✗ Evitar se possível |
| Fatorial | O(n!) | Todas as permutações | ✗ Apenas entradas minúsculas |
Comparação Prática
Assumindo 1 bilhão de operações por segundo:
| Complexidade | n = 20 | n = 40 | n = 100 |
|---|---|---|---|
| O(n) | 0.00002 ms | 0.00004 ms | 0.0001 ms |
| O(n log n) | 0.00009 ms | 0.0002 ms | 0.0007 ms |
| O(n²) | 0.0004 ms | 0.0016 ms | 0.01 ms |
| O(2ⁿ) | 1 ms | 18 minutos | 40 trilhões de anos |
Apêndice
Backtracking vs DP vs Greedy
| Aspecto | Backtracking | Programação Dinâmica | Greedy |
|---|---|---|---|
| Estratégia | Tenta tudo, desfaz e retenta | Resolve subproblemas uma vez, reutiliza | Faz melhor escolha local, nunca volta |
| Explora | Todos os caminhos possíveis | Todos os subproblemas (cada um uma vez) | Caminho único |
| Garante ótimo? | Sim (exaustivo) | Sim (se tiver subestrutura ótima) | Às vezes (só para problemas específicos) |
| Reconsidera escolhas? | Sim (backtrack) | Sim (compara opções) | Nunca |
| Complexidade de tempo | Geralmente exponencial | Geralmente polinomial | Geralmente linear ou O(n log n) |
Qual a diferença entre o "0/1" do Knapsack?
O "0/1" refere-se à escolha binária para cada item: você pega (1) ou deixa (0). Não há meio-termo.
| Variante | Regra de Seleção | Exemplo |
|---|---|---|
| 0/1 Knapsack | Cada item: pega uma vez ou não pega | Arrumando mala — ou leva o laptop ou não |
| Unbounded Knapsack | Pode pegar qualquer item ilimitadamente | Comprando no supermercado — pode comprar 10 do mesmo |
| Bounded Knapsack | Cada item disponível até k vezes |
Estoque limitado — 5 unidades do item A |
| Fractional Knapsack | Pode pegar fração de um item | Carregando grãos — pode levar meio saco |
Quando cada técnica é melhor?
| Objetivo | Técnica |
|---|---|
| Listar TODAS as soluções | Backtracking |
| CONTAR soluções | DP |
| Encontrar ÓTIMO (min/max) | DP |
| Problema com "greedy-choice property" | Greedy |
| Satisfação de restrições | Backtracking |