递归的一般公式

一、递归介绍

说起递归,大家都耳熟能详。因为它是面试中非常喜欢考的。递归不仅能考察一个程序员的算法功底,还能很好的考察对时间复杂度与空间复杂度的理解和分析。本文尝试总结出递归问题的一般套路。

正如大家所知道的,一个方法自己调用自己就是递归,但这只是对递归最表层的理解。

  • 那么递归的实质是什么?
  • 答:递归的实质是能够把一个大问题分解成比它小点的问题,然后我们拿到了小问题的解,就可以用小问题的解去构造大问题的解。
  • 那小问题的解是如何得到的?
  • 答:用再小一号的问题的解构造出来的,小到不能再小的时候就是到了零号问题的时候,也就是 base case 了。

所以套路就是

1、确定递归函数的功能:必须确定该函数是做什么的,这也是写程序的第一步。

2、寻找零号问题(Base case):即找出递归结束的条件。找到最终的那个问题后,它能够直接给出结果,不必再往下走了。

3、拆解找出函数的等价关系式:每一层的问题都应该比上一层的小,不断缩小问题的 size,才能从大到小到 base case;

下面通过一个最基本的递归问题来实际操作一下。(理论指导实践,实践验证理论,请记住这句话

二、实际的问题

斐波那契数列是一位意大利的数学家,他闲着没事去研究兔子繁殖的过程,研究着就发现,可以写成这么一个序列:1,1,2,3,5,8,13,21… 也就是每个数等于它前两个数之和。那么给你第 n 个数,问 F(n) 是多少。

用数学公式表示很简单:

代码也很简单,用我们刚总结的三步:

1、确定递归函数功能

假设 f(n) 的功能是求第 n 项的值,代码如下:

int f(int n){

}

2、寻找零号问题(递归结束的条件)

显然,当 n = 1 或者 n = 2 ,我们可以轻易着知道结果 f(1) = f(2) = 1。所以递归结束条件可以为 n <= 2。代码如下:

int f(int n){
    if(n <= 2){
        return 1;
    }
}

 

3、拆解,找出函数的等价关系式

 题目已经把等价关系式给我们了,所以我们很容易就能够知道 f(n) = f(n-1) + f(n-2)

class Solution
{

public:
	int fib(int N) {
		if (N <= 2) {
			return 1;
		}
		return fib(N - 1) + fib(N - 2);
	}
};

但是这种解法 Leetcode 给出的速度经验只比 15% 的答案快,因为,它的时间复杂度实在是太高了!

三、过程分析

那这就是我想分享的第一点,如何去分析递归的过程。首先我们把这颗 Recursion Tree 画出来,比如我们把 F(5) 的递归树画出来:

 

 

首先是沿着最左边这条线一路到底:F(5) → F(4) → F(3) → F(2) → F(1),好了终于有个 base case 可以返回 F(1) = 1 了,然后返回到 F(2) 这一层,再往下走,就是 F(0),又触底反弹,回到 F(2),得到 F(2) = 1+0 =1 的结果,把这个结果返回给 F(3),然后再到 F(1),拿到结果后再返回 F(3) 得到 F(3) = 左 + 右 = 2,再把这个结果返上去...

这种方式本质上是由我们计算机的冯诺伊曼体系造就的,目前一个 CPU 一个核在某一时间只能执行一条指令,所以不能 F(3) 和 F(4) 一起进行了,一定是先执行了 F(4) ,再去执行 F(3).

对于这个题来说,时间复杂度是多少呢?

答:因为我们每个节点都走了一遍,所以是把所有节点的时间加起来就是总的时间。

在这里,我们在每个节点上做的事情就是相加求和,是 O(1) 的操作,且每个节点的时间都是一样的,所以:总时间 = 节点个数 * 每个节点的时间。那就变成了求节点个数的数学题:

在 N = 5 时,

 

最上面一层有1个节点,
第二层 2 个,
第三层 4 个,
第四层 8 个,
第五层 16 个,如果填满的话,想象成一颗很大的树:)

这里就不要在意这个没填满的地方了,肯定是会有差这么几个 node,但是大 O 表达的时间复杂度我们刚说过了,那么总的节点数就是:1 + 2 + 4 + 8 + 16。这就是一个等比数列求和了。所以时间复杂O(2^n)

空间复杂度又是多少呢?

从图中也很容易看出来,是最左边这条路线占用 stack 的空间最多,一直不断的压栈,也就是从 5 到 4 到 3 到 2 一直压到 1,才到 base case 返回,每个节点占用的空间复杂度是 O(1),所以加起来总的空间复杂度就是 O(n).

四、优化算法

也不难看出来,在这棵 Recursion Tree 里,有太多的重复计算了。比如一个 F(2) 在这里都被计算了 3 次,F(3) 被计算了 2 次,每次还都要再重新算。为了解决这种重复计算,计算机采用的方法就是:记录以前的数据

Index012345
      F(n)    0   1   1    2   3   5

用一个数组来记录每个f(n)的值

class Solution
{

public:
	int fib(int N) {
		
		
		if (N == 0) {
			return 0;
		}
		if (N == 1) {
			return 1;
		}
		int *notes = new int[N+1];
		memset(notes, 0, sizeof(notes));
		notes[0] = 0;
		notes[1] = 1;
		for (int i = 2; i <= N; i++) {
			notes[i] = notes[i - 1] + notes[i - 2];
		}
		int result = notes[N];
			delete[] notes;
			return result;
	}
};

其实每项的计算只取决于它前面的两项,所以只用保留这两个就好了。那我们可以用一个长度为 2 的数组来计算,或者就用 2 个变量。更新代码:

class Solution
{

public:
	int fib(int N) {
		
		
		int a = 0;
		int b = 1;
		if (N == 0) {
			return a;
		}
		if (N == 1) {
			return b;
		}
		for (int i = 2; i <= N; i++) {
			int tmp = a + b;
			a = b;
			b = tmp;
		}
		return b;
		
	}
};

 题外话:

解法的缺陷

这种解法虽然简单易懂而且代码量小,但是它的缺陷也是不可避免的,有兴趣的同学可以尝试着去将n设为100,你就会发现所得到的结果竟然是一个负数。其实这是因为第100个斐波那契数已经远远的超过了32位int类型可以保存的最大值,实际上普通的int类型只能存储到斐波那契数列的第46个数,到第47个就会产生溢出,从而无法再使用上面的解法来解决问题。

有同学可能会想将int换成long long类型,不就可以存放更大的值了吗?其实用上面这种思想,不管你能存放的最大数是多少,总会有一个溢出点,将int换成long long无异于五十步笑百步,我们需要另外一种存放数据的思路来一劳永逸地解决这个问题。那这就是另一个问题了——大数的斐波那契数列。本文只介绍递归的套路,有兴趣的同学可以自己去百度相关的答案。

参考:

https://blog.csdn.net/weixin_44048823/article/details/89348886

https://mp.weixin.qq.com/s?__biz=MzAwMjk5Mjk3Mw==&mid=2247488967&idx=2&sn=9d8ef324ba3be0ebf0a6dd0aa0daee30&chksm=9ac0abe5adb722f3543de2a857ed48aad6811c9b73d402e03e7a2b2d27b34c930d9c6d49173b&scene=0&xt

 https://blog.csdn.net/Mr_H9527/article/details/100595732

https://www.zhihu.com/question/31412436

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值