Algorithms_算法思想_递归&分治


在这里插入图片描述


引导案例

案例一:

分销系统的返利: 比如B是A的下线,C是B的下线,那么在分钱返利的时候A可以分B,C的钱,这时候我们是不是就要分别找B,C的最后上级。这个问题我们一般怎么来解决呢?

C–>B–>A


案例二: .斐波那契数列:

1 1 2 3 5 8 13 21 ......

有什么特点?
从第三个数开始 就等于前面两个数相加;

数论思想:利用数学公式或者定理或者规律求解问题;

算法思想中最难的点:递归+动态规划

树论中(比如二叉树,红黑树)和递归密不可分,所以递归一定要弄明白了。


递归的定义

递归算法是一种直接或者间接调用自身函数或者方法的算法。

通俗来说,递归算法的实质是把问题分解成规模缩小的同类问题的子问题,然后递归调用方法来表示问题的解。

举个生活中的例子

比如我们在某窗口排队人太多了,我不知道我排在第几个,那么我就问我前面的人排第几个, 因为知道他排第几我就知道我是第几了。但前面的人也不知道自己排第几那怎么办呢?他也可以继续往前面问,直到问到第一个人,然后从第一个人一直传到我这里 我就很清楚的知道我是第几了

以上这个场景就是一个典型的递归。我们在这个过程中大家有没有发现一个规律那么就是会 有一个问的过程,问到第一个后有一个回来的过程吧。这就是递(问)加归(回)

那么这个过程我们是不是可以用一个数学公式来求解呢?那这个数学公式又是什么?

f(n)=f(n-1)+1
  • f(n):表示我的位置
  • f(n-1):表示我前面的那个人;

说白了 一个一个往前问 就是自己调用自己,完成这个功能。

推导出公式: f(n) = f(n-1) + f(n-2)


什么样的问题可以用递归算法来解决

需要满足的条件才可以用递归来解决?

(1)一个问题的解可以分解为几个子问题的解:

子问题,我们通过分治的思想可以把一个数据规模大的问题,分解为很多小的问题。
我们可以把刚刚那个问前面的那个人看为子问题。大化小

(2)这个问题与分解之后的子问题,求解思路完全一样:

(3)一定有一个最后确定的答案,即递归的终止条件。

刚刚那个问题就是第一个人。第一个人是肯定知道自己排第几吧即n=1的时候,如果没有这个特性那么我们这个递归就会出现死循环,最后程序就是栈溢出;StackOverflowError

递归并不是马上返回,而是一层一层的保存在Stack里边,满足结束条件后才一层一层的返回.

栈是用来存储函数调用信息的绝好方案,然而栈也有一些缺点:

  • 栈维护了每个函数调用的信息直到函数返回后才释放,这需要占用相当大的空间,尤其是在程序中使用了许多的递归调用的情况下。

  • 除此之外,因为有大量的 信息需要保存和恢复,因此生成和销毁活跃记录需要消耗一定的时间


递归如何实现以及包含的算法思

递归,回溯(归的过程);

递归的关键相信大家已经知道了就是要(1)求出这个递归公式,(2)找到终止条件

现在我们可以回到课堂前跟大家讲的那个斐波那契数列数列:
1 1 2 3 5 8 13 这个的数列我们称之为斐波那契数列

按照我们说的 两个点 ,来分析一下:

求解公式:f(n)=f(n-1)+f(n-2)
终止条件:n<=2 也就是f(n)=1


递归的公式

递归代码最重要的两个特征:结束条件和自我调用。自我调用是在解决子问题,而结束条件定义了最简子问题的答案。

int func(传入数值) {
  if (终止条件) return 最小子问题解;
  return func(缩小规模);
}



斐波那契数列代码实现

分析一下,给定一个值n , 输出 n个数字组成一个斐波那契数列

从n开始,一直往前递归,直到符合终止条件即可。



public class Fibonacc {

    public static int fab(int n){
        if(n <=2 ) return 1; // 终止条件 ,为啥return 1 ?  1 1 2 3 5 8 ... 前两位是1啊 到头了
        return fab(n-1) + fab(n-2); // 递归公式
    }

    public static void main(String[] args) {
        for (int i = 1; i <= 10; i++) {
            System.out.println(i + ":" + fab(i));
        }
    }
}

在这里插入图片描述


递归的时间复杂度和空间复杂度

代码实现了,我们来分析下 递归的复杂度

斐波那契数列为例为分析递归树:

f(n)=f(n-1)+f(n-2)

在这里插入图片描述

图一画,一目了然 ,斐波那契数列的 时间复杂度 O(2^n)

空间复杂度的话,递归并不是马上返回,而是一层一层的保存在Stack里边,因为还有一个 归的过程,满足结束条件后才一层一层的返回. 空间复杂度自然也是 O(2^n)


递 与 归

这个斐波那契数列 来演示 这个 递 与归 的过程,因为时间复杂度为2^n, 比较难画图。

我们换个常见的递归吧 -------------> 阶乘( n!)

阶乘的数学公式: n!=n×(n-1)×(n-2)……2×1

按照递归的两个步骤:

  1. 终止条件 n = 1
  2. 递归公式 f(n)=n*f(n-1) 如果 n > 1

以递归的方式计算4!

在这里插入图片描述
用代码实现如下


public class JieCheng {

    public static int fact(int n) {
        if (n == 1 || n == 0) return 1; // 终止条件
        return n * fact(n - 1); // 递归公式
    }

    public static void main(String[] args) {
        for (int i = 1; i <=4 ; i++) {
            System.out.println(i + " 的阶乘结果:" + fact(i));
        }
    }
}

递归的优化

时间复杂度和空间复杂度 是 O(2^n) , 随着问题规模的扩大,这个性能将急剧下降,所以我们需要优化,看看能不能优化到 O(n) 或者O(nlogn)…

优化方式一:不使用递归 ,使用循环—> O(n)

记住: 任何使用递归的程序 ,都可以转化为不是用递归 ,使用循环来代替 。

优化前我们简单记下耗时

在这里插入图片描述

才计算到45 , 2^45 太大的计算量了。。。。。

行了,别纠结了,问题规模一旦上去,你这个递归是算不出来的…

既然 任何使用递归的程序 ,都可以转化为不是用递归 ,那改吧

 public static int noCyCle(int n) {
        int a = 1; // 斐波那契数列的第一位
        int b = 1; // 斐波那契数列的第二位
        int c = 0; // 初始值

        if (n <= 2) return 1; // 终止条件

        for (int i = 3; i <= n; i++) { // 为啥i =3 开始算? 因为 第三位 = 前两位的和啊 , 所以从第三位算 。 从头往后算 ,递归是从后往前算。
            long beginTime = System.currentTimeMillis();
            c = a + b; // 求第三位的和
            a = b; //  将第二位 置为第一位
            b = c; // 将第三位 置为第二位  ,循环求和
            System.out.println(i + ":" + c + " 计算耗时: " + (System.currentTimeMillis() - beginTime) + "ms");
        }
        return c;
    }
    


   public static void main(String[] args) {
        
        noCyCle(45);

    }


在这里插入图片描述

比较下 递归和循环两种写法

递归的代码: 很容易理解 在这里插入图片描述

循环的代码,相比而言,不好理解。


优化方式二: 利用缓存,避免重复计算—> O(n)

既然,递归的代码 易读 ,那肯定是可以用的了,那继续思考下, 该如何又能使用递归,而时间复杂度又没有这么高呢?

先看下,递归是怎么玩的
在这里插入图片描述

有没有发现,每个分支上,都得重复计算好几个。

算f(8) = f(7)+f(6) , f(7)=f(6)+f(5) … 是不是需要重复计算

被计算次数最多的那个 肯定是 f(1) + f(2) ------> 如果我们把这个结算结果,缓存起来,是不是就只需要计算一次,剩下的直接从缓存里取就好了?

改造下

 private static int[] data; // 数组  缓存计算结果  值初始化为0

    public static int fabWithCache(int n) {
        
        if (n <= 2) return 1; // 终止条件

        // 如果数组不为空 ,说明之前计算过,直接从数组缓存中获取结果
        if (data[n] > 0) {
            return data[n] ;
        }

        int result = fabWithCache(n - 1) + fabWithCache(n - 2); // 递归的过程
        data[n] = result ; // 将结果存入数组缓存中

        return result;
    }


 public static void main(String[] args) {
        data = new int[46]; // 初始化数组的容量
        for (int i = 1; i <= 45; i++) {
            long beginTime = System.currentTimeMillis();
            System.out.println(i + ":" + fabWithCache(i) + " 耗时" + (System.currentTimeMillis() - beginTime) +" ms");
        }
    }

在这里插入图片描述

为啥能这么改呢? 归结到底还是我们分析了递归树中有太多重复的值,所以我们把中间的计算结果保存起来 , 在 归 的过程中,不需要重复计算,直接从第一次计算后缓存的那个结果中取即可。

分析下时间复杂度和空间复杂度 —> O(n)


优化方式三(最佳方式): 尾递归

什么是尾递归?

尾递归就是调用函数一定出现在末尾,没有任何其他的操作了。

如果一个函数中所有递归形式的调用都出现在函数的末尾,我们称这个递归函数是尾递归的。当递归调用是整个函数体中最后执行的语句且它的返回值不属于表达式的一部分时,这个递归调用就是尾递归

在这里插入图片描述

因为我们编译器在编译代码时,如果发现函数末尾已经没有操作了,这时候就不会创建新的栈,而且覆盖到前面去。


尾递归的原理

当编译器检测到一个函数调用是尾递归的时候,它就覆盖当前的活动记录而不是在栈中去创建一个新的。编译器可以做到这点,因为递归调用是当前活跃期内 最后一条待执行的语句,于是当这个调用返回时栈帧中并没有其他事情可做,因此也就没有保存栈帧的必要了。

通过覆盖当前的栈帧而不是在其之上重新添加一个, 这样所使用的栈空间就大大缩减了,这使得实际的运行效率会变得更高。


理解递归的形式计算阶乘为啥不是尾递归

为了理解尾递归是如何工作的,那我们先以递归的形式计算阶乘。

首先,这可以很容易让我们理解为什么之前所定义的递归不 是尾递归。

回忆之前对计算n!的定义:在每个活跃期计算n倍的(n-1)!的值,让n=n-1并持续这个过程直到n=1为止。
在这里插入图片描述

这种定义不是尾递归的,因为 每个活跃期的返回值都依赖于用n乘以下一个活跃期的返回值,因此每次调用产生的栈帧将不得不保存在栈上直到下一个子调用的返回值确定


尾递归重写阶层的算法

现在让我们考虑以尾 递归的形式来定义计算n!的过程。

这种定义还需要接受第二个参数a,除此之外并没有太大区别。

a(初始化为1)维护递归层次的深度。这就让我们避免了每次还需要将返回值再乘以n。然而,在每次递归调用中,令a=na并且n=n-1。继续递归调用,直到n=1,这满足结束条件,此时直接返回a即可。

在这里插入图片描述

每次都是把 上一个的计算结果传递下去,这样就避免了归的过程,这样的话,就不用开辟那么多的占空间。

上面的方法是尾递归的,因为对tailFact的单次递归调用是函数返回前最后执行的一条语句。

在tailFact中 碰巧最后一条语句也是对tailFact的调用,但这并不是必需的。换句话说,在递归调用之后还可以有其他的语句执行,只是它们只能在递归调用没有执行时 才可以执行。


尾递归重写斐波那契的算法

    /**
     *
     * @param pre 上上一次运算的结果
     * @param res 上一次运算的结果
     * @param n
     * @return
     */
  public static int fabTail(int pre, int res , int n ) {
        if (n <= 2) return res; // 终止条件  不能反悔1哈,已经把结果传递下来了,直接返回res即可,因为没有归的过程了。 
        return fabTail(res,pre+res, n-1); // 递归公式
    }
    

    public static void main(String[] args) {
        data = new int[46]; // 初始化数组的容量
        for (int i = 1; i <= 45; i++) {
            long beginTime = System.currentTimeMillis();
            System.out.println(i + ":" + fabTail(1,1,i) + " 耗时" + (System.currentTimeMillis() - beginTime) +" ms");
        }
    }

在这里插入图片描述


尾递归的重要性

尾递归就是把当前的运算结果(或路径)放在参数里传给下层函数

不用尾递归,函数的堆栈耗用难以估量,需要保存很多中间函数的堆栈。

比如f(n, sum) = f(n-1) + value(n) + sum; 会保存n个函数调用堆栈,而使用尾递归f(n, sum) = f(n-1, sum+value(n)); 这样则只保留后一个函数堆栈即可,之前的可优化删去。


分治

分治算法可以分三步走:分解 -> 解决 -> 合并

1. 分解原问题为结构相同的子问题。

2. 分解到某个容易求解的边界之后,进行递归求解。

3. 将子问题的解合并成原问题的解。

归并排序 ,典型的分治算法; 分治,典型的递归结构。

该函数的职即 对传入的一个数组排序 。

那么这个问题能不能分解呢?-----------------> 给一个数组排序,不就等于给该数组的两半分别排序,然后合并。

void merge_sort(一个数组) {
  if (可以很容易处理) return;
  merge_sort(左半个数组);
  merge_sort(右半个数组);
  merge(左半个数组, 右半个数组);
}

分治算法的套路是 分解 -> 解决(触底)-> 合并(回溯)

发布了782 篇原创文章 · 获赞 1978 · 访问量 411万+

猜你喜欢

转载自blog.csdn.net/yangshangwei/article/details/103943043
今日推荐