Факторный алгоритм более эффективен, чем наивное умножение


38

Я знаю, как кодировать для факториалов, используя итеративные и рекурсивные (например, n * factorial(n-1)например). Я прочитал в учебнике (без каких-либо дальнейших объяснений), что существует еще более эффективный способ кодирования для факториалов, разделив их пополам рекурсивно.

Я понимаю, почему это может иметь место. Однако я хотел попробовать написать код самостоятельно, и я не думаю, что знаю, с чего начать. Друг предложил мне сначала написать базовые случаи. и я думал об использовании массивов, чтобы я мог отслеживать числа ... но я действительно не вижу никакого выхода для разработки такого кода.

Какие методы я должен исследовать?

Ответы:


40

Лучший известный алгоритм состоит в том, чтобы выразить факториал как произведение простых степеней. Можно быстро определить простые числа, а также правильную мощность для каждого простого числа, используя метод сита. Вычисление каждой мощности может быть эффективно выполнено с использованием повторного возведения в квадрат, а затем коэффициенты умножаются вместе. Это было описано Питером Б. Borwein, О сложности вычисления факториалов , журнал алгоритмов 6 376-380, 1985. ( PDF ) Короче говоря, можно вычислить за время O ( n ( log n ) 3 log log n ) по сравнению с Ω ( nn!O(n(logn)3loglogn) время, необходимое при использовании определения.Ω(n2logn)

Возможно, учебник имел в виду метод «разделяй и властвуй». Можно уменьшить умножения, используя регулярный образец продукта.n1

Пусть обозначают 1 3 5 ( 2 п - 1 ) в качестве удобного обозначения. Переставь множители ( 2 n ) ! = 1 2 3 ( 2 n ) при ( 2 n ) ! = п ! 2 н3 5 7 ( 2 н -n?135(2n1)(2n)!=123(2n) Теперь предположим, что n = 2 k для некоторого целого числа k > 0 . (Это полезное предположение, чтобы избежать осложнений в следующем обсуждении, и эту идею можно распространить на общее п .) Тогда ( 2 k ) ! = ( 2 k - 1 ) ! 2 2 k - 1 ( 2 k - 1 ) ? и расширив это повторение, ( 2 k ) ! знак равно

(2n)!=n!2n357(2n1).
n=2kk>0n(2k)!=(2k1)!22k1(2k1)? Вычислительный( 2 к - 1 )?
(2k)!=(22k1+2k2++20)i=0k1(2i)?=(22k1)i=1k1(2i)?.
(2k1)?(k2)+2k1222k222k1

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)

Даже этот код первого прохода улучшает тривиальный

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

примерно на 20% в моем тестировании.

Если немного поработать, это можно еще улучшить, также устраняя требование N быть силой 2(см. обширное обсуждение ).


Вы пропустили важный фактор. Время расчета по статье Борвейна не равно O (n log n log log n). Это O (M (n log n) log log n), где M (n log n) - время для умножения двух чисел размера n log n.
gnasher729

18

Имейте в виду, что функция факториала растет настолько быстро, что вам понадобятся целые числа произвольного размера, чтобы получить какую-либо выгоду от более эффективных методов, чем наивный подход. Факториал 21 уже слишком велик для 64-битной unsigned long long int.

Насколько я знаю, нет алгоритма для вычисления N! (факториал N) что быстрее, чем делать умножения.

However, the order in which you do the multiplications matter. Multiplication on a machine integer is a basic operation that takes the same time no matter what the value of the integer is. But for arbitrary-sized integers, the time it takes to multiply a and b depends on the size of a and b: a naive algorithm operates in time Θ(|a||b|) (where |x| is the number of digits of x — in whatever base you like, as the result is the same up to a multiplicative constant). There are faster multiplication algorithms, but there is an obvious lower bound of Ω(|a|+|b|) since multiplication has to at least read all the digits. All known multiplication algorithms grow faster than linearly in max(|a|,|b|).

Armed with this background, the Wikipedia article should make sense.

Since the complexity of multiplications depend on the size of the integers that are being multiplied, you can save time by arranging multiplications in an order that keeps the numbers being multiplied small. It works out better if you arrange for the numbers to be of roughly the same size. The “division in half” that your textbook refers to consists of the following divide-and-conquer approach to multiply a (multi)set of integers:

  1. Arrange the numbers to be multiplied (initially, all the integers from 1 to n) in two sets whose product is roughly the same size. This is a lot less expensive than doing the multiplication: |ab||a|+|b| (one machine addition).
  2. Apply the algorithm recursively on each of the two subsets.
  3. Multiply the two intermediate results.

See the GMP manual for more specifics.

There are even faster methods that not only rearrange the factors 1 to n but split the numbers by decomposing them into their prime factorization and rearranging the resulting very long product of mostly-small integers. I'll just cite the references from the Wikipedia article: “On the Complexity of Calculating Factorials” by Peter Borwein and implementations by Peter Luschny.

¹ There are faster ways of computing approximations of n!, but that's not computing the factorial any more, it's computing an approximation of it.


9

Since the factorial function grows so fast, your computer can only store n! for relatively small n. For example, a double can store values up to 171!. So if you want a really fast algorithm for computing n!, just use a table of size 171.

The question becomes more interesting if you're interested in log(n!) or in the Γ function (or in logΓ). In all these cases (including n!), I don't really understand the comment in your textbook.

As an aside, your iterative and recursive algorithms are equivalent (up to floating point errors), since you are using tail recursion.


"your iterative and recursive algorithms are equivalent" you're referring to the their asymptotic complexity, right? as for the comment in the textbook, well i'm translating it from another language so, maybe my translation sucks.
user65165

The book talks about iterative and recursive, and then comments on how if you use divide and conquer to divide n! in half you can get a way faster solution...
user65165

1
My notion of equivalence isn't completely formal, but you could say that the arithmetic operations performed are the same (if you switch the order of the operands in the recursive algorithm). An "inherently" different algorithm will perform a different calculation, perhaps using some "trick".
Yuval Filmus

1
If you consider the size of the integer as a parameter in the complexity of multiplication, the overall complexity can change even if the arithmetic operations are "the same".
Tpecatte

1
@CharlesOkwuagwu Right, you could use a table.
Yuval Filmus
Используя наш сайт, вы подтверждаете, что прочитали и поняли нашу Политику в отношении файлов cookie и Политику конфиденциальности.
Licensed under cc by-sa 3.0 with attribution required.