数据结构和算法之三:递归算法

数据结构基础之递归算法

说到递归,相信大家都用过递归,我们今天就通过一个经典的斐波那契数列问题,展开聊下递归算法。

斐波那契数列,1 1 2 3 5 8 13 … , 满足规律是,从第3个数开始,后一个数是前两个数相加,用数学公式来表示第n个数的值的话,那就是 f(n) = f(n-1) + f(n-2) ,条件是 n > 2; 同时 f(1) = 1, f(2) = 1。

如果用代码来实现:

 /**
     * 斐波拉契数列,求第n个数的值
     * 1,1,2,3,5,8,13,21,。。。
     *
     *  3要素
     *  1. 问题拆解公式  f(n) = f(n-1) + f(n-2)
     *  2. 子问题计算逻辑  无
     *  3. 终止条件 f(2) = f(1) = 1
     *
     * 这个递归算法的时间复杂度是O(2^n), 这是因为在一个问题在拆解成子问题时,拆解成了2个子问题,
     * 那么拆解n次之后,会有2^n个子问题。
     *
     */
    public static int fabic(int n){
        /**
         * 普通递归的写法
         * 1. 先写终止条件
         * 2. 再写计算逻辑
         * 3. 调用函数,求解子问题(拆解公式)
         */
        if(n <= 2){
            return 1;
        }
        return fabic(n-1) + fabic(n-2 );
    }

画个图来分析一下递归的执行流程:

在这里插入图片描述

从上图可以看出,递归递归,先递后归,递就是问题拆解成子问题过程,归就是子问题把结果计算出来之后,往回进行回溯。比如你在某窗口排队人太多了,你不知道我排在第几个,那么你就问你前面的人排第几个,但前面的人也不知道排第几,他在往前问,这样一直问道第一个人(或者第一个知道他排第几),第一个人知道他排第一后,就往后回复,后面的人加上1就是自己排第几了,依次往后回复,最后你就会知道排第几了。这种往前问就是递的过程(拆解子问题),完后回复就是归的过程(子问题求解后回溯)。

通过以上的例子,我们可以发现要用递归来解决问题的话,需要满足一定的条件:

  1. 问题可以拆分为子问题,并且子问题的求解逻辑一样。
  2. 一定会有递归终止条件,也就是最小的子问题有解。

因此,要写递归的话,最重要的是两个,一个是问题拆解公式 另外一个就是递归终止条件。

现在,请你求一下n!的值, 你应该知道套路了吧。

接下类,我们来分析上面斐波拉契数列的时间复杂度吧,从前面的图我们可以发现,问题往下拆一级,子问题就会拆分为2个,那么f(n) = f(n-1) + f(n-2) = 2*f(n-1) = 4*f(n-2) … 时间复杂度为2的N次方,这是一个极高的时间复杂度了,怎么优化呢?

方案1, 通常来说,通过递归解决的问题,都能改写为通过循环来解决,针对斐波拉契问题,直接使用循环的代码

/**
     * 上面的递归算法,对于分解子问题产生分叉(分解成多个子问题)时,会导致时间复杂度成指数级,空间复杂度也是指数级增加。
     *
     * 那么如何来解决这个问题?
     *
     * 方式之一, 就是使用循环来代替递归,时间复杂度会降为O(N)
     *
     */
    public static int fabic2(int n){
        if (n<=2) {
            return 1;
        }
        int res = 0;  //计算结果
        int p_pre = 1; //上一个位置的结果
        int p_pre_pre = 1;  //上上一个位置的结果
        for (int i = 3; i < n; i++) {
            res = p_pre + p_pre_pre;
            p_pre_pre = p_pre;   //上上一个位置的结果 往前移,即上一个位置
            p_pre = res;          //上一个位置的结果 往前移,即当前结果
        }
        return res;

        //上面这种写法可以将p_pre,p_pre_pre理解为指向前两个结果的指针,随着循环往前滚动。可以画个图理解一下

        //另外,可以使用一个数组,来将所有中间结果保存下来,这样循环计算中,取数组前两个的结果来计算也可以。
//        int[] resArray = new int[n];
//        resArray[0] = 1;
//        resArray[1] = 1;
//        for (int i = 3; i < n; i++) {
//            resArray[i] = resArray[i-1] + resArray[i-2];
//        }
//        return resArray[n-1];
    }

通过循环的话,时间复杂度为O(n)。 你可以两段代码运行1亿次,对比下运行时间。

既然循环都可以解决递归,并且通常还要高效,那么为什么还要写递归代码呢?

通过对比递归和循环的代码,你会发现递归代码更简洁,可读性也更高,这也是我们写递归代码的主要原因。

方案2,通过尾递归来优化。什么叫尾递归?尾递归就是在递归调用的那行代码,只有递归函数的调用,不会包含其他的计算表达式,这样运行时在空间上就可以复用一个方法栈,同时也省去了“归”的过程。 比如下面的尾递归代码:

/**
     * 优化2,
     * 尾递归,最后一行就是函数调用,不是一个计算表达式,这样就可以直接往下调用,而不用暂存当前方法临时变量。
     * 思想就是将当前轮次的计算结果,一并传入下一次计算,不断的递下去,到最后一次计算完成了后,结果也就出来了,不用再有一个回"归"的过程。
     * 这样的时间复杂度,通常就是 计算逻辑的复杂度 * 递的次数,通常也是O(N)
     */
    public static int fabic3(int n, int res, int pre){

        if(n <= 2){
            return res;
        }
        /**
         * res,为当前轮次的计算结果
         * pre,为上一轮次的计算结果
         * 当往前走一次时
         *   新的res = 原res + pre
         *   pre = 原res
         *
         * res,pre 初始为1, 实际上对应的就是f(2)=f(1)=1
         *
         *  递归的过程,n没有参与计算,因此实际上n就是控制调用的轮次而已。
         */
        int temp = res;
        res = res + pre;
        pre = temp;

        return fabic3(n-1, res, pre);
    }

最后,对于斐波拉契问题这个特定的问题,还有一种优化方式,从最前的分析图中,可以发现,我们进行了大量的重复计算,比如在计算f(6)的时候f(4)重复了2次,越往子问题走,重复的次数就越高,如果我们将计算结果缓存起来,不重复计算,就会极大的提高执行效率(其实如果将递归的图看着一棵树,缓存的方法就是在剪枝)。

/**
 * 解决方式3
 *
 * 对于这个问题来说,虽然会分解成2^n个子问题,但是很多的都是重复的子问题,不重复的子问题个数其实是n。
 * 那么我们可以使用缓存的方式,来避免重复问题的求解,从而将复杂度降低到O(n)的水平。
 */
static int[] cache;
public static int fabic3(int n){

    if(n <= 2){
        return 1;
    }
    //缓存取,取到返回,取不到才计算,结果放入缓存
    if(cache[n] > 0){
        return cache[n];
    }
    int res = fabic(n - 1) + fabic(n - 2);
    cache[n] = res;
    return res;
}

最后,总结一下, 递归确实是一个写代码的神器,可以写出整洁、可读性高的代码,但是使用起来一定要注意,栈溢出和时间复杂度问题。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值