为什么循环比递归快

文章通过比较Java中循环和递归实现二分查找法的执行效率,指出循环通常比递归更快,原因是递归涉及到方法调用的额外开销,包括参数传递、动态链接、创建栈帧等步骤。尽管递归在某些情况下是必要的,如回溯算法,但在效率上循环更优。
摘要由CSDN通过智能技术生成

        我们知道一些常见的算法既可以用循环也可以使用递归实现,比如二分查找法。循环和递归基本思路是差不多的,但是这两者之间最大区别则是在于执行效率上,循环的执行效率要比递归快,递归的代价要比循环的大得多。

        以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这四条字节码。

  1. 10 iload_3 、11 iload 4:int变量加载指令。这两条指令是把int型局部变量从局部变量表中压入操作数栈当中,分别对应局部变量left和right。在Java栈帧当中,局部变量表存的是局部变量,比如这个方法中的result,left,right,index。JVM是基于栈的,字节码在操作局部变量时,首先需要把局部变量从局部变量表中压入操作数栈当中。
  2. 13 if_icmpgt 27 (+14):比较跳转指令。他会把操作数栈当中的两个数做比较,如果value1 > value2(栈顶),则会设置PC为指定的字节码行号,JVM则会跳转到相应的字节码行号继续执行,在这里就是27。27是把局部变量result压入操作数栈当中,28则是把result当返回值返回给上一级调用,27、28这里两行指令对应Java语句return result,这里已经跳出循环体了。
  3. 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中方法调用主要有以下几个过程:

  1. 参数的传递。首先需要把要传递的参数压入操作数栈,上面的指令从15到22行都在做传递参数的入栈,对于实例方法的调用需要传递一个隐形参数,就是被调用方法的引用,在这里就是this,通过这个引用可以找到相对于方法的具体信息。
  2. 动态链接。动态链接专业地讲是把符号引用转换成指令地址,说简单点就是通过被调用的方法名找到其对应的指令地址。对于Java这种动态链接的计算机语言而言,生成的字节码文件里面的方法调用依然是符号引,就是还是用字符串表示的,比如这里就是com/algo/zy/Test.search1 : ([IIII)I,这个字符串保存在字节码文件的常量池当中,由类名+方法名+参数列表+返回值构成。JVM在调用实例方法时首先会使用栈底的引用变量,通过这个引用在方法区找到其对应的实现类的类信息(这一步还涉及多态性的处理);然后再通过方法名和参数列表找到其对应的方法的具体信息(这里还涉及到方法重载的处理),这个信息里面包含有方法代码块的地址。
  3. 创建被调用方法的栈帧。通过动态链接找到相应的方法信息后,JVM知道该方法的操作数栈最大深度以及局部变量的最大槽数,以此为依据创建该方法的栈帧,并从上一级调用方法栈帧的操作数栈中,把要传递的参数传递到当前栈帧的局部变量表当中。
  4. 保存当前方法的PC到栈帧当中。
  5. 设置PC为被调用方法代码块的指令地址,跳转到被调用方法的代码块区域,执行相应的字节码指令。
  6. 将返回值压栈,返回上一级调用。

        由此可以看出方法的调用过程比循环要复杂得多得多,相应时间消耗也要大,所以循环的效率比递归高。当然就不能说循环就比递归好,有些算法是循环实现不了的,比如回溯算法,就必须得使用递归才能实现,因为这里必须要用到返回值来实现回溯,这种情况就只能使用递归了。

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值