【问题标题】:Can all recursive functions be re-written as tail-recursions? [duplicate]所有递归函数都可以重写为尾递归吗? [复制]
【发布时间】:2013-05-05 21:42:42
【问题描述】:

可能重复:
Are there problems that cannot be written using tail recursion?

据我了解,尾递归是一种优化,当递归调用不需要来自递归调用的信息时,您可以使用它会产生垃圾邮件。

那么是否可以使用尾递归来实现所有递归函数?像 DFS 这样的东西,你需要最里面的孩子在父母之前返回?

【问题讨论】:

    标签: algorithm recursion tail-recursion


    【解决方案1】:

    这完全取决于您的要求。

    如果您想将所有函数保留为具有相同签名的函数(无可变状态),那么不。最明显的例子是快速排序,其中两个调用都不能是尾调用。

    如果您可以通过各种方式修改功能,那么可以。有时本地修改就足够了——通常你可以添加一个“累加器”来构建一些返回的表达式,但是,如果结果涉及非交换操作,那么你需要小心(例如,当天真地构造链表时,顺序颠倒)或者你可以添加一个堆栈。

    或者,您可以对整个程序进行全局修改,其中每个函数将包含未来操作的函数作为额外参数。这是Pete is talking about 的延续。

    如果您是手工工作,那么本地修改通常相当容易。但是如果你正在做自动重写(例如在编译器中),那么采用全局方法会更简单(它需要更少的“智能”)。

    【讨论】:

    • 快速排序可以尾递归编程,维护后半部分的额外显式位置堆栈,以供以后处理。
    • 您不必在尾部位置调用您收到的延续本身。你可以在 CPS 的尾部位置调用任何你想要的函数。您只需将该延续(按原样或修改)传递给该尾调用。
    • 堆栈将是可变状态。我已尝试更正 cps 描述。
    • 不,不是。当您传递它时,您会推动并弹出。并且快速排序中的数组本身是可变的,否则它不是快速排序,因为否则无法以这种方式完成分区。 Jerry Coffin 的回答显然是错误的。
    • 然后它正在更改签名。我讨论快速排序的部分说没有突变并保持相同的签名(我同意你的观点,即快速排序作为一个整体应该是可变的,但我们很久以前就失去了这个论点——其他所有人和他们的狗都称之为功能递归排序快速排序)。
    【解决方案2】:

    是和不是。

    是的,与其他控制流机制(例如,延续传递)结合使用,您可以将任意控制流表示为尾递归。

    不,不可能将所有递归都表示为尾递归,除非您确实用其他控制流机制补充尾递归。

    【讨论】:

    • 我很好奇,不能all recursive algorithms be written using iteration,不能所有的迭代算法都是expressed using tail-recursion吗?也许这更多的是直接/间接表达方式的问题,或者可能只是有副作用?
    • 是的,递归函数可以通过使用迭代和手动管理(调用)堆栈来计算。如果函数可以转换为尾递归表示,则可以消除堆栈。这就是某种形式的尾递归优化所做的:它将当前调用中的堆栈帧重用于下一次调用。如果您有一个不使用堆栈的迭代,您可以将其转换为尾递归函数。如果它使用堆栈,则堆栈仍然存在,即堆栈的内存使用没有上限,这是尾递归优化的重点。
    • 对不起,这完全是错误的。事实上,所有程序都可以使用延续来重写为尾调用。
    • @PeteKirkham:声称它“完全是错误的”充其量只是误导(这可能是我一直以来看到的最糟糕的拒绝投票的借口)。是的,尾递归结合其他东西(其中延续只是一种众所周知的可能性)可以解决问题。尾递归本身是一个完全不同的故事。
    • @AndreyT 假设您可以实现完整的语言(方案),或通过转换为尾调用在编译器中执行优化,关于非尾递归操作的转换没有“意义”使用 O(f(N)) 堆栈到使用 O(f(N)) 堆的尾递归堆栈?在现实世界中,它为您提供了某些理想的功能,例如允许您使用单个垃圾收集分配机制进行操作,以便回收无法访问的堆栈对象的存储。
    【解决方案3】:

    是的,你可以。转换通常涉及显式维护必要的信息,否则这些信息会在运行时隐式地分布在执行堆栈的调用帧中为我们维护。

    就这么简单。无论运行时系统在执行期间隐式地做什么,我们都可以自己显式地做。这里没有什么大谜团。 PC 由硅、铜和钢制成。

    将 DFS 实现为具有要处理的状态/位置/节点的显式队列的循环是微不足道的。它实际上是这样定义的——DFS 用来自它的所有弧替换队列中弹出的第一个条目; BFS 将这些弧添加到队列的末尾。

    continuation-passing style transformation 在完成后将程序中的所有函数调用作为尾调用。这是一个简单的生活事实。使用的延续会增长和缩小,但调用都将是尾调用。

    我们可以进一步具体化进程在延续中传播的状态,作为在堆上显式维护的数据。最终实现的是解释和具体化,将堆栈上的隐式内容移动到堆中的显式内容,简化和揭开控制流的神秘面纱。

    【讨论】:

    • 这会失去 OP 提到的优化吗?
    • @WolframH 有哪些优化?他没有提到任何。他说他认为这是一种优化,而且确实如此。它解释事物,将隐藏在堆栈中的隐式事物移动到调用帧之间,进入开放的堆中。这恰恰明确了他提到的信息积累。即当上层调用需要来自递归调用的信息时,通常将其编码为非尾递归;但是信息流可以明确化,控制流变成迭代。
    【解决方案4】:

    所有程序都可以使用延续传递重写为尾调用。只需在尾调用中添加一个参数,代表当前执行的continuation

    任何 Turning 完整语言都执行与延续传递所提供的相同的转换 - 为非尾调用返回的程序和输入参数创建一个哥德尔数,并将其作为参数传递给尾调用 - 尽管显然是环境使用 continuation、co-routine 或其他一流的结构为您完成这项工作会更容易。

    CPS 用作编译器优化,我之前使用延续传递编写了解释器。 scheme programming language 旨在允许它以符合尾调用优化和第一类延续标准的要求的方式实现。

    【讨论】:

    • 作为问题的答案,如所问,这完全是错误的。是的,可以使用延续来模拟您想要的任何流控制。然而,问题只是关于尾递归,而不是延续。
    • @Pete Kirkham:如果您能找到一种使用O(1) 内存对延续状态进行编码的方法,那么它将确实构成将递归算法转换为尾递归或迭代算法的有效方法。但是,如果您的延续需要非常量的内存,则该方法不符合原始问题意义上的 尾递归 的条件。
    • @JerryCoffin 提出的问题没有提到 O(1) 内存。
    • @JerryCoffin 说您使用整数计数器而不仅仅是迭代并不遥远。 AFAIK,唯一具有保证尾调用优化的标准的语言是方案,它还提供延续。两者结合在一起,就像有一个带有非尾调用递归的堆栈,或者有一个带有迭代的计数器。
    • @AndreyT 您说“一种将递归算法转换为尾递归或迭代算法的有效方法”,但这两个概念根本不一样。您将尾递归作为代码的句法属性与该代码描述的过程是否迭代混为一谈。你是绝对正确的,TR代码可以描述一个递归的,即无迭代的过程。但问题是关于重写代码。这种重写是语法上的,它不会改变手头算法的性质。 double-rec fibonacci 可以做成线性的,但那是 算法 变化,重新排列信息流。
    【解决方案5】:

    递归算法是一种按照分治策略实现的算法,其中解决每个中间子问题会产生 0、1 或更多新的较小子问题。如果这些子问题以 LIFO 顺序解决,您将得到一个经典的递归算法。

    现在,如果已知您的算法在每一步仅产生 0 或 1 个子问题,那么该算法可以通过尾递归轻松实现。事实上,这样的算法可以很容易地重写为迭代算法,并通过一个简单的循环来实现。 (不用说,尾递归只是实现迭代的另一种不太明确的方式。)

    这种递归算法的教科书示例将是阶乘计算的递归方法:要计算n!,您需要首先计算(n-1)!,即在每个递归步骤中,您只会发现一个较小的子-问题。正是这一特性使得将阶乘计算算法转换为真正的迭代算法(或尾递归算法)变得如此容易。

    但是,如果您知道在一般情况下,算法的每一步生成的子问题的数量都超过 1,那么您的算法基本上是递归的。它不能重写为迭代算法,不能通过尾递归来实现。任何以迭代或尾递归方式实现此类算法的尝试都需要额外的非常量大小的 LIFO 存储来存储“未决”子问题。这样的实现尝试只会通过手动实现递归来简单地混淆算法不可避免的递归性质。

    例如,像遍历具有父->子链接(并且没有子->父链接)的二叉树这样的简单问题是一个实质上的递归问题。尾递归算法不行,迭代算法不行。

    【讨论】:

    • 您在二叉树遍历的约束中遗漏了“不可变”,否则可以使用众所周知的方法stackoverflow.com/a/791378/1527
    • 对于一些,尾递归比无数不同的循环构造更明确、更自然地表达迭代。 :)
    • 显式维护 LIFO 存储有什么问题,否则我们会在调用堆栈帧上隐式维护这些存储?土豆,土豆。该转换不会增加复杂性,它是固有手头的算法本身。并且不可变二叉树的遍历很容易在循环中通过显式的节点访问堆栈完成。
    • @Will Ness:这并没有什么“错误”。只是它不会产生迭代算法。仅仅因为您在代码中使用了“循环”,并不意味着您神奇地找到了迭代算法。只要您的算法遵循 D&C 策略并为未决任务提供额外存储空间,您的算法就是 递归。你如何实现它并不重要。父->子树是递归数据结构的典型例子,这就是为什么任何遍历算法都是递归的。没有办法解决它。
    • 同时,“尾递归”是指特定优化策略的术语,而不仅仅是最后碰巧调用自身的算法。该优化策略的要求之一是静态存储大小。动态大小的延续不是解决方案。
    【解决方案6】:

    我不知道 所有 递归函数是否可以重写为尾递归,但其中许多都可以。这样做的一种标准方法是使用累加器。例如,阶乘函数可以这样写(在 Common Lisp 中):

    (defun factorial (n)
       (if (<= n 1)
           1
           (* n (factorial (1- n)))))
    

    这是递归的,但不是尾递归的。它可以通过添加一个累加器参数来实现尾递归:

    (defun factorial-accum (accum n)
       (if (<= n 1)
           accum
           (factorial-accum (* n accum) (1- n))))
    

    可以通过将累加器设置为1来计算阶乘。例如,3的阶乘是:

    (factorial-accum 1 3)
    

    不过,我不清楚是否可以使用这样的方法将所有递归函数重写为尾递归函数。但肯定有很多功能可以。

    【讨论】:

    • 感知到的问题不是像阶乘计算这样的单递归过程,而是像二叉树遍历这样的双递归过程。尾调用与否是语法的特征,而不是算法的特征。
    • 这个问题的解释现在很明显了。
    • 您的意思是“绝不明显”吗?我对这个问题的解释很简单。它问,是否可以将任何递归代码编写为尾递归代码。 TR 代码是一个语法问题——它所做的所有可能导致它被再次调用的函数调用是否都处于尾部位置。如果实施不应用 TCO,TR 代码可能不会得到优化。应用了 TCO 的 TR 代码可以在常量堆栈中运行,这就是关于 TCO 的全部内容。像factorial 这样的单递归代码的重写很简单,这就是我的意思,对于双递归代码,它更复杂。
    【解决方案7】:

    不,它只能“自然”地完成一次递归调用。对于两个或多个递归调用,您当然可以自己模拟堆栈帧。但在优化内存的意义上,它会非常丑陋,并且实际上不会是尾递归。

    尾递归的要点是您不想回到父堆栈。因此,只需将该信息传递给可以完全替换父堆栈的子堆栈,而不是堆栈增长。

    【讨论】:

    • 对不起,这完全是错误的。事实上,所有程序都可以使用延续来重写为尾调用。
    • @Pete - 在使用常量内存时?就是这个问题。
    • 这不是问的问题。我添加了一条评论,但是使用 CPS 编写解释器和编译器后,使用延续将任何递归算法转换为尾调用(具有相同空间要求)没有问题。如果您的递归算法是常量内存,那么 CPS 尾调用版本将是。如果它消耗“堆栈”空间,则 CPS 尾调用版本将消耗类似数量的“堆”空间。
    • @PeteKirkham - 如果它需要相同的内存,为什么还要麻烦?
    • 在编译器中,将所有内容重写为尾调用意味着您只需为一种形式的控制流编写优化。在基于角色的语言中,具有显式延续意味着您可以创建大量并发。在垃圾收集环境中,这意味着您不必区别对待堆栈和堆,并且可以将堆栈空间作为正常 gc 的一部分回收。在堆栈大小小于堆大小的环境中,您可以对嵌套更深的构造进行操作。
    猜你喜欢
    • 2020-09-16
    • 2013-09-10
    • 1970-01-01
    • 2013-01-10
    • 1970-01-01
    • 2023-03-25
    • 2012-12-30
    • 2019-09-14
    相关资源
    最近更新 更多