【问题标题】:StackOverflowError caused by memoized Fibonacci on Clojure由 Clojure 上的记忆斐波那契引起的 StackOverflowError
【发布时间】:2022-01-22 05:56:55
【问题描述】:

配置

clojure 1.10.3openjdk 17.0.1

下测试

问题

下面是memoized Fibonacci的小改版,一般技巧参考 维基memoization.

(def fib
  (memoize #(condp = %
              0 (bigdec 0)
              1 1
              (+ (fib (dec %)) (fib (- % 2))))))

(fib 225) ; line 7

我曾认为 FP 中的上述 memoized Fibonacci 类似于 Clojure 将在精神上等同于命令式DP,例如在Python下面,

def fib(n):
    dp = [0, 1] + [0] * n
    for i in range(2, n + 1):
        dp[i] = dp[i - 1] + dp[i - 2]
    return dp[n]

问题 1

在我的例子中,当斐波那契数提高到 225 时,为什么我实际上得到了以下错误?

Syntax error (StackOverflowError) compiling at 7:1

我还在core.memoize 上尝试了插入式替换 memo,当斐波那契数提高到 110 时遇到了同样的错误。

追踪

下面我添加了跟踪naive Fibonaccimemoized Fibonacci 的递归前景,

(ns fib.core)

(defn naive-fib [n]
  (condp = n
    0 (bigdec 0)
    1 1
    (+ (naive-fib (dec n)) (naive-fib (- n 2)))))

(def memo-fib
  (memoize #(condp = %
              0 (bigdec 0)
              1 1
              (+ (memo-fib (dec %)) (memo-fib (- % 2))))))

(in-ns 'user)

(require '[clojure.tools.trace :refer [trace-ns]])
(trace-ns 'fib.core)

(fib.core/naive-fib 5)
(println)
(fib.core/memo-fib 5)

naive Fibonacci 中的重叠子问题已被 memoized Fibonacci 明显消除。乍一看似乎没有什么可疑的原因导致 StackOverflowErrormemoized Fibonacci 的堆栈帧深度与输入数字 n 严格线性相关,并且宽度受到限制到1

TRACE t427: (fib.core/naive-fib 5)
TRACE t428: | (fib.core/naive-fib 4)
TRACE t429: | | (fib.core/naive-fib 3)
TRACE t430: | | | (fib.core/naive-fib 2)
TRACE t431: | | | | (fib.core/naive-fib 1)
TRACE t431: | | | | => 1
TRACE t432: | | | | (fib.core/naive-fib 0)
TRACE t432: | | | | => 0M
TRACE t430: | | | => 1M
TRACE t433: | | | (fib.core/naive-fib 1)
TRACE t433: | | | => 1
TRACE t429: | | => 2M
TRACE t434: | | (fib.core/naive-fib 2)
TRACE t435: | | | (fib.core/naive-fib 1)
TRACE t435: | | | => 1
TRACE t436: | | | (fib.core/naive-fib 0)
TRACE t436: | | | => 0M
TRACE t434: | | => 1M
TRACE t428: | => 3M
TRACE t437: | (fib.core/naive-fib 3)
TRACE t438: | | (fib.core/naive-fib 2)
TRACE t439: | | | (fib.core/naive-fib 1)
TRACE t439: | | | => 1
TRACE t440: | | | (fib.core/naive-fib 0)
TRACE t440: | | | => 0M
TRACE t438: | | => 1M
TRACE t441: | | (fib.core/naive-fib 1)
TRACE t441: | | => 1
TRACE t437: | => 2M
TRACE t427: => 5M

TRACE t446: (fib.core/memo-fib 5)
TRACE t447: | (fib.core/memo-fib 4)
TRACE t448: | | (fib.core/memo-fib 3)
TRACE t449: | | | (fib.core/memo-fib 2)
TRACE t450: | | | | (fib.core/memo-fib 1)
TRACE t450: | | | | => 1
TRACE t451: | | | | (fib.core/memo-fib 0)
TRACE t451: | | | | => 0M
TRACE t449: | | | => 1M
TRACE t452: | | | (fib.core/memo-fib 1)
TRACE t452: | | | => 1
TRACE t448: | | => 2M
TRACE t453: | | (fib.core/memo-fib 2)
TRACE t453: | | => 1M
TRACE t447: | => 3M
TRACE t454: | (fib.core/memo-fib 3)
TRACE t454: | => 2M
TRACE t446: => 5M

问题 2

为什么 Clojure 可以在 编译时 断言,在我的情况下,memoized Fibonacci 的堆栈帧深度仅为 225爆炸整个 JVM 堆栈从而完全停止运行递归?从下面memoize 的源代码中,我可以看到一个空的hashmap 被启动来缓存memoized Fibonacci 的参数和返回。上述 hashmap 是否导致了 StackOverflowError 的断言?为什么?

(defn memoize
  "Returns a memoized version of a referentially transparent function. The
  memoized version of the function keeps a cache of the mapping from arguments
  to results and, when calls with the same arguments are repeated often, has
  higher performance at the expense of higher memory use."
  {:added "1.0"
   :static true}
  [f]
  (let [mem (atom {})]
    (fn [& args]
      (if-let [e (find @mem args)]
        (val e)
        (let [ret (apply f args)]
          (swap! mem assoc args ret)
          ret)))))

其他 - (为了完整性,但与 OP 无关)

我们可以利用 loop recur 来实现类似TCO迭代 implementationlaziness

(ns fib.core)

(defn tail-fib [n]
  (loop [n n
         x (bigdec 0)
         y 1]
    (condp = n
      0 0
      1 y
      (recur (dec n) y (+ x y)))))

(defn lazy-fib [n]
  (->> n
       (nth (iterate (fn [[x y]]
                       [y (+ x y)])
                     [(bigdec 0) 1]))
       first))

(in-ns 'user)

(require '[clojure.tools.trace :refer [trace-ns]])
(trace-ns 'fib.core)

(fib.core/tail-fib 2000)
(println)
(fib.core/lazy-fib 2000)

跟踪表明它们不会使任何递归调用生效。

TRACE t471: (fib.core/tail-fib 2000)
TRACE t471: => 4224696333392304878706725602341482782579852840250681098010280137314308584370130707224123599639141511088446087538909603607640194711643596029271983312598737326253555802606991585915229492453904998722256795316982874482472992263901833716778060607011615497886719879858311468870876264597369086722884023654422295243347964480139515349562972087652656069529806499841977448720155612802665404554171717881930324025204312082516817125M

TRACE t476: (fib.core/lazy-fib 2000)
TRACE t476: => 4224696333392304878706725602341482782579852840250681098010280137314308584370130707224123599639141511088446087538909603607640194711643596029271983312598737326253555802606991585915229492453904998722256795316982874482472992263901833716778060607011615497886719879858311468870876264597369086722884023654422295243347964480139515349562972087652656069529806499841977448720155612802665404554171717881930324025204312082516817125M

【问题讨论】:

  • 你是说defn而不是def
  • 是的,这是错字。已更正。
  • 将拼写错误从 def fib [n] 更新为 def fibmemoize 返回一个函数。
  • 如果您有多个问题,您应该发布多个问题。如果你不断在你的问题中添加新的东西,你的答案就会失效。很好,您自己进行研究并添加您的见解,但请保持重点。您的“编译时”错误的原因是您的代码在顶层运行。这是 clojure 编译器的工作方式:它运行 ns 中的代码并保留工件(字节码)。

标签: clojure dynamic-programming memoization


【解决方案1】:

Memoize返回函数,所以你必须使用def

(def fib
  (memoize #(condp = %
              0 (bigdec 0)
              1 1
              (+ (fib (dec %)) (fib (- % 2))))))

(fib 225)
=> 47068900554068939361891195233676009091941690850M

【讨论】:

  • 当@akond 要求验证时,我错误地将 OP 从 def 更新为 defn。现在OP恢复了。在我的情况下,我总是得到 StackOverflowError 。你介意你提高斐波那契数吗?到2000年看看效果?
【解决方案2】:

当缓存为空时,Memoization 不会影响堆栈。您的 Clojure 代码不等同于 Python 代码,因为 Clojure 版本是递归的,而 Python 版本是迭代的。

【讨论】:

    【解决方案3】:

    不知道为什么在新的repl上

    (def fib
      (memoize #(condp = %
              0 (bigdec 0)
              1 1
              (+ (fib (dec %)) (fib (- % 2))))))
    

    (fib 136) 给了我一个溢出 (fib 135)工作正常 然后当我再次这样做(fib 136)时,我没有错误。 做,从一个新的repl, (地图 fib(范围 1 10000)) 工作正常

    【讨论】:

    • 我怀疑memoize 启动了一个准全局原子哈希图来存储所有斐波那契对{1 1, 2 1, 3 2, 4 3, 5 5, ...},所以@ 987654323@ 将查找 fib_n-1 和 fib_n-2 的值,而不是从 (fib 1), (fib 2) 等开始重复递归。
    • 简而言之,memoize 模仿了通常迭代 DP 在递归设置中所做的事情。
    • 它间接部分回答了我的问题 - 上述哈希图不是 StackOverflowError 的根源,因为(map fib (range 1 10000)) 一直有效。
    【解决方案4】:

    我已经仔细调查了这个问题,并希望终于弄明白了。

    首先,让我们看一下 Pythonmemoized Fibonacci 上的一个可比较但方便的递归实现,然后再开始在 Clojure 中研究它。

    def memo_fib(n):
        def fib(n):
            print(f"ALL: {n}")
            if n not in dp:
                print(f"NEW: {n}")
                if n == 0:
                    dp[0] = 0
                elif n == 1:
                    dp[1] = 1
                else:
                    dp[n - 1], dp[n - 2] = fib(n - 1), fib(n - 2)
                    dp[n] = dp[n - 1] + dp[n - 2]
            return dp[n]
    
        dp = {}
        return fib(n)
    
    
    memo_fib(5)
    

    我们添加两个 print 子句来跟踪 memoized Fibonacci 上的递归执行。 ALL 表示实际进入生成的调用堆栈的内容,即使它们已经被记忆,而NEW 表示只有当它们尚未记忆时才会按比例显示。显然,ALL 的总步数大于 NEW 的总步数,这在下面的输出中也很明显,

    ALL: 5
    NEW: 5
    ALL: 4
    NEW: 4
    ALL: 3
    NEW: 3
    ALL: 2
    NEW: 2
    ALL: 1
    NEW: 1
    ALL: 0
    NEW: 0
    ALL: 1
    ALL: 2
    ALL: 3
    

    让我们也将其与下面Python中的naive Fibonacci进行比较,

    def naive_fib(n):
        print(f"NAIVE: {n}")
        if n == 0:
            return 0
        if n == 1:
            return 1
        return naive_fib(n - 1) + naive_fib(n - 2)
    
    
    naive_fib(5)
    

    我们可以在下面看到memoized Fibonacci 确实减少了递归步骤,从而比naive Fibonacci 更有效地改进了堆栈,这使@Eugene Pakhomov 发布的解释无效:

    当缓存为空时,Memoization 不会影响堆栈。

    NAIVE: 5
    NAIVE: 4
    NAIVE: 3
    NAIVE: 2
    NAIVE: 1
    NAIVE: 0
    NAIVE: 1
    NAIVE: 2
    NAIVE: 1
    NAIVE: 0
    NAIVE: 3
    NAIVE: 2
    NAIVE: 1
    NAIVE: 0
    NAIVE: 1
    

    回到 Clojure 中的 memoized Fibonacci,我们在 OP 中能够追踪的只是 NEW 步骤。要查看ALL 步骤,我们必须从内到外跟踪memoize 函数本身。如果您知道如何方便地进行操作,请提供帮助。

    现在是时候得出结论了,memoize 可以帮助减少重复的递归调用,但它不能完全防止递归调用因一些重叠的子问题而一次又一次地产生堆栈帧。如果这些子问题迅速增长,StackOverflowError 的出现会让您大吃一惊。

    【讨论】:

    • 这使@Eugene Pakhomov 发布的解释无效 关于堆栈溢出,不,它没有。当堆栈增长太深时会发生堆栈溢出。调用具有空缓存的记忆递归 fib 仍需要在 (n-1) 侧重复 N 次,一次。但随后 (n-2) 上的所有调用都已经缓存了答案。
    • @Eugene Pakhomov 提出的子句只是Memoization does not affect the stack when the cache is empty,没有详细信息。我会说这是误导而不是错误。您提到的和我调查的内容确定了隐藏的根本原因。
    猜你喜欢
    • 2019-06-04
    • 2015-10-19
    • 2011-12-14
    • 2021-08-17
    • 2021-05-21
    • 2015-06-16
    • 1970-01-01
    • 2021-11-17
    • 2018-11-09
    相关资源
    最近更新 更多