有没有可以用循环完成的递归操作呢?


126

有时使用递归比使用循环更好,而使用循环比使用递归更好。选择“正确”的代码可以节省资源和/或减少代码行数。

在任何情况下,只能使用递归而不是循环来完成任务吗?


13
我对此表示严重怀疑。递归是一个美化的循环。
Lightness Races in Orbit

6
如果您提供了更多的背景知识以及所追求的是什么样的答案,那么看到答案所遵循的方向各不相同(并且自己在提供更好的答案时失败了),您可能会尝试任何一种尝试帮忙的人。您是否需要理论上的假设机器证明(具有无限的存储空间和运行时间)?还是实际的例子?(在哪里“会荒唐复杂”可能被视为“无法完成”。)还是有所不同?
5gon12eder

8
@LightnessRacesinOrbit在我的非英语母语人士的耳朵上,“递归是光荣的循环”听起来你的意思是“您最好在任何地方使用循环结构而不是递归调用,并且这个概念实在不值得它自己的名字” 。那么,也许我把“光荣的东西”成语解释错了。
海德2015年

13
那阿克曼函数呢?en.wikipedia.org/wiki/Ackermann_function,它不是特别有用,但不可能通过循环来完成。(您可能还需要检查这个视频youtube.com/watch?v=i7sm9dzFtEI通过Computerphile)
WizardOfMenlo

8
@WizardOfMenlo的befunge代码是一个实施ERRE溶液(这也是一个交互式溶液...用栈)。带有堆栈的迭代方法可以模拟递归调用。在任何功能强大的编程上,都可以使用一个循环结构来模拟另一个。带有说明的注册机INC (r)JZDEC (r, z)可以实现图灵机。它没有“递归”-如果为零则递归。如果Ackermann函数是可计算的(是),那么该注册机就可以执行。

Answers:


164

是的,没有。最终,没有任何递归可以计算出循环无法做到的事情,但是循环需要花费更多的精力。因此,递归可以使循环做不到的一件事就是使某些任务变得非常容易。

走一棵树。递归地走树很愚蠢。这是世界上最自然的事情。用循环走一棵树要简单得多。您必须维护一个堆栈或其他数据结构来跟踪您所做的事情。

通常,问题的递归解决方案更漂亮。这是一个技术术语,很重要。


120
基本上,执行循环而不是递归意味着手动处理堆栈。
Silviu Burcea,2015年

15
... 堆栈。以下情况可能强烈希望具有多个堆栈。考虑一个A在树中找到某些东西的递归函数。每次A遇到该事物时,它都会启动另一个递归函数B,该函数在子树中由其启动的位置中找到一个相关事物A。一旦B完成它返回到递归A,而后者继续自己的递归。有人可能会宣布一个栈A,一个用于B,或将B堆内A循环。如果坚持使用单个堆栈,事情就会变得非常复杂。
rwong

35
Therefore, the one thing recursion can do that loops can't is make some tasks super easy. 循环可以做到递归不能做的一件事就是使某些任务变得非常容易。您是否看到过将最自然的迭代问题从朴素的递归转换为尾递归所必须执行的丑陋,不直观的操作,以免它们使堆栈崩溃?
梅森惠勒

10
@MasonWheeler 99%的时间可以将这些“事物”更好地封装在诸如map或的递归运算符中fold(实际上,如果您选择将它们视为原语,我想您可以使用fold/ unfold作为循环递归的第三种替代方法)。除非您正在编写库代码,否则在很多情况下,您不必担心迭代的实现,而不必担心应该完成的任务-在实践中,这意味着显式循环和显式递归都同样糟糕在顶层应该避免的抽象。
Leushenko

7
您可以通过递归比较子字符串来比较两个字符串,但是只是一个一个地比较每个字符,直到出现不匹配为止,这样才能更好地表现并且让读者更清楚。
史蒂芬·本纳普

78

没有。

越来越下降到非常必要的最小值的基础知识,以便计算,你只需要能够循环(仅此是不够的,而是一个必要组成部分)。不要紧如何

任何可以实现Turing Machine的编程语言都称为Turing complete。并且有很多语言正在完善中。

我最喜欢的语言是“有效吗?” 图灵完全是,FRACTRAN,这是图灵完备。它具有一个循环结构,您可以在其中实现图灵机。因此,任何可计算的内容都可以用没有递归的语言来实现。因此,就简单循环无法实现的可计算性而言,递归没有给您带来任何好处

这实际上可以归结为以下几点:

  • 任何可计算的东西都可以在图灵机上计算
  • 可以实现图灵机的任何语言(称为图灵完成),都可以计算任何其他语言可以实现的任何功能
  • 由于有些图灵机使用的语言缺乏递归功能(还有其他一些只有当您进入其他一些esolang 中时才具有递归的语言),因此对于递归您无能为力,这是不言而喻的。循环(对于递归无法完成的循环,您无能为力)。

这并不是说有些问题类更容易通过递归而不是循环来考虑,或者通过循环而不是递归来考虑。但是,这些工具同样强大。

虽然我将其带到了“ 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上的“ 教堂折磨” 论文很有用。


29
图灵完整性显得过于重要。图灵完备有很多东西(例如《聚会的魔法》),但这并不意味着它与图灵完备的其他东西一样。至少没有那么重要的水平。我不想和魔术聚会一起走到树上。
罗杰(Roger)

7
一旦您可以将问题简化为“与图灵机具有同等的功能”,就可以解决问题。图灵机的门槛相当低,但这就是所需要的。没有任何循环可以做到递归不能做到的,反之亦然。

4
这个答案中的说法当然是正确的,但我敢说这个论点并不真正令人信服。图灵机没有递归的直接概念,因此说“您可以模拟图灵机而无需递归”并不能真正证明任何东西。为了证明该陈述,您必须显示的是Turing机器可以模拟递归。如果您不显示此信息,则必须忠实地认为Church-Turing假设也适用于递归(确实如此),但OP对此表示怀疑。
5gon12eder

10
OP的问题是“可以”,而不是“最佳”或“最有效”或其他限定词。“完成转换”意味着可以通过递归完成的任何事情也可以通过循环来完成。这是否是在任何特定语言实现中做到这一点的最佳方法,则完全是另外一个问题。
史蒂芬·本纳普

7
“可以”与“最佳”完全不同。当您将“不是最好的”错误理解为“不能”时,您会变得瘫痪,因为无论您以何种方式做某事,总会有更好的方法。
史蒂芬·本纳普

31

在任何情况下,只能使用递归而不是循环来完成任务吗?

您始终可以将递归算法转换成一个循环,该循环使用后进先出数据结构(AKA堆栈)存储临时状态,因为递归调用正是这样,将当前状态存储在堆栈中,然后进行算法处理,然后再恢复状态。如此简短的答案是:不,没有这种情况

但是,可以将参数设为“是”。让我们举一个具体的简单示例:合并排序。您需要将数据分为两部分,对部分进行合并排序,然后将它们组合在一起。即使您没有进行实际的编程语言函数调用来进行合并排序以对部件进行合并排序,您也需要实现与实际执行函数调用相同的功能(将状态推入自己的堆栈,跳转至使用不同的启动参数开始循环,然后从堆栈中弹出状态)。

如果您自己实现递归调用,它是否是递归,作为单独的“推送状态”和“跳转至开始”和“弹出状态”步骤?答案是:不,它仍然不称为递归,它称为具有显式堆栈的迭代(如果要使用已建立的术语)。


注意,这也取决于“任务”的定义。如果任务是排序的,那么您可以使用许多算法来实现,其中许多不需要任何递归。如果任务是实现特定的算法,如merge sort,则适用上述歧义。

因此,让我们考虑一个问题,是否有一般任务,而这些任务只有类似递归的算法。从@WizardOfMenlo问题的评论中,Ackermann函数就是一个简单的例子。因此,即使可以使用不同的计算机程序构造(使用显式堆栈进行迭代)来实现,递归的概念也独立存在。


2
当处理无堆栈处理器的程序集时,这两种技术突然变成一种。
约书亚

@约书亚的确!这是抽象程度的问题。如果您降低一个或两个级别,那只是逻辑门。
海德

2
那不是很正确。为了通过迭代模拟递归,您需要一个可以进行随机访问的堆栈。没有随机访问加上有限数量的可直接访问的内存的单个堆栈就是PDA,这不是图灵完备的。
吉尔斯2015年

@Gilles旧帖子,但是为什么需要随机访问堆栈?而且,难道不是所有的真实计算机都比PDA还要少,因为它们只有有限数量的可直接访问的内存,而根本没有堆栈(除非使用该内存)?如果说“我们不能在现实中进行递归”,这似乎不是非常实用的抽象。
海德

20

这取决于您定义“递归”的严格程度。

如果我们严格要求它包含调用堆栈(或使用任何用于维护程序状态的机制),那么我们总是可以用不包含它的东西代替它。确实,自然导致大量使用递归的语言倾向于使编译器大量使用尾调用优化,因此您编写的内容是递归的,而运行的则是迭代的。

但是,让我们考虑一下进行递归调用并将递归调用的结果用于该递归调用的情况。

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函数溢出堆栈?进行一些进一步的优化,使其实际上返回更多可能的输入])。

考虑到一般如何实现递归,我们已将使用调用堆栈的代码转换为使用不同堆栈来保存挂起操作的代码。因此,我们可以认为,以较低的级别考虑时,它仍然是递归的。

在这个级别上,确实没有其他解决方法。因此,如果您确实认为该方法是递归的,那么确实有些事情离不开它。通常,尽管我们不标记此类代码递归。术语“ 递归”非常有用,因为它涵盖了某些方法集,并为我们提供了一种讨论它们的方式,并且我们不再使用其中一种方法。

当然,所有这些都假定您可以选择。既有禁止递归调用的语言,又有缺乏迭代必要的循环结构的语言。


如果调用堆栈是有界的,或者可以访问调用堆栈外部的无界内存,则只能用等效的东西替换调用堆栈。下推式自动机可以解决一大类问题,这些问题具有无限的调用堆栈,但否则只能具有有限数量的状态。
2015年

这是最好的答案,也许是唯一的正确答案。甚至第二个示例仍然是递归的,在这个级别上,原始问题的答案为。使用更广泛的递归定义,就无法避免Ackermann函数的递归。
gerrit 2015年

@gerrit并缩小范围,它确实避免了这种情况。最终,它归结为我们所做的或未应用我们用于某些代码的有用标签的边缘。
乔恩·汉娜

1
加入该网站以对此进行投票。Ackermann函数本质上是/ is /递归的。用循环和堆栈实现递归结构并不能使其成为迭代解决方案,您只是将递归移到了用户空间中。
亚伦·麦克米林

9

经典答案是“否”,但请允许我详细说明为什么我认为“是”是更好的答案。


在继续之前,让我们先解决一些问题:从可计算性和复杂性的角度来看:

  • 如果允许您在循环时使用辅助堆栈,则答案为“否”。
  • 如果在循环时不允许任何额外的数据,答案是“是”。

好吧,现在,让我们将一只脚放在实践领域,而将另一只脚放在理论领域。


调用堆栈是控制结构,而手动堆栈是数据结构。控制和数据不是等同的概念,但是从可计算性或复杂性的角度来看,它们可以彼此简化(或彼此“模拟”),在某种意义上,它们是等效的。

这种区别何时会重要?使用实际工具时。这是一个例子:

假设您正在实施N-way mergesort。您可能有一个for遍历每个N段的循环,分别调用mergesort它们,然后合并结果。

您如何将其与OpenMP并行化?

在递归领域中,这非常简单:只需将#pragma omp parallel for循环从1扩展到N,就可以了。在迭代领域,您不能这样做。您必须手动生成线程并手动将适当的数据传递给它们,以便它们知道该怎么做。

另一方面,还有其他工具(例如自动矢量化程序#pragma vector)可用于循环,但对于递归完全无效。

重要的是,仅仅因为您可以证明两个范例在数学上是等效的,所以这并不意味着它们在实践中是相等的。在一个范例中可能难以实现自动化的问题(例如,循环并行化)在另一个范例中可能很难解决。

即:一种范例的工具不会自动转换为其他范例。

因此,如果您需要使用一种工具来解决问题,则该工具很可能仅适用于一种特定的方法,因此即使您可以数学证明问题可以解决,您也将无法使用其他方法来解决问题。无论哪种方式都可以解决。


甚至超出此范围,请考虑使用下推式自动机可以解决的问题集合大于使用有限自动机(无论是确定性的还是非确定性的)可以解决的问题集合,但小于可以通过下式自动机解决的问题集合。图灵机。
2015年

8

撇开理论推理,让我们从(硬件或虚拟)机器的角度看一下递归和循环是什么样的。递归是控制流的组合,该控制流允许开始执行某些代码并在完成时返回(在忽略信号和异常的情况下,在简化视图中)以及传递给该其他代码(自变量)并从中返回的数据它(结果)。通常不涉及显式的内存管理,但是会隐式分配堆栈内存以保存返回地址,参数,结果和中间本地数据。

循环是控制流和本地数据的组合。将其与递归进行比较,我们可以看到这种情况下的数据量是固定的。打破此限制的唯一方法是使用可以在需要时分配(和释放)的动态内存(也称为heap)。

总结一下:

  • 递归情况=控制流+堆栈(+堆)
  • 循环情况=控制流+堆

假设控制流部分相当强大,则唯一的区别在于可用的内存类型。因此,我们剩下4种情况(表达能力列在括号中):

  1. 没有堆栈,没有堆:递归和动态结构是不可能的。(递归=循环)
  2. 堆栈,没有堆:递归可以,动态结构是不可能的。(递归>循环)
  3. 没有堆栈,堆:不可能进行递归,可以使用动态结构。(递归=循环)
  4. 堆栈,堆:递归和动态结构都可以。(递归=循环)

如果游戏规则比较严格,并且不允许使用循环递归实现,则可以使用以下代码:

  1. 没有堆栈,没有堆:递归和动态结构是不可能的。(递归<循环)
  2. 堆栈,没有堆:递归可以,动态结构是不可能的。(递归>循环)
  3. 没有堆栈,堆:不可能进行递归,可以使用动态结构。(递归<循环)
  4. 堆栈,堆:递归和动态结构都可以。(递归=循环)

与以前的方案的主要区别在于,堆栈内存不足不允许递归执行,而没有循环执行的步数比代码行多。


2

是。使用递归可以轻松完成一些常见任务,而使用Just循环则无法完成这些任务:

  • 导致堆栈溢出。
  • 完全使初学者感到困惑。
  • 创建实际上是O(n ^ n)的快速查找函数。

3
拜托,这些真的很容易使用循环,我一直都在看。哎呀,稍加努力,您甚至不需要循环。即使递归比较容易。
AviD 2015年

1
实际上,A(0,n)= n + 1; 如果m> 0,则A(m,0)= A(m-1,1); 如果m> 0,n> 0的增长速度甚至比O(n ^ n)(对于m = n)快一点,则A(m,n)= A(m-1,A(m,n-1))
约翰·唐恩

1
@JohnDonn不止一点,它是超指数的。对于n = 3 n ^ n ^ n对于n = 4 n ^ n ^ n ^ n ^ n,依此类推。n到n次幂n次。
亚伦·麦克米林

1

递归函数和原始递归函数之间有区别。原始递归函数是使用循环计算的,其中每个循环的最大迭代计数是在循环执行开始之前计算的。(这里的“递归”与使用递归无关)。

严格来说,原始递归函数的功能不及递归函数。如果采用使用递归的函数,则将获得相同的结果,其中必须事先计算最大递归深度。


3
我不确定这如何适用于上述问题?您能否使这种联系更加清楚?
Yakk 2015年

1
与“循环无限重复计数”,“有限的迭代次数循环”之间的重要区别和,我认为每个人都知道,从CS 101更换不精确的“循环”
gnasher729

可以,但是仍然不适用于这个问题。问题是关于循环和递归,而不是原始的递归和递归。试想一下,如果有人问C / C ++的差异,而您回答了K&R C和Ansi C之间的差异。当然这会使事情更精确,但是并不能回答问题。
Yakk

1

如果您使用c ++进行编程,并且使用c ++ 11,则必须使用递归完成一件事:constexpr函数。但是标准将其限制为512,如本答案所述。在这种情况下无法使用循环,因为在这种情况下该函数不能为constexpr,但是在c ++ 14中已对此进行了更改。


0
  • 如果递归调用是递归函数的第一个或最后一个语句(不包括条件检查),则很容易转换为循环结构。
  • 但是如果函数在递归调用之前和之后执行其他操作,那么将其转换为循环将很麻烦。
  • 如果该函数具有多个递归调用,则几乎不可能将其转换为仅使用循环的代码。需要一些堆栈来跟上数据。在递归中,调用栈本身将作为数据栈。

树遍历有多个递归调用(每个孩子一个),但是使用显式堆栈将它微不足道地转换成循环。另一方面,解析器通常很烦人。
CodesInChaos

@CodesInChaos编辑。
Gulshan

-6

我同意其他问题。递归不能做,循环不能做。

但是,在我看来,递归可能非常危险。首先,对于某些难以理解的代码实际发生了什么。其次,至少对于C ++(我不确定Java),每个递归步骤都会对内存产生影响,因为每个方法调用都会导致内存累积和方法标头的初始化。这样,您可以炸毁堆栈。只需尝试递归输入值较高的斐波那契数。


2
带有递归的Fibonacci数的朴素递归实现将在耗尽堆栈空间之前“用尽时间”。我猜还有其他一些问题对于此示例来说更好。另外,对于许多问题,循环版本与递归版本具有相同的内存影响,只是在堆而不是堆栈上(如果您的编程语言可以区分)。
圣保罗Ebermann

6
如果您只是忘记增加循环变量,循环也可能是“非常危险的” ...
h22

2
因此,确实,在不使用递归的情况下,故意产生堆栈溢出是一项非常棘手的任务。
5gon12eder 2015年

@ 5gon12eder带我们去了解有什么方法可以避免递归算法中的堆栈溢出?-撰写参与TCO或备忘录的信将很有用。 迭代与递归方法也很有趣,因为它涉及斐波那契的两种不同的递归方法。

1
大多数情况下,如果递归确实导致堆栈溢出,则迭代版本会挂起。至少前者抛出堆栈跟踪。
乔恩·汉娜
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.