一、递归介绍
说起递归,大家都耳熟能详。因为它是面试中非常喜欢考的。递归不仅能考察一个程序员的算法功底,还能很好的考察对时间复杂度与空间复杂度的理解和分析。本文尝试总结出递归问题的一般套路。
正如大家所知道的,一个方法自己调用自己就是递归,但这只是对递归最表层的理解。
- 那么递归的实质是什么?
- 答:递归的实质是能够把一个大问题分解成比它小点的问题,然后我们拿到了小问题的解,就可以用小问题的解去构造大问题的解。
- 那小问题的解是如何得到的?
- 答:用再小一号的问题的解构造出来的,小到不能再小的时候就是到了零号问题的时候,也就是 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 次,每次还都要再重新算。为了解决这种重复计算,计算机采用的方法就是:记录以前的数据
。
Index | 0 | 1 | 2 | 3 | 4 | 5 |
---|---|---|---|---|---|---|
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