Java 的递归入门教程

455 阅读12分钟

递归是一种强大的算法技术**(分而治之的策略**),其中一个函数在同一类型的较小问题上(直接或间接)调用自己,以便将问题简化到可解状态。

1.什么是递归?

递归是一种针对可以部分解决的问题的方法,剩下的问题也是同样的形式。通俗地说,递归函数调用自己,但其输入参数略有变化,直到达到结束条件并返回一个实际值。

1.1.递归的结构

递归由两种不同的操作类型组成。

  • 基本条件(或基本情况):是一个预定义的条件,解决了返回实际值的问题,并解除了递归调用链。基准条件将其值提供给前一个步骤,现在它可以计算出一个结果并将其返回给它的前辈。
  • 递归调用(或递归情况):是指调用链,直到调用链它到达基条件。在这个调用链中,每一步都将创建一个新的步骤,并修改输入参数。

不要担心定义的细节。主要的收获是,它是以自身为单位定义的。"递归:......更多信息,见递归。" 🙂

1.2.递归实例

一个简单的例子是计算一个阶乘。数n的阶乘是所有小于或等于n的正整数的乘积。

f(n) = n * (n-1) * (n-2) * .... * 2 * 1

当我们使用递归来解决阶乘问题时,计算的每一步都分解为输入参数与下一个阶乘运算的乘积。当计算达到fac(1)时,它就会终止,并向上一步提供数值,如此反复。

例如,我们正在计算5的阶乘。递归会产生一连串的递归调用,其中f(5)调用f(4),后者调用f(3),后者调用f(2),最后再调用f(1)。

f(1)击中基本条件,并向f(2)返回实际值1。f(2)将返回2,f(3)将返回6,f(4)返回24,然后最后f(5)返回最终值120

[

](https://howtodoinjava.com/wp-content/uploads/2021/12/Factorial-Steps.png)

还有很多这样的问题,我们可以用递归来解决,例如,河内塔(TOH)无序/前序/后序树的遍历,图的DFS,等等。

2.递归类型

从广义上讲,递归可以有两种类型:直接递归和间接递归。

2.1.直接或间接递归

2.1.1.直接递归

在直接递归中,一个函数从自身内部调用自己,正如我们在阶乘的例子中看到的那样。这是最常见的递归形式。

int testfunc(int num) {
  if (num == 0)
    return 0;
  else
    return (testfunc(num - 1));
}

当一个方法调用自己时,它必须遇到一些条件,在这些条件下它停止调用自己。如果一个方法一次又一次地调用自己而没有遇到这样的情况,它将永远不会终止。这就是所谓的无界递归。它导致了应用程序的异常终止。

为了防止无界递归,至少要有一个基础条件存在,在这个条件下,方法不会调用自己。此外,传递给方法的值有必要最终满足这个条件。当这种情况发生时,我们说我们有有界递归。

2.1.2.间接递归

在间接递归中,一个方法,比如说方法A,调用另一个方法B,后者再调用方法A。这涉及到两个或多个方法,最终形成一个循环调用序列。

int testfunc1(int num) {
  if (num == 0)
    return 0;
  else
    return (testfunc2(num - 1));
}

int testfunc2(int num2) { 
     return testfunc1(num2 - 1); 
}

2.2.头部递归与尾部递归

2.2.1.头部递归

在头部递归中,当递归调用发生时,在函数中的其他处理之前(可以认为它发生在函数的顶部,或头部)。

public void head(int n)
{
    if(n == 0)
        return;
    else
        head(n-1);
        
    System.out.println(n);
}

2.2.2.尾部递归

它与头部递归相反。处理过程发生在递归调用之前。一个尾部递归类似于一个循环。该方法在跳转到下一个递归调用之前执行所有的语句。

public void tail(int n)
{ 
    if(n == 1) 
        return; 
    else
        System.out.println(n);

    tail(n-1);
} 

一般来说,尾部递归总是比头部递归好。尽管它们都具有相同的时间复杂性和辅助空间,但尾部递归在函数栈的内存方面具有优势。

头部递归将在函数栈内存中等待,直到递归后的代码语句被执行,这导致了整体结果的延迟,而尾部递归将在函数栈中被终止执行。

3.尾部调用优化(TCO)

3.1.什么是尾部调用优化

如果我们仔细看一下尾部递归,我们在做递归之前做了所有的计算,在最后,没有必要存储堆栈帧,因为该帧中没有计算。我们可以简单地将函数的结果传递给下一个递归调用,让它将其结果加入到之前的计算结果中。

所以在尾部递归的情况下,我们可以不创建新的堆栈框架,而只是重新使用当前的堆栈框架。无论递归调用多少次,堆栈都不会变得更深。

这样,TCO让我们把常规的递归调用转换成尾部调用,使递归在大的输入中变得实用,这在早期的正常递归情况下会导致堆栈溢出错误。

例如,如果没有尾部递归,阶乘程序的堆栈框架是这样的。

(fact 3)
(* 3 (fact 2))
(* 3 (* 2 (fact 1)))
(* 3 (* 2 (* 1 (fact 0))))
(* 3 (* 2 (* 1 1)))
(* 3 (* 2 1))
(* 3 2)
6

相比之下,尾部递归的阶乘的堆栈跟踪看起来如下。

(fact 3)
(fact 3 1)
(fact 2 3)
(fact 1 6)
(fact 0 6)
6

正如我们所看到的,我们只需要在每次调用fact时跟踪相同的数据量,因为我们只是将得到的值直接返回到顶部。

这意味着,即使我调用*(fact1000000),我只需要和(fact 3)一样多的空间。而非尾部递归的事实*则不是这样,因此大的数值可能会导致堆栈溢出。

在尾部递归中,由于不需要对递归调用的返回值进行额外的计算,因此不需要堆栈框架。这就把递归调用的堆栈框架空间复杂度从O(N)降低到O(1),从而使机器代码的速度更快、内存更友好。

3.2.使用Java流的尾部调用优化

内存是有限的,如果不巧妙地使用,会很快耗尽。请看下面的代码。它使用简单的递归逻辑来寻找从1到N的数字之和,其中N是方法的参数。

public class RecursionDemo {
    public static void main(String[] args) {

        var result = simpleRecursiveSum(1L, 40000L);
        System.out.println(result); 
    }

    static long simpleRecursiveSum(long total, long summand) {
        if (summand == 1L) {
            return total;
        }
        return simpleRecursiveSum(total + summand, summand - 1L);
    }
}

上述代码将导致线程 "main "中出现异常,java.lang.StackOverflowError错误。这是因为该程序创建了大量的堆栈框架,内存超出了限制。

Java Streams可以用来支持尾部调用优化,我们可以自己实现一个更好的方式来做递归式编码。我们可以用流写递归函数,一直运行到达到一个基本条件。

但不是递归地调用lambda表达式,而是返回一个新的表达式,在Stream流水线上运行。这样一来,无论执行多少步骤,堆栈深度都将保持不变。

给出的RecursiveCall 接口是一个功能接口,可以用来以更优化的方式编写递归操作。

在下面的代码中,apply() 代表递归调用。它执行递归步骤,并返回一个新的lambda,即下一个步骤。对done()的调用将返回一个终止的RecursiveCall 的专门实例,表示递归的终止。*invoke()*方法现在将返回计算的最终结果。

import java.util.stream.Stream;

@FunctionalInterface
public interface RecursiveCall<T> {
    RecursiveCall<T> apply();

    default boolean isComplete() {
        return false;
    }

    default T result() {
        throw new Error("not implemented");
    }

    default T run() {
        return Stream.iterate(this, RecursiveCall::apply)
                .filter(RecursiveCall::isComplete).findFirst().get().result();
    }

    static <T> RecursiveCall<T> done(T value) {

        return new RecursiveCall<T>() {

            @Override
            public boolean isComplete() {
                return true;
            }

            @Override
            public T result() {
                return value;
            }

            @Override
            public RecursiveCall<T> apply() {
                throw new UnsupportedOperationException();
            }
        };
    }
}

要使用上述接口,我们必须在达到基本条件时调用RecursiveCall.doed();即value 。在此之前,我们必须创建一个新的RecursiveCall,将N递减1,并将N加入到总的运行和中。

现在给定的代码成功执行,总和被打印在控制台。

public class RecursionDemo 
{
    public static void main(String[] args) {

        var result = sum(1L, 40000L).run();
        System.out.println(result);
    }

    static RecursiveCall<Long> sum(Long total, Long summand) {
        if (summand == 1) {
            return RecursiveCall.done(total);
        }
        return () -> sum(total + summand, summand - 1L);	//800020000
    }
}

3.3.Project Loom的Unwind和Invoke

Project Loom的主要目标是支持Java中的高吞吐量、轻量级并发模型。它将提供对**轻量级线程(fibres)尾部递归(unwind and invoke)**的支持。你可以在其提案页面上阅读详细的信息。

新功能unwind-and-invoke,即UAI,将为JVM增加操作调用栈的能力。UAI将允许解开堆栈到某个点,然后用给定的参数调用一个方法(基本上是高效尾随调用的概括)。如果有必要的工具,这将降低使用递归的障碍。

作为Project Loom的一部分,UAI将在JVM中完成,并作为非常薄的Java API暴露出来。

4.递归与堆栈

堆栈是一种后进先出(LIFO)的数据结构,它来自于对一组堆叠在一起的物理项目的类比。这种结构使得从堆栈顶部取下一个项目很容易,而要取到堆栈中更深的一个项目可能需要先取下其他多个项目。

在Stack中,推送和弹出操作只发生在结构的一端,被称为堆栈的顶部。每个推送操作都会在堆栈中创建一个新的堆栈框架。

在函数式编程中,一个堆栈帧包含单个方法调用的状态。每次代码调用一个方法时,JVM都会在全局堆栈中创建并推送一个新的框架。从一个方法返回后,其堆栈帧被弹出并丢弃。

当使用递归时,每个递归函数调用f(n)被存储为堆栈帧,因为n的每个值必须与之前的计算隔离。有多少个堆栈帧,就有多少个递归函数调用来达到基本条件。

注意,如果可用的堆栈大小是有限的。太多的调用会填满可用空间,导致StackOverflowError

5.用树进行递归

与堆栈类似,树也是一个递归数据类型。树也在其节点中存储信息,在需要时可以在应用程序中搜索。

遍历树的三种最常见的方式被称为顺序内、顺序前和顺序后。而实现任何一种搜索顺序的最有效的技术是使用递归。

例如,在二进制搜索树上搜索某个数据是非常简单的。我们从树的根部开始,与我们要搜索的数据元素进行比较。如果我们正在寻找的节点包含该数据,那么我们就完成了。否则,我们确定搜索元素是否小于或大于当前节点。如果它小于当前节点,我们就移动到该节点的左侧子节点。如果它大于当前节点,我们就移动到该节点的右侧子节点。然后我们根据需要重复。

最终,通过递归,我们达到了一个点,即 "子树 "只是节点。在这一点上,如果我们达到了基本条件,那么我们就找到了结果。记得我们是如何将递归定义为解决一个更大问题的子问题的。二元搜索是该理论的另一个应用案例。

SEARCH(x, T)
  if(T.root != null)
      if(T.root.data == x)
          return r
      else if(T.root.data > x)
          return SEARCH(x, T.root.left)
      else
          return SEARCH(x, T.root.right)

[

](https://howtodoinjava.com/wp-content/uploads/2021/12/Binary-Search.png)

与搜索操作类似,事实上,所有的树状操作(排序、插入、删除)都可以在递归的帮助下实现。

6.递归和迭代的区别

一般来说,上面讨论的每个问题都可以用迭代或递归来解决。理想的解决方案在很大程度上取决于我们要解决的问题和代码的运行环境。

递归通常是解决更抽象问题的首选工具,而迭代则是解决更低级别的代码的首选。迭代可能提供更好的运行时性能,但递归可以提高你作为程序员的生产力。

注意,很容易出错,而且要理解一个现有的递归解决方案可能更难。在递归和其替代品之间进行选择时,要非常小心执行时间较慢和堆栈溢出问题。

让我们看看两种方法的快速比较。

因素/属性

递归

迭代

基本方法

递归是在自己的代码中调用一个函数本身的过程。

在迭代中,循环被用来重复执行一组指令,直到条件为假。

语法

递归有一个终止条件,称为基本条件

迭代包括初始化、条件和变量的增量/减量。

性能方面

它比迭代慢。

它比递归快。

适用于

问题可以部分解决,剩下的问题将以同样的形式解决。

问题被转换为一系列的步骤,一次完成一个,一个接一个。

时间复杂度

它的时间复杂度很高。

它的时间复杂度相对较低。我们可以通过找出循环中的迭代次数来计算时间复杂性。

堆栈

在某些类型的递归中,它必须更新和维护堆栈。

没有利用堆栈。

内存

如果没有应用TCO,与迭代相比,它使用更多的内存。

与递归相比,它使用的内存较少。

7.总结

递归算法将一个问题分解成更小的部分,这些部分我们要么已经知道答案,要么可以通过对每个部分应用相同的算法来解决,然后将结果合并。

递归是编程中的一个有价值的工具,但简单的递归实现往往对实际问题没有用。函数式接口、lambda表达式和无限流可以帮助我们设计尾部调用优化,使递归在这种情况下可行。

学习愉快!!

参考资料。

Venkat Subramaniam的《JAVA中的功能编程》。