Marzo 2023: En este tema se resuelve la recurrencia usando la técnica de la ecuación característica. En una nueva serie de temas estudio como usar las funciones generadoras para resolver recurrencias y también para este problema de Fibonacci con función generadora.

Fibonacci

Figura
Figura. Espiral de Fibonacci

La función recursiva de Fibonacci se define como f(0)=0, f(1)=1 y f(n) = f(n-1) + f(n-2) para n≥2. Los primeros téminos de la sucesión son 0, 1, 1, 2, 3, 5, 8, 13, 21,... La espiral de Fibonacci de la Figura representa esta sucesión como arcos de cuadrante inscritos en rectángulos cuyos lados siguen esta sucesión.

Si implementamos un algoritmo que ejecute la función recursiva f(n) = f(n-1) + f(n-2) veremos que no es muy eficiente, pues tendrá un coste exponencial. El recursivo resultante reduce el tamaño del problema en cada llamada mediante substracción, pero no se adapta a los que vimos en el tema anterior que eran de la forma T(n) = a T(n-b) + p(nk).

La solución exacta de la función recursiva de Fibonacci es f(n) = (Φn - (1-Φ)n)/5½, donde Φ = (1+5½)/2 = 1.618033... (irracional) es la razón áurea. Pero dado que trabajamos con enteros, esa solución puede reducirse a f(n) = [Φn/5½]. Expresamos [x] como el redondeo del número real x al entero más cercano. Y vemos que abs(1-Φ) < 1 así que abs((1-Φ)n) será cada vez mucho menor que uno, despreciándose a efectos de cómputo de valores enteros.

Sin embargo esta fórmula puede tener un problema con la precisión del cálculo. En el siguiente ejemplo la ponemos en ejecución para calcular f(72) con la fórmula anterior y con otro algoritmo fibonnaci2() que veremos más abajo:

Ejemplo: Fibonacci con fórmula

n/5½]:
fibonacci2(n):

Se observa que el último dígito no es el mismo, siendo el segundo que finaliza en 4 el valor correcto. Esto tiene que ver con la precisión del formato IEEE754. Los valores que podemos obtener con JavaScript para la razón áurea y para f(72) (sin redondear a entero) son estos:

Φ = 1.618033988749895 f(72) = 498454011879265.2

Vemos que tras redondear f(72) terminará en 5 en lugar del correcto 4. Si utilizamos una calculadora de mayor precisión, como la que viene con Windows, obtenemos estos valores:

Φ = 1.6180339887498948482045868343656 f(72) = 498454011879264.0000000000000004

Con esa calculadora si obtenemos el valor correcto cuando lo redondeemos al entero más cercano. Si queremos que la función devuelva valores correctos con n grandes y sin calculadora debemos utilizar un algoritmo, como el propio recursivo que define la función f(n) = f(n-1) + f(n-2), que de forma compacta sería como sigue:

function fibonacci1(n){
    if (n<2){
        return n;
    } else {
        return fibonacci1(n-1) + fibonacci1(n-2);
    }
}

Usando la misma estructura de caso trivial, descomposición, llamadas y combinación podemos expresarlo como sigue, donde podemos ubicar un contador de iteraciones en la entrada del recursivo:

function fibonacci1(n){
    numIter++;
    if (n<2){
        //Caso trivial
        return n;
    } else {
        //Descomponemos problema n en 2 subproblemas n-1 y n-2
        let [p1, p2] = [n-1, n-2];
        //1 llamada por cada subproblema
        let s1 = fibonacci1(p1);
        let s2 = fibonacci1(p2);
        //Combinamos soluciones parciales
        return s1+s2;
    }
}

Ejemplo: Fibonacci con recurrencia

fibonacci1(n):
Iteraciones:
Coste calculado T(n):
 

Hemos limitado el máximo valor n≤26, pues con valores grandes la ejecución puede colapsar el navegador. Con ese valor de n=26 ya vemos que el ordenador se queda ejecutando un tiempo perceptible. ¿Tan costoso es de ejecutar este algoritmo? Calculemos el coste. El coste del caso trivial es uno, pues se se realiza un comparativo. En la llamada n≥2 el coste es la suma de las dos llamadas más el coste de la suma de la descomposición del problema y de la combinación de resultados parciales. Suponemos que estas son unitarias por ser operaciones simples y en secuencia con el comparativo resulta un coste unitario, quedando la definición de la recurrencia como sigue:

T(n) ={1  si n<2
T(n-1) + T(n-2) + 1  si n≥2

Usando la expresión modelo de una recurrencia así como las ecuaciones características:

a0tn+a1tn-1+...+aktn-k = bnp(n) (a0xk+a1xk-1+...+a)(x-b)d+1 = 0

Identificaremos la recurrencia T(n) = T(n-1) + T(n-2) + 1 con este modelo:

tn - tn-1 - tn-2 = 1n (n0)

Viendo que k=2, b=1 y d=0 (grado del polinomio p(n)) obtenemos la ecuación característica:

(x2-x1-x0)(x-1)(0+1) = 0 ⇒ (x2-x-1)(x-1) = 0

Si resolvemos la ecuación de segundo grado obtenemos las soluciones r = (1+5½)/2 y r = (1-5½)/2 para la parte homogénea. La otra ecuación tiene por solución r = 1. Una combinación lineal de ellas nos da esta solución:

tn = c1 ((1+5½)/2)n + c2 ((1-5½)/2)n + c3 1n

Indentificamos la razón áurea Φ = (1+5½)/2 y abreviamos con δ = (1-5½)/2 quedando como sigue (veáse que δ = 1-Φ pero preferimos identificarlo de forma separada):

tn = c1 Φn + c2 δn + c3

Vemos que Φn es la función que domina el orden del coste, puesto que Φ = 1.61803... mayor que 1 y δ = -0.61803... con valor absoluto menor que 1, por lo que a medida que n crece este término será cada vez más insignificante respecto al otro. Así que finalmente podemos suponer que t(n) ∈ O(Φn).

Calculemos las constantes usando los valores triviales T(0) = 1, T(1) = 1. La tercera condición la obtenemos del primer valor de la recurrencia T(2) = T(1) + T(0) + 1 = 3, planteándose estas tres ecuaciones:

t(0) = 1 ⇒ c1 + c2 + c3 = 1 t(1) = 1 ⇒ c1 Φ + c2 δ+ c3 = 1 t(2) = 3 ⇒ c1 Φ2 + c2 δ2 + c3 = 3

Resolviendo obtenemos

c1 = 1 + 1/5½ = 2Φ/5½ c2 = 1 - 1/5½ = -2δ/5½ c3 = -1

Resolver con WolframAlpha

Utilizamos WolframAlpha para la resolución usando la siguiente entrada:

{g=(1+sqrt(5))/2,
h=(1-sqrt(5))/2,
x + y + z = 1,
x*g + y*h + z = 1,
x*g^2 + y*h^2 + z = 3}

Obtenemos las soluciones

x = 1 + 1/5½ y = 1 - 1/5½ z = -1

Con esto el coste exacto en tn = c1 Φn + c2 δn + c3 es:

T(n) = (2Φ/5½) Φn + (-2δ/5½) δn - 1

con lo que finalmente tenemos el coste:

T(n) = (2/5½) (Φn+1 - δn+1) - 1

Usamos el resultado de esta fórmula del coste calculado en el ejemplo, obteniéndose un valor redondeando a entero igual al que resulta de contar las iteraciones con el contador numIter.

Figura
Figura. Coste del recursivo Fibonacci

Es de notar que si resolvemos la recurrencia de Fibonacci f(n) = f(n-1) + f(n-2) obtenemos la fórmula cerrada f(n) = (1/5½) (Φn - δn) (con δ=1-Φ), por lo que el coste exacto T(n) parece estar está un orden de potencia por encima de f(n), es decir T(n) = 2 f(n+1)-1.

En la gráfica de la Figura representamos f(n) y T(n) hasta n=16, observándose como T(n) crece por encima de f(n). Vemos por ejemplo que T(16) = 2 f(17)-1 = 2×1597-1 = 3193.

Sin embargo desde el punto de vista del coste asintótico T(n) sigue estando en O(Φn) pues aplicando la regla del límite (resolver en WolframAlpha).

limn→∞ T(n)/Φn = limn→∞ ((2/5½) (Φn+1 - δn+1) - 1) / Φn = 2Φ/5½ ∈ ℝ+

Recursivo Fibonacci con coste lineal

El recursivo f(n) = f(n-1) + f(n-2) es costoso porque se realizan repetidas veces los cálculos. Por ejemplo, para T(5) se calcula 1 vez T(5), 1 vez T(4), 2 veces T(3) y 3 veces T(2). Esto suma 7 operaciones:

T(5) = T(4) + T(3) + 1
    T(4) = T(3) + T(2) + 1
        T(3) = T(2) + T(1) + 1
            T(2) = T(1) + T(0) + 1
        T(2) = T(1) + T(0) + 1
    T(3) = T(2) + T(1) + 1
        T(2) = T(1) + T(0) + 1
    

Hay una forma de reducir esa duplicidad de operaciones y es con el siguiente bucle:

function fibonacci2(n){
    let i = 1, j = 0;
    for (let k=1; k<=n; k++){
        [i, j] = [j, i+j];
    }
    return j;
}

Ejemplo: Fibonacci con bucle

fibonacci2(n):
Iteraciones:

El coste ahora es T(n) ∈ O(n) si consideramos que incrementar una de las dos variables i, j e intercambiarlas tiene coste unitario. Podemos convertir ese iterativo en un recursivo si hacemos una inmersión de resultados en los parámetros de cada llamada tal como vimos en un tema anterior:

function fibonacci3(n, ri=0, rj=1){
    numIter++;
    if (n<1){
        return ri;
    } else if (n<2) {
        return rj;
    } else {
        let p = n-1;
        [ri, rj] = [rj, ri+rj];
        return fibonacci3(p, ri, rj);
    }
}

Ejemplo: Fibonacci con recursivo final

fibonacci3(n):
Iteraciones:
Coste calculado T(n):
 

Pasar resultado parciales a los argumentos no reduce el coste, pero si obtenemos un recursivo final, de tal forma que el resultado se obtiene en la fase de bajada. Esto podría ser una mejora de eficiencia en alguna implementación, tal como explicamos en un tema anterior. De todas formas el coste es T(n) ∈ O(n). Para calcular el coste exacto vemos que los casos triviales tienen coste unitario por los comparativos, mientras que en otro caso vemos un coste unitario combinado en secuencia con los comparativos y las operaciones descomponer problema y operar los parámetros:

T(n) ={1  si n<2
T(n-1) + 1  si n≥2

La resolución de la recurrencia es idéntica a la del primer apartado del factorial:

tn = c1 + c2 n

Pero ahora las condiciones iniciales son otras, tomamos n=1 cuyo coste es 1 por definición y el primer valor n=2 donde T(2) = T(1)+1 = 1+1 = 2:

t1 = 1 ⇒ c1 + c2 = 1 t2 = 2 ⇒ c1 + 2 c2 = 2

Se resuelve con c1 = 0 y c2 = 1 por lo que el coste exacto es

T(n) = n

Este coste coincide con el contador de iteraciones que hemos puesto en la parte donde se consume coste. Hemos reducido el coste de Fibonacci desde O(Φn) hasta O(n). Pero es que aún hay una forma de reducirlo hasta O(log n) aplicando la técnica denominada divide y vencerás que veremos en un tema siguiente.