Недавно я изучал руководство « Learn You a Haskell for Great Good», и на практике я хотел решить с ним задачу Project Euler 5 , в которой указано:
Какое наименьшее положительное число равномерно делится на все числа от 1 до 20?
Я решил сначала написать функцию, определяющую, делится ли данное число на эти числа:
divisable x = all (\y -> x `mod` y == 0)[1..20]
Затем я рассчитал наименьшее из них, используя head
:
sm = head [x | x <- [1..], divisable x]
И, наконец, написал строку для отображения результата:
main = putStrLn $ show $ sm
К сожалению, это заняло около 30 секунд, чтобы закончить. Выполнение того же действия с числами от 1 до 10 дает результат почти сразу, но с другой стороны, результат намного меньше, чем решение для 1 до 20.
Я решил это ранее в C, и там результат для 1 до 20 также был вычислен почти мгновенно. Это заставляет меня поверить, что я неправильно понимаю, как интерпретировать эту проблему для Хаскелла. Я просмотрел решения других людей и нашел это:
main = putStrLn $ show $ foldl1 lcm [1..20]
Честно говоря, здесь используется встроенная функция, но почему конечный результат намного медленнее, если вы делаете это самостоятельно? В руководствах рассказывается, как использовать Haskell, но я не вижу особой помощи в преобразовании алгоритмов в быстрый код.
Ответы:
Прежде всего вы должны убедиться, что у вас есть оптимизированный двоичный файл, прежде чем думать, что проблема в языке. Прочитайте раздел « Профилирование и оптимизация» в Real Wolrd Haskell. Стоит отметить, что в большинстве случаев высокоуровневая природа языка стоит вам хотя бы части производительности.
Однако обратите внимание, что другое решение не быстрее, потому что оно использует встроенную функцию, а просто потому, что оно использует гораздо более быстрый алгоритм : чтобы найти наименьшее общее кратное из набора чисел, вам нужно найти только несколько GCD. Сравните это с вашим решением, которое перебирает все числа от 1 до
foldl lcm [1..20]
. Если вы попробуете с 30, разница между временем выполнения будет еще больше.Посмотрите на сложности: ваш алгоритм имеет
O(ans*N)
время выполнения, гдеans
ответ иN
число, до которого вы проверяете делимость (20 в вашем случае).Другой алгоритм выполняет
N
времяlcm
, однакоlcm(a,b) = a*b/gcd(a,b)
, и GCD имеет сложностьO(log(max(a,b)))
. Поэтому второй алгоритм имеет сложностьO(N*log(ans))
. Вы можете судить сами, что быстрее.Итак, подведем итог:
ваша проблема в вашем алгоритме, а не в языке.
Обратите внимание, что существуют специализированные языки, которые функциональны и ориентированы на сложные математические программы, такие как Mathematica, которая для задач, ориентированных на математику, вероятно, быстрее, чем что-либо еще. Он имеет очень оптимизированную библиотеку функций и поддерживает функциональную парадигму (правда, он также поддерживает императивное программирование).
источник
Моей первой мыслью было, что только числа, кратные всем простым числам <= 20, будут делиться на все числа, меньшие 20. Поэтому вам нужно учитывать только числа, кратные 2 * 3 * 5 * 7 * 11 * 13 * 17 * 19 , Такое решение проверяет 1/9 699 690 столько же чисел, сколько и метод грубой силы. Но ваше быстрое решение Haskell работает лучше, чем это.
Если я понимаю «быстрое решение Haskell», оно использует foldl1, чтобы применить функцию lcm (наименьшее общее кратное) к списку чисел от 1 до 20. Таким образом, он применил бы lcm 1 2, давая 2. Затем lcm 2 3, давая 6 Затем lcm 6 4, получая 12 и так далее. Таким образом, функция lcm вызывается только 19 раз, чтобы получить ваш ответ. В обозначении Big O это O (n-1) операций, чтобы прийти к решению.
Ваше медленное решение Haskell проходит номера 1-20 для каждого числа от 1 до вашего решения. Если мы называем решение s, то решение с медленным Haskell выполняет O (s * n) операций. Мы уже знаем, что s превышает 9 миллионов, что, вероятно, объясняет медлительность. Даже если все ярлыки и в среднем получаются в середине списка чисел 1-20, это все равно только O (s * n / 2).
Вызов
head
не спасает вас от выполнения этих вычислений, они должны быть выполнены, чтобы вычислить первое решение.Спасибо, это был интересный вопрос. Это действительно расширило мои знания Хаскеля. Я не смог бы ответить на это вообще, если бы я не изучал алгоритмы прошлой осенью.
источник
total time = 0.00 secs (0 ticks @ 1000 us, 1 processor)
иtotal alloc = 51,504 bytes
. Время выполнения - ничтожно малая доля секунды, чтобы даже не зарегистрироваться на профилировщике.foldl lcm [1..N]
решеткой Аткина не намного лучше, поэтому я пришел к выводу, что алгоритм будет менее привлекательным, чем тот , для которого требуется постоянное число больших пальцев.total time = 0.04 secs
иtotal alloc = 108,327,328 bytes
. Для другого алгоритма, основанного на lcm, профилировщик дал мне:total time = 0.67 secs
иtotal alloc = 1,975,550,160 bytes
. Для n = 1 000 000 я получил для простых:total time = 1.21 secs
иtotal alloc = 8,846,768,456 bytes
, а для lcm:total time = 61.12 secs
иtotal alloc = 200,846,380,808 bytes
. Другими словами, вы не правы, основанная на прайме намного лучше.Я изначально не планировал писать ответ. Но мне сказали, что после того, как другой пользователь сделал странное утверждение, что просто умножение простых чисел первой пары было более вычислительно дорогим, чем повторное применение
lcm
. Итак, вот два алгоритма и некоторые тесты:Мой алгоритм:
Алгоритм генерации простых чисел, дающий мне бесконечный список простых чисел.
Теперь используем этот простой список для вычисления результата для некоторых
N
:Теперь другой основанный на lcm алгоритм, который, по общему признанию, является довольно лаконичным, главным образом потому, что я реализовал простое генерирование с нуля (и не использовал алгоритм понимания супер сжатого списка из-за его низкой производительности), тогда как он
lcm
был просто импортирован изPrelude
.Теперь для тестов, код, который я использовал для каждого, был прост: (
-prof -fprof-auto -O2
тогда+RTS -p
)Для
n = 100,000
,solvePrime
:против
solveLcm
:Для
n = 1,000,000
,solvePrime
:против
solveLcm
:Для
n = 3,000,000
,solvePrime
:против
solveLcm
:Я думаю, что результаты говорят сами за себя.
Профилировщик указывает, что генерация простых чисел занимает все меньше и меньше процента времени выполнения по мере
n
увеличения. Так что это не узкое место, поэтому мы можем пока игнорировать это.Это означает, что мы действительно сравниваем вызов,
lcm
где один аргумент идет от 1 доn
, а другой геометрически от 1 доans
. Для звонков*
с такой же ситуацией и дополнительным преимуществом пропуска каждого непростого номера (асимптотически бесплатно, из-за более дорогой природы*
).И хорошо известно, что
*
это быстрее, чемlcm
, чтоlcm
требует многократного примененияmod
, иmod
асимптотически медленнее (O(n^2)
против~O(n^1.5)
).Таким образом, приведенные выше результаты и краткий анализ алгоритма должны сделать очень очевидным, какой алгоритм быстрее.
источник