递归或while循环


123

我正在阅读一些开发面试的实践,特别是面试中提出的技术问题和测试,我对这种类型的说法误解了很多次:“好的,您可以用while循环解决问题,现在可以递归”或“每个人都可以用100行while循环解决此问题,但是他们可以用5行递归函数来实现吗?” 等等

我的问题是,递归是否通常比if / while / for构造好?

老实说,我一直认为递归不是首选,因为它仅限于比堆小得多的堆栈内存,而且从性能的角度来看,执行大量的函数/方法调用也不理想。是错的...



73
关于递归,看起来很有趣。
dan_waterworth 2013年

4
@dan_waterworth也可以,这将对您有所帮助:google.fr/…但我似乎总是会拼写错误:P
Shivan Dragon

@ShivanDragon我这么想^ _ ^非常合适,我昨天发布了它:-)
Neal

2
在嵌入式环境中,我从事递归工作的人充其量会皱眉,而在最坏的情况下会让您公开困惑。实际上,有限的堆栈空间使其成为非法。
Fred Thomsen

Answers:


192

递归在本质上并不比循环好或坏-循环各有优缺点,甚至有赖于编程语言(和实现)。

从技术上讲,迭代循环在硬件级别上更适合于典型的计算机系统:在机器代码级别,循环只是测试和有条件的跳转,而递归(天真的实现)涉及推入堆栈帧,跳转,返回和弹出从堆栈。OTOH,可以编写许多递归情况(尤其是那些等同于迭代循环的情况),从而可以避免堆栈推入/弹出操作;当递归函数调用是返回之前在函数主体中发生的最后一件事情时,这是可能的,并且通常称为尾调用优化(或尾递归优化)。适当的尾调用优化的递归函数在机器代码级别上大致等效于迭代循环。

另一个考虑因素是迭代循环需要破坏性的状态更新,这使它们与纯(无副作用)语言语义不兼容。这就是为什么像Haskell这样的纯语言根本没有循环构造的原因,而许多其他函数式编程语言要么完全缺少循环构造,要么尽可能避免使用它们。

这些问题之所以在面试中如此之多,是因为要回答这些问题,您需要对许多重要的编程概念(变量,函数调用,作用域,当然还有循环和递归)有透彻的了解,并且为您带来思维上的灵活性,使您能够从两个截然不同的角度解决问题,并在同一概念的不同表现形式之间移动。

经验和研究表明,有能力理解变量,指针和递归的人与没有理解变量,指针和递归的人之间存在着界限。可以通过学习和经验来获取编程中几乎所有其他内容,包括框架,API,编程语言及其优势,但是,如果您无法对这三个核心概念有直觉,则不适合成为程序员。将简单的迭代循环转换为递归版本是滤除非程序员的最快方法-即使是经验不足的程序员通常也可以在15分钟内完成,这是一个与语言无关的问题,因此应聘者可以选择他们选择的语言,而不是绊倒特质。

如果您在面试中遇到这样的问题,那就是一个好兆头:这意味着准雇主正在寻找可以编程的人,而不是记住了编程工具手册的人。


3
我最喜欢您的答案,因为它涉及到该问题的答案可能涉及的最重要方面,它解释了主要的技术部分,并且还为该问题在编程领域的应用提供了很好的放大视图。
Shivan Dragon 2013年

1
我还注意到同步编程中的一种模式,其中避免了循环,而有利于对包含.next()方法的迭代器进行递归调用。我认为它可以防止长时间运行的代码变得过于贪婪。
Evan Plaice

1
迭代版本还涉及推入和弹出值。您只需要手动编写代码即可执行此操作。递归版本以迭代算法将状态推入堆栈,通常必须通过将状态推入某种结构来手动模拟此状态。只有最普通的算法不需要这种状态,在这种情况下,编译器通常可以发现尾递归并植入迭代解决方案。
马丁·约克

1
@tdammers您能告诉我在哪里可以看到您提到的研究报告“经验和研究表明人与人之间存在界限……”这对我来说似乎很有趣。
Yoo Matsuo

2
您忘记提及的一件事是,当您处理单个执行线程时,迭代代码往往会表现出更好的性能,但是递归算法则倾向于使自己能够在多个线程中执行。
GordonM 2013年

37

这取决于。

  • 某些问题非常适合递归解决方案,例如快速排序
  • 某些语言并不真正支持递归,例如早期的FORTRAN
  • 有些语言将递归作为循环的主要方式,例如Haskell

还值得注意的是,对尾部递归的支持使尾部递归和迭代循环等效,也就是说,递归并不一定总是浪费堆栈。

同样,始终可以通过使用显式堆栈来迭代地实现递归算法。

最后,我要指出,五行解决方案可能总是比一百行解决方案更好(假设它们实际上是等效的)。


5
好答案(+1)。“ 5行解决方案可能总是比100行解决方案更好”:我认为简洁并不是递归的唯一优势。使用递归调用会迫使您明确不同迭代中值之间的功能依赖性。
Giorgio

4
较短的解决方案确实会更好,但是有些事情过于简洁。
dan_waterworth

5
@dan_waterworth相比,“100行”这是相当困难的是过于简洁
蚊蚋

4
@Giorgio,您可以通过删除不必要的代码或通过使显式内容隐式来使程序更小。只要您坚持使用前者,就可以提高质量。
dan_waterworth

1
@jk,我认为这是使显式信息隐式化的另一种形式。有关变量用途的信息将从其名称(显式名称)中删除,并推入隐式用法中。
dan_waterworth 2013年

17

在编程方面,关于“更好”的定义尚未达成共识,但我将其理解为“易于维护/阅读”。

递归比迭代循环结构具有更多的表达能力:之所以这么说,是因为while循环等效于尾递归函数,而递归函数不必是尾递归。强大的构造通常是一件坏事,因为它们使您能够执行难以阅读的事情。但是,递归使您无需使用可变性就可以编写循环,而在我看来,可变性比递归功能强大得多。

因此,从低表达能力到高表达能力,循环构造像这样堆积:

  • 尾部使用不可变数据的递归函数,
  • 使用不可变数据的递归函数,
  • 虽然使用可变数据的循环
  • 使用可变数据的尾递归函数
  • 使用可变数据的递归函数

理想情况下,您将使用表达能力最低的构造。当然,如果您的语言不支持尾部调用优化,那么这也可能会影响您对循环结构的选择。


1
“ while循环等效于尾部递归函数,而递归函数不必是尾部递归”:+1。您可以通过while循环和堆栈来模拟递归。
Giorgio

1
我不确定我是否100%同意,但这肯定是一个有趣的观点,因此+1。
康拉德·鲁道夫

+1是一个很好的答案,还提到某些语言(或编译器)不进行尾部调用优化。
Shivan Dragon,

@Giorgio,“您可以通过while循环和堆栈来模拟递归”,这就是为什么我说表达力。通过计算,它们同样强大。
dan_waterworth 2013年

@dan_waterworth:确实,正如您在回答中所说,仅递归比while循环更具表现力,因为您需要在while循环中添加堆栈以模拟递归。
Giorgio 2013年

7

递归通常不太明显。不那么明显很难维护。

如果您for(i=0;i<ITER_LIMIT;i++){somefunction(i);}在主流中编写代码,则可以很清楚地看到您正在编写一个循环。如果您写的somefunction(ITER_LIMIT);话,您并不会很清楚会发生什么。只看到内容:该somefunction(int x)调用somefunction(x-1)告诉您实际上是使用迭代的循环。同样,您不能轻易将转义条件放置break;在迭代的一半位置,您必须添加将一直传递的条件,或者引发异常。(异常再次增加了复杂性...)

本质上,如果在迭代和递归之间是一个显而易见的选择,请执行直观的操作。如果迭代能够轻松完成任务,那么从长远来看,节省2行几乎不值得其头痛。

当然,如果要节省98条线,那就完全不同了。

在某些情况下,递归完全适合,而且并非罕见。遍历树结构,多重链接的网络,可以包含其自身类型的结构,多维锯齿形数组,从本质上讲,它既不是简单的向量也不是固定维数的数组。如果遍历已知的直线路径,则进行迭代。如果您陷入未知,请递归。

本质上,如果somefunction(x-1)要在每个级别内从自身内部多次调用,则无需进行迭代。

...为最好通过递归完成的任务迭代编写函数是可能的,但并不令人满意。无论您在哪里使用int,都需要类似stack<int>。我做过一次,更多的是作为练习,而不是出于实际目的。我可以向您保证,一旦您遇到这样的任务,您将不会像您所表达的那样有任何疑问。


10
什么是显而易见的,什么是不太明显的,部分取决于您的习惯。迭代在编程语言中使用得更多,因为它更接近CPU的工作方式(即,它使用较少的内存并执行得更快)。但是,如果您习惯于归纳思维,则递归可以很直观。
Giorgio

5
“如果他们看到一个循环关键字,就知道这是一个循环。但是没有递归关键字,他们只能通过在f(x)中看到f(x-1)来识别它。”:调用递归函数时,您会不想知道它是递归的。同样,当您调用包含循环的函数时,您不想知道它包含循环。
Giorgio

3
@SF:是的,但是如果您看一下函数的主体,则只能看到它。在循环的情况下,您会看到循环;在递归的情况下,您会看到函数调用了自己。
乔治(Giorgio)2013年

5
@SF:对我来说,似乎有点像循环推理:“如果按照我的直觉,这是一个循环,那么这就是一个循环。” map可以定义为递归函数(例如,参见haskell.org/tutorial/functions.html),即使从直觉上清楚它遍历列表并将函数应用于列表的每个成员也是如此。
乔治

5
@SF map不是关键字,它是一个常规函数,但这有点不相关。当函数式程序员使用递归时,通常不是因为他们想要执行一系列动作,而是因为要解决的问题可以表示为函数和参数列表。然后可以将问题简化为另一个函数和另一个参数列表。最终,您有一个可以轻松解决的问题。
dan_waterworth 2013年

6

通常,这通常是无法回答的,因为存在其他因素,这些因素在实践中在案例之间广泛不平等,在用例中彼此不平等。这是一些压力。

  • 简短而优雅的代码通常优于冗长而复杂的代码。
  • 但是,如果您的开发人员基础不熟悉递归并且不愿意/不学习,那么最后一点是无效的。它甚至可能会变成轻微的负面而非正面。
  • 如果您在实践中需要深度嵌套的调用并且不能使用尾部递归(或者您的环境无法优化尾部递归),递归可能会降低效率。
  • 如果您不能适当地缓存中间结果,则递归在很多情况下也很糟糕。例如,如果不缓存,使用树递归计算斐波纳契数的常见示例的性能将非常糟糕。如果您进行缓存,则它简单,快速,优雅且绝对出色。
  • 递归不适用于某些情况,就像在其他情况下一样好,而在其他情况下则绝对必要。通过漫长的业务规则链进行操作通常根本没有递归帮助。通过递归可以有效地遍历数据流。没有递归,显式或隐式,几乎不可能遍历多维动态数据结构(例如迷宫,对象树等)。请注意,在这些情况下,显式递归比隐式好得多-没有什么比读取代码更痛苦的了,因为有人在语言中实现了自己的即席,不完整,错误的堆栈,只是为了避免可怕的R字。

与递归相关的缓存是什么意思?
Giorgio 2013年

@ Giorgio大概是回忆
jk。

FEH。如果您的环境没有优化尾调用,那么您应该找到一个更好的环境,如果您的开发人员不熟悉递归,那么您应该找到更好的开发人员。有一些标准的人!
CA McCann


1

这实际上取决于便利性或要求:

如果您使用Python编程语言,则它支持递归,但是默认情况下,递归深度是有限制的(1000)。如果超过限制,我们将收到错误或异常消息。该限制可以更改,但是如果这样做,我们可能会遇到该语言的异常情况。

这时(调用数量大于递归深度),我们需要使用循环构造。我的意思是,如果堆栈大小不够,我们必须更喜欢循环结构。


3
是Guido van Rossum撰写的博客,介绍了为什么他不希望在Python中进行尾递归优化(支持不同语言采用不同战术方法的观点)。
hardmath 2013年

-1

使用策略设计模式。

  • 递归是干净的
  • 循环(可以说)是有效的

根据您的负载(和/或其他条件),选择一个。


5
等一下 策略模式如何适合这里?第二句话听起来像一个空洞的短语。
康拉德·鲁道夫

@KonradRudolph我会去递归。对于非常大的数据集,我将切换到循环。我正是这个意思。如果不清楚,我深表歉意。
Pravin Sonawane,2013年

3
啊。好吧,我仍然不确定是否可以将其称为“战略设计模式”,它具有非常固定的含义,您可以将其用作隐喻。但是现在至少我知道你要去哪里。
康拉德·鲁道夫

@KonradRudolph学到了重要的一课。深入解释您想说的话..谢谢..有所帮助.. :)
Pravin Sonawane 2013年

2
@Pravin Sonawane:如果可以使用尾部递归优化,则还可以对庞大的数据集使用递归。
Giorgio
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.