为什么Haskell中的因子计算要比Java中快得多

时间:2013-07-11 03:34:50

标签: java haskell factorial

我遇到的一个编程问题涉及计算大数(最多10 ^ 5的数字)的阶乘。我见过一个简单的Haskell代码,就像这样

factorial :: (Eq x, Num x) => x -> x
factorial 0 = 1
factorial a = a * factorial (a - 1)

隐式处理大数字,并且即使没有代码中涉及的任何缓存,也会以某种方式运行得更快。

当我尝试使用Java解决问题时,我不得不使用BigInteger来保存大数字并使用因子的迭代版本

public static BigInteger factorialIterative(int n)
{
        if(n == 0 || n == 1) return BigInteger.valueOf(1);
        BigInteger f = BigInteger.valueOf(1);
        for(int i = 1 ; i <= n ;i++)
            f = f.multiply(BigInteger.valueOf(i));
        return f;
}

上述代码超出了程序执行的设定时间限制。我也试过了factorial

的缓存递归版本
public static BigInteger factorial(int n)
{
     if(cache[n] != null) 
         return cache[n];
     else if(n == 0) 
         return new BigInteger("1");
     else {
         cache[n] = n* factorial(n - 1);
         return cache[n]; 
     }
}          

给了我一个内存不足的错误(可能是由于递归)。

我的问题是,为什么像Haskell这样的函数式编程语言能更好地处理涉及大量问题的这类问题? (尽管没有明显的缓存)。有没有办法让java代码像Haskell代码一样快速运行?

5 个答案:

答案 0 :(得分:32)

正如shachaf所说的那样,差异在于GHC(默认情况下)使用GMP进行超出Integer范围的Int次计算,并且GMP得到了相当好的优化。它与纯度,缓存,尾调优化等无关。

Java的BigInteger或多或少地使用了天真的教科书算法。如果你看一下multiply(openjdk7)的代码,那么工作马就是

/**
 * Multiplies int arrays x and y to the specified lengths and places
 * the result into z. There will be no leading zeros in the resultant array.
 */
private int[] multiplyToLen(int[] x, int xlen, int[] y, int ylen, int[] z) {
    int xstart = xlen - 1;
    int ystart = ylen - 1;

    if (z == null || z.length < (xlen+ ylen))
        z = new int[xlen+ylen];

    long carry = 0;
    for (int j=ystart, k=ystart+1+xstart; j>=0; j--, k--) {
        long product = (y[j] & LONG_MASK) *
                       (x[xstart] & LONG_MASK) + carry;
        z[k] = (int)product;
        carry = product >>> 32;
    }
    z[xstart] = (int)carry;

    for (int i = xstart-1; i >= 0; i--) {
        carry = 0;
        for (int j=ystart, k=ystart+1+i; j>=0; j--, k--) {
            long product = (y[j] & LONG_MASK) *
                           (x[i] & LONG_MASK) +
                           (z[k] & LONG_MASK) + carry;
            z[k] = (int)product;
            carry = product >>> 32;
        }
        z[i] = (int)carry;
    }
    return z;
}

二次逐位乘法(数字当然不是基数10)。这并没有太大的影响,因为其中一个因素总是单位数,但表明还没有太多的工作用于优化Java中的BigInteger计算。

从源代码可以看出,在smallNumber * largeNumber形式的Java产品中,largeNumber * smallNumber的速度快于f = f.multiply(BigInteger.valueOf(i)); (特别是如果小数字是单位数,那么将其作为第一个number表示嵌套循环的第二个循环根本不运行,所以你总是有更少的循环控制开销,并且运行的循环有一个更简单的主体。)

如此改变

f = BigInteger.valueOf(i).multiply(f);

在Java版本中

BigInteger

给出了相当大的加速(随着参数的增加,~2×为25000,~2.5×为50000,~2.8×为100000)。

在我的盒子测试范围内,计算仍比GHC / GMP组合慢得多,大约为4倍,但是,GMP的实现更加优化。

如果你进行通常乘以两个大数的计算,那么当因子足够大时(FFT表示非常大的数字),二次String乘法与使用Karatsuba或Toom-Cook的GMP之间的算法差异将显示

但是,如果不是你所做的全部,那么如果你打印出阶乘,因此将它们转换为BigInteger,就会受到toString的{​​{1}}这一事实的影响。 }方法很慢(它大致是二次方的,所以因为阶乘的计算在结果的长度上完全是二次的,所以你的算法复杂度没有那么高,但你得到一个常数在计算之上的因素)。 Show的{​​{1}}实例要好得多,Integer [不确定O(n * (log n)^x)是什么,介于1和2之间],因此将结果转换为x会增加只是计算时间的一小部分。

答案 1 :(得分:10)

我首先要指出两个明显速度差异的因素,但在问题和答案中已经提到了。

没有缓存/记忆

这个问题提到了缓存,一些答案提到了memoization。但是因子函数不会从memoization中受益,因为它以不同的参数递归调用自身。因此,我们永远不会在已经填充的缓存中找到一个条目,并且整个缓存是不必要的。也许人们在想这里的斐波那契函数?

为了记录,Haskell无论如何也不会提供自动记忆。

没有其他聪明的优化

Java和Haskell程序看起来都非常适合我。两个程序都使用各自语言选择的迭代机制:Java使用循环,Haskell使用递归。两个程序都使用标准类型进行大整数运算。

如果有的话,Haskell版本应该更慢因为它不是尾递归的,而Java版本使用的循环是Java中可用的最快的循环结构。

我没有看到编译器可以对这些程序进行巧妙的高级优化的余地。我怀疑观察到的速度差异是由于关于如何实现大整数的低级细节造成的。

那么为什么Haskell版本会更快?

Haskell编译器内置并合理支持Integer。对于Java实现和大整数类,这似乎不那么重要。我用google搜索“BigInteger slow”,结果表明问题确实应该是:为什么Java的BigInteger这么慢?似乎还有其他更大的整数类更快。我不是Java专家,所以我不能详细回答这个问题的变体。

答案 2 :(得分:8)

以下是相关问题:https://softwareengineering.stackexchange.com/q/149167/26988

在这种特殊情况下,您似乎看到了纯粹与不纯函数的优化差异。在Haskell中,除非他们正在执行IO(参见链接),否则所有函数都是纯函数。

我认为正在发生的事情是GHC能够更好地优化代码,因为它保证了纯度。即使没有尾调用,它知道没有任何副作用(因为纯度保证),所以它可以做一些优化Java代码不能(如自动缓存和诸如@andrew之类的东西)在他的回答中提及)

Haskell中更好的解决方案是使用内置的产品功能:

factorial n = product [1..n]

这可以进行尾调用优化,因为它只是迭代。使用for循环可以在Java中完成相同的操作,但是它不具有功能纯粹的好处。

修改

我假设尾部呼叫消除正在发生,但显然不是。这是最初的答案供参考(它仍然有关于为什么Haskell在某些递归上下文中可能比Java更快的有用信息)。

像Haskell这样的函数式编程语言有利于消除尾部调用。

在大多数编程语言中,递归调用维护调用堆栈。每个递归函数都会分配一个新的堆栈,在它返回之前不会被清理。例如:

call fact()
    call fact()
        call fact()
        cleanup
    cleanup
cleanup

但是,功能语言不需要维护堆栈。在过程语言中,通常很难判断caling函数是否会使用返回值,因此很难进行优化。但是,在FP中,返回值仅在递归完成时才有意义,因此您可以消除调用堆栈并最终得到如下内容:

call fact()
call fact()
call fact()
cleanup

call fact()行都可以在同一堆栈帧中发生,因为在中间计算中不需要返回值。

现在,要回答您的问题,您可以通过各种方式解决此问题,所有这些方法都旨在消除调用堆栈:

  • 使用for循环而不是递归(通常是最佳选项)
  • 返回void并希望编译器执行尾调用消除
  • 使用trampoline function(类似于for-loop的想法,但看起来更像是递归)

以下是一些相关问题以及上述示例:

注意:

无法保证递归调用将重用相同的堆栈帧,因此某些实现可能会在每次递归调用时重新分配。这通常更容易,并且仍然提供与重用堆栈帧相同的内存安全性。

有关此内容的详细信息,请参阅以下文章:

答案 3 :(得分:4)

我认为这种差异与尾部调用优化或根本没有优化无关。我认为这样做的原因是,优化最好只能实现与迭代Java版本类似的东西。

真正的原因是,恕我直言,Java BigIntegers与Haskell相比速度较慢。

为了确定这一点,我提出了两个实验:

  1. 使用相同的算法,但使用long。 (对于更高的数字,结果将是一些垃圾,但计算仍然会完成。)这里,Java版本应与Haskell相提并论。

  2. 在java版本中使用更快的大整数库。表现应该相应提高。有GMP的包装器,以及像here这样的java大整数的改进。对于大数字乘法而言,可能存在的多重性能影响很明显。

答案 4 :(得分:1)

以下解释显然是不够的。这里有一些幻灯片解释了函数在参数严格时所经历的转换(如上例所示),并且没有生成任何thunk: http://www.slideshare.net/ilyasergey/static-analyses-and-code-optimizations-in-glasgow-haskell-compiler

Haskell版本将只进行计算,仅存储先前的计算并应用下一个计算,例如6 x 4.而Java版本正在进行缓存(所有历史值),内存管理,GC等

它正在进行严格性分析,它会自动缓存先前的计算。看到: http://neilmitchell.blogspot.com.au/2008/03/lazy-evaluation-strict-vs-speculative.html?m=1

有关Haskell Wiki的更多详细信息: “优化像GHC这样的编译器尝试使用严格性分析来降低懒惰的成本,严格性分析试图确定哪些函数参数总是由函数评估,因此可以由调用者进行评估。”

“严格性分析可以发现参数n是严格的,并且可以表示为未装箱。生成的函数在运行时不会使用任何堆,正如您所期望的那样。”

“严格性分析是GHC在编译时尝试确定哪些数据肯定会”总是需要“的过程。然后GHC可以构建代码来计算这样的数据,而不是正常的(更高的开销)存储计算并稍后执行的过程。“

http://www.haskell.org/haskellwiki/Performance/Strictness http://www.haskell.org/haskellwiki/GHC_optimisations