【问题标题】:Why is my Clojure prime number lazy sequence so slow?为什么我的 Clojure 素数惰性序列这么慢?
【发布时间】:2016-01-18 05:36:59
【问题描述】:

我正在做 Project Euler 的第 7 题(计算第 10001 个素数)。我已经以惰性序列的形式编写了一个解决方案,但它非常慢,而我在网上找到的另一个解决方案(下面的链接)基本上做同样的事情只需要不到一秒钟的时间。

我是 clojure 和惰性序列的新手,所以我对 take-while、lazy-cat rest 或 map 的使用可能是罪魁祸首。请你看看我的代码,如果你看到了什么,请告诉我?

在一秒钟内运行的解决方案在这里: https://zach.se/project-euler-solutions/7/

它不使用惰性序列。我想知道为什么它这么快而我的这么慢(他们遵循的过程是相似的)。

我的解决方案超级慢:

(def primes 
  (letfn [(getnextprime [largestprimesofar]
    (let [primessofar (concat (take-while #(not= largestprimesofar %) primes) [largestprimesofar])]
      (loop [n (+ (last primessofar) 2)]
          (if
            (loop [primessofarnottriedyet (rest primessofar)]
              (if (= 0 (count primessofarnottriedyet))
                true
                (if (= 0 (rem n (first primessofarnottriedyet)))
                  false
                  (recur (rest primessofarnottriedyet)))))
            n
            (recur (+ n 2))))))]
    (lazy-cat '(2 3) (map getnextprime (rest primes)))))

要尝试它,只需加载它并运行类似(取 10000 个素数)之类的东西,但是使用 Ctrl+C 来终止进程,因为它太慢了。但是,如果您尝试(取 100 个素数),您应该会立即得到答案。

【问题讨论】:

  • 10000 比 100 慢 2 个数量级。只是说说而已。
  • 问题是zach.se/project-euler-solutions/7处的解瞬间达到10000。我的函数执行相同数量的除法,并且需要将近一个小时。我的惰性 seq 使用有什么问题吗?
  • 那个代码 sn-p 准确吗?在第一个 loop 之后的 n 本身看起来可能是缺少的 if 语句的“然后”部分。
  • @manutter 你是对的,修正了那个错字,虽然它对函数的工作没有任何影响

标签: clojure


【解决方案1】:

让我稍微重新编写一下您的代码,将其分解成更易于讨论的部分。我正在使用您的相同算法,我只是将一些内部形式拆分为单独的函数。

(declare primes)   ;; declare this up front so we can refer to it below

(defn is-relatively-prime? [n candidates]
  (if (= 0 (count candidates))
    true
    (if (zero? (rem n (first candidates)))
      false
      (is-relatively-prime? n (rest candidates)))))

(defn get-next-prime [largest-prime-so-far]
  (let [primes-so-far (concat (take-while #(not= largest-prime-so-far %) primes) [largest-prime-so-far])]
    (loop [n (+ (last primes-so-far) 2)]
      (if
        (is-relatively-prime? n (rest primes-so-far))
        n
        (recur (+ n 2))))))

(def primes
  (lazy-cat '(2 3) (map get-next-prime (rest primes))))

(time (let [p (doall (take 200 primes))]))

最后一行只是为了更容易在 REPL 中获得一些非常粗略的基准。通过将时序语句作为源文件的一部分,我可以不断重新加载源代码,并且每次都能获得新的基准。如果我只加载文件一次,并继续尝试执行(take 500 primes),则基准将出现偏差,因为primes 将保留它已经计算的素数。我还需要doall,因为我在let 语句中提取素数,如果我不使用doall,它只会将惰性序列存储在p 中,而不是实际计算素数。

现在,让我们获取一些基本值。在我的电脑上,我得到了这个:

Loading src/scratch_clojure/core.clj... done
"Elapsed time: 274.492597 msecs"
Loading src/scratch_clojure/core.clj... done
"Elapsed time: 293.673962 msecs"
Loading src/scratch_clojure/core.clj... done
"Elapsed time: 322.035034 msecs"
Loading src/scratch_clojure/core.clj... done
"Elapsed time: 285.29596 msecs"
Loading src/scratch_clojure/core.clj... done
"Elapsed time: 224.311828 msecs"

所以大约 275 毫秒,给或取 50。我的第一个怀疑是我们如何在 let 中的 let 语句中获得 get-next-prime。我们正在遍历完整的素数列表(据我们所知),直到我们找到一个等于迄今为止最大的素数。然而,按照我们构建代码的方式,所有素数都已经按顺序排列,因此我们有效地遍历除最后一个以外的所有素数,然后连接最后一个值。我们最终得到的值与迄今为止在primes 序列中实现的值完全相同,因此我们可以跳过整个步骤而只使用primes。这应该可以为我们节省一些东西。

我的下一个怀疑是在循环中调用(last primes-so-far)。当我们在一个序列上使用last 函数时,它也会从头到尾遍历列表(或者至少,这是我的理解——我不会把它放在 Clojure 编译器作者那里偷偷加入一些特殊情况的代码来加快速度。)但同样,我们不需要它。我们用largest-prime-so-far 调用get-next-prime,由于我们的素数是有序的,就我们所知,这已经是最后一个素数了,所以我们可以只使用largest-prime-so-far 而不是(last primes)。这会给我们这个:

(defn get-next-prime [largest-prime-so-far]
  ; deleted the let statement since we don't need it
  (loop [n (+ largest-prime-so-far 2)]
    (if
      (is-relatively-prime? n (rest primes))
      n
      (recur (+ n 2)))))

这似乎应该加快速度,因为我们已经消除了两个完整的素数序列遍历。让我们试试吧。

Loading src/scratch_clojure/core.clj... done
"Elapsed time: 242.130691 msecs"
Loading src/scratch_clojure/core.clj... done
"Elapsed time: 223.200787 msecs"
Loading src/scratch_clojure/core.clj... done
"Elapsed time: 287.63579 msecs"
Loading src/scratch_clojure/core.clj... done
"Elapsed time: 244.927825 msecs"
Loading src/scratch_clojure/core.clj... done
"Elapsed time: 274.146199 msecs"

嗯,可能稍微好一点(?),但与我预期的改进相差无几。让我们看一下is-relatively-prime? 的代码(因为我已经重写了它)。我首先想到的是count 函数。 primes 序列是一个序列,而不是一个向量,这意味着count 函数还必须遍历整个列表才能将其中的元素数量相加。更糟糕的是,如果我们从一个包含 10 个候选者的列表开始,它会在第一次循环中遍历所有 10 个候选者,然后在下一个循环中遍历剩下的 9 个候选者,然后是剩下的 8 个,依此类推。随着素数越来越多,我们将在计数函数上花费越来越多的时间,所以这可能是我们的瓶颈。

我们想摆脱count,这表明我们可以使用if-let 以更惯用的方式进行循环。像这样:

(defn is-relatively-prime? [n candidates]
  (if-let [current (first candidates)]
    (if (zero? (rem n current))
      false
      (recur n (rest candidates)))
    true))

如果候选列表为空,(first candidates) 函数将返回 nil,如果发生这种情况,if-let 函数会注意到,并自动跳转到 else 子句,在这种情况下,这就是我们的返回结果“真的。”否则,我们将执行“then”子句,并可以测试n 是否可以被当前候选人整除。如果是,我们返回 false,否则我们与其余的候选者一起返回。我还利用了zero? 功能,因为我可以。让我们看看这会给我们带来什么。

Loading src/scratch_clojure/core.clj... done
"Elapsed time: 9.981985 msecs"
Loading src/scratch_clojure/core.clj... done
"Elapsed time: 8.011646 msecs"
Loading src/scratch_clojure/core.clj... done
"Elapsed time: 8.154197 msecs"
Loading src/scratch_clojure/core.clj... done
"Elapsed time: 9.905292 msecs"
Loading src/scratch_clojure/core.clj... done
"Elapsed time: 8.215208 msecs"

相当戏剧化,是吗?我是一名中级 Clojure 编码器,对内部结构有相当粗略的了解,所以请对我的分析持保留态度,但根据这些数字,我猜你被 count 咬了。

“快速”代码还使用了另一种优化,而您的代码没有使用,只要current 的平方大于n,它就会在is-relatively-prime? 测试中退出——您可能会加快您的代码如果你能把它放进去,可以再多一些。但我认为count 是你要寻找的主要内容。

【讨论】:

  • 顺便说一句,我使用了 200 个素数,所以时间足够长到几百毫秒,但足够短,我不需要一个小时来为每次试验计时。对于 10000 个素数,我的结果大约是 11 秒。仍然不如“快速”素数,但比原来快得多。
  • 通过对current^2 > n 的测试,我得到 10,000 个素数的经过时间为 255 毫秒,非常好。
  • 非常感谢!不过,我有一个问题,在您第一次重写时,当您将(剩余素数)传递给 is-relatively-prime 的候选参数时?然后调用 count ,为什么不 count 尝试实现整个惰性序列(并且卡在过程中,因为它是无限的)?我确实注意到 count 将自身限制为素数惰性序列的已实现元素,但这是为什么呢?根据这个线程,count总是尝试完整地实现一个惰性序列:stackoverflow.com/questions/18728916/…
  • 你可以加快速度。首先:(concat (take-while #(not= largest-prime-so-far %) primes) [largest-prime-so-far]) 显然等于:(take-while #(>= largest-prime-so-far %) primes),因此您可以节省删除 concat。也在这里:(loop [n (+ (last primes-so-far) 2)](last primes-so-far) 真的是largest-prime-so-far,所以如果你替换它,你不会每次都迭代整个素数序列。这两个对我来说几乎是 10000 个素数的生产力的三倍。
  • 哦!才发现!! (concat (take-while #(not= largest-prime-so-far %) primes) [largest-prime-so-far]) -- 真的只是到目前为止所有的素数!所以你可以用primes 完全替换它,大大加快了它的速度!对我来说,原始@manutter 的解决方案几乎需要 14 秒到 3.5 秒。
【解决方案2】:

根据@manutter 的解决方案,我将继续加快速度。

(declare primes)

(defn is-relatively-prime? [n candidates]
  (if-let [current (first candidates)]
    (if (zero? (rem n current))
      false
      (recur n (rest candidates)))
    true))

(defn get-next-prime [largest-prime-so-far]
  (let [primes-so-far (concat (take-while #(not= largest-prime-so-far %) primes) [largest-prime-so-far])]
    (loop [n (+ (last primes-so-far) 2)]
      (if
        (is-relatively-prime? n (rest primes-so-far))
        n
        (recur (+ n 2))))))

(def primes
  (lazy-cat '(2 3) (map get-next-prime (rest primes))))

(time (first (drop 10000 primes)))

“经过的时间:14092.414513 毫秒”

好的。首先让我们添加这个current^2 > n优化:

(defn get-next-prime [largest-prime-so-far]
  (let [primes-so-far (concat (take-while #(not= largest-prime-so-far %) primes) [largest-prime-so-far])]
    (loop [n (+ (last primes-so-far) 2)]
      (if
          (is-relatively-prime? n
                                (take-while #(<= (* % %) n)
                                            (rest primes-so-far)))
        n
        (recur (+ n 2))))))

user> (time (first (drop 10000 primes)))
"Elapsed time: 10564.470626 msecs"
104743

很好。现在让我们仔细看看get-next-prime

如果你仔细检查算法,你会注意到: (concat (take-while #(not= largest-prime-so-far %) primes) [largest-prime-so-far]) 真的等于我们迄今为止发现的所有素数,(last primes-so-far) 真的是 largest-prime-so-far。所以让我们稍微重写一下:

(defn get-next-prime [largest-prime-so-far]
  (loop [n (+ largest-prime-so-far 2)]
    (if (is-relatively-prime? n
          (take-while #(<= (* % %) n) (rest primes)))
      n
      (recur (+ n 2)))))

user> (time (first (drop 10000 primes)))
"Elapsed time: 142.676634 msecs"
104743

让我们再增加一个数量级:

user> (time (first (drop 100000 primes)))
"Elapsed time: 2615.910723 msecs"
1299721

哇!真是令人兴奋!

但这还不是全部。我们来看看is-relatively-prime函数: 它只是检查没有候选人平均分配数字。所以这确实是not-any? 库函数的作用。因此,让我们将其替换为 get-next-prime

(declare primes)

(defn get-next-prime [largest-prime-so-far]
  (loop [n (+ largest-prime-so-far 2)]
    (if (not-any? #(zero? (rem n %))
                  (take-while #(<= (* % %) n)
                              (rest primes)))
      n
      (recur (+ n 2)))))

(def primes
  (lazy-cat '(2 3) (map get-next-prime (rest primes))))

效率更高

user> (time (first (drop 100000 primes)))
"Elapsed time: 2493.291323 msecs"
1299721

而且明显更简洁更短。

【讨论】:

    猜你喜欢
    • 1970-01-01
    • 2010-12-08
    • 2023-03-20
    • 2011-02-26
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多