ChatGPT解决这个技术问题 Extra ChatGPT

递归比循环快吗?

我知道递归有时比循环干净得多,而且我没有询问何时应该在迭代上使用递归,我知道已经有很多问题了。

我要问的是,递归比循环快吗?在我看来,你总是能够优化循环并让它比递归函数更快地执行,因为循环不存在不断设置新的堆栈帧。

我特别在寻找递归是否在递归是处理数据的正确方法的应用程序中更快,例如在某些排序函数、二叉树等中。

有时,某些重复的迭代过程或封闭式公式需要几个世纪才能出现。我认为只有在那些时候递归更快:)哈哈
就我自己而言,我更喜欢迭代。 ;-)
Recursion or Iteration? 的可能重复项
@PratikDeoghare 不,问题不在于选择完全不同的算法。您始终可以将递归函数转换为使用循环的功能相同的方法。例如,this answer has the same algorithm in both recursive and looping format。通常,您会将递归函数的参数元组放入堆栈,压入堆栈以调用,从堆栈中丢弃以从函数返回。

D
Dietrich Epp

这取决于所使用的语言。你写了“语言不可知论”,所以我会举一些例子。

在 Java、C 和 Python 中,与迭代(通常)相比,递归相当昂贵,因为它需要分配新的堆栈帧。在某些 C 编译器中,可以使用编译器标志来消除这种开销,它将某些类型的递归(实际上是某些类型的尾调用)转换为跳转而不是函数调用。

在函数式编程语言实现中,有时迭代可能非常昂贵,而递归可能非常便宜。在许多情况下,递归被转换为简单的跳转,但更改循环变量(它是可变的)有时需要一些相对繁重的操作,尤其是在支持多线程执行的实现上。由于mutator和垃圾收集器之间的交互,如果两者可能同时运行,突变在其中一些环境中是昂贵的。

我知道在某些 Scheme 实现中,递归通常比循环更快。

简而言之,答案取决于代码和实现。使用你喜欢的任何风格。如果您使用的是函数式语言,递归可能会更快。如果您使用的是命令式语言,迭代可能会更快。在某些环境中,这两种方法都会生成相同的程序集(将其放入您的管道并抽它)。

附录:在某些环境中,最好的选择既不是递归也不是迭代,而是高阶函数。这些包括“map”、“filter”和“reduce”(也称为“fold”)。这些不仅是首选样式,而且它们通常更简洁,而且在某些环境中,这些函数是第一个(或唯一一个)从自动并行化中获得提升的函数——因此它们可以比迭代或递归快得多。 Data Parallel Haskell 就是这种环境的一个例子。

列表推导是另一种选择,但它们通常只是迭代、递归或高阶函数的语法糖。


我+1,并想评论“递归”和“循环”正是人类对其代码的命名。对性能重要的不是你如何命名事物,而是它们是如何编译/解释的。递归,顾名思义,是一个数学概念,与堆栈帧和汇编的东西几乎没有关系。
此外,递归通常是函数式语言中更自然的方法,而迭代通常在命令式语言中更直观。性能差异不太可能很明显,因此只需使用对该特定语言感觉更自然的任何内容。例如,当递归更简单时,您可能不想在 Haskell 中使用迭代。
通常,递归编译为循环,循环是较低级别的构造。为什么?因为递归通常建立在某些数据结构之上,因此引入 Initial F-algebra 并允许您证明一些关于终止的属性以及关于(递归)计算结构的归纳论证。将递归编译为循环的过程是尾调用优化。
最重要的是未执行的操作。你的“IO”越多,你需要处理的越多。 Un-IOing 数据(又称索引)始终是对任何系统的最大性能提升,因为您不必首先处理它。
正如我多次检查过的那样,在 Leetcode.com 上解决问题的 Java(一种大多数命令式语言)解决方案中最快的代码是递归代码。这让我很吃惊。
L
Lucio M. Tato

递归比循环快吗?

不,迭代总是比递归快。 (在冯诺依曼架构中)

解释:

如果您从头开始构建通用计算机的最小操作,“迭代”首先作为构建块出现,并且比“递归”占用更少的资源,因此更快。

从头开始构建伪计算机:

问问自己:你需要什么来计算一个值,即遵循一个算法并得到一个结果?

我们将建立概念的层次结构,从头开始,首先定义基本的核心概念,然后用这些概念构建第二级概念,依此类推。

第一个概念:存储单元、存储、状态。要执行某些操作,您需要存储最终和中间结果值的地方。假设我们有一个无限的“整数”单元数组,称为内存,M[0..Infinite]。说明:做某事 - 变换一个单元格,改变它的值。改变状态。每条有趣的指令都会执行一次转换。基本指令是: a) 设置和移动内存单元将值存储到内存中,例如:存储 5 m[4] 将值复制到另一个位置:例如:存储 m[4] m[8] b) 逻辑和算术以及, or, xor, not add, sub, mul, div。例如添加 m[7] m[8] 一个执行代理:现代 CPU 中的一个核心。 “代理”是可以执行指令的东西。代理人也可以是遵循纸上算法的人。步骤顺序:指令序列:即:先执行此操作,后执行此操作,等等。命令式指令序列。甚至一行表达式也是“命令式指令序列”。如果您有一个具有特定“评估顺序”的表达式,那么您有步骤。这意味着即使是单个组合表达式也有隐含的“步骤”,也有隐含的局部变量(我们称之为“结果”)。例如:4 + 3 * 2 - 5 (- (+ (* 3 2) 4 ) 5) (sub (add (mul 3 2) 4 ) 5) 上面的表达式暗示了 3 个步骤,其中包含一个隐含的“结果”变量。 // 伪代码 1.result = (mul 3 2) 2.result = (add 4 result) 3.result = (sub result 5) 所以即使是中缀表达式,因为你有一个特定的求值顺序,也是一个命令式的指令序列.该表达式暗示要按特定顺序进行的一系列操作,并且由于有步骤,因此还有一个隐含的“结果”中间变量。指令指针:如果您有一系列步骤,那么您还有一个隐含的“指令指针”。指令指针标记下一条指令,并在读取指令之后但在执行指令之前前进。在这个伪计算机中,指令指针是内存的一部分。 (注意:通常指令指针是 CPU 内核中的一个“特殊寄存器”,但在这里我们将简化概念并假设所有数据(包括寄存器)都是“内存”的一部分) 跳转 - 一旦你有一个有序数量的步骤和指令指针,您可以应用“存储”指令来更改指令指针本身的值。我们将使用一个新名称来调用 store 指令的这种特定用途:Jump。我们使用新名称是因为更容易将其视为一个新概念。通过更改指令指针,我们指示代理“转到第 x 步”。无限迭代:通过向后跳,现在可以让代理“重复”一定数量的步骤。此时我们有无限迭代。 1. mov 1000 m[30] 2. sub m[30] 1 3. jmp-to 2 // 无限循环 Conditional - 有条件地执行指令。使用“条件”子句,您可以根据当前状态(可以使用前一条指令设置)有条件地执行几条指令之一。适当的迭代:现在有了条件子句,我们可以逃脱跳回指令的无限循环。我们现在有一个条件循环,然后是正确的迭代 1. mov 1000 m[30] 2. sub m[30] 1 3.(如果不为零)跳转 2 // 仅当前一个子指令没有结果时才跳转in 0 // 这个循环将重复 1000 次 // 这里我们有适当的 ***iteration***,一个条件循环。命名:为保存数据或保存步骤的特定内存位置命名。这只是一个“方便”。我们不会通过定义内存位置的“名称”来添加任何新指令。 “命名”不是给代理的指令,只是给我们方便。命名使代码(此时)更易于阅读和更改。 #define counter m[30] // 命名内存位置 mov 1000 counter loop: // 命名指令指针位置 sub counter 1(如果不为零) jmp-to loop 一级子程序:假设有一系列步骤需要经常执行。您可以将步骤存储在内存中的指定位置,然后在需要执行它们时跳转到该位置(调用)。在序列结束时,您需要返回调用点以继续执行。使用这种机制,您可以通过组合核心指令来创建新指令(子程序)。实现:(不需要新概念)将当前指令指针存储在预定义的内存位置跳转到子程序结束时,您从预定义的内存位置检索指令指针,有效地跳转回原始指令的以下指令call 单级实现的问题:您不能从子程序调用另一个子程序。如果这样做,您将覆盖返回地址(全局变量),因此您不能嵌套调用。为了更好地实现子程序:您需要一个堆栈堆栈:您定义一个内存空间作为“堆栈”工作,您可以在堆栈上“推送”值,也可以“弹出”最后一个“推送”的值。要实现堆栈,您需要一个堆栈指针(类似于指令指针),它指向堆栈的实际“头”。当你“push”一个值时,堆栈指针递减并且你存储这个值。当您“弹出”时,您会在实际堆栈指针处获得值,然后堆栈指针会递增。子程序 现在我们有了一个堆栈,我们可以实现适当的子程序,允许嵌套调用。实现方式类似,但我们不是将指令指针存储在预定义的内存位置,而是将 IP 的值“压入”堆栈。在子程序结束时,我们只是从堆栈中“弹出”值,有效地跳回到原始调用之后的指令。这种具有“堆栈”的实现允许从另一个子程序调用一个子程序。通过这种实现,我们可以在将新指令定义为子程序时创建多个抽象级别,方法是使用核心指令或其他子程序作为构建块。递归:当子程序调用自身时会发生什么?这称为“递归”。问题:覆盖子程序可以存储在内存中的本地中间结果。由于您正在调用/重用相同的步骤,因此如果中间结果存储在预定义的内存位置(全局变量)中,它们将在嵌套调用中被覆盖。解决方案:为了允许递归,子程序应该将本地中间结果存储在堆栈中,因此,在每次递归调用(直接或间接)时,中间结果都存储在不同的内存位置。

...

达到递归后,我们停在这里。

结论:

在冯诺依曼架构中,显然“迭代”是比“递归”更简单/基本的概念。我们在第 7 级有一种“迭代”形式,而“递归”在概念层次结构的第 14 级。

机器代码的迭代总是更快,因为它意味着更少的指令,因此更少的 CPU 周期。

哪一个更好”?

当您处理简单的顺序数据结构时,您应该使用“迭代”,并且在任何地方都可以使用“简单循环”。

当您需要处理递归数据结构(我喜欢称它们为“分形数据结构”),或者当递归解决方案显然更“优雅”时,您应该使用“递归”。

建议:使用最适合工作的工具,但要了解每个工具的内部工作原理,以便明智地选择。

最后,请注意您有很多机会使用递归。你到处都有递归数据结构,你现在正在看一个:支持你正在阅读的内容的 DOM 部分是 RDS,JSON 表达式是 RDS,计算机中的分层文件系统是 RDS,即:你有一个根目录,包含文件和目录,每个目录都包含文件和目录,每个目录都包含文件和目录...


您假设您的进步是 1)必要的,并且 2)它停止在那里。但是 1)没有必要(例如,递归可以变成跳转,正如接受的答案所解释的那样,因此不需要堆栈),以及 2)它不必停在那里(例如,最终你'将达到并发处理,如果您在第二步中引入的可变状态可能需要锁定,因此一切都会变慢;而像函数/递归这样的不可变解决方案将避免锁定,因此可能更快/更并行) .
“递归可以变成跳转”是错误的。真正有用的递归不能变成跳转。尾调用“递归”是一种特殊情况,您将“递归”编码为编译器可以将其简化为循环的东西。此外,您将“不可变”与“递归”混为一谈,这些是正交概念。
“真正有用的递归不能变成跳转”->所以尾调用优化在某种程度上是无用的?此外,不可变和递归可能是正交的,但您确实将循环与可变计数器联系起来 - 看看您的第 9 步。在我看来,您认为循环和递归是完全不同的概念;他们不是。 stackoverflow.com/questions/2651112/…
@hmijail 我认为比“有用”更好的词是“真实”。尾递归不是真正的递归,因为它只是使用函数调用语法来伪装无条件分支,即迭代。真正的递归为我们提供了一个回溯堆栈。然而,尾递归仍然具有表现力,这使得它很有用。当使用尾调用表示迭代代码时,递归的特性使分析代码的正确性变得容易或更容易。虽然这有时会被尾部版本中的额外复杂性(如额外参数)略微抵消。
如果编译器从字面上将尾递归转换为迭代,那么在讨论其计算复杂性的上下文中将其称为“递归”是相当愚蠢的。您可能正在谈论您当时输入的键盘品牌。
s
starblue

在替代方案是显式管理堆栈的情况下,递归可能会更快,例如您提到的排序或二叉树算法。

我曾经遇到过用 Java 重写递归算法使其变慢的情况。

所以正确的方法是首先以最自然的方式编写它,只有在分析表明它很关键时才进行优化,然后测量假定的改进。


+1 表示“首先以最自然的方式编写它”,尤其是“仅在分析表明它很关键时才进行优化”
+1 承认硬件堆栈可能比软件、手动实现的堆内堆栈更快。有效地表明所有“否”的答案都是不正确的。
不过,这假设您编写的算法实际上是最快的迭代算法。糟糕的重构/优化也可能在这里。
C
Community

Tail recursion 与循环一样快。许多函数式语言都实现了尾递归。


当实现尾调用优化时,尾递归可以与循环一样快:c2.com/cgi/wiki?TailCallOptimization
P
Pasi Savolainen

考虑每个迭代和递归绝对必须做的事情。

迭代:跳转到循环的开头

递归:跳转到被调用函数的开头

您会看到这里没有太大的差异空间。

(我假设递归是尾调用并且编译器知道该优化)。


B
Björn Lindqvist

这里的大多数答案都是错误的。正确的答案是视情况而定。例如,这里有两个遍历树的 C 函数。首先是递归的:

static
void mm_scan_black(mm_rc *m, ptr p) {
    SET_COL(p, COL_BLACK);
    P_FOR_EACH_CHILD(p, {
        INC_RC(p_child);
        if (GET_COL(p_child) != COL_BLACK) {
            mm_scan_black(m, p_child);
        }
    });
}

这是使用迭代实现的相同功能:

static
void mm_scan_black(mm_rc *m, ptr p) {
    stack *st = m->black_stack;
    SET_COL(p, COL_BLACK);
    st_push(st, p);
    while (st->used != 0) {
        p = st_pop(st);
        P_FOR_EACH_CHILD(p, {
            INC_RC(p_child);
            if (GET_COL(p_child) != COL_BLACK) {
                SET_COL(p_child, COL_BLACK);
                st_push(st, p_child);
            }
        });
    }
}

了解代码的细节并不重要。只是 p 是节点,而 P_FOR_EACH_CHILD 负责行走。在迭代版本中,我们需要一个显式堆栈 st,将节点推送到该堆栈上,然后弹出和操作。

递归函数比迭代函数运行得快得多。原因是因为在后者中,对于每个项目,函数 st_push 需要一个 CALL,然后 st_pop 需要另一个。

在前者中,您只有每个节点的递归 CALL

另外,访问调用堆栈上的变量非常快。这意味着您正在从可能始终位于最里面的缓存中的内存中读取。另一方面,显式堆栈必须由堆中的 malloc:ed 内存支持,访问速度要慢得多。

通过仔细优化,例如内联 st_pushst_pop,我可以大致达到与递归方法相当的水平。但至少在我的电脑上,访问堆内存的成本大于递归调用的成本。

但是这个讨论主要是没有实际意义的,因为递归树行走是不正确的。如果您有足够大的树,您将用完调用堆栈空间,这就是必须使用迭代算法的原因。


我可以确认我遇到了类似的情况,并且在某些情况下递归可以比堆上的手动堆栈更快。尤其是在编译器中打开优化以避免调用函数的一些开销时。
对 7 节点二叉树进行了 10 ^ 8 次的前序遍历。递归 25ns。显式堆栈(是否经过边界检查——没有太大区别)~ 15ns。除了推动和跳跃之外,递归还需要做更多的事情(寄存器保存和恢复+(通常)更严格的帧对齐)。 (而且动态链接库中的 PLT 会变得更糟。)您不需要堆分配显式堆栈。您可以做一个第一帧在常规调用堆栈上的 obstack,这样您就不会在不超过第一个块的最常见情况下牺牲缓存局部性。
感谢您的回答。我在 leet 代码树比较中遇到了这个问题,无法弄清楚为什么我的迭代解决方案比 95% 的递归解决方案慢。堆栈内存和多次调用很有意义,特别是因为我使用的是带有混淆内存管理的 Java。
也许这里最尴尬的事实是,在(纯)C 中,您无法确定何时会溢出调用堆栈。一个更荒谬的事实是符合 ISO C 的任意小堆栈,这实际上意味着即使来自 main 的 argc 也可以在没有任何 C 函数调用的情况下溢出!尽管这在实践中不应该出现这种情况,但它表明有关调用堆栈的任何限制已经涉及足够的实现细节。所以,在纯 C 的意义上,我会忽略这些限制,两种算法都应该是正确的,而不需要更详细的实现环境要求。
P
Patrick Schlüter

这里的大多数答案都忘记了为什么递归通常比迭代解决方案慢的明显罪魁祸首。它与堆栈帧的建立和拆除有关,但并非完全如此。每次递归的自动变量的存储通常有很大的不同。在带有循环的迭代算法中,变量通常保存在寄存器中,即使它们溢出,它们也会驻留在 1 级缓存中。在递归算法中,变量的所有中间状态都存储在堆栈中,这意味着它们将产生更多的内存溢出。这意味着即使它进行相同数量的操作,它也会在热循环中进行大量内存访问,更糟糕的是,这些内存操作的重用率很差,从而导致缓存效率降低。

TL;DR 递归算法通常比迭代算法具有更差的缓存行为。


C
Community

一般来说,不,在任何具有两种形式的可行实现的实际用法中,递归都不会比循环快。我的意思是,当然,您可以编写需要很长时间的循环,但是会有更好的方法来实现相同的循环,通过递归可以胜过相同问题的任何实现。

关于原因,您一针见血;创建和销毁堆栈帧比简单的跳转更昂贵。

但是,请注意我说过“两种形式都有可行的实现”。对于像许多排序算法这样的事情,由于产生了本质上是流程一部分的子“任务”,因此往往没有一种非常可行的方式来实现它们,它不能有效地设置自己的堆栈版本。因此,递归可能与尝试通过循环实现算法一样快。

编辑:这个答案假设非功能语言,其中大多数基本数据类型是可变的。它不适用于函数式语言。


这也是为什么在经常使用递归的语言中,编译器通常会优化几种递归情况。例如,在 F# 中,除了使用 .tail 操作码完全支持尾递归函数外,您还经常看到将递归函数编译为循环。
是的。尾递归有时可能是两全其美 - 实现递归任务的功能“适当”方式,以及使用循环的性能。
这通常是不正确的。在某些环境中,突变(与 GC 交互)比尾递归更昂贵,尾递归在输出中转换为更简单的循环,不使用额外的堆栈帧。
K
Kilian Foth

在任何实际系统中,不,创建堆栈帧总是比 INC 和 JMP 更昂贵。这就是为什么真正好的编译器会自动将尾递归转换为对同一帧的调用,即没有开销,因此您可以获得更易读的源代码版本和更高效的编译版本。一个非常非常好的编译器甚至应该能够在可能的情况下将普通递归转换为尾递归。


e
eaorak

函数式编程更多的是关于“什么”而不是“如何”。

如果我们不尝试使其比需要的更优化,语言实现者将找到一种方法来优化代码在底层的工作方式。递归也可以在支持尾调用优化的语言中进行优化。

从程序员的角度来看,更重要的是可读性和可维护性,而不是优化。同样,“过早的优化是万恶之源”。


R
Roman A. Taycher

这是一个猜测。一般来说,如果两者都使用非常好的算法(不计算实现难度),递归可能不会经常或永远不会击败循环,如果两者都使用非常好的算法(不计算实现难度),如果使用带有 tail call recursion 的语言(和尾递归算法)可能会有所不同并且循环也作为语言的一部分)——这可能非常相似,有时甚至可能更喜欢递归。


H
Hydrophis Spiralis

根据理论,它是相同的东西。具有相同 O() 复杂度的递归和循环将以相同的理论速度工作,但实际速度当然取决于语言、编译器和处理器。具有数字幂的示例可以使用 O(ln(n)) 以迭代方式编码:

  int power(int t, int k) {
  int res = 1;
  while (k) {
    if (k & 1) res *= t;
    t *= t;
    k >>= 1;
  }
  return res;
  }

大 O 与“成比例”。所以两者都是 O(n),但对于所有 n,一个可能花费的时间是另一个的 x 倍。