我知道递归有时比循环要干净得多,并且我什么时候不应该在迭代中使用递归就没有任何疑问,我知道已经有很多问题了。
我要问的是,递归是否比循环快?在我看来,与循环函数相比,您总是能够完善一个循环并使其执行得更快,因为没有循环不断地建立新的堆栈框架。
我专门在寻找在应用程序中正确使用递归的应用程序中递归是否更快,例如某些排序函数,二进制树等。
我知道递归有时比循环要干净得多,并且我什么时候不应该在迭代中使用递归就没有任何疑问,我知道已经有很多问题了。
我要问的是,递归是否比循环快?在我看来,与循环函数相比,您总是能够完善一个循环并使其执行得更快,因为没有循环不断地建立新的堆栈框架。
我专门在寻找在应用程序中正确使用递归的应用程序中递归是否更快,例如某些排序函数,二进制树等。
Answers:
这取决于所使用的语言。您写了“与语言无关”,因此我将举一些例子。
在Java,C和Python中,与迭代相比,递归的开销相当大(通常),因为它需要分配新的堆栈框架。在某些C编译器中,可以使用编译器标志消除这种开销,该开销将某些类型的递归(实际上是某些类型的尾部调用)转换为跳转而不是函数调用。
在函数式编程语言实现中,有时迭代可能会非常昂贵,而递归可能会非常便宜。在许多情况下,递归转换为简单的跳转,但是更改循环变量(可变的)有时需要进行一些相对繁重的操作,尤其是在支持多个执行线程的实现上。在某些这样的环境中,如果变量和垃圾回收器可能同时运行,则由于它们之间的交互作用,导致突变的代价很高。
我知道在某些Scheme实现中,递归通常比循环快。
简而言之,答案取决于代码和实现。使用您喜欢的任何样式。如果您使用的是功能语言,则递归可能会更快。如果您使用命令式语言,则迭代速度可能会更快。在某些环境中,这两种方法都会导致生成相同的装配(将其放入管道中并抽烟)。
附录:在某些环境中,最好的选择既不是递归也不是迭代,而是高阶函数。这些包括“地图”,“过滤器”和“减少”(也称为“折叠”)。这些功能不仅是首选的样式,而且不仅通常更干净,而且在某些环境中,这些功能是第一个(或唯一)从自动并行化中获得提升的功能-因此,它们可以比迭代或递归快得多。数据并行Haskell是这种环境的一个示例。
列表理解是另一种选择,但是它们通常只是用于迭代,递归或更高阶函数的语法糖。
递归比循环快吗?
不,迭代永远比递归快。(在冯·诺依曼架构中)
如果您从头开始构建通用计算机的最少操作,则“迭代”首先作为构建块,并且比“递归”占用的资源更少,因此ergo更快。
问自己:您需要什么来计算值,即遵循算法并得出结果?
我们将建立概念的层次结构,从头开始,首先定义基本的核心概念,然后使用这些概念构建第二级概念,依此类推。
第一个概念:存储单元,存储,状态。为此,您需要放置最终和中间结果值的位置。假设我们有一个无限的“整数”单元格数组,称为Memory,M [0..Infinite]。
说明:做某事-变换单元格,更改其值。改变状态。每个有趣的指令都会执行一个转换。基本说明是:
a)设置和移动存储单元
b)逻辑与算术
执行代理:现代CPU中的核心。“代理”是可以执行指令的东西。一个代理,也可以在纸上的算法以下的人。
步骤顺序:一系列指令:即:首先执行,之后执行,依此类推。即使是一行表达式也是“命令的命令性序列”。如果您有一个带有特定“评估顺序”的表达式,那么您就有步骤。这意味着甚至单个组成的表达式也具有隐式的“步骤”,也具有隐式的局部变量(我们称其为“结果”)。例如:
4 + 3 * 2 - 5
(- (+ (* 3 2) 4 ) 5)
(sub (add (mul 3 2) 4 ) 5)
上面的表达式包含3个带有隐式“结果”变量的步骤。
// pseudocode
1. result = (mul 3 2)
2. result = (add 4 result)
3. result = (sub result 5)
因此,由于您具有特定的求值顺序,所以即使是infix表达式也是命令的必要命令。该表达式表示要按特定顺序执行的一系列操作,并且由于存在步骤,因此还存在一个隐式“结果”中间变量。
指令指针:如果有一系列步骤,则还有一个隐式的“指令指针”。指令指针标记下一条指令,并在读取指令之后但在执行指令之前前进。
在此伪计算机中,指令指针是Memory的一部分。(注意:通常,指令指针将是CPU内核中的“特殊寄存器”,但是在这里,我们将简化概念并假定所有数据(包括寄存器)都是“内存”的一部分)
跳转 -一旦您获得了预定数量的步骤和一个指令指针,就可以应用“ store ”指令来更改指令指针本身的值。我们将使用新名称调用存储指令的这种特定用法:Jump。我们使用新名称是因为更容易将其视为新概念。通过更改指令指针,我们指示代理“转到步骤x”。
无限迭代:现在,通过跳回,您可以使代理“重复”一定数量的步骤。在这一点上,我们有无限的迭代。
1. mov 1000 m[30]
2. sub m[30] 1
3. jmp-to 2 // infinite loop
有条件的 -有条件地执行指令。使用“条件”子句,您可以基于当前状态(可以使用上一条指令设置)有条件地执行几条指令之一。
正确的迭代:现在有了条件子句,我们可以逃避跳转指令的无限循环。现在我们有一个条件循环,然后进行适当的迭代
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.
命名:为保存数据或保存步骤的特定存储位置命名。这只是一个“便利”。我们没有通过定义存储器位置的“名称”来添加任何新指令。“命名”不是对代理的指示,对我们来说只是一种方便。命名使代码(此时)更易于阅读和更改。
#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重写递归算法使它变慢的情况。
因此,正确的方法是首先以最自然的方式编写它,仅在概要分析显示它很关键时才进行优化,然后评估所谓的改进。
尾递归与循环一样快。许多功能语言都实现了尾递归。
考虑每次迭代和递归绝对必须执行的操作。
您会发现这里没有太大的差异空间。
(我认为递归是一个尾部调用,而编译器知道该优化)。
这里的大多数答案都是错误的。正确的答案取决于情况。例如,这里有两个遍历树的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
将节点压入该堆栈,然后弹出并对其进行操作。
递归函数的运行速度比迭代函数快得多。原因是因为在后者中,对于每个项目,都需要一个CALL
功能st_push
,然后再另一个st_pop
。
在前者中,CALL
每个节点只有递归。
另外,在调用堆栈上访问变量的速度非常快。这意味着您正在从内存中读取数据,而该内存可能始终位于最内部的缓存中。另一方面,显式堆栈必须由malloc
堆中的:ed内存支持,这要慢得多。
通过精心优化,例如inline st_push
和st_pop
,我可以与递归方法大致达到同等水平。但是至少在我的计算机上,访问堆内存的成本大于递归调用的成本。
但是,由于递归树遍历是不正确的,因此此讨论主要是没有意义的。如果您有足够大的树,则将耗尽调用栈空间,这就是为什么必须使用迭代算法的原因。
通常,不行,递归不会比在两种形式下都可行的现实使用中的循环快。我的意思是,当然,您可以编写永久性的循环,但是会有更好的方法来实现相同的循环,该循环可能会通过递归方式胜过对相同问题的任何实现。
关于原因,您打在了头上。创建和销毁堆栈帧比简单的跳转要昂贵得多。
但是,请注意,我说过“两种形式都可行”。对于诸如排序算法之类的事情,由于子“任务”的产生是进程固有的一部分,因此往往没有一种可行的方法来实现它们而无法有效地建立自己的堆栈版本。因此,递归可能与尝试通过循环实现算法一样快。
在任何现实的系统中,没有,创建堆栈框架总是比INC和JMP昂贵。这就是为什么真正优秀的编译器会自动将尾递归转换为对同一帧的调用,即没有开销,因此您获得了更具可读性的源版本和更高效的编译版本。一个非常非常好的编译器甚至应该能够将普通递归转换为尾递归。
根据理论,它是一样的东西。具有相同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(n)
,但对于所有人而言,一个人可能x
要比另一人花费更长的时间n
。