我们知道一些常见的算法既可以用循环也可以使用递归实现,比如二分查找法。循环和递归基本思路是差不多的,但是这两者之间最大区别则是在于执行效率上,循环的执行效率要比递归快,递归的代价要比循环的大得多。
以Java的二分查找来比较两种算法的执行效率,如下代码:
public static void main(String[] args) throws Exception {
int length = 999999999;
int[] nums = new int[length];
for (int i = 0; i < length; i++) {
nums[i] = i;
}
int target = 1;
int time = 9000000;//单次执行时间太短,比较起来不太明显
long start0 = System.currentTimeMillis();
for (int i = 0; i < time; i++) {
search0(nums, target);
}
long end0 = System.currentTimeMillis();
System.out.println(end0 - start0);
long start1 = System.currentTimeMillis();
for (int i = 0; i < time; i++) {
search1(nums, 0, length - 1, target);
}
long end1 = System.currentTimeMillis();
System.out.println(end1 - start1);
}
public static int search0(int[] nums, int target) {
int result = -1;
int left = 0, right = nums.length - 1;
while (left <= right) {
int index = (left + right) / 2;
int mid = nums[index];
if (mid == target) {
result = 1;
break;
} else if (target > mid) {
left = index + 1;
} else {
right = index - 1;
}
}
return result;
}
public static int search1(int[] nums, int left, int right, int target) {
int result;
if (left <= right) {
int index = (left + right) / 2;
int mid = nums[index];
if (mid == target) {
result = 1;
} else if (target > mid) {
result = search1(nums, index + 1, right, target);
} else {
result = search1(nums, left, index - 1, target);
}
} else {
result = -1;
}
return result;
}
从测试的结果来看,循环要比递归快120ms左右。为什么会这样呢?我们从字节码层面来详细分析一下这个问题,看看循环跟递归具体有什么区别。为了便于分析,我们把上面的代码做一些简化:
public static int search0(int[] nums, int target) {
int result = -1;
int left = 0, right = nums.length - 1;
while (left <= right) {
int index = (left + right) / 2;
}
return result;
}
public static int search1(int[] nums, int left, int right, int target) {
int result = -1;
if (left <= right) {
int index = (left + right) / 2;
result = search1(nums, index + 1, right, target);
}
return result;
}
首先分析循环方法search0的字节码:
0 iconst_m1 //将常量-1压入操作数栈
1 istore_2 //将-1赋值给局部变量result,int result = -1
2 iconst_0 //将常量0压入操作数栈
3 istore_3 //将0赋值给局部变量left,int left = 0
4 aload_0 //将数组nums的引用压入操作数栈
5 arraylength //获取数组nums的长度,nums.length
6 iconst_1 //将常量1压入操作数栈
7 isub //做减法,nums.length - 1
8 istore 4 //将上面相减的结果赋值给变量right,right = nums.length - 1
10 iload_3 //将变量left压入栈
11 iload 4 //将变量right压入栈
13 if_icmpgt 27 (+14) //比较跳转, while (left <= right)
16 iload_3 //将变量left压入栈
17 iload 4 //将变量right压入栈
19 iadd //相加,left + right
20 iconst_2 //将常量2压入操作数栈
21 idiv //相除,(left + right) / 2
22 istore 5 //把相除的结果赋值给index, int index = (left + right) / 2
24 goto 10 (-14) //无条件跳转到行号10,实现循环
27 iload_2 //将变量result压入栈
28 ireturn //返回result到上一级调用
循环的字节码实现是很简单的,主要涉及10,12,13,24这四条字节码。
- 10 iload_3 、11 iload 4:int变量加载指令。这两条指令是把int型局部变量从局部变量表中压入操作数栈当中,分别对应局部变量left和right。在Java栈帧当中,局部变量表存的是局部变量,比如这个方法中的result,left,right,index。JVM是基于栈的,字节码在操作局部变量时,首先需要把局部变量从局部变量表中压入操作数栈当中。
- 13 if_icmpgt 27 (+14):比较跳转指令。他会把操作数栈当中的两个数做比较,如果value1 > value2(栈顶),则会设置PC为指定的字节码行号,JVM则会跳转到相应的字节码行号继续执行,在这里就是27。27是把局部变量result压入操作数栈当中,28则是把result当返回值返回给上一级调用,27、28这里两行指令对应Java语句return result,这里已经跳出循环体了。
- 24 goto 10 (-14):无条件跳转指令。当代码执行到这里时会直接跳转到相应的行号,这里是10,重复执行前面的指令,这样就达到了循环的目的。
由此可见的循序的实现是很简单的,主要就是指令跳转。
对于递归而言,过程相对要复杂得多。
0 iconst_m1 //将常量-1压入操作数栈
1 istore 5 //将-1赋值给变量result,int result = -1
3 iload_2 //将变量left入栈
4 iload_3 //将变量right入栈
5 if_icmpgt 29 (+24) //比较跳转,if (left <= right)
8 iload_2 //将变量left入栈
9 iload_3 //将变量right入栈
10 iadd //相加,left + right
11 iconst_2 //将常量2压入操作数栈
12 idiv //相除,(left + right) / 2
13 istore 6 //将相除的结果赋值给index,int index = (left + right) / 2;
15 aload_0 //将引用this压入栈,this是被调用方法search1的引用
16 aload_1 //将数组nums的引用压入栈
17 iload 6 //将变量index压入栈
19 iconst_1 //将常量1压入操作数栈
20 iadd //相加
21 iload_3 //将变量right压入栈
22 iload 4 //将变量target压入栈
24 invokevirtual #2 <com/algo/zy/Test.search1 : ([IIII)I> //调用实例方法search1
27 istore 5 //将方法调用的返回值赋值给变量result
29 iload 5 //将变量result入栈
31 ireturn //返回result到上一级调用
递归就是方法的重复调用,指令就是24行:
24 invokevirtual #2 <com/algo/zy/Test.search1 : ([IIII)I>
虽然只有一行指令,但是里面的涉及的过程比循环复杂得多,在Java中方法调用主要有以下几个过程:
- 参数的传递。首先需要把要传递的参数压入操作数栈,上面的指令从15到22行都在做传递参数的入栈,对于实例方法的调用需要传递一个隐形参数,就是被调用方法的引用,在这里就是this,通过这个引用可以找到相对于方法的具体信息。
- 动态链接。动态链接专业地讲是把符号引用转换成指令地址,说简单点就是通过被调用的方法名找到其对应的指令地址。对于Java这种动态链接的计算机语言而言,生成的字节码文件里面的方法调用依然是符号引,就是还是用字符串表示的,比如这里就是com/algo/zy/Test.search1 : ([IIII)I,这个字符串保存在字节码文件的常量池当中,由类名+方法名+参数列表+返回值构成。JVM在调用实例方法时首先会使用栈底的引用变量,通过这个引用在方法区找到其对应的实现类的类信息(这一步还涉及多态性的处理);然后再通过方法名和参数列表找到其对应的方法的具体信息(这里还涉及到方法重载的处理),这个信息里面包含有方法代码块的地址。
- 创建被调用方法的栈帧。通过动态链接找到相应的方法信息后,JVM知道该方法的操作数栈最大深度以及局部变量的最大槽数,以此为依据创建该方法的栈帧,并从上一级调用方法栈帧的操作数栈中,把要传递的参数传递到当前栈帧的局部变量表当中。
- 保存当前方法的PC到栈帧当中。
- 设置PC为被调用方法代码块的指令地址,跳转到被调用方法的代码块区域,执行相应的字节码指令。
- 将返回值压栈,返回上一级调用。
由此可以看出方法的调用过程比循环要复杂得多得多,相应时间消耗也要大,所以循环的效率比递归高。当然就不能说循环就比递归好,有些算法是循环实现不了的,比如回溯算法,就必须得使用递归才能实现,因为这里必须要用到返回值来实现回溯,这种情况就只能使用递归了。