循环或递归的选择
循环就不多介绍了,简单说一下递归。程序中,递归一般是指方法(函数)调用自己。常用的递归类型有两种:
-
头递归 (head recursion) 是在接近方法开始处发起的递归调用。头递归是要处理的第一批内容之一,因为它调用自己,所以它依赖于调用堆栈上执行的上一次操作的结果。因为它使用调用堆栈来处理,所以如果调用堆栈不够大,则有可能发生堆栈溢出。
-
尾递归 (tail recursion) 是在结尾处执行的递归调用,是要处理的最后一行代码。无论递归调用有多深,此方法都不会使用调用堆栈。
我们平时所说的递归一般为头递归。
为了更清晰的理解概念,通过代码分析一下,分别使用头递归、尾递归、循环的方式实现阶乘方法,下面以5!(5 的阶乘)为例做计算。
1.头递归
public long getFactorial(long currNum) {
if (currNum == 1) {
return 1;
}
return currNum * getFactorial(currNum - 1);
}
每个递归调用需要完成并放在递归堆栈上,才能计算阶乘,以表格表示栈,表格的每一行就是压到栈里的内容。
栈 |
---|
1 * 1 |
2 * f(1) |
3 * f(2) |
4 * f(3) |
5 * f(4) |
当currNum=1时,栈达到最大,此时,开始由栈顶层逐级出栈运算,计算顺序:
1 * 1 = 1 * 2 = 2 * 3 = 6 * 4 = 24 * 5 = 120
2.尾递归
public long getFactorial(long currNum, long sum) {
if (currNum == 0) {
return sum;
}
sum *= currNum;
return getFactorial(currNum - 1, sum);
}
没有压栈操作,每次调用都会传递本次的计算结果
计算顺序: |
---|
getFactorial(5,1) |
getFactorial(4,5) |
getFactorial(3,20) |
getFactorial(2,60) |
getFactorial(1,120) |
getFactorial(0,120) |
3.循环
public long getFactorial(long currNum) {
long sum = 1;
while(currNum > 0 ){
sum *= currNum;
currNum--;
}
return sum;
}
循环没有占操作,执行顺序为: 5 * 4 * 3 * 2 * 1 = 120
可以看到尾递归和循环已经十分相似了,实际上有些VM也是把尾递归转成循环执行的,但是注意,并不包含SUN的JVM。
4.Q&A
Q.哪一个更好?
A. 不绝对。在一般情形下,循环提供了比递归更好的性能,因为方法调用的成本比执行常规语句更高。在使用头递归的情况下,调用堆栈会增加,必须遍历它才能获得最终答案。但是,不能否认的是有些场景下,递归的性能更好、写法更优雅。
Q.尾递归的好处是什么?
A. 常规递归方法(头递归)会增加调用栈的大小。在尾递归中,最后要做的是递归,运算在之前就已经完成了。一轮递归调用完毕后就没有其他事情了(除了运算),因此调用时生成的信息也就没什么用了。这些无用信息可以丢弃,然后用一组新的参数来调用一次递归方法来产生一个新的结果。这也就是说,栈调用减少带来了内存消耗减少并且程序的性能更好。
Q.什么情况下应该采用递归?
A. 递归方法在循环树结构以及避免丑陋的嵌套循环的情况下是非常好用的,同时需要注意的是,方法必须是收敛的、必须有明确的结束条件、递归次数是可预见的。
5.总结
我们怎么选择? 优先使用循环,在满足以下几种条件时,可以考虑使用递归:
- 递归的语义特别简明;
- 执行频次不高;
- 递归次数可预见且十分有限;
- 循环的实现写法特别复杂。