博主秋招提前批已拿百度、字节跳动、拼多多、顺丰等公司的offer,可加微信:pcwl_Java 一起交流秋招面试经验,可获得博主的秋招简历和复习笔记。
一、递归的基本概念
1、定义
递归:指的是一个过程,函数直接或者间接的调用自己,此时则发生了递归。
递归的两个要素:递推公式和递归边界
可以看到递归的定义非常的简洁,但是理解起来就没有这么容易了。不知道大家是否和我一样,在遇到递归问题的时候,总是试图去一步一步的分析,然而往往递归不了几次,我就已经迷糊了。这并不是我们的理解能力和逻辑能力有问题,而是递归这种思想并不符合我们人类的思维习惯,相对于递归,我们更加容易理解迭代,生活中做事情不就是拆成一步一步的去做嘛。但是,在实际的生活场景中,几乎不会遇见递归的思想。所以我们不能够直观的理解递归这种思想,也是很正常的,而且实际应用中也不用去搞清楚整个递归过程,比只需要知道递归过程中关键的两个要素就可以了,下文中会详细讲解。
【ps:上面讲了这么多,就是想告诉自己以及看到这篇文章的你,不要试图去理解中整个递归的过程,这是在给自己制造麻烦,或许真正理解递归的人也无法用几句话把递归给讲清楚,这就是只可意会不可言传吧,多看多练,肯定都会弄明白的】
先来看个简单的案例:1+2+3+ ... +n 求和
我们以往遇到这个问题,有的人直接用for循环搞定,有的人通过等差数列公式求出,实际上我们也可用递归的方式求和。
// 求和 0 + 1 + 2 + 3 + ... + 9
public class RecursionDemo1 {
public static void main(String[] args) {
int sum = getSum(9);
System.out.println(sum);
int sum2 = Sum(10);
System.out.println(sum2);
}
// 等差数列方式
public static int Sum(int n){
if(n < 0){
return 0;
}else{
return (n * (n - 1)) / 2;
}
}
// 递归的方式求和
public static int getSum(int n){
if(n < 0){
return 0;
}else{
return getSum(n-1) + n;
}
}
}
2、什么时候用递归?
递归的基本思想就是“自己调自己”,递归方法实际上体现了“以此类推”、“用同样的步骤重复”的思想,它可以用简单的程序来解决一些较为复杂的问题,但是运算量却很大。
尽管递归程序不是很好理解,运算量也很大,但是递归程序的使用还是非常频繁的。无论是直接递归还是间接递归,都需要实现当前层调用下一层时的参数传递,并取得下一层所返回的结果,并向上一层调用返回当前层的结果。【递和归的两个过程】。
2.1 使用递归需要满足的三个条件:
(1)、一个问题的解可以分解为几个子问题的解,所谓的子问题就是数据规模更小的问题;
(2)、这个问题与分解后的子问题,除了数据规模不同,求解思路完全一样;
(3)、存在递归终止条件,不可以无限的进行递归循环。
2.2 编写递归代码的关键步骤:
(1)、根据将大问题拆分为小问题过程中的规律,总结出递推公式;
(2)、确定终止条件;
(3)、将递推公式和终止条件翻译成代码。
光说定义,还是有点抽象的,那下面就是“王道”时间,上代码!
3、递归的优缺点
优点:代码的表达力很强,写起来比较简洁;
缺点:空间复杂度较高,有堆栈溢出的风险、存在重复计算等问题。
对于上面的缺点,下面提出两个应对策略:
(1)堆栈溢出:可以声明一个全局变量来控制递归的深度,从而避免堆栈的溢出;
(2)重复计算:通过某种数据结构(比如:链表)来保存已经求解过的值,下次遇到的时候,进行查询,如果找到了则直接拿出来用,没有找到再进行计算,并将计算结果放入该数据结构中,这样就可以避免重复计算。
4、递归与迭代的对比
【迭代是人,递归是神!---- 编程之美】
递归:一个函数在其定义中直接或者间接调用自己的一种方法,它通常把一个大型复杂的问题转化为一个与原问题相似的规模较小的问题来解决,可以极大的减少代码的书写量。递归的能力在于用有限的代码语句来定义对象的无限集合。
迭代:从已知值出发,通过递推式,不断更新变量的新值,一直到能够解决要求的问题为止。如果递归是自己调用自己的话,迭代就是A不停的调用B。递归中一定有迭代,但是迭代中不一定有递归,大部分可以相互转换,能用迭代的不用递归,因为递归的空间复杂度高,如果递归太深很容易造成堆栈溢出。
下面给出两个案例:
案例1: 1 + 2 + 3 + ...+ n (ps:引用自递归和迭代的区别)
public class Demo {
// 递归
public int recursion(int n){
if(n > 1){
return n + recursion(n - 1);
}else{
return 1;
}
}
// 迭代
public int iterator(int n){
int sum = 0;
for(int i = 0; i < n; i++){
sum += i;
}
return sum;
}
}
案例2:斐波那契数列的求解
斐波那契数列(又称:黄金分割数列)指的是这样一个数列:0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, 233,377,610,987,1597,2584,4181,6765,10946,17711,28657,46368 【特别指出:第0项是0,第1项是第一个1】
这个数列从第三项开始,每一项都等于前两项之和,则递归的方法为:
fib(0) = 0;
fib(1) = 1;
fib(n) = fib(n - 1) + fib(n - 2); (n >= 2)
public class Demo2 {
public static void main(String[] args) {
int recursionFib = recursionFib(9); // 迭代8次
System.out.println(recursionFib);
int iteratorFib = iteratorFib(10); // 迭代8次
System.out.println(iteratorFib);
}
// 递归实现
public static int recursionFib(int n){
if(n > 1){
return recursionFib(n - 1) + recursionFib(n - 2);
}else{
return n; // 递归终止条件:n = 0/1
}
}
// 迭代实现
public static int iteratorFib(int n){
int temp1 = 0 , temp2 = 0, temp3 = 1;
if(n <= 1){
return n;
}else{
for(int i = 2; i <= n; i++){
temp1 = temp2 + temp3;
temp3 = temp2;
temp2 = temp1;
}
return temp1;
}
}
}
二、常见的递归案例
案例1:递归阶乘的实现: n! = n * (n - 1) * (n - 2)* ...*1 (n > 0)
// 阶乘的递归实现
public static int mulity(int n){
if(n == 1){
return 1;
}
return n * mulity(n-1);
}
上面这个程序的递归过程如下,我们以mulity(4)为例:
// 计算表达式表示
mulity(4) = 4 * mulity(3)
= 4 * (3 * mulity(2) )
= 4 * (3 * (2 * mulity(1) ) )
= 4 * (3 * (2 * (1 * mulity(0) ) ) )
= 4 * (3 * (2 * (1 * 1) ) )
= 4 * (3 * (2 * 1) )
= 4 * (3 * 2)
= 4 * 6
= 24
// 函数式表示
factorial(4)
factorial(3)
factorial(2)
factorial(1)
return 1
return 2*1 = 2
return 3*2 = 6
return 4*6 = 24
案例2:判断一串字符串中是否有相同的内容
// 判断一系列字符串中是否有相同的内容
public static boolean isContains(String[] arr) {
int n = 0;
boolean b = false;
if (n == arr.length) {
b = true;
} else {
for (int i = n; i < arr.length - 1; i++) {
if(arr[n].equals(arr[i + 1])){
System.out.println("重复位置的下标为为:" + n + "和" + (i + 1));
return true;
}
}
n++;
isContains(arr);
}
return b;
}
// 测试代码
public static void main(String[] args) {
String[] str = {"a","b","c","a","d","a","s"};
boolean contains = isContains(str);
System.out.println(contains);
}
案例3:数组中元素求和
// 数组元素求和
public static int arraySum(int array[], int n){
if(n == 1){
return array[0];
}else{
return array[n - 1] + arraySum(array, --n);
}
}
// 数组元素求和,无需参数:元素的个数n
public static int arrSum(int array[]){
int len = array.length;
if(len == 1){
return array[0];
}else{
// 递推公式,简单推导下,就可以找出来,每次最后两个位置的元素值相加,其和放在倒数第二个位置上,再和倒数第三个相加,依次类推
array[len - 2] = array[len - 2] + array[len - 1];
int[] tempArr = new int[len - 1];
System.arraycopy(array, 0, tempArr, 0, len - 1);
return arrSum(tempArr);
}
}
// 测试案例
public static void main(String[] args) {
int[] arr = {1, 2, 3};
int arraySum = arraySum(arr, 3);
System.out.println(arraySum);
int arrSum = arrSum(arr);
System.out.println(arrSum);
}
案例4:河内塔问题
河内之塔(Towers of Hanoi)是法国人M.Claus(Lucas)于1883年从泰国带至法国的,河内为越战时北越的首都,即现在的胡志明市;1883年法国数学家 Edouard Lucas曾提及这个故事,据说创世纪时Benares有一座波罗教塔,是由三支钻石棒(Pag)所支撑,开始时神在第一根棒上放置64个由上至下依由小至大排列的金盘(Disc),并命令僧侣将所有的金盘从第一根石棒移至第三根石棒,且搬运过程中遵守大盘子在小盘子之下的原则,若每日仅搬一个盘子,则当盘子全数搬运完毕之时,此塔将毁损,而也就是世界末日来临之时。事实上,若有n个盘子,则移动完毕所需之次数为2^n - 1,所以当盘数为64时,则所需次数为:264- 1 = 18446744073709551615 为5.05390248594782e+16年,也就是约5000世纪,如果对这数字没什么概念,就假设每秒钟搬一个盘子好了,也要约5850亿年左右。
解题思路:
三支棒子从左到右依次编号为:A、B、C。
当A棒只有1个盘子的时候,只需要将A棒上的一个盘子移到C棒上;
当A棒上有2个盘子的时候,先将A棒上的1号盘子移动到B棒上,再将A棒上的2号盘子移动到C棒上,最后再将B棒上的1号盘子移动到C棒上;
当A棒上有3个盘子的时候,先将A棒上的1号盘子搬移到C棒上,再将A棒上的2号盘子搬移到B棒上,再将C棒上的1号盘子搬移到B棒上,然后将A棒上的3号盘子搬移至C棒上,再将B棒上的1号盘子搬移至A棒上,B棒上的2号盘子搬移到C棒上,最后将A棒上的1号盘子搬移到C棒上。
当A棒上有4个盘子的时候,先将A棒上的1号盘子搬移到B棒上,2号盘子搬移至C棒上,然后将B棒上的一号盘子搬移到C棒上,再将A棒上的3号盘子搬移到B棒上,再将C棒上的一号盘子搬移到A棒上,再将C棒上的2号盘子搬移到B棒上,再将A棒上的1号盘子搬移到B棒上,然后将A棒上的4号盘子搬移到C棒上,然后将B棒上的1号盘子搬移到C棒上,B棒上的2号盘子搬移到A棒上,再将C棒上的1号盘子搬移到A棒上,然后将B棒上的3号盘子搬移到C棒上,再将A棒上的1号盘子搬移到B棒上,将A棒上的2号盘子搬移到C棒上,最后将B棒上的1号盘子搬移到C棒上,结束!
所以,当A棒上有n个盘子的时候,我们可以将整个盘子搬移过程大概分为下面三步:
(1)先将A棒上编号为1~n的盘子(共n-1个)搬移到C棒上(需要借助C棒);
(2)再将A棒上最大的n号盘子搬移到C棒上;
(3)最后将B棒上的n-1个盘子借助A棒移动到C棒上
public class HNT {
static int i = 1;
public static void main(String[] args) {
Scanner sc = new Scanner(System.in);
int n = sc.nextInt();
char from = 'A', depend_on = 'B', to = 'C';
hanio(n, from, depend_on, to);
}
public static void hanio(int n, char from, char depend_on, char to){
if(n == 1){
move(n, from, to);
}else{
hanio(n - 1, from, to, depend_on); // 借助C棒,将A棒上的n-1个盘子搬移到B棒上
move(n, from, to); // 将A棒上的n号盘子搬移到C棒上
hanio(n - 1, depend_on, from, to); // 将B棒上的n-1个盘子借助A棒移动到C棒上
}
}
public static void move(int n, char from, char to){
System.out.println("第" + (i++) + "步:" + n + "号盘子" + from + "移动到" + to);
}
}
【ps:对于上面的过程,你只需要明白划分出的三个步骤就行,不要试图去搞清楚一个数量级比较大的盘子搬移过程,只会自找麻烦,要相信计算机可以给你算出准确的结果】
【最后将极客时间《数据结构与算法之美》中的一段话作为本篇博文的结尾,也是我觉得最重要的!!!】
对待递代码的理解:对于递归代码,如果你想试图弄清楚整个递归(包括:递和归)的过程,实际上是进入了一个思想误区。当你遇到递归代码的时候,如果一个问题A可以分解为若干个子问题B、C、D,那么你可以假设子问题A、B、C已经解决。而且你只需要思考问题A与其子问题B、C、D两层之间的关系即可,不需要一层层的往下思考子问题与子子问题,子子问题与子子子问题之间的关系。屏蔽掉递归的细节,将递归的代码抽象成一个递推公式,不用想一层层的调用关系,更不要试图用人脑去分解递归的每个步骤。
参考及推荐:
4、《Thinking In Algorithm》彻底理解递归
学习不是单打独斗,如果你也是做Java开发,可以加我微信:pcwl_Java,一起分享经验学习!