在上一篇讲二分查找的文章中我们提到了实现二分查找的一种方式,那就是使用递归算法。
//递归
public static int recursionBinarySearch(int[] arr,int key,int low,int high){
if(key < arr[low] || key > arr[high] || low > high){
return -1;
}
int middle = (low + high) / 2; //初始中间位置
if(arr[middle] > key){
//比关键字大则关键字在左区域
return recursionBinarySearch(arr, key,low,middle-1);
}else if(arr[middle] < key){
//比关键字小则关键字在右区域
return recursionBinarySearch(arr, key,middle+1,high);
}else {
return middle;
}
}
根据二分查找的原理,当我们取得中间值arr[middle]
大于想要查找的目标值key
的时候,我们需要排除比key
大的那一半数据,也就是arr[middle]
之后的数据。在代码中我们可以看到,我们通过将high
参数设置为middle-1
来排除middle
之后的数据。同时我们将middle-1
当做新的high
传入到函数中继续调用。也就是这个函数自己又继续调用了自己本身,只是改变了参数,将问题分解为原来的一半。然后在此函数中继续上面的步骤,改变参数low
或者high
,将low
或者high
的新值作为参数继续传给自己并调用自己,这就是递归。
递归算法设计的基本思想是:对于一个复杂的问题,把原问题分解为若干个相对简单类同的子问题,继续下去直到子问题简单到能够直接求解,也就是说到了递归的出口,这样原问题就有递归得解。
例如上面二分查找(一个复杂的问题),我们不断将查找范围减半(也就是把原问题分解为相对简单的子问题),直到查找范围就剩一个数(子问题简单到能够直接求解),我们就找到了递归的出口(也就是上述函数中的return middle)。
举两个个生活中的例子来说明什么是递归
- 递归就是洋葱,一个洋葱是带着一层洋葱皮的洋葱,拨开一层洋葱皮,里面还是洋葱,再剥开一层洋葱皮,里面···还是洋葱。(如果你愿意一层一层一层的剥开TA的心。。。)
- 递归就是包子馅的包子,也就是你剥开包子皮,发现里面是一个新的包子。对这个包子取一个极限,你就会发现,这个包子就是一个馒头。。。
上面说到,使用递归算法求解,需要找到递归的出口,很容易理解,若是没有出口,那么函数总是自己调用自己,永远也不会停下来,直到报出栈溢出(StackOverflowError)的异常。例如如下代码
public class Test {
public static void main(String[] args) {
test();
}
public static void test(){
test(); //自己调用自己
}
}
输出结果为:
Exception in thread "main" java.lang.StackOverflowError
at Test.test(Test.java:11)
at Test.test(Test.java:11)
at Test.test(Test.java:11)
at Test.test(Test.java:11)
at Test.test(Test.java:11)
at Test.test(Test.java:11)
at Test.test(Test.java:11)
at Test.test(Test.java:11)
拿上面包子馅的包子来举例,我们一层层的剥开包子皮,目的就是找到最里面的包子,但如果我们在递归算法中不设置出口条件,那么就会出现不管剥开多少次包子皮,都找不到最里面的包子的情况。然后你就会发现,这包子其实就是一个馒头。。
所以在我们使用递归算法的时候,必须有一个明确的递归结束条件,称为递归出口
,有的书上也称为基线条件
。
每个递归函数都需要包含两部分,基线条件和递归条件。递归条件指的是函数调用自己,而基线条件指的是函数不再调用自己,从而避免无线循环。
例如上述二分查找中的基线条件与递归条件,
if(arr[middle] > key){ //递归条件
return recursionBinarySearch(arr, key,low,middle-1);
}else if(arr[middle] < key){ //递归条件
return recursionBinarySearch(arr, key,middle+1,high);
}else { //基线条件(递归出口)
return middle;
}
递归的应用有哪些呢?最经典的应用就是求斐波那契数列了。
public int f(int n){
if(n == 1 || n == 2){ // 基线条件(递归出口)
return 1;
}
return f(n-1) + f(n-2); //递归条件
}
当然斐波那契数列也可以用循环的方式来解决。
public int f(int n) {
int f0 = 1;
int f1 = 1;
int f2 = 0;
for(int i = 2; i < n; i++){
f2 = f0 + f1;
f0 = f1;
f1 = f2;
}
return f2;
}
这两种方法的作用相同,结过相同,但在程序运行的性能上有很大差距。
使用递归的方式来求解斐波那契数列的时间复杂度为O(2n),而使用循环的方式的时间复杂度为O(n)。可见递归只是让解决方案变得更清晰,并没有性能上的优势。实际上,在一些情况下,使用循环的性能更好。
当然,递归的应用并不只是斐波那契数列求和,二叉树的遍历,排序算法中的排序,都可以用递归来实现。
递归是一种非常优雅的问题解决方法(在不考虑程序性能的前提下),递归将人分为截然不同的阵营:恨它的、爱它的以及恨了几年后又爱上它的。
最后分享一句话,Leigh Caldwell在Stack Overflow上说:“如果使用循环,程序的性能可能更高;如果使用递归,程序可能更容易理解。如何选择要看什么对你来说更重要。”