分析Haskell函数的运行时效率

时间:2012-07-26 00:06:40

标签: performance haskell optimization

我在Haskell中有以下解决方案Problem 3

isPrime :: Integer -> Bool
isPrime p = (divisors p) == [1, p]

divisors :: Integer -> [Integer]
divisors n = [d | d <- [1..n], n `mod` d == 0]

main = print (head (filter isPrime (filter ((==0) . (n `mod`)) [n-1,n-2..])))
  where n = 600851475143

但是,它需要超过Project Euler给出的分钟限制。那么如何分析代码的时间复杂度以确定我需要进行更改的位置?

注意:发布替代算法。我想自己解决这些问题。现在我只想分析我的代码并寻找改进它的方法。谢谢!

3 个答案:

答案 0 :(得分:11)

两件事:

  1. 每当您看到列表理解时(就像您在divisors中所做的那样),或等效地,列表中的某些map和/或filter函数系列(如你有main),把它的复杂性视为Θ(n),就像用命令语言处理for - 循环一样。

  2. 这可能不是你期望的那种建议,但我希望更多更有帮助:Project Euler的部分目的是鼓励你思考关于可能正确满足这些定义的许多不同算法的各种数学概念的定义。

  3. 好的,第二个建议太模糊了......我的意思是,例如,你实现isPrime的方式实际上是教科书的定义:

    isPrime :: Integer -> Bool
    isPrime p = (divisors p) == [1, p]
    -- p is prime if its only divisors are 1 and p. 
    

    同样,您对divisors的实施很简单:

    divisors :: Integer -> [Integer]
    divisors n = [d | d <- [1..n], n `mod` d == 0]
    -- the divisors of n are the numbers between 1 and n that divide evenly into n.
    

    这些定义都读得非常好!另一方面,在算法上,它们太天真了。让我们举个简单的例子:10号的除数是多少? [1, 2, 5, 10]。在检查时,您可能会注意到一些事情:

    • 1和10是成对的,2和5是成对的。
    • 除了10本身之外,不能有任何超过5的除数。

    您可以利用这些属性来优化算法,对吧?因此,在不查看代码的情况下 - 仅使用铅笔和纸张 - 尝试为divisors勾画出更快的算法。如果您已了解我的提示,divisors n应该在sqrt n时间内运行。随着您的继续,您将在这些方面找到更多机会。您可能决定以不同方式使用divisors功能的方式重新定义所有内容...

    希望这有助于为您解决这些问题提供正确的思路!

答案 1 :(得分:8)

让我们从顶部开始。

divisors :: Integer -> [Integer]
divisors n = [d | d <- [1..n], n `mod` d == 0]

现在,让我们假设某些事情很便宜:递增数字是O(1),执行mod操作是O(1),与0的比较是O(1)。 (这些都是错误的假设,但是到底是什么。)divisors函数循环遍历从1n的所有数字,并对每个数字执行O(1)运算,因此计算完整的输出是O(n)。请注意,这里当我们说O(n)时,n是输入数,而不是输入的 size !由于需要m = log(n)位来存储n,因此该函数在输入的大小上花费O(2 ^ m)的时间来产生完整的答案。我将使用n和m来表示输入数字和输入大小。

isPrime :: Integer -> Bool
isPrime p = (divisors p) == [1, p]

在最坏的情况下,p是素数,迫使divisors产生其整个输出。与静态已知长度列表的比较是O(1),因此这是对divisors的调用所主导的。 O(n),O(2 ^ m)

你的main函数会同时执行一些操作,所以让我们稍微分解一下子表达式。

filter ((==0) . (n `mod`))

这循环遍历列表,并对每个元素执行O(1)操作。这是O(m),其中m是输入列表的长度。

filter isPrime

循环遍历列表,对每个元素执行O(n)工作,其中n是列表中的最大数字。如果列表恰好是n个元素长(就像你的情况那样),这意味着这是O(n * n)工作,或O(2 ^ m * 2 ^ m)= O(4 ^ m)工作(如上所述,此分析适用于生成整个列表的情况。)

print . head

微小的工作。我们称它为打印部件的O(m)。

main = print (head (filter isPrime (filter ((==0) . (n `mod`)) [n-1,n-2..])))

考虑到上面的所有子表达式,filter isPrime位显然是主导因素。 O(4 ^ m),O(n ^ 2)

现在,需要考虑一个最后的微妙之处:在上面的分析中,我一直假设每个函数/子表达式都被迫产生它的整个输出。正如我们在main中看到的,这可能不是真的:我们调用head,这只会强制列表中的一小部分。但是,如果输入数字本身不是素数,我们肯定知道我们必须查看至少一半的列表:n/2n之间肯定没有除数。所以,充其量,我们将工作减半 - 这对渐近成本没有影响。

答案 2 :(得分:2)

Daniel Wagner's answer解释了为运行时复杂性导出边界的一般策略。然而,正如一般策略的情况一样,它产生过于保守的界限。

所以,只是为了它,让我们更详细地研究一下这个例子。

main = print (head (filter isPrime (filter ((==0) . (n `mod`)) [n-1,n-2..])))
    where n = 600851475143

(旁白:如果n是素数,这会在检查n `mod` 0 == 0时导致运行时错误,因此我将列表更改为[n, n-1 .. 2],以便算法适用于所有{{1} }}。)

让我们将表达式分成几部分,这样我们就可以更容易地看到和分析每个部分

n > 1
像丹尼尔一样,我假设算术运算,比较等等都是O(1) - 尽管不是这样,这对于所有远程合理的输入都是足够好的近似值。

因此,在列表main = print answer where n = 600851475143 candidates = [n, n-1 .. 2] divisorsOfN = filter ((== 0) . (n `mod`)) candidates primeDivisors = filter isPrime divisorsOfN answer = head primeDivisors 中,必须生成从candidatesn的元素answer元素,总费用为n - answer + 1 }。对于复合O(n - answer + 1),我们有n,然后是Θ(n)。

然后根据需要生成除数列表answer <= n/2

对于Θ(n - answer + 1)的除数的d(n)个数,我们可以使用粗估计n

必须检查d(n) <= 2√n的所有除数>= answer的素数,这至少是所有除数的一半。 由于除数列表是懒惰生成的,所以

的复杂性
n

是O(p的最小素因子),因为只要找到第一个除数isPrime :: Integer -> Bool isPrime p = (divisors p) == [1, p] ,就会确定相等性检验。对于复合> 1,最小的素因子是p

我们对最差O(√n)的复杂性进行<= √p素检,并对复杂度< 2√n进行一次检查,因此所有主要测试的组合工作是O(n)。 / p>

总结一下,所需的总工作量为Θ(answer),因为每个步骤的费用最差为O(n)

事实上,此算法完成的总工作是O(n)。如果Θ(n)是素数,则根据需要生成除数列表在O(1)中完成,但素数测试为n。如果Θ(n)是复合的,n,并且根据需要生成除数列表answer <= n/2

如果我们不认为算术运算是O(1),我们必须乘以对Θ(n)大小的数字的算术运算的复杂性,即n位,根据所使用的算法,通常会给出略高于O(log n)且低于log n的因子。