关于性能:递归是否比循环更快?

Is recursion ever faster than looping?

我知道递归有时候比循环要干净得多,我不想问什么时候应该在迭代中使用递归,我知道已经有很多问题了。

我要问的是,递归是否比循环更快?在我看来,您总是能够优化一个循环,使它比递归函数执行得更快,因为这个循环是不存在的,不断地设置新的堆栈帧。

我专门研究递归在应用程序中是否更快,在这些应用程序中递归是处理数据的正确方法,例如在某些排序函数、二进制树等中。


(P)这取决于所使用的语言。You wrote'language-agnostic,so I'll give some examples.(p)(P)在Java,C,and Python,Reversion is fairly expensive compared to iteration(in general)because it requires the allocation of a new stack frame.In some C compilers,one can use a compiler flag to eliminate this overhead,which transforms certain types of returnion(actually,certain types of tail calls)in to jumps instead of function calls.(p)(P)在功能性方案编制语言实施中,有时,迭代可以是很高的期望和回报率。In many,returnion is transformed into a simple jump,but changing the loop variable(which is mutable)sometimes requires some relatively heavy operations,especially on implementations which support multiple threads of execution.Mutation is expensive in some of these environments because of the interaction between the Mutator and the Garbage collector,if both might be running at the same time.(p)(P)I know that in some scheme implementations,reversion will generally be faster than looping.(p)(P)在短期内,答案取决于《守则》和执行情况。用你喜欢的Whatever style.如果你使用的是一种功能语言,可能会很快。如果你使用一种强制性语言,那么迭代就很可能很快。In some environments,both methods will result in the same Assembly being generatiated(put that in your pipe and smoke it).(p)(P)Addendum:In some environments,the best alternative is neither returnion nor iteration but inside higher order functions.These include"map","filter",and"reduce"(which is also called"fold").并非仅此而已,它们本身便是有益的,但在某些环境中,这些功能是首先(或仅)从自动平行处理-&NBS获得一个繁荣的功能;因此,它们可能比这一迭代或递归更为重要。Data parallel Haskell is an example of such an environment.(p)(P)List comprehensions are another alternative,but these are usually just syntactic sugar for iteration,returnion,or higher order functions.(p)


is recursion ever faster than a loop?

Ok.

不,迭代总是比递归快。(冯·诺依曼建筑)好的。说明:

如果从零开始构建通用计算机的最小操作,"迭代"首先作为构建块出现,并且比"递归"占用的资源更少,那么遍历速度更快。好的。从头开始构建虚拟计算机:

问自己:你需要什么来计算一个值,也就是说,遵循一个算法并得到一个结果?好的。

我们将建立一个概念层次结构,从零开始,首先定义基本的核心概念,然后用这些概念构建二级概念,等等。好的。

  • 第一个概念:存储单元,存储,状态。要做一些事情,您需要存储最终和中间结果值的位置。假设我们有一个无限数组的"整数"单元,称为内存,m[0..infinite]。好的。

  • 说明:做点什么-转换单元格,更改其值。改变状态。每个有趣的指令都执行转换。基本说明如下:好的。

    a)设置和移动内存单元好的。

    • 将值存储到内存中,例如:存储5 m[4]
    • 将值复制到另一个位置:例如:存储m[4]m[8]

    b)逻辑和算术好的。

    • 还有,或者,XOR,不是
    • 添加,SUB,MUL,分区,例如添加m[7]m[8]
  • 执行代理:现代CPU中的核心。"代理"是可以执行指令的东西。代理也可以是纸面上遵循算法的人。好的。

  • 步骤顺序:一系列指令:即:先做这件事,后做这件事,等等。一系列指令的命令。甚至一行表达式也是"指令的命令序列"。如果有一个表达式具有特定的"计算顺序",那么就有步骤。这意味着,即使是单个组合表达式也具有隐式"步骤",而且还具有隐式局部变量(我们称之为"结果")。例如。:好的。

    1
    2
    3
    4 + 3 * 2 - 5
    (- (+ (* 3 2) 4 ) 5)
    (sub (add (mul 3 2) 4 ) 5)

    上面的表达式包含3个步骤,其中包含一个隐式的"result"变量。好的。

    1
    2
    3
    4
    5
    // pseudocode

           1. result = (mul 3 2)
           2. result = (add 4 result)
           3. result = (sub result 5)

    因此,即使中缀表达式,由于具有特定的计算顺序,也是指令的命令序列。表达式意味着要按特定的顺序执行一系列操作,并且由于存在步骤,因此还存在一个隐式的"结果"中间变量。好的。

  • 指令指针:如果你有一系列的步骤,你也有一个隐式的"指令指针"。指令指针标记下一条指令,并在指令读取后但执行前前进。好的。

    在这个伪计算机器中,指令指针是内存的一部分。(注:通常,指令指针将是CPU核心中的"特殊寄存器",但在这里,我们将简化概念,并假设所有数据(包括寄存器)都是"内存"的一部分。)好的。

  • 跳转-一旦您有了有序的步骤数和指令指针,就可以应用"存储"指令来更改指令指针本身的值。我们将用一个新名称来调用store指令的这种特定用法:jump。我们使用一个新的名字,因为它更容易被认为是一个新的概念。通过更改指令指针,我们将指示代理"转到步骤x"。好的。

  • 无限迭代:通过向后跳,现在可以让代理"重复"一定数量的步骤。在这一点上,我们有无限的迭代。好的。

    1
    2
    3
                       1. mov 1000 m[30]
                       2. sub m[30] 1
                       3. jmp-to 2  // infinite loop
  • 条件性-指令的条件性执行。使用"Conditional"子句,可以根据当前状态有条件地执行多条指令中的一条(可以使用以前的指令进行设置)。好的。

  • 正确的迭代:现在有了条件子句,我们就可以摆脱向后跳转指令的无限循环。我们现在有一个条件循环,然后是适当的迭代好的。

    1
    2
    3
    4
    5
    6
    7
    1. mov 1000 m[30]
    2. sub m[30] 1
    3. (if not-zero) jump 2  // jump only if the previous
                            // sub instruction did not result in 0

    // this loop will be repeated 1000 times
    // here we have proper ***iteration***, a conditional loop.

  • 命名:为保存数据或步骤的特定内存位置命名。这只是一种"便利"。我们没有通过为内存位置定义"名称"来添加任何新的指令。"命名"不是给代理商的指示,只是给我们带来方便。命名使代码(此时)更容易阅读和更改。好的。

    1
    2
    3
    4
    5
       #define counter m[30]   // name a memory location
       mov 1000 counter
    loop:                      // name a instruction pointer location
        sub counter 1
        (if not-zero) jmp-to loop
  • 一级子程序:假设您需要经常执行一系列步骤。您可以将步骤存储在内存中的指定位置,然后在需要执行步骤时跳到该位置(调用)。在序列的末尾,您需要返回到调用点以继续执行。使用这种机制,您将通过编写核心指令来创建新的指令(子例程)。好的。

    实施:(无需新概念)好的。

    • 将当前指令指针存储在预定义的内存位置
    • 跳转到子例程
    • 在子例程的末尾,您可以从预定义的内存位置检索指令指针,从而有效地跳转回原始调用的以下指令

    一级实现的问题:不能从子例程调用另一个子例程。如果这样做,将覆盖返回的地址(全局变量),因此不能嵌套调用。好的。

    为了更好地实现子例程:您需要一个堆栈好的。

  • 堆栈:将内存空间定义为"堆栈",可以"推送"堆栈上的值,也可以"弹出"最后一个"推送"值。要实现堆栈,您需要一个堆栈指针(类似于指令指针),该指针指向堆栈的实际"头"。当您"推"一个值时,堆栈指针将递减,并存储该值。当您"弹出"时,在实际堆栈指针处得到值,然后堆栈指针递增。好的。

  • 子例程既然有了堆栈,就可以实现允许嵌套调用的适当子例程。实现是类似的,但是我们不将指令指针存储在预先定义的内存位置,而是"推送"堆栈中IP的值。在子例程的末尾,我们只"弹出"堆栈中的值,有效地在原始调用后跳转回指令。此实现具有"堆栈"允许从另一个子例程调用子例程。通过这种实现,我们可以在将新指令定义为子例程时,通过使用核心指令或其他子例程作为构建块来创建多个抽象级别。好的。

  • 递归:当子例程调用自身时会发生什么?。这称为"递归"。好的。

    问题:覆盖本地中间结果,子例程可以存储在内存中。由于您正在调用/重用相同的步骤,如果中间结果存储在预定义的内存位置(全局变量)中,那么它们将在嵌套调用中被覆盖。好的。

    解决方案:为了允许递归,子例程应该将本地中间结果存储在堆栈中,因此,在每次递归调用(直接或间接)时,中间结果存储在不同的内存位置。好的。

  • …好的。

    到达递归之后,我们在这里停止。好的。结论:

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

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

    • 当您处理简单、连续的数据结构时,您应该使用"迭代",并且在任何地方都可以使用"简单循环"。好的。

    • 当需要处理递归数据结构(我喜欢称之为"分形数据结构")或者当递归解决方案明显更"优雅"时,应该使用"递归"。好的。

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

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


    如果另一种方法是显式地管理堆栈,递归可能会更快,就像在您提到的排序或二进制树算法中一样。

    我有一个例子,在Java中重写递归算法使它变慢。

    因此,正确的方法是首先以最自然的方式编写它,只有当分析显示它是关键的时候才进行优化,然后度量假定的改进。


    考虑一下每个迭代和递归都必须做什么。

    • 迭代:跳到循环的开始
    • 递归:跳转到被调用函数的开头

    你看这里没有太大的差异空间。

    (我假设递归是一个尾部调用,编译器知道这种优化)。


    (P)Tail reversion is as fast as looping.许多功能性语言在这些语言中得到了应用。(p)


    这里的大多数答案都忘记了递归通常比迭代解慢的明显原因。它与堆栈帧的建立和分解相关联,但并非如此。对于每个递归,自动变量的存储通常都有很大的不同。在具有循环的迭代算法中,变量通常保存在寄存器中,即使它们溢出,它们也将驻留在一级缓存中。在递归算法中,变量的所有中间状态都存储在堆栈中,这意味着它们将导致更多的溢出到内存中。这意味着,即使它执行相同数量的操作,在热循环中也会有大量的内存访问,更糟的是,这些内存操作的重用率很低,使缓存的效率降低。

    与迭代算法相比,dr递归算法通常具有更糟糕的缓存行为。


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

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    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);
            }
        });
    }

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

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    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,将节点推到上面,然后弹出并操作。

    递归函数的运行速度比迭代函数快得多。原因是,在后者中,每个项目都需要一个CALLst_push的函数,然后另一个到st_pop的函数。

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

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

    通过仔细的优化,比如内联st_pushst_pop,我可以与递归方法大致达到对等。但至少在我的计算机上,访问堆内存的成本大于递归调用的成本。

    但这种讨论大多是没有意义的,因为递归树遍历是不正确的。如果有足够大的树,那么调用堆栈空间就会耗尽,这就是为什么必须使用迭代算法的原因。


    (P)在任何现实系统中,不,创造一个Stack Frame将始终比一家公司和一个JMP更为期待。That's why really good compilers automatically transform tail returnion into a call to the same frame,I.E.without the overhead,so you get the more readable source version and the more efficient compiled version.一个真正的好竞争者甚至应该能够把正常的退缩变为可能的退缩。(p)


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

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

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


    (P)总的来说,不应在任何实际可行的方式中,倒退将不会超过一个周期。I mean,sure,you could code up loops that take forever,but there would be better ways to implement the same loop that could outperform any implementation of the same problem through returnion.(p)(P)You hit the nail on the head regarding the reason;creating and destroying stack frames is more expensive than a simple jump.(p)(P)However,do note that I said"Have possible implementations in both forms".对于许多人对算法感到不满的情况,存在着这样的倾向,即执行这些算法的一种非常可行的方式并不一定有效地完成其本身版本的一个斯塔克,它对儿童的空间"任务"是不适宜的。Thus,returnion may be just as fast as attempting to implement the算法through looping.(p)Edit:This answer is assuming non-functional languages,where most basic data types are mutable.它不适用于功能语言。


    从理论上讲,这是同样的事情。具有相同o()复杂性的递归和循环将以相同的理论速度工作,但实际速度当然取决于语言、编译器和处理器。具有数字幂的例子可以用o(ln(n))迭代方式编码:

    1
    2
    3
    4
    5
    6
    7
    8
    9
      int power(int t, int k) {
      int res = 1;
      while (k) {
        if (k & 1) res *= t;
        t *= t;
        k >>= 1;
      }
      return res;
      }


    这是一个猜测。一般来说,递归可能不会经常或曾经在规模相当大的问题上击败循环,如果两者都使用非常好的算法(不计算实现的难度),如果与一种语言w/tail调用递归(和tail递归算法以及循环也作为语言的一部分)一起使用,则可能会有所不同,这可能会非常困难。有时甚至更喜欢递归。