探究简单递归Java代码实现

  递归(Recursion),是一种非常基本而又极其重要的编程思想,也是解决某些面试题的强有力武器。无论是链表、二叉树、图等数据结构,还是排序、查找等算法,许多问题通常都能通过递归解决。

  程序调用自身的编程技巧称为递归( recursion)。一般来说,递归需要有边界条件、递归前进段和递归返回段。当边界条件不满足时,递归前进;当边界条件满足时,递归返回。简而言之,递归的两个point是:1)它是一段反复调用自身的程序 ;2)它必须有跳出调用自己的判断条件。不论是递归还是迭代,都是算法中最基础最简单的思想,这里就不做基础的介绍了。我们可以尝试反复从问题的实现、优化中思考,相信一定会有收获。

  我们从一个简单的问题开始。

问题一:猴子吃桃

孙悟空第一天摘下若干蟠桃,当即吃了一半,还不过瘾,又多吃了一个。第二天早上,他又将剩下的蟠桃吃掉一半,还不过瘾,又多吃了一个。之后每天早上他都吃掉前一天剩下桃子的一半零一个。到第10天早上想再吃时,就只剩下一个蟠桃了。求孙悟空第一天共摘了多少个蟠桃?


 这个问题很简单,设前一天剩下的蟠桃为an-1,当天所剩的为an,我们不难得出an-1 =  an-1/2 + 1 + an  整理一下,便是an-1=2an+2.

 按照递推的思想,我们可以得到如下代码

/**
	 * 递推算法
	 */
	public int eat01(int n){
		int a=1;
		//也可以这样考虑,“第1天开始吃桃子,连续吃了n-1天”
		//写成for(int i=1;i<=n-1;i++),无所谓,结果一样
		for(int i=2;i<=n;i++){
			a=2*a+2;
		}
		return a;
	}

同样的,我们也可以用递归的思想去实现

	/**
     * 递归算法
     */
    public int eat02(int n){
        System.out.println("f("+n+")压栈");
        if(n==1){
            System.out.println("此时函数栈达到最大深度!");
            System.out.println("f("+n+")弹栈");
            return 1;
        }else{
            int a=eat02(n-1)*2+2;
            System.out.println("f("+n+")弹栈");
            return a;
        }
    }
    
    /**
     * 递归算法
     * 用三元运算符把代码简化为一行
     */
    public int eat03(int n){
        return n==1?1:eat03(n-1)*2+2;
    }


我们来探讨一下这个算法的时间、空间复杂度。显而易见,时间复杂度极为O(N)

空间复杂度Space(N) = Heap(N)+Stack(N),忽略低次项、系数之后,也记作O(N)。
比如:Space(N) = 3*N^2+16*N+100,那么O(N) = N^2。
Heap(N)表示额外申请堆内存空间的大小,Stack(N)表示函数栈的最大深度。

开始调用哪个函数,该函数就压栈;调用完毕,该函数就弹栈。
我们测试eat02可以看到


这里是没有申请堆内存的,故Heap(N) =0

Space(N) = Heap(N)+Stack(N)
Heap(N) =0
Stack(N) =N
故而,Space(N) = 0+N = O(N)
当Stack(N)增长率很快(超过NlogN)的时候,慎用递归!


问题二:最大公约数与最小公倍数

最大公约数与最小公倍数

最大公约数(Greatest  Common Divisor),简称GCD;只考虑正整数
通常做法:

  • 分解质因数:24 = 2*2*2*3,60 = 2*2*3*5
  • 提取所有的公共质因数:2、2、3
  • 求所有公共质因数的乘积,即得最大公约数:2*2*3 = 12


显然,这个方法并不适合我们编程实现。

辗转相除法

古希腊数学家欧几里得(公元前330年—公元前275年)发明了一种巧妙的算法——辗转相除法,又称欧几里得算法:

  • 令较大数为m,较小数为n;
  • 当m除以n的余数不等于0时,把n作为m,并把余数作为n,进行下一次循环;
  • 当余数等于0时,返回n。


由这个思想我们可以写出一段代码:

/**
	 * 最大公约数的递推算法
	 */
public int gcd01(int m,int n){
	int a=Math.max(m, n);
	int b=Math.min(m, n);
	m=a;
	n=b;
	int r;
	while(m%n!=0){
		r=m%n;
		m=n;
		n=r;
	}
	return n;
}

每执行一次循环,m或者n至少有一个缩小了2倍,故时间复杂度上限为log2M。
对于大量的随机测试样例,每次循环平均能使m与n的值缩小一个10进位,所以平均复杂度为O(lgM)。空间复杂度为O(1)。

非递归的实现了,我们来尝试写一下递归的实现

/**
 * 最大公约数的递归算法
 */
public int gcd02(int m,int n){
	/*int a=Math.max(m, n);
	int b=Math.min(m, n);
	if(a%b==0){
		return b;
	}else{
		return gcd02(b, a%b);
	}*/
	return m>=n?m%n==0?n:gcd02(n, m%n):n%m==0?m:gcd02(m, n%m);
}

这段程序的时间、空间复杂度都为O(lgM)。

求出最大公约数之后,最小公倍数(Least Common Multiple,简称LCM),就能迎刃而解了。
LCM(m,n) = m*n/GCD(m,n)
比如,60与24的最大公约数为12,那么最小公倍数为:60*24/12 = 120。

/**
 * 最小公倍数
 */
public int lcm(int m,int n){
	return m*n/gcd01(m, n);
}


问题三:1到100累加的“非主流算法”

题目:求1+2+3+…+n
用递归以及非递归算法求解,要求时间复杂度为O(N)。


显然,这很简单:

/**
 *递推算法
 */
public int commonMethod01(int n){
	int sum=0;
	for(int i=1;i<=n;i++){
		sum+=i;
	}
	return sum;
}
/**
 * 递归算法
 */
public int commonMethod02(int n){
	if(n==1){
		return 1;
	}else{
		return commonMethod02(n-1)+n;
	}
}


这两个方法的时间复杂度,都为O(N),空间复杂度,前者为O(1),后者为O(N)

或者,更简单的方式,站在数学的肩膀上飞,我们用等差数列的求和公式

/**
 * 等差数列求和公式
 */
public int commonMethod03(int n){
	return n*(1+n)/2;
}

这个方法的时间、空间复杂度都为O(1)


但是正如题中所描述的,“非主流”。

如果加上以下限制,该如何求解?

  • 不允许使用循环语句
  • 不允许使用选择语句
  • 不允许使用乘法、除法
如果不能人为地终止循环或者递归,那就让程序意外终止。
自然而然就联想到:抛异常!


通过异常的话,我们可以换个思路,设计递归算法,使用数组存储数据;当发生数组越界异常时,捕获异常并结束递归。



这么玩,通过递归调用普通函数

/**
 * 递归调用普通函数,并捕获异常
 */
public class SumExceptionMethod {
private int n;
private int[] array;

public SumExceptionMethod() {
	super();
}
public SumExceptionMethod(int n) {
	super();
	this.n = n;
	array=new int[n+1];
}
public int sumMethod(int i){
	try {
		array[i]=array[i-1]+i;
		int k=sumMethod(i+1);
		return k;
	} catch (ArrayIndexOutOfBoundsException e) {
		return array[n];
	}
}
}

我们分析一下:

Heap(N) = N,Stack(N) = N
Space(N) = Heap(N)+Stack(N) = 2N = O(N)

我们还可以试着改造一下,通过递归调用构造函数

/**
 * 递归调用构造函数,并捕获异常
 */
public class SumExceptionConstructor {
public static int n;
public static int[] array;

public SumExceptionConstructor(int i){
	try {
		array[i]=array[i-1]+i;
		new SumExceptionConstructor(i+1);
	} catch (ArrayIndexOutOfBoundsException e) {
		System.out.println(array[n]);
		return;
	}
}
}

一样,Heap(N) = 2N,Stack(N) = N
Space(N) = Heap(N)+Stack(N) = 3N = O(N)


贴上测试方法

public class TestSum {
	@Test
	public void testMethod(){
		int n=100;
		SumExceptionMethod sem=new SumExceptionMethod(n);
		int sum=sem.sumMethod(1);
		System.out.println(sum);
	}
	@Test
	public void testConstructor(){
		int n=100;
		SumExceptionConstructor.n=n;
		SumExceptionConstructor.array=new int[n+1];
		new SumExceptionConstructor(1);
	}
}


问题四:爬楼梯问题

leetCode70:Climbing Stairs
楼梯一共有n级,每次你只能爬1级或者2级。问:从底部爬到顶部一共有多少种不同的路径?


最后一步:要么从5往上走2级,要么从6往上走1级;故f(7) = f(5)+f(6)
到达1,向上爬一级;f(1)=1
到达2,从1向上爬一级;直接向上爬两级;f(2)=2



递归方程(递推公式)为:

这其实也就是斐波那契数列的思想,我们可以轻易的写出以下代码:

/**
 *递归算法
 */
public int fib01(int n){
	count++;
	if(n==1||n==2){
		//System.out.println(n);
		return n;
	}else{
		int k=fib01(n-1)+fib01(n-2);
		//System.out.println(n);
		return k;
	}
}
/**
 * 递归算法 一行
 */
public int fib02(int n){
	return n==1||n==2?n:fib02(n-1)+fib02(n-2);
}

@Test
public void test(){
	int n=15;
    int result=fib01(n);
    System.out.println(result);
    System.out.println(count);
    //估计上限与下限
    Assert.assertTrue(count<=Math.pow(2, n)&&count>=Math.pow(2, n/2));
}


这么写的话时间、空间复杂度为O(2^N),O(N)。显然这样的思路很简单,但是效率貌似不咋地。所以我们或许得考虑以下别的方法。


递归树(Recursive Tree)
弹栈序列,为二叉树的后序遍历序列(Post Order Traversal Sequence)
2 1 3 2 4 2 1 3 5


一个重要定理:树的高度 = 栈的最大深度



备忘录法

新开辟一个数组;

如果array[i]不为0,则直接返回;

如果array[i]为0,array[i]=f(i-1)+f(i-2),并返回array[i]



代码如下:

public int dfs(int n,int[] array){
	if(array[n]!=0){
		return array[n];
	}else{
		array[n]=dfs(n-1, array)+dfs(n-2, array);
		return array[n];
	}
}
/**
 * 备忘录法
 */
public int fib03(int n){
	if(n==1||n==2){
		return n;
	}else{
		int[] array=new int[n+1];
		array[1]=1;
		array[2]=2;
		return dfs(n, array);
	}
}

这种方法的时间、空间复杂度都为O(N),是不是相比较上一个方法已经有了小小的进步呢?我们还可以继续尝试探索、优化。


动态规划法

动态规划法(Dynamic programming),简称DP。

有两个要素:1)最优子结构 (在这个问题中的体现为:fib(n-1)+fib(n-2)=fib(n))

       2)重叠子问题由递归树可知)


借助数组,从左往右依次求解

/**
 * 动态规划法
 */
public int fib04(int n){
	if(n==1||n==2){
		return n;
	}else{
		int[] array=new int[n+1];
		array[1]=1;
		array[2]=2;
		for(int i=3;i<=n;i++){
			array[i]=array[i-1]+array[i-2];
		}
		return array[n];
	}
}

这种方法的时间、空间复杂度都为O(N)好像没什么改进,没事我们继续坚持探索。


状态压缩法

状态压缩法,又称滚动数组、滑动窗口(Sliding Window),用于优化动态规划法的空间复杂度。

/**
 * 滚动数组
 */
public int fib05(int n){
	if(n==1||n==2){
		return n;
	}else{
		int a=1;
		int b=2;
		int t;
		for(int i=3;i<=n;i++){
			t=a+b;
			a=b;
			b=t;
		}
		return b;
	}
}

这种方法的时间复杂度为O(N),空间复杂度已经成功降低为O(1)了!但是我们还不满意,还想再优化一下,那只好再次站在数学的肩膀上飞翔了~


斐波那契数列的通项公式



开平方:Math.sqrt()
幂函数:Math.pow()
四舍五入:Math.floor()


/**
 * 通项公式法
 */
public int fib06(int n){
	if(n==1||n==2){
		return n;
	}else{
		double sqrtFive=Math.sqrt(5);
		n++;
		double a=Math.pow((1+sqrtFive)/2, n);
		double b=Math.pow((1-sqrtFive)/2, n);
		double result=1/sqrtFive*(a-b);
		return (int) Math.floor(result);
	}
}

这种方法的时间复杂度为O(log2N),空间复杂度为O(1).我们从一开始的 O(2^N),O(N)逐渐优化到O(log2N),O(1)。感慨数学的伟大、算法的神奇。

编程很简单,写代码也不难,而计算机科学的魅力是源于算法的,就像“编程之美”之中所描述的那样,当你对生活感到厌倦、当你疲惫于现在的编码生活,请打开这本书看看。愿从这一刻起,你我都能走上算法的这条“不归路”。


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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值