Python-数据结构和算法实用指南-全-

Python 数据结构和算法实用指南(全)

原文:zh.annas-archive.org/md5/66ae3d5970b9b38c5ad770b42fec806d

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

数据结构和算法是信息技术和计算机科学工程学习中最重要的核心学科之一。本书旨在提供数据结构和算法的深入知识,以及编程实现经验。它专为初学者和中级水平的研究 Python 编程的研究生和本科生设计,并通过示例解释复杂的算法。

在这本书中,您将学习基本的 Python 数据结构和最常见的算法。本书将提供 Python 的基本知识,并让读者深入了解数据算法。在书中,我们提供 Python 实现,并解释它们与几乎每个重要和流行的数据结构算法的关系。我们将研究提供数据分析中最常见问题的解决方案的算法,包括搜索和排序数据,以及能够从数据中提取重要统计信息。通过这本易于阅读的书,您将学习如何创建复杂的数据结构,如链表、栈、堆和队列,以及排序算法,包括冒泡排序、插入排序、堆排序和快速排序。我们还描述了各种选择算法,包括随机选择和确定性选择。我们详细讨论了各种数据结构算法和设计范例,如贪婪算法、分治算法和动态规划,以及它们如何在实时应用中使用。此外,我们使用直观的图示例解释了树和图等复杂数据结构的概念。您还将学习各种重要的字符串处理和模式匹配算法,如 KMP 和 Boyer-Moore 算法,以及它们在 Python 中的简单实现。您将学习在预处理、建模和转换数据等任务中使用的常见技术和结构。

拥有对数据结构和算法的深入理解的重要性不言而喻。这是一个重要的武器库,可以帮助您理解新问题并找到优雅的解决方案。通过更深入地了解算法和数据结构,您可能会发现它们的用途远远超出最初的意图。您将开始考虑您编写的代码以及它对内存量的影响。Python 进一步打开了许多专业人士和学生欣赏编程的大门。这种语言很有趣,而且在描述问题时非常简洁。我们利用这种语言的大众吸引力来研究许多广泛研究和标准化的数据结构和算法。本书以简洁地介绍 Python 编程语言开始。因此,在阅读本书之前并不需要您了解 Python。

本书的读者对象

本书适用于正在学习初级或中级数据结构和算法课程的 Python 开发人员。本书还适用于所有那些参加或曾参加数据结构和算法课程的本科和研究生工程学生,因为它涵盖了几乎所有在这门课程中学习的算法、概念和设计。因此,本书也可以作为数据结构和算法课程的教材。本书还是一种对于希望使用特定数据结构部署各种应用程序的通用软件开发人员的有用工具,因为它提供了存储相关数据的有效方式。它还提供了学习复杂算法的实用和简单的方法。

假设读者具有一些 Python 的基本知识。但是,这并不是强制性的,因为本书在快速概述 Python 及其面向对象的概念。本书不需要读者具有任何与计算机相关的概念的先验知识,因为所有的概念和算法都有足够详细的解释,配有大量的例子和图示。大多数概念都是通过日常场景来解释,以便更容易理解概念和算法。

充分利用本书

  1. 本书中的代码需要在 Python 3.7 或更高版本上运行。

  2. Python 交互环境也可以用来运行代码片段。

  3. 建议读者通过执行本书中提供的代码来学习算法和概念,以便更好地理解算法。

  4. 本书旨在给读者提供实际的经验,因此建议您为所有的算法进行编程,以便充分利用本书。

下载示例代码文件

您可以从您在www.packt.com的账户中下载本书的示例代码文件。如果您在其他地方购买了本书,您可以访问www.packt.com/support并注册,文件将直接发送到您的邮箱。

您可以按照以下步骤下载代码文件:

  1. www.packt.com上登录或注册。

  2. 选择“支持”选项卡。

  3. 点击“下载代码和勘误”。

  4. 在搜索框中输入书名,然后按照屏幕上的说明操作。

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

  • WinRAR/7-Zip for Windows

  • Zipeg/iZip/UnRarX for Mac

  • 7-Zip/PeaZip for Linux

本书的代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Hands-On-Data-Structures-and-Algorithms-with-Python-Second-Edition。如果代码有更新,将在现有的 GitHub 存储库上进行更新。

我们还提供了来自我们丰富书籍和视频目录的其他代码包,可在github.com/PacktPublishing/上找到。去看看吧!

下载彩色图片

我们还提供了一个 PDF 文件,其中包含本书中使用的屏幕截图/图表的彩色图片。您可以在这里下载:www.packtpub.com/sites/default/files/downloads/9781788995573_ColorImages.pdf

使用的约定

本书中使用了许多文本约定。

CodeInText:表示文本中的代码词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 用户名。这是一个例子:“我们实例化CountVectorizer类,并将training_data.data传递给count_vect对象的fit_transform方法。”

代码块设置如下:

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

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

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()

任何命令行输入或输出都以以下形式书写:

0     1      2
0   4.0  45.0  984.0
1   0.1   0.1    5.0
2  94.0  23.0   55.0

粗体:表示一个新术语、一个重要词或屏幕上看到的词。

警告或重要提示会以这种形式出现。提示和技巧会以这种形式出现。

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

数据结构和算法是一个大型复杂软件项目的核心要素之一。它们是一种系统化的方式,用于在软件中存储和组织数据,以便能够高效地使用。Python 具有高效的高级数据结构和有效的面向对象编程语言。Python 是许多高级数据任务的首选语言,原因很充分。它是最容易学习的高级编程语言之一。直观的结构和语义意味着对于那些不是计算机科学家,但可能是生物学家、统计学家或初创公司的负责人来说,Python 是执行各种数据任务的简单方式。它不仅仅是一种脚本语言,而是一种功能齐全的面向对象的编程语言。

在 Python 中,有许多有用的数据结构和算法内置在语言中。此外,由于 Python 是一种基于对象的语言,相对容易创建自定义数据对象。在本书中,我们将研究 Python 的内部库和一些外部库,并学习如何从头开始构建自己的数据对象。

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

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

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

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

技术要求

本书使用 Python 编程语言(版本 3.7)介绍数据结构和算法。本书假设您已经了解 Python。但是,如果您有点生疏,来自其他语言,或者根本不了解 Python,不用担心 - 这一章应该能让您迅速掌握。

以下是 GitHub 链接:github.com/PacktPublishing/Hands-On-Data-Structures-and-Algorithms-with-Python-Second-Edition/tree/master/Chapter01

如果您对 Python 不熟悉,请访问docs.python.org/3/tutorial/index.html,您也可以在www.python.org/doc/找到文档。这些都是很好的资源,可以轻松学习这种编程语言。

安装 Python

要安装 Python,我们使用以下方法。

Python 是一种解释性语言,语句是逐行执行的。程序员通常可以将一系列命令写在源代码文件中。对于 Python,源代码存储在一个带有.py文件扩展名的文件中。

Python 通常已经完全集成并安装在大多数 Linux 和 Mac 操作系统上。通常,预安装的 Python 版本是 2.7。您可以使用以下命令检查系统上安装的版本:

>>> import sys
>>> print(sys.version)
3.7.0 (v3.7.0:1bf9cc5093, Jun 27 2018, 04:06:47) [MSC v.1914 32 bit (Intel)]

您还可以使用以下命令在 Linux 上安装不同版本的 Python:

  1. 打开终端

  2. sudo apt-get update

  3. sudo apt-get install -y python3-pip

  4. pip3 install <package_name>

Python 必须安装在 Windows 操作系统的系统上,因为它不像 Linux/macOS 那样预安装。可以从此链接下载 Python 的任何版本:www.python.org/downloads/。您可以下载软件安装程序并运行它 - 选择为所有用户安装,然后单击下一步。您需要指定要安装软件包的位置,然后单击下一步。之后,在自定义 Python 对话框中选择将 Python 添加到环境变量的选项,然后再次单击下一步进行最终安装。安装完成后,您可以通过打开命令提示符并输入以下命令来确认安装:

python -V

最新的稳定 Python 版本是 Python 3.7.0。可以通过在命令行中输入以下内容来执行 Python 程序:

python <sourcecode_filename>.py

理解数据结构和算法

算法和数据结构是计算机中最基本的概念。它们是构建复杂软件的主要构建模块。理解这些基础概念在软件设计中是非常重要的,这涉及以下三个特征:

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

    1. 数据在内存中的排列方式
    1. 特定数据结构的性能特征是什么

在这本书中,我们将从几个角度来审视这个话题。首先,我们将从数据结构和算法的角度来看 Python 编程语言的基础知识。其次,重要的是我们要有正确的数学工具。我们需要理解计算机科学的基本概念,为此我们需要数学。通过采取一种启发式的方法,制定一些指导原则意味着,一般来说,我们不需要比高中数学更多的知识来理解这些关键思想的原则。

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

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

为了更好地理解算法思维的重要性,让我们考虑一个现实世界的例子。假设我们在一个陌生的市场,我们被要求购买一些物品。我们假设市场是随机布局的,每个供应商销售一个随机子集的物品,其中一些物品可能在我们的清单上。我们的目标是尽量减少每个购买物品的价格,同时最小化在市场上花费的时间。解决这个问题的一种方法是编写以下类似的算法:

  1. 供应商是否有我们清单上的物品,且成本低于该物品的预测成本?

  2. 如果是,购买并从清单中删除;如果不是,继续下一个供应商。

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

  4. 如果我们必须使用编程语言来实现这个简单的迭代器,我们需要数据结构来定义和存储我们想要购买的物品清单和供应商正在销售的物品清单。我们需要确定最佳的匹配物品的方式,并且我们需要一些逻辑来决定是否购买。

关于这个算法,我们可以做出几点观察。首先,由于成本计算是基于预测的,我们不知道真实成本是多少。因此,我们不会购买物品,因为我们低估了物品的成本,导致我们在市场结束时仍有剩余物品。为了处理这种情况,我们需要一种有效的方式来存储数据,以便我们可以有效地回溯到成本最低的供应商。

此外,我们需要了解比较我们购物清单上的物品与每个供应商出售的物品所花费的时间。这很重要,因为随着我们购物清单上物品的数量或每个供应商出售的物品数量的增加,搜索物品需要更多的时间。我们搜索物品的顺序和数据结构的形状可以对搜索所需的时间产生很大的影响。显然,我们希望安排我们的清单以及我们访问每个供应商的顺序,以便最小化搜索时间。

此外,考虑一下当我们将购买条件更改为以最便宜的价格购买,而不仅仅是低于平均预测价格时会发生什么。这会完全改变问题。我们不再是顺序地从一个供应商到另一个供应商,而是需要遍历市场一次,并且有了这个知识,我们可以根据我们想要访问的供应商对我们的购物清单进行排序。

显然,将现实世界的问题转化为编程语言这样的抽象构造涉及许多微妙之处。例如,随着我们在市场上的进展,我们对产品成本的了解会提高,因此我们预测的平均价格变量会变得更加准确,直到在最后一个摊位,我们对市场的了解是完美的。假设任何形式的回溯算法都会产生成本,我们可以看到有理由重新审视整个策略。高价格波动、数据结构的大小和形状,以及回溯的成本等条件都决定了最合适的解决方案。整个讨论清楚地表明了数据结构和算法在构建复杂解决方案中的重要性。

Python 用于数据

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

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

Python 环境

由于其可读性和灵活性,Python 是全球最受欢迎和广泛使用的编程语言之一。Python 环境的一个特点是其交互式控制台,允许您将 Python 用作桌面可编程计算器,也可以用作编写和测试代码片段的环境。

控制台的读取...评估...打印循环是与更大代码库交互的非常方便的方式,比如运行函数和方法或创建类的实例。这是 Python 相对于编译语言(如 C/C++或 Java)的主要优势之一,后者的编写...编译...测试...重新编译循环与 Python 的读取...评估...打印循环相比,可以大大增加开发时间。能够输入表达式并立即得到响应可以大大加快数据科学任务的速度。

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

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

变量和表达式

要通过算法实现解决现实世界的问题,我们首先必须选择变量,然后对这些变量应用操作。变量是附加到对象的标签。变量不是对象,也不是对象的容器;它们只是作为对象的指针或引用。例如,考虑以下代码:

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

在 Python 中,变量名在程序执行期间附加到不同的数据类型;不需要首先声明变量的数据类型。每个值都有一个类型(例如字符串或整数);然而,指向这个值的变量名没有特定的类型。更具体地说,变量指向一个对象,可以根据分配给它们的值的类型而改变它们的类型。考虑以下例子:

在前面的代码示例中,a的类型从int变为float,具体取决于变量中存储的值。

变量作用域

函数内部变量的作用域规则很重要。每当函数执行时,都会创建一个局部环境(命名空间)。这个局部命名空间包含所有由函数分配的变量和参数名。每当调用函数时,Python 解释器首先查找函数本身的局部命名空间——如果找不到匹配项,然后查找全局命名空间。如果名称仍然找不到,那么它会在内置命名空间中搜索。如果还是找不到,解释器会引发NameError异常。考虑以下代码:

a=15;b=25
def my_function():
  global a 
  a=11;b=21

my_function() 
print(a)  #prints 11 
print(b)  #prints 25

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

此外,让我们考虑另一个有趣的例子:

>>> a = 10
>>> def my_function():
...     print(a)
>>> my_function ()
10

代码可以正常工作,并输出10,但看看下面的代码:

>>> a = 10 
>>> def my_function():
...     print(a)
...     a= a+1 
>>> my_function()
 UnboundLocalError: local variable 'a' referenced before assignment

前面的代码出错了,因为在作用域内对变量进行赋值会使该变量成为该作用域的局部变量。在前面的例子中,在my_function()中对变量a进行赋值,编译器会将a视为局部变量,这就是为什么之前的print()函数尝试打印一个未初始化的局部变量a,从而导致错误。可以通过声明为global来访问外部作用域变量来解决这个问题:

>>> a = 10
>>> def my_function():
...     global a
...     print(a)
...     a = a+1
>>> my_function()
10

因此,在 Python 中,函数内部引用的变量隐式地是全局的,如果a变量在函数体内的任何地方被赋值,它会被假定为局部变量,除非显式声明为全局变量。

流程控制和迭代

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

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

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

#prints'Something else'

请注意使用==运算符来比较两个值。如果两个值相等,则返回True;否则返回False。还要注意,将x设置为字符串将返回Something else,而不会像在静态类型的语言中那样生成类型错误。动态类型的语言,如 Python,允许对具有不同类型的对象进行灵活赋值。

控制程序流的另一种方法是使用循环。Python 提供了两种构建循环的方式,如whilefor循环语句。while循环重复执行语句,直到布尔条件为真。for循环提供了一种通过一系列元素重复执行循环的方法。下面是一个例子:

在这个例子中,while循环执行语句,直到条件x < 3为真。让我们考虑另一个使用for循环的例子:

>>>words = ['cat', 'dog', 'elephant']
>>> for w in words:
...     print(w)
... 
cat
dog
elephant

在这个例子中,for循环执行对列表中所有项目的迭代。

数据类型和对象概述

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

Python 中的所有数据类型都是对象。实际上,在 Python 中几乎所有的东西都是对象,包括模块、类和函数,以及字面量,如字符串和整数。Python 中的每个对象都有一个类型、一个和一个标识。当我们写greet= "helloworld"时,我们创建了一个字符串对象的实例,其值为"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()等方法,可以改变对象的值。不可变对象如字符串不能改变其值,因此当我们运行它们的方法时,它们只是返回一个值,而不是改变底层对象的值。当然,我们可以通过将其分配给一个变量或将其用作函数中的参数来使用这个值。例如,int类是不可变的——一旦创建了它的实例,它的值就不能改变,但是,引用这个对象的标识符可以被重新分配另一个值。

字符串

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

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

方法 描述
s.capitalize 返回只有第一个字符大写的字符串,其余字符保持小写。
s.count(substring,[start,end]) 计算子字符串的出现次数。
s.expandtabs([tabsize]) 用空格替换制表符。
s.endswith(substring,[start, end] 如果字符串以指定的子字符串结尾,则返回True
s.find(substring,[start,end]) 返回子字符串第一次出现的索引。
s.isalnum() 如果字符串s中所有字符都是字母数字,则返回True
s.isalpha() 如果字符串s中所有字符都是字母,则返回True
s.isdigit() 如果字符串中所有字符都是数字,则返回True
s.split([separator],[maxsplit]) 以空格或可选分隔符分割字符串。返回一个列表。
s.join(t) 连接序列t中的字符串。
s.lower() 将字符串转换为全小写。
s.replace(old, new[maxreplace]) 用新的子字符串替换旧的子字符串。
s.startswith(substring, [start, end]]) 如果字符串以指定的子字符串开头,则返回True
s.swapcase() 返回字符串中交换大小写的副本。
s.strip([characters]) 移除空格或可选字符。
s.lstrip([characters]) 返回删除前导字符的字符串副本。

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

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

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

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

鉴于字符串是不可变的,一个常见的问题是如何执行插入值等操作。我们需要想办法为我们需要的结果构建新的字符串对象,而不是改变一个字符串。例如,如果我们想要在问候语中插入一个单词,我们可以将一个变量赋值给以下内容:

正如这段代码所示,我们使用切片操作符在索引位置5处拆分字符串,并使用+进行连接。Python 从不将字符串的内容解释为数字。如果我们需要对字符串执行数学运算,我们需要先将它们转换为数字类型:

列表

列表是最常用的内置数据结构之一,因为它们可以存储任意数量的不同数据类型。它们是对象的简单表示,并且由整数索引,从零开始,如我们在字符串中看到的那样。

下表包含了最常用的列表方法及其描述:

方法 描述
list(s) 返回序列s的列表。
s.append(x) 在列表s的末尾添加元素x
s.extend(x) 在列表s的末尾添加列表x
s.count(x) 返回列表sx出现的次数。
s.index(x,[start],[stop]) 返回最小的索引i,其中s[i]==x。我们可以为查找包括可选的开始和结束索引。
s.insert(i,e) 在索引i处插入x
s.pop(i) 返回列表s中的元素i并将其移除。
s.remove(x) 从列表s中移除元素x
s.reverse() 颠倒列表s的顺序。
s.sort(key,[reverse]) 用可选的 key 对列表s进行排序并反转。

在 Python 中,与其他语言相比,列表的实现是不同的。Python 不会创建变量的多个副本。例如,当我们将一个变量的值分配给另一个变量时,两个变量都指向存储值的相同内存地址。只有在变量改变其值时才会分配一个副本。这个特性使得 Python 在内存上更有效,因为它只在需要时才创建多个副本。

这对于可变的复合对象(如列表)有重要的影响。考虑以下代码:

在上述代码中,list1list2变量都指向同一内存位置。但是,当我们通过list2y更改为4时,实际上也更改了list1指向的相同y变量。

list的一个重要特性是它可以包含嵌套结构;也就是说,列表可以包含其他列表。例如,在以下代码中,列表items包含了另外三个列表:

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

我们可以使用非常常见和直观的方法,即列表推导,从表达式中创建一个列表。它允许我们通过一个表达式直接创建一个列表。考虑以下示例,使用这个表达式创建了一个列表l

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

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'

由于用户定义的函数是对象,我们可以将它们包含在其他对象中,比如列表中:

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

在这里,callf()接受一个函数作为参数,将语言变量设置为'eng',然后调用带有语言变量作为参数的函数。我们可以看到,如果我们想要生成一个以各种语言返回特定句子的程序,这将是有用的。在这里,我们有一个设置语言的中心位置。除了我们的问候函数,我们还可以创建返回不同句子的类似函数。通过在一个地方设置语言,程序逻辑的其余部分不必担心这一点。如果我们想要改变语言,我们只需改变语言变量,其他一切都可以保持不变。

高阶函数

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

同样,我们可以使用内置的 filter 函数来过滤列表中的项目:

请注意,map 和 filter 执行与列表推导可以实现的相同功能。除了在使用内置函数 map 和 filter 时,与列表推导相比,性能特性没有太大的区别,除了在不使用lambda运算符时稍微有一点性能优势。尽管如此,大多数风格指南建议使用列表推导而不是内置函数,可能是因为它们更容易阅读。

创建我们自己的高阶函数是函数式编程风格的一个标志。高阶函数的一个实际例子是以下演示的。在这里,我们将len函数作为 sort 函数的键传递。这样,我们可以按长度对单词列表进行排序:

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

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

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

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

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

递归函数

递归是计算机科学中最基本的概念之一。在执行过程中,当一个函数调用自身一次或多次时,它被称为递归。循环迭代和递归在循环通过布尔条件或一系列元素重复执行语句的意义上是不同的,而递归则重复调用一个函数。在 Python 中,我们可以通过在其自身函数体内调用它来实现递归函数。为了防止递归函数变成无限循环,我们需要至少一个测试终止情况的参数来结束递归。这有时被称为基本情况。应该指出,递归与迭代不同。虽然两者都涉及重复,但迭代循环通过一系列操作,而递归重复调用一个函数。从技术上讲,递归是迭代的一种特殊情况,通常总是可以将迭代函数转换为递归函数,反之亦然。递归函数的有趣之处在于它们能够用有限的语句描述一个无限的对象。

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

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

生成器和协程

我们可以创建不仅返回一个结果而且返回整个结果序列的函数,方法是使用 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 循环。例如,我们可以利用前面代码中创建的oddLst生成器函数来打印出110之间的奇数:

for i in oddLst (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 解释器调用,而不是由程序员调用;例如,当我们使用+运算符时,我们实际上是在调用to _add_()。例如,我们可以使用len(my_object)而不是使用my_object._len_();在字符串对象上使用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对象是由引号表示的字符串:

继承

继承是面向对象编程语言中最强大的功能之一。它允许我们从其他类继承功能。通过继承,可以创建一个修改现有类行为的新类。继承意味着如果通过继承另一个类创建一个类的对象,那么该对象将具有两个类的所有功能、方法和变量;即父类和新类。我们继承功能的现有类称为父类/基类,新类称为派生/子类。

继承可以用一个非常简单的例子来解释——我们创建一个employee类,具有员工姓名和每小时支付的费率等属性。现在我们可以创建一个新的specialEmployee类,继承自employee类的所有属性。

在 Python 中,继承是通过在类定义中传递继承的类作为参数来完成的。它经常用于修改现有方法的行为。

specialEmployee类的实例与Employee实例相同,只是hours()方法发生了变化。例如,在下面的代码中,我们创建一个新的specialEmployee类,它继承了Employee类的所有功能,并且还改变了hours()方法:

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

为了子类定义新的类变量,需要定义一个__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。让我们考虑以下示例来理解这一点,其中obj1obj2分别是EmployeespecialEmployee类的对象:

#Example issubclass() to check whether a class is a subclass of another class  
#Example isinstance() to check if an object belongs to a class or not 

print(issubclass(specialEmployee, Employee))
print(issubclass(Employee, specialEmployee)) 

d = specialEmployee("packt", 20, 100) 
b = Employee("packt", 20)  
print(isinstance(b, specialEmployee)) 
print(isinstance(b, Employee)) 

# the output prints  
True 
False 
False 
True

通常,所有方法都在类内定义的实例上操作。但这不是必需的。有两种类型的方法——静态方法类方法。静态方法与类方法非常相似,主要绑定到类,而不是与类的对象绑定。它在类内定义,不需要类的实例来执行。它不对实例执行任何操作,并且使用@staticmethod类装饰器定义。静态方法无法访问实例的属性,因此它们最常见的用法是作为一种方便的方式来将实用函数组合在一起。

类方法在类本身上操作,不与实例一起工作。类方法的工作方式与类变量相关联,而不是该类的实例。类方法是使用@classmethod装饰器定义的,并且在类中与实例方法区分开。它作为第一个参数传递,按照惯例命名为clsexponentialB类继承自exponentialA类,并将基类变量更改为4。我们也可以运行父类的exp()方法如下:

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

    @staticmethod   def addition(x, y):  
        return (x+y)

class exponentialB(exponentialA):
        base=4

a = exponentialA() 
b= a.exp(3) 
print("the value: 3 to the power 3 is", b) 
print('The sum is:', exponentialA.addition(15, 10)) 
print(exponentialB.exp(3))

#prints the following output
the value: 3 to the power 3 is 27 
The sum is: 25 
64

静态方法和类方法之间的区别在于,静态方法对类一无所知,它只处理参数,而类方法仅与类一起工作,其参数始终是类本身。

类方法可能有几个有用的原因。例如,因为子类继承了其父类的所有相同特性,所以有可能会破坏继承的方法。使用类方法是定义确切运行哪些方法的一种方式。

数据封装和属性

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

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

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

摘要

本章为我们提供了 Python 编程的基本基础和介绍。我们描述了 Python 提供的各种数据结构和算法。我们涵盖了变量的使用,列表,一些控制结构,并学习了如何使用条件语句。我们还讨论了 Python 中如何使用函数。我们讨论了各种类型的对象,以及 Python 语言面向对象的一些内容。我们创建了自己的对象并从中继承。

Python 还提供了更多功能。当我们准备在后面的章节中研究一些算法的实现时,下一章将重点介绍数字、序列、映射和集合。这些也是 Python 中的数据类型,在为一系列操作组织数据时非常有用。

进一步阅读

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

在本章中,我们将更详细地研究 Python 数据类型。我们已经介绍了两种数据类型,字符串和列表,str()list()。然而,这些数据类型是不够的,我们经常需要更专门的数据对象来表示/存储我们的数据。 Python 有各种其他标准数据类型,用于存储和管理数据,我们将在本章中讨论。除了内置类型之外,还有几个内部模块,允许我们解决处理数据结构时的常见问题。首先,我们将回顾一些适用于所有数据类型的操作和表达式,并将讨论更多与 Python 数据类型相关的内容。

本章的目标如下:

  • 了解 Python 3.7 支持的各种重要内置数据类型

  • 探索各种高性能替代品的其他附加集合,以替代内置数据类型

技术要求

本章中使用的所有代码都在以下 GitHub 链接中提供:github.com/PacktPublishing/Hands-On-Data-Structures-and-Algorithms-with-Python-Second-Edition/tree/master/Chapter02

内置数据类型

Python 数据类型可以分为三类:数字、序列和映射。还有一个表示Null或值的缺失的None对象。不应忘记其他对象,如类、文件和异常也可以被正确地视为类型;但是,它们在这里不会被考虑。

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

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

类别 名称 描述
None None 它是一个空对象。
数字 int 这是一种整数数据类型。
float 这种数据类型可以存储浮点数。
complex 它存储复数。
bool 它是布尔类型,返回TrueFalse
序列 str 用于存储一串字符。
liXst 它可以存储任意对象的列表。
Tuple 它可以存储一组任意项目。
range 用于创建一系列整数。
映射 dict 它是一种以键/值对存储数据的字典数据类型。
set 它是一个可变的无序唯一项集合。
frozenset 它是一个不可变的集合。

None 类型

None类型是不可变的。它用作None来表示值的缺失;它类似于许多编程语言中的null,如 C 和 C++。当实际上没有要返回的内容时,对象返回None。当False布尔表达式时,也会返回NoneNone经常用作函数参数的默认值,以检测函数调用是否传递了值。

数字类型

数字类型包括整数(int),即无限范围的整数,浮点数(float),复数(complex),由两个浮点数表示,以及布尔值(bool)在 Python 中。 Python 提供了允许标准算术运算符(+-*/)对它们进行操作的int数据类型,类似于其他编程语言。布尔数据类型有两个可能的值,TrueFalse。这些值分别映射为10。让我们考虑一个例子:

>>> a=4; b=5   # Operator (=) assigns the value to variable
>>>print(a, "is of type", type(a))
4 is of type 
<class 'int'>
>>> 9/5  
1.8
>>>c= b/a  *# division returns a floating point number* *>>>* print(c, "is of type", type(c))
1.25 is of type <class 'float'>
>>> c   # No need to explicitly declare the datatype
1.25

变量abint类型,c是浮点类型。除法运算符(/)始终返回float类型;但是,如果希望在除法后获得int类型,可以使用地板除法运算符(//),它会丢弃任何小数部分,并返回小于或等于x的最大整数值。考虑以下例子:

>>> a=4; b=5   
>>>d= b//a
*>>>* print(d, "is of type", type(d))1 is of type <class 'int'>
>>>7/5  # true division
1.4
>>> -7//5  # floor division operator
-2

建议读者谨慎使用除法运算符,因为其功能根据 Python 版本而异。在 Python 2 中,除法运算符仅返回integer,而不是float

指数运算符(**)可用于获取数字的幂(例如,x ** y),模数运算符(%)返回除法的余数(例如,a% b返回a/b的余数):

>>> a=7; b=5 
>>> e= b**a  # The operator (**)calculates power 
>>>e
78125
>>>a%b
2

复数由两个浮点数表示。它们使用j运算符分配,以表示复数的虚部。我们可以通过f.realf.imag访问实部和虚部,如下面的代码片段所示。复数通常用于科学计算。Python 支持复数的加法,减法,乘法,幂,共轭等,如下所示:

>>> f=3+5j
>>>print(f, "is of type", type(f))(3+5j) is of type <class 'complex'>
>>> f.real
3.0
>>> f.imag
5.0
>>> f*2   # multiplication
(6+10j)
>>> f+3  # addition
(6+5j)
>>> f -1  # subtraction
(2+5j)  

在 Python 中,布尔类型使用真值表示,即TrueFalse;这类似于01。Python 中有一个bool类,返回TrueFalse。布尔值可以与逻辑运算符(如andornot)结合使用:

>>>bool(2)
True
>>>bool(-2)
True
>>>bool(0)
False

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

运算符 示例
not x 如果xTrue,则返回False,如果xFalse,则返回True
x and y 如果xy都为True,则返回True;否则返回False
x or y 如果xy中有一个为True,则返回True;否则返回False

Python 在评估布尔表达式时非常高效,因为它只在需要时评估运算符。例如,如果在表达式x or yxTrue,则无需评估y,因为表达式无论如何都是True,这就是为什么在 Python 中不会评估y。类似地,在表达式x and y中,如果xFalse,解释器将简单地评估x并返回False,而不会评估y

比较运算符(<<=>>===!=)适用于数字,列表和其他集合对象,并在条件成立时返回True。对于集合对象,比较运算符比较元素的数量,等价运算符(==)在每个集合对象在结构上等价且每个元素的值相同时返回True。让我们看一个例子:

>>>See_boolean = (4 * 3 > 10) and (6 + 5 >= 11)
>>>print(See_boolean)
True
>>>if (See_boolean):
...    print("Boolean expression returned True")
   else:
...  print("Boolean expression returned False")
...

Boolean expression returned True

表示错误

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

>>> 1-0.9
0.09999999999999998
>>> 1-0.9==.1
False

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

>>> import decimal
>>> x=decimal.Decimal(3.14)
>>> y=decimal.Decimal(2.74)
>>> x*y
Decimal('8.603600000000001010036498883')
>>> decimal.getcontext().prec=4
>>> x*y
Decimal('8.604')

在这里,我们创建了一个全局上下文,并将精度设置为4Decimal对象可以被视为intfloat一样对待。它们可以进行相同的数学运算,并且可以用作字典键,放置在集合中等等。此外,Decimal对象还有几种数学运算的方法,如自然指数x.exp(),自然对数x.ln()和以 10 为底的对数x.log10()

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

>>> import fractions
>>> fractions.Fraction(3,4)
Fraction(3, 4)
>>> fractions.Fraction(0.5)
Fraction(1, 2)
>>> fractions.Fraction("0.25") 
Fraction(1, 4)

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

成员资格、身份和逻辑操作

成员资格运算符(innot in)用于测试序列中的变量,如列表或字符串,并执行您所期望的操作;如果在y中找到了x变量,则x in y返回Trueis运算符比较对象标识。例如,以下代码片段展示了对比等价性和对象标识:

>>> x=[1,2,3]
>>> y=[1,2,3]
>>> x==y  # test equivalence 
True
>>> x is y   # test object identity
False
>>> x=y   # assignment
>>> x is y
True

序列

序列是由非负整数索引的对象的有序集合。序列包括stringlisttuplerange对象。列表和元组是任意对象的序列,而字符串是字符的序列。然而,stringtuplerange对象是不可变的,而list对象是可变的。所有序列类型都有许多共同的操作。请注意,对于不可变类型,任何操作都只会返回一个值,而不会实际更改该值。

对于所有序列,索引和切片操作适用于前一章节中描述的方式。stringlist数据类型在第一章中有详细讨论,Python 对象、类型和表达式。在这里,我们介绍了一些对所有序列类型(stringlisttuplerange对象)都通用的重要方法和操作。

所有序列都有以下方法:

方法 描述
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 创建ns的副本,其中n是整数。
v1,v2...,vn=s s中解包n个变量到v1v2等。
s[i] 索引返回s的第i个元素。
s[i:j:stride] 切片返回ij之间的元素,可选的步长。
x in s 如果s中存在x元素,则返回True
x not in s 如果s中不存在x元素,则返回True

让我们考虑一个示例代码片段,实现了对list数据类型的一些前述操作:

>>>list() # an empty list   
>>>list1 = [1,2,3, 4]
>>>list1.append(1)  # append value 1 at the end of the list
>>>list1
[1, 2, 3, 4, 1]
>>>list2 = list1 *2    
[1, 2, 3, 4, 1, 1, 2, 3, 4, 1]
>>> min(list1)
1
>>> max(list1)
4
>>>list1.insert(0,2)  # insert an value 2 at index 0
>>> list1
[2, 1, 2, 3, 4, 1]
>>>list1.reverse()
>>> list1
[1, 4, 3, 2, 1, 2]
>>>list2=[11,12]
>>>list1.extend(list2)
>>> list1
[1, 4, 3, 2, 1, 2, 11, 12]
>>>sum(list1)
36
>>> len(list1)
8
>>> list1.sort()
>>> list1
[1, 1, 2, 2, 3, 4, 11, 12]
>>>list1.remove(12)   #remove value 12 form the list
>>> list1
[1, 1, 2, 2, 3, 4, 11]

了解元组

元组是任意对象的不可变序列。元组是一个逗号分隔的值序列;然而,通常的做法是将它们括在括号中。当我们想要在一行中设置多个变量,或者允许函数返回不同对象的多个值时,元组非常有用。元组是一种有序的项目序列,类似于list数据类型。唯一的区别是元组是不可变的;因此,一旦创建,它们就不能被修改,不像list。元组由大于零的整数索引。元组是可散列的,这意味着我们可以对它们的列表进行排序,并且它们可以用作字典的键。

我们还可以使用内置函数tuple()创建一个元组。如果没有参数,这将创建一个空元组。如果tuple()的参数是一个序列,那么这将创建一个由该序列元素组成的元组。在创建只有一个元素的元组时,重要的是要记住使用尾随逗号——没有尾随逗号,这将被解释为一个字符串。元组的一个重要用途是通过在赋值的左侧放置一个元组来一次性分配多个变量。

考虑一个例子:

>>> t= tuple()   # create an empty tuple
>>> type(t)
<class 'tuple'>
>>> t=('a',)  # create a tuple with 1 element
>>> t
('a',)
>>> print('type is ',type(t))
type is  <class 'tuple'>
>>> tpl=('a','b','c')
>>> tpl('a', 'b', 'c')
>>> tuple('sequence')
('s', 'e', 'q', 'u', 'e', 'n', 'c', 'e')
>>> x,y,z= tpl   #multiple assignment 
>>> x
'a'
>>> y
'b'
>>> z
'c'
>>> 'a' in tpl  # Membership can be tested
True
>>> 'z' in tpl
False

大多数运算符,如切片和索引运算符,都像列表一样工作。然而,由于元组是不可变的,尝试修改元组的元素会导致TypeError。我们可以像比较其他序列一样比较元组,使用==><运算符。考虑一个示例代码片段:

>>> tupl = 1, 2,3,4,5  # braces are optional
>>>print("tuple value at index 1 is ", tupl[1])
tuple value at index 1 is  2
>>> print("tuple[1:3] is ", tupl[1:3])
tuple[1:3] is (2, 3)
>>>tupl2 = (11, 12,13)
>>>tupl3= tupl + tupl2   # tuple concatenation
>>> tupl3
(1, 2, 3, 4, 5, 11, 12, 13)
>>> tupl*2      # repetition for tuples
(1, 2, 3, 4, 5, 1, 2, 3, 4, 5)
>>> 5 in tupl    # membership test
True
>>> tupl[-1]     # negative indexing
5
>>> len(tupl)   # length function for tuple
5
>>> max(tupl)
5
>>> min(tupl)
1
>>> tupl[1] = 5 # modification in tuple is not allowed.
Traceback (most recent call last):  
  File "<stdin>", line 1, in <module>
TypeError: 'tuple' object does not support item assignment
>>>print (tupl== tupl2)
False
>>>print (tupl>tupl2)
False

让我们考虑另一个例子来更好地理解元组。例如,我们可以使用多个赋值来交换元组中的值:

>>> l = ['one','two']
>>> x,y = l
('one', 'two')
>>> x,y = y,x
>>> x,y
('two', 'one')

从字典开始

在 Python 中,字典数据类型是最受欢迎和有用的数据类型之一。字典以键和值对的映射方式存储数据。字典主要是对象的集合;它们由数字、字符串或任何其他不可变对象索引。字典中的键应该是唯一的;然而,字典中的值可以被更改。Python 字典是唯一的内置映射类型;它们可以被看作是从一组键到一组值的映射。它们使用{key:value}的语法创建。例如,以下代码可以用来创建一个将单词映射到数字的字典,使用不同的方法:

>>>a= {'Monday':1,'Tuesday':2,'Wednesday':3} #creates a dictionary 
>>>b =dict({'Monday':1 , 'Tuesday': 2, 'Wednesday': 3})
>>> b
{'Monday': 1, 'Tuesday': 2, 'Wednesday': 3}
>>> c= dict(zip(['Monday','Tuesday','Wednesday'], [1,2,3]))
>>> c={'Monday': 1, 'Tuesday': 2, 'Wednesday': 3}
>>> d= dict([('Monday',1), ('Tuesday',2), ('Wednesday',3)])
>>>d
{'Monday': 1, 'Tuesday': 2, 'Wednesday': 3}

我们可以添加键和值。我们还可以更新多个值,并使用in运算符测试值的成员资格或出现情况,如下面的代码示例所示:

>>>d['Thursday']=4     #add an item
>>>d.update({'Friday':5,'Saturday':6})  #add multiple items
>>>d
{'Monday': 1, 'Tuesday': 2, 'Wednesday': 3, 'Thursday': 4, 'Friday': 5, 'Saturday': 6}
>>>'Wednesday' in d  # membership test (only in keys)
True
>>>5 in d       # membership do not check in values
False

如果列表很长,使用in运算符在列表中查找元素会花费太多时间。在列表中查找元素所需的运行时间随着列表大小的增加而线性增加。而字典中的in运算符使用哈希函数,这使得字典非常高效,因为查找元素所花费的时间与字典的大小无关。

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

>>> dict(zip('packt', range(5)))
{'p': 0, 'a': 1, 'c': 2, 'k': 3, 't': 4}
>>> a = dict(zip('packt', range(5)))
>>> len(a)   # length of dictionary a
5
>>> a['c']  # to check the value of a key
2
>>> a.pop('a')  
1
>>> a{'p': 0, 'c': 2, 'k': 3, 't': 4}
>>> b= a.copy()   # make a copy of the dictionary
>>> b
{'p': 0, 'c': 2, 'k': 3, 't': 4}
>>> a.keys()
dict_keys(['p', 'c', 'k', 't'])
>>> a.values()
dict_values([0, 2, 3, 4])
>>> a.items()
dict_items([('p', 0), ('c', 2), ('k', 3), ('t', 4)])
>>> a.update({'a':1})   # add an item in the dictionary
>>> a{'p': 0, 'c': 2, 'k': 3, 't': 4, 'a': 1}
>>> a.update(a=22)  # update the value of key 'a'
>>> a{'p': 0, 'c': 2, 'k': 3, 't': 4, 'a': 22}

以下表格包含了所有字典方法及其描述:

方法 描述
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的所有键:值对。
d.keys() 返回字典d中定义的所有键。
d.pop(k[,default]) 返回d[k]并从d中删除它。
d.popitem() 从字典d中删除一个随机的键:值对,并将其作为元组返回。
d.setdefault(k[,v]) 返回d[k]。如果找不到,它返回v并将d[k]设置为v
d.update(b) b字典中的所有对象添加到d字典中。
d.values() 返回字典d中的所有值。

Python

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

list对象相反,当in运算符应用于字典时,它使用哈希算法,这会导致每次查找时间的增加几乎独立于字典的大小。这使得字典作为处理大量索引数据的一种方式非常有用。我们将在第四章和第十四章中更多地讨论这个重要主题,即哈希的增长率。

对字典进行排序

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

>>> d = {'one': 1, 'two': 2, 'three': 3, 'four': 4, 'five': 5, 'six': 6} 
>>> sorted(list(d)) 
['five', 'four', 'one', 'six', 'three', 'two']  
>>> sorted(list(d.values())) 
[1, 2, 3, 4, 5, 6] 

请注意,前面代码中的第一行按字母顺序对键进行排序,第二行按整数值的顺序对值进行排序。

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

基本上,前面的代码对d中的每个键使用相应的值进行排序。我们也可以根据字典键的排序顺序对值进行排序。然而,由于字典没有一种方法可以通过其值返回一个键,就像列表的list.index方法一样,使用可选的key参数来做到这一点有点棘手。另一种方法是使用列表推导式,就像下面的例子演示的那样:

sorted()方法还有一个可选的reverse参数,毫不奇怪,它确实做到了它所说的—反转排序列表的顺序,就像下面的例子一样:

现在,假设我们有以下字典,其中英语单词作为键,法语单词作为值。我们的任务是将字符串值放在正确的数字顺序中:

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

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

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

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

def corder(string): 
    return (string[len(string)-1])

然后,我们可以将其用作排序函数的关键,按其最后一个字母对每个元素进行排序:

文本分析的字典

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

def wordcount(fname):  
   try: 
        fhand=open(fname) 
   except:
        print('File can not 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,或者使用您自己的文本文件。在下面的代码中,我们创建了另一个字典filtered,其中包含来自count的子集:

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

当我们打印过滤字典时,我们得到以下结果:

{'once': 18, 'eyes': 18, 'There': 19, 'this,': 17, 'before': 19, 'take': 18, 'tried': 18, 'even': 17, 'things': 19, 'sort': 17, 'her,': 18, '`And': 17, 'sat': 17, '`But': 19, "it,'": 18, 'cried': 18, '`Oh,': 19, 'and,': 19, "`I'm": 19, 'voice': 17, 'being': 19, 'till': 19, 'Mouse': 17, '`but': 19, 'Queen,': 17}

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

集合

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

与序列类型不同,集合类型不提供任何索引或切片操作。Python 中有两种类型的集合对象,可变的set对象和不可变的frozenset对象。使用花括号内的逗号分隔的值创建集合。顺便说一句,我们不能使用a={}创建一个空集,因为这将创建一个字典。要创建一个空集,我们要么写a=set(),要么写a=frozenset()

集合的方法和操作描述在下表中:

方法 描述
len(a) 提供了a集合中元素的总数。
a.copy() 提供了a集合的另一个副本。
a.difference(t) 提供了a集合中存在但不在t中的元素的集合。
a.intersection(t) 提供了两个集合at中都存在的元素的集合。
a.isdisjoint(t) 如果两个集合at中没有共同的元素,则返回True
a.issubset(t) 如果a集合的所有元素也在t集合中,则返回True
a.issuperset(t) 如果t集合的所有元素也在a集合中,则返回True
a.symmetric_difference(t) 返回一个既在a集合中又在t集合中的元素的集合,但不在两者中都存在。
a.union(t) 返回一个既在a集合中又在t集合中的元素的集合。

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

可变的set对象具有其他方法,如下表所述:

方法 描述
s.add(item) 将项目添加到s;如果项目已经添加,则不会发生任何事情。
s.clear() 从集合s中删除所有元素。
s.difference_update(t) s集合中删除那些也在其他集合t中的元素。
s.discard(item) 从集合s中删除项目。
s.intersection_update(t) 从集合s中删除不在集合st的交集中的项目。
s.pop() 从集合s中返回一个任意项目,并从s集合中删除它。
s.remove(item) s集合中删除项目。
s.symetric_difference_update(t) 从集合s中删除不在集合st的对称差集中的所有元素。
s.update(t) 将可迭代对象t中的所有项目附加到s集合。

在这里,考虑一个简单的示例,显示了添加、删除、丢弃和清除操作:

>>> s1 = set()
>>> s1.add(1)
>>> s1.add(2)
>>> s1.add(3)
>>> s1.add(4)
>>> s1
{1, 2, 3, 4}
>>> s1.remove(4)
>>> s1
{1, 2, 3}
>>> s1.discard(3)
>>> s1
{1, 2}
>>>s1.clear()
>>>s1
set()

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

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

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

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

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

不可变集合

Python 有一个名为frozenset的不可变集合类型。它的工作方式几乎与set完全相同,除了不允许更改值的方法或操作,例如add()clear()方法。这种不可变性有几种有用之处。

例如,由于普通集合是可变的,因此不可哈希,它们不能用作其他集合的成员。另一方面,frozenset是不可变的,因此可以用作集合的成员:

此外,frozenset的不可变属性意味着我们可以将其用作字典的键,如下例所示:

数据结构和算法的模块

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

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

集合

collections模块提供了更专门的、高性能的替代品,用于内置数据类型,以及一个实用函数来创建命名元组。以下表列出了collections模块的数据类型和操作及其描述:

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

双端队列

双端队列,通常发音为decks,是类似列表的对象,支持线程安全、内存高效的追加。双端队列是可变的,并支持列表的一些操作,如索引。双端队列可以通过索引分配,例如,dq[1] = z;但是,我们不能直接切片双端队列。例如,dq[1:2]会导致TypeError(我们将看一种从双端队列返回切片作为列表的方法)。

双端队列比列表的主要优势在于,在双端队列的开头插入项目要比在列表的开头插入项目快得多,尽管在双端队列的末尾插入项目的速度比列表上的等效操作略慢一些。双端队列是线程安全的,并且可以使用pickle模块进行序列化。

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

我们可以使用pop()popleft()方法来消耗双端队列中的项目,如下例所示:

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

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

itertools.islice()方法的工作方式与列表上的切片相同,只是它不是以列表作为参数,而是以可迭代对象作为参数,并返回所选值,按起始和停止索引,作为列表。

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

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

这将打印出以下内容:

在这个例子中,我们从右侧填充并从左侧消耗。请注意,一旦缓冲区已满,最旧的值将首先被消耗,然后从右侧替换值。在第四章中,当实现循环列表时,我们将再次看循环缓冲区。

ChainMap 对象

collections.chainmap类是在 Python 3.2 中添加的,它提供了一种将多个字典或其他映射链接在一起,以便它们可以被视为一个对象的方法。此外,还有一个maps属性,一个new_child()方法和一个parents属性。ChainMap对象的基础映射存储在列表中,并且可以使用maps[i]属性来检索第i个字典。请注意,尽管字典本身是无序的,ChainMap对象是有序的字典列表。

ChainMap在使用包含相关数据的多个字典的应用程序中非常有用。消费应用程序期望按优先级获取数据,如果两个字典中的相同键出现在基础列表的开头,则该键将优先考虑。ChainMap通常用于模拟嵌套上下文,例如当我们有多个覆盖配置设置时。以下示例演示了ChainMap的可能用例:

>>> import collections
>>> dict1= {'a':1, 'b':2, 'c':3}
>>> dict2 = {'d':4, 'e':5}
>>> chainmap = collections.ChainMap(dict1, dict2)  # linking two dictionaries
>>> chainmap
ChainMap({'a': 1, 'b': 2, 'c': 3}, {'d': 4, 'e': 5})
>>> chainmap.maps
[{'a': 1, 'b': 2, 'c': 3}, {'d': 4, 'e': 5}]
>>> chainmap.values
<bound method Mapping.values of ChainMap({'a': 1, 'b': 2, 'c': 3}, {'d': 4, 'e': 5})
>>>> chainmap['b']   #accessing values 
2
>>> chainmap['e']
5

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

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

>>> from collections import ChainMap
>>> defaults= {'theme':'Default','language':'eng','showIndex':True, 'showFooter':True}
>>> cm= ChainMap(defaults)   #creates a chainMap with defaults configuration
>>> cm.maps[{'theme': 'Default', 'language': 'eng', 'showIndex': True, 'showFooter': True}]
>>> cm.values()
ValuesView(ChainMap({'theme': 'Default', 'language': 'eng', 'showIndex': True, 'showFooter': True}))
>>> cm2= cm.new_child({'theme':'bluesky'}) # create a new chainMap with a child that overrides the parent.
>>> cm2['theme']  #returns the overridden theme'bluesky'
>>> cm2.pop('theme')  # removes the child theme value
'bluesky' 
>>> cm2['theme']
'Default'
>>> cm2.maps[{}, {'theme': 'Default', 'language': 'eng', 'showIndex': True, 'showFooter': True}]
>>> cm2.parents
ChainMap({'theme': 'Default', 'language': 'eng', 'showIndex': True, 'showFooter': True})

计数器对象

Counter是字典的一个子类,其中每个字典键都是可散列对象,关联的值是该对象的整数计数。有三种初始化计数器的方法。我们可以将任何序列对象、key:value对的字典或格式为(object=value,...)的元组传递给它,如下例所示:

>>> from collections import Counter
>>> Counter('anysequence')
Counter({'e': 3, 'n': 2, 'a': 1, 'y': 1, 's': 1, 'q': 1, 'u': 1, 'c': 1})
>>> c1 = Counter('anysequence')
>>> c2= Counter({'a':1, 'c': 1, 'e':3})
>>> c3= Counter(a=1, c= 1, e=3)
>>> c1
Counter({'e': 3, 'n': 2, 'a': 1, 'y': 1, 's': 1, 'q': 1, 'u': 1, 'c': 1})
>>> c2
Counter({'e': 3, 'a': 1, 'c': 1})
>>> c3
Counter({'e': 3, 'a': 1, 'c': 1})

我们还可以创建一个空的计数器对象,并通过将其update方法传递给一个可迭代对象或字典来填充它。请注意,update方法添加计数,而不是用新值替换它们。填充计数器后,我们可以以与字典相同的方式访问存储的值,如下例所示:

>>> from collections import Counter
>>> ct = Counter()  # creates an empty counter object
>>> ct
Counter()
>>> ct.update('abca') # populates the object
>>> ct
Counter({'a': 2, 'b': 1, 'c': 1})
>>> ct.update({'a':3}) # update the count of 'a'
>>> ct
Counter({'a': 5, 'b': 1, 'c': 1})
>>> for item in ct:
 ...  print('%s: %d' % (item, ct[item]))
 ...
a: 5
b: 1
c: 1

计数器对象和字典之间最显着的区别是计数器对象对于缺失的项返回零计数,而不是引发键错误。我们可以使用其elements()方法从Counter对象创建迭代器。这将返回一个迭代器,其中不包括小于一的计数,并且顺序不被保证。在下面的代码中,我们执行一些更新,从Counter元素创建一个迭代器,并使用sorted()按字母顺序对键进行排序:

>>> ct
Counter({'a': 5, 'b': 1, 'c': 1})
>>> ct['x']
0
>>> ct.update({'a':-3, 'b':-2, 'e':2})
>>> ct
Counter({'a': 2, 'e': 2, 'c': 1, 'b': -1})
>>>sorted(ct.elements())
['a', 'a', 'c', 'e', 'e']

另外两个值得一提的Counter方法是most_common()subtract()。最常见的方法接受一个正整数参数,确定要返回的最常见元素的数量。元素作为(key,value)元组的列表返回。

减法方法的工作方式与更新相同,只是它不是添加值,而是减去它们,如下例所示:

>>> ct.most_common()
[('a', 2), ('e', 2), ('c', 1), ('b', -1)]
>>> ct.subtract({'e':2})
>>> ct
Counter({'a': 2, 'c': 1, 'e': 0, 'b': -1})

有序字典

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

>>> import collections
>>> od1=  collections.OrderedDict()
>>> od1['one'] = 1
>>> od1['two'] = 2
>>> od2 =  collections.OrderedDict()
>>> od2['two'] = 2
>>> od2['one'] = 1
>>> od1==od2
False

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

>>> kvs = [('three',3), ('four',4), ('five',5)]
>>> od1.update(kvs)
>>> od1
OrderedDict([('one', 1), ('two', 2), ('three', 3), ('four', 4), ('five', 5)])
>>> for k, v in od1.items(): print(k, v)
...
one 1
two 2
three 3
four 4
five 5

OrderedDict经常与 sorted 方法一起使用,以创建一个排序的字典。在下面的示例中,我们使用 Lambda 函数对值进行排序,并且在这里我们使用数值表达式对整数值进行排序:

>>> od3 = collections.OrderedDict(sorted(od1.items(), key= lambda t : (4*t[1])- t[1]**2))
>>>od3
OrderedDict([('five', 5), ('four', 4), ('one', 1), ('three', 3), ('two', 2)])
>>> od3.values() 
odict_values([5, 4, 1, 3, 2])

defaultdict

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

>>> from collections import defaultdict
>>> dd = defaultdict(int)
>>> words = str.split('red blue green red yellow blue red green green red')
>>> for word in words: dd[word] +=1
...
>>> dd
defaultdict(<class 'int'>, {'red': 4, 'blue': 2, 'green': 3, 'yellow': 1})

您会注意到,如果我们尝试使用普通字典来做这件事,当我们尝试添加第一个键时,我们会得到一个键错误。我们提供给defaultdictint实际上是int()函数,它只是返回零。

当然,我们可以创建一个函数来确定字典的值。例如,以下函数在提供的参数是主要颜色(即redgreenblue)时返回True,否则返回False

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

了解命名元组

namedtuple方法返回一个类似元组的对象,其字段可以通过命名索引以及普通元组的整数索引进行访问。这允许在某种程度上自我记录和更易读的代码。在需要轻松跟踪每个元组代表的内容的应用程序中,这可能特别有用。此外,namedtuple从元组继承方法,并且与元组向后兼容。

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

>>> from collections import namedtuple
>>> space = namedtuple('space', 'x y z')
>>> s1= space(x=2.0, y=4.0, z=10) # we can also use space(2.0,4.0, 10)
>>> s1
space(x=2.0, y=4.0, z=10)
>>> s1.x * s1.y * s1.z   # calculate the volume
80.0

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

>>> sl = [4,5,6]
>>> space._make(sl)
space(x=4, y=5, z=6)
>>> s1._1
4

_asdict方法返回一个OrderedDict对象,其中字段名称映射到索引键,值映射到字典值。_replace方法返回元组的新实例,替换指定的值。此外,_fields返回列出字段名称的字符串元组。_fields_defaults方法提供将字段名称映射到默认值的字典。考虑以下示例代码片段:

>>> s1._asdict()
OrderedDict([('x', 3), ('_1', 4), ('z', 5)])
>>> s1._replace(x=7, z=9)
space2(x=7, _1=4, z=9)
>>> space._fields
('x', 'y', 'z')
>>> space._fields_defaults
{}

数组

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

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

代码 C 类型 Python 类型 最小字节数
'b' signedchar int 1
'B' unsignedchar int 1
'u' Py_UNICODE Unicodecharacter 2
'h' signedshort int 2
'H' unsignedshort int 2
'i' signedint int 2
'I' unsignedint int 2
'l' signedlong int 4
'L' unsignedlong int 8
'q' signedlonglong int 8
'Q' unsignedlonlong int 8
'f' float float 4
'd' double float 8

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

属性或方法 描述
a.itemsize 一个数组项的大小(以字节为单位)。
a.append(x) a数组的末尾添加一个x元素。
a.buffer_info() 返回一个元组,包含用于存储数组的缓冲区的当前内存位置和长度。
a.byteswap() 交换a数组中每个项目的字节顺序。
a.count(x) 返回a数组中x的出现次数。
a.extend(b) a数组的末尾添加可迭代对象b的所有元素。
a.frombytes(s) 从字符串s中附加元素,其中字符串是机器值的数组。
a.fromfile(f,n) 从文件中读取n个机器值,并将它们附加到数组的末尾。
a.fromlist(l) l列表中的所有元素附加到数组。
a.fromunicode(s) 用 Unicode 字符串s扩展u类型的数组。
index(x) 返回x元素的第一个(最小)索引。
a.insert(i,x) 在数组的i索引位置插入值为x的项目。
a.pop([i]) 返回索引i处的项目,并从数组中删除它。
a.remove(x) 从数组中删除第一个出现的x项。
a.reverse() 颠倒a数组中项目的顺序。
a.tofile(f) 将所有元素写入f文件对象。
a.tolist() 将数组转换为列表。
a.tounicode() u类型的数组转换为 Unicode 字符串。

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

与列表相比,使用数组是存储相同类型数据的更有效的方法。在下面的例子中,我们创建了一个整数数组,其中包含从0到一百万减去1的数字,以及一个相同的列表。在整数数组中存储一百万个整数,大约需要相当于等效列表的 90%的内存:

>>> import array
>>> ba = array.array('i', range(10**6))
>>> bl = list(range(10**6))
>>> import sys
>>> 100*sys.getsizeof(ba)/sys.getsizeof(bl)
90.92989871246161

因为我们对节省空间感兴趣,也就是说,我们处理大型数据集和有限的内存大小,通常我们对数组进行原地操作,只有在需要时才创建副本。通常,enumerate 用于对每个元素执行操作。在下面的片段中,我们执行简单的操作,为数组中的每个项目添加一。

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

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

总结

在最后两章中,我们介绍了 Python 的语言特性和数据类型。我们研究了内置数据类型和一些内部 Python 模块,尤其是collections模块。还有其他几个与本书主题相关的 Python 模块,但与其单独检查它们,不如在开始使用它们时,它们的使用和功能应该变得不言自明。还有一些外部库,例如 SciPy。

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

第三章:算法设计原则

我们为什么要学习算法设计?当然有很多原因,我们学习某些东西的动机很大程度上取决于我们自己的情况。对于对算法设计感兴趣有重要专业原因。算法是所有计算的基础。我们可以将计算机视为一台硬件,带有硬盘、内存芯片、处理器等。然而,如果缺少的是算法,现代技术将不可能存在。让我们在接下来的章节中了解更多。

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

  • 算法简介

  • 递归和回溯

  • 大 O 符号

技术要求

我们需要使用 Python 安装matplotlib库来绘制本章的图表。

可以通过在终端上运行以下命令在 Ubuntu/Linux 上安装:

python3 -mpip install matplotlib

您还可以使用以下内容:

sudo apt-get install python3-matplotlib 

在 Windows 上安装matplotlib

如果 Python 已经安装在 Windows 操作系统上,可以从以下链接获取matplotlib并在 Windows 上安装:github.com/matplotlib/matplotlib/downloadsmatplotlib.org

本章的代码文件可以在以下链接找到:github.com/PacktPublishing/Hands-On-Data-Structures-and-Algorithms-with-Python-Second-Edition/tree/master/Chapter03

算法简介

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

算法对技术创新的影响是另一个方面。显而易见的例子是页面排名搜索算法,Google 搜索引擎就是基于其变体。使用这些和类似的算法允许研究人员、科学家、技术人员等快速搜索大量信息。这对新研究的速度、新发现的速度以及新的创新技术的开发速度产生了巨大影响。算法是执行特定任务的顺序指令集。它们非常重要,因为我们可以将一个复杂的问题分解为一个小问题,以准备执行一个大问题的简单步骤——这是算法最重要的部分。一个好的算法是解决特定问题的高效程序的关键。学习算法也很重要,因为它训练我们对某些问题进行非常具体的思考。它可以通过隔离问题的组成部分并定义这些组成部分之间的关系来增加我们的问题解决能力。总之,学习算法有一些重要原因:

  • 它们对计算机科学和智能系统至关重要

  • 它们在许多其他领域中很重要(计算生物学、经济学、生态学、通信、生态学、物理等)

  • 它们在技术创新中发挥作用

  • 它们改进问题解决和分析思维

解决给定问题主要有两个重要方面。首先,我们需要一个有效的机制来存储、管理和检索数据,这对解决问题很重要(这属于数据结构);其次,我们需要一个有效的算法,这是一组有限的指令来解决问题。因此,研究数据结构和算法对使用计算机程序解决任何问题至关重要。有效的算法应具有以下特征:

  • 它应该尽可能具体

  • 算法的每个指令都应该被正确定义

  • 不应该有任何模糊的指令

  • 算法的所有指令都应该在有限的时间内和有限的步骤内可执行

  • 它应该有清晰的输入和输出来解决问题

  • 算法的每个指令在解决给定问题时都很重要

算法在其最简单的形式中只是一系列操作 - 一系列指令。它可能只是一个形式为 do x,然后 do y,然后 do z,然后完成的线性构造。然而,为了使事情更有用,我们添加了类似于 do x然后 do y的子句;在 Python 中,这些是 if-else 语句。在这里,未来的行动取决于某些条件;比如数据结构的状态。为此,我们还添加了操作、迭代、while 和 for 语句。扩展我们的算法素养,我们添加了递归。递归通常可以实现与迭代相同的结果,但它们在根本上是不同的。递归函数调用自身,将相同的函数应用于逐渐减小的输入。任何递归步骤的输入是前一个递归步骤的输出。

算法设计范式

一般来说,我们可以分辨出三种算法设计的广泛方法。它们是:

  • 分而治之

  • 贪婪算法

  • 动态规划

正如其名称所示,分而治之范式涉及将问题分解为较小的简单子问题,然后解决这些子问题,最后将结果组合以获得全局最优解。这是一种非常常见和自然的问题解决技术,可以说是算法设计中最常用的方法。例如,归并排序是一种对 n 个自然数列表进行递增排序的算法。

在这个算法中,我们迭代地将列表分成相等的部分,直到每个子列表包含一个元素,然后我们将这些子列表组合在一起,以排序顺序创建一个新列表。我们将在本节/章节后面更详细地讨论归并排序。

分而治之算法范式的一些例子如下:

  • 二分搜索

  • 归并排序

  • 快速排序

  • Karatsuba 算法用于快速乘法

  • 斯特拉森矩阵乘法

  • 最接近的点对

贪婪算法通常涉及优化和组合问题。在贪婪算法中,目标是在每一步中从许多可能的解决方案中获得最佳的最优解,并且我们试图获得局部最优解,这可能最终导致我们获得整体最优解。通常,贪婪算法用于优化问题。以下是许多流行的标准问题,我们可以使用贪婪算法来获得最优解:

  • 克鲁斯卡尔最小生成树

  • 迪杰斯特拉最短路径

  • 背包问题

  • 普林姆最小生成树算法

  • 旅行推销员问题

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

另一个经典的例子是将贪婪算法应用于旅行推销员问题;这是一个 NP 难问题。在这个问题中,贪婪方法总是首先选择当前城市中最近的未访问城市;这样,我们不能确定我们得到了最佳解决方案,但我们肯定得到了一个最优解。这种最短路径策略涉及在希望这将导致全局解决方案的情况下找到局部问题的最佳解决方案。

动态规划方法在我们的子问题重叠时非常有用。这与分治法不同。与将问题分解为独立子问题不同,动态规划中间结果被缓存并可以在后续操作中使用。与分治法一样,它使用递归;然而,动态规划允许我们在不同阶段比较结果。这对于某些问题来说可能比分治法具有性能优势,因为通常从内存中检索先前计算的结果比重新计算要快。动态规划也使用递归来解决问题。例如,矩阵链乘法问题可以使用动态规划来解决。矩阵链乘法问题确定了在给定一系列矩阵时,最有效的矩阵相乘的顺序,它找到需要最少操作次数的乘法顺序。

例如,让我们看看三个矩阵——PQR。要计算这三个矩阵的乘法,我们有许多可能的选择(因为矩阵乘法是可结合的),比如(PQ)R = P(QR)。因此,如果这些矩阵的大小是——P是 20×30,Q是 30×45,R是 45×50,那么(PQ)RP(QR)的乘法次数将是:

  • (PQ)R = 20 x 30 x 45 + 20 x 45 x 50 = 72,000

  • P(QR) =  20 x 30 x 50 + 30 x 45 x 50 = 97,500

从这个例子可以看出,如果我们使用第一个选项进行乘法,那么我们需要 72,000 次乘法,与第二个选项相比要少。这在以下代码中显示:

def MatrixChain(mat, i, j):   
    if i == j:   
        return 0   
    minimum_computations = sys.maxsize  
    for k in range(i, j): 
        count = (MatrixChain(mat, i, k) + MatrixChain(mat, k+1, j)+ mat[i-1] * mat[k] * mat[j])   
        if count < minimum_computations:  
              minimum_computations= count;    
        return minimum_computations;  

matrix_sizes = [20, 30, 45, 50];  
print("Minimum multiplications are", MatrixChain(matrix_sizes , 1, len(matrix_sizes)-1));

#prints 72000

第十三章,设计技术和策略,对算法设计策略进行了更详细的讨论。

递归和回溯

递归对于分治问题特别有用;然而,确切地了解发生了什么可能很困难,因为每个递归调用本身都会产生其他递归调用。递归函数可能会陷入无限循环,因此需要每个递归函数都遵守一些属性。递归函数的核心是两种类型的情况:

  • 基本情况:这些告诉递归何时终止,意味着一旦满足基本条件,递归将停止

  • 递归情况:函数调用自身,我们朝着实现基本条件的目标前进

一个自然适合递归解决方案的简单问题是计算阶乘。递归阶乘算法定义了两种情况:当n为零时的基本情况(终止条件),以及当n大于零时的递归情况(函数本身的调用)。一个典型的实现如下:

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

factorial(4)

要计算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 =1 时返回,与先前递归调用生成的字符串的每个元素。在这个意义上,它是 回溯,以揭示先前未生成的组合。返回的最终字符串是初始字符串的所有 n 个字母组合。

分治——长乘法

为了使递归不仅仅是一个巧妙的技巧,我们需要了解如何将其与其他方法进行比较,例如迭代,并了解何时使用它将导致更快的算法。我们都熟悉的迭代算法是我们在小学数学课上学到的程序,用于将两个大数相乘。那就是长乘法。如果你记得的话,长乘法涉及迭代乘法和进位操作,然后是移位和加法操作。

我们的目标是检查如何衡量这个过程的效率,并尝试回答这个问题——这是我们用来将两个大数相乘的最有效的过程吗?

在下图中,我们可以看到将两个四位数相乘需要 16 次乘法运算,我们可以概括地说,一个 n 位数需要大约 n^(2) 次乘法运算:

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

递归方法

事实证明,在长乘法的情况下,答案是肯定的,实际上有几种算法可以减少操作次数。其中最著名的替代长乘法的算法之一是Karatsuba 算法,首次发表于 1962 年。这采用了一种基本不同的方法:而不是迭代地相乘单个数字,它在逐渐减小的输入上递归地进行乘法运算。递归程序在输入的较小子集上调用自身。构建递归算法的第一步是将一个大数分解为几个较小的数。这样做的最自然的方式是将数字分成两半,前半部分是最高有效数字,后半部分是最低有效数字。例如,我们的四位数 2345 变成了一对两位数 23 和 45。我们可以使用以下更一般的分解来写出任意两个n位数xy的分解,其中m是小于n的任意正整数:

现在我们可以将我们的乘法问题xy重写如下:

当我们展开时,我们得到以下结果:

更方便的是,我们可以这样写(方程 3.1):

                          ... (3.1)

在哪里:

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

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

由于乘积acbd已经处于最简形式,看来我们无法消除这些计算。然而,我们可以做出以下观察:

当我们减去我们在上一个递归步骤中计算的量acbd时,我们得到我们需要的量,即(ad + bc):

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

  1. 递归计算ac

  2. 递归计算bd

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

以下示例展示了 Karatsuba 算法的 Python 实现。在以下代码中,最初,我们检查给定数字中是否有任何一个小于 10,然后就不需要运行递归函数。接下来,我们确定较大值的数字位数,并在数字位数为奇数时加一。最后,我们递归调用函数三次来计算acbd和(a + d)(c + d)。以下代码打印任意两个数字的乘积;例如,它打印出4264704来表示12343456的乘积。Karatsuba 算法的实现如下:

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)))

t= karatsuba(1234,3456)
print(t)

# outputs - 4264704

运行时间分析

算法的性能通常由其输入数据的大小(n)以及算法使用的时间和内存空间来衡量。所需的时间由算法执行的关键操作(如比较操作)来衡量,而算法的空间需求则由在程序执行期间存储变量、常量和指令所需的存储空间来衡量。算法的空间需求在执行期间也可能动态变化,因为它取决于变量大小,这在运行时决定,例如动态内存分配、内存堆栈等。

算法所需的运行时间取决于输入大小;随着输入大小(n)的增加,运行时间也会增加。例如,对于输入大小为 5,000 的列表,排序算法将需要更多的运行时间来排序,而对于输入大小为 50 的列表,运行时间较短。因此,可以清楚地看出,要计算时间复杂度,输入大小是重要的。此外,对于特定输入,运行时间取决于算法中要执行的关键操作。例如,对于排序算法,关键操作是比较操作,它将占用大部分时间,而不是赋值或其他任何操作。要执行的关键操作越多,运行算法所需的时间就越长。

应该注意的是,算法设计的一个重要方面是评估效率,无论是在空间(内存)还是时间(操作次数)方面。应该提到的是,用于衡量算法内存性能的度量标准与衡量算法运行时间的度量标准相同。我们可以以多种方式来衡量运行时间,最明显的方式可能是简单地测量算法所需的总时间。这种方法的主要问题在于算法运行所需的时间非常依赖于其运行的硬件。衡量算法运行时间的一个与平台无关的方法是计算所涉及的操作次数。然而,这也是有问题的,因为没有明确的方法来量化一个操作。这取决于编程语言、编码风格以及我们决定如何计算操作。然而,如果我们将这种计算操作的想法与一个期望相结合,即随着输入大小的增加,运行时间将以特定方式增加,我们就可以使用这个想法。也就是说,输入大小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 subarrays 
    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的大小相关的增长率是多少?为了更好地理解这一点,我们可以将每个递归调用映射到一个树结构上。树中的每个节点都是递归调用,处理逐渐变小的子问题:

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

关注运行时分析,在第一级,问题分成两个n/2 个子问题;在第二级,有四个n/4 个子问题,依此类推。问题是,递归何时结束,也就是说,何时达到基本情况?这只是当数组要么是零要么是一时。

递归级别的数量恰好是将n除以二直到得到最多为一的数字的次数。这恰好是 log2 的定义。由于我们将初始递归调用计为级别零,总级别数为 log[2]n + 1。

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

此外,还有一些细节我们忽略了,我相信你也开始好奇了。例如,当m/2 不是整数时会发生什么,或者当我们的输入数组中有重复元素时会发生什么?事实证明,这对我们的分析并没有重要影响;我们将在《第十二章设计技术和策略》中重新审视归并排序算法的一些细节。

使用递归树来分析算法的优势在于我们可以计算每个递归级别的工作量。我们定义这个工作量就是总操作次数,这当然与输入的大小有关。以平台无关的方式来测量和比较算法的性能是很重要的。实际运行时间当然取决于其运行的硬件。计算操作次数很重要,因为它给了我们一个与算法性能直接相关的度量,而不受平台的影响。

一般来说,由于归并排序的每次调用都会进行两次递归调用,所以调用次数在每个级别都会翻倍。与此同时,每个调用都在处理其父级别一半大小的输入。我们可以形式化地说,在第j级,其中j是整数0, 1, 2 ... log[2]n,有两个大小为n/2^j的子问题。

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

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

这可能看起来有点令人生畏,因为每次递归调用本身都会产生更多的递归调用,似乎呈指数级增长。使这一切变得可控的关键事实是,随着递归调用次数翻倍,每个子问题的大小减半。这两股相反的力量得到了很好的抵消,我们可以证明这一点。

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

重要的是,这表明,因为2^j取消了每个级别的操作数量,所以每个级别的操作数量是独立的。这给了我们每个级别执行的操作数量的上限,在这个例子中是 7n。需要指出的是,这包括在该级别上每个递归调用执行的操作数量,而不是在后续级别上进行的递归调用。这表明工作是完成的,因为随着每个级别递归调用的数量翻倍,而每个子问题的输入大小减半,这正好抵消了这一事实。

要找到完整归并排序的总操作数,我们只需将每个级别上的操作数乘以级别数。这给出了以下结果:

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

从中可以得出一个关键点,即输入大小和总运行时间之间存在对数关系。如果你还记得学校数学,对数函数的显著特点是它非常快速地变平。作为输入变量,x增加,输出变量y增加的幅度越来越小。

例如,将对数函数与线性函数进行比较:

在前面的例子中,将nlog[2] n分量与进行比较:

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

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

import matplotlib.pyplotasplt 
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库,您需要安装它才能运行。详细信息可以在以下地址找到;我鼓励您尝试使用列表推导表达式来生成图表。例如,我们可以添加以下plot语句:

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

这给出了以下输出:

前面的图表显示了计算六次操作或七次操作的差异。我们可以看到这两种情况的分歧,这在谈论应用程序的具体情况时很重要。然而,我们在这里更感兴趣的是一种表征增长率的方法。我们不太关心绝对值,而是关心这些值随着n的增加而如何变化。通过这种方式,我们可以看到两条较低的曲线与顶部(x²)曲线相比具有相似的增长率。我们说这两条较低的曲线具有相同的复杂度类。这是一种理解和描述不同运行时行为的方法。我们将在下一节中正式化这个性能指标。

渐近分析

算法的渐近分析是指计算算法的运行时间。要确定哪个算法更好,给定两个算法,一个简单的方法是运行两个程序,对于给定的输入,执行时间最短的算法比另一个更好。然而,可能对于特定的输入,一个算法比另一个更好,而对于算法可能表现更差的任何其他输入值。

在渐近分析中,我们比较两个算法的输入大小而不是实际运行时间,并测量随着输入大小的增加,所需时间的增加情况。这通过以下代码表示:

# Linear search program to search an element, return the index position of the #array
def searching(search_arr, x):     
    for i in range(len(search_arr)):         
        if search_arr [i] == x:             
                return i     
    return -1

search_ar= [3, 4, 1, 6, 14]
x=4

searching(search_ar, x)
print("Index position for the element x is :",searching(search_ar, x))

#outputs index position of the element x that is - 1

假设数组的大小为nT(n)是执行线性搜索所需的关键操作总数,这个例子中的关键操作是比较。让我们以线性搜索为例来理解最坏情况、平均情况和最佳情况的复杂性:

  • 最坏情况分析:我们考虑上界运行时间,即算法所需的最长时间。在线性搜索中,最坏情况发生在要搜索的元素在最后一次比较中被找到或者在列表中未找到。在这种情况下,将会有最大数量的比较,即数组中的元素总数。因此,最坏情况的时间复杂度是Θ(n)。

  • 平均情况分析:在这种分析中,我们考虑元素可能在列表中被找到的所有可能情况,然后计算平均运行时间复杂度。例如,在线性搜索中,如果要搜索的元素在0索引处找到,那么所有位置的比较次数将为1,类似地,对于在1, 2, 3, … (n-1)索引位置找到的元素,比较次数将分别为 2, 3,直到n。因此,平均时间复杂度可以定义为average-case complexity= (1+2+3…n)/n = n(n+1)/2

  • 最佳情况分析:最佳情况的运行时间复杂度是算法运行所需的最短时间;它是下界运行时间。在线性搜索中,最佳情况是要搜索的元素在第一次比较中被找到。在这个例子中,很明显最佳情况的时间复杂度不取决于列表的长度。因此,最佳情况的时间复杂度将是Θ(1)

通常,我们使用最坏情况分析来分析算法,因为它为我们提供了运行时间的上界,而最佳情况分析是最不重要的,因为它为我们提供了算法所需的最小时间的下界。此外,计算平均情况分析非常困难。

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

大 O 符号

大 O 符号中的 O 代表 order,意味着增长率被定义为函数的阶。它衡量最坏情况的运行时间复杂度,即算法所需的最长时间。我们说一个函数T(n)是另一个函数F(n)的大 O,我们定义如下:

输入大小n的函数g(n)基于这样的观察:对于所有足够大的n值,g(n)都受到f(n)的常数倍的上界限制。目标是找到小于或等于f(n)的增长率最小的增长率。我们只关心在较高的n值发生的情况。变量n**0表示增长率不重要的阈值以下。函数T(n)表示紧密上界F(n)。在下图中,我们可以看到T(n) = n^(2) + 500 = O(n^(2)),其中C = 2,n[0]约为 23:

您还会看到符号f(n) = O(g(n))。这描述了O(g(n))实际上是一个包含所有增长速度与f(n)相同或更小的函数的集合。例如,O(n^(2))也包括函数O(n)O(nlogn)等。让我们考虑另一个例子。

函数f(x)= 19n log[2]n  +56 的大 O 时间复杂度为O(nlogn)

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

复杂度类 名称 示例操作
常数 常数 追加,获取项目,设置项目。
对数 对数 在排序数组中查找元素。
线性 线性 复制,插入,删除,迭代。
线性对数 线性对数 对列表进行排序,归并排序。
二次 二次 在图中两个节点之间找到最短路径。嵌套循环。
三次 三次 矩阵乘法。
指数 指数 汉诺塔问题,回溯。

组合复杂度类

通常,我们需要找到一系列基本操作的总运行时间。事实证明,我们可以组合简单操作的复杂度类来找到更复杂的组合操作的复杂度类。目标是分析函数或方法中的组合语句,以了解执行多个操作的总时间复杂度。组合两个复杂度类的最简单方法是将它们相加。当我们有两个连续的操作时就会发生这种情况。例如,考虑将元素插入列表然后对该列表进行排序的两个操作。我们可以看到插入项目需要O(n)时间,排序需要O(nlogn)时间。我们可以将总时间复杂度写为O(n + nlogn),也就是说,我们将两个函数放在O(...)中。我们只对最高阶项感兴趣,因此这让我们只剩下O(nlogn)

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

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

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

然后,这个循环的时间复杂度变为O(n²) * O(n) = O(n * n²) = O(n³)。在这里,我们只是将操作的时间复杂度乘以这个操作执行的次数。循环的运行时间最多是循环内部语句的运行时间乘以迭代次数。一个单独的嵌套循环,也就是一个循环嵌套在另一个循环中,假设两个循环都运行 n 次,将在 n²时间内运行,就像下面的例子中演示的那样:

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

每个语句都是一个常数c,执行nn次,因此我们可以将运行时间表示为以下形式:

对于嵌套循环中的连续语句,我们将每个语句的时间复杂度相加,然后乘以语句执行的次数,例如:

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^(2 )= O(n²)

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

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

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

由此可得,总时间 = O(log(n))

尽管大 O 符号是渐近分析中最常用的符号,但还有两个相关的符号应该简要提到。它们是 Omega 符号和 Theta 符号。

Omega 符号(Ω)

Omega 符号描述了算法的严格下界,类似于大 O 符号描述了严格的上界。Omega 符号计算算法的最佳运行时间复杂度。它提供了最高的增长率T(n),它小于或等于给定算法。它可以计算如下:

Theta 符号(ϴ)

通常情况下,给定函数的上界和下界是相同的,Theta 符号的目的是确定是否是这种情况。定义如下:

尽管 Omega 和 Theta 符号需要完全描述增长率,但最实用的是大 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函数的setup函数,以及一个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函数并绘制结果,以及适当缩放的函数进行比较,用虚线表示:

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 中的指针

  • 理解节点的概念和实现

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

技术要求

根据本章讨论的概念执行程序将有助于更好地理解它们。我们已经提供了本章中所有程序和概念的源代码。我们还在 GitHub 上提供了完整的源代码文件,链接如下:github.com/PacktPublishing/Hands-On-Data-Structures-and-Algorithms-with-Python-Second-Edition/tree/master/Chapter04

我们假设您已经在系统上安装了 Python。

从一个例子开始

让我们先提醒一下指针的概念,因为我们将在本章中处理它们。首先,想象一下你有一所房子想要卖掉。由于时间不够,你联系了一个中介来寻找感兴趣的买家。所以,你拿起你的房子,把它带到中介那里,中介会把房子带给任何可能想要买它的人。你觉得这很荒谬?现在想象一下你有一些处理图像的 Python 函数。所以,你在这些函数之间传递高分辨率图像数据。

当然,你不会带着你的房子四处走动。你要做的是把房子的地址写在一张废纸上,递给中介。房子还在原地,但包含房子地址的纸条在传递。你甚至可以在几张纸上写下来。每张纸都足够小,可以放在你的钱包里,但它们都指向同一所房子。

事实证明,在 Python 领域情况并没有太大不同。那些大型图像文件仍然在内存中的一个地方。

你要做的是创建变量,保存这些图像在内存中的位置。这些变量很小,可以在不同的函数之间轻松传递。

这就是指针的好处——它们允许你用一个简单的内存地址指向一个潜在的大内存段。

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

在 Python 中,你不会直接操作指针,不像其他一些语言,比如 C 或 Pascal。这导致一些人认为 Python 中不使用指针。这是大错特错。考虑一下在 Python 交互式 shell 中的这个赋值:

>>> s = set()

通常我们会说s集合类型的变量。也就是说,s是一个集合。然而,这并不严格正确;变量s实际上是一个引用(一个安全指针)指向一个集合。集合构造函数在内存中创建一个集合,并返回该集合开始的内存位置。这就是存储在s中的内容。Python 隐藏了这种复杂性。我们可以安全地假设s是一个集合,一切都运行正常。

数组

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

当然,硬币的另一面是数组非常快速。由于每个元素在内存中紧随前一个元素,因此无需在不同的内存位置之间跳转。在选择在你自己的现实世界应用程序中列表和数组之间时,这可能是一个非常重要的考虑因素。

我们已经在第二章中讨论了数组,Python 数据类型和结构。我们看了数组数据类型,并讨论了可以对其执行的各种操作。

指针结构

与数组相反,指针结构是可以在内存中分散的项目列表。这是因为每个项目都包含一个或多个指向结构中其他项目的链接。这些链接的类型取决于我们拥有的结构类型。如果我们处理的是链表,那么我们将有指向结构中下一个(可能是上一个)项目的链接。在树的情况下,我们有父子链接以及兄弟链接。

指针结构有几个好处。首先,它们不需要顺序存储空间。其次,它们可以从小开始,随着向结构添加更多节点而任意增长。然而,指针的这种灵活性是有代价的。我们需要额外的空间来存储地址。例如,如果你有一个整数列表,每个节点都将占用空间来存储一个整数,以及额外的整数来存储指向下一个节点的指针。

节点

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

首先,让我们考虑一个例子。我们将创建一些字符串:

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

现在你有了三个变量,每个变量都有一个唯一的名称、类型和值。目前,没有办法显示这些变量之间的关系。节点允许我们展示这些变量之间的关系。节点是数据的容器,以及一个或多个指向其他节点的链接。链接就是指针。

一种简单类型的节点只有一个指向下一个节点的链接。正如我们所知道的指针,字符串实际上并没有存储在节点中,而是有一个指向实际字符串的指针。考虑下面的图表中的例子,其中有两个节点。第一个节点有一个指向存储在内存中的字符串(eggs)的指针,另一个指针存储着另一个节点的地址:

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

查找端点

我们已经创建了三个节点——一个包含eggs,一个ham,另一个spameggs节点指向ham节点,ham节点又指向spam节点。但是spam节点指向什么呢?由于这是列表中的最后一个元素,我们需要确保它的下一个成员有一个清晰的值。

如果我们使最后一个元素指向空,那么我们就清楚地表明了这一事实。在 Python 中,我们将使用特殊值None来表示空。考虑下面的图表。节点B是列表中的最后一个元素,因此它指向None

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

节点类

这是我们迄今为止讨论的一个简单节点实现:

class Node:

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

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

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

您可能想要做的一件事是实现_str_方法,以便在将节点对象传递给打印时调用所包含对象的_str_方法:

def _str_ (self):
   return str(data)

其他节点类型

正如我们已经讨论过的,一个节点具有指向下一个节点的指针来链接数据项,但它可能是最简单的节点类型。此外,根据我们的需求,我们可以创建许多其他类型的节点。

有时我们想从节点A到节点B,但同时我们可能需要从节点B到节点A。在这种情况下,我们除了Next指针之外还添加了Previous指针:

从上图可以看出,我们除了数据和Next指针之外,还创建了Previous指针。还需要注意的是,BNext指针是None,而节点APrevious指针也是None,这表示我们已经到达了列表的边界。第一个节点A的前指针指向None,因为它没有前驱,就像最后一个项目BNext指针指向None一样,因为它没有后继节点。

引入列表

列表是一个重要且流行的数据结构。列表有三种类型——单链表、双链表和循环链表。我们将在本章更详细地讨论这些数据结构。我们还将在接下来的小节中讨论各种重要操作,如append操作、delete操作以及可以在这些列表上执行的traversingsearching操作。

单链表

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

实际上,我们可以使用之前创建的节点类来实现一个非常简单的单链表。例如,我们创建三个存储三个字符串的节点n1n2n3

>>> 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设置为指向列表中的下一个元素。我们一直这样做,直到我们到达列表的末尾。

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

  • 程序员需要做太多的手动工作

  • 这太容易出错了(这是第一点的结果)

  • 列表的内部工作过于暴露给程序员

我们将在接下来的章节中解决所有这些问题。

单链表类

列表是一个与节点不同的概念。我们首先创建一个非常简单的类来保存我们的列表。我们从一个构造函数开始,它保存对列表中第一个节点的引用(在下面的代码中是tail)。由于这个列表最初是空的,我们将首先将这个引用设置为None

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

追加操作

我们需要执行的第一个操作是向列表追加项目。这个操作有时被称为插入操作。在这里,我们有机会隐藏Node类。我们的列表类的用户实际上不应该与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)。如果是None,我们将新节点设置为列表的第一个节点;否则,我们通过遍历列表找到插入点,将最后一个节点的下一个指针更新为新节点。

考虑以下示例代码以追加三个节点:

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

列表遍历将按照我们之前讨论的方式进行。您将从列表本身获取列表的第一个元素,然后通过next指针遍历列表:

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

更快的追加操作

在前一节中,追加方法存在一个大问题:它必须遍历整个列表以找到插入点。当列表中只有一些项目时,这可能不是问题,但当列表很长时,这将是一个大问题,因为我们需要每次遍历整个列表来添加一个项目。每次追加都会比上一次略慢。追加操作的当前实现速度降低了O(n),这在长列表的情况下是不可取的。

为了解决这个问题,我们不仅存储了对列表中第一个节点的引用,还存储了对最后一个节点的引用。这样,我们可以快速地在列表的末尾追加一个新节点。追加操作的最坏情况运行时间现在从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.headself.tail变量指向列表中的第一个节点。

获取列表的大小

我们希望能够通过计算节点的数量来获取列表的大小。我们可以通过遍历整个列表并在遍历过程中增加一个计数器来实现这一点:

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

这很好用。但是,列表遍历可能是一个昂贵的操作,我们应该尽量避免。因此,我们将选择另一种重写方法。我们在SinglyLinkedList类中添加一个 size 成员,在构造函数中将其初始化为0。然后我们在追加方法中将 size 增加一:

class SinglyLinkedList:
    def init (self):
        # ...

        self.size = 0

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

因为我们现在只是读取节点对象的 size 属性,而不是使用循环来计算列表中节点的数量,所以我们将最坏情况的运行时间从O(n)降低到了O(1)

改进列表遍历

如果您注意到,在列表遍历的早期,我们向客户/用户公开了节点类。但是,希望客户端节点不要与节点对象进行交互。我们需要使用node.data来获取节点的内容,使用node.next来获取下一个节点。我们可以通过创建一个返回生成器的方法来访问数据。如下所示:

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

现在,列表遍历变得简单得多,看起来也好得多。我们可以完全忽略列表之外有一个叫做节点的东西:

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.count -= 1
            return
        prev = current
        current = current.next

删除节点的delete操作的时间复杂度为O(n)

列表搜索

我们可能还需要一种方法来检查列表是否包含某个项目。由于我们之前编写的iter()方法,这种方法非常容易实现。循环的每次通过将当前数据与正在搜索的数据进行比较。如果找到匹配项,则返回True,否则返回False

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

清除列表

我们可能需要快速清除列表;有一种非常简单的方法可以做到。我们可以通过简单地将指针头和尾清除为None来清除列表:

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

双向链表

我们已经讨论了单链表以及可以在其上执行的重要操作。现在,我们将在本节中专注于双向链表的主题。

双向链表与单链表非常相似,因为我们使用了将字符串节点串在一起的相同基本概念,就像在单链表中所做的那样。单链表和双链表之间唯一的区别在于,在单链表中,每个连续节点之间只有一个链接,而在双链表中,我们有两个指针——一个指向下一个节点,一个指向前一个节点。请参考以下节点的图表;有一个指向下一个节点和前一个节点的指针,它们设置为None,因为没有节点连接到这个节点。考虑以下图表:

单链表中的节点只能确定与其关联的下一个节点。然而,没有办法或链接可以从这个引用节点返回。流动的方向只有一种。

在双向链表中,我们解决了这个问题,并且不仅可以引用下一个节点,还可以引用前一个节点。考虑以下示例图表,以了解两个连续节点之间链接的性质。这里,节点A引用节点B;此外,还有一个链接返回到节点A

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

双向链表可以在任何方向进行遍历。在双向链表中,可以很容易地引用节点的前一个节点,而无需使用变量来跟踪该节点。然而,在单链表中,可能难以返回到列表的开始或开头,以便在列表的开头进行一些更改,而在双向链表的情况下现在非常容易。

双向链表节点

创建双向链表节点的 Python 代码包括其初始化方法、prev指针、next指针和data实例变量。当新建一个节点时,所有这些变量默认为None

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

prev变量引用前一个节点,而next变量保留对下一个节点的引用,data变量存储数据。

双向链表类

双向链表类捕获了我们的函数将要操作的数据。对于size方法,我们将计数实例变量设置为0;它可以用来跟踪链表中的项目数量。当我们开始向列表中插入节点时,headtail将指向列表的头部和尾部。考虑以下用于创建类的 Python 代码:

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

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

双链表还需要返回列表大小、向列表中插入项目以及从列表中删除节点的功能。我们将在以下子部分中讨论并提供关于双链表的重要功能和代码。让我们从附加操作开始。

附加操作

append操作用于在列表的末尾添加元素。重要的是要检查列表的head是否为None。如果是None,则表示列表为空,否则列表有一些节点,并且将向列表添加一个新节点。如果要向空列表添加新节点,则应将head指向新创建的节点,并且列表的尾部也应通过head指向该新创建的节点。经过这一系列步骤,头部和尾部现在将指向同一个节点。以下图示了当向空列表添加新节点时,双链表的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

上述程序的If部分用于将节点添加到空节点;如果列表不为空,则将执行上述程序的else部分。如果要将新节点添加到列表中,则新节点的前一个变量应设置为列表的尾部:

new_node.prev = self.tail

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

self.tail.next = new_node

最后,我们更新尾部指针以指向新节点:

self.tail = new_node

由于附加操作将节点数增加一,因此我们将计数器增加一:

self.count += 1

以下图示了向现有列表附加操作的可视表示:

删除操作

与单链表相比,双链表中的删除操作更容易。

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

在双链表中,delete操作可能会遇到以下四种情况:

  • 未找到要删除的搜索项在列表中

  • 要删除的搜索项位于列表的开头

  • 要删除的搜索项位于列表的末尾

  • 要删除的搜索项位于列表的中间

要删除的节点是通过将数据实例变量与传递给方法的数据进行匹配来识别的。如果数据与节点的数据变量匹配,则将删除该匹配的节点。以下是从双链表中删除节点的完整代码。我们将逐步讨论此代码的每个部分:

def delete(self, data):
    """ Delete a node from the list. """ 
    current = self.head 
    node_deleted = False 
    if current is None:       #Item to be deleted is not found in the list
        node_deleted = False 

    elif current.data == data:   #Item to be deleted is found at starting of list
        self.head = current.next  
        self.head.prev = None 
        node_deleted = True 

    elif self.tail.data == data:   #Item to be deleted is found at the end of list.
        self.tail = self.tail.prev  
        self.tail.next = None 
        node_deleted = True 
    else: 
        while current:          #search item to be deleted, and delete that node
            if current.data == data: 
                current.prev.next = current.next  
                current.next.prev = current.prev 
                node_deleted = True 
            current = current.next 

    if node_deleted: 
        self.count -= 1

最初,我们创建一个node_deleted变量来表示列表中被删除的节点,并将其初始化为False。如果找到匹配的节点并随后删除,则将node_deleted变量设置为True。在删除方法中,current变量最初设置为列表的head(即指向列表的self.head)。请参阅以下代码片段:

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

接下来,我们使用一组if...else语句来搜索列表的各个部分,找出具有指定数据的节点,该节点将被删除。

首先,我们在head节点处搜索要删除的数据,如果在head节点处匹配数据,则将删除该节点。由于current指向head,如果currentNone,则表示列表为空,没有节点可以找到要删除的节点。以下是其代码片段:

if current is None:
  node_deleted = False

但是,如果current(现在指向头部)包含正在搜索的数据,这意味着我们在head节点找到了要删除的数据,那么self.head被标记为指向current节点。由于现在head后面没有节点了,self.head.prev被设置为None。考虑以下代码片段:

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

同样,如果要删除的节点位于列表的“尾部”,我们通过将其前一个节点指向None来删除最后一个节点。这是双向链表中“删除”操作的第三种可能情况,搜索要删除的节点可能在列表末尾找到。self.tail被设置为指向self.tail.prevself.tail.next被设置为None,因为后面没有节点了。考虑以下代码片段:

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

最后,我们通过循环整个节点列表来搜索要删除的节点。如果要删除的数据与节点匹配,则删除该节点。要删除节点,我们使用代码current.prev.next = current.next使current节点的前一个节点指向当前节点的下一个节点。在那之后,我们使用current.next.prev = current.prev使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

为了更好地理解双向链表中的删除操作的概念,请考虑以下示例图。在下图中,有三个节点,ABC。要删除列表中间的节点B,我们实质上会使A指向C作为其下一个节点,同时使C指向A作为其前一个节点:

进行此操作后,我们得到以下列表:

最后,检查node_delete变量以确定是否实际删除了节点。如果删除了任何节点,则将计数变量减少1,这可以跟踪列表中节点的总数。以下代码片段减少了删除任何节点时的计数变量1

if node_deleted:
  self.count -= 1

列表搜索

在双向链表中搜索项目与在单向链表中的方式类似。我们使用iter()方法来检查所有节点中的数据。当我们遍历列表中的所有数据时,每个节点都与contain方法中传递的数据进行匹配。如果我们在列表中找到项目,则返回True,表示找到了该项目,否则返回False,这意味着在列表中未找到该项目。其 Python 代码如下:

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

双向链表中的追加操作具有运行时间复杂度O(1),删除操作具有复杂度O(n)

循环列表

循环链表是链表的特殊情况。在循环链表中,端点彼此相连。这意味着列表中的最后一个节点指向第一个节点。换句话说,我们可以说在循环链表中,所有节点都指向下一个节点(在双向链表的情况下还指向前一个节点),没有结束节点,因此没有节点将指向Null。循环列表可以基于单向链表和双向链表。在双向循环链表的情况下,第一个节点指向最后一个节点,最后一个节点指向第一个节点。考虑以下基于单向链表的循环链表的图示,其中最后一个节点C再次连接到第一个节点A,从而形成循环列表:

下图显示了基于双向链表的循环链表概念,其中最后一个节点C通过next指针再次连接到第一个节点A。节点A也通过previous指针连接到节点C,从而形成一个循环列表:

在这里,我们将看一个单链表循环列表的实现。一旦我们理解了基本概念,实现双链表循环列表应该是直截了当的。

我们可以重用我们在子节中创建的节点类——单链表。事实上,我们也可以重用大部分SinglyLinkedList类的部分。因此,我们将专注于循环列表实现与普通单链表不同的方法。

追加元素

要在单链表循环列表中追加一个元素,我们只需包含一个新功能,使新添加或追加的节点指向tail节点。这在以下代码中得到了演示。与单链表实现相比,多了一行额外的代码,如粗体所示:

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

在循环列表中删除元素

要删除循环列表中的一个节点,看起来我们可以类似于在追加操作中所做的方式来做。只需确保head指向tail。在删除操作中只有一行需要更改。只有当我们删除tail节点时,我们需要确保head节点被更新为指向新的尾节点。这将给我们以下实现(粗体字代码行是单链表中删除操作实现的一个补充):

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,因为在循环链表的情况下,当前节点永远不会指向None。如果删除一个现有节点,你不会看到这一点,但是尝试删除一个不存在的节点,你将陷入无限循环。

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

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 个元素,我们就会跳出循环。

总结

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

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

第五章:栈和队列

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

在本章中,我们将了解栈和队列的概念。我们还将使用各种方法在 Python 中实现这些数据结构,如listsnode

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

  • 使用各种方法实现栈和队列

  • 栈和队列的一些真实应用示例

技术要求

你应该有一台安装了 Python 的计算机系统。本章讨论的概念的所有程序都在书中提供,也可以在以下链接的 GitHub 存储库中找到:github.com/PacktPublishing/Hands-On-Data-Structures-and-Algorithms-with-Python-Second-Edition/tree/master/Chapter05

栈是一种存储数据的数据结构,类似于厨房里的一堆盘子。你可以把一个盘子放在栈的顶部,当你需要一个盘子时,你从栈的顶部拿走它。最后添加到栈上的盘子将首先从栈中取出。同样,栈数据结构允许我们从一端存储和读取数据,最后添加的元素首先被取出。因此,栈是一种后进先出LIFO)结构:

前面的图表描述了一堆盘子。只有将一个盘子放在堆的顶部才有可能添加一个盘子。从盘子堆中移除一个盘子意味着移除堆顶上的盘子。

栈上执行的两个主要操作是pushpop。当元素被添加到栈顶时,它被推送到栈上。当要从栈顶取出元素时,它被弹出栈。有时使用的另一个操作是peek,它可以查看栈顶的元素而不将其弹出。

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

def b(): 
    print('b') 

def a(): 
    b() 

a() 
print("done")

当程序执行到a()的调用时,发生以下情况:

  1. 首先将当前指令的地址推送到栈上,然后跳转到a的定义

  2. 在函数a()内部,调用函数b()

  3. 函数b()的返回地址被推送到栈上

  4. 一旦b()函数和函数执行完毕,返回地址将从栈中弹出,这将带我们回到函数a()

  5. 当函数a中的所有指令完成时,返回地址再次从栈中弹出,这将带我们回到main函数和print语句

栈也用于在函数之间传递数据。考虑以下示例。假设你的代码中有以下函数调用:

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

内部发生的是,函数传递的值14, 'eggs', 'ham''spam'将依次被推送到栈上,如下图所示:

当代码调用jump到函数定义时,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类。它的开始方式与单链表类似。我们需要两样东西来实现使用节点的栈:

  1. 首先,我们需要知道位于栈顶的节点,以便我们能够通过这个节点应用pushpop操作。

  2. 我们还希望跟踪栈中节点的数量,因此我们向栈类添加一个size变量。考虑以下代码片段用于栈类:

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

推送操作

push操作是栈上的一个重要操作;它用于在栈顶添加一个元素。我们在 Python 中实现推送功能以了解它是如何工作的。首先,我们检查栈是否已经有一些项目或者它是空的,当我们希望在栈中添加一个新节点时。

如果栈已经有一些元素,那么我们需要做两件事:

  1. 新节点必须使其下一个指针指向先前位于顶部的节点。

  2. 我们通过将self.top指向新添加的节点,将这个新节点放在栈的顶部。请参阅以下图表中的两条指令:

如果现有栈为空,并且要添加的新节点是第一个元素,我们需要将此节点作为元素的顶部节点。因此,self.top将指向这个新节点。请参阅以下图表:

以下是stackpush操作的完整实现:

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

弹出操作

现在,我们需要栈的另一个重要功能,那就是pop操作。它读取栈的顶部元素并将其从栈中移除。pop操作返回栈的顶部元素,并且如果栈为空则返回None

要在栈上实现pop操作:

  1. 首先,检查栈是否为空。在空栈上不允许pop操作。

  2. 如果栈不为空,可以检查顶部节点是否具有其next属性指向其他节点。这意味着栈中有元素,并且顶部节点指向栈中的下一个节点。要应用pop操作,我们必须更改顶部指针。下一个节点应该在顶部。我们通过将self.top指向self.top.next来实现这一点。请参阅以下图表以了解这一点:

  1. 当栈中只有一个节点时,在弹出操作后栈将为空。我们必须将顶部指针更改为None。见下图:

  1. 移除这样一个节点会导致self.top指向None

  1. 如果栈不为空,如果栈的顶部节点具有其next属性指向其他节点,则可以将栈的大小减少1。以下是 Python 中stackpop操作的完整代码:
    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 

查看操作

还有另一个可以应用在栈上的重要操作——peek方法。这个方法返回栈顶的元素,而不从栈中删除它。peekpop之间唯一的区别是,peek方法只返回顶部元素;然而,在pop方法的情况下,顶部元素被返回并且也从栈中删除。

弹出操作允许我们查看顶部元素而不改变栈。这个操作非常简单。如果有顶部元素,则返回其数据;否则,返回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 的相同概念。首先添加的项目将首先被读取。我们将称添加元素到队列的操作为enqueue。当我们从队列中删除一个元素时,我们将称之为dequeue操作。每当一个元素被入队时,队列的长度或大小增加 1。相反,出队的项目会减少队列中的元素数量 1。

为了演示这两个操作,以下表格显示了从队列中添加和删除元素的效果:

队列操作 大小 内容 操作结果
Queue() 0 [] 创建了一个空的队列对象。
Enqueue Packt 1 ['Packt'] 队列中添加了一个 Packt 项目。
Enqueue 发布 2 ['发布', 'Packt'] 队列中添加了一个 发布 项目。
Size() 2 ['Publishing', 'Packt'] 返回队列中的项目数,在此示例中为 2。
Dequeue() 1 ['Publishing'] Packt项目被出队并返回。(这个项目是第一个添加的,所以它被第一个移除。)
Dequeue() 0 [] Publishing项目被出队并返回。(这是最后添加的项目,所以最后返回。)

基于列表的队列

队列可以使用各种方法实现,例如liststacknode。我们将逐一讨论使用所有这些方法实现队列的方法。让我们从使用 Python 的list类实现队列开始。这有助于我们快速了解队列。必须在队列上执行的操作封装在ListQueue类中:

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

在初始化方法__init__中,items实例变量设置为[],这意味着创建时队列为空。队列的大小也设置为enqueuedequeue是队列中重要的方法,我们将在下一小节中讨论它们。

入队操作

enqueue操作将项目添加到队列中。它使用list类的insert方法在列表的前面插入项目(或数据)。请参阅以下代码以实现enqueue方法:

  def enqueue(self, data): 
    self.items.insert(0, data)   # Always insert items at index 0
    self.size += 1               # increment the size of the queue by 1

重要的是要注意我们如何使用列表实现队列中的插入。概念是我们在列表的索引0处添加项目;这是数组或列表中的第一个位置。要理解在列表的索引0处添加项目时队列的工作原理的概念,请考虑以下图表。我们从一个空列表开始。最初,我们在索引0处添加一个项目1。接下来,我们在索引0处添加一个项目2;它将先前添加的项目移动到下一个索引。

接下来,当我们再次在索引0处向列表中添加一个新项目3时,已添加到列表中的所有项目都会被移动,如下图所示。同样,当我们在索引0处添加项目4时,列表中的所有项目都会被移动:

因此,在我们使用 Python 列表实现队列时,数组索引0是唯一可以向队列中插入新数据元素的位置。insert操作将列表中现有的数据元素向上移动一个位置,然后将新数据插入到索引0处创建的空间中。

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

self.size += 1 

我们可以使用 Python 列表的shift方法作为在0处实现插入的另一种方法。

出队操作

dequeue操作用于从队列中删除项目。该方法返回队列中的顶部项目并将其从队列中删除。以下是dequeue方法的实现:

  def dequeue(self):
    data = self.items.pop()    # delete the topmost item from the queue
    self.size -= 1             # decrement the size of the queue by 1
     return data

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

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

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

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

考虑以下图表作为我们的队列实现,其中添加了三个元素—123。执行dequeue操作时,数据为1的节点从队列的前面移除,因为它是最先添加的:

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

由于一个原因,enqueue操作非常低效。该方法必须首先将所有元素向前移动一个空间。想象一下,列表中有 100 万个元素需要在每次向队列添加新元素时进行移动。这将使大型列表的入队过程非常缓慢。

基于堆栈的队列

队列也可以使用两个栈来实现。我们最初设置了两个实例变量来在初始化时创建一个空队列。这些是帮助我们实现队列的栈。在这种情况下,栈只是允许我们在其上调用pushpop方法的 Python 列表,最终允许我们获得enqueuedequeue操作的功能。以下是Queue类:

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

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

入队操作

enqueue方法用于向队列中添加项目。这个方法非常简单,只接收要附加到队列的data。然后将此数据传递给queue类中inbound_stackappend方法。此外,append方法用于模拟push操作,将元素推送到栈的顶部。以下代码是使用 Python 中的栈实现enqueue的方法:

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

要将数据enqueueinbound_stack,以下代码可以完成任务:

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

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

[5, 6, 7]

出队操作

dequeue操作用于按添加的项目顺序从队列中删除元素。添加到我们的队列中的新元素最终会出现在inbound_stack中。我们不是从inbound_stack中删除元素,而是将注意力转移到另一个栈,即outbound_stack。我们只能通过outbound_stack删除队列中的元素。

为了理解outbound_stack如何用于从队列中删除项目,让我们考虑以下示例。

最初,我们的inbound_stack填充了元素567,如下图所示:

我们首先检查outbound_stack是否为空。由于开始时它是空的,我们使用pop操作将inbound_stack的所有元素移动到outbound_stack。现在inbound_stack变为空,而outbound_stack保留元素。我们在下图中展示了这一点,以便更清楚地理解:

现在,如果outbound_stack不为空,我们继续使用pop操作从队列中删除项目。在前面的图中,当我们对outbound_stack应用pop操作时,我们得到了元素5,这是正确的,因为它是第一个添加的元素,应该是从队列中弹出的第一个元素。这样outbound_stack就只剩下两个元素了:

以下是队列的dequeue方法的实现:

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() 

if语句首先检查outbound_stack是否为空。如果不为空,我们继续使用pop方法删除队列前端的元素,如下所示:

return self.outbound_stack.pop() 

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

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

while循环将在inbound_stack中有元素的情况下继续执行。

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

让我们考虑一个示例代码,以理解队列上的操作。我们首先使用队列实现向队列中添加三个项目,即567。接下来,我们应用dequeue操作从队列中删除项目。以下是代码:

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类和Node类非常相似,用于在双向链表中添加节点:

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

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

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

入队操作

通过enqueue方法向Queue对象添加元素。元素或数据通过节点添加。enqueue方法的代码与我们在第四章中讨论的双向链表的append操作非常相似,列表和指针结构

入队操作从传递给它的数据创建一个节点,并将其附加到队列的tail,如果队列为空,则将self.headself.tail都指向新创建的节点。队列中元素的总数增加了一行self.count += 1。如果队列不为空,则新节点的previous变量设置为列表的tail,并且尾部的下一个指针(或变量)设置为新节点。最后,我们更新尾指针指向新节点。代码如下所示:

    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

出队操作

使我们的双向链表作为队列的另一个操作是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.count1,那么意味着列表中只有一个节点,也就是队列。因此,要移除相关的节点(由self.head指向),self.headself.tail变量被设置为None

如果队列有多个节点,那么头指针会移动到self.head之后的下一个节点。

在执行if语句之后,该方法返回被head指向的节点。此外,在这两种情况下,即初始计数为1和大于1时,变量self.count都会减少1

有了这些方法,我们已经实现了一个队列,很大程度上借鉴了双向链表的思想。

还要记住,将我们的双向链表转换成队列的唯一方法是enqueuedequeue方法。

队列的应用

队列可以在许多实际的计算机应用程序中用于实现各种功能。例如,可以通过排队打印机要打印的内容,而不是为网络上的每台计算机提供自己的打印机。当打印机准备好打印时,它将选择队列中的一个项目(通常称为作业)进行打印。它将按照不同计算机给出的命令的顺序打印出来。

操作系统也会对要由 CPU 执行的进程进行排队。让我们创建一个应用程序,利用队列来创建一个简单的媒体播放器。

媒体播放器队列

大多数音乐播放器软件允许用户将歌曲添加到播放列表中。点击播放按钮后,主播放列表中的所有歌曲都会依次播放。使用队列可以实现歌曲的顺序播放,因为排队的第一首歌曲是要播放的第一首歌曲。这符合 FIFO 首字母缩写。我们将实现自己的播放列表队列以按 FIFO 方式播放歌曲。

我们的媒体播放器队列只允许添加曲目以及播放队列中的所有曲目。在一个完整的音乐播放器中,线程将被用于改进与队列的交互方式,同时音乐播放器继续用于选择下一首要播放、暂停或停止的歌曲。

track类将模拟音乐曲目:

from random import randint 
class Track: 
    def __init__(self, title=None): 
        self.title = title 
        self.length = randint(5, 10) 

每个曲目都保存了歌曲的标题的引用,以及歌曲的长度。歌曲的长度是在510之间的随机数。Python 中的随机模块提供了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对象:

您可以看到,我们的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,也可以以树形式表示。我们将在本章中看一些树的用途。

在本章中,我们将涵盖以下主题:

  • 树的术语和定义

  • 二叉树和二叉搜索树

  • 树的遍历

  • 三叉搜索树

技术要求

本章讨论的所有源代码都在本书的 GitHub 存储库中提供,网址为github.com/PacktPublishing/Hands-On-Data-Structures-and-Algorithms-with-Python-3.x-Second-Edition/tree/master/Chapter06

术语

让我们考虑与树数据结构相关的一些术语。

要理解树,我们首先需要了解与其相关的基本概念。树是一种数据结构,其中数据以分层形式组织。以下图表包含一个典型的树,由字符节点 A 到 M 标记:

以下是与树相关的术语列表:

  • 节点:在前面的图表中,每个圈起来的字母代表一个节点。节点是实际存储数据的任何数据结构。

  • 根节点:根节点是树中所有其他节点都连接到的第一个节点。在每棵树中,始终存在一个唯一的根节点。我们示例树中的根节点是节点 A。

  • 子树:树的子树是具有其节点作为其他树的后代的树。例如,节点 F、K 和 L 形成原始树的子树,其中包含所有节点。

  • 给定节点的子节点总数称为该节点的度。只包含一个节点的树的度为 0。在前面的图表中,节点 A 的度为 2,节点 B 的度为 3,节点 C 的度为 3,同样,节点 G 的度为 1。

  • 叶节点:叶节点没有任何子节点,是给定树的终端节点。叶节点的度始终为 0。在前面的图表中,节点 J、E、K、L、H、M 和 I 都是叶节点。

  • :树中任意两个节点之间的连接称为。给定树中边的总数将最多比树中的总节点数少一个。前面示例树结构中显示了一个边的示例。

  • 父节点:树中具有进一步子树的节点是该子树的父节点。例如,节点 B 是节点 D、E 和 F 的父节点,节点 F 是节点 K 和 L 的父节点。

  • 子节点:这是连接到其父节点的节点,是该节点的后代节点。例如,节点 B 和 C 是节点 A 的子节点,而节点 H、G 和 I 是节点 C 的子节点。

  • 兄弟节点:具有相同父节点的所有节点是兄弟节点。例如,节点 B 和 C 是兄弟节点,同样,节点 D、E 和 F 也是兄弟节点。

  • 层级:树的根节点被认为是在第 0 级。根节点的子节点被认为在第 1 级,第 1 级节点的子节点被认为在第 2 级,依此类推。例如,根节点在第 0 级,节点 B 和 C 在第 1 级,节点 D、E、F、H、G 和 I 在第 2 级。

  • 树的高度:树中最长路径上的节点总数是树的高度。例如,在前面的树示例中,树的高度为 4,因为最长路径A-B-D-JA-C-G-MA-B-F-K都有 4 个节点。

  • 深度:节点的深度是从树的根到该节点的边的数量。在前面的树示例中,节点 H 的深度为 2。

我们将通过考虑树中的节点并抽象出一个类来开始处理树。

树节点

在线性数据结构中,数据项按顺序依次存储,而非线性数据结构将数据项以非线性顺序存储,其中一个数据项可以连接到多个数据项。线性数据结构中的所有数据项可以在一次遍历中遍历,而在非线性数据结构中这是不可能的。树是非线性数据结构;它们以与数组列表队列等其他线性数据结构不同的方式存储数据。

在树数据结构中,节点按照父-子关系排列。树中的节点之间不应该有循环。树结构有节点形成层次结构,没有节点的树称为空树。

首先,我们将讨论一种最重要和特殊的树,即二叉树。二叉树是节点的集合,树中的节点可以有零个、1 个或 2 个子节点。简单的二叉树最多有两个子节点,即左子节点和右子节点。例如,在下面的二叉树示例中,有一个根节点,它有两个子节点(左子节点、右子节点):

如果二叉树的所有节点都有零个或两个子节点,并且没有一个节点有 1 个子节点,则称树为满二叉树。如果二叉树完全填满,底层可能有一个例外,从左到右填充,则称为完全二叉树

就像我们之前的实现一样,节点是数据的容器,并且持有对其他节点的引用。在二叉树节点中,这些引用是指左右子节点。让我们看一下下面的 Python 代码,构建一个二叉树node类:

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

为了测试这个类,我们首先要创建四个节点——n1n2n3n4

    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 

遍历上述代码块的输出如下:

root node 
left child node 
left grandchild node

树的遍历

访问树中所有节点的方法称为树的遍历。这可以通过深度优先搜索DFS)或广度优先搜索BFS)来完成。我们将在接下来的小节中讨论这两种方法。

深度优先遍历

在深度优先遍历中,我们从根开始遍历树,并尽可能深入每个子节点,然后继续遍历到下一个兄弟节点。我们使用递归方法进行树遍历。深度优先遍历有三种形式,即中序、前序和后序。

中序遍历和中缀表示法

中序树遍历的工作方式如下。首先,我们检查当前节点是否为空或空。如果不为空,我们遍历树。在中序树遍历中,我们按照以下步骤进行:

  1. 我们开始遍历左子树,并递归调用“中序”函数

  2. 接下来,我们访问根节点

  3. 最后,我们遍历右子树,并递归调用“中序”函数

因此,在“中序”树遍历中,我们按照(左子树、根、右子树)的顺序访问树中的节点。

让我们考虑一个示例来理解中序树遍历:

在“中序”遍历的示例二叉树中,首先,我们递归访问根节点 A 的左子树。节点 A 的左子树以节点 B 为根,所以我们再次转到节点 B 的左子树,即节点 D。我们递归地转到节点 D 的左子树,以便我们得到根节点 D 的左子树。因此,我们首先访问左子节点,即 G,然后访问根节点 D,然后访问右子节点 H。

接下来,我们访问节点 B,然后访问节点 E。这样,我们已经访问了根节点 A 的左子树。所以下一步,我们访问根节点 A。之后,我们将访问根节点 A 的右子树。在这里,我们转到根节点 C 的左子树,它是空的,所以下一步我们访问节点 C,然后访问节点 C 的右子节点,即节点 F。

因此,这个示例树的中序遍历是“G-D-H-B-E-A-C-F”。

树的递归函数的 Python 实现,以返回树中节点的“中序”列表如下:

    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_child,然后访问根节点,最后我们再次递归调用“中序”函数与current.right_child

中缀表示法(也称为逆波兰表示法)是一种常用的表示算术表达式的表示法,其中操作符放置在操作数之间。通常使用这种方式来表示算术表达式,因为这是我们在学校通常学到的方式。例如,操作符被插入(插入)在操作数之间,如3 + 4。必要时,可以使用括号来构建更复杂的表达式,例如(4 + 5) * (5 - 3)

表达式树是一种特殊的二叉树,可用于表示算术表达式。表达式树的中序遍历产生中缀表示法。例如,考虑以下表达式树:

前面的表达式树的中序遍历给出了中缀表示法,即(5 + 3)

前序遍历和前缀表示法

前序树遍历的工作方式如下。首先,我们检查当前节点是否为空或空。如果不为空,我们遍历树。前序树遍历的工作方式如下:

  1. 我们从根节点开始遍历

  2. 接下来,我们遍历左子树,并递归调用“前序”函数与左子树

  3. 接下来,我们遍历右子树,并递归调用“前序”函数与右子树

因此,要以前序方式遍历树,我们按照根节点、左子树和右子树节点的顺序访问树。

考虑以下示例树以了解前序遍历:

在上面的二叉树示例中,首先我们访问根节点A。接下来,我们转到根节点A的左子树。节点A的左子树以节点B为根,因此我们访问这个根节点,然后转到根节点B的左子树,即节点D。然后我们访问节点D,并转到根节点D的左子树,然后我们访问左子节点G,它是根节点D的子树。接下来,我们访问根节点D的右子节点,即节点H。接着,我们访问根节点B的右子树的右子节点,即节点E。因此,以这种方式,我们已经访问了根节点A和以根节点A为根的左子树。现在,我们将访问根节点A的右子树。在这里,我们访问根节点C,然后我们转到根节点C的左子树,它为空,所以下一步,我们访问节点C的右子节点,即节点F

这个示例树的前序遍历将是A-B-D-G-H-E-C-F

pre-order树遍历的递归函数如下:

    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) 

前缀表示法通常被称为波兰表示法。在这种表示法中,运算符位于其操作数之前。前缀表示法是 LISP 程序员熟知的。例如,要添加两个数字 3 和 4 的算术表达式将显示为+ 3 4。由于没有运算符优先级的歧义,因此不需要括号:* + 4 5 - 5 3

让我们考虑另一个例子,即(3 +4) * 5。这也可以用前缀表示法表示为* (+ 3 4) 5

表达式树的前序遍历将得到算术表达式的前缀表示法。例如,考虑以下表达式树:

上述树的前序遍历将以前缀表示法给出表达式为+- 8 3 3

后序遍历和后缀表示法

post-order树遍历的工作方式如下。首先,我们检查当前节点是否为空。如果不为空,我们遍历树。post-order树遍历的工作方式如下:

  1. 我们开始遍历左子树并递归调用postorder函数

  2. 接下来,我们遍历右子树并递归调用postorder函数

  3. 最后,我们访问根节点

因此,简而言之,关于post-order树遍历,我们按照左子树、右子树和最后根节点的顺序访问树中的节点。

考虑以下示例树以理解后序树遍历:

在上图中,我们首先递归访问根节点A的左子树。我们到达最后的左子树,也就是根节点 D,然后我们访问它的左节点,即节点G。然后,我们访问右子节点 H,然后我们访问根节点 D。按照相同的规则,我们接下来访问节点B的右子节点,即节点E。然后,我们访问节点B。接着,我们遍历节点A的右子树。在这里,我们首先到达最后的右子树并访问节点F,然后我们访问节点C。最后,我们访问根节点A

这个示例树的后序遍历将是G-H-D-E-B-F-C-A

树遍历的post-order方法的实现如下:

    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)

后缀或逆波兰表示法RPN)将运算符放在其操作数之后,如3 4 +。与波兰表示法一样,运算符的优先级不会引起混淆,因此永远不需要括号:4 5 + 5 3 - *

以下表达式树的后序遍历将给出算术表达式的后缀表示法:

上述表达式树的后缀表示法是8 3 -3 +

广度优先遍历

广度优先遍历从树的根开始,然后访问树的下一级上的每个节点。然后,我们移动到树的下一级,依此类推。这种树遍历方式是广度优先的,因为它在深入树之前通过遍历一个级别上的所有节点来扩展树。

让我们考虑以下示例树,并使用广度优先遍历方法遍历它:

在前面的图表中,我们首先访问level 0的根节点,即值为4的节点。我们通过打印出它的值来访问这个节点。接下来,我们移动到level 1并访问该级别上的所有节点,即值为28的节点。最后,我们移动到树的下一级,即level 3,并访问该级别上的所有节点。该级别上的节点是13510

因此,该树的广度优先遍历如下:42813510

这种遍历模式是使用队列数据结构实现的。从根节点开始,我们将其推入队列。访问队列前面的节点(出队)并打印或存储以供以后使用。左节点被添加到队列,然后是右节点。由于队列不为空,我们重复这个过程。

该算法的 Python 实现将根节点4入队,出队并访问该节点。接下来,节点28入队,因为它们分别是下一级的左节点和右节点。节点2出队以便访问。接下来,它的左节点和右节点,即节点13,入队。此时队列前面的节点是8。我们出队并访问节点8,然后将其左节点和右节点入队。这个过程一直持续到队列为空。

广度优先遍历的 Python 实现如下:

    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语句将左子节点入队,如果提供了左节点则存在。第二个if语句对右子节点执行相同的操作。

list_of_nodes列表在最后一个语句中返回。

二叉树

二叉树是每个节点最多有两个子节点的树。二叉树中的节点以左子树和右子树的形式组织。如果树有一个根 R 和两个子树,即左子树T1和右子树T2,那么它们的根分别称为左继和右继。

以下图表是一个具有五个节点的二叉树的示例:

以下是我们对前面图表的观察:

  • 每个节点都保存对右节点和左节点的引用,如果节点不存在

  • 根节点用5表示

  • 根节点有两个子树,左子树有一个节点,即值为3的节点,右子树有三个节点,值分别为769

  • 值为3的节点是左继节点,而值为7的节点是右继节点

常规的二叉树在树中排列元素方面没有其他规则。它只需满足每个节点最多有两个子节点的条件。

二叉搜索树

二叉搜索树(BST)是一种特殊的二叉树。它是计算机科学应用中最重要和最常用的数据结构之一。二叉搜索树是一棵结构上是二叉树的树,并且非常有效地在其节点中存储数据。它提供非常快速的搜索操作,插入和删除等操作也非常简单和方便。

如果树中任意节点的值大于其左子树中所有节点的值,并且小于或等于其右子树中所有节点的值,则称二叉树为二叉搜索树。例如,如果K1K2K3是三个节点树中的关键值(如下图所示),则应满足以下条件:

  1. K2<=K1的关键值

  2. 关键值K3>K1

以下图表描述了这一点:

让我们考虑另一个例子,以便更好地理解二叉搜索树。考虑以下树:

这是 BST 的一个例子。在这棵树中,左子树中的所有节点都小于或等于该节点的值。同样,该节点的右子树中的所有节点都大于父节点的值。

测试我们的树是否具有 BST 的属性时,我们注意到根节点左子树中的所有节点的值都小于 5。同样,右子树中的所有节点的值都大于 5。这个属性适用于 BST 中的所有节点,没有例外。

考虑另一个二叉树的例子,让我们看看它是否是二叉搜索树。尽管以下图表看起来与前一个图表相似,但它并不符合 BST 的条件,因为节点7大于根节点5;然而,它位于根节点的左侧。节点4位于其父节点7的右子树中,这是不正确的。因此,以下图表不是二叉搜索树:

二叉搜索树实现

让我们开始在 Python 中实现 BST。我们需要跟踪树的根节点,因此我们首先创建一个Tree类,其中包含对根节点的引用:

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

这就是维护树状态所需的全部内容。让我们在下一节中研究树上的主要操作。

二叉搜索树操作

二叉搜索树上可以执行的操作包括插入删除查找最小值查找最大值搜索等。我们将在后续小节中讨论它们。

查找最小和最大节点

二叉搜索树的结构使得查找具有最大或最小值的节点非常容易。

要找到树中具有最小值的节点,我们从树的根开始遍历,并每次访问左节点,直到到达树的末端。类似地,我们递归遍历右子树,直到到达末端,以找到树中具有最大值的节点。

例如,考虑以下图表;我们从节点6向下移动到3,然后从节点3移动到1,以找到具有最小值的节点。类似地,要找到树中具有最大值的节点,我们从根向树的右侧移动,然后从节点6移动到节点8,然后从节点8移动到节点10以找到具有最大值的节点。以下是一个 BST 树的例子:

找到最小和最大节点的概念也适用于子树。因此,根节点为8的子树中的最小节点是节点7。同样,该子树中具有最大值的节点是10

返回最小节点的 Python 实现如下:

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

        return current 

while循环继续获取左节点并访问它,直到最后一个左节点指向None。这是一个非常简单的方法。

同样,以下是返回最大节点的方法的代码:

    def find_max(self): 
        current = self.root_node 
        while current.right_child: 
            current = current.right_child 

        return current 

在 BST 中查找最小值或最大值的运行时间复杂度为 O(h),其中h是树的高度。

基本上还有两个其他操作,即insertdelete,它们对 BST 非常重要。在对树应用这些操作时,确保我们保持 BST 树的属性是很重要的。

插入节点

在二叉搜索树上实现的最重要的操作之一是在树中插入数据项。正如我们已经讨论过的,关于二叉搜索树的属性,对于树中的每个节点,左子节点应该包含小于其自身值的数据,右子节点应该包含大于其值的数据。因此,我们必须确保每当我们在树中插入一个项目时,二叉搜索树的属性都得到满足。

例如,通过在树中插入数据项5371来创建一个二叉搜索树。考虑以下内容:

  1. 插入 5:我们从第一个数据项5开始。为此,我们将创建一个数据属性设置为5的节点,因为它是第一个节点。

  2. 插入 3:现在,我们想添加值为3的第二个节点,以便将数据值3与根节点5的现有节点值进行比较:

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

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

  1. 插入 7:要向树中添加值为7的另一个节点,我们从值为5的根节点开始比较:

由于7大于5,值为7的节点被放置在此根节点的右侧。

  1. 插入 1:让我们添加另一个值为1的节点。从树的根开始,我们比较15

这个比较表明1小于5,所以我们转到5的左节点,即值为3的节点:

当我们将13进行比较时,由于1小于3,我们向下移动到节点3的下一级并向左移动。然而,那里没有节点。因此,我们创建一个值为1的节点,并将其与节点3的左指针关联,以获得以下结构。在这里,我们有4个节点的最终二叉搜索树:

我们可以看到这个例子只包含整数或数字。因此,如果我们需要在二叉搜索树中存储字符串数据,在这种情况下字符串将按字母顺序进行比较。如果我们想在 BST 中存储自定义数据类型,我们必须确保我们的类支持排序。

给出了在 BST 中添加节点的insert方法的 Python 实现如下:

def insert(self, data):
    node = Node(data) 
    if self.root_node is None: 
        self.root_node = node 
    else: 
        current = self.root_node 
        parent = None  
    while True: 
        parent = current 
        if node.data < parent.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

现在,让我们逐步理解insert函数的每条指令。我们将从函数声明开始:

    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中删除。

另一方面,当我们要删除的节点只有一个子节点时,该节点的父节点被指向该节点的子节点。让我们看一下下面的图表,我们要删除节点6,它只有一个子节点,即节点5

为了删除只有一个子节点的节点6,我们将节点9的左指针指向节点5。在这里,我们需要确保子节点和父节点的关系遵循二叉搜索树的属性。

当我们要删除的节点有两个子节点时,会出现更复杂的情况。考虑以下示例树,我们要删除节点9,它有两个子节点:

我们不能简单地用节点613替换节点9。我们需要找到节点9的下一个最大的后代。这是节点12。要到达节点12,我们移动到节点9的右节点,然后向左移动以找到最左边的节点。节点12被称为节点9的中序后继。第二步类似于查找子树中的最大节点。

我们用节点9的值替换节点9的值,并删除节点12。删除节点12后,我们得到了一个更简单的节点删除形式,这是之前讨论过的。节点 12 没有子节点,所以我们相应地应用了删除没有子节点的节点的规则。

我们的node类没有父节点的引用。因此,我们需要使用一个辅助方法来搜索并返回带有其父节点的节点。这个方法类似于搜索方法:

    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 

我们将父节点和找到的节点分别传递给parentnode,使用parent, node = self.get_node_with_parent(data)。了解要删除的节点有多少个子节点是很重要的,我们在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语句的elif部分执行以下操作:

        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将计算为True,并且while循环将运行。当我们到达最左边的节点时,它要么是叶节点(意味着它将没有子节点),要么有一个右子节点。

我们使用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是树的高度。

搜索树

二叉搜索树是一种树形数据结构,其中所有节点都遵循这样的属性:节点的左子树中的所有节点具有较低的键值,在其右子树中具有较大的键值。因此,搜索具有给定键值的元素非常容易。让我们考虑一个示例二叉搜索树,其中的节点为12348510,如下图所示:

在上述树中,如果我们想要搜索值为5的节点,则我们从根节点开始,并将其与根节点进行比较。由于节点5的值大于根节点值4,我们移动到右子树。在右子树中,我们有节点8作为根节点;我们将节点5与节点8进行比较。由于要搜索的节点的值小于节点8,我们移动到左子树。当我们移动到左子树时,我们将左子树节点5与值为5的所需节点进行比较。这是一个匹配,所以我们返回“找到项目”。

以下是二叉搜索树中searching方法的实现:

  def search(self, data):
        current = self.root_node
        while True:
            if current is None:
                return None
            elif current.data is data:
                return data
            elif current.data > data:
                current = current.left_child
            else:
                current = current.right_child

在上述代码中,如果找到数据,我们将返回数据,如果未找到数据,则返回None。我们从根节点开始搜索。接下来,如果要搜索的数据项不存在于树中,则我们将返回None给客户端代码。我们也可能已经找到了数据,如果是这种情况,我们将返回数据。

如果我们要搜索的数据小于当前节点的数据,则我们向树的左侧移动。此外,在代码的else部分中,我们检查我们要查找的数据是否大于当前节点中保存的数据,这意味着我们向树的右侧移动。

最后,我们可以编写一些客户端代码来测试 BST 的工作原理。我们必须创建一棵树,并在110之间插入一些数字。然后,我们搜索该范围内的所有数字。存在于树中的数字将被打印出来:

    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)) 

二叉搜索树的好处

二叉搜索树与数组和链表相比是更好的选择。对于大多数操作,如搜索、插入和删除,BST 都很快,而数组提供了快速的搜索,但在插入和删除操作上相对较慢。同样,链表在执行插入和删除操作时效率很高,但在执行搜索操作时速度较慢。在二叉搜索树中搜索元素的“最佳情况”运行时间复杂度为O(log n),而“最坏情况”时间复杂度为O(n),而在列表中搜索的“最佳情况”和“最坏情况”时间复杂度均为O(n)

以下表格提供了数组、链表和二叉搜索树数据结构的比较:

属性 数组 链表 BST
数据结构 线性。 线性。 非线性。
易用性 创建和使用都很容易。搜索、插入和删除的平均情况复杂度为O(n) 插入和删除很快,特别是使用双向链表。 元素访问、插入和删除都很快,平均情况复杂度为O(log n)
访问复杂度 访问元素容易。复杂度为O(1) 只能进行顺序访问,所以很慢。平均和最坏情况下的复杂度是O(n) 访问很快,但当树不平衡时很慢,最坏情况下的复杂度为O(n)
搜索复杂度 平均和最坏情况下的复杂度是O(n) 由于顺序搜索,所以很慢。平均和最坏情况下的复杂度是O(n) 搜索的最坏情况复杂度是O(n)
插入复杂度 插入很慢。平均和最坏情况下的复杂度是O(n) 平均和最坏情况下的复杂度是O(1) 插入的最坏情况复杂度是O(n)
删除复杂度 删除很慢。平均和最坏情况下的复杂度是O(n) 平均和最坏情况下的复杂度是O(1) 删除的最坏情况复杂度是O(n)

让我们举个例子来理解何时使用二叉搜索树来存储数据是一个好选择。假设我们有以下数据节点——5371469。如果我们使用列表来存储这些数据,最坏的情况将需要我们搜索整个包含七个元素的列表来找到这个项目。因此,在这个数据节点中,需要七次比较来搜索项目9

然而,如果我们使用二叉搜索树来存储这些值,如下图所示,在最坏的情况下,我们需要三次比较来搜索项目9

然而,重要的是要注意搜索效率也取决于我们如何构建二叉搜索树。如果树没有被正确构建,它可能会很慢。例如,如果我们按照{1345679}的顺序将元素插入到树中,如下图所示,那么树将不会比列表更有效:

因此,选择自平衡树有助于改善搜索操作。在这里,我们应该注意,二叉搜索树在大多数情况下是更好的选择;然而,我们应该尝试平衡树。

平衡树

我们已经在前一节中看到,如果节点按顺序插入到树中,它会变得很慢,行为上更像一个列表;也就是说,每个节点恰好有一个子节点。为了提高树数据结构的性能,我们通常希望尽可能减少树的高度,通过填充树中的每一行来平衡树。这个过程称为平衡树

有不同类型的自平衡树,如红黑树、AA 树和替罪羊树。这些树在修改树的每个操作期间平衡树,比如插入或删除。还有一些外部算法来平衡树。这些方法的好处是你不需要在每次操作中都平衡树,可以在需要时再进行平衡。

表达树

算术表达式由操作数和运算符的组合表示,其中运算符可以是一元或二元。算术表达式也可以使用二叉树表示,称为表达式树。这种树结构也可以用于解析算术和布尔表达式。在表达式树中,所有叶节点包含操作数,非叶节点包含运算符。我们还应该注意,表达式树的子树(右子树或左子树)在一元运算符的情况下将为空。

例如,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是一个包含值45+53-*的列表。

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) 

请注意,在操作数的情况下,我们执行了从stringint的转换。如果您希望支持浮点操作数,可以使用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)的结果。

堆数据结构是树的一种特殊形式,其中节点以特定方式排序。堆分为max堆和min堆。

max堆中,每个父节点的值必须始终大于或等于其子节点。由此可知,根节点必须是树中最大的值。考虑以下最大堆的图表,其中所有节点的值都大于其子节点的值:

min堆中,每个父节点必须小于或等于其两个子节点。因此,根节点包含最小值。考虑以下最小堆的图表,其中所有节点的值都小于其子节点的值:

堆用于许多不同的事情。首先,它们用于实现优先级队列。还有一种非常高效的排序算法,称为堆排序,它使用堆。我们将在后续章节中深入研究这些内容。

三元搜索树

三元树是一种数据结构,树的每个节点最多可以包含3个子节点。与二叉搜索树相比,它不同之处在于二叉树中的节点最多可以有2个子节点,而三元树中的节点最多可以有3个子节点。三元树数据结构也被认为是字典树数据结构的特殊情况。在字典树数据结构中,当我们使用字典树数据结构存储字符串时,每个节点包含 26 个指向其子节点的指针,而在三元搜索树数据结构中,我们有 3 个指向其子节点的指针。

三元搜索树可以表示如下:

  • 每个节点都存储一个字符

  • 它具有指向存储与当前节点相等值的节点的等指针

  • 它具有指向存储小于当前节点值的节点的左指针

  • 它具有指向存储大于当前节点值的节点的右指针

  • 每个节点都有一个标志变量,用于跟踪该节点是否是字符串的结尾

为了更好地理解三元搜索树数据结构,我们将通过一个示例来演示,其中我们将字符串PUTCATSITSINGPUSH插入到一个空的三元树中,如下图所示:

将值插入三元搜索树与在二叉搜索树中进行的方式非常相似。在三元搜索树中,我们遵循以下步骤将字符串插入三元搜索树:

  1. 由于树最初为空,我们首先创建根节点,其中包含第一个字符P,然后我们为字符U创建另一个节点,最后是字符T

  2. 接下来,我们希望添加单词CAT。首先,我们将第一个字符C与根节点字符P进行比较。由于不匹配,并且它小于根节点,我们在根节点的左侧为字符C创建一个新节点。此外,我们创建了字符AT的节点。

  3. 接下来,我们添加一个新单词SIT。首先,我们将第一个字符S与根节点字符P进行比较。由于不匹配,并且字符S大于字符P,我们在右侧为字符S创建一个新节点。此外,我们创建了字符IT的节点。

  4. 接下来,我们将单词SING插入到三叉搜索树中。我们首先将第一个字符S与根节点进行比较。由于不匹配,并且字符S大于根节点P,我们查看右侧的下一个字符,即S。这里,字符匹配,因此我们比较下一个字符I;这也匹配。接下来,我们将字符N与树中的字符T进行比较。这里,字符不匹配,因此我们移动到节点T的左侧。在这里,我们为字符N创建一个新节点。此外,我们为字符G创建另一个新节点。

  5. 然后,在三叉搜索树中添加一个新节点PUSH。首先,我们比较单词的第一个字符,即P,与根节点。由于匹配,我们查看三叉树中的下一个字符。这里,字符U也与单词的下一个字符匹配。因此,我们查看单词的下一个字符,即S。它与树中的下一个字符T不匹配。因此,我们在节点T的左侧为字符S创建一个新节点,因为字符S小于T。接下来,我们为下一个字符H创建另一个节点。

请注意,三叉树中的每个节点都通过使用标志变量来跟踪哪个节点是叶节点或非叶节点。

三叉搜索树非常适用于字符串搜索相关的应用,比如当我们希望搜索所有以特定前缀开头的字符串,或者当我们希望搜索以特定数字开头的电话号码,拼写检查等等。

总结

在本章中,我们研究了树数据结构及其用途。特别是我们研究了二叉树,这是树的一个子类型,其中每个节点最多有两个子节点。我们还看了二叉树如何作为可搜索的数据结构与 BST 一起使用。广度优先和深度优先搜索遍历模式也通过使用队列递归在 Python 中实现。

我们还看了二叉树如何用来表示算术或布尔表达式。然后,我们构建了一个表达式树来表示算术表达式。之后,我们向您展示了如何使用栈来解析以逆波兰表示法编写的表达式,构建表达式树,并最终遍历它以获得算术表达式的结果。

最后,我们提到了堆,这是树结构的一种特殊形式。我们在本章至少尝试奠定了堆的理论基础,以便在接下来的章节中为不同的目的实现堆。

在下一章中,我们将讨论哈希表和符号表的细节。

第七章:哈希和符号表

我们之前已经看过数组列表,其中项目按顺序存储并通过索引号访问。索引号对计算机来说很有效。它们是整数,因此快速且易于操作。但是,它们并不总是对我们很有效。例如,如果我们有一个地址簿条目,比如在索引号 56 处,那个数字并没有告诉我们太多。没有任何东西将特定联系人与数字 56 联系起来。使用索引值从列表中检索条目是困难的。

在本章中,我们将研究一种更适合这种问题的数据结构:字典。字典使用关键字而不是索引号,并以(键,值)对的形式存储数据。因此,如果该联系人被称为James,我们可能会使用关键字James来定位联系人。也就是说,我们不会通过调用contacts [56]来访问联系人,而是使用contacts james

字典是一种广泛使用的数据结构,通常使用哈希表构建。顾名思义,哈希表依赖于一种称为哈希的概念。哈希表数据结构以键/值对的方式存储数据,其中键是通过应用哈希函数获得的。它以非常高效的方式存储数据,因此检索速度非常快。我们将在本章讨论所有相关问题。

我们将在本章涵盖以下主题:

  • 哈希

  • 哈希表

  • 不同的元素功能

技术要求

除了需要在系统上安装 Python 之外,没有其他技术要求。这是本章讨论的源代码的 GitHub 链接:github.com/PacktPublishing/Hands-On-Data-Structures-and-Algorithms-with-Python-Second-Edition/tree/master/Chapter07

哈希

哈希是一个概念,当我们将任意大小的数据提供给函数时,我们会得到一个简化的小值。这个函数称为哈希函数。哈希使用一个哈希函数将给定的数据映射到另一个数据范围,以便新的数据范围可以用作哈希表中的索引。更具体地说,我们将使用哈希将字符串转换为整数。在本章的讨论中,我们使用字符串转换为整数,但它可以是任何其他可以转换为整数的数据类型。让我们看一个例子来更好地理解这个概念。我们想要对表达式hello world进行哈希,也就是说,我们想要得到一个数值,我们可以说代表该字符串。

我们可以使用ord()函数获得任何字符的唯一序数值。例如,ord('f')函数给出 102。此外,要获得整个字符串的哈希值,我们只需对字符串中每个字符的序数进行求和。请参阅以下代码片段:

>>> sum(map(ord, 'hello world'))
1116

对于整个hello world字符串获得的数值1116称为字符串的哈希。请参考以下图表,以查看导致哈希值1116的字符串中每个字符的序数值:

前面的方法用于获得给定字符串的哈希值,并且似乎运行良好。但是,请注意,我们可以更改字符串中字符的顺序,我们仍然会得到相同的哈希值;请参阅以下代码片段,我们对world hello字符串获得相同的哈希值:

>>> sum(map(ord, 'world hello'))
1116

同样,对于gello xorld字符串,哈希值将是相同的,因为该字符串的字符的序数值之和将是相同的,因为g的序数值比h小 1,x的序数值比w大 1。请参阅以下代码片段:

>>> sum(map(ord, 'gello xorld'))
1116

看一下下面的图表,我们可以观察到该字符串的哈希值再次为1116

完美哈希函数

完美哈希函数是指我们为给定字符串(它可以是任何数据类型,这里我们现在限制讨论为字符串)得到唯一的哈希值。实际上,大多数哈希函数都是不完美的,并且会发生冲突。这意味着哈希函数给一个以上的字符串返回相同的哈希值;这是不希望的,因为完美哈希函数应该为一个字符串返回唯一的哈希值。通常,哈希函数需要非常快速,因此通常不可能创建一个为每个字符串返回唯一哈希值的函数。因此,我们接受这一事实,并且知道我们可能会遇到一些冲突,也就是说,两个或更多个字符串可能具有相同的哈希值。因此,我们尝试找到一种解决冲突的策略,而不是试图找到一个完美的哈希函数。

为了避免前面示例中的冲突,我们可以例如添加一个乘数,使得每个字符的序数值乘以一个随着字符串进展而不断增加的值。接下来,通过添加每个字符的乘以序数值来获得字符串的哈希值。为了更好地理解这个概念,请参考以下图表:

在上图中,每个字符的序数值逐渐乘以一个数字。请注意,最后一行是值的乘积结果;第二行是每个字符的序数值;第三行显示乘数值;第四行通过将第二行和第三行的值相乘得到值,因此 104 x 1 等于 104。最后,我们将所有这些乘积值相加,得到 hello world 字符串的哈希值,即 6736

这个概念的实现如下函数所示:

    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

我们可以看到,这一次对这三个字符串得到了不同的哈希值。但是,这并不是一个完美的哈希。让我们尝试字符串 adga

% python hashtest.py

ad: 297
ga: 297

我们仍然得到两个不同字符串相同的哈希值。因此,我们需要制定一种解决这种冲突的策略。我们很快将看到这一点,但首先,我们将学习哈希表的实现。

哈希表

哈希表是一种数据结构,其中元素是通过关键字而不是索引号访问的,不同于列表数组。在这种数据结构中,数据项以类似于字典的键/值对的形式存储。哈希表使用哈希函数来找到应该存储和检索元素的索引位置。这使我们能够快速查找,因为我们使用与键的哈希值对应的索引号。

哈希表数据结构中的每个位置通常称为,可以存储一个元素。因此,形式为 (key, value) 的每个数据项将存储在哈希表中由数据的哈希值决定的位置上。例如,哈希函数将输入字符串名称映射到哈希值;hello world 字符串被映射到哈希值 92,找到哈希表中的一个槽位置。考虑以下图表:

为了实现哈希表,我们首先创建一个类来保存哈希表项。这些项需要有一个键和一个值,因为我们的哈希表是一个 {key-value} 存储:

    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 个元素的列表。这些是要存储元素的位置——插槽或桶。因此,我们有 256 个插槽来存储哈希表中的元素。最后,我们添加一个计数器,用于记录实际哈希表元素的数量:

重要的是要注意表的大小和计数之间的区别。表的大小是指表中插槽的总数(已使用或未使用)。表的计数是指填充的插槽的数量,也就是已添加到表中的实际(键-值)对的数量。

现在,我们需要决定将我们的哈希函数添加到表中。我们可以使用相同的哈希函数,它返回字符串中每个字符的序数值的总和,稍作修改。由于我们的哈希表有 256 个插槽,这意味着我们需要一个返回 1 到 256 范围内的值的哈希函数(表的大小)。一个很好的方法是返回哈希值除以表的大小的余数,因为余数肯定是 0 到 255 之间的整数值。

哈希函数只是用于类内部的,所以我们在名称前面加下划线(_)来表示这一点。这是 Python 中用来表示某些东西是内部使用的正常约定。这是hash函数的实现:

    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类中,然后计算键的哈希值。

这是put函数的实现,用于将元素存储在哈希表中:

    def put(self, key, value): 
        item = HashItem(key, value) 
        h = self._hash(key) 

一旦我们知道键的哈希值,它将被用来找到元素应该存储在哈希表中的位置。因此,我们需要找到一个空插槽。我们从与键的哈希值对应的插槽开始。如果该插槽为空,我们就在那里插入我们的项。

但是,如果插槽不为空,并且项的键与当前键不同,那么我们就会发生冲突。这意味着我们有一个项的哈希值与表中先前存储的某个项相同。这就是我们需要想出一种处理冲突的方法的地方。

例如,在下面的图表中,hello world键字符串已经存储在表中,当一个新的字符串world hello得到相同的哈希值92时,就会发生冲突。看一下下面的图表:

解决这种冲突的一种方法是从冲突的位置找到另一个空插槽;这种冲突解决过程称为开放寻址。我们可以通过线性地查找下一个可用插槽来解决这个问题,方法是在发生冲突的前一个哈希值上加1。我们可以通过将键字符串中每个字符的序数值的总和加1来解决这个冲突,然后再除以哈希表的大小来获得哈希值。这种系统化的访问每个插槽的方式是解决冲突的线性方式,称为线性探测

让我们考虑一个例子,如下图所示,以更好地理解我们如何解决这个冲突。密钥字符串eggs的哈希值是 51。现在,由于我们已经使用了这个位置来存储数据,所以发生了冲突。因此,我们在哈希值中添加 1,这是由字符串的每个字符的序数值的总和计算出来的,以解决冲突。因此,我们获得了这个密钥字符串的新哈希值来存储数据——位置 52。请参见以下图表和代码片段以进行此实现:

现在,考虑以下代码:

    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()方法。此方法将返回与给定密钥对应的表中存储的值。

首先,我们计算要检索的密钥的哈希值对应的值。一旦我们有了密钥的哈希值,我们就在哈希表的哈希值位置查找。如果密钥项与该位置处存储的密钥值匹配,则检索相应的value。如果不匹配,那么我们将 1 添加到字符串中所有字符的序数值的总和,类似于我们在存储数据时所做的操作,然后查看新获得的哈希值。我们继续查找,直到找到我们的密钥元素或者检查了哈希表中的所有槽。

考虑一个例子来理解以下图表中的概念,分为四步:

  1. 我们计算给定密钥字符串"egg"的哈希值,结果为 51。然后,我们将此密钥与位置 51 处存储的密钥值进行比较,但不匹配。

  2. 由于密钥不匹配,我们计算一个新的哈希值。

  3. 我们查找新创建的哈希值位置 52 处的密钥;我们将密钥字符串与存储的密钥值进行比较,这里匹配,如下图所示。

  4. 在哈希表中返回与此密钥值对应的存储值。请参见以下图表:

为了实现这个检索方法,即get()方法,我们首先计算密钥的哈希值。接下来,我们在表中查找计算出的哈希值。如果匹配,则返回相应的存储值。否则,我们继续查看描述的计算出的新哈希值位置。以下是get()方法的实现:

def get(self, key): 
    h = self._hash(key)    # computer hash for the given key 
    while self.slots[h] is not None:
        if self.slots[h].key is key: 
            return self.slots[h].value 
        h = (h+ 1) % self.size 
    return None        

最后,如果在表中找不到密钥,则返回None。另一个很好的选择可能是在表中不存在密钥的情况下引发异常。

测试哈希表

为了测试我们的哈希表,我们创建HashTable并将一些元素存储在其中,然后尝试检索它们。我们还将尝试get()一个不存在的密钥。我们还使用了两个字符串adga,它们发生了冲突,并且由我们的哈希函数返回了相同的哈希值。为了正确评估哈希表的工作,我们也会处理这个冲突,只是为了看到冲突是如何正确解决的。请参见以下示例代码:

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)) 

请注意,我们还打印了已存储在哈希表中的元素数量,使用count变量。

非字符串键

在实时应用中,通常我们需要使用字符串作为键。然而,如果有必要,您可以使用任何其他 Python 类型。如果您创建自己的类并希望将其用作键,您需要重写该类的特殊__hash__()函数,以便获得可靠的哈希值。

请注意,您仍然需要计算哈希值的模运算(%)和哈希表的大小以获取插槽。这个计算应该在哈希表中进行,而不是在键类中,因为表知道自己的大小(键类不应该知道它所属的表的任何信息)。

扩大哈希表

在我们的示例中,我们将哈希表的大小固定为 256。很明显,当我们向哈希表添加元素时,我们将开始填满空插槽,而在某个时刻,所有插槽都将被填满,哈希表将变满。为了避免这种情况,我们可以在表开始变满时扩大表的大小。

为了扩大哈希表的大小,我们比较表中的大小和计数。size是插槽的总数,count表示包含元素的插槽的数量。因此,如果count等于size,这意味着我们已经填满了表。哈希表的负载因子通常用于扩展表的大小;这给了我们一个关于表中有多少可用插槽被使用的指示。哈希表的负载因子通过将表中已使用的插槽数量除以表中的插槽数量来计算。它的定义如下:

当负载因子接近 1 时,这意味着表即将被填满,我们需要扩大表的大小。最好在表几乎填满之前扩大表的大小,因为当表填满时,从表中检索元素会变慢。负载因子为 0.75 可能是一个不错的值,用来扩大表的大小。

下一个问题是我们应该将表的大小增加多少。一种策略是简单地将表的大小加倍。

开放寻址

我们在示例中使用的冲突解决机制是线性探测,这是一种开放寻址策略的例子。线性探测很简单,因为我们使用了固定数量的插槽。还有其他开放寻址策略,它们都共享一个思想,即存在一个插槽数组。当我们想要插入一个键时,我们会检查插槽是否已经有项目。如果有,我们会寻找下一个可用的插槽。

如果我们有一个包含 256 个插槽的哈希表,那么 256 就是哈希表中元素的最大数量。此外,随着负载因子的增加,查找新元素的插入点将需要更长的时间。

由于这些限制,我们可能更喜欢使用不同的策略来解决冲突,比如链接法。

链接法

链接是处理哈希表中冲突问题的另一种方法。它通过允许哈希表中的每个插槽存储在冲突位置的多个项目的引用来解决这个问题。因此,在冲突的索引处,我们可以在哈希表中存储多个项目。观察以下图表——字符串hello worldworld hello发生冲突。在链接的情况下,这两个项目都被允许存储在哈希值为92的位置上,使用一个列表。以下是用于显示使用链接解决冲突的示例图表:

在链接中,哈希表中的插槽被初始化为空列表:

当插入一个元素时,它将被追加到与该元素的哈希值对应的列表中。也就是说,如果您有两个具有哈希值1075的元素,这两个元素都将被添加到哈希表的1075%256=51插槽中存在的列表中:

前面的图表显示了具有哈希值51的条目列表。

然后通过链接避免冲突,允许多个元素具有相同的哈希值。因此,哈希表中可以存储的元素数量没有限制,而在线性探测的情况下,我们必须固定表的大小,当表填满时需要后续增长,这取决于负载因子。此外,哈希表可以容纳比可用插槽数量更多的值,因为每个插槽都包含一个可以增长的列表。

然而,在链接中存在一个问题——当列表在特定的哈希值位置增长时,它变得低效。由于特定插槽有许多项目,搜索它们可能会变得非常缓慢,因为我们必须通过列表进行线性搜索,直到找到具有我们想要的键的元素。这可能会减慢检索速度,这是不好的,因为哈希表的目的是高效的。以下图表演示了通过列表项进行线性搜索,直到找到匹配项:

因此,当哈希表中的特定位置具有许多条目时,检索项目的速度会变慢。可以通过在使用列表的位置上使用另一个数据结构来解决这个问题,该数据结构可以执行快速搜索和检索。使用二叉搜索树BSTs)是一个不错的选择,因为它提供了快速检索,正如我们在前一章中讨论的那样。

我们可以简单地在每个插槽中放置一个(最初为空的)BST,如下图所示:

在前面的图表中,51插槽包含一个 BST,我们使用它来存储和检索数据项。但我们仍然可能会遇到一个潜在的问题——根据将项目添加到 BST 的顺序,我们可能会得到一个与列表一样低效的搜索树。也就是说,树中的每个节点都只有一个子节点。为了避免这种情况,我们需要确保我们的 BST 是自平衡的。

符号表

符号表由编译器和解释器使用,用于跟踪已声明的符号并保留有关它们的信息。符号表通常使用哈希表构建,因为从表中高效地检索符号很重要。

让我们看一个例子。假设我们有以下 Python 代码:

    name = "Joe" 
    age = 27 

在这里,我们有两个符号,nameage。它们属于一个命名空间,可以是__main__,但如果您将其放在那里,它也可以是模块的名称。每个符号都有一个value;例如,name符号的值是Joeage符号的值是27。符号表允许编译器或解释器查找这些值。因此,nameage符号成为哈希表中的键。与它们关联的所有其他信息成为符号表条目的value

不仅变量是符号,函数和类也被视为符号,并且它们也将被添加到符号表中,以便在需要访问它们时,可以从符号表中访问。例如,greet()函数和两个变量存储在以下图表中的符号表中:

在 Python 中,每个加载的模块都有自己的符号表。符号表以该模块的名称命名。这样,模块就充当了命名空间。只要它们存在于不同的符号表中,我们可以拥有相同名称的多个符号,并且可以通过适当的符号表访问它们。请参见以下示例,显示程序中的多个符号表:

总结

在本章中,我们研究了哈希表。我们研究了如何编写一个哈希函数将字符串数据转换为整数数据。然后,我们研究了如何使用哈希键快速高效地查找与键对应的值。

另外,我们还研究了哈希表实现中由于哈希值冲突而产生的困难。这导致我们研究了冲突解决策略,因此我们讨论了两种重要的冲突解决方法,即线性探测和链表法。

在本章的最后一节中,我们研究了符号表,它们通常是使用哈希表构建的。符号表允许编译器或解释器查找已定义的符号(如变量、函数或类)并检索有关它们的所有信息。

在下一章中,我们将讨论图和其他算法。

第八章:图和其他算法

在本章中,我们将讨论与图相关的概念。图的概念来自数学的一个分支,称为图论。图被用来解决许多计算问题。图是一种非线性数据结构。这种结构通过连接一组节点或顶点以及它们的边来表示数据。这与我们迄今为止所看到的数据结构非常不同,对图的操作(例如遍历)可能是非常规的。在本章中,我们将讨论与图相关的许多概念。此外,我们还将在本章后面讨论优先队列和堆。

到本章结束时,您应该能够做到以下几点:

  • 了解图是什么

  • 了解图的类型和它们的组成部分

  • 了解如何表示图并遍历它

  • 获得优先队列的基本概念

  • 能够实现优先队列

  • 能够确定列表中第 i 个最小的元素

技术要求

本章讨论的所有源代码都可以在以下链接的 GitHub 存储库中找到:github.com/PacktPublishing/Hands-On-Data-Structures-and-Algorithms-with-Python-Second-Edition/tree/master/Chapter08

图是一组顶点和边,它们之间形成连接。在更正式的方法中,图G是一个顶点集V和边集E的有序对,以正式的数学符号表示为G = (V, E)

这里给出了一个图的示例:

让我们讨论一些图的重要定义:

  • 节点或顶点:图中的一个点或节点称为一个顶点,通常在图中用一个点表示。在前面的图中,顶点或节点是ABCDE

  • :这是两个顶点之间的连接。连接AB的线是前面图中边的一个例子。

  • 循环:当一个节点的边与自身相连时,该边形成一个循环。

  • 顶点的度:一个给定顶点上的边的总数被称为该顶点的度。例如,前面图中B顶点的度为4

  • 邻接:这指的是任意两个节点之间的连接;因此,如果两个顶点或节点之间有连接,则它们被称为相邻。例如,C节点与A节点相邻,因为它们之间有一条边。

  • 路径:任意两个节点之间的顶点和边的序列表示从A节点到B节点的路径。例如,CABE表示从C节点到E节点的路径。

  • 叶节点(也称为挂节点):如果一个顶点或节点的度为 1,则称为叶节点或挂节点。

有向和无向图

图由节点之间的边表示。连接边可以被认为是有向的或无向的。如果图中的连接边是无向的,则图被称为无向图,如果图中的连接边是有向的,则它被称为有向图。无向图简单地将边表示为节点之间的线。除了它们相互连接之外,关于节点之间关系的其他信息都没有。例如,在下图中,我们展示了一个由四个节点ABCD组成的无向图,它们之间通过边相连:

在有向图中,边提供了有关图中任意两个节点之间连接方向的信息。如果从节点AB的边是有向的,那么边(AB)就不等于边(BA)。有向边用带箭头的线表示,箭头指向边连接两个节点的方向。例如,在下图中,我们展示了一个有向图,其中许多节点使用有向边连接:

边的箭头确定了方向的流动。如前图所示,只能从AB,而不能从BA。在有向图中,每个节点(或顶点)都有一个入度和一个出度。让我们来看看这些是什么:

  • 入度:进入图中一个顶点的边的总数称为该顶点的入度。例如,在前面的图中,E节点由于边CE进入,所以入度为1

  • 出度:从图中一个顶点出去的边的总数称为该顶点的出度。例如,在前面的图中,E节点的出度为2,因为它有两条边EFED出去。

  • 孤立顶点:当一个节点或顶点的度为零时,称为孤立顶点。

  • 源顶点:如果一个顶点的入度为零,则称为源顶点。例如,在前面的图中,A节点是源顶点。

  • 汇点:如果一个顶点的出度为零,则称为汇点。例如,在前面的图中,F节点是汇点。

加权图

加权图是一个在图中的边上关联了数值权重的图。它可以是有向图,也可以是无向图。这个数值可以用来表示距离或成本,取决于图的目的。让我们来考虑一个例子。下图表示了从A节点到D节点的不同路径。你可以直接从AD,也可以选择经过BC,考虑到每条边的关联权重是到达下一个节点所需的时间(以分钟为单位):

在这个例子中,ADABCD代表两条不同的路径。路径就是在两个节点之间通过的一系列边。跟随这些路径,你会发现AD需要40分钟,而ABCD只需要25分钟。如果唯一关心的是时间,那么沿着ABCD路径旅行会更好,即使它可能是一条更长的路线。这里要记住的是边可以是有方向的,并且可能包含其他信息(例如所需时间、要行驶的距离等)。

我们可以以类似的方式实现图形,就像我们对其他数据结构(如链表)所做的那样。对于图形来说,将边看作对象和节点一样是有意义的。就像节点一样,边也可以包含额外的信息,这使得跟随特定路径成为必要。图中的边可以用不同节点之间的链接来表示;如果图中有一个有向边,我们可以用一个箭头从一个节点指向另一个节点来实现它,这在节点类中很容易用nextpreviousparentchild来表示。

图的表示

在 Python 中实现图时,可以用两种主要形式来表示。一种是使用邻接表,另一种是使用邻接矩阵。让我们考虑一个例子,如下图所示,为图开发这两种表示类型:

邻接表

邻接列表存储所有节点,以及与它们在图中直接连接的其他节点。在图G中,两个节点AB如果之间有直接连接,则称它们是相邻的。在 Python 中,使用list数据结构表示图。列表的索引可以用来表示图中的节点或顶点。

在每个索引处,将该顶点的相邻节点存储起来。例如,考虑以下对应于先前显示的示例图的邻接列表:

方框中的数字代表顶点。0索引代表图的A顶点,其相邻节点为BC1索引代表图的B顶点,其相邻节点为ECA。类似地,图的其他顶点CEF在索引234处表示,其相邻节点如前图所示。

使用list进行表示相当受限制,因为我们缺乏直接使用顶点标签的能力。因此,使用dictionary数据结构更适合表示图。要使用dictionary数据结构实现相同的先前图,我们可以使用以下语句:

    graph = dict() 
    graph['A'] = ['B', 'C'] 
    graph['B'] = ['E','C', 'A'] 
    graph['C'] = ['A', 'B', 'E','F'] 
    graph['E'] = ['B', 'C'] 
    graph['F'] = ['C'] 

现在我们可以很容易地确定A顶点的相邻顶点是BCF顶点的唯一邻居是C。同样,B顶点的相邻顶点是EBA

邻接矩阵

图可以使用邻接矩阵表示的另一种方法是使用邻接矩阵。矩阵是一个二维数组。这里的想法是用10表示单元格,具体取决于两个顶点是否由边连接。我们在下图中演示了一个示例图,以及其对应的邻接矩阵:

可以使用给定的邻接列表来实现邻接矩阵。要实现邻接矩阵,让我们使用图的先前基于字典的实现。首先,我们必须获得邻接矩阵的关键元素。重要的是要注意,这些矩阵元素是图的顶点。我们可以通过对图的键进行排序来获得关键元素。此操作的代码片段如下:

    matrix_elements = sorted(graph.keys()) 
    cols = rows = len(matrix_elements) 

接下来,使用图的键的长度来提供邻接矩阵的维度,这些维度存储在colsrows中,colsrows中的值相等。然后我们创建一个正确大小的空邻接矩阵,大小为cols乘以rows,并用零填充它。edges_list变量将存储图中形成边的元组。例如,A 和 B 节点之间的边将存储为(A, B)。初始化空邻接矩阵的代码片段如下:

    adjacency_matrix = [[0 for x in range(rows)] for y in range(cols)] 
    edges_list = []

多维数组是使用嵌套的for循环填充的:

    for key in matrix_elements: 
        for neighbor in graph[key]: 
            edges_list.append((key, neighbor)) 

顶点的邻居是通过graph[key]获得的。然后,结合neighbor使用edges_list存储创建的元组。

用于存储图的边的上述 Python 代码的输出如下:

>>> [('A', 'B'), ('A', 'C'), ('B', 'E'), ('B', 'C'), ('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]) 
        adjacency_matrix[index_of_first_vertex][index_of_second_vertex] = 1 

matrix_elements数组有它的rowscols,从A到所有其他顶点,索引从05for循环遍历我们的元组列表,并使用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行,有一个值为1,表示图中 C 和 B 顶点之间的边。

图遍历

图遍历意味着访问图的所有顶点,同时跟踪已经访问和尚未访问的节点或顶点。如果图遍历算法以最短可能的时间遍历图的所有节点,则该算法是高效的。图遍历的常见策略是沿着一条路径前进,直到遇到死胡同,然后向上遍历,直到遇到另一条路径。我们还可以迭代地从一个节点移动到另一个节点,以遍历整个图或部分图。图遍历算法在回答许多基本问题时非常重要——它们可以确定如何从图中的一个顶点到达另一个顶点,以及在图中从 A 到 B 顶点的哪条路径比其他路径更好。在接下来的部分中,我们将讨论两个重要的图遍历算法:广度优先搜索BFS)和深度优先搜索DFS)。

广度优先遍历

广度优先遍历算法以图的广度为基础工作。使用队列数据结构来存储要在图中访问的顶点的信息。我们从起始节点A开始。首先,我们访问该节点,然后查找它所有的相邻顶点。我们逐个访问这些相邻顶点,同时将它们的邻居添加到要访问的顶点列表中。我们一直遵循这个过程,直到访问了图的所有顶点,确保没有顶点被访问两次。

让我们通过以下图示例更好地理解图的广度优先遍历:

在上图中,左侧有一个五个节点的图,右侧有一个队列数据结构,用于存储要访问的顶点。我们开始访问第一个节点A,然后将其所有相邻的顶点BCE添加到队列中。在这里,需要注意的是,添加相邻节点到队列有多种方式,因为有三个节点BCE,可以按照BCECEBCBEBECECB的顺序添加到队列中,每种方式都会给出不同的树遍历结果。

图遍历的所有可能解决方案都是正确的,但在这个例子中,我们将按字母顺序添加节点。如图所示,访问了A节点:

一旦我们访问了A顶点,接下来,我们访问它的第一个相邻顶点B,并添加那些尚未添加到队列或未访问的相邻顶点。在这种情况下,我们需要将D顶点添加到队列中:

现在,在访问B顶点之后,我们访问队列中的下一个顶点C。然后,添加那些尚未添加到队列中的相邻顶点。在这种情况下,没有未记录的顶点,因此不需要进行任何操作:

在访问C顶点之后,我们访问队列中的下一个顶点E

同样,在访问E顶点之后,我们在最后一步访问D顶点:

因此,用于遍历上述图的 BFS 算法按照A-B-C-E-D的顺序访问顶点。这是上述图的 BFS 遍历的一种可能解决方案,但根据我们如何将相邻节点添加到队列中,我们可以得到许多可能的解决方案。

要学习 Python 中此算法的实现,让我们考虑另一个无向图的示例。考虑以下图表作为图:

图的邻接列表如下:

    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。

BFS 的代码如下:

    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))语句。这使用set对象的difference方法来找到在adj_nodes中但不在visited_vertices中的节点。

在最坏的情况下,每个顶点或节点和边都将被遍历,因此 BFS 算法的时间复杂度为O(|V| + |E|),其中|V|是顶点或节点的数量,而|E|是图中的边的数量。

深度优先搜索

正如其名称所示,DFS 算法在遍历广度之前,会先遍历图中任何特定路径的深度。因此,首先访问子节点,然后是兄弟节点。使用栈数据结构来实现 DFS 算法。

我们从访问 A 节点开始,然后查看 A 顶点的邻居,然后是邻居的邻居,依此类推。让我们在 DFS 的上下文中考虑以下图:

访问完 A 顶点后,我们访问其邻居之一,B,如下所示:

访问完 B 顶点后,我们查看 A 的另一个邻居 S,因为没有与 B 相连的顶点可以访问。接下来,我们查看 S 顶点的邻居,即 C 和 G 顶点。我们访问 C 如下:

访问C节点后,我们访问其相邻的DE节点:

类似地,访问E顶点后,我们访问HF顶点,如下图所示:

最后,我们访问F节点:

DFS 遍历的输出是A-B-S-C-D-E-H-G-F

为了实现 DFS,我们从给定图的邻接表开始。以下是先前图的邻接表:

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'] 

DFS 算法的实现始于创建一个列表来存储已访问的节点。graph_stack栈变量用于辅助遍历过程。我们使用普通的 Python 列表作为栈。起始节点称为root,并与图的邻接矩阵graph一起传递。root被推入栈中。node = root保存栈中的第一个节点:

    def depth_first_search(graph, root): 
        visited_vertices = list() 
        graph_stack = list() 

        graph_stack.append(root) 
        node = root 

只要栈不为空,while循环的主体将被执行。如果node不在已访问节点列表中,我们将其添加。通过adj_nodes = graph[node]收集node的所有相邻节点。如果所有相邻节点都已经被访问,我们将从栈中弹出该节点,并将node设置为graph_stack[-1]graph_stack[-1]是栈顶的节点。continue语句跳回到while循环的测试条件的开始。

        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 

另一方面,如果并非所有相邻节点都已经被访问,则通过使用remaining_elements = set(adj_nodes).difference(set(visited_vertices))语句找到adj_nodesvisited_vertices之间的差异来获得尚未访问的节点。

sorted(remaining_elements)中的第一个项目被分配给first_adj_node,并推入栈中。然后我们将栈的顶部指向这个节点。

while循环结束时,我们将返回visited_vertices

现在我们将通过将其与先前的示例相关联来解释源代码的工作。A节点被选择为我们的起始节点。A被推入栈中,并添加到visisted_vertices列表中。这样做时,我们将其标记为已访问。graph_stack栈使用简单的 Python 列表实现。我们的栈现在只有 A 作为其唯一元素。我们检查A节点的相邻节点BS。为了测试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。我们通过将其附加到graph_stack来将B节点推入栈。我们通过将其分配给node来准备访问B节点。

while循环的下一次迭代中,我们将B节点添加到visited nodes列表中。我们发现B的唯一相邻节点A已经被访问。因为B的所有相邻节点都已经被访问,我们将其从栈中弹出,只留下A作为栈中的唯一元素。我们返回到A,检查它的所有相邻节点是否都已经被访问。A节点现在只有S节点是未访问的。我们将S推入栈中,然后重新开始整个过程。

遍历的输出是A-B-S-C-D-E-H-G-F

深度优先搜索在解决迷宫问题、查找连通分量和查找图的桥梁等方面有应用。

其他有用的图方法

我们经常需要使用图来找到两个节点之间的路径。有时,需要找到节点之间的所有路径,在某些情况下,我们可能需要找到节点之间的最短路径。例如,在路由应用中,我们通常使用各种算法来确定从源节点到目标节点的最短路径。对于无权图,我们只需确定它们之间边数最少的路径。如果给定了加权图,我们必须计算通过一组边的总权重。

因此,在不同的情况下,我们可能需要使用不同的算法来找到最长或最短的路径。

优先队列和堆

优先队列是一种类似于队列和栈数据结构的数据结构,它存储与其关联的优先级的数据。在优先队列中,具有最高优先级的项目首先被服务。优先队列通常使用堆来实现,因为对于这个目的来说它非常高效;然而,它也可以使用其他数据结构来实现。它是一个修改过的队列,以最高优先级的顺序返回项目,而队列则以添加项目的顺序返回项目。优先队列在许多应用中使用,例如 CPU 调度。

让我们举个例子来演示优先队列比普通队列的重要性。假设在商店里,顾客排队等候服务只能在队列的前端进行。每个顾客在得到服务之前都会在队列中花费一些时间。如果四个顾客在队列中花费的时间分别是 4、30、2 和 1,那么队列中的平均等待时间就变成了(4 + 34 + 36 + 37)/4,即27.75。然而,如果我们将优先条件与队列中存储的数据关联起来,那么我们可以给予花费时间最少的顾客更高的优先级。在这种情况下,顾客将按照花费时间的顺序进行服务,即按照 1、2、4、30 的顺序。因此,平均等待时间将变为(1 + 3 + 7 + 37)/4,现在等于12——一个更好的平均等待时间。显然,按照花费时间最少的顾客进行服务是有益的。按照优先级或其他标准选择下一个项目的方法是创建优先队列的基础。优先队列通常使用堆来实现。

堆是满足堆属性的数据结构。堆属性规定父节点和其子节点之间必须存在一定的关系。这个属性必须在整个堆中都适用。

在最小堆中,父节点和子节点之间的关系是父节点的值必须始终小于或等于其子节点的值。由于这个关系,堆中最小的元素必须是根节点。

另一方面,在最大堆中,父节点大于或等于其子节点。由此可知,最大值组成了根节点。

堆是二叉树,尽管我们将使用二叉树,但实际上我们将使用列表来表示它。堆存储完全二叉树。完全二叉树是指在开始填充下一行之前,每一行必须完全填满,如下图所示:

为了使索引的数学运算更容易,我们将把列表中的第一项(索引 0)留空。之后,我们将树节点按照从上到下、从左到右的顺序放入列表中,如下图所示:

如果你仔细观察,你会注意到你可以很容易地检索到任何节点的子节点在n索引。左子节点位于2n,右子节点位于2n + 1。这总是成立的。例如,C 节点将位于3索引,因为 C 是 A 节点的右子节点,其索引为1,所以它变成了2n+1 = 2*1 + 1 = 3

让我们讨论使用 Python 实现最小堆,因为一旦我们理解了最小堆,实现最大堆将更加直接。我们从堆类开始,如下所示:

     class Heap: 
        def __init__(self): 
            self.heap = [0] 
            self.size = 0 

我们用零初始化堆列表,以表示虚拟的第一个元素(记住我们只是为了简化数学而这样做)。我们还创建一个变量来保存堆的大小。这并不是必要的,因为我们可以检查列表的大小,但我们总是需要记住将其减少一。因此,我们选择保持一个单独的变量。

插入操作

向最小堆插入项目需要分两步进行。首先,我们将新元素添加到列表的末尾(我们理解为树的底部),并将堆的大小增加一。其次,在每次插入操作之后,我们需要将新元素在堆树中安排起来,以使所有节点以满足堆属性的方式组织。这是为了提醒我们,最小堆中最小的元素需要是根元素。

我们首先创建一个名为arrange的辅助方法,它负责在插入后安排所有节点。让我们考虑在最小堆中添加元素的示例。我们在下图中提供了一个示例堆,并希望在其中插入值为2的元素:

新元素已经占据了第三行或级别的最后一个插槽。它的索引值为 7。现在我们将该值与其父节点进行比较。父节点的索引为7/2 = 3(整数除法)。该元素的值为 6,所以我们交换 2,如下所示:

我们的新元素已经被交换并移动到了3索引。我们还没有达到堆的顶部(3/2 > 0),所以我们继续。我们元素的新父节点位于索引3/2=1。所以我们再次比较,如果需要,再次交换:

最终交换后,我们得到了一个堆,如下所示。请注意,它符合堆的定义:

在我们向最小堆插入元素后,这是arrange()方法的实现:

    def arrange(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.arrange(self.size) 

请注意,insert中的最后一行调用arrange()方法来根据需要重新组织堆。

弹出操作

pop操作从堆中移除一个元素。从最小堆中移除元素的原因是,首先找出要删除的项目的索引,然后组织堆以满足堆属性。然而,更常见的是从最小堆中弹出最小值,并根据最小堆的属性,我们可以通过其根值获得最小值。因此,为了获取并从最小堆中删除最小值,我们移除根节点并重新组织堆的所有节点。我们还将堆的大小减少一。

然而,一旦根节点被弹出,我们就需要一个新的根节点。为此,我们只需取出列表中的最后一个项目,并将其作为新的根。也就是说,我们将它移动到列表的开头。然而,所选的最后一个节点可能不是堆中最小的元素,因此我们需要重新组织堆的节点。为了根据最小堆属性对所有节点进行结构化,我们遵循了与插入元素时使用的arrange()方法相反的策略。我们将最后一个节点作为新的根,然后让它根据需要向下移动(或下沉)。

让我们通过一个例子来帮助理解这个概念。首先,我们弹出root元素:

如果我们选择移动根的一个子节点,我们将不得不弄清楚如何重新平衡整个树结构,这将更加复杂。因此,我们做一些非常有趣的事情。我们将列表中的最后一个元素移动到root元素的位置上;例如,在下面的堆示例中,最后一个元素6被放置在根位置上:

现在,这个元素显然不是堆中最小的。因此,我们需要将它下沉到堆中。首先,我们需要确定是向左还是向右子节点下沉。我们比较两个子节点,以便最小的元素将作为根下沉。在这个例子中,我们比较了根的两个子节点,即53

右子节点显然更小:它的索引是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)

就像我们在插入操作中的arrange()方法中所做的那样,我们比较父节点和子节点,看看我们是否需要进行交换:

            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

测试堆

现在,让我们测试堆的实现,并通过一个例子来讨论。我们首先通过逐个插入 10 个元素来构建一个堆。让元素为{4, 8, 7, 2, 9, 10, 5, 1, 3, 6}。首先,我们手动创建一个包含这些元素的堆,然后我们将实现它并验证我们是否做得正确:

在上图中,我们展示了一个逐步插入元素到堆中的过程。在这里,我们继续按照所示添加元素:

最后,我们向堆中插入一个元素6

现在,让我们开始创建堆并插入数据,如下所示的代码:

    h = Heap() 
    for i in (4, 8, 7, 2, 9, 10, 5, 1, 3, 6): 
        h.insert(i) 

我们可以打印堆列表,只是为了检查元素的排序方式。如果你将其重新绘制为树结构,你会注意到它满足堆的所需属性,类似于我们手动创建的那样:

    print(h.heap) 

现在我们将一个一个地弹出项目。注意项目是如何按照从低到高的顺序排序出来的。同时,注意每次pop后堆列表是如何改变的。sink()方法将重新组织堆中的所有项目:

    for i in range(10): 
        n = h.pop() 
        print(n) 
        print(h.heap) 

在前面的部分中,我们讨论了使用最小堆的概念,因此通过简单地颠倒逻辑,实现最大堆应该是一个简单的任务。

我们将在第十章中再次使用我们在这里讨论的最小堆,排序,关于排序算法,并将重写列表中元素的排序代码。这些算法被称为堆排序算法。

选择算法

选择算法属于一类算法,旨在解决在列表中找到第 i 个最小元素的问题。当列表按升序排序时,列表中的第一个元素将是列表中最小的项。列表中的第二个元素将是列表中第二小的元素。列表中的最后一个元素将是最小的(或最大的)元素。

在创建堆数据结构时,我们已经了解到调用pop方法将返回最小堆中的最小元素。从最小堆中弹出的第一个元素是列表中的最小元素。同样,从最小堆中弹出的第七个元素将是列表中第七小的元素。因此,在列表中找到第 i 个最小元素将需要我们弹出堆 i 次。这是在列表中找到第 i 个最小元素的一种非常简单和高效的方法。

然而,在第十一章,选择算法中,我们将学习更多寻找列表中第 i 个最小元素的方法。

选择算法在过滤嘈杂数据、查找列表中的中位数、最小和最大元素等方面有应用,并且甚至可以应用在计算机国际象棋程序中。

总结

本章讨论了图和堆。图的主题对于许多现实世界的应用非常重要和有用。我们已经看过了用列表和字典表示图的不同方法。为了遍历图,我们使用了两种方法:BFS 和 DFS。

然后我们转向了堆和优先队列,以了解它们的实现。本章以使用堆的概念来查找列表中第 i 个最小元素的讨论结束。

下一章将引领我们进入搜索领域,以及我们可以有效搜索列表中项目的各种方法。

第九章:搜索

所有数据结构中最重要的操作之一是从存储的数据中搜索元素。有各种方法可以在数据结构中搜索元素;在本章中,我们将探讨可以用来在项目集合中查找元素的不同策略。

搜索操作对于排序非常重要。如果没有使用某种搜索操作的变体,几乎不可能对数据进行排序。如果搜索算法有效,排序算法将会很快。在本章中,我们将讨论不同的搜索算法。

搜索操作的性能受到即将搜索的项目是否已经排序的影响,我们将在后续章节中看到。

在本章结束时,您将能够做到以下事情:

  • 了解各种搜索算法

  • 了解流行搜索算法的实现

  • 了解二分搜索算法的实现

  • 了解插值的实现

技术要求

本章中使用的源代码可在以下 GitHub 链接找到:github.com/PacktPublishing/Hands-On-Data-Structures-and-Algorithms-with-Python-3.7-Second-Edition/tree/master/Chapter09

搜索简介

搜索算法分为两种类型:

  • 将搜索算法应用于已经排序的项目列表;即应用于有序的项目集

  • 将搜索算法应用于未排序的项目集

线性搜索

搜索操作是为了从存储的数据中找出给定的项目。如果存储的列表中存在搜索的项目,则返回其所在的索引位置,否则返回未找到该项目。在列表中搜索项目的最简单方法是线性搜索方法,其中我们在整个列表中逐个查找项目。

让我们以5个列表项{60, 1, 88, 10, 11, 100}为例,以了解线性搜索算法,如下图所示:

前面的列表中的元素可以通过列表索引访问。为了在列表中找到一个元素,我们使用线性搜索技术。该技术通过使用索引遍历元素列表,从列表的开头移动到末尾。每个元素都会被检查,如果它与搜索项不匹配,则会检查下一个项目。通过从一个项目跳到下一个项目,列表被顺序遍历。

本章中使用整数值的列表项来帮助您理解概念,因为整数可以很容易地进行比较;但是,列表项也可以保存任何其他数据类型。

无序线性搜索

线性搜索方法取决于列表项的存储方式-它们是否按顺序排序或无序存储。让我们首先看看列表是否包含未排序的项目。

考虑一个包含元素 60、1、88、10 和 100 的列表-一个无序列表。列表中的项目没有按大小排序。要在这样的列表上执行搜索操作,首先从第一个项目开始,将其与搜索项进行比较。如果搜索项不匹配,则检查列表中的下一个元素。这将继续进行,直到我们到达列表中的最后一个元素或找到匹配项为止。

以下是 Python 中对无序项目列表进行线性搜索的实现:

    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进行比较,如果不相等,我们将66与下一个元素1进行比较,然后是88,依此类推,直到在列表中找到搜索项为止:

无序线性搜索的最坏情况运行时间为O(n)。在找到搜索项之前,可能需要访问所有元素。最坏情况是搜索项位于列表的最后位置。

有序线性搜索

线性搜索的另一种情况是,当列表元素已经排序时,我们的搜索算法可以得到改进。假设元素已按升序排序,则搜索操作可以利用列表的有序性使搜索更加高效。

算法简化为以下步骤:

  1. 按顺序移动列表

  2. 如果搜索项大于循环中当前检查的对象或项,则退出并返回None

在遍历列表的过程中,如果搜索项大于当前项,则无需继续搜索。

让我们考虑一个示例来看看这是如何工作的。我们拿一个项目列表,如下图所示,并且我们想搜索术语5

当搜索操作开始并且第一个元素与搜索项(5)进行比较时,找不到匹配项。但是,列表中还有更多元素,因此搜索操作继续检查下一个元素。在排序列表中继续前进的更有力的原因是,我们知道搜索项可能与大于2的任何元素匹配。

经过第四次比较,我们得出结论,搜索项无法在列表中后面的任何位置找到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)。一般来说,这种搜索被认为是低效的,特别是在处理大型数据集时。

二进制搜索

二进制搜索是一种搜索策略,用于在排序的数组或列表中查找元素;因此,二进制搜索算法从给定的排序项目列表中找到给定的项目。这是一种非常快速和高效的搜索元素的算法,唯一的缺点是我们需要一个排序的列表。二进制搜索算法的最坏情况运行时间复杂度为O(log n),而线性搜索的复杂度为O(n)

二分搜索算法的工作方式如下。它通过将给定的列表分成两半来开始搜索项。如果搜索项小于中间值,则只在列表的前半部分查找搜索项,如果搜索项大于中间值,则只在列表的后半部分查找。我们重复相同的过程,直到找到搜索项或者我们已经检查了整个列表。

让我们通过一个例子来理解二分搜索。假设我们有一本 1000 页的书,我们想找到第 250 页。我们知道每本书的页码是从1开始顺序编号的。因此,根据二分搜索的类比,我们首先检查搜索项 250,它小于 500(书的中点)。因此,我们只在书的前半部分搜索所需的页面。然后我们再次看到书的前半部分的中点,即使用 500 页作为参考,我们找到中点,即 250。这使我们更接近找到第 250 页。然后我们在书中找到所需的页面。

让我们考虑另一个例子来理解二分搜索的工作原理。我们想从包含 12 个项的列表中搜索43,如下图所示:

我们通过将其与列表的中间项进行比较来开始搜索项,例如在示例中是37。如果搜索项小于中间值,则只查看列表的前半部分;否则,我们将查看另一半。因此,我们只需要在列表的后半部分搜索项。我们遵循相同的概念,直到在列表中找到搜索项43,如前图所示。

以下是对有序物品列表进行二分搜索算法的实现:

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循环来迭代地调整列表中的限制,以便找到搜索项。停止while循环的终止条件是起始索引index_of_first_elementindex_of_last_element索引之间的差值应为正数。

该算法首先通过将第一个元素的索引(0)加上最后一个元素的索引(4)并除以2来找到列表的中点,mid_point

mid_point = (index_of_first_element + index_of_last_element)//2 

在这种情况下,中点是100,值10不在列表的中间位置找到。由于我们正在搜索10,它小于中点,因此它位于列表的前半部分,因此,我们将索引范围调整为index_of_first_elementmid_point-1,如下图所示。然而,如果我们正在搜索120,在这种情况下,由于 120 大于中间值(100),我们将在列表的后半部分搜索项,并且我们需要将列表索引范围更改为mid_point +1index_of_last_element。如下图所示:

现在我们的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 为底的对数。

插值搜索

插值搜索算法是二分搜索算法的改进版本。当排序列表中的元素均匀分布时,它的性能非常高。在二分搜索中,我们总是从列表的中间开始搜索,而在插值搜索中,我们根据要搜索的项确定起始位置。在插值搜索算法中,起始搜索位置很可能最接近列表的开头或结尾,具体取决于搜索项。如果搜索项接近列表中的第一个元素,则起始搜索位置很可能靠近列表的开头。

插值搜索是二分搜索算法的另一种变体,与人类在任何项目列表上执行搜索的方式非常相似。它基于尝试猜测在排序项目列表中可能找到搜索项的索引位置。它的工作方式类似于二分搜索算法,只是确定分割标准以减少比较次数的方法不同。在二分搜索的情况下,我们将数据分成相等的两部分,在插值搜索的情况下,我们使用以下公式来分割数据:

mid_point = 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]) 

在上述公式中,lower_bound_index变量是下界索引,即列表中最小值的索引,upper_bound_index表示列表中最大值的索引位置。input_list[lower_bound_index]input_list[lower_bound_index]变量分别是列表中的最小值和最大值。search_term变量包含要搜索的项的值。

让我们通过以下包含 7 个项目的列表来考虑一个示例,以了解插值搜索算法的工作原理:

为了找到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表示正在搜索的值。

给定我们的搜索列表,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的值:

mid_point= 0 + (6-0)//(250-44) * (230-44)
         = 5 

现在可以看到mid_point的值将接收值5。因此,在插值搜索的情况下,算法将从索引位置5开始搜索,这是我们搜索词的位置索引。因此,要搜索的项将在第一次比较中找到,而在二分搜索的情况下,我们将选择100作为mid_point,这将需要再次运行算法。

以下是一个更直观的例子,说明了典型的二分搜索与插值搜索的不同之处。在典型的二分搜索中,它找到了看起来在列表中间的中点

![可以看到中点实际上站在前面列表的大致中间。这是通过将列表分成两部分得到的结果。另一方面,在插值搜索的情况下,中点被移动到最有可能匹配项的位置:

在插值搜索中,中点通常更靠左或更靠右。这是由于在除法时使用的乘数的影响。在前面的图表中,我们的中点已经向右倾斜。

插值算法的实现与二分搜索的实现相同,只是我们计算中点的方式不同。

在这里,我们提供了插值搜索算法的实现,如下所示的代码:

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。这两种不同的算法将如何处理?

如果我们将这个列表传递给interpolation search函数,那么nearest_mid函数将使用mid_point计算公式返回等于0的值:

mid_point= 0 + (7-0)//(77-2) * (2-2)
         = 0 

当我们得到mid_point0时,我们从索引0开始插值搜索。只需一次比较,我们就找到了搜索项。

另一方面,二分搜索算法需要三次比较才能找到搜索项,如下图所示:

![

计算得到的第一个mid_point值为3。第二个mid_point值为1,最后一个mid_point值为搜索项所在的0

因此,很明显,插值搜索算法在大多数情况下比二分搜索效果更好。

选择搜索算法

与有序和无序线性搜索函数相比,二分搜索和插值搜索算法在性能上更好。由于有序和无序线性搜索在列表中顺序探测元素以找到搜索项,因此其时间复杂度为O(n)。当列表很大时,性能非常差。

另一方面,二分搜索操作在每次搜索尝试时都会将列表切成两半。在每次迭代中,我们比线性策略更快地接近搜索项。时间复杂度为O(log n)。尽管使用二分搜索可以获得速度上的优势,但其主要缺点是不能应用于未排序的项目列表,也不建议用于小型列表,因为排序的开销很大。

能够到达包含搜索项的列表部分在很大程度上决定了搜索算法的性能。在插值搜索算法中,中点的计算方式使得更有可能更快地获得我们的搜索项。插值搜索的平均情况时间复杂度为O(log(log n)),而最坏情况时间复杂度为O(n)。这表明插值搜索比二分搜索更好,并在大多数情况下提供更快的搜索。

总结

在本章中,我们讨论了两种重要的搜索算法类型。讨论了线性和二分搜索算法的实现以及它们的比较。本章还详细讨论了二分搜索变体插值搜索。

在下一章中,我们将使用搜索算法的概念进行排序算法。我们还将利用已经获得的知识对项目列表执行排序算法。

第十章:排序

排序意味着重新组织数据,使其按从小到大的顺序排列。排序是数据结构和计算中最重要的问题之一。数据在排序之前经常被排序,因为这样可以非常高效地检索,无论是一组姓名、电话号码,还是简单待办事项清单上的项目。

在本章中,我们将学习一些最重要和流行的排序技术,包括以下内容:

  • 冒泡排序

  • 插入排序

  • 选择排序

  • 快速排序

  • 堆排序

在本章中,我们通过考虑它们的渐近行为来比较不同的排序算法。一些算法相对容易开发,但性能可能较差,而其他算法在实现上稍微复杂一些,但在对长列表进行排序时表现良好。

排序后,对一组项目进行搜索操作变得更加容易。我们将从最简单的排序算法开始;即冒泡排序算法。

技术要求

用于解释本章概念的所有源代码都在以下 GitHub 存储库中提供:github.com/PacktPublishing/Hands-On-Data-Structures-and-Algorithms-with-Python-Second-Edition/tree/master/Chapter10

排序算法

排序意味着将列表中的所有项目按其大小的升序排列。我们将讨论一些最重要的排序算法,它们各自具有不同的性能属性,涉及运行时复杂性。排序算法根据它们的内存使用、复杂性、递归性以及它们是否基于比较等考虑进行分类。

一些算法使用更多的 CPU 周期,因此具有糟糕的渐近值。其他算法在对一些值进行排序时会消耗更多的内存和其他计算资源。另一个考虑因素是排序算法如何适合递归、迭代或两者表达。有些算法使用比较作为排序元素的基础。冒泡排序算法就是一个例子。非比较排序算法的例子包括桶排序和鸽巢排序算法。

冒泡排序算法

冒泡排序算法的思想非常简单。给定一个无序列表,我们比较列表中相邻的元素,每次比较后,将它们按大小顺序放置。这是通过交换相邻的项目来实现的,如果它们的顺序不正确。这个过程对于 n 个项目的列表会重复 n-1 次。在每次迭代中,最大的元素都会被放在最后。例如,在第一次迭代中,最大的元素将被放在列表的最后位置,然后,相同的过程将对剩下的 n-1 个项目进行。在第二次迭代中,第二大的元素将被放在列表的倒数第二个位置,然后该过程将重复,直到列表排序完成。

让我们以只有两个元素{5, 2}的列表来理解冒泡排序的概念,如下图所示:

为了对这个列表进行排序,我们只需将值交换到正确的位置,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 

现在我们已经能够交换一个包含两个元素的数组,使用相同的思路对整个列表进行排序应该很简单。

让我们考虑另一个例子,以了解冒泡排序算法对包含6个元素的无序列表进行排序的工作原理,例如{45238712324}。在第一次迭代中,我们开始比较前两个元素4523,并交换它们,因为45应该放在23之后。然后,我们比较下一个相邻值4587,看它们是否按正确顺序排列。如果它们没有按正确顺序排列,则交换它们。我们可以看到,在冒泡排序的第一次迭代后,最大的元素87被放置在列表的最后位置:

第一次迭代后,我们只需要排列剩下的(n-1)个元素;我们通过比较剩下的五个元素的相邻元素来重复相同的过程。第二次迭代后,第二大的元素45被放置在列表中倒数第二个位置,如下图所示:

接下来,我们需要比较剩下的(n-2)个元素,将它们排列如下图所示:

同样地,我们比较剩下的元素来对它们进行排序:

最后,在剩下的两个元素中,我们将它们按正确顺序放置,以获得最终排序的列表,如下图所示:

冒泡排序算法的实现将在一个双嵌套循环中工作,其中内部循环重复比较和交换给定列表中每次迭代中的相邻元素,而外部循环则跟踪内部循环应重复多少次。内部循环的实现如下:

    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 是因为它确切地给出了需要运行的最大迭代次数。让我们通过以下示例来展示这一点,在一个包含 3 个数字的列表中,通过在恰好两次迭代中交换相邻元素,最大的数字最终位于列表的最后位置:

if语句确保如果两个相邻元素已经按正确顺序排列,则不会发生不必要的交换。内部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)。通常,不应该使用冒泡排序算法对大型列表进行排序。但是,在相对较小的列表上,它的性能还算不错。

插入排序算法

将相邻元素交换以对项目列表进行排序的想法也可以用于实现插入排序。插入排序算法维护一个始终排序的子列表,而列表的另一部分保持未排序。我们从未排序的子列表中取出元素,并将它们插入到排序的子列表的正确位置,使得这个子列表保持排序。

在插入排序中,我们从一个元素开始,假设它已经排序,然后从未排序的子列表中取出另一个元素,并将其放在排序的子列表中正确的位置(相对于第一个元素)。这意味着我们的排序子列表现在有两个元素。然后,我们再次从未排序的子列表中取出另一个元素,并将其放在排序的子列表中正确的位置(相对于已排序的两个元素)。我们反复遵循这个过程,将未排序的子列表中的所有元素一个接一个地插入到排序的子列表中。阴影元素表示有序子列表,在每次迭代中,未排序子列表中的一个元素被插入到排序子列表的正确位置。

让我们考虑一个例子来理解插入排序算法的工作原理。在我们的例子中,我们将对包含6个元素的列表{45, 23, 87, 12, 32, 4}进行排序。首先,我们从1个元素开始,假设它已经排序,然后从未排序的子列表中取出下一个元素23,并将其插入到排序的子列表中的正确位置。在下一次迭代中,我们从未排序的子列表中取出第三个元素87,并再次将其插入到排序的子列表中的正确位置。我们一直遵循相同的过程,直到所有元素都在排序的子列表中。整个过程如下图所示:

为了理解插入排序算法的实现,让我们以另一个包含5个元素的示例列表{5, 1, 100, 2, 10}为例,并用详细的解释来检查这个过程。

让我们考虑以下数组:

该算法通过使用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_value变量。unsorted_list[search_index-1]数组将执行以下操作之一:

  • while循环第一次执行之前,指向unsorted_list[search_index]之前的一个元素

  • while循环第一次运行后,指向unsorted_list[search_index-1]之前的一个元素

在我们的示例列表中,while循环将被执行,因为5 > 1。在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循环不会执行。100将被替换为它自己,因为search_index变量从未被减少。因此,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)

选择排序算法

另一个流行的排序算法是选择排序。选择排序算法首先找到列表中最小的元素,并将其与列表中的第一个位置存储的数据交换。因此,它使子列表排序到第一个元素。接下来,识别出剩余列表中最小的元素(即剩余列表中最小的元素),并将其与列表中的第二个位置交换。这使得初始的两个元素排序。该过程重复进行,列表中剩余的最小元素应该与列表中第三个索引处的元素交换。这意味着前三个元素现在已排序。这个过程重复了(n-1)次来对n个项目进行排序。

让我们通过一个示例来理解算法的工作原理。我们将使用选择排序算法对以下 4 个元素的列表进行排序:

从索引0开始,我们搜索列表中存在于索引1和最后一个元素索引之间的最小项。找到这个元素后,将其与索引0处的数据交换。我们只需重复此过程,直到列表完全排序。

在列表中搜索最小的项目是一个递增的过程:

对元素25进行比较,选择2,因为它是这两个值中较小的值,因此这两个元素被交换。

交换操作后,数组如下所示:

此外,在索引0处,我们将265进行比较:

由于65大于2,这两个元素不会交换。在索引0处的元素2和索引3处的元素10之间进行了进一步比较。在这种情况下不会发生交换。当我们到达列表中的最后一个元素时,最小的元素将占据索引0

在下一次迭代中,我们从索引1开始比较元素。我们重复整个过程,将索引1处存储的元素与从索引2到最后一个索引的所有元素进行比较。

第二次迭代从比较565开始,结果如下:

一旦我们发现5是从索引13的子列表中的最小值,我们将其放在索引1处。同样,从子列表23的索引中找到的下一个最小元素被放置在索引3处。

以下是选择排序算法的实现。函数的参数是我们想要按大小顺序排列的未排序项目列表:

    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+1开始搜索最小元素,但使用j索引:

前面的图表显示了算法搜索下一个最小项的方向。

选择排序算法的最坏情况和最佳情况运行时间复杂度均为O(n2)

快速排序算法

快速排序算法对于排序非常有效。快速排序算法属于分治类算法,类似于归并排序算法,其中我们将问题分解为更简单的小块来解决。

列表分区

快速排序的概念是对给定的列表或数组进行分区。为了对列表进行分区,我们首先选择一个枢轴。列表中的所有元素将与此枢轴进行比较。在分区过程结束时,所有小于枢轴的元素将位于枢轴的左侧,而所有大于枢轴的元素将位于数组中枢轴的右侧。

枢轴选择

为了简单起见,我们将数组中的第一个元素作为枢轴。这种枢轴选择在性能上会下降,特别是在对已排序列表进行排序时。随机选择数组中间或最后一个元素作为枢轴并不会改善快速排序的性能。我们将在下一章讨论更好的选择枢轴和找到列表中最小元素的方法。

举例说明

在这个算法中,我们将一个未排序的数组分成两个子数组,使得分区点(也称为枢轴)左侧的所有元素都应该小于枢轴,而枢轴右侧的所有元素都应该大于枢轴。在快速排序算法的第一次迭代之后,选择的枢轴点被放置在列表中的正确位置。第一次迭代之后,我们得到两个无序的子列表,并在这两个子列表上再次执行相同的过程。因此,快速排序算法将列表分成两部分,并递归地在这两个子列表上应用快速排序算法以对整个列表进行排序。

我们首先选择一个枢轴点,所有项目都将与其进行比较,并在第一次迭代结束时,该值将被放置在有序列表中的正确位置。接下来,我们使用两个指针,一个左指针和一个右指针。左指针最初指向索引1处的值,右指针指向最后一个索引处的值。快速排序算法的主要思想是移动在枢轴值错误一侧的项目。因此,我们从左指针开始,从左到右移动,直到找到一个比枢轴值大的位置。类似地,我们将右指针向左移动,直到找到一个小于枢轴值的值。接下来,我们交换左右指针指示的这两个值。我们重复相同的过程,直到两个指针交叉;换句话说,右指针索引指示的值小于左指针索引的值时。

让我们以一个数字列表{45, 23, 87, 12, 72, 4, 54, 32, 52}为例,来理解快速排序算法的工作原理。假设我们列表中的枢轴点是第一个元素45。我们从索引1处向右移动左指针,并在找到值87时停止,因为(87>45)。接下来,我们将右指针向左移动,并在找到值32时停止,因为(32<45)。

现在,我们交换这两个值,如下图所示:

之后,我们重复相同的过程,将左指针向右移动,并在找到值72时停止,因为(72>45)。接下来,我们将右指针向左移动,并在找到值4时停止,因为(4<45)。现在,我们交换这两个值,因为它们与枢轴值的方向相反。我们重复相同的过程,并在右指针索引值小于左指针索引值时停止。在这里,我们找到4作为分割点,并将其与枢轴值交换。如下图所示:

在快速排序算法的第一次迭代之后,可以观察到枢轴值45被放置在列表中的正确位置。

现在我们有了两个子列表:

  1. 枢轴值45左侧的子列表具有小于45的值。

  2. 枢轴值右侧的另一个子列表包含大于 45 的值。我们将在这两个子列表上递归应用快速排序算法,并重复此过程,直到整个列表排序完成。

实施

分区步骤对于理解快速排序算法的实现非常重要,因此我们将从实现分区开始进行检查。

让我们看另一个例子来理解实现。考虑以下整数列表。我们将使用分区函数对此列表进行分区,如下所示:

考虑以下代码:

     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,标记了我们开始在数组中寻找大于主元的元素的位置,即greater_than_pivot_index = first_index + 1右指针less_than_pivot_index变量指向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_indexif语句的主体交换了这些索引处的元素。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 

以下图表显示了代码在分区过程的最后一步中如何交换443

总之,第一次调用quick_sort函数时,它是围绕索引0的元素进行分区的。在分区函数返回后,我们得到的数组顺序为[4320438977]。

正如你所看到的,主元43右边的所有元素都大于43,而左边的元素都小于43。因此,分区完成。

使用分割点43和索引3,我们将递归地对两个子数组进行排序,即[43020]和[8977],使用刚刚经历的相同过程。

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函数是一个非常简单的方法,代码不超过六行。繁重的工作由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_indexwhile 循环的进一步执行将停止。将主元 43 交换,同时索引 1 被返回为分区点。

在快速排序算法中,分区算法需要 O(n) 时间。由于快速排序算法遵循“分而治之”的范式,它需要 O(log n) 时间;因此,快速排序算法的整体平均情况运行时间复杂度为 O(n) * O(log n) = O(n log n)。快速排序算法给出了最坏情况的运行时间复杂度为 O(n²)。快速排序算法的最坏情况复杂度是每次选择最坏的主元点,并且其中一个分区始终只有一个元素。例如,如果列表已经排序,最坏情况复杂度将发生在分区选择最小元素作为主元点时。当最坏情况复杂度发生时,可以通过使用随机化快速排序来改进快速排序算法。与其他上述排序算法相比,快速排序算法在对大量数据进行排序时非常高效。

堆排序算法

在第八章《图和其他算法》中,我们实现了一个二叉堆数据结构。我们的实现始终确保,在从堆中移除或添加元素后,使用 sink()arrange() 辅助方法来维护堆顺序属性。

堆数据结构可以用来实现一种称为堆排序的排序算法。简而言之,让我们创建一个包含以下项目的简单堆:

    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 次。加上 arrange() 方法,insert 操作的最坏情况运行时间为 O(n log n)pop 方法也是如此。因此,这种排序算法的最坏情况运行时间为 O(n log n)

不同排序算法的复杂性比较如下表所示:

算法 最坏情况 平均情况 最佳情况
冒泡排序 O(n²) O(n²) O(n)
插入排序 O(n²) O(n²) O(n)
选择排序 O(n²) O(n²) O(n²)
快速排序 O(n²) O(n log n) O(n log n)
堆排序 O(n log n) O(n log n) O(n log n)

总结

在本章中,我们探讨了许多重要和流行的排序算法,这些算法对许多实际应用非常有用。我们讨论了冒泡排序、插入排序、选择排序、快速排序和堆排序算法,以及它们在 Python 中的实现解释。快速排序比其他排序算法表现要好得多。在所有讨论的算法中,快速排序保留了它所排序的列表的索引。在下一章中,我们将利用这一特性来探讨选择算法。

在下一章中,我们将讨论与选择策略和算法相关的概念。

第十一章:选择算法

与在无序项目列表中查找元素相关的一组有趣的算法是选择算法。给定一个元素列表,选择算法用于从列表中找到第 i 个最小元素。在这样做的过程中,我们将回答与选择一组数字的中位数和在列表中选择第 i 个最小或最大元素有关的问题。

在本章中,我们将涵盖以下主题:

  • 排序选择

  • 随机选择

  • 确定性选择

技术要求

本章中使用的所有源代码都在以下 GitHub 链接中提供:github.com/PacktPublishing/Hands-On-Data-Structures-and-Algorithms-with-Python-Second-Edition/tree/master/Chapter11

排序选择

列表中的项目可能会接受统计调查,比如找到平均值、中位数和众数。找到平均值和众数并不需要列表被排序。然而,要在数字列表中找到中位数,列表必须首先被排序。找到中位数需要你找到有序列表中间位置的元素。此外,当我们想要找到列表中最后最小的项目或者第一个最小的项目时,可以使用选择算法。

要在无序项目列表中找到第 i 个最小数,获取该项目出现的索引是很重要的。由于列表的元素没有排序,很难知道列表中索引为 0 的元素是否真的是第一个最小数。

处理无序列表时一个实用且明显的做法是首先对列表进行排序。在列表排序后,你可以放心地认为索引为 0 的元素将持有列表中的第一个最小元素。同样,列表中的最后一个元素将持有列表中的最后一个最小元素。然而,在长列表中应用排序算法来获取列表中的最小值或最大值并不是一个好的解决方案,因为排序是一个非常昂贵的操作。

让我们讨论一下是否可能在不排序列表的情况下找到第 i 个最小元素。

随机选择

在前一章中,我们讨论了快速排序算法。快速排序算法允许我们对无序项目列表进行排序,但在排序算法运行时保留元素索引的方法。一般来说,快速排序算法执行以下操作:

  1. 选择一个主元素

  2. 围绕主元素对未排序的列表进行分区

  3. 使用步骤 1步骤 2递归地对分区列表的两半进行排序

一个有趣且重要的事实是,在每次分区步骤之后,主元素的索引不会改变,即使列表已经排序。这意味着在每次迭代后,所选的主元素值将被放置在列表中的正确位置。正是这个属性使我们能够在一个不太完全排序的列表中获得第 i 个最小数。因为随机选择是基于快速排序算法的,它通常被称为快速选择。

快速选择

快速选择算法用于获取无序项目列表中的第 k 个最小元素,并基于快速排序算法。在快速排序中,我们递归地对主元素的两个子列表进行排序。在快速排序中,每次迭代中,我们知道主元素值达到了正确的位置,两个子列表(左子列表和右子列表)的所有元素都被设置为无序。

然而,在快速选择算法中,我们递归地调用函数,专门针对具有第k小元素的子列表。在快速选择算法中,我们将枢轴点的索引与k值进行比较,以获取给定无序列表中的第k小元素。快速选择算法中将会有三种情况,它们如下:

  1. 如果枢轴点的索引小于k,那么我们可以确定第k小的值将出现在枢轴点右侧的子列表中。因此,我们只需递归地调用快速选择函数来处理右子列表。

  2. 如果枢轴点的索引大于k,那么很明显第k小的元素将出现在枢轴点左侧。因此,我们只需递归地在左子列表中寻找第i个元素。

  3. 如果枢轴点的索引等于k,那么意味着我们已经找到了第k小的值,并将其返回。

让我们通过一个例子来理解快速选择算法的工作原理。假设有一个元素列表{45, 23, 87, 12, 72, 4, 54, 32, 52},我们想要找出这个列表中第 3 个最小的元素——我们通过使用快速排序算法来实现这一点。

我们通过选择一个枢轴值,即 45,来开始算法。在算法的第一次迭代之后,我们将枢轴值放置在列表中的正确位置,即索引 4(索引从 0 开始)。现在,我们将枢轴值的索引(即 4)与k的值(即第 3 个位置,或索引 2)进行比较。由于这是在k<枢轴点(即 2<4),我们只考虑左子列表,并递归调用函数。

现在,我们取左子列表并选择枢轴点(即4)。运行后,4被放置在其正确的位置(即 0 索引)。由于枢轴的索引小于k的值,我们考虑右子列表。同样,我们将23作为枢轴点,它也被放置在了正确的位置。现在,当我们比较枢轴点的索引和k的值时,它们是相等的,这意味着我们已经找到了第 3 个最小的元素,并将其返回。

这个过程也在下面的图表中显示:

要实现快速选择算法,我们首先需要了解主要函数,其中有三种可能的情况。我们将算法的主要方法声明如下:

    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函数接受列表中第一个元素的索引以及最后一个元素的索引作为参数。第三个参数k指定了第i个元素。k的值应该始终是正数;只有大于或等于零的值才被允许,这样当k为 0 时,我们知道要在列表中搜索第一个最小的项。其他人喜欢处理k参数,使其直接映射到用户正在搜索的索引,这样第一个最小的数字就映射到排序列表的0索引。

partition函数的方法调用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^(th)个元素,即使枢轴是随机选择的。

partition函数返回由less_than_pivot_index指向的枢轴索引,正如我们在前一章中看到的。

确定性选择

随机选择算法的最坏情况性能是O(n²)。可以通过改进随机选择算法的元素部分来获得O(n)的最坏情况性能。我们可以通过使用一个算法,即确定性选择,获得O(n)的性能。

中位数中位数是一种算法,它为我们提供了近似中位数值,即接近给定未排序元素列表的实际中位数的值。这个近似中位数通常用作快速选择算法中选择列表中第i^(th)最小元素的枢轴点。这是因为中位数中位数算法在线性时间内找到了估计中位数,当这个估计中位数用作快速选择算法中的枢轴点时,最坏情况下的运行时间复杂度从O(n²)大幅提高到线性的O(n)。因此,中位数中位数算法帮助快速选择算法表现得更好,因为选择了一个好的枢轴值。

确定性算法选择第i^(th)最小元素的一般方法如下:

  1. 选择一个枢轴:

  2. 将未排序项目的列表分成每组五个元素。

  3. 对所有组进行排序并找到中位数。

  4. 递归执行步骤 12,以获得列表的真实中位数。

  5. 使用真实中位数来分区未排序项目的列表。

  6. 递归到可能包含第i^(th)最小元素的分区列表部分。

让我们考虑一个包含 15 个元素的示例列表,以了解确定性方法确定列表中第三个最小元素的工作原理。首先,您需要将具有 5 个元素的列表分成两个,并对子列表进行排序。一旦我们对列表进行了排序,我们就找出子列表的中位数,也就是说,元素235234是这三个子列表的中位数。我们准备了所有子列表中位数的列表,然后对中位数列表进行排序。接下来,我们确定这个列表的中位数,也就是中位数的中位数,即34。这个值是整个列表的估计中位数,并用于选择整个列表的分区/枢轴点。由于枢轴值的索引为 7,大于i^(th)值,我们递归考虑左子列表。

算法的功能如下图所示:

枢轴选择

为了有效地确定列表中第 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 

现在让我们了解 partition 函数的代码。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_index 和 last_index 将相等。因此,first_index 会被返回。

然而,如果列表的大小大于 1,我们将使用由 first_index 和 last_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 个项目,将会有 20 个组,由 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]) 

创建一个空数组并将其分配给 medians,它存储分配给 sublists 的每个五个元素数组中的中位数。

for 循环遍历 sublists 中的列表列表。每个子列表都被排序,找到中位数,并存储在 medians 列表中。

medians.append(sorted(sublist)[len(sublist)//2])语句将对列表进行排序并获得存储在其中间索引的元素。这成为五个元素列表的中位数。由于列表的大小很小,使用现有的排序函数不会影响算法的性能。

从一开始我们就明白,我们不会对列表进行排序以找到第 i 个最小的元素,那么为什么要使用 Python 的 sorted 方法呢?嗯,因为我们要对一个非常小的列表进行排序,只有五个元素或更少,所以这个操作对算法的整体性能的影响被认为是可以忽略的。

此后,如果列表现在包含五个或更少的元素,我们将对 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 函数。

中位数中位数算法也可以用于选择快速排序算法中的枢轴点,从而将快速排序算法的最坏情况性能从 O(n²)显著提高到 O(n log n)的复杂度。

分区步骤

现在我们已经获得了近似中位数,get_index_of_nearest_median 函数使用 first 和 last 参数指示的列表边界:

    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]返回一个索引从0list-1大小的数组。当我们找到中位数的索引时,由于[first:second]代码返回的新范围索引,我们失去了它所在的列表部分。因此,我们必须将arraylist[first:second]返回的任何索引添加到first以获得中位数的真实索引位置:

    swap(unsorted_array, first_index, index_of_nearest_median) 

然后使用swap函数将unsorted_array中的第一个元素与index_of_nearest_median进行交换。

交换两个数组元素的utility函数如下所示:

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)的时间复杂度,我们着手寻找中位数中的中位数,以便在分区过程中找到一个良好的分割点。

在下一章中,我们将探讨字符串的世界。我们将学习如何高效地存储和操作大量文本。还将涵盖数据结构和常见的字符串操作。

第十二章:字符串算法和技术

根据所解决的问题,有许多流行的字符串处理算法。然而,最重要、最流行和最有用的字符串处理问题之一是从给定文本中找到给定的子字符串或模式。它有各种应用,例如从文本文档中搜索元素,检测抄袭等。

在本章中,我们将学习标准的字符串处理或模式匹配算法,以找出给定模式或子字符串在给定文本中的位置。我们还将讨论暴力算法,以及 Rabin-Karp、Knuth-Morris-Pratt(KMP)和 Boyer-Moore 模式匹配算法。我们还将讨论与字符串相关的一些基本概念。我们将用简单的解释、示例和实现来讨论所有算法。

本章旨在讨论与字符串相关的算法。本章将涵盖以下主题:

  • 学习 Python 中字符串的基本概念

  • 学习模式匹配算法及其实现

  • 理解和实现 Rabin-Karp 模式匹配算法

  • 理解和实现 Knuth-Morris-Pratt(KMP)算法

  • 理解和实现 Boyer-Moore 模式匹配算法

技术要求

本章讨论的基于本章讨论的概念和算法的所有程序都在书中以及 GitHub 存储库中提供,链接如下:github.com/PacktPublishing/Hands-On-Data-Structures-and-Algorithms-with-Python-Second-Edition/tree/master/Chapter12

字符串符号和概念

字符串基本上是一系列对象,主要是一系列字符。与其他任何数据类型(如 int 或 float)一样,我们需要存储数据和要应用的操作。字符串数据类型允许我们存储数据,Python 提供了一组丰富的操作和函数,可以应用于字符串数据类型的数据。Python 3.7 提供的大多数操作和函数,可以应用于字符串的数据,都在第一章中详细描述了Python 对象、类型和表达式

字符串主要是文本数据,通常处理得非常高效。以下是一个字符串(S)的示例——"packt publishing"

子字符串也是给定字符串的一部分字符序列。例如,"packt"是字符串"packt publishing"的子字符串。

子序列是从给定字符串中删除一些字符但保持字符出现顺序的字符序列。例如,"pct pblishing"是字符串"packt publishing"的有效子序列,通过删除字符aku获得。但是,这不是一个子字符串。子序列不同于子字符串,因为它可以被认为是子字符串的泛化。

字符串s的前缀是字符串s的子字符串,它出现在字符串的开头。还有另一个字符串u,它存在于前缀之后的字符串 s 中。例如,子字符串"pack"是字符串(s) = "packt publishing"的前缀,因为它是起始子字符串,之后还有另一个子字符串。

后缀(d)是一个子字符串,它出现在字符串(s)的末尾,以便在子字符串 d 之前存在另一个非空子字符串。例如,子字符串"shing"是字符串"packt publishing"的后缀。Python 具有内置函数,用于检查字符串是否具有给定的前缀或后缀,如下面的代码片段所示:

string =  "this is data structures book by packt publisher"; suffix =  "publisher"; prefix = "this"; print(string.endswith(suffix))  #Check if string contains given suffix.
print(string.startswith(prefix)) #Check if string starts with given prefix.

#Outputs
>>True
>>True

模式匹配算法是最重要的字符串处理算法,我们将在后续章节中讨论它们。

模式匹配算法

模式匹配算法用于确定给定模式字符串(P)在文本字符串(T)中匹配的索引位置。如果模式在文本字符串中不匹配,则返回"pattern not found"。例如,对于给定字符串(s)="packt publisher",模式(p)="publisher",模式匹配算法返回模式在文本字符串中匹配的索引位置。

在本节中,我们将讨论四种模式匹配算法,即暴力方法,以及 Rabin-Karp 算法,Knuth-Morris-Pratt(KMP)和 Boyer Moore 模式匹配算法。

暴力算法

暴力算法,或者模式匹配算法的朴素方法,非常基础。使用这种方法,我们简单地测试给定字符串中输入模式的所有可能组合,以找到模式的出现位置。这个算法非常朴素,如果文本很长就不适用。

在这里,我们首先逐个比较模式和文本字符串的字符,如果模式的所有字符与文本匹配,我们返回模式的第一个字符放置的文本的索引位置。如果模式的任何字符与文本字符串不匹配,我们将模式向右移动一个位置。我们继续比较模式和文本字符串,通过将模式向右移动一个索引位置。

为了更好地理解暴力算法的工作原理,让我们看一个例子。假设我们有一个文本字符串(T)=acbcabccababcaacbcac,模式字符串(P)是acbcac。现在,模式匹配算法的目标是确定给定文本 T 中模式字符串的索引位置,如下图所示:

我们首先比较文本的第一个字符,即a,和模式的字符。在这里,模式的初始五个字符匹配,最后一个字符不匹配。由于不匹配,我们进一步将模式向右移动一个位置。我们再次开始逐个比较模式的第一个字符和文本字符串的第二个字符。在这里,文本字符串的字符c与模式的字符a不匹配。由于不匹配,我们将模式向右移动一个位置,如前面的图所示。我们继续比较模式和文本字符串的字符,直到遍历整个文本字符串。在上面的例子中,我们在索引位置14找到了匹配,用箭头指向aa

在这里,让我们考虑模式匹配的暴力算法的 Python 实现:

def brute_force(text, pattern):
    l1 = len(text)      # The length of the text string
    l2 = len(pattern)   # The length of the pattern 
    i = 0
    j = 0               # looping variables are set to 0
    flag = False        # If the pattern doesn't appear at all, then set this to false and execute the last if statement

    while i < l1:         # iterating from the 0th index of text
        j = 0
        count = 0    
        # Count stores the length upto which the pattern and the text have matched

        while j < l2:
            if i+j < l1 and text[i+j] == pattern[j]:  
        # statement to check if a match has occoured or not
        count += 1     # Count is incremented if a character is matched 
            j += 1
        if count == l2:   # it shows a matching of pattern in the text 
                print("\nPattern occours at index", i) 
                  # print the starting index of the successful match
                flag = True 
     # flag is True as we wish to continue looking for more matching of  
      pattern in the text. 
            i += 1
    if not flag: 
        # If the pattern doesn't occours at all, means no match of  
         pattern in the text string
        print('\nPattern is not at all present in the array')

brute_force('acbcabccababcaacbcac','acbcac')         # function call

#outputs
#Pattern occours at index 14

在暴力方法的上述代码中,我们首先计算给定文本字符串和模式的长度。我们还用0初始化循环变量,并将标志设置为False。这个变量用于在字符串中继续搜索模式的匹配。如果标志在文本字符串结束时为False,这意味着在文本字符串中根本没有模式的匹配。

接下来,我们从文本字符串的0th索引开始搜索循环,直到末尾。在这个循环中,我们有一个计数变量,用于跟踪匹配的模式和文本的长度。接下来,我们有另一个嵌套循环,从0th索引运行到模式的长度。在这里,变量i跟踪文本字符串中的索引位置,变量j跟踪模式中的字符。接下来,我们使用以下代码片段比较模式和文本字符串的字符:

if i+j<l1 and text[i+j] == pattern[j]:

此外,我们在文本字符串中每次匹配模式的字符后递增计数变量。然后,我们继续匹配模式和文本字符串的字符。如果模式的长度等于计数变量,那么就意味着有匹配。

如果在文本字符串中找到了模式的匹配,我们会打印文本字符串的索引位置,并将标志变量保持为True,因为我们希望继续在文本字符串中搜索更多模式的匹配。最后,如果标志变量的值为False,这意味着在文本字符串中根本没有找到模式的匹配。

朴素字符串匹配算法的最佳情况和最坏情况的时间复杂度分别为O(n)O(m*(n-m+1))。最佳情况是模式在文本中找不到,并且模式的第一个字符根本不在文本中,例如,如果文本字符串是ABAACEBCCDAAEE,模式是FAA。在这种情况下,由于模式的第一个字符在文本中不匹配,比较次数将等于文本的长度(n)。

最坏情况发生在文本字符串和模式的所有字符都相同的情况下,例如,如果文本字符串是AAAAAAAAAAAAAAAA,模式是AAAA。另一个最坏情况是只有最后一个字符不同,例如,如果文本字符串是AAAAAAAAAAAAAAAF,模式是AAAAF。因此,最坏情况的时间复杂度将是O(m*(n-m+1))

拉宾-卡普算法

拉宾-卡普模式匹配算法是改进后的蛮力方法,用于在文本字符串中找到给定模式的位置。拉宾-卡普算法的性能通过减少比较次数来改进,借助哈希。我们在第七章中详细描述了哈希,哈希和符号表。哈希函数为给定的字符串返回一个唯一的数值。

这种算法比蛮力方法更快,因为它避免了不必要的逐个字符比较。相反,模式的哈希值一次性与文本字符串的子字符串的哈希值进行比较。如果哈希值不匹配,模式就向前移动一位,因此无需逐个比较模式的所有字符。

这种算法基于这样的概念:如果两个字符串的哈希值相等,那么假定这两个字符串也相等。这种算法的主要问题是可能存在两个不同的字符串,它们的哈希值相等。在这种情况下,算法可能无法工作;这种情况被称为虚假命中。为了避免这个问题,在匹配模式和子字符串的哈希值之后,我们通过逐个比较它们的字符来确保模式实际上是匹配的。

拉宾-卡普模式匹配算法的工作原理如下:

  1. 首先,在开始搜索之前,我们对模式进行预处理,即计算长度为m的模式的哈希值以及长度为m的文本的所有可能子字符串的哈希值。因此,可能的子字符串的总数将是(n-m+1)。这里,n是文本的长度。

  2. 我们比较模式的哈希值,并逐一与文本的子字符串的哈希值进行比较。

  3. 如果哈希值不匹配,我们就将模式向前移动一位。

  4. 如果模式的哈希值和文本的子字符串的哈希值匹配,那么我们逐个比较模式和子字符串的字符,以确保模式实际上在文本中找到。

  5. 我们继续进行步骤 2-4 的过程,直到达到给定文本字符串的末尾。

在这个算法中,我们可以使用 Horner 法则或任何返回给定字符串唯一值的哈希函数来计算数值哈希值。我们也可以使用字符串所有字符的序数值之和来计算哈希值。

让我们举个例子来理解 Rabin-Karp 算法。假设我们有一个文本字符串(T)="publisher paakt packt",模式(P)="packt"。首先,我们计算模式(长度为m)的哈希值和文本字符串的所有子字符串(长度为m)的哈希值。

我们开始比较模式"packt"的哈希值与第一个子字符串“publi”的哈希值。由于哈希值不匹配,我们将模式移动一个位置,然后再次比较模式的哈希值与文本的下一个子字符串"ublis"的哈希值。由于这些哈希值也不匹配,我们再次将模式移动一个位置。如果哈希值不匹配,我们总是将模式移动一个位置。

此外,如果模式的哈希值和子字符串的哈希值匹配,我们逐个比较模式和子字符串的字符,并返回文本字符串的位置。在这个例子中,这些值在位置17匹配。重要的是要注意,可能有一个不同的字符串,其哈希值可以与模式的哈希值匹配。这种情况称为虚假命中,是由于哈希冲突而引起的。Rabin-Karp 算法的功能如下所示:

实现 Rabin-Karp 算法

实现 Rabin-Karp 算法的第一步是选择哈希函数。我们使用字符串所有字符的序数值之和作为哈希函数。

我们首先存储文本和模式的所有字符的序数值。接下来,我们将文本和模式的长度存储在len_textlen_pattern变量中。然后,我们通过对模式中所有字符的序数值求和来计算模式的哈希值。

接下来,我们创建一个名为len_hash_array的变量,它存储了使用len_text - len_pattern + 1的长度(等于模式的长度)的所有可能子字符串的总数,并创建了一个名为hash_text的数组,它存储了所有可能子字符串的哈希值。

接下来,我们开始一个循环,它将运行所有可能的文本子字符串。最初,我们通过使用sum(ord_text[:len_pattern])对其所有字符的序数值求和来计算第一个子字符串的哈希值。此外,所有子字符串的哈希值都是使用其前一个子字符串的哈希值计算的,如((hash_text[i-1] - ord_text[i-1]) + ord_text[i+len_pattern-1])

计算哈希值的完整 Python 实现如下所示:

def generate_hash(text, pattern):
      ord_text = [ord(i) for i in text]   
                       # stores unicode value of each character in text 
      ord_pattern = [ord(j) for j in pattern] 
                   # stores unicode value of each character in pattern
      len_text = len(text)           # stores length of the text 
      len_pattern = len(pattern)     # stores length of the pattern
      hash_pattern = sum(ord_pattern)
      len_hash_array = len_text - len_pattern + 1    
       #stores the length of new array that will contain the hash 
       values of text
      hash_text = [0]*(len_hash_array) 
                         # Initialize all the values in the array to 0.
      for i in range(0, len_hash_array): 
           if i == 0:  
                hash_text[i] = sum(ord_text[:len_pattern]) 
                                      # initial value of hash function
           else:
                hash_text[i] = ((hash_text[i-1] - ord_text[i-1]) + 
                ord_text[i+len_pattern-1]) 
                    # calculating next hash value using previous value

      return [hash_text, hash_pattern]         # return the hash values

在预处理模式和文本之后,我们有预先计算的哈希值,我们将用它们来比较模式和文本。

主要的 Rabin-Karp 算法实现如下。首先,我们将给定的文本和模式转换为字符串格式,因为只能为字符串计算序数值。

接下来,我们调用generate_hash函数来计算哈希值。我们还将文本和模式的长度存储在len_textlen_pattern变量中。我们还将flag变量初始化为False,以便跟踪模式是否至少出现一次在文本中。

接下来,我们开始一个循环,实现算法的主要概念。这个循环将运行hash_text的长度,这是可能子字符串的总数。最初,我们通过使用if hash_text[i] == hash_pattern比较子字符串的第一个哈希值和模式的哈希值。它们不匹配;我们什么也不做,寻找另一个子字符串。如果它们匹配,我们通过循环使用if pattern[j] == text[i+j]逐个字符比较子字符串和模式。

然后,我们创建一个count变量来跟踪模式和子字符串中匹配的字符数。如果计数的长度和模式的长度变得相等,这意味着所有字符都匹配,并且返回模式被找到的索引位置。最后,如果flag变量保持为False,这意味着模式在文本中根本不匹配。

Rabin-Karp 算法的完整 Python 实现如下所示:

def Rabin_Karp_Matcher(text, pattern):
    text = str(text)                 # convert text into string format
    pattern = str(pattern)           # convert pattern into string format
    hash_text, hash_pattern = generate_hash(text, pattern) 
                    # generate hash values using generate_hash function
    len_text = len(text)              # length of text
    len_pattern = len(pattern)        # length of pattern
    flag = False # checks if pattern is present atleast once or not at all
    for i in range(len(hash_text)): 
        if hash_text[i] == hash_pattern:     # if the hash value matches
            count = 0 
            for j in range(len_pattern): 
                if pattern[j] == text[i+j]: 
                        # comparing patten and substring character by character
                    count += 1  
                else:
                    break
                if count == len_pattern:       # Pattern is found in the text
                    flag = True                # update flag accordingly
                    print("Pattern occours at index", i)
                if not flag:                # Pattern doesn't match even once.
                    print("Pattern is not at all present in the text")

Rabin-Karp 模式匹配算法在搜索之前预处理模式,即计算模式的哈希值,其复杂度为O(m)。此外,Rabin-Karp 算法的最坏情况运行时间复杂度为O(m *(n-m+1))

最坏情况是模式根本不在文本中出现。

平均情况将发生在模式至少出现一次的情况下。

Knuth-Morris-Pratt 算法

Knuth-Morris-PrattKMP)算法是一种基于预先计算的前缀函数的模式匹配算法,该函数存储了模式中重叠文本部分的信息。KMP 算法预处理这个模式,以避免在使用前缀函数时进行不必要的比较。该算法利用前缀函数来估计模式应该移动多少来搜索文本字符串中的模式,每当我们得到一个不匹配时。KMP 算法是高效的,因为它最小化了给定模式与文本字符串的比较。

KMP 算法背后的动机可以在以下解释性图表中看到:

前缀函数

prefix函数(也称为失败函数)在模式中查找模式本身。当出现不匹配时,它试图找出由于模式本身的重复而可以重复使用多少之前的比较。它的值主要是最长的前缀,也是后缀。

例如,如果我们有一个模式的prefix函数,其中所有字符都不同,那么prefix函数的值将为0,这意味着如果我们找到任何不匹配,模式将被移动到模式中的字符数。这也意味着模式中没有重叠,并且不会重复使用任何先前的比较。如果文本字符串只包含不同的字符,我们将从模式的第一个字符开始比较。考虑以下示例:模式abcde包含所有不同的字符,因此它将被移动到模式中的字符数,并且我们将开始比较模式的第一个字符与文本字符串的下一个字符,如下图所示:

让我们考虑另一个示例,以更好地理解prefix函数如何为模式(P)abcabbcab工作,如下图所示:

在上图中,我们从索引1开始计算prefix函数的值。如果字符没有重复,我们将值赋为0。在上面的例子中,我们为索引位置13prefix函数分配了0。接下来,在索引位置4,我们可以看到有一个字符a,它是模式中第一个字符的重复,所以我们在这里分配值1,如下所示:

接下来,我们看索引位置5处的下一个字符。它有最长的后缀模式ab,因此它的值为2,如下图所示:

同样,我们看下一个索引位置6。这里,字符是b。这个字符在模式中没有最长的后缀,所以它的值是0。接下来,我们在索引位置7处赋值0。然后,我们看索引位置8,并将值1分配给它,因为它有长度为1的最长后缀。最后,在索引位置9,我们有长度为2的最长后缀:

prefix函数的值显示了如果不匹配,字符串的开头有多少可以重复使用。例如,如果在索引位置5处比较失败,prefix函数的值为2,这意味着不需要比较前两个字符。

理解 KMP 算法

KMP 模式匹配算法使用具有模式本身重叠的模式,以避免不必要的比较。KMP 算法的主要思想是根据模式中的重叠来检测模式应该移动多少。算法的工作原理如下:

  1. 首先,我们为给定的模式预先计算prefix函数,并初始化一个表示匹配字符数的计数器 q。

  2. 我们从比较模式的第一个字符与文本字符串的第一个字符开始,如果匹配,则递增模式的计数器q和文本字符串的计数器,并比较下一个字符。

  3. 如果不匹配,我们将预先计算的prefix函数的值赋给q的索引值。

  4. 我们继续在文本字符串中搜索模式,直到达到文本的末尾,即如果我们找不到任何匹配。如果模式中的所有字符都在文本字符串中匹配,我们返回模式在文本中匹配的位置,并继续搜索另一个匹配。

让我们考虑以下示例来理解这一点:

给定模式的prefix函数如下:

现在,我们开始比较模式的第一个字符与文本字符串的第一个字符,并继续比较,直到找到匹配。例如,在下图中,我们从比较文本字符串的字符a和模式的字符a开始。由于匹配,我们继续比较,直到找到不匹配或者我们已经比较了整个模式。在这里,我们在索引位置6找到了不匹配,所以现在我们必须移动模式。

我们使用prefix函数的帮助来找到模式应该移动的次数。这是因为在不匹配的位置(即prefix_function(6)2)上,prefix函数的值为2,所以我们从模式的索引位置2开始比较模式。由于 KMP 算法的效率,我们不需要比较索引位置1的字符,我们比较模式的字符c和文本的字符b。由于它们不匹配,我们将模式向右移动1个位置,如下所示:

接下来,我们比较的字符是ba——它们不匹配,所以我们将模式向右移动1个位置。接下来,我们比较模式和文本字符串,并在文本的索引位置 10 处找到字符bc之间的不匹配。在这里,我们使用预先计算的“前缀”函数来移动模式,因为prefix_function(4)2,所以我们将其移动到索引位置2,如下图所示:

之后,由于字符bc不匹配,我们将模式向右移动 1 个位置。接下来,我们比较文本中索引为11的字符,直到找到不匹配为止。我们发现字符bc不匹配,如下图所示。由于prefix_function(2)0,我们将模式移动到模式的索引0。我们重复相同的过程,直到达到字符串的末尾。我们在文本字符串的索引位置13找到了模式的匹配,如下所示:

KMP 算法有两个阶段,预处理阶段,这是我们计算“前缀”函数的地方,它的空间和时间复杂度为O(m),然后,在第二阶段,即搜索阶段,KMP 算法的时间复杂度为O(n)

现在,我们将讨论如何使用 Python 实现 KMP 算法。

实现 KMP 算法

这里解释了 KMP 算法的 Python 实现。我们首先为给定的模式实现“前缀”函数。为此,首先我们使用len()函数计算模式的长度,然后初始化一个列表来存储“前缀”函数计算出的值。

接下来,我们开始执行循环,从 2 到模式的长度。然后,我们有一个嵌套循环,直到我们处理完整个模式为止。变量k初始化为0,这是模式的第一个元素的“前缀”函数。如果模式的第k个元素等于第q个元素,那么我们将k的值增加1

k 的值是由“前缀”函数计算得出的值,因此我们将其分配给模式的q的索引位置。最后,我们返回具有模式每个字符的计算值的“前缀”函数列表。以下是“前缀”函数的代码:

def pfun(pattern): # function to generate prefix function for the given pattern
    n = len(pattern) # length of the pattern
    prefix_fun = [0]*(n) # initialize all elements of the list to 0
    k = 0
    for q in range(2,n):
         while k>0 and pattern[k+1] != pattern[q]:
            k = prefix_fun[k]
         if pattern[k+1] == pattern[q]: # If the kth element of the pattern is equal to the qth element
            k += 1            # update k accordingly
         prefix_fun[q] = k
    return prefix_fun         # return the prefix function 

一旦我们创建了“前缀”函数,我们就实现了主要的 KMP 匹配算法。我们首先计算文本字符串和模式的长度,它们分别存储在变量mn中。以下代码详细显示了这一点:


def KMP_Matcher(text,pattern): 
    m = len(text)
    n = len(pattern)
    flag = False
    text = '-' + text       # append dummy character to make it 1-based indexing
    pattern = '-' + pattern       # append dummy character to the pattern also
    prefix_fun = pfun(pattern) # generate prefix function for the pattern
    q = 0
    for i in range(1,m+1):
        while q>0 and pattern[q+1] != text[i]: 
        # while pattern and text are not equal, decrement the value of q if it is > 0
            q = prefix_fun[q]
        if pattern[q+1] == text[i]: # if pattern and text are equal, update value of q
            q += 1
        if q == n: # if q is equal to the length of the pattern, it means that the pattern has been found.
            print("Pattern occours with shift",i-n) # print the index,
where first match occours.
            flag = True
            q = prefix_fun[q]
    if not flag:
            print('\nNo match found')

KMP_Matcher('aabaacaadaabaaba','abaac')         #function call

Boyer-Moore 算法

正如我们已经讨论过的,字符串模式匹配算法的主要目标是通过避免不必要的比较来尽可能地跳过比较。

Boyer-Moore 模式匹配算法是另一种这样的算法(除了 KMP 算法),它通过使用一些方法跳过一些比较来进一步提高模式匹配的性能。您需要理解以下概念才能使用 Boyer-Moore 算法:

  1. 在这个算法中,我们将模式从左向右移动,类似于 KMP 算法

  2. 我们从右向左比较模式和文本字符串的字符,这与 KMP 算法相反

  3. 该算法通过使用好后缀和坏字符移位的概念来跳过不必要的比较

理解 Boyer-Moore 算法

Boyer-Moore 算法从右到左比较文本上的模式。它通过预处理模式来使用模式中各种可能的对齐信息。这个算法的主要思想是我们将模式的末尾字符与文本进行比较。如果它们不匹配,那么模式可以继续移动。如果末尾的字符不匹配,就没有必要进行进一步的比较。此外,在这个算法中,我们还可以看到模式的哪一部分已经匹配(与匹配的后缀),因此我们利用这个信息,通过跳过任何不必要的比较来对齐文本和模式。

当我们发现不匹配时,Boyer-Moore 算法有两个启发式来确定模式的最大可能移位:

  • 坏字符启发式

  • 好后缀启发式

在不匹配时,每个启发式都建议可能的移位,而 Boyer-Moore 算法通过考虑由于坏字符和好后缀启发式可能的最大移位来移动模式。坏字符和好后缀启发式的详细信息将在以下子节中通过示例详细解释。

坏字符启发式

Boyer-Moore 算法将模式和文本字符串从右到左进行比较。它使用坏字符启发式来移动模式。根据坏字符移位的概念,如果模式的字符与文本不匹配,那么我们检查文本的不匹配字符是否出现在模式中。如果这个不匹配的字符(也称为坏字符)不出现在模式中,那么模式将被移动到这个字符的旁边,如果该字符在模式中的某处出现,我们将模式移动到与文本字符串的坏字符对齐的位置。

让我们通过一个例子来理解这个概念。考虑一个文本字符串(T)和模式={acacac}。我们从右到左比较字符,即文本字符串的字符b和模式的字符c。它们不匹配,所以我们在模式中寻找文本字符串的不匹配字符b。由于它不在模式中出现,我们将模式移动到不匹配的字符旁边,如下图所示:

让我们看另一个例子。我们从右到左比较文本字符串和模式的字符,对于文本的字符d,我们得到了不匹配。在这里,后缀ac是匹配的,但是字符dc不匹配,不匹配的字符d不在模式中出现。因此,我们将模式移动到不匹配的字符旁边,如下图所示:

让我们考虑坏字符启发式的另一个例子。在这里,后缀ac是匹配的,但是接下来的字符ac不匹配,因此我们在模式中搜索不匹配的字符a的出现。由于它在模式中出现了两次,我们有两个选项来对齐不匹配的字符,如下图所示。在这种情况下,我们有多个选项来移动模式,我们移动模式的最小次数以避免任何可能的匹配。(换句话说,它将是模式中该字符的最右出现位置。)如果模式中只有一个不匹配的字符的出现,我们可以轻松地移动模式,使不匹配的字符对齐。

在以下示例中,我们更喜欢选项1来移动模式:

好后缀启发式

坏字符启发式并不总是提供良好的建议。Boyer-Moore 算法还使用好后缀启发式来将模式移位到文本字符串上,以找出匹配模式的位置。

好后缀启发式是基于匹配的后缀。在这里,我们将模式向右移动,以使匹配的后缀子模式与模式中另一个相同后缀的出现对齐。它的工作原理是:我们从右到左开始比较模式和文本字符串。如果我们找到任何不匹配,那么我们检查到目前为止已经匹配的后缀的出现。这被称为好后缀。我们以这样的方式移动模式,以便将好后缀的另一个出现对齐到文本上。好后缀启发式主要有两种情况:

  1. 匹配的后缀在模式中有一个或多个出现。

  2. 匹配后缀的某部分存在于模式的开头(这意味着匹配后缀的后缀存在于模式的前缀中)。

让我们通过以下示例了解这些情况。假设我们有一个模式acabac。我们对字符ab进行不匹配,但此时,我们已经匹配了后缀,即ac。现在,我们在模式中搜索好后缀ac的另一个出现,并通过对齐后缀来移动模式,如下所示:

让我们考虑另一个例子,我们有两个选项来对齐模式的移位,以便获得两个好后缀字符串。在这里,我们将选择1来通过考虑具有最小移位的选项来对齐好后缀,如下所示:

让我们再看一个例子。在这里,我们得到了aac的后缀匹配,但对于字符ba,我们得到了不匹配。我们搜索好后缀aac,但在模式中找不到另一个出现。但是,我们发现模式开头的前缀ac与整个后缀不匹配,但与匹配后缀aac的后缀ac匹配。在这种情况下,我们通过将模式与后缀对齐来移动模式,如下所示:

另一个好后缀启发式的案例如下。在这种情况下,我们匹配后缀aac,但在字符ba处不匹配。我们尝试在模式中搜索匹配的后缀,但在模式中没有后缀的出现,所以在这种情况下,我们将模式移位到匹配的后缀之后,如下所示:

我们通过坏字符启发式和好后缀启发式给出的更长距离来移动模式。

Boyer-Moore 算法在模式的预处理中需要O(m)的时间,进一步搜索需要O(mn)的时间复杂度。

实现 Boyer-Moore 算法

让我们了解 Boyer-Moore 算法的实现。最初,我们有文本字符串和模式。在初始化变量之后,我们开始使用 while 循环,该循环从模式的最后一个字符开始与文本的相应字符进行比较。

然后,通过使用嵌套循环从模式的最后一个索引到模式的第一个字符,从右到左比较字符。这使用range(len(pattern)-1, -1, -1)

外部 while 循环跟踪文本字符串中的索引,而内部 for 循环跟踪模式中的索引位置。

接下来,我们开始使用pattern[j] != text[i+j]来比较字符。如果它们不匹配,我们将使标志变量False,表示存在不匹配。

现在,我们通过使用条件j == len(pattern)-1来检查好后缀是否存在。如果这个条件为真,意味着没有可能的好后缀,所以我们检查坏字符启发式,即如果模式中存在或不存在不匹配的字符,使用条件text[i+j] in pattern[0:j],如果条件为真,则意味着坏字符存在于模式中。在这种情况下,我们使用i=i+j-pattern[0:j].rfind(text[i+j])将模式移动到与模式中此字符的其他出现对齐。这里,(i+j)是坏字符的索引。

如果坏字符不在模式中(它不在else部分),我们使用索引i=i+j+1将整个模式移动到不匹配的字符旁边。

接下来,我们进入条件的else部分,检查好后缀。当我们发现不匹配时,我们进一步测试,看看我们的模式前缀中是否有任何好后缀的子部分。我们通过使用以下条件来做到这一点:

 text[i+j+k:i+len(pattern)] not in pattern[0:len(pattern)-1]

此外,我们检查好后缀的长度是否为1。如果好后缀的长度为1,我们不考虑这个移动。如果好后缀大于1,我们通过好后缀启发式找出移动次数,并将其存储在gsshift变量中。这是将模式移动到与文本的好后缀匹配的位置的指令。此外,我们计算由于坏字符启发式可能的移动次数,并将其存储在bcshift变量中。当坏字符存在于模式中时,可能的移动次数是i+j-pattern[0:j].rfind(text[i+j]),当坏字符不在模式中时,可能的移动次数将是i+j+1

接下来,我们通过使用坏字符和好后缀启发式的最大移动次数将模式移动到文本字符串上。最后,我们检查标志变量是否为True。如果为True,这意味着找到了模式,并且匹配的索引已存储在matched_indexes变量中。

Boyer-Moore 算法的完整实现如下所示:

text= "acbaacacababacacac"
pattern = "acacac"

matched_indexes = []
i=0
flag = True
while i<=len(text)-len(pattern):
    for j in range(len(pattern)-1, -1, -1): #reverse searching
        if pattern[j] != text[i+j]:
            flag = False #indicates there is a mismatch
            if j == len(pattern)-1: #if good-suffix is not present, we test bad character 
                if text[i+j] in pattern[0:j]:
                    i=i+j-pattern[0:j].rfind(text[i+j]) #i+j is index of bad character, this line is used for jumping pattern to match bad character of text with same character in pattern
                else:
                    i=i+j+1 #if bad character is not present, jump pattern next to it
            else:
                k=1
                while text[i+j+k:i+len(pattern)] not in pattern[0:len(pattern)-1]: #used for finding sub part of a good-suffix
                    k=k+1
                if len(text[i+j+k:i+len(pattern)]) != 1: #good-suffix should not be of one character
                    gsshift=i+j+k-pattern[0:len(pattern)-1].rfind(text[i+j+k:i+len(pattern)]) #jumps pattern to a position where good-suffix of pattern matches with good-suffix of text
                else:
                    #gsshift=i+len(pattern)
                    gsshift=0 #when good-suffix heuristic is not applicable, we prefer bad character heuristic
                if text[i+j] in pattern[0:j]:
                    bcshift=i+j-pattern[0:j].rfind(text[i+j]) #i+j is index of bad character, this line is used for jumping pattern to match bad character of text with same character in pattern
                else:
                    bcshift=i+j+1
                i=max((bcshift, gsshift))
            break
    if flag: #if pattern is found then normal iteration
        matched_indexes.append(i)
        i = i+1
    else: #again set flag to True so new string in text can be examined
        flag = True

print ("Pattern found at", matched_indexes)

总结

在本章中,我们已经讨论了在实时场景中具有广泛应用的最流行和重要的字符串处理算法。我们从查看与字符串相关的基本概念和定义开始了本章。接下来,我们详细描述了用于模式匹配问题的暴力、Rabin-Karp、KMP 和 Boyer-Moore 模式匹配算法。我们已经看到,暴力模式匹配算法非常慢,因为它逐个比较模式和文本字符串的字符。

在模式匹配算法中,我们试图找到跳过不必要比较的方法,并尽快将模式移动到文本上,以快速找到匹配模式的位置。KMP 算法通过查看模式本身中的重叠子字符串来找出不必要的比较,以避免不重要的比较。此外,我们讨论了 Boyer-Moore 算法,在文本和模式很长时非常高效。这是实践中使用的最流行的模式匹配算法。

在下一章中,我们将更详细地讨论数据结构设计策略和技术。

第十三章:设计技术和策略

在本章中,我们退一步,关注计算机算法设计中更广泛的主题。随着编程经验的增长,某些模式开始变得明显。算法的世界包含了大量的技术和设计原则。掌握这些技术是解决该领域更难问题所必需的。

在本章中,我们将讨论不同类型算法的分类方式。将描述和说明设计技术。我们还将进一步讨论算法分析。最后,我们将提供一些非常重要算法的详细实现。

本章将涵盖以下主题:

  • 算法的分类

  • 各种算法设计方法

  • 各种重要算法的实现和解释

技术要求

本章使用的源代码可在以下 GitHub 链接中找到:

github.com/PacktPublishing/Hands-On-Data-Structures-and-Algorithms-with-Python-3.7-Second-Edition/tree/master/Chapter13

算法的分类

基于算法设计的目标,有许多分类方案。在之前的章节中,我们实现了各种算法。要问的问题是:这些算法是否具有相同的形式或相似之处?如果答案是肯定的,那么问:作为比较基础使用的相似之处和特征是什么?如果答案是否定的,那么这些算法能否被分成类别?

这些是我们将在随后的小节中讨论的问题。这里我们介绍了分类算法的主要方法。

按实现分类

将一系列步骤或流程翻译成工作算法时,可能采用多种形式。算法的核心可能使用以下一个或多个资产。

递归

递归算法是指调用自身以重复执行代码直到满足某个条件的算法。有些问题通过递归实现它们的解决方案更容易表达。一个经典的例子是汉诺塔。

简单来说,迭代函数是循环执行代码的一部分,而递归函数是调用自身来重复执行代码的函数。另一方面,迭代算法使用一系列步骤或重复结构来制定解决方案;它迭代执行代码的一部分。

这种重复结构可以是一个简单的while循环,或者任何其他类型的循环。迭代解决方案也比递归实现更容易想到。

逻辑

算法的一种实现是将其表达为受控逻辑推导。这个逻辑组件由将在计算中使用的公理组成。控制组件确定了推导应用到公理的方式。这表达为形式 algorithm = logic + control。这构成了逻辑编程范式的基础。

逻辑组件决定了算法的含义。控制组件只影响其效率。在不修改逻辑的情况下,可以通过改进控制组件来提高效率。

串行或并行算法

大多数计算机的 RAM 模型允许假设计算是一次执行一条指令的。

串行算法,也称为顺序算法,是按顺序执行的算法。执行从开始到结束进行,没有其他执行过程。

为了能够同时处理多条指令,需要不同的模型或计算技术。并行算法可以同时执行多个操作。在 PRAM 模型中,有共享全局内存的串行处理器。处理器还可以并行执行各种算术和逻辑操作。这使得可以同时执行多条指令。

并行/分布式算法将问题分解成子问题,分配给处理器来收集结果。一些排序算法可以有效地并行化,而迭代算法通常是可并行化的。

确定性与非确定性算法

确定性算法每次以相同的输入运行时都会产生相同的输出。有一些问题的解决方案设计非常复杂,以至于以确定性的方式表达它们的解决方案可能是一个挑战。

非确定性算法可以改变执行顺序或某些内部子过程,导致每次运行算法时最终结果都会发生变化。

因此,每次运行非确定性算法时,算法的输出都会不同。例如,使用概率值的算法将根据生成的随机数的值,在连续执行时产生不同的输出。

按复杂度分类

确定算法的复杂度是为了估计在计算或程序执行期间需要多少空间(内存)和时间。通常,通过它们的复杂度来比较两个算法的性能。较低复杂度的算法,即执行给定任务所需的空间和时间较少的算法,更受青睐。

第三章《算法设计原理》更全面地介绍了复杂性。我们将在这里总结我们所学到的内容。

复杂度曲线

让我们考虑一个规模为 n 的问题。为了确定算法的时间复杂度,我们用 T(n)表示。该值可能属于 O(1)、O(log n)、O(n)、O(n log(n))、O(n²)、O(n³)或 O(2^n)。根据算法执行的步骤,时间复杂度可能会受到影响。符号 O(n)捕捉了算法的增长率。

让我们现在来考虑一个实际的场景,来确定哪种算法更适合解决给定的问题。我们如何得出冒泡排序算法比快速排序算法慢的结论?或者,一般来说,我们如何衡量一个算法相对于另一个算法的效率?

好吧,我们可以比较任意数量的算法的大 O 来确定它们的效率。这种方法给我们提供了一个时间度量或增长率,它描述了算法在 n 变大时的行为。

这是一个图表,显示了算法性能可能属于的不同运行时间:

按照从最好到最差的顺序,运行时间的列表为 O(1)、O(log n)、O(n)、O(n log n)、O(n²)、O(n³)和 O(2^n)。因此,如果一个算法的时间复杂度为 O(1),而另一个算法的复杂度为 O(log n),应该选择第一个算法。

按设计分类

在本节中,我们根据它们的设计提出了算法的分类。

一个给定的问题可能有多种解决方案。当分析这些解决方案时,可以观察到每个解决方案都遵循某种模式或技术。我们可以根据它们解决问题的方式对算法进行分类,如下面的小节所示。

分而治之

这种问题解决方法正如其名称所示。为了解决(征服)某个问题,算法将其分解为可以轻松解决的子问题。此外,这些子问题的解决方案被组合在一起,以便最终解决方案是原始问题的解决方案。

问题被分解为更小的子问题的方式大多是通过递归完成的。我们将在随后的小节中详细讨论这种技术。使用这种技术的一些算法包括归并排序、快速排序和二分查找。

动态规划

这种技术与分而治之类似,即将问题分解为更小的问题。然而,在分而治之中,必须先解决每个子问题,然后才能用其结果来解决更大的问题。

相比之下,动态规划不会计算已经遇到的子问题的解决方案。相反,它使用一种记忆技术来避免重新计算。

动态规划问题具有两个特征——最优子结构重叠子问题。我们将在下一节进一步讨论这一点。

贪婪算法

确定某个问题的最佳解决方案可能会非常困难。为了克服这一点,我们采用一种方法,从多个可用选项或选择中选择最有前途的选择。

使用贪婪算法,指导规则是始终选择产生最有利的结果的选项,并继续这样做,希望达到完美的解决方案。这种技术旨在通过一系列局部最优选择找到全局最优的最终解决方案。局部最优选择似乎导致解决方案。

技术实现

让我们深入讨论一些我们讨论过的理论编程技术的实现。我们从动态规划开始。

使用动态规划进行实现

正如我们已经描述的,在这种方法中,我们将给定的问题分解为更小的子问题。在找到解决方案时,要注意不要重新计算任何先前遇到的子问题。

这听起来有点像递归,但这里有些不同。一个问题可能适合使用动态规划来解决,但不一定需要形成递归调用的形式。

使问题成为动态规划的理想候选者的一个特性是它具有重叠的子问题集

一旦我们意识到在计算过程中子问题的形式已经重复,我们就不需要再次计算它。相反,我们返回先前遇到的子问题的预先计算结果。

为了确保我们永远不必重新评估子问题,我们需要一种有效的方法来存储每个子问题的结果。以下两种技术是 readily available。

记忆化

这种技术从初始问题集开始,将其分解为小的子问题。在确定了子程序的解决方案之后,我们将结果存储到该特定子问题中。将来,当遇到这个子问题时,我们只返回其预先计算的结果。

制表

在制表中,我们填充一个表格,其中包含子问题的解决方案,然后将它们组合起来解决更大的问题。

斐波那契数列

让我们考虑一个例子来理解动态规划的工作原理。我们使用斐波那契数列来说明记忆化和制表技术。

斐波那契数列可以使用递推关系来演示。递推关系是用来定义数学函数或序列的递归函数。例如,以下递推关系定义了斐波那契数列[1, 1, 2, 3, 5, 8 ...]:

func(1) = 1
func(0) = 1 
func(n) = func(n-1) + func(n-2)

请注意,斐波那契数列可以通过将n的值放入序列[1, 2, 3, 4, ...]来生成。

记忆化技术

让我们生成斐波那契数列的前五项:

    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^(th)项的策略布局如下:

对树状图的仔细观察显示了一些有趣的模式。对fib(1)的调用发生了两次。对fib(2)的调用发生了三次。此外,对fib(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]

要创建一个包含 1,000 个元素的列表,我们执行以下操作,并将其传递给dyna_fib函数的 lookup 参数:

    map_set = [None]*(1000)

这个列表将存储对dyna_fib()函数的各种调用的计算值:

    if n <= 2: 
        lookup[n] = 1 

对于dyna_fib()函数的任何小于或等于 2 的n的调用都将返回 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*^(th)项时,我们可以看到显着的改进。这个实现比我们最初的实现运行得快得多。将值 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 和 1。这代表fib(1)fib(2)的返回值。要计算大于 2 的值的fib()函数的值,我们只需调用for循环,将results[i-1] + results[i-2]的和附加到结果列表中。

使用分治法实现

这种编程方法强调将问题分解为与原始问题相同类型或形式的较小子问题的需要。这些子问题被解决并组合以获得原始问题的解决方案。

以下三个步骤与这种编程相关。

划分

划分意味着分解实体或问题。在这里,我们设计手段将原始问题分解为子问题。我们可以通过迭代或递归调用来实现这一点。

征服

无法无限地继续将问题分解为子问题。在某个时候,最小的不可分割问题将返回一个解决方案。一旦这种情况发生,我们可以扭转我们的思维过程,并说如果我们知道最小子问题的解决方案,我们就可以获得原始问题的最终解决方案。

合并

为了得到最终解决方案,我们需要结合较小问题的解决方案来解决更大的问题。

还有其他变体的分而治之算法,例如合并和组合,征服和解决。许多算法使用分而治之原则,例如归并排序、快速排序和 Strassen 矩阵乘法。

我们现在将描述归并排序算法的实现,就像我们在第三章“算法设计原理”中看到的那样。

归并排序

归并排序算法基于分而治之的原则。给定一列未排序的元素,我们将列表分成两个近似的部分。我们继续递归地将列表分成两半。

经过一段时间,由于递归调用而创建的子列表将只包含一个元素。在那时,我们开始在征服或合并步骤中合并解决方案:

    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传递了它们的值后,我们调用merge函数,该函数将合并或组合存储在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 

merge函数接受我们要合并的两个列表first_sublistsecond_sublistij变量被初始化为 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_listij索引递增以反映我们在合并步骤中的位置。当任一子列表为空时,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
步骤 0 [4 6 8] [5 7 11 40] []
步骤 1 [ 6 8] [5 7 11 40] [4]
步骤 2 [ 6 8] [ 7 11 40] [4 5]
步骤 3 [ 8] [ 7 11 40] [4 5 6]
步骤 4 [ 8] [ 11 40] [4 5 6 7]
步骤 5 [ ] [ 11 40] [4 5 6 7 8]
步骤 6 [] [ ] [4 5 6 7 8 11 40]

注意粗体文本代表循环中当前项的引用,first_sublist(使用i索引)和second_sublist(使用j索引)。

在执行的这一点上,合并函数中的第三个while循环开始将 11 和 40 移入merged_list。返回的merged_list将包含完全排序的列表。

请注意,合并算法需要O(n)的时间,而合并排序算法的运行时间复杂度为O(log n) T(n) = O(n)*O(log n) = O(n log n)

使用贪婪算法的实现

正如我们之前讨论的,贪婪算法做出决策以产生最佳的局部解决方案,从而提供最佳解决方案。这种技术的希望是,通过在每一步做出最佳选择,总路径将导致整体最优解决方案或结束。

贪婪算法的例子包括用于查找最小生成树的Prim 算法背包问题旅行推销员问题

硬币计数问题

为了演示贪婪技术的工作原理,让我们看一个例子。考虑一个问题,我们希望计算使给定金额 A 所需的最小硬币数量,其中我们有给定硬币值的无限供应。

例如,在某个任意国家,我们有以下硬币面额:1、5 和 8 GHC。给定一个金额(例如 12 GHC),我们想要找到提供这个金额所需的最少硬币数量。

使用面额{a[1],a[2],a[3]...a[n]}来提供给定金额 A 的最小硬币数量的算法如下:

  1. 我们对面额列表{a[1], a[2], a[3] ...a[n]}进行排序。

  2. 我们得到小于 A 的{a[1], a[2], a[3]...a[n]}中的最大面额。

  3. 我们通过将 A 除以最大面额来获得商。

  4. 我们通过使用(A % 最大面额)来获得剩余金额 A。

  5. 如果 A 的值变为 0,则返回结果。

  6. 否则,如果 A 的值大于 0,我们将最大面额和商变量附加到结果变量中。并重复步骤 2-5。

使用贪婪方法,我们首先选择可用面额中的最大值——8——来除以 12。余数 4 既不能被 8 整除,也不能被比 8 更小的面额 5 整除。所以,我们尝试 1 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中。

然而,有一些可能的情况下这种算法可能会失败。例如,当传入 12 GHC 时,我们的算法返回了一个 8 GHC 和四个 1 GHC 硬币。然而,这个输出并不是最优解。最佳解是使用两个 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 到点 B 的最有效路径时,这对于地图和路径规划具有重要应用。

迪杰斯特拉算法是解决这个问题的一种非常流行的方法。该算法用于在图中找到从源到所有其他节点或顶点的最短距离。在这里,我们解释了如何使用贪婪方法来解决这个问题。

迪杰斯特拉算法适用于加权有向和无向图。该算法产生了从加权图中给定源节点 A 到最短路径的列表的输出。算法的工作原理如下:

  1. 最初,将所有节点标记为未访问,并将它们从给定源节点的距离设置为无穷大(源节点设置为零)。

  2. 将源节点设置为当前节点。

  3. 对于当前节点,查找所有未访问的相邻节点;计算从源节点通过当前节点到该节点的距离。将新计算的距离与当前分配的距离进行比较,如果更小,则将其设置为新值。

  4. 一旦我们考虑了当前节点的所有未访问的相邻节点,我们将其标记为已访问。

  5. 接下来,考虑下一个未访问的节点,该节点距离源节点最近。重复步骤 2 到 4。

  6. 当未访问节点的列表为空时,我们停止,这意味着我们已经考虑了所有未访问的节点。

考虑以下带有六个节点[A,B,C,D,E,F]的加权图的示例,以了解迪杰斯特拉算法的工作原理:

通过手动检查,最初看起来节点 A 和节点 D 之间的最短路径似乎是距离为 9 的直线。然而,最短路径意味着最低总距离,即使这包括几个部分。相比之下,从节点 A 到节点 E,然后到节点 F,最后到节点 D 的旅行将产生总距离为 7,这使得它成为更短的路径。

我们将使用单源最短路径算法。它将确定从原点(在本例中为 A)到图中任何其他节点的最短路径。

在第八章中,图和其他算法,我们讨论了如何用邻接列表表示图。我们使用邻接列表以及每条边上的权重/成本/距离来表示图,如下面的 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。

在算法的第一步中,我们开始检查节点 A 的相邻节点。要找到从节点 A 到节点 B 的最短距离,我们需要找到从起始节点到节点 B 的前一个节点的距离,这恰好是节点 A,并将其添加到从节点 A 到节点 B 的距离。我们对 A 的其他相邻节点(B、E 和 D)也是这样做的。这在下图中显示:

我们将相邻节点 B 作为其从节点 A 的距离最小;从起始节点(A)到前一个节点(None)的距离为 0,从前一个节点到当前节点(B)的距离为 5。这个和与节点 B 的最短距离列中的数据进行比较。由于 5 小于无穷大(∞),我们用两者中较小的 5 替换∞。

每当一个节点的最短距离被较小的值替换时,我们需要为当前节点的所有相邻节点更新前一个节点列。之后,我们将节点 A 标记为已访问:

在第一步结束时,我们的表如下所示:

节点 源的最短距离 前一个节点
A* 0 None
B 5 A
C None
D 9 A
E 2 A
F None

此时,节点 A 被视为已访问。因此,我们将节点 A 添加到已访问节点的列表中。在表中,我们通过将文本加粗并在其后附加星号来显示节点 A 已被访问。

在第二步中,我们使用我们的表找到最短距离的节点作为指南。节点 E 的值为 2,具有最短距离。这是我们从节点 E 的表中可以推断出来的。要到达节点 E,我们必须访问节点 A 并覆盖距离 2。从节点 A,我们覆盖 0 的距离到达起始节点,即节点 A 本身。

节点 E 的相邻节点是 A 和 F。但是节点 A 已经被访问过,所以我们只考虑节点 F。要找到到节点 F 的最短路径或距离,我们必须找到从起始节点到节点 E 的距离,并将其添加到节点 E 和 F 之间的距离。我们可以通过查看节点 E 的最短距离列来找到从起始节点到节点 E 的距离,其值为 2。从节点 E 到 F 的距离可以从我们在本节早些时候开发的 Python 中的邻接列表中获得。

这个距离是 3。这两个加起来是 5,小于无穷大。记住我们正在检查相邻的节点 F。由于节点 E 没有更多相邻的节点,我们将节点 E 标记为已访问。我们更新的表和图将具有以下值:

节点 源的最短距离 前一个节点
A* 0 None
B 5 A
C None
D 9 A
E* 2 A
F 5 E

访问节点 E 后,我们在表的最短距离列中找到最小值,即节点 B 和 F 的值为 5。让我们选择 B 而不是 F,纯粹基于字母顺序(我们也可以选择 F)。

节点 B 的相邻节点是 A 和 C,但是节点 A 已经被访问。根据我们之前建立的规则,从 A 到 C 的最短距离是 7。我们得到这个数字是因为从起始节点到节点 B 的距离是 5,而从节点 B 到 C 的距离是 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的上一个节点列中用F替换A

节点F现在被标记为已访问。这是更新后的表格和到目前为止的图:

Node Shortest distance from source Previous node
A* 0 None
B* 5 A
C 7 B
D 7 F
E* 2 A
F* 5 E

现在,只剩下两个未访问的节点,CD,都具有距离成本为7。按字母顺序,我们选择检查C,因为这两个节点都与起始节点A的最短距离相同:

然而,所有与C相邻的节点都已经被访问。因此,我们除了将节点C标记为已访问外,没有其他事情要做。此时表格保持不变:

最后,我们取节点D,发现它的所有相邻节点也都已经被访问。我们只将其标记为已访问。表格保持不变:

Node Shortest distance from source Previous node
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,然后从E到节点A,这实际上是最短路径。

为了实现 Dijkstra 算法以找到最短路径,我们开始编写程序,通过表示能够跟踪图中变化的表格来找到最短距离。对于我们使用的图表,这是表格的字典表示:

    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函数返回我们表中索引 0 处存储的值。在该索引处,我们始终存储从起始节点到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。在表中,我们将previous_node的值存储在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-Complete 和 NP-Hard 复杂度类。

P 与 NP

计算机的出现加快了某些任务的执行速度。总的来说,计算机擅长完善计算的艺术和解决可以归结为一组数学计算的问题。

然而,这种说法并非完全正确。有一些类别的问题对计算机来说需要大量时间来做出合理的猜测,更不用说找到正确的解决方案了。

在计算机科学中,计算机可以使用逻辑步骤的逐步过程在多项式时间内解决的问题类别被称为 P 类型,其中 P 代表多项式。这些问题相对容易解决。

然后还有另一类被认为很难解决的问题。术语难问题用于指代在寻找解决方案时问题难度增加的方式。然而,尽管这些问题的难度增长率很高,但可以确定一个提议的解决方案是否在多项式时间内解决问题。这些被称为 NP 类型问题。这里的 NP 代表非确定性多项式时间。

现在百万美元的问题是,P = NP吗?

P* = NP*的证明是克莱数学研究所的百万美元问题之一,为正确解决方案提供了百万美元的奖金。

旅行推销员问题是 NP 类型问题的一个例子。问题陈述如下:在一个国家中给定n个城市,找到它们之间的最短路线,从而使旅行成本有效。

当城市数量较小时,这个问题可以在合理的时间内解决。然而,当城市数量超过两位数时,计算机所需的时间就会非常长。

许多计算机和网络安全系统都基于 RSA 加密算法。该算法的强度基于它使用的整数因子问题,这是一个 NP 类型问题。

找到由许多位数组成的质数的质因数是非常困难的。当两个大质数相乘时,得到一个大的非质数。这个数的因数分解是许多加密算法借用其强度的地方。

所有 P 类型问题都是NP问题的子集。这意味着任何可以在多项式时间内解决的问题也可以在多项式时间内验证:

但是P = NP调查了可以在多项式时间内验证的问题是否也可以在多项式时间内解决。特别是,如果它们相等,这意味着可以在不需要实际尝试所有可能的解决方案的情况下解决通过尝试多个可能解决方案来解决的问题,从而不可避免地产生某种快捷证明。

当最终发现证明时,它肯定会对密码学、博弈论、数学和许多其他领域产生严重影响。

NP-Hard

如果 NP 中的所有其他问题都可以在多项式时间内可归约或映射到它,那么问题就是 NP-Hard。它至少和 NP 中最难的问题一样难。

NP-Complete

NP-Complete问题是最困难的问题。如果一个问题是NP-Hard问题,同时也在NP类中找到,那么它被认为是NP-Complete问题。

在这里,我们展示了各种复杂性群的维恩图:

数据中的知识发现

为了从给定数据中提取有用信息,我们首先收集要用于学习模式的原始数据。接下来,我们应用数据预处理技术来去除数据中的噪音。此外,我们从数据中提取重要特征,这些特征代表了数据,用于开发模型。特征提取是机器学习算法有效工作的最关键步骤。一个好的特征必须对机器学习算法具有信息量和区分度。特征选择技术用于去除不相关、冗余和嘈杂的特征。此外,突出的特征被输入到机器学习算法中,以学习数据中的模式。最后,我们应用评估措施来评判开发模型的性能,并使用可视化技术来可视化结果和数据。以下是步骤:

  1. 数据收集

  2. 数据预处理

  3. 特征提取

  4. 特征选择

  5. 机器学习

  6. 评估和可视化

总结

在本章中,我们详细讨论了算法设计技术,在计算机科学领域非常重要。在没有太多数学严谨的情况下,我们还讨论了一些算法分类的主要类别。

该领域中的其他设计技术,如分治、动态规划和贪婪算法,也被涵盖,以及重要样本算法的实现。最后,我们对复杂度类进行了简要讨论。我们看到,如果 P = NP 的证明被发现,它肯定会在许多领域产生重大影响。

在下一章中,我们将讨论一些真实世界的应用、工具和机器学习应用的基础知识。

第十四章:实现、应用和工具

学习算法而没有任何现实生活的应用仍然是一种纯粹的学术追求。在本章中,我们将探讨正在塑造我们世界的数据结构和算法。

这个时代的一个黄金机会是数据的丰富。电子邮件、电话号码、文本文档和图像包含大量的数据。在这些数据中,有着使数据更加重要的有价值信息。但是要从原始数据中提取这些信息,我们必须使用专门从事这项任务的数据结构、过程和算法。

机器学习使用大量算法来分析和预测某些变量的发生。仅基于纯数字的数据分析仍然使得许多潜在信息埋藏在原始数据中。因此,通过可视化呈现数据,使人们能够理解并获得有价值的见解。

在本章结束时,您应该能够做到以下几点:

  • 精确修剪和呈现数据

  • 为了预测,需要同时使用监督学习和无监督学习算法。

  • 通过可视化呈现数据以获得更多见解

技术要求

为了继续本章,您需要安装以下包。这些包将用于对正在处理的数据进行预处理和可视化呈现。其中一些包还包含对我们的数据进行操作的算法的良好实现。

最好使用pip安装这些模块。因此,首先,我们需要使用以下命令为 Python 3 安装 pip:

  • sudo apt-get update

  • sudo apt-get install python3-pip

此外,需要运行以下命令来安装numpyscikit-learnmatplotlibpandastextblob包:

# pip3 install numpy
# pip3 install scikit-learn
# pip3 install matplotlib
# pip3 install pandas
# pip3 install textblob  

如果您使用的是旧版本的 Python(即 Python 2),则可以使用相同的命令来安装这些包,只需将pip3替换为pip

您还需要安装nltkpunkt包,这些包提供了内置的文本处理功能。要安装它们,请打开 Python 终端并运行以下命令:

>>import nltk
>>nltk.download('punkt')

这些包可能需要先安装其他特定于平台的模块。请注意并安装所有依赖项:

  • NumPy:一个具有操作 n 维数组和矩阵功能的库。

  • Scikit-learn:用于机器学习的高级模块。它包含许多用于分类、回归和聚类等算法的实现。

  • Matplotlib:这是一个绘图库,利用 NumPy 绘制各种图表,包括折线图、直方图、散点图,甚至 3D 图表。

  • Pandas:这个库处理数据操作和分析。

GitHub 链接如下:github.com/PacktPublishing/Hands-On-Data-Structures-and-Algorithms-with-Python-3.x-Second-Edition/tree/master/Chapter14

数据预处理

首先,要分析数据,我们必须对数据进行预处理,以去除噪音并将其转换为适当的格式,以便进一步分析。来自现实世界的数据集大多充满噪音,这使得直接应用任何算法变得困难。收集到的原始数据存在许多问题,因此我们需要采取方法来清理数据,使其适用于进一步的研究。

处理原始数据

收集到的数据可能与随时间收集的其他记录不一致。重复条目的存在和不完整的记录要求我们以这样的方式处理数据,以揭示隐藏的有用信息。

为了清理数据,我们完全丢弃了不相关和嘈杂的数据。缺失部分或属性的数据可以用合理的估计值替换。此外,当原始数据存在不一致性时,检测和纠正就变得必要了。

让我们探讨如何使用NumPypandas进行数据预处理技术。

缺失数据

如果数据存在缺失值,机器学习算法的性能会下降。仅仅因为数据集存在缺失字段或属性并不意味着它没有用处。可以使用几种方法来填补缺失值。其中一些方法如下:

  • 使用全局常数填补缺失值。

  • 使用数据集中的均值或中位数值。

  • 手动提供数据。

  • 使用属性的均值或中位数来填补缺失值。选择基于数据将要使用的上下文和敏感性。

例如,以下数据:

    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也进行类似的操作。

特征缩放

数据框中的列称为其特征。行称为记录或观察。如果一个属性的值比其他属性的值具有更高的范围,机器学习算法的性能会下降。因此,通常需要将属性值缩放或归一化到一个公共范围内。

考虑一个例子,以下数据矩阵。这些数据将在后续部分中被引用,请注意:

data1= ([[  58.,    1.,   43.],
 [  10.,  200.,   65.],
 [  20.,   75.,    7.]]

特征一的数据为581020,其值位于1058之间。对于特征二,数据位于1200之间。如果将这些数据提供给任何机器学习算法,将产生不一致的结果。理想情况下,我们需要将数据缩放到一定范围内以获得一致的结果。

再次仔细检查发现,每个特征(或列)的均值都在不同的范围内。因此,我们要做的是使特征围绕相似的均值对齐。

特征缩放的一个好处是它提升了机器学习的学习部分。scikit模块有大量的缩放算法,我们将应用到我们的数据中。

最小-最大标量形式的归一化

最小-最大标量形式的归一化使用均值和标准差将所有数据装箱到位于某些最小和最大值之间的范围内。通常,范围设置在01之间;尽管可以应用其他范围,但01范围仍然是默认值:

from sklearn.preprocessing import MinMaxScaler

scaled_values = MinMaxScaler(feature_range=(0,1)) 
results = scaled_values.fit(data1).transform(data1) 
print(results) 

使用MinMaxScaler类的一个实例,范围为(0,1),并传递给scaled_values变量。调用fit函数进行必要的计算,用于内部使用以改变数据集。transform函数对数据集进行实际操作,并将值返回给results

[[ 1\.          0\.          0.62068966]
 [ 0\.          1\.          1\.        ]
 [ 0.20833333  0.3718593   0\.        ]]

从前面的输出中可以看出,所有数据都经过了归一化,并位于01之间。这种输出现在可以提供给机器学习算法。

标准缩放

我们初始数据集或表中各特征的均值分别为 29.3、92 和 38。为了使所有数据具有相似的均值,即数据的均值为零,方差为单位,我们可以应用标准缩放算法,如下所示:

    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,如果该值小于阈值,则替换为0。考虑以下代码片段,我们以 50 作为阈值来对原始数据进行二值化:

 results = preprocessing.Binarizer(50.0).fit(data).transform(data) 
 print(results) 

创建一个Binarizer的实例,并使用参数50.050.0是将在二值化算法中使用的阈值:

[[ 1\. 0\. 0.]
 [ 0\. 1\. 1.]
 [ 0\. 1\. 0.]] 

数据中所有小于 50 的值将为0,否则为1

学习机器学习

机器学习是人工智能的一个子领域。机器学习基本上是一个可以从示例数据中学习并可以基于此提供预测的算法。机器学习模型从数据示例中学习模式,并使用这些学习的模式来预测未见数据。例如,我们将许多垃圾邮件和正常邮件的示例输入来开发一个机器学习模型,该模型可以学习邮件中的模式,并可以将新邮件分类为垃圾邮件或正常邮件。

机器学习类型

机器学习有三个广泛的类别,如下:

  • 监督学习:在这里,算法会接收一组输入和相应的输出。然后算法必须找出对于未见过的输入,输出将会是什么。监督学习算法试图学习输入特征和目标输出中的模式,以便学习的模型可以预测新的未见数据的输出。分类和回归是使用监督学习方法解决的两种问题,其中机器学习算法从给定的数据和标签中学习。分类是一个将给定的未见数据分类到预定义类别集合中的过程,给定一组输入特征和与其相关的标签。回归与分类非常相似,唯一的区别在于,在回归中,我们有连续的目标值,而不是固定的预定义类别集合(名义或分类属性),我们预测连续响应中的值。这样的算法包括朴素贝叶斯、支持向量机、k-最近邻、线性回归、神经网络和决策树算法。

  • 无监督学习:无监督学习算法仅使用输入来学习数据中的模式和聚类,而不使用存在于一组输入和输出变量之间的关系。无监督算法用于学习给定输入数据中的模式,而不带有与其相关的标签。聚类问题是使用无监督学习方法解决的最流行的问题之一。在这种情况下,数据点根据特征之间的相似性被分组成组或簇。这样的算法包括 k 均值聚类、凝聚聚类和层次聚类。

  • 强化学习:在这种学习方法中,计算机动态地与环境交互,以改善其性能。

你好分类器

让我们举一个简单的例子来理解机器学习的工作原理;我们从一个文本分类器的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 

我们可以看到算法在正确将输入短语分类到它们的类别方面取得了一定程度的成功。

这个刻意构造的例子过于简单,但它确实显示了如果提供了正确数量的数据和合适的算法或模型,机器是可以在没有任何人类帮助的情况下执行任务的。

在我们的下一个例子中,我们将使用scikit模块来预测一个短语可能属于的类别。

一个监督学习的例子

让我们考虑一个文本分类问题的例子,可以使用监督学习方法来解决。文本分类问题是在我们有一组与固定数量的类别相关的文档时,将新文档分类到预定义的文档类别集合之一。与监督学习一样,我们需要首先训练模型,以便准确预测未知文档的类别。

收集数据

scikit模块带有我们可以用于训练机器学习模型的示例数据。在这个例子中,我们将使用包含 20 个文档类别的新闻组文档。为了加载这些文档,我们将使用以下代码行:

 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)) 

机器学习算法不能直接处理文本属性,因此每个文档所属类别的名称被表示为数字(例如,alt.atheism表示为0),使用以下代码行:

    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,am,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的维度为(2,257 x 35,788),对应于我们在这个例子中使用的四个数据类别。这意味着 2,257 对应于文档的总数,而 35,788 对应于列的数量,即构成所有文档中唯一单词集的特征的总数。

我们实例化CountVectorizer类,并将training_data.data传递给count_vect对象的fit_transform方法。结果存储在training_matrix中。training_matrix包含所有唯一的单词及其相应的频率。

有时,频率计数对于文本分类问题表现不佳;我们可以使用词项频率-逆文档频率TF-IDF)加权方法来表示特征,而不是使用频率计数。

在这里,我们将导入TfidfTransformer,它有助于为我们的数据中的每个特征分配权重:

    matrix_transformer = TfidfTransformer() 
    tfidf_data = matrix_transformer.fit_transform(training_matrix) 

    print(tfidf_data[1:4].todense()) 

tfidf_data[1:4].todense()只显示了一个三行 35,788 列矩阵的截断列表。所见的值是 TF-IDF;与使用频率计数相比,它是一种更好的表示方法。

一旦我们提取了特征并以表格格式表示它们,我们就可以应用机器学习算法进行训练。有许多监督学习算法;让我们看一个朴素贝叶斯算法的例子来训练文本分类器模型。

朴素贝叶斯算法是一种简单的分类算法,它基于贝叶斯定理。它是一种基于概率的学习算法,通过使用特征/单词/术语的词频来计算属于的概率来构建模型。朴素贝叶斯算法将给定的文档分类为预定义类别中的一个,其中新文档中观察到的单词的最大概率所在的类别。朴素贝叶斯算法的工作方式如下——首先,处理所有训练文档以提取出现在文本中的所有单词的词汇,然后计算它们在不同目标类别中的频率以获得它们的概率。接下来,将新文档分类到具有属于特定类别的最大概率的类别中。朴素贝叶斯分类器基于这样的假设,即单词出现的概率与文本中的位置无关。多项式朴素贝叶斯可以使用scikit库的MultinomialNB函数来实现,如下所示:

 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函数,以获得测试数据的向量化形式。为了获得测试数据集的 TF-IDF 表示,我们调用matrix_transformer对象的transform方法。当我们将新的测试数据传递给机器学习模型时,我们必须以与准备训练数据相同的方式处理数据。

为了预测文档可能属于哪个类别,我们使用predict函数如下:

    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 均值算法对一些数据进行聚类。

无监督学习示例

无监督学习算法能够发现数据中可能存在的固有模式,并以这样的方式将它们聚类成组,使得一个组中的数据点非常相似,而来自两个不同组的数据点在性质上非常不相似。这些算法的一个例子就是 k 均值算法。

k 均值算法

k 均值算法使用给定数据集中的均值点来对数据进行聚类并发现数据集中的组。K是我们希望发现的聚类的数量。k 均值算法生成了分组/聚类之后,我们可以将未知数据传递给该模型,以预测新数据应该属于哪个聚类。

请注意,在这种算法中,只有原始的未分类数据被输入到算法中,没有任何与数据相关联的标签。算法需要找出数据是否具有固有的组。

k 均值算法通过迭代地根据提供的特征之间的相似性将数据点分配到聚类中。k 均值聚类使用均值点将数据点分组成 k 个聚类/组。它的工作方式如下。首先,我们创建 k 个非空集合,并计算数据点与聚类中心之间的距离。接下来,我们将数据点分配给具有最小距离且最接近的聚类。然后,我们重新计算聚类点,并迭代地遵循相同的过程,直到所有数据都被聚类。

为了理解这个算法的工作原理,让我们检查包含 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)。在每条记录中,我们将使用其中的数据来表示最终将绘制的xy值。

original_set中的最后 50 个数字将被1+2*np.random.rand(50, 2)替换。实际上,我们已经创建了两个数据子集,其中一个集合中的数字为负数,而另一个集合中的数字为正数。现在算法的责任是适当地发现这些段。

我们实例化KMeans算法类,并传递n_clusters=2。这使得算法将其所有数据聚类成两组。在 k 均值算法中,簇的数量必须事先知道。使用scikit库实现 k 均值算法如下所示:

    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]]

original_set中的每个数据点在我们的 k 均值算法完成训练后将属于一个簇。k 均值算法将它发现的两个簇表示为 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时,所有属于零组的点都返回到变量index。对于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] 

在这里,两个测试样本分配到了两个不同的簇。

数据可视化

数值分析有时不那么容易理解。在本节中,我们将向您展示一些可视化数据和结果的方法。图像是分析数据的一种快速方式。图像中大小和长度的差异是快速标记,可以得出结论。在本节中,我们将介绍表示数据的不同方法。除了这里列出的图表外,在处理数据时还可以实现更多。

条形图

要将值 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轴上绘制,其中x为零。第二根带有数据 5 的条将在x轴上绘制,其中x为 1:

通过修改以下行可以改变每个条的宽度:

    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.303.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() 

图表中的扇形用标签数组中的字符串标记:

气泡图

散点图的另一种变体是气泡图。在散点图中,我们只绘制数据的xy点。气泡图通过展示点的大小添加了另一个维度。这第三个维度可以表示市场的规模甚至利润:

    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变量,我们指定了随机生成的xy值的数量。这个数字也用于确定我们的xy坐标的随机颜色。随机气泡大小由area = np.pi * (60 * np.random.rand(n))**2确定。

以下图表显示了这个气泡图:

总结

在本章中,我们探讨了数据和算法如何结合起来帮助机器学习。通过数据清洗技术和缩放和归一化过程,我们首先对大量数据进行了整理。将这些数据输入到专门的学习算法中,我们能够根据算法从数据中学到的模式来预测未知数据的类别。我们还讨论了机器学习算法的基础知识。

我们详细解释了监督和无监督的机器学习算法,使用朴素贝叶斯和 k 均值聚类算法。我们还使用基于 Python 的scikit-learn机器学习库提供了这些算法的实现。最后,我们讨论了一些重要的可视化技术,因为对压缩数据进行图表化和绘图有助于更好地理解和做出有见地的发现。

希望您在阅读本书时有一个愉快的体验,并且它能够帮助您在未来的数据结构和 Python 3.7 的学习中取得成功!

posted @ 2025-09-20 21:35  绝不原创的飞龙  阅读(11)  评论(0)    收藏  举报