Python-数学冒险指南-全-
Python 数学冒险指南(全)
原文:
zh.annas-archive.org/md5/bfe781f1924b7ca06c7aed3c55609fd3译者:飞龙
序言

你更倾向于选择图 1 中的哪种方法?左边是传统数学教学方法的例子,包括定义、命题和证明。这种方法需要大量的阅读和各种奇怪的符号。你永远不会猜到这与几何图形有什么关系。事实上,这段文字在讲解如何找到三角形的质心,或者说三角形的中心。但像这样的传统方法并没有告诉我们,为什么我们一开始就应该对找到三角形的中心感兴趣。

图 1:关于质心的两种教学方法
在这段文字旁边,你看到了一张动态草图,草图中大约有一百个旋转的三角形。这是一个具有挑战性的编程项目,如果你希望它旋转得正确(并且看起来很酷),你必须找到三角形的质心。在许多情况下,制作酷炫的图形几乎是不可能的,除非你了解几何学背后的数学原理。例如,正如你将在本书中看到的,了解三角形背后的数学知识,像是质心,就能让我们轻松创造出艺术作品。一个懂数学并能设计酷炫图形的学生,更有可能深入研究几何学,忍受一些平方根或三角函数。而一个看不出任何结果、只是在做教科书上的作业的学生,可能没有太大的动力去学习几何学。
在我作为数学教师的八年经验和三年计算机科学教师的经验中,我遇到了更多倾向于视觉方法的数学学习者。在创造有趣的东西的过程中,你会发现数学不仅仅是按步骤解方程。你会看到,运用编程来探索数学,可以有多种方法解决有趣的问题,并且在过程中会有许多意想不到的错误和改进的机会。
这就是学校数学与真实数学之间的区别。
学校数学的问题
我所说的“学校数学”到底是什么意思?在 19 世纪 60 年代的美国,学校数学是为成为一名职员准备的,帮助人们手工加总一列数字。而今天,工作不同了,为这些工作做准备也需要改变。
人们通过实践学习得最好。然而,在学校中,这种方式并没有得到日常的应用,学校更倾向于被动学习。在英语和历史课上,“做”可能意味着学生写论文或做演讲,科学课上学生做实验,但数学学生做什么呢?过去,在数学课上,你能做的主动“做”的事情就是解方程、因式分解和画图。但现在,计算机可以为我们做大部分计算,这些做法已经不再足够了。
单纯学习如何自动化解决问题、因式分解和绘制图形并不是最终目标。一旦学生学会了自动化某个过程,他们就能比以往更深入地探讨某个主题。
图 2 展示了你在教科书中会遇到的典型数学问题,要求学生定义一个函数“f(x)”并为许多值进行求值。

图 2:传统的函数教学方法
这种格式在接下来的 18 个问题中依然适用!这种类型的练习对于像 Python 这样的编程语言来说是个微不足道的问题。我们可以简单地定义函数f(x),然后通过遍历一个列表来插入数值,像这样:
import math
def f(x):
return math.sqrt(x + 3) - x + 1
#list of values to plug in
for x in [0,1,math.sqrt(2),math.sqrt(2)-1]:
print("f({:.3f}) = {:.3f}".format(x,f(x)))
最后一行只是为了让输出更加美观,并将所有解答四舍五入到小数点后三位,如下所示:
f(0.000) = 2.732
f(1.000) = 2.000
f(1.414) = 1.687
f(0.414) = 2.434
在像 Python、JavaScript、Java 等编程语言中,函数是转化数字和其他对象(甚至其他函数)的一项至关重要的工具。使用 Python,你可以给函数一个描述性的名称,这样更容易理解其功能。例如,你可以命名一个计算矩形面积的函数为calculateArea(),像这样:
def calculateArea(width,height):
一本在 21 世纪出版的数学教科书,在 Benoit Mandelbrot 首次为 IBM 工作时通过计算机生成他的著名分形图像几十年后,展示了曼德尔布罗特集的图像,并对这一发现赞不绝口。教科书描述曼德尔布罗特集,如图 3 所示,称其为“一个来自复数的迷人数学对象。其美丽的边界展示了混沌行为。”

图 3:曼德尔布罗特集
教科书随后带领读者进行一项艰苦的“探索”,展示如何在复平面中转换一个点。但学生仅仅是被教如何在计算器上完成此操作,这意味着只有两个点能够在合理时间内被转换(迭代七次)。仅仅是两个点。
在本书中,你将学习如何在 Python 中实现这一点,并让程序自动化地转换数十万个点,甚至创建你上面看到的曼德尔布罗特集!
关于本书
本书旨在通过使用编程工具使数学变得有趣且相关,同时仍保持一定挑战性。你将制作图表展示一个函数的所有可能输出。你将创作动态的、互动的艺术作品。你甚至会创建一个生态系统,里面有会移动、吃草和繁殖的羊群,你还将创建虚拟生物,它们会在你观察的过程中,尝试找到一条穿越一系列城市的最短路径!
你将使用 Python 和 Processing 来增强你在数学课上的能力。本书不是要跳过数学,而是利用最新、最酷的工具来进行创意探索,同时学习真正的计算机技能,并发现数学、艺术、科学和技术之间的联系。Processing 将提供图形、形状、运动和颜色,而 Python 则负责计算,并在背后执行你的指令。
对于本书中的每个项目,你将从零开始构建代码,从一个空白文件开始,并在每个步骤检查自己的进度。通过犯错和调试自己的程序,你将更深入地理解每一段代码的作用。
本书适合谁使用
本书适合任何正在学习数学的人,或是想要使用最现代工具来接触数学主题,如三角学和代数的人。如果你正在学习 Python,你可以利用本书,将你不断提升的编程技能应用于一些复杂项目,如元胞自动机、遗传算法和计算艺术。
教师可以使用本书中的项目来挑战学生,或让数学变得更易接近和更有意义。还有什么比通过将一堆点保存到矩阵并用它们来绘制三维图形更好的教矩阵的方式呢?掌握 Python 后,你可以做到这一切,甚至更多。
本书内容
本书从三章基本的 Python 概念开始,你将通过这些内容为探索更复杂的数学做准备。接下来的九章将探索数学概念和问题,你可以通过 Python 和 Processing 来可视化并解决这些问题。你可以尝试书中的练习,以应用所学内容并挑战自己。
第一章:用海龟画多边形 使用 Python 内建的turtle模块教授基本的编程概念,如循环、变量和函数。
第二章:通过列表和循环让枯燥的算术变得有趣 深入探讨编程概念,如列表和布尔值。
第三章:用条件语句猜测与验证 将你不断提升的 Python 技能应用于像因式分解和制作交互式猜数字游戏等问题。
第四章:用代数变换和存储数字 从解简单方程逐步到通过数值和图形解三次方程。
第五章:用几何变换形状 教你如何创建形状,然后将它们乘法、旋转并分布到屏幕上。
第六章:用三角函数创建振荡 不仅仅局限于直角三角形,它还让你创建振荡形状和波动。
第七章:复数 教你如何使用复数在屏幕上移动点,从而创建类似曼德尔布罗集的图形。
第八章:使用矩阵进行计算机图形学和方程组求解 带你进入三维空间,你将在其中转换和旋转三维形状,并使用一个程序解决庞大的方程组。
第九章:使用类构建对象 讲解了如何创建一个对象,或者根据你的电脑处理能力创建任意多个对象,里面有游动的绵羊和美味的草,它们为了生存展开了激烈的斗争。
第十章:使用递归创建分形 展示了如何使用递归作为一种全新的方式来测量距离并创造出意想不到的设计。
第十一章:元胞自动机 教你如何生成并编程让元胞自动机根据你设定的规则行为。
第十二章:使用遗传算法解决问题 展示了如何利用自然选择理论来解决那些我们在一百万年内也无法解决的问题!
下载和安装 Python
最简单的入门方式是使用 Python 3 软件分发版,可以免费从 https://www.python.org/ 下载。Python 已经成为全球最受欢迎的编程语言之一。它被用来创建像 Google、YouTube 和 Instagram 这样的网页,全球各地的研究人员也使用它来处理各个领域的数字,从天文学到动物学。至今发布的最新版本是 Python 3.7。请访问 https://www.python.org/downloads/ 并选择最新的 Python 3 版本,如 图 4 所示。

图 4:Python 软件基金会的官方网站

图 5:点击下载的文件开始安装
你可以选择适合你操作系统的版本。网站已检测到我正在使用 Windows。下载完成后,点击文件,如 图 5 所示。
按照说明进行操作,并始终选择默认选项。安装可能需要几分钟时间。安装完成后,搜索系统中的“IDLE”。那就是 Python 的 IDE,或称集成开发环境,你将需要它来编写 Python 代码。为什么叫“IDLE”?Python 编程语言的名称来源于蒙提·派森喜剧团(Monty Python),其中一位成员是 Eric Idle。
启动 IDLE
在系统中找到 IDLE 并打开它。

图 6:在 Windows 上打开 IDLE
一个名为“shell”的窗口将会出现。你可以在这里使用交互式编程环境,但你会想保存你的代码。点击 文件▸新建文件,或者按 ALT-N,一个文件将会打开(如 图 7 所示)。

图 7:Python 的交互式 shell(左)和一个新的模块(文件)窗口,准备好编写代码!
这里是你将编写 Python 代码的地方。我们还将使用 Processing,接下来我们来看看如何下载和安装 Processing。
安装 PROCESSING
使用 Python 你可以做很多事情,我们会经常使用 IDLE。但当我们需要进行一些复杂的图形处理时,我们将使用 Processing。Processing 是一个专业级的图形库,程序员和艺术家都使用它来创作动态的互动艺术和图形。
访问https://processing.org/download/,选择你的操作系统,如图 8 所示。

图 8:Processing 官网

图 9:如何找到其他 Processing 模式,比如我们将使用的 Python 模式
点击并按照指示下载适合你操作系统的安装程序。双击图标启动 Processing。默认情况下,它是 Java 模式。点击Java以打开下拉菜单,如图 9 所示,然后点击添加模式。
选择Python 模式▸安装。这应该只需一两分钟,但完成后你就可以使用 Processing 进行 Python 编程了。
现在你已经设置好了 Python 和 Processing,你可以开始探索数学了!
第一部分:绑好你的 Python 马车
第一章:使用 TURTLE 模块绘制多边形
几百年前,一位西方人听到一位印度人说地球就托在一只海龟的背上。当被问到海龟站在什么上面时,这位印度人解释道:“海龟就是站在海龟上,一直到无尽。”

在开始使用数学构建本书中展示的所有酷炫东西之前,您需要学习如何使用一种名为 Python 的编程语言向计算机发出指令。在本章中,您将通过使用 Python 内置的 turtle 工具绘制不同的图形,熟悉一些基本的编程概念,如循环、变量和函数。正如您将看到的,turtle 模块是学习 Python 基本特性的有趣方式,也能让您初步体验到使用编程创建的乐趣。
PYTHON 的 TURTLE 模块
Python 的 turtle 工具基于 Logo 编程语言中的原始“海龟”代理,该语言在 1960 年代发明,旨在让每个人都能更容易接触到计算机编程。Logo 的图形化环境让与计算机的交互变得直观和有趣。(请查阅 Seymour Papert 的精彩著作 《心智风暴》,了解更多使用 Logo 虚拟海龟学习数学的绝妙点子。)Python 编程语言的创造者非常喜欢 Logo 的海龟,于是他们在 Python 中编写了一个名为 turtle 的模块,用以复制 Logo 海龟的功能。
Python 的 turtle 模块让您控制一个小海龟形状的图像,就像视频游戏中的角色一样。您需要给出精确的指令来引导海龟在屏幕上移动。由于海龟在移动时会留下痕迹,我们可以利用它编写一个程序,绘制不同的图形。
让我们首先导入 turtle 模块!
导入 TURTLE 模块
在 IDLE 中打开一个新的 Python 文件,并将其保存为 myturtle.py 文件,存放在 Python 文件夹中。您应该会看到一个空白页面。要在 Python 中使用海龟,首先必须导入 turtle 模块中的函数。
函数 是一组可重用的代码,用于在程序中执行特定的操作。Python 中有许多内置函数可供使用,您也可以编写自己的函数(稍后在本章中,您将学习如何编写自己的函数)。
在 Python 中,模块 是一个包含预定义函数和语句的文件,您可以在其他程序中使用它。例如,turtle 模块包含了很多有用的代码,当您安装 Python 时,它会自动下载这些代码。
尽管可以通过多种方式从模块中导入函数,但我们在这里使用一种简单的方法。在您刚刚创建的 myturtle.py 文件中,输入以下内容:
from turtle import *
from 命令表示我们正在从外部文件导入某些内容。然后我们给出要导入的模块名称,在这个例子中是 turtle。我们使用 import 关键字从海龟模块中获取我们需要的有用代码。我们在这里使用星号(*)作为 通配符命令,意味着“从该模块导入所有内容”。确保 import 和星号之间有一个空格。
保存文件,并确保它位于 Python 文件夹中;否则,程序会抛出错误。
警告
不要将文件保存为 turtle.py。这个文件名已经存在,会导致与海龟模块的导入冲突!其他任何文件名都可以使用: myturtle.py, turtle2.py, mondayturtle.py,等等。
移动你的海龟
现在你已经导入了海龟模块,准备输入指令来移动海龟了。我们将使用 forward() 函数(缩写为 fd)使海龟前进一定步数,并留下一个轨迹。请注意,forward() 是我们刚刚从海龟模块中导入的函数之一。输入以下内容以使海龟前进:
forward(100)
在这里,我们使用 forward() 函数,并在括号中输入数字 100,表示海龟应该走多少步。在这种情况下,100 是我们传递给 forward() 函数的 参数。所有函数都接受一个或多个参数。你可以随意传递其他数字给这个函数。当你按下 F5 运行程序时,一个新窗口应该会打开,中心有一个箭头,如 图 1-1 所示。

图 1-1: 运行你的第一行代码!
如你所见,海龟从屏幕中间开始,向前走了 100 步(实际上是 100 像素)。注意,默认形状是一个箭头,而不是海龟,且箭头默认朝右。要将箭头改为海龟,可以更新代码,使其如下所示:
*myturtle.py*
from turtle import *
forward(100)
shape('turtle')
正如你可能猜到的,shape() 是海龟模块中定义的另一个函数。它允许你将默认的箭头形状改为其他形状,如圆形、方形或箭头。在这里,shape() 函数接受字符串值 'turtle' 作为参数,而不是数字。(你将在下一章学习更多关于字符串和不同数据类型的内容。)保存并重新运行 myturtle.py 文件,你应该会看到类似于 图 1-2 的效果。

图 1-2: 将箭头改为海龟!
现在你的箭头应该变成一个小海龟了!
改变方向
海龟只能朝它当前面朝的方向前进。要改变海龟的方向,你必须先使用 right() 或 left() 函数让海龟转动指定的角度,然后才能前进。通过添加接下来显示的最后两行代码,更新你的 myturtle.py 程序:
*myturtle.py*
from turtle import *
forward(100)
shape('turtle')
right(45)
forward(150)
在这里,我们将使用right()函数(或简称rt())让乌龟在向前走 150 步之前先向右转 45 度。当你运行这段代码时,输出应该像图 1-3 一样。

图 1-3:改变乌龟的方向
正如你所看到的,乌龟从屏幕中间开始,向前走了 100 步,右转 45 度,然后又向前走了 150 步。注意 Python 按顺序运行每一行代码,从上到下。
练习 1-1:广场舞
返回到myturtle.py程序。你面临的第一个挑战是,仅使用forward和right函数修改程序中的代码,使得乌龟能够画出一个正方形。
使用循环重复代码
每种编程语言都有一种自动重复指定次数命令的方法。这非常有用,因为它可以避免你不断输入相同的代码,从而使程序变得冗长。它还可以帮助你避免拼写错误,避免程序无法正常运行。
使用 FOR 循环
在 Python 中,我们使用for循环来重复代码。我们还使用range关键字来指定循环执行的次数。打开 IDLE 中的一个新程序文件,保存为for_loop.py,然后输入以下内容:
*for_loop.py*
for i in range(2):
print('hello')
在这里,range()函数为每个for循环创建了i,即一个迭代器。迭代器是一个每次使用时都会增加的值。括号中的数字 2 是我们传递给函数的参数,用来控制其行为。这类似于我们在前面章节中传递给forward()和right()函数的不同值。
在这个例子中,range(2)创建了一个包含两个数字(0 和 1)的序列。对于这两个数字中的每一个,for命令执行冒号后面指定的动作,也就是打印单词hello。
确保你通过按 TAB 键(一个 TAB 等于四个空格)来缩进所有你想要重复执行的代码行。缩进告诉 Python 哪些行在循环内,这样for就能准确地知道要重复哪些代码。别忘了结尾的冒号,它告诉计算机接下来是什么代码将在循环中执行。当你运行程序时,你应该在 shell 中看到以下内容:
hello
hello
正如你所看到的,程序打印了两次hello,因为range(2)创建了一个包含两个数字(0 和 1)的序列。这意味着for命令会遍历序列中的两个项目,每次打印hello。让我们更新括号中的数字,像这样:
*for_loop.py*
for i in range(10):
print('hello')
当你运行这个程序时,应该会得到 10 次hello,像这样:
hello
hello
hello
hello
hello
hello
hello
hello
hello
hello
让我们试试另一个例子,因为你在本书中将写很多for循环:
*for_loop.py*
for i in range(10):
print(i)
由于在 Python 中计数是从 0 开始的,而不是从 1 开始,因此 for i in range(10) 会给我们提供从 0 到 9 的数字。这段示例代码的意思是“对于范围 0 到 9 中的每一个值,显示当前数字。”for 循环会重复执行代码,直到范围中的数字用完。运行这段代码,你应该会看到类似这样的输出:
0
1
2
3
4
5
6
7
8
9
未来你需要记住,在使用 range 的循环中,i 从 0 开始,并且在循环中的最后一个数字之前结束,但目前,如果你希望某些操作重复四次,你可以使用以下方式:
for i in range(4):
就这么简单!让我们看看如何将这个方法应用到实际中。
使用 for 循环绘制正方形
在练习 1-1 中,你的挑战是仅使用 forward() 和 right() 函数绘制一个正方形。为此,你必须将 forward(100) 和 right(90) 重复四次。但这需要多次输入相同的代码,既浪费时间又容易出错。
让我们使用 for 循环来避免重复相同的代码。以下是 myturtle.py 程序,它使用 for 循环来代替四次重复调用 forward() 和 right() 函数:
*myturtle.py*
from turtle import *
shape('turtle')
for i in range(4):
forward(100)
right(90)
请注意,shape('turtle') 应该紧跟在导入 turtle 模块之后并在开始绘图之前。for 循环中的两行代码告诉海龟向前走 100 步,然后右转 90 度。(你可能需要站在与海龟相同的方向上才能知道“右”是哪个方向!)因为正方形有四条边,所以我们使用 range(4) 来将这两行代码重复四次。运行程序后,你应该会看到类似于 图 1-4 的效果。

图 1-4:使用 for 循环绘制的正方形
你应该看到海龟向前移动并向右转四次,最终回到原来的位置。你已经成功使用 for 循环绘制了一个正方形!
使用函数创建快捷方式
现在我们已经写了代码来绘制一个正方形,我们可以将所有这些代码保存到一个魔法关键词中,随时调用它来再次使用这些正方形代码。每种编程语言都有一种方法来实现这一点,在 Python 中,它被称为函数,这是计算机编程中最重要的特性。函数使代码更加紧凑且易于维护,将一个问题分解成多个函数通常能让你看到最好的解决方法。之前你使用了一些内置函数,它们来自 turtle 模块。在本节中,你将学习如何定义自己的函数。
要定义一个函数,你首先需要给它起个名字。这个名字可以是任何你想要的,只要它不是已经是 Python 关键词,比如 list、range 等等。在命名函数时,最好具有描述性,这样你在以后使用它们时能记得它们的作用。我们把函数命名为 square(),因为我们将用它来绘制正方形:
*myturtle.py*
def square():
for i in range(4):
forward(100)
right(90)
def命令告诉 Python 我们正在定义一个函数,之后列出的单词将成为函数名;在这里,它是square()。不要忘记square后面的括号!它们是 Python 中表示你在处理一个函数的标志。稍后我们会将值放入括号中,但即使没有任何值,括号也需要包含在内,以告诉 Python 你在定义一个函数。此外,不要忘记在函数定义末尾加上冒号。请注意,我们会将函数内部的所有代码缩进,以告诉 Python 哪些代码属于该函数。
如果你现在运行这个程序,什么也不会发生。你已经定义了一个函数,但你还没有告诉程序运行它。要做到这一点,你需要在myturtle.py文件的函数定义后面调用该函数。输入 Listing 1-1 中显示的代码。
*myturtle.py*
from turtle import *
shape('turtle')
def square():
for i in range(4):
forward(100)
right(90)
square()
Listing 1-1:在文件末尾调用square()函数。
当你像这样在最后调用square()时,程序应该会正确运行。现在你可以在程序中的任何地方使用square()函数来快速绘制另一个正方形。
你还可以在循环中使用这个函数来构建更复杂的内容。例如,要画一个正方形,稍微右转,画另一个正方形,再稍微右转,重复这些步骤,把函数放进循环中是有意义的。
下一个练习展示了一种由正方形组成的有趣形状!可能需要一些时间才能让你的海龟完成这个形状,所以你可以通过在shape('turtle')后面添加speed()函数来加速它。在myturtle.py中使用speed(0)可以让海龟移动得最快,而speed(1)是最慢的。如果你想,也可以尝试不同的速度,比如speed(5)和speed(10)。
练习 1-2:正方形的圆
编写并运行一个函数,绘制 60 个正方形,每个正方形之后右转 5 度。使用循环!你的结果应该看起来像这样:

使用变量绘制图形
到目前为止,我们的所有正方形都是相同大小的。为了画不同大小的正方形,我们需要改变海龟每条边前进的距离。我们不必每次想要不同的大小时都改变square()函数的定义,而是可以使用一个变量,在 Python 中,变量是一个可以表示并且可以改变的值。它类似于代数中x可以代表一个可以变化的值。
在数学课上,变量通常是单个字母,但在编程中,你可以给变量任何你想要的名字!就像函数一样,我建议给变量起一个具有描述性的名字,这样可以让代码更容易阅读和理解。
在函数中使用变量
当你定义一个函数时,你可以在括号内使用变量作为函数的参数。例如,你可以将myturtle.py程序中square()函数的定义更改为以下内容,以便绘制任何大小的正方形,而不是固定大小:
*myturtle.py*
def square(sidelength):
for i in range(4):
forward(sidelength)
right(90)
这里,我们使用 sidelength 来定义 square() 函数。现在当你调用这个函数时,你必须在括号内放一个值,我们称这个值为 参数,括号中的任何数字都会代替 sidelength。例如,调用 square(50) 和 square(80) 会像 图 1-5 所示。

图 1-5:大小为 50 和大小为 80 的两个正方形
当你用变量来定义一个函数时,你可以通过输入不同的数字来直接调用 square() 函数,而不需要每次都更新函数定义。
变量错误
目前,如果我们忘记在函数的括号中填写一个值,就会出现这个错误:
Traceback (most recent call last):
File "C:/Something/Something/my_turtle.py", line 12, in <module>
square()
TypeError: square() missing 1 required positional argument: 'sidelength'
这个错误告诉我们缺少 sidelength 的值,因此 Python 不知道该把正方形做多大。为了避免这个问题,我们可以在函数定义的第一行给这个长度一个默认值,像这样:
def square(sidelength=100):
这里,我们在 sidelength 中放了一个默认值 100。现在,如果我们在 square 后的括号中放一个值,它将绘制该长度的正方形;但是如果我们把括号留空,它将默认绘制一个边长为 100 的正方形,并且不会报错。更新后的代码应该会生成 图 1-6 中的图像:
square(50)
square(30)
square()

图 1-6:默认大小为 100 的正方形、大小为 50 的正方形和大小为 30 的正方形
通过设置这样的默认值,可以更方便地使用我们的函数,而不用担心如果做错了什么会导致错误。在编程中,这被称为让程序变得更 健壮。
练习 1-3:尝试并再试
编写一个 triangle() 函数,绘制一个给定“边长”的三角形。
等边三角形
多边形 是一个具有多条边的图形。等边三角形 是一种特殊的多边形,具有三条相等的边。图 1-7 显示了它的样子。

图 1-7:等边三角形的角度,包括一个外角
等边三角形的三个内角都为 60 度。你可能记得几何课上有一个规则:等边三角形的三个角度加起来是 180 度。事实上,这对所有三角形都是成立的,不仅仅是等边三角形。
编写 triangle() 函数
让我们运用你迄今为止学到的知识,编写一个函数,让海龟沿三角形路径行走。由于等边三角形的每个角都是 60 度,你可以将 square() 函数中的 right() 移动角度更新为 60,像这样:
*myturtle.py*
def triangle(sidelength=100):
for i in range(3):
forward(sidelength)
right(60)
triangle()
但是,当你保存并运行这个程序时,你不会得到一个三角形。相反,你会看到类似 图 1-8 的内容。

图 1-8:第一次尝试绘制三角形
看起来我们开始画的是一个六边形(一个六边形多边形),而不是一个三角形。我们得到了六边形,而不是三角形,因为我们输入的是 60 度,这是等边三角形的内角。我们需要输入的是外角,而不是right()函数中的内角,因为海龟是根据外角来转动的,而不是内角。对于正方形来说,这不是问题,因为正方形的内角和外角恰好是相同的:90 度。
要找到三角形的外角,只需用 180 减去内角。这意味着等边三角形的外角是 120 度。将代码中的 60 更新为 120,你应该就能得到一个三角形。
练习 1-4:多边形函数
写一个名为polygon的函数,它接受一个整数作为参数,并让海龟画出一个有该整数个边的多边形。
使变量变化
我们可以对变量做更多的操作:我们可以自动增加变量的值,这样每次运行函数时,正方形就会比上一个大。例如,使用一个length变量,我们可以先画一个正方形,然后增加length变量一点,再画下一个正方形,像这样增加变量:
length = length + 5
作为一个数学爱好者,当我第一次看到这行代码时,它让我感到困惑!“length 等于 length + 5”怎么可能呢?这不可能!但代码不是方程式,在这种情况下,等号(=)并不意味着“这边等于那边”。在编程中,等号表示我们在给变量赋值。
以以下示例为例。打开 Python shell,并输入以下代码:
>>> radius = 10
这意味着我们正在创建一个名为radius的变量(如果它还不存在的话),并将其赋值为 10。你以后可以随时给它赋不同的值,比如这样:
radius = 20
按下 ENTER 键,你的代码将会被执行。这意味着值 20 将被赋给radius变量。要检查一个变量是否等于某个值,可以使用双等号(==)。例如,要检查radius变量的值是否为 20,你可以在 shell 中输入以下内容:
>>> radius == 20
按下 ENTER 键,它应该会打印出以下内容:
True
现在radius变量的值是 20。通常,比起手动给变量赋数值,增量操作会更有用。你可以使用一个名为count的变量来统计程序中某个事件发生的次数。它应该从 0 开始,并在每次事件发生后增加 1。为了让一个变量增加 1,你可以将它的值加 1,然后将新值赋给该变量,像这样:
count = count + 1
你也可以按以下方式编写代码,使其更加简洁:
count += 1
这意味着“给我的计数变量加 1”。你可以在这种表示法中使用加法、减法、乘法和除法。让我们通过在 Python shell 中运行这段代码来看看它是如何工作的。我们将x赋值为 12,y赋值为 3,然后让x增加y的值:
>>> x = 12
>>> y = 3
>>> x += y
>>> x
15
>>> y
3
注意y没有改变。我们可以通过加法、减法、乘法和除法来递增x,使用类似的符号表示法:
>>> x += 2
>>> x
17
现在,我们将把x设置为其当前值减去 1:
>>> x -= 1
>>> x
16
我们知道x是 16。现在我们将x设置为其当前值的两倍:
>>> x *= 2
>>> x
32
最后,我们可以通过将x除以 4 来将其设置为当前值的四分之一:
>>> x /= 4
>>> x
8.0
现在你知道如何使用算术运算符和等号递增一个变量。总之,x += 3将使x增加 3,而x -= 1将使其减少 1,以此类推。
你可以使用以下代码行让长度在每次循环时增加 5,这在接下来的练习中会非常有用:
length += 5
使用这种符号表示法,每次使用length变量时,都会向其值添加 5,并将结果保存到该变量中。
练习 1-5:TURTLE 螺旋
创建一个函数来绘制 60 个正方形,每绘制一个正方形后旋转 5 度,并使每个连续的正方形变大。从length为 5 开始,每个正方形增加 5 个单位。它应该像这样:

总结
在本章中,你学习了如何使用 Python 的 turtle 模块及其内置函数,如forward()和right()来绘制不同的图形。你还看到了 turtle 能够执行比我们在这里介绍的更多功能。还有很多其他的功能,我鼓励你在进入下一章之前先尝试一下。如果你在网上搜索“python turtle”,第一个结果很可能是官方 Python 网站上的 turtle 模块文档 (python.org/)。你将在该页面找到所有 turtle 方法,其中一些在图 1-9 中展示。

图 1-9:你可以在 Python 网站上找到更多的 turtle 函数和方法!
你学习了如何定义自己的函数,从而节省了可以随时重用的宝贵代码。你还学习了如何使用for循环多次运行代码,而无需重新编写代码。知道如何使用函数和循环节省时间并避免错误,将在你以后构建更复杂的数学工具时非常有用。
在下一章中,我们将基于你使用的基本算术运算符来递增变量。你将学习更多关于 Python 中基本运算符和数据类型的知识,以及如何使用它们来构建简单的计算工具。我们还将探索如何将项目存储在列表中,并使用索引访问列表项。
练习 1-6:星星的诞生
首先,编写一个“star”函数,它将绘制一个五角星,像这样:

接下来,编写一个名为starSpiral()的函数,它将绘制一个星星螺旋,像这样:

第二章:用列表和循环让繁琐的算术变得有趣
“你是说我明天还得再来一次?” — 艾丹·法雷尔在第一天上学后

大多数人在想到数学时,会想到算术:加法、减法、乘法和除法。虽然使用计算器和计算机做算术相当简单,但它仍然可能涉及很多重复的任务。例如,要使用计算器加上 20 个不同的数字,你必须输入加号运算符 19 次!
在本章中,你将学习如何使用 Python 自动化一些繁琐的算术操作。首先,你将了解数学运算符和 Python 中可以使用的不同数据类型。然后,你将学习如何使用变量来存储和计算值。你还将学习如何使用列表和循环来重复代码。最后,你将结合这些编程概念,编写函数来自动执行复杂的计算。你会发现,Python 可以比任何你能买到的计算器更强大,最棒的是,它是免费的!
基本运算符
在交互式 Python shell 中做算术很容易:你只需输入表达式,按下 ENTER 键即可进行计算。表 2-1 显示了一些最常见的数学运算符。
表 2-1:Python 中常见的数学运算符
| 运算符 | 语法 |
|---|---|
| 加法 | + |
| 减法 | – |
| 乘法 | * |
| 除法 | / |
| 指数 | ** |
打开你的 Python shell,尝试一下清单 2-1 中的基本算术示例。
>>> 23 + 56 #Addition
79
>>> 45 * 89 #Multiplication is with an asterisk
4005
>>> 46 / 13 #Division is with a forward slash
3.5384615384615383
>>> 2 ** 4 #2 to the 4th power
16
清单 2-1:尝试一些基本的数学运算符
答案应作为输出显示。你可以使用空格使代码更具可读性(6 + 5),也可以不使用空格(6+5),但是在做算术时,Python 对这些空格不会有任何影响。
请记住,在 Python 2 中,除法有点棘手。例如,Python 2 会对 46/13 进行整数除法,只返回整数结果(3),而不是返回小数值,如在清单 2-1 中所示。因为你安装的是 Python 3,所以不应遇到这个问题。不过我们接下来看到的图形包使用的是 Python 2,因此我们在除法时必须确保要求返回小数结果。
对变量进行操作
你也可以在变量上使用运算符。在第一章中,你学习了在定义函数时使用变量。像代数中的变量一样,编程中的变量可以通过存储结果并在之后再次使用,从而将复杂的计算分解为多个阶段。清单 2-2 展示了如何使用变量存储数字并对其进行操作,无论其值是什么。
>>> x = 5
>>> x = x + 2
>>> length = 12
>>> x + length
19
清单 2-2:在变量中存储结果
在这里,我们将值 5 赋给 x 变量,然后将其增加 2,使得 x 变成 7。接着,我们将值 12 赋给变量 length。当我们将 x 和 length 相加时,我们在加 7 + 12,所以结果是 19。
使用运算符编写 average() 函数
让我们练习使用运算符来计算一系列数字的平均值。正如你可能知道的,计算平均值的方法是将所有数字相加,再除以数字的个数。例如,如果你的数字是 10 和 20,你将 10 和 20 相加并将和除以 2,如下所示:
(10 + 20) / 2 = 15
如果你的数字是 9、15 和 23,你将它们相加并将和除以 3:
(9 + 15 + 23) / 3 = 47 / 3 = 15.67
手动做这件事可能会很繁琐,但用代码就简单多了。我们先创建一个名为 arithmetic.py 的 Python 文件,并编写一个函数来计算两个数字的平均值。你应该能够运行这个函数并传入两个数字作为参数,不需要任何运算符,最终输出平均值,如下所示:
>>> average(10,20)
15.0
让我们来试一下。
注意运算顺序!
我们的 average() 函数将两个数字 a 和 b 转换为它们和的一半,然后使用 return 关键字返回该值。这里是我们函数的代码:
*arithmetic.py*
def average(a,b):
return a + b / 2
我们定义了一个名为 average() 的函数,它需要两个数字 a 和 b 作为输入。我们编写该函数返回这两个数字的和除以 2。然而,当我们在 shell 中测试这个函数时,得到的结果是错误的:
>>> average(10,20)
20.0
之所以这样,是因为我们在编写函数时没有考虑到 运算顺序。你可能还记得数学课上,乘法和除法优先于加法和减法,所以在这个例子中,除法是先执行的。这个函数是先将 b 除以 2,然后 再 加上 a。那么我们该如何修正这个问题呢?
使用括号与运算符
我们需要使用括号来告诉 Python 先加两个数字,再进行除法运算:
*arithmetic.py*
def average(a,b):
return (a + b) / 2
现在,函数应该先加 a 和 b,再除以 2。以下是在 shell 中运行函数时发生的情况:
>>> average(10,20)
15.0
如果你手动执行相同的计算,你会发现输出是正确的!尝试使用不同的数字来调用 average() 函数。
PYTHON 中的数据类型
在继续对数字进行算术运算之前,让我们先了解一些基本的 Python 数据类型。不同的数据类型有不同的能力,并且你不能对所有数据类型执行相同的操作,因此了解每种数据类型的工作原理非常重要。
整数与浮点数
在 Python 中,你常常需要对两个数据类型进行操作,它们是整数和浮点数。整数是没有小数部分的数字。浮点数是包含小数的数字。你可以通过使用 float() 和 int() 函数,分别将整数转换为浮点数,反之亦然,像这样:
>>> x = 3
>>> x
3
>>> y = float(x)
>>> y
3.0
>>> z = int(y)
>>> z
3
在这个例子中,我们使用 x = 3 将值 3 赋给变量 x。然后我们使用 float(x) 将 x 转换为浮动类型,并将结果(3.0)赋给变量 y。最后,我们将 y 转换为整数并将结果(3)赋给变量 z。这展示了如何在浮动类型和整数之间轻松转换。
字符串
字符串 是按顺序排列的字母数字字符,可以是字母组成的词,或者是数字。你可以通过将字符放在单引号('')或双引号("")中来定义字符串,例如:
>>> a = "hello"
>>> a + a
'hellohello'
>>> 4*a
'hellohellohellohello'
在这里,我们将字符串 "hello" 存储在变量 a 中。当我们将变量 a 与自身相加时,我们得到一个新字符串 'hellohello',它是两个 hello 的组合。请记住,字符串和数字数据类型(整数和浮动类型)不能相加。如果你尝试将整数 2 与字符串 "hello" 相加,你将得到以下错误信息:
>>> b = 2
>>> b
2
>>> d = "hello"
>>> b + d
Traceback (most recent call last):
File "<pyshell#34>", line 1, in <module>
b + d
TypeError: unsupported operand type(s) for +: 'int' and 'str'
然而,如果一个数字是字符串(或者用引号括起来),你可以将它与另一个字符串相加,例如:
>>> b = '123'
>>> c = '4'
>>> b + c
'1234'
>>> 'hello' + ' 123'
'hello 123'
在这个例子中,'123' 和 '4' 都是由数字组成的字符串,而不是数字数据类型。因此,当你将这两个字符串相加时,你得到一个更长的字符串('1234'),它是这两个字符串的组合。你也可以用字符串 'hello' 和 ' 123' 做同样的事情,尽管一个是字母组成,另一个是数字组成。将字符串连接起来形成一个新字符串叫做 连接。
你也可以通过将字符串乘以一个整数来重复该字符串,例如:
>>> name = "Marcia"
>>> 3 * name
'MarciaMarciaMarcia'
但你不能将一个字符串减去、乘以或除以另一个字符串。在命令行中输入以下内容,看看会发生什么:
>>> noun = 'dog'
>>> verb = 'bark'
>>> noun * verb
Traceback (most recent call last):
File "<pyshell#6>", line 1, in <module>
noun * verb
TypeError: can't multiply sequence by non-int of type 'str'
正如你所看到的,当你尝试将 'dog' 和 'bark' 相乘时,你会得到一个错误,告诉你不能将两个字符串数据类型相乘。
布尔值
布尔值 是真/假的值,这意味着它们只能是其中之一,没有中间值。布尔值在 Python 中必须大写,通常用于比较两个事物的值。要比较值,你可以使用大于(>)和小于(<)符号,如下所示:
>>> 3 > 2
True
因为 3 大于 2,所以这个表达式返回 True。但是检查两个值是否相等需要两个等号(==),因为一个等号只是将值赋给变量。这里是一个例子,展示了如何工作:
>>> b = 5
>>> b == 5
True
>>> b == 6
False
首先,我们使用一个等号将值 5 赋给变量 b。然后我们使用两个等号检查 b 是否等于 5,这会返回 True。
检查数据类型
你可以通过将一个变量传递给 type() 函数来始终检查你正在处理的数据类型。Python 会方便地告诉你该变量中的值是什么数据类型。例如,我们可以将一个布尔值赋给一个变量,如下所示:
>>> a = True
>>> type(a)
<class 'bool'>
当你将变量 a 传递给 type() 函数时,Python 会告诉你变量 a 中的值是布尔类型。
尝试检查一个整数的数据类型:
>>> b = 2
>>> type(b)
<class 'int'>
以下代码检查 0.5 是否是浮动类型:
>>> c = 0.5
>>> type(c)
<class 'float'>
这个例子确认了引号中的字母数字符号是字符串:
>>> name = "Steve"
>>> type(name)
<class 'str'>
现在你已经了解了 Python 中的不同数据类型以及如何检查你正在处理的值的数据类型,让我们开始自动化简单的算术任务吧。
使用列表存储值
到目前为止,我们一直使用变量来保存单个值。列表是一种可以保存多个值的变量类型,这在自动化重复任务时非常有用。要在 Python 中声明一个列表,你只需要为列表创建一个名称,像使用变量一样使用=命令,然后将你想放入列表的元素用方括号[]括起来,并用逗号分隔每个元素,如下所示:
>>> a = [1,2,3]
>>> a
[1, 2, 3]
经常会创建一个空列表,这样以后可以向其中添加值,比如数字、坐标和对象。为了做到这一点,只需像平常一样创建列表,但不添加任何值,如下所示:
>>> b = []
>>> b
[]
这会创建一个空列表b,你可以将不同的值填充到其中。让我们看看如何向列表添加元素。
向列表添加元素
要向列表添加一个元素,请使用append()函数,如下所示:
>>> b.append(4)
>>> b
[4]
首先,键入你想要添加元素的列表名称(b),然后加一个句点,接着使用append()并在括号内指定你想添加的元素。你可以看到列表现在仅包含数字 4。
你也可以向非空列表添加元素,像这样:
>>> b.append(5)
>>> b
[4, 5]
>>> b.append(True)
>>> b
[4, 5, True]
添加到现有列表的元素会出现在列表的末尾。如你所见,列表不一定只能包含数字。这里,我们将布尔值True添加到包含数字 4 和 5 的列表中。
单个列表也可以包含多个数据类型。例如,你可以像这里这样将文本作为字符串添加进去:
>>> b.append("hello")
>>> b
[4, 5, True, 'hello']
要添加一个字符串,你需要在文本周围加上双引号或单引号。否则,Python 会查找名为hello的变量,这个变量可能存在也可能不存在,从而导致错误或意外的行为。现在你在列表b中有四个元素:两个数字,一个布尔值和一个字符串。
列表操作
和字符串一样,你可以在列表上使用加法和乘法运算符,但你不能直接将数字和列表相加。相反,你必须使用连接操作符将其附加到列表上。
例如,你可以使用+操作符将两个列表相加,像这样:
>>> c = [7,True]
>>> d = [8,'Python']
>>> c + d #adding two lists
[7, True, 8, 'Python']
我们还可以通过一个数字来乘以一个列表,像这样:
>>> 2 * d #multiplying a list by a number
[8, 'Python', 8, 'Python']
如你所见,将数字 2 与列表d相乘会使原始列表中的元素数量翻倍。
但是,当我们尝试用加号操作符将数字和列表相加时,会出现一个错误,称为类型错误(TypeError):
>>> d + 2 #you can't add a list and an integer
Traceback (most recent call last):
File "<pyshell#22>", line 1, in <module>
d + 2
TypeError: can only concatenate list (not "int") to list
这是因为你不能用加号将数字和列表相加。虽然你可以将两个列表相加,将一个元素添加到列表中,甚至将一个列表与一个数字相乘,但你只能将列表连接到另一个列表。
从列表中移除元素
从列表中移除一个元素也很简单:你可以使用remove()函数,传入你要移除的元素作为参数,如下所示。确保引用你要移除的元素时与代码中完全一致,否则 Python 无法理解应该删除什么。
>>> b = [4,5,True,'hello']
>>> b.remove(5)
>>> b
[4, True, 'hello']
在这个例子中,b.remove(5)从列表中移除5,但注意,其他项的顺序保持不变。顺序被保持这一点将变得很重要。
在循环中使用列表
在数学中,你常常需要对多个数字应用相同的操作。例如,一本代数书可能定义了一个函数,并要求你将一堆不同的数字代入该函数。你可以通过将数字存储在列表中,然后使用在第一章中学习到的for循环,对列表中的每一项执行相同的操作来完成此任务。记住,当你反复执行某个操作时,这叫做迭代。迭代器是for i in range(10)中的变量i,我们在之前的程序中使用过,但它不一定总是叫i;它可以是任何你想要的名字,就像这个例子:
>>> a = [12,"apple",True,0.25]
>>> for thing in a:
print(thing)
12
apple
True
0.25
这里,迭代器被称为thing,它对列表a中的每一项应用print()函数。注意,项是按顺序打印的,每项占一行。如果要将所有内容打印在同一行,你需要在print()函数中添加end参数并设置为空字符串,如下所示:
>>> for thing in a:
print(thing, end='')
12appleTrue0.25
这会将所有项打印在同一行,但所有值会连在一起,使得它们难以区分。end参数的默认值是换行符,如你在前面的例子中看到的那样,但你可以通过在引号中插入任何字符或标点来更改它。这里我添加了一个逗号:
>>> a = [12,"apple",True,0.25]
>>> for thing in a:
print(thing, end=',')
12,apple,True,0.25,
现在每项之间用逗号分隔,这样更容易阅读。
使用列表索引访问单个项
你可以通过指定列表的名称,然后在方括号中输入索引来引用列表中的任何元素。索引是列表中项的位置或编号。列表的第一个索引是 0。索引使我们能够使用有意义的名称来存储一系列值,并在程序中轻松访问它们。你可以在 IDLE 中尝试以下代码,看看索引是如何工作的:
>>> name_list = ['Abe','Bob','Chloe','Daphne']
>>> score_list = [55,63,72,54]
>>> print(name_list[0], score_list[0])
Abe 55
索引也可以是一个变量或迭代器,如下所示:
>>> n = 2
>>> print(name_list[n], score_list[n+1])
Chloe 54
>>> for i in range(4):
print(name_list[i], score_list[i])
Abe 55
Bob 63
Chloe 72
Daphne 54
使用enumerate()获取索引和值
要获取列表中项的索引和值,你可以使用一个方便的函数,叫做enumerate()。它的工作原理如下:
>>> name_list = ['Abe','Bob','Chloe','Daphne']
>>> for i, name in enumerate(name_list):
print(name,"has index",i)
Abe has index 0
Bob has index 1
Chloe has index 2
Daphne has index 3
这里,name是列表中项的值,而i是索引。使用enumerate()时需要记住的重要事项是,索引先出现,然后是值。你将在后面看到,当我们将对象放入列表并同时访问对象及其在列表中的位置时,这一点非常重要。
索引从零开始
在第一章中,你学习了range(n)函数,它生成一个从 0 开始,到n(但不包括n)的数字序列。类似地,列表索引从 0 开始,而不是 1,因此第一个元素的索引是0。尝试以下操作,看看它是如何工作的:
>>> b = [4,True,'hello']
>>> b[0]
4
>>> b[2]
'hello'
在这里,我们创建了一个名为 b 的列表,然后请求 Python 显示列表 b 中索引为 0 的项目,也就是第一个位置。因此,我们得到 4。当我们请求列表 b 中索引为 2 的项目时,我们得到 'hello'。
访问一系列列表项目
你可以在方括号内使用范围(:)语法来访问列表中的一系列元素。例如,要返回从列表中的第二项到第六项的所有元素,可以使用以下语法:
>>> myList = [1,2,3,4,5,6,7]
>>> myList[1:6]
[2, 3, 4, 5, 6]
需要注意的是,1:6 范围语法包含该范围中的 第一个 索引 1,但 不包括 最后一个索引 6。这意味着范围 1:6 实际上给我们返回的是索引 1 到 5 之间的项目。
如果你没有指定范围的结束索引,Python 会默认使用列表的长度。默认情况下,它返回从第一个索引到列表末尾的所有元素。例如,你可以使用以下语法访问列表 b 中从第二个元素(索引 1)到列表末尾的所有内容:
>>> b[1:]
[True, 'hello']
如果你没有指定起始位置,Python 会默认从列表的第一个项目开始,并且不会包括结束索引,如下所示:
>>> b[:1]
[4]
在这个例子中,b[:1] 包含第一个项目(索引 0),但不包括索引为 1 的项目。一个非常有用的知识是,你可以通过使用负数来访问列表中的最后几个元素,即使你不知道它的长度。要访问最后一项,你可以使用 -1,要访问倒数第二项,你可以使用 -2,像这样:
>>> b[-1]
'hello'
>>> b[-2]
True
当你使用他人制作的列表或使用非常长的列表时,这非常有用,因为在这些情况下很难跟踪所有的索引位置。
查找项目的索引
如果你知道某个值在列表中,但不知道它的索引,可以通过给出列表名称,后跟index函数,并将你要搜索的值作为其参数放在括号中来找到它的位置。在 Shell 中,创建列表 c,如下面所示,然后尝试以下操作:
>>> c = [1,2,3,'hello']
>>> c.index(1)
0
>>> c.index('hello')
3
>>> c.index(4)
Traceback (most recent call last):
File "<pyshell#85>", line 1, in <module>
b.index(4)
ValueError: 4 is not in list
你可以看到,查询值 1 返回索引 0,因为它是列表中的第一个项目。当你查询 'hello' 的索引时,它告诉你是 3。然而,最后一次尝试会导致错误信息。如错误信息的最后一行所示,错误的原因是我们要找的值 4 不在列表中,因此 Python 无法返回它的索引。
要检查某个项目是否存在于列表中,使用 in 关键字,像这样:
>>> c = [1,2,3,'hello']
>>> 4 in c
False
>>> 3 in c
True
在这里,如果某个项目在列表中,Python 会返回 True,如果不在列表中,则返回 False。
字符串也使用索引
你学到的关于列表索引的所有知识同样适用于字符串。一个字符串有一个长度,字符串中的所有字符都有索引。在 Shell 中输入以下命令,看看它是如何工作的:
>>> d = 'Python'
>>> len(d) #How many characters are in 'Python'?
6
>>> d[0]
'P'
>>> d[1]
'y'
>>> d[-1]
'n'
>>> d[2:]
'thon'
>>> d[:5]
'Pytho'
>>> d[1:4]
'yth'
在这里,你可以看到字符串 'Python' 由六个字符组成。每个字符都有一个索引,你可以使用与访问列表相同的语法来访问它们。
总结
当你在循环中加一堆数字时,跟踪这些数字的累加和非常有用。保持累加和是一个重要的数学概念,叫做 求和。
在数学课上,你经常会看到求和符号与一个大写的希腊字母 sigma 关联,代表 S(和)。其表示形式如下:

求和符号表示你将 n 替换为 i,从最小值(列在 sigma 下方)开始,一直到最大值(列在 sigma 上方)。与 Python 的 range(n) 不同,求和符号包括最大值。
创建 RUNNING_SUM 变量
要在 Python 中编写求和程序,我们可以创建一个名为 running_sum 的变量(sum 已经是 Python 内置的函数)。我们首先将其初始化为零,然后每次添加值时递增 running_sum 变量。为此,我们再次使用 += 符号。你可以在命令行中输入以下内容查看示例:
>>> running_sum = 0
>>> running_sum += 3
>>> running_sum
3
>>> running_sum += 5
>>> running_sum
8
你学会了如何使用 += 命令作为快捷方式:使用 running_sum += 3 等同于 running_sum = running_sum + 3。让我们通过多次递增累加和来测试它。为此,请将以下代码添加到 arithmetic.py 程序中:
*arithmetic.py*
running_sum = 0
➊ for i in range(10):
➋ running_sum += 3
print(running_sum)
我们首先创建一个值为 0 的 running_sum 变量,然后使用 range(10) 运行 for 循环 10 次 ➊。循环体中的缩进部分在每次循环时都会将 3 加到 running_sum 的值中 ➋。当循环执行完 10 次后,Python 会跳转到最后一行代码,这里是 print 语句,用于显示 running_sum 在 10 次循环后的值。
从这里,你可能能够推算出最终的和,下面是输出结果:
30
换句话说,10 乘以 3 等于 30,因此输出是有道理的!
编写 MYSUM() 函数
让我们将我们的累加和程序扩展为一个名为 mySum() 的函数,它接受一个整数作为参数,并返回从 1 到指定数字的所有数字的和,如下所示:
>>> mySum(10)
55
首先,我们声明累加和的值,然后在循环中递增它:
*arithmetic.py*
def mySum(num):
running_sum = 0
for i in range(1,num+1):
running_sum += i
return running_sum
为了定义 mySum() 函数,我们将累加和从 0 开始。然后,我们设置一个 i 的取值范围,从 1 到 num。请注意,range(1,num) 不包括 num 本身!接着,我们在每次循环后将 i 加到累加和中。当循环结束时,它应该返回累加和的值。
使用一个更大的数字在命令行中运行该函数。它应该能迅速返回从 1 到该数字的所有数字的和:
>>> mySum(100)
5050
非常方便!要解决我们之前提到的更复杂的 sigma 问题,只需将循环修改为从 0 到 20(包括 20),并在每次循环时加上 i 的平方加 1:
*arithmetic.py*
def mySum2(num):
running_sum = 0
for i in range(num+1):
running_sum += i**2 + 1
return running_sum
我将循环修改为从 0 开始,正如 sigma 符号所示:

当我们运行这个时,得到的结果是:
>>> mySum2(20)
2891
练习 2-1:求和
求 1 到 100 的所有数字之和。那 1 到 1,000 呢?有没有发现什么规律?
求一组数字的平均值
现在你已经掌握了一些新技能,让我们来改进我们的平均值函数。我们可以编写一个函数,使用列表来计算任何数字列表的平均值,而无需指定数字的数量。
在数学课上,你学到要计算一组数字的平均值,方法是将这些数字的和除以数字的个数。在 Python 中,你可以使用名为sum()的函数来将列表中所有数字相加,像这样:
>>> sum([8,11,15])
34
现在我们只需要找出列表中的项数。在本章前面编写的average()函数中,我们知道只有两个数字。但如果有更多呢?幸运的是,我们可以使用len()函数来计算列表中的项数。下面是一个示例:
>>> len([8,11,15])
3
如你所见,你只需输入函数并将列表作为参数传递。这意味着我们可以同时使用sum()和len()函数,通过将列表的和除以列表的长度来求得列表中项的平均值。利用这些内置的关键字,我们可以创建一个简洁的平均值函数,它的样子可能是这样的:
*arithmetic.py*
def average3(numList):
return sum(numList)/len(numList)
当你在命令行中调用这个函数时,你应该得到以下输出:
>>> average3([8,11,15])
11.333333333333334
这个版本的平均值函数的好处是,它既适用于短列表,也适用于长列表!
练习 2-2:求平均值
求下面列表中数字的平均值:
d = [53, 28, 54, 84, 65, 60, 22, 93, 62, 27, 16, 25, 74, 42, 4, 42,
15, 96, 11, 70, 83, 97, 75]
总结
在本章中,你学习了数据类型,如整数、浮点数和布尔值。你学会了如何创建列表、添加和删除列表中的元素,并使用索引查找列表中的特定项。接着,你学习了如何使用循环、列表和变量来解决算术问题,比如求一组数字的平均值和保持累计和。
在下一章中,你将学习条件语句,这是另一个重要的编程概念,学习它你才能应对本书的其余部分。
第三章:使用条件语句进行猜测和检查
*“当它热时,将面团放入烤箱:确保它确实是面团之后。” — 伊德里斯·沙阿,《学习如何学习》

在你为本书编写的几乎每个程序中,你都会指示计算机做出决策。你可以使用一个非常重要的编程工具,称为条件语句,来做到这一点。在编程中,我们可以使用条件语句
比如“如果这个变量大于 100,就做这个;否则,做那个”,用来检查某些条件是否满足,然后根据结果决定接下来该做什么。事实上,这是一种非常强大的方法,我们在解决大问题时会用到,甚至它是机器学习的核心。在最基本的层面上,程序是在进行猜测,然后根据反馈修改其猜测。
在本章中,你将学习如何使用猜测与检查方法,通过 Python 获取用户输入,并根据输入告诉程序打印什么内容。然后你将使用条件语句比较不同的数值,在不同的数学情境下使海龟在屏幕上随机漫游。你还将创建一个猜数字游戏,并使用相同的逻辑来找到大数的平方根。
比较运算符
正如你在第二章中学到的,True 和 False(在 Python 中我们将其大写)称为布尔值。Python 在比较两个值时会返回布尔值,你可以使用这些结果来决定接下来要做什么。例如,我们可以使用比较运算符,如大于(>)或小于(<)来比较两个值,像这样:
>>> 6 > 5
True
>>> 6 > 7
False
在这里,我们让 Python 判断 6 是否大于 5,Python 返回True。接着我们问 6 是否大于 7,Python 返回False。
回顾一下,在 Python 中我们使用一个等号来给变量赋值。但检查相等性需要两个等号(==),如下所示:
>>> 6 = 6
SyntaxError: can't assign to literal
>>> 6 == 6
True
如你所见,当我们尝试只使用一个等号进行检查时,会出现语法错误。我们还可以使用比较运算符来比较变量:
>>> y = 3
>>> x = 4
>>> y > x
False
>>> y < 10
True
我们将变量y设置为 3,然后将变量x设置为 4。接着,我们使用这些变量来判断y是否大于x,因此 Python 返回False。然后我们问y是否小于 10,返回True。这就是 Python 如何进行比较的方式。
使用 IF 和 ELSE 语句进行决策
你可以通过 if 和 else 语句让程序决定运行哪些代码。例如,如果你设定的条件为 True,程序将运行一组代码。如果条件为 False,你可以让程序做其他事情,甚至什么也不做。以下是一个例子:
>>> y = 7
>>> if y > 5:
print("yes!")
yes!
在这里,我们说,将变量y的值设为 7。如果y的值大于 5,则打印“yes!”;否则,不做任何事情。
你还可以为你的程序提供备用代码,通过使用else和elif来运行。由于我们将编写一些较长的代码,打开一个新的 Python 文件并将其保存为conditionals.py。
*conditionals.py*
y = 6
if y > 7:
print("yes!")
else:
print("no!")
在这个例子中,我们说的是,如果y的值大于 7,则打印“yes!”;否则,打印“no!”。运行这个程序,它应该打印“no!”,因为 6 并不大于 7。
你可以使用elif添加更多的替代条件,elif是“else if”的缩写。你可以根据需要添加任意数量的elif语句。下面是一个包含三个elif语句的示例程序:
*conditionals.py*
age = 50
if age < 10:
print("What school do you go to?")
elif 11 < age < 20:
print("You're cool!")
elif 20 <= age < 30:
print("What job do you have?")
elif 30 <= age < 40:
print("Are you married?")
else:
print("Wow, you're old!")
这个程序会根据age值所在的特定范围执行不同的代码。注意,你可以使用<=表示“小于或等于”,并且可以使用复合不等式,如if 11 < age < 20:表示“如果年龄在 11 到 20 之间”。例如,当age = 50时,输出将是以下字符串:
Wow, you're old!
能够根据你定义的条件快速且自动地让程序做出决策,是编程中的一个重要方面!
使用条件语句查找因子
现在让我们使用到目前为止学到的知识来因数化一个数字!因子是能够整除另一个数字的数字;例如,5 是 10 的因子,因为我们可以用 5 整除 10。在数学课上,我们使用因子来做各种事情,从寻找最小公倍数到判断一个数字是否是质数。然而,手动寻找因子可能是一个繁琐的任务,特别是当处理较大的数字时,涉及大量的反复试探。让我们看看如何使用 Python 来自动化因数分解。
在 Python 中,你可以使用取余运算符(%)来计算两个数字相除的余数。例如,如果a % b等于零,表示b能整除a。下面是一个取余操作的示例:
>>> 20 % 3
2
这表明,当你将 20 除以 3 时,得到的余数是 2,这意味着 3 不是 20 的因子。让我们试试 5:
>>> 20 % 5
0
现在我们得到了余数为零,所以我们知道 5 是 20 的因子。
编写 FACTORS.PY 程序
让我们使用取余运算符来编写一个函数,接收一个数字并返回该数字的所有因子。我们不仅仅打印因子,而是将它们放入一个列表中,以便稍后可以在另一个函数中使用这个因子列表。在开始编写这个程序之前,先规划一下我们的步骤。以下是factors.py程序的步骤:
-
定义
factors函数,接收一个数字作为参数。 -
创建一个空的因子列表,准备填充因子。
-
循环遍历从 1 到给定数字的所有数字。
-
如果这些数字中有任何一个能够整除,则将其添加到因子列表中。
-
在最后返回因子列表。
列表 3-1 显示了factors()函数。将这段代码输入到 IDLE 中的一个新文件,并将其保存为factors.py。
*factors.py*
def factors(num):
'''returns a list of the factors of num'''
factorList = []
for i in range(1,num+1):
if num % i == 0:
factorList.append(i)
return factorList
列出 3-1:编写 factors.py 程序
我们首先创建一个空列表 factorList,稍后在找到因数时将其填充。然后我们开始一个循环,从 1 开始(因为不能除以零),以 num + 1 结束,这样循环就会包括 num。在循环内部,我们指示程序做出决策:如果 num 能被当前的 i 整除(余数为 0),则程序将 i 添加到因数列表中。最后,我们返回因数列表。
现在通过按下 F5 键或点击 Run ▸ Run Module 来运行 factors.py,如 图 3-1 所示。

图 3-1:运行 factors.py 模块
运行此模块后,你可以在普通的 IDLE 终端中使用 factors 函数,通过传入一个你想找到因数的数字,例如这样:
>>> factors(120)
[1, 2, 3, 4, 5, 6, 8, 10, 12, 15, 20, 24, 30, 40, 60, 120]
你已经使用 factors 函数找到了 120 的所有因数!这比使用试错法要容易和快速得多。
练习 3-1:寻找因数
factors() 函数对于找到两个数字的最大公因数(GCF)很有用。编写一个函数来返回两个数字的 GCF,如下所示:
>>> gcf(150,138)
6
漫游的海龟
现在你知道如何指示程序自动做出决策,让我们探索如何让程序无限期地执行!首先,我们将让海龟在屏幕上四处走动,并使用条件语句让它在超出某个点时转身。
海龟的窗口是一个经典的 x-y 坐标系,默认情况下 x 轴和 y 轴的范围是从 -300 到 300。让我们将海龟的位置限制在 x 和 y 的 -200 到 200 之间,如 图 3-2 所示。

图 3-2:海龟受限的坐标矩形
在 IDLE 中打开一个新的 Python 文件,并将其保存为 wander.py。首先,让我们导入 turtle 模块。为此,添加以下代码:
from turtle import *
from random import randint
请注意,我们还需要从 random 模块导入 randint 函数来生成随机整数。
编写 wander.py 程序
现在让我们创建一个名为 wander 的函数,让海龟在屏幕上漫游,如 清单 3-2 所示。为此,我们使用 Python 的无限 while True 循环,该循环始终评估为 True。这将使海龟不停地漫游。要停止它,你可以点击海龟图形窗口上的 X。
*wander.py*
speed(0)
def wander():
while True:
fd(3)
if xcor() >= 200 or xcor() <= -200 or ycor()<= -200 or ycor() >= 200:
lt(randint(90,180))
wander()
清单 3-2:编写 wander.py 程序
首先,我们将海龟的速度设置为 0,这是最快的速度,然后定义 wander() 函数。在函数内部,我们使用无限循环,所以 while True 内部的所有内容都会永远执行。然后海龟向前走三步(或 3 个像素),并使用条件语句评估其位置。海龟的 x 坐标和 y 坐标分别是 xcor() 和 ycor() 函数。
使用if语句,我们告诉程序,如果任何一个条件语句为True(海龟在指定区域外),那么让海龟随机转动 90 到 180 度之间的角度,防止它偏离方向。如果海龟在矩形内,条件判断为假,程序不执行任何操作。无论哪种情况,程序会返回到while True循环的顶部,并再次执行fd(3)。
运行 wander.py 程序
当你运行wander.py程序时,你应该会看到类似于图 3-3 的内容。

图 3-3:wander.py 程序的输出
如你所见,海龟会沿着直线走,直到它的 x 坐标达到 200。(海龟总是从右向左走,沿着正 x 方向。)然后它会随机转动 90 到 180 度之间的角度,继续直线前进。有时,海龟会走出边界,因为它在 90 度转弯后仍然指向屏幕外,你会看到它每次循环时都会转身,试图重新进入矩形区域。这就是图 3-3 中你看到的矩形外的小点。
创建一个数字猜测游戏
你成功地使用了条件语句创建了一个似乎能自己做决定的海龟!让我们继续使用条件语句来编写一个互动的数字猜测程序,让它看起来像是有意识的。在这个游戏中,我会想一个 1 到 100 之间的数字,你来猜这个数字是多少。你认为你需要多少次猜测才能正确猜出我的数字?为了缩小选择范围,在每次猜错之后,我会告诉你是猜高一点还是低一点。幸运的是,我们可以使用在第二章中编写的average函数,让这个任务变得极其简单。
当你做出错误的猜测时,你的下一个猜测应该取决于你之前的猜测是过低还是过高。例如,如果你的猜测太低,那么下一个猜测应该是你上次猜测和最大可能值之间的中间数。如果你的猜测太高,那么下一个猜测应该是你上次猜测和最小可能值之间的中间数。
这听起来像是在计算两个数字的平均值——幸运的是,我们有average函数!我们将利用它编写numberGame.py程序,该程序通过每次将可能的数字范围缩小一半来做出智能猜测。你会惊讶于自己能多快找出答案。
让我们一步一步来,首先从制作一个随机数生成器开始。
制作一个随机数生成器
首先,我们需要让计算机随机选择一个 1 到 100 之间的数字。在 IDLE 中创建一个新文件并保存为numberGame.py。然后输入清单 3-3 中的代码。
*number Game.py*
from random import randint
def numberGame():
#choose a random number
#between 1 and 100
number = randint(1,100)
清单 3-3:编写numberGame()函数
在这里,我们导入 random 模块,并使用 randint() 函数将一个随机整数分配给一个变量。然后我们创建一个 number 变量,它将存储一个 1 到 100 之间的随机数字,每次调用时都会生成。
获取用户输入
现在程序需要让用户输入,以便他们可以进行猜测!以下是你可以在交互式 shell 中输入的示例,看看 input() 函数是如何工作的:
>>> name = input("What's your name? ")
What's your name?
程序在 shell 中打印出“你叫什么名字?”提示用户输入他们的名字。用户输入一些内容,按下回车键,程序保存输入。
我们可以检查 Python 是否将用户输入保存到 name 变量中,像这样:
What's your name? Peter
>>> print(name)
Peter
当我们让程序打印 name 时,它会打印出保存在该变量中的用户输入(在这个例子中是 Peter)。
我们可以创建一个名为 greet() 的函数,以便在程序的后续部分使用:
def greet():
name = input("What's your name? ")
print("Hello, ",name)
greet()
输出将如下所示:
>>>
What's your name? Al
Hello, Al
>>>
尝试编写一个简单的程序,接受用户的名字作为输入,如果他们输入“Peter”,程序将打印“那也是我的名字!”如果名字不是“Peter”,它只会打印“Hello”和名字。
将用户输入转换为整数
现在你知道如何处理用户输入的文本,但我们将在猜数字游戏中接受数字输入。在第二章中,你学习了基本数据类型,比如整数和浮点数,你可以使用它们进行数学运算。在 Python 中,所有用户输入的内容始终被作为 字符串 处理。这意味着,如果我们需要数字输入,我们必须将它们转换为整数数据类型,这样才能在运算中使用。
要将字符串转换为整数,我们将输入传递给 int(),像这样:
print("I'm thinking of a number between 1 and 100.")
guess = int(input("What's your guess? "))
现在,无论用户输入什么,都会被转换为 Python 可以操作的整数。
使用条件语句检查是否猜对
现在 numberGame.py 程序需要一种方法来检查用户猜测的数字是否正确。如果正确,我们会宣布猜对了并结束游戏。否则,我们告诉用户应该猜更高还是更低。
我们使用 if 语句将输入与 number 的内容进行比较,使用 elif 和 else 来决定在每种情况下应该做什么。修改现有代码 numberGame.py,使其看起来像 列出 3-4 中的代码。
*number Game.py*
from random import randint
def numberGame():
#choose a random number
#between 1 and 100
number = randint(1,100)
print("I'm thinking of a number between 1 and 100.")
guess = int(input("What's your guess? "))
if number == guess:
print("That's correct! The number was", number)
elif number > guess:
print("Nope. Higher.")
else:
print("Nope. Lower.")
numberGame()
列出 3-4:检查是否猜对
如果存储在 number 中的随机数字与存储在 guess 中的输入相等,我们会告诉用户他们的猜测是正确的,并打印随机数字。否则,我们告诉用户是否需要猜更高或更低。如果他们猜的数字低于随机数字,我们会告诉他们猜更高。如果他们猜得更高,我们会告诉他们猜更低。
以下是到目前为止的输出示例:
I'm thinking of a number between 1 and 100.
What's your guess? 50
Nope. Higher.
很不错,但目前我们的程序到此为止,并没有让用户进行更多猜测。我们可以使用循环来解决这个问题。
使用循环再次猜测!
为了让用户再次猜测,我们可以创建一个循环,让程序不断请求更多的猜测,直到用户猜对为止。我们使用 while 循环来持续循环,直到 guess 等于 number,然后程序会打印出成功信息并跳出循环。用 Listing 3-4 中的代码替换为 Listing 3-5 中的代码。
*number Game.py*
from random import randint
def numberGame():
#choose a random number
#between 1 and 100
number = randint(1,100)
print("I'm thinking of a number between 1 and 100.")
guess = int(input("What's your guess? "))
while guess:
if number == guess:
print("That's correct! The number was", number)
break
elif number > guess:
print("Nope. Higher.")
else:
print("Nope. Lower.")
guess = int(input("What's your guess? "))
numberGame()
Listing 3-5: 使用循环允许用户再次猜测
在这个例子中,while guess 表示“当变量 guess 包含一个值时”。首先,我们检查它选择的随机数是否等于猜测值。如果是,程序会打印出猜测正确并跳出循环。如果数字大于猜测值,程序会提示用户猜更大一些。否则,程序会打印出需要猜更小一些。然后,程序会接受下一个猜测并重新开始循环,允许用户根据需要多次猜测直到猜对为止。最后,当我们定义完函数后,写上 numberGame() 来调用这个函数,这样程序就可以运行了。
猜测技巧
保存 numberGame.py 程序并运行它。每次你猜错时,你的下一个猜测应该恰好位于你的第一个猜测与范围的最近端之间。例如,如果你从猜测 50 开始,程序告诉你猜得更大一些,那么你的下一个猜测应该位于 50 和范围顶部 100 之间的中点,即猜 75。
这是到达正确数字的最有效方法,因为每次猜测时,你都在排除一半可能的数字,无论猜测是太高还是太低。让我们看看需要多少次猜测才能猜中 1 到 100 之间的一个数字。图 3-4 展示了一个例子。

Figure 3-4: 数字猜测游戏的输出
这次共猜了六次。
让我们看看在将 100 乘以二分之一后,能重复多少次,直到得到小于 1 的数字:
>>> 100*0.5
50.0
>>> 50*0.5
25.0
>>> 25*0.5
12.5
>>> 12.5*0.5
6.25
>>> 6.25*0.5
3.125
>>> 3.125*0.5
1.5625
>>> 1.5625*0.5
0.78125
要得到小于 1 的数字需要七次猜测,因此平均而言,猜一个 1 到 100 之间的数字大约需要六到七次尝试。这是因为每次猜测时,我们都在排除范围中一半的数字。这个策略可能看起来只适用于数字猜测游戏,但我们可以用这个相同的思路来找到一个数字的平方根的非常准确的值,接下来我们就要这样做。
寻找平方根
你可以使用数字猜测游戏的策略来近似计算平方根。正如你所知道的,有些平方根是整数(例如 100 的平方根是 10)。但更多的平方根是 无理数,它们是永无止境且不重复的小数。在坐标几何中,你经常需要找到多项式的根时,这种情况会经常出现。
那么,如何利用数字猜测游戏的策略来找到一个平方根的准确值呢?你可以简单地使用平均法来计算平方根,精确到八位或九位小数。事实上,你的计算器或电脑使用的正是类似数字猜测策略的迭代方法来求出精确到 10 位小数的平方根!
应用数字猜测游戏逻辑
例如,假设你不知道 60 的平方根。首先,你将选项缩小到一个范围,就像我们在数字猜测游戏中做的那样。你知道 7 的平方是 49,8 的平方是 64,所以 60 的平方根必须介于 7 和 8 之间。使用average()函数,你可以计算 7 和 8 的平均值,得到 7.5,这就是你的第一次猜测。
>>> average(7,8)
7.5
为了检查 7.5 是否是正确的猜测,你可以将 7.5 平方,看看是否得到 60:
>>> 7.5**2
56.25
如你所见,7.5 的平方是 56.25。在我们的数字猜测游戏中,由于 56.25 小于 60,我们会被告知猜更大一些。
因为我们必须猜得更高,我们知道 60 的平方根应该介于 7.5 和 8 之间,所以我们取这两个数的平均值,并将新猜测代入,如下所示:
>>> average(7.5, 8)
7.75
现在我们检查 7.75 的平方,看它是否为 60:
>>> 7.75**2
60.0625
太大了!因此,平方根必须在 7.5 和 7.75 之间。
编写 SQUAREROOT()函数
我们可以使用清单 3-6 中的代码来自动化这个过程。打开一个新的 Python 文件,并命名为squareRoot.py。
*squareRoot.py*
def average(a,b):
return (a + b)/2
def squareRoot(num,low,high):
'''Finds the square root of num by
playing the Number Guessing Game
strategy by guessing over the
range from "low" to "high"'''
for i in range(20):
guess = average(low,high)
if guess**2 == num:
print(guess)
elif guess**2 > num: #"Guess lower."
high = guess
else: #"Guess higher."
low = guess
print(guess)
squareRoot(60,7,8)
清单 3-6:编写squareRoot()函数
在这里,squareRoot()函数有三个参数:num(我们想要求平方根的数字)、low(num的最小值)和high(num的最大值)。如果你猜测的数字的平方等于num,我们就打印出来并跳出循环。这种情况可能发生在整数上,但不适用于无理数。记住,无理数永远不会结束!
接下来,程序检查你猜测的数字的平方是否大于num,如果是这种情况,你应该猜更小的数字。我们将范围缩小到从low到猜测的数字,并用猜测替换high。另一种可能是猜测太小,此时我们将范围缩小到从猜测到high,并用猜测替换low。
程序会按照我们设定的次数(在这个例子中是 20 次)重复这一过程,然后打印出近似的平方根。记住,无论小数点后有多少位,它始终只能近似一个无理数。但我们仍然能得到非常好的近似值!
在最后一行,我们调用squareRoot()函数,传入我们想要求平方根的数字,以及我们知道平方根必须位于的低值和高值范围。我们的输出应如下所示:
7.745966911315918
我们可以通过对其进行平方来找出我们的近似值有多接近:
>>> 7.745966911315918**2
60.00000339120106
那么接近 60 了!难道不令人惊讶吗?我们仅凭猜测和平均计算就能如此准确地求出一个无理数的值?
练习 3-2:求平方根
求这些数字的平方根:
-
200
-
1000
-
50000(提示:你知道平方根应该介于 1 和 500 之间,对吧?)
总结
在这一章,你了解了一些有用的工具,如算术运算符、列表、输入和布尔值,以及一个关键的编程概念——条件语句。通过条件语句,我们可以让计算机自动、瞬时、反复地比较数值并做出选择,这个想法非常强大。每种编程语言都有类似的方式来实现这一点,而在 Python 中,我们使用if、elif和else语句。正如你将在本书中看到的,你将以这些工具为基础,解决更具挑战性的任务,探索数学问题。
在下一章,你将练习到目前为止学到的工具,以快速高效地解决代数问题。你将使用猜数字的策略来解决那些有多个解的复杂代数方程!你还将编写一个绘图程序,以便更好地估算方程的解,并使你的数学探索更加可视化!
第二部分:进入数学领域
第四章:使用代数变换和存储数字
“数学可以定义为我们永远不知道自己在讲什么,也不知道我们所说的是否正确。”
— 伯特兰·罗素*

如果你在学校学过代数,你可能熟悉用字母代替数字的概念。例如,你可以写 2x,其中 x 是一个占位符,可以代表任何数字。所以 2x 代表将 2 乘以某个未知数字。在数学中
在代数课上,变量成为了“神秘数字”,你需要找出字母代表的是什么数字。图 4-1 展示了一位学生对“找 x”这一问题的调皮回应。

图 4-1:定位 x 变量,而不是解出它的值
正如你所看到的,这个学生在图中找到了变量 x,而不是解出它的值。代数课的核心就是解决这样的方程:解 2x + 5 = 13。在这个背景下,“解”意味着找出一个数字,当你用这个数字替换 x 时,使方程成立。你可以通过平衡方程来解代数问题,这需要你记住并遵循很多规则。
以字母作为占位符的方式就像在 Python 中使用变量。事实上,你已经在之前的章节中学会了如何使用变量来存储和计算数值。数学学生应学习的重要技能不是解变量,而是使用变量。事实上,手动解方程的价值是有限的。在这一章中,你将使用变量编写程序,快速而自动地找到未知数,而不必平衡方程!你还将学习使用一个叫做 Processing 的编程环境来绘制函数图像,帮助你直观地探索代数。
解一阶方程
一种通过编程解简单方程的方法是使用暴力法(即通过随机输入数字,直到找到正确的那个)。对于这个特定的方程,我们需要找到一个数字,x,当我们将它乘以 2 再加上 5 时,结果等于 13。我会做一个合理的猜测,认为 x 的值介于−100 和 100 之间,因为我们处理的主要是两位数或更小的数字。
这意味着我们可以编写一个程序,将所有介于−100 和 100 之间的整数代入方程,检查输出,并打印出使方程成立的数字。打开 IDLE 中新建一个文件,将其保存为 plug.py,然后输入 Listing 4-1 中的代码,看看这个程序是如何运行的。
def plug():
➊ x = -100 #start at -100
while x < 100: #go up to 100
➋ if 2*x + 5 == 13: #if it makes the equation true
print("x =",x) #print it out
➌ x += 1 #make x go up by 1 to test the next number
plug() #run the plug function
Listing 4-1:暴力破解程序,通过插入数字查看哪个数字满足方程
在这里,我们定义了 plug() 函数并将 x 变量初始化为 -100 ➊。在下一行,我们开始一个 while 循环,直到 x 等于 100,这是我们设置的范围上限。然后,我们将 x 乘以 2 并加上 5 ➋。如果输出等于 13,我们就告诉程序打印这个数字,因为那就是解。如果输出不等于 13,我们就告诉程序继续执行代码。
然后,循环重新开始,程序测试下一个数字,我们通过将 x 增加 1 ➌ 来获得。我们会继续循环,直到找到匹配的结果。一定要包括最后一行,这一行会使程序运行我们刚刚定义的 plug() 函数;如果你不加这一行,程序就不会有任何操作!输出应该是这样的:
x = 4
使用猜测和检查法是解决这个问题的一种完全有效的方法。手动插入所有数字可能很费力,但使用 Python 可以轻松搞定!如果你怀疑解不是整数,你可能需要通过更小的数字来增加,在 ➌ 这一行改为 x += .25 或其他小数值。
一阶方程的公式推导
另一种解像 2x + 5 = 13 这样的方程的方法是找出这种类型方程的一般公式。然后,我们可以使用这个公式在 Python 中写一个程序。你可能记得数学课上讲过,方程 2x + 5 = 13 是一个 一阶方程 的例子,因为在这个方程中,变量的最高指数是 1。你也应该知道,任何数的 1 次方等于它本身。
实际上,所有一阶方程都符合这个一般公式:ax + b = cx + d,其中 a、b、c 和 d 代表不同的数字。这里是一些其他一阶方程的例子:

在等号的两边,你可以看到一个 x 项和一个 常数,常数是一个没有 x 的数字。前面那个 x 变量的数字叫做 系数。例如,3x 的系数是 3。
但有时方程的一边根本没有 x 项,这意味着那个 x 的系数为零。你可以在第一个例子中看到这一点,3x − 5 = 22,其中 22 是等号右边唯一的项:

使用一般公式,你可以看到 a = 3,b = −5,d = 22。唯一缺少的似乎是 c 的值。但实际上它并不缺失。事实上,什么都没有意味着 cx = 0,这也就意味着 c 必须等于零。
现在让我们用一点代数来解方程 ax + b = cx + d 中的 x。如果我们能找到 x 的值,就可以用它来解几乎所有这种形式的方程。
为了解这个方程,我们首先通过从方程的两边同时减去 cx 和 b,将所有的 x 移到等号的一边,像这样:
ax − cx = d − b
然后我们可以从 ax 和 cx 中提取出 x:
x(a − c) = d − b
最后,除以 a − c 来孤立 x,这给出了 x 作为 a、b、c 和 d 的函数值:

现在,你可以使用这个一般方程来求解任何变量 x,当方程是一次方程并且所有系数(a、b、c 和 d)已知时。让我们用这个来编写一个 Python 程序,可以为我们解一次代数方程。
编写 equation() 函数
要编写一个程序,它将接受一般方程的四个系数并打印出 x 的解,请在 IDLE 中打开一个新的 Python 文件,并将其保存为 algebra.py。我们将编写一个函数,接受四个数字 a、b、c 和 d 作为参数,并将它们代入公式中(参见清单 4-2)。
def equation(a,b,c,d):
''''solves equations of the
form ax + b = cx + d''''
return (d - b)/(a - c)
清单 4-2:使用编程求解 x
记住,一次方程的一般公式是这样的:

这意味着对于任何形式为 ax + b = cx + d 的方程,如果我们将系数代入这个公式,就可以计算出 x 的值。首先,我们设置 equation() 函数,使其接受四个系数作为参数。然后,我们使用表达式 (d - b)/(a − c) 来表示一般方程。
现在让我们用你已经见过的方程来测试我们的程序:2x + 5 = 13。打开 Python shell,在 >>> 提示符下输入以下代码并按下 ENTER:
>>> equation(2,5,0,13)
4.0
如果你将这个方程的系数输入到函数中,你将得到 4 作为解。你可以通过将 4 代入 x 来确认它是正确的。它有效!
练习 4-1:求解更多方程中的 x
使用你在清单 4-2 中编写的程序求解 12x + 18 = –34x + 67。
使用 print() 而不是 return
在清单 4-2 中,我们使用 return 而不是 print() 来显示结果。这是因为 return 会返回一个可以赋值给变量并再次使用的数字。清单 4-3 展示了如果我们使用 print() 而不是 return 来查找 x 会发生什么:
def equation(a,b,c,d):
''''solves equations of the
form ax + b = cx + d''''
print((d - b)/(a − c))
清单 4-3:使用 print() 不允许我们保存输出
当你运行这个程序时,你会得到相同的输出:
>>> x = equation(2,5,0,13)
4.0
>>> print(x)
None
但是当你尝试使用 print() 来调用 x 的值时,程序无法识别你的命令,因为它没有保存结果。如你所见,return 在编程中更有用,因为它允许你保存函数的输出,以便在其他地方使用。这就是为什么我们在清单 4-2 中使用了 return。
要查看如何使用返回的输出,请使用练习 4-1 中的方程 12x + 18 = −34x + 67,并将结果赋值给 x 变量,如下所示:
>>> x = equation(12,18,-34,67)
>>> x
1.065217391304348
首先,我们将方程的系数和常数传递给equation()函数,这样它就能为我们解方程并将解赋值给变量x。然后我们可以简单地输入 x 来查看它的值。现在变量x已经存储了解,我们可以将其代入方程中检查它是否是正确的答案。
输入以下内容来查找方程左侧 12x + 18 的值:
>>> 12*x + 18
30.782608695652176
我们得到了30.782608695652176。现在输入以下内容,计算方程右侧的−34x + 67 的值:
>>> -34*x + 67
30.782608695652172
除了第 15 位小数处的轻微舍入误差外,你可以看到方程的两边结果都接近 30.782608。所以我们可以有信心,1.065217391304348 确实是正确的x解!幸运的是,我们返回了这个解并保存了值,而不是仅仅打印一次。毕竟,谁想一遍又一遍地输入像 1.065217391304348 这样的数字呢?
练习 4-2:分数作为系数
使用equation()函数来解答你在第 55 页看到的最后一个最复杂的方程:

解高次方程
现在你知道如何编写程序求解一元一次方程中的未知值了,我们来尝试一些更难的题目。例如,当方程中有一个二次项时,事情会变得更加复杂,例如x² + 3x − 10 = 0。这些方程被称为二次方程,它们的一般形式是 ax² + bx + c = 0,其中 a、b、c 可以是任何数字:正数或负数、整数、分数或小数。唯一的例外是 a 不能为 0,因为那样会使其变成一元一次方程。与一元一次方程只有一个解不同,二次方程有两个可能的解。
要解含有平方项的方程,你可以使用二次方程公式,这是当你通过平衡方程 ax² + bx + c = 0 将x孤立出来时得到的公式:

二次方程公式是解决方程的一个非常强大的工具,因为无论 a、b、c 分别是多少,在方程 ax² + bx + c = 0 中,你只需将它们代入公式,并使用基本的算术运算就能找到解。
我们知道方程x² + 3x − 10 = 0 的系数分别是 1、3 和−10。当我们将这些数代入公式时,我们得到

将x孤立后,这个公式简化为

有两个解:

结果为 2,

结果等于−5。
我们可以看到,将x代入二次方程公式中的任一解,都能使方程成立:
(2)² + 3(2) − 10 = 4 + 6 − 10 = 0
(−5)² + 3(−5) − 10 = 25 − 15 − 10 = 0
接下来,我们将编写一个使用此公式的函数,返回任何二次方程的两个解。
使用 QUAD()求解二次方程
假设我们想用 Python 来求解以下二次方程:
2x² + 7x − 15 = 0
为此,我们将编写一个名为 quad() 的函数,它接收三个系数(a、b 和 c)并返回两个解。但在我们做任何事情之前,我们需要从 math 模块导入 sqrt 方法。sqrt 方法让我们能够在 Python 中计算一个数字的平方根,就像计算器上的平方根按钮一样。它对正数效果很好,但如果你尝试计算负数的平方根,程序会报错:
>>> frommath importsqrt
>>> sqrt(-4)
Traceback (most recent call last):
File "<pyshell#11>", line 1, in <module>
sqrt(-4)
ValueError: math domain error
在 IDLE 中打开一个新的 Python 文件,并将其命名为 polynomials.py。在文件顶部添加以下代码,以从 math 模块导入 sqrt 函数:
from math import sqrt
然后输入清单 4-4 中的代码来创建 quad() 函数。
def quad(a,b,c):
''''Returns the solutions of an equation
of the form a*x**2 + b*x + c = 0''''
x1 = (-b + sqrt(b**2 - 4*a*c))/(2*a)
x2 = (-b - sqrt(b**2 - 4*a*c))/(2*a)
return x1,x2
清单 4-4:使用二次公式求解方程
quad() 函数接收数字 a、b 和 c 作为参数,并将它们代入二次方程公式中。我们使用 x1 来存储第一个解的结果,x2 将存储第二个解的值。
现在,让我们测试这个程序,求解方程 2x² + 7x − 15 = 0。将 2、7 和 −15 代入 a、b 和 c 后,应该返回以下输出:
>>> quad(2,7,-15)
(1.5, -5.0)
如你所见,x 的两个解是 1.5 和 −5,这意味着这两个值都应该满足方程 2x² + 7x − 15 = 0。为了验证这一点,我们将原方程 2x² + 7x − 15 = 0 中的所有 x 变量分别替换为 1.5(第一个解)和 −5(第二个解),如下所示:
>>> 2*1.5**2 + 7*1.5 - 15
0.0
>>> 2*(-5)**2 + 7*(-5) - 15
0
成功!这证明了两个值都能在原始方程中成立。你可以在未来随时使用 equation() 和 quad() 函数。既然你已经学会了编写函数来解决一阶和二阶方程,我们来讨论如何解决更高次的方程吧!
使用 plug() 求解三次方程
在代数课上,学生经常需要解这样的 三次方程,例如 6x³ + 31x² + 3x − 10 = 0,它包含一个三次项。我们可以修改我们在清单 4-1 中编写的 plug() 函数,使用暴力法来解这个三次方程。将清单 4-5 中的代码输入到 IDLE 中,看看它如何工作。
*plug.py*
def g(x):
return 6*x**3 + 31*x**2 + 3*x − 10
def plug():
x = -100
while x < 100:
if g(x) == 0:
print("x =",x)
x += 1
print("done.")
清单 4-5:使用 plug() 求解三次方程
首先,我们定义 g(x) 为一个函数,用于求解表达式 6*x**3 + 31*x**2 + 3*x − 10,这是我们三次方程的左边。然后我们让程序将 −100 到 100 之间的所有数字代入刚才定义的 g(x) 函数。如果程序找到一个使得 g(x) 等于零的数字,那么它就找到了一个解并输出给用户。
当你调用 plug() 时,应该会看到以下输出:
>>> plug()
x = -5
done.
这样可以得到-5 作为解,但正如你可能从以前做二次方程时猜到的那样,x³项意味着这个方程可能有多达三个解。如你所见,你可以通过暴力求解的方式得到一个解,但你无法确定是否存在其他解或它们是什么。幸运的是,有一种方法可以看到函数的所有可能输入及其对应输出,那就是图形化。
图形化求解方程
在这一节中,我们将使用一个叫做 Processing 的酷工具来图形化更高次方程的解。这种工具将帮助我们以一种有趣且直观的方式找到高次方程的解!如果你还没有安装 Processing,请按照第 xxiii 页的“安装 Processing”部分进行操作。
开始使用 Processing
Processing 是一个编程环境和图形库,能让你轻松地将代码可视化。你可以在https://processing.org/examples/这个示例页面看到你可以用 Processing 做出来的酷炫、动态、互动的艺术作品。你可以把 Processing 当作一个编程创意的草图本。事实上,你创建的每一个 Processing 程序都被称为草图。图 4-2 展示了一个在 Python 模式下的短 Processing 草图的样子。

图 4-2:Processing 示例程序
正如你所看到的,这里有一个编程环境,你可以在其中输入代码,以及一个独立的显示窗口,显示代码的可视化效果。这是一个简单程序的草图,程序会创建一个小圆形。我们将创建的每一个 Processing 草图都将包含两个 Processing 内置函数:setup()和draw()。我们在setup()函数中放入的代码会运行一次,当你点击界面左上角的播放按钮时。我们放在draw()中的内容会作为一个无限循环重复执行,直到你点击播放按钮旁边的停止按钮。
在图 4-2 中,你可以看到在setup()函数中,我们使用size()函数声明了显示屏的大小为 600 像素乘 600 像素。在draw()函数中,我们告诉程序使用ellipse()函数画一个圆。在哪里?多大?我们必须告诉ellipse()函数四个数字:椭圆的 x 坐标、y 坐标、宽度和高度。
注意到圆形出现在屏幕中央,在数学课上,这是原点(0,0)。但在 Processing 以及许多其他图形库中,(0,0)位于屏幕的左上角。所以为了把圆放到中间,我必须将窗口的长度(600)和宽度(600)各自除以二。因此,它的位置是(300,300),而不是(0,0)。
Processing 有许多函数,比如ellipse(),可以帮助我们轻松绘制图形。要查看完整的函数列表,可以访问processing.org/reference/,其中有绘制椭圆、三角形、矩形、弧线等图形的函数。在下一章中,我们将更详细地探讨如何在 Processing 中绘制图形。
注意
在 Processing 中的代码颜色与 IDLE 中使用的颜色不同。例如,在图 4-2 中,可以看到def在 Processing 中显示为绿色,而在 IDLE 中则是橙色的。
创建你自己的图表工具
现在你已经下载了 Processing,我们可以用它来创建一个图表工具,帮助我们查看方程有多少个解。首先,我们创建一个蓝色线条组成的网格,看起来像图表纸。接着,我们用黑色线条创建 x 轴和 y 轴。
设置图表尺寸
为了创建我们的图表工具,首先需要设置显示窗口的尺寸。在 Processing 中,可以使用size()函数来指定屏幕的宽度和高度,单位是像素。默认的屏幕尺寸是 600 像素×600 像素,但为了我们的图表工具,我们将创建一个包含从−10 到 10 的 x 和 y 值的图表。
在 Processing 中打开一个新文件,并将其保存为grid.pyde。确保处于 Python 模式。输入清单 4-6 中的代码,声明我们希望显示的 x 值和 y 值范围,用于我们的图表。
*grid.pyde*
#set the range of x-values
xmin = -10
xmax = 10
#range of y-values
ymin = -10
ymax = 10
#calculate the range
rangex = xmax - xmin
rangey = ymax - ymin
def setup():
size(600,600)
清单 4-6:设置图表的 x 值和 y 值范围
在清单 4-6 中,我们创建了两个变量,xmin和xmax,分别代表网格中最小和最大 x 值,然后我们对 y 值做同样的处理。接下来,我们声明rangex表示 x 轴范围,rangey表示 y 轴范围。我们通过将xmax减去xmin来计算rangex的值,对 y 值做同样的操作。
由于我们不需要一个 600 单位×600 单位的图表,因此我们需要通过将 x 坐标和 y 坐标乘以缩放因子来缩放坐标。当绘制图表时,我们必须记得将所有 x 坐标和 y 坐标都乘以这些缩放因子,否则它们不会在屏幕上正确显示。为此,在setup()函数中更新现有代码,加入清单 4-7 中的代码行。
*grid.pyde*
def setup()
global xscl, yscl
size(600,600)
xscl = width / rangex
yscl = -height / rangey
清单 4-7:使用缩放因子缩放坐标
首先,我们声明全局变量xscl和yscl,它们将用于缩放我们的屏幕。xscl和yscl分别代表 x 轴缩放因子和 y 轴缩放因子。例如,如果我们希望 x 范围为 600 像素,即屏幕的完整宽度,那么 x 轴缩放因子就是 1。但如果我们希望屏幕显示的范围在−300 到 300 之间,那么 x 轴缩放因子就是 2,这可以通过将width(600)除以rangex(300)得到。
在我们的例子中,我们可以通过将 600 除以 x 范围(即 20,从 −10 到 10)来计算缩放因子。所以缩放因子是 30。从现在开始,我们需要将所有的 x 和 y 坐标都放大 30 倍,这样它们才能显示在屏幕上。好消息是,计算机会为我们完成所有的除法和缩放工作。我们只需要记得在绘制图表时使用 xscl 和 yscl!
绘制网格
现在我们已经设置了图表的适当尺寸,我们可以像在图表纸上看到的那样绘制网格线。setup() 函数中的所有代码会执行一次。然后我们用一个名为 draw() 的函数创建一个无限循环。Setup() 和 draw() 是 Processing 中的内置函数,如果你想让草图运行,你不能改变它们的名称。请将代码添加到 Listing 4-8 中,以创建 draw() 函数。
*grid.pyde*
#set the range of x-values
xmin = -10
xmax = 10
#range of y-values
ymin = -10
ymax = 10
#calculate the range
rangex = xmax - xmin
rangey = ymax - ymin
def setup():
global xscl, yscl
size(600,600)
xscl = width / rangex
yscl = height / rangey
def draw():
global xscl, yscl
background(255) #white
translate(width/2,height/2)
#cyan lines
strokeWeight(1)
stroke(0,255,255)
for i in range(xmin,xmax + 1):
line(i*xscl,ymin*yscl,i*xscl,ymax*yscl)
line(xmin*xscl,i*yscl,xmax*xscl,i*yscl)
Listing 4-8: 为图表创建蓝色网格线
首先,我们使用 global xscl, yscl 来告诉 Python,我们并不是创建新变量,而是使用我们已经创建的全局变量。然后,我们通过使用值 255 来将背景色设置为白色。我们使用 Processing 的 translate() 函数来上下或左右移动图形。代码 translate(width/2,height/2) 会将原点(x 和 y 都为 0)从左上角移动到屏幕中心。然后,我们通过 strokeWeight 设置线条的粗细,其中 1 是最细的。如果你想让线条更粗,可以使用更高的数字。你还可以使用 stroke 更改线条的颜色。在这里,我们使用青色(“天蓝色”),其 RGB 值是 (0,255,255),意味着没有红色值,最大绿色和最大蓝色。
之后,我们使用 for 循环来避免输入 40 行代码绘制 40 条蓝色线条。我们希望蓝色线条从 xmin 到 xmax,包括 xmax,因为这就是我们的图表宽度。
RGB 值
RGB 值是红、绿、蓝三种颜色的混合,顺序为红、绿、蓝。值的范围从 0 到 255。例如,(255,0,0) 表示“最大红色,没有绿色,没有蓝色”。黄色是红色和绿色的混合,青色(“天蓝色”)是绿色和蓝色的混合。

其他颜色是不同红、绿、蓝三种颜色的混合:

你可以通过网页搜索“RGB 表”来获取更多颜色的 RGB 值!
在 Processing 中,你可以通过声明四个数字来绘制一条线:线段的起始点和结束点的 x 和 y 坐标。垂直线看起来像这样:
line(-10,-10, -10,10)
line(-9,-10, -9,10)
line(-8,-10, -8,10)
但因为 range(x) 不包括 x(正如你之前学到的),所以我们的 for 循环需要从 xmin 到 xmax + 1,以包括 xmax。
同样,水平线会像这样:
line(-10,-10, 10,-10)
line(-10,-9, 10,-9)
line(-10,-8, 10,-8)
这一次,你可以看到 y 值是 −10, −9, −8 等等,而 x 值则保持不变,分别是 −10 和 10,这就是 xmin 和 xmax。让我们添加另一个循环,从 ymin 到 ymax:
for i in range(xmin,xmax+1):
line(i,ymin,i,ymax)
for i in range(ymin,ymax+1):
line(xmin,i,xmax,i)
如果你正确地绘制了图形,你现在会看到屏幕中间有一个小斑点,因为 x 和 y 坐标范围是从 −10 到 10,而屏幕默认范围是从 0 到 600。这是因为我们还没有将所有的 x 和 y 坐标乘以它们的缩放因子!为了正确显示网格,更新你的代码如下:
for i in range(xmin,xmax+1):
line(i*xscl,ymin*yscl,i*xscl,ymax*yscl)
for i in range(ymin,ymax+1):
line(xmin*xscl,i*yscl,xmax*xscl,i*yscl)
现在你准备好创建 x 轴和 y 轴了。
创建 X 轴和 Y 轴
为了添加两条黑色的线条作为 x 轴和 y 轴,我们首先通过调用 stroke() 函数将描边颜色设置为黑色(0 为黑色,255 为白色)。然后,我们从 (0,−10) 到 (0,10) 画一条垂直线,从 (−10,0) 到 (10,0) 画一条水平线。别忘了将这些值乘以它们各自的缩放因子,除非它们是 0,在这种情况下,乘上它们也不会改变它们。
清单 4-9 显示了创建网格的完整代码。
*grid.pyde*
#cyan lines
strokeWeight(1)
stroke(0,255,255)
for i in range(xmin,xmax+1):
line(i*xscl,ymin*yscl,i*xscl,ymax*yscl)
for i in range(ymin,ymax+1):
line(xmin*xscl,i*yscl,xmax*xscl,i*yscl)
stroke(0) #black axes
line(0,ymin*yscl,0,ymax*yscl)
line(xmin*xscl,0,xmax*xscl,0)
清单 4-9:创建网格线
当你点击 运行 时,你应该会看到一个漂亮的网格,就像在 图 4-3 中一样。

图 4-3:你已经创建了一个用于绘图的网格——而且你只需要做一次!
这看起来已经完成了,但是如果我们试图在 (3,6) 位置放一个点(实际上是一个小椭圆),我们会看到一个问题。将以下代码添加到 draw() 函数的末尾:
*grid.pyde*
#test with a circle
fill(0)
ellipse(3*xscl,6*yscl,10,10)
当你运行这个时,你会在图 4-4 中看到输出结果。

图 4-4:检查我们的绘图程序。快完成了!
如你所见,点最终出现在 (3,−6) 位置,而不是 (3,6) 位置。我们的图像颠倒了!要修正这个问题,我们可以在 setup() 函数中将 y 缩放因子加上一个负号,以翻转它:
yscl = -height/rangey
现在,你应该能看到该点在正确的位置,就像在图 4-5 中所示。

图 4-5:绘图程序正常工作!
现在我们已经写好了绘图工具,让我们将其放入一个函数中,这样以后每次需要绘制方程时就可以重复使用它。
编写 grid() 函数
为了保持代码的组织性,我们将所有创建网格的代码分离出来,放到一个叫做 grid() 的独立函数中。然后,我们像在清单 4-10 中那样,在 draw() 函数中调用 grid() 函数。
*grid.pyde*
def draw():
global xscl, yscl
background(255)
translate(width/2,height/2)
grid(xscl,yscl) #draw the grid
def grid(xscl,yscl):
#Draws a grid for graphing
#cyan lines
strokeWeight(1)
stroke(0,255,255)
for i in range(xmin,xmax+1):
line(i*xscl,ymin*yscl,i*xscl,ymax*yscl)
for i in range(ymin,ymax+1):
line(xmin*xscl,i*yscl,xmax*xscl,i*yscl)
stroke(0) #black axes
line(0,ymin*yscl,0,ymax*yscl)
line(xmin*xscl,0,xmax*xscl,0)
清单 4-10:将所有网格代码移动到一个单独的函数中
在编程中,我们通常将代码组织成函数。注意在清单 4-10 中,我们可以轻松看到在draw()函数中执行的内容。现在我们准备好解方程了:6x³ + 31x² + 3x − 10 = 0。
绘制方程
绘制图形是一种有趣且直观的方式,用于寻找具有多个潜在解的多项式的解。例如,6x³ + 31x² + 3x − 10 = 0 这样的复杂方程,但在我们尝试绘制这样的复杂方程之前,先来绘制一个简单的抛物线。
绘制点
在 清单 4-10 的 draw() 函数后面添加这个函数:
*grid.pyde*
def f(x):
return x**2
这定义了我们所调用的函数f(x)。我们正在告诉 Python 如何处理数字 x 以生成函数的输出。在这个例子中,我们告诉它对数字 x 进行平方运算并返回结果。数学课程传统上将函数称为f(x),g(x),h(x),等等。使用编程语言,你可以根据自己的喜好命名函数!我们本可以给这个函数一个描述性的名字,比如parabola(x),但由于f(x)是常见的表示方法,我们暂时还是使用它。
这是一个简单的抛物线,我们将在深入研究更复杂的函数之前先绘制它。曲线上的所有点仅仅是x和它对应的 y 值。我们本可以使用循环并在所有整数值的x上绘制小椭圆,但那样看起来就像是一个不连接的点集,如图 4-6 所示。

图 4-6:一个由不连续点组成的图
使用不同类型的循环,我们可以将点画得更靠近,就像在图 4-7 中那样。

图 4-7:点更靠近了,但它仍然不像一条令人信服的曲线。
绘制连接曲线的最佳方法是从一个点绘制到下一个点。如果这些点足够接近,它们就会看起来像曲线。首先,我们将在f(x)后创建一个graphFunction()函数。
连接点
在graphFunction()函数中,从xmin开始设置x,像这样:
*grid.pyde*
def graphFunction():
x = xmin
为了使图形覆盖整个网格,我们将不断增加x,直到它等于xmax。这意味着我们将继续运行这个循环,“只要x小于或等于xmax”,如这里所示:
def graphFunction():
x = xmin
while x <= xmax:
为了绘制曲线本身,我们将从每个点绘制一条线到下一个点,每次上升 0.1 个单位。即使我们的函数产生的是一条曲线,如果我们在两个非常接近的点之间绘制一条直线,你可能也不会注意到。例如,从(2, f(2))到(2.1, f(2.1))的距离非常小,因此整体输出看起来仍然是弯曲的。
def graphFunction():
x = xmin
while x <= xmax:
fill(0)
line(x*xscl,f(x)*yscl,(x+0.1)*xscl,f(x+0.1)*yscl)
x += 0.1
这段代码定义了一个函数,通过从xmin开始,一直到xmax,绘制f(x)的图像。只要 x 值小于或等于xmax,我们就会从(x, f(x))绘制一条线到((x + 0.1), f(x + 0.1))。我们不能忘记在循环结束时将x增加 0.1。
列表 4-11 显示了grid.pyde的完整代码。
*grid.pyde*
#set the range of x-values
xmin = -10
xmax = 10
#range of y-values
ymin = -10
ymax = 10
#calculate the range
rangex = xmax - xmin
rangey = ymax - ymin
def setup():
global xscl, yscl
size(600,600)
xscl = width / rangex
yscl = -height / rangey
def draw():
global xscl, yscl
background(255) #white
translate(width/2,height/2)
grid(xscl,yscl)
graphFunction()
def f(x):
return x**2
def graphFunction():
x = xmin
while x <= xmax:
fill(0)
line(x*xscl,f(x)*yscl,(x+0.1)*xscl,f(x+0.1)*yscl)
x += 0.1
def grid(xscl, yscl):
#Draws a grid for graphing
#cyan lines
strokeWeight(1)
stroke(0,255,255)
for i in range(xmin,xmax+1):
line(i*xscl,ymin*yscl,i*xscl,ymax*yscl)
for i in range(ymin,ymax+1):
line(xmin*xscl,i*yscl,xmax*xscl,i*yscl)
stroke(0) #black axes
line(0,ymin*yscl,0,ymax*yscl)
line(xmin*xscl,0,xmax*xscl,0)
列表 4-11:绘制抛物线的完整代码
这样我们就得到了我们想要的曲线,如图 4-8 所示。

图 4-8:一个漂亮的连续抛物线图!
现在我们可以将函数更改为更复杂的形式,绘图器将轻松地绘制出来:
*grid.pyde*
def f(x):
return 6*x**3 + 31*x**2 + 3*x − 10
通过这个简单的修改,你将看到图 4-9 中的输出,但是函数会显示为黑色。如果你更喜欢红色曲线,只需将graphFunction()中的stroke(0)行改为stroke(255,0,0),你就能得到红色曲线。

图 4-9:绘制多项式函数图像
只需要改变 f() 函数中的一行代码,程序就能自动绘制不同的函数图像,真是太棒了!方程的解(称为 根)是图形与 x 轴的交点。我们可以看到三个位置:一个是 x = −5,另一个是 x 位于 −1 和 0 之间,第三个是 x 位于 0 和 1 之间。
使用猜测和检查法寻找根
我们已经看到在 第三章中,猜测和检查方法在猜数字时是多么有效。现在我们可以用它来近似求解方程 6x³ + 31x² + 3x − 10 = 0 的根或解。我们从 0 和 1 之间的根开始。是 0.5 还是其他值?为了验证这一点,我们可以轻松地将 0.5 代入方程。创建一个新文件,在 IDLE 中命名为 guess.py,并输入以下代码:
*guess.py*
def f(x):
return 6*x**3 + 31*x**2 + 3*x − 10
>>> f(0.5)
0.0
如你所见,当 x 等于 0.5 时,函数值为 0,因此我们的方程的另一个解是 x = 0.5。
接下来,我们尝试在 −1 和 0 之间找到根。我们首先尝试 −1 和 0 的平均值:
>>> f(-0.5)
-4.5
在 x = −0.5 时,函数值为负,不是零。从图形上看,我们可以判断猜测过高,因此根一定位于 −1 和 −0.5 之间。我们将这两个端点取平均值,再次尝试:
>>> f(-0.75)
2.65625
我们得到一个正值,所以猜测过低。因此,解应该位于 −0.75 和 −0.5 之间:
>>> f(-0.625)
-1.23046875
仍然过高。这有点繁琐。我们来看看如何用 Python 来帮我们完成这些步骤。
编写 guess() 函数
我们来创建一个函数,通过平均下限和上限的值并相应调整下一个猜测,来找到方程的根。这个方法适用于我们当前的任务,其中函数从正数经过 x 轴到负数。对于一个从负数到正数的上升函数,我们需要稍作调整。 列表 4-12 显示了这个函数的完整代码。
'''The guess method'''
def f(x):
return 6*x**3 + 31*x**2 + 3*x - 10
def average(a,b):
return (a + b)/2.0
def guess():
lower = -1
upper = 0
➊ for i in range(20):
midpt = average(lower,upper)
if f(midpt) == 0:
return midpt
elif f(midpt) < 0:
upper = midpt
else:
lower = midpt
return midpt
x = guess()
print(x,f(x))
列表 4-12:求解方程的猜测方法
首先,我们声明我们尝试求解的方程的函数 f(x)。然后我们创建 average() 函数来找到两个数字的平均值,我们将在每一步中使用它。最后,我们编写一个 guess() 函数,起始时将下限设为 −1,上限设为 0,因为这是我们的图形穿过 x 轴的地方。
然后我们使用 for i in range(20): ➊ 来创建一个循环,将范围分割为 20 次。我们的猜测将是上下限的平均值或中点。我们将该中点代入 f(x),如果输出为 0,我们就知道它是我们的根。如果输出为负值,我们知道我们的猜测过高。然后,中点将替换我们的上限,我们将再次猜测。否则,如果我们的猜测过低,中点将成为下限,我们将继续猜测。
如果我们在 20 次猜测后仍未找到解,我们将返回最新的中点及其对应的函数值。
当我们运行这个时,应该会得到两个值作为输出:
-0.6666669845581055 9.642708896251406e-06
第一个输出是 x 值,接近−2/3。第二个输出是当我们将−2/3 作为 x 值代入时f(x)的结果。最后的e-06是科学记数法,意味着你把 9.64 的小数点向左移六位。因此,f(x)的结果是 0.00000964,非常接近零。通过这个猜测并检查程序并得到这个解决方案,或者说精确到百万分之一的近似值,在不到一秒钟的时间内弹出,仍然让我感到惊讶和欣喜!你能感受到使用像 Python 和 Processing 这样的自由软件探索数学问题的力量吗?
如果我们将迭代次数从 20 增加到 40,我们将得到一个更接近 0 的数字:
-0.6666666666669698 9.196199357575097e-12
让我们检查f(-0.6666666666669698),或者f(-2/3):
>>> f(-2/3)
0.0
这没错,所以 6x³ + 31x² + 3x − 10 = 0 的三个解是x* = −5, −2/3 和 1/2。
练习 4-3:寻找更多的根
使用你刚刚创建的图形工具找到方程 2x² + 7x – 15 = 0 的根。记住,根是图形与 x 轴交点的位置,或者说是函数等于 0 的地方。使用你的quad()函数检查你的答案。
总结
数学课以前总是需要花费多年时间来学习如何解高次方程。在这一章中,你学习到,使用我们的方法猜测并检查,程序化地解决这个问题并不是那么难。你还编写了其他方法来解决方程,比如使用二次公式和图形法。实际上,你已经学会了,要解决一个方程,不论它多复杂,我们所需要做的就是绘制它的图形,并估算它与 x 轴的交点位置。通过迭代并缩小有效值的范围,我们可以达到任意精度。
在编程中,我们使用代数创建变量来表示将会变化的值,比如物体的大小或坐标。用户可以在一个地方更改变量的值,然后程序会自动更新程序中所有地方的该变量的值。用户也可以通过循环改变这些变量的值,或者在函数调用中声明变量值。在未来的章节中,我们将模拟需要使用变量来表示模型参数和约束的现实情况,比如能量含量和重力。使用变量让我们能够轻松改变值,从而调整模型的不同方面。
在下一章中,你将使用 Processing 创建交互式图形,比如旋转的三角形和五彩斑斓的网格!
第五章:使用几何学变换形状
有一天,纳斯鲁丁在茶馆里宣布他要卖掉自己的房子。当其他顾客让他描述房子时,他拿出了一块砖。“这只不过是这些东西的集合。”—伊德里斯·沙赫

在几何学课堂上,你所学的一切都涉及使用形状来表示空间中的维度。你通常从一维的直线和二维的圆形、正方形或三角形开始,然后转向三维的物体,如球体和立方体。如今,借助科技和免费的软件,创建几何形状变得很容易,但操控和改变你创建的形状可能会更具挑战性。
本章中,你将学习如何使用 Processing 图形包来操控和变换几何形状。你将从基本的形状开始,如圆形和三角形,这将帮助你在后续章节中处理复杂的形状,如分形和细胞自动机。你还将学会如何将一些看似复杂的设计分解成简单的组成部分。
绘制一个圆形
让我们从一个简单的一维圆开始。在 Processing 中打开一个新的草图,并将其保存为 geometry.pyde。然后输入 列表 5-1 中的代码,在屏幕上绘制一个圆形。
*geometry.pyde*
def setup():
size(600,600)
def draw():
ellipse(200,100,20,20)
列表 5-1:绘制一个圆圈
在绘制形状之前,我们首先定义草图的大小,称为 坐标平面。在这个例子中,我们使用 size() 函数设置我们的网格宽度为 600 像素,高度也为 600 像素。
设置好坐标平面后,我们使用绘图函数 ellipse() 在这个平面上绘制圆形。前两个参数,200 和 100,表示圆心的位置。这里,200 是圆心的 x 坐标,第二个数值 100 是 y 坐标,这样圆心就位于平面上的 (200,100)。
最后两个参数决定了形状的宽度和高度(单位:像素)。在这个例子中,形状的宽度为 20 像素,高度也是 20 像素。因为这两个参数相同,这意味着圆周上的各个点与中心的距离相等,从而形成一个完美的圆形。
点击 运行 按钮(看起来像播放符号),一个包含小圆圈的新窗口应该会打开,像 图 5-1 中所示。

图 5-1:列表 5-1 的输出显示一个小圆圈
Processing 提供了多个函数供你绘制形状。你可以查看完整的函数列表,访问 https://processing.org/reference/ 来探索其他形状函数。
现在你已经知道如何在 Processing 中绘制圆形,你几乎可以使用这些简单的形状来创建动态的交互式图形。为了实现这一点,你首先需要了解位置和变换。让我们从位置开始。
使用坐标指定位置
在示例 5-1 中,我们使用 ellipse() 函数的前两个参数来指定圆的位置。同样,使用 Processing 创建的每个形状都需要一个位置,这个位置由坐标系指定,其中图中的每个点由两个数字表示:(x,y)。在传统的数学图表中,原点(即 x=0 和 y=0)位于图表的中心,如图 5-2 所示。

图 5-2:传统坐标系,原点在中心
然而,在计算机图形学中,坐标系稍有不同。它的原点位于屏幕的左上角,因此 x 和 y 坐标分别在向右和向下移动时增加,如图 5-3 所示。

图 5-3:计算机图形学的坐标系,原点在左上角
这个平面上的每个坐标代表屏幕上的一个像素。如你所见,这意味着你不需要处理负坐标。我们将使用函数来转换并平移越来越复杂的形状。
绘制一个单独的圆形相对简单,但绘制多个形状会很快变得复杂。例如,假设你需要绘制一个像图 5-4 中所示的设计。

图 5-4:由圆组成的圆形
如果要指定每个圆的大小和位置,并且将它们间隔均匀地排列,就需要输入很多类似的代码行。幸运的是,实际上你并不需要知道每个圆的绝对 x 和 y 坐标就能做到这一点。使用 Processing,你可以轻松地将对象放置在网格的任何位置。
让我们通过一个简单的例子来看看如何实现这个功能。
变换函数
你可能记得在几何课上用铅笔和纸做变换时,你需要对一组点进行繁琐的操作以移动形状。让计算机来进行变换要有趣得多。事实上,没有变换,计算机图形就不可能有什么可看的!几何变换,如平移和旋转,可以让你在不改变对象本身的情况下,改变对象出现的位置和方式。例如,你可以使用变换将三角形移动到另一个位置,或将其旋转而不改变其形状。Processing 提供了许多内置的变换函数,使得平移和旋转对象变得非常简单。
使用 TRANSLATE() 进行对象平移
平移意味着将形状在网格上移动,使得形状的所有点都朝相同的方向和相同的距离移动。换句话说,平移让你在不改变形状本身且不倾斜形状的情况下,将形状在网格上移动。
在数学课上,平移一个物体涉及手动改变物体上所有点的坐标。但在 Processing 中,平移物体是通过移动网格本身来实现的,而物体的坐标保持不变!举个例子,我们可以在屏幕上绘制一个矩形。请按照清单 5-2 中的代码修改你现有的geometry.pyde代码。
*geometry.pyde*
def setup():
size(600,600)
def draw():
rect(20,40,50,30)
清单 5-2:绘制一个矩形以进行平移
在这里,我们使用rect()函数来绘制矩形。前两个参数是 x 和 y 坐标,告诉 Processing 矩形的左上角应该在哪里。第三和第四个参数分别表示矩形的宽度和高度。
运行这段代码,你应该能看到图 5-5 中的矩形。

图 5-5:默认坐标设置,原点位于左上角
注意
在这些示例中,我展示了网格以供参考,但你在屏幕上是看不见它的。
现在,让我们通过清单 5-3 中的代码来告诉 Processing 平移矩形。注意,我们并没有改变矩形的坐标。
*geometry.pyde*
def setup():
size(600,600)
def draw():
translate(50,80);
rect(50,100,100,60)
清单 5-3:平移矩形
在这里,我们使用translate()来平移矩形。我们提供两个参数:第一个参数告诉 Processing 在水平(x)方向上平移网格的距离,第二个参数表示在垂直方向(y)上平移的距离。因此,translate(50,80)将整个网格向右移动 50 像素并向下移动 80 像素,如图 5-6 所示。

图 5-6:通过将网格向右移动 50 像素并向下移动 80 像素来平移矩形
很多时候,将原点(0,0)放在画布的中心是非常有用的(也更容易!)。你可以使用translate()轻松地将原点移动到网格的中心。如果你希望画布更大或更小,也可以用它来改变画布的宽度和高度。让我们来看看 Processing 内置的width和height变量,它们允许你更新画布的大小,而无需手动更改数字。为了演示这一点,请更新清单 5-3 中的现有代码,使其像清单 5-4 那样。
*geometry.pyde*
def setup():
size(600,600)
def draw():
translate(width/2, height/2)
rect(50,100,100,60)
清单 5-4:使用width和height变量平移矩形
在 setup() 函数中,您在 size 声明中输入的任何数字都会成为画布的“宽度”和“高度”。在这个例子中,由于我使用了 size(600,600),它们的宽度和高度都是 600 像素。当我们将 translate() 语句更改为 translate(width/2, height/2),使用变量代替具体数字时,我们告诉 Processing 将位置 (0,0) 移动到显示窗口的中心,无论窗口大小如何。这意味着,如果您改变窗口的大小,Processing 会自动更新 width 和 height,您无需手动修改所有代码中的数字。
运行更新后的代码,您应该会看到类似 图 5-7 的效果。

图 5-7:网格被平移到屏幕中心。
注意,原点仍然标记为 (0,0),这表明我们并没有真正移动原点,而是移动了整个坐标平面,使得原点位于画布的中心。
使用 ROTATE() 旋转物体
在几何学中,旋转是一种变换,它使物体围绕中心点旋转,就像它绕轴旋转一样。Processing 中的 rotate() 函数围绕原点 (0,0) 旋转网格。它接受一个数字作为参数,用来指定您希望围绕点 (0,0) 旋转的角度。旋转角度的单位是弧度,这是您在预备微积分课中学到的内容。我们可以使用 2π(大约 6.28)弧度代替 360 度来进行完整的旋转。如果您像我一样习惯用度数思考,可以使用 radians() 函数轻松将度数转换为弧度,这样就不必自己做数学计算了。
为了查看 rotate() 函数如何工作,将 图 5-8 中显示的代码输入到现有草图中,替换 draw() 函数中的 translate() 代码,然后运行它们。图 5-8 显示了结果。

图 5-8:网格始终围绕 (0,0) 旋转
在 图 5-8 的左侧,网格围绕 (0,0) 旋转了 20 度,而 (0,0) 位于屏幕的左上角。在右侧的例子中,原点首先向右平移 200 个单位,向下平移 200 个单位,然后 网格进行了旋转。
rotate() 函数使得绘制一个物体圆形排列变得简单,就像 图 5-4 中那样,按照以下步骤操作:
-
移动到您希望圆心所在的位置。
-
旋转网格并将物体放置在圆的周长上。
现在,您已经知道如何使用变换函数来操作画布上不同物体的位置,让我们在 Processing 中重新创建 图 5-4。
绘制圆形中的圆形
为了在图 5-4 中创建排列成圆形的圆形,我们将使用 for i in range() 循环来重复绘制圆形,并确保圆形均匀分布。首先,我们要考虑两个圆形之间的角度应该是多少,记住圆形总共有 360 度。
输入列表 5-5 中显示的代码来创建这个设计。
*geometry.pyde*
def setup():
size(600,600)
def draw():
translate(width/2,height/2)
for i in range(12):
ellipse(200,0,50,50)
rotate(radians(360/12))
列表 5-5:绘制圆形设计
请注意,draw() 函数中的 translate(width/2,height/2) 函数将坐标系平移到屏幕中心。然后,我们开始一个 for 循环,在坐标系上的某个点创建一个椭圆,从 (200,0) 开始,如函数的前两个参数所示。接着,我们通过将椭圆的 width 和 height 都设置为 50 来设定每个小圆形的大小。最后,我们在创建下一个椭圆之前,将坐标系旋转 360/12,即 30 度。请注意,我们在 rotate() 函数中使用 radians() 将 30 度转换为弧度。这意味着每个圆形与下一个圆形相距 30 度。
当你运行这段代码时,你应该会看到图 5-9 中显示的内容。

图 5-9:使用变换创建圆形设计
我们已经成功地将一堆圆形排列成了一个圆形!
绘制方形的圆形
修改你在列表 5-5 中写的程序,将圆形改为方形。为此,只需将现有代码中的ellipse改为rect,即可将圆形变为方形,如下所示:
*geometry.pyde*
def setup():
size(600,600)
def draw():
translate(width/2,height/2)
for i in range(12):
rect(200,0,50,50)
rotate(radians(360/12))
这很简单!
动画化对象
Processing 很适合为你的对象制作动画,创建动态图形。在你的第一个动画中,你将使用 rotate() 函数。通常,rotate 会立即发生,因此你无法看到旋转的过程——只能看到旋转的结果。但这次,我们将使用一个时间变量 t,它允许我们实时看到旋转过程!
创建 t 变量
让我们用这个方形的圆形来编写一个动画程序。首先,创建 t 变量,并通过在setup()函数前添加 t = 0 来初始化它。然后,将列表 5-6 中的代码插入到for循环之前。
*geometry.pyde*
t = 0
def setup():
size(600,600)
def draw():
translate(width/2,height/2)
rotate(radians(t))
for i in range(12):
rect(200,0,50,50)
rotate(radians(360/12))
列表 5-6:添加 t 变量
然而,如果你尝试运行这段代码,你会看到以下错误信息:
UnboundLocalError: local variable 't' referenced before assignment
这是因为 Python 无法确定我们是在函数内部创建一个与全局变量 t 无关的新的局部变量,还是在调用全局变量 t。由于我们希望使用全局变量,在 draw() 函数的开始处添加 global t,这样程序就知道我们指的是哪一个。
输入此处显示的完整代码:
*geometry.pyde*
t = 0
def setup():
size(600,600)
def draw():
global t
#set background white
background(255)
translate(width/2,height/2)
rotate(radians(t))
for i in range(12):
rect(200,0,50,50)
rotate(radians(360/12))
t += 1
这段代码将 t 从 0 开始,旋转网格相应的角度,然后将 t 增加 1,接着重复执行。运行它后,你应该会看到方块开始以圆形模式旋转,如 图 5-10 所示。

图 5-10:让方块围绕圆形旋转
很酷!现在我们来试试旋转每个单独的方块。
旋转单个方块
因为在 Processing 中旋转是围绕 (0,0) 进行的,所以在循环内,我们首先要将坐标系平移到每个方块应在的位置,然后进行旋转,最后绘制方块。将代码中的循环更改为 清单 5-7 的样子。
*geometry.pyde*
for i in range(12):
translate(200,0)
rotate(radians(t))
rect(0,0,50,50)
rotate(radians(360/12))
清单 5-7:旋转每个方块
这段代码将网格平移到我们希望放置方块的位置,旋转网格使方块旋转,然后使用 rect() 函数绘制方块。
使用 pushMatrix() 和 popMatrix() 保存方向
当你运行 清单 5-7 时,你会看到它会产生一些奇怪的行为。方块没有围绕中心旋转,而是继续在屏幕上移动,就像在 图 5-11 中所示。

图 5-11:方块飞得四处乱窜!
这是由于改变中心点和频繁改变网格方向所致。在平移到方块位置后,我们需要先旋转回圆心,然后再平移到下一个方块的位置。我们可以使用另一个 translate() 函数来撤销第一个平移,但可能还需要撤销更多的变换,这样会变得有些混乱。幸运的是,有更简单的方法。
Processing 有两个内建函数,它们可以在某个特定点保存网格的方向,并且可以返回该方向:pushMatrix() 和 popMatrix()。在这种情况下,我们希望在屏幕中心时保存方向。为此,修改循环使其看起来像 清单 5-8。
*geometry.pyde*
for i in range(12):
pushMatrix() #save this orientation
translate(200,0)
rotate(radians(t))
rect(0,0,50,50)
popMatrix() #return to the saved orientation
rotate(radians(360/12))
清单 5-8:使用 pushMatrix() 和 popMatrix()
pushMatrix() 函数保存了方块圆环中心的坐标系位置。然后我们将坐标系平移到方块的位置,旋转网格使得方块旋转,最后绘制方块。然后我们使用 popMatrix() 快速返回到方块圆环的中心,并对所有 12 个方块重复此过程。
绕中心旋转
上述代码应该能够正常工作,但旋转效果可能看起来有点奇怪;这是因为 Processing 默认将矩形定位到其左上角并围绕左上角旋转。这使得方块看起来像是偏离了大圆的路径。如果你希望方块围绕它们的中心旋转,可以在 setup() 函数中添加以下行:
rectMode(CENTER)
请注意,rectMode()中的全大写CENTER是很重要的。(你也可以尝试其他类型的rectMode(),比如CORNER、CORNERS和RADIUS。)添加rectMode(CENTER)应该让每个方格围绕其中心旋转。如果你想让方格旋转得更快,可以更改rotate()这一行,增加t中的时间,如下所示:
rotate(radians(5*t))
这里,5是旋转的频率。这意味着程序将t的值乘以 5,然后根据乘积旋转。因此,方格将旋转之前的五倍。更改它,看看会发生什么!注释掉循环外的rotate()这一行(通过在前面加上一个井号),使方格围绕其中心旋转,如列表 5-9 所示。
translate(width/2,height/2)
#rotate(radians(t))
for i in range(12):
rect(200,0,50,50)
列表 5-9:注释掉一行代码而不是删除它
能够使用translate()和rotate()等变换来创建动态图形是一项非常强大的技巧,但如果顺序错误,可能会产生意想不到的结果!
创建一个交互式彩虹网格
现在你已经学会了如何使用循环创建设计,并以不同的方式旋转它们,我们将创造一些相当酷的东西:一个方格网,其中的彩虹颜色会随着你的鼠标光标移动!第一步是制作一个网格。
绘制一个物体网格
许多涉及数学的任务以及像扫雷这样的游戏都需要使用网格。网格对于我们在后续章节中将创建的一些模型和所有的细胞自动机来说都是必要的,因此值得学习如何编写可重复使用的网格代码。首先,我们将制作一个 12 × 12 的方格网,方格大小均匀且间隔相等。制作这个大小的网格可能看起来是一个费时的任务,但事实上,使用循环很容易实现。
打开一个新的 Processing 草图并保存为colorGrid.pyde。真可惜我们之前用了“grid”这个名字。我们将在白色背景上制作一个 20 × 20 的方格网。方格需要是rect,并且我们需要在一个for循环内再嵌套一个for循环,确保它们的大小相同且均匀间隔。此外,我们需要让我们的 25 × 25 像素的方格每 30 像素绘制一次,使用以下代码:
rect(30*x,30*y,25,25)
随着x和y变量每增加 1,方格将在两个维度上按 50 像素的间隔绘制。我们将像往常一样,从编写setup()和draw()函数开始,就像在上一个草图中一样(参见列表 5-10)。
*colorGrid.pyde*
def setup():
size(600,600)
def draw():
#set background white
background(255)
列表 5-10:Processing 草图的标准结构:setup()和draw()
这将窗口的大小设置为 600 × 600 像素,并将背景颜色设置为白色。接下来,我们将创建一个嵌套循环,其中两个变量都从 0 到 19,总共 20 个数字,因为我们需要 20 行 20 个方格。列表 5-11 展示了创建网格的代码。
def setup():
size(600,600)
def draw():
#set background white
background(255)
for x in range(20):
for y in range(20):
rect(30*x,30*y,25,25)
列表 5-11:网格的代码
这应该会创建一个 20 × 20 的方格网,如图 5-12 所示。现在是时候给我们的网格添加一些颜色了。

图 5-12:一个 20 × 20 的网格!
为对象添加彩虹色
Processing 的colorMode()函数帮助我们为草图添加一些酷炫的颜色!它用于在 RGB 和 HSB 模式之间切换。回想一下,RGB 使用三个数字表示红、绿、蓝的数量;而在 HSB 中,三个数字分别表示色调、饱和度和亮度。这里我们需要改变的只有第一个数字,也就是色调值。其他两个数字可以设置为最大值 255。图 5-13 展示了通过仅更改第一个值——色调来创建彩虹色。这里,10 个方块的色调值如图所示,饱和度为 255,亮度为 255。

图 5-13:使用 HSB 模式并更改色调值来显示彩虹色
由于我们在代码清单 5-11 中将矩形放置在(30*x,30*y)位置,我们将创建一个变量来测量鼠标与该位置的距离:
d = dist(30*x,30*y,mouseX,mouseY)
Processing 有一个dist()函数,用来计算两点之间的距离,在这个例子中是方块和鼠标之间的距离。它将距离保存在一个叫做d的变量中,我们将色调与这个变量关联。代码清单 5-12 展示了代码的变化。
*colorGrid.pyde*
def setup():
size(600,600)
rectMode(CENTER)
➊ colorMode(HSB)
def draw():
#set background black
➋ background(0)
translate(20,20)
for x in range(30):
for y in range(30):
➌ d = dist(30*x,30*y,mouseX,mouseY)
fill(0.5*d,255,255)
rect(30*x,30*y,25,25)
代码清单 5-12:使用dist()函数
我们插入colorMode()函数,并传递HSB给它 ➊。在draw()函数中,我们首先将背景设为黑色 ➋。然后我们计算鼠标与方块之间的距离,方块位于(30*x,30*y)位置 ➌。接着,在下一行中,我们使用 HSB 值设置填充颜色。色调值是距离的一半,而饱和度和亮度都设为 255,即最大值。
色调是我们唯一需要更改的:我们根据矩形与鼠标的距离来更新色调。我们用dist()函数来做到这一点,它有四个参数:两个点的 x 和 y 坐标,返回两个点之间的距离。
运行这段代码,你应该能看到一个非常多彩的设计,它会根据鼠标的位置变化颜色,如图 5-14 所示。
现在你已经学会了如何为对象添加颜色,让我们来探讨如何创建更复杂的形状。

图 5-14:为网格添加颜色
使用三角形绘制复杂图案

图 5-15:Roger Antonsen 绘制的 90 个旋转的等边三角形草图。查看动态图像请访问 rantonse.no/en/art/2016-11-30。
在本节中,我们使用三角形创建更复杂的、类似螺旋图形的模式。例如,看看图 5-15 中的草图,它是由奥斯陆大学的 Roger Antonsen 创建的,包含旋转的三角形。
原始设计是会动的,但在本书中,你需要想象所有的三角形都在旋转。这幅草图让我震撼!虽然这个设计看起来非常复杂,但其实并不难制作。记得本章开头纳斯鲁丁的关于砖块的笑话吗?就像纳斯鲁丁的房子一样,这个复杂的设计其实只是由相同形状的图形组成。那么,是什么形状呢?安东森在命名这幅草图为“90 个旋转的等边三角形”时给了我们一个有用的线索。这告诉我们,我们需要做的就是弄清楚如何画一个等边三角形,旋转它,然后重复这一过程,共画 90 个三角形。让我们先讨论一下如何使用triangle()函数画一个等边三角形。首先,打开一个新的 Processing 草图,并将其命名为triangles.pyde。清单 5-13 中的代码展示了创建旋转三角形的一种方法,但并不是等边三角形。
*triangles.pyde*
def setup():
size(600,600)
rectMode(CENTER)
t = 0
def draw():
global t
translate(width/2,height/2)
rotate(radians(t))
triangle(0,0,100,100,200,-200)
t += 0.5
清单 5-13:绘制旋转三角形,但不是正确的那种
清单 5-13 使用了你之前学到的知识:它创建了一个t变量(用于时间),平移到我们希望三角形所在的位置,旋转坐标网格,然后绘制三角形。最后,它递增t。运行这段代码后,你应该能看到类似于图 5-16 的效果。

图 5-16:围绕一个顶点旋转三角形
如图 5-16 所示,三角形围绕其顶点(或角点)旋转,从而形成一个外点构成的圆形。你还会注意到,这个是一个直角三角形(包含 90 度角的三角形),而不是等边三角形。
为了重新创建安东森的草图,我们需要绘制一个等边三角形,它是一个边长相等的三角形。我们还需要找到等边三角形的中心,以便能够围绕其中心旋转它。为此,我们需要找到三角形三个顶点的位置。接下来,让我们讨论如何通过定位三角形的中心来绘制等边三角形,并指定其顶点的位置。
30-60-90 三角形

图 5-17:一个被分成三个相等部分的等边三角形
为了找到我们等边三角形的三个顶点的位置,我们将复习一种你在几何课上可能见过的特殊三角形:30-60-90 三角形,它是一种特殊的直角三角形。首先,我们需要一个等边三角形,如图 5-17 所示。
这个等边三角形由三个相等的部分组成。中间的点是三角形的中心,三条分割线在 120 度角相交。为了在 Processing 中绘制三角形,我们给triangle()函数六个数字:三个顶点的 x 和 y 坐标。为了找到图 5-17 中所示等边三角形的顶点坐标,我们将底部三角形对半切割,如图 5-18 所示。

图 5-18:将等边三角形划分为特殊三角形
将底部三角形对半切割会创建两个直角三角形,它们是经典的 30-60-90 三角形。如你所记得,30-60-90 三角形的边长比可以像图 5-19 所示那样表示。

图 5-19:30-60-90 三角形中边长的比例,来自 SAT 考试的图例
如果我们将较短的直角边的长度称为x,那么斜边是该长度的两倍,即 2x,而较长的直角边是x乘以 3 的平方根,即大约 1.732x。我们将使用从图 5-18 中大等边三角形的中心到其中一个顶点的长度来创建我们的函数,而该顶点恰好是 30-60-90 三角形的斜边。这意味着我们可以以该长度为单位来测量所有其他长度。例如,如果我们将斜边称为length,那么较短的直角边将是该长度的一半,即length/2。最后,较长的直角边将是length除以 2 乘以 3 的平方根。图 5-20 放大了 30-60-90 三角形。

图 5-20:30-60-90 三角形的近距离观察
如你所见,30-60-90 三角形的内部角度为 30 度、60 度和 90 度,边长的比例是已知的。你可能对这个比例很熟悉,它出现在勾股定理中,稍后我们将再次提到。
我们将从较大等边三角形的中心到其顶点的距离称为“长度”,这也是 30-60-90 三角形的斜边。你需要知道这个特殊三角形的边长比例,以便找到相对于中心的等边三角形的三个顶点——你可以通过指定三角形每个点的位置来绘制它(我们要绘制的大等边三角形)。
直角三角形中与 30 度角相对的较短的直角边总是斜边的一半,而较长的直角边是较短的直角边乘以 3 的平方根。所以,如果我们使用中心点来绘制大的等边三角形,那么三个顶点的坐标将如图 5-21 所示。

图 5-21:等边三角形的顶点
正如你所看到的,因为这个三角形由所有边上的 30-60-90 三角形组成,我们可以利用它们之间的特殊关系来确定每个等边三角形的顶点应该离原点多远。
绘制等边三角形
现在我们可以使用从 30-60-90 三角形推导出的顶点来创建一个等边三角形,使用列表 5-14 中的代码。
*triangles.pyde*
def setup():
size(600,600)
rectMode(CENTER)
t = 0
def draw():
global t
translate(width/2,height/2)
rotate(radians(t))
tri(200) #draw the equilateral triangle
t += 0.5
➊ def tri(length):
'''Draws an equilateral triangle
around center of triangle'''
➋ triangle(0,-length,
-length*sqrt(3)/2, length/2,
length*sqrt(3)/2, length/2)
列表 5-14:制作旋转等边三角形的完整代码
首先,我们编写tri()函数,传入变量length ➊,它是我们将等边三角形切割成的特殊 30-60-90 三角形的斜边。然后,我们使用找到的三个顶点来构建一个三角形。在对triangle()函数的调用 ➋ 中,我们指定了三角形三个顶点的位置:(0,-length)、(-length*sqrt(3)/2, length/2)和(length*sqrt(3)/2, length/2)。
当你运行代码时,你应该看到类似于图 5-22 的内容。

图 5-22:一个旋转的等边三角形!
现在,我们可以通过在draw()函数的开头添加这一行来遮掩旋转过程中创建的所有三角形:
background(255) #white
这应该会擦除所有旋转三角形,除了一个,因此屏幕上只会显示一个等边三角形。我们只需要像本章之前那样,用rotate()函数将 90 个三角形放置在一个圆圈中。
练习 5-1:旋转周期
在 Processing 草图中创建一个等边三角形的圆圈,并使用rotate()函数旋转它们。
绘制多个旋转三角形
现在你已经学会了如何旋转一个等边三角形,我们需要弄清楚如何将多个等边三角形排列成一个圆圈。这与旋转正方形时的创建方式类似,但这次我们将使用我们的tri()函数。将列表 5-15 中的代码替换为 Processing 中的def draw()部分,然后运行它。
*triangles.pyde*
def setup():
size(600,600)
rectMode(CENTER)
t = 0
def draw():
global t
background(255)#white
translate(width/2,height/2)
➊ for i in range(90):
#space the triangles evenly
#around the circle
rotate(radians(360/90))
➋ pushMatrix() #save this orientation
#go to circumference of circle
translate(200,0)
#spin each triangle
rotate(radians(t))
#draw the triangle
tri(100)
#return to saved orientation
➌ popMatrix()
t += 0.5
def tri(length):
➍ noFill() #makes the triangle transparent
triangle(0,-length,
-length*sqrt(3)/2, length/2,
length*sqrt(3)/2, length/2)
列表 5-15:创建 90 个旋转三角形
在 ➊ 处,我们使用for循环将 90 个三角形排列在圆圈中,确保它们均匀间隔,通过将 360 除以 90 来实现。然后在 ➋ 处,我们使用pushMatrix()在移动网格之前保存当前位置。在循环末尾的 ➌ 处,我们使用popMatrix()返回到保存的位置。在 ➍ 处的tri()函数中,我们添加了noFill()这一行来使三角形透明。
现在我们有了 90 个旋转的透明三角形,但它们的旋转方式完全相同。虽然有点酷,但还不如 Antonsen 的草图那么酷。接下来,你将学会如何让每个三角形与相邻的三角形稍微不同地旋转,以使图案更加有趣。
旋转的相位偏移
我们可以通过相位偏移来改变三角形旋转的方式,这使得每个三角形在相邻的三角形后面稍微滞后,从而赋予图形“波浪”或“级联”效果。每个三角形在循环中都有一个编号,表示为i。我们需要在rotate(radians(t))函数中将i加到t上,像这样:
rotate(radians(t+i))
运行这个代码时,你应该看到类似于图 5-23 的效果。

图 5-23:带有相位偏移的旋转三角形
注意到屏幕右侧的图案有一个断裂。这个图案的断裂是因为从第一个三角形到最后一个三角形的相位偏移没有对齐。我们希望有一个平滑无缝的图案,因此必须确保相位偏移加起来是 360 度的倍数,才能完成圆形。由于设计中有 90 个三角形,我们将 360 除以 90,再乘以i:
rotate(radians(t+i*360/90))
计算 360/90(即 4)并将这个数字代入代码很容易,但我保留了这个表达式,因为如果以后我们想改变三角形的数量时,它会用得上。现在,这应该能够创建一个平滑的无缝图案,如图 5-24 所示。

图 5-24:带有相位偏移的无缝旋转三角形
通过使我们的相位偏移加起来成为 360 的倍数,我们成功地去除了图案中的断裂。
完成设计
为了让设计看起来更像图 5-15 中的设计,我们需要稍微调整相位偏移。自己试试看如何改变草图的外观!
在这里,我们将通过将i乘以 2 来改变相位偏移,这将增加每个三角形与相邻三角形之间的偏移。在代码中的rotate()行改为以下内容:
rotate(radians(t+2*i*360/90))
做出这个更改后,运行代码。正如图 5-25 所示,我们的设计现在看起来非常接近我们想要重新创建的设计。

图 5-25:重新创建 Antonsen 的“90 个旋转的等边三角形”,来自图 5-15
现在你已经学会了如何重新创建这样的复杂设计,尝试下一个练习来测试你的变换技能!
练习 5-2:彩虹三角形
使用stroke()为旋转三角形草图中的每个三角形上色。它应该像这样。

总结
在本章中,你学习了如何绘制圆形、正方形和三角形,并使用 Processing 的内置变换函数将它们排列成不同的图案。你还学习了如何通过动画化图形和添加颜色使形状具有动态效果。就像 Nasrudin 的房子只是一堆砖块一样,本章中的复杂代码示例也只是一些更简单的形状或函数的组合。
在下一章,你将基于本章所学的内容,扩展你的技能,学习使用三角函数,如正弦和余弦。你将绘制更加酷炫的设计,并编写新的函数,创造出更复杂的行为,比如留下轨迹并从一组顶点创建任意形状。
第六章:用三角函数创建振荡
我家里有一个振荡风扇。风扇来回摆动,看起来风扇在说“不要”。所以我喜欢问它一些风扇会说“不”的问题。“你能保持我的头发整齐吗?你能整理我的文件吗?你有三个档位吗?骗子!”我的风扇骗了我。—米奇·赫德伯格

三角学字面意思是三角形的研究。具体来说,它是对直角三角形及其各边之间特殊比率的研究。然而,从传统三角学课堂上教授的内容来看,你可能会认为这就是三角学的全部。图 6-1 显示的是典型三角学作业的一部分。

图 6-1:传统三角学课堂上关于三角形未知边的一个个问题
这是大多数人从三角学课上记得的任务,解决三角形中未知边长是一个常见的作业。但这并不是三角函数在现实中的常见用途。三角函数,如正弦和余弦,更多的应用于振荡运动,比如水波、光波和声波。假设你拿出你在第四章中使用的图形代码 grid.pyde,并将函数修改为以下内容:
def f(x):
return sin(x)
在这种情况下,你会得到图 6-2 中显示的输出。

图 6-2:正弦波
x 轴上的数值是弧度,是正弦函数的输入值。y 轴是输出值。如果你在计算器或 Python shell 中输入 sin(1),你会得到一个以 0.84 开头的长小数……这就是当 x = 1 时曲线的高度。它几乎处于曲线的顶部,在图 6-2 中可以看到。输入 sin(3) 到计算器中,你会得到 0.14……在曲线上,你会看到当 x = 3 时它几乎在 x 轴上。输入其他任何 x 值,输出都会遵循这种上下波动的模式,在 1 和 -1 之间振荡。一个完整的波形大约需要六个单位的时间,或者一个 波长,我们也称之为该函数的 周期。正弦函数的周期是 2π,或在 Processing 和 Python 中为 6.28 弧度。在学校里,你不会做比画出这样的波形更多的事情。但在本章中,你将使用正弦、余弦和正切函数来实时模拟振荡运动。你还将使用三角学在 Processing 中创建一些有趣的动态交互式草图。主要的三角函数见图 6-3。

图 6-3:直角三角形各边的比率
我们将使用三角函数生成任意边数的多边形,以及任何(奇数)角度的星形。之后,你将创建一个从点沿圆周旋转的正弦波。你将绘制类似 Spirograph 和 harmonograph 的图形,这些都需要三角函数。你还将使五颜六色的点在圆内外振荡!
让我们首先讨论一下,使用三角函数将如何使变换、旋转和振荡形状变得比以前更容易。
使用三角学进行旋转和振荡
首先,正弦和余弦使旋转变得非常简单。在图 6-3 中,sin A 表示为对边除以斜边,或者边 a 除以边 c:

解这个方程来求边 a,你会得到斜边乘以角 A 的正弦值:
a = c Sin A
因此,点的 y 坐标可以表示为原点到该点的距离乘以该点与水平线之间的角度的正弦值。想象一个半径为 r 的圆,斜边是三角形的斜边,围绕点(0,0)旋转,如图 6-4 所示。

图 6-4:点的极坐标形式
要旋转一个点,我们将保持圆的半径不变,简单地改变角度 theta。计算机会通过将半径 r 乘以角度 theta 的余弦或正弦值来重新计算所有点的位置!我们还需要记住,正弦和余弦函数期望的是弧度输入,而不是度数。幸运的是,你已经学会了使用 Processing 内建的radians()和degrees()函数,轻松地将单位转换为我们想要的任何形式。
编写绘制多边形的函数
将顶点看作围绕中心旋转的点,使得创建多边形变得非常容易。回想一下,多边形是一个多边形状;规则多边形是通过将一定数量的点等间距地连接在圆周上形成的。还记得我们在第五章中需要多少几何知识才能绘制一个等边三角形吗?有了三角函数帮助旋转后,我们只需要使用图 6-4 来创建一个多边形函数,就能绘制多边形。
在 Processing 中打开一个新草图并将其保存为polygon.pyde。然后输入清单 6-1 中的代码,通过vertex()函数绘制一个多边形。
*polygon.pyde*
def setup():
size(600,600)
def draw():
beginShape()
vertex(100,100)
vertex(100,200)
vertex(200,200)
vertex(200,100)
vertex(150,50)
endShape(CLOSE)
清单 6-1:使用vertex()绘制多边形
我们当然可以使用line()来绘制多边形,但一旦我们连接了所有的线条,就无法用颜色填充形状了。Processing 函数beginShape()和endShape()通过使用vertex()函数来定义我们想要的任何形状,指定形状的各个点位置。这让我们能够创建任意数量的顶点。
我们总是从beginShape()开始形状,列出形状的所有顶点并通过vertex()函数传递它们,最后通过endShape()结束形状。如果我们在endShape()函数中放入CLOSE,程序将连接最后一个顶点和第一个顶点。
当你运行这段代码时,你应该看到类似于图 6-5 的图形。

图 6-5:由顶点组成的房屋形状多边形
然而,手动输入四五个以上的点是很麻烦的。如果我们能够通过循环将一个点围绕另一个点旋转就好了。接下来我们试试看。
使用循环绘制六边形
我们可以使用for循环来创建六个六边形的顶点,代码见清单 6-2。
*polygon.pyde*
def draw():
translate(width/2,height/2)
beginShape()
for i in range(6):
vertex(100,100)
rotate(radians(60))
endShape(CLOSE)
清单 6-2:尝试在for循环中使用rotate()
然而,你会发现,如果你运行这段代码,你会看到一片空白屏幕!你不能在形状内部使用rotate()函数,因为这个函数会旋转整个坐标系统。这就是为什么我们需要你在图 6-4 中看到的正弦和余弦表示法来旋转顶点!这正是我们需要的原因!
图 6-6 展示了如何通过表达式(r*cos(60*i), r*sin(60*i))来创建六边形的每个顶点。当 i = 0 时,括号中的角度为 0 度;当 i = 1 时,角度为 60 度;以此类推。

图 6-6:使用正弦和余弦旋转点绕中心
要在代码中重新创建这个六边形,我们需要创建一个变量r,它表示从旋转中心到每个顶点的距离,而这个距离不会改变。我们唯一需要改变的是sin()和cos()函数中的角度,它们都是 60 的倍数。一般情况下,它可以写成这样:
for i in range(6):
vertex(r*cos(60*i),r*sin(60*i))
首先,我们让i从 0 到 5,这样每个顶点的角度将是 60 的倍数(0、60、120 等等),如图 6-7 所示。接着,我们将r更改为 100 并将角度转换为弧度,这样代码就像清单 6-3 一样。
*polygon.pyde*
def setup():
size(600,600)
def draw():
translate(width/2,height/2)
beginShape()
for i in range(6):
vertex(100*cos(radians(60*i)),
100*sin(radians(60*i)))
endShape(CLOSE)
清单 6-3:绘制六边形

图 6-7:使用vertex()函数和for循环绘制的六边形
现在我们已经将r设置为 100 并将角度转换为弧度,当我们运行这段代码时,我们应该看到一个类似于图 6-7 的六边形。
事实上,我们可以创建一个函数,通过这种方式绘制任何多边形!
绘制等边三角形
现在让我们用这个函数创建一个等边三角形。清单 6-4 展示了一种更简单的方法,通过循环来创建等边三角形,而不是像我们在第五章中那样使用平方根。
*polygon.pyde*
def setup():
size(600,600)
def draw():
translate(width/2,height/2)
polygon(3,100) #3 sides, vertices 100 units from the center
def polygon(sides,sz):
'''draws a polygon given the number
of sides and length from the center'''
beginShape()
for i in range(sides):
step = radians(360/sides)
vertex(sz*cos(i * step),
sz*sin(i * step))
endShape(CLOSE)
清单 6-4:绘制等边三角形
在这个示例中,我们创建了一个polygon()函数,根据边数(sides)和多边形的大小(sz)来绘制多边形。每个顶点的旋转角度是 360 度除以边数。在我们的六边形中,我们旋转 60 度,因为六边形有六个边(360 / 6 = 60)。polygon(3,100)这一行调用了多边形函数,并传入了两个参数:边数为 3,中心到顶点的距离为 100。
运行这段代码,你应该会看到图 6-8 所展示的效果。

图 6-8:一个等边三角形!
现在,绘制任意边数的规则多边形应该非常简单。无需平方根!图 6-9 展示了一些可以使用polygon()函数绘制的样本多边形。

图 6-9:你想要的所有多边形!
尝试更新polygon(3,100)中的数字,看看多边形如何改变形状!
制作正弦波
就像章节开头提到的 Mitch Hedberg 的风扇一样,正弦和余弦用于旋转和振荡。当测量圆上的一个点随时间变化的高度时,正弦和余弦函数会形成波浪。为了让这一点更具体,我们可以画一个圆来可视化正弦波的生成,在圆的圆周上放置一个点(用红色椭圆表示)。当这个点沿着圆周移动时,它的高度随时间变化将画出一条正弦波。
启动一个新的 Processing 草图并将其保存为CircleSineWave.pyde。在屏幕左侧创建一个大圆,如图 6-10 所示。在查看代码之前,自己尝试一下。

图 6-10:正弦波草图的开始
清单 6-5 展示了绘制一个红点在大圆圆周上的草图代码。
*CircleSineWave.pyde*
r1 = 100 #radius of big circle
r2 = 10 #radius of small circle
t = 0 #time variable
def setup():
size(600,600)
def draw():
background(200)
#move to left-center of screen
translate(width/4,height/2)
noFill() #don't color in the circle
stroke(0) #black outline
ellipse(0,0,2*r1,2*r1)
#circling ellipse:
fill(255,0,0) #red
y = r1*sin(t)
x = r1*cos(t)
ellipse(x,y,r2,r2)
清单 6-5:我们的圆形和点
首先,我们声明圆的半径变量,并使用t表示使点移动所需的时间。在draw()中,我们将背景设置为gray(200),将画布移到屏幕的中心,并绘制半径为r1的大圆。接着,我们使用极坐标绘制围绕圆旋转的椭圆,分别为 x 和 y 坐标。
为了让椭圆绕着圆旋转,我们只需要改变三角函数中的数字(在这种情况下是t)。在draw()函数的末尾,我们只需让时间变量稍微增加一点,如下所示:
t += 0.05
如果你现在运行这段代码,你会看到一个关于local variable 't' referenced before assignment的错误信息。Python 函数有局部变量,但我们希望draw()函数使用全局时间变量t。因此,我们需要在draw()函数的开始部分添加以下代码:
global t
现在你会看到一个红色椭圆沿着圆周移动,如图 6-11 所示。

图 6-11:红色椭圆沿着大圆的圆周运动。
现在我们需要选择屏幕右侧的一个位置来开始绘制波形。我们将在红色椭圆的基础上延伸一条绿色的线,假设是 x = 200。将这些行添加到你的draw()函数中,在t += 0.05之前。绘制正弦波的完整代码应如下所示,清单 6-6。
*CircleSineWave.pyde*
r1 = 100 #radius of big circle
r2 = 10 #radius of small circle
t = 0 #time variable
def setup():
size(600,600)
def draw():
global t
background(200)
#move to left-center of screen
translate(width/4,height/2)
noFill() #don't color in the circle
stroke(0) #black outline
ellipse(0,0,2*r1,2*r1)
#circling ellipse:
fill(255,0,0) #red
y = r1*sin(t)
x = r1*cos(t)
ellipse(x,y,r2,r2)
stroke(0,255,0) #green for the line
line(x,y,200,y)
fill(0,255,0) #green for the ellipse
ellipse(200,y,10,10)
t += 0.05
清单 6-6:添加一行来绘制波形
在这里,我们绘制了一条与旋转的红色椭圆保持相同高度(y 值)的绿色线。这条绿色线始终与水平线平行,因此当红色椭圆上下移动时,绿色椭圆会保持在相同的高度。当你运行程序时,你会看到类似图 6-12 的效果。

图 6-12:准备绘制波形!
你可以看到,我们添加了一个绿色椭圆,它只衡量红色椭圆上下移动的距离,别的什么都不做。
留下轨迹
现在我们希望绿色椭圆留下一个轨迹,以显示它随时间的高度变化。留下轨迹实际上意味着我们保存所有的高度并在每次循环时显示它们。为了保存许多东西,比如数字、字母、单词、点等,我们需要一个列表。在程序开始时声明的变量中,在setup()函数之前,添加这一行:
circleList = []
这会创建一个空列表,我们将在其中保存绿色椭圆的位置。将circleList变量添加到draw()函数中的global行:
global t, circleList
在draw()函数中计算 x 和 y 之后,我们需要将 y 坐标添加到circleList中,但有几种不同的方法可以做到这一点。你已经知道append()函数,它会将点添加到列表的末尾。我们可以使用 Python 的insert()函数将新点放到列表的开头,像这样:
circleList.insert(0,y)
然而,列表会在每次循环时变大。我们可以通过将新值添加到前 249 个已有项中,将其长度限制为 250,如清单 6-7 所示。
y = r1*sin(t)
x = r1*cos(t)
#add point to list:
circleList = [y] + circleList[:249]
清单 6-7:向列表中添加一个点并将列表限制为 250 个点
新的一行代码将我们刚刚计算的 y 值与circleList中的前 249 个项连接起来。这个包含 250 个点的列表现在变成了新的circleList。
在draw()函数的末尾(在增加t之前),我们将放入一个循环,遍历circleList中的所有元素并绘制一个新的椭圆,模拟绿色椭圆留下轨迹的效果。这在清单 6-8 中有所展示。
#loop over circleList to leave a trail:
for i in range(len(circleList)):
#small circle for trail:
ellipse(200+i,circleList[i],5,5)
清单 6-8:循环遍历圆形列表并在列表中的每个点绘制椭圆
这段代码使用了一个循环,其中i从 0 到circleList的长度,并为列表中的每个点绘制一个椭圆。x 值从 200 开始,并随着i的变化而增加。椭圆的 y 值是我们保存到circleList中的 y 值。
当你运行此代码时,你将看到类似于图 6-13 的效果。

图 6-13:正弦波!
你可以看到波形被绘制出来,留下了绿色的轨迹。
使用 Python 内置的enumerate()函数
你还可以使用 Python 内置的enumerate()函数在列表中的每个点上绘制一个椭圆。这是一种更便捷、更“Pythonic”的方法,用于跟踪列表中项目的索引和值。要查看此功能的演示,请在 IDLE 中打开一个新文件,并输入清单 6-9 中的代码。
>>> myList = ["I","love","using","Python"]
>>> for index, value in enumerate(myList):
print(index,value)
0 I
1 love
2 using
3 Python
清单 6-9:学习使用 Python 的enumerate()函数
你会注意到有两个变量(索引和值),而不仅仅是一个(i)。要在你的圆列表中使用enumerate()函数,你可以使用两个变量来跟踪迭代器(i,索引)和圆(c,值),就像在清单 6-10 中那样。
#loop over circleList to leave a trail:
for i,c in enumerate(circleList):
#small circle for trail:
ellipse(200+i,c,5,5)
清单 6-10:使用enumerate()获取列表中每个项目的索引和值
最终的代码应类似于你在清单 6-11 中看到的。
*CircleSineWave.pyde*
r1 = 100 #radius of big circle
r2 = 10 #radius of small circle
t = 0 #time variable
circleList = []
def setup():
size(600,600)
def draw():
global t, circleList
background(200)
#move to left-center of screen
translate(width/4,height/2)
noFill() #don't color in the circle
stroke(0) #black outline
ellipse(0,0,2*r1,2*r1)
#circling ellipse:
fill(255,0,0) #red
y = r1*sin(t)
x = r1*cos(t)
#add point to list:
circleList = [y] + circleList[:245]
ellipse(x,y,r2,r2)
stroke(0,255,0) #green for the line
line(x,y,200,y)
fill(0,255,0) #green for the ellipse
ellipse(200,y,10,10)
#loop over circleList to leave a trail:
for i,c in enumerate(circleList):
#small circle for trail:
ellipse(200+i,c,5,5)
t += 0.05
清单 6-11: CircleSineWave.pyde 草图的最终代码
这是通常展示给初学三角学的学生的动画,而你已经做出了自己的版本!
创建一个涡轮图形程序
现在你已经知道如何旋转圆并留下轨迹,让我们做一个涡轮图形类型的模型!涡轮图形是一种由两个重叠的圆形齿轮组成的玩具,这些齿轮相互滑动。齿轮上有孔,你可以将笔和铅笔穿过这些孔来绘制酷炫的曲线图案。许多人在小时候玩过涡轮图形,手工绘制这些图案。但我们可以利用计算机和你刚刚学到的正弦和余弦代码来制作涡轮图形类型的图案。
首先,在 Processing 中启动一个新的草图,命名为spirograph.pyde。然后添加清单 6-12 中的代码。
*spirograph.pyde*
r1 = 300.0 #radius of big circle
r2 = 175.0 #radius of circle 2
r3 = 5.0 #radius of drawing "dot"
#location of big circle:
x1 = 0
y1 = 0
t = 0 #time variable
points = [] #empty list to put points in
def setup():
size(600,600)
def draw():
global r1,r2,x1,y1,t
translate(width/2,height/2)
background(255)
noFill()
#big circle
stroke(0)
ellipse(x1,y1,2*r1,2*r1)
清单 6-12:将大圆显示在屏幕上
我们首先在屏幕中央放一个大圆,并为大圆创建变量,然后将一个较小的圆放在其圆周上,就像涡轮图形套件中的圆盘一样。
绘制较小的圆
让我们将较小的圆放在大圆的圆周上,如图 6-14 所示。

图 6-14:两个圆
接下来,我们将让较小的圆绕着较大的圆“内部”旋转,就像一个涡轮图形齿轮。更新清单 6-12 中的代码,并使用清单 6-13 中的代码绘制第二个圆。
#big circle
stroke(0)
ellipse(x1,y1,2*r1,2*r1)
#circle 2
x2 = (r1 - r2)
y2 = 0
ellipse(x2,y2,2*r2,2*r2)
清单 6-13:添加较小的圆
为了让较小的圆在较大的圆内部旋转,我们需要将正弦和余弦部分添加到“圆 2”的位置,使其发生振荡。
旋转较小的圆
最后,在draw()函数的最后,我们需要像在清单 6-14 中那样递增我们的时间变量t。
#big circle
stroke(0)
ellipse(x1,y1,2*r1,2*r1)
#circle 2
x2 = (r1 - r2)*cos(t)
y2 = (r1 - r2)*sin(t)
ellipse(x2,y2,2*r2,2*r2)
t += 0.05
清单 6-14:使圆旋转的代码
这意味着圆 2 将在大圆内沿圆形路径上下左右振动。运行代码,你应该能看到圆 2 顺利旋转!但是,齿轮上笔的位置和绘制轨迹的那个孔怎么办呢?我们将创建第三个椭圆来表示那个点。它的位置将是第二个圆的中心加上半径差。关于“绘制点”的代码在清单 6-15 中有展示。
#drawing dot
x3 = x2+(r2 - r3)*cos(t)
y3 = y2+(r2 - r3)*sin(t)
fill(255,0,0)
ellipse(x3,y3,2*r3,2*r3)
清单 6-15:添加绘制点
当你运行这段代码时,你会看到绘制点正好位于圆 2 的边缘,旋转得就像圆 2 沿着圆 1 的圆周滑动一样。圆 3(绘制点)必须在圆 2 的中心和其圆周之间保持一定比例,因此我们需要在setup()函数之前引入一个比例变量(prop)。在draw()函数的开头确保将其声明为全局变量,正如在清单 6-16 中所示。
prop = 0.9
*--snip--*
global r1,r2,x1,y1,t,prop
*--snip--*
x3 = x2+prop*(r2 - r3)*cos(t)
y3 = y2+prop*(r2 - r3)*sin(t)
清单 6-16:添加比例变量
现在我们需要弄清楚绘制点旋转的速度。只需一点代数就可以证明它的角速度(旋转速度)是大圆大小与小圆大小的比例。注意,负号意味着点的旋转方向相反。将draw()函数中的x3和y3行改为如下:
x3 = x2+prop*(r2 - r3)*cos(-((r1-r2)/r2)*t)
y3 = y2+prop*(r2 - r3)*sin(-((r1-r2)/r2)*t)
剩下的就是将点(x3,y3)保存到points列表中,并像我们在波形草图中一样,在点之间绘制线条。将points列表添加到全局线:
global r1,r2,x1,y1,t,prop,points
绘制完第三个椭圆后,将这些点放入一个列表中。这与我们在本章前面使用的CircleSineWave.pyde中的步骤相同。最后,遍历列表并在点之间绘制线条,就像在清单 6-17 中所做的那样。
fill(255,0,0)
ellipse(x3,y3,2*r3,2*r3)
#add points to list
points = [[x3, y3]] + points[:2000]
for i,p in enumerate(points): #go through the points list
if i < len(points)-1: #up to the next to last point
stroke(255,0,0) #draw red lines between the points
line(p[0],p[1],points[i+1][0],points[i+1][1])
t += 0.05
清单 6-17:在 Spirograph 中绘制点
我们在圆形波形示例中使用了类似的技巧,将当前点的列表与包含 2000 个circleList项目的列表连接起来。这会自动限制我们保存的点数。运行这段代码,你将看到程序绘制出 Spirograph,正如图 6-15 所示。

图 6-15:绘制 Spirograph
你可以通过改变第二个圆的大小(r2)和绘制点的位置(prop)来绘制不同的设计。例如,图 6-16 中的 Spirograph 的r2为 105,prop为 0.8。

图 6-16:通过改变r2和prop创建的另一个 Spirograph 设计
到目前为止,我们一直在使用正弦和余弦让形状上下或左右振动,但如何让形状在两个不同方向上振动呢?接下来我们将尝试这一点。
制作和谐图
在 1800 年代,有一种名为 和谐图 的发明,它是一个与两个摆锤相连的桌子。当摆锤摆动时,附着的笔会在纸上绘画。随着摆锤来回摆动并逐渐减速(衰减),图案会以有趣的方式发生变化,如 图 6-17 所示。

图 6-17:和谐图机器与设计
通过编程和几个方程式,我们可以模拟和谐图如何绘制其图案。模拟一个摆锤振荡的方程式是:

在这些方程中,x 和 y 分别表示笔的水平和垂直位移(左右和上下的距离)。变量 a 是运动的振幅(大小),f 是摆锤的频率,t 是经过的时间,p 是相位偏移,e 是自然对数的底数(它是一个常数,约为 2.7),d 是衰减因子(摆锤减速的速度)。时间变量 t 在这两个方程中当然是相同的,但其他所有变量可以不同:例如,左右的频率可以与上下的频率不同。
编写和谐图程序
让我们创建一个 Python-Processing 草图来模拟摆锤的运动。创建一个新的 Processing 草图并命名为 harmonograph.pyde。初始代码如 清单 6-18 所示。
*harmonograph.pyde*
t = 0
def setup():
size(600,600)
noStroke()
def draw():
global t
➊ a1,a2 = 100,200 #amplitudes
f1,f2 = 1,2 #frequencies
p1,p2 = 0,PI/2 #phase shifts
d1,d2 = 0.02,0.02 #decay constants
background(255)
translate(width/2,height/2)
➋ x = a1*cos(f1*t + p1)*exp(-d1*t)
y = a2*cos(f2*t + p2)*exp(-d2*t)
fill(0) #black
ellipse(x,y,5,5)
t += .1
清单 6-18:和谐图草图的初始代码
这只是通常的 setup() 和 draw() 函数,带有时间变量(t)和振幅(a1,a2)、频率(f1,f2)、相位偏移(p1,p2)以及衰减常数(d1,d2)的值。
然后,从 ➊ 开始,我们定义了一堆变量来代入和谐图绘图笔位置的两个公式中。x = 和 y = 的代码行 ➋ 使用这些变量并计算椭圆的坐标。
现在运行这段代码,你应该会看到圆形在移动,但它在画什么?我们需要将点放入一个列表中,然后绘制出列表中的所有点。在声明 t 变量后,创建一个名为 points 的列表。目前的代码如 清单 6-19 所示。
*harmonograph.pyde*
t = 0
points = []
def setup():
size(600,600)
noStroke()
def draw():
global t,points
a1,a2 = 100,200
f1,f2 = 1,2
p1,p2 = 0,PI/2
d1,d2 = 0.02,0.02
background(255)
translate(width/2,height/2)
x = a1*cos(f1*t + p1)*exp(-d1*t)
y = a2*cos(f2*t + p2)*exp(-d2*t)
#save location to points List
points.append([x,y])
#go through points list and draw lines between them
for i,p in enumerate(points):
stroke(0) #black
if i < len(points) - 1:
line(p[0],p[1],points[i+1][0],points[i+1][1])
t += .1
清单 6-19:用点之间的线条绘制和谐图的代码

图 6-18:和谐图
我们首先在文件顶部定义points列表,并在draw()函数中将点添加到全局变量中。在计算出x和y的位置后,我们添加一行代码,将点[x,y]添加到points列表中。最后,我们遍历points列表,并从每个点绘制一条线到下一个点。然后我们使用 Python 的enumerate()函数,停止在倒数第二个点之前。这样我们就不会得到索引超出范围的错误信息,当它尝试从最后一个点绘制到下一个点时。现在,当我们运行代码时,看到点在后面留下了轨迹,就像在图 6-18 中看到的那样。
请注意,如果你注释掉公式中的衰减部分,如下所示,程序将简单地在相同的线条上进行绘制:
x = a1*cos(f1*t + p1)#*exp(-d1*t)
y = a2*cos(f2*t + p2)#*exp(-d2*t)
衰减模拟了摆锤最大振幅的逐渐减小,这也正是许多和谐图图像中“波浪状”效果的来源。前几次观看代码绘制设计时很有趣,但过程会有些慢。如果我们能够一次性填充points列表呢?
即时填充列表
我们不再在每一帧都绘制整个列表,而是想出一种方法来即时填充列表。我们可以将整个和谐图的代码从draw()函数中剪切出来,粘贴到一个独立的函数中,像在清单 6-20 中那样。
def harmonograph(t):
a1,a2 = 100,200
f1,f2 = 1,2
p1,p2 = PI/6,PI/2
d1,d2 = 0.02,0.02
x = a1*cos(f1*t + p1)*exp(-d1*t)
y = a2*cos(f2*t + p2)*exp(-d2*t)
return [x,y]
清单 6-20:分离出的harmonograph()函数
现在在draw()函数中,你只需要一个循环,在其中为t的值添加一堆点,就像在清单 6-21 中那样。
def draw():
background(255)
translate(width/2,height/2)
points = []
t = 0
while t < 1000:
points.append(harmonograph(t))
t += 0.01
#go through points list and draw lines between them
for i,p in enumerate(points):
stroke(0) #black
if i < len(points) - 1:
line(p[0],p[1],points[i+1][0],points[i+1][1])
清单 6-21:新的draw()函数,它调用了harmonograph()函数
运行这段代码,你将立即看到一个完整的和谐图!因为我们改变了椭圆的大小和相位偏移,所以下面的结果看起来与之前不同,正如你在图 6-19 中看到的。自己改变每个值,看看它如何改变设计!

图 6-19:使用不同的公式来生成和谐图
两个摆锤比一个摆锤更好
我们可以通过在每个公式中添加另一个项来增加另一个摆锤,从而制作出更复杂的设计,像这样:
x = a1*cos(f1*t + p1)*exp(-d1*t) + a3*cos(f3*t + p3)*exp(-d3*t)
y = a2*sin(f2*t + p2)*exp(-d2*t) + a4*sin(f4*t + p4)*exp(-d4*t)
这只是在每一行中添加相同的代码,只改变几个数字,以模拟每个方向上不止一个摆锤。当然,你需要创建更多的变量并给它们赋值。在清单 6-22 中是我建议的,复制我在 www.walkingrandomly.com/?p=151 上找到的设计。
def harmonograph(t):
a1=a2=a3=a4 = 100
f1,f2,f3,f4 = 2.01,3,3,2
p1,p2,p3,p4 = -PI/2,0,-PI/16,0
d1,d2,d3,d4 = 0.00085,0.0065,0,0
x = a1*cos(f1*t + p1)*exp(-d1*t) + a3*cos(f3*t + p3)*exp(-d3*t)
y = a2*sin(f2*t + p2)*exp(-d2*t) + a4*sin(f4*t + p4)*exp(-d4*t)
return [x,y]
清单 6-22:在图 6-20 中的和谐图设计代码
在清单 6-22 中,我们只改变了a、f、p和d的常数,制作了一个完全不同的设计。如果你在绘制线条之前向代码中添加stroke(255,0,0),你会使线条变成红色,正如在图 6-20 中看到的那样。

图 6-20:一个完整的 harmonograph!
清单 6-23 显示了harmonograph.pyde的最终代码。
*harmonograph.pyde*
t = 0
points = []
def setup():
size(600,600)
noStroke()
def draw():
background(255)
translate(width/2,height/2)
points = []
t = 0
while t < 1000:
points.append(harmonograph(t))
t += 0.01
#go through points list and draw lines between them
for i,p in enumerate(points):
stroke(255,0,0) #red
if i < len(points) - 1:
line(p[0],p[1],points[i+1][0],points[i+1][1])
def harmonograph(t):
a1=a2=a3=a4 = 100
f1,f2,f3,f4 = 2.01,3,3,2
p1,p2,p3,p4 = -PI/2,0,-PI/16,0
d1,d2,d3,d4 = 0.00085,0.0065,0,0
x = a1*cos(f1*t + p1)*exp(-d1*t) + a3*cos(f3*t + p3)*exp(-d3*t)
y = a2*sin(f2*t + p2)*exp(-d2*t) + a4*sin(f4*t + p4)*exp(-d4*t)
return [x,y]
清单 6-23:harmonograph 草图的最终代码
总结
三角学课上的学生必须解算三角形中未知的边长或角度测量。但是现在你知道了正弦和余弦的真正用途是旋转和转换点与形状,从而制作 Spirograph 和 harmonograph 设计!在本章中,你看到保存点到列表中,然后遍历列表绘制点之间的线条是多么有用。我们还复习了一些 Python 工具,如enumerate()和vertex()。
在下一章中,我们将使用正弦和余弦以及你在本章学到的旋转概念,发明一种全新的数字!我们还将使用这些新数字来旋转和转换网格,并通过像素的位置创造出复杂(有意为之)的艺术作品!
第七章:复数
虚数是神圣精神的美妙庇护所,几乎是存在与非存在之间的两栖物种。——戈特弗里德·莱布尼茨

包含 –1 的平方根的数字在数学课堂上常常被误解。我们把 –1 的平方根称为 虚数,或 i。称某物为“虚拟”会让它看起来像是不存在的,或者好像没有实际的用途。但虚数确实存在,而且在电磁学等领域中有很多实际应用。
在本章中,你将体验使用 复数 创建美丽艺术作品的乐趣。复数是具有实部和虚部的数字,写成 a + bi 的形式,其中 a 和 b 是实数,i 是虚数。由于复数包含两个不同的信息,实部和虚部,你可以利用它将一维物体转化为二维物体。使用 Python 后,操作这些数字变得更加简单,我们可以将其用于一些非常神奇的用途。事实上,我们使用复数来解释电子和光子的行为,而我们认为是自然的、“正常”的数字实际上是虚部为零的复数!
本章开始时,我们回顾如何在复数坐标平面中绘制复数。你还将学习如何将复数表示为 Python 列表,然后编写函数对其进行加法和乘法运算。最后,你将学习如何求复数的大小或绝对值。在本章后面,我们编写用于生成曼德尔布罗特集合和朱莉亚集合的程序时,了解如何操作复数将大有帮助。
复数坐标系
正如弗兰克·法里斯在他精彩且插图精美的著作《对称的创造》中总结的那样:“复数……仅仅是一种将实数的笛卡尔有序对(x, y)紧凑地表示为一个单一数字 z = x + iy 的方式。” 我们都知道,笛卡尔坐标系使用 x 表示水平轴,y 表示垂直轴,但我们从未对这些数字进行加法或乘法运算;它们仅仅代表一个位置。
相比之下,复数不仅可以表示位置,还可以像其他数字一样进行运算。从几何角度来看复数会更有帮助。让我们稍微改变一下坐标系统,使得实数位于水平轴上,虚数位于垂直轴上,如 图 7-1 所示。

图 7-1:复数坐标系
在这里,你可以看到 a + bi 和 a – bi 在复数坐标系中的位置。
复数相加
复数的加法和减法与实数的操作一样:你从一个数开始,然后按照另一个数所表示的步数进行操作。例如,要加上 2 + 3i 和 4 + i,你只需分别加上它们的实部和虚部,得到 6 + 4i,如图 7-2 所示。

图 7-2:加法运算的复数
如你所见,我们从 4 + i 开始。为了加上 2 + 3i,我们向正实轴方向移动两个单位,向正虚轴方向移动三个单位,最终到达 6 + 4i。
让我们编写一个用于加法运算的复数函数,代码示例见清单 7-1。在 IDLE 中打开一个新文件并命名为 complex.py。
def cAdd(a,b):
'''adds two complex numbers'''
return [a[0]+b[0],a[1]+b[1]]
清单 7-1:加法运算函数示例
在这里,我们定义了一个名为 cAdd() 的函数,给它两个复数以列表形式 [x,y] 传入,该函数返回另一个列表。列表的第一个元素 a[0] + b[0] 是我们提供的复数的第一个项(索引 0)的和。第二个元素 a[1] + b[1] 是两个复数的第二个项(索引 1)的和。保存并运行此程序。
现在让我们使用复数 u = 1 + 2i 和 v = 3 + 4i 来测试这个程序。在交互式 shell 中将它们传递给我们的 cAdd() 函数,如下所示:
>>> u = [1,2]
>>> v = [3,4]
>>> cAdd(u,v)
[6, 4]
你应该得到 6 + 4i,这是复数 1 + 2i 和 3 + 4i 的和。复数相加就像是先沿 x 方向走一步,再沿 y 方向走一步,当我们需要创建像曼德尔布罗集和朱莉亚集这样的美丽设计时,这个函数还会再次出现。
乘以复数的 i
但是,复数相加并不是最有用的操作,乘法才是。例如,乘以 i 会让复数绕原点旋转 90 度。在复数坐标系统中,乘以 -1 相当于绕原点旋转 180 度,如图 7-3 所示。

图 7-3:乘以 -1 表示 180 度旋转
如你所见,1 乘以 -1 等于 -1,这使得 1 绕零点旋转到了另一侧。
因为乘以 -1 相当于进行 180 度的旋转,所以 -1 的平方根表示 90 度的旋转,如图 7-4 所示。

图 7-4:乘以 i 表示 90 度旋转
这意味着 i 代表 -1 的平方根,乘以 1 时将我们旋转至 -1 的一半。将结果 (i) 再乘以 i 会让我们再旋转 90 度,最终得到 -1。这样就验证了平方根的定义,因为通过将同一个数 (i) 自身乘以两次,我们可以得到一个负数。
乘法运算:两个复数的乘法
让我们看看当我们相乘两个复数时会发生什么。就像你会用 FOIL 方法相乘两个二项式一样,你可以用代数方式通过 FOIL 方法相乘两个复数:

为了简化这一过程,让我们将这个过程转化为cMult()函数,如列表 7-2 所示。
def cMult(u,v):
'''Returns the product of two complex numbers'''
return [u[0]*v[0]-u[1]*v[1],u[1]*v[0]+u[0]*v[1]]
列表 7-2:编写乘法函数以相乘两个复数
要测试cMult()函数,尝试将 u = 1 + 2i与 v = 3 + 4i相乘。在交互式 shell 中输入以下内容:
>>> u = [1,2]
>>> v = [3,4]
>>> cMult(u,v)
[-5, 10]
如你所见,乘积是–5 + 10i。
回想一下上一节内容,复数与i相乘等同于在复平面坐标系原点进行 90 度旋转。现在,让我们用 v = 3 + 4i来试一试:
>>> cMult([3,4],[0,1])
[-4, 3]
结果是 –4 + 3i。当我们将 3 + 4i与–4 + 3i画出时,你应该会看到类似于图 7-5 所示的内容。

图 7-5:通过与i相乘将复数旋转 90 度
如你所见,–4 + 3i是 3 + 4i的 90 度旋转结果。
现在你已经知道如何加法和乘法运算复数,让我们来看看如何求一个复数的模,你将使用它来创建曼德尔布罗特集和朱莉亚集。
编写 MAGNITUDE()函数
复数的模,或绝对值,表示复数与复平面原点的距离。现在,让我们使用毕达哥拉斯定理来创建一个模函数。返回到complex.py并确保在文件顶部从 Python 的math模块导入平方根函数:
from math import sqrt
magnitude()函数就是毕达哥拉斯定理:
def magnitude(z):
return sqrt(z[0]**2 + z[1]**2)
让我们来找出复数 2 + i 的模:
>>> magnitude([2,1])
2.23606797749979
现在,你已经准备好编写一个 Python 程序,根据复数的大小为显示窗口上的像素上色。复数的意外行为将导致一个无限复杂的设计,无法在没有计算机的情况下复制!
创建曼德尔布罗特集
为了创建曼德尔布罗特集,我们将把显示窗口上的每个像素表示为复数z,然后反复对其进行平方,并加上原始的复数z。
然后,我们将对输出进行相同的处理,一次又一次。如果数值持续增大,我们将根据其模大于某个特定值(如 2)所需的迭代次数为原始复数对应的像素上色。如果数值持续变小,我们将为其选择不同的颜色。
你已经知道,将一个数与大于 1 的数相乘会使原始数变大。将一个数与 1 相乘保持不变,而与小于 1 的数相乘则会使原始数变小。复数遵循类似的模式,你可以在复平面上表示,如图 7-6 所示。

图 7-6:可视化当你乘以复数时发生的情况
如果我们只是在乘以复数,曼德布罗集合将看起来像图 7-6,一个圆形。但不仅仅是对复数进行平方,之后还会加上一个数字。这将使得圆形变成一个无限复杂且令人惊叹的美丽图形。但是在此之前,我们需要对网格上的每个点进行操作!
根据操作的结果,有些复数将变小并收敛到零,而其他的则变大并发散。在数学术语中,接近某个数字称为收敛,而变得过大则称为发散。对于我们的目的,我们将根据每个像素点需要多少次迭代才能变得太大并飞出网格来为其上色。我们将数字代入的公式类似于我们在清单 7-2 中使用的 cMult() 函数,只不过多了一步。我们将数字平方,再加上原始的复数,并重复这个过程,直到它发散。如果平方后的复数的大小大于 2,就意味着它已经发散(我们可以选择任何数字作为最大值)。如果它从未超过 2,我们将保持其颜色为黑色。
例如,我们手动尝试使用复数 z = 0.25 + 1.5i 来进行曼德布罗集合操作:
>>> z = [0.25,1.5]
我们通过将 z 乘以它自己并将结果保存到变量 z2 来进行平方:
>>> z2 = cMult(z,z)
>>> z2
[-2.1875, 0.75]
然后我们使用 cAdd() 函数将 z2 和 z 相加:
>>> cAdd(z2,z)
[-1.9375, 2.25]
我们有一个函数可以用来测试这个复数是否距离原点超过两单位,方法是使用毕达哥拉斯定理。我们可以使用之前的 magnitude() 函数来检查得到的复数的大小是否大于 2:
>>> magnitude([-1.9375,2.25])
2.969243380054926
我们设定规则如下:“如果一个数字离原点超过两单位,它就会发散。”因此,复数 z = 0.25 + 1.5i 在进行 1 次迭代后就会发散!
这次,我们尝试 z = 0.25 + 0.75i,如下所示:
>>> z = [0.25,0.75]
>>> z2 = cMult(z,z)
>>> z3 = cAdd(z2,z)
>>> magnitude(z3)
1.1524430571616109
在这里,我们重复了之前的相同过程,只不过这次我们需要再次将 z2 和 z 相加,并将其保存为 z3。它仍然在离原点两单位以内,因此我们用这个新值替换 z 并再次进行该过程。首先,我们创建一个新变量 z1,用于对原始的 z 进行平方:
>>> z1 = z
让我们使用复数 z3 的最新值重复这个过程。我们将对其进行平方,加上 z1,然后找出大小:
>>> z2 = cMult(z3,z3)
>>> z3 = cAdd(z2,z1)
>>> magnitude(z3)
0.971392565148097
因为 0.97 小于 1.152,我们可能猜测结果正在变小,因此看起来不会发散,但我们只重复了两次这个过程。手动进行这些操作很费劲!让我们自动化这些步骤,以便能够快速轻松地重复这一过程。我们将使用平方、相加和求大小的函数来编写一个名为 mandelbrot() 的函数,自动化检查过程,这样我们就能将发散的数字与收敛的数字区分开来。你认为它会呈现什么样的设计?一个圆形?一个椭圆?让我们来看看!
编写 MANDELBROT() 函数
让我们打开一个 Processing 草图,并将其命名为 mandelbrot.pyde。我们在这里尝试重现的 Mandelbrot 集合以数学家贝努瓦·曼德布罗特(Benoit Mandelbrot)的名字命名,他在 1970 年代首次使用计算机探索这个过程。我们将重复平方和相加的过程,最多迭代若干次,或者直到数字发散,具体过程见清单 7-3。
def mandelbrot(z,num):
'''runs the process num times
and returns the diverge count '''
➊ count=0
#define z1 as z
z1=z
#iterate num times
➋ while count <= num:
#check for divergence
if magnitude(z1) > 2.0:
#return the step it diverged on
return count
#iterate z
➌ z1=cAdd(cMult(z1,z1),z)
count+=1
#if z hasn't diverged by the end
return num
清单 7-3:编写 mandelbrot() 函数以检查复数发散所需的步数
mandelbrot() 函数接受一个复数 z 和一个迭代次数作为参数。它返回 z 发散所需的次数,如果 z 永远不发散,则返回 num(在函数结束时)。我们创建了一个 count 变量 ➊ 来跟踪迭代次数,并创建了一个新的复数 z1,它被平方并如此继续,但 z 保持不变。
我们开始一个循环,在 count 变量小于 num ➋ 时重复这个过程。在循环内,我们检查 z1 的大小,看看它是否已经发散,如果发散了,我们返回 count 并停止代码。否则,我们对 z1 进行平方操作并将 z 加到其中 ➌,这就是我们对复数的操作定义。最后,我们将 count 变量加 1,并再次进行循环。
使用 mandelbrot.pyde 程序,我们可以将复数 z = 0.25 + 0.75i 插入,并在每次迭代后检查其大小。以下是每次循环后的大小:
0.7905694150420949
1.1524430571616109
0.971392565148097
1.1899160852817983
2.122862368187107
第一个数字是 z = 0.25 + 0.75i 在任何迭代之前的大小:

你可以看到它在四次迭代后发散,因为它与原点的距离超过了两单位。图 7-7 绘制了每一步的图表,帮助你可视化它们。

图 7-7:运行复数 0.25 + 0.75i* 通过 mandelbrot() 函数直到它发散*
红色圆圈的半径为两单位,表示我们对复数发散设定的限制。当对原始值 z 进行平方和相加时,我们使数字的位置旋转并平移,最终使它们距离原点比我们的规则允许的更远。
让我们使用在第四章中学到的一些绘图技巧,在 Processing 显示中绘制点和函数。将complex.py中的所有复数函数(cAdd、cMult和magnitude)复制并粘贴到mandelbrot.pyde文件的底部。我们将使用 Processing 的println()函数打印出一个点发散所需的步骤数。将清单 7-4 中的代码添加到你在清单 7-3 中编写的mandelbrot()代码之前。
*mandelbrot.pyde*
#range of x-values
xmin = -2
xmax = 2
#range of y-values
ymin = -2
ymax = 2
#calculate the range
rangex = xmax - xmin
rangey = ymax - ymin
def setup():
global xscl, yscl
size(600,600)
noStroke()
xscl = float(rangex)/width
yscl = float(rangey)/height
def draw():
z = [0.25,0.75]
println(mandelbrot(z,10))
清单 7-4:曼德博集合代码的开始部分
我们在程序的顶部计算实数值(x)和虚数值(y)的范围。在setup()函数内,我们计算所需的缩放因子(xscl和yscl),将像素乘以(在这种情况下是 0 到 600),以获得复数(在这种情况下是从–2 到 2)。在draw()函数中,我们定义我们的复数z,然后将其传递给mandelbrot()函数并打印出结果。此时,屏幕上不会显示任何内容,但在控制台中,你会看到打印出的数字 4。现在,我们将遍历屏幕上的每一个像素,并将它们的位置输入mandelbrot()函数并显示结果。
让我们回到mandelbrot()函数,位于mandelbrot.pyde程序中。对像素位置进行重复的乘法和加法操作会返回一个数值,如果这个数值从不发散,我们就将该像素涂成黑色。整个draw()函数展示在清单 7-5 中。
*mandelbrot.pyde*
def draw():
#origin in center:
translate(width/2,height/2)
#go over all x's and y's on the grid
➊ for x in range(width):
for y in range(height):
➋ z = [(xmin + x * xscl) ,
(ymin + y * yscl) ]
#put it into the mandelbrot function
➌ col=mandelbrot(z,100)
#if mandelbrot returns 0
if col == 100:
fill(0) #make the rectangle black
else:
fill(255) #make the rectangle white
#draw a tiny rectangle
rect(x,y,1,1)
清单 7-5:遍历显示窗口中所有像素
遍历所有像素需要一个嵌套循环来处理x和y ➊。我们声明复数z为 x + iy ➋。根据窗口坐标计算复数z稍微有点复杂。我们从xmin值开始,例如,然后加上我们所采取的步数乘以缩放因子。我们并不是在 0 和 600 之间变化,这是显示窗口的像素大小;我们实际上是在–2 和 2 之间变化。我们将其传递给mandelbrot()函数 ➌。
mandelbrot()函数将复数平方并加起来 100 次,并返回数值发散所需的迭代次数。这个数字保存到一个名为col的变量中,因为color已经是 Processing 中的关键词。col中的数字决定我们将该像素设置为哪种颜色。目前,我们只需通过将每个不发散的像素设置为黑色,来在屏幕上绘制曼德博集合。否则,我们将使矩形变为白色。运行这段代码,你应该会看到著名的曼德博集合,像图 7-8 中那样。

图 7-8:著名的曼德博集合
不觉得这很神奇吗?而且绝对是出乎意料的:仅通过对复数进行平方并相加,再根据数字的大小给像素上色,我们就绘制出了一个复杂到无穷的图案,没有计算机,这种设计是无法想象的!你可以通过改变x和y的范围来放大图案中的特定区域,像在清单 7-6 中一样。
#range of x-values
xmin = -0.25
xmax = 0.25
#range of y-values
ymin = -1
ymax = -0.5
清单 7-6:改变值的范围以放大曼德布罗集合
现在输出应该类似于图 7-9。

图 7-9:放大曼德布罗集合!
我强烈推荐你查看网上一些人发布的关于放大曼德布罗集合的视频。
为曼德布罗集合添加颜色
现在,让我们为你的曼德布罗设计添加一些颜色。通过添加以下代码,告诉 Processing 你使用的是 HSB(色调、饱和度、亮度)色标,而不是 RGB(红色、绿色、蓝色)色标:
def setup():
size(600,600)
colorMode(HSB)
noStroke()
然后根据mandelbrot()函数返回的值为矩形上色:
if col == 100:
fill(0)
else:
fill(3*col,255,255)
#draw a tiny rectangle
rect(x*xscl,y*yscl,1,1)
在fill行中,我们将col变量(复数发散所需的迭代次数)乘以 3,并将其作为 HSB 颜色模式中的 H(色调)组件。运行这段代码,你应该会看到一个漂亮的着色曼德布罗集合,就像在图 7-10 中看到的那样。

图 7-10:使用发散值为曼德布罗集合着色
你可以看到每一步发散的点,从深橙色的圆圈到变成黑色曼德布罗集合的浅橙色椭圆。你也可以试验其他颜色。例如,将填充行更改为以下内容:
fill(255-15*col,255,255)
运行此更新,你会看到图像中更多的蓝色,正如图 7-11 所示。

图 7-11:在曼德布罗集合中试验不同的颜色
接下来,我们将探索一个相关的设计,叫做朱莉亚集合,它的外观可以根据我们输入的不同而变化。
创建朱莉亚集合
在曼德布罗集合中,为了确定每个点的颜色,我们从点作为复数 z 开始,然后反复对其平方并加上原始数字 z。朱莉亚集合的构造方式与曼德布罗集合相同,但在对复数进行平方之后,我们并不是加上该点的原始复数,而是不断加上一个常数复数c,该常数对所有点保持相同。通过为c选择不同的值,我们可以创建许多不同的朱莉亚集合。
编写JULIA()函数
维基百科页面上列出了许多美丽的 Julia 集合示例,以及用于创造这些集合的复数。让我们尝试使用 c = –0.8 + 0.156i 来创建一个。我们可以轻松地将 mandelbrot() 函数修改为 julia() 函数。将你的 mandelbrot.pyde 草图保存为 julia.pyde,并更改 mandelbrot() 函数的代码,使其看起来像清单 7-7。
*julia.pyde*
def julia(z,c,num):
'''runs the process num times
and returns the diverge count'''
count = 0
#define z1 as z
z1 = z
#iterate num times
while count <= num:
#check for divergence
if magnitude(z1) > 2.0:
#return the step it diverged on
return count
#iterate z
➊ z1 = cAdd(cMult(z1,z1),c)
count += 1
清单 7-7:为 Julia 集合编写 julia() 函数
它与曼德尔布罗特函数几乎相同。唯一改变的代码行是➊,其中 z 被改为 c。复数 c 会与 z 不同,因此我们需要将其传递给 draw() 中的 julia() 函数,如清单 7-8 所示。
def draw():
#origin in center:
translate(width/2,height/2)
#go over all x's and y's on the grid
x = xmin
while x < xmax:
y = ymin
while y < ymax:
z = [x,y]
➊ c = [-0.8,0.156]
#put it into the julia program
col = julia(z,c,100)
#if julia returns 100
if col == 100:
fill(0)
else:
#map the color from 0 to 100
#to 0 to 255
#coll = map(col,0,100,0,300)
fill(3*col,255,255)
rect(x*xscl,y*yscl,1,1)
y += 0.01
x += 0.01
清单 7-8:为 Julia 集合编写 draw() 函数
除了我们为这个 Julia 集合选择的复数 c ➊ 之外,其他与 mandelbrot.pyde 中的内容完全相同。紧接着,我们将 c 添加到调用 julia() 函数时的参数中。当你运行时,你将得到一个与曼德尔布罗特集合大不相同的设计,如图 7-12 所示。

图 7-12:对应于 c = –0.8 + 0.156 i 的 Julia 集合
Julia 集合的精彩之处在于你可以改变输入的 c 值,从而得到不同的输出。例如,如果你将 c 改为 0.4 + 0.6i,你应该会看到类似于图 7-13 的图案。

图 7-13:对应于 c = –0.4 + 0.6i 的 Julia 集合
练习 7-1:绘制 Julia 集合
绘制一个 c = 0.285 + 0.01i 的 Julia 集合。
摘要
在这一章中,你学习了复数是如何在复数坐标平面上绘制的,以及它们如何让你执行旋转操作——并且你跟随它们的逻辑,深入学习了如何加法和乘法运算。你使用学到的知识编写了 mandelbrot() 和 julia() 函数,将复数转化为不可思议的艺术作品,这些艺术作品如果没有复数的发明和计算机的出现,是无法实现的。
如你所见,这些数字一点也不虚幻!希望当你现在想到复数时,它们会让你想起那些你可以通过数字和代码创造出来的美丽设计。
第八章:使用矩阵进行计算机图形学和方程组
“我很伟大,我包容万象。”
—沃尔特·惠特曼,摘自《我歌唱我自己》*

在数学课上,学生被教导如何加、减和乘矩阵,但他们从未真正学过矩阵是如何应用的。这是很遗憾的,因为矩阵使我们能够轻松地将大量项目分组,并模拟物体从多个角度的坐标,这使得它们在机器学习中非常有用,对 2D 和 3D 图形至关重要。换句话说,如果没有矩阵,就不会有视频游戏!
要理解矩阵如何用于创建图形,首先需要理解如何对它们进行算术运算。在本章中,你将回顾如何加法和乘法运算矩阵,以便你可以在 Processing 中创建和转换 2D 和 3D 对象。最后,你将学习如何使用矩阵瞬间解决大型方程组。
什么是矩阵?
矩阵 是一个矩形的数字数组,有特定的规则用于对它们进行操作。图 8-1 显示了矩阵的样子。

图 8-1:矩阵有 m 行和 n 列
在这里,数字按行和列排列,其中 m 和 n 分别表示行数和列数。你可以有一个 2 × 2 的矩阵,包含两行两列,如下所示:

或者,你可以有一个 3 × 4 的矩阵,包含三行四列,如下所示:

传统上,我们使用字母 i 来表示行号,使用字母 j 来表示列号。注意,矩阵中的数字并不是彼此相加;它们只是排在一起。这类似于我们使用坐标格式 (x, y) 来排列坐标,但你并不对坐标进行运算。例如,一个位于 (2, 3) 的点并不意味着你要加或乘 2 和 3;它们只是并排在一起,告诉你该点在图表中的位置。但正如你很快会看到的那样,你 确实可以 像对待普通数字一样,对两个矩阵进行加、减和乘法运算。
添加矩阵
你只能对相同维度(大小和形状)的矩阵进行加法和减法运算,这意味着你只能加或减 对应元素。以下是如何加两个 2 × 2 矩阵的示例:

例如,我们加 1 和 5,因为它们是矩阵中对应的元素,意味着它们处于相同的位置:第一行,第一列。因此,我们得到 6 在左上角。将对应的元素 3 和 –7 相加,得到 –4,就像你在结果的左下角看到的那样。
这对于放入 Python 函数来说足够简单,因为你可以将一个矩阵保存到一个变量中。在 IDLE 中打开一个新文件并将其保存为 matrices.py。然后按照 Listing 8-1 编写代码。
*matrices.py*
A = [[2,3],[5,-8]]
B = [[1,-4],[8,-6]]
def addMatrices(a,b):
'''adds two 2x2 matrices together'''
C = [[a[0][0]+b[0][0],a[0][1]+b[0][1]],
[a[1][0]+b[1][0],a[1][1]+b[1][1]]]
return C
C = addMatrices(A,B)
print(C)
Listing 8-1:编写 matrices.py 程序以添加矩阵
在这里,我们使用 Python 的列表语法声明了两个 2 × 2 的矩阵,A 和 B。例如,A 是一个包含两个列表的列表,每个列表都有两个元素。然后,我们声明了一个名为 addMatrices() 的函数,它接受两个矩阵作为参数。最后,我们创建了另一个矩阵 C,它将第一个矩阵中的每个元素与第二个矩阵中对应的元素相加。
当你运行这个时,输出应该类似于下面这样:
[[3, -1], [13, -14]]
这展示了将矩阵 A 和 B 相加得到的 2 × 2 矩阵:

现在你知道如何加矩阵了,让我们来试试矩阵乘法,它将帮助你进行坐标转换。
矩阵乘法
矩阵相乘比矩阵相加更有用。例如,你可以通过将 (x,y) 坐标矩阵与变换矩阵相乘来旋转一个二维或三维形状,正如你将在本章稍后做的那样。
在矩阵相乘时,你不是直接相乘对应的元素。相反,你将第一个矩阵每一行的元素与第二个矩阵每一列的对应元素相乘。这意味着第一个矩阵的列数必须等于第二个矩阵的行数。否则,它们就不能相乘。例如,下面这两个矩阵可以相乘:

首先,我们将第一个矩阵的第一行(1 和 2)与第二个矩阵第一列的元素(5 和 6)相乘。这些乘积的和将成为结果矩阵第一行第一列的元素。对第一个矩阵的第二行执行相同的操作,依此类推。结果将如下所示:

这里是将 2 × 2 矩阵与 2 × 2 矩阵相乘的通用公式:

我们还可以乘以以下两个矩阵,因为 A 是一个 1 × 4 矩阵,B 是一个 4 × 2 矩阵:

结果矩阵将是什么样子?好吧,A的第一行将与B的第一列相乘,得到结果矩阵的第一行第一列的数字。对于第一行第二列也是一样。结果矩阵将是一个 1 × 2 矩阵。你可以看到,当你在做矩阵乘法时,第一个矩阵的行元素会与第二个矩阵的列元素对应起来。这意味着结果矩阵的行数将等于第一个矩阵的行数,而列数将等于第二个矩阵的列数。
现在我们将直接将矩阵 A 中的元素与矩阵 B 中的对应元素相乘,并将所有的乘积相加。

这看起来可能是一个复杂的自动化过程,但只要我们有矩阵作为输入,就能轻松找出列数和行数。
示例 8-2 展示了一个矩阵乘法程序,它比加法代码稍微复杂一些。将此代码添加到matrices.py中。
def multmatrix(a,b):
#Returns the product of matrix a and matrix b
m = len(a) #number of rows in first matrix
n = len(b[0]) #number of columns in second matrix
newmatrix = []
for i in range(m):
row = []
#for every column in b
for j in range(n):
sum1 = 0
#for every element in the column
for k in range(len(b)):
sum1 += a[i][k]*b[k][j]
row.append(sum1)
newmatrix.append(row)
return newmatrix
示例 8-2:编写矩阵乘法函数
在这个示例中,multmatrix()函数接受两个矩阵作为参数:a和b。在函数一开始,我们声明了m,即矩阵a的行数,以及n,即矩阵b的列数。我们创建一个名为newmatrix的空列表作为结果矩阵。 “行乘列”的操作将进行m次,所以第一个循环是for i in range(m),使得i重复m次。对于每一行,我们向newmatrix中添加一个空行,以便我们可以用n个元素填充该行。接下来的循环使得j重复n次,因为矩阵b有n列。棘手的部分是匹配正确的元素,但这只需要一点思考。
只需考虑哪些元素会被相乘。当j = 0时,我们将矩阵a的第i行的元素与矩阵b的第一列(索引 0)相乘,结果成为newmatrix新行中的第一列,正如你在之前的示例中看到的那样。然后,当j = 1时,矩阵a的第i行和矩阵b的第二列(索引 1)发生同样的情况。该乘积成为newmatrix新行中的第二列。这个过程会为矩阵a的每一行重复进行。
对于矩阵a中每一行的每个元素,矩阵b中有一个对应的列元素。矩阵a的列数和矩阵b的行数是相同的,但我们可以将其表示为len(a[0])或len(b)。我选择了len(b)。因此,在第三个循环中,k将重复len(b)次。矩阵a的第i行的第一个元素与矩阵b的第j列的第一个元素将相乘,可以写成这样:
a[i][0] * b[0][j]
对于矩阵a的第i行的第二个元素和矩阵b的第j列的第二个元素,同样的操作:
a[i][1] * b[1][j]
因此,对于每一列(在j循环中),我们将从 0 开始累加求和(因为sum已经是 Python 的关键字,所以我使用sum1),并且它会随着每个k元素的增加而增加:
sum1 += a[i][k] * b[k][j]
看起来不算很多,但这行代码会跟踪并相乘所有对应的元素!在完成所有k元素的循环后(即k循环结束后),我们将把和添加到行中,并且一旦遍历完矩阵b中的所有列(即j循环结束后),我们将把该行放入newmatrix中。完成矩阵a中的所有行后,我们返回结果矩阵。
让我们通过将我们的样本矩阵相乘来测试这个程序,乘以一个 1 × 4 的矩阵和一个 4 × 2 的矩阵:
>>> a = [[1,2,-3,-1]]
>>> b = [[4,-1],
[-2,3],
[6,-3],
[1,0]]
>>> print(multmatrix(a,b))
[[-19, 14]]
这样检查是正确的:

因此,我们的新函数用于乘任何两个矩阵(如果它们可以相乘)工作。让我们通过将一个 2 × 2 的矩阵与一个 2 × 2 的矩阵相乘来测试它:

输入以下内容来将矩阵a乘以矩阵b:
>>> a = [[1,-2],[2,1]]
>>> b = [[3,-4],[5,6]]
>>> multmatrix(a,b)
[[-7, -16], [11, -2]]
该代码展示了如何使用 Python 列表输入 2 × 2 的矩阵。矩阵乘法也像这样:
让我们检查这些答案。我们从将 a 的第一行与 b 的第一列相乘开始:
(1)(3) + (–2)(5) = 3 – 10 = –7
而 –7 是结果矩阵中第一行、第一列的数字。接下来我们将 a 的第二行与 b 的第一列相乘:
(2)(3) + (1)(5) = 6 + 5 = 11
11 是结果矩阵中第二行、第一列的数字。其他数字也是正确的。multmatrix() 函数将帮助我们避免做大量繁琐的计算!
矩阵乘法中顺序很重要
关于矩阵乘法的一个重要事实是 A × B 不一定等于 B × A。让我们通过反转之前的示例来证明这一点:

下面是在 Python shell 中反向乘法的方法:
>>> a = [[1,-2],[2,1]]
>>> b = [[3,-4],[5,6]]
>>> multmatrix(b,a)
[[-5, -10], [17, -4]]
如你所见,当你用 multmatrix(b,a) 而不是 multmatrix(a,b) 反向相乘相同的矩阵时,你会得到完全不同的结果矩阵。记住,矩阵相乘时,A × B 不一定等于 B × A。
绘制二维形状
现在你知道如何进行矩阵操作了,让我们将一堆点放入列表中,形成一个二维形状。在 Processing 中打开一个新草图,并将其保存为 matrices.pyde。如果你仍然保留着 列表 4-11 中的 grid.pyde 草图,可以复制并粘贴绘制网格的核心代码。否则,请添加 列表 8-3 中的代码。
*matrices.pyde*
#set the range of x-values
xmin = -10
xmax = 10
#range of y-values
ymin = -10
ymax = 10
#calculate the range
rangex = xmax - xmin
rangey = ymax - ymin
def setup():
global xscl, yscl
size(600,600)
#the scale factors for drawing on the grid:
xscl= width/rangex
yscl= -height/rangey
noFill()
def draw():
global xscl, yscl
background(255) #white
translate(width/2,height/2)
grid(xscl, yscl)
def grid(xscl,yscl):
'''Draws a grid for graphing'''
#cyan lines
strokeWeight(1)
stroke(0,255,255)
for i in range(xmin,xmax+1):
line(i*xscl,ymin*yscl,i*xscl,ymax*yscl)
for i in range(ymin,ymax+1):
line(xmin*xscl,i*yscl,xmax*xscl,i*yscl)
stroke(0) #black axes
line(0,ymin*yscl,0,ymax*yscl)
line(xmin*xscl,0,xmax*xscl,0)
列表 8-3:绘制网格的代码
我们将绘制一个简单的图形,并通过矩阵变换它。我将使用字母F,因为它没有旋转或对称反射的特性(而且因为它是我的名字首字母)。我们将先画出它的轮廓来获得点,如 图 8-2 所示。

图 8-2:绘制 F 所需的点
在 draw() 函数后添加 列表 8-4 中的代码,以输入 F 的所有角点,并在这些点之间画线。
fmatrix = [[0,0],[1,0],[1,2],[2,2],[2,3],[1,3],[1,4],[3,4],[3,5],[0,5]]
def graphPoints(matrix):
#draw line segments between consecutive points
beginShape()
for pt in matrix:
vertex(pt[0]*xscl,pt[1]*yscl)
endShape(CLOSE)
列表 8-4:绘制 F 的点
在这里,我们首先创建一个名为 fmatrix 的列表,并在每一行中输入与字母 F 中的点相对应的坐标。graphPoints() 函数以矩阵为参数,每一行成为形状的一个顶点,使用 Processing 的 beginShape() 和 endShape() 函数。同时,我们在 draw() 函数中使用 fmatrix 作为参数调用 graphPoints() 函数。在 draw() 函数的末尾添加 列表 8-5 中的代码:
strokeWeight(2) #thicker line
stroke(0) #black
graphPoints(fmatrix)
列表 8-5:让程序绘制 F 的点
我们正在创建 fmatrix,它是一个包含许多坐标的列表,并且调用 graphPoints() 函数来指示程序绘制所有的点。
Processing 内置的strokeWeight()函数允许你控制轮廓的厚度,而stroke()函数让你选择轮廓的颜色。我们将用黑色绘制第一个F。输出结果如图 8-3 所示。

图 8-3:绘制矩阵中点的输出,称为“f-矩阵”
当我们在学校学习矩阵时,我们学会了如何进行加法和乘法运算,但我们从未学过为什么。只有当你将它们绘制出来时,才会意识到矩阵乘法实际上是在变换它们。接下来,我们将使用矩阵乘法来变换我们的F。
变换矩阵
为了展示如何通过矩阵相乘来进行变换,我们将使用我在网上找到的一个 2 × 2 的变换矩阵(见图 8-4)。

图 8-4:一个在网上找到的变换矩阵 mathworld.wolfram.com
它将把我们的F逆时针旋转一个角度,角度由θ(θ)给出。如果角度是 90 度,则 cos(90) = 0 且 sin(90) = 1。因此,逆时针旋转 90 度的旋转矩阵是

我们可以通过在setup()函数之前,将以下代码添加到matrices.pyde文件中来创建一个变换矩阵:
transformation_matrix = [[0,-1],[1,0]]
接下来,我们将 f-矩阵与变换矩阵相乘,并将结果保存到一个新矩阵中。由于 f-矩阵是 10 × 2 矩阵,而变换矩阵是 2 × 2 矩阵,所以它们相乘的唯一方式是 F × T,而不是 T × F。
记住,第一个矩阵的列数必须等于第二个矩阵的行数。我们将用黑色绘制 f-矩阵,并将新矩阵的描边颜色更改为红色。通过将以下代码添加到draw()函数中的清单 8-6 来替换graphPoints(fmatrix)。
newmatrix = multmatrix(fmatrix,transformation_matrix)
graphPoints(fmatrix)
stroke(255,0,0) #red resultant matrix
graphPoints(newmatrix)
清单 8-6:矩阵相乘并绘制点
当你运行这个时,它将显示为图 8-5。

图 8-5:顺时针旋转?
这不是逆时针旋转!再看一下图 8-4 中的数学表示法,我们发现乘法的顺序与我们的不同。标准的方法是先乘以变换矩阵,再乘以要变换的点:

这意味着变换后的向量 v(v')是通过将旋转矩阵 R[θ]与初始向量 v[0]相乘得到的。向量表示法不同于坐标表示法。例如,向量在 x 方向上为 2,y 方向上为 3,不是像标准(x,y)坐标那样给出为(2,3)。相反,它表示为

它像一个 2 × 1 矩阵,而不是 1 × 2 矩阵。在我们的列表表示法中,我们会将其写作[[2],[3]]。这意味着我们需要将我们的 f-矩阵更改为
fmatrix = [[[0],[0]],[[1],[0]],[[1],[2]],[[2],[2]],[[2],[3]],
[[1],[3]],[[1],[4]],[[3],[4]],[[3],[5]],[[0],[5]]]
或者
fmatrix = [[0,1,1,2,2,1,1,3,3,0],[0,0,2,2,3,3,4,4,5,5]]
第一个例子至少能保持点的 x 和 y 值在一起,但这有很多括号!第二个例子甚至没有将 x 和 y 值放在一起。我们来看看是否有其他方法。
转置矩阵
在矩阵中,一个重要的概念是 转置,即将列变成行,反之亦然。在我们的例子中,我们想将 F 转换成 F^T,即“f 矩阵的转置”。

让我们编写一个 transpose() 函数,它将转置任何矩阵。将 列表 8-7 中的代码添加到 matrices.pyde 的 draw() 函数后面。
def transpose(a):
'''Transposes matrix a'''
output = []
m = len(a)
n = len(a[0])
#create an n x m matrix
for i in range(n):
output.append([])
for j in range(m):
#replace a[i][j] with a[j][i]
output[i].append(a[j][i])
return output
列表 8-7:转置矩阵的代码
首先,我们创建一个名为 output 的空列表,它将是转置后的矩阵。接着,我们定义 m,矩阵的行数,以及 n,矩阵的列数。我们将把 output 变成一个 n × m 的矩阵。对于所有 n 行,我们将开始一个空列表,然后将矩阵中第 i 行的所有内容添加到转置矩阵的第 j 列。
transpose 函数中的以下代码行交换了 a 的行和列:
output[i].append(a[j][i])
最后,我们返回转置后的矩阵。让我们测试一下。将 transpose() 函数添加到你的 matrices.py 文件中并运行它。然后我们可以在 shell 中输入以下代码:
>>> a = [[1,2,-3,-1]]
>>> transpose(a)
[[1], [2], [-3], [-1]]
>>> b = [[4,-1],
[-2,3],
[6,-3],
[1,0]]
>>> transpose(b)
[[4, -2, 6, 1], [-1, 3, -3, 0]]
它有效!我们需要做的就是在将 f 矩阵与变换矩阵相乘之前对其进行转置。为了绘制它,我们会将其转置回去,如 列表 8-8 中所示。
*matrices.pyde*
def draw():
global xscl, yscl
background(255) #white
translate(width/2,height/2)
grid(xscl, yscl)
strokeWeight(2) #thicker line
stroke(0) #black
➊ newmatrix = transpose(multmatrix(transformation_matrix,
➋ transpose(fmatrix)))
graphPoints(fmatrix)
stroke(255,0,0) #red resultant matrix
graphPoints(newmatrix)
列表 8-8:转置矩阵、相乘,然后再转置
将 transpose() ➋ 函数的调用添加到 draw() 函数的 newmatrix 行 ➊。这样应该能获得正确的逆时针旋转,如 图 8-6 所示。

图 8-6:通过矩阵进行逆时针旋转
最终的 matrices.pyde 代码应如下所示 列表 8-9。
*matrices.pyde*
#set the range of x-values
xmin = -10
xmax = 10
#range of y-values
ymin = -10
ymax = 10
#calculate the range
rangex = xmax - xmin
rangey = ymax - ymin
transformation_matrix = [[0,-1],[1,0]]
def setup():
global xscl, yscl
size(600,600)
#the scale factors for drawing on the grid:
xscl= width/rangex
yscl= -height/rangey
noFill()
def draw():
global xscl, yscl
background(255) #white
translate(width/2,height/2)
grid(xscl,yscl)
strokeWeight(2) #thicker line
stroke(0) #black
newmatrix = transpose(multmatrix(transformation_matrix,
transpose(fmatrix)))
graphPoints(fmatrix)
stroke(255,0,0) #red resultant matrix
graphPoints(newmatrix)
fmatrix = [[0,0],[1,0],[1,2],[2,2],[2,3],[1,3],[1,4],[3,4],[3,5],[0,5]]
def multmatrix(a,b):
'''Returns the product of
matrix a and matrix b'''
m = len(a) #number of rows in first matrix
n = len(b[0]) #number of columns in second matrix
newmatrix = []
for i in range(m): #for every row in a
row = []
#for every column in b
for j in range(n):
sum1 = 0
#for every element in the column
for k in range(len(b)):
sum1 += a[i][k]*b[k][j]
row.append(sum1)
newmatrix.append(row)
return newmatrix
def transpose(a):
'''Transposes matrix a'''
output = []
m = len(a)
n = len(a[0])
#create an n x m matrix
for i in range(n):
output.append([])
for j in range(m):
#replace a[i][j] with a[j][i]
output[i].append(a[j][i])
return output
def graphPoints(matrix):
#draw line segments between consecutive points
beginShape()
for pt in matrix:
vertex(pt[0]*xscl,pt[1]*yscl)
endShape(CLOSE)
def grid(xscl, yscl):
'''Draws a grid for graphing'''
#cyan lines
strokeWeight(1)
stroke(0,255,255)
for i in range(xmin,xmax + 1):
line(i*xscl,ymin*yscl,i*xscl,ymax*yscl)
for i in range(ymin,ymax+1):
line(xmin*xscl,i*yscl,xmax*xscl,i*yscl)
stroke(0) #black axes
line(0,ymin*yscl,0,ymax*yscl)
line(xmin*xscl,0,xmax*xscl,0)
列表 8-9:绘制并变换字母 F 的完整代码
练习 8-1:更多的变换矩阵
查看当你将变换矩阵改为这些矩阵时,形状会发生什么变化:
### 实时旋转矩阵
所以你刚刚学到了矩阵如何变换点。但这个过程可以实时进行,也可以进行交互式操作!将 matrices.pyde 中 draw() 函数的代码更改为 列表 8-10 中的内容。
def draw():
global xscl, yscl
background(255) #white
translate(width/2,height/2)
grid(xscl, yscl)
ang = map(mouseX,0,width,0,TWO_PI)
rot_matrix = [[cos(ang),-sin(ang)],
[sin(ang),cos(ang)]]
newmatrix = transpose(multmatrix(rot_matrix,transpose(fmatrix)))
graphPoints(fmatrix)
strokeWeight(2) #thicker line
stroke(255,0,0) #red resultant matrix
graphPoints(newmatrix)
列表 8-10:使用矩阵实时旋转
回想一下,我们在 第七章 中使用了 sin() 和 cos() 来旋转和振荡形状。在这个例子中,我们正在使用旋转矩阵变换一个点的矩阵。这里是一个典型的 2 × 2 旋转矩阵的样子:

因为我没有 θ(θ)键,我将旋转角度称为ang。我们现在做的有趣的事情是通过鼠标改变ang变量。因此,在每次循环时,鼠标位置决定ang的值,然后将ang代入每个表达式。它快速计算ang的正弦和余弦,并将旋转矩阵与 f 矩阵相乘。每次循环时,旋转矩阵会略有不同,具体取决于鼠标的位置。

图 8-7:使用矩阵实时变换点!
现在,当你在图表上左右移动鼠标时,红色的F应该围绕原点旋转,如图 8-7 所示。
这就是你在计算机屏幕上看到任何动画时发生的变换。创建计算机图形学可能是矩阵最常见的应用。
创建 3D 形状
到目前为止,我们已经使用矩阵创建和操作二维形状。你可能会好奇,我们数学家是如何计算数字,将三维物体表示在像计算机屏幕这样的二维表面上的。
返回清单 8-11 中的代码,并将其保存为matrices3D.pyde。将fmatrix转换为以下点矩阵:
fmatrix = [[0,0,0],[1,0,0],[1,2,0],[2,2,0],[2,3,0],[1,3,0],[1,4,0],
[3,4,0],[3,5,0],[0,5,0],
[0,0,1],[1,0,1],[1,2,1],[2,2,1],[2,3,1],[1,3,1],[1,4,1],
[3,4,1],[3,5,1],[0,5,1]]
清单 8-11:我们 f 矩阵的 3D 版本
为我们的F添加深度需要向我们的点矩阵中添加另一个层。因为我们的F现在只有二维,所以它只由 x 和 y 值组成。但是我们可以将二维物体视为具有第三维度,表示为 z 轴。二维物体的 z 值为 0。因此,对于每个点,我们将添加一个零作为其第三个值,使前 10 个点成为三维。然后,我们将这些值复制粘贴,并将第三个值更改为 1。这将创建后层,这是一个与前层F相同的图形,且位于前层后面一个单位。
现在我们已经为F创建了两个层,我们需要将前层的点与后层的点连接起来。我们创建一个edges列表,这样就可以简单地告诉程序要用线段连接哪些点,如清单 8-12 所示。
#list of points to connect:
edges = [[0,1],[1,2],[2,3],[3,4],[4,5],[5,6],[6,7],
[7,8],[8,9],[9,0],
[10,11],[11,12],[12,13],[13,14],[14,15],[15,16],[16,17],
[17,18],[18,19],[19,10],
[0,10],[1,11],[2,12],[3,13],[4,14],[5,15],[6,16],[7,17],
[8,18],[9,19]]
清单 8-12:跟踪边(点与点之间的线F)
这是跟踪哪些点将被线段连接的方式,或称为边。例如,第一个条目[0,1]从点 0(0,0,0)到点 1(1,0,0)画一条边。前 10 条边画出前F,接下来的 10 条边画出后F。然后我们画出连接前F上的点与后F上相应点之间的边。例如,边[0,10]画出点 0(0,0,0)与点 10(0,0,1)之间的线段。
现在,在绘制这些点时,我们不仅仅是在相邻的点之间绘制线条。列表 8-13 显示了新的 graphPoints() 函数,它绘制了列表中点与点之间的 边缘。将旧的 graphPoints() 函数替换为以下代码,放在 grid() 函数的定义之前。
def graphPoints(pointList,edges):
'''Graphs the points in a list using segments'''
for e in edges:
line(pointList[e[0]][0]*xscl,pointList[e[0]][1]*yscl,
pointList[e[1]][0]*xscl,pointList[e[1]][1]*yscl)
列表 8-13:使用边缘绘制点
记住,在 Processing 中,你通过 line(x1,y1,x2,y2) 在两个点(x1, y1)和(x2, y2)之间绘制一条线。在这里,我们通过在 edges 列表中的数字来调用 pointList 中的点(当我们运行时将传递 fmatrix)。该函数会遍历 edges 列表中的每个元素 e,并将由第一个数字 e[0] 表示的点与由第二个数字 e[1] 表示的点连接起来。x 坐标会乘以 xscl 变量,这个变量会缩放 x 值:
pointList[e[0]][0]*xscl
我们对 y 坐标做相同的操作:
pointList[e[0]][1]*yscl
我们可以通过创建两个旋转变量:rot 和 tilt,让鼠标代表旋转角度。第一个变量 rot 将鼠标的 x 值映射到 0 到 2π 之间的角度,这个值将放入我们在 列表 8-5 中制作的旋转矩阵中。我们对 tilt 做同样的操作,使其能映射鼠标的 y 值。在将矩阵相乘之前,将 列表 8-14 中的代码放入 draw() 函数中。
rot = map(mouseX,0,width,0,TWO_PI)
tilt = map(mouseY,0,height,0,TWO_PI)
列表 8-14:将上下和左右旋转与鼠标移动关联起来
接下来,我们将创建一个函数,将旋转矩阵相乘,以便将所有的变换合并到一个矩阵中。这就是使用矩阵乘法执行变换的优点。你只需要通过乘法不断“添加”更多的变换!
创建旋转矩阵
现在,让我们将两个单独的旋转矩阵合并成一个单一的旋转矩阵。如果你在数学书中看到 3D 旋转矩阵,它们可能会像以下方程那样:

Ry 会旋转这些点,y 轴作为旋转轴,所以这是一个左右旋转。Rx 会围绕 x 轴旋转这些点,因此它是一个上下旋转。
列表 8-15 显示了创建 rottilt() 函数的代码,该函数将接收 rot 和 tilt 值,并将它们放入矩阵中。这就是我们如何将两个矩阵合并成一个矩阵。将 列表 8-15 中的代码添加到 matrices3D.pyde 文件中:
def rottilt(rot,tilt):
#returns the matrix for rotating a number of degrees
rotmatrix_Y = [[cos(rot),0.0,sin(rot)],
[0.0,1.0,0.0],
[-sin(rot),0.0,cos(rot)]]
rotmatrix_X = [[1.0,0.0,0.0],
[0.0,cos(tilt),sin(tilt)],
[0.0,-sin(tilt),cos(tilt)]]
return multmatrix(rotmatrix_Y,rotmatrix_X)
列表 8-15:创建旋转矩阵的函数
我们将rotmatrix_Y和rotmatrix_X相乘,得到一个旋转矩阵作为输出。当有一系列矩阵操作时,这非常有用,比如围绕 x 轴旋转R[x],围绕 y 轴旋转R[y],缩放S,平移T。我们可以将所有这些操作合并到一个矩阵中,而不是为每个操作执行单独的乘法。矩阵乘法使我们能够创建一个新的矩阵:M = R[y](R[x](S(T)))。这意味着我们的draw()函数也会改变。通过上述新增内容,draw()函数应该像清单 8-16 中所示:
def draw():
global xscl, yscl
background(255) #white
translate(width/2,height/2)
grid(xscl, yscl)
rot = map(mouseX,0,width,0,TWO_PI)
tilt = map(mouseY,0,height,0,TWO_PI)
newmatrix = transpose(multmatrix(rottilt(rot,tilt),transpose(fmatrix)))
strokeWeight(2) #thicker line
stroke(255,0,0) #red resultant matrix
graphPoints(newmatrix,edges)
清单 8-16:新的 draw() 函数
当你运行程序时,你会看到图 8-8 中显示的内容。

图 8-8:一个 3D F!
我们可以去掉蓝色网格,并通过更改xmin、xmax、ymin和ymax变量,以及注释掉draw()中对grid()函数的调用来增大F的尺寸。
清单 8-17 展示了绘制旋转 3D 形状的完整代码。
*matrices3D.pyde*
#set the range of x-values
xmin = -5
xmax = 5
#range of y-values
ymin = -5
ymax = 5
#calculate the range
rangex = xmax - xmin
rangey = ymax - ymin
def setup():
global xscl, yscl
size(600,600)
#the scale factors for drawing on the grid:
xscl= width/rangex
yscl= -height/rangey
noFill()
def draw():
global xscl, yscl
background(0) #black
translate(width/2,height/2)
rot = map(mouseX,0,width,0,TWO_PI)
tilt = map(mouseY,0,height,0,TWO_PI)
strokeWeight(2) #thicker line
stroke(0) #black
newmatrix = transpose(multmatrix(rottilt(rot,tilt),transpose(fmatrix)))
#graphPoints(fmatrix)
stroke(255,0,0) #red resultant matrix
graphPoints(newmatrix,edges)
fmatrix = [[0,0,0],[1,0,0],[1,2,0],[2,2,0],[2,3,0],[1,3,0],[1,4,0],
[3,4,0],[3,5,0],[0,5,0],
[0,0,1],[1,0,1],[1,2,1],[2,2,1],[2,3,1],[1,3,1],[1,4,1],
[3,4,1],[3,5,1],[0,5,1]]
#list of points to connect:
edges = [[0,1],[1,2],[2,3],[3,4],[4,5],[5,6],[6,7],
[7,8],[8,9],[9,0],
[10,11],[11,12],[12,13],[13,14],[14,15],[15,16],[16,17],
[17,18],[18,19],[19,10],
[0,10],[1,11],[2,12],[3,13],[4,14],[5,15],[6,16],[7,17],
[8,18],[9,19]]
def rottilt(rot,tilt):
#returns the matrix for rotating a number of degrees
rotmatrix_Y = [[cos(rot),0.0,sin(rot)],
[0.0,1.0,0.0],
[-sin(rot),0.0,cos(rot)]]
rotmatrix_X = [[1.0,0.0,0.0],
[0.0,cos(tilt),sin(tilt)],
[0.0,-sin(tilt),cos(tilt)]]
return multmatrix(rotmatrix_Y,rotmatrix_X)
def multmatrix(a,b):
'''Returns the product of
matrix a and matrix b'''
m = len(a) #number of rows in first matrix
n = len(b[0]) #number of columns in second matrix
newmatrix = []
for i in range(m): #for every row in a
row = []
#for every column in b
for j in range(n):
sum1 = 0
#for every element in the column
for k in range(len(b)):
sum1 += a[i][k]*b[k][j]
row.append(sum1)
newmatrix.append(row)
return newmatrix
def graphPoints(pointList,edges):
'''Graphs the points in a list using segments'''
for e in edges:
line(pointList[e[0]][0]*xscl,pointList[e[0]][1]*yscl,
pointList[e[1]][0]*xscl,pointList[e[1]][1]*yscl)
def transpose(a):
'''Transposes matrix a'''
output = []
m = len(a)
n = len(a[0])
#create an n x m matrix
for i in range(n):
output.append([])
for j in range(m):
#replace a[i][j] with a[j][i]
output[i].append(a[j][i])
return output
清单 8-17:旋转 3D F的完整代码
我去掉了网格,并将draw()函数中对background()函数的调用更改为background(0),这样背景会变为黑色,F将看起来在外太空中旋转(见图 8-9)!

图 8-9:移动鼠标将使 F 发生变化!
使用矩阵解线性方程组
你是否曾经尝试过解一个有两个或三个未知数的方程组?对于任何数学学生来说,这是一个棘手的任务。随着未知数的增加,方程组变得更加复杂。矩阵在解决这种方程组时非常有用:

例如,你可以使用矩阵表示这种乘法:

这看起来类似于代数方程 2x = 10,我们可以通过将两边同时除以 2 来轻松求解。如果我们能将系统的两边都除以左边的矩阵就好了!
实际上,有一种方法可以做到这一点,即通过求矩阵的逆,类似于你可以通过乘以 ½ 来将一个数字除以 2。这被称为 2 的乘法逆,但它是一种复杂的方法。
高斯消元法
使用矩阵解线性方程组的更高效方法是使用行变换将左边的 2 × 2 矩阵转换为单位矩阵,单位矩阵代表数字 1。例如,将一个矩阵与单位矩阵相乘,结果就是原矩阵,像这样:

右边的数字就是x和y的解,因此我们的目标是将这些零和一放置在正确的位置。正确的位置就是矩阵的对角线,像这样:

每个方阵中的单位矩阵在对角线上有 1,且行号等于列号。
高斯消元法是一种通过对矩阵的整行进行操作来得到单位矩阵的方法。你可以将一行乘以常数或除以常数,也可以将一行加到或从另一行中减去。
在使用高斯消元法之前,我们首先需要将系数和常数排列成一个矩阵,如下所示:

然后,我们将整个行除以一个数,使得左上角变为 1。这意味着我们首先需要将第一行的所有项除以 2,因为 2/2 等于 1。这个操作会给我们以下结果:

现在,我们得到我们想要零的位置的加法逆元(与另一个数相加得到零的数)。例如,在第二行中,我们希望在 3 的位置得到零,因为我们希望将这个矩阵转化为单位矩阵。由于 3 的加法逆元是–3,我们将第一行的每一项乘以–3,并将结果加到第二行对应的项上。也就是说,我们将第一行中的 1 乘以–3,然后将结果(仍然是–3)加到第二行。我们对行中的所有项都执行这个过程。例如,第三列中的–1/2 将乘以–3(得到 1.5),然后加到该列中的所有数值上。在这种情况下,它是–13,因此和为–11.5 或–23/2。继续这个过程,你应该得到以下结果:

现在,我们在第二行中想要得到 1 的位置重复这个过程。我们可以将第二行中的所有项乘以–2/23,这应该给我们如下结果:

最后,我们将第一行的所有项加到第二行,乘以 5/2 的加法逆元,这样就能在第一行得到零。我们将第一行的每一项加到对应的第二行项,乘以–5/2。注意,这不会影响第一行中的 1,我们希望保留这个 1:

方程组的解现在在右边的列中:x = –3,y = 1。
我们可以通过将这些数值代入原方程组来检查我们的答案:

两个解都是正确的,但这个过程非常繁琐。让我们使用 Python 来自动化这个过程,这样我们就可以解决任意大小的方程组了!
编写 GAUSS()函数
在本节中,我们编写了一个名为gauss()的函数,用于为我们解线性方程组。尝试通过编程来实现这个过程看起来可能很复杂,但实际上我们只需要编码两个步骤:
-
将行中的所有元素除以对角线上的元素。
-
将一行中的每一项加到另一行中对应的项。
将一行中的所有项除以常数
第一个任务是将一行中的所有项除以一个数。假设我们有一行数字 [1,2,3,4,5]。例如,我们可以使用 Listing 8-18 中的代码将此行除以 2。打开一个新的 Python 文件,命名为 gauss.py 并输入 Listing 8-18 中的代码。
divisor = 2
row = [1,2,3,4,5]
for i, term in enumerate(row):
row[i] = term / divisor
print(row)
Listing 8-18: 将每一行的所有项除以一个除数
这段代码遍历 row 列表,使用 enumerate() 函数跟踪索引和值。然后,我们将每个项 row[i] 用除数除过的结果替换。当你运行它时,你将得到一个包含五个值的列表:
[0.5, 1.0, 1.5, 2.0, 2.5]
将每个元素加到其对应元素上
第二个任务是将一行中的每个元素与另一行中相应的元素相加。例如,将第 0 行下面的所有元素加到第 1 行的元素上,并用和替换第 1 行的元素:
>>> my_matrix = [[2,-4,6,-8],
[-3,6,-9,12]]
>>> for i in range(len(my_matrix[1])):
my_matrix[1][i] += my_matrix[0][i]
>>> print(my_matrix)
[[2, -4, 6, -8], [-1, 2, -3, 4]]
我们正在循环遍历 my_matrix 的第二行(索引 1)中的所有项。然后我们将第二行中的每个项(索引 i)递增,增加对应第一行(索引 0)中的项。我们成功地将第一行的项加到第二行的项上。请注意,第一行没有改变。我们将在解线性方程组时使用这些步骤。
对每一行重复这个过程
现在我们只需要将这些步骤组合在一起,应用到矩阵中的所有行。我们将矩阵命名为 A。一旦我们把 x, y 和 z 的系数以及常数项排好顺序,我们只需将系数和常数项放入矩阵中:

首先,我们将行中的每个项除以对角线上的项,使得对角线项变为 1,使用 Listing 8-19 中的代码。
for j,row in enumerate(A):
#diagonal term to be 1
#by dividing row by diagonal term
if row[j] != 0: #diagonal term can't be 0
divisor = row[j] #diagonal term
for i, term in enumerate(row):
row[i] = term / divisor
Listing 8-19: 将每个项除以该行的对角线项
使用 enumerate,我们可以获取 A 的第一行(例如 [2,1,-1,8]),并且 j 将是该行的索引(在这种情况下为零)。对角线项是行号与列号相同的地方,比如第 0 行、第 0 列,或者第 1 行、第 1 列。
现在我们遍历矩阵中的每个其他行,执行第二步。对于每个其他行(其中 i 不等于 j),计算 j 项的加法逆元,将该逆元乘以第 j 行中的每一项,并将这些项加到对应的第 i 行中的项上。将 Listing 8-20 中的代码添加到 gauss() 函数中。
for i in range(m):
if i != j: #don't do this to row j
#calculate the additive inverse
addinv = -1*A[i][j]
#for every term in the ith row
for ind in range(n):
#add the corresponding term in the jth row
#multiplied by the additive inverse
#to the term in the ith row
A[i][ind] += addinv*A[j][ind]
Listing 8-20: 将每行的非对角线项变为 0
这会发生在每一行,因此由于 m 是行数,我们从 for i in range(m) 开始。我们已经将相关行除以对角线上的元素,因此无需对该行进行其他操作。也正因如此,只有当 i 不等于 j 时我们才会进行操作。在我们的示例中,A 的第一行中的每个元素将乘以 3 并加到第二行的相应元素上。然后,第一行中的每个元素将乘以 2 并加到第三行的相应元素上。这将使得第一列的第二行和第三行变为零:

现在我们的第一列已经完成,我们希望对角线上的值是 1。因此,我们希望第二列第二行的值为 1,于是我们重复这一过程。
整合所有内容
将所有代码整合成一个 gauss() 函数,并输出结果。清单 8-21 显示了完整代码。
def gauss(A):
'''Converts a matrix into the identity
matrix by Gaussian elimination, with
the last column containing the solutions
for the variables'''
m = len(A)
n = len(A[0])
for j,row in enumerate(A):
#diagonal term to be 1
#by dividing row by diagonal term
if row[j] != 0: #diagonal entry can't be zero
divisor = row[j]
for i, term in enumerate(row):
row[i] = term / divisor
#add the other rows to the additive inverse
#for every row
for i in range(m):
if i != j: #don't do it to row j
#calculate the additive inverse
addinv = -1*A[i][j]
#for every term in the ith row
for ind in range(n):
#add the corresponding term in the jth row
#multiplied by the additive inverse
#to the term in the ith row
A[i][ind] += addinv*A[j][ind]
return A
#example:
B = [[2,1,-1,8],
[-3,-1,2,-1],
[-2,1,2,-3]]
print(gauss(B))
清单 8-21:完整的 gauss() 函数代码
输出应该是以下内容:
[[1.0, 0.0, 0.0, 32.0], [0.0, 1.0, 0.0, -17.0], [-0.0, -0.0, 1.0, 39.0]]
这是矩阵形式的表现:

我们查看每行的最后一个数字,因此我们的解是 x = 32, y = –17 和 z = 39。我们通过将这些值代入原始方程来检查:

这是一个重要的成就!现在,我们不仅能解两元或三元的方程组,还能解任意数量未知数的方程组!如果学生不知道 Python,解四元方程组是一项繁重的任务。但幸运的是,我们知道!当正确的解答迅速出现在 Python 命令行中时,我总是感到震惊。如果你曾经手动执行过高斯消元法,练习 8-2 也会让你震撼。
练习 8-2:进入矩阵
使用你刚才编写的程序解这个方程组的 w, x, y 和 z:

总结
你在数学探险的旅程中走了很长一段路!你从一些基本的 Python 开始,做一些简单的海龟图形操作,然后逐步创建更复杂的 Python 函数来解决更难的数学问题。在本章中,你不仅学会了如何使用 Python 来加法和乘法矩阵,还亲身体验了矩阵如何创建并转换 2D 和 3D 图形!我们使用 Python 来加法、乘法、转置以及进行其他矩阵操作的能力真是让人叹为观止。
你还学会了自动化你本来会手动完成的过程,以求解一个方程组。适用于 3 × 3 矩阵的同一个程序也能用于 4 × 4 或任何更大的方阵!
矩阵是构建神经网络的关键工具,神经网络中有数十个甚至数百条路径连接虚拟神经元。输入通过矩阵乘法和转置在网络中“传播”,这些正是你在本章中创建的工具。
曾几何时,像你在本章所做的事情,对于没有访问庞大昂贵计算机的人来说是遥不可及的,那些计算机需要占用大学或大型企业整整一层楼的空间。而现在,你可以使用 Python 进行高速的矩阵计算,并通过 Processing 可视化结果!
在本章中,我指出了我们能够即时获得复杂方程组的解,以及在探索图形时可以即时响应鼠标移动的优势。在下一章,我们将创建一个包含草和羊的生态系统模型,并让它自行运行。随着羊的出生、吃草、繁殖和死亡,模型将随时间变化。只有在让模型运行一分钟或更长时间后,我们才能判断环境是否能够在草生长和羊吃草、繁殖之间找到平衡。
第三部分:开辟你自己的道路
第九章:使用类构建对象
老教师永远不会死,他们只是失去了他们的班级。
—匿名*

现在,使用函数和 Processing 中的其他代码,你已经创建了很酷的图形,你可以通过使用类来进一步激发你的创造力。类是一种结构,它让你可以创建新的对象类型。对象类型(通常称为对象)可以有属性,即变量,也可以有方法,即函数。有时你可能想要在 Python 中绘制多个对象,但绘制太多会非常麻烦。类让你能够轻松地绘制多个具有相同属性的对象,但它们需要特定的语法,你需要学习。
以下是来自官方 Python 网站的示例,展示了如何使用类创建一个“狗”对象。要进行编码,可以在 IDLE 中打开一个新文件,将其命名为dog.py,并输入以下代码。
*dog.py*
class Dog:
def __init__(self,name):
self.name = name
这行代码通过class Dog创建了一个新的对象,名为Dog。在 Python 以及许多其他语言中,类名通常是大写的,但即使你不这样做,它仍然能正常工作。为了实例化,或者说创建这个类,我们必须使用 Python 的__init__方法,init前后都有两个下划线,这意味着它是一个特殊的方法,用于创建(或构造)对象。__init__这一行使得我们可以创建类的实例(在这里就是狗)。在__init__方法中,我们可以创建类的任何属性。由于这是狗,它可以有一个名字,而且每只狗都有自己的名字,所以我们使用self语法。我们在调用对象时不需要使用它,只在定义时使用。
然后,我们可以使用以下代码行创建一个有名字的狗:
d = Dog('Fido')
现在,d是一个Dog,它的名字是 Fido。你可以通过运行文件并在 shell 中输入以下内容来确认这一点:
>>> d.name
'Fido'
现在,当我们调用d.name时,我们会得到 Fido,因为那是我们刚才赋予它的name属性。我们可以创建另一个Dog并给它命名为 Bettisa,像这样:
>>> b = Dog('Bettisa')
>>> b.name
'Bettisa'
你可以看到一只狗的名字和另一只狗的名字是不同的,但程序能完美记住它们!当我们为我们创建的对象赋予位置和其他属性时,这将非常重要。
最后,我们可以通过在类中放置一个函数来让狗做点事情。但别称它为函数!类中的函数叫做方法。狗会叫,所以我们将在示例 9-1 的代码中添加这个方法。
*dog.py*
class Dog:
def __init__(self,name):
self.name = name
def bark(self):
print("Woof!")
d = Dog('Fido')
示例 9-1:创建一只会叫的狗!
当我们调用d狗的bark()方法时,它会叫:
>>> d.bark()
Woof!
从这个简单的例子中,你可能不太明白为什么需要一个Dog类,但知道你可以用类做任何你想做的事情,发挥创意,真的是很重要的。在这一章,我们将使用类来创建许多有用的对象,比如弹跳的球和吃草的羊。让我们从弹跳球的例子开始,看看如何通过使用类来做一些很酷的事情,同时节省很多工作量。
弹跳球程序
启动一个 Processing 草图并将其保存为BouncingBall.pyde。我们将在屏幕上绘制一个圆形,并将其变成一个弹跳的小球。清单 9-2 展示了绘制一个圆形的代码。
*BouncingBall.pyde*
def setup():
size(600,600)
def draw():
background(0) #black
ellipse(300,300,20,20)
清单 9-2:绘制一个圆形
首先,我们将窗口的大小设置为 600 像素宽和 600 像素高。然后,我们将背景设置为黑色,并使用ellipse()函数绘制一个圆形。函数中的前两个数字表示圆心距离窗口左上角的水平和垂直距离,最后两个数字表示椭圆的宽度和高度。在这个例子中,ellipse(300,300, 20,20)创建了一个 20 像素宽、20 像素高的圆形,位于显示窗口的中心,如图 9-1 所示。

图 9-1:为弹跳小球草图绘制一个圆形
现在我们已经成功地创建了一个位于显示窗口中心的圆形,让我们尝试让它移动。
让小球移动
我们将通过改变小球的位置来让它移动。为此,首先创建一个表示 x 值的变量和一个表示 y 值的变量,并将它们的值设置为 300,这个值是屏幕的中间位置。回到清单 9-2,并在代码的开头插入以下两行,就像在清单 9-3 中一样。
*BouncingBall.pyde*
xcor = 300
ycor = 300
def setup():
size(600,600)
清单 9-3:设置 x 值和 y 值的变量
在这里,我们使用xcor变量来表示 x 值,使用ycor变量来表示 y 值。然后,我们将这两个变量的值都设置为 300。
现在让我们通过改变 x 值和 y 值来改变椭圆的位置。确保使用这些变量来绘制椭圆,如清单 9-4 中所示。
*BouncingBall.pyde*
xcor = 300
ycor = 300
def setup():
size(600,600)
def draw():
➊ global xcor, ycor
background(0) #black
xcor += 1
ycor += 1
ellipse(xcor,ycor,20,20)
清单 9-4:递增xcor和ycor来改变椭圆的位置
在这个例子中,值得注意的关键点是global xcor, ycor ➊,它告诉 Python 使用我们已经创建的变量,而不是仅仅为了draw()函数而创建新的变量。如果你没有包含这行代码,你将会看到类似“在赋值之前引用了局部变量xcor”这样的错误信息。一旦 Processing 知道了要为xcor和ycor分配什么值,我们就可以将它们都增加 1,并使用全局变量(xcor, ycor)绘制椭圆。
当你保存并运行清单 9-4 时,你应该看到小球移动,就像在图 9-2 中看到的那样。
现在小球开始向下和向右移动,因为它的 x 值和 y 值都在增加,但是接着它移出了屏幕,我们再也看不见它了!程序会继续顺从地递增我们的变量。它并不知道自己在绘制一个小球,或者我们希望小球能反弹回墙壁。让我们来探索如何避免小球消失。

图 9-2:小球在移动!
让小球从墙壁反弹
当我们通过增加 1 来改变 x 值和 y 值时,我们在改变一个物体的位置。在数学中,这种随时间变化的位置变化被称为速度。x 在时间上的正向变化(正 x 速度)看起来像是向右移动(因为 x 在增大),而负 x 速度则看起来像是向左移动。我们可以用这种“正向右,负向左”的概念让球弹跳出墙壁。首先,我们通过在现有代码中添加以下几行来创建 x 速度和 y 速度变量,具体代码见 Listing 9-5。
*BouncingBall.pyde*
xcor = 300
ycor = 300
xvel = 1
yvel = 2
def setup():
size(600,600)
def draw():
global xcor,ycor,xvel,yvel
background(0) #black
xcor += xvel
ycor += yvel
#if the ball reaches a wall, switch direction.
if xcor > width or xcor < 0:
xvel = -xvel
if ycor > height or ycor < 0:
yvel = -yvel
ellipse(xcor,ycor,20,20)
Listing 9-5: 添加代码让球弹跳出墙壁
首先,我们设置xvel = 1和yvel = 2来指定球的运动方式。你可以使用其他值,观察它们如何改变运动。然后在draw()函数中,我们告诉 Python xvel和yvel是全局变量,并且通过使用这些变量来增量地改变 x 和 y 坐标。例如,当我们设置xcor += xvel时,我们通过速度(位置的变化)来更新位置。
这两个if语句告诉程序,如果球的位置超出屏幕边界,它应该将球的速度改为其负值。当我们将球的速度改为负值时,我们告诉程序将球向它原本的相反方向移动,这样就看起来像球在弹跳。
我们需要精确地告诉程序,在什么情况下球应该改变方向,具体是基于它的坐标。例如,xcor > width表示xcor大于显示窗口的宽度,这时球碰到了屏幕的右边缘。而xcor < 0表示xcor小于 0,或者球碰到了屏幕的左边缘。同样,ycor > height检查ycor大于窗口的高度,或者球到达了屏幕的底部。最后,ycor < 0检查球是否到达屏幕的上边缘。由于向右移动是正的 x 速度(x 的正向变化),相反方向就是负的 x 速度。如果速度已经是负值(即球向左移动),那么负的负值就是正值,这意味着球将向右移动,正如我们希望的那样。
当你运行 Listing 9-5 时,你应该会看到类似于 Figure 9-3 中所示的效果。

Figure 9-3: 一个弹跳的球!
球看起来像是在弹跳出墙壁,因此保持在视图中。
没有类的情况下制作多个球
现在假设我们想要制作另一个弹跳球,或者多个弹跳球。我们该怎么做呢?我们可以为第二个球的 x 值创建一个新变量,为第二个球的 y 值创建另一个变量,为其 x 速度创建第三个变量,为其 y 速度创建第四个变量。然后,我们必须按其速度递增其位置,检查是否需要从墙壁上反弹,最后绘制它。然而,我们最终将得到双倍的代码量!再增加一个球就会使代码量增加三倍!二十个球体根本无法处理。你不想跟踪所有这些位置和速度的变量。清单 9-6 显示了这将是什么样子。
#ball1:
ball1x = random(width)
ball1y = random(height)
ball1xvel = random(-2,2)
ball1tvel = random(-2,2)
#ball2:
ball2x = random(width)
ball2y = random(height)
ball2xvel = random(-2,2)
ball2tvel = random(-2,2)
#ball3:
ball3x = random(width)
ball3y = random(height)
ball3xvel = random(-2,2)
ball3tvel = random(-2,2)
#update ball1:
ball1x += ball1xvel
ball1y += ball1yvel
ellipse(ball1x,ball1y,20,20)
#update ball2:
ball2x += ball2xvel
ball2y += ball2yvel
ellipse(ball2x,ball2y,20,20)
#update ball3:
ball3x += ball3xvel
ball3y += ball3yvel
ellipse(ball3x,ball3y,20,20)
清单 9-6:没有使用类创建多个球体。代码量太多了!
这是仅创建三个球体的代码。如你所见,它非常长,而且这还没有包括弹跳部分!让我们看看如何使用类来简化这个任务。
使用类创建对象
在编程中,类就像一个食谱,详细说明了如何创建具有特定属性的对象。使用类,我们告诉 Python 如何创建一个球体一次。然后,我们所要做的就是使用 for 循环创建多个球体,并将它们放入一个列表中。列表非常适合保存许多事物——字符串、数字和对象!
使用类创建对象时,请按照以下三个步骤操作:
-
编写类。这就像是制作球体、行星、火箭等的食谱。
-
实例化对象。你可以通过在
setup()函数中调用对象来做到这一点。 -
更新对象。在
draw()函数(显示循环)中执行此操作。
让我们使用这些步骤将我们已经编写的代码放入类中。
编写类
使用类创建对象的第一步是编写一个类,告诉程序如何制作一个球体。让我们将 清单 9-7 中的代码添加到我们现有程序的最前面。
*BouncingBall.pyde*
ballList=[] #empty list to put the balls in
class Ball:
def __init__(self,x,y):
'''How to initialize a Ball'''
self.xcor = x
self.ycor = y
self.xvel = random(-2,2)
self.yvel = random(-2,2)
清单 9-7:定义一个名为 Ball 的类
请注意,由于我们将位置和速度变量作为属性放入了 Ball 类中,你可以从现有代码中删除以下几行:
xcor = 300
ycor = 300
xvel = 1
yvel = 2
在 清单 9-7 中,我们创建了一个空列表,用于保存球体;然后我们开始定义这个食谱。类对象的名称(在本例中为 Ball)总是以大写字母开头。__init__ 方法是 Python 中创建类的必要条件,它包含了对象在初始化时获得的所有属性。否则,类将无法正常工作。
self语法意味着每个对象都有自己的方法和属性,这些函数和变量只能被Ball对象使用。这意味着每个Ball都有自己的xcor,自己的ycor,等等。因为我们可能在某个时刻需要在特定位置创建一个Ball,我们将x和y设置为__init__方法的参数。添加这些参数可以让我们在创建Ball时告诉 Python 它的位置,如下所示:
Ball(100,200)
在这种情况下,球将位于坐标(100, 200)。
示例 9-7 中的最后几行告诉 Processing 为新球的 x 速度和 y 速度分配一个介于-2 和 2 之间的随机数。
实例化对象
既然我们已经创建了一个名为Ball的类,我们需要告诉 Processing 每次draw()函数循环时如何更新球。我们将创建一个update方法,并将其嵌套在Ball类中,就像我们之前做的__init__方法一样。你可以简单地将所有球的代码剪切并粘贴到update()方法中,然后为每个对象的属性添加self.,如下所示在示例 9-8 中展示。
*BouncingBall.pyde*
ballList=[] #empty list to put the balls in
class Ball:
def __init__(self,x,y):
'''How to initialize a Ball'''
self.xcor = x
self.ycor = y
self.xvel = random(-2,2)
self.yvel = random(-2,2)
def update(self):
self.xcor += self.xvel
self.ycor += self.yvel
#if the ball reaches a wall, switch direction
if self.xcor > width or self.xcor < 0:
self.xvel = -self.xvel
if self.ycor > height or self.ycor < 0:
self.yvel = -self.yvel
ellipse(self.xcor,self.ycor,20,20)
示例 9-8:创建update()方法
这里,我们将所有移动和弹跳球的代码都放入了Ball类的update()方法中。唯一的新代码是self出现在速度变量中,使它们成为Ball对象的速度属性。虽然看起来有很多self,但这就是我们告诉 Python,举个例子,x 坐标属于特定的那个球而不是其他球。很快,Python 将要更新上百个球,所以我们需要self来追踪每个球的位置和速度。
现在程序知道如何创建和更新一个球,我们来更新setup()函数,创建三个球并将它们放入球列表(ballList)中,具体见示例 9-9。
def setup():
size(600,600)
for i in range(3):
ballList.append(Ball(random(width),
random(height)))
示例 9-9:在setup()函数中创建三个球
我们已经在示例 9-7 中创建了ballList,现在我们在列表中随机位置添加一个Ball。当程序创建(实例化)一个新的球时,它会选择一个介于 0 和屏幕宽度之间的随机数作为 x 坐标,另一个介于 0 和屏幕高度之间的随机数作为 y 坐标。然后,它会将这个新的球添加到列表中。因为我们使用了for i in range(3)的循环,程序会向球列表中添加三个球。
更新对象
现在让我们告诉程序遍历ballList,并在每次循环中更新列表中的所有球(即绘制它们),使用以下draw()函数:
*BouncingBall.pyde*
def draw():
background(0) #black
for ball in ballList:
ball.update()
请注意,我们仍然希望背景是黑色的,然后我们遍历球列表,对于列表中的每个球,我们运行它的update()方法。draw()中之前的所有代码都被移到了Ball类中!

图 9-4:创建任意数量的弹跳球!
当你运行这个草图时,你应该能看到三个小球在屏幕上移动并撞击墙壁!使用类的一个好处是可以轻松更改小球的数量。你所要做的就是在setup()函数中的for i in range(*number*)里更改number,这样就能创建更多的小球。例如,将这个数字改为 20,你就会看到类似图 9-4 的效果。
使用类的一个酷点是,你可以为对象赋予任何你想要的属性或方法。例如,我们不必让所有的小球都使用相同的颜色。你可以将 Listing 9-10 中的三行代码添加到你现有的Ball类中。
*BouncingBall.pyde*
class Ball:
def __init__(self,x,y):
'''How to initialize a Ball'''
self.xcor = x
self.ycor = y
self.xvel = random(-2,2)
self.yvel = random(-2,2)
self.col = color(random(255),
random(255),
random(255))
Listing 9-10: 更新Ball类
这段代码在创建每个小球时给它分配一个独立的颜色。Processing 的color()函数需要三个数字,分别代表红色、绿色和蓝色。RGB 值的范围是从 0 到 255。使用random(255)让程序随机选择这三个数字,从而得到一个随机的颜色。然而,因为__init__方法只执行一次,所以一旦小球拥有了颜色,它就会保持这个颜色。
接下来,在update()方法中,添加如下代码,使得椭圆被填充上自己随机选中的颜色:
fill(self.col)
ellipse(self.xcor,self.ycor,20,20)
在绘制形状或线条之前,你可以使用fill为形状声明颜色,或者使用stroke为线条声明颜色。在这里,我们告诉 Processing 使用小球自己的颜色(通过self)来填充下面的形状。
现在,当你运行程序时,每个小球应该都有一个随机颜色,如图 9-5 所示!

图 9-5:给小球赋予不同的颜色
练习 9-1:创建不同大小的小球
给每个小球一个自己的大小,大小范围为 5 到 50 单位。
放牧羊程序
现在你已经可以创建类了,我们来做一些有用的事情。我们将编写一个 Processing 草图,模拟羊在草地上走动并吃草的生态系统。在这个草图中,羊有一定的能量,走动时能量会减少,而吃草时能量会恢复。如果它们获得足够的能量,它们就会繁殖。如果能量不足,它们就会死亡。通过创建和调整这个模型,我们可能会学到很多关于生物学、生态学和进化学的知识。
在这个程序中,Sheep对象有点像你在本章前面创建的Ball对象;每个对象都有自己的 x 和 y 坐标及大小,并且用圆形表示。
编写羊类代码
开始一个新的 Processing 草图,并将其保存为SheepAndGrass.pyde。首先,我们创建一个类,使其能够生成一个具有自己 x 和 y 坐标及大小的Sheep对象。然后我们将创建一个update方法,在羊的位置画出一个椭圆,表示羊的大小。
类的代码几乎与Ball类相同,正如你在 Listing 9-11 中所看到的那样。
*SheepAndGrass.pyde*
class Sheep:
def __init__(self,x,y):
self.x = x #x-position
self.y = y #y-position
self.sz = 10 #size
def update(self):
ellipse(self.x,self.y,self.sz,self.sz)
Listing 9-11: 为一只羊创建类
因为我们知道将要创建一堆羊,所以我们从创建一个Sheep类开始。在必需的__init__方法中,我们将羊的 x 和 y 坐标设置为我们在创建羊实例时声明的参数。我将羊的大小(即椭圆的直径)设置为 10 像素,但如果你喜欢,也可以有更大或更小的羊。update()方法只是简单地在羊的位置绘制一个与羊大小相同的椭圆。
下面是包含一个Sheep对象(我将其命名为shawn)的 Processing 草图中的setup()和draw()代码。将清单 9-12 中的代码添加到你刚刚在清单 9-11 中编写的update()方法下方。
def setup():
global shawn
size(600,600)
#create a Sheep object called shawn at (300,200)
shawn = Sheep(300,200)
def draw():
background(255)
shawn.update()
清单 9-12:创建一个名为shawn的Sheep对象
我们首先在setup()函数中创建了一个shawn,它是一个Sheep对象的实例。然后我们在draw()函数中更新它——但是 Python 并不知道我们指的是同一个shawn,除非我们告诉它shawn是一个全局变量。
当你运行这段代码时,你应该看到类似图 9-6 中的效果。

图 9-6:一只羊
你会看到一个白色的屏幕,屏幕上有一个小圆形的羊,位于坐标(300,200)处,也就是从起点向右 300 像素,向下 200 像素。
编程让羊四处移动
现在让我们教一个Sheep如何移动。我们将从编程让Sheep随机移动开始。(如果以后你想要让它以不同的方式移动,随时可以更改。)清单 9-13 通过一个介于-10 和 10 之间的随机数来改变Sheep的 x 和 y 坐标。返回到现有代码,在update()方法中的ellipse()函数上方添加以下几行代码:
*SheepAndGrass.pyde*
def update(self):
#make sheep walk randomly
move = 10 #the maximum it can move in any direction
self.x += random(-move, move)
self.y += random(-move, move)
fill(255) #white
ellipse(self.x,self.y,self.sz,self.sz)
清单 9-13:让羊随机移动
这段代码创建了一个名为move的变量,用来指定羊能够在屏幕上移动的最大值或距离。然后我们将move设置为 10,并使用它通过一个介于-move(–10)和move(10)之间的随机数来更新羊的 x 和 y 坐标。最后,我们使用fill(255)将羊的颜色暂时设置为白色。
当你运行这段代码时,你应该看到羊随机地在四处游荡——它可能会游荡出屏幕之外。
让我们给羊群增加一些伙伴。如果我们想创建和更新多个对象,把它们放进一个列表是个不错的主意。然后在draw()函数中,我们会遍历这个列表并更新每一个Sheep对象。更新你的现有代码,使其像清单 9-14 一样。
*SheepAndGrass.pyde*
class Sheep:
def __init__(self,x,y):
self.x = x #x-position
self.y = y #y-position
self.sz = 10 #size
def update(self):
#make sheep walk randomly
move = 10 #the maximum it can move in any direction
self.x += random(-move, move)
self.y += random(-move, move)
fill(255) #white
ellipse(self.x,self.y,self.sz,self.sz)
sheepList = [] #list to store sheep
def setup():
size(600,600)
for i in range(3):
sheepList.append(Sheep(random(width),
random(height)))
def draw():
background(255)
for sheep in sheepList:
sheep.update()
清单 9-14:使用for循环创建更多的羊
这段代码与我们之前写的将弹跳球放入列表的代码类似。首先,我们创建一个列表来存储羊。然后,我们创建一个for循环,将一只Sheep添加到羊的列表中。接着,在draw()函数中,我们再写一个for循环,遍历羊列表,并根据我们已经定义的update()方法更新每只羊。当你运行这段代码时,应该会看到三只Sheep在随机走动。你可以将for i in range(3):中的数字3改成更大的数字,以增加更多的羊。
创建能量属性
走路会消耗能量!让我们在羊创建时给予它一定的能量,并在它们走动时消耗掉能量。使用清单 9-15 中的代码来更新你现有的__init__和update()方法,在SheepAndGrass.pyde中完成。
class Sheep:
def __init__(self,x,y):
self.x = x #x-position
self.y = y #y-position
self.sz = 10 #size
self.energy = 20 #energy level
def update(self):
#make sheep walk randomly
move = 1
self.energy -= 1 #walking costs energy
if sheep.energy <= 0:
sheepList.remove(self)
self.x += random(-move, move)
self.y += random(-move, move)
fill(255) #white
ellipse(self.x,self.y,self.sz,self.sz)
清单 9-15:使用energy属性更新__init__和update()方法
我们通过在__init__方法中创建一个energy属性并将其设置为 20,表示每只羊的初始能量水平。然后,在update()方法中,self.energy -= 1会让羊在四处走动时减少 1 点能量。
然后我们检查羊是否能量耗尽,如果是,就将其从sheepList中移除。在这里,我们使用一个条件语句来检查if sheep.energy <= 0是否返回True。如果是,我们使用remove()函数从sheepList中移除那只羊。一旦那只Sheep实例被移除,它就不再存在。
使用类创建草
当你运行程序时,应该会看到Sheep走动一会儿然后消失——走动会消耗羊的能量,一旦能量用尽,羊就会死掉。我们需要做的是给羊提供草让它们吃。我们将每片草称为Grass并为其创建一个新类。Grass将具有自己的 x、y 坐标、大小和能量含量。我们还会让它在被吃掉时改变颜色。
事实上,在这个草图中,我们将使用许多不同的颜色来表示羊和草,所以让我们将清单 9-16 中的代码添加到程序的最开始,这样我们就可以通过颜色名称来引用它们。如果你想,也可以添加其他颜色。
WHITE = color(255)
BROWN = color(102,51,0)
RED = color(255,0,0)
GREEN = color(0,102,0)
YELLOW = color(255,255,0)
PURPLE = color(102,0,204)
清单 9-16:将颜色设置为常量
使用全大写的颜色名称表示它们是常量,不会改变值,但这只是对程序员而言。常量本身并没有什么魔力,如果你愿意,可以更改这些值。设置常量的好处是你可以直接使用颜色名称,而不必每次都写 RGB 值。我们将在让草变绿时做这个更新。通过在SheepAndGrass.pyde中将清单 9-17 中的代码添加到Sheep类之后来更新你现有的代码:
class Grass:
def __init__(self,x,y,sz):
self.x = x
self.y = y
self.energy = 5 #energy from eating this patch
self.eaten = False #hasn't been eaten yet
self.sz = sz
def update(self):
fill(GREEN)
rect(self.x,self.y,self.sz,self.sz)
清单 9-17:编写Grass类
你可能已经开始习惯类的结构了。它通常从__init__方法开始,在这个方法中你会创建它的属性。在这种情况下,你告诉程序Grass将有一个 x 和 y 位置,一个能量级别,一个布尔值(True/False)变量用于跟踪草地是否被吃掉了,还有一个大小。要更新一块草地,我们只需在Grass对象的位置创建一个绿色矩形。
现在我们需要像处理羊一样初始化和更新草地。因为草地数量会很多,所以我们可以为草地创建一个列表。在setup()函数之前,添加以下代码。
sheepList = [] #list to store sheep
grassList = [] #list to store grass
patchSize = 10 #size of each patch of grass
我们可能希望将来改变草地的大小,因此我们创建一个名为patchSize的变量,这样我们只需在一个地方修改它即可。在setup()函数中,创建羊之后,添加新代码来创建草地,具体见清单 9-18。
def setup():
global patchSize
size(600,600)
#create the sheep
for i in range(3):
sheepList.append(Sheep(random(width),
random(height)))
#create the grass:
for x in range(0,width,patchSize):
for y in range(0,height,patchSize):
grassList.append(Grass(x,y,patchSize))
清单 9-18:使用patchSize变量更新Grass对象
在这个例子中,global patchSize告诉 Python 我们在所有地方使用相同的patchSize变量。然后我们写两个for循环(一个用于 x,另一个用于 y),将Grass添加到草地列表中,这样我们就可以创建一个方形的草地网格。
然后,我们像处理羊一样,在draw()函数中更新所有内容。无论先绘制什么,都会被后绘制的内容覆盖,因此我们会首先更新草地,通过将draw()函数改为清单 9-19 中的代码。
*SheepAndGrass.pyde*
def draw():
background(255)
#update the grass first
for grass in grassList:
grass.update()
#then the sheep
for sheep in sheepList:
sheep.update()
清单 9-19:在羊之前更新草地
当你运行这段代码时,你应该能看到一个绿色方块的网格,就像在图 9-7 中一样。

图 9-7:带有网格线的草地
让我们关闭黑色轮廓,这样看起来就像是一片平滑的草地。在setup()函数中添加noStroke(),以去掉绿色方块的轮廓:
def setup():
global patchSize
size(600,600)
noStroke()
现在我们有草地了!
让草地在被吃掉时变成棕色
那么,如何让羊在站在草地上时获取草地的能量,并且草地变成棕色,表示羊已经吃掉了它呢?通过添加以下代码行来修改Grass的update()方法:
def update(self):
if self.eaten:
fill(BROWN)
else:
fill(GREEN)
rect(self.x,self.y,self.sz,self.sz)
这段代码告诉 Processing,如果草地被“吃掉”,矩形应该填充为棕色。否则,草地应该是绿色的。羊吃草的方式有不止一种。 一种方法是让每块草地检查整个sheepList,看是否有羊在其位置上,这可能意味着成千上万块草地在检查成千上万只羊。这些数字可能会很大。然而,由于每块草地都在grassList中,另一种方法是,当羊改变位置时,它可以简单地将该位置的草地标记为“已吃”(如果它还没被吃过的话),并从吃草中获得能量。这意味着会少做很多检查。
问题是,羊的 x、y 坐标与草地区域在grassList中的位置并不完全匹配。例如,我们的patchSize是 10,这意味着如果一只羊在(92,35)的位置,它会在右侧第 10 个草地区域和下方第 4 个草地区域(因为“第一个”区域是从 x = 0 到 x = 9)。我们通过除以patchSize来获取“缩放后的”x、y 值,即 9 和 3。
然而,grassList没有行和列。我们知道 x 值为 9,意味着它是第 10 行(别忘了行 0),所以我们只需要加上 9 行 60(高度除以patchSize),然后再加上 y 值来获得羊所在草地区域的索引。因此,我们需要一个变量来告诉我们每行有多少个草地区域,我们将其命名为rows_of_grass。将global rows_of_grass添加到setup()函数的开头,然后在声明大小之后,将此行代码添加到setup()中:
rows_of_grass = height/patchSize
这段代码计算显示窗口的宽度,并将其除以草地区域的大小,以告诉我们有多少列草地。添加到Sheep类中的代码见清单 9-20。
*SheepAndGrass.pyde*
self.x += random(-move, move)
self.y += random(-move, move)
#"wrap" the world Asteroids-style
➊ if self.x > width:
self.x %= width
if self.y > height:
self.y %= height
if self.x < 0:
self.x += width
if self.y < 0:
self.y += height
#find the patch of grass you're on in the grassList:
➋ xscl = int(self.x / patchSize)
yscl = int(self.y / patchSize)
➌ grass = grassList[xscl * rows_of_grass + yscl]
if not grass.eaten:
self.energy += grass.energy
grass.eaten = True
清单 9-20:更新羊的能量水平并将草地变成棕色
更新羊的位置后,我们将坐标“包装” ➊,这样如果羊在某一方向走出屏幕,它会出现在屏幕的另一边,就像视频游戏《小行星》中那样。我们根据patchSize ➋计算羊所在的草地区域。然后,我们使用代码将 x、y 值转换为该区域在grassList中的索引 ➌。现在我们知道羊所处的草地区域的确切索引。如果这块草地还没有被吃掉,羊就会吃掉它!它从草地中获取能量,草地的eaten属性被设置为True。
运行这段代码,你会看到三只羊四处奔跑,吃掉草地,而草地一旦被吃掉就会变成棕色。通过将move变量改为较小的值(比如 5)来减慢羊的速度。你也可以通过修改patchSize变量将草地区域缩小到 5。你可以尝试其他值。
现在我们可以创建更多的Sheep了。让我们将for i in range行中的数字改为 20,像这样:
#create the sheep
for i in range(20):
sheepList.append(Sheep(random(width),
random(height)))
当你运行这段代码时,你应该能看到类似图 9-8 的内容。

图 9-8:一群羊!
现在有 20 只羊在四处走动,留下了棕色的草地。
为每只羊指定一个随机颜色
让我们让羊在“出生”时选择一种颜色。在定义颜色常量的代码之后,我们将一些颜色放入一个颜色列表,如下所示:
YELLOW = color(255,255,0)
PURPLE = color(102,0,204)
colorList = [WHITE,RED,YELLOW,PURPLE]
对Sheep类进行以下更改以使用不同的颜色。首先,你需要为Sheep添加一个颜色属性。由于color在 Processing 中已经是一个关键字,所以在清单 9-21 中使用了col。
class Sheep:
def __init__(self,x,y,col):
self.x = x #x-position
self.y = y #y-position
self.sz = 10 #size
self.energy = 20
self.col = col
清单 9-21:为Sheep类添加颜色属性
然后,在update()方法中,用以下代码替换填充行:
fill(self.col) #its own color
ellipse(self.x,self.y,self.sz,self.sz)
在椭圆形绘制之前,fill(self.col)告诉 Processing 使用Sheep自身随机选择的颜色来填充椭圆。
当所有Sheep在setup()函数中被实例化时,你需要给它们随机分配一个颜色。这意味着在程序的顶部,你必须从random模块导入choice()函数,如下所示:
from random import choice
Python 的choice()函数允许你从列表中随机选择一个项并返回。我们应该能够让程序按如下方式执行:
choice(colorList)
现在程序将从颜色列表中返回一个单一的值。最后,在创建Sheep时,将颜色列表中的随机选择颜色作为参数之一传递给Sheep构造函数,如下所示:
def setup():
size(600,600)
noStroke()
#create the sheep
for i in range(20):
sheepList.append(Sheep(random(width),
random(height),
choice(colorList)))
现在,当你运行这段代码时,你应该会看到一群随机颜色的羊在屏幕上走动,如图 9-9 所示。

图 9-9:五颜六色的羊
每只新羊都会被分配到我们在colorList中定义的四种颜色之一:白色、红色、黄色或紫色。
编程让羊繁殖
不幸的是,在我们当前的程序中,羊会吃草,直到它们离草地太远,能量耗尽并死亡。为了防止这种情况发生,让我们指示羊使用一部分能量来繁殖。
让我们使用清单 9-22 中的代码,指示羊在能量达到 50 时进行繁殖。
if self.energy <= 0:
sheepList.remove(self)
if self.energy >= 50:
self.energy -= 30 #giving birth takes energy
#add another sheep to the list
sheepList.append(Sheep(self.x,self.y,self.col))
清单 9-22:为羊添加繁殖条件
条件语句if self.energy >= 50:检查该羊的能量是否大于或等于 50。如果是,它会将能量水平减少 30(用于生育),并将另一只羊添加到羊群中。注意,新羊的位置与它的父母相同,且颜色也与父母相同。运行这段代码,你应该会看到羊繁殖,像图 9-10 所示。

图 9-10:羊吃草并繁殖
很快你会看到看起来像是同色羊群的场景。
让草重新生长
不幸的是,羊很快吃光了它们区域内的所有草并死亡(可能在这里有某种教训)。我们需要让草重新生长。为此,请将Grass类的update()方法修改为如下:
def update(self):
if self.eaten:
if random(100) < 5:
self.eaten = False
else:
fill(BROWN)
else:
fill(GREEN)
rect(self.x,self.y,self.sz,self.sz)
Processing 代码random(100)生成一个介于 0 和 100 之间的随机数。如果该数字小于 5,我们通过将草的eaten属性设置为False来重新生长一块草。我们选择 5 这个数字,因为它让吃过的草在每帧中以 5/100 的概率重新生长。否则,草仍然保持棕色。
运行代码,你应该会看到类似图 9-11 的效果。

图 9-11:草重新生长,羊群占据了整个屏幕!
现在,你可能会发现羊群数量增加到程序开始变慢!这可能是因为羊群有过多的能量。如果是这样,尝试将每块草地所含的能量从 5 减少到 2:
class Grass:
def __init__(self,x,y,sz):
self.x = x
self.y = y
self.energy = 2 #energy from eating this patch
self.eaten = False #hasn't been eaten yet
self.sz = sz
这似乎是一个很好的平衡,使得羊群数量以合理的速度增长。随意调整数字——这是你的世界!
提供进化优势
让我们给其中一群羊提供一个优势。你可以选择任何你能想到的优势(例如,从草地上获得更多能量或一次性繁殖更多后代)。在这个例子中,我们将让紫色的羊走得比其他羊稍远一点。这会有什么不同吗?为了找出答案,请让Sheep的update()方法与以下代码匹配:
def update(self):
#make sheep walk randomly
move = 5 #the maximum it can move in any direction
if self.col == PURPLE:
move = 7
self.energy -= 1
这个条件语句检查Sheep的颜色是否为紫色。如果是,它将Sheep的move值设置为 7。否则,保持值为 5。这样,紫色的羊可以走得更远,因此比其他羊更有可能找到绿色的草地。我们运行代码并检查结果,应该会看到如图 9-12 所示。

图 9-12:给紫色羊提供优势
过了一段时间,看起来紫色羊的微小优势确实取得了回报。它们正在主导环境,通过争夺草地把其他羊排挤出去。这个模拟可能引发关于生态学、外来物种、生物多样性和进化的有趣讨论。
练习 9-2:设置羊的寿命
创建一个“年龄”属性,并在每次更新羊时将其减少,让它们只活有限的时间。
练习 9-3:改变羊的大小
根据羊的能量水平来变化羊的大小。
总结
在这一章中,你学习了如何使用类创建对象,包括定义类的属性,然后实例化(“创建”)并更新对象。这使得你能够更高效地创建多个相似但独立的对象,且它们具有相同的属性。你使用类越多,就越能通过让自主对象行走、飞行或跳跃,而不需要每一步都手动编写代码,从而变得更加富有创造力!
学会使用类可以大大增强你的编程能力。现在你可以轻松创建复杂情况的模型,一旦你告诉程序如何处理一个粒子、一个行星或一只羊,它就能非常轻松地创建出十只、百只,甚至是百万只这样的模型!
你还体验了用极少的方程式建立模型,探索物理、生物、化学或环境问题!有位物理学家曾告诉我,这通常是解决涉及多个因素或“代理”的问题最有效的方法。你建立一个计算机模型,让它运行,然后查看结果。
在下一章中,你将学习如何使用一种几乎神奇的现象——递归,来创建分形。
第十章:使用递归创建分形
“thesaurus”的另一个词是什么?
— 史蒂文·赖特*

分形是令人愉快的复杂设计,其中设计的每个小部分都包含了整个设计(参见图 10-1)。它们由贝努瓦·曼德布罗特于 1980 年发明(或者说是发现,因为分形在自然界中存在),当时他在一台先进的 IBM 计算机上可视化一些复杂函数。

图 10-1:分形的例子
分形看起来不像我们在几何学中认识的常规形状,比如正方形、三角形和圆形。它们的形状曲折且 jagged(锯齿状),使得它们成为模拟自然现象的绝佳模型。实际上,科学家使用分形来模拟从你心脏的动脉,到地震,再到大脑中的神经元等一切。
分形之所以如此有趣,是因为它们展示了如何通过简单的规则反复运行,以及模式在更小尺度上不断重复,从而获得出乎意料的复杂设计。
我们的主要兴趣是你可以用分形制作的那些有趣且复杂的设计。如今每本数学书中都有分形的图像,但教科书从不告诉你如何制作分形——你需要计算机来做这件事。在本章中,你将学习如何使用 Python 创建自己的分形。
海岸线的长度
在你开始创建分形之前,让我们看一个简单的例子,帮助理解分形如何有用。一位数学家名叫路易斯·理查森提出了一个简单的问题:“英国的海岸线有多长?”正如你在图 10-2 中看到的,答案取决于你使用的尺子的长度。

图 10-2:逼近海岸线长度
你的尺子越小,你就能越精确地逼近海岸线的曲折边缘,这意味着你最终得到的测量值会更长。有趣的是,当尺子的长度接近零时,海岸线的长度接近无限大! 这被称为海岸线悖论。
认为这只是抽象的数学胡思乱想?在现实世界中,海岸线的长度估算差异很大。即使是现代技术,最终还是取决于用来测量地图的尺度。我们将绘制一幅像图 10-3 这样的图,科赫雪花,以展示分形如何证明一个足够粗糙的海岸线可以变得任意长!

图 10-3:一个越来越详细的分形,模拟一个越来越崎岖的海岸线
首先,你需要学习一些技巧,比如递归。
递归是什么?
分形的强大之处在于你可以重复数值或形状的模式,每一步都会变得更小,直到你处理的是非常小的数字。重复这些代码的关键是一个叫做递归的概念,它是指某事物以自身为定义的方式进行描述。以下一些笑话说明了递归的工作原理:
-
如果你在谷歌中搜索“递归”,它会问你:“你是想搜递归吗?”
-
在多本计算机编程书籍的索引中,你会看到类似这样的条目:“递归,见递归。”
正如你可以想象的,递归是一个相当奇怪的概念。递归的优点是它能够简化本来过于复杂的代码,但缺点是你可能会用尽过多的内存。
编写factorial()函数
让我们通过编写一个计算数字阶乘的函数来观察递归的实际应用。你可能还记得数学课上,n的阶乘(表示为n!)被定义为从 1 到n的所有整数的乘积。例如,5! = 1 × 2 × 3 × 4 × 5 = 120。
公式看起来像这样:n! = 1 × 2 × 3 . . . × (n – 2) × (n – 1) × n。这是递归序列的一个例子,因为 5! = 5 × 4!,4! = 4 × 3!,依此类推。递归是数学中的一个重要概念,因为数学本质上是关于模式的,而递归使你能够无限地复制和扩展模式!
我们可以将 n 的阶乘定义为 n 与 n – 1 的阶乘的乘积。我们只需要定义 0 的阶乘(它是 1,而不是 0)和 1 的阶乘,然后使用递归语句。在 IDLE 中打开一个新文件,保存为factorial.py,然后输入清单 10-1 中的代码。
*factorial.py*
def factorial(n):
if n == 0:
return 1
else:
return n * factorial(n – 1)
清单 10-1:使用递归语句编写factorial()函数
首先,我们说:“如果用户(或程序)请求 0 或 1 的阶乘,返回 1。”这是因为 0!和 1!都等于 1。然后我们告诉程序,“对于任何其他数字n,返回n乘以比n小 1 的数字的阶乘。”
请注意,在清单 10-1 的最后一行,我们在定义factorial()函数时内部调用了factorial()函数!这就像是一个面包食谱,里面有“烤一个面包”这样的步骤。人们根本不会开始按照这样的食谱去做。但是计算机可以从头到尾地执行这些步骤。
在这个例子中,当我们请求 5 的阶乘时,程序会顺从地执行,直到最后一行,它请求n – 1 的阶乘,在这种情况下(因为n = 5),就是 4 的阶乘。为了计算阶乘(5 – 1),程序会再次启动factorial()函数,n = 4,并试图以相同的方式计算 4 的阶乘,然后是 3 的阶乘、2 的阶乘、1 的阶乘,最后是 0 的阶乘。因为我们已经定义了函数将 0 的阶乘返回为 1,所以函数可以按照顺序回到上一步,计算 1 的阶乘、2 的阶乘、3 的阶乘、4 的阶乘,最后是 5 的阶乘。
递归地定义一个函数(通过在其自身定义中调用该函数)可能会让人感到困惑,但它是本章所有分形图案的关键。我们从一个经典的例子开始:分形树。
构建一个分形树
创建分形图形从定义一个简单的函数开始,并在函数内部调用该函数。让我们尝试构建一个像图 10-4 一样的分形树。

图 10-4:一棵分形树
如果你需要告诉程序每一条要画的线,这将是一个非常复杂的设计。但如果使用递归,这所需的代码 surprisingly 少。通过平移、旋转和line()函数,我们将在 Processing 中首先绘制一个 Y 形,如图 10-5 所示。

图 10-5:分形树的初步形态
最终将这个 Y 形转变为分形的唯一要求是,在程序绘制完 Y 树和分支后,程序必须返回到“树干”的底部。因为“分支”将会变成 Y 形本身。如果程序每次不返回到 Y 的底部,我们就无法得到树的结构。
编写y()函数
你的 Y 形不必完美或对称,但这是我绘制 Y 形的代码。打开 Processing 中新建一个草图,将其命名为fractals.pyde,并输入清单 10-2 中的代码。
*fractals.pyde*
def setup():
size(600,600)
def draw():
background(255)
translate(300,500)
y(100)
def y(sz):
line(0,0,0,-sz)
translate(0,-sz)
rotate(radians(30))
line(0,0,0,-0.8*sz) #right branch
rotate(radians(-60))
line(0,0,0,-0.8*sz) #left branch
rotate(radians(30))
translate(0,sz)
清单 10-2:编写用于分形树的y()函数
我们像往常一样设置 Processing 草图:在setup()函数中告诉程序显示窗口的大小,然后在draw()函数中设置背景色(255 为白色)并进行平移,确定绘制起始点。最后,我们调用y()函数并传递数字 100 作为分形树“树干”的大小。
y()函数接受一个数字sz作为参数,表示树干的长度。然后,所有分支将基于这个数字。y()函数中的第一行代码使用垂直线绘制树干。为了创建一条向右分叉的线,我们将垂直线沿着树干(负 y 方向)平移,然后将其向右旋转 30 度。接下来,我们绘制另一条右分支的线,再将其旋转到左侧(负 60 度),并绘制另一条左分支的线。最后,我们需要旋转回直立的方向,以便再次沿着树干向下平移。保存并运行这个草图,你应该能够看到图 10-5 中的 Y 形。
我们可以通过将分支变成更小的 Y 形状,将这个绘制单一 Y 的程序转换为绘制分形的程序。但是,如果我们仅仅在y()函数中将“line”替换为“y”,程序就会陷入无限循环,抛出类似这样的错误:
RuntimeError: maximum recursion depth exceeded
回想一下,我们在阶乘函数中并没有直接调用 factorial(n),而是调用了 factorial(n-1)。我们必须在 y() 函数中引入一个 level 参数。然后,每向上分支一次,树的级别就会下降一级,因此该分支将获得 level - 1 参数。这意味着树干总是最高编号的级别,树上最后一组分支总是级别 0。下面是如何更改 Listing 10-3 中的 y() 函数。
*fractals.pyde*
def setup():
size(600,600)
def draw():
background(255)
translate(300,500)
y(100,2)
def y(sz,level):
if level > 0:
line(0,0,0,-sz)
translate(0,-sz)
rotate(radians(30))
y(0.8*sz,level-1)
rotate(radians(-60))
y(0.8*sz,level-1)
rotate(radians(30))
translate(0,sz)
Listing 10-3: 向 y() 函数添加递归
注意,我们已经将代码中所有的 line() 函数替换为 y() 函数来绘制分支。因为我们将 draw() 函数中的 y() 调用改为 y(100,2),所以我们将得到一个树干大小为 100 且有两个级别的树。试试三级树,四级树,依此类推!你应该能看到类似 Figure 10-6 的效果。

Figure 10-6: 1 到 4 级的树
映射鼠标
现在,让我们创建一个程序,允许你通过上下移动鼠标实时控制分形的形状!我们可以通过追踪鼠标并根据其位置返回一个 0 到 10 之间的值,动态地改变旋转的级别。用 Listing 10-4 中的代码更新 draw() 函数。
*fractals.pyde*
def draw():
background(255)
translate(300,500)
level = int(map(mouseX,0,width,0,10))
y(100,level)
Listing 10-4: 向 draw() 函数添加 level 参数
我们的鼠标 x 值可以在 0 和窗口宽度之间的任何地方。map() 函数将一个范围的值替换为另一个范围的值。在 Listing 10-4 中,map() 会将 x 值转换为 0 到 10 之间的值,而不是 0 到 600(显示屏的宽度),这是我们希望绘制的级别范围。所以我们将该值分配给一个名为 level 的变量,并将该值传递给下一行中的 y() 函数。
现在,我们已经调整了 draw() 函数,使其根据鼠标的位置返回一个值,我们可以通过将鼠标的 y 坐标与旋转角度关联,来改变树的形状。
旋转角度应该限制在 180 度以内,因为树木在 180 度时会“完全折叠”,但是鼠标的 y 值可以达到 600,因为这是我们在 setup() 中声明的屏幕高度。我们可以做一些数学运算来自己转换这些值,但直接使用 Processing 内置的 map() 函数会更简单。我们告诉 map() 函数我们想要映射的变量,指定它的当前最小值和最大值,以及所需的最小值和最大值。Y 分形树的完整代码显示在 Listing 10-5 中。
*fractals.pyde*
def setup():
size(600,600)
def draw():
background(255)
translate(300,500)
level = int(map(mouseX,0,width,0,15))
y(100,level)
def y(sz,level):
if level > 0:
line(0,0,0,-sz)
translate(0,-sz)
angle = map(mouseY,0,height,0,180)
rotate(radians(angle))
y(0.8*sz,level-1)
rotate(radians(-2*angle))
y(0.8*sz,level-1)
rotate(radians(angle))
translate(0,sz)
Listing 10-5: 创建动态分形树的完整代码
我们取鼠标的 y 值,并将其转换为 0 到 180 之间的范围(如果你已经习惯使用弧度,则可以将其映射到 0 到π之间)。在rotate()行中,我们为其指定该角度(以度为单位),并让 Processing 将度数转换为弧度。第一行rotate()将向右旋转。第二行rotate()将旋转一个负角度,也就是向左旋转两倍。然后第三行rotate()将再次向右旋转。
当你运行代码时,你应该看到类似于图 10-7 的内容。

图 10-7:动态分形树
现在,当你上下、左右移动鼠标时,分形的级别和形状应该会相应地改变。
通过绘制分形树,你学会了如何使用递归绘制复杂的设计,而仅需写非常少的代码。现在,我们将回到海岸线问题。海岸线或任何线条,如何通过变得更崎岖而使长度加倍或加三倍呢?
科赫雪花
科赫雪花(Koch snowflake)是一个著名的分形,得名于瑞典数学家赫尔格·冯·科赫(Helge von Koch),他在 1904 年发表的论文中讨论了这一形状!它由一个等边三角形构成。我们从一条线开始,并为其添加一个“突起”。然后,我们在每个结果线段上添加一个更小的突起,并重复这一过程,如图 10-8 所示。

图 10-8:为每个线段添加“突起”
让我们开始一个新的 Processing 草图,命名为snowflake.pyde,并添加清单 10-6 中的代码,这将给我们一个倒置的等边三角形。
*snowflake.pyde*
def setup():
size(600,600)
def draw():
background(255)
translate(100,100)
snowflake(400,1)
def snowflake(sz,level):
for i in range(3):
line(0,0,sz,0)
translate(sz,0)
rotate(radians(120))
清单 10-6:编写snowflake()函数
在draw()函数中,我们调用了snowflake()函数,目前该函数仅接受两个参数:sz(初始三角形的大小)和level(分形的级别)。snowflake()函数通过启动一个循环绘制三角形,这个循环重复三次。在循环内部,我们绘制一条长度为sz的线段,它将是三角形的一边,然后沿着这条线段平移到三角形的下一个顶点并旋转 120 度。然后,我们绘制三角形的下一条边。
当你运行清单 10-6 中的代码时,你应该能看到图 10-9。

图 10-9:级别 1 雪花:一个三角形
编写SEGMENT()函数
现在我们需要告诉程序如何将一条线变成具有不同级别的线段。级别 0 时只是直线,但下一级别会在边上添加“凸起”。我们实际上是将线段分成三等分,然后将中间的线段复制,使它变成一个小等边三角形。我们将修改snowflake()函数,调用另一个函数来绘制线段。这将是递归函数,因为随着级别的增加,线段会变成图 10-10 中更小的线段副本。

图 10-10:将线段分成三等分,并在中间三分之一处添加一个“凸起”
我们将边称为线段。如果级别是 0,线段仅仅是一条直线,即三角形的边。在下一步中,我们在边的中间添加一个凸起。图 10-10 中的所有线段长度相同,为整个边长的三分之一。这需要 11 个步骤:
-
绘制一条边长三分之一的线段。
-
平移到你刚才绘制的线段的末端。
-
旋转–60 度(向左)。
-
绘制另一个线段。
-
平移到该线段的末端。
-
旋转 120 度(向右)。
-
绘制第三个线段。
-
平移到该线段的末端。
-
再次旋转–60 度(向左)。
-
绘制最后一个线段。
-
平移到该线段的末端。
现在,我们不再画直线,而是调用snowflake()函数中的segment()函数,这个函数将完成绘制和移动操作。在清单 10-7 中添加segment()函数。
*snowflake.pyde*
def snowflake(sz,level):
for i in range(3):
segment(sz,level)
rotate(radians(120))
def segment(sz,level):
if level == 0:
line(0,0,sz,0)
translate(sz,0)
else:
line(0,0,sz/3.0,0)
translate(sz/3.0,0)
rotate(radians(-60))
line(0,0,sz/3.0,0)
translate(sz/3.0,0)
rotate(radians(120))
line(0,0,sz/3.0,0)
translate(sz/3.0,0)
rotate(radians(-60))
line(0,0,sz/3.0,0)
translate(sz/3.0,0)
清单 10-7:在三角形的边上绘制一个“凸起”
在segment()函数中,如果级别为 0,它就是一条直线,然后我们平移到该直线的末端。否则,我们有 11 行代码,对应着制作“凸起”的 11 个步骤。首先,我们画一条边长三分之一的线段,然后平移到该线段的末端。我们向左旋转(–60 度)绘制第二个线段。该线段的长度也是三角形边长的三分之一。我们平移到该线段的末端,然后右转 120 度。接着,我们绘制一个线段,再次向左旋转–60 度。最后,我们绘制第四条线(线段),并平移到边的末端。
如果级别是 0,这会绘制一个三角形,如果级别不为 0,则在每条边上添加一个凸起。正如图 10-8 所示,在每一步中,前一步中的每个线段都会有一个凸起。没有递归的话,这将非常头疼!但我们将绘制直线的那行代码改成绘制线段,只是把级别降低了一个。这就是递归步骤。
接下来,我们需要将每条线替换为一个更低一级的线段,其长度为sz除以 3。segment()函数的代码如清单 10-8 所示。
*snowflake.pyde*
def segment(sz,level):
if level == 0:
line(0,0,sz,0)
translate(sz,0)
else:
segment(sz/3.0,level-1)
rotate(radians(-60))
segment(sz/3.0,level-1)
rotate(radians(120))
segment(sz/3.0,level-1)
rotate(radians(-60))
segment(sz/3.0,level-1)
清单 10-8:用线段替代直线
所以我们所做的只是将清单 10-7 中每个line的实例(其层级大于 0)替换为segment()。因为我们不想进入无限循环,所以这些片段必须比前一个片段低一级(level - 1)。现在我们可以通过在draw()函数中更改雪花的层级,如下面的代码所示,从而看到不同的设计,如图 10-11 所示。
def draw():
background(255)
translate(100,height-100)
snowflake(400,3)

图 10-11:一个 3 级雪花
更好的是,我们可以通过将鼠标的 x 值映射到层级来实现交互式功能。鼠标的 x 值可以在 0 到屏幕宽度之间的任何位置。我们希望将这个范围更改为 0 到 7 之间。以下是相应的代码:
level = map(mouseX,0,width,0,7)
然而,我们只希望得到整数层级,因此我们将使用int将该值转换为整数,如下所示:
level = int(map(mouseX,0,width,0,7))
我们将把它添加到draw()函数中,并将输出的“层级”传递给snowflake()函数。科赫雪花的完整代码见清单 10-9。
*snowflake.pyde*
def setup():
size(600,600)
def draw():
background(255)
translate(100,200)
level = int(map(mouseX,0,width,0,7))
#y(100,level)
snowflake(400,level)
def snowflake(sz,level):
for i in range(3):
segment(sz,level)
rotate(radians(120))
def segment(sz,level):
if level == 0:
line(0,0,sz,0)
translate(sz,0)
else:
segment(sz/3.0,level-1)
rotate(radians(-60))
segment(sz/3.0,level-1)
rotate(radians(120))
segment(sz/3.0,level-1)
rotate(radians(-60))
segment(sz/3.0,level-1)
清单 10-9:科赫雪花的完整代码
现在,当你运行程序并左右移动鼠标时,你会看到雪花的片段上出现更多的“凸起”,就像在图 10-12 中看到的那样。

图 10-12:一个 7 级雪花
这如何帮助我们理解海岸线悖论呢?回顾图 10-3,我们将三角形的边长(即边长)定为 1 个单位(例如 1 英里)。当我们将它分成三份,取出中间部分,并在中间加入一个长为三分之二的“凸起”时,这一边现在的长度变为 1 1/3 单位。它变长了三分之一,对吧?雪花的周长(即“海岸线”)在每一步都会增加三分之一。所以在第n步时,海岸线的长度是原始三角形周长的(4/3)^(n)倍。虽然可能不容易看出,但经过 20 步,雪花的海岸线变得如此曲折,以至于其总长度已经超过了原始长度的 300 倍!
谢尔宾斯基三角形
谢尔宾斯基三角形是一个著名的分形,首次由波兰数学家瓦茨瓦夫·谢尔宾斯基(Wacław Sierpiński)在 1915 年描述,但早在 11 世纪,就有意大利教堂地板上出现过类似的设计!它遵循了一种简单易懂的几何图案,但其设计却出奇复杂。它基于一个有趣的递归思想:首先画一个三角形作为第一层,接着在下一层,将每个三角形的三个角落各自转化为三个更小的三角形,如图 10-13 所示。

图 10-13:谢尔宾斯基三角形,层级 0、1 和 2
第一步很简单:只需绘制一个三角形。打开一个新草图并命名为sierpinski.pyde。我们像往常一样设置它,包括setup()和draw()函数。在setup()中,我们将输出窗口的大小设置为 600 像素乘 600 像素。在draw()中,我们将背景设为白色,并将画布平移到屏幕左下角的(50,450)点,开始绘制三角形。接下来,我们编写一个名为sierpinski()的函数,类似于我们在tree()中做的,当级别为 0 时绘制一个三角形。到目前为止的代码如清单 10-10 所示。
*sierpinski.pyde*
def setup():
size(600,600)
def draw():
background(255)
translate(50,450)
sierpinski(400,0)
def sierpinski(sz, level):
if level == 0: #draw a black triangle
fill(0)
triangle(0,0,sz,0,sz/2.0,-sz*sqrt(3)/2.0)
清单 10-10:谢尔宾斯基分形的设置
sierpinski()函数有两个参数:图形的大小(sz)和level变量。填充颜色为 0,表示黑色,但你可以通过使用 RGB 值将其设置为任何颜色。三角形的线包含六个数字:等边三角形的三个角的 x 和 y 坐标,边长为sz。
如你在图 10-13 中看到的,级别 1 包含在原始三角形的每个角落的三个三角形。这些三角形的大小也比前一层的三角形小一半。我们的做法是创建一个较小的低层次谢尔宾斯基三角形,平移到下一个角落,然后旋转 120 度。将清单 10-11 中的代码添加到sierspinski()函数中。
def draw():
background(255)
translate(50,450)
sierpinski(400,8)
def sierpinski(sz, level):
if level == 0: #draw a black triangle
fill(0)
triangle(0,0,sz,0,sz/2.0,-sz*sqrt(3)/2.0)
else: #draw sierpinskis at each vertex
for i in range(3):
sierpinski(sz/2.0,level-1)
translate(sz/2.0,-sz*sqrt(3)/2.0)
rotate(radians(120))
清单 10-11:向谢尔宾斯基程序中添加递归步骤
这段新代码告诉 Processing 当级别不为 0 时该做什么(行for i in range(3):表示“重复三次”):绘制一个比当前小一半大小的谢尔宾斯基三角形,并将其平移到等边三角形的中点,再向右旋转 120 度。注意sierspinski()函数在sierspinski(sz/2.0,level-1)中被执行,这就是递归步骤!当你调用
sierpinski(400,8)

图 10-14:一个 8 级谢尔宾斯基三角形
在draw()函数中,你将看到一个 8 级谢尔宾斯基三角形,如图 10-14 所示。
有趣的是,谢尔宾斯基三角形也出现在其他分形中,比如下一个,它并不是从三角形开始的。
方形分形
我们也可以用方形来创建谢尔宾斯基三角形。例如,我们可以创建一个方形,去掉右下象限,然后将剩余的每个象限用相同的形状替换。当我们重复这个过程时,应该会得到类似图 10-15 的效果。

图 10-15:0 级、1 级、2 级和 3 级的方形分形
为了创建这个分形,我们必须将三个较小的方形变成整个形状的副本。开始一个新的 Processing 草图,命名为squareFractal.pyde,然后使用清单 10-12 中的代码设置草图。
*squareFractal.pyde*
def setup():
size(600,600)
fill(150,0,150) #purple
noStroke()
def draw():
background(255)
translate(50,50)
squareFractal(500,0)
def squareFractal(sz,level):
if level == 0:
rect(0,0,sz,sz)
清单 10-12:创建squareFractal()函数

图 10-16:紫色正方形(层级 0)
我们可以在 setup() 函数中使用紫色的 RGB 值,因为我们不会在其他地方更改填充颜色。我们使用 noStroke() 以避免看到正方形上的黑色轮廓。在 draw() 函数中,我们调用 squareFractal() 函数,告诉它每个正方形的大小为 500 像素,层级为 0。在函数定义中,我们告诉程序如果层级为零,就简单地绘制一个正方形。这应该会给我们一个漂亮的大紫色正方形,如 图 10-16 所示。
对于下一个层级,我们将制作初始正方形边长一半的正方形。一个将放置在图形的左上角;然后我们将进行平移,将另外两个正方形放置在左下角和右上角,图 10-16 中展示了这个过程。列表 10-13 在此过程中去掉了大正方形的四分之一。
*squareFractal.pyde*
def squareFractal(sz,level):
if level == 0:
rect(0,0,sz,sz)
else:
rect(0,0,sz/2.0,sz/2.0)
translate(sz/2.0,0)
rect(0,0,sz/2.0,sz/2.0)
translate(-sz/2.0,sz/2.0)
rect(0,0,sz/2.0,sz/2.0)
列表 10-13:向正方形分形中添加更多正方形
在这里,如果层级为 0,我们绘制一个大正方形。如果层级不是 0,我们会在屏幕的左上角添加一个较小的正方形,向右平移后,在右上角添加另一个较小的正方形,再向左(负 x 轴)和平移向下(正 y 轴),并在屏幕的左下角添加一个较小的正方形。
这是下一个层级,当我们在 draw() 函数中将 squareFractal(500,0) 更新为 squareFractal(500,1) 时,它应该给我们一个底部右侧四分之一被去掉的正方形,如 图 10-17 所示。

图 10-17:正方形分形的下一个层级
对于接下来的层级,我们希望每个正方形进一步细分为分形,因此我们将 rect 线条替换为 squareFractal(),将 sz 中的值除以 2,并告诉它向下一层级移动,像在 列表 10-14 中那样。
*squareFractal.pyde*
def squareFractal(sz,level):
if level == 0:
rect(0,0,sz,sz)
else:
squareFractal(sz/2.0,level-1)
translate(sz/2.0,0)
squareFractal(sz/2.0,level-1)
translate(-sz/2.0,sz/2.0)
squareFractal(sz/2.0,level-1)
列表 10-14:向正方形分形中添加递归步骤

图 10-18:不是我们预期的!
在 列表 10-14 中,注意到 rect 线条(当层级不是 0 时)被 squareFractal() 替代。当我们在 draw() 函数中调用 squareFractal(500,2) 时,我们并没有得到预期的输出——而是得到了 图 10-18。
这是因为我们没有像在本章的 Y 分形中那样返回到起始点。
虽然我们可以手动计算需要多少平移,但我们也可以在 Processing 中使用 pushMatrix() 和 popMatrix() 函数,这些你在 第五章 中学过。
我们可以使用 pushMatrix() 函数来保存当前屏幕的方向——即原点 (0,0) 的位置以及网格的旋转角度。之后,我们可以进行任意多的平移和旋转,然后使用 popMatrix() 函数返回到保存的方向,而无需任何计算!
让我们在 squareFractal() 函数的开头添加 pushMatrix(),在结尾添加 popMatrix(),就像在清单 10-15 中一样。
*squareFractal.pyde*
def squareFractal(sz,level):
if level == 0:
rect(0,0,sz,sz)
else:
pushMatrix()
squareFractal(sz/2.0,level-1)
translate(sz/2.0,0)
squareFractal(sz/2.0,level-1)
translate(-sz/2.0,sz/2.0)
squareFractal(sz/2.0,level-1)
popMatrix()
清单 10-15:使用 pushMatrix() 和 popMatrix() 完成方形分形
现在,来自第 1 层的每个小方块都应该转变成一个分形,右下角的方块被去除,如图 10-19 所示。

图 10-19:方形分形的第 2 层
现在让我们尝试让鼠标生成层级数字,像我们之前做的那样,通过将 squareFractal(500,2) 替换为清单 10-16 中的代码。
*squareFractal.pyde*
def draw():
background(255)
translate(50,50)
level = int(map(mouseX,0,width,0,7))
squareFractal(500,level)
清单 10-16:让方形分形变得互动
在更高的层级,方形分形看起来非常像谢尔宾斯基三角形,正如你在图 10-20 中所看到的那样!

图 10-20:高级方形分形看起来像是谢尔宾斯基三角形!
龙曲线
我们将要创建的最终分形与我们迄今为止创建的其他分形不同,因为每个层级上的形状不是变小,而是变大。图 10-21 展示了从第 0 层到第 3 层的龙曲线示例。

图 10-21:龙曲线的前四个层级
正如数学娱乐家 Vi Hart 在她的 YouTube 视频中展示的那样,龙曲线的后半部分是前半部分的完美复制,她通过折叠和展开纸张来模拟这一过程。图 10-21 中的第三层(第 2 层)看起来像是两次左转后跟随一次右转。“铰链”或“折痕”位于每个龙曲线的中点。看看你能否在你的龙曲线中找到它!稍后,你将动态旋转曲线的一部分,以匹配下一个层级的曲线。
打开一个新的 Processing 草图,并将其命名为 dragonCurve.pyde。为了创建这个分形,我们首先创建一个“左龙”的函数,像在清单 10-17 中一样。
*dragonCurve.pyde*
def setup():
size(600,600)
strokeWeight(2) #a little thicker lines
def draw():
background(255)
translate(width/2,height/2)
leftDragon(5,11)
def leftDragon(sz,level):
if level == 0:
line(0,0,sz,0)
translate(sz,0)
else:
leftDragon(sz,level-1)
rotate(radians(-90))
rightDragon(sz,level-1)
清单 10-17:编写 leftDragon() 函数
在常规的 setup() 和 draw() 函数之后,我们定义我们的 leftDragon() 函数。如果层级为 0,我们只需画一条线,然后沿着线进行平移。这有点像第一章中的海龟画线。当层级大于 0 时,先画一个左龙(降低一级),然后左转 90 度,再画一个右龙(降低一级)。
现在我们来创建“右龙”函数(参见清单 10-18)。它与 leftDragon() 函数非常相似。如果层级为 0,只需画一条线并沿着它移动。否则,先画一个左龙,然后这次向 右 转 90 度,再画一个右龙。
*dragonCurve.pyde*
def rightDragon(sz,level):
if level == 0:
line(0,0,sz,0)
translate(sz,0)
else:
leftDragon(sz,level-1)
rotate(radians(90))
rightDragon(sz,level-1)
清单 10-18:编写 rightDragon() 函数
有趣的是,在这种情况下,递归语句不仅仅在一个函数内,而且还在左龙函数和右龙函数之间来回跳转!执行它后,第 11 级将呈现如图 10-22 所示的样子。

图 10-22:第 11 级龙形曲线
远非单纯的混乱角度堆积,这个分形在足够的级别后开始看起来像一条龙!记得我说过龙形曲线在中间是“折叠”的吗?在代码清单 10-19 中,我添加了一些变量来改变级别和大小,并且我让一个angle变量随鼠标的 x 坐标变化。这将使龙形曲线围绕下一级龙形曲线的“铰链”旋转。看看你是如何通过简单地旋转曲线,得到下一级曲线的两个部分!
*dragonCurve.pyde*
➊ RED = color(255,0,0)
BLACK = color(0)
def setup():
➋ global thelevel,size1
size(600,600)
➌ thelevel = 1
size1 = 40
def draw():
global thelevel
background(255)
translate(width/2,height/2)
➍ angle = map(mouseX,0,width,0,2*PI)
stroke(RED)
strokeWeight(3)
pushMatrix()
leftDragon(size1,thelevel)
popMatrix()
leftDragon(size1,thelevel-1)
➎ rotate(angle)
stroke(BLACK)
rightDragon(size1,thelevel-1)
def leftDragon(sz,level):
if level == 0:
line(0,0,sz,0)
translate(sz,0)
else:
leftDragon(sz,level-1)
rotate(radians(-90))
rightDragon(sz,level-1)
def rightDragon(sz,level):
if level == 0:
line(0,0,sz,0)
translate(sz,0)
else:
leftDragon(sz,level-1)
rotate(radians(90))
rightDragon(sz,level-1)
def keyPressed():
global thelevel,size1
➏ if key == CODED:
if keyCode == UP:
thelevel += 1
if keyCode == DOWN:
thelevel -= 1
if keyCode == LEFT:
size1 -= 5
if keyCode == RIGHT:
size1 += 5
代码清单 10-19:动态龙形曲线
在代码清单 10-19 中,我们添加了一些颜色 ➊ 用于曲线。在setup()函数中,我们声明了两个全局变量,thelevel和size1 ➋,它们的初始值在 ➌ 声明,并且我们在文件末尾的keyPressed()函数中使用箭头键来改变它们。
在draw()函数中,我们将一个angle变量 ➍ 与鼠标的 x 位置关联。之后,我们设置描边颜色为红色,稍微加大描边宽度,并使用thelevel和size1的初始值绘制左龙形曲线。你还记得pushMatrix()和popMatrix()函数,它们只是将绘图点返回到原始位置,以便绘制另一个曲线。然后我们按照angle变量的弧度数 ➎ 来旋转网格,并绘制另一个黑色的龙形曲线。leftDragon()和rightDragon()函数与之前完全相同。
Processing 的内置keyPressed()函数对于在草图中改变变量非常有用!你只需要声明你想要改变的全局变量,并通过键盘上的左(在此情况下),右,上,下箭头键来控制它们的变化。请注意,CODED ➏ 表示它不是字母或字符键。最后,它检查按下的是哪个箭头键,并根据按下的是上下箭头键(则调整level变量),还是左右箭头键(则调整size变量)来改变变量。
当你运行这个版本的dragonCurve草图时,它会在第 5 级绘制一个红色的龙形曲线;然后你可以旋转一个第 4 级曲线,看看第 5 级曲线是如何由两个第 4 级曲线组成的,只不过在中间旋转过,如图 10-23 所示。

图 10-23:第 5 级龙形曲线与动态交互式第 4 级曲线
当你移动鼠标时,黑龙曲线应该会旋转,你可以看到它如何与红色曲线的两半相吻合。上下箭头控制曲线的级别;按下上箭头,曲线会变长。如果曲线超出显示窗口,可以使用左箭头使每段变短,这样它就能适应屏幕。右箭头则使其变大。
这很有道理,因为leftDragon()函数首先执行,向左转,形成了一个右龙曲线。rightDragon()函数只是在leftDragon()的基础上转向相反的方向:它在中间右转,而不是左转。难怪它最终成了一个完美的复制品。
总结
我们仅仅触及了分形的表面,但希望你已经感受到分形的美丽以及它们在模拟自然界的复杂性方面的强大功能。分形和递归可以帮助我们重新审视关于逻辑和测量的观念。问题不再是“海岸线有多长?”而是“它有多崎岖?”
对于像海岸线和蜿蜒河流这样的分形线条,标准特征是自相似的尺度,或者说我们需要把地图放大多少,才能让它看起来像是同一事物的不同尺度。这实际上就是你通过输入0.8*sz、sz/2.0或sz/3.0到下一个层级所做的。
在下一章,我们将创建元胞自动机(CAs),我们会将它们绘制为屏幕上的小方块,这些方块会出生、成长并响应周围的环境。就像在第九章中我们的吃草羊一样,我们将创建元胞自动机并让它们运行——就像分形一样,我们将观察到由非常简单的规则生成的惊人且美丽的图案。
第十一章:细胞自动机
我喜欢把加湿器和除湿器放在一个房间里,让它们互相对抗。
—斯蒂文·赖特*

数学方程式是建模我们可以测量的事物的非常强大的工具;毕竟,方程式甚至帮助我们登上了月球。但是尽管它们非常强大,方程式在生物学和社会科学中的应用却有限,因为生物体并不是按照方程式来生长的。
生物在一个充满其他生物的环境中生长,并在一天中进行无数的互动。这些互动的网络决定了生物如何生长,而方程式通常无法捕捉这种复杂的关系。方程式可以帮助我们计算单个互动或反应所转换的能量或质量,但要模拟一个生物系统,例如,你需要重复计算几百或几千次。
幸运的是,有一种工具可以模拟细胞、生物和其他活系统如何根据环境生长和变化。由于它们与独立的生物有相似性,这些模型被称为细胞自动机 (CAs)。术语自动机指的是能够自主运行的事物。图 11-1 展示了使用计算机生成的两个细胞自动机示例。

图 11-1:一个基础细胞自动机的例子,以及充满虚拟生物的屏幕
我们在本章中将创建的 CAs 是由单元格组成的网格。每个细胞在 CA 中有多个状态(例如,开/关,生/死,或有色/空白)。细胞根据邻居的状态变化,这使得它们能够像活着一样生长和变化!
细胞自动机从上世纪 40 年代开始就成为研究对象,但它们真正起飞是在计算机变得普及之后。事实上,细胞自动机实际上只能通过计算机进行研究,因为尽管它们遵循非常简单的规则,比如“如果一个生物没有足够的邻居,它就会死”,这些规则只有在创建了数百或数千个这些生物并允许它们运行几百或几千代之后,才能产生有用的结果。
由于数学是研究模式的学科,细胞自动机这一数学主题充满了有趣的想法、编程挑战和无限的美丽输出可能!
创建一个细胞自动机
打开一个新的 Processing 草图,并命名为cellularAutomata.pyde。让我们从一个方格网开始,细胞将驻留在其中。我们可以轻松地画出一个 10×10 的方格网,每个方格的大小为 20,如清单 11-1 所示。
*cellular Automata.pyde*
def setup():
size(600,600)
def draw():
for x in range(10):
for y in range(10):
rect(20*x,20*y,20,20)
清单 11-1:创建一个方格网
保存并运行这个草图,你应该能看到像图 11-2 中展示的那样的网格。

图 11-2:一个 10 × 10 的网格
然而,每次我们想要更大的单元格,或者一个维度不同的网格时,都需要更改一堆数字。因此,如果我们使用变量,稍后修改起来会更容易。由于height、width 和 size 这些关键字已经用于图形窗口,我们需要使用不同的变量名。列表 11-2 通过创建一个易于调整大小的网格来改进列表 11-1,同时这些单元格也易于调整大小——这一切都通过使用变量来实现。
*cellular Automata.pyde*
GRID_W = 15
GRID_H = 15
#size of cell
SZ = 18
def setup():
size(600,600)
def draw():
for c in range(GRID_W): #the columns
for r in range(GRID_H): #the rows
rect(SZ*c,SZ*r,SZ,SZ)
列表 11-2:使用变量改进的网格程序
我们为网格的高度(GRID_H)和宽度(GRID_W)创建变量,使用大写字母表示这些是常量,并且它们的值不会变化。单元格的大小也是一个常量(目前如此),因此在声明其初始值时我们也将其大写(SZ)。现在,当你运行这段代码时,你应该能看到一个更大的网格,像图 11-3 中所示的那样。

图 11-3:使用变量创建的更大的网格
编写一个单元格类
我们需要编写一个类,因为我们创建的每个单元格都需要有自己的位置、状态(“开”或“关”)、邻居(它旁边的单元格)等等。我们通过添加列表 11-3 中所示的代码来创建Cell类。
*cellular Automata.pyde*
#size of cell
SZ = 18
class Cell:
def __init__(self,c,r,state=0):
self.c = c
self.r = r
self.state = state
def display(self):
if self.state == 1:
fill(0) #black
else:
fill(255) #white
rect(SZ*self.r,SZ*self.c,SZ,SZ)
列表 11-3:创建Cell类
单元格的初始state属性为 0(即关闭)。__init__方法中的state=0代码意味着如果我们没有指定状态,state将被设置为 0。display()方法只是告诉Cell对象如何在屏幕上显示自己。如果它是“开”的,单元格将显示为黑色;否则,显示为白色。此外,每个单元格都是正方形的,我们需要通过将其列和行号乘以它们的大小(self.SZ)来将单元格分开。
在draw()函数之后,我们需要写一个函数来创建一个空列表,用于存放我们的Cell对象,并使用嵌套循环将这些Cell对象添加到列表中,而不是像之前一样一个一个地绘制它们,如列表 11-4 所示。
*cellular Automata.pyde*
def createCellList():
'''Creates a big list of off cells with
one on Cell in the center'''
➊ newList=[]#empty list for cells
#populate the initial cell list
for j in range(GRID_H):
➋ newList.append([]) #add empty row
for i in range(GRID_W):
➌ newList [j].append(Cell(i,j,0)) #add off Cells or zeroes
#center cell is set to on
➍ newList [GRID_H//2][GRID_W//2].state = 1
return newList
列表 11-4:创建单元格列表的函数
首先,我们创建一个名为newList的空列表 ➊,并添加一个空列表作为一行 ➋,这个列表将填充Cell对象 ➌。然后,我们通过将行数和列数除以 2(双斜杠表示整数除法)来获取中心单元格的索引,并将该单元格的state属性设置为 1(即“开”) ➍。
在setup()中,我们将使用createCellList()函数,并将cellList声明为全局变量,这样它就可以在draw()函数中使用。最后,在draw()中,我们将循环遍历cellList中的每一行并更新它。新的setup()和draw()函数见列表 11-5。
def setup():
global cellList
size(600,600)
cellList = createCellList()
def draw():
for row in cellList:
for cell in row:
cell.display()
列表 11-5:用于创建网格的新的setup()和draw()函数
然而,当我们运行这段代码时,我们得到的是一个显示窗口角落里有更小单元格的网格,像图 11-4 中所示的那样。

图 11-4:尚未居中的单元格网格
现在我们可以通过改变 15x15 网格的大小,来创建我们想要的任意大小的单元格列表。
调整每个单元格的大小
为了调整单元格的大小,我们可以让 SZ 自动依赖于窗口的宽度。现在宽度是 600,所以我们可以按照列表 11-6 中的代码修改 setup() 函数。
*cellular Automata.pyde*
def setup():
global SZ,cellList
size(600,600)
SZ = width // GRID_W + 1
cellList = createCellList()
列表 11-6:调整单元格大小以自动适应显示窗口
双斜杠 (//) 表示 整数除法,它只返回商的整数部分。现在,当你运行程序时,它应该会生成一个网格,除了中心有一个彩色单元格外,其他单元格都是空的,像在图 11-5 中那样。

图 11-5:中心单元格为“开启”的网格
请注意,当你像在列表 11-16 中那样将 SZ (单元格的大小)加 1 时,这段代码的效果会更好,因为如果不加,网格有时无法填满整个显示窗口。但你也可以选择不加。
让 CA 生长
现在我们希望根据邻居的状态为“开启”的数量来改变单元格的状态。本部分灵感来源于斯蒂芬·沃尔夫拉姆(Stephen Wolfram)的《新科学》中描述的二维 CA。你可以在图 11-6 中看到这个 CA 的一个版本是如何生长的。

图 11-6:细胞自动机的生长阶段
在这个设计中,如果一个单元格有 一个或四个 开启的邻居,我们就让它变为开启状态(并保持开启)。
将单元格放入矩阵中
很容易找到列表中该单元格前后紧挨的单元格,这样我们就能得到它左边和右边的邻居。那么,如何找到单元格上方和下方的邻居呢?为了更容易做到这一点,我们可以将单元格放入一个二维 数组 或 矩阵 中,这是一种每一行都是列表的列表。这样,如果一个单元格位于第 5 列,我们就知道它的“上方”和“下方”邻居也会在第 5 列。
在 Cell 类中,我们添加了一个名为 checkNeighbors() 的方法,使得单元格能够统计有多少邻居是开启状态的。如果开启邻居的数量是 1 或 4,那么该单元格将返回 1 表示“开启”。否则,它返回 0 表示“关闭”。我们从检查上方的邻居开始:
def checkNeighbors(self):
if self.state == 1: return 1 #on Cells stay on
neighbs = 0
#check the neighbor above
if cellList[self.r-1][self.c].state == 1:
neighbs += 1
这段代码检查 cellList 中与当前单元格在同一列(self.c)但位于上一行(self.r – 1)的项。如果该项的 state 属性是 1,那么它是开启状态,我们将 neighbs 变量加 1。然后,我们需要对该单元格下面的邻居进行相同的操作,再对左侧和右侧的邻居进行相同操作。你能在这里看到简单的规律吗?
cellList[self.r - 1][self.c + 0] #above
cellList[self.r + 1][self.c + 0] #below
cellList[self.r + 0][self.c - 1] #left
cellList[self.r + 0][self.c + 1] #right
我们只需要跟踪行号和列号的变化。我们只需检查四个方向,对于“左边一个,右边一个”邻居,依此类推:[-1,0]、[1,0]、[0,-1] 和 [0,1]。如果我们把它们叫做 dr 和 dc(d,或希腊字母delta,是表示变化的传统数学符号),我们就可以避免重复自己:
*cellular Automata.pyde*
def checkNeighbors(self):
if self.state == 1: return 1 #on Cells stay on
neighbs = 0 #check the neighbors
for dr,dc in [[-1,0],[1,0],[0,-1],[0,1]]:
if cellList[self.r + dr][self.c + dc].state == 1:
neighbs += 1
if neighbs in [1,4]:
return 1
else:
return 0
最后,如果邻居数量是 1 或 4,state 属性将被设置为 1。在 Python 中,if neighbs in [1,4] 等同于说 if neighbs == 1 or neighbs == 4:。
创建单元格列表
到目前为止,我们通过在 setup() 中运行 createCellList() 函数并将输出赋值给 cellList 来创建了单元格列表,接着我们遍历了 cellList 中的每一行并更新了每个单元格。现在我们需要检查规则是否有效。围绕中心单元格的四个方格应该在下一步中改变状态。这意味着我们需要运行 checkNeighbors() 方法,然后显示结果。请按照以下方式更新你的 draw() 函数:
def draw():
for row in cellList:
for cell in row:
➊ cell.state = cell.checkNeighbors()
cell.display()
更新后的第 ➊ 行运行所有的 checkNeighbors() 代码,并根据结果设置单元格的开关状态。运行它,你应该会得到如下错误:
IndexError: index out of range: 15
错误出现在检查右边邻居的那一行。果然,由于每行只有 15 个单元格,15 号单元格右边没有邻居是合乎逻辑的。
如果一个单元格右边没有邻居(意味着它的列号是 GRID_W 减一),显然我们不需要检查那个邻居,可以直接跳到下一个单元格。对于检查位于第 0 行的单元格上方的邻居也是如此,因为它们上面没有单元格。类似地,第 0 列的单元格没有左边的邻居,第 14 行(即 GRID_H 减 1)的单元格也没有下面的邻居。在示例 11-7 中,我们使用了一个有价值的 Python 技巧,称为异常处理,通过 try 和 except 关键字添加到 checkNeighbors() 方法中。
*cellular Automata.pyde*
def checkNeighbors(self,cellList):
if self.state == 1: return 1 #on Cells stay on
neighbs = 0
#check the neighbors
for dr,dc in [[-1,0],[1,0],[0,-1],[0,1]]:
➊ try:
if cellList[self.r + dr][self.c + dc].state == 1:
neighbs += 1
➋ except IndexError:
continue
if neighbs in [1,4]:
return 1
else:
return 0
示例 11-7:在 checkNeighbors() 中添加条件判断
try 关键字 ➊ 字面意思是“尝试运行下一行代码”。在早期的错误信息中,我们遇到了一个 IndexError。我们使用 except 关键字 ➋ 来表示“如果遇到这个错误,就执行这个”。因此,如果我们遇到 IndexError,我们就继续进行下一轮循环。运行这段代码,你会得到一些有趣的结果,如图 11-7 所示。这显然与我们在图 11-6 中看到的不同。

图 11-7:不是我们预期的结果!
问题在于我们检查邻居并改变当前单元格的状态。然后,单元格的邻居在检查它们的邻居,但它们是在检查邻居的新状态。我们希望所有的单元格都检查它们的邻居,并将信息保存在一个新的列表中;然后,当所有单元格完成检查后,我们可以一次性更新整个网格。这就需要另一个列表来存储单元格,newList,它将在循环结束时替换掉cellList。
所以我们需要做的就是声明newList等于cellList,对吧?
cellList = newList #?
尽管这似乎合乎逻辑,但 Python 并没有像你可能期望的那样将newList的内容复制到cellList的原始内容中。它实际上是引用了newList,但是当你更改newList时,cellList也会被更改。
Python 列表很奇怪
Python 的列表有一种奇怪的行为。假设你声明了一个列表,并将另一个列表设置为它的副本,然后更改第一个列表。你可能不期望第二个列表也会变化,但事实正是如此,正如这里所展示的那样:
>>> a = [1,2,3]
>>> b = a
>>> b
[1, 2, 3]
>>> a.append(4)
>>> a
[1, 2, 3, 4]
>>> b
[1, 2, 3, 4]
如你所见,我们创建了列表a,然后将列表a的值赋给了列表b。当我们更改列表a时,没有更新列表b,但是 Python 也会更改列表b!
列表索引符号
确保在更新一个列表时不会意外更新另一个列表的一种方法是使用索引符号。将列表a的所有内容赋给列表b应该能够避免这种情况发生:
>>> a = [1,2,3]
>>> b = a[::]
>>> b
[1, 2, 3]
>>> a.append(4)
>>> a
[1, 2, 3, 4]
>>> b
[1, 2, 3]
这里,我们使用b = a[::]来表示“将列表a中的所有内容赋给变量b”,而不是简单地声明列表a等于列表b。这样,两个列表就不会相互关联。
在声明SZ之后,我们需要添加以下代码行来声明generation变量的初始值,这将追踪我们正在查看的代数:
generation = 0
我们将在更新代码的最后使用索引符号来避免列表引用问题。让我们在draw()后创建一个新的update()函数,这样所有更新将会在那个独立的函数中完成。示例 11-8 展示了setup()和draw()函数应该如何编写。
*cellular Automata.pyde*
def setup():
global SZ, cellList
size(600,600)
SZ = width // GRID_W + 1
cellList = createCellList()
def draw():
global generation,cellList
cellList = update(cellList)
for row in cellList:
for cell in row:
cell.display()
generation += 1
if generation == 3:
noLoop()
def update(cellList):
newList = []
for r,row in enumerate(cellList):
newList.append([])
for c,cell in enumerate(row):
newList[r].append(Cell(c,r,cell.checkNeighbors()))
return newList[::]
示例 11-8:检查更新是否生效,并在三代后停止
我们在setup()函数中创建第一次的cellList,然后将其声明为全局变量,以便在其他函数中使用。在draw()函数中,我们使用generation变量来表示我们要检查的代数(在这个例子中是三代);然后我们调用来更新cellList。我们像之前一样绘制单元格,使用display()方法,接着递增generation并检查它是否达到了我们想要的代数。如果达到了,内建的 Processing 函数noLoop()将停止循环。
我们使用 noLoop() 来关闭无限循环,因为我们只想绘制给定数量的世代。如果你注释掉它,程序将继续运行!图 11-8 显示了三代之后 CA 的样子。

图 11-8:一个正常工作的 CA!
使用变量来定义网格大小的好处是,我们可以通过简单地改变 GRID_W 和 GRID_H 变量来大幅改变 CA,如下所示:
GRID_W = 41
GRID_H = 41
如果我们将世代数增加到 13(在当前显示 if generation == 3 的行中),输出结果应该像图 11-9 那样。

图 11-9:我们的 CA 在更高级别上的样子,带有网格(左)和不带网格(右)
要去掉 CA 中空细胞周围的网格,只需在 setup() 函数中添加这一行:
noStroke()
这应该会关闭方格的轮廓,但填充颜色仍会显示,像图 11-9 那样。
到目前为止,我们做了很多工作!我们创建了二维列表,填充了细胞,并根据简单的规则打开了某些细胞。然后我们更新了这些细胞并展示了它们。CA 就这样不断生长!
练习 11-1:手动生长 CA
使用你在第十章中学到的 keyPressed() 函数手动让 CA 生长。
让你的 CA 自动生长
如果你希望 CA 从第 0 级循环到最大世代数(你为窗口选择适当的数字),只需将 draw() 函数更改为清单 11-9 中所示的内容。
*cellular Automata.pyde*
def draw():
global generation,cellList
➊ frameRate(10)
cellList = update(cellList)
for row in cellList:
for cell in row:
cell.display()
generation += 1
➋ if generation == 30:
generation = 1
cellList = createCellList()
清单 11-9: 使 CA 自动生长和再生
为了减慢动画的速度,我们使用了 Processing 内置的 frameRate() 函数 ➊。默认情况下是每秒 60 帧,所以我们将其减慢至 10 帧。然后我们告诉程序,如果 generation 变量达到 30 ➋(你可以将其更改为其他数字),就重置 generation 为 1,并创建一个新的 cellList。现在你应该能够根据需要观看 CA 以任何速度生长。更改规则,看看这如何改变 CA。你也可以更改颜色!
我们刚刚定义了一个简单的规则(如果一个细胞有 1 个或 4 个邻居,它是“开启”的),并编写了一个程序,将这个规则应用到成千上万的细胞上!结果看起来像一个活的、不断生长的有机体。接下来,我们将把代码扩展到一个著名的元胞自动机(CA),其中虚拟有机体可以移动、成长并死亡!
玩《生命游戏》
在 1970 年出版的《科学美国人》杂志中,数学科普作家马丁·加德纳(Martin Gardner)引起了人们对一个奇怪而美妙的游戏的关注,其中细胞的生死取决于它们有多少个邻居。这款游戏由英国数学家约翰·康威(John Conway)发明,具有三个简单的规则:
-
如果一个活细胞有少于两个活邻居,它会死亡。
-
如果一个活细胞有超过三个活邻居,它会死亡。
-
如果一个死细胞有恰好三个活邻居,它将复生。
通过这样一套简单的规则,这个游戏变得非常复杂也令人惊讶。1970 年,大多数人只能通过棋盘上的跳棋来可视化这个游戏,并且一个世代的计算可能需要很长时间。幸运的是,我们有计算机,而我们刚刚编写的元胞自动机代码已经包含了大部分创建此游戏所需的 Python 代码。保存我们到目前为止编写的元胞自动机文件,然后用不同的名字保存它,例如 生命游戏。
在这个游戏中,我们的细胞也将有对角线邻居。这意味着我们需要在dr,dc行中添加四个新值。列表 11-10 展示了你需要对checkNeighbors()代码进行的更改。
*GameOfLife.pyde*
def checkNeighbors(self):
neighbs = 0 #check the neighbors
➊ for dr,dc in [[-1,-1],[-1,0],[-1,1],[1,0],[1,-1],[1,1],[0,-1],[0,1]]:
try:
if cellList[self.r + dr][self.c + dc].state == 1:
neighbs += 1
except IndexError:
continue
➋ if self.state == 1:
if neighbs in [2,3]:
return 1
return 0
if neighbs == 3:
return 1
return 0
列表 11-10:修改 checkNeighbors() 代码以包括对角线邻居
首先,我们添加四个值 ➊ 来检查对角线邻居:[-1,-1] 表示左上方的邻居,[1,1] 表示右下方的邻居,依此类推。然后我们告诉程序,如果细胞位于 ➋ 上,检查它是否有两个或三个也处于打开状态的邻居。如果有,我们告诉程序返回 1,如果没有,则返回 0。否则,如果细胞关闭,我们告诉它检查是否有三个邻居是开启的。如果是,返回 1;如果不是,返回 0。
然后我们将活细胞随机放置在网格中,因此我们需要从 Python 的 random 模块导入 choice() 函数。将这一行添加到程序的顶部:
from random import choice
然后我们使用 choice() 函数随机选择一个新的 Cell 是开还是关。所以我们只需要将 createCellList() 函数中的 append 行更改为以下内容:
newList [j].append(Cell(i,j,choice([0,1])))
现在,我们不再需要前一个文件中的生成代码。draw()函数中的剩余代码如下所示:
def draw():
global cellList
frameRate(10)
cellList = update(cellList)
for row in cellList:
for cell in row:
cell.display()
运行这段代码,你将看到一场充满活力的动态游戏,其中生物在移动、变形、分裂,并与其他生物互动,正如图 11-10 所展示的那样。

图 11-10:生命游戏正在运行!
有趣的是,细胞的“云”如何变形、移动并与其他云(家族?群体?)碰撞。一些生物在屏幕上游荡,直到最终,网格将稳定到某种平衡状态。图 11-11 展示了这种平衡的示例。

图 11-11:进入稳定状态的生命游戏示例
在这个平衡状态的示例中,某些形状看起来稳定且不动,而其他形状则陷入了重复的模式中。
基本元胞自动机
这个最后的元胞自动机非常酷,涉及了一些额外的数学,但它仍然是一个简单的模式,只不过是扩展了的(虽然只是一个维度的扩展,这就是它为什么叫做“基本元胞自动机”)。我们从一行细胞开始,并将中间细胞的状态设置为 1,如图 11-12 所示。

图 11-12:一个基本元胞自动机的第一行
这段代码很容易编写。创建一个新的 Processing 草图并命名为 elementaryCA.pyde。绘制第一行单元格的代码如 清单 11-11 所示。
*elementaryCA.pyde*
➊ #CA variables
w = 50
rows = 1
cols = 11
def setup():
global cells
size(600,600)
#first row:
➋ cells = []
for r in range(rows):
cells.append([])
for c in range(cols):
cells[r].append(0)
➌ cells[0][cols//2] = 1
def draw():
background(255) #white
#draw the CA
for i, cell in enumerate(cells): #rows
for j, v in enumerate(cell): #columns
➍ if v == 1:
fill(0)
else: fill(255)
➎ rect(j*w-(cols*w-width)/2,w*i,w,w)
清单 11-11:绘制初等 CA 的第一行(代数)
首先,我们声明一些重要变量 ➊,例如每个单元格的大小和我们的 CA 的行列数。接下来,我们开始创建 cells 列表 ➋。我们在 cells 中创建 rows 行,并在每个列表中附加 cols 个 0。在行中,我们将中间的单元格设置为 1(或开) ➌。在 draw() 函数中,我们使用 enumerate 循环遍历行(很快就会有多行!)和列。我们检查元素是否为 1,如果是,则将其涂成黑色 ➍。否则,将其涂成白色。最后,我们绘制单元格的方框 ➎。x 值看起来有点复杂,但这只是确保 CA 始终居中。
当你运行这段代码时,你应该会看到 图 11-12 中展示的内容:一排单元格,其中中心有一个“开”的单元格。CA 下一行单元格的状态将取决于我们为每个单元格及其两个邻居设定的规则。总共有多少种可能性?每个单元格有两种可能的状态(1 或 0,或者“开”或“关”),因此左邻居、中心单元格和右邻居各有两种状态。这就意味着 2 × 2 × 2 = 8 种可能性。所有的组合展示在 图 11-13 中。

图 11-13:单元格及其两个邻居的所有可能组合
第一种可能性是中心单元格为开且两个邻居单元格也为开。接下来的可能性是中心单元格为开,左邻居为开,右邻居为关——以此类推。这个顺序非常重要。(你看出规律了吗?)我们如何将这些可能性描述给计算机程序呢?我们可以像下面这样写出八个条件语句:
if left == 1 and me == 1 and right == 1:
但是有一种更简单的方法。在《新科学的种类》中,斯蒂芬·沃尔夫拉姆根据三个单元格所代表的二进制数字为这些可能性分配了数字。记住,1 是开,0 是关,你可以看到 111 在二进制中是 7,110 是 6,以此类推,如 图 11-14 所示。

图 11-14:八种可能性的编号方法
现在我们已经为每种可能性编号,我们可以创建一个规则集——即一个列表,其中包含在下一代中处理每种可能性的规则。请注意,这些数字就像列表的索引,只不过是反过来的。我们可以很容易地解决这个问题。我们可以随机地或根据某些计划为每种可能性分配一个结果。图 11-15 展示了一个结果集。

图 11-15:为 CA 中每种可能性分配的结果集
每个可能性下方的框表示结果,或者是 CA 下一代中单元格的状态。左侧“可能性 7”下的白色框表示“如果单元格打开且两个邻居都打开,则在下一代中它会关闭。”同样,对于接下来的两个可能性(在我们的 CA 中尚不存在):结果是“关闭”。如图 11-12 所示,我们有很多“关闭”单元格被“关闭”单元格包围,这就是图 11-14 右侧显示的可能性:三个白色方块。在这种情况下,中间的单元格将在下一代中关闭。我们还有一个“打开”的单元格被两个“关闭”的单元格包围(可能性 5)。在下一代中,单元格将会打开。我们将使用 0 和 1 来表示我们的ruleset列表,如图 11-16 所示。

图 11-16:将生成下一行规则放入列表中
我们将把所有这些数字收集到一个名为ruleset的列表中,我们将在setup()函数之前添加这个列表:
ruleset = [0,0,0,1,1,1,1,0]
可能性顺序很重要,因为这个规则集被称为“规则 30”(00011110 在二进制中是 30)。我们的任务是根据规则创建下一行。让我们创建一个generate()函数,它查看第一行并生成第二行,然后查看第二行生成第三行,以此类推。添加列表 11-12 中显示的代码。
*elementaryCA.pyde*
#CA variables
w = 50
➊ rows = 10
cols = 100
--snip--
ruleset = [0,0,0,1,1,1,1,0] #rule 30
➋ def rules(a,b,c):
return ruleset[7 - (4*a + 2*b + c)]
def generate():
for i, row in enumerate(cells): #look at first row
for j in range(1,len(row)-1):
left = row[j-1]
me = row[j]
right = row[j+1]
if i < len(cells) - 1:
cells[i+1][j] = rules(left,me,right)
return cells
列表 11-12:编写generate()函数以生成 CA 中的新行
首先,通过更新行和列的数量来增大 CA ➊。接下来,我们创建rules()函数 ➋,它接受三个参数:左邻居的数字、当前单元格的数字和右邻居的数字。该函数检查ruleset并返回下一代中单元格的值。我们利用二进制数字,行4*a + 2*b + c将“1,1,1”转换为 7,将“1,1,0”转换为 6,依此类推。然而,正如你从图 11-15 中回忆的那样,索引是反向排序的,因此我们从 7 中减去总数,以获得ruleset的正确索引。
在setup()函数的末尾添加以下行:
cells = generate()
这将创建完整的 CA,而不仅仅是第一行。当你运行此代码时,你应该能看到使用“规则 30”制作的前 10 行 CA,如图 11-17 所示。

图 11-17:规则 30 的前 10 行

图 11-18:规则 30 的更多内容
程序正在从顶部开始,逐行生成下一行,依据我们在ruleset中给定的规则。如果我们继续执行会怎样呢?将行数和列数改为 1000,将每个单元格的宽度(w)设为 3。在setup()函数中添加noStroke()来去掉单元格的轮廓,然后运行草图。你应该能看到图 11-18 中的内容。
规则 30 是一个迷人的设计,因为它既不完全随机,也不完全规则。规则 73 也很酷;实际上,一位名叫 Fabienne Serriere 的女士将这一规则编程到编织机上,创造出带有该图案的围巾,如图 11-19 所示。你可以从knityak.com/购买带有这一及其他算法生成规则的围巾。

图 11-19:一条设计为元胞自动机的围巾:规则 73!
练习 11-2:改变规则集
将ruleset改为数字 90 的二进制形式。结果的元胞自动机(CA)是什么样子?提示:它是一个分形。
练习 11-3:放大与缩小
使用你在第十章中学到的keyPressed()函数,通过上下箭头键来改变宽度变量w的值。这应该能让你在元胞自动机(CA)中放大和缩小!
总结
在本章中,你学习了如何使用 Python 创建元胞自动机,或根据特定规则独立运作的单元格。我们编写程序使这些单元格在巨大的网格中遵循某些规则并自我更新,一代代产生出出乎意料的美丽设计和令人惊讶的生命般的行为。
在下一章,我们将创建虚拟生物,它们将为我们解决问题!这些生物将通过不断进化出更好的解决方案,来猜测一个秘密短语或在多个城市间找到最短的路线。
第十二章:使用遗传算法解决问题
Steve: 我们迷路了。
Mike: 我们有多迷路?*

当很多人想到数学时,他们会想到“石刻般”的方程式和运算,答案要么是对的,要么是错的。他们可能会惊讶地发现,在我们代数探索中,已经进行了很多的猜测和验证。
在本章中,你将学习以一种间接的方式破解密码和隐藏信息。这有点像第四章中的“猜测和验证”方法,我们只是将一堆整数代入方程,如果其中任何一个让方程成立,我们就将其打印出来。这次,我们将猜测一堆值,而不仅仅是一个。这不是解决问题的最优雅方式,但有了计算机,有时候蛮力方法反而是最有效的。
为了找出我们的秘密短语,我们生成猜测,然后根据它们与目标的匹配程度进行评分。但这就是我们与猜测和验证方法不同的地方:我们保留最好的猜测,并随机地反复变异,直到揭开谜底。程序不知道哪些字母是对的,哪些字母是错的,但通过不断变异我们目前为止做出的最佳猜测,我们会越来越接近正确答案。虽然这种方法现在看起来可能不太有希望,但你会发现它能出奇地迅速破解密码。这种方法被称为 遗传算法,计算机科学家使用它基于自然选择和进化生物学理论来寻找问题的解决方案。它的灵感来自于适应和变异的生物体,以及它们如何建立微小的优势,正如我们在第九章的类章节中看到的羊模型一样。
然而,对于更复杂的问题,随机变异不足以解决我们的难题。在这些情况下,我们加入 交叉,用来结合最适应的个体(或最佳猜测),以提高破解密码的可能性,就像最适应的生物更有可能将它们的遗传物质组合传递给后代一样。除了评分之外,所有这些活动都将相当随机,因此,可能会让人惊讶的是,我们的遗传算法竟然能这么有效。
使用遗传算法猜测短语
打开 IDLE,创建一个名为 geneticQuote.py 的新文件。与在第四章中猜数字不同,这个程序试图猜测一个秘密短语。我们只需要告诉程序它猜对了多少个字符——而不是字符的位置或是哪一个字符,只需告诉它猜对了多少个字符。
我们的程序将能够比猜测短密码做得更好。
编写 MAKELIST() 函数
为了了解这个如何工作,让我们创建一个目标短语。这里有一句我儿子从漫画《火影忍者》中想出来的长句子:
target = "I never go back on my word, because that is my Ninja way."
在英语中,我们可以选择一堆字符:小写字母、大写字母、空格和一些标点符号。
characters = " abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ.',?!"
让我们创建一个名为makeList()的函数,它将随机创建一个与target长度相同的字符列表。稍后,当我们尝试猜测目标短语时,我们将通过逐个字符地与目标进行比较来评分。较高的分数意味着猜测更接近目标。然后,我们将随机更改猜测中的一个字符,看看是否能提高其分数。看起来这种随机方法似乎不可能让我们得到确切的目标短语,但它会成功。
首先,导入random模块并编写makeList()函数,如列表 12-1 所示。
*genetic
Quote.py*
import random
target = "I never go back on my word, because that is my Ninja way."
characters = " abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ.',?!"
def makeList():
'''Returns a list of characters the same length
as the target'''
charList = [] #empty list to fill with random characters
for i in range(len(target)):
charList.append(random.choice(characters))
return charList
列表 12-1:编写makeList()函数以创建一个与目标长度相同的随机字符列表
在这里,我们创建一个名为charList的空列表,并根据目标中的字符数量循环。每次循环时,程序将从characters中随机选取一个字符放入charList中。循环完成后,返回charList。让我们测试它,以确保它能正常工作。
测试 MAKE LIST()函数
首先,让我们找出目标的长度,并检查我们的随机列表是否具有相同的长度:
>>> len(target)
57
>>> newList = makeList()
>>> newList
['p', 'H', 'Z', '!', 'R', 'i', 'e', 'j', 'c', 'F', 'a', 'u', 'F', 'y', '.',
'w', 'u', '.', 'H', 'W', 'w', 'P', 'Z', 'D', 'D', 'E', 'H', 'N', 'f', ' ',
'W', 'S', 'A', 'B', ',', 'w', '?', 'K', 'b', 'N', 'f', 'k', 'g', 'Q', 'T',
'n', 'Q', 'H', 'o', 'r', 'G', 'h', 'w', 'l', 'l', 'W', 'd']
>>> len(newList)
57
我们测量了target列表的长度,它有 57 个字符。我们的新列表长度也为 57 个字符。为什么要创建一个列表而不是字符串呢?我们创建列表是因为有时列表比字符串更容易操作。例如,你不能简单地在字符串中用另一个字符替换一个字符。但在列表中,你可以做到这一点,就像你在这里看到的那样:
>>> a = "Hello"
>>> a[0] = "J"
Traceback (most recent call last):
File "<pyshell#16>", line 1, in <module>
a[0] = "J"
TypeError: 'str' object does not support item assignment
>>> b = ["H","e","l","l","o"]
>>> b[0] = "J"
>>> b
['J', 'e', 'l', 'l', 'o']
在这个例子中,当我们尝试用"J"替换"Hello"字符串中的第一个项时,Python 不允许我们这样做,并且会报错。然而,使用列表进行相同的操作就没有问题。
在我们的geneticQuote.py程序中,我们希望以字符串形式查看随机生成的引号,因为这样更容易阅读。以下是如何使用 Python 的join()函数将列表打印为字符串:
>>> print(''.join(newList))
pHZ!RiejcFauFy.wu.HWwPZDDEHNf WSAB,w?KbNfkgQTnQHorGhwllWd
这些都是newList中的字符,但它们是字符串形式。看起来不像是一个有前景的开始!
编写 SCORE()函数
现在让我们编写一个名为score()的函数,通过将每个猜测逐个字符与目标进行比较,来为每个猜测打分,如列表 12-2 所示。
*genetic
Quote.py*
def score(mylist):
'''Returns one integer: the number of matches with target'''
matches = 0
for i in range(len(target)):
if mylist[i] == target[i]:
matches += 1
return matches
列表 12-2:编写score()函数以为猜测打分
score()函数接受我们传入的列表(mylist)中的每个项,并检查mylist的第一个字符是否与target列表的第一个字符匹配。然后,函数检查第二个字符是否匹配,以此类推。对于每个匹配的字符,我们将matches增加 1。最终,函数返回一个单一的数字,而不是正确的字符,因此我们实际上并不知道哪些字符是正确的!
我们的得分是多少?
>>> newList = makeList()
>>> score(newList)
0
我们的第一次猜测完全失败。一个匹配都没有!
编写 MUTATE()函数
现在我们将编写一个函数,通过随机更改一个字符来突变列表。这将允许我们的程序“猜测”直到接近我们尝试猜测的目标短语。代码见 Listing 12-3。
*genetic
Quote.py*
def mutate(mylist):
'''Returns mylist with one letter changed'''
newlist = list(mylist)
new_letter = random.choice(characters)
index = random.randint(0,len(target)-1)
newlist[index] = new_letter
return newlist
Listing 12-3:编写用于更改列表中一个字符的mutate()函数
首先,我们将列表中的元素复制到一个名为newlist的变量中。然后我们从characters列表中随机选择一个字符,作为将替换现有字符的新字母。我们随机选择一个 0 到目标长度之间的数字,作为替换字母的索引。然后我们将newlist中该索引位置的字符设置为新字母。这个过程在循环中反复进行。如果新列表的得分更高,它将成为“最佳”列表,最佳列表会继续突变,希望进一步提高其得分。
生成随机数
在所有函数定义之后启动程序时,我们通过调用random.seed()来确保随机性。调用random.seed()会将随机数生成器重置为当前时间。然后我们创建一个字符列表,由于第一个列表是目前为止最好的列表,所以我们将其声明为最佳列表。它的得分将是最佳得分。
*genetic
Quote.py*
random.seed()
bestList = makeList()
bestScore = score(bestList)
我们跟踪我们已经做了多少次猜测:
guesses = 0
现在我们开始一个无限循环,将突变bestList以生成新的猜测。我们计算其得分,并增加guesses变量:
while True:
guess = mutate(bestList)
guessScore = score(guess)
guesses += 1
如果新猜测的得分小于或等于目前为止的最佳得分,程序可以“继续”,如下面所示。那意味着它会回到循环的开头,因为它不是一个好的猜测,我们不需要对其做任何其他处理。
if guessScore <= bestScore:
continue
如果我们仍然在循环中,那意味着猜测已经足够好,可以输出了。我们也打印出它的得分。我们可以打印出列表(作为字符串)、得分以及进行了多少次总猜测。如果新猜测的得分等于目标的长度,那么我们就解决了这个引用,可以跳出循环:
print(''.join(guess),guessScore,guesses)
if guessScore == len(target):
break
否则,新猜测必须比目前为止的最佳列表更好,但还不完美,所以我们可以将其声明为最佳列表并保存其得分作为最佳得分:
bestList = list(guess)
bestScore = guessScore
Listing 12-4 展示了geneticQuote.py程序的完整代码。
*genetic Quote.py*
import random
target = "I never go back on my word, because that is my Ninja way."
characters = " abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ.',?!"
#function to create a "guess" list of characters the same length as target
def makeList():
'''Returns a list of characters the same length
as the target'''
charList = [] #empty list to fill with random characters
for i in range(len(target)):
charList.append(random.choice(characters))
return charList
#function to "score" the guess list by comparing it to target
def score(mylist):
'''Returns one integer: the number of matches with target'''
matches = 0
for i in range(len(target)):
if mylist[i] == target[i]:
matches += 1
return matches
#function to "mutate" a list by randomly changing one letter
def mutate(mylist):
'''Returns mylist with one letter changed'''
newlist = list(mylist)
new_letter = random.choice(characters)
index = random.randint(0,len(target)-1)
newlist[index] = new_letter
return newlist
#create a list, set the list to be the bestList
#set the score of bestList to be the bestScore
random.seed()
bestList = makeList()
bestScore = score(bestList)
guesses = 0
#make an infinite loop that will create a mutation
#of the bestList, score it
while True:
guess = mutate(bestList)
guessScore = score(guess)
guesses += 1
#if the score of the newList is lower than the bestList,
#"continue" on to the next iteration of the loop
if guessScore <= bestScore:
continue
#if the score of the newlist is the optimal score,
#print the list and break out of the loop
print(''.join(guess),guessScore,guesses)
if guessScore == len(target):
break
#otherwise, set the bestList to the value of the newList
#and the bestScore to be the value of the score of the newList
bestList = list(guess)
bestScore = guessScore
Listing 12-4: geneticQuote.py 程序的完整代码
现在当我们运行这个时,我们会得到一个非常快速的解决方案,并打印出所有改善得分的猜测。
i.fpzgPG.'kHT!NW WXxM?rCcdsRCiRGe.LWVZzhJe zSzuWKV.FfaCAV 1 178
i.fpzgPG.'kHT!N WXxM?rCcdsRCiRGe.LWVZzhJe zSzuWKV.FfaCAV 2 237
i.fpzgPG.'kHT!N WXxM?rCcdsRCiRGe.LWVZzhJe zSzuWKV.FfwCAV 3 266
i fpzgPG.'kHT!N WXxM?rCcdsRCiRGe.LWVZzhJe zSzuWKV.FfwCAV 4 324
--snip--
I nevgP go back on my word, because that is my Ninja way. 55 8936
I neveP go back on my word, because that is my Ninja way. 56 10019
I never go back on my word, because that is my Ninja way. 57 16028
这个输出显示最终得分为 57,总共进行了 16,028 次猜测才能完全匹配该引用。请注意输出的第一行,达到得分 1 时需要进行 178 次猜测!有更高效的方法来猜测一个引用,但我想通过一个简单的例子介绍基因算法的概念。重点是展示如何通过评分猜测并随机突变“目前最佳猜测”来在非常短的时间内得到准确结果。
现在,你可以利用这种通过打分和变异成千上万的随机猜测的思路,来解决其他问题。
求解旅行商问题 (TSP)
我的一个学生对猜测名言的程序不感兴趣,因为“我们已经知道名言是什么了。”所以让我们使用遗传算法来解决一个我们还不知道答案的问题。旅行商问题,简称TSP,是一个古老的难题,容易理解,但解决起来却非常困难。一个销售员需要访问给定的多个城市,目标是找到距离最短的路线。听起来简单吧?而且通过计算机,我们应该可以通过程序运行所有可能的路线并测量它们的距离,对吧?
事实证明,当城市数量超过一定程度时,即使是今天的超级计算机,计算复杂度也变得太大了。让我们看看在有六个城市时,有多少条可能的路线,如图 12-1 所示。

图 12-1:在n城市之间的路径数量,n从 2 到 6 的可能路线。
当只有两三个城市时,只有一条可能的路线。加上第四个城市后,它可以在之前的三个城市之间访问,因此将前一个步骤的路线数量乘以 3。所以,在四个城市之间,有三条可能的路线。再加上第五个城市,它可以在之前的四个城市之间访问,因此数量是前一步的四倍,结果是 12 条可能的路线。看到规律了吗?在n个城市之间,有

所以,在 10 个城市之间有 181,440 条可能的路线。在 20 个城市之间,有 60,822,550,204,416,000 条路线。超过一万亿之后呢?即使一台计算机每秒能检查一百万条路线,计算仍然需要将近 2000 年。这对于我们的目的来说太慢了。肯定有更好的方法。
使用遗传算法
类似于我们的名言猜测程序,我们将创建一个包含路线“基因”的对象,然后通过路线的短暂程度来为其打分。最佳路线随后将被随机变异,我们会对其变异进行评分。我们可以将一堆“最佳路线”拼接在一起,生成它们的“后代”,然后为后代打分。这个探索的最佳部分是我们并不知道答案。我们可以给程序一组城市及其位置,或者仅让它随机绘制城市并尝试优化路线。
打开一个新的 Processing 草图,并命名为travelingSalesperson.pyde。我们首先需要创建一个City对象。每个城市将有自己的 x 和 y 坐标以及一个用于标识它的编号。这样,我们就可以通过一个城市编号列表来定义一条路线。例如,[5,3,0,2,4,1]表示从城市 5 出发,前往城市 3,再到城市 0,依此类推。规则是销售员必须最终返回到第一个城市。Listing 12-5 显示了City类。
*travelingSales person.pyde*
class City:
def __init__(self,x,y,num):
self.x = x
self.y = y
self.number = num #identifying number
def display(self):
fill(0,255,255) #sky blue
ellipse(self.x,self.y,10,10)
noFill()
清单 12-5:编写City类以供travelingSalesperson.pyde程序使用
在初始化City时,我们获取 x 和 y 坐标,并为每个City对象赋予它自己的(self)x 和 y 分量。我们还获取一个数字,这个数字是城市的标识号。在display()方法中,我们选择一个颜色(在这种情况下是天蓝色),并在该位置绘制一个椭圆。在用noFill()函数绘制城市后,我们关闭填充,因为其他形状不需要填充颜色。
让我们确保它能够正常工作。我们创建setup()函数,声明一个显示窗口的大小,并创建City类的一个实例。记住,我们必须为它提供两个坐标的位置和一个标识编号,如在清单 12-6 中所示。
def setup():
size(600,600)
background(0)
city0 = City(100,200,0)
city0.display()
清单 12-6:编写setup()函数以创建一个城市
运行这个,你将看到你的第一座城市(见图 12-2)!

图 12-2:第一座城市
可能有助于让城市在其上方显示编号。要做到这一点,在城市的display()方法中,noFill()之前添加以下代码:
textSize(20)
text(self.number,self.x-10,self.y-10)
我们使用 Processing 内置的textSize()函数声明文本的大小。然后,我们使用text()函数告诉程序要打印的内容(城市的编号)以及打印位置(离城市左侧和上方 10 个像素)。在创建城市时,让我们开始一个cities列表,并在随机位置将更多城市添加到屏幕上。为了使用random模块中的方法,我们需要在文件顶部导入random:
import random
现在我们可以像在清单 12-7 中一样更新我们的setup()函数。
*travelingSalesperson.pyde*
cities = []
def setup():
size(600,600)
background(0)
for i in range(6):
cities.append(City(random.randint(50,width-50),
random.randint(50,height-50),i))
for city in cities:
city.display()
清单 12-7:编写setup()函数以创建六个随机城市
在setup()函数中,我们添加了一个循环,执行六次。它在屏幕上添加一个位置随机的City对象,离边缘 50 个单位。下一个循环遍历cities列表中的所有元素,并显示每一个。运行这个,你将看到六个城市在随机位置显示,每个城市都标有其 ID 号,如在图 12-3 中所示。

图 12-3:六个城市,标有它们的编号
现在让我们考虑一下城市之间的路线。我们将City对象(包含它们的位置和编号)放入cities列表中,最终,这个数字列表(我们的“遗传物质”)将由按某种顺序排列的城市编号组成。因此,Route对象也需要一个随机的数字列表:一个包含所有城市编号的随机顺序。当然,数字的范围将从 0 到城市数量减去 1。我们不想在每次想要更改城市数量时就到处修改代码中的数字,因此我们将为城市数量创建一个变量。将这一行放在文件的开头,在City类之前:
N_CITIES = 10
为什么 N_CITIES 要全大写?在整个代码中,我们不会改变城市的数量。因此,它实际上不是一个变量,而是一个常量。在 Python 中,常量的名称通常会大写,以便与变量区分开。这样做不会改变 Python 对这些常量的处理方式;即使是大写名称的变量,也仍然可以被修改。所以要小心。
我们将在需要使用城市总数的地方使用 N_CITIES,而且我们只需要改变一次这个值!将 Listing 12-8 中显示的代码放在 City 类之后。
class Route:
def __init__(self):
self.distance = 0
#put cities in a list in order:
self.cityNums = random.sample(list(range(N_CITIES)),N_CITIES)
Listing 12-8: Route 类
首先,我们将路线的距离(或长度,但 length 是 Processing 中的关键字)设置为零,然后我们创建一个 cityNums 列表,将城市的编号按随机顺序排列,以构成这条路线。
你可以使用 random 模块的 sample() 函数,给 Python 一个列表,然后通过告诉它要随机选择多少个项目来从该列表中取样。这就像 choice() 函数,但它不会选择同一个项目超过一次。在概率论中,这种方法叫做“无放回抽样”。输入以下代码到 IDLE 中查看抽样的效果:
>>> n = list(range(10))
>>> n
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
>>> import random
>>> x = random.sample(n,5)
>>> x
[2, 0, 5, 3, 8]
在这里,我们通过调用 range(10) 并将其转换为一个列表(它是一个“生成器”)来创建一个名为 n 的包含 0 到 9 之间数字的列表。接着,我们导入 random 模块,并让 Python 使用 sample() 函数从列表 n 中随机选择五个项目,并将它们保存到列表 x 中。在我们的 Route 代码中,参考 Listing 12-8,由于变量 N_CITIES 代表城市数量,值为 10,我们使用 range(10) 随机选择了 0 到 9 之间的 10 个数字,并将它们赋值给 Route 的 cityNums 属性。
那么它将如何显示呢?让我们在城市之间绘制紫色的线条。你也可以使用任何你喜欢的颜色。
这样在城市之间画线应该会让你想起代数或三角函数课上画图形的情形。唯一的区别是,现在在图形的结尾我们必须回到起点。还记得第六章中使用 beginShape、vertex 和 endShape 吗?就像我们用线条绘制形状一样,我们会将 Route 对象绘制成形状的轮廓,只不过这次我们不会填充它。使用 endshape(CLOSE) 会自动关闭循环!将 Listing 12-9 中的代码添加到 Route 类中。
def display(self):
strokeWeight(3)
stroke(255,0,255) #purple
beginShape()
for i in self.cityNums:
vertex(cities[i].x,cities[i].y)
#then display the cities and their numbers
cities[i].display()
endShape(CLOSE)
Listing 12-9: 编写 Route 类的 display 方法
循环将 Route 的 cityNums 列表中的每个城市都作为多边形的一个顶点。路线就是多边形的轮廓。注意,在 Route 的 display() 方法中,我们调用了城市的 display() 方法。这样,我们就不需要手动分别命令每个城市进行显示。
在 setup() 函数中,我们将创建一个 Route 对象,并传入 cities 列表和一个数字列表作为参数。然后我们显示它。清单 12-10 底部的最后两行代码就是实现这一点。
def setup():
size(600,600)
background(0)
for i in range(N_CITIES):
cities.append(City(random.randint(50,width-50),
random.randint(50,height-50),i))
route1 = Route()
route1.display()
清单 12-10:显示一条路线
运行这个,你会看到一个随机顺序的城市间路径,如图 12-4 所示。

图 12-4:一个随机的路线顺序
要改变城市的数量,只需将第一行中声明 N_CITIES 的值改为其他数字,然后运行程序。图 12-5 显示了我对 N_CITIES = 7 的输出。

图 12-5:包含七个城市的路线
现在你可以创建和显示路线了,接下来我们写一个函数来测量每条路线的距离。
编写 CALCLENGTH() 方法
Route 对象有一个 distance 属性,它在创建时被设为零。每个 Route 对象还有一个按顺序排列的城市列表,叫做 cityNums。我们只需遍历 cityNums 列表,并累加每对城市之间的距离。对于城市 0 到 4 没问题,但我们还需要计算从最后一个城市返回第一个城市的距离。
清单 12-11 显示了 calcLength() 方法的代码,它位于 Route 对象内部。
def calcLength(self):
self.distance = 0
for i,num in enumerate(self.cityNums):
# find the distance from the current city to the previous city
self.distance += dist(cities[num].x,
cities[num].y,
cities[self.cityNums[i-1]].x,
cities[self.cityNums[i-1]].y)
return self.distance
清单 12-11:计算 Route 的长度
首先,我们将 Route 的 distance 属性设为零,这样每次调用此方法时,它都会从零开始。我们使用 enumerate() 函数,这样不仅能获取 cityNums 列表中的城市编号,还能获取其索引。然后,我们通过当前城市(num)与前一个城市(self.cityNums[i-1])之间的距离来增加 distance 属性。接下来,让我们在 setup() 函数的末尾添加这一行代码:
println(route1.calcLength())
我们现在可以在控制台中看到销售人员所走的总距离,像在图 12-6 中一样。

图 12-6:我们已经计算出距离……我想是的。
这真的是距离吗?让我们确认一下。
测试 CALCLENGTH() 方法
让我们给程序设置一个简单的路线,即一个边长为 200 的正方形,并检查距离。首先,我们将城市数量的常量改为 4:
N_CITIES = 4
接下来,我们将 setup() 函数改为清单 12-12 所示的样子。
cities = [City(100,100,0), City(300,100,1),
City(300,300,2), City(100,300,3)]
def setup():
size(600,600)
background(0)
'''for i in range(N_CITIES):
cities.append(City(random.randint(0,width),
random.randint(0,height),i))'''
route1 = Route()
route1.cityNums = [0,1,2,3]
route1.display()
println(route1.calcLength())
清单 12-12:手动创建一个 Route 来测试 calcLength() 方法
我们注释掉了随机创建城市的循环,因为在检查 calcLength() 方法之后,我们会回到这个部分。我们创建了一个新的 cities 列表,包含一个边长为 200 的正方形的顶点。我们还声明了 cityNums 列表用于 route1;否则,它会随机混合城市。我们预计这个 Route 的长度是 800。
当我们运行代码时,看到的内容如图 12-7 所示。

图 12-7:calcLength() 方法有效!
结果是 800 单位,正如预测的那样!你可以尝试一些矩形路线或其他容易验证的路线。
随机路线
为了找到到达目的地的最短路线,我们需要找出所有可能的路线。为此,我们需要使用无限循环和 Processing 内建的 draw() 函数。我们将把路线代码从 setup() 函数移到 draw() 函数中。我们还会创建一堆随机路线,并显示它们及其长度。完整的代码展示在 Listing 12-13 中。
*travelingSales person.pyde*
import random
N_CITIES = 10
class City:
def __init__(self,x,y,num):
self.x = x
self.y = y
self.number = num #identifying number
def display(self):
fill(0,255,255) #sky blue
ellipse(self.x,self.y,10,10)
textSize(20)
text(self.number,self.x-10,self.y-10)
noFill()
class Route:
def __init__(self):
self.distance = 0
#put cities in a list in numList order:
self.cityNums = random.sample(list(range(N_CITIES)),N_CITIES)
def display(self):
strokeWeight(3)
stroke(255,0,255) #purple
beginShape()
for i in self.cityNums:
vertex(cities[i].x,cities[i].y)
#then display the cities and their numbers
cities[i].display()
endShape(CLOSE)
def calcLength(self):
self.distance = 0
for i,num in enumerate(self.cityNums):
# find the distance to the previous city
self.distance += dist(cities[num].x,
cities[num].y,
cities[self.cityNums[i-1]].x,
cities[self.cityNums[i-1]].y)
return self.distance
cities = []
def setup():
size(600,600)
for i in range(N_CITIES):
cities.append(City(random.randint(50,width-50),
random.randint(50,height-50),i))
def draw():
background(0)
route1 = Route()
route1.display()
println(route1.calcLength())
Listing 12-13: 创建和显示随机路线
当你运行这个时,你应该会看到一堆路线被显示,并且一堆数字被打印到控制台。
但我们真正关心的是保留最好的(最短的)路线,因此我们将添加一些代码来保存“bestRoute”并检查新的随机路线。将 setup() 和 draw() 修改为 Listing 12-14 中所示。
cities = []
random_improvements = 0
mutated_improvements = 0
def setup():
global best, record_distance
size(600,600)
for i in range(N_CITIES):
cities.append(City(random.randint(50,width-50),
random.randint(50,height-50),i))
best = Route()
record_distance = best.calcLength()
def draw():
global best, record_distance, random_improvements
background(0)
best.display()
println(record_distance)
println("random: "+str(random_improvements))
route1 = Route()
length1 = route1.calcLength()
if length1 < record_distance:
record_distance = length1
best = route1
random_improvements += 1
Listing 12-14: 跟踪随机改进
在 setup() 函数之前,我们创建一个变量来计数程序所做的随机改进次数。同时,我们创建另一个变量,稍后会用它来计数突变改进的次数。
在 setup() 中,我们创建了 route1 作为第一个 Route,我们将其命名为“最佳路线”,并将其距离命名为 record_distance。由于我们希望将这些变量与其他函数共享,我们在函数开始时将它们声明为全局变量。
在 draw() 中,我们持续生成新的随机路线,并检查它们是否比我们认为最好的路线更优。由于我们只使用 10 个城市,如果让程序运行一段时间,这可能会得到一个最优解。你会发现,它只需要大约十几个随机改进。但是,请记住,只有 181,440 条独特的路线可以穿越 10 个城市。一个 10 城市路线如 Figure 12-8 所示。
然而,如果你将城市数增加到 20,程序将不断运行,如果你允许它运行几天,可能永远也无法接近最优解。我们需要开始使用章节开头提到的短语猜测程序中的思路,给我们的猜测打分,并突变最好的猜测。与之前不同,我们将创建一个“交配池”,将最好的路线进行基因般的组合。

Figure 12-8: 随机寻找最优路线——如果你能等几分钟的话
应用短语猜测突变思想
数字列表(销售员访问的城市顺序)将是 Route 的遗传物质。首先,我们看看一些随机突变的路线如何解决旅行商问题(就像我们在短语猜测程序中做的一样),然后我们将突变和“交配”更好的路线,以(希望)创造一个更优的路线。
在列表中突变两个数字
让我们编写一个方法,随机变异 Route 对象中 cityNums 列表中的两个数字。实际上,这只是一个交换操作。你应该能猜到我们将如何随机选择两个数字并让它们在列表中交换位置。
Python 有一种独特的语法来交换两个数字的值。你可以在不创建临时变量的情况下交换两个数字。例如,如果你在 IDLE 中输入 列表 12-15 中的代码,它将无法正常工作。
>>> x = 2
>>> y = 3
>>> x = y
>>> y = x
>>> x
3
>>> y
3
列表 12-15:交换变量值的错误方式
当你通过输入 x = y 将 x 的值改为与 y 相同,它们都变成了 3。现在当你尝试将 y 设置为与 x 相同的值时,它并没有设置为 x 原来的值(2),而是当前 x 的值,即 3。所以两个变量最终都变成了 3。
但你可以在同一行交换值,像这样:
>>> x = 2
>>> y = 3
>>> x,y = y,x
>>> x
3
>>> y
2
这样交换两个变量的值对我们接下来要做的变异非常有用。我们可以将交换操作扩展到多个城市,而不仅仅局限于交换两个数字。我们可以将交换操作放入循环中,这样程序将选择任意数量的城市并交换前两个数字,然后交换下一个数字对,以此类推。mutateN() 方法的代码如 列表 12-16 所示。
def mutateN(self,num):
indices = random.sample(list(range(N_CITIES)),num)
child = Route()
child.cityNums = self.cityNums[::]
for i in range(num-1):
child.cityNums[indices[i]],child.cityNums[indices[(i+1)%num]] = \
child.cityNums[indices[(i+1)%num]],child.cityNums[indices[i]]
return child
列表 12-16:编写 mutateN() 方法,变异任意数量的城市
我们给 mutateN() 方法传入 num,即要交换的城市数量。然后该方法通过从城市编号的范围中随机抽取样本,生成一个要交换的索引列表。它创建一个“子” Route,并将自身的城市编号列表复制给子类。然后它交换 num-1 次。如果交换了完整的 num 次,第一次交换的城市将会与所有其他索引交换,最后回到它原来的位置。
那一长行代码其实就是我们之前看到的 a,b = b,a 语法,只不过这里交换的是两个 cityNums。% 运算符确保索引不会超过 num,即样本中的城市数量。例如,如果你交换的是四个城市,当 i 为 4 时,它会将 i + 1 从 5 改为 5 % 4,结果是 1。
接下来,我们在 draw() 函数的末尾添加一个部分,变异最优秀的 Route 的城市编号列表,并测试变异后的 Route 的长度,如 列表 12-17 所示。
def draw():
global best,record_distance,random_improvements
global mutated_improvements
background(0)
best.display()
println(record_distance)
println("random: "+str(random_improvements))
println("mutated: "+str(mutated_improvements))
route1 = Route()
length1 = route1.calcLength()
if length1 < record_distance:
record_distance = length1
best = route1
random_improvements += 1
for i in range(2,6):
#create a new Route
mutated = Route()
#set its number list to the best one
mutated.cityNums = best.cityNums[::]
mutated = mutated.mutateN(i) #mutate it
length2 = mutated.calcLength()
if length2 < record_distance:
record_distance = length2
best = mutated
mutated_improvements += 1
列表 12-17:变异最优秀的“生物”
在 for i in range(2,6): 循环中,我们告诉程序在 number 列表中变异 2、3、4 和 5 个数字,并检查结果。现在,程序通常可以在几秒钟内很好地处理 20 城市的路线,如 图 12-9 所示。

图 12-9:一条 20 城市的路线
变异后的“生物”在改善距离方面表现得比随机的要好得多!图 12-10 显示了打印输出。

图 12-10:变异的结果比随机改进效果要好得多!
图 12-10 对所有改进进行了分类,其中 29 个改进来自变异,只有一个改进来自随机生成的Route。这表明,变异列表比创建新的随机路线更能找到最优路线。我通过修改这一行,增加了变异强度,将 2 到 10 个城市进行交换:
for i in range(2,11):
尽管这在 20 城市问题中有所提升,甚至对于一些 30 城市问题也有效,但程序往往会陷入非最优的死胡同,如图 12-11 所示。

图 12-11:一个陷入非最优状态的 30 城市问题
我们将迈出最后一步,完全走向基因算法。现在,我们不再局限于我们认为最好的路线。相反,我们将拥有一个庞大的种群来选择最佳路线。
我们将为任意数量的路线创建一个population列表,挑选出最“适应”的路线,交叉它们的数字列表,并希望能生成更好的路线!在setup()函数之前,在cities列表后,添加population列表和路线数量常量,如列表 12-18 所示。
cities = []
random_improvements = 0
mutated_improvements = 0
population = []
POP_N = 1000 #number of routes
列表 12-18:初始化population列表和种群大小变量
我们刚刚创建了一个空列表,用来存放我们的路线种群,并为总路线数创建了一个变量。在setup()函数中,我们将POP_N条路线填充进population列表,如列表 12-19 所示。
def setup():
global best,record_distance,first,population
size(600,600)
for i in range(N_CITIES):
cities.append(City(random.randint(50,width-50),
random.randint(50,height-50),i))
#put organisms in population list
for i in range(POP_N):
population.append(Route())
best = random.choice(population)
record_distance = best.calcLength()
first = record_distance
列表 12-19:创建路线种群
请注意,我们必须将population列表声明为全局变量。我们使用for i in range(POP_N)将POP_N条路线放入population列表中,然后我们将一个随机选择的路线作为当前最好的路线。
改进路线的交叉
在draw()函数中,我们将对population列表进行排序,以便最短的Route对象排在最前面。我们将创建一个名为crossover()的方法,随机地将cityNums列表拼接在一起。它的工作方式如下:
a: [6, 0, 7, 8, 2, 1, 3, 9, 4, 5]
b: [1, 0, 4, 9, 6, 2, 5, 8, 7, 3]
index: 3
c: [6, 0, 7, 1, 4, 9, 2, 5, 8, 3]
“父母”是列表a和b。索引是随机选择的:索引 3。然后,a列表从索引 2(7)到索引 3(8)之间被切割,因此子列表从[6,0,7]开始。剩余的、不在切片中的数字按它们在b列表中的顺序添加到子列表中:[1,4,9,2,5,8,3]。我们将这两个列表连接起来,得到子列表。crossover()方法的代码见列表 12-20。
def crossover(self,partner):
'''Splice together genes with partner's genes'''
child = Route()
#randomly choose slice point
index = random.randint(1,N_CITIES - 2)
#add numbers up to slice point
child.cityNums = self.cityNums[:index]
#half the time reverse them
if random.random()<0.5:
child.cityNums = child.cityNums[::-1]
#list of numbers not in the slice
notinslice = [x for x in partner.cityNums if x not in child.cityNums]
#add the numbers not in the slice
child.cityNums += notinslice
return child
列表 12-20:编写Route类的crossover()方法
crossover()方法要求我们指定partner,另一个父母路线。child路线就此生成,并且会随机选择一个切片位置。子列表获取第一个切片中的数字,然后我们有一半的时间会反转这些数字,以增加基因多样性。我们创建一个列表,包含不在切片中的数字,并将这些数字按顺序加入另一个父母(或合作父母)的列表中。最后,连接这些切片并返回child路线。
在draw()函数中,我们需要检查population列表中的路线,找出最短的一条。我们还需要像之前那样检查每一条路线吗?幸运的是,Python 提供了一个方便的sort()函数,我们可以用它按calcLength()对population列表进行排序。所以,列表中的第一个Route就是最短的那条。draw()函数的最终代码展示在列表 12-21 中。
def draw():
global best,record_distance,population
background(0)
best.display()
println(record_distance)
#println(best.cityNums) #If you need the exact Route through the cities!
➊ population.sort(key=Route.calcLength)
population = population[:POP_N] #limit size of population
length1 = population[0].calcLength()
if length1 < record_distance:
record_distance = length1
best = population[0]
#do crossover on population
➋ for i in range(POP_N):
parentA,parentB = random.sample(population,2)
#reproduce:
child = parentA.crossover(parentB)
population.append(child)
#mutateN the best in the population
➌ for i in range(3,25):
if i < N_CITIES:
new = best.mutateN(i)
population.append(new)
#mutateN random Routes in the population
➍ for i in range(3,25):
if i < N_CITIES:
new = random.choice(population)
new = new.mutateN(i)
population.append(new)
列表 12-21:编写最终的draw()函数
我们在➊使用sort()函数,然后修剪population列表的末尾(最长的路线),使得列表保持POP_N条路线的长度。接下来,我们检查population列表中的第一个项目,看它是否比最优路线更短。如果是,我们就像以前一样将其设为最佳路线。接着,我们随机从人群中选取两条路线,对它们的cityNums列表进行交叉操作,并将结果child路线添加到人群中 ➋。在➌,我们突变best路线,交换 3、4、5 个数字,直到最多 24 个数字(如果这少于草图中城市的数量)。最后,我们随机从人群中选择路线,并对其进行突变,以尝试改进距离 ☐。
现在,使用 10,000 条路线的人群,我们的程序可以对 100 个城市的最优路线做出相当好的近似。图 12-12 展示了程序如何将一条初始长度为 26,000 单位的路线优化到低于 4,000 单位。

图 12-12:通过 100 个城市的路线改进
这“只”花了半个小时就完成了!
总结
在本章中,我们不仅仅使用 Python 来解答那些数学课上答案已经知道的问题。相反,我们使用间接方法(为字符串或经过多个城市的路线打分)来解决没有答案的题目!
为了做到这一点,我们模仿了基因发生突变的生物体行为,利用了某些突变比其他突变在解决当前问题时更有用的事实。我们在本章开始时就知道了目标短语,但为了确定我们的最终路线是否最优,我们必须保存城市位置并多次运行程序。这是因为遗传算法,就像真实的生物体一样,只能从它们开始时的状态出发,并且常常陷入非最优的困境,正如你所看到的那样。
但这些间接方法出奇地有效,并且在机器学习和工业过程中得到广泛应用。方程式适合表达非常简单的关系,但许多情况并不像那样简单。现在你有了许多有用的工具,比如我们的“羊与草”模型、分形、元胞自动机,最后是遗传算法,用于研究和建模非常复杂的系统。


浙公网安备 33010602011771号