【问题标题】:What can I do to speed up this code?我能做些什么来加快这段代码的速度?
【发布时间】:2013-01-18 01:29:55
【问题描述】:

我正在尝试学习 Java、Scala 和 Clojure。

我正在用三种语言解决 Project Euler 问题。下面列出了问题 #5 (http://projecteuler.net/problem=5) 的代码以及到目前为止前五个问题的运行时间(以秒为单位)。令我吃惊的是,对于问题 #5,Java 和 Clojure 版本比 Scala 版本慢得多。它们在同一台机器、同一 jvm 上运行,并且多次试验的结果是一致的。我能做些什么来加快这两个速度(尤其是 Clojure 版本)?为什么 Scala 版本的速度这么快?

运行时间(以秒为单位)

|---------|--------|--------|----------|
| problem | Java   | Scala  | Clojure  |
|=========|========|========|==========|
|    1    |  .0010 |  .1570 |   .0116  |
|    2    |  .0120 |  .0030 |   .0003  |
|    3    |  .0530 |  .0200 |   .1511  |
|    4    |  .2120 |  .2600 |   .8387  |
|    5    | 3.9680 |  .3020 | 33.8574  |

问题 #5 的 Java 版本

public class Problem005 {

  private static ArrayList<Integer> divisors;

  private static void initializeDivisors(int ceiling) {
    divisors = new ArrayList<Integer>();
    for (Integer i = 1; i <= ceiling; i++)
      divisors.add(i);
  }

  private static boolean isDivisibleByAll(int n) {
    for (int divisor : divisors)
      if (n % divisor != 0)
        return false;
    return true;
  }

  public static int findSmallestMultiple (int ceiling) {
    initializeDivisors(ceiling);
    int number = 1;
    while (!isDivisibleByAll(number))
      number++;
    return number;
  }

}

问题 #5 的 Scala 版本

object Problem005 {
  private def isDivisibleByAll(n: Int, top: Int): Boolean = 
    (1 to top).forall(n % _ == 0)

  def findSmallestMultiple(ceiling: Int): Int = {
    def iter(n: Int): Int = if (isDivisibleByAll(n, ceiling)) n else iter(n+1)
    iter(1)
  }

}

问题 #5 的 Clojure 版本

(defn smallest-multiple-of-1-to-n
  [n]
  (loop [divisors (range 2 (inc n))
        i n]
    (if (every? #(= 0 (mod i %)) divisors)
      i
      (recur divisors (inc i)))))

编辑

有人建议我将各种答案编译成我自己的答案。但是,我想在应得的地方给予赞扬(我自己真的没有回答这个问题)。

关于第一个问题,所有三个版本都可以通过使用更好的算法来加速。具体来说,创建一个数字 1-20 的最大公因数列表(2^4、3^2、5^1、7^1、11^1、13^1、17^1、19^1)和将它们相乘。

更有趣的方面是使用基本相同的算法来理解三种语言之间的差异。在某些情况下,诸如此类的蛮力算法可能会有所帮助。那么,为什么会有性能差异呢?

对于 Java,一个建议是将 ArrayList 更改为原始整数数组。这确实减少了运行时间,减少了大约 0.5 - 1 秒(我今天早上才运行它,它把运行时间从 4.386 秒减少到 3.577 秒。这减少了一点,但没有人能想出一个将其降低到半秒以下(类似于 Scala 版本)的方法。考虑到所有三个都编译为 java 字节码,这令人惊讶。@didierc 建议使用不可变迭代器;我测试了这个建议,并将运行时间增加到刚刚超过 5 秒。

对于 Clojure,@mikera 和 @Webb 提供了一些加快速度的建议。他们建议使用 loop/recur 进行带有两个循环变量的快速迭代,使用 unchecked-math 进行稍快的数学运算(因为我们知道这里没有溢出的危险),使用原始 long 而不是装箱数,并避免使用更高阶的函数,例如每个?

运行@mikera的代码,我最终得到了2.453秒的运行时间,不如scala代码,但比我原来的版本好很多,也比Java版本好:

(set! *unchecked-math* true)

(defn euler5 
  []
  (loop [n 1 
         d 2]
    (if (== 0 (unchecked-remainder-int n d))
      (if (>= d 20) n (recur n (inc d)))
      (recur (inc n) 2))))

(defn is-divisible-by-all?
  [number divisors]
  (= 0 (reduce + (map #(mod 2 %) divisors))))

对于 Scala,@didierc 声明范围对象 1 到 20 实际上不是对象列表,而是一个对象。很酷。因此,Scala 的性能差异在于我们迭代单个对象而不是整数 1-20 的列表/数组。

实际上,如果我将 scala 方法中的 helper 函数从范围对象更改为列表(见下文),那么 scala 版本的运行时间从 0.302 秒增加到 226.59 秒。

private def isDivisibleByAll2(n: Int, top: Int): Boolean = {
    def divisors: List[Int] = List(1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20)
    divisors.forall(n % _ == 0)
  }

因此,@didierc 似乎正确识别了 scala 在这种情况下的优势。知道如何在 java 和 clojure 中实现这种类型的对象会很有趣。

@didierc 建议通过创建一个 ImmutableRange 类来改进代码,如下:

import java.util.Iterator;
import java.lang.Iterable;

public class ImmutableRange implements Iterable<Integer> {

  class ImmutableRangeIterator implements Iterator<Integer> {
    private int counter, end, step;

    public ImmutableRangeIterator(int start_, int end_, int step_) {
      end = end_;
      step = step_;
      counter = start_;
    }

    public boolean hasNext(){
      if (step>0) return counter  <= end;
      else return counter >= end;
    }

    public Integer next(){
      int r = counter;
      counter+=step;
      return r;
    }

    public void remove(){
      throw new UnsupportedOperationException();
    }

  }

  private int start, end, step;

  public ImmutableRange(int start_, int end_, int step_){
    // fix-me: properly check for parameters consistency
    start = start_;
    end = end_;
    step = step_;
  }

  public Iterator<Integer> iterator(){
    return new ImmutableRangeIterator(start,end,step);
  }
}

没有提高运行时间。 java 版本在我的机器上运行了 5.097 秒。因此,最后,对于为什么 Scala 版本性能更好,我们有一个令人满意的答案,我们了解如何提高 Clojure 版本的性能,但缺少的是了解如何在 Java 中实现 Scala 的不可变范围对象.

最后的想法

正如一些人所评论的,提高此代码运行时间的最有效方法是使用更好的算法。例如,以下 java 代码使用 Sieve of EratosthenesTrial Division 在不到 1 毫秒的时间内计算出答案:

/**
 * Smallest Multiple
 *
 * 2520 is the smallest number that can be divided by each of the numbers 
 * from 1 to 10 without any remainder. What is the smallest positive number
 * that is evenly divisible by all of the numbers from 1 to 20?
 *
 * User: Alexandros Bantis
 * Date: 1/29/13
 * Time: 7:06 PM
 */
public class Problem005 {

  final private static int CROSSED_OUT = 0;
  final private static int NOT_CROSSED_OUT = 1;

  private static int intPow(int base, int exponent) {
    int value = 1;
    for (int i = 0; i < exponent; i++)
      value *= base;
    return value;
  }

  /**
   * primesTo computes all primes numbers up to n using trial by 
   * division algorithm
   *
   * @param n designates primes should be in the range 2 ... n
   * @return int[] a sieve of all prime factors 
   *              (0=CROSSED_OUT, 1=NOT_CROSSED_OUT)
   */
  private static int[] primesTo(int n) {
    int ceiling = (int) Math.sqrt(n * 1.0) + 1;
    int[] sieve = new int[n+1];

    // set default values
    for (int i = 2; i <= n; i++)
      sieve[i] = NOT_CROSSED_OUT;

    // cross out sieve values
    for (int i = 2; i <= ceiling; i++)
      for (int j = 2; i*j <= n; j++)
        sieve[i*j] = CROSSED_OUT;
    return sieve;
  }


  /**
   * getPrimeExp computes a prime factorization of n
   *
   * @param n the number subject to prime factorization
   * @return int[] an array of exponents for prime factors of n
   *               thus 8 => (0^0, 1^0, 2^3, 3^0, 4^0, 5^0, 6^0, 7^0, 8^0)
   */
  public static int[] getPrimeExp(int n) {
    int[] factor = primesTo(n);
    int[] primePowAll = new int[n+1];

    // set prime_factor_exponent for all factor/exponent pairs
    for (int i = 2; i <= n; i++) {
      if (factor[i] != CROSSED_OUT) {
        while (true) {
          if (n % i == 0) {
          n /= i;
          primePowAll[i] += 1;
          } else {
            break;
          }
        }
      }
    }

    return primePowAll;
  }

  /**
   * findSmallestMultiple computes the smallest number evenly divisible 
   * by all numbers 1 to n
   *
   * @param n the top of the range
   * @return int evenly divisible by all numbers 1 to n
   */
  public static int findSmallestMultiple(int n) {
    int[] gcfAll = new int[n+1];

    // populate greatest common factor arrays
    int[] gcfThis = null;
    for (int i = 2; i <= n; i++) {
      gcfThis = getPrimeExp(i);
      for (int j = 2; j <= i; j++) {
        if (gcfThis[j] > 0 && gcfThis[j] > gcfAll[j]) {
          gcfAll[j] = gcfThis[j];
        }
      }
    }

    // multiply out gcf arrays
    int value = 1;
    for (int i = 2; i <= n; i++) {
      if (gcfAll[i] > 0)
        value *= intPow(i, gcfAll[i]);
    }
    return value;
  }
}

【问题讨论】:

  • 您可以通过将 ArrayList 除数更改为 int[] 来获得小幅加速 - 您知道一开始的大小并且不需要自动装箱。
  • 你能提供你用来做基准测试的代码吗?
  • 尝试用 isDivisibleByAll 中的循环替换 Java 版本中的 divisors。这更像是 Scala 版本中发生的情况。此外,通过将 isDivisibleByAll 重写为 while 循环或尾递归函数,Scala 版本可能仍然更快。
  • @Alex 我想你已经有了所有的解释,但散布着每种语言的各种答案(例如,mikera 本着幼稚版本的精神拥有最快的 Clojure)。也许您可以自己创建一个摘要答案并接受它。

标签: java scala clojure


【解决方案1】:

这是 Clojure 中更快的版本:

(set! *unchecked-math* true)

(defn euler5 []
  (loop [n 1 
         d 2)]
    (if (== 0 (unchecked-remainder-int n d))
      (if (>= d 20) n (recur n (inc d)))
      (recur (inc n) 2))))

(time (euler5))
=> "Elapsed time: 2438.761237 msecs"

即它的速度与您的 Java 版本差不多。

关键技巧是:

  • 使用loop/recur 进行具有两个循环变量的快速迭代
  • 使用unchecked-math 稍微加快数学运算(因为我们知道这里没有溢出的危险)
  • 使用原始长整数而不是盒装数字
  • 避免使用像 every? 这样的高阶函数 - 它们的开销比低级操作更高

显然,如果您真的关心速度,您会选择更好的算法:-)

【讨论】:

  • 这是迄今为止最好的 Clojure 答案。不需要长演员表,我删除了那些。
【解决方案2】:

Scala 更快,因为其他解决方案会无缘无故地创建显式集合。在 Scala 中,1 to top 创建一个表示从1top 的数字的对象,但没有在任何地方明确列出它们。在 Java 中,您确实会显式创建列表——而且每次迭代创建一个对象比创建一个包含 20 个(实际上是 21 个对象,因为 ArrayList 也是一个对象)的数组要快得多。

(请注意,实际上没有一个版本接近最优。请参阅“最小公倍数”,Eastsun 正在做的事情没有提及。)

【讨论】:

  • +1 直接回答“为什么 Scala 版本这么快?”
【解决方案3】:

我注意到在 Java 版本中可能会对速度产生一些影响的第一件事是,您正在创建 ArrayList&lt;Integer&gt; 而不是 int[]

Java 从版本 5 开始有一个功能,可以在 Integerint 之间自动转换 - 您正在遍历此列表,在比较和数学计算中将它们视为 int 类型,这会迫使 Java在这两种类型之间进行大量转换。将 ArrayList&lt;Integer&gt; 替换为 int[] 可能会对性能产生一些影响。

查看您的时间安排时,我的第一反应是验证所有的结果是否正确。我假设你已经正确地测试了所有三个,以确保更快的 Scala 版本确实给你正确的结果。

这似乎与解决它的算法的选择无关,因为这三个策略看起来都一样(我不熟悉 Clojure 或 Scala,所以我可能会遗漏一些细微的差别)。也许 Scala 能够在内部优化这个特定的循环/算法,从而产生更快的结果?

【讨论】:

  • 这些都可能是不错的选择,但是,他的算法太糟糕了,尝试优化相同的算法就像尝试用牙签划船更快。
  • @DhaivatPandya 承认算法很差,但是所有三个版本的运行应该同样差。我的问题是要理解为什么 Java 和 Clojure 版本使用类似(尽管很糟糕)的算法运行得这么慢。
【解决方案4】:

在我非常慢的计算机上,Clojure 代码需要将近 10 分钟,所以我在老忠实用户上的运行速度慢了大约 20 倍。

user=> (time (smallest-multiple-of-1-to-n 20))
"Elapsed time: 561420.259 msecs"
232792560

您可以通过避免惰性、使用类型提示/原语/未经检查的操作等,使相同的算法与其他算法更具可比性。Clojure 代码为匿名函数装箱并创建/实现惰性序列rangeloop 的每次迭代。这种开销通常可以忽略不计,但在这里它被循环了数亿次。以下非惯用代码提供了 3 倍的加速。

(defn smallest-multiple-of-1-to-n [n]
  (loop [c (int n)] 
    (if 
      (loop [x (int 2)]
        (cond (pos? (unchecked-remainder-int c x)) false
              (>= x n) true
              :else (recur (inc x))))
      c (recur (inc c)))))

user=> (time (smallest-multiple-of-1-to-n 20))
"Elapsed time: 171921.80347 msecs"
232792560

您可以继续对此进行修改,并且可能会更接近,但最好通过算法进行思考,而不是从 20 到 2 亿迭代做得更好。

(defn gcd [a b]
  (if (zero? b) a (recur b (mod a b))))

(defn lcm 
  ([a b] (* b (quot a (gcd a b))))
  ([a b & r] (reduce lcm (lcm a b) r)))

user=> (time (apply lcm (range 2 21)))
"Elapsed time: 0.268749 msecs"
232792560

因此,即使在我的古老机器上,这也比在您的快速机器上实现您的算法快 1000 倍以上。我注意到也为 Scala 发布了一个 gcd/lcm 折叠解决方案。因此,比较这些相似算法的速度会很有趣。

【讨论】:

  • @Webb 是的,你完全正确!我当然想测试更有效的算法(而不是单独检查所有 20 个数字,我可以只检查 1-20 的 gcf)。尽管如此,让我吃惊的是,Scala 对这种算法的反应似乎与其他两种截然不同。
  • @Webb 如何向 clojure 函数添加类型提示?
  • @Alex 查看 clojure.org/java_interop 的类型提示和原语部分——可能是也可能不是这里的瓶颈,但对于好奇的人来说值得一试。另请参阅我的编辑,其中我将您的算法重新编码为使用两个循环。这甚至更不习惯,但在内部循环中更快。
【解决方案5】:

按照你的算法,clojure 比 java 版本慢大约 10 倍。

clojure 版本要快一点: 46555ms => 23846ms

(defn smallest-multiple-of-1-to-n
 [n]
  (let [divisors (range 2 (inc n))]
   (loop [i n]
     (if (loop [d 2]
        (cond (> d n) true
              (not= 0 (mod i d)) false
              :else (recur (inc d))))
     i
     (recur (inc i))))))

Java 版本快一点:3248ms => 2757ms

private static int[] divisors;

private static void initializeDivisors(int ceiling) {
    divisors = new int[ceiling];

    for (Integer i = 1; i <= ceiling; i++)
        divisors[i - 1] = i;
}

【讨论】:

    【解决方案6】:

    首先,如果一个数可以被 4 整除,那么它也可以被 2(4 的因数之一)整除。

    所以,从 1 到 20,您只需要检查部分数字,而不是全部。

    其次,如果您可以对数字进行质因数分解,这只是要求您提供最小公乘数(这是解决此问题的另一种方法)。事实上,你可以用笔和纸来做,因为它只有 1-20。

    您正在使用的算法相当幼稚 - 它没有充分利用问题为您提供的信息。

    【讨论】:

    • -1 我不认为 OP 要求改进他的算法,而是要求三种语言之间的差异。编辑:他实际上是在对另一个答案的评论中准确地说明了这一点。
    • 有道理。对此感到抱歉。
    【解决方案7】:

    这是 scala 中更有效的解决方案:

    def smallestMultipe(n: Int): Int = {
      @scala.annotation.tailrec
      def gcd(x: Int, y: Int): Int = if(x == 0) y else gcd(y%x, x)
      (1 to n).foldLeft(1){ (x,y) => x/gcd(x,y)*y }
    }
    

    我怀疑为什么您的问题 1 的 scala 版本如此低效。 以下是 Scala 中问题 1 的两种可能解决方案:

    一个简短的:

    (1 until 1000) filter (n => n%3 == 0 || n%5 == 0) sum
    

    更高效的一个:

    (1 until 1000).foldLeft(0){ (r,n) => if(n%3==0||n%5==0) r+n else r }
    

    【讨论】:

      【解决方案8】:

      问题不在于装箱、惰性、列表、向量等。问题在于算法。解决办法当然是“蛮力”,不过是“蛮力”占“蛮力”的比例。

      首先,在欧拉问题 5 中,我们没有被要求检查 1 到 n 的整除性:只有 1 到 20。也就是说:第二,解必须是 38 的倍数。第三,必须首先检查素数,并且必须按降序检查所有除数,以便尽快失败。第四,一些除数也保证了其他除数,即如果一个数能被 18 整除,那么它也能被 9、6 和 3 整除。最后,所有数都能被 1 整除。

      Clojure 中的这个解决方案在 MacBook Pro i7 上的运行时间可以忽略不计,只需 410 毫秒:

      ;Euler 5 helper
      (defn divisible-by-all [n]
        (let [divisors [19 17 13 11 20 18 16 15 14 12]
              maxidx (dec (count divisors))]
        (loop [idx 0]
           (let [result (zero? (mod n (nth divisors idx)))]
             (cond
                (and (= idx maxidx) (true? result)) true
                (false? result) false
                :else (recur (inc idx)))))))
      
      ;Euler 5 solution
      (defn min-divisible-by-one-to-twenty []
        (loop[ x 38 ] ;this one can be set MUCH MUCH higher...
          (let [result (divisible-by-all x)]
             (if (true? result) x (recur (+ x 38))))))
      
      user=>(time (min-divisible-by-one-to-twenty))
      "Elapsed time: 410.06 msecs"
      

      【讨论】:

      • @A.Webb 您的代码在我的 Mac 上运行 0.13。没有你的那么慢……只有 2 倍。
      • 能够对算法进行元编程该有多酷。
      • @WojciechWinogrodzki 是的,好点,很难概括相对性能。我的旧机器很慢,因为它是单核和资源受限的,这对于高效算法来说都不是什么大问题。
      【解决方案9】:

      我相信这是您可以针对该问题和幼稚算法编写的最快的纯 Java 代码。它比 Scala 更快。

      public class Euler5 {
        public static void main(String[] args) {
          int test = 2520;
          int i;
          again: while (true) {
            test++;
            for (i = 20; i >1; i--) {
              if (test % i != 0)
                continue again;
            }
            break;
          }
          System.out.println(test);
        }
      }
      

      几个小细节:

      1. 我们可以在 2520 开始测试,因为问题提到它是一个值 :)
      2. 在我看来,我们在范围顶部比在底部失败得更快 - 我的意思是,有多少东西可以被 19 整除而不是 3?
      3. 我为 continue 语句使用了一个标签。这基本上是一种廉价的综合方法,既可以重置 for 循环,又可以增加我们的测试用例。

      【讨论】:

        猜你喜欢
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        • 2022-12-05
        • 2013-04-19
        • 1970-01-01
        • 2011-01-29
        • 1970-01-01
        相关资源
        最近更新 更多