灵感
相信大家小时候都被这样一个小故事给绕进去过:
从前有座山,山上有个庙。庙里有两个和尚,一个老和尚、一个小和尚。老和尚在给小和尚讲故事,老和尚说,从前有座山,山上有个庙。庙里有两个和尚,一个老和尚、一个小和尚。老和尚在给小和尚讲故事,老和尚说……
小时候给别人讲这个故事,总能引得大家哄堂大笑。
现在再次看到这个故事时,却发现它并没有想象中的那么简单,因为其中隐含着一种重要的编程思想——递归思想。
初识
那么问题就来了,什么是递归思想呢?我们先来看看递归的*:
递归(英语:Recursion),又译为递回,在数学与计算机科学中,是指在函数的定义中使用函数自身的方法。递归一词还较常用于描述以自相似方法重复事物的过程。例如,当两面镜子相互之间近似平行时,镜中嵌套的图像是以无限递归的形式出现的。也可以理解为自我复制的过程。
好像有那么点感觉了,说白了,在编程中, 递归就是函数自己调用自己的行为。
实现
说了这么多,也没看见递归怎么用啊?
别急,接下来我们就来实现一个简单的递归。
就拿老和尚讲故事的例子,我们可以实现一个简单的函数:
void tellStory() {
cout << "从前有座山,山上有个庙。庙里有两个和尚,一个老和尚、一个小和尚。老和尚在给小和尚讲故事,老和尚说,";
tellStory();
}
调用、运行,Duang!程序报错了。
这是怎么回事呢?
让我们来看看错误信息:
Stack overflow(栈溢出)
栈溢出???什么玩意???
别急,我们先一步一步分析看看。
首先,我们来到第一个tellStory(),执行到第一行,正常输出一段故事,然后来到第二行,发现居然也是个tellStory(),于是我们进入了第二个tellStory(),执行第二个tellStory()第一行,正常输出一段故事,然后来到第二个tellStory()第二行…好家伙,没完没了了是吧?这不无限循环了吗?但是为什么会报错呢?毕竟我写一个这样的无限循环他也不会报错啊:
while (true) {
cout << "从前有座山,山上有个庙。庙里有两个和尚,一个老和尚、一个小和尚。老和尚在给小和尚讲故事,老和尚说,";
}
这个就要涉及到递归的底层实现原理了,我理解的是递归实际上是借助了栈结构将外层的函数先入栈,然后进入内层函数,等最内层的函数执行完后逐层回溯。既然借助了栈结构,那么这个栈肯定是有大小的呀,而我们的程序是无限循环,不断地入栈,等到栈满了,自然就报错了。
那我们怎么解决这个问题呢?
这就要讲到一个十分重要的知识点—— 递归基。
什么是递归基?简单来说,就是限制你这个递归函数不会无限循环下去的一种最基本的情况。
而我们这个程序的递归基,可以用执行次数来限制,例如我们可以加一个计数器,用来记录我们讲了多少个这样的故事,然后我们可以加入一个判断,当次数达到多少次时返回:
void tellStory(int& cnt) {
if (cnt >= 3) return; //例如我们设置成当讲了三次时返回,停止继续递归
cout << "从前有座山,山上有个庙。庙里有两个和尚,一个老和尚、一个小和尚。老和尚在给小和尚讲故事,老和尚说,";
tellStory(++cnt);
}
结果也是符合了我们的预期:
从前有座山,山上有个庙。庙里有两个和尚,一个老和尚、一个小和尚。老和尚在给小和尚讲故事,老和尚说,从前有座山,山上有个庙。庙里有两个和尚,一个老和尚、一个小和尚。老和尚在给小和尚讲故事,老和尚说,从前有座山,山上有个庙。庙里有两个和尚,一个老和尚、一个小和尚。老和尚在给小和尚讲故事,老和尚说,
当然,我们可以通过改动,让他在最后输出的是省略号而不是逗号,这里就不演示了。
深入
从上面的例子我们可以初步地认识递归,但这样还不够,我们需要更深层次地认识递归。
什么时候我们能使用递归呢?总的来说,只要可以通过递归基和递推关系来得出结果,就可以用递归实现。
从上面的例子我们可以看出,首选我们得有一个递归基来限制这个递归不会出现无限套娃的情况。
还有其他的吗?我们先来想想看,为什么我们会让函数自己调用自己?
我们声明了一个名为tellStory的函数,不管它的实现如何,它的功能应该是讲一个“套娃”的故事,也就是打印一段“套娃”的故事。那么,我们要实现这样一段“套娃”的故事,是不是就应该在打印完“从前有座山,山上有个庙。庙里有两个和尚,一个老和尚、一个小和尚。老和尚在给小和尚讲故事,老和尚说,”后,在”老和尚说“后面再打印一个“套娃”的故事,也就是说这个时候我们可以直接调用tellStory,因为我们函数的功能就是打印这么一段故事。
还有点懵?那我们再看一个例子——著名的斐波那契数列。
我们想要写一个这样的函数F(n)它能返回斐波那契数列的第n项(假设第一项为1),那么它的声明也很容易得到:
int F(int n); //返回斐波那契数列的第n项
好,现在我们不管它怎么实现,反正它的功能就是返回斐波那契数列的第n项,在自己调用自己的时候,我们时刻要记住这个函数的功能。
首先考虑递归基,那么它的递归基显然是F(1)和F(2),因为通过这两个可以推出所有的斐波那契数。
递推关系也很容易得到:F(n) = F(n - 1) + F(n - 2)。
那么我们可以进一步写出他的递归式:
F
(
n
)
=
{
1
,
n = 1 or n = 2
F
(
n
−
1
)
+
F
(
n
−
2
)
,
n > 2
F(n) = \begin{cases} 1, & \text{n = 1 or n = 2} \\ F(n - 1) + F(n - 2), & \text{n > 2} \\ \end{cases}
F(n)={1,F(n−1)+F(n−2),n = 1 or n = 2n > 2
根据递归式我们可以很容易实现:
int F(int n) {
return n < 3 ? 1 : F(n - 1) + F(n - 2); //为了防止输入非正数报错,这里用n < 3代替n == 1 || n == 2
}
怎么实现的呢?首先当然是实现递归基,也就是n<3的情况,直接返回1;其他情况则是根据递推关系来得到F(n):因为F(n)是返回斐波那契数列的第n项,而第n项是前两项之和,所以我们可以通过返回F(n - 1) + F(n - 2)来得到第n项。
不止这些简单的问题,一些复杂的问题也可以通过递归来解答(只不过单纯的递归可能效率令人堪忧)。
这里贴上一段力扣中最小路径和的python纯递归解法(会超时,有兴趣可改成带备忘录的递归):
def min_path_sum(self, grid):
return sum(grid[0]) if len(grid) == 1 else sum([i[0] for i in grid]) if len(grid[0]) == 1 else min(self.min_path_sum([[j for j in i[0 : -1]] for i in grid]), self.min_path_sum( [[j for j in i] for i in grid[0 : -1]])) + grid[-1][-1]
递归实现起来十分简洁易懂,但递归可能会遇到栈溢出的情况,以及单纯的递归会造成大量时间和空间的浪费,所以我们在用递归时也需要考虑到这些问题。