Python-数据结构与算法-全-

Python 数据结构与算法(全)

原文:zh.annas-archive.org/md5/fa80ae184021d0dfb02d4c92e4b82203

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

了解数据结构和使它们生动起来的算法是构建成功数据应用的关键。有了这些知识,我们就有了一种强大的方式来揭示大量数据中隐藏的秘密。在数据饱和的世界里,数据的产生量远远超过了我们的分析能力,这种技能变得越来越重要。在这本书中,你将学习到必要的 Python 数据结构和最常用的算法。本书将提供 Python 的基本知识以及对数据算法激动人心的世界的洞察。我们将研究解决数据分析中最常见问题的算法,包括排序和搜索数据,以及能够从数据中提取重要统计信息。通过这本易于阅读的书籍,你将学习如何创建复杂的数据结构,如链表、栈和队列,以及排序算法,如冒泡排序和插入排序。你将学习在预处理、建模和转换数据等任务中使用的常见技术和结构。我们还将讨论如何以可管理、一致和可扩展的方式组织代码。你将学习如何构建易于理解、调试和使用于不同应用的组件。

对数据结构和算法的良好理解不容忽视。这是理解新问题并找到优雅解决方案的重要武器。通过更深入地理解算法和数据结构,你可能会发现它们在许多原本未预料到的方式中都有用。你将开始关注你编写的代码以及它对内存和 CPU 周期的最少影响。代码将不再仅仅是为了编写,而是以最小资源实现更多功能的思维方式。当经过彻底分析和审查的程序在实际环境中使用时,其性能令人愉悦。马虎的代码总是导致性能不佳。无论你是纯粹从智力锻炼的角度喜欢算法,还是将其作为解决问题的灵感来源,这都是值得追求的投入。

Python 语言进一步为许多专业人士和学生的编程欣赏打开了大门。这种语言易于使用,对问题的描述简洁。我们利用语言的广泛吸引力来研究许多广泛研究和标准化的数据结构和算法。

本书以对 Python 编程语言的简要概述开始。因此,在拿起这本书之前,你不需要了解 Python。

本书涵盖的内容

第一章,《Python 对象、类型和表达式》,介绍了 Python 的基本类型和对象。我们将概述语言特性、执行环境和编程风格。我们还将回顾常见的编程技术和语言功能。

第二章,《Python 数据类型和结构》,解释了五种数值和五种序列数据类型,以及一种映射和两种集合数据类型,并检查了适用于每种类型的操作和表达式。我们还将给出典型用例的示例。

第三章,《算法设计原理》,介绍了如何使用现有的 Python 数据结构构建具有特定功能的新结构。一般来说,我们创建的数据结构需要符合一系列原则。这些原则包括健壮性、适应性、可重用性以及将结构从功能中分离出来。我们探讨了迭代在其中的作用,并介绍了递归数据结构。

第四章,《列表和指针结构》,涵盖了链表,这是最常见的几种数据结构之一,常用于实现其他结构,如栈和队列。在本章中,我们描述了它们的操作和实现。我们将它们的行为与数组进行比较,并讨论了各自的相对优缺点。

第五章,《栈和队列》,讨论了这些线性数据结构的行为,并演示了一些实现。我们给出了典型应用的例子。

第六章,《树》,将探讨如何实现二叉树。树是许多最重要的先进数据结构的基础。我们将研究如何遍历树以及检索和插入值。我们还将探讨如何创建如堆这样的结构。

第七章,《哈希和符号表》,描述了符号表,给出了一些典型实现,并讨论了各种应用。我们将探讨哈希过程,给出哈希表的实现,并讨论各种设计考虑因素。

第八章,《图和其他算法》,探讨了某些更专业的结构,包括图和空间结构。在许多应用中将数据表示为节点和顶点的集合是方便的,基于此,我们可以创建如有向图和无向图这样的结构。我们还将介绍其他一些结构如优先队列、堆和选择算法等概念。

第九章,搜索,讨论了最常见的搜索算法,并给出了它们在各种数据结构中使用的示例。搜索数据结构是一个基本任务,有几种不同的方法。

第十章,排序,探讨了排序的最常见方法。这包括冒泡排序、插入排序和选择排序。

第十一章,选择算法,涵盖了涉及寻找统计数据(如列表中的最小值、最大值或中值元素)的算法。有几种不同的方法,其中最常见的方法是首先应用排序操作。其他方法包括分区和线性选择。

第十二章,设计技术和策略,与我们试图解决新问题时寻找类似问题的解决方案有关。理解我们如何对算法进行分类以及它们最自然解决的类型的问题是一个关键方面。我们可以以许多方式对算法进行分类,但最有用的分类往往围绕实现方法或设计方法。

第十三章,实现、应用和工具,讨论了各种现实世界的应用。这包括数据分析、机器学习、预测和可视化。此外,还有库和工具使我们的算法工作更加高效和愉快。

您需要为这本书准备什么

这本书中的代码将需要您运行 Python 2.7.x 或更高版本。Python 的默认交互式环境也可以用来运行代码片段。为了使用其他第三方库,您的系统上应安装 pip。

本书面向的对象

这本书将吸引 Python 开发者。虽然首选具有 Python 基础知识,但不是必需的。不假设有计算机概念的前置知识。大多数概念都通过日常场景进行解释,以便于理解。

习惯用法

在这本书中,你会找到许多文本样式,用于区分不同类型的信息。以下是一些这些样式的示例及其含义的解释。

文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称如下所示:"这种重复的结构可能是一个简单的while循环或任何其他类型的循环"。

代码块设置如下:

    def dequeue(self):
        if not self.outbound_stack:
            while self.inbound_stack:
                self.outbound_stack.append(self.inbound_stack.pop())
        return self.outbound_stack.pop()

当我们希望将您的注意力引向代码块的一个特定部分时,相关的行或项目将以粗体显示:

    def dequeue(self):
        if not self.outbound_stack:
            while self.inbound_stack:
                self.outbound_stack.append(self.inbound_stack.pop())
        return self.outbound_stack.pop()

任何命令行输入或输出都如下所示:

% python bubble.py

新术语重要词汇以粗体显示。屏幕上显示的单词,例如在菜单或对话框中,在文本中显示如下:“点击“下一步”按钮将您移动到下一屏幕。”

警告或重要提示以这种方式出现在框中。

小贴士和技巧看起来像这样。

读者反馈

我们的读者反馈总是受欢迎的。请告诉我们您对这本书的看法——您喜欢或不喜欢什么。读者反馈对我们来说很重要,因为它帮助我们开发出您真正能从中获得最大价值的标题。

要向我们发送一般反馈,请简单地发送电子邮件至feedback@packtpub.com,并在邮件主题中提及书籍的标题。

如果您在某个主题上具有专业知识,并且您有兴趣撰写或为书籍做出贡献,请参阅我们的作者指南www.packtpub.com/authors

客户支持

现在,您已经成为 Packt 书籍的骄傲所有者,我们有一些事情可以帮助您充分利用您的购买。

下载示例代码

您可以从www.packtpub.com的账户下载此书的示例代码文件。如果您在其他地方购买了此书,您可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。

您可以通过以下步骤下载代码文件:

  1. 使用您的电子邮件地址和密码登录或注册我们的网站。

  2. 将鼠标指针悬停在顶部的“支持”选项卡上。

  3. 点击“代码下载 & 错误清单”。

  4. 在搜索框中输入书籍名称。

  5. 选择您想要下载代码文件的书籍。

  6. 从下拉菜单中选择您购买此书的来源。

  7. 点击“代码下载”。

文件下载完成后,请确保您使用最新版本的软件解压或提取文件夹:

  • 适用于 Windows 的 WinRAR / 7-Zip

  • 适用于 Mac 的 Zipeg / iZip / UnRarX

  • 适用于 Linux 的 7-Zip / PeaZip

书籍的代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Python-Data-Structures-and-Algorithma。我们还有来自我们丰富的图书和视频目录的其他代码包,可在github.com/PacktPublishing/找到。查看它们吧!

错误清单

尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在我们的书中发现错误——可能是文本或代码中的错误——如果您能向我们报告这一点,我们将不胜感激。通过这样做,您可以避免其他读者的挫败感,并帮助我们改进本书的后续版本。如果您发现任何勘误,请通过访问www.packtpub.com/submit-errata,选择您的书籍,点击勘误提交表单链接,并输入您的勘误详情来报告它们。一旦您的勘误得到验证,您的提交将被接受,勘误将被上传到我们的网站或添加到该标题的勘误表部分。

要查看之前提交的勘误表,请访问www.packtpub.com/books/content/support,并在搜索字段中输入书籍名称。所需信息将出现在勘误表部分。

侵权

互联网上版权材料的侵权是一个持续存在的问题,涉及所有媒体。在 Packt,我们非常重视保护我们的版权和许可证。如果您在互联网上发现我们作品的任何非法副本,请立即提供位置地址或网站名称,以便我们可以寻求补救措施。

请通过copyright@packtpub.com与我们联系,并提供疑似侵权材料的链接。

我们感谢您在保护我们作者和我们为您提供有价值内容的能力方面的帮助。

问题

如果您对本书的任何方面有问题,您可以通过questions@packtpub.com与我们联系,我们将尽力解决问题。

第一章:Python 对象、类型和表达式

Python 是许多高级数据任务的优选语言,这有一个非常好的原因。Python 是最容易学习的高级编程语言之一。直观的结构和语义意味着对于不是计算机科学家的人来说,比如生物学家、统计学家或初创公司的负责人,Python 是执行各种数据任务的直接途径。它不仅仅是一种脚本语言,而是一种功能齐全的面向对象编程语言。

在 Python 中,有许多有用的数据结构和算法内置在语言中。此外,由于 Python 是一种基于对象的编程语言,因此相对容易创建自定义数据对象。在本书中,我们将检查 Python 的内部库,一些外部库,以及如何从第一原理构建自己的数据对象。

本书假设您已经了解 Python。然而,如果您对 Python 有点生疏,来自其他语言,或者根本不知道 Python,请不要担心,第一章应该能迅速让您跟上进度。如果不行,请访问 docs.python.org/3/tutorial/index.html,您也可以在 www.python.org/doc/ 找到文档。这些都是学习这种编程语言的极好资源。

在本章中,我们将探讨以下主题:

  • 获得数据结构和算法的一般工作知识

  • 理解核心数据类型及其功能

  • 探索 Python 编程语言的面向对象特性

理解数据结构和算法

算法和数据结构是计算中最基本的概念。它们是构建复杂软件的基石。对这些基础概念的理解在软件设计中至关重要,这涉及到以下三个特征:

  • 算法如何操作数据结构中包含的信息

  • 数据在内存中的排列方式

  • 特定数据结构的性能特征是什么

在本书中,我们将从多个角度探讨这个主题。首先,我们将从数据结构和算法的角度来看 Python 编程语言的基础。其次,拥有正确的数学工具非常重要。我们需要理解一些计算机科学的基本概念,而这需要数学知识。通过采用启发式方法,制定一些指导原则意味着,通常情况下,我们不需要超过高中数学水平就能理解这些关键思想的原则。

另一个重要方面是评估。衡量算法性能涉及理解数据大小每增加一次如何影响该数据上的操作。当我们处理大型数据集或实时应用时,我们的算法和结构尽可能高效是至关重要的。

最后,我们需要一个合理的实验设计策略。能够将现实世界问题概念性地转化为编程语言的算法和数据结构,涉及到理解问题的关键要素以及将这些要素映射到编程结构的方法。

为了让我们对算法思维有所了解,让我们考虑一个现实世界的例子。想象我们在一个不熟悉的市场,并被分配购买一系列项目的任务。我们假设市场布局是随机的,每个供应商销售的项目子集是随机的,其中一些可能在我们列表上。我们的目标是尽量减少每个项目的价格以及尽量减少在市场花费的时间。一种方法是编写如下算法:

对每个供应商重复:

  1. 供应商是否有我列表上的项目,并且成本是否低于该项目的预测成本?

  2. 如果是,购买并从列表中删除;如果不是,转到下一个供应商。

  3. 如果没有更多的供应商,结束。

这是一个简单的迭代器,包含决策和动作。如果我们实现这个功能,我们需要数据结构来定义我们想要购买的项目列表以及每个供应商的项目列表。我们需要确定匹配每个列表中项目的最佳方式,并且需要某种逻辑来决定是否购买。

关于这个算法,我们可以做出几个观察。首先,由于成本计算基于预测,我们不知道实际的平均成本是多少;如果我们低估了某个项目的成本,我们会在市场结束时发现列表上还有剩余的项目。因此,我们需要一种有效的方法来回溯到成本最低的供应商。

此外,我们需要了解当我们的购物列表上的项目数量或每个供应商销售的项目数量增加时,比较购物列表上的项目与每个供应商销售的项目所需的时间会发生什么变化。我们搜索项目的顺序和数据结构的形式可以大大影响搜索所需的时间。显然,我们希望以这种方式安排我们的列表,以及我们访问每个供应商的顺序,以最小化搜索时间。

此外,考虑当我们改变购买条件为以最低价格购买,而不仅仅是低于平均价格时会发生什么。这完全改变了问题。我们不再按顺序从一个供应商走到下一个供应商,我们需要遍历市场一次,并利用这些知识,我们可以根据我们想要访问的供应商来排序我们的购物列表。

显然,将现实世界问题转化为编程语言等抽象结构涉及许多细微之处。例如,随着我们在市场的进展,我们对产品成本的了解不断改进,因此我们的预测平均价格变量变得更加准确,直到最后摊位,我们对市场的了解变得完美。假设任何类型的回溯算法都会产生成本,我们可以看到有必要审查我们的整个策略。诸如高价格变异性、我们的数据结构的大小和形状以及回溯的成本等因素都决定了最合适的解决方案。

Python 用于数据

Python 具有几个内置的数据结构,包括列表、字典和集合,我们使用它们来构建自定义对象。此外,还有一些内部库,如 collections 和math对象,允许我们创建更高级的结构以及在这些结构上执行计算。最后,还有外部库,如SciPy包中找到的库。这些库允许我们执行一系列高级数据任务,如逻辑回归和线性回归、可视化以及矩阵和向量的数学计算等。外部库对于“即用型”解决方案非常有用。然而,我们也必须意识到,与从头开始构建自定义对象相比,通常会有性能上的损失。通过学习如何自己编写这些对象,我们可以针对特定任务进行优化,使它们更高效。这并不是要排除外部库的作用,我们将在第十二章设计技术和策略**中探讨这一点

首先,我们将概述一些关键语言特性,这些特性使 Python 成为数据编程的绝佳选择。

Python 环境

Python 环境的特性之一是其交互式控制台,它允许你既可以将 Python 用作桌面可编程计算器,也可以用作编写和测试代码片段的环境。控制台的读取-评估-打印循环是一种与大型代码库交互的非常方便的方式,例如运行函数和方法或创建类的实例。这是 Python 相对于 C/C++或 Java 等编译语言的主要优势之一,在 Python 的读取-评估-打印循环中,编写-编译-测试-重新编译的周期可以显著增加开发时间。能够输入表达式并获得即时响应可以大大加快数据科学任务的速度。

除了官方的 CPython 版本之外,还有一些优秀的 Python 发行版。其中最受欢迎的两个是 Anaconda (www.continuum.io/downloads) 和 Canopy (www.enthought.com/products/canopy/)。大多数发行版都附带自己的开发环境。Canopy 和 Anaconda 都包括用于科学、机器学习和其他数据应用的库。大多数发行版都附带一个编辑器。

除了 CPython 版本之外,还有许多 Python 控制台的实现。其中最值得注意的是 Ipython/Jupyter 平台,它包括一个基于网络的计算环境。

变量和表达式

将现实世界的问题转化为算法可以解决的问题,有两个相互关联的任务。首先,选择变量,其次,找到与这些变量相关的表达式。变量是附加到对象上的标签;它们不是对象本身。它们也不是对象的容器。变量不包含对象,而是作为对象指针或引用。例如,考虑以下代码:

图片

在这里,我们创建了一个变量 a,它指向一个列表对象。我们创建了另一个变量 b,它指向这个相同的列表对象。当我们向这个列表对象添加一个元素时,这个变化会在 ab 中都反映出来。

Python 是一种动态类型语言。在程序执行期间,变量名可以绑定到不同的值和类型。每个值都有其类型,例如字符串或整数;然而,指向这个值的名称并没有特定的类型。这与许多语言(如 C 和 Java)不同,在这些语言中,名称代表固定的大小、类型和内存中的位置。这意味着当我们初始化 Python 中的变量时,我们不需要声明类型。此外,变量,或者更具体地说,它们指向的对象,可以根据分配给它们的值改变类型,例如:

图片

变量作用域

理解函数内部变量的作用域规则非常重要。每次函数执行时,都会创建一个新的局部命名空间。这代表了一个包含函数分配的参数和变量名称的局部环境。当函数被调用时,Python 解释器首先搜索局部命名空间(即函数本身),如果找不到匹配项,它将搜索全局命名空间。这个全局命名空间是定义函数的模块。如果名称仍然没有找到,它将搜索内置命名空间。最后,如果这还失败,解释器将引发一个 NameError 异常。考虑以下代码:

    a=10; b=20 
    def my_function(): 
        global a 
        a=11; b=21 
    my_function() 
    print(a) #prints 11 
    print(b) #prints 20

这是前面代码的输出:

图片

在前面的代码中,我们定义了两个全局变量。我们需要使用关键字global告诉解释器,在函数内部,我们正在引用一个全局变量。当我们将其更改为11时,这些更改将在全局范围内反映出来。然而,我们设置为21的变量b是局部于函数的,并且在该函数内部对其所做的任何更改都不会反映在全局范围内。当我们运行该函数并打印b时,我们看到它保留了其全局值。

流程控制和迭代

Python 程序由一系列语句组成。解释器按顺序执行每个语句,直到没有更多语句为止。这适用于既作为主程序运行,也通过import加载的文件。所有语句,包括变量赋值、函数定义、类定义和模块导入,都具有相同的状态。没有比其他语句具有更高优先级的特殊语句,每个语句都可以放置在程序中的任何位置。有两种主要方式来控制程序执行的流程,即条件语句和循环。

ifelseelif语句控制语句的条件执行。一般格式是一系列ifelif语句,后跟一个最终的else语句:

    x='one' 
    if x==0:  
        print('False') 
    elif x==1: 
        print('True') 
    else: print('Something else') 
    #prints 'Something else' 

注意使用==运算符来测试相同的值。如果值相等,则返回true;否则返回false。还要注意,将x设置为字符串将返回其他内容,而不是像在非动态类型语言中可能发生的那样生成类型错误。动态类型语言,如 Python,允许灵活地为不同类型的对象分配。

控制程序流程的另一种方式是使用循环。它们是通过whilefor语句创建的,例如:

图片

数据类型和对象概述

Python 包含 12 种内置数据类型。这些包括四种数值类型(intfloatcomplexbool)、四种序列类型(strlisttuplerange)、一种映射类型(dict)和两种集合类型。还可以创建用户定义的对象,如函数或类。我们将在本章中查看stringlist数据类型,并在下一章中查看剩余的内置类型。

Python 中的所有数据类型都是 对象。实际上,在 Python 中几乎一切都是对象,包括模块、类和函数,以及字符串和整数等字面量。Python 中的每个对象都有一个 类型、一个 和一个 身份。当我们编写 greet = "hello world" 时,我们正在创建一个具有值 "hello world" 和身份 greet 的字符串对象。对象的身份充当指向对象在内存中位置的指针。对象类型,也称为对象的类,描述了对象的内部表示以及它支持的方法和操作。一旦创建了对象的一个实例,其身份和类型就不能更改。

我们可以使用内置函数 id() 来获取对象的身份。这个函数返回一个标识整数,在大多数系统中这指的是它的内存位置,尽管你不应该在代码中依赖这一点。

此外,还有许多比较对象的方法,例如:

    if a== b: #a and b have the same value 

    if a is b: # if a and b are the same object 
    if type(a) is type(b): # a and b are the same type 

需要在 可变不可变 对象之间做出重要区分。可变对象,如列表,可以更改其值。它们有 insert()append() 等方法可以更改对象的值。不可变对象,如字符串,不能更改其值,因此当我们运行它们的方法时,它们只是返回一个值,而不是更改底层对象的值。当然,我们可以通过将其赋值给变量或用作函数参数来使用这个值。

字符串

字符串是不可变序列对象,每个字符代表序列中的一个元素。与所有对象一样,我们使用方法来执行操作。字符串是不可变的,不会改变实例;每个方法只是返回一个值。这个值可以存储为另一个变量,或者作为函数或方法的参数。

以下表格列出了一些最常用的字符串方法和它们的描述:

方法 描述
s.count(substring, [start,end]) 计算具有可选起始和结束参数的子串出现的次数。
s.expandtabs([tabsize]) 将制表符替换为空格。
s.find(substring, [start, end]) 返回子串首次出现的索引,如果未找到子串,则返回 -1
s.isalnum() 如果所有字符都是字母数字,则返回 True,否则返回 False
s.isalpha() 如果所有字符都是字母,则返回 True,否则返回 False
s.isdigit() 如果所有字符都是数字,则返回 True,否则返回 False
s.join(t) 将序列 t 中的字符串连接起来。
s.lower() 将字符串转换为全小写。
s.replace(old, new [maxreplace]) 将旧子串替换为新子串。
s.strip([characters]) 移除空白字符或可选字符。
s.split([separator], [maxsplit]) 按空白字符或可选分隔符拆分字符串。返回列表。

字符串,像所有序列类型一样,支持索引和切片。我们可以通过使用其索引s[i]来检索字符串中的任何字符。我们可以通过使用s[i:j]来检索字符串的切片,其中ij是切片的起始和结束点。我们可以通过使用步长来返回扩展切片,如下所示:s[i:j:stride]。以下代码应该会使这一点变得清晰:

图片

前两个例子相当直接,分别返回字符串中索引为1的字符和字符串的前七个字符。请注意,索引从0开始。在第三个例子中,我们使用了步长2。这导致返回每个第二个字符。在最后一个例子中,我们省略了结束索引,切片返回整个字符串中的每个第二个字符。

您可以使用任何表达式、变量或运算符作为索引,只要值是整数即可,例如:

图片

另一个常见的操作是使用循环遍历字符串,例如:

图片

由于字符串是不可变的,一个常见的问题是我们在插入值时如何操作。我们不是改变字符串,而是需要考虑构建新的字符串对象以获得所需的结果。例如,如果我们想在问候语中插入一个单词,我们可以将变量分配给以下内容:

图片

如此代码所示,我们使用切片运算符在索引位置5处拆分字符串,并使用+进行连接。Python 永远不会将字符串的内容解释为数字。如果我们需要对字符串进行数学运算,我们需要首先将它们转换为数值类型,例如:

图片

列表

列表可能是 Python 中最常用的内置数据结构,因为它们可以由任意数量的其他数据类型组成。它们是任意对象的简单表示。像字符串一样,它们从零开始的整数进行索引。以下表格包含最常用的列表方法和它们的描述:

方法 描述
list(s) 返回序列s的列表。
s.append(x) 将元素x追加到s的末尾。
s.extend(x) 将列表x追加到s
s.count(x) 统计sx出现的次数。
s.index(x, [start], [stop]) 返回最小的索引i,其中s[i] == x。搜索可以包括可选的起始和停止索引。
s.insert(i,e) 在索引i处插入x
s.pop(i) 返回元素i并从列表中删除它。
s.remove(x) s中删除x
s.reverse() 反转s的顺序。
s.sort(key ,[reverse]) 使用可选的键和逆序对s进行排序。

当我们处理列表和其他容器对象时,了解 Python 用于复制它们的内部机制非常重要。Python 仅在必要时才创建真正的副本。当我们把一个变量的值赋给另一个变量时,这两个变量都指向相同的内存位置。只有当其中一个变量发生变化时,才会分配新的内存槽位。这对于列表等可变复合对象有重要的影响。考虑以下代码:

图片

在这里,list1list2变量都指向内存中的同一个槽位。当我们把y变量改为4时,我们是在改变list1指向的同一个y变量。

列表的一个重要特性是它们可以包含嵌套结构,即其他列表,例如:

图片

我们可以使用方括号运算符来访问列表的值,由于列表是可变的,它们是在原地复制的。以下示例演示了我们可以如何使用这种方法来更新元素;例如,在这里我们提高了面粉的价格 20%:

图片

创建列表的一种常见且直观的方法是使用列表推导。这允许我们通过直接将表达式写入列表来创建列表,例如:

图片

列表推导非常灵活;例如,考虑以下代码。它本质上展示了两种不同的函数组合方式,其中我们将一个函数(x * 4)应用到另一个函数(x * 2)上。以下代码打印出两个列表,分别表示使用for循环和列表推导计算出的f1f2的函数组合:

    def f1(x): return x*2 
    def f2(x): return x*4 

    lst = [] 
    for i in range(16): 
        lst.append(f1(f2(i))) 

    print(lst) 

    print([f1(x)  for x in range(64) if x in [f2(j) for j in range(16)]]) 

输出的第一行来自for循环结构。第二行来自列表推导表达式:

图片

列表推导也可以用来以更紧凑的形式复制嵌套循环的动作。例如,我们将list1中包含的每个元素相互相乘:

图片

我们还可以使用列表推导与其他对象(如字符串)一起使用,以构建更复杂的数据结构。例如,以下代码创建了一个包含单词及其字母计数的列表:

图片

正如我们将看到的,列表是我们将要查看的许多数据结构的基础。它们的灵活性、创建和使用简便性使它们能够构建更专业和复杂的数据结构。

函数作为一等对象

在 Python 中,不仅数据类型被视为对象。函数和类都是一等对象,允许它们以与内置数据类型相同的方式进行操作。根据定义,一等对象是:

  • 在运行时创建

  • 作为变量或数据结构中的赋值

  • 作为函数的参数传递

  • 作为函数的结果返回

在 Python 中,术语一等对象有点误导,因为它暗示了一种某种层次结构,而所有 Python 对象本质上都是一等对象。

要了解这是如何工作的,让我们定义一个简单的函数:

    def greeting(language): 
    if language== 'eng': 
             return 'hello world' 
       if language  == 'fr' 
             return 'Bonjour le monde' 
       else: return 'language not supported' 

由于用户定义的函数是对象,我们可以做诸如将它们包含在其他对象中(如列表)之类的事情:

图片 2

函数也可以用作其他函数的参数。例如,我们可以定义以下函数:

图片 1

在这里,callf()接受一个函数作为参数,设置一个语言变量为'eng',然后使用语言变量作为参数调用该函数。例如,如果我们想创建一个返回多种语言的特定句子的程序,这可能用于某种自然语言应用,我们可以看到这将是有用的。在这里,我们有一个设置语言的中心位置。除了我们的greeting函数外,我们还可以创建返回不同句子的类似函数。通过在设置语言的一个点上,程序的其他逻辑就不必担心这一点。如果我们想更改语言,我们只需更改语言变量,就可以保持其他一切不变。

高阶函数

将其他函数作为参数或返回函数的函数称为高阶函数。Python 3 包含两个内置的高阶函数,filter()map()。请注意,在 Python 的早期版本中,这些函数返回列表;在 Python 3 中,它们返回一个迭代器,这使得它们更加高效。map()函数提供了一个简单的方法将每个项转换为一个可迭代的对象。例如,这是一个高效、紧凑地对序列执行操作的方法。注意使用lambda匿名函数:

图片 5

类似地,我们可以使用内置的filter函数来过滤列表中的项:

图片 4

注意,mapfilter在实现上与列表推导式所能达到的功能相同。除了使用内置函数mapfilter而不使用lambda运算符时,性能略有优势之外,似乎在性能特征上没有很大的差异。尽管如此,大多数风格指南推荐使用列表推导式而不是内置函数,这可能是由于它们通常更容易阅读。

创建我们自己的高阶函数是函数式编程风格的一个显著特点。以下是如何使用高阶函数的一个实际示例。在这里,我们将len函数作为键传递给sort函数。这样,我们可以根据长度对单词列表进行排序:

图片 6

这里是另一个不区分大小写的排序示例:

图片 3

注意list.sort()方法和内置的sorted函数之间的区别。list.sort()list对象的一个方法,它对列表的现有实例进行排序而不进行复制。此方法改变目标对象并返回None。在 Python 中,这是一个重要的约定,即改变对象的函数或方法返回None,以清楚地表明没有创建新对象,而是改变了对象本身。

另一方面,内置的sorted函数返回一个新的列表。它实际上接受任何可迭代对象作为参数,但它总是返回一个列表。list sortsorted都接受两个可选的关键字参数作为键。

使用lambda运算符通过元素的索引对更复杂的数据结构进行排序是一种简单的方法,例如:

图片

在这里,我们根据价格对项目进行了排序。

递归函数

递归是计算机科学中最基本的概念之一。在 Python 中,我们可以通过在其自身函数体内调用它来实现递归函数。为了防止递归函数变成无限循环,我们需要至少一个测试终止情况的参数来结束递归。这有时被称为基本情况。应该指出的是,递归与迭代不同。尽管两者都涉及重复,迭代通过一系列操作进行循环,而递归则反复调用函数。两者都需要选择语句来结束。技术上,递归是迭代的一个特殊案例,称为尾递归,并且通常总是可以将迭代函数转换为递归函数,反之亦然。关于递归函数有趣的是,它们能够在有限语句中描述无限对象。

以下代码应该演示递归和迭代的区别。这两个函数简单地打印出lowhigh之间的数字,第一个使用迭代,第二个使用递归:

图片

注意,在迭代示例iterTest中,我们使用while语句测试条件,然后调用print方法,最后增加low值。递归示例测试条件,打印,然后调用自身,在其参数中增加low变量。一般来说,迭代更有效;然而,递归函数通常更容易理解和编写。递归函数也适用于操作递归数据结构,如链表和树,正如我们将看到的。

生成器和协程

我们可以通过使用yield语句来创建不仅返回一个结果,而是返回整个结果序列的函数。这些函数被称为生成器。Python 包含生成器函数,这是一种创建迭代器的简单方法,并且它们特别适用于替代无法工作的长列表。生成器产生项目而不是构建列表。例如,以下代码展示了为什么我们可能选择使用生成器而不是创建列表:

    # compares the running time of a list compared to a generator 
    import time 
    #generator function creates an iterator of odd numbers between n and m 
    def oddGen(n, m):         
        while n < m: 
            yield n 
            n += 2 
    #builds a list of odd numbers between n and m 
    def oddLst(n,m): 
        lst=[] 
        while n<m: 
            lst.append(n) 
            n +=2 
        return lst 
    #the time it takes to perform sum on an iterator    
    t1=time.time() 
    sum(oddGen(1,1000000)) 
    print("Time to sum an iterator: %f" % (time.time() - t1)) 

    #the time it takes to build and sum a list 
    t1=time.time() 
    sum(oddLst(1,1000000)) 
    print("Time to build and sum a list: %f" % (time.time() - t1))      

这将打印出以下内容:

如我们所见,构建一个列表来完成这个计算需要更长的时间。使用生成器带来的性能提升是因为值是在需要时生成的,而不是作为列表保存在内存中。计算可以在所有元素生成之前开始,并且只有在需要时才会生成元素。

在前面的例子中,sum方法在需要用于计算时将每个数字加载到内存中。这是通过生成器对象反复调用__next__()特殊方法来实现的。生成器永远不会返回除None之外的其他值。

通常,生成器对象用于for循环中。例如,我们可以利用前面代码中创建的oddcount生成器函数来打印出110之间的奇数:

    for i in oddcount(1,10):print(i) 

我们还可以创建一个生成器表达式,它除了将方括号替换为圆括号外,使用相同的语法并执行与列表推导式相同的操作。然而,生成器表达式并不创建列表,而是创建一个生成器对象。这个对象并不创建数据,而是在需要时创建数据。这意味着生成器对象不支持如append()insert()之类的序列方法。但是,你可以使用list()函数将生成器转换为列表:

类和面向对象编程

类是创建新类型对象的一种方式,它们是面向对象编程的核心。类定义了一组属性,这些属性在类的各个实例之间共享。通常,类是一组函数、变量和属性的集合。

面向对象范式非常有吸引力,因为它为我们提供了一个具体的方式来思考和表示程序的核心功能。通过围绕对象和数据而不是动作和逻辑来组织我们的程序,我们有一个强大且灵活的方式来构建复杂的应用程序。当然,动作和逻辑仍然存在,但通过将它们体现在对象中,我们有了封装功能的方法,允许对象以非常具体的方式改变。这使得我们的代码更不容易出错,更容易扩展和维护,并且能够模拟现实世界的对象。

在 Python 中,使用 class 语句创建类。这定义了一组与类实例集合相关的共享属性。一个类通常包含多个方法、类变量和计算属性。重要的是要理解,定义一个类本身并不会创建该类的任何实例。要创建一个实例,必须将一个变量分配给一个类。类体由一系列在类定义期间执行的语句组成。在类内部定义的函数称为实例方法。它们通过传递该类的实例作为第一个参数来对类实例执行一些操作。这个参数传统上被称为 self,但它可以是任何合法的标识符。以下是一个简单的例子:

    class Employee(object): 
        numEmployee = 0 
        def __init__(self, name, rate): 
            self.owed = 0         
            self.name = name 
            self.rate=rate 
            Employee.numEmployee += 1 

        def __del__(self): 
            Employee.numEmployee -= 1 

        def hours(self, numHours): 
            self.owed += numHours * self.rate 
            return("%.2f hours worked" % numHours) 

        def pay(self):                 
            self.owed = 0 
            return("payed %s " % self.name) 

类变量,如 numEmployee,在类的所有实例之间共享值。在这个例子中,numEmployee 用于计算员工实例的数量。请注意,Employee 类实现了 __init____del__ 特殊方法,我们将在下一节讨论。

我们可以通过以下方式创建 Employee 对象的实例,运行方法,并返回类和实例变量:

图片

特殊方法

我们可以使用 dir(object) 函数获取特定对象的属性列表。以两个下划线开头和结尾的方法称为特殊方法。除了以下例外**,特殊方法通常由 Python 解释器而不是程序员调用;例如,当我们使用 + 运算符时,我们实际上是在调用 __add__()。例如,我们不必使用 my_object.__len__(),而是可以使用 len(my_object)。在字符串对象上使用 len() 实际上要快得多,因为它返回表示对象在内存中大小的值,而不是调用对象的 __len__ 方法。我们实际上在程序中调用的唯一特殊方法是 __init__ 方法,用于在类定义中调用超类初始化器。强烈建议不要在自己的对象中使用双下划线语法,因为可能会与 Python 的特殊方法产生当前或未来的冲突。

然而,我们可能想在自定义对象中实现特殊方法,以赋予它们一些内置类型的某些行为。在下面的代码中,我们创建了一个实现了 __repr__ 方法的类。该方法创建了一个字符串表示,这对于检查目的很有用:

    class my_class(): 
        def __init__(self, greet): 
            self.greet = greet 
        def __repr__(self): 
            return 'a custom object (%r)' % (self.greet) 

当我们创建这个对象的实例并检查它时,我们可以看到我们得到了定制的字符串表示。注意使用了 %r 格式占位符来返回对象的规范表示。这很有用,是最佳实践,因为在这种情况下,它显示 greet 对象是一个由引号指示的字符串:

图片

继承

通过继承,可以创建一个新的类来修改现有类的行为。这是通过在类定义中将继承的类作为参数来实现的。这通常用于修改现有方法的行为,例如:

    class specialEmployee(Employee): 
        def hours(self, numHours): 
            self.owed += numHours * self.rate * 2 
            return("%.2f hours worked" % numHours)    

specialEmployee 类的实例与 Employee 实例相同,除了更改了 hours() 方法。

为了使子类定义新的类变量,它需要定义一个 __init__() 方法,如下所示:

    class specialEmployee(Employee): 
        def __init__(self,name,rate, bonus): 
            Employee.__init__(self, name, rate) #calls the base classes 
            self.bonus = bonus 

        def hours(self, numHours): 
            self.owed += numHours * self.rate + self.bonus  
            return("%.2f hours worked" % numHours)      

注意,基类的方 法不是自动调用的,并且派生类需要调用它们。我们可以使用内置函数 isinstance(obj1, obj2) 来测试类成员资格。如果 obj1 属于 obj2 的类或从 obj2 派生的任何类,则返回 true。

在类定义中,假设所有方法都操作实例,但这不是必需的。然而,还有其他类型的方 法:静态方法类方法。静态方法只是一个恰好定义在类中的普通函数。它不对实例执行任何操作,并且使用 @staticmethod 类装饰器定义。静态方法不能访问实例的属性,因此它们最常用的用途是将实用函数分组在一起。

类方法操作的是类本身,而不是实例,就像类变量与类相关而不是与该类的实例相关一样。它们使用 @classmethod 装饰器定义,并且与实例方法区分开来,因为类作为第一个参数传递。按照惯例,这个参数命名为 cls

    class Aexp(object): 
        base=2 
        @classmethod 
        def exp(cls,x): 
            return(cls.base**x) 

    class Bexp(Aexp): 
            base=3 

BexpAexp 类继承,并将基类变量更改为 3。我们可以如下运行父类的 exp() 方法:

图片

虽然这个例子有点牵强,但有几个原因说明类方法可能很有用。例如,因为子类继承了其父类的所有相同功能,所以它有可能破坏继承的方法。使用类方法是一种定义确切运行哪些方法的方式。

数据封装和属性

除非另有说明,否则所有属性和方法都可以无限制地访问。这也意味着在基类中定义的任何内容都可以从派生类中访问。这可能导致我们在构建面向对象的应用程序时遇到问题,我们可能希望隐藏对象的内部实现。这可能导致派生类中定义的对象与基类定义的方法之间出现命名空间冲突。为了防止这种情况,我们定义私有属性的方法具有双下划线,例如__privateMethod()。这些方法名会自动更改为_Classname__privateMethod(),以防止与基类中定义的方法发生名称冲突。请注意,这并不严格地隐藏私有属性,而只是提供了一种防止名称冲突的机制。

建议在使用类属性定义可变属性时使用私有属性。属性是一种属性,它不是返回存储的值,而是在调用时计算其值。例如,我们可以用以下方式重新定义exp()属性:

    class Bexp(Aexp): 
        __base=3 
        def __exp(self): 
            return(x**cls.base)     

在本章中,我们探讨了 Python 编程语言的一些基础知识,从基本操作到 Python 中的函数、类和对象。在下一章中,我们将详细探讨 Python 的内置数据结构。

摘要

本章为我们提供了 Python 编程的良好基础和入门。我们涵盖了变量的使用、列表、几个控制结构,并学习了如何使用条件语句。讨论了各种类型的对象,以及 Python 语言面向对象方面的相关材料。我们创建了自定义对象并从它们继承。

Python 还有更多功能。随着我们准备检查关于算法实现的一些后续章节,下一章将专注于数字、序列、映射和集合。这些也是 Python 中的数据类型,在组织数据以进行一系列操作时非常有用。

第二章:Python 数据类型和结构

在本章中,我们将详细检查 Python 数据类型。我们已经介绍了两种数据类型,即字符串 str()list()。我们通常希望有更专业的对象来表示我们的数据。除了内置类型之外,还有一些内部模块允许我们在处理数据结构时解决常见问题。首先,我们将回顾一些对所有数据类型都通用的运算和表达式。

运算和表达式

有许多运算对所有数据类型都是通用的。例如,所有数据类型,以及通常所有对象,都可以以某种方式测试其真值。以下是在 Python 中被认为是 False 的值:

  • None 类型

  • False

  • 整数、浮点数或复数零

  • 空序列或映射

  • 用户定义的类的实例,该类定义了一个返回零或 False__len__()__bool__() 方法

所有其他值都被认为是 True

布尔运算

布尔运算返回 TrueFalse 的值。布尔运算按优先级排序,因此如果表达式中有多个布尔运算,则优先级最高的运算将首先发生。以下表格按优先级降序概述了三个布尔运算符:

运算符 示例
x 如果 xFalse,则返回 True;否则返回 False
xy 如果 xy 都为 True,则返回 True;否则返回 False
xy 如果 xy 中的任何一个为 True,则返回 True;否则返回 False

and 运算符和 or 运算符在评估表达式时使用“短路”。这意味着 Python 只会在需要时评估运算符。例如,如果 xTrue,则在表达式 x or y 中,由于表达式显然是 True,所以不会评估 y。以类似的方式,在表达式 x and y 中,如果 xFalse,则解释器将简单地评估 x 并返回 False,而不会评估 y

比较和算术运算符

标准算术运算符(+-*/)与所有 Python 数值类型一起工作。// 运算符给出一个整数商(例如,3 // 2 返回 1),指数运算符是 x ** y,而模运算符,由 a % b 给出,返回除法 a/b 的余数。比较运算符(<<=>>===!=)与数字、字符串、列表和其他集合对象一起工作,如果条件成立,则返回 True。对于集合对象,这些运算符比较元素的数量,并且等价运算符 == b 如果每个集合对象在结构上是等效的,并且每个元素的值都相同,则返回 True

成员关系、身份和逻辑运算

成员运算符(innot in)用于测试序列中的变量,例如列表或字符串,如你所期望的,x in y 如果变量 xy 中找到,则返回 Trueis 运算符比较对象身份。例如,以下代码片段显示了对象身份的对比等价性:

内置数据类型

Python 数据类型可以分为三类:数值型、序列型和映射型。还有一个表示空值或值不存在的 None 对象,也不应忘记,其他对象如类、文件和异常也可以被认为是 类型;然而,这里将不会考虑它们。

Python 中的每个值都有一个数据类型。与许多编程语言不同,在 Python 中,你不需要显式声明变量的类型。Python 在内部跟踪对象类型。

Python 内置数据类型如下表所示:

类别 名称 描述
空值 None 空对象。
数值 int 整数。
float 浮点数。
complex 复数。
bool 布尔值(True,False)。
序列 str 字符串。
list 随意对象的列表。
Tuple 随意项的组。
range 创建一个整数范围。
映射 dict 键值对字典。
set 可变、无序且元素唯一的集合。
frozenset 不可变集合。

空值类型

None 类型是不可变的,只有一个值,即 None。它用于表示值不存在。它由未显式返回值的对象返回,在布尔表达式中求值为 False。它通常用作可选参数的默认值,以便函数检测调用者是否传递了值。

数值类型

除了 bool 之外的所有数值类型都是有符号的,并且都是不可变的。布尔值有两个可能的值,TrueFalse。这些值分别映射到 1 和 0。整数类型 int 表示无限制范围的整数。浮点数由机器的本地双精度浮点表示表示。复数由两个浮点数表示。它们使用 j 运算符分配,以表示复数的虚部,例如:

a = 2+3j

我们可以使用 a.reala.imag 分别访问实部和虚部。

表示错误

应注意,浮点数的本地双精度表示可能导致一些意外的结果。例如,考虑以下情况:

这是因为大多数十进制分数不能精确地表示为二进制分数,而大多数底层硬件都是用二进制分数表示浮点数的。对于可能存在问题的算法或应用程序,Python 提供了一个decimal模块。此模块允许精确表示十进制数,并便于控制诸如舍入行为、有效数字数量和精度等属性。它定义了两个对象,一个Decimal类型,表示十进制数,一个Context类型,表示各种计算参数,如精度、舍入和错误处理。其用法示例如下:

在这里,我们创建了一个全局上下文并设置了精度为4Decimal对象可以像处理intfloat一样处理。它们受到所有相同的数学运算的影响,可以用作字典键,放入集合中等。此外,Decimal对象还具有几个数学运算的方法,例如自然指数x.exp()、自然对数x.ln()和以 10 为底的对数x.log10()

Python 还有一个fractions模块,它实现了有理数类型。以下示例展示了创建分数的几种方法:

还值得一提的是NumPy扩展。它有数学对象(如数组、向量和矩阵)的类型,以及线性代数、傅里叶变换计算、特征向量、逻辑运算等功能。

序列

序列是有序的对象集合,通过非负整数索引。列表和元组是任意对象的序列,字符串是字符的序列。字符串、元组和范围对象是不可变的。所有序列类型都有一些共同的运算。对于所有序列,索引和切片操作符的用法如前一章所述。请注意,对于不可变类型,任何操作都只会返回一个值,而不会实际更改值。

所有序列都有以下方法:

方法 描述
len(s) s中的元素数量
min(s, [,default=obj, key=func]) s中的最小值(对于字符串按字母顺序)
max(s, [,default=obj, key=func]) s中的最大值(对于字符串按字母顺序)
sum(s,[,start=0]) 元素的总和(如果s不是数值,则返回TypeError
all(s) 如果s中的所有元素都是 True(即不是0FalseNull),则返回True
any(s) 检查s中的任何项是否为True

此外,所有序列还支持以下操作:

操作 描述
s + r 连接两个相同类型的序列
s * n 创建sn个副本,其中n是一个整数
v1, v2 ..., vn = s s中解包 n 个变量到v1v2
s[i] 索引返回 s 的元素 i
s[i:j:stride] 切片返回 ij 之间的元素,可选步长
x in s 如果元素 xs 中,则返回 True
x not in s 如果元素 x 不在 s 中,则返回 true

元组

元组是由任意对象组成的不可变序列。它们可以通过大于零的整数进行索引。元组是可哈希的,这意味着我们可以对它们的列表进行排序,并且可以用作字典的键。在语法上,元组只是由逗号分隔的值序列;然而,通常的做法是将它们括在括号中:

tpl= ('a', 'b', 'c') 

在创建只有一个元素的元组时,记得使用尾随逗号,例如:

t = ('a',) 

如果没有尾随逗号,这将被视为一个字符串。

我们还可以使用内置函数 tuple() 创建元组。如果没有参数,这将创建一个空元组。如果 tuple() 的参数是一个序列,则这将创建一个包含该序列元素的元组,例如:

图片

大多数运算符,如切片和索引,与列表上的操作方式相同。但是,由于元组是不可变的,尝试修改元组的元素将导致 TypeError。我们可以使用 ==>< 运算符以与其他序列相同的方式比较元组。

元组的一个重要用途是允许我们通过在赋值语句的左侧放置一个元组来同时分配多个变量,例如:

图片

实际上,我们可以使用这种多重赋值在元组中交换值,例如:

图片

如果赋值语句两边的值数量不相同,将会抛出 ValueError

字典

字典是由数字、字符串或其他不可变对象索引的对象的任意集合。字典本身是可变的;然而,它们的索引键必须是不可变的。以下表格包含所有字典方法和它们的描述:

方法 描述
len(d) d 中的项数。
d.clear() d 中移除所有项。
d.copy() 创建 d 的浅拷贝。
d.fromkeys(s [,value]) 返回一个新的字典,其键来自序列 s,值设置为 value
d.get(k [,v]) 如果找到,则返回 d[k],否则返回 v,如果没有给出 v,则返回 None。
d.items() 返回 d 中的 key:value 对序列。
d.keys() 返回 d 中的键序列。
d.pop(k [,default]) 返回 d[k] 并将其从 d 中移除。如果 d[k] 未找到,则返回默认值或引发 KeyError
d.popitem() d 中随机移除一个 key:value 对并将其作为元组返回。
d.setdefault(k [,v]) 返回 d[k]。如果 d[k] 未找到,则返回 v 并将 d[k] 设置为 v
d.update(b) b 中的所有对象添加到 d 中。
d.values() 返回 d 中的值序列。

Python 字典是唯一的内置映射类型,它们类似于其他语言中找到的哈希表或关联数组。它们可以被视为从一组键到一组值的映射。它们使用语法{key:value}创建。例如,以下创建了一个将单词映射到数字的字典:

d ={'one': 1 , 'two': 2, 'three': 3 } # creates a dictionary 

我们可以如下添加键和值:

d['four']=4 #add an item 

或者使用以下方式更新多个值:

d.update({'five': 5, 'six': 6}) #add multiple items 

当我们检查d时,我们得到以下结果:

图片

我们可以使用in运算符来测试一个值的出现,例如:

图片

应该注意的是,当in运算符应用于字典时,其工作方式与应用于列表时略有不同。当我们使用in运算符在列表上时,找到元素所需的时间与列表大小之间的关系被认为是线性的。也就是说,随着列表大小的增加,找到元素所需的时间最多以线性方式增长。算法运行时间与其输入大小之间的关系通常被称为其时间复杂度。我们将在下一章(以及随后的章节)中更多地讨论这个重要的话题。

list对象相比,当in运算符应用于字典时,它使用哈希算法,并且这种算法的效果是几乎独立于字典大小的查找时间增加。这使得字典作为处理大量索引数据的方式变得极其有用。我们将在第四章,列表和指针结构,以及第十三章,实现、应用和工具中更多地讨论这个重要的话题——哈希增长速率。

注意当我们打印出字典的key:value对时,它并不按特定顺序打印。这并不是一个问题,因为我们使用指定的键来查找每个字典值,而不是像字符串和列表那样使用有序的整数序列。

字典排序

如果我们想要对字典的键或值进行简单的排序,我们可以这样做:

图片

注意到上述代码中的第一行按字母顺序对键进行排序,而第二行按整数值对值进行排序。

sorted()方法有两个可选参数值得关注:keyreversekey参数与字典键无关,而是将函数传递给排序算法以确定排序顺序的一种方式。例如,在以下代码中,我们使用__getitem__特殊方法根据字典值对字典键进行排序:

图片

前面的代码实际上是在对d中的每个key使用相应的值进行排序。我们也可以根据词典键的排序顺序对值进行排序。然而,由于词典没有通过值返回key的方法,类似于列表的list.index方法,使用可选的key参数来做这一点有点棘手。一个替代方法是使用列表推导,以下示例演示了这一点:

图片

sorted()方法还有一个可选的reverse参数,不出所料,它确实做了它所说的,反转排序列表的顺序,例如:

图片

现在,假设我们得到了以下词典,以英语单词为键,法语单词为值。我们的任务是将这些字符串值放置在正确的数值顺序中:

d2 ={'one':'uno' , 'two':'deux', 'three':'trois', 'four': 'quatre', 'five': 'cinq', 'six':'six'}

当然,当我们打印这个词典时,它不太可能按正确的顺序打印。因为所有键和值都是字符串,我们没有数值排序的上下文。为了将这些项放置在正确的顺序中,我们需要使用我们最初创建的词典,将单词映射到数字,作为我们英语到法语词典排序的方式:

图片

注意我们正在使用第一个词典d的值来对第二个词典d2的键进行排序。由于我们两个词典中的键是相同的,我们可以使用列表推导来对法语到英语词典的值进行排序:

图片

当然,我们可以定义自己的自定义方法,并将其用作sorted方法的key参数。例如,这里我们定义了一个函数,它简单地返回一个字符串的最后一个字母:

def corder(string): 

   return(string[len(string)-1]) 

然后,我们可以将这个值作为sorted函数的key参数来按每个元素的最后一个字母进行排序:

图片

文本分析词典

词典的常见用途是计算序列中类似项的出现的次数;一个典型的例子是计算文本中单词的出现次数。以下代码创建了一个词典,其中文本中的每个单词用作键,出现次数作为其值。这使用了一个非常常见的嵌套循环的习语。在这里,我们使用它在外层循环中遍历文件的行,在内层循环中遍历词典的键:

def wordcount(fname): 
    try: 
        fhand=open(fname) 
    except: 
        print('File cannot be opened') 
        exit() 

    count= dict() 
    for line in fhand: 
        words = line.split() 
        for word in words: 
            if word not in count: 
                count[word] = 1 
            else: 
                count[word] += 1 
    return(count) 

这将返回一个包含文本文件中每个唯一单词的元素的词典。一个常见的任务是过滤这些项到我们感兴趣的子集中。你需要一个与运行代码相同的目录中的文本文件。在这里,我们使用了alice.txt,这是《爱丽丝梦游仙境》的一个简短摘录。要获得相同的结果,你可以从davejulian.net/bo5630下载alice.txt,或者使用你自己的文本文件。在以下代码中,我们创建另一个包含count中子集的词典filtered

count=wordcount('alice.txt') 
filtered = { key:value for key, value in count.items() if value  < 20 and value > 15 } 

当我们打印过滤后的词典时,我们得到以下内容:

图片

注意使用字典推导来构建过滤字典。字典推导与我们在第一章,“Python 对象、类型和表达式”中看到的列表推导以相同的方式工作。

集合

集合是无序的唯一项目集合。集合本身是可变的,我们可以从中添加和删除项目;然而,项目本身必须是不可变的。与集合的一个重要区别是它们不能包含重复的项目。集合通常用于执行诸如交集、并集、差集和补集之类的数学运算。

与序列类型不同,集合类型不提供任何索引或切片操作。与字典的情况一样,也没有与值关联的键。Python 中有两种集合对象,可变集合set对象和不可变frozenset对象。集合是通过在大括号内使用逗号分隔的值来创建的。顺便说一下,我们不能使用a={}创建一个空集合,因为这将会创建一个字典。要创建一个空集合,我们可以写a=set()a=frozenset()

集合的方法和操作如下表所述:

方法 运算符 描述
len(s) 返回s中元素的数量
s.copy() 返回s的浅拷贝
s.difference(t) s - t- t2 - ... 返回s中但不在t中的所有项目的集合
s.intersection(t) 返回ts中所有项目的集合
s.isdisjoint(t) 如果st没有共同的项目,则返回 True
s.issubset(t) s <= t``s < t (s != t) 如果s中的所有项目也在t中,则返回 True
s.issuperset(t) s >= t``s > t (s != t) 如果t中的所有项目也在s中,则返回 True
s.symmetric_difference(t) s ^ t 返回一个集合,包含st中的所有项目,但不是两者都有的项目
s.union(t) s &#124; t1 &#124; t2 &#124;... 返回st中所有项目的集合

在前面的表中,参数t可以是任何支持迭代的 Python 对象,并且所有方法都对setfrozenset对象可用。重要的是要注意,这些方法的操作符版本要求它们的参数是集合,而方法本身可以接受任何可迭代类型。例如,对于任何集合ss - [1,2,3]将生成一个不支持的运算符类型。使用等效的s.difference([1,2,3])将返回一个结果。

可变集合对象有额外的方法,如下表所述:

方法 描述
s.add(item) 添加items。如果item已经存在,则没有效果。
s.clear() s中移除所有项目。
s.difference_update(t) s中移除在t中的所有项目。
s.discard(item) s中移除item
s.intersection_update(t) s中移除不在st的交集中的所有项目。
s.pop() 返回并从s中移除一个任意项目。
s.remove(item) s中移除项目。
s.symmetric_difference_update(t) s中移除不在st的对称差集中的所有项目。
s.update(t) 将可迭代对象t中的所有项目添加到s中。

以下示例演示了一些简单的集合操作及其结果:

图片

注意,集合对象并不关心其成员是否都是同一类型,只要它们都是不可变的。如果你尝试在集合中使用可变对象,如列表或字典,你会收到一个不可哈希的类型错误。所有可哈希类型都具有在整个实例生命周期中不变的哈希值。所有内置不可变类型都是可哈希的。所有内置可变类型都不是可哈希的,因此不能用作集合的元素或字典的键。

注意,在前面的代码中,当我们打印出s1s2的并集时,只有一个值为'ab'的元素。这是集合的自然属性,即它们不包含重复项。

除了这些内置方法之外,我们还可以在集合上执行许多其他操作。例如,要测试集合的成员资格,可以使用以下方法:

图片

我们可以使用以下方式遍历集合中的元素:

图片

不可变集合

Python 有一个不可变的集合类型,称为frozenset。它几乎与set完全相同,除了不允许更改值的方法或操作,如add()clear()方法。这种不可变性有几种有用的方式。例如,由于正常集合是可变的,因此不可哈希,它们不能用作其他集合的成员。另一方面,frozenset是不可变的,因此可以用作集合的成员:

图片

不可变属性意味着我们可以将frozenset用作字典的key,例如:

图片

数据结构和算法模块

除了内置类型外,还有几个 Python 模块可以用来扩展这些内置类型和函数。在许多情况下,这些 Python 模块可能提供效率和编程优势,使我们能够简化代码。

到目前为止,我们已经探讨了字符串、列表、集合和字典的内置数据类型以及decimalfractions模块。它们通常被称为抽象数据类型ADTs)。ADTs 可以被视为对可以执行在数据上的操作集的数学规范。它们是由其行为而不是其实施来定义的。除了我们探讨的 ADTs 之外,还有几个 Python 库提供了对内置数据类型的扩展。这些将在下一节中讨论。

集合

collections 模块提供了对内置数据类型的更多专业化和高性能替代方案,以及一个创建命名元组的实用函数。以下表格列出了 collections 模块的数据类型和操作及其描述:

数据类型或操作 描述
namedtuple() 创建具有命名字段的元组子类。
deque 具有快速追加和弹出两端列表的功能。
ChainMap 创建多个映射的单个视图的字典类。
Counter 用于计数可哈希对象的字典子类。
OrderedDict 记录条目顺序的字典子类。
defaultdict 调用一个函数来提供缺失值的字典子类。
UserDict UserList UserString 这三个数据类型只是它们底层基类的包装器。它们的使用在很大程度上已被直接子类化各自基类的功能所取代。可以用来将底层对象作为属性访问。

双端队列

双端队列,或称为双端队列(通常发音为 decks),是类似列表的对象,支持线程安全的、内存高效的追加操作。双端队列是可变的,并支持列表的一些操作,如索引。可以通过索引赋值,例如,dq[1] = z;然而,我们无法直接对双端队列进行切片。例如,dq[1:2] 会导致一个 TypeError(我们将在稍后查看如何从 deque 返回一个列表作为示例)。

与列表相比,双端队列的主要优势是在双端队列的开始处插入项目比在列表的开始处插入项目要快得多,尽管在 deque 的末尾插入项目比在列表上执行等效操作要稍微慢一些。双端队列是线程安全的,可以使用 pickle 模块进行序列化。

关于双端队列的一个有用思考方式是关于填充和消耗项目。双端队列中的项目通常从两端按顺序填充和消耗:

我们可以使用 pop()popleft() 方法在 deque 中消耗项目,例如:

我们还可以使用 rotate(n) 方法将所有项目向右移动并旋转 n 步,对于正整数的 n,或者对于负整数的 n 向左移动,使用正整数作为参数,例如:

注意,我们可以使用 rotatepop 方法来删除选定的元素。还值得知道的一个简单方法是将双端队列的切片作为列表返回,可以按照以下方式完成:

itertools.islice 方法的工作方式与列表上的切片操作相同,不同之处在于它接受一个可迭代对象作为参数,而不是列表,并通过起始和结束索引返回选定的值,作为一个列表。

双端队列(deque)的一个有用特性是它支持一个可选的 maxlen 参数,该参数限制了 deque 的大小。这使得它非常适合称为循环缓冲区的数据结构。这是一个固定大小的结构,它实际上是通过端到端连接的,并且通常用于缓冲数据流。以下是一个基本示例:

dq2=deque([],maxlen=3) 
for i in range(6): 
    dq2.append(i) 
    print(dq2) 

这将打印出以下内容:

图片

在这个例子中,我们从右侧填充,从左侧消费。请注意,一旦缓冲区满了,最旧的数据首先被消费,然后从右侧替换数据。我们将在 第四章,列表和指针结构中再次讨论循环缓冲区,通过实现循环列表来做到这一点。

ChainMaps

Python 3.2 中添加了 collections.chainmap 类,它提供了一种将多个字典或其他映射链接起来的方法,这样它们就可以被当作一个对象来处理。此外,还有一个 maps 属性,一个 new_child() 方法,以及一个 parents 属性。ChainMap 对象的底层映射存储在一个列表中,可以通过 maps[*i*] 属性来访问以检索第 i 个字典。请注意,尽管字典本身是无序的,但 ChainMaps 是字典的有序列表。ChainMap 在我们使用包含相关数据的多个字典的应用程序中非常有用。消费应用程序期望以优先级的方式提供数据,其中两个字典中相同的键如果在底层列表的开头出现,则具有优先级。ChainMap 通常用于模拟嵌套上下文,例如当我们有多个覆盖配置设置时。以下是一个 ChainMap 的可能用例示例:

图片

使用 ChainMaps 而不是仅仅使用字典的优势在于,我们保留了之前设置的值。添加子上下文会覆盖相同键的值,但不会从数据结构中删除它。这在我们可能需要记录更改以便我们可以轻松回滚到之前的设置时非常有用。

我们可以通过向 map() 方法提供一个适当的索引来检索和更改任何字典中的任何值。这个索引代表 ChainMap 中的一个字典。此外,我们可以通过使用 parents() 方法来检索父设置,即默认设置:

图片

计数器对象

计数器是字典的一个子类,其中每个字典 key 是一个可哈希的对象,与之关联的值是该对象的整数计数。初始化计数器有三种方式。我们可以传递任何序列对象,一个 key:value 对的字典,或者一个格式为 (object = value, ...) 的元组,例如:

图片

我们还可以创建一个空的计数器对象,并通过传递其 update 方法的可迭代对象或字典来填充它,例如:

图片

注意update方法是如何添加计数而不是用新值替换它们的。一旦 Counter 被填充,我们可以像访问字典中的值一样访问存储的值,例如:

图片

Counter 对象与字典之间最显著的区别是,Counter 对象对于缺失的项返回零计数,而不是抛出key错误,例如:

图片

我们可以通过使用Counter对象的elements()方法来从Counter对象创建一个迭代器。这个迭代器不包括计数小于一的元素,并且顺序没有保证。在下面的代码中,我们执行了一些更新,从Counter元素创建了一个迭代器,并使用sorted()对键进行字母排序:

图片

值得一提的另外两种 Counter 方法有most_common()subtract()most_common()方法接受一个正整数参数,用于确定返回最常见元素的数量。元素以(key, value)元组的列表形式返回。subtract方法的工作方式与update方法类似,但不同的是它不是添加值,而是减去值,例如:

图片

有序字典

有序字典的重要之处在于它们会记住插入顺序,因此当我们迭代它们时,它们会按插入顺序返回值。这与正常字典不同,正常字典的顺序是任意的。当我们测试两个字典是否相等时,这种相等性仅基于它们的键和值;然而,对于OrderedDict,插入顺序也会被考虑。具有相同键和值但不同插入顺序的两个OrderedDict之间的相等性测试将返回False

图片

同样,当我们使用update从列表中添加值时,OrderedDict将保留与列表相同的顺序。这是我们在迭代值时返回的顺序,例如:

图片

OrderedDict通常与sorted方法一起使用来创建排序字典。例如,在下面的示例中,我们使用lambda函数按值排序,这里我们使用数值表达式来排序整数值:

图片

defaultdict

defaultdict对象是dict的子类,因此它们共享方法和操作。它是一种方便初始化字典的方式。使用dict时,Python 会在尝试访问不在字典中的键时抛出KeyErrordefaultdict覆盖了一个方法__missing__(key),并创建了一个新的实例变量default_factory。使用defaultdict时,而不是抛出错误,它将运行作为default_factory参数提供的函数,该函数将生成一个值。defaultdict的一个简单用法是将default_factory设置为int,并快速统计字典中项的计数,例如:

图片

你会注意到,如果我们尝试用普通的字典来做这件事,当我们尝试添加第一个键时,我们会得到一个键错误。我们提供给默认 dictint 参数实际上是返回零的函数 int()。我们当然可以创建一个函数来确定字典的值。例如,以下函数如果提供的参数是主色(即红色、绿色或蓝色),则返回 True,否则返回 False

def isprimary(c):  
    if (c == 'red') or (c == 'blue') or (c == 'green'):  
        return True  
    else: 
        return False 

我们现在可以创建一个新的 defaultdict 对象,并使用 isprimary 函数来填充它:

命名元组

namedtuple 方法返回一个类似元组的对象,它具有字段,可以通过命名索引以及正常元组的整数索引访问。这允许代码在一定程度上自我文档化,并且更易于阅读。在存在大量元组且需要轻松跟踪每个元组代表的内容的应用程序中,这特别有用。namedtuple 继承了 tuple 的方法,并且与 tuple 兼容。

字段名称作为逗号和/或空格分隔的值传递给 namedtuple 方法。它们也可以作为字符串序列传递。字段名称是单个字符串,并且可以是任何合法的 Python 标识符,但不能以数字或下划线开头。这里有一个典型的示例:

namedtuple 方法接受两个可选的布尔参数,verboserename。当 verbose 设置为 True 时,在构建类定义时将打印出类定义。此参数已被 __source 属性所取代。当 rename 参数设置为 True 时,任何无效的字段名称将被自动替换为位置参数。例如,我们尝试使用 def 作为字段名称。这通常会产生错误,但由于我们将 rename 设置为 True,Python 解释器允许这样做。然而,当我们尝试查找 def 值时,由于 def 是一个保留关键字,我们会得到一个语法错误。非法字段名称已被添加下划线的位置值创建的字段名称所替换:

除了继承的元组方法外,命名元组还定义了它自己的三个方法,_make()asdict()_replace。这些方法以前缀下划线开头,以防止与字段名称发生潜在冲突。_make() 方法接受一个可迭代对象作为参数,并将其转换为命名元组对象,例如:

_asdict 方法返回一个 OrderedDict,字段名称映射到索引键,值映射到字典值,例如:

_replace 方法返回一个新的元组实例,替换指定的值,例如:

数组

数组模块定义了一个类似于 list 数据类型的 array 数据类型,除了它们的约束是它们的必须包含底层表示的单一种类的数据,这由机器架构或底层 C 实现确定。

数组的类型在创建时确定,并由以下类型代码之一指示:

代码类型 C 类型 Python 类型 最小字节数
'b' signed char int 1
'B' unsigned char int 1
'u' Py_UNICODE Unicode 字符 2
'h' signed short int 2
'H' unsigned short int 2
'i' signed int int 2
'I' unsigned int int 2
'l' signed long int 4
'L' unsigned long int 8
'q' signed long long int 8
'Q' unsigned long long int 8
'f' float float 4
'd' double float 8

数组对象支持以下属性和方法:

属性或方法 描述
a.typecode 创建数组时使用的类型代码字符。
a.itemsize 存储在数组中的项的大小,以字节为单位。
a.append(x) 将项 x 追加到数组的末尾。
a.buffer_info() 返回用于存储数组的内存位置和长度。
a.byteswap() 交换每个项的字节顺序。用于写入具有不同字节顺序的机器或文件。
a.count(x) 返回 ax 的出现次数。
a.extend(b) 将任何可迭代对象 b 追加到数组 a 的末尾。
a.frombytes(s) 从字符串 s 中追加项作为机器值数组。
a.fromfile(f, n) 从文件对象 f 中读取 n 项,作为机器值,并将它们追加到 a 中。如果 n 中的项少于 n,则引发 EOFError
a.fromlist(l) 从列表 l 中追加项。
a.fromunicode(s) 使用 Unicode 字符串 s 扩展 a。如果数组 a 的类型不是 u,则引发 ValueError
index(x) 返回项 x 的第一个(最小的)索引。
a.insert(i, x) 在索引 i 之前插入项 x
a.pop([i]) 移除并返回索引为 i 的项。如果未指定,则默认为最后一个项 (i = -1)
a.remove(x) 移除项 x 的第一个出现。
a.reverse() 反转项的顺序。
a.tobytes() 将数组转换为机器值并返回字节表示。
a.tofile(f) 将所有项,作为机器值,写入文件对象 f
a.tolist() 将数组转换为列表。
a.tounicode() 将数组转换为 unicode 字符串。数组类型必须是 'u',否则引发 ValueError

数组对象支持所有正常的序列操作,如索引、切片、连接和乘法。

与列表相比,使用数组存储所有相同类型的数据是一种更有效的方式。在下面的例子中,我们创建了一个包含从 0 到 100 万减 1 的数字的整数数组和一个相同的列表。在整数数组中存储 100 万个整数大约需要相当于列表的 45%的内存:

图片

由于我们关注节省空间,也就是说,我们在处理大量数据集和有限的内存大小,我们通常在数组上执行原地操作,并且只有在需要时才创建副本。通常,枚举用于对每个元素执行操作。在下面的代码片段中,我们执行了将数组中每个元素加一的简单操作:

图片

应该注意的是,当对创建列表的数组执行操作时,例如列表推导式,使用数组带来的内存效率提升将会被抵消。当我们需要创建一个新的数据对象时,一个解决方案是使用生成器表达式来执行操作,例如:

图片

使用此模块创建的数组不适合需要矩阵或向量操作的工作。在下一章中,我们将构建自己的抽象数据类型来处理这些操作。对于数值工作也很重要的是NumPy扩展,可在www.numpy.org找到。

摘要

在最后两章中,我们已经探讨了 Python 的语言特性和数据类型。我们查看了一些内置数据类型和一些内部 Python 模块,最值得注意的是 collections 模块。还有其他几个与本书主题相关的 Python 模块,但与其单独检查它们,不如在我们开始使用它们时,它们的使用和功能应该变得显而易见。还有一些外部库,如 SciPy 堆栈,同样,我将在我们开始应用它们时尝试解释它们的基本功能。

在下一章中,我们将介绍算法设计的基本理论和技巧。

第三章:算法设计原理

我们为什么要学习算法设计?当然有很多原因,我们学习某件事的动机很大程度上取决于我们自己的情况。毫无疑问,对算法设计感兴趣有重要的专业原因。算法是所有计算的基础。我们认为计算机是一块硬件,包括硬盘、内存芯片、处理器等。然而,本质的组成部分,如果缺失,就会使现代技术变得不可能,那就是算法。

算法的理论基础,以图灵机的形式,在数字逻辑电路能够实际实现这种机器的几十年前就已经确立。图灵机本质上是一个数学模型,它使用一组预定义的规则,将一组输入转换成一组输出。图灵机的最初实现是机械的,下一代可能会看到数字逻辑电路被量子电路或类似的东西所取代。无论平台如何,算法都发挥着中心主导的作用。

另一方面,算法在技术创新中的作用也不容忽视。一个明显的例子是页面排名搜索算法,谷歌搜索引擎就是基于这种算法的变体。使用这种以及类似的算法,研究人员、科学家、技术人员和其他人可以非常快速地搜索大量信息。这对新研究开展的速度、新发现以及新创新技术的发展产生了巨大影响。

算法的研究也很重要,因为它训练我们非常具体地思考某些问题。它可以通过帮助我们隔离问题的组成部分并定义这些组成部分之间的关系,来提高我们的心理和问题解决能力。总的来说,学习算法有四个主要原因:

  1. 它们对于计算机科学和智能系统至关重要。

  2. 它们在许多其他领域(计算生物学、经济学、生态学、通信、物理学等)也非常重要。

  3. 他们在技术创新中扮演着重要角色。

  4. 它们提高了问题解决和分析思维能力。

算法在其最简单形式中,只是一系列动作,一个指令列表。它可能只是形式为执行 x,然后执行 y,然后执行 z,然后结束的线性结构。然而,为了使事情更有用,我们添加了诸如“x 然后执行 y”之类的条款,在 Python 中是 if-else 语句。在这里,未来行动的路径取决于某些条件;比如说数据结构的状态。对此,我们还添加了操作,迭代,while 和 for 语句。进一步扩展我们的算法素养,我们添加了递归。递归通常可以达到与迭代相同的结果,然而,它们在本质上不同。递归函数调用自身,将相同的函数应用于越来越小的输入。任何递归步骤的输入是前一个递归步骤的输出。

实质上,我们可以这样说,算法由以下四个要素组成:

  • 顺序操作

  • 基于数据结构状态的行动

  • 迭代,重复执行一个动作多次

  • 递归,在输入子集上调用自身

算法设计范式

通常,我们可以区分三种广泛的问题解决方法。它们是:

  • 分而治之

  • 贪心算法

  • 动态规划

正如其名所示,分而治之范式涉及将问题分解成更小的子问题,然后以某种方式组合结果以获得全局解决方案。这是一种非常常见且自然的问题解决技术,并且可以说是最常用的算法设计方法。

贪心算法通常涉及优化和组合问题;经典的例子是将它应用于旅行推销员问题,其中贪心方法总是首先选择最近的目的地。这种最短路径策略涉及寻找局部问题的最佳解决方案,希望这能导致全局解决方案。

当我们的子问题重叠时,动态规划方法是有用的。这与分而治之不同。而不是将我们的问题分解成独立的子问题,在动态规划中,中间结果被缓存并可用于后续操作。像分而治之一样,它使用递归;然而,动态规划允许我们在不同阶段比较结果。对于某些问题,这可以比分而治之有性能优势,因为从内存中检索先前计算的结果通常比重新计算它要快。

递归和回溯

递归对于分而治之的问题特别有用;然而,由于每个递归调用本身又会产生其他递归调用,因此理解到底发生了什么可能很困难。递归函数的核心包含两种类型的情形:基础情形,它告诉递归何时终止,以及递归情形,它调用它们所在的函数。一个自然适合递归解决方案的简单问题就是计算阶乘。递归阶乘算法定义了两种情形:当 n 为零时的基础情形,以及当 n 大于零时的递归情形。一个典型的实现如下:

    def factorial(n):
        #test for a base case
        if n==0:
            return 1
            # make a calculation and a recursive call
            f= n*factorial(n-1)
        print(f)
        return(f)
        factorial(4)

此代码打印出数字 1, 2, 4, 24。要计算 4 需要四个递归调用加上初始父调用。在每次递归中,方法变量的副本被存储在内存中。一旦方法返回,它就会被从内存中移除。以下是我们可视化的这种过程的方法:

并不一定总是清楚递归或迭代是解决特定问题的更好方案;毕竟,它们都重复一系列操作,并且都非常适合算法设计中分而治之的方法。迭代会一直进行,直到问题解决。递归将问题分解成越来越小的部分,然后合并结果。对于程序员来说,迭代通常更容易,因为控制始终位于循环内部,而递归可以更紧密地表示数学概念,如阶乘。递归调用被存储在内存中,而迭代则不是。这就在处理器周期和内存使用之间产生了一个权衡,因此选择使用哪一个可能取决于任务是处理器密集型还是内存密集型。以下表格概述了递归和迭代之间的关键区别:

递归 迭代
当达到基础情形时终止 当满足定义的条件时终止
每个递归调用都需要内存空间 每次迭代不会被存储在内存中
无限递归会导致栈溢出错误 无限迭代会在硬件供电时运行
一些问题自然更适合递归解决方案 迭代解决方案可能并不总是明显

回溯

回溯是一种特别适用于如遍历树结构等问题的递归形式,在每个节点我们都会面临多个选项,必须从中选择一个。随后,我们会面临另一组不同的选项,并且根据所做出的选择系列,要么达到目标状态,要么遇到死胡同。如果是后者,我们必须回溯到前一个节点并遍历不同的分支。回溯是穷举搜索的分而治之方法。重要的是,回溯剪枝了无法产生结果的分支。

以下示例给出了回溯的一个例子。在这里,我们使用递归方法生成给定字符串s的所有可能的排列,该字符串的长度为n

    def bitStr(n, s):            

         if n == 1: return s 
         return [ digit + bits for digit in bitStr(1,s)for bits in bitStr(n - 1,s)] 

    print (bitStr(3,'abc'))     

这会产生以下输出:

图片

注意到在这个理解中的双重列表压缩和两次递归调用。在这种意义上,它是回溯以揭示先前生成的组合。返回的最终字符串是初始字符串的所有n个字母组合。

分而治之 - 长乘法

要使递归不仅仅是一个巧妙的技巧,我们需要了解如何将其与其他方法,如迭代,进行比较,并了解何时使用递归将导致更快的算法。我们所有人都熟悉的迭代算法是在小学数学课上学习的过程,用于乘以两个大数。也就是说,长乘法。如果你还记得,长乘法涉及迭代乘法和进位操作,随后是移位和加法操作。

我们在这里的目标是检查衡量此过程效率的方法,并尝试回答问题;这是否是我们用于乘以两个大数的最有效过程?

在以下图中,我们可以看到将两个四位数相乘需要 16 次乘法操作,我们可以推广说,一个n位数大约需要次乘法操作:

图片

这种分析算法的方法,从乘法和加法等计算原语的数量来看,很重要,因为它为我们提供了一种理解完成特定计算所需时间与该计算输入大小之间关系的方式。特别是,我们想知道当输入,即数字的位数,n非常大时会发生什么。这个称为渐近分析或时间复杂性的主题对于我们的算法研究至关重要,我们将在本章和本书的其余部分经常回顾它。

我们能做得更好吗?递归方法

结果表明,在长乘法的情况下,答案是肯定的,实际上确实存在几种用于乘以大数的算法,这些算法需要的操作更少。最著名的长乘法替代方案之一是Karatsuba 算法,该算法首次发表于 1962 年。它采用了一种根本不同的方法:它不是迭代地乘以单个数字,而是递归地对越来越小的输入执行乘法操作。递归程序在输入的较小子集上调用自己。构建递归算法的第一步是将大数分解为几个较小的数。最自然的方法是将数字简单地分成两半,即最高有效位的前半部分和最低有效位的后半部分。例如,我们的四位数,2345,变成了两个两位数,23 和 45。我们可以使用以下方式更普遍地分解任何 2 n位数字的xy,其中m是小于n的任何正整数:

因此,现在我们可以将我们的乘法问题 xy 重新写为如下形式:

当我们展开并合并同类项时,我们得到以下结果:

更方便的是,我们可以这样写:

其中:

应该指出的是,这表明了一种递归方法来乘以两个数,因为此过程本身涉及乘法。具体来说,乘积 acadbcbd 都涉及比输入数小的数字,因此我们可以设想我们可以将相同的操作作为整体问题的部分解决方案来应用。到目前为止,该算法由四个递归乘法步骤组成,而且并不立即清楚它是否比经典的长乘法方法更快。

我们到目前为止所讨论的关于乘法的递归方法,自 19 世纪末以来就已经为数学家所熟知。Karatsuba 算法通过以下观察进行了改进。我们实际上只需要知道三个数量:z[2]= acz[1]= ad + bcz[0]= bd,以解决方程 3.1。我们只需要知道abcd的值,只要它们对计算数量z[2]z[1]z[0]所涉及的总体和乘积有贡献。这表明,我们可能可以减少递归步骤的数量。结果证明,这确实是情况。

由于乘积 acbd 已经是最简形式,因此我们似乎不太可能消除这些计算。然而,我们可以做出以下观察:

当我们从之前递归步骤中计算出的数量 acbd 中减去时,我们得到所需的数量,即 (ad + bc):

这表明我们确实可以计算 ad + bc 的和,而无需单独计算每个单独的量。总之,我们可以通过将四个递归步骤减少到三个来改进方程 3.1。这三个步骤如下:

  1. 递归计算 ac

  2. 递归计算 bd

  3. 递归计算 (a +b)(c + d) 并减去 acbd

以下示例展示了 Karatsuba 算法的 Python 实现:

    from math import log10  
    def karatsuba(x,y): 

        # The base case for recursion 
        if x < 10 or y < 10: 
            return x*y     

        #sets n, the number of digits in the highest input number 
        n = max(int(log10(x)+1), int(log10(y)+1)) 

        # rounds up n/2     
        n_2 = int(math.ceil(n / 2.0)) 
        #adds 1 if n is uneven 
        n = n if n % 2 == 0 else n + 1 

        #splits the input numbers      
        a, b = divmod(x, 10**n_2) 
        c, d = divmod(y, 10**n_2) 

        #applies the three recursive steps 
        ac = karatsuba(a,c) 
        bd = karatsuba(b,d) 
        ad_bc = karatsuba((a+b),(c+d)) - ac - bd 

        #performs the multiplication     
        return (((10**n)*ac) + bd + ((10**n_2)*(ad_bc))) 

为了确保这个方法确实有效,我们可以运行以下测试函数:

    import random 
    def test(): 
            for i in range(1000): 
                x = random.randint(1,10**5) 
                y = random.randint(1,10**5) 
                expected = x * y 
                result = karatsuba(x, y) 
                if result != expected: 
                    return("failed")                 
            return('ok')   

运行时分析

应该越来越清楚,算法设计的一个重要方面是衡量其在空间(内存)和时间(操作数数量)方面的效率。这个第二度量,称为运行时性能,是本节的主题。应该提到的是,一个相同的度量用于衡量算法的内存性能。我们可以以多种方式测量运行时间,可能最明显的方法就是简单地测量算法完成所需的时间。这种方法的主要问题是算法运行所需的时间在很大程度上取决于其运行的硬件。衡量算法运行时间的平台无关方法是计算涉及的操作数数量。然而,这也存在问题,因为没有明确的方法来量化一个操作。这取决于编程语言、编码风格以及我们决定如何计数操作。尽管如此,如果我们结合这个计数操作的想法,并预期随着输入大小的增加,运行时间将以特定方式增加,那么我们可以使用这个想法。也就是说,输入大小 n 和算法运行所需时间之间存在数学关系。

以下讨论的大部分内容将由以下三个指导原则来框架。随着我们继续前进,这些原则的理性和重要性将变得更加清晰。这些原则如下:

  • 最坏情况分析。不对输入数据做任何假设。

  • 忽略或抑制常数因子和低阶项。在大输入情况下,高阶项占主导地位。

  • 关注大输入规模的问题。

最坏情况分析是有用的,因为它给我们一个紧的上界,保证我们的算法不会超过这个上界。忽略小的常数因子和低阶项实际上就是忽略那些在输入大小很大的情况下,对总体运行时间贡献不大的因素。这不仅使我们的工作在数学上更容易,还允许我们关注对性能影响最大的因素。

我们通过 Karatsuba 算法看到,乘法操作的次数增加到输入大小,n,的平方。如果我们有一个四位数,乘法操作的次数是 16;一个八位数需要 64 次操作。然而,通常我们并不真正对算法在小的n值时的行为感兴趣,所以我们通常忽略增长速度较慢的因素,比如与n线性增长。这是因为当n值较高时,随着n的增加而增长最快的操作将占主导地位。

我们将通过一个例子详细解释这一点,即归并排序算法。排序是第十章“排序”的主题,然而,作为先导和了解运行时性能的有用方式,我们在这里介绍归并排序。

归并排序算法是一种 60 多年前开发的经典算法。它仍然被广泛应用于许多最受欢迎的排序库中。它相对简单且高效。它是一种使用分治法的递归算法。这涉及到将问题分解成更小的子问题,递归地解决它们,然后以某种方式合并结果。归并排序是分治范式最明显的演示之一。

归并排序算法由三个简单的步骤组成:

  1. 递归地对输入数组的左半部分进行排序。

  2. 递归地对输入数组的右半部分进行排序。

  3. 将两个已排序的子数组合并成一个。

一个典型的问题是将数字列表按数值顺序排序。归并排序通过将输入分成两半并并行处理每个半部分来工作。我们可以用以下示意图来示意这个过程:

下面是归并排序算法的 Python 代码:

    def mergeSort(A): 
        #base case if the input array is one or zero just return. 
        if len(A) > 1: 
            # splitting input array 
            print('splitting ', A ) 
            mid = len(A)//2 
            left = A[:mid] 
            right = A[mid:] 
            #recursive calls to mergeSort for left and right sub arrays                 
            mergeSort(left) 
            mergeSort(right) 
            #initalizes pointers for left (i) right (j) and output array (k)  
    # 3 initalization operations 
            i = j = k = 0         
            #Traverse and merges the sorted arrays 
            while i <len(left) and j<len(right): 
    # if left < right comparison operation  
                if left[i] < right[j]: 
    # if left < right Assignment operation 
                    A[k]=left[i] 
                    i=i+1 
                else: 
    #if right <= left assignment 
                    A[k]= right[j] 
                    j=j+1 
                k=k+1 

            while i<len(left): 
    #Assignment operation 
                A[k]=left[i] 
                i=i+1 
                k=k+1 

            while j<len(right): 
    #Assignment operation 
                A[k]=right[j] 
                j=j+1 
                k=k+1 
        print('merging ', A) 
        return(A)   

我们运行这个程序得到以下结果:

我们感兴趣的问题是确定运行时间性能,即算法完成所需时间相对于n大小的增长速率。为了更好地理解这一点,我们可以将每个递归调用映射到一个树结构上。树中的每个节点都是一个递归调用,它正在处理越来越小的子问题:

每次调用 merge-sort 都会随后创建两个递归调用,因此我们可以用二叉树来表示这一点。每个子节点都接收输入的一个子集。最终我们想知道算法相对于n大小完成所需的总时间。首先,我们可以计算树中每一层的作业量和操作数。

专注于运行时分析,在级别 1 时,问题被分割成两个 n/2 子问题,在级别 2 时有四个 n/4 子问题,以此类推。问题是递归何时触底,即何时达到其基本案例。这简单地说就是当数组是零或一时。

递归级别的数量正好是你需要将 n 除以 2 直到得到一个最多为 1 的数的次数。这正是 log2 的定义。由于我们将初始递归调用视为级别 0,所以总级别数是 log[2]n + 1。

让我们先暂停一下,来细化我们的定义。到目前为止,我们一直用字母 n 来描述输入元素的数量。这指的是递归第一级中的元素数量,即初始输入的长度。我们需要区分后续递归级别中输入的大小。为此,我们将使用字母 m 或更具体地,使用 m[j] 来表示递归级别 j 的输入长度。

此外,还有一些细节我们忽略了,我确信你已经开始对此感到好奇。例如,当 m/2 不是一个整数时,或者当我们的输入数组中有重复项时会发生什么。实际上,这对我们的分析没有重要影响;我们将在第十二章“设计技术和策略”中重新审视合并排序算法的一些更细致的细节。

使用递归树分析算法的优势在于我们可以计算递归每一级的完成工作。如何定义这项工作简单地说就是操作的总数,这当然与输入的大小有关。以平台无关的方式衡量和比较算法的性能是很重要的。当然,实际的运行时间将取决于运行它的硬件。计算操作数很重要,因为它给我们一个与算法性能直接相关的度量,与平台无关。

通常,由于每次合并排序调用都会进行两次递归调用,所以调用次数在每一级都是翻倍的。同时,这些调用正在处理的是其父项一半大小的输入。我们可以形式化地说:

对于级别 j,其中 j 是整数 0, 1, 2 ... log[2]n,有两个 ^j 子问题,每个子问题的规模是 n/2^j。

要计算操作的总数,我们需要知道单个合并两个子数组所包含的操作数。让我们来计算之前 Python 代码中的操作数。我们感兴趣的是在两次递归调用之后的所有代码。首先,我们有三个赋值操作。接着是三个 while 循环。在第一个循环中,我们有一个 if else 语句,并且每个 if else 语句中包含两个操作,一个比较操作后跟一个赋值操作。由于这些操作集在 if else 语句中只有一个,我们可以将这段代码视为执行m次的两条操作。接着是两个包含赋值操作的 while 循环。这使得每次归并排序的递归调用总共需要 4m + 3 次操作。

由于m至少为 1,操作数的上限是 7m。必须说的是,这并不是一个精确的数字。我们当然可以决定以不同的方式计算操作数。我们没有计算增量操作或任何维护操作;然而,这并不那么重要,因为我们更关心在n的高值下运行时间的增长率。

这可能看起来有点令人畏惧,因为每次递归调用本身会产生更多的递归调用,看起来呈指数级增长。使这变得可管理的关键事实是,随着递归调用数量的加倍,每个子问题的规模减半。这两股相反的力量很好地相互抵消,我们可以证明这一点。

要计算递归树每层的最大操作数,我们只需将子问题的数量乘以每个子问题的操作数,如下所示:

图片

重要的是,这表明,因为 2^j 抵消了每层的操作数,所以它与层无关。这给我们每个层上操作数的上限,在这个例子中是 7n。应该指出的是,这包括在该层上每个递归调用所执行的操作数,而不是在后续层上所做的递归调用。这表明,随着递归调用数量的加倍,所做的工作与每个子问题的输入大小减半的事实正好相抵消。

要找到完整的归并排序操作的总数,我们只需将每层的操作数乘以层的数量。这给我们以下结果:

图片

当我们展开这个公式时,我们得到以下结果:

图片

从这个例子中可以得出的关键点是,输入大小与总运行时间之间的关系有一个对数成分。如果您还记得学校数学,对数函数的显著特征是它迅速变得平坦。作为一个输入变量x增加大小,输出变量y增加的量会越来越小。例如,将对数函数与线性函数进行比较:

图片

在前面的例子中,将nlog[2]n组件相乘,并将其与n²进行比较。

图片

注意,对于非常低的n值,完成时间t实际上对于运行在 n²时间内的算法来说更低。然而,对于大约 40 以上的值,对数函数开始主导,使输出变得平坦,直到在相对适中的大小n = 100 时,性能是运行在n²时间内的算法的两倍以上。注意,在n的高值中,常数因子+ 7 的消失是无关紧要的。

生成这些图表所使用的代码如下:

    import matplotlib.pyplot as plt 
    import math 
    x=list(range(1,100)) 
    l =[]; l2=[]; a = 1 
    plt.plot(x , [y * y for y in x] ) 
    plt.plot(x, [(7 *y )* math.log(y, 2) for y in x]) 
    plt.show() 

如果 matplotlib 库尚未安装,您需要安装它才能使以下功能正常工作。详细信息可以在以下地址找到;我鼓励您尝试使用此列表推导表达式生成图表。例如,添加以下绘图语句:

    plt.plot(x, [(6 *y )* math.log(y, 2) for y in x]) 

输出如下:

图片

上述图表显示了计数六个操作或七个操作之间的差异。我们可以看到这两种情况是如何分叉的,当我们讨论应用的特定细节时,这一点很重要。然而,我们更感兴趣的是一种表征增长速率的方法。我们不太关心绝对值,而是关注随着n的增加这些值是如何变化的。这样我们就可以看到,与顶部的(x²)曲线相比,两个较低的曲线具有相似的增长速率。我们说这两个较低的曲线具有相同的复杂度类。这是一种理解和描述不同运行行为的方式。我们将在下一节中正式化这个性能指标。

渐近分析

算法的运行性能本质上由三个要素来表征。它们是:

  • 最坏情况 - 使用一个导致性能最慢的输入

  • 最佳情况 - 使用一个能给出最佳结果的输入

  • 平均情况 - 假设输入是随机的

为了计算这些,我们需要知道上下限。我们已经看到了使用数学表达式表示算法运行时间的方法,本质上是通过加法和乘法操作。要使用渐近分析,我们只需创建两个表达式,一个用于最佳情况,一个用于最坏情况。

大 O 符号

O 表示法中的字母 "O" 代表阶,以表明增长率被定义为函数的阶。我们说一个函数 T(n) 是另一个函数 F(n) 的大 O,我们将其定义为以下内容:

输入大小 n 的函数 g(n) 基于以下观察:对于所有足够大的 n 值,g(n) 被一个常数乘以 f(n) 限制在上界。目标是找到小于或等于 f(n) 的最小增长率。我们只关心 n 较大时的行为。变量 n[0] 代表增长率不重要的阈值,函数 T(n) 代表 紧上界 F(n)。在下面的图中,我们看到 T(n) = + 500 = O(),其中 C = 2,而 n[0] 大约是 23:

你也会看到表示法 f(n) = O(g(n))。这描述了 O(g(n)) 实际上是一组函数,包括所有与 f(n) 具有相同或更小增长率的函数。例如,O() 也包括函数 O(n)、O(nlogn) 等等。

在以下表中,我们按从低到高的顺序列出最常见的增长率。我们有时将这些增长率称为函数的 时间复杂度 或函数的复杂度类:

复杂度类 名称 示例操作
O(1) 常数 追加、获取项、设置项。
O(logn) 对数 在排序数组中查找元素。
O(n) 线性 复制、插入、删除、迭代。
nLogn 线性对数 排序列表,归并排序。
平方 在图中两个节点之间找到最短路径。嵌套循环。
立方 矩阵乘法。
2^n 指数 汉诺塔问题、回溯。

组成复杂度类

通常,我们需要找到多个基本操作的总运行时间。结果是我们可以将简单操作的复杂度类组合起来,以找到更复杂、组合操作的复杂度类。目标是分析函数或方法中的组合语句,以了解执行多个操作的总时间复杂度。将两个复杂度类组合的最简单方法是相加。这发生在我们有两个顺序操作时。例如,考虑将元素插入列表并排序该列表的两个操作。我们可以看到插入项的时间复杂度是 O(n),而排序的时间复杂度是 O(nlogn)。我们可以将总时间复杂度写成 O(n + nlogn),即,我们将两个函数放入 O(...)。我们只对最高阶项感兴趣,所以这仅留下 O(nlogn)。

如果我们重复一个操作,例如在 while 循环中,那么我们将复杂度类乘以操作执行的次数。如果一个具有时间复杂度 O(f(n))的操作重复 O(n)次,那么我们将两个复杂度相乘:

O(f(n) * O(n)) = O(nf(n)).

例如,假设函数 f(...)的时间复杂度为 O(n²),并且它在以下 while 循环中执行n次:

    for i n range(n): 
        f(...) 

这个循环的时间复杂度因此变为 O(n²) * O(n) = O(n * n²) = O()。这里我们只是将操作的复杂度与执行此操作的次数相乘。循环的运行时间最多是循环内语句的运行时间乘以迭代次数。一个嵌套的单层循环,即一个循环嵌套在另一个循环中,如果两个循环都运行n次,则将在n²时间内运行。例如:

    for i in range(0,n):  
        for j in range(0,n) 
            #statements 

每个语句都是一个常数,c,执行n**n次,因此我们可以将运行时间表示为; c**n n = cn² = O(n2)。

对于嵌套循环中的连续语句,我们添加每个语句的复杂度,并乘以语句执行的次数。例如:

    n = 500    #c0   
    #executes n times 
    for i in range(0,n): 
        print(i)    #c1 
    #executes n times 
    for i in range(0,n): 
        #executes n times 
        for j in range(0,n): 
        print(j)   #c2 

这可以写成 c[0] +c[1]n + cn² = O(n²)。

我们可以定义(以 2 为底)对数复杂度,在常数时间内减少问题的大小。例如,考虑以下代码片段:

    i = 1 
    while i <= n: 
        i=i * 2 
        print(i) 

注意到i在每次迭代中都翻倍,如果我们用n = 10 运行它,我们会看到它打印出四个数字;2,4,8,和 16。如果我们加倍n,我们会看到它打印出五个数字。随着 n 的后续加倍,迭代次数仅增加 1。如果我们假设k次迭代,我们可以将其写成以下形式:

图片

从这里我们可以得出结论,总时间 = O(log(n))。

虽然大 O 符号是参与渐近分析中最常用的符号,但还有两个其他相关的符号应该简要提及。它们是Ω符号和Θ符号。

Ω符号(Ω)

以类似的方式,大 O 符号描述了上界,Ω符号描述了一个紧下界。定义如下:

图片

目标是给出与给定算法 T(n)增长率相等或更小的最大增长率。

Θ符号(ϴ)

经常会出现给定函数的上界和下界相同的情况,Θ符号的目的是确定这种情况是否成立。定义如下:

图片

虽然Ω和Θ符号需要完全描述增长率,但最实用的还是大 O 符号,这也是你最常见的符号。

平均分析

通常我们并不那么关心单个操作的时空复杂度,而是关心一系列操作的平均运行时间。这被称为换算分析。它与我们将很快讨论的平均情况分析不同,因为它对输入值的分布没有做出任何假设。然而,它确实考虑了数据结构的状态变化。例如,如果一个列表已排序,它应该使任何后续的查找操作更快。换算分析可以考虑到数据结构的状态变化,因为它分析的是操作序列,而不是简单地汇总单个操作。

换算分析通过在一系列操作中对每个操作施加一个人为的成本,然后结合这些成本来找到一个运行时间的上界。一个序列的人为成本考虑到初始的昂贵操作可以使后续操作更便宜。

当我们有一些昂贵的操作,如排序,以及大量的便宜操作,如查找时,标准的最坏情况分析可能会导致过于悲观的结论,因为它假设每个查找都必须比较列表中的每个元素,直到找到匹配项。我们应该考虑到一旦我们排序列表,我们就可以使后续的查找操作更便宜。

到目前为止,在我们的运行时间分析中,我们假设输入数据是完全随机的,并且只看了输入大小对运行时间的影响。算法分析有两种其他常见的方法;它们是:

  • 平均情况分析

  • 基准测试

平均情况分析基于对各种输入值相对频率的一些假设来找到平均运行时间。使用现实世界的数据,或者复制现实世界数据分布的数据,在特定数据分布上多次进行,并计算平均运行时间。

基准测试只是有一个达成一致的典型输入集,用于衡量性能。基准测试和平均时间分析都依赖于某些领域知识。我们需要知道典型或预期的数据集是什么。最终,我们将尝试通过针对非常具体的应用设置进行微调来找到提高性能的方法。

让我们看看评估算法运行性能的一种简单方法。这可以通过简单地测量算法完成给定各种输入大小所需的时间来完成。正如我们之前提到的,这种测量运行性能的方法取决于其运行的硬件。显然,更快的处理器会给出更好的结果,然而,随着输入大小的增加,相对增长率将保留算法本身的特征,而不是其运行的硬件。绝对时间值将在不同的硬件(和软件)平台上有所不同;然而,它们的相对增长率仍然受算法时间复杂度的限制。

让我们以一个嵌套循环的简单例子为例。应该很明显,这个算法的时间复杂度是 O(n²),因为对于外循环中的每个 n 次迭代,内循环中也有 n 次迭代。例如,我们的简单嵌套 for 循环由内循环中执行的一个简单语句组成:

    def nest(n): 
        for i in range(n): 
            for j in range(n): 
                i+j 

以下是一个简单的测试函数,它使用递增的n值运行nest函数。在每次迭代中,我们使用timeit.timeit函数计算此函数完成所需的时间。在这个例子中,timeit函数接受三个参数,一个表示要计时的函数的字符串表示,一个导入nest函数的设置函数,以及一个表示执行主语句次数的int参数。由于我们感兴趣的是nest函数相对于输入大小n完成所需的时间,因此,在我们的目的上,每次迭代只需调用一次nest函数。以下函数返回每个n值的计算运行时间列表:

    import timeit  
    def test2(n): 
        ls=[] 
        for n in range(n): 
            t=timeit.timeit("nest(" + str(n) +")", setup="from __main__ import nest", number = 1) 
            ls.append(t) 
        return ls    

在以下代码中,我们运行了test2函数,并绘制了结果图,包括用于比较的适当缩放的 n²函数,用虚线表示:

    import matplotlib.pyplot as plt 
    n=1000 
    plt.plot(test2(n)) 
    plt.plot([x*x/10000000 for x in range(n)]) 

这给出了以下结果:

图片

如我们所见,这几乎是我们预期的结果。应该记住,这既代表了算法本身的性能,也代表了底层软件和硬件平台的行为,正如测量运行时间的可变性和运行时间的相对大小所表明的。显然,更快的处理器将导致更快的运行时间,而且性能还会受到其他运行进程、内存限制、时钟速度等因素的影响。

概述

在本章中,我们对算法设计进行了概述。重要的是,我们看到了一种平台无关的方式来衡量算法的性能。我们探讨了算法问题的不同方法。我们研究了递归乘大数和归并排序的递归方法。我们看到了如何使用回溯进行穷举搜索和生成字符串。我们还介绍了基准测试和一种简单的平台相关的方式来衡量运行时间。在接下来的章节中,我们将参考具体的数据结构重新审视许多这些想法。在下一章中,我们将讨论链表和其他指针结构。

第四章:列表和指针结构

你已经在 Python 中见过列表了。它们既方便又强大。通常,每次你需要将某物存储在列表中时,你都会使用 Python 的内置列表实现。然而,在本章中,我们更感兴趣的是了解列表是如何工作的。因此,我们将研究列表的内部机制。正如你所注意到的,存在不同类型的列表。

Python 的列表实现旨在强大且涵盖多个不同的用例。我们将对我们的列表定义更加严格。

节点的概念对列表非常重要。我们将在本章中讨论它们,但这个概念将以不同的形式贯穿整本书。

本章的重点将是以下内容:

  • 理解 Python 中的指针

  • 处理节点概念

  • 实现单链表、双链表和循环链表

在本章中,我们将大量处理指针。因此,提醒自己这些是什么可能是有用的。首先,想象一下你有一所房子想要出售。由于缺乏时间,你联系了一个经纪人来寻找感兴趣的买家。所以你拿起你的房子,带到经纪人那里,经纪人会把它带给任何可能想买的人。你可能会说这是荒谬的?现在想象一下你有几个处理图像的 Python 函数。所以你将在你的函数之间传递高分辨率的图像数据。

当然,你不会把你的房子随身携带。你会做的是在一张废纸上写下房子的地址,然后把它交给经纪人。房子仍然在那里,但包含去房子方向的纸条被传递开来。你甚至可能把它写在几页纸上。每一页都足够小,可以放进你的钱包里,但它们都指向同一所房子。

实际上,在 Python 的世界里,事情并没有太大的不同。那些大图像文件在内存中仍然只有一个地方。你所做的是创建变量来保存这些图像在内存中的位置。这些变量很小,可以很容易地在不同的函数之间传递。

这就是指针的大好处:它们允许你用一个简单的内存地址指向一个可能很大的内存段。

指针的支持存在于你的计算机硬件中,这被称为间接寻址。

在 Python 中,你不会像在 C 或 Pascal 等一些其他语言中那样直接操作指针。这导致一些人认为 Python 中没有使用指针。这完全不是事实。考虑在 Python 交互式 shell 中的这个赋值操作:

    >>> s = set()

我们通常会说s是一个集合类型的变量。也就是说,s是一个集合。然而,这并不完全正确。变量s实际上是一个指向集合的引用(一个“安全的”指针)。集合构造函数在内存中创建一个集合,并返回该集合开始的内存位置。这就是存储在s中的内容。

Python 隐藏了这种复杂性。我们可以安全地假设s是一个集合,并且一切正常。

数组

数组是一系列数据的顺序列表。顺序意味着每个元素都存储在内存中紧随前一个元素之后。如果你的数组真的很大,而你内存又不足,可能无法找到足够大的存储空间来容纳整个数组。这将导致问题。

当然,硬币的另一面是数组非常快。由于每个元素在内存中紧随前一个元素,因此不需要在不同的内存位置之间跳跃。这在选择列表和数组时是一个非常重要的考虑点,尤其是在你自己的实际应用中。

在第二章的后面部分,Python 数据类型和结构中,我们研究了数组数据类型,并发现了可以对其执行的各种操作。

指针结构

与数组相反,指针结构是内存中可以分散的项的列表。这是因为每个项包含一个或多个指向结构中其他项的链接。这些链接的类型取决于我们拥有的结构类型。如果我们处理的是链表,那么我们将有指向结构中下一个(以及可能的前一个)项的链接。在树的情况下,我们有父子链接以及兄弟链接。在一个基于瓦片的游戏中,游戏地图由六边形组成,每个节点将链接到最多六个相邻的地图单元格。

指针结构有几个优点。首先,它们不需要顺序存储空间。其次,它们可以从小开始,随着你向结构中添加更多节点而任意增长。

正如第二章中提到的,Python 数据类型和结构,然而,这是有代价的。如果你有一个整数列表,每个节点将占用一个整数的空间,以及一个额外的整数来存储指向下一个节点的指针。

节点

列表(以及几种其他数据结构)的核心是节点这个概念。在我们继续前进之前,让我们考虑一下这个想法。

首先,我们将创建一些字符串:

>>> a = "eggs"
>>> b = "ham"
>>> c = "spam"

现在你有三个变量,每个变量都有一个独特的名称、类型和值。我们还没有一种方式来说明变量之间是如何相互关联的。节点允许我们做到这一点。节点是数据的容器,同时包含一个或多个指向其他节点的链接。链接是一个指针。

简单类型的节点是只包含指向下一个节点的链接的节点。

当然,了解我们关于指针的知识,我们意识到这并不完全正确。字符串并不是真正存储在节点中,而是一个指向实际字符串的指针:

图片

因此,这个简单节点的存储需求是两个内存地址。节点的数据属性是指向字符串eggsham的指针。

寻找端点

我们创建了三个节点:一个包含鸡蛋,一个火腿,另一个垃圾邮件鸡蛋节点指向火腿节点,而火腿节点又指向垃圾邮件节点。但垃圾邮件节点指向什么呢?由于这是列表中的最后一个元素,我们需要确保其下一个成员具有一个值,使其清晰明了。

如果我们将最后一个元素指向空,那么我们就清楚地表明了这一点。在 Python 中,我们将使用特殊值None来表示空:

最后一个节点的下一个指针指向None。因此,它是节点链中的最后一个节点。

节点

这里是我们之前讨论的简单节点实现的示例:

    class Node: 
        def __init__(self, data=None): 
            self.data = data 
            self.next = None 

不要将节点的概念与 Node.js 混淆,Node.js 是一种用 JavaScript 实现的客户端技术。

next指针被初始化为None,这意味着除非你更改next的值,否则节点将是一个终点。这是一个好主意,这样我们就不会忘记正确地终止列表。

你可以根据需要向node类添加其他内容。只需确保你记住节点和数据之间的区别。如果你的节点将包含客户数据,那么创建一个Customer类并将所有数据放在那里。

你可能想实现__str__方法,以便在节点对象传递给打印时调用包含对象的__str__方法:

    def __str__(self): 
        return str(data) 

其他节点类型

我们假设节点具有指向下一个节点的指针。这可能是最简单的节点类型。然而,根据我们的需求,我们可以创建许多其他类型的节点。

有时候我们希望从 A 到 B,同时从 B 到 A。在这种情况下,我们除了下一个指针外,还添加一个前一个指针:

如上图所示,我们让最后一个和第一个节点都指向None,以表示我们已经到达了列表的边界,即列表的终点。第一个节点的前一个指针指向None,因为它没有前驱,就像最后一个项目的下一个指针指向None,因为它没有后续节点。

你可能还在为基于瓦片的游戏创建瓦片。在这种情况下,你可能会使用北、南、东和西,而不是上一个和下一个。还有更多类型的指针,但原则是相同的。地图末尾的瓦片将指向None

你可以根据需要做得更远。如果你需要能够向北西、东北、东南和西南移动,你只需要将这些指针添加到你的node类中。

单链表

单链表是只有两个连续节点之间有一个指针的列表。它只能单向遍历,也就是说,你可以从列表的第一个节点到最后的节点,但不能从最后一个节点移动到第一个节点。

我们实际上可以使用我们之前创建的node类来实现一个非常简单的单链表:

    >>> n1 = Node('eggs')
    >>> n2 = Node('ham')
    >>> n3 = Node('spam')

接下来,我们将节点链接在一起,形成一个

    >>> n1.next = n2
    >>> n2.next = n3

要遍历列表,您可以做一些类似以下的事情。我们首先将变量current设置为列表中的第一个项目:

    current = n1
    while current:
        print(current.data)
        current = current.next 

在循环中,我们在打印当前元素后,将current设置为列表中的下一个元素。我们一直这样做,直到我们到达列表的末尾。

然而,这种简单列表实现有几个问题:

  • 它需要程序员进行太多的手动工作

  • 这太容易出错(这是第一个问题的后果)

  • 列表的内部工作太多地暴露给了程序员

我们将在以下几节中解决所有这些问题。

单链表类

列表显然是一个与节点不同的概念。因此,我们首先创建一个非常简单的类来保存我们的列表。我们将从一个构造函数开始,该构造函数保留对列表中第一个节点的引用。由于这个列表最初是空的,我们将首先将这个引用设置为None

    class SinglyLinkedList:
         def __init__(self):
             self.tail = None 

追加操作

我们需要执行的第一项操作是将项目添加到列表中。这个操作有时被称为插入操作。在这里,我们有机会将Node类隐藏起来。我们的list类的用户实际上永远不需要与 Node 对象交互。这些都是纯粹的内部使用。

append()方法的第一次尝试可能看起来像这样:

    class SinglyLinkedList:
         # ...

         def append(self, data):
             # Encapsulate the data in a Node
             node = Node(data)

             if self.tail == None:
                 self.tail = node
             else:
                 current = self.tail
                 while current.next:
                     current = current.next
                 current.next = node 

我们在节点中封装数据,因此它现在具有下一个指针属性。从这里我们检查列表中是否存在任何现有节点(即self.tail是否指向一个 Node)。如果没有,我们将新节点作为列表的第一个节点;否则,通过遍历列表到最后一个节点,更新最后一个节点的下一个指针到新节点。

我们可以追加一些项目:

>>> words = SinglyLinkedList()
 >>> words.append('egg')
 >>> words.append('ham')
 >>> words.append('spam')

列表遍历将大致与之前相同。您将从列表本身获取列表的第一个元素:

>>> current = words.tail
>>> while current:
        print(current.data) 
        current = current.next

更快的追加操作

上一节中append方法有一个大问题:它必须遍历整个列表来找到插入点。当列表中只有几个项目时,这可能不是问题,但等到您需要添加数千个项目时。每次追加都会比上一次稍微慢一些。O(n)证明了我们的append方法当前实现实际上有多慢。

为了解决这个问题,我们将存储,不仅是对列表中第一个节点的引用,还有对最后一个节点的引用。这样,我们就可以快速将新节点追加到列表的末尾。追加操作的运行时间最坏情况现在从O(n)减少到O(1)。我们只需要确保上一个最后一个节点指向即将追加到列表中的新节点。以下是我们的更新代码:

    class SinglyLinkedList:
         def __init__(self): 
             # ...
             self.tail = None

         def append(self, data):
            node = Node(data)
            if self.head:
                self.head.next = node
                self.head = node
            else:
                self.tail = node
                self.head = node 

注意正在使用的约定。我们通过self.head追加新节点。self.tail变量指向列表中的第一个节点。

获取列表的大小

我们希望能够通过计数节点的数量来获取列表的大小。我们可以这样做的一种方法是在遍历整个列表的同时增加一个计数器:

    def size(self):
         count = 0
         current = self.tail
         while current:
             count += 1
             current = current.next
         return count 

这有效,但列表遍历可能是一个昂贵的操作,我们应该在可能的情况下避免它。因此,我们将选择对方法进行另一种重写。我们在SinglyLinkedList类中添加一个size成员,在构造函数中将它初始化为 0。然后在append方法中增加size的值:

class SinglyLinkedList:
     def __init__(self):
         # ...
         self.size = 0

     def append(self, data):
         # ...
         self.size += 1 

由于我们现在只读取节点对象的size属性,而不是使用循环来计算列表中节点的数量,我们将运行时间从O(n)降低到O(1)。

改进列表遍历

如果你注意到我们如何遍历我们的列表。那个我们仍然暴露在node类的地方。我们需要使用node.data来获取节点的内容,以及node.next来获取下一个节点。但之前我们提到过,客户端代码不应该需要与 Node 对象交互。我们可以通过创建一个返回生成器的方法来实现这一点。它看起来如下所示:

    def iter(self):
        current = self.tail
        while current:
            val = current.data
            current = current.next
            yield val  

现在列表遍历要简单得多,看起来也好多了。我们可以完全忽略列表外有任何名为 Node 的东西:

    for word in words.iter():
        print(word) 

注意,由于iter()方法产生节点的数据成员,我们的客户端代码根本不需要担心这一点。

删除节点

你还需要能够在列表上执行的一个常见操作是删除节点。这看起来可能很简单,但我们必须首先决定如何选择要删除的节点。是按索引号还是按节点包含的数据来删除?在这里,我们将选择按节点包含的数据来删除节点。

以下是在从列表中删除节点时考虑的特殊情况的图示:

图片

当我们想要删除位于两个其他节点之间的节点时,我们只需要让前一个节点直接指向其下一个节点的后继节点。也就是说,我们只是简单地将要删除的节点从链中切出来,就像前面的图像中那样。

delete()方法的实现可能看起来如下所示:

    def delete(self, data):
        current = self.tail
        prev = self.tail
        while current:
            if current.data == data:
                if current == self.tail:
                    self.tail = current.next
                else:
                    prev.next = current.next
                self.size -= 1
                return
            prev = current
            current = current.next 

它应该以O(n)的时间复杂度来删除一个节点。

列表搜索

我们可能还需要一种方法来检查列表是否包含一个项目。由于我们之前已经编写了iter()方法,这个方法实现起来相当简单。循环的每次迭代都会将当前数据与要搜索的数据进行比较。如果找到匹配项,则返回True,否则返回False

def search(self, data):
     for node in self.iter():
         if data == node:
             return True
     return False  

清除列表

我们可能希望有一种快速清除列表的方法。幸运的是,这对我们来说非常简单。我们只需将指针headtail清空,将它们设置为None

def clear(self): 
       """ Clear the entire list. """ 
       self.tail = None 
       self.head = None 

一举一动,我们将列表尾部的tail和头部的head指针上的所有节点都变成了孤儿。这会对中间的所有节点产生连锁反应,使它们也成为孤儿。

双向链表

现在我们已经对单链表及其上可以执行的操作有了坚实的理解,我们现在将注意力提升一个层次,转向双链表的话题。

双链表在某种程度上与单链表相似,因为我们使用了将节点串联在一起的基本思想。在单链表中,每个连续节点之间存在一个链接。双链表中的节点有两个指针:一个指向下一个节点,一个指向前一个节点:

单链表中的节点只能确定与其关联的下一个节点。但被引用的节点或下一个节点没有方法知道是谁在进行引用。方向的流动是单向的

在双链表中,我们给每个节点添加了不仅能够引用下一个节点,还能引用前一个节点的功能。

让我们检查两个连续节点之间存在的链接的性质,以便更好地理解:

由于存在指向下一个和前一个节点的两个指针,双链表具备了某些功能。

双链表可以双向遍历。根据操作的类型,双链表中的节点可以很容易地引用其前一个节点,而无需指定一个变量来跟踪该节点。因为单链表只能单向遍历,有时可能意味着移动到列表的开始或开头,以便对列表中埋藏的某些更改产生影响。

由于可以立即访问前一个和后一个节点,删除操作将更容易执行,正如你将在本章后面看到的那样。

双链表节点

Python 代码创建一个类来捕捉双链表节点是什么,包括其初始化方法中的prevnextdata实例变量。当一个节点被新创建时,所有这些变量默认为None

    class Node(object): 
        def __init__(self, data=None, next=None, prev=None): 
           self.data = data 
           self.next = next 
           self.prev = prev 

prev变量持有对前一个节点的引用,而next变量继续持有对下一个节点的引用。

双链表

仍然重要的是创建一个类来捕捉我们的函数将要操作的数据:

    class DoublyLinkedList(object):
       def __init__(self):
           self.head = None
           self.tail = None
           self.count = 0

为了增强size方法,我们还设置了count实例变量为 0。当我们开始向列表中插入节点时,headtail将指向列表的头和尾。

我们采用一个新的约定,其中self.head指向列表的起始节点,self.tail指向最新添加到列表中的节点。这与我们在单链表中使用的约定相反。关于头和尾节点指针的命名没有固定的规则。

双链表还需要提供返回列表大小、插入列表以及从列表中删除节点的函数。我们将检查执行这些操作的代码。让我们从append操作开始。

添加操作

append操作期间,检查head是否为None非常重要。如果是None,则意味着列表为空,应该将head设置为指向刚刚创建的节点。列表的tail也通过头部指向新节点。通过这些步骤的结束,headtail现在将指向相同的节点:

    def append(self, data): 
        """ Append an item to the list. """ 

           new_node = Node(data, None, None) 
           if self.head is None: 
               self.head = new_node 
               self.tail = self.head 
           else: 
               new_node.prev = self.tail 
               self.tail.next = new_node 
               self.tail = new_node 

               self.count += 1 

以下图表说明了在空列表中添加新节点时双链表的头部和尾部指针:

算法的else部分仅在列表不为空时执行。新节点的上一个变量被设置为列表的尾部:

    new_node.prev = self.tail 

尾部的下一个指针(或变量)被设置为新节点:

    self.tail.next = new_node 

最后,我们将尾指针更新为新节点:

    self.tail = new_node 

由于append操作会使节点数量增加一个,因此我们将计数器增加一个:

    self.count += 1 

添加操作的视觉表示如下:

删除操作

与单链表不同,在遍历整个列表长度时,我们需要跟踪遇到的先前节点,双链表通过使用前向指针避免了这一步骤。

从双链表中删除节点之前,算法基本上处理四种场景。这些是:

  • 当搜索项根本找不到时

  • 当搜索项在列表的非常开始处找到时

  • 当搜索项在列表的尾部找到时

  • 当搜索项在列表的中间位置找到时

当要删除的节点的data实例变量与传递给方法以用于搜索节点数据的数据匹配时,就识别出要删除的节点。如果找到匹配的节点并将其删除,则将变量node_deleted设置为True。任何其他结果都会导致node_deleted被设置为False

    def delete(self, data): 
        current = self.head 
        node_deleted = False 
        ...    

delete方法中,current变量被设置为列表的头部(即,它指向列表的self.head)。然后使用一系列的if...else语句来搜索列表的各个部分以找到具有指定数据的节点。

首先搜索head节点。由于current指向head,如果current是 None,则假定列表没有节点,以至于无法开始搜索要删除的节点:

    if current is None: 
        node_deleted = False     

然而,如果current(现在指向头部)包含正在搜索的数据,则self.head被设置为指向current的下一个节点。由于现在头部后面没有节点,self.head.prev被设置为None

    elif current.data == data: 
        self.head = current.next 
        self.head.prev = None 
        node_deleted = True 

如果要删除的节点位于链表的尾部,则采用类似的策略。这是第三个寻找要删除的节点可能位于链表末尾的可能性:

    elif self.tail.data == data: 
        self.tail = self.tail.prev 
        self.tail.next = None 
        node_deleted = True 

最后,查找和删除节点的算法遍历节点列表。如果找到一个匹配的节点,current的前一个节点连接到current的下一个节点。在此步骤之后,current的下一个节点连接到current的前一个节点:

else
    while current: 
        if current.data == data: 
            current.prev.next = current.next 
            current.next.prev = current.prev 
            node_deleted = True 
        current = current.next 

在评估完所有if-else语句后,然后检查node_delete变量。如果任何if-else语句改变了这个变量,那么这意味着链表中的一个节点已被删除。因此,计数变量减 1:

    if node_deleted: 
        self.count -= 1 

例如,假设存在三个节点 A、B 和 C。要删除列表中间的节点 B,我们实际上将使 A 指向 C 作为其下一个节点,同时使 C 指向 A 作为其前一个节点:

在这样的操作之后,我们得到以下列表:

列表搜索

搜索算法与单链表中的search方法类似。我们调用内部方法iter()来返回所有节点的数据。当我们遍历数据时,每个数据都与传递到contain方法中的数据进行匹配。如果找到匹配项,我们返回True,否则返回False以表示未找到匹配项:

    def contain(self, data): 
        for node_data in self.iter(): 
            if data == node_data: 
                return True 
            return False 

我们的双链表对于append操作的时间复杂度是O(1),对于delete操作的时间复杂度是O(n)。

循环链表

循环链表是链表的一种特殊情况。它是一个端点相连的列表。也就是说,列表中的最后一个节点指向第一个节点。循环链表可以基于单链表和双链表。在双链表循环链表的情况下,第一个节点也需要指向最后一个节点。

在这里,我们将查看单链表循环链表的实现。一旦掌握了基本概念,实现双链表循环链表应该很简单。

我们可以重用我们在单链表部分创建的node类。实际上,我们还可以重用SinglyLinkedList类的大部分内容。因此,我们将重点关注与正常单链表实现不同的循环链表方法。

添加元素

当我们将一个元素添加到循环链表时,我们需要确保新节点指向尾部节点。以下代码展示了这一点。与单链表实现相比,这里多了一行:

     def append(self, data): 
           node = Node(data) 
           if self.head: 
               self.head.next = node 
               self.head = node 
           else: 
               self.head = node 
               self.tail = node 
           self.head.next = self.tail 
           self.size += 1 

删除一个元素

我们可能会认为我们可以遵循与 append 相同的原理,只需确保头指针指向尾部。这将给出以下实现:

   def delete(self, data): 
       current = self.tail 
       prev = self.tail 
       while current: 
           if current.data == data: 
               if current == self.tail: 
                   self.tail = current.next 
                   self.head.next = self.tail 
               else: 
                   prev.next = current.next 
               self.size -= 1 
               return 
           prev = current 
           current = current.next 

如前所述,只需要更改一行。只有当我们移除尾节点时,我们才需要确保头节点更新为指向新的尾节点。

然而,这段代码存在一个严重的问题。在循环列表的情况下,我们不能通过直到current变为None来循环,因为这种情况永远不会发生。如果你删除一个现有的节点,你不会看到这个问题,但尝试删除一个不存在的节点,你将陷入一个不确定的循环中。

因此,我们需要找到一种不同的方式来控制while循环。我们不能检查current是否到达了head,因为那样它将永远不会检查最后一个节点。但我们可以使用prev,因为它落后于current一个节点。然而,有一个特殊情况。在第一次循环迭代中,currentprev将指向同一个节点,即尾节点。我们想确保循环在这里确实运行,因为我们需要考虑只有一个节点的列表。更新后的delete方法现在如下所示:

def delete(self, data): 
        current = self.tail 
        prev = self.tail 
        while prev == current or prev != self.head: 
            if current.data == data: 
                if current == self.tail: 
                    self.tail = current.next 
                    self.head.next = self.tail 
                else: 
                    prev.next = current.next 
                self.size -= 1 
                return 
            prev = current 
            current = current.next 

遍历循环列表

你不需要修改iter()方法。它将完美适用于我们的循环列表。但当你遍历循环列表时,你确实需要设置一个退出条件,否则你的程序将陷入循环。这里有一种你可以这样做的方法,通过使用一个计数器变量:

    words = CircularList() 
    words.append('eggs') 
    words.append('ham') 
    words.append('spam') 

    counter = 0 
    for word in words.iter(): 
       print(word) 
       counter += 1 
       if counter > 1000: 
           break 

一旦我们打印出 1,000 个元素,我们就跳出循环。

摘要

在本章中,我们研究了链表。我们研究了列表背后的概念,例如节点和其他节点的指针。我们实现了这些类型列表上发生的主要操作,并看到了它们的运行时间最坏情况是如何比较的。

在下一章,我们将探讨两种通常使用列表实现的其他数据结构:栈和队列。

第五章:栈和队列

在本章中,我们将基于上一章学到的技能来创建特殊的列表实现。我们仍然坚持线性结构。我们将在接下来的章节中学习更复杂的数据结构。

在本章中,我们将研究以下内容:

  • 实现栈和队列

  • 栈和队列的一些应用

栈是一种常被比作盘子堆的数据结构。如果你刚刚洗完一个盘子,你就把它放在栈顶。当你需要盘子时,你就从栈顶取下来。所以最后被添加到栈中的盘子将是第一个被取出的。因此,栈是一种后进先出LIFO)的结构:

图片

之前的图展示了盘子堆。向盘子堆中添加盘子只能通过将盘子留在堆顶来实现。从盘子堆中移除盘子意味着移除堆顶的盘子。

栈上执行的两个主要操作是pushpop。当一个元素被添加到栈顶时,它被推入栈中。当一个元素从栈顶取出时,它被从栈中弹出。有时还会使用另一个操作peek,这使得可以在不弹出元素的情况下查看栈上的元素。

栈被用于许多事情。栈的一个非常常见的用途是在函数调用期间跟踪返回地址。让我们想象一下,我们有一个以下的小程序:

def b(): 
    print('b') 

def a(): 
    b() 

a() 
print("done") 

当程序执行到达对a()的调用时,它首先将后续指令的地址推入栈中,然后跳转到a。在a内部调用b()之前,返回地址被推入栈中。一旦进入b()并且函数执行完毕,返回地址从栈中弹出,这使我们回到a()。当a完成时,返回地址从栈中弹出,这使我们回到print语句。

栈实际上也被用来在函数之间传递数据。比如说,你的代码中某处有一个以下函数调用:

   somefunc(14, 'eggs', 'ham', 'spam') 

将要发生的事情是14, 'eggs', 'ham''spam'将逐个推入栈中:

图片

当代码跳入函数时,a, b, c, d的值将从栈中弹出。spam元素将首先弹出并赋值给d,然后"ham"将赋值给c,依此类推:

    def somefunc(a, b, c, d): 
        print("function executed")

栈实现

现在我们来研究一下 Python 中栈的实现。我们首先创建一个node类,就像我们在上一章中使用列表时做的那样:

class Node: 
    def __init__(self, data=None): 
        self.data = data 
        self.next = None 

到现在为止,这应该对你来说很熟悉了:一个节点持有数据并指向列表中的下一个项目。我们将实现一个栈而不是列表,但节点链接在一起的原则仍然适用。

现在让我们看看 stack 类。它开始时类似于一个单链表。我们需要知道栈顶的节点。我们还想跟踪栈中的节点数量。因此,我们将把这些字段添加到我们的类中:

class Stack: 
    def __init__(self): 
        self.top = None 
        self.size = 0 

推送操作

push 操作用于将元素添加到栈顶。以下是一个实现示例:

   def push(self, data): 
       node = Node(data) 
       if self.top: 
           node.next = self.top 
           self.top = node                 
       else: 
           self.top = node 
       self.size += 1 

在以下图中,创建我们的新节点后没有现有的节点。因此 self.top 将指向这个新节点。if 语句的 else 部分保证了这一点:

图片

在我们有一个现有的栈的场景中,我们将 self.top 移动,使其指向新创建的节点。新创建的节点必须有其 next 指针,指向原来栈顶的节点:

图片

弹出操作

现在我们需要一个 pop 方法来从栈中移除顶部元素。当我们这样做时,我们需要返回顶部元素。如果没有更多元素,我们将使栈返回 None

    def pop(self): 
        if self.top: 
            data = self.top.data 
            self.size -= 1  
            if self.top.next: 
                self.top = self.top.next 
            else: 
                self.top = None 
            return data 
        else: 
            return None 

这里需要注意的地方是内部的 if 语句。如果顶部节点有它的 next 属性指向另一个节点,那么我们必须将栈顶设置为现在指向那个节点:

图片

当栈中只有一个节点时,pop 操作将按以下方式进行:

图片

移除这样的节点会导致 self.top 指向 None

图片

查看操作

如我们之前所说,我们也可以添加一个 peek 方法。这个方法将只返回栈顶元素而不从栈中移除它,允许我们查看顶部元素而不改变栈本身。这个操作非常直接。如果有顶部元素,返回其数据,否则返回 None(这样 peek 的行为就与 pop 相匹配):

    def peek(self): 
        if self.top 
            return self.top.data 
        else: 
            return None 

括号匹配应用

现在让我们看看我们如何使用我们的栈实现。我们将编写一个小的函数来验证包含括号(-[{)的语句是否平衡,即闭括号的数量是否与开括号的数量匹配。它还将确保一对括号确实包含在另一个括号中:

    def check_brackets(statement): 
        stack = Stack() 
        for ch in statement: 
            if ch in ('{', '[', '('): 
                stack.push(ch) 
            if ch in ('}', ']', ')'): 
                last = stack.pop() 
            if last is '{' and ch is '}': 
                continue 
            elif last is '[' and ch is ']': 
                continue 
            elif last is '(' and ch is ')': 
                continue 
            else: 
                return False 
    if stack.size > 0: 
        return False 
    else: 
        return True 

我们的功能解析传递给它的语句中的每个字符。如果它得到一个开括号,它将其推入栈中。如果它得到一个闭括号,它将栈顶元素弹出并与两个括号进行比较,以确保它们的类型匹配:( 应该匹配 )[ 应该匹配 ]{ 应该匹配 }。如果不匹配,我们返回 False,否则我们继续解析。

一旦我们到达语句的末尾,我们需要进行最后的检查。如果栈为空,那么我们就好了,我们可以返回 True。但如果栈不为空,那么我们有一些没有匹配闭括号的开括号,我们应该返回 False

我们可以用以下简短的代码测试括号匹配器:

sl = ( 
   "{(foo)(bar)}hellois)a)test", 
   "{(foo)(bar)}hellois)atest", 
   "{(foo)(bar)}hellois)a)test))" 
) 

for s in sl: 
   m = check_brackets(s) 
   print("{}: {}".format(s, m)) 

只有三个语句中的第一个应该匹配。当我们运行代码时,我们得到以下输出:

TrueFalseFalse。代码是有效的。总之,栈数据结构的pushpop操作吸引了一个O(1)。栈数据结构足够简单,但在现实世界中用于实现一系列功能。浏览器的后退和前进按钮就是通过栈实现的。为了在文字处理器中实现撤销和重做功能,栈也被使用。

队列

另一种特殊的列表类型是队列数据结构。这种数据结构与你在现实生活中习惯的普通队列没有区别。如果你曾在机场排队或在你家附近的商店排队等待点你最喜欢的汉堡,那么你应该知道队列是如何工作的。

队列也是一个非常基础且重要的概念,需要掌握,因为许多其他数据结构都是基于它构建的。

队列的工作方式是,通常第一个加入队列的人会先被服务,在所有条件相同的情况下。缩写 FIFO 最能解释这一点。FIFO代表先进先出。当人们排队等待轮到他们被服务时,服务只在前端进行。人们退出队列的唯一时间是当他们被服务,这只会发生在队列的最前端。根据严格的定义,人们加入正在被服务的前端队列是非法的:

要加入队列,参与者必须首先移动到队列中最后一个人的后面。队列的长度无关紧要。这是队列接受新成员的唯一合法或允许的方式。

尽管我们人类如此,我们形成的队列并不遵循严格的规则。可能会有已经在队列中的人决定退出,甚至有其他人代替他们。我们并不打算模拟现实中队列发生的所有动态。抽象队列是什么以及它是如何工作的,使我们能够解决许多挑战,尤其是在计算领域。

我们将提供队列的各种实现,但所有实现都将围绕 FIFO(先进先出)这一相同理念。我们将把向队列中添加元素的运算称为入队。要从队列中移除元素,我们将创建一个出队操作。每次元素入队时,队列的长度或大小增加一。相反,出队项目将队列中的元素数量减少一。

为了演示两种操作,以下表格显示了向队列中添加和移除元素的效果:

队列操作 大小 内容 操作结果
Queue() 0 [] 创建队列对象
入队 "Mark" 1 ['mark'] 将 Mark 添加到队列中
入队 "John" 2 ['mark','john'] 将 John 添加到队列中
Size() 2 ['mark','john'] 返回队列中的项目数量
Dequeue() 1 ['mark'] John 被出队并返回
Dequeue() 0 [] Mark 被出队并返回

基于列表的队列

为了将关于队列的所有讨论转化为代码,让我们继续使用 Python 的 list 类来实现一个非常简单的队列。这是为了帮助我们快速开发并了解队列。必须在队列上执行的操作封装在 ListQueue 类中:

class ListQueue: 
    def __init__(self): 
        self.items = [] 
        self.size = 0 

在初始化方法 __init__ 中,items 实例变量被设置为 [],这意味着队列在创建时是空的。队列的大小也被设置为 zero。更有趣的方法是 enqueuedequeue 方法。

Enqueue 操作

enqueue 操作或方法使用 list 类的 insert 方法在列表的前端插入项目(或数据):

    def enqueue(self, data): 
        self.items.insert(0, data) 
        self.size += 1 

请注意我们是如何实现向队列末尾插入元素的。索引 0 是任何列表或数组中的第一个位置。然而,在我们的使用 Python 列表实现的队列中,数组索引 0 是唯一可以插入新数据元素到队列中的地方。insert 操作会将列表中的现有数据元素向上移动一个位置,然后在索引 0 处创建的空间中插入新数据。以下图示展示了这一过程:

为了使我们的队列反映新元素的添加,大小增加了一个:

self.size += 1 

我们本可以使用 Python 列表的 shift 方法作为实现“在 0 处插入”的另一种方式。最终,实现是这项练习的整体目标。

Dequeue 操作

dequeue 操作用于从队列中移除项目。参考队列主题的介绍,这个操作捕捉了我们服务第一个加入队列并等待时间最长的客户这一点:

    def dequeue(self):
        data = self.items.pop()
        self.size -= 1
        return data

Python 的 list 类有一个名为 pop() 的方法。pop 方法执行以下操作:

  1. 从列表中移除最后一个项目。

  2. 将从列表中移除的项目返回给调用它的用户或代码。

列表中的最后一个项目被弹出并保存在 data 变量中。在方法的最后一行,数据被返回。

考虑以下图中的隧道作为我们的队列。为了执行 dequeue 操作,数据为 1 的节点从队列的前端被移除:

队列中的结果元素如下所示:

我们对 enqueue 操作有什么可以说的?它在多个方面都效率低下。该方法必须首先将所有元素移动一个空间。想象一下,当列表中有 100 万个元素需要移动时,每次向队列中添加新元素时都需要进行移动。这通常会使大型列表的入队过程变得非常缓慢。

基于栈的队列

队列的另一种实现是使用两个栈。再次使用 Python 的 list 类来模拟栈:

class Queue: 
    def __init__(self): 
        self.inbound_stack = [] 
        self.outbound_stack = [] 

上述queue类在初始化时将两个实例变量设置为空列表。这些是帮助我们实现队列的栈。在这种情况下,栈只是允许我们调用pushpop方法的 Python 列表。

inbound_stack仅用于存储添加到队列的元素。对此栈不能执行其他操作。

入队操作

enqueue方法是将元素添加到队列的方法:

def enqueue(self, data): 
    self.inbound_stack.append(data) 

该方法是简单的,它只接收客户端想要添加到队列中的data。然后,这些数据被传递到queue类中inbound_stackappend方法。此外,append方法用于模拟push操作,该操作将元素推送到栈顶。

要将数据enqueueinbound_stack,以下代码是合适的:

queue = Queue() 
queue.enqueue(5) 
queue.enqueue(6) 
queue.enqueue(7) 
print(queue.inbound_stack) 

队列内部inbound_stack的命令行输出如下:

    [5, 6, 7]

出队操作

dequeue操作比其enqueue对应操作更复杂。添加到我们队列的新元素最终会进入inbound_stack。我们不是从inbound_stack中删除元素,而是将注意力转向outbound_stack。正如我们所说,只能通过outbound_stack来删除队列中的元素:

    if not self.outbound_stack: 
        while self.inbound_stack: 
            self.outbound_stack.append(self.inbound_stack.pop()) 
    return self.outbound_stack.pop() 

if语句首先检查outbound_stack是否为空。如果不为空,我们继续执行以下操作来移除队列前端的元素:

return self.outbound_stack.pop() 

如果outbound_stack为空,则在从队列前端弹出元素之前,将inbound_stack中的所有元素移动到outbound_stack

while self.inbound_stack: 
    self.outbound_stack.append(self.inbound_stack.pop()) 

只要inbound_stack中有元素,while循环就会继续执行。

语句self.inbound_stack.pop()将移除最近添加到inbound_stack的最新元素,并将其立即传递给self.outbound_stack.append()方法调用。

初始时,我们的inbound_stack被填充了元素567

图片

执行while循环体之后,outbound_stack看起来如下:

图片图片

dequeue方法中的最后一行将返回5作为对outbound_stack执行pop操作的结果:

return self.outbound_stack.pop() 

这使得outbound_stack只剩下两个元素:

图片

下一次调用dequeue操作时,while循环将不会执行,因为outbound_stack中没有元素,这使得外部的if语句失败。

在这种情况下,立即调用pop操作,以便只返回等待时间最长的队列中的元素。

使用此队列实现的典型代码运行如下:

queue = Queue() 
queue.enqueue(5) 
queue.enqueue(6) 
queue.enqueue(7) 
print(queue.inbound_stack) 
queue.dequeue() 
print(queue.inbound_stack) 
print(queue.outbound_stack) 
queue.dequeue() 
print(queue.outbound_stack) 

上述代码的输出如下:

 [5, 6, 7] 
 [] 
 [7, 6] 
 [7] 

代码示例向队列中添加元素并打印队列中的元素。调用 dequeue 方法后,再次打印队列时观察到元素数量的变化。

在面试中,使用两个栈实现队列是一个常见的问题。

基于节点的队列

使用 Python 列表实现队列是一个很好的入门方式,以了解队列的工作原理。我们完全可以通过利用我们对指针结构的了解来实现自己的队列数据结构。

可以使用双向链表实现队列,并且在此数据结构上的 插入删除 操作的时间复杂度为 O(1)。

node 类的定义与我们在讨论双向链表时定义的 Node 相同。如果双向链表允许进行 FIFO 类型的数据访问,即首先添加到列表的元素是第一个被移除的,则可以将双向链表视为队列。

队列类

queue 类与双向链表的 list 类非常相似:

class Queue: 
def __init__(self): 
        self.head = None 
        self.tail = None 
        self.count = 0 

在创建 queue 类的实例时,将 self.headself.tail 指针设置为 None。为了保持 Queue 中节点数量的计数,这里也维护了一个 count 实例变量,并将其设置为 0

入队操作

通过 enqueue 方法将元素添加到 Queue 对象中。在这种情况下,元素是节点:

    def enqueue(self, data): 
        new_node = Node(data, None, None) 
        if self.head is None: 
            self.head = new_node 
            self.tail = self.head 
        else: 
            new_node.prev = self.tail 
            self.tail.next = new_node 
            self.tail = new_node 

        self.count += 1 

enqueue 方法的代码与我们在双向链表的 append 操作中解释的代码相同。它从传递给它的数据创建一个节点并将其追加到队列的尾部,或者如果队列是空的,则将 self.headself.tail 都指向新创建的节点。通过 self.count += 1 这一行,队列中的元素总数增加。

出队操作

使我们的双向链表表现得像队列的另一个操作是 dequeue 方法。此方法就是移除队列前端的节点。

要移除由 self.head 指向的第一个元素,使用一个 if 语句:

def dequeue(self): 
current = self.head 
        if self.count == 1: 
            self.count -= 1 
            self.head = None 
            self.tail = None 
        elif self.count > 1: 
            self.head = self.head.next 
            self.head.prev = None 
            self.count -= 1 

current 通过将其指向 self.head 来初始化。如果 self.count 是 1,那么这意味着列表和队列中只有一个节点。因此,为了移除相关的节点(由 self.head 指向),将 self.headself.tail 变量设置为 None

另一方面,如果队列中有许多节点,则头指针会移动以指向 self.head 的下一个节点。

在执行 if 语句后,方法返回由 head 指向的节点。无论 if 语句执行路径如何,self.count 都会递减一个。

配备了这些方法,我们已经成功地实现了一个队列,大量借鉴了双向链表的想法。

记住,将我们的双向链表转变为队列的只有两个方法,即 enqueuedequeue

队列的应用

在计算机领域,队列被用来实现各种功能。例如,而不是为网络上的每一台计算机提供其自己的打印机,可以通过排队每台打印机想要打印的内容,让一组计算机共享一台打印机。当打印机准备好打印时,它会从队列中选择一个项目(通常称为作业)进行打印。

操作系统也会将进程排队以供 CPU 执行。让我们创建一个利用队列创建基本媒体播放器的应用程序。

媒体播放器队列

大多数音乐播放器软件都允许用户有机会将歌曲添加到播放列表中。当按下播放按钮时,主播放列表中的所有歌曲会依次播放。由于首先被排队的歌曲是第一个被播放的歌曲,因此可以使用队列来实现歌曲的顺序播放,这与 FIFO 的缩写相符合。我们将实现我们自己的播放列表队列,以 FIFO 的方式播放歌曲。

基本上,我们的媒体播放器队列只允许添加轨道以及播放队列中所有轨道的方式。在一个完整的音乐播放器中,会使用线程来改善队列的交互方式,同时音乐播放器继续被用来选择下一首将要播放、暂停或甚至停止的歌曲。

track 类将模拟一个音乐轨道:

from random import randint 
class Track: 

    def __init__(self, title=None): 
        self.title = title 
        self.length = randint(5, 10) 

每个轨道都保存了歌曲标题的引用以及歌曲的长度。长度是介于 5 到 10 之间的随机数。随机模块提供了 randint 方法,使我们能够生成随机数。这个类代表任何包含音乐的 MP3 轨道或文件。轨道的随机长度被用来模拟播放一首歌曲或轨道所需的时间。

要创建几个轨道并打印出它们的长度,我们执行以下操作:

track1 = Track("white whistle") 
track2 = Track("butter butter") 
print(track1.length) 
print(track2.length) 

上述代码的输出如下:

    6
 7

您的输出可能因两个轨道生成的随机长度不同而不同。

现在,让我们创建我们的队列。使用继承,我们简单地从 queue 类继承:

import time 
class MediaPlayerQueue(Queue): 

    def __init__(self): 
        super(MediaPlayerQueue, self).__init__() 

通过调用 super 来正确初始化队列,进行调用以创建队列。这个类本质上是一个队列,它在一个队列中持有多个轨道对象。为了向队列中添加轨道,创建了一个 add_track 方法:

    def add_track(self, track): 
        self.enqueue(track) 

该方法将一个 track 对象传递给队列的 super 类的 enqueue 方法。这将实际上使用 track 对象(作为节点数据)创建一个 Node,并将队列的尾指针(如果队列不为空)或头尾指针(如果队列为空)指向这个新节点。

假设队列中的轨道是按照从第一个添加的轨道到最后的顺序播放(FIFO),那么 play 函数必须遍历队列中的元素:

def play(self): 
        while self.count > 0: 
            current_track_node = self.dequeue() 
            print("Now playing {}".format(current_track_node.data.title)) 
            time.sleep(current_track_node.data.length) 

self.count记录了曲目被添加到我们的队列中和曲目被出队的时间。如果队列不为空,调用dequeue方法将返回队列前端的节点(其中包含track对象)。然后print语句通过节点的data属性访问曲目的标题。为了进一步模拟曲目的播放,time.sleep()方法使程序执行暂停,直到曲目的秒数已过:

time.sleep(current_track_node.data.length) 

媒体播放器队列由节点组成。当一首曲目被添加到队列中时,该曲目会隐藏在一个新创建的节点中,并与节点的数据属性相关联。这就解释了为什么我们通过调用dequeue返回的节点(其中包含track对象)的数据属性来访问节点的track对象:

你可以看到,我们的node对象不仅仅存储任何数据,在这种情况下,它存储的是曲目。

让我们来试一下我们的音乐播放器:

track1 = Track("white whistle") 
track2 = Track("butter butter") 
track3 = Track("Oh black star") 
track4 = Track("Watch that chicken") 
track5 = Track("Don't go") 

我们创建了五个曲目对象,标题为随机单词:

print(track1.length) 
print(track2.length) 
>> 8 >> 9

由于随机长度,输出应该与你在机器上得到的不同。

接下来,创建了一个MediaPlayerQueue类的实例:

media_player = MediaPlayerQueue() 

曲目将被添加,play函数的输出应该以我们排队相同的顺序打印出正在播放的曲目:

media_player.add_track(track1) 
media_player.add_track(track2) 
media_player.add_track(track3) 
media_player.add_track(track4) 
media_player.add_track(track5) 
media_player.play() 

上述代码的输出如下:

    >>Now playing white whistle
 >>Now playing butter butter
 >>Now playing Oh black star
 >>Now playing Watch that chicken
 >>Now playing Don't go

程序执行后,可以看到曲目是按照它们被加入队列的顺序播放的。在播放曲目时,系统也会暂停与曲目长度相等的秒数。

概述

在本章中,我们利用将节点链接在一起的知识来创建其他数据结构,即栈和队列。我们看到了这些数据结构如何紧密地模仿现实世界中的栈和队列。展示了具体实现及其不同类型。我们后来将栈和队列的概念应用于编写现实生活中的程序。

在下一章中,我们将考虑树。将讨论树上的主要操作,同样也会讨论应用这种数据结构的不同领域。

第六章:树

树是一种层次化的数据结构。当我们处理列表、队列和栈时,项目是依次跟随的。但在树中,项目之间存在 父子 关系。

要可视化树的外观,想象一棵从地面生长起来的树。现在从你的脑海中移除那个图像。树通常向下绘制,所以你最好想象树的根结构向下生长。

每棵树的顶部都有所谓的 根节点。这是树中所有其他节点的祖先。

树被用于许多事情,例如解析表达式和搜索。某些文档类型,如 XML 和 HTML,也可以以树的形式表示。在本章中,我们将探讨树的一些用途。

在本章中,我们将涵盖以下内容:

  • 树的术语和定义

  • 二叉树和二叉搜索树

  • 树遍历

术语

让我们考虑一些与树相关的术语。

要理解树,我们首先需要理解它们所基于的基本概念。以下图包含一个典型的树,由字母节点 AM 组成。

这里是一个与树相关的术语列表:

  • 节点:每个圆圈中的字母代表一个节点。节点是任何包含数据的结构。

  • 根节点:根节点是从中产生所有其他节点的唯一节点。如果一个树没有可区分的根节点,则不能将其视为树。我们树中的根节点是节点 A。

  • 子树:树的子树是具有其节点为其他树的后代的一些树的树。节点 F、K 和 L 形成了原始树(包含所有节点)的子树。

  • :给定节点的子树数量。仅由一个节点组成的树具有度为 0。这个单一树节点在所有标准下也被认为是树。节点 A 的度数为 2。

  • 叶节点:这是一个度为 0 的节点。节点 J、E、K、L、H、M 和 I 都是叶节点。

  • :两个节点之间的连接。有时边可以连接一个节点到自身,使边看起来像是一个环。

  • 父节点:树中带有其他连接节点的节点是这些节点的父节点。节点 B 是节点 D、E 和 F 的父节点。

  • 子节点:这是连接到其父节点的节点。节点 B 和 C 是节点 A(父节点和根节点)的子节点。

  • 兄弟节点:所有具有相同父节点的节点都是兄弟节点。这使得节点 B 和 C 成为兄弟节点。

  • 级别:节点的级别是从根节点到该节点的连接数量。根节点位于级别 0。节点 B 和 C 位于级别 1。

  • 树的高度:这是树中的层数。我们的树的高度为 4。

  • 深度:节点的深度是从树的根到该节点的边的数量。节点 H 的深度为 2。

我们将通过对树中的节点进行考虑并抽象出一个类来开始我们对树的讨论。

树节点

正如我们之前遇到的其他数据结构一样,例如列表和栈,树是由节点组成的。但构成树的节点需要包含我们之前提到的父子关系的数据。

让我们现在看看如何在 Python 中构建一个二叉树 node 类:

    class Node: 
        def __init__(self, data): 
            self.data = data 
            self.right_child = None 
            self.left_child = None 

就像我们之前的实现一样,节点是数据的容器,并持有对其他节点的引用。作为一个二叉树节点,这些引用是对左子节点和右子节点的引用。

为了测试这个类,我们首先创建几个节点:

    n1 = Node("root node")  
    n2 = Node("left child node") 
    n3 = Node("right child node") 
    n4 = Node("left grandchild node") 

接下来,我们将节点连接起来。我们让 n1 作为根节点,n2n3 作为其子节点。最后,我们将 n4 作为 n2 的左子节点连接起来,这样当我们遍历左子树时就会得到几个迭代:

    n1.left_child = n2 
    n1.right_child = n3 
    n2.left_child = n4 

一旦我们设置了树结构,我们就可以遍历它了。如前所述,我们将遍历左子树。我们打印出节点,并沿着树向下移动到下一个左节点。我们一直这样做,直到我们到达左子树的末尾:

    current = n1 
    while current: 
        print(current.data) 
        current = current.left_child 

你可能已经注意到,这需要在客户端代码中做相当多的工作,因为你必须手动构建树结构。

二叉树

二叉树是一种每个节点最多有两个子节点的树。二叉树非常常见,我们将使用它们来构建 BST 的实现。

Python.

以下是一个以 5 为根节点的二叉树的示例:

每个子节点都被标识为其父节点的右子节点或左子节点。由于父节点本身也是一个节点,每个节点都将持有对右节点和左节点的引用,即使这些节点不存在。

一个常规的二叉树没有关于元素如何排列在树中的规则。它只满足每个节点最多有两个子节点的条件。

二叉搜索树

二叉搜索树BST)是一种特殊的二叉树。也就是说,它是一个结构上是二叉树的树。功能上,它是一个以能够高效搜索树的方式存储其节点。

BST 有一种结构。对于给定值的节点,左子树中的所有节点都小于或等于该节点的值。同样,该节点的右子树中的所有节点都大于其父节点的值。作为一个例子,考虑以下树:

这是一个 BST 的例子。测试我们的树以检查 BST 的属性,你会发现根节点的左子树中的所有节点值都小于 5。同样,右子树中的所有节点值都大于 5。这个属性适用于 BST 中的所有节点,没有例外:

尽管前面的图看起来与前面的图相似,但它并不符合二叉搜索树(BST)的定义。节点 7 大于根节点 5;然而,它位于根节点的左侧。节点 4 是其父节点 7 的右子树,这是不正确的。

二叉搜索树实现

让我们开始实现 BST。我们希望树能持有其自己的根节点的引用:

    class Tree: 
        def __init__(self): 
            self.root_node = None 

这就是维护树状态所需的所有内容。让我们在下一节中检查树上的主要操作。

二叉搜索树操作

实际上需要两种操作来拥有一个可用的 BST。这些是insertremove操作。这些操作必须遵循一个规则,即它们必须维护 BST 结构的原理。

在我们处理节点的插入和删除之前,让我们讨论一些同样重要的操作,这些操作将帮助我们更好地理解insertremove操作。

寻找最小和最大节点

BST 的结构使得寻找最大和最小值节点非常容易。

要找到具有最小值的节点,我们从树的根节点开始遍历,每次到达子树时都访问左节点。为了在树中找到具有最大值的节点,我们做相反的操作:

图片

我们从节点 6 移动到 3,再到 1,以到达具有最小值的节点。同样,我们向下移动 6,8 到节点 10,这是具有最大值的节点。

这种寻找最小和最大节点的方法同样适用于子树。具有根节点 8 的子树中的最小节点是 7。该子树中具有最大值的节点是 10。

返回最小节点的方如下:

    def find_min(self): 
        current = self.root_node 
        while current.left_child: 
            current = current.left_child 

        return current 

while循环继续获取左节点并访问它,直到最后一个左节点指向None。这是一个非常简单的方法。返回最大节点的方法相反,其中current.left_child现在变为current.right_child

在 BST 中找到最小或最大值需要O(h)时间,其中h是树的高度。

插入节点

BST 上的一个操作是需要将数据作为节点插入。在我们的第一次实现中,我们必须自己插入节点,而在这里我们将让树负责存储其数据。

为了使搜索成为可能,节点必须以特定的方式存储。对于每个给定的节点,其左子节点将持有小于其自身值的数据,如前所述。该节点的右子节点将持有大于其父节点值的数据。

我们将通过从数据 5 开始来创建一个新的整数 BST。为此,我们将创建一个节点,其数据属性设置为 5。

现在,为了添加第二个具有值 3 的节点,3 与根节点 5 进行比较:

图片

由于 5 大于 3,它将被放入节点 5 的左子树中。我们的 BST 将如下所示:

树满足 BST 规则,其中所有左子树中的节点都小于其父节点。

要将另一个值为 7 的节点添加到树中,我们从值为 5 的根节点开始并进行比较:

由于 7 大于 5,值为 7 的节点位于这个根的右侧。

当我们想要添加一个等于现有节点的节点时会发生什么?我们将简单地将其作为左节点添加,并在整个结构中保持此规则。

如果一个节点已经在新节点要插入的位置有一个子节点,那么我们必须向下移动树并附加它。

让我们添加另一个值为 1 的节点。从树的根开始,我们在 1 和 5 之间进行比较:

比较显示 1 小于 5,因此我们将注意力转移到 5 的左节点,即值为 3 的节点:

我们将 1 与 3 进行比较,由于 1 小于 3,我们向下移动到节点 3 的下一级并移动到其左侧。但那里没有节点。因此,我们创建一个值为 1 的节点并将其与节点 3 的左指针关联,以获得以下结构:

到目前为止,我们只处理包含整数或数字的节点。对于数字,大于和小于的概念是明确定义的。字符串将按字母顺序比较,所以那里也没有大问题。但如果你想在 BST 中存储自己的自定义数据类型,你必须确保你的类支持排序。

现在让我们创建一个函数,使我们能够将数据作为节点添加到 BST 中。我们从函数声明开始:

    def insert(self, data): 

到现在为止,你将习惯于将数据封装在节点中。这样,我们就隐藏了node类,客户端代码只需要处理树:

        node = Node(data) 

首先检查我们将有一个根节点。如果没有,新节点将成为根节点(没有根节点的树是不可能的):

        if self.root_node is None: 
            self.root_node = node 
        else: 

在我们向下移动树的过程中,我们需要跟踪我们正在处理的当前节点以及其父节点。变量current始终用于此目的:

        current = self.root_node 
        parent = None 
        while True: 
            parent = current 

在这里我们必须进行一次比较。如果新节点中持有的数据小于当前节点中持有的数据,那么我们检查当前节点是否有左子节点。如果没有,这就是我们插入新节点的位置。否则,我们继续遍历:

        if node.data < current.data: 
            current = current.left_child 
            if current is None: 
                parent.left_child = node 
                return 

现在我们来处理大于或等于的情况。如果当前节点没有右子节点,那么新节点将被插入为右子节点。否则,我们向下移动并继续寻找插入点:

        else: 
            current = current.right_child 
            if current is None: 
                parent.right_child = node 
                return 

在 BST 中插入节点的时间复杂度为O(h),其中 h 是树的高度。

删除节点

BST 上的另一个重要操作是节点的删除移除。在这个过程中,我们需要处理三种情况。我们想要删除的节点可能有以下几种情况:

  • 没有子节点

  • 一个子节点

  • 两个子节点

第一种情况是最容易处理的。如果即将被删除的节点没有子节点,我们只需将其从其父节点中分离出来:

因为节点 A 没有子节点,我们将简单地将其与其父节点 Z 断开联系。

另一方面,当我们要删除的节点有一个子节点时,该节点的父节点被设置为指向该特定节点的子节点:

为了删除只有一个子节点 5 的节点 6,我们将节点 9 的左指针指向节点 5。父节点和子节点之间的关系必须被保留。这就是为什么我们需要注意子节点是如何连接到其父节点(即即将被删除的节点)的。被删除节点的子节点被存储起来。然后我们将被删除节点的父节点连接到那个子节点。

当我们想要删除的节点有两个子节点时,会出现一个更复杂的情况:

我们不能简单地将节点 9 替换为节点 6 或 13。我们需要做的是找到节点 9 的下一个最大的后裔。这是节点 12。要到达节点 12,我们移动到节点 9 的右节点。然后向左移动以找到最左边的节点。节点 12 被称为节点 9 的中序后继。第二步类似于寻找子树中的最大节点。

我们将节点 9 的值替换为 12,并删除节点 12。在删除节点 12 时,我们最终得到一个更简单的节点删除形式,这之前已经讨论过。节点 12 没有子节点,因此我们相应地应用删除无子节点节点的规则。

我们的node类没有父节点的引用。因此,我们需要使用一个辅助方法来搜索并返回带有父节点的节点。这种方法与search方法类似:

    def get_node_with_parent(self, data): 
        parent = None 
        current = self.root_node 
        if current is None: 
            return (parent, None) 
        while True: 
            if current.data == data: 
                return (parent, current) 
            elif current.data > data: 
                parent = current 
                current = current.left_child 
            else: 
                parent = current 
                current = current.right_child 

        return (parent, current) 

唯一的区别是在我们更新循环中的当前变量之前,我们使用parent = current存储其父节点。实际删除节点的操作方法从以下搜索开始:

    def remove(self, data): 
        parent, node = self.get_node_with_parent(data) 

        if parent is None and node is None: 
            return False 

        # Get children count 
        children_count = 0 

        if node.left_child and node.right_child: 
            children_count = 2 
        elif (node.left_child is None) and (node.right_child is None): 
            children_count = 0 
        else: 
            children_count = 1 

我们通过parent, node = self.get_node_with_parent(data)这一行将父节点和找到的节点分别传递给parentnode。了解我们想要删除的节点有多少个子节点是有帮助的。这就是if语句的目的。

在此之后,我们需要开始处理节点可以删除的各种条件。if语句的第一部分处理节点没有子节点的情况:

        if children_count == 0: 
            if parent: 
                if parent.right_child is node: 
                    parent.right_child = None 
                else: 
                    parent.left_child = None 
            else: 
                self.root_node = None 

if parent:用于处理整个三个节点中只有一个节点的 BST 的情况。

在即将被删除的节点只有一个子节点的情况下,elif部分的if语句执行以下操作:

        elif children_count == 1: 
            next_node = None 
            if node.left_child: 
                next_node = node.left_child 
            else: 
                next_node = node.right_child 

            if parent: 
                if parent.left_child is node: 
                    parent.left_child = next_node 
                else: 
                    parent.right_child = next_node 
            else: 
                self.root_node = next_node 

next_node用于跟踪我们想要删除的节点所指向的单个节点所在的位置。然后我们将parent.left_childparent.right_child连接到next_node

最后,我们处理要删除的节点有两个子节点的情况:

        ... 
        else: 
            parent_of_leftmost_node = node 
            leftmost_node = node.right_child 
            while leftmost_node.left_child: 
                parent_of_leftmost_node = leftmost_node 
                leftmost_node = leftmost_node.left_child 

            node.data = leftmost_node.data 

在寻找中序后继时,我们移动到右节点,其中leftmost_node = node.right_child。只要存在左节点,leftmost_node.left_child将评估为Truewhile循环将运行。当我们到达最左边的节点时,它将要么是一个叶子节点(意味着它将没有子节点)或者有一个右子节点。

我们使用node.data = leftmost_node.data将即将被删除的节点更新为中序后继的值:

    if parent_of_leftmost_node.left_child == leftmost_node: 
       parent_of_leftmost_node.left_child = leftmost_node.right_child 
    else: 
       parent_of_leftmost_node.right_child = leftmost_node.right_child 

前面的语句允许我们正确地将最左节点的父节点与任何子节点关联。观察等号右侧保持不变。这是因为中序后继只能有一个右子节点作为其唯一的子节点。

remove操作的时间复杂度为O(h),其中 h 是树的高度。

树搜索

由于insert方法以特定的方式组织数据,我们将遵循相同的程序来查找数据。在这个实现中,如果找到了数据,我们将简单地返回数据;如果没有找到,则返回None

    def search(self, data): 

我们需要从最顶层开始搜索,即根节点:

        current = self.root_node 
        while True: 

我们可能已经通过了一个叶子节点,在这种情况下,数据在树中不存在,我们将返回None给客户端代码:

            if current is None: 
                return None 

我们可能也找到了数据,在这种情况下,我们将返回它:

            elif current.data is data: 
                return data 

根据 BST 中数据存储的规则,如果我们正在搜索的数据小于当前节点的数据,我们需要沿着树向左下:

            elif current.data > data: 
                current = current.left_child 

现在我们只剩下一个选择:我们要找的数据大于当前节点中的数据,这意味着我们沿着树向右下:

            else: 
                current = current.right_child 

最后,我们可以编写一些客户端代码来测试 BST 的工作方式。我们创建一个树,并在 1 到 10 之间插入几个数字。然后我们搜索该范围内的所有数字。存在于树中的数字将被打印出来:

    tree = Tree() 
    tree.insert(5) 
    tree.insert(2) 
    tree.insert(7) 
    tree.insert(9) 
    tree.insert(1) 

    for i in range(1, 10): 
        found = tree.search(i) 
        print("{}: {}".format(i, found)) 

树遍历

在树中访问所有节点可以是深度优先或广度优先。这些遍历模式不仅限于二叉搜索树,也适用于一般树。

深度优先遍历

在这种遍历模式中,我们在回溯之前会沿着一个分支(或边)走到尽头,然后向上继续遍历。我们将使用递归方法进行遍历。深度优先遍历有三种形式,即中序先序后序

按序遍历和中缀表示法

我们大多数人可能习惯于这种表示算术表达式的方式,因为这是我们通常在学校里被教授的方式。运算符被插入(中缀)在操作数之间,如3 + 4。必要时,可以使用括号来构建更复杂的表达式:(4 + 5)*(5 - 3)。

在这种遍历模式中,你会访问左子树,父节点,最后是右子树。

返回树中节点中序列表的递归函数如下:

    def inorder(self, root_node): 
        current = root_node 
        if current is None: 
            return 
        self.inorder(current.left_child) 
        print(current.data) 
        self.inorder(current.right_child) 

我们通过打印节点并使用current.left_childcurrent.right_child进行两次递归调用来访问节点。

先序遍历和前缀表示法

前缀表示法通常被称为波兰表示法。在这里,操作符在其操作数之前,例如+ 3 4。由于没有优先级的不确定性,不需要括号:* + 4 5 - 5 3

要以先序模式遍历树,你会按照顺序访问节点、左子树和右子树节点。

前缀表示法对 LISP 程序员来说很熟悉。

这种遍历的递归函数如下:

    def preorder(self, root_node): 
        current = root_node 
        if current is None: 
            return 
        print(current.data) 
        self.preorder(current.left_child) 
        self.preorder(current.right_child) 

注意递归调用的顺序。

后序遍历和后缀表示法。

后缀或逆波兰表示法RPN)将操作符放在其操作数之后,例如3 4 +。与波兰表示法一样,操作符的优先级永远不会产生混淆,因此不需要括号:4 5 + 5 3 - *

在这种遍历模式中,你会先访问左子树,然后是右子树,最后是根节点。

后序遍历的方法如下:

    def postorder(self, root_node): 
        current = root_node 
        if current is None: 
            return 
        self.postorder(current.left_child) 
        self.postorder(current.right_child) 

        print(current.data) 

广度优先遍历

这种遍历从树的根节点开始,从树的某一层访问节点到另一层:

图片

第 1 层的节点是节点 4。我们通过打印其值来访问这个节点。接下来,我们移动到第 2 层并访问该层的节点,即节点 2 和 8。在最后一层,第 3 层,我们访问节点 1, 3, 5 和 10。

这种遍历的完整输出是 4, 2, 8, 1, 3, 5 和 10。

这种遍历模式是通过使用队列数据结构实现的。从根节点开始,我们将其推入队列。队列前面的节点被访问(出队)并打印出来以供以后使用。左节点被添加到队列中,然后是右节点。由于队列不为空,我们重复这个过程。

算法的预演将把根节点 4 入队,然后出队并访问或访问该节点。节点 2 和 8 作为左节点和右节点分别入队。为了访问,节点 2 被出队。它的左节点和右节点,1 和 3,被入队。此时,队列前面的节点是 8。我们出队并访问节点 8,之后将其左节点和右节点入队。因此,这个过程一直持续到队列为空。

算法如下:

    from collections import deque 
    class Tree: 
        def breadth_first_traversal(self): 
            list_of_nodes = [] 
            traversal_queue = deque([self.root_node]) 

我们将根节点入队,并在list_of_nodes列表中保持已访问节点的列表。使用dequeue类来维护队列:

        while len(traversal_queue) > 0: 
            node = traversal_queue.popleft() 
            list_of_nodes.append(node.data) 

            if node.left_child: 
                traversal_queue.append(node.left_child) 

            if node.right_child: 
                traversal_queue.append(node.right_child) 
        return list_of_nodes 

如果traversal_queue中的元素数量大于零,则执行循环体。队列前面的节点被弹出并附加到list_of_nodes列表中。第一个if语句将在存在左节点的情况下将node的左子节点入队。第二个if语句对右子节点做同样的操作。

在最后一条语句中返回list_of_nodes

二叉搜索树的好处

我们现在简要地看看是什么使得二叉搜索树(BST)在需要搜索的数据方面比使用列表更好。让我们假设我们有以下数据集:5, 3, 7, 1, 4, 6, 和 9。使用列表,最坏的情况需要你搜索整个包含七个元素的列表才能找到搜索项:

搜索 9 需要六次跳跃。

使用树,最坏的情况是三次比较:

搜索 9 需要两步。

注意,然而,如果你按顺序 1, 2, 3, 5, 6, 7, 9 将元素插入到树中,那么树可能不会比列表更有效率。我们首先需要平衡树:

因此,不仅使用 BST 很重要,选择一个自平衡树也有助于提高搜索操作。

表达式树

树结构也用于解析算术和布尔表达式。例如,3 + 4的表达式树如下所示:

对于一个稍微复杂一些的表达式,(4 + 5) * (5-3),我们会得到以下结果:

解析逆波兰表达式

现在我们将构建一个后缀表示法写成的表达式的树。然后我们将计算结果。我们将使用一个简单的树实现。为了使其尽可能简单,因为我们将通过合并较小的树来增长树,我们只需要一个树节点实现:

    class TreeNode: 
        def __init__(self, data=None): 
            self.data = data 
            self.right = None 
            self.left = None 

为了构建树,我们将求助于栈。你很快就会看到原因。但在此期间,让我们先创建一个算术表达式并设置我们的栈:

        expr = "4 5 + 5 3 - *".split() 
        stack = Stack() 

由于 Python 是一种努力拥有合理默认值的语言,其split()方法默认按空白字符分割。(如果你想想,这也可能是你期望的。)结果是 expr 将是一个包含值 4, 5, +, 5, 3, - 和 * 的列表。

expr 列表中的每个元素都将是一个运算符或操作数。如果我们得到一个操作数,那么我们将其嵌入到一个树节点中并将其推入栈中。另一方面,如果我们得到一个运算符,那么我们将运算符嵌入到一个树节点中,并将其两个操作数弹出并放入节点的左右子节点中。在这里,我们必须注意确保第一次弹出的元素进入右子节点,否则我们将在减法和除法中遇到问题。

这是构建树的代码:

    for term in expr: 
        if term in "+-*/": 
            node = TreeNode(term) 
            node.right = stack.pop() 
            node.left = stack.pop() 
        else: 
            node = TreeNode(int(term)) 
        stack.push(node) 

注意,在操作数的情况下,我们执行从字符串到整数的转换。如果你想支持浮点操作数,可以使用float()

在这个操作结束时,我们应该在栈中有一个单独的元素,它包含了完整的树。

我们现在可能想要能够评估表达式。我们构建以下小函数来帮助我们:

    def calc(node): 
        if node.data is "+": 
            return calc(node.left) + calc(node.right) 
        elif node.data is "-": 
            return calc(node.left) - calc(node.right) 
        elif node.data is "*": 
            return calc(node.left) * calc(node.right) 
        elif node.data is "/": 
            return calc(node.left) / calc(node.right) 
        else: 
            return node.data 

这个函数非常简单。我们传入一个节点。如果节点包含一个操作数,那么我们只需返回该值。然而,如果我们得到一个运算符,那么我们就执行该运算符所表示的操作,在节点的两个子节点上。然而,由于一个或多个子节点也可能包含运算符或操作数,我们在两个子节点上递归调用 calc() 函数(记住,每个节点的所有子节点也都是节点)。

现在我们只需要从栈中弹出根节点,并将其传递到 calc() 函数中,我们应该得到计算结果:

    root = stack.pop() 
    result = calc(root) 
    print(result) 

运行此程序应该得到结果 18,这是 (4 + 5) * (5 - 3) 的结果。

平衡树

之前我们提到,如果节点按照顺序插入到树中,那么树的行为或多或少就像一个列表,也就是说,每个节点恰好有一个子节点。我们通常希望尽可能降低树的高度,通过填充树的每一行来实现。这个过程被称为平衡树。

有许多种自平衡树,如红黑树、AA 树和替罪羊树。这些树在每次修改树的操作(如插入或删除)期间都会平衡树。

也有外部算法可以平衡树。这些算法的好处是,你不需要在每次操作时都平衡树,而可以留到你需要的时候再进行平衡。

在这一点上,我们将简要介绍堆数据结构。堆是树的一种特殊化,其中节点以特定的方式排序。堆分为最大堆和最小堆。在最大堆中,每个父节点必须始终大于或等于其子节点。因此,根节点必须是树中的最大值。最小堆则相反。每个父节点必须小于或等于其两个子节点。因此,根节点持有最小值。

堆被用于许多不同的事情。一方面,它们用于实现优先队列。还有一个非常高效的排序算法,称为堆排序,它使用堆。我们将在后续章节中深入研究这些内容。

概述

在本章中,我们探讨了树结构及其一些示例用法。我们特别研究了二叉树,它是树的一个子类型,其中每个节点最多有两个子节点。

我们探讨了如何将二叉树用作带有 BST 的可搜索数据结构。我们看到,在大多数情况下,在 BST 中查找数据比在链表中更快,尽管如果数据是顺序插入的,情况并非如此,除非当然树是平衡的。

宽度和深度优先搜索遍历模式也使用队列递归实现。

我们还探讨了如何使用二叉树来表示算术或布尔表达式。我们构建了一个表达式树来表示算术表达式。我们展示了如何使用栈来解析逆波兰表示法(RPN)书写的表达式,构建表达式树,并最终遍历它以获取算术表达式的结果。

最后,我们提到了堆,这是树结构的一种特殊化。我们试图至少在本章中为堆奠定理论基础,以便我们可以在接下来的章节中为不同的目的实现堆。

第七章:哈希和符号表

我们之前已经讨论过列表,其中项目按顺序存储并按索引号访问。索引号对计算机来说工作得很好。它们是整数,因此它们运行速度快且易于操作。然而,它们对我们来说并不总是那么有效。例如,如果我们有一个索引号为 56 的地址簿条目,这个数字并没有告诉我们太多。没有任何东西可以将特定的联系人与 56 号联系起来。它只是碰巧是列表中的下一个可用位置。

在本章中,我们将探讨一个类似的结构:字典。字典使用关键字而不是索引号。因此,如果那个联系人称之為James,我们可能会使用关键字James来定位联系人。也就是说,我们不是通过调用contacts [56]来访问联系人,而是使用contacts ["james"]

字典通常使用哈希表构建。正如其名所示,哈希表依赖于一个称为哈希的概念。这就是我们将开始讨论的地方。

在本章中,我们将讨论以下主题:

  • 哈希

  • 哈希表

  • 不同元素的函数

哈希

哈希是将任意大小的数据转换为固定大小数据的概念。更具体地说,我们将使用它将字符串(或可能的其他数据类型)转换为整数。这可能听起来比实际情况要复杂,让我们来看一个例子。我们想要对表达式hello world进行哈希,也就是说,我们想要得到一个数值,我们可以说是代表这个字符串的。

通过使用ord()函数,我们可以获取任何字符的序数值。例如,ord('f')函数给出 102。要获取整个字符串的哈希值,我们只需将字符串中每个字符的序数值相加:

>>> sum(map(ord, 'hello world'))
1116

这没问题。然而,请注意,我们可以改变字符串中字符的顺序并得到相同的哈希值:

>>> sum(map(ord, 'world hello'))
1116

并且字符串gello xorld的字符序数值之和也相同,因为g的序数值比h少一,而x的序数值比w多一,因此:

>>> sum(map(ord, 'gello xorld'))
1116

完美哈希函数

完美哈希函数是指每个字符串(因为我们现在限制讨论范围在字符串上)都保证是唯一的。在实践中,哈希函数通常需要非常快,因此尝试创建一个为每个字符串提供唯一哈希值的函数通常是不可能的。相反,我们接受有时会出现冲突(两个或更多字符串具有相同的哈希值)的事实,并且当这种情况发生时,我们会想出一个解决冲突的策略。

在此同时,我们至少可以想出一个避免一些冲突的方法。例如,我们可以添加一个乘数,使得每个字符的哈希值成为乘数值乘以字符的序数值。乘数随着我们遍历字符串而增加。这在上面的函数中显示:

    def myhash(s): 
        mult = 1 
        hv = 0 
        for ch in s: 
            hv += mult * ord(ch) 
            mult += 1 
        return hv 

我们可以在我们之前使用的字符串上测试这个函数:

    for item in ('hello world', 'world hello', 'gello xorld'): 
        print("{}: {}".format(item, myhash(item))) 

运行程序,我们得到以下输出:

% python hashtest.py

hello world: 6736
world hello: 6616
gello xorld: 6742

注意,最后一行是第 2 行和第 3 行值的乘积的结果,例如 104 x 1 等于 104。

这次我们得到了不同的哈希值。当然,这并不意味着我们有一个完美的哈希。让我们尝试字符串adga

% python hashtest.py 
ad: 297
ga: 297

我们仍然得到了两个不同字符串相同的哈希值。正如我们之前所说的,这并不一定是个问题,但我们需要制定一个解决冲突的策略。我们将很快探讨这个问题,但首先我们将研究哈希表的一个实现。

哈希表

哈希表是一种列表形式,其中元素是通过关键字而不是索引号来访问的。至少,这是客户端代码将看到的样子。内部,它将使用我们修改过的哈希函数的略微不同版本来找到元素应该插入的索引位置。这使我们能够快速查找,因为我们使用的是与键的哈希值相对应的索引号。

我们首先创建一个类来存储哈希表项。这些项需要一个键和一个值,因为我们的哈希表是一个键值存储:

    class HashItem: 
        def __init__(self, key, value): 
            self.key = key 
            self.value = value 

这为我们提供了一个非常简单的方式来存储项。接下来,我们开始着手实现哈希表类本身。像往常一样,我们从构造函数开始:

    class HashTable: 
        def __init__(self): 
            self.size = 256 
            self.slots = [None for i in range(self.size)] 
            self.count = 0 

哈希表使用标准的 Python 列表来存储其元素。我们同样可以使用我们之前开发的链表,但现在的重点是理解哈希表,所以我们将使用我们手头可用的工具。

我们一开始将哈希表的大小设置为 256 个元素。稍后,我们将探讨如何随着表的填充增长表的大小。我们现在初始化一个包含 256 个元素的列表。这些元素通常被称为槽或桶。最后,我们添加一个计数器来记录我们拥有的实际哈希表元素数量:

重要的是要注意表的大小和计数的区别。表的大小指的是表中的总槽位数(使用或未使用)。另一方面,表的计数仅指已填满的槽位数,或者换句话说,我们添加到表中的实际键值对的数量。

现在,我们将添加我们的散列函数到表中。它将类似于我们在散列函数部分所发展的,但有一点不同:我们需要确保我们的散列函数返回一个介于 1 和 256(表的大小)之间的值。这样做的一个好方法是返回散列除以表大小的余数,因为余数始终是一个介于 0 和 255 之间的整数值。

由于散列函数仅打算由类内部使用,我们在名称前加上下划线(_)来表示这一点。这是 Python 表示某物打算用于内部使用的正常约定:

    def _hash(self, key): 
        mult = 1 
        hv = 0 
        for ch in key: 
            hv += mult * ord(ch) 
            mult += 1 
        return hv % self.size 

目前,我们将假设键是字符串。我们将在稍后讨论如何使用非字符串键。现在,请记住,_hash()函数将生成字符串的哈希值。

添加元素

我们使用put()函数向哈希表中添加元素,并使用get()函数检索。首先,我们将查看put()函数的实现。我们首先将键和值嵌入到HashItem类中,并计算键的哈希值:

    def put(self, key, value): 
        item = HashItem(key, value) 
        h = self._hash(key) 

现在我们需要找到一个空槽位。我们从与键的哈希值相对应的槽位开始。如果那个槽位是空的,我们就将我们的项目插入那里。

然而,如果槽位不为空,并且项目的键与我们的当前键不同,那么我们就遇到了冲突。这就是我们需要想出处理冲突的方法的地方。我们将通过将前一个哈希值加一来实现这一点,并得到这个值除以哈希表大小的余数。这是一种解决冲突的线性方法,相当简单:

图片

    while self.slots[h] is not None: 
        if self.slots[h].key is key: 
            break 
        h = (h + 1) % self.size 

我们找到了插入点。如果这是一个新元素(即,它之前包含None),那么我们增加计数器一。最后,我们将项目插入到所需的列表位置:

    if self.slots[h] is None: 
        self.count += 1 
    self.slots[h] = item  

获取元素

get()方法的实现应该返回与键相对应的值。我们还必须决定在键在表中不存在时应该做什么。我们首先计算键的哈希值:

    def get(self, key): 
        h = self._hash(key)

现在,我们简单地从列表中查找具有我们正在搜索的键的元素,从具有传递的键的哈希值的元素开始。如果当前元素不是正确的,那么,就像在put()方法中一样,我们将前一个哈希值加一,并得到这个值除以列表大小的余数。这个值成为我们的新索引。如果我们找到一个包含None的元素,我们就停止查找。如果我们找到我们的键,我们就返回值:

图片

        while self.slots[h] is not None: 
            if self.slots[h].key is key: 
                return self.slots[h].value 
            h = (h+ 1) % self.size 

最后,我们决定如果键在表中未找到时应该做什么。在这里,我们将选择返回None。另一个好的选择可能是抛出一个异常:

        return None 

测试哈希表

为了测试我们的哈希表,我们创建了一个HashTable,在其中放入一些元素,然后尝试检索这些元素。我们还将尝试get()一个不存在的键。还记得我们通过哈希函数返回相同哈希值的两个字符串 ad 和 ga 吗?为了确保,我们将它们也加入其中,只是为了看看碰撞是否得到了适当的解决:

    ht = HashTable() 
    ht.put("good", "eggs") 
    ht.put("better", "ham") 
    ht.put("best", "spam") 
    ht.put("ad", "do not") 
    ht.put("ga", "collide") 

    for key in ("good", "better", "best", "worst", "ad", "ga"): 
        v = ht.get(key) 
        print(v) 

运行此代码返回以下结果:

% python hashtable.py 
eggs
ham
spam
None
do not
collide  

如你所见,查找键 worst 返回None,因为该键不存在。键adga也返回它们对应的值,这表明它们之间的碰撞得到了处理。

使用哈希表中的[]

使用put()get()方法看起来不太好。我们希望将我们的哈希表当作列表来处理,也就是说,我们希望能够使用ht["good"]而不是ht.get("good")。这可以通过特殊方法__setitem__()__getitem__()轻松实现:

    def __setitem__(self, key, value): 
        self.put(key, value) 

    def __getitem__(self, key): 
        return self.get(key) 

我们的可测试代码现在可以像这样:

    ht = HashTable() 
    ht["good"] = "eggs" 
    ht["better"] = "ham" 
    ht["best"] = "spam" 
    ht["ad"] = "do not" 
    ht["ga"] = "collide" 

    for key in ("good", "better", "best", "worst", "ad", "ga"): 
        v = ht[key] 
        print(v) 

    print("The number of elements is: {}".format(ht.count)) 

注意,我们还打印了哈希表中的元素数量。这对于我们接下来的讨论很有用。

非字符串键

在大多数情况下,只使用字符串作为键更有意义。然而,如果需要,你可以使用任何其他 Python 类型。如果你创建了自己的类,并希望将其用作键,你可能需要覆盖该类的特殊__hash__()函数,以便获得可靠的哈希值。

注意,你仍然需要计算哈希值和哈希表大小的模(%)以获得槽位。这个计算应该在哈希表中发生,而不是在键类中,因为表知道它自己的大小(键类不应该了解它所属的表)。

扩展哈希表

在我们的例子中,哈希表的大小被设置为 256。显然,当我们向列表中添加元素时,我们开始填满空槽位。在某个时刻,所有的槽位都将被填满,表将满。为了避免这种情况,我们可以在表满时扩展表。

要做到这一点,我们比较大小和计数。还记得size保存了槽位的总数,而count保存了包含元素的槽位数量吗?好吧,如果count等于size,那么我们就填满了表。

哈希表的负载因子给我们提供了一个关于可用槽位中有多少部分被使用的指示。它定义如下:

当负载因子接近 1 时,我们需要扩展表。实际上,我们应该在它到达那里之前就做这件事,以避免查找变得太慢。0.75 可能是一个增长表的好值。

下一个问题是如何确定表的增长量。一种策略是简单地加倍表的大小。

开放寻址

我们在示例中使用的冲突解决机制,线性探测,是开放寻址策略的一个例子。线性探测非常简单,因为我们使用固定的探测间隔。还有其他开放寻址策略,但它们都共享一个想法,即有一个槽位数组。当我们想要插入一个键时,我们会检查槽位是否已经有一个项目。如果有,我们会寻找下一个可用的槽位。

如果我们有一个包含 256 个槽位的哈希表,那么 256 是该哈希表中的最大元素数。此外,随着负载因子的增加,找到新元素插入点所需的时间会更长。

由于这些限制,我们可能更喜欢使用不同的策略来解决冲突,例如链接。

链接

链接是一种解决冲突和避免哈希表元素数量限制的策略。在链接中,哈希表的槽位被初始化为空列表:

当一个元素被插入时,它将被添加到与该元素哈希值相对应的列表中。也就是说,如果你有两个元素,它们的哈希值都是 1167,这两个元素都将被添加到哈希表槽位 1167 中存在的列表中:

上述图示显示了一个具有哈希值 51 的条目列表。

链接通过允许多个元素具有相同的哈希值来避免冲突。它还避免了随着负载因子增加而插入的问题,因为我们不需要寻找槽位。此外,哈希表可以存储比可用槽位更多的值,因为每个槽位可以包含一个可以增长的列表。

当然,如果一个特定的槽位包含很多项,搜索它们可能会变得非常慢,因为我们不得不在列表中进行线性搜索,直到找到具有我们想要的关键字的元素。这可能会减慢检索速度,这并不好,因为哈希表旨在高效:

上述图示演示了通过列表项进行线性搜索,直到找到匹配项。

我们可以在表槽位中使用另一种允许快速搜索的结构。我们已经研究了二叉搜索树(BST)。我们可以在每个槽位中简单地放置一个(最初为空)BST:

槽位 51 包含一个 BST,我们搜索该键。

但我们仍然可能遇到一个问题:根据项目添加到 BST 的顺序,我们可能会得到一个与列表一样低效的搜索树。也就是说,树中的每个节点恰好有一个子节点。为了避免这种情况,我们需要确保我们的 BST 是自平衡的。

符号表

符号表被编译器和解释器用来跟踪已声明的符号及其信息。由于在表中高效检索符号很重要,符号表通常使用哈希表构建。

让我们来看一个例子。假设我们有以下 Python 代码:

    name = "Joe" 
    age = 27 

在这里,我们有两个符号,name 和 age。它们属于一个命名空间,这可能是一个__main__,也可能是你放置它的模块的名称。每个符号都有一个值;name 的值是Joe,age 的值是27。符号表允许编译器或解释器查找这些值。符号 name 和 age 成为我们哈希表中的键。与之相关的所有其他信息,如值,都成为符号表条目值的一部分。

不仅变量是符号,函数和类也是。它们都将被添加到我们的符号表中,以便当任何一个需要被访问时,它们都可以从符号表中访问:

图片

在 Python 中,每个被加载的模块都有自己的符号表。符号表被赋予该模块的名称。这样,模块充当命名空间。只要它们存在于不同的符号表中,我们可以有多个名为 age 的符号。要访问任何一个,我们通过适当的符号表来访问:

图片

摘要

在本章中,我们研究了哈希表。我们研究了如何编写哈希函数将字符串数据转换为整数数据。然后我们研究了如何使用哈希键快速有效地查找与键相对应的值。

我们也注意到了哈希函数并不完美,有时几个字符串可能会得到相同的哈希值。这促使我们去研究冲突解决策略。

我们研究了哈希表的增长以及如何观察表的负载因子,以确定何时扩展哈希。

在本章的最后部分,我们研究了符号表,这些符号表通常使用哈希表构建。符号表允许编译器或解释器查找已定义的符号(变量、函数、类等),并检索有关它的所有信息。

在下一章中,我们将讨论图和其他算法。

第八章:图与其他算法

在本章中,我们将讨论图。这是一个来自数学分支图论的概念。

图被用来解决许多计算问题。它们比我们之前看到的其他数据结构结构简单得多,并且像遍历这样的操作可能更加不寻常,正如我们将看到的。

到本章结束时,你应该能够做到以下几件事情:

  • 理解图是什么

  • 了解图的类型及其组成部分

  • 了解如何表示图并遍历它

  • 获得对优先队列的基本理解

  • 能够实现优先队列

  • 能够确定列表中的第 i 个最小元素

图是一组顶点和边,它们在顶点之间形成连接。在更正式的方法中,图 G 是一个顶点集合 V 和边集合 E 的有序对,用形式数学符号表示为G = (V, E)

这里给出一个图的例子:

图片

让我们现在来了解一下图的定义:

  • 节点或顶点:一个点,通常在图中用点表示。顶点或节点是 A、B、C、D 和 E。

  • :这是两个顶点之间的连接。连接 A 和 B 的线条是一个边的例子。

  • :当一个节点的边指向自身时,该边形成一个环。

  • 顶点的度:这是与给定顶点相关的顶点的数量。顶点 B 的度是4

  • 邻接:这指的是节点与其邻居之间的连接。节点 C 与节点 A 相邻,因为它们之间有一条边。

  • 路径:一个顶点的序列,其中每个相邻对都通过一条边连接。

有向和无向图

根据它们是无向的还是有向的,可以将图进行分类。无向图只是将边表示为节点之间的线条。除了节点之间有连接这一事实之外,没有关于节点之间关系的其他信息:

图片

在有向图中,边除了连接节点外,还提供了方向。也就是说,作为线条带有箭头的边将指向边连接的两个节点的方向:

图片

边的箭头决定了方向的流动。在前面的图中,只能从A移动到B,不能从B移动到A

加权图

加权图在边中添加了一些额外的信息。这可以是一个表示某物的数值。比如说,以下图表示从点A到点D的不同方式。你可以直接从AD,或者选择经过BC。每个边都与到达下一个节点所需的时间(分钟)相关联:

图片

也许 AD 的旅程需要你骑自行车(或步行)。BC 可能代表公交车站。在 B,你可能需要换乘不同的公交车。最后,CD 可能是一段短途步行即可到达 D

在这个例子中,ADABCD 代表两条不同的路径。路径简单地说是一系列边,你在两个节点之间通过这些边。沿着这些路径,你会发现 AD 的总旅程需要 40 分钟,而 ABCD 的旅程需要 25 分钟。如果你的唯一关注点是时间,你最好沿着 ABCD 行驶,即使有换乘公交车的额外不便。

边可以是定向的,并且可能包含其他信息,如花费的时间或路径上移动关联的任何其他值,这表明了一些有趣的事情。在我们之前使用过的数据结构中,我们画在节点之间的“线”仅仅是连接器。即使它们有从节点指向另一个节点的箭头,这也很容易在节点类中使用 nextpreviousparentchild 来表示。

在图中,将边视为对象与将节点视为对象一样有意义。就像节点一样,边可以包含必要的信息,以便遵循特定的路径。

图的表示

图可以以两种主要形式表示。一种方式是使用邻接矩阵,另一种方式是使用邻接表。

我们将使用以下图形来开发图的两种表示类型:

邻接表

可以使用一个简单的列表来表示图。列表的索引将代表图中的节点或顶点。在每个索引处,可以存储该顶点的相邻节点:

方框中的数字代表顶点。索引 0 代表顶点 A,其相邻节点为 BC

使用列表进行表示非常受限,因为我们缺乏直接使用顶点标签的能力。因此,字典更适合。为了在图中表示图,我们可以使用以下语句:

    graph = dict() 
    graph['A'] = ['B', 'C'] 
    graph['B'] = ['E','A'] 
    graph['C'] = ['A', 'B', 'E','F'] 
    graph['E'] = ['B', 'C'] 
    graph['F'] = ['C'] 

现在,我们可以轻松地确定顶点 A 的相邻顶点是 BC。顶点 F 的唯一邻接顶点是 C

邻接矩阵

另一种表示图的方法是使用邻接矩阵。矩阵是一个二维数组。这里的想法是根据两个顶点是否通过边连接,用 1 或 0 来表示单元格。

给定一个邻接表,应该可以创建一个邻接矩阵。需要一个排序后的图键列表:

    matrix_elements = sorted(graph.keys()) 
    cols = rows = len(matrix_elements) 

键的长度用于提供矩阵的维度,这些维度存储在 colsrows 中。colsrows 中的这些值是相等的:

    adjacency_matrix = [[0 for x in range(rows)] for y in range(cols)] 
    edges_list = [] 

然后我们设置一个cols乘以rows的数组,用零填充它。edges_list变量将存储构成图中边的元组。例如,节点 A 和 B 之间的边将被存储为(A, B)。

使用嵌套for循环填充多维数组:

    for key in matrix_elements: 
        for neighbor in graph[key]: 
            edges_list.append((key,neighbor)) 

通过graph[key]获取顶点的邻居。然后,使用neighbor与键结合来创建存储在edges_list中的元组。

迭代输出如下:

>>> [('A', 'B'), ('A', 'C'), ('B', 'E'), ('B', 'A'), ('C', 'A'), 
     ('C', 'B'), ('C', 'E'), ('C', 'F'), ('E', 'B'), ('E', 'C'), 
     ('F', 'C')]

现在需要做的是通过使用 1 标记边的存在来填充我们的多维数组,使用以下行adjacency_matrix[index_of_first_vertex][index_of_second_vertex] = 1

    for edge in edges_list: 
        index_of_first_vertex = matrix_elements.index(edge[0]) 
        index_of_second_vertex = matrix_elements.index(edge[1]) 
        adjacecy_matrix[index_of_first_vertex][index_of_second_vertex] = 1 

matrix_elements数组从 A 到 E 的行和列开始,索引从 0 到 5。for循环遍历我们的元组列表,并使用index方法获取要存储边的相应索引。

生成的邻接矩阵如下所示:

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

在第 1 列和第 1 行,那里的 0 表示 A 和 A 之间没有边。在第 2 列和第 3 行,C 和 B 之间存在边。

图遍历

由于图不一定有有序结构,遍历图可能更复杂。遍历通常涉及跟踪哪些节点或顶点已经被访问,哪些还没有。一种常见策略是沿着路径走,直到达到死胡同,然后返回直到有替代路径的点。我们也可以迭代地从节点移动到另一个节点,以遍历整个图或其部分。在下一节中,我们将讨论图遍历的广度和深度优先搜索算法。

广度优先搜索

广度优先搜索算法从一个节点开始,选择该节点或顶点作为其根节点,然后访问相邻的节点,之后它探索图的下一级的邻居节点。

考虑以下图作为示例:

图片

该图是一个无向图的示例。我们继续使用这种类型的图来帮助解释,而不太冗长。

该图的邻接表如下所示:

    graph = dict() 
    graph['A'] = ['B', 'G', 'D'] 
    graph['B'] = ['A', 'F', 'E'] 
    graph['C'] = ['F', 'H'] 
    graph['D'] = ['F', 'A'] 
    graph['E'] = ['B', 'G'] 
    graph['F'] = ['B', 'D', 'C'] 
    graph['G'] = ['A', 'E'] 
    graph['H'] = ['C'] 

在尝试广度优先遍历此图时,我们将使用队列。算法创建一个列表来存储在遍历过程中已访问的节点。我们将从节点 A 开始我们的遍历。

节点 A 被排队并添加到已访问节点的列表中。之后,我们使用while循环来实现图的遍历。在while循环中,节点 A 被出队。它的未访问相邻节点 B、G 和 D 按字母顺序排序并排队。现在队列将包含节点 B、D 和 G。这些节点也被添加到已访问节点的列表中。此时,我们开始while循环的另一个迭代,因为队列不为空,这也意味着我们实际上还没有完成遍历。

节点 B 被出队。在其相邻节点 A、F 和 E 中,节点 A 已经被访问。因此,我们只按字母顺序入队节点 E 和 F。节点 E 和 F 然后添加到已访问节点列表中。

在这一点上,我们的队列包含以下节点:D、G、E 和 F。已访问节点列表包含 A、B、D、G、E、F。

节点 D 被出队,但所有相邻节点都已访问,所以我们只需将其出队。队列前面的下一个节点是 G。我们出队节点 G,但我们还发现所有相邻节点都已访问,因为它们在已访问节点列表中。节点 G 也被出队。我们也出队节点 E,因为所有节点都已访问。现在队列中只剩节点 F。

节点 F 被出队,我们意识到在其相邻节点 B、D 和 C 中,只有节点 C 尚未访问。然后我们将节点 C 入队并添加到已访问节点列表中。节点 C 被出队。节点 C 有相邻节点 F 和 H,但 F 已经被访问,留下节点 H。节点 H 被入队并添加到已访问节点列表中。

最后,while 循环的最后一次迭代将导致节点 H 被出队。它的唯一相邻节点 C 已经被访问。一旦队列完全为空,循环就会中断。

图中遍历的输出为 A、B、D、G、E、F、C、H。

广度优先搜索的代码如下:

    from collections import deque 

    def breadth_first_search(graph, root): 
        visited_vertices = list() 
        graph_queue = deque([root]) 
        visited_vertices.append(root) 
        node = root 

        while len(graph_queue) > 0: 
            node = graph_queue.popleft() 
            adj_nodes = graph[node] 

            remaining_elements = 
                set(adj_nodes).difference(set(visited_vertices)) 
            if len(remaining_elements) > 0: 
                for elem in sorted(remaining_elements): 
                    visited_vertices.append(elem) 
                    graph_queue.append(elem) 

        return visited_vertices 

当我们想要找出是否有一组节点在已访问节点列表中时,我们使用语句 remaining_elements = set(adj_nodes).difference(set(visited_vertices))。这使用集合对象的差集方法来找出在 adj_nodes 中但不在 visited_vertices 中的节点。

在最坏的情况下,每个顶点或节点和边都将被遍历,因此算法的时间复杂度为 O(|V| + |E|),其中 |V| 是顶点或节点的数量,而 |E| 是图中边的数量。

深度优先搜索

如其名所示,此算法在遍历图的宽度之前先遍历任何特定路径的深度。因此,先访问子节点,然后访问兄弟节点。它适用于有限图,并需要使用栈来维护算法的状态:

    def depth_first_search(graph, root): 
        visited_vertices = list() 
        graph_stack = list() 

        graph_stack.append(root) 
        node = root 

算法首先创建一个列表来存储已访问的节点。graph_stack 栈变量用于辅助遍历过程。为了连续性,我们使用常规 Python 列表作为栈。

起始节点,称为 root,与图的邻接矩阵 graph 一起传递。root 被压入栈中。node = root 保存栈中的第一个节点:

        while len(graph_stack) > 0: 

            if node not in visited_vertices: 
                visited_vertices.append(node) 

            adj_nodes = graph[node] 

            if set(adj_nodes).issubset(set(visited_vertices)): 
                graph_stack.pop() 
            if len(graph_stack) > 0: 
                node = graph_stack[-1] 
                continue 
            else: 
                remaining_elements = 
                set(adj_nodes).difference(set(visited_vertices)) 

            first_adj_node = sorted(remaining_elements)[0] 
            graph_stack.append(first_adj_node) 
            node = first_adj_node 
                return visited_vertices 

当栈不为空时,将执行while循环的主体。如果node不在已访问节点的列表中,我们将它添加进去。通过adj_nodes = graph[node]收集node的所有相邻节点。如果所有相邻节点都已访问,我们就从栈中弹出该节点,并将node设置为graph_stack[-1]graph_stack[-1]是栈顶的节点。continue语句将跳回到while循环测试条件的开始。

如果不是所有相邻节点都已访问,则通过使用语句remaining_elements = set(adj_nodes).difference(set(visited_vertices))找到adj_nodesvisited_vertices之间的差异,以获取尚未访问的节点。

sorted(remaining_elements)中的第一个项目分配给first_adj_node,并将其推入栈中。然后我们将栈顶指向该节点。

while循环退出时,我们将返回visited_vertices

实际运行算法将非常有用。考虑以下图:

图片

这样图的邻接表如下所示:

    graph = dict() 
    graph['A'] = ['B', 'S'] 
    graph['B'] = ['A'] 
    graph['S'] = ['A','G','C'] 
    graph['D'] = ['C'] 
    graph['G'] = ['S','F','H'] 
    graph['H'] = ['G','E'] 
    graph['E'] = ['C','H'] 
    graph['F'] = ['C','G'] 
    graph['C'] = ['D','S','E','F'] 

节点 A 被选为起始节点。节点 A 被推入栈中,并添加到visisted_vertices列表中。这样做时,我们将其标记为已访问。graph_stack栈使用简单的 Python 列表实现。我们的栈现在只有一个元素 A。我们检查节点 A 的相邻节点 B 和 S。为了测试 A 的所有相邻节点是否都已访问,我们使用 if 语句:

    if set(adj_nodes).issubset(set(visited_vertices)): 
        graph_stack.pop() 
        if len(graph_stack) > 0: 
            node = graph_stack[-1] 
        continue 

如果所有节点都已访问,我们就弹出栈顶。如果栈graph_stack不为空,我们将栈顶的节点赋值给node,并开始while循环主体的另一个执行。如果set(adj_nodes).issubset(set(visited_vertices))语句评估为True,则表示adj_nodes中的所有节点都是visited_vertices的子集。如果 if 语句失败,则意味着还有一些节点尚未访问。我们通过remaining_elements = set(adj_nodes).difference(set(visited_vertices))获取那些节点的列表。

从图中可以看出,节点BS将被存储在remaining_elements中。我们将按字母顺序访问该列表:

    first_adj_node = sorted(remaining_elements)[0] 
    graph_stack.append(first_adj_node) 
    node = first_adj_node 

我们对remaining_elements进行排序,并将第一个节点返回给first_adj_node。这将返回 B。我们将节点 B 推入栈中,通过将其附加到graph_stack。我们通过将其赋值给node来准备节点 B 的访问。

while循环的下一个迭代中,我们将节点 B 添加到visited nodes列表中。我们发现 B 的唯一相邻节点 A 已经被访问。因为 B 的所有相邻节点都已访问,所以我们将其从栈中弹出,留下 A 作为栈上的唯一元素。我们回到节点 A,检查其所有相邻节点是否都已访问。现在节点 A 的唯一未访问节点是 S。我们将 S 推入栈中,并再次开始整个过程。

遍历的输出为 A-B-S-C-D-E-H-G-F。

深度优先搜索在解决迷宫问题、寻找连通分量和寻找图的桥等问题中都有应用。

其他有用的图方法

非常常见的是,你关心的是在两个节点之间找到路径。你也可能想找到节点之间的所有路径。另一种有用的方法是在节点之间找到最短路径。在无权图中,这将是它们之间边数最少的路径。在加权图中,正如你所看到的,这可能涉及到通过一系列边计算总权重。

当然,在另一种情况下,你可能想找到最长或最短路径。

优先队列和堆

优先队列基本上是一种队列,它将始终按优先级顺序返回项目。这种优先级可能是,例如,最低的项目总是首先弹出。尽管它被称为队列,但优先队列通常使用堆来实现,因为这对于此目的非常高效。

考虑到,在一家商店里,顾客排队等待服务,服务只在前面的队列中进行。每位顾客在等待服务的过程中都会花费一些时间。如果队列中顾客的等待时间分别是 4、30、2 和 1,那么平均等待时间变为(4 + 34 + 36 + 37)/4,即27.75。然而,如果我们改变服务的顺序,让等待时间最短的顾客先被服务,那么我们会得到不同的平均等待时间。这样做,我们通过(1 + 3 + 7 + 37)/4来计算新的平均等待时间,现在等于12,这是一个更好的平均等待时间。显然,从最短等待时间开始服务顾客是有益的。通过优先级或其他标准选择下一个项目的方法是创建优先队列的基础。

堆是一种满足堆属性的数据结构。堆属性表明,父节点和子节点之间必须存在某种关系。这个属性必须在整个堆中适用。

在最小堆中,父节点和子节点之间的关系是父节点必须始终小于或等于其子节点。因此,堆中的最小元素必须是根节点。

相反,在最大堆中,父节点大于或等于其子节点或其子节点。由此可知,最大值构成了根节点。

如你所见,堆是树,更具体地说,是二叉树。

虽然我们打算使用二叉树,但实际上我们会使用列表来表示它。这是因为堆将存储一个完整的二叉树。一个完整的二叉树是指每一行在开始填充下一行之前必须完全填满:

图片

为了使使用索引的数学计算更简单,我们将保留列表中的第一个项目(索引 0)为空。之后,我们将树节点从上到下、从左到右放入列表中:

如果你仔细观察,你会注意到你可以非常容易地检索任何节点 n 的子节点。左子节点位于 2n,右子节点位于 2n + 1。这始终是正确的。

我们将探讨最小堆的实现。为了得到最大堆,反转逻辑并不困难:

     class Heap: 
        def __init__(self): 
            self.heap = [0] 
            self.size = 0 

我们用零初始化我们的堆列表来表示虚拟的第一个元素(记住我们这样做是为了使数学计算更简单)。我们还创建了一个变量来保存堆的大小。这样做并非必需,因为我们本可以检查列表的大小,但我们总是需要记住减去一。因此,我们选择保留一个单独的变量。

插入

插入一个项目本身非常简单。我们将新元素添加到列表的末尾(我们理解为树的底部)。然后我们增加堆的大小。

但在每次插入后,如果需要,我们需要将新元素向上浮动。请注意,最小堆中的最低元素需要是根元素。我们首先创建一个名为 float 的辅助方法来处理这个问题。让我们看看它应该如何表现。想象一下,我们有一个以下堆,并想要插入值 2

新元素已经占据了第三行或级别的最后一个槽位。它的索引值是7。现在我们将该值与其父元素进行比较。父元素位于索引 7/2 = 3(整数除法)。该元素持有6,因此我们交换2

我们的新元素已经交换并移动到了索引 3。我们还没有到达堆的顶部(3 / 2 > 0),所以我们继续。我们元素的新的父元素位于索引 3/2 = 1。因此我们比较,并在必要时再次交换:

在最后的交换之后,我们留下的堆看起来如下。注意它如何遵循堆的定义:

下面是实现我们刚刚描述的内容:

    def float(self, k): 

我们将循环,直到我们达到根节点,这样我们就可以将元素向上浮动到它需要到达的高度。由于我们使用整数除法,一旦我们低于 2,循环就会退出:

        while k // 2 > 0: 

比较父元素和子元素。如果父元素大于子元素,则交换这两个值:

        if self.heap[k] < self.heap[k//2]: 
            self.heap[k], self.heap[k//2] = self.heap[k//2], 
            self.heap[k] 

最后,别忘了将树向上移动:

        k //= 2 

此方法确保元素按正确顺序排列。现在我们只需要从我们的 insert 方法中调用此方法:

    def insert(self, item): 
        self.heap.append(item) 
        self.size += 1 
        self.float(self.size) 

注意到在插入中调用了 float() 方法来根据需要重新组织堆。

弹出

就像插入一样,pop() 本身是一个简单的操作。我们移除根节点并将堆的大小减一。然而,一旦根被弹出,我们需要一个新的根节点。

为了尽可能简单,我们只需取列表中的最后一个元素,将其作为新的根。也就是说,我们将其移动到列表的开头。但现在我们可能没有最低的元素在堆的顶部,所以我们需要执行与 float 操作相反的操作:我们让新的根节点按照需要下沉。

正如我们在插入时做的那样,让我们看看整个操作是如何在一个现有的堆上工作的。想象以下堆。我们弹出root元素,使堆暂时没有根:

图片

由于我们不能有一个没有根的堆,我们需要用某样东西来填充这个位置。如果我们选择移动其中一个子节点,我们就必须想出如何重新平衡整个树结构。所以,我们做了一些非常有趣的事情。我们将列表中的最后一个元素向上移动以填充root元素的位置:

图片

现在这个元素显然不是堆中的最低元素。这就是我们开始将其下沉的地方。首先我们需要确定将其下沉的位置。我们比较两个子树,这样最低的元素就会在根节点下沉时浮上来:

图片

右子树显然更小。它的索引是3,这代表了根索引* 2 + 1。我们继续比较我们的新根节点和这个索引处的值:

图片

现在我们的节点已经跳到了索引3。我们需要将其与它的较小子节点进行比较。然而,现在我们只有一个子节点,所以我们不需要担心比较哪个子节点(对于最小堆来说,总是较小的子节点):

图片

这里没有必要交换。由于没有更多的行,我们完成了。再次注意,在sink()操作完成后,我们的堆符合堆的定义。

现在我们可以开始实现了。在我们做sink()方法本身之前,注意我们需要确定比较父节点的子树是哪一个。好吧,让我们把这个选择放在它自己的小方法中,这样代码看起来会简单一些:

    def minindex(self, k): 

我们可能会超出列表的末尾,在这种情况下,我们返回左子树的索引:

        if k * 2 + 1 > self.size: 
            return k * 2 

否则,我们只需简单地返回两个子树中较小的一个的索引:

        elif self.heap[k*2] < self.heap[k*2+1]: 
            return k * 2 
        else: 
            return k * 2 + 1 

现在我们可以创建sink函数:

    def sink(self, k): 

和以前一样,我们将循环,这样我们就可以将元素下沉到所需的程度:

        while k * 2 <= self.size: 

接下来我们需要知道是和左子树还是右子树进行比较。这就是我们使用minindex()函数的地方:

            mi = self.minindex(k) 

正如我们在float()方法中所做的那样,我们比较父节点和子节点,以确定是否需要交换:

            if self.heap[k] > self.heap[mi]: 
                self.heap[k], self.heap[mi] = self.heap[mi], 
                self.heap[k] 

我们还需要确保我们沿着树向下移动,这样我们才不会陷入循环:

            k = mi 

现在唯一剩下的事情就是实现pop()本身。这非常直接,因为脏活是由sink()方法来干的:

    def pop(self): 
        item = self.heap[1] 
        self.heap[1] = self.heap[self.size] 
        self.size -= 1 
        self.heap.pop() 
        self.sink(1) 
        return item 

测试堆

现在我们只需要一些代码来测试堆。我们首先创建我们的堆并插入一些数据:

    h = Heap() 
    for i in (4, 8, 7, 2, 9, 10, 5, 1, 3, 6): 
        h.insert(i) 

我们可以打印堆列表,只是为了检查元素是如何排序的。如果你将其重新绘制为树结构,你应该注意到它符合堆所需的所有属性:

    print(h.heap) 

现在,我们将逐个弹出项目。注意项目是如何以排序顺序(从低到高)弹出的。同时注意堆列表在每次弹出后的变化。拿出笔和纸,在每次弹出后重新绘制这个列表作为树,以完全理解sink()方法是如何工作的:

    for i in range(10): 
        n = h.pop() 
        print(n) 
        print(h.heap) 

在排序算法章节中,我们将重新组织堆排序算法的代码。

一旦你正确实现了最小堆并理解了它是如何工作的,实现最大堆应该是一个简单的任务。你所要做的就是反转逻辑。

选择算法

选择算法属于一类算法,旨在解决在列表中找到第 i 个最小元素的问题。当列表按升序排序时,列表中的第一个元素将是列表中最小的项。列表中的第二个元素将是列表中的第二个最小元素。列表中的最后一个元素将是列表中的最后一个最小元素,但这也将符合列表中的最大元素。

在创建堆数据结构时,我们达到了这样的理解:对pop方法的调用将返回堆中的最小元素。从最小堆中弹出的第一个元素是列表中的第一个最小元素。同样,从最小堆中弹出的第七个元素将是列表中的第七个最小元素。因此,要找到列表中的第 i 个最小元素,我们需要弹出堆i次。这是一个非常简单且高效的方法来找到列表中的第 i 个最小元素。

但在第十一章,“选择算法”中,我们将研究另一种方法,通过这种方法我们可以找到列表中的第 i 个最小元素。

选择算法在过滤噪声数据、找到列表中的中位数、最小和最大元素以及甚至可以应用于计算机棋类程序中都有应用。

摘要

本章讨论了图和堆。我们探讨了使用列表和字典在 Python 中表示图的方法。为了遍历图,我们研究了广度优先搜索和深度优先搜索。

我们随后将注意力转向堆和优先队列,以理解它们的实现。本章以使用堆的概念在列表中找到第 i 个最小元素结束。

图论非常复杂,仅一章内容无法公正地对待它。以节点为旅程的旅程将在这个章节结束。下一章将引领我们进入搜索领域,以及我们如何在列表中高效搜索项目的各种方法。

第九章:搜索

在前几章中已经开发出的数据结构中,对它们进行的一个关键操作是搜索。在本章中,我们将探讨可以用来在项目集合中查找元素的不同策略。

另一个重要的操作是排序,它利用了搜索。没有搜索操作的某种变体,几乎不可能进行排序。"如何搜索"也很重要,因为它影响着排序算法最终执行的速度。

搜索算法分为两大类。一类假设要应用搜索操作的项列表已经排序,而另一类则没有。

搜索操作的性能受到即将搜索的项是否已经排序的严重影响,正如我们将在后续主题中看到的。

线性搜索

让我们集中讨论线性搜索,这是在典型的 Python 列表上执行的。

图片

前面的列表中的元素可以通过列表索引访问。要找到列表中的元素,我们采用线性搜索技术。这种技术通过使用索引从列表的开始移动到结束来遍历元素列表。每个元素都会被检查,如果不匹配搜索项,则检查下一个项。通过从一个项跳到其下一个项,列表被顺序遍历。

在处理本章和其他章节的内容时,我们使用整数列表来增强我们的理解,因为整数易于比较。

无序线性搜索

包含元素6018810100的列表是一个无序列表的例子。列表中的项没有按大小排序。要对这样的列表执行搜索操作,需要从第一个项开始,将其与搜索项进行比较。如果没有匹配,则检查列表中的下一个元素。这个过程一直持续到我们到达列表的最后一个元素或直到找到匹配项。

    def search(unordered_list, term): 
       unordered_list_size = len(unordered_list) 
        for i in range(unordered_list_size): 
            if term == unordered_list[i]: 
                return i 

        return None 

search函数接受两个参数,一个是存放我们数据列表,另一个是我们正在寻找的项,称为搜索项

获取数组的大小并确定for循环执行的次数。

        if term == unordered_list[i]: 
            ... 

在每次for循环的迭代中,我们测试搜索项是否等于索引指向的项。如果是真的,则不需要继续搜索。我们返回匹配发生的位置。

如果循环运行到列表的末尾而没有找到匹配项,则返回None以表示列表中没有这样的项。

在无序列表中,没有关于如何插入元素的指导规则。因此,这影响了搜索的方式。缺乏顺序意味着我们无法依赖任何规则来进行搜索。因此,我们必须逐个访问列表中的项目。如图所示,搜索术语66的搜索从第一个元素开始,然后移动到列表中的下一个元素。因此,6066进行比较,如果不相等,则将66188等比较,直到在列表中找到搜索词。

图片

无序线性搜索的最坏情况运行时间为O(n)。在找到搜索词之前可能需要访问所有元素。如果搜索词位于列表的最后一个位置,就会发生这种情况。

有序线性搜索

在列表元素已经排序的情况下,我们的搜索算法可以改进。假设元素已经按升序排序,搜索操作可以利用列表的有序性来提高搜索效率。

算法简化为以下步骤:

  1. 顺序遍历列表。

  2. 如果搜索项大于循环中当前检查的对象或项,则退出并返回None

在遍历列表的过程中,如果搜索词大于当前项,则无需继续搜索。

图片

当搜索操作开始时,将第一个元素与(5)进行比较,没有匹配。但由于列表中还有更多元素,搜索操作继续进行,以检查下一个元素。继续前进的更有说服力的原因是,我们知道搜索项可能与大于2的任何元素匹配。

经过第 4 次比较后,我们得出结论,搜索词无法在任何位置找到,这些位置都在6所在位置之上。换句话说,如果当前项大于搜索词,那么这意味着没有必要进一步搜索列表。

    def search(ordered_list, term): 
        ordered_list_size = len(ordered_list) 
        for i in range(ordered_list_size): 
            if term == ordered_list[i]: 
                return i 
            elif ordered_list[i] > term: 
                return None 

        return None 

现在的if语句处理了这个检查。elif部分测试ordered_list[i] > term的条件。如果比较结果为True,则方法返回None

方法中的最后一行返回None,因为循环可能遍历列表,但仍找不到任何与搜索词匹配的元素。

有序线性搜索的最坏情况时间复杂度为O(n)。一般来说,这种搜索被认为效率低下,尤其是在处理大数据集时。

二分搜索

二分搜索是一种搜索策略,用于通过持续减少要搜索的数据量来找到列表中的元素,从而提高找到搜索词的速率。

要使用二分搜索算法,要操作列表必须已经排序。

“二分”这个词有几个含义,帮助我们正确理解算法。

在列表中寻找一个项目时,每次尝试都需要做出一个二进制决策。一个关键的决策是猜测列表的哪一部分可能包含我们正在寻找的项目。搜索词会在列表的前半部分还是后半部分,也就是说,如果我们总是把列表看作由两部分组成的话?

与其从一个列表的单元格移动到另一个单元格,如果我们采用一种有根据的猜测策略,我们很可能会更快地到达找到项目所在的位置。

例如,假设我们想要找到一本 1000 页书的中间页。我们已经知道每本书的页码都是按顺序从 1 往上编号的。所以可以推断出第 500 页应该正好在书的中间,而不是从第 1 页、第 2 页翻到第 500 页。假设我们现在要找第 250 页。我们仍然可以使用我们的策略轻松地找到页面。我们猜测第 500 页将书分成两半。第 250 页将位于书的左侧。无需担心是否能在第 500 页和第 1000 页之间找到第 250 页,因为那里永远找不到。所以以第 500 页为参考,我们可以打开到大约位于第 1 页和第 500 页之间的半数页面。这使我们更接近找到第 250 页。

以下是对有序项目列表进行二分搜索的算法:

def binary_search(ordered_list, term): 

    size_of_list = len(ordered_list) - 1 

    index_of_first_element = 0 
    index_of_last_element = size_of_list 

    while index_of_first_element <= index_of_last_element: 
        mid_point = (index_of_first_element + index_of_last_element)/2 

        if ordered_list[mid_point] == term: 
            return mid_point 

        if term > ordered_list[mid_point]: 
            index_of_first_element = mid_point + 1 
        else: 
            index_of_last_element = mid_point - 1 

    if index_of_first_element > index_of_last_element: 
        return None 

假设我们必须找到列表中项目10的位置如下:

图片

算法使用while循环来迭代调整列表中的限制,以在其中找到搜索词。只要起始索引index_of_first_elementindex_of_last_element索引之间的差值为正,while循环就会运行。

算法首先通过将第一个元素的索引(0)与最后一个元素的索引(4)相加,然后除以2来找到中间索引mid_point

mid_point = (index_of_first_element + index_of_last_element)/2 

在这种情况下,10没有在列表的中间位置或索引处找到。如果我们正在寻找120,我们就必须将index_of_first_element调整为mid_point +1。但由于10位于列表的另一侧,我们将index_of_last_element调整为mid_point-1

图片

现在我们新的索引index_of_first_elementindex_of_last_element分别是01,我们计算中点(0 + 1)/2,等于0。新的中点是0,我们找到中间的项目并与搜索项ordered_list[0]进行比较,其值为10。哇!我们的搜索词找到了。

通过重新调整 index_of_first_elementindex_of_last_element 的索引,我们的列表大小减半,只要 index_of_first_element 小于 index_of_last_element,这个过程就会继续。当这种情况不再成立时,最可能的情况是我们的搜索词不在列表中。

这里的实现是迭代的。我们也可以通过应用相同的原理,即移动标记搜索列表开始和结束的指针,来开发算法的递归变体。

def binary_search(ordered_list, first_element_index, last_element_index, term): 

    if (last_element_index < first_element_index): 
        return None 
    else: 
        mid_point = first_element_index + ((last_element_index - first_element_index) / 2) 

        if ordered_list[mid_point] > term: 
            return binary_search(ordered_list, first_element_index, mid_point-1,term) 
        elif ordered_list[mid_point] < term: 
            return binary_search(ordered_list, mid_point+1, last_element_index, term) 
        else: 
            return mid_point 

对这个二分查找算法递归实现的调用及其输出如下:

    store = [2, 4, 5, 12, 43, 54, 60, 77]
    print(binary_search(store, 0, 7, 2))   

Output:
>> 0

递归二分查找和迭代二分查找之间唯一的区别是函数定义以及 mid_point 的计算方式。在 ((last_element_index - first_element_index) / 2) 操作之后的 mid_point 计算必须将结果加到 first_element_index 上。这样我们就定义了尝试搜索的列表部分。

二分查找算法的最坏时间复杂度为 O(log n)。每次迭代中对列表的减半遵循元素数量的 log n 进程。

不言而喻,log x 是指以 2 为底的对数。

插值搜索

二分查找算法还有一种变体,可以更接近地说是模仿人类在任意项目列表上执行搜索的方式。它仍然基于尝试对排序列表中搜索项可能被找到的位置进行良好猜测。

例如,检查以下项目列表:

要找到 120,我们知道要查看列表的右侧部分。我们最初的二分查找处理通常会首先检查中间元素,以确定它是否与搜索词匹配。

更符合人类做法的是,选择一个中间元素,不仅要将数组分成两半,还要尽可能接近搜索词。中间位置的计算遵循以下规则:

mid_point = (index_of_first_element + index_of_last_element)/2 

我们将用更好的公式替换这个公式,使其接近搜索词。mid_point 将接收 nearest_mid 函数的返回值。

def nearest_mid(input_list, lower_bound_index, upper_bound_index, search_value): 
    return lower_bound_index + (( upper_bound_index -lower_bound_index)/ (input_list[upper_bound_index] -input_list[lower_bound_index])) * (search_value -input_list[lower_bound_index]) 

nearest_mid 函数接受要执行搜索的列表作为参数。lower_bound_indexupper_bound_index 参数表示列表中我们希望找到搜索词的界限。search_value 表示要搜索的值。

这些用于以下公式:

lower_bound_index + (( upper_bound_index - lower_bound_index)/ (input_list[upper_bound_index] - input_list[lower_bound_index])) * (search_value - input_list[lower_bound_index]) 

给定我们的搜索列表 446075100120230250nearest_mid 将使用以下值进行计算:

lower_bound_index = 0
upper_bound_index = 6
input_list[upper_bound_index] = 250
input_list[lower_bound_index] = 44
search_value = 230

现在可以看出,mid_point 将接收值为 5,这是我们的搜索词位置的索引。二分查找会选择 100 作为中间值,这将需要算法的另一次运行。

下面给出了一个更直观的说明,说明了典型的二分搜索与插值之间的区别。对于典型的二分搜索,找到中点的方式如下:

可以看到,中点实际上位于前一个列表的中间位置。这是由于除以列表 2 的结果。

另一方面,插值搜索将移动如下:

在插值搜索中,我们的中点更偏向左边或右边。这是由于在除以获取中点时使用的乘数效应造成的。从前面的图像中,我们可以看到中点已经偏向右边。

插值算法的其余部分与二分搜索相同,只是计算中点位置的方式不同。

def interpolation_search(ordered_list, term): 

    size_of_list = len(ordered_list) - 1 

    index_of_first_element = 0 
    index_of_last_element = size_of_list 

    while index_of_first_element <= index_of_last_element: 
        mid_point = nearest_mid(ordered_list, index_of_first_element, index_of_last_element, term) 

        if mid_point > index_of_last_element or mid_point < index_of_first_element: 
            return None 

        if ordered_list[mid_point] == term: 
            return mid_point 

        if term > ordered_list[mid_point]: 
            index_of_first_element = mid_point + 1 
        else: 
            index_of_last_element = mid_point - 1 

    if index_of_first_element > index_of_last_element: 
        return None 

nearest_mid函数使用乘法运算。这可能会产生大于upper_bound_index或小于lower_bound_index的值。当这种情况发生时,这意味着搜索项term不在列表中。因此返回None来表示这一点。

那么,当ordered_list[mid_point]不等于搜索项时会发生什么?嗯,我们现在必须重新调整index_of_first_elementindex_of_last_element,以便算法将关注可能包含搜索项的数组部分。这就像我们在二分搜索中所做的那样。

if term > ordered_list[mid_point]: 
index_of_first_element = mid_point + 1 

如果搜索项大于ordered_list[mid_point]存储的值,那么我们只调整index_of_first_element变量,使其指向mid_point + 1的索引。

下面的图像显示了调整发生的方式。index_of_first_element被调整并指向mid_point+1的索引。

图像仅说明了中点的调整。在插值中,中点很少将列表分成两个相等的部分。

另一方面,如果搜索项小于ordered_list[mid_point]存储的值,那么我们只调整index_of_last_element变量,使其指向mid_point - 1的索引。这个逻辑被 if 语句的 else 部分所捕获index_of_last_element = mid_point - 1

图像显示了重新计算index_of_last_element对中点位置的影响。

让我们用一个更实际的例子来理解二分搜索和插值算法的内部工作原理。

考虑具有以下元素的列表:

[ 2, 4, 5, 12, 43, 54, 60, 77] 

在索引 0 处存储了 2,在索引 7 处找到了值 77。现在,假设我们想要在列表中找到元素 2。两种不同的算法将如何进行?

如果我们将此列表传递给插值search函数,nearest_mid函数将返回一个等于0的值。仅通过一次比较,我们就能找到搜索项。

另一方面,二分搜索算法需要三次比较才能到达搜索项,如下面的图像所示:

计算的第一个mid_point3。第二个mid_point1,最后一个找到搜索项的mid_point0

选择搜索算法

二分查找和插值搜索操作在性能上优于有序和无序线性搜索函数。由于在列表中按顺序探测元素以找到搜索项,有序和无序线性搜索的时间复杂度为O(n)。当列表很大时,这会导致非常差的表现。

另一方面,二分查找操作在每次搜索尝试时将列表分成两半。在每次迭代中,我们比线性策略更快地接近搜索项。时间复杂度为O(log n)。尽管使用二分查找可以获得速度提升,但它不能用于未排序的项目列表,也不建议用于小尺寸的列表。

能够到达包含搜索项的列表部分在很大程度上决定了搜索算法的性能。在插值搜索算法中,计算中间值,这提高了获得搜索项的概率。插值搜索的时间复杂度为O(log(log n))。这使得搜索速度比其变体二分搜索更快。

摘要

在本章中,我们考察了两种搜索算法。讨论了线性搜索和二分搜索算法的实现,并进行了比较。本节还介绍了二分搜索的变体,插值搜索。了解使用哪种搜索操作将在后续章节中变得相关。

在下一章中,我们将使用我们所获得的知识来使我们能够在项目列表上执行排序操作。

第十章:排序

每当收集数据时,总有需要对这些数据进行排序的时候。排序操作对所有数据集都是通用的,无论是姓名集合、电话号码还是简单的待办事项列表。

在本章中,我们将研究几种排序技术,包括以下内容:

  • 冒泡排序

  • 插入排序

  • 选择排序

  • 快速排序

  • 堆排序

在我们处理这些排序算法时,我们将考虑它们的渐近行为。一些算法相对容易开发,但可能性能不佳。其他一些稍微复杂一些的算法将展现出令人印象深刻的性能。

排序后,对一组项目进行搜索操作变得容易得多。我们将从所有排序算法中最简单的一个——冒泡排序算法开始。

排序算法

在本章中,我们将介绍多种不同实现难度的排序算法。排序算法根据它们的内存使用、复杂度、递归、是否基于比较以及其他考虑因素进行分类。

一些算法使用更多的 CPU 周期,因此具有较差的渐近值。其他算法在排序多个值时消耗更多的内存和其他计算资源。另一个考虑因素是排序算法如何适合递归或迭代或两者兼而有之的表达。有一些算法使用比较作为排序元素的基础。冒泡排序算法就是这样一个例子。非比较排序算法的例子包括桶排序和鸽巢排序。

冒泡排序

冒泡排序算法背后的思想非常简单。给定一个无序列表,我们比较列表中的相邻元素,每次只比较两个元素,将它们放入正确的数量级。算法的关键在于交换过程。

考虑一个只有两个元素的列表:

要对这个列表进行排序,只需将它们交换到正确的位置,2 占据索引 05 占据索引 1。为了有效地交换这些元素,我们需要一个临时存储区域:

冒泡排序算法的实现从先前的图像中展示的交换方法开始。首先,元素 5 将被复制到一个临时位置,temp。然后元素 2 将被移动到索引 0。最后,5 将从 temp 移动到索引 1。最终,元素将被交换。现在列表将包含元素:[2, 5]。以下代码将在元素 unordered_list[j]unordered_list[j+1] 不在正确顺序时交换它们的元素:

    temp = unordered_list[j] 
    unordered_list[j] = unordered_list[j+1] 
    unordered_list[j+1] = temp 

现在我们已经能够交换一个两个元素的数组,那么使用这个相同的思想来排序整个列表应该很简单。

我们将在双层循环中运行这个交换操作。内层循环如下:

    for j in range(iteration_number): 
        if unordered_list[j] > unordered_list[j+1]: 
            temp = unordered_list[j] 
            unordered_list[j] = unordered_list[j+1] 
            unordered_list[j+1] = temp 

在实现冒泡排序算法时,知道何时交换很重要。要排序如[3, 2, 1]这样的数字列表,我们需要最多交换两次元素。这等于列表的长度减去 1,iteration_number = len(unordered_list)-1。我们减去1是因为它给出了运行的最大迭代次数:

通过恰好两次迭代交换相邻元素,最大的数字最终出现在列表的最后一个位置。

if语句确保如果两个相邻元素已经处于正确的顺序,则不会发生不必要的交换。内层for循环只会在我们的列表中恰好交换两次相邻元素。

然而,你会发现第一次运行for循环并不能完全排序我们的列表。为了使整个列表排序,这种交换操作需要发生多少次?如果我们重复交换相邻元素的过程多次,列表就会被排序。一个外循环被用来实现这一点。列表中元素的交换导致了以下动态:

我们认识到最多需要四次比较才能使我们的列表排序。因此,内外循环都必须运行len(unordered_list)-1次,以便所有元素都被排序:

iteration_number = len(unordered_list)-1 
    for i in range(iteration_number): 
        for j in range(iteration_number): 
            if unordered_list[j] > unordered_list[j+1]: 
                temp = unordered_list[j] 
                unordered_list[j] = unordered_list[j+1] 
                unordered_list[j+1] = temp 

即使列表包含很多元素,也使用同样的原理。冒泡排序也有很多变体,这些变体可以最小化迭代和比较的次数。

冒泡排序是一种非常低效的排序算法,时间复杂度为O(n²),最佳情况为O(n)。通常,冒泡排序算法不应该用于排序大型列表。然而,在相对较小的列表上,它的性能相当不错。

在冒泡排序算法的一个变体中,如果内循环中没有比较,我们就简单地退出整个排序过程。内循环中不需要交换元素的存在意味着列表已经排序。从某种意义上说,这可以加快通常被认为较慢的算法。

插入排序

将相邻元素交换以排序项目列表的想法也可以用来实现插入排序。在插入排序算法中,我们假设列表的一部分已经排序,而另一部分仍然未排序。基于这个假设,我们遍历列表的未排序部分,一次取一个元素。使用这个元素,我们遍历列表的已排序部分,并按正确的顺序插入它,以确保列表的已排序部分保持排序。这有很多语法。让我们用一个例子来解释说明。

考虑以下数组:

算法首先使用一个for循环在索引14之间运行。我们从索引1开始,因为我们假设索引0的子数组已经处于排序状态:

在循环执行开始时,我们有以下内容:

    for index in range(1, len(unsorted_list)): 
        search_index = index 
        insert_value = unsorted_list[index] 

在每次for循环执行的开始,unsorted_list[index]处的元素被存储在insert_value变量中。稍后,当我们找到列表排序部分中的适当位置时,insert_value将被存储在该索引或位置:

    for index in range(1, len(unsorted_list)): 
        search_index = index 
        insert_value = unsorted_list[index] 

        while search_index > 0 and unsorted_list[search_index-1] >     
              insert_value : 
            unsorted_list[search_index] = unsorted_list[search_index-1] 
            search_index -= 1 

        unsorted_list[search_index] = insert_value 

search_index用于向while循环提供信息——确切地找到需要插入到列表排序部分的下一个元素的位置。

while循环从列表的末尾开始遍历,受两个条件的引导:首先,如果search_index > 0,则意味着列表的排序部分还有更多元素;其次,为了while循环能够运行,unsorted_list[search_index-1]必须大于insert_valueunsorted_list[search_index-1]数组将执行以下任一操作:

  • 在第一次执行while循环之前,指向unsorted_list[search_index]之前的元素

  • 在第一次运行while循环后,指向unsorted_list[search_index-1]之前的一个元素

在我们的列表示例中,由于5 > 1while循环将被执行。在while循环体中,unsorted_list[search_index-1]处的元素被存储在unsorted_list[search_index]中。search_index -= 1将列表遍历向后移动,直到它具有值0

我们现在的列表看起来是这样的:

while循环退出后,search_index的最后一个已知位置(在这种情况下是0)现在帮助我们了解在哪里插入insert_value

for循环的第二次迭代中,search_index将具有值2,这是数组中第三个元素的索引。此时,我们开始向左(指向索引0)进行比较。100将与5进行比较,但由于100大于5while循环将不会执行。由于search_index变量从未递减,100将被替换为其自身。因此,unsorted_list[search_index] = insert_value将没有任何效果。

search_index指向索引3时,我们将2100进行比较,并将100移动到2所在的位置。然后,我们将25进行比较,并将5移动到100最初存储的位置。此时,while循环将中断,2将被存储在索引1处。数组将部分排序,值为[1, 2, 5, 100, 10]

为了使列表排序,上述步骤将最后一次发生。

插入排序算法被认为是稳定的,因为它不会改变具有相等键的元素的相对顺序。它也只需要比列表消耗的内存更多的内存,因为它是在原地执行交换。

其最坏情况值为O(n²),其最好情况为O(n)。

选择排序

另一种流行的排序算法是选择排序。这种排序算法易于理解,但效率不高,其最坏和最好的渐进复杂度均为O()。它首先在一个数组中找到最小的元素,并将其与数组索引[0]处的数据交换。同样的操作进行第二次;然而,在找到第一个最小元素后,列表剩余部分的最小元素与索引[1]处的数据交换。

为了更清楚地说明算法的工作原理,让我们对一组数字进行排序:

图片

从索引0开始,我们在索引1和最后一个元素的索引之间寻找列表中的最小项。当找到这个元素时,它被与索引0处的数据交换。我们简单地重复这个过程,直到列表排序。

在列表中寻找最小项是一个增量过程:

图片

元素25的比较选择2作为较小的元素。这两个元素被交换。

交换操作后,数组看起来像这样:

图片

仍然在索引0处,我们比较265

图片

由于65大于2,这两个元素没有交换。进一步比较索引0处的元素,即2,与索引3处的元素,即10。没有发生交换。当我们到达列表的最后一个元素时,最小的元素将占据索引0

将开始一组新的比较,但这次从索引1开始。我们重复整个过程,将存储在该处的元素与索引2到最后一个索引之间的所有元素进行比较。

第二次迭代的第一个步骤看起来像这样:

图片

以下是对选择排序算法的实现。函数的参数是我们想要按大小顺序排列的无序项目列表:

    def selection_sort(unsorted_list): 

        size_of_list = len(unsorted_list) 

        for i in range(size_of_list): 
            for j in range(i+1, size_of_list): 

                if unsorted_list[j] < unsorted_list[i]: 
                    temp = unsorted_list[i] 
                    unsorted_list[i] = unsorted_list[j] 
                    unsorted_list[j] = temp 

算法首先使用外部for循环遍历列表size_of_list多次。因为我们把size_of_list传递给range方法,它将生成从0size_of_list-1的序列。这是一个细微的注意点。

内部循环负责遍历列表,并在遇到小于unsorted_list[i]所指向元素的任何元素时进行必要的交换。注意,内部循环从i+1开始,到size_of_list-1结束。内部循环在其i+1size_of_list-1之间寻找最小元素,但使用j索引:

图片

上述图示显示了算法搜索下一个最小元素的搜索方向。

快速排序

快速排序算法属于分而治之算法类别,其中我们将问题分解(分)成更小的块,这些块更容易解决(治)。在这种情况下,未排序的数组被分解成部分排序的子数组,直到列表中的所有元素都处于正确的位置,此时我们的未排序列表将变为已排序。

列表分区

在我们将列表分成更小的块之前,我们必须先对其进行分区。这是快速排序算法的核心。为了分区数组,我们必须首先选择一个枢轴。数组中的所有元素都将与这个枢轴进行比较。在分区过程结束时,所有小于枢轴的元素都将位于枢轴的左侧,而所有大于枢轴的元素都将位于数组的右侧。

枢轴选择

为了简化,我们将任何数组中的第一个元素作为枢轴。这种枢轴选择会降低性能,尤其是在对已排序的列表进行排序时。随机选择数组中的中间或最后一个元素作为枢轴并不会进一步改善情况。在下一章中,我们将采用更好的方法来选择枢轴,以便帮助我们找到列表中的最小元素。

实现

在我们深入代码之前,让我们通过快速排序算法来回顾一下列表的排序过程。理解分区步骤非常重要,因此我们将首先处理这个操作。

考虑以下整数列表。我们将使用下面的分区函数来分区这个列表:

图片


    def partition(unsorted_array, first_index, last_index): 

        pivot = unsorted_array[first_index] 
        pivot_index = first_index 
        index_of_last_element = last_index 

        less_than_pivot_index = index_of_last_element 
        greater_than_pivot_index = first_index + 1 
        ... 

分区函数接收我们需要分区的数组作为其参数:其第一个元素的索引和最后一个元素的索引。

枢轴的值存储在 pivot 变量中,而其索引存储在 pivot_index 中。我们不使用 unsorted_array[0],因为当使用数组的某个片段作为参数调用未排序数组时,索引 0 并不一定指向该数组中的第一个元素。下一个元素到枢轴的索引 first_index + 1 标记了我们开始寻找数组中大于 pivot 的元素的位置,greater_than_pivot_index = first_index + 1

less_than_pivot_index = index_of_last_element 标记了列表中最后一个元素的位置,这是我们开始搜索小于枢轴元素的起点:

    while True: 

        while unsorted_array[greater_than_pivot_index] < pivot and 
              greater_than_pivot_index < last_index: 
              greater_than_pivot_index += 1 

        while unsorted_array[less_than_pivot_index] > pivot and 
              less_than_pivot_index >= first_index: 
              less_than_pivot_index -= 1 

在主 while 循环执行开始时,数组看起来是这样的:

图片

第一个内部while循环将一个索引向右移动,直到它落在索引2上,因为该索引处的值大于43。在这个点上,第一个while循环中断,不再继续。在第一个while循环中对条件进行每次测试时,只有当while循环的测试条件评估为True时,才会评估greater_than_pivot_index += 1。这使得寻找大于枢轴的元素的搜索进展到右侧的下一个元素。

第二个内部while循环每次移动一个索引到左边,直到它落在索引5上,其值20小于43

图片

在这一点上,内部while循环都不能再执行下去:

    if greater_than_pivot_index < less_than_pivot_index: 
        temp = unsorted_array[greater_than_pivot_index] 
            unsorted_array[greater_than_pivot_index] =    
                unsorted_array[less_than_pivot_index] 
            unsorted_array[less_than_pivot_index] = temp 
    else: 
        break 

由于greater_than_pivot_index < less_than_pivot_index,if 语句的主体交换了那些索引处的元素。else 条件在任何greater_than_pivot_index变为大于less_than_pivot_index时都会中断无限循环。在这种情况下,这意味着greater_than_pivot_indexless_than_pivot_index已经交叉。

我们现在的数组看起来是这样的:

图片

less_than_pivot_index等于3greater_than_pivot_index等于4时,执行break语句。

一旦我们退出while循环,我们就交换unsorted_array[less_than_pivot_index]less_than_pivot_index处的元素,这个索引作为枢轴的索引返回:

    unsorted_array[pivot_index]=unsorted_array[less_than_pivot_index] 
    unsorted_array[less_than_pivot_index]=pivot 
    return less_than_pivot_index 

下面的图片显示了代码如何在分区过程的最后一步中将 4 与 43 交换:

图片

为了回顾,当quick sort函数第一次被调用时,它是在索引0处的元素周围进行分区的。在分区函数返回后,我们得到了数组[4, 3, 20, 43, 89, 77]

如您所见,元素43右侧的所有元素都更大,而左侧的元素都更小。分区已完成。

使用分割点 43 和索引 3,我们将使用我们刚刚经历的过程递归地对两个子数组[4, 30, 20][89, 77]进行排序。

主要quick sort函数的主体如下:

    def quick_sort(unsorted_array, first, last): 
        if last - first <= 0: 
            return 
    else: 
        partition_point = partition(unsorted_array, first, last) 
        quick_sort(unsorted_array, first, partition_point-1) 
        quick_sort(unsorted_array, partition_point+1, last) 

quick sort函数是一个非常简单的方法,不超过 6 行代码。繁重的工作由partition函数完成。当调用partition方法时,它返回分区点。这是unsorted_array中所有左侧元素都小于枢轴,而所有右侧元素都大于它的点。

当我们在分区过程之后立即打印unsorted_array的状态时,我们可以清楚地看到分区是如何发生的:

Output:
[43, 3, 20, 89, 4, 77]
[4, 3, 20, 43, 89, 77]
[3, 4, 20, 43, 89, 77]
[3, 4, 20, 43, 77, 89]
[3, 4, 20, 43, 77, 89]

退一步来说,让我们在第一次分区后对第一个子数组进行排序。[4, 3, 20]子数组的分区将在greater_than_pivot_index位于索引2less_than_pivot_index位于索引1时停止。在那个点上,两个标记被认为是交叉的。因为greater_than_pivot_index大于less_than_pivot_index,所以while循环的进一步执行将停止。基准值 4 将与3交换,而索引1将作为分区点返回。

快速排序算法的最坏情况复杂度为O(),但在对大量数据进行排序时效率很高。

堆排序

在第八章《图和其他算法》中,我们实现了(二叉)堆数据结构。我们的实现始终确保在从堆中删除或添加元素后,通过使用下沉和上浮辅助方法来维护堆顺序属性。

堆数据结构可以用来实现称为堆排序的排序算法。作为回顾,让我们创建一个包含以下项目的简单堆:

    h = Heap() 
    unsorted_list = [4, 8, 7, 2, 9, 10, 5, 1, 3, 6] 
    for i in unsorted_list: 
        h.insert(i) 
    print("Unsorted list: {}".format(unsorted_list)) 

创建堆h,并将unsorted_list中的元素插入。在每次调用insert方法后,通过后续调用float方法来恢复堆顺序属性。循环结束后,我们堆的顶部将是元素4

我们堆中的元素数量是10。如果我们对堆对象h调用pop方法 10 次并存储实际弹出的元素,我们将得到一个排序后的列表。每次pop操作后,堆都会重新调整以维护堆顺序属性。

heap_sort方法如下:

    class Heap: 
        ... 
        def heap_sort(self): 
            sorted_list = [] 
            for node in range(self.size): 
                n = self.pop() 
                sorted_list.append(n) 

            return sorted_list 

for循环简单地调用pop方法self.size次。循环结束后,sorted_list将包含一个排序后的项目列表。

insert方法被调用n次。与float方法一起,insert操作的最坏情况运行时间为O(n log n),pop方法也是如此。因此,这种排序算法的最坏情况运行时间为O(n log n)。

摘要

在本章中,我们探讨了多种排序算法。快速排序的性能远优于其他排序算法。在所有讨论的算法中,快速排序保留了它排序的列表的索引。我们将在下一章中利用这个属性,当我们探索选择算法时。

第十一章:选择算法

与在无序列表中查找元素相关的一组有趣的算法是选择算法。在这样做的时候,我们将回答有关选择一组数字的中位数以及选择列表中的第 i 个最小或最大元素等问题。

在本章中,我们将涵盖以下主题:

  • 排序选择

  • 随机选择

  • 确定性选择

排序选择

列表中的项目可能需要进行统计调查,例如找到平均值、中位数和众数。找到平均值和众数不需要对列表进行排序。然而,要找到数字列表中的中位数,必须首先对列表进行排序。找到中位数需要找到有序列表中间位置的元素。但如果我们想找到列表中的最后一个最小项或第一个最小项呢?

要在无序列表中找到第 i 个最小的数字,该元素出现的位置索引是重要的。但由于元素尚未排序,很难知道列表中索引为 0 的元素是否真的是第一个最小的数字。

当处理无序列表时,一个实用且明显的事情是首先对列表进行排序。一旦列表排序,就可以确保列表中的零元素将包含列表中的第一个最小元素。同样,列表中的最后一个元素将包含列表中的最后一个最小元素。

假设在进行搜索之前排序的奢侈可能负担不起。是否可以在不首先对列表进行排序的情况下找到第 i 个最小的元素?

随机选择

在上一章中,我们研究了快速排序算法。快速排序算法允许我们对无序列表中的项目进行排序,但有一个方法可以在排序算法运行时保留元素的索引。一般来说,快速排序算法执行以下操作:

  1. 选择一个枢轴。

  2. 在枢轴周围划分未排序的列表。

  3. 递归地对划分后的列表的两半使用步骤 1步骤 2进行排序。

一个有趣且重要的事实是,在每次划分步骤之后,枢轴的索引即使在列表排序后也不会改变。正是这个特性使我们能够处理一个不是完全排序的列表来获取第 i 个最小的数字。因为随机选择基于快速排序算法,所以通常被称为快速选择。

快速选择

快速选择算法用于在无序列表的项目中获取第 i 个最小的元素,在这种情况下,是数字。我们声明算法的主要方法如下:

    def quick_select(array_list, left, right, k): 

        split = partition(array_list, left, right) 

        if split == k: 
            return array_list[split] 
        elif split < k: 
            return quick_select(array_list, split + 1, right, k) 
        else: 
            return quick_select(array_list, left, split-1, k) 

quick_select函数接受列表中第一个元素的索引以及最后一个元素的索引作为参数。第 i 个元素由第三个参数k指定。允许值大于或等于零(0),这样当k为 0 时,我们知道要搜索列表中的第一个最小元素。其他人喜欢将k参数直接映射到用户正在搜索的索引,这样第一个最小数字就映射到排序列表的 0 索引。这完全取决于个人喜好。

对分区函数的调用split = partition(array_list, left, right)返回split索引。这个split数组的索引是在未排序列表中,所有元素在rightsplit-1之间都小于split数组中包含的元素,而所有元素在split+1left之间都大于。

partition函数返回split值时,我们将其与k进行比较,以确定split是否对应于第 k 个元素。

如果split小于k,那么这意味着第 k 个最小元素应该存在于split+1right之间:

图片

在前面的例子中,在一个假想的未排序列表中的分割发生在索引 5 处,而我们正在寻找第二个最小的数字。由于 5<2 的结果是false,因此会进行递归调用quick_select(array_list, left, split-1, k),以便搜索列表的另一部分:

如果split索引小于k,那么我们将调用quick_select如下:

图片

分区步骤

分区步骤与我们在快速排序算法中看到的是一样的。有几个值得注意的点:

    def partition(unsorted_array, first_index, last_index): 
        if first_index == last_index: 
            return first_index 

        pivot = unsorted_array[first_index] 
        pivot_index = first_index 
        index_of_last_element = last_index 

        less_than_pivot_index = index_of_last_element 
        greater_than_pivot_index = first_index + 1 

        while True: 

            while unsorted_array[greater_than_pivot_index] < pivot and  
                  greater_than_pivot_index < last_index: 
                  greater_than_pivot_index += 1 
            while unsorted_array[less_than_pivot_index] > pivot and 
                  less_than_pivot_index >= first_index: 
                  less_than_pivot_index -= 1 

            if greater_than_pivot_index < less_than_pivot_index: 
                temp = unsorted_array[greater_than_pivot_index] 
                unsorted_array[greater_than_pivot_index] = 
                    unsorted_array[less_than_pivot_index] 
                unsorted_array[less_than_pivot_index] = temp 
            else: 
                break 

        unsorted_array[pivot_index] =  
            unsorted_array[less_than_pivot_index] 
        unsorted_array[less_than_pivot_index] = pivot 

        return less_than_pivot_index 

在函数定义的开始处插入了一个 if 语句,以处理first_index等于last_index的情况。在这种情况下,这意味着我们的子列表中只有一个元素。因此,我们只需返回任何函数参数即可,在这种情况下,是first_index

第一个元素总是被选作枢轴。将第一个元素作为枢轴的选择是一个随机决策。这通常不会产生好的分割,从而也不会产生好的分区。然而,即使枢轴是随机选择的,最终也会找到第 i 个元素。

partition函数返回由less_than_pivot_index指向的枢轴索引,正如我们在前一章中看到的。

从这一点开始,你需要用铅笔和纸跟随程序执行,以更好地了解如何使用分割变量来确定搜索第 i 个最小元素的列表部分。

确定性选择

随机选择算法的最坏情况性能是O()。有可能改进随机选择算法的一部分,以获得最坏情况性能为O(n)。这种算法被称为确定性选择

确定性算法的一般方法如下所示:

  1. 选择一个枢轴:

    1. 将无序项目列表分成每组五个元素。

    2. 对所有组进行排序并找到中位数。

    3. 递归重复步骤 1步骤 2以获得列表的真实中位数。

  2. 使用真实中位数对无序项目列表进行分区。

  3. 递归到可能包含第 i 个最小元素的分区列表部分。

枢轴选择

在随机选择算法中,我们之前选择第一个元素作为枢轴。我们将用一系列步骤来替换这一步,使我们能够获得真实或近似的中位数。这将改善列表关于枢轴的分区:

    def partition(unsorted_array, first_index, last_index): 

        if first_index == last_index: 
            return first_index 
        else: 
            nearest_median =     
            median_of_medians(unsorted_array[first_index:last_index]) 

        index_of_nearest_median = 
            get_index_of_nearest_median(unsorted_array, first_index, 
                                        last_index, nearest_median) 

        swap(unsorted_array, first_index, index_of_nearest_median) 

        pivot = unsorted_array[first_index] 
        pivot_index = first_index 
        index_of_last_element = last_index 

        less_than_pivot_index = index_of_last_element 
        greater_than_pivot_index = first_index + 1 

现在我们来研究分区函数的代码。nearest_median变量存储给定列表的真实或近似中位数:

    def partition(unsorted_array, first_index, last_index): 

        if first_index == last_index: 
            return first_index 
        else: 
            nearest_median =   
            median_of_medians(unsorted_array[first_index:last_index]) 
        .... 

如果unsorted_array参数只有一个元素,first_indexlast_index将相等。因此,无论如何都会返回first_index

然而,如果列表大小大于一个,我们将使用由first_indexlast_index定义的数组部分调用median_of_medians函数。返回值再次存储在nearest_median中。

中位数的中位数

median_of_medians函数负责找到任何给定项目列表的近似中位数。该函数使用递归来返回真实中位数:

def median_of_medians(elems): 

    sublists = [elems[j:j+5] for j in range(0, len(elems), 5)] 

    medians = [] 
    for sublist in sublists: 
        medians.append(sorted(sublist)[len(sublist)/2]) 

    if len(medians) <= 5: 
        return sorted(medians)[len(medians)/2] 
    else: 
        return median_of_medians(medians) 

函数首先将列表elems分成每组五个元素的组。这意味着如果elems包含 100 个元素,那么通过语句sublists = [elems[j:j+5] for j in range(0, len(elems), 5)]将创建 20 个组,每个组包含恰好五个元素或更少:

    medians = [] 
        for sublist in sublists: 
            medians.append(sorted(sublist)[len(sublist)/2]) 

创建一个空数组并将其分配给medians,它存储分配给sublists的每个五个元素数组中的中位数。

for 循环遍历sublists内部的列表列表。每个子列表被排序,找到中位数并存储在medians列表中。

medians.append(sorted(sublist)[len(sublist)/2])语句将排序列表并获取存储在其中间索引的元素。这成为五个元素列表的中位数。由于列表的大小很小,使用现有的排序函数不会影响算法的性能。

我们从一开始就明白,我们不会对列表进行排序以找到第 i 个最小元素,那么为什么还要使用 Python 的排序方法呢?嗯,由于我们正在对非常小的列表(五个元素或更少)进行排序,该操作对算法整体性能的影响被认为是微不足道的。

此后,如果列表现在包含五个或更少的元素,我们将对medians列表进行排序,并返回位于其中间索引的元素:

    if len(medians) <= 5: 
            return sorted(medians)[len(medians)/2] 

如果列表的大小大于五个,我们将再次递归调用median_of_medians函数,并给它提供存储在medians中的中位数列表。

以以下数字列表为例:

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

我们可以使用代码语句sublists = [elems[j:j+5] for j in range(0, len(elems), 5)]将这个列表分成每组五个元素的组,以获得以下列表:

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

对五个元素的列表进行排序并获取它们的中位数,得到以下列表:

[3, 12, 9, 19, 22]

由于列表大小为五个元素,我们只返回排序列表的中位数,否则我们将对median_of_median函数进行另一次调用。

分区步骤

现在我们已经得到了近似中位数,get_index_of_nearest_median函数根据firstlast参数指定的列表范围取值:

    def get_index_of_nearest_median(array_list, first, second, median): 
        if first == second: 
            return first 
        else: 
            return first + array_list[first:second].index(median) 

再次强调,如果列表中只有一个元素,我们只返回第一个索引。arraylist[first:second]返回一个从索引 0 到list -1大小的数组。当我们找到中位数的索引时,由于新的范围索引,我们失去了列表中该元素所在的区域,因为[first:second]代码返回的范围。因此,我们必须将arraylist[first:second]返回的任何索引加到first上,以获得中位数实际找到的索引:

    swap(unsorted_array, first_index, index_of_nearest_median) 

然后我们使用交换函数将unsorted_array中的第一个元素与index_of_nearest_median交换。

交换两个数组元素的实用函数如下所示:

def swap(array_list, first, second): 
    temp = array_list[first] 
    array_list[first] = array_list[second] 
    array_list[second] = temp 

我们现在将近似中位数存储在未排序列表的first_index位置。

分区函数继续按照快速选择算法的代码进行。在分区步骤之后,数组看起来是这样的:


 def deterministic_select(array_list, left, right, k): 

        split = partition(array_list, left, right) 

        if split == k: 
            return array_list[split] 
        elif split < k : 
            return deterministic_select(array_list, split + 1, right, k) 
        else: 
            return deterministic_select(array_list, left, split-1, k) 

正如您已经观察到的,确定性选择算法的主要功能与随机选择对应算法完全相同。在array_list关于近似中位数分区后,与第 k 个元素进行比较。

如果split小于k,则调用deterministic_select(array_list, split + 1, right, k)进行递归调用。这将寻找数组那一半的第 k 个元素。否则,调用deterministic_select(array_list, left, split-1, k)

概述

本章探讨了如何回答如何在列表中找到第 i 个最小元素的问题。已经探讨了简单地排序列表以执行查找第 i 个最小元素操作的平凡解决方案。

在确定第 i 个最小元素之前,我们不一定需要对列表进行排序。随机选择算法允许我们修改快速排序算法以确定第 i 个最小元素。

为了进一步提高随机选择算法,以便我们可以获得O(n)的时间复杂度,我们着手寻找中位数的中位数,以便我们在分区期间找到一个好的分割点。

在下一章中,我们将探索字符串的世界。我们将学习如何高效地存储和操作大量文本。同时,我们还将涵盖数据结构和常见的字符串操作。

第十二章:设计技术和策略

在本章中,我们将退后一步,探讨计算机算法设计中的更广泛主题。随着你对编程经验的增长,某些模式开始变得明显。就像任何其他熟练的手艺一样,你不能没有一些技术和原则来实现目标。在算法的世界里,有大量的这些技术和设计原则。要解决该领域的更难问题,需要对这些技术的实际知识和掌握。

我们将探讨算法通常是如何分类的。其他设计技术将与一些算法的实现一起处理。

本章的目标不是让你成为算法设计和策略的专家,而是要在几页纸内揭示算法的广阔天地。

算法分类

存在着许多基于算法必须达到的目标的分类方案。在前几章中,我们实现了许多算法。可能出现的一个问题是,这些算法是否具有相同的形式?如果是,基于哪些相似性和特征作为基础?如果不是,算法能否被分组到类别中?

这些是我们将在解决算法分类的主要模式时考察的问题。

按实现分类

当将一系列步骤或过程转换为工作算法时,它可能采取多种形式。算法的核心可能使用一些资产,这些资产在本节中将进一步描述。

递归

递归算法是那些在满足一定条件之前对自己进行调用的算法。一些问题通过递归实现其解决方案更为容易表达。一个经典的例子是汉诺塔。也存在不同类型的递归算法,其中包括单递归和多递归、间接递归、匿名递归和生成递归。另一方面,迭代算法使用一系列步骤或重复结构来制定解决方案。这种重复结构可以是简单的while循环或任何其他类型的循环。迭代解决方案比它们的递归实现更容易想到。

逻辑

算法的一种实现是将它表达为受控的逻辑演绎。这个逻辑组件由将在计算中使用的公理组成。控制组件确定演绎应用于公理的方式。这以algorithm = logic + control*的形式表达。这构成了逻辑编程范式的基石。

逻辑组件决定了算法的意义。控制组件仅影响其效率。在不修改逻辑的情况下,可以通过改进控制组件来提高效率。

串行或并行

大多数计算机的 RAM 模型允许假设计算是一次执行一条指令。

串行算法,也称为顺序算法,是按顺序执行的算法。执行从开始到结束,没有其他执行过程。

要能够同时处理多个指令,就需要不同的模型或计算技术。并行算法可以同时执行多个操作。在 PRAM 模型中,有串行处理器共享全局内存。处理器还可以并行执行各种算术和逻辑操作。这使得可以同时执行多个指令。

并行/分布式算法将问题分解为子问题,分配给其处理器以收集结果。一些排序算法可以有效地并行化,而迭代算法通常可以并行化。

确定性算法与非确定性算法

确定性算法每次使用相同的输入运行算法时,都会产生相同的输出。有一些问题在设计解决方案时非常复杂,以确定性方式表达它们的解决方案可能是一个挑战。非确定性算法可以在每次运行算法时改变执行顺序或某些内部子过程,从而导致最终结果的变化。因此,每次运行非确定性算法时,算法的输出都不同。例如,使用概率值的算法在连续执行时,根据生成的随机数的值会产生不同的输出。

按复杂度分类

确定一个算法的复杂度是尝试估计在整个计算或程序执行过程中总共使用了多少空间(内存)和时间。

第三章,《算法设计原理》,更全面地介绍了关于复杂性的主题。我们将在下面总结我们在那里学到的内容。

复杂度曲线

现在考虑一个规模为n的问题。为了确定算法的时间复杂度,我们用T(n)表示它。这个值可能属于O(1)、O(log n)、O(n)、O(n log(n)、O()、O()或O(2^n)。根据算法执行的步骤,时间复杂度可能会或可能不会受到影响。O(n)的表示捕捉了算法的增长率。

现在我们来考察一个实际场景。我们是通过什么方式得出冒泡排序算法比快速排序算法慢的结论?或者一般而言,我们如何衡量一个算法相对于另一个算法的效率?嗯,我们可以比较任意数量算法的大 O 表示来决定它们的效率。正是这种方法给我们提供了一个时间度量或增长速率,它描绘了当n变大时算法的行为。

下面是一个图表,展示了算法性能可能落下的不同运行时:

按升序排列,从好到差的运行时列表给出为 O(1),O(log n),O(n),O(n log n),O(),O(),以及 O(2^n)。

设计分类

在本节中,我们将根据用于解决问题设计的各种算法的类别来介绍算法。

一个给定的问题可能有多个解决方案。当分析这些解决方案的算法时,很明显,其中一些实现了一种特定的技术或模式。正是这些技术我们将在这里讨论,并在稍后的章节中更详细地讨论。

分而治之

这种解决问题的方法正如其名所示。为了解决(征服)某些问题,该算法将问题分解成与原始问题相同且易于解决的子问题。子问题的解决方案以这种方式组合,使得最终解决方案是原始问题的解决方案。

将问题分解成更小部分的方式主要是通过递归。我们将在接下来的章节中详细考察这种技术。使用这种技术的算法包括归并排序、快速排序和二分查找。

动态规划

这种技术类似于分而治之,因为问题被分解成更小的子问题。在分而治之的方法中,每个子问题必须在它的结果被用来解决更大的问题之前得到解决。相比之下,动态规划不会重新计算已经遇到的子问题的解。相反,它使用一种记忆技术来避免重复计算。

动态规划问题有两个特征:最优子结构重叠子问题。我们将在下一节中进一步讨论这一点。

贪心算法

对于某些类别的问题,确定最佳解决方案非常困难。为了弥补最优解的不足,我们采取从一系列选项或选择中挑选出最有可能获得解决方案的最近似解决方案的方法。

贪心算法更容易构思,因为指导规则是选择带来最大利益的解决方案,并继续这样做,希望达到完美的解决方案。

这种技术旨在通过一系列局部最优选择来找到全局最优最终解决方案。局部最优选择似乎指向解决方案。在现实生活中,大多数这些局部最优选择都是次优的。因此,大多数贪心算法具有较差的渐近时间复杂度。

技术实现

让我们深入探讨本章前面讨论的一些理论编程技术的实现。我们将从动态规划开始。

动态规划

正如我们已经描述的,在这种方法中,我们将问题划分为更小的子问题。在寻找子程序的解决方案时,注意不要重新计算之前遇到的任何子问题。

这听起来有点像递归,但在这里事情要宽泛一些。一个问题可能适合通过使用动态规划来解决,但并不一定采取递归调用的形式。

一个问题具有的属性,使其成为使用动态规划解决的理想候选者,是它应该有一个重叠的子问题集。

一旦我们意识到在计算过程中子问题的形式重复出现,我们就不需要再次计算它。相反,我们返回之前遇到的该子问题的预计算值的结果。

为了避免出现我们永远不需要重新评估子问题的情况,我们需要一种有效的方法来存储每个子问题的结果。以下两种技术是现成的。

缓存

这种技术从初始问题集开始,将其划分为小子问题。在确定子程序的解决方案后,我们将结果存储到特定的子问题中。在将来,当遇到这个子问题时,我们只返回其预计算的值。

表格法

在表格法中,我们采用了一种方法,即先填充一个子问题的解表,然后将它们组合起来解决更大的问题。

斐波那契数列

我们将使用斐波那契数列来展示生成数列的缓存和表格技术。

缓存技术

让我们生成到第五项的斐波那契数列:

    1 1 2 3 5

生成序列的递归程序风格如下:

    def fib(n): 
        if n <= 2: 
            return 1 
        else: 
            return fib(n-1) + fib(n-2) 

代码非常简单,但由于递归调用而变得有点难以阅读,这些递归调用最终解决了问题。

当遇到基本情况时,fib() 函数返回 1。如果 n 等于或小于 2,则满足基本情况。

如果没有遇到基本情况,我们将再次调用 fib() 函数,这次将第一个调用提供 n-1,第二个调用提供 n-2

    return fib(n-1) + fib(n-2) 

解决斐波那契数列的第 i 项的策略布局如下:

图片

仔细观察前面的树形图,可以发现一些有趣的模式。对 f(1) 的调用发生了两次。对 f(1) 的调用发生了三次。此外,对 f(3) 的调用也发生了两次。

fib(2) 被调用时所有函数调用的返回值从未改变。对 fib(1)fib(3) 也是如此。由于相同的参数和输出返回相同的结果,因此计算时间被浪费了。

这些具有相同参数和输出的函数重复调用表明存在重叠。某些计算在较小的子问题中是重复发生的。

一个更好的方法是将fib(1)第一次遇到时的计算结果存储起来。这也适用于fib(2)fib(3)。以后,每次我们遇到对fib(1)fib(2)fib(3)的调用时,我们只需返回它们各自的结果。

我们fib调用的图现在看起来是这样的:

图片

我们现在完全消除了计算fib(3)fib(2)fib(1)的需求。这典型地代表了记忆化技术,其中将问题分解为其子问题,不会重新计算函数的重复调用。在我们的斐波那契示例中,重叠的函数调用是fib(1)fib(2)fib(3)

    def dyna_fib(n, lookup): 
        if n <= 2: 
            lookup[n] = 1 

        if lookup[n] is None: 
            lookup[n] = dyna_fib(n-1, lookup) + dyna_fib(n-2, lookup) 

        return lookup[n] 

要创建一个包含 1000 个元素的列表,我们执行以下操作,并将其传递给dyna_fib函数的lookup参数:

    map_set = [None]*(10000) 

此列表将存储对dyna_fib()函数的各种调用的计算值:

    if n <= 2: 
        lookup[n] = 1 

n小于或等于 2 时,对dyna_fib()的任何调用都将返回 1。当dyna_fib(1)被评估时,我们将值存储在map_set的索引 1 处:

编写lookup[n]的条件,如下所示:

if lookup[n] is None:
    lookup[n] = dyna_fib(n-1, lookup) + dyna_fib(n-2, lookup)

我们传递lookup以便在评估子问题时可以引用它。对dyna_fib(n-1, lookup)dyna_fib(n-2, lookup)的调用存储在lookup[n]中。当我们运行函数的更新实现以找到斐波那契数列的第 i 项时,我们发现有很大的改进。这种实现比我们的初始实现运行得快得多。将值 20 提供给两个实现,并观察执行速度的差异。由于在存储结果到函数调用中使用了内存,算法牺牲了空间复杂度以换取时间。

表格技术

动态规划中还有第二种技术,它涉及在某些情况下使用结果表或矩阵来存储用于后续使用的计算结果。

这种方法通过首先找到最终解决方案的路径来解决更大的问题。在fib()函数的情况下,我们将开发一个包含fib(1)fib(2)值的表,这些值预先确定。基于这两个值,我们将逐步计算到fib(n)

    def fib(n): 

        results = [1, 1] 

        for i in range(2, n): 
            results.append(results[i-1] + results[i-2]) 

        return results[-1] 

results变量位于索引 0,值为 1 和 1。这代表了fib(1)fib(2)的返回值。为了计算fib()函数高于 2 的值,我们只需调用for循环,将results[i-1] + results[i-2]的和追加到结果列表中。

分解与征服

这种编程方法强调了解决问题时需要将问题分解成与原始问题相同类型或形式的更小问题。这些子问题被解决并组合起来以解决原始问题。与这种编程相关联的以下三个步骤:

分解

分割意味着将实体或问题分解。在这里,我们制定将原始问题分解为子问题的方法。我们可以通过迭代或递归调用实现这一点。

征服

不可能无限期地将问题分解为子问题。在某个时刻,最小的不可分割问题将返回一个解决方案。一旦发生这种情况,我们可以反转我们的思考过程,并说如果我们知道可能的最小问题的解决方案,我们就可以获得原始问题的最终解决方案。

合并

为了获得最终解决方案,我们需要将解决较小问题的较小解决方案结合起来,以便解决更大的问题。

分治算法还有其他变体,例如合并和组合,以及征服和解决。

利用分治原则的算法包括归并排序、快速排序和斯特拉斯矩阵乘法。我们将从第三章 算法设计原理中开始的归并排序实现进行讲解。

归并排序

归并排序算法基于分治规则。给定一个未排序的元素列表,我们将列表分成大约两半。我们继续递归地分割这两半。经过一段时间,由递归调用创建的子列表将只包含一个元素。在那个时刻,我们开始在征服或合并步骤中合并解决方案:

    def merge_sort(unsorted_list): 
        if len(unsorted_list) == 1: 
            return unsorted_list 

        mid_point = int((len(unsorted_list))/2) 

        first_half = unsorted_list[:mid_point] 
        second_half = unsorted_list[mid_point:] 

        half_a = merge_sort(first_half) 
        half_b = merge_sort(second_half) 

        return merge(half_a, half_b) 

我们的实现首先通过将未排序的元素列表接受到merge_sort函数中开始。if语句用于建立基本情况,如果unsorted_list中只有一个元素,我们只需再次返回该列表。如果列表中有超过一个元素,我们使用mid_point = int((len(unsorted_list))/2)找到大约的中间值。

使用这个mid_point,我们将列表分为两个子列表,即first_halfsecond_half

    first_half = unsorted_list[:mid_point] 
    second_half = unsorted_list[mid_point:] 

通过再次将两个子列表传递给merge_sort函数,进行递归调用:

    half_a = merge_sort(first_half)  
    half_b = merge_sort(second_half) 

进入合并步骤。当half_ahalf_b已经传递了它们的值后,我们调用归并函数,该函数将合并或组合存储在half_ahalf_b中的两个解决方案,它们是列表:

    def merge(first_sublist, second_sublist): 
        i = j = 0 
        merged_list = [] 

        while i < len(first_sublist) and j < len(second_sublist): 
            if first_sublist[i] < second_sublist[j]: 
                merged_list.append(first_sublist[i]) 
                i += 1 
            else: 
                merged_list.append(second_sublist[j]) 
                j += 1 

        while i < len(first_sublist): 
            merged_list.append(first_sublist[i]) 
            i += 1 

        while j < len(second_sublist): 
            merged_list.append(second_sublist[j]) 
            j += 1 

        return merged_list 

归并函数接受我们想要合并的两个列表,即first_sublistsecond_sublist。变量ij被初始化为0,并用作指针,告诉我们合并过程中在两个列表中的位置。最终的merged_list将包含合并后的列表:

    while i < len(first_sublist) and j < len(second_sublist): 
        if first_sublist[i] < second_sublist[j]: 
            merged_list.append(first_sublist[i]) 
            i += 1 
        else: 
            merged_list.append(second_sublist[j]) 
            j += 1 

while循环开始比较first_sublistsecond_sublist中的元素。if语句选择两个中的较小者,即first_sublist[i]second_sublist[j],并将其追加到merged_list中。ij索引增加以反映合并步骤中的位置。while循环仅在任一子列表为空时停止。

first_sublistsecond_sublist 中可能会有元素被遗漏。最后的两个 while 循环确保在返回之前将这些元素添加到 merged_list 中。

最后一次调用 merge(half_a, half_b) 将返回排序后的列表。

让我们通过模拟合并两个子列表 [4, 6, 8][5, 7, 11, 40] 的最后一步来测试这个算法:

步骤 first_sublist second_sublist merged_list
第零步 [4 6 8] [5 7 11 40] []
第一步 [ 6 8] [5 7 11 40] [4]
第二步 [ 6 8] [ 7 11 40] [4 5]
第三步 [ 8] [ 7 11 40] [4 5 6]
第四步 [ 8] [ 11 40] [4 5 6 7]
第五步 [ ] [ 11 40] [4 5 6 7 8]

注意,粗体的文本表示在 first_sublist(使用索引 i)和 second_sublist(使用索引 j)循环中引用的当前项。

在执行的这个阶段,合并函数中的第三个 while 循环开始工作,将 11 和 40 移入 merged_list。返回的 merged_list 将包含完全排序的列表。

贪婪算法

正如我们之前所说的,贪婪算法做出在短期内带来最大收益的决定。这个技术的希望是,通过做出这些高收益的选择,整个路径将导致一个整体上好的解决方案或结果。

贪婪算法的例子包括用于找到最小生成树的 Prim 算法背包问题旅行商问题,仅举几例。

硬币计数问题

让我们考察贪婪技术的一个非常简单的应用。在某个任意国家,我们有 1 GHC、5 GHC 和 8 GHC 的面额。给定一个像 12 GHC 这样的金额,我们可能希望找到所需的最少面额数量以提供零钱。使用贪婪方法,我们从面额中选择最大值来分割 12 GHC。我们使用 8,因为它提供了将 12 GHC 减少到较低面额的最佳方式。

剩余的 4 GHC 不能被 5 整除,所以我们尝试 1 GHC 的面额,并意识到我们可以将其乘以 4 来获得 4 GHC。最终,创建 12 GHC 所需的最少面额数量是得到一张 8 GHC 和四张 1 GHC 的纸币。

到目前为止,我们的贪婪算法似乎做得相当不错。一个返回相应面额的函数如下:

    def basic_small_change(denom, total_amount): 
        sorted_denominations = sorted(denom, reverse=True) 

        number_of_denoms = [] 

        for i in sorted_denominations: 
            div = total_amount / i 
            total_amount = total_amount % i 
            if div > 0: 
                number_of_denoms.append((i, div)) 

        return number_of_denoms 

这种贪婪算法始终从可能的最大面额开始。denom 是一个面额列表。sorted(denom, reverse=True) 将列表按逆序排序,这样我们就可以在索引 0 处获得最大的面额。现在,从排序后的面额列表 sorted_denominations 的索引 0 开始,我们迭代并应用贪婪技术:

    for i in sorted_denominations: 
        div = total_amount / i 
        total_amount = total_amount % i 
        if div > 0: 
            number_of_denoms.append((i, div)) 

循环将遍历货币列表。每次循环运行时,它通过将total_amount除以当前货币i来获取商divtotal_amount更新以存储剩余部分以供进一步处理。如果商大于 0,我们将其存储在number_of_denoms中。

不幸的是,我们的算法在某些情况下会失败。例如,当传入 14 GHS 时,我们的算法返回一个 8 GHC 和四个 1 GHS。然而,这个输出并不是最优解。正确的解决方案是使用两个 5 GHC 和两个 1 GHC 的货币。

这里提供了一个更好的贪婪算法。这次,函数返回一个元组列表,使我们能够调查更好的结果:

    def optimal_small_change(denom, total_amount): 

        sorted_denominations = sorted(denom, reverse=True) 

        series = [] 
        for j in range(len(sorted_denominations)): 
            term = sorted_denominations[j:] 

            number_of_denoms = [] 
            local_total = total_amount 
            for i in term: 
                div = local_total / i 
                local_total = local_total % i 
                if div > 0: 
                    number_of_denoms.append((i, div)) 

            series.append(number_of_denoms) 

        return series 

外部for循环使我们能够限制我们从中找到解决方案的货币:

    for j in range(len(sorted_denominations)): 
        term = sorted_denominations[j:] 
        ...     

假设我们有一个列表[5, 4, 3]在sorted_denominations中,使用[j:]切片可以帮助我们获得子列表[5, 4, 3],[4, 3]和[3],然后我们尝试从中得到正确的组合来创建零钱。

迪杰斯特拉最短路径算法

我们介绍并研究迪杰斯特拉算法。这个算法是贪婪算法的一个例子。它找到从源点到图中所有其他节点或顶点的最短距离。在本节结束时,你将理解为什么它被归类为贪婪算法。

考虑以下图:

图片

通过检查,我们首先想到的从节点A到节点D的最短路径问题是具有值或距离 9 的边。从图中看,从节点AD的直线路径似乎也会产生两个节点之间的最短路径。但连接两个节点的边是最短路径的假设并不总是成立。

这种在解决问题时选择第一个选项的短视方法赋予了算法其名称和类别。一旦找到所谓的最短路径或距离,算法就会继续优化和改进其结果。

从节点A到节点D的其他路径证明比我们最初的选择更短。例如,从节点A到节点B再到节点C的总距离为 10。但通过节点AEFD的路线甚至更短。

我们将使用单个源实现最短路径算法。我们的结果将帮助我们确定从起点,在本例中是A,到图中任何其他节点的最短路径。

从节点A到节点C的最短路径是通过节点B,总距离为 7。同样,到F的最短路径是通过节点E,总距离为 5。

为了提出一个帮助我们找到图中最短路径的算法,让我们手动解决这个问题。然后,我们将以 Python 的形式展示解决方案。

在关于图章节中,我们看到了如何使用邻接表来表示一个图。我们将对其进行轻微修改,以便我们能够捕捉到每条边的距离。我们将使用一个表格来跟踪图中从源节点到任何其他节点的最短距离。我们将使用 Python 字典来实现这个表格。下面是一个这样的表格:

节点 从源点到最短距离 前一个节点
A 0 None
B None
C None
D None
E None
F None

图和表格的邻接表如下:

    graph = dict() 
    graph['A'] = {'B': 5, 'D': 9, 'E': 2} 
    graph['B'] = {'A': 5, 'C': 2} 
    graph['C'] = {'B': 2, 'D': 3} 
    graph['D'] = {'A': 9, 'F': 2, 'C': 3} 
    graph['E'] = {'A': 2, 'F': 3} 
    graph['F'] = {'E': 3, 'D': 2} 

嵌套字典包含距离和相邻节点。

这个表格是我们解决问题的努力的基础。当算法开始时,我们不知道从源节点(A)到任何节点的最短距离是多少。为了保险起见,我们将该列的值设置为无穷大,除了节点A。从起始节点开始,从节点A到节点A的距离是 0。因此,我们可以安全地使用这个值作为节点A到自身的最短距离。算法开始时还没有访问任何前一个节点。因此,我们将节点的前一个节点列标记为None

在算法的第 1 步中,我们首先检查节点A的相邻节点。为了找到节点A到节点B的最短距离,我们需要找到从起始节点到节点 B 的前一个节点的距离,这个前一个节点恰好是节点A,并将其添加到节点A到节点B的距离中。我们为A的其他相邻节点(BED)做同样的事情。

以相邻节点B为例,从起始节点到前一个节点的距离是 0。从前一个节点到当前节点(B)的距离是 5。这个总和与节点 B 的最短距离列中的数据进行比较。由于 5 小于无穷大(),我们将替换为这两个数中的最小值,即 5。

每当节点的最短距离被替换为一个更小的值时,我们还需要更新前一个节点列。在第一步结束时,我们的表格如下所示:

节点 从源点到最短距离 前一个节点
A* 0 None
B 5 A
C None
D 9 A
E 2 A
F None

在这一点上,节点A被认为是已访问的。因此,我们将节点A添加到已访问节点的列表中。在表格中,我们通过使文本加粗并在其后附加一个星号来表示节点A已被访问。

在第二步中,我们使用表格作为指南找到具有最短距离的节点。具有值 2 的节点E具有最短距离。这就是我们从表格中关于节点E可以推断出的内容。要到达节点E,我们必须访问节点A并覆盖 2 的距离。从节点 A,我们覆盖 0 的距离到达起始节点,即节点A本身。

节点E的相邻节点是AF。但节点A已经被访问过,所以我们只考虑节点F。为了找到到节点F的最短路径或距离,我们必须找到从起始节点到节点E的距离,并将其加到节点EF之间的距离上。我们可以通过查看节点E的最短距离列来找到从起始节点到节点E的距离,该列的值为 2。从节点EF的距离可以通过我们在本节前面开发的 Python 中的邻接表获得。这个距离是 3。这两个相加等于 5,小于无穷大。记住我们正在检查相邻节点F。由于节点E还有更多的相邻节点,我们标记节点E为已访问。我们的更新表格将具有以下值:

节点 从源节点到最短距离 前一个节点
A* 0 None
B 5 A
C None
D 9 A
E* 2 A
F 5 E

在这个阶段,我们开始下一步。最短距离列中的最小值是 5。我们纯粹基于字母顺序选择B而不是FB的相邻节点是AC,但节点A已经被访问过。使用我们之前建立的规则,从AC的最短距离是 7。我们得到这个数字是因为从起始节点到节点B的距离是 5,而从节点BC的距离是 2。由于总和 7 小于无穷大,我们更新最短距离为 7,并在前一个节点列中更新为节点B。现在B也被标记为已访问。表格的新状态如下:

节点 从源节点到最短距离 前一个节点
A* 0 None
B* 5 A
C 7 B
D 9 A
E* 2 A
F 5 E

尚未访问的最短距离节点是节点FF的相邻节点是节点DE。但节点E已经被访问过。因此,我们专注于找到从起始节点到节点D的最短距离。我们通过将节点AF的距离与节点FD的距离相加来计算这个距离。总和为 7,小于 9。因此,我们将 9 更新为 7,并在节点D的前一个节点列中将A替换为F。现在F也被标记为已访问。以下是到此为止更新的表格:

节点 从源节点到最短距离 前一个节点
A* 0 None
B* 5 A
C 7 B
D 7 F
E* 2 A
F* 5 E

现在,两个未访问的节点是CD。按字母顺序,我们选择检查C,因为这两个节点从起始节点A的最短距离相同。

然而,C的所有相邻节点都已访问过。因此,我们除了将节点C标记为已访问外,别无他法。此时表格保持不变。

最后,我们取节点D并发现它的所有相邻节点都已访问过。我们只将其标记为已访问。表保持不变:

节点 从源到最短距离 前一个节点
A* 0 None
B* 5 A
C* 7 B
D* 7 F
E* 2 A
F* 5 E

让我们用我们的图来验证这个表。从图中,我们知道从AF的最短距离是 5。我们需要通过E到达节点F。根据表,节点F的源列的最短距离是值 5。这是正确的。这也告诉我们,要到达节点F,我们需要访问节点E,然后从EA,这是我们的起始节点。这实际上是 shortest path。

我们通过表示一个表来开始寻找最短距离的程序,这个表使我们能够跟踪我们图中变化。对于我们使用的给定图,以下是表的字典表示:

    table = dict() 
    table = { 
        'A': [0, None], 
        'B': [float("inf"), None], 
        'C': [float("inf"), None], 
        'D': [float("inf"), None], 
        'E': [float("inf"), None], 
        'F': [float("inf"), None], 
    } 

表的初始状态使用float("inf")来表示无穷大。字典中的每个键都映射到一个列表。列表的第一个索引存储从源A的最短距离。第二个索引存储前一个节点:

    DISTANCE = 0 
    PREVIOUS_NODE = 1 
    INFINITY = float('inf') 

为了避免使用魔法数字,我们使用前面的常量。最短路径列的索引由DISTANCE引用。前一个节点列的索引由PREVIOUS_NODE引用。

现在所有准备工作都已经完成,主函数将接受由邻接表表示的图、表和起始节点作为参数:

    def find_shortest_path(graph, table, origin): 
        visited_nodes = [] 
        current_node = origin 
        starting_node = origin 

我们将已访问节点的列表保存在visited_nodes列表中。current_nodestarting_node变量都将指向我们选择的起始节点。origin值是相对于寻找最短路径的所有其他节点的参考点。

整个过程的繁重工作是通过使用一个while循环来完成的:

    while True: 
        adjacent_nodes = graph[current_node] 
        if set(adjacent_nodes).issubset(set(visited_nodes)): 
            # Nothing here to do. All adjacent nodes have been visited. 
            pass 
        else: 
            unvisited_nodes = 
                set(adjacent_nodes).difference(set(visited_nodes)) 

            for vertex in unvisited_nodes: 

                distance_from_starting_node = 
                    get_shortest_distance(table, vertex) 
                if distance_from_starting_node == INFINITY and 
                   current_node == starting_node: 
                    total_distance = get_distance(graph, vertex, 
                                                  current_node) 
                else: 
                    total_distance = get_shortest_distance (table, 
                    current_node) + get_distance(graph, current_node, 
                                                 vertex) 

                if total_distance < distance_from_starting_node: 
                    set_shortest_distance(table, vertex, 
                                          total_distance) 
                    set_previous_node(table, vertex, current_node) 

        visited_nodes.append(current_node) 

        if len(visited_nodes) == len(table.keys()): 
            break 

        current_node = get_next_node(table,visited_nodes) 

让我们分解一下while循环正在做什么。在while循环的主体中,我们通过adjacent_nodes = graph[current_node]这一行获取我们想要调查的图中的当前节点。current_node应该已经被设置。if语句用于确定current_node的所有相邻节点是否都已访问。当while循环第一次执行时,current_node将包含 A,adjacent_nodes将包含节点 B、D 和 E。visited_nodes也将为空。如果所有节点都已访问,我们则只继续程序中的后续语句。否则,我们开始另一个完整的步骤。

语句set(adjacent_nodes).difference(set(visited_nodes))返回未访问的节点。循环遍历这个未访问节点的列表:

    distance_from_starting_node = get_shortest_distance(table, vertex) 

辅助方法get_shortest_distance(table, vertex)将返回我们表中存储的最短距离列的值,使用由vertex引用的未访问节点之一:

    if distance_from_starting_node == INFINITY and current_node == 
       starting_node: 
    total_distance = get_distance(graph, vertex, current_node) 

当我们检查起始节点的相邻节点时,如果 distance_from_starting_node == INFINITY and current_node == starting_node 评估为 True,那么我们只需通过引用图来获取起始节点和顶点之间的距离:

    total_distance = get_distance(graph, vertex, current_node) 

get_distance 方法是我们用来获取 vertexcurrent_node 之间边(距离)值的另一个辅助方法。

如果条件失败,那么我们将 total_distance 赋值为从起始节点到 current_node 的距离加上 current_nodevertex 之间的距离。

一旦我们有了总距离,我们需要检查这个 total_distance 是否小于我们表中最短距离列中现有的数据。如果是的话,我们就使用两个辅助方法来更新那一行:

    if total_distance < distance_from_starting_node: 
        set_shortest_distance(table, vertex, total_distance) 
    set_previous_node(table, vertex, current_node) 

在这一点上,我们将 current_node 添加到已访问节点列表中:

    visited_nodes.append(current_node) 

如果所有节点都已访问,那么我们必须退出 while 循环。为了检查是否所有节点都已访问,我们比较 visited_nodes 列表的长度与我们表中键的数量。如果它们已经相等,我们就简单地退出 while 循环。

辅助方法 get_next_node 用于获取要访问的下一个节点。正是这个方法帮助我们通过表找到从起始节点到最短距离列中的最小值。

整个方法通过返回更新后的表来结束。为了打印表,我们使用以下语句:

    shortest_distance_table = find_shortest_path(graph, table, 'A') 
    for k in sorted(shortest_distance_table): 
        print("{} - {}".format(k,shortest_distance_table[k])) 

前述语句的输出:

>>> A - [0, None] B - [5, 'A'] C - [7, 'B'] D - [7, 'F'] E - [2, 'A'] F - [5, 'E']

为了完整性,让我们找出辅助方法在做什么:

    def get_shortest_distance(table, vertex): 
        shortest_distance = table[vertex][DISTANCE] 
        return shortest_distance 

get_shortest_distance 函数返回我们表中的零(th)索引存储的值。在该索引处,我们始终存储从起始节点到 vertex 的最短距离。set_shortest_distance 函数只通过以下方式设置此值:

    def set_shortest_distance(table, vertex, new_distance): 
        table[vertex][DISTANCE] = new_distance 

当我们更新一个节点的最短距离时,我们使用以下方法来更新其前一个节点:

    def set_previous_node(table, vertex, previous_node): 
        table[vertex][PREVIOUS_NODE] = previous_node 

记住,常数 PREVIOUS_NODE 等于 1。在表中,我们在 table[vertex][PREVIOUS_NODE] 存储前一个节点的值。

为了找到任何两个节点之间的距离,我们使用 get_distance 函数:

    def get_distance(graph, first_vertex, second_vertex): 
        return graph[first_vertex][second_vertex] 

最后一个辅助方法是 get_next_node 函数:

    def get_next_node(table, visited_nodes): 
        unvisited_nodes = 
            list(set(table.keys()).difference(set(visited_nodes))) 
        assumed_min = table[unvisited_nodes[0]][DISTANCE] 
        min_vertex = unvisited_nodes[0] 
        for node in unvisited_nodes: 
            if table[node][DISTANCE] < assumed_min: 
                assumed_min = table[node][DISTANCE] 
                min_vertex = node 

        return min_vertex 

get_next_node 函数类似于一个在列表中查找最小项的函数。

函数首先通过使用 visited_nodes 来获取表中的未访问节点,通过获取两组列表之间的差异。unvisited_nodes 列表中的第一个项目被认为是 table 中最短距离列中最小的一个。如果在 for 循环运行期间找到更小的值,则 min_vertex 将被更新。然后函数将 min_vertex 返回为未访问的顶点或从源点到具有最小最短距离的节点。

Dijkstra 算法的最坏情况运行时间是 O(|E| + |V| log |V|),其中 |V| 是顶点的数量,|E| 是边的数量。

复杂度类别

计算机算法试图解决的问题范围很广,它们的解决方案是通过一系列逻辑步骤得出的。在本节中,我们将讨论 N、NP、NP-完全和 NP-难问题。

P 与 NP

计算机的出现加快了某些任务执行的速度。一般来说,计算机擅长完善计算艺术以及所有可以归结为一系列数学计算的问题。然而,这个说法并不完全正确。有一些自然或问题类别,计算机需要花费大量时间才能做出合理的猜测,更不用说找到正确的解决方案了。

在计算机科学中,计算机可以在多项式时间内通过一系列逻辑步骤解决的问题类别被称为 P 类问题,其中 P 代表多项式。这些相对容易解决。

然后还有另一类被认为非常难以解决的问题。术语“难题”用来指代在寻找解决方案时问题难度增加的方式。然而,好事是尽管这些问题的难度增长速度很快,但可以确定一个提出的解决方案是否在多项式时间内解决了问题。这些都是 NP 类问题。这里的 NP 代表非确定性多项式时间。

现在的百万美元问题就是,N 是否等于 NP?

N = NP 的证明是克莱数学研究所的千禧年大奖难题之一,正确解决这些问题可以获得 100 万美元的奖金。这些问题共有 7 个。

旅行商问题是一个典型的 NP 类问题。问题陈述是这样的:假设某个国家有 n 个城市,找到所有城市之间的最短路线,从而使旅行变得经济高效。当城市数量较少时,这个问题可以在合理的时间内解决。然而,当城市数量超过任何两位数时,计算机所需的时间就非常长了。

许多计算机系统和网络安全都基于 RSA 加密算法。该算法的强度及其安全性是由于它基于整数分解问题,这是一个 NP 类问题。

找出由许多数字组成的质数的质因数是非常困难的。当两个大质数相乘时,得到一个只有两个大质因数的大非质数。这个数的分解是许多加密算法借力的地方:

所有 P 类问题都是 NP 问题的子集。这意味着任何可以在多项式时间内解决的问题也可以在多项式时间内验证。

但问题,P = NP?探讨的是否存在可以在多项式时间内验证的问题也可以在多项式时间内解决。特别是,如果它们相等,这意味着可以通过尝试多种可能的解决方案来解决的问题,可以在不实际尝试所有可能的解决方案的情况下解决,从而不可避免地创造出某种形式的捷径证明。

当最终被发现时,这个证明无疑将在密码学、博弈论、数学以及许多其他领域产生严重影响。

NP-Hard

如果一个问题是 NP-Hard,那么 NP 中的所有其他问题都可以在多项式时间内归约或映射到它。

NP-Complete

如果一个问题首先是一个 NP hard 问题,并且也被发现在NP类中,那么它被认为是一个 NP-complete 问题。

摘要

在最后一章中,我们研究了支持计算机科学领域的理论。没有使用过多的数学严谨性,我们看到了算法被分类的主要类别。该领域的其他设计技术,如分而治之、动态规划和贪心算法,也被讨论,并附有示例实现。

最后,数学领域尚未解决的众多突出问题之一得到了解决。我们看到了 P = NP?的证明如何肯定会在多个领域产生变革,如果这样的证明被发现的话。

第十三章:实施方案、应用和工具

在没有实际应用的情况下学习算法仍然是一种纯粹学术的追求。在本章中,我们将探讨塑造我们世界的各种数据结构和算法。

这个时代的黄金法则之一是数据的丰富性。电子邮件、电话号码、文本和图像文档包含大量数据。在这些数据中找到了有价值的信息,使得数据变得更加重要。但要从原始数据中提取这些信息,我们必须使用专门为此任务设计的数据结构、过程和算法。

机器学习使用大量算法来分析和预测某些变量的发生。仅基于数值分析数据仍然会留下大量潜在信息隐藏在原始数据中。因此,以视觉方式呈现数据使人们能够理解和获得有价值的洞察。

到本章结束时,你应该能够做到以下几件事情:

  • 准确修剪和呈现数据

  • 使用监督学习和无监督学习算法进行预测

  • 以视觉方式呈现数据以获得更多洞察

行业工具

为了继续本章,你需要安装以下包。这些包将用于预处理和视觉呈现正在处理的数据。其中一些包还包含编写良好且经过优化的算法,将在我们的数据上运行。

最好在这些模块中安装虚拟环境,例如pip

% pip install numpy
% pip install scikit-learn
% pip install matplotlib
% pip install pandas
% pip install textblob  

这些包可能需要首先安装其他平台特定的模块。请注意并安装所有依赖项:

  • Numpy:一个具有操作 n 维数组和矩阵的函数库。

  • Scikit-learn:一个高度先进的机器学习模块。它包含大量用于分类、回归和聚类等算法。

  • Matplotlib:这是一个利用 NumPy 绘制各种图表的绘图库,包括线图、直方图、散点图,甚至 3D 图表。

  • Pandas:这个库处理数据操作和分析。

数据预处理

从现实世界收集数据充满了巨大的挑战。收集到的原始数据存在许多问题,以至于我们需要采用方法来净化数据,使其适合进一步研究使用。

为什么处理原始数据?

从现场收集的原始数据充满了人为错误。数据录入是收集数据时的主要错误来源。即使是收集数据的技术方法也难以幸免。设备读取不准确、设备故障和环境因素的变化在收集数据时可以引入显著的误差范围。

收集的数据也可能与其他随时间收集的记录不一致。存在重复条目和不完整记录的存在要求我们以这种方式处理数据,以便挖掘隐藏和埋藏的宝藏。原始数据也可能被无关的数据所掩盖。

为了清理数据,我们可以完全丢弃无关的数据,这通常被称为噪声。具有缺失部分或属性的数据可以用合理的估计值替换。此外,如果原始数据存在不一致性,检测和纠正它们变得必要。

让我们探索如何使用 NumPy 和 pandas 进行数据预处理技术。

缺失数据

数据收集是繁琐的,因此一旦收集了数据,就不应该轻易丢弃。仅仅因为数据集有缺失字段或属性,并不意味着它没有用。可以使用几种方法来填补缺失的部分。这些方法之一是使用全局常数、使用数据集的平均值,或者手动提供数据。选择取决于数据的上下文和使用敏感性。

以以下数据为例:

    import numpy as np 
    data = pandas.DataFrame([ 
        [4., 45., 984.], 
        [np.NAN, np.NAN, 5.], 
        [94., 23., 55.], 
    ]) 

如我们所见,数据元素data[1][0]data[1][1]的值为np.NAN,表示它们没有值。如果给定数据集中的np.NAN值是不希望的,可以将它们设置为某个常数数字。

让我们将值为np.NAN的数据元素设置为 0.1:

    print(data.fillna(0.1)) 

数据的新状态变为以下:

0     1      2
0   4.0  45.0  984.0
1   0.1   0.1    5.0
2  94.0  23.0   55.0

要应用平均值,我们执行以下操作:

    print(data.fillna(data.mean())) 

计算每列的平均值,并将其插入具有np.NAN值的数据区域:

0     1      2
0   4.0  45.0  984.0
1  49.0  34.0    5.0
2  94.0  23.0   55.0

对于第一列,列0,平均值通过(4 + 94)/2获得。得到的49.0随后存储在data[1][0]中。对于列12,执行类似的操作。

特征缩放

数据框中的列被称为其特征。行被称为记录或观察。现在检查以下数据矩阵。这些数据将在子节中引用,请务必注意:

[[  58\.    1\.   43.]
 [  10\.  200\.   65.]
 [  20\.   75\.    7.]]

特征 1,其数据为581020,其值位于1058之间。对于特征 2,数据位于1200之间。如果我们向任何机器学习算法提供这些数据,将会产生不一致的结果。理想情况下,我们需要将数据缩放到一定的范围内,以获得一致的结果。

再次仔细检查,可以发现每个特征(或列)都围绕不同的平均值。因此,我们想要做的是将特征调整到相似的均值附近。

特征缩放的一个好处是它能提升机器学习的学习部分。

scikit模块有相当多的缩放算法,我们将将其应用于我们的数据。

最小-最大标度

归一化的最小-最大标量形式使用均值和标准差将所有数据框定在某个最小值和最大值之间。在大多数情况下,范围被设置为 0 到 1 之间。在其他时候,可能会应用其他范围,但 0 到 1 的范围仍然是默认值:

    scaled_values = preprocessing.MinMaxScaler(feature_range=(0,1)) 
    results = scaled_values.fit(data).transform(data) 
    print(results) 

使用范围(0,1)创建MinMaxScaler类的实例,并将其传递给变量scaled_values。调用fit函数进行必要的计算,这些计算将用于内部更改数据集。transform函数对数据集进行实际操作,并将值返回到results

[[ 1\.          0\.          0.62068966]
 [ 0\.          1\.          1\.        ]
 [ 0.20833333  0.3718593   0\.        ]]

从前面的输出中我们可以看到,所有数据都进行了归一化,并且位于 0 和 1 之间。这种输出现在可以被提供给机器学习算法。

标准标量

我们初始数据集或表中相应特征的均值分别是 29.3、92 和 38。为了使所有数据具有相似的均值,即数据的均值为零且方差为 1,我们将应用标准标量算法:

    stand_scalar =  preprocessing.StandardScaler().fit(data) 
    results = stand_scalar.transform(data) 
    print(results)

data传递给从实例化StandardScaler类返回的对象的fit方法。transform方法作用于数据中的数据元素,并将输出返回到结果:

[[ 1.38637564 -1.10805456  0.19519899]
 [-0.93499753  1.31505377  1.11542277]
 [-0.45137812 -0.2069992  -1.31062176]]

检查结果,我们发现所有特征现在分布得都很均匀。

数据二值化

为了对给定的特征集进行二值化,我们使用一个阈值。如果给定数据集中的任何值大于阈值,则该值将被替换为 1。如果值小于阈值 1,我们将将其替换:

    results = preprocessing.Binarizer(50.0).fit(data).transform(data) 
    print(results) 

使用参数 50.0 创建Binarizer实例。50.0 是二值化算法中将使用的阈值:

[[ 1\.  0\.  0.]
 [ 0\.  1\.  1.]
 [ 0\.  1\.  0.]] 

数据中所有小于 50 的值将用 0 代替。相反的情况也成立。

机器学习

机器学习是人工智能的一个子领域。我们知道我们永远无法真正创造出能够“思考”的机器,但我们可以通过提供足够的数据和模型来让机器做出合理的判断。机器学习专注于创建自主系统,这些系统能够继续决策过程,几乎不需要或不需要人为干预。

为了教会机器,我们需要从现实世界中抽取数据。例如,为了区分哪些电子邮件是垃圾邮件,哪些不是,我们需要向机器提供每种类型的样本。在获得这些数据后,我们必须将这些数据通过使用概率和统计来挖掘数据中的模式和结构的模型(算法)。如果这样做得当,算法本身将能够分析电子邮件并将它们正确分类。对电子邮件进行分类只是机器“训练”后可以做到的一件事的例子。

机器学习的类型

机器学习大致可以分为三个主要类别,如下所示:

  • 监督学习:在这里,算法被喂入一组输入及其相应的输出。然后,算法必须确定对未知输入的输出将会是什么。此类算法的例子包括朴素贝叶斯、线性回归和决策树算法。

  • 无监督学习:不使用一组输入和输出变量之间存在的关联,无监督学习算法仅使用输入来挖掘数据中的组、模式和聚类。此类算法的例子包括层次聚类和 k-means 聚类。

  • 强化学习:在这种学习中,计算机与环境动态交互,以改善其性能。

Hello 分类器

为了在理解机器学习的道路上祈求编程神灵的祝福,我们从一个文本分类器的 hello world 示例开始。这旨在对机器学习进行温和的介绍。

此示例将预测给定文本是否带有负面或正面含义。在这样做之前,我们需要用一些数据来训练我们的算法(模型)。

朴素贝叶斯模型适用于文本分类目的。基于朴素贝叶斯模型的算法通常运行速度快,结果准确。整个模型基于这样一个假设:特征之间相互独立。为了准确预测降雨的发生,需要考虑三个条件。这些条件是风速、温度和空气中的湿度量。在现实中,这些因素确实相互影响,以判断降雨的可能性。但朴素贝叶斯中的抽象假设是这些特征在某种程度上是无关的,因此独立地贡献于降雨的可能性。朴素贝叶斯在预测未知数据集的类别方面很有用,我们很快就会看到。

现在回到我们的 hello 分类器。在我们训练了我们的模型之后,其预测结果将落入正类或负类:

    from textblob.classifiers import NaiveBayesClassifier 
    train = [ 
        ('I love this sandwich.', 'pos'), 
        ('This is an amazing shop!', 'pos'), 
        ('We feel very good about these beers.', 'pos'), 
        ('That is my best sword.', 'pos'), 
        ('This is an awesome post', 'pos'), 
        ('I do not like this cafe', 'neg'), 
        ('I am tired of this bed.', 'neg'), 
        ("I can't deal with this", 'neg'), 
        ('She is my sworn enemy!', 'neg'), 
        ('I never had a caring mom.', 'neg') 
    ] 

首先,我们将从textblob包中导入NaiveBayesClassifier类。这个分类器非常易于使用,并且基于贝叶斯定理。

train变量由元组组成,每个元组都包含实际的训练数据。每个元组包含一个句子和与之相关的组。

现在,为了训练我们的模型,我们将通过传递 train 给它来实例化一个NaiveBayesClassifier对象:

    cl = NaiveBayesClassifier(train) 

更新的朴素贝叶斯模型cl将预测未知句子所属的类别。到目前为止,我们的模型只知道短语可以属于两个类别,negpos

以下代码使用我们的模型运行以下测试:

    print(cl.classify("I just love breakfast")) 
    print(cl.classify("Yesterday was Sunday")) 
    print(cl.classify("Why can't he pay my bills")) 
    print(cl.classify("They want to kill the president of Bantu")) 

我们的测试输出如下:

pos 
pos 
neg 
neg 

我们可以看到,该算法在将输入短语分类到其类别方面已经取得了一定的成功。

这个人为的例子过于简单,但它确实显示出,如果提供正确数量的数据和合适的算法或模型,机器可以在没有任何人类帮助的情况下执行任务。

专门的类NaiveBayesClassifier也在后台为我们做了很多工作,所以我们无法欣赏算法到达各种预测的内部机制。我们的下一个示例将使用scikit模块来预测一个短语可能属于的类别。

一个监督学习示例

假设我们有一组要分类的帖子。与监督学习一样,我们需要首先训练模型,以便它能够准确预测未知帖子的类别。

收集数据

scikit模块附带了一些样本数据,我们将使用这些数据来训练我们的模型。在这种情况下,我们将使用新闻组帖子。为了加载帖子,我们将使用以下代码行:

    from sklearn.datasets import fetch_20newsgroups 
    training_data = fetch_20newsgroups(subset='train',     
        categories=categories, shuffle=True, random_state=42) 

在我们训练好模型后,预测结果必须属于以下类别之一:

    categories = ['alt.atheism', 
                  'soc.religion.christian','comp.graphics', 'sci.med'] 

我们将要使用的训练数据记录数如下:

    print(len(training_data)) 

机器学习算法与文本属性混合得不好,因此每个帖子所属的类别以数字形式呈现:

    print(set(training_data.target)) 

类别具有整数值,我们可以使用print(training_data.target_names[0])将其映射回类别本身。

这里,0 是从set(training_data.target)中随机选择的数值索引。

现在训练数据已经获得,我们必须将数据输入到机器学习算法中。词袋模型将分解训练数据,以便为学习算法或模型做好准备。

词袋

词袋是一个模型,用于以这种方式表示文本数据,它不考虑单词的顺序,而是使用单词计数将单词分割成区域。

考虑以下句子:

    sentence_1 = "As fit as a fiddle"
    sentence_2 = "As you like it"

词袋模型使我们能够将文本分解成由矩阵表示的数值特征向量。

为了将我们的两个句子简化为词袋模型,我们需要获得所有单词的唯一列表:

    set((sentence_1 + sentence_2).split(" ")) 

这个集合将成为矩阵的列。矩阵的行将代表用于训练的文档。行和列的交集将存储单词在文档中出现的次数。以我们的两个句子为例,我们得到以下矩阵:

As Fit A Fiddle You Like it
句子 1 2 1 1 1 0 0 0
句子 2 1 0 0 0 1 1 1

仅凭前面的数据,我们无法准确预测新文档或文章将属于哪个类别。表格本身存在一些固有的缺陷。可能存在这样的情况,较长的文档或出现在许多帖子中的单词会降低算法的精确度。可以通过移除停用词来确保只分析相关数据。停用词包括 is、are、was 等。由于词袋模型在分析中不考虑语法,因此可以安全地删除停用词。还可能需要将一些认为应该从最终分析中豁免的词添加到停用词列表中。

为了生成进入矩阵列的值,我们必须对训练数据进行分词:

    from sklearn.feature_extraction.text import CountVectorizer 
    from sklearn.feature_extraction.text import TfidfTransformer 
    from sklearn.naive_bayes import MultinomialNB 
    count_vect = CountVectorizer() 
    training_matrix = count_vect.fit_transform(training_data.data) 

training_matrix的维度为(2257,35788)。这意味着 2257 对应于数据集,而 35788 对应于构成所有帖子中唯一单词集合的列数。

我们实例化了CountVectorizer类,并将training_data.data传递给count_vect对象的fit_transform方法。结果存储在training_matrix中。training_matrix包含所有唯一的单词及其相应的频率。

为了减轻仅基于频率计数进行预测的问题,我们将导入TfidfTransformer,它有助于平滑我们数据中的不准确度:

    matrix_transformer = TfidfTransformer() 
    tfidf_data = matrix_transformer.fit_transform(training_matrix) 

    print(tfidf_data[1:4].todense()) 

tfidf_data[1:4].todense()仅显示一个 3 行 35,788 列矩阵的截断列表。所看到的值是词频-逆文档频率,它减少了使用频率计数带来的不准确度:

    model = MultinomialNB().fit(tfidf_data, training_data.target) 

MultinomialNB是朴素贝叶斯模型的一个变体。我们将合理化的数据矩阵tfidf_data和类别training_data.target传递给其fit方法。

预测

为了测试我们的模型是否已经学习到足够多的知识来预测一个未知帖子可能属于的类别,我们有以下样本数据:

    test_data = ["My God is good", "Arm chip set will rival intel"] 
    test_counts = count_vect.transform(test_data) 
    new_tfidf = matrix_transformer.transform(test_counts) 

test_data列表传递给count_vect.transform函数以获得测试数据的向量形式。为了获得测试数据集的词频-逆文档频率表示,我们调用matrix_transformer对象的transform方法。

为了预测文档可能属于哪个类别,我们执行以下操作:

    prediction = model.predict(new_tfidf)  

循环用于遍历预测,显示它们预测属于的类别:

    for doc, category in zip(test_data, prediction): 
        print('%r => %s' % (doc, training_data.target_names[category])) 

当循环运行完成后,将显示短语及其可能属于的类别。一个示例输出如下:

'My God is good' => soc.religion.christian
'Arm chip set will rival intel' => comp.graphics

到目前为止我们所看到的一切都是监督学习的典范。我们首先加载了已知类别的帖子。然后,根据朴素贝叶斯定理,将这些帖子输入到最适合文本处理的机器学习算法中。向模型提供了一组测试帖子片段,并预测了类别。

为了探索无监督学习算法的例子,我们将研究 k-means 算法对某些数据进行聚类。

一个无监督学习示例

一种学习算法类别能够发现数据集中可能存在的固有分组。这些算法的例子是 k-means 算法。

K-means 算法

k-means 算法使用给定数据集中的均值点来聚类并发现数据集中的分组。K 是我们想要并希望发现的簇的数量。在 k-means 算法生成分组后,我们可以传递额外的未知数据给它,以预测它将属于哪个组。

注意,在这种算法中,只有未经分类的原始数据被输入到算法中。算法需要自行判断数据中是否存在固有的分组。

为了理解这个算法是如何工作的,我们将检查由 x 和 y 值组成的 100 个数据点。我们将将这些值输入到学习算法中,并期望算法将数据聚类成两组。我们将对这两组进行着色,以便簇是可见的。

让我们创建一个包含 100 个xy对的样本数据:

    import numpy as np 
    import matplotlib.pyplot as plt 
    original_set = -2 * np.random.rand(100, 2) 
    second_set = 1 + 2 * np.random.rand(50, 2) 
    original_set[50: 100, :] = second_set 

首先,我们创建 100 条记录,使用-2 * np.random.rand(100, 2)。在每条记录中,我们将使用其中的数据来表示最终将被绘制的 x 和 y 值。

original_set中的最后 50 个数字将被替换为1 + 2 * np.random.rand(50, 2)。实际上,我们所做的是创建了两个数据子集,其中一个子集包含负数,而另一个子集包含正数。现在,算法有责任适当地发现这些段。

我们实例化了KMeans算法类,并传递了n_clusters=2。这使得算法将所有数据仅聚类到两个组中。这个数字2是通过一系列的试错得到的。但在学术目的上,我们已知这个数字。当处理来自现实世界的不熟悉数据集时,这一点并不明显:

    from sklearn.cluster import KMeans 
    kmean = KMeans(n_clusters=2) 

    kmean.fit(original_set) 

    print(kmean.cluster_centers_) 

    print(kmean.labels_) 

数据集被传递到kmeanfit函数中,即kmean.fit(original_set)。算法生成的簇将围绕某个均值点旋转。定义这两个均值点的点是kmean.cluster_centers_

打印出的均值点如下所示:

[[ 2.03838197  2.06567568]
 [-0.89358725 -0.84121101]]

在我们的 k-means 算法完成训练后,original_set中的每个数据点都将属于一个簇。k-means 算法将其发现的两个簇表示为 1 和 0。如果我们要求算法将数据聚类成四个簇,这些簇的内部表示将是 0、1、2 和 3。为了打印出每个数据集所属的各种簇,我们执行以下操作:

    print(kmean.labels_) 

这给出了以下输出:

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

有 100 个 1 和 0。每个数字表示每个数据点所属的簇。通过使用matplotlib.pyplot,我们可以绘制每个组的点并适当地着色以显示簇:

    import matplotlib.pyplot as plt 
    for i in set(kmean.labels_): 
        index = kmean.labels_ == i 
        plt.plot(original_set[index,0], original_set[index,1], 'o') 

index = kmean.labels_ == i 是一种巧妙的方法,通过它我们可以选择所有对应于群体i的点。当i=0时,所有属于群体 0 的点都会返回到索引中。对于index =1, 2 ... 等等,情况相同。

plt.plot(original_set[index,0], original_set[index,1], 'o') 然后使用“o”字符绘制这些数据点。

接下来,我们将绘制形成簇的质心或均值:

    plt.plot(kmean.cluster_centers_[0][0],kmean.cluster_centers_[0][1], 
             '*', c='r', ms=10) 
    plt.plot(kmean.cluster_centers_[1][0],kmean.cluster_centers_[1][1], 
             '*', c='r', ms=10) 

最后,我们用星号标示两个均值,展示整个图表:

    plt.show()

算法在我们的样本数据中发现了两个不同的簇。两个簇的均值用红色星号符号表示。

预测

对于我们获得的两个簇,我们可以预测一组新数据可能属于哪个群体。

让我们预测点[[-1.4, -1.4]][[2.5, 2.5]]将属于哪个群体:

    sample = np.array([[-1.4, -1.4]]) 
    print(kmean.predict(sample)) 

    another_sample = np.array([[2.5, 2.5]]) 
    print(kmean.predict(another_sample)) 

输出如下所示:

[1]
[0] 

至少,我们可以期待两个测试数据集属于不同的簇。当print语句打印出 1 和 0 时,我们的预期得到了证实,从而确认我们的测试数据确实属于两个不同的簇。

数据可视化

数值分析并不总是容易理解。确实,一张图片胜过千言万语,在本节中,一张只包含数字的表格可能就相当于一千张表格。图片提供了一种快速分析数据的方法。大小和长度的差异是图像中的快速标记,可以根据这些标记得出结论。在本节中,我们将游览不同的数据表示方法。除了这里列出的图表之外,在数据交流中还可以实现更多。

条形图

要将值 25、5、150 和 100 绘制成条形图,我们将值存储在数组中,并将其传递给bar函数。图中的条形代表y轴上的幅度:

    import matplotlib.pyplot as plt 

    data = [25., 5., 150., 100.] 
    x_values = range(len(data)) 
    plt.bar(x_values, data) 

    plt.show() 

x_values存储由range(len(data))生成的值数组。x_values还将确定条形将在x轴上的哪些点绘制。第一个条形将在x为 0 的x轴上绘制。数据为 5 的第二个条形将在x为 1 的x轴上绘制:

每个条形的宽度可以通过修改以下行来改变:

    plt.bar(x_values, data, width=1.)  

这应该产生以下图表:

然而,这看起来并不美观,因为条形之间没有空间了,这使得它看起来很笨拙。每个条形现在占据了x轴上的一个单位。

多条形图

在尝试可视化数据时,堆叠多个条形可以让人进一步理解一个数据点或变量如何与另一个变量变化:

    data = [ 
            [8., 57., 22., 10.], 
            [16., 7., 32., 40.],
           ] 

    import numpy as np 
    x_values = np.arange(4) 
    plt.bar(x_values + 0.00, data[0], color='r', width=0.30) 
    plt.bar(x_values + 0.30, data[1], color='y', width=0.30) 

    plt.show() 

第一批数据的 y 值为[8., 57., 22., 10.]。第二批是[16., 7., 32., 40.]。当条形绘制时,8 和 16 将占据相同的 x 位置,并排在一起。

x_values = np.arange(4) 生成包含值 [0, 1, 2, 3] 的数组。第一组条形首先在位置 x_values + 0.30 绘制。因此,第一个 x 值将在 0.00, 1.00, 2.00 和 3.00 处绘制。

第二批 x_values 将在 0.30, 1.30, 2.30 和 3.30 处绘制:

箱线图

箱线图用于可视化分布的中位数值以及低和高范围。它也被称为箱线和触须图。

让我们绘制一个简单的箱线图。

我们首先从正态分布中生成 50 个数字。然后,将这些数字传递给 plt.boxplot(data) 以进行图表绘制:

    import numpy as np 
    import matplotlib.pyplot as plt 

    data = np.random.randn(50) 

    plt.boxplot(data) 
    plt.show() 

下图是生成的结果:

对前面的图进行一些评论:箱线图的特征包括一个跨越四分位距的箱体,它衡量分散程度;数据的外围边缘由连接到中央箱体的触须表示;红色线代表中位数。

箱线图有助于轻松识别数据集中的异常值以及确定数据集可能偏斜的方向。

饼图

饼图将数据解释并视觉上呈现为似乎适合圆圈。各个数据点表示为圆的扇区,总和为 360 度。此图表适用于显示分类数据和摘要:

    import matplotlib.pyplot as plt 
    data = [500, 200, 250] 

    labels = ["Agriculture", "Aide", "News"] 

    plt.pie(data, labels=labels,autopct='%1.1f%%') 
    plt.show() 

图表中的各个部分用标签数组中的字符串标记:

气泡图

散点图的另一种变体是气泡图。在散点图中,我们只绘制数据的 x,y 点。气泡图通过说明点的尺寸增加了另一个维度。这个第三维度可能代表市场的大小甚至利润:

    import numpy as np 
    import matplotlib.pyplot as plt 

    n = 10 
    x = np.random.rand(n) 
    y = np.random.rand(n) 
    colors = np.random.rand(n) 
    area = np.pi * (60 * np.random.rand(n))**2 

    plt.scatter(x, y, s=area, c=colors, alpha=0.5) 
    plt.show() 

使用变量 n,我们指定随机生成的 x 和 y 值的数量。这个相同的数字用于确定 x 和 y 坐标的随机颜色。随机气泡大小由 area = np.pi * (60 * np.random.rand(n))**2 确定。

下图显示了此气泡图:

摘要

在本章中,我们探讨了数据和算法如何结合以帮助机器学习。通过首先通过归一化过程修剪我们的数据,我们才能理解大量数据。将此数据输入专门的算法,我们能够预测数据将落入的类别和集合。

最后,绘制和图表化浓缩数据有助于更好地理解和做出有洞察力的发现。

posted @ 2025-09-21 12:13  绝不原创的飞龙  阅读(51)  评论(0)    收藏  举报