如何通过函数式编程提高效率?


20

最近,我一直在阅读“ 学习Haskell以获得卓越成就”指南,并且作为练习,我想用它来解决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的结果。这使我相信我误解了如何为Haskell解释这个问题。我查看了其他人的解决方案,发现了以下几点:

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

公平地说,这使用了内置函数,但是为什么自己动手最终结果会这么慢?那里的教程告诉您如何使用Haskell,但是在将算法转换为快速代码方面我没有看到太多帮助。


6
我应该指出,许多已解决的欧拉问题旁边都有pdf文件,可用来解决数学问题。您可以尝试阅读该pdf文件并实现每种语言描述的算法,然后对其进行概要分析。

Answers:


25

首先,您需要确保您有优化的二进制文件,然后再考虑语言是问题所在。阅读Real Wolrd Haskell中的“性能分析和优化”一章。值得注意的是,在大多数情况下,该语言的高级性质至少会花费您一些性能。

但是,请注意,另一个解决方案并不是更快,因为它使用了内置函数,而仅仅是因为它使用了更快的算法:要找到一组数字的最小公倍数,您只需找到几个GCD。将此与您的解决方案进行比较,该解决方案将循环显示从1到的所有数字foldl lcm [1..20]。如果尝试使用30,则运行时之间的差异会更大。

看一下复杂性:您的算法具有O(ans*N)运行时间,ans答案在哪里N,是检查除数的最大数字(本例中为20)。但是,
另一种算法执行N时间长lcmlcm(a,b) = a*b/gcd(a,b)GCD具有复杂性O(log(max(a,b)))。因此,第二种算法具有复杂性O(N*log(ans))。您可以自己判断哪个更快。

因此,总结一下:
您的问题是算法,而不是语言。

请注意,有一些专门的语言既可以起作用,又可以专注于数学繁重的程序,例如Mathematica,对于专注于数学的问题,它可能比其他任何语言都快。它具有非常优化的函数库,并且支持功能范例(诚然,它也支持命令式编程)。


3
我最近在Haskell程序中遇到性能问题,然后我意识到我在关闭优化的情况下进行了编译。切换优化可将性能提高约10倍。因此,用C编写的同一程序仍然更快,但是Haskell却没有慢很多(大约慢2到3倍,我认为这是一个很好的性能,考虑到我没有尝试进一步改进Has​​kell代码)。总结:概要分析和优化是一个很好的建议。+1
Giorgio

3
老实说,您可以删除前两段,它们并没有真正回答问题,并且可能不准确(它们肯定会在术语上玩得很快而又松散,语言不能有速度)
jk。

1
您给出了矛盾的答案。一方面,您断言OP“没有误解任何东西”,并且这种延迟是Haskell固有的。另一方面,您证明算法的选择确实很重要!如果跳过前两段,则答案会更好,这与其余答案有些矛盾。
Andres F.

2
接受Andres F.和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然后lcm 6 4产生12,依此类推。这样,仅调用lcm函数19次才能产生答案。用大O表示法,这是O(n-1)运算,用于得出解决方案。

对于从1到您的解决方案中的每个数字,您的slow-Haskell解决方案都会经历数字1-20。如果我们将解决方案称为s,那么慢速Haskell解决方案将执行O(s * n)运算。我们已经知道s超过900万,所以这可能可以解释它的缓慢性。即使所有快捷方式平均进入数字列表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,2016年

@ K.Steff你在跟我开玩笑吗...你必须将素数计算到19 ...这只需要一秒钟的时间。您的陈述绝对是零,即使使用素数生成,我的方法的总运行时间也非常小。我启用了剖析和我的做法(在Haskell)得到了total time = 0.00 secs (0 ticks @ 1000 us, 1 processor)total alloc = 51,504 bytes。运行时的时间比例可以忽略不计,甚至根本没有在分析器上注册。
分号

@semicolon,我应该保留我的评论,对此表示抱歉。我的陈述与计算直到N的所有素数的隐含价格有关-天真的Eratosthenes是O(N * log(N)* log(log(N)))运算和O(N)内存,这意味着这是第一个如果N很大,将耗尽内存或时间的算法的组成部分。使用Atkin筛子并不能获得更好的效果,因此我得出结论,该算法的吸引力将小于foldl lcm [1..N],后者需要一定数量的bigints。
K.Steff

@ K.Steff我刚刚测试了两种算法。对于基于素数的算法,探查器给了我(n = 100,000): total time = 0.04 secstotal alloc = 108,327,328 bytes。对于其他基于lcm的算法,探查器给了我:total time = 0.67 secstotal alloc = 1,975,550,160 bytes。对于n = 1,000,000,我得到基于素数的:total time = 1.21 secstotal alloc = 8,846,768,456 bytes,对于基于lcm的数:total time = 61.12 secstotal 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,000solvePrime

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,000solvePrime

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,000solvePrime

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)vs ~O(n^1.5))。

因此,以上结果和简要的算法分析应使显而易见的是哪种算法更快。

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.