MIT6.031 软件构造 Reading10阅读笔记Recursion

学习目标

1、能够将递归问题分解成递归步骤和基本情况
2、知道何时以及如何在递归中使用助手方法
3、理解递归和迭代的优缺点

简述递归

在今天我们将讨论如何实现一个方法,一旦你已经有了一个规范。我们将关注一种特殊的技术,递归。递归并不适合每一个问题,但它是你的软件开发工具箱中的一个重要工具,也是很多人挠头的工具。希望你对递归感到舒适和胜任,因为你会一遍又一遍地遇到它。(那是玩笑,但也是真的。)
递归对我们来说不应该是完全陌生的,大家应该已经看过并写过像阶乘和斐波那契这样的递归函数。今天的简述将会比你以前更深入地探究递归。对于即将实现的类来说,熟悉递归实现是必要的。
递归函数的定义如下基本案例和递归步骤。
在一个基本情况下,给定函数调用的输入,我们立即计算结果。
在递归步骤中,我们借助一个或多个递归调用同样的功能,但输入的大小或复杂性有所降低,更接近基本情况。
考虑写一个函数来计算阶乘。我们可以用两种不同的方式定义阶乘:
在这里插入图片描述
在右边的递归实现中,基本情况是n = 0,我们计算并立即返回结果:0!被定义为一。递归步骤是n %3 = 0,我们在递归调用的帮助下计算结果,以获得(n-1)!,然后通过乘以完成计算n。

为了可视化递归函数的执行,绘制调用堆栈当前正在执行的函数。

让我们运行的递归实现阶乘在主要方法中:

public static void main(String[] args) {
    long x = factorial(3);
}

在每一步,随着时间从左向右移动:
在这里插入图片描述

为问题选择正确的分解

找到分解问题的正确方法,例如方法实现是很重要的,方法实现简单、简短、易于理解、不受bug影响,并且随时可以改变。
递归便是对某些问题的优雅而简单的分解。假设我们想要实现这个规范:
在这里插入图片描述
比如,子序列(“abc”)可能会回来abc,ab,bc,ac,a,b,c。请注意空子序列前面的尾随逗号,它也是一个有效的子序列。
这个问题适合于更好的递归分解。取单词的第一个字母。我们可以形成一组子序列包括那个字母和另一组排除那个字母的子序列,这两组完全覆盖了可能的子序列。

1 public static String subsequences(String word) {
 2     if (word.isEmpty()) {
 3         return ""; // base case
 4     } else {
 5         char firstLetter = word.charAt(0);
 6         String restOfWord = word.substring(1);
 7         
 8         String subsequencesOfRest = subsequences(restOfWord);
 9         
10         String result = "";
11         for (String subsequence : subsequencesOfRest.split(",", -1)) {
12             result += "," + subsequence;
13             result += "," + firstLetter + subsequence;
14         }
15         result = result.substring(1); // remove extra leading comma
16         return result;
17     }
18 }

递归实现的结构

递归实现总是有两个部分:
基础案例: 这是问题的最简单、最小的实例,无法进一步分解。基本情况通常对应于空——空字符串、空列表、空集合、空树、零等。
递归步骤:哪个分解将问题的较大实例分解成一个或多个可通过递归调用解决的更简单或更小的实例,然后重组这些子问题的结果产生原问题的解。

递归步骤将问题实例转换成更小的东西很重要,否则递归可能永远不会结束。如果每一个递归步骤都缩小了问题,并且基本情况位于底部,那么递归保证是有限的。

递归实现可以有多个基本情况,或者多个递归步骤。例如,斐波那契函数有两种基本情况,n=0和n=1。

助手方法

我们刚刚看到的递归实现子序列()是问题的一种可能的递归分解。我们找到了一个子问题的解决方案——去掉第一个字符后字符串剩余部分的子序列——并使用它来构造原始问题的解决方案,方法是获取每个子序列并添加或省略第一个字符。这在某种意义上是一个直接的递归实现,其中我们使用递归方法的现有规范来解决子问题。

在某些情况下,要求更强(或不同)的递归步骤规范是有用的,这样可以使递归分解更简单或更优雅。在这种情况下,如果我们使用单词的首字母构建一个部分子序列,并使用递归调用完成使用单词剩余字母的部分子序列?比如,假设原词是“橙”。我们都将选择“o”作为部分子序列,并用“range”的所有子序列递归扩展它;我们将跳过“o”,使用“”作为部分子序列,并再次用“range”的所有子序列递归扩展它。

使用这种方法,我们的代码现在看起来简单多了:

/**
 * Return all subsequences of word (as defined above) separated by commas,
 * with partialSubsequence prepended to each one.
 */
private static String subsequencesAfter(String partialSubsequence, String word) {
    if (word.isEmpty()) {
        // base case
        return partialSubsequence;
    } else {
        // recursive step
        return subsequencesAfter(partialSubsequence, word.substring(1))
             + ","
             + subsequencesAfter(partialSubsequence + word.charAt(0), word.substring(1));
    }
}

这之后方法称为助手方法。它满足不同于原始的规格子序列,因为它有一个新参数partialSubsequence。这个参数填充了一个类似于局部变量在迭代实现中的角色。它在计算过程中保持暂时状态。递归调用稳定地扩展这个部分子序列,选择或忽略单词中的每个字母,直到最后到达单词的末尾(基本情况),此时部分子序列作为唯一的结果返回。然后递归回溯并填充其他可能的子序列。

为了完成实现,我们需要实现原始的子序列spec,它通过调用带有部分子序列参数的初始值的helper方法来获得全局滚动:

public static String subsequences(String word) {
    return subsequencesAfter("", word);
}

不要向您的客户公开助手方法。您决定以这种方式而不是另一种方式分解递归完全是特定于实现的。特别是,如果你发现你需要临时变量,比如partialSubsequence在你的递归中,不要改变你的方法的原始规范,也不要强迫你的客户正确地初始化那些参数。这会将您的实现暴露给客户端,并降低您将来更改它的能力。为递归使用一个私有帮助函数,并让您的公共方法用正确的初始化调用它,如上所示。

选择正确的递归子问题

我们再来看一个例子。假设我们想要将一个整数转换为具有给定基数的字符串表示形式,遵循以下规范:

/**
 * @param n integer to convert to string
 * @param base base for the representation. Requires 2<=base<=10.
 * @return n represented as a string of digits in the specified base, with 
 *           a minus sign if n<0.
 */
public static String stringValue(int n, int base)

比如,stringValue(16,10)应该返回"16",和stringValue(16,2)应该返回"10000"。

让我们开发这个方法的递归实现。这里的一个递归步骤很简单:我们可以简单地通过递归调用相应正整数的表示来处理负整数:

if (n < 0) return "-" + stringValue(-n, base);

这表明递归子问题可以比数字参数的值或字符串或列表参数的大小更小或更简单。我们仍然有效地将问题简化为正整数。

下一个问题是,假设我们有一个正n,比如说10进制中的n=829,我们应该如何把它分解成一个递归子问题?当我们把数字写在纸上时,我们可以从8(最左边或最高位的数字)开始,或者从9(最右边的低位数字)开始。从左端开始似乎很自然,因为这是我们写的方向,但在这种情况下更难,因为我们需要首先找到数字中的位数,以确定如何提取最左边的数字。相反,分解n的更好方法是取其余数的模基数(它给出最右面的数字)并除以基数(这给出了子问题,剩余的高阶数字):

return stringValue(n/base, base) + "0123456789".charAt(n%base);

想好几种分解问题的方法,试着写出递归的步骤。你想找到一个最简单,最自然的递归步骤。

它仍然需要弄清楚基本情况是什么,并包含一个if语句来区分基本情况和这个递归步骤。

递归问题和递归数据

到目前为止,我们看到的例子都是问题结构自然适合递归定义的例子。阶乘很容易用较小的子问题来定义。有一个递归问题就像这是一个提示,你应该从你的工具箱中取出一个递归解决方案。

另一个提示是,您正在操作的数据在结构上是固有递归的。从现在开始,我们将在几个类中看到许多递归数据的例子,但是现在让我们看看在每台笔记本电脑中发现的递归数据:它的文件系统。文件系统由命名的文件。有些文件是文件夹,它可以包含其他文件。所以文件系统是递归的:文件夹包含其他文件夹,其他文件夹包含其他文件夹,直到最后递归的底部是普通(非文件夹)文件。

Java库表示文件系统,使用java.io.File。这是一种递归数据类型,从这个意义上说f.getParentFile()返回文件的父文件夹f,这是一个文件对象,并且f.listFiles()返回包含的文件f,它是一个其他数组文件物体。

对于递归数据,编写递归实现是很自然的:
/**
 * @param f a file in the filesystem
 * @return the full pathname of f from the root of the filesystem
 */
public static String fullPathname(File f) {
    if (f.getParentFile() == null) {
        // base case: f is at the root of the filesystem
        return f.getName();  
    } else {
        // recursive step
        return fullPathname(f.getParentFile()) + "/" + f.getName();
    }
}

Java的最新版本增加了一个新的API,java.nio.Files和java.nio.Path,它在文件系统和用于命名文件的路径名之间提供了更清晰的分隔。但是数据结构从根本上还是递归的

可重入代码

递归——一种调用自身的编程方法,也叫做可重入性。可重入代码可以被安全地重新输入,这意味着它可以被再次调用即使是在打电话的时候。可重入代码将其状态完全保存在参数和局部变量中,不使用静态变量或全局变量,也不与程序的其他部分或对自身的其他调用共享可变对象的别名。

直接递归是可重入性发生的一种方式。在阅读过程中,我们已经看到了很多这样的例子。这阶乘()方法被设计成阶乘(n-1)即使阶乘(n)还没有完成工作。

两个或多个函数之间的相互递归是另一种可能发生的方式——甲调用乙,乙又调用甲。直接相互递归实际上总是由程序员有意设计的。但是意外的相互递归会导致错误。

当我们在课程的后面讨论并发时,可重入性将再次出现,因为在并发程序中,一个方法可能同时被并发运行的程序的不同部分调用。

将代码设计成尽可能的可重入是很好的。可重入代码更安全,不会出现错误,可以用于更多情况,如并发、回调或相互递归。

何时使用递归而不是迭代

我们已经看到了使用递归的两个常见原因:

问题自然是递归的(例如斐波那契) 数据自然是递归的(例如文件系统)

使用递归的另一个原因是为了更好地利用不变性。
在一个理想的递归实现中,所有的变量都是最终的,所有的数据都是不可变的,递归方法在不变异任何东西的意义上都是纯函数。方法的行为可以简单地理解为它的参数和返回值之间的关系,对程序的任何其他部分都没有副作用。这种范式叫做功能程序设计,这比推理要容易得多命令式编程循环和变量。

相比之下,在迭代实现中,不可避免地会有在迭代过程中被修改的非最终变量或可变对象。关于程序的推理需要考虑程序状态在不同时间点的快照,而不是纯粹的输入/输出行为。

递归的一个缺点是它可能比迭代解占用更多的空间。
建立递归调用的堆栈会暂时消耗内存,并且堆栈的大小是有限的,这可能会限制您的递归实现可以解决的问题的大小。

递归实现中常用错误

以下是递归实现可能出错的两种常见方式:

1、基本案例完全缺失,或者问题需要一个以上的基本案例,但不是所有的基本案例都涵盖在内。
2、递归步骤没有减少到更小的子问题,所以递归没有收敛。

调试时应该关注查找这些错误。

好的一面是,迭代实现中的无限循环通常会变成堆栈溢出在递归实现中。有问题的递归程序失败得更快。

总结

本次就是讲述了以下问题:

递归问题和递归数据
比较递归问题的可选分解
使用辅助方法加强递归步骤
递归与迭代

今天阅读的主题与我们优秀软件的三个关键特性相关,如下所示:

safe from bugs。递归代码更简单,经常使用不可变变量和不可变对象。

Easy to understand 。自然递归问题和递归数据的递归实现通常比迭代解更短,更容易理解。

Ready for change 。递归代码也是自然可重入的,这使得它更安全,可以在更多的情况下使用。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值