有时使用递归比使用循环更好,而使用循环比使用递归更好。选择“正确”的代码可以节省资源和/或减少代码行数。
在任何情况下,只能使用递归而不是循环来完成任务吗?
INC (r)
,JZDEC (r, z)
可以实现图灵机。它没有“递归”-如果为零则递归。如果Ackermann函数是可计算的(是),那么该注册机就可以执行。
有时使用递归比使用循环更好,而使用循环比使用递归更好。选择“正确”的代码可以节省资源和/或减少代码行数。
在任何情况下,只能使用递归而不是循环来完成任务吗?
INC (r)
,JZDEC (r, z)
可以实现图灵机。它没有“递归”-如果为零则递归。如果Ackermann函数是可计算的(是),那么该注册机就可以执行。
Answers:
是的,没有。最终,没有任何递归可以计算出循环无法做到的事情,但是循环需要花费更多的精力。因此,递归可以使循环做不到的一件事就是使某些任务变得非常容易。
走一棵树。递归地走树很愚蠢。这是世界上最自然的事情。用循环走一棵树要简单得多。您必须维护一个堆栈或其他数据结构来跟踪您所做的事情。
通常,问题的递归解决方案更漂亮。这是一个技术术语,很重要。
A
在树中找到某些东西的递归函数。每次A
遇到该事物时,它都会启动另一个递归函数B
,该函数在子树中由其启动的位置中找到一个相关事物A
。一旦B
完成它返回到递归A
,而后者继续自己的递归。有人可能会宣布一个栈A
,一个用于B
,或将B
堆内A
循环。如果坚持使用单个堆栈,事情就会变得非常复杂。
Therefore, the one thing recursion can do that loops can't is make some tasks super easy.
循环可以做到递归不能做的一件事就是使某些任务变得非常容易。您是否看到过将最自然的迭代问题从朴素的递归转换为尾递归所必须执行的丑陋,不直观的操作,以免它们使堆栈崩溃?
map
或的递归运算符中fold
(实际上,如果您选择将它们视为原语,我想您可以使用fold
/ unfold
作为循环或递归的第三种替代方法)。除非您正在编写库代码,否则在很多情况下,您不必担心迭代的实现,而不必担心应该完成的任务-在实践中,这意味着显式循环和显式递归都同样糟糕在顶层应该避免的抽象。
没有。
越来越下降到非常必要的最小值的基础知识,以便计算,你只需要能够循环(仅此是不够的,而是一个必要组成部分)。不要紧如何。
任何可以实现Turing Machine的编程语言都称为Turing complete。并且有很多语言正在完善中。
我最喜欢的语言是“有效吗?” 图灵完全是,FRACTRAN,这是图灵完备。它具有一个循环结构,您可以在其中实现图灵机。因此,任何可计算的内容都可以用没有递归的语言来实现。因此,就简单循环无法实现的可计算性而言,递归没有给您带来任何好处。
这实际上可以归结为以下几点:
这并不是说有些问题类更容易通过递归而不是循环来考虑,或者通过循环而不是递归来考虑。但是,这些工具同样强大。
虽然我将其带到了“ esolang”极端(主要是因为您可以找到图灵完整且以相当奇怪的方式实现的事物),但这并不意味着esolangs绝对不是可选的。图灵有一个不完整的完整清单,包括聚会魔术师,Sendmail,MediaWiki模板和Scala类型系统。在实际执行任何实际操作时,其中许多都不是最佳选择,只是您可以使用这些工具计算任何可计算的内容。
当您进入一种称为尾调用的特定类型的递归时,这种等效会变得特别有趣。
假设您有一个析因方法编写为:
int fact(int n) {
return fact(n, 1);
}
int fact(int n, int accum) {
if(n == 0) { return 1; }
if(n == 1) { return accum; }
return fact(n-1, n * accum);
}
这种类型的递归将被重写为循环-不使用堆栈。与编写等效循环相比,此类方法的确通常更优雅,更易于理解,但同样,对于每个递归调用,都可以编写等效循环,对于每个循环,都可以编写递归调用。
在某些情况下,将简单循环转换为尾部调用递归调用有时会很麻烦,而且更难以理解。
如果您想进入理论方面,请参阅Church Turing论文。您也可能会发现CS.SE上的“ 教堂折磨” 论文很有用。
在任何情况下,只能使用递归而不是循环来完成任务吗?
您始终可以将递归算法转换成一个循环,该循环使用后进先出数据结构(AKA堆栈)存储临时状态,因为递归调用正是这样,将当前状态存储在堆栈中,然后进行算法处理,然后再恢复状态。如此简短的答案是:不,没有这种情况。
但是,可以将参数设为“是”。让我们举一个具体的简单示例:合并排序。您需要将数据分为两部分,对部分进行合并排序,然后将它们组合在一起。即使您没有进行实际的编程语言函数调用来进行合并排序以对部件进行合并排序,您也需要实现与实际执行函数调用相同的功能(将状态推入自己的堆栈,跳转至使用不同的启动参数开始循环,然后从堆栈中弹出状态)。
如果您自己实现递归调用,它是否是递归,作为单独的“推送状态”和“跳转至开始”和“弹出状态”步骤?答案是:不,它仍然不称为递归,它称为具有显式堆栈的迭代(如果要使用已建立的术语)。
注意,这也取决于“任务”的定义。如果任务是排序的,那么您可以使用许多算法来实现,其中许多不需要任何递归。如果任务是实现特定的算法,如merge sort,则适用上述歧义。
因此,让我们考虑一个问题,是否有一般任务,而这些任务只有类似递归的算法。从@WizardOfMenlo问题的评论中,Ackermann函数就是一个简单的例子。因此,即使可以使用不同的计算机程序构造(使用显式堆栈进行迭代)来实现,递归的概念也独立存在。
这取决于您定义“递归”的严格程度。
如果我们严格要求它包含调用堆栈(或使用任何用于维护程序状态的机制),那么我们总是可以用不包含它的东西代替它。确实,自然导致大量使用递归的语言倾向于使编译器大量使用尾调用优化,因此您编写的内容是递归的,而运行的则是迭代的。
但是,让我们考虑一下进行递归调用并将递归调用的结果用于该递归调用的情况。
public static BigInteger Ackermann(BigInteger m, BigInteger n)
{
if (m == 0)
return n+1;
if (n == 0)
return Ackermann(m - 1, 1);
else
return Ackermann(m - 1, Ackermann(m, n - 1));
}
进行第一个递归调用很容易:
public static BigInteger Ackermann(BigInteger m, BigInteger n)
{
restart:
if (m == 0)
return n+1;
if (n == 0)
{
m--;
n = 1;
goto restart;
}
else
return Ackermann(m - 1, Ackermann(m, n - 1));
}
然后,我们可以清理除去,goto
以避开速激肽和Dijkstra的阴影:
public static BigInteger Ackermann(BigInteger m, BigInteger n)
{
while(m != 0)
{
if (n == 0)
{
m--;
n = 1;
}
else
return Ackermann(m - 1, Ackermann(m, n - 1));
}
return n+1;
}
但是要删除其他递归调用,我们将必须将某些调用的值存储到堆栈中:
public static BigInteger Ackermann(BigInteger m, BigInteger n)
{
Stack<BigInteger> stack = new Stack<BigInteger>();
stack.Push(m);
while(stack.Count != 0)
{
m = stack.Pop();
if(m == 0)
n = n + 1;
else if(n == 0)
{
stack.Push(m - 1);
n = 1;
}
else
{
stack.Push(m - 1);
stack.Push(m);
--n;
}
}
return n;
}
现在,当我们考虑源代码时,我们肯定已将递归方法转换为迭代方法。
考虑到已将其编译为什么,我们已经将使用调用堆栈的代码实现了递归,将代码转换为不执行递归的代码(这样做的话,即使很小的值也将引发堆栈溢出异常的代码变成了仅花费非常长的时间才能返回[请参阅如何防止我的Ackerman函数溢出堆栈?进行一些进一步的优化,使其实际上返回更多可能的输入])。
考虑到一般如何实现递归,我们已将使用调用堆栈的代码转换为使用不同堆栈来保存挂起操作的代码。因此,我们可以认为,以较低的级别考虑时,它仍然是递归的。
在这个级别上,确实没有其他解决方法。因此,如果您确实认为该方法是递归的,那么确实有些事情离不开它。通常,尽管我们不标记此类代码递归。术语“ 递归”非常有用,因为它涵盖了某些方法集,并为我们提供了一种讨论它们的方式,并且我们不再使用其中一种方法。
当然,所有这些都假定您可以选择。既有禁止递归调用的语言,又有缺乏迭代必要的循环结构的语言。
经典答案是“否”,但请允许我详细说明为什么我认为“是”是更好的答案。
在继续之前,让我们先解决一些问题:从可计算性和复杂性的角度来看:
好吧,现在,让我们将一只脚放在实践领域,而将另一只脚放在理论领域。
调用堆栈是控制结构,而手动堆栈是数据结构。控制和数据不是等同的概念,但是从可计算性或复杂性的角度来看,它们可以彼此简化(或彼此“模拟”),在某种意义上,它们是等效的。
这种区别何时会重要?使用实际工具时。这是一个例子:
假设您正在实施N-way mergesort
。您可能有一个for
遍历每个N
段的循环,分别调用mergesort
它们,然后合并结果。
您如何将其与OpenMP并行化?
在递归领域中,这非常简单:只需将#pragma omp parallel for
循环从1扩展到N,就可以了。在迭代领域,您不能这样做。您必须手动生成线程并手动将适当的数据传递给它们,以便它们知道该怎么做。
另一方面,还有其他工具(例如自动矢量化程序#pragma vector
)可用于循环,但对于递归完全无效。
重要的是,仅仅因为您可以证明两个范例在数学上是等效的,所以这并不意味着它们在实践中是相等的。在一个范例中可能难以实现自动化的问题(例如,循环并行化)在另一个范例中可能很难解决。
因此,如果您需要使用一种工具来解决问题,则该工具很可能仅适用于一种特定的方法,因此即使您可以数学证明问题可以解决,您也将无法使用其他方法来解决问题。无论哪种方式都可以解决。
撇开理论推理,让我们从(硬件或虚拟)机器的角度看一下递归和循环是什么样的。递归是控制流的组合,该控制流允许开始执行某些代码并在完成时返回(在忽略信号和异常的情况下,在简化视图中)以及传递给该其他代码(自变量)并从中返回的数据它(结果)。通常不涉及显式的内存管理,但是会隐式分配堆栈内存以保存返回地址,参数,结果和中间本地数据。
循环是控制流和本地数据的组合。将其与递归进行比较,我们可以看到这种情况下的数据量是固定的。打破此限制的唯一方法是使用可以在需要时分配(和释放)的动态内存(也称为heap)。
总结一下:
假设控制流部分相当强大,则唯一的区别在于可用的内存类型。因此,我们剩下4种情况(表达能力列在括号中):
如果游戏规则比较严格,并且不允许使用循环递归实现,则可以使用以下代码:
与以前的方案的主要区别在于,堆栈内存不足不允许递归执行,而没有循环执行的步数比代码行多。
是。使用递归可以轻松完成一些常见任务,而使用Just循环则无法完成这些任务:
递归函数和原始递归函数之间有区别。原始递归函数是使用循环计算的,其中每个循环的最大迭代计数是在循环执行开始之前计算的。(这里的“递归”与使用递归无关)。
严格来说,原始递归函数的功能不及递归函数。如果采用使用递归的函数,则将获得相同的结果,其中必须事先计算最大递归深度。
我同意其他问题。递归不能做,循环不能做。
但是,在我看来,递归可能非常危险。首先,对于某些难以理解的代码实际发生了什么。其次,至少对于C ++(我不确定Java),每个递归步骤都会对内存产生影响,因为每个方法调用都会导致内存累积和方法标头的初始化。这样,您可以炸毁堆栈。只需尝试递归输入值较高的斐波那契数。