Як підвищити ефективність функціонального програмування?


20

Нещодавно я переглядав керівництво Learn You a Haskell for Great Good, і в якості практики я хотів вирішити з ним проблему 5 проекту Euler , в якій вказано:

Яке найменше додатне число, яке рівномірно ділиться на всі числа від 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 також був підрахований майже миттєво. Це змушує мене вважати, що я нерозумію, як інтерпретувати цю проблему для Haskell. Я переглянув рішення інших людей і виявив таке:

main = putStrLn $ show $ foldl1 lcm [1..20]

Досить справедливо, для цього використовується вбудована функція, але чому кінцевий результат настільки повільніше, коли ви робите це самостійно? Навчальні посібники там розповідають, як використовувати Haskell, але я не бачу великої допомоги в перетворенні алгоритмів у швидкий код.


6
Я мушу зазначити, що у багатьох вирішених проблем Ейлера поруч із ними є pdfs, що стосується вирішення математичної задачі. Ви можете спробувати прочитати цей pdf та реалізувати алгоритм, описаний у кожній мові, а потім профілювати його.

Відповіді:


25

Спочатку потрібно переконатися, що у вас є оптимізований двійковий файл, перш ніж думати, що мова є проблемою. Прочитайте розділ « Профілювання та оптимізація» у розділі Real Wolrd Haskell. Варто зазначити, що в більшості випадків мова на високому рівні коштує вам хоча б частини виконання.

Однак зауважте, що інше рішення не швидше, оскільки воно використовує вбудовану функцію, а просто тому, що воно використовує набагато швидший алгоритм : щоб знайти найменше спільне множину набору чисел, вам потрібно знайти лише кілька GCD. Порівняйте це зі своїм рішенням, яке перебирає всі числа від 1 до foldl lcm [1..20]. Якщо спробувати 30, різниця між тривалістю виконання буде ще більшою.

Погляньте на складності: ваш алгоритм має O(ans*N)час виконання, де ansвідповідь і Nчисло, до якого ви перевіряєте на подільність (20 у вашому випадку).
Інший алгоритм , однак , виконує Nрази , і GCD має складність . Тому другий алгоритм має складність . Ви можете самі судити, що швидше.lcmlcm(a,b) = a*b/gcd(a,b)O(log(max(a,b)))O(N*log(ans))

Отже, підсумовуючи:
Вашою проблемою є ваш алгоритм, а не мова.

Зауважте, що існують спеціалізовані мови, які функціонують і орієнтовані на математично важкі програми, наприклад, Mathematica, які для математично орієнтованих проблем, мабуть, швидші, ніж майже все інше. У нього дуже оптимізована бібліотека функцій, і вона підтримує функціональну парадигму (правда, вона також підтримує імперативне програмування).


3
Нещодавно у мене виникли проблеми з роботою з програмою Haskell, і тоді я зрозумів, що компіляцію з оптимізаціями вимкнено. Переключення оптимізації на підвищення продуктивності приблизно в 10 разів. Так що та сама програма, написана на C, все ще була швидшою, але Haskell була не набагато повільнішою (приблизно в 2, 3 рази повільніше, що, на мою думку, є хорошим показником, враховуючи також, що я не намагався вдосконалити код Haskell більше). Підсумок: профілювання та оптимізація - це гарна пропозиція. +1
Джорджіо

3
чесно кажучи, ви можете видалити перші два абзаци, вони насправді не відповідають на питання і, ймовірно, неточні (вони, звичайно, грають швидко і вільно з термінологією, мови не можуть мати швидкість)
jk.

1
Ви даєте суперечливу відповідь. З одного боку, ви стверджуєте, що ОП "нічого не зрозуміла", і що повільність притаманна Haskell. З іншого боку, ви показуєте, що вибір алгоритму має значення! Ваша відповідь була б набагато кращою, якби вона пропустила перші два абзаци, які дещо суперечать решті відповіді.
Андрес Ф.

2
Беручи відгуки від Андреса Ф. та jk. Я вирішив звести перші два абзаци до кількох речень. Дякую за коментарі
K.Steff

5

Перша моя думка полягала в тому, що тільки числа, що діляться на всі прості <= 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 Тоді lсм 6 4 виходить 12 і так далі. Таким чином функція lcm викликається лише 19 разів, щоб отримати вашу відповідь. У нотації Big O, це операції O (n-1) для досягнення рішення.

Ваше рішення з повільним Haskell проходить через числа 1-20 для кожного числа від 1 до вашого рішення. Якщо ми називаємо рішення s, то рішення slow-Haskell виконує операції O (s * n). Ми вже знаємо, що s - понад 9 мільйонів, так що, ймовірно, пояснює повільність. Навіть якщо всі ярлики і отримує в середньому на півдорозі список списків цифр 1-20, це все одно лише O (s * n / 2).

Виклик headне рятує вас від виконання цих обчислень, їх потрібно зробити, щоб обчислити перше рішення.

Дякую, це було цікаве питання. Це дійсно розтягнуло мої знання про Haskell. Я би не змогла відповісти на це взагалі, якби не вивчила алгоритми минулої осені.


Насправді підхід, який ви отримували з 2 * 3 * 5 * 7 * 11 * 13 * 17 * 19, ймовірно, є принаймні таким же швидким, як рішення на основі lcm. Що вам конкретно потрібно, це 2 ^ 4 * 3 ^ 2 * 5 * 7 * 11 * 13 * 17 * 19. Тому що 2 ^ 4 - найбільша потужність на 2 менша або дорівнює 20, а 3 ^ 2 - найбільша потужність на 3 менше або дорівнює 20 тощо.
крапка з комою

@semicolon Хоча, безумовно, швидше, ніж інші обговорювані альтернативи, цей підхід також вимагає попередньо розрахованого списку простих чисел, менших за вхідний параметр. Якщо ми врахуємо, що під час виконання (і, що ще важливіше, у сліді пам’яті), такий підхід, на жаль, стає менш привабливим
K.Steff

@ K.Steff Ви жартуєте зі мною ... вам доведеться комп’ютерирувати праймери до 19 ..., що займає невелику частку секунди. Ваша заява має сенс НЕРО, загальний час мого підходу неймовірно крихітний навіть у прем'єр-генерації. Я включив профілювання, і мій підхід (в Haskell) отримав total time = 0.00 secs (0 ticks @ 1000 us, 1 processor)і total alloc = 51,504 bytes. Час виконання - це незначна частка секунди, щоб навіть не зареєструватися на профілері.
крапка з комою

@semicolon Я повинен був кваліфікувати свій коментар, вибачте за це. Моє твердження стосувалося прихованої ціни обчислення всіх праймерів до N - наївний Ератостен є операціями O (N * log (N) * log (log (N))) і O (N) пам'яттю, що означає, що це перший компонент алгоритму, який втратить пам'ять або час, якщо N дійсно великий. З ситом Аткіна це не стає набагато кращим, тому я зробив висновок, що алгоритм буде менш привабливим, ніж той foldl lcm [1..N], для якого потрібна постійна кількість бинтинов.
K.Steff

@ K.Steff Ну я просто перевірив обидва алгоритми. Для мого основного алгоритму профілер дав мені (для n = 100 000): 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. Отже, іншими словами, ви неправі, основана на прайме набагато краще.
крапка з комою

1

Спочатку я не планував писати відповідь. Але мені сказали після того, як інший користувач висловив дивну заяву, що просто множення перших парних праймерів було обчислювально дорожчим, ніж багаторазове застосування lcm. Ось два алгоритми та деякі орієнтири:

Мій алгоритм:

Алгоритм основного покоління, що дає мені нескінченний список простих чисел.

isPrime :: Int -> Bool
isPrime 1 = False
isPrime n = all ((/= 0) . mod n) (takeWhile ((<= n) . (^ 2)) primes)

toPrime :: Int -> Int
toPrime n 
    | isPrime n = n 
    | otherwise = toPrime (n + 1)

primes :: [Int]
primes = 2 : map (toPrime . (+ 1)) primes

Тепер використовуємо цей простий список для обчислення результату для деяких N:

solvePrime :: Integer -> Integer
solvePrime n = foldl' (*) 1 $ takeWhile (<= n) (fromIntegral <$> primes)

Тепер інший алгоритм на основі lcm, який, правда кажучи, є досить стислим, головним чином тому, що я реалізував першокласне покоління з нуля (і не використовував алгоритм розуміння суперкороткого списку через його низькі показники), тоді як lcmбув просто імпортований з Prelude.

solveLcm :: Integer -> Integer
solveLcm n = foldl' (flip lcm) 1 [2 .. n]
-- Much slower without `flip` on `lcm`

Тепер для орієнтирів код, який я використовував для кожного, був простим: ( -prof -fprof-auto -O2тоді +RTS -p)

main :: IO ()
main = print $ solvePrime n
-- OR
main = print $ solveLcm n

Для n = 100,000, solvePrime:

total time = 0.04 secs
total alloc = 108,327,328 bytes

vs solveLcm:

total time = 0.12 secs
total alloc = 117,842,152 bytes

Для n = 1,000,000, solvePrime:

total time = 1.21 secs
total alloc = 8,846,768,456 bytes

vs solveLcm:

total time = 9.10 secs
total alloc = 8,963,508,416 bytes

Для n = 3,000,000, solvePrime:

total time = 8.99 secs
total alloc = 74,790,070,088 bytes

vs solveLcm:

total time = 86.42 secs
total alloc = 75,145,302,416 bytes

Я думаю, що результати говорять самі за себе.

Профілер вказує, що основне покоління займає все менший і менший відсоток часу виконання, оскільки nзбільшується. Тож це не вузьке місце, тому ми можемо поки що це ігнорувати.

Це означає, що ми дійсно порівнюємо виклики, lcmколи один аргумент переходить від 1 до n, а інший іде геометрично від 1 до ans. Для дзвінків *з однаковою ситуацією та додатковою перевагою отримати пропустити кожне непросте число (асимптотично безкоштовно, через більш дорогий характер *).

І це добре відомо , що *швидше , ніж lcm, як і lcmвимагає повторних застосувань mod, і modасимптотично повільніше ( O(n^2)проти ~O(n^1.5)).

Отже, наведені вище результати та короткий аналіз алгоритму повинні зробити дуже очевидним, який алгоритм швидший.

Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.