从零开始的数学黑魔法-全-

从零开始的数学黑魔法(全)

原文:zh.annas-archive.org/md5/8768ddcdfd0a25005ca88eb5a7c58080

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

Image

本书通过 Scratch 引导你探索数学。它展示了如数字表示、可整除性、质数和密码学等概念,这些在日常生活中有用且相关,同时也是有趣的编程项目。它的重点是如何提出有趣的数学问题,以及如何编程让计算机回答这些问题。

这本书还讲述了如何找到解决问题的最佳方法。你将看到,一点点的规划,加上合适的数学或编程技巧,能够让复杂的计算变得可行。这些就是书名中的“黑客”技巧;你将学习如何通过编程策略引导 Scratch 给出你想要的答案,并掌握提供巧妙解决方案的数学技巧,帮助你解决看似不可能的问题。

为什么选择 Scratch?

探索计算机语言意味着了解它能做什么,并弄明白它如何回答你想提出的问题。Scratch 是一种鼓励玩的语言。虽然它通常用于制作图形、声音和游戏,但它的玩乐精神同样适用于探索数学。

Scratch 简单易用:它可以直接在网页浏览器中通过 Scratch 网站运行,* scratch.mit.edu ,无需安装(不过,如果你更喜欢离线工作,也可以从 scratch.mit.edu/download *下载 Scratch 应用程序)。你通过像拼接 LEGO^®积木一样,将命令组合在一起,所有选项都可以通过拖放立即使用。基于块的界面让你能够专注于将循环、条件语句和变量组合起来,构建你想要的任何东西,而无需在文本编程语言的语法细节中陷入困扰。如果你曾使用 Scratch 制作游戏或编写故事,那么你会发现,使用它来解决与数学相关的问题也变得轻而易举。

这本书适合谁

如果你符合以下条件,这本书适合你:

Image 你喜欢 Scratch 编程,并希望深入了解它

Image 你喜欢数学,准备好迎接一些新想法

Image 你喜欢谜题和模式

Image 当你找到解决问题的最佳方法时,你知道那种感觉有多好

如果你已经有一些基本的 Scratch 编程经验,你将从这本书中受益匪浅。例如,你应该能够舒适地创建自己的变量、列表和自定义模块。如果你对代数和几何有一些了解,比如知道如何处理代数变量和解释坐标平面上的点,那也会有所帮助。仅凭这几样基本元素,你将惊讶于在 Scratch 中可以做的事情,从找出一个集合中的所有质数,到创建无法破解的秘密代码。

本书内容

以下是你将在本书每一章中找到的内容概览:

第一章:计算机如何处理数字 我们将从探索计算机如何内部处理数字开始。了解这一点可以帮助你避免一些难以察觉的错误,并克服编程语言的一些限制。

第二章:探索可除性与质数 接下来,我们将研究质数,它们是所有整数的基本构件。你将看到 Scratch 如何帮助你找到质数并将它们用于计算。

第三章:利用质因数分解分解数字 本章介绍了不同的策略,用于找出如何将一个数字表示为质数的乘积。通过尝试不同的技巧,你将能找到最有效的策略。

第四章:在序列中寻找模式 在本章中,你将学习如何使用 Scratch 理解数字列表中的模式。有时这些模式来自代数,有时来自几何,或者两者兼而有之。

第五章:从序列到数组 掌握了一维列表后,我们将进入二维数字表格,如乘法表和帕斯卡三角形。你将学习如何在 Scratch 中追踪二维数据结构。

第六章:制作代码并破解它们 本章将向你展示如何使用数学规则将消息加密成乱码。你还将发现如何解密这些消息,无论是否拥有秘密密钥。

第七章:计数实验 接下来,我们将解决来自组合学领域的两个有趣问题,组合学也叫做计数艺术。你将看到 Scratch 如何通过简单的规则构建模式,并计算这些模式可以出现的方式有多少种。

第八章:三次圆周率计算 在本章中,你将尝试不同的方法来计算π的值,包括通过面积法、收敛法以及使用数论法。

第九章:接下来做什么? 最后一章提供了一些建议,帮助你寻找更多数学和编程问题,以及更多的工具来解决这些问题。

每一章都包含一系列动手实践的 Scratch 编程项目,帮助你将章节中的概念付诸实践。全书共有 33 个项目。此外,书中的挑战将帮助你在每章讨论的思想和程序基础上进一步构建。书的附录包含了一些示例代码以及如何解决这些挑战的提示。

如何阅读本书

阅读这本书最简单的方式是像读小说一样,从头到尾直接读完。尽管如此,书中涵盖的主题大部分是独立的,因此你也可以按照自己的兴趣跳跃阅读各章。如果你特别对某个章节感兴趣,或许你想跳到那一章;或者也许某一章和你正在学校学习的内容相关,你想从那一章开始阅读。以这种方式进行探索是完全可以的,但我有几点建议。

第一章,关于计算机算术,将帮助你理解后续章节中程序可能出现的算术问题。最好先阅读这一章。第二章介绍了质数,所以你应该先读这一章,再阅读第三章,它建立了质因数分解。你可能还想在阅读第五章(关于数组)之前,先读一下第四章(关于数列),因为数组可以被看作是二维的数列与一维数列之间的等效关系。接下来的章节都可以独立阅读,但尤其是第七章,其中包含了一些相当复杂的概念和程序;在开始阅读这一章之前,最好先阅读一些其他章节,以便获得更多的 Scratch 编程和数学思维的练习。

在线资源

你可以在本书的 Scratch 工作室找到所有 33 个项目的 Scratch 代码和大部分挑战问题,网址是 scratch.mit.edu/studios/29153814。这些程序也可以从本书的网页下载,网址是 nostarch.com/math-hacks-scratch

我的灵感

我写这本书是考虑到我的孙辈。当他们向我介绍 Scratch 时,我立刻被这个语言的吉祥物——那只微笑的橙色小猫吸引了。我当时不知道这个吉祥物的名字,但它完全可以叫做 Gummitch,那个在我最喜欢的短篇小说《春季跳跃者的时空》中表现出色的主角。Gummitch 是一只超天才的小猫,他有着宏大的计划,打算写书来向其他超天才小猫们解释这个世界,书名包括《气味百科全书》、《人类与猫的心理学》,以及最引人注目的 《春季跳跃者的时空》

看到 Scratch Cat 与 Gummitch 的相似性,我想要写一本 Gummitch 会写的数学书,一本类似 《春季跳跃者的数字学》 的书。我尽力将这本书的数学水平调整到适合各地超天才小猫的程度。书中的项目既有趣又能在 Scratch 中进行创作,将一点数学与 Scratch 代码结合,开辟了全新的思维方式,去思考数字、字母、几何和模式。我希望你们读得开心,就像我写得那么开心!

1

第一章:计算机如何看待数字

Image

为了进行数学运算,计算机需要一种表示数字的方式。事实证明,计算机处理数字的方式与人类有很大不同。

例如,我们喜欢认为我们可以一直数下去,数到越来越大的数字,但计算机有有限的存储空间。如果它们开始计数,最终会用完空间。没有最大的数字,但在 Scratch 中,确实有一个计算机能够表示的最大数字。

类似地,我们将分数和小数看作与数轴上的点相对应,任意两个整数之间都有无限多个点。然而,随着我们将这些点在计算机有限的世界中越来越紧密地压缩,最终会没有足够的空间来记录它们。没有最小的正数,但确实有一个计算机能够在 Scratch 中使用的最小正数。

在这一章中,我们将探讨计算机屏幕后发生的事情,以及计算机如何看待数字。我们将深入了解 Scratch 能够表示的数字的限制。理解这些限制非常重要,这样我们才能确保程序的结果是准确的。你还将学习一些绕过 Scratch 限制的小技巧,并欺骗计算机以表示比正常情况下更多的数字。

数字究竟是什么?

每个人都认为他们知道什么是数字,但实际上有许多不同的数字系统可供选择,取决于我们希望数字做什么。我们通常从 1 开始学习计数,这些数字被称为计数数字。有时我们希望从 0 开始计数,这时这些数字就叫做整数。当我们可以前进或后退,允许负数时,就生成了整数集:{ . . . , –3, –2, –1, 0, 1, 2, 3, . . . }。

我们可以将数字与几何学建立联系,把数字看作对应于数轴上的点,从而构建出实数集。我们还可以通过将整数除以其他(非零)整数来得到有理数。这里的有理不是指逻辑性和合理性,而是因为这些数字是由比率构成的。有时候有理数被称为分数,但这可能会造成误解。在日常语言中,分数通常指的是某物的一部分,意味着小于 1 的部分,而像 3/2 和 4/3 这样的有理数可以大于 1。此外,分数通常是指分母大于或等于 2 的情况(如二分之一、三分之一等等),但有理数有时也有分母为 1 的情况,如 2/1、3/1 等。这样,整数实际上是一种特殊的有理数。

在所有这些数字的使用方式中,我们依靠直觉,当我们命名一个特定数字时,实际上是在识别一个无限集合中的一个元素。也就是说,我们期望数字会永无止境地延续下去:“通向无限,超越一切”,正如巴斯光年所说。然而,数字还有另一种使用方式,它们会循环回绕,反复使用它们的值,就像时钟上的数字一样。这种数字的使用方式非常适合追踪定期或重复发生的事件,并且具有一些有趣的特性,我们将在下一章讨论模运算时进一步探讨。

在本书的前几章,我们将最关注计数数字,也就是正整数。当我们谈论数字而不加具体说明时,这就是默认的解释。但到目前为止,我们仅仅考虑了数字在数字系统中是否被包含。还有一个需要考虑的因素:这些数字是如何表示的。

十进制?二进制?你来选择!

一个基数表示系统决定了数字如何分组以便于计数,以及需要多少符号来表示这些数字。也许是因为我们有 10 根手指,人类通常使用十进制来思考数字,这是一种使用 10 为一组的基数系统。我们从一一数起,并为每个新数字使用不同的符号,从 1 到 9。当我们数完手指后,我们会将手指分成两组十根手指,并按十进制数数。当分组后的十位数没有剩余时,我们使用符号 0,表示握紧的拳头,来表示这一点。因此,我们理解 34 为 3 个十和 4 个一,60 为 6 个十和 0 个一。

我们可以扩展按 10 分组的模式,用相对较少的数字来表示越来越大的数字,引入第三位数字表示 10 个十(100),第四位表示 10 个百(1,000),依此类推。使用指数可以帮助我们表示重复的乘法。例如:

Image

我们总是可以选择使用展开记法来整理分组。这里有一个例子:

Image

由于十进制系统基于 10 的幂,它也被称为十进制。但尽管人类偏爱十进制,其他分组方式也是可能的。例如,如果你只想用一只手的手指来计数,你可以按五个一组,这样就是五进制系统。五进制计数只需要符号 0、1、2、3 和 4。数字五写作 10,六写作 11,依此类推。

鸡蛋和甜甜圈是按打出售的,每打为 12 个,我们甚至有一个词来表示一打打:gross(一打打为 144 个)。12 进制计数法使用符号 0, 1, 2, 3, 4, 5, 6, 7, 8, 和 9,和通常一样,但它需要两个额外的单字符符号来表示数字 10 和 11。通常我们使用 T 和 E。如果我们需要指定进制,可以用下标来表示,比如 15[10]表示 15 是十进制。这使得比较不同进制系统中的数字更容易。例如,15[10] = 13[12] = 30[5],也就是说(1 ⋅ 10)+ 5 = (1 ⋅ 12)+ 3 = (3 ⋅ 5)+ 0。

大多数计算机使用二进制(base 2)系统来内部表示数字。这个系统的优点是它只需要两个符号,0 和 1。这很重要,因为 0 和 1 很容易通过开关的位置来跟踪:开关要么是关闭(0),要么是开启(1)。只有两个符号在表示逻辑时也很有用,其中两个可能性可以表示。一个缺点是,2 的幂(1, 2, 4, 8, 16,……)的增长速度比 10 的幂(1, 10, 100, 1,000,……)慢,因此,通常需要更多的数字来表示一个数字的二进制形式,而表示同一个数字的十进制则需要较少的数字。我们稍后会进一步讨论这个问题。

项目 1:77 的二进制是多少?

对于给定的正整数,它有一种独特的方式表示为十进制(base 10),它也有唯一的二进制表示。在这个项目中,我们将编写一个 Scratch 程序,将十进制转换为二进制,这样我们就能看到计算机在表示一个数字时所看到的样子。

我们可以用两种方式来解决这个问题:我们可以称之为从大到小从小到大的策略。根据从大到小策略,我们首先找到十进制数中包含的最大 2 的幂,以确定二进制表示的最左边数字。然后我们减去该 2 的幂,并找到差值中的最大 2 的幂。我们不断重复这个过程,从左到右生成二进制表示。举个例子:

Image

从大到小策略与大多数人在头脑中进行十进制到二进制转换时的方式相匹配,但从小到大的策略在计算机中编程要容易得多。我们不需要去寻找数字中包含的最大 2 的幂,只需编写一系列的除以 2 的操作,并记录下余数。这将从右到左构建二进制表示。图 1-1 展示了一个使用从小到大的方法的 Scratch 程序。

Image

图 1-1:将十进制(base 10)数转换为二进制(base 2)的程序

我们在 string 变量中构建数字的二进制版本,程序开始时该变量为空。(string 是字符的序列;有关详细信息,请参见“破解代码”部分中的 第 5 页。)首先,我们使用 ask and wait 块让 Scratch Cat 提示用户输入一个十进制数字,并将用户的 answer 存储在 n 变量中。然后,我们进入一个 repeat until 循环 ❶,在这里实现真正的逻辑。

n mod 2 返回将 n 除以 2 的余数,如果 n 是奇数则为 1,如果是偶数则为 0。每次通过循环时,这个 mod 操作会给我们数字的一个二进制位,从最不重要的(最右边的)位开始。我们使用 join 块将该数字与 string 变量中已有的内容合并,然后将结果放回 string。接着我们将 n 除以 2,并使用 floor 函数将结果向下取整到最接近的整数。这将移除我们刚刚处理过的二进制位的值。然后,循环可以重新开始,找到下一个二进制位。

一旦 n 减少到 0,我们就从右到左构建了该数字的完整二进制表示。然后,我们使用更多的 join 块将其组合成一个有意义的句子,报告结果,Scratch Cat 会通过 say 块宣布这个结果。

结果

运行程序并尝试在 Scratch Cat 提示输入一个数字时输入 77。你应该会得到 1001101 作为结果,正如 图 1-2 中所示。

图片

图 1-2:将 77[10] 转换为二进制

由于 Scratch 的工作方式,你会在本书中看到很多将程序执行过程中的不同点合并成一张图像的示例,就像这个图一样。在这个例子中,你可以看到 Scratch Cat 的对话框来自 ask and waitsay 块,以及输入十进制数字的框。当你自己做这些实验时,执行流程会很清楚,但在查看书中的图示时,你可能需要稍微解释一下每个步骤发生的时机。

破解代码

该程序将十进制到二进制的转换视为一系列的除以 2 操作。你也可以从反向思考,将其看作一系列的乘以 2 操作,每遇到二进制表示中的 1 就加 1,每遇到 0 就加 0。例如,我们可以通过以下方式数学表示转换 77:

图片

你可以看到二进制表示中的数字是红色的。以这种方式堆叠乘法,使得每个二进制数字都与其相应的 2 的幂匹配。

另一个需要了解的关于这个程序的重要点是,虽然 Scratch 报告的答案看起来像一个数字,但实际上它是一个字符串。正如我之前提到的,字符串只是一个字符列表。通常这些字符是字母组成的,用来形成像HelloTell me a number这样的消息,但在这种情况下,这些字符恰好是 0 和 1。所以,即使结果看起来像是由二进制数字构成的一个数字,Scratch 并不知道1001101是一个具有十进制值 77 的二进制数字。

我们必须使用字符串,因为 Scratch 没有内建的方法直接处理二进制数字。如果我们想让 Scratch 对基数 2 的数字进行二进制运算,我们就需要编写一个自定义程序来教它怎么做。这是本书中很多需要将数字当作字符串处理的情况之一,目的是“欺骗”Scratch 执行我们想要的操作。

Image 编程挑战

1.1 编写一个程序,提示输入一个基数b,然后提示输入一个十进制数字n,最后返回该数字n在基数b下的表示。你可以将基数b限制为 2 到 10 之间,或者继续使用数字 E 和 T 来允许基数为 11 或 12。

1.2 一个常见的与计算机相关的进制是 16 进制,十六进制,它通常使用额外的符号 A、B、C、D、E 和 F 来表示 10、11、12、13、14 和 15。扩展你的进制转换器,给出十六进制表示。看看你能否找到二进制和十六进制之间转换的技巧。

项目 2:1001101 的十进制是多少?

现在让我们尝试从二进制表示转换回十进制。图 1-3 展示了一个实现这一功能的 Scratch 程序。

我们首先通过ask and wait模块请求用户输入一个表示二进制值的字符串。然后,我们使用repeat循环和index变量一次查看字符串中的一个字符,从左到右,通过letter index of answer访问当前字符。变量n0开始。对于二进制表示中的每个数字,n会翻倍,然后将当前数字(01)的值加到n中。当没有更多的二进制数字时,n就保存了该数字的十进制表示。

Image

图 1-3:将二进制(基数 2)数字转换为十进制(基数 10)的程序

结果

我们知道 77 的二进制表示是 1001101。尝试运行程序并输入 1001101,看看它是否返回 77。图 1-4 显示了结果。

Image

图 1-4:将 1001101[2]转换为十进制

我们已经在图 1-2 中将 77 转换为二进制,并在图 1-4 中再转换回十进制。但是,77到底是什么?无论我们是用二进制还是十进制表示,77 代表的数量是相同的。我们选择如何表示一个数字可能会揭示出它的一些有趣的特性(例如,如果一个十进制数以 0 结尾,那么这个数字一定能被 10 整除;如果一个二进制数以 0 结尾,那么这个数字必须能被 2 整除),但它并不会改变数字的实际值。

破解代码

我们的二进制转十进制程序的一个问题是,它没有检查输入是否为二进制数字串。如果你输入的不是二进制表示的数字,Scratch Cat 很高兴地告诉你一些无意义的内容,正如图 1-5 中的例子所示。

图片

图 1-5:二进制转十进制转换器的三个输出。没有任何东西能阻止你输入非二进制数字!

我们可以通过包含一个自定义的Screen string块来检查输入,从而修复这个问题。这个块在图 1-6 中显示,它确保输入格式正确:一个仅包含 0 和 1 的字符串。

图片

图 1-6:确保只允许二进制数字

该块将逻辑(布尔,即真/假)变量binary string?设置为true,只要用户没有输入其他字符(例如空格、字母或大于 1 的数字)。否则,它将binary string?设置为false。现在,我们可以使用这个块与if...else语句结合,以确保不会得到任何荒谬、不正确的答案,正如图 1-7 所示。Scratch 用于布尔测试的运算符块都是绿色六边形,可以插入到控制块中的测试条件里。

图片

图 1-7:更加小心的二进制转十进制转换器

在这里,我们已经将原始的二进制转十进制代码移到if...else块的if分支中,这样只有在binary string?变量为true时,它才会运行。否则,Scratch Cat 会告知用户输入的不是二进制数字。

你可能会失望地发现,现在程序的长度比最初稍长。Scratch 让编写非常紧凑的程序变得容易,甚至通过告诉你编程窗口中有多少个积木来鼓励这种方式。但有时,即使这会让你的程序变得稍长一些,还是小心为好。如果某些内容可能会被误解或在输入时出错,总会有人在某些时候犯这样的错误,因此还是谨慎为好!一些编程语言有专门的命令来拦截错误并将其引导到某个无害的地方(语法通常涉及 trycatch 关键字),但 Scratch 让你自己预见问题并加以防范,就像我们用 Screen string 积木所做的那样。

Image 编程挑战

1.3 有时新闻报道中提到的 指数增长 是一个通用术语,用来表示快速增长。指数增长在数学中有特定的含义,指的是在数字序列中,从一个值到下一个值的进展是通过乘法并使用一个固定的因子来完成的。例如,每个数字可能是前一个数字的两倍。另一种选择是 线性增长,其中变化是通过 上一个固定增量来完成的,比如每个数字比前一个大两个。

编写一些 Scratch 代码,提示用户输入一个乘数或增量,并返回一个按指数或线性增长的数字序列。比较增长:如果用户输入一个小于 1 的数字用于指数增长会发生什么?如果用户输入一个小于 0 的数字用于线性增长会发生什么?也许这是一个需要输入筛选的情况。

计算机如何表示数字

计算机可以以不同的方式内部表示数字,但它们都涉及某种妥协。之前我提到过,人类将整数和实数等数字系统视为无限的。然而,计算机并不是为了处理无限的集合而构建的。它们必须在硬件架构或所运行编程语言的逻辑结构的限制内工作。

在硬件方面,中央处理单元(CPU)通常有 寄存器,这是 CPU 中可以一次性存储和操作一定数量的二进制数字或 的区域。编程语言被设计为为每个数字分配固定数量的位,因此语言设计者需要做出决定,确定这些位究竟代表什么(例如,某些位是否代表基数,其他位代表指数)。他们还需要做出有关如何表示正数或负数以及如果一个数字超出了可用空间应该怎么做的决定。计算是否应该在出现警告或错误时停止,还是应该在没有提示的情况下重新循环,可能会导致意外和不正确的结果?

最终,我们无法在计算机有限的可能空间中容纳无限多个数字,因此必须舍弃“多数”数字。这意味着计算机语言的开发者需要决定哪些数字以及哪种类型的数字足够有趣或重要,值得被包括在内。有些人需要表示微小的数字,比如亚原子粒子的大小,而另一些人则需要表示巨大的数字,比如宇宙的大小,还有一些则需要表示介于两者之间的数字。不同的语言可能会设计来满足其中的一些需求,但不是所有需求。

除了数字的大小或小的问题外,语言设计者还可能想要考虑数字的使用方式。例如,有时数字用于计数,回答“多少?”的问题。在这种情况下,答案通常是一个整数。(这类问题出现得相当频繁,以至于许多编程语言为整数提供了独立的表示系统,区别于其他类型的数字。)另一些时候,数字用于测量,回答“多少?”的问题。对此类问题的回答不太可能是整数。语言设计者需要一种表示这些“介于两者之间”的数字的方法,并且他们必须决定到底要表示多大的“介于两者之间”。

浮点数的要点

浮点表示法通过包括小于 1 的基数幂来表示“介于两者之间”的数字。在十进制中,有一个小数点,小数点右侧的数字表示一个介于 0 和 1 之间的数字,结合了 1/10、1/100 等的幂。例如,π的近似值 3.14 表示的是一个稍微大于 3 的数字:

Image

如果你需要更精确的近似值,可以再加上几个数字:

Image

同样的原理适用于二进制浮点表示法,其中位于二进制点(即小数点的二进制等价物)右侧的数字表示 1/2 的幂。你可能已经熟悉使用二进制小数表示数字——即使你没有意识到——如果你曾经用尺子或卷尺测量过长度(见图 1-8)。

Image

图 1-8:卷尺上的二进制小数

英寸被细分为二分之一(1/2)、四分之一(1/4)、八分之一(1/8)、十六分之一(1/16)等。这些是二进制小数,因为每个分母都是 2 的幂。例如,你可以通过先走过 1,再走过 1/2,接着是 1/4,再是 1/16,最后写作 1 13/16,并用二进制表示为 1.1101 来表示 1 13/16 英寸。

二进制小数为我们提供了一种表示非整数的方式,但应该使用多少比特来表示一个数字,以及如何解释这些比特呢?任何发明计算机语言的人都可以制定自己的规则,但最好有一个大家都同意使用的标准,这样在从一种语言切换到另一种语言时就不会产生混淆。其中一个标准就是 IEEE 浮点数运算标准,或称 IEEE 754。Scratch 的开发者选择使用这个标准来表示所有数字,甚至是整数,而一些语言则将 IEEE 754 用于浮点数,采用不同的标准表示整数。

精度翻倍,乐趣翻倍

IEEE 754 使用双精度,意味着二进制中的浮点数将占用 64 个比特(与单精度不同,后者每个数字只允许 32 个比特)。图 1-9 显示了 64 个比特是如何分配的。

图片

图 1-9:IEEE 754 标准中比特是如何分配的

第一个比特,如图中所示的青色部分,用于表示符号:0 或 1 代表正负。剩下的 64 – 1 – 11 = 52 个比特,如图中橙色部分所示,表示一个数字,通常是一个介于 1 和 2 之间的值,称为尾数。由于介于 1 和 2 之间的二进制数总是以 1 开始,然后是二进制点和其他数字,因此我们可以通过不显式写出初始的 1 来节省一个二进制位。所以,实际上我们有 53 位精度,而不是 52 位。符号位之后的 11 个比特,图中的紫色部分,表示指数,指定用于乘以尾数的 2 的幂。有时,指数也称为特征。拥有 11 个位数的特征给出了 2¹¹个可能的指数范围,这些指数从 2^(-1,023)到 2^(1,024)不等,但最顶端和最底端的数字被保留供特殊用途。

注意

要实际了解 IEEE 754 是如何工作的,可以使用一些在线交互工具,逐位(字面意义上)修改浮点数,看看发生了什么。一些示例包括 float.exposed evanw.github.io/float-toy/

请记住,二进制中 53 位的精度并不等同于十进制中 53 位的精度。例如,2¹⁰ = 1,024,大约等同于 10³ = 1,000。这表明,10 个二进制数字传达的信息量大约相当于 3 个十进制数字,因此 53 个二进制数字可以承载大约 16 或 17 个十进制数字的信息量。这仍然是非常大的——远远超过你在计算器屏幕上看到的数字——但它远非无限。

在本书中,我们主要关注整数,因此 16 位十进制数字的精度意味着一个 16 位数字,处于低四千万亿范围内,是 Scratch 能够保证精确表示的最大数字。当我们在未来的章节中测试数字的属性,如可除性时,我们需要确保所有数字的每一位都能可靠地表示,以便获得正确的结果。我们将在下一个项目中探索这一限制。

项目 3:2⁵³ + 1 = ?

一些语言为可以精确表示的最大整数在浮点表示法中起了一个特殊的名字,flintmax,是 floating-point integer maximum 的缩写。在 Scratch 中,flintmax 的值是:

Image

图 1-10 展示了一个小程序,说明了当你尝试处理大于 flintmax 的数字时,算术运算是如何出错的。在运行程序时,注意观察变量,看看问题是在哪里出现的。

Image

图 1-10:大于 flintmax 时,整数运算不可靠。

从 flintmax 中减去 1 是有效的,但将 1 加到 flintmax 上并没有得到预期的结果。变量 flintmax+1 的值仍然是 flintmax 本身。你必须加 2 才能改变 flintmax 并得到正确的答案。

破解代码

测试极限并看看计算机语言或其他系统在哪里出问题总是很有趣。例如,对视频游戏感兴趣的人会花大量时间寻找漏洞和破坏游戏基础模型的位置,或者让游戏中的物体表现得很怪异。看到事情出错并进行实验,尝试弄明白计算机是如何处理这些问题的,成为其中的一部分乐趣。

在这种情况下,我们的程序显示,当 Scratch 中的计算超出 flintmax 时,结果是可疑的,可能不对应于精确的整数算术。我们在设计程序以探索 Scratch 中的整数运算时需要记住这一点。不过,只要数字,包括中间结果,保持在 flintmax 之下,结果将是精确的。例如,你可以编写一个 Scratch 程序,从 1 开始计数,直到 flintmax,你将得到所有整数值,直到没有缺失的整数值。

我们的程序还显示,IEEE 754 可以正确表示某些大于 flintmax 的整数,例如 flintmax + 2。事实上,它可以精确表示大于 flintmax 的偶数(2 的倍数),但在一段时间后,它会失去一位二进制精度,从那时起,它只会精确表示 4 的倍数。你可以尝试扩展 图 1-10 中的程序来说明这一点。Scratch 能正确区分 nn + 2 的最大整数 n 是多少?这个值与 flintmax 相比如何?同样,nn + 4 之间有差异的最大整数 n 是多少?

在 Scratch 中,算术运算可能会出错的另一种方式是,当计算结果不匹配任何数值时。例如,当你尝试除以 0 时,Scratch 会报告 Infinity,如 图 1-11 所示。

图片

图 1-11:有时答案是 无限大。

但如果你尝试从 Infinity 中减去 Infinity,会发生什么呢?在 图 1-12 中报告的答案是 NaN,这意味着 不是一个数字

图片

图 1-12:有时答案不是一个数字。

在我们的某些程序输出中,我们会遇到这些特殊值:InfinityNaN

图片 编程挑战

1.4 Scratch 能表示的最大数字是多少,无论是整数还是非整数?当超过这个数字时会发生什么?

1.5 尝试使用 Scratch 创建一个浮点模拟器,就像在 第 12 页 中提到的那些。你应该能够查看一个由 0 和 1 组成的 64 位字符串,并看到与之相关的浮点数,然后改变这些位,看看数字如何变化。

项目 4:百万位数字?

在这个项目中,我们将通过编程技巧让 Scratch 进行精确的整数运算,计算出比 flintmax 提供的精度更多的位数。为了绕过 IEEE 754 标准对数字表示的限制,我们必须编写自己的大数字表示系统。我们这里有几种不同的选择。例如,我们可以将十进制数字一个一个地存储在列表中,这样唯一的限制就是 Scratch 最大的列表长度 200,000。如果我们每次存储五个数字作为列表项,我们就能达到一百万个数字!

另一种选择是将数字存储为字符串。字符串可以非常长,甚至有数百万个字符。然而,Scratch 并没有提供对字符串进行算术运算的内置操作,因此如果我们想处理以字符串表示的数字,就必须自己编写算术运算操作。

图 1-13 显示了一个程序的示例,它可以可靠地对超出 flintmax 范围的数字进行计算。该程序提示输入指数 n,然后显示 2^(n) 的所有数字,既以数字列表的形式,也以将数字连接起来构建的字符串形式。

图片

图 1-13:使用扩展精度计算 2 的幂

该程序从右到左构建 2^(n) 的数字列表。该列表(称为 Digits)以数字 1 开始,即 2⁰。然后,我们重复此过程,进行 n 次倍增,通过逐步遍历列表中的每个数字(使用 i 变量)并将其加倍 ❶ 来计算下一个更高的 2 的幂。

要解决这个问题,可以考虑加法是如何进行的。特别是,考虑到你被教导如何从右到左加多位数,并跟踪进位。例如,如果你要计算 24 + 18,你会从个位数开始,因此你会计算 4 + 8 = 12,写下 2,并进位 1。然后,你会查看十位数;你会计算 2 + 1 = 3,加上进位得到 4,然后报告答案为 42。第一步中进位的 1 实际上是 10,因此当我们跟踪最左侧的 10 的组时,它作为 1 ⋅ 10 来计算。

图 1-13 中的第二个嵌套重复循环➋实现了这个进位逻辑,确保列表中的每个数字只有一个数字。计算floor of item i of Digits / 10如果当前数字有两位数则给出1。我们将该1添加到列表中的下一个项目(项目i + 1)以执行进位,然后使用item i of Digits mod 10将当前数字限制为个位数。在所有这些之前,我们在列表的末尾添加一个0,以防最后一个项目需要进位操作。程序接近结束时的if...then语句会在不需要时去除该0

破解代码

如果能看到报告的答案呈现为一个数字,而不是逐个列出每个数字,那就太好了。我们可以通过在图 1-14 中展示的自定义To string模块来实现这一点。

Image

图 1-14:将数字列表合并成字符串

这个模块将列表中的项目连接成一个字符串,从右到左构建它,因此答案以更易读的方式展示出来。

结果

尝试在比 Scratch 通常能够表示的更大的数字上测试程序。例如,图 1-15 展示了计算 2¹⁰⁶的程序运行结果,这就是 flintmax 的平方。

Image

图 1-15:精确计算 flintmax 的平方

所有 32 位数字都正确报告(Scratch 完成后,你可以滚动查看所有数字)。请注意,Digits列出了从个位开始的数字。

Image 编程挑战

1.6 修改图 1-13 中的 2 的幂代码来计算 3⁵³。

1.7 修改图 1-13 中的 2 的幂代码,使其能够处理介于 0 和 99,999 之间的“数字”。这样,每个列表项将提供计算出的幂的五个数字,使得 Scratch 可以保存最多一百万个数字。

1.8 编写一个扩展精度的加法程序,在其中,Scratch Cat 提示输入两个作为字符串输入的大数字,将这些数字解析为数字列表,然后使用与图 1-13 中程序相同的技巧进行加法。尝试将代码扩展以处理乘法。

结论

如果我们理解了 Scratch 是如何跟踪数字的,就能确保避免通过请求超过 Scratch 能提供的数字来生成错误。这在整数运算中特别重要,因为我们需要数字的所有位数来正确处理关于可除性和计数的问题。

Scratch 的内部数字表示与许多现代编程语言一致,因此本章中的信息具有广泛的适用性。一旦我们了解了这些限制,就可以想出绕过它们的方法,从程序中获取更多的信息,而这些信息通常是语言无法提供的。这才是最棒的技巧!

第二章:## 探索可除性与质数

Image

你可以对任意两个整数进行加法、减法或乘法运算,结果仍然是一个整数。但当你将一个整数除以另一个整数时,结果不一定是整数。值得注意的是,当除法的结果一个整数时,这是一个特殊的情况。同样值得注意的是那些无法被除以除了 1 和它本身以外任何数的稀有情况。我们称这些为质数

在本章中,我们将研究这两种有趣的现象。这些概念是数论的基础,数论是研究整数的性质和数学的学科。数论被广泛应用于从计算机游戏和模拟中的随机数生成,到数据传输和存储中的错误纠正码设计等各个领域。这些现实世界的应用都始于可除性和质数。

可除性因子

我们说整数d是整数n约数,如果n / d的结果是一个整数。我们也可以用乘法来表达:如果我们能找到一个整数k,使得n = dk,则数字n能被数字d整除。另一种说法是,dn因子

以下是关于可除性的几个事实、观察和词汇:

Image 每个数字都能被 1 整除,因为我们可以写成n = n ⋅ 1。

Image 每个数字n都是它自己的约数(或因子)。如果我们不想将n包括在约数列表中,我们可以指定其他约数为真约数

Image 整数是偶数还是奇数,取决于它是否能被 2 整除。

Image 每个能被 5 整除的整数,最后一位数字一定是 0 或 5。

Image 每个能被 10 整除的整数,最后一位数字是 0。

Image 6 的正约数集合是{1, 2, 3, 6}。数字 6 被认为是完美数,因为它的所有真约数 1 + 2 + 3 的和等于 6 本身。

Image 编程挑战

2.1 Fizz-Buzz 是一个可以由任何数量的玩家围成一圈玩的小游戏。玩家轮流从 1 开始计数,但如果他们要说的数字能被 5 整除,玩家就说“Fizz”而不是数字。如果数字能被 7 整除,玩家就说“Buzz”。如果数字能同时被 5 和 7 整除,玩家就说“Fizz Buzz”。如果玩家说错了,便被淘汰,最后剩下的玩家获胜。写一个程序,让 Scratch Cat 和你一起玩 Fizz-Buzz。

模拟算术

尽管将一个整数除以另一个整数不一定会得到另一个整数,模运算却为我们提供了一种用整数表示任何除法运算的方法。模除法问题的答案通常以两个整数的形式呈现:本身,去掉任何小数部分,和一个额外的部分叫做余数。从符号上讲,我们说整数b除以正整数a得到一个商q和一个余数r,其中 0 ≤ r < a。这一关系由公式b = (qa) + r 给出。

除法是给定ba后,确定商和余数的过程。除法算法用来识别商和余数。Scratch 有一个内建的操作来捕获余数r,叫做mod。为了找到商q,我们使用/运算符进行除法,并通过使用floor操作来指示我们只保留结果的整数部分。图 2-1 给出了一个示例。

Image

图 2-1:计算 45/7 的商和余数

在这里,floor of 45 / 7给我们一个商6,而45 mod 7给我们一个余数3。为了验证这是正确的,我们可以将结果代入我们的公式中:

Image

我们说两个数字xy对模n同余的,如果x mod n = y mod n。在这种情况下,当xy分别除以n时,它们有相同的余数r。例如,7 和 19 对模 6 同余,因为 7 和 19 除以 6 都得到余数 1。同余的概念没有等于那么严格,等于的数字必须是同余的,但同余的数字不一定相等。我们使用三条横线符号(≡)表示同余,而不是等号(=),所以我们写作 7 ≡ 19 mod 6。

这里有一些将模运算与整除性联系起来的事实:

Image 我们可以通过在 Scratch 中查看b mod a是否为 0 来测试b是否能被a整除。

Image 奇数对模 2 同余 1,偶数对模 2 同余 0。

Image 以 0 结尾的数字对模 10 同余。它们也能被 10 整除。

Image 以 0 或 5 结尾的数字对模 5 同余。它们也能被 5 整除。

Image 当我们用除法算法表示b = (qa) + r时,所有可能的余数的集合是{0, 1, 2, . . . , a – 1},这是一个包含a个元素的集合。有时,使用另一个包含a个元素的集合更有用,其中每个整数都与该集合中的一个元素同余。由于 Scratch 中的列表元素从 1 开始编号,集合{1, 2, 3, . . . , a}通常是一个不错的选择。

在下一个项目中,我们将探索一个简单的技巧,使用模运算帮助检查计算结果。

项目 5:检查数学结果的小技巧

除九求余是一种验证大数加法或乘法答案的技巧。为了理解它是如何工作的,首先要注意到每一个 10 的幂数在除以 9 时的余数都是 1。例如:

图片

这指出了一个更广泛的规则:当你将一个数字n除以 9 时,你得到的余数和将n的各位数字之和除以 9 得到的余数是相同的。例如,347 除以 9。为了确定余数,首先我们将各位数字相加:3 + 4 + 7 = 14。此时,我们可以注意到 14 = (1 ⋅ 9) + 5,从而得到余数为 5。或者,我们可以再一次使用除九求余技巧,以更简单的方式得到结果:1 + 4 = 5。(事实上,347 除以 9 的结果是 38 余 5。)

除九求余是检查大加法或乘法计算是否正确的好方法,因为做模 9 的算术(通过数字之和)比追踪多位数的加法和乘法更容易。例如,假设你计算了 347 + 264 并得到了 601 的答案。我们已经看到 347 模 9 的结果是 5。对于 264,2 + 6 + 4 = 12,1 + 2 = 3,所以 264 模 9 的结果是 3。这意味着(347 + 264)模 9 应该是 5 + 3 = 8。但是,601 模 9 的结果是 6 + 0 + 1 = 7,显然哪里出了问题。看起来是原始加法时有人忘记进位了!当我们将和修正为 611 时,除九求余结果符合预期。

尽管将一个数字的各个数字相加是相对简单的心算,我们还是让 Scratch Cat 来为我们完成这项工作。图 2-2 中的程序使用除九求余技巧来计算任何数字的模 9。

图片

图 2-2:一个通过计算数字之和来找出x mod 9的程序

这个技巧是让 Scratch 把数字x视为一串数字。length of操作符报告数字的位数,letter of操作符允许我们逐个取出数字进行相加。代码嵌套在一个repeat until循环中,直到x的长度为1时才停止,这意味着数字只有一位。如果这个单一的数字在 0 到 8 的范围内,我们就得到了答案。不过,这个单一数字也可能是 9,这在模 9 运算中等同于 0。在这种情况下,最后的if语句选择 0 作为报告的答案,而不是 9。

结果

图 2-3 显示了程序的一个示例运行,输入为 601。

图片

图 2-3:计算 601 模 9

程序的最后一行使用join操作使输出更加美观,提醒我们输入的数字以及除九求余过程的结果。

破解代码

我们在这里遇到的问题与第一章中遇到的问题相同:Scratch 很乐意在非数字输入上运行代码。按照程序的写法,它甚至会对本应完全允许的输入(如负整数)出现问题。例如,数字 -3 被当作字符串解释时,长度为 2,而根据 Scratch 的规定,第一个字符即负号的数值为 0。因此,Scratch 报告说 -3 的各位数字之和是 0 + 3 = 3。但问题是,-3 除以 9 的余数是 6,而不是 3。

由于我们将遇到负整数和非整数输入的问题,因此在将代码发布供一般使用之前,我们应该通过筛选输入确保安全,只允许我们想要的输入:正整数。我们可以创建一个自定义块来筛选输入,如图 2-4 所示。

Image

图 2-4:确保输入是正整数

布尔语句 round test = test 是一种技巧,它让我们一举多得。它筛选掉非数字输入(例如单词 banana),因为在 Scratch 中尝试对非数字进行四舍五入会得到 0 作为结果。它还会筛选掉带有非零小数部分的数字,这些数字四舍五入后将不再等于它们自己。结合 text > 0,我们的 if 语句会在输入 test 是正整数时为真,否则为假。因此,如果满足这两个条件,我们可以将变量 positive integer? 的值设置为 true

注意

某些编程语言有特殊的布尔变量,只能取值为 true false,但 Scratch 并没有。在这里,我们简单地使用 true false 这两个词。某些程序员更喜欢使用数字值 1 和 0 来表示真假值。

一旦我们有了筛选块,就可以修改图 2-2 中的代码,使其仅对适当的值执行,如图 2-5 所示。将 repeat until 块后面的原始程序粘贴到 if 之后的空白位置。

Image

图 2-5:别让 Scratch Cat 犯错误!

当然,你实际上不必经过去除九的过程来进行此计算。你可以直接使用 Scratch 的 mod 块!不过,编写程序对你来说是解决问题的好练习,能够帮助你逐位分析数字。该程序同样可以推广到其他情况,比如挑战 2.2 中的情况。

Image 编程挑战

2.2 去九法可以测试一个数字是否能被 9 整除,因为如果数字的各位数字之和为 0 或 9,那么该数字就能被 9 整除。测试一个数字是否能被 11 整除的方式类似,不同之处在于,计算时不是将所有数字相加,而是交替地加和减。例如,1,342 可以被 11 整除,因为 1 – 3 + 4 – 2 = 0。编写 Scratch 程序计算给定数字的加减位数之和,看看它是否能被 11 整除。

2.3 Scratch 有一个运算符,允许你从指定范围内随机选择一个数字。编写一个程序,选择 1 到 100 之间的 10 个随机数字。预测其中有多少个能被 9 整除,然后使用 Scratch 检查你的预测是否正确。

2.4 有时,当你需要在计算机表单中输入一个数字(例如信用卡号码或书籍的 ISBN 代码)时,数字中会包含一个 校验位 来确保你没有输入错误。一种实现方式是,在数字的末尾加上一个额外的数字,这个数字是从原始数字派生出来的。例如,额外的数字可以是原始数字 mod 9,通过像 图 2-2 中的程序那样使用去九法计算得出。扩展此程序,给出包含校验位的原始数字。

2.5 在复制数字时,我们有时会犯 位数交换 错误,即交换两个数字的位置。例如,我们可能将 1,467 错写为 1,647。你能否使用“去九法”来帮助发现这种错误?

质数

一些整数有很多除数,而一些只有少数几个。整数 1 是一个特殊的情况,它只能被自身整除。对于任何其他数字,最少的除数数量是两个:1 和该数字本身。正如本章开始所提到的,只有两个除数的数字被称为质数。除数超过两个的数字被称为 合成数

前几个质数是 2、3、5、7、11、13 和 17。为了找到更多的质数,我们将使用 Scratch。

项目 6:它是质数吗?

确定一个数字是否为质数的一种方法是逐一尝试可能的因子,这个过程叫做 试除法。如果 1 和该数字之间没有其他除数,那么该数字就是质数。例如,对于数字 5,我们会先尝试将 5 除以 2,再除以 3,再除以 4。没有任何一个数字能整除 5,所以 5 是质数。

手动进行试除法很快就会变得乏味,因此我们将编写一个程序,让 Scratch 为我们完成这项工作。图 2-6 显示了一个简单版本的代码,它并未考虑可能导致错误答案的不当输入(例如,字符串或不是正整数的数字)。

图片

图 2-6:通过试除法检查质数

代码提示输入一个待测试的数字,并通过与布尔变量 prime? 配合使用来决定该数字是否为素数。我们在 repeat until 循环 ❶ 中执行试除法,通过计算 answer mod trial ➋,其中变量 trial 是试除数。如果结果为 0,我们知道已经找到一个除数,且 answer 不是素数,因此退出循环。否则,我们将 1 加到 trial 中并重新尝试。最后,我们根据 prime? 的值是 true 还是 false 来报告结果。

结果

图 2-7 显示了试除法程序的示例运行。

Image

图 2-7:试除法程序的示例运行

该程序正确识别了 29 是素数,30 不是素数。

破解代码

我们应该筛选输入,以便 Scratch 仅考虑正整数。像我们为“去九程序”创建的自定义块(见图 2-4)可以使用,并将其放入一个if语句中(如图 2-5 所示)。不过,筛选代码中还有一些额外的条件。首先,整数 1 既不是素数也不是合成数,但 1 会在我们的试除法程序中的repeat until循环中存活并被标记为素数。图 2-8 中的自定义块包括一个初步的if测试,禁止输入 1。

Image

图 2-8:限制试除法程序的输入

一个更微妙的问题是,正如我们在第一章中看到的,整数运算仅在 flintmax 以内是精确的。这意味着可除性测试仅对最大为 9,007,199,254,740,992 的数字有效。之后,Scratch Cat 会认为所有数字都是合成数!图 2-8 中的检查代码也考虑到了这一点,通过验证test是否小于 flintmax。该代码块还返回一个message变量,当输入无法可靠测试时,程序会报告更多信息。

这个程序的另一个考虑因素是,处理大数字的试除法可能需要很多步骤——如此之多,以至于即使在快速计算机上,你也可能需要等待很长时间才能得到答案。图 2-6 中repeat until循环的测试 ➊ 是一种加速过程的技巧:我们实际上只需要考虑试除数,直到输入数字的平方根。这是因为如果一个数字 n 不是素数,它必须具有一个因式分解 n = ab,而不是简单的因式分解 1 ⋅ n。由于 n = Images,所以 ab 中的一个必须至少与 Image 一样大,另一个则必须小于或等于 Image。我们只需要进行试除法,直到 Image 来找到较小的那个,若它存在的话。

这个技巧带来了巨大的节省!我们可以测试高达 1,000,000 的数字,最多只需Image = 1,000 次试除。为了进一步加速代码,一旦检查完 2 的可整除性,我们就只需要测试奇数的可整除性。这是因为如果一个数字n能被任何偶数整除,它也一定能被 2 整除。

所有这些改进都能缩短运行时间,但也使程序变得更长、更复杂。是否值得做这些权衡取决于谁会使用你的作品,以及用途如何。使程序更易用的改进通常是值得的。而加速运行时间的改进需要显著才能被注意到,但如果用户需要快速的结果,这些改进可能是值得的。

项目 7:埃拉托斯特尼筛法

试除法不是唯一的质数查找方法。在这个项目中,我们将探索另一种技术:查看所有数字列表,直到某个限制,并丢弃那些合数。这种方法筛选出质数,称为埃拉托斯特尼筛法,以首次使用该方法的古希腊数学家命名。Scratch Cat 在图 2-9 中使用了筛法,其中 1 到 120 的数字已被排列成网格。

Image

图 2-9:通过丢弃非质数来筛选质数

首先,我们用红色划掉 1,因为它既不是质数也不是合数。接着,我们用绿色划掉所有 2 的倍数,如图 2-10 左侧所示,看看剩下的是什么。然后,我们继续识别 2 之后的下几个质数(3、5、7),并划掉它们的倍数,如图 2-10 右侧所示。

Image

图 2-10:去除 2 之后的所有偶数(左)和所有 3、5、7 的倍数(右)

注意,2 和 3 的倍数在网格的列中被竖线划掉。这是因为网格被设置为宽度为 6,而 6 能被 2 和 3 同时整除。5 的倍数被粉色划掉,沿对角线向后移动。这是因为要从一个 5 的倍数跳到下一个,我们需要加 5,也就是 6 – 1。因此,要找到下一个 5 的倍数,我们沿着 6 的行向下移动一行,再沿着-1 的列回退一列。同样,要找到 7 的倍数(黄色划掉),我们沿着行向下移动一行,再向右移动一列(因为 7 = 6 + 1),这样我们就得到了沿着另一条对角线的线条。

Image

图 2-11:筛选后到 120 为止的所有质数

这是筛法的回报:如果一个数字n是合数,并且有因式分解 n = ab,其中 1 < ab < n,那么 aImage。在我们的例子中,n = 120,因此网格中的任何合数必须有一个小于 Image 的素因数,大约是 10.95。一旦我们筛到 7,接下来没有被筛去的数字是 11,它大于 Image,因此筛法就不需要继续了。剩下的每个数字,即未被筛去为 2、3、5 或 7 的倍数的数字,必须是素数(见图 2-11)。

这是平方根技巧第二次发挥作用。首先,它使得图 2-6 中的试除法程序运行更快。现在,它告诉我们何时停止筛法,使我们(在此例中)通过筛到 7 就能找到所有小于 120 的素数。

我们可以使用相同的技术将筛法扩展到更高的范围。我们只需在发现每个素数时,去除它的所有倍数,直到平方根为止。这就是我们在图 2-12 的 Scratch 程序中所做的。

Image

图 2-12:筛法程序

我们首先询问筛到多远,然后将primes列表初始化为相应的大小❶。(由于我们使用的是列表,我们的上限受 Scratch 支持的最大列表大小限制,最大为 200,000。)Scratch 的列表索引从 1 开始,因此列表中索引为n的条目将用于跟踪n是否是素数。初始时,我们将每个条目设置为true,但我们会在筛法过程中将非素数条目改为false

首先,我们处理特殊情况 1,1 既不是素数也不是合数➋。然后,我们寻找下一个未被筛去的数字。我们将该数字保持为true,但将它的所有倍数设置为false ➌。我们重复这个过程,直到下一个未被筛去的数字大于限制的平方根。

一旦我们有了完整的列表,就可以访问它并回答关于我们找到的素数的问题。图 2-13 中有一段小代码,用来计算筛法限制以内有多少个素数。

Image

图 2-13:使用筛法计数素数

在这里,我们逐步检查我们构建的列表,并计算其中有多少个true条目,每次增加变量primecount的值。图 2-14 展示了另一段额外的代码,用于列出我们找到的素数。

Image

图 2-14:使用筛法列出素数

这个代码块会找到列表中的true项,并将它们对应的索引号存储到一个单独的列表中。

破解代码

有时,将 Scratch 生成的数据保存为单独的文件是很有用的,这样你就可以将其导入到文本编辑器或电子表格中。幸运的是,Scratch 允许我们通过右键点击图形窗口中的列表来导入和导出列表(见 图 2-15)。通过这种方式,你可以将筛选出的素数列表从 Scratch 中取出,进一步处理。

Image

图 2-15:保存列表以便稍后使用

文本编辑器、文字处理软件和电子表格程序都能很方便地处理 Scratch 输出的文本数据。试着将数据导入 Excel、Numbers 或 Open Office 等电子表格程序。如果你希望每行包含多个条目,确保 Scratch 在文本文件中插入逗号分隔条目(使用 join 块),然后使用 CSV 格式(即 逗号分隔值 格式)导入数据。Scratch 生成的文件中的默认换行符将把条目列在电子表格的不同行中。

Image 编程挑战

2.6 使用筛选程序找出 1 到 10、100、1,000、10,000 和 100,000 之间有多少个素数。记录素数数量与列表大小之间的比例,并以表格形式展示结果。当上限增大时,素数的相对数量是如何变化的?

2.7 编写一个块来扫描筛选程序生成的整数列表,寻找长的连续合数序列。你能找到最长的序列吗?

2.8双生素数是相差恰好为 2 的一对素数;例如,3 和 5 或 11 和 13。编写一个块来扫描筛选程序的输出,并计算在筛选限制范围内有多少对双生素数。

2.9 重写 图 2-12 中的筛选程序,使其将结果以六列的表格显示,像 图 2-9 中的表格一样。使用同余语言解释为什么表格第一行之后出现的唯一素数仅位于第 1 列和第 5 列。

公约数与最大公约数的关系

给定两个整数 ab公约数集合指的是所有能整除 ab 的整数。总是至少有一个公约数,那就是数字 1,因为 1 是所有整数的因子。但也可能存在更大的公约数。特别关注的是 最大公约数(GCD),它是能够整除 ab 的最大数。如果这个最大公约数是 d,我们写作 GCD(a, b) = d

与识别素数一样,求两个数的最大公约数(GCD)有多种方法,它们的效率各不相同。在接下来的两个项目中,我们将探讨两种这种方法。

项目 8:慢速求最大公约数

这是找到两个整数 ab 最大公约数的一种方法。从 1 开始,尝试用每个数字分别除以 ab。如果它能同时整除这两个数,你就找到了一个公约数。一旦你到达 ab,取先到的为止。你找到的最大的公约数就是最大公约数。图 Figure 2-16 中的程序使用了这种方法。

我们使用一个自定义块来识别两个输入值 ab 中的最小值。然后我们从 1 开始向上计数,检查 abmod 是否为 0。如果是,我们将当前的除数存储在变量 gcd 中,程序运行结束时,这个变量保存了答案。

这种测试每个数字作为可能的公约数的方法被称为 暴力破解 方法。就像通过测试每一种可能的字母和数字组合来猜测某人的计算机密码一样。对于我们的 GCD 程序,暴力破解对于较小的 ab 值(例如最大到 100 万)足够快,但对于更大的数字来说,它明显变得更慢。当筛选的数字接近最大值时,等待时间特别烦人。幸运的是,有更好的方法。

Image

图 2-16:使用慢方法查找 GCD

项目 9:快速求最大公约数

古希腊数学家欧几里得在其公元前 300 年左右写成的教材《几何原本》中描述了一种更高效的计算两个数最大公约数的方法。《几何原本》涵盖了多个数学领域的主题,重点是几何学和数论。该书影响深远,欧几里得的材料组织方式在几个世纪内用于教学,直到今天仍然被沿用。

欧几里得计算最大公约数的方法基于这样一个观察:对于两个正整数 ab,其中 a < b,任何 ab 的公约数也是 b - a 的约数。例如,假设 a = 330 且 b = 876。330 和 876 的公约数是 6,6 同时也是 876 - 330 = 546 的约数。

通过扩展,如果我们用较大的数 b 除以较小的数 a,并通过商和余数跟踪除法,即 b = qa + r,那么 ba 的任何公约数也是 ar 的公约数。然后我们可以用 ar 重复这个过程,直到最后的余数为 0。在这个时候,倒数第二个余数就是 ab 的最大公约数。除法序列如下所示:

Image

余数逐渐减少,因此 a > r[1] > r[2] > . . . > r[k],其中 r[k] = GCD(b, a) 且 r[k + 1] = 0。

下面是计算 6 是 b = 876 和 a = 330 的最大公约数的步骤,分别使用除法算法和模算术进行解释。注意,随着我们从一行到下一行,数值的顺序是如何从右到左变化的:

Image

图 2-17 中的 Scratch 程序实现了欧几里得算法。

Image

图 2-17:使用欧几里得算法求最大公约数

程序的组织方式是将所有重复除法的工作放在自定义的 gcd 块 ❶ 中。与我们的蛮力 GCD 程序(图 2-16)相比,这个块的定义异常简短。在一个 repeat until 循环中,我们不断进行 b mod a ➋ 运算,并将 ar 的值交换回 ba,直到最终得到余数为 0\。这时,循环停止,最后的 a 值可以作为 GCD 报告 ➌。

结果

图 2-18 展示了一个示例,运行 GCD 程序,输入为两个非常大的数字。

Image

图 2-18:使用欧几里得算法的计算

与我们的蛮力方法不同,代码运行非常迅速,即使对于接近 flintmax 的数字也是如此。

破解代码

到目前为止,我们谈论算法效率的语言还比较笼统。我们说一个程序运行得快或慢,但最好知道在你的计算机上实际的速度有多快或多慢。看到程序性能随着数字从十位或百位到千位或百万位的变化也会很有用。

Scratch 内置了一个计时器,可以从程序开始执行的那一刻起,按秒计量经过的时间。通过块菜单中的 timer 块可以访问该计时器。我们可以将任何程序包装在几行代码中,来计算算法运行的时间,如 图 2-19 所示。

Image

图 2-19:计时程序运行速度

在这里,initialize 块包含任何你不希望计时的设置代码,比如提示用户输入,而 run code 块则包含你想要计时的算法代码。我们在执行 run code 之前和之后记录 timer 的值,然后取两者之间的差值来查看执行花费了多少时间。

Image

图 2-20:测试一个大素数

图 2-20 展示了将 图 2-6 中的试除法素数测试程序包装在计时器代码中的结果,包括程序结束时 elapsed time 的值。对于一个接近 flintmax 的素数,程序运行结果大约需要一分钟。

对于许多具有小测试值的程序,经过的时间将显示为 0,因为算法运行只需要一小部分时间。报告的时间也可能在不同的运行之间有所不同,因为计算机在后台执行其他任务,这限制了 Scratch 可用的资源。为了获得准确的时间,连续多次运行程序并记录累积的运行时间,然后将其除以运行次数,得到每次运行的平均时间。

图片 编程挑战

2.10 使用计时循环来比较两个最大公约数计算程序的运行时间(图 2-16 中的暴力版本和图 2-17 中的欧几里得版本)。

2.11 编写一个计数器来计算欧几里得算法需要多少步。进行实验,看看哪些数字会使算法需要最多的步骤来运行。

结论

涉及可除性的计算在计算机帮助下要容易和快速得多。如果我必须通过手工试除来判断一个数字是否为素数,我可能在几次计算后就会放弃。即使我在计算器上输入可能的除数,我也会很快感到厌烦,并可能开始犯错(“试错法”大多是错误!)。但 Scratch 猫愿意在我需要时提供帮助。Scratch 是一个望远镜,它让我们能够比自己做得到的更深入地观察数字的世界。我们所需要做的就是提出请求。

第三章:## 通过素因数分解拆分数字

Image

素数就像正整数世界中的化学元素。它们是基本的构建块,可以用来生成其他正整数。

化学科学教会我们,少数物质——元素,构成了世界上的一切。元素的原子结合形成其他物质,叫做化合物,但元素的原子无法分开,否则会失去其物理属性。类似地,我们可以通过乘法将素数结合起来,生成任何我们想要的合成数,而素数无法再被进一步分解,因为素数的唯一因子就是它本身和 1。

在本章中,我们将探讨素因数分解,即识别可以相乘得到给定合成数的素数的过程。我们将考虑如何将一个数字写成素数的乘积,并研究从数字的素因子中可以学到的一些有趣的事情。

算术基本定理

素数的一个重要事实——重要到被称为算术基本定理——是每个合成数都有自己独特的素因子集合。从这个意义上讲,乘法和加法是完全不同的。如果你想通过加法得到 16,方法有很多:2 + 14、5 + 11、3 + 13、5 + 4 + 6 + 1,等等。但如果你想将 16 写成素数的乘积,唯一的方法是 2 ⋅ 2 ⋅ 2 ⋅ 2。同样,唯一通过素数相乘得到 20 的方法是 2 ⋅ 2 ⋅ 5,唯一得到 54,252 的方法是 2 ⋅ 2 ⋅ 3 ⋅ 3 ⋅ 11 ⋅ 137。

这些都是素因数分解的例子:我们已识别出一组独特的素数,可以将它们相乘得到某个合成数。一旦你知道了一个数字的素因子,你就可以了解该数字的因子,以及它如何通过共同的因子或倍数与其他数字相关联。但我们首先如何计算一个数字的素因子呢?

项目 10:它是素因子吗?

在第六章项目中,我们通过从 2 开始逐个整除的方式来判断一个数字是否为素数。我们也可以使用类似的试除法来找出一个数的素因子。我们需要做的就是在除法过程中跟踪已揭示的素因子。图 3-1 展示了这一过程。

Image

图 3-1:将一个数分解为素数因子

该程序通过首先找到较小的质因子来工作。我们使用两个变量:rest,它初始值为待因式分解的数字,以及 factor,它初始值为 2。如果 rest mod factor 等于 0,则说明我们找到一个质因子,于是我们将其存储在名为 factors 的列表中。然后,我们通过将 rest 除以 factor 并将结果重新存储到 rest 中来去除该因子 ➋。接着,循环使用新的 rest 值重新开始。当 rest mod factor 不等于 0 时,我们将 factor 增加 1。就像我们在项目 6 中测试质数时一样,当 rest 达到其平方根时,我们可以停止寻找因子 ➊,因为此时 rest 要么是质数,要么是 1

结果

factors 列表会显示在屏幕上,因此当程序完成时,所有的质因子都会被显示出来(如果列表过长,可能会有滚动)。图 3-2 展示了该程序的一个示例运行。

Image

图 3-2:找到 54,252 的质因子

我们成功地识别了 54,252 的质因式分解为 2 ⋅ 2 ⋅ 3 ⋅ 3 ⋅ 11 ⋅ 137。Scratch 会自动为列表中的元素编号,因此我们可以一眼看出 54,252 有六个质因子。知道一个数有多少个质因子在以后会派上用场。

破解代码

请注意,质数在一个数的质因式分解中可以重复出现。我们可以修改图 3-1 中的代码,通过指数来表示这些重复的因子,因为指数表示重复的乘法。例如,我们可以使用指数将 54,252 的质因式分解表示为以下形式,而不是列出 {2, 2, 3, 3, 11, 137}:

Image

图 3-3 展示了一种做法。

Image

图 3-3:带有指数的因式分解

这个版本的程序增加了两个列表:primes 用于跟踪质因式分解中的每个唯一质数,exps 用于跟踪每个唯一质因子的出现次数(即指数)。程序开始时会清除列表中的所有旧值 ❶。然后,每当找到一个质因子时,我们会检查该因子是否已经出现过 ➋。如果出现过,我们就不把它添加到 primes 列表,而是将 exps 列表中最后一个指数加 1。如果该因子之前没有出现过,我们就将其添加到 primes 中,并在 exps 的末尾添加 1 来表示该因子的指数。

唯一质因子和指数的列表会与所有质因子的原始列表一起显示在屏幕上,后者包括重复因子。图 3-4 展示了一个包含多个不同质因子的示例。

Image

图 3-4:带有指数的因式分解输出

观察primesexps列表的内容,我们可以将 1,036,728 的素因数分解解释为 2³ ⋅ 3² ⋅ 7¹ ⋅ 11² ⋅ 17¹。我们将在本章后面的其他项目中使用这个修改后的程序,届时我们将利用独特的素因数和它们的指数进行各种计算。对于其他程序,当我们不想看到这些列表时,我们可以简单地将它们从 Scratch 舞台上隐藏。

除数的乐趣

到目前为止,我们讨论的是素因数,但我们也可以将它们看作素数除数——即能整除一个数的素数。当我们将一个数的素因数分解格式化为独特的素数及其相关指数的列表时,如在图 3-3 中所示,这个独特素数的列表实际上就是该数的素数除数列表。一旦我们知道一个数的素数除数,我们就可以构建该数的所有除数列表,而不仅仅是素数除数。

这个技巧是通过根据素数的指数以不同组合的方式将素数除数相乘来构建每一个除数。例如,54 的素因数分解是 2¹ ⋅ 3³。这告诉我们,54 的任何除数必须由零或一个因子的 2(2⁰或 2¹)和零到三个因子的 3(3⁰、3¹、3²或 3³)构成。两个选择的 2 的因子与四个选择的 3 的因子结合,总共有八种方式构造 54 的除数:

Image

从这个例子来看,我们可以开始提出一些规则来计算一个数字有多少个除数。希腊字母τ(τ)通常用来表示除数的总数,因此τ(n)表示正整数n的除数个数。首先,以下是一些特殊情况:

Image 素数有恰好两个除数,因此如果p是素数,则τ(p) = 2。

Image 1 的唯一除数是 1,因此τ(1) = 1。

Image 如果p是素数,那么p的正整数次幂p^(n)的除数是 1、pp²、……、p(*n*)。这意味着*τ*(*p*(n)) = n + 1。注意,这对于前面两个特殊情况也适用。对于素数本身,p = p¹,因此τ(p¹) = 1 + 1,给我们两个除数。我们也可以认为τ(1) = 1 等同于τ(p⁰) = 0 + 1 = 1。

Image 如果n是两个不同素数pq的积,那么n的除数是 1、pqpq,所以τ(n) = 4。一个由两个素数相乘得到的数叫做biprime(双素数)。

更广泛地说,我们如何确定任何合成数nτ(n) 呢?首先,我们可以通过运行图 3-3 中的因式分解程序来找到 n 的质因数分解。这将给我们两个列表:一个是能整除 n 的质数列表,另一个是对应的指数列表。对于每个质数,指数告诉我们能整除 n 的该质数的最大可能次方,因此我们可以使用该质数的任意重复次数(从 0 到指数)来构造 n 的因子。由于我们是从 0 开始计数的,所有可能的组合总数是指数加 1。因此,为了得到 τ(n),我们所要做的就是将每个质数的指数加 1,然后将结果相乘。这等同于从每个质因子的所有可能组合和每个指数的所有可能组合中构造因子。

回到我们 54 的例子,已知其质因数分解为 2¹ ⋅ 3³,因此 τ(54) = (1 + 1) ⋅ (3 + 1) = 共有 8 个因子。Scratch Cat 在图 3-5 中同意这一结论。

Image

图 3-5:计算 τ(54)

但 Scratch Cat 是如何得出这个结论的呢?让我们来看看。

项目 11:Tau 多个因子?

我们可以通过在质因数分解程序的末尾添加一些代码来计算 τ(n),即 n 的总因子数量,这些代码使用了图 3-3 中的指数。图 3-6 展示了这段额外的代码。

Image

图 3-6:使用 n 的质因数的指数来计算 n 的所有因子总数

在我们使用图 3-3 中的代码创建指数列表之后,这段额外的代码会从 exps 列表的开头开始,逐步处理这个列表,并将所有因子的总数累计到 divisors 变量中。对于每个指数,我们加 1,然后将结果乘以当前 divisors 的值。每处理一个指数,我们就从列表中删除它 ➊,这样我们始终可以取出列表中的第一个元素。if...else 语句 ➋ 只是为了让 Scratch Cat 的语法正确,因此,如果用户询问数字 1 的因子数量(1 是唯一只有一个因子的正整数),答案是单数。

项目 12:总和到 Sigma

如果我们想要找到一个数字的所有因子之和呢?数字n的因子之和用希腊字母 sigma 表示,记作 σ(n)。我们可以使用与计算因子数量相同的策略,在这个过程中我们一次只跟踪一个质因子及其相关的指数。这是组合数学的一个例子,组合数学是数学的一个分支,有时被称为计数的艺术,它通过分析更简单的情况来计算某事发生的次数。我们将在第七章中更详细地探讨组合推理,但目前来看,它是理解因子算术的一个有用工具。

要计算σ(n),首先考虑对于给定的质数p,该质数的k次方的唯一因子是从p⁰ = 1 到p^(k)本身的p的各次方。例如,3³ = 27 的因子有 1 (3⁰)、3 (3¹)、9 (3²)和 27 (3³)。因此,27 的因子之和为:

Image

这是一个几何数列的例子,几何数列是指每个数字(除第一个外)通过将前一个数字乘以一个常数值(在此情况下为 3)来确定的序列。对于这种序列中数字的总和(或几何级数)有一个计算公式,σ(p^(k))。在求 3⁰到 3³的所有幂的和时,公式为:

Image

请注意,公式中涉及了 3⁴,这是比我们要求的指数高一级的更一般的情况。如果p是一个质因子,k是它的指数,那么从 0 到k的所有p的幂次之和的公式是:

Image

要找到n的所有因子之和,我们需要对每个质因子及其对应的指数应用这个公式,然后将所有结果相乘。这基本上与计算因子个数的方法相同:我们逐个质因子构建因子,考虑质因子和指数的所有组合。

由于我们在图 3-3 中的程序已经计算了一个数字的质因子和指数,我们可以再次向该程序添加代码来求出该数字的因子之和。但首先,因为公式涉及到计算幂,我们需要一种方法来轻松地进行指数运算。Scratch 没有内置的运算符来处理这个问题,所以我们必须通过定义一个自定义模块来实现,如图 3-7 所示。

Image

图 3-7:计算正整数幂的模块

这个模块接收两个值,baseexponent,并将base与自身相乘exponent次,将结果存储在result变量中。借助这个模块,我们现在可以将图 3-8 中显示的代码添加到我们之前创建的程序中,即图 3-3。

Image

图 3-8:使用n的质因子的指数来计算n的因子之和

我们从primesexps列表中提取每个质数p及其指数k,并使用我们自定义的power模块计算p^(k + 1)。然后,我们将结果代入前面给出的公式,通过sum变量跟踪总和。当我们遍历完两个列表后,在报告最终答案之前,我们会从sum中减去原始数字。这样,我们就得到了该数字的因子之和,即排除数字本身的总和。

注意,在计算过程中,primesexps 列表都会被清空。与我们计算因数数量的程序一样,总是从每个列表中获取第一个项并删除该项,然后使下一个列表元素成为新的第一个元素,而不是一直追踪每个列表中的项编号,这样做更为简单。

破解代码

在编写代码时,始终测试代码是一个好主意,以确保它按预期工作。例如,我们可以通过勾选 result 变量旁边的框来测试我们自定义的 power 块,这样它的值就会出现在舞台上。然后,我们可以将该块拖入编程区域,输入参数值,点击块并查看 result 是否显示正确的值。图 3-9 通过计算 flintmax 来测试该块。正如我们在第一章中讨论的那样,这是 Scratch Cat 在按 1 计数时能得到的最大整数。

Image

图 3-9:使用块计算 2⁵³

当我在 Scratch 中编程时,我通过在舞台上显示大量变量并将代码的各个部分隔离开来调试我的代码,以验证它是否按我预期的方式运行。一旦代码正常工作,我就通过隐藏不需要显示的变量来清理舞台。

Image 编程挑战

3.1 记住,6 是第一个完美数,之所以这样称呼是因为它等于其真因数之和:1 + 2 + 3 = 6。使用图 3-8 中的因数和代码,找到其他三个小于 10,000 的完美数。

3.2 一个数 n 可能有两种方式不是完美数:要么 n 的所有真因数之和小于 n,这种情况下 n 被称为 不足;要么 n 的所有真因数之和大于 n,这种情况下 n 被称为 富余。编写一些 Scratch 代码来报告给定的数字是不足数、完美数还是富余数。每种类型的数字有多少个(最多到 10、100、1,000)?

3.3 将一个非零数的 0 次方运算结果为 1。检查图 3-7 中的指数块,看看它是否对 0 次方有效。如果无效,请重写该块,使其给出正确的结果。

3.4 负指数的计算使用倒数:n^(–k) = 1/n^(k)。使指数块在处理负指数时给出正确的答案。

3.5 修改 τσ 的代码,这样在访问列表元素后,不再删除它们,而是使用一个新的计数器变量,比如 i,按顺序访问每个列表元素。

质因数分解如何帮助找到 GCD

在第二章中,我们探讨了计算两个整数 ba 的最大公约数(GCD)的方法。如果我们知道每个整数的质因数分解,我们还有另一种选择。假设我们有以下两个质因数分解:

Image

要找到ba的最大公约数(GCD),第一步是重写它们的质因数分解,确保包括任何一个数字的每个质因数,必要时使用指数 0:

Image

在这里,我们已经将 7⁰添加到a的质因数分解中,并将 5⁰和 11⁰添加到b的质因数分解中。接下来,对于每个质因数,我们比较ab,并取最小指数。例如,3²和 3³的最小值是 3²。ba的 GCD 是使用最小指数的质因数分解的乘积:

Image

如果我们已经投入时间和精力去分解ba的因式,那么这可能是一个有用的方法。但如果我们事先不知道因式分解结果,使用欧几里得算法来求最大公约数(GCD)会更高效,就像我们在项目 9 中所做的那样。

与最大公约数(GCD)相关的概念是最小公倍数(LCM)ba的 LCM 是一个既是b的倍数又是a的倍数的最小数字。例如,2 和 3 的 LCM 是 6。LCM 对于加法分数很有用,因为两个分母的 LCM 是你在进行加法之前应该转换的公分母。在这种情况下,最小公倍数通常被称为最小公分母(LCD)。如果你忘记了 LCD 中的 D 代表分母,而 GCD 中的 D 代表除数,这可能会让人有点困惑!

如果我们知道ba的质因数分解,那么计算它们的 LCM 与计算 GCD 非常相似。唯一的区别是,我们取每个质因数的最大指数,而不是最小指数。继续以b = 5,292 和a = 990 为例,我们得到:

Image

由于我们使用最小指数来求最大公约数(GCD),而使用最大指数来求最小公倍数(LCM),一个巧妙的技巧是,将两个数字ba的 GCD 与 LCM 相乘,等于将这两个数字相乘:

Image

推广一下,如果我们已经计算出了 GCD(b, a),我们可以通过以下公式计算 LCM(b, a):

Image

Image 编程挑战

3.6 编写一个自定义模块,使你能够通过试除法确定一个给定的数字是否为素数。

3.7 使用挑战 3.6 中的模块编写另一个自定义模块,告诉你给定数字之后的下一个素数。如果你想基于项目 10 中的程序(图 3-3)编写一个基于因式分解方法的 GCD 计算器,你需要确保两个数字的质因数列表都包含相同的素数,因此像这样的模块会很有用。

3.8 螺旋画板是一种经典的玩具,通过在带齿环内旋转带齿齿轮,可以画出复杂的曲线。下图展示了不同齿轮大小如何导致不同的形状。很多人已经使用 Scratch 制作了螺旋画板模拟器,你可能还可以找到其他在网页上运行的版本。写一个程序,让 Scratch 猫使用最大公约数(GCD)或最小公倍数(LCM)模块,预测当一个带有b个齿的齿轮在一个带有 96 个齿的环内旋转时,曲线会有多少个“点”。

Image

使用双素数联系外星人

双素数,有时也称为半素数,是一类很有趣的数字。作为两个素数的乘积,双素数的定义就是只有两个素因数。这个性质引出了一个有趣的可能性:你可以利用双素数来结合每个素因数的信息。

这里有一个双素数派上用场的实际例子。1974 年,科学家们通过位于波多黎各的 Arecibo 射电望远镜向太空广播了 Arecibo 消息。他们希望消息能包含有关地球上人类生活的信息,以便任何接收到消息的外星人都知道我们存在并了解我们的一些情况。但他们不想用英语或其他人类语言编写消息,因为接收者可能无法理解。相反,消息的作者决定将其设计为一系列位(0 和 1),这些位应该排成一个矩形,0 位用白色表示,1 位用黑色表示,从而形成一个像素化的图像。但是,消息的长度应该是多少呢,接收者又如何知道如何将这些位排列成正确尺寸的矩形呢?

假设你正在设计这样的消息。该消息有n位。如果矩形的尺寸是ab,你需要确保abn的因数,使得ab = n。如果n是素数,只有一个因式分解,1 ⋅ n(或n ⋅ 1),那么这些位就只能排成一行或一列——这几乎没什么用。但如果n有很多因数,那么矩形的尺寸就有很多种可能性——这也不是很有帮助。

解决方案是让n成为双素数。这样,它只有两个重要的因数(不包括 1 和n本身),即两个素数,这两个素数相乘得到n,因此矩形的维度就很清晰。这正是 Arecibo 团队所做的,他们发送了 1,679 位的信息。由于 1,679 = 73 ⋅ 23,所以只有两种可能的方式来组织这些位:23 行和 73 列,或者 73 行和 23 列。对于他们发送的位串,第一个可能性解码出来是一个随机排列的点,而第二个则给出了图 3-10 所示的图像。

Image

图 3-10:Arecibo 消息

该消息包含了关于二进制计数、生命的化学基础、太阳系、典型人类的大小以及发射该消息的望远镜的信息——这些信息在仅仅 1679 个 1 和 0 中压缩得相当紧凑。不幸的是,Arecibo 望远镜在 2020 年倒塌,但即使望远镜已经消失,消息仍在前往球状星团梅西耶 13 的途中,预计将在大约 25,000 年后抵达。

为了从 Arecibo 消息中提取信息,我们需要做的就是因式分解其长度n。我们已经有了一种通过试除法来做到这一点的方法,如在项目 10 中所示,但这种方法对于大数——尤其是大合数——可能会比较慢,因为我们必须先通过所有小数进行试除。正如我们将在下一个项目中看到的,知道我们正在尝试因式分解一个合数,可以给我们提供一个捷径。我们可能会期望合数的两个质因数接近相同的大小,围绕n的平方根。因此,我们可以从那里开始查找,而不是从 2 开始。

项目 13:费马因式分解法

数学家皮埃尔·德·费马意识到,存在一种更高效的方法来因式分解接近相同大小的两个因数的数字,比如我们这里关注的合数。这个技巧基于平方差公式。假设我们有一个数字n,可以写成平方差的形式,即n = a² – b²。我们可以将其改写为n = (ab) (a + b)。以这种方式查看公式可以告诉我们,(ab)和(a + b)一定是n的因数。

诀窍是从n中找到ab。为此,我们将方程重写为a² – n = b²,尝试不同的a值,并寻找一种使差值a² – n成为完全平方数的选择,从而找到b。首先,我们需要识别完全平方数,这可以通过图 3-11 中的自定义块来实现。

图片

图 3-11:我们有完全平方数吗?

这是一个布尔块,根据传入的数字是否是完全平方数返回一个逻辑值truefalse。我们计算number的平方根,使用内置的floor函数将结果向下取整到最接近的整数❶,然后进行比较。如果number是完全平方数,那么它的平方根已经是一个整数,所以比较结果相等。

一旦我们能够识别完全平方数,就可以开始搜索它们以识别ab,从而找到n的因数。图 3-12 中的程序演示了这一过程。

图片

图 3-12:费马因式分解

我们首先选择一个比Images ❶稍小的 a 值。然后,在一个循环中,我们将 a 增加 1 ➌ 并检查 a² – n 是否是一个完全平方数。(在循环前将 a 减少 1 ➋ 让我们可以捕捉到 n 恰好是一个完全平方数的情况。)一旦我们找到一个有效的 b 值,就计算并显示因数 factor1 (a + b) 和 factor2 (ab)。

结果

图 3-13 显示了程序运行的样本结果。

Image

图 3-13:因数分解 4,398,091,599,977

请注意,我们没有让 Scratch Cat 说出结果,而是直接将相关变量显示在屏幕上。

破解代码

将费马分解方法的时间与项目 10 中的试除法方法进行比较会很有趣。你可能会在图 3-13 中注意到,屏幕上显示了一个额外的变量,叫做time,它的目的是用于这个对比。我们之前在项目 9 中嵌入了一个程序,放在一个定时器循环中,用来检查使用试除法测试质数所需的时间(请参见图 2-19,位于第 39 页)。这一次,我们将创建两个简单的自定义模块,用来打开和关闭 Scratch 的内部定时器,如图 3-14 所示。

Image

图 3-14:定时器模块

为了计时费马分解程序,将 timer start 块添加到图 3-12 中第三个 set 块之前 ➊,并将 timer stop 块添加到程序的末尾。正如我们在第二章中讨论的那样,Scratch 的默认时间单位是秒,因此对于小数字来说,时间可能会报告为 0,因为实际的时间间隔太短,经过四舍五入后无法显示。重复运行程序并将总时间除以运行次数应该能给出更准确的结果。这个技巧还帮助消除了计算机上其他进程对 Scratch 程序运行速度的影响。

Image 编程挑战

3.9 在图 3-12 中的费马分解代码中添加一个测试,检查它产生的因数是否为质数。这将告诉你输入的数字是否真的是一个双质数。

3.10 将图 3-12 中的费马分解代码与图 2-6 中的试除法代码结合在一起,位于第 28 页,这样你就可以比较每种方法找到因数的时间。

3.11 Scratch 的舞台大小为 480 像素乘 360 像素。编写一个程序,接收一个由n = ab 位(其中a最多为 480,b最多为 360)组成的字符串,并在屏幕上以ab像素的矩形显示它,将 1 位显示为黑色,0 位显示为白色。尝试使用该程序重现 Arecibo 信息。

结论

本章我们提出了不同的问题,并在第二章中提出了类似的问题(“一个数字如何分解因子?”和“一个数字是否为质数?”),但我们在两种情况下都使用了相同的初步方法来解答它们:试除法。正如我们所见,这种方法的局限性在于执行工作的时间。在最坏的情况下,找到答案所需的步骤与被测试数字的平方根成正比。对于不超过 flintmax 的数字,这样的时间并不算长,但对于拥有数百位数字的数字来说,宇宙中的时间也不够试除法来奏效。其他方法,如费马分解法,可以帮助我们更快地找到所需的结果,尤其是当我们对数字的形态有所了解时,例如它是否为双质数。如果你能想到加速计算的方法,Scratch Cat 将会更快给出答案!

第四章:## 在序列中寻找模式

Image

人类天生就能寻找模式并预测接下来会发生什么。现实世界中的模式可能很复杂,涉及到很多变量和结果,可能是时间上的(比如下次月圆是什么时候?)或空间上的(比如那个洞里有熊吗?)。在本章中,我们将探索数字序列中的模式。你将学会如何揭示序列形成的规则,以及如何预测序列中的后续数字。

什么是序列?

一个 序列 就是一系列数字。这些数字按照特定的顺序排列——有第一个数字、第二个数字、第三个数字,等等——所以我们可以说序列是由正整数 索引排列 的。当我们写关于序列的数学时,我们通常会将索引数字写作下标。例如,我们可能会将一个序列写作 a[1],a[2],a[3],. . . ,其中每个 a 是序列中的一个值,称为 元素

通常,序列中的数字讲述了它们是如何生成的。也许有一个规则描述了一个公式,用来获取索引数字并对其进行操作,以生成对应的序列元素。例如,如果我们想研究奇数的序列(1, 3, 5, 7, . . .),我们可能会将第 n 个奇数描述为 2n – 1。我们可以通过将偶数看作是 2 的倍数,奇数则是比偶数少 1,来推导出这个公式。我们可以快速检查这个公式是否有效:当索引 n = 1 时,第一个奇数是 2 ⋅ 1 – 1 = 1。接着,当 n = 2 时,我们得到 2n – 1 = 2 ⋅ 2 – 1 = 3,以此类推。我们可以通过以下方式明确模式:a[n] = 2n – 1,或者用语言来说就是:将索引翻倍并减去 1

也许每个索引 n 代表一个数学对象,比如几何形状,而对应的序列中的数字可以通过检查或计数该对象的一些特征来找到。例如,如果我们让每个 n 代表一个边长为 n 的正方形,我们可能对正方形面积的序列感兴趣(1, 4, 9, 16, . . .)。或者也许我们更想要正方形周长的序列(4, 8, 12, 16, . . .)。

在序列中寻找下一个值

可能有可能找到一个基于几何或逻辑模式的公式,来描述像刚才提到的那样的数列。然后,这个公式可以帮助我们深入理解数列的规律。例如,我们可能会注意到,面积数列中的所有数字都是完全平方数,并且意识到这与一个正方形的面积是其长和宽的乘积有关。同样,我们可能会注意到,周长数列中的数字都是 4 的倍数,并且能够找到一个理由,这与正方形四条相等的边长有关。如果我们将面积数列写成 s[1]、s[2]、s[3]、. . . ,将周长数列写成 p[1]、p[2]、p[3]、. . . ,我们可能会发现公式 s[n] = n² 和 p[n] = 4n。这些公式在代数上是正确的,但我们是基于数列的几何描述得出的这些公式。

有时,描述数列的模式最好是通过提供一个生成后续项的规则,基于前面的项。递归就是通过给出一个公式来描述 a[n],这个公式不仅仅依赖于索引 n,而是基于前面元素的值。例如,周长数列可以通过注意到 p[1] = 4,并且每个周长都比前一个多 4 来生成。位于索引 n 前的元素具有索引 n – 1,因此公式可以是 p[n] = p[n – 1] + 4。只要我们有了起始值(即 p[1]),我们就能生成剩下的数列。

正方形的面积数列也遵循它自己更微妙的递归规律。给定初始元素 s[1] = 1,任何后续元素 s[n] 都可以通过公式 s[n – 1] + 2n – 1 计算得出。这为我们提供了另一种描述该数列的方式。一般来说,要通过递归来指定一个数列,你必须提供一个初始值(例如 p[1] = 4)和一个形成规则(例如 p[n] = p[n – 1] + 4)。有时,递归可能依赖于两个或更多前导项,而不仅仅是一个,在这种情况下,必须提供两个或更多初始值。一个著名的例子是斐波那契数列,我们稍后会进行探讨。

在 Scratch 中创建数列

在 Scratch 中,我们可以将数列表示为列表。Scratch 列表可以包含数字或字符串,并且可以最多容纳 200,000 个项目,这对于探索模式来说是足够的。与许多其他编程语言(这些语言通常从 0 开始索引列表项)不同,Scratch 从 1 开始索引其列表。这一特性使得 Scratch 列表特别适用于表示数列,因为数列通常也是从 1 开始索引的。如 第三章 中所提到的,当 Scratch 在舞台上显示一个列表时,它会在左侧显示索引数字,因此你可以轻松看到一个项在数列中的位置。如果列表太长,无法完全显示在舞台上,你可以滚动查看后面的条目。

用于处理列表的 Scratch 模块如图 4-1 所示。这些模块可以在模块面板的变量部分找到。

图片

图 4-1:Scratch 的列表操作模块

注意,除了在列表末尾添加项,我们还可以在列表的任何位置插入项,这会导致后续项的索引增加(加 1)。我们可以删除列表项,这也会使后续项的索引减小(减少 1),同时我们还可以用其他项替换列表项,这样后续项的索引则不会改变。我们可以查找某个项的索引,查看它在列表中的位置,也可以检查某个项是否出现在列表中。我们还可以查看列表的长度。

项目 14:斐波那契的兔子

在这个项目中,我们将探索斐波那契数列,这是一个由二项递推描述的著名数列。这意味着数列中的每个数字都是基于前两个数字计算出来的。这个数列最早在 1202 年由意大利数学家斐波那契在《Liber Abaci》一书中提到。斐波那契使用这个数列来描述兔子种群的增长。

在给定斐波那契数列的前两个数字,f[1] = 1 和 f[2] = 1 之后,其他的数字都是通过这个生成规则来找到的:

图片

换句话说,我们将序列中的前两个数相加,得到下一个数字。第三个数字是 1 + 1 = 2,第四个是 1 + 2 = 3,第五个是 2 + 3 = 5,依此类推。更多内容我们可以让 Scratch 来处理,如图 4-2 所示。

图片

图 4-2:使用二项递推生成斐波那契数列

注意,在这段代码中,为了生成二项递推的数字,我们只需要追踪前两个值来计算下一个值。我们首先将名为 oldoldold 的变量设置为初始值(11)。然后,在一个循环 ❶ 中,我们将 new 变量赋值为它们的和,将 oldold 替换为 old,并将 old 替换为 new。通过这些替换,我们就准备好在下一次循环时计算 new 的下一个值。

项目 15:黄金比例

斐波那契数列的一个有趣的事实是,连续两个数的比值趋近于一个极限值,这意味着随着数列的展开,f[n] 除以 f[n – 1] 会越来越接近某个特定数字(即极限),但永远不会完全到达它。这被称为收敛比率。对于斐波那契数列来说,这个极限是一个著名的数学常数,叫做黄金比例。它的准确值是:

图片

为了证明斐波那契数列具有收敛比值,我们可以修改前一个项目中的代码,添加另一个列表来跟踪每个数字与序列中前一个数字的比值。图 4-3 展示了如何做。

图片

图 4-3:追踪连续斐波那契项的比值

这段代码与图 4-2 类似,只不过多了一个额外的列表来追踪Fibonacci ratio。我们在计算序列中的下一个项之前,用old / oldold的值更新这个列表 ➋。我们让列表经过 40 次迭代 ➊,因为这是比值稳定下来的地方。

结果

图 4-4 显示了运行图 4-3 中代码后的两个列表中的初始结果。你需要向下滚动才能查看列表中的后续值。

图片

图 4-4:前几个斐波那契数及其比值

Fibonacci列表显示了斐波那契数列本身,而Fibonacci ratio列表显示了每一项与其后续项之间的比值。正如你所看到的,比值在它们的极限值上下波动。如果你想提前停止程序,取Fibonacci ratio中两个连续列表元素的平均值,将比单独的元素提供更好的极限值近似。

破解代码

我们使用了repeat循环来为程序提供看起来合理的计算项数,但最好是让程序自己决定需要多少项。例如,我们可以让程序在比值足够收敛到极限值时停止计算斐波那契数。我们可以将此定义为比值不再变化的时刻,或者当它们的变化小于指定的量时。图 4-5 展示了使用这种方法的修改版斐波那契程序。

这个程序不再将斐波那契数存储在列表中,因为我们只关心比值收敛所需的时间。我们使用repeat until循环 ➊ 来监控比值随着更多项的计算而变化,直到当前比值与前一个比值的差异小于0.0000001为止。这个精度水平是可配置的,但如果我们使用过多的数字(过小的数字),我们将遇到 IEEE 754 浮点表示法的限制。

请注意,repeat until条件使用了绝对值函数(abs)。这是必要的,因为比值在极限值周围来回摆动,交替地过大或过小。这意味着我们计算的差异交替为正值和负值,因此abs将负值转换为正值。

图片

图 4-5:当比值收敛时停止程序

这个程序包含一个名为 count 的变量,用来跟踪需要多少项才能达到指定的准确度水平。在循环结束后,程序将最终的比率存储在 ratio 变量中 ➋。在舞台上,只显示比率的前几位数字,但你可以点击编码区域中的比率,查看计算出的所有数字,如图 4-6 所示。

Image

图 4-6:计算斐波那契比率

作为一个实验,你可以尝试将准确度设置为不同的值(0.010.001,……),看看需要多少项才能达到那个准确度水平。

Image 编程挑战

4.1 尝试通过在原始斐波那契程序中使用不同的初始值来更改斐波那契递归的起始条件(图 4-2)。将 old 设置为 2oldold 设置为 1 并不会很有趣,因为这只是将斐波那契数列向右平移了一位。然而,如果将 old 设置为 1oldold 设置为 2,则会得到一个不同的数列,称为 卢卡斯数列。看看你是否能找到卢卡斯数与斐波那契数之间的关系。

4.2 你如何理解斐波那契数列的反向计算?为了保持递归关系,f[0] 应该是什么?f[–1] 和 f[–2] 又应该是什么?编写一个针对负整数的递归程序。

4.3 在图 4-2 中的代码上进行修改,探索它的极限。斐波那契数列能走多远,才会超出 flintmax?在数字超过 Scratch 浮点数表示的绝对限制并被报告为 Infinity 之前,能走多远?增长速度是指数级的,因此不需要很多步骤,flintmax 就会被超越!

几何数

几何数 来自那些计算构建嵌套几何图形所需点数的数列。例如,在图 4-7 中,我们有一个内嵌的正方形序列。蓝色的点是我们要计数的点,这些点位于网格上。通过连接这些点,从左下角开始,我们可以绘制出越来越大的正方形,包含越来越多的点。

Image

图 4-7:作为几何数的嵌套正方形

平方一词可以是名词或动词。作为名词时,它指的是一种几何图形,一个具有四个相等边和四个相等角的多边形。作为动词时,它通常用于算术中,用来描述将一个数字乘以自身的过程。当然,算术和几何是相关的。公式A = s²,用于通过将边长s自乘来计算(几何)正方形的面积,从几何角度解释了算术。若某个数字是通过将一个正整数自乘得到的,那么这个数字就是平方数(1² = 1, 2² = 4, 3² = 9, 16, 25,...)。

图 4-7 展示了我们如何排列平方数个点,形成不断增长的几何正方形。我们有一个包含 4 个点(2 行 2 列)的正方形,内嵌着一个包含 9 个点(3 行 3 列)的正方形,再内嵌着一个包含 16 个点(4 行 4 列)的正方形,最后是一个包含 25 个点(5 行 5 列)的正方形。你也可以把最左下角的点当作包含 1 个点的正方形。每个较大的正方形都会在前一个正方形的边缘添加一组新的连接点。

实际上,任何类型的多边形都可以嵌套,从而生成类似平方数序列的几何数列,正如我们接下来要探讨的那样。

项目 16:平方数、三角形数和五边形数?

接下来的三个图中的 Scratch 程序绘制了嵌套的s边形,并计算新添加的点以生成一个序列。到目前为止,我们还没有使用很多 Scratch 的图形功能,但在这个程序中,我们使用了 Pen 扩展来为绘制各种图形添加动画。(点击左下角的添加扩展图标来添加这些 Pen 块。)我们的程序从图 4-8 中的初始设置开始。

Image

图 4-8:绘制嵌套多边形的设置代码

这个initial setup块清除屏幕上的前面绘图,并要求用户输入边数(保存在变量number of sides中)和嵌套的多边形数量(保存在变量reps中)❶。计算side length ➋,即舞台上两个相邻点之间的距离,确保多边形能够适应舞台。

图 4-9 展示了程序的主要逻辑。

Image

图 4-9:绘制多边形

我们在一个循环中绘制嵌套的多边形,该循环重复reps次。我们从左下角开始绘制每个多边形,使用go to块跳过已经绘制的点(因为所有多边形都有相同的底边)。在用自定义的Draw n segment(s)块绘制完第一条边后,我们根据多边形的边数旋转笔刷❶。counter变量跟踪已绘制的点的总数。一旦当前的多边形完成,我们将counter添加到end point list ➋。该列表跟踪每完成一个多边形时绘制的点数,构建我们的图形数列。我们使用自定义的Highlight point块来指定如何绘制这些点。图 4-10 显示了这两个自定义块。

Image

图 4-10:绘制点和线

Draw n segment(s)块沿多边形的一条边执行n步,首先画一个大点(笔刷大小 5),然后画一条细线(笔刷大小 1)连接到下一个点。wait块计算每一步之间的暂停时间❶,但如果你厌倦了观看绘图过程,可以通过降低分子中的值来加快速度。

自定义的Highlight point块只是将笔刷颜色改为红色,并增加其大小,以标记每个多边形中的最后一个点。然后,它将笔刷颜色恢复为蓝色,并再次减小大小。

结果

我们使用绘图程序生成一些数列。当边数s = 4 时,我们得到方形,无论是几何上还是算术上,如图 4-11 所示。从几何角度看,每添加一层点到现有点周围就形成一个新的、更大的方形。从算术角度看,end point list序列中累积的数字都是完全平方数。我们通过从左下角开始计数点,逆时针移动,并随着每一层嵌套扩展,得到了这些数字。如前所述,当我们完成一个方形时,到目前为止计数的点数会被添加到列表中,并且该点会用红色高亮显示。

Image

图 4-11:方形数

我们不必局限于方形。三角形数是通过将点按嵌套等边三角形排列生成的序列,如图 4-12 所示。

Image

图 4-12:三角形数

请注意,每个三角形数都是通过将下一个整数加到前一个三角形数中构建的:1, 1 + 2 = 3, 1 + 2 + 3 = 6, 1 + 2 + 3 + 4 = 10,依此类推。换句话说,第n个三角形数是从 1 到n所有整数的和。

我们如何用公式描述三角形数列的模式呢?可以考虑将第n个三角形复制一份并旋转,使其形成平行四边形,如图 4-13 所示。

Image

图 4-13:我们可以通过思考两个三角形排列成平行四边形的方式来推导三角形数字的公式。

这个平行四边形的底部有n + 1 个点,且有n行点,因此一共有n(n + 1)个点。由于这个平行四边形是由两个三角形复制而成,每个三角形中包含n(n + 1) / 2 个点。这就是n阶三角形数的公式。

我们可以通过在嵌套的五边形中数点,来生成另一个图形数列,这将在第七章中派上用场。图 4-14 展示了五边形数字的序列。

Image

图 4-14:五边形图形数字

在嵌套的五边形中还有其他有趣的数字序列。例如,如果你数一下这些点,并记录下沿着图 4-14 中绿色路径的点的数字,你会得到 1, 2, 6, 13, 23, 36,……这样的序列。另一个在第七章中会用到的路径从 2 开始,沿着图 4-14 中的紫色路径向上和向右走。围绕嵌套的五边形数点得到的序列是 2, 7, 15, 26, 40, 57,……。

破解代码

即使你将绘制 n 段积木中的等待时间(图 4-10 ❶)设置为0,在绘制多边形和报告图形数列的值时,仍然会有延迟。如果你想立即得到结果,可以通过使用 Turbo 模式来加速程序(参见图 4-15)。

Image

图 4-15:开启 Turbo 模式

Turbo 模式是一个功能,能够消除 Scratch 在运行更新屏幕的积木后通常会插入的短暂停顿。要开启 Turbo 模式,请在 Scratch 编辑器中选择编辑 ▸ 开启 Turbo 模式,或在点击绿色旗帜按钮时按住 SHIFT 键。当 Turbo 模式激活时,菜单栏会有相应的指示。

预测序列中的值

每当你看到一个序列的前几个项时,大家都会自然地问:“接下来是什么?”一个可能的回答是:“随便你想要什么!”如果你只知道有一些数字一个接一个地列出,那么任何数字都可以是接下来的项。但如果你假设这些数字有某种意义——也就是说,它们是通过某种规则生成的——那么要弄清楚接下来是什么,就需要发现这个规则,并将其应用到后面的项上。可能存在多个符合条件的规则,在这种情况下,你可以选择最自然或最有用的那个规则。

例如,考虑图 4-11 中的平方数列。我们可以通过找到规则来预测数列中的下一个数字,即数列中的第n项是n²。9² = 81 之后的元素应该是 10² = 100。或者,我们可以注意到,对于每一个新的(几何)平方,我们都是在前面的平方基础上,沿着上边和右边添加了一层新的点。第n层会添加第n个奇数(2n - 1)个点到总数中(因此数列可以描述为 1, 1 + 3, 1 + 3 + 5, 1 + 3 + 5 + 7, ……)。这是一个加法规则的例子,它揭示了规律的另一个方面。从这个角度考虑,我们可以通过在第九个数(81)上加上(2 ⋅ 10)– 1 = 19,得到 81 + 19 = 100。无论哪种方式,我们都会做出相同的预测,但我们对其描述方式不同。

项目 17:差分表带来的巨大变化

了解一个数列的规律,尤其是使用某种加法规则构建的数列,可以通过减法来逆转加法操作。一个数列的差分表是通过将原始数列的每个项与下一个项进行相减而得到的另一个数列。在这个项目中,我们将探讨如何使用 Scratch 来创建差分表。

如果一个差分表无法揭示数列的规律,我们可以通过在第一个差分表中找到相邻数字的差值来创建第二个差分表。这些差值被称为原始数列的第二差分。如果有需要,我们可以基于第二差分表再创建第三差分表,以此类推。有时,通过这个过程会出现有趣的规律。

图 4-16 展示了部分 Scratch 代码,用于输入一个数列并生成其差分表。

Image

图 4-16:构建差分表以分析一个数列

首先,initialize 块清空了上次运行程序时的数据。然后,repeat until 循环 ➊ 提示我们一次输入一个数,直到我们通过输入 x 告诉 Scratch 我们已经完成了。数列被存储在 seq 列表中。接着,我们通过计算数列中相邻值之间的差值来构建第一个差分表,并将其存储在 1st 列表中 ➋。

图 4-17 中的附加代码计算了第二和第三差分,并将它们存储在 2nd3rd 列表中。

Image

图 4-17:计算第二和第三差分

这个代码片段遵循了我们计算第一个差分表时使用的相同模式,只不过我们这次使用 1st2nd 列表作为输入,而不是使用 seq

结果

图 4-18 展示了运行差分表程序后,来自平方数列的前几个值的结果:1,4,9,16,25,36,49,64 和 81。

Image

图 4-18:平方数序列的差异表

第一次差异表确认了我们之前讨论的内容:差异是连续的奇数,意味着序列中的第 n 个数字是前 n 个奇数的总和。第二次差异是恒定的:它们都等于 2,因为连续的奇数之间总是相差 2。第三差异都为 0。

第三差异为 0 是一个明显的标志,表明序列的基础形成规则可以用 二次多项式 给出,形式为 ax² + bx + c。因此,写出形成规则就变成了确定 abc 的值,这些值被称为 系数。对于平方数序列,写出二次多项式尤其容易:我们可以使用 a = 1, b = 0, 和 c = 0。这样我们得到:

Image

对于来自图 4-13 的三角形数,我们可以使用 a = 1/2, b = 1/2, 和 c = 0。然后我们得到:

Image

这与我们之前通过将两个三角形复制并视为平行四边形得出的规则相同。

破解代码

这里有一个 Scratch 技巧,可以让程序中使用列表变得更容易。假设你有另一个程序中的数字列表——例如,使用项目 16 中的代码生成的五边形数(图 4-14)——并且你想将它们带入差异表程序进行分析。你可以通过右键点击该列表并选择 导出 来导出该列表,而不是逐一手动复制数字(见图 4-19)。这样会将列表保存为名为 end point list.txt(或者任何列表的名称)的文本文件,保存在你的默认目录中。

Image

图 4-19:保存列表以备后用

要在差异表程序中使用该列表,请忽略输入数字的提示,右键点击舞台上的 seq 列表。点击 导入,如图 4-20 所示,然后选择你刚刚保存的文件进行上传。

Image

图 4-20:恢复列表以进行进一步工作

seq 列表现在将填充五边形数,因此你只需要在提示符中输入一个 x,就能计算差异。图 4-21 展示了结果。

Image

图 4-21:五边形数序列的差异表

你知道什么吗?再次地,第三差异都为 0,因此五边形数序列也可以通过二次多项式生成!

Image 编程挑战

4.4 编写一个 Scratch 程序,它可以处理任何第三差分为 0 的序列,并恢复该序列的二次多项式系数 abc,即 ax² + bx + c。使用该程序查找五边形数的系数。如果你只想要五边形数的公式,试着像小孩子画房子一样画第 n 个五边形,将它看作是第 n 个正方形上方加上第 (n – 1) 个三角形。

4.5 如果你将 项目 7 中通过筛法生成的素数列表导出,并将其导入差分表程序中,看看会发生什么。这里显示了前几个结果。如你所见,差分并没有形成如此整齐的模式。编写一些代码来查找第一差分表中最大的差值。

Image

4.6 根据这个规则生成一个序列:序列的第 n 项是 n 的二进制表示中数字 1 出现的次数。这个序列从 1 开始,1, 1, 2, 1, 2, 2, 3, . . . ,(通过计算二进制序列 1, 10, 11, 100, 101, 110, 111, . . . 中的 1 的个数)。编写一个 Scratch 程序来计算这个序列的几百项,并看看你能否找到一个公式或递推关系来预测未来的项。

4.7 当你对斐波那契数列制作差分表时,会发生什么?

4.8 当你对 2 的幂次序列制作差分表时,会发生什么?

4.9 尝试对立方数列(1, 8, 27, 64, 125, . . .)创建差分表。你可以扩展图 4-17 中的代码,得到更高次差分,从而探索高次多项式的差分表。

结论

在 Scratch 中,列表非常适合追踪数字序列,而列表运算帮助我们理解其中出现的模式。Scratch 图形使用几何学来动画化具象数列,而差分表使这些数列中的模式更加易于识别。Scratch Cat 拥有所有答案——你的任务是提出问题!

第五章:## 从序列到数组

图片

当你问“下一个值是什么?”时,你假设这些值是沿着一条线排列的一维序列。一个值跟随另一个值按顺序排列,因此从一个项跳到下一个项只有一种方式。但我们生活在一个多维的世界,有时使用多维来组织信息会更有帮助。

例如,数组是一个二维对象,是按行和列组织的值表格。我们仍然可以在一个数字数组中寻找模式,但现在这些模式可能在我们从一行移动到另一行、从一列移动到另一列,或沿对角线移动时显现出来。在本章中,我们将使用 Scratch 来研究一些有趣的数组。虽然 Scratch 通过其 list 数据类型使得探索一维序列变得容易,但它没有类似的结构来处理二维数组。我们将不得不开发一些创造性的方法来在 Scratch 中表示数组。

Pascal 三角形

Pascal 三角形 是一个二维数字数组,而不是线性序列。就像序列中的条目是由正整数索引的,Pascal 三角形中的条目是通过给出两个索引数字来指定的,这两个索引分别对应于某个条目的行号和列号。在这种情况下,最好将索引数字从 0 开始,而不是从 1 开始。

Pascal 三角形的行数用字母 n 来表示。第一行时,n = 0;第二行时,n = 1,依此类推。在每一行中,列用字母 k 表示,从 k = 0 开始,一直到 k = n。因此,第 n 行总是有 n + 1 个条目:第 0 行有一个条目,第 1 行有两个条目,依此类推。当我们写出这些行时,居中排列会使我们容易看出数组从一行到下一行的扩展,从而形成 Pascal 三角形独特的形状:

图片

每个条目的值可以通过将其正上方的两个条目相加来确定。例如,第 n = 2(从顶部算起的第三行)中的 2 是上方两个 1 相加的结果,第 n = 5(这里显示的底部行)中的每个 10 是其上方 4 和 6 相加的结果。位于行边缘的条目,由于它们正上方没有两个条目,所有的值都为 1。

使用二项式

现在你知道了 Pascal 三角形中数值的来源,但它们意味着什么呢?它们与 二项式定理 有关。这个代数规则使我们更容易计算出 二项式 的正整数次方,二项式是由两项和组成的表达式。以 1 + x 为例,二项式定理帮助我们计算 (1 + x)⁰, (1 + x)¹, (1 + x)², (1 + x)³ 等的值。展开每一个次方,我们得到如下结果:

图片

看起来熟悉吗?这些展开式中的系数(即 x 的幂的常数乘数)与帕斯卡三角形中的值是相同的。三角形第一行的 1(行 n = 0)对应于 (1 + x)⁰ = 1。第二行中的两个 1(n = 1)对应于 (1 + x)¹ = 1 + x。(想象 x 前面有一个隐形的 1。)第三行中的 1、2 和 1(n = 2)对应于 (1 + x)² = 1 + 2x + x²,依此类推。

通常,帕斯卡三角形的第 n 行显示的是二项式 n 次方的系数——也就是 (1 + x)^(n)。更重要的是,该行中的第 k 项包含了 x^(k) 在二项式展开中的系数。为了看清楚这一点,可以通过将表达式如 1 + 2x + x² 转换为 1x⁰ + 2x¹ + 1x²,并注意 x 的指数是如何从 0 到 n 依次递增的。

由于帕斯卡三角形中的值代表二项式展开式中的系数,所以它们被称为 二项式系数。任何给定的二项式系数都可以写作 C(n, k),其中 nk 分别是帕斯卡三角形中的行号和列号。因此,我们可以将帕斯卡三角形符号化地表示为:

Image

我们将帕斯卡三角形表示为一个三角形数组。但如果你观察索引号的模式,将其看作一个方形数组同样是有意义的,只不过方形的右上部分要么被省略,要么填充为 0,如下所示:

Image

我们可以通过代数运算来求得 C(n, k) 的值,方法是展开多项式 (1 + x)^(n),看看系数会变成什么样。(我已经为你做了一些工作,展示了 n = 5 时的值。)但这样做会很繁琐。为了得到 C(n, k) 的更一般的公式,可以通过思考一个相关的计数问题来帮助理解代数。

从集合中创建子集

假设你有五个朋友(Albert、Barb、Charley、Deb 和 Eve),你只能邀请其中三个人来吃披萨。那么,你能邀请多少组不同的三人组合呢?假设 ABC 是 Albert, Barb, Charley,这是第一种可能。BAC 是同一组人,只是顺序不同,因此不应该算作不同的组合;你仍然会在披萨派对上迎接相同的客人。不过,ABD 是一个不同的组合,ABE 也是如此。你可以继续下去,一次列出一个子集,最终列出 10 种不同的披萨派对组合,可能最后是 CDE。

你如何确认这个数字是正确的呢?让我们看看能否提出一个通用的规则。假设你有一个包含n个元素的集合(所有你的朋友),你想从中选择k个元素组成一个子集(派对嘉宾),其中k是一个整数,满足 0 ≤ kn。在我们的示例中,n = 5,k = 3。首先,考虑选择子集时顺序重要的情况。在这种情况下,第一个元素可以是集合中的任意n个元素之一。第二个元素可以是选择第一个元素后剩下的n – 1 个元素之一,第三个元素可以是剩下的n – 2 个元素之一,以此类推,直到我们选择第k个元素,它只能有n – (k – 1) = nk + 1 种选择方式。在我们的示例中,我们从五个朋友(A 到 E)中选择第一个元素,然后从剩下的四个中选择第二个,再从剩下的三个中选择第三个。

将每个位置的选择数量相乘,得到可能组合的总数。对于k = 3 和n = 5 的情况,可能性有 5 ⋅ 4 ⋅ 3 = 60 种。一般来说,可能组合数的公式是:

Image

在我们的示例中,尽管选择子集中的元素顺序并不重要,但我们必须考虑到不同的顺序会导致相同的子集(就像之前提到的 ABC 和 BAC 顺序)。我们如何计算有多少种不同的方式来排列一个包含k个元素的子集呢?我们可以使用我们刚才用来处理n的相同逻辑来找出答案:第一个元素有k种可能性,第二个元素有k – 1 种可能性,依此类推。所以,总的排列数是:

Image

例如,对于k = 3,排列三个特定元素的方式有 3 ⋅ 2 ⋅ 1 = 6 种。这意味着,如果我们只想计算唯一的子集,忽略排列顺序,我们应该每六个组合中只计数一个。也就是说,如果从五个元素中选择三个元素的方式有 5 ⋅ 4 ⋅ 3 种,而这三个元素的排列方式有 3 ⋅ 2 ⋅ 1 种,那么总共有(5 ⋅ 4 ⋅ 3) / (3 ⋅ 2 ⋅ 1) = 60 / 6 = 10 个唯一子集,不考虑顺序。一般公式是:

Image

请注意,我们在这里使用的变量与我们用来表示帕斯卡三角形中行和列的变量相同:nk。这是因为这两个问题是相关的。计算帕斯卡三角形中特定项C(n, k)的公式——也就是说,计算二项式系数的公式——与我们刚刚算出来的公式是一样的:

Image

有一个特殊的数列,叫做阶乘数,它让我们以更简洁的方式写出C(n, k)的公式。如果 n 是一个正整数,n 的阶乘(写作 n!)是从 1 到 n 的每个整数的乘积。例如,3! 是 1 ⋅ 2 ⋅ 3 = 6,5! 是 1 ⋅ 2 ⋅ 3 ⋅ 4 ⋅ 5 = 120。更正式地说:

Image

通过查看 C(n, k) 公式中的项序列,可以明显看出其中涉及了一些阶乘的逻辑。通过一些代数运算,我们可以简化公式,使阶乘符号得以应用:

Image

随着 nk 越大,手动计算阶乘变得非常繁琐。然而,有了 Scratch Cat 的帮助,一切变得轻松自如。

项目 18:从帕斯卡三角形中选一个数字

在本项目中,我们将使用刚刚得到的阶乘定义来计算 C(n, k),从而让 Scratch 计算给定 nk 的二项式系数。换句话说,我们将编写一个程序,计算帕斯卡三角形中 nk 列的数字。图 5-1 展示了如何实现。

Image

图 5-1:使用阶乘计算二项式系数

主程序是本书中最简短的一个:它仅包含一行代码,用于调用计算给定 nk 的二项式系数的自定义块。该块又调用 factorial 计算块,后者接收一个值并计算它的阶乘,使用循环将 1 到该值之间的所有数字相乘。结果存储在 product 变量中,我们在块的开始将其设置为 1

我们使用 factorial 块三次来计算 n 的阶乘、k 的阶乘和 n - k 的阶乘,将计算得到的 product 的值分别存储在变量 xyz 中。然后,我们计算 x / y * z 得到二项式系数 ❶。这就是我们公式的等价表达:

Image

这个程序使用滑块来设置 nk 的输入。滑块比之前项目中的 ask and wait 块更高效地接收输入,并且它们能自动将输入值限制在一定范围内的整数,从而避免了我们必须筛选不适当的输入(如负整数、字符串或带小数的数字)。要为变量创建一个滑块,可以右击舞台上的变量,然后从下拉菜单中选择滑块,如图 5-2 所示。

Image

图 5-2:使用滑块计算 C(5, 3) = 10

拖动滑块的圆圈左右移动,会改变相关变量的值,范围可以通过下拉菜单中的更改滑块范围选项来指定。我将滑块范围设置为 n 从 1 到 50,k 从 0 到 50,但正如我们将要讨论的那样,这些范围可能会对我们计算的某些二项式系数造成问题。

要使用程序,设置滑块为你选择的 nk 值,然后点击绿色旗帜查看结果的二项式系数。

破解代码

这个程序有一个问题。我在设置披萨派对计数场景时,假设 k(允许的客人数量)小于或等于 n(总共可选择的人数)。但是,虽然滑块限制了 nk 为整数,但并没有任何限制阻止我们将 k 设置为大于 n

如果我们将滑块设置为这样,当计算(nk)!时,我们会传递给factorial块一个负数。repeat循环不能重复负次数,因此循环会在第一次执行之前退出,而product会保持初始值1。这会破坏公式,并报告一个奇怪的结果,如图 5-3 所示。

图片

图 5-3: C*(5, 6) = ???

修复方法很简单:只需要在主程序中加入一个测试,检查 k 是否大于 n,如果满足该条件,通知用户输入无效,如图 5-4 所示。

图片

图 5-4:为 k > n 添加检查

请注意,我们使用了一个名为C(n, k)的变量❶,同时使用了一个自定义块C,该块有nk两个输入➋,用来计算一个值并赋给该变量。Scratch 的颜色编码和块的形状帮助我们跟踪哪个是C(n, k)

该程序的另一个问题是,阶乘增长得非常快:18! 是 Scratch 能够可靠计算的最大阶乘值,超过 flintmax 就无法计算了。实际上,由于 n! 总是可以被 2 的不同幂次整除,所以在超过 flintmax 后,报告的值仍然是正确的,但当我们计算到 171!时,已经超过了 IEEE 754 浮点标准的总体最大值。此时,Scratch 会放弃计算,报告涉及 171!或更大阶乘的值为Infinity(见图 5-5)。

图片

图 5-5: C*(171, 18) = ???

即使对于较小的值,IEEE 754 对大于 flintmax 的整数进行四舍五入也会破坏结果并产生非整数值,如图 5-6 所示。

图片

图 5-6: C*(49, 23) = ???

公平地说,C(49, 23) 的真实值是 58,343,356,817,424,所以 Scratch 的结果已经非常接近,但“非常接近”还不够好。为了让 Scratch 能够继续为更大值的 nk 找到准确的二项式系数,我们需要采取一种不同的方法,这种方法不涉及阶乘运算。为此,我们可以利用这样的事实:尽管 C(n, k) 公式中的阶乘很快变得很大,但二项式系数本身并不会这么迅速增长。所以,如果我们能够在不先计算任何阶乘的情况下计算二项式系数,我们就能在达到 flintmax 之前走得更远。

Pascal 的递归

Pascal 三角形中第 n 行和第 k 列的值是二项式系数 C(n, k)。如果我们能够找到 Pascal 三角形的递归公式——一种基于前一个值生成数组中下一个值的规则——我们就可以在不需要阶乘的情况下计算二项式系数。

对于数组,我们在决定应该使用哪些前一个元素来指定递归时有很大的灵活性。这里的“前一个元素”可以是早期行中的值,也可以是同一行中的早期值。我在最初的描述中提到了 Pascal 三角形的递归:每个值是直接上方两个相邻值的和。以下是如何将其写为递归的方式:

Image

为了理解为什么这个方法有效,我们可以将 Pascal 三角形中的每个值 C(n, k) 解释为一个子集计数问题的答案。我们不再考虑披萨派对上的客人,而是思考在 (1 + x)^(n) 中如何从 n 个因子中选择 k 个 (1 + x) 因子的组合方式。每种选择 k 个因子的方式都会将一个 x^(k) 加到总和中。

例如,假设我们想展开 (1 + x)⁴ 并查看 x³ 的系数应该是多少。这就是二项式系数 C(4, 3)。当我们展开 (1 + x)(1 + x)(1 + x)(1 + x) 时,在每个 (1 + x) 因子中,我们可以选择 1 或 x 来进行相乘。为了得到 x³ 项,我们需要选择一次 1 和三次 x。我们可以选择第一个因子的 1,或者第二个因子的 1,或者第三个因子的 1,或者最后一个因子的 1,因此有四种方法可以得到 x³。这使得 C(4, 3) = 4。

现在只考虑 n – 1 次重复的 (1 + x) 因子。对于 (1 + x)^(n – 1),二项式系数列在帕斯卡三角形的 n – 1 行。如果我们想从 n – 1 行到 n 行,我们需要再乘一个 (1 + x) 的额外因子。同样,我们需要选择 (1 + x) 中的 1 或 x 进行乘法运算。有两种可能性:要么我们在选择 (1 – x)^(k – 1) 的因子时,已经有了 kx,此时我们乘上额外因子的 1,要么我们有 k – 1 次 x,此时我们乘上额外因子的 x 来得到 kx。这种计算给出了我刚才提到的递归关系,我们可以用它逐行构建三角形:

图片

再次强调,这一切意味着,为了得到帕斯卡三角形中的一个条目,我们只需要将它上面两个数字相加。

这个递归关系帮助我们理解我们为 C(4, 3) 计算出的值 4。首先,我们回退到三角形中的一行,从第 4 行回到第 3 行,对应 (1 + x)³ 的展开系数。然后,我们可以选择该行中由 C(3, 2) 计算的 x² 项,并将其乘以 x,或者选择该行中由 C(3, 3) 计算的 x³ 项,并将其乘以 1。只有这两种方式可以在第 4 行生成 x³ 项。这意味着 C(3, 2) + C(3, 3) = C(4, 3)。

项目 19:帕斯卡三角形,逐行计算

图 5-7 显示了一个 Scratch 程序,用来计算帕斯卡三角形的第 n 行,使用我们刚刚讨论的递归关系。

图片

图 5-7:逐行计算帕斯卡三角形

首先,我们询问要计算的行号 ❶。然后,我们进行相应次数的循环,逐行处理到该行。我们使用两个列表来跟踪值:row 是数组中的前一行,newrow 是当前正在计算的行。我们从每一行开始时都会放入一个 1 ➋,然后将 row 中每一对相邻的值相加,得到 newrow 中的下一个值 ➌,正如我们递归关系所规定的那样。在每次循环结束时,自定义的 copy newrow to row 块会将 newrow 复制回 row,为下一次迭代做准备。

结果

图 5-8 显示了该程序的一个示例运行,n = 8 时的结果。

图片

图 5-8:帕斯卡三角形的第 8 行

请注意,由于 Scratch 列表的索引从 1 开始,而不是从 0 开始,因此输出中没有列出第一个元素 C(8, 0) = 1。它确实被计算了,但我们在程序结束时删除了它 ➍。这样,Scratch 的索引号与给定行的 k 值相匹配。只需要想象在列表的开头有一个额外的 1,位于索引 0 处。

通过这种方法,我们可以在计算精确的二项式系数时走得更远。事实上,直到到达帕斯卡三角形的第n = 56 行时,我们才会超过 flintmax。在那里,k = 25 的值是错误的(但k = 26、27 和 28 的值是正确的)。我们直到超过第 1000 行时,才会看到任何二项式系数报告为Infinity

项目 20:绘制帕斯卡三角形

理解帕斯卡三角形的一种方式是思考每一行的二项式系数如何分布。例如,我们可以观察到,值是围绕行的中心对称分布的:1-2-1,1-3-3-1,1-4-6-4-1,依此类推。

从数学角度看,我们可以通过注意到帕斯卡三角形第n行第k列的值与同一行第nk列的值相同来表达每一行的对称性。换句话说,C(n, k) = C(n, nk)。我们可以通过回顾本章早些时候讨论的帕斯卡三角形的子集计数解释来验证这一观察。要从n个元素中创建一个包含k个元素的子集,我们可以指定哪些k个元素应包含,或者哪些nk个元素应包含。

另一个有趣的观察是,帕斯卡三角形第n行的二项式系数之和为 2^(n)。例如,在第n = 3 行时,1 + 3 + 3 + 1 = 8,或者 2³,而在第n = 4 行时,1 + 4 + 6 + 4 + 1 = 16,或者 2⁴。这也与子集计数的解释相关:一个包含n个元素的集合共有 2^(n)个子集,因为对于每个n个元素,存在两个选择:包括该元素或不包括该元素。

另一个值得注意的特征是,每一行的值都是单峰的,这意味着它们先是较小,朝着中间的最大值增加,然后再变小。我们可以通过观察数字本身来发现这些特征,但像值的对称性和单峰结构这样的特征,通过将帕斯卡三角形的行可视化为条形图会更容易观察到。条形图,也称为直方图,是一种图表,其中每个条目的值由条形的高度表示。例如,图 5-9 显示了一个条形图,表示帕斯卡三角形第n = 10 行的值。请注意条形高度的对称性。

Image

图 5-9:帕斯卡三角形第 10 行

要绘制像这样的条形图,从项目 19(图 5-7)开始,该项目用于计算帕斯卡三角形的一行。然后,添加图 5-10 中所示的自定义draw histogram模块。它使用来自 Pen 扩展的模块为每个行中的值绘制一个条形。务必在删除行开头的1之前插入此自定义模块(图 5-7 ➍),否则对称性将被破坏。

Image

图 5-10:从一行绘制条形图

draw histogram 块首先从帕斯卡三角形程序中获取 row 列表,并使用自定义的 find max of row 块(见图 5-11)找到其中的最大值。根据这个最大值,我们计算 horizontal step sizevertical step size,确保图形能够适应舞台 ❶。然后,我们使用循环 ➋ 一次画一个条形图,根据 row 中对应的值向上移动,按条形的数量水平移动,然后再回到起始位置开始下一个条形。

图片

图 5-11:寻找最大行元素

find max of row 块简单地遍历 row 中的所有值,每当找到更高的值时,就更新 max 变量。

结果

图 5-12 显示了三角形的另外两行条形图。

图片

图 5-12:帕斯卡三角形的第 20 行(左)和第 50 行(右)

不论是哪一行,条形图的形状看起来都很相似。事实上,随着 n 的增加,图形越来越接近著名的正态分布的钟形曲线。

破解代码

新的 draw histogram 块足够通用,能够为除帕斯卡三角形行之外的其他数据集绘制条形图。例如,图 5-13 中的代码提示用户输入一系列数字——类似于我们在项目 17 中做的那样(图 4-16),当时我们正在使用差分表——然后调用 draw histogram 块来可视化这些数据。

图片

图 5-13:从数据集中创建条形图

draw histogram 块期望数据以一个名为 row 的列表的形式存在,且长度为 n。只要你的程序具备这些特性,条形图代码就能正常工作。如果你只有 row,那么在绘制条形图之前,你需要提供 n ❶。

图片 编程挑战

5.1 你可能会注意到,在 n! 的值表中,对于较大的 n 值,n! 的值将以多个零结尾。编写一个程序来预测给定 n 时会有多少个零。特别是,你能预测 25! 的值将以多少个零结尾吗?

5.2 编写一个程序,通过沿对角线向下遍历,从帕斯卡三角形中提取数列。利用它来考虑帕斯卡三角形中的对角线,该对角线包含 C(n, 2) 的值。根据我们在第四章中讨论的数形数字,识别这一数列。

5.3 作为一个整数,二项式系数的值可以是偶数或奇数。修改帕斯卡三角形的行递推程序(项目 19),使其仅显示 0 或 1,取决于二项式系数是偶数还是奇数。看看你能找到什么样的模式。

操作表有所有答案

操作表是一个值表,显示在不同输入组合下数学运算的结果。例如,你可能通过乘法表(乘法口诀表)学习了基本的乘法。此类操作表通常有九行九列,它提供任何乘法问题的答案,其中被乘数和乘数通过行和列来表示。例如,如果你想计算 6 乘 7,你会在顶部的行(索引行)中找到 6,然后在左侧的列(索引列)中找到 7。列 6 和行 7 交叉处的值是 42。

我们不可能为所有正整数创建一个完整的乘法表,因为它们是无限的。然而,通常用于单个数字的九乘九乘法表包含了我们计算更长数字乘积所需的所有信息。将这种情况与我们在第二章中讨论的模运算做比较。一旦你选择了一个模数,比如n,那么重要的只是一个数字除以n后的余数。余数的可能值只有n个,从 0 到n - 1。这意味着基于模运算的任何操作表都会有一个有限的条目数,一个nn的表格会包含所有可能的条目。

有限操作表,包含行和列,符合数组的定义。在下一个项目中,我们将使用 Scratch 生成给定模数的操作表,然后看看我们能发现哪些模式。

项目 21:使用模运算生成无限操作表

我们的程序会提示用户输入一个模数n,然后询问他们选择哪种操作:加法还是乘法。接着,它会构建一个nn的表格,展示所选操作下的所有可能结果,模n。例如,假设模数是 7。加法表应该显示从 0 到 6 的每一对数字的和,取模 7。例如,列 6 和行 2 交汇处的条目应该显示(6 + 2) mod 7,结果是 1。乘法表则对乘法执行相同操作:列 6 和行 2 交汇处的条目应该显示(6 ⋅ 2) mod 7,结果是 5。

Image

图 5-14:询问模数和操作类型

我们将把表格的每一行构建为 Scratch 列表中的一个条目,列表名为table。首先,我们需要一些自定义积木来帮助我们整理。图 5-14 展示了setup积木,它在程序开始时被调用。

setup积木中,我们首先删除任何先前版本的table ❶。然后,我们提示用户输入modulus(一个整数)和operation+*)。一旦得到这些信息,我们调用Make index row积木 ➋,如图 5-15 所示。

Image

图 5-15:构建操作表的索引行

Make index row块在表格的顶部添加了一个索引行,包含列的标签,从 0 到不包括模数,同时附有一个+*符号,表示是加法还是乘法。我们通过一系列join块将行内容构建为一个字符串,保存在row变量中。我们还添加了一行额外的破折号,以帮助区分列索引标签和表格本身的内容。

这块中的大部分工作都集中在使表格看起来整齐,无论列中包含的是一位数还是两位数,都能均匀地间隔。为了帮助排版,我们使用了自定义的pad块❶(在图 5-16 中定义),它根据字符串的长度在给定的字符串前添加一个或两个空格,确保每列中的所有数字能够整齐排列。如果我们知道需要多少空格来使数字对齐,可以直接将它们放进去,就像我们在索引行➋中做的那样;但如果不知道,最好让程序来决定。

注意

这两个 set x 命令位于 pad 块中,看起来相同,但实际上是不同的。第一个命令执行时 x 的长度为 1,其中有两个空格,而底部的命令执行时 x 的长度为 2,其中只有一个空格。

Image

图 5-16:为字符串填充一个或两个空格

现在我们有了所有这些辅助块,我们可以构建一个格式良好的操作表,主程序代码在图 5-17 中。

Image

图 5-17:一次构建一行的操作表

在调用setup块后,我们使用两个嵌套循环按行构建操作表。外循环递增变量i,表示当前行的索引号,而内循环递增变量j,表示行内列的索引号。每行以该行的索引号开始,后跟一个冒号 ❶。这是必要的,因为 Scratch 中的列表索引从 1 开始,而为了我们的目的,从 0 开始编号行和列会更自然。

真实的工作是在内循环的if...else块内完成的➋。根据所需的操作,我们计算i + ji * j,并对结果取mod,从而得到操作表中的当前条目。再次使用pad块,在将条目加入正在构建的行之前,添加适当数量的空格。在外循环的每个周期结束时,我们将拥有一行完整的数据,并将其添加到table列表中。

结果

图 5-18 显示了小模数n = 7 的加法和乘法表。

Image

图 5-18:模 7 的运算表

注意,每个表格从一个索引行开始,显示列的标签,从 0 到 6. 表格左侧还有一个索引列,显示行的标签(同样是从 0 到 6)。

使用这些表格,我们可以找到任意两个整数的和或积,模 7。例如,假设我们要计算(152 + 263) mod 7. 首先,我们需要对每个输入数进行模 7 运算:152 mod 7 是 5,263 mod 7 是 4. 接下来,我们在加法表中查找列 5 与行 4 的交点。交点处的值是 2,所以答案就是 2。通过这种方式,尽管表格只有七行七列,但它们可以给出任何正整数的答案。我们只需先对整数进行模 7 运算。

在这些模 7 运算表中,有几个模式可以观察到:

Image 对于加法表(见图 5-18 左侧),值是逐行循环的,每行的值向左移动一列,从一行到下一行。每一行最左边一列的值会回绕,成为下一行最右边一列的值。

Image 加法表中第 0 行的值与表格的索引行的值相匹配。这表明 0 是加法单位元:将 0 加到一个数上不会改变这个数。就 0 而言,模加法与普通加法完全相同。

Image 一个数n加法逆元是需要加到n上得到 0 的数。在普通加法中,一个数的加法逆元是该数的相反数。例如,3 的加法逆元是-3。 然而,在模运算中,我们不需要使用负数来表示加法逆元。注意,在加法表的每一行都有一个 0。这意味着无论从哪个数开始,我们都能找到一个正数加到它上面得到 0。也就是说,每个数都有一个加法逆元,针对一个特定的模数。例如,第 3 行在第 4 列有一个 0,所以 3 + 4 = 0 mod 7。

Image 对于乘法表(见图 5-18 右侧),第 1 行的值与索引行的值相匹配。这意味着 1 是乘法单位元:将一个数乘以 1 不会改变这个数。从这个意义上说,模乘法就像常规乘法一样。

Image 在乘法表中,每一行和每一列都有一个 1,除了第 0 行和第 0 列。也就是说,每个非零数都可以与另一个数相乘,这个数叫做乘法逆元,它的乘积是 1 mod 7。

这些观察不仅仅适用于模 7;它们对任何模数都适用。试着用其他模数运行程序,你会看到相同的模式。

破解代码

当你开始为更大的模数制作运算表时,你会发现每一行的内容将不再适合显示在 Scratch 舞台上。不过,完整的表格仍然存在于后台。如果你想一次性查看完整表格,你可以将table列表导出到文本文件中,并在文本编辑器或其他程序中打开,正如第二章中讨论的那样。表格如果使用等宽字体(如 Courier)查看会更清晰,其中每个字符的宽度相同,因此所有列都会对齐。例如,图 5-19 显示了模 12 下的乘法运算表的导出版本。

Image

图 5-19:模 12 的乘法表

请注意,这个表格在某些行和列中包含多个 0。第 0 行和第 0 列中的 0 是可以预期的:乘以 0 总是得到 0。其他的 0 则更有趣。它们的出现是因为 12 不是一个质数。12 的每一种因式分解(事实上,任何 12 的倍数的因式分解)都会给出两个数,它们的乘积能被 12 整除,因此它们的乘积对 12 取模结果为 0。例如,在第 4 行,我们在第 0 列、第 3 列、第 6 列和第 9 列都有 0。实际上,4 ⋅ 3、4 ⋅ 6 和 4 ⋅ 9 都是两个数相乘得到 0 mod 12 的例子,尽管最初这两个数都不是 0 mod 12。这些值被称为零因子,它们在实数的算术中并不存在。它们仅存在于模运算中,并且只有当模数不是质数时才会出现。

如果我们划掉所有有额外 0 的行和列,如图 5-20 所示,剩下的未被划掉的值就形成了一个更小、更简化的乘法表。

Image

图 5-20:模 12 的简化乘法表

我们剩下一个 4×4 的值表,行和列的索引分别是 1、5、7 和 11。这个较小的表格中每一行和每一列都有一个 1,这意味着剩下的数都有乘法逆元。

Image 编程挑战

5.4 如果两个数除了 1 以外没有共同因子,则称它们为互质。例如,6 和 35 是互质的,但 35 和 49 不是(它们都能被 7 整除)。请重写运算表的代码,使其能够为给定的模数n生成一个简化的乘法表,该表仅包括与n互质的数对应的行和列。例如,对于模 12 的乘法运算,它应生成一个只包括图 5-20 中未被划掉的四行四列的表格。如果给定的模数是一个质数p,它应生成一个(p – 1)×(p – 1)的表格,省略第 0 行和第 0 列。

5.5 一个质数p原根是一个数,它的各次幂对p取模时能够生成从 1 到p – 1 的所有整数。例如,2 是 11 的原根,因为:

Image 2¹⁰ = 1,024,且 1,024 mod 11 = 1

Image 2¹ = 2

Image 2⁸ = 256,且 256 mod 11 = 3

Image 2² = 4

Image 2⁴ = 16,且 16 mod 11 = 5

Image 2⁹ = 512,且 512 mod 11 = 6

Image 2⁷ = 128,且 128 mod 11 = 7

Image 2³ = 8

Image 2⁶ = 64,且 64 mod 11 = 9

Image 2⁵ = 32,且 32 mod 11 = 10

另一方面,2 不是 7 的原根,因为 2 的模 7 次幂只有 1、2 和 4。编写一个程序,要求输入一个素数 p,并返回找到的第一个原根。

5.6 电子表格程序如 Excel 可以轻松导入文本文件,只要文件是 CSV 格式。该格式要求每行输入数据中的每个条目之间用逗号分隔。修改 Scratch 中运算表的代码,使导出的文件格式符合这种要求。

结论

数组是二维的数字表格,通过行和列来索引。表格中一个条目的位置提供了两项信息——行号和列号——这些信息有时可以用来在公式中确定该表格条目的值。Scratch 没有内置的 array 类型,但你可以使用列表来表示数组,每个值包含某一行的所有数组元素,正如我们在构建运算表时所做的那样。另一种表示数组的方法是为数组的每一行构建一个列表,就像我们为帕斯卡三角形所做的那样(一个列表的列表)。如何表示数组取决于你想如何使用它。Scratch 非常灵活,能够满足你想做的任何操作!

第六章:## 制作密码,并破解它们

Image

假设你有一个秘密,想要与朋友分享。你可以把它写下来并传递给他们,但别人可能会看到。或者你可以悄悄地告诉他们,但也许有人会偷听。想象一下,如果任何拦截你信息的人都无法理解它,那该多好。一个秘密的密码!

在本章中,我们将使用 Scratch 来练习密码学,即秘密编码的艺术。我们将编写程序,使用几种不同的密码技术,考虑它们的优缺点,并用它们来编码信息。然而,写下加密信息只是工作的一半。必须有一种方法可以撤销这个编码,否则没有人能读取它,甚至包括你希望阅读的人。我们也将探讨故事的另一面,看看如何解码秘密信息。

凯撒的移位密码

秘密编码在军事应用中非常重要,因为指挥官需要一种方法来让前线的士兵了解他们的作战计划,最好是在计划落入敌人手中时不会被揭示。罗马将军尤利乌斯·凯撒被认为发明了最早的已知信息加密方法,正是出于这个目的。故事是这样的:凯撒在与士兵通信时使用了一种简单的替换系统,在这个系统中,消息中的所有字母(我想是用拉丁语写的)都被移位了三位。因此,如果消息中有一个 A,它会被替换为 D,每个 B 会被替换为 E,以此类推。这种通过字母表移位创建加密消息的方法如今被称为凯撒密码。移位不一定是三位;任何位移都能将可读的消息变成完全没有意义的内容。

密码是一个古老的词,用来表示一种程序或谜题。以前,算术运算被称为密码运算,因为大乘法或长除法中的数字操作看起来像一个复杂的谜题。将信息转换成一种伪装形式(加密)并从加密形式恢复原始信息(解密)的过程也像是在解谜,因此今天密码更多地指代一种编码。

凯撒密码的谜题可以通过一个双行表格来解决。一行显示从 A 到 Z 的字母表,另一行显示字母表经过适当位移后的结果:

Image

将表格的行围绕成一个圆圈有助于表示,当我们到达字母表的末尾时,应该重新回到字母表的开头。想象两个圆圈,一个是内圈字母表,一个是外圈字母表,如图 6-1 所示。

Image

图 6-1:围绕圆圈进行移位

如果这些圆圈能够独立旋转,那么它们可以用来展示带有不同移位的凯撒密码。你可以制作这样的工具,手动加密和解密消息,每次一个字母。但为什么要做这么多工作呢,Scratch 可以为你完成这些任务。

项目 22:通过凯撒移位加密

在这个项目中,我们将使用 Scratch 来自动化凯撒密码加密消息的过程。首先,我们将字母表放入一个列表中,使用图 6-2 中显示的自定义块。这样,我们就可以通过使用索引这些字母的数字来操作字母。

Image

图 6-2:将字母表放入列表中

自定义的 Alphabet 块通过一次添加一个字母,构建名为 alphabet 的列表。如果需要,你可以将字母表扩展到包括其他符号,比如空格、数字和标点符号,但现在我们先保持字母表只有 A 到 Z 的 26 个字母。

接下来,我们将创建一个名为 Initialize 的自定义块来设置程序(见图 6-3)。

Image

图 6-3:凯撒密码的设置代码

在这个块中,在调用 Alphabet 后,我们根据所选字母表中的字符数定义 size。接着,我们提示用户输入移位大小和要加密的消息。然后,使用自定义的 Scramble 块(如图 6-4 所示)根据所选的移位大小构建一个打乱的字母表。

Image

图 6-4:构建一个打乱的字母表

Scramble 块通过对原始 alphabet 列表中的每个字母应用移位,构建一个名为 scrambled 的列表。从理论上讲,scrambled 列表中索引为 i 的字母应该与 alphabet 列表中索引为 shift + i 的字母相同。但事情并没有那么简单,因为有时我们需要回绕到字母表的起始位置。自定义的 Wrap 块(也如图 6-4 所示)使用 mod 来在必要时重新计算索引。由于模运算期望从最小值 0 开始,而 Scratch 列表的索引从 1 开始,因此我们需要在取 mod 之前减去 1,然后再将结果索引加回 1。

注意

加密代码被拆分为一个独立的块,这样我们可以方便地在后续程序中修改加密方法。我们需要做的只是改变如何构建打乱的加密字母表。

图 6-5 显示了主要的程序栈。

Image

图 6-5:凯撒密码的主要代码

在调用Initialize后,我们逐个字符地遍历提供的消息。if...else语句块检查当前消息字符是否包含在alphabet中。如果包含,程序会查找对应的移位字符,并将其添加到encrypted变量中的加密消息。如果字符不在字母表中——例如,如果是空格或标点符号——程序则将该字符原封不动地传递到加密消息中。

结果

图 6-6 展示了该程序运行的两个示例结果。

图片

图 6-6:加密和解密一条消息

在左侧,我们指定了移位数为 3,以加密消息“hello!” Scratch 将字符视为大写字母,因此当每个字母移位 3 个位置时,我们得到加密后的消息“KHOOR!”。按照我们编写的程序,标点符号不会发生变化。

这个程序(以及凯撒密码本身)的一大便利功能是,它不仅可以用来加密,还可以用来解密。右侧的输出展示了我们如何将加密后的消息“KHOOR!”再移位 23 位,从而恢复原始消息“HELLO!”(现在全部为大写字母)。由于字母表大小为 26,初始移位 3 后,再移位 23 就回到了 26,即没有移位。输入第二次移位为-3 而不是 23 也是有效的,因为在模 26 的情况下,往回移动 3 步或向前移动 23 步,结果是一样的。

破解代码

由于非字母字符不会被加密,我们的代码保留了单词间的空格,即使单词本身被打乱。这有时会成为破译的线索,提供原始消息内容的提示:例如,一个单字可能是aI,而theand是最常见的三字词之一。如果将空格字符也包括在字母表中,它将有助于隐藏这些线索,使加密消息中的原始单词分隔不那么显眼。(此外,如果字母表的大小是素数,某些加密技术会更有效;这也是将空格和其他几个标点符号加入字母表的另一个原因!)

幸运的是,我们编写的代码使得它无论字母表的长度如何都能正常工作,因此添加额外字符非常简单。将字母表重新计算回起始位置所需的模运算依赖于size变量,这个变量根据程序开始时Alphabet列表的长度进行设置。这样,如果字母表发生变化,模运算会自动调整。

这里有一个可能的改进:经过几次加密和解密后,你可能会发现你想要有一个记录 Scratch 所做工作的历史。很容易添加一个日志,将程序的所有输入和输出集中记录在一个地方。你只需定义一个名为log的列表,然后添加一些代码块(如图 6-7 所示)将数据写入该列表。由于列表的内容可以保存到文件中,保持这个日志使得将加密的消息复制到另一个程序(如文本编辑器或电子邮件客户端),或重新输入到 Scratch 程序中的输入框进行进一步处理变得非常简单。

Image

图 6-7:添加日志文件

要创建日志文件,添加图 6-7 中所示的前两个代码块栈,将shiftmessage的值记录到Initialize块(图 6-3)的末尾。然后,将第三个代码块栈,记录加密消息,添加到主程序栈(图 6-5)的末尾。图 6-7 中的最后一个栈在按下向下箭头键时会清除日志。你可以利用这个功能来掩盖你的痕迹,或者在日志变得太长时重置它。

图 6-8 展示了当我们通过-3 而不是 23 的移位解密“KHOOR!”时,日志的样子。

Image

图 6-8:对“HELLO!”的另一种解密方式

因为log列表只有在按下向下箭头键时才会重置,它会在程序的多次运行中继续存储值,即使shiftmessageencrypted的值被覆盖。

项目 23:破解凯撒密码

凯撒密码在当时是有效的,也许因为大多数人都不太懂阅读。但实际上,这并不是一种非常安全的加密方法。如果你有一条消息,并且知道它是通过移位加密的,你只需要通过所有可能的移位因子来传递消息,其中一个将给你解密后的文本。可能的移位数目仅为字母表的大小——在这种情况下,是 26。正如我们在这个项目中将看到的,Scratch 几乎可以瞬间完成所有可能性的计算。

要创建一个自动解密使用凯撒移位加密的消息的程序,保留前一个项目中的支持代码块,但修改主程序的when clicked程序栈,如图 6-9 所示。这个更新的代码将生成一个列表,应用所有可能的移位到加密消息。当你滚动查看列表时,合适的移位和解码结果应该会立即显现。

Image

图 6-9:进行所有可能的移位

我们从初始的shift值为1 ❶开始,使用一个循环逐步位移整个字母表。然后,使用内部循环遍历消息中的字母,并使用当前的位移因子对其进行解码。在内部循环结束时,我们得到了一个可能的解密消息,并将其添加到Shifts列表中。与之前的项目一样,我们使用if ... else模块,如果字母不在字母表中,就让其原封不动地通过 ➋。

Image

图 6-10:初始化破解程序

我们还需要修改前一个项目中的Initialize模块,如图 6-10 所示。

这个更新后的Initialize模块通过删除之前的内容来管理Shifts列表 ❶。我们仍然提示输入消息,但不再需要提示输入位移,因为代码会自动生成所有可能的位移。

结果

假设你截获了消息“UVA CLYF DLSS OPKKLU, DHZ PA?”将其输入解密程序(你可以复制粘贴或手动输入)以发现图 6-11 中的消息。

解密后的消息位于Shifts列表的第 19 行,这告诉我们需要使用 19 的位移来恢复消息。所以原始消息必须是使用 26 – 19 = 7 的位移进行加密的。使用 7 的位移,N 变成 U,O 变成 V,T 变成 A(绕回),依此类推。

Image

图 6-11:揭示一个位移后的信息

Image 编程挑战

6.1 使用 Scratch 中的图形设计一个凯撒密码魔法解码戒指。该程序应像图 6-1 中的戒指一样进行动画演示,使得位移后的字母与未位移的字母对齐。

6.2 在电影2001 太空漫游中,感知计算机 HAL 控制着一艘载有两名宇航员前往木星执行任务的宇宙飞船,并试图谋杀船员。对“HAL”应用 1 的凯撒位移,看看是否能发现隐藏的关于谁制造了它的秘密信息。

6.3 如果幸运的话,凯撒位移可以在不同语言之间进行转换!对“yes”应用 16 的位移,将其翻译成法语。

6.4 找到凯撒位移解密消息“DROBO GSVV LO K RYD DSWO SX DRO YVN DYGX DYXSQRD!”

更多的替换密码

凯撒密码是一种替换密码的示例,在这种密码中,字母表中的每个字母都被替换为另一个字母。实际上,替换密码通过混淆字母表,使得曾经可读的单词看起来陌生。使用凯撒密码时,我们通过将所有字母按设定的位数进行位移来进行混淆,但任何其他混淆技巧同样适用于加密文本。实际上,我们最初的凯撒密码程序的一个优点是,它的主要栈(图 6-5 中的代码)可以与任何混淆字母表一起使用,而不仅仅是通过位移生成的字母表。我们可以修改Scramble块(图 6-4)以某种方式构建scrambled列表,程序将相应地对消息进行编码。

如果我们不局限于位移,那么有多少种混淆的可能性呢?嗯,我们有 26 种选择来决定字母 A 变成什么,然后是 25 种选择来决定 B 变成什么,C 有 24 种选择,依此类推。总的来说,这给我们提供了 26!(26 的阶乘)种排列方式,或者超过 400 千亿亿(4 ⋅ 10²⁶)种混淆字母表的方式。凯撒密码仅考虑其中的 25 种排列方式(假设你不想使用 0 的位移)。

凯撒密码的优势在于,只需要一个数字——位移因子——就能确定混淆字母表。换句话说,如果你想给朋友提供解码你位移信息的密钥,你所需要做的就是将这个数字悄悄告诉他们。加密本质上是通过位移因子进行加法,而解密则是撤销加法。你可以通过减去s或加上 26 – s来撤销s的位移。相比之下,如果你想让你的朋友了解其他混淆方案(可能是从 26!个可能的排列中随机选择),你需要提供 25 个独立的信息,才能让他们知道如何解码你的信息。你得说清楚从 A 到 Y 每个字母变成什么,之后 Z 必须放在剩下的唯一位置。这需要大量额外的信息来跟踪!

通过模乘法加密

这是另一个想法。凯撒密码通过位移来混淆字母表,这可以被认为是模 26 加法。假设我们通过乘法模 26 而不是加法来混淆字母表会怎样?也就是说,我们可以将每个字母在字母表中的位置与某个数字进行模 26 相乘,得到应该替换的字母的位置。为了让我们入门,图 6-12 展示了一个模 26 乘法的操作表。(你可以使用第五章中项目 21 的代码自己生成此表。)

图片

图 6-12:模 26 乘法

这个表格列出了两个数字模 26 的所有可能乘积。例如,要将 9 乘以 5,可以查看第 9 行和第 5 列。它们交汇处的值是 19,所以 9 ⋅ 5 是 19 mod 26。这个结果是合理的,因为我们知道 9 ⋅ 5 实际上是 45,而 45 除以 26 的余数是 19。

为了使这张表的一行(或一列)能够成功地扰乱字母表,它需要包括从 0 到 25 的每个数字——也就是说,它必须是表格索引的一个排列(或重新排序)。并不是每一行都有效。例如,第 4 行的数字是(0, 4, 8, . . .),并且在第 13 列开始重复。而第 13 行的值则在 0 和 13 之间交替,这样会将信息中的所有字母都转换成 A 或 N。这并不太有用!

可用的行(和列)是那些与 26 互质的行和列,也就是说,它们除了 1 以外与 26 没有其他公因数。共有 12 行:第 1、3、5、7、9、11、15、17、19、21、23 和 25 行。这些行包含 0 到 25 之间数字的排列。例如,字母表中的字母乘以 3 mod 26 会得到以下密码:

图片

你可以通过每次向前数三个字母来构建扰乱的行(A B C, D E F, G H I, . . .),在 Z 处循环重新开始,直到整个字母表都被分配完。

要使用我们在项目 22 中的代码实现基于乘法的密码,只需对我们之前的Scramble模块进行一个小小的修改,如图 6-13 所示。

图片

图 6-13:乘法,别加法!

通过将+改为*,我们告诉程序使用模乘法而不是模加法来加密消息。

通过模乘法解密

现在让我们考虑解密。我们通过撤销加法来解密凯撒移位。例如,要撤销向右移动 3 个字母的移位,我们向左移动 3 个字母(或者说 26 – 3 = 23,向右移动 23 个字母,因为算术是模 26 的)。要撤销乘法 3 的操作,我们需要除以 3,但在模运算中无法处理分数或小数。如果原始的模乘法结果是 14,我们不能反过来除以 3,去说我们想要字母表中的字母 4.66。

幸运的是,仍然有方法可以撤销乘法操作。我们需要找到乘数的模反元素,并将其作为解密密钥。模反元素是乘法逆元,其中乘法使用模运算来解释。给定模数m——在本例中为 26——将一个数字与其模反元素相乘会得到 1 mod m。例如,在图 6-12 中的乘法表中,注意到在第 3 行第 9 列有一个 1。这告诉我们 9 是 3 在模 26 下的模反元素。为了验证,检查数学:3 ⋅ 9 = 27,而 27 mod 26 = 1。

如果你习惯于算术给出一个乘法逆元作为分数,那么看到一个乘法逆元是整数,甚至是一个大于原始数字的整数,可能会觉得很奇怪。乘法逆元不应该是小的东西吗?毕竟,在普通算术中,3 的乘法逆元是 1/3,因为 3 ⋅ 1/3 = 1。然而,对于逆元来说,唯一重要的是乘积为 1。在普通算术中,你通过将n乘以 1/n得到 1。而在模算术中,你通过将n乘以另一个整数得到 1。

由于 9 是 3 在模 26 下的乘法逆元,我们可以使用 9 作为乘数来解密一个使用 3 作为乘数加密的消息。图 6-14 中的日志证实了这一点。

Image

图 6-14:揭示被乘加密的消息

首先,我们使用 3 作为乘数对消息“Hello!”进行加密。然后,我们使用 9,它是 3 在模 26 下的模反元素,来“加密”结果,从而恢复原始消息。

项目 24:模反元素是关键

我们已经确定,要恢复通过模乘法加密的消息,我们需要模反元素。在这个项目中,我们将研究如何找到模反元素来帮助解密过程。

寻找模反元素的一种方法是直接寻找它:研究乘法模字母表大小的运算表,查看哪个m的倍数能得到答案 1。这个倍数就是模反元素。这就是我们在上一节采取的方法,检查图 6-12 中的模 26 乘法表,确定 3 的模反元素是 9。现在,让我们使用 Scratch 自动化这个过程,这样我们就能轻松找到任何数字和任何模数的模反元素。图 6-15 展示了如何操作。

Image

图 6-15:寻找模反元素

程序首先提示输入模数和待求逆的数字。这两个值必须是互质的,才能使数字具有模块逆。我们通过使用在第二章中的项目 9 中构建的自定义gcd模块来进行测试(见图 2-17 第 38 页)。如果最大公约数是1,则这两个值是互质的,因此我们使用一个循环来测试每一个可能的逆,从1开始,直到找到使x * inverse mod modulus = 1 ❶成立的那个逆。这相当于在操作表的一行中扫描,直到找到包含 1 的那一列。

结果

图 6-16 展示了一些输出,演示了代码的工作原理。

图片

图 6-16:模块逆计算

程序确认 9 是 3 模 26 的逆运算结果。它还正确地得出结论,4 模 26 没有模块逆,因为 4 和 26 不是互质的。

破解代码

我们在模块逆运算程序中使用的试错法,对于小字母表(也就是说小模数)来说效果不错,但如果能有一个更专注的算法来快速计算任何数在任意模数下的模块逆,就更好了。与gcd模块一样,我们可以重用之前编写的部分程序来实现这一目标:通过欧几里得算法计算最大公约数,该算法来源于第二章中的项目 9。

请记住,欧几里得算法通过一系列除法来计算两个给定数字ba的最大公约数d,直到最后得到最大公约数作为最后一个非零余数。要使用这个算法来查找模块逆,请将b设置为模数,将a设置为你想要找到其逆的数字。按常规方法执行算法,跟踪除法运算。然后,向后推算,寻找一个方程,使得 1 在一边,ab在另一边。

例如,如果我们想找到 3 模 26 的模块逆,我们可以首先将这两个数字传入欧几里得算法:

图片

最后的非零余数 1 就是最大公约数。接下来,我们需要回溯这些步骤,找到 26 和 3 的组合,使其等于 1。将算法中的中间方程改写为 1 = 3 – 2,再将算法中的顶部方程改写为 2 = 26 – 8 ⋅ 3。然后,将 26 – 8 ⋅ 3 代入中间方程中的 2,得到 1 = 3 – (26 – 8 ⋅ 3)。总体来看,这个方程右边有一个 3 和括号中 8 个 3,所以 1 + 8 = 9 个 3,再加上–1 ⋅ 26。我们可以将这些部分合并,得到 1 = 9 ⋅ 3 – 26。这告诉我们 1 = 9 ⋅ 3 mod 26,因此 9 是 3 的模块逆。接下来会有一个编程挑战,旨在让这种方法能够普遍适用。

使用线性变换的更多加密选项

你可能认为,从模加法转为模乘法我们并没有获得太多。毕竟,对于一个 26 个字母的字母表,有 26 种可能的移位字母表,而只有 12 种可能的乘法字母表。然而,通过结合这两种方法:乘法移位,我们可以得到更多的扰乱字母表。也就是说,我们可以将每个通过乘法获得的 12 个扰乱字母表应用 26 种可能的移位,从而得到 26 ⋅ 12 = 312 种潜在的字母表。这样可以隐藏更多的信息。

这个组合方法的一般规则是,我们通过用字母索引为i的字母替换字母索引为mi + s的字母来扰乱字母表。换句话说,我们先将索引乘以m,然后加上常数s。如果我们为ms选择了值,并为每个i值绘制这个公式的结果,我们会发现图形显示的是一条具有m斜率的直线。例如,假设我们将m设置为 2,s设置为 3。如果我们绘制函数 2i + 3 的图形,线条将通过(0, 3)、(1, 5)、(2, 7)、(3, 9)、(4, 11)、(5, 13)和(6, 15)这些点,如图 6-17 所示。

Image

图 6-17:2i* + 3 的图形*

因为它们产生直线,“乘法加常数”公式像mi + s被称为线性函数。因此,“乘法和移位”加密过程被称为线性变换。只要乘数m被选为与字母表大小互质,这种加密方法就能奏效。

项目 25:通过线性变换进行加密

让我们将项目 22 中的凯撒密码代码改编为处理线性变换。首先,我们将更新Initialize模块,如图 6-18 所示。

Image

图 6-18:线性变换加密

现在我们同时提示输入移位值和乘数,而不再仅仅是之前的移位值。还要注意新增的代码,用来维护log列表。接下来,我们将修改Scramble模块,以匹配图 6-19。

Image

图 6-19:使用线性变换进行封装

这个更新后的Scramble模块仍然使用图 6-4 中的原始Wrap模块。Wrap的输入实现了mi + s的线性函数来扰乱字母表。

结果

图 6-20 展示了线性变换程序在实际应用中的例子。它使用乘数 3 和移位 5 加密了消息“TOP SECRET!”

Image

图 6-20:使用线性变换加密

为了解密得到的消息,我们需要撤销移位和乘法。撤销移位很简单:对于原始移位s,我们执行移位–s。正如我们所讨论的,要撤销乘法m,我们可以乘以m的模块逆。我们将通过运行加密消息两次,分别执行撤销移位和撤销乘法。

首先,我们通过移位–5 撤销移位 5,正如图 6-21 所示。我们使用乘数 1,这意味着我们实际上并未进行任何乘法。

Image

图 6-21:撤销移位

接下来,我们需要将结果反馈到线性变换程序中,撤销乘法。我们知道,3 mod 26 的模块逆是 9,所以在图 6-22 中我们使用的乘数就是 9。这一次,我们使用移位 0,仅关注撤销乘法。结果就是原始消息。

Image

图 6-22:撤销乘法

总的来说,只有两个数字决定了线性变换加密:移位和乘数。同样,解密消息也只需要这两个数字。它是一个非常紧凑的密钥!

Image 编程挑战

6.5 你已经看到如何通过两个步骤解密线性变换密码,首先撤销移位,然后撤销乘法。是否可以将这两个步骤合并成一个步骤呢?例如,能否通过运行一次线性变换程序来解密消息“MXA JTNGTM!”?假设移位为–5,乘数为 9。从图 6-20 来看,实际情况是这样做行不通,但有一个不同的移位因子可以奏效。想一想它可能是什么,以及为什么。

6.6 利用你从挑战 6.5 中学到的知识,编写一个 Scratch 程序,输入线性变换加密的乘数和移位,并计算出模块逆和适当的移位,以便一步完成解密。

6.7 如果乘数与字母表大小不是互质的,线性变换加密会出什么问题?

6.8 修改项目 23 的代码,用于破解凯撒移位密码(图 6-9),使得程序列出所有 312 种可能的线性变换密码的所有解密结果。

6.9 任何字母表的排列或打乱都可以作为替换密码的密钥。编写一个 Scratch 程序,生成字母表的随机打乱。pick random操作块可能会在这方面派上用场。

6.10 编写一个程序,使用欧几里得算法计算模逆,如《破解代码》一书中第 115 页所讨论的。你可能需要将商和余数记录为列表,并通过反向追溯列表中的步骤来解开过程。

不可破解的一次性密钥密码

一次性密钥密码 是一种技术,使用一种文本(密钥)来加密或解密另一种文本(信息)。加密过程将信息的第一个字符按密钥第一个字符的位置进行位移。然后将第二个字符按密钥第二个字符的位置进行位移,依此类推。信息中的每个字符都有自己的加密方案,字母表基本上为每个字母进行重新排列。

举个例子,假设我们想要使用“Scratch”这个词作为密钥来加密消息“Hello”。(密钥的长度应该至少与消息长度相同,最好更长。)密钥的第一个字母 S 是字母表中的第 19 个字母,因此我们需要将消息中的第一个字母 H 按 19 个位置进行位移,得到字母 A(在 Z 处换行)。密钥的第二个字母 C 是字母表中的第 3 个字母,因此我们需要将消息中的第二个字母 E 按 3 个位置进行位移,得到字母 H。如果我们继续这样做,最终加密得到的消息是“AHDMI”。使用密钥逆向进行位移即可解密消息。

通常,一次性密钥用于共享较长的编码信息,使用相应更长的密钥。密钥可能是一个字面意义上的记事本,里面有一长串随机字母手写序列,用来确定需要使用的位移。或者它也可以是发送方和接收方商定共享的任何其他文本,比如歌曲的歌词、书中的一段话,或是互联网上发布的文章。关键是要保持密钥的保密性。

一次性密钥比我们在本章早些时候讨论的简单密码更强大。事实上,如果你使用一个真正随机的字符序列作为密钥,并且永远不重复使用相同的密钥(因此是一次性密钥),你的编码消息将是无法破解的。对于任何基于字母表固定排列的秘密代码,这一点无法成立,无论是凯撒密码、线性变换,还是其他任何混排算法。这是因为,正如前面所提到的,英语语言中存在一些模式和规律,提供了破解消息的线索。

即使我们移除单词之间的空格,以掩盖像单字词(几乎确定是Ia)和常见的三字词(很可能是theand)等明显的线索,仍然会有其他模式在混淆的字母表中显现出来。例如,只有某些字母在英语中常常连续出现两次:有很多双重 E、S 和 T 的单词,但双重 A 或双重 Z 的单词就少得多,几乎没有双重 Q 或双重 J 的单词。像 TH 和 CK 这样的双字母组合也很常见。

更广泛地说,像 E 和 A 这样的字母在给定的英文文本中出现的频率远高于 Q 和 Z,因此统计加密消息中每个字母的频率——即每个字母出现的次数——尤其是在长消息中,能够提供有关加密方案的有力线索。为了验证这一点,我们将编写一个程序来计算字母的频率,并用一个使用凯撒移位加密的文本进行测试。然后,我们将编写一个程序来实现一次性密码本加密,并测试其结果。我们应该会看到一次性密码本加密消除了任何规律性的模式。

项目 26:破解密码的频率分析

本项目的目标是创建一个程序,用于统计加密消息中每个字母的出现次数,这可能会根据不同字母在典型的未加密英文文本中的使用频率,提供关于加密方案的线索。我们将需要利用 Scratch 的文本处理能力来实现这一目标。特别是,我们将结合使用绿色的length ofletter of积木,在循环中逐个字符地检查文本。图 6-23 展示了该程序。

图片

图 6-23:统计每个字母的使用次数

我们使用两个独立的列表:alphabet,它在程序开始时由图 6-2 中首次定义的Alphabet积木构建;以及frequency,我们将每个字母在字母表中出现的次数存储在此列表中。首先,我们将frequency填充为 26 个 0。然后,我们从用户处获取要处理的文本,并逐个字符地循环遍历它。item # of letter i of text in alphabet会反向查找文本中第i个字符在alphabet中的位置。例如,如果字符是 C,它会返回3,即 C 在alphabet列表中的位置。我们将在frequency列表中相应位置上加 1,以统计该字符的出现次数❶。

破解代码

图 6-24 中的代码是对程序的一个小改进,添加了标签到frequency列表中。将其放在repeat循环之后。

图片

图 6-24:为频率列表中的每一项添加标签

在完成所有计数后,这段额外的代码会为frequency列表中的每一项标注它所代表的字母。这样,你就不需要时刻提醒自己字母 A 是 1,字母 B 是 2,依此类推。

结果

让我们启动频率分析程序。我们将从一段合理大小的未加密文本开始,选取《爱丽丝梦游仙境》的第一章,来感受普通英文文本中字母的正常频率。你不需要自己输入完整的文本,只需在线查找并在 Scratch Cat 要求输入时复制粘贴即可。图 6-25 显示了结果。

Image

图 6-25:分析未加密文本中的字符频率(掉进兔子洞……)

正如你所看到的,文本中包含了大量的 E 和 A 字母,但只有一个 J。如果你向下滚动到列表的底部,你会发现有一个 Q,但没有 Xs 或 Zs。这是英文文本中字母分布的典型特征。

现在尝试使用我们原始的凯撒密码程序,来自项目 22,对同一章节的《爱丽丝梦游仙境》进行加密,字母表偏移三个位置。将加密后的文本输入到频率分析程序中。图 6-26 显示了结果。

Image

图 6-26:分析偏移后文本中的字符频率

我无法读取加密后的消息,但我注意到单词DQG出现了很多次,这是一个线索。更重要的是,字母的频率仍然有很强的模式,这表明最常见的字母是如何被编码的。当然,这个模式与原始消息完全匹配,只是偏移了三个位置:你可以看到字母 A 的原始频率出现在 D 的位置,字母 E 的原始频率出现在 H 的位置,依此类推。A 和 C 处的零来自于 X 和 Z 的回绕。从这个输出中,即使我不知道原文是什么,我也能大致猜出哪个字母是 E。

理论上,一次性密钥密码应该消除这些模式,使得加密文本中每个字母的出现频率大致相同。我们将在下一个项目中验证这一点。

项目 27:一次性密钥加密

我们可以通过对我们至今写的加密程序做一些小修改,在 Scratch 中编写一次性密钥加密。图 6-27 显示了Initialize模块。

Image

图 6-27:一次性密钥的设置代码

这个模块要求输入编码密钥和消息。它还询问用户是否要进行加密或解密 ❶,这样我们就可以使用相同的程序进行这两种操作。根据回答,变量action会被赋值为1-1。这个值被用在移位的运算中,导致加密时添加移位,而解密时则是减去移位。

注意,Initialize块设置了一个日志,以便我们可以滚动查看程序使用的历史记录。它还调用了一个自定义的Trim key块,该块在图 6-28 中定义。

图片

图 6-28:修剪密钥

Trim key块中,我们将用于加密消息的文本,并去除任何不在字母表中的字符(如空格或标点符号)。变量trimmed_key最初为空。然后,repeat循环逐个字符地遍历密钥;它忽略不在alphabet列表中的字符,并将其余字符放入trimmed_key中。

图 6-29 显示了主程序代码。

图片

图 6-29:一次性密码本加密(和解密)的主要代码

在这个堆栈中,我们通过消息中的每个字符(使用索引i)和修剪后的密钥(使用索引j)逐个字符地移动,根据密钥中的当前字符确定消息中当前字符的位移。我们使用if语句❶,当消息中的字符比密钥中的字符多时,会将指针回绕到修剪后的密钥的开头。如我在本章早些时候提到的,最好使用一个至少和消息一样长的密钥。较短的密钥是一个漏洞:如果密钥开始重复,频率分析就能揭示关于密钥长度和编码短语的信息。例如,一个单字符的密钥,相当于凯撒移位!

实际的工作是在set块中完成的➋。它通过将原始字符按密钥中相应字符确定的偏移量逐个字符地加密消息。我们使用图 6-4 中的原始Wrap块来获取适当字母的索引。还要注意,在设置shift值时,我们乘以action(即1-1)。如前所述,这允许程序通过向前或向后移动来进行加密和解密。

结果

为了看到一次性密码本加密相对于简单字母表乱序(如凯撒密码)的安全性,我们将使用我们的程序加密与之前相同的《爱丽丝梦游仙境》章节。为此,我们需要选择一个密钥。我将使用“贾布沃基”这首无意义的诗歌,如图 6-30 所示,以保持刘易斯·卡罗尔的风格。

图片

图 6-30:一次性密码本密钥

使用一次性密码本程序在加密模式下编码《爱丽丝》章节,在日志中找到加密后的文本,并将其复制粘贴到上一个项目中的频率分析程序中。结果应该类似于图 6-31。

图片

图 6-31:分析用一次性密码本加密的文本中的字符频率

如你所见,字母分布现在更平坦了:没有任何字母在使用频率上显著高(或低)于其他字母,因此频率分析无法提供破解密码的线索。而且,一次性密码本还消除了加密文本中的常规模式。与凯撒加密版本(图 6-26)中每次出现的 DQG(表示 and)不同,现在每个 and 的编码方式都不一样:首先是 ZKI,然后是 JFH,依此类推。没有密钥,解密这段文本似乎是不可能的任务;没有任何线索可以帮助你。而且,想象一下,如果空格和标点符号也被包括在字母表中并与字母一起打乱——加密后的消息看起来就像字母汤一样!

Image 编程挑战

6.11密码谜题是基于字母表乱序的文字谜题,不一定是凯撒加密或线性变换。编写一个 Scratch 程序,通过跟踪已猜字母并显示部分解密进度,帮助解决密码谜题。这里有一个密码谜题供你解答。作为提示,帮助你入手,这个密码谜题使用 M 代表 S。你可以通过观察模式和字母频率来猜测其他字母。

Image

6.12 编写一个 Scratch 程序,去除文本字符串中的空格和标点符号,只留下字母和数字的字符串。这对去除加密信息中的词语分隔线索可能很有用。

结论

Scratch 不仅仅用于处理数字;它同样擅长处理文本。任何可逆的变换规则都可以成为加密算法的基础,用来共享秘密,但某些规则(比如使用一次性密码本)比其他规则(如简单的位移)更适合保护秘密的安全。你始终可以使用频率分析等技术来寻找解密消息的线索。

第七章:## 计数实验

Image

组合学是数学的一个分支,通常被称为计数的艺术。这种“艺术”在于想出一种方法来组织计数问题,使得被计数的对象能够优雅地生成。

组合学有许多重要的应用。例如,在计算机科学中,组合算法适用于排序和数据查找等任务。在电信领域,组合学提供了用于高效数据传输的错误更正代码和网络协议。在遗传学中,它被用来分析和建模基因,以理解遗传和基因变异。

本章探讨了组合学领域的两个经典例子:卡塔兰数和分割数。对于每个例子,我们将制定一个策略,列出符合某一规则的所有模式实例。然后,我们将寻找一个递推公式来计算所有实例,而无需实际列出它们。

什么是计数问题?

在某些计数问题中,有一个参数表示某种大小或数量的度量:比如计数数值n。我们想知道有多少种方法可以使用n来生成不同的对象。一个经典的例子是计算有多少种方法可以排列n个物品。每一个独特的排列方式都被视为一个不同的对象。(答案是n的阶乘,正如我们在第五章中看到的。)

在其他计数问题中,有一个由参数n确定的单一对象,我们想要测量该对象的某个方面。例如,在讨论第四章中的序列时,我们考虑了平方数。在那里,与n相关的对象是边长为n的正方形,它可以由n²个 1×1 的小方格构成。平方数的序列是通过考虑n = 1, 2, 3,依此类推时,所包含的点数来确定的。

我喜欢把计数问题中的参数想象成一个旋钮,你可以通过旋转它来获得不同的结果。例如,第四章中的斐波那契数列,最初是作为一个计数问题的答案,问题是:在某些关于兔子繁殖的限制下,n代之后兔子会有多少只。把旋钮转到n = 6 代,你会得到 8,这是第六代的答案。把旋钮转到n = 7 代,你会得到 13。

许多计数问题可以通过一个序列来解决,一旦我们得到了一个序列,就可以寻找其中的规律。我们可能对序列的增长速度、可除性性质或与其他序列的联系感兴趣。有一个著名的项目收集了整数序列的信息并将它们按顺序排列,就像字典一样:在线整数序列百科全书®(OEIS®)。它最初在 1960 年代作为一个存储在打孔卡片上的数据库,由 AT&T 贝尔实验室的 Neil Sloane 维护。经过多年的发展,它已经大大扩大,现在可以在* oeis.org *上找到。OEIS 欢迎公众贡献新的序列,如果你想出了一个以前没人想到过的有趣序列,你可以提交它!

使用卡塔兰数爬山

卡塔兰数是一组出现在各种计数问题中的数列,包括我们这里要讨论的那个问题。假设你想要建造一条上下起伏的阶梯路径,就像在山脉中标记一条路径一样。你从地面开始,每次迈一步,要么向上走,要么向下走。最后,你回到地面水平。唯一的限制是,山脉路径在任何时刻都不能低于地面。你能创建多少种不同的上下步模式,也就是多少种独特的山脉形态呢?

随着步数的增加,可能的山脉形态越来越多,但仍然有一些重要的限制,源于中央约束。因为你必须保持在地面之上,你迈出的第一步必须是向上而不是向下。更重要的是,到目前为止,向上的步数必须始终至少与向下的步数相等。否则,如果你向下走得多于向上,你将会低于地面。例如,如果我们将向上一步表示为↗,向下一步表示为↘,那么像↗↘↘↗↗↘这样的步序就不行。将它展开为二维来看为什么不行:

图片

山脉在第一次上下之后会下降到起始位置以下,因此不允许这样做。另一方面,步序列↗↗↘↘↗↘是允许的。它展现如下:

图片

本质上,为了回到地面,步伐必须成对出现:每一次向上,总会有相应的向下。因此,我们可以用n来表示向上的步数,并且我们可以说卡塔兰数C(n)是由n个向上步构成的可接受路径的总数。这个n同样代表向下步的数量,每条路径总共有 2n步。

总的来说,使用n = 3 个上箭头和n = 3 个下箭头,可以构建五条可接受的路径。换句话说,C(3) = 5。其中一条路径就是我刚刚展示的↗↗↘↘↗↘模式。你能找到其他的路径吗?对于像 3 这样的低n值,想出所有可能的组合并不难,但随着n的增大,拥有一种系统化的方法来追踪所有路径就变得尤为重要。找到正确的方法是组合学成为计数的艺术的美妙之处。

在第四章中,斐波那契数列的一个有效方法是寻找递推式,一种公式,允许我们从已有的数生成新数。在那个情况下,我们只需要将前两个数字相加,得到下一个数字。对于卡塔兰数,我们可能会尝试将已经生成的旧路径拼接起来形成新的、更长的路径,但我们需要回溯到序列中的更早的项,以便找到所有的路径。事实上,我们必须查看每一条已经生成的较短路径。

我们可以通过首先选择任意两个数字,它们的和为n - 1,来构建一个由n个上箭头组成的新路径。我们称这两个数字为ab。例如,如果n是 3,我们可以选择a = 1 和b = 1,或者a = 0 和b = 2,或者a = 2 和b = 0,因为它们的和是 3 - 1 = 2。对于旧路径,我们知道,当ab = 0 时,只有一条路径。这是一条没有上箭头和下箭头的空路径,我们不会走任何地方。同样,当ab = 1 时,也只有一条路径:↗↘。当ab = 2 时,有两条路径:↗↗↘↘和↗↘↗↘。

构建新路径的方法是从一个上箭头(↗)开始,接着是任何一个之前创建的具有a个上箭头的路径。然后,我们加一个下箭头(↘),再接一个任何之前创建的具有b个上箭头的路径。这样我们就得到了总共n个上箭头:第一个上箭头加上两个路径中的ab个上箭头。第一个上箭头和对先前生成路径的约束保证了下箭头的数量永远不会超过上箭头的数量,否则我们就会跌入地下。

让我们用这个方法来创建所有可能的路径,当n = 3 时。对于a = 1 和b = 1,我们只生成一条路径:↗ ↗↘ ↘ ↗ ↘。对于a = 0 和b = 2,生成两种情况:↗↘↗↗↘↘和↗↘↗↘↗↘。对于a = 2 和b = 0,同样生成两种情况:↗↗↗↘↘↘和↗↗↘↗↘↘。就这样,这就是n = 3 上箭头的五条可能路径。

请注意,当a = 0 时,每条新生成的路径仅仅是↗↘后接一个具有b个上箭头的旧路径。与此同时,当b = 0 时,新路径仅仅是一个旧路径,它有a个上箭头,并且通过在开始时添加一个额外的↗,在结束时添加一个额外的↘,使得路径变得更加"上升"。

每个接受的路径,具有n个上升步长,都可以使用这个简单的公式生成,只要我们考虑每一对可能的ab值。例如,对于n = 4,我们需要所有相加为 3 的ab值:0 + 3,1 + 2,2 + 1,和 3 + 0。

项目 28:导航卡塔兰路径

在这个项目中,我们将编写一个 Scratch 程序,使用我们刚才讨论的方法来构建新的卡塔兰路径。首先,我们需要根据路径的上升步数来分类路径,因此我们定义了一个模块(如图 7-1 所示),它会计算序列中的所有上升步数。

图片

图 7-1:多少个上升步长?

图片

图 7-2:列出所有路径

该模块假设每条路径都以字符串的形式表示,其中斜杠字符(/)表示上升一步,反斜杠字符(\)表示下降一步。该模块逐个字符扫描字符串。每当它找到一个斜杠 ❶ 时,它就将upcount变量增加1

现在,我们可以构建一个名为Catalan的列表,包含所有接受的路径,直到达到期望的上升步数。我们从长度为 0 的路径开始,然后反复应用递归,生成越来越长的新路径。图 7-2 中的主程序只需询问用户要走多远,然后调用自定义的Iterate模块 ❶ 合适的次数。

实际的工作在Iterate模块中完成,如图 7-3 所示,它执行递归操作。

图片

图 7-3:从旧路径生成新路径

我们使用两个嵌套循环来查看到目前为止生成的所有路径对。路径对中的每一条路径根据其上升步数进行分类。例如,Upcount item loop1 of Catalan为我们提供了路径对中第一条路径的upcount ❶。每当我们找到两条路径,它们的上升步数总和等于正确的值 ➋时,我们就用一个额外的上升和下降步长 ➌(注意 /\ 字符后面的额外空格,使输出的间距更美观)将它们结合在一起,并将生成的新路径添加到Catalan中。代码的编写方式是,构建新的卡塔兰路径的条件是a + b = desired size - 1。这样,当我们添加额外的上升和下降步长时,就能得到一个上升步数为desired size的路径。

结果

与往常一样,输出受限于 Scratch 舞台,但你可以滚动以查看更多输出,或将Catalan列表导出为文本文件以查看完整内容。图 7-4 展示了列表的顶部。

图片

图 7-4:卡塔兰路径,适用于 n = 0, 1, 2 和 3

第一个项目显示了最初的空路径,在第一次应用递归时它变成了 ↗↘ 的路径。图中展示了 n = 3 的输出,显示了五条具有三个上升步骤的可接受路径(列表项 5 到 9)。图 7-5 显示了接下来几条路径的导出文本格式,适用于 n = 4。

图片

图 7-5: n = 4 的卡塔兰路径

Scratch 的列表长度限制为 200,000,意味着这个程序最多只能工作到 n = 11,在此时会生成并添加 58,786 条新路径到包含从 n = 0 到 10 的 23,714 条较短路径的列表中。对于 n = 12 来说,路径数量过多,无法继续完成列表。更糟糕的是,Iterate 块中的双重循环需要很长时间才能完成。由于每一对路径都要检查是否能够结合以满足卡塔兰条件,测试路径对的数量是当前列表长度的平方。因此,当我们到达 n = 10 或 11 时,程序明显变慢。

然而,通过计算列表中具有给定步数的项,我们可以开始通过这个程序看到卡塔兰数列。以 C(0) = C(1) = 1 为起点,接下来的几个数值是 2、5、14、42、132、429、1,430、4,862、16,796 和 58,786。

破解代码

如果我们只想知道卡塔兰数本身——即每个 n 值的唯一路径数量——我们不必实际构建所有路径。相反,我们可以通过一个递归公式直接计算这些数字,基于相同的组合学见解,这帮助我们生成新路径。要找到 C(n),我们首先需要找到所有满足 a + b = n – 1 的数对 ab。然后,对于每一对,我们可以查找相应的卡塔兰数并将它们相乘:C(a) ⋅ C(b)。最后,我们将所有这些乘积加起来得到 C(n)。

例如,假设我们想要找到 C(5)。由于 5 – 1 = 4,我们首先需要找到所有相加为 4 的数字对。它们是 0 + 4、1 + 3、2 + 2、3 + 1 和 4 + 0。注意,每对中的第一个值是从 0 到 n – 1 递增的,而第二个值则在递减。接下来,我们需要找到这些对中对应的卡塔兰数。假设我们已经使用这个递归公式计算出了所有从 C(0) 开始的卡塔兰数,我们应该已经知道它们:C(0) = 1,C(1) = 1,C(2) = 2,C(3) = 5 和 C(4) = 14。然后,我们可以将每一对卡塔兰数相乘,并将结果相加来得到 C(5):

图片

写这个递归公式的简短方法是使用求和符号,其中 Σ 符号(大写希腊字母 Sigma)表示对于每个索引 i 从最小值 i = 0(位于 Σ 下方)到最大值 i = n – 1(位于 Σ 上方)每增加一个值进行求和:

图片

这里,i 相当于 a,而 n – 1 – i 相当于 b。这是有道理的;如果 a + b = n – 1,那么 b = n – 1 – a

图 7-6 显示了一些代码,帮助 Scratch 计算并列出我们想要的卡塔兰数。

图片

图 7-6:列出卡塔兰数

我们将列表命名为 C(n),并从为 C(0) = 1 添加 1 开始。不过,如果列表的索引与序列条目的索引匹配会更好,索引 1 存放 C(1),索引 2 存放 C(2),以此类推。为了实现这一点,我们在程序结束时删除列表中的第一个项目 ❶。

和原始卡塔兰程序一样,实际的工作是在 Iterate 块中完成的,我们可以根据需要调用多次。图 7-7 显示了块的定义。

图片

图 7-7:应用递推公式

repeat 循环内,我们查找一对对的卡塔兰数,变量 a 的值从 i(1) 开始,逐渐增加至 size,而变量 b 的值从 size 开始递减,以涵盖递推公式中的所有对。注意 b 的定义如何使其倒计数 ❶。我们将每对数值相乘,并将结果加到保存在 sum 变量中的运行总数中,最后将其添加到列表的末尾。

使用递推公式,Scratch 能够相当快速地计算 C(n) 的值,并且程序的限制值由 flintmax 决定,而不是最大列表长度。直到 flintmax 被超越,结果都是准确的,这通常发生在 n = 30 后。Scratch 计算出的 C(31) 的值会偏差 1,但直到 n = 520 时,我们才会遇到 Infinity 的问题,这时才会超出 Scratch 的浮动点最大值。

图片 编程挑战

7.1这是另一个卡塔兰数的递推公式:

图片

C(0) = 1 开始,这个公式适用于所有大于 0 的 n 值。在 Scratch 中编写这个替代的卡塔兰数递推公式。

7.2编写一个程序,当你提供一条来自 Catalan 列表的路径时,让 Scratch 绘制上下山脉。

7.3卡塔兰数与帕斯卡三角形中的中心二项式系数存在关系,后者的条目形式为 C(2n, n)。使用 项目 19(图 5-7)中的程序计算一些中心二项式系数,看看你能否找到另一种计算卡塔兰数的公式。

7.4另一个涉及卡塔兰数的计数问题是通过嵌套括号来指定乘法的顺序。考虑如何通过改变括号的分组方式,而不是改变因子的顺序,来相乘四个数a, b, cd。例如,((ab)c)d首先会先将ab相乘,然后将该计算结果(部分积)与c相乘,最后将结果与d相乘。另一方面,(ab)(cd)会先将ab相乘,再将cd相乘,最后将两个部分积相乘。还有三种不同的方式来分组四个数。找到它们。然后,修改生成卡塔兰路径的程序,使其显示所有可能的括号分组方式。

使用加法分解数字

我们在前几章探讨的许多关于数字的问题都涉及到乘法。例如,质数是正整数的乘法基本单元,算术基本定理说一个数字只能以一种方式写成质数的乘积(忽略因子的顺序)。但是加法呢?我们可以以什么方式将一个数字写成其他数字的和?

首先,我们可以专注于数字 1,并注意到每个正整数都可以唯一地写成 1 的和:2 = 1 + 1,3 = 1 + 1 + 1,依此类推。我们还可以施加不同的限制,要求每个加数(被加的数字)最多只使用一次。然后,如果我们考虑以 2 为基数,那么每个正整数都有唯一的表示。也就是说,每个正整数可以唯一地表示为不同 2 的幂的和。(我们在第一章中探讨了二进制表示法。)例如,得到 5 的唯一方法是不重复使用相同的 2 的幂,就是 2² + 2⁰,或者 4 + 1。然而,一旦我们允许比 2 的幂更大的加数集合,我们就失去了唯一性。如果我们允许 3 作为加数与 2 的幂一起使用,例如,我们就可以通过 1 + 4 或 2 + 3 得到 5。

组合:顺序很重要

另一个需要做出的决定是顺序是否重要。我们是否将 1 + 2 和 2 + 1 视为表示 3 的不同方式?如果我们决定它们应该被视为不同,那么就有一种简单的方法来找出给定数字n作为和的所有可能表示(也叫做n组合)。首先,将n写成 1 + 1 + . . . + 1。然后,查看每个加号并做出选择:要么保留它,要么省略它并将它两边的数字合并。例如,假设n = 3。我们可以将 3 = 1 + 1 + 1,并决定去掉第一个加号,使得 3 = 1 1 + 1 → 2 + 1,或者去掉第二个加号,使得 3 = 1 + 1 1 → 1 + 2。我们也可以保留两个加号得到 1 + 1 + 1,或者去掉两个加号,得到单一的加数 3。

我们已经找到了 3 的四个组成方式,而且就是这四个。为了理解为什么是这样,并且推导出n的组成方式数量的一般公式,可以想一想从 1 + 1 + 1 + ... + 1 到n的组成方式的过程。由n个 1 组成的字符串中,n – 1 个加号分隔它们。对于每一个加号,我们有两个选择:保留它或去掉它。这意味着有n – 1 个独立的二进制决策,这就导致有 2^(n – 1)种方式来组合这些决策,形成一个n的组成方式。我们识别出的 3 的四个组成方式是“去掉,保留”,“保留,去掉”,“保留,保留”和“去掉,去掉”。

分区:顺序无关

一个更有趣的情况,并且没有那么简单的公式答案的是,如果我们决定加法项的顺序重要——例如,1 + 2 和 2 + 1 应被视为相同的 3 的表示方式。这样的表示方式,忽略顺序,被称为n分区,而不是组成。

如果加法项的顺序无关,那么我们不如将加法项按升序(或更确切地说,按非降序)排列,这样我们可以更系统地跟踪所有的分区。然后,我们可以将一个分区看作是一个特殊的组成方式,其项是非降序的。例如,3 的三个分区是 1 + 1 + 1,1 + 2 和 3。

注意

我说非降序而不是升序因为将 1 + 1 + 1 中的 1 看作是升序的并不完全公平。重要的是每个加法项的值不会小于前一个项的值。*

让我们称P(n)为计算n的分区数量的函数。确保你同意P(1) = 1(1 只有一个分区:1 = 1),以及P(2) = 2(2 = 1 + 1,2 = 2)。我们已经确定 3 有三个分区,因此P(3) = 3。你可能觉得看到了一个规律,但从这里开始,n的分区数量增长得更快:P(4) = 5,P(5) = 7,当我们到达 12 时,P(12) = 77。

项目 29:一个分区探险

让我们开发一个 Scratch 程序来查找给定值n的所有分区。我们将把结果存储在一个partitions列表中,每个分区表示为由加号连接的数字字符串,中间不留空格,例如1+1+2。我们的策略是从n = 1 开始,一步步增加到所需的n值,每一步都用当前n的分区替换partitions的内容。这意味着我们需要一种方法,利用所有n的分区列表来找到所有n + 1 的分区。

通过在每个n的分区前加上一个加法项 1,就可以获得一些n + 1 的分区。这只不过是将1+连接到partitions列表中的每个项目上。图 7-8 显示了一个自定义块来实现这一点。

图片

图 7-8:从旧的分区构建新的分区

该代码块简单地循环遍历partitions列表,一次取出一个项,将1+添加到它的开头,然后将结果存回列表中的相同位置。

n + 1 的其余分区可以通过回顾n – 1 的分区并添加新的首项 2,回顾n – 2 的分区并添加新的首项 3,依此类推。这一步稍微复杂一些,因为由于非递减顺序规则,并不是每个新创建的分区都能接受。例如,n – 1 的分区中只有那些最小项至少为 2 的分区才能加上 2。如果分区中有 1,放一个 2 在前面就破坏了顺序。同样,当向n – 2 的分区添加 3 时,原始分区不能以 1 或 2 开头。

另一个复杂点是,程序中的partitions列表只保存n的分区,这些分区被修改为n + 1 的分区。我们实际上没有记录早期的n – 1、n – 2 等分区来回顾。不过有一个解决方法:一旦我们将所有的n分区取出并在前面添加 1 来生成n + 1 的分区后,我们可以查看每个新分区并合并所有的 1。如果有两个 1,这将与向n – 1 的分区中添加 2 效果相同。同样,如果有三个 1,这就相当于向n – 2 的分区添加 3,依此类推。

为了理解为什么这样做有效,考虑从n = 5 到n + 1 = 6 的情况。5 的一个分区是 1 + 2 + 2,向它添加一个额外的 1 后,我们得到 1 + 1 + 2 + 2,这是 6 的一个分区。如果我们把这两个 1 合并成一个 2,我们得到 2 + 2 + 2,这是 6 的另一个分区。这相当于回顾n – 1 = 4 的分区,找到分区 2 + 2,并在它前面加一个 2。同样,5 的另一个分区是 1 + 1 + 3。添加一个额外的 1 后,我们得到 1 + 1 + 1 + 3 = 6。如果我们将这些 1 合并成一个 3,我们得到 3 + 3,这与回顾n – 2 = 3 的分区,找到分区 3(任何数字都是它自己的有效分区),并在它前面加一个 3 的效果相同。

实现这个过程的代码需要几个步骤。首先,我们需要将partitions(我们通过向* n 的旧分区添加一个初始项 1 得到的n*+1 的分区)中的内容复制到一个新的列表中,以免覆盖它们。图 7-9 中的代码完成了这个任务,将复制的内容放入一个名为dup的列表中。

Image

图 7-9:将分区列表复制到一个副本列表中

接下来,我们需要一种方法来计算给定分区开头有多少个 1。图 7-10 中的代码块处理了这个任务。

Image

图 7-10:计算分区中 1 的数量

记住,我们假设每个分区都是由加号分隔的数字串,但我们不知道每个数字包含多少位。所以这个How many 1s代码块每次查看字符串中的两个字符,检查第一个字符(m)是否是 1,第二个字符(n)是否是加号。repeat until 循环会不断增加 1count,直到情况不再成立。检查加号和 1 可以防止像 1+10 这样的字符串被误算为两个 1。还要注意,在代码块开始时,我们会暂时在字符串的末尾添加一个额外的加号。如果没有这一点,像 1+1+1 这样的全 1 字符串中的最后一个 1 就不会被计算在内。

图片

图 7-11:将 1 合并为更大的数字

一旦我们在1count变量中得到了 1 的数量,我们就可以用1count的值替换所有这些 1。图 7-11 中的代码块完成了这个替换操作。这个代码块还会初次调用How many 1s代码块。

在这个代码块中,我们将新的分区构建在 temp 变量中。我们从将 temp 设置为 1count 开始。然后,我们使用索引变量 k 将原始分区字符串的其余部分复制到 temp 中,从字符 2 * 1count 开始,也就是最后一个 1 后面的加号。如果原始分区完全由 1 组成,repeat until 循环中的 k > partition 的长度 检查会立即失败,因此新的分区将只是 1count 的值。

接下来是实际的工作:我们需要决定,通过将一些初始的 1 替换为一个更大的数字,我们创建的字符串是否符合有效分区的条件,应该被加入到 partitions 列表中。这里有两个重要的方面。首先,第一个项不能是 1。如果它是 1,那么它已经被计数过了。(如果原始分区只有一个 1,那么 Replace 1s 代码块就会保持它不变。)第二,数字串必须是非递减的。我们知道原始的分区是非递减的,而我们所做的只是将所有的 1 合并成一个更大的数字,所以唯一可能违反这个条件的情况是,如果新的第一个项大于第二个项。还有一种可能性是分区只包含一个项。这种情况也应该被认为是有效的。图 7-12 中的代码块检查了所有这些情况。

这个模块旨在根据字符串p是否表示一个有效的分区,将proper变量设置为布尔值truefalse。我们从一个repeat until循环开始,将第一个数字提取到first变量中❶。该循环持续进行,直到索引变量i遇到加号或超出字符串的长度。我们需要这个循环,因为数字可能有多个数字位。此时,如果i超过了整个字符串的长度➋,我们就知道这个分区包含单一项,因此将proper设置为true

如果我们还没有到达字符串的末尾,我们使用另一个repeat until循环将第二个数字提取到second变量中➌。(同样,它也可能有多个数字。)然后,我们再次进行两个主要检查:如果first大于second,或者first1 ➍,我们将proper设置为false。否则,我们有一个有效的分区,因此将proper设置为true

图片

图 7-12:验证新的字符串是否仍然符合分区条件

利用我们目前定义的所有模块,现在可以定义一个主要的Iterate模块,它协调从上一批次构建下一批分区的过程。图 7-13 展示了如何操作。

图片

图 7-13:将分区列表从 n 扩展到 n + 1

这个模块几乎可以看作是算法的口头描述。首先,我们将 1 加到每个现有的分区,并复制partitions列表。接下来,我们将复制中的每个分区,合并其中所有的 1 为一个单独的加数。然后,我们评估结果,只将有效的分区添加到partitions列表中。

在评估阶段,请注意我们使用计数器j向后遍历dup列表❶。算法的一个特殊之处在于,由于分区是以特定的方式添加到该列表中的,结果会从最大数到最小数生成。例如,1 + 1 + 1 + 1 = 4 最初排在 1 + 1 + 2 = 4 之前,但在这些被转化为 4 和 2 + 2 后,4 会排在前面。逆向评估列表将结果重新排列为升序。

现在我们已经有了所有的模块,只需要在图 7-14 中使用绿色标志代码来启动程序。

图片

图 7-14:生成分区列表

这个栈会提示输入一个数字,并迭代足够的次数来获取该数字的分区。我们从仅包含1partitions列表开始,1 是 1 本身唯一有效的分区。

结果

图 7-15 展示了程序示例运行的输出:数字 6 的所有 11 个分区的列表。

图片

图 7-15:6 的分区

像往常一样,要查看更大值的n的完整列表,你需要滚动或将列表导出到文本文件中。

破解代码

我们可以通过让 Scratch 列出所有的分区,然后查看列表的长度来计算P(n),即n的分区数。只要列表能够容纳所有的分区,这种方法就有效。Scratch 对列表长度的限制意味着此程序适用于n的值最大为 49,这时有 173,525 个分区。(在我那台九年的旧电脑上,找到所有分区不到两分钟。)对于更大的n值,我们可以使用递归公式直接计算P(n),而不必列出所有分区。事实证明,递归公式表示P(n)是早期项的组合,这些项以一种有趣的方式间隔开。关系式为:

Image

1, 5, 12, 22, . . .,沿左列排列的序列是第四章中五边形数的序列,定义如下:

Image

相关序列 2, 7, 15, 26, . . .,沿右列排列的序列是这样计算的:

Image

第二个序列也可以按如下方式编写,以查看不同类型的对称性:

Image

请回顾一下图 4-14 在项目 16 中的内容,位于第 71 页,查看这两个序列在五边形数可视化中的高亮显示。

我们可以在 Scratch 中编程实现这个递归,以进一步计算P(n)的值。首先,我们需要图 7-16 中的自定义块,以在给定某个k值时,计算刚刚描述的两个序列的值。

Image

图 7-16:计算五边形数的公式

接下来,图 7-17 显示了一个Initialize块,它设置了Partitions,这是一个存储结果的列表。

Image

图 7-17:初始化递归

我们询问要计算多少个P(n)的值,并用前两个值P(0) = 1 和P(1) = 1 来初始化列表。然后我们将n设置为3,以开始计算序列的第三项,即P(2)的值。

主程序堆栈,如图 7-18 所示,调用Initialize,然后使用repeat循环计算所需的P(n)值的数量。

Image

图 7-18:实现分区函数的递归

我们从每个新的P(n)值开始,初始值为0,并使用两个自定义块loop1loop2,从k = 1 开始,使用递归公式计算实际值。一旦repeat循环结束,我们从Partitions列表中移除第一个项,表示P(0),以使索引编号与n的值匹配❶。

图 7-19 显示了loop1loop2的定义。

Image

图 7-19:计算五边形数的循环

loop1 中,我们根据递归规则的左列查找 Partitions 列表中的早期值——即 P(n – 1)、P(n – 5)、P(n – 12) 等——使用 p1 块计算所需的五边形数。每个值被存储在变量 i 中,然后加到 Partitions 中的最新值。由于递归中的项交替进行加法和减法,我们使用 sign 变量 ➋ 来跟踪所需的操作。在调用 图 7-18 中的 loop1 之前,它的初始值是 1(加法),对于每个新项,我们将其乘以 -1 来切换加法与减法,或反之 ➌。我们继续查找项,直到下一个五边形数(p1)大于当前的 n ❶。

loop2 块遵循相同的逻辑,但它使用 p2 来计算递归规则右列的项:P(n – 2)、P(n – 7)、P(n – 15) 等。

图 7-20 显示了该程序的一些示例输出。

图片

图 7-20: P(n) 的前几个值,以及一些后续值

该方法迅速生成正确的 P(n) 值,直到 n = 298,当递归中结合的数字超出了 flintmax。随着 n 的继续增大,浮点数算术的局限性使得计算出的 P(n) 值变得不可靠,值在错误的值和一些负数之间剧烈波动,直到达到浮点数限制并得到 Infinity

图片 编程挑战

7.5我们可以通过对分割的加法项设置额外的条件,提出有趣的计数问题。编写一个布尔块,筛选分割并返回 true,如果每个加法项都是奇数。将该块应用于通过 图 7-14 中的代码为给定值 n 生成的分割列表,并查看有多少个符合此条件。例如,所有加法项为奇数的 5 的分割将是 1 + 1 + 1 + 1 + 1、1 + 1 + 3 和 5 本身。

7.6编写另一个布尔块,检查分割的所有部分是否都不同。例如,所有部分不同的 5 的分割将是 1 + 4、2 + 3 和 5 本身。分割 1 + 2 + 2 等将不算,因为它有重复的部分。将 n 的所有不同部分的分割数与 n 的所有奇数部分的分割数进行比较。

7.7印度数学家斯里尼瓦萨·拉马努金注意到,P(n) 在 n 以 4 或 9 结尾时能被 5 整除。通过查看 P(n) 值的列表来验证这一事实。记住,你可以将 Scratch 列表导出为文本文件,以便更轻松查看。看看你是否能找到其他能被 7 和 11 整除的 P(n) 值的模式。

结论

我们在之前的章节中已经看到,Scratch 适合用来计算数字和处理文本。现在我们知道,它同样适合用来制作模式。有时,计算一个模式出现的方式有多少种的第一步,就是列出所有可能的情况——这又是 Scratch Cat 的工作!然后,通过正确的公式或递推关系,我们就能在不需要列出所有情况的前提下,计算模式的出现次数。

第八章:## 三份圆周率

图片

数字π(圆周率)表示圆的周长(圆的“边缘”全程的距离)与其直径(从圆的一侧通过圆心到另一侧的直线距离)之间的比率。值得注意的是,这个比率的值无论圆的大小如何都相同。圆的其他属性,如面积,依赖于它的大小,但π不受影响;随着圆的增大,增加的周长与增加的直径之比保持不变。

你可以将周长和直径的测量值看作是用一个常见的长度单位表示的,比如厘米或英寸。在这个比率中,这些测量值的单位会相互抵消,从而使π成为一个纯粹的数字,没有单位。π通常被近似为 3.14,但小数点后的数字实际上会一直延续下去,并且不会重复。多年来,数学家们提出了许多不同的方法来计算π,其准确度各不相同。在本章中,我们将探讨几种这样的技巧,涉及代数、几何,甚至数论。

阿基米德如何计算圆周率

让我们首先探讨一下古希腊数学家阿基米德用来计算π的方法。首先画一个圆,然后画一个内切多边形,这是一个完全位于圆内的形状,其顶点恰好接触圆的边缘。接下来,画一个外切多边形,这是一个完全包围圆的形状,其每条边的中点都接触圆的边缘。两个多边形应该具有相同的边数,并且应该是规则多边形,这意味着它们的所有边长相等。图 8-1 展示了这种绘图可能的样子。

请注意,内切(紫色)六边形的周长小于圆的周长,而外切(黑色)六边形的周长大于圆的周长。这意味着我们可以使用这两个六边形的周长来找到π的上下界。

图中没有指定长度单位,所以我们假设圆的半径为r = 1。半径为 1 的圆叫做单位圆。这个单位圆的周长为C = 2πr = 2π。内切六边形由六个边长为 1 的等边三角形组成,因此通过求和三角形外边的长度,我们可以得出内切六边形的周长为 6。这进一步告诉我们 2π > 6,因此π > 3。

图片

图 8-1:内切和外切六边形

利用一点三角学,我们可以计算出外接六边形的边长为 (2Image) / 3,因此其周长为 (6 ⋅ 2Image) / 3。这等于 4Image,如果 2π < 4Image,那么 π < 2Image,即大约是 3.4642。

我们现在知道,π 介于 3 和 3.4642 之间。为了获得更精确的值,让我们尝试将内切和外接多边形的边数翻倍。随着边数的增加,内外多边形会越来越接近圆形。图 8-2 展示了从 6 边形增加到 12 边形时发生的情况。

随着多边形越来越接近圆形,它们的周长会 收敛 到 2π 的值。阿基米德从 6 边形开始,逐步增加到 12 边形、24 边形、48 边形、96 边形,最终得到了一个被称为最精确的 π 近似值,持续了几个世纪:223 / 71 < π < 22 / 7。

Image

图 8-2:内切和外接 12 边形

阿基米德通过发展一种递推规则来跟踪当两种多边形的边数翻倍时周长的变化,从而得到了他的近似值。这个递推公式以 a[n] 和 b[n] 为输入,即π的旧上界和下界,计算出新的上界和下界,即 a[n + 1] 和 b[n + 1],其公式如下:

Image

例如,从我们最初的六边形(a[1] = 2Imageb[1] = 3)开始,到 12 边形,我们的计算过程为:

Image

并且:

Image

这告诉我们,π 必须介于 3.10583 和 3.21539 之间。

a[n + 1] 的计算被称为 a[n] 和 b[n] 的 调和平均数b[n + 1] 的计算是 a[n + 1] 和 b[n] 的 几何平均数。你可以在 mathworld.wolfram.com/ArchimedesRecurrenceFormula.html 上找到更多关于这些术语的详细解释,以及阿基米德是如何使用三角学推导他的递推公式的。

项目 30:阿基米德递推法

在这个项目中,我们将在 Scratch 中编程实现阿基米德的递推公式,以计算π的近似值。我们将从六边形开始,正如我们已经确定的,它们给出了上界 a[1] 为 2Image 和下界 b[1] 为 3。然后,我们将使边数从此开始翻倍。图 8-3 展示了代码。

Image

图 8-3:从内切和外接六边形计算 π

在设置了上界和下界的起始值❶之后,我们使用一个循环计算新值,直到结果相等➋,这意味着我们已经达到了 Scratch 支持的最高精度。我们将上界存储在列表A中,将下界存储在列表B中。在循环中,注意我们先计算a的新值,这样我们就可以在计算b时使用它。

结果

图 8-4 展示了运行程序后AB列表的内容。

Image

图 8-4:从六边形开始并收敛到π

只需 27 个循环,我们就能达到 Scratch 浮点数表示的精度限制。此时,边界的值收敛到 3.141592653589792. 如果你记得记忆法“我需要一杯冰沙,当然是巧克力口味的,在那些涉及量子力学的沉重讲座后”,你可以检查前几个数字是否正确。通过数每个单词的字母数,你可以得到π的前 15 个数字:“How I need”对应 3, 1, 4,依此类推。

破解代码

即使我们不从六边形开始,边长翻倍的递归依然有效。假设我们用内切和外接正方形来近似圆的周长,如图 8-5 所示。

Image

图 8-5:使用正方形来近似周长

如果圆的半径仍为 1,则外接正方形的周长为 8。根据勾股定理,内切正方形的边长为Image。由于圆的周长为 2π,因此第一个估算值为 2Image < π < 4. 要从这里运行递归,只需将设置ab初始值的两个模块(参见图 8-3 ❶,第 153 页)替换为图 8-6 中的模块。

Image

图 8-6:递归的新的初始值

图 8-7 展示了使用这些新起始值运行程序的结果。

Image

图 8-7:从正方形开始并收敛到π

尽管递归从更宽的范围开始,但它很快就会收敛,再次需要 27 个循环才能达到 Scratch 的精度限制。

Image 编程挑战

8.1如果从内切和外接三角形开始,计算递归的初始值。由于边长的第一次翻倍是从三角形到六边形,因此从第二行开始的输出应与图 8-4 中的结果相同。

从圆的面积估算π

计算π的另一种方法是使用公式A = πr²,表示圆的面积。假设你在一个网格上绘制了一个半径为r的圆,圆心位于点(0, 0)。圆内的任何点(x, y)都满足不等式x² + y² < r²。假设我们只关注坐标为整数的点,这些点被称为格点。我们可以将每个格点视为一个单位正方形的左下角,这个正方形的边长为s = 1,面积为s² = 1。通过计数圆内的格点(即满足x² + y² < r²不等式的点),我们可以得到圆面积的近似值。图 8-8 展示了如何使用这种方法,示例为半径为 4 的圆。

Image

图 8-8:半径为 4 的圆内的格点

圆内的格点以紫色点显示,总共有 45 个。每个格点标记着一个黄色单位正方形的左下角。这些正方形中的一些超出了圆的范围,但这一点被圆中未被正方形覆盖的部分所抵消。综合来看,我们可以说圆的面积大约是 45,和黄色正方形覆盖的面积相同。我们知道圆的面积是πr²,因此将 45 除以r²得到一个估算值:45 / 16 = 2.8125。

如果我们还统计四个恰好位于圆周上的格点——点(4, 0)、(0, 4)、(–4, 0)和(–4, 0)——我们可以得到更精确的估算值:49 / 16 = 3.0625。通过使用更大的圆,我们可以进一步提高精度。这是因为圆的面积与半径的平方成比例增长,而误差仅来自圆周围的正方形,圆周上正方形的数量仅与半径的线性关系成比例增长。因此,圆越大,相对于总面积的误差越小。在我们的下一个项目中,我们将看到如何通过增加半径的大小来提高估算值,同时使用 Scratch 来处理计算。

项目 31:使用格点计数

图 8-9 展示了一些 Scratch 代码,提示输入半径并在生成的圆内计数格点,以估算π。输出会记录满足条件x² + y² < r²的格点数量以及由此得到的π估算值。

Image

图 8-9:通过计数格点来估算π

我们首先要求圆的半径值。然后,使用两个嵌套循环 ❶ 遍历围绕圆形的方形网格中的行和列。我们从方形的右上角开始,在那里 xy 都等于半径 r,然后向左下角移动,直到它们等于 –r。对于每一对坐标,我们检查该点是否在圆内 ➋,如果是,则增加格点计数。最后,我们将格点计数除以半径的平方,从而得到 π 的近似值。

结果

图 8-10 显示了运行程序的结果,计算半径为 1,000 的圆形。

图片

图 8-10: 计算半径为 r * = 1,000 的圆内的格点数*

这个 π 的近似值精确到小数点后四位。效果好多了!

破解代码

对于半径为 r 的圆,图 8-9 中的程序需要检查 (2r)² 个格点。当 r = 1,000 时,需要检查 400 万个点,这需要一些时间。随着圆形变大,延迟会更长。例如,当 r = 10,000 时,将需要检查 4 亿个点,你将需要等待很长时间才能得到结果。

但为什么要检查 所有 的点呢?我们可以随机选择较少的格点,并使用这些点来估计圆的总体面积。图 8-11 显示了这个方法。

图片

图 8-11: 通过采样随机格点来确定 π

变量 tries 控制 repeat 循环 ❶,并决定检查多少个随机点。我建议将其设置为圆半径的 10 倍左右。我们可以限制自己只查看网格的第一象限中的点,在该象限中坐标是正整数,通过选择 0radius 之间的随机 xy 值 ➋。若点位于圆内,则如前更新格点计数。通过足够多的尝试,我们应该能够看到以下等式:

图片

该等式的左侧是“命中”(圆内的格点)与总采样点数的比率。右侧是圆面积四分之一的比率(位于网格第一象限的圆部分)与半径平方的比率。这里的 r² 可看作包含所有我们可能采样的点的第一象限方形的面积。将 A 代入 πr² 并解出 π,得到:

图片

我们在程序的最后使用这个公式来估算 π。图 8-12 显示了一个示例结果,半径为 10,000,随机采样了 100,000 个点。

图片

图 8-12: 通过随机试验估算 π

你从这个程序中得到的输出每次运行时可能都会不同,因为随机数生成器决定了测试点的选择。不过,我们这里得到的结果非常接近,而且比程序检查圆中每一个格点要快得多。

Image 编程挑战

8.2这两个版本的pick random模块之间有一个微妙的差别:

Image

将每个模块嵌入一小段代码,以报告结果,看看它们的表现如何。带有1的版本返回整数值,因此请求 0 到 1 之间的值时,大约一半时间会得到 0,另一半时间会得到 1。带有1.0的版本返回的是 0 到 1 之间的值,这些值不一定是整数。如果我们没有整数的(x, y)坐标,那么我们就没有真正的格点,但这有关系吗?看看图 8-11 中的代码,如果随机选择的点没有整数坐标,代码是否还能正常工作。

用相对素数逼近π

数字π在许多看似与圆形和几何学无关的地方出现。一个有趣的涉及π的公式回溯到第二章和第三章中的公因数的概念。记住,两个整数的公因数是一个能整除这两个整数的数。如果这两个整数唯一的公因数是 1,那么这两个整数被称为相对素数

这里有一种几何方法来解释相对素数。假设你站在坐标平面的原点(0, 0),面朝格点。你可以看到大部分格点,但有些被挡住了,因为有另一个格点在前面。例如,图 8-13 标出了第一象限内的可见格点,这些格点用紫色圆点表示。黑色直线显示了点(1, 1)挡住了点(2, 2)、(3, 3)等等;点(2, 1)挡住了(4, 2)和(6, 3);点(3, 2)挡住了(6, 4)。

Image

图 8-13:可见和被遮挡的格点

可见点的坐标,如(1, 1)、(7, 2)和(3, 8),是相对素数。被遮挡的点坐标,如(6, 8)和(2, 4),则不是。图 8-13 中显示的 8×8 方格里有 44 个可见格点,共 64 个格点,因此可见格点的比例是 44 / 64 ≈ 0.6875。这告诉我们 1 到 8 之间的数字对中有相对素数的比例。

现在假设我们扩大了正方形的大小。可见的格点数和相对素数对的数量会发生什么变化呢?这两个数字当然都会增长,但以非常特定的方式增长。随着正方形大小的增加,正方形中可见格点所占比例会趋近于大约 0.608 的极限值。令人惊讶的是,这个数字与π相关。它是 6/π²。之所以这样,是因为这个原因稍微有些复杂,不适合在本书中讨论(如果你有兴趣,它与黎曼 ζ 函数有关),但我们仍然可以探讨该比率的变化,并利用它来估算π的值。

项目 32:仅使用可见的格点

让我们编写一个程序,计算给定大小的第一象限正方形内可见格点的数量,并使用该计数来计算π的近似值。(我们这里使用正方形而不是圆形,因为在正方形中使用嵌套循环生成格点更容易。)由于每个可见格点的坐标将是相对素数,我们可以使用我们为项目 9 在第二章中创建的自定义 gcd(最大公约数)块来帮助(查看图 2-17,它的定义在第 38 页)。如果一组坐标的最大公约数是 1,我们就找到了一个可见的格点。图 8-14 显示了代码。

Image

图 8-14:通过计算可见格点来近似π**

我们提示输入象限的大小,然后使用嵌套循环 ❶ 测试正方形内所有格点,正方形的左下角是(11),右上角是(sizesize)。我们从(1,1)开始,这样我们总是在计算一对正整数的 GCD。对于每一个其坐标的 GCD 为 1 的可见格点,我们递增 count 变量。

循环完成后,我们使用 count 的值来近似π。我们已经知道以下内容:

Image

解出π,我们得到:

Image

我们将在程序结束时进行这一计算 ➋。

结果

运行这个程序并对一个相当大的正方形进行计算,比如 size = 1000,然后观看 Scratch Cat 在几秒钟内统计格点数量,十分有趣。图 8-15 显示了结果。如之前所说,样本越大,近似结果越精确。

Image

图 8-15:大小为 1,000 的正方形中的可见格点

再次强调,π的值至少在前几位小数上是准确的。

Image 编程挑战

8.3 可见格点枚举背后的序列是:

π²/6 = 1 + 1/4 + 1/9 + 1/16 + . . . + 1/n² + . . .

使用 Scratch 来验证这一点,尝试计算前几个部分和:

1, 1 + 1/4, 1 + 1/4 + 1/9, . . .

8.4涉及无穷级数的π公式为π/4 = 1 – 1/3 + 1/5 – 1/7 + ……。这有时被称为格雷戈里级数。编写 Scratch 程序,使用这个公式来计算π的前几个数字。

8.5挑战 8.3 中的级数由所有正项组成,而格雷戈里级数的项在正负之间交替。比较每个级数需要多少项才能得到精确到小数点后三位的π值。一般来说,交替级数的收敛速度比正项级数慢得多。

8.6在项目 31 中,我们使用了两种区域计算方法来近似π:一种是使用正方形中的每个点,另一种是随机抽样点。尝试将类似的随机方法应用于项目 32。检查随机抽样的格点,计算有多少点的坐标是互质的,并使用该计数来近似π

结论

数字π在数学中出现的地方很多,这也导致了许多不同的计算方法来近似它的值。由于 IEEE 754 浮点表示的限制,Scratch 无法精确表示π。不过我们也做不到这一点,因为π的数字是无限延续的!不过,在 Scratch Cat 的帮助下,我们可以用多种方式轻松近似π的值,精度可以达到 15 或 16 位。

第九章:## 接下来做什么?

图片

祝贺你通过了以 Scratch 辅助数学和以数学辅助 Scratch 的八章!希望你在这里读到的内容能激励你继续尝试和学习更多。本章提供了一些关于下一步去哪里的想法,无论你是想尝试另一种编程语言,还是寻找新的数学问题和解决方法。

学习其他语言

我是在上世纪 70 年代初学习编程的,当时用穿孔卡编写 FORTRAN 和 COBOL 代码,站在神奇的作业控制卡和等待计算机中心打印输出之间。在 1972 年达特茅斯学院的数学协会(MAA)会议上,我有机会尝试约翰·克门尼的 BASIC,使用分时共享打印终端让我感到惊讶。我从未想过会有像 Scratch 这样的语言,它对每个人都是可接触的,具有拖放界面、集成声音和图形以及能够在眨眼间进行复杂计算的能力。

互联网提供的按需资源已经改变了世界。然而,1970 年我用 FORTRAN 写的第一个程序(当然是“Hello, world!”之后)是一个基于章节 1 中的基数转换实用程序,用于二进制和十进制之间的转换。计算工具箱多年来发生了巨大变化,资源的可用性、功能和易用性都有了显著提升,但探索的数学理念仍然具有相同的基础。

如今,我不建议学习 FORTRAN 或 COBOL,但你可能想要探索其他编程语言。在章节 1 中,我们讨论了 Scratch 中数字的浮点表示限制了可以研究的数字范围。其他语言设计时没有这些限制,或者它们有标准扩展可用以克服这些限制。其中两种语言,Python 和 Mathematica,与树莓派计算机捆绑在一起,以及 Scratch。Mathematica 是一款商业产品,可在 Linux、Windows 或 macOS 上运行,而 Python 可以在www.python.org免费下载,适用于多种操作系统。

这两种语言特别适合探索数字理论的应用,因为它们本地支持任意精度整数运算——没有溢出和四舍五入。例如,图 9-1 展示了 Mathematica 计算 2¹⁰⁶(flintmax 的平方),这与我们在章节 1 中编写的 Scratch 程序计算的大值相同。

图片

图 9-1:在 Mathematica 中计算 2¹⁰⁶

图 9-2 展示了 Python 中相同的计算。

图片

图 9-2:在 Python 中计算 2¹⁰⁶

不仅计算结果是用所有数字显示,并且答案是以数字形式给出的,而不是字符串形式,而且语言本身有原生的幂运算符(**^),使得指数表达式的编写变得非常简单,无需编写循环代码。

查找更多问题

如果你想探索更多的数学问题,可以从你周围的世界开始。你可能会看到一串数字,心里想“如果……呢?”或者发现一个模式,觉得“这个有意思!”通过 Scratch,你可以探索看看接下来会发生什么。这个模式会继续吗?如果会,可能是时候寻找原因了。如果模式中断了,或许你可以加上一些额外的条件来修复它,拯救局面。

还有很多其他人提出的问题,你可能会想要探索。别人提出的问题保证至少会对另一个人感兴趣(即提出问题的人),而且它很可能是一个“金发姑娘问题”:既不太容易到没有人关心,也不太难到没有人能解决。

我最喜欢的、利用计算机来支持数学问题的网站是 Project Euler (projecteuler.net)。它提供了超过 800 个问题,随着时间的推移还会不断增加,提供了各种类型和难度等级的计算挑战。你可以追踪自己的进度,并通过解决某些问题获得奖励,还可以在论坛中与其他解题者交流,分享他们的见解和代码。

正如 Project Euler 网站所说,每个问题都是根据“1 分钟规则”设计的。这意味着,尽管设计一个成功的算法可能需要数小时的思考和编码,但一个高效的程序将允许即使是性能较低的计算机也能在不到一分钟的时间内给出答案。例如,网站上的第一个问题如下:

如果我们列出所有小于 10 的自然数中是 3 或 5 的倍数的数,我们得到 3、5、6 和 9。它们的和是 23。

求所有小于 1,000 的 3 或 5 的倍数之和。

让我们用 Scratch 的方式来解决它!

Project 33: 破解 Project Euler 问题 1

图 9-3 展示了一些代码,用于计算所有小于给定限制的 3 或 5 的倍数的和。

Image

图 9-3:Project Euler 问题 1 的代码

这是一个repeat循环,测试每个从 1 到指定上限减一的数字(问题描述中说“低于 1,000”,所以我们在循环中不包括上限本身 ❶)。我们使用mod块来检查是否是 3 或 5 的倍数 ➋,如果符合其中任何一个可整除条件,则增加sum

结果

让我们看看 Scratch Cat 对这个问题的看法。图 9-4 展示了一个解决方案,适用于上限为 1,000 的情况。

图片

图 9-4:项目欧拉问题 1 的答案

当然,Scratch 生成答案的时间不到一分钟!

破解代码

项目欧拉问题要求我们找出所有小于 1,000 的 3 和 5 的倍数的和,Scratch Cat 给出的答案是 233,168。问题描述中还给出了 10 作为示例,并给出了和为 23。那么,如果我们尝试其他 10 的幂呢?表 9-1 显示了一些结果。

表 9-1: 3 和 5 的倍数之和

上界
10 23
100 2,318
1,000 233,168
10,000 23,331,668
100,000 2,333,316,668
1,000,000 233,333,166,668
10,000,000 23,333,331,666,668

看起来好像有一个模式在出现(看看那些重复的 3 和 6!),但是当我们处理更高的 10 的幂时,代码会变得很慢。问题是程序逐一检查每个数字,因此必须数到结束值。数到 10 亿所需的时间是数到 1,000 的 1 百万倍。也就是说,即使你的计算机能在 1 秒钟内数到 1,000,它也需要 1 百万秒,或者超过 11 天,才能数到 10 亿。根据你的耐心,这可能是等待答案的时间太长。

理想情况下,应该有一种方法能够在不逐一计算的情况下将所有 3 和 5 的倍数加起来。关键是看到 3 的倍数加起来是 3 + 6 + 9 + . . . = 3(1 + 2 + 3 + . . .),然后认识到 1 + 2 + 3 + . . . 是一个三角形数列,就像我们在第四章讨论的那样。同样,5 的倍数加起来是 5 + 10 + 15 + . . . = 5(1 + 2 + 3 + . . .)。

我们有一个来自第四章的公式来计算n的三角形数,该公式来源于图 4-13:

图片

我们可以使用这个公式来简化计数过程,如图 9-5 所示。

图片

图 9-5:项目欧拉问题 1 的三角形数方法

首先,我们创建一个自定义块来计算n的三角形数❶。然后,我们在主程序中使用这个块三次。第一次,我们传入range/3 的下限,然后将结果乘以3。这给我们的是所有小于range的 3 的倍数的和。第二次,我们传入range/5 的下限并将结果乘以5,这给我们的是所有小于range的 5 的倍数的和。

将这两个数字相加能让我们接近答案,但存在一个问题:任何 15 的倍数被计算了两次,作为 3 的倍数和 5 的倍数。所以我们再使用一次 Triangular 块,传入 range/15 的地板值,并将结果乘以 15。这会给我们所有小于或等于 range 的 15 的倍数之和,我们将其从 sum 中减去,得到最终结果 ➋。这个技巧是一个通用方法,叫做 包含–排除原理;你可以在任何时候使用它来跟踪多个重叠条件满足的次数,而不重复计算重叠部分。

果然,这段代码的输出与程序的第一个版本匹配,且优点是它能够快速给出答案,一直到 flintmax。

Image 编程挑战

9.1更改图 9-3 中的代码,以允许使用与 3 和 5 不同的倍数。让 Scratch 猫询问使用哪些倍数,并将其添加到程序中。

9.2使图 9-5 中的代码也能适用于不同的倍数组合。这里要小心:你需要确保即使这些数字不是互质的,解决方案仍然有效。

9.3弄清楚如何在有三个互质的倍数需要筛选而不是两个时,编程实现包含–排除技巧。

超越 Project Euler

如果你已经完成了 Project Euler,并准备继续挑战更多内容,以下是一些你可以在你喜欢的搜索引擎中尝试的搜索词,可能会引导你找到其他有趣的问题:

数学挑战问题 这可能会出现不同难度级别的问题;选择适合你的那些。

Scratch 挑战 这些问题大多集中在图形和游戏方面,但你也会发现一些数学挑战。

Scratch 数学游戏 这更可能出现以数学为重点的练习和想法。

MAA Convergence 这将引导你到美国数学会的期刊《Convergence》,该期刊有一个按时间顺序、地理位置或主题整理的开放访问问题列表。重点通常不在于编程,但有时进行一些实验是一种了解事物发生原理的好方法。

这些搜索结果有可能出现太简单或太困难的问题。但即便一个问题让你感到无聊或困惑,它也可能是一个通往合适变体的入口。

更多 Scratch 项目供你探索

如果你想找到更多专门为 Scratch 设计的数学项目,一个很好的地方是 Scratch 网站,* scratch.mit.edu*。在顶部有一个搜索框(见图 9-6),你可以用它来查找社区成员发布的各种项目。

Image

图 9-6:在 Scratch 中寻找更多数学内容的地方

Scratch 哲学认为代码应该被共享,任何已发布到网站上的内容都可以复制和扩展,只要你给原作者适当的信用。网站上有许多程序,它们是初学者的作品,适合轻松玩耍,还有一些非常精巧的作品。

以下是一些用于 Scratch 网站的搜索词,将引导你找到与本书中构建的程序相关的有趣项目:

第一章:计算机如何看待数字
  • Image二进制时钟

  • Image三进制

  • Image八进制

  • Image小数分数

  • Image二分查找

  • Image数学解析器

  • Image浮动点数

第二章:探索可除性和素数
  • Image模乘法表

  • Image孪生素数

  • Image厄拉托斯特尼

  • Image网格序列

  • Image分数加法器

  • Image埃及分数

  • Image可除性测试

第三章:用素因数分解拆解数字
  • Image试除法

  • Image因式分解

  • Image梅森素数

  • Image约数和

  • Image费马素数

  • Image半素数(另一种说法是双素数)

  • Image卢卡斯–莱默

  • Image螺旋图

  • Image重复数(像 1111 这样的数字,只包含数字 1)

第四章:在序列中寻找模式
  • Image斐波那契数列

  • Image帕多万数列

  • Image三角形数

  • Image泽肯多夫

  • Image五边形数

  • Image几何艺术

  • Image数字模式

第五章:从序列到数组
  • Image帕斯卡三角形

  • Image二项式系数

  • Image魔方阵

  • Image直方图

  • Image分布

  • Image模算术

第六章:制作代码,也破解它们
  • Image秘密代码

  • Image凯撒密码

  • Image隐写术

  • Image模逆

  • Image一次性密钥

  • Image频率分析

  • Image公钥

第七章:计数实验
  • Image组合学

  • Image8 皇后

  • Image握手问题

  • Image递归

  • Image谢尔宾斯基

第八章:三次π的帮助
  • Image布丰

  • Image水龙头

  • Imageτ(不是约数函数的数量,而是与 π 相关的一个量!)

  • Imageπ 计算器

结论

Scratch 就像是一个思维放大器,让你能比自己独立思考时更加深入地看到模式。在本书中,我们使用 Scratch 探索了算术、数论、几何学、密码学、序列和数组——这只是它能做的一部分。

无论你是坚持使用 Scratch 还是转向其他编程语言,Scratch 提供的算法和通过计算机辅助视觉看待世界的方式都能帮助你成为一个更具创意思维的人。我希望有一天能在我的 Scratch 工作室见到你 (scratch.mit.edu/studios/29153814),也很期待看到你决定分享的本书程序的任何变体或扩展。在那之前,继续编程吧!

第十章:A

编程挑战提示

图片

学习数学涉及在课堂上学习和理解数学概念,使用教科书和工作表。这可能是一个被动的过程,你只是试图吸收信息。另一方面,数学涉及积极地应用概念来解决问题并创建新的数学模型。这个过程需要创造力和解决问题的能力,以及批判性和逻辑性思维的能力。

编程是做数学的重要组成部分,它让你能够自动化复杂的计算和构建,从而以在纸上或脑中无法实现的方式探索数学思想。本书的全部内容都是关于使用 Scratch 做数学——编写算法和进行数值计算——编程挑战是邀请你“动手”解决一些实际问题。本章提供了一些注释和代码片段,以帮助你解决这些挑战。

第一章:计算机如何理解数字

挑战 1.1

作为第一步,你可以使用像图 A-1 中的循环来列出基数 b 数字的各个数字。然后,你可以将这些数字组合成一个字符串,使答案看起来像一个基数 b 数字,使用像图 A-2 中的代码。

图片

图 A-1:转换为基数 b

对于基数 11 或 12,检查 convert mod b 是否为 1011,如果是,则在将结果添加到 digits 列表之前,替换为 TE

图片

图 A-2:将数字组合成一个字符串

挑战 1.2

图 A-3 显示了一些适用于基数 11 或基数 12 的代码。你可以添加更多情况以支持基数 16。

图片

图 A-3:将字母替换为数字

当你从二进制转换为十六进制时,每四个二进制数字(从最不重要的位开始,或者说最右边的位)相当于一个十六进制数字。例如,二进制的 1011 转换为十六进制的 E。

挑战 1.3

你可以通过向列表中添加新项来扩展它。假设你已经要求用户指定列表的起始值、变化值(存储在变量 change 中)以及列表的长度(存储在变量 length 中)。图 A-4 显示了一些代码,用于根据指数增长和线性增长计算列表中的剩余值。每种类型的增长都有自己的列表。

图片

图 A-4:比较指数增长和线性增长

挑战 1.4

使用线性增长,计算到 Scratch 的绝对最大数值将需要漫长的时间,但使用指数增长(参见挑战 1.3),你可以非常迅速地达到目标。例如,通过倍增,只需要 1,024 步。Scratch 能表示的最大数字约为 1.08 ⋅ 10³⁰⁸;超出这个范围,报告的值为Infinity。图 A-5 展示了如何达到这个数值。

挑战 1.5

解决这个挑战的最佳方法是将 IEEE 表示中的 64 位二进制数字当作字符串处理。幸运的是,Scratch 舞台上有足够的空间显示一个包含 64 个字符的变量的完整值。图 A-6 对字符进行了编号(按十个一组),以展示屏幕的宽度,尽管你实际二进制表示时只会使用字符 0 和 1。

Image

图 A-5:通过倍增达到无限

Image

图 A-6:舞台上有足够空间显示 64 位。

将二进制表示视为字符串,可以通过letter of块提取单个位。你可能希望将这些位存储在一个列表中,这样更容易操作它们。

挑战 1.6

要计算 3^(n),你需要做的就是将replace item块 ❶中的乘数从2改为3,见图 1-13。图 A-7 显示了更新后的块。

Image

图 A-7:计算 3 的幂而不是 2 的幂

挑战 1.7

确保在将答案从五位数字块的列表转换回字符串时,较小的数字被适当填充 0。例如,如果“数字”是 435,它应该变成 00435 然后再与字符串连接。你可以通过几个 if 块来实现这一点。

挑战 1.8

从提示一个数字开始。将其视为字符串,并通过逐个取出字符串中的字符(从最右边的字符开始,即最低位数字)来创建一个数字列表。对第二个数字也做类似的处理。将两个列表中相应的数字相加,并且如果有进位,将其分开存储,以便加到下一对数字上。如果数字位数不同,可以通过在较小的数字的数字列表中填充 0 来完成加法运算。

第二章:探索可除性和质数

挑战 2.1

图 A-8 显示了一个自定义块,可以在无限循环中使用,既用于 Scratch Cat 的回合,也用于筛选你的答案,以检查你是否正确找到模式。它检查一个数字是否能被 5、7 或 35(即 5 和 7 同时)整除,并将该数字“转化”为适当的短语。

Image

图 A-8:用于玩 Fizz-Buzz 的块

挑战 2.2

为了计算交替数字和,最好将输入数字 n 视为字符串,就像我们在 图 2-2 的除九法程序中所做的那样。然后,n 的每个数字可以当作字符逐个提取并单独处理,使用 letter i of n。为了在加法和减法之间交替,可以创建一个名为 plus minus 的变量,并在每个数字之后将其乘以 -1 来切换其值,从而在 1-1 之间切换。图 A-9 提供了一些起步的代码。

Image

图 A-9:使用交替数字和来测试是否能被 11 整除

挑战 2.3

图 A-10 中的代码计算了多少随机数能被 9 整除。

Image

图 A-10:测试随机数是否能被 9 整除

不过,这是一个有点技巧性的问题。你可能认为约有 1/9 的数字能被 9 整除,但在从 1 到 100 这个特定范围内,虽然有 100 个数字,只有 11 个是 9 的倍数,所以实际的概率是 11/100,比 1/9 稍小。尽管如此,由于样本量很小,很难察觉到这个差异。

挑战 2.4

在除九法程序的末尾,变量 x 保存着“校验位”。如果把数字当作字符串来处理,并使用 join 块,就可以轻松地将该校验位加到原始数字上,正如 图 A-11 所示。

Image

图 A-11:给数字添加“校验位”

Scratch Cat 不需要通过校验位说出答案。你可以只让变量 with check digit 在舞台上可见。

挑战 2.5

除九法无法帮助捕捉置换错误,因为无论数字顺序如何,数字之和始终相同。

挑战 2.6

表 A-1 显示了随着上界增加,素数比率如何变化。

表 A-1:素数的比率

上界 素数数量 比率
10 4 0.4
100 25 0.25
1,000 168 0.168
10,000 1,229 0.1229
100,000 9,592 0.09592

素数的相对数量似乎在减少。

挑战 2.7

你需要在筛选输出中寻找连续的 false。从 188,030 开始,有连续的 77 个 false

挑战 2.8

图 A-12 显示了一段代码,用来扫描 primes 列表中的双胞胎素数。

Image

图 A-12:寻找双胞胎素数

挑战 2.9

最简单的方法可能是创建一个新列表,其中每个条目包含原始primes列表中的六个连续数字,并将它们作为字符串连接在一起。当你在 Scratch 舞台上查看生成的列表时,它将看起来像一个具有六列的表格。然而,你需要小心格式设置,以确保每一列中不同长度的数字对齐得很好。(请参阅第 21 项目和第五章,了解如何实现这种格式设置的示例。)

第 2 列包含所有对 6 取模为 2 的数字,它们能被 2 整除。第 4 列和第 6 列的元素也都能被 2 整除,而第 3 列的元素能被 3 整除。这意味着,素数只能出现在第一行之后的第 1 列和第 5 列。

挑战 2.10

如在“破解代码”部分讨论的那样,你可以使用 Scratch 的感知模块中的timer块来跟踪运行时间(详见第 39 页和第 9 项目)。在开始计算时设置一个名为timer1的变量,完成后设置一个名为timer2的变量,然后计算差值(timer2 - timer1),以查看已过去的时间,单位为秒。

如果你正在尝试计时某些非常快速的操作,可能很难分辨出计算机在处理你的问题时花费了多少时间,以及它花费在后台任务上的时间是多少。为了得到更准确的估算,你可以让计算机重复执行你的问题若干次——比如 100 次——然后将总的经过时间除以次数,得到每次运行的平均时间。

挑战 2.11

你可以为图 2-17 中的自定义gcd块添加一个步骤计数器,以查看算法执行了多少次循环。图 A-13 展示了更新后的块定义。

图片

图 A-13:计算欧几里得算法完成所需的步骤数

当商较小时时,算法需要更长的时间,因此保持商为 1 可以形成一个漂亮的余数模式。从 3 = 1 ⋅ 2 + 1 开始,接着是 5 = 1 ⋅ 3 + 2,8 = 1 ⋅ 5 + 3,以此类推。你将在第四章再次看到这个模式!

第三章:通过素因数分解拆分数字

挑战 3.1

要找到一个完美数,只需检查除数和代码的结果是否等于原始输入数字(第 12 项目,图 3-8)。事实证明,任何偶数完美数 n 都满足以下方程,其中 2^(p) – 1 是一个素数:

n = 2^(p – 1)(2^p – 1)

p = 2 时,方程给出完美数 6。当 p = 3 时,方程给出 28。当 p = 5 时,方程给出 496,而当 p = 7 时,方程给出 8,128。

没有人知道是否存在奇数完美数。

挑战 3.2

图 A-14 展示了一些代码,用于识别一个数字是完美数、过剩数还是缺乏数。将其添加到图 3-8 中除数和程序的末尾。注意,由于除数和包括原始数字answer,我们首先减去answer以跟踪适当除数的总和❶。

图片

图 A-14:确定一个数字是完美数、过剩数还是缺乏数

现在您需要编写一个计数例程,该例程循环直到某个上限,自动将数字输入到除数和程序中。使用三个变量来跟踪过剩数、完美数和缺乏数的数量,每当发现每种类型的数字时,增加相应的变量。10 以内没有过剩数,但 100 以内有 22 个,1000 以内有 246 个。10 以内有 9 个缺乏数,1000 以内有 751 个。

挑战 3.3

该代码为 0 指数给出了正确的答案,即使底数为 0。

挑战 3.4

图 A-15 展示了图 3-7 的修改,允许使用正指数或负指数。

挑战 3.5

图 A-16 展示了如何在计算 τ(n) 时保存指数列表。该代码替换了项目 11 代码中的原始repeat循环(见图 3-6)。

图片

图 A-15:处理正指数或负指数

图片

图 A-16:保存 指数 列表

在计算 σ(n) 时,您可以使用相同的索引变量i,同时遍历素因子和指数。

挑战 3.6

图 A-17 中的自定义块使用一个布尔变量primeQ来跟踪其答案。我们首先将primeQ设置为true,然后通过循环改变其值为false,如果p可以被任何数字整除。我们只需要查找不超过p的平方根的除数。

图片

图 A-17:测试一个数字是否为素数

您可以通过在第一个false之后退出,来让图 A-17 中的代码运行得更快。

挑战 3.7

图 A-18 展示了一个块,用于找到n之后的下一个素数。

图片

图 A-18:寻找下一个素数

该块从n开始向前推进,将值传递到挑战 3.6 中的素数检查块,直到它达到一个使primeQtrue的数字。

挑战 3.8

想象内轮所走的轨迹是直线而不是环的内部。每次完整旋转时,环会在直线轨迹上添加 96 个齿。96 个齿和 b 个齿的最短公共轨迹包含 LCM(b, 96) 个齿——这时,轮子会回到起点,绘制完成。每 b 个齿就确定一个点,因此总共有 LCM(b, 96) / b 个点。

挑战 3.9

你可以通过挑战 3.6 中的自定义积木来完成此挑战。

挑战 3.10

为了比较分解方法,你可以使用图 2-19 中的计时器黑客。对于小于 flintmax 的数字,试除法通常更快,但如果被分解的数字 n 是一个双素数,且两个素因子都接近 Image,那么费马分解法可能会更快。

挑战 3.11

这里是一些通用代码,使用 Pen 扩展来用像素绘制舞台,这些像素的颜色值(0 为白色,1 为黑色)来自二进制消息。我们将从图 A-19 中的initial setup积木开始。

Image

图 A-19:准备绘制消息

这个积木首先将起始坐标设置为舞台的左上角附近的位置❶。然后,它会询问widthheight,即你希望消息包含的列数和行数。接下来,它通过将舞台的总宽度和高度分别除以消息的宽度和高度来确定每个块的大小。将通过在适当的像素位置添加印章来绘制消息的精灵是一个 9×9 的黑色方块,该方块会被缩放以适应正在绘制的网格➋。

图 A-20 中的代码完成了绘制消息的工作。

Image

图 A-20:绘制消息

我们通过两个循环来指定消息的矩形大小,一个循环处理height,另一个循环处理widthmessage是一个比特串(0 和 1)。我们使用变量n逐位查看消息,为每个为 1 的比特绘制印章❶。在每行的末尾,我们将光标移动到下一行的开始位置➋。

你可以使用这个程序通过复制并粘贴 Arecibo 消息的 1,679 位来绘制其内容,或者如果你愿意,也可以手动输入它们。或者,你可以在“3-PC11 二进制消息到像素” Scratch 项目中测试该程序,该项目由 rumpus88366 创建(* scratch.mit.edu/projects/771257850 *),如图 A-21 所示。

Image

图 A-21:一个测试消息

当消息作为一个七列五行的矩形绘制时,它会生成一个更简单的输出(见图 A-22)。我们称之为 S,代表 Scratch

Image

图 A-22:可视化测试消息

你可以以各种方式扩展这个程序。例如,程序可以只要求输入消息,并对消息的长度进行因式分解,以找到矩形的适当尺寸,假设该长度是双素数。

第四章:在序列中寻找模式

挑战 4.1

Lucas 序列的开始是 2, 1, 3, 4, 7, 11, 18, 29, 47, …… Lucas 数字(用 l 表示)和斐波那契数字(用 f 表示)之间有很多有趣的关系。例如:

f[n] [– 2] + f[n] = l[n]

挑战 4.2

为了从 f[1] 向后扩展斐波那契数列,我们需要理解 f[0]、f[-1]、f[-2] 等等。我们可以从 f[0] 应该是我们需要加到 f[1] = 1 才能得到 f[2] = 1 的数开始。因此,f[0] = 0。为了保持递推关系,我们需要 f[-1] = 1,f[-2] = -1,f[-3] = 2,依此类推。它是原始的斐波那契数列,但每隔一个值为负数。

图 A-23 中的程序使用了一系列 if 语句来报告给定索引的斐波那契数,包括负索引。

Image

图 A-23:计算正负斐波那契项

最初的几个 if 语句处理了序列中索引从 -2 到 +2 的初始值。对于其他索引,自定义的 Fibonacci 块计算斐波那契数,假设索引是正数。对于奇数负索引,结果会被转换为负数。

挑战 4.3

在 flintmax 之前的最后一个值出现在第 78 行。结果报告为 Infinity 之前的最后一个值出现在第 1,476 行。

挑战 4.4

要生成系数,你需要知道序列中第 0、1 和 2 项的值。我们分别称这三个值为 rst。(在第四章中,我们大多讨论的是以第 1 项开始的序列,但它们也可以有第 0 项。)我们可以将这三个值看作是二次多项式函数 f (x) = ax² + bx + cx 等于 0、1 和 2 时的结果。

想想当 x 等于 0 时,二次多项式会发生什么:

Image

系数 ab 被消去,只剩下 c。因此我们称为 r 的序列第 0 项的值,也必须是 c 的值。那么 x = 1 的情况呢?

Image

这告诉我们,序列中的第 1 项,也就是我们称之为 s 的项,是三个系数的和。那么 x = 2 时呢?

Image

这相当于序列中的第 2 项,也就是我们称之为 t 的项。

通过一点代数运算,我们可以利用这些结果写出 abc 的单独公式。它们是:

Image

在你的程序中,你将从一个数列中取出第 0、1、2 项,并将它们代入这些公式作为变量 rst。对于五边形数列,你应该得到 a = 3/2,b = -1/2,c = 0。

这种巧妙的数学技巧,用来通过二次多项式的前几个值来确定系数,被称为 未定系数法

挑战 4.5

这是一个巧妙的方式,要求找出你能找到的最长的连续合成数集。你在挑战 2.7 中解决了这个问题。

挑战 4.6

图 A-24 修改了 项目 1 的十进制到二进制转换器,以生成一组二进制数。新的 有多少个 1? 块计算每个二进制数中 1 的个数,并将结果存储在一个单独的列表中,从而创建出所需的序列。

Image

图 A-24:计算二进制数中的 1 的个数

思考这个数列的一种方式是将其视为由 1、2、4、8、... 等元素构成的块。(每个块的长度是 2 的幂。)要获得下一个块,首先复制前一个块,然后在此基础上再复制一份前一个块,但每个元素加 1。例如:

  • Image第一块是 1。

  • Image第二块是 1、1 + 1(或者 1、2)。

  • Image第三块是 1、2、1 + 1、2 + 1(或者 1、2、2、3)。

  • Image第四块是 1、2、2、3、1 + 1、2 + 1、2 + 1、3 + 1(或者 1、2、2、3、2、3、3、4)。

这个数列的一个有趣特点是它包含了自身的副本。如果你只看位置 2、4、6、8、10、... 的元素,你会发现它们与原始数列完全相同!

挑战 4.7

斐波那契数列再次出现在第一次、第二次及更高次差分中。

挑战 4.8

类似于斐波那契数列,2 的幂次再次出现在第一次、第二次及更高次差分中。两者的底层指数增长使得它们的差分呈现出相同的模式。

挑战 4.9

第三差分是常数,第四及更高次差分为 0。对于平方数列,第二差分是常数,值为 2 ⋅ 1 = 2,而对于立方数,第三差分是常数,值为 3 ⋅ 2 ⋅ 1 = 6。这是另一个值得检查的模式!

第五章:从数列到数组

挑战 5.1

每当 n! 的末尾有一个零时,就表示有一个因子是 10。因子 10 来自因子 5 和因子 2。由于因子 2 很多,因此更容易计算因子 5 的个数。每隔五个数就会有一个 5 的倍数,它为 n! 贡献一个因子 5。直到 n,总共有 floor of n/5 个 5 的倍数。此外,还有来自更高次方的因子 5 的额外因子。例如,每隔 25 个数就会有一个 25 的倍数,贡献一个额外的因子 5;每隔 125 个数就会有一个 125 的倍数,贡献又一个额外的因子 5。图 A-25 中的程序第一次通过 repeat until 循环时只计算因子 5,然后通过循环的额外周期来计算来自更高次方因子 5 的额外因子。

Image

图 A-25:计算 n 阶乘末尾的零的个数

程序报告显示 25 的阶乘末尾有六个零。

挑战 5.2

你可以改编来自项目 19 的行生成程序(图 5-7),在计算出行之后提取行条目。图 A-26 展示了一个自定义块,用于计算该行并提取 k 位置的条目。你也可以像我们在项目 18 中做的那样(图 5-1)通过阶乘的商来进行计算,但这种方法在处理更多行时能提供更准确的结果。

Image

图 A-26:计算特定的二项式系数

你需要重复调用这个自定义块,用连续的n值,每次保持k2不变,将每组结果添加到它自己的列表中。你应该会发现,C(n, 2) 对角线上的数字就是三角形数字。

挑战 5.3

诀窍是对每个值取模 2,这样偶数变成 0,奇数变成 1。图 A-27 显示了前 32 行帕斯卡三角形模 2 的一种解释。在这里,1 被可视化为黑色方块,0 则为空白空间。

Image

图 A-27:帕斯卡三角形模 2

结果的模式被称为谢尔宾斯基三角形。这是一个著名的分形例子,一种在不同尺度上重复的模式。

挑战 5.4

你可以使用来自项目 9 的自定义gcd块(参见图 2-17)来筛选行和列索引,然后在表格中只包含那些行和列索引都与模数互质的条目。图 A-28 显示了代码的更新。

Image

图 A-28:将运算表限制为与模数互质的行和列

左侧的堆栈用于Make index row块的定义(参见图 5-15),取代repeat modulus循环中的set row块。右侧的堆栈则放入主程序堆栈(参见图 5-17),替换当前的add row to table块。

通过这些更改,模 12 的乘法表的显示效果如图 A-29 所示。

图片

图 A-29:模 12 的简化乘法表

挑战 5.5

你可以修改项目 12 中的自定义power块(参见图 3-7),使其与模运算一起工作,从而接受一个基数和一个指数并计算该基数的适当幂,模p。然后,你可以构建一个自定义的布尔块,测试一个特定的数字n是否是一个特定素数p的原根。图 A-30 展示了这两个块。

图片

图 A-30:寻找原根

primitive root?块中的循环会到达p - 2,因为如果此时还没有找到一个n的幂等于 1,那么最后一个幂将是 1,n将是一个原根。

有了这些组件,主程序就是一个循环,用于询问一个素数并找到第一个可以作为原根的数字。作为后续,你可以考虑的两个数学问题是:为什么素数必须有原根,以及是否所有复合数都有原根。

挑战 5.6

你可以精简图 5-15 中的Make index row块,去除格式化表格时的索引行和适当填充空格的工作。然后,修改图 5-16 中的自定义pad块,在x之前插入逗号,而不是一个或多个空格。

你可能还想在将每一行添加到表格之前,去除每一行开头的初始逗号。你可以通过另一个自定义块来完成这个操作。Scratch 并没有像处理列表元素那样让我们处理字符串中的字符,所以你可以像图 A-31 所示,从第二个字符开始重写字符串。

图片

图 A-31:从字符串中移除第一个字符

第六章:制作代码,并破解它们

挑战 6.1

在你喜欢的绘图程序中绘制一个字母环,类似于图 A-32。将其导入 Scratch 作为标记为Outer Wheel的精灵。

图片

图 A-32:字母环

现在,缩小Outer Wheel精灵,制作一个足够小以适应其中的精灵,并将其标记为Inner Wheel。你需要的唯一代码是一些操作小精灵的代码,如图 A-33 所示。

图片

图 A-33:每次旋转 内轮 一个字母

这段代码将内轮旋转到左侧或右侧。13.846 度的角度是 360 / 26,因此每次按键都会使内轮相对于外轮移动一个字母。

挑战 6.2

H 变成 I,A 变成 B,L 变成 M(见图 A-34)。

图片

图 A-34:将 “HAL” 移位 1

挑战 6.3

是的 用法语是 oui。你的解码环可以通过移位 16 来翻译这个单词:Y 变成 O,E 变成 U,S 变成 I(见图 A-35)。遗憾的是,它的神奇翻译能力并不能延伸得太远!

图片

图 A-35:YES?

挑战 6.4

这是项目 23 中的程序(见图 6-9)的工作。移位 16 后,解密得到消息:“THERE WILL BE A HOT TIME IN THE OLD TOWN TONIGHT!”

挑战 6.5

问题在于,由于操作顺序的关系,乘法在执行线性变换代码时发生在移位之前。如果你尝试同时撤销两者,乘数也会作用于移位,导致移位结果错误。这就是我们最初将解密过程分为两步进行的原因,以强制在乘法之前先撤销移位。

如果 s 是原始的移位因子,那么解决方法是取 –s 并将其乘以模逆(对字母表大小取模)。然后,使用该结果作为解密的新移位因子,同时将模逆作为新的乘数。这样,当乘数应用时,逆元会相互抵消,从而得到你想要的移位。

挑战 6.6

图 A-36 中的程序实现了这一点。在提示输入 modulus(字母表的大小)和 multiplier(乘数)后,它调用我们自定义的 gcd 模块(来自图 2-17)来确保存在模逆。如果不存在模逆,程序将退出,否则它将通过试错法计算出逆元❶。然后,程序会提示输入 shift 并计算出逆变换的移位 ➋。

图片

图 A-36:寻找数字以撤销线性变换密码

挑战 6.7

如果乘数与字母表的大小不是互质的,那么打乱的字母表将不完整。一些字母会重复出现,其他字母则完全不会出现。另外,也没有模逆,因此无法进行解密。

挑战 6.8

你可以重用本章前面写的一些代码来完成这个挑战,包括图 6-2 中的Alphabet块来构建alphabet列表,以及图 6-19 中的Scramble块来混淆字母表。你还需要一个Initialize块来设置alphabet列表并提示输入消息(你不需要提示输入移位和乘数,因为你将自己生成所有可能的移位和乘数),以及我们老朋友gcd块来自项目 9(图 2-17)。图 A-37 展示了新代码。

图片

图 A-37:生成所有可能的线性变换解密

外部的repeat循环遍历所有可能的乘数❶,而内部的repeat循环遍历该乘数的所有可能移位➌。这两个循环共同生成所有可能的线性变换,利用Scramble根据当前的multipleshift参数来混淆字母表 ➍。每个可能的消息通过逐个字符构建在encrypted变量中,然后加入到可能的解码列表 ➎。在探索给定的乘数之前,gcd测试 ➋确认乘数和字母表大小是互质的。这表示乘数有模逆元素,因此乘数是有效的。

挑战 6.9

混淆字母表的一种简单方法是将每个字母与一个随机字母交换。图 A-38 展示了一个使用自定义swap块来实现这一功能的程序。注意,你需要一个额外的变量x来暂时保存交换的其中一个值。

图片

图 A-38:随机混淆字母表

如果你希望在舞台上看到整个混淆后的字母表,你可以从alphabet列表构建一个字符串,逐个字符地添加(见图 A-39)。

图片

图 A-39:将混淆后的字母表视为一个字符串

挑战 6.10

从扩展版的gcd代码开始,如图 A-40 所示,该代码会记住商和余数,分别存储为qlistrlist

图片

图 A-40:列出欧几里得算法中的商和余数

现在你可以对余数进行线性组合,从而表示最大公约数。当你从底部开始替代时,你最终会得到一个由原始数字ab构成的线性组合,从而得出最大公约数。图 A-41 展示了这一过程。

图片

图 A-41:求解 x y

举个例子,如果你要求程序使用b = 26 和a = 17,你最终会得到x = 2y = -3。这意味着(2 ⋅ 26)–(3 ⋅ 17)= 1,这告诉你–3 ⋅ 17 ≡ 1 mod 26。所以你可以使用–3,或者 26 – 3 = 23,作为 17 的模逆元素。图 A-42 展示了这种情况的输出。

图片

图 A-42:识别 –3(或 +23)为 17 的模逆元素

挑战 6.11

该程序的逻辑在其主要部分中,见图 A-43。

图片

图 A-43:猜字母并查看它如何适配

自定义的Initialize块要求解决一个密码谜题,并设置舞台显示,展示谜题和解答的形状。然后,forever循环要求猜测谜题中的一个字母,并决定它应该替换成什么。如果将解答保持为一个单独字符的列表,而不是一个字符串,那么处理猜测会更容易。因此,有一些背景代码用来在显示的Solution和隐藏的Solution list之间进行相互转换。此项记录工作在update块中完成,如图 A-44 所示。

图片

图 A-44:更新解答

挑战 6.12

图 A-45 展示了一种快速判断字符是否为字母的方法。布尔变量letterQ对于大写或小写字母设置为true,否则为false。(contains块不关心大小写。)

图片

图 A-45:字母测试

一旦你有了这个块,你就可以用它逐个测试字符串中的每个字符。只保留那些使letterQ值为true的字符。

第七章:计数实验

挑战 7.1

图 A-46 中的程序使用替代递推列出了卡塔兰数。

图片

图 A-46:另一个卡塔兰递推

计算在repeat循环❶内实现。请注意,公式是一个单项递推,这意味着我们只需要Cn – 1)来计算C。这意味着我们可以不断使用C变量,而不必查找列表中之前的值。此外,项C(0)根本不需要包含在列表中,因此从一开始列表的索引号就是正确的。这个递推能够产生直到 flintmax 的准确值。

挑战 7.2

该程序的输入将是由项目 28(图 7-1 至 7-3)生成的Catalan列表中的一行,由正斜杠(/)和反斜杠(\)字符组成。假设你已将其中一行提取到input pattern变量中。然后,你可以使用图 A-47 中的代码绘制该模式。

图片

图 A-47:可视化卡塔兰路径

自定义的 draw 块设置了 步长,使得绘图能够合适地适应舞台。然后,主栈中的 repeat 循环使用字符作为绘制图形的食谱,忽略 输入模式 中除了斜杠之外的所有字符。它会为每个正斜杠绘制一条向上的对角线,为每个反斜杠绘制一条向下的对角线。

挑战 7.3

需要注意的模式是,第 n 个卡塔兰数是 C(2n, n) / (n + 1)。

挑战 7.4

你需要做的就是将每个 / 替换为左括号,每个 \ 替换为右括号。为了将括号标注为字母(将标签放在左括号后、右括号前),使其看起来像一个乘法问题,最简单的做法是通过 Catalan 列表中的每个字符串进行两遍处理。第一次遍历将每个斜杠替换为适当的括号,并为字母添加一个占位符符号(图 A-48 中的代码使用了 + 符号)。第二次遍历可以将每个占位符替换为字母表中下一个未使用的字母。

图片

图 A-48:用括号表示卡塔兰数

图 A-49 显示了给定输入字符串的输出结果。

图片

图 A-49: 漂亮的输出 变量将字母替换为加号。

挑战 7.5 和 7.6

假设一个划分是由加号连接的加数组成的字符串,就像我们在图 7-15 中看到的列表那样。我们还假设加数是按递增顺序排列的。由于你需要查看每个加数,第一步可以是将字符串转换为一个由单个加数组成的列表,称为 parts。然后,你可以定义自定义块来报告布尔值,如果列表中没有偶数元素(比如,oddpartsQ),并且如果列表中没有重复的元素(比如,distinctpartsQ)。输出可能如下所示:图 A-50。

图片

图 A-50:测试所有奇数和所有不同的加数

数学家莱昂哈德·欧拉(Leonhard Euler)提出了一个有趣的观察,欧拉是欧拉项目的名字来源,他发现:所有奇数加数的 n 的划分数量与所有不同加数的 n 的划分数量相同。

挑战 7.7

拉马努金做出了另外两个与此相关的观察:任何一个比 7 的倍数多 5 的数的划分数量都能被 7 整除,任何一个比 11 的倍数多 6 的数的划分数量都能被 11 整除。例如,P(19) = P(2 ⋅ 7 + 5) = 490,是 7 的倍数,而 P(17) = P(1 ⋅ 11 + 6) = 297,是 11 的倍数。

第八章:三份圆周率

挑战 8.1

内切三角形的周长等于 3Image,因此我们应将 b(下界)设为 3Image / 2。外接三角形的周长等于 6Image,因此 a 应该是其一半,即 3Image。图 A-51 展示了这些新的起始值。

Image

图 A-51:内切和外接三角形的起始值

如图 A-52 所示,使用这些起始值运行程序的结果, 从第二行开始,确实与使用六边形起始值运行程序的结果相同。

Image

图 A-52:从三角形开始的输出的前几行

挑战 8.2

你只需修改图 8-11 中的两个 set 块 ➋,即可选择随机的非整数坐标。更新后的块如图 A-53 所示。

Image

图 A-53:随机选择非整数坐标

这段代码不仅仍然可以处理非整数,还能提供更好的 π 近似值。

挑战 8.3

图 A-54 展示了一个循环,用于计算系列的部分和,直到给定的项数。

Image

图 A-54:计算 π²/6 的部分和

这里有两个技巧需要注意。首先,我们使用一个自定义块来计算系列的 n 项 ❶。这使得代码容易调整以研究其他系列。在此情况下,该自定义块计算 1/n²,如图 A-55 所示。

Image

图 A-55:计算求和的一个项

第二个技巧是,一旦我们生成了 n 项的系列列表,我们可以通过从最后一项加到第一项来求它们的和 ➋。这是必要的,因为该系列由递减项组成,如果将较小项加到之前(较大项)的和中,某些后续项的小数部分可能会被抹去。

如果你运行这个程序,并逐渐增加项数,你会接近 π²/6 的值,大约是 1.644934。

挑战 8.4

你可以重用挑战 8.3 中用于求和系列前 n 项的代码来解决这个问题。你只需为计算每一项定义一个不同的自定义块,如图 A-56 所示。

Image

图 A-56:计算 π/4 的求和项

这一次,所添加的项是奇数整数,而不是平方数,并且符号在每一项之间交替变化。因此,你需要引入一个新变量 sign,使其在每一项之间在 1-1 之间切换。

目标值大约是 0.785398,但交替级数通常收敛得很慢,所以可能需要一些时间才能到达该值。将此值乘以 4 就能得到π的近似值。

挑战 8.5

挑战 8.3 中的级数在 202 项之后始终正确报告π的前三位数字。对于挑战 8.4 中的交替级数,必须经过 626 项,前三位数字才稳定。

挑战 8.6

与项目 31 中的随机抽样代码不同,在这里你不能使用非整数坐标(参见挑战 8.2),因为计算 GCD 需要整数。不过,你仍然可以使用pick random模块来生成随机格点。代码图 A-57 中有一个使用随机选择的格点坐标的循环。

Image

图 A-57:随机采样格点

它提供了π的近似值,精度与使用图 8-13 中的代码进行穷举枚举得到的值差不多。

第九章:接下来做什么?

挑战 9.1

图 A-58 展示了一个程序,该程序查找任意两个数的倍数之和,直到达到指定的限制。

Image

图 A-58:找到任意两个倍数的和

与我们在项目 33 中将35的值硬编码到程序中的做法不同,这里我们从用户那里获取两个值,并将其作为firstsecond变量进行引用。

挑战 9.2

你可能认为只需要将两个数字相乘就可以得到修正值,但如果这两个数字不是相对质数,这种方法就不适用了。相反,你需要找到这两个数字的最小公倍数(LCM)(对于相对质数来说,最小公倍数恰好等于它们的积)。使用我们在项目 9 中提供的gcd代码来计算最小公倍数非常简单(见图 2-17),因为* b a 的最小公倍数是 b a *的积除以它们的最大公约数(GCD),正如我们在第三章中所指出的那样。

图 A-59 展示了一个更新后的程序,该程序使用三角数技巧和最小公倍数进行包含-排除法,求解任意两个数的倍数之和。

Image

图 A-59:求任意两个数倍数的和

我们使用一个自定义的lcm模块,它首先调用我们可靠的gcd模块,然后基于结果计算最小公倍数。

挑战 9.3

如果倍数是abc,首先分别加上abc的倍数,然后减去abacbc的倍数。最后,再加上abc的倍数。

posted @ 2025-11-28 09:38  绝不原创的飞龙  阅读(11)  评论(0)    收藏  举报