问小白 wenxiaobai
资讯
历史
科技
环境与自然
成长
游戏
财经
文学与艺术
美食
健康
家居
文化
情感
汽车
三农
军事
旅行
运动
教育
生活
星座命理

递归的一般公式

创作时间:
作者:
@小白创作中心

递归的一般公式

引用
CSDN
1.
https://blog.csdn.net/sinat_31608641/article/details/106447255

递归介绍

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

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

  • 那么递归的实质是什么?
  • 答:递归的实质是能够把一个大问题分解成比它小点的问题,然后我们拿到了小问题的解,就可以用小问题的解去构造大问题的解。
  • 那小问题的解是如何得到的?
  • 答:用再小一号的问题的解构造出来的,小到不能再小的时候就是到了零号问题的时候,也就是 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无异于五十步笑百步,我们需要另外一种存放数据的思路来一劳永逸地解决这个问题。那这就是另一个问题了——大数的斐波那契数列。本文只介绍递归的套路,有兴趣的同学可以自己去百度相关的答案。

© 2023 北京元石科技有限公司 ◎ 京公网安备 11010802042949号