首页 > 编程语言 >Java-递归-爆栈问题

Java-递归-爆栈问题

时间:2023-12-17 21:04:16浏览次数:75  
标签:调用 return 函数 递归 sum long 爆栈 Java

一、递归时出现的错误

现使用单路递归的方法进行n到一的求和,用Java代码实现如下:

//递归求和 n + (n-1) + ... + 1
public class E06Sum {

    public static void main(String[] args) {
        long s = sum(15000);
        System.out.println(s);
    }

    //f(n) = f(n - 1) + 1
    //简单的单路递归
    public static long sum(long n){
        if(n == 1){
            return 1;
        }
        return sum(n-1) + n;
    }
}

当n较小时没有问题可以正常跑,但是当我这里设置n = 15000 的时候报错了,Exception in thread "main" java.lang.StackOverflowError StackOverflowError栈内存耗尽,俗称爆栈。 

Java-递归-爆栈问题_栈内存

二、对错误进行分析

用伪代码来分析错误:

/*
     用伪代码说明以下执行过程:
       long sum(long n = 15000){
        return 15000 + long sum(long n = 14999){
        return 14999 + sum(14998){
        。。。。。。。。。。。。。。
        return 2 + long sum(long n = 1){
           if(n == 1){
            return 1;
           }
         }
       }
    };
  }
     */

其中,若最内层的函数(如下所示)sum(long n = 1)没有完成,其他的函数是不会执行的,只有最内层的函数得到返回值了才能一层一层的向外归,也就是递的过程必须递到最深处才能归。

/*
return 2 + long sum(long n = 1){
           if(n == 1){
            return 1;
           }
         }
*/

每个方法调用时需要消耗一定的内存,因为方法调用时需要存储方法相关的信息比如方法的参数信息,局部变量的信息,方法的返回地址等信息,这些信息都要储存到栈内存中。最内层sum(n = 1)这个方法没有结束之前,前面的14999个sum方法都要等待,它们占用的内存也不能得到释放,只有当方法执行完毕时这个方法占用的内存才能释放,故我们占用得内存是一点一点释放的。

所以我们刚刚出现StackOverflowError栈内存耗尽 这个错误是因为方法调用得太多了,层级太深,导致栈内存耗尽。

三、爆栈问题解决方法

1.尾调用的概念

如果函数的最后一步是调用一个函数,那么就称为尾调用,例如:

function a(){
	return b();//函数a的最后一步是调用函数b
}

下面三段代码不能称之为尾调用:

  • 最后一步并虽然调用了函数,但是又进行了运算:
function a(){
	return b() + 1;
}
  • 最后一步并非调用函数:
function a(){
  const c = b();
	return c;
}
  • 最后一步虽然调用了函数,但又用到了外层函数的变量x:
function a(x){
	return b() + x;
}

2.尾调用的用途

一些语言的编译器能够对尾调用做优化,例如:

没优化之前的代码:

//有以下三个函数a\b\c

//函数a尾调用了函数b
function a(){
  //前面的处理
	return b();
}
//函数b尾调用了函数c
function b(){
  //前面的处理
	return c();
}

function c(){
	return 1000;
}
//首先调用函数a
a();

当我们先调用函数a时的过程的伪代码:

function a(){
	return function b(){
  	return function c(){
    	return 1000;
    }
  }
}

可以看到,这种执行的过程是一种嵌套的关系。当函数c没有执行完之前,a、b都要等待c执行完,故c执行完之前a、b所占的内存并不能得到释放。

优化之后的代码:

a();
b();
c();

优化后的代码执行过程就从嵌套的关系变成一种平级的调用关系,内存也能够得到及时地释放。这就是某一些编译器能够对尾调用进行的优化。那么具体优化方法就是,例如在a函数前面的处理执行完了之后我们就不需要用到a了,就将a占用的内存释放释放,因为最后a的结果是由调用b来提供的,b函数也同理。这也是为什么尾调用才可以做这种优化的原因,若不是尾调用,例如最后的操作是 return b() + 1; 那么我们要先得到b返回的值,再进行运算,最后才能得到a的结果,所以此时无法将a释放,这种情况就不能做尾调用的优化了。

3.尾调用的一种特殊情况-尾递归

尾递归指的是函数最后调用的函数是他本身:

function a(){
	//前面的处理
  return a();
}

能对尾调用做优化的编译器肯定能对尾递归做优化。

4.什么语言能够对尾调用做优化

  • c++
  • scala

这里以scala为例来讲尾调用的优化,scala是Java的近亲,也是编译处理啊class类然后再JVM上运行,但是语法有些不同。scala可以使用java中的类,所以可以混用两种语言进行开发。

用scala进行n到1的求和:

object Main{

  def main(args: Array[String]): Unit = {
    println(sum(15000))
  }
  
  def sum(n: Long) : Long = {
		if(n == 1){
    	return 1
    }
    return n + sum(n - 1)//最后一步不是尾递归
  }
  
}

现在运行出来仍然是报栈内存溢出错误,因为注意看我们上面的代码,最后我们没有进行尾递归,因此scala的编译器没法进行优化,但是Scala给我们提供了一个注解,可以检查我们函数是不是用了尾递归这种写法。

@tailrec//tailrec就是尾递归的意思

该写代码,使用尾调用:

object Main{

  def main(args: Array[String]): Unit = {
    println(sum(15000,accumulator = 0))
  }
  
  @tailrec
  def sum(n: Long , accumulator : Long) : Long = {
		if(n == 1){
    	return 1 + accumulatior
    }
    return sum(n - 1, n + accumulator)//写一个参数计数器,把加法提前做。
  }
  
}

运行时候爆栈问题得到解决。

以n = 3为例,用伪代码来解释一下运行过程:

/*
sum (n = 3, accumulator = 0): Long = {
	return sum(2,3)//这时我们已经不需要用到外层的函数了,故外层函数的内存可以及时释放了。
}

sum (n = 2, accumulator = 3): Long = {
	return sum(1,5)//这时我们已经不需要用到外层的函数了,故外层函数的内存可以及时释放了。
}

sum (n = 1, accumulator = 5): Long = {
	if(n == 1){
    	return 1 + accumulatior//最终得到6
    }
}

*/

但是尾递归的优化限制比较大,不是所有语言都支持,所以我们的爆栈问题从根本上是不能由尾递归解决的,最终的解决方法是将代码改为循环的代码。

四、用循环解决爆栈问题

将递归代码改成循环代码,用循环来进行n到1的加法:

public class Sum {

    public static void main(String[] args) {
        long n = 100000000;
        long sum = 0;//设置一个变量用来存储最终的值
        for(long i = n; i >= 1; i--){
            sum += i;
        }
        System.out.println(sum);
    }
}

这样子就可以运行出来不出错,理论上所有的递归代码都可以改成循环代码,但是有些时候改起来不是那么容易。

标签:调用,return,函数,递归,sum,long,爆栈,Java
From: https://blog.51cto.com/u_15535912/8863209

相关文章

  • 无涯教程-Java - String toLowerCase(Locale locale)函数
    如果指定Locale则根据Locale将此String中的所有字符转换为小写,否则调用toLowerCase(Locale.getDefault())默认方法。StringtoLowerCase-语法publicStringtoLowerCase(Localelocale)StringtoLowerCase-返回值它返回转换为小写字母的字符串。StringtoLowerCase-示......
  • java基础知识点之一维数组的两个常见小问题
    一:概述在一维数组的使用中,一不小心就会出现错误,尤其是在初学的情况下。在这里我要说明的是两个常见的问题索引越界问题和空指针异常的问题。二:具体说明<1>索引越界问题初学者打眼一看,可能认为这没有错误,但运行之后,程序报错了。这个错误,一不小心就会犯。因为有时候我们会惯性思维的......
  • 链表递归题型
    递归的定义在定义一个过程或函数时出现调用本过程或本函数的成分,称之为递归。若调用自身,称之为直接递归。若过程或函数p调用过程或函数q,而q又调用p,称之为间接递归。递归算法的设计递归的求解过程均有这样的特征:先将整个问题划分为若干个子问题,通过分别求解子问题,最后获......
  • javascript基础
       ......
  • Java加解密【回车换行】坑与解决
    在Java中进行加解密时,经常会遇到回车换行的问题,这可能导致加解密结果不符合预期,引发一系列的错误。本文将探讨在Java加解密中常见的回车换行问题,并提供解决方案,以确保数据的准确性和一致性。一、问题背景在文本数据进行加密时,回车换行字符可能会在不同的操作系统上表示方式不同。例......
  • 无涯教程-Java - String toLowerCase()函数
    将此String中的所有字符转换为小写,这等同于调用toLowerCase(Locale.getDefault())。StringtoLowerCase()-语法publicStringtoLowerCase()StringtoLowerCase()-返回值它返回转换为小写字母的字符串。StringtoLowerCase()-示例importjava.io.*;publicclassTest......
  • 无涯教程-Java - toCharArray()函数
    此方法将此字符串转换为新的字符数组。char[]toCharArray()-语法这是此方法的语法-publicchar[]toCharArray()char[]toCharArray()-返回值它返回一个新分配的字符数组,其长度是此字符串的长度,并且其内容已初始化为包含此字符串表示的字符序列。char[]toCharArray()......
  • JavaScript调研
    一、JS初识1、JavaScript一种直译式脚本语言;2、组成部分;(1)ECMAScript语法和基本对象(2)文档对象模型(DOM)处理网页内容的方法和接口(3)浏览器对象模型(BOM)与浏览器进行交互的方法和接口3、JS特点(1)解释性脚本语言(2)用来向HTML页面添加交互行为,可以嵌入HTML......
  • 无涯教程-Java - String substring(int beginIndex)函数
    从beginIndex索引处开始截取字符串。Stringsubstring-语法publicStringsubstring(intbeginIndex)这是参数的详细信息-beginIndex  -  包含开始索引。Stringsubstring-返回值指定的子字符串。Stringsubstring-示例importjava.io.*;publicclassTest......
  • 《Java编程思想第四版》学习笔记48--关于Runnable
    现在run()位于类内,但它在init()结束以后仍处在“睡眠”状态。若按下启动按钮,线程便会用多少有些暧昧的表达方式创建(若线程尚不存在):newThread(Counter3.this);若某样东西有一个Runnable接口,实际只是意味着它有一个run()方法,但不存在与之相关的任何特殊东西——它不具有任何天......