七、记忆化和动态规划
原文:Chapter 7 - Memoization and Dynamic Programming
译者:飞龙
在本章中,我们将探讨记忆化,这是一种使递归算法运行更快的技术。我们将讨论记忆化是什么,如何应用它,以及它在函数式编程和动态规划领域的用处。我们将使用第二章中的斐波那契算法来演示我们编写的代码和 Python 标准库中可以找到的记忆化功能。我们还将了解为什么记忆化不能应用于每个递归函数。
记忆化
记忆化是记住函数对其提供的特定参数的返回值的技术。例如,如果有人让我找到 720 的平方根,即乘以自身的结果为 720 的数字,我将不得不坐下来用铅笔和纸几分钟(或在 JavaScript 中调用Math.sqrt(720)或在 Python 中调用math.sqrt(720))来算出答案:26.832815729997478。如果他们几秒钟后再问我,我就不必重复计算,因为我已经有了答案。通过缓存先前计算的结果,记忆化通过增加内存使用量来节省执行时间。
将记忆化与记忆混淆是许多人现代的错误。 (随时可以做一个备忘录来提醒自己它们的区别。)
自顶向下的动态规划
记忆化是动态规划中的一种常见策略,这是一种涉及将大问题分解为重叠子问题的计算机编程技术。这听起来很像我们已经看到的普通递归。关键区别在于动态规划使用具有重复递归情况的递归;这些是重叠子问题。
例如,让我们考虑第二章中的递归斐波那契算法。进行递归fibonacci(6)函数调用将依次调用fibonacci(5)和fibonacci(4)。接下来,fibonacci(5)将调用fibonacci(4)和fibonacci(3)。斐波那契算法的子问题重叠,因为fibonacci(4)调用以及许多其他调用都是重复的。这使得生成斐波那契数成为一个动态规划问题。
这里存在一个低效:多次执行相同的计算是不必要的,因为fibonacci(4)将始终返回相同的值,即整数3。相反,我们的程序可以记住,如果递归函数的参数是4,函数应立即返回3。
图 7-1 显示了所有递归调用的树状图,包括记忆化可以优化的冗余函数调用。与此同时,快速排序和归并排序是递归分而治之算法,但它们的子问题不重叠;它们是独特的。动态规划技术不适用于这些排序算法。
图 7-1:从fibonacci(6)开始进行的递归函数调用的树状图。冗余的函数调用以灰色显示。
动态规划的一种方法是对递归函数进行记忆化,以便将先前的计算记住以供将来的函数调用使用。如果我们可以重用先前的返回值,重叠子问题就变得微不足道了。
使用记忆化的递归称为自顶向下动态规划。这个过程将一个大问题分解成更小的重叠子问题。相反的技术,自底向上动态规划,从较小的子问题(通常与基本情况有关)开始,并“构建”到原始大问题的解决方案。从第一个和第二个斐波那契数作为基本情况开始的迭代斐波那契算法就是自底向上动态规划的一个例子。自底向上方法不使用递归函数。
请注意,不存在自顶向下递归或自底向上递归。这些是常用但不正确的术语。所有递归已经是自顶向下的,因此自顶向下递归是多余的。而且没有底向上方法使用递归,因此没有自底向上递归这种东西。
函数式编程中的记忆化
并非所有函数都可以进行记忆化。为了理解原因,我们必须讨论函数式编程,这是一种强调编写不修改全局变量或任何外部状态(如硬盘上的文件、互联网连接或数据库内容)的函数的编程范式。一些编程语言,如 Erlang、Lisp 和 Haskell,都是围绕函数式编程概念进行设计的。但你可以将函数式编程特性应用到几乎任何编程语言,包括 Python 和 JavaScript。
函数式编程包括确定性和非确定性函数、副作用和纯函数的概念。介绍中提到的sqrt()函数是一个确定性函数,因为当传入相同的参数时,它总是返回相同的值。然而,Python 的random.randint()函数返回一个随机整数,是非确定性的,因为即使传入相同的参数,它也可能返回不同的值。time.time()函数返回当前时间,也是非确定性的,因为时间不断向前推移。
副作用是函数对其代码和局部变量之外的任何东西所做的任何更改。为了说明这一点,让我们创建一个实现 Python 减法运算符(-)的subtract()函数:
Python
>>> def subtract(number1, number2):
... return number1 - number2
...
>>> subtract(123, 987)
-864
这个subtract()函数没有副作用;调用这个函数不会影响程序代码外的任何东西。从程序或计算机的状态来看,无法判断subtract()函数之前是否被调用过一次、两次或一百万次。函数可能会修改函数内部的局部变量,但这些更改是局限于函数内部的,并与程序的其余部分隔离开来。
现在考虑一个addToTotal()函数,它将数字参数添加到名为TOTAL的全局变量中:
Python
>>> TOTAL = 0
>>> def addToTotal(amount):
... global TOTAL
... TOTAL += amount
... return TOTAL
...
>>> addToTotal(10)
10
>>> addToTotal(10)
20
>>> TOTAL
20
addToTotal()函数确实有副作用,因为它修改了函数外存在的元素:TOTAL全局变量。
副作用不仅仅是对全局变量的简单更改。它还包括更新或删除文件、在屏幕上打印文本、打开数据库连接、对服务器进行身份验证,或者对函数外的数据进行任何其他操作。函数调用在返回后留下的任何痕迹都是副作用。
如果一个函数是确定性的,没有副作用,那么它被称为纯函数。只有纯函数应该被记忆化。在接下来的部分中,当我们对递归斐波那契函数和doNotMemoize程序的不纯函数进行记忆化时,你会明白为什么。
记忆化递归斐波那契算法
让我们对第二章的递归斐波那契函数进行记忆化。请记住,这个函数非常低效:在我的计算机上,递归fibonacci(40)调用需要 57.8 秒来计算。与此同时,fibonacci(40)的迭代版本实际上太快了,以至于我的代码分析器无法测量:0.000 秒。
记忆化可以极大地加速函数的递归版本。例如,图 7-2 显示了原始和记忆化fibonacci()函数对前 20 个斐波那契数的函数调用次数。原始的、非记忆化的函数正在进行大量不必要的计算。
原始的fibonacci()函数的函数调用次数急剧增加(顶部),而记忆化的fibonacci()函数的函数调用次数增长缓慢(底部)。
图 7-2:原始的fibonacci()函数的函数调用次数急剧增加(顶部),而记忆化的fibonacci()函数的函数调用次数增长缓慢(底部)。
Python 版本的记忆化斐波那契算法在fibonacciByRecursionMemoized.py中。第二章原始fibonacciByRecursion.html程序的添加已用粗体标记:
fibonacciCache = {} # ❶ # Create the global cache.
def fibonacci(nthNumber, indent=0):
global fibonacciCache
indentation = '.' * indent
print(indentation + 'fibonacci(%s) called.' % (nthNumber))
if nthNumber in fibonacciCache:
# If the value was already cached, return it.
print(indentation + 'Returning memoized result: %s' % (fibonacciCache[nthNumber]))
return fibonacciCache[nthNumber] # ❷
if nthNumber == 1 or nthNumber == 2:
# BASE CASE
print(indentation + 'Base case fibonacci(%s) returning 1.' % (nthNumber))
fibonacciCache[nthNumber] = 1 # ❸ # Update the cache.
return 1
else:
# RECURSIVE CASE
print(indentation + 'Calling fibonacci(%s) (nthNumber - 1).' % (nthNumber - 1))
result = fibonacci(nthNumber - 1, indent + 1)
print(indentation + 'Calling fibonacci(%s) (nthNumber - 2).' % (nthNumber - 2))
result = result + fibonacci(nthNumber - 2, indent + 1)
print('Call to fibonacci(%s) returning %s.' % (nthNumber, result))
fibonacciCache[nthNumber] = result # ❹ # Update the cache.
return result
print(fibonacci(10))
print(fibonacci(10)) # ❺
JavaScript 版本的记忆化斐波那契算法在fibonacciByRecursionMemoized.html中。第二章原始fibonacciByRecursion.html程序的添加已用粗体标记:
JavaScript
<script type="text/javascript">
let fibonacciCache = {}; // Create the global cache. // ❶
function fibonacci(nthNumber, indent) {
if (indent === undefined) {
indent = 0;
}
let indentation = '.'.repeat(indent);
document.write(indentation + "fibonacci(" + nthNumber + ") called.
<br />");
if (nthNumber in fibonacciCache) {
// If the value was already cached, return it.
document.write(indentation +
"Returning memoized result: " + fibonacciCache[nthNumber] + "<br />");
return fibonacciCache[nthNumber]; // ❷
}
if (nthNumber === 1 || nthNumber === 2) {
// BASE CASE
document.write(indentation +
"Base case fibonacci(" + nthNumber + ") returning 1.<br />");
fibonacciCache[nthNumber] = 1; // Update the cache. // ❸
return 1;
} else {
// RECURSIVE CASE
document.write(indentation +
"Calling fibonacci(" + (nthNumber - 1) + ") (nthNumber - 1).<br />");
let result = fibonacci(nthNumber - 1, indent + 1);
document.write(indentation +
"Calling fibonacci(" + (nthNumber - 2) + ") (nthNumber - 2).<br />");
result = result + fibonacci(nthNumber - 2, indent + 1);
document.write(indentation + "Returning " + result + ".<br />");
fibonacciCache[nthNumber] = result; // Update the cache. // ❹
return result;
}
}
document.write("<pre>");
document.write(fibonacci(10) + "<br />");
document.write(fibonacci(10) + "<br />"); // ❺
document.write("</pre>");
</script>
如果你将这个程序的输出与第二章中的原始递归斐波那契程序进行比较,你会发现它要短得多。这反映了为达到相同结果所需的计算量的大幅减少:
fibonacci(10) called.
Calling fibonacci(9) (nthNumber - 1).
.fibonacci(9) called.
.Calling fibonacci(8) (nthNumber - 1).
..fibonacci(8) called.
..Calling fibonacci(7) (nthNumber - 1).
# --snip--
.......Calling fibonacci(2) (nthNumber - 1).
........fibonacci(2) called.
........Base case fibonacci(2) returning 1.
.......Calling fibonacci(1) (nthNumber - 2).
........fibonacci(1) called.
........Base case fibonacci(1) returning 1.
Call to fibonacci(3) returning 2.
......Calling fibonacci(2) (nthNumber - 2).
.......fibonacci(2) called.
.......Returning memoized result: 1
# --snip--
Calling fibonacci(8) (nthNumber - 2).
.fibonacci(8) called.
.Returning memoized result: 21
Call to fibonacci(10) returning 55.
55
fibonacci(10) called.
Returning memoized result: 55
55
为了对这个函数进行记忆,我们将创建一个全局变量命名为fibonacciCache的字典(在 Python 中)或对象(在 JavaScript 中)❶。它的键是传递给nthNumber参数的参数,它的值是fibonacci()函数返回的整数,给定该参数。每个函数调用首先检查它的nthNumber参数是否已经在缓存中。如果是,缓存的返回值就会被返回❷。否则,函数会正常运行(尽管它也会在函数返回之前将结果添加到缓存中❸ ❹)。
记忆函数实际上扩展了斐波那契算法中的基本情况数量。原始的基本情况只适用于第一个和第二个斐波那契数:它们立即返回1。但是,每当递归情况返回一个整数时,它就成为所有未来fibonacci()调用的基本情况。结果已经在fibonacciCache中,可以立即返回。如果您之前已经调用过fibonacci(99),它就像fibonacci(1)和fibonacci(2)一样成为一个基本情况。换句话说,记忆通过增加基本情况的数量来改善具有重叠子问题的递归函数的性能。请注意,当我们的程序第二次尝试找到第 10 个斐波那契数时❺,它立即返回了记忆的结果:55。
请记住,虽然记忆可以减少递归算法所做的冗余函数调用的数量,但它不一定会减少调用堆栈上的帧对象的增长。记忆不会防止堆栈溢出错误。再次强调,您可能最好放弃递归算法,选择更直接的迭代算法。
Python 的 functools 模块
通过添加一个全局变量和管理它的代码来为每个想要记忆的函数实现缓存可能会相当麻烦。Python 的标准库有一个functools模块,其中有一个名为@lru_cache()的函数装饰器,它可以自动记忆它装饰的函数。在 Python 语法中,这意味着在函数的def语句之前添加@lru_cache()。
缓存可以设置内存大小限制。装饰器名称中的lru代表最近最少使用的缓存替换策略,这意味着当缓存达到限制时,最近最少使用的条目将被新条目替换。LRU 算法简单快速,尽管其他缓存替换策略可用于不同的软件需求。
fibonacciFunctools.py程序演示了@lru_cache()装饰器的使用。第二章中原始的fibonacciByRecursion.py程序的添加已经用粗体标记出来:
Python
import functools
@functools.lru_cache()
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) (nthNumber - 1).' % (nthNumber - 1))
result = fibonacci(nthNumber - 1)
print('Calling fibonacci(%s) (nthNumber - 2).' % (nthNumber - 2))
result = result + fibonacci(nthNumber - 2)
print('Call to fibonacci(%s) returning %s.' % (nthNumber, result))
return result
print(fibonacci(99))
与在fibonacciByRecursionMemoized.py中实现自己的缓存所需的添加相比,使用 Python 的@lru_cache()装饰器要简单得多。通常,使用递归算法计算fibonacci(99)需要几个世纪。通过记忆,我们的程序在几毫秒内显示了218922995834555169026的结果。
记忆是一种对具有重叠子问题的递归函数很有用的技术,但它可以应用于任何纯函数,以加快运行时,代价是计算机内存。
当您记忆不纯函数时会发生什么?
您不应该将@lru_cache添加到不纯的函数中,这意味着它们是不确定的或具有副作用。记忆通过跳过函数中的代码并返回先前缓存的返回值来节省时间。这对于纯函数来说是可以的,但对于不纯函数可能会导致各种错误。
在非确定性函数中,例如返回当前时间的函数,记忆化会导致函数返回不正确的结果。对于具有副作用的函数,例如向屏幕打印文本的函数,记忆化会导致函数跳过预期的副作用。doNotMemoize.py程序演示了当@lru_cache函数装饰器(在前一节中描述)记忆化这些不纯函数时会发生什么:
Python
import functools, time, datetime
@functools.lru_cache()
def getCurrentTime():
# This nondeterministic function returns different values each time
# it's called.
return datetime.datetime.now()
@functools.lru_cache()
def printMessage():
# This function displays a message on the screen as a side effect.
print('Hello, world!')
print('Getting the current time twice:')
print(getCurrentTime())
print('Waiting two seconds...')
time.sleep(2)
print(getCurrentTime())
print()
print('Displaying a message twice:')
printMessage()
printMessage()
当您运行此程序时,输出如下:
Getting the current time twice:
2022-07-30 16:25:52.136999
Waiting two seconds...
2022-07-30 16:25:52.136999
Displaying a message twice:
Hello, world!
请注意,尽管第二次调用getCurrentTime()比第一次调用晚了两秒,但返回的结果相同。而对printMessage()的两次调用中,只有第一次调用会在屏幕上显示Hello, world!消息。
这些错误很微妙,因为它们不会导致明显的崩溃,而是导致函数的行为不正确。无论如何记忆化函数,一定要彻底测试它们。
总结
记忆化(不是记忆)是一种优化技术,可以通过记住相同计算的先前结果来加速具有重叠子问题的递归算法。记忆化是动态规划领域的常见技术。通过交换计算机内存使用量以改善运行时间,记忆化使一些原本难以处理的递归函数成为可能。
然而,记忆化不能防止堆栈溢出错误。请记住,记忆化并不是使用简单迭代解决方案的替代品。仅仅为了使用递归而使用递归的代码并不会自动比非递归代码更加优雅。
记忆化函数必须是纯函数——即它们必须是确定性的(每次给定相同的参数返回相同的值)并且不能具有副作用(影响函数之外的计算机或程序的任何内容)。纯函数通常在函数式编程中使用,函数式编程大量使用递归。
记忆化是通过为每个要记忆化的函数创建一个称为缓存的数据结构来实现的。您可以自己编写此代码,但 Python 具有内置的@functools.lru_cache()装饰器,可以自动记忆化它装饰的函数。
进一步阅读
动态规划算法不仅仅是简单地记忆化函数。这些算法通常在编程面试和编程竞赛中使用。Coursera 提供了一个免费的“动态规划,贪婪算法”课程www.coursera.org/learn/dynamic-programming-greedy-algorithms。freeCodeCamp 组织还在www.freecodecamp.org/news/learn-dynamic-programing-to-solve-coding-challenges上推出了一系列关于动态规划的课程。
如果您想了解有关 LRU 缓存和其他与缓存相关的函数的更多信息,请参阅官方 Python 文档中的functools模块docs.python.org/3/library/functools.html。有关其他类型的缓存替换算法的更多信息,请参阅维基百科en.wikipedia.org/wiki/Cache_replacement_policies。
练习问题
通过回答以下问题来测试您的理解:
-
什么是记忆化?
-
动态规划问题与常规递归问题有何不同?
-
函数式编程强调什么?
-
一个函数必须具备哪两个特征才能成为纯函数?
-
返回当前日期和时间的函数是确定性函数吗?
-
记忆化如何改善具有重叠子问题的递归函数的性能?
-
将
@lru_cache()函数装饰器添加到归并排序函数中会提高其性能吗?为什么? -
在函数的局部变量中改变值是副作用的一个例子吗?
-
记忆化能防止堆栈溢出吗?
八、尾调用优化
原文:Chapter 8 - Tail Call Optimization
译者:飞龙
在上一章中,我们介绍了使用记忆化来优化递归函数。本章探讨了一种称为尾调用优化的技术,这是编译器或解释器提供的一种功能,用于避免堆栈溢出。尾调用优化也称为尾调用消除或尾递归消除。
本章旨在解释尾调用优化,而不是为其背书。我甚至会建议永远不要使用尾调用优化。正如你将看到的,重新排列函数的代码以使用尾调用优化通常会使其变得难以理解。你应该考虑尾调用优化更像是一种黑客或变通方法,用于使递归在你本不应该使用递归算法的情况下工作。记住,一个复杂的递归解决方案并不自动成为一个优雅的解决方案;简单的编码问题应该用简单的非递归方法来解决。
许多流行编程语言的实现甚至不提供尾调用优化作为一项功能。这些包括 Python、JavaScript 和 Java 的解释器和编译器。然而,尾调用优化是一种你应该熟悉的技术,以防你在你的代码项目中遇到它。
尾递归和尾调用优化的工作原理
要利用尾调用优化,一个函数必须使用尾递归。在尾递归中,递归函数调用是递归函数的最后一个操作。在代码中,这看起来像是一个return语句返回递归调用的结果。
要看到这个过程,回想一下第二章中的factorialByRecursion.py和factorialByRecursion.html程序。这些程序计算了一个整数的阶乘;例如,5!等于 5 × 4 × 3 × 2 × 1,即 120。这些计算可以通过递归来进行,因为factorial(n)等同于n * factorial(n - 1),其中n == 1是基本情况,返回1。
让我们重写这些程序以使用尾递归。下面的factorialTailCall.py程序有一个使用尾递归的factorial()函数:
Python
def factorial(number, accum=1):
if number == 1:
# BASE CASE
return accum
else:
# RECURSIVE CASE
return factorial(number - 1, accum * number)
print(factorial(5))
factorialTailCall.html程序有等效的 JavaScript 代码:
JavaScript
<script type="text/javascript">
function factorial(number, accum=1) {
if (number === 1) {
// BASE CASE
return accum;
} else {
// RECURSIVE CASE
return factorial(number - 1, accum * number);
}
}
document.write(factorial(5));
</script>
请注意,factorial()函数的递归情况以return语句结束,返回对factorial()的递归调用的结果。为了允许解释器或编译器实现尾调用优化,递归函数所做的最后一个操作必须是返回递归调用的结果。在进行递归调用和return语句之间不能发生任何指令。基本情况返回accum参数。这是累加器,在下一节中解释。
要理解尾调用优化的工作原理,记住第一章中函数调用时发生了什么。首先,创建一个帧对象并将其存储在调用堆栈上。如果函数调用另一个函数,将创建另一个帧对象并将其放在调用堆栈的第一个帧对象的顶部。当函数返回时,你的程序会自动从调用堆栈的顶部删除帧对象。
堆栈溢出发生在太多的函数调用没有返回的情况下,导致帧对象的数量超过调用堆栈的容量。对于 Python 来说,这个容量是 1,000 个函数调用,对于 JavaScript 程序来说大约是 10,000 个。虽然这些数量对于典型程序来说已经足够了,但递归算法可能会超过这个限制,导致堆栈溢出,从而使你的程序崩溃。
回想一下第二章,帧对象存储了函数调用中的局部变量,以及函数完成时返回的指令地址。然而,如果函数递归情况中的最后一个动作是返回递归函数调用的结果,就没有必要保留局部变量。函数在递归调用之后不涉及任何局部变量,因此当前帧对象可以立即被删除。下一个帧对象的返回地址信息可以与被删除的旧帧对象的返回地址相同。
由于当前帧对象被删除而不是保留在调用堆栈上,调用堆栈永远不会增长并且永远不会导致堆栈溢出!
回想一下第一章,所有递归算法都可以使用堆栈和循环来实现。由于尾调用优化消除了对调用堆栈的需求,我们实际上是在使用递归来模拟循环的迭代代码。然而,在本书的前面,我曾经说过适合递归解决方案的问题涉及类似树的数据结构和回溯。没有调用堆栈,没有尾递归函数可能做任何回溯工作。在我看来,每个可以使用尾递归实现的算法都更容易和更可读地使用循环来实现。仅仅因为递归而使用递归并不会自动更加优雅。
尾递归中的累加器
尾递归的缺点在于它要求重新排列递归函数,使得最后一个动作是返回递归调用的返回值。这会使我们的递归代码变得更加难以阅读。事实上,本章的factorialTailCall.py和factorialTailCall.html程序中的factorial()函数比第二章的factorialByRecursion.py和factorialByRecursion.html程序中的版本更难理解一些。
在我们的尾调用factorial()函数中,一个名为accum的新参数跟随着递归函数调用产生的计算结果。这被称为累加器参数,它跟踪了一个计算的部分结果,否则这个结果将会被存储在一个局部变量中。并不是所有的尾递归函数都使用累加器,但它们充当了尾递归无法在最后的递归调用之后使用局部变量的一种变通方法。请注意,在factorialByRecursion.py的factorial()函数中,递归情况是return number * factorial(number - 1)。乘法发生在factorial(number - 1)递归调用之后。accum累加器取代了number局部变量的位置。
还要注意,factorial()的基本情况不再返回1,而是返回accum累加器。当factorial()被调用时,number == 1并且达到基本情况时,accum存储了要返回的最终结果。调整代码以使用尾调用优化通常涉及更改基本情况以返回累加器的值。
你可以把factorial(5)函数调用看作是转换成以下的return,如图 8-1 所示。
图 8-1:factorial(5)转换为整数 120 的过程
重新排列递归调用作为函数中的最后一个动作,并添加累加器,会使你的代码变得比典型的递归代码更难理解。但这并不是尾递归的唯一缺点,我们将在下一节中看到。
尾递归的限制
尾递归函数需要重新排列它们的代码,使其适合编译器或解释器的尾调用优化功能。然而,并非所有编译器和解释器都提供尾调用优化作为一项功能。值得注意的是,CPython(从python.org下载的 Python 解释器)不实现尾调用优化。即使你将递归函数写成递归调用作为最后一个动作,它仍会在足够多的函数调用后导致堆栈溢出。
此外,CPython 可能永远不会将尾调用优化作为一项功能。Python 编程语言的创始人 Guido van Rossum 解释说,尾调用优化可能会使程序更难调试。尾调用优化会从调用堆栈中移除帧对象,从而移除帧对象可以提供的调试信息。他还指出,一旦实现了尾调用优化,Python 程序员将开始编写依赖于该功能的代码,他们的代码将无法在不实现尾调用优化的非 CPython 解释器上运行。
最后,我同意,van Rossum 不同意递归是日常编程的基本重要部分的观点。计算机科学家和数学家倾向于把递归放在一个高位。但尾调用优化只是一个解决方案,使一些递归算法实际可行,而不是简单地因堆栈溢出而崩溃。
虽然 CPython 不支持尾调用优化,但这并不意味着实现 Python 语言的其他编译器或解释器不能具有尾调用优化。除非尾调用优化明确地成为编程语言规范的一部分,否则它不是编程语言的特性,而是编程语言的个别编译器或解释器的特性。
缺乏尾调用优化并不是 Python 独有的。自第 8 版以来,Java 编译器也不支持尾调用优化。尾调用优化是 JavaScript 的 ECMAScript 6 版本的一部分;然而,截至 2022 年,只有 Safari 浏览器的 JavaScript 实现实际上支持它。确定你的编程语言的编译器或解释器是否实现了这一功能的一种方法是编写一个尾递归阶乘函数,尝试计算 100,000 的阶乘。如果程序崩溃,那么尾调用优化没有被实现。
就个人而言,我认为尾递归技术不应该被使用。正如第二章所述,任何递归算法都可以用循环和堆栈来实现。尾调用优化通过有效地移除调用堆栈来防止堆栈溢出。因此,所有尾递归算法都可以仅用循环来实现。由于循环的代码比递归函数简单得多,应该在任何可以使用尾调用优化的地方使用循环。
此外,即使实现了尾调用优化,也可能存在潜在问题。由于尾递归仅在函数的最后一个动作是返回递归调用的返回值时才可能发生,因此对于需要两个或更多递归调用的算法来说,尾递归是不可能的。例如,考虑斐波那契数列算法调用fibonacci(n - 1)和fibonacci(n - 2)。尽管后者的递归调用可以进行尾调用优化,但对于足够大的参数,第一个递归调用将导致堆栈溢出。
尾递归案例研究
让我们来检查一些在本书中早些时候展示的递归函数,看看它们是否适合尾递归。请记住,由于 Python 和 JavaScript 实际上并未实现尾调用优化,这些尾递归函数仍然会导致堆栈溢出错误。这些案例研究仅用于演示目的。
尾递归反转字符串
第一个例子是我们在第三章中制作的反转字符串的程序。这个尾递归函数的 Python 代码在reverseStringTailCall.py中:
Python
def rev(theString, accum=''): # ❶
if len(theString) == 0:
# BASE CASE
return accum # ❷
else:
# RECURSIVE CASE
head = theString[0]
tail = theString[1:]
return rev(tail, head + accum) # ❸
text = 'abcdef'
print('The reverse of ' + text + ' is ' + rev(text))
等效的 JavaScript 在reverseStringTailCall.html中:
JavaScript
<script type="text/javascript">
function rev(theString, accum='') { // ❶
if (theString.length === 0) {
// BASE CASE
return accum; // ❷
} else {
// RECURSIVE CASE
let head = theString[0];
let tail = theString.substring(1, theString.length);
return rev(tail, head + accum); // ❸
}
}
let text = "abcdef";
document.write("The reverse of " + text + " is " + rev(text) + "<br />");
</script>
将reverseString.py和reverseString.html中的原始递归函数转换为涉及添加累加器参数。如果没有为它传递参数,则默认情况下将累加器命名为accum,设置为空字符串❶。我们还将基本情况从return ''更改为return accum❷,将递归情况从return rev(tail) + head(在递归调用返回后执行字符串连接)更改为return rev(tail, head + accum)❸。您可以将rev('abcdef')函数调用视为转换为以下return,如图 8-2 所示。
通过有效地使用累加器作为跨函数调用共享的本地变量,我们可以使rev()函数成为尾递归。
图 8-2:rev('abcdef')对字符串fedcba进行的转换过程
尾递归查找子字符串
一些递归函数自然地使用尾递归模式。如果您查看第二章中findSubstring.py和findSubstring.html程序中的findSubstringRecursive()函数,您会注意到递归情况的最后操作是返回递归函数调用的值。不需要进行任何调整使此函数成为尾递归。
尾递归指数
exponentByRecursion.py和exponentByRecursion.html程序,也来自第二章,不是尾递归的好候选。这些程序有两个递归情况,当n参数为偶数或奇数时。这没问题:只要所有递归情况都将递归函数调用的返回值作为它们的最后操作,函数就可以使用尾调用优化。
但是,请注意n 为偶数的 Python 代码递归情况:
Python
# --snip--
elif n % 2 == 0:
# RECURSIVE CASE (when n is even)
result = exponentByRecursion(a, n / 2)
return result * result
# --snip--
并注意等效的 JavaScript 递归情况:
JavaScript
# --snip--
} else if (n % 2 === 0) {
// RECURSIVE CASE (when n is even)
result = exponentByRecursion(a, n / 2);
return result * result;
# --snip--
这个递归情况没有递归调用作为它的最后操作。我们可以摆脱result本地变量,而是两次调用递归函数。这将减少递归情况到以下内容:
# --snip--
return exponentByRecursion(a, n / 2) * exponentByRecursion(a, n / 2)
# --snip--
但是,现在我们有两个对exponentByRecursion()的递归调用。这不仅使算法执行的计算量翻倍,而且函数执行的最后操作是将两个返回值相乘。这与递归斐波那契算法的问题相同:如果递归函数有多个递归调用,那么至少有一个递归调用不能是函数的最后操作。
尾递归奇偶数
要确定一个整数是奇数还是偶数,可以使用%模数运算符。表达式number%2 == 0如果number是偶数,则为True,如果number是奇数,则为False。但是,如果您更喜欢过度设计更“优雅”的递归算法,可以在isOdd.py中实现以下isOdd()函数(isOdd.py的其余部分稍后在本节中介绍):
Python
def isOdd(number):
if number == 0:
# BASE CASE
return False
else:
# RECURSIVE CASE
return not isOdd(number - 1)
print(isOdd(42))
print(isOdd(99))
# --snip--
等效的 JavaScript 在isOdd.html中:
JavaScript
<script type="text/javascript">
function isOdd(number) {
if (number === 0) {
// BASE CASE
return false;
} else {
// RECURSIVE CASE
return !isOdd(number - 1);
}
}
document.write(isOdd(42) + "<br />");
document.write(isOdd(99) + "<br />");
# --snip--
我们有两个isOdd()的基本情况。当number参数为0时,函数返回False以表示偶数。为简单起见,我们的isOdd()实现仅适用于正整数。递归情况返回isOdd(number - 1)的相反值。
您可以通过一个例子看到这是如何工作的:当调用isOdd(42)时,函数无法确定42是偶数还是奇数,但知道答案与41是奇数还是偶数相反。函数将返回not isOdd(41)。这个函数调用,反过来返回isOdd(40)的相反布尔值,依此类推,直到isOdd(0)返回False。递归函数调用的数量决定了在最终返回值返回之前作用于返回值的not运算符的数量。
然而,这个递归函数对于大数参数会导致堆栈溢出。调用isOdd(100000)会导致 100,001 个函数调用而没有返回,这远远超出了任何调用堆栈的容量。我们可以重新排列函数中的代码,使递归情况的最后一个操作是返回递归函数调用的结果,使函数成为尾递归。我们在isOdd.py中的isOddTailCall()中这样做。以下是isOdd.py程序的其余部分:
Python
# --snip--
def isOddTailCall(number, inversionAccum=False):
if number == 0:
# BASE CASE
return inversionAccum
else:
# RECURSIVE CASE
return isOddTailCall(number - 1, not inversionAccum)
print(isOddTailCall(42))
print(isOddTailCall(99))
JavaScript 等效代码在isOdd.html的其余部分中:
JavaScript
# --snip--
function isOddTailCall(number, inversionAccum) {
if (inversionAccum === undefined) {
inversionAccum = false;
}
if (number === 0) {
// BASE CASE
return inversionAccum;
} else {
// RECURSIVE CASE
return isOddTailCall(number - 1, !inversionAccum);
}
}
document.write(isOdd(42) + "<br />");
document.write(isOdd(99) + "<br />");
</script>
如果这个 Python 和 JavaScript 代码是由支持尾调用优化的解释器运行的,调用isOddTailCall(100000)不会导致堆栈溢出。然而,尾调用优化仍然比简单使用%模运算符确定奇偶性要慢得多。
如果您认为递归,无论是否有尾递归,是确定正整数是否为奇数的一种极其低效的方法,那么您是完全正确的。与迭代解决方案不同,递归可能会因堆栈溢出而失败。添加尾调用优化以防止堆栈溢出并不能修复不适当使用递归的效率缺陷。作为一种技术,递归并不自动比迭代解决方案更好或更复杂。而且尾递归永远不是比循环或其他简单解决方案更好的方法。
总结
尾调用优化是编程语言的编译器或解释器的一个特性,可以用于特别编写为尾递归的递归函数。尾递归函数将递归函数调用的返回值作为递归情况中的最后一个操作返回。这允许函数删除当前帧对象,并防止调用堆栈在进行新的递归函数调用时增长。如果调用堆栈不增长,递归函数不可能导致堆栈溢出。
尾递归是一种解决方案,允许一些递归算法在处理大参数时不会崩溃。然而,这种方法需要重新排列代码,可能需要添加一个累加器参数。这可能会使您的代码更难理解。您可能会发现,牺牲代码的可读性不值得使用递归算法而不是迭代算法。
进一步阅读
Stack Overflow(网站,而不是编程错误)在stackoverflow.com/questions/33923/what-is-tail-recursion上对尾递归的基础进行了详细讨论。
Van Rossum 在两篇博文中写到了他决定不使用尾递归的原因,网址分别为neopythonic.blogspot.com.au/2009/04/tail-recursion-elimination.html和neopythonic.blogspot.com.au/2009/04/final-words-on-tail-calls.html。
Python 的标准库包括一个名为inspect的模块,允许您在 Python 程序运行时查看调用堆栈上的帧对象。inspect模块的官方文档位于docs.python.org/3/library/inspect.html,Doug Hellmann 的 Python 3 Module of the Week 博客上有一个教程,网址为pymotw.com/3/inspect。
练习问题
通过回答以下问题来测试您的理解:
-
尾调用优化可以防止什么?
-
递归函数的最后一个动作与尾递归函数有什么关系?
-
所有编译器和解释器都实现尾调用优化吗?
-
什么是累加器?
-
尾递归的缺点是什么?
-
快速排序算法(第五章介绍)可以重写以使用尾调用优化吗?
九、绘制分形
原文:Chapter 9 - Drawing Fractals
译者:飞龙
当然,递归最有趣的应用是绘制分形。 分形是在不同尺度上重复自己的形状,有时是混乱的。 这个术语是由分形几何学的创始人 Benoit B. Mandelbrot 在 1975 年创造的,源自拉丁语frāctus,意思是破碎或断裂,就像破碎的玻璃一样。 分形包括许多自然和人造形状。 在自然界中,您可能会在树的形状,蕨类叶子,山脉,闪电,海岸线,河网和雪花的形状中看到它们。 数学家,程序员和艺术家可以根据一些递归规则创建复杂的几何形状。
递归可以使用惊人地少的代码行生成复杂的分形艺术。 本章介绍了 Python 的内置“turtle”模块,用于使用代码生成几种常见的分形。 要使用 JavaScript 创建海龟图形,您可以使用 Greg Reimer 的“jtg”库。 为简单起见,本章仅介绍了 Python 分形绘图程序,而没有 JavaScript 等价物。 但是,本章介绍了“jtg”JavaScript 库。
海龟图形
海龟图形是 Logo 编程语言的一个特性,旨在帮助孩子们学习编码概念。 此功能自那时以来已在许多语言和平台上复制。 其核心思想是一个叫做海龟的对象。
海龟充当可编程笔,在 2D 窗口中绘制线条。 想象一只真正的海龟在地面上拿着一支笔,随着它移动,它在身后画一条线。 海龟可以调整其笔的大小和颜色,或者“抬起笔”,以便在移动时不绘制。 海龟程序可以产生复杂的几何图形,如图 9-1。
当您将这些指令放在循环和函数中时,即使是小程序也可以创建令人印象深刻的几何图形。 考虑以下spiral.py程序:
Python
import turtle
turtle.tracer(1, 0) # Makes the turtle draw faster.
for i in range(360):
turtle.forward(i)
turtle.left(59)
turtle.exitonclick() # Pause until user clicks in the window.
当您运行此程序时,海龟窗口会打开。 海龟(由三角形表示)将在图 9-1 中追踪螺旋图案。 虽然不是分形,但它是一幅美丽的图画。
图 9-1:使用 Python 的“turtle”模块绘制的螺旋
海龟图形系统中的窗口使用笛卡尔 x 和 y 坐标。 水平 x 坐标的数字向右增加,向左减少,而垂直 y 坐标的数字向上增加,向下减少。 这两个坐标一起可以为窗口中的任何点提供唯一的地址。 默认情况下,原点(坐标点在 0,0 处)位于窗口的中心。
海龟还有一个heading,或者方向,是从 0 到 359 的数字(一个圆被分成 360 度)。 在 Python 的“turtle”模块中,0 的 heading 面向东(朝屏幕的右边缘),并且顺时针增加; 90 的 heading 面向北,180 的 heading 面向西,270 的 heading 面向南。 在 JavaScript 的“jtg”库中,此方向被旋转,以便 0 度面向北,并且逆时针增加。 图 9-2 演示了 Python“turtle”模块和 JavaScript“jtg”库的 heading。
图 9-2:Python 的“turtle”模块(左)和 JavaScript 的“jtg”库(右)中的航向
在 JavaScript 的“jtg”库中,进入inventwithpython.com/jtg,将以下代码输入到页面底部的文本字段中:
JavaScript
for (let i = 0; i < 360; i++) { t.fd(i); t.lt(59) }
这将在网页的主要区域绘制与图 9-1 中显示的相同螺旋线。
基本海龟函数
海龟图形中最常用的函数会导致海龟改变航向并向前或向后移动。turtle.left()和turtle.right()函数从当前航向开始旋转海龟一定角度,而turtle.forward()和turtle.backward()函数根据当前位置移动海龟。
表 9-1 列出了一些海龟的函数。第一个函数(以“turtle.”开头)是为 Python,第二个(以“t.”开头)是为 JavaScript。完整的 Python 文档可在docs.python.org/3/library/turtle.html找到。在 JavaScript 的“jtg”软件中,您可以按 F1 键显示帮助屏幕。
表 9-1:Python 的“turtle”模块和 JavaScript 的“jtg”库中的海龟函数
| Python | JavaScript | 描述 |
|---|---|---|
goto(x, y) | xy(x, y) | 将海龟移动到 x,y 坐标。 |
setheading(deg) | heading(deg) | 设置海龟的航向。在 Python 中,0 度是东(右)。在 JavaScript 中,0 度是北(向上)。 |
forward(steps) | fd(steps) | 以面对的方向将海龟向前移动一定步数。 |
backward(steps) | bk(steps) | 以面对的相反方向将海龟向后移动一定步数。 |
left(deg) | lt(deg) | 将海龟的航向向左转动。 |
right(deg) | rt(deg) | 将海龟的航向向右转动。 |
penup() | pu() | “提起笔”以使海龟在移动时停止绘制。 |
pendown() | pd() | “放下笔”以使海龟在移动时开始绘制。 |
pensize(size) | thickness(size) | 更改海龟绘制线条的粗细。默认值为1。 |
pencolor(color) | color(color) | 更改海龟绘制线条的颜色。这可以是常见颜色的字符串,如red或white。默认值为black。 |
xcor() | get.x() | 返回海龟当前的 x 坐标。 |
ycor() | get.y() | 返回海龟当前的 y 坐标。 |
heading() | get.heading() | 以 0 到 359 的浮点数返回海龟当前的航向。在 Python 中,0 度是东(右)。在 JavaScript 中,0 度是北(向上)。 |
reset() | reset() | 清除任何绘制的线条,并将海龟移回原始位置和航向。 |
clear() | clean() | 清除任何绘制的线条,但不移动海龟。 |
表 9-2 中列出的函数仅在 Python 的“turtle”模块中可用。
表 9-2:仅 Python 的海龟函数
| Python | 描述 |
|---|---|
begin_fill() | 开始绘制填充形状。此调用之后绘制的线条将指定填充形状的周长。 |
end_fill() | 绘制以调用turtle.begin_fill()开始的填充形状。 |
fillcolor( color ) | 设置用于填充形状的颜色。 |
hideturtle() | 隐藏代表海龟的三角形。 |
showturtle() | 显示代表海龟的三角形。 |
tracer( drawingUpdates , delay ) | 调整绘制速度。将delay设置为0,表示在乌龟绘制每条线后延迟 0 毫秒。传递给drawingUpdates的数字越大,乌龟绘制的速度就越快,因为模块在更新屏幕之前绘制的次数越多。 |
update() | 将任何缓冲线(稍后在本节中解释)绘制到屏幕上。在乌龟完成绘制后调用此函数。 |
setworldcoordinates( llx , lly , urx, ury ) | 重新调整窗口显示坐标平面的哪一部分。前两个参数是窗口左下角的 x、y 坐标。后两个参数是窗口右上角的 x、y 坐标。 |
exitonclick() | 当用户单击任何位置时,暂停程序并关闭窗口。如果在程序的最后没有这个命令,乌龟图形窗口可能会在程序结束时立即关闭。 |
在 Python 的turtle模块中,线条会立即显示在屏幕上。然而,这可能会减慢绘制数千条线的程序。更快的方法是缓冲——即暂时不显示几条线,然后一次性显示它们。
通过调用turtle.tracer(1000, 0),你可以指示turtle模块在程序创建了 1,000 条线之前不显示这些线。在程序完成调用绘制线条的函数后,最后调用turtle.update()来显示剩余的缓冲线。如果你的程序仍然花费太长时间来绘制图像,可以将一个更大的整数,如2000或10000,作为第一个参数传递给turtle.tracer()。
谢尔宾斯基三角形
在纸上绘制的最简单的分形是谢尔宾斯基三角形,它是在第一章介绍的。这个分形是由波兰数学家瓦茨瓦夫·谢尔宾斯基于 1915 年描述的(甚至早于术语分形的出现)。然而,这种图案至少有数百年的历史。
要创建一个谢尔宾斯基三角形,首先绘制一个等边三角形——一个三边长度相等的三角形,就像图 9-3 中左边的那个。然后在第一个三角形内部绘制一个倒置的等边三角形,就像图 9-3 中右边的那个。你将得到一个形状,如果你熟悉塞尔达传说视频游戏,它看起来像三角力量。
图 9-3:一个等边三角形(左)和一个倒置的三角形相加形成了一个谢尔宾斯基三角形,递归地添加了额外的三角形
当你绘制内部的倒置三角形时,一个有趣的事情发生了。你形成了三个新的正立等边三角形。在这三个三角形的每一个内部,你可以绘制另一个倒置的三角形,这样就会创建出九个三角形。这种递归在数学上可以无限进行,尽管在现实中,你的笔无法不断地绘制更小的三角形。
这种描述一个与自身的一部分相似的完整对象的属性被称为自相似性。递归函数可以产生这些对象,因为它们一遍又一遍地“调用”自己。实际上,这段代码最终必须达到一个基本情况,但在数学上,这些形状具有无限的分辨率:你理论上可以永远放大这个形状。
让我们编写一个递归程序来创建谢尔宾斯基三角形。递归的drawTriangle()函数将绘制一个等边三角形,然后递归调用这个函数三次来绘制内部的等边三角形,就像图 9-4 中那样。midpoint()函数找到距离函数传递的两个点等距离的点。这对于内部三角形使用这些等距离的点作为它们的顶点是很重要的。
图 9-4:三个内部三角形,中点用大点显示
请注意,此程序调用了turtle.setworldcoordinates(0, 0, 700, 700),这使得 0, 0 原点位于窗口的左下角。右上角的 x、y 坐标为 700, 700。sierpinskiTriangle.py的源代码如下:
import turtle
turtle.tracer(100, 0) # Increase the first argument to speed up the drawing.
turtle.setworldcoordinates(0, 0, 700, 700)
turtle.hideturtle()
MIN_SIZE = 4 # Try changing this to decrease/increase the amount of recursion.
def midpoint(startx, starty, endx, endy):
# Return the x, y coordinate in the middle of the four given parameters.
xDiff = abs(startx - endx)
yDiff = abs(starty - endy)
return (min(startx, endx) + (xDiff / 2.0), min(starty, endy) + (yDiff / 2.0))
def isTooSmall(ax, ay, bx, by, cx, cy):
# Determine if the triangle is too small to draw.
width = max(ax, bx, cx) - min(ax, bx, cx)
height = max(ay, by, cy) - min(ay, by, cy)
return width < MIN_SIZE or height < MIN_SIZE
def drawTriangle(ax, ay, bx, by, cx, cy):
if isTooSmall(ax, ay, bx, by, cx, cy):
# BASE CASE
return
else:
# RECURSIVE CASE
# Draw the triangle.
turtle.penup()
turtle.goto(ax, ay)
turtle.pendown()
turtle.goto(bx, by)
turtle.goto(cx, cy)
turtle.goto(ax, ay)
turtle.penup()
# Calculate midpoints between points A, B, and C.
mid_ab = midpoint(ax, ay, bx, by)
mid_bc = midpoint(bx, by, cx, cy)
mid_ca = midpoint(cx, cy, ax, ay)
# Draw the three inner triangles.
drawTriangle(ax, ay, mid_ab[0], mid_ab[1], mid_ca[0], mid_ca[1])
drawTriangle(mid_ab[0], mid_ab[1], bx, by, mid_bc[0], mid_bc[1])
drawTriangle(mid_ca[0], mid_ca[1], mid_bc[0], mid_bc[1], cx, cy)
return
# Draw an equilateral Sierpinski triangle.
drawTriangle(50, 50, 350, 650, 650, 50)
# Draw a skewed Sierpinski triangle.
#drawTriangle(30, 250, 680, 600, 500, 80)
turtle.exitonclick()
当你运行这段代码时,输出看起来像图 9-5。
图 9-5:标准谢尔宾斯基三角形
谢尔宾斯基三角形不一定要用等边三角形来绘制。只要使用外部三角形的中点来绘制内部三角形,你可以使用任何类型的三角形。注释掉第一个drawTriangle()调用,并取消注释第二个(在# Draw a skewed Sierpinski triangle.注释下面),然后再次运行程序。输出将看起来像图 9-6。
图 9-6:一个倾斜的谢尔宾斯基三角形
drawTriangle()函数接受六个参数,对应于三角形的三个点的 x、y 坐标。尝试尝试不同的值来调整谢尔宾斯基三角形的形状。你也可以将MIN_SIZE常量更改为较大的值,以使程序更快地达到基本情况,并减少绘制的三角形数量。
谢尔宾斯基地毯
一个类似于谢尔宾斯基三角形的分形形状可以使用矩形来绘制。这种模式被称为Sierpiński carpet。想象将一个黑色矩形分成 3×3 的网格,然后“切除”中心矩形。在网格的周围八个矩形中重复这种模式。当这样递归地完成时,你会得到一个像图 9-7 的图案。
图 9-7:谢尔宾斯基地毯
绘制地毯的 Python 程序使用turtle.begin_fill()和turtle.end_fill()函数来创建实心的填充形状。乌龟在这些调用之间绘制的线用于绘制形状,就像图 9-8 中那样。
图 9-8:调用turtle.begin_fill(),绘制路径,然后调用turtle.end_fill()创建填充形状。
当 3×3 的矩形变得小于一边的六个步骤时,基本情况就会到达。你可以将MIN_SIZE常量更改为较大的值,以使程序更快地达到基本情况。sierpinskiCarpet.py的源代码如下:
import turtle
turtle.tracer(10, 0) # Increase the first argument to speed up the drawing.
turtle.setworldcoordinates(0, 0, 700, 700)
turtle.hideturtle()
MIN_SIZE = 6 # Try changing this to decrease/increase the amount of recursion.
DRAW_SOLID = True
def isTooSmall(width, height):
# Determine if the rectangle is too small to draw.
return width < MIN_SIZE or height < MIN_SIZE
def drawCarpet(x, y, width, height):
# The x and y are the lower-left corner of the carpet.
# Move the pen into position.
turtle.penup()
turtle.goto(x, y)
# Draw the outer rectangle.
turtle.pendown()
if DRAW_SOLID:
turtle.fillcolor('black')
turtle.begin_fill()
turtle.goto(x, y + height)
turtle.goto(x + width, y + height)
turtle.goto(x + width, y)
turtle.goto(x, y)
if DRAW_SOLID:
turtle.end_fill()
turtle.penup()
# Draw the inner rectangles.
drawInnerRectangle(x, y, width, height)
def drawInnerRectangle(x, y, width, height):
if isTooSmall(width, height):
# BASE CASE
return
else:
# RECURSIVE CASE
oneThirdWidth = width / 3
oneThirdHeight = height / 3
twoThirdsWidth = 2 * (width / 3)
twoThirdsHeight = 2 * (height / 3)
# Move into position.
turtle.penup()
turtle.goto(x + oneThirdWidth, y + oneThirdHeight)
# Draw the inner rectangle.
if DRAW_SOLID:
turtle.fillcolor('white')
turtle.begin_fill()
turtle.pendown()
turtle.goto(x + oneThirdWidth, y + twoThirdsHeight)
turtle.goto(x + twoThirdsWidth, y + twoThirdsHeight)
turtle.goto(x + twoThirdsWidth, y + oneThirdHeight)
turtle.goto(x + oneThirdWidth, y + oneThirdHeight)
turtle.penup()
if DRAW_SOLID:
turtle.end_fill()
# Draw the inner rectangles across the top.
drawInnerRectangle(x, y + twoThirdsHeight, oneThirdWidth, oneThirdHeight)
drawInnerRectangle(x + oneThirdWidth, y + twoThirdsHeight, oneThirdWidth, oneThirdHeight)
drawInnerRectangle(x + twoThirdsWidth, y + twoThirdsHeight, oneThirdWidth, oneThirdHeight)
# Draw the inner rectangles across the middle.
drawInnerRectangle(x, y + oneThirdHeight, oneThirdWidth,
oneThirdHeight)
drawInnerRectangle(x + twoThirdsWidth, y + oneThirdHeight, oneThirdWidth,
oneThirdHeight)
# Draw the inner rectangles across the bottom.
drawInnerRectangle(x, y, oneThirdWidth, oneThirdHeight)
drawInnerRectangle(x + oneThirdWidth, y, oneThirdWidth, oneThirdHeight)
drawInnerRectangle(x + twoThirdsWidth, y, oneThirdWidth,
oneThirdHeight)
drawCarpet(50, 50, 600, 600)
turtle.exitonclick()
您还可以将DRAW_SOLID常量设置为False并运行程序。这将跳过对turtle.begin_fill()和turtle.end_fill()的调用,以便只绘制矩形的轮廓,如图 9-9 所示。
尝试将不同的参数传递给drawCarpet()。前两个参数是地毯左下角的 x、y 坐标,而后两个参数是宽度和高度。您还可以将MIN_SIZE常量更改为较大的值,以使程序更快地达到基本情况,并减少绘制的矩形数量。
图 9-9:Sierpiński 地毯,只绘制了矩形的轮廓
另一个 3D Sierpiński 地毯使用立方体而不是正方形。在这种形式中,它被称为Sierpiński 立方体或Menger 海绵。它最早由数学家卡尔·门格在 1926 年描述。图 9-10 显示了在视频游戏Minecraft中创建的 Menger 海绵。
图 9-10:3D Menger 海绵分形
分形树
虽然 Sierpiński 三角形和地毯等人造分形是完全自相似的,但分形可以包括没有完美自相似性的形状。数学家 Benoit B. Mandelbrot(他的中间名字母递归地代表 Benoit B. Mandelbrot)构想的分形几何包括自然形状,如山脉、海岸线、植物、血管和星系的聚类。仔细观察,这些形状继续由简化几何的光滑曲线和直线难以包容的“粗糙”形状组成。
例如,我们可以使用递归来复制分形树,无论是完全还是不完全自相似。生成树需要创建一个具有两个子分支的分支,这些分支从父分支发出,角度和长度减小。它们产生的 Y 形状被递归重复,以创建一棵树的逼真图像,如图 9-11 和 9-12 所示。
图 9-11:使用一致的角度和长度生成的完全自相似的分形树
电影和视频游戏可以在程序生成中使用这种递归算法,自动(而不是手动)创建树、蕨类植物、花朵和其他植物等 3D 模型。使用算法,计算机可以快速创建由数百万棵独特树组成的整个森林,节省了大量人类 3D 艺术家的辛苦努力。
图 9-12:使用随机改变分支角度和长度创建的更真实的树
我们的分形树程序每两秒显示一个新的随机生成的树。fractalTree.py的源代码如下:
Python
import random
import time
import turtle
turtle.tracer(1000, 0) # Increase the first argument to speed up the drawing.
turtle.setworldcoordinates(0, 0, 700, 700)
turtle.hideturtle()
def drawBranch(startPosition, direction, branchLength):
if branchLength < 5:
# BASE CASE
return
# Go to the starting point & direction.
turtle.penup()
turtle.goto(startPosition)
turtle.setheading(direction)
# Draw the branch (thickness is 1/7 the length).
turtle.pendown()
turtle.pensize(max(branchLength / 7.0, 1))
turtle.forward(branchLength)
# Record the position of the branch's end.
endPosition = turtle.position()
leftDirection = direction + LEFT_ANGLE
leftBranchLength = branchLength - LEFT_DECREASE
rightDirection = direction - RIGHT_ANGLE
rightBranchLength = branchLength - RIGHT_DECREASE
# RECURSIVE CASE
drawBranch(endPosition, leftDirection, leftBranchLength)
drawBranch(endPosition, rightDirection, rightBranchLength)
seed = 0
while True:
# Get pseudorandom numbers for the branch properties.
random.seed(seed)
LEFT_ANGLE = random.randint(10, 30)
LEFT_DECREASE = random.randint( 8, 15)
RIGHT_ANGLE = random.randint(10, 30)
RIGHT_DECREASE = random.randint( 8, 15)
START_LENGTH = random.randint(80, 120)
# Write out the seed number.
turtle.clear()
turtle.penup()
turtle.goto(10, 10)
turtle.write('Seed: %s' % (seed))
# Draw the tree.
drawBranch((350, 10), 90, START_LENGTH)
turtle.update()
time.sleep(2)
seed = seed + 1
这个程序产生完全自相似的树,因为LEFT_ANGLE、LEFT_DECREASE、RIGHT_ANGLE和RIGHT_DECREASE变量最初是随机选择的,但对所有递归调用保持不变。random.seed()函数为 Python 的随机函数设置一个种子值。随机数种子值使程序产生看似随机的数字,但对树的每个分支使用相同的随机数序列。换句话说,相同的种子值每次运行程序都会产生相同的树。(我从不为我说的双关语道歉。)
要看到这个过程,输入以下内容到 Python 交互式 shell 中:
Python
>>> import random
>>> random.seed(42)
>>> [random.randint(0, 9) for i in range(20)]
[1, 0, 4, 3, 3, 2, 1, 8, 1, 9, 6, 0, 0, 1, 3, 3, 8, 9, 0, 8]
>>> [random.randint(0, 9) for i in range(20)]
[3, 8, 6, 3, 7, 9, 4, 0, 2, 6, 5, 4, 2, 3, 5, 1, 1, 6, 1, 5]
>>> random.seed(42)
>>> [random.randint(0, 9) for i in range(20)]
[1, 0, 4, 3, 3, 2, 1, 8, 1, 9, 6, 0, 0, 1, 3, 3, 8, 9, 0, 8]
在这个例子中,我们将随机种子设置为 42。当我们生成 20 个随机整数时,我们得到1、0、4、3等。我们可以生成另外 20 个整数,并继续接收随机整数。然而,如果我们将种子重置为42,再次生成 20 个随机整数,它们将与之前的相同的“随机”整数。
如果你想创建一个更自然、不那么自相似的树,用以下行替换#记录分支末端的位置。注释后的行。这会为每个递归调用生成新的随机角度和分支长度,更接近树在自然界中生长的方式:
Python
# Record the position of the branch's end.
endPosition = turtle.position()
leftDirection = direction + random.randint(10, 30)
leftBranchLength = branchLength - random.randint(8, 15)
rightDirection = direction - random.randint(10, 30)
rightBranchLength = branchLength - random.randint(8, 15)
你可以尝试不同范围的random.randint()调用,或者尝试添加更多的递归调用,而不仅仅是两个分支。
英国海岸线有多长?科赫曲线和雪花
在我告诉你关于科赫曲线和雪花之前,考虑这个问题:英国的海岸线有多长?看一下图 9-13。左边的地图有一个粗略的测量,将海岸线长度约为 2000 英里。但右边的地图有一个更精确的测量,包括了更多海岸的角落,长度约为 2800 英里。
图 9-13:大不列颠岛,粗略测量(左)和更精确测量(右)。更精确地测量海岸线长度增加了 800 英里。
曼德布罗特关于英国海岸线等分形的关键见解是,你可以继续越来越近地观察,每个尺度上都会有“粗糙”。因此,随着你的测量变得越来越精细,海岸线的长度也会变得越来越长。这条“海岸线”将沿着泰晤士河上游,深入陆地沿着一岸,然后回到英吉利海峡的另一岸。因此,对于我们关于大不列颠海岸线长度的问题的答案是,“这取决于。”
Koch 曲线分形具有与其海岸线长度或周长相关的类似特性。Koch 曲线最早由瑞典数学家赫尔格·冯·科赫于 1902 年提出,是最早被数学描述的分形之一。要构造它,取长度为b的线段并将其分成三等分,每部分长度为b/3。用长度也为b/3 的“凸起”替换中间部分。这个凸起使得 Koch 曲线比原始线段更长,因为现在我们有四条长度为b/3 的线段。(我们将排除原始线段的中间部分。)这个凸起的创建可以在新的四条线段上重复。图 9-14 展示了这个构造过程。
图 9-14:将线段分成三等分(左),在中间部分添加一个凸起(右)。现在我们有长度为b/3 的四段线段,可以再次添加凸起(底部)。
要创建科赫雪花,我们从一个等边三角形开始,并从其三边构造三个科赫曲线,如图 9-15 所示。
图 9-15:在等边三角形的三边上创建三个科赫曲线,形成科赫雪花
每次创建一个新的凸起,都会将曲线的长度从三个b/3 长度增加到四个b/3 长度,或 4b/3。如果你继续在等边三角形的三边上这样做,你将创建科赫雪花,就像图 9-16 中所示的那样。(小点状图案是因为轻微的舍入误差导致turtle模块无法完全擦除中间的b/3 段。)你可以继续永远创建新的凸起,尽管我们的程序在它们变得小于几个像素时停止。
图 9-16:科赫雪花。由于小的舍入误差,一些内部线条仍然存在。
kochSnowflake.py的源代码如下:
Python
import turtle
turtle.tracer(10, 0) # Increase the first argument to speed up the drawing.
turtle.setworldcoordinates(0, 0, 700, 700)
turtle.hideturtle()
turtle.pensize(2)
def drawKochCurve(startPosition, heading, length):
if length < 1:
# BASE CASE
return
else:
# RECURSIVE CASE
# Move to the start position.
recursiveArgs = []
turtle.penup()
turtle.goto(startPosition)
turtle.setheading(heading)
recursiveArgs.append({'position':turtle.position(),
'heading':turtle.heading()})
# Erase the middle third.
turtle.forward(length / 3)
turtle.pencolor('white')
turtle.pendown()
turtle.forward(length / 3)
# Draw the bump.
turtle.backward(length / 3)
turtle.left(60)
recursiveArgs.append({'position':turtle.position(),
'heading':turtle.heading()})
turtle.pencolor('black')
turtle.forward(length / 3)
turtle.right(120)
recursiveArgs.append({'position':turtle.position(),
'heading':turtle.heading()})
turtle.forward(length / 3)
turtle.left(60)
recursiveArgs.append({'position':turtle.position(),
'heading':turtle.heading()})
for i in range(4):
drawKochCurve(recursiveArgs[i]['position'],
recursiveArgs[i]['heading'],
length / 3)
return
def drawKochSnowflake(startPosition, heading, length):
# A Koch snowflake is three Koch curves in a triangle.
# Move to the starting position.
turtle.penup()
turtle.goto(startPosition)
turtle.setheading(heading)
for i in range(3):
# Record the starting position and heading.
curveStartingPosition = turtle.position()
curveStartingHeading = turtle.heading()
drawKochCurve(curveStartingPosition,
curveStartingHeading, length)
# Move back to the start position for this side.
turtle.penup()
turtle.goto(curveStartingPosition)
turtle.setheading(curveStartingHeading)
# Move to the start position of the next side.
turtle.forward(length)
turtle.right(120)
drawKochSnowflake((100, 500), 0, 500)
turtle.exitonclick()
科赫雪花有时也被称为科赫岛。它的海岸线将是无限长的。虽然科赫雪花可以放入本书一页的有限区域,但其周长的长度是无限的,证明了,尽管看起来违反直觉,有限可以包含无限!
希尔伯特曲线
填充曲线是一条 1D 曲线,它弯曲直到完全填满 2D 空间而不交叉。德国数学家大卫·希尔伯特于 1891 年描述了他的填充曲线。如果你将一个 2D 区域分成一个网格,希尔伯特曲线的单一 1D 线可以穿过网格中的每个单元格。
图 9-17 包含希尔伯特曲线的前三次递归。下一次递归包含前一次递归的四个副本,虚线显示了这四个副本如何连接在一起。
图 9-17:希尔伯特填充曲线的前三次递归
当单元格变成无穷小点时,1D 曲线可以像 2D 正方形一样填满整个 2D 空间。令人费解的是,这样可以从严格的 1D 线创建一个 2D 形状!
hilbertCurve.py的源代码如下:
Python
import turtle
turtle.tracer(10, 0) # Increase the first argument to speed up the drawing.
turtle.setworldcoordinates(0, 0, 700, 700)
turtle.hideturtle()
LINE_LENGTH = 5 # Try changing the line length by a little.
ANGLE = 90 # Try changing the turning angle by a few degrees.
LEVELS = 6 # Try changing the recursive level by a little.
DRAW_SOLID = False
#turtle.setheading(20) # Uncomment this line to draw the curve at an angle.
def hilbertCurveQuadrant(level, angle):
if level == 0:
# BASE CASE
return
else:
# RECURSIVE CASE
turtle.right(angle)
hilbertCurveQuadrant(level - 1, -angle)
turtle.forward(LINE_LENGTH)
turtle.left(angle)
hilbertCurveQuadrant(level - 1, angle)
turtle.forward(LINE_LENGTH)
hilbertCurveQuadrant(level - 1, angle)
turtle.left(angle)
turtle.forward(LINE_LENGTH)
hilbertCurveQuadrant(level - 1, -angle)
turtle.right(angle)
return
def hilbertCurve(startingPosition):
# Move to starting position.
turtle.penup()
turtle.goto(startingPosition)
turtle.pendown()
if DRAW_SOLID:
turtle.begin_fill()
hilbertCurveQuadrant(LEVELS, ANGLE) # Draw lower-left quadrant.
turtle.forward(LINE_LENGTH)
hilbertCurveQuadrant(LEVELS, ANGLE) # Draw lower-right quadrant.
turtle.left(ANGLE)
turtle.forward(LINE_LENGTH)
turtle.left(ANGLE)
hilbertCurveQuadrant(LEVELS, ANGLE) # Draw upper-right quadrant.
turtle.forward(LINE_LENGTH)
hilbertCurveQuadrant(LEVELS, ANGLE) # Draw upper-left quadrant.
turtle.left(ANGLE)
turtle.forward(LINE_LENGTH)
turtle.left(ANGLE)
if DRAW_SOLID:
turtle.end_fill()
hilbertCurve((30, 350))
turtle.exitonclick()
尝试通过减小LINE_LENGTH来缩短线段的长度,同时增加LEVELS来增加递归的层次。因为这个程序只使用海龟的相对移动,你可以取消注释turtle.setheading(20)这一行来以 20 度角绘制希尔伯特曲线。图 9-18 显示了使用LINE_LENGTH为10和LEVELS为5时产生的绘图。
图 9-18:希尔伯特曲线的五个级别,线长为10
希尔伯特曲线进行 90 度(直角)转弯。但尝试将ANGLE变量调整几度至89或86,并运行程序查看变化。您还可以将DRAW_SOLID变量设置为True,以生成填充的希尔伯特曲线,如图 9-19。
图 9-19:填充的希尔伯特曲线的六个级别,线长为5
总结
分形的广阔领域结合了编程和艺术的最有趣的部分,使得这一章节成为最有趣的写作。数学家和计算机科学家谈论他们领域的高级主题产生的美丽和优雅,但递归分形能够将这种概念上的美丽转化为任何人都能欣赏的视觉美。
本章介绍了几种分形和绘制它们的程序:谢尔宾斯基三角形、谢尔宾斯基地毯、程序生成的分形树、科赫曲线和雪花、以及希尔伯特曲线。所有这些都是使用 Python 的turtle模块和递归调用自身的函数绘制的。
进一步阅读
要了解更多关于使用 Python 的turtle模块绘图的知识,我在github.com/asweigart/simple-turtle-tutorial-for-python写了一个简单的教程。我还在github.com/asweigart/art-of-turtle-programming上有一个个人的乌龟程序集合。
关于英国海岸线长度的问题来自曼德布罗特在 1967 年的一篇论文的标题。这个想法在维基百科上有很好的总结。可汗学院有更多关于科赫雪花几何的内容。
3Blue1Brown YouTube 频道有关分形的出色动画,特别是“分形通常不是自相似”的视频和“分形魅力:填充曲线”视频。
其他填充曲线需要递归来绘制,例如皮亚诺曲线、戈斯珀曲线和龙曲线,值得在网上进行研究。
练习问题
通过回答以下问题来测试您的理解:
-
什么是分形?
-
笛卡尔坐标系中的 x 和 y 坐标代表什么?
-
笛卡尔坐标系中的原点坐标是什么?
-
什么是程序生成?
-
什么是种子值?
-
科赫雪花的周长有多长?
-
什么是填充曲线?
练习项目
为了练习,为以下每个任务编写一个程序:
- 创建一个乌龟程序,绘制如图 9-20 所示的盒子分形。这个程序类似于本章介绍的谢尔宾斯基地毯程序。使用
turtle.begin_fill()和turtle.end_fill()函数来绘制第一个大的黑色正方形。然后将这个正方形分成九个相等的部分,在顶部、左侧、右侧和底部的正方形中绘制白色正方形。对四个角落的正方形和中心正方形重复这个过程。
图 9-20:一个绘制了两层的盒子分形
- 创建一个乌龟程序,绘制 Peano 填充曲线。这类似于本章中的希尔伯特曲线程序。图 9-21 显示了 Peano 曲线的前三次迭代。虽然每个希尔伯特曲线迭代被分割成 2×2 的部分(依次分割成 2×2 的部分),Peano 曲线被分割成 3×3 的部分。
图 9-21:Peano 曲线的前三次迭代,从左到右。底部一行包括每个曲线部分分割的 3×3 部分。`*``