【问题标题】:Performance of two alternative functions that compute least divisor计算最小除数的两个替代函数的性能
【发布时间】:2014-03-25 03:02:13
【问题描述】:

在“The Haskell Road to Logic, Maths and Programming 一书中,作者提出了两种替代方法来找到数字nk > 1 的最小除数k,声称第二个版本比第一个版本快得多。我很难理解为什么(我是初学者)。

这是第一个版本(第 10 页):

ld :: Integer -> Integer -- finds the smallest divisor of n which is > 1
ld n = ldf 2 n

ldf :: Integer -> Integer -> Integer
ldf k n | n `rem` k == 0 = k
        | k ^ 2 > n      = n
        | otherwise      = ldf (k + 1) n

如果我理解正确,ld 函数基本上会遍历区间 [2..sqrt(n)] 中的所有整数,并在其中一个整数除以 n 时立即停止,并将其作为结果返回。

第二个版本,作者声称要快得多,如下所示(第 23 页):

ldp :: Integer -> Integer -- finds the smallest divisor of n which is > 1
ldp n = ldpf allPrimes n

ldpf :: [Integer] -> Integer -> Integer
ldpf (p:ps) n | rem n p == 0 = p
              | p ^ 2 > n    = n
              | otherwise    = ldpf ps n

allPrimes :: [Integer]
allPrimes = 2 : filter isPrime [3..]

isPrime :: Integer -> Bool
isPrime n | n < 1     = error "Not a positive integer"
          | n == 1    = False
          | otherwise = ldp n == n

作者声称此版本更快,因为它仅迭代区间 2..sqrt(n) 内的 素数 列表,而不是迭代该范围内的所有数字。

但是,这个论点并不能说服我:递归函数ldpf 正在一个接一个地吃掉素数列表allPrimes 中的数字。这个列表是通过对所有整数列表执行filter 生成的。

所以除非我遗漏了什么,否则第二个版本最终也会遍历区间 2..sqrt(n) 内的所有数字,但是对于每个数字,它首先检查它是否是素数(一个相对昂贵的操作),如果是,它检查它是否划分n(一个相对便宜的)。

我想说,仅检查 k 是否为每个 k 划分 n 应该更快。我的推理缺陷在哪里?

【问题讨论】:

  • 可能是编译器的魔力适用于 isPrime,这使得它在大多数情况下比您预期的要快。
  • 确实如此。与Petr Pudlák's answer 一样,对于单次使用,朴素算法应该击败仅素数算法。任何后续使用等于或低于先前使用会更快,因为primes 是一个常量应用形式(CAF)并且会被记忆。事实上,primes 列表并不是最快的算法,但至少它避免了 Eratosthenes 多次删除问题的常见“不忠实”搜索。如果您想更快地找到素数,请阅读The Genuine Sieve of Eratosthenes
  • 与问题无关,我会使用n*n 而不是n^2,因为我不相信编译器会为我这样做。
  • 会不会让第一个变体相对于第二个变体更具竞争力?即,检查 k=2,3,5,然后将 2 和 4 交替添加到 k 以遍历数字 6j-1 和 6j+1?

标签: performance algorithm haskell primes


【解决方案1】:

第二种解决方案的主要优点是您只需计算一次素数列表allPrimes。由于惰性求值,每个调用只计算它需要的素数,或者重用已经计算过的素数。因此,昂贵的部分只计算一次,然后就可以重复使用。

对于计算单个数字的最小除数,第一个版本确实更有效。但是,请尝试对 1 到 100000 之间的所有数字运行 ldpld,您会发现差异。

【讨论】:

  • For computing the least divisor of just a single number, the first version is indeed more efficient - 好的,这基本上就是我所关心的。我确实理解记忆和其他优化可以使第二个算法在计算中运行得更快,例如sum $ map ldp [1..1000000],以前计算的结果可以被重用,但是当作者写“通过运行程序时,这种情况并不明显primes2primes1 很容易检查 primes2 是否更快”。无论如何,我将此答案标记为已接受。感谢您的帮助。
【解决方案2】:

haskell 对我来说是未知的,所以如果没有正确测量展位版本,我只能假设声明是正确的。在这种情况下,原因可能是:

1.primes 预先计算在某个数组中

  • 那么是素数吗?时间不贵

2.primes 在运行(和记忆)时计算

  • 从一开始就将是第一个版本更快
  • 但连续使用会使第二个版本更快(尤其是对于更大的 n)

3.primes 是在运行时计算的(而不是记忆)

  • 如 DeadMG 所述
  • 编译器优化 + CPU 缓存有时会让人感觉像是记忆效果
  • 在这种情况下,版本 2 总体上会更快
  • 但如果你继续使用越来越大的 n 和小号
  • 达到缓存失效点后,速度会比版本 1 更慢

4.这只是猜测

  • 您的编译器是否可以转换递归
  • 类似于通过列表或范围的单个整数迭代
  • 循环? (这种类型的大多数递归无论如何都可以转换为循环)
  • 这可以解释一切......
  • 没有递归调用开销
  • 没有堆垃圾

[注意]

  • 正如我在上面写的,我不是 haskell 用户,所以请相应地对待这个答案

【讨论】:

  • 感谢您的回答。不管所谓的性能差异的原因是什么(我也没有做过任何适当的基准测试),我认为作者的解释充其量是误导性的。它们似乎暗示优势显然是由于更聪明的算法(您处理较小的素数列表而不是较长的所有整数列表),但完全掩盖了素数列表本身是按需生成的事实通过过滤所有整数的列表。 [继续...]
  • [...follows] 虽然我知道编译器魔法或记忆甚至 CPU 缓存都可能发挥作用,但这并不明显,作者也没有暗示任何这些东西。 悲伤的脸
【解决方案3】:

据我了解,除法运算并不像您对 2 的除数所想的那样昂贵,这使得 allPrimes 过滤掉的数字中有一半要检查“右移 1 位”,即一个计算操作可以很简单,而第一个算法将执行一个相对昂贵的真正除以整个数字。假设可能的除数是 1956,它将通过执行第一个测试几乎免费(右移将返回零 - 可被 2 整除)从 allPrimes 中过滤掉,同时将 2^4253-1 除以 1956 已经毫无意义,因为它不能被 2 整除,并且在非常大的数字的情况下,除法需要很多时间,并且至少有一半(或者说 5/6,对于除数 2 和 3)是无用的。 allPrimes 也是一个缓存列表,因此检查下一个整数是否包含在 allPrimes 中的素数仅使用经过验证的素数,因此即使对于实际素数,素数测试也不是非常昂贵。这种结合赋予了第二种方法的优势。

【讨论】:

    猜你喜欢
    • 1970-01-01
    • 2022-11-27
    • 2014-08-17
    • 1970-01-01
    • 2015-10-29
    • 1970-01-01
    • 2019-10-03
    • 2011-03-10
    • 1970-01-01
    相关资源
    最近更新 更多