【问题标题】:Clojure: Simple factorial causes stack overflowClojure:简单的阶乘导致堆栈溢出
【发布时间】:2023-03-14 12:49:01
【问题描述】:

我做错了什么?几千次调用深度的简单递归会抛出StackOverflowError

如果 Clojure 递归的限制这么低,我怎么能依赖它?

(defn fact[x]
  (if (<= x 1) 1 (* x  (fact (- x 1))  )))

user=> (fact 2)
2

user=> (fact 4)
24

user=> (fact 4000)
java.lang.StackOverflowError (NO_SOURCE_FILE:0)

【问题讨论】:

    标签: recursion clojure stack-overflow


    【解决方案1】:

    据我所知,堆栈大小取决于您使用的 JVM 以及平台。如果您使用的是 Sun JVM,则可以使用 -Xss-XThreadStackSize 参数来设置堆栈大小。

    在 Clojure 中进行递归的首选方法是使用 loop/recur:

    (defn fact [x]
        (loop [n x f 1]
            (if (= n 1)
                f
                (recur (dec n) (* f n)))))
    

    Clojure 会为此进行尾调用优化;这样可以确保您永远不会遇到StackOverflowErrors。

    由于defn implies a loop binding,您可以省略loop 表达式,并将其参数用作函数参数。要使其成为 1 参数函数,请使用 the multiary caracteristic of functions:

    (defn fact
      ([n] (fact n 1))
      ([n f]
      (if (<= n 1)
        f
        (recur (dec n) (* f n)))))
    

    编辑:为了记录,这里有一个 Clojure 函数,它返回所有阶乘的惰性序列:

    (defn factorials []
        (letfn [(factorial-seq [n fact]
                               (lazy-seq
                                 (cons fact (factorial-seq (inc n) (* (inc n) fact)))))]
          (factorial-seq 1 1)))
    
    (take 5 (factorials)) ; will return (1 2 6 24 120)
    

    【讨论】:

    • 这是定义无限阶乘序列的更优雅的方法:(def facts (lazy-cat [1] (map * facts (iterate inc 2))))。然后(take 5 facts) 产生(1 2 6 24 120)
    • @android - 另一种说法(自 Clojure 1.3 起):(def facts (reductions * (iterate inc 1)))
    • that ensures that you’ll never run into StackOverflowErrors 从来没有?真的吗?
    【解决方案2】:

    要添加到 Siddhartha Reddy 的答案,您还可以借用 Factorial 函数形式 Structure And Interpretation of Computer Programs,并进行一些 Clojure 特定的调整。即使对于非常大的阶乘计算,这也给了我很好的性能。

    (defn fac [n]
      ((fn [product counter max-count]
         (if (> counter max-count)
             product
             (recur (apply *' [counter product])
                    (inc counter)
                    max-count)))
       1 1 n))
    

    【讨论】:

      【解决方案3】:

      另一个简单的递归实现可能是这样的:

      (defn fac [x]
          "Returns the factorial of x"
          (if-not (zero? x) (* x (fac (- x 1))) 1))
      

      【讨论】:

        【解决方案4】:

        Clojure 有几种方法破坏递归

      • 带有 recur 的显式尾调用。 (它们必须是真正的尾调用,所以这不起作用)
      • 惰性序列如上所述。
      • trampolining 在这里,您返回一个完成工作的函数,而不是直接执行它,然后调用一个蹦床函数,该函数重复调用其结果,直到它变成一个真正的值而不是一个函数。
      • (defn fact ([x] (trampoline (fact (dec x) x))) 
                   ([x a] (if (<= x 1) a #(fact (dec x) (*' x a)))))
        (fact 42)
        620448401733239439360000N
        

      • memoizing 事实上,这确实可以缩短堆栈深度,尽管它并不普遍适用。

        ps:我没有repl,所以有人可以测试修复trapoline fact 函数吗?

      • 【讨论】:

        • 函数在 REPL 中返回错误,问题是返回的函数不能乘以之前的函数 ClassCastException utility$fact$fn__16548 不能转换为 java.lang.Number clojure.lang。 Numbers.multiply (Numbers.java:146)。我的函数就像@Anon 下面的(defn fact ([x] (fact x nil)) ([ x lastResult] (if (&lt;= x 1) (if (nil? lastResult) 1 lastResult) (if (nil? lastResult) (fact (dec x) x) (fact (dec x) (* lastResult x ))) )) ) Btw trampoline 是用 (trampoline fn arg) 调用的,所以应该删除 (fact 42) 周围的括号
        【解决方案5】:

        这是另一种方式:

        (defn factorial [n]
          (reduce * (range 1 (inc n))))
        

        这不会破坏堆栈,因为range 返回一个惰性序列,而reduce 在没有抓住头部的情况下穿过序列。

        reduce 尽可能使用分块序列,因此这实际上比使用 recur 自己执行得更好。使用基于Siddhartha Reddy'srecur 的版本和基于reduce 的版本:

        user> (time (do (factorial-recur 20000) nil))
        "Elapsed time: 2905.910426 msecs"
        nil
        user> (time (do (factorial-reduce 20000) nil))
        "Elapsed time: 2647.277182 msecs"
        nil
        

        只是略有不同。我喜欢将我的recurring 留给mapreduce 和朋友们,它们更具可读性和明确性,并且在内部使用recur 比我可能手动操作要优雅一点。有时您需要手动 recur,但根据我的经验,这并不多。

        【讨论】:

        • 我完全同意。我认为这是比直接使用循环/递归更好的方法,即使速度差异不存在。我个人只会使用这种方法。我给出循环/递归版本主要是为了演示 Clojure 中的递归。
        • 我喜欢它很好。顺便说一句,也可以是:(defn factorial [n] (apply * (range 1 (inc n))))
        • 在 Clojure 1.3.0 中,我通过编写 (defn factorial [n] (reduce *' (range 1 (inc n)))) 来计算 n > 20,注意 * 旁边的 ' 标记。
        • @MiguelVitorino 但是您的版本负责人是否保留?想象一下你的范围放在另一个函数的参数中。
        【解决方案6】:

        堆栈深度是一个小麻烦(但可配置),但即使在具有尾递归的语言(如 Scheme 或 F#)中,您的代码最终也会耗尽堆栈空间。

        据我所知,即使在透明支持尾递归的环境中,您的代码也不太可能进行尾递归优化。您可能希望查看延续传递样式以最小化堆栈深度。

        这是来自 Wikipedia 的 Scheme 中的一个规范示例,可以毫不费力地翻译成 Clojure、F# 或其他函数式语言:

        (define factorial
          (lambda (n)
              (let fact ([i n] [acc 1])
                (if (zero? i)
                    acc
                    (fact (- i 1) (* acc i))))))
        

        【讨论】:

          【解决方案7】:

          按照 l0st3d 的建议,考虑使用 recurlazy-seq

          另外,尝试通过使用内置序列形式构建序列来使序列变得惰性,而不是直接进行。

          这是使用内置序列形式创建惰性斐波那契序列的示例(来自 Programming Clojure 书):

          (defn fibo []
            (map first (iterate (fn [[a b]] [b (+ a b)]) [0 1])))
          
          => (take 5 (fibo))
          (0 1 1 2 3)
          

          【讨论】:

            【解决方案8】:

            当我即将发布以下内容时,我发现它与 JasonTrue 发布的 Scheme 示例几乎相同......无论如何,这是 Clojure 中的一个实现:

            user=> (defn fact[x]
                    ((fn [n so_far]
                      (if (<= n 1)
                          so_far
                          (recur (dec n) (* so_far n)))) x 1))
            #'user/fact
            user=> (fact 0)
            1
            user=> (fact 1)
            1
            user=> (fact 2)
            2
            user=> (fact 3)
            6
            user=> (fact 4)
            24
            user=> (fact 5)
            120
            

            等等

            【讨论】:

            • 我还不能把它翻译成 Clojure,所以我很感激你可以,即使没有其他人喜欢我的观点,即持续传递风格是真正的解决方案 :)
            • 谢谢。我不是一个 Scheme 程序员,所以我只能代表这个 Clojure 代码,在我看来,这基本上就是你的示例正在做的事情。在此,我没有传递延续函数,而只是(在内部“工作者”函数中)在每次调用时更新的额外累加器值。就我所阅读的内容而言,我对延续传递风格的理解是,所有函数都需要一个额外的延续函数来处理接下来要调用的内容,并且 CPS 需要尾调用优化以避免增加堆栈,而不是解决缺乏尾调用优化。
            【解决方案9】:

            阶乘数本质上非常大。我不确定 Clojure 如何处理这个问题(但我确实看到它适用于 java),但任何不使用大数字的实现都会很快溢出。

            编辑:这没有考虑到您为此使用递归这一事实,这也可能会耗尽资源。

            Edit x2:如果实现使用大数字,据我所知,这通常是数组,再加上递归(每个函数条目一个大数字副本,由于函数调用总是保存在堆栈中)会解释堆栈溢出。尝试在 for 循环中执行此操作,看看是否是问题所在。

            【讨论】:

            • 然后我希望看到类似“IntegerOverflow”,而不是“StackOverflow”
            • 它是 StackOverflow 的原因是因为你的代码本质上是在方法调用中进行方法调用,直到它用完堆栈帧。
            • 另外,为了记录,Clojure 具有任意精度的数值类型。这意味着您永远不会在纯 Clojure 代码中获得 IntegerOverflow。
            • 没有理由将“大整数”存储在堆栈中。也许对它们的引用存储在堆栈中,但我怀疑整个值是。
            • 如果我们使用*' 而不是* 它不会抛出溢出而是动态使用需要的类型。
            猜你喜欢
            • 1970-01-01
            • 2015-12-21
            • 1970-01-01
            • 1970-01-01
            • 1970-01-01
            • 2015-05-21
            • 2014-02-14
            • 1970-01-01
            • 1970-01-01
            相关资源
            最近更新 更多