Fork me on GitHub
代码改变世界

递归,循环,尾递归

2018-11-13 10:55  沉睡的木木夕  阅读(1261)  评论(1编辑  收藏  举报

递归,循环,尾递归

方法递归,简而言之就是方法本身自己调用自己;

咬文嚼字的分析就是两个过程:“递“过程和”归“过程,所有的递归问题都能用地推公式标识.例如斐波拉契数列就能用递推公式表示:

\[f(n) = f(n-1) +f(n-2)其中fn(0)=1,f(1)=1 \]

转换成代码就是

public static int FibonacciRecursively(int n){
  	if(n<2) return n;
  	return FibonacciRecursively(n-1) + FibonacciRecursively(n-2);
}

递归问题要满足三个条件:

  • 一个问题可以分解成多个子问题的解;子问题就是规模更小的问题(逻辑不变)
  • 这些被分解的子问题,除了规模不一样之外,解决思路一样
  • 存在条件来终止递归;这个好理解,因为自己调用自己总不能无线循环下去,所以必须有终止条件。

我们来切换一个思考场景:假如这里有N(>1)个台阶,人上台阶每次只能跨一个或者两个,那么有多少种走法能到顶上呢?

我们先分解问题,第一个台阶的走法只有两种,第一种是走一个台阶,第二种是走两个台阶;那么n个台阶的走法就是等于先走1阶后,n-1个台阶的走法加上先走2阶后,n-2个台阶的走法;

所以用公式表示就是

\[f(n) = f(n-1)+f(n-2) \]

满足终止条件的就是当只有一个台阶的时候就只有一种可能那就是f(1)=1,f(2)=2

所以这个时候就很容易看出这种走楼梯的思想也是斐波拉契数列的体现。

递归的陷阱

线程在执行方法的时候,都会分配一定尺寸的栈空间。方法调用时,其中的成员信息(临时变量,参数,返回地址等)等信息都会存储在线程栈里,所以这些信息没有及时被GC,返回大深度的循环调用方法,这些内存累加起来就会超出该线程分配的栈空间了,自然就报内存超出的错误。

如何避免内存超出这个问题

  1. 固定方法调用的深度;当超出设定的深度时,显示报异常,这种方法局限性很大,总有不满足这个深度值的时候,这个方法就不奏效了。
  2. 把方法改成非递归模式(while,for循环)这样就不会存在栈内存堆积
  3. 在2的基础之上改写成“尾递归”形式(函数式编程思想)

具体实现方法在后面拓展会具体讲到。

递归代码所产生的重复计算

从走台阶的递推公式我们发现,其实有很多值被重复计算了多次。例如计算f(5),需要先计算f(4)和f(3),而计算f(4)要计算f(3)和f(2)。其中f(3)就被重复计算了,那么为了避免这种情况,缩减重复计算带来的时间损耗,我们可以用一个对象结构(散列表等)来记录已经计算的值,我们就可以避免这个问题了

上述代码改成如下:

public static int FibonacciRecurisivelyAvoidRepeat (int n) {
    if (n < 2) return n;
    if (dic.ContainsKey (n)) return dic[n];
    int ret = FabonacciRecurisively (n - 1) + FabonacciRecurisively (n - 2);
    dic.Add (n, ret);
    return ret;
}

这种方式是典型的“空间换时间”,并且空间复杂度是O(n)。

递归函数拓展理解

在前面谈如何避免内存超出这个问题时,就谈到了可以把递归模式的方式改成一个方法中的循环体模式

那是不是所有的递归方法都能改成这样呢?答案是可以这么说。

那么我们把 f(n)=f(n-1)+f(n-2) 改成非递归形式是什么样子的呢?请看代码

public static int FibonacciGeneral(n){	
  	if(n < 2) return n;
  	int acc1 = 0;	//prevprev
  	int acc2 = 1;	//prev
  	while(n != 0){
    	acc2 = acc1 + acc2;
      	acc1 = acc2 - acc1;
      	n--;
  	}
  	return acc1
}

这里while循环是关键,主要是实现以下过程

f(5) = f(4) + f(3)

f(5) = [(f(3) + f(2))] + [(f(2) + f(1))]

f(5) = [((f(2) + f(1) )+ f(2))] + [(f(2) + f(1))] f(1) = 1,f(0) =0 或者 f(1) = 1,f(2) = 1,n>2

f(5) = [(((f(1) + f(0)) + f(1))) + (f(1) + f(0))] + [((f(1) + f(0)) + f(1))]

我们可以这么理解,f(5)是要求的当前值,所以上述公式改成文字公式则为:

f(4)当前值 = f(3)上一个值 + f(2)上上一个值

(f5)当前值 = (f4)上一个值 + (f3)上上一个值

(f6)当前值 = f(5)上一个值 + f(4)上上一个值

..

上一个值 = 求上一个值时参与的上一个值 + 求上一个值时参与的上上一个值

...

所以我们只需要循环把单次循环体计算的值记录下来参与下一次循环体计算。如此反复达到结束条件即可,这样就不会存在栈空间堆积超出内存异常了。

我还讲了最后一点,是在循环遍历基础上改写成的一种尾递归方法调用,改写方式很简单,把这个方法所用到的变量提取出来当参数使用,就变成下面的方法

public static int FibonacciTailRecurisively(int n, int acc1, int acc2){
  	if(n == 0) return acc1;
  	return FibonacciTailRecurisively(n - 1, acc2, acc1 + acc2)
}

这种形式的调用方式就是尾递归,在方法最后被调用时,线程栈里面的临时变量与参数此时已经没任何用了,可以被GC回收,所以理论上就是同上面的循环方法是一致的,无论有多深,都不会发生内存异常。

练习

结合业务场景给定一个菜单结构数据源,如何查找某个菜单的最大父菜单?

普通方法,递归,尾递归。