← Todos os algoritmos

Fibonacci

Programação Dinâmica · ver código-fonte no GitHub

Leia em: English · Português · Español

Categoria: Dynamic Programming

O problema

fib(n) = fib(n-1) + fib(n-2) é uma definição de uma linha — e, traduzida literalmente para código recursivo, é uma armadilha. Calcular fib(n-1) e fib(n-2) eventualmente exige fib(n-2), fib(n-3) e assim por diante, até o fim — e a recursão ingênua recalcula do zero cada um desses subproblemas compartilhados, toda vez que é necessário. O número de chamadas redundantes cresce exponencialmente com n.

A solução

Perceba que fib(k) só tem um valor possível para um dado k — resolva uma vez e reutilize essa resposta em todo lugar onde for necessária, em vez de recalculá-la. A memoização faz isso de cima para baixo: mantém um cache e, antes de recursar, verifica se esse n já foi resolvido. A tabulação faz a mesma coisa de baixo para cima: constrói fib(0), fib(1), fib(2), ..., fib(n) em um loop simples, de forma que nada é calculado antes que suas dependências existam. Ambas reduzem o custo de exponencial para O(n) — a memoização recusando-se a refazer trabalho, a tabulação nunca sendo solicitada a fazê-lo. A tabulação vai um passo além aqui: como cada fib(k) só precisa dos dois valores anteriores, não há necessidade de manter uma tabela (nem um mapa de memoização, nem uma pilha de recursão) — duas variáveis bastam.

flowchart TD
    F5["fib(5)"] --> F4a["fib(4)"]
    F5 --> F3a["fib(3)"]
    F4a --> F3b["fib(3)"]
    F4a --> F2a["fib(2)"]
    F3b -.->|"same subproblem as F3a - naive recursion solves it again"| F3a
Abordagem Custo Por quê
Recursão ingênua O(2^n) todo subproblema é recalculado do zero toda vez que é alcançado
Memoizada (de cima para baixo + cache) O(n) cada um dos n subproblemas distintos é resolvido exatamente uma vez
Tabulada (de baixo para cima) O(n) tempo, O(1) espaço mesma garantia de resolver uma vez por subproblema, sem mapa de memoização nem pilha de recursão

Exemplo clássico

classic/Fibonacci implementa as três abordagens lado a lado, especificamente para que o benchmark abaixo possa medir a mesma afirmação de três formas diferentes na mesma máquina. Usa long e rejeita n > 90 para permanecer dentro do intervalo de long em vez de estourar silenciosamente. FibonacciTest cobre valores base conhecidos, que as versões memoizada e tabulada concordam com a ingênua em uma faixa de entradas, que ambas concordam entre si no limite de estouro n=90, e as proteções contra n negativo e contra o limite de estouro.

Exemplo aplicado: contagem de rotas de pagamento entre bancos correspondentes

applied/PaymentRouteCounter conta as rotas distintas de exatamente k saltos entre bancos correspondentes, de uma conta a outra através de uma rede de liquidação — a mesma forma de subproblemas sobrepostos dos números de Fibonacci deste módulo, aplicada a uma pergunta real em vez de uma abstrata. Sem memoização, contar rotas que passam por um nó por onde passam múltiplos caminhos parciais recalcula do zero, toda vez que é alcançado, a contagem completa de saltos restantes daquele nó — exponencial no orçamento de saltos, exatamente pela mesma razão que o fib(n) ingênuo é. Memoizar em (nó atual, saltos restantes) reduz isso a um trabalho proporcional a tamanho da rede × orçamento de saltos. PaymentRouteCounterTest cobre a contagem de múltiplas rotas para o mesmo destino, o limite de zero saltos (só conta quando origem é igual a destino), uma contagem de saltos inalcançável, a concordância entre as versões ingênua e memoizada na mesma rede, uma rede cíclica (provando que a memoização não entra em loop infinito em ciclos) e as proteções contra argumentos nulos/negativos.

Benchmark

./gradlew :dynamic-programming:fibonacci:jmh

Execução real nesta máquina (JMH 1.37, JDK 26.0.2, 2 iterações de warmup + 3 de medição, 1 fork). n permanece deliberadamente pequeno — a Fibonacci ingênua em n=35 já leva dezenas de milissegundos por chamada, e qualquer valor maior tornaria este benchmark impraticavelmente lento, o que é, em si, parte do ponto:

Custo n=20 n=30 n=35
ingênua 35.92 µs 4,518.08 µs 48,460.35 µs
memoizada 0.317 µs 0.548 µs 0.619 µs
tabulada 0.007 µs 0.008 µs 0.008 µs

Em n=35, a versão ingênua é ~78,289x mais lenta que a memoizada e ~6,057,544x mais lenta que a tabulada — para exatamente a mesma resposta. O próprio crescimento da versão ingênua confirma diretamente a forma exponencial: ir de n=30 para n=35 (mais 5 passos) custa aproximadamente 10.7x mais tempo, batendo de perto com o próprio fator de crescimento por passo da razão áurea (φ ≈ 1.618, e 1.618^5 ≈ 11.1) — a própria taxa de crescimento de forma fechada de Fibonacci, aparecendo diretamente no tempo de execução do algoritmo ingênuo. Já as versões memoizada e tabulada mal se movem na mesma faixa — ambas lineares, com o crescimento quase nulo e imensurável da tabulada refletindo que ela nunca paga o custo de um HashMap ou de uma pilha de recursão.

Quando não usar

Cobertura de testes

100% de cobertura de instruções, 100% de cobertura de branches (JaCoCo). Reproduza você mesmo:

./gradlew :dynamic-programming:fibonacci:jacocoTestReport

Relatório em dynamic-programming/fibonacci/build/reports/jacoco/test/html/index.html.

Leitura complementar

Testes unitários

src/test/java/com/algorithms/dynamicprogramming/fibonacci/classic/FibonacciTest.java
package com.algorithms.dynamicprogramming.fibonacci.classic;

import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

class FibonacciTest {

    @Test
    void naiveComputesKnownValues() {
        assertThat(Fibonacci.naive(0)).isZero();
        assertThat(Fibonacci.naive(1)).isEqualTo(1);
        assertThat(Fibonacci.naive(10)).isEqualTo(55);
    }

    @Test
    void memoizedMatchesNaiveForTheSameInputs() {
        for (int n = 0; n <= 20; n++) {
            assertThat(Fibonacci.memoized(n)).isEqualTo(Fibonacci.naive(n));
        }
    }

    @Test
    void tabulatedMatchesNaiveForTheSameInputs() {
        for (int n = 0; n <= 20; n++) {
            assertThat(Fibonacci.tabulated(n)).isEqualTo(Fibonacci.naive(n));
        }
    }

    @Test
    void allThreeAgreeAtTheOverflowBoundary() {
        assertThat(Fibonacci.tabulated(90)).isEqualTo(Fibonacci.memoized(90));
        assertThat(Fibonacci.tabulated(90)).isEqualTo(2880067194370816120L);
    }

    @Test
    void rejectsANegativeN() {
        assertThatThrownBy(() -> Fibonacci.naive(-1)).isInstanceOf(IllegalArgumentException.class);
        assertThatThrownBy(() -> Fibonacci.memoized(-1)).isInstanceOf(IllegalArgumentException.class);
        assertThatThrownBy(() -> Fibonacci.tabulated(-1)).isInstanceOf(IllegalArgumentException.class);
    }

    @Test
    void rejectsAnNThatWouldOverflowALong() {
        assertThatThrownBy(() -> Fibonacci.naive(91)).isInstanceOf(IllegalArgumentException.class);
        assertThatThrownBy(() -> Fibonacci.memoized(91)).isInstanceOf(IllegalArgumentException.class);
        assertThatThrownBy(() -> Fibonacci.tabulated(91)).isInstanceOf(IllegalArgumentException.class);
    }
}
src/test/java/com/algorithms/dynamicprogramming/fibonacci/applied/PaymentRouteCounterTest.java
package com.algorithms.dynamicprogramming.fibonacci.applied;

import org.junit.jupiter.api.Test;

import java.util.List;
import java.util.Map;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

class PaymentRouteCounterTest {

    private static final Map<String, List<String>> NETWORK = Map.of(
            "A", List.of("B", "C"),
            "B", List.of("D"),
            "C", List.of("D"),
            "D", List.of()
    );

    @Test
    void countsBothTwoHopRoutesFromAToD() {
        PaymentRouteCounter counter = new PaymentRouteCounter(NETWORK);

        assertThat(counter.countRoutesMemoized("A", "D", 2)).isEqualTo(2);
    }

    @Test
    void zeroHopsOnlyCountsWhenSourceEqualsDestination() {
        PaymentRouteCounter counter = new PaymentRouteCounter(NETWORK);

        assertThat(counter.countRoutesMemoized("A", "A", 0)).isEqualTo(1);
        assertThat(counter.countRoutesMemoized("A", "D", 0)).isZero();
    }

    @Test
    void noRouteExistsForAnUnreachableHopCount() {
        PaymentRouteCounter counter = new PaymentRouteCounter(NETWORK);

        assertThat(counter.countRoutesMemoized("A", "D", 5)).isZero();
    }

    @Test
    void naiveAndMemoizedAgreeOnTheSameNetwork() {
        PaymentRouteCounter counter = new PaymentRouteCounter(NETWORK);

        assertThat(counter.countRoutesNaive("A", "D", 2))
                .isEqualTo(counter.countRoutesMemoized("A", "D", 2));
    }

    @Test
    void naiveCountsZeroWhenTheHopBudgetLandsOnTheWrongNode() {
        PaymentRouteCounter counter = new PaymentRouteCounter(NETWORK);

        assertThat(counter.countRoutesNaive("A", "D", 1)).isZero();
    }

    @Test
    void handlesACycleInTheNetworkWithoutInfiniteRecursion() {
        Map<String, List<String>> cyclic = Map.of(
                "X", List.of("Y"),
                "Y", List.of("X")
        );
        PaymentRouteCounter counter = new PaymentRouteCounter(cyclic);

        assertThat(counter.countRoutesMemoized("X", "X", 4)).isEqualTo(1);
    }

    @Test
    void rejectsANullNetwork() {
        assertThatThrownBy(() -> new PaymentRouteCounter(null)).isInstanceOf(IllegalArgumentException.class);
    }

    @Test
    void rejectsNullFromOrTo() {
        PaymentRouteCounter counter = new PaymentRouteCounter(NETWORK);

        assertThatThrownBy(() -> counter.countRoutesMemoized(null, "D", 1)).isInstanceOf(IllegalArgumentException.class);
        assertThatThrownBy(() -> counter.countRoutesMemoized("A", null, 1)).isInstanceOf(IllegalArgumentException.class);
    }

    @Test
    void rejectsNegativeHops() {
        PaymentRouteCounter counter = new PaymentRouteCounter(NETWORK);

        assertThatThrownBy(() -> counter.countRoutesMemoized("A", "D", -1)).isInstanceOf(IllegalArgumentException.class);
    }
}

Ver relatório completo de cobertura JaCoCo →