所有递归都能非递归
![](https://i-blog.csdnimg.cn/blog_migrate/6b7753fb298d3671f1097cc587d90eea.png)
注意 在本 系列中, 我将介绍我一直在努力的编程概念,直到有一天才单击它。 读者,我希望它也能为您点击。 在“ Medium” 上关注我, 或订阅我的 RSS 以获取更多信息。
什么
我认为花了很多年的时间才能掌握递归的原因是,如果您愿意,我还没有遇到正确的方式来思考和概念化。 以下是一些想法,这些想法可以帮助我将递归从本该使用的颤抖的东西转变成我今天拥有的最有价值的工具之一。
一世
假设您要在跑道上奔跑,并且想要完成20圈。 你可以说
- “为了跑20圈,我将从只跑一圈开始。 然后我再跑一圈,依此类推,直到跑完二十圈。”
或者你可以说,
- “要跑20圈,我只需要跑19圈,然后再跑一圈。 我怎么会跑19圈? 我要跑18圈,然后再跑一圈。 等等。 完成后我怎么知道? 当我为零时-再也没有圈了。”
这就是迭代和递归地考虑流程之间的区别。 后者的目的是将最初的问题简化为较小的问题,然后将其简化为较小的问题,直到达到所谓的“基本情况”为止,这意味着没有其他要减少的东西了。
II
另一个想法与递归无关,但是在帮助我们弄清楚它方面将是无价的。 这称为替代模型 。 这就是我们要做的,以解开……的复杂性。
三级
递归定义的函数中的递归过程和迭代过程。 笨重的东西,对不对? 让我们马上看几个例子。
怎么样
首先,替代模型。 这是两个简单的功能。 一个取两个数字并返回它们的总和。 另一个采用一个数字并将其平方(所有代码示例均位于JavaScript中)。
![](https://i-blog.csdnimg.cn/blog_migrate/cd6fd955f37b4cc345331cf3fec9fc33.png)
现在,我们要将两个数字3和4相加,然后对结果求平方。
square(add(3, 4))
我不认识你,但是对我来说,这似乎已经很复杂了。 因此,让我们将函数的参数替换为其各自的形式参数,以查看发生了什么。 这是我们得到的:
square(add(3, 4))
⬇⬇⬇⬇⬇⬇⬇⬇⬇
square(3+4)
⬇⬇⬇⬇⬇⬇⬇⬇⬇
square(7)
⬇⬇⬇⬇⬇⬇⬇⬇⬇
7 * 7
⬇⬇⬇⬇⬇⬇⬇⬇⬇
49
顺带一提,我强烈建议您在纸上进行此操作。 实际上,我想说的是,通常在纸上写代码,特别是在这种情况下,例如针对高度专业化的功能编写代码时,是非常有益的,但这也许就是我自己。 无论如何,我离题了。
关键是,我们上面所做的是,我们逐步将每个函数调用替换为相应函数的主体。
最大的收获是,运行时是否以不同的方式评估我们的代码并不重要。 我们只关心结果。
好吧,这并非完全正确。 我们刚刚看到的替代方法适用于严格评估的语言-JavaScript是其中之一。 我们在这里不打算深入研究惰性计算,这是另一种评估表达式的方法,但是为了显示差异,这是一个惰性评估的替换模型的样子:
square(add(3, 4))
⬇⬇⬇⬇⬇⬇⬇⬇⬇
add(3,4) * add(3,4)
⬇⬇⬇⬇⬇⬇⬇⬇⬇
(3+4) * (3+4)
⬇⬇⬇⬇⬇⬇⬇⬇⬇
7 * 7
⬇⬇⬇⬇⬇⬇⬇⬇⬇
49
在这种情况下,结果是相同的。 现在,我们将继续使用严格评估的替代模型。
现在我们准备解决递归问题。 回想一下,在具有运行圈数的示例中,我们实际上是在递减计数-运行20圈就是运行19圈(还有一个),再运行18圈(还有一个),依此类推。 无限广告? 并不是的。 在某个时候,我们想停止。 在这里,我们将停在零位-我们的跑步不能少于零圈。 现在,让我们将相同的想法应用于定义计算阶乘的函数的问题。
整数的阶乘是该整数与其下所有整数的乘积。 例如,阶乘四为4 * 3 * 2 * 1 =24。看到递归还在吗?
让我们对其进行正式定义:
![](https://i-blog.csdnimg.cn/blog_migrate/f2c4945530a79c0b6ad513ecd5c59e75.png)
如果阶乘函数的输入为0或1,则答案为1。如果不是,则答案为输入乘以阶乘函数减去1所得结果的结果。 放弃所有想法并立即编写代码!
![](https://i-blog.csdnimg.cn/blog_migrate/cd6fd955f37b4cc345331cf3fec9fc33.png)
那很简单。 它实际上进行了编码。
现在让我们替代。
factorial(4)
// is 4 equal to 0 or 1? Nah. Hence, we'll go with the second option:
⬇⬇⬇⬇⬇⬇⬇⬇⬇
4 * factorial(3)
// Now we evaluate factorial(3). Is 3 equal to 0 or 1? Nope
⬇⬇⬇⬇⬇⬇⬇⬇⬇
4 * (3 * factorial(2))
// I add the parentheses here to illustrate that the code inside them is going to be evaluated first.
// We'll see the significance of it soon. In the meantime, 2 is still not 1. Or 0, for that matter
⬇⬇⬇⬇⬇⬇⬇⬇⬇
4 * (3 * (2 * factorial(1)))
// Finally, we've reached -- you got it -- the base case. As per definition, we humbly return 1
⬇⬇⬇⬇⬇⬇⬇⬇⬇
4 * (3 * (2 * 1))
⬇⬇⬇⬇⬇⬇⬇⬇⬇
4 * (3 * 2)
⬇⬇⬇⬇⬇⬇⬇⬇⬇
4 * 6
⬇⬇⬇⬇⬇⬇⬇⬇⬇
24
这是这里发生的事情。 我们首先尝试评估factorial(4)
。 我们有两个选择:如果输入为0或1,则立即求值为1。否则,我们将输入放到堆栈上-也就是说,我们记住输入-在这种情况下为4-以后要乘以,如返回最终结果之前的最后一步 。 我相信这部分对于理解递归至关重要。 再一次,将评估的整个过程写在纸上使我很清楚。
好的,所以我们记住“ 4次某事”部分。 接下来是什么? 评估factorial(3)
。 想象一秒钟,我们从factorial(3)
开始,在那之前没有任何东西。 结果如何? “三回事”。 那是什么? factorial(2)
,其结果是“两倍于某物”。
现在,这一次,值– factorial(1)
–实际求值,我们可以立即说是一个有形的结果–数字1(因为条件是,返回1是输入是0还是1)。
factorial(4)
4 * factorial(3)
3 * factorial(2)
2 * factorial(1))
1
这里是最酷的部分:我们开始记住! 也就是说,我们开始往上走,逐一评估“ X某事”表达式。 我们将上面表示中的factorial(1)
替换为1并求值:2 * 1 =2。好的,这2现在是factorial(2)
的结果,我们可以在下一个表达式3 * factorial(2)
factorial(2)
进行替换。 factorial(2)
,这将导致3 * 2 =6。还剩最后一步:4 * factorial(3)
➡4 * 6 =24。这就是我们的答案,这就是factorial(4)
返回的结果。
基本上,这就是递归的全部。 将初始问题( factorial(4)
)缩小为较小的问题( factorial(3)
)甚至缩小为较小的问题( factorial(2)
)和缩小为较小的问题( factorial(1)
),直到没有什么可减少的为止。 从阶乘的正式定义中可以看到,它甚至很自然地转换为代码。
不过,有什么东西不见了吗? 让我们看看如何再次评估factorial(4)
(或者,更相关的是,它产生了什么样的过程 )。
factorial(4)
4 * factorial(3)
4 * (3 * factorial(2))
4 * (3 * (2 * factorial(1)))
4 * (3 * (2 * 1))
4 * (3 * 2)
4 * 6
24
看到它如何扩展,直到达到基本情况,然后开始收缩? 那是因为对factorial(4)
的调用不等于对factorial(3)
的调用,我们需要一种跟踪它的方法。 这就是堆栈的用途:它记录了我们调度对阶乘函数的调用的顺序,以便我们知道在达到基本情况后该怎么做。 有什么收获? 它消耗内存,并且就堆栈大小而言,它远非无限。
还有另一种方法可以定义阶乘函数,这样我们就不必关心堆栈大小了吗? 确实有。
![](https://i-blog.csdnimg.cn/blog_migrate/cd6fd955f37b4cc345331cf3fec9fc33.png)
如您所见,差异是细微的,但是就我们将要定义的这两个阶乘函数要生成的过程的类型而言,这是实质性的(正如您可能已经从名称中猜测的那样)。
第二个函数factorialIter
具有两个参数,其中一个具有默认值。 它的名称acc
表示这样一种事实,即有时将这种解决问题的方法称为“ 累加器模式 ”,因为我们正在累积 –随身携带结果,直到需要返回结果为止。
好的,由于我们对该函数的作用感到困惑(不同),让我们直接跳入替换。
factorialIter(4)
// since we're only passing one argument, the second parameter, acc, // will take its default value, which is 1
⬇⬇⬇⬇⬇⬇⬇⬇⬇
factorialIter(3, 4) // 1 * 4 ➡ 4
⬇⬇⬇⬇⬇⬇⬇⬇⬇
factorialIter(2, 12) // 4 * 3 ➡ 12
⬇⬇⬇⬇⬇⬇⬇⬇⬇
factorialIter(1, 24)
// The base case is reached
⬇⬇⬇⬇⬇⬇⬇⬇⬇
24
和以前一样,我们首先尝试评估factorialIter(4)
。 4不等于0或1,因此我们遵循第二条路径,这导致对函数的另一个调用: factorialIter(4 - 1, 1 * 4)
4-1-1 factorialIter(4 - 1, 1 * 4)
➡factorialIter factorialIter(3, 4)
。
我们重复几次,直到达到基本情况。 factorialIter(1, 24)
acc
factorialIter(1, 24)
返回acc
,即24。
让我们看一下factorialIter
生成的过程。
factorialIter(4)
factorialIter(3, 4)
factorialIter(2, 12)
factorialIter(1, 24)
24
没有扩展吧? 这是因为对函数的调用实际上是等效的,因此已在堆栈上替换。 调用factorialIter(4)
与调用factorialIter(3,4)
, factorialIter(2, 12)
或factorialIter(1, 24)
的意义相同,因为它们的结果均为相同值。
那就是迭代过程和递归过程之间的区别。 即使这两个函数都是根据其递归定义的,但它们生成的过程却大不相同。 实际上,如果函数定义中的最后一次调用是对自身的调用,它将生成一个迭代过程。 factorialIter
自我调用,这是它要做的最后一件事(此类函数称为“ tail-recursive ”)。
另一方面,如果一个函数在其定义中调用自身,但这并不是最后一步,它将生成一个递归过程。 factorial
的最后一个调用是对内置乘法函数的调用,而不是对自身的调用。
为什么
如果您走到了这一步,您可能会问:“这项递归业务非常酷,但是我真的需要弄清楚吗? 为什么不只坚持平常的活泼-懂吗? —做事方式?”
这是一个公平的问题。 即使您无需递归也可能很好,但是一旦掌握了它,就只能考虑它的用途,就无法放下它。
让我们考虑一下数据结构。 列表,哈希表,二叉树–不仅对它们本身有用,而且还是编码采访的主要内容。 这是完全合理的-每当您要在代码中实现业务逻辑的某些部分时,就不可避免地会使用数据结构。 递归为您提供了一种与之配合使用的强大技术-其中任何一种 。
我们将说明递归如何通过解决FizzBuzz问题来帮助我们对列表的元素进行操作。 它如下(由Daniel Bunte所说):
编写一个程序,打印从1到100的数字。如果它是3的倍数,则应打印“ Fizz”。 如果是5的倍数,则应打印“ Buzz”。 如果是3到5的倍数,则应打印“ Fizz Buzz”。
让我们尝试立即从数据结构的角度考虑问题。 有效地要求我们执行的操作是,给定一个从1到100的数字列表,然后根据某些规则将其转换为字符串列表。 我们将需要:
- 列举清单
- 一一变换元素
- 打印结果列表
假设您正在接受采访,并且要求您解决FizzBuzz。 您是如此紧张,以至于忘记了任何基本的内置功能。 查看上面的概述,您可能会想到在列表上进行映射,但您只是不记得该怎么做。 欢喜-您不必! 您需要的只是递归。 如标题所述,它确实解决了(几乎)所有问题。
什么是列表映射? 如果列表为空,则结果为相同的空列表。 如果列表由一个元素组成,则该元素将被转换并附加到一个空列表中。 两个要素? 它几乎就像一个元素的列表,然后是另一个。 您已经知道该怎么办。
![](https://i-blog.csdnimg.cn/blog_migrate/cd6fd955f37b4cc345331cf3fec9fc33.png)
非常简单。 现在转到步骤2:
![](https://i-blog.csdnimg.cn/blog_migrate/cd6fd955f37b4cc345331cf3fec9fc33.png)
和3:
![](https://i-blog.csdnimg.cn/blog_migrate/cd6fd955f37b4cc345331cf3fec9fc33.png)
我们还需要一种方便的方法来生成初始列表。 记住,内置的方法已经从我们的记忆中消失了。 但这并不能阻止我们:
![](https://i-blog.csdnimg.cn/blog_migrate/cd6fd955f37b4cc345331cf3fec9fc33.png)
就是这样! 剩下的就是将这些功能组合在一起:
print(map(fizzBuzz, range(100)));
最好的部分是,该解决方案具有可伸缩性,可用于进一步的抽象。 的map
功能可以在一个合适的折叠功能(来表示reduceRight
在JavaScript), print
在以下方面forEach
。 代替在fizzBuzz
中检查模数,我们可以传递规则的哈希表,以用字符串替换数字。 等等。 有各种各样的可能性,通过使用专门的功能和递归,我们最终得到了优雅且可重复使用的代码。
结论
在这个简短的概述中,还有很多想法有待探索(实际上,帮助我着手进行递归和其他许多概念的书是《计算机程序的结构和解释》 ,我对此并不推荐)。 在我看来,最突出的是利用递归并促进功能的可重用性和专业化的函数式编程思想。 我希望现在,有了递归的力量,您将有足够的能力更深入地研究函数式编程和其他技术来管理复杂性,而这正是软件开发的全部。
最初于 2018年6月26日 发布在 https://danplisetsky.github.io/2018/06/26/how-i-figured-out-recursion.html 。
翻译自: https://hackernoon.com/how-i-figured-out-recursion-or-recursion-solves-everything-9eaa9f7a1b20
所有递归都能非递归