我知道递归有时比循环更简洁,而且我不问什么时候应该使用递归而不是迭代,因为已经有很多关于这个问题的答案了。
我想问的是,在任何情况下,递归是否比循环更快?在我看来,你总是能够改进循环并使其比递归函数更快,因为循环不需要不断地设置新的堆栈帧。
我特别想知道,在处理数据的正确方式是递归的应用程序中,例如某些排序函数、二叉树等,递归是否更快。
我知道递归有时比循环更简洁,而且我不问什么时候应该使用递归而不是迭代,因为已经有很多关于这个问题的答案了。
我想问的是,在任何情况下,递归是否比循环更快?在我看来,你总是能够改进循环并使其比递归函数更快,因为循环不需要不断地设置新的堆栈帧。
我特别想知道,在处理数据的正确方式是递归的应用程序中,例如某些排序函数、二叉树等,递归是否更快。
递归比循环更快吗?
不是的,(在冯·诺依曼架构中)迭代总是比递归快。
如果你从头开始构建一个通用计算机的最小操作,“迭代”是第一个构建块,且比“递归”所需资源少,因此更快。
反问自己:你需要什么才能计算出一个值,即按照算法并达到结果?
我们将建立一个概念层次结构,从头开始定义基本的核心概念,然后用这些概念构建第二层概念,以此类推。
第一概念:存储器单元、存储、状态。要执行某项任务,需要位置来存储最终和中间结果值。假设我们有一个无穷的整数单元数组,称为存储器,M [0..Infinite]。
指令:执行某个操作——转换一个单元,改变其值。改变状态。每个有趣的指令都执行一种转换。基本指令包括:
a)存储和移动存储单元
b)逻辑和算术
执行代理:现代CPU中的核心。一个“代理”是可以执行指令的东西。一个代理也可以是一个人在纸上按照算法进行操作。
步骤顺序:即:先做这个,再做那个等。有着命令式指令序列。即使是一个表达式也是“命令式指令序列”。如果你有一个带有特定“求值顺序”的表达式,那么你就有了步骤。这意味着一个组合表达式即使有隐含的“步骤”,也有一个隐含的局部变量(让我们称之为“结果”)。例如:
4 + 3 * 2 - 5
(- (+ (* 3 2) 4 ) 5)
(sub (add (mul 3 2) 4 ) 5)
上述表达式暗示了三个步骤,其中有一个隐含的“result”变量。
// pseudocode
1. result = (mul 3 2)
2. result = (add 4 result)
3. result = (sub result 5)
即使是中缀表达式,由于你有一个特定的评估顺序,因此它们是一系列命令的命令序列。表达式意味着要按特定顺序执行一系列操作,因为有步骤,所以还存在一个隐含的“结果”中间变量。
指令指针:如果您有一系列步骤,则还有一个隐含的“指令指针”。指令指针标记下一条指令,并在读取指令但在执行指令之前推进。
在这个伪计算机中,指令指针是存储器的一部分。(注意:通常情况下,指令指针将是CPU核心中的“特殊寄存器”,但在这里我们将简化概念,并假设所有数据(包括寄存器)都是“存储器”的一部分)
跳跃 - 一旦您有一定顺序的步骤和一个指令指针,您可以应用“存储”指令来改变指令指针本身的值。我们将使用一个新名称来称呼这个存储指令的特定用途:跳跃。我们使用一个新名称是因为更容易将其视为新概念。通过改变指令指针,我们正在指示代理“去到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
,将节点压入堆栈,然后弹出并操作。
递归函数比迭代函数快得多。原因是因为在后者中,对于每个项,都需要调用函数 st_push
,然后再调用 st_pop
。
在前者中,你只需为每个节点进行递归调用。
此外,访问调用堆栈上的变量非常快。这意味着你正在从内存中读取,该内存很可能始终在最内部的缓存中。另一方面,显式堆栈必须由堆中的 malloc
分配的内存支持,访问速度要慢得多。
通过仔细优化,如内联 st_push
和 st_pop
,我可以达到与递归方法大致相同的效果。但至少在我的电脑上,访问堆内存的代价比递归调用的代价更大。
但这次讨论大多无意义,因为递归遍历树是 不正确的。如果你有一个足够大的树,你将用尽调用堆栈空间,这就是必须使用迭代算法的原因。
尾递归 和循环一样快。许多函数式编程语言都已经在它们中实现了尾递归。
这里的大多数答案都忽略了递归通常比迭代解决方案慢的明显原因。它与堆栈帧的建立和撤销有关,但并非完全如此。通常是每个递归的自动变量存储方式存在巨大差异。在具有循环的迭代算法中,变量通常存储在寄存器中,即使溢出,它们也会驻留在一级缓存中。在递归算法中,变量的所有中间状态都存储在堆栈上,这意味着它们将生成更多的内存溢出。这意味着即使执行相同数量的操作,它在热循环中将有很多内存访问,并且更糟糕的是,这些内存操作的重用率很低,使高速缓存的效果不佳。
简而言之,递归算法通常具有比迭代算法更差的高速缓存行为。
考虑每个迭代和递归必须完成的任务。
你会发现这里并没有太多差异。
(我假设递归是尾调用,并且编译器意识到了这种优化。)
在任何实际的系统中,创建堆栈帧的成本总是比INC和JMP更高。这就是为什么真正好的编译器会自动将尾递归转换为对同一帧的调用,即无需开销,因此您可以获得更可读的源版本和更有效的编译版本。一个非常优秀的编译器甚至应该能够将普通递归转换为尾递归,如果可能的话。
函数式编程更关注"什么"而不是"如何"。
如果我们不试图使代码比必须更优化,语言实现者会找到一种优化代码工作方式的方法。在支持尾调用优化的语言中,递归也可以进行优化。
从程序员的角度来看,可读性和可维护性比优化更重要。再次强调,“过早优化是万恶之源”。