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