东哥说算法——聊聊递归那些事儿

什么是递归函数?

递归函数的标准定义是,定义一个函数f,f直接或间接调用自身,则f函数被叫做递归函数。
话不多说,示例代码如下:

// 打印从10到0
public class 递归函数示例 {
	
	// 递归函数
    static void f(int m){
    	// 出口,
    	// 如果这段代码取消,你的程序一定会报栈越界的错误:java.lang.StackOverflowError
        if(m < 0)
            return;
        System.out.println(m);
        
        f(m-1); // 我调用我自己!

    }
    	
    public static void main(String[] args) {
        f(5);
    }
}

在这里面我们可以看到函数f()内部调用了自己(套娃)。
那么,接下来我们来仔细看看这个代码的调用逻辑:
在这里插入图片描述
因此控制台结果如下:
在这里插入图片描述

如何使用递归函数解决问题?

Ⅰ.牢记你所定义的递归函数的意义

递归函数与其参数所蕴含的意义十分重要,你定义它的意义是什么,它的意义就是什么,在解题的过程中,你必须时刻记住这个函数的意义和它参数的意义。
比如,在求n的阶乘的问题中,你定义 f(n) 为求n的阶乘,那么 f(n-1) 就一定是求n-1的阶乘,f(n-2) 就是求n-2的阶乘。

Ⅱ.递归函数的三“找”

设计一个递归函数一般需要找三个东西,它们分别是

  1. 找子问题(递归函数的子问题)
  2. 找划分时的变化量(递归函数的参数)
  3. 找出口(递归函数的结束)

接下来对这三“找”进行详细讲解:(ps:突然发现不认识找这个字了)

1. 找子问题(递归函数的子问题)

所谓找子问题,就是对原有问题进行拆解,寻找规模更小的和原有问题一样逻辑的子问题
比如:在求n的阶乘问题中,n! = n * (n-1)! ,(n-1)! 就是 n! 的规模更小的子问题。

2. 找划分时的变化量(递归函数的参数)

划分时的变化量,就是在从原问题变成子问题的过程中,谁在变化,谁就是变化量。一般来说,划分时的变化量就是递归函数中的参数
比如:在求n的阶乘问题中,n的值在不断减小,所以递归函数中的参数就是n。

3.找出口(递归函数的结束)

出口,就是递归函数的结束,也就是递归函数应在何时停止调用
比如:在求n的阶乘问题中,n不能为负数,因此,n最小只能为0。即n=0时,就是该递归函数的出口。此时将n=0带入,f(0),即0的阶乘,结果为1.

Ⅲ.典型题例

我们在上面两个部分讲解了递归函数的概念与如何使用递归函数,光说不练假把式,下面我们使用上面的东西来解决一些问题,当然,问题的难度是由浅入深的。首先解决的就是一直在叨叨的求n的阶乘问题。

1. 求n的阶乘

我们按照第Ⅱ 部分的顺序来解决这个问题。

  1. 定义函数:我们定义函数 f() 为求解n的阶乘,因此我们需要传入一个参数n,表示去求这个数的阶乘,此时函数定义为 f(int n).
  2. 找子问题:我们发现 n! = n*(n-1)! ,求解n-1的阶乘的逻辑与求解n的阶乘是一致,n-1的规模比n更小,因此求解n-1的阶乘是子问题。
  3. 找划分时的变化量:在划分子问题的过程中,我们可以很明显的发现只有n的值在不断的减小,因此递归函数的唯一参数就是n。
  4. 找出口:我们知道n为非负数,因此当n为0时,为递归函数的出口。此时f(0),即0的阶乘结果为1。

因此,求解n的阶乘的递归函数代码如下:

public class 求n的阶乘 {
   
    //函数的意义:求解阶乘
    //@param n:被求解阶乘的数
    static int f(int n){
    	// 不符要求的情况
    	if(n < 0){
            System.out.println("n不能为负数");
            return 0;
        }
    	// 出口
        if(n==0){
            return 1;
        }
        // 划分子问题
        return n*f(n-1);
    }

    // 测试代码
    public static void main(String[] args) {
        System.out.println(f(10));
        System.out.println(f(1)); // 特殊数值测试
        System.out.println(f(0)); // 特殊数值测试
    }
}

控制台输出结果如下:
在这里插入图片描述

2. 数组求和

对于数组求和问题,我们只要使用循环的方式便可以轻松得出,本例只是为了方便大家更好的熟悉递归函数的设计方法。
记住,使用循环能解决的问题,递归都可以解决;使用递归能解决的问题,循环不一定能解决。
分析如下:

  1. 定义函数:我们定义SumOfArray()函数的意义为数组求和,因此其参数一定要传入一个数组。因此目前函数定义为 SumOfArray(int[] array)
  2. 找子问题求一个数组的和 = 数组第一个元素的值 + 求剩下数组元素的和。求剩下数组元素的和就是一个规模更小的子问题。
  3. 找划分时的变化量:我们发现在划分子问题的过程中,数组的脚标的值在不断变化,因此我们应该在函数定义时新增加一个参数,begin,用于表示此时被求和数组的第一个元素的脚标,此时的函数定义为SumOfArray(int[] array, int begin)。
    那么结合2的分析,我们可以得到这样的一个表达式:SumOfArray(array, begin) = array[begin]+SumOfArray(array, begin+1).
  4. 找出口:我们可以轻松分析出来,当begin变成数组的最后一个元素时,也就是begin = array.length-1时,整个过程就结束了。此时就相当于传入一个只有一个元素的数组,所以返回的结果就是array[beigin].

因此,数组求和的递归函数代码如下:

public class 数组求和 {
    static int SumOfArray(int[] arr, int begin){
    	// 出口
        if(begin == arr.length-1){
            return arr[begin];
        }
        // 重复
        return arr[begin]+SumOfArray(arr, begin+1);
    }
	// 测试代码
    public static void main(String[] args) {
        System.out.println(SumOfArray(new int[]{1,2,3,4,5,6},0));
    }
}

控制台结果如下:
在这里插入图片描述

*题例1与题例2的小升华

我们简单回忆一下题例1与题例2,我们可以发现题例1与题例2有一个划分特点:原问题 = 直接量 + 规模更小的子问题
在求n的阶乘时候 n! = n * (n-1)! 或者说 f(n) = n * f(n-1),n是一个直接量,(n-1)!是一个规模更小的子问题。
同样的,在数组求和问题中,SumOfArray(array, begin) = array[begin] + SumOfArray(array, begin+1),array[begin]是一个直接量,SumOfArray(array, begin+1)是一个规模更小的子问题。
接下来的题例将会提升一点难度,同时划分方法也不再一样。让我们继续学习吧,加油!

3. 斐波那契数列

斐波那契数列,又称黄金分割数列。我们要使用递归函数去求解斐波那契数列的第N项的值。当然,如果不了解斐波那契数列,那么这道题就没办法去求解,所以我们先来了解一下斐波那契数列。
斐波那契数列是一组数字,其特点是F(1) = 1, F(2) = 1, 从第三项开始,每项的值等于前两项的和,即 F(n) = F(n-1) + F(n-2), (n ≥ 3, n ∈ N*) 。我们使用一组数字更形象地描述它,1, 1, 2, 3, 5, 8, 13, ……
斐波那契数列介绍到此结束,我们言归正传,我们依旧使用之前的设计方法去设计递归函数。

  1. 定义函数: 我们定义F()为求第n项的值,因此我们需要传入一个项数n作为参数,此时函数被定义为 F(int n).
  2. 找子问题: 我们在介绍中已经发现了如何将原问题拆分成子问题,当然它是以表达式的形式出现的,F(n) = F(n-1) + F(n-2)。有没有发现它和题例1与题例2的划分是不同的,这里的划分是将原问题拆解成了两个规模更小的子问题
  3. 找划分时的变化量: 很容易发现,项数是唯一变化的量,因此项数n是唯一的参数。
  4. 找出口: 我们知道n属于正整数,同时输入的参数不能为0或负数。所以当 n = 1 或 n = 2 时,便是该函数的出口。此时带入原函数,结果都是1.

综上,使用递归求解斐波那契数列第n项的值的代码如下:

public class 求解斐波那契数列第n项的值 {
    static int F(int n){
    	// 限制项数为正整数
        if(n <= 0){
            System.out.println("项数不能为0或负数");
            return -1;
        }
		// 出口
        if(n==1 || n==2)
            return 1;
       	
       	// 划分的子问题
        return F(n-1) + fib(n-2);
    }

    public static void main(String[] args) {
        System.out.println(F(10));
    }
}

控制台输出结果如下:
在这里插入图片描述

4.汉诺塔问题

在题例3中,我们可以发现划分原问题不仅仅可以划分成一个直接量和一个规模更小的子问题,也可以划分成多个子问题。在本题例中,划分思维进一步升华,需要有更进一步的等价思想。在解决问题之前,我们需要先了解一下汉诺塔问题是什么。
汉诺塔(又称河内塔)问题是源于印度一个古老传说的益智玩具。大梵天创造世界的时候做了三根金刚石柱子,在一根柱子上从下往上按照大小顺序摞着64片黄金圆盘。大梵天命令婆罗门把圆盘从下面开始按大小顺序重新摆放在另一根柱子上。并且规定,在小圆盘上不能放大圆盘,在三根柱子之间一次只能移动一个圆盘。我们以3阶汉诺塔问题图解作为示例,如下图。
在这里插入图片描述
OK,我们已经知晓了什么是汉诺塔问题,那么让我们开始使用递归的思想给出不同阶数的汉诺塔问题的解决步骤,使得所有的圆盘在满足规则的前提下从A移到C。

  1. 定义函数:HanoiTower()的意义为移动N层汉诺塔借助辅助柱从起始柱移动到终点柱。因此应当具有圆盘数(层数)N,origin(起始柱)、help(辅助柱)、destination(终点柱)。此时,函数定义为 HanoiTower(int N, String origin, String help, String destination).

  2. 找子问题(重点,希望大家仔细研读):汉诺塔问题就是将1~N个圆盘(当成一个整体)借助C,从A移动到B。我们可以将其拆为三个步骤:

    1. 将1 ~ N-1 个圆盘(当成一个整体)(此时在A)借助C,从A移动到B;
    2. 将第N个圆盘(此时在A)从A移动到C;
    3. 将1 ~ N-1 个圆盘(当成一个整体)(此时在B)借助A,从B移动到C。

    我们仔细观察可以发现步骤1与步骤3实际上就是原问题更小的划分,都是将一定数目的圆盘(当成一个整体)借助一个柱子移动到另一个柱子。这便是利用等价思想划分子问题。

  3. 找变化量:在整个划分过程中,变化的量有圆盘数目N,以及不同的柱子。

  4. 找出口:当圆盘数目只有一个时,便是汉诺塔问题的出口。此时带入函数,相当于只有一个圆盘的汉诺塔问题。

发布了16 篇原创文章 · 获赞 28 · 访问量 8916

猜你喜欢

转载自blog.csdn.net/NoBuggie/article/details/105495477