递归的递归之书:引言到第二章

180 阅读1小时+

引言

原文:Introduction

译者:飞龙

协议:CC BY-NC-SA 4.0

递归编程技术可以产生优雅的代码解决方案。然而,更常见的情况是它会使程序员感到困惑。这并不意味着程序员可以(或应该)忽视递归。尽管它以具有挑战性而闻名,但递归是一个重要的计算机科学主题,可以为编程本身提供深刻的见解。至少,了解递归可以帮助你在编程工作面试中脱颖而出。

如果你是一个对计算机科学感兴趣的学生,递归是一个必须克服的障碍,以理解许多流行的算法。如果你是一个编程训练营的毕业生或自学的程序员,成功地绕过了更理论性的计算机科学主题,递归问题仍然会在白板编程面试中出现。如果你是一个有经验的软件工程师,在之前从未接触过递归算法,你可能会发现递归是你知识中一个令人尴尬的空白。

不用担心。递归并不像教授它那样难以理解。正如我将在第一章中解释的那样,我认为递归的普遍误解是由于教学不佳而不是任何固有的困难。由于递归函数在日常编程中并不常用,许多人可以很好地没有它们。

但递归算法背后存在着一定的概念美,即使你不经常应用它们,也可以帮助你理解编程。递归也有视觉美。这种技术是分形的惊人数学艺术的基础,如图 1 所示的自相似形状。

图 1:这些分形的例子包括 Sierpiński 三角形(左)、希尔伯特曲线(中)和科赫雪花(右)。

然而,这本书并不完全是在赞美递归。我对这种技术提出了一些尖锐的批评。在存在更简单解决方案的情况下,递归被过度使用。递归算法可能难以理解,性能较差,并容易导致堆栈溢出错误。某种类型的程序员可能使用递归,并不是因为它是解决特定问题的正确技术,而只是因为他们觉得当他们编写其他程序员难以理解的代码时更聪明。计算机科学家约翰·威兰德博士曾说过:“当你在计算机科学领域获得博士学位时,他们会带你去一个特殊的房间,并解释你绝不能在现实生活中使用递归。它的唯一目的是让本科生的编程变得困难。”

因此,无论你是想在编程面试中获得优势,想创建美丽的数学艺术,还是固执地寻求最终理解这个概念的迷人特性,这本书将是你进入递归的兔子洞的向导(以及兔子洞内部的兔子洞)。递归是计算机科学中将专业人士与初学者区分开的主题之一。通过阅读本书,你将掌握一项重要的技能,并了解它的黑暗秘密:递归并不像人们想象的那么复杂。

这本书是为谁写的?

这本书是为那些对递归算法感到害怕或感兴趣的人写的。递归是一个对初学者或大一计算机科学学生来说似乎像黑魔法的主题之一。大多数递归课程很难理解,使这个主题看起来令人沮丧,甚至可怕。对于这些读者,我希望本书直接的解释和丰富的例子可以帮助他们最终理解这个主题。

这本书的唯一先决条件是具有 Python 或 JavaScript 编程语言的基本编程经验,这些章节的代码示例使用这些语言。本书的程序已经被简化到它们的本质;如果你知道如何调用和创建函数以及全局变量和局部变量之间的区别,你就足够了。

关于本书

本书共有 14 章:

第一部分:理解递归

  1. 第一章:什么是递归?解释了递归以及它是编程语言实现函数和函数调用的自然结果。本章还认为递归并不像许多人声称的那样优雅、神秘。

  2. 第二章:递归与迭代深入探讨了递归和迭代技术之间的差异(以及许多相似之处)。

  3. 第三章:经典递归算法涵盖了著名的递归程序,如汉诺塔、泛洪填充算法等。

  4. 第四章:回溯和树遍历算法讨论了递归特别适用的问题:遍历树数据结构,比如解决迷宫和导航目录时。

  5. 第五章:分治算法讨论了递归如何将大问题分解为更小的子问题,并涵盖了几种常见的分治算法。

  6. 第六章:排列和组合涵盖了涉及排序和匹配的递归算法,以及这些技术应用到的常见编程问题。

  7. 第七章:记忆化和动态规划解释了一些简单的技巧,以提高在现实世界中应用递归时的代码效率。

  8. 第八章:尾递归优化涵盖了尾递归优化,这是一种用于改进递归算法性能的常见技术,以及它的工作原理。

  9. 第九章:绘制分形介绍了可以通过递归算法以编程方式生成的有趣艺术。本章利用海龟图形生成其图像。

第二部分:项目

  1. 第十章:文件查找器涵盖了一个可以根据您提供的自定义搜索参数搜索计算机上文件的项目。

  2. 第十一章:迷宫生成器涵盖了一个自动生成任意大小迷宫的项目,使用了递归回溯算法。

  3. 第十二章:滑块拼图求解器涵盖了一个解决滑块拼图(也称为 15 拼图)的项目。

  4. 第十三章:分形艺术生成器探索了一个可以制作自己设计的自定义分形艺术的项目。

  5. 第十四章:Droste 生成器探索了一个使用 Pillow 图像处理模块制作递归图片的项目。

动手实验计算机科学

单单阅读关于递归的内容并不能教会你如何独立实现它。本书包含了许多 Python 和 JavaScript 编程语言的递归代码示例供您实验。如果您是编程新手,您可以阅读我的书《用 Python 自动化繁琐工作》,第二版(No Starch Press,2019 年),或者 Eric Matthes 的《Python 编程快速上手》,第二版(No Starch Press,2019 年)来介绍编程和 Python 编程语言。

我建议使用调试器逐行执行这些程序。 调试器允许您逐行执行程序并检查程序的状态,从而可以准确定位错误发生的位置。《用 Python 自动化繁琐工作》,第二版,第十一章介绍了如何使用 Python 调试器,并可在automatetheboringstuff.com/2e/chapter11免费在线阅读。

本书的章节展示了 Python 和 JavaScript 代码示例。Python 代码保存在*.py文件中,JavaScript 代码保存在.html文件中(而不是.js*文件)。例如,看下面的hello.py文件:

print('Hello, world!')

以及以下的hello.html文件:

<script type="text/javascript">
document.write("Hello, world!<br />");
</script>

这两个代码清单充当了一块罗塞塔石,描述了以两种不同语言产生相同结果的程序。

我鼓励您使用键盘手动复制这些程序,而不是简单地将它们的源代码复制粘贴到一个新文件中。这有助于您对程序的“肌肉记忆”,并迫使您在输入每一行时考虑它。

.html文件在技术上不是有效的,因为它们缺少几个必要的 HTML 标签,例如<html><body>,但您的浏览器仍然可以显示输出。这些标签是故意省略的。本书中的程序是为了简单和可读性而编写的,而不是为了展示 Web 开发的最佳实践。

安装 Python

虽然每台计算机都有一个可以查看本书中*.html*文件的 Web 浏览器,但如果您希望运行本书的 Python 代码,则必须单独安装 Python。您可以从python.org/downloads免费下载 Microsoft Windows、Apple macOS 和 Ubuntu Linux 的 Python。确保下载 Python 3 的版本(如 3.10),而不是 Python 2。Python 3 对语言进行了一些不兼容的更改,本书中的程序可能无法在 Python 2 上正确运行,如果能运行的话。

运行 IDLE 和 Python 代码示例

您可以使用 Python 自带的 IDLE 编辑器编写 Python 代码,也可以安装免费的编辑器,例如来自codewith.mu的 Mu 编辑器,来自www.jetbrains.com/pycharm/download的 PyCharm 社区版,或来自code.visualstudio.com/Download的 Microsoft Visual Studio Code。

要在 Windows 上打开 IDLE,打开屏幕左下角的开始菜单,在搜索框中输入IDLE,然后选择IDLE(Python 3.10 64 位)

在 macOS 上,打开 Finder 窗口,点击应用程序Python 3.10,然后点击 IDLE 图标。

在 Ubuntu 上,选择应用程序附件终端,然后输入IDLE 3。您也可以点击屏幕顶部的应用程序,选择编程,然后点击IDLE 3

IDLE 有两种类型的窗口。交互式 shell 窗口有>>>提示符,用于逐个运行 Python 指令。当您想要尝试 Python 代码的一部分时,这是很有用的。文件编辑器窗口是您可以输入完整的 Python 程序并将它们保存为*.py*文件的地方。这是您将输入本书中 Python 程序的源代码的方式。要打开新的文件编辑器窗口,点击文件新建文件。您可以通过点击运行运行模块或按F5来运行程序。

在浏览器中运行 JavaScript 代码示例

您的计算机的 Web 浏览器可以运行 JavaScript 程序并显示它们的输出,但要编写 JavaScript 代码,您需要一个文本编辑器。像记事本或 TextMate 这样的简单程序就可以,但您也可以安装专门用于编写代码的文本编辑器,例如来自www.sublimetext.com的 IDLE 或 Sublime Text。

在输入 JavaScript 程序代码后,将文件保存为*.html文件,而不是.js*文件。在 Web 浏览器中打开它们以查看结果。任何现代 Web 浏览器都可以用于此目的。

一、什么是递归?

原文:Chapter 1 - What Is Recursion?

译者:飞龙

协议:CC BY-NC-SA 4.0

递归有着令人望而生畏的声誉。人们认为很难理解,但其核心只依赖于两件事:函数调用和栈数据结构。

大多数新程序员通过跟踪执行来追踪程序的操作。这是阅读代码的简单方法:你只需把手指放在程序顶部的代码行上,然后向下移动。有时你的手指会回到原点;其他时候,它会进入一个函数,然后返回。这使得很容易可视化程序的操作和顺序。

但要理解递归,你需要熟悉一个不太明显的数据结构,称为调用栈,它控制程序的执行流程。大多数编程初学者不了解栈,因为编程教程在讨论函数调用时通常甚至不提及它们。此外,自动管理函数调用的调用栈在源代码中根本看不到。

当你看不见并且不知道它的存在时,很难理解某件事!在本章中,我们将拉开窗帘,消除递归难以理解的夸大概念,你将能够欣赏其中的优雅之处。

递归的定义

在开始之前,让我们先把陈词滥调的递归笑话搞定,比如:“要理解递归,你必须先理解递归。”

在我写这本书的几个月里,我可以向你保证,这个笑话听得越多就越好笑。

另一个笑话是,如果你在谷歌上搜索递归,结果页面会问你是否是指递归。点击链接,如图 1-1 所示,会带你到...递归的搜索结果。

谷歌搜索术语“递归”的截图。在搜索栏下方是一个横幅,上面写着“您是不是要找:递归。”

图 1-1:递归的谷歌搜索结果链接到递归的谷歌搜索结果。

图 1-2 显示了网络漫画 xkcd 中的一个递归笑话。

一个单格漫画,有人在读“我如此元,甚至这个首字母缩略词。”

图 1-2:我如此元,甚至这个首字母缩略词(I.S. M.E.T.A.)(xkcd.com/917 by Randall Munroe)

关于 2010 年科幻动作电影《盗梦空间》的大多数笑话都是递归笑话。电影中的角色在梦中有梦,而这些梦中还有梦。

最后,作为计算机科学家,谁能忘记希腊神话中的递归半人马怪物?正如你在图 1-3 中所看到的,它是半马半递归半人马。

图中是一个雕像,有马的身体,前腿和躯干呈螺旋状重复,尺寸逐渐减小。

图 1-3:递归半人马。Joseph Parker 提供的图片。

根据这些笑话,你可能会得出结论,递归是一种元、自我引用、梦中梦、无限镜中镜的东西。让我们建立一个具体的定义:递归是指其定义包括自身的东西。也就是说,它具有自我引用的定义。

图 1-4 中的谢尔宾斯基三角形被定义为一个等边三角形,中间有一个倒置的三角形,形成三个新的等边三角形,每个三角形都包含一个谢尔宾斯基三角形。谢尔宾斯基三角形的定义包括谢尔宾斯基三角形。

图形描绘了三角形。第一个三角形中间有一个较小的倒置三角形,将原始三角形分成较小的三角形。下一个三角形显示了这三个较小的外部三角形,每个都分成更小的三角形。第三个三角形显示了这些更小的三角形进一步分成三角形。

图 1-4:谢尔宾斯基三角形是包含谢尔宾斯基三角形的分形(递归形状)。

在编程上下文中,递归函数是调用自身的函数。在我们探索递归函数之前,让我们退一步,了解正常函数是如何工作的。程序员往往认为函数调用理所当然,但即使是有经验的程序员也会发现值得回顾下一节中的函数。

什么是函数?

函数可以被描述为程序中的迷你程序。它们是几乎每种编程语言的特性。如果您需要在程序中的三个不同位置运行相同的指令,而不是将源代码复制粘贴三次,您可以在函数中编写一次代码并调用函数三次。有益的结果是更短和更易读的程序。程序也更容易更改:如果您需要修复错误或添加功能,您只需要在一个地方更改程序,而不是三个地方。

所有编程语言在它们的函数中实现了四个特性:

  1. 函数有在调用函数时运行的代码。

  2. 参数(即值)在调用函数时传递。这是函数的输入,函数可以有零个或多个参数。

  3. 函数返回一个返回值。这是函数的输出,尽管一些编程语言允许函数不返回任何东西或返回像undefinedNone这样的空值。

  4. 程序记住了调用函数的代码行,并在函数完成执行时返回到它。

不同的编程语言可能具有其他特性,或者对如何调用函数有不同的选项,但它们都具有这四个一般元素。您可以在源代码中直观地看到这四个元素中的前三个,但是当函数返回时,程序如何跟踪执行应该返回到哪里呢?

为了更好地理解问题,创建一个functionCalls.py程序,其中包含三个函数:a()调用b()b()调用c()

Python

def a():
    print('a() was called.')
    b()
    print('a() is returning.')

def b():
    print('b() was called.')
    c()
    print('b() is returning.')

def c():
    print('c() was called.')
    print('c() is returning.')

a()

这段代码等同于以下functionCalls.html程序:

JavaScript

<script type="text/javascript">
function a() {
    document.write("a() was called.<br />");
    b();
    document.write("a() is returning.<br />");
}

function b() {
    document.write("b() was called.<br />");
    c();
    document.write("b() is returning.<br />");
}

function c() {
    document.write("c() was called.<br />");
    document.write("c() is returning.<br />");
}

a();
</script>

当您运行此代码时,输出如下:

a() was called.
b() was called.
c() was called.
c() is returning.
b() is returning.
a() is returning.

输出显示了函数a()b()c()的开始。然后,当函数返回时,输出以相反的顺序出现:c()b(),然后是a()。注意文本输出的模式:每次函数返回时,它都记住了最初调用它的代码行。当c()函数调用结束时,程序返回到b()函数并显示b()正在返回。然后b()函数调用结束,程序返回到a()函数并显示a()正在返回。最后,程序返回到程序末尾的原始a()函数调用。换句话说,函数调用并不会使程序的执行成为单向行程。

但程序如何记住是a()还是b()调用了c()?这个细节由程序隐式处理,使用一个调用栈。要理解调用栈如何记住函数调用结束时执行返回的位置,我们首先需要了解栈是什么。

什么是栈?

之前我提到过一个陈词滥调的笑话,“要理解递归,你必须先理解递归。”但这实际上是错误的:要真正理解递归,你必须先理解栈。

是计算机科学中最简单的数据结构之一。它像列表一样存储多个值,但与列表不同的是,它只限制您在栈的“顶部”添加或删除值。对于使用列表或数组实现的栈,“顶部”是最后一个项目,在列表或数组的右端。添加值称为推送值到栈上,而删除值称为弹出值出栈。

想象一下,您正在与某人进行一场漫谈。您正在谈论您的朋友 Alice,然后想起了关于您同事 Bob 的故事,但为了讲清楚这个故事,您首先必须解释一些关于您表妹 Carol 的事情。您讲完了关于 Carol 的故事,然后回到谈论 Bob,当您讲完了关于 Bob 的故事后,您又回到了谈论 Alice。然后您想起了您的兄弟 David,于是您讲了一个关于他的故事。最终,您回到了最初关于 Alice 的故事。

您的对话遵循类似堆栈的结构,就像图 1-5 中的那样。对话类似于堆栈,因为当前话题总是在堆栈的顶部。

一个时间轴,显示了各个时间点上叠放在一起的名字。它从没有名字开始,然后显示了 Alice,然后是 Alice 上面的 Bob,然后是 Alice 上面的 Bob 上面的 Carol,然后是 Alice 上面的 Bob,然后只有 Alice,然后是 Alice 上面的 David,然后再次只有 Alice,最后没有名字。

图 1-5:您的漫谈对话堆栈

在我们的对话堆栈中,新话题被添加到堆栈的顶部,并在完成时被移除。之前的话题在堆栈中的当前话题下面被“记住”。

如果我们限制自己使用append()pop()方法来执行推入和弹出操作,我们可以将 Python 列表用作堆栈。JavaScript 数组也可以通过它们的push()pop()方法用作堆栈。

例如,考虑这个cardStack.py程序,它将扑克牌的字符串值推入和弹出到名为cardStack的列表的末尾:

Python

cardStack = [] # ❶
cardStack.append('5 of diamonds') # ❷
print(','.join(cardStack))
cardStack.append('3 of clubs')
print(','.join(cardStack))
cardStack.append('ace of hearts')
print(','.join(cardStack))
cardStack.pop() # ❸
print(','.join(cardStack))

以下的cardStack.html程序包含了 JavaScript 中的等效代码:

JavaScript

<script type="text/javascript">
let cardStack = []; // ❶
cardStack.push("5 of diamonds"); // ❷
document.write(cardStack + "<br />");
cardStack.push("3 of clubs");
document.write(cardStack + "<br />");
cardStack.push("ace of hearts");
document.write(cardStack + "<br />");
cardStack.pop() // ❸
document.write(cardStack + "<br />");
</script>

当您运行此代码时,输出如下所示:

5 of diamonds
5 of diamonds,3 of clubs
5 of diamonds,3 of clubs,ace of hearts
5 of diamonds,3 of clubs

堆栈从空开始❶。推入堆栈的是代表卡片的三个字符串❷。然后弹出堆栈❸,这将移除红桃 A 并再次将梅花三放在堆栈的顶部。cardStack堆栈的状态在图 1-6 中进行了跟踪,从左到右。

时间轴显示了在不同时间点上叠放在一起的扑克牌。从没有扑克牌开始,然后是方块五,然后是梅花三叠在方块五上面,然后是红桃 A 叠在梅花三上面,最后移除红桃 A 以显示梅花三。

图 1-6:堆栈开始为空。然后将卡片推入和弹出堆栈。

您只能看到卡堆中的最顶部卡片,或者在我们程序的堆栈中,最顶部的值。在最简单的堆栈实现中,您无法看到堆栈中有多少张卡片(或值)。您只能看到堆栈是否为空。

堆栈是一种LIFO数据结构,代表后进先出,因为推入堆栈的最后一个值是弹出的第一个值。这种行为类似于您的网络浏览器的“返回”按钮。您的浏览器标签的历史记录就像一个包含您按顺序访问的所有页面的堆栈。浏览器始终显示历史记录“堆栈”中的顶部网页。单击链接会将新网页推入历史记录堆栈,而单击“返回”按钮会弹出顶部网页并显示其下面的网页。

调用堆栈是什么?

程序也使用堆栈。程序的调用堆栈,也简称为堆栈,是一堆帧对象。帧对象,也简称为,包含有关单个函数调用的信息,包括调用函数的代码行,因此当函数返回时,执行可以回到那里。

当调用函数时,将创建帧对象并将其推送到堆栈上。当函数返回时,该帧对象将从堆栈中弹出。如果我们调用一个调用一个调用函数的函数,调用堆栈将在堆栈上有三个帧对象。当所有这些函数返回时,调用堆栈将在堆栈上有零个帧对象。

程序员不必编写处理帧对象的代码,因为编程语言会自动处理它们。不同的编程语言有不同的实现帧对象的方式,但通常它们包含以下内容:

  • 返回地址,或者函数返回时执行的位置

  • 传递给函数调用的参数

  • 在函数调用期间创建的一组局部变量

例如,看一下以下localVariables.py程序,它有三个函数,就像我们之前的functionCalls.pyfunctionCalls.html程序一样:

Python

def a():
    spam = 'Ant' # ❶
    print('spam is ' + spam) # ❷
    b() # ❸
    print('spam is ' + spam)

def b():
    spam = 'Bobcat' # ❹
    print('spam is ' + spam)
    c() # ❺
    print('spam is ' + spam)

def c():
    spam = 'Coyote' # ❻
    print('spam is ' + spam)

a() # ❼

这个localVariables.html是等效的 JavaScript 程序:

JavaScript

<script type="text/javascript">
function a() {
    let spam = "Ant"; // ❶
    document.write("spam is " + spam + "<br />"); // ❷
    b(); // ❸
    document.write("spam is " + spam + "<br />");
}

function b() {
    let spam = "Bobcat";//# ❹
    document.write("spam is " + spam + "<br />");
    c(); // ❺
 document.write("spam is " + spam + "<br />");
}

function c() {
    let spam = "Coyote"; // ❻
    document.write("spam is " + spam + "<br />");
}

a(); // ❼
</script>

当您运行此代码时,输出如下所示:

spam is Ant
spam is Bobcat
spam is Coyote
spam is Bobcat
spam is Ant

当程序调用函数a() ❼时,将创建一个帧对象并将其放置在调用堆栈顶部。该帧存储传递给a()的任何参数(在本例中没有),以及局部变量spam ❶和a()函数返回时执行的位置。

当调用a()时,它显示其局部spam变量的内容,即Ant ❷。当a()中的代码调用函数b() ❸时,将创建一个新的帧对象并将其放置在调用堆栈上方,用于a()的帧对象。b()函数有自己的局部spam变量 ❹,并调用c() ❺。为c()调用创建一个新的帧对象并将其放置在调用堆栈上,其中包含c()的局部spam变量 ❻。随着这些函数的返回,帧对象从调用堆栈中弹出。程序执行知道要返回到哪里,因为返回信息存储在帧对象中。当执行从所有函数调用返回时,调用堆栈为空。

图 1-7 显示了每个函数调用和返回时调用堆栈的状态。请注意,所有局部变量都具有相同的名称:spam。我这样做是为了突出局部变量始终是具有不同值的单独变量,即使它们与其他函数中的局部变量具有相同的名称。

时间轴描绘了调用堆栈在不同时间点的状态。它开始为空。然后,在 a()函数中,spam 变量等于“Ant”。接下来,在 b()函数中,spam 等于“Bobcat”,然后在 c()函数中,spam 等于“Coyote”。在 b()函数中,spam 再次等于“Bobcat”,在 a()函数中,spam 再次等于“Ant”。

图 1-7:localVariables程序运行时调用堆栈的状态

正如您所看到的,编程语言可以具有相同名称(spam)的单独的局部变量,因为它们保存在单独的帧对象中。当在源代码中使用局部变量时,将使用顶部帧对象中具有该名称的变量。

每个运行的程序都有一个调用堆栈,多线程程序每个线程都有一个调用堆栈。但是当您查看程序的源代码时,您无法在代码中看到调用堆栈。调用堆栈不像其他数据结构一样存储在变量中;它在后台自动处理。

调用堆栈在源代码中不存在的事实是递归对初学者如此令人困惑的主要原因:递归依赖于程序员甚至看不到的东西!揭示堆栈数据结构和调用堆栈的工作原理消除了递归背后的许多神秘之处。函数和堆栈都是简单的概念,我们可以将它们结合起来理解递归是如何工作的。

递归函数和堆栈溢出是什么?

递归函数是调用自身的函数。这个shortest.py程序是递归函数的最短可能示例:

Python

def shortest():
    shortest()

shortest()

前面的程序等同于这个shortest.html程序:

JavaScript

<script type="text/javascript">
function shortest() {
    shortest();
}

shortest();
</script>

shortest()函数除了调用shortest()函数什么也不做。当这发生时,它再次调用shortest()函数,然后shortest()会调用shortest(),依此类推,看起来永远不会停止。这类似于地壳靠着一只巨大的空间乌龟的背部,而那只乌龟又靠着另一只乌龟。在那只乌龟下面:另一只乌龟。如此循环,永无止境。

但是这个“无穷递归”的理论并不能很好地解释宇宙学,也不能很好地解释递归函数。由于调用堆栈使用了计算机的有限内存,这个程序不能永远继续下去,就像无限循环那样。这个程序唯一能做的就是崩溃并显示错误消息。

shortest.py的 Python 输出看起来像这样:

Traceback (most recent call last):
  File "shortest.py", line 4, in <module>
    shortest()
  File "shortest.py", line 2, in shortest
    shortest()
  File "shortest.py", line 2, in shortest
    shortest()
  File "shortest.py", line 2, in shortest
    shortest()
  [Previous line repeated 996 more times]
RecursionError: maximum recursion depth exceeded

shortest.html的 JavaScript 输出在 Google Chrome 网页浏览器中看起来像这样(其他浏览器会有类似的错误消息):

Uncaught RangeError: Maximum call stack size exceeded
    at shortest (shortest.html:2)
    at shortest (shortest.html:3)
    at shortest (shortest.html:3)
    at shortest (shortest.html:3)
    at shortest (shortest.html:3)
    at shortest (shortest.html:3)
    at shortest (shortest.html:3)
    at shortest (shortest.html:3)
    at shortest (shortest.html:3)
    at shortest (shortest.html:3)

这种错误被称为堆栈溢出。(这就是流行网站stackoverflow.com得名的地方。)不断的函数调用而没有返回会使调用堆栈增长,直到计算机为调用堆栈分配的所有内存都被用完。为了防止这种情况,Python 和 JavaScript 解释器在一定数量的不返回值的函数调用后会终止程序。

这个限制被称为最大递归深度最大调用堆栈大小。对于 Python,这被设置为 1,000 个函数调用。对于 JavaScript,最大调用堆栈大小取决于运行代码的浏览器,但通常至少为 10,000 左右。把堆栈溢出想象成当调用堆栈变得“太高”(也就是消耗了太多的计算机内存)时发生,就像图 1-8 中的情况。

图形描绘了一叠卡片,顶部卡片上写着“c() spam = 'Coyote'”。一个警告写着,“堆栈太高。”

图 1-8:当调用堆栈变得太高时,堆栈溢出就会发生,有太多的帧对象占用了计算机的内存。

堆栈溢出不会损坏计算机。计算机只是检测到函数调用的限制已经达到并终止程序。最坏的情况下,你会丢失程序中的任何未保存的工作。堆栈溢出可以通过有一个叫做基本情况的东西来防止,接下来会解释。

基本情况和递归情况

堆栈溢出示例有一个shortest()函数调用shortest()但从不返回。为了避免崩溃,需要有一个情况,或一组情况,使得函数停止调用自身,而是直接返回。这被称为基本情况。相比之下,函数递归调用自身的情况被称为递归情况

所有递归函数都需要至少一个基本情况和至少一个递归情况。如果没有基本情况,函数永远不会停止进行递归调用,最终导致堆栈溢出。如果没有递归情况,函数永远不会调用自身,只是一个普通函数,而不是递归函数。当你开始编写自己的递归函数时,一个很好的第一步是找出基本情况和递归情况应该是什么。

看一下这个shortestWithBaseCase.py程序,它定义了不会因堆栈溢出而崩溃的最短递归函数:

Python

def shortestWithBaseCase(makeRecursiveCall):
    print('shortestWithBaseCase(%s) called.' % makeRecursiveCall)
    if not makeRecursiveCall:
        # BASE CASE
        print('Returning from base case.')
        return # ❶
    else:
        # RECURSIVE CASE
        shortestWithBaseCase(False) # ❷
        print('Returning from recursive case.')
        return

print('Calling shortestWithBaseCase(False):')
shortestWithBaseCase(False) # ❸
print()
print('Calling shortestWithBaseCase(True):')
shortestWithBaseCase(True) # ❹

这段代码等同于以下shortestWithBaseCase.html程序:

JavaScript

<script type="text/javascript">
function shortestWithBaseCase(makeRecursiveCall) {
    document.write("shortestWithBaseCase(" + makeRecursiveCall + 
     ") called.<br />");
    if  (makeRecursiveCall === false) {
        // BASE CASE
        document.write("Returning from base case.<br />");
        return; // ❶
 } else {
        // RECURSIVE CASE
        shortestWithBaseCase(false); // ❷
        document.write("Returning from recursive case.<br />");
        return;
    }
}

document.write("Calling shortestWithBaseCase(false):<br />");
shortestWithBaseCase(false); // ❸
document.write("<br />");
document.write("Calling shortestWithBaseCase(true):<br />");
shortestWithBaseCase(true); // ❹
</script>

当你运行这段代码时,输出看起来像这样:

Calling shortestWithBaseCase(False):
shortestWithBaseCase(False) called.
Returning from base case.

Calling shortestWithBaseCase(True):
shortestWithBaseCase(True) called.
shortestWithBaseCase(False) called.
Returning from base case.
Returning from recursive case.

这个函数除了提供递归的简短示例外并没有做任何有用的事情(并且通过删除文本输出可以使其更短,但文本对我们的解释很有用)。当调用shortestWithBaseCase(False)时❸,基本情况被执行,函数仅返回❶。然而,当调用shortestWithBaseCase(True)时❹,递归情况被执行,并调用shortestWithBaseCase(False)❷。

重要的是要注意,当从❷递归调用shortestWithBaseCase(False)并返回时,执行不会立即回到❹处的原始函数调用。递归调用后的递归情况中的其余代码仍然会运行,这就是为什么输出中会出现Returning from recursive case.。从基本情况返回并不会立即返回到之前发生的所有递归调用。这在下一节中的countDownAndUp()示例中将是重要的要记住的。

递归调用前后的代码

递归情况中的代码可以分为两部分:递归调用前的代码和递归调用后的代码。(如果在递归情况中有两个递归调用,比如第二章中的斐波那契数列示例,那么会有一个前、一个中和一个后。但现在让我们保持简单。)

重要的是要知道,达到基本情况并不一定意味着递归算法的结束。它只意味着基本情况不会继续进行递归调用。

例如,考虑这个countDownAndUp.py程序,其递归函数从任何数字倒数到零,然后再次升到该数字:

Python

def countDownAndUp(number):
    print(number) # ❶
    if number == 0:
        # BASE CASE
        print('Reached the base case.') # ❷
        return
    else:
        # RECURSIVE CASE
        countDownAndUp(number - 1) # ❸
        print(number, 'returning') # ❹
        return

countDownAndUp(3) # ❺

这里是等效的countDownAndUp.html程序:

JavaScript

<script type="text/javascript">
function countDownAndUp(number) {
    document.write(number + "<br />"); // ❶
    if (number === 0) {
        // BASE CASE
        document.write("Reached the base case.<br />"); // ❷
        return;
    } else {
        // RECURSIVE CASE
        countDownAndUp(number - 1); // ❸
        document.write(number + " returning<br />"); // ❹
        return;
    }
}

countDownAndUp(3); # ❺
</script>

当运行此代码时,输出如下:

3
2
1
0
Reached the base case.
1 returning
2 returning
3 returning

请记住,每次调用函数时,都会创建一个新帧并推送到调用堆栈上。这个帧是存储所有局部变量和参数(如number)的地方。因此,对于调用堆栈上的每个帧都有一个单独的number变量。这是关于递归经常令人困惑的另一个要点:尽管从源代码看,似乎只有一个number变量,但请记併,因为它是局部变量,实际上对于每个函数调用都有一个不同的number变量。

当调用countDownAndUp(3)时❺,会创建一个帧,该帧的局部变量number设置为3。函数将number变量打印到屏幕上❶。只要number不是0,就会递归调用countDownAndUp(),参数为number - 1❸。当调用countDownAndUp(2)时,会推送一个新帧到堆栈上,并且该帧的局部变量number设置为2。同样,递归情况被触发,调用countDownAndUp(1),再次触发递归情况并调用countDownAndUp(0)

连续进行递归函数调用然后从递归函数调用返回的模式是导致数字倒数出现的原因。一旦调用countDownAndUp(0),就会达到基本情况❷,不会再进行递归调用。然而,这并不是我们程序的结束!当达到基本情况时,局部变量number0。但当基本情况返回并且帧从调用堆栈中弹出时,其下面的帧有自己的局部变量number,其值始终为1。当执行返回到调用堆栈中的前一个帧时,递归调用后的代码会被执行❹。这就是导致数字升序出现的原因。图 1-9 显示了在递归调用countDownAndUp()并返回时调用堆栈的状态。

时间轴描述了每次调用 countDownAndUp()后的调用堆栈。它开始为空,然后显示 number 变量等于 3,然后等于 2,然后等于 1,然后等于 0。然后 number 变量开始增加,先是 1,然后是 2,然后是 3,直到调用堆栈为空。

图 1-9:调用堆栈跟踪每个函数调用中“number”局部变量的值

当基本情况达到时,代码不会立即停止,这一点对于下一章中的阶乘计算非常重要。请记住,递归情况之后的任何代码仍然必须运行。

此时,您可能会认为递归的countDownAndUp()函数设计过于复杂,难以理解。为什么不使用迭代解决方案来打印数字呢?迭代方法通常被认为是递归的相反,它使用循环重复任务直到完成。

每当您问自己,“使用循环会更容易吗?”答案几乎肯定是“是”,您应该避免使用递归解决方案。递归对于初学者和有经验的程序员都可能很棘手,递归代码并不自动比迭代代码“更好”或“更优雅”。可读性强、易于理解的代码比递归提供的任何所谓的优雅更重要。然而,在某些情况下,算法可以清晰地映射到递归方法。涉及树状数据结构并需要回溯的算法特别适合使用递归。这些想法在第二章和第四章中进一步探讨。

总结

递归经常会让新手程序员感到困惑,但它建立在一个简单的思想上,即函数可以调用自身。每次进行函数调用时,都会向调用堆栈添加一个新的帧对象,其中包含与调用相关的信息(例如局部变量和函数返回时执行移动到的返回地址)。调用堆栈作为一个堆栈数据结构,只能通过向其“顶部”添加或删除数据来改变。这分别称为推入弹出堆栈。

程序隐式处理调用堆栈,因此没有调用堆栈变量。调用函数会将一个帧对象推入调用堆栈,从函数返回会从调用堆栈中弹出一个帧对象。

递归函数有递归情况,即进行递归调用的情况,和基本情况,即函数简单返回的情况。如果没有基本情况或者错误阻止基本情况运行,执行将导致堆栈溢出,从而使程序崩溃。

递归是一种有用的技术,但递归并不会自动使代码“更好”或更“优雅”。这个想法在下一章中会更详细地探讨。

进一步阅读

您可以在 2018 年北湾 Python 大会上找到有关递归的其他介绍,标题为“递归入门:递归初学者指南”,网址为youtu.be/AfBqVVKg4GE。YouTube 频道 Computerphile 还在其视频“地球上的递归是什么?”中介绍了递归,网址为youtu.be/Mv9NEXX1VHc。最后,V. Anton Spraul 在他的书像程序员一样思考(No Starch Press,2012)和他的视频“递归(像程序员一样思考)”中讨论了递归,网址为youtu.be/oKndim5-G94。维基百科的递归文章在en.wikipedia.org/wiki/Recursion中有详细介绍。

您可以为 Python 安装“ShowCallStack”模块。该模块添加了一个“showcallstack()”函数,您可以将其放在代码中的任何位置,以查看程序在特定点的调用堆栈状态。您可以在pypi.org/project/ShowCallStack下载该模块并找到相关说明。

练习题

通过回答以下问题来测试你的理解能力:

  1. 一般来说,什么是递归的东西?

  2. 在编程中,什么是递归函数?

  3. 函数有哪四个特征?

  4. 什么是堆栈?

  5. 向堆栈的顶部添加和移除值的术语是什么?

  6. 假设你将字母J推送到堆栈,然后推送字母Q,然后弹出堆栈,然后推送字母K,然后再次弹出堆栈。堆栈是什么样子?

  7. 被推送和弹出到调用堆栈上的是什么?

  8. 是什么导致堆栈溢出?

  9. 什么是基本情况?

  10. 什么是递归情况?

  11. 递归函数有多少个基本情况和递归情况?

  12. 如果一个递归函数没有基本情况会发生什么?

  13. 如果一个递归函数没有递归情况会发生什么?

二、递归与迭代

原文:Chapter 2 - Recursion vs. Iteration

译者:飞龙

协议:CC BY-NC-SA 4.0

递归和迭代都没有一般上是更好的技术。事实上,任何递归代码都可以用循环和堆栈编写成迭代代码。递归并没有某种特殊的能力使其能够执行迭代算法无法执行的计算。任何迭代循环都可以重写为递归函数。

本章比较和对比了递归和迭代。我们将研究经典的斐波那契和阶乘函数,并看看它们的递归算法为什么有关键的弱点。我们还将通过考虑指数算法来探索递归方法可以产生的见解。总的来说,本章揭示了递归算法的所谓优雅之处,并展示了递归解决方案何时有用以及何时不适用。

计算阶乘

许多计算机科学课程使用阶乘计算作为递归函数的经典示例。一个整数(我们称之为n)的阶乘是从 1 到n的所有整数的乘积。例如,4 的阶乘是 4 × 3 × 2 × 1,即 24。感叹号是阶乘的数学表示法,如 4!,表示4 的阶乘。表 2-1 显示了前几个阶乘。

表 2-1:前几个整数的阶乘

n!展开形式乘积
1!=1=1
2!=1 × 2=2
3!=1 × 2 × 3=6
4!=1 × 2 × 3 × 4=24
5!=1 × 2 × 3 × 4 × 5=120
6!=1 × 2 × 3 × 4 × 5 × 6=720
7!=1 × 2 × 3 × 4 × 5 × 6 × 7=5,040
8!=1 × 2 × 3 × 4 × 5 × 6 × 7 × 8=40,320

阶乘在各种计算中都有用途——例如,找到某物排列的排列数。如果你想知道有多少种方式可以将四个人——Alice、Bob、Carol 和 David——排成一行,答案就是 4 的阶乘。四个可能的人可以先站在队伍中(4);然后对于这四个选项中的每一个,还有三个人可以站在第二位(4 × 3);然后两个人可以站在第三位(4 × 3 × 2);最后一个人站在第四位(4 × 3 × 2 × 1)。人们可以排成队伍的方式数量——也就是排列的数量——就是人数的阶乘。

现在让我们来看看计算阶乘的迭代和递归方法。

迭代阶乘算法

用迭代方法计算阶乘相当直接:在循环中将整数 1 到n相乘。迭代算法总是使用循环。factorialByIteration.py程序看起来像这样:

Python

def factorial(number):
    product = 1
    for i in range(1, number + 1):
        product = product * i
    return product
print(factorial(5))

factorialByIteration.html程序看起来像这样:

JavaScript

<script type="text/javascript">
function factorial(number) {
    let product = 1;
    for (let i = 1; i <= number; i++) {
        product = product * i;
    }
    return product;
}
document.write(factorial(5));
</script>

当你运行这段代码时,输出会显示 5!的计算结果如下:

120

用迭代方法计算阶乘没有问题;它很直接并且完成了任务。但是让我们也看看递归算法,以便更好地理解阶乘和递归本身的性质。

递归阶乘算法

注意 4 的阶乘是 4 × 3 × 2 × 1,5 的阶乘是 5 × 4 × 3 × 2 × 1。所以你可以说 5! = 5 × 4!。这是递归,因为 5 的阶乘(或任何数字n)的定义包括 4 的阶乘(数字n - 1)的定义。依此类推,4! = 4 × 3!,以此类推,直到必须计算 1!,即基本情况,它只是 1。

递归阶乘算法的 Python 程序factorialByRecursion.py使用了递归阶乘算法:

Python

def factorial(number):
    if number == 1:
        # BASE CASE
        return 1
    else:
        # RECURSIVE CASE
        return number * factorial(number - 1) # ❶
print(factorial(5))

factorialByRecursion.html的 JavaScript 程序与等效代码看起来是这样的:

JavaScript

<script type="text/javascript">
function factorial(number) {
    if (number == 1) {
        // BASE CASE
        return 1;
 } else {
        // RECURSIVE CASE
        return number * factorial(number - 1); // ❶
    }
}
document.write(factorial(5));
</script>

当你运行这段代码递归计算 5!时,输出与迭代程序的输出相匹配:

120

对许多程序员来说,这个递归代码看起来很奇怪。你知道factorial(5)必须计算 5 × 4 × 3 × 2 × 1,但很难指出这个乘法发生在哪一行代码上。

混淆是因为递归情况有一行 ❶,其中一半在递归调用之前执行,另一半在递归调用返回后执行。我们不习惯一行代码只有一半在执行。

第一部分是factorial(number - 1)。这涉及计算number - 1并创建一个递归函数,导致调用栈中推送一个新的帧对象。这发生在递归调用之前。

代码再次运行时,旧的帧对象是在factorial(number - 1)返回后。当调用factorial(5)时,factorial(number - 1)将是factorial(4),返回值是24。这时第二部分代码运行。return number * factorial(number - 1)现在看起来像return 5 * 24,这就是为什么factorial(5)返回120

图 2-1 跟踪了调用栈的状态,帧对象被推送(当递归函数调用时发生)和帧对象被弹出(当递归函数调用返回时)。注意乘法发生在递归调用之后,而不是之前。

当原始函数调用factorial()返回时,它返回了计算出的阶乘。

为什么递归阶乘算法很糟糕

用于计算阶乘的递归实现有一个关键的弱点。计算 5 的阶乘需要五次递归函数调用。这意味着在基本情况到达之前,调用栈上会有五个帧对象。这不可扩展。

如果你想计算 1001 的阶乘,递归的factorial()函数必须进行 1001 次递归函数调用。然而,你的程序很可能在完成之前引起堆栈溢出,因为进行如此多的函数调用而不返回会超过解释器的最大调用栈大小。这很糟糕;你绝对不会想在真实世界的代码中使用递归阶乘函数。

调用阶乘函数后每次调用后调用栈的时间线。我们从 number 变量等于 5 和阶乘函数在参数 4 时的返回值 5 倍阶乘函数结果开始。接下来,number 等于 4,返回值是 4 倍阶乘函数在参数 3 时的返回值。然后,number 等于 3,返回值是 3 倍阶乘函数在参数 2 时的返回值。接下来,number 等于 2,返回值是 2 倍阶乘函数在参数 1 时的返回值。之后,number 等于 1,返回值是 1。然后,number 等于 2,返回值是 2 乘 1。接下来,number 等于 3,返回值是 3 乘 2。然后,number 等于 4,返回值是 4 乘 6。接下来,number 等于 5,返回值是 5 乘 24。最后,调用栈为空。

图 2-1:调用栈的状态,递归调用factorial()后的返回

另一方面,迭代阶乘算法将快速高效地完成计算。可以使用一些编程语言中的一种称为尾递归优化的技术来避免堆栈溢出。第八章涵盖了这个主题。然而,这种技术进一步复杂化了递归函数的实现。对于计算阶乘,迭代方法是最简单和最直接的。

计算斐波那契序列

斐波那契序列是介绍递归的另一个经典例子。数学上,整数的斐波那契序列以数字 1 和 1(有时是 0 和 1)开始。序列中的下一个数字是前两个数字的和。这创建了序列 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144 等,永远不会结束。

如果我们将序列中的最新两个数字称为ab,您可以在图 2-2 中看到序列是如何增长的。

显示两个变量 a 和 b 的值以及它们的和随着斐波那契序列的进展而变化的图表。我们从 a 和 b 都等于 1 以及它们的和等于 2 开始。接下来,a 等于 1,b 等于 2,它们的和等于 3。然后 a 等于 2,b 等于 3,它们的和等于 5。斐波那契序列的每次进展都发生在新的一行上,每一行都比上一行多一个数字,形成了金字塔形状。

图 2-2:斐波那契序列的每个数字都是前两个数字的和。

让我们探索一些迭代和递归解决方案的代码示例,用于生成斐波那契数。

迭代斐波那契算法

迭代斐波那契示例很简单,由一个简单的for循环和两个变量ab组成。这个fibonacciByIteration.py Python 程序实现了迭代斐波那契算法:

Python

def fibonacci(nthNumber):
    a, b = 1, 1 # ❶
    print('a = %s, b = %s' % (a, b))
    for i in range(1, nthNumber):
        a, b = b, a + b # Get the next Fibonacci number. # ❷
        print('a = %s, b = %s' % (a, b))
    return a

print(fibonacci(10))

这个fibonacciByIteration.html程序包含了等效的 JavaScript 代码:

JavaScript

<script type="text/javascript">
function fibonacci(nthNumber) {
    let a = 1, b = 1; // ❶
    let nextNum;
    document.write('a = ' + a + ', b = ' + b + '<br />');
    for (let i = 1; i < nthNumber; i++) {
        nextNum = a + b; // Get the next Fibonacci number. // ❷
        a = b;
        b = nextNum;
 document.write('a = ' + a + ', b = ' + b + '<br />');
    }
    return a;
};

document.write(fibonacci(10));
</script>

当您运行此代码来计算第 10 个斐波那契数时,输出如下:

a = 1, b = 1
a = 1, b = 2
a = 2, b = 3
# --snip--
a = 34, b = 55
55

程序只需要一次跟踪序列中的最新两个数字。由于斐波那契序列中的前两个数字被定义为 1,我们将1存储在变量ab中❶。在for循环内,通过将ab相加来计算序列中的下一个数字❷,这成为b的下一个值,而a获得b的前一个值。当循环结束时,b包含第n个斐波那契数,因此返回它。

递归斐波那契算法

计算斐波那契数涉及递归属性。例如,如果要计算第 10 个斐波那契数,您将第九个和第八个斐波那契数相加。要计算这些斐波那契数,您将第八个和第七个,然后第七个和第六个斐波那契数相加。会发生大量重复计算:注意到将第九个和第八个斐波那契数相加涉及再次计算第八个斐波那契数。您继续递归,直到达到第一个或第二个斐波那契数的基本情况,它们始终为 1。

递归斐波那契函数在这个fibonacciByRecursion.py Python 程序中:

def fibonacci(nthNumber):
    print('fibonacci(%s) called.' % (nthNumber))
    if nthNumber == 1 or nthNumber == 2: # ❶
        # BASE CASE
        print('Call to fibonacci(%s) returning 1.' % (nthNumber))
        return 1
    else:
        # RECURSIVE CASE
        print('Calling fibonacci(%s) and fibonacci(%s).' % (nthNumber - 1, nthNumber - 2))
        result = fibonacci(nthNumber - 1) + fibonacci(nthNumber - 2)
        print('Call to fibonacci(%s) returning %s.' % (nthNumber, result))
        return result

print(fibonacci(10))

这个fibonacciByRecursion.html文件包含了等效的 JavaScript 程序:

<script type="text/javascript">
function fibonacci(nthNumber) {
    document.write('fibonacci(' + nthNumber + ') called.<br />');
    if (nthNumber === 1 || nthNumber === 2) { // ❶
        // BASE CASE
        document.write('Call to fibonacci(' + nthNumber + ') returning 1.<br />');
        return 1;
    }
    else {
        // RECURSIVE CASE
        document.write('Calling fibonacci(' + (nthNumber - 1) + ') and fibonacci(' + (nthNumber - 2) + ').<br />');
        let result = fibonacci(nthNumber - 1) + fibonacci(nthNumber - 2);
        document.write('Call to fibonacci(' + nthNumber + ') returning ' + result + '.<br />');
        return result;
    }
}

document.write(fibonacci(10) + '<br />');
</script>

当您运行此代码来计算第 10 个斐波那契数时,输出如下:

fibonacci(10) called.
Calling fibonacci(9) and fibonacci(8).
fibonacci(9) called.
Calling fibonacci(8) and fibonacci(7).
fibonacci(8) called.
Calling fibonacci(7) and fibonacci(6).
fibonacci(7) called.
# --snip--
Call to fibonacci(6) returning 8.
Call to fibonacci(8) returning 21.
Call to fibonacci(10) returning 55.
55

大部分代码用于显示这个输出,但fibonacci()函数本身很简单。基本情况——不再进行递归调用的情况——发生在nthNumber12时❶。在这种情况下,函数返回1,因为第一个和第二个斐波那契数始终为 1。任何其他情况都是递归情况,因此返回的值是fibonacci(nthNumber - 1)fibonacci(nthNumber - 2)的和。只要原始的nthNumber参数是大于0的整数,这些递归调用最终会达到基本情况并停止进行更多的递归调用。

还记得递归阶乘示例中的“递归调用之前”和“递归调用之后”部分吗?因为递归斐波那契算法在其递归情况中进行了两次递归调用,所以你应该记住它有三个部分:“第一个递归调用之前”,“第一个递归调用之后但第二个递归调用之前”,以及“第二个递归调用之后”。但相同的原则适用。不要认为因为达到了基本情况,递归调用之后就不再需要运行任何代码。只有在原始函数调用返回后,递归算法才算完成。

你可能会问:“迭代斐波那契解决方案是否比递归斐波那契解决方案更简单?”答案是“是的”。更糟糕的是,递归解决方案存在一个关键的低效性,下一节将对此进行解释。

为什么递归斐波那契算法很糟糕

与递归阶乘算法一样,递归斐波那契算法也存在一个关键的弱点:它一遍又一遍地重复相同的计算。图 2-3 显示了调用fibonacci(6)(在树形图中标记为fib(6)以简洁表示)时调用了fibonacci(5)fibonacci(4)

以参数 6 调用斐波那契函数开始的树形图。两个后续分支显示了对斐波那契函数的下一次调用,参数分别为 5 和 4。树继续分支,直到每个分支以值 1 结束。其中一些分支被标记为灰色,表示斐波那契函数以与先前分支相同的参数调用。

图 2-3:从fibonacci(6)开始进行的递归函数调用的树形图。冗余的函数调用以灰色显示。

这会引起其他函数调用的级联,直到它们达到fibonacci(2)fibonacci(1)的基本情况,返回1。但请注意,fibonacci(4)被调用了两次,fibonacci(3)被调用了三次,依此类推。这会使整体算法变慢,因为存在不必要的重复计算。随着要计算的斐波那契数变得更大,这种低效性会变得更糟。虽然迭代斐波那契算法可以在不到一秒的时间内完成fibonacci(100),但递归算法需要超过一百万年才能完成。

将递归算法转换为迭代算法

将递归算法转换为迭代算法总是可能的。虽然递归函数通过调用自身重复计算,但这种重复可以通过循环来执行。递归函数还利用调用堆栈;然而,迭代算法可以用堆栈数据结构来替代。因此,任何递归算法都可以通过使用循环和堆栈来进行迭代执行。

为了证明这一点,这里有一个名为factorialEmulateRecursion.py的 Python 程序,它实现了一个迭代算法来模拟递归算法:

callStack = [] # The explicit call stack, which holds "frame objects". ❶
callStack.append({'returnAddr': 'start', 'number': 5}) # "Call" the "factorial() function". ❷
returnValue = None

while len(callStack) > 0:
    # The body of the "factorial() function":

    number = callStack[-1]['number'] # Set number parameter.
    returnAddr = callStack[-1]['returnAddr']

    if returnAddr == 'start':
        if number == 1:
            # BASE CASE
            returnValue = 1
            callStack.pop() # "Return" from "function call". ❸
            continue
        else:
            # RECURSIVE CASE
            callStack[-1]['returnAddr'] = 'after recursive call'
            # "Call" the "factorial() function":
            callStack.append({'returnAddr': 'start', 'number': number - 1}) # ❹
            continue
    elif returnAddr == 'after recursive call':
        returnValue = number * returnValue
        callStack.pop() # "Return from function call". ❺
        continue

print(returnValue)

factorialEmulateRecursion.html程序包含了等效的 JavaScript 代码:

<script type="text/javascript">
let callStack = []; // The explicit call stack, which holds "frame objects". // ❶
callStack.push({"returnAddr": "start", "number": 5}); // "Call" the "factorial() function". // ❷
let returnValue;

while (callStack.length > 0) {
// The body of the "factorial() function":
    let number = callStack[callStack.length - 1]["number"]; // Set number parameter.
    let returnAddr = callStack[callStack.length - 1]["returnAddr"];

    if (returnAddr == "start") {
        if (number === 1) {
            // BASE CASE
            returnValue = 1;
            callStack.pop(); // "Return" from "function call". // ❸
            continue;
        } else {
            // RECURSIVE CASE
 callStack[callStack.length - 1]["returnAddr"] = "after recursive call";
            // "Call" the "factorial() function":
            callStack.push({"returnAddr": "start", "number": number - 1}); // ❹
            continue;
        }
    } else if (returnAddr == "after recursive call") {
        returnValue = number * returnValue;
        callStack.pop(); // "Return from function call". // ❺
        continue;
    }
}

document.write(returnValue + "<br />");
</script> 

请注意,这个程序没有递归函数;它根本没有任何函数!该程序通过使用列表作为堆栈数据结构(存储在callStack变量中❶)来模拟调用堆栈,从而模拟递归函数调用。存储返回地址信息和nthNumber本地变量的字典模拟了帧对象❷。该程序通过将这些帧对象推送到调用堆栈❹来模拟函数调用,并通过从调用堆栈中弹出帧对象 35 来模拟从函数调用返回。

任何递归函数都可以以这种方式被写成迭代的。虽然这段代码非常难以理解,你永远不会以这种方式编写一个真实的阶乘算法,但它确实证明了递归没有任何迭代代码没有的固有能力。

将迭代算法转换为递归算法

同样,将迭代算法转换为递归算法总是可能的。迭代算法只是使用循环的代码。重复执行的代码(循环的主体)可以放在递归函数的主体中。就像循环主体中的代码被重复执行一样,我们需要重复调用函数来执行它的代码。我们可以通过从函数本身调用函数来做到这一点,创建一个递归函数。

hello.py中的 Python 代码演示了通过使用循环打印Hello, world!五次,然后还使用递归函数:

Python

print('Code in a loop:')
i = 0
while i < 5:
    print(i, 'Hello, world!')
    i = i + 1

print('Code in a function:')
def hello(i=0):
    print(i, 'Hello, world!')
    i = i + 1
    if i < 5:
        hello(i) # RECURSIVE CASE
 else:
        return # BASE CASE
hello()

等价的 JavaScript 代码在hello.html中:

JavaScript

<script type="text/javascript">
document.write("Code in a loop:<br />");
let i = 0;
while (i < 5) {
    document.write(i + " Hello, world!<br />");
    i = i + 1;
}

document.write("Code in a function:<br />");
function hello(i) {
    if (i === undefined) {
        i = 0; // i defaults to 0 if unspecified.
    }

    document.write(i + " Hello, world!<br />");
    i = i + 1;
    if (i < 5) {
        hello(i); // RECURSIVE CASE
    }
    else {
        return; // BASE CASE
    }
}
hello();
</script>

这些程序的输出如下:

Code in a loop:
0 Hello, world!
1 Hello, world!
2 Hello, world!
3 Hello, world!
4 Hello, world!
Code in a function:
0 Hello, world!
1 Hello, world!
2 Hello, world!
3 Hello, world!
4 Hello, world!

while循环有一个条件,i < 5,用于确定程序是否继续循环。同样,递归函数使用这个条件作为它的递归情况,这会导致函数调用自身并执行Hello, world!来再次显示它的代码。

对于一个更真实的例子,以下是迭代和递归函数,它们返回字符串haystack中子字符串needle的索引。如果没有找到子字符串,这些函数返回-1。这类似于 Python 的find()字符串方法和 JavaScript 的indexOf()字符串方法。这个findSubstring.py程序有一个 Python 版本:`

`Python

def findSubstringIterative(needle, haystack):
    i = 0
    while i < len(haystack):
        if haystack[i:i + len(needle)] == needle:
            return i # Needle found.
        i = i + 1
    return -1 # Needle not found.

def findSubstringRecursive(needle, haystack, i=0):
    if i >= len(haystack):
        return -1 # BASE CASE (Needle not found.)

    if haystack[i:i + len(needle)] == needle:
        return i # BASE CASE (Needle found.)
    else:
        # RECURSIVE CASE
        return findSubstringRecursive(needle, haystack, i + 1)

print(findSubstringIterative('cat', 'My cat Zophie'))
print(findSubstringRecursive('cat', 'My cat Zophie'))

这个findSubstring.html程序有等价的 JavaScript 版本:

JavaScript

<script type="text/javascript">
function findSubstringIterative(needle, haystack) {
    let i = 0;
    while (i < haystack.length) {
        if (haystack.substring(i, i + needle.length) == needle) {
            return i; // Needle found.
        }
        i = i + 1
    }
    return -1; // Needle not found.
}

function findSubstringRecursive(needle, haystack, i) {
    if (i === undefined) {
        i = 0;
    }

    if (i >= haystack.length) {
        return -1; // # BASE CASE (Needle not found.)
    }

    if (haystack.substring(i, i + needle.length) == needle) {
        return i; // # BASE CASE (Needle found.)
    } else {
        // RECURSIVE CASE
        return findSubstringRecursive(needle, haystack, i + 1);
    }
}

document.write(findSubstringIterative("cat", "My cat Zophie") + "<br />");
document.write(findSubstringRecursive("cat", "My cat Zophie") + "<br />");
</script>

这些程序调用findSubstringIterative()findSubstringRecursive(),它们返回3,因为这是在My cat Zophie中找到cat的索引:

3
3

本节中的程序表明,将任何循环转换为等价的递归函数总是可能的。虽然用递归替换循环是可能的,但我建议不要这样做。这是为了递归而递归,而且由于递归通常比迭代代码更难理解,代码的可读性会下降。

案例研究:计算指数

尽管递归不一定会产生更好的代码,但采用递归方法可以让你对编程问题有新的见解。作为一个案例研究,让我们来看看如何计算指数。

指数是通过将一个数字乘以自身来计算的。例如,指数“三的六次方”,或 3⁶,等于将 3 乘以自身六次:3 × 3 × 3 × 3 × 3 × 3 = 729。这是一个如此常见的操作,以至于 Python 有**运算符,JavaScript 有内置的Math.pow()函数来执行指数运算。我们可以用 Python 代码3 ** 6和 JavaScript 代码Math.pow(3, 6)来计算 3⁶。

但让我们编写我们自己的指数计算代码。解决方案很简单:创建一个循环,重复地将一个数字乘以自身,并返回最终的乘积。下面是一个迭代的exponentByIteration.py Python 程序:

Python

def exponentByIteration(a, n):
    result = 1
    for i in range(n):
        result *= a
    return result

print(exponentByIteration(3, 6))
print(exponentByIteration(10, 3))
print(exponentByIteration(17, 10))

这里是一个等价的 JavaScriptexponentByIteration.html程序:

JavaScript

<script type="text/javascript">
function exponentByIteration(a, n) {
    let result = 1;
    for (let i = 0; i < n; i++) {
 result *= a;
    }
    return result;
}

document.write(exponentByIteration(3, 6) + "<br />");
document.write(exponentByIteration(10, 3) + "<br />");
document.write(exponentByIteration(17, 10) + "<br />");
</script>

当你运行这些程序时,输出如下:

729
1000
2015993900449

这是一个直接的计算,我们可以很容易地用循环来编写。使用循环的缺点是,随着指数变大,函数的速度变慢:计算 3¹²需要的时间是 3⁶的两倍,而 3⁶⁰⁰需要的时间是 3⁶的一百倍。在下一节中,我们将通过递归来解决这个问题。

创建一个递归指数函数

让我们想一想,比如说,3⁶的指数的递归解决方案会是什么样的。由于乘法的结合性质,3 × 3 × 3 × 3 × 3 × 3 与(3 × 3 × 3) × (3 × 3 × 3)是相同的,这与(3 × 3 × 3)²是相同的。由于(3 × 3 × 3)与 3³是相同的,我们可以确定 3⁶与(3³)²是相同的。这是数学所称的幂规则的一个例子:(a^(m))^(n) = a^(mn)。数学还给了我们乘法规则a^(n) × a^(m) = a^(n) ^(+ m),包括a^(n) × a = a^(n) ^(+ 1)。

我们可以使用这些数学规则来制作一个exponentByRecursion()函数。如果调用exponentByRecursion(3, 6),它与exponentByRecursion(3, 3) * exponentByRecursion(3, 3)是相同的。当然,我们实际上不必进行两次exponentByRecursion(3, 3)调用:我们可以将返回值保存到一个变量中,然后将其乘以自身。

这对于偶数次幂有效,但对于奇数次幂呢?如果我们必须计算 3⁷,或 3 × 3 × 3 × 3 × 3 × 3 × 3,这与(3 × 3 × 3 × 3 × 3 × 3) × 3 相同,或(3⁶) × 3。然后我们可以进行相同的递归调用来计算 3⁶。

这些是递归情况,但基本情况是什么?从数学上讲,任何数的零次幂被定义为 1,而任何数的一次幂就是这个数本身。因此,对于任何函数调用exponentByRecursion(a, n),如果n01,我们可以简单地返回1a,因为a``0总是1,而a``1总是a

利用所有这些信息,我们可以为exponentByRecursion()函数编写代码。以下是带有 Python 代码的exponentByRecursion.py文件:

Python

def exponentByRecursion(a, n):
    if n == 1:
        # BASE CASE
        return a
    elif n % 2 == 0:
        # RECURSIVE CASE (When n is even.)
        result = exponentByRecursion(a, n // 2)
        return result * result
    elif n % 2 == 1:
        # RECURSIVE CASE (When n is odd.)
        result = exponentByRecursion(a, n // 2)
        return result * result * a

print(exponentByRecursion(3, 6))
print(exponentByRecursion(10, 3))
print(exponentByRecursion(17, 10))

以下是exponentByRecursion.html中等效的 JavaScript 代码:

JavaScript

<script type="text/javascript">
function exponentByRecursion(a, n) {
    if (n === 1) {
        // BASE CASE
        return a;
    } else if (n % 2 === 0) {
        // RECURSIVE CASE (When n is even.)
        result = exponentByRecursion(a, n / 2);
        return result * result;
    } else if (n % 2 === 1) {
        // RECURSIVE CASE (When n is odd.)
        result = exponentByRecursion(a, Math.floor(n / 2));
        return result * result * a;
    }
}

document.write(exponentByRecursion(3, 6));
document.write(exponentByRecursion(10, 3));
document.write(exponentByRecursion(17, 10));
</script>

当运行此代码时,输出与迭代版本相同:

729
1000
2015993900449

每个递归调用实际上将问题规模减半。这就是使我们的递归指数算法比迭代版本更快的原因;迭代地计算 3¹⁰⁰⁰需要 1000 次乘法操作,而递归计算只需要 23 次乘法和除法。在性能分析器下运行 Python 代码时,迭代地计算 3¹⁰⁰⁰100,000 次需要 10.633 秒,但递归计算只需要 0.406 秒。这是一个巨大的改进!

基于递归洞察力创建一个迭代指数函数

我们最初的迭代指数函数采用了一种直接的方法:循环的次数与指数幂相同。然而,这对于更大的幂并不适用。我们的递归实现迫使我们考虑如何将这个问题分解为更小的子问题。这种方法事实证明更加高效。

因为每个递归算法都有一个等效的迭代算法,我们可以基于递归算法使用的幂规则创建一个新的迭代指数函数。以下exponentWithPowerRule.py程序有这样一个函数:

Python

def exponentWithPowerRule(a, n):
    # Step 1: Determine the operations to be performed.
    opStack = []
    while n > 1:
        if n % 2 == 0:
            # n is even.
            opStack.append('square')
            n = n // 2
        elif n % 2 == 1:
            # n is odd.
            n -= 1
            opStack.append('multiply')

    # Step 2: Perform the operations in reverse order.
    result = a # Start result at `a`.
    while opStack:
        op = opStack.pop()

        if op == 'multiply':
            result *= a
        elif op == 'square':
            result *= result

    return result

print(exponentWithPowerRule(3, 6))
print(exponentWithPowerRule(10, 3))
print(exponentWithPowerRule(17, 10))

以下是exponentWithPowerRule.html中等效的 JavaScript 程序:

JavaScript

<script type="text/javascript">
function exponentWithPowerRule(a, n) {
    // Step 1: Determine the operations to be performed.
    let opStack = [];
    while (n > 1) {
 if (n % 2 === 0) {
            // n is even.
            opStack.push("square");
            n = Math.floor(n / 2);
        } else if (n % 2 === 1) {
            // n is odd.
            n -= 1;
            opStack.push("multiply");
        }
    }

    // Step 2: Perform the operations in reverse order.
    let result = a; // Start result at `a`.
    while (opStack.length > 0) {
        let op = opStack.pop();

        if (op === "multiply") {
            result = result * a;
        } else if (op === "square") {
            result = result * result;
        }
    }

    return result;
}

document.write(exponentWithPowerRule(3, 6) + "<br />");
document.write(exponentWithPowerRule(10, 3) + "<br />");
document.write(exponentWithPowerRule(17, 10) + "<br />");
</script>

我们的算法通过将“n”减半(如果它是偶数)或减 1(如果它是奇数)来不断减少“n”,直到它变为“1”。这给了我们必须执行的平方或乘以“a”的操作。完成此步骤后,我们按相反的顺序执行这些操作。通用的堆栈数据结构(与调用堆栈分开)对于颠倒这些操作的顺序非常有用,因为它是一种先进后出的数据结构。第一步将平方或乘以“a”的操作推送到“opStack”变量中的堆栈。在第二步中,它在弹出堆栈时执行这些操作。

例如,调用“exponentWithPowerRule(6, 5)”来计算 6⁵,将“a”设置为“6”,将“n”设置为“5”。函数注意到“n”是奇数。这意味着我们应该从“n”中减去“1”得到“4”,并将一个乘以“a”的操作推送到“opStack”。现在“n”是“4”(偶数),我们将其除以“2”得到“2”,并将一个平方操作推送到“opStack”。由于“n”现在是“2”并且再次是偶数,我们将其除以“2”得到“1”,并将另一个平方操作推送到“opStack”。现在“n”是“1”,我们已经完成了这一步。

要执行第二步,我们将“result”开始为“a”(即“6”)。我们弹出 opStack 堆栈以获得一个平方操作,告诉程序将“result”设置为“result * result”(即“result”2)或“36”。我们弹出 opStack 的下一个操作,又是一个平方操作,所以程序将“result”中的“36”更改为“36 * 36”,或“1296”。我们弹出 opStack 的最后一个操作,它是一个乘以“a”的操作,所以我们将“result”中的“1296”乘以“a”(即“6”)得到“7776”。opStack 上没有更多的操作,所以函数现在已经完成。当我们再次检查我们的数学时,我们发现 6⁵确实是 7,776。

opStack 中的堆栈在函数调用“exponentWithPowerRule(6, 5)”执行时看起来像图 2-4。

时间轴显示了随时间推移 opStack 堆栈的状态。在第一步中,它开始为空;然后将 Multiply 推送到堆栈,将 Square 推送到堆栈,最后将第二个 Square 操作推送到堆栈顶部。在第二步中,最近的 Square 操作从堆栈中弹出,第一个 Square 操作从堆栈中弹出,然后将 Multiply 操作从堆栈中弹出,现在堆栈为空。

图 2-4:在函数调用“exponentWithPowerRule(6, 5)”期间 opStack 中的堆栈

当您运行此代码时,输出与其他指数程序相同:

729
1000
2015993900449

使用幂规则的迭代指数函数具有递归算法的改进性能,同时不会遭受堆栈溢出的风险。如果没有递归思维的见解,我们可能不会想到这种新的、改进的迭代算法。

何时需要使用递归?

您永远不需要使用递归。没有编程问题需要递归。本章已经表明,递归没有魔力可以做迭代代码和堆栈数据结构中的循环无法做的事情。实际上,递归函数可能是您尝试实现的内容的过于复杂的解决方案。

然而,正如我们在上一节中创建的指数函数所示,递归可以为我们如何思考编程问题提供新的见解。编程问题的三个特征,当存在时,使其特别适合递归方法:

  • 它涉及树状结构。

  • 它涉及回溯。

  • 它并不是如此深度递归,以至于可能导致堆栈溢出。

树具有自相似结构:分叉点看起来类似于较小子树的根。递归通常涉及自相似性和可以分解为更小、相似子问题的问题。树的根类似于对递归函数的第一次调用,分叉点类似于递归情况,叶子类似于没有更多递归调用的基本情况。

迷宫也是一个具有树状结构并需要回溯的问题的很好例子。在迷宫中,分叉点出现在您必须选择许多路径中的一个时。如果您到达了死胡同,那么您已经遇到了基本情况。然后您必须回溯到先前的分叉点,选择一个不同的路径继续前进。

图 2-5 显示了一个迷宫的路径在视觉上形变成生物树的样子。尽管迷宫路径和树形路径在视觉上有所不同,但它们的分叉点在数学上是相关的。从数学上讲,这些图是等价的。

三幅图像:第一幅是一个经典的迷宫,有分叉路径,第二幅显示了迷宫中每条路径的线条,并在每个分叉处有一个圆圈,第三幅是这条线和圆圈的图案扭曲成树枝的样子。

图 2-5:一个迷宫(左)以及其内部路径(中)形变成生物树的形状(右)

许多编程问题都具有这种树状结构。例如,文件系统具有树状结构;子文件夹看起来像较小文件系统的根文件夹。图 2-6 将文件系统与树进行了比较。

两幅图像,一幅是一棵树,另一幅是一个文件系统,箭头指出了每个等价的叶子、分叉点和根。

图 2-6:文件系统类似于树结构。

在文件夹中搜索特定文件名是一个递归问题:您搜索文件夹,然后递归搜索文件夹的子文件夹。没有子文件夹的文件夹是导致递归搜索停止的基本情况。如果您的递归算法找不到它正在寻找的文件名,它会回溯到先前的父文件夹,并从那里继续搜索。

第三点是实用性问题。如果您的树结构具有如此多层的分支,以至于递归函数在到达叶子之前就会导致堆栈溢出,那么递归就不是一个合适的解决方案。

另一方面,递归是创建编程语言编译器的最佳方法。编译器设计是一个庞大的课题,超出了本书的范围。但是编程语言有一组语法规则,可以将源代码分解成类似于语法规则可以将英语句子分解成树状图的树状结构。递归是应用于编译器的理想技术。

我们将在本书中识别许多递归算法,它们通常具有树状结构或回溯特性,非常适合递归。

提出递归算法

希望本章让您对递归函数与您可能更熟悉的迭代算法有了一个坚实的概念。本书的其余部分将深入探讨各种递归算法的细节。但是,您应该如何编写自己的递归函数呢?

第一步总是要确定递归情况和基本情况。您可以采用自顶向下的方法,将问题分解为与原始问题相似但更小的子问题;这就是您的递归情况。然后考虑子问题何时足够小以获得一个微不足道的答案;这就是您的基本情况。您的递归函数可能有多个递归情况或基本情况,但所有递归函数都至少有一个递归情况和至少一个基本情况。

递归斐波那契算法就是一个例子。斐波那契数是前两个斐波那契数的和。我们可以将查找斐波那契数的问题分解为查找两个较小斐波那契数的子问题。我们知道前两个斐波那契数都是 1,所以一旦子问题足够小,就可以得到基本情况的答案。

有时候,先采用自下而上的方法考虑基本情况,然后再看如何从中构建和解决更大的问题。递归阶乘问题就是一个例子。1 的阶乘是 1。这形成了基本情况。下一个阶乘是 2!,你可以通过将 1!乘以 2 来创建它。之后的阶乘,3!,是通过将 2!乘以 3 来创建的,依此类推。通过这种一般模式,我们可以找出我们算法的递归情况会是什么。

总结

在这一章中,我们涵盖了计算阶乘和斐波那契数列,这两个经典的递归编程问题。本章介绍了这些算法的迭代和递归实现。尽管它们是递归的经典示例,但它们的递归算法存在严重的缺陷。递归阶乘函数可能会导致堆栈溢出,而递归斐波那契函数执行了太多的冗余计算,以至于在现实世界中效率太低。

我们探讨了如何从迭代算法创建递归算法,以及如何从递归算法创建迭代算法。迭代算法使用循环,任何递归算法都可以通过使用循环和堆栈数据结构来进行迭代执行。递归通常是一个过于复杂的解决方案,但涉及树状结构和回溯的编程问题特别适合递归实现。

编写递归函数是一种随着练习和经验而提高的技能。本书的其余部分涵盖了几个众所周知的递归示例,并探讨了它们的优势和局限性。

进一步阅读

您可以在 Computerphile YouTube 频道的视频“Programming Loops vs. Recursion”中找到有关比较迭代和递归的更多信息,网址为youtu.be/HXNhEYqFo0o。如果您想比较迭代和递归函数的性能,您需要学习如何使用分析器。Python 分析器在我的书Beyond the Basic Stuff with Python(No Starch Press, 2020)的第十三章中有解释,可以在inventwithpython.com/beyond/chapter13.html上阅读。官方的 Python 文档也涵盖了分析器,网址为docs.python.org/3/library/profile.html。Mozilla 网站上解释了 JavaScript 的 Firefox 分析器,网址为developer.mozilla.org/en-US/docs/Tools/Performance。其他浏览器也有类似于 Firefox 的分析器。

练习问题

通过回答以下问题来测试您的理解:

  1. 4 的阶乘是多少?

  2. 你如何使用(n – 1)的阶乘来计算n的阶乘?

  3. 递归阶乘函数的关键弱点是什么?

  4. 斐波那契数列的前五个数字是什么?

  5. 为了得到第n个斐波那契数,你需要加上哪两个数字?

  6. 递归斐波那契函数的关键弱点是什么?

  7. 迭代算法总是使用什么?

  8. 总是可以将迭代算法转换为递归算法吗?

  9. 总是可以将递归算法转换为迭代算法吗?

  10. 任何递归算法都可以通过使用哪两种方法来进行迭代执行?

  11. 适合递归解决方案的编程问题具有哪三个特征?

  12. 何时需要递归来解决编程问题?

练习项目

练习时,为以下每个任务编写一个函数:

  1. 迭代计算从1n的整数序列的和。这类似于factorial()函数,只是它执行加法而不是乘法。例如,sumSeries(1)返回1sumSeries(2)返回3(即1 + 2),sumSeries(3)返回6(即1 + 2 + 3),依此类推。这个函数应该使用循环而不是递归。可以参考本章的factorialByIteration.py程序。

  2. 编写sumSeries()的递归形式。这个函数应该使用递归函数调用而不是循环。可以参考本章的factorialByRecursion.py程序。

  3. 在一个名为sumPowersOf2()的函数中迭代计算前n个 2 的幂的和。2 的幂是 2、4、8、16、32 等等。在 Python 中,这些是通过2 ** 12 ** 22 ** 32 ** 42 ** 5等等计算的。在 JavaScript 中,这些是通过Math.pow(2, 1)Math.pow(2, 2)等等计算的。例如,sumPowersOf2(1)返回2sumPowersOf2(2)返回6(即2 + 4),sumPowersOf2(3)返回14(即2 + 4 + 8),依此类推。

  4. 编写sumPowersOf2()的递归形式。这个函数应该使用递归函数调用而不是循环。