Python-数据结构与算法使用指南第三版-全-
Python 数据结构与算法使用指南第三版(全)
原文:
zh.annas-archive.org/md5/7ca80fef5489f3f57b35807da4f8fc57译者:飞龙
前言
数据结构在存储和组织应用程序中的数据方面发挥着至关重要的作用。选择正确的数据结构对于显著提高应用程序的性能至关重要,因为随着数据量的增加,能够扩展应用程序是非常理想的。这一新版本教你基本的 Python 数据结构和构建简单、可维护应用程序的最常用和最重要的算法。它还允许你通过实际示例和易于遵循的逐步说明来实现这些算法。
在这本书中,你将学习到基本的 Python 数据结构和最常用的算法。通过这本易于阅读的书籍,你将学习如何创建复杂的数据结构,如链表、栈、堆、队列、树和图,以及包括冒泡排序、插入排序、堆排序和快速排序在内的排序算法。我们还描述了各种选择算法,如随机选择和确定性选择,并详细讨论了各种数据结构算法和设计范式,如贪心算法、分而治之、动态规划。此外,通过简单的图示示例解释了复杂的数据结构,如树和图,以帮助理解这些有用的数据结构的概念。你还将学习到各种重要的字符串处理和模式匹配算法,如 KMP 和 Boyer-Moore 算法,以及它们在 Python 中的简单实现。
这本书面向谁
这本书旨在为正在学习数据结构和算法的初学者或中级 Python 开发者编写,因为章节提供了实用的示例和简单的方法来理解复杂的算法。它也可能对学习数据结构和算法的工程学生有所帮助,因为它涵盖了几乎所有学习的内容。这本书还旨在为希望使用特定数据结构部署各种应用的软件开发者设计,因为这本书提供了高效存储相关数据的方法。
假设读者对 Python 有一些基本了解;然而,这不是必需的,因为我们提供了 Python 和面向对象概念的快速概述。
这本书涵盖的内容
第一章,Python 数据类型和结构,介绍了 Python 中的基本数据类型和结构。它将概述 Python 中可用的几个内置数据结构,这些数据结构对于理解数据结构的内部机制至关重要。
第二章,算法设计简介,提供了有关算法设计问题和技术的详细信息。这一章将通过运行时间和计算复杂度比较不同的分析算法,这将告诉我们对于给定问题哪些算法比其他算法表现更好。
第三章,算法设计技术和策略,涵盖了各种重要的数据结构设计范例,如贪心算法、动态规划、分而治之。我们将通过一系列基本原理,如鲁棒性、适应性和可重用性,学习如何创建数据结构,并学习如何将结构从函数中分离出来。
第四章,链表,涵盖了链表,这是最常见的几种数据结构之一,常被用来实现其他结构,如栈和队列。在本章中,我们描述了链表、它们的操作和实现。我们比较了它们与数组的行为,并讨论了各自的相对优缺点。
第五章,栈和队列,详细描述了栈和队列数据结构。它还讨论了这些线性数据结构的行为,并演示了一些实现。我们给出了典型现实生活应用示例。
第六章,树,考虑了树如何成为许多最重要的先进数据结构的基础。在本章中,我们探讨了如何实现二叉树。我们将检查如何遍历树以及检索和插入值。
第七章,堆和优先队列,探讨了优先队列作为重要数据结构,并展示了如何使用堆来实现它们。
第八章,哈希表,描述了符号表,给出了一些典型实现,并讨论了各种应用。我们将查看哈希过程,给出一个哈希表实现,并讨论各种设计考虑因素。
第九章,图和算法,探讨了包括图和空间结构在内的一些更专业的结构。我们将学习如何通过节点和顶点表示数据,并创建如有向图和无向图等结构。我们还将学习最小生成树的不同算法,如普里姆算法和克鲁斯卡尔算法。
第十章,搜索,讨论了最常用的搜索算法,包括二分搜索和插值搜索算法。我们还给出了它们在各种数据结构中的应用示例。搜索数据结构是一个基本任务,有几种不同的方法。
第十一章,排序,探讨了排序的最常见方法。这包括冒泡排序、插入排序、选择排序、快速排序和堆排序算法,以及它们的详细解释和 Python 实现。
第十二章,选择算法,讨论了选择算法通常是如何被用来从列表中找到第 i 个最小元素的。它与排序算法密切相关,并且广泛地与数据结构和算法相关。
第十三章,字符串匹配算法,涵盖了与字符串相关的基本概念和定义。在本章中,详细讨论了各种字符串和模式匹配算法,例如朴素方法,以及Knuth-Morris-Pratt(KMP)和 Boyer-Moore 模式匹配算法。
附录,问题答案,提供了每章末尾练习的答案。请随时查看本书末尾的附录。
还有一些与树算法相关的额外内容可在网上找到,网址为static.packt-cdn.com/downloads/9781801073448_Bonus_Content.pdf。
为了充分利用本书
本书中的代码需要在 Python 3.10 或更高版本上运行。Python 的交互式环境也可以用来运行代码片段。建议通过执行本书中提供的代码来学习算法和概念,以便更好地理解算法。本书旨在让读者获得实践经验,因此建议为所有算法进行编程,以充分利用本书。
下载示例代码文件
书籍的代码包托管在 GitHub 上,网址为github.com/PacktPublishing/Hands-On-Data-Structures-and-Algorithms-with-Python-Third-Edition。如果代码有更新,它将在现有的 GitHub 仓库中更新。
我们还有其他来自我们丰富的书籍和视频目录的代码包可供在github.com/PacktPublishing/找到。查看它们吧!
下载彩色图像
我们还提供了一个包含本书中使用的截图/图表彩色图像的 PDF 文件。您可以从这里下载:static.packt-cdn.com/downloads/9781801073448_ColorImages.pdf。
使用的约定
在本书中使用了多种文本约定。
CodeInText:表示文本中的代码词汇、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“‘not in’运算符如果未在指定序列中找到变量则返回True,如果找到则返回False。”
代码块设置如下:
p = "Hello India"
q = 10
r = 10.2
print(type(p))
print(type(q))
print(type(r))
当我们希望您注意代码块中的特定部分时,相关的行或项目将以粗体显示:
while self.slots[h] != None:
if self.slots[h].key == key:
return self.slots[h].value
`h = (h + j * (self.prime_num - (self.h2(key) % self.prime_num))) % self.size`
`j = j + 1`
return None
任何命令行输入或输出都写成如下:
sudo apt-get install python3.10
粗体:表示新术语、重要词汇或屏幕上看到的词汇。例如,菜单或对话框中的文字会以这种方式显示。以下是一个示例:“哈希表中的每个位置通常被称为槽或桶,可以存储一个元素。”
警告或重要提示看起来像这样。
小贴士和技巧看起来像这样。
联系我们
我们始终欢迎读者的反馈。
总体反馈:请发送邮件至 feedback@packtpub.com 并在邮件主题中提及书名。如果您对本书的任何方面有疑问,请发送邮件至 questions@packtpub.com。
勘误:尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,我们将不胜感激,如果您能向我们报告这一点。请访问www.packtpub.com/submit-errata,选择您的书籍,点击勘误提交表单链接,并输入详细信息。
盗版:如果您在互联网上发现任何形式的我们作品的非法副本,如果您能提供位置地址或网站名称,我们将不胜感激。请通过 mailto:copyright@packtpub.com 与我们联系,并提供材料的链接。
如果您有兴趣成为作者:如果您在某个领域有专业知识,并且您有兴趣撰写或参与一本书的编写,请访问 authors.packtpub.com。
分享您的想法
一旦您阅读了《Python 动手实践数据结构与算法 - 第三版》,我们非常乐意听到您的想法!请点击此处直接访问亚马逊评论页面并分享您的反馈。
您的评论对我们和科技社区都非常重要,并将帮助我们确保我们提供高质量的内容。
第一章:Python 数据类型和结构
数据结构和算法是任何软件开发的重要组件。算法可以被定义为解决任何给定问题的一系列逐步指令;算法处理数据并根据特定问题产生输出结果。算法用于解决问题的数据必须在计算机内存中高效存储和组织,以便软件的高效实现。系统的性能取决于数据的有效访问和检索,这取决于存储和组织系统数据的结构选择是否得当。
数据结构处理在程序中将使用的数据在计算机内存中的存储和组织方式。计算机科学家应该了解算法的效率以及在其实现中应该使用哪种数据结构。Python 编程语言是一种强大、功能丰富且广泛使用的语言,用于开发基于软件的系统。Python 是一种高级、解释型、面向对象的语言,非常便于学习和理解数据结构和算法的概念。
在本章中,我们简要回顾了我们将用于实现本书中讨论的各种数据结构的 Python 编程语言组件。对于更广泛的 Python 语言讨论,请参阅 Python 文档:
在本章中,我们将探讨以下主题:
-
介绍 Python 3.10
-
安装 Python
-
设置 Python 开发环境
-
数据类型和对象概述
-
基本数据类型
-
复杂数据类型
-
Python 的 collections 模块
介绍 Python 3.10
Python 是一种解释型语言:语句逐行执行。它遵循面向对象编程的概念。Python 是动态类型的,这使得它成为脚本和许多平台上快速开发的首选语言。其源代码是开源的,有一个非常大的社区在持续快速地使用和开发它。Python 代码可以用任何文本编辑器编写,并保存为.py文件扩展名。Python 由于其简洁性和优雅的语法,易于使用和学习。
由于 Python 语言将被用于编写算法,因此提供了如何设置环境以运行示例的解释。
安装 Python
Python 预安装在基于 Linux 和 Mac 的操作系统上。然而,您可能想要安装最新版本的 Python,这可以通过以下说明在不同的操作系统上完成。
Windows 操作系统
对于 Windows 系统,Python 可以通过可执行的 .exe 文件进行安装。
-
根据你的架构选择 Python 的最新版本——目前是 3.10.0。如果你有 32 位版本的 Windows,请选择 32 位安装程序;否则,请选择 64 位安装程序。
-
下载
.exe文件。 -
打开
python-3.10.0.exe文件。 -
确保检查将 Python 3.10.0 添加到 PATH。
-
点击立即安装,然后等待安装完成;现在你可以使用 Python 了。
-
要验证 Python 是否正确安装,请在命令提示符中输入
python -–version命令。它应该输出Python 3.10.0。
基于 Linux 的操作系统
要在 Linux 机器上安装 Python,请按照以下步骤操作:
-
通过在终端中输入
python --version命令来检查你是否已预先安装了 Python。 -
如果你还没有 Python 版本,可以通过以下命令进行安装:
sudo apt-get install python3.10 -
现在,通过在终端中输入
python3.10 --version命令来验证你是否已正确安装 Python。它应该输出Python 3.10.0。
Mac 操作系统
要在 Mac 上安装 Python,请按照以下步骤操作:
-
下载并打开
Python 3.10.0的安装程序文件。 -
点击立即安装。
-
要验证 Python 是否正确安装,请在终端中输入
python –version命令。它应该输出Python 3.10.0。
设置 Python 开发环境
一旦成功在你的操作系统上安装了 Python,你就可以开始使用数据结构和算法进行实践了。有两种流行的方法来设置开发环境。
通过命令行进行设置
设置 Python 执行环境的第一个方法是使用命令行,在你的操作系统上安装 Python 软件包后。可以通过以下步骤进行设置。
-
在 Mac/Linux OS 上打开终端或在 Windows 上打开命令提示符。
-
执行 Python 3 命令以启动 Python,或在 Windows 命令提示符中简单地输入
py来启动 Python。 -
命令可以在终端中执行。

图 1.1:Python 命令行界面的截图
命令行执行环境的用户界面如图 1.1 所示。
通过 Jupyter Notebook 进行设置
运行 Python 程序的第二种方法是使用 Jupyter Notebook,这是一个基于浏览器的界面,我们可以在这里编写代码。Jupyter Notebook 的用户界面如图 1.2 所示。我们可以编写代码的地方被称为“单元格”。

图 1.2:Jupyter Notebook 界面截图
Python 安装完成后,在 Windows 上,可以通过以下步骤轻松安装和设置 Jupyter Notebook,使用名为 Anaconda 的科学 Python 发行版。
-
从
www.anaconda.com/products/individual下载 Anaconda 发行版。 -
按照安装说明进行安装。
-
安装完成后,在 Windows 上,我们可以通过在命令提示符中执行
jupyter notebook命令来运行笔记本。或者,安装后,可以从任务栏中搜索并运行Jupyter Notebook应用程序。 -
在 Linux/Mac 操作系统上,可以通过在终端中运行以下代码使用
pip3安装 Jupyter Notebook:pip3 install notebook -
安装 Jupyter Notebook 后,我们可以在终端中执行以下命令来运行它:
jupyter notebook在某些系统上,此命令可能不起作用,这取决于操作系统或系统配置。在这种情况下,应在终端中执行以下命令以启动 Jupyter Notebook。
python3 -m notebook
重要的是要注意,我们将使用 Jupyter Notebook 执行本书中的所有命令和程序,但如果您愿意,代码也可以在命令行中运行。
数据类型和对象概述
给定一个问题,我们可以通过编写计算机程序或软件来计划解决它。第一步是开发算法,本质上是一系列计算机系统将遵循的指令,以解决问题。算法可以使用任何编程语言转换为计算机软件。总是希望计算机软件或程序尽可能高效和快速;计算机程序的性能或效率也高度依赖于数据如何在计算机内存中存储,然后将在算法中使用。
在算法中使用的数据必须存储在变量中,这些变量取决于将要存储在其中的值的类型。这些被称为数据类型:整数变量只能存储整数,浮点变量可以存储实数、字符等。变量是存储值的容器,而值是不同数据类型的内 容。
在大多数编程语言中,变量及其数据类型必须最初进行声明,然后才能在那些变量中静态存储那种类型的数据。然而,在 Python 中并非如此。Python 是一种动态类型语言;变量的数据类型不需要显式定义。Python 解释器在运行时隐式地将变量的值与其类型绑定。在 Python 中,可以使用type()函数检查变量的数据类型,该函数返回传入变量的类型。例如,如果我们输入以下代码:
p = "Hello India"
q = 10
r = 10.2
print(type(p))
print(type(q))
print(type(r))
print(type(12+31j))
我们将得到以下类似的输出:
<class 'str'>
<class 'int'>
<class 'float'>
<class 'complex'>
以下示例演示了一个具有var浮点值的变量,该值被替换为字符串值:
var = 13.2
print(var)
print(type (var))
var = "Now the type is string"
print(type(var))
代码的输出如下:
13.2
<class 'float'>
<class 'str'>
在 Python 中,每个数据项都是特定类型的对象。考虑前面的例子;在这里,当变量 var 被赋予值 13.2 时,解释器最初创建一个值为 13.2 的浮点对象;然后变量 var 指向该对象,如图 图 1.3 所示:

图 1.3:变量赋值
Python 是一种易于学习的面向对象语言,拥有丰富的内置数据类型。主要内置类型如下,将在以下章节中详细讨论:
-
数值类型:
整数(int)、浮点数(float)、复数(complex) -
布尔类型:
bool -
序列类型:
字符串(str)、range、列表(list)、元组(tuple) -
映射类型:
字典(dict) -
集合类型:
集合(set)、冻结集合(frozenset)
我们将这些分为基本类型(数值、布尔型和序列型)和复杂类型(映射型和集合型)。在后续章节中,我们将逐一详细讨论它们。
基本数据类型
最基本的数据类型是数值型和布尔型。我们首先介绍这些类型,然后是序列数据类型。
数值型
数值数据类型变量存储数值。整数、浮点数和复数值属于此数据类型。Python 支持三种类型的数值:
-
整数(int):在 Python 中,解释器将一系列十进制数字视为十进制值,例如整数
45、1000或-25。 -
浮点数:Python 将具有浮点值的值视为浮点类型;它用小数点指定。它用于存储浮点数,如
2.5和100.98。它精确到15位小数。 -
复数:复数使用两个浮点值表示。它包含一个有序对,例如 a + ib。在这里,a 和 b 表示实数,i 表示虚数部分。复数的形式为
3.0 + 1.3i、4.0i等。
布尔型
这将提供一个 True 或 False 的值,检查任何语句是否为真或假。True 可以用任何非零值表示,而 False 可以用 0 表示。例如:
print(type(bool(22)))
print(type(True))
print(type(False))
输出将如下所示:
<class 'bool'>
<class 'bool'>
<class 'bool'>
在 Python 中,可以使用内置的 bool() 函数将数值用作布尔值。任何值为零的数字(整数、浮点数、复数)被视为 False,而非零值被视为 True。例如:
bool(False)
print(bool(False))
va1 = 0
print(bool(va1))
va2 = 11
print(bool(va2))
va3 = -2.3
print(bool(va3))
上述代码的输出将如下所示。
False
False
True
True
序列数据类型也是一种非常基本且常见的类型,我们将在下一节中探讨。
序列
序列数据类型用于以有组织和高效的方式在单个变量中存储多个值。有四种基本序列类型:字符串、范围、列表和元组。
字符串
字符串是一个不可变的字符序列,可以用单引号、双引号或三引号表示。
不可变意味着一旦数据类型被赋予某个值,就不能更改。
Python 中的字符串类型称为str。三引号字符串可以跨越多行,包括字符串中的所有空白。例如:
str1 = 'Hello how are you'
str2 = "Hello how are you"
str3 = """multiline
String"""
print(str1)
print(str2)
print(str3)
输出将如下所示:
Hello how are you
Hello how are you
multiline
String
+运算符用于连接字符串,连接操作数后返回一个字符串。例如:
f = 'data'
s = 'structure'
print(f + s)
print('Data ' + 'structure')
输出将如下所示:
datastructure
Data structure
可以使用*运算符创建字符串的多个副本。当它与一个整数(例如n)和一个字符串一起使用时,*运算符返回一个由n个字符串连接副本组成的字符串。例如:
st = 'data.'
print(st * 3)
print(3 * st)
输出将如下所示。
data.data.data.
data.data.data.
范围
range数据类型表示一个不可变的数字序列。它主要用于for和while循环中。它返回从给定数字开始到由函数参数指定的数字的序列。它用于以下命令中:
range(start, stop, step)
在这里,start参数指定序列的开始,stop参数指定序列的结束限制,step参数指定序列应如何增加或减少。以下 Python 代码示例演示了range函数的工作原理:
print(list(range(10)))
print(range(10))
print(list(range(10)))
print(range(1,10,2))
print(list(range(1,10,2)))
print(list(range(20,10,-2)))
输出将如下所示。
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
range(0, 10)
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
range(1, 10, 2)
[1, 3, 5, 7, 9]
[20, 18, 16, 14, 12]
列表
Python 列表用于在单个变量中存储多个项目。列表中允许有重复值,并且元素可以是不同类型:例如,Python 列表中可以同时包含数字和字符串数据。
列表中存储的项目用方括号[]括起来,并用逗号分隔,如下所示:
a = ['food', 'bus', 'apple', 'queen']
print(a)
mylist = [10, "India", "world", 8]
# accessing elements in list.
print(mylist[1])
上述代码的输出将如下所示。
['food', 'bus', 'apple', 'queen']
India
列表的数据元素在图 1.4中显示,显示了列表中每个元素的索引值:

图 1.4:样本列表的数据元素
Python 列表的特点如下。首先,可以通过索引访问列表元素,如图 1.4所示。列表元素是有序和动态的。它可以包含任何所需的任意对象。此外,list数据结构是可变的,而大多数其他数据类型,如integer和float是不可变的。
由于列表是可变的数据类型,一旦创建,列表元素可以添加、删除、移动和在列表内移动。
下面的表 1.1中解释了列表的所有属性,以提供更清晰的说明:
| 属性 | 描述 | 示例 |
|---|---|---|
| 有序 | 列表元素按照在定义时在列表中指定的顺序进行排序。这种顺序不需要改变,并且在其生命周期内保持固有。 |
[10, 12, 31, 14] == [14, 10, 31, 12]
False
|
| 动态 | 列表是动态的。它可以通过添加或删除列表项来根据需要增长或缩小。 |
|---|
b = ['data', 'and', 'book', 'structure', 'hello', 'st']
b += [32]
print(b)
b[2:3] = []
print(b)
del b[0]
print(b)
['data', 'and', 'book', 'structure', 'hello', 'st', 32]
['data', 'and', 'structure', 'hello', 'st', 32]
['and', 'structure', 'hello', 'st', 32]
|
| 列表元素可以是任何任意对象集合 | 列表元素可以是相同类型或不同数据类型。 |
|---|
a = [2.2, 'python', 31, 14, 'data', False, 33.59]
print(a)
[2.2, 'python', 31, 14, 'data', False, 33.59]
|
| 列表元素可以通过索引访问 | 可以使用基于零的索引在方括号中访问元素,类似于字符串。访问列表中的元素类似于字符串;负列表索引在列表中也可以使用。列表还支持切片。如果abc是一个列表,则表达式abc[x:y]将返回从索引x到索引y(不包括索引y)的元素部分 |
|---|
a = ['data', 'structures', 'using', 'python', 'happy', 'learning']
print(a[0])
print(a[2])
print(a[-1])
print(a[-5])
print(a[1:5])
print(a[-3:-1])
data
using
learning
structures
['structures', 'using', 'python', 'happy']
['python', 'happy']
|
| 可变 | 单个列表值:可以通过索引和简单赋值更新列表中的元素。也可以通过切片修改多个列表值。 |
|---|
a = ['data', 'and', 'book', 'structure', 'hello', 'st']
print(a)
a[1] = 1
a[-1] = 120
print(a)
a = ['data', 'and', 'book', 'structure', 'hello', 'st']
print(a[2:5])
a[2:5] = [1, 2, 3, 4, 5]
print(a)
|
['data', 'and', 'book', 'structure', 'hello', 'st']
['data', 1, 'book', 'structure', 'hello', 120]
['book', 'structure', 'hello']
['data', 'and', 1, 2, 3, 4, 5, 'st']
|
| 其他运算符 | 几种运算符和内置函数也可以应用于列表,例如in、not in、连接运算符(+)和复制运算符(*)。此外,还有其他内置函数,如len()、min()和max(),也是可用的。 |
|---|
a = ['data', 'structures', 'using', 'python', 'happy', 'learning']
print('data' in a)
print(a)
print(a + ['New', 'elements'])
print(a)
print(a *2)
print(len(a))
print(min(a))
['data', 'structures', 'using', 'python', 'happy', 'learning']
['data', 'structures', 'using', 'python', 'happy', 'learning', 'New', 'elements']
['data', 'structures', 'using', 'python', 'happy', 'learning']
['data', 'structures', 'using', 'python', 'happy', 'learning', 'data', 'structures', 'using', 'python', 'happy', 'learning']
6
data
|
表 1.1:具有示例的列表数据结构特征
现在,在讨论列表数据类型时,我们应该首先了解不同的运算符,例如成员、身份和逻辑运算符,然后再讨论它们以及它们如何在列表数据类型或其他数据类型中使用。在下一节中,我们将讨论这些运算符的工作原理以及它们在各种数据类型中的应用。
成员、身份和逻辑操作
Python 支持成员、身份和逻辑运算符。Python 中的几种数据类型支持这些运算符。为了理解这些运算符的工作原理,我们将在本节中讨论这些操作。
成员运算符
这些运算符用于验证项的成员资格。成员资格意味着我们希望测试给定的值是否存储在序列变量中,例如字符串、列表或元组。成员运算符用于测试序列中的成员资格;即字符串、列表或元组。Python 中使用的两个常见成员运算符是in和not in。
in运算符用于检查一个值是否存在于一个序列中。如果它在指定的序列中找到给定的变量,则返回True;如果没有找到,则返回False:
# Python program to check if an item (say second
# item in the below example) of a list is present
# in another list (or not) using 'in' operator
mylist1 = [100,20,30,40]
mylist2 = [10,50,60,90]
if mylist1[1] in mylist2:
print("elements are overlapping")
else:
print("elements are not overlapping")
输出将如下所示:
elements are not overlapping
如果在指定的序列中找不到变量,则not in运算符返回True;如果找到,则返回False:
val = 104
mylist = [100, 210, 430, 840, 108]
if val not in mylist:
print("Value is NOT present in mylist")
else:
print("Value is present in mylist")
输出将如下所示。
Value is NOT present in mylist
身份运算符
身份运算符用于比较对象。不同的身份运算符类型是is和is not,定义如下。
is运算符用于检查两个变量是否指向同一个对象。这与相等运算符(==)不同。在相等运算符中,我们检查两个变量是否相等。如果两边变量指向同一个对象,则返回True;如果不指向同一个对象,则返回False:
Firstlist = []
Secondlist = []
if Firstlist == Secondlist:
print("Both are equal")
else:
print("Both are not equal")
if Firstlist is Secondlist:
print("Both variables are pointing to the same object")
else:
print("Both variables are not pointing to the same object")
thirdList = Firstlist
if thirdList is Secondlist:
print("Both are pointing to the same object")
else:
print("Both are not pointing to the same object")
输出将如下所示:
Both are equal
Both variables are not pointing to the same object
Both are not pointing to the same object
is not 运算符用于检查两个变量是否指向同一对象。如果两侧变量指向不同的对象,则返回 True,否则返回 False:
Firstlist = []
Secondlist = []
if Firstlist is not Secondlist:
print("Both Firstlist and Secondlist variables are the same object")
else:
print("Both Firstlist and Secondlist variables are not the same object")
输出结果如下:
Both Firstlist and Secondlist variables are not the same object
本节介绍了身份运算符。接下来,让我们讨论逻辑运算符。
逻辑运算符
这些运算符用于组合条件语句(True 或 False)。逻辑运算符有三种类型:AND、OR 和 NOT。
逻辑 AND 运算符如果两个语句都为真时返回 True,否则返回 False。它使用以下语法:A and B:
a = 32
b = 132
if a > 0 and b > 0:
print("Both a and b are greater than zero")
else:
print("At least one variable is less than 0")
输出结果如下。
Both a and b are greater than zero
逻辑 OR 运算符如果在任何语句为真时返回 True,否则返回 False。它使用以下语法:A or B:
a = 32
b = -32
if a > 0 or b > 0:
print("At least one variable is greater than zero")
else:
print("Both variables are less than 0")
输出结果如下。
At least one variable is greater than zero
逻辑 NOT 运算符是一个布尔运算符,可以应用于任何对象。如果操作数/操作数是假的,则返回 True,否则返回 False。在这里,操作数是运算符所应用的单一表达式/语句。它使用以下语法:not A:
a = 32
if not a:
print("Boolean value of a is False")
else:
print("Boolean value of a is True")
输出结果如下。
Boolean value of a is True
在本节中,我们学习了 Python 中可用的不同运算符,并看到了如何将成员运算符和身份运算符应用于列表数据类型。在下一节中,我们将继续讨论最后一个序列数据类型:元组。
元组
元组用于在单个变量中存储多个项。它是一个只读集合,其中数据是有序的(基于零的索引)且不可变/不可更改(无法添加、修改或删除项)。元组中允许有重复的值,元素可以是不同类型,类似于列表。当我们希望存储在程序中不应更改的数据时,我们使用元组而不是列表。
元组用圆括号书写,项之间用逗号分隔:
tuple_name = ("entry1", "entry2", "entry3")
例如:
my_tuple = ("Shyam", 23, True, "male")
元组支持 +(连接)和 *(重复)操作,类似于 Python 中的字符串。此外,元组中还有成员运算符和迭代操作。元组支持的不同的操作列于 表 1.2: 中:
| 表达式 | 结果 | 描述 |
|---|
|
`print(len((4,5, "hello")))`
|
`3`
| 长度 |
|---|
|
`print((4,5)+(10,20))`
|
`(4,5,10,20)`
| 连接 |
|---|
|
`print((2,1)*3)`
|
`(2,1,2,1,2,1)`
| 重复 |
|---|
|
`print(3 in ('hi', 'xyz',3))`
|
`True`
| 成员关系 |
|---|
|
`for p in (6,7,8):`
`print(p)`
|
`6,7,8`
| 迭代 |
|---|
表 1.2:元组操作示例
Python 中的元组支持基于零的索引、负数索引和切片。为了理解它,让我们看一个示例元组,如下所示:
x = ( "hello", "world", " india")
我们可以在 表 1.3 中看到零基于索引、负数索引和切片操作的示例:
| 表达式 | 结果 | 描述 |
|---|
|
`print(x[1])`
|
`"world"`
| 基于零的索引意味着索引从 0 开始而不是 1,因此在这个例子中,第一个索引指的是元组的第二个成员。 |
|---|
|
`print(x[-2])`
|
`"world"`
| 负数:从右侧开始计数。 |
|---|
|
`print(x[1:])`
|
`("world", "india")`
| 切片获取一个部分。 |
|---|
表 1.3:元组索引和切片示例
复杂数据类型
我们已经讨论了基本数据类型。接下来,我们将讨论复杂数据类型,即映射数据类型,换句话说,字典和集合数据类型,即集合和冻结集合。我们将在本节中详细讨论这些数据类型。
字典
在 Python 中,字典是另一个重要的数据类型,类似于列表,因为它也是一个对象的集合。它以无序的 {键-值} 对的形式存储数据;键必须是可哈希和不可变的数据类型,值可以是任何任意的 Python 对象。在这种情况下,一个对象是可哈希的,如果它在程序生命周期中具有不变的哈希值。
字典中的项用大括号 {} 括起来,用逗号分隔,可以使用 {key:value} 语法创建,如下所示:
dict = {
<key>: <value>,
<key>: <value>,
.
.
.
<key>: <value>
}
字典中的键是区分大小写的,它们应该是唯一的,不能重复;然而,字典中的值可以重复。例如,以下代码可以用来创建一个字典:
my_dict = {'1': 'data',
'2': 'structure',
'3': 'python',
'4': 'programming',
'5': 'language'
}
图 1.5 展示了前面代码块创建的 {key-value} 对:

图 1.5:示例字典数据结构
字典中的值可以根据键来检索。例如:my_dict['1'] 返回 data 作为输出。
dictionary 数据类型是可变和动态的。它与列表的不同之处在于,字典元素可以通过键访问,而列表元素是通过索引访问的。表 1.4 展示了具有示例的字典数据结构的不同特征:
| 项目 | 示例 |
|---|---|
| 创建字典,并从字典中访问元素 |
person = {}
print(type(person))
person['name'] = 'ABC'
person['lastname'] = 'XYZ'
person['age'] = 31
person['address'] = ['Jaipur']
print(person)
print(person['name'])
<class 'dict'>{'name': 'ABC', 'lastname': 'XYZ', 'age': 31, 'address': ['Jaipur']}ABC
|
in 和 not in 操作符 |
|---|
print('name' in person)
print('fname' not in person)
True
True
|
| 字典的长度 |
|---|
print(len(person))
4
|
表 1.4:具有示例的字典数据结构特征
Python 还包括如下 表 1.5 所示的字典方法:
| 函数 | 描述 | 示例 |
|---|---|---|
mydict.clear() |
从字典中移除所有元素。 |
mydict = {'a': 1, 'b': 2, 'c': 3}
print(mydict)
mydict.clear()
print(mydict)
{'a': 1, 'b': 2, 'c': 3}
{}
|
mydict.get(<键>) |
在字典中搜索键,如果找到则返回相应的值;否则返回 None。 |
|---|
mydict = {'a': 1, 'b': 2, 'c': 3}
print(mydict.get('b'))
print(mydict)
print(mydict.get('z'))
2
{'a': 1, 'b': 2, 'c': 3}
None
|
mydict.items() |
返回字典中 (键,值) 对的列表。 |
|---|
print(list(mydict.items()))
[('a', 1), ('b', 2), ('c', 3)]
|
mydict.keys() |
返回字典键的列表。 |
|---|
print(list(mydict.keys()))
['a', 'b', 'c']
|
mydict.values() |
返回字典值的列表。 |
|---|
print(list(mydict.values()))
[1, 2, 3]
|
mydict.pop() |
如果字典中存在给定的键,则此函数将移除该键并返回关联的值。 |
|---|
print(mydict.pop('b'))
print(mydict)
{'a': 1, 'c': 3}
|
mydict.popitem() |
此方法移除字典中最后添加的键值对,并将其作为元组返回。 |
|---|
mydict = {'a': 1,'b': 2,'c': 3}
print(mydict.popitem())
print(mydict)
{'a': 1, 'b': 2}
|
mydict.update(<obj>) |
合并一个字典与另一个字典。首先,它检查第二个字典的键是否存在于第一个字典中;然后更新相应的值。如果键不存在于第一个字典中,则添加键值对。 |
|---|
d1 = {'a': 10, 'b': 20, 'c': 30}
d2 = {'b': 200, 'd': 400}
print(d1.update(d2))
print(d1)
{'a': 10, 'b': 200, 'c': 30, 'd': 400}
|
表 1.5:字典数据结构的方法列表
集合
集合是无序的哈希对象集合。它是可迭代的、可变的,并且具有唯一元素。元素的顺序也是未定义的。虽然允许添加和删除项目,但集合内的项目本身必须是不可变的且可哈希的。集合支持成员测试操作符(in, not in),以及交集、并集、差集和对称差集等操作。集合不能包含重复的项目。它们是通过使用内置的 set() 函数或花括号 {} 创建的。set() 从可迭代对象返回一个集合对象。例如:
x1 = set(['and', 'python', 'data', 'structure'])
print(x1)
print(type(x1))
x2 = {'and', 'python', 'data', 'structure'}
print(x2)
输出将如下所示:
{'python', 'structure', 'data', 'and'}
<class 'set'>
{'python', 'structure', 'data', 'and'}
需要注意的是,集合是无序的数据结构,集合中元素的顺序不会被保留。因此,本节中的输出可能与这里显示的略有不同。然而,这不会影响本节将要展示的操作的功能。
集合通常用于执行数学运算,如交集、并集、差集和补集。len() 方法给出集合中的项目数,in 和 not in 操作符可以用于集合中测试成员资格:
x = {'data', 'structure', 'and', 'python'}
print(len(x))
print('structure' in x)
输出将如下所示:
4
True
可以应用于 set 数据结构的最常用方法和操作如下。两个集合,例如 x1 和 x2 的并集,是一个包含两个集合中所有元素的集合:
x1 = {'data', 'structure'}
x2 = {'python', 'java', 'c', 'data'}
图 1.6 展示了一个维恩图,展示了两个集合之间的关系:

图 1.6:集合的维恩图
在 表 1.6 中展示了可以应用于集合类型变量的各种操作的描述,包括示例:
| 描述 | 示例代码 |
|---|---|
两个集合 x1 和 x2 的并集。可以通过两种方法实现,(1) 使用 | 操作符,(2) 使用 union 方法。 |
x1 = {'data', 'structure'}
x2 = {'python', 'java', 'c', 'data'}
x3 = x1 | x2
print(x3)
print(x1.union(x2))
{'structure', 'data', 'java', 'c', 'python'}
{'structure', 'data', 'java', 'c', 'python'}
|
集合的交集:要计算两个集合的交集,可以使用 & 操作符和 intersection() 方法,它返回一个包含 x1 和 x2 共同元素的集合。 |
|---|
print(x1.intersection(x2))
print(x1 & x2)
{'data'}
{'data'}
|
可以使用 .difference() 和减号 - 操作符来获取集合之间的差集,它返回一个包含 x1 中但不在 x2 中的所有元素的集合。 |
|---|
print(x1.difference(x2))
print(x1 - x2)
{'structure'}
{'structure'}
|
对称差集可以使用 .symmetric_difference() 获取,而 ^ 返回一个包含在 x1 或 x2 中但不同时在两者中都存在的所有数据项的集合。 |
|---|
print(x1.symmetric_difference(x2))
print(x1 ^ x2)
{'structure', 'python', 'c', 'java'}
{'structure', 'python', 'c', 'java'}
|
要测试一个集合是否是另一个集合的子集,请使用 .issubset() 和操作符 <=。 |
|---|
print(x1.issubset(x2))
print(x1 <= x2)
False
False
|
表 1.6:适用于集合类型变量的各种操作的描述
不可变集合
在 Python 中,frozenset 是另一种内置类型数据结构,它在所有方面都与集合完全相同,除了它是不可变的,因此创建后不能被更改。元素的顺序也是未定义的。frozenset 是通过使用内置函数 frozenset() 创建的:
x = frozenset(['data', 'structure', 'and', 'python'])
print(x)
输出如下:
frozenset({'python', 'structure', 'data', 'and'})
Frozensets 在我们需要使用集合但需要使用不可变对象时很有用。此外,由于集合元素也必须是不可变的,因此不可能在集合中使用集合元素。考虑以下示例:
a11 = set(['data'])
a21 = set(['structure'])
a31 = set(['python'])
x1 = {a11, a21, a31}
输出结果将是:
TypeError: unhashable type: 'set'
现在有了 frozenset:
a1 = frozenset(['data'])
a2 = frozenset(['structure'])
a3 = frozenset(['python'])
x = {a1, a2, a3}
print(x)
输出结果为:
{frozenset({'structure'}), frozenset({'python'}), frozenset({'data'})}
在上述示例中,我们创建了一个 frozensets 的集合 x(a1、a2 和 a3),这是可能的,因为 frozensets 是不可变的。
我们已经讨论了 Python 中可用的最重要和最受欢迎的数据类型。Python 还提供了一组其他重要的方法和模块,我们将在下一节中讨论。
Python 的 collections 模块
collections 模块提供了不同类型的容器,这些是用于存储不同对象并提供访问方式的对象。在访问这些之前,让我们简要地考虑模块、包和脚本之间的角色和关系。
模块是一个具有 .py 扩展名的 Python 脚本,其中包含一系列函数、类和变量。包是一个包含模块集合的目录;它包含一个 __init__.py 文件,这使解释器知道它是一个包。一个模块可以被调用到一个 Python 脚本中,反过来,脚本可以使用模块中的函数和变量。在 Python 中,我们可以使用 import 语句将这些导入到脚本中。每当解释器遇到 import 语句时,它就会导入指定模块的代码。
表 1.7 提供了集合模块的数据类型和操作及其描述:
| 容器数据类型 | 描述 |
|---|---|
namedtuple |
创建一个具有命名字段的 tuple,类似于常规的 tuple。 |
deque |
双向链表,提供从列表两端高效添加和删除项的功能。 |
defaultdict |
一个返回默认值的 dictionary 子类,用于缺失的键。 |
ChainMap |
一个合并多个字典的 dictionary。 |
Counter |
一个返回对应对象/键的计数的 dictionary。 |
UserDict UserList UserString |
这些数据类型用于向其基本数据结构添加更多功能,例如 dictionary、list 和 string。我们可以从它们创建自定义的 dict/list/string 子类。 |
表 1.7:collections 模块的不同的容器数据类型
让我们更详细地考虑这些类型。
命名元组
collections 模块的 namedtuple 提供了对内置 tuple 数据类型的扩展。namedtuple 对象是不可变的,类似于标准 tuple。因此,在创建 namedtuple 实例之后,我们无法添加新字段或修改现有字段。它们包含映射到特定值的键,我们可以通过索引或键迭代命名元组。namedtuple 函数主要用于在应用程序中使用多个 tuple 时,并且需要跟踪每个 tuple 所代表的内容。
在这种情况下,namedtuple 提供了一个更易读且自文档化的方法。语法如下:
nt = namedtuple(typename , field_names)
这里有一个例子:
from collections import namedtuple
Book = namedtuple ('Book', ['name', 'ISBN', 'quantity'])
Book1 = Book('Hands on Data Structures', '9781788995573', '50')
#Accessing data items
print('Using index ISBN:' + Book1[1])
print('Using key ISBN:' + Book1.ISBN)
输出将如下所示。
Using index ISBN:9781788995573
Using key ISBN:9781788995573
在上述代码中,我们首先从collections模块中导入了namedtuple。Book是一个命名元组,“class”,然后创建了Book1,它是Book的一个实例。我们还可以看到数据元素可以通过索引和键方法访问。
双端队列
deque是一个双端队列(deque),它支持从列表的两端添加和删除元素。Deques 被实现为双链表,在 O(1)时间复杂度下插入和删除元素非常高效。
考虑以下示例:
from collections import deque
s = deque() # Creates an empty deque
print(s)
my_queue = deque([1, 2, 'Name'])
print(my_queue)
输出将如下所示。
deque([])
deque([1, 2, 'Name'])
您也可以使用以下预定义函数中的一些:
| 函数 | 描述 |
|---|---|
my_queue.append('age') |
将 'age' 插入列表的右侧。 |
my_queue.appendleft('age') |
将 'age' 插入列表的左侧。 |
my_queue.pop() |
删除最右侧的值。 |
my_queue.popleft() |
删除最左侧的值。 |
表 1.8:不同队列函数的描述
在本节中,我们展示了collections模块中deque方法的使用,以及如何向队列中添加和删除元素。
有序字典
有序字典是一个保留插入键顺序的字典。如果键顺序对任何应用程序很重要,则可以使用OrderedDict:
od = OrderedDict([items])
一个例子可能如下所示:
from collections import OrderedDict
od = OrderedDict({'my': 2, 'name ': 4, 'is': 2, 'Mohan' :5})
od['hello'] = 4
print(od)
输出将如下所示。
OrderedDict([('my', 2), ('name ', 4), ('is', 2), ('Mohan', 5), ('hello', 4)])
在上述代码中,我们使用OrderedDict模块创建了一个字典od。我们可以观察到键的顺序与我们创建键时的顺序相同。
默认字典
默认字典(defaultdict)是内置字典类(dict)的子类,它具有与dictionary类相同的方法和操作,唯一的区别是它永远不会引发KeyError,就像普通字典会做的那样。defaultdict是初始化字典的一种方便方式:
d = defaultdict(def_value)
一个例子可能如下所示:
from collections import defaultdict
dd = defaultdict(int)
words = str.split('data python data data structure data python')
for word in words:
dd[word] += 1
print(dd)
输出将如下所示。
defaultdict(<class 'int'>, {'data': 4, 'python': 2, 'structure': 1})
在上述示例中,如果使用了普通字典,那么在添加第一个键时,Python 会显示KeyError。我们提供给defaultdict作为参数的int实际上是一个int()函数,它简单地返回零。
ChainMap 对象
ChainMap用于创建字典列表。collections.ChainMap数据结构将多个字典组合成一个单一的映射。每当在chainmap中搜索键时,它会逐个遍历所有字典,直到找不到键:
class collections.ChainMap(dict1, dict2)
一个例子可能如下所示。
from collections import ChainMap
dict1 = {"data": 1, "structure": 2}
dict2 = {"python": 3, "language": 4}
chain = ChainMap(dict1, dict2)
print(chain)
print(list(chain.keys()))
print(list(chain.values()))
print(chain["data"])
print(chain["language"])
输出将如下所示。
ChainMap({'data': 1, 'structure': 2}, {'python': 3, 'language': 4})
['python', 'language', 'data', 'structure']
[3, 4, 1, 2]
1
4
在上述代码中,我们创建了两个字典,即dict1和dict2,然后我们可以使用ChainMap方法将这两个字典结合起来。
Counter 对象
如我们之前讨论的,一个可哈希的对象是在程序生命周期中其哈希值将保持不变的对象。counter用于计数可哈希对象的数量。在这里,字典键是一个可哈希对象,而相应的值是该对象的计数。换句话说,counter对象创建了一个哈希表,其中元素及其计数作为字典键值对存储。
在某种程度上,Dictionary和counter对象是相似的,因为数据都存储在{key, value}对中,但在counter对象中,值是键的计数,而在dictionary中可以是任何东西。因此,当我们只想查看字符串中每个唯一单词出现的次数时,我们使用counter对象。
一个例子可能如下所示:
from collections import Counter
inventory = Counter('hello')
print(inventory)
print(inventory['l'])
print(inventory['e'])
print(inventory['o'])
输出结果将如下:
Counter({'l': 2, 'h': 1, 'e': 1, 'o': 1})
2
1
1
在上面的代码中,创建了inventory变量,它使用counter模块来存储所有字符的计数。可以通过类似字典的键访问方式([key])来访问这些字符的计数值。
UserDict
Python 支持一个容器UserDict,它位于 collections 模块中,封装了字典对象。我们可以向字典中添加自定义函数。这对于我们想要添加/更新/修改字典功能的应用程序非常有用。考虑以下示例代码,其中不允许在字典中推送/添加新的数据元素:
# we can not push to this user dictionary
from collections import UserDict
class MyDict(UserDict):
def push(self, key, value):
raise RuntimeError("Cannot insert")
d = MyDict({'ab':1, 'bc': 2, 'cd': 3})
d.push('b', 2)
输出结果如下:
RuntimeError: Cannot insert
在上面的代码中,在MyDict类中创建了一个自定义的 push 函数,以添加不允许将元素插入到字典中的自定义功能。
UserList
UserList是一个封装列表对象的容器。它可以用来扩展list数据结构的功能。考虑以下示例代码,其中不允许在list数据结构中推送/添加新的数据元素:
# we can not push to this user list
from collections import UserList
class MyList(UserList):
def push(self, key):
raise RuntimeError("Cannot insert in the list")
d = MyList([11, 12, 13])
d.push(2)
输出结果如下:
RuntimeError: Cannot insert in the list
在上面的代码中,在MyList类中创建了一个自定义的push函数,以添加不允许将元素插入到list变量的功能。
UserString
字符串可以被看作是字符数组。在 Python 中,一个字符是一个长度为 1 的字符串,它作为一个容器来封装字符串对象。它可以用来创建具有自定义功能的字符串。一个例子可能如下所示:
#Create a custom append function for string
from collections import UserString
class MyString(UserString):
def append(self, value):
self.data += value
s1 = MyString("data")
print("Original:", s1)
s1.append('h')
print("After append: ", s1)
输出结果如下:
Original: data
After append: datah
在上面的示例代码中,在MyString类中创建了一个自定义的 append 函数,以添加将字符串附加到字符串的功能。
概述
在本章中,我们讨论了 Python 支持的不同内置数据类型。我们还查看了一些基本的 Python 函数、库和模块,例如 collections 模块。本章的主要目标是提供一个 Python 的概述,使用户熟悉该语言,以便于实现数据结构的先进算法。
总体而言,本章概述了 Python 中可用的几个关键数据结构,这些数据结构对于理解数据结构的内部机制至关重要。在下一章中,我们将介绍算法设计和分析的基本概念。
加入我们的 Discord 社区
加入我们的 Discord 空间,与作者和其他读者进行讨论:packt.link/MEvK4

第二章:算法设计简介
本章的目标是理解算法设计的原理,以及在解决现实世界问题中分析算法的重要性。给定输入数据,算法是一系列按顺序执行的指令,用于解决给定的问题。
在本章中,我们还将学习如何比较不同的算法,并确定给定用例的最佳算法。对于给定的问题,可能有多种可能的正确解决方案,例如,对于排序n个数值的问题,我们可以有几种算法。因此,没有一种算法可以解决任何现实世界的问题。
在本章中,我们将探讨以下主题:
-
算法的介绍
-
算法性能分析
-
渐近符号
-
资源化分析
-
选择复杂度类别
-
计算算法的运行时间复杂度
算法的介绍
算法是一系列应按顺序遵循的步骤,以完成给定的任务/问题。
它是一个定义良好的过程,它接受输入数据,处理它,并产生所需的输出。图 2.1展示了这一过程的表示。

图 2.1:算法介绍
以下是一些研究算法的重要理由的总结:
-
计算机科学与工程的基本要素
-
在许多其他领域(如计算生物学、经济学、生态学、通信、物理学等)都很重要
-
它们在技术创新中扮演着角色
-
它们提高了解决问题的能力和分析思维能力
在解决给定问题时,有两个方面非常重要。首先,我们需要一个有效的机制来存储、管理和检索数据,这是解决问题所必需的(这属于数据结构);其次,我们需要一个有效的算法,它是一组有限的指令,用于解决问题。因此,数据结构和算法的研究对于使用计算机程序解决任何问题都是关键。一个有效的算法应具有以下特点:
-
应尽可能具体
-
每条指令都应有明确的定义
-
不应有任何含糊不清的指令
-
算法的所有指令应在有限的时间内和有限步骤内可执行
-
它应有清晰的输入和输出以解决问题
-
算法的每一条指令都应完整地解决给定的问题
以我们日常生活中完成一项任务的算法(一个类比)为例,让我们以准备一杯茶为例。准备一杯茶的算法可以包括以下步骤:
-
把水倒入锅中
-
把锅放在炉子上并点燃炉子
-
在温热的水中加入捣碎的姜
-
在锅中加入茶叶
-
加入牛奶
-
当它开始沸腾时,加入糖
-
2-3 分钟后,茶可以上桌
上述程序是准备茶的一种可能方法。同样,现实世界问题的解决方案可以转化为算法,这些算法可以使用编程语言开发成计算机软件。由于对于给定的问题可能有多个解决方案,因此当它需要用软件实现时,应该尽可能高效。给定一个问题,可能有多个正确的算法,定义为对所有有效输入值产生精确期望输出的算法。执行不同算法的成本可能不同;它可能以在计算机系统上运行算法所需的时间和所需的内存空间来衡量。
在设计高效算法时,应该主要注意以下两点:
-
算法应该是正确的,并且对于所有有效输入值应该产生预期的结果
-
算法应该在计算机上以期望的时间限制和最优的内存空间要求执行
算法的性能分析对于决定给定问题的最佳解决方案非常重要。如果一个算法的性能在期望的时间和空间要求范围内,那么它是最优的。估计算法性能最流行和常见的方法之一是通过分析其复杂性。算法分析帮助我们确定在时间和空间消耗方面哪个算法最有效。
算法的性能分析
算法的性能通常是通过其输入数据的大小,n,以及算法使用的时间和内存空间来衡量的。所需的时间是通过算法需要执行的关键操作(如比较操作)来衡量的,其中关键操作是在执行过程中占用大量时间的指令。而算法的空间需求是通过在程序执行过程中存储变量、常量和指令所需的内存来衡量的。
时间复杂度
算法的时间复杂度是指算法在计算机系统上执行以产生输出所需的时间量。分析算法时间复杂度的目的是确定,对于给定的问题和多个算法,哪个算法在执行所需的时间方面是最有效的。算法所需的运行时间取决于输入大小;随着输入大小,n,的增加,运行时间也会增加。输入大小是输入中项目数量的度量,例如,排序算法的输入大小将是输入中的项目数量。因此,排序算法在排序大小为 5,000 的输入列表时所需的运行时间将大于排序大小为 50 的输入列表时的运行时间。
对于特定输入的算法运行时间取决于算法中要执行的关键操作。例如,排序算法的关键操作是比较操作,它将占用大部分运行时间,与赋值或其他操作相比。理想情况下,这些关键操作不应依赖于硬件、操作系统或用于实现算法的编程语言。
执行每行代码需要恒定的时间;然而,每行代码的执行时间可能不同。为了理解算法所需的运行时间,以下代码作为示例:
| 代码 | 所需时间(成本) |
|---|
|
if n==0 || n == 3 #constant time
print("data")
else:
for i in range( #loop run for n times
print("structure")
|
c1
c2
c3
c4
c5
|
在上述示例的第 1 条语句中,如果条件为真,则打印"data",如果条件不为真,则for循环将执行n次。算法所需的时间取决于每个语句所需的时间以及语句执行的次数。算法的运行时间是所有语句所需时间的总和。对于上述代码,假设第 1 条语句需要c1时间,第 2 条语句需要c2时间,依此类推。因此,如果第i条语句需要恒定的时间c[i],并且如果第i条语句执行了n次,那么它将需要c[i]n时间。对于给定的n值(假设n不为零或三)的算法的总运行时间T(n)如下。
T(n) = c[1] + c[3] + c[4] x n + c[5] x n
如果n的值等于零或三,那么算法所需的时间如下。
T(n) = c[1] + c[2]
因此,算法所需的运行时间不仅取决于输入的大小,还取决于输入的内容。对于给定的例子,最佳情况是输入为零或三,在这种情况下,算法的运行时间将是常数。在最坏的情况下,n的值不等于零或三,那么,算法的运行时间可以表示为 a x n + b。在这里,a和b的值是依赖于语句成本的常数,并且常数时间不计入最终的时间复杂度。在最坏的情况下,算法所需的运行时间是n的线性函数。
让我们考虑另一个例子,线性搜索:
def linear_search(input_list, element):
for index, value in enumerate(input_list):
if value == element:
return index
return -1
input_list = [3, 4, 1, 6, 14]
element = 4
print("Index position for the element x is:", linear_search(input_list,element))
在这个例子中,输出结果如下:
Index position for the element x is: 1
算法的最坏运行时间是上界复杂度;它是算法对任何给定输入执行所需的最大运行时间。最坏情况的时间复杂度非常有用,因为它保证了对于任何输入数据,所需的运行时间不会比最坏运行时间长。例如,在线性搜索问题中,最坏情况发生在要搜索的元素在最后一次比较中找到或未在列表中找到。在这种情况下,所需的运行时间将线性依赖于列表的长度,而在最佳情况下,搜索元素将在第一次比较中找到。
平均运行时间是算法执行所需的平均运行时间。在这个分析中,我们计算所有可能输入值的运行时间的平均值。通常,概率分析用于分析算法的平均运行时间,这是通过对所有可能输入的分布的平均成本来计算的。例如,在线性搜索中,如果要搜索的元素在 0^(th)索引处找到,则所有位置的比较次数将为 1;同样,对于在1, 2, 3, … (n-1)索引位置找到的元素,比较次数将分别为 2,3,等等,直到n。因此,平均运行时间将如下所示。

对于平均情况,所需的运行时间也线性依赖于n的值。然而,在大多数实际应用中,最坏情况分析主要使用,因为它保证了对于任何输入值,运行时间不会比算法的最坏运行时间长。
最佳运行时间是算法运行所需的最短时间;它是算法运行时间的下界;在上面的例子中,输入数据以这种方式组织,使得执行给定算法所需的最短运行时间。
空间复杂度
算法的空间复杂度估计了在计算机上执行它以产生输出所需的内存需求,这取决于输入数据。算法的内存空间需求是决定其效率的几个标准之一。在计算机系统上执行算法时,需要存储输入,以及数据结构中的中间和临时数据,这些数据存储在计算机的内存中。为了编写任何问题的编程解决方案,需要一些内存来存储变量、程序指令以及在计算机上执行程序。算法的空间复杂度是执行和产生结果所需的内存量。
为了计算空间复杂度,考虑以下示例,其中,给定一个整数值的列表,该函数返回相应整数的平方值。
def squares(n):
square_numbers = []
for number in n:
square_numbers.append(number * number)
return square_numbers
nums = [2, 3, 5, 8 ]
print(squares(nums))
代码的输出为:
[4, 9, 25, 64]
在上述代码中,算法将需要为输入列表中的项目数量分配内存。假设输入中的元素数量为 n,那么随着输入大小的增加,空间需求也会增加,因此,算法的空间复杂度变为 O(n)。
给定两个算法来解决给定的问题,在其他所有要求相同的情况下,需要较少内存的算法可以被认为是更有效的。例如,假设有两个搜索算法,一个具有 O(n) 的空间复杂度,另一个算法具有 O(nlogn) 的空间复杂度。第一个算法在空间需求方面比第二个算法更好。空间复杂度分析对于理解算法的效率很重要,尤其是在内存空间需求高的应用中。
当输入大小足够大时,增长顺序也变得很重要。在这种情况下,我们研究算法的渐近效率。通常,渐近效率高的算法被认为是适用于大型输入的更好算法。在下一节中,我们将研究渐近符号。
渐近符号
要分析算法的时间复杂度,当输入大小很大时,增长速率(增长顺序)非常重要。当输入大小变得很大时,我们只考虑高阶项,忽略不重要的项。在渐近分析中,我们考虑高阶增长,忽略乘法常数和低阶项,来分析大输入大小下算法的效率。
我们比较两个算法,根据输入大小而不是实际运行时间,并测量随着输入大小的增加所花费的时间如何增加。在渐近效率方面更有效的算法通常被认为比其他算法更好。以下渐近符号通常用于计算算法的运行时间复杂度:
-
θ 符号:它表示具有紧界的最坏情况运行时间复杂度。
-
Ο 符号:它表示具有上界的最坏情况运行时间复杂度,这确保了函数的增长永远不会超过上界。
-
Ω 符号:它表示算法运行时间的下界。它衡量执行算法的最佳时间。
Theta 符号
以下函数描述了在时间复杂度部分讨论的第一个示例的最坏情况运行时间:
T(n) = c[1] + c[3] x n + c[5] x n
在这里,对于大的输入规模,最坏情况下的运行时间将是 ϴ(n)(读作 theta of n)。我们通常认为如果一个算法的最坏情况运行时间具有更低的增长阶,那么它比另一个算法更高效。由于常数因子和低阶项,一个运行时间具有更高增长阶的算法可能在小输入上比一个运行时间具有更低增长阶的算法花费更少的时间。例如,一旦输入规模 n 足够大,归并排序算法的性能比插入排序更好,分别具有最坏情况运行时间 ϴ(logn) 和 ϴ(n²)。
Theta 符号 (ϴ) 表示具有紧界的算法的最坏情况运行时间。对于给定的函数 F(n),渐近最坏情况运行时间复杂度可以定义为以下内容。

当且仅当存在常数 n[0]、c[1] 和 c[2] 使得:

如果存在正常数 c[1] 和 c[2],使得对于所有大的 n 值,T(n) 的值始终介于 c[1]F(n) 和 c[2]F(n) 之间,那么函数 T(n) 属于函数集合 ϴ(F(n))。如果这个条件成立,那么我们说 F(n) 是 T(n) 的渐近紧界。
图 2.2 展示了 theta 符号的图形示例(ϴ)。从图中可以观察到,对于大于 n[0] 的 n 值,T(n) 的值始终介于 c[1]F(n) 和 c[2]F(n) 之间。

图 2.2:theta 符号(ϴ)的图形示例
让我们考虑一个例子,以了解给定函数的正式 theta 符号定义下应该具有的最坏情况运行时间复杂度:

为了确定使用 ϴ 符号定义的时间复杂度,我们首先需要识别常数 c[1]、c[2]、n[0],使得

除以 n² 将产生:

通过选择 c[1] = 1,c[2] = 2,和 n[0] = 1,以下条件可以满足 theta 符号的定义。

这将给出:

考虑另一个例子,找出另一个函数的渐近紧界(ϴ):

为了识别满足以下条件的常数 c[1]、c[2] 和 n[0]:

通过选择 c[1] = 1/5,c[2] = 1,和 n[0] = 1,以下条件可以满足 theta 符号的定义:


因此,以下是真的:

这表明根据 theta 符号的定义,给定的函数具有 ϴ(n²) 的复杂度。
因此,theta 符号提供了算法时间复杂度的紧界。在下一节中,我们将讨论 Big O 符号。
Big O 符号
我们已经看到,theta 符号从函数的上侧和下侧渐近地有界,而大 O 符号描述的是最坏情况下的运行时间复杂度,这仅仅是函数的渐近上界。大 O 符号定义为:给定一个函数 F(n),T(n) 是函数 F(n) 的大 O 符号,我们将其定义为以下内容:
T(n) = O(F(n))
当存在常数 n[0] 和 c 使得:

在大 O 符号中,F(n) 的常数倍是 T(n) 的渐近上界,并且正的常数 n[0] 和 c 应该以这样的方式,即所有大于 n[0] 的 n 的值始终位于或低于函数 cF(n*)。
此外,我们只关心在 n 的较高值时发生的情况。变量 n[0] 代表增长率不再重要的阈值。图 2.3 展示了函数 T(n) 随 n 变化的图形表示。我们可以看到 T(n) = n² + 500 = O(n²),其中 c = 2,n[0] 大约是 23。

图 2.3:O 符号的图形示例
在 O 符号中,O(F(n)) 实际上是一组函数,包括所有与 F(n) 具有相同或更小增长率的函数。例如,O(n²) 也包括 O(n),O(log n),等等。然而,大 O 符号应尽可能准确地描述一个函数,例如,函数 F(n) = 2n³+2n²+5 是 O(n⁴),然而,更准确的是 F(n) 是 O(n³)。
在下面的表中,我们按从低到高的顺序列出最常见的增长率。
| 时间复杂度 | 名称 |
|---|---|
O(1) |
常数 |
O(logn) |
对数 |
O(n) |
线性 |
O(nlogn) |
线性对数 |
O(n2) |
二次 |
O(n3) |
三次 |
O(2n) |
指数 |
表 2.1:不同函数的运行时间复杂度
使用大 O 符号,可以通过分析算法的结构来计算算法的运行时间。例如,算法中的双层循环将具有 O(n²) 的最坏情况运行时间上界,因为 i 和 j 的值最多为 n,并且两个循环都将运行 n² 次,如下面的示例代码所示:
for i in range(n):
for j in range(n):
print("data")
让我们考虑几个例子,以便使用 O 符号计算函数的上界:
-
求该函数的上界:
T(n) = 2n + 7
解决方案:使用 O 符号,上界的条件是:
T(n) <= c * F(n)
对于所有 n > 7 和 c=3 的值,此条件成立。
2n + 7 <= 3n 这对所有 n 的值都成立,其中 c=3,n[0]=7
T(n) = 2n+7 = O(n)
-
对于函数 *T(n) =2n+5,找到满足 T(n) = O(F(n)) 的 F(n)。
解决方案:使用 O 符号,上界的条件是 T(n) <=c * F(n)。
由于 2n+5 ≤ 3n,对所有 n ≥ 5 成立。
当 c=3,n[0]=5 时,该条件成立。
2n + 5 ≤ O(n)
F(n) = n
-
找到函数 T(n) = n² +n 的 F(n),使得 T(n) = O(F(n))。
解法:使用 O 符号,因为,n²+ n ≤ 2n²,对于所有 n ≥ 1(c = 2,n[0]=2)
n²+ n ≤ O(n²)
F(n) = n²
-
证明 f(n) =2n³ - 6n ≠ O(n²)。
解法:显然,2n³-6n ≥ n²,对于 n ≥ 2. 所以,2n³ - 6n ≠ O(n²) 不成立。
-
证明:20n²+2n+5 = O(n²)。
解法:显然:
20n² + 2n + 5 <= 21n² 对于所有 n > 4(取 c = 21 和 n[0] = 4)
对于所有 n > 4,n² > 2n + 5。
因此,复杂度为 O(n²)。
因此,Big-O 符号提供了一个函数的上界,这确保了函数的增长速度永远不会超过上界函数。在下一节中,我们将讨论 Omega 符号。
Omega 符号
Omega 符号 (Ω) 描述了算法的渐近下界,类似于 Big O 符号描述上界的方式。Omega 符号计算算法的最佳运行时间复杂度。Ω 符号 (Ω(F(n)) 读作 omega of F of n),是一组函数,使得存在正的常数 n[0] 和 c,对于所有 n > n[0] 的值,T(n) 总是位于或高于函数 cF(n*)。
T(n) = Ω (F(n))
如果存在常数 n[0] 和 c,那么:

图 2.4 展示了 omega (Ω) 符号的图形表示。从图中可以观察到,对于 n > n[0] 的值,T(n) 总是位于 cF(n) 之上。

图 2.4:Ω 符号的图形表示
如果算法的运行时间是 Ω(F(n)),这意味着对于足够大的输入大小(n)的值,算法的运行时间至少是 F(n) 的一个常数倍。Ω 符号给出了给定算法最佳运行时间复杂度的下界。这意味着给定算法的运行时间至少是 F(n),而不依赖于输入。
为了理解 Omega 符号以及如何计算算法最佳运行时间复杂度的下界:
-
找到函数 T(n) =2n² +3 的 F(n),使得 T(n) = Ω(F(n)).
解法:使用 Omega 符号,下界条件为:
c*F(n) ≤ T(n)
找到函数 T(n) = n² +n 的 F(n),使得 T(n) = O(F(n))。
0 ≤ cn² ≤ 2n² +3, 对于所有 n ≥ 0
2n² +3 = Ω(n²)
F(n)=n²
-
找到 T(n) = 3n² 的下界。
解法:使用 Omega 符号,下界条件为:
c*F(n) ≤ T(n)
考虑 0 ≤ cn² ≤ 3n²。对于所有 n > 1 的值,以及 c=2,Ω 符号的条件成立。
cn² ≤ 3n² (对于 c = 2 和 n[0] = 1)
3n² = Ω(n²)
-
证明 3n = Ω(n)。
解法:使用 Omega 符号,下界条件为:
c*F(n) ≤ T(n)
考虑 0 ≤ c*n≤ 3n。对于所有 n > 1 的值,以及 c=1,Ω 符号的条件成立。
考虑 0 ≤ c*n≤ 3n。对于所有 n > 1 的值,以及 c=1,Ω 符号的条件成立。
3n = Ω(n)
Ω符号用于描述算法对于大输入大小至少需要一定量的运行时间。在下一节中,我们将讨论摊销分析。
摊销分析
在算法的摊销分析中,我们平均计算执行一系列操作所需的时间与算法中所有操作的时间。这被称为摊销分析。当我们对单个操作的时间复杂度不感兴趣,而关注一系列操作的平均运行时间时,摊销分析就很重要。在算法中,每个操作执行所需的时间是不同的。某些操作需要大量的时间和资源,而有些操作则几乎不耗费成本。在摊销分析中,我们考虑了成本高昂和成本较低的操作,以便分析所有操作序列。因此,摊销分析是考虑所有操作序列的完整成本的平均性能。摊销分析与平均情况分析不同,因为不考虑输入值的分布。摊销分析给出了每个操作在最坏情况下的平均性能。
摊销分析有三种常用的方法:
-
聚合分析。在聚合分析中,摊销成本是所有操作序列的平均成本。对于给定的 n 个操作序列,每个操作的摊销成本可以通过将 n 个操作的总成本的上界除以 n 来计算。
-
会计方法。在会计方法中,我们为每个操作分配一个摊销成本,这可能与它们的实际成本不同。在这里,我们对序列中的早期操作征收额外费用,并保存“信用成本”,这些成本用于支付序列中后面的昂贵操作。
-
势方法。势方法类似于会计方法。我们确定每个操作的摊销成本,并对可能在未来序列中使用的早期操作征收额外费用。与会计方法不同,势方法将超额收费的信用积累为数据结构整体的“势能”,而不是为单个操作存储信用。
在本节中,我们概述了摊销分析。现在我们将讨论如何通过下一节的示例计算不同函数的复杂性。
组成复杂度类
通常,我们需要找到复杂操作和算法的总运行时间。结果证明,我们可以通过组合简单操作的复杂度类来找到更复杂、组合操作的复杂度类。目标是分析函数或方法中的组合语句,以了解执行多个操作的总时间复杂度。将两个复杂度类组合的最简单方法是相加。这发生在我们有两个顺序操作时。例如,考虑将一个元素插入列表并排序该列表的两个操作。假设插入项的时间复杂度为 O(n),排序的时间复杂度为 O(nlogn),那么我们可以将总时间复杂度写为 O(n + nlogn);即,我们按照大 O 计算的规则,将两个函数放入 O(…)内。只考虑最高阶项,最终的复杂度变为 O(nlogn)。
如果我们重复一个操作,例如在while循环中,那么我们将复杂度类乘以操作执行的次数。如果一个具有时间复杂度 O(f(n))的操作重复 O(n)次,那么我们将两个复杂度相乘:O(f(n) * O(n)) = O(nf(n))。例如,假设函数f(n)的时间复杂度为 O(n²),并在for循环中执行n次,如下所示:
for i in range(n):
f(...)
上述代码的时间复杂度变为:
O(n²) x O(n) = O(n x n²) = O(n³)
在这里,我们正在将内函数的时间复杂度乘以该函数执行的次数。循环的运行时间最多是循环内语句的运行时间乘以迭代次数。一个单层嵌套循环,即一个循环嵌套在另一个循环中,将运行n²次,如下例所示:
for i in range(n):
for j in range(n)
#statements
如果每个语句的执行时间都是常数时间,即 O(1),执行n x n次,我们可以将运行时间表示如下:
c x n x n = c x n² = O(n²)
对于嵌套循环中的连续语句,我们将每个语句的时间复杂度相加,并乘以该语句执行的次数——例如,以下代码中的情况:
def fun(n):
for i in range(n): #executes n times
print(i) #c1
for i in range(n):
for j in range(n):
print(j) #c2
这可以写成:c¹n + c² **n² = O(n²)*。
我们可以定义(以 2 为底)对数复杂度,通过在常数时间内减半问题的大小。例如,考虑以下代码片段:
i = 1
while i <= n:
i = i*2
print(i)
注意到i在每次迭代中都在翻倍。如果我们用 n = 10 运行此代码,我们会看到它打印出四个数字:2、4、8 和 16。如果我们加倍 n,我们会看到它打印出五个数字。随着 n 的后续翻倍,迭代次数仅增加 1。如果我们假设循环有 k 次迭代,那么 n 的值将是 2^n。我们可以这样写:



从这个结果来看,上述代码的最坏情况运行时间复杂度等于 O(log(n))。
在本节中,我们看到了计算不同函数的运行时间复杂度的例子。在下一节中,我们将通过例子了解如何计算算法的运行时间复杂度。
计算算法的运行时间复杂度
要根据算法的最佳、最坏和平均运行时间来分析算法,并不总是可能为每个给定的函数或算法计算这些值。然而,在实际情况中,了解算法的上界最坏运行时间复杂度总是很重要的;因此,我们专注于计算上界大 O 符号以计算算法的最坏运行时间复杂度:
-
找出以下 Python 段落的最坏运行时间复杂度:
# loop will run n times for i in range(n): print("data") #constant time解答:一般来说,循环的运行时间等于循环中所有语句的执行时间乘以迭代次数。在这里,总运行时间定义为以下:
T(n) = 常数时间 (c) * n = c*n = O(n)
-
找出以下 Python 段落的复杂度:
for i in range(n): for j in range(n): # This loop will also run for n times print("run")解答:O(n²)。
print语句将被执行 n² 次,内循环执行 n 次,对于外循环的每一次迭代,内循环都会被执行。 -
找出以下 Python 段落的复杂度:
for i in range(n): for j in range(n): print("run fun") break解答:最坏情况下的复杂度将是 O(n),因为
print语句将运行 n 次,因为内循环由于break语句只执行一次。 -
找出以下 Python 段落的复杂度:
def fun(n): for i in range(n): print("data") #constant time #outer loop execute for n times for i in range(n): for j in range(n): #inner loop execute n times print("run fun") #constant time解答:在这里,
print语句将在第一个循环中执行 n 次,在第二个嵌套循环中将执行 n² 次。这里,所需的总时间定义为以下:T(n) = 常数时间 (c[1]) * n + c[2]nn
c[1] n + c[2] n² = O(n²)
-
找出以下 Python 段落的复杂度:
if n == 0: #constant time print("data") else: for i in range(n): #loop run for n times print("structure")解答:O(n)。在这里,最坏情况下的运行时间复杂度将是执行所有语句所需的时间;即执行
if-else条件和for循环所需的时间。所需的时间定义为以下:T(n) = c[1] + c[2] n = O(n)
-
找出以下 Python 段落的复杂度:
i = 1 j = 0 while i*i < n: j = j +1 i = i+1 print("data")解答:O(
)。循环将根据 i的值终止;循环将根据以下条件迭代:![]()
T(n) = O(
) -
找出以下 Python 段落的复杂度:
i = 0 for i in range(int(n/2), n): j = 1 while j+n/2 <= n: k = 1 while k < n: k *= 2 print("data") j += 1解答:在这里,外循环将执行 n/2 次,中间循环也将执行 n/2 次,最内层循环将运行 log(n) 时间。因此,总的运行时间复杂度将是 O(nnlogn):
O(n²logn)
概述
在本章中,我们概述了算法设计。算法的研究很重要,因为它训练我们非常具体地思考某些问题。通过隔离问题的组成部分并定义它们之间的关系,这有助于提高我们的解决问题的能力。在本章中,我们讨论了分析算法和比较算法的不同方法。我们还讨论了渐近符号,即:大 O、Ω和θ符号。
在下一章中,我们将讨论算法设计技术和策略。
练习
-
找出以下 Python 碎片的复杂度:
-
i=1 while(i<n): i*=2 print("data") -
i =n while(i>0): print('complexity') i/ = 2 -
for i in range(1,n): j = i while(j<n): j*=2 -
i=1 while(i<n): print('python') i = i**2
加入我们的 Discord 社区
加入我们的社区 Discord 空间,与作者和其他读者进行讨论:packt.link/MEvK4

第三章:算法设计技术与策略
在计算领域,算法设计对于 IT 专业人士来说非常重要,因为它能提高他们的技能并促进行业的发展。算法设计过程从大量的实际计算问题开始,这些问题必须被明确地表述出来,以便使用算法设计技术中可能的技术之一来有效地构建解决方案。算法的世界包含了许多技术和设计原则,掌握这些是解决该领域更困难问题所必需的。在计算机科学中,算法设计对于高效地设计精确表述问题的解决方案非常重要,因为一个非常复杂和困难的问题可以通过适当的算法设计技术轻松解决。
在本章中,我们将讨论不同类型的算法如何被分类。我们将描述并展示设计技术,并进一步讨论算法分析。最后,我们将为几个非常重要的算法提供详细的实现。
在本章中,我们将探讨以下算法设计技术:
-
分而治之
-
动态规划
-
贪心算法
算法设计技术
算法设计是观察和清晰地理解良好设定的、现实世界问题的强大工具。对于许多问题,有一种简单而有效的方法,即暴力方法。暴力方法试图尝试所有可能的解决方案组合来解决任何问题。例如,假设一位销售人员必须访问全国 10 个城市。为了使总行程距离最小化,这些城市应该按什么顺序访问?解决这个问题的暴力方法将是计算所有可能路线的总距离,然后选择提供最小距离的路线。
如你所猜,暴力算法并不高效。
它可以为有限的输入大小提供有用的解决方案,但当输入大小变得很大时,它变得非常低效。因此,我们将过程分解为两个基本组件,以找到计算问题的最优解:
-
明确表述问题
-
根据问题的结构选择合适的算法设计技术以获得高效的解决方案
正因如此,在开发可扩展和健壮的系统时,算法设计的研究变得非常重要。设计和分析首先很重要,因为它们有助于开发组织良好且易于理解的算法。设计技术指南也有助于轻松地为复杂问题开发新算法。此外,设计技术还可以用来对算法进行分类,这也有助于更好地理解它们。以下是一些算法范式:
-
递归
-
分而治之
-
动态规划
-
贪心算法
由于在讨论不同的算法设计技术时我们将多次使用递归,让我们首先了解递归的概念,然后我们将讨论不同的算法设计技术。
递归
递归算法通过重复调用自身来解决问题,直到满足某个条件。每个递归调用本身会产生其他递归调用。递归函数可能陷入无限循环;因此,每个递归函数都必须遵循某些属性。递归函数的核心是两种类型的案例:
-
基本情况:这些告诉递归何时终止,意味着一旦满足基本条件,递归就会停止
-
递归情况:函数递归调用自身,我们朝着实现基本情况前进
一个自然适合递归解决方案的简单问题就是计算阶乘。递归阶乘算法定义了两种情况:当 n 为零时的基本情况(终止条件)和当 n 大于零时的递归情况(函数自身的调用)。一个典型的实现如下:
def factorial(n):
# test for a base case
if n == 0:
return 1
else:
# make a calculation and a recursive call
return n*factorial(n-1)
print(factorial(4))
这会产生以下输出:
24
要计算 4 的阶乘,我们需要进行四次递归调用,加上初始的父调用,如图 3.1所示。这些递归调用的工作细节如下。最初,数字 4 被传递给阶乘函数,它将返回 4 乘以 (4-1=3) 的阶乘的值。为此,数字 3 再次传递给阶乘函数,它将返回 3 乘以 (3-1=2) 的阶乘的值。同样,在下一个迭代中,值 2 乘以 (2-1 =1) 的阶乘。
这将继续,直到我们达到 0 的阶乘,它返回 1。现在,每个函数都返回值,最终计算 1*1*2*3*4=24,这是函数的最终输出。

图 3.1:阶乘 4 的执行流程
我们讨论了递归的概念,这在理解不同算法范式的实现中将非常有用。因此,现在让我们依次讨论不同的算法设计策略,从下一节的分而治之技术开始。
分而治之
解决复杂问题的重要且有效技术之一是分而治之。分而治之范式将问题分解成更小的子问题,然后解决这些子问题;最后,它将这些结果合并以获得全局、最优的解决方案。更具体地说,在分而治之设计中,问题被分解成两个更小的子问题,每个子问题都是递归解决的。部分解决方案被合并以获得最终解决方案。这是一种非常常见的问题解决技术,可以说是算法设计中最常用的方法之一。
分而治之设计技术的以下是一些示例:
-
二分搜索
-
归并排序
-
快速排序
-
快速乘法算法
-
Strassen 矩阵乘法
-
点对最近距离
让我们看看两个例子,二分搜索和归并排序算法,以了解分而治之设计技术是如何工作的。
二分搜索
二分搜索算法基于分而治之设计技术。此算法用于从有序元素列表中查找给定元素。它首先将搜索元素与列表的中间元素进行比较;如果搜索元素小于中间元素,则丢弃大于中间元素的元素列表的一半;这个过程递归地重复,直到找到搜索元素或达到列表的末尾。重要的是要注意,在每次迭代中,搜索空间的一半被丢弃,这提高了整体算法的性能,因为要搜索的元素更少。
以图 3.2中所示为例;假设我们想在给定的有序元素列表中搜索元素 4。列表在每次迭代中都被分成两半;使用分而治之的策略,元素被搜索O(logn)次。

图 3.2:使用二分搜索算法搜索元素的过程
在这里展示了在有序元素列表中搜索元素的 Python 代码:
def binary_search(arr, start, end, key):
while start <= end:
mid = start + (end - start)/2
if arr[mid] == key:
return mid
elif arr[mid] < key:
start = mid + 1
else:
end = mid - 1
return -1
arr = [4, 6, 9, 13, 14, 18, 21, 24, 38]
x = 13
result = binary_search(arr, 0, len(arr)-1, x)
print(result)
当我们在给定的元素列表中搜索13时,前面代码的输出是3,这是搜索项的位置。
在代码中,最初,起始和结束索引给出了输入数组[4, 6, 9, 13, 14, 18, 21, 24, 38]的第一个和最后一个索引的位置。存储在变量key中的要搜索的项首先与数组的中间元素匹配,然后我们丢弃列表的一半,并在列表的另一半中搜索该项。这个过程重复迭代,直到找到要搜索的项,或者达到列表的末尾,并且我们没有找到元素。
在分析二分查找算法在最坏情况下的工作原理时,我们可以看到,对于一个包含 8 个元素的数组,在第一次不成功的尝试之后,列表被分成两半,然后在不成功的搜索尝试之后,列表长度为 2,最后只剩下一个元素。因此,二分查找需要 4 次搜索。如果列表的大小加倍,也就是说,到 16,在第一次不成功的搜索之后,我们将有一个大小为 8 的列表,这将总共需要 4 次搜索。因此,二分查找算法将需要 5 次搜索来处理 16 个元素的列表。因此,我们可以观察到,当我们加倍列表中的项目数量时,所需的搜索次数也增加 1。我们可以这样说,当我们有一个长度为 n 的列表时,所需的搜索总数将是我们将列表分成两半的次数加上 1,这在数学上等同于(log[2] n + 1)。例如,如果 n=8,输出将是 3,这意味着所需的搜索次数将是 4。在每次迭代中,列表被分成一半;使用分治策略,二分查找算法的最坏情况时间复杂度是O(log n)。
归并排序是基于分治设计策略的另一种流行算法。我们将在下一节更详细地讨论归并排序。
归并排序
归并排序是一种用于按升序对自然数列表进行排序的算法。首先,给定的元素列表通过迭代方式被分成相等的部分,直到每个子列表只包含一个元素,然后这些子列表被合并以创建一个新的有序列表。这种编程方法基于分治法,强调将问题分解成与原始问题相同类型或形式的更小的子问题。这些子问题被单独解决,然后将结果合并以获得原始问题的解决方案。
在这种情况下,给定一个未排序的元素列表,我们将列表分成两个大致相等的部分。我们继续递归地将列表分成两半。
经过一段时间后,由递归调用创建的子列表将只包含一个元素。在那个时刻,我们开始合并征服或合并步骤中的解决方案。这个过程在图 3.3中展示:

图 3.3:归并排序算法概述
归并排序算法的实现主要使用两种方法,即merge_sort方法,它递归地划分列表。之后,我们将介绍merge方法来合并结果:
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 = len(unsorted_list)//2找到大约的中间值。
使用这个mid_point,我们将列表分为两个子列表,即first_half和second_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_a和half_b被排序。当half_a和half_b的值传递完毕后,我们调用merge函数,该函数将合并或组合存储在half_a和half_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_sublist和second_sublist。i和j变量被初始化为 0,并用作指针,告诉我们两个列表在合并过程中的位置。
最终的merged_list将包含合并后的列表。
while循环开始比较first_sublist和second_sublist中的元素:
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
if语句选择两个子列表中较小的一个,即first_sublist[i]或second_sublist[j],并将其追加到merged_list中。i或j索引增加以反映合并步骤中的位置。while循环在任一子列表为空时停止。
可能会在first_sublist或second_sublist中留下元素。最后的两个while循环确保在返回merged_list之前将这些元素添加到其中。对merge(half_a, half_b)的最后调用将返回排序后的列表。以下代码显示了如何使用归并排序对数组进行排序:
a= [11, 12, 7, 41, 61, 13, 16, 14]
print(merge_sort(a))
输出结果将是:
[7, 11, 12, 14, 16, 41, 61]
让我们通过合并两个子列表[4, 6, 8]和[5, 7, 11, 40]来对算法进行实际操作,如表 3.1所示。在这个例子中,最初给出两个已排序的子列表,然后匹配起始元素,由于第一个列表的第一个元素较小,它被移动到merge_list中。接下来,在步骤 2中,再次从两个列表中匹配起始元素,较小的元素(来自第二个列表)被移动到merge_list中。这个过程会重复进行,直到其中一个列表为空。
| 步骤 | 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] |
表 3.1:合并两个列表的示例
此过程也可以在图 3.4中看到:

图 3.4:合并两个子列表的过程
在其中一个列表变为空之后,比如在这个例子中的 步骤 4 之后,在执行的这个点上,merge 函数中的第三个 while 循环开始工作,将 11 和 40 移动到 merged_list 中。返回的 merged_list 将包含完全排序的列表。
归并排序的最坏情况运行时间复杂度将取决于以下步骤:
-
首先,分割步骤将花费常数时间,因为它只是计算中点,这可以在 O(1) 时间内完成。
-
然后,在每次迭代中,我们将列表递归地分成一半,这将需要 O(log n) 的时间复杂度,这与我们在二分查找算法中看到的情况非常相似。
-
此外,合并/合并步骤将所有 n 个元素合并到原始数组中,这将需要 (n) 的时间。
因此,归并排序算法的运行时间复杂度为 O(log n) T(n) = O(n) * O(log n) = O(n log n)。
我们已经通过几个例子讨论了分而治之算法设计技术。在下一节中,我们将讨论另一种算法设计技术:动态规划。
动态规划
动态规划是解决优化问题最强大的设计技术。这类问题通常有多个可能的解决方案。动态规划的基本思想基于分而治之技术的直觉。在这里,我们本质上通过将问题分解成一系列子问题,然后结合结果来计算大问题的正确解,来探索所有可能解决方案的空间。分而治之算法通过组合非重叠(不相交)子问题的解来解决一个问题,而动态规划用于子问题重叠的情况,这意味着子问题共享子子问题。动态规划技术与分而治之类似,因为问题被分解成更小的问题。然而,在分而治之中,必须先解决每个子问题,然后才能使用其结果来解决更大的问题。相比之下,基于动态规划的技术只解决每个子子问题一次,并且不会重新计算已遇到的子问题的解。相反,它使用记忆技术来避免重新计算。
动态规划问题有两个重要的特性:
-
最优子结构:对于任何问题,如果可以通过组合其子问题的解来获得解决方案,则称该问题具有最优子结构。换句话说,最优子结构意味着问题的最优解可以从其子问题的最优解中获得。例如,可以从 (i-1)^(th) 和 (i-2)^(th) 斐波那契数来计算 i^(th) 斐波那契数;例如,fib(6) 可以从 fib(5) 和 fib(4) 计算得出。
-
重叠子问题:如果一个算法必须反复解决相同的子问题,那么这个问题就有重叠的子问题。例如,fib(5)将为 fib(3)和 fib(2)进行多次计算。
如果一个问题具有这些特性,那么动态规划方法是有用的,因为可以通过重用之前计算出的相同解决方案来改进实现。在动态规划策略中,问题被分解为独立的子问题,中间结果被缓存,然后可以在后续操作中使用。
在动态方法中,我们将给定问题划分为更小的子问题。在递归中,我们也将问题划分为子问题。然而,递归和动态编程之间的区别在于,相似的子问题可以解决任意次数,但在动态编程中,我们跟踪先前解决的子问题,并注意不要重新计算任何先前遇到的子问题。一个问题成为动态编程解决理想候选者的一个特性是它有一个重叠的子问题集。一旦我们意识到在计算过程中子问题的形式已经重复,我们就不需要再次计算它。相反,我们返回先前遇到的子问题的预先计算结果。
动态规划考虑了每个子问题只需解决一次的事实,并且为了确保我们不会重新评估子问题,我们需要一种有效的方式来存储每个子问题的结果。以下两种技术是现成的:
- 自顶向下带记忆化:这种技术从初始问题集开始,将其划分为小子问题。一旦确定了子程序的解决方案,我们就存储该特定子问题的结果。在将来,当遇到这个子问题时,我们只返回其预先计算的结果。因此,如果给定问题的解决方案可以使用子问题的解决方案递归地表示,那么重叠子问题的解决方案可以很容易地记忆化。
记忆化意味着将子问题的解决方案存储在数组或哈希表中。每当需要计算子问题的解决方案时,首先参考已保存的值,如果它已经被计算,如果没有存储,则按常规方式计算。这个过程被称为记忆化,这意味着它“记得”之前计算的操作的结果。
- 自底向上的方法:这种方法依赖于子问题的“大小”。我们首先解决较小的子问题,然后在解决特定子问题时,我们已经有了依赖于它的较小子问题的解决方案。每个子问题只解决一次,并且每次我们尝试解决任何子问题时,所有先决的较小子问题的解决方案都是可用的,可以用来解决它。在这种方法中,给定的问题通过将其递归地分解为子问题来解决,然后解决可能的最小子问题。此外,子问题的解决方案以自底向上的方式组合,以便递归地达到较大子问题的解决方案,从而最终达到最终解决方案。
让我们通过一个例子来了解动态规划是如何工作的。让我们使用动态规划来解决斐波那契数列的问题。
计算斐波那契数列
斐波那契数列可以用递归关系来表示。递归关系是递归函数,用于定义数学函数或序列。例如,以下递归关系定义了斐波那契数列 [1, 1, 2, 3, 5, 8 ...]:
func(0) = 1
func(1) = 1
func(n) = func(n-1) + func(n-2) for n>1
注意,斐波那契数列可以通过将 n 的值按顺序放入 [0, 1, 2, 3, 4, ...] 来生成。让我们举一个例子来生成到第五项的斐波那契数列:
1 1 2 3 5
以下是一个递归风格的程序,用于生成序列:
def fib(n):
if n <= 1:
return 1
else:
return fib(n-1) + fib(n-2)
for i in range(5):
print(fib(i))
这将产生如下输出:
1
1
2
3
5
在此代码中,我们可以看到递归调用是按顺序调用来解决问题的。当遇到基本案例时,fib() 函数返回 1。如果 n 等于或小于 1,则满足基本案例。如果基本案例不满足,我们再次调用 fib() 函数。解决斐波那契数列前五项的递归树如图 3.5 所示:

图 3.5:fib(5) 的递归树
从如图 3.6 所示的递归树中的重叠子问题中,我们可以观察到对 fib(1) 的调用发生两次,对 fib(2) 的调用发生三次,对 fib(3) 的调用发生两次。相同函数调用的返回值永远不会改变;例如,fib(2) 的返回值将始终相同,无论何时调用它。同样,fib(1) 和 fib(3) 也将如此。因此,它们是重叠问题,因此,如果每次遇到相同的函数时都重新计算,将会浪费计算时间。这些具有相同参数和输出的重复函数调用表明存在重叠。某些计算在较小的子问题中重复发生。

图 3.6:递归树中显示的重叠子问题 fib(5)
在使用记忆化技术的动态规划中,我们首次遇到fib(1)时存储其计算结果。同样,我们存储fib(2)和fib(3)的返回值。以后,无论何时遇到对fib(1)、fib(2)或fib(3)的调用,我们只需返回它们各自的结果。递归树图如图 3.7 所示:

图 3.7:显示已计算值重用的 fib(5)递归树
因此,在动态规划中,我们消除了在遇到多次时重新计算fib(3)、fib(2)和fib(1)的需求。这被称为记忆化技术,其中在将问题分解为其子问题时,没有对函数重叠调用的重新计算。
因此,在我们的斐波那契示例中,重叠的函数调用是fib(1)、fib(2)和fib(3)。以下是基于动态规划的斐波那契数列实现的代码:
def dyna_fib(n):
if n == 0:
return 0
if n == 1:
return 1
if lookup[n] is not None:
return lookup[n]
lookup[n] = dyna_fib(n-1) + dyna_fib(n-2)
return lookup[n]
lookup = [None]*(1000)
for i in range(6):
print(dyna_fib(i))
这将产生如下输出:
0
1
1
2
3
5
在斐波那契数列的动态实现中,我们将先前解决的子问题的结果存储在一个列表中(换句话说,在这个示例代码中是一个查找)。我们首先检查任何数字的斐波那契数是否已经计算过;如果是,则从lookup[n]返回存储的值。否则,当我们计算其值时,是通过以下代码完成的:
if lookup[n] is not None:
return lookup[n]
在计算子问题的解决方案后,它再次存储在查找列表中。给定的斐波那契数值如以下代码片段所示返回:
lookup[n] = dyna_fib(n-1) + dyna_fib(n-2)
此外,为了存储 1,000 个元素的列表,我们使用dyna_fib函数创建一个列表查找:
lookup = [None]*(1000)
因此,在基于动态规划的解决方案中,我们使用预先计算的结果来计算最终结果。
动态规划提高了算法的运行时间复杂度。在递归方法中,对于每个值,都会调用两个函数;例如,fib(5)调用fib(4)和fib(3),然后fib(4)调用fib(3)和fib(2),依此类推。因此,递归方法的复杂度是 O(2^n),而在动态规划方法中,我们不重新计算子问题,所以对于fib(n),我们有总共n个值需要计算,换句话说,fib(0)、fib(1)、fib(2)… fib(n)。因此,我们只解决这些值一次,所以总体的运行时间复杂度是 O(n)。因此,动态规划通常提高了性能。
在本节中,我们讨论了动态规划设计技术,在下一节中,我们将讨论贪心算法的设计技术。
贪心算法
贪婪算法通常涉及优化和组合问题。在贪婪算法中,目标是获得每一步中许多可能解决方案中的最佳解决方案。我们试图获得局部最优解,这最终可能使我们获得全局最优解。贪婪策略并不总是产生最优解。然而,局部最优解的序列通常近似于全局最优解。
例如,假设你被给出一些随机数字,比如1、4、2、6、9和5。现在你必须使用所有这些数字,不重复任何数字,来组成最大的数字。要使用贪婪策略从给定的数字中创建最大的数字,我们执行以下步骤。首先,我们从给定的数字中选择最大的数字,并将其附加到数字上,然后从列表中删除该数字,直到列表中没有剩余的数字。一旦所有数字都被使用,我们就得到了可以使用这些数字组成的最大数字:965421。该问题的逐步解决方案如图 3.8所示:


图 3.8:贪婪算法的示例
让我们考虑另一个例子,以更好地理解贪婪方法。比如说,你必须以最少的纸币数量给某人 29 印度卢比,一次给一张纸币,但不要超过欠款金额。假设我们有面额为 1、2、5、10、20 和 50 的纸币。要使用贪婪方法解决这个问题,我们将首先交付 20 卢比的纸币,然后对于剩余的 9 卢比,我们将给一张 5 卢比的纸币;对于剩余的 4 卢比,我们将给一张 2 卢比的纸币,然后另一张 2 卢比的纸币。
在这种方法中,在每一步,我们选择最佳可能的解决方案,并给出最大的可用纸币。假设,对于这个例子,我们必须使用面额为 1、14 和 25 的纸币。然后,使用贪婪算法,我们将选择 25 卢比的纸币,然后是四张 1 卢比的纸币,这样总共就有 5 张纸币。然而,这并不是可能的最小纸币数量。更好的解决方案是给出 14、14 和 1 的纸币。因此,这也清楚地表明,贪婪算法并不总是给出最佳解决方案,但是一个可行且简单的解决方案。
经典的例子是将贪婪算法应用于旅行商问题,其中贪婪算法总是首先选择最近的目的地。在这个问题中,贪婪算法总是选择与当前城市相关的最近未访问城市;这样,我们无法确定我们是否会得到最佳解决方案,但我们确实得到了一个最优解决方案。这种最短路径策略涉及找到局部问题的最佳解决方案,希望这能导致全局解决方案。
列出许多流行的标准问题,在这些问题中,我们可以使用贪婪算法来获得最佳解决方案:
-
Kruskal 的最小生成树
-
Dijkstra 的最短路径问题
-
背包问题
-
Prim 的最小生成树算法
-
旅行商问题
让我们讨论一个流行的问题,换句话说,最短路径问题,该问题可以使用贪婪方法解决,我们将在下一节中进行讨论。
最短路径问题
最短路径问题要求我们找出图中节点之间可能的最短路径。Dijkstra 算法是解决此问题的一种非常流行的贪婪方法。该算法用于在图中找到从源节点到目标节点的最短距离。
Dijkstra 算法适用于加权有向和无向图。该算法从给定的源节点 A 在加权图中生成从源节点到所有其他节点的最短路径列表。算法的工作原理如下:
-
初始时,将所有节点标记为未访问,并将它们从给定源节点的距离设置为无穷大(源节点设置为 0)。
-
将源节点设置为当前节点。
-
对于当前节点,查找所有未访问的相邻节点,并计算从源节点通过当前节点到该节点的距离。将新计算的距离与当前分配的距离进行比较,如果它更小,则将其设置为新的值。
一旦我们考虑了当前节点的所有未访问的相邻节点,我们就将其标记为已访问。
如果目标节点已被标记为已访问,或者未访问节点的列表为空,这意味着我们已经考虑了所有未访问的节点,那么算法就完成了。
我们接下来考虑下一个未访问的节点,该节点与源节点的距离最短。重复步骤 2到6。
考虑图 3.9中的加权图示例,该图有六个节点[A、B、C、D、E 和 F],以了解 Dijkstra 算法的工作原理。

图 3.9:具有六个节点的示例加权图
通过手动检查,节点A和D之间的最短路径最初看起来是距离为 9 的直接线路。然而,最短路径意味着最低的总距离,即使这包括几个部分。通过比较,从节点A到E,然后从E到F,最后到D的总距离将是 7,这使得它成为更短的路径。
我们将使用单源实现最短路径算法。它将确定从起点到图中任何其他节点的最短路径,在本例中是A。在第九章,图和其他算法中,我们将讨论如何使用邻接表表示图。我们使用邻接表以及每条边的权重/成本/距离来表示图,如下面的 Python 代码所示。该图和表的邻接表如下:
`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}`
在视觉演示之后,我们将返回到其余的代码,但不要忘记声明图以确保代码正确运行。
嵌套字典包含距离和相邻节点。使用表格来跟踪图中从源节点到任何其他节点的最短距离。表 3.2 是起始表格:
| 节点 | 从源节点到最短距离 | 前一个节点 |
|---|---|---|
| A | 0 | 无 |
| B | ![]() |
无 |
| C | ![]() |
无 |
| D | ![]() |
无 |
| E | ![]() |
无 |
| F | ![]() |
无 |
表 3.2:显示从源节点到最短距离的初始表格
当算法开始时,从给定的源节点(A)到任何其他节点的最短距离是未知的。因此,我们最初将所有其他节点的距离设置为无穷大,除了节点 A,因为节点 A 到节点 A 的距离是 0。算法开始时没有访问任何前一个节点。因此,我们将节点 A 的前一个节点列标记为 无。
在算法的 步骤 1 中,我们首先检查节点 A 的相邻节点。为了找到从节点 A 到节点 B 的最短距离,我们需要找到从起始节点到节点 B 的前一个节点的距离,这个前一个节点恰好是 A,然后将其加到从节点 A 到节点 B 的距离上。我们为 A 的其他相邻节点做同样的事情,这些节点是 B、E 和 D。这如图 3.10 所示:

图 3.10:Dijkstra 算法的示例图
首先,我们选择相邻节点 E,因为从节点 A 到它的距离是最小的;从起始节点(A)到前一个节点(无)的距离是 0,从前一个节点到当前节点(E)的距离是 2。
这个总和与节点 E 的最短距离列中的数据进行比较(参见 表 3.3)。由于 2 小于无穷大(
),我们将
替换为两个数中的较小者,也就是说,2。同样,从节点 A 到节点 B 和 D 的距离与从节点 A 到这些节点的现有最短距离进行比较。每当一个节点的最短距离被替换为更小的值时,我们需要更新当前节点的所有相邻节点的前一个节点列。
然后,我们将节点 A 标记为已访问(在 图 3.11 中用蓝色表示):

图 3.11:使用 Dijkstra 算法访问节点 A 后的最短距离图
在 步骤 1 的末尾,表格看起来像 表 3.3 中所示的那样,其中节点 A 到节点 B、D 和 E 的最短距离已更新。
| 节点 | 从源节点到最短距离 | 前一个节点 |
|---|---|---|
| A | 0 | 无 |
| B | 5 | A |
| C | ![]() |
无 |
| D | 9 | A |
| E | 2 | A |
| F | ![]() |
无 |
表 3.3:访问节点 A 后的最短距离表
在这一点上,节点A被认为是已访问的。因此,我们将节点A添加到已访问节点的列表中。在表格中,我们通过在其旁边添加星号来表示节点A已被访问。
在第二步,我们使用表 3.3作为指南,找到最短距离的节点。节点E,其值为 2,具有最短距离。要到达节点E,我们必须访问节点A并覆盖2的距离。
现在,节点E的相邻节点是节点A和F。由于节点A已经被访问,我们只考虑节点F。为了找到到节点F的最短路径或距离,我们必须找到从起始节点到节点E的距离,并将其添加到节点E和F之间的距离。我们可以通过查看节点E的最短距离列来找到从起始节点到节点E的距离,其值为2。从邻接表中可以获得节点E到F的距离,即3。这两个值加起来是 5,小于无穷大。记住,我们正在检查相邻节点F。由于节点E没有更多的相邻节点,我们标记节点E为已访问。我们的更新表格和图将具有以下值,如表 3.4和图 3.12所示:
| 节点 | 从源节点到最短距离 | 前一个节点 |
|---|---|---|
| A* | 0 | 无 |
| B | 5 | A |
| C | ![]() |
无 |
| D | 9 | A |
| E* | 2 | A |
| F | 5 | E |
表 3.4:访问节点 E 后的最短距离表

图 3.12:使用 Dijkstra 算法访问节点 E 后的最短距离图
访问节点E后,我们在表 3.4的最短距离列中找到最小的值,对于节点B和F,这个值是 5。出于字母顺序的原因,我们选择B而不是F。B的相邻节点是节点A和C,因为节点A已经被访问。使用我们之前建立的规则,从A到C的最短距离是 7,这是从起始节点到节点B的距离,即 5,而节点B到C的距离是 2。由于 7 小于无穷大,我们在表 3.4中更新最短距离为 7,并更新前一个节点列为节点B。
现在,B也被标记为已访问(在图 3.13中以蓝色表示)。
| 节点 | 从源节点到最短距离 | 前一个节点 |
|---|---|---|
| A* | 0 | 无 |
| B* | 5 | A |
| C | 7 | B |
| D | 9 | A |
| E* | 2 | A |
| F | 5 | E |
表 3.5:访问节点 B 后的最短距离表
表格的新状态如下,在表 3.5中:
图 3.13:使用 Dijkstra 算法访问节点 B 后的最短距离图
尚未访问的最短距离节点是节点F。F的相邻节点是节点D和E。由于节点E已经访问过,我们将重点关注节点D。为了找到从起始节点到节点D的最短距离,我们通过将节点A到F的距离与节点F到D的距离相加来计算这个距离。总共是 7,这小于9。因此,我们将9更新为7,并在表 3.5中节点D的前一个节点列中将A替换为F。
节点F现在被标记为已访问(在图 3.14中以蓝色表示)。

图 3.14:使用 Dijkstra 算法访问节点 F 后的最短距离图
这里是更新后的表格,如表 3.6所示:
| 节点 | 从源到最短距离 | 前一个节点 |
|---|---|---|
| A* | 0 | 无 |
| B* | 5 | A |
| C | 7 | B |
| D | 7 | F |
| E* | 2 | A |
| F* | 5 | E |
表 3.6:访问节点 F 后的最短距离表
现在,只剩下两个未访问的节点,C和D,它们的距离成本都是7。按字母顺序排列,我们选择考虑节点C,因为这两个节点从起始节点A的最短距离相同。
然而,C的所有相邻节点都已访问(在图 3.15中以蓝色表示)。因此,我们除了将节点C标记为已访问外,别无他法。此时,表格保持不变。

图 3.15:使用 Dijkstra 算法访问节点 C 后的最短距离图
最后,我们选择节点D,并发现它的所有相邻节点也已访问。我们只将其标记为已访问(在图 3.16中以蓝色表示)。

图 3.16:使用 Dijkstra 算法访问节点 D 后的最短距离图
表格保持不变,如表 3.7所示:
| 节点 | 从源到最短距离 | 前一个节点 |
|---|---|---|
| A* | 0 | 无 |
| B* | 5 | A |
| C* | 7 | B |
| D* | 7 | F |
| E* | 2 | A |
| F* | 5 | E |
表 3.7:访问节点 F 后的最短距离表
让我们用我们的初始图来验证表 3.7。从图中,我们知道从A到F的最短距离是5。
根据表格,从源列到节点F的最短距离是 5。这是正确的。它还告诉我们,要到达节点F,我们需要访问节点E,然后从E到我们的起始节点A。这实际上是节点A到节点F的最短路径。
现在,我们将讨论使用 Python 实现 Dijkstra 算法寻找最短路径。我们通过表示允许我们跟踪图变化的表来开始寻找最短距离的程序。对于我们在本节中使用的初始图 3.8,以下是表的字典表示,以伴随我们之前在节中展示的图表示:
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引用前一个节点的列索引。
首先,我们讨论在实现寻找最短路径的主要函数find_shortest_path时将使用的辅助方法。换句话说,第一个辅助方法是get_shortest_distance,它返回从源节点到节点的最短距离:
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。在表中,我们在table[vertex][PREVIOUS_NODE]存储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作为未访问的顶点或从源节点到具有最小最短距离的节点。
现在所有设置都已就绪,算法的主要函数,即find_shortest_path,如下所示:
def find_shortest_path(graph, table, origin):
visited_nodes = []
current_node = origin
starting_node = origin
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)
#print(visited_nodes)
if len(visited_nodes) == len(table.keys()):
break
current_node = get_next_node(table,visited_nodes)
return (table)
在前面的代码中,该函数将图(由邻接表表示)、表和起始节点作为输入参数。我们保持visited_nodes列表中的已访问节点列表。current_node和starting_node变量都指向图中我们选择的起始节点。origin值是相对于寻找最短路径的所有其他节点的参考点。
函数的主要过程是通过 while 循环实现的。让我们分解一下 while 循环正在做什么。在 while 循环体中,我们考虑图中我们想要调查的当前节点,并最初通过 adjacent_nodes = graph[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 方法是我们使用的另一个辅助方法,用于获取 vertex 和 current_node 之间边的值(距离)。如果条件失败,则将 total_distance 赋值为从起始节点到 current_node 的距离加上 current_node 和 vertex 之间的距离。
一旦我们有了总距离,我们需要检查 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']
Dijkstra 算法的运行时间复杂度取决于顶点的存储和检索方式。通常,最小优先队列用于存储图的顶点,因此,Dijkstra 算法的时间复杂度取决于最小优先队列的实现方式。
在第一种情况下,顶点按编号从 1 到|V|存储在数组中。在这里,从整个数组中搜索顶点的每个操作将花费 O(V)时间,使得总时间复杂度为 O(V²) + O(V² + E) = O(V²)。此外,如果使用斐波那契堆实现最小优先队列,则循环的每次迭代和提取最小节点所需的时间为 O(|V|)时间。进一步地,遍历所有顶点的相邻节点并更新最短距离需要 O(|E|)时间,每次优先值更新需要 O(log|V|)时间,这使得 O(|E| + log|V|)。因此,算法的总运行时间复杂度为 O(|E| + |V|log |V|),其中|V|是顶点的数量,|E|是边的数量。
摘要
算法设计技术在制定、理解和开发复杂问题的最优解方面非常重要。在本章中,我们讨论了算法设计技术,这在计算机科学领域非常重要。我们详细讨论了重要的算法设计类别,如动态规划、贪心算法和分而治之,并附上了重要算法的实现。
动态规划和分而治之技术在某种程度上非常相似,因为它们都是通过组合子问题的解来解决更大的问题。在这里,分而治之技术将问题划分为不相交的子问题,递归地解决它们,然后将子问题的解组合起来以获得原始问题的解,而在动态规划中,当子问题重叠时采用这种技术,以避免对相同子问题的重复计算。此外,在基于贪心算法的算法设计技术中,在算法的每一步中,都会选择看起来可能达到解的最佳选择。
在下一章中,我们将讨论重要的数据结构,例如链表和指针结构。
练习
-
当应用自顶向下的动态规划方法来解决与空间和时间复杂度相关的问题时,以下哪个选项将是正确的?
-
它将同时增加时间和空间复杂度。
-
它将增加时间复杂度,并减少空间复杂度
-
它将增加空间复杂度,并减少时间复杂度
-
它将同时减少时间和空间复杂度。
-
-
迪杰斯特拉单源最短路径算法应用于图 3.17 所示的带权重的有向图。假设 A 为源点,最短路径距离的节点顺序将是什么?

图 3.17:一个带权重的有向图
-
考虑表 3.8中列出的项目的权重和值。请注意,每种物品只有一个单位。
项目 重量 价值 A 2 10 B 10 8 C 4 5 D 7 6 表 3.8:不同物品的权重和值
我们需要最大化价值;最大重量应为 11 kg。任何物品不得分割。使用贪婪算法确定物品的价值。
加入我们的 Discord 社区
加入我们的 Discord 空间,与作者和其他读者进行讨论:packt.link/MEvK4

第四章:链表
Python 的列表实现非常强大,可以涵盖多个不同的用例。我们在第一章,Python 数据类型和结构中讨论了 Python 的内置数据结构列表。大多数时候,Python 的内置列表数据结构实现用于使用链表存储数据。在本章中,我们将了解链表的工作原理及其内部结构。
链表是一种数据结构,其中数据元素按线性顺序存储。链表通过指针结构提供高效的数据线性存储。指针用于存储数据项的内存地址。它们存储数据和位置,位置存储内存中下一个数据项的位置。
本章的重点将是以下内容:
-
数组
-
链表的介绍
-
单链表
-
双向链表
-
循环链表
-
链表的实用应用
在讨论链表之前,让我们首先讨论数组,它是最基本的数据结构之一。
数组
数组是一系列相同类型的数据项的集合,而链表是按顺序存储并通过指针连接的相同数据类型的集合。在列表的情况下,数据元素存储在不同的内存位置,而数组元素存储在连续的内存位置中。
数组存储相同数据类型的数据,数组中的每个数据元素都存储在连续的内存位置中。存储多个相同类型的数据值使得使用偏移量和基址计算数组中任何元素的位容易且快速。术语基址指的是存储第一个元素的内存位置的地址,而偏移量指的是一个整数,表示第一个元素和给定元素之间的位移。
图 4.1 展示了一个包含七个整数值的数组,这些值按顺序存储在连续的内存位置中。第一个元素(数据值 3)存储在索引 0 处,第二个元素在索引位置 1,以此类推。

图 4.1:一维数组的表示
与列表相比,存储、遍历和访问数组元素非常快,因为可以通过它们的索引位置随机访问元素,而在链表的情况下,元素是顺序访问的。因此,如果要存储在数组中的数据量很大且系统内存较低,数组数据结构不是存储数据的良好选择,因为很难分配一大块内存位置。数组数据结构还有进一步的限制,即它具有静态大小,必须在创建时声明。
此外,与链表相比,数组数据结构中的插入和删除操作较慢。这是因为很难在数组中给定位置插入一个元素,因为在此位置之后的所有数据元素都必须移动,然后才能在它们之间插入新元素。因此,当我们需要进行大量元素访问而插入和删除操作较少时,数组数据结构是合适的,而链表适用于列表大小不固定且需要大量插入和删除操作的应用。
介绍链表
链表是一个重要且流行的数据结构,具有以下特性:
-
数据元素存储在内存的不同位置,通过指针连接。指针是一个可以存储变量内存地址的对象,每个数据元素都指向下一个数据元素,以此类推,直到最后一个元素,它指向
None。 -
在程序执行过程中,列表的长度可以增加或减少。
与数组不同,链表在内存的不同位置顺序存储数据项,其中每个数据项都是独立存储的,并使用指针与其他数据项链接。这些数据项中的每一个都称为节点。更具体地说,节点存储实际数据和指针。在图 4.2中,节点 A 和 B 独立存储数据,节点 A 连接到节点 B。

图 4.2:包含两个节点的链表
此外,节点可以根据我们想要存储数据的方式以及基于何种基础来链接到其他节点,我们将学习各种数据结构,如单链表、双链表、循环链表和树。
节点和指针
节点是多个数据结构(如链表)的关键组成部分。节点是数据的容器,同时包含一个或多个指向其他节点的链接,其中链接是一个指针。
首先,让我们考虑一个创建包含数据(例如,字符串)的两个节点链表的例子。为此,我们首先声明一个变量来存储数据,以及指向下一个变量的指针。请参考以下图 4.3的例子,其中有两个节点。第一个节点有一个指向字符串(eggs)的指针,另一个节点指向ham字符串。
此外,第一个指向eggs字符串的节点有一个指向另一个节点的链接。指针用于存储变量的地址,由于字符串实际上并不存储在节点中,而是在节点中存储字符串的地址。

图 4.3:两个节点的示例链表
此外,我们还可以向现有的链表添加一个新的第三个节点,该节点存储的数据值为垃圾邮件,而第二个节点指向第三个节点,如图 4.4 所示。因此,图 4.3 展示了具有数据字符串的三个节点的结构,换句话说,鸡蛋、火腿和垃圾邮件,它们按顺序存储在链表中。

图 4.4:三个节点的示例链表
因此,我们创建了三个节点——一个包含 鸡蛋,一个 火腿,另一个 垃圾邮件。鸡蛋 节点指向 火腿 节点,而 火腿 节点又指向 垃圾邮件 节点。但 垃圾邮件 节点指向什么?由于这是列表中的最后一个元素,我们需要确保其 next 成员具有一个值来明确这一点。如果我们让最后一个元素指向空值,那么我们就清楚地表明了这一点。在 Python 中,我们将使用特殊值 None 来表示空值。考虑图 4.5。节点 B 是列表中的最后一个元素,因此它指向 None。

图 4.5:包含两个节点的链表
让我们先了解节点的实现,如下代码片段所示:
class Node:
def __init__ (self, data=None):
self.data = data
self.next = None
在这里,next 指针被初始化为 None,这意味着除非我们更改 next 的值,否则节点将成为一个端点,也就是说,最初,任何附加到列表中的节点将是独立的。
如果需要,您也可以向节点类添加任何其他数据项。如果您的节点将包含客户数据,那么创建一个 Customer 类并将所有数据放在那里。
有三种类型的列表——单链表、双链表和循环链表。首先,让我们讨论单链表。
为了在实时应用中使用任何链表,我们需要学习以下操作。
-
遍历列表
-
在列表中插入数据项:
-
在列表开头插入新的数据项(节点)
-
在列表末尾插入新的数据项(节点)
-
在列表中间/任何给定位置插入新的数据项(节点)
-
-
从列表中删除一个项目:
-
删除第一个节点
-
删除最后一个节点
-
在列表中间/任何给定位置删除节点
-
我们将在后续的小节中讨论这些重要操作,包括它们的实现,使用 Python。让我们从单链表开始。
单链表
链表(也称为单链表)包含多个节点,其中每个节点包含数据和指向下一个节点的指针。列表中最后一个节点的链接是 None,这表示列表的末尾。请参考以下链表,如图 4.6 所示,其中存储了一系列整数。

图 4.6:单链表的示例
接下来,我们讨论如何创建单链表以及如何遍历它。
创建和遍历
为了实现单链表,我们可以使用我们在上一节中创建的节点类。例如,我们创建三个节点n1、n2和n3,它们存储三个字符串:
n1 = Node('eggs')
n2 = Node('ham')
n3 = Node('spam')
接下来,我们将节点依次链接起来形成链表。例如,在以下代码中,节点n1指向节点n2,节点n2指向节点n3,节点n3是最后一个节点,并指向None:
n1.next = n2
n2.next = n3
链表遍历意味着访问列表中的所有节点,从起始节点到最后一个节点。遍历单链表的过程从第一个节点开始,显示当前节点的数据,跟随指针,最后在到达最后一个节点时停止。
为了实现链表的遍历,我们首先将current变量设置为列表中的第一个项目(起始节点),然后通过循环遍历整个列表,如以下代码所示:
current = n1
while current:
print(current.data)
current = current.next
在循环中,我们在打印当前元素后,将current设置为指向列表中的下一个元素。我们一直这样做,直到到达列表的末尾。前面代码的输出如下:
eggs
ham
spam
然而,这种简单列表实现存在几个问题:
-
这需要程序员进行太多的手动操作。
-
链表的大部分内部工作都暴露给了程序员。
因此,让我们讨论一种更好、更高效的遍历链表的方法。
改进列表创建和遍历
正如你将在列表遍历的早期示例中注意到的那样,我们正在将节点类暴露给客户端/用户。然而,客户端节点不应与节点对象交互。我们需要使用node.data来获取节点的内容,使用node.next来获取下一个节点。我们可以通过创建一个返回生成器的方法来访问数据,这可以通过 Python 中的yield关键字来完成。列表遍历的更新代码片段如下:
def iter(self):
current = self.head
while current:
val = current.data
current = current.next
yield val
在这里,yield关键字用于在保存函数局部变量的状态的同时从函数返回,以便函数可以从上次停止的地方继续执行。每次函数再次被调用时,执行从最后一个yield语句开始。任何包含yield关键字的函数都被称为生成器。
现在,列表遍历变得简单多了。我们可以完全忽略列表外存在任何称为节点的东西:
for word in words.iter():
print(word)
注意,由于iter()方法返回节点的数据成员,我们的客户端代码根本不需要担心这一点。
可以使用一个简单的类来创建单链表。我们从一个构造函数开始,该构造函数包含对列表中第一个节点的引用(在下面的代码中是head)。由于这个列表最初是空的,我们将把这个引用设置为None:
class SinglyLinkedList:
def __init__ (self):
self.head = None
在前面的代码中,我们从一个空列表开始,该列表指向None。现在,新的数据元素可以被追加/添加到这个列表中。
追加项目
我们需要执行的第一项操作是将项目append到列表中。这个操作也称为插入操作。在这里,我们有机会将Node类隐藏起来。列表类的用户永远不需要与Node对象交互。
在列表末尾追加项目
让我们看看创建链表的 Python 代码,其中我们使用append()方法将新元素追加到列表中,如下所示:
第一次尝试的 append() 方法可能看起来像这样:
class SinglyLinkedList:
def __init__ (self):
self.head = None
self.size = 0
def append(self, data):
# Encapsulate the data in a Node
node = Node(data)
if self.head is None:
self.head = node
else:
current = self.head
while current.next:
current = current.next
current.next = node
在此代码中,我们将数据封装在节点中,以便它具有下一个指针属性。从这里,我们检查列表中是否存在任何现有节点(即self.head是否指向一个Node)。如果为None,这意味着列表最初是空的,新节点将是第一个节点。因此,我们将新节点作为列表的第一个节点;否则,我们通过遍历列表到最后一个节点并更新最后一个节点的下一个指针来找到插入点。这种工作方式在图 4.7中有所描述。

图 4.7:在单链表中在列表末尾插入节点
考虑以下示例代码来追加三个节点:
words = SinglyLinkedList()
words.append('egg')
words.append('ham')
words.append('spam')
列表遍历将像我们之前讨论的那样工作。您将从列表本身获取列表的第一个元素,然后通过next指针遍历列表:
current = words.head
while current:
print(current.data)
current = current.next
尽管如此,这种实现并不非常高效,append 方法存在一个缺点。在这种情况下,我们必须遍历整个列表来找到插入点。当列表中只有少量项目时,这可能不是问题,但当列表很长时,这将非常低效,因为它每次添加项目时都必须遍历整个列表。让我们讨论append方法的更好实现。
对于这一点,我们的想法是,我们不仅有一个指向列表中第一个节点的引用,而且在节点中还有一个变量,它引用列表的最后一个节点。这样,我们可以快速在列表末尾追加一个新的节点。使用这种方法,append 操作的运行时间最坏情况可以从O(n)减少到O(1)。我们必须确保上一个最后一个节点指向要追加到列表中的新节点。
这是我们的更新后的代码:
class SinglyLinkedList:
def __init__ (self):
self.tail = None
self.head = None
self.size = 0
def append(self, data):
node = Node(data)
if self.tail:
self.tail.next = node
self.tail = node
else:
self.head = node
self.tail = node
注意所使用的约定。我们通过self.tail来追加新节点。self.head变量指向列表中的第一个节点。
在此代码中,可以通过tail指针通过从最后一个节点到新节点的链接来在末尾追加一个新节点。图 4.8显示了前面代码的工作原理。

图 4.8:展示在链表末尾插入节点
在 图 4.8 中,步骤 1 显示了在末尾添加新节点,而 步骤 2 显示了列表为空的情况。在这种情况下,head 被设置为新的节点,tail 指向该节点。
此外,以下代码片段显示了代码的工作原理:
words = SinglyLinkedList()
words.append('egg')
words.append('ham')
words.append('spam')
current = words.head
while current:
print(current.data)
current = current.next
上述代码的输出如下:
eggs
ham
spam
在中间位置追加项目
在现有的链表中在指定位置追加或插入一个元素时,首先,我们必须遍历列表以到达我们想要插入元素的目标位置。可以使用两个指针(prev 和 current)在两个连续的节点之间插入一个元素。
通过更新这些链接,可以轻松地在两个现有节点之间插入一个新节点,如图 4.9 所示。

图 4.9:在链表中两个连续节点之间插入节点
当我们想在两个现有节点之间插入一个节点时,我们只需更新两个链接。前一个节点指向新节点,而新节点应该指向前一个节点的后继节点。
让我们看看下面的完整代码,以在给定索引位置添加一个新元素:
class SinglyLinkedList:
def __init__ (self):
self.tail = None
self.head = None
self.size = 0
def append_at_a_location(self, data, index):
current = self.head
prev = self.head
node = Node(data)
count = 1
while current:
if count == 1:
node.next = current
self.head = node
print(count)
return
elif index == index:
node.next = current
prev.next = node
return
count += 1
prev = current
current = current.next
if count < index:
print("The list has less number of elements")
在前面的代码中,我们从第一个节点开始,移动当前指针以到达我们想要添加新元素的索引位置,然后相应地更新节点指针。在 if 条件中,首先检查索引位置是否为 1。在这种情况下,我们必须更新节点,因为我们正在列表的开始处添加新节点。因此,我们必须使新节点成为 head 节点。接下来,在 else 部分,我们通过比较 count 和 index 的值来检查是否已到达所需的索引位置。如果这两个值相等,我们在由 prev 和 current 指示的节点之间添加一个新节点,并相应地更新指针。最后,如果所需的索引位置大于链表的长度,我们打印一条适当的消息。
2 in the existing linked list:
words = SinglyLinkedList()
words.append('egg')
words.append('ham')
words.append('spam')
current = words.head
while current:
print(current.data)
current = current.next
words.append_at_a_location('new', 2)
current = words.head
while current:
print(current.data)
current = current.next
上述代码的输出如下:
egg
new
ham
spam
重要的是要注意,我们想要插入新元素的条件可能会根据需求而改变,所以假设我们想在具有相同数据值的元素之前插入一个新元素。在这种情况下,append_at_a_position 的代码如下:
def append_at_a_location(self, data):
current = self.head
prev = self.head
node = Node(data)
while current:
if current.data == data:
node.next = current
prev.next = node
prev = current
current = current.next
我们现在可以使用前面的代码在中间位置插入一个新节点:
words.append_at_a_location('ham')
current = words.head
while current:
print(current.data)
current = current.next
上述代码的输出如下:
egg
ham
ham
spam
当我们有一个指向最后一个节点的额外指针时,insert 操作的最坏情况时间复杂度是 O(1)。否则,当我们没有指向最后一个节点的链接时,时间复杂度将是 O(n),因为我们必须遍历列表以到达目标位置,在最坏的情况下,我们可能需要遍历列表中的所有 n 个节点。
查询列表
一旦创建列表,我们可能需要一些有关链表的快速信息,例如列表的大小,有时还需要确定给定的数据项是否存在于列表中。
在列表中搜索元素
我们还可能需要检查列表是否包含给定的项。这可以通过使用我们在上一节中遍历链表时已经看到的iter()方法来实现。使用它,我们编写如下搜索方法:
def search(self, data):
for node in self.iter():
if data == node:
return True
return False
在上述代码中,循环的每次迭代都会将待搜索的数据与列表中的每个数据项逐一比较。如果找到匹配项,则返回True,否则返回False。
如果我们运行以下代码来搜索给定的数据项:
print(words.search('sspam'))
print(words.search('spam'))
上述代码的输出如下:
False
True
获取列表的大小
通过计数节点数量来获取列表的大小是很重要的。一种方法是在遍历整个列表的同时增加计数器:
def size(self):
count = 0
current = self.head
while current:
count += 1
current = current.next
return count
上述代码与我们遍历链表时所做的非常相似。同样,在这段代码中,我们逐个遍历列表的节点,并增加count变量。然而,列表遍历可能是一个昂贵的操作,我们应该尽量避免。
因此,我们可以选择另一种方法,在SinglyLinkedList类中添加一个大小成员,并在构造函数中将它初始化为0,如下面的代码片段所示:
class SinglyLinkedList:
def __init__(self):
self.head = data
self.size = 0
因为我们现在只读取节点对象的size属性,而不是使用循环来计数列表中的节点数量,所以我们把最坏情况下的运行时间从O(n)降低到O(1)。
删除项
在链表上进行的另一个常见操作是删除节点。为了从单链表中删除节点,我们可能会遇到三种可能性。
删除单链表开头的节点
从列表开头删除节点相当简单。这涉及到将head指针更新为列表中的第二个节点。这可以通过以下两个步骤完成:
- 创建一个临时指针(
current指针),它指向第一个节点(head节点),如图4.10所示。

图 4.10:展示从链表中删除第一个节点的示意图
- 接下来,将
current节点指针移动到下一个节点,并将其分配给head节点。现在,第二个节点成为由head指针指向的head节点,如图4.11所示。

图 4.11:删除第一个节点后,头指针现在指向新的起始元素
这可以通过以下 Python 代码实现。在这个代码中,最初添加了三个数据元素,就像我们之前做的那样,然后删除了列表的第一个节点:
def delete_first_node (self):
current = self.head
if self.head is None:
print("No data element to delete")
elif current == self.head:
self.head = current.next
在上述代码中,我们首先检查链表中是否没有要删除的项目,并打印相应的消息。接下来,如果链表中有一些数据项,我们根据步骤 1将head指针赋值给临时指针current,然后head指针现在指向下一个节点,假设我们已经有了一个包含三个数据项的链表——“鸡蛋”、“火腿”和“垃圾”:
words.delete_first_node()
current = words.head
while current:
print(current.data)
current = current.next
上述代码的输出如下:
ham
spam
从单链表末尾删除节点
要从链表中删除最后一个节点,我们首先需要遍历链表到达最后一个节点。那时,我们还需要一个额外的指针,它指向最后一个节点之前的一个节点,这样在删除最后一个节点后,倒数第二个节点可以被标记为最后一个节点。这可以通过以下三个步骤实现:
- 首先,我们有两个指针,换句话说,一个
current指针将指向最后一个节点,一个prev指针将指向最后一个节点之前的节点(倒数第二个节点)。最初,我们将有三个指针(current、prev和head)指向第一个节点,如图图 4.12所示。

图 4.12:从链表中删除末尾节点的示意图
- 要到达最后一个节点,我们需要移动
current和prev指针,使得current指针指向最后一个节点,而prev指针指向倒数第二个节点。因此,当current指针到达最后一个节点时,我们停止。这如图图 4.13所示。

图 4.13:遍历链表到达链表末尾
- 最后,我们将
prev指针标记为指向倒数第二个节点,通过将此节点指向None将其表示为链表的最后一个节点,如图图 4.14所示。

图 4.14:从链表中删除最后一个节点
在 Python 中从链表末尾删除节点的实现如下:
def delete_last_node (self):
current = self.head
prev = self.head
while current:
if current.next is None:
prev.next = current.next
self.size -= 1
prev = current
current = current.next
在前面的代码中,首先,根据步骤 1,将current和prev指针赋值为head指针。然后,在while循环中,我们使用current.next is None条件检查是否到达了链表的末尾。一旦到达链表末尾,我们将由prev指针指示的倒数第二个节点标记为最后一个节点。我们还会减少链表的大小。如果我们没有到达链表末尾,我们在代码的最后两行中的while循环中增加prev和current指针。接下来,让我们讨论如何删除单链表中的任何中间节点。
从单链表中删除任何中间节点
我们首先必须决定如何选择要删除的节点。通过索引号或节点包含的数据来识别要删除的中间节点。让我们通过根据节点包含的数据删除节点来理解这个概念。
要删除任何中间节点,我们需要两个指针,类似于我们学习删除最后一个节点的情况;换句话说,current指针和prev指针。一旦我们到达要删除的节点,可以通过使前一个节点指向要删除的节点的下一个节点来删除所需的节点。这个过程在以下步骤中提供:
- 图 4.15显示了从给定的链表中删除中间节点的情况。在这里,我们可以看到初始指针指向第一个节点。

图 4.15:从链表中删除中间节点的示意图
- 一旦识别出节点,
prev指针就会更新以删除节点,如图 4.16所示。要删除的节点以及需要更新的链接在图 4.16中显示。

图 4.16:遍历以到达要删除的中间节点在链表中的位置
- 最后,删除节点后的列表在图 4.17中显示。

图 4.17:从链表中删除中间节点
假设我们想要删除具有给定值的数据元素。对于这个条件,我们首先可以搜索要删除的节点,然后按照讨论的步骤进行删除。
下面是delete()方法实现的可能样子:
def delete(self, data):
current = self.head
prev = self.head
while current:
if current.data == data:
if current == self.head:
self.head = current.next
else:
prev.next = current.next
self.size -= 1
return
prev = current
current = current.next
假设我们已经有了一个包含三个项目——“鸡蛋”、“火腿”和“垃圾食品”的链表,以下代码是执行删除操作,即从给定的链表中删除具有值“火腿”的数据元素:
words.delete("ham")
current = words.head
while current:
print(current.data)
current = current.next
上述代码的输出如下:
egg
spam
delete操作的 worst-case 时间复杂度为O(n),因为我们必须遍历列表以到达目标位置,在 worst-case 情景下,我们可能需要遍历列表中的所有 n 个节点。
清除列表
我们可能需要快速清除列表,有一种非常简单的方法。我们可以通过将指针head和tail设置为None来清除列表:
def clear(self):
# clear the entire list.
self.tail = None
self.head = None
在上面的代码中,我们可以通过将tail和head指针赋值为None来清除列表。
我们已经讨论了单链表的不同操作,现在我们将讨论双链表的概念,并学习如何在下一节中实现双链表的不同操作。
双链表
从某种意义上说,双链表与单链表非常相似,因为我们使用相同的节点基本概念,以及我们如何将数据和链接一起存储,就像我们在单链表中做的那样。单链表和双链表之间的唯一区别是,在单链表中,每个连续节点之间只有一个链接,而在双链表中,我们有两个指针——一个指向下一个节点,一个指向前一个节点。请参考以下 图 4.18 的节点;有一个指向下一个节点和前一个节点的指针,这些指针被设置为 None,因为没有节点连接到这个节点。

图 4.18:表示具有单个节点的双链表
单链表中的一个节点只能确定与其关联的下一个节点。然而,从该引用节点返回没有链接。流的方向只有单向。在双链表中,我们解决了这个问题,并包括了不仅能够引用下一个节点,还能够引用前一个节点的功能。请参考以下 图 4.19 来理解两个连续节点之间链接的性质。在这里,节点 A 引用了节点 B;此外,还有一个返回到节点 A 的链接。

图 4.19:具有两个节点的双链表
双链表可以双向遍历。在需要时,双链表中的一个节点可以很容易地通过其前一个节点来引用,而不需要有一个变量来跟踪该节点。
然而,在单链表中,可能很难回到列表的起始或开头以在列表的开头进行一些更改,而现在在双链表中这变得非常容易。
创建和遍历
创建双链表节点的 Python 代码包括其初始化方法、prev 指针、next 指针和 data 实例变量。当一个节点被新创建时,所有这些变量默认为 None:
class Node:
def __init__ (self, data=None, next=None, prev=None):
self.data = data
self.next = next
self.prev = prev
prev 变量有一个对前一个节点的引用,而 next 变量保持对下一个节点的引用,data 变量存储数据。
接下来,让我们创建一个双链表类。
双链表类有两个指针,head 和 tail,分别指向双链表的起始和结束。此外,对于列表的大小,我们设置 count 实例变量为 0。它可以用来跟踪链表中的项目数量。请参考以下创建双链表类的 Python 代码:
class DoublyLinkedList:
def __init__ (self):
self.head = None
self.tail = None
self.count = 0
在这里,self.head 指向链表的起始节点,而 self.tail 指向最后一个节点。然而,关于头尾节点指针的命名并没有固定的规则。
双链表还需要一些功能,例如返回列表的大小、将项目插入列表以及从列表中删除节点。接下来,我们将讨论可以应用于双链表的不同操作。让我们从追加操作开始。
追加项目
append操作用于在列表的末尾添加一个元素。在以下情况下,可以将元素追加或插入到双链表中。
在列表的起始处插入节点
首先,重要的是检查列表的head节点是否为None。如果是None,这意味着列表为空,否则列表有一些节点,可以在列表中追加一个新节点。如果要将新节点添加到空列表中,它应该有head指针指向新创建的节点,并且列表的尾部也应该指向这个新创建的节点。
以下图 4.20说明了在空列表中添加新节点时双链表的head和tail指针。

图 4.20:空双链表中插入节点的示意图
或者,我们可以在现有双链表的开始处插入或追加一个新节点,如图图 4.21所示。

图 4.21:双链表中插入元素的示意图
新节点应成为列表的新起始节点,并指向之前的head节点。
可以通过更新三个链接来实现,这三个链接也在图 4.22中以虚线显示,具体描述如下:
-
首先,新节点的
next指针应指向现有列表的head节点 -
现有列表的
head节点的prev指针应指向新节点 -
最后,将新节点标记为列表中的
head节点

图 4.22:在双链表的起始处插入节点
当列表最初为空且存在双链表时,以下代码用于在列表的起始处追加/插入一个项目:
def append_at_start(self, data):
#Append an item at beginning 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.next = self.head
self.head.prev = new_node
self.head = new_node
self.count += 1
在上述代码中,首先检查self.head条件,无论列表是否为空。如果为空,则头和尾指针指向新创建的节点。在这种情况下,新节点成为head节点。接下来,如果条件不成立,这意味着列表不为空,需要在列表的起始处添加一个新节点。为此,需要更新如图图 4.22所示的三条链接,并在粗体字体的代码中显示。更新这些三条链接后,最后将列表的大小增加1。此外,让我们了解如何将元素插入到双链表的末尾。
此外,以下代码片段展示了我们如何创建一个双链表并在列表的起始处追加一个新节点:
words = DoublyLinkedList()
words.append('egg')
words.append('ham')
words.append('spam')
print("Items in doubly linked list before append:")
current = words.head
while current:
print(current.data)
current = current.next
words.append_at_start('book')
print("Items in doubly linked list after append:")
current = words.head
while current:
print(current.data)
current = current.next
上述代码的输出为:
Items in doubly linked list before append:
egg
ham
spam
Items in doubly linked list after append:
book
egg
ham
spam
在输出中,我们可以看到新数据项“book"被添加到列表的起始处。
在列表末尾插入节点
如果我们没有指向列表末尾的单独指针,为了在双链表的末尾追加/插入一个新元素,我们需要遍历整个列表以到达列表的末尾。在这里,我们有一个tail指针指向列表的末尾。
在以下图 4.23中显示了将追加操作应用于现有列表的视觉表示。

图 4.23:在双链表中在列表末尾插入节点
要在末尾添加新节点,我们更新两个链接如下:
-
将新节点的
prev指针指向之前的tail节点 -
将之前的
tail节点指向新节点 -
最后,更新
tail指针,使tail指针现在指向新节点
以下代码用于在双链表末尾追加一个项目:
def append(self, data):
#Append an item at the end of 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部分。如果要将新节点添加到列表中,新节点的previous变量应设置为列表的尾部:
new_node.prev = self.tail
tail的next指针(或变量)必须设置为新节点:
self.tail.next = new_node
最后,我们更新tail指针,使其指向新节点:
self.tail = new_node
由于追加操作使节点数量增加一个,我们将计数器增加一个:
self.count += 1
以下代码片段可以用来在列表末尾追加一个节点:
print("Items in doubly linked list after append")
words = DoublyLinkedList()
words.append('egg')
words.append('ham')
words.append('spam')
words.append('book')
print("Items in doubly linked list after adding element at end.")
current = words.head
while current:
print(current.data)
current = current.next
上述代码的输出:
Items in doubly linked list after adding element at end.
egg
ham
spam
book
将元素追加到双链表的最坏情况时间复杂度为O(1),因为我们已经有了指向列表末尾的tail指针,可以直接添加新元素。接下来,我们将讨论如何在双链表的中间位置插入一个节点。
在列表中间位置插入节点
在双链表的任何给定位置插入新节点与我们在单链表中讨论的类似。让我们以一个例子为例,我们在具有给定数据值的元素之前插入一个新元素。
首先,我们遍历到要插入新元素的位置。current指针指向目标节点,而prev指针仅指向目标节点的前一个节点,如图图 4.24所示。

图 4.24:在双链表中插入节点到中间位置的指针示意图
在达到正确位置后,需要添加几个指针以添加一个新节点。需要更新的这些链接的详细信息(也显示在图 4.25中)如下:
-
新节点的
next指针指向当前节点 -
新节点的
prev指针应指向前一个节点 -
前一个节点的
next指针应指向新节点 -
当前节点的
prev指针应指向新节点

图 4.25:演示在列表中任何中间位置添加新节点需要更新的链接
下面是append_at_a_location()方法实现可能的样子:
def append_at_a_location(self, data):
current = self.head
prev = self.head
new_node = Node(data, None, None)
while current:
if current.data == data:
new_node.prev = prev
new_node.next = current
prev.next = new_node
current.prev = new_node
self.count += 1
prev = current
current = current.next
在前面的代码中,首先,通过指向head节点初始化current和prev指针。然后,在while循环中,我们首先通过检查条件到达期望的位置。在这个例子中,我们检查当前节点的数据值与用户提供的值。一旦我们到达期望的位置,我们就更新四个链接,如前所述,这些链接也显示在图 4.25中。
ham" after the first occurrence of the word “ham" in the doubly linked list:
words = DoublyLinkedList()
words.append('egg')
words.append('ham')
words.append('spam')
words.append_at_a_location('ham')
print("Doubly linked list after adding an element after word \"ham\" in the list.")
current = words.head
while current:
print(current.data)
current = current.next
上述代码的输出:
Doubly linked list after adding an element after word "ham" in the list.
egg
ham
ham
spam
在双链表中,在起始位置和末尾位置添加将具有最坏情况下的运行时间复杂度O(1),因为我们可以直接添加新节点,而在任何中间位置添加新节点的最坏情况时间复杂度将是O(n),因为我们可能需要遍历包含n个项目的列表。
接下来,让我们学习如何搜索给定项是否存在于双链表中。
查询列表
在双链表中搜索一个项目的方式与我们之前在单链表中所做的方式相似。我们使用iter()方法检查所有节点的数据。当我们遍历列表中的所有数据时,每个节点都与通过contain方法传入的数据进行匹配。如果我们找到列表中的项目,则返回True,表示找到了项目,否则返回False,表示列表中没有找到该项目。相应的 Python 代码如下:
def iter(self):
current = self.head
while current:
val = current.data
current = current.next
yield val
def contains(self, data):
for node_data in self.iter():
if data == node_data:
print("Data item is present in the list.")
return
print("Data item is not present in the list.")
return
以下代码可以用来搜索一个数据项是否存在于现有的双链表中:
words = DoublyLinkedList()
words.append('egg')
words.append('ham')
words.append('spam')
words.contains("ham")
words.contains("ham2")
上述代码的输出如下:
Data item is present in the list.
Data item is not present in the list.
在双链表中的搜索操作具有运行时间复杂度O(n),因为我们必须遍历列表以到达期望的元素,在最坏的情况下,我们可能需要遍历包含n个项目的整个列表。
删除项目
与单链表相比,在双链表中删除操作更简单。与单链表不同,在单链表中我们需要遍历链表以到达期望的位置,我们还需要一个额外的指针来跟踪目标节点的上一个节点,而在双链表中,我们不需要这样做,因为我们可以在两个方向上遍历。
在双链表中的delete操作有四种情况,如下所述:
-
要删除的项目位于列表的起始位置
-
要删除的项目位于列表的末尾
-
要删除的项目位于列表的中间位置
-
要删除的项目不在列表中
通过将数据实例变量与传递给方法的数据进行匹配来识别要删除的节点。如果数据与节点的数据变量匹配,则该匹配节点将被删除:
- 对于第一种情况,当我们找到要删除的第一个位置的项时,我们只需简单地更新
head指针到下一个节点。这如图4.26所示。

图 4.26:双链表中删除第一个节点的示意图
- 对于第二种情况,当我们找到要删除的项位于列表的最后一个位置时,我们只需简单地更新
tail指针到倒数第二个节点。这如图4.27所示。

图 4.27:双链表中删除最后一个节点的示意图
- 对于第三种情况,我们在任何中间位置找到要删除的数据项。为了更好地理解这一点,请考虑图4.28中所示的示例。在这里,有三个节点,A、B和C。要删除列表中间的节点B,我们实际上会让A指向节点C作为其下一个节点,同时让C指向A作为其前一个节点。

图 4.28:双链表中删除中间节点 B 的示意图
删除双链表中节点在 Python 中的完整实现如下。我们将逐步讨论此代码的每个部分:
def delete(self, data):
# Delete a node from the list.
current = self.head
node_deleted = False
if current is None:
#List is empty
print("List is empty")
elif current.data == data:
#Item to be deleted is found at starting of the list
self.head.prev = None
node_deleted = True
self.head = current.next
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 == False:
# Item to be deleted is not found in the list
print("Item not found")
if node_deleted:
self.count -= 1
初始时,我们创建一个node_deleted变量来表示列表中删除的节点,并将其初始化为False。如果找到匹配的节点并将其删除,则node_deleted变量被设置为True。
在delete方法中,current变量最初被设置为列表的head节点(即,它指向列表的self.head节点)。这在上面的代码片段中有所体现:
def delete(self, data):
current = self.head
node_deleted = False
接下来,我们使用一组if...else语句来搜索列表的各个部分,以确定要删除的指定数据的节点。
首先,我们在head节点搜索要删除的数据,如果数据与head节点匹配,则该节点将被删除。由于current指向head,如果current是None,这意味着列表为空,没有节点可以找到要删除的节点。以下是其代码片段:
if current is None:
node_deleted = False
然而,如果current(现在指向head)包含正在搜索的数据,这意味着我们在head节点找到了要删除的数据,然后self.head被标记为指向current.next节点。由于现在head后面没有节点,self.head.prev被设置为None。以下是其代码片段:
elif current.data == data:
self.head.prev = None
node_deleted = True
self.head = current.next
类似地,如果要删除的节点位于列表的tail端,我们通过将其前一个节点指向None来删除最后一个节点。self.tail被设置为指向self.tail.prev,而self.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.next.prev = current.prev 使当前节点的下一个节点指向当前节点的前一个节点。此外,如果我们遍历整个列表,并且找不到所需的项目,我们将打印相应的消息。以下代码片段展示了这一点:
else:
while current:
if current.data == data:
current.prev.next = current.next
current.next.prev = current.prev
node_deleted = True
current = current.next
if node_deleted == False:
# Item to be deleted is not found in the list
print("Item not found")
最后,检查 node_delete 变量以确认是否确实删除了节点。如果删除了任何节点,则将计数变量减 1,这样就可以跟踪列表中的总节点数。以下代码片段展示了这一点:
if node_deleted:
self.count -= 1
如果删除了任何节点,则将计数变量减 1。
让我们通过添加三个字符串的示例来了解删除操作是如何工作的——“egg”、“ham”和“spam”,然后从列表中删除一个值为“ham”的节点。代码如下:
#Code to create for a doubly linked list
words = DoublyLinkedList()
words.append('egg')
words.append('ham')
words.append('spam')
words.delete('ham')
current = words.head
while current:
print(current.data)
current = current.next
上一段代码的输出如下:
egg
spam
删除操作的最坏情况运行时间复杂度是 O(n),因为我们可能需要遍历包含 n 个项目的列表来搜索要删除的项目。
在下一节中,我们将学习循环链表的不同操作。
循环列表
循环链表是链表的一种特殊情况。在循环链表中,端点是相连的,这意味着列表中的最后一个节点指向第一个节点。换句话说,我们可以说在循环链表中,所有节点都指向下一个节点(在双链表中是前一个节点)并且没有结束节点,意味着没有节点会指向 None。
循环链表可以基于单链表和双链表。考虑 图 4.29,它展示了基于单链表的循环链表,其中最后一个节点 C 再次连接到第一个节点 A,从而形成一个循环列表。

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

图 4.30:基于双链表的单循环列表示例
现在,我们将查看单链表循环列表的实现。一旦我们理解了单链表和双链表的基本概念,实现双链表循环列表就非常直接了。
几乎所有内容都是相似的,但我们应小心管理最后一个节点到第一个节点的链接。
我们可以重用我们在单链表部分创建的节点类。我们也可以重用SinglyLinkedList类的大部分内容。因此,我们将关注循环链表实现与普通单链表的不同之处。
创建和遍历
可以使用以下代码创建循环链表类:
class CircularList:
def __init__ (self):
self.tail = None
self.head = None
self.size = 0
在上述代码中,最初在循环链表类中,我们有两个指针;self.tail用于指向最后一个节点,而self.head用于指向列表的第一个节点。
添加项目
在这里,我们想在循环链表的末尾添加一个节点,如图图 4.31所示,其中我们有一个四个节点的列表,其中头指针指向起始节点,尾指针指向最后一个节点。

图 4.31:在链表末尾添加节点的示例
图 4.32 展示了如何将一个节点添加到循环链表中。

图 4.32:在单链表的末尾插入节点
要在末尾添加节点,我们将更新三个链接:
-
将最后一个节点的
next指针指向新节点 -
将新节点的
next指针指向head节点 -
更新
tail指针以指向新节点
基于单链表在循环链表的末尾添加元素的循环链表实现如下:
def append(self, data):
node = Node(data)
if self.tail:
self.tail.next = node
self.tail = node
`node.next = self.head`
else:
self.head = node
self.tail = node
`self.tail.next = self.tail`
self.size += 1
在上述代码中,首先检查列表是否为空。如果列表为空,则进入上述代码的else部分。在这种情况下,新节点将成为列表的第一个节点,head和tail指针都将指向新节点,而新节点的next指针将再次指向新节点。
否则,如果列表不为空,则进入前面代码的if部分。在这种情况下,我们将更新三个指针,如图图 4.32所示。这与我们在单链表的情况中所做的是相似的。在这种情况下,额外添加了一个链接,这在前面代码中以粗体显示。
此外,我们可以使用iter()方法遍历列表的所有元素,下面的iter()方法应在CircularList类中定义:
def iter(self):
current = self.head
while current:
val = current.data
current = current.next
yield val
以下代码可以用来创建单链表循环,然后打印列表的所有数据元素,然后当计数器变为 3(列表的长度)时停止。
words = CircularList()
words.append('eggs')
words.append('ham')
words.append('spam')
counter = 0
for word in words.iter():
print(word)
counter += 1
if counter > 2:
break
上述代码的输出如下:
eggs
ham
spam
在循环链表中在任何中间位置添加任何元素与单链表的实现方式完全相同。
查询列表
遍历循环链表非常方便,因为我们不需要寻找起始点。我们可以从任何地方开始,只需要在再次遇到相同的节点时小心地停止遍历。我们可以使用相同的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 > 100:
break
在上面的代码中,我们向循环链表中添加了三条数据字符串,然后通过遍历链表 100 次来打印数据值。
在下一节中,让我们了解如何在循环链表中实现delete操作。
在循环链表中删除一个元素
要在循环链表中删除一个节点,看起来我们可以像在追加操作的情况中那样做——只需确保通过tail指针指向的最后一个节点通过head指针指向链表的起始节点。我们有以下三种情况:
-
当要删除的项是
head节点时:在这个场景中,我们必须确保将列表的第二个节点作为新的
head节点(如图图 4.33中的步骤 1所示),并且最后一个节点应该指向新的头节点(如图图 4.33中的步骤 2所示)。![图片]()
图 4.33:单循环链表中删除起始节点
-
当要删除的项是
last节点时:在这个场景中,我们必须确保将第二个最后一个节点作为新的尾节点(如图图 4.34中的步骤 1所示),而新的尾节点应该指向新的头节点(如图图 4.34中的步骤 2所示)。
![图片]()
图 4.34:单循环链表中删除最后一个节点
-
当要删除的项是中间节点时:
这与我们之前在单链表中所做的是非常相似的。我们需要从目标节点的上一个节点建立一个链接到目标节点的下一个节点,如图图 4.35所示。
![图片]()
图 4.35:单循环链表中删除任何中间节点
delete操作的实现如下:
def delete(self, data):
current = self.head
prev = self.head
`while prev == current or prev != self.tail:`
if current.data == data:
if current == self.head:
#item to be deleted is head node
self.head = current.next
self.tail.next = self.head
elif current == self.tail:
#item to be deleted is tail node
self.tail = prev
prev.next = self.head
else:
#item to be deleted is an intermediate node
prev.next = current.next
self.size -= 1
return
prev = current
current = current.next
if flag is False:
print("Item not present in the list")
在前面的代码中,首先遍历所有元素以搜索要删除的所需元素。在这里,重要的是要注意停止条件。如果我们简单地检查current指针是否等于None(我们在单链表中就是这样做的),由于在循环链表中当前节点永远不会指向None,程序将进入无限循环。
对于这种情况,我们不能检查 current 是否到达 tail,因为那样它将永远不会检查最后一个节点。所以,循环链表的停止标准是 prev 和 current 指针指向相同的节点。它将正常工作,除非在第一次循环迭代时,那时 current 和 prev 将指向相同的节点,换句话说,就是 head 节点。
一旦进入循环,我们就将当前指针的数据值与给定数据值进行比较,以找到要删除的节点。我们检查要删除的节点是 head 节点、尾节点还是中间节点,然后更新如 图 4.33、图 4.34 和 图 4.35 所示的适当链接。
因此,我们已经讨论了在单链循环链表中删除任何节点时的不同场景,同样,基于双链表的循环链表也可以实现。
以下代码可以用来创建循环链表,并应用不同的删除操作:
words = CircularList()
words.append('eggs')
words.append('ham')
words.append('spam')
words.append('foo')
words.append('bar')
print("Let us try to delete something that isn't in the list.")
words.delete('socks')
counter = 0
for item in words.iter():
print(item)
counter += 1
if counter > 4:
break
print("Let us delete something that is there.")
words.delete('foo')
counter = 0
for item in words.iter():
print(item)
counter += 1
if counter > 3:
break
上述代码的输出如下:
Let us try to delete something that isn't in the list.
Item not present in the list
eggs
ham
spam
foo
bar
Let us delete something that is there.
eggs
ham
spam
bar
在循环链表中在指定位置插入一个元素的 worst-case 时间复杂度是 O(n),因为我们必须遍历链表到所需的位置。在循环链表的第一和最后一个位置插入的复杂度将是 O(1)。同样,在指定位置删除一个元素的 worst-case 时间复杂度也是 O(n)。
到目前为止,我们已经讨论了在单链循环链表中删除任何节点时的不同场景。同样,基于循环链表的双链表也可以实现。
在单链表中,节点的遍历可以单向进行,而在双链表中,可以双向遍历(正向和反向)。在这两种情况下,在给定位置进行插入和删除操作的复杂度都是 O(n),无论何时我们需要遍历链表以到达我们想要插入或删除元素的位置。同样,对于给定位置的节点插入或删除的 worst-case 时间复杂度也是 O(n)。当我们需要节省内存空间时,我们应该使用单链表,因为它只需要一个指针,而双链表需要更多的内存空间来存储双指针。当搜索操作很重要时,我们应该使用双链表,因为它可以双向搜索。此外,当我们有一个需要遍历链表中节点应用时,应该使用循环链表。现在让我们看看链表在现实世界中的更多应用。
链表的实用应用
到目前为止,我们已经讨论了单链表、循环链表和双向链表。根据不同应用中所需的不同操作(插入、删除、更新等),相应地使用这些数据结构。让我们看看这些数据结构在实际应用中的几个实时例子。
单链表可以用来表示任何稀疏矩阵。另一个重要的应用是通过在链表节点中累积常数来表示和操作多项式。
它还可以用于实现一个动态内存管理方案,允许用户在程序执行期间根据需要分配和释放内存。
另一方面,双向链表被操作系统中的线程调度器用来维护当时正在运行的进程列表。这些列表也被用于操作系统中的MRU(最近最少使用)和LRU(最近最少使用)缓存的实现。
双向链表也可以被各种应用程序用来实现撤销和重做功能。浏览器可以使用这些列表来实现访问过的网页的向前和向后导航。
循环链表可以被操作系统用来实现循环调度机制。循环链表的另一个应用是在 Photoshop 或 Word 软件中实现撤销功能,并用于实现允许你点击后退按钮的浏览器缓存。除此之外,它还被用来实现如斐波那契堆等高级数据结构。多人游戏也使用循环链表在玩家之间循环切换。
概述
在本章中,我们研究了列表背后的概念,例如节点和其他节点的指针。我们讨论了单链表、双向链表和循环链表。我们看到了可以应用于这些数据结构的各种操作及其使用 Python 的实现。
这些类型的数据结构相对于数组有一定的优势。在数组的情况下,插入和删除操作相当耗时,因为这些操作需要由于连续内存分配而向下和向上移动元素。另一方面,在链表的情况下,这些操作只需要改变指针。链表相对于数组的另一个优势是允许动态内存管理方案,该方案在程序执行期间根据需要分配内存,而数组基于静态内存分配方案。
单链表只能向前遍历,而双链表的遍历是双向的,这就是为什么与单链表相比,在双链表中删除节点更容易。同样,与单链表相比,循环链表在从末尾访问第一个节点时可以节省时间。因此,每种链表都有其优缺点。我们应该根据应用程序的需求来使用它们。
在下一章中,我们将探讨两种通常使用列表实现的其他数据结构——栈和队列。
练习
-
在链表中插入数据元素到指针所指向的元素之后时的时间复杂度是多少?
-
确定给定链表长度时的时间复杂度是多少?
-
在长度为 n 的单链表中搜索给定元素的最坏情况时间复杂度是多少?
-
对于给定的链表,假设它只有一个指向列表起始点的
head指针,以下操作的时间复杂度是多少?-
在链表前端插入节点
-
在链表末尾插入节点
-
删除链表的前端节点
-
删除链表最后一个节点
-
-
从链表末尾找到第 n 个节点。
-
如何确定给定链表中是否存在循环(或环形)?
-
如何确定链表的中间元素?
加入我们的 Discord 社区
加入我们的社区 Discord 空间,与作者和其他读者进行讨论:packt.link/MEvK4

第五章:栈和队列
在本章中,我们将讨论两个非常重要的数据结构:栈和队列。栈和队列有许多重要的应用,例如操作系统架构、算术表达式评估、负载均衡、管理打印作业和数据遍历。在栈和队列数据结构中,数据是按顺序存储的,类似于数组和链表,但与数组和链表不同,数据是按照特定的顺序和某些约束来处理的,我们将在本章中详细讨论这些内容。此外,我们还将探讨如何使用链表和数组来实现栈和队列。
在本章中,我们将讨论处理栈和队列中的数据的约束和方法。我们还将实现这些数据结构,并学习如何在 Python 中应用不同的操作到这些数据结构中。
在本章中,我们将涵盖以下内容:
-
如何使用各种方法实现栈和队列
-
栈和队列的一些实际应用示例
栈
栈是一种数据结构,它存储数据,类似于厨房中盘子堆。你可以在栈顶放置一个盘子,当你需要盘子时,从栈顶取出。
最后添加到栈中的盘子将是第一个从栈中取出的:

图 5.1:栈的示例
上一图展示了盘子堆栈。向盘子堆中添加盘子只能通过将盘子放在堆顶来实现。从盘子堆中移除盘子意味着移除堆顶的盘子。
栈是一种数据结构,它以类似于数组和链表的方式存储数据,具有一些约束条件:
-
栈中的数据元素只能通过
push操作在末尾插入 -
栈中的数据元素只能从末尾删除(
pop操作) -
栈中只能读取最后一个数据元素(
peek操作)
栈数据结构允许我们从一端存储和读取数据,最后添加的元素是第一个被取出的。因此,栈是一种后进先出(LIFO)结构,或后进后出(LILO)。
在栈上执行的两个主要操作是push和pop。当一个元素被添加到栈顶时,称为push操作,而当要从栈顶取出(即移除)一个元素时,称为pop操作。另一个操作是peek,在peek操作中,可以查看栈顶元素而不从栈中移除它。栈上的所有操作都是通过一个指针来执行的,这个指针通常被称为top。所有这些操作都在图 5.2中展示:

图 5.2:栈中的推入和弹出操作的演示
以下表格展示了在栈中使用两个重要的栈操作(push和pop)的用法:
| 栈操作 | 大小 | 内容 | 操作结果 |
|---|---|---|---|
stack() |
0 | [] |
创建了一个栈对象,它是空的。 |
push "egg" |
1 | ['egg'] |
向栈中添加了一个项目 egg。 |
push "ham" |
2 | ['egg', 'ham'] |
再添加一个项目,ham,到栈中。 |
peek() |
2 | ['egg', 'ham'] |
返回了栈顶元素 ham。 |
pop() |
1 | ['egg'] |
弹出并返回了 ham 项目。(这个项目是最后添加的,所以它首先被移除。) |
pop() |
0 | [] |
弹出并返回了 egg 项目。(这是第一个添加的项目,所以它最后被返回。) |
表 5.1:不同栈操作的示例说明
栈被用于许多事情。栈的一个常见用途是在函数调用期间跟踪返回地址。让我们想象我们有一个以下程序:
def b():
print('b')
def a():
b()
a()
print("done")
当程序执行到达对 a() 的调用时,将按照顺序执行一系列事件以完成此程序的执行。所有这些步骤的可视化显示在 图 5.3 中:

图 5.3:在示例程序中函数调用期间事件序列的步骤
事件序列如下:
-
首先,当前指令的地址被推入栈中,然后执行跳转到
a的定义。 -
在函数
a()内部,调用了函数b() -
函数
b()的返回地址被推入栈中。一旦b()中的指令和函数执行完成,返回地址从栈中弹出,这使我们回到函数a() -
当函数
a()中的所有指令完成时,返回地址再次从栈中弹出,这使我们回到主程序和print语句
上述程序输出如下:
b
done
我们现在已经讨论了栈数据结构的概念。现在,让我们了解其在 Python 中使用数组和链表数据结构实现的细节。
使用数组实现的栈
栈以顺序方式存储数据,就像数组和链表一样,有一个特定的约束,即数据只能按照后进先出(LIFO)的原则从栈的一端存储和读取。通常,栈可以使用数组和链表实现。基于数组的实现将具有固定长度的栈,而基于链表的实现可以具有可变长度的栈。
在基于数组的栈实现(其中栈具有固定大小)的情况下,检查栈是否已满是很重要的,因为尝试将元素推入一个满栈将生成一个错误,称为溢出。同样,尝试对一个空栈应用 pop 操作会导致一个称为下溢的错误。
让我们通过一个例子来理解使用数组实现的栈的实现,我们希望将三个数据元素“egg”、“ham”和“spam”推入栈中。首先,为了使用push操作将新元素插入到栈中,我们检查溢出条件,即top指针指向数组的末尾索引。top指针是栈中顶元素的索引位置。如果顶元素等于溢出条件,则无法添加新元素。这是一个栈溢出条件。如果数组中有空间可以插入新元素,则将新数据推入栈中。使用数组在栈上执行push操作的概述如图 5.4 所示:

图 5.4:使用数组实现的栈的push操作序列
push操作的 Python 代码如下:
size = 3
data = [0]*(size) #Initialize the stack
top = -1
def push(x):
global top
if top >= size - 1:
print("Stack Overflow")
else:
top = top + 1
data[top] = x
在上述代码中,我们使用固定大小(例如,在这个例子中为 3)初始化栈,并将top指针设置为-1,这表示栈为空。进一步地,在push方法中,将top指针与栈的大小进行比较以检查溢出条件,如果栈已满,则打印栈溢出信息。如果栈未满,则将top指针加 1,并将新的数据元素添加到栈顶。以下代码用于将数据元素插入到栈中:
push('egg')
push('ham')
push('spam')
print(data[0 : top + 1] )
push('new')
push('new2')
在上述代码中,当我们尝试插入前三个元素时,由于有足够的空间,它们被添加到栈中,但当我们尝试添加数据元素new和new2时,栈已经满了,因此这两个元素不能添加到栈中。此代码的输出如下:
['egg', 'ham', 'spam']
Stack Overflow
Stack Overflow
接下来,pop操作返回栈顶元素的值并将其从栈中移除。首先,我们检查栈是否为空。如果栈已经为空,则打印栈下溢信息。否则,从栈中移除栈顶。pop操作的概述如图 5.5 所示:

图 5.5:使用数组实现的栈的pop操作序列
pop操作的 Python 代码如下:
def pop():
global top
if top == -1:
print("Stack Underflow")
else:
top = top – 1
data[top] = 0
return data[top+1]
在上述代码中,我们首先通过检查栈是否为空来检查下溢条件。如果top指针的值为-1,则意味着栈为空。否则,通过将top指针减 1 来移除栈中的数据元素,并将栈顶数据元素返回到主函数。
假设我们已将三个数据元素添加到栈中,然后我们调用pop函数四次。由于栈中只有三个元素,所以最初的前三个数据元素被移除,当我们尝试第四次调用pop操作时,会打印出栈下溢信息。这如下面的代码片段所示:
print(data[0 : top + 1])
pop()
pop()
pop()
pop()
print(data[0 : top + 1])
上述代码的输出如下:
['egg', 'ham', 'spam']
Stack Underflow
[]
接下来,让我们看看 peek 操作的实现,其中我们返回栈顶元素的价值。这个操作的 Python 代码如下:
def peek():
global top
if top == -1:
print("Stack is empty")
else:
print(data[top])
在上述代码中,首先,我们检查栈中 top 指针的位置。如果 top 指针的值为 -1,则表示栈为空,否则,我们打印栈顶元素的价值。
我们已经讨论了使用数组实现的 Python 栈,所以接下来让我们讨论使用链表实现的栈。
使用链表的栈实现
为了使用链表实现栈,我们将编写一个 Stack 类,其中将声明所有方法;然而,我们还将使用与上一章中讨论的类似的 node 类:
class Node:
def __init__(self, data=None):
self.data = data
self.next = None
如我们所知,链表中的一个 node 包含数据和指向链表中下一个元素的引用。使用链表实现栈数据结构可以被视为一个具有一些约束的标准链表,包括元素可以通过 top 指针从列表的末尾添加或删除(push 和 pop 操作)。这如图 5.6 所示:

图 5.6:使用链表表示栈
现在我们来看看 stack 类。它的实现与单链表非常相似。此外,为了实现栈,我们还需要两样东西:
-
我们首先需要知道栈顶是哪个节点,这样我们就可以通过这个节点应用
push和pop操作 -
我们还希望跟踪栈中节点的数量,因此我们在
Stack类中添加了一个size变量
Stack class:
class Stack:
def __init__(self):
self.top = None
self.size = 0
在上述代码中,我们已声明 top 和 size 变量,它们被初始化为 None 和 0。一旦初始化了 Stack 类,接下来,我们将在 Stack 类中实现不同的操作。首先,让我们从对 push 操作的讨论开始。
push 操作
push 操作是栈上的一个重要操作;它用于在栈顶添加一个元素。为了将新节点添加到栈中,首先,我们需要检查栈中是否已经有了一些项目或者它是空的。在这里,我们不需要检查溢出条件,因为我们不需要固定栈的长度,这与使用数组实现的栈不同。
如果栈已经有一些元素,那么我们必须做两件事:
-
新节点必须将其下一个指针指向之前位于顶部的节点
-
我们通过将
self.top指向新添加的节点,将这个新节点放在栈顶
请参见以下 图 5.7 中的两个指令:

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

图 5.8:将数据元素“鸡蛋”插入到空栈中
以下是在Stack类中定义的push操作的完整实现:
def push(self, data):
# create a new node
node = Node(data)
if self.top:
node.next = self.top
self.top = node
else:
self.top = node
self.size += 1
在上述代码中,我们创建了一个新的节点并将数据存储在其中。然后我们检查top指针的位置。如果它不是null,这意味着栈不为空,我们添加新的节点,并更新两个指针,如图5.7所示。在else部分,我们将top指针指向新的节点。最后,我们通过增加self.size变量的值来增加栈的大小。
要创建包含三个数据元素的栈,我们使用以下代码:
words = Stack()
words.push('egg')
words.push('ham')
words.push('spam')
#print the stack elements.
current = words.top
while current:
print(current.data)
current = current.next
上述代码的输出如下:
spam
ham
egg
在上述代码中,我们创建了一个包含三个元素(鸡蛋、火腿和香肠)的栈。接下来,我们将讨论栈数据结构中的pop操作。
Pop 操作
应用到栈上的另一个重要操作是pop操作。在这个操作中,读取栈顶元素,然后从栈中移除。pop方法返回栈顶元素,如果栈为空则返回None。
要在栈上实现pop操作,我们执行以下操作:
-
首先,检查栈是否为空。不允许在空栈上执行
pop操作。 -
如果栈不为空,检查栈顶节点是否有其
next属性指向其他节点。如果是这样,这意味着栈包含元素,栈顶节点指向栈中的下一个节点。要应用pop操作,我们必须更改栈顶指针。下一个节点应该位于栈顶。我们通过将self.top指向self.top.next来实现这一点。请参见以下图 5.9以了解这一点:

图 5.9:栈上 pop 操作的原理
- 当栈中只有一个节点时,
pop操作后栈将为空。我们必须将栈顶指针更改为None。请参见以下图 5.10:

图 5.10:栈中只有一个元素时的 pop 操作
-
移除此节点会导致
self.top指向None,如图5.10所示。 -
如果栈不为空,我们也会将栈的大小减
1。
这里是 Python 中栈的pop操作的代码,应该在Stack类中定义:
def pop(self):
if self.top:
data = self.top.data
self.size -= 1
if self.top.next: #check if there is more than one node.
self.top = self.top.next
else:
self.top = None
return data
else:
print("Stack is empty")
在上述代码中,首先,我们检查top指针的位置。如果它不是null,这意味着栈不为空,我们可以应用pop操作,如果栈中有多个数据元素,我们将top指针移动到指向下一个节点(参见图 5.9),如果是最后一个节点,我们将top指针指向None(参见图 5.10)。我们还通过减少self.size变量的值来减少栈的大小。
假设我们在栈中有三个数据元素。我们可以使用以下代码对栈应用pop操作:
words.pop()
current = words.top
while current:
print(current.data)
current = current.next
上述代码的输出如下:
ham
egg
在上面的代码中,我们从包含三个元素的栈中弹出了栈顶元素egg、ham、spam。
接下来,我们将讨论在栈数据结构上使用的peek操作。
查看操作
可以应用于栈的另一个重要操作是peek方法。此方法从栈中返回栈顶元素,但不将其从栈中删除。peek和pop之间的唯一区别是peek方法仅返回最顶层的元素;然而,在pop方法的情况下,返回最顶层的元素,并且该元素也被从栈中删除。
peek操作允许我们查看栈顶元素而不改变栈。这个操作非常简单。如果有顶部元素,返回其数据;否则,返回None(因此,peek的行为与pop匹配)。peek方法的实现如下(这应该在Stack类中定义):
def peek(self):
if self.top:
return self.top.data
else:
print("Stack is empty")
在上述代码中,我们首先使用self.top检查top指针的位置。如果不是 null,这意味着栈不为空,我们返回最顶层节点的数据值,否则,我们打印出栈为空的消息。我们可以使用peek方法通过以下代码获取栈的顶部元素:
words.peek()
上述代码的输出如下:
spam
根据我们最初添加到栈中的三个数据元素的示例,如果我们使用peek方法,我们将得到栈顶元素spam作为输出。
栈是一个重要的数据结构,具有多个实际应用。为了更好地理解栈的概念,我们将讨论这些应用之一:使用栈进行括号匹配。
栈的应用
如我们所知,数组和链表数据结构可以执行栈或队列数据结构(我们将在稍后讨论)所能做的任何事情。
尽管如此,这些数据结构因其许多应用而变得重要。例如,在任何应用程序中,可能需要以特定顺序添加或删除任何元素。栈和队列可用于此,以避免程序中可能出现的任何潜在错误,例如从列表中间访问/删除元素(这可能在数组和链表的情况下发生)。
现在我们来看一个括号匹配应用的示例,看看我们如何使用我们的栈来实现它。
让我们编写一个函数check_brackets,该函数将验证包含括号的表达式——( )、[ ]或{ }——是否平衡,即关闭括号的数量是否与打开括号的数量匹配。由于栈遵循LILO规则,这使得它们在解决这个问题时是一个很好的选择,因此栈可以用于以相反的顺序遍历项目列表。
以下代码是为在Stack类外部定义的check_brackets方法。此方法将使用我们在上一节中讨论的Stack类。该方法接受由字母字符和括号组成的表达式作为输入,并输出True或False,分别表示给定的表达式是否有效。check_brackets方法的代码如下:
def check_brackets(expression):
brackets_stack = Stack() #The stack class, we defined in previous section.
last = ' '
for ch in expression:
if ch in ('{', '[', '('):
brackets_stack.push(ch)
if ch in ('}', ']', ')'):
last = brackets_stack.pop()
if last == '{' and ch == '}':
continue
elif last == '[' and ch == ']':
continue
elif last == '(' and ch == ')':
continue
else:
return False
if brackets_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))
只有三个语句中的第一个应该匹配。当我们运行代码时,我们得到以下输出:
{(foo)(bar)}hellois)a)test: True
{(foo)(bar)}hellois)atest: False
{(foo)(bar)}hellois)a)test)): False
在上述三个示例表达式中,我们可以看到第一个表达式是有效的,而其他两个不是有效的表达式。因此,前面代码的输出是True、False和False。
总结来说,栈数据结构的push、pop和peek操作的时间复杂度为O(1),因为添加和删除操作可以通过top指针直接在常数时间内执行。栈数据结构很简单;然而,它在现实世界的应用中用于实现许多功能。例如,网络浏览器的后退和前进按钮就是使用栈实现的。栈也被用于在文字处理器中实现撤销和重做功能。
我们已经讨论了栈数据结构及其使用数组和链表实现的实现。在下一节中,我们将讨论队列数据结构以及可以应用于队列的不同操作。
队列
另一个重要的数据结构是队列,它以类似于栈和链表的方式存储数据,但有一些约束和特定的顺序。队列数据结构与你在现实生活中习惯的常规队列非常相似。它就像在商店里按顺序排队等待被服务的人群。队列是一个基本且重要的概念,需要掌握,因为许多其他数据结构都是基于它构建的。
队列的工作原理如下。第一个加入队列的人通常会先被服务,所有人都会按照他们加入队列的顺序被服务。缩写FIFO最好地解释了队列的概念。FIFO代表先进先出。当人们排队等待服务时,服务只在队列的前端进行。因此,人们从队列的前端出队,从后端入队等待他们的轮次。人们退出队列的唯一时间是当他们被服务,这只会发生在队列的最前端。参考以下图表,其中人们正在排队,排在前面的人将首先被服务:

图 5.11:队列的示意图
要加入队列,参与者必须站在队列中最后一个人的后面。这是队列接受新成员的唯一合法或允许的方式。队列的长度无关紧要。
队列是一个具有以下约束的元素序列:
-
数据元素只能从队列的一端插入,即队列的尾部/尾端。
-
数据元素只能从队列的另一端,即前端删除。
-
只能读取队列前端的数据元素。
向队列中添加元素的操作称为enqueue。从队列中删除元素使用dequeue操作。每当一个元素被入队时,队列的长度或大小增加 1,出队一个项目将减少队列中的元素数量 1。
我们可以在图 5.12中看到这个概念,其中我们可以向尾部/尾端添加新元素,而元素只能从队列的头部/前端删除:

图 5.12:使用栈数据结构实现的队列
读者请注意不要混淆符号:enqueue操作将在尾部/尾端执行,而dequeue操作将从头部/前端执行。应确保一个端点用于enqueue操作,另一个端点用于dequeue操作;然而,每个操作都可以使用任一端点。通常,固定从尾部执行enqueue操作,从前端执行dequeue操作是好的。为了演示这两个操作,以下表格显示了向队列中添加和删除元素的效果:
| 队列操作 | Size | Contents | 操作结果 |
|---|---|---|---|
queue() |
0 | [] |
创建了一个队列对象,它是空的。 |
enqueue- "packt" |
1 | ['packt'] |
向队列中添加了一个元素,packt。 |
enqueue "publishing" |
2 | [ 'packt', 'publishing'] |
向队列中又添加了一个元素,publishing。 |
Size() |
2 | [ 'packt', 'publishing'] |
返回队列中的项目数量,在这个例子中是 2。 |
dequeue() |
1 | ['publishing'] |
packt项目被出队并返回。(这个项目最先被添加,所以最先被移除。) |
dequeue() |
0 | [] |
publishing项目被出队并返回。(这是最后添加的项目,所以最后被返回。) |
表 5.2:示例队列上不同操作的说明
Python 中的队列数据结构有一个内置实现,即queue.Queue,也可以使用collections模块中的deque类来实现。队列数据结构可以使用 Python 中的各种方法来实现,即:(1) Python 的内置列表,(2) 栈,和(3) 基于节点的链表。我们将逐一详细讨论它们。
Python 的基于列表的队列
首先,为了实现基于 Python 的list数据结构的队列,我们创建一个ListQueue类,在其中声明和定义队列的不同功能。在这个方法中,我们使用 Python 的list数据结构存储实际数据。ListQueue类的定义如下:
class ListQueue:
def __init__(self):
self.items = []
self.front = self.rear = 0
self.size = 3 # maximum capacity of the queue
在__init__初始化方法中,items实例变量被设置为[],这意味着当创建队列时它是空的。队列的大小也被设置为4(作为此代码中的示例),这是队列中可以存储元素的最大容量。此外,rear 和 front 索引的初始位置也被设置为0。"enqueue"和"dequeue"是队列中的重要方法,我们将在下面讨论。
入队操作
enqueue操作在队列的末尾添加一个项目。考虑将元素添加到队列的例子来理解图 5.13中展示的概念。我们从一个空列表开始。最初,我们在索引0处添加一个项目3。
接下来,我们在索引1处添加一个项目11,每次添加一个元素时都移动 rear 指针:

图 5.13:队列上入队操作的示例
为了实现入队操作,我们使用List类的append方法将项目(或数据)追加到队列的末尾。以下代码展示了enqueue方法的实现。这应该在ListQueue类中定义:
def enqueue(self, data):
if self.size == self.rear:
print("\n Queue is full")
else:
self.items.append(data)
self.rear += 1
在这里,我们首先通过比较队列的最大容量与rear索引的位置来检查队列是否已满。进一步地,如果有空间在队列中,我们使用List类的append方法将数据添加到队列的末尾,并将 rear 指针增加1。
要使用ListQueue类创建队列,我们使用以下代码:
q= ListQueue()
q.enqueue(20)
q.enqueue(30)
q.enqueue(40)
q.enqueue(50)
print(q.items)
上述代码的输出如下:
Queue is full
[20, 30, 40]
在上面的代码中,我们可以添加最多三个数据元素,因为我们已经将队列的最大容量设置为3。添加三个元素后,当我们尝试添加另一个新元素时,我们会收到队列已满的消息。
出队操作
dequeue操作用于从队列中读取和删除项。此方法返回队列的前项并将其删除。考虑从队列中出队元素的示例,如图 5.14所示。在这里,我们有一个包含元素{3, 11, 7, 1, 4, 2}的队列。为了从这个队列中出队任何元素,首先插入的元素将被移除,因此元素3被移除。当我们从队列中出队任何元素时,我们也会将rear指针减1:

图 5.14. 队列上出队操作的示例
以下是在ListQueue类中定义的dequeue方法的实现:
def dequeue(self):
if self.front == self.rear:
print("Queue is empty")
else:
data = self.items.pop(0) # delete the item from front end of the queue
self.rear -= 1
return data
在上述代码中,我们首先通过比较前指针和后指针来检查队列是否已经为空。如果后指针和前指针相同,则意味着队列是空的。如果队列中有一些元素,我们使用pop方法来出队一个元素。Python 的List类有一个名为pop()的方法。pop方法执行以下操作:
-
从列表中删除最后一个元素
-
将删除的项从列表返回给调用它的用户或代码
前变量指向的第一个位置的元素被弹出并保存在data变量中。我们还将rear变量减1,因为队列中已经删除了一个数据项。最后,在方法的最后一行,返回数据。
要从现有的队列(例如{20, 30, 40})中出队任何元素,我们使用以下代码:
data = q.dequeue()
print(data)
print(q.items)
上述代码的输出如下:
20
[30, 40]
在上述代码中,当我们从队列中出队一个元素时,我们得到元素 20,这是第一个添加的。
这种队列实现方法的局限性在于队列的长度是固定的,这可能不适合高效实现队列。现在,让我们讨论基于链表的队列实现。
基于链表的队列
队列数据结构也可以使用任何链表实现,例如单链表或双向链表。我们已经在之前的第四章,链表中讨论了单链表或双向链表的实现。我们使用遵循队列数据结构FIFO属性的链表来实现队列。
让我们讨论使用双向链表实现队列的实现。为此,我们首先从实现node类开始,这个node类与我们之前在第四章,链表中讨论双向链表时定义的node类相同。此外,Queue类与双向链表类非常相似。在这里,我们有head和tail指针,其中tail指向队列的末尾(即后端),它将被用于添加新元素,而head指针指向队列的起始位置(即前端),它将被用于从队列中出队元素。Queue类的实现如下所示:
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.head和self.tail指针被设置为None。为了保持队列中节点数量的计数,这里还维护了一个count实例变量,并初始设置为0。
enqueue操作
元素通过enqueue方法添加到Queue对象中。数据元素通过节点添加。enqueue方法的代码与我们讨论过的双向链表的append操作非常相似,该操作在第四章,链表中讨论过。
enqueue操作从传递给它的数据创建一个节点并将其添加到队列的tail。
首先,我们检查要入队的新节点是否是第一个节点,以及队列是否为空。如果队列为空,新节点将成为队列的第一个节点,如图图 5.15所示:

图 5.15:在空队列中入队新节点的示意图
如果队列不为空,新节点将被添加到队列的末尾。为了做到这一点并将一个元素入队到一个现有的队列中,我们需要通过更新三个链接来完成:1) 新节点的上一个指针应指向队列的尾部,2) 尾节点的下一个指针应指向新节点,3) 尾指针应更新为新节点。所有这些链接在图 5.16中展示:

图 5.16:队列中enqueue操作要更新的链接示意图
enqueue操作在Queue类中实现,如下面的代码所示:
def enqueue(self, data):
new_node = Node(data, None, None)
if self.head == 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
在上面的代码中,我们首先检查队列是否为空。如果head指向None,这意味着队列为空。如果队列为空,新节点将成为队列的第一个节点,我们将self.head和self.tail都指向新创建的节点。如果队列不为空,我们通过更新图 5.16中显示的三个链接将新节点添加到队列的末尾。最后,通过self.count += 1行增加队列中元素的总数。
队列上enqueue操作的 worst-case 时间复杂度是O(1),因为任何项目都可以通过tail指针在常数时间内直接添加。
dequeue操作
使双向链表表现得像队列的另一个操作是dequeue方法。此方法移除队列前端的节点,如图图 5.17所示。在这里,我们首先检查要出队的元素是否是队列的最后一个节点,如果是,那么在dequeue操作后队列将被清空。如果不是这种情况,我们通过更新前/头指针到下一个节点和新头节点的上一个指针为None来出队第一个元素,如图图 5.17所示:

图 5.17:队列上的dequeue操作示意图
队列上dequeue操作的实现与从给定的双向链表中删除第一个元素非常相似,如下面的dequeue操作代码所示:
def dequeue(self):
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
elif self.count <1:
print("Queue is empty")
self.count -= 1
为了从队列中删除任何元素,我们首先使用self.count变量检查队列中的项目数量。如果self.count变量等于1,这意味着要删除的元素是最后一个元素,然后我们更新头和尾指针为None。
如果队列中有许多节点,则将头指针移动到self.head之后的下一个节点,通过更新图 5.17中显示的两个链接来实现。我们还检查队列中是否还有剩余的项目,如果没有,则打印出队列为空的消息。最后,将self.count变量减1。
队列中出队操作的最坏情况时间复杂度为O(1),因为任何项目都可以通过head指针在常数时间内直接移除。
基于栈的队列
队列是一种线性数据结构,其中入队操作在一端执行,删除(出队)操作在另一端执行,遵循FIFO原则。使用栈实现队列有两种方法:
-
当出队操作成本较高时
-
当入队操作成本较高时
方法 1:当出队操作成本较高时
我们使用两个栈来实现队列。在这个方法中,入队操作很简单。可以使用对第一个栈(换句话说,栈 1)的推操作将新元素入队,该栈用于队列的实现。
入队操作在图 5.18中展示,显示了将元素{23, 13, 11}入队到队列的示例:

图 5.18:使用方法 1 在队列中执行入队操作的示意图
此外,出队操作可以通过以下步骤使用两个栈(栈 1 和栈 2)来实现:
-
首先,从栈 1 中移除元素(弹出),然后逐个将所有元素添加(推入)到栈 2。
-
最顶部的数据元素将从栈 2 弹出,并作为所需元素返回。
-
最后,将剩余的元素逐个从栈 2 弹出,然后再次推入栈 1。
让我们通过一个例子来帮助理解这个概念。假设我们在队列中存储了三个元素{23, 13, 11},现在我们想要从这个队列中删除一个元素。完整的流程如上三个步骤所示在图 5.19中。正如你可能注意到的,这个实现遵循队列的FIFO属性,因此返回了 23,因为它是最先添加的:

图 5.19:使用方法 1 在队列中执行出队操作的示意图
入队操作的最坏情况时间复杂度为 O(1),因为任何元素都可以直接添加到第一个栈中,而出队操作的时间复杂度为 O(n),因为所有元素都需要从 Stack-1 转移到 Stack-2。
方法 2:当入队操作成本较高时
在这种方法中,入队操作与之前讨论的出队操作非常相似,而出队操作同样与之前的入队操作相似。
为了实现入队操作,我们遵循以下步骤:
-
将所有元素从 Stack-1 移动到 Stack-2。
-
将要入队的元素推入 Stack-2。
逐个将所有元素从 Stack-2 移动到 Stack-1。从 Stack-2 弹出元素并将其推入 Stack-1。
让我们通过一个例子来理解这个概念。假设我们想依次将三个元素 {23, 13, 11} 入队到队列中。我们可以通过遵循上述三个步骤来实现,如图 图 5.20、图 5.21 和 图 5.22 所示:

图 5.20:使用方法 2 将元素 23 入队到空队列中

图 5.21:使用方法 2 将元素 13 入队到现有队列中

图 5.22:使用方法 2 将元素 11 入队到队列中
出队操作可以通过对 Stack-1 应用弹出操作直接实现。让我们通过一个例子来理解这一点。假设我们已入队了三个元素,并想执行出队操作,我们可以简单地从栈中弹出顶部元素,如图 图 5.23 所示:

图 5.23:使用方法 2 在队列上执行出队操作的示意图
在第二种方法中,入队操作的时间复杂度为 O(n),而出队操作的时间复杂度为 O(1)。
接下来,我们将讨论使用方法-1 实现队列的实现,其中出队操作成本较高。为了使用两个栈实现队列,我们最初设置两个栈实例变量以在初始化时创建一个空队列。在这种情况下,栈只是允许我们调用其上的 push 和 pop 方法的 Python 列表,这使我们能够获得 enqueue 和 dequeue 操作的功能。以下是 Queue 类:
class Queue:
def __init__(self):
self.Stack1 = []
self.Stack2 = []
Stack1仅用于存储添加到队列中的元素。在此栈上不能执行其他操作。
入队操作
enqueue 方法用于向队列中添加项目。此方法仅接收要追加到队列中的 data。然后,该数据被传递到 Queue 类中 Stack1 的 append 方法。此外,append 方法用于模拟 push 操作,该操作将元素推到栈顶。以下是在 Python 中使用栈实现 enqueue 的代码,应在 Queue 类中定义:
def enqueue(self, data):
self.Stack1.append(data)
要将数据入队到 Stack1,以下代码可以完成这项工作:
queue = Queue()
queue.enqueue(23)
queue.enqueue(13)
queue.enqueue(11)
print(queue.Stack1)
Stack1 在队列上的输出如下:
[23, 13, 11]
接下来,我们将检查 dequeue 操作的实现。
dequeue 操作
dequeue 操作用于根据 FIFO 原则,以添加项的相同顺序从队列中删除元素。新元素添加到 Stack1 的队列中。此外,我们使用另一个栈,即 Stack2,来从队列中删除元素。删除(dequeue)操作仅通过 Stack2 执行。为了更好地理解如何使用 Stack2 从队列中删除项,让我们考虑以下示例。
初始时,假设 Stack2 已填充了元素 5、6 和 7,如图 图 5.24 所示:

图 5.24. 队列中 Stack1 的示例
接下来,我们检查 Stack2 是否为空。由于开始时它是空的,我们使用 Stack1 上的 pop 操作将所有要删除的元素从 Stack1 移动到 Stack2,并对所有元素进行操作,然后将它们推送到 Stack2。现在,Stack1 为空,Stack2 包含所有元素。我们在 图 5.25 中展示这一点以获得更清晰的了解:

图 5.25. Stack1 和 Stack2 在队列中的演示
现在,如果 Stack 不为空,为了从这个队列中弹出元素,我们应用 pop 操作到 Stack2,我们得到元素 5,这是正确的,因为它是最先添加的,应该是队列中第一个被弹出的元素。
这里是队列的 dequeue 方法的实现,该方法应在 Queue 类中定义:
def dequeue(self):
if not self.Stack2:
while self.Stack1:
self.Stack2.append(self.Stack1.pop())
if not self.Stack2:
print("No element to dequeue")
return
return self.Stack2.pop()
if 语句首先检查 Stack2 是否为空。如果不为空,我们继续使用 pop 方法从队列的前端移除元素,如下所示:
return self.Stack2.pop()
如果 Stack2 为空,则将 Stack1 的所有元素移动到 Stack2:
while self.Stack1:
self.Stack2.append(self.Stack1.pop())
while 循环将一直执行,直到 Stack1 中有元素为止。
self.Stack1.pop() 语句将移除 Stack1 中最后添加的元素,并将其立即传递给 self.Stack2.append() 方法。
让我们考虑一些示例代码来理解队列上的操作。我们首先使用 Queue 实现向队列添加三个项,即 5、6 和 7。然后,我们应用 dequeue 操作来从队列中移除项,如下所示:
queue = Queue()
queue.enqueue(23)
queue.enqueue(13)
queue.enqueue(11)
print(queue.Stack1)
queue.dequeue()
print(queue.Stack2)
上述代码的输出如下:
[23, 13, 11]
[13, 11]
dequeue method is called, after which a change in the number of elements is observed when the queue is printed out again.
使用方法 1 的栈在队列数据结构上执行 enqueue 和 dequeue 操作的时间复杂度分别为 O(1) 和 O(n)。这是因为 enqueue 操作很简单,因为可以直接添加新元素,而在 dequeue 操作中,需要访问并移动所有 n 个元素到另一个栈。
总体来说,基于链表的实现是最高效的,因为入队和出队操作都可以在O(1)时间内完成,且队列的大小没有限制。在基于栈的实现中,队列的一个操作成本较高,无论是入队还是出队。
队列的应用
队列可以用于在许多基于计算机的实际应用中实现各种功能。例如,而不是为网络中的每一台计算机提供其自己的打印机,可以通过排队每台计算机想要打印的内容,使计算机网络共享一台打印机。当打印机准备好打印时,它会从队列中选择一个项目(通常称为作业)进行打印。它会先打印发出命令的计算机的命令,然后按照不同计算机提交的顺序选择后续作业。
操作系统也会将进程排队以供 CPU 执行。让我们创建一个利用队列创建基本媒体播放器的应用程序。
大多数音乐播放器软件允许用户将歌曲添加到播放列表中。按下播放按钮后,主播放列表中的所有歌曲将依次播放。由于首先入队的歌曲是首先播放的歌曲,因此顺序播放歌曲可以通过队列实现,这与FIFO(先进先出)的缩写一致。我们将实现自己的播放列表队列以FIFO方式播放歌曲。
我们的媒体播放器队列只允许添加曲目以及播放队列中所有曲目的方式。在一个完整的音乐播放器中,会使用线程来改善队列的交互方式,同时音乐播放器继续用于选择下一首将要播放、暂停或甚至停止的歌曲。
track类将模拟一个音乐曲目:
from random import randint
class Track:
def __init__(self, title=None):
self.title = title
self.length = randint(5, 10)
每个曲目都包含歌曲标题的引用以及歌曲的长度。歌曲的长度是介于5和10之间的随机数。Python 中的random模块提供了randint函数,使我们能够生成随机数。该类代表任何 MP3 曲目或包含音乐的文件。曲目的随机长度用于模拟播放曲目所需的时间(以秒为单位)。
要创建一些曲目并打印它们的长度,我们执行以下操作:
track1 = Track("white whistle")
track2 = Track("butter butter")
print(track1.length)
print(track2.length)
上述代码的输出如下:
6
7
您的输出可能因为两个曲目生成的随机长度而不同。
现在,让我们通过继承来创建我们的队列。我们简单地从Queue类继承:
import time
class MediaPlayerQueue(Queue):
要将曲目添加到队列中,在MediaPlayerQueue类中创建了一个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
程序执行后,可以看到轨道是按照它们入队的顺序播放的。在播放每个轨道时,系统也会暂停与轨道长度相等的秒数。
摘要
在本章中,我们讨论了两种重要的数据结构,即栈和队列。我们看到了这些数据结构如何紧密地模拟现实世界中的栈和队列。我们探讨了具体实现及其不同类型。后来,我们将栈和队列的概念应用于编写现实生活中的程序。
我们将在下一章考虑树。我们将讨论树的主要操作,以及此数据结构的各种应用领域。
练习
-
以下哪个选项是使用链表实现的真正队列?
-
如果在入队操作中,新的数据元素被添加到列表的起始位置,那么出队操作必须从列表的末尾进行。
-
如果在入队操作中,新的数据元素被添加到列表的末尾,那么入队操作必须从列表的起始位置进行。
-
以上两者都是。
-
以上皆非。
-
-
假设队列使用具有头指针和尾指针的单链表实现。入队操作在队列头部实现,出队操作在队列尾部实现。入队和出队操作的时间复杂度是多少?
-
实现队列需要多少个栈?
-
队列中的入队(enqueue)和出队(dequeue)操作使用数组实现时效率很高。这两个操作的时间复杂度是多少?
-
我们如何以相反的顺序打印队列数据结构的数据元素?
加入我们的 Discord 社区
加入我们的社区 Discord 空间,与作者和其他读者进行讨论:packt.link/MEvK4

第六章:树
树是一种层次化的数据结构。列表、队列和栈等数据结构是线性的,因为项目以顺序方式存储。然而,树是一种非线性数据结构,因为项目之间存在父子关系。树的顶端数据结构被称为根节点。这是树中所有其他节点的祖先。
树数据结构非常重要,因为它们在各种应用中使用,例如解析表达式、高效搜索和优先队列。某些文档类型,如XML和HTML,也可以用树来表示。
在本章中,我们将介绍以下主题:
-
树的术语和定义
-
二叉树和二叉搜索树
-
树遍历
-
二叉搜索树
术语
让我们考虑一些与树数据结构相关的术语。
要理解树,我们首先需要了解与它们相关的概念。树是一种数据结构,其中数据以层次形式组织。
图 6.1包含一个典型的树,由字母A到M的字符节点组成:

图 6.1:示例树数据结构
下面是一个与树相关的术语列表:
-
节点:在前面的图中,每个圆圈中的字母代表一个节点。节点是任何存储数据的结构。
-
根节点:根节点是从其派生所有其他树节点的第一个节点。换句话说,根节点是一个没有父节点的节点。在每一棵树中,总有一个唯一的根节点。在上面的示例树中,根节点是节点
A。 -
子树:子树是从某个其他树派生出的树。例如,节点
F、K和L是原始树的子树。 -
度:给定节点的子节点总数称为该节点的度。只有一个节点的树具有度 0。在前面的图中,节点
A的度是 2,节点B的度是 3,节点C的度是 3,节点G的度是 1。 -
叶节点:叶节点没有子节点,是给定树的终端节点。叶节点的度数总是 0。在前面的图中,节点
J、E、K、L、H、M和I都是叶节点。 -
边:树中任意两个节点之间的连接称为边。给定树中的边总数将最多比节点总数少一个。一个示例边在图 6.1中显示。
-
父节点:具有子树节点的节点是该子树的父节点。例如,节点
B是节点D、E和F的父节点,节点F是节点K和L的父节点。 -
子节点:这是一个从父节点派生的节点。例如,节点
B和C是父节点A的子节点,而节点H、G和I是父节点C的子节点。 -
兄弟节点:所有具有相同父节点的节点都是兄弟节点。例如,节点
B是节点C的兄弟节点,同样地,节点D、E和F也是兄弟节点。 -
层级:树的根节点被认为是处于层级 0。根节点的子节点被认为是处于层级 1,层级 1 的节点的子节点被认为是处于层级 2,依此类推。例如,在 图 6.1 中,根节点
A处于层级 0,节点B和C处于层级 1,节点D、E、F、H、G和I处于层级 2。 -
树的高度:树中最长路径上的节点总数是树的高度。例如,在 图 6.1 中,树的高度是 4,因为最长的路径
A-B-D-J、A-C-G-M和A-B-F-K都有四个节点。 -
深度:一个节点的深度是从树的根节点到该节点的边的数量。在先前的树示例中,节点
H的深度是 2。
在线性数据结构中,数据项以顺序存储,而非线性数据结构以非线性顺序存储数据项,其中数据项可以连接到多个其他数据项。线性数据结构(如 数组、列表、栈 和 队列)中的所有数据项都可以一次遍历,而在非线性数据结构(如树)的情况下则不可能;它们以与其他线性数据结构不同的方式存储数据。
在树数据结构中,节点以父子关系排列。树中的节点之间不应存在任何循环。树结构通过节点形成层次结构,没有节点的树称为空树。
首先,我们将讨论最重要的一种树,那就是二叉树。
二叉树
二叉树是由节点组成的集合,其中树中的节点可以有零个、一个或两个子节点。一个简单的二叉树最多有两个子节点,即左子节点和右子节点。
例如,在 图 6.2 中显示的二叉树中,有一个根节点,它有两个子节点(一个左子节点,一个右子节点):

图 6.2:二叉树的示例
二叉树中的节点以左子树和右子树的形式组织。例如,图 6.3 中显示的五个节点的树有一个根节点 R 和两个子树,即左子树 T1 和右子树 T2:

图 6.3:五个节点的二叉树示例
正规的二叉树在元素如何排列在树中没有其他规则。它应该只满足每个节点最多有两个子节点的条件。
如果二叉树的所有节点要么没有子节点,要么有两个子节点,并且没有节点只有一个子节点,则该树被称为满二叉树。图 6.4 中显示了一个满二叉树的示例:

图 6.4:满二叉树的示例
一个完全二叉树填充了二叉树中的所有节点,并且没有空间为任何新节点留白;如果我们添加新节点,它们只能通过增加树的高度来添加。一个示例完全二叉树如图图 6.5所示:

图 6.5:完全二叉树的示例
一个完全二叉树填充了除了树最低层可能有一个例外之外的所有可能的节点。所有节点也都在左侧填充。一个完全二叉树如图图 6.6所示:

图 6.6:完全二叉树的示例
二叉树可以是平衡的或不平衡的。在一个平衡的二叉树中,树中每个节点的左右子树的高度差不超过 1。一个平衡的树如图图 6.7所示:

图 6.7:平衡树的示例
不平衡的二叉树是右子树和左子树之间高度差超过 1 的二叉树。一个不平衡树的示例如图图 6.8所示:

图 6.8:不平衡树的示例
接下来,我们将讨论简单二叉树实现的细节。
树节点的实现
如我们已在前面章节中讨论过的,一个节点由数据项和其他节点的引用组成。
在二叉树节点中,每个节点将包含数据项和两个引用,分别指向它们的左子树和右子树。让我们看看以下代码,用于在 Python 中构建二叉树Node类:
class Node:
def __init__(self, data):
self.data = data
self.right_child = None
self.left_child = None
为了更好地理解这个类的运作,让我们首先创建一个包含四个节点——n1、n2、n3和n4——的二叉树,如图图 6.9所示:

图 6.9:四个节点的示例二叉树
为了此,我们首先创建四个节点——n1、n2、n3和n4:
n1 = Node("root node")
n2 = Node("left child node")
n3 = Node("right child node")
n4 = Node("left grandchild node")
Figure 6.9:
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
根据访问根节点、左子树或右子树的顺序,有多种处理和遍历树的方法。主要来说,有两种方法,第一种是我们从一个节点开始,遍历所有可用的子节点,然后继续遍历到下一个兄弟节点。这种方法有三种可能的变体,即中序、先序和后序。另一种遍历树的方法是从根节点开始,然后访问每一层的所有节点,并逐层处理节点。我们将在以下各节中讨论每种方法。
中序遍历
中序树遍历的工作原理如下:我们开始递归地遍历左子树,一旦左子树被访问,就访问根节点,然后最终递归地访问右子树。它有以下三个步骤:
-
我们开始遍历左子树,并递归地调用排序函数
-
接下来,我们访问根节点
-
最后,我们遍历右子树,并递归地调用排序函数
因此,简而言之,对于中序树遍历,我们按照左子树、根、然后右子树的顺序访问树中的节点。
让我们考虑一个在图 6.10中显示的示例树,以理解中序树遍历:

图 6.10:用于中序树遍历的示例二叉树
在图 6.10中显示的二叉树中,中序遍历的工作原理如下:首先,我们递归地访问根节点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(root_node):
current = root_node
if current is None:
return
inorder(current.left_child)
print(current.data)
inorder(current.right_child)
inorder(n1)
首先,我们检查当前节点是否为空或为空。如果不为空,我们遍历树。我们通过打印访问的节点来访问节点。在这种情况下,我们首先递归地调用inorder函数,传入current.left_child,然后访问根节点,最后递归地调用inorder函数,传入current.right_child。
最后,当我们将上述中序遍历算法应用于上述四个节点的示例树时,以n1作为根节点,我们得到以下输出:
left grandchild node
left child node
root node
right child node
接下来,我们将讨论前序遍历。
前序遍历
前序树遍历按照根节点、左子树,然后是右子树的顺序遍历树。它的工作方式如下:
-
我们从根节点开始遍历
-
接下来,我们遍历左子树,并递归地调用带有左子树的排序函数
-
接下来,我们递归地访问右子树并调用带有右子树的排序函数
考虑图 6.11所示的示例树来理解前序遍历:

图 6.11:理解前序遍历的示例树
如图 6.11所示的示例二叉树的前序遍历工作如下:首先,我们访问根节点A。接下来,我们访问根节点A的左子树。节点A的左子树以节点B为根,因此我们访问这个根节点,然后访问根节点B的左子树,节点D。我们访问节点D,然后访问根节点D的左子树,然后访问左孩子,G,它是根节点D的子树。由于节点G没有子节点,我们访问右子树。我们访问根节点D的子树的右孩子,节点H。接下来,我们访问根节点B的子树的右孩子,节点E。
以这种方式,我们已经访问了根节点A和根节点A的左子树。接下来,我们访问根节点A的右子树。在这里,我们访问根节点C,然后我们访问根节点C的左子树,它是空的,所以我们访问节点C的右孩子,节点F。
这个示例树的前序遍历将是A-B-D-G-H-E-C-F。
前序树遍历的递归函数如下:
def preorder(root_node):
current = root_node
if current is None:
return
print(current.data)
preorder(current.left_child)
preorder(current.right_child)
preorder(n1)
首先,我们检查当前节点是否为空或为空。如果它是空的,这意味着树是一个空树;如果当前节点不为空,那么我们使用前序算法遍历树。前序遍历算法递归地按照根、左子树和右子树的顺序遍历树,如上述代码所示。最后,当我们对上述以n1节点作为根节点的四个节点的示例树应用上述前序遍历算法时,我们得到以下输出:
root node
left child node
left grandchild node
right child node
接下来,我们将讨论后序遍历。
后序遍历
后序树遍历的工作方式如下:
-
我们开始遍历左子树,并递归地调用排序函数
-
接下来,我们递归地遍历右子树并调用排序函数
-
最后,我们访问根节点
因此,简而言之,对于后序树遍历,我们按照左子树、右子树,最后是根节点的顺序访问树中的节点。
考虑以下示例树,如图 6.12所示,来理解后序树遍历:

图 6.12:理解前序遍历的示例树
在前面的图,图 6.12 中,我们首先递归地访问根节点 A 的左子树。我们到达最后一个左子树,即根节点 D,然后我们访问它左边的节点,即节点 G。在此之后,我们访问右子节点 H,然后访问根节点 D。遵循相同的规则,我们接下来访问节点 B 的右子节点,即节点 E。然后,我们访问节点 B。在此基础上,我们遍历节点 A 的右子树。在这里,我们首先到达最后一个右子树并访问节点 F,然后访问节点 C。最后,我们访问根节点 A。
对于这个示例树的后序遍历将是 G-H-D-E-B-F-C-A。
树遍历的后序方法的实现如下:
def postorder( root_node):
current = root_node
if current is None:
return
postorder(current.left_child)
postorder(current.right_child)
print(current.data)
postorder(n1)
首先,我们检查当前节点是否为空或空。如果不为空,我们使用前面讨论的后序算法遍历树,最后,当我们对上面以 n1 作为根节点的四个节点的示例树应用上述后序遍历算法时,我们得到以下输出:
left grandchild node
left child node
right child node
root node
接下来,我们将讨论层次遍历。
层次遍历
在这种遍历方法中,我们首先访问树的根节点,然后再访问树的下一层的每个节点。然后,我们继续访问树的下一层,依此类推。这种树遍历方式是图中的广度优先遍历,因为它在深入树之前先遍历了同一层的所有节点。
让我们考虑以下示例树并遍历它:

图 6.13:理解层次遍历的示例树
在 图 6.13 中,我们首先访问第 0 层的根节点,其值为 4。我们通过打印其值来访问此节点。接下来,我们移动到第 1 层并访问此层的所有节点,这些节点的值分别为 2 和 8。最后,我们移动到树的下一层,即第 3 层,并访问此层的所有节点,这些节点的值分别为 1、3、5 和 10。因此,此树的层次遍历顺序如下:4、2、8、1、3、5 和 10。
这种层次遍历树是通过使用队列数据结构实现的。我们首先访问根节点,并将其推入队列。队列前面的节点被访问(出队),然后可以打印或存储以供以后使用。在添加根节点后,将左子节点添加到队列中,然后是右子节点。因此,在遍历树的任何给定层时,首先从左到右将那一层的所有数据项插入队列中。之后,逐个从队列中访问所有节点。这个过程重复应用于树的每一层。
使用此算法遍历前面的树将入队根节点4,出队并访问该节点。接下来,节点2和8被入队,因为它们是下一级的左节点和右节点。节点2被出队以便访问。接下来,它的左节点和右节点,节点1和3,被入队。此时,队列前面的节点是节点8。我们出队并访问节点8,之后将其左节点和右节点入队。这个过程一直持续到队列为空。
Python 中广度优先遍历的实现如下。我们将根节点入队,并在list_of_nodes列表中保持已访问节点的列表。使用dequeue类来维护队列:
from collections import deque
class Node:
def __init__(self, data):
self.data = data
self.right_child = None
self.left_child = None
n1 = Node("root node")
n2 = Node("left child node")
n3 = Node("right child node")
n4 = Node("left grandchild node")
n1.left_child = n2
n1.right_child = n3
n2.left_child = n4
def level_order_traversal(root_node):
list_of_nodes = []
traversal_queue = deque([root_node])
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
print(level_order_traversal(n1))
如果traversal_queue中的元素数量大于零,则执行循环体。将队列前面的节点弹出并添加到list_of_nodes列表中。第一个if语句如果提供的node存在左节点,则将左子节点入队。第二个if语句对右子节点做同样的处理。此外,在最后一个语句中返回list_of_nodes列表。
上述代码的输出如下:
['root node', 'left child node', 'right child node', 'left grandchild node']
我们已经讨论了不同的树遍历算法;我们可以根据应用选择使用这些算法中的任何一个。当我们需要从树中获取排序内容时,中序遍历非常有用。这也适用于我们需要按降序排列项的情况,我们可以通过反转顺序来实现,例如右子树、根节点,然后是左子树。这被称为逆中序遍历。而且,如果我们需要在任何叶子节点之前检查根节点,我们使用前序遍历。同样地,如果我们需要在根节点之前检查叶子节点。
二叉树的一些重要应用如下:
-
二叉树作为表达式树在编译器中使用
-
它也用于数据压缩中的霍夫曼编码
-
二叉搜索树用于高效地搜索、插入和删除列表中的项目
-
优先队列(PQ),用于在元素集合中以对数时间复杂度(在最坏情况下)查找和删除最小或最大项
接下来,让我们讨论表达式树。
表达式树
表达式树是一种特殊的二叉树,可以用来表示算术表达式。算术表达式由运算符和操作数组合而成,其中运算符可以是单目或双目。在这里,运算符表示我们想要执行哪种操作,而运算符告诉我们我们想要应用这些操作的数据项。如果运算符应用于一个操作数,则称为单目运算符;如果应用于两个操作数,则称为双目运算符。
算术表达式也可以使用二叉树来表示,这也被称为表达式树。中缀表示法是一种常用的表示法,用于表达算术表达式,其中运算符位于操作数之间。它是一种常用的表示算术表达式的方法。在表达式树中,所有叶节点包含操作数,而非叶节点包含运算符。还值得注意的是,在单目运算符的情况下,表达式树将会有一个子树(右或左)为空。
算术表达式可以使用三种表示法来表示:中缀、后缀或前缀。表达式的中序遍历会产生中缀表示法。例如,3 + 4的表达式树将如图图 6.14所示:

图 6.14:表达式 3 + 4 的表达式树
在这个例子中,运算符被插入(中缀)在操作数之间,如3 + 4。当需要时,可以使用括号来构建更复杂的表达式。例如,对于(4 + 5) * (5 - 3),我们会得到以下结果:

图 6.15:表达式(4 + 5) * (5-3)的表达式树
前缀表示法通常被称为波兰表示法。在这种表示法中,运算符位于其操作数之前。例如,用于将两个数字 3 和 4 相加的算术表达式将表示为+ 3 4。让我们再举一个例子,(3 + 4) * 5。在前缀表示法中,这也可以表示为* (+ 3 4) 5。表达式的先序遍历会产生算术表达式的前缀表示法。例如,考虑图 6.16中所示的表达式树:

图 6.16:一个示例表达式树,用于理解先序遍历
图 6.16中所示的表达式树的先序遍历将给出前缀表示法的表达式,即+- 8 3 3。
后缀,或称为逆波兰表示法(RPN),将运算符放在操作数之后,例如3 4 +。图 6.17中所示的表达式树的后续遍历给出了算术表达式的后缀表示法。

图 6.17:一个示例表达式树,用于理解后序遍历
上述表达式树的后缀表示法为8 3 -3 +。我们已经讨论了表达式树。由于它提供了更快的计算,因此使用逆波兰表示法来评估给定算术表达式的表达式树很容易。
解析逆波兰表达式
要从后缀表示法创建表达式树,使用一个栈。在这里,我们一次处理一个符号;如果符号是操作数,则将其引用推入栈中,如果符号是运算符,则从栈中弹出两个指针并形成一个新子树,其根是运算符。从栈中弹出的第一个引用是子树的右侧子节点,第二个引用成为子树的左侧子节点。进一步,将此新子树的引用推入栈中。以这种方式,处理所有后缀表示法的符号以创建表达式树。
让我们以 4 5 + 5 3 - * 为例。
首先,我们将符号 4 和 5 推入栈中,然后我们按照 图 6.18 中的方式处理下一个符号 +:

图 6.18:操作数 4 和 5 被推入栈中
当读取新的符号 + 时,它被转换为新子树的根节点,然后从栈中弹出两个引用,最上面的引用被添加为根节点的右侧,下一个弹出的引用被添加为子树的左侧子节点,如图 图 6.19 所示:

图 6.19:在创建表达式树时处理运算符 +
下一个符号是 5 和 3,它们被推入栈中。接下来,当新的符号是一个运算符(-)时,它被创建为新子树的根,然后从栈中弹出两个顶部引用,分别添加到根的右侧和左侧子节点,如图 图 6.20 所示。然后,将此子树的引用推入栈中:

图 6.20:在创建表达式树时处理运算符(-)
下一个符号是运算符 *;如我们所做的那样,这将作为根创建,然后从栈中弹出两个引用,如图 图 6.21 所示。最终的树如图 图 6.21 所示:

图 6.21:在创建表达式树时处理运算符(*)
要了解如何在 Python 中实现此算法,我们将查看如何为后缀表示法编写的表达式构建树。为此,我们需要一个树节点实现;它可以定义为如下:
class TreeNode:
def __init__(self, data=None):
self.data = data
self.right = None
self.left = None
下面的代码是我们将要使用的栈类实现的代码:
class Stack:
def __init__(self):
self.elements = []
def push(self, item):
self.elements.append(item)
def pop(self):
return self.elements.pop()
为了构建树,我们将借助栈来列出项目。让我们以一个算术表达式为例,并设置我们的栈:
expr = "4 5 + 5 3 - *".split()
stack = Stack()
在第一个语句中,split() 方法默认按空白分隔。expr 是一个包含值 4、5、+、5、3、- 和 * 的列表。
expr 列表中的每个元素都将是一个运算符或操作数。如果我们得到一个操作数,那么我们将其嵌入到一个树节点中并将其推入栈中。如果我们得到一个运算符,我们将运算符嵌入到一个树节点中,并将其两个操作数弹出并放入节点的左右子节点中。在这里,我们必须注意确保第一次 pop 引用进入右子节点。
在前面的代码片段之后,下面的代码是一个循环来构建树:
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)
注意,在操作数的情况下,我们执行从 string 到 int 的转换。如果你希望支持浮点操作数,你可以使用 float()。
在此操作结束时,我们应该在栈中有一个单独的元素,它包含整个树。
如果我们要评估表达式,我们可以使用以下函数:
def calc(node):
if node.data == "+":
return calc(node.left) + calc(node.right)
elif node.data == "-":
return calc(node.left) - calc(node.right)
elif node.data == "*":
return calc(node.left) * calc(node.right)
elif node.data == "/":
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) 的结果。
表达式树在轻松表示和评估复杂表达式方面非常有用。它也有助于评估后缀、前缀和中缀表达式。它可以用来找出给定表达式中的运算符的结合性。
在下一节中,我们将讨论二叉搜索树,它是一种特殊的二叉树。
二叉搜索树
二叉搜索树(BST)是一种特殊的二叉树。它是计算机科学应用中最重要且最常用的数据结构之一。二叉搜索树是一种结构上为二叉树的树,并且在其节点中非常有效地存储数据。它提供了非常快的搜索、插入和删除操作。
如果树中任何节点的值都大于其左子树中所有节点的值,并且小于(或等于)其右子树中所有节点的值,则称为二叉搜索树。例如,如果 K1、K2 和 K3 是三个节点树中的关键值(如图 6.22 所示),那么它应该满足以下条件:
-
关键值 K2<=K1
-
关键值 K3>K1
以下图描述了二叉搜索树的上述条件:

图 6.22:二叉搜索树的一个示例
让我们考虑另一个例子,以便我们更好地理解二叉搜索树。考虑图 6.23 所示的二叉搜索树:

图 6.23:六个节点的二叉搜索树
在这个树中,左子树中的所有节点值都小于(或等于)其父节点的值。这个节点的右子树中的所有节点值也都大于其父节点的值。
为了查看上述示例树是否满足二叉搜索树的属性,我们看到根节点的左子树中的所有节点值都小于 5。同样,右子树中的所有节点值都大于 5。这个属性适用于树中的所有节点,没有例外。例如,如果我们取另一个值为 3 的节点,我们可以看到所有左子树节点的值都小于 3,而所有右子树节点的值都大于 3。
考虑另一个二叉树的示例。让我们检查它是否是一个二叉搜索树。尽管以下图,图 6.24,看起来与前面的图相似,但它不符合二叉搜索树的资格,因为节点 7 大于根节点 5;即使它位于根节点的左子树中。节点 4 在其父节点 7 的右子树中,这也违反了二叉搜索树的规则。因此,以下图,图 6.24,不是一个二叉搜索树:

图 6.24:一个不是二叉搜索树的二叉树示例
让我们在 Python 中开始实现二叉搜索树。由于我们需要跟踪树的根节点,我们首先创建一个 Tree 类,它包含对根节点的引用:
class Tree:
def __init__(self):
self.root_node = None
这就是维护树状态所需的所有操作。现在,让我们检查在二叉搜索树中使用的所有主要操作。
二叉搜索树操作
可以在二叉搜索树上执行的操作有 insert、delete、寻找最小值、寻找最大值 和 搜索。我们将在以下各节中逐一详细讨论它们。
插入节点
在二叉搜索树上要实现的最重要操作之一是在树中插入数据项。为了将新元素插入到二叉搜索树中,我们必须确保在添加新元素后二叉搜索树的属性没有被违反。
为了插入一个新元素,我们首先比较新节点的值与根节点的值:如果值小于根值,则新元素将被插入到左子树中;否则,它将被插入到右子树中。以这种方式,我们走到树的末端来插入新元素。
让我们在树中插入数据项 5、3、7 和 1 来创建一个二叉搜索树。考虑以下情况:
-
插入 5:我们从第一个数据项
5开始。为此,我们将创建一个节点,其数据属性设置为5,因为它是第一个节点。 -
插入 3:现在,我们想要添加一个值为
3的第二个节点,以便将3的数据值与根节点的现有值5进行比较。由于节点值3小于5,它将被放置在节点5的左子树中。二叉搜索树将看起来像 图 6.25 所示:![]()
图 6.25:示例二叉搜索树插入操作的步骤 2
在这里,树满足二叉搜索树规则,即左子树中的所有节点都小于父节点。
-
插入 7:要将另一个值为
7的节点添加到树中,我们从值为5的根节点开始,并进行比较,如图 图 6.26 所示。由于7大于5,值为7的节点被放置在这个根的右侧:

图 6.26:示例二叉搜索树插入操作的步骤 3
- 插入 1:接下来,我们添加另一个值为
1的节点。从树的根开始,我们将1与5进行比较,如图 图 6.27 所示:

图 6.27:示例二叉搜索树插入操作的步骤 4
这种比较表明 1 小于 5,因此我们进入 5 的左子树,该子树有一个值为 3 的节点,如图 图 6.28 所示:

图 6.28:示例二叉搜索树中节点 1 和节点 3 的比较
当我们将 1 与 3 进行比较时,1 小于 3,因此我们向下移动到节点 3 的下一级,并移动到其左侧,如图 图 6.28 所示。然而,那里没有节点。因此,我们创建一个值为 1 的节点,并将其与节点 3 的左指针关联,以获得最终的树。在这里,我们有了包含 4 个节点的最终二叉搜索树,如图 图 6.29 所示:

图 6.29:示例二叉搜索树插入操作的最终步骤
我们可以看到,这个例子只包含整数或数字。因此,如果我们需要在二叉搜索树中存储字符串数据,字符串将按字母顺序进行比较。
如果我们想在二叉搜索树中存储任何自定义数据类型,我们必须确保二叉搜索树类支持排序。
在二叉搜索树中添加节点的方法 insert 的 Python 实现如下:
class Node:
def __init__(self, data):
self.data = data
self.right_child = None
self.left_child = None
class Tree:
def __init__(self):
self.root_node = None
def insert(self, data):
node = Node(data)
if self.root_node is None:
self.root_node = node
return self.root_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 self.root_node
else:
current = current.right_child
if current is None:
parent.right_child = node
return self.root_node
在上述代码中,我们首先使用 Tree 类声明 Node 类。可以在 Tree 类中定义可以应用于树的所有操作。让我们了解 insert 方法的步骤。我们从函数声明开始:
def insert(self, data):
接下来,我们使用 Node 类封装数据。我们检查是否有一个根节点。如果没有树中的根节点,新节点将成为根节点,然后返回根节点:
node = Node(data)
if self.root_node is None:
self.root_node = node
return self.root_node
else:
此外,为了插入一个新元素,我们必须遍历树并到达可以插入新元素的正确位置,同时确保二叉搜索树的性质不被违反。为此,我们在遍历树的同时跟踪当前节点及其父节点。current变量始终用于跟踪新节点将被插入的位置:
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 self.root_node
在此之后,我们需要处理大于(或等于)的情况。如果当前节点没有右子节点,则新节点作为右子节点插入。否则,我们向下移动并继续寻找插入点:
else:
current = current.right_child
if current is None:
parent.right_child = node
return self.root_node
现在,为了查看我们在二叉搜索树中插入的内容,我们可以使用任何现有的树遍历算法。让我们实现中序遍历,这应该在Tree类中定义。代码如下:
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)
现在,让我们以插入几个元素(例如元素5、2、7、9和1)到二叉搜索树为例,如图图 6.24所示,然后我们可以使用中序遍历算法来查看我们在树中插入的内容:
tree = Tree()
r = tree.insert(5)
r = tree.insert(2)
r = tree.insert(7)
r = tree.insert(9)
r = tree.insert(1)
tree.inorder(r)
上述代码的输出如下:
1
2
5
7
9
在二叉搜索树中插入一个节点的时间复杂度为O(h),其中h是树的高度。
搜索树
二叉搜索树是一种树形数据结构,其中每个节点的左子树的所有节点都具有比该节点更低的键值,而右子树具有更高的键值。因此,搜索具有给定键值的元素相当容易。让我们考虑一个具有节点1、2、3、4、8、5和10的示例二叉搜索树,如图图 6.30所示:

图 6.30:具有七个节点的示例二叉搜索树
在前面显示的图 6.30的树中,如果我们想搜索具有值5的节点,例如,我们从根节点开始,将根节点与我们的目标值进行比较。由于节点5的值大于根节点的值4,我们移动到右子树。在右子树中,我们有节点8作为根节点,因此我们将节点5与节点8进行比较。由于要搜索的节点值小于节点8,我们将其移动到左子树。当我们移动到左子树时,我们将左子树节点5与所需的节点值5进行比较。这是一个匹配,因此我们返回"item found"。
这里是二叉搜索树中搜索方法的实现,该方法正在Tree类中定义:
def search(self, data):
current = self.root_node
while True:
if current is None:
print("Item not found")
return None
elif current.data is data:
print("Item found", data)
return data
elif current.data > data:
current = current.left_child
else:
current = current.right_child
在前面的代码中,如果找到了数据,我们返回数据,如果没有找到,则返回None。我们从根节点开始搜索。接下来,如果要搜索的数据项在树中不存在,我们返回None。如果我们找到了数据,则返回。
如果我们正在搜索的数据小于当前节点的数据,我们向下到树的左侧。此外,在代码的else部分,我们检查我们正在寻找的数据是否大于当前节点持有的数据,这意味着我们向下到树的右侧。
最后,以下代码可以用来创建一个具有 1 到 10 之间一些值的示例二叉搜索树。然后,我们搜索具有值9的数据项,以及该范围内的所有数字。存在于树中的那些数字将被打印出来:
tree = Tree()
tree.insert(5)
tree.insert(2)
tree.insert(7)
tree.insert(9)
tree.insert(1)
tree.search(9)
上述代码的输出如下:
Item found 9
在上面的代码中,我们看到树中存在的项目已经被正确找到;其余的项目在 1 到 10 的范围内找不到。在下一节中,我们将讨论二叉搜索树中节点的删除。
删除节点
在二叉搜索树上的另一个重要操作是节点的删除或移除。在这个过程中,我们需要注意三种可能的情况。我们想要删除的节点可能具有以下情况:
-
没有子节点:如果没有叶子节点,则直接删除该节点
-
一个子节点:在这种情况下,我们将该节点的值与其子节点交换,然后删除该节点
-
两个子节点:在这种情况下,我们首先找到中序后继或前驱,交换它们的值,然后删除那个节点
第一种情况最容易处理。如果即将被删除的节点没有子节点,我们可以简单地从其父节点中删除它。在图 6.31中,假设我们想要删除没有子节点的节点A。在这种情况下,我们可以简单地从其父节点(节点Z)中删除它:

图 6.31:删除没有子节点的节点时的删除操作
在第二种情况下,当我们要删除的节点有一个子节点时,该节点的父节点被设置为指向该特定节点的子节点。让我们看看以下图表,其中我们想要删除有一个子节点5(如图 6.32所示)的节点6:

图 6.32:删除具有一个子节点的节点时的删除操作
为了删除节点6,该节点只有一个子节点5,我们将节点9的左指针指向节点5。在这里,我们需要确保子节点和父节点的关系符合二叉搜索树的属性。
在第三种情况下,当要删除的节点有两个子节点时,为了删除它,我们首先找到一个后继节点,然后将后继节点的内容移动到要删除的节点中。后继节点是要删除节点的右子树中具有最小值的节点;它将是当我们对要删除节点的右子树进行中序遍历时第一个元素。
让我们通过图 6.33中显示的示例树来理解它,其中我们想要删除具有两个子节点的节点9:

图 6.33:删除具有两个子节点的节点时的删除操作
在图 6.33 所示的示例树中,我们找到节点右子树中的最小元素(即在右子树中顺序遍历的第一个元素),即节点 12。之后,我们将节点 9 的值替换为 12 并删除节点 12。节点 12 没有子节点,因此我们相应地应用删除没有子节点的规则。
要使用 Python 实现上述算法,我们需要编写一个辅助方法来获取我们想要删除的节点及其父节点的引用。我们必须编写一个单独的方法,因为我们没有在 Node 类中找到任何父节点的引用。这个辅助方法 get_node_with_parent 与 search 方法类似,它找到要删除的节点,并返回该节点及其父节点:
def get_node_with_parent(self, data):
parent = None
current = self.root_node
if current is None:
return (parent, None)
while True:
if current.data == data:
return (parent, current)
elif current.data > data:
parent = current
current = current.left_child
else:
parent = current
current = current.right_child
return (parent, current)
唯一的区别在于,在我们更新循环中的当前变量之前,我们使用 parent = current 存储其父节点。实际删除节点的操作方法从以下搜索开始:
def remove(self, data):
parent, node = self.get_node_with_parent(data)
if parent is None and node is None:
return False
# Get children count
children_count = 0
if node.left_child and node.right_child:
children_count = 2
elif (node.left_child is None) and (node.right_child is None):
children_count = 0
else:
children_count = 1
我们使用 parent, node = self.get_node_with_parent(data) 将父节点和找到的节点分别传递给 parent 和 node。了解我们想要删除的节点有多少个子节点非常重要,我们在 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_child 或 parent.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
前面的语句使我们能够正确地将最左节点的父节点连接到任何子节点。观察等号右侧保持不变。这是因为顺序后继节点只能有一个右子节点作为其唯一的子节点。
以下代码演示了如何在 Tree 类中使用 remove 方法:
tree = Tree()
tree.insert(5)
tree.insert(2)
tree.insert(7)
tree.insert(9)
tree.insert(1)
tree.search(9)
tree.remove(9)
tree.search(9)
上述代码的输出是:
Item found 9
Item not found
在上述代码中,当我们搜索项目 9 时,它在树中可用,并且在删除方法之后,项目 9 不再存在于树中。在最坏的情况下,remove 操作的时间复杂度为 O(h),其中 h 是树的高度。
查找最小和最大节点
二叉搜索树的结构使得搜索具有最大或最小值的节点非常容易。为了找到树中最小的节点,我们从树的根节点开始遍历,每次都访问左节点,直到我们到达树的末尾。同样,我们递归地遍历右子树,直到我们到达树的末尾以找到树中最大的节点。
例如,考虑图 6.34,为了搜索最小和最大元素。

图 6.34:在二叉搜索树中找到最小和最大节点
在这里,我们首先从根节点 6 向下移动到 3,然后从节点 3 移动到 1 以找到具有最小值的节点。同样,为了从树中找到最大值节点,我们沿着树的右侧从根节点向下移动,因此我们从节点 6 移动到节点 8,然后从节点 8 移动到节点 10 以找到具有最大值的节点。
返回任何节点最小值的 Python 实现方法如下:
def find_min(self):
current = self.root_node
while current.left_child:
current = current.left_child
return current.data
while 循环继续获取左节点并访问它,直到最后一个左节点指向 None。这是一个非常简单的方法。
同样,以下代码是返回最大节点的方法的代码:
def find_max(self):
current = self.root_node
while current.right_child:
current = current.right_child
return current.data
以下代码演示了如何在 Tree 类中使用 find_min 和 find_max 方法:
tree = Tree()
tree.insert(5)
tree.insert(2)
tree.insert(7)
tree.insert(9)
tree.insert(1)
print(tree.find_min())
print(tree.find_max())
上述代码的输出如下所示:
1
9
上述代码的输出,1 和 9,分别是最小值和最大值。树中的最小值是 1,最大值是 9。在二叉搜索树中查找最小或最大值的运行时间复杂度是 O(h),其中 h 是树的高度。
二叉搜索树的好处
通常情况下,当我们主要对任何应用程序中频繁访问元素感兴趣时,与数组和链表相比,二叉搜索树是一个更好的选择。二叉搜索树对于大多数操作,如搜索、插入和删除,都非常快,而数组提供快速的搜索,但在插入和删除操作方面相对较慢。同样,链表在执行插入和删除操作时效率很高,但在执行搜索操作时较慢。从二叉搜索树中搜索元素的最好情况运行时间复杂度是 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)。 |
让我们通过一个例子来理解何时二叉搜索树是存储数据的良好选择。假设我们有以下数据节点—5, 3, 7, 1, 4, 6, 和 9,如图 图 6.35 所示。如果我们使用列表来存储这些数据,最坏的情况将需要我们搜索整个包含七个元素的列表来找到项目。因此,在图 图 6.35 中,搜索数据节点中的项目 9 将需要六次比较:

图 6.35:一个包含七个元素的列表,如果存储在列表中,则需要六次比较
然而,如果我们使用二叉搜索树来存储这些值,如图下所示,在最坏的情况下,我们将需要两次比较来搜索项目 9,如图 图 6.36 所示:

图 6.36:一个包含七个元素的列表,如果存储在二叉搜索树中,则需要三次比较
然而,重要的是要注意,搜索效率还取决于我们如何构建二叉搜索树。如果树没有正确构建,它可能会很慢。例如,如果我们按照 1, 3, 4, 5, 6, 7, 9 的顺序将元素插入到树中,如图 图 6.37 所示,那么树将不会比列表更有效率:

图 6.37:使用元素顺序 1, 3, 4, 5, 6, 7, 9 构建的二叉搜索树
根据添加到树中的节点序列,我们可能会得到一个不平衡的二叉树。因此,使用一种可以使树成为自平衡树的方法是很重要的,这反过来又会提高 搜索 操作。因此,我们应该注意,如果二叉树是平衡的,那么二叉搜索树是一个很好的选择。
摘要
在本章中,我们讨论了一个重要的数据结构,即树数据结构。一般来说,与线性数据结构相比,树数据结构在搜索、插入和删除操作中提供更好的性能。我们还讨论了如何将各种操作应用于树数据结构。我们研究了二叉树,每个节点最多有两个子节点。此外,我们还学习了二叉搜索树,并讨论了如何对它们应用不同的操作。当我们想要开发一个现实世界应用,其中数据元素的检索或搜索是一个重要操作时,二叉搜索树非常有用。我们需要确保树是平衡的,以获得二叉搜索树的良好性能。我们将在下一章讨论优先队列和堆。
练习
-
以下关于二叉树的哪个说法是正确的?
-
每棵二叉树要么是完整的,要么是满的
-
每棵完整的二叉树也是满的
-
每棵满二叉树也是完整的
-
没有一棵二叉树既是完整的又是满的
-
以上都不对
-
-
哪种树遍历算法最后访问根节点?
考虑以下二叉搜索树:

图 6.38:示例二叉搜索树
-
假设我们移除根节点
8,并希望用左子树中的任意节点来替换它,那么新的根节点会是什么? -
以下树的
中序、后序和前序遍历是什么?

图 6.39:示例树
-
你如何判断两棵树是否相同?
-
在第 4 个问题中提到的树中有多少个叶子节点?
-
一个完美二叉树的高度与该树中的节点数之间有什么关系?
加入我们的 Discord 社区
加入我们的社区 Discord 空间,与作者和其他读者进行讨论:packt.link/MEvK4

第七章:堆和优先队列
堆数据结构是一种基于树的数据库结构,其中树中的每个节点与其他节点具有特定的关系,并且它们以特定的顺序存储。根据树中节点的特定顺序,堆可以是不同类型的,例如最小堆和最大堆。
优先队列是一个重要的数据结构,它类似于队列和栈数据结构,存储数据及其相关的优先级。在这里,数据是按照优先级进行服务的。优先队列可以使用数组、链表和树来实现;然而,它们通常使用堆来实现,因为它非常高效。
在本章中,我们将学习以下内容:
-
堆数据结构的概念及其上的不同操作
-
理解优先队列的概念及其使用 Python 的实现
堆
堆数据结构是树的一种特殊化,其中节点以特定的方式进行排序。堆是一种数据结构,其中每个数据元素都满足堆属性,堆属性表明父节点和子节点之间必须存在某种关系。根据这种特定的关系,堆可以分为两种类型,换句话说,最大堆和最小堆。在最大堆中,每个父节点的值必须始终大于或等于其所有子节点。在这种类型的树中,根节点必须是树中的最大值。例如,参见 图 7.1 展示的最大堆,其中所有节点的值都大于其子节点:

图 7.1:最大堆的示例
在最小堆中,父节点和子节点之间的关系是父节点的值必须始终小于或等于其子节点。这个规则应该适用于树中的所有节点。在最小堆中,根节点持有最低的值。例如,参见 图 7.2 展示的最小堆,其中所有节点的值都小于其子节点:

图 7.2:最小堆的示例
堆是一个重要的数据结构,因为它有多个应用,并且在实现堆排序算法和优先队列中得到了广泛的使用。我们将在本章的后面详细讨论这些内容。堆可以是任何类型的树;然而,最常见的一种堆是二叉堆,其中每个节点最多有两个子节点。
如果二叉堆是一个包含 n 个节点的完全二叉树,那么它的高度至少为 log[2]n。
完全二叉树是一种必须先填满每一行,然后才能开始填充下一行的树,如下面的 图 7.3 所示:

图 7.3:完全二叉树的示例
为了实现堆,我们可以推导出父节点和子节点在index值之间的关系。这种关系是,任何节点在n索引处的子节点可以很容易地检索到,换句话说,左子节点位于2n,右子节点位于2n + 1。例如,节点C位于索引3,因为节点C是位于索引1的节点A的右子节点,所以它变为2n+1 = 2*1 + 1 = 3。这种关系始终成立。假设我们有一个元素列表{A, B, C, D, E},如图 7.4所示。如果我们把任何元素存储在索引i处,那么它的父节点可以存储在索引i/2处,例如,如果节点D的索引是4,那么它的父节点就在4/2 = 2,索引2。根节点的索引必须在数组中从1开始。参见图 7.4以了解概念:

图 7.4:二叉树和所有节点的索引位置
父亲和子节点之间的关系是一个完全二叉树。在索引值方面,这对于在堆中有效地检索、搜索和存储数据元素非常重要。由于这个特性,实现堆变得非常容易。唯一的约束是我们应该从1开始索引,如果我们使用数组实现堆,那么我们必须在数组的索引0处添加一个虚拟元素。接下来,让我们了解堆的实现。重要的是要注意,我们将讨论所有概念都与min堆相关,而max堆的实现将与它非常相似,唯一的区别是heap属性。
让我们讨论使用 Python 实现最小堆的实现。我们首先从heap类开始,如下所示:
class MinHeap:
def __init__(self):
self.heap = [0]
self.size = 0
我们用零初始化堆列表来表示虚拟的第一个元素,并且添加一个虚拟元素只是为了从1开始索引数据项,因为如果我们从1开始索引,由于父子关系,访问元素变得非常容易。我们还创建了一个变量来保存堆的大小。我们将进一步讨论不同的操作,例如在堆中插入、删除和删除特定位置的元素。让我们从堆中的插入操作开始。
插入操作
将一个元素插入到min堆中分为两个步骤。首先,我们将新元素添加到列表的末尾(我们理解为树的底部),并将堆的大小增加一。其次,在每次插入操作之后,我们需要将新元素在堆树中重新排列,以组织所有节点,使其满足堆属性,在这种情况下是每个节点必须大于其父节点。换句话说,父节点的值必须始终小于或等于其子节点,而min-heap中的最小元素需要是根元素。因此,我们首先将一个元素插入到树的最后一个堆中;然而,在将元素插入堆之后,可能会违反堆属性。在这种情况下,节点必须重新排列,以便所有节点都满足堆属性。这个过程称为堆化。为了堆化min堆,我们需要找到其子节点的最小值并将其与当前元素交换,并且这个过程必须重复进行,直到所有节点都满足堆属性。
让我们考虑一个在min堆中添加元素的例子,比如在图 7.5中插入一个值为2的新节点:

图 7.5:在现有堆中插入新节点 2
新元素将被添加到第三行或级别的最后一个位置。其索引值是7。我们将其值与其父节点进行比较。父节点位于索引7/2 = 3(整数除法)。父节点持有值6,这个值高于新节点值(换句话说,2),因此根据min堆的性质,我们交换这两个值,如图图 7.6所示:

图 7.6:交换节点 2 和 6 以保持堆属性
新的数据元素已经交换并移动到索引3。由于我们必须检查所有节点直到根节点,我们检查其父节点的索引,即3/2 = 1(整数除法),所以我们继续堆化过程。
因此,我们比较这两个元素,并再次交换,如图图 7.7所示:

图 7.7:交换节点 2 和 3 以保持堆属性
在最终交换后,我们到达根节点。在这里,我们可以注意到这个堆符合min堆的定义,如图图 7.8所示:

图 7.8:插入新节点 2 后的最终堆
现在,让我们再举一个例子,看看如何创建和插入堆中的元素。我们从一个堆的构建开始,逐个插入 10 个元素。这些元素是{4, 8, 7, 2, 9, 10, 5, 1, 3, 6}。我们可以在图 7.9中看到将元素插入堆的逐步过程:

图 7.9:创建堆的逐步过程
我们可以在前面的图中看到将元素插入堆的逐步过程。在这里,我们继续添加元素,如图图 7.10所示:

图 7.10:创建堆的步骤 7 到 9
最后,我们将元素6插入到堆中,如图图 7.11所示:

图 7.11:最后一步和最终堆的构建
堆中插入操作的实现如下。首先,我们创建一个辅助方法,称为arrange,它负责在插入新节点后对所有节点进行排列。以下是arrange()方法的实现,该方法应在MinHeap类中定义:
def arrange(self, k):
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
我们执行循环直到达到root节点;在此之前,我们可以继续排列元素。在这里,我们使用整数除法。循环将在以下条件成立后退出:
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方法,该方法应在MinHeap类中定义:
def insert(self, item):
self.heap.append(item)
self.size += 1
self.arrange(self.size)
在上述代码中,我们可以使用append方法插入一个元素;然后增加堆的大小。然后在insert方法的最后一行,我们调用arrange()方法重新组织堆(堆化)以确保堆中的所有节点都满足heap属性。
现在,让我们创建堆并使用MinHeap类中定义的insert()方法插入数据{4, 8, 7, 2, 9, 10, 5, 1, 3, 6},如下所示:
h = MinHeap()
for i in (4, 8, 7, 2, 9, 10, 5, 1, 3, 6):
h.insert(i)
我们可以打印堆列表,以便检查元素是如何排序的。如果你将其重新绘制为树结构,你会注意到它符合堆所需的所有属性,类似于我们手动创建的:
print(h.heap)
上述代码的输出如下:
[0, 1, 2, 5, 3, 6, 10, 7, 8, 4, 9]
我们可以在输出中看到,数组中堆的所有数据项与图 7.11中的索引位置一致。接下来,我们将讨论堆中的删除操作。
删除操作
delete操作从堆中删除一个元素。要从堆中删除任何元素,我们首先讨论如何删除根元素,因为它在许多用例中都被广泛使用,例如在堆中查找最小或最大元素。记住,在min-heap中,根元素表示列表的最小值,而max-heap的根给出元素列表的最大值。
一旦我们从堆中删除根元素,我们将堆的最后一个元素作为堆的新根。在这种情况下,树将不满足heap属性。因此,我们必须重新组织树的节点,使得树中的所有节点都满足heap属性。min-heap中的删除操作如下所示。
-
删除
root节点后,我们需要一个新的root节点。为此,我们从列表中取出最后一个元素并将其作为新的根。 -
由于选定的最后一个节点可能不是堆中的最低元素,我们必须重新组织堆的节点。
-
我们从根节点到最后一个节点(被制成一个新的根节点)重新组织节点;这个过程称为堆化。由于我们从顶部到底部(这意味着从根节点到底部元素)移动堆,这个过程称为下沉。
让我们考虑一个例子来帮助我们理解以下堆中的这个概念。首先,我们删除具有值2的根节点,如图7.12所示:

图 7.12:在现有堆中删除根节点值为 2 的节点
删除根节点后,接下来我们需要选择一个可以作为新根的节点;通常,我们选择取最后一个节点,换句话说,索引为7的节点6。因此,最后一个元素6被放置在根位置,如图7.13所示:

图 7.13:将最后一个元素,即节点 6 移动到根位置
将最后一个元素移动到新根之后,很明显,这棵树现在不再满足min-heap属性。因此,我们必须重新组织堆的节点,因此我们从根节点向下移动到堆中的节点,即堆化树。因此,我们比较新替换的节点与树中所有子节点的值。在这个例子中,我们比较根节点的两个子节点,即5和3。由于右子节点较小,其索引为3,表示为(root index * 2 + 1)。我们将继续使用这个节点,并将新的root节点与该索引处的值进行比较,如图7.14所示:

图 7.14:根节点与节点 3 的交换
现在,具有值6的节点应该根据最小堆属性移动到索引3。接下来,我们需要将其与其子节点比较到堆中。在这里,我们只有一个子节点,所以我们不需要担心比较哪个子节点(对于最小堆,总是较小的子节点),如图7.15所示:

图 7.15:节点 6 和节点 10 的交换
这里不需要交换,因为它遵循min-heap属性。达到最后一个节点后,最终的堆遵循min-heap属性。
为了使用 Python 实现从堆中删除根节点,首先,我们实现下沉过程,换句话说,sink()方法。在我们实现sink()方法之前,我们实现一个用于确定与父节点比较的子节点的helper方法。这个helper方法是minchild(),应该在MinHeap类中定义:
def minchild(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
在这个方法中,首先,我们检查是否超过了列表的末尾——如果我们做到了,那么我们就返回左子节点的索引:
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()方法。sink()方法应该在MinHeap类中定义:
def sink(self, k):
while k * 2 <= self.size:
mc = self.minchild(k)
if self.heap[k] > self.heap[mc]:
self.heap[k], self.heap[mc] = self.heap[mc], self.heap[k]
k = mc
在上述代码中,我们首先运行循环直到树的末尾,这样我们就可以将我们的元素下沉(向下移动)到所需的最低位置;这在下述代码片段中显示:
def sink(self, k):
while k*2 <= self.size:
接下来,我们需要知道比较的是左子节点还是右子节点。这就是我们使用minindex()函数的地方,如下述代码片段所示:
mi = self.minchild(k)
接下来,我们比较父节点和子节点,以确定是否需要交换,就像我们在插入操作期间在arrange()方法中所做的那样:
if self.heap[k] > self.heap[mc]:
self.heap[k], self.heap[mc] = self.heap[mc], self.heap[k]
最后,我们需要确保在每次迭代中向下移动树,以避免陷入循环,如下所示:
k = mc
现在,我们可以实现主要的delete_at_root()方法本身,该方法应在MinHeap类中定义:
def delete_at_root(self):
item = self.heap[1]
self.heap[1] = self.heap[self.size]
self.size -= 1
self.heap.pop()
self.sink(1)
return item
在上述删除root节点的代码中,我们首先将根元素复制到一个变量item中,然后在下一条语句中将最后一个元素移动到root节点:
self.heap[1] = self.heap[self.size]
此外,我们减少堆的大小,从堆中删除元素,然后我们使用sink()方法重新组织堆元素,以便堆的所有元素都遵循heap属性。
我们现在可以使用以下代码从堆中删除root节点。首先,我们在堆中插入一些数据项{2, 3, 5, 7, 9, 10, 6},然后删除root节点:
h = MinHeap()
for i in (2, 3, 5, 7, 9, 10, 6):
h.insert(i)
print(h.heap)
n = h.delete_at_root()
print(n)
print(h.heap)
上述代码的输出如下:
[0, 2, 3, 5, 7, 9, 10, 6]
2
[0, 3, 6, 5, 7, 9, 10]
我们可以在输出中看到,新的堆中返回了根元素 2,并且数据元素被重新排列,以便堆的所有节点都遵循heap属性(可以像图 7.16中所示的那样检查节点的索引)。接下来,我们将讨论是否要删除给定索引位置的任何节点。
从堆中删除特定位置的元素
通常,我们会从根节点删除一个元素,然而,也可以从堆的特定位置删除一个元素。让我们通过一个例子来理解它。给定以下堆,假设我们想要删除值为3的节点,索引为2。删除值为3的节点后,我们将最后一个节点移动到被删除的节点,换句话说,就是值为15的节点,如图图 7.16所示:

图 7.16:从堆中删除节点 3
将最后一个元素移至被删除的节点后,我们比较这个元素与其根元素,因为它已经大于根元素,所以我们不需要交换。接下来,我们比较这个元素与其所有子元素,因为左子节点较小,所以它与左子节点交换,如图图 7.17所示:

图 7.17:节点 15 与 5 和 11 的比较,以及节点 15 和节点 5 的交换
在将节点15与节点5交换后,我们在堆中向下移动。接下来,我们比较节点15与其子节点,节点8。最后,节点8和节点15交换。现在,最终的树遵循heap属性,如图图 7.18所示:

图 7.18:交换节点 8 和节点 15 后的最终堆
以下给出删除任何给定索引位置数据项的删除操作的实现,应在MinHeap类中定义:
def delete_at_location(self, location):
item = self.heap[location]
self.heap[location] = self.heap[self.size]
self.size -= 1
self.heap.pop()
self.sink(location)
return item
{4, 8, 7, 2, 9, 10, 5, 1, 3, 6}:
h = MinHeap()
for i in (4, 8, 7, 2, 9, 10, 5, 1, 3, 6):
h.insert(i)
print(h.heap)
n = h.delete_at_location(2)
print(n)
print(h.heap)
上述代码的输出如下:
[0, 1, 2, 5, 3, 6, 10, 7, 8, 4, 9]
2
[0, 1, 3, 5, 4, 6, 10, 7, 8, 9]
在上述输出中,我们看到,在前后,堆节点都是按照它们的索引位置放置的。我们已经通过最小堆的例子讨论了概念和实现;所有这些操作和概念都可以通过简单地反转在最小堆中确保父节点值小于子节点的条件逻辑,轻松地应用于最大堆。现在在最大堆的情况下,我们必须使父节点的值更大。堆被用于各种应用,例如实现堆排序和优先队列,我们将在后续章节中讨论。
堆排序
堆是一种重要的数据结构,用于对元素列表进行排序,因为它非常适合大量元素。如果我们想按升序对元素列表进行排序,我们可以使用最小堆来实现这个目的;我们首先创建一个包含所有给定数据元素的最小堆,根据堆的性质,最小的数据值将被存储在堆的根节点。借助堆的性质,对元素进行排序变得非常直接。过程如下:
-
使用所有给定的数据元素创建一个
最小堆。 -
读取并删除根元素,即最小值。之后,将树的最后一个元素复制到新的根节点,并进一步重新组织树以保持
堆的性质。 -
现在,我们重复步骤 2,直到我们得到所有元素。
-
最后,我们得到排序后的元素列表。
数据元素按照堆的性质存储在堆中;每当添加或删除新元素时,分别使用前面章节中讨论的arrange()和sink()``辅助方法来维护堆的性质。
为了使用堆数据结构实现堆排序,我们首先使用以下代码创建一个包含数据项 {4, 8, 7, 2, 9, 10, 5, 1, 3, 6} 的堆(堆的创建细节在前面章节中给出):
h = MinHeap()
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()方法后,通过后续调用sink方法恢复堆顺序属性。
在创建堆之后,接下来,我们读取并删除根元素。在每次迭代中,我们得到最小值,因此数据项按升序排列。heap_sort()方法的实现应该在minHeap类中定义(它使用前面章节中讨论的delete_at_root()方法):
def heap_sort(self):
sorted_list = []
for node in range(self.size):
n = self.delete_at_root()
sorted_list.append(n)
return sorted_list
在上述代码中,我们创建了一个空数组sorted_list,用于存储所有已排序的数据元素。然后我们运行循环,循环次数与列表中的项目数相同。在每次迭代中,我们调用delete_at_root()方法来获取最小值,并将其追加到sorted_list。
现在我们可以使用以下代码来使用堆排序算法:
print("Unsorted list: {}".format(unsorted_list))
print("Sorted list: {}".format(h.heap_sort()))
上述代码的输出如下:
Unsorted list: [4, 8, 7, 2, 9, 10, 5, 1, 3, 6]
Sorted list: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
使用插入方法构建堆的时间复杂度为O(n)。进一步地,在删除根元素后重新组织树的时间复杂度为O(log n),因为我们是从堆树的上到下进行操作的,而堆的高度是log2(n),因此重新排列树的时间复杂度是O(log n)。所以,总体来说,堆排序的最坏情况时间复杂度是O(n logn)。堆排序在一般情况下非常高效,其最坏情况、平均情况和最佳情况的时间复杂度均为O(nlogn)。
优先队列
优先队列是一种数据结构,它类似于队列,其中数据是根据先进先出(FIFO)策略检索的,但在优先队列中,数据与优先级相关联。在优先队列中,数据是根据与数据元素相关的优先级检索的,优先级最高的数据元素先被检索,优先级较低的数据元素后被检索,如果两个数据元素具有相同的优先级,则根据FIFO策略检索。
我们可以根据应用来分配数据的优先级。它在许多应用中都有使用,例如 CPU 调度,许多算法也依赖于优先队列,如 Dijkstra 的最短路径算法、A*搜索算法和用于数据压缩的 Huffman 编码。
因此,在优先队列中,优先级最高的项目首先被服务。优先队列根据与数据相关的优先级存储数据,因此元素的插入将在优先队列的特定位置。优先队列可以被视为修改后的队列,它按最高优先级顺序返回项目,而不是按FIFO顺序返回项目。可以通过修改入队位置并按优先级插入项目来实现优先队列。这在图 7.19中得到了演示,其中给定的队列中添加了一个新的项目5到队列的特定索引(这里假设具有更高值的数据项具有更高的优先级):

图 7.19:优先队列的演示
让我们通过一个例子来理解优先队列。当我们按顺序接收数据元素时,元素将按照优先级(假设数据值越高,重要性越高)的顺序入队。首先,优先队列是空的,所以最初在队列中添加了3;下一个数据元素是8,由于它大于3,它将被入队到队列的起始位置。接下来是数据项2,然后是6,最后是10,它们按照优先级入队,当执行出队操作时,高优先级的项目将首先出队。所有步骤都在图 7.20中表示:

图 7.20:创建优先队列的逐步过程
让我们讨论在 Python 中实现优先队列。我们首先定义节点类。节点类将包含与优先队列中的数据相关联的数据元素:
# class for Node with data and priority
class Node:
def __init__(self, info, priority):
self.info = info
self.priority = priority
接下来,我们定义PriorityQueue类并初始化队列:
# class for Priority queue
class PriorityQueue:
def __init__(self):
self.queue = []
接下来,让我们讨论插入操作的实施,用于将新的数据元素添加到优先队列中。在实现中,我们假设数据元素的优先级值越小,优先级越高(例如,优先级值为1的数据元素比优先级值为4的数据元素优先级高)。以下是在优先队列中插入元素的案例:
-
当队列最初为空时,向优先队列中插入数据元素。
-
如果队列不为空,我们执行队列的遍历,并通过比较现有节点与新节点的优先级,根据相关的优先级在队列中达到适当的索引位置。我们将在优先级高于新节点的节点之前添加新节点。
-
如果新节点的优先级低于高优先级值,则节点将被添加到队列的起始位置。
insert() 方法的实现如下,它应该在 PriorityQueue 类中定义:
def insert(self, node):
if len(self.queue) == 0:
# add the new node
self.queue.append(node)
else:
# traverse the queue to find the right place for new node
for x in range(0, len(self.queue)):
# if the priority of new node is greater
if node.priority >= self.queue[x].priority:
# if we have traversed the complete queue
if x == (len(self.queue)-1):
# add new node at the end
self.queue.insert(x+1, node)
else:
continue
else:
self.queue.insert(x, node)
return True
在上述代码中,当队列为空时,我们首先添加一个新的数据元素,然后通过比较数据元素相关的优先级,迭代地达到适当的位置。
接下来,当我们对优先队列应用删除操作时,最高优先级的数据元素将被返回并从队列中移除。它应该在PriorityQueue类中如下定义:
def delete(self):
# remove the first node from the queue
x = self.queue.pop(0)
print("Deleted data with the given priority-", x.info, x.priority)
return x
在前面的代码中,我们获取具有最高优先级值的顶级元素。进一步,实现show()方法,该方法应在PriorityQueue类中定义,用于按优先级顺序打印优先队列中的所有数据元素:
def show(self):
for x in self.queue:
print(str(x.info)+ " - "+ str(x.priority))
现在,让我们考虑一个例子,看看如何使用优先队列,我们首先添加具有相关优先级13、2、1、26和25的数据元素("Cat"、"Bat"、"Rat"、"Ant"和"Lion"):
p = PriorityQueue()
p.insert(Node("Cat", 13))
p.insert(Node("Bat", 2))
p.insert(Node("Rat", 1))
p.insert(Node("Ant", 26))
p.insert(Node("Lion", 25))
p.show()
p.delete()
上述代码的输出如下:
Rat – 1
Bat – 2
Cat – 13
Lion – 25
Ant – 26
Deleted data with the given priority- Rat 1
优先队列可以使用几种数据结构实现;在上面的例子中,我们看到了使用包含优先级作为第一个元素和值数据项作为下一个元素的元组列表实现的示例。然而,优先队列通常使用堆实现,因为它在插入和删除操作中的最坏情况时间复杂度为O(log n),因此效率很高。
使用堆实现优先队列的代码与我们在min-heap实现中讨论的非常相似。唯一的区别是现在我们存储与数据元素相关的优先级,并使用 Python 中的元组列表创建一个考虑优先级值的min-heap树。为了完整性,以下是用堆实现的优先队列的代码:
class PriorityQueueHeap:
def __init__(self):
self.heap = [()]
self.size = 0
def arrange(self, k):
while k // 2 > 0:
if self.heap[k][0] < self.heap[k//2][0]:
self.heap[k], self.heap[k//2] = self.heap[k//2], self.heap[k]
k //= 2
def insert(self,priority, item):
self.heap.append((priority, item))
self.size += 1
self.arrange(self.size)
def sink(self, k):
while k * 2 <= self.size:
mc = self.minchild(k)
if self.heap[k][0] > self.heap[mc][0]:
self.heap[k], self.heap[mc] = self.heap[mc], self.heap[k]
k = mc
def minchild(self, k):
if k * 2 + 1 > self.size:
return k * 2
elif self.heap[k*2][0] < self.heap[k*2+1][0]:
return k * 2
else:
return k * 2 + 1
def delete_at_root(self):
item = self.heap[1][1]
self.heap[1] = self.heap[self.size]
self.size -= 1
self.heap.pop()
self.sink(1)
return item
我们使用以下代码创建一个包含数据元素"Bat"、"Cat"、"Rat"、"Ant"、"Lion"和"Bear"的优先队列,相应的优先级值分别为2、13、18、26、3和4:
h = PriorityQueueHeap()
h.insert(2, "Bat")
h.insert(13,"Cat")
h.insert(18, "Rat")
h.insert(26, "Ant")
h.insert(3, "Lion")
h.insert(4, "Bear")
h.heap
上述代码的输出如下:
[(), (2, 'Bat'), (3, 'Lion'), (4, 'Bear'), (26, 'Ant'), (13, 'Cat'), (18, 'Rat')]
在上述输出中,我们可以看到它显示了一个遵循min-heap属性的min-heap树。现在我们可以使用以下代码来删除数据元素:
for i in range(h.size):
n = h.delete_at_root()
print(n)
print(h.heap)
上述代码的输出如下:
'Bat
[(), (3, 'Lion'), (13, 'Cat'), (4, 'Bear'), (26, 'Ant'), (18, 'Rat')]
Lion
[(), (4, 'Bear'), (13, 'Cat'), (18, 'Rat'), (26, 'Ant')]
Bear
[(), (13, 'Cat'), (26, 'Ant'), (18, 'Rat')]
Cat
[(), (18, 'Rat'), (26, 'Ant')]
Rat
[(), (26, 'Ant')]
Ant
[()]
在上述输出中,我们可以看到数据项是按照与数据元素相关的优先级产生的。
摘要
在本章中,我们讨论了一个重要的数据结构,换句话说,就是堆数据结构。我们还讨论了min-heap和max-heap的堆属性。我们看到了可以应用于堆数据结构的几个操作的实现,例如堆化、从堆中插入和删除数据元素。我们还讨论了堆的两个重要应用——堆排序和优先队列。堆是一个重要的数据结构,因为它有多个应用,例如排序、在列表中选择最小和最大值、图算法和优先队列。此外,当我们需要反复删除具有最高或最低优先级值的数据对象时,堆也可能很有用。
在下一章中,我们将讨论哈希和符号表的概念。
练习
-
从
min-heap中删除任意元素的时间复杂度是多少? -
查找
min-heap中第k个最小元素的时间复杂度是多少? -
从二叉
max-heap和二叉min-heap中确定最小元素的最坏情况时间复杂度是多少? -
将两个大小为
n的max-heap合并成一个max-heap的时间复杂度是多少? -
max-heap的层序遍历是12、9、7、4和2。在插入新元素1和8之后,最终的max-heap和最终的max-heap的层序遍历将是什么? -
以下哪个是二叉
max-heap?


图 7.21:示例树
加入我们的 Discord 社区
加入我们社区的 Discord 空间,与作者和其他读者进行讨论:packt.link/MEvK4

第八章:哈希表
哈希表是一种实现关联数组的抽象数据结构,其中数据通过将键映射到值作为键值对进行存储。在许多应用中,我们通常需要在字典数据结构中执行不同的操作,如插入、搜索和删除。例如,符号表是一种基于哈希表的数据结构,由编译器使用。将编程语言翻译成机器语言的编译器维护一个符号表,其中键是映射到标识符的字符串。在这种情况下,哈希表是一种有效的数据结构,因为我们可以通过对键应用哈希函数直接计算所需记录的索引。因此,我们不是直接使用键作为数组索引,而是通过将键应用于哈希函数来计算数组索引。这使得从哈希表的任何索引访问元素变得非常快。哈希表使用哈希函数来计算数据项在哈希表中应存储的位置的索引。
在哈希表中查找元素时,键的哈希运算给出表中相应记录的索引。理想情况下,哈希函数为每个键分配一个唯一值;然而,在实践中,我们可能会遇到哈希冲突,即哈希函数为多个键生成相同的索引。在本章中,我们将讨论处理此类冲突的不同技术。
在本章中,我们将讨论与这些概念相关的所有内容,包括:
-
哈希方法和哈希表技术
-
哈希表中的不同冲突解决技术
介绍哈希表
如我们所知,数组和列表按顺序存储数据元素。与数组一样,数据项通过索引号访问。使用索引号访问数组元素是快速的。然而,当需要访问任何元素而我们又无法记住索引号时,它们非常不便。例如,如果我们希望从索引 56 的地址簿中提取某人的电话号码,没有任何东西可以将特定的联系人与号码 56 联系起来。使用索引值从列表中检索条目是困难的。
哈希表是更适合此类问题的数据结构。哈希表是一种数据结构,其中元素通过关键字而不是索引号访问,这与列表和数组不同。在这个数据结构中,数据项以类似于字典的方式存储在键值对中。哈希表使用哈希函数来找到元素应存储和检索的索引位置。这使我们能够快速查找,因为我们使用的是与键的哈希值相对应的索引号。
哈希表如何存储数据的概述如图 8.1 所示,其中使用任何哈希函数对键值进行哈希运算,以获得记录在哈希表中的索引位置。

图 8.1:哈希表的示例
字典是一种广泛使用的数据结构,通常使用哈希表构建。字典使用关键字而不是索引号,并以 (key, value) 对的形式存储数据。也就是说,我们不是使用索引值来访问联系人,而是使用字典数据结构中的 key 值。
以下代码演示了存储在 (key, value) 对中的字典的工作原理:
my_dict={"Basant" : "9829012345", "Ram": "9829012346", "Shyam": "9829012347", "Sita": "9829012348"}
print("All keys and values")
for x,y in my_dict.items():
print(x, ":" , y) #prints keys and values
my_dict["Ram"]
上述代码的输出如下:
Basant : 9829012345
Ram : 9829012346
Shyam : 9829012347
Sita : 9829012348
'9829012346'
哈希表以非常高效的方式存储数据,以便检索可以非常快。哈希表基于称为哈希的概念。
哈希函数
哈希是一种技术,当我们向函数提供任意大小的数据时,我们得到一个小的、简化的值。这个函数被称为哈希函数。哈希使用哈希函数将键映射到另一个数据范围,以便可以使用新的键范围作为哈希表中的索引;换句话说,哈希用于将键值转换为整数,这些整数可以用作哈希表中的索引。
在本章的讨论中,我们使用哈希将字符串转换为整数。我们本可以使用任何其他可以转换为整数的类型来代替字符串。让我们举一个例子。比如说,我们想要哈希表达式 hello world,也就是说,我们想要得到与这个字符串相对应的数值,这个数值可以用作哈希表中的索引。
在 Python 中,ord() 函数返回一个唯一的整数值(称为序数值),该值映射到 Unicode 编码系统中的字符。只要字符是 Unicode 兼容的,序数值将 Unicode 字符映射到唯一的数值表示,例如,数字 0-127 映射到 ASCII 字符,这些字符也对应于 Unicode 系统中的序数值。然而,Unicode 编码的范围可能更大。因此,Unicode 编码是 ASCII 的超集。例如,在 Python 中,我们通过使用 ord('f') 获取字符 'f' 的唯一序数值 102。进一步地,为了获取整个字符串的哈希值,我们只需将字符串中每个字符的序数值相加。请看以下代码片段:
sum(map(ord, 'hello world'))
上述输出的结果如下:
1116
在上述输出中,我们获得了字符串 hello world 的数值 1116,这是给定字符串的哈希值。考虑以下图 8.2以查看导致哈希值 1116 的字符串中每个字符的序数值:

图 8.2:hello world 字符串中每个字符的序数值
'world hello' string:
sum(map(ord, 'world hello'))
上述输出的结果如下:
1116
再次,对于 'gello xorld' 字符串,也会有相同的哈希值,因为该字符串中字符的序数值之和是相同的,因为 g 的序数值比 h 小一,而 x 的序数值比 w 大一。请看以下代码片段:
sum(map(ord, 'gello xorld'))
上述输出的结果如下:
1116
看一下以下图 8.3,我们可以看到这个'gello xorld'字符串的散列值再次是1116:

图 8.3:gello xorld 字符串中每个字符的序数值
在实践中,大多数散列函数都是不完美的,会面临冲突。这意味着散列函数会给多个字符串相同的散列值。这种冲突对于实现散列表来说是不希望的。
完美散列函数
一个完美的散列函数是指对于给定的字符串(可以是任何数据类型;在这里,我们使用字符串数据类型作为例子),我们得到一个唯一的散列值。我们的目标是创建一个散列函数,该函数最小化冲突的数量,速度快,易于计算,并且在散列表中均匀分布数据项。但是,通常创建一个既快速又为每个字符串提供唯一散列值的效率高的散列函数是非常困难的。如果我们试图开发一个避免冲突的散列函数,这将变得非常慢,而一个慢速的散列函数并不能满足散列表的目的。因此,我们使用一个快速的散列函数,并试图找到一种解决冲突的策略,而不是试图找到一个完美的散列函数。
为了避免在前一节讨论的散列函数中的冲突,我们可以在字符串的每个字符的序数值上添加一个乘数,随着我们在字符串中的进展,这个乘数会持续增加。此外,可以通过将每个字符的乘积序数值相加来获得字符串的散列值。为了更好地理解这一概念,请参考以下图 8.4:

图 8.4:hello world 字符串中每个字符的序数值乘以数值
在前面的图 8.4中,每个字符的序数值逐渐乘以一个数。请注意,第二行有每个字符的序数值;第三行显示乘数值;在第四行,我们通过将第二行和第三行的值相乘得到值,例如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)))
当我们执行前面的代码时,我们得到以下输出:
hello world: 6736
world hello: 6616
gello xorld: 6742
我们可以看到,这次,我们为这三个字符串得到了不同的散列值。但这仍然不是一个完美的散列。现在让我们尝试字符串ad和ga:
for item in ('ad', 'ga'):
print("{}: {}".format(item, myhash(item)))
前面代码片段的输出如下:
ad: 297
ga: 297
因此,我们仍然没有得到一个完美的散列函数,因为我们为这两个不同的字符串得到了相同的散列值。因此,我们需要制定一种解决这种冲突的策略。我们将在下一节中讨论更多解决冲突的策略。
解决冲突
哈希表中的每个位置通常被称为槽或桶,可以存储一个元素。每个以(键,值)对形式存在的数据项存储在哈希表中,其位置由键的哈希值决定。让我们以一个例子来说明,首先我们使用一个散列函数,通过计算所有字符的序数值之和来计算哈希值。然后,我们通过计算总序数值对 256 取模来得到最终的哈希值(换句话说,索引位置)。在这里,我们以 256 个槽/桶为例。我们可以根据在哈希表中需要多少条记录来使用任意数量的槽。我们在图 8.5中展示了样本哈希,其中包含对应数据值的关键字符串,例如,eggs关键字符串对应的值为123456789。
这个哈希表使用一个散列函数,将输入字符串hello world映射到哈希值92,这找到了哈希表中的槽位置:

图 8.5:一个样本哈希表
一旦我们知道了键的哈希值,它将被用来找到元素在哈希表中应该存储的位置。因此,我们需要找到一个空槽。我们从与键的哈希值相对应的槽开始。如果那个槽是空的,我们就将数据项插入那里。如果槽不为空,这意味着我们发生了冲突。这意味着我们有一个与表中之前存储的项相同的哈希值。我们需要确定一种策略来避免这种冲突或冲突。
例如,在下面的图中,关键字符串hello world已经存储在索引位置92的表中,并且对于一个新的关键字符串,例如world hello,我们得到相同的哈希值92。这意味着发生了冲突。请参考下面的图 8.6来展示这个概念:

图 8.6:两个字符串的哈希值相同
解决这种冲突的一种方法是从冲突的位置找到另一个空闲槽。这种冲突解决过程被称为开放寻址。
开放寻址
在开放寻址中,关键值存储在哈希表中,并且使用探测技术来解决冲突。开放寻址是一种在哈希表中使用的冲突解决技术。冲突通过搜索(也称为探测)一个替代位置来解决,直到我们在哈希表中找到一个未使用的槽来存储数据项。
对于基于开放寻址的冲突解决技术,有三种流行的方法:
-
线性探测
-
二次探测
-
双重散列
线性探测
系统地访问每个槽位的方法是线性解决冲突的方式,其中我们通过将 1 加到发生冲突的前一个哈希值上来线性地寻找下一个可用的槽位。这被称为线性探测。我们可以通过将键字符串中每个字符的序数值之和加 1 来解决冲突,这个和进一步用于根据哈希表的大小计算最终的哈希值。
让我们考虑一个例子。首先,计算键的哈希值。如果位置已被占用,我们按顺序检查哈希表以找到下一个空闲槽位。让我们用以下 图 8.7 来解决冲突,其中,对于键字符串 egg,序数值之和为 307,然后我们通过取模 256 来计算哈希值,得到 egg 键字符串的哈希值为 51。然而,数据已经存储在这个位置,这意味着发生了冲突。因此,我们将字符串中每个字符的序数值之和计算出的哈希值加 1。这样,我们为这个键字符串获得一个新的哈希值 52 来存储数据。请参考以下 图 8.7,它描述了上述过程:

图 8.7:冲突解决的示例
为了在哈希表中找到下一个空闲槽位,我们增加哈希值,在线性探测的情况下,这个增加是固定的。由于在发生冲突时哈希值的增加是固定的,新的数据元素总是存储在由哈希函数给出的下一个可用索引位置。这创建了一个连续的占用索引位置的簇,当我们在簇内的任何位置得到另一个具有哈希值的数据元素时,这个簇就会增长。
因此,这种方法的一个主要缺点是哈希表可能会有连续的占用位置,这些位置被称为项的簇。在这种情况下,哈希表的一部分可能会变得密集,而表的另一部分则保持为空。由于这些限制,我们可能更喜欢使用不同的策略来解决冲突,例如四分探测或双哈希,这些内容我们将在后续章节中讨论。让我们首先讨论使用线性探测作为冲突解决技术的哈希表实现,在理解了线性探测的概念之后,我们将讨论其他冲突解决技术。
实现哈希表
要实现哈希表,我们首先创建一个类来存储哈希表项。由于哈希表是一个 {键-值} 存储结构,这些项需要有一个键和一个值:
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个槽位,这意味着我们需要一个返回值在0到255(表的大小)范围内的哈希函数。一种好的方法是返回哈希值除以表大小的余数,因为余数肯定是一个介于0和255之间的整数值。
由于哈希函数仅打算由类内部使用,我们在名称前加上下划线(_)以表示这一点。这是 Python 表示某物打算用于内部使用的约定。以下是hash函数的实现,该函数应在HashTable类中定义:
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()方法应在HashTable类中定义:
def put(self, key, value):
item = HashItem(key, value)
h = self._hash(key)
while self.slots[h] != None:
if self.slots[h].key == key:
break
h = (h + 1) % self.size
if self.slots[h] == None:
self.count += 1
self.slots[h] = item
self.check_growth()
在获取键的哈希值并且如果槽位不为空的情况下,通过将前一个哈希值加1并应用线性探测技术来检查下一个空闲槽位。考虑以下代码:
while self.slots[h] != None:
if self.slots[h].key == key:
break
h = (h + 1) % self.size
如果槽位为空,则我们将计数增加1并将新元素(意味着槽位之前包含None)存储在所需位置上的列表中。参考以下代码:
if self.slots[h] is None:
self.count += 1
self.slots[h] = item
self.check_growth()
在上面的代码中,我们创建了一个哈希表,并讨论了在发生冲突时使用线性探测技术在哈希表中存储数据元素的put()方法。
在上述代码的最后一行,我们调用一个 check_growth() 方法,该方法用于在我们哈希表中剩余非常有限的空槽位时扩展哈希表的大小。我们将在下一节中更详细地讨论这个问题。
增加哈希表
在我们讨论的示例中,我们将哈希表的大小固定为 256。很明显,当我们向哈希表中添加元素时,哈希表开始填满,在某个时刻,所有的槽位都将被填满,哈希表将满。为了避免这种情况,我们可以在表开始填满时增加表的大小。
为了增加哈希表的大小,我们比较表中的大小和计数。size 是槽位的总数,而 count 表示包含元素的槽位数量。因此,如果 count 等于 size,这意味着我们已经填满了表。哈希表的负载因子通常用于扩展表的大小;这为我们提供了关于表中有多少可用槽位被使用的指示。哈希表的负载因子是通过将已用槽位数量除以表中槽位的总数来计算的。它定义如下:
负载因子 = n/k
在这里,n 是已用槽位的数量,k 是槽位的总数。当负载因子值接近 1 时,这意味着表将要被填满,我们需要增加表的大小。在表几乎填满之前增加表的大小会更好,因为当表填满时,从表中检索元素会变得缓慢。负载因子为 0.75 可能是增加表大小的良好值。另一个问题是我们应该增加表的大小多少。一种策略是简单地将其大小加倍。
线性探测的问题在于,随着负载因子的增加,找到新元素插入点所需的时间会变长。此外,在开放寻址冲突解决技术的情况下,我们应该根据负载因子增加哈希表的大小,以减少冲突的数量。
当负载因子超过阈值时增加哈希表大小的实现如下。首先,我们重新定义包含一个额外变量 MAXLOADFACTOR 的 HashTable 类,该变量用于确保哈希表的负载因子始终低于预定义的最大负载因子。HashTable 类定义如下:
class HashTable:
def __init__(self):
self.size = 256
self.slots = [None for i in range(self.size)]
self.count = 0
self.MAXLOADFACTOR = 0.65
接下来,我们使用以下 check_growth() 方法检查在向哈希表添加任何记录后哈希表的负载因子,该方法应在 HashTable 类中定义:
def check_growth(self):
loadfactor = self.count / self.size
if loadfactor > self.MAXLOADFACTOR:
print("Load factor before growing the hash table", self.count / self.size )
self.growth()
print("Load factor after growing the hash table", self.count / self.size )
在前述代码中,我们计算表的重载因子,然后检查它是否超过设定的阈值(换句话说,MAXLOADFACTOR是一个在创建哈希表时初始化的变量)。在这种情况下,我们调用growth()方法来增加哈希表的大小(在这个例子中,我们加倍哈希表的大小)。growth()方法应该在HashTable类中定义,其实现如下:
def growth(self):
New_Hash_Table = HashTable()
New_Hash_Table.size = 2 * self.size
New_Hash_Table.slots = [None for i in range(New_Hash_Table.size)]
for i in range(self.size):
if self.slots[i] != None:
New_Hash_Table.put(self.slots[i].key, self.slots[i].value)
self.size = New_Hash_Table.size
self.slots = New_Hash_Table.slots
在前述代码中,我们首先创建一个新的哈希表,其大小是原始哈希表的两倍,然后我们初始化其所有槽位为None。接下来,我们检查原始哈希表中所有已填充的槽位,因为我们必须将这些现有记录插入到新的哈希表中,因此,我们使用现有哈希表的所有键值对调用put()方法。一旦我们将所有记录复制到新的哈希表中,我们就用新的哈希表替换现有表的大小和槽位。
让我们通过在HashTable类的__init__方法中定义self.size = 10来创建一个最大容量为 10 条记录,阈值负载因子为 65%的哈希表,这意味着每当第七条记录被添加到哈希表中时,我们调用一个check_growth()方法:
ht = HashTable()
ht.put("good", "eggs")
ht.put("better", "ham")
ht.put("best", "spam")
ht.put("ad", "do not")
ht.put("ga", "collide")
ht.put("awd", "do not")
ht.put("add", "do not")
ht.checkGrow()
在上述代码中,我们使用put()方法添加了七个记录。前述代码的输出如下:
Load factor before growing the hash table 0.7
Load factor after growing the hash table 0.35
在上述输出中,我们可以看到在添加第七条记录之前和之后,负载因子变成了增长哈希表之前的负载因子的一半。
在下一节中,我们将讨论用于检索存储在哈希表中的数据元素的get()方法。
从哈希表中检索元素
要从哈希表中检索元素,将返回与键存储的值。在这里,我们讨论检索方法的实现——get()方法。此方法返回表中存储的与给定键对应的值。
首先,我们计算给定键对应的要检索的值的哈希值。一旦我们得到了键的哈希值,我们就在哈希表的哈希值位置查找。如果键项与该位置的存储键值匹配,则检索相应的值。
如果不匹配,那么我们将字符串中所有字符的序数值之和加 1,类似于我们在存储数据时所做的,然后我们查看新获得的哈希值。我们继续搜索,直到我们得到键元素,或者检查哈希表中的所有槽位。
在这里,我们使用了线性探测技术来解决冲突,因此在从哈希表中检索数据元素时也使用了同样的技术。因此,如果我们打算在存储数据元素时使用不同的技术,比如说双重哈希或二次探测,那么在检索数据元素时也应该使用相同的方法。考虑图 8.8中的例子,以及以下四个步骤:
-
我们计算给定键字符串
egg的哈希值,结果为51。然后,我们将这个键与位置51处的存储键值进行比较,但它们不匹配。 -
由于键不匹配,我们计算一个新的哈希值。
-
我们在新建的哈希值位置查找键,该值为
52;我们将键字符串与存储的键值进行比较,在这里它们匹配,如下面的图所示。 -
哈希表返回与该键值对应的存储值。请参考以下图 8.8:

图 8.8:演示了从哈希表中检索元素的四步
为了实现这种检索方法,即get()方法,我们首先计算键的哈希值。接下来,我们在表中查找计算出的哈希值。如果找到匹配项,我们返回相应的存储值。否则,我们继续查看按照描述计算出的新哈希值位置。以下是get()方法的实现,它应该在HashTable类中定义:
def get(self, key):
h = self._hash(key) # computed hash for the given key
while self.slots[h] != None:
if self.slots[h].key == key:
return self.slots[h].value
h = (h+ 1) % self.size
return None
最后,如果在表中找不到键,我们返回None;我们本可以打印出键在哈希表中未找到的消息。
测试哈希表
为了测试哈希表,我们创建HashTable并存储一些元素在其中,然后尝试检索它们。我们可以使用get()方法来查找是否存在给定键的记录。我们还使用了两个字符串ad和ga,它们在哈希函数中返回了相同的哈希值,并且发生了冲突。为了评估哈希表的工作,我们将这个冲突也抛出,只是为了看看冲突是否得到了适当的解决。请参考以下示例代码:
ht = HashTable()
ht.put("good", "eggs")
ht.put("better", "ham")
ht.put("best", "spam")
ht.put("ad", "do not")
ht.put("ga", "collide")
for key in ("good", "better", "best", "worst", "ad", "ga"):
v = ht.get(key)
print(v)
执行上述代码后,我们得到以下输出:
eggs
ham
spam
none
do not
collide
如您所见,查找worst键返回None,因为该键不存在。ad和ga键也返回它们各自的值,这表明它们之间的冲突得到了适当的处理。
将哈希表实现为字典
使用put()和get()方法在哈希表中存储和检索元素可能看起来有些不便。然而,我们也可以将哈希表用作字典,这样使用起来会更方便。例如,我们希望使用ht["good"]而不是ht.get("good")来从表中检索元素。
这可以通过特殊方法__setitem__()和__getitem__()轻松实现,这些方法应该在HashTable类中定义。
以下代码展示了这一点:
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))
上述代码的输出如下:
eggs
ham
spam
none
do not
collide
The number of elements is: 5
注意,我们还使用count变量打印已存储在哈希表中的元素数量。上述代码与上一节中所做的一样,但使用起来更方便。
在下一节中,我们讨论用于冲突解决的二次探测技术。
二次探测
这也是一种用于解决哈希表冲突的开放寻址方案。它通过计算键的哈希值并添加二次多项式的连续值来解决冲突;新的哈希值迭代计算,直到找到空槽位。如果发生冲突,则在位置 h + 1²、h + 2²、h + 3²、h + 4²等位置检查下一个空闲槽位。因此,新的哈希值按以下方式计算:
new-hash(key) = (old-hash-value + i²)
Here, hash-value = key mod table_size
当我们有一个字符串键时,我们使用每个字符的序数值乘以数值之和来计算哈希值,然后将其传递给哈希函数,最终获得键字符串的哈希值。然而,在非字符串键元素的情况下,我们可以直接使用哈希函数来计算键的哈希值。
让我们以一个有七个槽位的简单哈希表为例,并假设哈希函数是h(key) = key mod 7。为了理解二次探测的概念,让我们假设我们有键元素值,它们是给定键字符串的哈希值。
因此,每次当我们使用二次探测技术来确定存储数据元素的下一次索引位置时,我们遇到冲突,我们应该执行以下步骤来解决冲突:
-
初始时,由于我们有一个空表,当我们得到一个键元素
15(假设它是给定字符串的哈希值)时,我们使用我们的给定哈希函数来计算哈希值,换句话说,15 mod 7= 1。因此,数据元素存储在索引位置1。 -
然后,假设我们得到一个键元素
22(假设它是下一个给定字符串的哈希值),我们使用哈希函数来计算哈希值,换句话说,22 mod 7 = 1,它给出索引位置1。由于索引位置1已被占用,因此发生冲突,所以我们使用二次探测计算一个新的哈希值,即(1+ 1² = 2)。新的索引位置是2。因此,数据元素存储在索引位置2。 -
接下来,假设我们得到一个数据元素
29(假设它是给定字符串的哈希值),我们计算哈希值29 mod 7 = 1。由于这里发生冲突,我们再次计算哈希值,就像步骤 2中那样,但在这里我们又遇到了冲突,因此我们必须重新计算哈希值一次,换句话说(1+2² = 5),所以数据存储在那个位置。
使用二次探测技术解决过程的上述示例显示在图 8.9中:

图 8.9:使用二次探测解决冲突的示例
用于冲突避免的二次探测技术不会像线性探测那样形成相同项的簇;然而,它确实会遭受次级簇的影响。次级簇会创建一个填充槽位的长时间运行,因为具有相同哈希值的数据元素也将具有相同的探测序列。
我们在上一节中讨论了哈希表的实现,包括数据元素的添加和检索,并使用了线性探测技术来解决冲突。现在,如果我们想使用任何其他冲突解决技术,如二次探测技术,我们可以更新哈希表的实现。除了以下两个方法外,HashTable类中的所有方法都将保持不变,这两个方法应该在HashTable类中定义:
def get_quadratic(self, key):
h = self._hash(key)
`j = 1`
while self.slots[h] != None:
if self.slots[h].key == key:
return self.slots[h].value
`h = (h+ j*j) % self.size`
`j = j + 1`
return None
def put_quadratic(self, key, value):
item = HashItem(key, value)
h = self._hash(key)
`j = 1`
while self.slots[h] != None:
if self.slots[h].key == key:
break
`h = (h + j*j) % self.size`
`j = j+1`
if self.slots[h] == None:
self.count += 1
self.slots[h] = item
self.check_growth()
上面的get_quadratic()和put_quadratic()方法的代码与之前讨论的get()和put()方法的实现类似,只是前面的代码中代码语句是加粗的。加粗的语句表示在发生冲突时,我们使用二次探测公式检查下一个空槽:
ht = HashTable()
ht.put_quadratic("good", "eggs")
ht.put_quadratic("ad", "packt")
ht.put_quadratic("ga", "books")
v = ht.get_quadratic("ga")
print(v)
在上面的代码中,我们首先添加了三个数据元素及其关联的值,然后我们在哈希表中搜索键为"ga"的数据项。前面代码的输出如下:
books
上面的输出对应于键字符串"ga",这是正确的,因为输入数据存储在哈希表中。接下来,我们将讨论另一种冲突解决技术——双重哈希。
双重哈希
在双重哈希冲突解决技术中,我们使用两个哈希函数。这种技术的工作原理如下。首先,使用主哈希函数计算哈希表中的索引位置,每当发生冲突时,我们使用另一个哈希函数通过增加哈希值来决定存储数据的下一个空闲槽位。
为了在哈希表中找到下一个空闲槽位,我们增加哈希值,在线性探测和二次探测的情况下,这个增加是固定的。由于在发生冲突时哈希值的增加是固定的,记录总是被移动到由哈希函数给出的下一个可用索引位置。这会创建一个连续的占用索引位置的簇。每当有另一个记录的哈希值在簇内任何位置时,这个簇就会增长。
然而,在双重哈希技术的情况下,探测间隔取决于键数据本身,这意味着每次发生冲突时,我们总是映射到哈希表中的不同索引位置,这反过来又有助于避免簇的形成。
这种冲突解决技术的探测序列如下:
(h¹(key)+i*h²(key))mod table_size
h¹(key) = key mod table_size
这里需要注意的是,第二个散列函数应该是快速的,易于计算,不应等于 0,并且应与第一个散列函数不同。
第二个散列函数的一个选择可以定义为以下内容:
h²(key) = prime_number - (key mod prime_number)
在上述散列函数中,素数应该小于表的大小。
例如,假设我们有一个最多可以容纳七个槽位的哈希表,当我们按顺序将数据元素{15, 22, 29}添加到该表中时。以下步骤是在发生碰撞时使用双散列技术将这些数据元素存储在哈希表中的步骤:
-
首先,我们有数据元素
15,我们使用主散列函数计算散列值,换句话说,(15 mod 7 = 1)。由于表最初是空的,我们将数据存储在索引位置1。 -
接下来,数据元素是
22,我们使用主散列函数计算散列值,换句话说,(22 mod 7 = 1)。由于索引位置 1 已经被占用,这意味着发生了碰撞。接下来,我们使用上面定义的二级散列函数h²(key) = prime_number - (key mod prime_number)来确定哈希表中的下一个索引位置。这里,我们假设小于表大小的素数是 5。这意味着哈希表中的下一个索引位置将是(1 + 1*(5 - (22 mod 5))) mod 7,这相当于 4。因此,我们将此数据元素存储在索引位置4。 -
接下来,我们有数据元素 29,因此我们使用主散列函数计算散列值,换句话说,
(29 mod 7 =1)。我们遇到了碰撞,现在我们使用二级散列函数来确定存储数据元素的下一个索引位置,换句话说,(1 + 1*(5 - (29 mod 5))) mod 7,结果是 2,因此我们将此数据元素存储在位置 2。
使用双散列法解决碰撞的过程示例如图 8.10所示:

图 8.10:使用双散列法解决碰撞的示例
让我们现在看看如何实现具有双散列技术以解决碰撞的哈希表。put_double_hashing()和get_double_hashing()方法如下,这些方法应在HashTable类中定义。
以下h2()方法用于计算序数值之和,因为在我们示例中,我们将字符串作为键元素:
def h2(self, key):
mult = 1
hv = 0
for ch in key:
hv += mult * ord(ch)
mult += 1
return hv
此外,我们应该重新定义哈希表,包括一个作为变量使用的素数,该变量将用于计算二级散列函数:
class HashTable:
def __init__(self):
self.size = 256
self.slots = [None for i in range(self.size)]
self.count = 0
self.MAXLOADFACTOR = 0.65
self.prime_num = 5
以下代码旨在在哈希表中插入数据元素及其关联值,并在碰撞时使用双散列技术:
def put_double_hashing(self, key, value):
item = HashItem(key, value)
h = self._hash(key)
j = 1
while self.slots[h] != None:
if self.slots[h].key == key:
break
`h = (h + j * (self.prime_num - (self.h2(key) % self.prime_num))) % self.size`
`j = j+1`
if self.slots[h] == None:
self.count += 1
self.slots[h] = item
self.check_growth()
def get_double_hashing(self, key):
h = self._hash(key)
j = 1
while self.slots[h] != None:
if self.slots[h].key == key:
return self.slots[h].value
`h = (h + j * (self.prime_num - (self.h2(key) % self.prime_num))) % self.size`
`j = j + 1`
return None
get_doubleHashing() 和 put_doubleHashing() 方法的上述代码与之前讨论的 get() 和 put() 方法的实现非常相似,除了前述代码中加粗的语句。加粗的语句表明在发生冲突时,我们使用双哈希技术公式来获取散列表中的下一个空槽位:
ht = HashTable()
ht.put_doubleHashing("good", "eggs")
ht.put_doubleHashing("better", "spam")
ht.put_doubleHashing("best", "cool")
ht.put_doubleHashing("ad", "donot")
ht.put_doubleHashing("ga", "collide")
ht.put_doubleHashing("awd", "hello")
ht.put_doubleHashing("addition", "ok")
for key in ("good", "better", "best", "worst", "ad", "ga"):
v = ht.get_doubleHashing(key)
print(v)
print("The number of elements is: {}".format(ht.count))
在上述代码中,我们首先插入七个不同的数据元素及其关联的值,然后我们在散列表中搜索和检查一些随机数据项。前述代码的输出如下:
eggs
spam
cool
none
donot
collide
The number of elements is: 7
在上述输出中,我们可以观察到键字符串 worst 不在散列表中,这意味着对应的输出是 None。
线性探测会导致主要聚簇,而二次探测可能会导致次级聚簇,而双哈希技术是解决冲突的最有效方法之一,因为它不会产生任何聚簇。这种技术的优点是它在散列表中产生记录的均匀分布。
在开放寻址冲突解决技术中,我们像在线性探测、二次探测和双哈希中做的那样,在散列表中搜索另一个空槽位。“closed”在“closed hashing”中指的是我们不会离开散列表,并且每个记录都存储在哈希函数给出的索引位置,因此“closed hashing”和“open addressing”是同义词。
另一方面,当一个记录始终存储在哈希函数给出的索引位置时,这被称为“closed addressing”或“open hashing”技术。在这里,“open”在“open hashing”中指的是我们愿意通过一个单独的列表离开散列表,其中可以存储数据元素;例如,链地址法是一种封闭寻址技术。
在下一节中,我们将讨论另一种冲突解决技术——链表技术。
链地址法
链地址法是另一种处理散列表中冲突问题的方法。它通过允许散列表中的每个槽位存储多个项的引用来解决此问题。因此,在冲突的位置,我们允许在散列表中存储多个项。
在链表中,散列表的槽位被初始化为空列表。当插入一个数据元素时,它被追加到对应该元素哈希值的列表中。例如,在以下 图 8.11 中,键字符串 hello world 和 world hello 发生了冲突。在链地址法的情况下,两个数据元素都使用哈希函数给出的索引位置存储,也就是说,在 图 8.11 中的示例中是 92。以下是一个使用链地址法解决冲突的示例:

图 8.11:使用链式连接解决冲突的示例
另一个示例在图 8.12中展示,其中如果我们有多个具有哈希值51的数据元素,所有这些元素都会被添加到哈希表相同槽位中存在的列表中:

图 8.12:存储在列表中的多个具有相同哈希值的元素
通过允许多个元素具有相同的哈希值,链式连接避免了冲突。因此,在哈希表中存储元素的数量没有限制,而在开放寻址冲突解决技术的情况下,我们必须固定表的大小,当表被填满时,我们还需要扩展它。此外,哈希表可以存储比可用槽位更多的值,因为每个槽位都包含一个可以增长的列表。
然而,链式连接存在一个问题——当特定哈希值位置的列表增长时,它会变得低效。由于特定槽位中有许多项目,搜索它们可能会变得非常缓慢,因为我们必须对列表进行线性搜索,直到找到具有我们想要的键的元素。这可能会减慢检索速度,这对哈希表来说并不好,因为哈希表旨在高效。因此,使用链表进行单独链式连接算法搜索的最坏情况时间复杂度为 O(n),因为在最坏的情况下,所有项目都将添加到哈希表中的唯一索引位置,搜索一个项目的工作方式将与链表相似。以下图 8.13展示了通过列表项进行线性搜索直到找到匹配项:

图 8.13:演示线性搜索以找到哈希值为 51 的匹配项
因此,当哈希表中的特定位置有多个条目时,检索项目会变得缓慢。这个问题可以通过使用另一种数据结构来解决,而不是使用可以快速搜索和检索的列表。一个很好的选择是使用二叉搜索树(BST),正如我们在上一章中讨论的那样,它提供了快速的检索。
我们可以在每个槽位中简单地插入一个(最初为空的)二叉搜索树(BST),如下面的图 8.14所示:

图 8.14:哈希值为 51 的桶的二叉搜索树
在前面的图中,51槽位包含一个 BST,我们用它来存储和检索数据项。然而,我们仍然可能遇到一个问题——取决于项目添加到 BST 中的顺序,我们可能会得到一个与列表一样低效的搜索树。也就是说,树中的每个节点恰好有一个子节点。为了避免这种情况,我们需要确保我们的 BST 是自平衡的。
下面是具有单独链式连接的哈希表的实现。首先,我们创建一个Node类来存储键值对,并有一个指针指向链表中的下一个节点:
class Node:
def __init__(self, key=None, value=None):
self.key = key
self.value = value
self.next = None
接下来,我们定义单链表,其详细信息在第四章,链表中提供。在这里,我们已定义了append()方法,用于向链表中添加新的数据记录:
class SinglyLinkedList:
def __init__ (self):
self.tail = None
self.head = None
def append(self, key, value):
node = Node(key, value)
if self.tail:
self.tail.next = node
self.tail = node else:
self.head = node
self.tail = node
接下来,我们定义traverse()方法,该方法打印出所有带有键值对的数据记录。traverse()方法应在SinglyLinkedList类中定义。我们从头节点开始,在迭代while循环时移动下一个节点:
def traverse(self):
current = self.head
while current:
print("\"", current.key, "--", current.value, "\"")
current = current.next
接下来,我们定义一个search()方法,该方法匹配我们想要在链表中搜索的键。如果键与任何节点匹配,则打印相应的键值对。search()方法应在SinglyLinkedList类中定义:
def search(self, key):
current = self.head
while current:
if current.key == key:
print("\"Record found:", current.key, "-", current.value, "\"")
return True
current = current.next
return False
一旦我们定义了链表和所有必需的方法,我们定义HashTableChaining类,在其中我们初始化哈希表的大小和所有槽位为空链表:
class HashTableChaining:
def __init__(self):
self.size = 6
self.slots = [None for i in range(self.size)]
for x in range(self.size) :
self.slots[x] = SinglyLinkedList()
接下来,我们定义哈希函数,即_hash(),类似于我们在前面的章节中讨论的内容:
def _hash(self, key):
mult = 1
hv = 0
for ch in key:
hv += mult * ord(ch)
mult += 1
return hv % self.size
然后,我们定义put()方法,用于在哈希表中插入新的数据记录。首先,我们创建一个带有键值对的节点,然后根据哈希函数计算索引位置。然后,我们将节点追加到与给定索引位置关联的链表的末尾。put()方法应在HashTableChaining类中定义:
def put(self, key, value):
node = Node(key, value)
h = self._hash(key)
self.slots[h].append(key, value)
接下来,我们定义get()方法,用于根据哈希表中的键值检索数据元素。首先,我们使用与在哈希表中添加记录时相同的哈希函数计算索引位置,然后我们在与计算出的给定索引位置关联的链表中搜索所需的数据记录。get()方法应在HashTableChaining类中定义:
def get(self, key):
h = self._hash(key)
v = self.slots[h].search(key)
最后,我们可以定义printHashTable()方法,该方法打印出完整的哈希表,显示哈希表中的所有记录:
def printHashTable(self) :
print("Hash table is :- \n")
print("Index \t\tValues\n")
for x in range(self.size) :
print(x,end="\t\n")
self.slots[x].traverse()
我们可以使用以下代码在哈希表中插入一些示例数据记录,并使用链式技术存储数据。然后,我们使用键字符串best搜索数据记录,并打印出完整的哈希表:
ht = HashTableChaining()
ht.put("good", "eggs")
ht.put("better", "ham")
ht.put("best", "spam")
ht.put("ad", "do not")
ht.put("ga", "collide")
ht.put("awd", "do not")
ht.printHashTable()
上述代码的输出如下:
Hash table is :-
Index Values
0
1
2
" good - eggs "
3
" better - ham "
" ad - do not "
" ga - collide "
4
5
" best - spam "
" awd - do not "
上述输出显示了所有数据记录如何存储在哈希表的每个索引位置。我们可以观察到,根据哈希函数给出的相同索引位置,存储了多个数据记录。
哈希表是存储键值对数据的重要数据结构,我们可以使用任何冲突解决技术,即开放寻址或单独链表。当键在哈希表中均匀分布时,开放寻址技术非常快,但可能存在集群形成的复杂性。
单链表技术没有聚集问题,但当所有数据记录都哈希到哈希表中的非常少数索引位置时,它可能会变慢。
符号表
符号表被编译器和解释器用来跟踪程序中声明的符号和不同实体,例如对象、类、变量和函数名。由于从表中高效检索符号很重要,符号表通常使用哈希表构建。
让我们来看一个例子。假设我们在symb.py文件中有以下 Python 代码:
name = "Joe"
age = 27
在这里,我们有两个符号,name和age。每个符号都有一个值;例如,name符号的值是Joe,而age符号的值是27。符号表允许编译器或解释器查找这些值。因此,name和age符号成为哈希表中的键。所有与之相关的其他信息成为符号表条目的value。
在编译器中,符号表还可以包含其他符号,例如函数和类名。例如,greet()函数和两个变量,换句话说,name和age,如图 8.15所示存储在符号表中:

图 8.15:符号表示例
编译器为它在执行时加载到内存中的每个模块创建一个符号表。符号表是哈希表的重要应用之一,主要用于编译器和解释器中高效存储和检索符号及其相关值。
摘要
在本章中,我们讨论了哈希技术和哈希表的数据结构。我们学习了在哈希表上执行的不同操作的实现和概念。我们还讨论了几种冲突解决技术,包括开放寻址技术,即线性探测、二次探测和双重哈希。此外,我们还讨论了另一种冲突解决方法——分离链接。最后,我们探讨了通常使用哈希表构建的符号表。符号表允许编译器或解释器查找已定义的符号(如变量、函数或类),并检索有关它的所有信息。在下一章中,我们将详细讨论图算法。
练习
-
有一个包含 40 个槽位的哈希表,表中存储了 200 个元素。哈希表的负载因子是多少?
-
使用分离链接算法进行哈希的最坏情况搜索时间是什么?
-
假设哈希表中的键分布均匀。搜索/插入/删除操作的时间复杂度将是什么?
-
从字符数组中删除重复字符的最坏情况复杂度是什么?
加入我们的 Discord 社区
加入我们社区的 Discord 空间,与作者和其他读者进行讨论:packt.link/MEvK4

第九章:图和算法
图是一种非线性数据结构,其中问题通过连接节点集与边来表示网络,例如电话网络或社交网络。例如,在图中,节点可以代表不同的城市,而它们之间的链接代表边。图是最重要的数据结构之一;它们用于解决许多计算问题,尤其是在问题以对象及其连接的形式表示时,例如找出从一个城市到另一个城市的最短路径。图是解决现实世界问题的有用数据结构,其中问题可以表示为类似网络的结构。在本章中,我们将讨论与图相关的重要和流行概念。
在本章中,我们将学习以下概念:
-
图数据结构的概念
-
如何表示图和遍历它
-
图上的不同操作及其实现
首先,我们将探讨不同类型的图。
图
图是一组有限数量的顶点(也称为节点)和边,其中边是顶点之间的链接,图中每条边连接两个不同的节点。此外,图是网络的正式数学表示,即图G是一个顶点集合V和边集合E的有序对,在正式数学符号中表示为G = (V, E)。
一个图的示例在图 9.1中显示:

图 9.1:一个图的示例
图 9.1中的图G = (V, E)可以描述如下:
-
V = {A, B, C, D, E} -
E = {{A, B}, {A, C}, {B, C}, {B, D}, {C, D}, {D, D}, {B, E}, {D, E}} -
G = (V, E)
让我们讨论一些图的重要定义:
-
节点或顶点:图中的一个点或节点称为顶点。在前面的图中,顶点或节点是A、B、C、D和E,并用点表示。
-
边:这是两个顶点之间的连接。连接A和B的线是一个边的例子。
-
循环:当一个节点的边返回到自身时,这条边形成一个循环,例如D节点。
-
顶点/节点的度:给定顶点上偶然出现的边的总数称为该顶点的度。例如,前图中B节点的度是
4。 -
邻接:这指的是任何两个节点之间的连接;因此,如果任何两个顶点或节点之间存在连接,则它们被称为彼此相邻。例如,C 节点与 A 节点相邻,因为它们之间有一条边。
-
路径:任何两个节点之间顶点和边的序列表示一条路径。例如,CABE表示从C节点到E节点的路径。
-
叶节点(也称为悬垂节点):如果一个节点或顶点恰好有一个度,则称为叶节点或悬垂节点。
现在,我们将探讨不同类型的图。
有向图和非定向图
图由节点之间的边表示。连接边可以是定向的或非定向的。如果一个图中的连接边是非定向的,那么这个图被称为非定向图;如果连接边是定向的,那么它被称为定向图。非定向图简单地用节点之间的线条表示边。除了节点之间是相连的事实之外,没有关于节点之间关系的其他信息。例如,在图 9.2中,我们展示了四个节点A、B、C和D的非定向图,这些节点通过边相连:

图 9.2:一个非定向图的示例
在有向图中,边提供了图中任意两个节点之间连接方向的有关信息。如果从A节点到B的边被称为有向的,那么边(A, B)就不会等于边(B, A)。有向边用带有箭头的线条绘制,箭头将指向边连接两个节点的方向。
例如,在图 9.3中,我们展示了一个有向图,其中许多节点通过有向边相连:

图 9.3:一个有向图的示例
边的箭头决定了方向的流动。只能从A移动到B,如前图所示——不能从B移动到A。在有向图中,每个节点(或顶点)都有一个入度和一个出度。让我们看看这些是什么:
-
入度:图中进入一个节点的边的总数被称为该节点的入度。例如,在之前的图中,E节点有
1个入度,因为边CE进入E节点。 -
出度:从图中一个节点出发的边的总数被称为该节点的出度。例如,在之前的图中,E节点有一个出度为
2,因为它有两个边,EF和ED,从这个节点出发。 -
孤立点:当一个节点或顶点的度为零时,它被称为孤立点,如图图 9.3中所示的G节点。
-
源点:如果一个节点没有入度,那么这个节点被称为源点。例如,在之前的图中,A节点是源点。
-
汇点:如果一个节点没有出度,那么这个节点被称为汇点。例如,在之前的图中,F节点是汇点。
现在我们已经了解了有向图的工作原理,我们可以看看有向无环图。
有向无环图
有向无环图(DAG)是一个没有环的有向图;在 DAG 中,所有边都是从一个节点指向另一个节点,使得边的序列永远不会形成一个闭环。当序列中第一条边的起始节点等于最后一条边的结束节点时,图中就形成了一个环。
在图 9.4中展示了一个有向无环图,其中图中的所有边都是有向的,并且图中没有环:

图 9.4:有向无环图的示例
因此,在一个有向无环图中,如果我们从一个给定的节点开始沿任何路径,我们永远不会找到一条以相同节点结束的路径。有向无环图有许多应用,例如在作业调度、引用图和数据压缩中。
接下来,我们将讨论带权图。
带权图
一个带权图是一个与图中边相关联有数值权重的图。带权图可以是定向图或无向图。数值权重可以根据图的目的用来表示距离或成本:

图 9.5:带权图的示例
让我们考虑一个例子 – 图 9.5 指示了从A节点到D节点的不同路径。有两种可能的路径,例如从A节点到D节点,或者它可以是节点A-B-C-D通过B节点和C节点。现在,根据与边相关的权重,任何一条路径都可以被认为是比其他路径更适合旅行 – 例如,假设这个图中的权重代表两个节点之间的距离,我们想要找出A-D节点之间的最短路径;那么一条可能的路径A-D有一个相关的成本为 40,而另一条可能的路径A-B-C-D有一个相关的成本为 25。在这种情况下,更好的路径是A-B-C-D,它有更低的距离。
接下来,我们将讨论二部图。
二部图
二部图(也称为双图)是一个特殊的图,其中图中的所有节点都可以分成两个集合,使得边连接来自一个集合的节点到另一个集合的节点。参见图 9.6中的示例二部图;图中所有节点被分成两个独立的集合,即集合 U 和集合 V,使得图中的每条边都有一个端点在集合 U 中,另一个端点在集合 V 中(例如,在边(A, B)中,一个端点或一个顶点来自集合 U,另一个端点或另一个顶点来自集合 V)。
在二部图中,没有边会连接到同一集合的节点:

图 9.6:二部图的示例
当我们需要对两个不同类别的对象之间的关系进行建模时,二部图非常有用,例如,一个申请人和工作的图,其中我们可能需要建模这两个不同群体之间的关系;另一个例子可能是一个足球球员和俱乐部的二部图,其中我们可能需要建模一个球员是否为特定的俱乐部效力过。
接下来,我们将讨论不同的图表示技术。
图的表示
图表示技术意味着我们在内存中如何存储图,即我们如何存储顶点、边和权重(如果图是有权图)。图可以用两种方法表示,即(1)邻接表,和(2)邻接矩阵。
邻接表表示基于链表。在这种情况下,我们通过为图的每个顶点(或节点)维护一个邻居列表(也称为相邻节点)来表示图。在图的邻接矩阵表示中,我们维护一个矩阵,该矩阵表示图中哪个节点与哪个其他节点相邻;即,邻接矩阵包含图中每条边的所有信息,这些信息由矩阵的单元格表示。
这两种表示方法都可以使用;然而,我们的选择取决于我们将要使用图表示的应用。当我们预期图将是稀疏的并且边的数量较少时,邻接表是首选;例如,如果一个包含 200 个节点的图有大约 100 条边,那么将这种类型的图存储在邻接表中更好,因为如果我们使用邻接矩阵,矩阵的大小将是 200x200,其中包含许多零值。当我们预期图将有大量边,并且矩阵将是密集的时,邻接矩阵是首选。在邻接矩阵中,与邻接表表示相比,查找和检查边的存在或不存在非常容易。
在随后的章节中,我们将详细讨论邻接矩阵。首先,我们将查看邻接表。
邻接表
在这种表示中,所有直接连接到节点 x 的节点都列在该节点的相邻节点列表中。图通过显示图中所有节点的相邻列表来表示。
在图 9.7中显示的图中,如果节点A和B之间存在直接连接,则称这两个节点是相邻的:

图 9.7:五个节点的示例图
可以使用链表来实现邻接表。为了表示图,我们需要与图中节点总数相等的链表数量。在每个索引处,存储与该顶点相邻的节点。例如,考虑图 9.8中所示的邻接表,它对应于图 9.7中所示的示例图:

图 9.8:图 9.7 中所示图的邻接表
在这里,第一个节点代表图的A顶点,其相邻节点为B和C。第二个节点代表图的B顶点,其相邻节点为E、C和A。同样,图的C、E和F等其他顶点也用它们的相邻节点表示,如前述图 9.8所示。
使用list进行表示非常受限,因为我们缺乏直接使用顶点标签的能力。因此,为了有效地使用 Python 实现图,我们使用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有与顶点B和C的相邻顶点。顶点F的唯一邻接顶点是顶点C。同样,顶点B有与顶点E、C和A的相邻顶点。
当图将要变得稀疏,并且我们可能需要频繁地在图中添加或删除节点时,邻接表是一种更可取的图表示技术。然而,使用这种方法很难检查给定的边是否存在于图中。
接下来,我们将讨论另一种图表示方法,即邻接矩阵。
邻接矩阵
表示图的另一种方法是使用邻接矩阵。在这种情况下,通过显示节点及其通过边相互连接来表示图。使用这种方法,矩阵的维度(V x V)用于表示图,其中每个单元格表示图中的一个边。矩阵是一个二维数组。因此,这里的想法是使用1或0来表示矩阵的单元格,这取决于两个节点是否通过边连接。我们在图 9.9中展示了示例图及其相应的邻接矩阵:

图 9.9:给定图的邻接矩阵
可以使用给定的邻接表实现邻接矩阵。为了实现邻接矩阵,让我们以之前基于字典的图实现为例。首先,我们必须获取邻接矩阵的关键元素。需要注意的是,这些矩阵元素是图中的顶点。我们可以通过排序图的键来获取关键元素。相应的代码片段如下:
matrix_elements = sorted(graph.keys())
cols = rows = len(matrix_elements)
接下来,图的键的长度将是邻接矩阵的维度,这些维度存储在cols和rows中。cols和rows的值相等。
因此,现在,我们创建一个空的邻接矩阵,其维度为cols乘以rows,初始时所有值都填充为零。初始化空邻接矩阵的代码片段如下:
adjacency_matrix = [[0 for x in range(rows)] for y in range(cols)]
edges_list = []
edges_list变量将存储构成图中边的元组。例如,节点 A 和 B 之间的边将被存储为(A, B)。使用嵌套for循环填充多维数组:
for key in matrix_elements:
for neighbor in graph[key]:
edges_list.append((key, neighbor))
print(edges_list)
通过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
print(adjacency_matrix)
matrix_elements 数组有它的 rows 和 cols,从 A 到所有其他顶点,索引为 0 到 5。for 循环遍历元组列表,并使用 index 方法获取要存储边的对应索引。
前述代码的输出是先前在 图 9.9 中显示的示例图的邻接矩阵。生成的邻接矩阵如下所示:
[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 之间不存在边。同样,在行 3 和列 2,有一个值为 1 的值表示图中 C 和 B 顶点之间的边。
在图表示中使用邻接矩阵适合于我们需要频繁地查找和检查图中两个节点之间是否存在边的情况,例如在网络中创建路由表、在公共交通应用和导航系统中搜索路线等。当图中的节点频繁添加或删除时,邻接矩阵不适合,在这些情况下,邻接表是一种更好的技术。
接下来,让我们讨论不同的图遍历方法,在这些方法中,我们访问给定图的全部节点。
图遍历
图遍历意味着在访问图的所有顶点的同时,跟踪哪些节点或顶点已经被访问过,哪些还没有。如果一个图遍历算法以最短时间遍历图的所有节点,则该算法是高效的。图遍历,也称为图搜索算法,与 preorder、inorder、postorder 和层次遍历算法等树遍历算法非常相似;与它们类似,在图搜索算法中,我们从节点开始,通过边遍历图中的所有其他节点。
图遍历的常见策略是沿着一条路径走到尽头,然后回溯直到遇到替代路径的点。我们也可以迭代地从节点移动到另一个节点,以遍历整个图或其部分。图遍历算法在解决许多基本问题中非常重要——它们可以用来确定如何在图中从一个顶点到达另一个顶点,以及图中从 A 节点到 B 节点的路径比其他路径更好。例如,图遍历算法在找出城市网络中从一个城市到另一个城市的最短路径时非常有用。
在下一节中,我们将讨论两个重要的图遍历算法:广度优先搜索(BFS)和深度优先搜索(DFS)。
广度优先遍历
广度优先搜索(BFS)在树数据结构中的层序遍历算法的工作方式非常相似。BFS 算法也是按层工作的;它从访问层 0 的根节点开始,然后访问与根节点直接连接的第一层的所有节点。层 1 的节点与根节点的距离为 1。访问完层 1 的所有节点后,接下来访问层 2 的节点。同样,图中的所有节点都是按层遍历,直到所有节点都被访问。因此,广度优先遍历算法在图中按广度工作。
队列数据结构用于存储图中要访问的顶点的信息。我们从起始节点开始。首先,我们访问该节点,然后查找所有相邻的或相邻的顶点。我们首先逐个访问这些相邻顶点,同时将它们的邻居添加到要访问的顶点的列表中。我们遵循这个过程,直到访问了图中的所有顶点,确保没有顶点被访问两次。
让我们通过图 9.10中的示例来更好地理解图的广度优先遍历的工作原理:

图 9.10:一个示例图
在图 9.10中,我们有一个左边的五个节点图,右边是一个队列数据结构,用于存储待访问的顶点。我们开始访问第一个节点,即A节点,然后我们将所有相邻的顶点B、C和E添加到队列中。在这里,需要注意的是,由于有三个节点B、C和E可以以BCE、CEB、CBE、BEC或ECB的顺序添加到队列中,每种顺序都会给出不同的树遍历结果,因此有多个将相邻节点添加到队列的方法。
所有这些图遍历的可能解决方案都是正确的,但在这个例子中,我们按字母顺序添加节点,只是为了使队列中的事情简单,即BCE。A节点如图图 9.11所示访问:

图 9.11:在广度优先遍历中访问节点 A
一旦我们访问了A顶点,接下来,我们访问它的第一个相邻顶点B,并添加顶点B的相邻顶点,这些顶点尚未添加到队列或尚未访问。在这种情况下,我们必须将D顶点(因为它有两个顶点,A和D节点,其中A已经访问过)添加到队列中,如图图 9.12所示:

图 9.12:在广度优先遍历中访问节点 B
现在,在访问了B顶点之后,我们从队列中访问下一个顶点——C顶点。然后,再次添加那些尚未添加到队列中的相邻顶点。在这种情况下,没有未记录的顶点,如图图 9.13所示:

图 9.13:在广度优先遍历中访问节点 C
在访问C顶点后,我们访问队列中的下一个顶点,即E顶点,如图图 9.14所示:

图 9.14:在广度优先遍历中访问节点 E
类似地,在访问E顶点后,我们在最后一步访问D顶点,如图图 9.15所示:

图 9.15:在广度优先遍历中访问节点 D
因此,遍历前述图的 BFS 算法按A-B-C-E-D的顺序访问顶点。这是前述图 BFS 遍历的一个可能解,但我们可以得到许多可能的解,这取决于我们如何将相邻节点添加到队列中。
要理解 Python 中此算法的实现,我们将使用另一个无向图的示例,如图图 9.16所示:

图 9.16:一个无向样本图
图图 9.16所示图的邻接表如下:
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']
在使用邻接表存储图后,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
要使用广度优先算法遍历此图,我们首先初始化队列和源节点。我们从A节点开始遍历。首先,A节点被入队并添加到已访问节点列表中。之后,我们使用while循环来影响图的遍历。在while循环的第一轮迭代中,节点 A 被出队。
接下来,我们将A节点的所有未访问相邻节点(B、D和G)按字母顺序排序并排队。现在队列包含节点B、D和G。这如图图 9.17所示:

图 9.17:使用 BFS 算法访问节点 A
对于实现,我们将所有这些节点(B、D、G)添加到已访问节点列表中,然后我们添加这些节点的相邻/邻近节点。此时,我们开始while循环的另一个迭代。在访问A节点后,B节点被出队。在其相邻节点(A、E和F)中,A节点已经被访问。因此,我们只按字母顺序将E和F节点入队,如图图 9.18所示。
当我们想要找出一个节点集合是否在已访问节点列表中时,我们使用remaining_elements = set(adj_nodes).difference(set(visited_vertices))语句。这个语句使用set对象的difference方法来找出在adj_nodes中但不在visited_vertices中的节点:


在这一点,队列中包含以下节点——D、G、E 和 F。D 节点被出队,但所有相邻的节点都已访问,所以我们简单地将其出队。队列前面的下一个节点是 G。我们出队 G 节点,但我们还发现所有相邻的节点都已访问,因为它们在已访问节点列表中。因此,G 节点也被出队。我们也出队 E 节点,因为所有相邻的节点也已访问。现在队列中只剩下一个节点,即 F 节点;这如图 9.19 所示:

图 9.19:使用 BFS 算法访问节点 E
F 节点被出队,我们看到在其相邻节点中,B、D 和 C 中只有 C 节点尚未被访问。然后我们将 C 节点入队并添加到已访问节点列表中,如图 9.20 所示:

图 9.20:使用 BFS 算法访问节点 E
然后,C 节点被出队。C 节点有相邻的 F 和 H 节点,但 F 节点已经被访问,留下 H 节点。H 节点被入队并添加到已访问节点列表中。最后,while 循环的最后一次迭代将导致 H 节点被出队。
它唯一的相邻节点,C,已经被访问。一旦队列为空,循环就会中断。这如图 9.21 所示:

图 9.21:使用 BFS 算法访问最终节点 H
使用 BFS 算法遍历给定图的输出是 A、B、D、G、E、F、C 和 H。
当我们使用以下代码在如图 9.16 所示的图上运行上述 BFS 代码时:
print(breadth_first_search(graph, 'A'))
当我们遍历如图 9.16 所示的图时,我们得到以下节点序列:
['A', 'B', 'D', 'G', 'E', 'F', 'C', 'H']
在最坏的情况下,每个节点和边都需要遍历,因此每个节点至少会被入队和出队一次。每次入队和出队操作所需的时间是 O(1),所以总时间是 O(V)。此外,扫描每个顶点的邻接表所需的时间是 O(E)。因此,BFS 算法的总时间复杂度是 O(|V| + |E|),其中 |V| 是顶点或节点的数量,而 |E| 是图中边的数量。
广度优先搜索(BFS)算法在构建具有最少迭代的图的最短路径遍历中非常有用。至于 BFS 的某些实际应用,它可以用来创建一个高效的网页爬虫,其中可以为搜索引擎维护多个索引级别,并且可以从源网页维护一个已关闭网页列表。BFS 还可以用于导航系统,其中可以从不同位置的图中轻松检索相邻位置。
接下来,我们将讨论另一个图遍历算法,即深度优先搜索(DFS)算法。
深度优先搜索
如其名所示,深度优先搜索(DFS)或遍历算法遍历图的方式类似于树中的前序遍历算法工作。在 DFS 算法中,我们在图中任何特定路径的深度遍历树。因此,在访问兄弟节点之前,先访问子节点。
在这里,我们从根节点开始;首先访问它,然后查看当前节点的所有相邻顶点。我们开始访问其中一个相邻节点。如果边通向一个已访问的节点,我们就回溯到当前节点。如果边通向一个未访问的节点,那么我们就转到那个节点并从那个节点继续处理。我们继续同样的过程,直到我们到达一个死胡同,即没有未访问的节点;在这种情况下,我们回溯到前面的节点,并在回溯过程中停止,当我们到达根节点时。
让我们通过图 9.22所示的图来举例说明 DFS 算法的工作原理:

图 9.22:用于理解 DFS 算法的示例图
我们首先访问A节点,然后查看A顶点的邻居,然后是那个邻居的邻居,依此类推。在访问A顶点后,我们访问其一个邻居,B(在我们的例子中,我们按字母顺序排序;然而,任何邻居都可以添加),如图 9.23所示:

图 9.23:在深度优先遍历中访问了节点 A 和 B
访问B顶点后,我们查看A的另一个邻居,即S,因为没有与B相连的顶点可以访问。接下来,我们查找S顶点的邻居,即C和G顶点。我们按照图 9.24所示访问C:

图 9.24:深度优先遍历中访问了节点 C
在访问C节点后,我们访问其相邻顶点D和E,如图 9.25所示:

图 9.25:在深度优先遍历中访问了节点 D 和 E
同样,在访问E顶点后,我们访问H和G顶点,如图 9.26所示:

图 9.26:在深度优先遍历中访问了节点 H 和 F
最后,我们访问F节点,如图 9.27所示:

图 9.27:在深度优先遍历中访问了节点 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 graph_stack:
if node not in visited_vertices:
visited_vertices.append(node)
adj_nodes = graph[node]
if set(adj_nodes).issubset(set(visited_vertices)):
graph_stack.pop()
if len(graph_stack) > 0:
node = graph_stack[-1]
continue
else:
remaining_elements = set(adj_nodes).difference(set(visited_vertices))
first_adj_node = sorted(remaining_elements)[0]
graph_stack.append(first_adj_node)
node = first_adj_node
return visited_vertices
当栈不为空时,将执行while循环的主体。如果正在考虑的node不在已访问节点列表中,我们将它添加进去。通过adj_nodes = graph[node]收集node的所有相邻节点。如果所有相邻节点都已访问,则从栈中弹出顶部节点,并将node设置为graph_stack[-1]。在这里,graph_stack[-1]是栈顶的节点。continue语句将跳回到while循环测试条件的开始。
如果不是所有相邻的节点都已被访问,那么尚未访问的节点将通过使用remaining_elements = set(adj_nodes).difference(set(visited_vertices))语句来找到adj_nodes和visited_vertices之间的差异来获得。
sorted(remaining_elements)中的第一个项目被分配给first_adj_node,并推入栈中。然后我们将栈顶指向此节点。
当while循环退出时,我们将返回visited_vertices。
我们现在将通过与之前的示例相关联来解释源代码的工作原理。我们选择A节点作为起始节点。A被推入栈中并添加到visited_vertices列表中。这样做时,我们将其标记为已访问。graph_stack栈使用简单的 Python 列表实现。我们的栈现在只有A作为其唯一元素。我们检查A节点的相邻节点B和S。为了测试A的所有相邻节点是否都已访问,我们使用if语句:
if set(adj_nodes).issubset(set(visited_vertices)):
graph_stack.pop()
if len(graph_stack) > 0:
node = graph_stack[-1]
continue
如果所有节点都已访问,我们将弹出栈顶。如果graph_stack栈不为空,我们将栈顶的节点分配给node,并开始另一个while循环主体的执行。如果set(adj_nodes).issubset(set(visited_vertices))语句评估为True,则表示adj_nodes中的所有节点都是visited_vertices的子集。如果if语句失败,则意味着还有一些节点尚未访问。我们使用remaining_elements = set(adj_nodes).difference(set(visited_vertices))来获取该节点列表。
参考图示,B和S节点将被存储在remaining_elements中。我们将按字母顺序访问该列表,如下所示:
first_adj_node = sorted(remaining_elements)[0]
graph_stack.append(first_adj_node)
node = first_adj_node
我们对remaining_elements进行排序,并将第一个节点返回给first_adj_node。这将返回B。我们将B节点推入栈中,通过将其附加到graph_stack。我们通过将其分配给node来准备B节点以便访问。
在 while 循环的下一迭代中,我们将 B 节点添加到 已访问节点 列表中。我们发现 B 的唯一相邻节点是 A,它已经被访问过。因为 B 的所有相邻节点都已访问过,所以我们将其从栈中弹出,留下 A 作为栈上的唯一元素。我们回到 A 并检查其所有相邻节点是否都已访问过。现在 A 节点唯一的未访问节点是 S。我们将 S 推入栈中,并再次开始整个过程。
遍历的输出结果是 A-B-S-C-D-E-H-G-F。
当我们使用邻接表表示图时,DFS 的时间复杂度是 O(V+E),当我们使用邻接矩阵表示图时,DFS 的时间复杂度是 O(V²)。使用邻接表进行 DFS 的时间复杂度较低,因为获取相邻节点更容易,而使用邻接矩阵则效率不高。
深度优先搜索(DFS)可以应用于解决迷宫问题、寻找连通分量、图中检测循环以及寻找图的桥等用例。
我们已经讨论了非常重要的图遍历算法;现在让我们讨论一些更有用的与图相关的算法,用于从给定的图中找到生成树。生成树在解决旅行商问题等几个现实世界问题中非常有用。
其他有用的图方法
我们经常需要使用图来在两个节点之间找到路径。有时,我们需要找到节点之间的所有路径,在某些情况下,我们可能需要找到节点之间的最短路径。例如,在路由应用中,我们通常使用各种算法来确定从源节点到目标节点的最短路径。对于无权图,我们只需确定它们之间边数最少的路径。如果给定的是加权图,我们必须计算通过一组边的总权重。
因此,在不同的情境下,我们可能需要使用不同的算法来找到最长或最短路径,例如最小生成树(Minimum Spanning Tree),我们将在下一节中探讨。
最小生成树
最小生成树(MST)是连接图的所有节点的边权图的有向图边的子集,具有最低可能的总边权重且没有环。更正式地说,给定一个连通图 G,其中 G = (V, E)具有实值边权重,一个 MST 是一个子图,它包含边的一个子集
,使得边权重的总和最小且没有环。有许多可能的生成树可以连接图的所有节点而不形成任何环,但最小权重生成树是所有其他可能生成树中具有最低总边权重(也称为成本)的生成树。一个示例图如图图 9.28所示,以及其对应的 MST(在右侧),我们可以观察到所有节点都是连接的,并且从原始图(在左侧)中选取了边的子集。
最小生成树(MST)具有所有边中最低的总权重,即(1+4+2+4+5=16),在所有其他可能的生成树中:

图 9.28:一个带有对应最小生成树的示例图
最小生成树在现实世界中有着多样的应用。它们主要用于网络设计,如道路拥堵、液压电缆、电力电缆网络,甚至聚类分析。
首先,让我们讨论 Kruskal 的最小生成树算法。
Kruskal 的最小生成树算法
Kruskal 算法是寻找给定加权、连通和无向图的生成树的广泛使用算法。它基于贪婪方法,因为我们首先找到权重最低的边并将其添加到树中,然后在每次迭代中,我们添加权重最低的边到生成树中,以避免形成环。在这个算法中,最初,我们将图的所有顶点视为一个单独的树,然后在每次迭代中,我们选择权重最低的边,这样它就不会形成环。这些单独的树被组合起来,并逐渐形成一个生成树。我们重复这个过程,直到所有节点都被处理。算法的工作原理如下:
-
初始化一个空的 MST(M)带有零条边
-
按照边的权重对所有边进行排序
-
对于排序列表中的每条边,我们依次将它们添加到 MST(M)中,这样它就不会形成环
让我们考虑一个例子。
我们首先选择权重最低的边(权重 1),如图图 9.29中所示:

图 9.29:在生成树中选择权重最低的第一条边
在选择权重为 1 的边之后,我们选择权重为 2 的边,然后是权重为 3 的边,因为这些是下一个最低的权重,如图图 9.30所示:

图 9.30:在生成树中选择权重为 2 和 3 的边
同样,我们选择权重为 4 和 5 的下一个边,如图图 9.31所示:

图 9.31:在生成树中选择权重为 4 和 5 的边
接下来,我们选择下一个权重为 6 的边,并将其变为虚线。之后,我们发现最低权重是 7,但如果选择它,则会形成一个环,所以我们忽略它。接下来,我们检查权重为 8 的边,然后是 9,这些也被忽略,因为它们也会形成环。因此,下一个最低权重的边,10,被选中。这如图 9.32 所示:

图 9.32:在生成树中选择权重为 6 和 10 的边
最后,我们使用 Kruskal 算法看到以下生成树,如图 9.33 所示:

图 9.33:使用 Kruskal 算法创建的最终生成树
Kruskal 算法有许多实际应用,例如解决旅行商问题(TSP),在这个问题中,从一座城市出发,我们必须以最低的总成本访问网络中的所有不同城市,并且不能重复访问同一城市。还有许多其他应用,例如电视网络、旅游运营、局域网和电网。
Kruskal 算法的时间复杂度为 O(E log(E)) 或 O(E log(V)),其中 E 是边的数量,V 是顶点的数量。
现在,让我们在下一节讨论另一个流行的 MST 算法。
Prim 最小生成树算法
Prim 算法也是基于贪婪算法来寻找最小生成树。Prim 算法在寻找图中最短路径方面与 Dijkstra 算法非常相似。在这个算法中,我们从任意节点作为起点,然后检查所选节点的出度边,并通过具有最低成本(或权重)的边进行遍历。在这个算法中,成本和权重是可互换的。因此,从所选节点开始,我们通过选择权重最低且不形成环的边来逐个扩展树。算法的工作原理如下:
-
创建一个包含所有边及其权重的字典
-
从字典中逐个获取具有最低成本的边,并按这种方式扩展树,以确保不形成环
-
重复执行步骤 2,直到访问所有顶点
让我们通过一个例子来了解 Prim 算法的工作原理。假设我们任意选择 A 节点,然后检查从 A 出发的所有出度边。在这里,我们有两种选择,AB 和 AC;我们选择边 AC,因为它具有更低的成本/权重(权重 1),如图 9.34 所示:

图 9.34:使用 Prim 算法构建生成树时选择边 AC
接下来,我们从边 AC 检查最低出度边。我们有选项 AB、CD、CE、CF,其中我们选择边 CF,其权重最低,为 2。同样,我们扩展树,接下来我们选择下一个最低权重的边,即 AB,如图 9.35 所示:

图 9.35:在构造生成树时选择边 AB 使用普里姆算法
之后,我们选择边BD,其权重为 3,同样地,接下来我们选择边DG,其权重为最低的 4。这如图图 9.36所示:

图 9.36:在构造生成树时选择边 BD 和 DG 使用 Prim 算法
接下来,我们选择边FE和GH,分别具有 6 和 10 的权重,如图图 9.37所示:

图 9.37:在构造生成树时选择边 FE 和 GH 使用 Prim 算法
接下来,每次我们尝试添加更多边时,都会形成一个环,所以我们忽略这些边。最后,我们得到生成树,如下所示在图 9.38中:

图 9.38:使用普里姆算法得到的最终生成树
普里姆算法也有许多现实世界的应用。对于我们可以使用克鲁斯卡尔算法的所有应用,我们也可以使用普里姆算法。其他应用包括道路网络、游戏开发等。
由于克鲁斯卡尔和普里姆的最小生成树算法都用于相同的目的,我们应该使用哪一个?一般来说,这取决于图的结构。对于一个有C个顶点和E条边的图,克鲁斯卡尔算法的最坏情况时间复杂度是 O(E logV),而普里姆算法的时间复杂度是 O(E + V logV)。因此,我们可以观察到,当图是稠密图时,普里姆算法表现更好,而当图是稀疏图时,克鲁斯卡尔算法表现更好。
概述
图是一种非线性数据结构,由于它在现实世界中有大量的应用,因此非常重要。在本章中,我们讨论了在 Python 中使用列表和字典表示图的不同方法。此外,我们还学习了两个非常重要的图遍历算法,即深度优先搜索(DFS)和广度优先搜索(BFS)。此外,我们还讨论了寻找最小生成树(MST)的两个非常重要的算法,即克鲁斯卡尔算法和普里姆算法。
在下一章中,我们将讨论搜索算法以及我们如何有效地在列表中搜索项的各种方法。
练习
-
在一个有五个节点的无向简单图中,可能的最大边数(不包括自环)是多少?
-
我们称所有节点度数都相等的图为什么?
-
解释什么是割点,并识别给定图中的割点:

图 9.39:一个示例图
- 假设一个阶数为 n 的图 G,图 G 中可能的最大割点数是多少?
加入我们的 Discord 社区
加入我们的社区 Discord 空间,与作者和其他读者进行讨论:packt.link/MEvK4

第十章:搜索
对于所有数据结构来说,搜索集合中的元素是一个重要操作。在数据结构中搜索元素有多种方法;在本章中,我们将探讨可以用于在项目集合中查找元素的不同策略。
数据元素可以存储在任何类型的数据结构中,例如数组、链表、树或图;搜索操作对于许多应用非常重要,尤其是在我们想要知道特定数据元素是否存在于现有数据项列表中时。为了有效地检索信息,我们需要一个高效的搜索算法。
在本章中,我们将学习以下内容:
-
各种搜索算法
-
线性搜索算法
-
跳跃搜索算法
-
二分搜索算法
-
插值搜索算法
-
指数搜索算法
让我们先从搜索的介绍和定义开始,然后看看线性搜索算法。
搜索简介
搜索操作是为了从数据项集合中找到所需数据项的位置。搜索算法返回搜索值在项目列表中的位置,如果数据项不存在,则返回 None。
高效搜索对于从存储的数据项列表中高效检索所需数据项的位置非常重要。例如,我们有一个长列表的数据值,例如 {1, 45, 65, 23, 65, 75, 23},我们想知道 75 是否在列表中。当数据项列表变得很大时,拥有一个高效的搜索算法变得很重要。
数据可以以两种不同的方式组织,这可能会影响搜索算法的工作方式:
-
首先,搜索算法应用于已排序的项目列表;也就是说,它应用于有序的项目集合。例如,
[1, 3, 5, 7, 9, 11, 13, 15, 17]。 -
搜索算法应用于未排序的项目集合,即未排序的集合。例如,
[11, 3, 45, 76, 99, 11, 13, 35, 37]。
我们首先将看看线性搜索。
线性搜索
搜索操作用于找出给定数据项在数据项列表中的索引位置。如果搜索的项在给定的数据项列表中可用,则搜索算法返回其所在位置的索引位置;否则,它返回该项未找到。在这里,索引位置是所需项目在给定列表中的位置。
在列表中搜索一个项目的最简单方法是进行线性搜索,其中我们逐个在整个列表中查找项目。让我们以六个列表项 {60, 1, 88, 10, 11, 100} 为例,来理解线性搜索算法,如图 图 10.1 所示:

图 10.1:线性搜索的示例
前面的列表具有可以通过索引访问的元素。要找到列表中的元素,我们可以逐个线性地搜索给定的元素。这种技术通过使用索引从列表的开始移动到末尾来遍历元素列表。每个元素都会进行检查,如果它不匹配搜索项,则检查下一个项。通过从一个项目跳到下一个项目,列表被顺序遍历。在本章中,我们使用具有整数值的列表项来帮助您理解这个概念,因为整数可以很容易地进行比较;然而,列表项也可以持有任何其他数据类型。
线性搜索方法依赖于列表项在内存中的存储方式——它们是否已经按顺序排序,或者它们是否未排序。让我们首先看看如果给定的项目列表未排序,线性搜索算法是如何工作的。
无序线性搜索
无序线性搜索是一种线性搜索算法,其中给定的日期项目列表未排序。我们逐个将所需数据项与列表中的数据项进行线性匹配,直到列表末尾或找到所需数据项。考虑一个包含元素 60、1、88、10 和 100 的示例列表——一个无序列表。要对这样的列表执行 搜索 操作,从第一个项目开始,将其与搜索项进行比较。如果搜索项不匹配,则检查列表中的下一个元素。这个过程一直持续到我们到达列表的最后一个元素或找到匹配项。
在无序列表中,对术语 10 的搜索从第一个元素开始,并移动到列表中的下一个元素。因此,首先将 60 与 10 进行比较,由于它们不相等,我们将 66 与下一个元素 1 进行比较,然后是 88,依此类推,直到我们在列表中找到搜索项。一旦找到项目,我们就返回找到所需项的索引位置。这个过程在 图 10.2 中显示:

图 10.2:无序线性搜索
下面是使用 Python 对无序列表中的项目进行线性搜索的实现:
def search(unordered_list, term):
for i, item in enumerate(unordered_list):
if term == unordered_list[i]:
return i
return None
search 函数接受两个参数;第一个是包含数据的列表,第二个参数是我们正在寻找的项目,称为 搜索项。在 for 循环的每次迭代中,我们检查搜索项是否等于索引项。如果是这样,那么就找到了匹配项,无需进一步进行搜索。我们返回在列表中找到搜索项的索引位置。如果循环运行到列表末尾而没有找到匹配项,则返回 None 以表示列表中没有这样的项。
我们可以使用以下代码片段来检查所需的数据元素是否存在于给定的数据项列表中:
list1 = [60, 1, 88, 10, 11, 600]
search_term = 10
index_position = search(list1, search_term)
print(index_position)
list2 = ['packt', 'publish', 'data']
search_term2 = 'data'
Index_position2 = search(list2, search_term2)
print(Index_position2)
上述代码的输出如下:
3
2
在上述代码的输出中,首先,当我们在list1中搜索数据元素10时,返回索引位置3。其次,当在list2中搜索数据项'data'时,返回索引位置2。由于 Python 中的字符串元素可以像数字数据一样进行比较,因此我们可以使用相同的算法在 Python 中从非数字数据项的列表中搜索非数字数据项。
在从无序列表中搜索任何元素时,在最坏的情况下,所需元素可能位于列表的最后一个位置,或者根本不在列表中。在这种情况下,我们必须将搜索项与列表中的所有元素进行比较,即如果列表中的数据项总数为n,则需要比较n次。因此,无序线性搜索的最坏情况运行时间为O(n)。在找到搜索项之前可能需要访问所有元素。最坏的情况是搜索项位于列表的最后一个位置。
接下来,我们讨论如果给定的数据项列表已经排序,线性搜索算法是如何工作的。
有序线性搜索
如果数据元素已经按顺序排列,则可以改进线性搜索算法。在已排序的元素列表中的线性搜索算法有以下步骤:
-
顺序遍历列表
-
如果搜索项的值大于循环中当前检查的对象或项,则退出并返回
None
在遍历列表的过程中,如果搜索项的值小于列表中的当前项,则无需继续搜索。让我们通过一个例子来看看这是如何工作的。假设我们有一个如图 10.3所示的元素列表{2, 3, 4, 6, 7},我们想要搜索项5:

图 10.3:有序线性搜索的示例
我们通过将期望的搜索元素5与第一个元素进行比较来开始search操作;没有找到匹配项。我们继续将搜索元素与列表中的下一个元素,即3进行比较。由于它也不匹配,我们继续检查下一个元素,即4,由于它也不匹配,我们继续在列表中搜索,并将搜索元素与第四个元素,即6进行比较。这也不匹配搜索项。由于给定的列表已经按升序排序,且搜索项的值小于第四个元素,因此搜索项不可能在列表的任何后续位置找到。换句话说,如果列表中的当前项大于搜索项,那么这意味着无需进一步搜索列表,我们停止在列表中搜索该元素。
这里是当列表已经排序时线性搜索的实现:
def search_ordered(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,因为循环可能遍历整个列表,但搜索项仍然没有在列表中匹配。
我们使用以下代码片段来使用搜索算法:
list1 = [2, 3, 4, 6, 7]
search_term = 5
index_position1 = search_ordered(list1, search_term)
if index_position1 is None:
print("{} not found".format(search_term))
else:
print("{} found at position {}".format(search_term, index_position1))
list2 = ['book','data','packt', 'structure']
search_term2 = 'structure'
index_position2 = search_ordered(list2, search_term2)
if index_position2 is None:
print("{} not found".format(search_term2))
else:
print("{} found at position {}".format(search_term2, index_position2))
上述代码的输出如下:
5 not found
structure found at position 3
在上述代码的输出中,首先,搜索项5在给定的列表中没有匹配。对于第二组非数值数据元素,字符串结构在索引位置3处匹配。因此,我们可以使用相同的线性搜索算法从有序数据项列表中搜索非数值数据项,所以给定的数据项列表应该与电话簿上的联系人列表类似地排序。
在最坏的情况下,所需的搜索项将位于列表的最后一个位置或根本不存在。在这种情况下,我们必须遍历整个列表(例如n个元素)。因此,有序线性搜索的最坏情况时间复杂度为O(n)。
接下来,我们将讨论跳跃搜索算法。
跳跃搜索
跳跃搜索算法是线性搜索在有序(或排序)列表中搜索给定元素的一种改进。它使用分而治之的策略来搜索所需元素。在线性搜索中,我们比较搜索值与列表中的每个元素,而在跳跃搜索中,我们在列表的不同间隔处比较搜索值,这减少了比较的次数。
在这个算法中,首先,我们将排序后的数据列表分成数据元素子集,称为块。由于数组已排序,每个块中的最高值将位于最后一个元素。接下来,在这个算法中,我们开始将搜索值与每个块的最后一个元素进行比较。可能有三种情况:
-
如果搜索值小于块的最后一个元素,我们将其与下一个块进行比较。
-
如果搜索值大于块的最后一个元素,这意味着所需的搜索值必须存在于当前块中。因此,我们在该块中应用线性搜索并返回索引位置。
-
如果搜索值与块的比较元素相同,我们返回元素的索引位置并返回候选者。
通常,块的大小取为
,因为它为给定长度为n的数组提供了最佳性能。
在最坏的情况下,如果最后一个块的最后一个元素大于要搜索的元素,我们将不得不进行 n/m 次跳跃(这里,n 是元素总数,m 是块大小),并且我们需要对最后一个块进行 m - 1 次线性搜索。因此,总的比较次数将是 ((n/m) + m - 1),当 m = √n 时将最小化。所以块的大小取为 √n,因为它提供了最佳性能。
以下是一个示例列表 {1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11},用于搜索给定的元素(例如 10):

图 10.4:跳跃搜索算法的说明
在上述示例中,我们在 5 次比较中找到了所需的元素 10。首先,我们将数组的第一个值与所需的项 A[0] <= item 进行比较;如果是真的,则增加索引以块大小(这在 Figure 10.4 中的 步骤 1 中显示)。接下来,我们将所需的项与每个块的最后一个元素进行比较。如果它更大,则我们移动到下一个块,例如从块 1 到块 3(这在 Figure 10.4 中的 步骤 2、3 和 4 中显示)。
此外,当所需的搜索元素小于一个块的最后元素时,我们停止增加索引位置,然后在当前块中进行线性搜索。现在,让我们讨论跳跃搜索算法的实现。首先,我们实现线性搜索算法,这与我们在上一节中讨论的内容类似。
这里再次给出,以完整起见如下:
def search_ordered(ordered_list, term):
print("Entering Linear Search")
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 -1
return -1
在上述代码中,给定一个有序元素列表,它返回给定数据元素在列表中找到的位置的索引。如果所需的元素在列表中未找到,则返回 -1。接下来,我们按照以下方式实现 jump_search() 方法:
def jump_search(ordered_list, item):
import math
print("Entering Jump Search")
list_size = len(ordered_list)
block_size = int(math.sqrt(list_size))
i = 0
while i != len(ordered_list)-1 and ordered_list[i] <= item:
print("Block under consideration - {}".format(ordered_list[i: i+block_size]))
if i+ block_size > len(ordered_list):
block_size = len(ordered_list) - i
block_list = ordered_list[i: i+block_size]
j = search_ordered(block_list, item)
if j == -1:
print("Element not found")
return
return i + j
if ordered_list[i + block_size -1] == item:
return i+block_size-1
elif ordered_list[i + block_size - 1] > item:
block_array = ordered_list[i: i + block_size - 1]
j = search_ordered(block_array, item)
if j == -1:
print("Element not found")
return
return i + j
i += block_size
在上述代码中,首先我们将列表的长度赋值给变量 n,然后计算块大小为
。接下来,我们从第一个元素,索引 0 开始,然后继续搜索直到我们到达列表的末尾。
我们从起始索引 i = 0 和大小为 m 的块开始,然后继续增加直到窗口到达列表的末尾。我们比较 ordered_list [I + block_size -1] == item。如果它们匹配,则返回索引位置 (i+ block_size -1)。此代码片段如下:
if ordered_list[i+ block_size -1] == item:
return i+ block_size -1
如果 ordered_list [i+ block_size -1] > item,我们将在当前块 block_array = ordered_list [i : i+ block_size-1] 内执行线性搜索算法,如下所示:
elif ordered_list[i+ block_size -1] > item:
block_array = ordered_list[i: i+ block_size -1]
j = search_ordered(block_array, item)
if j == -1:
print("Element not found")
return
return i + j
在上面的代码中,我们在子数组中使用线性搜索算法。如果列表中找不到所需元素,则返回-1;否则,返回(i + j)的索引位置。在这里,i是可能找到所需元素的最后一个块的索引位置,j是块内匹配所需元素的数据元素的索引位置。这个过程也在图 10.5中展示。
在这个图中,我们可以看到i位于索引位置 5,然后j是我们找到所需元素(即2)的最后一个块中的元素数量,所以最终返回的索引将是5 + 2 = 7:

图 10.5:搜索值 8 的索引位置 i 和 j 的演示
此外,我们需要检查最后一个块的长度,因为它可能包含的元素数量少于块大小。例如,如果元素总数是 11,那么在最后一个块中我们将有 2 个元素。因此,我们检查所需搜索元素是否在最后一个块中,如果是的话,我们应该更新起始和结束索引如下:
if i+ block_size > len(ordered_list):
block_size = len(ordered_list) - i
block_list = ordered_list[i: i+block_size]
j = search_ordered(block_list, item)
if j == -1:
print("Element not found")
return
return i + j
在上面的代码中,我们使用线性搜索算法搜索所需元素。
最后,如果ordered_list[i+m-1] < item,则我们进入下一次迭代,并通过将块大小添加到索引来更新索引i += block_size。
print(jump_search([1,2,3,4,5,6,7,8,9, 10, 11], 8))
上面的代码片段的输出是:
Entering Jump Search
Block under consideration - [1, 2, 3]
Block under consideration - [4, 5, 6]
Block under consideration - [7, 8, 9]
Entering Linear Search
7
在上面的输出中,我们可以看到我们如何在给定的元素列表中搜索元素10的步骤。
因此,跳跃搜索在块上执行线性搜索,所以它首先找到包含元素的块,然后在那个块内应用线性搜索。块的大小取决于数组的大小。如果数组的大小是n,那么块的大小可能是
。如果在该块中找不到元素,它将移动到下一个块。跳跃搜索首先找出所需元素可能存在的块。对于一个包含n个元素的列表,以及一个块大小为m,可能的跳跃总数将是n/m次跳跃。假设块的大小是
;因此,最坏情况下的时间复杂度将是
。
接下来,我们将讨论二分查找算法。
二分查找
二分查找算法从给定的排序列表中查找给定项。这是一个快速且高效的搜索元素算法;然而,这个算法的一个缺点是我们需要一个排序的列表。二分查找算法的最坏情况运行时间复杂度是O(logn),而线性搜索是O(n)。
二分查找算法的工作原理如下。它通过将给定的列表分成一半来开始搜索项目。如果搜索项目小于中间值,它将只在列表的前半部分查找搜索项目,如果搜索项目大于中间值,它将只查看列表的后半部分。我们重复这个过程,直到找到搜索项目,或者我们检查了整个列表。在非数值数据项的列表的情况下,例如,如果我们有字符串数据项,那么我们应该按字母顺序对数据项进行排序(类似于手机上存储的联系人列表)。
让我们通过一个例子来理解二分查找算法。假设我们有一本有 1000 页的书,我们想要到达第 250 页。我们知道每本书的页面都是按顺序从1向上编号的。因此,根据二分查找的类比,我们首先检查搜索项目 250,它小于中间点 500。因此,我们只在书的前半部分搜索所需的页面。
我们再次找到这本书前半部分的中间点,以第 500 页为参考,我们找到中间点,即 250 页。这使我们更接近找到第 250 页。然后我们在书中找到所需的页面。
让我们再举一个例子来理解二分查找的工作原理。我们想要在一个包含 12 个项目的列表中搜索项目43,如图图 10.6所示:

图 10.6:二分查找的工作原理
我们通过将项目与列表的中间项目进行比较来开始搜索,在例子中是37。如果搜索项目的值小于中间值,我们只查看列表的前半部分;否则,我们将查看另一半。因此,我们只需要在列表的后半部分搜索该项目。我们遵循相同的程序,直到在列表中找到搜索项目43。这个过程如图图 10.6所示。
以下是在有序项目列表上实现二分查找算法的示例:
def binary_search_iterative(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, 30, 100, 120, 500}来解释上述代码。现在假设我们必须在图 10.7中显示的列表中找到项目10的位置:

图 10.7:五个项目的示例列表
首先,我们声明两个变量,即index_of_first_element和index_of_last_element,它们表示给定列表中的起始和结束索引位置。然后,算法使用while循环来迭代调整列表中的限制,在这些限制内我们要找到搜索项目。停止while循环的终止条件是起始索引index_of_first_element和index_of_last_element索引之间的差值应该是正数。
算法首先通过将第一个元素的索引(即本例中的0)与最后一个元素的索引(在这个例子中是4)相加,然后除以2来找到列表的中间点。我们得到中间索引,mid_point:
mid_point = (index_of_first_element + index_of_last_element)/2
在这种情况下,中间索引是2,存储在此位置的的数据项是100。我们比较中间元素与搜索项10。
由于这些不匹配,并且搜索项10小于中间点,所以所需的搜索项应该位于列表的前半部分,因此,我们将index_of_first_element的索引范围调整为mid_point-1,这意味着新的搜索范围变为0到1,如图10.8所示:

图 10.8:列表前半部分第一个和最后一个元素索引
然而,如果我们正在搜索120,因为120将大于中间值(100),我们将搜索列表的第二部分,因此,我们需要将列表索引范围更改为mid_point +1到index_of_last_element。在这种情况下,新的范围将是(3, 4)。
因此,使用新的第一个和最后一个元素的索引,即index_of_first_element和index_of_last_element,现在分别是0和1,我们计算中间点(0 + 1)/2,等于0。新的中间点是0,因此我们找到中间项并将其与搜索项比较,得到值10。现在,我们的搜索项已找到,并返回索引位置。
最后,我们检查index_of_first_element是否小于index_of_last_element。如果这个条件不成立,这意味着搜索词不在列表中。
我们可以使用下面的代码片段来搜索给定列表中的术语/项:
list1 = [10, 30, 100, 120, 500]
search_term = 10
index_position1 = binary_search_iterative(list1, search_term)
if index_position1 is None:
print("The data item {} is not found".format(search_term))
else:
print("The data item {} is found at position {}".format(search_term, index_position1))
list2 = ['book','data','packt', 'structure']
search_term2 = 'structure'
index_position2 = binary_search_iterative(list2, search_term2)
if index_position2 is None:
print("The data item {} is not found".format(search_term2))
else:
print("The data item {} is found at position {}".format(search_term2, index_position2))
上述代码的输出如下:
The data item 10 is found at position 0
The data item structure is found at position 3
在上述代码中,首先我们在列表中检查搜索词10,并得到正确的位置,即索引位置0。进一步,我们检查给定排序数据项列表中字符串结构的索引位置,并得到索引位置3。
我们所讨论的实现是基于迭代过程的。然而,我们也可以使用递归方法来实现它,在这种方法中,我们递归地移动指向搜索列表开始(或起始)和结束的指针。以下是一个递归实现二分搜索算法的示例代码:
def binary_search_recursive(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_recursive (ordered_list, first_element_index, mid_point-1, term)
elif ordered_list[mid_point] < term:
return binary_search_recursive (ordered_list, mid_point+1, last_element_index, term)
else:
return mid_point
对二分搜索算法的这种递归实现的调用及其输出如下:
list1 = [10, 30, 100, 120, 500]
search_term = 10
index_position1 = binary_search_recursive(list1, 0, len(list1)-1, search_term)
if index_position1 is None:
print("The data item {} is not found".format(search_term))
else:
print("The data item {} is found at position {}".format(search_term, index_position1))
list2 = ['book','data','packt', 'structure']
search_term2 = 'data'
index_position2 = binary_search_recursive(list2, 0, len(list1)-1, search_term2)
if index_position2 is None:
print("The data item {} is not found".format(search_term2))
else:
print("The data item {} is found at position {}".format(search_term2, index_position2))
上述代码的输出如下:
The data item 10 is found at position 0
The data item data is found at position 1
在这里,递归二分搜索和迭代二分搜索之间的唯一区别是函数定义以及mid_point的计算方式。在((last_element_index - first_element_index)//2)操作之后对mid_point的计算必须将结果加到first_element_index上。这样,我们定义了尝试搜索的列表部分。
在二分搜索中,我们反复将搜索空间(即可能包含所需项目的列表)分成两半。我们从完整的列表开始,并在每次迭代中计算中间点;我们只考虑列表的一半来搜索项目,而另一半列表被忽略。我们反复检查,直到找到值或区间为空。因此,在每次迭代中,数组的大小减半;例如,在迭代 1 中,列表的大小是n,在迭代 2 中,列表的大小变为 n/2,在迭代 3 中,列表的大小变为 n/2²,经过k次迭代后,列表的大小变为 n/2^k。那时列表的大小将等于1。这意味着:
=> n/2^k = 1
对等式两边应用log函数:
=> log2 = log2
=> log2 = k log2
=> k = log2
因此,二分搜索算法的最坏情况时间复杂度为O(log n)。
接下来,我们将讨论插值搜索算法。
插值搜索
二分搜索算法是一种高效的搜索算法。它总是通过根据搜索项的值丢弃搜索空间的一半来减半搜索空间。如果搜索项小于列表中间的值,则从搜索空间中丢弃列表的后半部分。在二分搜索的情况下,我们总是通过一个固定值的一半来减少搜索空间,而插值搜索算法是二分搜索算法的改进版本,其中我们使用一种更有效的方法,在每次迭代后使搜索空间减少超过一半。
当排序列表中有均匀分布的元素时,插值搜索算法工作得非常有效。在二分搜索中,我们总是从列表的中间开始搜索,而在插值搜索中,我们根据要搜索的项目计算起始搜索位置。在插值搜索算法中,起始搜索位置最有可能接近列表的开始或结束;如果搜索项接近列表的第一个元素,那么起始搜索位置可能接近列表的开始,如果搜索项接近列表的末尾,那么起始搜索位置可能接近列表的末尾。
这与人类在任意项目列表中进行搜索的方式非常相似。它基于尝试对搜索项目可能在排序项目列表中找到的索引位置进行良好的猜测。
它的工作方式与二分搜索算法类似,只是在确定分割标准以划分数据以减少比较次数的方法上有所不同。在二分搜索的情况下,我们将数据分成相等的两半,而在插值搜索的情况下,我们使用以下公式来划分数据:

在前面的公式中,low_index 是列表的下界索引,即最小值的索引,而 upper_index 表示列表中最大值的索引位置。list[low_index] 和 list[upper_index] 分别是列表中的最小值和最大值。search_value 变量包含要搜索的项的值。
以下是一个示例,用于理解如何使用以下七个元素的列表来使用插值搜索算法:

图 10.9:插值搜索示例
给定七个元素的列表 44、60、75、100、120、230 和 250,可以使用上述公式计算 mid 点,如下所示:
list1 = [4,60,75,100,120,230,250]
low_index = 0
upper_index = 6
list1[upper_index] = 250
list1[low_index] = 44
search_value = 230
将所有变量的值代入公式中,我们得到:
mid = low_index + ((upper_index - low_index)/ (list1[upper_index] - list1[low_index])) * (search_value - list1[low_index])
=> 0 + [(6-0)/(250-44)] * (230-44)
=> 5.41
=> 5
在插值搜索的情况下,mid 索引为 5,因此算法从索引位置 5 开始搜索。因此,这就是我们从哪个中点开始搜索给定元素的计算方式。
插值搜索算法的工作原理如下:
-
我们从中点开始搜索给定的搜索值(我们刚刚看到了如何计算它)。
-
如果搜索值与中点索引处的值匹配,我们返回此索引位置。
-
如果搜索值与中点存储的值不匹配,我们将列表分成两个子列表,即一个较高的子列表和一个较低的子列表。较高的子列表包含所有索引值高于中点的元素,而较低的子列表包含所有索引值低于中点的元素。
-
如果搜索值大于中点的值,我们将在较高的子列表中搜索给定的搜索值,并忽略较低的子列表。
-
如果搜索值低于中点的值,我们将在较低的子列表中搜索给定的搜索值,并忽略较高的子列表。
-
我们重复此过程,直到子列表的大小减少到零。
让我们通过以下七个元素的列表示例来理解插值搜索算法的实现。首先,我们定义 nearest_mid() 方法,它按照以下方式计算中点:
def nearest_mid(input_list, low_index, upper_index, search_value):
mid = low_index + (( upper_index - low_index)/(input_list[upper_index] - input_list[low_index])) * (search_value - input_list[low_index])
return int(mid)
nearest_mid 函数接受要执行搜索的列表作为参数。low_index 和 upper_index 参数表示列表中我们希望找到搜索项的界限。此外,search_value 表示要搜索的值。
在插值搜索中,中点通常更偏向左边或右边。这是由于在除以获取中点时使用的乘数效应造成的。插值算法的实现与二分搜索相同,只是在计算中点的方式上有所不同。
在以下代码中,我们提供了插值搜索算法的实现:
def interpolation_search(ordered_list, search_value):
low_index = 0
upper_index = len(ordered_list) - 1
while low_index <= upper_index:
mid_point = nearest_mid(ordered_list, low_index, upper_index, search_value)
if mid_point > upper_index or mid_point < low_index:
return None
if ordered_list[mid_point] == search_value:
return mid_point
if search_value > ordered_list[mid_point]:
low_index = mid_point + 1
else:
upper_index = mid_point – 1
if low_index > upper_index:
return None
在上面的代码中,我们初始化了给定排序列表的 low_index 和 upper_index 变量。我们首先使用 nearest_mid() 方法计算中点。
使用nearest_mid函数计算出的中间点值可能大于upper_bound_index或小于lower_bound_index。当这种情况发生时,意味着搜索项term不在列表中。因此,返回None来表示这一点。
接下来,我们将搜索值与存储在中间点的值进行匹配,即ordered_list[mid_point]。如果匹配,则返回中间点的索引;如果不匹配,则将列表分为更高和更低的子列表,并重新调整low_index和upper_index,以便算法将重点放在可能包含类似搜索项的子列表上,就像我们在二分搜索中所做的那样:
if search_value > ordered_list[mid_point]:
low_index = mid_point + 1
else:
upper_index = mid_point - 1
在上述代码中,我们检查搜索值是否大于存储在ordered_list[mid_point]中的值,然后我们只调整low_index变量,使其指向mid_point + 1的索引。
让我们看看这种调整是如何发生的。假设我们想在给定的列表中搜索190,根据上述公式,中间点将是4。然后我们将搜索值(即190)与存储在中间点的值(即120)进行比较。由于搜索值较大,我们在较高子列表中搜索元素,并调整low_index值。这如图图 10.10所示:

图 10.10:当搜索项的值大于中间点值时,重新调整low_index
另一方面,如果搜索项的值小于存储在ordered_list[mid_point]中的值,那么我们只需调整upper_index变量,使其指向mid_point - 1的索引。例如,如果我们有如图图 10.11所示的列表,并且我们想搜索185,那么根据公式,中间点将是4。
接下来,我们将搜索值(即185)与存储在中间点的值(即190)进行比较。由于搜索值小于ordered_list[mid_point],我们在较低子列表中搜索元素,并调整upper_index值。这如图图 10.11所示:

图 10.11:当搜索项小于中间点值时,重新调整upper_index
{44, 60, 75, 100, 120, 230, 250}, in which we want to search for 120 using the interpolation search algorithm.
list1 = [44, 60, 75, 100, 120, 230, 250]
a = interpolation_search(list1, 120)
print("Index position of value 2 is ", a)
上述代码的输出如下:
Index position of value 2 is 4
让我们用一个更实际的例子来理解二分搜索和插值算法的内部工作原理。
考虑以下元素列表的例子:
[ 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_point值0时,我们开始使用索引0处的值进行插值搜索。仅通过一次比较,我们就找到了搜索项。
另一方面,二分搜索算法需要三次比较才能到达搜索项,如图 10.12 所示:

图 10.12:使用二分搜索算法搜索项目需要三次比较
首先计算出的 mid_point 值是 3。第二个 mid_point 值是 1,而搜索项被找到的最后一个 mid_point 值是 0。因此,我们通过三次比较就找到了所需的搜索项,而在插值搜索中,我们可以在第一次尝试中找到所需的项。
当数据集是有序且均匀分布时,插值搜索算法效果良好。在这种情况下,平均情况的时间复杂度是 O(log(log n)),其中 n 是数组的长度。此外,如果数据集是随机化的,那么插值搜索算法的最坏情况时间复杂度将是 O(n)。因此,如果给定数据是均匀分布的,插值搜索可能比二分搜索更有效。
指数搜索
指数搜索是一种主要用于列表中有大量元素时的搜索算法。指数搜索也被称为跳跃搜索和倍增搜索。指数搜索算法的工作步骤如下:
-
给定一个包含
n个数据元素的有序数组,我们首先确定原始列表中可能包含所需搜索项的子范围 -
接下来,我们使用二分搜索算法在步骤 1 中确定的数据元素子范围内找到搜索值
首先,为了找到数据元素的子范围,我们通过每次迭代跳过 2^i 个元素来在给定的有序数组中搜索所需的项。在这里,i 是数组索引的值。每次跳跃后,我们检查搜索项是否在最后一次跳跃和当前跳跃之间。如果搜索项存在,则在此子数组中使用二分搜索算法,如果不存在,则将索引移动到下一个位置。因此,我们首先找到第一个指数 i 的出现,使得索引 2^i 处的值大于搜索值。然后,2^i 成为这个数据元素子范围的下界,2^i-1 成为这个子范围的上界,其中搜索值将存在。指数搜索算法定义如下:
-
首先,我们将搜索元素与第一个元素
A[0]进行比较。 -
初始化索引位置
i=1。 -
我们检查两个条件:(1)是否是数组的末尾(即 2^i
<len(A)),以及(2)A[i]<=search_value)。在第一个条件下,我们检查是否已搜索整个列表,如果已到达列表末尾,则停止搜索。在第二个条件下,当我们遇到一个值大于搜索值的元素时停止搜索,因为这表示所需的元素将存在于这个索引位置之前(因为列表是有序的)。 -
如果上述两个条件中的任何一个为真,我们就通过以 2 的幂次递增
i来移动到下一个索引位置。 -
我们在满足 步骤 3 的两个条件之一时停止。
-
我们在范围 2^i//2 到 min (2^i, len(A)) 上应用二分搜索算法。
让我们以一个排序数组 A = {3, 5, 8, 10, 15, 26, 35, 45, 56, 80, 120, 125, 138} 为例,其中我们想要搜索元素 125。
我们从比较索引 i = 0 的第一个元素,即 A[0] 与搜索元素开始。由于 A[0] < search_value,我们跳到下一个位置 2^i,i = 0,因为 A[2⁰] < search_value,条件为真,所以我们跳到下一个位置,i = 1,即 A[22¹] < search_value。我们再次跳到下一个位置 2^i,i = 2,因为 A[2²] < search_value,条件为真。我们迭代地跳到下一个位置,直到我们完成列表搜索或搜索值大于该位置的价值,即 A[2^i] < len(A) 或 A[2^i] <= search_value。然后我们在子数组的范围内应用二分搜索算法。使用指数搜索算法在排序数组中搜索给定元素的完整过程如图 图 10.13 所示:

图 10.13:指数搜索算法的说明
现在,让我们讨论指数搜索算法的实现。首先,我们实现二分搜索算法,我们已经在上一节中讨论过了,但为了完整起见,我们再次给出如下:
def binary_search_recursive(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_recursive (ordered_list, first_element_index, mid_point-1, term)
elif ordered_list[mid_point] < term:
return binary_search_recursive (ordered_list, mid_point+1, last_element_index, term)
else:
return mid_point
在上述代码中,给定有序元素列表,它返回给定数据元素在列表中的位置索引。如果所需的元素不在列表中,则返回 None。接下来,我们按照以下方式实现 exponential_search() 方法:
def exponential_search(A, search_value):
if (A[0] == search_value):
return 0
index = 1
while index < len(A) and A[index] < search_value:
index *= 2
return binary_search_recursive(A, index // 2, min(index, len(A) - 1), search_value)
在上述代码中,首先,我们将第一个元素 A[0] 与搜索值进行比较。如果匹配,则返回索引位置 0。如果不匹配,我们将索引位置增加到 2⁰,即 1。我们检查 A[1] < search_value。由于条件为真,我们跳到下一个位置 2¹,即我们比较 A[2] < search_value。由于条件为真,我们移动到下一个位置。
我们迭代地以 2 的幂次增加索引位置,直到满足停止条件:
while index < len(A) and A[index] < search_value:
index *= 2
最后,当满足停止条件时,我们使用二分搜索算法在子范围内搜索所需的搜索值,如下所示:
return binary_search_recursive(A, index // 2, min(index, len(A) - 1), search_value)
最后,exponential_search() 方法如果搜索值在给定的数组中找到,则返回索引位置;否则,返回 None。
print(exponential_search([1,2,3,4,5,6,7,8,9, 10, 11, 12, 34, 40], 34))
上述代码片段的输出为:
12
在上述输出中,我们得到搜索项 34 在给定数组中的索引位置 12。
指数搜索对于非常大的数组非常有用。这比二分搜索更好,因为我们可以找到一个可能包含元素的子数组,而不是在整个数组上执行二分搜索,从而减少了比较的次数。
指数搜索的最坏情况时间复杂度为 O(log[2]i),其中 i 是要搜索的元素所在的索引。当所需的搜索元素位于数组开头时,指数搜索算法可以优于二分搜索。因为指数搜索需要 O(log(i)) 的时间,而二分搜索需要 O(logn) 的时间,其中 n 是元素总数。指数搜索的最佳情况复杂度为 O(1),当元素位于数组的第一个位置时。
我们还可以使用指数搜索在有界数组中进行搜索。当目标接近数组开头时,它甚至可以优于二分搜索,因为指数搜索需要 O(log(i)) 的时间,而二分搜索需要 O(logn) 的时间,其中 n 是元素总数。指数搜索的最佳情况复杂度为 O(1),当元素位于数组的第一个位置时。
接下来,让我们讨论如何决定在给定情况下选择哪种搜索算法。
选择搜索算法
现在我们已经涵盖了不同类型的搜索算法,我们可以探讨哪些算法在什么情况下表现更好。与有序和无序的线性搜索函数相比,二分搜索和插值搜索算法在性能上更优越。线性搜索算法较慢,因为它需要在列表中顺序探测元素以找到搜索项。
线性搜索的时间复杂度为 O(n)。当给定的数据元素列表很大时,线性搜索算法表现不佳。
相反,二分搜索操作在任何搜索尝试时都会将列表分成两部分。在每次迭代中,我们比线性策略更快地接近搜索项。时间复杂度为 O(logn)。二分搜索算法表现良好,但其缺点是它需要一个排序的元素列表。因此,如果给定的数据元素较短且未排序,则最好使用线性搜索算法。
插值搜索会丢弃搜索空间中超过一半的项目列表,这使得它能够更有效地到达包含搜索项的部分列表。在插值搜索算法中,中点的计算方式使得更快地获得搜索项的概率更高。插值搜索的平均情况时间复杂度为 O(log(logn)),而插值搜索算法的最坏情况时间复杂度为 O(n)。这表明插值搜索比二分搜索更好,并且在大多数情况下提供更快的搜索。
因此,如果列表较短且未排序,则线性搜索算法是合适的;如果列表已排序且不是很大,则可以使用二分查找算法。此外,如果列表中的数据元素均匀分布,则插值查找算法是很好的选择。如果列表非常大,则可以使用指数搜索算法和跳跃搜索算法。
摘要
在本章中,我们讨论了从数据元素列表中搜索给定元素的概念。我们讨论了几个重要的搜索算法,如线性搜索、二分搜索、跳跃搜索、插值搜索和指数搜索。我们详细讨论了这些算法的实现,使用了 Python。我们将在下一章讨论排序算法。
练习
-
平均来说,在
n个元素的线性搜索中需要多少次比较? -
假设有一个排序数组中有八个元素。如果所有搜索都成功,并且使用二分查找算法,平均需要多少次比较?
-
二分查找算法的最坏情况时间复杂度是多少?
-
在什么情况下插值查找算法比二分查找算法表现更好?
加入我们的 Discord 社区
加入我们的社区 Discord 空间,与作者和其他读者进行讨论:packt.link/MEvK4

第十一章:排序
排序意味着以升序或降序重新组织数据。排序是计算机科学中最重要的算法之一,在数据库相关算法中得到广泛应用。对于一些应用,如果数据被排序,它可以被有效地检索,例如,如果它是一组名称、电话号码或简单的待办事项列表中的项目。
在本章中,我们将研究一些最重要和最受欢迎的排序技术,包括以下内容:
-
冒泡排序
-
插入排序
-
选择排序
-
快速排序
-
Timsort
技术要求
本章中用于解释概念的所有源代码都提供在以下 GitHub 仓库链接中:
排序算法
排序意味着以升序或降序排列列表中的所有项目。我们可以通过比较使用不同排序算法所需的时间和内存空间来比较不同的排序算法。
算法所需的时间取决于输入的大小。此外,一些算法相对容易实现,但在时间和空间复杂度方面可能表现不佳,而其他算法虽然实现起来稍微复杂一些,但在排序较长的数据列表时可以表现良好。我们已经讨论过的一种排序算法是归并排序,它在第三章,算法设计技术和策略中有所提及。我们将逐一详细讨论更多排序算法及其实现细节,从冒泡排序算法开始。
冒泡排序算法
冒泡排序算法背后的思想非常简单。给定一个无序列表,我们比较列表中的相邻元素,并在每次比较后根据它们的值将它们放置在正确的顺序。因此,如果相邻的元素不在正确的顺序,我们会交换它们。这个过程会重复n-1次,其中n是列表中的项目数量。
在每次迭代中,列表中的最大元素会被移动到列表的末尾。经过第二次迭代后,第二大的元素将被放置在列表的倒数第二位。这个过程会一直重复,直到列表被排序。
让我们以只有一个元素为5和2的列表为例,来理解冒泡排序的概念,如图11.1所示:

图 11.1:冒泡排序的示例
为了对这两个元素的列表进行排序,首先,我们比较5和2;由于5大于2,这意味着它们不在正确的顺序,因此我们需要交换这些值以将它们放在正确的顺序。为了交换这两个数字,首先,我们将存储在索引0的元素移动到一个临时变量中(图 11.2的步骤 1),然后存储在索引1的元素被复制到索引0(图 11.2的步骤 2),最后将存储在临时变量中的第一个元素存储回索引1(图 11.2的步骤 3)。因此,首先,元素5被复制到一个临时变量temp中。然后,元素2被移动到索引0。最后,5从temp移动到索引1。列表现在将包含元素[2, 5]:

图 11.2:冒泡排序中两个元素的交换
以下代码将在unordered_list[0]和unordered_list[1]不在正确顺序的情况下交换它们的元素:
unordered_list = [5, 2]
temp = unordered_list[0]
unordered_list[0] = unordered_list[1]
unordered_list[1] = temp
print(unordered_list)
上述代码的输出是:
[2, 5]
现在我们已经能够交换一个两个元素的数组,那么使用这个相同的思想来使用冒泡排序对整个列表进行排序应该很简单。
让我们考虑另一个例子来理解冒泡排序算法的工作原理,并对一个包含六个元素的未排序列表进行排序,例如 {45, 23, 87, 12, 32, 4}。在第一次迭代中,我们开始比较前两个元素45和23,并将它们交换,因为45应该放在23之后。然后,我们比较下一个相邻的值45和87,看看它们是否处于正确的顺序。由于87的值高于45,我们不需要交换它们。如果它们不在正确的顺序,我们将交换两个元素。
我们可以在图 11.3中看到,在冒泡排序的第一次迭代之后,最大的元素87被放置在列表的最后一个位置:

图 11.3:使用冒泡排序对示例数组进行第一次迭代的步骤
在第一次迭代之后,我们只需要排列剩余的(n-1)个元素;我们通过比较剩余五个元素的相邻元素来重复相同的过程。在第二次迭代之后,第二大元素45被放置在列表的倒数第二位置,如图图 11.4所示:

图 11.4:使用冒泡排序对示例数组进行第二次迭代的步骤
接下来,我们必须比较剩余的(n-2)个元素,如图图 11.5所示,以将它们排列好:

图 11.5:使用冒泡排序对示例数组进行第三次迭代的步骤
同样,我们比较剩余的元素以对它们进行排序,如图图 11.6所示:

图 11.6:使用冒泡排序对示例数组进行第四次迭代的步骤
最后,对于最后两个剩余的元素,我们将它们放置在正确的顺序中,以获得最终的排序列表,如图图 11.7所示:

图 11.7:使用冒泡排序对示例数组进行排序的第五次迭代步骤
冒泡排序算法的完整 Python 代码如下,之后将详细解释每个步骤:
def bubble_sort(unordered_list):
iteration_number = len(unordered_list)-1
for i in range(iteration_number,0,-1):
for j in range(i):
if unordered_list[j] > unordered_list[j+1]:
temp = unordered_list[j]
unordered_list[j] = unordered_list[j+1]
unordered_list[j+1] = temp
冒泡排序是通过使用双层循环实现的,其中一个循环嵌套在另一个循环中。在冒泡排序中,内层循环在每次迭代中重复比较和交换给定列表中的相邻元素,外层循环跟踪内层循环应该重复多少次。
首先,在上面的代码中,我们计算循环应该运行多少次才能完成所有交换;这等于列表长度减 1,可以写成iteration_number = len(unordered_list)-1。在这里,len函数将给出列表的长度。我们减去 1 是因为它给出了运行的最大迭代次数。外层循环确保这一点,并执行列表大小的减 1 次。
此外,在上面的代码中,对于每次迭代,在内层循环中,我们使用if语句比较相邻的元素,并检查相邻的元素是否处于正确的顺序。对于第一次迭代,内层循环应该运行n次,对于第二次迭代,内层循环应该运行n-1次,以此类推。例如,要排序一个包含三个数字的列表,例如[3, 2, 1],内层循环运行两次,我们最多需要交换两次元素,如图 11.8所示:

图 11.8:示例列表[3, 2, 1]在第一次迭代中的交换次数
此外,在第一次迭代之后,在第二次迭代中,我们执行内层循环一次,如图 11.9所示:

图 11.9:示例列表[3, 2, 1]在第二次迭代中的交换次数
以下代码片段可以用来部署冒泡排序算法:
my_list = [4,3,2,1]
bubble_sort(my_list)
print(my_list)
my_list = [1,12,3,4]
bubble_sort(my_list)
print(my_list)
输出如下:
[1, 2, 3, 4]
[1, 3, 4, 12]
在最坏的情况下,第一次迭代所需的比较次数将是(n-1),第二次迭代,比较次数将是(n-2),第三次迭代将是(n-3),以此类推。因此,冒泡排序所需的总比较次数如下:
(n-1) + (n-2) + (n-3) +.....+ 1 = n(n-1)/2
n(n+1)/2
O(n²)
冒泡排序算法不是一个高效的排序算法,因为它提供了最坏情况下的运行时间复杂度为O(n²),最佳情况下的复杂度为O(n)。最坏的情况是我们想要按升序排序给定的列表,而给定的列表是降序的,最佳情况是给定的列表已经排序;在这种情况下,将不需要进行交换。
通常情况下,冒泡排序算法不适用于排序大型列表。冒泡排序算法适用于性能不重要或给定列表长度较短的应用,而且更倾向于简短和简单的代码。冒泡排序算法在相对较小的列表上表现良好。
现在我们将探讨插入排序算法。
插入排序算法
插入排序的想法是,我们维护两个子列表(子列表是原始较大列表的一部分),一个是已排序的,另一个是未排序的,元素逐个从未排序的子列表添加到已排序的子列表中。因此,我们从未排序的子列表中取出元素,并将它们插入到已排序子列表的正确位置,这样这个子列表仍然保持排序状态。
在插入排序算法中,我们始终从一个元素开始,将其视为已排序,然后逐个从未排序的子列表中取出元素,并将它们放置在已排序子列表中的正确位置(与第一个元素相关)。因此,在从未排序的子列表中取出一个元素并将其添加到已排序的子列表后,现在我们已排序的子列表中有两个元素。然后,我们再次从未排序的子列表中取出另一个元素,并将其放置在已排序子列表中的正确位置(与已排序的两个元素相关)。我们反复执行此过程,将未排序子列表中的所有元素逐个插入到已排序子列表中。阴影元素表示图 11.10中的有序子列表,并且在每次迭代中,未排序子列表中的一个元素被插入到已排序子列表的正确位置。
让我们通过一个例子来理解插入排序算法的工作原理。假设;我们需要对一个包含六个元素的列表进行排序:{45, 23, 87, 12, 32, 4}。首先,我们从一个元素开始,假设它已排序,然后从未排序的子列表中取出下一个元素23,并将其插入到已排序子列表的正确位置。在下一个迭代中,我们从未排序的子列表中取出第三个元素87,并将其再次插入到已排序子列表的正确位置。我们遵循相同的步骤,直到所有元素都在已排序子列表中。整个过程如图 11.10所示:

图 11.10:使用插入排序算法对示例数组元素进行排序的步骤
下面给出了插入排序的完整 Python 代码;算法的每个语句都通过示例进行了详细的解释:
def insertion_sort(unsorted_list):
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
为了理解插入排序算法的实现,让我们再举一个包含五个元素的例子,{5, 1, 100, 2, 10},并对其进行详细的解释。让我们考虑以下数组,如图 11.11所示:

图 11.11:带有索引位置的示例数组
算法首先使用一个for循环在1和4索引之间运行。我们从索引1开始,因为我们将存储在索引0的元素视为已排序的子数组,而索引1到4之间的元素是未排序的子列表,如图 11.12所示:

图 11.12:插入排序中排序和未排序子列表的演示
在循环执行开始时,我们有以下代码片段:
for index in range(1, len(unsorted_list)):
search_index = index
insert_value = unsorted_list[index]
在每次for循环执行的开始,unsorted_list[index]处的元素被存储在insert_value变量中。稍后,当我们找到子列表排序部分的适当位置时,insert_value将被存储在该索引的排序子列表中。下面的代码片段显示了:
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]数组将执行以下任一操作:
-
指向
unsorted_list[search_index]之前的元素,在第一次执行while循环之前 -
指向
unsorted_list[search_index-1]之前的一个元素,在第一次执行while循环之后
在示例列表中,while循环将执行,因为5 > 1。在while循环体内,unsorted_list[search_index-1]处的元素将被存储在unsorted_list[search_index]处。然后,search_index -= 1将列表遍历向后移动,直到它持有值为0。
在while循环退出后,最后一个已知的search_index位置(在这种情况下为0)现在帮助我们了解在哪里插入insert_value。图 11.13显示了第一次迭代后元素的位置:

图 11.13:第一次迭代后示例列表的位置
在for循环的第二次迭代中,search_index将具有值为2,这是数组中第三个元素的索引。此时,我们开始从左向右(向索引0)进行比较。100将与5进行比较,但由于100大于5,while循环将不会执行。100将被替换为自己,因为search_index变量从未递减。因此,unsorted_list[search_index] = insert_value将没有效果。
当search_index指向索引3时,我们比较2与100,并将100移动到2存储的位置。然后,我们比较2与5,并将5移动到100最初存储的位置。此时,while循环将中断,2将被存储在索引1处。数组将部分排序,值为[1, 2, 5, 100, 10]。为了使列表排序,上述步骤将最后一次发生。
以下代码可以用来创建一个元素列表,我们可以使用定义的insertion_sort()方法对其进行排序:
my_list = [5, 1, 100, 2, 10]
print("Original list", my_list)
insertion_sort(my_list)
print("Sorted list", my_list)
上述代码的输出如下:
Original list [5, 1, 100, 2, 10]
Sorted list [1, 2, 5, 10, 100]
插入排序的最坏情况时间复杂度发生在给定的元素列表按逆序排序时。在这种情况下,每个元素都必须与其他每个元素进行比较。因此,在第一轮迭代中需要一次比较,第二轮迭代中需要两次比较,第三轮迭代中需要三次比较,在(n-1)^(th)迭代中需要(n-1)次比较。因此,总的比较次数为:
1 + 2 + 3 .. (n-1)
n(n-1)/2
因此,插入排序算法的最坏情况运行时间复杂度为 O(n²)。此外,插入排序算法的最佳情况复杂度为 O(n),在这种情况下,给定的输入列表已经排序,并且每个未排序子列表的每个元素在每个迭代中只与已排序子列表的最右侧元素进行比较。当给定的列表元素数量较少时,插入排序算法很好用,当输入数据逐个到达,并且我们需要保持列表排序时,它是最适合的。现在我们将来看看选择排序算法。
选择排序算法
另一个流行的排序算法是选择排序。选择排序算法首先在列表中寻找最小的元素,并将其与列表第一个位置存储的数据交换。因此,它对直到第一个元素为止的子列表进行排序。这个过程重复(n-1)次,以对n个元素进行排序。
接下来,找到的第二小元素,即剩余列表中的最小元素,被识别并与列表的第二个位置交换。这使得前两个元素排序。这个过程重复进行,列表中剩余的最小元素与列表的第三个索引处的元素交换。这意味着现在前三个元素已经排序。
让我们通过一个例子来了解算法是如何工作的。我们将使用选择排序算法对以下四个元素的列表 {15, 12, 65, 10, 7} 进行排序,如图图 11.14所示,同时使用选择排序算法显示它们的索引位置:

图 11.14:选择排序第一轮迭代的演示
在选择排序的第一轮迭代中,我们从索引0开始,在列表中寻找最小的元素,当找到最小元素时,它会被与列表索引0处的第一个数据元素交换。我们简单地重复这个过程,直到列表完全排序。在第一轮迭代之后,最小的元素将被放置在列表的第一个位置。
接下来,我们从列表的第二个元素(索引位置1)开始,从索引位置1到列表长度的数据列表中寻找最小的元素。一旦从这个剩余的元素列表中找到最小的元素,我们就将这个元素与列表的第二个元素交换。选择排序第二轮迭代的逐步过程如图图 11.15所示:

图 11.15:选择排序第二次迭代的演示
在下一次迭代中,我们在索引位置 2 到 4 的剩余列表中找到最小的元素,并将最小的数据元素与第二次迭代中索引 2 的数据元素交换。我们遵循相同的流程,直到整个列表排序完成。
以下是对选择排序算法的实现。函数的参数是我们想要按值升序排列的未排序项目列表:
def selection_sort(unsorted_list):
size_of_list = len(unsorted_list)
for i in range(size_of_list):
small = i
for j in range(i+1, size_of_list):
if unsorted_list[j] < unsorted_list[small]:
small = j
temp = unsorted_list[i]
unsorted_list[i] = unsorted_list[small]
unsorted_list[small] = temp
在上述选择排序的代码中,算法从外部的 for 循环开始遍历列表,从索引 0 到 size_of_list。因为我们把 size_of_list 传递给 range 方法,它将生成从 0 到 size_of_list-1 的序列。
接下来,我们声明一个变量 small,它存储最小元素的索引。进一步地,内循环负责遍历列表,我们跟踪列表中最小值的索引。一旦找到最小元素的索引,我们就将这个元素与列表中的正确位置交换。
以下代码可以用来创建元素列表,我们使用选择排序算法来排序列表:
a_list = [3, 2, 35, 4, 32, 94, 5, 7]
print("List before sorting", a_list)
selection_sort(a_list)
print("List after sorting", a_list)
上述代码的输出如下:
List before sorting [3, 2, 35, 4, 32, 94, 5, 7]
List after sorting [2, 3, 4, 5, 7, 32, 35, 94]
在选择排序中,第一次迭代需要 (n-1) 次比较,第二次迭代需要 (n-2) 次比较,第三次迭代需要 (n-3) 次比较,以此类推。所以,所需的总比较次数是:(n-1) + (n-2) + (n-3) + ... + 1 = n(n-1) / 2,这几乎等于 n²。因此,选择排序的最坏情况时间复杂度为 O(n²)。最坏的情况是给定的元素列表是逆序的。选择排序算法给出了最佳情况运行时间复杂度为 O(n²)。当元素列表较小的时候,可以使用选择排序算法。
接下来,我们将讨论快速排序算法。
快速排序算法
快速排序是一种高效的排序算法。快速排序算法基于分而治之的算法类别,类似于归并排序算法,其中我们将问题分解(分)成更小的部分,这些部分更容易解决,并且最终结果是通过组合较小问题的输出(治)获得的。
快速排序背后的概念是将给定的列表或数组分区。为了分区列表,我们首先从给定的列表中选择一个数据元素,这个元素被称为基准元素。
我们可以在列表中选择任何元素作为枢轴元素。然而,为了简单起见,我们将取数组中的第一个元素作为枢轴元素。接下来,列表中的所有元素都与这个枢轴元素进行比较。在第一轮迭代结束时,列表中的所有元素都按照以下方式排列:小于枢轴元素的元素排列在枢轴的左侧,而大于枢轴元素的元素排列在枢轴的右侧。
现在,让我们通过一个例子来理解快速排序算法的工作原理。
在这个算法中,首先我们将给定的未排序数据元素列表分成两个子列表,使得该分区点(也称为枢轴)左侧的所有元素都应该小于枢轴,而枢轴右侧的所有元素都应该大于枢轴。这意味着左右子列表中的元素将未排序,但枢轴元素将在整个列表中的正确位置。这如图图 11.16所示。
因此,在快速排序算法的第一轮迭代之后,选定的枢轴点被放置在列表中的正确位置,然后我们在这两个子列表上再次执行相同的步骤。这样,快速排序算法将列表分成两部分,并递归地对这两个子列表应用快速排序算法以对整个列表进行排序:

图 11.16:快速排序中子列表的示意图
快速排序算法的工作原理如下:
-
我们首先选择一个枢轴元素,所有数据元素都要与之比较,在第一轮迭代结束时,这个枢轴元素将放置在列表中的正确位置。为了将枢轴元素放置在其正确位置,我们使用两个指针,一个左指针和一个右指针。这个过程如下:
-
左指针最初指向索引
1处的值,右指针指向最后一个索引处的值。这里的主要思想是移动位于枢轴元素错误一侧的数据项。因此,我们从左指针开始,向左到右移动,直到我们到达一个位置,列表中的数据项的值大于枢轴元素。 -
同样,我们将右指针向左移动,直到我们找到一个小于枢轴元素的元素。
-
接下来,我们交换这两个由左指针和右指针指示的值。
-
我们重复相同的步骤,直到两个指针交叉,换句话说,直到右指针的索引指示的值小于左指针的索引。
-
-
在描述的 步骤 1 每次迭代之后,枢轴元素将放置在列表中的正确位置,原始列表将被分为两个无序的子列表,左子列表和右子列表。我们对这两个左子列表和右子列表都遵循相同的流程(如 步骤 1 所述),直到每个子列表只包含一个元素。
-
最后,所有元素都将放置在其正确的位置,这将给出排序后的列表作为输出。
让我们以一个数字列表 {45, 23, 87, 12, 72, 4, 54, 32, 52} 为例,来了解快速排序算法是如何工作的。假设我们列表中的枢轴元素(也称为枢轴点)是第一个元素,45。我们将左指针从索引 1 向右移动,直到我们达到值 87,因为 (87>45)。接下来,我们将右指针向左移动,直到我们找到值 32,因为 (32<45)。现在,我们交换这两个值。这个过程如图 图 11.17 所示:

图 11.17:快速排序算法的说明性示例
然后,我们重复相同的流程,并将左指针向右移动,直到我们找到值 72,因为 (72 > 45)。接下来,我们将右指针向左移动,直到我们达到值 4,因为 (4 < 45)。现在,我们交换这两个值,因为它们位于枢轴值的错误一侧。我们重复相同的流程,直到右指针的索引值小于左指针的索引。在这里,我们找到 4 作为分割点,并将其与枢轴值交换。这如图 图 11.18 所示:

图 11.18:快速排序算法的一个示例(继续)
可以观察到,在快速排序算法的第一次迭代之后,枢轴值 45 被放置在列表中的正确位置。
现在我们有两个子列表:
-
位于枢轴值
45左侧的子列表包含小于45的值。 -
位于枢轴值右侧的另一个子列表包含大于
45的值。我们将对这些两个子列表递归地应用快速排序算法,并重复此过程,直到整个列表排序,如图 图 11.19 所示:

图 11.19:在示例元素列表上快速排序算法的第一次迭代后的结果
我们将在下一节中查看快速排序算法的实现。
快速排序的实现
快速排序算法的主要任务是首先将基准元素放置在其正确的位置,以便我们将给定的未排序列表分成两个子列表(左子列表和右子列表);这个过程称为分区步骤。分区步骤在理解快速排序算法的实现中非常重要,因此我们将首先通过一个示例来理解分区步骤的实现。在这个过程中,给定一个元素列表,所有元素都将按照以下方式排列:小于基准元素的元素将位于其左侧,而大于基准元素的元素将排列在基准元素的右侧。
让我们通过一个示例来理解实现。考虑以下整数列表:[43, 3, 20, 89, 4, 77]。我们将使用分区函数来对这个列表进行分区:
[43, 3, 20, 89, 4, 77]
考虑以下分区函数的代码;我们将详细讨论代码的每一行:
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
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
分区函数接收作为其参数的数组中需要分区的第一个和最后一个元素的索引。
基准值存储在pivot变量中,而其索引存储在pivot_index中。我们没有使用unsorted_array[0],因为当使用数组的某个片段作为未排序数组参数调用时,索引0不一定指向该数组中的第一个元素。紧邻基准元素的元素的索引,即左指针first_index + 1,标记了我们开始查找数组中元素的位置。这个数组大于pivot,因为greater_than_pivot_index = first_index + 1表明。右指针less_than_pivot_index变量指向less_than_pivot_index = index_of_last_element列表中最后一个元素的位置,我们从这个位置开始搜索小于基准的元素。
进一步地,在主while循环执行开始时,数组看起来如图图 11.20所示:

图 11.20:快速排序算法示例数组的说明 1
第一个内部while循环将索引向右移动一个位置,直到它落在索引2上,因为该索引处的值大于43。在这个点上,第一个while循环中断,不再继续。在第一个while循环中的每次条件测试中,只有当while循环的测试条件评估为True时,才会评估greater_than_pivot_index += 1。这使得搜索大于基准的元素的搜索进展到数组的下一个元素。
第二个内部while循环每次移动一个索引,直到它落在索引5上,其值20小于43,如图图 11.21所示:

图 11.21:快速排序算法示例数组的说明 2
接下来,在这个点上,内部while循环中的任何一个都不能再执行下去,下一个代码片段如下所示:
if greater_than_pivot_index < less_than_pivot_index:
temp = unsorted_array[greater_than_pivot_index]
unsorted_array[greater_than_pivot_index] =
unsorted_array[less_than_pivot_index]
unsorted_array[less_than_pivot_index] = temp
else:
break
在这里,由于 greater_than_pivot_index < less_than_pivot_index,if 语句的主体交换了那些索引处的元素。else 条件在任何 greater_than_pivot_index 变得大于 less_than_pivot_index 时中断无限循环。在这种情况下,这意味着 greater_than_pivot_index 和 less_than_pivot_index 已经交叉。
数组现在看起来如 图 11.22 所示:

图 11.22:快速排序算法示例数组的第 3 个示例说明
当 less_than_pivot_index 等于 3 而 greater_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
图 11.23 展示了代码如何在分区过程的最后一步将 4 与 43 交换:

图 11.23:快速排序算法示例数组的第 4 个示例说明
回顾一下,第一次调用 quick_sort 函数时,它在索引 0 的元素处进行了分区。在分区函数返回后,我们获得了按顺序排列的数组 [4, 3, 20, 43, 89, 77]。
如您所见,元素 43 右侧的所有元素都大于 43,而左侧的元素都较小。因此,分区完成。
使用分割点 43 和索引 3,我们将递归地对两个子数组 [4, 30, 20] 和 [89, 77] 进行排序,使用我们刚才使用的过程。
主要 quick_sort 函数的主体如下:
def quick_sort(unsorted_array, first, last):
if last - first <= 0:
return
else:
partition_point = partition(unsorted_array, first, last)
quick_sort(unsorted_array, first, partition_point-1)
quick_sort(unsorted_array, partition_point+1, last)
quick_sort 函数相当简单;最初,调用 partition 方法,该方法返回分区点。这个分区点位于 unsorted_array 数组中,其中所有左边的元素都小于枢轴值,而所有右边的元素都大于枢轴值。我们在分区过程之后立即打印 unsorted_array 的状态,以查看每次调用后的数组状态。
在第一次分区后,第一个子数组 [4, 3, 20] 将完成;当 greater_than_pivot_index 在索引 2 而 less_than_pivot_index 在索引 1 时,这个子数组的分区将停止。在那个点上,两个标记被认为是交叉的。因为 greater_than_pivot_index 大于 less_than_pivot_index,所以 while 循环的进一步执行将停止。枢轴 4 将与 3 交换,而索引 1 被返回作为分区点。
我们可以使用以下代码片段创建一个元素列表,并使用快速排序算法对其进行排序:
my_array = [43, 3, 77, 89, 4, 20]
print(my_array)
quick_sort(my_array, 0, 5)
print(my_array)
上述代码的输出如下:
[43, 3, 77, 89, 4, 20]
[3, 4, 20, 43, 77, 89]
在快速排序算法中,分区算法需要O(n)的时间。由于快速排序算法遵循分而治之的范式,它需要O(logn)的时间;因此,快速排序算法的平均情况运行时间复杂度为O(n) * O(logn) = O(nlogn)。快速排序算法的最坏情况运行时间复杂度为 O(n²)。快速排序算法的最坏情况复杂度会在每次都选择最差的基准点时出现,其中一个分区始终只有一个元素。例如,如果列表已经排序,如果分区选择了最小的元素作为基准点,那么最坏情况复杂度就会发生。当最坏情况复杂度发生时,可以通过使用随机快速排序来改进快速排序算法。当给定的元素列表非常长时,快速排序算法是高效的;与其他上述排序算法相比,在这种情况下排序效果更好。
Timsort 算法
Timsort 被用作所有 Python 版本>=2.3 的默认标准排序算法。Timsort 算法是基于归并排序和插入排序算法组合的针对现实世界长列表的最优算法。Timsort 算法利用了两种算法的最佳之处;插入排序在数组部分排序且大小较小时表现最佳,而归并排序的合并方法在需要合并小的、已排序的列表时运行速度快。
Timsort 算法的主要概念是它使用插入排序算法对数据元素的小块(也称为块)进行排序,然后使用归并排序算法合并所有已排序的块。Timsort 算法的主要特点是它利用了已知为“自然运行”的已排序数据元素,这在现实世界的数据中非常常见。
Timsort 算法的工作原理如下:
-
首先,我们将给定的数据元素数组划分为多个块,这些块也称为运行。
-
我们通常使用 32 或 64 作为运行的大小,因为它适合 Timsort;然而,我们可以使用任何其他可以从给定数组长度(例如
N)计算出的大小。minrun是每个运行的最小长度。minrun的大小可以通过遵循以下原则来计算:-
minrun的大小不应过长,因为我们使用插入排序算法对这些小块进行排序,这对于短列表的元素表现良好。 -
运行的长度不应过短;在这种情况下,会导致运行数量更多,这将使合并算法变慢。
-
由于归并排序在运行的数量是 2 的幂时效果最佳,因此如果计算为
N/minrun的运行数量是 2 的幂,那就很好。
-
-
例如,如果我们取运行大小为 32,那么运行的数量将是
(size_of_array/32);如果这是一个 2 的幂,那么合并过程将非常高效。 -
使用插入排序算法逐个对每个运行进行排序。
-
使用归并排序算法的合并方法逐个合并所有排序好的运行。
-
在每次迭代后,我们将合并的子数组大小加倍。
让我们通过一个例子来理解 Timsort 算法的工作原理。假设我们有数组 [4, 6, 3, 9, 2, 8, 7, 5]。我们使用 Timsort 算法对其进行排序;这里,为了简单起见,我们取运行大小为 4。因此,我们将给定的数组分为两个运行,运行 1 和运行 2。接下来,我们使用插入排序算法对运行 1 进行排序,然后使用插入排序算法对运行 2 进行排序。一旦我们有了所有排序好的运行,我们使用归并排序算法的合并方法来获得最终的完整排序列表。整个过程如 图 11.24 所示:

图 11.24:Timsort 算法示例数组的说明
接下来,让我们讨论 Timsort 算法的实现。首先,我们实现插入排序算法和归并排序算法的合并方法。插入排序算法已经在之前的章节中详细讨论过。为了完整性,下面再次给出:
def Insertion_Sort(unsorted_list):
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
return unsorted_list
在上述代码中,插入排序方法负责对运行进行排序。接下来,我们介绍归并排序算法的合并方法;这在 第三章,算法设计技术和策略 中已经详细讨论过。这个 Merge() 函数用于合并排序好的运行,其定义如下:
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
接下来,让我们讨论 Timsort 算法的实现。以下是其实施代码。让我们一点一点地理解它:
def Tim_Sort(arr, run):
for x in range(0, len(arr), run):
arr[x : x + run] = Insertion_Sort(arr[x : x + run])
runSize = run
while runSize < len(arr):
for x in range(0, len(arr), 2 * runSize):
arr[x : x + 2 * runSize] = Merge(arr[x : x + runSize], arr[x + runSize: x + 2 * runSize])
runSize = runSize * 2
在上述实现中,我们首先传递两个参数,要排序的数组和运行的大小。接下来,我们使用插入排序在下面的代码片段中按运行大小对子数组进行排序:
for x in range(0, len(arr), run):
arr[x : x + run] = Insertion_Sort(arr[x : x + run])
在上述示例列表 [4, 6, 3, 9, 2, 8, 7, 5] 的代码中,假设运行大小为 2,那么我们将有总共四个块/块/运行,在退出这个循环后,数组将变成这样:[4, 6, 3, 9, 2, 8, 5, 7],这表明所有大小为 2 的运行都已排序。之后我们初始化 runSize 并迭代,直到 runSize 等于数组长度。因此,我们使用合并方法来合并排序的小列表:
runSize = run
while runSize < len(arr):
for x in range(0, len(arr), 2 * runSize):
arr[x : x + 2 * runSize] = Merge(arr[x : x + runSize], arr[x + runSize: x + 2 * runSize])
runSize = runSize * 2
在上述代码中,for 循环使用 Merge 函数合并大小为 runSize 的运行。对于上面的例子,runSize 是 2。在第一次迭代中,它将合并从索引 (0 到 1) 的左运行和从索引 (2 到 3) 的右运行,形成一个从索引 (0 到 3) 的排序数组,数组将变成 [3, 4, 6, 9, 2, 8, 5, 7]。
进一步,在第二次迭代中,它将合并从索引 (4 到 5) 的左侧运行和从索引 (6 到 7) 的右侧运行,以形成一个从索引 (4 到 7) 的排序运行。在第二次迭代后,for 循环将终止,数组将变为 [3, 4, 6, 9, 2, 5, 7, 8],这表明数组已经从索引 (0 到 3) 和 (4 到 7) 排序。
现在我们将运行的大小更新为 2*runSize,并重复相同的步骤来更新 runSize。因此,现在 runSize 是 4。现在,在第一次迭代中,它将合并从索引 0 到 3 的左侧运行和从索引 4 到 7 的右侧运行,以形成一个从索引 0 到 7 的排序数组,然后 for 循环将终止,数组将变为 [2, 3, 4, 5, 6, 7, 8, 9],这表明数组已经排序。
现在,runSize 将等于数组长度,因此 while 循环将终止,最后我们将得到一个排序后的数组。
我们可以使用下面的代码片段来创建一个列表,然后使用 Timsort 算法对列表进行排序:
arr = [4, 6, 3, 9, 2, 8, 7, 5]
run = 2
Tim_Sort(arr, run)
print(arr)
上述代码的输出如下:
[2,3,4,5,6,7,8,9]
Timsort 对于实际应用非常高效,因为它具有最坏情况复杂度 O(n log n)。即使给定的列表长度较短,Timsort 也是排序的最佳选择。在这种情况下,它使用插入排序算法,这对于较短的列表非常快,而 Timsort 算法由于合并方法,对于长列表工作得很快;因此,由于其在实际应用中对任何长度的数组排序的适应性,Timsort 算法是排序的一个很好的选择。
下表给出了不同排序算法复杂度的比较:
| 算法 | 最坏情况 | 平均情况 | 最好情况 |
|---|---|---|---|
| 冒泡排序 | 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) |
| Timsort | O(n log n) |
O(n log n) |
O(n) |
表 11.1:比较不同排序算法的复杂度
摘要
在本章中,我们探讨了重要且流行的排序算法,这些算法对于许多实际应用非常有用。我们讨论了冒泡排序、插入排序、选择排序、快速排序和 Timsort 算法,并解释了它们在 Python 中的实现。一般来说,快速排序算法的性能优于其他排序算法,而 Timsort 算法是实际应用中最佳的选择。
在下一章中,我们将讨论选择算法。
练习
-
如果给定一个数组
arr = {55, 42, 4, 31}并使用冒泡排序对数组元素进行排序,那么需要多少次迭代才能对数组进行排序?-
3
-
2
-
1
-
0
-
-
冒泡排序的最坏情况复杂度是多少?
-
O(n log n) -
O(log n) -
O(n) -
O(n²)
-
-
对序列 (
56, 89, 23, 99, 45, 12, 66, 78, 34) 应用快速排序。第一阶段后序列是什么,第一个元素是什么枢轴?-
45, 23, 12, 34, 56, 99, 66, 78, 89
-
34, 12, 23, 45, 56, 99, 66, 78, 89
-
12, 45, 23, 34, 56, 89, 78, 66, 99
-
34, 12, 23, 45, 99, 66, 89, 78, 56
-
-
快速排序是一种 ___________
-
贪心算法
-
分而治之算法
-
动态规划算法
-
回溯算法
-
-
考虑一种情况,交换操作非常昂贵。以下哪种排序算法应该被使用以使交换操作的数量最小化?
-
堆排序
-
选择排序
-
插入排序
-
归并排序
-
-
如果输入数组
A={15,9,33,35,100,95,13,11,2,13},使用选择排序,第五次交换后数组的顺序会是什么?(注意:无论它们是否交换位置,都计算在内。)-
2, 9, 11, 13, 13, 95, 35, 33, 15, 100
-
2, 9, 11, 13, 13, 15, 35, 33, 95, 100
-
35, 100, 95, 2, 9, 11, 13, 33, 15, 13
-
11, 13, 9, 2, 100, 95, 35, 33, 13, 13
-
-
使用插入排序对元素
{44, 21, 61, 6, 13, 1}进行排序需要多少次迭代?-
6
-
5
-
7
-
1
-
-
如果使用插入排序对数组元素
A= [35, 7, 64, 52, 32, 22]进行排序,第二次迭代后数组元素将如何排列?-
7, 22, 32, 35, 52, 64
-
7, 32, 35, 52, 64, 22
-
7, 35, 52, 64, 32, 22
-
7, 35, 64, 52, 32, 22
-
加入我们的 Discord 社区
加入我们的 Discord 空间,与作者和其他读者进行讨论:packt.link/MEvK4

第十二章:选择算法
与在无序列表中查找元素相关的一组有趣的算法是选择算法。给定一个元素列表,选择算法用于从列表中找到第k个最小或最大的元素。因此,给定一个数据元素列表和一个数字(k),目标是找到第k个最小或最大的元素。选择算法的最简单情况是从列表中找到最小或最大的数据元素。然而,有时我们可能需要找到列表中的第k个最小或最大的元素。最简单的方法是首先使用任何排序算法对列表进行排序,然后我们可以轻松地获得第k个最小(或最大)的元素。然而,当列表非常大时,对列表进行排序以获取第k个最小或最大的元素可能不是高效的。在这种情况下,我们可以使用不同的选择算法,这些算法可以有效地产生第k个最小或最大的元素。
在本章中,我们将涵盖以下主题:
-
排序选择
-
随机选择
-
确定性选择
我们将从技术要求开始,然后讨论排序选择。
技术要求
本章中使用的所有源代码都提供在给定的 GitHub 链接中:github.com/PacktPublishing/Hands-On-Data-Structures-and-Algorithms-with-Python-Third-Edition/tree/main/Chapter12。
排序选择
列表中的项目可能需要进行统计调查,例如找到均值、中位数和众数。找到均值和众数不需要列表是有序的。然而,要找到数字列表中的中位数,列表必须首先是有序的。找到中位数需要你找到有序列表中间位置的元素。此外,这也可以用于当我们想要找到列表中的第k个最小项目时。要找到无序列表中第k个最小的数字,一个明显的方法是首先对列表进行排序,排序后,你可以放心,列表中索引为 0 的元素将包含列表中的最小元素。同样,列表中的最后一个元素将包含列表中的最大元素。
有关如何在列表中排序数据项的更多信息,请参阅 第十一章,排序。然而,为了从列表中获得第 k 个最小的元素,将排序算法应用于长列表元素以获得最小值、最大值或第 k 个最小值或最大值并不是一个好的解决方案,因为排序是一个相当昂贵的操作。因此,如果我们需要从给定的列表中找出第 k 个最小或最大的元素,就没有必要对整个列表进行排序,因为我们有其他可以用于此目的的方法。让我们讨论更好的技术来找到第 k 个最小的元素,而无需首先对列表进行排序,从随机选择开始。
随机选择
随机选择算法是基于快速排序算法来获得第 k 个最小数的;随机选择算法也被称为快速选择。在 第十一章,排序 中,我们讨论了快速排序算法。快速排序算法是一种高效的算法,用于对未排序的项目列表进行排序。总结来说,快速排序算法的工作原理如下:
-
它选择一个枢轴。
-
它将未排序的列表围绕枢轴进行分区。
-
它递归地使用步骤 1 和 2 对分区后的列表的两半进行排序。
关于快速排序的一个重要事实是,在每次分区步骤之后,枢轴的索引不会改变,即使列表已经排序。这意味着在每次迭代之后,选定的枢轴值将放置在列表中的正确位置。快速排序的这个特性使我们能够获得第 k 个最小的数,而无需对整个列表进行排序。让我们讨论随机选择方法,也称为快速选择算法,以从包含 n 个数据项的列表中获得第 k 个最小的元素。
快速选择
快速选择算法用于从未排序的项目列表中获得第 k 个最小的元素。它基于快速排序算法,其中我们递归地对枢轴点两侧的子列表的元素进行排序。在每次迭代中,枢轴值达到列表中的正确位置,从而将列表分为两个未排序的子列表(左侧子列表和右侧子列表),其中左侧子列表的值小于枢轴值,而右侧子列表的值大于枢轴值。现在,在快速选择算法的情况下,我们只递归调用具有第 k 个最小元素的子列表中的函数。
在快速选择算法中,我们比较枢轴点的索引与 k 值,以从给定的未排序列表中获得第 k 个最小的元素。快速选择算法中将有三种情况,如下所示:
-
如果枢轴点的索引小于
k,那么我们可以确定第k个最小的值将出现在枢轴点的右侧子列表中。因此,我们只需递归地调用快速选择函数对右侧子列表进行操作。 -
如果枢轴点的索引大于
k,那么很明显,第k小的元素将出现在枢轴点的左侧。因此,我们只需在左子列表中递归地查找第i个元素。 -
如果枢轴点的索引等于
k,那么这意味着我们已经找到了第k小的值,并返回它。
让我们通过一个例子来理解 quickselect 算法的工作原理。考虑一个元素列表,{45, 23, 87, 12, 72, 4, 54, 32, 52}。我们可以使用 quickselect 算法来找到这个列表中的第 3 小的元素。
我们开始算法时选择一个枢轴值,即45。在这里,为了简单起见,我们选择第一个元素作为枢轴元素;然而,任何其他元素都可以被选为枢轴元素。在算法的第一轮迭代之后,枢轴值移动到列表中的正确位置,在这个例子中是索引 4(索引从 0 开始)。接下来,我们检查条件k<pivot点(即2<4)。情况-2 适用,所以我们只考虑左子列表,并递归调用函数。在这里,我们比较枢轴值的索引(即4)与k的值(即第 3 个位置或索引 2)。
接下来,我们取左子列表并选择枢轴点(即4)。运行后,4被放置在其正确的位置(即第 0 个索引)。由于枢轴点的索引小于k的值,我们考虑右子列表。
同样,我们将23作为枢轴点,它也被放置在其正确的位置。现在,当我们比较枢轴点的索引和k的值时,它们是相等的,这意味着我们已经找到了第 3 小的元素,并且它将被返回。找到第 3 小元素的完整步骤过程如图12.1所示:

图 12.1:quickselect 算法的逐步演示
让我们讨论quick_select方法的实现。它被定义为如下:
def quick_select(array_list, start, end, k):
split = partition(array_list, start, end)
if split == k:
return array_list[split]
elif split < k:
return quick_select(array_list, split + 1, end, k)
else:
return quick_select(array_list, start, split-1, k)
在上面的代码中,quick_select函数接受完整的数组、列表第一个元素的索引、最后一个元素的索引以及由值k指定的k个元素作为参数。k的值与用户要搜索的索引相对应,意味着列表中的第k小的数字。
首先,我们使用partition()方法(该方法在第十一章,排序中定义并详细讨论)将选定的枢轴点放置在适当的位置,以便将给定的元素列表分割成左子列表和右子列表,其中左子列表包含小于枢轴值的元素,而右子列表包含大于枢轴值的元素。partition()方法调用为split = partition(array_list, start, end),并返回split索引。在这里,split索引是枢轴元素在数组中的位置,而(start, end)是列表的起始和结束索引。一旦我们得到分割点,我们就比较split索引与所需的k值,以确定我们是否已经到达了第 k 小数据项的位置,或者所需的第 k 小元素是否将在左子列表或右子列表中。这三个条件如下:
-
如果
split等于k的值,那么这意味着我们已经到达了列表中的第 k 小数据项。 -
如果
split小于k,那么这意味着第 k 小的元素应该存在于split+1和right之间。 -
如果
split大于k,那么这意味着第 k 小的元素应该存在于left和split-1之间。
在前面的例子中,分割点出现在索引 4(索引从 0 开始)。如果我们正在寻找第 3 小的数字,那么由于 4 < 2 的结果为false,就会对右子列表进行递归调用,使用quick_select(array_list, left, split-1, k)。
为了使此算法完整,下面给出了partition()方法的实现:
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
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
th smallest element using the quickselect algorithm for a given array.
list1 = [3,1,10, 4, 6, 5]
print("The 2nd smallest element is", quick_select(list1, 0, 5, 1))
print("The 3rd smallest element is", quick_select(list1, 0, 5, 2))
上述代码的输出如下:
The 2nd smallest element is 3
The 3rd smallest element is 4
在上面的代码中,我们从给定的元素列表中获取第 2 小和第 3 小的元素。基于随机选择的quick-select算法的最坏情况性能是 O(n²)。
在上述partition()方法的实现中,为了简单起见,我们使用列表的第一个元素作为枢轴元素,但可以从列表中选择任何元素作为枢轴元素。一个好的枢轴元素是能够将列表几乎平分的元素。因此,通过更有效地选择分割点,可以在线性时间内提高快速选择算法的性能,其最坏情况复杂度为O(n)。我们将在下一节中讨论如何使用确定性选择来实现这一点。
确定性选择
确定性选择是一种在无序元素列表中查找第 k 项的算法。正如我们在quickselect算法中所见,我们选择一个随机的“枢轴”元素,将列表分割成两个子列表,并对其中一个子列表进行递归调用。在确定性选择算法中,我们更有效地选择枢轴元素,而不是随机选择任何枢轴元素。
确定性算法的主要概念是选择一个能产生良好列表分割的枢轴元素,良好的分割是将列表分成两半。例如,选择枢轴元素的一个好方法就是选择所有值的均值。但是,我们需要对元素进行排序以找到中值元素,这并不高效,因此,我们尝试找到一种方法来选择一个大致将列表分成中间的枢轴元素。
均值的中位数是一种提供给我们近似中值的方法,即一个接近给定未排序元素列表实际中值的价值。它以这种方式划分给定的元素列表,在最坏的情况下,至少有 10 个中的 3 个(3/10)的列表将位于枢轴元素之下,至少有 10 个中的 3 个的元素将位于列表之上。
让我们通过一个例子来理解这一点。假设我们有一个包含 15 个元素的列表:{11, 13, 12, 111, 110, 15, 14, 16, 113, 112, 19, 18, 17, 114, 115}。
接下来,我们将它分成每组 5 个元素的组,并按以下方式排序:{{11, 12, 13, 110, 111}, {14, 15, 16, 112, 113}, {17, 18, 19, 114, 115}}。
接下来,我们计算这些组中每一组的均值,分别是13、16和19。进一步,这些均值的中位数是16。这是给定列表的中位数的中位数。在这里,我们可以看到有 5 个元素比枢轴元素小,有9个元素比枢轴元素大。当我们选择这个均值的中位数作为枢轴元素时,n个元素的列表被分成这样的一种方式,至少有3n/10个元素比枢轴元素小。
选择第k个最小元素的确定性算法工作如下:
-
将无序项的列表分成每组五个元素(数字 5 不是强制的;它可以改为任何其他数字,例如 8)
-
对这些组进行排序(通常,我们使用插入排序来完成此目的)并找到所有这些组的均值
-
递归地,从这些组中找到中位数的中位数;假设这个点是p
-
使用这个点p作为枢轴元素,递归调用类似于 quickselect 的分区算法来找出第
k个最小的元素
让我们考虑一个包含 15 个元素的例子来理解从列表中找出第 3 个最小元素的确定性算法的工作原理,如图12.2所示。首先,我们将列表分成每组 5 个元素的组,然后对这些组/子列表进行排序。一旦我们有了排序的列表,我们就找出子列表的均值。对于这个例子,项目23、52和34是这三个子列表的均值,如图12.2所示。
接下来,我们对所有子列表的中位数列表进行排序。进一步地,我们找出这个列表的中位数,即中位数的中位数,它是34。这个中位数的中位数被用来选择整个列表的分区/枢轴点。进一步地,我们使用这个枢轴元素来划分给定的列表,将枢轴元素放在列表中的正确位置。对于这个例子,枢轴元素的索引是 7(索引从 0 开始;这显示在图 12.2.中)。

图 12.2:确定性选择算法的逐步过程
枢轴元素的索引大于k(th)值,因此我们递归地在左子列表上调用算法以获得所需的`k`(th)最小元素。
接下来,我们将讨论确定性选择算法的实现。
确定性选择算法的实现
为了实现确定算法以从列表中确定k^(th)最小值,我们开始实现更新的partition()方法,该方法使用中位数的中位数方法选择枢轴元素来划分列表。现在让我们理解partition函数的代码:
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
## This while loop is used to correctly place pivot element at its correct position
while 1:
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
在上述代码中,我们实现了分区方法,这与我们在 quickselect 算法中所做的方法非常相似。在 quickselect 算法中,我们使用了一个随机枢轴元素(为了简单起见,列表的第一个元素),但在确定性选择算法中,我们使用中位数的中位数来选择枢轴元素。分区方法将列表划分为两个子列表——左子列表和右子列表,其中左子列表包含小于枢轴元素的元素,而右子列表包含大于枢轴元素的元素。使用中位数的中位数作为枢轴元素的主要好处是,它通常将列表几乎平分为两半。
在代码的开始部分,首先,在if-else条件中,我们检查给定元素列表的长度。如果列表的长度为 1,则返回该元素的索引,因此如果unsorted_array只有一个元素,first_index和last_index将相等。因此,返回first_index。如果长度大于 1,则调用median_of_medians()方法来计算传递给此方法的列表的中位数的中位数,起始和结束索引分别为first_index和last_index。返回的中位数的中位数值存储在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)[int(len(sublist)/2)])
if len(medians) <= 5:
return sorted(medians)[int(len(medians)/2)]
else:
return median_of_medians(medians)
在上述 median_of_medians 函数的代码中,使用了递归来计算给定列表的中位数的中位数。函数首先将给定的列表 elems 分成每组五个元素的组。如前所述,在确定性算法中,我们将给定的列表分成每组 5 个元素的组。在这里,我们选择 5 个元素,因为它通常表现良好。然而,我们也可以使用任何其他数字。这意味着如果 elems 包含 100 个项目,那么将通过 sublists = [elems[j:j+5] for j in range(0, len(elems), 5)] 语句创建 20 个组,每个组最多包含五个元素。
在创建了每个包含五个元素的子列表之后,我们创建一个空的数组 medians,用于存储每个五个元素数组的(即 sublists)中位数。此外,for 循环遍历 sublists 内部的列表。每个子列表被排序,找到中位数,并将其存储在 medians 列表中。medians.append(sorted(sublist)[len(sublist)//2]) 语句将排序列表并获取其中间索引处的元素。medians 变量成为所有五个元素子列表的中位数列表。在这个实现中,我们使用 Python 的现有排序函数;由于列表的大小很小,它不会影响算法的性能。
此后,下一步是递归地计算中位数的中位数,我们将使用它作为枢轴元素。在此需要注意的是,中位数数组的长度本身也可以是一个大数组,因为如果原始数组的长度是 n,那么中位数数组的长度将是 n/5,对它的排序可能本身就会消耗时间。因此,我们检查 medians 数组的长度,如果它小于 5,我们就对 medians 列表进行排序,并返回其中间索引处的元素。另一方面,如果列表的大小大于五,我们再次递归调用 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]]
每个五个元素的列表将按以下方式排序:
[[1, 2, 3, 5, 5], [7, 11, 12, 13, 16], [6, 8, 9, 10, 17], [15, 18, 19, 20, 23], [14, 21, 22, 24, 25]]
接下来,我们获取它们的平均值,生成以下列表:
[3, 12, 9, 19, 22]
我们对上述列表进行排序:
[3, 9, 12, 19, 22]
由于列表大小为五个元素,我们只需返回排序后的列表的中位数,在这种情况下是 12。否则,如果这个数组的长度大于 5,我们就会再次调用 median_of_median 函数。
一旦我们得到了中位数的中位数值,我们需要找出它在给定列表中的索引。我们编写 get_index_of_nearest_median 函数来完成这个目的。此函数接受由 first 和 last 参数指定的列表的起始和结束索引:
def get_index_of_nearest_median(array_list, first, last, median):
if first == last:
return first
else:
return array_list.index(median)
在分区方法中,我们将中位数的中位数值与列表的第一个元素交换,即使用 swap 函数将 index_of_nearest_median 与 unsorted_array 的 first_index 交换:
swap(unsorted_array, first_index, index_of_nearest_median)
交换两个数组元素的 utility 函数如下所示:
def swap(array_list, first, index_of_nearest_median):
temp = array_list[first]
array_list[first] = array_list[index_of_nearest_median]
array_list[index_of_nearest_median] = temp
我们交换这两个元素。其余的实现与我们在 quick_select 算法中讨论的相当相似。现在,我们得到了给定列表的中位数的中位数,它存储在未排序列表的 first_index 中。
现在,其余的实现与 quick_select 算法的分区方法以及快速排序算法相似,这在 第十一章,排序 中有详细讨论。为了算法的完整性,我们再次讨论这个问题。
我们将第一个元素视为枢轴元素,并取两个指针,即左指针和右指针。左指针从列表的左侧向右移动,以保持枢轴元素左侧的所有小于枢轴元素的元素。它初始化为列表的第二个元素,即 first_index+1,而右指针从列表的右侧向左移动,以保持列表的顺序,使得大于枢轴元素的元素位于列表的枢轴元素右侧。它初始化为列表的最后一个元素。因此,我们有两个变量 less_than_pivot_index(右指针)和 greater_than_pivot_index(左指针),其中 less_than_pivot_index 初始化为 index_of_last_element,greater_than_pivot_index 初始化为 first_index + 1:
less_than_pivot_index = index_of_last_element
greater_than_pivot_index = first_index + 1
接下来,我们移动左指针和右指针,以便在经过一次迭代后,枢轴元素被放置在列表中的正确位置。这意味着它将列表分为两个子列表,左子列表包含所有小于枢轴元素的元素,右子列表包含大于枢轴元素的元素。我们通过以下三个步骤来完成这个操作:
## This while loop is used to correctly place pivot element at its correct position
while 1:
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
-
第一个
while循环将greater_than_pivot_index移到数组的右侧,直到greater_than_pivot_index所指向的元素小于枢轴元素,并且greater_than_pivot_index小于last_index:while unsorted_array[greater_than_pivot_index] < pivot and greater_than_pivot_index < last_index: greater_than_pivot_index += 1 -
在第二个
while循环中,我们将对数组中的less_than_pivot_index执行相同操作。我们将less_than_pivot_index向左移动,直到less_than_pivot_index所指向的元素大于枢轴元素,并且less_than_pivot_index大于或等于first_index:while unsorted_array[less_than_pivot_index] > pivot and less_than_pivot_index >= first_index: less_than_pivot_index -= 1 -
现在,我们检查
greater_than_pivot_index和less_than_pivot_index是否已经交叉或没有。如果greater_than_pivot_index仍然小于less_than_pivot_index(即我们还没有找到枢轴元素的正确位置),我们交换由greater_than_pivot_index和less_than_pivot_index指示的元素,然后我们将再次重复相同的三个步骤。如果它们已经交叉,这意味着我们已经找到了枢轴元素的正确位置,我们将从循环中跳出: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
在退出循环后,变量less_than_pivot_index将指向枢轴的正确索引,因此我们只需交换less_than_pivot_index和pivot_index处的值:
unsorted_array[pivot_index]=unsorted_array[less_than_pivot_index]
unsorted_array[less_than_pivot_index]=pivot
最后,我们只需简单地返回枢轴索引,它存储在变量less_than_pivot_index中。
在分割方法之后,枢轴元素达到其在列表中的正确位置。此后,我们根据所需的k值和枢轴元素的位置递归调用分割方法到子列表之一(左子列表或右子列表),以找到k^(th)最小元素。这个过程与快速选择算法相同。
确定性选择算法的实现如下:
def deterministic_select(array_list, start, end, k):
split = partition(array_list, start, end)
if split == k:
return array_list[split]
elif split < k:
return deterministic_select(array_list, split + 1, end, k)
else:
return deterministic_select(array_list, start, split-1, k)
如您所观察到的,确定性选择算法的实现看起来与快速选择算法完全相同。两者之间的唯一区别是我们如何选择枢轴元素;除此之外,一切相同。
在初始array_list被选定的枢轴元素(即列表的中位数的中位数)分割后,与k^(th)元素进行比较:
-
如果分割点的索引,即
split,等于k所需的值,这意味着我们已经找到了所需的k^(th)最小元素。 -
如果分割点的索引
split小于k所需的值,那么对右子数组进行递归调用,调用为deterministic_select(array_list, split + 1, right, k)。这将寻找数组右侧的k^(th)元素。 -
否则,如果分割索引大于
k的值,那么对左子数组的函数调用为deterministic_select(array_list, left, split-1, k)。
kth smallest element from the list:
list1= [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]
print("The 6th smallest element is", deterministic_select(list1, 0, len(list1)-1, 5))
上述代码的输出如下。
The 6th smallest element is 6
在上述代码的输出中,我们有从给定 25 个元素的列表中得到的第 6(th)小元素。确定性选择算法通过使用中位数的中位数元素作为选择列表中`k`(th)最小元素的枢轴点来改进快速选择算法。它提高了性能,因为中位数的中位数方法在线性时间内找到估计的中位数,当这个估计的中位数被用作快速选择算法中的枢轴点时,最坏情况下的运行时间复杂度从 O(n²)提高到线性 O(n)。
中位数的中位数算法也可以用于在快速排序算法中选择枢轴点以排序元素列表。这显著提高了快速排序算法的最坏情况性能,从 O(n²) 提高到 O(nlogn) 的复杂度。
摘要
在本章中,我们讨论了两种寻找列表中第 k 小元素的重要方法,即随机选择和确定性选择算法。仅仅对列表进行排序以执行寻找第 k 小元素的运算并不是最佳方案,因为我们可以使用更好的方法来确定第 k 小元素。快速选择算法,作为随机选择算法,将列表分为两个子列表。一个列表包含比选定的枢轴元素小的值,另一个列表包含比选定的枢轴元素大的值。我们递归地使用其中一个子列表来找到第 k 小元素的位置,这可以通过在确定性选择算法中使用中位数的中位数方法来选择枢轴点进一步改进。
在下一章中,我们将讨论几个重要的字符串匹配算法。
练习
-
如果将快速选择算法应用于给定的数组,输出将会是什么?
arr = [3, 1, 10, 4, 6, 5],给定k为 2? -
快速选择能否在具有重复值的数组中找到最小元素?
-
快速排序算法与快速选择算法之间的区别是什么?
-
确定性选择算法与快速选择算法的主要区别是什么?
-
什么触发了选择算法的最坏情况行为?
加入我们的 Discord 社区
加入我们的社区 Discord 空间,与作者和其他读者进行讨论:packt.link/MEvK4

第十三章:字符串匹配算法
有许多流行的字符串匹配算法。字符串匹配算法有非常重要的应用,例如在文本文档中搜索元素、抄袭检测、文本编辑程序等。在本章中,我们将研究寻找给定模式或子串在任意给定文本中的位置的模式匹配算法。我们将讨论暴力算法,以及Rabin-Karp、Knuth-Morris-Pratt(KMP)和Boyer-Moore模式匹配算法。本章旨在讨论与字符串相关的算法。本章将涵盖以下主题:
-
学习模式匹配算法及其实现
-
理解和实现Rabin-Karp模式匹配算法
-
理解和实现Knuth-Morris-Pratt(KMP)算法
-
理解和实现Boyer-Moore模式匹配算法
技术要求
本章讨论的概念和算法的所有程序都包含在书中,以及以下链接的 GitHub 仓库中:github.com/PacktPublishing/Hands-On-Data-Structures-and-Algorithms-with-Python-Third-Edition/tree/main/Chapter13。
字符串表示法和概念
字符串是字符序列。Python 提供了一组丰富的操作和函数,可以应用于字符串数据类型。字符串是文本数据,在 Python 中处理得非常高效。以下是一个字符串 (s) 的示例——“packt publishing"。
子串是指给定字符串中字符序列的一部分,即字符串中连续顺序的指定索引。例如,“packt" 是字符串 “packt publishing" 的子串。另一方面,子序列也是从给定字符串中通过删除一些字符(同时保持字符出现的顺序)得到的字符序列。例如,“pct pblishing" 是从字符串 “packt publishing" 中删除字符 a、k 和 u 后得到的有效子序列。然而,这并不是子串,因为 “pct pblishing" 不是一个连续的字符序列。因此,子序列与子串不同,它可以被认为是子串的推广。
前缀 (p) 是字符串 (s) 的子串,因为它位于字符串的开头。在字符串 (s) 中也存在另一个字符串 (u),它在前缀之后。例如,子串 “pack" 是字符串 (s) = "packt publishing" 的前缀,因为它是最初的子串,并且在其后还有另一个子串 u = "publishing"。因此,前缀加上字符串 (u) 构成了 “packt publishing",这就是整个字符串。
后缀(d)是出现在字符串(s)末尾的子字符串。例如,子字符串“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.
上述代码的输出如下:
True
True
在上述给定的字符串示例中,我们可以看到给定的文本字符串以另一个子字符串“publisher”结尾,这是一个有效的后缀,并且还有一个子字符串“this”,它是字符串的开始部分的一个子字符串,也是一个有效的前缀。
注意,这里讨论的模式匹配算法不要与 Python 3.10 的匹配语句混淆。
模式匹配算法是最重要的字符串处理算法,我们将在后续章节中讨论它们,从模式匹配算法开始。
模式匹配算法
模式匹配算法用于确定给定模式字符串(P)在文本字符串(T)中匹配的索引位置。因此,模式匹配算法找到并返回给定字符串模式在文本字符串中出现的索引。如果模式在文本字符串中没有匹配项,则返回"pattern not found"。
例如,对于给定的文本字符串(s) = "packt publisher"和模式字符串(p) = "publisher",模式匹配算法返回模式字符串在文本字符串中匹配的索引位置。一个字符串匹配问题的示例如图13.1所示:

图 13.1:字符串匹配问题的示例
我们将讨论四种模式匹配算法,即贪心方法、Rabin-Karp 算法,以及Knuth-Morris-Pratt(KMP)和 Boyer-Moore 模式匹配算法。我们首先从贪心模式匹配算法开始。
贪心算法
贪心算法也被称为模式匹配算法的朴素方法。朴素方法意味着它是一个非常基础且简单的算法。在这种方法中,我们在给定的文本字符串中匹配所有可能的输入模式的组合,以找到模式出现的位置。这个算法非常朴素,如果文本非常长,则不适合使用。
在这个算法中,我们首先逐个比较模式字符串和文本字符串中的字符,如果模式的所有字符都与文本匹配,我们就返回文本中模式第一个字符所在的位置索引。如果模式中的任何字符与文本字符串不匹配,我们就将模式向右移动一个位置,以检查模式是否出现在下一个索引位置。我们通过将模式向右移动一个索引位置来继续比较模式和文本字符串。
为了更好地理解暴力算法的工作原理,让我们看一个例子。假设我们有一个文本字符串(T) = “acbcabccababcaacbcac”,模式字符串(P)是“acbcac”。现在,模式匹配算法的目标是在给定的文本T中确定模式字符串的索引位置,如图13.2所示:

图 13.2:字符串匹配的暴力算法示例
我们首先比较文本的第一个字符,即a,和模式的第一个字符。在这里,模式的前五个字符被匹配,然后在模式的最后一个字符处出现不匹配。这是一个不匹配,因此我们将模式向右移动一个位置。我们再次从比较模式的第一个字符和文本字符串的第二个字符开始逐个比较。在这里,文本字符串中的字符c与模式的字符a不匹配。因此,这也是一个不匹配,我们将模式向右移动一个空格,如图13.2所示。我们继续比较模式和文本字符串中的字符,直到我们遍历整个文本字符串。在这个例子中,我们在索引位置14找到一个匹配,如图13.2所示。
让我们考虑模式匹配的暴力算法的 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 occurred 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 occurs 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 occur at all, means no match of pattern in the text string
print('\nPattern is not at all present in the array')
'acbcac' in the given string:
brute_force('acbcabccababcaacbcac','acbcac') # function call
上面的函数调用输出如下:
Pattern occurs at index 14
在前面的暴力方法代码中,我们首先计算给定文本字符串和模式的长度。我们还用0初始化循环变量,并将标志设置为False。
这个变量用于在字符串中继续搜索模式的匹配。如果flag变量在文本字符串结束时为False,则意味着在文本字符串中根本找不到模式的匹配。
接下来,我们从文本字符串的0(th)索引开始搜索循环,直到文本字符串的末尾。在这个循环中,我们有一个`count`变量,用于跟踪模式和文本匹配到的长度。接下来,我们有一个嵌套循环,从`0`(th)索引运行到模式的长度。在这里,变量i跟踪文本字符串中的索引位置,变量j跟踪模式中的字符。接下来,我们使用以下代码片段比较模式和文本字符串中的字符:
if i+j<l1 and text[i+j] == pattern[j]:
此外,我们在文本字符串中模式字符的每次匹配后增加count变量。然后,我们继续匹配模式和文本字符串中的字符。如果模式的长度等于count变量,则表示有一个匹配。
如果在文本字符串中找到与模式字符串匹配的索引位置,我们将打印文本字符串的索引位置,并将flag变量保持为True,因为我们希望继续在文本字符串中搜索更多模式的匹配。最后,如果flag变量的值为False,则表示在文本字符串中根本找不到该模式的匹配。
原始字符串匹配算法的最佳和最坏情况时间复杂度分别为O(n)和O(m*(n-m+1))。最佳情况发生在模式在文本中找不到,并且模式的第一个字符根本不在文本中,例如,如果文本字符串是ABAACEBCCDAAEE,而模式是FAA。在这里,由于模式的第一个字符将在文本的任何地方都找不到匹配项,它将进行的比较等于文本的长度(n)。
最坏情况发生在文本字符串和模式的全部字符都相同,并且我们想要找出文本字符串中给定模式字符串的所有出现,例如,如果文本字符串是AAAAAAAAAAAAAAAA,而模式字符串是AAAA。另一个最坏情况发生在只有最后一个字符不同,例如,如果文本字符串是AAAAAAAAAAAAAAAF,而模式是AAAAF。因此,比较的总数将是m*(n-m+1),最坏情况时间复杂度将是O(m*(n-m+1))。
接下来,我们讨论 Rabin-Karp 模式匹配算法。
Rabin-Karp 算法
Rabin-Karp 模式匹配算法是寻找给定模式在文本字符串中位置的暴力方法的改进版本。通过哈希减少比较次数来提高 Rabin-Karp 算法的性能。我们在第八章,哈希表中讨论了哈希的概念。哈希函数为给定的字符串返回一个唯一的数值。
与暴力方法相比,此算法更快,因为它避免了不必要的比较。在此算法中,我们比较模式与文本字符串子字符串的哈希值。如果哈希值不匹配,则将模式向前移动一个位置。与暴力算法相比,这是一个更好的算法,因为不需要逐个比较模式的全部字符。
此算法基于以下概念:如果两个字符串的哈希值相等,则假设这两个字符串也相等。然而,也可能存在两个不同的字符串,它们的哈希值相等。在这种情况下,算法将不起作用;这种情况被称为虚假匹配,是由于哈希中的冲突引起的。为了避免这种情况,在 Rabin-Karp 算法中,在匹配模式和子字符串的哈希值之后,我们通过逐字符比较模式和子字符串来确保模式实际上在字符串中匹配。
Rabin-Karp 模式匹配算法的工作原理如下:
-
首先,我们在开始搜索之前对模式进行预处理,即计算长度为
m的模式及其所有可能的长度为m的文本子串的哈希值。可能的子串总数将是 (n-m+1)。在这里,n是文本的长度。 -
我们逐个比较模式与文本子串的哈希值。
-
如果哈希值不匹配,则我们将模式向右移动一个位置。
-
如果模式和文本子串的哈希值匹配,则我们逐字符比较模式和子串,以确保模式实际上在文本中匹配。
-
我们继续执行 步骤 2-5 的过程,直到达到给定文本字符串的末尾。
在此算法中,我们使用霍纳法则(任何其他哈希函数也可以使用)计算数值哈希值,该法则为给定字符串返回一个唯一值。我们还使用字符串中所有字符的序数值之和来计算哈希值。
让我们通过一个例子来理解 Rabin-Karp 算法。假设我们有一个文本字符串 (T) = "publisher paakt packt",以及模式 (P) = "packt"。首先,我们计算模式(长度 m)和文本字符串的所有子串(长度 m)的哈希值。Rabin-Karp 算法 的功能在 图 13.3 中展示:

图 13.3:Rabin-Karp 字符串匹配算法的示例
我们开始比较模式 "packt" 的哈希值与第一个子串 "publi" 的哈希值。由于哈希值不匹配,我们将模式向右移动一个位置,然后我们比较模式与文本的下一个子串的哈希值,即 "ublis"。由于这些哈希值也不匹配,我们再次将模式向右移动一个位置。如果哈希值不匹配,我们每次移动一个位置。如果模式和子串的哈希值匹配,我们逐字符比较模式和子串,并在它们匹配时返回文本字符串的位置。
在 图 13.3 所示的示例中,模式和文本子串的哈希值在位置 17 匹配。
需要注意的是,可能存在不同的字符串,其哈希值可以与模式的哈希值匹配,即虚假匹配。
接下来,让我们讨论 Rabin-Karp 模式匹配算法 的实现。
实现 Rabin-Karp 算法
Rabin-Karp 算法 的实现分为两个步骤:
-
我们实现了
generate_hash()方法,用于计算模式及其所有可能的长度等于模式长度的子串组合的哈希值。 -
我们实现了 Rabin-Karp 算法,该算法使用
generate_hash()方法来识别与模式哈希值匹配的子字符串。最后,我们逐字符匹配它们以确保正确找到了模式。
让我们先讨论生成模式和文本子字符串的哈希值的实现。为此,我们首先需要决定哈希函数。在这里,我们使用字符串中所有字符的序数值之和作为哈希函数。
完整的 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
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.
hash_pattern = sum(ord_pattern)
for i in range(0,len_hash_array): # step size of the loop will be the size of the pattern
if i == 0: # Base condition
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
[i+len_pattern-1]) # calculating next hash value using previous value
return [hash_text, hash_pattern] # return the hash values
在上述代码中,我们首先将文本和模式的全部字符的序数值存储在 ord_text 和 ord_pattern 变量中。接下来,我们将文本和模式的长度存储在 len_text 和 len_pattern 变量中。
接下来,我们创建一个名为 len_hash_array 的变量,它存储了长度(等于模式的长度)的所有可能子字符串的数量,使用 len_text - len_pattern + 1,并且我们创建一个名为 hash_text 的数组,它存储了所有可能子字符串的哈希值。这在上面的代码片段中显示:
len_hash_array = len_text - len_pattern + 1
hash_text = [0]*(len_hash_array)
接下来,我们通过以下代码片段计算模式的哈希值,即通过求模式中所有字符的序数值之和:
hash_pattern = sum(ord_pattern)
接下来,我们开始一个循环,该循环对文本的所有可能子字符串执行。为此,最初,我们通过使用 sum(ord_text[:len_pattern]) 求所有字符的序数值来计算第一个子字符串的哈希值。进一步,使用前一个子字符串的哈希值来计算所有子字符串的哈希值,如以下代码片段所示:
hash_text[i] = ((hash_text[i-1] - ord_text[i-1]) + ord_text[i+len_pattern-1])
因此,我们在 Rabin-Karp 算法 的实现中预先计算了模式的哈希值和文本的所有子字符串的哈希值,这些我们将用于比较模式和文本。Rabin-Karp 算法的工作原理如下。首先,我们比较模式和文本子字符串的哈希值。接下来,我们取与模式哈希值匹配的子字符串,并逐字符比较它们。
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 # count the total characters upto which both are similar
for j in range(len_pattern):
if pattern[j] == text[i+j]: # checking equality for each character
count += 1 # if value is equal, then update the count value
else:
break
if count == len_pattern: # if count is equal to length of pattern, it means there is a match
flag = True # update flag accordingly
print('Pattern occurs at index',i)
if not flag: # if pattern doesn't match even once, then this if statement is executed
print('Pattern is not at all present in the text')
在上述代码中,首先,我们将给定的文本和模式转换为字符串格式,因为只有字符串才能计算序数值。然后,我们使用 generate_hash 函数来计算模式和文本的哈希值。我们在 len_text 和 len_pattern 变量中存储文本和模式的长度。我们还初始化 flag 变量为 False,以便跟踪模式是否至少在文本中出现一次。
接下来,我们启动一个循环,该循环实现了算法的主要概念。这个循环执行hash_text的长度,即可能的子字符串总数。最初,我们通过使用if hash_text[i] == hash_pattern比较第一个子字符串的哈希值与模式的哈希值。如果它们不匹配,我们移动一个索引位置并寻找另一个子字符串。我们迭代地移动,直到我们得到一个匹配项。
如果我们找到一个匹配项,我们通过使用if pattern[j] == text[i+j]通过循环逐个字符比较子字符串和模式。
然后,我们创建一个count变量来跟踪模式与子字符串匹配的字符数量。如果count的长度与模式的长度相等,这意味着所有字符都匹配,并且返回模式被找到的索引位置。最后,如果flag变量保持False,这意味着模式与文本完全不匹配。以下代码片段可以用来执行Rabin-Karp 匹配算法:
Rabin_Karp_Matcher("101110000011010010101101","1011")
Rabin_Karp_Matcher("ABBACCADABBACCEDF","ACCE")
上述代码的输出如下:
Pattern occurs at index 0
Pattern occurs at index 18
Pattern occurs at index 11
在上述代码中,我们首先检查模式“1011"是否出现在给定的文本字符串“101110000011010010101101"中。输出显示,给定模式出现在索引位置0和18。接下来,模式“ACCE"在文本字符串“ABBACCADABBACCEDF"中的索引位置11出现。
Rabin-Karp 模式匹配算法在搜索之前预处理模式;也就是说,它计算具有复杂度O(m)的模式的哈希值。此外,Rabin-Karp 算法的最坏情况运行时间复杂度为O(m *(n-m+1))。最坏的情况是模式根本不在文本中出现。平均情况是模式至少出现一次。
接下来,我们将讨论 KMP 字符串匹配算法。
Knuth-Morris-Pratt 算法
KMP 算法是一种基于重叠文本在模式本身可以用来立即知道在任何不匹配时模式应该移动多少以跳过不必要的比较的匹配算法。在这个算法中,我们将预计算prefix函数,该函数指示在发生不匹配时模式所需的移动次数。KMP 算法通过使用prefix函数预处理模式来避免不必要的比较。因此,该算法利用prefix函数来估计在发生不匹配时模式应该移动多少以在文本字符串中搜索模式。KMP 算法效率高,因为它最小化了给定模式与文本字符串的比较次数。
KMP 算法背后的动机可以在图 13.4中观察到。在这个例子中,可以看到在匹配了前 5 个字符后,在第 6 个位置发生了不匹配,最后一个字符是“d”。从prefix函数中也可以知道,字符“d”在模式中之前没有出现过,利用这一信息,模式可以向前移动六个位置:

图 13.4:KMP 算法的示例
因此,在这个例子中,模式移动了六个位置而不是一个。让我们讨论另一个例子来理解 KMP 算法的概念,如图图 13.5所示:
图 13.5:KMP 算法的第二个示例
在上面的例子中,不匹配发生在模式的最后一个字符处。由于不匹配位置的模式有一个前缀bc的部分匹配,这个信息由prefix函数提供。在这里,模式可以移动以与模式中匹配的前缀bc的其他出现对齐。
我们将在了解如何使用prefix函数来确定应该移动模式多少之后,进一步探讨prefix函数。
前缀函数
prefix函数(也称为失败函数)在模式中找到模式。它找出由于模式本身的重复而在不匹配时可以重用多少前一次的比较。prefix函数为每个发生不匹配的位置返回一个值,这告诉我们模式应该移动多少。
让我们通过以下示例来了解我们如何使用prefix函数来找到所需的移动量。考虑第一个例子:如果我们有一个所有字符都不同的模式的prefix函数,那么prefix函数的值将是0。这意味着如果我们找到任何不匹配,模式将根据模式中该位置之前的字符数进行移动。
考虑一个模式abcde的例子,其中包含所有不同的字符。我们开始将模式的第一个字符与文本字符串的第一个字符进行比较,如图图 13.6所示。如图所示,模式在第 4 个字符处发生不匹配。由于前缀函数的值为 0,这意味着模式中没有重叠,并且不会重用之前的比较,因此模式将移动到那个点之前比较的字符数:

图 13.6:KMP 算法中的前缀函数
让我们考虑另一个例子来更好地理解prefix函数是如何对模式(P)abcabbcab工作的,如图图 13.7所示:

图 13.7:KMP 算法中前缀函数的示例
在图 13.7中,我们从索引1开始计算prefix函数的值。如果没有字符在模式中重复,我们赋值为0。因此,在这个例子中,我们将0赋给索引位置 1 到 3 的prefix函数。接下来,在索引位置4,我们可以看到一个字符,a,它是模式本身第一个字符的重复,所以我们在这里赋值为1,如图图 13.8所示:

图 13.8:KMP 算法中索引 4 处的prefix函数值
接下来,我们查看位置 5 的下一个字符。它具有最长的后缀模式ab,因此它的值应该是2,如图图 13.9所示:

图 13.9:KMP 算法中索引 5 处的prefix函数值
同样,我们查看下一个索引位置6。在这里,字符是b。这个字符在模式中没有最长的后缀,因此它的值是0。接下来,我们在索引位置7赋值0。然后,我们查看索引位置8,我们将其赋值为1,因为它有长度为1的最长后缀。
最后,在索引位置9,我们有2的最长后缀。这如图图 13.10所示:

图 13.10:KMP 算法中索引 6 到 9 的prefix函数值
prefix函数的值显示了如果发生不匹配,字符串的开始部分可以重用多少。例如,如果比较在索引位置5处失败,prefix函数的值是2,这意味着两个起始字符不需要比较,模式可以相应地移动。
接下来,我们讨论KMP 算法的细节。
理解 KMP 算法
KMP 模式匹配算法检测模式本身的重叠,从而避免不必要的比较。KMP 算法背后的主要思想是根据模式的重叠检测模式应该移动多少。算法的工作方式如下:
-
首先,我们为给定的模式预计算
prefix函数,并初始化一个计数器q,它代表匹配的字符数。 -
我们首先比较模式的第一个字符与文本字符串的第一个字符,如果匹配,则增加模式计数器q和文本字符串计数器,并比较下一个字符。
-
如果存在不匹配,则我们将预计算的
prefix函数的值赋给q的索引值。 -
我们继续在文本字符串中搜索模式,直到达到文本的末尾,即如果我们没有找到任何匹配。如果模式中的所有字符都在文本字符串中匹配,我们返回模式在文本中匹配的位置,并继续搜索另一个匹配。
让我们考虑以下例子来理解 KMP 算法的工作原理。我们有一个模式 acacac,以及从 1 到 6 的索引位置(为了简单起见,我们将索引位置从 1 开始而不是 0),如图 图 13.11 所示。给定模式的 prefix 函数如图 图 13.11 所示构建:

图 13.11:模式 “acacac” 的前缀函数
让我们通过一个例子来了解我们如何使用 prefix 函数根据 图 13.12 中给出的文本字符串和模式来偏移模式,以理解 KMP 算法。我们从逐个比较模式和文本字符开始。当我们在第 6 个索引位置不匹配时,我们看到该位置的偏移值是 2。然后我们根据 prefix 函数的返回值偏移模式。接下来,我们从模式上的索引位置 2(字符 c)开始,比较模式和文本字符串,并与文本字符串中的字符 b 进行比较。由于这是一个不匹配,模式将根据该位置的 prefix 函数值进行偏移。这一描述如图 图 13.12 所示:

图 13.12:模式根据前缀函数的返回值进行偏移
现在,让我们再看一个如图 图 13.13 所示的例子,其中显示了模式在文本上的位置。当我们开始比较字符 b 和 a 时,它们不匹配,我们看到索引位置 1 的 prefix 函数显示的值是 0,这意味着模式中没有文本重叠。因此,我们将模式偏移 1 个位置,如图 图 13.12 所示。接下来,我们逐个比较模式和文本字符串,并在文本的索引位置 10 之间找到字符 b 和 c 之间的不匹配。
在这里,我们使用预先计算的前缀函数来偏移模式——因为 prefix_function(4) 的返回值是 2,我们将模式偏移以使其与文本对齐,位于模式索引位置 2 的文本。之后,我们在索引位置 10 比较字符 b 和 c,由于它们不匹配,我们将模式偏移一个位置。这一过程如图 图 13.13 所示:

图 13.13:根据前缀函数的返回值偏移模式
让我们从索引位置 11 继续搜索,如图 图 13.14 所示。接下来,我们比较文本中索引位置 11 的字符,并继续比较,直到找到不匹配。我们在索引位置 12 找到字符 b 和 c 之间的不匹配,如图 图 13.14 所示。由于 prefix_function(2) 的返回值是 0,我们将模式偏移并移动到不匹配字符旁边。我们重复相同的过程,直到达到字符串的末尾。我们在文本字符串中找到模式匹配的位置,位于文本字符串的索引位置 13,如图 图 13.14 所示:

图 13.14:索引位置 11 到 18 的模式移动
KMP 算法有两个阶段:首先,预处理阶段,在这里我们计算prefix函数,它具有O(m)的空间和时间复杂度。其次,第二阶段涉及搜索,KMP 算法的搜索时间复杂度为O(n)。因此,KMP 算法的最坏情况时间复杂度为O(m + n)。
现在,我们将讨论使用 Python 实现KMP 算法。
实现 KMP 算法
这里解释了 KMP 算法的 Python 实现。我们首先实现给定模式的prefix函数。prefix函数的代码如下:
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
在上述代码中,我们首先使用len()函数计算模式的长度,然后初始化一个列表来存储prefix函数计算出的值。
接下来,我们开始一个从 2 到模式长度的循环。然后,我们有一个嵌套循环,它执行直到我们处理完整个模式。变量k被初始化为0,这是模式的第一个元素的prefix函数。如果模式的k(th)元素等于`q`(th)元素,那么我们将k的值增加1。k的值是prefix函数计算出的值,因此我们将它赋值给模式中的q索引位置。最后,我们返回包含模式每个字符计算值的prefix函数的列表。
一旦我们创建了prefix函数,我们就实现主要的KMP 匹配算法。以下代码详细展示了这一点:
def KMP_Matcher(text,pattern): # KMP matcher function
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 occurs at positions ",i-n) # print the index, where first match occurs.
flag = True
q = prefix_fun[q]
if not flag:
print('\nNo match found')
在上述代码中,我们首先计算文本字符串和模式的长度,分别存储在变量m和n中。接下来,我们定义一个变量flag来指示模式是否找到匹配。进一步,我们在文本和模式中添加一个虚拟字符-,以便从索引1而不是索引0开始索引。接下来,我们调用pfun()方法,使用prefix_fun = pfun(pattern)构建包含模式所有位置的prefix值的数组。接下来,我们执行一个从1到m+1的循环,其中m是模式的长度。进一步,对于for循环的每次迭代,我们使用while循环比较模式和文本,直到我们完成模式的搜索。
如果发生不匹配,我们使用索引q处的prefix函数的值(在这里,q是不匹配发生的索引)来确定我们需要将模式移动多少位。如果模式和文本相等,那么1和n的值将相等,并且我们可以返回模式在文本中匹配的索引。此外,当在文本中找到模式时,我们将flag变量更新为True。如果我们已经搜索了整个文本字符串,但变量flag仍然是False,这意味着模式在给定的文本中不存在。
以下代码片段可以用来执行字符串匹配的 KMP 算法:
KMP_Matcher('aabaacaadaabaaba','aabaa') # function call, with two parameters, text and pattern
上述代码的输出如下:
Pattern occurs at positions 0
Pattern occurs at positions 9
在上述输出中,我们看到模式在给定文本字符串中的索引位置为 0 和 9。
接下来,我们将讨论另一种模式匹配算法,即 Boyer-Moore 算法。
Boyer-Moore 算法
正如我们之前讨论的,字符串模式匹配算法的主要目标是尽可能通过避免不必要的比较来找到跳过比较的方法。
Boyer-Moore 模式匹配算法是另一种这样的算法(与 KMP 算法一起),通过使用不同的方法跳过比较来进一步提高模式匹配的性能。为了理解 Boyer-Moore 算法,我们必须理解以下概念:
-
在这个算法中,我们像 KMP 算法一样从左到右移动模式。
-
我们从右到左比较模式和文本字符串的字符,这与我们在 KMP 算法中的做法相反。
-
该算法通过使用好的后缀和坏字符偏移启发式方法来跳过不必要的比较。这些启发式方法本身找到可以跳过的可能比较次数。我们使用这两个启发式方法建议的最大偏移量在给定文本上滑动模式。
让我们了解所有关于这些启发式方法以及 Boyer-Moore 模式匹配算法的工作细节。
理解 Boyer-Moore 算法
Boyer-Moore 算法从右到左比较模式与文本,这意味着在这个算法中,如果模式的末尾与文本不匹配,则可以移动模式,而不是检查文本中的每个字符。关键思想是模式与文本对齐,并且比较模式的最后一个字符与文本,如果不匹配,则不需要继续逐个字符比较,我们可以直接移动模式。
在这里,我们移动模式的大小取决于不匹配的字符。如果文本中的不匹配字符不在模式中,这意味着我们可以通过整个模式长度来移动模式,而如果模式中某处有不匹配的字符,那么我们部分地移动模式,使得不匹配的字符与模式中该字符的其他出现对齐。
此外,在这个算法中,我们还可以看到模式匹配的部分(与匹配的后缀一起),因此我们利用这一信息,通过跳过任何不必要的比较来对齐文本和模式。通过在文本上跳过模式,以减少比较次数,而不是将模式中的每个字符与文本中的每个字符进行比较,这是高效字符串匹配算法的主要思想。
Boyer-Moore 算法背后的概念在图 13.15中展示:

图 13.15:Boyer-Moore 算法概念示例
在图 13.15 所示的示例中,模式中的字符b与文本中的字符d不匹配,由于不匹配的字符d在模式中任何地方都没有出现,因此我们可以将整个模式进行位移。在第二次不匹配中,我们可以看到文本中的不匹配字符a在模式中存在,因此我们将模式位移以与该字符对齐。这个例子展示了我们如何跳过不必要的比较。接下来,我们将进一步讨论算法的细节。
当我们找到不匹配时,Boyer-Moore 算法有两个启发式算法来确定模式可能的最大位移:
-
坏字符启发式算法
-
好后缀启发式算法
在不匹配时,这些启发式算法都会建议可能的位移,而 Boyer-Moore 算法会根据坏字符和好后缀启发式算法给出的最大位移,将模式在文本字符串上移动更长的距离。坏字符和好后缀启发式算法的详细内容将在以下小节中用示例进行解释。
坏字符启发式算法
Boyer-Moore 算法以从右到左的方向比较模式和文本字符串。它使用坏字符启发式算法来移动模式,我们从模式末尾开始逐个字符比较,如果它们匹配,则比较倒数第二个字符,如果这也匹配,则重复此过程,直到整个模式匹配或出现不匹配。
文本中的不匹配字符也称为坏字符。如果在过程中出现任何不匹配,我们将根据以下条件之一将模式进行位移:
-
如果文本中的不匹配字符在模式中不存在,则将模式移至不匹配字符旁边。
-
如果不匹配的字符在模式中只有一个出现,那么我们将模式移动到与不匹配字符对齐的位置。
-
如果不匹配的字符在模式中出现了多次,那么我们将进行最小的位移,以使模式与该字符对齐。
让我们通过示例来理解这三种情况。考虑一个文本字符串(T)和模式 = {acacac}。我们首先从右到左比较字符,即模式中的字符c和文本字符串中的字符b。由于它们不匹配,我们在模式中寻找文本字符串的不匹配字符(即b)。由于坏字符b在模式中不存在,我们将模式移至不匹配字符旁边,如图 13.16 所示:

图 13.16:Boyer-Moore 算法中坏字符启发式算法的示例
让我们再举一个例子,给定一个文本字符串和模式={acacac},如图图 13.17所示。对于这个例子,我们从右到左比较文本字符串和模式的字符,并得到文本中字符d的不匹配。在这里,后缀ac是匹配的,但字符d和c不匹配,不匹配的字符d没有出现在模式中。因此,我们将模式移到不匹配字符旁边,如图图 13.17所示:

图 13.17:Boyer-Moore 算法中坏字符启发式的第二个例子
让我们考虑一个例子来理解给定文本字符串和模式(如图图 13.18所示)的坏字符启发式的第二和第三种情况。在这里,后缀ac是匹配的,但接下来的字符a和c不匹配,因此我们在模式中搜索不匹配字符a的出现。由于它在模式中有两个出现,我们有两种选择来改变模式以使其与不匹配的字符对齐。这两种选择都如图图 13.18所示:
在我们有多于一个选项来改变模式的情况下,我们应用尽可能少的改变次数以防止错过任何可能的匹配。另一方面,如果我们模式中只有一个不匹配字符的出现,我们可以轻松地改变模式,使不匹配的字符对齐。因此,在这个例子中,我们更倾向于选择 1 号选项来改变模式,如图图 13.18所示:

图 13.18:Boyer-Moore 算法中坏字符启发式的第三个例子
我们已经讨论了坏字符启发式,接下来我们将考虑好的后缀启发式。
好的后缀启发式
坏字符启发式并不总是为模式改变提供好的建议。Boyer-Moore 算法也使用好的后缀启发式来在文本字符串上改变模式,这是基于匹配的后缀。在这个方法中,我们将模式向右移动,使得模式的匹配后缀与模式中相同后缀的另一个出现对齐。
它的工作原理是这样的:我们首先从右到左比较模式和文本字符串,如果我们发现任何不匹配,然后我们检查模式中已经匹配的后缀的出现,这被称为好的后缀。
在这种情况下,我们改变模式的方式是使另一个好的后缀的出现与文本对齐。好的后缀启发式有两个主要情况:
-
匹配后缀在模式中有一个或多个出现
-
匹配后缀的一部分出现在模式的开始处(这意味着匹配后缀的后缀作为模式的词首存在)
让我们通过以下示例来理解这些情况。假设我们有一个给定的文本字符串和如图 图 13.19 所示的模式 acabac。我们从右向左比较字符,并且我们在文本字符串的 a 字符和模式的 b 字符之间得到一个不匹配。在这个不匹配的点,我们已经有了一个匹配的尾部 ac,这被称为“良好后缀”。现在,我们在模式中搜索另一个良好后缀 ac 的出现(在这个例子中,它在模式的起始位置),并将模式移动以与该后缀对齐,如图 图 13.19 所示:

图 13.19:Boyer-Moore 算法中良好后缀启发式算法的示例
让我们再举一个例子来理解良好后缀启发式算法。考虑 图 13.18 中给出的文本字符串和模式。在这里,我们在字符 a 和 c 之间得到一个不匹配,并且我们得到一个良好后缀 ac。在这里,我们有两种将模式移动以与良好后缀字符串对齐的选项。
在我们有多个移动模式选项的情况下,我们选择移动次数较少的选项。因此,在这个例子中,我们选择选项 1,如图 图 13.20 所示:

图 13.20:Boyer-Moore 算法中良好后缀启发式算法的第二个示例
让我们看看 图 13.19 中显示的文本字符串和模式的另一个示例。在这个例子中,我们得到一个良好后缀字符串 aac,并且文本字符串的 b 字符与模式的 a 字符不匹配。现在,我们在模式中搜索良好后缀 aac,但没有找到另一个出现。当这种情况发生时,我们检查模式的前缀是否与良好后缀的尾部匹配,如果是,我们将模式移动以与它对齐。
对于这个例子,我们发现模式开头的 ac 前缀与完整的好后缀不匹配,但与良好后缀 aac 的尾部 ac 匹配。在这种情况下,我们将模式通过与也是模式前缀的 aac 尾部对齐来移动,如图 图 13.21 所示:

图 13.21:Boyer-Moore 算法中良好后缀启发式算法的第三个示例
对于给定的文本字符串和模式,良好后缀启发式算法的另一个情况如图 图 13.22 所示。在这个例子中,我们比较文本和模式,并找到良好后缀 aac,并且文本的 b 字符与模式的 a 字符不匹配。
接下来,我们在模式中搜索匹配的良好后缀,但模式中没有后缀的出现,也没有任何模式的前缀与良好后缀的尾部匹配。因此,在这种情况下,我们将模式在匹配的良好后缀之后进行移动,如图 图 13.22 所示:

图 13.22:Boyer-Moore 算法中良好后缀启发式算法的第四个示例
在 Boyer-Moore 算法中,我们计算由坏字符和良好后缀启发式方法给出的位移。进一步地,我们通过坏字符和良好后缀启发式方法给出的距离中的较长者来移动模式。
Boyer-Moore 算法在模式预处理阶段的时间复杂度为O(m),搜索阶段的时间复杂度为O(mn),其中m是模式的长度,n是文本的长度。
接下来,让我们讨论 Boyer-Moore 算法的实现。
实现 Boyer-Moore 算法
让我们了解 Boyer-Moore 算法的实现。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)
以下是前面代码中每个语句的解释。最初,我们有文本字符串和模式。初始化变量后,我们开始一个while循环,首先比较模式的最后一个字符与文本的相应字符。
然后,通过使用从模式的最后一个索引到模式第一个字符的嵌套循环,从右到左比较字符。这使用了range(len(pattern)-1, -1, -1)。
外部while循环跟踪文本字符串中的索引,而内部for循环跟踪模式中的索引位置。
接下来,我们通过使用pattern[j] != text[i+j]来按字符比较。如果它们不匹配,我们将flag变量设置为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 变量中。这是模式,它通过指令 gsshift=i+j+k-pattern[0:len(pattern)-1].rfind(text[i+j+k:i+len(pattern)]) 将模式的好后缀与文本中的好后缀匹配到一起。此外,我们计算了由于坏字符启发式导致的可能位移的数量,并将其存储在 bcshift 变量中。当模式中存在坏字符时,可能的位移数量是 i+j-pattern[0:j].rfind(text[i+j]),而在模式中不存在坏字符的情况下,可能的位移数量将是 i+j+1。
接下来,我们使用指令 i=max((bcshift, gsshift)) 通过坏字符和好后缀启发式给出的最大移动次数在文本字符串上移动模式。最后,我们检查 flag 变量是否为 True。如果是 True,这意味着已经找到了模式,并且匹配的索引已经存储在 matched_indexes 变量中。
我们已经讨论了 Boyer-Moore 模式匹配算法的概念,这是一个高效的算法,它通过使用坏字符和好后缀启发式来跳过不必要的比较。
概述
在本章中,我们讨论了在实时场景中有广泛应用的最多和最重要的字符串匹配算法。我们讨论了暴力、Rabin-Karp、KMP 和 Boyer-Moore 模式匹配算法。在字符串匹配算法中,我们试图揭示跳过不必要的比较并将模式尽可能快地移动到文本上的方法。KMP 算法通过查看模式本身中的重叠子串来检测不必要的比较,以避免冗余比较。此外,我们还讨论了Boyer-Moore 算法,当文本和模式很长时,它非常高效。这是实际应用中用于字符串匹配的最流行算法。
练习
-
展示模式
"aabaabcab"的 KMPprefix函数。 -
如果期望的有效位移数量很小,且模数大于模式的长度,那么 Rabin-Karp 算法的匹配时间是多少?
-
Θ(m)
-
大 O(n+m)
-
Θ(n-m)
-
大 O(n)
-
-
当在文本
T = "3141512653849792"中寻找模式P = "26"的所有出现时,Rabin-Karp 字符串匹配算法在模q = 11和字母表集合Σ = {0, 1, 2,..., 9}上遇到多少个虚假匹配? -
Rabin-Karp 算法中用于将计算时间作为 Θ(m) 的基本公式是什么?
-
二分法
-
Horner 规则
-
求和引理
-
抵消引理
-
-
Rabin-Karp 算法可用于在文本文档中检测剽窃。
-
正确
-
错误
-
加入我们的 Discord 社区
加入我们社区的 Discord 空间,与作者和其他读者进行讨论:packt.link/MEvK4

附录
答案到问题
第二章:算法设计简介
问题 1
求以下 Python 代码片段的时间复杂度:
-
i=1 while(i<n): i*=2 print("data") -
i =n while(i>0): print("complexity") i/ = 2 -
for i in range(1,n): j = i while(j<n): j*=2 -
i=1 while(i<n): print("python") i = i**2
解答
-
复杂度将是
O(log(n))。在每一步中,我们将整数
i乘以2,因此将会有恰好log(n)步。(1,2,4,……直到n)。 -
复杂度将是
O(log(n))。在每一步中,我们将整数
i除以2,因此将会有恰好log(n)步。(n,n/2,n/4,……直到1)。 -
外层循环将为每个外层循环中的
i运行n次,而内层while循环将运行log(i)次,因为我们正在将每个j值乘以2,直到它小于n。因此,内层循环中最多将有log(n)步。因此,整体复杂度将是O(nlog(n))。while loop will execute based on the value of i until the condition becomes false. The value of i is incrementing in the following series:2, 4, 16, 256, ... n我们可以看到,对于给定的
n值,循环执行的次数是 log2)。因此,对于这个序列,循环将恰好执行 log2)次。因此,时间复杂度将是 O(log2))。
第三章:算法设计技术和策略
问题 1
当使用自顶向下的动态规划方法解决与空间和时间复杂度相关的问题时,以下哪个选项将是正确的?
-
它将同时增加时间和空间复杂度
-
它将增加时间复杂度,并降低空间复杂度
-
它将增加空间复杂度,并降低时间复杂度
-
它将同时降低时间和空间复杂度
解答
选项 c 是正确的。
由于自顶向下的动态规划方法使用记忆化技术,它存储子问题的预计算解,这避免了重复计算相同的子问题,从而降低了时间复杂度,但同时也因为存储额外的子问题解而增加了空间复杂度。
问题 2
使用贪心算法,以下边权有向图中的节点序列将是什么(假设节点A为源节点)?

图 A.1:加权有向图
解答
A, B, C, F, E, D
在迪杰斯特拉算法中,在每一步,我们选择权重最小的边,它从迄今为止找到的最短路径中的任何一个顶点开始,并将其添加到最短路径中。
问题 3
考虑表 3.8中的物品的重量和价值。注意,每种物品只有一个单位。
| 物品 | 重量 | 价值 |
|---|---|---|
| A | 2 | 10 |
| B | 10 | 8 |
| C | 4 | 5 |
| D | 7 | 6 |
表 A.1:不同物品的重量和价值
我们需要最大化价值;最大重量应为 11 公斤。不允许分割任何物品。使用贪心算法确定物品的价值。
解答
首先,我们选择了项目 A(重量 2 公斤),因为其值是最大的(10)。第二高的值是项目 B,但由于总重量达到 12 公斤,这违反了给定的条件,因此我们不能选择它。下一个最高的值是项目 D,现在总重量变为 2+7 = 9 公斤(项目 A + 项目 D)。下一个剩余的项目 C 不能选择,因为添加它后,总重量条件将被违反。
因此,使用贪婪方法选择的物品的总价值 = 10 + 6 = 16
第四章:链表
问题 1
在链表中插入数据元素后,指向该元素的指针的时间复杂度是多少?
解决方案
这将是 O(1),因为不需要遍历列表以到达要添加新元素的目标位置。有一个指针指向当前位置,可以直接通过链接添加新元素。
问题 2
确定给定链表长度的时间复杂度是多少?
解决方案
O(n)。
为了找出长度,必须遍历列表中的每个节点,这将花费 O(n)。
问题 3
在长度为 n 的单链表中搜索给定元素的最坏情况时间复杂度是多少?
解决方案
O(n).
在最坏的情况下,要搜索的数据元素将在列表的末尾,或者根本不在列表中。在这种情况下,将进行总共 n 次比较,从而使最坏情况下的时间复杂度为 O(n)。
问题 4
对于给定的链表,假设它只有一个指向列表起始点的 head 指针,以下操作的时间复杂度是多少?
-
在链表前插入
-
在链表末尾插入
-
删除链表的前节点
-
删除链表的最后一个节点
解决方案
-
O(1). 这个操作可以通过头节点直接执行。 -
O(n). 需要遍历列表以到达列表的末尾。 -
O(1). 这个操作可以通过头节点直接执行。 -
O(n). 需要遍历列表以到达列表的末尾。
问题 5
从链表末尾找到第 n 个节点。
解决方案
为了从链表末尾找到第 n 个节点,我们可以使用两个指针——first 和 second。首先,将第二个指针移动到起始点的第 n 个节点。然后,每次移动两个指针一步,直到第二个指针到达列表的末尾。那时,第一个指针将指向链表末尾的第 n 个节点。
问题 6
你如何确定给定链表中是否存在循环(或环)?
解决方案
为了找到链表中的循环,最有效的方法是使用弗洛伊德循环查找算法。在这种方法中,使用两个指针来检测循环——比如说第一个和第二个指针。我们从列表的起始点开始移动这两个指针。
我们每次将第一个和第二个指针各移动一个和两个节点。如果这两个指针在同一个节点相遇,那么这表明存在一个循环,否则,给定的链表中不存在循环。
以下图例展示了这个过程的一个示例:

图 A.2:单链表中的循环
问题 7
如何确定链表的中间元素?
解答
可以使用两个指针来完成,比如,第一个和第二个指针。从起始节点开始移动这两个指针。第一个和第二个指针应该分别每次移动一个和两个节点。当第二个指针到达列表的末尾时,第一个指针将指向单链表的中间元素。
第五章:栈和队列
问题 1
以下哪个选项是使用链表实现的正确队列实现?
-
如果在入队操作中,新的数据元素被添加到列表的起始位置,那么出队操作必须从列表的末尾执行。
-
如果在入队操作中,新的数据元素被添加到列表的末尾,那么入队操作必须从列表的起始位置执行。
-
两个都是。
-
以上都不对。
解答
B 是正确的。队列数据结构遵循 FIFO 顺序,因此数据元素必须添加到列表的末尾,然后从前面移除。
问题 2
假设一个队列使用一个具有head和tail指针的单链表实现。入队操作在head处实现,出队操作在队列的tail处实现。入队和出队操作的时间复杂度将是什么?
解答
入队操作的时间复杂度将是O(1),出队操作的时间复杂度是O(n)。至于入队操作,我们只需要删除head节点,对于单链表来说,这可以在O(1)时间内完成。对于出队操作,为了删除tail,我们需要先遍历整个列表到tail,然后才能删除它。为此,我们需要线性时间,O(n)。
问题 3
实现一个队列需要多少个栈?
解答
两个栈。
使用两个栈和入队操作,新元素被添加到stack1的顶部。在出队过程中,如果stack2为空,则将所有元素移动到stack2,最后返回stack2的顶部。
问题 4
在队列中使用数组高效地实现了入队和出队操作。这两个操作的时间复杂度将是多少?
解答
两个操作都是O(1)。
如果我们使用循环数组来实现队列,那么我们不需要移动元素,只需要移动指针,因此我们可以以O(1)的时间复杂度实现入队和出队操作。
问题 5
我们如何以相反的顺序打印队列数据结构的数据元素?
解答
创建一个空栈,然后将队列中的每个元素从队列中取出并推入栈中。当队列为空时,开始从栈中弹出元素并逐个打印。
第六章:树
问题 1
关于二叉树以下哪个说法是正确的:
-
每棵二叉树要么是完全二叉树,要么是满二叉树
-
每棵完全二叉树也都是满二叉树
-
每个满二叉树也都是一个完全二叉树
-
没有二叉树既是完全二叉树又是满二叉树
-
以上都不对
解答
选项 A 是错误的,因为二叉树不一定是完全二叉树或满二叉树。
选项 B 是错误的,因为完全二叉树可以有某些节点在最后一层未填充,所以完全二叉树不一定是满二叉树。
选项 C 是错误的,因为这不总是正确的,以下图是一个满二叉树,但不是完全二叉树:

图 A.3:一棵是满二叉树但不是完全二叉树的二叉树
选项 D 是错误的,因为这不总是正确的。以下树既是完全二叉树,也是满二叉树:

图 A.4:一棵既是满二叉树又是完全二叉树的二叉树
问题 2
哪种树遍历算法最后访问根节点?
解答
后序遍历.
使用 后序遍历,我们首先访问左子树,然后是右子树,最后访问 根 节点。
问题 3
考虑这个二叉搜索树:

图 A.5:示例二叉搜索树
假设我们移除根节点 8,并希望用左子树中的任何节点替换它,那么新的根节点将是什么?
解答
新节点将是节点 6。为了保持二叉搜索树的性质,左子树中的最大值应该是新的根。
问题 4
以下树的 中序、后序和 前序遍历将是什么?

图 A.6:示例树
解答
前序遍历将是 7-5-1-6-8-9。
中序遍历将是 1-5-6-7-8-9。
后序遍历将是 1-6-5-9-8-7。
问题 5
你如何判断两棵树是否相同?
解答
为了找出两棵二叉树是否相同,这两棵树应该有完全相同的数据和元素排列。这可以通过使用任何遍历算法(对于两棵树应该是相同的)遍历这两棵树并逐个匹配元素来实现。如果在遍历两棵树的过程中所有元素都相同,那么这两棵树是相同的。
问题 6
在问题 4 中提到的树中有多少个叶子节点?
解答
三个节点 1、6 和 9。
问题 7
完美二叉树的高度与该树中的节点数之间有什么关系?
解答
log2 = h.
每一层的节点数:
层级 0: 2⁰ = 1 个节点
层级 1: 2¹ = 2 个节点
层级 2: 2² = 4 个节点
层级 3: 2³ = 8 个节点
可以通过将每一层的所有节点相加来计算第 h 层的总节点数:
n = 2⁰ + 2¹ + 2² + 2³ + …… 2^(h-1) = 2^h - 1
因此,n 和 h 之间的关系是:n = 2^h - 1
= log (n+1) = log2^h
= log[2] (n+1) = h
第七章:堆和优先队列
问题 1
从 min-heap 中删除任意元素的时间复杂度是多少?
解答
要从 heap 中删除任何元素,我们首先必须搜索要删除的元素,然后删除该元素。
总时间复杂度 = 搜索元素的时间 + 删除元素的时间
= O(n) + O(log n)
= O(n)
问题 2
从 min-heap 中找到第 k 个最小元素的时间复杂度是多少?
解答
可以通过执行 delete 操作 k 次来从 min-heap 中找到第 k 个元素。对于每次 delete 操作,时间复杂度是 O(logn)。因此,找到第 k 个最小元素的总时间复杂度将是 O(klogn)。
问题 3
将两个大小为 n 的 max-heap 合并成一个 max-heap 的时间复杂度是多少?
解答
O(n)。
由于从 n 个元素创建 heap 的时间复杂度是 O(n),因此创建 2n 个元素的 heap 也将是 O(n)。
问题 4
确定二叉最大堆和二叉最小堆中最小元素的最坏情况时间复杂度是多少?
解答
在最大堆中,最小的元素始终位于叶节点。因此,为了找到最小的元素,我们必须搜索所有的叶节点。因此,最坏情况复杂度将是 O(n)。
在最小堆中找到最小元素的最坏情况时间复杂度将是 O(1),因为它始终位于根节点。
问题 5
max-heap 的层序遍历是 12, 9, 7, 4, 2。在插入新元素 1 和 8 之后,最终的 max-heap 和最终 max-heap 的层序遍历将是什么?
解答
插入元素 1 后的 max-heap 如下图所示:

图 A.7:插入元素 8 之前的最大堆
插入元素 8 后的最终 max-heap 如下图所示:

图 A.8:插入元素 1 和 8 后的最大堆
最终 max-heap 的层序遍历将是 12, 9, 8, 4, 2, 1, 7。
问题 6
以下哪个是二叉 max-heap?


图 A.9:示例树
解答
B.
一个二叉 max-heap 应该是一个完整的二叉树,除了最后一层之外的所有层都应该被填满。父节点的值应该大于或等于其子节点的值。
选项 A 是不正确的,因为它不是一个完整的二叉树。选项 C 和 D 是不正确的,因为它们没有满足 heap 属性。选项 B 是正确的,因为它既完整又满足 heap 属性。
第八章:哈希表
问题 1
该哈希表有 40 个槽位,表中存储了 200 个元素。哈希表的负载因子是多少?
解答
哈希表的负载因子 = (元素数量) / (表槽位数量) = 200/40 = 5。
问题 2
使用单独链接算法进行哈希的最坏情况搜索时间是多少?
解答
使用链表进行单独链接算法搜索的最坏情况时间复杂度是O(n),因为在最坏的情况下,所有项目都将添加到链表的index 1,搜索一个项目的工作方式将与链表类似。
问题 3
假设哈希表中的键均匀分布。搜索/插入/删除操作的时间复杂度将是什么?
解答
当哈希表中的键均匀分布时,哈希表的索引在O(1)时间内从键中计算出来。创建表将花费O(n)时间,而其他操作如搜索、插入和删除操作将花费O(1)时间,因为所有元素都是均匀分布的,因此,我们可以直接获取所需元素。
问题 4
从字符数组中删除重复字符的最坏情况复杂度是多少?
解答
暴力算法从第一个字符开始,与数组的所有字符进行线性搜索。如果找到重复字符,则应将该字符与最后一个字符交换,然后字符串的长度应减少一个。重复此过程,直到所有字符都被处理。此过程的时间复杂度是 O(n²)。
可以使用哈希表以O(n)的时间复杂度更有效地实现。
使用这种方法,我们从数组的第一个字符开始,根据哈希值将其存储在哈希表中。我们对所有字符都这样做。如果有任何冲突,则可以忽略该字符,否则,该字符将被存储在哈希表中。
第九章:图和算法
问题 1
在具有五个节点的无向简单图中,可能的最大边数(不包括自环)是多少?
解答
每个节点可以连接到图中的其他每个节点。因此,第一个节点可以连接到n-1个节点,第二个节点可以连接到n-2个节点,第三个节点可以连接到n-3个节点,以此类推。节点的总数将是:
[(n-1)+(n-2)+…+3+2+1] = n(n-1)/2.
问题 2
我们称所有节点具有相等度数的图为什么?
解答
一个完全图。
问题 3
解释什么是割点,并识别给定图中的割点?

图 A.10:示例图
解答
割点也称为连通点。这些是在图中,移除后图将分成两个不连通部分的顶点。在给定的图中,顶点 B 和 C 是割点,因为移除节点 B 后,图将分成 {A,D}、{C,E} 顶点。同样,移除节点 C 后,图将分成 {A,B,D}、{E} 顶点。
问题 4
假设有一个阶数为 n 的图 G,图 G 中可能的最大割点数是多少?
解答
将是 n-2,因为第一个和最后一个顶点不会是割点,除了这两个节点,所有节点都可以将图分割成两个不连通的图。请参见下面的图:

图 A.11:图 G
第十章:搜索
问题 1
在平均情况下,对 n 个元素的线性搜索需要多少次比较?
解答
线性搜索的平均比较次数如下。当搜索元素在第一个位置、第二个位置、第三个位置,以及类似地在第 n 个位置找到时,相应地,它将需要 1、2、3…n 次比较。
总平均比较次数
= 
= 
= 
问题 2
假设有一个排序数组中有八个元素。如果所有搜索都成功并且使用二分搜索算法,平均需要多少次比较?
解答
平均比较次数 = (1+2+2+3+3+3+3+4)/8
= 21/8
= 2.625


问题 3
二分搜索算法的最坏情况时间复杂度是多少?
解答
O(logn)。
二分搜索算法的最坏情况将在所需元素位于第一个位置或最后一个位置时发生。在这种情况下,需要 log(n) 次比较。因此,最坏情况的复杂度将是 O(logn)。
问题 4
在什么情况下插值搜索算法的性能应该优于二分搜索算法?
解答
当数组中的数据项均匀分布时,插值搜索算法的性能优于二分搜索算法。
第十一章:排序
问题 1
如果给定一个数组 arr = {55, 42, 4, 31} 并使用冒泡排序对数组元素进行排序,那么需要多少次遍历才能对数组进行排序?
-
3
-
2
-
1
-
0
解答
答案是 a。为了对 n 个元素进行排序,冒泡排序算法需要 (n-1) 次迭代(遍历),其中 n 是给定数组中的元素数量。在此问题中,n 的值为 4,所以需要 4-1 = 3 次迭代来排序给定的数组。
问题 2
冒泡排序的最坏情况复杂度是多少?
-
O(nlogn)
-
O(logn)
-
O(n)
-
O(n²)
解答
答案是 d。最坏情况出现在给定数组是逆序的情况下。在这种情况下,冒泡排序的时间复杂度将是 O(n²)。
问题 3
将快速排序应用于序列 (56, 89, 23, 99, 45, 12, 66, 78, 34)。第一阶段后的序列是什么,第一个元素是什么支点?
-
45, 23, 12, 34, 56, 99, 66, 78, 89
-
34, 12, 23, 45, 56, 99, 66, 78, 89
-
12, 45, 23, 34, 56, 89, 78, 66, 99
-
34, 12, 23, 45, 99, 66, 89, 78, 56
解决方案
b.
在第一阶段后,56 将处于正确的位置,以便所有小于 56 的元素都将位于其左侧,而大于 56 的元素都将位于其右侧。进一步,快速排序递归地应用于左子数组和右子数组。如图所示,对给定序列进行快速排序的过程。

图 A.13:快速排序算法的演示
问题 4
快速排序是一种 ___________
-
贪心算法
-
分而治之算法
-
动态规划算法
-
回溯算法
解决方案
答案是 b。快速排序是一种分而治之算法。快速排序首先将一个大数组分成两个较小的子数组,然后递归地对子数组进行排序。在这里,我们找到支点元素,使得支点元素左侧的所有元素都小于支点元素,并创建第一个子数组。支点元素右侧的元素都大于支点元素,并创建第二个子数组。因此,给定问题被简化为两个较小的集合。现在,再次对这些两个子数组进行排序,在每个子数组中找到支点元素,即在每个子数组上应用快速排序。
问题 5
考虑一种情况,swap 操作非常昂贵。以下哪种排序算法应该被使用,以便最小化 swap 操作的数量?
-
堆排序
-
选择排序
-
插入排序
-
归并排序
解决方案
b. 在选择排序算法中,通常我们识别最大的元素,然后将其与最后一个元素交换,以便在每次迭代中只需要一个 swap。对于 n 个元素,总共需要 (n-1) 次 swap,这是与其他所有算法相比最低的。
问题 6
如果给定的输入数组 A = {15, 9, 33, 35, 100, 95, 13, 11, 2, 13},使用选择排序,第五次 swap 后数组的顺序是什么?(注意:无论它们是否交换或保持在同一位置,都计算在内。)
-
2, 9, 11, 13, 13, 95, 35, 33, 15, 100
-
2, 9, 11, 13, 13, 15, 35, 33, 95, 100
-
35, 100, 95, 2, 9, 11, 13, 33, 15, 13
-
11, 13, 9, 2, 100, 95, 35, 33, 13, 13
解决方案
答案是 a。在选择排序中,选择最小的元素。从数组的开始处开始比较,并将最小的元素与第一个最大的元素交换。现在,排除之前选为最小的元素,因为它已经被放在了正确的位置。

图 A.14:给定序列上插入排序的演示
问题 7
使用插入排序对元素 {44, 21, 61, 6, 13, 1} 进行排序需要多少次迭代?
-
6
-
5
-
7
-
1
解答
答案是 a。假设输入列表中有 N 个键,那么使用插入排序对整个列表进行排序需要 N 次迭代。
问题 8
如果使用插入排序对数组元素 A= [35, 7, 64, 52, 32, 22] 进行排序,那么在第二次迭代后数组元素将如何排列?
-
7, 22, 32, 35, 52, 64
-
7, 32, 35, 52, 64, 22
-
7, 35, 52, 64, 32, 22
-
7, 35, 64, 52, 32, 22
解答
d. 在这里 N = 6。在第一次迭代中,第一个元素,即 A[1] = 35,被插入到初始为空的数组 B 中。在第二次迭代中,A[2] = 7 与 B 的最右侧元素开始比较,以找到其位置。因此,在第二次迭代后,输入数组将是 A = [7, 35, 64, 52, 32, 22]。
第十二章:选择算法
问题 1
如果将快速选择算法应用于给定的数组 arr=[3, 1, 10, 4, 6, 5] 并给定 k 为 2,输出将是什么?
解答
-
给定初始数组:
[3, 1, 10, 4, 6, 5],我们可以找到中位数的中位数:4(索引 =3)。 -
我们将枢轴元素与第一个元素交换:
[4, 1, 3, 10, 6, 5]。 -
我们将枢轴元素移动到其正确的位置:
[1, 3, 4, 10, 6, 5]。 -
现在我们得到一个分割索引等于
2,但k的值也等于2,因此索引2的值将是我们的输出。因此,输出将是4。
问题 2
快速选择算法能否在具有重复值的数组中找到最小元素?
解答
是的,它有效。在每个迭代的结束时,我们都有所有小于当前枢轴的元素存储在枢轴的左侧。让我们考虑当所有元素都相同的情况。在这种情况下,每个迭代都会在数组的左侧放置一个枢轴元素。下一个迭代将继续使用数组中少一个元素的数组。
问题 3
快速排序算法与快速选择算法之间的区别是什么?
解答
在快速选择算法中,我们不对数组进行排序,它专门用于在数组中找到第 k 个最小的元素。该算法根据枢轴元素的值将数组反复分为两个部分。正如我们所知,枢轴元素将被放置,使得其左侧的所有元素都小于枢轴元素,而其右侧的所有元素都大于枢轴元素。因此,我们可以根据目标值选择数组的任意一个部分。这样,我们数组可操作的范围不断缩小。这降低了复杂度从 O(nlog2) 到 O(n)。
问题 4
确定性选择算法与快速选择算法的主要区别是什么?
解答
在 quickselect 算法中,我们根据随机选择的枢轴元素在无序列表中找到第 k 小的元素。而,在确定性选择算法中,该算法也用于从无序列表中找到第 k 小的元素,但在这个算法中,我们通过中位数的中位数来选择枢轴元素,而不是随机选择任何枢轴元素。
问题 5
什么触发了选择算法的最坏情况行为?
解答
在每次迭代中连续选择最大或最小元素会触发选择算法的最坏情况行为。
第十三章:字符串匹配算法
问题 1
展示模式 "aabaabcab" 的 KMP prefix 函数。
解答
给定的 prefix 函数值如下:
| 模式 | a | a | b | a | a | b | c | a | b |
|---|---|---|---|---|---|---|---|---|---|
prefix_function ![]() |
0 | 1 | 0 | 1 | 2 | 3 | 0 | 1 | 0 |
表 A.2:给定模式的预置函数
问题 2
如果期望的有效位移数很小,并且模数大于模式长度,那么 Rabin-Karp 算法的匹配时间是多少?
-
Theta (
m) -
Big O (
n+m) -
Theta (
n-m) -
Big O (
n)
解答
Big O (n+m)
问题 3
当在文本 T = "3141512653849792" 中寻找模式 P = "26" 的所有出现时,Rabin-Karp 字符串匹配算法遇到多少虚假匹配,工作在模 q = 11 和字母表集 Σ = {0, 1, 2,..., 9} 上?
解答
问题 4
Rabin-Karp 算法中用于获得计算时间为 Theta (m) 的基本公式是什么?
-
二分法
-
Horner 规则
-
求和引理
-
抵消引理
解答
Horner 规则。
问题 5
Rabin-Karp 算法可用于在文本文档中检测剽窃。
-
真的
-
错误
解答
真的,Rabin-Karp 算法是一种字符串匹配算法,它可以用于检测文本文档中的剽窃。
加入我们的 Discord 社区
加入我们社区的 Discord 空间,与作者和其他读者进行讨论:packt.link/MEvK4



)。循环将根据 
)












浙公网安备 33010602011771号