Ideas Interesantes


Algoritmo para el cálculo de los términos de la secuencia de Fibonacci con complejidad O(log(n))

Miguel Angel Carrasco
20 de noviembre de 2022

Resumen

Se muestra una implementación eficiente para calcular los términos de la secuencia de fibonacci en tiempo logarítmico O(log(n))

Introducción

Un problema muy recurrente en el estudio de algoritmos es la recursividad, para la cual se suele presentar de ejemplo clásico la famosa sucesión de Fibonacci, que está definida como la sucesión de enteros donde cada término está compuesto por la suma de los dos términos anteriores, empezando por el y el

La cual se puede escribir como

La forma anterior de definir la secuencia de fibonacci resulta muy conveniente para programar una función recursiva que calcule el valor de en algún lenguaje de programación, como por ejemplo en lenguaje C

int fib(int n){
if(n == 0){
return 0;
}

if(n == 1){
return 1;
}
return fib(n - 1) + fib(n - 2);
}

El gran inconveniente con esta implementación es que su complejidad algorítmica resulta ser exponencial. Podemos observar que por cada llamada a esta función, la mayoría de las veces se "expanden" otras dos llamadas. Por lo que su complejidad se acerca a . Por ejemplo para calcular fib(6), se tienen que hacer 25 llamadas a la misma función, muchas de las cuales repiten innecesariamente el mismo cálculo.

árbol de llamadas para la implementación recursiva para fib(6)

En este artículo presento ideas interesantes para reducir la complejidad computacional de este problema con diferentes algoritmos.

Reducción a tiempo lineal

Una mejora en la implementación permite bajar la complejidad a , es decir, resolver el problema en tiempo lineal. Como por ejemplo la función de recursión de cola (tail recursion) siguiente

unsigned long fib(unsigned long n, unsigned long currentVal = 1,
unsigned long previousVal = 0) {
if (n == 0) {
return previousVal;
}

if (n == 1) {
return currentVal;
}
return fib(n - 1, currentVal + previousVal, currentVal);
}

Donde básicamente se calcula la suma de todos los componentes de la sucesión hasta alcanzar el término buscado. Aun así queda la duda de si podría existir una implementación más eficiente. De hecho se puede reducir a un problema que se resuelve en tiempo logarítmico, es decir, existe un algoritmo con complejidad .

Reducción a usando la fórmula de Binet

En el caso de la secuencia de Fibonacci se le atribuye a Jacques Philippe Marie Binet la siguiente fórmula explícita para obtener

Otra forma de escribir esta fórmula es la siguiente

donde y

A se le suele denominar la proporción áurea.

Escribí anteriormente un artículo con la demostración y derivación de la fórmula de Binet con dos demostraciones. La primera mediante inducción fuerte y la segunda derivando la fórmula usando álgebra lineal. Es importante revisar este artículo para comprender mejor las siguientes optimizaciones.

Una implementación de la fórmula de Binet en C, podría ser la siguiente

const long double FIVE_SQUARE = pow(5, 0.5);
const long double PHI = (1 + FIVE_SQUARE) / 2;
const long double PSI = (1 - FIVE_SQUARE) / 2;

unsigned long fib(unsigned long n) {
return ((pow(PHI, n) - pow(PSI, n)) / FIVE_SQUARE);
}

La cual nos reduce la complejidad algorítmica aún más a , ya que la manera más eficiente conocida de elevar a la potencia tiene complejidad usando la exponenciación por cuadrados. Esta idea es muy importante para los siguientes algoritmos.

El único problema de esta implementación es que en algún punto aparecen errores de redondeo conforme aumenta . En esta implementación en C, a partir de , aparecen errores en el resultado. Sin embargo, resulta bastante interesante como usando una fórmula explicita es posible reducir la complejidad a tiempo logarítmico.

Optimización a sin pérdida de precisión por errores de redondeo

En el algoritmo anterior que usa la fórmula de Binet, notamos que su complejidad es logarítmica, únicamente porque para elevar un número a la potencia , el algoritmo más eficiente es la exponenciación por cuadrados. De no ser por esta razón las demás operaciones se hacen idealmente, en tiempo constante .

Para entender el siguiente algoritmo que conserva la complejidad logarítmica sin perder precisión, tenemos que entender bien la exponenciación por cuadrados.

Exponenciación por cuadrados

La exponenciación es una operación muy importante en la teoría algorítmica de números. Por ejemplo, se usa intensamente en muchas pruebas de primalidad y algoritmos de factorización. Por lo tanto, se han estudiado métodos eficientes durante siglos.

En los criptosistemas basados en el problema del logaritmo discreto, la exponenciación suele ser la parte que consume más tiempo y, es por esta razón, que determina la eficiencia de los protocolos criptográficos como el intercambio de llaves de Diffie-Hellman, el algoritmo ElGamal para la cripotgrafía de llave pública y algoritmos para firma digital.

la exponenciación por cuadrados consiste en que podemos simplificar el cálculo de potencias muy grandes, debido a la observación de que dado un entero positivo , sabemos que

Por ejemplo si queremos calcular , en lugar de hacer multiplicaciones

Podemos hacer menos operaciones. Aplicando primeramente la regla anterior, dado que es par

Al multiplicar y hacer la división llevamos dos operaciones. Luego volvemos a aplicar la regla anterior, pero notando que es impar

En este último paso hicimos primero , luego y finalmente , es decir, 3 operaciones más. Con lo cual reducimos el número de operaciones a 5 en lugar de 10.

Una implementación en C recursiva de la exponenciación por cuadrados es la siguiente

int exponentiation(int a, int b){
if(b==1)
return a;
if(b%2==1)
return a*exponentiation(a, b-1);
int tmp_exp = exponentiation(a, b/2)
return tmp_exp * tmp_exp;
}

Es claro que el número de llamadas a esta función será de . Lo interesante de la exponenciación por cuadrados es que no solo sirve para multiplicar números, sino que este resultado es válido para semigrupos.

Definición Un semigrupo es un conjunto , junto con una operación binaria , que es una función , que es asociativa. Es decir, para todo se cumple que

Es claro que el conjunto de matrices cuadradas de , junto con la multiplicación matricial, forman un semigrupo. La multiplicación de matrices es cerrada y asociativa.

Un semigrupo puede ser visto como un magma que además es asociativo. Un grupo es una subclase de semigrupo. En particular el grupo general lineal de grado sobre el cuerpo (o campo) , si se usa el cuerpo de los reales o complejos, puede ser visto como el grupo formado por matrices cuadradas invertibles de con la multiplicación de matrices. Este grupo en particular forma un Grupo de Lie, ya que forma también una variedad diferencial. Los grupos de Lie son ampliamente utilizados en la matemática moderna y en la física. En la física se usan en el modelo de partículas estandard, ya que es un tipo de teoría de campo de gauge, donde los grupos de Lie son fundamentales. Los semigrupos también son ampliamente utilizados en la teoría algebráica de autómatas finitos y en la criptografía elíptica.

Podemos entonces usar la forma matricial de la relación de recurrencia de la secuencia de fibonacci que se usó para derivar la fórmula de binet en el artículo anterior

Y utilizar la exponenciación por cuadrados para elevar a la potencia la matriz . Que sobre el semigrupo formado por las matrices de es también válido.

Por lo que podemos escribir la siguiente implementación que utiliza la exponenciación por cuadrados de esta matriz para calcular en tiempo logarítmico, es decir con complejidad computacional .

/**
* Multiplica la matriz a por la matrix b y deposita el resultado
* en la matriz r
*
* @param a matriz de 2x2 como un arreglo bidimensional
* @param b matriz de 2x2 como un arreglo bidimensional
* @param r matriz resultado de 2x2 como arreglo bidimensional
*/

void matrixMultiply(unsigned long a[2][2], unsigned long b[2][2],
unsigned long r[2][2]) {

unsigned long r00 = a[0][0] * b[0][0] + a[0][1] * b[1][0];
unsigned long r01 = a[0][0] * b[0][1] + a[0][1] * b[1][1];
unsigned long r10 = a[1][0] * b[0][0] + a[1][1] * b[1][0];
unsigned long r11 = a[1][0] * b[0][1] + a[1][1] * b[1][1];

r[0][0] = r00;
r[0][1] = r01;
r[1][0] = r10;
r[1][1] = r11;
}


/**
* Eleva la matriz "a" a la potencia n y deposita el resultado en "r"
* usando exponenciación por cuadrados
*
* @param a matriz de 2x2 como un arreglo bidimensional
* @param n un entero que representa la potencia a la que se elevará a
* @param r matriz resultado de 2x2 como arreglo bidimensional
* @return
*/

void power(unsigned long a[2][2], int n, unsigned long r[2][2]) {
// deposita la matriz identidad en r
if (n == 0) {
r[0][0] = 1;
r[0][1] = 0;
r[1][0] = 0;
r[1][1] = 1;
return;
}

// copia a en r
if (n == 1) {
for (int j = 0; j < 2; j++) {
for (int i = 0; i < 2; i++) {
r[j][i] = a[j][i];
}
}
return;
}

unsigned long a2[2][2];
matrixMultiply(a, a, a2);

if (n % 2 == 0) {
power(a2, n / 2, r);
} else {
power(a2, (n - 1) / 2, r);
matrixMultiply(a, r, r);
}
}

unsigned long fib(unsigned long n) {
unsigned long a[2][2] = { {1, 1},
{1, 0} };
unsigned long r[2][2];

power(a, n, r);

return r[1][0];
}

Lo interesante de esta implementación es que no sacrifica precisión y no genera errores de redondeo como en la implementación de la fórmula de Binet, con la misma complejidad algorítmica .

Referencias

← anterior