《Java的函数式》第十二章:递归

258 阅读5分钟

递归是一种解决问题的方法,可以将问题分解为其较小的版本。许多开发者将递归视为另一种(通常是复杂的)迭代问题解决方法。

然而,了解不同的技术以应对特定类型的问题,在函数式编程中也是很有益的。 本章介绍递归的一般思想,如何实现递归方法,以及与其他形式的迭代相比,在Java代码中使用递归的位置。

什么是递归?

在“递归”一章中,你已经看到了计算阶乘的示例,即所有小于或等于输入参数的正整数的乘积。许多书籍、指南和教程都使用阶乘来演示递归,因为它是一个完美的部分解决问题,并且它也是本章的第一个示例。

计算阶乘的每一步都可以分解为输入参数和下一个阶乘操作的结果的乘积。当计算达到fac(1)——定义为“1”时,链条终止,并向上一步提供值。完整的计算步骤如方程式 12-1 所示。

截屏2023-06-21 16.36.48.png

这种计算步骤的概括展示了递归的基本概念:通过组合同一问题的较小实例来解决问题。这是通过使用调用自身并传入修改后的参数的方法来完成的,直到达到基本条件为止。

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

  • 基本条件

基本条件是预定义的情况,即问题的解决方案,它将返回一个实际值并解开递归调用链。它将其值提供给上一步,上一步现在可以计算出结果并将其返回给其前任步骤,依此类推。

  • 递归调用

在调用链达到基本条件之前,每一步都会通过使用修改后的输入参数来调用自身来创建另一个步骤。

问题会变得越来越小,直到为最小的部分找到解决方案。这个解决方案将成为下一个更大的问题的输入,依此类推,直到所有部分的总和构建出原始问题的解决方案。

image.png

头递归与尾递归

根据递归调用在方法体中的位置,递归调用可以分为两类:头递归和尾递归。

  • 头递归:在递归方法调用之后,还会执行其他的语句/表达式,因此递归调用不是最后一条语句。

  • 尾递归:递归调用是方法的最后一条语句,没有进一步的计算将其结果与当前调用关联起来。

让我们来看一个计算阶乘的例子,以更好地说明它们之间的区别。示例12-1展示了如何使用头递归来计算阶乘。

long factorialHead(long n) { 
  if (n == 1L) { 
    return 1L;
  }

  var nextN = n - 1L;

  return n * factorialHead(nextN); 
}

var result = factorialHead(4L);
// => 24

现在是时候看一下尾递归了,如示例12-2所示。

long factorialTail(long n, long accumulator) { 
  if (n == 1L) { 
    return accumulator;
  }

  var nextN = n - 1L;
  var nextAccumulator = n * accumulator;

  return factorialTail(nextN, nextAccumulator); 
}

var result = factorialTail(4L, 1L); 
// => 24

头递归和尾递归之间的主要区别在于调用栈的构造方式。

在头递归中,递归调用发生在返回值之前。因此,直到运行时从每个递归调用中返回后,最终结果才可用。

而在尾递归中,先解决了被分解的问题,然后将结果传递给下一个递归调用。实质上,任何给定的递归步骤的返回值与下一个递归调用的结果相同。如果运行时支持,这可以优化调用栈,正如你将在下一节中看到的那样。

递归和调用栈之间的关系非常密切

如果你再次查看图12-1,你可以将每个方框视为一个单独的方法调用,因此也是调用栈上的一个新的栈帧。这是必要的,因为每个方框必须与先前的计算相互隔离,这样它们的参数就不会相互影响。递归调用的总数仅受到达到基本条件所需时间的限制。然而,问题在于可用的栈大小是有限的。过多的调用将填满可用的栈空间,并最终引发StackOverflowError异常。

为了防止堆栈溢出,许多现代编译器使用尾调用优化/消除来删除在递归调用链中不再需要的帧。如果在递归调用之后没有进行额外的计算,那么堆栈帧就不再需要并且可以被移除。这将递归调用的堆栈帧空间复杂度从O(N)降低到O(1),从而产生更快、更节省内存的机器码,而不会造成堆栈溢出。

遗憾的是,截至2023年初,Java编译器和运行时环境还没有这种特定的能力。

尽管如此,递归仍然是解决特定问题子集的有价值的工具,即使在不优化调用栈的情况下。

一个更为复杂的例子

虽然使用阶乘计算来解释递归非常有用,但它并不是典型的现实世界问题。因此,现在是时候看一个更真实的例子了:遍历一个类似树状的数据结构,如图12-2所示。

这个数据结构有一个根节点,并且每个节点都可以有一个可选的左子节点和右子节点。它们的编号是用于标识,并不代表任何遍历顺序。

image.png

节点由泛型记录Node表示,如示例12-3所示。

public record Node<T>(T value, Node<T> left, Node<T> right) {

  public static <T> Node<T> of(T value, Node<T> left, Node<T> right) {
    return new Node<>(value, left, right);
  }

  public static <T> Node<T> of(T value) {
    return new Node<>(value, null, null);
  }

  public static <T> Node<T> left(T value, Node<T> left) {
    return new Node<>(value, left, null);
  }

  public static <T> Node<T> right(T value, Node<T> right) {
    return new Node<>(value, null, right);
  }
}

var root = Node.of("1",
                   Node.of("2",
                           Node.of("4",
                                   Node.of("7"),
                                   Node.of("8")),
                           Node.of("5")),
                   Node.right("3",
                              Node.left("6",
                                        Node.of("9"))));