【问题标题】:Recursion vs. Stack递归与堆栈
【发布时间】:2016-02-04 15:33:47
【问题描述】:

我想知道递归是否是问题的唯一解决方案,那么堆栈迭代是否是唯一的其他解决方案?我认为它们是等价的:如果递归有效,那么迭代肯定会有效,反之亦然。

另外,我不确定为什么递归被认为是低效的并且经常导致堆栈溢出,而使用堆栈的迭代不会。递归只是以用户不可见的自动方式使用堆栈。

【问题讨论】:

  • 递归效率低下不是因为隐式堆栈,而是因为context switching 开销。它会导致堆栈溢出,因为分配给每个进程的堆栈空间量是有限的,并且远远小于分配给它的堆空间量。进程通常需要比堆栈空间更多的堆空间。因此,最好尽量避免使用堆栈。如果编译器执行tail call optimization,递归可以和迭代一样快。
  • 当分配给进程的堆栈内存量(在进程创建时)用完时,就会发生堆栈溢出。每次扩展调用堆栈时(通常通过方法调用或在特殊情况下为递归调用),都会消耗它。
  • 不一定是低效的,这取决于你使用哪种语言。看到这个问题stackoverflow.com/questions/2651112/…
  • @AaditMShah 虽然递归中不涉及上下文切换(除非这就是您所说的寄存器保存/恢复、堆栈框架设置和 IP 跳转,但是您的术语不寻常并且与您发布的链接不一致)。无论哪种情况,都不是昼夜差异。我测试了一个 7 节点缓存内二叉树的前序遍历:递归每个节点 3.6ns,显式堆栈每个节点 2.3ns。

标签: algorithm recursion


【解决方案1】:

尽管dancancode 的answer 讨论了不同类型的问题,例如primitive recursive 问题、recursive 问题和recursively enumerable 问题,恕我直言,这个问题更多的是关于直截了当的recursion 或iteration。

我想知道如果递归是问题的唯一解决方案,那么堆栈迭代是否是唯一的其他解决方案?

不,有很多不同的计算模型。然而,lambda calculus(递归的基础)和Turing machines(迭代的基础)是最流行的计算模型。另一个流行的计算模型是μ-recursion。

什么是计算模型?

长期以来,数学家都想研究计算的本质。他们想知道哪些问题可以计算(即哪些问题有解),哪些问题不能计算(即哪些问题没有解)。他们还想知道计算的性质(例如,计算解决方案相对于其输入大小需要多少时间等)。

但是,只有一个问题:“计算”是一个非常抽象的术语。你如何对不具体的事情进行推理?这就是数学家需要一个他们可以推理的计算模型的原因。计算模型抓住了“计算的本质”。这意味着如果有一个可以计算的问题,那么在每个计算模型中都必须有一个算法来计算它。

我认为它们是等价的:如果递归有效,那么迭代肯定会有效,反之亦然。

是的,没错。 Church-Turing thesis 实质上表明每个计算模型的功率都是等效的。因此,您可以使用递归(即 lambda 演算)做的所有事情也可以使用迭代(即图灵机)来完成。

事实上,世界上大多数计算机都是基于图灵机的。因此,每台计算机仅使用迭代。尽管如此,您的计算机仍然可以执行递归程序。这是因为编译器会将您的递归程序翻译成迭代机器码。

另外,我不确定为什么递归被认为是低效的并且经常导致堆栈溢出,而使用堆栈的迭代则不会。递归只是以用户不可见的自动方式使用堆栈。

这是因为操作系统处理进程的方式。大多数操作系统都对堆栈的大小施加了最大限制。在我的 Linux 操作系统上,最大堆栈大小是 8192 KB,这不是很多。使用ulimit -s 在符合 POSIX 的操作系统上查找默认堆栈大小。这就是使用过多递归导致堆栈溢出的原因。

另一方面,堆的大小可以在进程执行时动态增加(只要有可用空间)。因此,您不必担心在使用迭代时会耗尽内存(即使使用显式堆栈时也是如此)。

此外,递归通常比迭代慢,因为调用函数需要context switch,而在迭代中您只需要修改指令指针(即跳转,可能是有条件的)。

但是,这并不意味着迭代总是比递归更好。递归程序通常比迭代程序更小更容易理解。此外,在某些情况下,编译器可以通过tail call optimization (TCO) 完全消除上下文切换。这不仅使递归与迭代一样快,而且确保堆栈大小不会增加。

有趣的是,所有递归程序都可以通过将程序转换为continuation-passing style (CPS) 来实现尾递归。因此,CPS 和 TCO 可以一起eliminate the need of an implicit call stack。一些函数式编程语言的编译器和解释器在novel ways 中使用了这种能力。

【讨论】:

  • 这是一个非常全面且组织良好的答案。非常感谢您的分享!
【解决方案2】:

有所谓的原始递归函数可以用循环重写。然后有一类函数叫做recursive,必须递归定义。最后一个类是递归可枚举函数。

著名的阿克曼函数是递归的,但不是原始递归的。

Computerphile youtube 频道上有一段关于该主题的精彩视频: The Most Difficult Program to Compute?

递归当然可以是有效的。考虑到合并排序的 glibc 实现 (msort.c) 是递归的。

请注意,尾递归有一个常见的编译器优化,它将递归函数编译成一个循环。

【讨论】:

  • 谁说不能用循环实现阿克曼函数?这是在 JavaScript 中使用循环的 Ackermann 函数的实现:jsfiddle.net/mww92ryk
  • @AaditMShah var stack === 递归。您会惊讶于使用堆栈的语言与使用自定义堆栈的语言之间的相似之处。
  • @Sylwester 使用堆栈并不意味着递归。递归意味着递归。递归基于 lambda 演算,并且 lambda 演算的操作语义没有调用堆栈的概念。计算机系统中的进程具有调用堆栈这一事实是无关紧要的。递归当然不依赖于堆栈数据结构。一个完美的例子是尾递归函数。这些函数是递归的,但它们不需要调用堆栈。递归是一种编写程序的方式,我编写 Ackermann 函数的方式当然不是递归的,即使我使用了堆栈。
  • @AaditMShah 尾递归(简单递归)是迭代过程。因此,他们总是减少工作量。简单的递归可以像function () { return 1;} 一样简单。有些语言只有递归,没有其他循环形式。我的意思是递归过程。因此,减少 lambda 演算是需要这样做的一个很好的例子。如果它是系统堆栈,嵌套闭包,增加数组真的没关系。您会看到在结果趋于增加之前需要执行的等待任务,这可能是它是递归过程的线索,而您的 ackerman 函数就是这样做的。
  • @Sylwester 我认为您对recursive problems 和recursion 感到困惑。我同意 Ackermann 函数是一个递归函数(“递归”的意思是“可判定的”)。但是,阿克曼函数的算法可以使用递归或迭代来编写。递归(基于 lambda 演算)在能力上等同于迭代(基于图灵机)。尽管问题是递归的,但我的算法不使用递归。问题标题是 Recursion vs Stack。
猜你喜欢
  • 2018-10-16
  • 1970-01-01
  • 2011-11-24
  • 2015-06-06
  • 2011-01-30
  • 2014-09-02
  • 2015-09-12
  • 2021-01-21
  • 2021-03-23
相关资源
最近更新 更多