A sequência de Fibonacci é o ponto de partida clássico para aprender programação dinâmica (DP). Embora o problema em si seja simples — cada número é a soma dos dois anteriores — ele ilustra perfeitamente os dois pilares de DP: subestrutura ótima e subproblemas sobrepostos. Nesta página, partiremos de uma solução recursiva ingênua com complexidade exponencial e chegaremos a soluções lineares e até com espaço constante, tudo em Rust puro.
Na prática, variações da recorrência de Fibonacci aparecem em contagem de caminhos em grades, análise de algoritmos de divisão e conquista, modelagem financeira e até na natureza (filotaxia, proporção áurea). Dominar as técnicas aqui apresentadas prepara você para problemas muito mais complexos de DP.
O Problema
Dado um inteiro não negativo n, calcule o n-ésimo número de Fibonacci, definido por:
F(0) = 0
F(1) = 1
F(n) = F(n-1) + F(n-2) para n >= 2
Exemplo de entrada/saída:
| Entrada (n) | Saída F(n) |
|---|---|
| 0 | 0 |
| 1 | 1 |
| 5 | 5 |
| 10 | 55 |
| 50 | 12586269025 |
Abordagem Ingênua (Força Bruta)
A tradução direta da definição matemática gera uma função recursiva pura:
/// Calcula F(n) de forma recursiva ingênua.
/// ATENÇÃO: complexidade exponencial O(2^n) — impraticável para n > ~40.
fn fib_ingenua(n: u64) -> u64 {
if n <= 1 {
return n;
}
fib_ingenua(n - 1) + fib_ingenua(n - 2)
}
fn main() {
// Funciona para valores pequenos
for i in 0..=10 {
println!("F({}) = {}", i, fib_ingenua(i));
}
// fib_ingenua(50) levaria MINUTOS ou mais!
}
Árvore de chamadas para F(5)
O diagrama abaixo mostra por que essa abordagem é lenta — os mesmos subproblemas são recalculados muitas vezes:
F(5)
/ \
F(4) F(3)
/ \ / \
F(3) F(2) F(2) F(1)
/ \ / \ / \
F(2) F(1) F(1) F(0) F(1) F(0)
/ \
F(1) F(0)
Observe que F(3) é calculado 2 vezes, F(2) é calculado 3 vezes e F(1) é calculado 5 vezes. Para valores grandes de n, essa redundância cresce exponencialmente.
Identificando Subestrutura Ótima
A programação dinâmica se aplica quando duas condições são satisfeitas:
Subestrutura ótima: a solução ótima do problema pode ser construída a partir de soluções ótimas de subproblemas. No caso de Fibonacci,
F(n)depende diretamente deF(n-1)eF(n-2).Subproblemas sobrepostos: os mesmos subproblemas aparecem repetidamente. Como vimos na árvore acima,
F(3),F(2), etc., são recalculados várias vezes.
A ideia central é: calcule cada subproblema apenas uma vez e armazene o resultado. Isso pode ser feito de duas formas:
- Top-Down (Memoização): mantenha a recursão, mas guarde os resultados já calculados em um cache (HashMap ou Vec).
- Bottom-Up (Tabulação): preencha uma tabela iterativamente, dos casos base até o resultado desejado.
Visualização da Tabela DP
Para n = 7, a tabela bottom-up é preenchida da esquerda para a direita:
Índice: 0 1 2 3 4 5 6 7
+---+---+---+---+---+---+---+---+
Valor: | 0 | 1 | 1 | 2 | 3 | 5 | 8 | 13|
+---+---+---+---+---+---+---+---+
^ ^ |
| | dp[2] = dp[1] + dp[0] = 1 + 0 = 1
| caso base
caso base
Passo a passo:
dp[0] = 0 (caso base)
dp[1] = 1 (caso base)
dp[2] = dp[1] + dp[0] = 1 + 0 = 1
dp[3] = dp[2] + dp[1] = 1 + 1 = 2
dp[4] = dp[3] + dp[2] = 2 + 1 = 3
dp[5] = dp[4] + dp[3] = 3 + 2 = 5
dp[6] = dp[5] + dp[4] = 5 + 3 = 8
dp[7] = dp[6] + dp[5] = 8 + 5 = 13
Complexidade
| Abordagem | Tempo | Espaço | Observação |
|---|---|---|---|
| Força Bruta (recursão) | O(2^n) | O(n) pilha | Impraticável para n > 40 |
| Top-Down (Memoização) | O(n) | O(n) cache + pilha | Risco de stack overflow para n grande |
| Bottom-Up (Tabulação) | O(n) | O(n) vetor | Iterativo, sem risco de pilha |
| Otimizado (duas variáveis) | O(n) | O(1) | Melhor abordagem geral |
Implementação: Top-Down (Memoização)
Usamos um HashMap para armazenar resultados já calculados. Cada subproblema é resolvido no máximo uma vez.
use std::collections::HashMap;
/// Calcula F(n) usando memoização com HashMap.
/// Cada subproblema é calculado apenas uma vez: O(n) tempo, O(n) espaço.
fn fib_memo(n: u64, cache: &mut HashMap<u64, u64>) -> u64 {
// Se já calculamos, retornamos do cache
if let Some(&valor) = cache.get(&n) {
return valor;
}
// Casos base
let resultado = if n <= 1 {
n
} else {
// Chamadas recursivas com memoização
let a = fib_memo(n - 1, cache);
let b = fib_memo(n - 2, cache);
a + b
};
// Armazena no cache antes de retornar
cache.insert(n, resultado);
resultado
}
fn main() {
let mut cache = HashMap::new();
for i in 0..=10 {
println!("F({}) = {}", i, fib_memo(i, &mut cache));
}
// Agora funciona instantaneamente mesmo para valores grandes
cache.clear();
println!("F(50) = {}", fib_memo(50, &mut cache)); // 12586269025
}
Alternativa com Vec: para Fibonacci, onde os índices são inteiros consecutivos a partir de 0, um Vec é mais eficiente que um HashMap:
/// Memoização com Vec — acesso O(1) em vez de O(1) amortizado do HashMap.
fn fib_memo_vec(n: usize, cache: &mut Vec<Option<u64>>) -> u64 {
if let Some(valor) = cache[n] {
return valor;
}
let resultado = if n <= 1 {
n as u64
} else {
fib_memo_vec(n - 1, cache) + fib_memo_vec(n - 2, cache)
};
cache[n] = Some(resultado);
resultado
}
fn main() {
let n = 50;
let mut cache = vec![None; n + 1];
println!("F({}) = {}", n, fib_memo_vec(n, &mut cache));
}
Implementação: Bottom-Up (Tabulação)
A abordagem iterativa elimina a recursão e preenche a tabela dos casos base até n:
/// Calcula F(n) com tabulação bottom-up.
/// O(n) tempo, O(n) espaço.
fn fib_tabular(n: usize) -> u64 {
if n <= 1 {
return n as u64;
}
// Cria a tabela DP com n+1 posições
let mut dp = vec![0u64; n + 1];
// Casos base
dp[0] = 0;
dp[1] = 1;
// Preenche a tabela iterativamente
for i in 2..=n {
dp[i] = dp[i - 1] + dp[i - 2];
}
dp[n]
}
fn main() {
for i in 0..=10 {
println!("F({}) = {}", i, fib_tabular(i));
}
println!("F(50) = {}", fib_tabular(50)); // 12586269025
println!("F(90) = {}", fib_tabular(90)); // 2880067194370816120
}
Otimização de Espaço
Observe que F(n) depende apenas de F(n-1) e F(n-2). Não precisamos manter toda a tabela — apenas os dois valores anteriores:
/// Calcula F(n) usando apenas duas variáveis.
/// O(n) tempo, O(1) espaço — a versão mais eficiente.
fn fib_otimizado(n: u64) -> u64 {
if n <= 1 {
return n;
}
let mut anterior = 0u64; // F(i-2)
let mut atual = 1u64; // F(i-1)
for _ in 2..=n {
let proximo = anterior + atual;
anterior = atual;
atual = proximo;
}
atual
}
fn main() {
// Verificação de todos os métodos
for i in 0..=20 {
let resultado = fib_otimizado(i);
println!("F({:2}) = {}", i, resultado);
}
println!("\nF(50) = {}", fib_otimizado(50));
println!("F(93) = {}", fib_otimizado(93)); // Máximo que cabe em u64
}
Versão com iterador: para quem quer explorar o sistema de iteradores de Rust:
/// Iterador infinito de Fibonacci usando o sistema de iteradores de Rust.
struct FibIterator {
atual: u64,
proximo: u64,
}
impl FibIterator {
fn new() -> Self {
FibIterator { atual: 0, proximo: 1 }
}
}
impl Iterator for FibIterator {
type Item = u64;
fn next(&mut self) -> Option<Self::Item> {
let valor = self.atual;
let novo_proximo = self.atual + self.proximo;
self.atual = self.proximo;
self.proximo = novo_proximo;
Some(valor)
}
}
fn main() {
// Primeiros 15 números de Fibonacci
let fibs: Vec<u64> = FibIterator::new().take(15).collect();
println!("Primeiros 15: {:?}", fibs);
// O n-ésimo Fibonacci (0-indexado)
let f50 = FibIterator::new().nth(50).unwrap();
println!("F(50) = {}", f50); // 12586269025
}
Reconstruindo a Solução
No caso de Fibonacci, o “resultado” é o próprio valor numérico, então não há reconstrução de caminho. Porém, em problemas derivados (como contar caminhos em uma grade), a reconstrução segue o mesmo princípio: a partir do resultado final, percorremos a tabela DP de volta identificando quais subproblemas contribuíram.
Para ilustrar, considere o problema de decompor F(n) em soma de Fibonacci anteriores (representação de Zeckendorf):
/// Encontra a representação de Zeckendorf de um número:
/// decomposição em números de Fibonacci não consecutivos.
fn zeckendorf(mut n: u64) -> Vec<u64> {
if n == 0 {
return vec![0];
}
// Gera todos os Fibonacci até n
let mut fibs = vec![1u64, 2];
loop {
let proximo = fibs[fibs.len() - 1] + fibs[fibs.len() - 2];
if proximo > n {
break;
}
fibs.push(proximo);
}
// Algoritmo guloso: escolhe o maior Fibonacci possível
let mut resultado = Vec::new();
for &f in fibs.iter().rev() {
if f <= n {
resultado.push(f);
n -= f;
}
if n == 0 {
break;
}
}
resultado
}
fn main() {
let n = 100;
let decomposicao = zeckendorf(n);
println!("{} = {:?} (soma: {})", n, decomposicao, decomposicao.iter().sum::<u64>());
// 100 = [89, 8, 3] (soma: 100)
}
Exercícios Práticos
Fibonacci com módulo: Calcule
F(n) mod 10^9 + 7para valores enormes den(até 10^18) usando exponenciação de matrizes. Dica: use a identidade matricial[[1,1],[1,0]]^n.Tribonacci: Adapte as soluções para a sequência de Tribonacci, onde
T(n) = T(n-1) + T(n-2) + T(n-3), comT(0)=0, T(1)=0, T(2)=1.Subir escadas: Você pode subir uma escada de
ndegraus, subindo 1 ou 2 degraus por vez. De quantas formas distintas pode chegar ao topo? (Dica: a resposta éF(n+1).)Ladrilhamento 2xN: De quantas maneiras podemos preencher um retângulo 2xN com dominós 2x1? Mostre que a resposta segue a recorrência de Fibonacci.
Veja Também
- Vec — Vetores dinâmicos — estrutura fundamental para tabulação em DP
- HashMap — Tabelas hash — usada para memoização com chaves arbitrárias
- Iterator — Iteradores em Rust — padrão funcional para sequências
- Otimização de Performance — técnicas gerais de otimização em Rust
- Problema da Mochila (Knapsack) — próximo problema clássico de DP
- Problema do Troco de Moedas — outra aplicação direta de DP com recorrência similar