深入浅出算法-全-

深入浅出算法(全)

原文:zh.annas-archive.org/md5/29688fa5d1f2cb17e6b155bc37e5c030

译者:飞龙

协议:CC BY-NC-SA 4.0

第一章:使用算法解决问题

接住一个球的动作是值得称奇的。一个球可能从非常远的地方开始,似乎只是地平线上的一个小点。它可能只在空中停留几秒钟甚至更短的时间。球会遇到空气阻力、风力,当然还有重力,沿着类似抛物线的轨迹运动。而且每次投掷球时,所施加的力、角度和环境条件都各不相同。那么,如何解释当击球员打出一颗棒球时,300 英尺外的外野手似乎立刻知道该跑到哪里,以便在球落地之前接住它呢?

这个问题被称为外野手问题,至今仍在学术期刊中讨论。我们从外野手问题开始,因为它有两种截然不同的解决方案:分析性解决方案和算法性解决方案。比较这两种解决方案将生动地说明什么是算法,它与其他问题解决方法有何不同。此外,外野手问题将帮助我们可视化一个偶尔显得抽象的领域——你可能有过投掷和接住某物的经验,而这种经验可以帮助你理解实践背后的理论。

在我们真正理解人类如何准确知道一个球会落在哪里之前,理解机器是如何做到这一点的将会有所帮助。我们将从分析性解决方案开始,研究外野手问题。这个解决方案在数学上是精确的,且计算机可以瞬间执行,而且它通常会在基础物理课程中教授。它可以使足够灵活的机器人为棒球队担任外野手。

然而,人类不容易在脑中快速运行分析方程式,显然无法像计算机那样快速。更适合人类大脑的解决方案是算法性解决方案,我们将用它来探索什么是算法以及它相较于其他问题解决方案的优势。此外,算法性解决方案将向我们展示算法是符合人类思维过程的,并不需要让人感到畏惧。外野手问题旨在介绍一种新的问题解决方式:算法方法。

分析方法

要从分析的角度解决这个问题,我们需要回溯几个世纪,了解早期的运动模型。

伽利略模型

用来模拟球的运动的方程式最早可以追溯到伽利略,几百年前他制定了捕捉加速度、速度和距离的多项式。如果我们忽略风和空气阻力,并假设球从地面开始,伽利略的模型表示,在时间t时,投掷的球的水平位置将由以下公式给出:

c01eq001

v[1]代表球在x(水平)方向上的起始速度。此外,根据伽利略的理论,投掷的球的高度(y)可以通过以下公式在时间t时计算:

c01eq002

v[2]代表球在y(垂直)方向上的起始速度,a代表由于重力引起的恒定向下加速度(如果我们使用公制单位,这个值大约是-9.81)。当我们将第一个方程代入第二个方程时,我们发现投掷的球的高度(y)与球的水平位置(x)的关系如下:

c01eq003

我们可以使用伽利略的方程在 Python 中建模一个假设的球的轨迹,使用列表 1-1:中的函数。列表 1-1 中的具体多项式适用于初始水平速度约为 0.99 米/秒,初始垂直速度约为 9.9 米/秒的球。您可以随意尝试其他* v[1]和v*[2]的值,以模拟任何您感兴趣的投掷方式。

def ball_trajectory(x):
    location = 10*x - 5*(x**2)
    return(location)

列表 1-1: 一个计算球轨迹的函数

我们可以在 Python 中绘制列表 1-1:中的函数,看看球的轨迹大致是什么样子(忽略空气阻力和其他微不足道的因素)。在第一行,我们将从一个名为matplotlib的模块中导入一些绘图功能。matplotlib模块是我们在本书中将导入的许多第三方模块之一。在使用第三方模块之前,您需要先安装它。您可以按照automatetheboringstuff.com/2e/appendixa/中的说明来安装matplotlib和其他任何第三方模块。

import matplotlib.pyplot as plt
xs = [x/100 for x in list(range(201))]
ys = [ball_trajectory(x) for x in xs]
plt.plot(xs,ys)
plt.title('The Trajectory of a Thrown Ball')
plt.xlabel('Horizontal Position of Ball')
plt.ylabel('Vertical Position of Ball')
plt.axhline(y = 0)
plt.show()

列表 1-2: 绘制一个假设的球的轨迹,从它被投掷的那一刻(x = 0)到它再次落地的时刻(x = 2)

输出(图 1-1:)是一个漂亮的图,展示了我们假设的球预计将穿越空间的路径。这条美丽的弯曲路径对每个受重力影响的运动物体都是相似的,并且被小说家托马斯·品钦(Thomas Pynchon)诗意地称为重力的彩虹

不是所有的球都会遵循这条完全相同的路径,但这是一条球可能遵循的路径。球从 0 开始,先向上再向下,正如我们习惯看到的球从我们视野的左侧飞到右侧一样。

figure_1-1

图 1-1: 一个假设的投掷球的轨迹

求解 x 的策略

既然我们已经有了描述球位置的方程,我们就可以求解出任何我们感兴趣的内容:例如,球会达到的最高点,或者它再次回到地面的位置——这是外场手需要知道的唯一信息,才能接到球。全世界的物理课堂上,学生们都在学习如何找到这些解,如果我们想教机器人打外场,教它这些方程也是非常自然的。求解球最终位置的方法就像我们最初使用的 ball_trajectory() 函数一样简单,只需要将其设为 0:

c01eq004

然后,我们可以使用青少年们学过的二次方程公式来解这个 x

c01eq005

在这种情况下,我们发现 x = 0 和 x = 2 是解答。第一个解 x = 0 是球开始的地方,就是投手投出或击球员击出的地方。第二个解 x = 2 是球飞行后再次落地的地方。

我们刚才使用的策略是相对简单的。我们可以称它为 x 策略。我们写下一个描述情境的方程,然后解这个方程,找出我们感兴趣的变量的值。解 x 的策略在硬科学中非常常见,无论是在高中还是大学水平。学生们常常被要求解答:球的预期着陆点、经济生产的理想水平、实验中应使用的化学物质比例,或者其他许多问题。

x 的策略极其强大。例如,如果一支军队观察到敌方发射了一个投射武器(比如导弹),他们可以迅速将伽利略的方程输入到计算器中,几乎瞬间就能找出导弹预计着陆的位置,从而躲避或拦截它。这项操作可以在一台运行 Python 的消费者级笔记本电脑上免费完成。如果一个机器人在棒球比赛中担任外场,它也可以通过这种方法接到球,轻松应对。

在这种情况下,解 x 的策略很简单,因为我们已经知道需要解的方程和解法。我们所说的投掷球的方程来自于伽利略,正如之前所提到的。而求解二次方程的公式则归功于伟大的穆罕默德·伊本·穆萨·阿尔-花拉兹米,他是第一个给出完全一般的二次方程解法的人。

阿尔-花拉兹米是九世纪的博学者,他在天文学、制图学和三角学方面做出了贡献,还给我们带来了“代数”一词及其相关方法。他是使我们能够走到这本书这一步的重要人物之一。由于我们生活在像伽利略和阿尔-花拉兹米这样巨人的后代,我们不需要经历推导他们方程的艰难过程——我们只需要记住它们并恰当地使用。

内在物理学家

使用伽利略和阿尔·花拉子米的方程式以及解 x 的策略,一台复杂的机器可以接住一个球或拦截一枚导弹。但合理的假设是,大多数棒球运动员看到球飞向空中时并不会马上开始写出方程式。可靠的观察者报告称,职业棒球春季训练项目包含了大量的跑动和比赛时间,而围绕白板推导纳维-斯托克斯方程的时间则相对较少。解开球落地点的谜团并不能直接解决外野手问题——即,人类如何凭直觉知道球会落在哪,而不是将其输入计算机程序中。

或许它能解决。最简单的外野手问题解决方案就是断言,如果计算机通过解伽利略二次方程来确定球的落点,那么人类也是如此。我们将这一解决方案称为内在物理学家理论。根据这一理论,我们大脑中的“湿件”能够设立并解出二次方程,或者画出图形并推断出它们的线条,这一切都远在我们意识的层面之下。换句话说,我们每个人的大脑深处都有一个“内在物理学家”,能够在几秒钟内计算出复杂数学问题的精确解,并将解传递给我们的肌肉,进而引导我们的身体和手套去接球。即使我们从未上过物理课或解过 x,我们的潜意识也许能做到这一点。

内在物理学家理论并非没有支持者。著名数学家基思·德夫林在 2006 年出版了一本书,名为数学本能:为什么你是数学天才(与龙虾、鸟类、猫和狗一起)。书的封面展示了一只狗跳起来接飞盘,箭头描绘了飞盘和狗各自的轨迹向量,暗示着狗能够进行复杂的计算,从而让这些向量交汇。

狗能够接飞盘和人类能够接棒球的显著能力似乎支持了内在物理学家理论。潜意识是一个神秘而强大的存在,我们还未完全探索它的深度。那么,为什么它不能偶尔解解一些高中水平的方程呢?更紧迫的是,内在物理学家理论难以反驳,因为很难想到其他替代方案:如果狗不能通过解偏微分方程来接飞盘,那么它们究竟是怎么接到的呢?它们大力跃起,用嘴巴像毫不费力般接住不规则飞行的飞盘。如果它们的大脑里没有解决物理问题的过程,那么我们(以及它们)又是如何精确拦截一个球的呢?

直到 1967 年,没人能给出一个好的答案。那一年,工程师范尼瓦尔·布什写了一本书,在书中他描述了自己理解的棒球的科学特征,但他无法解释外场手是如何知道该往哪里跑以接飞球的。幸运的是,物理学家塞维尔·查普曼读了布什的书,并受到了启发,次年他提出了自己的理论。

算法方法

查普曼,作为一位真正的科学家,并不满足于对人类潜意识的神秘且未经验证的信任,他想要为外场手的能力提供一个更为具体的解释。这就是他发现的内容。

颈部思考法

查普曼开始解决外场手问题时,注意到捕球者可用的信息。尽管人类很难准确估计一个物体的速度或抛物线轨迹,但他认为我们更容易观察角度。如果某人从地面投掷或击打球,并且地面是平坦的,那么外场手会看到球几乎是从眼平线开始的。想象由两条线构成的角度:地面和外场手眼睛与球之间的线。当球被击打的那一刻,这个角度大约是 0 度。在球飞行了一小段时间后,它会高于地面,因此地面与外场手视线之间的角度会增大。即使外场手没有学过几何学,他们也会对这个角度有一种“感觉”——比如,通过感觉自己需要将脖子向后仰多远才能看到球。

如果我们假设外场手站在球最终会落地的位置,假设 x = 2,我们可以通过绘制从球的轨迹初期开始的视线来感受外场手与球之间视角的变化。以下代码行在 Listing 1-2 中绘制了我们所画的视线的线段,并且应该在同一个 Python 会话中运行。这一线段表示的是外场手眼睛与球之间的线段,当球水平移动了 0.1 米之后。

xs2 = [0.1,2]
ys2 = [ball_trajectory(0.1),0]

我们可以绘制这条视线以及其他的视线,看看球的轨迹中,视角是如何不断增加的。以下几行代码向我们在 Listing 1-2 中绘制的图表中添加了更多线段。这些线段表示的是外场手眼睛与球之间的线段,分别对应球在轨迹中水平移动了 0.1、0.2 和 0.3 米时的位置。创建了所有这些线段后,我们将它们全部绘制在一起。

xs3 = [0.2,2]
ys3 = [ball_trajectory(0.2),0]
xs4 = [0.3,2]
ys4 = [ball_trajectory(0.3),0]
plt.title('The Trajectory of a Thrown Ball - with Lines of Sight')
plt.xlabel('Horizontal Position of Ball')
plt.ylabel('Vertical Position of Ball')
plt.plot(xs,ys,xs2,ys2,xs3,ys3,xs4,ys4)
plt.show()

结果图表显示了几条视线,这些视线与地面形成了持续增大的角度(见 Figure 1-2)。

figure_1-2

图 1-2: 假设投掷球的轨迹,线段表示外野手在球飞行过程中注视球的状态

随着球的飞行,外野手的视线角度不断增大,外野手需要不停地仰头,直到完成接球。我们将地面和外野手视线与球之间的角度称为theta。我们假设外野手站在球最终落点的位置(x = 2)。回想一下高中几何课,直角三角形中角度的正切值是与该角相对的边长与邻边长的比例(邻边不包括斜边)。在这个例子中,theta 的正切值是球的高度与球到外野手水平距离的比例。我们可以使用以下 Python 代码绘制这些比例对应的边:

xs5 = [0.3,0.3]
ys5 = [0,ball_trajectory(0.3)]
xs6 = [0.3,2]
ys6 = [0,0]
plt.title('The Trajectory of a Thrown Ball - Tangent Calculation')
plt.xlabel('Horizontal Position of Ball')
plt.ylabel('Vertical Position of Ball')
plt.plot(xs,ys,xs4,ys4,xs5,ys5,xs6,ys6)
plt.text(0.31,ball_trajectory(0.3)/2,'A',fontsize = 16)
plt.text((0.3 + 2)/2,0.05,'B',fontsize = 16)
plt.show()

结果图像见 图 1-3。

figure_1-3

图 1-3: 假设投掷球的轨迹,线段表示外野手在球飞行过程中注视球的状态,线段 A 和 B 显示了长度比例构成我们关心的正切值

我们通过取 A 边的长度与 B 边的长度之比来计算正切值。A 边的高度方程为 10x – 5x²,B 边的长度方程为 2 – x。所以以下方程隐式地描述了球飞行过程中每一刻的角度theta

c01eq006

整体情况较为复杂:球被打得很远,迅速通过一个抛物线曲线,且其结束位置很难立即估计。但在这个复杂的情况下,查普曼找到了一个简单的关系:当外野手站在正确的位置时,角度的正切值以简单且恒定的速率增长。查普曼突破的核心在于,角度的正切值,即球与地面的角度,随着时间线性增长。由于查普曼在外野手问题的复杂情境中发现了这一简单关系,他得以开发出一个优雅的算法解决方案。

他的解决方案依赖于这样的事实:如果某物——在这个案例中是θ的切线——以恒定速率增长,则其加速度为零。因此,如果你站在球即将到达的位置,你会观察到一个切线加速度为零的角度。相比之下,如果你站得离球的初始位置太近,你会观察到正加速度。如果你站得离球的初始位置太远,你会观察到负加速度。(如果你愿意,可以验证这些真理背后的复杂微积分。)这意味着外野手可以通过感觉自己在看球上升时需要多稳当地后仰头部,来知道该去哪儿——可以说是用脖子思考。

应用查普曼算法

机器人不一定有脖子,因此“用脖子思考”的方法可能对机器人外野手并不有用。记住,它们可以直接且瞬间解出二次方程来找出该去哪里接球,而无需担心θ的切线的加速度。但对人类来说,查普曼的脖子思维法可能极为有用。为了到达球的最终位置,人类外野手可以遵循这个相对简单的过程:

  1. 观察你与球之间的视线与地面之间角度的切线的加速度。

  2. 如果加速度为正,向后迈步。

  3. 如果加速度为负,向前迈步。

  4. 重复步骤 1–3,直到球正好在你面前。

  5. 接住球。

查普曼的五步法有一个严重的异议,那就是按照这个过程的外野手似乎必须在飞行过程中计算角度的切线,这意味着我们正在用一种“内在几何学理论”来替代“内在物理学理论”,即棒球运动员能够瞬间并潜意识地求出切线。

解决这个异议的一种潜在方法是,对于许多角度,tan(θ)大致等于θ,因此外野手不需要观察切线的加速度,而只需观察角度的加速度。如果角度的加速度可以通过脖关节在脖子回转以观察球时的感觉加速度来估算,并且如果角度是其切线的合理近似,那么我们就不需要假设外野手具有任何强大的潜意识数学或几何能力——只需要具备准确感知微妙感觉输入的身体技能。

通过将加速度估算作为过程中的唯一难点,我们已经获得了一个比内心物理学家关于潜意识推演抛物线的理论更具心理合理性的外场员问题潜在解决方案。当然,解决方案的心理吸引力并不意味着它只能被人类使用。机器人外场员也可以被编程来遵循查普曼的五步过程,甚至可能表现得更好,因为例如,查普曼的过程使得使用者能够动态应对风速或弹跳的变化。

除了心理上的合理性之外,查普曼洞察力所暗示的五步过程还具备一个至关重要的特性:它不依赖于解算 x 的策略,也不依赖任何显式的方程式。相反,它提出了通过简单的观察和小而渐进的步骤,逐步达成一个明确目标的方式。换句话说,我们从查普曼理论中推导出的过程是一个算法。

使用算法解决问题

算法 这个词来源于伟大的阿尔·花拉子米的名字,前面已经提到过。这个词并不容易定义,尤其是因为它的公认定义随着时间的推移发生了变化。简单来说,算法只是一个产生明确结果的指令集合。这是一个广泛的定义;正如我们在导言中所看到的,税表和帕菲的食谱都可以被认为是算法。

查普曼的接球过程,或者我们可以称之为查普曼的算法,甚至比帕菲食谱更具算法特征,因为它包含了一个循环结构,其中小步骤会被反复执行,直到达到一个明确的条件。这是你在本书中会看到的常见算法结构。

查普曼提出了一个算法解决方案来应对外场员问题,因为解算 x 的方案并不可信(外场员通常不知道相关的方程式)。一般来说,当解算 x 的策略失败时,算法最为有效。有时我们不知道应该使用什么方程式,但更多时候是因为没有任何方程式能完全描述一个情境,方程式无法求解,或者我们面临时间或空间上的限制。算法存在于可能性边缘,每当一个算法被创建或改进时,我们都会把效率和知识的前沿推得更远。

今天,普遍的看法是算法难懂、深奥、神秘,并且严格是数学性的,需要多年学习才能理解。根据我们现在的教育体系,我们尽早开始教孩子们解 x 的方法,而只有在大学或研究生阶段才会明确教授算法(如果教授的话)。对于许多学生来说,掌握解 x 的方法需要多年时间,而且总是让他们觉得不自然。曾有过这种经历的人可能会假设算法同样会感到不自然,而且由于它们“更高级”,理解起来也会更加困难。

然而,我从查普曼算法中得到的教训是,我们完全搞错了。在课间休息时,学生们学习并完善他们在抓球、投掷、踢球、奔跑和移动等方面的数十种算法。可能还有许多更复杂的算法,虽然尚未完全明确,但它们支配着课间休息的社交世界:谈话、寻求地位、八卦、结盟和友谊的培养。当我们结束休息时间并开始数学课时,我们把学生从一个算法探索的世界中带走,逼迫他们去学习一个不自然且机械化的解 x 的过程,这个过程既不是人类发展的自然部分,也不是解决分析问题的最有效方法。只有当学生进入高级数学和计算机科学的学习时,他们才会重新回到自然的算法世界,并掌握他们在课间休息时无意识且愉快地掌握的强大过程。

本书旨在为好奇的人提供一个智力上的课间休息——在这里,课间休息意味着一个年轻学生所说的:所有重要活动的开始,所有繁重工作的结束,以及与朋友们一起继续愉快探索的时光。如果你对算法感到一丝恐惧,提醒自己,我们人类天生就是算法型的,如果你能接住一个球或烤一个蛋糕,你就能掌握一个算法。

在本书的剩余部分,我们将探索许多不同的算法。有些会排序列表或计算数字,其他的则支持自然语言处理和人工智能。我鼓励你记住,算法不是从天而降的。在这些算法成为主流并在本书中被广泛介绍之前,它们是由像查普曼(Chapman)这样的人发现或创造的,他在某一天醒来时,发现自己身处一个没有他发明的算法的世界,而在这一天结束时,他又进入了一个算法已经存在的世界。我鼓励你尽量进入这些英雄发现者的思维方式。也就是说,我鼓励你不仅把算法当作一个工具使用,还要把它看作一个已经解决的复杂问题。算法的世界还远未被完全描绘出来——许多算法仍然有待发现和完善,我真心希望你能参与到这个发现过程当中。

摘要

在这一章中,你看到了两种解决问题的方法:分析性方法和算法性方法。通过用两种方式解决外场问题,我们探索了这些方法之间的差异,最终得出了查普曼的算法。查普曼在一个复杂的情境中发现了一个简单的模式(θ切线的恒定加速度),并利用它发展出了一个迭代循环的过程,这个过程只需要一个简单的输入(脖子伸长时的加速度感觉),就能达到一个明确的目标(接住一个球)。当你在自己的工作中寻求开发和使用算法时,可以尝试模仿查普曼的例子。

在接下来的章节中,我们将查看一些历史上的算法实例。这些实例将加深你对算法的理解,包括它们是什么以及如何工作。我们将讨论来自古埃及、古希腊和帝国日本的算法。你学到的每一个新算法,都会成为你“工具箱”中的一部分,帮助你在最终能够设计和完善自己的算法时有所依靠。

第二章:历史中的算法

大多数人将算法与计算机联系在一起。这并不无道理;计算机操作系统使用许多复杂的算法,编程非常适合精确实现各种算法。但算法比我们实现它们的计算机架构更为基础。如第一章所提到的,算法这个词可以追溯到大约千年前,而且早期的记录中就已有描述算法的内容。即使在没有文字记录的情况下,也有大量证据表明古代世界使用了复杂的算法——例如在他们的建筑方法中。

本章介绍了几种古老的算法。考虑到这些算法必须在没有计算机帮助的情况下被发明和验证,它们展现了极大的巧妙与洞察力。我们首先讨论俄罗斯农民乘法,这是一种算术方法,尽管名字如此,它可能起源于埃及,并且实际上与农民并无直接关联。接着我们介绍欧几里得算法,这是一种经典的“经典”算法,用于求最大公约数。最后,我们介绍一种来自日本的算法,用于生成魔方阵。

俄罗斯农民乘法

许多人记得学习乘法表是他们教育过程中最痛苦的部分。小孩子会问父母为什么学习乘法表是必要的,父母通常会回答说,如果不懂它就不能进行乘法计算。他们错得很离谱。俄罗斯农民乘法(RPM)是一种方法,可以让人们在不熟悉大部分乘法表的情况下进行大数乘法运算。

RPM 的起源尚不明确。一份古埃及卷轴《林德纸草书》包含了这个算法的一种版本,一些历史学家提出了(大多不太令人信服的)猜测,认为该方法可能从古埃及学者传入俄罗斯辽阔的农民中。不管它的历史细节如何,RPM 依然是一个有趣的算法。

手工进行 RPM

以 89 乘以 18 为例。俄罗斯农民乘法的步骤如下。首先,创建两列并排。第一列称为除法列,起始值为 89。第二列称为倍增列,起始值为 18(见表 2-1)。

表 2-1: 除法/倍增表,第一部分

除法 倍增
89 18

我们首先填写除法列。除法列的每一行都将上一行的数值除以 2,忽略余数。例如,89 除以 2 为 44 余 1,因此在除法列的第二行填写 44(见表 2-2)。

表 2-2: 除法/倍增表,第二部分

除法 倍增
89 18
44

我们继续除以 2,直到达到 1,每次都丢弃余数并将结果写入下一行。继续下去,我们发现 44 除以 2 是 22,然后一半是 11,再然后一半(丢掉余数)是 5,再然后是 2,最后是 1。将这些写入减半列后,我们得到了表 2-3。

表 2-3: 减半/倍增表,第三部分

减半 倍增
89 18
44
22
11
5
2
1

我们已经完成了减半列。顾名思义,倍增列中的每个条目都将是前一个条目的两倍。所以,由于 18 × 2 是 36,36 就是倍增列中的第二个条目(见表 2-4)。

表 2-4: 减半/倍增表,第四部分

减半 倍增
89 18
44 36
22
11
5
2
1

我们继续按照相同的规则向倍增列中添加条目:只需将前一个条目乘以 2。我们一直这样做,直到倍增列的条目数量与减半列一样多(见表 2-5)。

表 2-5: 减半/倍增表,第五部分

减半 倍增
89 18
44 36
22 72
11 144
5 288
2 576
1 1,152

下一步是划掉或移除减半列中包含偶数的每一行。结果如表 2-6 所示。

表 2-6: 减半/倍增表,第六部分

减半 倍增
89 18
11 144
5 288
1 1,152

最后一步是将倍增列中剩下的条目求和。结果是 18 + 144 + 288 + 1,152 = 1,602。你可以用计算器验证这是正确的:89 × 18 = 1,602。通过减半、倍增和加法,我们完成了乘法运算,而无需记忆大多数年轻孩子所讨厌的繁琐乘法表。

为了理解这种方法为什么有效,试着将倍增列用 18 来表示,即我们要乘的数字(见表 2-7)。

表 2-7: 减半/倍增表,第七部分

减半 倍增
89 18 × 1
44 18 × 2
22 18 × 4
11 18 × 8
5 18 × 16
2 18 × 32
1 18 × 64

现在倍增列已经用 1、2、4、8,依此类推,直到 64 来表示。这些是 2 的幂次,我们也可以将它们表示为 2⁰、2¹、2²,依此类推。当我们求出最终的和(将倍增列中减半列为奇数的行相加),实际上我们是在求这个和:

c02eq001

RPM 工作的关键在于

c02eq002

如果你仔细观察分半列,你就能明白为什么前面的方程成立。我们还可以将此列以 2 的幂的形式表示(表 2-8)。这样做时,从最底部的项开始并向上进行计算会更容易。记住 2⁰是 1,2¹是 2。在每一行中,我们都乘以 2¹,而在分半数字为奇数的行中,我们还会加上 2⁰。随着你向上走,你会看到这个表达式越来越像我们的方程。等到我们到达表格的顶部时,我们得到了一个简化为 2⁶ + 2⁴ + 2³ + 2⁰的表达式。

表 2-8: 分半/倍增表,第八部分

分半 倍增
(2⁵ + 2³ + 2²) × 2¹ + 2⁰ = 2⁶ + 2⁴ + 2³ + 2⁰ 18 × 2⁰
(2⁴ + 2² + 2¹) × 2¹ = 2⁵ + 2³ + 2² 18 × 2¹
(2³ + 2¹ + 2⁰) × 2¹ = 2⁴ + 2² + 2¹ 18 × 2²
(2² + 2⁰) × 2¹ + 2⁰ = 2³ + 2¹ + 2⁰ 18 × 2³
2¹ × 2¹ + 2⁰ = 2² + 2⁰ 18 × 2⁴
2⁰ × 2¹ = 2¹ 18 × 2⁵
2⁰ 18 × 2⁶

如果你从顶行开始编号分半列的行,从 0 行开始,然后是 1、2,一直到最底行为 6 行,你可以看到分半列中包含奇数值的行是 0、3、4 和 6 行。现在注意到一个关键的模式:这些行号恰好是我们在表达式中找到的 89 的指数:2⁶ + 2⁴ + 2³ + 2⁰。这不是巧合;我们构造分半列的方式意味着奇数项的行号总是 2 的幂和中等于我们原始数字的指数。当我们将这些指数的倍增项加起来时,我们是在求 18 与 2 的幂之和,结果正好是 89,所以我们的结果将是 89 × 18。

之所以有效,是因为实际上,RPM 是一个嵌套在另一个算法中的算法。分半列本身是一个算法的实现,用于找到与列顶端数字相等的 2 的幂的和。这个 2 的幂的和也叫做二进制展开。二进制是一种只使用 0 和 1 来表示数字的方式,近年来变得极其重要,因为计算机以二进制方式存储信息。我们可以将 89 表示为二进制的 1011001,其中 1 出现在第 0、3、4 和 6 位(从右向左数),与分半列的奇数行相同,也与我们方程中的指数相同。我们可以将二进制表示中的 1 和 0 解释为 2 的幂和中的系数。例如,如果我们写 100,我们将其在二进制中解释为

1 × 2² + 0 × 2¹ + 0 × 2⁰

或我们通常写作 4。如果我们写 1001,我们将其在二进制中解释为

1 × 2³ + 0 × 2² + 0 × 2¹ + 1 × 2⁰

或我们通常写作 9。在运行这个小算法得到 89 的二进制展开后,我们已经准备好轻松地运行完整算法并完成乘法过程。

在 Python 中实现 RPM

在 Python 中实现 RPM 相对简单。假设我们要乘以两个数字,我们称之为n[1]和n[2]。首先,让我们打开一个 Python 脚本并定义这些变量:

n1 = 89
n2 = 18

接下来,我们将开始创建除法列。正如之前所描述的,除法列以我们想要相乘的一个数字开始:

halving = [n1]

下一个条目将是halving[0]/2,忽略余数。在 Python 中,我们可以使用math.floor()函数来实现这一点。这个函数返回一个小于给定数字的最接近整数。例如,除法列的第二行可以按如下方式计算:

import math
print(math.floor(halving[0]/2))

如果你在 Python 中运行这个,你将看到答案是 44。

我们可以遍历每一行除法列,在每次循环迭代中,我们将以相同的方式找到除法列中的下一个条目,直到达到 1 为止:

while(min(halving) > 1):
    halving.append(math.floor(min(halving)/2))

这个循环使用append()方法进行连接。在每次while循环的迭代中,它将halving向量与其最后一个值的一半连接,使用math.floor()函数忽略余数。

对于倍增列,我们可以做同样的操作:从 18 开始,然后通过一个循环继续。在每次循环中,我们将把前一个条目的两倍加到倍增列中,直到该列与除法列的长度相同为止:

doubling = [n2]
while(len(doubling) < len(halving)):
    doubling.append(max(doubling) * 2)

最后,让我们把这两列合并到一个名为half_double的数据框中:

import pandas as pd
half_double = pd.DataFrame(zip(halving,doubling))

我们在这里导入了一个名为pandas的 Python 模块。这个模块使我们能够轻松地处理表格。在这种情况下,我们使用了zip命令,顾名思义,它像拉链一样将halvingdoubling连接在一起。两个数字集合,halvingdoubling,开始时是独立的列表,在被“拉链”连接并转换成一个pandas数据框后,作为两列对齐的数据存储在表格中,如表 2-5 所示。由于它们已经对齐并被拉链连接,我们可以引用表 2-5 的任何一行,例如第三行,并获取该行的完整数据,包括halvingdoubling中的元素(22 和 72)。能够引用和处理这些行将使我们能够轻松删除不需要的行,正如我们对表 2-5 所做的那样,将其转换为表 2-6。

现在我们需要删除除法列中条目为偶数的行。我们可以使用 Python 中的%(取模)运算符来测试偶性,它会在除法后返回余数。如果一个数字x是奇数,那么x%2将是 1。以下代码行将只保留除法列中条目为奇数的行:

half_double = half_double.loc[half_double[0]%2 == 1,:]

在这种情况下,我们使用pandas模块中的loc功能来选择我们想要的行。当我们使用loc时,我们需要在其后面的方括号([])中指定我们要选择的行和列。在方括号内,我们按顺序指定我们想要的行和列,并用逗号隔开:格式是[,]。例如,如果我们想要索引为 4 的行和索引为 1 的列,我们可以写half_double.loc[4,1]。在这种情况下,我们不仅仅指定索引。我们将表达一个逻辑模式来选择我们想要的行:我们希望所有halving列为奇数的行。我们通过half_double[0]指定halving列,因为它是索引为 0 的列。我们通过%2 == 1指定奇数性。最后,我们通过写冒号来指定我们想要所有列,冒号是一个快捷方式,表示我们想要每一列。

最后,我们简单地计算剩余的倍增项之和:

answer = sum(half_double.loc[:,1])

这里,我们再次使用loc。我们在方括号中指定使用冒号快捷方式选择每一行。我们在逗号后指定我们想要的doubling列,即索引为 1 的列。请注意,我们之前做的 89 × 18 的例子如果改为计算 18 × 89 会更快更容易——也就是说,如果我们把 18 放在 halving 列,把 89 放在 doubling 列。我鼓励你试试看,感受一下改进。一般来说,如果较小的乘数放在 halving 列,较大的放在 doubling 列,RPM 会更快。

对于那些已经记住乘法表的人来说,RPM 可能显得毫无意义。但除了它的历史魅力外,RPM 还是值得学习的,原因有几个。首先,它表明即使像乘法这种枯燥的事情也可以有多种做法,并且可以采取创造性的方法。仅仅因为你学会了一种算法,并不意味着它是唯一的,或者是最好的算法——保持思维开放,去接受新的、可能更好的方法。

RPM 可能较慢,但它在前期要求较少的记忆,因为它不需要知道大多数乘法表。为了低内存需求,有时牺牲一点速度是非常有用的,而这种速度/内存的权衡是我们在设计和实现算法时要考虑的一个重要因素。

像许多优秀的算法一样,RPM 也将看似不相关的思想之间的关系呈现出来。二进制展开可能看起来只是一个好奇心的产物,对于晶体管工程师来说有兴趣,但对普通人甚至是专业程序员来说没什么用。但 RPM 展示了数字的二进制展开和一种仅需最少的乘法表知识就能进行乘法的便捷方法之间的深刻联系。这也是为什么要不断学习的另一个原因:你永远不知道什么时候一些看似无用的细节可能成为一个强大算法的基础。

欧几里得算法

古希腊人给人类带来了许多礼物,其中最伟大的之一就是理论几何学,它由伟大的欧几里得在他的 13 本书《几何原本》中严谨地整理而成。欧几里得的大部分数学著作采用定理/证明的形式,其中一个命题是从更简单的假设中逻辑推导出来的。他的一些工作也是 构造性的,意味着它提供了一种使用简单工具绘制或创建有用图形的方法,比如具有特定面积的正方形或曲线的切线。尽管那个时候这个词还没有被创造出来,欧几里得的构造性方法实际上是算法,他的算法背后的某些思想今天仍然是有用的。

手动进行欧几里得算法

欧几里得最著名的算法通常被称为 欧几里得算法,尽管这只是他所写的众多算法中的一种。欧几里得算法是一种用于求解两个数的最大公约数的方法。它简单而优雅,只需几行代码即可在 Python 中实现。

我们从两个自然数(整数)开始:我们将它们叫做 ab。假设 a 大于 b(如果不是,直接将 ab 交换名称,那么 a 就会大于 b)。如果我们进行 a/b 的除法,我们将得到一个整数商和一个整数余数。我们将商记作 q[1],余数记作 c。我们可以将其写成如下形式:

c02eq003

例如,如果我们说 a = 105 且 b = 33,我们会发现 105/33 的商为 3,余数为 6。注意,余数 c 总是小于 ab——这就是余数的特性。接下来的步骤是忘记 a,只关注 bc。像之前一样,我们假设 b 大于 c。然后我们找出 b/c 的商和余数。如果我们说 b/c 的商是 q[2],余数是 d,我们可以将结果写成如下形式:

c02eq004

再次说明,d 将比 bc 都小,因为它是余数。如果你看我们这里的两个方程,你可以开始看到一个模式:我们正在按字母表的顺序进行,每次将项移到左边。我们从 abc 开始,然后我们得到了 bcd。你可以看到这个模式在我们下一步中继续进行,在这一步中我们将 c/d 相除,并将商记作 q[3],余数记作 e

c02eq005

我们可以继续这个过程,一直执行直到余数为零。记住,余数总是比被除数小,所以 c 小于 abd 小于 bce 小于 cd,以此类推。这意味着每一步,我们都在处理越来越小的整数,所以最终我们一定会得到零。当余数为零时,我们停止这个过程,并知道最后一个非零余数就是最大公约数。例如,如果我们发现 e 为零,那么 d 就是原始两个数的最大公约数。

在 Python 中实现欧几里得算法

我们可以非常容易地在 Python 中实现这个算法,如 Listing 2-1 所示。

def gcd(x,y):
    larger = max(x,y)
    smaller = min(x,y)

    remainder = larger % smaller

    if(remainder == 0):
        return(smaller)

    if(remainder != 0):
  1       return(gcd(smaller,remainder))

Listing 2-1: 使用递归实现欧几里得算法

首先需要注意的是,我们不需要任何 q[1]、q[2]、q[3] 等商。我们只需要余数,即字母表中的连续字母。在 Python 中,获取余数很容易:我们可以使用上一节的 % 运算符。我们可以编写一个函数,接受两个数字的除法余数。如果余数为零,那么最大公约数就是较小的那个输入。如果余数不为零,我们就使用较小的输入和余数作为输入,递归调用同一个函数。

注意,如果余数不为零,函数会调用自身 1。函数调用自身的行为称为递归。递归刚开始可能会让人感到害怕或困惑;一个调用自身的函数可能看起来像是自相矛盾的,比如一条能够吃掉自己的蛇,或者一个人通过拉自己的靴带来飞行。但不要害怕。如果你不熟悉递归,最好的方法之一是从一个具体的例子入手,比如计算 105 和 33 的最大公约数,并像计算机一样一步步跟随代码。你会发现,在这个例子中,递归只是以简洁的方式表达我们在《手工做欧几里得算法》一节中列出的步骤。递归有一个危险,就是可能会造成无限递归——函数不断调用自己,在调用自身时又调用自己,永远没有结束的条件,导致函数永无休止地调用自己,这是一个问题,因为我们需要程序终止才能得到最终的答案。在这种情况下,我们可以放心,因为每一步我们得到的余数会越来越小,最终会变为零,从而让我们退出函数。

欧几里得算法简洁、有效且实用。我鼓励你在 Python 中创建一个更简洁的实现。

日本魔方阵

日本数学史特别引人入胜。在 1914 年首次出版的《日本数学史》中,历史学家 David Eugene Smith 和 Yoshio Mikami 写道,日本数学历来拥有“耐心极致的天赋”和“解开千头万绪的巧妙智慧”。一方面,数学揭示了不受时间和文化影响的绝对真理。另一方面,不同群体倾向于关注的问题类型以及他们独特的解决方式,更不用说符号和沟通方式的差异,都为数学这一严谨的领域提供了广阔的文化差异空间。

在 Python 中创建洛书方阵

日本数学家喜欢几何学,他们的许多古代手稿中提出并解决了与计算一些特殊形状的面积相关的问题,例如椭圆内的圆和日本手扇。另一个长期关注的领域是日本数学家们对魔方阵的研究。

魔方阵是一个由独特且连续的自然数组成的矩阵,使得所有的行、所有的列和两个主对角线的和都相等。魔方阵可以是任何大小的。表 2-9 展示了一个 3×3 魔方阵的例子。

表 2-9: 洛书方阵

4 9 2
3 5 7
8 1 6

在这个方阵中,每一行、每一列和两个主对角线的和都为 15。这不仅仅是一个随机的例子——它是著名的洛书方阵。根据中国的一个古老传说,这个魔方阵最早出现在一只神奇乌龟的背上,这只乌龟从河里浮现出来,回应一个受苦人民的祈祷和祭祀。除了每行、每列和对角线之和为 15 的定义模式外,还有一些其他模式。例如,数字的外环交替出现奇数和偶数,且 4、5、6 这三个连续的数字出现在主对角线上。

关于这个简单却迷人的方阵突然作为神赐的礼物出现的传说,恰如其分地反映了算法的研究。算法通常容易验证和使用,但从零开始设计却非常困难。尤其是那些优雅的算法,当我们有幸发明一个时,常常显得如同启示般,好像它们从无到有,犹如神赐的礼物,刻在神奇乌龟的背上。如果你对此有疑问,可以试着从零开始创建一个 11×11 的魔方阵,或者尝试发现一个生成新魔方阵的通用算法。

这些魔方阵的知识显然至少在 1673 年通过中国传入日本,当时一位名叫三信信的数学家在日本发表了一个 20×20 的魔方阵。我们可以用以下命令在 Python 中创建洛书方阵:

luoshu = [[4,9,2],[3,5,7],[8,1,6]]

如果有一个功能能够验证给定的矩阵是否是一个魔方阵,那将非常方便。以下的函数通过验证所有行、列和对角线的和,然后检查它们是否都相同,来实现这一功能:

def verifysquare(square):
    sums = []
    rowsums = [sum(square[i]) for i in range(0,len(square))]
    sums.append(rowsums)
    colsums = [sum([row[i] for row in square]) for i in range(0,len(square))]
    sums.append(colsums)
    maindiag = sum([square[i][i] for i in range(0,len(square))])
    sums.append([maindiag])
    antidiag = sum([square[i][len(square) - 1 - i] for i in \range(0,len(square))])
    sums.append([antidiag])
    flattened = [j for i in sums for j in i]
    return(len(list(set(flattened))) == 1)

在 Python 中实现 Kurushima 算法

在前面的章节中,我们讨论了如何在“手工”执行算法之前,提供代码实现的详细信息。在 Kurushima 算法的情况下,我们将同时概述步骤并介绍代码。之所以做出这种改变,是因为该算法的相对复杂性,特别是实现所需的代码长度。

生成魔方阵最优雅的算法之一是Kurushima 算法,它以江户时代的 Kurushima Yoshita 命名。Kurushima 算法只适用于奇数维度的魔方阵,即当n是奇数时,适用于任何n×n的正方形。它首先按照与洛书正方形相匹配的方式填充正方形的中心。特别是,中心的五个方格由以下公式给出,其中n是正方形的维度(表 2-10)。

表 2-10: Kurushima 正方形的中心

n²
n (n² + 1)/2 n² + 1 – n
1

Kurushima 算法生成n×n的魔方阵(当n为奇数时)可以简要描述如下:

  1. 根据表 2-10 填充中心的五个方格。

  2. 从任何已知值的条目开始,通过遵循接下来介绍的三条规则之一,确定未知相邻条目的值。

  3. 重复步骤 2,直到填满整个魔方阵。

填充中心方格

我们可以通过创建一个空的方形矩阵来开始创建魔方阵的过程,之后再填充它。例如,如果我们想创建一个 7×7 的矩阵,我们可以定义n=7,然后创建一个具有n行和n列的矩阵:

n = 7
square = [[float('nan') for i in range(0,n)] for j in range(0,n)]

在这种情况下,我们不知道应该在正方形中放入什么数字,因此我们将其完全填充为float('nan')。这里,nan代表不是一个数字,我们可以在 Python 中使用它作为占位符,当我们想先填充一个列表,但还不知道要使用哪些数字时。如果我们运行print(square),会发现该矩阵默认填充为nan条目:

[[nan, nan, nan, nan, nan, nan, nan], [nan, nan, nan, nan, nan, nan, nan], [nan, nan, nan, nan, nan, nan, nan], [nan, nan, nan, nan, nan, nan, nan], [nan, nan, nan, nan, nan, nan, nan], [nan, nan, nan, nan, nan, nan, nan], [nan, nan, nan, nan, nan, nan, nan]]

由于该正方形在 Python 控制台中输出时并不太美观,我们可以编写一个函数,以更易读的方式打印它:

def printsquare(square):
    labels = ['['+str(x)+']' for x in range(0,len(square))]
    format_row = "{:>6}" * (len(labels) + 1)
    print(format_row.format("", *labels))
    for label, row in zip(labels, square):
        print(format_row.format(label, *row))

不用担心printsquare()函数的细节,因为它只是为了美观地打印输出,并不是我们算法的一部分。我们可以用简单的命令填充中心的五个方格。首先,我们可以按如下方式获取中心条目的索引:

import math
center_i = math.floor(n/2)
center_j = math.floor(n/2)

中心的五个方格可以根据表 2-10 中的公式填充,如下所示:

square[center_i][center_j] = int((n**2 +1)/2)
square[center_i + 1][center_j] = 1
square[center_i - 1][center_j] = n**2
square[center_i][center_j + 1] = n**2 + 1 - n
square[center_i][center_j - 1] = n

指定三条规则

Kurushima 算法的目的是根据简单的规则填充其余的 nan 项。我们可以指定三个简单的规则,帮助我们填充每一项,无论魔方阵有多大。第一个规则见图 2-1。

Figure_2-1

Figure 2-1: Kurushima 算法的规则 1

所以对于魔方阵中的任何 x,我们可以通过简单地加上 n 并对 n² 取模(mod 代表取模运算),来确定与 x 具有这种对角关系的项。当然,我们也可以通过逆向操作进行计算:减去 n 并对 n² 取模。

第二条规则更简单,表达式见图 2-2。

Figure_2-2

Figure 2-2: Kurushima 算法的规则 2

对于魔方阵中的任何 x,位于 x 右下方的项比 x 大 1,mod n²。这是一个简单的规则,但有一个重要的例外:当我们从魔方阵的左上半部分穿越到右下半部分时,这个规则不适用。换句话说,如果我们穿越魔方阵的 反对角线(即从左下到右上的对角线,见图 2-3),我们就不遵循第二条规则。

Figure_2-3

Figure 2-3: 方阵的反对角线

你可以看到反对角线上的单元格。反对角线完全通过这些单元格。当我们处理这些单元格时,可以遵循我们正常的两条规则。只有当我们从完全在反对角线上方的单元格出发,并穿越到完全位于反对角线下方的单元格,或反之时,才需要第三条例外规则。最后这条规则见图 2-4,其中显示了反对角线和两个在穿越时需要遵循此规则的单元格。

Figure_2-4

Figure 2-4: Kurushima 算法的规则 3

当我们穿越反对角线时,遵循此规则。如果我们从右下角穿越到左上角,则可以遵循此规则的逆操作,其中 x 被转换为 x + n – 1,mod n²。

我们可以通过定义一个接收 xn 作为参数并返回 (x+n)%n**2 的函数,用 Python 简单实现规则 1:

def rule1(x,n):
    return((x + n)%n**2)

我们可以尝试从洛书方阵的中心项开始。记住,洛书方阵是一个 3×3 的方阵,所以 n = 3。洛书方阵的中心项是 5。该项下方且左侧的项是 8,如果我们正确实现了 rule1() 函数,当我们运行以下代码时,会得到一个 8:

print(rule1(5,3))

你应该在 Python 控制台中看到一个 8。我们的 rule1() 函数似乎按预期工作。然而,我们可以通过使它能够“倒退”来进行改进,确定不仅是给定条目左下方的条目,还包括右上方的条目(也就是说,不仅可以从 5 走到 8,也可以从 8 走到 5)。我们可以通过向函数添加一个额外的参数来进行改进。我们将把这个新参数命名为 upright,它将是一个 True/False 指示器,表示我们是否要查找 x 上方和右侧的条目。如果不是,默认情况下我们将查找 x 左下方的条目:

def rule1(x,n,upright):
    return((x + ((-1)**upright) * n)%n**2)

在数学表达式中,Python 会将 True 解释为 1,将 False 解释为 0。如果 uprightFalse,我们的函数将返回与之前相同的值,因为 (–1)⁰ = 1。如果 uprightTrue,则它会减去 n 而不是加上 n,这将使我们能够朝相反的方向前进。让我们检查一下它是否可以确定 Luo Shu 方阵中 1 上方和右侧的入口:

print(rule1(1,3,True))

它应该打印出 7,这是 Luo Shu 方阵中的正确值。

对于规则 2,我们可以创建一个类似的函数。我们的规则 2 函数将像规则 1 一样接受 xn 作为参数。但规则 2 默认查找 x 右下方的条目。因此,我们将添加一个 upleft 参数,如果我们想要反向规则,它将为 True。最终规则如下:

def rule2(x,n,upleft):
    return((x + ((-1)**upleft))%n**2)

你可以在 Luo Shu 方阵上测试这一点,尽管只有两对条目不会遇到规则 2 的例外情况。对于这个例外情况,我们可以编写以下函数:

def rule3(x,n,upleft):
    return((x + ((-1)**upleft * (-n + 1)))%n**2)

这条规则只有在我们穿越魔方的反对角线时才需要遵循。稍后我们会看到如何判断是否在穿越反对角线。

现在我们知道如何填充五个中央方块,并且有了基于这些中央方块的知识来填充其余方块的规则,我们可以填充其余部分的方阵。

填充其余部分的方阵

填充方阵其余部分的一种方法是通过随机“行走”,使用已知的条目填充未知的条目。首先,我们将按如下方式确定中央条目的索引:

center_i = math.floor(n/2)
center_j = math.floor(n/2)

然后,我们可以随机选择一个方向进行“行走”,如下所示:

import random
entry_i = center_i
entry_j = center_j
where_we_can_go = ['up_left','up_right','down_left','down_right']
where_to_go = random.choice(where_we_can_go)

这里,我们使用了 Python 的 random.choice() 函数,该函数从列表中随机选择元素。它从我们指定的集合(where_we_can_go)中选择一个元素,但它是随机选择的(或者说尽可能接近随机)。

在我们决定了前进方向之后,可以根据我们选择的方向遵循相应的规则。如果我们选择了 down_leftup_right,我们将遵循规则 1,并按如下方式选择正确的参数和索引:

if(where_to_go == 'up_right'):
    new_entry_i = entry_i - 1
    new_entry_j = entry_j + 1
    square[new_entry_i][new_entry_j] = rule1(square[entry_i][entry_j],n,True)
 if(where_to_go == 'down_left'):
    new_entry_i = entry_i + 1
    new_entry_j = entry_j - 1
    square[new_entry_i][new_entry_j] = rule1(square[entry_i][entry_j],n,False)

类似地,如果我们选择了 up_leftdown_right,我们将遵循规则 2:

if(where_to_go == 'up_left'):
    new_entry_i = entry_i - 1
    new_entry_j = entry_j - 1
    square[new_entry_i][new_entry_j] = rule2(square[entry_i][entry_j],n,True)

if(where_to_go == 'down_right'):
    new_entry_i = entry_i + 1
    new_entry_j = entry_j + 1
    square[new_entry_i][new_entry_j] = rule2(square[entry_i][entry_j],n,False)

这段代码用于向上左移动和向下右移动,但只有在不跨越反对角线时,我们才应遵循它。如果我们跨越了反对角线,就必须确保在这种情况下遵循规则 3。我们有一种简单的方法可以判断我们是否处在接近反对角线的条目:反对角线正上方的条目,其索引和为n-2,而反对角线正下方的条目,其索引和为n。在这些特殊情况下,我们需要实现规则 3:

if(where_to_go == 'up_left' and (entry_i + entry_j) == (n)):
    new_entry_i = entry_i - 1
    new_entry_j = entry_j - 1
    square[new_entry_i][new_entry_j] = rule3(square[entry_i][entry_j],n,True)

if(where_to_go == 'down_right' and (entry_i + entry_j) == (n-2)):
    new_entry_i = entry_i + 1
    new_entry_j = entry_j + 1
    square[new_entry_i][new_entry_j] = rule3(square[entry_i][entry_j],n,False)

请记住,我们的魔方阵是有限的,因此我们不能像从顶部行或最左列那样向上或向左移动。通过根据当前位置创建可移动的列表,我们可以添加一些简单的逻辑,确保只朝着允许的方向移动:

where_we_can_go = []

if(entry_i < (n - 1) and entry_j < (n - 1)):
    where_we_can_go.append('down_right')

if(entry_i < (n - 1) and entry_j > 0):
    where_we_can_go.append('down_left')

if(entry_i > 0 and entry_j < (n - 1)):
    where_we_can_go.append('up_right')

if(entry_i > 0 and entry_j > 0):
    where_we_can_go.append('up_left')

我们已经拥有了编写实现 Kurushima 算法的 Python 代码所需的所有元素。

将一切整合在一起

我们可以将所有内容整合到一个函数中,该函数接受一个包含一些nan条目的起始方格,并使用我们的三条规则穿越它来填充这些条目。列表 2-2 包含了完整的函数。

import random
def fillsquare(square,entry_i,entry_j,howfull):
     while(sum(math.isnan(i) for row in square for i in row) > howfull):
        where_we_can_go = []

        if(entry_i < (n - 1) and entry_j < (n - 1)):
            where_we_can_go.append('down_right')
        if(entry_i < (n - 1) and entry_j > 0):
            where_we_can_go.append('down_left')
        if(entry_i > 0 and entry_j < (n - 1)):
            where_we_can_go.append('up_right')
        if(entry_i > 0 and entry_j > 0):
            where_we_can_go.append('up_left')

        where_to_go = random.choice(where_we_can_go)
        if(where_to_go == 'up_right'):
            new_entry_i = entry_i - 1
            new_entry_j = entry_j + 1
            square[new_entry_i][new_entry_j] = rule1(square[entry_i][entry_j],n,True)

        if(where_to_go == 'down_left'):
            new_entry_i = entry_i + 1
            new_entry_j = entry_j - 1
            square[new_entry_i][new_entry_j] = rule1(square[entry_i][entry_j],n,False)

        if(where_to_go == 'up_left' and (entry_i + entry_j) != (n)):
            new_entry_i = entry_i - 1
            new_entry_j = entry_j - 1
            square[new_entry_i][new_entry_j] = rule2(square[entry_i][entry_j],n,True)

        if(where_to_go == 'down_right' and (entry_i + entry_j) != (n-2)):
            new_entry_i = entry_i + 1
            new_entry_j = entry_j + 1
            square[new_entry_i][new_entry_j] = rule2(square[entry_i][entry_j],n,False)

        if(where_to_go == 'up_left' and (entry_i + entry_j) == (n)):
            new_entry_i = entry_i - 1
            new_entry_j = entry_j - 1
            square[new_entry_i][new_entry_j] = rule3(square[entry_i][entry_j],n,True)

 if(where_to_go == 'down_right' and (entry_i + entry_j) == (n-2)):
            new_entry_i = entry_i + 1
            new_entry_j = entry_j + 1
            square[new_entry_i][new_entry_j] = rule3(square[entry_i][entry_j],n,False)

     1 entry_i = new_entry_i
        entry_j = new_entry_j

    return(square)

列表 2-2: 一个实现 Kurushima 算法的函数

这个函数将接受四个参数:首先,一个包含一些nan条目的起始方格;第二和第三,表示我们想要开始的条目的索引;第四,我们希望填充方格的程度(由我们愿意容忍的nan条目数量来衡量)。这个函数由一个while循环组成,每次迭代时通过遵循我们三条规则之一来向方格中的条目写入一个数字。它会一直执行,直到写入的nan条目数达到我们在函数第四个参数中指定的数量。每次写入后,它会通过改变索引 1 来“移动”到该条目,然后继续执行。

现在我们已经有了这个函数,剩下的就是以正确的方式调用它。

使用正确的参数

让我们从中央条目开始,从这里填充魔方阵。对于我们的howfull参数,我们将指定(n**2)/2-4。使用这个值作为howfull的原因将在我们看到结果后变得清晰:

entry_i = math.floor(n/2)
entry_j = math.floor(n/2)

square = fillsquare(square,entry_i,entry_j,(n**2)/2 - 4)

在这种情况下,我们使用之前定义的square变量调用fillsquare()函数。记得我们定义它时,除了我们指定的五个中央元素外,其他位置都填充了nan。当我们用这个square作为输入运行fillsquare()函数后,fillsquare()函数会填充许多剩余的条目。接下来让我们打印出结果方阵,看看它看起来如何:

printsquare(square)

结果如下:

 [0]   [1]   [2]   [3]   [4]   [5]   [6]
   [0]    22   nan    16   nan    10   nan     4
   [1]   nan    23   nan    17   nan    11   nan
   [2]    30   nan    24    49    18   nan    12
   [3]   nan    31     7    25    43    19   nan
   [4]    38   nan    32     1    26   nan    20
   [5]   nan    39   nan    33   nan    27   nan
   [6]    46   nan    40   nan    34   nan    28

你会注意到nan值占据了交替的条目,就像棋盘一样。原因在于我们用于斜向移动的规则使得我们只能访问总条目的大约一半,具体取决于我们从哪个条目开始。有效的移动规则与跳棋相同:从黑色格子上开始的棋子可以斜着移动到其他黑色格子,但它的斜向移动模式永远无法移动到任何白色格子。如果我们从中心条目开始,看到的nan条目是不可访问的。我们在howfull参数中指定了(n**2)/2``-``4而不是零,因为我们知道仅仅调用一次我们的函数无法完全填充矩阵。但如果我们从中心条目的邻居开始,就能访问我们“棋盘”中的其余nan条目。让我们再次调用fillsquare()函数,这次从不同的条目开始,并将第四个参数指定为零,表示我们希望完全填满方阵:

entry_i = math.floor(n/2) + 1
entry_j = math.floor(n/2)

square = fillsquare(square,entry_i,entry_j,0)

如果我们现在打印出我们的方阵,可以看到它已经完全填满:

>>> **printsquare(square)**
         [0]   [1]   [2]   [3]   [4]   [5]   [6]
   [0]    22    47    16    41    10    35     4
   [1]     5    23    48    17    42    11    29
   [2]    30     6    24     0    18    36    12
   [3]    13    31     7    25    43    19    37
   [4]    38    14    32     1    26    44    20
   [5]    21    39     8    33     2    27    45
   [6]    46    15    40     9    34     3    28

我们只需要做一个最后的更改。由于%运算符的规则,我们的方阵包含了从 0 到 48 的连续整数,但久留岛算法是用来填充从 1 到 49 的整数。我们可以加上一行代码,将方阵中的 0 替换为 49:

square=[[n**2 if x == 0 else x for x in row] for row in square]

现在我们的方阵已经完成。我们可以使用之前创建的verifysquare()函数验证它是否确实是一个魔方阵:

verifysquare(square)

这应该返回True,表示我们已经成功。

我们刚刚使用久留岛算法创建了一个 7×7 的魔方阵。让我们测试一下我们的代码,看看它是否能创建一个更大的魔方阵。如果我们将n更改为 11 或任何其他奇数,我们可以运行完全相同的代码,得到任何大小的魔方阵:

n = 11
square=[[float('nan') for i in range(0,n)] for j in range(0,n)]

center_i = math.floor(n/2)
center_j = math.floor(n/2)

square[center_i][center_j] = int((n**2 + 1)/2)
square[center_i + 1][center_j] = 1
square[center_i - 1][center_j] = n**2
square[center_i][center_j + 1] = n**2 + 1 - n
square[center_i][center_j - 1] = n

entry_i = center_i
entry_j = center_j

square = fillsquare(square,entry_i,entry_j,(n**2)/2 - 4)

entry_i = math.floor(n/2) + 1
entry_j = math.floor(n/2)

square = fillsquare(square,entry_i,entry_j,0)

square = [[n**2 if x == 0 else x for x in row] for row in square]

我们的 11×11 方阵如下所示:

>>> **printsquare(square)**
         [0]   [1]   [2]   [3]   [4]   [5]   [6]   [7]   [8]   [9]  [10]
   [0]    56   117    46   107    36    97    26    87    16    77     6
   [1]     7    57   118    47   108    37    98    27    88    17    67
   [2]    68     8    58   119    48   109    38    99    28    78    18
   [3]    19    69     9    59   120    49   110    39    89    29    79
   [4]    80    20    70    10    60   121    50   100    40    90    30
   [5]    31    81    21    71    11    61   111    51   101    41    91
   [6]    92    32    82    22    72     1    62   112    52   102    42
   [7]    43    93    33    83    12    73     2    63   113    53   103
   [8]   104    44    94    23    84    13    74     3    64   114    54
   [9]    55   105    34    95    24    85    14    75     4    65   115
  [10]   116    45   106    35    96    25    86    15    76     5    66

我们可以手动验证,或者使用我们的verifysquare()函数,来确认这确实是一个魔方阵。你可以用任何奇数n做同样的操作,惊叹于结果。

魔方阵虽然没有太多实际意义,但观察它们的模式仍然很有趣。如果你感兴趣,可以花些时间思考以下问题:

  • 我们创建的更大魔方阵是否遵循了罗书方阵外缘所见的奇偶交替模式?你认为每个可能的魔方阵都遵循这个模式吗?如果有的话,为什么会有这个模式?

  • 你是否发现我们创建的魔方阵中还有其他未提及的模式?

  • 你能找到另一组规则来创建久留岛的方阵吗?例如,是否有规则能够让人们通过久留岛的方阵上下移动,而不是斜着移动?

  • 是否存在其他类型的魔方阵,它们符合魔方阵的定义,但完全不遵循久留岛规则?

  • 是否有更高效的方式编写代码来实现久留岛算法?

魔方占据了几百年来日本伟大数学家的注意力,并且在世界各地的文化中找到了重要的位置。我们可以认为自己非常幸运,因为过去的伟大数学家为我们提供了生成和分析魔方的算法,我们可以轻松地在今天强大的计算机上实现这些算法。与此同时,我们也可以钦佩他们仅凭纸笔和智慧(偶尔还有神奇的海龟)进行魔方研究所需的耐心和洞察力。

总结

在本章中,我们讨论了一些历史性的算法,时间跨度从几个世纪到几千年不等。对历史算法感兴趣的读者可以找到更多的算法进行学习。这些算法今天可能没有很大的实际用途,但研究它们是值得的——首先因为它们让我们感受到了历史的气息,其次因为它们帮助我们拓宽视野,并且可能为编写我们自己创新算法提供灵感。

下一章中的算法使我们能够完成一些常见且有用的数学功能任务:最大化和最小化它们。现在我们已经讨论了算法的概念以及历史中的算法,你应该对什么是算法以及它是如何工作的有了充分的了解,并且准备好深入研究当今最前沿软件中使用的严肃算法。

第三章:最大化与最小化

金发姑娘偏爱中间的选项,但在算法的世界里,我们通常更关心极端的高值和低值。一些强大的算法能够帮助我们找到极值(例如,最大收入、最大利润、最大效率、最大生产力)和极小值(例如,最小成本、最小错误、最小不适、最小损失)。本章介绍了梯度上升和梯度下降两种简单但有效的方法,用于高效地找到函数的极值点。我们还讨论了最大化和最小化问题中出现的一些问题,以及如何处理这些问题。最后,我们讨论了如何判断某个算法是否适合在特定情况下使用。我们将从一个假设场景开始——尝试设定最优税率以最大化政府收入——并看看如何使用算法找到正确的解决方案。

设置税率

假设你被选为一个小国的总理。你有雄心勃勃的目标,但你觉得自己的预算不足以实现它们。所以你上任后的第一项工作就是最大化政府的税收收入。

要最大化收入,选择什么样的税率并不显而易见。如果税率为 0%,你将获得零收入。如果税率为 100%,看起来纳税人很可能会避免生产性活动,拼命寻找避税工具,以至于收入可能接近零。优化收入将需要找到一个正确的平衡点,在这个平衡点上,税率既不会高到让生产性活动受到抑制,也不会低到导致收入不足。要实现这种平衡,你需要了解税率与收入之间的关系。

正确方向的步骤

假设你和你的经济学团队讨论了这个问题。他们理解你的观点后,便回到他们的研究办公室,在那里他们使用了顶级研究经济学家们普遍使用的工具——主要是试管、在跑步机上跑的仓鼠、星盘和占卜棒——来确定税率和收入之间的确切关系。

经过一段时间的隔离后,团队告诉你他们已经确定了一个将税率与税收收入相关联的函数,并且他们很友好地用 Python 为你编写了这个函数。也许这个函数看起来如下所示:

import math
def revenue(tax):
    return(100 * (math.log(tax+1) - (tax - 0.2)**2 + 0.04))

这是一个 Python 函数,它以tax为参数并返回一个数值输出。这个函数本身存储在一个名为revenue的变量中。你启动 Python 来生成这个曲线的简单图表,在控制台中输入以下内容。就像在第一章中一样,我们将使用matplotlib模块来绘制图表。

import matplotlib.pyplot as plt
xs = [x/1000 for x in range(1001)]    
ys = [revenue(x) for x in xs]
plt.plot(xs,ys)
plt.title('Tax Rates and Revenue')
plt.xlabel('Tax Rate')
plt.ylabel('Revenue')
plt.show()

这个图表展示了你们经济学家团队预计在 0 到 1 之间各个税率下的收入(单位为你国家的货币,单位为十亿)。其中 1 代表 100%的税率。如果你们国家当前对所有收入实行 70%的统一税率,我们可以在代码中添加两行来将该点绘制在曲线上,代码如下:

import matplotlib.pyplot as plt
xs = [x/1000 for x in range(1001)]    
ys = [revenue(x) for x in xs]
plt.plot(xs,ys)
current_rate = 0.7
plt.plot(current_rate,revenue(current_rate),'ro')
plt.title('Tax Rates and Revenue')
plt.xlabel('Tax Rate')
plt.ylabel('Revenue')
plt.show()

最终输出是图 Figure 3-1 中的简单图表。

figure_3-1

Figure 3-1: 税率与收入之间的关系,图中的点表示你们国家当前的情况

根据经济学家的公式,你们国家当前的税率并没有完全最大化政府的收入。尽管通过简单地视觉检查图表,可以大致判断哪个税率对应最大收入,但你不满足于粗略的估算,想要找到更精确的最优税率。通过曲线图可以明显看出,从当前 70%的税率开始,任何增加都将导致总收入下降,而任何减少都会增加总收入,因此,在这种情况下,收入最大化需要降低总体税率。

我们可以通过更正式的方式验证这一点,即求经济学家收入公式的导数。导数是切线的斜率度量,较大的值表示陡峭,负值表示向下运动。你可以在 Figure 3-2 中看到导数的示意图:它只是用来衡量一个函数增长或收缩的速度。

figure_3-2

Figure 3-2: 为了计算导数,我们在曲线的某个点上取切线并找出其斜率。

我们可以在 Python 中创建一个函数来指定这个导数,代码如下:

def revenue_derivative(tax):
    return(100 * (1/(tax + 1) - 2 * (tax - 0.2)))

我们使用了四条微积分规则来推导这个函数。首先,我们使用了log(x)的导数是 1/x的规则。正因为如此,log(tax + 1)的导数是 1/(tax + 1)。另一个规则是x²的导数是 2x。因此,(tax – 0.2)²的导数是 2(tax – 0.2)。另外还有两个规则:常数的导数永远是 0,100f(x)的导数是 100 乘以f(x)的导数。将这些规则结合起来,你会发现我们的税收-收入函数,100(log(tax + 1) – (tax – 0.2)² + 0.04),其导数如下所示,正如 Python 函数中所描述的那样:

c03eq001

我们可以检查该国当前税率下的导数确实是负值:

print(revenue_derivative(0.7))

这将给我们输出-41.17647

负导数意味着提高税率会导致收入减少。反之,降低税率应该会导致收入增加。虽然我们还不确定与曲线最大值对应的准确税率,但我们可以至少确信,如果我们从当前位置朝着减少税收的方向迈出一小步,收入应该会增加。

为了朝着收入最大值迈出一步,我们首先应当指定一个步长。在 Python 中,我们可以将一个适当小的步长存储在一个变量中,如下所示:

step_size = 0.001

接下来,我们可以通过找到一个新的税率,使得它与当前税率之间的差距与一个步长成比例,从而朝着最大值的方向迈出一步:

current_rate = current_rate + step_size * revenue_derivative(current_rate)

到目前为止,我们的过程是从当前的税率开始,朝着最大值迈出一步,这一步的大小与我们选择的step_size成正比,方向由当前税率下税收函数的导数决定。

我们可以验证,在这一步之后,新的current_rate0.6588235(大约是 66%的税率),对应的新税率的收入是33.55896。但是,虽然我们朝着最大值迈出了步伐并增加了收入,但我们发现自己基本上处于与之前相同的情况:我们还没有到达最大值,但我们知道了函数的导数以及我们应该朝哪个方向走才能达到最大值。所以,我们只需要再迈出一步,就像之前一样,不过这次是使用代表新税率的值。再次设置:

current_rate = current_rate + step_size * revenue_derivative(current_rate)

再次运行后,我们发现新的current_rate0.6273425,对应的新税率的收入是34.43267。我们在正确的方向上迈出了另一 步。但我们仍然没有到达最大收入税率,我们还需要再迈出一步才能更接近。

将步骤转化为算法

你可以看到正在出现的模式。我们正在重复执行以下步骤:

  1. 从一个current_ratestep_size开始。

  2. 计算你试图最大化的函数在current_rate处的导数。

  3. step_size * revenue_derivative(current_rate)加到当前税率,得到新的current_rate

  4. 重复步骤 2 和 3。

唯一缺少的就是一个停止规则,一个在达到最大值时触发的规则。实际上,我们很可能会渐近地接近最大值:越来越接近它,但始终保持微小的距离。因此,虽然我们可能永远无法达到最大值,但我们可以接近它,精确到 3 位、4 位或 20 位小数。当我们离渐近线足够近时,我们就知道停止了,这时我们改变税率的幅度非常小。我们可以在 Python 中为此指定一个阈值:

threshold = 0.0001

我们的计划是,当我们每次迭代中税率变化的幅度小于某个量时停止过程。可能我们的步骤过程永远无法收敛到我们期望的最大值,所以如果我们设置一个循环,可能会陷入无限循环。为此,我们会指定一个“最大迭代次数”,如果我们达到这个最大次数,就会放弃并停止。

现在,我们可以将所有这些步骤汇总在一起(清单 3-1)。

threshold = 0.0001
maximum_iterations = 100000

keep_going = True
iterations = 0
while(keep_going):
    rate_change = step_size * revenue_derivative(current_rate)
    current_rate = current_rate + rate_change

    if(abs(rate_change) < threshold):
        keep_going = False

    if(iterations >= maximum_iterations):
        keep_going = False

    iterations = iterations+1

清单 3-1: 实现梯度上升法

运行这段代码后,你会发现收入最大化的税率大约是0.528。我们在清单 3-1 中做的就是所谓的梯度上升法。之所以这么叫,是因为它被用来“上升”到一个最大值,并通过计算梯度来确定移动的方向。(在像我们这样二维的情况下,梯度就叫做导数。)我们可以将我们遵循的步骤完整列出,包括停止标准的描述:

  1. 从一个current_ratestep_size开始。

  2. 计算你试图最大化的函数在current_rate处的导数。

  3. step_size * revenue_derivative(current_rate)加到当前税率上,得到一个新的current_rate

  4. 重复步骤 2 和 3,直到你接近最大值,使得每次步骤中当前税率的变化小于一个非常小的阈值,或者直到你达到足够高的迭代次数。

我们的过程可以简单地用四个步骤来写。尽管它外观简朴、概念简单,梯度上升法依然是一个算法,就像前几章中描述的算法一样。然而,与大多数这些算法不同,梯度上升法今天被广泛应用,并且是许多专业人员日常使用的高级机器学习方法的关键部分。

对梯度上升法的反对意见

我们刚刚通过梯度上升法来最大化假设政府的收入。许多学习梯度上升法的人对此有实际的,甚至是道德上的反对。以下是人们对梯度上升法提出的一些论点:

  • 这没有必要,因为我们可以通过视觉检查来找到最大值。

  • 这没有必要,因为我们可以通过反复猜测、一种猜测与检查的策略来找到最大值。

  • 这没有必要,因为我们可以解一阶条件。

让我们依次考虑这些反对意见。我们之前讨论过视觉检查。对于我们的税收/收入曲线,通过视觉检查很容易大致判断出最大值的位置。但对图表的视觉检查并不能提供高精度的结果。更重要的是,我们的曲线非常简单:它可以在二维中绘制,并且在我们关心的范围内显然只有一个最大值。如果你想象更复杂的函数,就会明白为什么视觉检查不能作为找到函数最大值的有效方法。

例如,考虑一个多维的情况。如果我们的经济学家得出结论,认为收入不仅依赖于税率,还依赖于关税率,那么我们的曲线就必须在三维中绘制。如果它是一个复杂的函数,可能会更难看出最大值的位置。如果我们的经济学家构造了一个将 10 个、20 个甚至 100 个预测变量与预期收入相关联的函数,那么根据我们宇宙、眼睛和大脑的局限性,我们就无法同时绘制所有这些函数图。如果我们甚至无法绘制税收/收入曲线,那么视觉检查就不可能帮助我们找到它的最大值。视觉检查适用于像税收/收入曲线这样简单的示例,但不适用于高度复杂的多维问题。除此之外,绘制曲线本身需要计算每一个感兴趣点的函数值,因此总是比一个写得好的算法花费更多的时间。

可能看起来梯度上升使问题变得过于复杂,而猜测检查策略就足以找到最大值。猜测检查策略包括猜测一个潜在的最大值,并检查它是否高于所有先前猜测的候选最大值,直到我们确信找到了最大值。对此,一种可能的回应是指出,就像视觉检查一样,对于高复杂度的多维函数,猜测检查在实践中可能会变得过于困难。然而,针对猜测检查法来找最大值的最好回应是,这正是梯度上升已经在做的事。梯度上升本质上就是一种猜测检查策略,但它是通过沿着梯度的方向移动猜测,而不是随机猜测,从而“引导”猜测。梯度上升只是猜测检查的一个更高效的版本。

最后,考虑通过求解一阶条件来找到最大值的思路。这是世界各地微积分课程中教授的一种方法。它可以被称为一种算法,其步骤是:

  1. 找到你要最大化的函数的导数。

  2. 将该导数设为零。

  3. 求解使导数为零的点。

  4. 确保该点是最大值而不是最小值。

(在多维空间中,我们可以使用梯度而不是导数,并执行类似的过程。)这个优化算法在其适用范围内是有效的,但可能很难或者无法找到一个闭式解,使得导数为零(步骤 2),而且找到这个解可能比简单地执行梯度上升更为困难。此外,它可能需要大量的计算资源,包括空间、处理能力或时间,并且并非所有软件都具有符号代数能力。从这个角度来看,梯度上升比这个算法更为稳健。

局部极值问题

任何尝试寻找最大值或最小值的算法都面临着局部极值(局部最大值和最小值)的严重潜在问题。我们可能完美地执行了梯度上升,但最终意识到我们达到的顶点只是一个“局部”峰值——它比周围的每个点都高,但比某个远处的全局最大值要低。这种情况在现实生活中也可能发生:你尝试攀登一座山峰,虽然你到达的山顶比周围所有地方都高,但你意识到你只是在山脚下,真正的山顶还远远在上方,而且更高。具有讽刺意味的是,你可能需要稍微下山一点才能到达更高的山顶,因此梯度上升所遵循的“天真”策略——始终向稍微更高的邻近点迈进——无法达到全局最大值。

教育与终身收入

局部极值是梯度上升中的一个非常严重的问题。举个例子,假设我们尝试通过选择最优的教育水平来最大化终身收入。在这种情况下,我们可能假设终身收入与教育年限之间存在如下公式关系:

import math
def income(edu_yrs):
    return(math.sin((edu_yrs - 10.6) * (2 * math.pi/4)) + (edu_yrs - 11)/2)

这里,edu_yrs是一个变量,表示一个人接受了多少年的教育,而income是衡量一个人终身收入的标准。我们可以按照以下方式绘制这条曲线,包括一个拥有 12.5 年正式教育的人的数据点——也就是一个高中毕业生(12 年正式教育),并且已经完成了半年的本科学位课程:

import matplotlib.pyplot as plt
xs = [11 + x/100 for x in list(range(901))]    
ys = [income(x) for x in xs]
plt.plot(xs,ys)
current_edu = 12.5
plt.plot(current_edu,income(current_edu),'ro')
plt.title('Education and Income')
plt.xlabel('Years of Education')
plt.ylabel('Lifetime Income')
plt.show()

我们在图 3-3 中得到了这个图。

figure_3-3

图 3-3: 正式教育与终身收入的关系

这张图,以及用于生成它的收入函数,并非基于实证研究,而仅作为一个说明性的、纯粹假设性的例子。它展示了教育与收入之间可能的直观关系。对于那些没有高中毕业的人(拥有不到 12 年正式教育的人),他们的一生收入可能会很低。高中毕业(12 年教育)是一个重要的里程碑,应当对应着比辍学者更高的收入。换句话说,这是一个最大值,但值得注意的是,它只是一个局部最大值。获得超过 12 年的教育是有帮助的,但一开始并不显著。只完成了几个月大学教育的人,可能不会获得与高中毕业生有太大区别的工作机会,但因为多上了几个月的学,他们错过了那些几个月的收入机会,因此他们的终生收入实际上低于那些高中毕业后直接进入职场并一直待在那里的人的收入。

只有经过几年的大学教育,才能获得那些能够使一个人在一生中赚取比高中毕业生更多的技能——这还考虑到了在学校度过的几年所失去的收入潜力。然后,大学毕业生(拥有 16 年教育)的收入达到了比本地高中毕业生更高的收入峰值。再次强调,这只是一个局部的峰值。在获得本科学位之后再接受一些教育,和高中毕业后接受更多教育的情况类似:你并不会立即获得足够的技能来弥补那段未能赚取收入的时间。最终,这种情况会发生逆转,在获得研究生学位后,你会达到看似另一个收入峰值。很难再对这个问题做出更多推测,但这个简单的教育与收入的关系可以满足我们的目的。

攀登教育山丘——正确的方式

对于我们所想象的个体,其在图表中所代表的是 12.5 年的教育,我们可以像之前所述的那样执行梯度上升。列表 3-2 是我们在列表 3-1 中介绍的梯度上升代码的稍微修改版。

def income_derivative(edu_yrs):
    return(math.cos((edu_yrs - 10.6) * (2 * math.pi/4)) + 1/2)

threshold = 0.0001
maximum_iterations = 100000

current_education = 12.5
step_size = 0.001

keep_going = True
iterations = 0
while(keep_going):
    education_change = step_size * income_derivative(current_education)
    current_education = current_education + education_change
    if(abs(education_change) < threshold):
        keep_going = False
    if(iterations >= maximum_iterations):
        keep_going=False
    iterations = iterations + 1

列表 3-2: 一种爬升收入山丘而非收入山丘的梯度上升实现

Listing 3-2 中的代码完全遵循了与我们之前实现的收入最大化过程相同的梯度上升算法。唯一的区别是我们所处理的曲线不同。我们的税收/收入曲线有一个全局最大值,同时也是唯一的局部最大值。与此相比,我们的教育/收入曲线更为复杂:它有一个全局最大值,但也有多个局部最大值(局部峰值或极大值),这些局部最大值都低于全局最大值。我们必须指定这条教育/收入曲线的导数(在 Listing 3-2 的前几行),我们有不同的初始值(12.5 年的教育而不是 70%的税率),变量的名称也不同(current_education而不是current_rate)。但这些差异是表面的;从根本上说,我们做的事情是一样的:在梯度方向上采取小步骤,朝着最大值前进,直到我们达到适当的停止点。

这个梯度上升过程的结果是我们得出结论:这个人受教育程度过高,实际上,约 12 年是收入最大化的教育年限。如果我们过于天真,过度依赖梯度上升算法,我们可能会建议大学新生立刻辍学并加入工作市场,以在这个局部最大值下最大化收入。过去确实有一些大学生得出过这样的结论,因为他们看到自己高中毕业的朋友比他们挣得多,而他们则在为一个不确定的未来努力。显然,这是不对的:我们的梯度上升过程找到了局部山顶,但并没有找到全局最大值。梯度上升过程令人沮丧地局限于局部:它只爬上它所在的山丘,并且无法为了最终到达更高的山顶而暂时向下走一步。生活中也有类似的例子,比如那些因为短期内无法赚到钱而未能完成大学学位的人。他们没有考虑到,如果他们通过局部最低点,继续爬向另一个更高的山丘(他们的下一个、更有价值的学位),长期收入将会提高。

局部极值问题是一个严峻的问题,并没有什么灵丹妙药来解决它。一种解决问题的方法是尝试多个初始猜测,并对每个初始值执行梯度上升。例如,如果我们对 12.5 年、15.5 年和 18.5 年的教育年限执行梯度上升,我们每次都会得到不同的结果,然后可以比较这些结果,发现实际上全局最大值是通过最大化教育年限来获得的(至少在这个尺度上)。

这是处理局部极值问题的一种合理方法,但它可能需要花费过长的时间进行足够多次的梯度上升才能找到正确的最大值,而且即便尝试了数百次,我们也无法保证得到正确的答案。一种看似更好的方法是向过程引入一定程度的随机性,这样我们有时可能会朝着一个局部较差的解迈进,但从长远来看,这样的做法可能会帮助我们找到更好的最大值。梯度上升的高级版本,称为随机梯度上升,正是因为这个原因引入了随机性,其他算法,如模拟退火,也采取了类似的做法。我们将在第六章讨论模拟退火和与高级优化相关的问题。现在,只需记住,尽管梯度上升非常强大,但它始终面临局部极值问题的挑战。

从最大化到最小化

到目前为止,我们一直在寻求最大化收入:爬上山顶,向上攀登。合理的疑问是,我们是否曾经想过要下山,下降并最小化某些东西(比如成本或误差)。你可能认为最小化需要一整套新技巧,或者我们现有的技巧需要完全翻转、内外调换,或者反向运行。

实际上,从最大化到最小化是非常简单的。一种方法是“翻转”我们的函数,或者更准确地说,取它的负值。以我们的税收/收入曲线为例,定义一个新的翻转函数就像这样简单:

def revenue_flipped(tax):
    return(0 - revenue(tax))

我们可以按如下方式绘制翻转后的曲线:

import matplotlib.pyplot as plt
xs = [x/1000 for x in range(1001)]    
ys = [revenue_flipped(x) for x in xs]
plt.plot(xs,ys)
plt.title('The Tax/Revenue Curve - Flipped')
plt.xlabel('Current Tax Rate')
plt.ylabel('Revenue - Flipped')
plt.show()

图 3-4 显示了翻转后的曲线。

figure_3-4

图 3-4: 税收/收入曲线的负值或“翻转”版本

因此,如果我们想最大化税收/收入曲线,一种选择是最小化翻转后的税收/收入曲线。如果我们想最小化翻转后的税收/收入曲线,一种选择是最大化翻转后的翻转曲线——换句话说,就是原始曲线。每个最小化问题都是翻转函数的最大化问题,每个最大化问题都是翻转函数的最小化问题。如果你能解决一个,你也能解决另一个(在翻转后)。与其学习最小化函数,不如直接学习最大化它们,每次需要最小化时,就去最大化翻转后的函数,你就会得到正确的答案。

翻转并不是唯一的解决方案。实际的最小化过程与最大化过程非常相似:我们可以使用梯度下降代替梯度上升。唯一的区别是每一步的移动方向;在梯度下降中,我们是向下而不是向上。记住,为了找到税收/收入曲线的最大值,我们是沿着梯度的方向移动。为了最小化,我们则沿着梯度的反方向移动。这意味着我们可以按照清单 3-3 中的方式修改我们原有的梯度上升代码。

threshold = 0.0001
maximum_iterations = 10000

**def revenue_derivative_flipped(tax):**
    **return(0-revenue_derivative(tax))**

current_rate = 0.7

keep_going = True
iterations = 0
while(keep_going):
    rate_change = step_size * revenue_derivative_flipped(current_rate)
    **current_rate = current_rate - rate_change**
    if(abs(rate_change) < threshold):
        keep_going = False
    if(iterations >= maximum_iterations):
        keep_going = False
    iterations = iterations + 1

列表 3-3: 实现梯度下降

这里一切相同,只是当我们改变current_rate时,将一个+改成了-。通过这个微小的改变,我们将梯度上升代码转换为梯度下降代码。从某种意义上来说,它们本质上是相同的;它们使用梯度来确定方向,然后朝着这个方向朝着一个确定的目标前进。事实上,现在最常见的惯例是讲梯度下降,而将梯度上升称为梯度下降的稍微修改版本,这与本章介绍的方式相反。

一般的爬山算法

被选为总理是一个罕见的事件,即使是总理,设定税率以最大化政府收入也不是日常事务。(对于本章开头关于税收/收入讨论的现实版本,我鼓励你查阅拉弗曲线。)然而,最大化或最小化某事的思想是非常常见的。企业试图选择价格以最大化利润。制造商试图选择能够最大化效率并最小化缺陷的做法。工程师试图选择能够最大化性能或最小化阻力或成本的设计特性。经济学在很大程度上是围绕最大化和最小化问题构建的:尤其是最大化效用,也包括最大化像 GDP 和收入这样的货币金额,以及最小化估计误差。机器学习和统计学的核心方法依赖于最小化;它们最小化一个“损失函数”或误差指标。对于这些问题,都有可能使用类似爬山算法的解决方案,如梯度上升或下降,来获得最优解。

即使在日常生活中,我们也会选择花费多少钱,以最大化实现我们的财务目标。我们努力最大化幸福、快乐、和平与爱,同时最小化痛苦、不适和悲伤。

举一个生动且容易理解的例子,想象你正在自助餐厅,像我们所有人一样,试图吃到适量的食物以最大化满足感。如果你吃得太少,你会饿着离开,并且可能会觉得花了自助餐的全价却只吃了很少的食物,觉得没物有所值。如果你吃得太多,你会感到不适,甚至可能会生病,可能还会违反你自己制定的饮食计划。总会有一个最佳的平衡点,就像税收/收入曲线的顶点一样,它是最大化满足感的最佳自助餐消费量。

我们人类能够感知并解读来自我们胃部的感官输入,这些输入告诉我们是饿了还是饱了,这有点像物理上对曲线求梯度的过程。如果我们太饿了,我们就会采取某个预先决定的步伐,比如吃一口,朝着满足的最佳状态迈进。如果我们吃得太饱了,我们就停止进食;我们不能“吃回”已经吃过的东西。如果我们的步伐足够小,我们就能确信自己不会大大超过那个最佳状态。我们在自助餐时决定吃多少的过程是一个迭代过程,涉及反复的方向检查和小步调整——换句话说,它本质上与我们在本章学习的梯度上升算法是一样的。

就像捕捉球的例子一样,我们可以在这个自助餐的例子中看到,像梯度上升这样的算法在人的生活和决策中是自然的。即使我们从未上过数学课或写过一行代码,它们对我们来说也是自然的。本章中的工具仅仅是为了使你已经拥有的直觉更加形式化和精确。

何时不使用算法

学习一个算法常常让我们感到强大。我们觉得,只要遇到需要最大化或最小化的情况,就应该立刻应用梯度上升或下降,并盲目相信我们找到的任何结果。然而,有时候,比了解一个算法更重要的是知道何时不使用它,何时它不适合当前任务,或者何时有其他更好的方法可以尝试。

我们什么时候应该使用梯度上升(或下降),什么时候又不该使用呢?如果我们从正确的因素开始,梯度上升通常效果很好:

  • 一个数学函数来最大化

  • 了解我们当前所处的位置

  • 一个明确的目标来最大化该函数

  • 改变我们所处位置的能力

有很多情况下,其中一个或多个因素是缺失的。在设定税率的案例中,我们使用了一个假设函数,描述税率与收入之间的关系。然而,经济学家们并没有就这种关系及其功能形式达成共识。因此,我们可以尽情地进行梯度上升和下降,但直到我们能够就需要最大化的函数达成一致,我们无法依赖我们所找到的结果。

在其他情况下,我们可能会发现梯度上升并不是非常有用,因为我们没有采取行动来优化我们状况的能力。例如,假设我们推导出一个公式,关联了一个人的身高与他们的幸福感。也许这个函数表达了那些身高过高的人因为无法在飞机上舒适地坐着而感到痛苦,而身高过矮的人则因为无法在街头篮球比赛中脱颖而出而感到痛苦,但身高不太高也不太矮的“黄金区间”往往能最大化幸福感。即使我们能够完美地表达这个函数并应用梯度上升找到最大值,这对我们也没有用,因为我们无法控制自己的身高。

如果我们进一步放大视野,可能会发现我们拥有梯度上升(或任何其他算法)所需的所有要素,但仍然因为更深层的哲学原因而选择放弃。例如,假设你能精确地确定税收收入函数,并且你当选为总理,完全控制国家的税率。在你应用梯度上升并攀登到收入最大化的峰值之前,你可能需要问问自己,最大化税收收入是否是一个值得追求的目标。也许你更关心的是自由、经济活力、再分配正义,甚至是民意调查,而不是国家收入。即使你已经决定要最大化收入,最大化短期收入(也就是今年)也不一定能带来长期收入的最大化。

算法在实际应用中非常强大,使我们能够实现诸如接住棒球和寻找最大化税收收入的税率等目标。但尽管算法可以有效地达成目标,它们并不适合用于决定哪些目标值得追求这个更具哲学性的问题。算法可以让我们聪明,但它们无法让我们变得智慧。重要的是要记住,如果算法的巨大力量被用于错误的目的,它是无用的,甚至可能是有害的。

总结

本章介绍了梯度上升和梯度下降,作为简单且强大的算法,用于分别找到函数的最大值和最小值。我们还讨论了局部极值可能带来的严重问题,以及在何时使用算法、何时优雅地避免使用算法的一些哲学思考。

紧紧抓住,因为在下一章我们将讨论各种搜索和排序算法。搜索和排序在算法的世界中是基础且重要的内容。我们还会讨论“大 O”符号和评估算法性能的标准方法。

第四章:排序和查找

在几乎所有类型的程序中,我们都会使用一些基础的算法。有时这些算法是如此基本,以至于我们把它们视为理所当然,甚至没有意识到我们的代码依赖于它们。

排序和查找的几种方法属于这些基础算法之一。它们值得了解,因为它们被广泛使用,并且深受算法爱好者(以及那些让人痛苦的面试官)的喜爱。这些算法的实现可以非常简短和简单,但每个字符都至关重要,由于它们需求如此广泛,计算机科学家们一直致力于使它们能够以惊人的速度进行排序和查找。因此,我们还将在本章讨论算法速度和比较算法效率时使用的特殊符号。

我们首先介绍插入排序,这是一种简单直观的排序算法。我们将讨论插入排序的速度和效率,并了解如何衡量算法的效率。接下来,我们将介绍归并排序,这是一种更快速的算法,目前是搜索算法中的先进技术。我们还会探讨睡眠排序,这是一种奇怪的算法,虽然在实践中不常用,但作为一种好奇心的产物还是值得关注。最后,我们将讨论二分查找,并展示一些有趣的查找应用,包括反转数学函数。

插入排序

想象一下,你被要求整理文件柜中的所有文件。每个文件都有一个编号,你需要将这些文件重新排序,使得编号最小的文件排在最前面,编号最大的文件排在最后,其他文件的编号按顺序排列。

无论你采用什么方法来整理文件柜,我们都可以将其描述为一种“排序算法”。但在你打开 Python 编程来实现该算法之前,先停下来想一想,如何在现实生活中整理这个文件柜。这个任务看似普通,但请让你内心的冒险者发挥创造力,考虑一系列广泛的可能性。

在这一部分,我们将介绍一种非常简单的排序算法,叫做插入排序。该方法依赖于逐个查看列表中的每个项目,并将其插入到一个新的列表中,最终该列表是按正确顺序排序的。我们算法的代码将分为两个部分:插入部分,它执行将文件插入列表的简单任务;以及排序部分,它重复执行插入操作,直到完成排序任务。

将“插入”放入插入排序

首先,考虑插入任务本身。假设你有一个文件柜,里面的文件已经排序好。如果有人递给你一个新文件,让你将它插入到文件柜中合适的(已排序的)位置,你该如何完成这个任务?这项任务可能看起来如此简单,以至于不需要解释,甚至不需要考虑任何可能性(直接做就行!你可能会这样想)。但是在算法的世界里,每个任务,不论多么简单,都必须完全解释清楚。

以下方法描述了一种合理的算法,用于将一个文件插入到已排序的文件柜中。我们将要插入的文件称为“要插入的文件”。我们假设我们可以比较两个文件,并称一个文件“高于”另一个文件。这可能意味着一个文件的编号比另一个文件的编号高,或者它可能在字母顺序或其他排序中排得更靠前。

  1. 选择文件柜中最高的文件。(我们将从文件柜的底部开始,逐步向前移动。)

  2. 将你选择的文件与要插入的文件进行比较。

  3. 如果你选择的文件比要插入的文件低,就将要插入的文件放在该文件后面一个位置。

  4. 如果你选择的文件比要插入的文件高,选择文件柜中下一个较高的文件。

  5. 重复步骤 2 到 4,直到你插入了文件,或者与每个现有文件进行了比较。如果在与每个现有文件比较后你还没有插入文件,则将其插入到文件柜的最前面。

这个方法应该或多或少与你插入记录到已排序列表中的直觉相匹配。如果你愿意,也可以从列表的开头开始,而不是从末尾开始,并按照类似的过程进行,结果也是一样的。注意,我们不仅仅是插入了一个记录;我们是将记录插入了正确的位置,所以插入后,我们仍然会得到一个已排序的列表。我们可以编写一个 Python 脚本来执行这个插入算法。首先,我们可以定义我们的排序文件柜。在这个例子中,我们的文件柜将是一个 Python 列表,而我们的文件将仅仅是数字。

cabinet = [1,2,3,3,4,6,8,12]

然后,我们可以定义我们想要插入到文件柜中的“文件”(在这个例子中只是一个数字)。

to_insert = 5

我们按顺序逐一处理列表中的每个数字(文件柜中的每个文件)。我们将定义一个变量叫做check_location。如广告所示,它将存储我们想要检查的文件柜位置。我们从文件柜的最底部开始:

check_location = len(cabinet) - 1

我们还将定义一个变量叫做insert_location。我们算法的目标是确定insert_location的正确值,然后只需将文件插入到insert_location即可。我们将假设insert_location的初始值为 0:

insert_location = 0

然后,我们可以使用一个简单的if语句检查要插入的文件是否大于位于check_location的文件。一旦我们遇到一个比要插入的数字小的数字,就可以使用其位置来决定新数字的插入位置。我们加 1 是因为插入的位置就在我们找到的较小数字之后:

if to_insert > cabinet[check_location]:
    insert_location = check_location + 1

在我们确定了正确的insert_location之后,可以使用 Python 内置的列表操作方法insert将文件插入到文件柜中:

cabinet.insert(insert_location,to_insert)

然而,运行这段代码仍然不能正确插入文件。我们需要将这些步骤整合到一个连贯的插入函数中。这个函数将所有之前的代码合并在一起,并且还添加了一个while循环。while循环用于遍历文件柜中的文件,从最后一个文件开始,直到我们找到合适的insert_location,或者检查完所有文件。文件柜插入的最终代码在示例 4-1 中。

def insert_cabinet(cabinet,to_insert):
  check_location = len(cabinet) - 1
  insert_location = 0
  while(check_location >= 0):
    if to_insert > cabinet[check_location]:
        insert_location = check_location + 1
        check_location = - 1
    check_location = check_location - 1
  cabinet.insert(insert_location,to_insert)
  return(cabinet)

cabinet = [1,2,3,3,4,6,8,12]
newcabinet = insert_cabinet(cabinet,5)
print(newcabinet)

示例 4-1: 将一个编号文件插入到我们的文件柜中

当你运行示例 4-1 中的代码时,它会输出newcabinet,你可以看到,5 这个新“文件”已经按正确的位置(4 和 6 之间)插入到文件柜中了。

现在值得花一点时间思考插入的一个极端情况:插入到一个空列表中。我们的插入算法提到过“依次处理文件柜中的每个文件”。如果文件柜中没有文件,那么就没有什么可以依次处理的了。在这种情况下,我们只需要关注最后一句话,它告诉我们将新文件插入到柜子的开头。当然,这比说起来容易,因为空文件柜的开头也是文件柜的结尾和中间。因此,在这种情况下,我们所需要做的就是把文件插入到柜子里,而不必考虑位置。我们可以通过使用 Python 中的insert()函数,并在位置 0 插入文件来做到这一点。

通过插入排序

既然我们已经严格定义了插入操作并知道如何执行它,我们几乎可以开始执行插入排序了。插入排序很简单:它一次处理未排序列表中的每个元素,并使用我们的插入算法将其正确地插入到一个新的已排序列表中。在文件柜的术语中,我们从一个未排序的文件柜开始,称之为“旧文件柜”,以及一个空的文件柜,称之为“新文件柜”。我们移除旧文件柜中的第一个元素,并使用插入算法将其添加到新的空文件柜中。我们对旧文件柜中的第二个元素执行相同操作,然后是第三个,以此类推,直到将所有旧文件柜中的元素都插入到新文件柜中。然后,我们忘记旧文件柜,只使用新的已排序文件柜。由于我们一直在使用插入算法进行插入,而且该算法总是返回一个排序好的列表,因此我们知道在过程结束时,我们的新文件柜将是已排序的。

在 Python 中,我们从一个未排序的文件柜和一个空的newcabinet开始:

cabinet = [8,4,6,1,2,5,3,7]
newcabinet = []

我们通过反复调用我们的insert_cabinet()函数来实现插入排序,这个函数来自于列表 4-1。为了调用它,我们需要手中有一个文件,这通过从未排序的文件柜中弹出文件来实现:

to_insert = cabinet.pop(0)
newcabinet = insert_cabinet(newcabinet, to_insert)

在这个代码片段中,我们使用了一种名为pop()的方法。这个方法会移除指定索引位置的列表元素。在本例中,我们移除了cabinet中索引为 0 的元素。使用pop()后,cabinet不再包含该元素,我们将它存储在变量to_insert中,以便将其放入newcabinet

我们将在列表 4-2 中把这一切结合起来,在那里我们定义了一个insertion_sort()函数,它循环遍历未排序的文件柜中的每个元素,将元素一个一个地插入到newcabinet中。最后,在过程的末尾,我们打印出结果,即一个名为sortedcabinet的已排序文件柜。

cabinet = [8,4,6,1,2,5,3,7]
def insertion_sort(cabinet):
  newcabinet = []
  while len(cabinet) > 0:
    to_insert = cabinet.pop(0)
    newcabinet = insert_cabinet(newcabinet, to_insert)
  return(newcabinet)

sortedcabinet = insertion_sort(cabinet)
print(sortedcabinet)

列表 4-2: 插入排序的实现

既然我们已经能够实现插入排序,那么我们就可以对任何遇到的列表进行排序了。我们可能会倾向于认为这意味着我们已经掌握了所有需要的排序知识。然而,排序是如此基础和重要,我们希望能够以最佳的方式来完成它。在讨论插入排序的替代方法之前,让我们先看看什么意味着一个算法比另一个更好,并且更基本地,什么才算是一个好的算法。

测量算法效率

插入排序是一个好的算法吗?这个问题很难回答,除非我们明确知道“好”是什么意思。插入排序有效——它能够排序列表——所以从它完成目标的意义上来说,它是好的。另一个优点是,它很容易理解,并且可以通过许多人熟悉的物理任务来解释。它的又一个优势是,表达它所需的代码行数不多。到目前为止,插入排序看起来是一个好的算法。

然而,插入排序有一个关键的缺陷:它需要很长时间来执行。示例 4-2 中的代码几乎肯定在你的计算机上运行不到一秒钟,因此插入排序所需的“长时间”并不是指像小种子成长为参天红木那样的漫长时光,也不是指在车管所排队等待的长时间。它更像是与一只蚊子拍一次翅膀所需的时间相比的“长时间”。

担心一只小蚊子拍翅膀所需要的“长时间”似乎有些极端。但推向零秒运行时间是有几个合理的原因的。

为什么追求效率?

不断追求算法效率的第一个原因是它能提升我们的基础能力。如果你的低效算法排序一个八项列表需要一分钟,可能看起来不成问题。但假设这种低效算法排序一个千项列表需要一小时,排序一个百万项列表需要一周,排序一个十亿项列表可能需要一年甚至一个世纪,或者根本无法完成排序。如果我们能够改善这个算法,让它能更有效地排序一个八项列表(虽然它只节省了一分钟,但似乎是微不足道的),那可能就能在一小时内排序一个十亿项的列表,而不是一个世纪,这能开启许多可能性。像 k 均值聚类和 k-NN 监督学习这样的高级机器学习方法依赖于对长列表的排序,而改进排序等基础算法的性能可以使我们在处理庞大的数据集时,能够执行这些方法,否则这些数据集可能会超出我们的掌控。

即使是对短列表的排序,如果是我们需要多次执行的任务,也应该尽量快速完成。例如,全球的搜索引擎每几个月就会收到万亿次搜索,并且必须按相关性从高到低对每一组结果进行排序,然后再呈现给用户。如果它们能将一次简单排序所需的时间从一秒钟减少到半秒钟,它们就能将所需的处理时间从万亿秒减少到半万亿秒。这为用户节省了时间(为五亿人节省一千秒真的是个大数!),同时降低了数据处理成本,而通过减少能量消耗,高效的算法也更环保。

创建更快算法的最终原因与人们在任何追求中试图做得更好的原因相同。即使没有明显的需求,人们也会试图跑得更快,棋下得更好,做出比以往任何人更美味的披萨。他们这样做的原因正如乔治·马洛里所说的,他想攀登珠穆朗玛峰:“因为它在那里。”人类天生就有推动可能性边界的欲望,努力变得更好、更快、更强大、更聪明。算法研究人员之所以想做得更好,部分原因是他们希望做出一些令人瞩目的事情,无论它是否在实际中有用。

精确测量时间

由于算法运行所需的时间非常重要,我们应该比说插入排序“很长时间”或“不到一秒”更精确。究竟需要多长时间?为了得到确切的答案,我们可以使用 Python 中的 timeit 模块。通过 timeit,我们可以创建一个计时器,在运行排序代码前启动,并在运行之后结束。当我们检查开始时间和结束时间的差异时,就能得出代码运行所需的时间。

from timeit import default_timer as timer

start = timer()
cabinet = [8,4,6,1,2,5,3,7]
sortedcabinet = insertion_sort(cabinet)
end = timer()
print(end - start)

当我在我的消费级笔记本电脑上运行这段代码时,它大约花了 0.0017 秒。这是表达插入排序好坏的一种合理方式——它可以在 0.0017 秒内完全排序一个包含八个项目的列表。如果我们想将插入排序与其他排序算法进行比较,我们可以通过比较这个 timeit 定时的结果来看哪个更快,并认为更快的那个更好。

然而,使用这些定时来比较算法性能存在一些问题。例如,当我第二次在笔记本电脑上运行定时代码时,我发现它只用了 0.0008 秒。第三次,我发现它在另一台电脑上运行用了 0.03 秒。你得到的精确时长取决于你的硬件的速度和架构、操作系统(OS)的当前负载、你运行的 Python 版本、操作系统内部的任务调度器、代码的效率,可能还有其他随机性的混乱因素、电子运动以及月相等。由于每次定时尝试可能会得到非常不同的结果,因此很难仅依赖定时来比较算法的效率。一位程序员可能吹嘘说他们能在 Y 秒内排序一个列表,而另一位程序员则笑着说他们的算法在 Z 秒内得到更好的性能。我们可能会发现他们运行的其实是完全相同的代码,但在不同的硬件上、不同的时间运行,因此他们的比较并不是算法效率的比较,而是硬件速度和运气的比较。

计算步骤

与其使用秒数作为时间度量,更可靠的算法性能衡量标准是执行算法所需的步骤数。算法所需的步骤数是算法本身的特性,它不依赖于硬件架构,甚至不一定依赖于编程语言。清单 4-3 是我们在清单 4-1 和 4-2 中的插入排序代码,增加了几行代码,其中我们指定了 stepcounter+=1。每次我们从旧列表中取出一个新元素插入时,每次我们将该元素与新列表中的其他元素进行比较时,以及每次我们将该元素插入新列表时,都会增加我们的步骤计数器。

def insert_cabinet(cabinet,to_insert):
  check_location = len(cabinet) - 1
  insert_location = 0
  global stepcounter
  while(check_location >= 0):
    stepcounter += 1
    if to_insert > cabinet[check_location]:
        insert_location = check_location + 1
        check_location = - 1
    check_location = check_location - 1
  stepcounter += 1
  cabinet.insert(insert_location,to_insert)
  return(cabinet)

def insertion_sort(cabinet):
  newcabinet = []
  global stepcounter
  while len(cabinet) > 0:
    stepcounter += 1
    to_insert = cabinet.pop(0)
    newcabinet = insert_cabinet(newcabinet,to_insert)
  return(newcabinet)

cabinet = [8,4,6,1,2,5,3,7]
stepcounter = 0
sortedcabinet = insertion_sort(cabinet)
print(stepcounter)

清单 4-3: 我们带有步骤计数器的插入排序代码

在这种情况下,我们可以运行这段代码,看到它需要执行 36 步才能完成一个长度为 8 的列表的插入排序。让我们尝试对其他长度的列表进行插入排序,看看需要多少步骤。

为此,让我们编写一个函数,检查不同长度的未排序列表进行插入排序所需的步骤数。我们可以使用 Python 中的简单列表推导式生成任意指定长度的随机列表,而不是手动编写每个未排序的列表。我们可以导入 Python 的 random 模块,使得随机生成列表变得更加容易。以下是如何生成一个长度为 10 的随机未排序列表:

import random
size_of_cabinet = 10
cabinet = [int(1000 * random.random()) for i in range(size_of_cabinet)]

我们的函数将简单地生成一个给定长度的列表,运行我们的插入排序代码,并返回它找到的 stepcounter 最终值。

def check_steps(size_of_cabinet):
  cabinet = [int(1000 * random.random()) for i in range(size_of_cabinet)]
  global stepcounter
  stepcounter = 0
  sortedcabinet = insertion_sort(cabinet)
  return(stepcounter)

让我们创建一个包含 1 到 100 之间所有数字的列表,并检查对每个长度的列表进行排序所需的步骤数。

random.seed(5040)
xs = list(range(1,100))
ys = [check_steps(x) for x in xs]
print(ys)

在这段代码中,我们首先调用 random.seed() 函数。这不是必需的,但如果你运行相同的代码,它将确保你看到的结果与这里打印的一致。你可以看到,我们定义了 x 的值集,存储在 xs 中,以及 y 的值集,存储在 ys 中。x 值只是 1 到 100 之间的数字,y 值是对应每个 x 的随机生成的列表所需的排序步骤数。如果你查看输出,你可以看到插入排序对长度从 1 到 99 的随机生成列表所需的步骤数。我们可以绘制列表长度和排序步骤之间的关系,如下所示。我们将导入 matplotlib.pyplot 来完成绘图。

import matplotlib.pyplot as plt
plt.plot(xs,ys)
plt.title('Steps Required for Insertion Sort for Random Cabinets')
plt.xlabel('Number of Files in Random Cabinet')
plt.ylabel('Steps Required to Sort Cabinet by Insertion Sort')
plt.show()

图 4-1 展示了输出结果。你可以看到输出曲线有些锯齿状——有时较长的列表会比较短的列表排序所需的步骤更少。之所以会这样,是因为我们每次都随机生成列表。偶尔,我们的随机列表生成代码会创建一个插入排序可以快速处理的列表(因为它已经部分排序),而偶尔它会创建一个通过纯随机方式难以快速处理的列表。由于这个原因,如果你没有使用相同的随机种子,你可能会发现屏幕上的输出与这里打印的结果不完全一样,但整体形状应该是相同的。

figure_4-1

图 4-1: 插入排序步骤

与著名函数的比较

超越图 4-1 表面上的锯齿状,我们可以检查曲线的一般形状,并尝试推测其增长速率。从x = 1 到大约x = 10 之间,所需的步骤数增长得相当缓慢。之后,它似乎开始逐渐变陡(而且更加锯齿状)。在大约x = 90 到x = 100 之间,增长速率确实变得非常陡峭。

说随着列表长度的增加,图表逐渐变陡,这样的说法仍然不够精确。我们有时口语中会把这种加速增长称为“指数型”。我们这里是在处理指数增长吗?严格来说,有一个叫做指数函数的函数,它由e^(x)定义,其中e是欧拉数,约等于 2.71828。那么,插入排序所需的步骤数是否遵循这个指数函数,从而可以说它符合最狭义的指数增长定义呢?我们可以通过将我们的步骤曲线与指数增长曲线一起绘制来获取答案的线索,方法如下。我们还将导入numpy模块,以便获取步骤值的最大值和最小值。

import math
import numpy as np
random.seed(5040)
xs = list(range(1,100))
ys = [check_steps(x) for x in xs]
ys_exp = [math.exp(x) for x in xs]
plt.plot(xs,ys)
axes = plt.gca()
axes.set_ylim([np.min(ys),np.max(ys) + 140])
plt.plot(xs,ys_exp)
plt.title('Comparing Insertion Sort to the Exponential Function')
plt.xlabel('Number of Files in Random Cabinet')
plt.ylabel('Steps Required to Sort Cabinet')
plt.show()

就像之前一样,我们定义xs为 1 到 100 之间的所有数字,ys为排序每个大小对应的随机生成列表所需的步骤数。我们还定义了一个变量ys_exp,它是每个存储在xs中的值对应的e^(x)。然后,我们将ysys_exp都绘制在同一个图表上。这个结果让我们能够看到排序一个列表所需步骤的增长与真正的指数增长之间的关系。

运行这段代码会生成图 4-2 所示的图表。

figure_4-2

图 4-2: 插入排序步骤与指数函数的比较

我们可以看到图表左侧的真实指数增长曲线朝着无限大急速上升。尽管插入排序步骤曲线以加速的速率增长,但它的加速似乎并没有接近匹配真正的指数增长。如果你绘制其他增长速率也可以称为指数型的曲线,如 2^(×)或 10^(×),你会发现这些曲线的增长速度也远远快于我们的插入排序步骤计数曲线。所以,如果插入排序步骤曲线并不符合指数增长,它可能匹配什么样的增长呢?我们不妨尝试在同一张图中绘制更多的函数。在这里,我们将绘制 y = xy = x^(1.5)、y = x² 和 y = x³ 以及插入排序步骤曲线。

random.seed(5040)
xs = list(range(1,100))
ys = [check_steps(x) for x in xs]
xs_exp = [math.exp(x) for x in xs]
xs_squared = [x**2 for x in xs]
xs_threehalves = [x**1.5 for x in xs]
xs_cubed = [x**3 for x in xs]
plt.plot(xs,ys)
axes = plt.gca()
axes.set_ylim([np.min(ys),np.max(ys) + 140])
plt.plot(xs,xs_exp)
plt.plot(xs,xs)
plt.plot(xs,xs_squared)
plt.plot(xs,xs_cubed)
plt.plot(xs,xs_threehalves)
plt.title('Comparing Insertion Sort to Other Growth Rates')
plt.xlabel('Number of Files in Random Cabinet')
plt.ylabel('Steps Required to Sort Cabinet')
plt.show()

这就得出了 图 4-3。

figure_4-3

图 4-3: 插入排序步骤与其他增长率的比较

在 图 4-3 中绘制了五种增长速率,除了用于计数插入排序步骤的锯齿状曲线。你可以看到指数曲线增长最快,紧随其后的是几乎没有出现在图表上的三次方曲线,因为它的增长速度也非常快。与其他曲线相比,y = x 曲线增长极其缓慢;你可以看到它位于图表的最底部。

与插入排序曲线最接近的曲线是 y = x² 和 y = x^(1.5)。目前还不明显哪条曲线最能与插入排序曲线进行比较,因此我们无法确切说明插入排序的增长速率。但通过绘制图形,我们能够做出类似“如果我们正在排序一个包含 n 个元素的列表,插入排序将需要介于 n^(1.5) 和 n² 步之间”的表述。这比“像一只蚊子翅膀的摆动一样”或“今天早上在我的独特笔记本上大约是 0.002 秒”这样的表述更精确且更有力。

添加更多的理论精确度

为了更加精确,我们应该仔细推理插入排序所需的步骤。让我们再次想象,假设我们有一个新的未排序列表,包含 n 个元素。在 表 4-2 中,我们逐步进行插入排序并计算每个步骤所需的步骤数。

表 4-2: 插入排序中的步骤计数

操作描述 从旧柜子中取文件所需的步骤数 与其他文件比较时所需的最大步骤数 将文件插入新柜子所需的步骤数
从旧柜子中取出第一个文件,并将其插入到(空的)新柜子中。 1 0.(没有文件可以比较。) 1
从旧柜子中取出第二个文件并将其插入到现在已有一个文件的新柜子中。 1 1.(有一个文件需要比较,我们必须进行比较。) 1
从旧柜子中取出第三个文件,并将其插入到新柜子中(新柜子现在包含两个文件)。 1 2 个或更少。(有两个文件,我们需要在其中一个文件和所有文件之间进行比较。) 1
从旧柜子中取出第四个文件,并将其插入到新柜子中(新柜子现在包含三个文件)。 1 3 个或更少。(有三个文件,我们需要在其中一个文件和所有文件之间进行比较。) 1
. . . . . . . . . . . .
从旧柜子中取出第n个文件,并将其插入到新柜子中(新柜子包含n   – 1 个文件)。 1 n   – 1 个或更少。(有n   – 1 个文件,我们需要在其中一个文件和所有文件之间进行比较。) 1

如果我们将表中描述的所有步骤加起来,我们得到以下的最大总步骤数:

  • 拉取文件所需的步骤:n(每个n文件拉取需要 1 步)

  • 比较所需的步骤:最多 1 + 2 + . . . + (n – 1)

  • 插入文件所需的步骤:n(每个n文件插入需要 1 步)

如果我们将这些加起来,我们得到如下的表达式:

c04eq001

我们可以使用一个便捷的恒等式来简化这个表达式:

c04eq002

如果我们使用这个恒等式并将所有内容加起来并简化,我们会发现所需的总步骤数是

c04eq003

我们最终得到了一个非常精确的表达式,表示执行插入排序所需的最大总步骤数。但信不信由你,这个表达式甚至可能过于精确,原因有几个。其一是它表示的是所需的最大步骤数,而最小值和平均值可能要低得多,几乎每一个我们可能想要排序的列表所需的步骤都要少得多。记住我们在图 4-1 中绘制的曲线的锯齿状——执行算法所需的时间总是会有所变化,这取决于我们选择的输入。

我们的最大步骤数的表达式可能被认为过于精确的另一个原因是,算法的步骤数对大n值最为重要,但随着n的增大,表达式中的小部分开始主导其余部分,因为不同函数的增长速率差异非常大。

考虑表达式n² + n。它是两个项的和:一个n²项和一个n项。当n = 10 时,n² + n是 110,比n²高 10%。当n = 100 时,n² + n是 10,100,比n²高 1%。随着n的增加,表达式中的n²项变得比n项更重要,因为二次函数增长速度远快于线性函数。所以,如果我们有一个算法需要n² + n步来执行,另一个算法需要n²步来执行,当n非常大时,它们之间的差异会越来越不重要。它们两个差不多都在n²步内运行。

使用大 O 符号表示法

说一个算法大致运行 n² 步是我们在精确度和简洁度之间的一种合理平衡(以及我们拥有的随机性)。我们以正式的方式表达这种“或多或少”关系是通过使用 大 O 表示法(Oorder 的缩写)。我们可以说某个特定的算法是“n² 的大 O”,即 O(n²),如果在最坏情况下,它对于大 n 来说大致运行 n² 步。技术定义表明,如果存在某个常数 M,使得对于所有足够大的 x,函数 f(x) 的绝对值始终小于 M 倍的 g(x),那么函数 f(x) 是函数 g(x) 的大 O。

以插入排序为例,当我们查看执行该算法所需的最大步骤数时,我们发现它是两个项的和:一个是 n² 的倍数,另一个是 n 的倍数。正如我们刚才讨论的那样,随着 n 的增加, n 的倍数项变得越来越不重要,而 n² 项将成为我们唯一关心的。因此,插入排序的最坏情况是它是一个 O(n²)(“大 O 的 n²”)算法。

算法效率的追求在于寻找运行时间为更小函数的大 O 的算法。如果我们能找到一种方法来改变插入排序,使其为 O(n^(1.5)) 而不是 O(n²),那将是一个重大突破,并能在 n 较大的情况下显著提高运行时间。我们可以使用大 O 表示法不仅来讨论时间,也可以讨论空间。一些算法通过将大数据集存储在内存中来提高速度。它们的运行时间可能是小函数的大 O,但在内存需求方面却是较大函数的大 O。根据具体情况,我们可能需要通过消耗内存来提高速度,或者通过牺牲速度来释放内存。在本章中,我们将专注于提高速度,并设计运行时间为最小函数的大 O 的算法,而不考虑内存需求。

在学习了插入排序并看到其运行时间为 O(n²) 后,我们自然会想知道可以合理地期望多少改进。我们能否找到一种“圣杯”算法,可以在不到 10 步的时间内排序任何可能的列表?不能。每个排序算法至少需要 n 步,因为必须依次考虑列表中的每个元素。因此,任何排序算法的最少步骤数都将是 O(n)。我们无法做到比 O(n) 更好,但我们能否做得比插入排序的 O(n²) 更好?可以。接下来,我们将考虑一种已知是 O(nlog(n)) 的算法,它比插入排序有了显著改进。

归并排序

归并排序 是比插入排序更快的一种算法。和插入排序一样,归并排序包含两个部分:一部分用于合并两个列表,另一部分则通过反复合并来实现实际的排序。让我们在考虑排序之前,先来考虑合并的过程。

假设我们有两个文件柜,这两个文件柜各自已经排序,但从未进行过比较。我们想要将它们的内容合并到一个最终的文件柜中,这个文件柜也要完全排序。我们将这一任务称为两个已排序文件柜的 合并。我们该如何处理这个问题?

再次提醒,值得在打开 Python 开始编写代码之前,先考虑一下如果我们面对真实的文件柜会如何操作。在这种情况下,我们可以想象面前有三个文件柜:两个已经排序好的文件柜,我们需要将它们的文件合并在一起;还有一个第三个空文件柜,我们将把文件插入其中,最终这个文件柜将包含来自原始两个文件柜的所有文件。我们可以将这两个原始的文件柜称为“左边”文件柜和“右边”文件柜,假设它们分别放置在我们的左右两边。

合并

合并时,我们可以同时拿起两个原始柜子的第一个文件:用左手拿起左边柜子的第一个文件,用右手拿起右边柜子的第一个文件。无论哪个文件较小,就将其插入到新柜子的第一个位置。要找到新柜子的第二个文件,再次拿起左边和右边柜子的第一个文件进行比较,插入较小的文件到新柜子的最后一个位置。当左边柜子或右边柜子为空时,将非空柜子中的剩余文件一起取出并放到新柜子的末尾。完成后,你的新柜子将包含来自左边和右边柜子的所有文件,并且按照顺序排序。我们成功地合并了原来的两个柜子。

在 Python 中,我们将使用 leftright 变量来表示我们原始的已排序柜子,并且我们将定义一个 newcabinet 列表,它开始时为空,最终将按顺序包含 leftright 中的所有元素。

newcabinet = []

我们将定义两个示例柜子,分别称为 leftright

left = [1,3,4,4,5,7,8,9]
right = [2,4,6,7,8,8,10,12,13,14]

为了比较左边和右边柜子中的第一个元素,我们将使用以下的 if 语句(在我们填充 --snip-- 部分之前,这些语句无法执行):

 if left[0] > right[0]:
    `--snip--`
   elif left[0] <= right[0]:
    *--snip--*

请记住,如果左边柜子的第一个元素小于右边柜子的第一个元素,我们需要将左边柜子的该元素弹出并插入到newcabinet中,反之亦然。我们可以通过使用 Python 内置的 pop() 函数来完成这一操作,并将其插入到我们的 if 语句中,代码如下:

if left[0] > right[0]:
    to_insert = right.pop(0)
    newcabinet.append(to_insert)
elif left[0] <= right[0]:
    to_insert = left.pop(0)
    newcabinet.append(to_insert)

这个过程——检查左柜子和右柜子的第一个元素,并将适当的元素弹出到新柜子中——需要继续进行,直到这两个柜子中至少有一个没有文件。正因如此,我们会将这些if语句嵌套在一个while循环中,循环检查leftright的最小长度。只要leftright都包含至少一个文件,过程就会继续:

while(min(len(left),len(right)) > 0):
    if left[0] > right[0]:
        to_insert = right.pop(0)
        newcabinet.append(to_insert)
    elif left[0] <= right[0]:
        to_insert = left.pop(0)
        newcabinet.append(to_insert)

我们的while循环将在leftright中的任意一个文件插入完毕后停止执行。此时,如果left为空,我们将把right中所有剩余的文件按照当前顺序插入到新柜子的末尾,反之亦然。我们可以通过以下方式实现最后的插入:

if(len(left) > 0):
    for i in left:
        newcabinet.append(i)

if(len(right) > 0):
    for i in right:
        newcabinet.append(i)

最后,我们将所有这些代码片段合并成最终的归并算法,如 Listing 4-4 所示。

def merging(left,right):
    newcabinet = []
    while(min(len(left),len(right)) > 0):
        if left[0] > right[0]:
            to_insert = right.pop(0)
            newcabinet.append(to_insert)
        elif left[0] <= right[0]:
            to_insert = left.pop(0)
            newcabinet.append(to_insert)
    if(len(left) > 0):
        for i in left:
            newcabinet.append(i)
    if(len(right)>0):
        for i in right:
            newcabinet.append(i)
    return(newcabinet)

left = [1,3,4,4,5,7,8,9]
right = [2,4,6,7,8,8,10,12,13,14]

newcab=merging(left,right)

Listing 4-4: 合并两个已排序列表的算法

Listing 4-4 中的代码创建了newcab,一个包含leftright中所有元素的列表,且这些元素已经合并并按顺序排列。你可以运行print(newcab)来验证我们的合并函数是否有效。

从合并到排序

一旦我们了解如何进行合并,归并排序就易如反掌。让我们从创建一个简单的归并排序函数开始,它只处理包含两个或更少元素的列表。一个包含一个元素的列表本身已经是排序好的,所以如果我们将它作为输入传递给归并排序函数,我们应该直接返回它而不做修改。如果我们将一个包含两个元素的列表传递给归并排序函数,我们可以将这个列表拆分成两个包含一个元素的列表(这些列表已经排序),然后对这两个列表调用合并函数,得到最终的排序结果。下面的 Python 函数实现了我们所需的功能:

import math

def mergesort_two_elements(cabinet):
    newcabinet = []
    if(len(cabinet) == 1):
        newcabinet = cabinet
    else:
        left = cabinet[:math.floor(len(cabinet)/2)]
        right = cabinet[math.floor(len(cabinet)/2):]
        newcabinet = merging(left,right)
    return(newcabinet)

这段代码依赖于 Python 的列表索引语法,将我们想要排序的任何柜子分成左柜子和右柜子。你可以看到在定义leftright的代码行中,我们使用了:math.floor(len(cabinet)/2)math.floor(len(cabinet)/2):分别表示原柜子的前半部分和后半部分。你可以使用任何包含一个或两个元素的柜子来调用此函数,例如,mergesort_two_elements([3,1]),并看到它成功返回一个已排序的柜子。

接下来,让我们编写一个可以排序包含四个元素的列表的函数。如果我们将一个四元素的列表拆分为两个子列表,每个子列表将包含两个元素。我们可以按照合并算法将这些列表合并。然而,回想一下,我们的合并算法是设计用来合并两个已经排好序的列表的。而这两个列表可能并没有排好序,因此使用我们的合并算法并不能成功排序它们。然而,每个子列表只有两个元素,而且我们刚刚编写了一个可以对两个元素的列表执行归并排序的函数。所以我们可以将四元素列表拆分为两个子列表,然后对每个子列表调用我们处理两个元素列表的归并排序函数,最后将这两个已排序的子列表合并起来,得到一个包含四个元素的排序结果。这个 Python 函数实现了这一过程:

def mergesort_four_elements(cabinet):
    newcabinet = []
    if(len(cabinet) == 1):
        newcabinet = cabinet
 else:
        left = mergesort_two_elements(cabinet[:math.floor(len(cabinet)/2)])
        right = mergesort_two_elements(cabinet[math.floor(len(cabinet)/2):])
        newcabinet = merging(left,right)
    return(newcabinet)

cabinet = [2,6,4,1]
newcabinet = mergesort_four_elements(cabinet)

我们可以继续编写这些函数来处理越来越大的列表。但突破性进展出现在我们意识到可以通过递归来简化整个过程。考虑清单 4-5 中的函数,并将其与前面的mergesort_four_elements()函数进行比较。

def mergesort(cabinet):
    newcabinet = []
    if(len(cabinet) == 1):
        newcabinet = cabinet
    else:
   1 left = mergesort(cabinet[:math.floor(len(cabinet)/2)])
   2 right = mergesort(cabinet[math.floor(len(cabinet)/2):])
        newcabinet = merging(left,right)
    return(newcabinet)

清单 4-5: 使用递归实现归并排序

你可以看到,这个函数几乎与我们的mergesort_four_elements()函数完全相同。关键的区别在于,创建已排序的左边和右边子列表时,它并没有调用另一个处理更小列表的函数。而是它自己递归调用了较小的列表。归并排序是一种分治法算法。我们从一个大的未排序列表开始。然后我们反复将这个列表拆分成越来越小的部分(分治),直到我们得到每个只包含一个元素的已排序列表,接着我们只需依次将这些列表合并起来,直到最终得到一个大的已排序列表。我们可以将这个归并排序函数应用于任何大小的列表,并检查它是否有效:

cabinet = [4,1,3,2,6,3,18,2,9,7,3,1,2.5,-9]
newcabinet = mergesort(cabinet)
print(newcabinet)

当我们将所有的归并排序代码组合在一起时,我们得到了清单 4-6。

def merging(left,right):
    newcabinet = []
    while(min(len(left),len(right)) > 0):
        if left[0] > right[0]:
            to_insert = right.pop(0)
            newcabinet.append(to_insert)
        elif left[0] <= right[0]:
            to_insert = left.pop(0)
            newcabinet.append(to_insert)
    if(len(left) > 0):
        for i in left:
            newcabinet.append(i)
 if(len(right) > 0):
        for i in right:
            newcabinet.append(i)
    return(newcabinet)

import math

def mergesort(cabinet):
    newcabinet = []
    if(len(cabinet) == 1):
        newcabinet=cabinet
    else:
        left = mergesort(cabinet[:math.floor(len(cabinet)/2)])
        right = mergesort(cabinet[math.floor(len(cabinet)/2):])
        newcabinet = merging(left,right)
    return(newcabinet)

cabinet = [4,1,3,2,6,3,18,2,9,7,3,1,2.5,-9]
newcabinet=mergesort(cabinet)

清单 4-6: 我们的完整归并排序代码

你可以在归并排序代码中添加一个步骤计数器,来检查运行所需的步骤数,并将其与插入排序进行比较。归并排序的过程包括将初始列表反复拆分成子列表,然后将这些子列表合并回去,同时保持排序顺序。每次我们拆分列表时,都会将其对半切割。一个长度为n的列表能够被拆分为子列表的次数大约是 log(n)(这里的 log 是以 2 为底的对数),而在每次合并时我们需要进行的比较次数最多是n。因此,每次 log(n)次比较最多进行n次比较,这意味着归并排序的时间复杂度是 O(n×log(n)),这可能看起来不那么惊人,但实际上它使得归并排序成为排序领域的先进技术。事实上,当我们调用 Python 内置的排序函数sorted时:

print(sorted(cabinet))

Python 在背后使用了一种混合版的归并排序和插入排序来完成这个排序任务。通过学习归并排序和插入排序,你已经跟上了计算机科学家们所能创造的最快排序算法,这是每天在每种可能的应用中使用数百万次的算法。

睡眠排序

互联网对人类的巨大负面影响偶尔会被它提供的一个小而闪亮的宝藏所抵消。偶尔,互联网的深处甚至会产生一种科学发现,这些发现会悄然进入科学期刊或“主流”之外的世界。2011 年,匿名用户在在线图片论坛 4chan 上提出并提供了一种以前从未发表过的排序算法的代码,这种算法后来被称为睡眠排序

睡眠排序并不是为了模拟现实世界的任何情境,比如将文件插入文件柜。如果我们要找一个类比,或许可以考虑在Titanic号开始下沉时,分配救生艇位置的任务。我们可能希望先让孩子和年轻人有机会上救生艇,然后让年长者尝试获得剩余的席位。如果我们宣布“年轻人在年长者之前上船”,那么我们会面临混乱,因为每个人都得比较自己的年龄——在沉船的混乱中,他们将面临一个艰难的排序问题。

对于Titanic号救生艇的睡眠排序方法如下。我们会宣布:“大家请站稳,按自己的年龄数:1,2,3,...。当你数到自己的年龄时,走到前面去上救生艇。”我们可以想象,8 岁的小孩会比 9 岁的小孩早大约一秒钟完成数数,因此他们会提前一秒钟,并且能够在 9 岁的人之前得到救生艇的名额。8 岁和 9 岁的小孩也会比 10 岁的小孩先上船,依此类推。我们无需做任何比较,只需依靠每个人根据我们要排序的度量暂停相应的时间,并随后插入自己,排序就会轻松完成——没有直接的人与人之间的比较。

这个Titanic救生艇过程展示了睡眠排序的思想:允许每个元素直接插入,但必须先暂停一段时间,暂停的时长与其排序依据的度量成正比。从编程的角度来看,这些暂停被称为sleeps,并且可以在大多数编程语言中实现。

在 Python 中,我们可以这样实现睡眠排序。我们将导入threading模块,这样可以为列表中的每个元素创建不同的计算机进程来进行睡眠,然后再让它插入自己。我们还将导入time.sleep模块,这样可以使不同的“线程”根据适当的时间间隔休眠。

import threading
from time import sleep

def sleep_sort(i):
    sleep(i)
    global sortedlist
    sortedlist.append(i)
    return(i)

items = [2, 4, 5, 2, 1, 7]
sortedlist = []
ignore_result = [threading.Thread(target = sleep_sort, args = (i,)).start() \for i in items]

排序后的列表将存储在sortedlist变量中,你可以忽略我们创建的名为ignore_result的列表。你可以看到,睡眠排序的一个优点是它可以用简洁的 Python 代码编写。还有一个有趣的地方是,在排序完成之前(在这个例子中,大约 7 秒内),打印sortedlist变量也很有趣,因为根据你执行print命令的具体时间,你会看到不同的列表。然而,睡眠排序也有一些主要的缺点。其中之一是,由于不可能以负时间长度进行睡眠,睡眠排序无法对包含负数的列表进行排序。另一个缺点是,睡眠排序的执行高度依赖于异常值——如果你向列表中添加 1000,你必须至少等待 1000 秒才能完成算法的执行。还有一个缺点是,如果线程没有完全并发执行,彼此接近的数字可能会被错误地插入。最后,由于睡眠排序使用了线程,它无法在不支持(或支持不好)线程的硬件或软件上良好执行。

如果我们必须用大 O 符号表示睡眠排序的运行时间,我们可能会说它是O(max(list))。与其他所有著名排序算法的运行时间不同,它的运行时间不仅取决于列表的大小,还取决于列表中元素的大小。这使得睡眠排序难以依赖,因为我们只能对某些列表的性能有信心——即使是一个短列表,如果其中的元素过大,也可能需要花费太长时间来排序。

即使在一艘沉船上,睡眠排序也可能永远没有实际应用。我在这里提它有几个原因。首先,因为它与所有其他现存的排序算法都截然不同,它提醒我们,即使是最陈旧、最静态的研究领域,也有创造力和创新的空间,并为看似狭窄的领域提供了耳目一新的视角。其次,因为它是匿名设计并发布的,可能是由研究和实践主流之外的人创作的,它提醒我们,伟大的思想和天才不仅出现在名校、权威期刊和顶级公司,也出现在没有学历和未被认可的人中。第三,它代表了新一代有趣的算法,这些算法是“计算机本土化”的,意味着它们不是可以用柜子和两只手做的类似许多旧算法的翻译,而是基于计算机独有的能力(在这种情况下,是睡眠和线程)。第四,它所依赖的计算机本土化的思想(睡眠和线程)非常有用,值得放进任何算法工程师的工具箱中,以便在设计其他算法时使用。第五,我对它有一种特别的喜爱,可能只是因为它是一个奇特、创造性的“不合群者”,或者因为我喜欢它自组织排序的方法,并且如果我负责拯救沉船,我也可以使用它。

从排序到搜索

搜索和排序一样,是计算机科学中各种任务的基础(在生活中的其他领域也是如此)。我们可能想要在电话簿中查找一个名字,或者(因为我们生活在 2000 年以后)可能需要访问一个数据库并找到相关的记录。

搜索通常只是排序的一个附带结果。换句话说,一旦我们对列表进行了排序,搜索就变得非常直接——排序通常是最困难的部分。

二分查找

二分查找是一种快速有效的在排序列表中查找元素的方法。它有点像猜数字游戏。假设有人想到了一个从 1 到 100 之间的数字,你要猜它。你可能会猜 50 作为第一次尝试。你的朋友说 50 不对,但允许你再次猜,并给出提示:50 太大了。既然 50 太大,你就猜 49。你还是错了,你的朋友告诉你 49 也太大,再给你一次机会。你可以猜 48,然后是 47,依此类推,直到你猜对为止。但这可能会花费很长时间——如果正确的数字是 1,你需要猜 50 次才能猜到,这看起来有点太多了,因为一开始只有 100 种可能性。

一种更好的方法是在确定你的猜测是太高还是太低之后,再进行更大的跳跃。如果 50 太高,考虑下次猜 40 而不是 49,我们能从中学到什么。如果 40 太低,我们已经排除了 39 个可能性(1–39),而且我们肯定能在最多 9 次猜测内猜出(41–49)。如果 40 太高,我们至少排除了 9 个可能性(41–49),而且我们肯定能在最多 39 次猜测内猜出(1–39)。所以在最坏的情况下,猜 40 可以将可能性从 49(1–49)缩小到 39(1–39)。相比之下,猜 49 会将可能性从 49(1–49)缩小到 48(1–48)在最坏情况下。显然,猜 40 比猜 49 是一种更好的搜索策略。

事实证明,最好的搜索策略是猜剩余可能性的正中间。如果你这样做,然后检查你的猜测是太高还是太低,你总是可以排除剩余可能性的一半。如果每次猜测都能排除一半可能性,你实际上可以相当快速地找到正确的值(对于在家记分的人来说是O(log(n)))。例如,一个包含 1000 个项目的列表,只需要 10 次猜测就能通过二分查找策略找到任何元素。如果我们只能有 20 次猜测,我们就能正确地找到一个包含超过一百万个项目的列表中某个元素的位置。顺便说一下,这就是为什么我们可以编写猜谜游戏应用程序,通过仅仅问大约 20 个问题就能“读懂你的心”。

为了在 Python 中实现这一点,我们将从定义文件在文件柜中可能占据的位置的上下限开始。下限将是 0,上限将是柜子的长度:

sorted_cabinet = [1,2,3,4,5]
upperbound = len(sorted_cabinet)
lowerbound = 0

一开始,我们将猜测文件位于柜子的中间。我们将导入 Python 的math库来使用floor()函数,该函数可以将小数转换为整数。记住,猜测中间点给我们提供了最大可能的信息量:

import math
guess = math.floor(len(sorted_cabinet)/2)

接下来,我们将检查我们的猜测是太低还是太高。我们将根据找到的结果采取不同的行动。我们使用looking_for变量来表示我们正在寻找的值:

if(sorted_cabinet[guess] > looking_for):
    `--snip--`
if(sorted_cabinet[guess] < looking_for):
    `--snip--`

如果柜子里的文件太高,那么我们会将我们的猜测设为新的上限,因为没有必要再往柜子的更高处寻找。然后我们新的猜测会更低——准确来说,它将位于当前猜测和下限之间的中间位置:

looking_for = 3
if(sorted_cabinet[guess] > looking_for):
    upperbound = guess
    guess = math.floor((guess + lowerbound)/2)

如果柜子里的文件太低,我们将采取类似的过程:

if(sorted_cabinet[guess] < looking_for):
    lowerbound = guess
    guess = math.floor((guess + upperbound)/2)

最后,我们可以将所有这些步骤整合成一个binarysearch()函数。该函数包含一个while循环,将一直运行,直到我们找到我们一直在寻找的柜子部分(Listing 4-7)。

import math
sortedcabinet = [1,2,3,4,5,6,7,8,9,10]

def binarysearch(sorted_cabinet,looking_for):
    guess = math.floor(len(sorted_cabinet)/2)
    upperbound = len(sorted_cabinet)
    lowerbound = 0
    while(abs(sorted_cabinet[guess] - looking_for) > 0.0001):
        if(sorted_cabinet[guess] > looking_for):
            upperbound = guess
            guess = math.floor((guess + lowerbound)/2)
        if(sorted_cabinet[guess] < looking_for):
            lowerbound = guess
            guess = math.floor((guess + upperbound)/2)
    return(guess)

print(binarysearch(sortedcabinet,8))

Listing 4-7: 二分查找的实现

这段代码的最终输出告诉我们,数字 8 在我们的sorted_cabinet中的位置是 7。这是正确的(记住,Python 列表的索引从 0 开始)。这种通过消除剩余可能性的一半来猜测的策略在许多领域中都非常有用。例如,它是曾经流行的桌游Guess Who中最有效策略的基础。它也是查找大型、不熟悉字典中单词的最佳方法(理论上)。

二分查找的应用

除了猜谜游戏和查找单词,二分查找还在其他一些领域中得到了应用。例如,在调试代码时,我们也可以使用二分查找的思路。假设我们编写了一段不工作的代码,但我们不确定是哪个部分出了问题。我们可以使用二分查找策略来找到问题所在。我们将代码分成两半,并分别运行这两部分。无论哪一半运行不正常,问题就出在那一半。然后,我们再将有问题的部分分成两半,测试每一半,以进一步缩小可能性,直到找到问题所在的代码行。一个类似的思想已经在流行的代码版本控制软件 Git 中实现,命令为git bisect(尽管git bisect是通过时间上分隔的代码版本迭代,而不是通过同一版本中的代码行)。

二分查找的另一个应用是反转一个数学函数。例如,假设我们需要编写一个函数,能够计算给定数字的反正弦(arcsin)。只需几行代码,我们就能编写一个调用binarysearch()函数来得到正确答案的函数。首先,我们需要定义一个定义域;这些是我们将搜索的值,用以找到特定的反正弦值。sine函数是周期性的,它的所有可能值都位于–pi/2 到 pi/2 之间,因此这些极值之间的数字将构成我们的定义域。接下来,我们为定义域中的每个值计算正弦值。然后我们调用binarysearch()函数,找到该值的正弦值与我们正在寻找的值相符的位置,并返回具有相应索引的定义域值,像这样:

def inverse_sin(number):
    domain = [x * math.pi/10000 - math.pi/2 for x in list(range(0,10000))]
    the_range = [math.sin(x) for x in domain]
    result = domain[binarysearch(the_range,number)]
    return(result)

你可以运行inverse_sin(0.9)并看到该函数返回正确答案:大约 1.12。

这并不是反转函数的唯一方法。有些函数可以通过代数操作反转。然而,代数函数反转对于许多函数来说可能非常困难,甚至不可能。相比之下,这里介绍的二分查找方法可以适用于任何函数,并且由于其O(log(n))的运行时间,它的速度非常快。

总结

排序和查找可能对你来说显得平凡,就像你从环游世界的冒险中抽空去参加一个关于叠衣服的研讨会。也许是这样,但请记住,如果你能高效地叠衣服,就可以为登上基里曼扎罗峰准备更多的装备。排序和查找算法可以成为推动者,帮助你在它们的基础上构建更新、更伟大的事物。除此之外,深入研究排序和查找算法是值得的,因为它们是基础且常见的,而你在其中看到的思想对于你今后的智力生涯都是有益的。在这一章中,我们讨论了一些基础且有趣的排序算法,以及二分查找。我们还讨论了如何比较算法并使用大 O 标记法。

在下一章,我们将转向纯数学的一些应用。我们将看看如何利用算法探索数学世界,以及数学世界如何帮助我们理解自己。

第五章:纯数学

算法的定量精确性使得它们自然适用于数学应用。在本章中,我们将探讨在纯数学中有用的算法,并讨论数学思想如何改善我们的算法。我们将从讨论连分数开始,这是一个严谨的话题,它将带领我们攀登无限的高峰,并赋予我们在混乱中找到秩序的能力。接着,我们将讨论平方根,这是一个更为平凡但无疑更有用的话题。最后,我们将讨论随机性,包括随机性的数学以及一些生成随机数的重要算法。

连分数

1597 年,伟大的约翰内斯·开普勒写到他认为几何学的“两个伟大宝藏”:毕达哥拉斯定理和一个后来被称为黄金比例的数字。黄金比例通常用希腊字母phi表示,约等于 1.618,而开普勒只是众多被它吸引的伟大思想家中的一位。像圆周率(pi)以及其他一些著名常数,如指数基数e,phi 也有一种倾向,常常出现在一些出乎意料的地方。人们在大自然中发现了 phi,并且费心记录它在美术作品中的出现,比如 Figure 5-1 中注释版的《罗克比维纳斯》。

在 Figure 5-1 中,一位 phi 爱好者添加了叠加图层,标示出某些长度的比率,如b/ad/c,似乎等于 phi。许多伟大的画作的构图都适合进行这种 phi 狩猎。

Figure_5-1

Figure 5-1: Phi/Venus(来自commons.wikimedia.org/wiki/File:DV_The_Toilet_of_Venus_Gr.jpg

压缩与传递 phi

phi 的精确值出奇地难以表达。我可以说它等于 1.61803399...。这里的省略号是一种“作弊”方式;它意味着后面有更多的数字(实际上是无限多个数字),但我并没有告诉你这些数字是什么,因此你仍然不知道 phi 的精确值。

对于某些具有无限小数扩展的数字,一个分数可以精确表示它们。例如,数字 0.11111...等于 1/9——在这里,分数提供了一种简便的方法来表达无限连续小数的精确值。即使你不知道这种分数表示法,你也可以看到 0.1111...中重复 1 的模式,从而理解它的精确值。不幸的是,黄金比例是所谓的无理数,这意味着没有两个整数xy,使得我们可以说 phi 等于x/y。而且,至今没有人能够辨认出它的数字中有任何规律。

我们有一个无限的小数展开式,既没有明显的规律,也没有分数表示。似乎不可能清楚地表达 phi 的精确值。但是,如果我们进一步了解 phi,就能找到一种既精确又简洁的表达方式。我们知道,phi 是这个方程的解:

c05eq001

我们可以设想一种表达 phi 精确值的方法,那就是写下“上面这段文字中所写方程的解”。这种方法既简洁又在技术上是精确的,但这意味着我们必须以某种方式求解该方程。而且,这种描述并没有告诉我们 phi 展开式中的第 200 位或第 500 位数字。

如果我们将方程除以 phi,我们得到以下结果:

c05eq002

如果我们重新排列该方程,我们得到:

c05eq003

现在,想象一下,如果我们尝试将这个方程奇异地代入自己:

c05eq004

这里,我们将右侧的 phi 重写为 1 + 1/phi。我们可以再次进行同样的代入,为什么不呢?

c05eq005

我们可以任意多次进行这种代入,没有终点。随着我们继续,phi 会被越来越多的层次“推入”一个不断增长的分数的角落。清单 5-1 显示了一个包含 phi 的七级表达式。

c05eq006

清单 5-1: 一个七级连分式,表示 phi 的值

如果我们想象继续这个过程,我们可以把 phi 推向无限层次。然后,剩下的部分在清单 5-2 中显示。

c05eq007

清单 5-2: 一个表达 phi 值的无限连分式

理论上,在由省略号表示的无穷多个 1 和加号以及分数线之后,我们应该在清单 5-2 中插入一个 phi,就像它在清单 5-1 的右下角出现的那样。但我们永远无法完成所有的 1(因为它们是无穷多个),因此我们完全可以忽略掉本应嵌套在右侧的 phi。

更多关于连分式的内容

刚才展示的表达式称为连分数。一个连分数由多个层次嵌套的和与倒数构成。连分数可以是有限的,比如列表 5-1 中的那个,在七层后就结束,或者是无限的,像列表 5-2 中的那个,无限延续下去。连分数特别适合我们的目的,因为它们让我们能够精确表示黄金比例的值,而无需砍伐无限的森林来制造足够的纸张。事实上,数学家有时会使用一种更简洁的表示法,让我们能够在一行中表示一个连分数。我们可以不写出所有连分数中的分数线,而使用方括号([ ])来表示我们正在处理一个连分数,并用分号将“孤立”的数字与组成分数的其他数字分开。通过这种方法,我们可以将黄金比例的连分数写成如下形式:

c05eq008

在这种情况下,省略号不再丢失信息,因为黄金比例的连分数有一个明确的模式:全是 1,所以我们知道它的第 100 项或第 1000 项是什么。这是数学向我们展示奇迹的一次:一种简洁地写下我们曾认为是无限的、没有规律的、难以言喻的数字的方法。但黄金比例并不是唯一可能的连分数。我们还可以写出另一个连分数,如下所示:

c05eq009

在这种情况下,在前几个数字之后,我们找到了一个简单的模式:一对对的 1 与逐渐增大的偶数交替。接下来的值将是 1、1、10、1、1、12,依此类推。我们可以用更常规的方式写出这个连分数的开头,如下所示:

c05eq010

事实上,这个神秘数字不就是我们老朋友e吗?它是自然对数的底数!常数e就像黄金比例和其他无理数一样,具有无限的小数展开,没有明显的规律,并且无法用有限的分数表示,似乎不可能简洁地表示其准确的数值。但通过使用新的连分数概念和新的简洁表示法,我们可以在一行中写出这些看似不可处理的数字。实际上,还有几种独特的方式可以使用连分数表示圆周率π。这是数据压缩的胜利。这也是秩序与混乱之间长期斗争的胜利:我们曾以为数字背后只有无休止的混乱,但我们发现,表面下总有一股深邃的秩序存在。

我们的黄金比例连分数来自一个只对黄金比例有效的特殊方程。但事实上,任何数字都可以生成连分数表示。

生成连分数的算法

要为任何数字找到连分数展开式,我们将使用一个算法。

对于已经是整数分数的数字,最容易找到连分数展开。例如,考虑求 105/33 的连分数表示。我们的目标是将这个数字表示为如下形式:

c05eq011

其中省略号可能指的是一个有限而非无限的延续。我们的算法将首先生成 a,然后是 b,再然后是 c,并依次处理字母表中的项,直到达到最终项或直到我们要求它停止。

如果我们将我们的例子 105/33 解释为除法问题,而非分数,我们发现 105/33 等于 3,余数为 6。我们可以将 105/33 重新写成 3 + 6/33:

c05eq012

该方程的左右两边都由一个整数(3 和 a)和一个分数(6/33 以及右侧其余部分)组成。我们得出结论,整数部分相等,因此 a = 3。之后,我们需要找到合适的 bc 等,确保表达式的整个分数部分等于 6/33。

为了找到正确的 bc 和其余部分,来看一下在得出 a = 3 后我们需要解决的内容:

c05eq013

如果我们对方程的两边取倒数,就得到以下方程:

c05eq014

我们现在的任务是找到 bc。我们可以再次进行除法;33 除以 6 等于 5,余数为 3,因此我们可以将 33/6 重新写成 5 + 3/6:

c05eq015

我们可以看到,方程的两边都有一个整数(5 和 b)和一个分数(3/6 以及右侧其余部分)。我们可以得出结论,整数部分相等,因此 b = 5。我们已经得到了另一个字母,现在需要将 3/6 简化,以便进一步推进。如果你不能立刻看出 3/6 等于 1/2,你可以按照我们之前对 6/33 的处理方法:说 3/6 的倒数是 1/(6/3),然后我们会发现 6/3 是 2,余数为 0。我们遵循的算法要求在余数为 0 时结束,因此我们会意识到过程已完成,并且可以像在 Listing 5-3 中那样写出完整的连分数。

c05eq016

Listing 5-3: 105/33 的连分数

如果你觉得这个通过反复除以两个整数来得到商和余数的过程有些熟悉,那应该没错。事实上,这正是我们在第二章的欧几里得算法中所遵循的相同过程!我们遵循相同的步骤,但记录不同的答案:对于欧几里得算法,我们记录最终的非零余数作为最后的答案,而在连分数生成算法中,我们记录了沿途的每个商(每个字母)。正如数学中常常发生的那样,我们发现了一个意外的联系——在本例中,是连分数生成与最大公约数发现之间的联系。

我们可以按如下方式在 Python 中实现这个连分数生成算法。

我们假设从一个 x/y 形式的分数开始。首先,我们决定 xy 哪个更大,哪个更小:

x = 105
y = 33
big = max(x,y)
small = min(x,y)

接下来,我们将较大的数除以较小的数,正如我们处理 105/33 时所做的那样。当我们发现结果是 3,余数是 6 时,我们得出结论,3 是连分数的第一个项(a)。我们可以将这个商存储如下:

import math
output = []
quotient = math.floor(big/small)
output.append(quotient)

在这种情况下,我们已经准备好获取完整的字母表结果(abc 等),因此我们创建一个空列表 output,并将我们的第一个结果附加到其中。

最后,我们必须重复该过程,就像我们对 33/6 执行的那样。记住,33 之前是small变量,但现在它变成了big,而除法过程的余数是新的small变量。由于余数总是小于除数,bigsmall 会始终正确标记。我们可以在 Python 中通过以下方式完成这一转换:

new_small = big % small
big = small
small = new_small

到此为止,我们已经完成了一轮算法,现在需要对下一组数字(33 和 6)重复执行。为了简洁地完成这个过程,我们可以将其放入一个循环中,如清单 5-4 所示。

import math
def continued_fraction(x,y,length_tolerance):
    output = []
    big = max(x,y)
    small = min(x,y)

 while small > 0 and len(output) < length_tolerance:
        quotient = math.floor(big/small)
        output.append(quotient)
        new_small = big % small
        big = small
        small = new_small
    return(output)

清单 5-4: 一个将分数表示为连分数的算法

在这里,我们将 xy 作为输入,并定义了一个length_tolerance变量。记住,某些连分数是无限长的,而其他一些则非常长。通过在函数中加入一个length_tolerance变量,如果输出变得过于复杂,我们可以提前停止过程,从而避免陷入无限循环。

记住,当我们执行欧几里得算法时,我们使用了递归解法。而在这种情况下,我们则使用了 while 循环。递归非常适合欧几里得算法,因为它只需要在最后得到一个最终输出结果。然而,在这里,我们希望将一系列数字收集到一个列表中。循环更适合这种按顺序收集的任务。

我们可以按如下方式运行我们的新 continued_fraction 生成函数:

print(continued_fraction(105,33,10))

我们将得到以下简单的输出:

[3,5,2]

我们可以看到,这里的数字与清单 5-3 右侧的关键整数是相同的。

我们可能想要检查某个特定的连分数是否正确地表示了我们感兴趣的数字。为了做到这一点,我们应该定义一个get_number()函数,将连分数转换为十进制数,如清单 5-5 所示。

def get_number(continued_fraction):
    index = -1
    number = continued_fraction[index]

    while abs(index) < len(continued_fraction):
        next = continued_fraction[index - 1]
        number = 1/number + next
        index -= 1
    return(number) 

清单 5-5: 将连分数转换为数字的十进制表示

我们不需要担心这个函数的细节,因为我们只是用它来检查我们的连分数。我们可以通过运行get_number([3,5,2])来检查该函数是否正常工作,看到输出结果是 3.181818 . . .,这就是另一种表示 105/33(我们最初的数字)的方法。

从小数到连分数

假设我们不是从某个x/y开始作为输入进入我们的连分数算法,而是从一个小数开始,例如 1.4142135623730951?我们需要做一些调整,但基本上可以沿用我们对分数所采取的相同过程。记住,我们的目标是找到abc,以及接下来字母表中的其他字母,像下面这种类型的表达式:

c05eq017

找到a是最简单的,它就是小数点左边的数字部分。我们可以将这个first_term(我们方程中的a)和剩余部分定义如下:

x = 1.4142135623730951
output = []
first_term = int(x)
leftover = x - int(x)
output.append(first_term)

就像之前一样,我们将连续的答案存储在一个名为output的列表中。

解决了a后,我们会有一个剩余部分,需要为其找到一个连分数表示:

c05eq018

同样,我们可以取这个数的倒数:

c05eq019

我们的下一个项b将是这个新项的小数点左边的整数部分——在这个例子中是 2。然后我们将重复这个过程:取倒数的小数部分,找到小数点左边的整数部分,以此类推。

在 Python 中,我们可以通过以下方式完成每一轮:

next_term = math.floor(1/leftover)
leftover = 1/leftover - next_term
output.append(next_term)

我们可以将整个过程组合成一个函数,如清单 5-6 所示。

def continued_fraction_decimal(x,error_tolerance,length_tolerance):
    output = []
    first_term = int(x)
    leftover = x - int(x)
    output.append(first_term)
    error = leftover
    while error > error_tolerance and len(output) <length_tolerance:
        next_term = math.floor(1/leftover)
        leftover = 1/leftover - next_term
        output.append(next_term)
        error = abs(get_number(output) - x)
    return(output)

清单 5-6: 从小数找到连分数

在这种情况下,我们像之前一样包含一个length_tolerance项。我们还添加了一个error_tolerance项,这使得我们能够在得到一个“足够接近”精确答案的近似值时退出算法。为了判断我们是否足够接近,我们计算x(我们想要逼近的数字)与我们已经计算出的连分数项的小数值之间的差异。为了获得这个小数值,我们可以使用我们在清单 5-5 中编写的get_number()函数。

我们可以很容易地尝试我们新的函数,方法如下:

print(continued_fraction_decimal(1.4142135623730951,0.00001,100))

我们得到以下输出:

[1, 2, 2, 2, 2, 2, 2, 2]

我们可以将这个连分数写成如下形式(使用近似等号,因为我们的连分数是一个近似值,存在微小误差,并且我们没有时间计算无限序列中每一项):

c05eq020

请注意,在右边的分数中,沿对角线有 2。我们已经找到了另一个无限连分数的前七项,其无限展开式由所有的 2 组成。我们可以将它的连分数展开式写为[1,2,2,2,2,...]。这是√2 的连分数展开式,√2 是另一个不能表示为整数分数的无理数,它的十进制数字没有规律,但却有一个方便且易于记忆的连分数表示。

从分数到根式

如果你对连分数感兴趣,我推荐你阅读关于斯里尼瓦萨·拉马努金的资料,他在短暂的一生中,心灵穿越了无限的边缘,并为我们带回了一些宝贵的瑰宝。除了连分数,拉马努金还对连根数(也称为嵌套根式)感兴趣——例如,以下三个无限嵌套的根式:

c05eq021

c05eq022

c05eq023

结果发现,x = 2(这是一个古老的匿名结果),y = 3(拉马努金证明了这一点),而z不就是黄金比例 phi 吗!我鼓励你尝试在 Python 中构思一个生成嵌套根式表示的方法。平方根显然很有趣,如果我们将它们延伸到无限长,但实际上即使只考虑它们本身,它们也非常有趣。

平方根

我们理所当然地认为手持计算器是理所应当的,但当我们思考它们能做什么时,实际上它们非常令人印象深刻。例如,你可能记得在几何课上学习过,正弦是通过三角形的边长来定义的:角的对边的长度除以斜边的长度。但如果正弦是如此定义的,计算器如何有一个 sin 按钮来瞬间完成这个计算呢?计算器是不是在内部画出一个直角三角形,拿出一把尺子,测量各边的长度,然后再进行除法运算?我们可能会对平方根提出类似的问题:平方根是平方的逆运算,并且没有简单的封闭形式的算术公式,计算器如何使用呢?我想你已经能猜到答案:计算平方根有一个快速计算的算法。

巴比伦算法

假设我们需要找到一个数字x的平方根。像所有数学问题一样,我们可以尝试猜测并检查的策略。假设我们对x的平方根的最佳猜测是某个数字y。我们可以计算y²,如果它等于x,那么我们就完成了(成功实现了稀有的一步“幸运猜测算法”)。

如果我们的猜测 y 不完全是 x 的平方根,那么我们就需要再猜一次,我们希望下一次的猜测能更接近 x 的真实平方根值。巴比伦算法提供了一种系统地改进猜测的方法,直到我们逼近正确答案。这个算法很简单,只需要除法和平均操作:

  1. x 的平方根值做一个猜测 y

  2. 计算 z = x/y

  3. 计算 zy 的平均值。这个平均值就是你新的 y 值,或者说你对 x 的平方根的新猜测值。

  4. 重复步骤 2 和 3,直到 y² – x 足够小。

我们将巴比伦算法描述为四个步骤。相反,一位纯粹的数学家可能会将整个过程用一个方程表示:

c05eq024

在这种情况下,数学家会依赖于通过继续下标描述无限序列的常见数学做法,如:(y[1], y[2], . . . y[n], . . .)。如果你知道这个无限序列的第 n 项,你就可以从上面的方程中得到第 n + 1 项。这个序列会收敛到 c05eqsqrtx,换句话说,y[∞] = c05eqsqrtx。无论你更喜欢四步描述的清晰,方程的优雅简洁,还是我们将要编写的代码的实用性,这都是个人口味的问题,但了解描述算法的所有可能方式是很有帮助的。

如果你考虑这两个简单的情况,你就能理解为什么巴比伦算法有效:

  • c05eq025 所以 c05eq026,所以 c05eq028

    但请注意 c05eq029。所以 z² > x这意味着 c05eq030

  • *c05eq031 所以 c05eq032,所以 c05eq033

    但请注意 c05eq034。所以 z² < x这意味着 c05eq035。*

*我们可以通过去掉一些文本来简洁地表示这些情况:

  • c05eq036

  • c05eq037

如果 y 是正确值的低估值 c05eqsqrtx,那么 z 就是高估值。如果 y 是正确值的高估值 c05eqsqrtx,那么 z 就是低估值。巴比伦算法的步骤 3 要求我们计算一个高估值和一个低估值的平均值。低估值和高估值的平均值会比低估值高、比高估值低,因此它会比 yz 中任何一个较差的猜测更接近真实值。最终,在多轮逐步改进我们的猜测后,我们会得到 c05eqsqrtx 的真实值。

Python 中的平方根

巴比伦算法在 Python 中并不难实现。我们可以定义一个函数,将xy和一个error_tolerance变量作为参数。我们创建一个while循环,反复执行,直到我们的误差足够小。在每次while循环的迭代中,我们计算z,将y的值更新为yz的平均值(就像算法中步骤 2 和步骤 3 描述的那样),并更新我们的误差,误差为y² – x。列表 5-7 展示了这个函数。

def square_root(x,y,error_tolerance):
    our_error = error_tolerance * 2
    while(our_error > error_tolerance):
        z = x/y
        y = (y + z)/2
        our_error = y**2 - x
    return y

列表 5-7: 使用巴比伦算法计算平方根的函数

你可能会注意到,巴比伦算法与梯度上升法和外场算法有一些相似之处。它们都由小而迭代的步骤组成,直到接近最终目标。这是算法的一种常见结构。

我们可以按如下方式检查我们的平方根函数:

print(square_root(5,1,.000000000000001))

我们可以看到数字 2.23606797749979 被打印在控制台中。你可以检查这是否与我们从 Python 标准的math.sqrt()方法中得到的相同数字:

print(math.sqrt(5))

我们得到了完全相同的输出:2.23606797749979。我们成功地编写了自己的平方根计算函数。如果你曾被困在荒岛上,无法下载像math模块这样的 Python 模块,你可以放心地知道,你可以自己编写类似math.sqrt()的函数,并且你可以感谢巴比伦人,他们为我们提供了这个算法。

随机数生成器

到目前为止,我们已将混乱转化为秩序。数学擅长这一点,但在本节中,我们将考虑一个完全相反的目标:在秩序中寻找混乱。换句话说,我们将研究如何算法地创造随机性。

随机数的需求是常态。视频游戏依赖于随机选择的数字,以保持玩家对游戏角色的位置和移动感到惊讶。许多最强大的机器学习方法(包括随机森林和神经网络)在正常运作时都严重依赖随机选择。同样,强大的统计方法(如自助法)也依赖随机性,使得静态数据集更能像混乱的世界一样。公司和研究人员进行 A/B 测试,依赖随机分配被试者到不同条件,以便能够正确比较各条件的效果。这个清单还在继续;在大多数技术领域,对于随机性的需求巨大且持续。

随机性的可能性

随机数需求如此庞大的唯一问题是,我们不确定它们是否真正存在。有些人认为宇宙是决定论的:就像碰撞的台球一样,如果某物在运动,那么它的运动是由另一个完全可以追溯的运动引起的,而这个运动又是由另一个运动引起的,依此类推。如果宇宙像台球桌上的台球一样运作,那么通过了解宇宙中每个粒子的当前状态,我们就能确定宇宙的完整过去和未来。如果真是这样,那么任何事件——赢得彩票、在世界另一端偶遇失联多年的朋友、被陨石击中——实际上并非我们通常所认为的随机事件,而仅仅是宇宙在大约一百亿年前设定的完全预定结果。这意味着没有随机性,我们就像在一台玩家钢琴上听旋律,事情之所以看起来随机,只是因为我们对它们了解得不够多。

我们理解的物理学数学规则与决定论的宇宙是一致的,但它们也与一个非决定论的宇宙相符,在这个宇宙中确实存在随机性,正如一些人所说,神“掷骰子”。它们也与“多重宇宙”情景一致,在这种情景下,每一个事件的可能版本都会发生,但发生在不同的宇宙中,这些宇宙彼此不可接触。如果我们试图为自由意志在宇宙中找到一个位置,那么这些物理学定律的所有解释都会更加复杂。我们接受的数学物理学解释,并不取决于我们的数学理解,而是取决于我们的哲学倾向——任何立场在数学上都是可以接受的。

无论宇宙本身是否包含随机性,你的笔记本电脑是没有的——或者至少它不应该有。计算机是我们完美服从的仆人,它们只做我们明确指令的事情,按照我们指令的时间和方式去做。让计算机运行一个视频游戏、通过随机森林执行机器学习,或者进行一个随机化实验,就是让一台本应是决定性的机器生成某些非决定性的东西:一个随机数。这是一个不可能的请求。

由于计算机无法产生真正的随机性,我们设计了可以提供下一个最佳选择的算法:伪随机性。伪随机数生成算法之所以重要,是因为随机数的重要性。由于计算机上无法产生真正的随机性(而且在整个宇宙中也可能无法实现),伪随机数生成算法必须非常小心地设计,以便它们的输出尽可能地接近真正的随机性。我们判断一个伪随机数生成算法是否真正类似于随机性的标准,取决于我们将要探索的数学定义和理论。

我们从查看一个简单的伪随机数生成算法开始,检查它的输出在多大程度上看起来像随机。

线性同余生成器

最简单的伪随机数生成器**(PRNG)示例之一是线性同余生成器**(LCG)。要实现这个算法,你需要选择三个数字,我们将其称为n[1]、n[2]和n[3]。LCG 从某个自然数(比如 1)开始,然后简单地应用以下公式来得到下一个数字:

c05eq038

这是整个算法,可以说只需要一步。在 Python 中,我们会使用%代替,并且我们可以像清单 5-8 中那样写出完整的 LCG 函数。

def next_random(previous,n1,n2,n3):
    the_next = (previous * n1 + n2) % n3
    return(the_next)

清单 5-8: 一个线性同余生成器

请注意,next_random()函数是确定性的,这意味着如果我们输入相同的内容,我们总是会得到相同的输出。再一次,我们的 PRNG 必须是这样,因为计算机总是确定性的。LCG 并不会生成真正的随机数,而是生成看起来像随机数的数字,或者说是伪随机数

为了评估这个算法生成伪随机数的能力,查看它的许多输出一起可能会有所帮助。我们可以通过编写一个函数,重复调用我们刚才创建的next_random()函数,来编译一个完整的列表,而不是一次得到一个随机数,代码如下:

def list_random(n1,n2,n3):
    output = [1]
    while len(output) <=n3:
        output.append(next_random(output[len(output) - 1],n1,n2,n3))
    return(output)

考虑我们通过运行list_random(29,23,32)得到的列表:

[1, 20, 27, 6, 5, 8, 31, 26, 9, 28, 3, 14, 13, 16, 7, 2, 17, 4, 11, 22, 21, 24, 15, 10, 25, 12, 19, 30, 29, 0, 23, 18, 1]

在这个列表中,很难检测到简单的模式,这正是我们想要的。我们能注意到的一件事是,它只包含介于 0 和 32 之间的数字。我们还可能注意到,这个列表的最后一个元素是 1,与第一个元素相同。如果我们想要更多的随机数,可以通过在最后一个元素 1 上调用next_random()函数来扩展这个列表。然而,请记住,next_random()函数是确定性的。如果我们扩展列表,我们得到的只是列表开头的重复,因为 1 之后的下一个“随机”数总是 20,20 之后的下一个随机数总是 27,依此类推。如果我们继续下去,最终会再次得到数字 1,整个列表会永远重复。我们在它们重复之前得到的独特值的数量称为我们 PRNG 的周期。在这种情况下,我们 LCG 的周期是 32。

评估 PRNG

这种随机数生成方法最终会开始重复是一个潜在的弱点,因为它允许人们预测接下来会发生什么,而这正是我们在寻求随机性的情况下不希望发生的事情。假设我们使用 LCG 来管理一个在线轮盘应用,这个轮盘有 32 个槽位。一位足智多谋的赌徒如果观察轮盘足够长的时间,可能会注意到中奖号码遵循着一个每 32 次旋转就会重复的规律,这样他们就可以通过押注他们现在确定每一轮都会赢的号码,赢得我们所有的钱。

足智多谋的赌徒试图在轮盘上赢钱的这个想法,对于评估任何 PRNG 都很有帮助。如果我们管理的是一个具有真实随机性的轮盘,没有赌徒能够可靠地赢钱。但是,任何微小的弱点或偏离真实随机性的地方,都可以被足够聪明的赌徒利用。即使我们为一个与轮盘无关的目的创建一个 PRNG,我们也可以问自己:“如果我用这个 PRNG 来管理一个轮盘应用,我会不会把所有的钱都输掉?”这个直观的“轮盘测试”是判断任何 PRNG 好坏的合理标准。如果我们从不进行超过 32 次旋转,我们的 LCG 可能会通过轮盘测试,但之后,赌徒可能会注意到输出的重复模式,并开始精准下注。我们 LCG 的短周期导致它未能通过轮盘测试。

因此,确保 PRNG 具有长周期是有帮助的。但在像只有 32 个槽位的轮盘这样的情况下,任何确定性算法的周期都不可能超过 32。因此,我们常常通过 PRNG 是否具有完整周期来评判,而不是它是否具有长周期。考虑我们通过生成list_random(1,2,24)得到的 PRNG:

[1, 3, 5, 7, 9, 11, 13, 15, 17, 19, 21, 23, 1, 3, 5, 7, 9, 11, 13, 15, 17, 19, 21, 23, 1]

在这种情况下,周期是 12,这对于非常简单的用途来说可能已经足够长了,但它不是一个完整的周期,因为它没有涵盖其范围内的所有可能值。同样,足智多谋的赌徒可能会注意到,轮盘永远不会选择偶数(更不用说所选的奇数遵循的简单模式了),从而通过增加他们的投注来增加他们的收益,而我们则因此受损。

与完整周期的概念相关的是均匀分布的概念,我们的意思是,PRNG 范围内的每个数字被输出的可能性是相等的。如果我们运行list_random(1,18,36),我们得到:

[1, 19, 1, 19, 1, 19, 1, 19, 1, 19, 1, 19, 1, 19, 1, 19, 1, 19, 1, 19, 1, 19, 1, 19, 1, 19, 1, 19, 1, 19, 1, 19, 1, 19, 1, 19, 1]

在这里,1 和 19 每个被 PRNG 输出的概率是 50%,而其他数字的输出概率是 0%。一个轮盘玩家将非常容易利用这个不均匀的 PRNG。相比之下,在list_random(29,23,32)的情况下,我们发现每个数字被输出的概率大约是 3.1%。

我们可以看到,这些评判伪随机数生成器(PRNG)数学标准之间是有一定联系的:周期过短或不完整可能导致分布不均匀。从更实际的角度来看,这些数学属性之所以重要,是因为它们导致我们的轮盘应用程序亏损。更一般地说,伪随机数生成器的唯一重要测试就是是否可以在其中检测到模式。

不幸的是,检测模式的能力在数学或科学语言中很难简洁地描述。因此,我们寻找长周期、完整周期和均匀分布作为给我们提供关于模式检测线索的标志。但当然,它们并不是唯一能帮助我们检测模式的线索。考虑一下 LCG(线性同余生成器),它由list_random(1,1,37)表示。它输出以下列表:

[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 0, 1]

这个生成器有一个长周期(37),一个完整周期(37),并且有均匀分布(每个数字出现的概率是 1/37)。然而,我们仍然可以在其中检测到模式(数字每轮增加 1,直到达到 36,然后从 0 重新开始)。它通过了我们设计的数学测试,但它显然没有通过轮盘测试。

随机性 Diehard 测试

没有单一的灵丹妙药测试能够指示一个伪随机数生成器(PRNG)是否存在可被利用的模式。研究人员设计了许多创造性的测试来评估一组随机数在多大程度上抵抗模式检测(或者换句话说,能否通过轮盘测试)。其中一组这样的测试被称为Diehard测试。Diehard 测试共有 12 个,每个测试以不同的方式评估一组随机数。通过所有 Diehard 测试的数字集被认为与真实随机性非常相似。Diehard 测试中的一个,叫做重叠和测试,对整个随机数列表进行处理,计算列表中连续数字部分的和。所有这些和的集合应当遵循数学上通常所称的钟形曲线模式。我们可以在 Python 中实现一个生成重叠和列表的函数,如下所示:

def overlapping_sums(the_list,sum_length):
    length_of_list = len(the_list)
    the_list.extend(the_list)
    output = []
    for n in range(0,length_of_list):
        output.append(sum(the_list[n:(n + sum_length)]))
    return(output)

我们可以像这样在一个新的随机列表上运行这个测试:

import matplotlib.pyplot as plt
overlap = overlapping_sums(list_random(211111,111112,300007),12)
plt.hist(overlap, 20, facecolor = 'blue', alpha = 0.5)
plt.title('Results of the Overlapping Sums Test')
plt.xlabel('Sum of Elements of Overlapping Consecutive Sections of List')
plt.ylabel('Frequency of Sum')
plt.show()

我们通过运行list_random(211111,111112,300007)创建了一个新的随机列表。这个新的随机列表足够长,可以使得重叠和测试表现得很好。此代码的输出是一个直方图,记录了观察到的和的频率。如果该列表类似于真正的随机集合,我们期望一些和较高,一些和较低,但大多数和应该接近可能值范围的中间。这正是我们在图表输出中看到的情况(图 5-2)。

Figure_5-2

图 5-2: LCG 的重叠和测试结果

如果你眯起眼睛,你会看到这个图像像一只钟。记住,Diehard 重叠求和测试说,如果我们的列表与钟形曲线高度相似,它就会通过,这是一个在数学上非常重要的曲线(见图 5-3)。

图 5-3

图 5-3: 钟形曲线,或高斯正态曲线(来源:维基共享资源)

像黄金比例一样,钟形曲线出现在数学和宇宙中的许多有时令人惊讶的地方。在这种情况下,我们将重叠求和测试结果与钟形曲线的高度相似解释为我们的伪随机数生成器(PRNG)类似于真正的随机性。

随机性深层数学的知识可以帮助你设计随机数生成器。然而,你仅凭常识,了解如何在轮盘赌中获胜,几乎也能做得很好。

线性反馈移位寄存器

线性同余生成器(LCGs)易于实现,但对于许多伪随机数生成器应用来说并不够复杂;一个精明的轮盘赌玩家可以很快破解 LCG。让我们看看一种更先进且可靠的算法类型,称为线性反馈移位寄存器**(LFSRs),它可以作为伪随机数生成器算法高级研究的起点。

线性反馈移位寄存器(LFSRs)是在考虑计算机架构的情况下设计的。在计算机的最低层次,数据以一系列 0 和 1 的形式存储,这些 0 和 1 被称为比特。我们可以如图 5-4 所示,表示一个可能的 10 比特字符串。

图 5-4

图 5-4: 10 比特字符串

从这些比特开始,我们可以通过一个简单的 LFSR 算法继续。我们首先计算一组比特的简单和——例如,第 4 位、第 6 位、第 8 位和第 10 位的和(我们也可以选择其他子集)。在这种情况下,这个和是 3。我们的计算机架构只能存储 0 和 1,所以我们将和对 2 取模,最终得到 1 作为我们的最终和。然后,我们删除最右边的比特,并将其余所有比特向右移动一位(见图 5-5)。

图 5-5

图 5-5: 删除并移位后的比特

由于我们删除了一个比特并将所有比特向右移动,我们会在应该插入新比特的位置上留下一个空位。我们在这里插入的比特就是我们之前计算的和。插入之后,我们得到了新的比特状态(见图 5-6)。

图 5-6

图 5-6: 用选定比特和替换后的比特

我们将从右侧删除的比特作为算法的输出,即这个算法应该生成的伪随机数。现在我们有了一组新的 10 个有序比特,我们可以再次运行算法,像之前一样得到一个新的伪随机比特。我们可以根据需要重复这个过程。

在 Python 中,我们可以相对简单地实现反馈移位寄存器。我们不会直接覆盖硬盘上的单个位,而是创建一个类似下面的位列表:

bits = [1,1,1]

我们可以用一行代码定义指定位置的位的总和。我们将其存储在名为xor_result的变量中,因为对和取模 2 也叫做异或XOR 操作。如果你学过形式逻辑,可能会遇到 XOR,它有一个逻辑定义和一个等价的数学定义;在这里我们将使用数学定义。由于我们处理的是一个短位串,我们不对第 4、6、8 和 10 位求和(因为这些位不存在),而是对第 2 位和第 3 位求和:

xor_result = (bits[1] + bits[2]) % 2

然后,我们可以轻松地用 Python 的pop()函数取出位列表中的最右侧元素,将结果存储在名为output的变量中:

output = bits.pop()

然后,我们可以使用insert()函数插入我们的和,指定位置为 0,因为我们希望它位于列表的左侧:

bits.insert(0,xor_result)

现在让我们把这些内容汇总到一个函数中,该函数将返回两个输出:一个伪随机位和bits序列的新状态(Listing 5-9)。

def feedback_shift(bits):
    xor_result = (bits[1] + bits[2]) % 2
    output = bits.pop()
    bits.insert(0,xor_result)
    return(bits,output)

Listing 5-9: 实现 LFSR 的函数,完成我们本节的目标

就像我们在 LCG 中做的那样,我们可以创建一个函数,生成我们所有输出位的列表:

def feedback_shift_list(bits_this):
    bits_output = [bits_this.copy()]
    random_output = []
    bits_next = bits_this.copy()
 while(len(bits_output) < 2**len(bits_this)):
        bits_next,next = feedback_shift(bits_next)
        bits_output.append(bits_next.copy())
        random_output.append(next)
    return(bits_output,random_output)

在这种情况下,我们运行while循环直到预期序列重复。由于我们的位列表有 2³ = 8 种可能的状态,我们可以预计周期最多为 8。实际上,LFSR 通常不能输出一整套零值,因此实际上我们预计周期最多为 2³ – 1 = 7。我们可以运行以下代码来查找所有可能的输出并检查周期:

bitslist = feedback_shift_list([1,1,1])[0]

果然,我们存储在bitslist中的输出是:

[[1, 1, 1], [0, 1, 1], [0, 0, 1], [1, 0, 0], [0, 1, 0], [1, 0, 1], [1, 1, 0], [1, 1, 1]]

我们可以看到我们的 LFSR 输出了所有七种可能的非零位串。我们拥有一个周期完整的 LFSR,同时也有一个显示输出均匀分布的 LFSR。如果我们使用更多的输入位,最大可能的周期会呈指数增长:使用 10 个位时,最大可能的周期是 2¹⁰– 1 = 1023,使用 20 个位时,最大可能的周期是 2²⁰ – 1=1,048,575。

我们可以通过以下方式检查我们简单的 LFSR 生成的伪随机位列表:

pseudorandom_bits = feedback_shift_list([1,1,1])[1]

我们存储在pseudorandom_bits中的输出看起来相当随机,考虑到我们的 LFSR 及其输入有多简单:

[1, 1, 1, 0, 0, 1, 0]

LFSR(线性反馈移位寄存器)用于生成伪随机数,在多种应用中,包括白噪声。我们在此介绍它们,以便让你体验高级伪随机数生成器(PRNG)的概念。目前在实践中最广泛使用的 PRNG 是Mersenne Twister,它是经过修改的、广义的反馈移位寄存器——本质上是一个比这里展示的 LFSR 更为复杂的版本。如果你继续深入学习 PRNG,你会遇到大量的复杂性和高级数学,但这一切都将基于这里介绍的思想:确定性的数学公式,这些公式能够通过严格的数学测试显示出类随机性。

总结

数学和算法总是密切相关的。你越深入一个领域,就越能为掌握另一个领域的高级概念做好准备。数学可能看起来深奥且不切实际,但它是一个长远的过程:数学的理论进展有时需要几百年后才能转化为实际技术。在本章中,我们讨论了连分数以及生成任何数字的连分数表示的算法。我们还讨论了平方根,并研究了手持计算器用来计算平方根的算法。最后,我们讨论了随机性,包括生成伪随机数的两个算法,以及我们可以用来评估声称是随机的列表的数学原理。

在下一章中,我们将讨论优化,包括一种你可以用来环游世界或锻造剑的强大方法。*

第六章:高级优化

你已经了解了优化。在第三章中,我们介绍了梯度上升/下降方法,它帮助我们“爬坡”以找到最大值或最小值。任何优化问题都可以看作是爬坡问题:我们努力从大量可能性中找到最佳的结果。梯度上升工具简单且优雅,但它有一个致命缺点:它可能会引导我们找到一个局部最优解,而非全局最优解。在爬坡的比喻中,它可能会把我们带到一个小山丘的顶部,而事实上,稍微下坡一点就能让我们开始攀登真正想要登上的大山。解决这个问题是高级优化中的最困难也是最关键的方面。

在本章中,我们将通过一个案例研究讨论一种更先进的优化算法。我们将考虑旅行推销员问题,以及其几种可能的解决方案及其缺点。最后,我们将介绍模拟退火算法,这是一种克服这些缺点的高级优化算法,能够进行全局优化,而不仅仅是局部优化。

推销员的生活

旅行推销员问题(TSP)是计算机科学和组合学中极为著名的问题。假设一个旅行推销员希望拜访多个城市来兜售商品。由于多种原因——失去的收入机会、汽车油费、长时间旅行后的头痛(图 6-1)——城市之间的旅行成本很高。

figure_6_1

图 6-1: 那不勒斯的旅行推销员

TSP 要求我们确定城市之间的旅行顺序,以最小化旅行成本。像所有科学领域中的最佳问题一样,陈述起来很容易,但解决起来极其困难。

问题设置

让我们启动 Python 并开始探索。首先,我们将随机生成一张地图供我们的推销员穿越。我们从选择一个数字N开始,这个数字代表我们想要在地图上设置的城市数量。假设N = 40。接着我们将选择 40 组坐标:每个城市一个x值和一个y值。我们将使用numpy模块来进行随机选择:

import numpy as np
random_seed = 1729
np.random.seed(random_seed)
N = 40
x = np.random.rand(N)
y = np.random.rand(N)

在这个代码片段中,我们使用了numpy模块的random.seed()方法。这个方法接受你传入的任何数字,并将该数字作为“种子”来初始化其伪随机数生成算法(更多关于伪随机数生成的内容,请参见第五章)。这意味着如果你使用与我们在前面的代码片段中使用的相同的种子,你将生成与我们在这里生成的随机数相同,这样就更容易跟随代码,你也将得到与我们相同的图表和结果。

接下来,我们将把x值和y值配对在一起,创建一个cities列表,包含每个我们随机生成的 40 个城市位置的坐标对。

points = zip(x,y)
cities = list(points)

如果你在 Python 控制台中运行print(cities),你可以看到一个包含随机生成点的列表。这些点中的每一个代表一个城市。我们不会特意给任何城市命名,而是可以将第一个城市称为cities[0],第二个城市称为cities[1],依此类推。

我们已经拥有了解决 TSP 问题所需的一切。我们首先提出的解法将是按cities列表中出现的顺序依次访问所有城市。我们可以定义一个itinerary变量,将这个顺序以列表形式存储:

itinerary = list(range(0,N))

这仅仅是另一种写法:

itinerary = [0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29, \30,31,32,33,34,35,36,37,38,39]

我们行程中的数字顺序就是我们建议访问城市的顺序:首先是城市 0,然后是城市 1,依此类推。

接下来,我们需要判断这个行程并决定它是否是一个好的,或者至少是可接受的 TSP(旅行商问题)解法。记住,TSP 的核心目的是最小化销售员在各城市之间旅行时所面临的成本。那么,旅行的成本是什么呢?我们可以指定任何我们想要的成本函数:也许某些道路的交通比其他道路更拥堵,也许有些河流很难跨越,或者也许向北旅行比向东旅行更困难,反之亦然。但让我们从简单开始:假设旅行 1 个单位的距离需要 1 美元,无论方向如何,也无论我们在两个城市之间旅行。由于我们的算法在不论是英里、公里还是光年之间旅行时都能以相同的方式工作,本章中我们不会指定任何距离单位。在这种情况下,最小化成本就等同于最小化所旅行的距离。

为了确定特定行程所需的距离,我们需要定义两个新函数。首先,我们需要一个函数来生成一组连接所有点的线。接着,我们需要将这些线所代表的距离加总起来。我们可以从定义一个空列表开始,用来存储关于我们线路的信息:

lines = []

接下来,我们可以遍历行程中的每个城市,在每一步将一条新线添加到lines集合中,连接当前城市和下一个城市。

for j in range(0,len(itinerary) - 1):
    lines.append([cities[itinerary[j]],cities[itinerary[j + 1]]])

如果你运行print(lines),你可以看到我们在 Python 中如何存储关于线路的信息。每一条线作为一个列表存储,其中包含两座城市的坐标。例如,你可以通过运行print(lines[0])来查看第一条线,它会显示以下输出:

[(0.21215859519373315, 0.1421890509660515), (0.25901824052776146, 0.4415438502354807)]

我们可以将这些元素整合成一个名为genlines的函数("generate lines"的缩写),它接受citiesitinerary作为参数,并返回一个集合,这个集合包含按行程指定顺序连接每座城市的线:

def genlines(cities,itinerary):
    lines = []
    for j in range(0,len(itinerary) - 1):
        lines.append([cities[itinerary[j]],cities[itinerary[j + 1]]])
    return(lines)

现在我们有了一个生成城市间连接线的方式,可以创建一个函数来计算沿这些线的总距离。它将从将总距离定义为 0 开始,然后对 lines 列表中的每个元素,都会将该线的长度加到 distance 变量中。我们将使用勾股定理来计算这些线的长度。

import math
def howfar(lines):
    distance = 0
    for j in range(0,len(lines)):
        distance += math.sqrt(abs(lines[j][1][0] - lines[j][0][0])**2 + \
        abs(lines[j][1][1] - lines[j][0][1])**2)
    return(distance)

这个函数以一组线为输入,输出所有线段长度的总和。现在我们有了这些函数,我们可以将它们与我们的行程安排一起调用,以确定销售员需要行进的总距离:

totaldistance = howfar(genlines(cities,itinerary))
print(totaldistance)

当我运行这段代码时,我发现 totaldistance 大约是 16.81。如果你使用相同的随机种子,应该得到相同的结果。如果使用不同的种子或城市集,结果会略有不同。

为了更好地理解这个结果,帮助我们绘制行程安排会更有意义。为此,我们可以创建一个 plotitinerary() 函数:

import matplotlib.collections as mc
import matplotlib.pylab as pl
def plotitinerary(cities,itin,plottitle,thename):
    lc = mc.LineCollection(genlines(cities,itin), linewidths=2)
    fig, ax = pl.subplots()
    ax.add_collection(lc)
    ax.autoscale()
    ax.margins(0.1)
    pl.scatter(x, y)
    pl.title(plottitle)
    pl.xlabel('X Coordinate')
    pl.ylabel('Y Coordinate')
    pl.savefig(str(thename) + '.png')
    pl.close()

plotitinerary() 函数接受 citiesitinplottitlethename 作为参数,其中 cities 是我们城市的列表,itin 是我们想要绘制的行程安排,plottitle 是显示在图表顶部的标题,thename 是我们给 png 输出图像命名的名称。该函数使用 pylab 模块进行绘图,并使用 matplotlib 的 collections 模块来创建一组线条。然后它绘制了行程安排的点和我们连接这些点所创建的线条。

如果你使用 plotitinerary(cities,itinerary,'TSP - Random Itinerary','figure2') 来绘制行程安排,你将生成如图 6-2 所示的图表。

figure_6-2

图 6-2: 访问按随机顺序生成的城市所得到的行程安排

也许你仅凭 图 6-2 就能看出,我们还没有找到最优的旅行商问题解决方案。我们给可怜的销售员安排的行程让他多次横穿地图,去到一个非常远的城市,而显然他可以通过在途中停靠其他城市来做得更好。本章接下来的目标是使用算法找到一个最短的行程安排。

我们将讨论的第一个潜在解决方案是最简单的,但它的性能最差。之后,我们将讨论一些解决方案,它们通过增加一些复杂性来换取显著的性能提升。

智力与力量的对决

你可能会想到列出所有可能的行程安排,以便连接我们的城市并逐一评估它们,看看哪一个最优。如果我们要访问三个城市,以下是它们可以被访问的所有顺序的详尽列表:

  • 1, 2, 3

  • 1, 3, 2

  • 2, 3, 1

  • 2, 1, 3

  • 3, 1, 2

  • 3, 2, 1

评估哪种方法最优不应该花费太长时间,只需要逐一测量每个长度并比较所得结果。这被称为暴力破解解决方案。它指的不是物理上的力量,而是通过使用我们 CPU 的“蛮力”来检查一个详尽的列表,而不是依赖于算法设计者的智慧,后者可以找到一个更优雅、运行更快的方法。

有时候,暴力破解的解决方案恰恰是最合适的方法。它们通常容易编写代码,并且运行可靠。它们的主要缺点是运行时间,通常没有比算法解决方案更好,甚至通常更差。

在 TSP(旅行商问题)中,所需的运行时间增长得太快,以至于对于超过大约 20 座城市的情况,暴力破解解决方案就不再实际。为了说明这一点,考虑以下论点:如果我们处理的是四座城市,并试图找到所有可能的访问顺序,需要检查多少种可能的行程:

  1. 当我们选择第一座城市时,我们有四个选择,因为总共有四座城市,而我们还没有访问过其中任何一座。因此,选择第一座城市的总方式数是 4。

  2. 当我们选择第二座城市时,我们有三个选择,因为总共有四座城市,而我们已经访问了其中一座。因此,选择前两座城市的总方式数是 4 × 3 = 12。

  3. 当我们选择第三座城市时,我们有两个选择,因为总共有四座城市,而我们已经访问了其中两座。因此,选择前三座城市的总方式数是 4 × 3 × 2 = 24。

  4. 当我们选择第四座城市时,只有一个选择,因为总共有四座城市,而我们已经访问了其中三座。因此,选择所有四座城市的总方式数是 4 × 3 × 2 × 1 = 24。

你应该注意到这里的规律:当我们有N座城市要访问时,所有可能的行程总数是 N × (N–1) × (N–2) × . . . × 3 × 2 × 1,也就是N!(“N阶乘”)。阶乘函数增长得非常快:虽然 3!仅为 6(我们甚至可以不用电脑就能暴力破解),但我们发现 10!已经超过 300 万(在现代计算机上很容易暴力破解),而 18!超过 6 千兆,25!超过 15 千兆,35!及以上则开始接近今天技术下暴力破解的极限,考虑到宇宙的寿命预期。

这种现象称为组合爆炸。组合爆炸没有严格的数学定义,但它指的是像这样的情况:看似小的集合,在考虑组合和排列时,会导致远远超出原集合大小的选择数量,且超出我们使用暴力破解可以处理的范围。

例如,连接罗德岛 90 个邮政编码的所有可能行程数量,比宇宙中估算的原子数量要大得多,尽管罗德岛比宇宙要小得多。类似地,尽管棋盘比罗德岛还小,但一个棋盘能够进行的可能棋局数量也比宇宙中原子数量要多。这些看似矛盾的情况——在有限的事物中迸发出几乎无限的可能性——使得良好的算法设计变得更加重要,因为暴力搜索永远无法解决最难的问题。组合爆炸意味着我们必须考虑解决 TSP(旅行商问题)的算法方案,因为全世界的 CPU 都不足以计算暴力破解的解决方案。

最近邻算法

接下来,我们将考虑一种简单直观的方法,称为最近邻算法。我们从列表中的第一个城市开始。然后,我们简单地找到与第一个城市最接近的未访问城市,并将该城市作为第二个城市访问。在每一步,我们只需要查看当前位置,并选择最近的未访问城市作为下一个行程城市。虽然这种方法可能不会最小化总的旅行距离,但它在每一步都会最小化旅行距离。请注意,我们不是像暴力搜索那样查看所有可能的行程,而是每次只找到最近的邻居。这使得即使对于非常大的N,我们的运行时间也非常快。

实现最近邻搜索

我们将从编写一个可以找到任何给定城市最近邻的函数开始。假设我们有一个名为point的点和一个名为cities的城市列表。pointcities中第j个城市之间的距离由以下毕达哥拉斯式公式给出:

point = [0.5,0.5]
j = 10
distance = math.sqrt((point[0] - cities[j][0])**2 + (point[1] - cities[j][1])**2)

如果我们想找出cities中哪个元素最接近我们的point(即point的最近邻城市),我们需要遍历cities中的每个元素,检查point与每个城市之间的距离,如清单 6-1 所示。

def findnearest(cities,idx,nnitinerary):
    point = cities[idx]
    mindistance = float('inf')
    minidx = - 1
    for j in range(0,len(cities)):
        distance = math.sqrt((point[0] - cities[j][0])**2 + (point[1] - cities[j][1])**2)
        if distance < mindistance and distance > 0 and j not in nnitinerary:
            mindistance = distance
            minidx = j
    return(minidx)

清单 6-1: findnearest()函数,找到给定城市的最近邻城市

在我们拥有了findnearest()函数之后,我们就准备好实现最近邻算法了。我们的目标是创建一个名为nnitinerary的行程。我们将从假设cities中的第一个城市是我们的销售员出发的地方开始:

nnitinerary = [0]

如果我们的行程需要有N个城市,我们的目标是遍历从 0 到N–1 之间的所有数字,找到每个数字对应的最近邻城市(即我们最近访问的城市的邻近城市),并将这个城市添加到我们的行程中。我们将通过清单 6-2 中的donn()函数(即“do nearest neighbor”的缩写)来实现这一点。它从cities中的第一个城市开始,在每一步将最接近最近添加的城市的城市添加到行程中,直到所有城市都被添加到行程中。

def donn(cities,N):
    nnitinerary = [0]
    for j in range(0,N - 1):
        next = findnearest(cities,nnitinerary[len(nnitinerary) - 1],nnitinerary)
        nnitinerary.append(next)
    return(nnitinerary)

列表 6-2: 一个函数,依次找到每个城市的最近邻并返回完整的行程

我们已经拥有检查最近邻算法性能所需的所有内容。首先,我们可以绘制最近邻行程:

plotitinerary(cities,donn(cities,N),'TSP - Nearest Neighbor','figure3')

图 6-3 显示了我们得到的结果。

figure_6_3

图 6-3: 最近邻算法生成的行程

我们还可以检查使用这个新行程时,销售员需要走多远:

print(howfar(genlines(cities,donn(cities,N))))

在这种情况下,我们发现,尽管销售员沿着随机路径行驶了 16.81 的距离,但我们的算法将距离减少到了 6.29。记住,我们没有使用单位,因此我们可以将其解释为 6.29 英里(或公里或秒差距)。重要的是,这个距离小于我们从随机行程中找到的 16.81 英里、公里或秒差距。这是一个显著的改进,完全来自于一个非常简单、直观的算法。在图 6-3 中,性能的提升非常明显;往返地图两端的行程减少了,更多的是在彼此接近的城市之间的短途旅行。

检查进一步改进

如果你仔细看图 6-2 或图 6-3,你可能能想象出一些可以改进的地方。你甚至可以尝试这些改进,并使用我们的howfar()函数检查它们是否有效。例如,也许你看了我们的初始随机行程:

initial_itinerary = [0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26, \27,28,29,30,31,32,33,34,35,36,37,38,39]

你认为通过交换销售员访问城市 6 和城市 30 的顺序,可以改进行程。你可以通过定义这个新行程并交换这两个城市的顺序来进行交换(用粗体显示):

new_itinerary = [0,1,2,3,4,5**,30,**7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27, \28,29**,6,**31,32,33,34,35,36,37,38,39]

然后我们可以做一个简单的比较,检查我们执行的交换是否减少了总距离:

print(howfar(genlines(cities,initial_itinerary)))
print(howfar(genlines(cities,new_itinerary)))

如果new_itineraryinitial_itinerary更好,我们可能想丢弃initial_itinerary并保留新的行程。在这种情况下,我们发现新行程的总距离大约为 16.79,比我们最初的行程略有改进。在找到一个小的改进后,我们可以再次执行相同的过程:选择两个城市,交换它们在行程中的位置,然后检查距离是否减少。我们可以无限期地继续这个过程,每一步都有合理的机会找到减少旅行距离的方法。在重复这个过程多次后,我们可以(希望)得到一个总距离非常低的行程。

编写一个可以自动执行这个交换和检查过程的函数非常简单(列表 6-3):

def perturb(cities,itinerary):
    neighborids1 = math.floor(np.random.rand() * (len(itinerary)))
    neighborids2 = math.floor(np.random.rand() * (len(itinerary)))

    itinerary2 = itinerary.copy()

    itinerary2[neighborids1] = itinerary[neighborids2]
    itinerary2[neighborids2] = itinerary[neighborids1]

    distance1 = howfar(genlines(cities,itinerary))
    distance2 = howfar(genlines(cities,itinerary2))

    itinerarytoreturn = itinerary.copy()

    if(distance1 > distance2):
        itinerarytoreturn = itinerary2.copy()

    return(itinerarytoreturn.copy())

列表 6-3: 一个函数,对行程做出小的改变,将其与原始行程进行比较,并返回较短的行程

perturb()函数接受任意城市列表和行程作为参数。然后,它定义了两个变量:neighborids1neighborids2,这两个变量是从 0 到行程长度之间随机选取的整数。接下来,它创建一个新的行程itinerary2,该行程与原始行程相同,唯一不同的是,neighborids1neighborids2的城市交换了位置。然后,它计算distance1,即原始行程的总距离,以及distance2,即itinerary2的总距离。如果distance2小于distance1,则返回新的行程(包含交换)。否则,返回原始行程。因此,我们将行程传递给该函数,它总是返回一个与我们传入的行程一样好或者更好的行程。我们称这个函数为perturb(),因为它通过扰动给定的行程来尝试改进它。

现在我们有了perturb()函数,让我们在一个随机行程上反复调用它。实际上,我们不仅仅调用一次,而是调用 200 万次,尝试获得尽可能低的旅行距离:

itinerary = [0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29, \30,31,32,33,34,35,36,37,38,39]

np.random.seed(random_seed)
itinerary_ps = itinerary.copy()
for n in range(0,len(itinerary) * 50000):
    itinerary_ps = perturb(cities,itinerary_ps)

print(howfar(genlines(cities,itinerary_ps)))

我们刚刚实现的东西可以称为扰动搜索算法。它通过成千上万的可能行程进行搜索,希望找到一个好的行程,就像暴力搜索一样。然而,它更好,因为暴力搜索会毫无差别地考虑每一个可能的行程,而这个算法是一个引导搜索,它考虑的是一组旅行距离逐渐减少的行程,因此它应该比暴力搜索更快找到一个好的解决方案。我们只需要对这个扰动搜索算法做一些小的改进,就可以实现模拟退火算法,这是本章的重点算法。

在我们进入模拟退火的代码之前,我们先来回顾一下它相对于我们目前讨论的算法提供了什么样的改进。我们还想介绍一个温度函数,允许我们在 Python 中实现模拟退火的特性。

贪婪算法的算法

到目前为止,我们考虑过的最近邻和扰动搜索算法属于一种叫做贪婪算法的类别。贪婪算法按照步骤进行,每一步都做出局部最优的选择,但一旦考虑所有步骤,可能并不是全局最优的。在我们的最近邻算法中,在每一步,我们都寻找距离当前步骤最近的城市,而不考虑其他城市。访问最近的城市是局部最优的,因为它最小化了我们当前步骤的行程距离。然而,由于它没有同时考虑所有城市,可能并不是全局最优的——它可能会引导我们沿着地图上的奇怪路径行进,最终使得整体行程变得非常长,并且对推销员来说非常昂贵,尽管每个单独的步骤看起来都很合理。

“贪心”指的是这种局部优化决策过程的目光短浅性。我们可以通过尝试在复杂的山地地形中寻找最高点的问题来理解这些贪心的优化方法,其中“高”点类似于更好、更优化的解决方案(TSP 中的短距离),而“低”点则类似于更差、次优的解决方案(TSP 中的长距离)。在山地中寻找最高点的贪心方法是始终向上走,但这可能会让我们只到达一个小山坡的顶端,而不是最高山峰的顶端。有时候,最好是下到山坡的底部,从而开始更重要的登顶之旅。因为贪心算法只关注局部优化,它们永远不会允许我们下行,并可能让我们陷入局部极值。这正是第三章讨论的问题。

在了解这一点后,我们终于准备好引入一个可以帮助我们解决由贪心算法引起的局部优化问题的想法。这个想法就是放弃始终上升的单纯承诺。在旅行商问题(TSP)中,我们有时可能需要扰动到更差的路径,这样我们就能在后期找到最佳路径,就像我们为了最终爬上山顶,有时需要下到山脚一样。换句话说,为了最终做得更好,我们一开始必须做得更差。

引入温度函数

以做得更差为初衷,最终实现做得更好,这是一个微妙的过程。如果我们在愿意做得更差的态度上过于急功近,我们可能会在每一步都往下走,最终陷入低谷,而不是达到高峰。我们需要找到一种方法,只在少数情况下、偶尔地、并且在学习如何最终做得更好的背景下做得更差。

再次想象一下,我们身处复杂的山地地形中。我们在下午晚些时候开始,知道自己有两个小时的时间去找到整个地形的最高点。假设我们没有手表来跟踪时间,但我们知道傍晚空气逐渐变凉,因此我们决定利用温度来大致判断自己剩余的时间,以便找到最高点。

在我们开始的两个小时里,当外面相对较热时,我们自然会更开放于创造性探索。因为我们还有很长的时间,所以稍微向下走一点以更好地了解地形、看到一些新地方,并不是很大的风险。但随着气温变凉,我们接近两个小时的结束时,我们将变得不那么开放于广泛的探索。我们会更加专注于改进,减少了向下走的意愿。

花点时间思考一下这个策略,以及为什么它是到达最高点的最佳方式。我们已经讨论过为什么我们偶尔要向下走:这样我们可以避免“局部最优解”,或者说在巨大山脉旁边的小山顶。但是我们应该在什么时候下去呢?考虑我们两小时时间段的最后 10 秒。无论我们身处何处,我们应该尽可能直接向上走。因为即使我们发现了一个有前景的山峰,最后 10 秒也没有时间去爬它;如果我们在最后 10 秒时犯错并滑下来,也没有时间去纠正它。因此,最后 10 秒应该是我们直接向上走,而不考虑下去的时刻。

相比之下,考虑我们两小时时间段的前 10 秒。在那段时间里,不必急于直接向上走。一开始,我们可以通过稍微向下探索来学到最多的东西。如果我们在前 10 秒犯错,之后还有充足的时间来纠正。我们会有足够的时间利用我们所学到的任何东西,或者发现的任何山峰。在前 10 秒,我们应该最开放地考虑向下走,而对直接向上走的热情最小。

你可以通过思考相同的想法来理解剩余的两小时。如果我们考虑结束前 10 分钟的时间,我们将会有一个比结束前 10 秒更为温和的心态。由于结束临近,我们会被激励直接向上。但 10 分钟比 10 秒要长,因此我们对稍微向下探索还是有一定的开放性,以防我们发现有前景的东西。依此类推,开始后的 10 分钟将使我们形成一个比开始后 10 秒更温和的心态。整个两小时的时间段会呈现出一种意图的梯度:一开始愿意偶尔向下探索,随后逐渐增强的仅向上的热情。

为了在 Python 中模拟这个场景,我们可以定义一个函数。我们从一个较热的温度和愿意探索并向下走的心态开始,最终以较冷的温度和不愿意向下走的心态结束。我们的温度函数相对简单。它以 t 作为参数,其中 t 代表时间:

temperature = lambda t: 1/(t + 1)

你可以通过在 Python 控制台中运行以下代码来查看温度函数的简单图形。该代码首先导入matplotlib功能,然后定义变量ts,它包含从 1 到 100 的t值范围。最后,它绘制了与每个t值相关的温度图形。同样,我们不关心单位或确切的数值,因为这是一个假设情境,目的是展示降温函数的大致形状。因此,我们用 1 表示最高温度,用 0 表示最低温度,用 0 表示最短时间,用 99 表示最长时间,而不指定单位。

import matplotlib.pyplot as plt
ts = list(range(0,100))
plt.plot(ts, [temperature(t) for t in ts])
plt.title('The Temperature Function')
plt.xlabel('Time')
plt.ylabel('Temperature')
plt.show()

该图看起来像是图 6-4。

figure_6-4

图 6-4: 温度随着时间的推移而下降

该图显示了我们在假设优化过程中将经历的温度。温度作为一个调度,决定了我们的优化过程:我们愿意接受更差解的程度与当前的温度成正比。

现在我们已经具备了完全实现模拟退火所需的所有要素。去吧——在你过度思考之前,直接动手实施吧。

模拟退火

让我们将所有的想法结合起来:温度函数、丘陵地形中的搜索问题、扰动搜索算法和旅行商问题(TSP)。在 TSP 的背景下,我们所处的复杂丘陵地形包含了所有可能的 TSP 解。我们可以想象,更好的解对应地形中的更高点,而更差的解对应地形中的更低点。当我们应用perturb()函数时,我们实际上是在移动到地形中的另一个点,希望这个点尽可能高。

我们将使用温度函数来指导我们在这个地形中的探索。刚开始时,我们的高温度将促使我们更开放地选择更差的路线。接近过程结束时,我们将不那么愿意选择更差的路线,而更专注于“贪婪”优化。

我们将实现的算法,模拟退火,是扰动搜索算法的一种修改形式。其本质的区别在于,在模拟退火中,我们有时会接受使行程更长的路线改变,因为这样可以避免局部最优化问题。我们接受更差路线的意愿取决于当前的温度。

让我们用这个最新的变化来修改我们的perturb()函数。我们将添加一个新的参数:time,我们需要将其传递给perturb()time参数衡量我们在模拟退火过程中的进度;第一次调用perturb()时,时间从 1 开始,之后每次调用perturb()时,时间依次为 2、3,依此类推。我们将添加一行来指定温度函数,并添加一行来选择一个随机数。如果随机数小于温度值,那么我们愿意接受一个较差的路径;如果随机数大于温度值,那么我们就不愿意接受较差的路径。这样,我们偶尔会接受较差的路径,但不是常态,并且随着时间的推移,随着温度的降低,我们接受较差路径的可能性会减少。我们将这个新函数命名为perturb_sa1(),其中sa是模拟退火(simulated annealing)的缩写。清单 6-4 展示了我们带有这些变化的新的perturb_sa1()函数。

def perturb_sa1(cities,itinerary**,time**):
    neighborids1 = math.floor(np.random.rand() * (len(itinerary)))
    neighborids2 = math.floor(np.random.rand() * (len(itinerary)))

    itinerary2 = itinerary.copy()

    itinerary2[neighborids1] = itinerary[neighborids2]
    itinerary2[neighborids2] = itinerary[neighborids1]

    distance1 = howfar(genlines(cities,itinerary))
    distance2 = howfar(genlines(cities,itinerary2))

    itinerarytoreturn = itinerary.copy()

    **randomdraw = np.random.rand()**
 **temperature = 1/((time/1000) + 1)**

   ** if((distance2 > distance1 and (randomdraw) < (temperature)) or (distance1 > distance2)):**
        itinerarytoreturn=itinerary2.copy()

    return(itinerarytoreturn.copy())

清单 6-4: 更新后的perturb()函数版本,考虑了温度和随机抽样

仅通过添加这两行简短的代码、一个新参数和一个新的if条件(在清单 6-4 中以粗体显示),我们已经有了一个非常简单的模拟退火函数。我们还稍微修改了温度函数;因为我们将使用非常高的time值来调用这个函数,我们在温度函数的分母参数中使用time/1000而不是time。我们可以如下比较模拟退火与扰动搜索算法和最近邻算法的性能:

itinerary = [0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29, \30,31,32,33,34,35,36,37,38,39]
np.random.seed(random_seed)

itinerary_sa = itinerary.copy()
for n in range(0,len(itinerary) * 50000):
    itinerary_sa = perturb_sa1(cities,itinerary_sa,n)

print(howfar(genlines(cities,itinerary))) #random itinerary
print(howfar(genlines(cities,itinerary_ps))) #perturb search
print(howfar(genlines(cities,itinerary_sa))) #simulated annealing
print(howfar(genlines(cities,donn(cities,N)))) #nearest neighbor

恭喜!你可以执行模拟退火算法了。你可以看到一个随机路径的距离是 16.81,而最近邻路径的距离是 6.29,就像我们之前观察到的那样。扰动搜索路径的距离是 7.38,模拟退火路径的距离是 5.92。在这种情况下,我们发现扰动搜索的表现优于随机路径,最近邻的表现优于扰动搜索和随机路径,而模拟退火的表现则优于所有其他方法。当你尝试不同的随机种子时,可能会看到不同的结果,包括模拟退火表现不如最近邻的情况。这是因为模拟退火是一个敏感的过程,许多方面需要精确调整才能让它工作得好且可靠。经过调整后,它将始终比简单的贪婪优化算法提供显著更好的性能。本章的其余部分将关注模拟退火的细节,包括如何调整它以获得最佳的性能。

调整我们的算法

如前所述,模拟退火是一个敏感的过程。我们引入的代码展示了如何以基本方式进行,但我们希望对细节进行修改,以便做得更好。这个过程中,通过改变算法的小细节或参数来获得更好的性能,而不改变其主要方法,通常被称为调优,在像这种困难的情况下,它可能会带来很大的差异。

我们的perturb()函数对行程做了一个小的改变:它交换了两个城市的位置。但这并不是扰动行程的唯一方式。我们很难提前知道哪种扰动方法表现最佳,但我们总是可以尝试几种。

另一种扰动行程的自然方法是反转其中的一部分:选择一部分城市,并按相反的顺序访问它们。在 Python 中,我们可以用一行代码来实现这个反转。如果我们选择行程中的两个城市,索引分别是smallbig,下面的代码片段展示了如何反转它们之间所有城市的顺序:

small = 10
big = 20
itinerary = [0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29, \30,31,32,33,34,35,36,37,38,39]
itinerary[small:big] = itinerary[small:big][::-1]
print(itinerary)

当你运行这个代码片段时,你可以看到输出显示了一个城市从 10 到 19 按反序排列的行程:

[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, **19, 18, 17, 16, 15, 14, 13, 12, 11, 10**, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39]

另一种扰动行程的方法是将某个区间从原位置提升并放到行程的另一部分。例如,我们可能会采用以下行程:

itinerary = [0,1,2,3,4,5,6,7,8,9]

并将整个区间[1,2,3,4]移到行程后面,通过将其转换为以下新的行程:

itinerary = [0,5,6,7,8,1,2,3,4,9]

我们可以通过以下 Python 代码片段来实现这种类型的提升和移动,它将把选定的区间移动到一个随机位置:

small = 1
big = 5
itinerary = [0,1,2,3,4,5,6,7,8,9]
tempitin = itinerary[small:big]
del(itinerary[small:big])
np.random.seed(random_seed + 1)
neighborids3 = math.floor(np.random.rand() * (len(itinerary)))
for j in range(0,len(tempitin)):
    itinerary.insert(neighborids3 + j,tempitin[j])

我们可以更新我们的perturb()函数,使其在这些不同的扰动方法之间随机切换。我们将通过再次随机选择一个介于 0 和 1 之间的数字来实现这一点。如果这个新的随机数位于某个范围内(例如,0–0.45),我们将通过反转一部分城市的顺序来进行扰动;如果它位于另一个范围内(例如,0.45–0.55),我们将通过交换两个城市的位置来进行扰动。如果它位于最终的范围内(例如,0.55–1),我们将通过提升并移动一部分城市来进行扰动。通过这种方式,我们的perturb()函数可以在每种扰动类型之间随机切换。我们可以将这个随机选择和这些扰动类型放入我们新的函数中,现在称为perturb_sa2(),如 Listing 6-5 所示。

def perturb_sa2(cities,itinerary,time):
    neighborids1 = math.floor(np.random.rand() * (len(itinerary)))
    neighborids2 = math.floor(np.random.rand() * (len(itinerary)))

    itinerary2 = itinerary.copy()

 **randomdraw2 = np.random.rand()**
 **small = min(neighborids1,neighborids2)**
 **big = max(neighborids1,neighborids2)**
 **if(randomdraw2 >= 0.55):**
 **itinerary2[small:big] = itinerary2[small:big][:: - 1]**
 **elif(randomdraw2 < 0.45):**
 **tempitin = itinerary[small:big]**
 **del(itinerary2[small:big])**
 **neighborids3 = math.floor(np.random.rand() * (len(itinerary)))**
 **for j in range(0,len(tempitin)):**
 **itinerary2.insert(neighborids3 + j,tempitin[j])**
 **else:**
        itinerary2[neighborids1] = itinerary[neighborids2]
        itinerary2[neighborids2] = itinerary[neighborids1]

    distance1 = howfar(genlines(cities,itinerary))
    distance2 = howfar(genlines(cities,itinerary2))

    itinerarytoreturn = itinerary.copy()

    randomdraw = np.random.rand()
    temperature = 1/((time/1000) + 1)  

    if((distance2 > distance1 and (randomdraw) < (temperature)) or (distance1 > distance2)):
        itinerarytoreturn = itinerary2.copy()

    return(itinerarytoreturn.copy())

Listing 6-5: 现在,我们使用几种不同的方法来扰动我们的行程。

我们的perturb()函数现在更复杂也更灵活;它可以根据随机抽样对行程进行几种不同类型的修改。灵活性并不一定是值得追求的目标,复杂性更是如此。为了判断在这种情况下(以及每种情况中)复杂性和灵活性是否值得增加,我们应该检查它们是否提升了性能。这就是调优的本质:就像调音一样,你事先并不知道弦需要拉多紧——你必须稍微调紧或松弛,听听它的声音,然后进行调整。当你测试这里的变化(在 Listing 6-5 中以粗体显示)时,你会发现它们确实相较于我们之前运行的代码提升了性能。

避免重大挫折

模拟退火的核心思想是,我们需要做得更差才能做得更好。然而,我们希望避免做出使我们变得太糟的改变。我们设置的perturb()函数会在每次随机选择小于温度时接受更差的行程。它通过以下条件实现这一点(该条件同样不打算单独运行):

if((distance2 > distance1 and randomdraw < temperature) or (distance1 > distance2)):

我们可能想要改变这个条件,使得我们接受更差行程的意愿不仅取决于温度,还取决于我们假设的变化使行程变得多么更差。如果它只是稍微更差,我们会比它变得更差时更愿意接受。为此,我们将在条件中加入衡量新行程有多差的量度。以下条件(同样不打算单独运行)是实现这一目标的有效方式:

scale = 3.5
if((distance2 > distance1 and (randomdraw) < (math.exp(scale*(distance1-distance2)) * temperature)) or (distance1 > distance2)):

当我们在代码中加入这个条件时,我们就得到了 Listing 6-6 中的函数,其中我们只展示了perturb()函数的最后部分。

`--snip--`
# beginning of perturb function goes here

    scale = 3.5
    if((distance2 > distance1 and (randomdraw) < (math.exp(scale * (distance1 - distance2)) * temperature)) or (distance1 > distance2)):
        itinerarytoreturn = itinerary2.copy()

    return(itinerarytoreturn.copy())

允许重置

在模拟退火过程中,我们可能会不自觉地接受一个明显不好的行程变化。在这种情况下,跟踪我们迄今为止遇到的最佳行程,并允许我们的算法在某些条件下重置为这个最佳行程可能会很有用。Listing 6-6 提供了执行此操作的代码,在一个新的完整扰动函数中,模拟退火的代码部分以粗体显示。

def perturb_sa3(cities,itinerary,time**,maxitin**):
    neighborids1 = math.floor(np.random.rand() * (len(itinerary)))
    neighborids2 = math.floor(np.random.rand() * (len(itinerary)))
 **global mindistance**
 **global minitinerary**
 **global minidx**
    itinerary2 = itinerary.copy()
    randomdraw = np.random.rand()

    randomdraw2 = np.random.rand()
    small = min(neighborids1,neighborids2)
    big = max(neighborids1,neighborids2)
    if(randomdraw2>=0.55):
        itinerary2[small:big] = itinerary2[small:big][::- 1 ]
    elif(randomdraw2 < 0.45):
        tempitin = itinerary[small:big]
        del(itinerary2[small:big])
        neighborids3 = math.floor(np.random.rand() * (len(itinerary)))
        for j in range(0,len(tempitin)):
            itinerary2.insert(neighborids3 + j,tempitin[j])
 else:
        itinerary2[neighborids1] = itinerary[neighborids2]
        itinerary2[neighborids2] = itinerary[neighborids1]

    temperature=1/(time/(maxitin/10)+1)

    distance1 = howfar(genlines(cities,itinerary))
    distance2 = howfar(genlines(cities,itinerary2))

    itinerarytoreturn = itinerary.copy()

    scale = 3.5
    if((distance2 > distance1 and (randomdraw) < (math.exp(scale*(distance1 - distance2)) * \temperature)) or (distance1 > distance2)):
        itinerarytoreturn = itinerary2.copy()

 **reset = True**
 **resetthresh = 0.04**
 **if(reset and (time - minidx) > (maxitin * resetthresh)):**
 **itinerarytoreturn = minitinerary**
 **minidx = time**
 ****if(howfar(genlines(cities,itinerarytoreturn)) < mindistance):**
 **mindistance = howfar(genlines(cities,itinerary2))**
 **minitinerary = itinerarytoreturn**
 **minidx = time**

    if(abs(time - maxitin) <= 1):
        itinerarytoreturn = minitinerary.copy()

    return(itinerarytoreturn.copy())**

Listing 6-6: 这个函数执行完整的模拟退火过程,并返回一个优化后的行程。

在这里,我们定义了全局变量,用于存储迄今为止实现的最小距离、实现该距离的行程以及达到该距离的时间。如果时间进展很长而没有找到比已实现的最小距离更好的行程,我们可以得出结论,认为从那时起所做的改变是错误的,并允许重置为最佳行程。只有在我们尝试了许多扰动但没有找到比之前最好的更优结果时,我们才会重置,并通过一个名为resetthresh的变量来决定我们应该等待多长时间才重置。最后,我们添加了一个新的参数maxitin,它告诉函数我们打算调用此函数多少次,以便我们知道当前所处的过程阶段。我们在温度函数中也使用maxitin,以便温度曲线能灵活调整,适应我们打算进行的扰动次数。当时间到达时,我们返回迄今为止给出最好结果的行程。

测试我们的性能

现在我们已经进行这些编辑和改进,可以创建一个名为siman()的函数(即模拟退火的简写),该函数将创建我们的全局变量,然后反复调用我们最新的perturb()函数,最终得到一个距离非常低的行程(列表 6-7)。

def siman(itinerary,cities):
    newitinerary = itinerary.copy()
    global mindistance
    global minitinerary
    global minidx
    mindistance = howfar(genlines(cities,itinerary))
    minitinerary = itinerary
    minidx = 0

    maxitin = len(itinerary) * 50000
    for t in range(0,maxitin):
        newitinerary = perturb_sa3(cities,newitinerary,t,maxitin)

    return(newitinerary.copy())

列表 6-7: 该函数执行完整的模拟退火过程,并返回一个优化后的行程。

接下来,我们调用我们的siman()函数,并将其结果与最近邻算法的结果进行比较:

np.random.seed(random_seed)
itinerary = list(range(N))
nnitin = donn(cities,N)
nnresult = howfar(genlines(cities,nnitin))
simanitinerary = siman(itinerary,cities)
simanresult = howfar(genlines(cities,simanitinerary))
print(nnresult)
print(simanresult)
print(simanresult/nnresult)

当我们运行这段代码时,我们发现最终的模拟退火函数生成的行程距离为 5.32。与最近邻行程距离 6.29 相比,这已经提高了超过 15%。这可能对你来说看起来并不特别惊人:我们花费了十几页的篇幅深入探讨困难的概念,结果只是将总距离减少了大约 15%。这是一个合理的抱怨,可能你并不需要比最近邻算法提供的性能更好的结果。但想象一下,如果你能为像 UPS 或 DHL 这样的全球物流公司提供一种将旅行成本减少 15%的方法,你会看到他们的眼睛闪烁着美元符号,想到这将代表数十亿美元。物流在全球各行业中始终是高成本和环境污染的主要驱动因素,而在解决 TSP(旅行商问题)上取得成功将始终产生巨大的实际影响。除此之外,TSP 在学术上也极为重要,作为比较优化方法的基准,并作为研究高级理论思想的门户。

你可以通过运行plotitinerary(cities, simanitinerary, 'Traveling Salesman Itinerary - Simulated Annealing', 'figure5')来绘制我们获得的最终模拟退火结果行程图。你将在图 6-5 中看到该图。

figure_6-5

图 6-5: 模拟退火的最终结果

一方面,它只是一个随机生成的点图,并且用线将它们连接起来。另一方面,它是我们在数十万次迭代过程中进行优化的结果,毫不妥协地追求在几乎无限的可能性中达到完美,从这个角度来看,它是美丽的。

总结

在本章中,我们以旅行商问题作为高级优化的案例研究。我们讨论了几种解决该问题的方法,包括暴力搜索、最近邻搜索,最后是模拟退火,这是一种强大的解决方案,它通过“做得更糟”来“做得更好”。我希望通过解决旅行商问题这个难题,你能够获得可以应用于其他优化问题的技能。在商业和科学中,先进的优化技术总会有实际需求。

在下一章,我们将注意力转向几何学,研究使几何操作和构造成为可能的强大算法。让冒险继续!**

第七章:几何学

我们人类对几何学有着深刻的直觉理解。每当我们在走廊中搬动沙发、玩Pictionary时,或者判断高速公路上另一辆车的距离时,我们都在进行某种几何推理,通常依赖于我们潜意识中掌握的算法。到目前为止,你可能不会惊讶地发现,进阶的几何学与算法推理自然契合。

本章将使用几何算法来解决邮局问题。我们将从问题描述开始,看看如何使用 Voronoi 图来解决它。接下来的内容将解释如何通过算法生成这一解决方案。

邮局问题

想象一下,你是本杰明·富兰克林,并且你被任命为一个新国家的首任邮政总局长。随着国家的发展,原有的独立邮局是随意建立的,你的工作是将这些混乱的部分整合成一个高效运作的整体。假设在一个城镇中,四个邮局分布在各个住宅区,如同图 7-1 所示。

figure_7_1

图 7-1: 一个城镇及其邮局

由于在你的新国家从未有过邮政总局长,因此没有人监督优化邮局的送达工作。可能是邮局 4 被分配给一个距离邮局 2 和 3 更近的家庭,而与此同时,邮局 2 却被分配给了一个离邮局 4 更近的家庭,如同图 7-2 所示。

figure_7_2

图 7-2: 邮局 2 和 4 的分配效率低。

你可以重新安排送达任务,使得每个家庭都能从理想的邮局接收邮件。一个送达任务的理想邮局可能是员工最多的邮局、拥有适合穿越某区域的设备的邮局,或者是拥有能够找到该区域内所有地址的机构知识的邮局。但最可能的是,送达任务的理想邮局只是最靠近的那个。你可能会注意到,这与旅行商问题(TSP)类似,至少在我们需要在地图上移动物体并希望减少旅行距离的方面。然而,TSP 是一个优化路线顺序的旅行者问题,而在这里,你面临的是多个旅行者(投递员)优化多个路线分配的问题。实际上,这个问题和 TSP 可以连续解决,以获得最大的收益:在你完成哪些邮局应送达哪些家庭的任务分配后,个别的投递员可以使用 TSP 来决定访问这些家庭的顺序。

解决这个问题的最简单方法,我们可以称之为邮递员问题,是依次考虑每座房子,计算房子与四个邮局之间的距离,并将最近的邮局分配给该房子。

这种方法有一些弱点。首先,它没有提供一种简单的方式来分配新建房屋;每一座新建的房子必须通过与每个现有邮局的比较,经历同样繁琐的过程。其次,在单独为每个房子做计算时,我们无法了解整个区域的情况。例如,也许某个社区的整个区域都处在某个邮局的覆盖范围内,但却远离其他邮局。最好在一步中得出结论,认为整个社区应该由同一个邻近的邮局提供服务。不幸的是,我们的方法要求我们对社区内的每座房子重复计算,最后得到相同的结果。

通过为每座房子单独计算距离,我们在重复做一些不必要的工作,如果我们能对整个社区或区域做出一些概括的话,就不需要做这些工作。而且这些工作会逐渐累积。在拥有数千万居民的超级城市中,考虑到如今世界各地许多邮局以及快速的建筑速度,这种方法会显得不必要地慢且消耗大量计算资源。

更优雅的方法是将地图整体考虑,并将其划分为不同的区域,每个区域代表一个邮局的服务区域。通过仅绘制两条直线,我们就可以完成这一任务,就像我们假设的小镇(图 7-3)一样。

我们绘制的区域表示最近邻的区域,这意味着对于每一座房子、每一个点和每一个像素,最近的邮局就是与它共享同一区域的邮局。现在,整个地图已被细分,我们可以通过简单地检查某个区域来轻松地将任何新的建筑分配到它最近的邮局。

将地图细分为最近邻区域的图形,正如我们所做的那样,叫做Voronoi 图。Voronoi 图有着悠久的历史,早在勒内·笛卡尔时期就已出现。它们曾被用来分析伦敦水泵的布置,为霍乱传播的方式提供证据,至今仍广泛应用于物理学和材料科学中,用于表示晶体结构。本章将介绍一种为任意一组点生成 Voronoi 图的算法,从而解决邮递员问题。

figure_7_3

图 7-3: Voronoi 图将我们的城市划分为最佳邮政配送区域

三角形基础 101

让我们回过头来,从我们将要探索的算法的最简单元素开始。我们正在研究几何学,其中最简单的分析元素是点。我们将点表示为具有两个元素的列表:一个 x 坐标和一个 y 坐标,如以下示例所示:

point = [0.2,0.8]

在下一个复杂度级别,我们将点组合成三角形。我们将三角形表示为由三个点组成的列表:

triangle = [[0.2,0.8],[0.5,0.2],[0.8,0.7]]

我们还可以定义一个辅助函数,将三个不相干的点转换为一个三角形。这个小函数所做的只是将三个点收集到一个列表中,并返回该列表:

def points_to_triangle(point1,point2,point3):
    triangle = [list(point1),list(point2),list(point3)]
    return(triangle)

能够可视化我们正在处理的三角形会很有帮助。让我们创建一个简单的函数,它可以接收任何三角形并将其绘制出来。首先,我们将使用在第六章中定义的genlines()函数。记住,这个函数接收一组点并将它们转换为直线。再一次,这是一个非常简单的函数,只是将点附加到一个名为lines的列表中:

def genlines(listpoints,itinerary):
    lines = []
    for j in range(len(itinerary)-1):
        lines.append([listpoints[itinerary[j]],listpoints[itinerary[j+1]]])
    return(lines)

接下来,我们将创建一个简单的绘图函数。它将接收我们传递给它的三角形,拆分其* x y 值,调用genlines()来根据这些值创建一组直线,绘制点和直线,最后将图像保存为.png*文件。它使用pylab模块进行绘图,并使用matplotlib模块中的代码来创建直线集合。列表 7-1 展示了这个函数。

import pylab as pl
from matplotlib import collections as mc
def plot_triangle_simple(triangle,thename):
    fig, ax = pl.subplots()

    xs = [triangle[0][0],triangle[1][0],triangle[2][0]]
    ys = [triangle[0][1],triangle[1][1],triangle[2][1]]

    itin=[0,1,2,0]

    thelines = genlines(triangle,itin)

    lc = mc.LineCollection(genlines(triangle,itin), linewidths=2)

    ax.add_collection(lc)

    ax.margins(0.1)
    pl.scatter(xs, ys)
    pl.savefig(str(thename) + '.png')
    pl.close()

列表 7-1: 绘制三角形的函数

现在,我们可以选择三个点,将它们转换为三角形,并在一行代码中绘制该三角形:

plot_triangle_simple(points_to_triangle((0.2,0.8),(0.5,0.2),(0.8,0.7)),'tri')

图 7-4 展示了输出结果。

figure_7-4

图 7-4: 一个简朴的三角形

另外,拥有一个函数来计算任何两个点之间的距离也会很有用,使用的是勾股定理:

def get_distance(point1,point2):
    distance = math.sqrt((point1[0] - point2[0])**2 + (point1[1] - point2[1])**2)
    return(distance)

最后,提醒一下几种常见几何术语的含义:

  1. 平分 将一条线分成两个相等的部分。平分一条线找到它的中点。

  2. 等边 意思是“边长相等”。我们用这个术语来描述所有边长相等的形状。

  3. 垂直 我们描述两条形成 90 度角的直线的方式。

  4. 顶点 形状中两条边相交的点。

高级研究生级别的三角形研究

科学家和哲学家戈特弗里德·威廉·莱布尼茨认为,我们的世界是所有可能世界中最好的,因为它是“假设最简单、现象最丰富的”。他认为,科学的规律可以归结为几条简单的规则,而这些规则却能引发我们所观察到的世界的复杂多样性和美丽。虽然这可能不适用于整个宇宙,但对于三角形来说却完全正确。从一个假设极为简单的东西开始(即具有三条边的形状的概念),我们进入了一个现象极其丰富的世界。

寻找外接圆心

为了开始看到三角形世界现象的丰富性,考虑以下简单的算法,你可以用任何三角形来尝试:

  1. 找出三角形每条边的中点。

  2. 从三角形的每个顶点画一条线,连接到该顶点对面的边的中点。

在你遵循这个算法后,你会看到类似图 7-5 的情况。

figure_7_5

图 7-5: 三角形重心(来源:Wikimedia Commons)

值得注意的是,你画的所有线条都汇聚在一个点上,这个点看起来像是三角形的“中心”。无论你从哪个三角形开始,这三条线都会汇聚到一个点。它们汇聚的点通常被称为三角形的重心,并且总是位于三角形的内部,像是可以被称为三角形中心的地方。

有些形状,如圆形,总是有一个明确的点可以称为形状的中心。但三角形不是这样的:重心是一个可以称为三角形中心的点,但也有其他点也可以被视为中心。考虑这个新的算法,适用于任何三角形:

  1. 将三角形的每条边平分。

  2. 画一条垂直于每条边的线,穿过该边的中点。

在这种情况下,这些线条通常不像我们绘制重心时那样穿过顶点。比较图 7-5 与图 7-6。

figure_7_6

图 7-6: 三角形外接圆心(来源:Wikimedia Commons)

请注意,这些线确实会汇聚,再次汇聚到一个点,这个点不是重心,但通常在三角形内部。这个点有另一个有趣的性质:它是通过三角形所有三个顶点的唯一圆的圆心。这里是与三角形相关的另一个丰富现象:每个三角形都有一个唯一的圆,经过它的所有三个点。这个圆被称为外接圆,因为它是包围三角形的圆。我们刚才概述的算法找到了那个外接圆的圆心。正因为如此,这三条线的交点被称为外接圆心

像重心一样,外接圆心是一个可以称为三角形中心的点,但它们并不是唯一的候选点——一本名为https://faculty.evansville.edu/ck6/encyclopedia/ETC.html的百科全书列出了 40,000 个(迄今为止)因各种原因可以称为三角形中心的点。正如百科全书本身所说,三角形中心的定义是“满足无穷多对象的条件,其中只有有限的对象会被发布。”令人惊讶的是,从三个简单的点和三条直线开始,我们得到了一个可能无限的独特中心百科全书——莱布尼茨一定会非常高兴。

我们可以编写一个函数,找到任意给定三角形的外心和外接半径(外接圆的半径)。该函数依赖于转换为复数。它以一个三角形作为输入,并返回一个中心和半径作为输出:

def triangle_to_circumcenter(triangle):
    x,y,z = complex(triangle[0][0],triangle[0][1]), complex(triangle[1][0],triangle[1][1]), \    complex(triangle[2][0],triangle[2][1])
    w = z - x
    w /= y - x
    c = (x-y) * (w-abs(w)**2)/2j/w.imag - x
    radius = abs(c + x)
    return((0 - c.real,0 - c.imag),radius)

这个函数计算中心和半径的具体细节比较复杂。我们在这里不深入讨论,但如果你愿意,可以自己逐步查看代码。

增强我们的绘图功能

现在我们已经可以为每个三角形找到外心和外接半径了,让我们改进 plot_triangle() 函数,使其能够绘制所有内容。清单 7-2 显示了新函数。

def plot_triangle(triangles,centers,radii,thename):
    fig, ax = pl.subplots()
   ** ax.set_xlim([0,1])**
 **ax.set_ylim([0,1])**
 **for i in range(0,len(triangles)):**
 **triangle = triangles[i]**
 **center = centers[i]**
 **radius = radii[i]**
        itin = [0,1,2,0]
        thelines = genlines(triangle,itin)
        xs = [triangle[0][0],triangle[1][0],triangle[2][0]]
        ys = [triangle[0][1],triangle[1][1],triangle[2][1]]

        lc = mc.LineCollection(genlines(triangle,itin), linewidths = 2)

        ax.add_collection(lc)
        ax.margins(0.1)
        pl.scatter(xs, ys)
        pl.scatter(center[0],center[1])

       ** circle = pl.Circle(center, radius, color = 'b', fill = False)**

 **ax.add_artist(circle)**
    pl.savefig(str(thename) + '.png')
    pl.close()

清单 7-2: 我们改进后的 plot_triangle() 函数,绘制外心和外接圆

我们从添加两个新的参数开始:一个 centers 变量,它是所有三角形外心的列表,另一个 radii 变量,它是每个三角形外接圆半径的列表。请注意,我们传入的是由列表组成的参数,因为这个函数是为了绘制多个三角形,而不仅仅是一个三角形。我们将使用 pylab 的圆形绘制功能来绘制这些圆。以后,我们将同时处理多个三角形。为了能够绘制多个三角形而不是一个,我们将在绘图函数中加入一个循环,遍历每个三角形及其外心,并依次绘制它们。

我们可以用我们定义的三角形列表调用这个函数:

triangle1 = points_to_triangle((0.1,0.1),(0.3,0.6),(0.5,0.2))
center1,radius1 = triangle_to_circumcenter(triangle1)
triangle2 = points_to_triangle((0.8,0.1),(0.7,0.5),(0.8,0.9))
center2,radius2 = triangle_to_circumcenter(triangle2)
plot_triangle([triangle1,triangle2],[center1,center2],[radius1,radius2],'two')

我们的输出如图 7-7 所示。

figure_7-7

图 7-7: 两个带有外心和外接圆的三角形

请注意,我们的第一个三角形接近等边三角形。它的外接圆很小,外心位于外接圆内。我们的第二个三角形是一个狭长的三角形。它的外接圆很大,外心远离图形边界。每个三角形都有一个独特的外接圆,不同的三角形形状会导致不同类型的外接圆。你可能会发现自己可以独立探索不同的三角形形状及其相应的外接圆。稍后,这些三角形外接圆之间的差异将变得很重要。

德劳内三角剖分

我们准备好进入本章的第一个主要算法了。它接受一组点作为输入,并返回一组三角形作为输出。在这个上下文中,将一组点转化为一组三角形被称为三角剖分

我们在本章开始时定义的points_to_triangle()函数是最简单的三角剖分算法。然而,它非常有限,因为它仅在我们输入恰好三个点时才有效。如果我们要对三个点进行三角剖分,只有一种可能的方式:输出一个由这三点构成的三角形。如果我们要对超过三个点进行三角剖分,必然会有多种方法。例如,考虑图 7-8 中展示的对相同七个点的两种不同的三角剖分方式。

figure_7_8

图 7-8: 两种不同的七点三角剖分方式(来自维基共享资源)

实际上,有 42 种可能的方式来对这个规则的七边形进行三角剖分,图 7-9 展示了这些方式。

figure_7_9

图 7-9: 所有 42 种可能的七点三角剖分方式(来源:维基百科)

如果你有超过七个点,并且它们的位置不规则,可能的三角剖分数量会达到惊人的数量级。

我们可以通过拿起笔和纸手动进行三角剖分。毫不奇怪,我们通过使用算法可以做得更好更快。

有几种不同的三角剖分算法。有些算法旨在快速运行,有些则注重简单性,还有一些旨在得到具有特定优良性质的三角剖分。我们将在这里讨论的是Bowyer-Watson 算法,它的设计目的是接受一组点作为输入并输出德劳内三角剖分。

德劳内三角剖分(DT)旨在避免狭长的细三角形。它倾向于输出接近等边三角形的三角形。记住,等边三角形的外接圆相对较小,而细长三角形的外接圆相对较大。考虑德劳内三角剖分的技术定义:对于一组点,它是连接所有点的三角形集,其中没有任何点位于任何三角形的外接圆内。细长三角形的大外接圆很可能会包含集合中的一个或多个其他点,因此规定没有点可以位于任何外接圆内,导致了相对较少的细长三角形。如果这一点不清楚,不要担心——你将在下一节看到可视化的解释。

增量生成德劳内三角剖分

我们最终的目标是编写一个函数,接受任何一组点并输出完整的德劳内三角剖分。但让我们从简单的开始:我们将编写一个函数,接受一个已有的n点的 DT,以及一个我们想要添加进去的点,并输出一个n + 1 点的 DT。这个“德劳内扩展”函数将使我们非常接近能够编写完整的 DT 函数。

首先,假设我们已经得到了图 7-10 中展示的九个点的 DT。

现在假设我们想在我们的 DT 中添加第 10 个点(图 7-11)。

一个 DT 只有一个规则:没有任何点可以位于其任何三角形的外接圆内。因此,我们检查现有 DT 中每个圆的外接圆,以确定第 10 个点是否位于其中任何一个圆内。我们发现第 10 个点位于三个三角形的外接圆内(图 7-12)。

figure_7-10

图 7-10: 一个包含九个点的 DT

figure_7-11

图 7-11: 我们想要添加的第 10 个点在 9 点 DT 中的位置

figure_7-12

图 7-12: 在 DT 中的三个三角形的外接圆包含第 10 个点。

这些三角形不再允许出现在我们的 DT 中,因此我们将把它们移除,得到图 7-13。

figure_7-13

图 7-13: 我们已经移除了无效的三角形。

我们还没有完成。我们需要填补我们创建的空洞,并确保第 10 个点正确地与其他点连接。如果不这样做,我们就不会得到一组三角形,而只是一些点和线。我们连接第 10 个点的方式可以简单描述:添加一条边,将第 10 个点与它所处的最大空白多边形的每个顶点连接起来(图 7-14)。

figure_7-14

图 7-14: 通过重新连接有效三角形来完成 10 点 DT

啦!我们从一个 9 点 DT 开始,添加了一个新点,现在得到了一个 10 点 DT。这个过程看起来可能很简单。不幸的是,像许多几何算法一样,眼睛看起来直观而清晰的内容,写代码时却可能非常棘手。但让我们不要让这吓倒我们,勇敢的冒险者们。

实现德劳内三角剖分

假设我们已经有了一个 DT,我们称之为delaunay。它不过是一个三角形的列表。我们甚至可以从一个单独的三角形开始:

delaunay = [points_to_triangle((0.2,0.8),(0.5,0.2),(0.8,0.7))]

接下来,我们将定义一个我们想要添加的点,称为point_to_add

point_to_add = [0.5,0.5]

我们首先需要确定,在现有的 DT 中,哪些三角形(如果有的话)现在是无效的,因为它们的外接圆包含point_to_add。我们将执行以下操作:

  1. 使用循环遍历现有 DT 中的每个三角形。

  2. 对于每个三角形,找到其外心和外接圆的半径。

  3. 计算point_to_add与该外心之间的距离。

  4. 如果这个距离小于外接圆半径,那么新点就在三角形的外接圆内。我们可以得出结论,这个三角形是无效的,需要从 DT 中移除。

我们可以通过以下代码片段来完成这些步骤:

import math
invalid_triangles = []
delaunay_index = 0
while delaunay_index < len(delaunay):
    circumcenter,radius = triangle_to_circumcenter(delaunay[delaunay_index])
    new_distance = get_distance(circumcenter,point_to_add)
    if(new_distance < radius):
        invalid_triangles.append(delaunay[delaunay_index])
    delaunay_index += 1

这个代码片段创建了一个名为 invalid_triangles 的空列表,遍历我们现有 DT 中的每个三角形,并检查某个三角形是否无效。它通过检查 point_to_add 与外接圆心的距离是否小于外接圆的半径来判断。如果某个三角形无效,我们就将它添加到 invalid_triangles 列表中。

现在我们有了一份无效三角形的列表。由于它们无效,我们想要移除它们。最终,我们还需要向 DT 中添加新的三角形。为了做到这一点,拥有一个包含所有无效三角形中点的列表将非常有帮助,因为这些点将出现在我们的新有效三角形中。

我们接下来的代码片段将从我们的 DT 中移除所有无效的三角形,同时还会获取构成它们的点的集合。

points_in_invalid = []

for i in range(len(invalid_triangles)):
    delaunay.remove(invalid_triangles[i])
    for j in range(0,len(invalid_triangles[i])):
        points_in_invalid.append(invalid_triangles[i][j])

1 points_in_invalid = [list(x) for x in set(tuple(x) for x in points_in_invalid)]

我们首先创建一个空列表,名为 points_in_invalid。然后,我们遍历 invalid_triangles,使用 Python 的 remove() 方法将每个无效的三角形从现有的 DT 中删除。接着,我们遍历每个三角形中的点,并将它们添加到 points_in_invalid 列表中。最后,由于我们可能已将一些重复的点添加到 points_in_invalid 列表中,我们将使用列表推导式 1 来重新创建 points_in_invalid,确保只包含唯一的值。

我们算法的最后一步是最棘手的一步。我们必须添加新的三角形来替换无效的三角形。每个新三角形将包含 point_to_add 作为其中一个点,并且另外两个点来自现有的 DT。然而,我们不能添加 point_to_add 和两个现有点的每一种可能组合。

在图 7-13 和 7-14 中,请注意我们需要添加的新三角形都是以点 10 作为其中一个顶点,并且边是从包含点 10 的空多边形中选择的。经过视觉检查后,这看起来很简单,但要为其编写代码并不直接。

我们需要找到一个简单的几何规则,能够用 Python 的超字面风格进行简单的解释。想想可以用来生成新三角形的规则,例如在图 7-14 中所示。像许多数学情况一样,我们可能会找到多个等效的规则集。我们可以使用与点相关的规则,因为三角形的定义之一是由三点组成。我们还可以使用与线段相关的规则,因为三角形的另一种等效定义是由三条线段组成。我们可以使用任何一组规则;我们只是想要一组最简单理解和在代码中实现的规则。一种可能的规则是,我们应该考虑无效三角形中所有可能的点组合与point_to_add结合,但只有当不包含point_to_add的边在无效三角形列表中恰好出现一次时,我们才会将该三角形添加到新图形中。这个规则有效,因为那些恰好出现一次的边将是包围新点的外多边形的边(在图 7-13 中,相关的边是连接点 1、4、8、7 和 3 的多边形的边)。

以下代码实现了这一规则:

for i in range(len(points_in_invalid)):
    for j in range(i + 1,len(points_in_invalid)):
        #count the number of times both of these are in the bad triangles
        count_occurrences = 0
        for k in range(len(invalid_triangles)):
            count_occurrences += 1 * (points_in_invalid[i] in invalid_triangles[k]) * \            (points_in_invalid[j] in invalid_triangles[k])
        if(count_occurrences == 1):
            delaunay.append(points_to_triangle(points_in_invalid[i], points_in_invalid[j], \point_to_add))

在这里,我们遍历points_in_invalid中的每个点。对于每个点,我们会遍历points_in_invalid中每一个后续的点。这种双重循环使我们能够考虑位于无效三角形中的所有点对的组合。对于每一对组合,我们遍历所有无效三角形,并统计这两个点在无效三角形中同时出现的次数。如果这两个点只在一个无效三角形中一起出现,那么我们就可以得出结论,它们应该一起出现在我们新建的三角形中,并将这两个点与我们新点组成的新三角形添加到我们的 DT 中。

我们已经完成了将新点添加到现有 DT 中的步骤。因此,我们可以从一个包含 n 个点的 DT 开始,添加一个新点,最终得到一个包含 n + 1 个点的 DT。现在,我们需要学习如何利用这一能力从零开始构建 DT,从零点一直到 n 个点。当我们开始构建 DT 时,其实非常简单:我们只需要反复循环执行从 n 个点到 n + 1 个点的过程,直到我们将所有的点都添加进去。

还有一个额外的复杂问题。由于稍后我们会讨论的原因,我们希望向生成 DT 的点集合中添加三个点。这些点将位于我们选择的点之外,我们可以通过找到最上方和最左方的点,添加一个比这两者更高且更左的新点来确保这些点位于外部,类似地,对于最下方和最右方的点以及最下方和最左方的点进行类似的操作。我们将这些点作为我们的 DT 的第一个三角形。我们将从一个连接三个点的 DT 开始:即刚刚提到的这个新三角形的三个点。然后,我们将按照已经看到的逻辑,将三点 DT 转化为四点 DT,接着是五点 DT,依此类推,直到我们添加完所有点。

在列表 7-3 中,我们可以将之前编写的代码结合起来,创建一个名为gen_delaunay()的函数,该函数以一组点作为输入,并输出一个完整的 DT。

def gen_delaunay(points):
    delaunay = [points_to_triangle([-5,-5],[-5,10],[10,-5])]
    number_of_points = 0

    while number_of_points < len(points): 1
        point_to_add = points[number_of_points]

        delaunay_index = 0

        invalid_triangles = [] 2
        while delaunay_index < len(delaunay):
            circumcenter,radius = triangle_to_circumcenter(delaunay[delaunay_index])
            new_distance = get_distance(circumcenter,point_to_add)
            if(new_distance < radius):
                invalid_triangles.append(delaunay[delaunay_index])
            delaunay_index += 1

        points_in_invalid = [] 3
        for i in range(0,len(invalid_triangles)):
            delaunay.remove(invalid_triangles[i])
            for j in range(0,len(invalid_triangles[i])):
                points_in_invalid.append(invalid_triangles[i][j])
        points_in_invalid = [list(x) for x in set(tuple(x) for x in points_in_invalid)]

        for i in range(0,len(points_in_invalid)): 4
            for j in range(i + 1,len(points_in_invalid)):
                #count the number of times both of these are in the bad triangles
                count_occurrences = 0
                for k in range(0,len(invalid_triangles)):
                    count_occurrences += 1 * (points_in_invalid[i] in invalid_triangles[k]) * \                    (points_in_invalid[j] in invalid_triangles[k])
                if(count_occurrences == 1):
                    delaunay.append(points_to_triangle(points_in_invalid[i], \points_in_invalid[j], point_to_add))

**number_of_points += 1**

    return(delaunay)

列表 7-3: 一个接受一组点并返回 Delaunay 三角剖分的函数

完整的 DT 生成函数首先添加了前面提到的新外部三角形。然后,它循环遍历我们点集合中的每个点。对于每个点,它会创建一个无效三角形的列表:即在 DT 中所有外接圆包含当前正在查看的点的三角形。接着,它从 DT 中删除这些无效三角形,并使用这些无效三角形中的每个点创建一个新的点集合。然后,使用这些点,它会添加符合 Delaunay 三角剖分规则的新三角形。它以增量的方式执行这一过程,完全按照我们已经介绍过的代码进行。最后,它返回delaunay,这是一个包含所有三角形的列表,构成了我们的 DT。

我们可以轻松地调用这个函数,为任何点集合生成 DT。在以下代码中,我们为N指定一个数值,并生成N个随机点(xy 值)。然后,我们将 xy 值打包成一个列表,传递给gen_delaunay()函数,并返回一个完整有效的 DT,我们将其存储在名为the_delaunay的变量中:

N=15
import numpy as np
np.random.seed(5201314)
xs = np.random.rand(N)
ys = np.random.rand(N)
points = zip(xs,ys)
listpoints = list(points)
the_delaunay = gen_delaunay(listpoints)

我们将在下一节中使用the_delaunay来生成 Voronoi 图。

从 Delaunay 到 Voronoi

现在我们已经完成了 DT 生成算法,Voronoi 图生成算法也触手可及了。我们可以通过以下算法将一组点转化为 Voronoi 图:

  1. 查找一组点的 DT。

  2. 获取 DT 中每个三角形的外心。

  3. 绘制连接所有共享边的 DT 三角形外心的线条。

我们已经知道如何执行步骤 1(在上一节中已经做过了),我们可以通过triangle_to_circumcenter()函数完成步骤 2。所以我们需要的只是一个可以完成步骤 3 的代码片段。

我们在步骤 3 中编写的代码将位于我们的绘图函数中。记住,我们将一组三角形和外心传递给该函数作为输入。我们的代码需要创建一个连接外心的线条集合。但它不会连接所有外心,只连接那些共享边的三角形的外心。

我们将三角形存储为点的集合,而不是边。但仍然可以轻松检查两个三角形是否共享一条边;我们只需要检查它们是否共享恰好两个点。如果它们只共享一个点,那么它们有一个顶点相交,但没有公共边。如果它们共享三个点,它们就是相同的三角形,因此将有相同的外心。我们的代码会遍历每个三角形,对于每个三角形,再次遍历每个三角形,检查它们共享的点的数量。如果共同点的数量恰好是两个,那么它将连接这两个三角形的外心之间的连线。这些外心之间的连线将构成我们的 Voronoi 图的边界。以下代码片段展示了我们如何遍历三角形,但它是一个更大绘图函数的一部分,因此暂时不要运行它:

--`snip`--
for j in range(len(triangles)):
    commonpoints = 0
    for k in range(len(triangles[i])):
        for n in range(len(triangles[j])):
            if triangles[i][k] == triangles[j][n]:
               commonpoints += 1
    if commonpoints == 2:
        lines.append([list(centers[i][0]),list(centers[j][0])])

这段代码将被添加到我们的绘图函数中,因为我们的最终目标是绘制 Voronoi 图。

在此过程中,我们可以对我们的绘图函数进行一些其他有用的改进。新的绘图函数如列表 7-4 所示,其中的更改用粗体标出:

def plot_triangle_circum(triangles,centers,plotcircles,plotpoints, \plottriangles,plotvoronoi,plotvpoints,thename):
    fig, ax = pl.subplots()
    ax.set_xlim([-0.1,1.1])
    ax.set_ylim([-0.1,1.1])

    lines=[]
    for i in range(0,len(triangles)):
        triangle = triangles[i]
        center = centers[i][0]
        radius = centers[i][1]
        itin = [0,1,2,0]
        thelines = genlines(triangle,itin)
        xs = [triangle[0][0],triangle[1][0],triangle[2][0]]
        ys = [triangle[0][1],triangle[1][1],triangle[2][1]]

        lc = mc.LineCollection(genlines(triangle,itin), linewidths=2)
        **if(plottriangles):**
            ax.add_collection(lc)
        **if(plotpoints):**
            pl.scatter(xs, ys)

        ax.margins(0.1)
 1 **if(plotvpoints):**
 **pl.scatter(center[0],center[1])**

        circle = pl.Circle(center, radius, color = 'b', fill = False)
        **if(plotcircles):**
            ax.add_artist(circle)

     2 **if(plotvoronoi):**
 **for j in range(0,len(triangles)):**
 **commonpoints = 0**
 **for k in range(0,len(triangles[i])):**
 **for n in range(0,len(triangles[j])):**
 **if triangles[i][k] == triangles[j][n]:**
 **commonpoints += 1**
 **if commonpoints == 2:**
 **lines.append([list(centers[i][0]),list(centers[j][0])])**

        lc = mc.LineCollection(lines, linewidths = 1)

        ax.add_collection(lc)

    pl.savefig(str(thename) + '.png')
    pl.close()

列表 7-4: 一个绘制三角形、外心、外接圆、Voronoi 点和 Voronoi 边界的函数

首先,我们添加新的参数,明确指定我们希望绘制的内容。记住,在本章中,我们处理了点、边、三角形、外接圆、外心、DT 和 Voronoi 边界。将所有这些绘制在一起可能会让眼睛感到压迫,因此我们将添加plotcircles来指定是否希望绘制我们的外接圆,plotpoints来指定是否希望绘制我们的点集合,plottriangles来指定是否希望绘制我们的 DT,plotvoronoi来指定是否希望绘制我们的 Voronoi 图边界,以及plotvpoints来指定是否希望绘制我们的外心(它们是 Voronoi 图边界的顶点)。新的添加部分用粗体标出。一个添加项绘制 Voronoi 顶点(外心),如果我们在参数中指定要绘制它们的话。另一个较长的添加项绘制 Voronoi 边界。我们还指定了几个if语句,允许我们根据需要绘制或不绘制三角形、顶点和外接圆。

我们快要准备好调用这个绘图函数并查看最终的 Voronoi 图了。然而,首先我们需要获取我们 DT 中每个三角形的外心。幸运的是,这非常简单。我们可以创建一个空的列表circumcenters,并将 DT 中每个三角形的外心添加到该列表中,如下所示:

circumcenters = []
for i in range(0,len(the_delaunay)):
    circumcenters.append(triangle_to_circumcenter(the_delaunay[i]))

最后,我们将调用我们的绘图函数,指定我们希望它绘制 Voronoi 边界:

plot_triangle_circum(the_delaunay,circumcenters,False,True,False,True,False,'final')

图 7-15 展示了我们的输出。

figure_7-15

图 7-15: 一个 Voronoi 图。呼!

我们在短短几秒钟内将一组点转化为 Voronoi 图。你可以看到,这个 Voronoi 图中的边界一直延伸到图形的边缘。如果我们增加图形的大小,Voronoi 边界会继续延伸得更远。记住,Voronoi 边界连接的是 DT 中三角形外接圆的中心。但是我们的 DT 可能连接的是在图形中心非常接近的少数点,所以所有的外接圆中心可能都位于图形中间的一个小区域。如果发生这种情况,Voronoi 图的边界将不会延伸到图形的边缘。这就是为什么我们在gen_delaunay()函数的第一行中添加了新的外部三角形;通过使用一个其点远离图形区域的三角形,我们可以确保始终会有 Voronoi 边界延伸到地图的边缘,这样(例如)我们就知道应该为新建在城市边缘或外部郊区的区域分配哪个邮局来负责配送。

最后,你可能会喜欢玩一下我们的绘图函数。例如,如果你将所有输入参数设置为True,你就可以生成一张杂乱但美丽的图,展示本章讨论的所有元素:

plot_triangle_circum(the_delaunay,circumcenters,True,True,True,True,True,'everything')

我们的输出在图 7-16 中显示。

figure_7-16

图 7-16: 魔眼

你可以用这张图片来说服你的室友和家人,告诉他们你正在为 CERN 进行顶级机密的粒子碰撞分析工作,或者也许你可以用它申请一个艺术奖学金,成为 Piet Mondrian 的精神继承者。当你看着这个带有 DT 和外接圆的 Voronoi 图时,你可以想象邮局、水泵、晶体结构或任何其他可能的 Voronoi 图应用。或者你也可以只想象点、三角形和线条,尽情享受几何学的纯粹乐趣。

总结

本章介绍了编写代码进行几何推理的方法。我们从绘制简单的点、线和三角形开始,接着讨论了不同的方式来寻找三角形的中心,以及如何利用这些方法为任何一组点生成 Delaunay 三角剖分。最后,我们简要介绍了使用 Delaunay 三角剖分生成 Voronoi 图的简单步骤,这些 Voronoi 图可以用来解决邮递员问题,或用于各种其他应用。它们在某些方面很复杂,但最终归结为点、线和三角形的基本操作。

在下一章中,我们将讨论可以用于处理语言的算法。特别是,我们将讲解如何通过算法修正缺少空格的文本,以及如何编写一个程序来预测自然短语中接下来应该出现的单词。

第八章:语言

在本章中,我们将进入人类语言的复杂世界。我们将首先讨论语言与数学之间的差异,这些差异使得语言算法变得困难。接下来,我们将构建一个空格插入算法,它能够处理任何语言的文本,并在缺少空格的地方插入空格。之后,我们将构建一个短语补全算法,能够模仿作家的风格,并找到短语中最合适的下一个单词。

本章中的算法 heavily 依赖于我们之前没有使用过的两种工具:列表推导式和语料库。列表推导式使我们能够利用循环和迭代的逻辑快速生成列表。它们在 Python 中经过优化,运行非常快,而且容易简洁地编写,但它们可能难以阅读,且其语法需要一些时间来适应。语料库是指一组文本,它将“教”我们的算法我们希望它使用的语言和风格。

为什么语言算法如此困难

将算法思维应用到语言上,至少可以追溯到笛卡尔,他注意到,尽管数字是无限的,但任何具备基本算术知识的人都知道如何创建或解释一个他们从未遇到过的数字。例如,也许你从未遇到过数字 14,326——从未数过那么高的数,没看过涉及这么多美元的财务报告,也没在键盘上敲过正好那几个键。但我敢肯定,你可以轻松理解这个数字有多大,哪些数字比它大,哪些比它小,以及如何在方程中操作它。

使我们轻松理解前所未见的数字的算法,实际上只是将 10 个数字(0–9)按顺序记住,并结合使用位置系统。我们知道 14,326 比 14,325 大一个单位,因为数字 6 紧跟在数字 5 后面,它们在各自的数字中占据相同的位置,其他所有位置的数字都是一样的。知道这些数字和位置系统让我们能立刻了解 14,326 与 14,325 的相似之处,以及它们都大于 12 且小于 1,000,000。我们还可以一眼看出,14,326 在某些方面与 4,326 相似,但在大小上有很大的区别。

语言则不同。如果你正在学习英语,当你第一次看到单词stage时,你不能仅凭它与stalestakestatestavestadesage 的相似性来推断其含义,尽管这些词与stage的区别就像 14,326 与 14,325 的区别一样。你也不能仅凭单词的音节数和字符数来推测细菌比麋鹿要大。即使是那些看似可靠的语言规则,比如在英语中通过加s来构成复数,当我们推测“princes”比“princess”少一些某物时,也会把我们引入歧途。

为了使用算法处理语言,我们必须让语言变得更简单,这样我们迄今为止探索的简短数学算法就可以可靠地与之配合使用,或者让我们的算法更智能,这样它们就能够处理人类语言自然发展所带来的混乱复杂性。我们将选择后者。

空格插入

假设你是一个大型老公司中的首席算法官,该公司拥有一个满是手写纸质记录的仓库。首席记录数字化官员一直在进行一个长期项目,将这些纸质记录扫描成图像文件,然后使用文本识别技术将图像转换为可以轻松存储在公司数据库中的文本。然而,部分手写记录非常难以辨认,而文本识别技术也不完美,因此从纸质记录中提取的最终数字文本有时会出现错误。你只收到了数字化后的文本,要求你找到一种方法来纠正这些错误,而不参考纸质原件。

假设你将第一个数字化的句子读取到 Python 中,并发现它是 G. K. Chesterton 的名言:“唯一完全神圣的事情,唯一在地球上看到的上帝乐园的片刻,是与一场失败的战斗作斗争——而且没有失去它。”你将这段不完全数字化的文本存储在一个叫做text的变量中:

text = "The oneperfectly divine thing, the oneglimpse of God's paradisegiven on earth, is to fight a losingbattle - and notlose it."

你会注意到这段文本是英文的,虽然每个单词的拼写都是正确的,但其中缺少了空格:oneperfectly实际上应该是one perfectlyparadisegiven应该是paradise given,等等。(缺少空格对于人类来说不常见,但文本识别技术经常会犯这种错误。)为了完成你的任务,你需要在文本中的适当位置插入空格。对于一位流利的英语使用者来说,这个任务手动完成可能不难。然而,想象一下你需要为数百万页扫描文件快速完成这一任务——显然,你需要编写一个能够为你自动完成这一任务的算法。

定义单词列表并查找单词

我们首先要做的事情是教我们的算法一些英语单词。这并不困难:我们可以定义一个叫做word_list的列表,并用单词填充它。我们先从几个单词开始:

word_list = ['The','one','perfectly','divine']

在这一章中,我们将使用列表推导式来创建和操作列表,相信你习惯之后会喜欢这种方式。以下是一个非常简单的列表推导式,它创建了我们word_list的副本:

word_list_copy = [word for word in word_list]

你可以看到,语法for word in word_listfor循环的语法非常相似。但我们不需要冒号或额外的行。在这种情况下,列表推导式尽可能简单,只是指定我们希望word_list中的每个单词都出现在我们新的列表word_list_copy中。这可能没什么用,但我们可以简洁地添加逻辑使它更有用。例如,如果我们想找到word_list中每个包含字母n的单词,只需简单地添加一个if语句:

has_n = [word for word in word_list if 'n' in word]

我们可以运行print(has_n)来查看结果是否如我们所料:

['one', 'divine']

在本章后面,你将看到更复杂的列表推导式,包括一些包含嵌套循环的例子。然而,它们都遵循相同的基本模式:一个for循环指定迭代,附加的if语句描述我们想要选择的最终列表输出的逻辑。

我们将使用 Python 的re模块来访问文本处理工具。re模块的一个有用函数是finditer(),它可以在我们的文本中搜索,找到word_list中任何单词的位置。我们可以像下面这样在列表推导式中使用finditer()

import re
locs = list(set([(m.start(),m.end()) for word in word_list for m in re.finditer(word, text)]))

这一行有点密集,花点时间确保你理解它。我们定义了一个名为locs的变量,locs是“位置”的缩写;这个变量将包含文本中word_list中每个单词的位置。我们将使用列表推导式来获得这个位置列表。

列表推导式发生在方括号([])内。我们使用for word in word_list来迭代word_list中的每个单词。对于每个单词,我们调用re.finditer(),它会在我们的文本中找到该单词,并返回该单词出现的每个位置的列表。我们迭代这些位置,每个位置存储在m中。当我们访问m.start()m.end()时,我们会分别得到该单词在文本中开始和结束的位置。注意——并习惯于——for循环的顺序,因为有些人会觉得它与他们预期的顺序相反。

整个列表推导式被list(set())包裹住。这是一种方便的方法,可以得到一个仅包含唯一值且没有重复项的列表。我们的列表推导式可能包含多个相同的元素,但将其转换为集合会自动去除重复项,然后再将其转换回列表,得到我们想要的格式:一个包含唯一单词位置的列表。你可以运行print(locs)来查看整个操作的结果:

[(17, 23), (7, 16), (0, 3), (35, 38), (4, 7)]

在 Python 中,像这样的有序对被称为元组,这些元组显示了word_list中每个单词在文本中的位置。例如,当我们运行text[17:23](使用前面列表中第三个元组的数字时),我们发现它是divine。这里,d是我们文本中的第 17 个字符,i是第 18 个字符,依此类推,直到edivine的最后一个字母,是我们文本中的第 22 个字符,因此元组以 23 结尾。你可以检查其他元组,它们也指示了word_list中单词的位置。

注意,text[4:7]one,而text[7:16]perfectly。单词one的结尾与单词perfectly的开头相接,没有任何间隔的空格。如果我们没有通过阅读文本立即注意到这一点,我们可以通过查看locs变量中的元组(4, 7)和(7, 16)来捕捉到这一点:由于 7 是(4, 7)的第二个元素,同时也是(7, 16)的第一个元素,我们知道一个单词的结尾恰好与另一个单词的开头相同。为了找到需要插入空格的位置,我们将寻找这样的情况:一个有效单词的结尾恰好与另一个有效单词的开头重合。

处理复合词

不幸的是,两个有效单词并排出现而没有空格,并不能作为缺少空格的确凿证据。考虑单词butterfly。我们知道butter是一个有效单词,fly也是一个有效单词,但我们不能简单地得出butterfly是错误拼写的结论,因为butterfly也是一个有效单词。所以我们不仅需要检查那些没有空格而并排出现的有效单词,还需要检查那些没有空格并排在一起后,组合成另一个无效单词的情况。这意味着在我们的文本中,我们需要检查oneperfectly是否是一个有效单词,paradisegiven是否是一个有效单词,等等。

为了检查这一点,我们需要找到文本中的所有空格。我们可以查看两个连续空格之间的所有子字符串,并将其称为潜在单词。如果一个潜在单词不在我们的单词列表中,那么我们将认为它是无效的。我们可以检查每个无效单词,看看它是否由两个较小的单词组合而成;如果是,我们将认为缺少了一个空格,并将其添加回去,正好插入在两个有效单词之间,它们合并成了无效单词。

检查现有空格之间的潜在单词

我们可以再次使用re.finditer()来查找文本中的所有空格,并将它们存储在名为spacestarts的变量中。我们还将向spacestarts变量添加两个元素:一个表示文本开始位置,另一个表示文本结束位置。这样可以确保我们找到每个潜在的单词,因为文本最开始和最末尾的单词是唯一不在空格之间的单词。我们还添加了一行来对spacestarts列表进行排序:

spacestarts = [m.start() for m in re.finditer(' ', text)]
spacestarts.append(-1)
spacestarts.append(len(text))
spacestarts.sort()

列表 spacestarts 记录了文本中空格的位置。我们通过使用列表推导式和 re.finditer() 工具获得这些位置。在这种情况下,re.finditer() 查找文本中每个空格的位置并将其存储在一个列表中,每个元素被称为 m。对于每个 m 元素,也就是空格,我们通过使用 start() 函数获取空格的起始位置。我们正在寻找那些空格之间的潜在单词。我们还需要另一个列表,记录空格后紧跟的字符位置,这些将是每个潜在单词第一个字符的位置。我们将这个列表称为 spacestarts_affine,因为从技术角度来说,这个新列表是 spacestarts 列表的仿射变换。仿射 通常用于指代线性变换,比如在每个位置上加 1,我们将在这里这么做。我们还将对这个列表进行排序:

spacestarts_affine = [ss+1 for ss in spacestarts]
spacestarts_affine.sort()

接下来,我们可以获取所有位于两个空格之间的子字符串:

between_spaces = [(spacestarts[k] + 1,spacestarts[k + 1]) for k in range(0,len(spacestarts) - 1 )]

我们在这里创建的变量叫做 between_spaces,它是一个包含元组的列表,元组的形式是 (<子字符串起始位置>, <子字符串结束位置>),像 (17, 23)。我们通过列表推导式来获取这些元组。这个列表推导式会遍历 k。在这种情况下,k 取值为从 0 到 spacestarts 列表长度减一之间的整数。对于每个 k,我们将生成一个元组。元组的第一个元素是 spacestarts[k]+1,它是每个空格位置之后的位置。第二个元素是 spacestarts[k+1],它是文本中下一个空格的位置。通过这种方式,我们最终输出的将是指示空格之间每个子字符串起始和结束位置的元组。

现在,考虑所有位于空格之间的潜在单词,并找出那些无效的单词(不在我们的单词列表中):

between_spaces_notvalid = [loc for loc in between_spaces if \text[loc[0]:loc[1]] not in word_list]

查看 between_spaces_notvalid,我们可以看到它是一个包含所有无效潜在单词位置的列表:

[(4, 16), (24, 30), (31, 34), (35, 45), (46, 48), (49, 54), (55, 68), (69, 71), (72, 78), (79, 81), (82, 84), (85, 90), (91, 92), (93, 105), (106, 107), (108, 111), (112, 119), (120, 123)]

我们的代码认为所有这些位置都指向无效的单词。然而,如果你查看这里提到的一些单词,它们看起来是相当有效的。例如,text[103:106] 输出了有效单词 and。我们的代码认为 and 是无效单词的原因是它不在我们的单词列表中。当然,我们可以手动将其添加到我们的单词列表中,并继续使用这种方法,因为我们需要让代码识别单词。但是请记住,我们希望这个空格插入算法能够处理数百万页扫描的文本,而这些文本可能包含成千上万个独特的单词。如果我们能够导入一个已经包含大量有效英语单词的单词列表,那将会很有帮助。这样的单词集合被称为 语料库

使用导入的语料库来检查有效单词

幸运的是,有一些现成的 Python 模块可以让我们只用几行代码就能导入完整的语料库。首先,我们需要下载语料库:

import nltk
nltk.download('brown')

我们已经从名为nltk的模块中下载了一个名为brown的语料库。接下来,我们将导入这个语料库:

from nltk.corpus import brown
wordlist = set(brown.words())
word_list = list(wordlist)

我们已经导入了语料库,并将其中的单词集合转换成了一个 Python 列表。然而,在使用这个新的word_list之前,我们应该进行一些清理,去除它认为是单词但实际上是标点符号的部分:

word_list = [word.replace('*','') for word in word_list]
word_list = [word.replace('[','') for word in word_list]
word_list = [word.replace(']','') for word in word_list]
word_list = [word.replace('?','') for word in word_list]
word_list = [word.replace('.','') for word in word_list]
word_list = [word.replace('+','') for word in word_list]
word_list = [word.replace('/','') for word in word_list]
word_list = [word.replace(';','') for word in word_list]
word_list = [word.replace(':','') for word in word_list]
word_list = [word.replace(',','') for word in word_list]
word_list = [word.replace(')','') for word in word_list]
word_list = [word.replace('(','') for word in word_list]
word_list.remove('')

这些行使用了remove()replace()函数,将标点符号替换为空字符串,并去除空字符串。现在我们有了一个合适的单词列表,我们将能够更准确地识别无效单词。我们可以使用新的word_list重新运行无效单词检查,并获得更好的结果:

between_spaces_notvalid = [loc for loc in between_spaces if \text[loc[0]:loc[1]] not in word_list]

当我们打印列表between_spaces_notvalid时,得到的是一个更短且更准确的列表:

[(4, 16), (24, 30), (35, 45), (55, 68), (72, 78), (93, 105), (112, 119), (120, 123)]

现在我们已经找到了文本中无效的潜在单词,我们将在单词列表中查找可能组合成这些无效单词的单词。我们可以从查找那些在空格后开始的单词入手。这些单词可能是无效单词的前半部分:

partial_words = [loc for loc in locs if loc[0] in spacestarts_affine and \loc[1] not in spacestarts]

我们的列表推导式会遍历locs变量中的每个元素,该变量包含文本中每个单词的位置。它会检查locs[0](单词的起始位置)是否在spacestarts_affine中,spacestarts_affine是一个包含紧接着空格后的字符的列表。接着,它会检查loc[1]是否不在spacestarts中,spacestarts检查单词是否在空格开始的位置结束。如果一个单词在空格后开始,并且不在空格的位置结束,我们将它放入partial_words变量中,因为这可能是一个需要在其后插入空格的单词。

接下来,我们来查找以空格结尾的单词。这些单词可能是无效单词的后半部分。为了找到它们,我们对之前的逻辑做了一些小的调整:

partial_words_end = [loc for loc in locs if loc[0] not in spacestarts_affine \and loc[1] in spacestarts]

现在我们可以开始插入空格。

查找潜在单词的前半部分和后半部分

我们从oneperfectly开始插入空格。我们将定义一个名为loc的变量,存储oneperfectly在文本中的位置:

loc = between_spaces_notvalid[0]

我们现在需要检查partial_words中的任何单词是否可能是oneperfectly的前半部分。要成为oneperfectly的前半部分,一个有效的单词必须在文本中与oneperfectly有相同的起始位置,但结束位置不同。我们将编写一个列表推导式,找到每个有效单词的结束位置,这些单词与oneperfectly的起始位置相同:

endsofbeginnings = [loc2[1] for loc2 in partial_words if loc2[0] == loc[0] \and (loc2[1] - loc[0]) > 1]

我们指定了loc2[0] == loc[0],这意味着我们的有效单词必须与oneperfectly在同一位置开始。我们还指定了(loc2[1]-loc[0])>1,这确保我们找到的有效单词长度大于一个字符。这不是严格必要的,但它可以帮助我们避免误报。想想像avoidasidealongirateiconic这样的单词,其中第一个字母本身可能被认为是一个单词,但可能不应该这么处理。

我们的列表endsofbeginnings应该包含每个有效单词的结束位置,这些单词的开始位置与oneperfectly相同。我们可以使用列表推导式创建一个类似的变量,叫做beginningsofends,它会找到每个有效单词的开始位置,这些单词的结束位置与oneperfectly相同:

beginningsofends = [loc2[0] for loc2 in partial_words_end if loc2[1] == loc[1] and \(loc2[1] - loc[0]) > 1]

我们指定了loc2[1] == loc[1],这意味着我们的有效单词必须和oneperfectly在同一位置结束。我们还指定了(loc2[1]-loc[0])>1,这确保我们找到的有效单词长度大于一个字符,就像我们之前所做的那样。

我们快到了;我们只需要找出是否有任何位置同时出现在endsofbeginningsbeginningsofends中。如果有,说明我们的无效单词确实是由两个有效单词组成的,且中间没有空格。我们可以使用intersection()函数来找到两个列表中共享的所有元素:

pivot = list(set(endsofbeginnings).intersection(beginningsofends))

我们再次使用list(set())语法;和之前一样,这是为了确保我们的列表只包含唯一值,没有重复。我们将结果称为pivot。有可能pivot会包含多个元素,这意味着有多个可能的有效单词组合可以构成我们的无效单词。如果发生这种情况,我们必须决定哪个组合是原作者的意图。这无法确定。例如,考虑无效单词choosespain。有可能这个无效单词来自伊比利亚航空的旅行宣传单(“Choose Spain!”),但也可能来自描述一个受虐狂(“chooses pain”)。由于我们语言中单词的数量庞大,且它们可以以多种方式组合,有时我们无法确定哪个是正确的。一个更复杂的方法会考虑上下文——choosespain周围的其他单词是否倾向于讲橄榄和斗牛,或者是鞭子和多余的牙医预约。这样的做法很难做到好,而且不可能做到完美,这再次说明了语言算法的难度。在我们的情况下,我们将选择pivot中最小的元素,不是因为它一定是正确的,而仅仅因为我们必须选一个:

import numpy as np
pivot = np.min(pivot)

最后,我们可以写一行代码,用两个有效的组成单词加一个空格来替换我们无效的单词:

textnew = text
textnew = textnew.replace(text[loc[0]:loc[1]],text[loc[0]:pivot]+' '+text[pivot:loc[1]])

如果我们打印出这个新的文本,我们可以看到它已经正确地在拼写错误的oneperfectly中插入了空格,尽管它还没有在其他拼写错误中插入空格。

The one perfectly divine thing, the oneglimpse of God's paradisegiven on earth, is to fight a losingbattle - and notlose it.

我们可以将所有这些功能结合成一个漂亮的函数,如 Listing 8-1 所示。这个函数将使用一个for循环,在每一对有效单词组合成无效单词的地方插入空格。

def insertspaces(text,word_list):

    locs = list(set([(m.start(),m.end()) for word in word_list for m in re.finditer(word, \text)]))
 spacestarts = [m.start() for m in re.finditer(' ', text)]
    spacestarts.append(-1)
    spacestarts.append(len(text))
    spacestarts.sort()
    spacestarts_affine = [ss + 1 for ss in spacestarts]
    spacestarts_affine.sort()
    partial_words = [loc for loc in locs if loc[0] in spacestarts_affine and loc[1] not in \    spacestarts]
    partial_words_end = [loc for loc in locs if loc[0] not in spacestarts_affine and loc[1] \    in spacestarts]
    between_spaces = [(spacestarts[k] + 1,spacestarts[k+1]) for k in \    range(0,len(spacestarts) - 1)]
    between_spaces_notvalid = [loc for loc in between_spaces if text[loc[0]:loc[1]] not in \    word_list]
    textnew = text
    for loc in between_spaces_notvalid:
        endsofbeginnings = [loc2[1] for loc2 in partial_words if loc2[0] == loc[0] and \    (loc2[1] - loc[0]) > 1]
        beginningsofends = [loc2[0] for loc2 in partial_words_end if loc2[1] == loc[1] and \    (loc2[1] - loc[0]) > 1]
        pivot = list(set(endsofbeginnings).intersection(beginningsofends))
        if(len(pivot) > 0):
            pivot = np.min(pivot)
            textnew = textnew.replace(text[loc[0]:loc[1]],text[loc[0]:pivot]+' \            '+text[pivot:loc[1]])
    textnew = textnew.replace('  ',' ')
    return(textnew)

Listing 8-1: 一个插入空格的函数,结合了本章中大部分代码

然后我们可以定义任何文本并调用我们的函数,如下所示:

text = "The oneperfectly divine thing, the oneglimpse of God's paradisegiven on earth, is to \fight a losingbattle - and notlose it."
print(insertspaces(text,word_list))

我们看到的输出正如我们预期的那样,空格已完美插入:

The one perfectly divine thing, the one glimpse of God's paradise given on earth, is to fight a losing battle - and not lose it.

我们已经创建了一个可以正确在英语文本中插入空格的算法。需要考虑的一点是,你是否也能在其他语言中做到这一点。可以的——只要你读取适合目标语言的良好语料库,定义我们在此示例中定义并调用的word_list,该函数就可以正确地在任何语言的文本中插入空格。它甚至可以纠正你从未学过或听说过的语言中的文本。尝试不同的语料库、不同的语言和不同的文本,看看你能得到什么样的结果,你就能一窥语言算法的强大功能。

短语补全

假设你正在为一个初创公司提供算法咨询服务,该公司正在构建搜索引擎并计划添加一些新功能。他们想要添加短语补全功能,以便能够为用户提供搜索建议。例如,当用户输入peanut``butter and时,搜索建议功能可能会建议添加单词jelly。当用户输入squash时,搜索引擎可能会同时建议courtsoup

构建这个功能很简单。我们将从一个语料库开始,就像我们在空间检查器中做的那样。在这种情况下,我们不仅关注语料库中的单个单词,还关注单词是如何组合在一起的,因此我们将从语料库中编译 n-gram 列表。n-gram仅仅是指一组连续出现的 n个单词。例如,句子“Reality is not always probable, or likely”由七个单词组成,这是伟大的 Jorge Luis Borges 曾经说过的话。1-gram 是单个单词,所以这个句子的 1-grams 是realityisnotalwaysprobableorlikely。2-gram 是每一对连续的两个单词,包括reality**isis notnot alwaysalways probable,等等。3-gram 是reality is notis not always,依此类推。

分词和获取 n-gram

我们将使用一个名为nltk的 Python 模块来简化 n-gram 的收集。我们首先对文本进行分词。分词仅仅是指将一个字符串拆分成其组成的单词,忽略标点符号。例如:

from nltk.tokenize import sent_tokenize, word_tokenize
text = "Time forks perpetually toward innumerable futures"
print(word_tokenize(text))

我们看到的结果是:

['Time', 'forks', 'perpetually', 'toward', 'innumerable', 'futures']

我们可以对文本进行分词并获得 n-gram,如下所示:

import nltk
from nltk.util import ngrams
token = nltk.word_tokenize(text)
bigrams = ngrams(token,2)
trigrams = ngrams(token,3)
fourgrams = ngrams(token,4)
fivegrams = ngrams(token,5)

或者,我们可以将所有的 n-gram 放入一个名为grams的列表中:

grams = [ngrams(token,2),ngrams(token,3),ngrams(token,4),ngrams(token,5)]

在这种情况下,我们已经获得了一个短句文本的分词和 n-grams 列表。然而,为了拥有一个通用的短语补全工具,我们需要一个相当大的语料库。我们用于空间插入的brown语料库不适用,因为它只包含单个单词,因此我们无法获取它的 n-grams。

我们可以使用的一个语料库是由 Google 的 Peter Norvig 提供的在线文学文本合集,网址为norvig.com/big.txt。在本章的示例中,我下载了莎士比亚全集的文件,它可以在* www.gutenberg.org/files/100/100-0.txt免费获取,然后删除了 Project Gutenberg 的页眉文本。你也可以使用马克·吐温的全集,网址为 www.gutenberg.org/cache/epub/3200/pg3200.txt*。你可以通过以下方式将语料库读取到 Python 中:

import requests
file = requests.get('http://www.bradfordtuckfield.com/shakespeare.txt')
file = file.text
text = file.replace('\n', '')

在这里,我们使用requests模块直接从托管网站读取包含莎士比亚全集的文本文件,然后将其读取到名为text的 Python 变量中。

在读取所选语料库后,重新运行创建grams变量的代码。以下是新定义的text变量的代码:

token = nltk.word_tokenize(text)
bigrams = ngrams(token,2)
trigrams = ngrams(token,3)
fourgrams = ngrams(token,4)
fivegrams = ngrams(token,5)
grams = [ngrams(token,2),ngrams(token,3),ngrams(token,4),ngrams(token,5)]

我们的策略

我们生成搜索建议的策略很简单。当用户输入搜索内容时,我们检查搜索中包含多少个单词。换句话说,用户输入一个 n-gram,我们确定n的值。当用户搜索 n-gram 时,我们帮助他们扩展搜索,因此我们将建议一个n + 1-gram。我们将在语料库中搜索并找到所有首个n元素与我们的 n-gram 匹配的n + 1-grams。例如,用户可能搜索crane(起重机),一个 1-gram,而我们的语料库可能包含 2-gram crane feather(起重机羽毛)、crane operator(起重机操作员)和crane neck(起重机颈部)。每一个都是我们可以提供的潜在搜索建议。

我们可以在此停止,提供每个首个n元素与用户输入的n + 1-gram 匹配的所有n + 1-gram。然而,并不是所有的建议都是同等优秀的。例如,如果我们为一个搜索工业建筑设备手册的定制引擎工作,那么crane operator(起重机操作员)可能比crane feather(起重机羽毛)更相关、更有用。确定哪个n + 1-gram 是最佳建议的最简单方法是提供在我们的语料库中出现频率最高的那个。

因此,我们的完整算法是:用户搜索一个 n-gram,我们找到所有首个n元素与用户的 n-gram 匹配的n + 1-grams,并推荐在语料库中最常出现的匹配n + 1-gram。

查找候选n + 1-grams

为了找到将构成我们搜索建议的n + 1-grams,我们需要知道用户搜索词的长度。假设搜索词是life is a,意思是我们正在寻找如何完成短语“life is a . . .”的建议。我们可以使用以下简单的代码来获取搜索词的长度:

from nltk.tokenize import sent_tokenize, word_tokenize
search_term = 'life is a'
split_term = tuple(search_term.split(' '))
search_term_length = len(search_term.split(' '))

现在我们知道搜索词的长度,知道了n的值——它是 3。记住,我们将返回最频繁的n + 1-gram(即 4-gram)给用户。所以我们需要考虑不同n + 1-gram 的频率差异。我们将使用一个名为Counter()的函数,它会计算每个n + 1-gram 在我们语料库中出现的次数。

from collections import Counter
counted_grams = Counter(grams[search_term_length - 1])

这一行仅从我们的grams变量中选择了n + 1-gram。应用Counter()函数会创建一个元组列表。每个元组的第一个元素是n + 1-gram,第二个元素是该n + 1-gram 在我们语料库中的出现频率。例如,我们可以打印counted_grams的第一个元素:

print(list(counted_grams.items())[0])

输出显示我们语料库中的第一个n + 1-gram,并告诉我们它在整个语料库中仅出现一次:

(('From', 'fairest', 'creatures', 'we'), 1)

这个 n-gram 是莎士比亚《第一首十四行诗》的开头。看看我们在莎士比亚作品中随机找到的一些有趣的 4-grams 非常有趣。例如,如果你运行print(list(counted_grams)[10]),你可以看到莎士比亚作品中的第十个 4-gram 是“rose might never die”。如果你运行print(list(counted_grams)[240000]),你可以看到第 240,000 个 n-gram 是“I shall command all”。第 323,002 个是“far more glorious star”,第 328,004 个是“crack my arms asunder”。但我们要做的是短语补全,而不仅仅是n + 1-gram 浏览。我们需要找到一个n + 1-grams 的子集,其前n个元素与我们的搜索词匹配。我们可以通过以下方式来做到这一点:

matching_terms = [element for element in list(counted_grams.items()) if \element[0][:-1] == tuple(split_term)]

这个列表推导式会遍历每个n + 1-gram,并在此过程中调用每个元素。对于每个元素,它检查element[0][:-1]==tuple(split_term)。这个等式的左边,element[0][:-1],简单地获取每个n + 1-gram 的前n个元素:[:-1]是一个方便的方式来忽略列表的最后一个元素。等式的右边,tuple(split_term),是我们要搜索的 n-gram(“life is a”)。所以我们在检查那些前n个元素与我们感兴趣的 n-gram 相同的n + 1-grams。所有匹配的词汇都存储在我们的最终输出中,名为matching_terms

基于频率选择短语

我们的matching_terms列表包含完成任务所需的所有内容;它由那些前n个元素与搜索词匹配的n + 1-grams 组成,并包括它们在我们语料库中的频率。只要匹配的词汇列表中至少有一个元素,我们就可以找到在语料库中出现最频繁的元素,并将其建议给用户作为完整的短语。以下代码片段可以完成这个任务:

if(len(matching_terms)>0):
    frequencies = [item[1] for item in matching_terms]
    maximum_frequency = np.max(frequencies)
    highest_frequency_term = [item[0] for item in matching_terms if item[1] == \maximum_frequency][0]
    combined_term = ' '.join(highest_frequency_term)

在这个代码片段中,我们首先定义了frequencies,它是一个包含我们语料库中每个与搜索词匹配的n + 1-gram 频率的列表。接着,我们使用了numpy模块的max()函数来找到这些频率中的最大值。然后,我们使用了另一个列表推导式来获取语料库中出现频率最高的第一个n + 1-gram,最后我们创建了一个combined_term,它是一个将搜索词中所有单词连接在一起的字符串,单词之间用空格分隔。

最后,我们可以将所有代码整合成一个函数,如列表 8-2 所示。

def search_suggestion(search_term, text):
    token = nltk.word_tokenize(text)
    bigrams = ngrams(token,2)
    trigrams = ngrams(token,3)
    fourgrams = ngrams(token,4)
    fivegrams = ngrams(token,5)
    grams = [ngrams(token,2),ngrams(token,3),ngrams(token,4),ngrams(token,5)]
    split_term = tuple(search_term.split(' '))
    search_term_length = len(search_term.split(' '))
    counted_grams = Counter(grams[search_term_length-1])
    combined_term = 'No suggested searches'    
    matching_terms = [element for element in list(counted_grams.items()) if \element[0][:-1] == tuple(split_term)]
    if(len(matching_terms) > 0):
        frequencies = [item[1] for item in matching_terms]
        maximum_frequency = np.max(frequencies)
 highest_frequency_term = [item[0] for item in matching_terms if item[1] == \maximum_frequency][0]
        combined_term = ' '.join(highest_frequency_term)
    return(combined_term)

列表 8-2: 一个通过输入一个 n-gram 并返回最可能的以该 n-gram 开头的 n + 1-gram 的搜索建议函数

当我们调用我们的函数时,我们传入一个 n-gram 作为参数,函数将返回一个n + 1-gram。我们按如下方式调用它:

file = requests.get('http://www.bradfordtuckfield.com/shakespeare.txt')
file = file=file.text
text = file.replace('\n', '')
print(search_suggestion('life is a', text))

你会看到,建议是life is a tedious,这是莎士比亚最常用的以life is a开头的 4-gram(与其他两个 4-gram 并列)。莎士比亚只在一次使用了这个 4-gram,出现在《辛白林》中,当伊莫金说:“我看到一个人的生命是乏味的。”在《李尔王》中,埃德加对格洛斯特说:“你的生命是一个奇迹”(或者根据不同版本是“你的生命是奇迹”),所以这个 4-gram 也可以作为我们短语的有效补全。

我们可以尝试使用不同的语料库,看看结果有何不同。让我们使用马克·吐温的全集语料库:

file = requests.get('http://www.bradfordtuckfield.com/marktwain.txt')
file = file=file.text
text = file.replace('\n', '')

使用这个新的语料库,我们可以再次检查搜索建议:

print(search_suggestion('life is a',text))

在这个例子中,补全后的短语是life is a failure,这表明了两个文本语料库之间的差异,也许还暗示了莎士比亚和马克·吐温在风格和态度上的不同。你也可以尝试其他搜索词。例如,如果使用马克·吐温的语料库,I love会被补全为you,而如果使用莎士比亚的语料库,它会被补全为thee,这展示了跨越几个世纪和大洋的风格差异,尽管观点可能没有太大不同。尝试使用另一个语料库和其他短语,看看你的短语如何被补全。如果你使用的是另一种语言写成的语料库,使用我们刚才编写的函数,你甚至可以对你不懂的语言进行短语补全。

总结

在这一章中,我们讨论了可以用于处理人类语言的算法。我们从一个空格插入算法开始,该算法可以修正扫描错误的文本,接着我们讨论了一个短语补全算法,它可以向输入的短语中添加单词,以匹配文本语料库的内容和风格。我们采取的这些算法方法类似于适用于其他类型语言算法的方法,包括拼写检查器和意图解析器。

在下一章,我们将探讨机器学习,这是一门强大且不断发展的领域,每一个优秀的算法工程师都应该熟悉它。我们将重点介绍一种名为决策树的机器学习算法,它是一种简单、灵活、准确且可解释的模型,可以在你踏上算法和人生的旅程中帮助你走得更远。

第九章:机器学习

现在你已经理解了许多基础算法背后的思想,我们可以转向更高级的概念。本章我们将探索机器学习。机器学习指的是一系列广泛的方法,但它们都有一个共同的目标:从数据中发现模式并利用这些模式进行预测。我们将讨论一种名为决策树的方法,并构建一个能够根据个人的一些特征预测一个人幸福感水平的模型。

决策树

决策树是具有分支结构、类似树形的图表。我们可以像使用流程图一样使用决策树——通过回答是/否问题,我们将沿着一条路径走向最终的决定、预测或建议。创建一个能够做出最佳决策的决策树的过程,是机器学习算法的典型例子。

让我们考虑一个现实场景,看看我们如何使用决策树。在急诊室,重要的决策者必须为每一位新入院的患者进行分诊。分诊意味着分配优先级:那些即将死亡但通过及时手术可以挽救的人将立即接受治疗,而那些只是纸割伤或轻微流感症状的人则会被要求等到更紧急的病例处理完再进行治疗。

分诊很困难,因为你必须在极短的时间或信息有限的情况下做出相对准确的诊断。如果一位 50 岁的女性来到急诊室,抱怨胸口剧烈疼痛,负责分诊的人必须决定她的疼痛是更可能是胃灼热还是心脏病发作。做出分诊决策的人的思维过程必然是复杂的。他们会考虑许多因素:患者的年龄和性别,是否肥胖或吸烟,报告的症状以及她们描述这些症状的方式,面部表情,医院的忙碌程度以及其他等待治疗的患者,甚至可能是她们没有意识到的因素。为了成为一名优秀的分诊员,必须学习许多模式。

理解一个分诊专业人员做决定的方式并不容易。图 9-1 展示了一个假设的、完全虚构的分诊决策过程(不作为医疗建议——请不要在家尝试!)。

Figure_9-1

图 9-1: 简化版心脏病发作分诊决策树

你可以从上到下阅读这个图表。在顶部,我们可以看到心脏病发作诊断过程从患者报告胸痛开始。之后,诊断过程根据患者的性别进行分支。如果患者是男性,诊断过程会沿左侧分支继续,我们会判断他是否肥胖。如果患者是女性,过程则沿右侧分支继续,我们会判断她是否吸烟。在每个步骤中,我们按照适当的分支进行,直到到达树的底部,在那里我们可以看到树的分类,判断患者是否处于高风险或低风险的心脏病发作状态。这个二叉分支过程类似于一棵树的结构,树干分支出越来越小的枝条,直到达到最远的分支。因此,图中展示的决策过程图 9-1 被称为决策树*。

每个你在图 9-1 中看到的文本都是决策树的节点。像“非肥胖”这样的节点被称为分支节点,因为在我们能够做出预测之前,至少还有一个分支需要跟随。“非糖尿病=低风险”节点是终端节点,因为一旦我们到达那里,就不再需要分支,我们已经知道决策树的最终分类(“低风险”)。

如果我们能够设计出一棵完善的、经过充分研究的决策树,始终能够做出正确的分诊决策,那么没有医学培训的人也有可能对心脏病发作的患者进行分诊,这将为全球所有的急诊室节省大量资金,因为他们将不再需要雇佣和培训那些谨慎且受过高等教育的分诊专家。一棵足够优秀的决策树甚至可能使机器人取代人工分诊专家,尽管这是否是一个好的目标仍有争议。一棵优秀的决策树甚至可能做出比普通人更好的决策,因为它有可能消除我们这些易犯错误的人的潜意识偏见。(事实上,这已经发生过:1996 年和 2002 年,两个不同的研究团队分别发表了论文,讲述他们通过使用决策树改善胸痛患者分诊结果的成功经验。)

决策树中描述的分支决策步骤构成了一个算法。执行这样的算法非常简单:只需在每个节点上决定应该选择哪一条分支,然后沿着分支走到终点。但不要盲目遵循你遇到的每一棵决策树。记住,任何人都可以创建一个描述任何可想象的决策过程的决策树,即使它导致错误的决策。决策树的难点不在于执行决策树算法,而在于设计决策树,使其能够得出最佳决策。创建一个最优的决策树是机器学习的应用,虽然仅仅遵循一棵决策树并不是机器学习。让我们讨论创建最优决策树的算法——一种生成算法的算法——并通过步骤生成一个准确的决策树。

构建决策树

让我们构建一个决策树,利用关于一个人的信息来预测他们的幸福感。几千年来,找出幸福的秘密一直困扰着数百万的人,而今天的社会科学研究人员则倾尽笔墨(并消耗大量的研究经费)来追寻答案。如果我们有一个决策树,能够利用几条信息可靠地预测一个人的幸福感,这将为我们提供关于决定一个人幸福的关键线索,甚至可能给我们一些关于如何实现幸福的启示。在本章结束时,你将学会如何构建这样的决策树。

下载我们的数据集

机器学习算法从数据中发现有用的模式,因此它们需要一个好的数据集。我们将使用来自欧洲社会调查(ESS)的数据来构建我们的决策树。你可以从http://bradfordtuckfield.com/ess.csvhttp://bradfordtuckfield.com/variables.csv 下载我们将使用的文件。(我们最初是从https://www.kaggle.com/pascalbliem/european-social-survey-ess-8-ed21-201617获取的这些文件,那里它们是公开免费提供的)。ESS 是一个大规模的欧洲成年人调查,每两年进行一次。它提出了各种各样的个人问题,包括宗教信仰、健康状况、社交生活和幸福感水平。我们将要查看的文件是以CSV格式存储的。文件扩展名.csv逗号分隔值的缩写,它是一种非常常见且简单的存储数据集的方式,可以通过 Microsoft Excel、LibreOffice Calc、文本编辑器和一些 Python 模块打开。

文件variables.csv包含了调查中每个问题的详细描述。例如,在variables.csv的第 103 行,我们可以看到一个名为happy的变量的描述。这个变量记录了受访者对问题“综合来看,你认为自己有多幸福?”的回答。这个问题的回答范围从 1(一点也不幸福)到 10(非常幸福)。查看variables.csv中的其他变量,了解可供我们使用的信息种类。例如,变量sclmeet记录了受访者与朋友、亲戚或同事社交的频率。变量health记录了受访者的主观健康状况。变量rlgdgr记录了受访者的宗教信仰程度,等等。

在查看了数据后,我们可以开始考虑与幸福预测相关的假设。我们可能合理地假设,拥有积极社交生活和良好健康的人比其他人更幸福。其他变量——如性别、家庭规模和年龄——可能不太容易形成假设。

查看数据

让我们从读取数据开始。下载数据并将其保存为本地文件ess.csv。然后,我们可以使用pandas模块来操作数据,并将其存储在一个名为ess的 Python 变量中:

import pandas as pd
ess = pd.read_csv('ess.csv')

记住,为了读取 CSV 文件,你必须确保它和 Python 程序运行的路径在同一目录下,或者需要修改之前代码中的'ess.csv',使其反映你存储 CSV 文件的准确路径。我们可以使用pandas数据框的shape属性来查看数据的行数和列数:

print(ess.shape)

输出应该是(44387, 534),这表示我们的数据集有 44,387 行(每一行代表一个受访者)和 534 列(每一列代表调查中的一个问题)。我们可以通过使用pandas模块的切片功能,进一步查看我们感兴趣的某些列。例如,以下是如何查看“happy”问题的前五个回答:

print(ess.loc[:,'happy'].head())

我们的数据集ess有 534 列,每一列代表调查中的一个问题。出于某些目的,我们可能希望一次性处理所有 534 列。在这里,我们只想查看happy这一列,而不是其他 533 列。这就是我们使用loc()函数的原因。在这里,loc()函数从pandas数据框中提取了名为happy的变量。换句话说,它只取出了这一列,并忽略了其他 533 列。然后,head()函数展示了该列的前五行数据。你可以看到前五个回答分别是55885。我们也可以对sclmeet变量做同样的操作:

print(ess.loc[:,'sclmeet'].head())

结果应该是64446happy的回答和sclmeet的回答会一一对应。例如,sclmeet的第 134 个元素是由与happy的第 134 个元素相同的受访者给出的回答。

ESS 工作人员努力从每位调查参与者那里收集完整的回应。然而,也有一些情况下,调查问题的回答缺失,可能是因为参与者拒绝回答或者不知道如何回答。在 ESS 数据集中,缺失的回应会被赋予一个高于实际回答范围的代码。例如,如果问题要求受访者在 1 到 10 的范围内选择一个数字,而受访者拒绝回答,ESS 将记录为 77。在我们的分析中,我们只考虑完整的回应,即没有缺失的感兴趣变量的回答。我们可以限制ess数据,只保留包含我们关心的完整回应的记录,如下所示:

ess = ess.loc[ess['sclmeet'] <= 10,:].copy()
ess = ess.loc[ess['rlgdgr'] <= 10,:].copy()
ess = ess.loc[ess['hhmmb'] <= 50,:].copy()
ess = ess.loc[ess['netusoft'] <= 5,:].copy()
ess = ess.loc[ess['agea'] <= 200,:].copy()
ess = ess.loc[ess['health'] <= 5,:].copy()
ess = ess.loc[ess['happy'] <= 10,:].copy()
ess = ess.loc[ess['eduyrs'] <= 100,:].copy().reset_index(drop=True)

划分我们的数据

我们可以通过多种方式使用这些数据来探索个人社交生活与幸福感之间的关系。最简单的方法之一是进行二元划分:我们将社交生活非常活跃的人的幸福感与社交生活较少活跃的人的幸福感进行比较(参见清单 9-1)。

import numpy as np
social = list(ess.loc[:,'sclmeet'])
happy = list(ess.loc[:,'happy'])
low_social_happiness = [hap for soc,hap in zip(social,happy) if soc <= 5]
high_social_happiness = [hap for soc,hap in zip(social,happy) if soc > 5]

meanlower = np.mean(low_social_happiness)
meanhigher = np.mean(high_social_happiness)

清单 9-1: 计算社交生活不活跃和活跃人群的平均幸福感水平

在清单 9-1 中,我们导入了numpy模块来计算平均值。我们通过从ess数据框中切片定义了两个新变量,socialhappy。然后,我们使用列表推导式来找到所有社交活动评分较低的人的幸福感(我们将其保存在变量low_social_happiness中),以及所有社交活动评分较高的人的幸福感(我们将其保存在变量high_social_happiness中)。最后,我们计算了不活跃社交人群的平均幸福感(meanlower)和高度活跃社交人群的平均幸福感(meanhigher)。如果你运行print(meanlower)print(meanhigher),你应该会看到,认为自己社交活跃的人,幸福感评分也略高于那些社交不活跃的人:社交活跃者的平均幸福感为7.8,而社交不活跃者的平均幸福感为7.2

我们可以画出一个简单的图示,展示我们刚刚做的事情,如图 9-2 所示。

图 _9-2

图 9-2: 一个简单的决策树,基于社交活动频率预测幸福感

这个简单二元分割的图已经开始类似于决策树。这不是巧合:在数据集上做二元分割并比较每一半的结果,正是决策树生成算法的核心过程。事实上,图 9-2 可以称之为一个决策树,尽管它只有一个分支节点。我们可以将图 9-2 作为一个非常简单的幸福感预测器:我们了解某人有多频繁外出社交。如果他们的sclmeet值为5或更低,那么我们可以预测他们的幸福感为7.2。如果高于5,则可以预测他们的幸福感为7.8。这不是一个完美的预测,但它是一个起点,而且比随机猜测更准确。

我们可以尝试使用决策树来得出关于各种特征和生活方式选择对幸福感的影响。例如,我们看到低社交幸福感和高社交幸福感之间的差异大约是 0.6,我们得出结论:将个人的社交活动水平从低提高到高,可能会使幸福感在 10 分制上提高大约 0.6。当然,尝试得出这类结论充满了困难。也许社交活动并不直接导致幸福感,反而是幸福感促使社交活动;或许快乐的人更容易心情愉悦,从而打电话给朋友并安排社交活动。区分相关性与因果关系超出了本章的讨论范围,但无论因果关系的方向如何,我们简单的决策树至少告诉了我们这个关联的事实,如果我们愿意,还可以进一步研究。正如漫画家 Randall Munroe 所说:“相关性并不意味着因果关系,但它确实用挑眉示意并偷偷做手势,嘴里嘀咕着‘看那边’。”

我们知道如何创建一个简单的具有两个分支的决策树。现在,我们只需要完善如何创建分支,然后生成更多分支,来构建一个更好、更完整的决策树。

更智能的分割

当我们比较社交活跃与不活跃人群的幸福感时,我们将5作为分割点,认为那些评分高于5的人拥有活跃的社交生活,而评分为5或以下的人则拥有不活跃的社交生活。我们选择5是因为它是从 1 到 10 的评分中一个自然的中点。然而,请记住,我们的目标是构建一个准确的幸福感预测器。与其根据直觉判断什么是自然的中点或什么看起来像是活跃的社交生活,最好是将二元分割点放在能够带来最佳准确度的位置。

在机器学习问题中,有几种不同的方式来衡量准确度。最自然的方式是求出我们所有错误的总和。在我们的例子中,我们关心的错误是我们对某人幸福感评分的预测与他们实际幸福感评分之间的差异。如果我们的决策树预测你的幸福感是6,但实际是8,那么该树在你的评分上的错误就是2。如果我们将某个群体中每个受访者的预测误差加起来,我们就可以得到一个误差总和,来衡量该决策树在预测该群体成员幸福感方面的准确度。我们越能将误差总和接近零,我们的树就越好(但请参见第 179 页的“过拟合问题”以了解重要的警告)。这个代码片段展示了一种简单的计算误差总和的方法:

lowererrors = [abs(lowhappy - meanlower) for lowhappy in low_social_happiness]
highererrors = [abs(highhappy - meanhigher) for highhappy in high_social_happiness]

total_error = sum(lowererrors) + sum(highererrors)

这段代码计算了所有受访者的预测误差总和。它定义了lowererrors,一个包含每个低社交受访者预测误差的列表,以及highererrors,一个包含每个高社交受访者预测误差的列表。注意,我们使用了绝对值,这样我们只加上非负数来计算误差总和。当我们运行这段代码时,我们发现总误差大约是60224。这个数字远大于零,但如果考虑到这是一个超过 40,000 名受访者的错误总和,而且我们用的是一个仅有两个分支的决策树进行幸福感预测,突然间这就不显得那么糟糕了。

我们可以尝试不同的切分点,看看是否能够改进误差。例如,我们可以将社交评分高于4的每个人归为高社交,将社交评分为4或以下的每个人归为低社交,然后比较得到的误差率。或者,我们也可以选择6作为切分点。为了获得最高的准确度,我们应该依次检查每一个可能的切分点,并选择导致最低误差的切分点。列表 9-2 包含了一个实现此功能的函数。

def get_splitpoint(allvalues,predictedvalues):
    lowest_error = float('inf')
    best_split = None
    best_lowermean = np.mean(predictedvalues)
    best_highermean = np.mean(predictedvalues)
    for pctl in range(0,100):
        split_candidate = np.percentile(allvalues, pctl)

        loweroutcomes = [outcome for value,outcome in zip(allvalues,predictedvalues) if \value <= split_candidate]
        higheroutcomes = [outcome for value,outcome in zip(allvalues,predictedvalues) if \value > split_candidate]

        if np.min([len(loweroutcomes),len(higheroutcomes)]) > 0:
            meanlower = np.mean(loweroutcomes)
            meanhigher = np.mean(higheroutcomes)

            lowererrors = [abs(outcome - meanlower) for outcome in loweroutcomes]
            highererrors = [abs(outcome - meanhigher) for outcome in higheroutcomes]

            total_error = sum(lowererrors) + sum(highererrors)

            if total_error < lowest_error:
                best_split = split_candidate
                lowest_error = total_error
                best_lowermean = meanlower
                best_highermean = meanhigher
    return(best_split,lowest_error,best_lowermean,best_highermean)

列表 9-2: 一个找到决策树分支点处最优切分点的函数

在这个函数中,我们使用一个名为pctl的变量(代表百分位数)来循环遍历从 0 到 100 的每一个数字。在循环的第一行,我们定义了一个新的split_candidate变量,它是数据的pctl-百分位数。之后,我们遵循在清单 9-2 中使用的相同过程。我们创建一个列表,其中包含sclmeet值小于或等于分割候选值的人的幸福感水平,以及sclmeet值大于分割候选值的人的幸福感水平,并检查使用该分割候选值时出现的误差。如果使用该分割候选值时的误差总和小于任何之前使用的分割候选值的误差总和,那么我们将best_split变量重新定义为等于split_candidate。当循环完成时,best_split变量就等于导致最高准确度的分割点。

我们可以对任何变量运行这个函数,例如以下示例中我们对hhmmb变量(记录受访者家庭成员数)运行该函数。

allvalues = list(ess.loc[:,'hhmmb'])
predictedvalues = list(ess.loc[:,'happy'])
print(get_splitpoint(allvalues,predictedvalues))

这里的输出显示了正确的分割点以及该分割点定义的各组的预测幸福感水平:

(1.0, 60860.029867951016, 6.839403436723225, 7.620055170794695)

我们解释这个输出的意思是,最佳的分割点是1.0;我们将受访者分为独自居住(一个家庭成员)和与他人同住(超过一个家庭成员)两组。我们还可以看到这两组的平均幸福感水平:分别约为6.847.62

选择分割变量

对于我们在数据中选择的任何变量,我们都可以找到最佳的分割点。然而,请记住,在像图 9-1 这样的决策树中,我们不仅仅是在为单个变量寻找分割点。我们会将男性和女性分开,将肥胖者和非肥胖者分开,将吸烟者和非吸烟者分开,依此类推。一个自然的问题是,我们如何知道在每个分支节点上应该选择哪个变量进行分割?我们可以重新排列图 9-1 中的节点,使得首先按照体重分割,其次按性别分割,或者在左分支上仅按照性别分割,或者根本不分割性别。决定在每个分支点上选择哪个变量进行分割是生成最佳决策树的关键部分,因此我们应该为这个过程编写代码。

我们将使用与获取最佳分割点相同的原则来决定最佳分割变量:最好的分割方式是导致最小误差的方式。为了确定这一点,我们需要遍历每个可用的变量,并检查是否在该变量上进行分割会导致最小的误差。然后,我们确定哪个变量导致了误差最小的分割。我们可以通过使用清单 9-3 来实现这一点。

def getsplit(data,variables,outcome_variable):
    best_var = ''
    lowest_error = float('inf')
    best_split = None
    predictedvalues = list(data.loc[:,outcome_variable])
 best_lowermean = -1
    best_highermean = -1
    for var in variables:
        allvalues = list(data.loc[:,var])
        splitted = get_splitpoint(allvalues,predictedvalues)

        if(splitted[1] < lowest_error):
            best_split = splitted[0]
            lowest_error = splitted[1]
            best_var = var
            best_lowermean = splitted[2]
            best_highermean = splitted[3]          

    generated_tree = [[best_var,float('-inf'),best_split,best_lowermean],[best_var,best_split,\    float('inf'),best_highermean]]

    return(generated_tree)

清单 9-3: 一个遍历每个变量并找到最佳分割变量的函数

在清单 9-3 中,我们定义了一个包含for循环的函数,该函数遍历一个变量列表中的所有变量。对于这些变量中的每一个,它都会通过调用get_splitpoint()函数来找到最佳切分点。每个变量在最佳切分点处的分割将导致我们预测的某个误差总和。如果某个变量的误差总和低于我们之前考虑的任何变量,我们将把该变量的名称存储为best_var。在遍历完每个变量名后,它找到了误差总和最低的变量,并将其存储在best_var中。我们可以在除了sclmeet之外的变量集上运行这段代码,方法如下:

variables = ['rlgdgr','hhmmb','netusoft','agea','eduyrs']
outcome_variable = 'happy'
print(getsplit(ess,variables,outcome_variable))

在这种情况下,我们会看到以下输出:

[['netusoft', -inf, 4.0, 7.041597337770383], ['netusoft', 4.0, inf, 7.73042471042471]]

我们的getsplit()函数输出了一个非常简单的“树形”结构,形式为一个嵌套列表。这个树形结构只有两条分支。第一条分支由第一个嵌套列表表示,第二条分支由第二个嵌套列表表示。两个嵌套列表的每个元素都告诉我们它们各自分支的某些信息。第一个列表告诉我们,我们正在查看一个基于受访者netusoft(互联网使用频率)值的分支。具体来说,第一个分支对应的是那些netusoft值在-inf 和 4.0之间的人,其中inf代表无限大。换句话说,位于这个分支的人在 5 分制中报告他们的互联网使用频率为 4 或更低。每个列表的最后一个元素显示了一个估计的幸福感评分:对于那些互联网使用频率不高的人,幸福感评分大约是7.0。我们可以在图 9-3 中绘制这个简单的树形图。

Figure_9-3

图 9-3: 我们第一次调用getsplit()函数生成的树形图

到目前为止,我们的函数告诉我们,互联网使用较少的人报告的幸福感较低,平均幸福感评分大约为7.0,而报告最高互联网使用频率的人则报告的幸福感水平平均为7.7。再次强调,我们需要小心从这个单一事实得出结论:互联网使用可能并不是幸福感的真正驱动因素,它可能只是与幸福感水平相关,因为它与年龄、财富、健康、教育等因素有着强相关性。仅凭机器学习通常无法让我们确定复杂的因果关系,但正如它在图 9-3 中所示,它使我们能够做出准确的预测。

添加深度

我们已经完成了每个分支点上做出最佳切分的所有步骤,并生成了一个包含两个分支的树。接下来,我们需要让树继续生长,而不仅仅是一个分支节点和两个终端节点。请查看图 9-1,注意到它有多个分支。它的深度为三,因为在到达最终诊断之前,你需要遵循多达三个连续的分支。我们决策树生成过程的最后一步是指定我们希望达到的深度,并建立新的分支直到达到该深度。我们通过在清单 9-4 中显示的 getsplit() 函数中进行的修改来实现这一点。

maxdepth = 3
def getsplit(depth,data,variables,outcome_variable):
 `--snip--`
    generated_tree = [[best_var,float('-inf'),best_split,[]],[best_var,\best_split,float('inf'),[]]]

    if depth < maxdepth:
        splitdata1=data.loc[data[best_var] <= best_split,:]
        splitdata2=data.loc[data[best_var] > best_split,:]
        if len(splitdata1.index) > 10 and len(splitdata2.index) > 10:
            generated_tree[0][3] = getsplit(depth + 1,splitdata1,variables,outcome_variable)
            generated_tree[1][3] = getsplit(depth + 1,splitdata2,variables,outcome_variable)
        else:
            depth = maxdepth + 1
            generated_tree[0][3] = best_lowermean
            generated_tree[1][3] = best_highermean
 else:
        generated_tree[0][3] = best_lowermean
        generated_tree[1][3] = best_highermean
    return(generated_tree)

清单 9-4: 一个可以生成指定深度树的函数

在这个更新后的函数中,当我们定义 generated_tree 变量时,我们现在添加空列表,而不是均值。我们只在终端节点插入均值,但如果我们想要一个更深的树,我们需要在每个分支内插入其他分支(这就是空列表所包含的内容)。我们还在函数的末尾添加了一个带有长代码段的 if 语句。如果当前分支的深度小于我们希望在树中达到的最大深度,这个部分将递归地调用 get_split() 函数来填充其中的另一个分支。这个过程会一直持续,直到达到最大深度。

我们可以运行这段代码来找到导致我们的数据集中幸福预测误差最小的决策树:

variables = ['rlgdgr','hhmmb','netusoft','agea','eduyrs']
outcome_variable = 'happy'
maxdepth = 2
print(getsplit(0,ess,variables,outcome_variable))

当我们这样做时,应该得到以下输出,表示一个深度为二的树:

[['netusoft', -inf, 4.0, [['hhmmb', -inf, 4.0, [['agea', -inf, 15.0, 8.035714285714286], ['agea', 15.0, inf, 6.997666564322997]]], ['hhmmb', 4.0, inf, [['eduyrs', -inf, 11.0, 7.263969171483622], ['eduyrs', 11.0, inf, 8.0]]]]], ['netusoft', 4.0, inf, [['hhmmb', -inf, 1.0, [['agea', -inf, 66.0, 7.135361428970136], ['agea', 66.0, inf, 7.621993127147766]]], ['hhmmb', 1.0, inf, [['rlgdgr', -inf, 5.0, 7.743893678160919], ['rlgdgr', 5.0, inf, 7.9873320537428025]]]]]]

清单 9-5:使用嵌套列表表示决策树

你现在看到的是一个相互嵌套的列表集合。这些嵌套列表代表了我们完整的决策树,尽管它不像图 9-1 那样容易阅读。在每个嵌套级别,我们都会看到一个变量名及其范围,就像我们在图 9-3 中看到的简单树一样。第一层嵌套展示了我们在图 9-3 中看到的相同分支:一个表示 netusoft 值小于或等于 4.0 的响应者的分支。接下来的列表,嵌套在第一个列表中,开始于 hhmmb, -inf, 4.0。这是我们决策树的另一个分支,它从我们刚才检查过的分支分出来,包含了那些自报家庭规模为 4 或更少的人。如果我们画出到目前为止在嵌套列表中看到的决策树部分,它将类似于图 9-4。

我们可以继续查看嵌套列表,以便在决策树中填充更多分支。嵌套在其他列表中的列表对应于树中较低的分支。一个嵌套列表是从包含它的列表分支出来的。终端节点不像其他节点那样包含更多嵌套列表,而是包含一个估算的幸福评分。

Figure_9-4

图 9-4: 决策树分支的选择

我们已经成功地创建了一个决策树,使我们能够以相对较低的误差预测幸福水平。你可以检查输出,以查看幸福感的相对决定因素,以及与每个分支相关的幸福水平。

我们可以继续探索决策树和数据集的更多可能性。例如,我们可以尝试运行相同的代码,但使用不同或更大的变量集。我们还可以创建一个具有不同最大深度的树。下面是运行代码时使用不同变量列表和深度的示例:

variables = ['sclmeet','rlgdgr','hhmmb','netusoft','agea','eduyrs','health']
outcome_variable = 'happy'
maxdepth = 3
print(getsplit(0,ess,variables,outcome_variable))

当我们使用这些参数运行时,我们发现了一个非常不同的决策树。你可以在这里看到输出:

[['health', -inf, 2.0, [['sclmeet', -inf, 4.0, [['health', -inf, 1.0, [['rlgdgr', -inf, 9.0, 7.9919636617749825], ['rlgdgr', 9.0, inf, 8.713414634146341]]], ['health', 1.0, inf, [['netusoft', -inf, 4.0, 7.195121951219512], ['netusoft', 4.0, inf, 7.565659008464329]]]]], ['sclmeet', 4.0, inf, [['eduyrs', -inf, 25.0, [['eduyrs', -inf, 8.0, 7.9411764705882355], ['eduyrs', 8.0, inf, 7.999169779991698]]], ['eduyrs', 25.0, inf, [['hhmmb', -inf, 1.0, 7.297872340425532], ['hhmmb', 1.0, inf, 7.9603174603174605]]]]]]], ['health', 2.0, inf, [['sclmeet', -inf, 3.0, [['health', -inf, 3.0, [['sclmeet', -inf, 2.0, 6.049427365883062], ['sclmeet', 2.0, inf, 6.70435393258427]]], ['health', 3.0, inf, [['sclmeet', -inf, 1.0, 4.135036496350365], ['sclmeet', 1.0, inf, 5.407051282051282]]]]], ['sclmeet', 3.0, inf, [['health', -inf, 4.0, [['rlgdgr', -inf, 9.0, 6.992227707173616], ['rlgdgr', 9.0, inf, 7.434662998624484]]], ['health', 4.0, inf, [['hhmmb', -inf, 1.0, 4.948717948717949], ['hhmmb', 1.0, inf, 6.132075471698113]]]]]]]]

特别需要注意的是,第一分支是根据变量health而不是netusoft来拆分的。较低深度的其他分支则在不同的位置和不同的变量上拆分。决策树方法的灵活性意味着,即使使用相同的数据集和相同的最终目标,两个研究人员也可能根据他们使用的参数和处理数据的决策得出非常不同的结论。这是机器学习方法的一个常见特征,也是它们如此难以掌握的一部分原因。

评估我们的决策树

为了生成我们的决策树,我们比较了每个潜在拆分点和每个潜在拆分变量的误差率,并始终选择导致特定分支最低误差率的变量和拆分点。现在我们已经成功生成了决策树,进行类似的误差计算是有意义的,不仅仅是针对特定分支,而是针对整个树。评估整个树的误差率可以帮助我们了解我们在完成预测任务方面的表现,以及我们在未来任务中的表现如何(例如,未来因胸痛就诊的病人)。

如果你查看我们到目前为止生成的决策树输出,你会注意到嵌套的列表有点难以阅读,而且没有自然的方式来确定我们预测某人幸福感的水平,除非费力地阅读嵌套分支并找到正确的终端节点。编写代码来根据我们从 ESS 答案中了解到的内容,确定一个人预测的幸福水平会对我们有所帮助。以下函数get_prediction()可以为我们完成这个任务:

def get_prediction(observation,tree):
    j = 0
    keepgoing = True
    prediction = - 1
    while(keepgoing):
        j = j + 1
        variable_tocheck = tree[0][0]
        bound1 = tree[0][1]
        bound2 = tree[0][2]
        bound3 = tree[1][2]
        if observation.loc[variable_tocheck] < bound2:
            tree = tree[0][3]
        else:
            tree = tree[1][3]
        if isinstance(tree,float):
            keepgoing = False
            prediction = tree
    return(prediction)

接下来,我们可以创建一个循环,遍历数据集的任何部分,并获取该部分的任何树的幸福预测值。在这个例子中,我们尝试使用最大深度为四的树:

predictions=[]
outcome_variable = 'happy'
maxdepth = 4
thetree = getsplit(0,ess,variables,outcome_variable)
for k in range(0,30):
    observation = ess.loc[k,:]
    predictions.append(get_prediction(observation,thetree))

print(predictions)

这段代码只是反复调用get_prediction()函数,并将结果附加到我们的预测列表中。在这个例子中,我们仅对前 30 个观测值进行了预测。

最后,我们可以检查这些预测与实际幸福评分的比较,以了解我们的总误差率。这里,我们将对整个数据集进行预测,并计算预测值与记录的幸福评分之间的绝对差异:

predictions = []

for k in range(0,len(ess.index)):
    observation = ess.loc[k,:]
    predictions.append(get_prediction(observation,thetree))

ess.loc[:,'predicted'] = predictions
errors = abs(ess.loc[:,'predicted'] - ess.loc[:,'happy'])

print(np.mean(errors))

当我们运行这个模型时,我们发现决策树的预测平均误差为1.369。这个误差高于零,但低于如果我们使用更差的预测方法时可能得到的误差。到目前为止,我们的决策树似乎做出了相对不错的预测。

过拟合问题

你可能已经注意到,我们评估决策树的方法在一个非常重要的方面与现实中的预测方式不太一样。回想一下我们做了什么:我们使用了完整的调查响应者数据集来生成决策树,然后又用同一组响应者来判断树的预测准确性。但预测那些已经完成调查的响应者的幸福评分是多余的——他们已经参与了调查,所以我们已经知道他们的幸福评分,根本不需要再预测!这就像获取一组过去心脏病患者的数据,仔细研究他们的治疗前症状,并建立一个机器学习模型来判断某人上周是否发生了心脏病发作。到现在,是否发生了心脏病发作已经很明显,比通过查看他们的初步分诊诊断数据要清楚得多。预测过去很容易,但记住,真正的预测总是关于未来的。正如沃顿商学院教授 Joseph Simmons 所说,“历史是关于发生了什么,科学是关于接下来会发生什么(next)。”

你可能认为这不是一个严重的问题。毕竟,如果我们能做出一个对上周心脏病患者有效的决策树,那么假设它对下周的心脏病患者也会有效是合理的。这在某种程度上是对的。然而,有一个危险是,如果我们不小心,我们可能会遇到一个常见的、可怕的陷阱,叫做过拟合,即机器学习模型在用于训练它们的数据集上(比如过去的数据)表现出非常低的误差率,但在其他数据上(比如真正重要的、来自未来的数据)却意外地出现高误差率。

以心脏病预测为例。如果我们观察一个急诊室几天,也许碰巧每个穿蓝色衬衫的病人都在遭受心脏病,而每个穿绿色衬衫的病人都很健康。一个包含衬衫颜色作为预测变量的决策树模型会捕捉到这个模式,并将其作为分支变量,因为它在我们的观察中具有很高的诊断准确度。然而,如果我们随后用这个决策树预测另一个医院或未来某一天的心脏病,我们会发现预测往往是错误的,因为许多穿绿色衬衫的人也会得心脏病,而许多穿蓝色衬衫的人并不会。我们用来构建决策树的观察被称为样本内观察,而我们随后测试模型的观察,这些观察不属于决策树生成过程的部分,被称为样本外观察。过拟合意味着,通过过于热衷于在样本内观察的预测中寻求低错误率,我们导致了决策树模型在预测样本外观察时错误率异常高。

过拟合是所有机器学习应用中的一个严重问题,它甚至会绊倒最优秀的机器学习从业者。为了避免过拟合,我们将采取一个重要步骤,使我们的决策树构建过程更接近真实生活中的预测场景。

请记住,现实生活中的预测是关于未来的,但当我们构建决策树时,我们只能从过去获取数据。我们不可能从未来获取数据,因此我们将数据集分为两个子集:一个是训练集,我们仅用它来构建决策树;另一个是测试集,我们仅用它来检查决策树的准确性。我们的测试集来自过去,就像其他数据一样,但我们将其视为未来;我们不使用它来创建决策树(就像它还没有发生一样),但在完全构建好决策树后,我们会使用它来测试决策树的准确性(就像我们是在未来得到它一样)。

通过进行这个简单的训练/测试划分,我们使得决策树生成过程更类似于预测未知未来的真实问题;测试集就像是一个模拟的未来。我们在测试集上找到的错误率给了我们对实际未来中错误率的合理预期。如果我们的训练集错误率非常低,而测试集错误率非常高,那么我们就知道我们犯了过拟合的错误。

我们可以这样定义训练集和测试集:

import numpy as np
np.random.seed(518)
ess_shuffled = ess.reindex(np.random.permutation(ess.index)).reset_index(drop = True)
training_data = ess_shuffled.loc[0:37000,:]
test_data = ess_shuffled.loc[37001:,:].reset_index(drop = True)

在这个片段中,我们使用了numpy模块来打乱数据——换句话说,保持所有数据,但随机移动行。我们通过pandas模块的reindex()方法完成了这个操作。重新索引是通过随机打乱行号来完成的,这个行号通过使用numpy模块的排列功能获得。打乱数据集后,我们选择前 37,000 行作为训练数据集,剩下的行作为测试数据集。命令np.random.seed(518)并不是必须的,但如果你运行它,你将确保获得与我们在这里展示的相同的伪随机结果。

在定义了训练数据和测试数据之后,我们仅使用训练数据生成了一个决策树:

thetree = getsplit(0,training_data,variables,outcome_variable)

最后,我们检查在测试数据上的平均误差率,测试数据没有用于训练我们的决策树:

predictions = []
for k in range(0,len(test_data.index)):
    observation = test_data.loc[k,:]
    predictions.append(get_prediction(observation,thetree))

test_data.loc[:,'predicted'] = predictions
errors = abs(test_data.loc[:,'predicted'] - test_data.loc[:,'happy'])
print(np.mean(errors))

我们发现测试数据的平均误差率是1.371。这个误差率略高于我们在使用整个数据集进行训练和测试时得到的1.369的误差率。这表明我们的模型并没有遭遇过拟合:它在预测过去的数据时表现良好,在预测未来时也几乎同样准确。通常,我们得到的不是这个好消息,而是坏消息——我们的模型比我们预期的差——但是得到这个消息是好事,因为我们可以在开始在实际场景中使用模型之前做出改进。在这种情况下,在我们的模型准备好在实际生活中部署之前,我们需要对其进行改进,以使其在测试集上的误差率最小化。

改进与完善

你可能会发现你创建的决策树准确度比你预期的要低。例如,你的准确度可能比应有的还要差,因为你犯了过拟合的错误。解决过拟合问题的许多策略都归结为某种形式的简化,因为简单的机器学习模型比复杂模型更不容易遭遇过拟合。

简化决策树模型的第一种也是最简单的方法是限制其最大深度;由于深度是一个我们可以在一行代码中重新定义的变量,因此这很容易实现。为了确定合适的深度,我们需要检查不同深度下在样本外数据上的误差率。如果深度过大,可能会因为过拟合而导致高误差。如果深度过小,则可能因为欠拟合而导致高误差。你可以将欠拟合看作是过拟合的镜像。过拟合是指试图从任意或无关的模式中学习——换句话说,就是从训练数据中的噪声中“学得太多”,比如是否某人穿着绿色衬衫。欠拟合则是没有学到足够的东西——创建的模型忽视了数据中的关键模式,比如是否某人肥胖或使用烟草。

过拟合往往是由模型变量过多或深度过大造成的,而欠拟合则通常是由模型变量过少或深度过浅造成的。就像算法设计中的许多情况一样,理想的状态是在过高和过低之间找到一个平衡点。为机器学习模型选择合适的参数,包括决策树的深度,通常被称为调参,因为调节吉他或小提琴弦的松紧也依赖于在音高过高和过低之间找到一个平衡点。

简化我们的决策树模型的另一种方法是进行所谓的剪枝。为此,我们将决策树生长到其最大深度,然后找到我们可以从树中移除的分支,而不会显著增加我们的错误率。

另一个值得提及的改进是使用不同的度量方法来选择正确的分裂点和分裂变量。在本章中,我们介绍了使用分类错误总和来决定分裂点的位置的想法;正确的分裂点是能够最小化我们的错误总和的点。但实际上,还有其他方法来决定决策树的正确分裂点,包括基尼不纯度、熵、信息增益和方差减少。在实践中,这些其他度量,尤其是基尼不纯度和信息增益,几乎总是比分类错误率更常用,因为一些数学特性使得它们在很多情况下更有效。可以尝试不同的方式来选择分裂点和分裂变量,以找出最适合你的数据和决策问题的方式。

我们在机器学习中所做的一切,都是为了让我们能够对新数据做出准确的预测。当你试图改善机器学习模型时,你总是可以通过检查它在测试数据上的错误率提升情况来判断某个操作是否值得尝试。而且,尽管大胆尝试,任何能够改善测试数据上错误率的做法,都值得尝试。

随机森林

决策树有用且有价值,但在专业人士眼中,它们并不被视为最好的机器学习方法。这部分是因为它们容易过拟合且错误率相对较高,另一方面也因为出现了一种叫做随机森林的方法,最近变得非常流行,并且相较于决策树,提供了明确的性能提升。

正如其名称所示,随机森林模型由一组决策树模型组成。随机森林中的每棵决策树都依赖于某种随机化。通过随机化,我们得到了一片多样化的森林,包含许多树,而不是一棵树的重复。这种随机化发生在两个地方。首先,训练数据集是随机化的:每棵树的构建只考虑训练集的一个子集,而这个子集是随机选择的,每棵树的子集都不同。(测试集在过程开始时随机选择,但不会在每棵树构建时重新随机化或重新选择。)其次,用于构建树的变量也是随机化的:每棵树只使用完整变量集中的一个子集,而且这个子集每次可能都不同。

在构建了这些不同的随机化决策树之后,我们得到了一个完整的随机森林。为了对某个特定观察值做出预测,我们需要找出每棵决策树的预测结果,然后取所有决策树预测结果的平均值。由于决策树在数据和变量上都是随机化的,取其平均值有助于避免过拟合问题,并且通常能带来更准确的预测。

本章中的代码通过直接操作数据集、列表和循环,从“零基础”创建了决策树。当你在未来使用决策树和随机森林时,可以依赖现有的 Python 模块,这些模块为你处理了大部分繁重的工作。但不要让这些模块成为依赖:如果你能理解这些重要算法的每一步,并能够从零开始编写代码,那么你在机器学习的实践中将会更加高效。

总结

本章介绍了机器学习,并探讨了决策树学习,这是一种基础、简单且有用的机器学习方法。决策树构成了一种算法,决策树的生成本身也是一种算法,因此本章包含了一个生成算法的算法。通过学习决策树和随机森林的基本理念,你已经迈出了成为机器学习专家的重要一步。本章所获得的知识将成为你学习其他机器学习算法(包括神经网络等高级算法)的坚实基础。所有机器学习方法都试图完成我们在此尝试的任务:基于数据集中的模式进行预测。在下一章,我们将探讨人工智能,这是我们冒险旅程中最先进的课题之一。

第十章:人工智能

在本书中,我们已经注意到人类大脑执行惊人任务的能力,无论是接球、校对文本,还是判断某人是否发生心脏病发作。我们探讨了如何将这些能力转化为算法及其中的挑战。在本章中,我们将再次面对这些挑战,并构建一个人工智能(AI)算法。我们将讨论的 AI 算法不仅适用于某个特定任务,比如接球,还能应用于广泛的竞争场景。这种广泛的适应性是人工智能让人兴奋的地方——就像人类可以在一生中学习新技能一样,最好的 AI 也可以在只需最少的重新配置的情况下,应用到它从未见过的领域。

人工智能这一术语常常让人感觉神秘且高度先进。有人认为人工智能让计算机能够像人类一样思考、感受并体验意识的思维;是否计算机最终能够做到这一点是一个开放的、困难的问题,远远超出了本章的讨论范围。我们将要构建的人工智能要简单得多,能够玩好一款游戏,但并不能写出真挚的爱情诗,也无法体验沮丧或欲望(就我所知!)。

我们的 AI 将能够玩点与框,这是一款简单但不平凡的全球性游戏。我们将从绘制游戏棋盘开始。接着,我们会构建一些函数来在游戏进行中记录分数。然后,我们将生成游戏树,表示在特定游戏中可以进行的所有可能的动作组合。最后,我们将引入极小极大算法,这是一种优雅的方法,可以在几行代码中实现 AI。

La Pipopipette

点与框由法国数学家埃杜阿尔·卢卡斯发明,他将其命名为la pipopipette。游戏开始时使用的是格子,也就是点的网格,如图 10-1 所示。

figure_10_1

图 10-1: 一个格子,我们可以用它作为点与框的游戏棋盘

这个格子通常是矩形的,但也可以是任何形状。两名玩家轮流对战。在每一轮中,玩家可以画出一条连接格子中两个相邻点的线段。如果他们用不同的颜色画线段,我们可以看到谁画了哪条线,尽管这并不是必须的。随着游戏的进行,线段将填满格子,直到每条连接相邻点的线段都被画出。你可以在图 10-2 中看到一个正在进行的示例游戏。

点与方框游戏中的玩家目标是绘制完成方格的线段。在 图 10-2 中,你可以看到在游戏棋盘的左下角,已经完成了一个方格。绘制了完成这个方格的线段的玩家将因此得一分。在右上角,你可以看到另一个方格的三条边已经绘制出来。现在是玩家一的回合,如果他们利用这一回合绘制从 (4,4) 到 (4,3) 的线段,他们将获得一分。如果他们选择绘制另一条线段,例如从 (4,1) 到 (5,1),那么玩家二就有机会完成这个方格并获得一分。玩家仅在完成棋盘上最小的方格时才能得分:即边长为 1 的方格。当所有线段填满网格时,得分最多的玩家获胜。游戏还有一些变种,包括不同的棋盘形状和更高级的规则,但我们将在本章中构建的简单 AI 将适用于我们这里描述的规则。

figure_10_2

图 10-2: 一个正在进行的点与方框游戏

绘制棋盘

虽然对我们的算法目的来说并非严格必要,但绘制棋盘可以使我们更容易可视化我们正在讨论的思想。一个非常简单的绘图函数可以通过遍历 x 和 y 坐标,并使用 Python 中 matplotlib 模块的 plot() 函数,绘制一个 n×n 的网格:

import matplotlib.pyplot as plt
from matplotlib import collections as mc
def drawlattice(n,name):
    for i in range(1,n + 1):
        for j in range(1,n + 1):
            plt.plot(i,j,'o',c = 'black')
    plt.savefig(name)

在这段代码中,n 表示我们网格每一边的大小,我们使用 name 参数指定我们想要保存输出的文件路径。c = 'black' 参数指定网格中点的颜色。我们可以用以下命令创建一个 5×5 的黑色网格并保存它:

drawlattice(5,'lattice.png')

这正是用来创建 图 10-1 的命令。

表示游戏

由于点与方框游戏由一系列按顺序绘制的线段组成,我们可以将游戏记录为一个按顺序排列的线条列表。就像我们在前几章中所做的那样,我们可以将一条线(一个回合)表示为一个包含两个有序对(线段的两端)的列表。例如,我们可以用以下列表表示从 (1,2) 到 (1,1) 的线段:

[(1,2),(1,1)]

一个游戏将是这样一组按顺序排列的线条,例如以下示例:

game = [[(1,2),(1,1)],[(3,3),(4,3)],[(1,5),(2,5)],[(1,2),(2,2)],[(2,2),(2,1)],[(1,1),(2,1)], \[(3,4),(3,3)],[(3,4),(4,4)]]

这个游戏是 图 10-2 中展示的游戏。我们可以看出它仍在进行中,因为并非所有可能的线段都已绘制出来以填充网格。

我们可以在 drawlattice() 函数中添加内容,创建一个 drawgame() 函数。这个函数应当绘制游戏棋盘上的点以及至今为止在游戏中绘制的所有线段。 清单 10-1 中的函数将完成这一任务。

def drawgame(n,name,game):
    colors2 = []
    for k in range(0,len(game)):
        if k%2 == 0:
            colors2.append('red')
        else:
            colors2.append('blue')   
    lc = mc.LineCollection(game, colors = colors2, linewidths = 2)
    fig, ax = plt.subplots()
    for i in range(1,n + 1):
        for j in range(1,n + 1):
            plt.plot(i,j,'o',c = 'black')
    ax.add_collection(lc)
    ax.autoscale()
    ax.margins(0.1)
    plt.savefig(name)

清单 10-1: 一个绘制点与方框游戏棋盘的函数

这个函数接受nname作为参数,和drawlattice()一样。它还包括我们在drawlattice()中用来绘制格点的完全相同的嵌套循环。你可以看到的第一个新增内容是colors2列表,它最初为空,我们用它来填充我们将要绘制的线段的颜色。在“点和框”游戏中,玩家轮流进行,因此我们会交替为线段分配颜色——在这种情况下,第一位玩家的线段为红色,第二位玩家的线段为蓝色。colors2列表定义后的for循环将其填充为交替的'red''blue'实例,直到颜色分配的数量与游戏中的步数一样多。我们添加的其他代码行创建了一组游戏步数的线段,并像我们在前几章中一样绘制它们。

我们可以像下面这样用一行代码调用我们的drawgame()函数:

drawgame(5,'gameinprogress.png',game)

这正是我们创建图 10-2 的方式。

计分游戏

接下来,我们将创建一个可以记录“点和框”游戏得分的函数。我们从一个函数开始,该函数可以接受任何给定的游戏并找到已经完成的方块,然后我们创建一个计算得分的函数。我们的函数通过遍历游戏中的每一条线段来统计完成的方块。如果某条线是水平线,我们通过检查其下方平行的线是否也在游戏中绘制,以及该方块的左右两条边是否也已绘制来判断它是否是一个完全绘制的方块。列表 10-2 中的函数实现了这一点:

def squarefinder(game):
    countofsquares = 0
    for line in game:
        parallel = False
        left=False
        right=False
        if line[0][1]==line[1][1]:
            if [(line[0][0],line[0][1]-1),(line[1][0],line[1][1] - 1)] in game:
                parallel=True
            if [(line[0][0],line[0][1]),(line[1][0]-1,line[1][1] - 1)] in game:
                left=True           
            if [(line[0][0]+1,line[0][1]),(line[1][0],line[1][1] - 1)] in game:
                right=True  
 if parallel and left and right:
                countofsquares += 1
    return(countofsquares)

列表 10-2: 一个计算“点和框”游戏棋盘上方块数量的函数

你可以看到,该函数返回countofsquares的值,我们在函数开始时将其初始化为0。该函数的for循环遍历游戏中的每一条线段。我们一开始假设该线下方的平行线以及连接这些平行线的左右线都还没有在游戏中绘制。如果某条线是水平线,我们检查这些平行线、左右线是否存在。如果我们检查的方块的四条线都出现在游戏中,那么我们将countofsquares变量加 1。通过这种方式,countofsquares记录了迄今为止游戏中完全绘制的方块总数。

现在我们可以编写一个简短的函数来计算游戏的得分。得分将以一个包含两个元素的列表记录,如[2,1]。得分列表的第一个元素代表第一位玩家的得分,第二个元素代表第二位玩家的得分。列表 10-3 展示了我们的得分函数。

def score(game):
    score = [0,0]
    progress = []
    squares = 0
    for line in game:
        progress.append(line)
        newsquares = squarefinder(progress)
        if newsquares > squares:
            if len(progress)%2 == 0:
                score[1] = score[1] + 1
            else:
                score[0] = score[0] + 1
        squares=newsquares
    return(score)

列表 10-3: 一个计算正在进行的点线方块游戏得分的函数

我们的得分函数会按顺序遍历游戏中的每个线段,考虑每个回合所画的线段组成的部分游戏。如果在某个部分游戏中,绘制的方块数比上一个回合绘制的方块数多,那么我们就知道,当前回合的玩家得分了,我们将他们的得分加 1。你可以运行print(score(game))来查看图 10-2 中展示的游戏得分。

游戏树与如何赢得游戏

现在你已经了解了如何绘制和评分点线方块游戏,我们来考虑如何赢得这场游戏。你可能并不特别关心点线方块作为一款游戏,但赢得这场游戏的方式与赢得国际象棋、跳棋或井字游戏的方法是相同的,一个可以帮助你赢得所有这些游戏的算法,也可以为你提供一种全新的方式来思考你在生活中遇到的每一种竞争情境。制胜策略的本质,实际上就是系统地分析我们当前行动的未来后果,并选择能够带来最佳未来的行动。这听起来可能有些套话,但我们实现这一点的方式将依赖于小心、系统的分析;这可以呈现为一棵树,类似于我们在第九章中构建的树。

考虑图 10-3 中展示的可能的未来结果。

Figure_10-3

图 10-3: 我们游戏的一些可能延续的树形图

我们从树的顶部开始,考虑当前的情况:我们落后 0-1,现在轮到我们走棋。我们考虑的一步是左支路中的一步:从(4,4)到(4,3)画一条线。这一步将完成一个方块,并为我们获得 1 分。不论对手走什么棋(见图 10-3 中左下角两个分支列出的可能性),在对手下一步后,比赛将会平局。相反,如果我们在当前回合选择从(1,3)到(2,3)画一条线,如图 10-3 右支路所示,对手接下来将有两个选择:从(4,4)到(4,3)画线并完成一个方块得 1 分,或者画另一条线,如连接(3,1)和(4,1),并保持比分为 0-1。

考虑这些可能性,在两步内,游戏的得分可能是三种不同的结果:1-1,0-2,或者 0-1。在这棵树中,很明显我们应该选择左支路,因为从左支路发展出来的每一个可能性都会比右支路发展出来的可能性为我们带来更好的得分。这种推理方式正是我们的 AI 决策最佳走法的精髓。它将构建一个游戏树,检查游戏树中所有终端节点的结果,然后通过简单的递归推理,决定采取什么行动,依据这一决策将开启的可能未来。

你可能注意到,图 10-3 中的游戏树显得非常不完整。看起来似乎只有两种可能的移动(左分支和右分支),而在每一次这些可能的移动之后,对方也只有两种可能的移动。当然,这是不正确的;两位玩家都有许多选择。请记住,他们可以连接格子中任何两个相邻的点。真正表示我们游戏中这个时刻的游戏树应该有许多分支,每个玩家每个可能的移动都会对应一个分支。这在树的每一层都是如此:不仅我有很多可以选择的移动,我的对手也有,而每一个这些移动都会在游戏树中的每一个可操作的点上有一个分支。只有在游戏接近尾声时,当几乎所有线段已经绘制完成,可能的移动数才会减少到两个和一个。我们没有在图 10-3 中画出所有分支,是因为页面空间不够——我们只有空间展示了几种移动,仅仅是为了说明游戏树的概念和我们的思考过程。

你可以想象一个游戏树,它可以扩展到任何可能的深度——我们不仅要考虑我们的移动和对方的反应,还要考虑我们对该反应的回应,以及对方对这个回应的反应,以此类推,直到我们想要继续构建游戏树为止。

构建我们的树

我们这里构建的游戏树在几个重要方面与第九章中的决策树不同。最重要的区别是目标:决策树通过特征进行分类和预测,而游戏树则简单地描述了所有可能的未来。由于目标不同,因此我们的构建方法也会有所不同。请记得在第九章中,我们需要选择一个变量和一个分裂点来决定树中的每一条分支。而在这里,知道接下来会有哪些分支很容易,因为对于每一个可能的移动,都会有恰好一条分支。我们所需要做的,就是生成一个游戏中所有可能移动的列表。我们可以通过几个嵌套循环来实现这一点,循环会考虑格子中每一对点之间的所有可能连接:

allpossible = []

gamesize = 5

for i in range(1,gamesize + 1):
    for j in range(2,gamesize + 1):
        allpossible.append([(i,j),(i,j - 1)])

for i in range(1,gamesize):
    for j in range(1,gamesize + 1):
        allpossible.append([(i,j),(i + 1,j)])

这段代码首先定义了一个空的列表,名为allpossible,以及一个gamesize变量,表示我们格子中每一边的长度。接着,我们有两个循环。第一个循环是为了将垂直移动添加到我们的可能移动列表中。注意,对于每一个可能的ij值,这个循环将[(i,j),(i,j - 1)]表示的移动添加到可能的移动列表中。这将始终是一个垂直线段。我们的第二个循环类似,不过对于每一组可能的ij值,它会将水平移动[(i,j),(i + 1,j)]添加到可能的移动列表中。最终,我们的allpossible列表将包含所有可能的移动。

如果你考虑到一场正在进行的游戏,例如图 10-2 中展示的游戏,你会意识到并非每个移动总是可行的。如果某个玩家在游戏中已经执行了某个特定的移动,那么接下来的任何玩家都不能再执行这个相同的移动。我们需要一种方法来从所有可能的移动列表中移除那些已经执行过的移动,从而得到一个仅包含某个特定进行中的游戏中仍然可能的所有移动的列表。这很容易实现:

for move in allpossible:
    if move in game:
        allpossible.remove(move)

正如你所看到的,我们遍历了所有可能的移动列表中的每一个移动,如果某个移动已经被执行过,我们就将它从列表中移除。最终,我们得到的列表只包含当前游戏中可能的移动。你可以运行print(allpossible)来查看所有这些移动,并检查它们是否正确。

现在我们已经有了每一个可能移动的列表,我们可以构建游戏树。我们将记录游戏树作为一个嵌套的移动列表。记住,每个移动可以记录为有序对的列表,例如[(4,4),(4,3)],这是图 10-3 中左分支的第一个移动。如果我们想表示一个只包含图 10-3 中前两个移动的树,我们可以这样写:

simple_tree = [[(4,4),(4,3)],[(1,3),(2,3)]]

这棵树只包含两个移动:我们在图 10-3 中考虑执行的当前状态下的移动。如果我们想包括对手的潜在回应,我们将需要添加另一层嵌套。我们通过将每个移动和它的子节点(从原始移动分支出的移动)放在一个列表中来实现这一点。让我们从添加表示移动子节点的空列表开始:

simple_tree_with_children = [[[(4,4),(4,3)],[]],[[(1,3),(2,3)],[]]]

稍微停下来,确保你能看到我们所做的所有嵌套。每个移动本身就是一个列表,并且是一个列表的第一个元素,这个列表还将包含该列表的子节点。然后,所有这些列表一起存储在一个主列表中,这就是我们的完整树。

我们可以用这个嵌套的列表结构来表示图 10-3 中整个游戏树,包括对手的回应:

full_tree = [[[(4,4),(4,3)],[[(1,3),(2,3)],[(3,1),(4,1)]]],[[(1,3),(2,3)],[[(4,4),(4,3)],\[(3,1),(4,1)]]]]

方括号很快就会变得不易管理,但我们需要这种嵌套结构,这样我们才能正确地追踪哪些移动是哪个移动的子节点。

我们可以通过编写一个函数来代替手动编写游戏树,它会为我们创建游戏树。该函数将接受我们的可能移动列表作为输入,然后将每个移动附加到树中(见列表 10-4)。

def generate_tree(possible_moves,depth,maxdepth):
    tree = []
    for move in possible_moves:
        move_profile = [move]
        if depth < maxdepth:
            possible_moves2 = possible_moves.copy()
            possible_moves2.remove(move)
            move_profile.append(generate_tree(possible_moves2,depth + 1,maxdepth))
        tree.append(move_profile)
    return(tree)

列表 10-4: 一个创建指定深度游戏树的函数

这个函数generate_tree()首先定义了一个空列表,叫做tree。然后,它会遍历每一个可能的步伐。对于每一个步伐,它会创建一个move_profile。一开始,move_profile仅包含步伐本身。但对于还没有到达游戏树最低深度的分支,我们需要添加这些步伐的子步伐。我们递归地添加子步伐:我们再次调用generate_tree()函数,但这时我们已经从possible_moves列表中移除了一步。最后,我们将move_profile列表附加到树上。

我们可以用以下几行简单地调用这个函数:

allpossible = [[(4,4),(4,3)],[(4,1),(5,1)]]
thetree = generate_tree(allpossible,0,1)
print(thetree)

当我们运行这个时,我们会看到以下的树结构:

[[[(4, 4), (4, 3)], [[[(4, 1), (5, 1)]]]], [[(4, 1), (5, 1)], [[[(4, 4), (4, 3)]]]]]

接下来,我们将做出两个修改,使得我们的树更有用:第一个记录了游戏得分和步伐,第二个附加了一个空列表,用来为子步伐留个位置(清单 10-5)。

def generate_tree(possible_moves,depth,maxdepth,game_so_far):
    tree = []
    for move in possible_moves:
        move_profile = [move]
        game2 = game_so_far.copy()
        game2.append(move)
        move_profile.append(score(game2))
        if depth < maxdepth:
            possible_moves2 = possible_moves.copy()
            possible_moves2.remove(move)
            move_profile.append(generate_tree(possible_moves2,depth + 1,maxdepth,game2))
 else:
            move_profile.append([])
        tree.append(move_profile)
    return(tree)

清单 10-5: 一个生成游戏树的函数,包括子步伐和游戏得分

我们可以按如下方式再次调用:

allpossible = [[(4,4),(4,3)],[(4,1),(5,1)]]
thetree = generate_tree(allpossible,0,1,[])
print(thetree)

我们得到了以下结果:

[[[(4, 4), (4, 3)], [0, 0], [[[(4, 1), (5, 1)], [0, 0], []]]], [[(4, 1), (5, 1)], [0, 0], \[[[(4, 4), (4, 3)], [0, 0], []]]]]

你可以看到,这棵树中的每个条目都是一个完整的步伐资料,包括一个步伐(比如[(4,4),(4,3)])、一个得分(比如[0,0])以及一个(有时为空的)子步伐列表。

获得游戏胜利

我们终于准备好创建一个可以玩“点与方块”游戏的函数了。在编写代码之前,让我们先考虑一下它背后的原则。具体来说,作为人类,我们是如何玩好“点与方块”这款游戏的?更一般地说,我们是如何赢得任何战略性游戏(比如国际象棋或井字棋)的?每个游戏都有独特的规则和特性,但有一种基于游戏树分析选择获胜策略的一般方法。

我们将用来选择获胜策略的算法叫做极小极大minimax,由minimummaximum两个词组合而成),之所以这样叫,是因为在我们尝试最大化游戏得分时,我们的对手则试图最小化我们的得分。我们在最大化和对手在最小化之间的持续斗争,就是我们在选择正确的步伐时需要战略性考虑的内容。

让我们仔细看看图 10-3 中的简单游戏树。从理论上讲,游戏树可以变得非常庞大,具有巨大的深度,并且每一层都有许多分支。但无论游戏树有多大,它都由相同的组件组成:许多小的嵌套分支。

在我们正在考虑的图 10-3 这一点上,我们有两个选择。图 10-4 展示了这两种选择。

Figure_10-4

图 10-4: 考虑选择两个步骤中的哪一个

我们的目标是最大化我们的得分。为了在这两个步伐之间做出选择,我们需要知道它们将带来什么后果,每个步伐会带来什么样的未来。为了了解这一点,我们需要进一步深入游戏树,查看所有可能的后果。让我们从右边的步伐开始(图 10-5)。

Figure_10-5

图 10-5: 假设对手将试图最小化你的得分,你可以找到每一步预计将导致的未来局面。

这一步可能会导致两种未来局面中的任何一种:在树的末端我们可能落后于 0-1,或者我们可能落后于 0-2。如果我们的对手玩得很好,他们会想要最大化自己的得分,这等同于最小化我们的得分。如果对手想要最小化我们的得分,他们会选择把我们置于 0-2 落后的局面。相反,考虑我们的另一个选择,即图 10-5 的左分支,其可能的未来局面在图 10-6 中进行了考虑。

Figure_10-6

图 10-6: 无论对手选择什么,我们都预期得到相同的结果。

在这种情况下,我们对手的两个选择都将导致 1-1 的得分。同样假设我们的对手会采取最小化我们得分的行动,我们可以说这一步将导致比赛以 1-1 平局结束。

现在我们知道了这两步将带来的未来局面。图 10-7 在图 10-4 的更新版本中记录了这些未来局面。

因为我们准确知道每一步可能带来的未来局面,我们可以进行最大化:导致最大得分的最佳步骤是左侧的那一步,所以我们选择了那一步。

Figure_10-7

图 10-7: 利用图 10-5 和 10-6,我们可以推理出每一步将导致的未来局面,并进行比较。

我们刚才经历的推理过程被称为最小最大算法(minimax algorithm)。我们现在的决策是为了最大化我们的得分。但是为了最大化得分,我们必须考虑对手所有试图最小化我们得分的方式。因此,最佳选择是最大化的最小值。

请注意,极小极大算法是通过逆向时间进行的。游戏的进程是向前的,从现在到未来。但是在某种意义上,极小极大算法是向后进行的,因为我们首先考虑可能的遥远未来的得分,然后再回到当前,找到能够通向最佳未来的选择。在我们的游戏树中,极小极大算法从树的顶部开始。它递归地调用每一个子分支。子分支又递归地调用极小极大算法,继续递归到终端节点。在终端节点处,我们不再继续调用极小极大算法,而是为每个节点计算游戏得分。因此,我们首先计算终端节点的游戏得分;我们从遥远的未来开始计算游戏得分。这些得分随后被传回父节点,以便父节点可以计算出最佳的走法及其对应的得分。这些得分和走法将被传回游戏树的上方,直到到达最顶层的父节点,这个父节点代表着现在。

清单 10-6 中包含了一个实现极小极大算法的函数。

import numpy as np
def minimax(max_or_min,tree):
    allscores = []
    for move_profile in tree:
        if move_profile[2] == []:
            allscores.append(move_profile[1][0] - move_profile[1][1])
        else:
            move,score=minimax((-1) * max_or_min,move_profile[2])
            allscores.append(score)
    newlist = [score * max_or_min for score in allscores]
    bestscore = max(newlist)
    bestmove = np.argmax(newlist)
    return(bestmove,max_or_min * bestscore)

清单 10-6: 一个使用极小极大算法来寻找游戏树中最佳走法的函数

我们的minimax()函数相对较短。大部分代码是一个for循环,用于遍历树中的每个走法。如果该走法没有子走法,那么我们根据该走法与对手的得分差计算该走法的得分。如果该走法有子走法,我们就对每个子走法调用minimax()来获取每个走法的得分。然后,我们只需要找到与最大得分相关的走法。

我们可以调用我们的minimax()函数,在任何进行中的游戏中的任意回合找到最佳走法。在调用minimax()之前,让我们先确保一切都已正确定义。首先,让我们定义游戏,并使用之前的代码获取所有可能的走法:

allpossible = []

game = [[(1,2),(1,1)],[(3,3),(4,3)],[(1,5),(2,5)],[(1,2),(2,2)],[(2,2),(2,1)],[(1,1),(2,1)],\[(3,4),(3,3)],[(3,4),(4,4)]]

gamesize = 5

for i in range(1,gamesize + 1):
    for j in range(2,gamesize + 1):
        allpossible.append([(i,j),(i,j - 1)])

for i in range(1,gamesize):
    for j in range(1,gamesize + 1):
        allpossible.append([(i,j),(i + 1,j)])

for move in allpossible:
    if move in game:
        allpossible.remove(move)

接下来,我们将生成一个完整的游戏树,扩展到三层深度:

thetree = generate_tree(allpossible,0,3,game)

现在我们已经有了我们的游戏树,我们可以调用我们的minimax()函数:

move,score = minimax(1,thetree)

最后,我们可以按如下方式检查最佳的走法:

print(thetree[move][0])

我们看到最佳的走法是[(4, 4), (4, 3)],这一步完成了一个方块并为我们赢得了一个积分。我们的 AI 可以玩点阵和方格游戏,并选择最佳的走法!你可以尝试其他游戏板大小,或者不同的游戏场景,或者不同的树深度,检查我们的极小极大算法实现是否表现良好。在本书的续集中,我们将讨论如何确保你的 AI 既不会突然自我觉醒,也不会变得邪恶,决定推翻人类。

添加增强功能

既然你已经能执行最小最大算法了,你就可以把它应用到任何你正在玩的游戏中。或者,你也可以将它应用到生活决策中,考虑未来并最大化每个最小可能性。(最小最大算法的结构对于任何竞争情境都是一样的,但为了将我们的最小最大代码用于其他游戏,我们需要编写新的代码来生成游戏树、枚举每个可能的动作,以及计算游戏分数。)

我们在这里构建的 AI 能力非常有限。它只能玩一个游戏,且仅适用于一种简单的规则版本。根据你使用的处理器,AI 可能只能提前看几个步骤,而不会在每次决策时花费过多的时间(几分钟或更长)。我们自然希望能增强我们的 AI,使其变得更强大。

我们肯定需要改善的一个方面是 AI 的速度。它之所以很慢,是因为它需要处理庞大的游戏树。提高最小最大算法性能的主要方法之一是修剪游戏树。正如你在第九章中学到的那样,修剪就是字面意思:我们会去除那些特别糟糕的分支,或者是与其他分支重复的分支。修剪并非易事,它需要学习更多的算法来做好这件事。一个例子就是alpha–beta 修剪算法,它会在某些子分支肯定比其他分支差的情况下,停止检查这些子分支。

另一个自然的改进方向是让我们的 AI 能适应不同的规则或不同的游戏。例如,在“点与盒”游戏中,一个常见的规则是,玩家在得分后可以再画一条线。有时,这会导致一个连锁反应,其中一名玩家在一轮内连续完成多个盒子。这一简单的变化,叫做“得分即得继续”规则,在我小学的操场上就是这么叫的,它改变了游戏的战略考虑,并需要对我们的代码做出一些调整。你也可以尝试实现一个能在交叉形状或其他可能影响策略的特殊形状的网格上玩的“点与盒”AI。最小最大算法的美妙之处在于它不需要微妙的战略理解;它只需要具备提前预测的能力,这也是为什么一个不擅长象棋的程序员能够编写出一个最小最大算法,并在象棋中战胜他自己的原因。

有一些强大的方法超出了本章的范围,它们可以提高计算机 AI 的表现。这些方法包括强化学习(例如,棋类程序通过自我对弈来提高水平)、蒙特卡罗方法(例如,将随机生成未来的将棋游戏来帮助理解可能性),以及神经网络(例如,井字棋程序使用类似于我们在第九章讨论的机器学习方法来预测对手的动作)。这些方法既强大又引人注目,但它们大多数只是让我们的树搜索和 minimax 算法更高效;树搜索和 minimax 仍然是战略性 AI 中那种谦逊的核心工具。

摘要

在本章中,我们讨论了人工智能。它是一个充满炒作的术语,但当你看到只需约十几行代码就能编写一个minimax()函数时,人工智能突然看起来不那么神秘和令人畏惧。当然,为了准备编写这些代码,我们首先需要学习游戏规则,绘制游戏棋盘,构建游戏树,并配置我们的minimax()函数以正确计算游戏结果。更不用说本书的其余部分,我们在其中精心构建了算法,帮助我们以算法化的方式思考,并在需要时编写这个函数。

下一章为有志于深入算法世界的算法学者们提出了下一步的建议,帮助他们不断推动算法的前沿。

第十一章:继续前进

你已经穿越了搜索与排序的黑暗森林,越过了深奥数学的冰冻河流,攀越了梯度上升的危险山口,越过了几何绝望的沼泽,征服了慢运行时间的巨龙。恭喜你。如果你愿意,你可以回到你那无算法的舒适家园。本章是为那些希望在合上这本书后继续冒险的人准备的。

没有一本书能够涵盖关于算法的所有内容。要了解的东西实在太多了,而且新的知识不断被发现。本章内容涉及三件事:用算法做更多的事情,利用算法更好更快地完成任务,以及解决算法的深层次谜题。

在本章中,我们将构建一个简单的聊天机器人,它能够与我们讨论书中的前几章内容。接着,我们将讨论一些世界上最困难的问题,以及我们如何向着制定解决这些问题的算法迈进。最后,我们将讨论一些算法世界中最深奥的谜题,包括如何通过高级算法理论赢得百万美元的详细说明。

用算法做更多的事情

本书的前 10 章介绍了可以执行多种任务的算法,涵盖了多个领域。但算法能做的事情远不止我们在这里看到的。如果你希望继续你的算法冒险之旅,你应该探索其他领域及其相关的重要算法。

例如,许多信息压缩算法可以将一本长书存储为一种编码形式,其大小仅为原书的一个小部分,它们还能将复杂的照片或电影文件压缩成可管理的大小,且几乎不损失质量,甚至完全不损失质量。

我们在线上安全沟通的能力,包括自信地将我们的信用卡信息传递给第三方,依赖于加密算法。学习密码学非常有趣,因为它伴随着一段惊险的历史,涉及冒险家、间谍、背叛者,以及那些通过破译密码来赢得战争的英雄书呆子。

最近,创新的算法被开发出来用于执行并行分布式计算。与一次性执行一个操作、重复数百万次不同,分布式计算算法将一个数据集拆分成多个小部分,然后将它们发送到不同的计算机上,计算机同时执行所需操作并返回结果,结果再被重新组合并呈现为最终输出。通过并行处理数据的所有部分,而不是依次处理,並行计算节省了大量的时间。这对于机器学习中的应用非常有用,因为机器学习需要处理极其庞大的数据集,或者需要同时执行大量的简单计算。

几十年来,人们一直对量子计算的潜力感到兴奋。如果我们能将量子计算机工程化,使其正常工作,它们有潜力在今天的非量子超级计算机所需时间的极短时间内执行极其困难的计算(包括破解最先进加密技术所需的计算)。由于量子计算机与标准计算机的架构不同,因此有可能设计出新的算法,利用它们不同的物理特性,以更高的速度执行任务。目前,这更像是一个学术问题,因为量子计算机还没有进入可用于实际目的的状态。但如果技术成熟,量子算法可能会变得极为重要。

当你学习这些或其他许多领域的算法时,你不会从零开始。通过掌握本书的算法,你已经了解它们是什么,它们的功能倾向以及如何为它们编写代码。学习第一个算法可能会感觉非常困难,但学习第 50 个或第 200 个算法会容易得多,因为你的大脑已经习惯了它们的构造模式和思考方式。

为了证明你现在能够理解并编写算法,我们将探索几个共同工作的算法,提供聊天机器人的功能。如果你能够理解它们如何工作,以及如何为它们编写代码,那么你就能在任何领域掌握任何算法的工作原理。

构建一个聊天机器人

让我们构建一个简单的聊天机器人,可以回答有关本书目录的问题。我们将首先导入一些稍后会用到的模块:

import pandas as pd
from sklearn.feature_extraction.text import TfidfVectorizer
from scipy import spatial
import numpy as np
import nltk, string

创建聊天机器人的下一步是进行文本规范化,即将自然语言文本转换为标准化子字符串的过程;它使得表面上不同的文本之间可以轻松进行比较。我们希望我们的机器人理解,Americaamerica指的是同一件事,regenerationregenerate表达的是相同的思想(尽管词性不同),centuriescentury的复数形式,而且hello;本质上与hello没有什么不同。我们希望我们的聊天机器人对同根词汇进行相同的处理,除非有某些特殊原因。

假设我们有以下查询:

query = 'I want to learn about geometry algorithms.'

我们能做的第一件事是将所有字符转换为小写。Python 的内置lower()方法可以完成这个任务:

print(query.lower())

这将输出i want to learn about geometry algorithms。我们还可以做的另一件事是去除标点符号。为了做到这一点,首先我们将创建一个名为字典的 Python 对象:

remove_punctuation_map = dict((ord(char), None) for char in string.punctuation)

这个代码片段创建了一个字典,将每个标准标点符号映射到 Python 对象None,并将字典存储在名为remove_punctuation_map的变量中。我们然后使用这个字典像这样去除标点:

print(query.lower().translate(remove_punctuation_map))

在这里,我们使用了translate()方法,将查询中找到的所有标点符号替换为空—换句话说,移除标点符号。我们得到的输出与之前看到的一样—i want to learn about geometry algorithms—但没有结尾的句号。接下来,我们可以执行分词,将文本字符串转换为一组连贯的子字符串:

print(nltk.word_tokenize(query.lower().translate(remove_punctuation_map)))

我们使用了nltk的分词函数来实现这个功能,得到了如下输出:['i', 'want', 'to', 'learn', 'about', 'geometry', 'algorithms']

现在我们可以进行所谓的词干提取。在英语中,我们使用单词jumpjumpsjumpingjumped和其他派生形式,它们虽然不同,但共享一个词干:动词jump。我们不希望我们的聊天机器人被词形变化的小差异分心;我们希望把关于跳跃的句子和关于跳高者的句子看作是相似的,尽管它们在技术上是不同的单词。词干提取会去掉派生词的结尾部分,将其转换为标准化的词干。Python 的nltk模块中提供了词干提取的函数,我们可以通过列表推导式来使用这个函数,如下所示:

stemmer = nltk.stem.porter.PorterStemmer()
def stem_tokens(tokens):
    return [stemmer.stem(item) for item in tokens]

在这个代码片段中,我们创建了一个名为stem_tokens()的函数。它接受一个标记(tokens)列表,并调用nltkstemmer.stem()函数将它们转换为词干:

print(stem_tokens(nltk.word_tokenize(query.lower().translate(remove_punctuation_map))))

输出结果是['i', 'want', 'to', 'learn', 'about', 'geometri', 'algorithm']。我们的词干提取器将algorithms转换为algorithm,将geometry转换为geometri。它用它认为的词干替换了单词:一个单数单词或词部分,便于文本比较。最后,我们将所有的标准化步骤合并在一个函数normalize()中:

def normalize(text):
    return stem_tokens(nltk.word_tokenize(text.lower().translate(remove_punctuation_map)))

文本向量化

现在你已经准备好学习如何将文本转换为数字向量了。与单词相比,数字和向量之间的定量比较要容易得多,而我们将需要进行定量比较来让我们的聊天机器人正常工作。

我们将使用一种简单的方法,叫做TFIDF,即词频-逆文档频率,它将文档转换为数字向量。每个文档向量都有一个元素对应语料库中的每个词项。每个元素是给定词项的词频(即某个词在特定文档中出现的次数)和逆文档频率(即该词出现在文档中比例的倒数的对数)的乘积。

例如,假设我们正在为美国总统的传记创建 TFIDF 向量。在创建 TFIDF 向量的过程中,我们将每篇传记视作一个文档。在亚伯拉罕·林肯的传记中,词语representative可能至少出现一次,因为他曾在伊利诺伊州众议院和美国众议院任职。如果representative在传记中出现了三次,那么我们说它的词频是 3。美国众议院曾有十几位总统任职,所以大约在 44 位总统的传记中,有 20 篇包含了词语representative。我们可以通过以下方式计算逆文档频率:

c11eq001

我们要找的最终值是词频乘以逆文档频率:3 × 0.788 = 2.365。现在考虑词语Gettysburg。它可能在林肯的传记中出现了两次,但在其他任何传记中都没有出现,因此词频将是 2,逆文档频率将是:

c11eq002

Gettysburg相关的向量元素将是词频乘以逆文档频率,即 2 × 3.784 = 7.568。每个术语的 TFIDF 值应该反映它在文档中的重要性。很快,这对于我们的聊天机器人理解用户意图将变得至关重要。

我们不需要手动计算 TFIDF。我们可以使用scikit-learn模块中的一个函数:

vctrz = TfidfVectorizer(ngram_range = (1, 1),tokenizer = normalize, stop_words = 'english')

这一行创建了一个TfidfVectorizer()函数,它能够从文档集合中创建 TFIDF 向量。为了创建向量化器,我们需要指定一个ngram_range。这个参数告诉向量化器什么应该被视作一个术语。我们指定了(1, 1),这意味着我们的向量化器将只把 1-gram(单个词)视作术语。如果我们指定(1, 3),它将把 1-gram(单个词)、2-gram(两词短语)和 3-gram(三词短语)都视作术语,并为每个术语创建一个 TFIDF 元素。我们还指定了一个tokenizer,并且指定了我们之前创建的normalize()函数。最后,我们需要指定stop_words,即我们想要过滤掉的、没有信息量的词。在英语中,停用词包括theandof以及其他一些非常常见的词。通过指定stop_words = 'english',我们告诉向量化器过滤掉内建的英语停用词集合,只对不太常见、信息量较大的词进行向量化。

现在,让我们配置一下我们的聊天机器人能讨论的内容。在这里,它将能够讨论本书的章节,因此我们将创建一个包含每个章节简单描述的列表。在这个上下文中,每个字符串将是我们一个文档

alldocuments = ['Chapter 1\. The algorithmic approach to problem solving, including Galileo and baseball.',
            'Chapter 2\. Algorithms in history, including magic squares, Russian peasant multiplication, and Egyptian methods.',
            'Chapter 3\. Optimization, including maximization, minimization, and the gradient ascent algorithm.',
            'Chapter 4\. Sorting and searching, including merge sort, and algorithm runtime.',
            'Chapter 5\. Pure math, including algorithms for continued fractions and random numbers and other mathematical ideas.',
            'Chapter 6\. More advanced optimization, including simulated annealing and how to use it to solve the traveling salesman problem.',
            'Chapter 7\. Geometry, the postmaster problem, and Voronoi triangulations.',
            'Chapter 8\. Language, including how to insert spaces and predict phrase completions.',
            'Chapter 9\. Machine learning, focused on decision trees and how to predict happiness and heart attacks.',
            'Chapter 10\. Artificial intelligence, and using the minimax algorithm to win at dots and boxes.',
            'Chapter 11\. Where to go and what to study next, and how to build a chatbot.']

我们将继续通过拟合我们的 TFIDF 向量器到这些章节描述,这将处理文档,使我们随时准备创建 TFIDF 向量。我们不需要手动完成这个过程,因为在scikit-learn模块中定义了一个fit()方法:

vctrz.fit(alldocuments)

现在,我们将为章节描述和一个新的查询创建 TFIDF 向量,查询要求关于排序和搜索的章节:

query = 'I want to read about how to search for items.'
tfidf_reports = vctrz.transform(alldocuments).todense()
tfidf_question = vctrz.transform([query]).todense()

我们的新查询是关于搜索的自然英语文本。接下来的两行使用内置的translate()todense()方法创建章节描述和查询的 TFIDF 向量。

现在我们已将章节描述和查询转换为数字 TFIDF 向量。我们的简单聊天机器人将通过将查询的 TFIDF 向量与章节描述的 TFIDF 向量进行比较,得出用户正在寻找的章节是描述向量与查询向量最为接近的章节。

向量相似度

我们将通过一个名为余弦相似度的方法决定两个向量是否相似。如果你学过很多几何学,你会知道,对于任何两个数值向量,我们可以计算它们之间的角度。几何规则使我们能够计算向量之间的角度,不仅仅是在二维和三维空间中,还可以在四维、五维或任意维度中。如果向量之间非常相似,它们之间的角度将非常小。如果向量之间非常不同,角度将很大。很难想象我们可以通过计算“角度”来比较英文文本,但这正是我们创建数字 TFIDF 向量的原因——这样我们就可以使用像角度比较这样的数值工具来处理本来不是数值的文本数据。

实际上,计算两个向量之间角度的余弦比计算角度本身更容易。这不是问题,因为我们可以得出结论,如果两个向量之间角度的余弦值很大,那么角度本身很小,反之亦然。在 Python 中,scipy模块包含一个名为spatial的子模块,里面有一个计算向量之间角度余弦的函数。我们可以使用spatial中的功能,通过列表推导计算每个章节描述向量和查询向量之间的余弦:

row_similarities = [1 - spatial.distance.cosine(tfidf_reports[x],tfidf_question) for x in \range(len(tfidf_reports)) ]

当我们打印出row_similarities变量时,我们看到以下向量:

[0.0, 0.0, 0.0, 0.3393118510377361, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]

在这种情况下,只有第四个元素大于零,意味着只有第四个章节描述向量与我们的查询向量有任何角度接近。通常,我们可以自动找出哪个行的余弦相似度最高:

print(alldocuments[np.argmax(row_similarities)])

这给出了聊天机器人认为我们在寻找的章节:

Chapter 4\. Sorting and searching, including merge sort, and algorithm runtime.

列表 11-1 将聊天机器人的简单功能放入一个函数中。

def chatbot(query,allreports):
    clf = TfidfVectorizer(ngram_range = (1, 1),tokenizer = normalize, stop_words = 'english')
    clf.fit(allreports)
    tfidf_reports = clf.transform(allreports).todense()
    tfidf_question = clf.transform([query]).todense()
    row_similarities = [1 - spatial.distance.cosine(tfidf_reports[x],tfidf_question) for x in \range(len(tfidf_reports)) ]
    return(allreports[np.argmax(row_similarities)])

列表 11-1: 一个简单的聊天机器人功能,接受查询并返回与之最相似的文档

Listing 11-1 并没有包含任何新内容;所有的代码我们之前都已经见过。现在我们可以使用聊天机器人进行查询,询问在哪里可以找到某些内容:

print(chatbot('Please tell me which chapter I can go to if I want to read about mathematics algorithms.',alldocuments))

输出将告诉我们去第五章:

Chapter 5\. Pure math, including algorithms for continued fractions and random numbers and other mathematical ideas.

现在你已经看到了整个聊天机器人的工作原理,你可以理解为什么我们需要进行归一化和向量化。通过归一化和词干提取,我们可以确保“mathematics”这个词会促使机器人返回第五章的描述,即使该词并未直接出现在其中。通过向量化,我们启用了余弦相似性度量,它告诉我们哪一章的描述最匹配。

我们已经完成了我们的聊天机器人,它需要将几个不同的小型算法结合在一起(用于文本归一化、词干提取和数值化向量化的算法;用于计算向量之间夹角余弦的算法;以及基于查询/文档向量相似性提供聊天机器人回答的总体算法)。你可能已经注意到,我们并没有手动做很多计算——实际上,TFIDF 或余弦的计算是由我们导入的模块完成的。在实践中,你通常不需要真正理解一个算法的内部实现就可以导入它并在你的程序中使用。这有时是个福音,因为它可以加速我们的工作,并在需要时提供令人惊叹的复杂工具供我们使用。但它也可能是一个诅咒,因为它会导致人们误用他们不了解的算法;例如,《Wired》杂志中的一篇文章称,某个特定金融算法(使用高斯 copula 函数预测风险的方法)的误用导致了“摧毁华尔街”和“吞噬数万亿美元”,并且是大衰退的主要原因之一(* www.wired.com/2009/02/wp-quant/*)。我鼓励你研究算法的深层理论,即使导入 Python 模块的便利性让这种研究看起来似乎不必要;这总能让你成为一个更好的学者或实践者。

这可能是最简单的聊天机器人,它只回答与本书章节相关的问题。你可以为它添加许多增强功能来改进它:使章节描述更具体,从而更容易匹配广泛的查询;找到一个比 TFIDF 表现更好的向量化方法;添加更多文档,让它能够回答更多问题。但是,尽管我们的聊天机器人不是最先进的,我们依然可以为它感到骄傲,因为它是我们的,并且是我们自己构建的。如果你能轻松地构建一个聊天机器人,你可以认为自己是一个合格的算法设计师和实施者——祝贺你在这本书的学习旅程中取得这一终极成就。

变得更好、更快

你现在能做的事情,比刚开始读这本书时多得多。但每个认真的冒险者也会希望能够做得更好、更快。

许多事情都可以让您在设计和实现算法方面变得更好。思考一下本书中我们实现的每个算法是如何依赖于对非算法主题的理解的。我们的棒球接球算法依赖于对物理学甚至是一点心理学的理解。俄罗斯农民乘法依赖于对指数和算术的深层次理解,包括二进制表示法。第七章的几何算法依赖于对点、线和三角形如何相关和拟合的深刻见解。您对您试图为其编写算法的领域的理解越深入,设计和实现算法就会越容易。因此,变得更擅长算法的方法很简单:只需完美理解一切。

另一个自然的下一步,是对您原始编程技能进行打磨和再打磨。记住,第八章介绍了列表推导作为 Pythonic 工具,使我们能够编写简洁且性能良好的语言算法。随着您学习更多的编程语言并掌握它们的特性,您将能够编写更加组织良好、更加紧凑和更加强大的代码。即使是熟练的程序员也可以通过回归基础和掌握基本原理来受益。许多有才华的程序员编写混乱、文档不佳或效率低下的代码,并认为他们可以凭借它“运行”。但请记住,代码通常不会单独成功——它几乎总是作为更广泛的程序的一部分存在,依赖于人们之间的合作和时间。因此,甚至像计划、口头和书面沟通、谈判以及团队管理等软技能也可以提高您在算法世界中的成功机会。

如果您喜欢创建完美的最优算法并将其推向最高效率,那么您很幸运。对于大量计算机科学问题来说,没有已知的高效算法能够比暴力算法快得多。在接下来的部分中,我们将概述其中的一些问题,并讨论它们的难点所在。如果您,亲爱的冒险家,能够快速解决这些问题的算法,那么您可能会一生享有名誉、财富和全世界的感激之情。我们还在等什么?让我们来看看我们中最勇敢的人面临的一些挑战。

雄心勃勃的算法

让我们考虑一个与国际象棋相关的相对简单的问题。国际象棋在一个 8×8 的棋盘上进行,两位对手轮流移动不同样式的棋子。一个棋子,皇后,可以沿着它所在的行、列或对角线移动任意数量的格子。通常,每个玩家只有一个皇后,但在标准的国际象棋比赛中,一个玩家最多可以有九个皇后。如果一个玩家有多个皇后,可能会出现两个或更多皇后互相“攻击”的情况——换句话说,它们被放置在同一行、同一列或同一对角线上。八皇后问题要求我们在标准棋盘上放置八个皇后,使得没有一对皇后处于同一行、同一列或同一对角线。图 11-1 展示了八皇后问题的一个解法。

figure_11_1_lighter

图 11-1: 八皇后问题的一个解法(来源:Wikimedia Commons)

这个棋盘上的任何一个皇后都不会攻击其他皇后。解决八皇后问题的最简单方法就是简单地记住一个解法,比如 图 11-1 中的解法,并在每次被要求解决这个谜题时重复使用。然而,几个额外的难题使得记忆解法变得不可行。一个难题是增加皇后的数量和棋盘的大小。n 皇后问题要求我们在一个 n×n 的棋盘上放置 n 个皇后,使得没有一个皇后攻击任何其他皇后;n 可以是任何自然数,不管有多大。另一个难题是 n 皇后完成问题:你的对手首先放置一些皇后,可能是放置在一些位置,这会让你很难放置剩余的皇后,然后你需要将剩下的 n 个皇后放置到棋盘上,使得没有任何皇后会攻击其他皇后。你能设计一个运行非常快速并能解决这个问题的算法吗?如果能,你可能会赚到一百万美元(见第 212 页的“解决最深奥的谜题”)。

图 11-1 可能会让你联想到数独,因为它涉及到检查行和列中符号的唯一性。在数独中,目标是填入数字 1 到 9,使得每一行、每一列和每个 3×3 的小方块都包含每个数字的唯一一个实例 (图 11-2)。数独首先在日本流行起来,事实上,数独谜题让人想起我们在第二章中探讨的日本魔方阵。

图 11-2

图 11-2: 一个未完成的数独网格(来源:Wikimedia Commons)

想一想如何编写一个算法来解决数独难题是一个有趣的练习。最简单、最慢的算法是依靠暴力破解:尝试每一种可能的数字组合,并反复检查它们是否构成一个正确的解法,直到找到解法。这是可行的,但缺乏优雅,且可能需要极长的时间。直观上看,把 81 个数字填入一个网格,按照任何人都能轻松遵循的规则,似乎不应该突破我们世界计算资源的极限。更复杂的解法可以依靠逻辑来减少所需的运行时间。

n皇后问题和数独有另一个重要的共同特征:解法非常容易检查。也就是说,如果我给你看一个有皇后的棋盘,你可能只需几秒钟就能检查出它是否是n皇后问题的解法;如果我给你看一个 81 个数字的网格,你也能轻松判断它是否是一个正确的数独解法。我们可以轻松检查解法的便捷性,然而,遗憾的是,生成解法的便捷性远不及验证解法的便捷性——解决一个困难的数独难题可能需要几个小时,而验证它则只需几秒钟。生成与验证的努力不匹配在生活中的许多领域都很常见:我只需要很少的努力就能判断一顿饭是否好吃,但制作一顿美味的饭菜需要投入更多的时间和资源。同样,我检查一幅画是否美丽的时间远少于创作一幅美丽画作所需要的时间,而我验证一架飞机能否飞行所需的努力远少于制造一架能飞行的飞机所需的努力。

在理论计算机科学中,那些算法上难以解决但解法容易验证的问题是极其重要的,它们是该领域最深奥、最紧迫的谜团。尤其是那些勇敢的冒险者,可能会敢于深入这些谜团——但要小心那里潜伏的危险。

解开最深的谜团

当我们说数独解法容易验证但难以生成时,正式的说法是解法可以在多项式时间内验证;换句话说,验证解法所需的步骤数是数独棋盘大小的某个多项式函数。如果你回想一下第四章我们讨论的运行时间,你会记得,即使像x²和x³这样的多项式增长得很快,它们与像e^x 这样的指数函数相比,增长速度要慢得多。如果我们能够在多项式时间内验证一个问题的算法解法,我们认为验证是容易的,但如果解法的生成需要指数时间,我们就认为它是困难的。

有一个正式的名称来表示那些其解答可以在多项式时间内验证的问题类别:NP 复杂性类。(在这里,NP 代表 非确定性多项式时间,原因涉及到理论计算机科学,需要一个较长的讨论,这里不做展开。)NP 是计算机科学中两个最基本的复杂性类之一。第二个是 P 类,代表多项式时间。P 复杂性类包含所有可以通过在多项式时间内运行的算法找到解答的问题。对于 P 类问题,我们可以在多项式时间内 找到 完整的解答,而对于 NP 类问题,我们可以在多项式时间内 验证 解答,但找到这些解答可能需要指数级的时间。

我们知道数独是 NP 问题——在多项式时间内,验证一个给定的数独解是很容易的。那么,数独是否也是 P 问题呢?也就是说,是否存在一个算法能够在多项式时间内解决任何数独谜题?至今没有人找到过这样的算法,也没有人看起来接近找到它,但我们并不确定这是否是不可能的。

我们已知的属于 NP 类的问题列表非常长。一些旅行商问题的版本也属于 NP 类。魔方的最优解也是如此,还有像整数线性规划这样的重要数学问题。就像数独一样,我们不禁会想,这些问题是否也属于 P 类——我们能否在多项式时间内找到它们的解?可以这样提问:P 是否等于 NP?

2000 年,克莱数学研究所发布了一份名为千年奖问题的清单。它宣布,任何人如果发布一个已验证的解决方案,就能获得一百万美元的奖金。这个清单包含了七个与数学相关的世界上最重要的问题,而 P 是否等于 NP 也是其中之一;至今还没有人领取过这一奖项。是否会有某个高贵的冒险家在阅读这些文字后最终解开这个最关键的算法难题呢?我真诚地希望如此,并祝愿你们在这段旅程中好运、力量和快乐。

如果存在解决方案,它将证明以下两个论断之一:要么 P = NP,要么 P ≠ NP。证明 P = NP 可能相对简单,因为所需要的只是一个多项式时间算法来解决一个 NP 完全问题。NP 完全问题是一种特殊的 NP 问题,其特征是每个 NP 问题都可以快速简化为一个 NP 完全问题;换句话说,如果你能解决一个 NP 完全问题,就能解决所有 NP 问题。如果你能在多项式时间内解决任何一个 NP 完全问题,你就能在多项式时间内解决每个 NP 问题,这将证明 P = NP。事实上,数独和 n 皇后问题都是 NP 完全问题。这意味着找到一个多项式时间的算法来解决其中任何一个问题,不仅能解决所有现有的 NP 问题,还能为你赢得一百万美元和全球终身的声誉(更不用说在友好的数独比赛中击败你认识的每一个人)。

证明 P ≠ NP 可能不像解决数独那样直接。P ≠ NP 的概念意味着存在一些 NP 问题,它们无法通过任何具有多项式运行时间的算法解决。证明这一点等同于证明一个否定命题,从概念上讲,证明某个事物无法存在,比指出一个事物的例子要难得多。要在证明 P ≠ NP 的过程中取得进展,将需要深入研究理论计算机科学,超出本书的范围。虽然这条路更为艰难,但研究人员的共识似乎是 P ≠ NP,如果 P 与 NP 问题最终有解决方案,它很可能是证明 P ≠ NP。

P 与 NP 问题并不是唯一与算法相关的深刻谜题,尽管它是最直接能带来丰厚回报的那个。算法设计领域的每一个方面都有广阔的领域,供冒险者进军。这里不仅有理论和学术问题,还有一些实际问题,涉及如何在商业环境中实施算法上可靠的实践。不要浪费时间:记住你在这里学到的知识,继续前进,带着你的新技能,走向知识和实践的极限,开始你一生的算法冒险。朋友们,再见。

第十二章:索引

数字与符号

7×7 魔方,测试代码,33

%(取模)运算符

欧几里得算法,21

黑田算法,27–28

俄罗斯农民乘法(RPM),19

规则,32

[](方括号)

使用列表推导,152

使用 loc 功能,19

A

加速度

估算抛物线球,10

观察抛物线球,9

人工智能(AI)。参见 决策树游戏树随机森林

添加增强功能,199

绘制棋盘,187–188

游戏树与赢得游戏,190–199

pipopipette,186–187

表示游戏,188–189

评分游戏,189–190

代数,5

算法方法

查普曼算法,9–10

用脖子思考,6–9

算法,13

添加理论精度,63–64

α–β 剪枝,199

避免使用,48–49

巴比伦,90

博伊尔-沃森,136

与函数比较,60–63

计数步骤,57–60

分治法,69

做更多的,202–203

寻找最大值,42

获得专业知识,209

测量效率,55–57

测量时间,57

合并排序后的列表,67

极小极大算法,195–198

手动执行,14–18,20–21

扰动搜索,112

避免使用,48–49

算法解决问题,10–11

税率,39

使用大 O 符号,64–65

在算法中,17

阿尔·花拉子米,5,10

α–β 剪枝算法,199

分析方法

伽利略模型,2–4

内在的物理学家,5–6

x 策略,4–5

角度,正切,8–9

退火,过程, 117

方阵的反对角线, 26–27

append() 方法,俄罗斯农民乘法, 18

参数,魔方阵, 31–34

人工智能(AI), 185186

添加增强功能, 199

绘制棋盘, 187–188

游戏树和赢得游戏, 190–199

拉皮波皮管, 186–187

表示游戏, 188–189

评分游戏, 189–190

渐近线,与最大值的关系, 39–40

B

巴比伦算法, 90

球。另见 外场手问题

水平位置, 7

绘制轨迹, 1–2, 4, 7

切线计算, 8–9

ball_trajectory() 函数, 3–4

棒球,科学特征, 6

钟形曲线, 95–96

between_spaces 变量,创建, 154

大 O 标记法

睡眠排序的运行时间, 72

使用, 64–65

台球和随机性, 91

二进制分支过程,与决策树结合使用, 166–167

二进制扩展, 17

二分查找, 73–75

平分,几何术语, 130

比特,字符串, 97–98

游戏板,绘制点和方块游戏, 187–189

自举法, 91

博威尔-沃森算法, 136。另见 DT(德劳内三角剖分);三角剖分

大脑,“湿件”,5

分支过程,与决策树结合使用, 166–167

暴力破解法,TSP(旅行商问题)中使用, 107

布什,瓦内瓦尔, 6

C

微积分,规则, 38

三角形的质心,求解, 131–133

查普曼,塞维利亚, 6

查普曼算法, 9–11。另见 外场手问题

聊天机器人,构建, 203–208

国际象棋,解决八皇后问题, 209–212

切斯特顿,G.K., 151

绘制圆形, 133

外接圆心

查找三角形,131–133

绘图,145

与三角形的关系,134

外接圆

绘图,145

与三角形的关系,132,134

组合爆炸,在 TSP(旅行商问题)中的应用,108

复合词的处理,152–153。另见 单词

欧几里得的构造方法,20

连分数。另见 从分数到有理数

生成算法,82–85

压缩并传递 Phi,79–80

与小数的关系,86–88

概述,78,80–82

到有理数,88

连续平方根,88

语料库,149,160。另见 导入语料库

余弦相似度,206–208

使用Counter()函数与 n + 1-gram,161

计步,57–60

D

小数到连分数,86–88

决策树。另见 人工智能(AI)游戏树;机器学习;随机森林

增加深度,175–177

构建,167

计算幸福感水平,170

选择切分点,182

选择切分变量,173–175,182

下载数据集,168

评估,178–182

查看数据,168–169

节点,167

样本外观察,180

过拟合,181–182

概述,165–166

预测误差,171–172

过拟合问题,179–181

剪枝,182,199

样本内观察,180

简化,181–182

切分点,171

切分数据,169–173

测试集,180

训练集,180

欠拟合,181–182

使用嵌套列表,176

德劳内三角剖分(DT)。另见 几何

生成,136–139

实现,139–143

概览,134–136

目的,* 136*

从点返回,142

到 Voronoi,143–147

导数,计算,* 38*

德夫林,基思,5–6

字典对象,为聊天机器人创建,203

随机性检验中的 Diehard 测试,* 95*–97

分治算法,* 69*

狗,抓飞盘,6

点与框游戏。另见 游戏

绘图板,187–188

玩耍,* 186*–187

得分,190

倍增列,RPM(俄罗斯农民乘法),14–20

down_left,Kurushima 算法,28–29

drawgame() 函数,游戏中使用,188–189

绘制圆形,133

drawlattice() 函数,游戏中使用,188–189

DT(Delaunay 三角剖分)。另见 Bowyer-Watson 算法;三角剖分

生成,136–139

实现,139–143

概览,134–136

目的,* 136*

从点返回,142

到 Voronoi,143–147

E

教育与终生收入,42–45

元素,20

等边,几何术语,* 130*

ESS(欧洲社会调查),与决策树一起使用,168

欧几里得算法,* 20*–22,84–85

异或操作,* 98*

指数函数,* 60*–61

F

False,Kurushima 算法,27

反馈移位寄存器,98

文件排序方法,52–54。另见 排序文件柜

fillsquare() 函数,Kurushima 算法,31–32

查找单词,151–152

finditer() 函数,处理单词时使用,152

findnearest() 函数,在 TSP(旅行商问题)中使用,109

float('nan') 函数,与 Kurushima 算法一起使用,24

floor() 函数,用于二分查找,73–74

for 循环,与单词和空格一起使用,157

分数到根式,88。参见 继续分数

富兰克林,本杰明,126

飞盘,轨迹向量,6

函数

反转,75

递归,22

G

伽利略模型,2–5

游戏树。参见 AI(人工智能);决策树;随机森林

构建,192–195

以及赢得游戏,190–192

游戏。参见 点和框游戏

选择移动,195–198

极小极大算法,195–198

表示,188–189

评分,189–190

胜利,195–198

高斯正态曲线,96

gen_delaunay() 函数,传递 xy 值,143

generate_tree() 函数,使用于游戏,194

genlines 函数,使用三角形,129

genlines 函数,TSP(旅行商问题),104

几何。参见 DT(德劳内三角剖分)

邮差问题,126–128

表示点,128

角度的正切,8–9

术语,130

三角形,128–134

get_number() 函数,使用继续分数,85

get_prediction() 函数,使用决策树,178–179

get_split() 函数,使用决策树,174–176

get_splitpoint() 函数,使用决策树,174

git bisect 软件,二分查找使用,75

全局变量,用于模拟退火的定义,122

黄金比例,78–79

梯度上升,35

攀登收入山,44–45

实现,40–41

局部极值,42–44

异议,41–42

使用,49

梯度下降,3547

《重力的彩虹》,3

贪心算法,TSP(旅行商问题),112–113

引导搜索,在 TSP(旅行商问题)中使用,112

H

half_double dataframe,RPM(俄罗斯农民乘法),18

折半列,俄罗斯农民乘法,14–20

幸福水平,用决策树计算,170

爬山算法,47–48

howfull 参数,Kurushima 算法,31–32

I

if 语句

插入 pop() 函数,66–67

使用单词和空格,151

导入语料库,用于检查有效单词,154–155。参见 语料库

内部物理学理论,* 5*–6

样本内观测值,使用于决策树,* 180*

insert() 函数,使用于位,98

插入排序,52–55

与指数函数的比较,61

步数计数,63–64

步数计数器,58

安装,matplotlib 模块,3

整数,除法得到商,84

inverse_sin(0.9) 函数,用于二分查找,75

反转函数,75

无理数,* 79*

J

日本魔方。参见 魔方;正方形

Python 中的 Kurushima 算法,24–30

在 Python 中的洛书方阵,22–23

K

开普勒,约翰内斯,78

k-means 机器学习方法,56

k-NN 机器学习方法,56

Kurushima 算法

函数,30–31

规则,25–28

L

la pipopipette,* 186*–187

语言算法

难度,150

短语完成,159–163

空格插入,150–158

格子,使用 la pipopipette,186–187

LCGs(线性同余生成器),92–93

leftright 变量,Python,66

莱布尼茨,戈特弗里德·威廉,130–131

LFSRs(线性反馈移位寄存器),97–99

终生收入与教育,42–45

视线,抛物体的轨迹图,7–8

列表推导式,* 149*,156

列表索引语法,Python,68–69

列表,排序,153

loc 功能,RPM(俄罗斯农民乘法),19

局部极值,问题, 42–45

循环,俄罗斯农民乘法, 18

下界,定义二分查找, 73

lower() 方法,和聊天机器人一起使用, 203

吕卡斯,埃杜阿尔, 186

洛书方阵,在 Python 中创建, 22–23

M

机器学习。 另见 决策树

概述, 165

随机森林, 182–183

机器学习方法,k-means 聚类和 k-NN, 56

魔眼, 147

魔方阵, 22–23。 另见 日本魔方阵;方阵

参数, 31–34

黑石算法, 30–31

奇数维度, 24

模式, 34

“走过”过程, 28

The Math Instinct: Why You’re a Mathematical Genius (Along with Lobsters, Birds, Cats, and Dogs), 5–6

math 库,Python, 73–74

数学物理,解释, 92

math.floor(),俄罗斯农民乘法, 18

matplotlib 模块

设置税率, 36–37

和点与框游戏一起使用, 187–188

matplotlib 模块,安装, 3

max() 函数,和 numpy 一起使用, 162

最大值和最小值, 35

最大化与最小化, 45–48

最大值

和渐近线方法, 39–40

教育与终身收入, 44–45

步长值的最小值, 60–61

收入, 39

求解一阶条件, 42

税收/收入曲线, 41–42

maxitin 参数,添加, 122

合并排序, 65, 68–70。 另见 排序

梅森旋转 PRNG, 99

元启发式,基于隐喻, 117–118

三上义男, 22

千年奖问题, 212

minimax 算法

用于做决策, 199

用于游戏获胜, 195–198

minimax() 函数,调用, 198

模运算符(%

欧几里得算法, 21

黑石算法, 27–28

俄罗斯农民乘法, 19

规则,32

蒙特卡罗方法,199

神秘数和连分数,81

N

n + 1-gram,查找,161–163

n 皇后问题,求解国际象棋,210*–211

nan 条目,填充,25–28,30–31

纳维尔-斯托克斯方程,5

最近邻算法,TSP(旅行推销员问题),108–110

嵌套列表,在决策树中使用,176

嵌套根号,88

next_random() 函数,93

n-gram,分词和获取,159–160

诺维格,彼得,160

NP(非确定性多项式)复杂度类,212–213

编号文件,插入,54

numpy 模块

导入,60

用于选择短语,162

在决策树中使用,180–181

O

优化,101–102。另见 模拟退火;TSP(旅行推销员问题)

外场问题,1–2,6–9。另见 球;查普曼算法

样本外观测值,在决策树中使用,180

过拟合决策树,181–182

重叠和的测试,95–96

P

P 复杂度类问题,212–213

pandas 模块,在 Python 中使用,19

百分位,在决策树中使用,172–173

垂直,几何术语,130

perturb() 函数

修改,116

显示结束,121

更新,119

用于模拟退火,123

在 TSP(旅行推销员问题)中使用,111–112

扰动搜索算法,112另见 模拟退火

phi

压缩和通信,79–80

黄金比例,78

短语完成,159–163

plot() 函数,在点和框游戏中使用,187–188

plot_triangle() 函数

定义,129

改进,133–134

plotitinerary() 函数,在 TSP(旅行推销员问题)中使用,105

绘图功能,伽利略模型,3

.png 文件,保存至,129–130

点,表示,128–130

points_to_triangle() 函数

定义,128

在三角剖分中使用,134

多项式,伽利略模型,3

多项式时间,验证解决方案,212

pop() 方法

插入到 if 语句中,66–67

使用位操作,98

pop() 方法,通过插入排序,55

邮局问题,126–128

潜在单词。参见 单词

检查,153–154

寻找其一半,156–158

预测误差,决策树,171–172

print(cities) 函数,TSP(旅行商问题),103

print(lines) 函数,TSP(旅行商问题),104

print(square) 函数,结合 Kurushima 算法使用,24–25

PRNGs(伪随机数生成器),* 92*–99

使用算法解决问题,10–11

古腾堡计划,160

剪枝决策树,182,* 199*

伪随机性,* 92*–93

托马斯·品钦,3

毕达哥拉斯定理

使用,105

与三角形一起使用,130

在 TSP(旅行商问题)中使用,108–109

Python

创建洛书方阵,22–23

欧几里得算法,20–22

反馈移位寄存器,98

伽利略模型,3

实现 RPM(俄罗斯农民乘法),18–20

Kurushima 算法,24

leftright 变量,66

列表索引语法,68

math 库,73–74

在中有序对,152

重叠和测试,* 95*–96

pandas 模块,19

random 模块,58–59

random.choice() 函数,28

Kurushima 算法的规则,27–28,30–31

在 Python 中的平方根,90–91

timeit 模块,57

使用元组与单词和空格,152

Q

商,整数除法的结果,84

R

根数与分数,88

半径,返回三角形的,132–133

拉马努金,Srinivasa,88

随机森林,182–183。 另见 决策树;游戏树

random 模型,Python,58–59

随机数生成器

判断伪随机数生成器(PRNGs),93–95

LCDs(线性同余生成器),92–93

LFSR(线性反馈移位寄存器),97–99

概述,91

random.choice() 函数,Python,28

随机性

Diehard 测试,95–97

可能性,91–92

random.seed() 函数,59

递归

函数的,22

使用合并排序的实现,69

使用欧几里得算法,85

re.finditer() 函数,使用于单词,152

reindex() 方法,使用于决策树,181

remove() 函数,使用于单词和空格,155

replace() 函数,使用于单词和空格,155

resetthresh 变量,添加,122

收入

最大值,39

显示税率,36–37

rightleft 变量,Python,66

RPM(俄罗斯农民乘法),13–20

规则,应用于 Kurushima 算法,27,30–31

S

科学,定律,130–131

评分游戏,189–190

搜索建议,生成策略,160,162–163

搜索与排序,72–75

莎士比亚的作品,访问,160–161,163

siman() 函数,使用于模拟退火,122–123

西蒙斯,Joseph,179

模拟退火,115–124。 另见 优化;扰动搜索;TSP(旅行商问题)

睡眠排序,70–72。 另见 排序

史密斯,David Eugene,22

x 策略,4–5,10–11

排序文件柜,合并,62,64–65。另见 文件排序方法

排序。另见 合并排序;睡眠排序

列表,153

通过插入,54–55

搜索,72–75

空格插入

检查潜在单词,153–154

检查有效单词,154–156

处理复合词,152–153

定义单词列表,151–152

查找潜在单词的半部分,156–158

查找单词,151–152

概述,150–151

空格

获取子字符串,位于之间,153–154

插入到文本中,158

以...结尾的单词,156

拆分点,选择用于决策树,171,182

拆分变量,选择用于决策树,182

方括号 ([])

与列表推导式一起使用,152

loc 功能一起使用,19

方阵,反对角线,26–27

平方根,89–91

填充方格,30–34。另见 日本魔方;魔方

start() 函数,与单词一起使用,153

统计方法,作为自助法,91

步骤

插入排序中的计数,57–60,63–64

指数增长,60–61

随机梯度上升,45

字符串,拆分成单词,159–160

子字符串,在空格之间获取,153–154

解数独,211–212

T

角度的切线,8–9

设置税率,36–41

税收/收入曲线,梯度上升,41

税收/收入曲线,翻转,46–47

温度函数,TSP(旅行商问题),113–115

测试集,应用于决策树,180

文本规范化,与聊天机器人一起使用,203

文本向量化,204–206

TFIDF(词频-逆文档频率)方法,204–205,207–208

角度,应用于投掷的球,8–9

用脖子思考,6–9

时间,精确测量,57

timeit 模块,Python,57

泰坦尼克号 救生艇示例,使用睡眠排序,71–72

分词,与聊天机器人一起使用,204

分词 n-gram,* 159*–160

训练集,与决策树一起使用,* 180*

translate() 方法,与聊天机器人一起使用,203–204

分类和决策树,* 166*

三角形

重心,* 131*–133

创建用于邮递员问题,128–134

寻找外接圆心,131–133

绘制,129,145–146

替换,140–143

三角剖分。 参见 Bowyer-Watson 算法;DT(德劳内三角剖分)

定义,* 134*

七个点,135

True,Kurushima 算法,27

TSP(旅行商问题)。 参见 优化;模拟退火

贪心算法,* 112*–113

改进,110–112

最近邻算法, 108–110

概述,102–103

与邮递员问题对比,127

设置,103–108

温度函数,113–115

元组,使用于单词和空格,* 152*

U

决策树的欠拟合,* 181*–182

up_right,Kurushima 算法,28–29

上界,定义用于二分查找,73

V

向量相似度,确定,206–208

顶点,几何术语,* 130*

Voronoi 图

生成,143–147

用于邮递员问题,128

W

while 循环,Kurushima 算法,31

while 循环

用于二分查找,74

与位一起使用,99

与连分数一起使用,85

与归并排序一起使用,67

与平方根一起使用,90–91

while 循环,俄罗斯农民乘法, 18

赢得比赛,195–198

词汇表,定义,151–152

单词。另见 复合词;潜在词

使用导入语料库检查有效性,154–156

以空格结尾,156

查找,151–152

分词,159–160

X

XOR 操作,98

posted @ 2025-12-01 09:40  绝不原创的飞龙  阅读(0)  评论(0)    收藏  举报