← Todos los algoritmos

Fibonacci

Programación Dinámica · ver código fuente en GitHub

Leer en: English · Português · Español

Categoría: Dynamic Programming

El problema

fib(n) = fib(n-1) + fib(n-2) es una definición de una sola línea — y, traducida literalmente a código recursivo, es una trampa. Calcular fib(n-1) y fib(n-2) termina necesitando ambos fib(n-2), fib(n-3), y así sucesivamente hasta el final — y la recursión ingenua recalcula desde cero cada uno de esos subproblemas compartidos, cada vez que se necesita. La cantidad de llamadas redundantes crece exponencialmente con n.

La solución

Note que fib(k) solo tiene un valor posible para un k dado — resuélvalo una vez y reutilice esa respuesta en todos los lugares donde se necesite, en lugar de recalcularla. La memoización hace esto de arriba hacia abajo: mantiene un caché y, antes de recursar, verifica si ese n ya fue resuelto. La tabulación hace lo mismo de abajo hacia arriba: construye fib(0), fib(1), fib(2), ..., fib(n) en un bucle simple, de modo que nada se calcula antes de que existan sus dependencias. Ambas reducen el costo de exponencial a O(n) — la memoización negándose a rehacer trabajo, la tabulación nunca teniendo que hacerlo. La tabulación va un paso más allá aquí: dado que cada fib(k) solo necesita los dos valores anteriores, no hace falta mantener una tabla (ni un mapa de memoización, ni una pila de recursión) — dos variables bastan.

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
Enfoque Costo Por qué
Recursión ingenua O(2^n) cada subproblema se recalcula desde cero cada vez que se alcanza
Memoizado (de arriba hacia abajo + caché) O(n) cada uno de los n subproblemas distintos se resuelve exactamente una vez
Tabulado (de abajo hacia arriba) O(n) tiempo, O(1) espacio la misma garantía de una resolución por subproblema, sin mapa de memoización ni pila de recursión

Ejemplo clásico

classic/Fibonacci implementa los tres enfoques uno al lado del otro específicamente para que el benchmark de abajo pueda medir la misma afirmación de tres formas distintas en la misma máquina. Usa long y rechaza n > 90 para mantenerse dentro del rango de long en lugar de desbordarse silenciosamente. FibonacciTest cubre valores base conocidos, que las versiones memoizada y tabulada coinciden con la ingenua en un rango de entradas, que ambas coinciden entre sí en el límite de desbordamiento n=90, y las protecciones tanto para n negativo como para el límite de desbordamiento.

Ejemplo aplicado: conteo de rutas de pago entre bancos corresponsales

applied/PaymentRouteCounter cuenta las rutas distintas de exactamente k saltos entre bancos corresponsales, de una cuenta a otra a través de una red de liquidación — la misma forma de subproblemas superpuestos que los números de Fibonacci de este módulo, aplicada a una pregunta real en lugar de una abstracta. Sin memoización, contar rutas que pasan por un nodo por el que transitan múltiples caminos parciales recalcula desde cero, cada vez que se alcanza, todo el conteo de saltos restantes de ese nodo — exponencial en el presupuesto de saltos, exactamente por la misma razón que lo es el fib(n) ingenuo. Memoizar sobre (nodo actual, saltos restantes) reduce eso a un trabajo proporcional a tamaño de la red × presupuesto de saltos. PaymentRouteCounterTest cubre el conteo de múltiples rutas al mismo destino, el límite de cero saltos (solo cuenta cuando el origen es igual al destino), una cantidad de saltos inalcanzable, la coincidencia entre las versiones ingenua y memoizada sobre la misma red, una red cíclica (que demuestra que la memoización no entra en bucle infinito ante ciclos) y las protecciones ante argumentos nulos/negativos.

Benchmark

./gradlew :dynamic-programming:fibonacci:jmh

Ejecución real en esta máquina (JMH 1.37, JDK 26.0.2, 2 iteraciones de warmup + 3 de medición, 1 fork). n se mantiene deliberadamente pequeño — el Fibonacci ingenuo en n=35 ya toma decenas de milisegundos por llamada, y cualquier valor mayor haría este benchmark imprácticamente lento, lo cual es en sí mismo parte del punto:

Costo n=20 n=30 n=35
ingenuo 35.92 µs 4,518.08 µs 48,460.35 µs
memoizado 0.317 µs 0.548 µs 0.619 µs
tabulado 0.007 µs 0.008 µs 0.008 µs

En n=35, el ingenuo es ~78,289x más lento que el memoizado y ~6,057,544x más lento que el tabulado — para exactamente la misma respuesta. El propio crecimiento del ingenuo confirma directamente la forma exponencial: pasar de n=30 a n=35 (5 pasos más) cuesta aproximadamente 10.7x más tiempo, coincidiendo de cerca con el propio factor de crecimiento por paso de la razón áurea (φ ≈ 1.618, y 1.618^5 ≈ 11.1) — la propia tasa de crecimiento de forma cerrada de Fibonacci, apareciendo directamente en el tiempo de ejecución del algoritmo ingenuo. El memoizado y el tabulado, en cambio, apenas se mueven en el mismo rango — ambos lineales, con el crecimiento casi nulo e inmensurable del tabulado reflejando que nunca paga el costo de un HashMap ni de una pila de recursión.

Cuándo no usarlo

Cobertura de pruebas

100% de cobertura de instrucciones, 100% de cobertura de ramas (JaCoCo). Reprodúcelo tú mismo:

./gradlew :dynamic-programming:fibonacci:jacocoTestReport

Informe en dynamic-programming/fibonacci/build/reports/jacoco/test/html/index.html.

Lectura complementaria

Pruebas unitarias

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 informe completo de cobertura JaCoCo →