禁止套娃——浅谈递归思想

灵感

相信大家小时候都被这样一个小故事给绕进去过:

从前有座山,山上有个庙。庙里有两个和尚,一个老和尚、一个小和尚。老和尚在给小和尚讲故事,老和尚说,从前有座山,山上有个庙。庙里有两个和尚,一个老和尚、一个小和尚。老和尚在给小和尚讲故事,老和尚说……

小时候给别人讲这个故事,总能引得大家哄堂大笑。
现在再次看到这个故事时,却发现它并没有想象中的那么简单,因为其中隐含着一种重要的编程思想——递归思想。

初识

那么问题就来了,什么是递归思想呢?我们先来看看递归的维基百科:

递归(英语:Recursion),又译为递回,在数学与计算机科学中,是指在函数的定义中使用函数自身的方法。递归一词还较常用于描述以自相似方法重复事物的过程。例如,当两面镜子相互之间近似平行时,镜中嵌套的图像是以无限递归的形式出现的。也可以理解为自我复制的过程。

好像有那么点感觉了,说白了,在编程中, 递归就是函数自己调用自己的行为

禁止套娃——浅谈递归思想_第1张图片

实现

说了这么多,也没看见递归怎么用啊?
别急,接下来我们就来实现一个简单的递归。
就拿老和尚讲故事的例子,可以实现一个简单的函数:

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(n1)+F(n2),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]

递归实现起来十分简洁易懂,但递归可能会遇到栈溢出的情况,以及单纯的递归会造成大量时间和空间的浪费,所以我们在用递归时也需要考虑到这些问题。

去重

在递归计算时,往往会导致大量重复的计算,这些重复的计算会浪费大量时间,那么,是否能够消除这种重复的计算呢?
首先拿斐波那契数列中F(5)举例,先来看看重复计算是怎么来的:

F(5)
F(3)
F(4)
F(1)
F(2)
F(2)
F(3)
F(1)
F(2)

其中,例如F(3)就进行了多次计算,这显然是多余的,因为我们完全可以在算出F(3)后,将F(3)的结果保存起来,下次计算时就不需要再次向下递归计算了,这也就是我们的带备忘录的递归
其具体实现如下:

int helper(int n, vector<int>& m) {
	return n < 3 ? m[n] = 1 : m[n] ? m[n] : m[n] = helper(n - 1, m) + helper(n - 2, m);
}

int F(int n) {
	vector<int> m(n + 1, 0);
	return helper(n, m);
}

引入一个数组m用来记录之前计算过的数据,如果数据存在则直接取出,否则递归计算并存入m。

你可能感兴趣的:(算法,c语言,c++,python,算法,leetcode)