Algoritmo factorial más eficiente que la multiplicación ingenua

38

Sé cómo codificar factoriales usando tanto iterativo como recursivo (por ejemplo, n * factorial(n-1)por ejemplo). Leí en un libro de texto (sin más explicaciones) que hay una forma aún más eficiente de codificar los factoriales dividiéndolos por la mitad de forma recursiva.

Entiendo por qué ese puede ser el caso. Sin embargo, quería intentar codificarlo por mi cuenta, y no creo saber por dónde empezar. Un amigo me sugirió que escribiera casos base primero. y estaba pensando en usar matrices para poder hacer un seguimiento de los números ... pero realmente no puedo ver ninguna forma de diseñar dicho código.

¿Qué tipo de técnicas debería investigar?

usuario65165
fuente

Respuestas:

40

El mejor algoritmo que se conoce es expresar el factorial como producto de poderes primarios. Uno puede determinar rápidamente los números primos, así como la potencia correcta para cada primer utilizando un enfoque de tamiz. El cálculo de cada potencia se puede hacer de manera eficiente utilizando la cuadratura repetida, y luego los factores se multiplican juntos. Esto fue descrito por Peter B. Borwein, Sobre la complejidad de los factores de cálculo , Journal of Algorithms 6 376–380, 1985. ( PDF ) En resumen,se puede calcular en el tiempo O (n (\ log n) ^ 3 \ log \ log n) , en comparación con el tiempo \ Omega (n ^ 2 \ log n) requerido cuando se usa la definición.n!O(n(logn)3loglogn)Ω(n2logn)

Lo que tal vez significó el libro de texto fue el método de divide y vencerás. Se pueden reducir las multiplicaciones usando el patrón regular del producto.n1

Dejardenote como una notación conveniente. Reorganizar los factores de como Ahora suponga que para algún entero . (Esta es una suposición útil para evitar complicaciones en la siguiente discusión, y la idea puede extenderse al general ). Entoncesy al expandir esta recurrencia, Computación1 3 5 ( 2 n - 1 ) ( 2 n ) ! = 1 2 3 ( 2 n ) ( 2 n ) ! = n ! 2 n3 5 7 ( 2 n - 1 ) . n = 2 k k >n?135(2n1)(2n)!=123(2n)

(2n)!=n!2n357(2n1).
n=2kn ( 2 k ) ! = ( 2 k - 1 ) ! 2 2 k - 1 ( 2 k - 1 ) ? ( 2 k ) ! = ( 2 2 k - 1 + 2 k - 2 + + 2 0 ) k - 1 i = 0 ( 2 i )k>0n(2k)!=(2k1)!22k1(2k1)?
(2k)!=(22k1+2k2++20)i=0k1(2i)?=(22k1)i=1k1(2i)?.
(2k1)?y multiplicar los productos parciales en cada etapa requiere multiplicaciones. Esta es una mejora de un factor de casi de las multiplicaciones simplemente usando la definición. Se requieren algunas operaciones adicionales para calcular la potencia de , pero en aritmética binaria esto se puede hacer a bajo costo (dependiendo de lo que se requiera con precisión, puede requerir agregar un sufijo de ceros).(k2)+2k1222k222k1

El siguiente código Ruby implementa una versión simplificada de esto. Esto no evita volver a calcularincluso donde podría hacerlo:n?

def oddprod(l,h)
  p = 1
  ml = (l%2>0) ? l : (l+1)
  mh = (h%2>0) ? h : (h-1)
  while ml <= mh do
    p = p * ml
    ml = ml + 2
  end
  p
end

def fact(k)
  f = 1
  for i in 1..k-1
    f *= oddprod(3, 2 ** (i + 1) - 1)
  end
  2 ** (2 ** k - 1) * f
end

print fact(15)

Incluso este código de primer paso mejora lo trivial

f = 1; (1..32768).map{ |i| f *= i }; print f

en aproximadamente un 20% en mis pruebas.

Con un poco de trabajo, esto se puede mejorar aún más, eliminando también el requisito de que sea ​​una potencia de (vea la discusión extensa ).n2

András Salamon
fuente
Dejaste fuera un factor importante. El tiempo de cálculo según el artículo de Borwein no es O (n log n log log n). Es O (M (n log n) log log n), donde M (n log n) es el momento de multiplicar dos números de tamaño n log n.
gnasher729
18

Tenga en cuenta que la función factorial crece tan rápido que necesitará números enteros de tamaño arbitrario para obtener cualquier beneficio de técnicas más eficientes que el enfoque ingenuo. El factorial de 21 ya es demasiado grande para caber en un 64 bits unsigned long long int.

Hasta donde yo sé, ¡no hay un algoritmo para calcular(factorial de ) que es más rápido que hacer las multiplicaciones.n!n

Sin embargo, el orden en el que haces las multiplicaciones es importante. La multiplicación en un entero de máquina es una operación básica que lleva el mismo tiempo sin importar el valor del entero. Pero para los enteros de tamaño arbitrario, el tiempo que se necesita para multiplicar una y B depende del tamaño de una y b : un algoritmo ingenuo funciona en tiempo (en el que es el número de dígitos de - en cualquier base que desee, ya que el resultado es el mismo hasta una constante multiplicativa). Hay algoritmos de multiplicación más rápidos , pero hay un límite inferior obvio deΘ(|a||b|)|x|xΩ(|a|+|b|)ya que la multiplicación tiene que leer al menos todos los dígitos. Todos los algoritmos de multiplicación conocidos crecen más rápido que linealmente en .max(|a|,|b|)

Armado con estos antecedentes, el artículo de Wikipedia debería tener sentido.

Dado que la complejidad de las multiplicaciones depende del tamaño de los enteros que se multiplican, puede ahorrar tiempo organizando las multiplicaciones en un orden que mantenga los números pequeños. Funciona mejor si arreglas que los números sean aproximadamente del mismo tamaño. La “división por la mitad” de que su libro de texto se refiere a consiste en lo siguiente divide y vencerás enfoque para multiplicar un conjunto (multi) de enteros:

  1. Organice los números que se multiplicarán (inicialmente, todos los enteros del al ) en dos conjuntos cuyo producto es aproximadamente del mismo tamaño. Esto es mucho menos costoso que hacer la multiplicación:(Además de una máquina).1n|ab||a|+|b|
  2. Aplique el algoritmo recursivamente en cada uno de los dos subconjuntos.
  3. Multiplica los dos resultados intermedios.

Consulte el manual de GMP para más detalles.

Hay incluso más rápido que los métodos no sólo reorganizar los factores a , pero dividen los números por descomponerlos en su descomposición en factores primos y reordenando el producto que resulta de muy largo en su mayoría de pequeños números enteros. Solo citaré las referencias del artículo de Wikipedia: "Sobre la complejidad de calcular factores" de Peter Borwein y las implementaciones de Peter Luschny .1n

¹ ¡ Hay formas más rápidas de calcular aproximaciones de, pero eso ya no es calcular el factorial, es calcular una aproximación de él.n!

Gilles 'SO- deja de ser malvado'
fuente
9

Dado que la función factorial crece tan rápido, su computadora solo puede almacenarpara relativamente pequeño . Por ejemplo, ¡un doble puede almacenar valores de hasta. Entonces, si quieres un algoritmo realmente rápido para calcular, solo use una tabla de tamaño .n!n171!n!171

La pregunta se vuelve más interesante si está interesado en O en la función (o en ). En todos estos casos (¡incluido ), Realmente no entiendo el comentario en su libro de texto.log(n!)ΓlogΓn!

Por otro lado, sus algoritmos iterativos y recursivos son equivalentes (hasta errores de coma flotante), ya que está utilizando la recursividad de cola.

Yuval Filmus
fuente
"sus algoritmos iterativos y recursivos son equivalentes" se refiere a su complejidad asintótica, ¿verdad? En cuanto al comentario en el libro de texto, bueno, lo estoy traduciendo de otro idioma, así que tal vez mi traducción es una mierda.
user65165
El libro habla sobre iterativo y recursivo, y luego comenta cómo si usas divide y vencerás para dividir n! a la mitad puede obtener una solución mucho más rápida ...
user65165
1
Mi noción de equivalencia no es completamente formal, pero se podría decir que las operaciones aritméticas realizadas son las mismas (si cambia el orden de los operandos en el algoritmo recursivo). Un algoritmo "inherentemente diferente" realizará un cálculo diferente, quizás usando algún "truco".
Yuval Filmus
1
Si considera el tamaño del entero como un parámetro en la complejidad de la multiplicación, la complejidad general puede cambiar incluso si las operaciones aritméticas son "iguales".
Tpecatte
1
@CharlesOkwuagwu Correcto, podrías usar una mesa.
Yuval Filmus