【问题标题】:Causes of stack overflow in recursive functions递归函数中堆栈溢出的原因
【发布时间】:2016-03-22 23:45:08
【问题描述】:

this video 大约 28 分钟的时候,Brian Harvey 被一个学生问到在编写程序时我们是否应该始终使用迭代过程而不是递归过程。他说不,因为

您的程序不会遇到空间限制。就内存中内容的局部性而言,您必须比您对程序被解释的方式有更多的控制才能真正影响它。

由于这不是一门课程,我假设他在这里一般谈论的是编程语言。当他说““你的程序不会遇到空间限制。”时,他是否忽略了堆栈溢出?我对他的回答感到困惑,因为没有堆栈溢出意味着你已经用函数调用耗尽了空间?而且我对“局部性”部分一无所知。堆栈溢出可能发生在方案、Java 和其他语言上。我是对的还是误解了他的说法?

【问题讨论】:

  • 当然,栈溢出can happen in lisp,还有Java。
  • @azurefrog 已编辑,谢谢
  • 局部性部分指的是内存局部性,它涉及值在内存中存储的接近程度。由于 CPU 缓存的工作方式,局部性通常会以极大的方式影响性能。不看有问题的剪辑,只阅读引用,我猜哈维所说的是,在任何一般意义上,你几乎无法控制内存局部性,尤其是在高级编程语言中,因此编写迭代算法而不是递归的确实不会影响这一点。
  • @morbidCode 是的。这通常是 99% 的算法的有效假设,除非您在堆栈大小特别受限的环境中工作。即使在像 Java 这样具有相当浅堆栈的语言中,对于无法简单地迭代表达的算法也不太可能溢出。在像 Scheme 这样的语言中,递归是一种常用工具,它就更不用担心了。如果它确实成为一个问题,你可以重构它,但他说这可能无关紧要,所以你应该首先编写代码尽可能清晰,这是非常好的建议。
  • 我认为他想避免学生沉迷于优化。该课程处于非常早期的阶段,我的理解是他更看重简洁的代码而不是高性能的代码。所以他的意思是,他的学生必须编写的代码在空间要求方面非常有限,不会发生堆栈溢出。

标签: java recursion scheme iteration sicp


【解决方案1】:

您所指的视频是计算机科学讲座。计算机科学主要是理论性的,它解决了许多与实际无关的计算细节。在这种情况下,正如他在讲座开始时所说的那样,当今的计算机足够大且速度足够快,因此性能很少成为问题。


内存位置与StackOverflowExceptions 无关,在任何语言中。实际上,内存局部性是指 SRAM(静态 RAM),当总线从内存(可以是磁盘或 RAM)检索数据时,它会保存一个相邻数据的缓存。从这个缓存中获取数据比从内存中获取数据要快,所以如果一个程序在多个连续操作中需要的所有数据都在缓存中,那么它的运行速度会更快。


现在这一切都非常低级。在大多数(如果不是全部)现代语言(如 Java)的背后,都有一个编译器在进行大量的低级优化。这意味着,首先,您几乎无法在低级别优化您的代码,尤其是在不干扰编译器优化的情况下。其次,(就像他在您所指的部分之后所说的那样)除非您是制作资源密集型游戏,否则不值得您花时间担心性能(除非您有明显的性能问题,但这更有可能代码中其他问题的指示)。

【讨论】:

    【解决方案2】:

    如今,我们巨大的内存堆栈溢出通常是无限递归的标志,就像在迭代中非停止程序是无限循环的标志一样。

    所以是的,他是对的。

    【讨论】:

      【解决方案3】:

      我的递归过程会导致堆栈溢出吗? 这取决于您设计的递归过程的类型,根据问题,大多数天真的递归可以转换为尾调用(在尾调用优化的“TCO”语言中),这允许递归在不使用的情况下在恒定的内存空间中运行突变或其他有状态的东西。

      在方案中,一个迭代过程:

      (let ((i 0)
            (max 10))
        (let loop ()
          (cond ((< i max)
                 (printf "~A~N" i)
                 (set! i (+ i 1))
                 (loop))
                (else i))))
      

      此过程使用常量内存,该内存等于在堆栈上存储循环调用所需的空间。这个过程不是一个函数,它使用变异来迭代(它也是一个递归;)但是..)。

      在方案中,两个递归:

      (define (fact-1 n)
        (cond ((eq? n 1) n)
              (else (* n (fact-1 (- n 1))))))
      
      (define (fact-2 n carry)
        (cond ((eq? n 1) carry)
              (else (fact-2 (- n 1) (* carry n)))))
      

      Fact-1 是一个正常的递归,并且非常实用,没有状态变化,相反,随着每次fact-1 调用创建新的词法闭包,内存使用量增加,最终耗尽堆栈。它长得像

      =>(fact-1 10)
      ..(* 10 (fact-1 9))
      ..(* 10 (* 9 (fact-1 8)))
      ..(* 10 (* 9 (* 8 (fact-1 7))))
      ..     .....
      ..(* 10 (... (* 2 1) ...))
      ..     .....
      ..(* 10 362880)
      =>3628800
      

      而 Fact-2 是递归的,但采用尾部形式,因此不是构建堆栈并在基本情况下折叠调用,而是将值向前传递,我们得到:

      =>(fact-2 10 1)
      ..(fact-2 9 10)
      ..(fact-2 8 90)
      
      ..(fact-2 7 720)
      .. .......(fact-2 1 362880)
      =>3628800
      

      这相当于将 fact-1 变成一个交互过程,但没有突变,因为值是向前传递的,而不是赋值。请注意,每次调用仍会产生一个新的词法闭包,但由于函数不会返回到原始调用者,而是返回到原始调用者堆栈位置,编译器可以丢弃以前的闭包,而不是让它们相互嵌套,重新绑定每个递归级别的变量。

      那么我应该在哪里使用递归与迭代 这完全取决于要设计的过程和使用的语言。如果您的语言不支持 TCO,那么您将只需要使用浅递归,并以有状态的方式编写循环(递归或迭代)过程。如果您确实有 TCO,那么最好使用递归、尾调用或有状态的事物,或它们的组合。不是所有的递归过程都可以写成尾部形式,也不是所有的迭代过程都可以写成递归。如果您担心内存使用,并且想要深度递归,则必须使用 Tail-calls。

      注意: 你们中的一些人可能已经注意到,但第一个过程实际上也是一个尾调用,但该示例仍然说明了正常迭代执行有状态的要点!无论所有有效输入如何,都在恒定的最大内存中运行。

      【讨论】:

      • c# 和 scala 呢?我相信他们都支持尾巴形式。你的意思是如果我写一个没有基本情况的尾递归函数,我不会耗尽内存吗?带有迭代过程的递归函数真的和tail-form一样吗?
      • 好吧,如果 c# 和 scala 支持的是真正的 TCO,那么以尾部形式编写的递归函数将使用常量内存。即使没有基本情况,也不会耗尽内存,您将拥有一个无限循环(您可能想要)。
      • ie (define (foo n) (* n (foo (- n 1)))) 没有基本情况,并且由于它不是尾部形式,随着调用的增长,它将耗尽堆栈空间(fact 10) -> (* 10 (fact 9)) -> (* 10 (* 9 (fact 8))) .....,然而,如果我们将其设为(define (foo n carry) (foo (- n 1) (* carry n))),然后执行(foo 10 1),我们将得到(foo 9 10) -> (foo 8 90) -> (foo 7 720) .... 即使没有堆栈,它也永远不会用完一个基本情况,它只会继续使用恒定数量的内存,即存储当前函数调用所需的内存量
      猜你喜欢
      • 2017-10-20
      • 1970-01-01
      • 1970-01-01
      • 2012-08-31
      • 2011-02-26
      • 1970-01-01
      相关资源
      最近更新 更多