Python Data Structure CN
Python Data Structure CN
Introduction 1.1
1.介绍 1.2
1.1.目标 1.2.1
1.2.快速开始 1.2.2
1.3.什么是计算机科学 1.2.3
1.4.什么是编程 1.2.4
1.5.为什么要学习数据结构和抽象数据类型 1.2.5
1.6.为什么要学习算法 1.2.6
1.7.回顾Python基础 1.2.7
2.算法分析 1.3
2.1.目标 1.3.1
2.2.什么是算法分析 1.3.2
2.3.大O符号 1.3.3
2.4.一个乱序字符串检查的例子 1.3.4
2.5.Python数据结构的性能 1.3.5
2.6.列表 1.3.6
2.7.字典 1.3.7
2.8.总结 1.3.8
3.基本数据结构 1.4
3.1.目标 1.4.1
3.2.什么是线性数据结构 1.4.2
3.3.什么是栈 1.4.3
3.4.栈的抽象数据类型 1.4.4
3.5.Python实现栈 1.4.5
3.6.简单括号匹配 1.4.6
3.7.符号匹配 1.4.7
3.8.十进制转换成二进制 1.4.8
3.9.中缀前缀和后缀表达式 1.4.9
3.10.什么是队列 1.4.10
3.11.队列抽象数据类型 1.4.11
3.12.Python实现队列 1.4.12
3.13.模拟:烫手山芋 1.4.13
3.14.模拟:打印机 1.4.14
3.15.什么是Deque 1.4.15
3.16.Deque抽象数据类型 1.4.16
3.17.Python实现Deque 1.4.17
3.18.回文检查 1.4.18
1
3.19.列表
1.4.19
3.20.无序列表抽象数据类型 1.4.20
3.21.实现无序列表:链表 1.4.21
3.22.有序列表抽象数据结构 1.4.22
3.23.实现有序列表 1.4.23
3.24.总结 1.4.24
4.递归 1.5
4.1.目标 1.5.1
4.2.什么是递归 1.5.2
4.3.计算整数列表和 1.5.3
4.4.递归的三定律 1.5.4
4.5.整数转换为任意进制字符串 1.5.5
4.6.栈帧:实现递归 1.5.6
4.7.介绍:可视化递归 1.5.7
4.8.谢尔宾斯基三角形 1.5.8
4.10.汉诺塔游戏 1.5.9
4.11.探索迷宫 1.5.10
4.12.动态规划 1.5.11
4.13.总结 1.5.12
5.排序和搜索 1.6
5.1.目标 1.6.1
5.2.搜索 1.6.2
5.3.顺序查找 1.6.3
5.4.二分查找 1.6.4
5.5.Hash查找 1.6.5
5.6.排序 1.6.6
5.7.冒泡排序 1.6.7
5.8.选择排序 1.6.8
5.9.插入排序 1.6.9
5.10.希尔排序 1.6.10
5.11.归并排序 1.6.11
5.12.快速排序 1.6.12
5.13.总结 1.6.13
6.树和树的算法 1.7
6.1.目标 1.7.1
6.2.树的例子 1.7.2
6.3.词汇和定义 1.7.3
6.4.列表表示 1.7.4
6.5.节点表示 1.7.5
6.6.分析树 1.7.6
2
6.7.树的遍历
1.7.7
6.8.基于二叉堆的优先队列 1.7.8
6.9.二叉堆操作 1.7.9
6.10.二叉堆实现 1.7.10
6.11.二叉查找树 1.7.11
6.12.查找树操作 1.7.12
6.13.查找树实现 1.7.13
6.14.查找树分析 1.7.14
6.15.平衡二叉搜索树 1.7.15
6.16.AVL平衡二叉搜索树 1.7.16
6.17.AVL平衡二叉搜索树实现 1.7.17
6.18.Map抽象数据结构总结 1.7.18
6.19.总结 1.7.19
7.图和图的算法 1.8
7.1.目标 1.8.1
7.2.词汇和定义 1.8.2
7.3.图抽象数据类型 1.8.3
7.4.邻接矩阵 1.8.4
7.5.邻接表 1.8.5
7.6.实现 1.8.6
7.7.字梯的问题 1.8.7
7.8.构建字梯图 1.8.8
7.9.实现广度优先搜索 1.8.9
7.10.广度优先搜索分析 1.8.10
7.11.骑士之旅 1.8.11
7.12.构建骑士之旅图 1.8.12
7.13.实现骑士之旅 1.8.13
7.14.骑士之旅分析 1.8.14
7.15.通用深度优先搜索 1.8.15
7.16.深度优先搜索分析 1.8.16
7.17.拓扑排序 1.8.17
7.18.强连通分量 1.8.18
7.19.最短路径问题 1.8.19
7.20.Dijkstra算法 1.8.20
7.21.Dijkstra算法分析 1.8.21
7.22.Prim生成树算法 1.8.22
7.23.总结 1.8.23
3
Introduction
介绍
problem-solving-with-algorithms-and-data-structure-using-python 中文版
目的
数据结构作为计算机从业人员的必备基础,Java, c 之类的语言有很多这方面的书籍,Python 相对较少, 其中比较著名
的一本 problem-solving-with-algorithms-and-data-structure-using-python,所以我在学习的过程中将其翻译了中文版,
希望对大家有点帮助。
由于本人英语能力不佳,本书部分翻译参考谷歌,但每句话都经过个人理解后调整修改,尽量保证语句畅通。
由于翻译比较仓促,难以避免有些排版错别字等问题,后续会润色。如你也有兴趣参与,可 pull request 到 github
仓库
默认大家有一定的 Python 基础,故暂未翻译 Python 语法的几个章节。后续考虑书的完整性会加上这几节。
本书未加上课后练习,如有兴趣,可上原书网站练习。
地址
github 地址: https://github.com/facert/python-data-structure-cn
gitbook 在线浏览: https://facert.gitbooks.io/python-data-structure-cn
联系作者
邮箱: [email protected]
博客: https://facert.github.io
许可证
本作品采用 署名-非商业性使用-相同方式共享 4.0 国际许可协议 进行许可。传播此文档时请注意遵循以上许可协议。
关于本许可证的更多详情可参考 https://creativecommons.org/licenses/by-nc-sa/4.0/
4
1.介绍
5
1.1.目标
1.1.目标
回顾计算机科学的思想, 提高编程和解决问题的能力。
理解抽象化以及它在解决问题过程中发挥的作用
理解和实现抽象数据类型的概念
回顾 Python 编程语言
6
1.2.快速开始
1.2.快速开始
从第一台通过接入网线和交换机来传递人的指令的计算机开始,我们编程思考的方式发生了许多变化。与社会的许多方
面一样,计算技术的变化为计算机科学家提供了越来越多的工具和平台来实践他们的工艺。计算机的快速发展诸如更快
的处理器,高速网络和大的存储器容量已经让计算机科学家陷入高度复杂螺旋中。在所有这些快速演变中,一些基本原
则保持不变。计算机科学关注用计算机来解决问题。
毫无疑问你花了相当多的时间学习解决问题的基础知识,以此希望有足够的能力把问题弄清楚并想出解决方案。你还发
现编写代码通常很困难。问题的复杂性和解决方案的相应复杂性往往会掩盖与解决问题过程相关的基本思想。
本章着重介绍了其他两个重要的部分。首先回顾了计算机科学与算法和研究数据结构所必须适应的框架,特别是我们需
要研究这些主题的原因,以及如何理解这些主题有助于我们更好的解决问题。第二,我们回顾 Python 编程语言。虽然
我们不提供详尽的参考,我们将在其余章节中给出基本数据结构的示例和解释。
7
1.3.什么是计算机科学
1.3.什么是计算机科学
计算机科学往往难以定义。这可能是由于在名称中不幸使用了“计算机”一词。正如你可能知道的,计算机科学不仅仅是
计算机的研究。虽然计算机作为一个工具在学科中发挥重要的支持作用,但它们只是工具。
计算机科学是对问题,解决问题以及解决问题过程中产生的解决方案的研究。给定一个问题,计算机科学家的目标是开
发一个算法,一系列的指令列表,用于解决可能出现的问题的任何实例。算法遵循它有限的过程就可以解决问题。
计算机科学可以被认为是对算法的研究。但是,我们必须谨慎地包括一些事实,即一些问题可能没有解决方案。虽然证
明这种说法正确性超出了本文的范围,但一些问题不能解决的事实对于那些研究计算机科学的人是很重要的。所以我们
可以这么定义计算机科学,是研究能被解决的问题的方案和不能被解决问题的科学。
通常我们会说这个问题是可计算的,当在描述问题和解决方案时。如果存在一个算法解决这个问题,那么问题是可计算
的。计算机科学的另一个定义是说,计算机科学是研究那些可计算和不可计算的问题,研究是不是存在一种算法来解决
它。你会注意到,“电脑”一词根本没有出现。解决方案是独立于机器而言的。
计算机科学,因为它涉及问题解决过程本身,也是抽象的研究。抽象使我们能够以分离所谓的逻辑和物理角度的方式来
观察问题和解决方案。基本思想跟我们常见的例子一样。
假设你可能已经开车上学或上班。作为司机,汽车的用户。你为了让汽车载你到目的地,你会和汽车有些互动。进入汽
车,插入钥匙,点火,换挡,制动,加速和转向。从抽象的角度,我们可以说你所看到的是汽车的逻辑视角。你正在使
用汽车设计师提供的功能,将你从一个地方运输到另一个位置。这些功能有时也被称为接口。
另一方面,修理汽车的技工有一个截然不同的视角。他不仅知道如何开车,还必须知道所有必要的细节,使我们认为理
所当然的功能运行起来。他需要了解发动机是如何工作的,变速箱如何变速,温度是如何控制的等等。这被称为物理视
角,细节发生在“引擎盖下”。
当我们使用电脑时也会发生同样的情况。大多数人使用计算机写文档,发送和接收电子邮件,上网冲浪,播放音乐,存
储图像和玩游戏,而不知道让这些应用程序工作的细节。他们从逻辑或用户角度看计算机。计算机科学家,程序员,技
术支持人员和系统管理员看计算机的角度截然不同。他们必须知道操作系统如何工作的细节,如何配置网络协议,以及
如何编写控制功能的各种脚本。他们必须能够控制底层的细节。
这两个示例的共同点是用户态的抽象,有时也称为客户端,不需要知道细节,只要用户知道接口的工作方式。这个接口
是用户与底层沟通的方式。作为抽象的另一个例子,Python 数学模块。一旦我们导入模块,我们可以执行计算
这是一个程序抽象的例子。我们不一定知道如何计算平方根,但我们知道函数是什么以及如何使用它。如果我们正确地
执行导入,我们可以假设该函数将为我们提供正确的结果。我们知道有人实现了平方根问题的解决方案,但我们只需要
知道如何使用它。这有时被称为“黑盒子”视图。我们简单地描述下接口:函数的名称,需要什么(参数),以及将返回
什么。细节隐藏在里面(见图1)。
(图1)
8
1.4.什么是编程
1.4.什么是编程
编程是将算法编码为符号,编程语言的过程,以使得其可以由计算机执行。虽然有许多编程语言和不同类型的计算机存
在,第一步是需要有解决方案。没有算法就没有程序。
计算机科学不是研究编程。然而,编程是计算机科学家的一个重要能力。编程通常是我们为解决方案创建的表现形式。
因此,这种语言表现形式和创造它的过程成为该学科的基本部分。
算法描述了依据问题实例数据所产生的解决方案和产生预期结果所需的一套步骤。编程语言必须提供一种表示方法来表
示过程和数据。为此,它提供了控制结构和数据类型。
控制结构允许以方便而明确的方式表示算法步骤。至少,算法需要执行顺序处理,决策选择和重复控制迭代。只要语言
提供这些基本语句,它就可以用于算法表示。
计算机中的所有数据项都以二进制形式表示。为了赋给这些字符串含义,我们需要有数据类型。数据类型提供了对这个
二进制数据的解释,以便我们能够根据解决的问题思考数据。这些底层的内置数据类型(有时称为原始数据类型)为算
法开发提供了基础。
例如,大多数编程语言为整数提供数据类型。内存中的二进制数据可以解释为整数,并且能给予一个我们通常与整数
(例如 23,654 和 -19)相关联的含义。此外,数据类型还提供数据项参与的操作的描述。对于整数,诸如加法,减法
和乘法的操作是常见的。我们期望数值类型的数据可以参与这些算术运算。通常我们遇到的困难是问题及其解决方案非
常复杂。这些简单的,语言提供的结构和数据类型虽然足以表示复杂的解决方案,但通常在我们处理问题的过程中处于
不利地位。我们需要一些方法控制这种复杂性,并能给我们提供更好的解决方案。
9
1.5.为什么要学习数据结构和抽象数据类型
1.5.为什么要学习数据结构和抽象数据类型
为了管理问题的复杂性和解决问题的过程,计算机科学家使用抽象使他们能够专注于 “大局” 而不会迷失在细节中。通过
创建问题域的模型,我们能够利用更好和更有效的问题解决过程。这些模型允许我们以更加一致的方式描述我们的算法
将要处理的数据。
之前,我们将过程抽象称为隐藏特定函数的细节的过程,以允许用户或客户端在高层查看它。我们现在将注意力转向类
似的思想,即数据抽象的思想。 抽象数据类型 (有时缩写为 ADT )是对我们如何查看数据和允许的操作的逻辑描述,而
不用考虑如何实现它们。这意味着我们只关心数据表示什么,而不关心它最终将如何构造。通过提供这种级别的抽象,
我们围绕数据创建一个封装。通过封装实现细节,我们将它们从用户的视图中隐藏。这称为信息隐藏。
Figure 2 展示了抽象数据类型是什么以及如何操作。用户与接口交互,使用抽象数据类型指定的操作。抽象数据类型是
用户与之交互的 shell。实现隐藏在更深的底层。用户不关心实现的细节。
Figure 2
抽象数据类型(通常称为数据结构)的实现将要求我们使用一些程序构建和原始数据类型的集合来提供数据的物理视
图。 正如我们前面讨论的,这两个视角的分离将允许我们将问题定义复杂的数据模型,而不给出关于模型如何实际构
建的细节。 这提供了独立于实现的数据视图。由于通常有许多不同的方法来实现抽象数据类型,所以这种实现独立性
允许程序员在不改变数据的用户与其交互的方式的情况下切换实现的细节。 用户可以继续专注于解决问题的过程。
10
1.6.为什么要学习算法
1.6.为什么要学习算法
计算机科学家经常通过经验学习。我们通过看别人解决问题和自己解决问题来学习。接触不同的问题解决技术,看不同
的算法设计有助于我们承担下一个具有挑战性的问题。通过思考许多不同的算法,我们可以开始开发模式识别,以便下
一次出现类似的问题时,我们能够更好地解决它。
在最坏的情况下,我们可能有一个难以处理的问题,这意味着没有算法可以在实际的时间量内解决问题。重要的是能够
区分具有解决方案的那些问题,不具有解决方案的那些问题,以及存在解决方案但需要太多时间或其他资源来合理工作
的那些问题。
经常需要权衡,我们需要做决定。作为计算机科学家,除了我们解决问题的能力,我们还需要了解解决方案评估技术。
最后,通常有很多方法来解决问题。找到一个解决方案,我们将一遍又一遍比较,然后决定它是否是一个好的方案。
11
1.7.回顾Python基础
1.7.回顾Python基础
在本节中,我们将回顾 Python 编程语言,并提供一些更详细的例子。 如果你是 Python 新手,或者你需要有关提出的
任何主题的更多信息,我们建议你参考 Python语言参考或 Python教程。我们在这里的目标是重新认识下 python 语
言,并强化一些将成为后面章节中心的概念。
显示提示,打印结果和下一个提示。
12
2.算法分析
13
2.1.目标
2.1.目标
理解算法分析的重要性
能够使用 大O 符号描述算法执行时间
理解 Python 列表和字典的常见操作的 大O 执行时间
理解 Python 数据的实现是如何影响算法分析的。
了解如何对简单的 Python 程序做基准测试( benchmark )。
14
2.2.什么是算法分析
2.2.什么是算法分析
一些普遍的现象是,刚接触计算机科学的学生会将自己的程序和其他人的相比较。你可能还注意到,这些计算机程序看
起来很相似,尤其是简单的程序。经常出现一个有趣的问题。当两个程序解决同样的问题,但看起来不同,哪一个更好
呢?
为了回答这个问题,我们需要记住,程序和程序代表的底层算法之间有一个重要的区别。正如我们在第 1 章中所说,一
种算法是一个通用的,一步一步解决某种问题的指令列表。它是用于解决一种问题的任何实例的方法,给定特定输入,
产生期望的结果。另一方面,程序是使用某种编程语言编码的算法。根据程序员和他们所使用的编程语言的不同,可能
存在描述相同算法的许多不同的程序。
ActiveCode 1
def sumOfN(n):
theSum = 0
for i in range(1,n+1):
theSum = theSum + i
return theSum
print(sumOfN(10))
ActiveCode 2
def foo(tom):
fred = 0
for bill in range(1,tom+1):
barney = bill
fred = fred + barney
return fred
print(foo(10))
算法分析是基于每种算法使用的计算资源量来比较算法。我们比较两个算法,说一个比另一个算法好的原因在于它在使
用资源方面更有效率,或者仅仅使用的资源更少。从这个角度来看,上面两个函数看起来很相似。它们都使用基本相同
的算法来解决求和问题。
在这点上,重要的是要更多地考虑我们真正意义上的计算资源。有两种方法,一种是考虑算法解决问题所需的空间或者
内存。解决方案所需的空间通常由问题本身决定。但是,有时候有的算法会有一些特殊的空间需求,这种情况下我们需
要非常仔细地解释这些变动。
作为空间需求的一种替代方法,我们可以基于算法执行所需的时间来分析和比较算法。这种测量方式有时被称为算法
的“执行时间”或“运行时间”。我们可以通过基准分析( benchmark analysis )来测量函数 SumOfN 的执行时间。这意味
着我们将记录程序计算出结果所需的实际时间。在 Python 中,我们可以通过记录相对于系统的开始时间和结束时间来
15
2.2.什么是算法分析
Listing 1
import time
def sumOfN2(n):
start = time.time()
theSum = 0
for i in range(1,n+1):
theSum = theSum + i
end = time.time()
>>>for i in range(5):
print("Sum is %d required %10.7f seconds"%sumOfN(10000))
Sum is 50005000 required 0.0018950 seconds
Sum is 50005000 required 0.0018620 seconds
Sum is 50005000 required 0.0019171 seconds
Sum is 50005000 required 0.0019162 seconds
Sum is 50005000 required 0.0019360 seconds
>>>for i in range(5):
print("Sum is %d required %10.7f seconds"%sumOfN(100000))
Sum is 5000050000 required 0.0199420 seconds
Sum is 5000050000 required 0.0180972 seconds
Sum is 5000050000 required 0.0194821 seconds
Sum is 5000050000 required 0.0178988 seconds
Sum is 5000050000 required 0.0188949 seconds
>>>
再次的,尽管时间更长,但每次运行所需的时间也是非常一致的,平均大约多10倍。 对于 n 等于 1,000,000,我们得
到:
>>>for i in range(5):
print("Sum is %d required %10.7f seconds"%sumOfN(1000000))
Sum is 500000500000 required 0.1948988 seconds
Sum is 500000500000 required 0.1850290 seconds
Sum is 500000500000 required 0.1809771 seconds
Sum is 500000500000 required 0.1729250 seconds
Sum is 500000500000 required 0.1646299 seconds
>>>
sumOfN3 利用封闭方程而不是迭代来计算前n个整数的和。
ActiveCode 3
16
2.2.什么是算法分析
def sumOfN3(n):
return (n*(n+1))/2
print(sumOfN3(10))
100,000,000) , 我们得到如下结果
在这个输出中有两件事需要重点关注,首先上面记录的执行时间比之前任何例子都短,另外他们的执行时间和 n 无关,
看起来 sumOfN3 几乎不受 n 的影响。
但是这个基准测试能告诉我们什么?我们可以很直观地看到使用了迭代的解决方案需要做更多的工作,因为一些程序步
骤被重复执行。这可能是它需要更长时间的原因。此外,迭代方案执行所需时间随着 n 递增。另外还有个问题,如果我
们在不同计算机上或者使用不用的编程语言运行这个函数,我们也可能得到不同的结果。如果使用老旧的计算机,可能
需要更长时间才能执行完 sumOfN3。
我们需要一个更好的方法来描述这些算法的执行时间。基准测试计算的是程序执行的实际时间。它并不真正地提供给我
们一个有用的度量( measurement ),因为它取决于特定的机器,程序,时间,编译器和编程语言。 相反,我们希望有
一个独立于所使用的程序或计算机的度量。这个度量将有助于独立地判断算法,并且可以用于比较不同实现方法的算法
的效率。
17
2.3.大O符号
2.3.大O符号
当我们试图通过执行时间来表征算法的效率时,并且独立于任何特定程序或计算机,重要的是量化算法需要的操作或者
步骤的数量。选择适当的基本计算单位是个复杂的问题,并且将取决于如何实现算法。对于先前的求和算法,一个比较
好的基本计算单位是对执行语句进行计数。在 sumOfN 中,赋值语句的计数为 1 (theSum = 0) 加上 n 的值(我们执行
theSum=theSum+i 的次数)。我们通过函数 T 表示 T(n)=1 + n 。参数 n 通常称为‘问题的规模’,我们称作 ‘T(n) 是解决
问题大小为 n 所花费的时间,即 1+n 步长’。在上面的求和函数中,使用 n 来表示问题大小是有意义的。我们可以说,
100,000 个整数和比 1000 个问题规模大。因此,所需时间也更长。我们的目标是表示出算法的执行时间是如何相对问
题规模大小而改变的。
虽然我们没有在求和示例中看到这一点,但有时算法的性能取决于数据的确切值,而不是问题规模的大小。对于这种类
型的算法,我们需要根据最佳情况,最坏情况或平均情况来表征它们的性能。最坏情况是指算法性能特别差的特定数据
集。而相同的算法不同数据集可能具有非常好的性能。大多数情况下,算法执行效率处在两个极端之间(平均情况)。
对于计算机科学家而言,重要的是了解这些区别,使它们不被某一个特定的情况误导。
Table 1
18
2.3.大O符号
Figure 1
a=5
b=6
c=10
for i in range(n):
for j in range(n):
x = i * i
y = j * j
z = i * j
for k in range(n):
w = a*k + 45
v = b*b
d = 33
Listing 2
19
2.3.大O符号
增大时,所有其他项以及主项上的系数都可以忽略。
Figure 2
20
2.4.一个乱序字符串检查的例子
2.4.一个乱序字符串检查的例子
显示不同量级的算法的一个很好的例子是字符串的乱序检查。乱序字符串是指一个字符串只是另一个字符串的重新排
列。例如,'heart' 和 'earth' 就是乱序字符串。'python' 和 'typhon' 也是。为了简单起见,我们假设所讨论的两个字符串
具有相等的长度,并且他们由 26 个小写字母集合组成。我们的目标是写一个布尔函数,它将两个字符串做参数并返回
它们是不是乱序。
2.4.1.解法1:检查
我们对乱序问题的第一个解法是检查第一个字符串是不是出现在第二个字符串中。如果可以检验到每一个字符,那这两
个字符串一定是乱序。可以通过用 None 替换字符来了解一个字符是否完成检查。但是,由于 Python 字符串是不可变
的,所以第一步是将第二个字符串转换为列表。检查第一个字符串中的每个字符是否存在于第二个列表中,如果存在,
替换成 None。见 ActiveCode1
def anagramSolution1(s1,s2):
alist = list(s2)
pos1 = 0
stillOK = True
if found:
alist[pos2] = None
else:
stillOK = False
pos1 = pos1 + 1
return stillOK
print(anagramSolution1('abcd','dcba'))
ActiveCode1
2.4.2.解法2:排序和比较
另一个解决方案是利用这么一个事实:即使 s1,s2 不同,它们都是由完全相同的字符组成的。所以,我们按照字母顺序
从 a 到 z 排列每个字符串,如果两个字符串相同,那这两个字符串就是乱序字符串。见 ActiveCode2。
21
2.4.一个乱序字符串检查的例子
def anagramSolution2(s1,s2):
alist1 = list(s1)
alist2 = list(s2)
alist1.sort()
alist2.sort()
pos = 0
matches = True
return matches
print(anagramSolution2('abcde','edcba'))
ActiveCode2
2.4.3.解法3: 穷举法
解决这类问题的强力方法是穷举所有可能性。对于乱序检测,我们可以生成 s1 的所有乱序字符串列表,然后查看是不
是有 s2。这种方法有一点困难。当 s1 生成所有可能的字符串时,第一个位置有 n 种可能,第二个位置有 n-1 种,第三
个位置有 n-3 种,等等。总数为 n∗(n−1)∗(n−2)∗...∗3∗2∗1n∗(n−1)∗(n−2)∗...∗3∗2∗1, 即 n!。虽然一些字符
串可能是重复的,程序也不可能提前知道这样,所以他仍然会生成 n! 个字符串。
2.4.4.解法4: 计数和比较
我们最终的解决方法是利用两个乱序字符串具有相同数目的 a, b, c 等字符的事实。我们首先计算的是每个字母出现的
次数。由于有 26 个可能的字符,我们就用 一个长度为 26 的列表,每个可能的字符占一个位置。每次看到一个特定的
字符,就增加该位置的计数器。最后如果两个列表的计数器一样,则字符串为乱序字符串。见 ActiveCode 3
def anagramSolution4(s1,s2):
c1 = [0]*26
c2 = [0]*26
for i in range(len(s1)):
pos = ord(s1[i])-ord('a')
c1[pos] = c1[pos] + 1
for i in range(len(s2)):
pos = ord(s2[i])-ord('a')
c2[pos] = c2[pos] + 1
j = 0
stillOK = True
while j<26 and stillOK:
if c1[j]==c2[j]:
j = j + 1
else:
22
2.4.一个乱序字符串检查的例子
stillOK = False
return stillOK
print(anagramSolution4('apple','pleap'))
ActiveCode 3
同样,这个方案有多个迭代,但是和第一个解法不一样,它不是嵌套的。两个迭代都是 n, 第三个迭代,比较两个计数
列表,需要 26 步,因为有 26 个字母。一共 T(n)=2n+26T(n)=2n+26,即 O(n),我们找到了一个线性量级的算法解决
这个问题。
在结束这个例子之前,我们来讨论下空间花费,虽然最后一个方案在线性时间执行,但它需要额外的存储来保存两个字
符计数列表。换句话说,该算法牺牲了空间以获得时间。
很多情况下,你需要在空间和时间之间做出权衡。这种情况下,额外空间不重要,但是如果有数百万个字符,就需要关
注下。作为一个计算机科学家,当给定一个特定的算法,将由你决定如何使用计算资源。
23
2.5.Python数据结构的性能
2.5.Python数据结构的性能
现在你对 大O 算法和不同函数之间的差异有了了解。本节的目标是告诉你 Python 列表和字典操作的 大O 性能。然后
我们将做一些基于时间的实验来说明每个数据结构的花销和使用这些数据结构的好处。重要的是了解这些数据结构的效
率,因为它们是本书实现其他数据结构所用到的基础模块。本节中,我们将不会说明为什么是这个性能。在后面的章节
中,你将看到列表和字典一些可能的实现,以及性能是如何取决于实现的。
24
2.6.列表
2.6.列表
python 的设计者在实现列表数据结构的时候有很多选择。每一个这种选择都可能影响列表操作的性能。为了帮助他们
做出正确的选择,他们查看了最常使用列表数据结构的方式,并且优化了实现,以便使得最常见的操作非常快。当然,
他们还试图使较不常见的操作快速,但是当需要做出折衷时,较不常见的操作的性能通常牺牲以支持更常见的操作。
两个常见的操作是索引和分配到索引位置。无论列表有多大,这两个操作都需要相同的时间。当这样的操作和列表的大
小无关时,它们是 O(1)。
def test1():
l = []
for i in range(1000):
l = l + [i]
def test2():
l = []
for i in range(1000):
l.append(i)
def test3():
l = [i for i in range(1000)]
def test4():
l = list(range(1000))
25
2.6.列表
最后一点,你上面看到的时间都是包括实际调用函数的一些开销,但我们可以假设函数调用开销在四种情况下是相同
的,所以我们仍然得到的是有意义的比较。因此,拼接字符串操作需要 6.54 毫秒并不准确,而是拼接字符串这个函数
需要 6.54 毫秒。你可以测试调用空函数所需要的时间,并从上面的数字中减去它。
26
2.6.列表
popzero = timeit.Timer("x.pop(0)",
"from __main__ import x")
popend = timeit.Timer("x.pop()",
"from __main__ import x")
x = list(range(2000000))
popzero.timeit(number=1000)
4.8213560581207275
x = list(range(2000000))
popend.timeit(number=1000)
0.0003161430358886719
Listing 4
popzero = Timer("x.pop(0)",
"from __main__ import x")
popend = Timer("x.pop()",
"from __main__ import x")
print("pop(0) pop()")
for i in range(1000000,100000001,1000000):
x = list(range(i))
pt = popend.timeit(number=1000)
x = list(range(i))
pz = popzero.timeit(number=1000)
print("%15.5f, %15.5f" %(pz,pt))
Listing 5
27
2.6.列表
28
2.7.字典
2.7.字典
python 中第二个主要的数据结构是字典。你可能记得,字典和列表不同,你可以通过键而不是位置来访问字典中的项
目。在本书的后面,你会看到有很多方法来实现字典。字典的 get 和 set 操作都是 O(1)。另一个重要的操作是
contains,检查一个键是否在字典中也是 O(1)。所有字典操作的效率总结在 Table3 中。关于字典性能的一个重要方面
是,我们在表中提供的效率是针对平均性能。 在一些罕见的情况下,contains,get item 和 set item 操作可以退化为
O(n)。我们将在后面的章节介绍。
Table 3
import timeit
import random
for i in range(10000,1000001,20000):
t = timeit.Timer("random.randrange(%d) in x"%i,
"from __main__ import random,x")
x = list(range(i))
lst_time = t.timeit(number=1000)
x = {j:None for j in range(i)}
d_time = t.timeit(number=1000)
print("%d,%10.3f,%10.3f" % (i, lst_time, d_time))
Listing 6
29
2.7.字典
Figure 4
30
2.8.总结
2.8.总结
算法分析是一种独立的测量算法的方法。
大O表示法允许根据问题的大小,通过其主要部分来对算法进行分类。
31
3.基本数据结构
32
3.1.目标
3.1.目标
理解抽象数据类型的栈,队列,deque 和列表。
能够使用 Python 列表实现 ADT 堆栈,队列和 deque。
了解基本线性数据结构实现的性能。
了解前缀,中缀和后缀表达式格式。
使用栈来实现后缀表达式。
使用栈将表达式从中缀转换为后缀。
使用队列进行基本时序仿真。
能够识别问题中栈,队列和 deques 数据结构的适当使用。
能够使用节点和引用将抽象数据类型列表实现为链表。
能够比较我们的链表实现与 Python 的列表实现的性能。
33
3.2.什么是线性数据结构
3.2.什么是线性数据结构
我们从四个简单但重要的概念开始研究数据结构。栈,队列,deques, 列表是一类数据的容器,它们数据项之间的顺序
由添加或删除的顺序决定。一旦一个数据项被添加,它相对于前后元素一直保持该位置不变。诸如此类的数据结构被称
为线性数据结构。
线性数据结构有两端,有时被称为左右,某些情况被称为前后。你也可以称为顶部和底部,名字都不重要。将两个线性
数据结构区分开的方法是添加和移除项的方式,特别是添加和移除项的位置。例如一些结构允许从一端添加项,另一些
允许从另一端移除项。
这些变种的形式产生了计算机科学最有用的数据结构。他们出现在各种算法中,并可以用于解决很多重要的问题。
34
3.3.什么是栈
3.3.什么是栈
栈(有时称为“后进先出栈”)是一个项的有序集合,其中添加移除新项总发生在同一端。这一端通常称为“顶部”。与顶
部对应的端称为“底部”。
栈的底部很重要,因为在栈中靠近底部的项是存储时间最长的。最近添加的项是最先会被移除的。这种排序原则有时被
称为 LIFO,后进先出。它基于在集合内的时间长度做排序。较新的项靠近顶部,较旧的项靠近底部。
栈的例子很常见。几乎所有的自助餐厅都有一堆托盘或盘子,你从顶部拿一个,就会有一个新的托盘给下一个客人。想
象桌上有一堆书(Figure 1), 只有顶部的那本书封面可见,要看到其他书的封面,只有先移除他们上面的书。Figure 2 展
示了另一个栈,包含了很多 Python 对象。
Figure 1
Figure 2
和栈相关的最有用的想法之一来自对它的观察。假设从一个干净的桌面开始,现在把书一本本叠起来,你在构造一个
栈。考虑下移除一本书会发生什么。移除的顺序跟刚刚被放置的顺序相反。栈之所以重要是因为它能反转项的顺序。插
入跟删除顺序相反,Figure 3 展示了 Python 数据对象创建和删除的过程,注意观察他们的顺序。
35
3.3.什么是栈
Figure 3
36
3.4.栈的抽象数据类型
3.4.栈的抽象数据类型
栈的抽象数据类型由以下结构和操作定义。如上所述,栈被构造为项的有序集合,其中项被添加和从末端移除的位置称
为“顶部”。栈是有序的 LIFO 。栈操作如下。
Table1
37
3.5.Python实现栈
3.5.Python实现栈
现在我们已经将栈清楚地定义了抽象数据类型,我们将把注意力转向使用 Python 实现栈。回想一下,当我们给抽象数
据类型一个物理实现时,我们将实现称为数据结构。
class Stack:
def __init__(self):
self.items = []
def isEmpty(self):
return self.items == []
def pop(self):
return self.items.pop()
def peek(self):
return self.items[len(self.items)-1]
def size(self):
return len(self.items)
ActiveCode 1
s=Stack()
print(s.isEmpty())
s.push(4)
s.push('dog')
print(s.peek())
s.push(True)
print(s.size())
print(s.isEmpty())
s.push(8.4)
print(s.pop())
print(s.pop())
print(s.size())
ActiveCode 2
38
3.6.简单括号匹配
3.6.简单括号匹配
我们现在把注意力转向使用栈解决真正的计算机问题。你会这么写算术表达式
(5+6)∗(7+8)/(4+3)
(defun square(n)
(* n n))
在这两个例子中,括号必须以匹配的方式出现。括号匹配意味着每个开始符号具有相应的结束符号,并且括号能被正确
嵌套。考虑下面正确匹配的括号字符串:
(()()()())
(((())))
(()((())()))
对比那些不匹配的括号:
((((((())
()))
(()()(()
区分括号是否匹配的能力是识别很多编程语言结构的重要部分。具有挑战的是如何编写一个算法,能够从左到右读取一
串符号,并决定符号是否平衡。为了解决这个问题,我们需要做一个重要的观察。从左到右处理符号时,最近开始符号
必须与下一个关闭符号相匹配(见 Figure 4)。此外,处理的第一个开始符号必须等待直到其匹配最后一个符号。结束符
号以相反的顺序匹配开始符号。他们从内到外匹配。这是一个可以用栈解决问题的线索。
Figure 4
一旦你认为栈是保存括号的恰当的数据结构,算法是很直接的。从空栈开始,从左到右处理括号字符串。如果一个符号
是一个开始符号,将其作为一个信号,对应的结束符号稍后会出现。另一方面,如果符号是结束符号,弹出栈,只要弹
出栈的开始符号可以匹配每个结束符号,则括号保持匹配状态。如果任何时候栈上没有出现符合开始符号的结束符号,
则字符串不匹配。最后,当所有符号都被处理后,栈应该是空的。实现此算法的 Python 代码见 ActiveCode 1。
def parChecker(symbolString):
s = Stack()
balanced = True
index = 0
while index < len(symbolString) and balanced:
39
3.6.简单括号匹配
symbol = symbolString[index]
if symbol == "(":
s.push(symbol)
else:
if s.isEmpty():
balanced = False
else:
s.pop()
index = index + 1
print(parChecker('((()))'))
print(parChecker('(()'))
ActiveCode 1
40
3.7.符号匹配
3.7.符号匹配
上面显示的匹配括号问题是许多编程语言都会出现的一般情况的特定情况。匹配和嵌套不同种类的开始和结束符号的情
况经常发生。例如,在 Python 中,方括号 [ 和 ] 用于列表,花括号 { 和 } 用于字典。括号 ( 和 ) 用于元祖
和算术表达式。只要每个符号都能保持自己的开始和结束关系,就可以混合符号。符号字符串如
{ { ( [ ] [ ] ) } ( ) }
[ [ { { ( ( ) ) } } ] ]
[ ] [ ] [ ] ( ) { }
这些被恰当的匹配了,因为不仅每个开始符号都有对应的结束符号,而且符号的类型也匹配。
相反这些字符串没法匹配:
( [ ) ]
( ( ( ) ] ) )
[ { ( ) ]
上节的简单括号检查程序可以轻松的扩展处理这些新类型的符号。回想一下,每个开始符号被简单的压入栈中,等待匹
配的结束符号出现。当出现结束符号时,唯一的区别是我们必须检查确保它正确匹配栈顶部开始符号的类型。如果两个
符号不匹配,则字符串不匹配。如果整个字符串都被处理完并且没有什么留在栈中,则字符串匹配。
def parChecker(symbolString):
s = Stack()
balanced = True
index = 0
while index < len(symbolString) and balanced:
symbol = symbolString[index]
if symbol in "([{":
s.push(symbol)
else:
if s.isEmpty():
balanced = False
else:
top = s.pop()
if not matches(top,symbol):
balanced = False
index = index + 1
if balanced and s.isEmpty():
return True
else:
return False
def matches(open,close):
opens = "([{"
closers = ")]}"
return opens.index(open) == closers.index(close)
print(parChecker('{{([][])}()}'))
print(parChecker('[{()]'))
41
3.7.符号匹配
ActiveCode 1
这两个例子表明,栈是计算机语言结构处理非常重要的数据结构。几乎你能想到的任何嵌套符号必须按照平衡匹配的顺
序。栈还有其他重要的用途,我们将在接下来的部分讨论。
42
3.8.十进制转换成二进制
3.8.十进制转换成二进制
在你学习计算机的过程中,你可能已经接触了二进制。二进制在计算机科学中是很重要的,因为存储在计算机内的所有
值都是以 0 和 1 存储的。如果没有能力在二进制数和普通字符串之间转换,我们与计算机之间的交互非常棘手。
整数值是常见的数据项。他们一直用于计算机程序和计算。我们在数学课上学习它们,当然最后用十进制或者基数 10
来表示它们。十进制 233^10 以及对应的二进制表示 11101001^2 分别解释为
但是我们如何能够容易地将整数值转换为二进制呢?答案是 “除 2”算法,它用栈来跟踪二进制结果的数字。
Figure 5
def divideBy2(decNumber):
remstack = Stack()
binString = ""
while not remstack.isEmpty():
binString = binString + str(remstack.pop())
return binString
print(divideBy2(42))
43
3.8.十进制转换成二进制
ActiveCode 1
这个用于二进制转换的算法可以很容易的扩展以执行任何基数的转换。在计算机科学中,通常会使用很多不同的编码。
其中最常见的是二级制,八进制和十六进制。
def baseConverter(decNumber,base):
digits = "0123456789ABCDEF"
remstack = Stack()
newString = ""
while not remstack.isEmpty():
newString = newString + digits[remstack.pop()]
return newString
print(baseConverter(25,2))
print(baseConverter(25,16))
ActiveCode2
44
3.9.中缀前缀和后缀表达式
3.9.中缀前缀和后缀表达式
当你编写一个算术表达式如 B*C 时,表达式的形式使你能够正确理解它。在这种情况下,你知道 B 乘以 C, 因为乘法
运算符 * 出现在表达式中。这种类型的符号称为中缀,因为运算符在它处理的两个操作数之间。看另外一个中缀示
例, A+B*C ,运算符 + 和 * 仍然出现在操作数之间。这里面有个问题是,他们分别作用于哪个运算数上, + 作用
于 A 和 B , 还是 * 作用于 B 和 C?表达式似乎有点模糊。
事实上,你已经读写过这些类型的表达式很长一段时间,所以它们对你不会导致什么问题。这是因为你知道运算符
+ 和 * 。每个运算符都有一个优先级。优先级较高的运算符在优先级较低的运算符之前使用。唯一改变顺序的是括号
的存在。算术运算符的优先顺序是将乘法和除法置于加法和减法之上。如果出现具有相等优先级的两个运算符,则使用
从左到右的顺序排序或关联。
虽然这一切对你来说都很明显。但请记住,计算机需要准确知道要执行的操作符和顺序。一种保证不会对操作顺序产生
混淆的表达式的方法是创建一个称为完全括号表达式的表达式。这种类型的表达式对每个运算符都使用一对括号。括号
没有歧义的指示操作的顺序。也没有必要记住任何优先规则。
+ B) + C) + D) ,因为加法操作从左向右相关联。
改变操作符的位置得到了两种新的表达式格式,前缀和后缀。前缀表达式符号要求所有运算符在它们处理的两个操作数
之前。另一个方面,后缀要求其操作符在相应的操作数之后。看下更多的例子 (见 Table 2)
Table 2
45
3.9.中缀前缀和后缀表达式
Table 3
Table 4 展示了一些其他的例子
Table 4
3.9.1.中缀表达式转换前缀和后缀
到目前为止,我们已经使用特定方法在中缀表达式和等效前缀和后缀表达式符号之间进行转换。正如你可能期望的,有
一些算法来执行转换,允许任何复杂表达式转换。
* ,我们实际上已经将子表达式转换为后缀符号。 如果加法运算符也被移动到其相应的右括号位置并且匹配的左括号被
去除,则将得到完整的后缀表达式(见 Figure 6)。
Figure 6
Figure 7
所以为了转换表达式,无论是对前缀还是后缀符号,先根据操作的顺序把表达式转换成完全括号表达式。然后将包含的
运算符移动到左或右括号的位置,具体取决于需要前缀或后缀符号。
46
3.9.中缀前缀和后缀表达式
Figure 8
3.9.2.中缀转后缀通用法
我们需要开发一个算法来将任何中缀表达式转换为后缀表达式。 为了做到这一点,我们仔细看看转换过程。
当我们处理表达式时,操作符必须保存在某处,因为它们相应的右操作数还没有看到。 此外,这些保存的操作符的顺
序可能由于它们的优先级而需要反转。这是在该示例中的加法和乘法的情况,由于加法运算符在乘法运算符之前,并且
具有较低的优先级,因此需要在使用乘法运算符之后出现。 由于这种顺序的反转,考虑使用栈来保存运算符直到用到
它们是有意义的。
当我们从左到右扫描中缀表达式时,我们将使用栈来保留运算符。这将提供我们在第一个例子中注意到的反转。 堆栈
的顶部将始终是最近保存的运算符。每当我们读取一个新的运算符时,我们需要考虑该运算符如何与已经在栈上的运算
符(如果有的话)比较优先级。
47
3.9.中缀前缀和后缀表达式
Figure 9
def infixToPostfix(infixexpr):
prec = {}
prec["*"] = 3
prec["/"] = 3
prec["+"] = 2
prec["-"] = 2
prec["("] = 1
opStack = Stack()
postfixList = []
tokenList = infixexpr.split()
print(infixToPostfix("A * B + C * D"))
print(infixToPostfix("( A + B ) * C - ( D - E ) * ( F + G )"))
执行结果如下
48
3.9.中缀前缀和后缀表达式
3.9.3.后缀表达式求值
作为最后栈的示例,我们考虑对后缀符号中的表达式求值。在这种情况下,栈再次是我们选择的数据结构。但是,在扫
描后缀表达式时,它必须等待操作数,而不像上面的转换算法中的运算符。 解决问题的另一种方法是,每当在输入上
看到运算符时,计算两个最近的操作数。
在这种情况下,下一个符号是另一个操作数。所以,像先前一样,压入栈中。并检查下一个符号。现在我们看到了操作
符 * ,这意味着需要将两个最近的操作数相乘。通过弹出栈两次,我们可以得到正确的两个操作数,然后执行乘法
(这种情况下结果为 30)。
我们现在可以通过将其放回栈中来处理此结果,以便它可以表示为表达式后面的运算符的操作数。当处理最后一个操作
符时,栈上只有一个值,弹出并返回它作为表达式的结果。Figure 10 展示了整个示例表达式的栈的内容。
Figure 10
49
3.9.中缀前缀和后缀表达式
Figure 11
def postfixEval(postfixExpr):
operandStack = Stack()
tokenList = postfixExpr.split()
print(postfixEval('7 8 + 3 2 + /'))
50
3.10.什么是队列
3.10.什么是队列
队列是项的有序结合,其中添加新项的一端称为队尾,移除项的一端称为队首。当一个元素从队尾进入队列时,一直向
队首移动,直到它成为下一个需要移除的元素为止。
最近添加的元素必须在队尾等待。集合中存活时间最长的元素在队首,这种排序成为 FIFO,先进先出,也被成为先到
先得。
队列的最简单的例子是我们平时不时会参与的列。排队等待电影,在杂货店的收营台等待,在自助餐厅排队等待(这样
我们可以弹出托盘栈)。行为良好的线或队列是有限制的,因为它只有一条路,只有一条出路。不能插队,也不能离
开。你只有等待了一定的时间才能到前面。Figure 1 展示了一个简单的 Python 对象队列。
Figure 1
计算机科学也有常见的队列示例。我们的计算机实验室有 30 台计算机与一台打印机联网。当学生想要打印时,他们的
打印任务与正在等待的所有其他打印任务“一致”。第一个进入的任务是先完成。如果你是最后一个,你必须等待你前面
的所有其他任务打印。我们将在后面更详细地探讨这个有趣的例子。
除了打印队列,操作系统使用多个不同的队列来控制计算机内的进程。下一步做什么的调度通常基于尽可能快地执行程
序和尽可能多的服务用户的排队算法。此外,当我们敲击键盘时,有时屏幕上出现的字符会延迟。这是由于计算机在那
一刻做其他工作。按键的内容被放置在类似队列的缓冲器中,使得它们最终可以以正确的顺序显示在屏幕上。
51
3.11.队列抽象数据类型
3.11.队列抽象数据类型
队列抽象数据类型由以下结构和操作定义。如上所述,队列被构造为在队尾添加项的有序集合,并且从队首移除。队列
保持 FIFO 排序属性。 队列操作如下。
Table 1
52
3.12.Python实现队列
3.12.Python实现队列
我们为了实现队列抽象数据类型创建一个新类。和前面一样,我们将使用列表集合来作为构建队列的内部表示。
class Queue:
def __init__(self):
self.items = []
def isEmpty(self):
return self.items == []
def dequeue(self):
return self.items.pop()
def size(self):
return len(self.items)
Listing 1
进一步的操作这个队列产生如下结果:
>>> q.size()
3
>>> q.isEmpty()
False
>>> q.enqueue(8.4)
>>> q.dequeue()
4
>>> q.dequeue()
'dog'
>>> q.size()
2
53
3.13.模拟:烫手山芋
3.13.模拟:烫手山芋
队列的典型应用之一是模拟需要以 FIFO 方式管理数据的真实场景。首先,让我们看看孩子们的游戏烫手山芋,在这个
游戏中(见 Figure 2),孩子们围成一个圈,并尽可能快的将一个山芋递给旁边的孩子。在某一个时间,动作结束,有
山芋的孩子从圈中移除。游戏继续开始直到剩下最后一个孩子。
Figure 2
这个游戏相当于著名的约瑟夫问题,一个一世纪著名历史学家弗拉维奥·约瑟夫斯的传奇故事。故事讲的是,他和他的
39 个战友被罗马军队包围在洞中。他们决定宁愿死,也不成为罗马人的奴隶。他们围成一个圈,其中一人被指定为第
一个人,顺时针报数到第七人,就将他杀死。约瑟夫斯是一个成功的数学家,他立即想出了应该坐到哪才能成为最后一
人。最后,他加入了罗马的一方,而不是杀了自己。你可以找到这个故事的不同版本,有些说是每次报数 3 个人,有人
说允许最后一个人逃跑。无论如何,思想是一样的。
为了模拟这个圈,我们使用队列(见 Figure3)。假设拿着山芋的孩子在队列的前面。当拿到山芋的时候,这个孩子将
先出列再入队列,把他放在队列的最后。经过 num 次的出队入队后,前面的孩子将被永久移除队列。并且另一个周期
开始,继续此过程,直到只剩下一个名字(队列的大小为 1)。
Figure 3
54
3.13.模拟:烫手山芋
simqueue.dequeue()
return simqueue.dequeue()
print(hotPotato(["Bill","David","Susan","Jane","Kent","Brad"],7))
Active code 1
请注意,在此示例中,计数常数的值大于列表中的名称数。这不是一个问题,因为队列像一个圈,计数会重新回到开
始,直到达到计数值。另外,请注意,列表加载到队列中以使列表上的名字位于队列的前面。在这种情况下, Bill 是
列表中的第一个项,因此他在队列的前面。
55
3.14.模拟:打印机
3.14.模拟:打印机
一个更有趣的模拟是允许我们研究本节前面描述的打印机的行为,回想一下,当学生向共享打印机发送打印任务时,任
务被放置在队列中以便以先来先服务的方式被处理。此配置会出现许多问题。其中最重要的点可能是打印机是否能够处
理一定量的工作。如果它不能,学生将等待太长时间打印,可能会错过他们的下一节课。
在计算机科学实验室里考虑下面的情况。平均每天大约10名学生在任何给定时间在实验室工作。这些学生通常在此期间
打印两次,这些任务的长度范围从1到20页。实验室中的打印机较旧,每分钟以草稿质量可以处理10页。打印机可以切
换以提供更好的质量,但是它将每分钟只能处理五页。较慢的打印速度可能会使学生等待太久。应使用什么页面速率?
我们可以通过建立一个模拟实验来决定。我们将需要为学生,打印任务和打印机构建表现表示(Figure 4)。当学生提
交打印任务时,我们将把他们添加到等待列表中,一个打印任务的队列。 当打印机完成任务时,它将检查队列,以检
查是否有剩余的任务要处理。我们感兴趣的是学生等待他们的论文打印的平均时间。这等于任务在队列中等待的平均时
间量。
Figure 4
56
3.14.模拟:打印机
3.14.1.主要模拟步骤
1. 创建打印任务的队列,每个任务都有个时间戳。队列启动的时候为空。
2. 每秒(currentSecond):
3.14.2 Python 实现
为了设计此模拟,我们将为上述三个真实世界对象创建类: Printer , Task , PrintQueue
class Printer:
def __init__(self, ppm):
self.pagerate = ppm
self.currentTask = None
self.timeRemaining = 0
def tick(self):
if self.currentTask != None:
self.timeRemaining = self.timeRemaining - 1
if self.timeRemaining <= 0:
self.currentTask = None
def busy(self):
if self.currentTask != None:
return True
else:
return False
def startNext(self,newtask):
self.currentTask = newtask
self.timeRemaining = newtask.getPages() * 60/self.pagerate
Listing 2
每个任务还需要保存一个时间戳用于计算等待时间。此时间戳将表示任务被创建并放置到打印机队列中的时间。可以使
用 waitTime 方法来检索在打印开始之前队列中花费的时间。
57
3.14.模拟:打印机
import random
class Task:
def __init__(self,time):
self.timestamp = time
self.pages = random.randrange(1,21)
def getStamp(self):
return self.timestamp
def getPages(self):
return self.pages
Listing 3
import random
labprinter = Printer(pagesPerMinute)
printQueue = Queue()
waitingtimes = []
if newPrintTask():
task = Task(currentSecond)
printQueue.enqueue(task)
labprinter.tick()
averageWait=sum(waitingtimes)/len(waitingtimes)
print("Average Wait %6.2f secs %3d tasks remaining."%(averageWait,printQueue.size()))
def newPrintTask():
num = random.randrange(1,181)
if num == 180:
return True
else:
return False
for i in range(10):
simulation(3600,5)
Listing 4
当我们运行模拟时,我们不应该担心每次的结果不同。这是由于随机数的概率性质决定的。 因为模拟的参数可以被调
整,我们对调整后可能发生的趋势感兴趣。 这里有一些结果。
58
3.14.模拟:打印机
>>>for i in range(10):
simulation(3600,5)
>>>for i in range(10):
simulation(3600,10)
3.14.3.讨论
我们试图回答一个问题,即当前打印机是否可以处理任务负载,如果它设置为打印更好的质量,较慢的页面速率。我们
采用的方法是编写一个模拟打印任务作为各种页数和到达时间的随机事件的模拟。
因此,我们说减慢打印机的速度以获得更好的质量可能不是一个好主意。学生们不能等待他们的论文打印完,特别是当
他们需要到下一个班级。六分钟的等待时间太长了。
这种类型的模拟分析允许我们回答许多问题,通常被称为“如果”的问题。我们需要做的是改变模拟使用的参数,我们可
以模拟任何数量。例如
* 如果入学人数增加,平均学生人数增加 20 人 该怎么办?
* 如果是星期六,学生不需要上课怎么办?他们能负担得了吗?
* 如果平均打印任务的大小减少了,由于 Python 是一个强大的语言,程序往往要短得多?
这些问题都可以通过修改上述模拟来回答。然而,重要的是要记住,模拟有效取决于构建它的假设是没问题的。关于每
小时打印任务的数量和每小时的学生数量的真实数据对于构建鲁棒性的模拟是必要的。
59
3.15.什么是Deque
3.15.什么是Deque
deque(也称为双端队列)是与队列类似的项的有序集合。它有两个端部,首部和尾部,并且项在集合中保持不变。
deque 不同的地方是添加和删除项是非限制性的。可以在前面或后面添加新项。同样,可以从任一端移除现有项。在某
种意义上,这种混合线性结构提供了单个数据结构中的栈和队列的所有能力。 Figure 1 展示了一个 Python 数据对象的
deque 。
Figure 1
60
3.16.Deque抽象数据类型
3.16.Deque抽象数据类型
deque 抽象数据类型由以下结构和操作定义。如上所述,deque 被构造为项的有序集合,其中项从首部或尾部的任一端
添加和移除。下面给出了 deque 操作。
Table 1
61
3.17.Python实现Deque
3.17.Python实现Deque
正如我们在前面的部分中所做的,我们将为抽象数据类型 deque 的实现创建一个新类。同样,Python 列表将提供一组
非常好的方法来构建 deque 的细节。我们的实现(Listing 1)将假定 deque 的尾部在列表中的位置为 0。
class Deque:
def __init__(self):
self.items = []
def isEmpty(self):
return self.items == []
def removeFront(self):
return self.items.pop()
def removeRear(self):
return self.items.pop(0)
def size(self):
return len(self.items)
Listing 1
62
3.18.回文检查
3.18.回文检查
使用 deque 数据结构可以容易地解决经典回文问题。回文是一个字符串,读取首尾相同的字符,例如, radar toot
madam 。 我们想构造一个算法输入一个字符串,并检查它是否是一个回文。
Figure 2
我们可以直接删除并比较首尾字符,只有当它们匹配时才继续。如果可以持续匹配首尾字符,我们最终要么用完字符,
要么留出大小为 1 的deque,取决于原始字符串的长度是偶数还是奇数。在任一情况下,字符串都是回文。 回文检查的
完整功能在 ActiveCode 1 中。
def palchecker(aString):
chardeque = Deque()
for ch in aString:
chardeque.addRear(ch)
stillEqual = True
return stillEqual
print(palchecker("lsdkjfskf"))
print(palchecker("radar"))
63
3.18.回文检查
ActiveCode 1
64
3.19.列表
3.19.列表
在对基本数据结构的讨论中,我们使用 Python 列表来实现所呈现的抽象数据类型。列表是一个强大但简单的收集机
制,为程序员提供了各种各样的操作。然而,不是所有的编程语言都包括列表集合。在这些情况下,列表的概念必须由
程序员实现。
列表是项的集合,其中每个项保持相对于其他项的相对位置。更具体地,我们将这种类型的列表称为无序列表。我们可
以将列表视为具有第一项,第二项,第三项等等。我们还可以引用列表的开头(第一个项)或列表的结尾(最后一个
项)。为了简单起见,我们假设列表不能包含重复项。
65
3.20.无序列表抽象数据类型
3.20.无序列表抽象数据类型
如上所述,无序列表的结构是项的集合,其中每个项保持相对于其他项的相对位置。下面给出了一些可能的无序列表操
作。
List() 创建一个新的空列表。它不需要参数,并返回一个空列表。
add(item) 向列表中添加一个新项。它需要 item 作为参数,并不返回任何内容。假定该 item 不在列表中。
remove(item) 从列表中删除该项。它需要 item 作为参数并修改列表。假设项存在于列表中。
search(item) 搜索列表中的项目。它需要 item 作为参数,并返回一个布尔值。
isEmpty() 检查列表是否为空。它不需要参数,并返回布尔值。
size()返回列表中的项数。它不需要参数,并返回一个整数。
append(item) 将一个新项添加到列表的末尾,使其成为集合中的最后一项。它需要 item 作为参数,并不返回任何
内容。假定该项不在列表中。
index(item) 返回项在列表中的位置。它需要 item 作为参数并返回索引。假定该项在列表中。
insert(pos,item) 在位置 pos 处向列表中添加一个新项。它需要 item 作为参数并不返回任何内容。假设该项不在
列表中,并且有足够的现有项使其有 pos 的位置。
pop() 删除并返回列表中的最后一个项。假设该列表至少有一个项。
pop(pos) 删除并返回位置 pos 处的项。它需要 pos 作为参数并返回项。假定该项在列表中。
66
3.21.实现无序列表:链表
3.21.实现无序列表:链表
为了实现无序列表,我们将构造通常所知的链表。回想一下,我们需要确保我们可以保持项的相对定位。然而,没有要
求我们维持在连续存储器中的定位。例如,考虑 Figure 1 中所示的项的集合。看来这些值已被随机放置。如果我们可以
在每个项中保持一些明确的信息,即下一个项的位置(参见 Figure 2),则每个项的相对位置可以通过简单地从一个项
到下一个项的链接来表示。
Figure 1
Figure 2
要注意,必须明确地指定链表的第一项的位置。一旦我们知道第一个项在哪里,第一个项目可以告诉我们第二个是什
么,等等。外部引用通常被称为链表的头。类似地,最后一个项需要知道没有下一个项。
3.21.1.Node 类
链表实现的基本构造块是节点。每个节点对象必须至少保存两个信息。首先,节点必须包含列表项本身。我们将这个称
为节点的数据字段。此外,每个节点必须保存对下一个节点的引用。 Listing 1 展示了 Python 实现。要构造一个节点,
需要提供该节点的初始数据值。下面的赋值语句将产生一个包含值 93 的节点对象(见 Figure 3)。应该注意,我们通
常会如 Figure 4 所示表示一个节点对象。Node 类还包括访问,修改数据和访问下一个引用的常用方法。
class Node:
def __init__(self,initdata):
self.data = initdata
self.next = None
def getData(self):
return self.data
def getNext(self):
return self.next
67
3.21.实现无序列表:链表
def setData(self,newdata):
self.data = newdata
def setNext(self,newnext):
self.next = newnext
Listing 1
我们创建一个 Node 对象
Figure 3
Figure 4
3.21.2.Unordered List 类
如上所述,无序列表将从一组节点构建,每个节点通过显式引用链接到下一个节点。只要我们知道在哪里找到第一个节
点(包含第一个项),之后的每个项可以通过连续跟随下一个链接找到。考虑到这一点,UnorderedList 类必须保持对
第一个节点的引用。Listing 2 显示了构造函数。注意,每个链表对象将维护对链表头部的单个引用。
class UnorderedList:
def __init__(self):
self.head = None
Listing 2
我们构建一个空的链表。赋值语句
68
3.21.实现无序列表:链表
链接结构中第一个节点的单个引用。
Figure 5
Figure 6
Listing 3 中所示的 isEmpty 方法只是检查链表头是否是 None 的引用。 布尔表达式 self.head == None 的结果只有在链
表中没有节点时才为真。由于新链表为空,因此构造函数和空检查必须彼此一致。这显示了使用引用 None 来表示链接
结构的 end 的优点。在 Python 中,None 可以与任何引用进行比较。如果它们都指向相同的对象,则两个引用是相等
的。我们将在其他方法中经常使用它。
def isEmpty(self):
return self.head == None
Listing 3
回想一下,链表结构只为我们提供了一个入口点,即链表的头部。所有其他节点只能通过访问第一个节点,然后跟随下
一个链接到达。这意味着添加新节点的最简单的地方就在链表的头部。 换句话说,我们将新项作为链表的第一项,现
有项将需要链接到这个新项后。
>>> mylist.add(31)
>>> mylist.add(77)
>>> mylist.add(17)
>>> mylist.add(93)
>>> mylist.add(26)
>>> mylist.add(54)
Figure 6
因为 31 是添加到链表的第一个项,它最终将是链表中的最后一个节点,因为每个其他项在其前面添加。此外,由于 54
是添加的最后一项,它将成为链表的第一个节点中的数据值。
69
3.21.实现无序列表:链表
def add(self,item):
temp = Node(item)
temp.setNext(self.head)
self.head = temp
Listing 4
Figure 7
Figure 8
def size(self):
current = self.head
count = 0
while current != None:
count = count + 1
current = current.getNext()
return count
70
3.21.实现无序列表:链表
Listing 5
Figure 9
在链表中搜索也使用遍历技术。当我们访问链表中的每个节点时,我们将询问存储在其中的数据是否与我们正在寻找的
项匹配。然而,在这种情况下,我们不必一直遍历到列表的末尾。事实上,如果我们到达链表的末尾,这意味着我们正
在寻找的项不存在。此外,如果我们找到项,没有必要继续。
def search(self,item):
current = self.head
found = False
while current != None and not found:
if current.getData() == item:
found = True
else:
current = current.getNext()
return found
Listing 6
>>> mylist.search(17)
True
Figure 10
71
3.21.实现无序列表:链表
remove 方法需要两个逻辑步骤。首先,我们需要遍历列表寻找我们要删除的项。一旦我们找到该项(我们假设它存
在),删除它。第一步非常类似于搜索。从设置到链表头部的外部引用开始,我们遍历链接,直到我们发现正在寻找的
项。因为我们假设项存在,我们知道迭代将在 current 变为 None 之前停止。这意味着我们可以简单地使用 found
布尔值。
为了删除包含项的节点,我们需要修改上一个节点中的链接,以便它指向当前之后的节点。不幸的是,链表遍历没法回
退。因为 current 指我们想要进行改变的节点之前的节点,所以进行修改太迟了。
def remove(self,item):
current = self.head
previous = None
found = False
while not found:
if current.getData() == item:
found = True
else:
previous = current
current = current.getNext()
if previous == None:
self.head = current.getNext()
else:
previous.setNext(current.getNext())
Listing 7
Figure 11
72
3.21.实现无序列表:链表
Figure 12
Figure 13
Figure 14
73
3.22.有序列表抽象数据结构
3.22.有序列表抽象数据结构
我们现在将考虑一种称为有序列表的列表类型。例如,如果上面所示的整数列表是有序列表(升序),则它可以写为
17,26,31,54,77和93 。由于 17 是最小项,它占据第一位置。同样,由于 93 是最大的,它占据最后的位置。
有序列表的结构是项的集合,其中每个项保存基于项的一些潜在特性的相对位置。排序通常是升序或降序,并且我们假
设列表项具有已经定义的有意义的比较运算。许多有序列表操作与无序列表的操作相同。
OrderedList() 创建一个新的空列表。它不需要参数,并返回一个空列表。
add(item) 向列表中添加一个新项。它需要 item 作为参数,并不返回任何内容。假定该 item 不在列表中。
remove(item) 从列表中删除该项。它需要 item 作为参数并修改列表。假设项存在于列表中。
search(item) 搜索列表中的项目。它需要 item 作为参数,并返回一个布尔值。
isEmpty() 检查列表是否为空。它不需要参数,并返回布尔值。
size()返回列表中的项数。它不需要参数,并返回一个整数。
index(item) 返回项在列表中的位置。它需要 item 作为参数并返回索引。假定该项在列表中。
pop() 删除并返回列表中的最后一个项。假设该列表至少有一个项。
pop(pos) 删除并返回位置 pos 处的项。它需要 pos 作为参数并返回项。假定该项在列表中。
74
3.23.实现有序列表
3.23.实现有序列表
为了实现有序列表,我们必须记住项的相对位置是基于一些潜在的特性。上面给出的整数的有序列表 17,26,31,54,77
Figure 15
class OrderedList:
def __init__(self):
self.head = None
Listing 8
搜索无序列表需要我们一次遍历一个节点,直到找到我们正在寻找的节点或者没找到节点(None)。事实证明,相同
的方法在有序列表也有效。然而,在项不在链表中的情况下,我们可以利用该顺序来尽快停止搜索。
Figure 16
def search(self,item):
current = self.head
found = False
stop = False
while current != None and not found and not stop:
if current.getData() == item:
found = True
else:
if current.getData() > item:
75
3.23.实现有序列表
stop = True
else:
current = current.getNext()
return found
Listing 9
Figure 17
def add(self,item):
current = self.head
previous = None
stop = False
while current != None and not stop:
if current.getData() > item:
stop = True
else:
previous = current
current = current.getNext()
temp = Node(item)
if previous == None:
temp.setNext(self.head)
self.head = temp
else:
temp.setNext(current)
previous.setNext(temp)
Listing 10
76
3.23.实现有序列表
3.23.1.链表分析
为了分析链表操作的复杂性,我们需要考虑它们是否需要遍历。考虑具有 n 个节点的链表。 isEmpty 方法是 O(1),因
为它需要一个步骤来检查头的引用为 None 。另一方面, size 将总是需要 n 个步骤,因为不从头到尾地移动没法知道
有多少节点在链表中。因此,长度为 O(n)。将项添加到无序列表始终是O(1),因为我们只是将新节点放置在链表的头
部。但是,搜索和删除,以及添加有序列表,都需要遍历过程。虽然平均他们可能只需要遍历节点的一半,这些方法都
是 O(n),因为在最坏的情况下,都将处理列表中的每个节点。
77
3.24.总结
3.24.总结
线性数据结构以有序的方式保存它们的数据。
栈是维持 LIFO,后进先出,排序的简单数据结构。
栈的基本操作是 push , pop 和 isEmpty 。
队列是维护 FIFO(先进先出)排序的简单数据结构。
队列的基本操作是 enqueue , dequeue 和 isEmpty 。
前缀,中缀和后缀都是写表达式的方法。
栈对于设计计算解析表达式算法非常有用。
栈可以提供反转特性。
队列可以帮助构建定时仿真。
模拟使用随机数生成器来创建真实情况,并帮助我们回答“假设”类型的问题。
Deques 是允许类似栈和队列的混合行为的数据结构。
deque 的基本操作是 addFront , addRear , removeFront , removeRear 和 isEmpty 。
列表是项的集合,其中每个项目保存相对位置。
链表实现保持逻辑顺序,而不需要物理存储要求。
修改链表头是一种特殊情况。
78
4.递归
79
4.1.目标
4.1.目标
本章的目标如下:
要理解可能难以解决的复杂问题有一个简单的递归解决方案。
学习如何递归地写出程序。
理解和应用递归的三个定律。
将递归理解为一种迭代形式。
实现问题的递归公式化。
了解计算机系统如何实现递归。
80
4.2.什么是递归
4.2.什么是递归
递归是一种解决问题的方法,将问题分解为更小的子问题,直到得到一个足够小的问题可以被很简单的解决。通常递归
涉及函数调用自身。递归允许我们编写优雅的解决方案,解决可能很难编程的问题。
81
4.3.计算整数列表和
4.3.计算整数列表和
我们将以一个简单的问题开始,你已经知道如何不使用递归解决。 假设你想计算整数列表的总和,例
如: [1,3,5,7,9] 。 计算总和的迭代函数见ActiveCode 1。函数使用累加器变量( theSum )来计算列表中所有整数的
和,从 0 开始,加上列表中的每个数字。
def listsum(numList):
theSum = 0
for i in numList:
theSum = theSum + i
return theSum
print(listsum([1,3,5,7,9]))
Activecode 1
我们也可以把表达式用另一种方式括起来
def listsum(numList):
if len(numList) == 1:
return numList[0]
else:
return numList[0] + listsum(numList[1:])
print(listsum([1,3,5,7,9]))
Active code 2
82
4.3.计算整数列表和
Figure 1
Figure 2
83
4.3.计算整数列表和
84
4.4.递归的三定律
4.4.递归的三定律
像阿西莫夫机器人,所有递归算法必须服从三个重要的定律:
1. 递归算法必须具有基本情况。
2. 递归算法必须改变其状态并向基本情况靠近。
3. 递归算法必须以递归方式调用自身。
为了遵守第二定律,我们必须将算法向基本情况的状态改变。状态的改变意味着该算法正在使用的一些数据被修改。通
常,表示问题的数据在某种程度上变小。在 listsum 算法中,我们的主要数据结构是一个列表,因此我们必须将我们
的状态转换工作集中在列表上。因为基本情况是长度 1 的列表,所以朝向基本情况的自然进展是缩短列表。在
Activecode 2 第五行,我们调用 listsum 生成一个较短的列表。
最后的法则是算法必须调用自身。这是递归的定义。递归对于许多新手程序员来说是一个混乱的概念。作为一个新手程
序员,你已经知道函数是有益的,因为你可以将一个大问题分解成较小的问题。较小的问题可以通过编写一个函数来解
决。我们用一个函数解决问题,但该函数通过调用自己解决问题!该逻辑不是循环;递归的逻辑是通过将问题分解成更
小和更容易的问题来解决的优雅表达。
在本章的剩余部分,我们将讨论更多递归的例子。在每种情况下,我们将集中于使用递归的三个定律来设计问题的解决
方案。
85
4.5.整数转换为任意进制字符串
4.5.整数转换为任意进制字符串
假设你想将一个整数转换为一个二进制和十六进制字符串。例如,将整数 10 转换为十进制字符串表示为 10 ,或将
其字符串表示为二进制 1010 。虽然有很多算法来解决这个问题,包括在栈部分讨论的算法,但递归的解决方法非常优
雅。
知道我们的基本情况是什么意味着整个算法将分成三个部分:
1. 将原始数字减少为一系列单个位数字。
2. 使用查找将单个位数字数字转换为字符串。
3. 将单个位字符串连接在一起以形成最终结果。
下一步是找到改变其状态的方法并向基本情况靠近。由于我们示例为整数,所以考虑什么数学运算可以减少一个数字。
最可能的候选是除法和减法。虽然减法可能可以实现,但我们不清楚应该减去多少。使用余数的整数除法为我们提供了
一个明确的方向。让我们看看如果我们将一个数字除以我们试图转换的基数,会发生什么。
Figure 3
def toStr(n,base):
convertString = "0123456789ABCDEF"
if n < base:
return convertString[n]
else:
return toStr(n//base,base) + convertString[n%base]
print(toStr(1453,16))
86
4.5.整数转换为任意进制字符串
Figure 4
87
4.6.栈帧:实现递归
4.6.栈帧:实现递归
假设不是将递归调用的结果与来自 convertString 的字符串拼接到 toStr,我们修改了算法,以便在进行递归调用之前将
字符串入栈。此修改的算法的代码展示在 ActiveCode 1 中。
rStack = Stack()
def toStr(n,base):
convertString = "0123456789ABCDEF"
while n > 0:
if n < base:
rStack.push(convertString[n])
else:
rStack.push(convertString[n % base])
n = n // base
res = ""
while not rStack.isEmpty():
res = res + str(rStack.pop())
return res
print(toStr(1453,16))
ActiveCode 1
Figure 5
88
4.6.栈帧:实现递归
Figure 6
栈帧还为函数使用的变量提供了一个作用域。 即使我们重复地调用相同的函数,每次调用都会为函数本地的变量创建
一个新的作用域。
89
4.7.介绍:可视化递归
4.7.介绍:可视化递归
在上一节中,我们讨论了一些使用递归很容易解决的问题; 然而,我们可能很难找到一个模型或一种可视化方法知道在
递归函数中发生了什么。这使得递归难以让人掌握。在本节中,我们将看到几个使用递归绘制一些有趣图片的例子。当
你看到这些图片的形状,你会对递归过程有新的认识,可能有助于巩固你对递归理解。
模块后,我们创建一个乌龟。当乌龟被创建时,它也创建一个窗口来绘制。接下来我们定义 drawSpir
al
函数。这个简
单函数的基本情况是当我们想要绘制的线的长度(由 len 参数给出)减小到零或更小时。如果线的长度大于零,我们让
乌龟以 len 单位前进,然后向右转 90 度。当我们再次调用 drawSpir
al
并缩短长度时递归。在ActiveCode 1 结束时,
你会注意到我们调用函数 myWin.exitonclick() ,这是一个方便的缩小窗口的方法,使乌龟进入等待模式,直到你单击
窗口,然后程序清理并退出。
import turtle
myTurtle = turtle.Turtle()
myWin = turtle.Screen()
drawSpiral(myTurtle,100)
myWin.exitonclick()
这是关于你知道的所有龟图形,以制作一些令人印象深刻的涂鸦。我们的下一个程序,将绘制一个分形树。分形来自数
学的一个分支,并且与递归有很多共同之处。分形的定义是,当你看着它时,无论你放大多少,分形有相同的基本形
状。大自然的一些例子是大陆的海岸线,雪花,山脉,甚至树木或灌木。这些自然现象中的许多的分形性质使得程序员
能够为计算机生成的电影生成非常逼真的风景。在我们的下一个例子中,将生成一个分形树。
要理解这如何工作,需要想一想如何使用分形词汇来描述树。记住,我们上面说过,分形是在所有不同的放大倍率下看
起来是一样的。如果我们将它翻译成树木和灌木,我们可能会说,即使一个小树枝也有一个整体树的相同的形状和特
征。有了这个想法,我们可以说一棵树是树干,一棵较小的树向右走,另一棵较小的树向左走。如果你用递归的思想考
虑这个定义,这意味着我们将树的递归定义应用到较小的左树和右树。
def tree(branchLen,t):
if branchLen > 5:
t.forward(branchLen)
t.right(20)
tree(branchLen-15,t)
t.left(40)
tree(branchLen-10,t)
t.right(20)
90
4.7.介绍:可视化递归
t.backward(branchLen)
Listing 1
import turtle
def tree(branchLen,t):
if branchLen > 5:
t.forward(branchLen)
t.right(20)
tree(branchLen-15,t)
t.left(40)
tree(branchLen-15,t)
t.right(20)
t.backward(branchLen)
def main():
t = turtle.Turtle()
myWin = turtle.Screen()
t.left(90)
t.up()
t.backward(100)
t.down()
t.color("green")
tree(75,t)
myWin.exitonclick()
main()
Activecode 2
91
4.7.介绍:可视化递归
注意树上的每个分支点如何对应于递归调用,并注意树的右半部分如何一直绘制到它的最短的树枝。你可以在 Figure 1
中看到这一点。现在,注意程序如何工作,它的方式是直到树的整个右侧绘制完成回到树干。你可以在 Figure 2 中看到
树的右半部分。然后绘制树的左侧,但不是尽可能远地向左移动。相反,直到我们进入到左树最小的枝干,左树的右半
部分才开始绘制。
Figure 1
Figure 2
这个简单的树程序只是一个起点,你会注意到树看起来不是特别现实,因为自然不像计算机程序那样对称。
92
4.8.谢尔宾斯基三角形
4.8.谢尔宾斯基三角形
另一个展现自相似性的分形是谢尔宾斯基三角形。 Figure 3 是一个示例。谢尔宾斯基三角形阐明了三路递归算法。用手
绘制谢尔宾斯基三角形的过程很简单。 从一个大三角形开始。通过连接每一边的中点,将这个大三角形分成四个新的
三角形。忽略刚刚创建的中间三角形,对三个小三角形中的每一个应用相同的过程。 每次创建一组新的三角形时,都
会将此过程递归应用于三个较小的角三角形。 如果你有足够的铅笔,你可以无限重复这个过程。在继续阅读之前,你
可以尝试运用所描述的方法自己绘制谢尔宾斯基三角形。
Figure 3
因为我们可以无限地应用算法,什么是基本情况? 我们将看到,基本情况被任意设置为我们想要将三角形划分成块的
次数。有时我们把这个数字称为分形的“度”。 每次我们进行递归调用时,我们从度中减去 1,直到 0。当我们达到 0 度
时,我们停止递归。在 Figure 3 中生成谢尔宾斯基三角形的代码见 ActiveCode 1。
import turtle
def drawTriangle(points,color,myTurtle):
myTurtle.fillcolor(color)
myTurtle.up()
myTurtle.goto(points[0][0],points[0][1])
myTurtle.down()
myTurtle.begin_fill()
myTurtle.goto(points[1][0],points[1][1])
myTurtle.goto(points[2][0],points[2][1])
myTurtle.goto(points[0][0],points[0][1])
myTurtle.end_fill()
def getMid(p1,p2):
return ( (p1[0]+p2[0]) / 2, (p1[1] + p2[1]) / 2)
def sierpinski(points,degree,myTurtle):
colormap = ['blue','red','green','white','yellow',
'violet','orange']
drawTriangle(points,colormap[degree],myTurtle)
93
4.8.谢尔宾斯基三角形
if degree > 0:
sierpinski([points[0],
getMid(points[0], points[1]),
getMid(points[0], points[2])],
degree-1, myTurtle)
sierpinski([points[1],
getMid(points[0], points[1]),
getMid(points[1], points[2])],
degree-1, myTurtle)
sierpinski([points[2],
getMid(points[2], points[1]),
getMid(points[0], points[2])],
degree-1, myTurtle)
def main():
myTurtle = turtle.Turtle()
myWin = turtle.Screen()
myPoints = [[-100,-50],[0,100],[100,-50]]
sierpinski(myPoints,3,myTurtle)
myWin.exitonclick()
main()
Activecode 1
ActiveCode 1 中的程序遵循上述概念。谢尔宾斯基的第一件事是绘制外三角形。接下来,有三个递归调用,每个使我们
在连接中点获得新的三角形。我们再次使用 Python 附带的 turtle 模块。你可以通过使用 help('turtle') 了解
turtle 可用方法的详细信息。
看下代码,想想绘制三角形的顺序。虽然三角的确切顺序取决于如何指定初始集,我们假设三角按左下,上,右下顺
序。由于谢尔宾斯基函数调用自身,谢尔宾斯基以它的方式递归到左下角最小的三角形,然后开始填充其余的三角形。
填充左下角顶角中的小三角形。最后,它填充在左下角中右下角的最小三角形。
有时,根据函数调用图来考虑递归算法是有帮助的。Figure 4 展示了递归调用总是向左移动。活动函数以黑色显示,非
活动函数显示为灰色。向 Figure 4 底部越近,三角形越小。该功能一次完成一次绘制; 一旦它完成了绘制,它移动到左
下方底部中间位置,然后继续这个过程。
94
4.8.谢尔宾斯基三角形
Figure 4
95
4.10.汉诺塔游戏
4.10.汉诺塔游戏
汉诺塔是由法国数学家爱德华·卢卡斯在 1883 年发明的。他的灵感来自一个传说,有一个印度教寺庙,将谜题交给年轻
的牧师。在开始的时候,牧师们被给予三根杆和一堆 64 个金碟,每个盘比它下面一个小一点。他们的任务是将所有 64
个盘子从三个杆中一个转移到另一个。有两个重要的约束,它们一次只能移动一个盘子,并且它们不能在较小的盘子顶
部上放置更大的盘子。牧师日夜不停每秒钟移动一块盘子。当他们完成工作时,传说,寺庙会变成灰尘,世界将消失。
Figure 1 展示了在从第一杆移动到第三杆的过程中的盘的示例。请注意,如规则指定,每个杆上的盘子都被堆叠起来,
以使较小的盘子始终位于较大盘的顶部。如果你以前没有尝试过解决这个难题,你现在应该尝试下。你不需要花哨的盘
子,一堆书或纸张都可以。
Figure 1
我们如何递归地解决这个问题?我们的基本情况是什么?让我们从下到上考虑这个问题。假设你有一个五个盘子的塔,
在杆一上。如果你已经知道如何将四个盘子移动到杆二上,那么你可以轻松地将最底部的盘子移动到杆三,然后再将四
个盘子从杆二移动到杆三。但是如果你不知道如何移动四个盘子怎么办?假设你知道如何移动三个盘子到杆三;那么很
容易将第四个盘子移动到杆二,并将它们从杆三移动到它们的顶部。但是如果你不知道如何移动三个盘子呢?如何将两
个盘子移动到杆二,然后将第三个盘子移动到杆三,然后移动两个盘子到它的顶部?但是如果你还不知道该怎么办呢?
当然你会知道移动一个盘子到杆三足够容易。这听起来像是基本情况。
这里是如何使用中间杆将塔从起始杆移动到目标杆的步骤:
只要我们遵守规则,较大的盘子保留在栈的底部,我们可以使用递归的三个步骤,处理任何更大的盘子。上面概要中唯
一缺失的是识别基本情况。最简单的汉诺塔是一个盘子的塔。在这种情况下,我们只需要将一个盘子移动到其最终目的
地。 一个盘子的塔将是我们的基本情况。 此外,上述步骤通过在步骤1和3中减小塔的高度,使我们趋向基本情况。
Listing 1 展示了解决汉诺塔的 Python 代码。
96
4.10.汉诺塔游戏
Listing 1
请注意,Listing 1 中的代码与描述几乎相同。算法的简单性的关键在于我们进行两个不同的递归调用,一个在第 3 行
上,另一个在第 5 行。在第 3 行上,我们将初始杆上的底部圆盘移动到中间。下一行简单地将底部盘移动到其最终的位
置。然后在第 5 行上,我们将塔从中间杆移动到最大盘子的顶部。当塔高度为 0 时检测到基本情况; 在这种情况下不需
要做什么,所以 moveTower 函数简单地返回。关于以这种方式处理基本情况的重点是,从 moveTower 简单地返回以使
moveDisk 函数被调用。
def moveDisk(fp,tp):
print("moving disk from",fp,"to",tp)
Listing 2
97
4.11.探索迷宫
4.11.探索迷宫
在这一节中,我们将讨论一个与扩展机器人世界相关的问题:你如何找到自己的迷宫? 如果你在你的宿舍有一个扫地
机器人(不是所有的大学生?)你希望你可以使用你在本节中学到的知识重新给它编程。 我们要解决的问题是帮助我
们的乌龟在虚拟迷宫中找到出路。 迷宫问题的根源与希腊的神话有关,传说忒修斯被送入迷宫中以杀死人身牛头怪。
忒修斯用了一卷线帮助他找到回去的退路,当他完成杀死野兽的任务。在我们的问题中,我们将假设我们的乌龟在迷宫
中间的某处,必须找到出路。 看看 Figure 2,了解我们将在本节中做什么。
Figure 2
为了使问题容易些,我们假设我们的迷宫被分成“正方形”。迷宫的每个正方形是开放的或被一段墙壁占据。乌龟只能通
过迷宫的空心方块。 如果乌龟撞到墙上,它必须尝试不同的方向。乌龟将需要一个程序,以找到迷宫的出路。这里是
过程:
1. 从我们的起始位置,我们将首先尝试向北一格,然后从那里递归地尝试我们的程序。
2. 如果我们通过尝试向北作为第一步没有成功,我们将向南一格,并递归地重复我们的程序。
3. 如果向南也不行,那么我们将尝试向西一格,并递归地重复我们的程序。
4. 如果北,南和西都没有成功,则应用程序从当前位置递归向东。
5. 如果这些方向都没有成功,那么没有办法离开迷宫,我们失败。
现在,这听起来很容易,但有几个细节先谈谈。假设我们第一步是向北走。按照我们的程序,我们的下一步也将是向
北。但如果北面被一堵墙阻挡,我们必须看看程序的下一步,并试着向南。不幸的是,向南使我们回到原来的起点。如
果我们从那里再次应用递归过程,我们将又回到向北一格,并陷入无限循环。所以,我们必须有一个策略来记住我们去
过哪。在这种情况下,我们假设有一袋面包屑可以撒在我们走过的路上。如果我们沿某个方向迈出一步,发现那个位置
上已经有面包屑,我们应该立即后退并尝试程序中的下一个方向。我们看看这个算法的代码,就像从递归函数调用返回
一样简单。
正如我们对所有递归算法所做的一样,让我们回顾一下基本情况。其中一些你可能已经根据前一段的描述猜到了。在这
种算法中,有四种基本情况要考虑:
1. 乌龟撞到了墙。由于这一格被墙壁占据,不能进行进一步的探索。
2. 乌龟找到一个已经探索过的格。我们不想继续从这个位置探索,否则会陷入循环。
3. 我们发现了一个外边缘,没有被墙壁占据。换句话说,我们发现了迷宫的一个出口。
4. 我们探索了一格在四个方向上都没有成功。
98
4.11.探索迷宫
__init__ 读取迷宫的数据文件,初始化迷宫的内部表示,并找到乌龟的起始位置。
drawMaze 在屏幕上的一个窗口中绘制迷宫。
updatePosition 更新迷宫的内部表示,并更改窗口中乌龟的位置。
isExit 检查当前位置是否是迷宫的退出位置。
Listing 3
1,column) (或北,如果你从地理位置上思考)是在迷宫的路径上。如果没有一个好的路径向北,那么尝试下一个向南
的递归调用。如果向南失败,然后尝试向西,最后向东。如果所有四个递归调用返回 False ,那么认为是一个死胡
同。你应该下载或输入整个程序,并通过更改这些调用的顺序进行实验。
[ ['+','+','+','+',...,'+','+','+','+','+','+','+'],
['+',' ',' ',' ',...,' ',' ',' ','+',' ',' ',' '],
['+',' ','+',' ',...,'+','+',' ','+',' ','+','+'],
['+',' ','+',' ',...,' ',' ',' ','+',' ','+','+'],
99
4.11.探索迷宫
drawMaze 方法使用这个内部表示在屏幕上绘制迷宫的初始视图。
Figure 3:示例迷宫数据文件
++++++++++++++++++++++
+ + ++ ++ +
+ + + +++ + ++
+ + + ++ ++++ + ++
+++ ++++++ +++ + +
+ ++ ++ +
+++++ ++++++ +++++ +
+ + +++++++ + +
+ +++++++ S + +
+ + +++
++++++++++++++++++ +++
Figure 3
class Maze:
def __init__(self,mazeFileName):
rowsInMaze = 0
columnsInMaze = 0
self.mazelist = []
mazeFile = open(mazeFileName,'r')
rowsInMaze = 0
for line in mazeFile:
rowList = []
col = 0
for ch in line[:-1]:
rowList.append(ch)
if ch == 'S':
self.startRow = rowsInMaze
self.startCol = col
col = col + 1
rowsInMaze = rowsInMaze + 1
self.mazelist.append(rowList)
columnsInMaze = len(rowList)
self.rowsInMaze = rowsInMaze
self.columnsInMaze = columnsInMaze
self.xTranslate = -columnsInMaze/2
self.yTranslate = rowsInMaze/2
self.t = Turtle(shape='turtle')
setup(width=600,height=600)
setworldcoordinates(-(columnsInMaze-1)/2-.5,
-(rowsInMaze-1)/2-.5,
(columnsInMaze-1)/2+.5,
(rowsInMaze-1)/2+.5)
100
4.11.探索迷宫
Listing 4
def drawMaze(self):
for y in range(self.rowsInMaze):
for x in range(self.columnsInMaze):
if self.mazelist[y][x] == OBSTACLE:
self.drawCenteredBox(x+self.xTranslate,
-y+self.yTranslate,
'tan')
self.t.color('black','blue')
def drawCenteredBox(self,x,y,color):
tracer(0)
self.t.up()
self.t.goto(x-.5,y-.5)
self.t.color('black',color)
self.t.setheading(90)
self.t.down()
self.t.begin_fill()
for i in range(4):
self.t.forward(1)
self.t.right(90)
self.t.end_fill()
update()
tracer(1)
def moveTurtle(self,x,y):
self.t.up()
self.t.setheading(self.t.towards(x+self.xTranslate,
-y+self.yTranslate))
self.t.goto(x+self.xTranslate,-y+self.yTranslate)
def dropBreadcrumb(self,color):
self.t.dot(color)
def updatePosition(self,row,col,val=None):
if val:
self.mazelist[row][col] = val
self.moveTurtle(col,row)
if val == PART_OF_PATH:
color = 'green'
elif val == OBSTACLE:
color = 'red'
elif val == TRIED:
color = 'black'
elif val == DEAD_END:
color = 'red'
else:
color = None
if color:
self.dropBreadcrumb(color)
Listing 5
def isExit(self,row,col):
return (row == 0 or
row == self.rowsInMaze-1 or
col == 0 or
col == self.columnsInMaze-1 )
def __getitem__(self,idx):
return self.mazelist[idx]
101
4.11.探索迷宫
Listing 6
102
4.12.动态规划
4.12.动态规划
计算机科学中的许多程序是为了优化一些值而编写的; 例如,找到两个点之间的最短路径,找到最适合一组点的线,或
找到满足某些标准的最小对象集。计算机科学家使用许多策略来解决这些问题。本书的目标之一是向你展示几种不同的
解决问题的策略。 动态规划 是这些类型的优化问题的一个策略。
优化问题的典型例子包括使用最少的硬币找零。假设你是一个自动售货机制造商的程序员。你的公司希望通过给每个交
易最少硬币来简化工作。假设客户放入 1 美元的钞票并购买 37 美分的商品。你可以用来找零的最小数量的硬币是多
少?答案是六个硬币:两个 25 美分,一个 10美分 和 三个一美分。我们如何得到六个硬币的答案?我们从最大的硬币
(25 美分)开始,并尽可能多,然后我们去找下一个小点的硬币,并尽可能多的使用它们。这第一种方法被称为贪婪
方法,因为我们试图尽快解决尽可能大的问题。
当我们使用美国货币时,贪婪的方法工作正常,但是假设你的公司决定在埃尔博尼亚部署自动贩卖机,除了通常的 1,
5,10 和 25 分硬币,他们还有一个 21 分硬币 。在这种情况下,我们的贪婪的方法找不到 63 美分的最佳解决方案。
随着加入 21分硬币,贪婪的方法仍然会找到解决方案是六个硬币。然而,最佳答案是三个 21 分。
让我们看一个方法,我们可以确定会找到问题的最佳答案。由于这一节是关于递归的,你可能已经猜到我们将使用递归
解决方案。让我们从基本情况开始,如果我们可以与我们硬币的价值相同的金额找零,答案很容易,一个硬币。
如果金额不匹配,我们有几个选项。我们想要的是最低一个一分钱加上原始金额减去一分钱所需的硬币数量,或者一个
5 美分加上原始金额减去 5 美分所需的硬币数量,或者一个 10 美分加上原始金额减去 10 美分所需的硬币数量,等
等。因此,需要对原始金额找零硬币数量可以根据下式计算:
def recMC(coinValueList,change):
minCoins = change
if change in coinValueList:
return 1
else:
for i in [c for c in coinValueList if c <= change]:
numCoins = 1 + recMC(coinValueList,change-i)
if numCoins < minCoins:
minCoins = numCoins
return minCoins
print(recMC([1,5,10,25],63))
Listing 7
103
4.12.动态规划
大量的时间和精力重新计算旧的结果。
Figure 5
减少我们工作量的关键是记住一些过去的结果,这样我们可以避免重新计算我们已经知道的结果。一个简单的解决方案
是将最小数量的硬币的结果存储在表中。然后在计算新的最小值之前,我们首先检查表,看看结果是否已知。如果表中
已有结果,我们使用表中的值,而不是重新计算。 ActiveCode 1 显示了一个修改的算法,以合并我们的表查找方案。
def recDC(coinValueList,change,knownResults):
minCoins = change
if change in coinValueList:
knownResults[change] = 1
return 1
elif knownResults[change] > 0:
return knownResults[change]
else:
for i in [c for c in coinValueList if c <= change]:
numCoins = 1 + recDC(coinValueList, change-i,
knownResults)
if numCoins < minCoins:
minCoins = numCoins
knownResults[change] = minCoins
return minCoins
print(recDC([1,5,10,25],63,[0]*64))
ActiveCode 1
注意,在第 6 行中,我们添加了一个测试,看看我们的列表是否包含此找零的最小硬币数量。如果没有,我们递归计算
最小值,并将计算出的最小值存储在列表中。使用这个修改的算法减少了我们需要为四个硬币递归调用的数量,63美分
问题只需 221 次调用!
一个真正的动态编程算法将采取更系统的方法来解决这个问题。我们的动态编程解决方案将从找零一分钱开始,并系统
地找到我们需要的找零额。这保证我们在算法的每一步,已经知道为任何更小的数量进行找零所需的最小硬币数量。
104
4.12.动态规划
是四,再加一个一分钱是五,等于五个硬币。或者我们可以尝试 0 分加一个五分,五分钱等于一个硬币。由于一和五最
小的是一,我们在表中存储为一。再次快进到表的末尾,考虑 11 美分。Figure 5 展示了我们要考虑的三个选项:
选项 1 或 3 总共需要两个硬币,这是 11 美分的最小硬币数。
def dpMakeChange(coinValueList,change,minCoins):
for cents in range(change+1):
coinCount = cents
for j in [c for c in coinValueList if c <= cents]:
if minCoins[cents-j] + 1 < coinCount:
coinCount = minCoins[cents-j]+1
minCoins[cents] = coinCount
return minCoins[change]
105
4.12.动态规划
Listing 8
虽然我们的找零算法很好地找出最小数量的硬币,但它不帮助我们找零,因为我们不跟踪我们使用的硬币。我们可以轻
松地扩展 dpMakeChange 来跟踪硬币使用,只需记住我们为每个条目添加的最后一个硬币到 minCoins 表。如果我们知
道添加的最后一个硬币值,我们可以简单地减去硬币的值,在表中找到前一个条目,找到该金额的最后一个硬币。我们
可以通过表继续跟踪,直到我们开始的位置。
def dpMakeChange(coinValueList,change,minCoins,coinsUsed):
for cents in range(change+1):
coinCount = cents
newCoin = 1
for j in [c for c in coinValueList if c <= cents]:
if minCoins[cents-j] + 1 < coinCount:
coinCount = minCoins[cents-j]+1
newCoin = j
minCoins[cents] = coinCount
coinsUsed[cents] = newCoin
return minCoins[change]
def printCoins(coinsUsed,change):
coin = change
while coin > 0:
thisCoin = coinsUsed[coin]
print(thisCoin)
coin = coin - thisCoin
def main():
amnt = 63
clist = [1,5,10,21,25]
coinsUsed = [0]*(amnt+1)
coinCount = [0]*(amnt+1)
main()
AcitveCode 2
106
4.12.动态规划
107
4.13.总结
4.13.总结
在本章中,我们讨论了几个递归算法的例子。 选择这些算法来揭示几个不同的问题,其中递归是一种有效的问题解决
技术。 本章要记住的要点如下:
所有递归算法都必须具有基本情况。
递归算法必须改变其状态并朝基本情况发展。
递归算法必须调用自身(递归)。
递归在某些情况下可以代替迭代。
递归算法通常可以自然地映射到你尝试解决的问题的表达式。
递归并不总是答案。有时,递归解决方案可能比迭代算法在计算上更昂贵。
108
5.排序和搜索
109
5.1.目标
5.1.目标
能够解释和实现顺序查找和二分查找。
能够解释和实现选择排序,冒泡排序,归并排序,快速排序,插入排序和 shell 排序。
理解哈希作为搜索技术的思想。
引入映射抽象数据类型。
使用哈希实现 Map 抽象数据类型。
110
5.2.搜索
5.2.搜索
我们现在把注意力转向计算中经常出现的一些问题,即搜索和排序问题。在本节中,我们将研究搜索。我们将在本章后
面的章节中介绍。搜索是在项集合中查找特定项的算法过程。搜索通常对于项是否存在返回 True 或 False。有时它可
能返回项被找到的地方。我们在这里将仅关注成员是否存在这个问题。
>>> 15 in [3,5,2,4,1]
False
>>> 3 in [3,5,2,4,1]
True
>>>
这很容易写,一个底层的操作替我们完成这个工作。事实证明,有很多不同的方法来搜索。我们在这里感兴趣的是这些
算法如何工作以及它们如何相互比较。
111
5.3.顺序查找
5.3.顺序查找
当数据项存储在诸如列表的集合中时,我们说它们具有线性或顺序关系。 每个数据项都存储在相对于其他数据项的位
置。 在 Python 列表中,这些相对位置是单个项的索引值。由于这些索引值是有序的,我们可以按顺序访问它们。 这个
过程产生我们的第一种搜索技术 顺序查找 。
Figure 1
return found
CodeLens 1
5.3.1.顺序查找分析
为了分析搜索算法,我们需要定一个基本计算单位。回想一下,这通常是为了解决问题要重复的共同步骤。对于搜索,
计算比较操作数是有意义的。每个比较都有可能找到我们正在寻找的项目。此外,我们在这里做另一个假设。项列表不
以任何方式排序。项随机放置到列表中。换句话说,项在列表任何位置的概率是一样的。
112
5.3.顺序查找
Table 1
我们之前假设,我们列表中的项是随机放置的,因此在项之间没有相对顺序。如果项以某种方式排序,顺序查找会发生
什么?我们能够在搜索技术中取得更好的效率吗?
假设项的列表按升序排列。如果我们正在寻找的项存在于列表中,它在 n 个位置中的概率依旧相同。我们仍然会有相同
数量的比较来找到该项。然而,如果该项不存在,则有一些优点。Figure 2 展示了这个过程,寻找项 50。注意,项仍
然按顺序进行比较直到 54。此时,我们知道一些额外的东西。不仅 54 不是我们正在寻找的项,也没有超过 54 的其他
元素可以匹配到该项,因为列表是有序的。在这种情况下,算法不必继续查看所有项。它可以立即停止。 CodeLens 2
展示了顺序查找功能的这种变化。
Figure 2
return found
CodeLens 2
113
5.3.顺序查找
Table 2
114
5.4.二分查找
5.4.二分查找
有序列表对于我们的比较是很有用的。在顺序查找中,当我们与第一个项进行比较时,如果第一个项不是我们要查找
的,则最多还有 n-1 个项目。 二分查找从中间项开始,而不是按顺序查找列表。 如果该项是我们正在寻找的项,我
们就完成了查找。 如果它不是,我们可以使用列表的有序性质来消除剩余项的一半。如果我们正在查找的项大于中间
项,就可以消除中间项以及比中间项小的一半元素。如果该项在列表中,肯定在大的那半部分。
然后我们可以用大的半部分重复这个过程。从中间项开始,将其与我们正在寻找的内容进行比较。再次,我们找到元素
或将列表分成两半,消除可能的搜索空间的另一部分。Figure 3 展示了该算法如何快速找到值 54 。完整的函数见
CodeLens 3中。
Figure 3
return found
CodeLens 3
在我们继续分析之前,我们应该注意到,这个算法是分而治之策略的一个很好的例子。分和治意味着我们将问题分成更
小的部分,以某种方式解决更小的部分,然后重新组合整个问题以获得结果。 当我们执行列表的二分查找时,我们首
先检查中间项。如果我们正在搜索的项小于中间项,我们可以简单地对原始列表的左半部分执行二分查找。同样,如果
项大,我们可以执行右半部分的二分查找。 无论哪种方式,都是递归调用二分查找函数。 CodeLens 4 展示了这个递归
版本。
115
5.4.二分查找
return binarySearch(alist[:midpoint],item)
else:
return binarySearch(alist[midpoint+1:],item)
CodeLens 4
5.4.1.二分查找分析
为了分析二分查找算法,我们需要记住,每个比较消除了大约一半的剩余项。该算法检查整个列表的最大比较数是多
少?如果我们从 n 项开始,大约 n/2 项将在第一次比较后留下。第二次比较后,会有约 n/4。 然后 n/8,n/16,等等。
我们可以拆分列表多少次? Table 3 帮助我们找到答案。
Table 3
当我们切分列表足够多次时,我们最终得到只有一个项的列表。 要么是我们正在寻找的项,要么不是。达到这一点所
需的比较数是 i,当 n/2^i = 1 时。 求解 i 得出 i = log^n 。 最大比较数相对于列表中的项是对数的。 因此,二分查找是
O( log^n )。
还需要解决一个额外的分析问题。在上面所示的递归解中,递归调用,
binarySearch(alist[:midpoint],item)
使用切片运算符创建列表的左半部分,然后传递到下一个调用(同样对于右半部分)。我们上面做的分析假设切片操作
符是恒定时间的。然而,我们知道 Python中的 slice 运算符实际上是 O(k)。这意味着使用 slice 的二分查找将不会在严
格的对数时间执行。幸运的是,这可以通过传递列表连同开始和结束索引来纠正。可以像 CodeLens 3 中所做的那样计
算索引。我们将此实现作为练习。
即使二分查找通常比顺序查找更好,但重要的是要注意,对于小的 n 值,排序的额外成本可能不值得。事实上,我们应
该经常考虑采取额外的分类工作是否使搜索获得好处。如果我们可以排序一次,然后查找多次,排序的成本就不那么重
要。然而,对于大型列表,一次排序可能是非常昂贵,从一开始就执行顺序查找可能是最好的选择。
116
5.5.Hash查找
5.5.Hash查找
在前面的部分中,我们通过利用关于项在集合中相对于彼此存储的位置的信息,改进我们的搜索算法。例如,通过知道
列表是有序的,我们可以使用二分查找在对数时间中搜索。在本节中,我们将尝试进一步建立一个可以在 O(1) 时间内
搜索的数据结构。这个概念被称为 Hash 查找 。
为了做到这一点,当我们在集合中查找项时,我们需要更多地了解项可能在哪里。如果每个项都在应该在的地方,那么
搜索可以使用单个比较就能发现项的存在。然而,我们看到,通常不是这样的。
哈希表 是以一种容易找到它们的方式存储的项的集合。哈希表的每个位置,通常称为一个槽,可以容纳一个项,并且
由从 0 开始的整数值命名。例如,我们有一个名为 0 的槽,名为 1 的槽,名为 2 的槽,以上。最初,哈希表不包含
项,因此每个槽都为空。我们可以通过使用列表来实现一个哈希表,每个元素初始化为 None 。Figure 4 展示了大小 m
= 11 的哈希表。换句话说,在表中有 m 个槽,命名为 0 到 10。
Figure 4
Table 4
117
5.5.Hash查找
Figure 5
现在,当我们要搜索一个项时,我们只需使用哈希函数来计算项的槽名称,然后检查哈希表以查看它是否存在。该搜索
操作是 O(1),因为需要恒定的时间量来计算散列值,然后在该位置索引散列表。如果一切都正确的话,我们已经找到
了一个恒定时间搜索算法。
5.5.1.hash 函数
给定项的集合,将每个项映射到唯一槽的散列函数被称为完美散列函数。如果我们知道项和集合将永远不会改变,那么
可以构造一个完美的散列函数。不幸的是,给定任意的项集合,没有系统的方法来构建完美的散列函数。幸运的是,我
们不需要散列函数是完美的,仍然可以提高性能。
总是具有完美散列函数的一种方式是增加散列表的大小,使得可以容纳项范围中的每个可能值。这保证每个项将具有唯
一的槽。虽然这对于小数目的项是实用的,但是当可能项的数目大时是不可行的。例如,如果项是九位数的社保号码,
则此方法将需要大约十亿个槽。如果我们只想存储 25 名学生的数据,我们将浪费大量的内存。
我们的目标是创建一个散列函数,最大限度地减少冲突数,易于计算,并均匀分布在哈希表中的项。有很多常用的方法
来扩展简单余数法。我们将在这里介绍其中几个。
分组求和法 将项划分为相等大小的块(最后一块可能不是相等大小)。然后将这些块加在一起以求出散列值。例如,如
果我们的项是电话号码 436-555-4601 ,我们将取出数字,并将它们分成2位数 (43,65,55,46,01) 。 43 + 65 + 55 + 46
118
5.5.Hash查找
Table 5
>>> ord('c')
99
>>> ord('a')
97
>>> ord('t')
116
Figure 6
return sum%tablesize
Listing 1
119
5.5.Hash查找
有趣的是,当使用此散列函数时,字符串总是返回相同的散列值。 为了弥补这一点,我们可以使用字符的位置作为权
重。 Figure 7 展示了使用位置值作为加权因子的一种可能的方式。
Figure 7
你可以思考一些其他方法来计算集合中项的散列值。重要的是要记住,哈希函数必须是高效的,以便它不会成为存储和
搜索过程的主要部分。如果哈希函数太复杂,则计算槽名称的程序要比之前所述的简单地进行基本的顺序或二分搜索更
耗时。 这将打破散列的目的。
5.5.2.冲突解决
我们现在回到碰撞的问题。当两个项散列到同一个槽时,我们必须有一个系统的方法将第二个项放在散列表中。这个过
程称为冲突解决。如前所述,如果散列函数是完美的,冲突将永远不会发生。然而,由于这通常是不可能的,所以冲突
解决成为散列非常重要的部分。
解决冲突的一种方法是查找散列表,尝试查找到另一个空槽以保存导致冲突的项。一个简单的方法是从原始哈希值位置
开始,然后以顺序方式移动槽,直到遇到第一个空槽。注意,我们可能需要回到第一个槽(循环)以查找整个散列表。
这种冲突解决过程被称为开放寻址,因为它试图在散列表中找到下一个空槽或地址。通过系统地一次访问每个槽,我们
执行称为线性探测的开放寻址技术。
Figure 8
一旦我们使用开放寻址和线性探测建立了哈希表,我们就必须使用相同的方法来搜索项。假设我们想查找项 93 。当
我们计算哈希值时,我们得到 5 。查看槽 5 得到 93 ,返回 True。如果我们正在寻找 20 , 现在哈希值为 9 ,而
槽 9 当前项为 31 。我们不能简单地返回 False,因为我们知道可能存在冲突。我们现在被迫做一个顺序搜索,从位
置 10 开始寻找,直到我们找到项 20 或我们找到一个空槽。
120
5.5.Hash查找
线性探测的缺点是聚集的趋势;项在表中聚集。这意味着如果在相同的散列值处发生很多冲突,则将通过线性探测来填
充多个周边槽。这将影响正在插入的其他项,正如我们尝试添加上面的项 20 时看到的。必须跳过一组值为 0 的
值,最终找到开放位置。该聚集如 Figure 9 所示。
Figure 9
处理聚集的一种方式是扩展线性探测技术,使得不是顺序地查找下一个开放槽,而是跳过槽,从而更均匀地分布已经引
起冲突的项。这将潜在地减少发生的聚集。 Figure 10 展示了使用 加3 探头进行碰撞识别时的项。 这意味着一旦发生
碰撞,我们将查看第三个槽,直到找到一个空。
Figure 10
Figure 11
用于处理冲突问题的替代方法是允许每个槽保持对项的集合(或链)的引用。链接允许许多项存在于哈希表中的相同位
置。当发生冲突时,项仍然放在散列表的正确槽中。随着越来越多的项哈希到相同的位置,搜索集合中的项的难度增
加。 Figure 12 展示了添加到使用链接解决冲突的散列表时的项。
121
5.5.Hash查找
Figure 12
当我们要搜索一个项时,我们使用散列函数来生成它应该在的槽。由于每个槽都有一个集合,我们使用一种搜索技术来
查找该项是否存在。优点是,平均来说,每个槽中可能有更少的项,因此搜索可能更有效。我们将在本节结尾处查看散
列的分析。
字典一个很大的好处是,给定一个键,我们可以非常快速地查找相关的值。为了提供这种快速查找能力,我们需要一个
支持高效搜索的实现。我们可以使用具有顺序或二分查找的列表,但是使用如上所述的哈希表将更好,因为查找哈希表
中的项可以接近 O(1) 性能。
class HashTable:
def __init__(self):
self.size = 11
self.slots = [None] * self.size
self.data = [None] * self.size
Listing 2
122
5.5.Hash查找
def put(self,key,data):
hashvalue = self.hashfunction(key,len(self.slots))
if self.slots[hashvalue] == None:
self.slots[hashvalue] = key
self.data[hashvalue] = data
else:
if self.slots[hashvalue] == key:
self.data[hashvalue] = data #replace
else:
nextslot = self.rehash(hashvalue,len(self.slots))
while self.slots[nextslot] != None and \
self.slots[nextslot] != key:
nextslot = self.rehash(nextslot,len(self.slots))
if self.slots[nextslot] == None:
self.slots[nextslot]=key
self.data[nextslot]=data
else:
self.data[nextslot] = data #replace
def hashfunction(self,key,size):
return key%size
def rehash(self,oldhash,size):
return (oldhash+1)%size
Listing 3
def get(self,key):
startslot = self.hashfunction(key,len(self.slots))
data = None
stop = False
found = False
position = startslot
while self.slots[position] != None and \
not found and not stop:
if self.slots[position] == key:
found = True
data = self.data[position]
else:
position=self.rehash(position,len(self.slots))
if position == startslot:
stop = True
return data
def __getitem__(self,key):
return self.get(key)
def __setitem__(self,key,data):
self.put(key,data)
123
5.5.Hash查找
Listing 4
>>> H=HashTable()
>>> H[54]="cat"
>>> H[26]="dog"
>>> H[93]="lion"
>>> H[17]="tiger"
>>> H[77]="bird"
>>> H[31]="cow"
>>> H[44]="goat"
>>> H[55]="pig"
>>> H[20]="chicken"
>>> H.slots
[77, 44, 55, 20, 26, 93, 17, None, None, 31, 54]
>>> H.data
['bird', 'goat', 'pig', 'chicken', 'dog', 'lion',
'tiger', None, None, 'cow', 'cat']
接下来,我们将访问和修改哈希表中的一些项。注意,正替换键 20 的值。
>>> H[20]
'chicken'
>>> H[17]
'tiger'
>>> H[20]='duck'
>>> H[20]
'duck'
>>> H.data
['bird', 'goat', 'pig', 'duck', 'dog', 'lion',
'tiger', None, None, 'cow', 'cat']
>> print(H[99])
None
5.5.4.hash法分析
我们之前说过,在最好的情况下,散列将提供 O(1),恒定时间搜索。然而,由于冲突,比较的数量通常不是那么简
单。即使对散列的完整分析超出了本文的范围,我们可以陈述一些近似搜索项所需的比较数量的已知结果。
和以前一样,我们将有一个成功的搜索结果和不成功的。对于使用具有线性探测的开放寻址的成功搜索,平均比较数大
约为 1/2(1 + 1/(1-λ)) ,不成功的搜索为 1/2(1+(1/1-λ)^2 ) 如果我们使用链接,则对于成功的情况,平均比较数目是
1+λ/2,如果搜索不成功,则简单地是 λ 比较次数。
124
5.6.排序
5.6.排序
排序是以某种顺序从集合中放置元素的过程。例如,单词列表可以按字母顺序或按长度排序。城市列表可按人口,按地
区或邮政编码排序。我们已经看到了许多能够从排序列表中获益的算法(回忆之前的回文例子和二分查找)。
有许多开发和分析的排序算法。表明排序是计算机科学的一个重要研究领域。对大量项进行排序可能需要大量的计算资
源。与搜索一样,排序算法的效率与正在处理的项的数量有关。对于小集合,复杂的排序方法可能更麻烦,开销太高。
另一方面,对于更大的集合,我们希望利用尽可能多的改进。在本节中,我们将讨论几种排序技术,并对它们的运行时
间进行比较。
在分析特定算法之前,我们应该考虑可用于分析排序过程的操作。首先,必须比较两个值以查看哪个更小(或更大)。
为了对集合进行排序,需要一些系统的方法来比较值,以查看是否有问题。比较的总数将是测量排序过程的最常见方
法。第二,当值相对于彼此不在正确的位置时,可能需要交换它们。这种交换是一种昂贵的操作,并且交换的总数对于
评估算法的整体效率也将是很重要的。
125
5.7.冒泡排序
5.7.冒泡排序
冒泡排序需要多次遍历列表。它比较相邻的项并交换那些无序的项。每次遍历列表将下一个最大的值放在其正确的位
置。实质上,每个项“冒泡”到它所属的位置。
Figure 1
temp = alist[i]
alist[i] = alist[j]
alist[j] = temp
126
5.7.冒泡排序
Figure 2
def bubbleSort(alist):
for passnum in range(len(alist)-1,0,-1):
for i in range(passnum):
if alist[i]>alist[i+1]:
temp = alist[i]
alist[i] = alist[i+1]
alist[i+1] = temp
alist = [54,26,93,17,77,31,44,55,20]
bubbleSort(alist)
print(alist)
ActiveCode 1
Table1
127
5.7.冒泡排序
冒泡排序通常被认为是最低效的排序方法,因为它必须在最终位置被知道之前交换项。 这些“浪费”的交换操作是非常昂
贵的。 然而,因为冒泡排序遍历列表的整个未排序部分,它有能力做大多数排序算法不能做的事情。特别地,如果在
遍历期间没有交换,则我们知道该列表已排序。 如果发现列表已排序,可以修改冒泡排序提前停止。这意味着对于只
需要遍历几次列表,冒泡排序具有识别排序列表和停止的优点。 ActiveCode 2 展示了这种修改,通常称为 短冒泡排
序 。
def shortBubbleSort(alist):
exchanges = True
passnum = len(alist)-1
while passnum > 0 and exchanges:
exchanges = False
for i in range(passnum):
if alist[i]>alist[i+1]:
exchanges = True
temp = alist[i]
alist[i] = alist[i+1]
alist[i+1] = temp
passnum = passnum-1
alist=[20,30,40,90,50,60,70,80,100,110]
shortBubbleSort(alist)
print(alist)
Activecode 2
128
5.8.选择排序
5.8.选择排序
选择排序改进了冒泡排序,每次遍历列表只做一次交换。为了做到这一点,一个选择排序在他遍历时寻找最大的值,并
在完成遍历后,将其放置在正确的位置。与冒泡排序一样,在第一次遍历后,最大的项在正确的地方。 第二遍后,下
一个最大的就位。遍历 n-1 次排序 n 个项,因为最终项必须在第(n-1)次遍历之后。
Activecode 1
def selectionSort(alist):
for fillslot in range(len(alist)-1,0,-1):
positionOfMax=0
for location in range(1,fillslot+1):
if alist[location]>alist[positionOfMax]:
positionOfMax = location
temp = alist[fillslot]
alist[fillslot] = alist[positionOfMax]
129
5.8.选择排序
alist[positionOfMax] = temp
alist = [54,26,93,17,77,31,44,55,20]
selectionSort(alist)
print(alist)
130
5.9.插入排序
5.9.插入排序
插入排序,尽管仍然是 O(n^2 ),工作方式略有不同。它始终在列表的较低位置维护一个排序的子列表。然后将每个新
项 “插入” 回先前的子列表,使得排序的子列表称为较大的一个项。Figure 4 展示了插入排序过程。 阴影项表示算法进
行每次遍历时的有序子列表。
Figure 4
131
5.9.插入排序
Figure 5
关于移位和交换的一个注意事项也很重要。通常,移位操作只需要交换大约三分之一的处理工作,因为仅执行一次分
配。在基准研究中,插入排序有非常好的性能。
def insertionSort(alist):
for index in range(1,len(alist)):
currentvalue = alist[index]
position = index
alist[position]=currentvalue
alist = [54,26,93,17,77,31,44,55,20]
insertionSort(alist)
print(alist)
ActiveCode 1
132
5.10.希尔排序
5.10.希尔排序
希尔排序(有时称为“递减递增排序”)通过将原始列表分解为多个较小的子列表来改进插入排序,每个子列表使用插入
排序进行排序。 选择这些子列表的方式是希尔排序的关键。不是将列表拆分为连续项的子列表,希尔排序使用增量
i(有时称为 gap ),通过选择 i 个项的所有项来创建子列表。
Figure 6-7
133
5.10.希尔排序
Figure 8
Figure 9
def shellSort(alist):
sublistcount = len(alist)//2
while sublistcount > 0:
sublistcount = sublistcount // 2
def gapInsertionSort(alist,start,gap):
for i in range(start+gap,len(alist),gap):
currentvalue = alist[i]
position = i
134
5.10.希尔排序
alist[position]=currentvalue
alist = [54,26,93,17,77,31,44,55,20]
shellSort(alist)
print(alist)
Activecode 1
乍一看,你可能认为希尔排序不会比插入排序更好,因为它最后一步执行了完整的插入排序。 然而,结果是,该最终
插入排序不需要进行非常多的比较(或移位),因为如上所述,该列表已经被较早的增量插入排序预排序。 换句话
说,每个遍历产生比前一个“更有序”的列表。 这使得最终遍历非常有效。
135
5.11.归并排序
5.11.归并排序
我们现在将注意力转向使用分而治之策略作为提高排序算法性能的一种方法。 我们将研究的第一个算法是归并排序。
归并排序是一种递归算法,不断将列表拆分为一半。 如果列表为空或有一个项,则按定义(基本情况)进行排序。如
果列表有多个项,我们分割列表,并递归调用两个半部分的合并排序。 一旦对这两半排序完成,就执行称为合并的基
本操作。合并是获取两个较小的排序列表并将它们组合成单个排序的新列表的过程。 Figure 10 展示了我们熟悉的示例
列表,它被mergeSort 分割。 Figure 11 展示了归并后的简单排序列表。
Figure 10
Figure 11
136
5.11.归并排序
def mergeSort(alist):
print("Splitting ",alist)
if len(alist)>1:
mid = len(alist)//2
lefthalf = alist[:mid]
righthalf = alist[mid:]
mergeSort(lefthalf)
mergeSort(righthalf)
i=0
j=0
k=0
while i < len(lefthalf) and j < len(righthalf):
if lefthalf[i] < righthalf[j]:
alist[k]=lefthalf[i]
i=i+1
else:
alist[k]=righthalf[j]
j=j+1
k=k+1
alist = [54,26,93,17,77,31,44,55,20]
mergeSort(alist)
print(alist)
Activecode1
重要的是注意,mergeSort 函数需要额外的空间来保存两个半部分,因为它们是使用切片操作提取的。如果列表很大,
这个额外的空间可能是一个关键因素,并且在处理大型数据集时可能会导致此类问题。
137
5.12.快速排序
5.12.快速排序
快速排序使用分而治之来获得与归并排序相同的优点,而不使用额外的存储。然而,作为权衡,有可能列表不能被分成
两半。当这种情况发生时,我们将看到性能降低。
Figure 12
分区从通过在列表中剩余项目的开始和结束处定位两个位置标记(我们称为左标记和右标记)开始(Figure 13中的位置
1 和 8 )。分区的目标是移动相对于枢轴值位于错误侧的项,同时也收敛于分裂点。 Figure13展示了我们定位54的位
置的过程。
138
5.12.快速排序
Figure 13
我们首先增加左标记,直到我们找到一个大于枢轴值的值。 然后我们递减右标,直到我们找到小于枢轴值的值。我们
发现了两个相对于最终分裂点位置不适当的项。 对于我们的例子,这发生在 93 和 20。现在我们可以交换这两个项
目,然后重复该过程。
在右标变得小于左标记的点,我们停止。右标记的位置现在是分割点。枢轴值可以与拆分点的内容交换,枢轴值现在就
位(Figure 14)。 此外,分割点左侧的所有项都小于枢轴值,分割点右侧的所有项都大于枢轴值。现在可以在分割点
处划分列表,并且可以在两半上递归调用快速排序。
139
5.12.快速排序
Figure14
def quickSort(alist):
quickSortHelper(alist,0,len(alist)-1)
def quickSortHelper(alist,first,last):
if first<last:
splitpoint = partition(alist,first,last)
quickSortHelper(alist,first,splitpoint-1)
quickSortHelper(alist,splitpoint+1,last)
def partition(alist,first,last):
pivotvalue = alist[first]
leftmark = first+1
rightmark = last
done = False
while not done:
temp = alist[first]
alist[first] = alist[rightmark]
alist[rightmark] = temp
return rightmark
alist = [54,26,93,17,77,31,44,55,20]
quickSort(alist)
print(alist)
140
5.12.快速排序
不幸的是,在最坏的情况下,分裂点可能不在中间,并且可能非常偏向左边或右边,留下非常不均匀的分割。在这种情
况下,对 n 个项的列表进行排序划分为对0 个项的列表和 n-1 个项目的列表进行排序。然后将 n-1 个划分的列表排序为
大小为0的列表和大小为 n-2 的列表,以此类推。结果是具有递归所需的所有开销的 O(n) 排序。
我们之前提到过,有不同的方法来选择枢纽值。特别地,我们可以尝试通过使用称为中值三的技术来减轻一些不均匀分
割的可能性。要选择枢轴值,我们将考虑列表中的第一个,中间和最后一个元素。在我们的示例中,这些是54,77和20.
现在选择中值,在我们的示例中为54,并将其用于枢轴值(当然,这是我们最初使用的枢轴值)。想法是,在列表中的
第一项不属于列表的中间的情况下,中值三将选择更好的“中间”值。当原始列表部分有序时,这将特别有用。我们将此
枢轴值选择的实现作为练习。
141
5.13.总结
5.13.总结
对于有序和无序列表,顺序搜索是 O(n)。
在最坏的情况下,有序列表的二分查找是 O(log^n )。
哈希表可以提供恒定时间搜索。
冒泡排序,选择排序和插入排序是 O(n^2 )算法。
shell排序通过排序增量子列表来改进插入排序。它落在 O(n) 和 O(n^2 ) 之间。
归并排序是 O(nlog^n ),但是合并过程需要额外的空间。
快速排序是 O(nlog^n ),但如果分割点不在列表中间附近,可能会降级到O(n^2 ) 。它不需要额外的空间。
142
6.树和树的算法
143
6.1.目标
6.1.目标
要理解树数据结构是什么,以及如何使用它。
查看树如何用于实现 map 数据结构。
使用列表实现树。
使用类和引用来实现树。
实现树作为递归数据结构。
使用堆实现优先级队列。
144
6.2.树的例子
6.2.树的例子
现在我们已经研究了线性数据结构,如栈和队列,并且有一些递归的经验,我们将看一个称为树的常见数据结构。树在
计算机科学的许多领域中使用,包括操作系统,图形,数据库系统和计算机网络。树数据结构与他们的植物表亲有许多
共同之处。树数据结构具有根,分支和叶。自然界中的树和计算机科学中的树之间的区别在于树数据结构的根在顶部,
其叶在底部。
在我们开始研究树形数据结构之前,让我们来看几个常见的例子。我们树的第一个例子是生物学的分类树。Figure 1 展
示了一些动物的生物分类的实例。从这个简单的例子,我们可以了解树的几个属性。此示例演示的第一个属性是树是分
层的。通过分层,我们的意思是树的层次结构,更接近顶部的是抽象的东西和底部附近是更具体的东西。层次结构的顶
部是 Kingdom ,树的下一层(上面的层的“Children”)是 Phylum ,然后是 Class ,等等。然而,无论我们在分类树中
有多深,所有的生物仍然是 animals 。
Figure 1
注意,你可以从树的顶部开始,并沿着圆圈和箭头一直到底部的路径。在树的每一层,我们可能问自己一个问题,然后
遵循与我们的答案一致的路径。例如,我们可能会问,“这个动物是Chordate(脊椎动物)还是Arthropod(节肢动物)?”如
果答案是“Chordate”,那么我们遵循这条路径,问“这个Chordate是 Mammal(哺乳动物)吗?”如果不是,我们就卡住了
这个简化的例子)。当我们在哺乳动物那层时,我们问“这个哺乳动物是Primate(灵长类动物)还是 Carnivore(食肉动
物)?”我们可以遵循以下路径,直到我们到达树的最底部,在那里我们有共同的名字。
第三个属性是每个叶节点是唯一的。我们可以指定从树的根到唯一地识别动物王国中的每个物种的叶的路径;例如,
Animalia→→Chordate→→Mammal→→Carnivora→→Felidae→→Felis→→Domestica。
145
6.2.树的例子
Figure2
文件系统树与生物分类树有很多共同之处。你可以遵循从根目录到任何目录的路径。 该路径将唯一标识该子目录(及
其中的所有文件)。 树的另一个重要属性来源于它们的层次性质,你可以将树的整个部分(称为子树)移动到树中的
不同位置,而不影响层次结构的较低级别。 例如,我们可以使用整个子树 /etc/,从根节点分离,并重新附加在 usr/
下。 这将把 httpd 的唯一路径名从 /etc/httpd 更改为 /usr/etc/httpd,但不会影响 httpd 目录的内容或任何子级。
<html xmlns="http://www.w3.org/1999/xhtml"
xml:lang="en" lang="en">
<head>
<meta http-equiv="Content-Type"
content="text/html; charset=utf-8" />
<title>simple</title>
</head>
<body>
<h1>A simple web page</h1>
<ul>
<li>List item one</li>
<li>List item two</li>
</ul>
<h2><a href="http://www.cs.luther.edu">Luther CS </a><h2>
</body>
</html>
Figure 3
146
6.2.树的例子
HTML源代码和伴随源的树说明了另一个层次结构。请注意,树的每个级别都对应于HTML标记内的嵌套级别。源中的
第一个标记是 ,最后一个是</ html> 页面中的所有其余标记都是成对的。 如果你检查,你会看到这个嵌套属性在树的
所有级别都是 true。
147
6.3.词汇和定义
6.3.词汇和定义
我们已经看了树的示例,我们将正式定义树及其组件。
节点
节点是树的基本部分。它可以有一个名称,我们称之为“键”。节点也可以有附加信息。我们将这个附加信息称为“有效载
荷”。虽然有效载荷信息不是许多树算法的核心,但在利用树的应用中通常是关键的。
边是树的另一个基本部分。边连接两个节点以显示它们之间存在关系。每个节点(除根之外)都恰好从另一个节点的传
入连接。每个节点可以具有多个输出边。
路径
路径是由边连接节点的有序列表。例如,Mammal→→Carnivora→→Felidae→→Felis→→Domestica是一条路径。
子节点
父节点
兄弟
子树
子树是由父节点和该父节点的所有后代组成的一组节点和边。
叶节点
层数
节点 n 的层数为从根结点到该结点所经过的分支数目。 例如,图1中的Felis节点的级别为五。根据定义,根节点的层数
为零。
高度
定义一:树由一组节点和一组连接节点的边组成。树具有以下属性:
树的一个节点被指定为根节点。
除了根节点之外,每个节点 n 通过一个其他节点 p 的边连接,其中 p 是 n 的父节点。
148
6.3.词汇和定义
从根路径遍历到每个节点路径唯一。
如果树中的每个节点最多有两个子节点,我们说该树是一个二叉树。
Figure 3 展示了适合定义一的树。边上的箭头指示连接的方向。
Figure 3
定义二:树是空的,或者由一个根节点和零个或多个子树组成,每个子树也是一棵树。每个子树的根节点通过边连接到
父树的根节点。 Figure 4 说明了树的这种递归定义。使用树的递归定义,我们知道 Figure 4 中的树至少有四个节点,
因为表示一个子树的每个三角形必须有一个根节点。 它可能有比这更多的节点,但我们不知道,除非我们更深入树。
Figure 4
149
6.4.列表表示
6.4.列表表示
在由列表表示的树中,我们将从 Python 的列表数据结构开始,并编写上面定义的函数。虽然将接口作为一组操作在列
表上编写与我们实现的其他抽象数据类型有点不同,但这样做是有趣的,因为它为我们提供了一个简单的递归数据结
构,我们可以直接查看和检查。在列表树的列表中,我们将根节点的值存储为列表的第一个元素。列表的第二个元素本
身将是一个表示左子树的列表。列表的第三个元素将是表示右子树的另一个列表。为了说明这种存储技术,让我们看一
个例子。 Figure 1 展示了一个简单的树和相应的列表实现。
Figure 1
Activecode 1
让我们提供一些使我们能够使用列表作为树的函数来形式化树数据结构的这个定义。注意,我们不会定义一个二叉树
类。我们写的函数只是帮助我们操纵一个标准列表,就像我们正在使用一棵树。
def BinaryTree(r):
return [r, [], []]
150
6.4.列表表示
BinaryTree 函数简单地构造一个具有根节点和两个子列表为空的列表。要将左子树添加到树的根,我们需要在根列表
的第二个位置插入一个新的列表。我们必须小心。如果列表已经在第二个位置有东西,我们需要跟踪它,并沿着树向下
把它作为我们添加的列表的左子节点。Listing 1 展示了插入左子节点的 Python 代码。
def insertLeft(root,newBranch):
t = root.pop(1)
if len(t) > 1:
root.insert(1,[newBranch,t,[]])
else:
root.insert(1,[newBranch, [], []])
return root
Listing 1
注意,要插入一个左子节点,我们首先获得与当前左子节点对应的(可能为空的)列表。然后我们添加新的左子树,添
加旧的左子数作为新子节点的左子节点。这允许我们在任何位置将新节点拼接到树中。 insertRight 的代码
与 insertLeft 类似,如 Listing 2所示。
def insertRight(root,newBranch):
t = root.pop(2)
if len(t) > 1:
root.insert(2,[newBranch,[],t])
else:
root.insert(2,[newBranch,[],[]])
return root
Listing 2
def getRootVal(root):
return root[0]
def setRootVal(root,newVal):
root[0] = newVal
def getLeftChild(root):
return root[1]
def getRightChild(root):
return root[2]
Listing 3
151
6.5.节点表示
6.5.节点表示
我们的第二种表示树的方法使用节点和引用。在这种情况下,我们将定义一个具有根值属性的类,以及左和右子树。
由于这个表示更接近于面向对象的编程范例,我们将继续使用这个表示法用于本章的剩余部分。
Figure 2
class BinaryTree:
def __init__(self,rootObj):
self.key = rootObj
self.leftChild = None
self.rightChild = None
Listing 4
接下来,我们来看看需要构建超出根节点的树的函数。要向树中添加一个左子树,我们将创建一个新的二叉树对象,并
设置根的左边属性来引用这个新对象。 insertLeft 的代码如 Listing 5所示。
def insertLeft(self,newNode):
if self.leftChild == None:
self.leftChild = BinaryTree(newNode)
else:
t = BinaryTree(newNode)
t.leftChild = self.leftChild
self.leftChild = t
152
6.5.节点表示
Listing 5
我们必须考虑两种插入情况。 第一种情况的特征没有现有左孩子的节点。当没有左孩子时,只需向树中添加一个节
点。 第二种情况的特征在于具有现有左孩子的节点。在第二种情况下,我们插入一个节点并将现有的子节点放到树中
的下一个层。第二种情况由 Listing 5 第 4 行的 else 语句处理。
def insertRight(self,newNode):
if self.rightChild == None:
self.rightChild = BinaryTree(newNode)
else:
t = BinaryTree(newNode)
t.rightChild = self.rightChild
self.rightChild = t
Listing 6
def getRightChild(self):
return self.rightChild
def getLeftChild(self):
return self.leftChild
def setRootVal(self,obj):
self.key = obj
def getRootVal(self):
return self.key
Listing 7
class BinaryTree:
def __init__(self,rootObj):
self.key = rootObj
self.leftChild = None
self.rightChild = None
def insertLeft(self,newNode):
if self.leftChild == None:
self.leftChild = BinaryTree(newNode)
else:
t = BinaryTree(newNode)
t.leftChild = self.leftChild
self.leftChild = t
def insertRight(self,newNode):
if self.rightChild == None:
self.rightChild = BinaryTree(newNode)
else:
t = BinaryTree(newNode)
t.rightChild = self.rightChild
self.rightChild = t
153
6.5.节点表示
def getRightChild(self):
return self.rightChild
def getLeftChild(self):
return self.leftChild
def setRootVal(self,obj):
self.key = obj
def getRootVal(self):
return self.key
r = BinaryTree('a')
print(r.getRootVal())
print(r.getLeftChild())
r.insertLeft('b')
print(r.getLeftChild())
print(r.getLeftChild().getRootVal())
r.insertRight('c')
print(r.getRightChild())
print(r.getRightChild().getRootVal())
r.getRightChild().setRootVal('hello')
print(r.getRightChild().getRootVal())
154
6.6.分析树
6.6.分析树
随着我们的树数据结构的实现完成,我们现在看一个例子,说明如何使用树来解决一些真正的问题。在本节中,我们将
讨论分析树。 分析树可以用于表示诸如句子或数学表达式的真实世界构造。
Figure 1
Figure 2
155
6.6.分析树
Figure 3
在本节的其余部分,我们将更详细地检查分析树。 特别的,我们会看
如何从完全括号的数学表达式构建分析树。
如何评估存储在分析树中的表达式。
如何从分析树中恢复原始数学表达式。
构建分析树的第一步是将表达式字符串拆分成符号列表。 有四种不同的符号要考虑:左括号,右括号,运算符和操作
数。 我们知道,每当我们读一个左括号,我们开始一个新的表达式,因此我们应该创建一个新的树来对应于该表达
式。 相反,每当我们读一个右括号,我们就完成了一个表达式。 我们还知道操作数将是叶节点和它们的操作符的子节
点。 最后,我们知道每个操作符都将有一个左和右孩子。
使用上面的信息,我们可以定义四个规则如下:
156
6.6.分析树
157
6.6.分析树
158
6.6.分析树
Figure 4
使用 Figure 4,让我们一步一步地浏览示例:
a. 创建一个空树。
b. 读取 ( 作为第一个标记。按规则1,创建一个新节点作为根的左子节点。使当前节点到这个新子节点。
c. 读取 3 作为下一个符号。按照规则3,将当前节点的根值设置为3,使当前节点返回到父节点。
d. 读取 + 作为下一个符号。根据规则2,将当前节点的根值设置为+,并添加一个新节点作为右子节点。新的右子节点
成为当前节点。
e. 读取 ( 作为下一个符号,按规则1,创建一个新节点作为当前节点的左子节点,新的左子节点成为当前节点。
f. 读取 4 作为下一个符号。根据规则3,将当前节点的值设置为 4。使当前节点返回到父节点。
g. 读取 作为下一个符号。根据规则2,将当前节点的根值设置为 ,并创建一个新的右子节点。新的右子节点成为当前节
点。
h. 读取 5 作为下一个符号。根据规则3,将当前节点的根值设置为5。使当前节点返回到父节点。
i. 读取 ) 作为下一个符号。根据规则4,使当前节点返回到父节点。
从上面的例子,很明显,我们需要跟踪当前节点以及当前节点的父节点。树接口为我们提供了一种通过 getLeftChild
和 getRightChild 方法获取节点的子节点的方法,但是我们如何跟踪父节点呢?当我们遍历树时,保持跟踪父对象的
简单解决方案是使用栈。每当我们想下降到当前节点的子节点时,我们首先将当前节点入到栈上。当我们想要返回到当
前节点的父节点时,我们将父节点从栈中弹出。
159
6.6.分析树
def buildParseTree(fpexp):
fplist = fpexp.split()
pStack = Stack()
eTree = BinaryTree('')
pStack.push(eTree)
currentTree = eTree
for i in fplist:
if i == '(':
currentTree.insertLeft('')
pStack.push(currentTree)
currentTree = currentTree.getLeftChild()
elif i not in ['+', '-', '*', '/', ')']:
currentTree.setRootVal(int(i))
parent = pStack.pop()
currentTree = parent
elif i in ['+', '-', '*', '/']:
currentTree.setRootVal(i)
currentTree.insertRight('')
pStack.push(currentTree)
currentTree = currentTree.getRightChild()
elif i == ')':
currentTree = pStack.pop()
else:
raise ValueError
return eTree
pt = buildParseTree("( ( 10 + 5 ) * 3 )")
pt.postorder() #defined and explained in the next section
Activecode1
现在我们已经构建了一个分析树,我们可以用它做什么?作为第一个例子,我们将编写一个函数来评估分析树,返回数
值结果。要写这个函数,我们将利用树的层次性。回想一下 Figure 2。我们可以用 Figure 3 中所示的简化树替换原始
树。这表明我们可以编写一个算法,通过递归地评估每个子树来评估一个分析树。
正如我们对过去的递归算法所做的,我们将通过识别基本情况来开始递归评价函数的设计。对树进行操作的递归算法的
基本情况是检查叶节点。在分析树中,叶节点将始终是操作数。由于整数和浮点等数值对象不需要进一步解释,因此
evaluate 函数可以简单地返回存储在叶节点中的值。将函数移向基本情况的递归步骤是在当前节点的左子节点和右子
节点上调用 evaluate。递归调用有效地使我们沿着树向着叶节点移动。
为了将两个递归调用的结果放在一起,我们可以简单地将存储在父节点中的运算符应用于从评估这两个子节点返回的结
果。在 Figure 3的示例中,我们看到根的两个孩子计算得出结果,即 10 和 3。应用乘法运算符给我们一个最终结果
30。
160
6.6.分析树
效于 operator.add(2,2) 。
def evaluate(parseTree):
opers = {'+':operator.add, '-':operator.sub, '*':operator.mul, '/':operator.truediv}
leftC = parseTree.getLeftChild()
rightC = parseTree.getRightChild()
Listing 1
161
6.7.树的遍历
6.7.树的遍历
我们已经见到了树数据结构的基本功能,现在是看树的一些额外使用模式的时候了。这些使用模式可以分为我们访问树
节点的三种方式。有三种常用的模式来访问树中的所有节点。这些模式之间的差异是每个节点被访问的顺序。我们称这
种访问节点方式为“遍历”。我们将看到三种遍历方式称为 前序,中序 和 后序 。让我们更仔细地定义这三种遍历方式,然
后看看这些模式有用的一些例子。
前序 在前序遍历中,我们首先访问根节点,然后递归地做左侧子树的前序遍历,随后是右侧子树的递归前序遍历。 中
序 在一个中序遍历中,我们递归地对左子树进行一次遍历,访问根节点,最后递归遍历右子树。 后序 在后序遍历中,
我们递归地对左子树和右子树进行后序遍历,然后访问根节点。
让我们看一些例子,来说明这三种遍历。首先看前序遍历。作为遍历的树的示例,我们将把这本书表示为树。这本书是
树的根,每一章都是根节点的一个孩子。章节中的每个章节都是章节的子节点,每个小节都是章节的子节点,依此类
推。Figure 5 展示了一本只有两章的书的有限版本。注意,遍历算法适用于具有任意数量子节点的树,但是我们现在使
用二叉树。
Figure 5
假设你想从前到后读这本书。前序遍历给你正确的顺序。从树的根(Book节点)开始,我们将遵循前序遍历指令。我
们递归调用左孩子的 preorder ,在这种情况下是 Chapter1 。我们再次递归调用左孩子的 preorder 来得到 Section
你可能想知道,编写像前序遍历算法的最好方法是什么?是一个简单地使用树作为数据结构的函数,还是树数据结构本
身的方法?Listing 2 展示了作为外部函数编写的前序遍历的版本,它将二叉树作为参数。外部函数特别优雅,因为我们
的基本情况只是检查树是否存在。如果树参数为 None,那么函数返回而不采取任何操作。
def preorder(tree):
if tree:
print(tree.getRootVal())
preorder(tree.getLeftChild())
162
6.7.树的遍历
preorder(tree.getRightChild())
Listing 2
def preorder(self):
print(self.key)
if self.leftChild:
self.leftChild.preorder()
if self.rightChild:
self.rightChild.preorder()
Listing 3
以上哪种方式实现前序最好? 答案是在这种情况下,实现前序作为外部函数可能更好。原因是你很少只是想遍历树。
在大多数情况下,将要使用其中一个基本的遍历模式来完成其他任务。 事实上,我们将在下面的例子中看到后序遍历
模式与我们前面编写的用于计算分析树的代码非常接近。 因此,我们用外部函数实现其余的遍历。
def postorder(tree):
if tree != None:
postorder(tree.getLeftChild())
postorder(tree.getRightChild())
print(tree.getRootVal())
Listing 4
def postordereval(tree):
opers = {'+':operator.add, '-':operator.sub, '*':operator.mul, '/':operator.truediv}
res1 = None
res2 = None
if tree:
res1 = postordereval(tree.getLeftChild())
res2 = postordereval(tree.getRightChild())
if res1 and res2:
return opers[tree.getRootVal()](res1,res2)
else:
return tree.getRootVal()
Listing 5
def inorder(tree):
if tree != None:
inorder(tree.getLeftChild())
print(tree.getRootVal())
inorder(tree.getRightChild())
163
6.7.树的遍历
Listing 6
def printexp(tree):
sVal = ""
if tree:
sVal = '(' + printexp(tree.getLeftChild())
sVal = sVal + str(tree.getRootVal())
sVal = sVal + printexp(tree.getRightChild())+')'
return sVal
164
6.8.基于二叉堆的优先队列
6.8.基于二叉堆的优先队列
在前面的部分中,你了解了称为队列的先进先出数据结构。队列的一个重要变种称为优先级队列。优先级队列的作用就
像一个队列,你可以通过从前面删除一个项目来出队。然而,在优先级队列中,队列中的项的逻辑顺序由它们的优先级
确定。最高优先级项在队列的前面,最低优先级的项在后面。因此,当你将项排入优先级队列时,新项可能会一直移动
到前面。我们将在下一章中研究一些图算法看到优先级队列是有用的数据结构。
二叉堆是很有趣的研究,因为堆看起来很像一棵树,但是当我们实现它时,我们只使用一个单一的列表作为内部表示。
二叉堆有两个常见的变体:最小堆(其中最小的键总是在前面)和最大堆(其中最大的键值总是在前面)。在本节中,
我们将实现最小堆。我们将最大堆实现作为练习。
165
6.9.二叉堆操作
6.9.二叉堆操作
我们的二叉堆实现的基本操作如下:
BinaryHeap() 创建一个新的,空的二叉堆。
insert(k) 向堆添加一个新项。
findMin() 返回具有最小键值的项,并将项留在堆中。
delMin() 返回具有最小键值的项,从堆中删除该项。
如果堆是空的,isEmpty() 返回 true,否则返回 false。
size() 返回堆中的项数。
buildHeap(list) 从键列表构建一个新的堆。
ActiveCode 1 展示了使用一些二叉堆方法。注意,无论我们向堆中添加项的顺序是什么,每次都删除最小的。我们现在
将把注意力转向如何实现这个想法。
bh = BinHeap()
bh.insert(5)
bh.insert(7)
bh.insert(3)
bh.insert(11)
print(bh.delMin())
print(bh.delMin())
print(bh.delMin())
print(bh.delMin())
166
6.10.二叉堆实现
6.10.二叉堆实现
6.10.1.结构属性
为了使我们的堆有效地工作,我们将利用二叉树的对数性质来表示我们的堆。 为了保证对数性能,我们必须保持树平
衡。平衡二叉树在根的左和右子树中具有大致相同数量的节点。 在我们的堆实现中,我们通过创建一个 完整二叉树 来
保持树平衡。 一个完整的二叉树是一个树,其中每个层都有其所有的节点,除了树的最底层,从左到右填充。 Figure 1
展示了完整二叉树的示例。
Figure 1
完整二叉树的另一个有趣的属性是,我们可以使用单个列表来表示它。 我们不需要使用节点和引用,甚至列表的列
表。因为树是完整的,父节点的左子节点(在位置 p 处)是在列表中位置 2p 中找到的节点。 类似地,父节点的右子节
点在列表中的位置 2p + 1。为了找到树中任意节点的父节点,我们可以简单地使用Python 的整数除法。 假定节点在列
表中的位置 n,则父节点在位置 n/2。 Figure 2 展示了一个完整的二叉树,并给出了树的列表表示。 请注意父级和子级
之间是 2p 和 2p+1 关系。 树的列表表示以及完整的结构属性允许我们仅使用几个简单的数学运算来高效地遍历一个完
整的二叉树。 我们将看到,这也是我们的二叉堆的有效实现。
6.10.2.堆的排序属性
我们用于堆中存储项的方法依赖于维护堆的排序属性。 堆的排序属性如下:在堆中,对于具有父 p 的每个节点 x,p 中
的键小于或等于 x 中的键。 Figure 2 展示了具有堆顺序属性的完整二叉树。
167
6.10.二叉堆实现
Figure 2
6.10.3.堆操作
我们将开始实现一个二叉堆的构造函数。由于整个二叉堆可以由单个列表表示,所以构造函数将初始化列表和一个
currentSize 属性来跟踪堆的当前大小。 Listing 1 展示了构造函数的 Python 代码。 你会注意到,一个空的二叉堆有
一个单一的零作为 heapList 的第一个元素,这个零只是放那里,用于以后简单的整数除法。
class BinHeap:
def __init__(self):
self.heapList = [0]
self.currentSize = 0
Listing 1
168
6.10.二叉堆实现
Figure 2
注意,当我们完成一个项时,我们需要恢复新添加的项和父项之间的堆属性。 我们还需保留任何兄弟节点的堆属性。
当然,如果新添加的项非常小,我们可能仍需要将其交换另一上层。事实上,我们可能需要交换到树的顶部。 Listing 2
展示了 percUp 方法,它在树中向上遍历一个新项,因为它需要去维护堆属性。 注意,我们可以通过使用简单的整数
169
6.10.二叉堆实现
def percUp(self,i):
while i // 2 > 0:
if self.heapList[i] < self.heapList[i // 2]:
tmp = self.heapList[i // 2]
self.heapList[i // 2] = self.heapList[i]
self.heapList[i] = tmp
i = i // 2
Listing 2
def insert(self,k):
self.heapList.append(k)
self.currentSize = self.currentSize + 1
self.percUp(self.currentSize)
Listing 3
170
6.10.二叉堆实现
171
6.10.二叉堆实现
Figure 3
为了维护堆顺序属性,我们所需要做的是将根节点和最小的子节点交换。在初始交换之后,我们可以将节点和其子节点
重复交换,直到节点被交换到正确的位置,使它小于两个子节点。树交换节点的代码可以在 Listing 4中的
percDown 和 minChild 方法中找到。
def percDown(self,i):
while (i * 2) <= self.currentSize:
mc = self.minChild(i)
if self.heapList[i] > self.heapList[mc]:
tmp = self.heapList[i]
self.heapList[i] = self.heapList[mc]
self.heapList[mc] = tmp
i = mc
def minChild(self,i):
if i * 2 + 1 > self.currentSize:
return i * 2
else:
if self.heapList[i*2] < self.heapList[i*2+1]:
return i * 2
else:
return i * 2 + 1
Listing 4
def delMin(self):
retval = self.heapList[1]
self.heapList[1] = self.heapList[self.currentSize]
self.currentSize = self.currentSize - 1
self.heapList.pop()
self.percDown(1)
return retval
Listing 5
为了完成我们对二叉堆的讨论,我们将看从一个列表构建整个堆的方法。你可能想到的第一种方法如下所示。给定一个
列表,通过一次插入一个键轻松地构建一个堆。由于你从一个项的列表开始,该列表是有序的,可以使用二分查找找到
正确的位置,以大约 O(log^n ) 操作的成本插入下一个键。 但是,请记住,在列表中间插入项可能需要 O(n) 操作来移
动列表的其余部分,为新项腾出空间。 因此,要在堆中插入 n 个键,将需要总共 O(nlogn) 操作。 然而,如果我们从整
个列表开始,那么我们可以在 O(n) 操作中构建整个堆。Listing 6 展示了构建整个堆的代码。
def buildHeap(self,alist):
i = len(alist) // 2
self.currentSize = len(alist)
self.heapList = [0] + alist[:]
while (i > 0):
self.percDown(i)
i = i - 1
Listing 6
172
6.10.二叉堆实现
Figure 4
i = 2 [0, 9, 5, 6, 2, 3]
i = 1 [0, 9, 2, 6, 5, 3]
i = 0 [0, 2, 3, 6, 5, 9]
完整二叉堆代码实现见 activecode 1
class BinHeap:
def __init__(self):
self.heapList = [0]
self.currentSize = 0
def percUp(self,i):
while i // 2 > 0:
if self.heapList[i] < self.heapList[i // 2]:
tmp = self.heapList[i // 2]
self.heapList[i // 2] = self.heapList[i]
self.heapList[i] = tmp
i = i // 2
def insert(self,k):
self.heapList.append(k)
self.currentSize = self.currentSize + 1
self.percUp(self.currentSize)
def percDown(self,i):
while (i * 2) <= self.currentSize:
mc = self.minChild(i)
if self.heapList[i] > self.heapList[mc]:
tmp = self.heapList[i]
self.heapList[i] = self.heapList[mc]
self.heapList[mc] = tmp
i = mc
def minChild(self,i):
if i * 2 + 1 > self.currentSize:
173
6.10.二叉堆实现
return i * 2
else:
if self.heapList[i*2] < self.heapList[i*2+1]:
return i * 2
else:
return i * 2 + 1
def delMin(self):
retval = self.heapList[1]
self.heapList[1] = self.heapList[self.currentSize]
self.currentSize = self.currentSize - 1
self.heapList.pop()
self.percDown(1)
return retval
def buildHeap(self,alist):
i = len(alist) // 2
self.currentSize = len(alist)
self.heapList = [0] + alist[:]
while (i > 0):
self.percDown(i)
i = i - 1
bh = BinHeap()
bh.buildHeap([9,5,6,2,3])
print(bh.delMin())
print(bh.delMin())
print(bh.delMin())
print(bh.delMin())
print(bh.delMin())
ActiveCode 1
174
6.11.二叉查找树
6.11.二叉查找树
我们已经看到了两种不同的方法来获取集合中的键值对。回想一下,这些集合实现了 map 抽象数据类型。我们讨论的
map ADT 的两个实现是在列表和哈希表上的二分搜索。在本节中,我们将研究二叉查找树作为从键映射到值的另一种
方法。 在这种情况下,我们对树中项的确切位置不感兴趣,但我们有兴趣使用二叉树结构来提供高效的搜索。
175
6.12.查找树操作
6.12.查找树操作
在我们看实现之前,先来看看 map ADT 提供的接口。你会注意到,这个接口与Python 字典非常相似。
176
6.13.查找树实现
6.13.查找树实现
二叉搜索树依赖于在左子树中找到的键小于父节点的属性,并且在右子树中找到的键大于父代。 我们将这个称为 bst属
性。 当我们如上所述实现 Map 接口时,bst 属性将指导我们的实现。 Figure 1说明了二叉搜索树的此属性,展示了没
有任何关联值的键。请注意,该属性适用于每个父级和子级。 左子树中的所有键小于根中的键。 右子树中的所有键都
大于根。
Figure1
现在你知道什么是二叉搜索树,我们将看看如何构造二叉搜索树。Figure 1中的搜索树表示在按照所示的顺序插入以下
键之后存在的节点: 70,31,93,94,14,23,73 。因为 70 是插入树中的第一个键,它是根。接下来,31 小于 70,所以它
成为 70 的左孩子。接下来,93 大于 70,所以它成为 70 的右孩子。现在我们有两层的树填充,所以下一个键 94 ,因
为 94 大于70 和 93,它成为 93 的右孩子。类似地,14 小于 70 和 31,所以它变成 31 的左孩子。23 也小于 31,所以
它必须在左子树 31 中。但是,它大于14,所以它成为 14 的右孩子。
为了实现二叉搜索树,我们将使用类似于我们用于实现链表的节点和引用方法,以及表达式树。但是,因为我们必须能
够创建和使用一个空的二叉搜索树,我们的实现将使用两个类。第一个类称为 BinarySearchTree ,第二个类称
为 TreeNode 。 BinarySearchTree 类具有对作为二叉搜索树的根的 TreeNode 的引用。在大多数情况下,外部类中定义
的外部方法只是检查树是否为空。如果树中有节点,请求只是传递到 BinarySearchTree 类中定义的私有方法,该方法
以 root 作为参数。在树是空的或者我们想要删除树根的键的情况下,我们必须采取特殊的行动。 BinarySearchTree 类
构造函数的代码以及一些其他杂项函数如Listing 1所示。
class BinarySearchTree:
def __init__(self):
self.root = None
self.size = 0
def length(self):
return self.size
def __len__(self):
return self.size
def __iter__(self):
return self.root.__iter__()
177
6.13.查找树实现
Listing 1
class TreeNode:
def __init__(self,key,val,left=None,right=None,
parent=None):
self.key = key
self.payload = val
self.leftChild = left
self.rightChild = right
self.parent = parent
def hasLeftChild(self):
return self.leftChild
def hasRightChild(self):
return self.rightChild
def isLeftChild(self):
return self.parent and self.parent.leftChild == self
def isRightChild(self):
return self.parent and self.parent.rightChild == self
def isRoot(self):
return not self.parent
def isLeaf(self):
return not (self.rightChild or self.leftChild)
def hasAnyChildren(self):
return self.rightChild or self.leftChild
def hasBothChildren(self):
return self.rightChild and self.leftChild
def replaceNodeData(self,key,value,lc,rc):
self.key = key
self.payload = value
self.leftChild = lc
self.rightChild = rc
if self.hasLeftChild():
self.leftChild.parent = self
if self.hasRightChild():
self.rightChild.parent = self
Listing 2
从树的根开始,搜索二叉树,将新键与当前节点中的键进行比较。如果新键小于当前节点,则搜索左子树。如果新
键大于当前节点,则搜索右子树。
178
6.13.查找树实现
当没有左(或右)孩子要搜索时,我们在树中找到应该建立新节点的位置。
要向树中添加节点,请创建一个新的 TreeNode 对象,并将对象插入到上一步发现的节点。
我们实现插入的一个重要问题是重复的键不能正确处理。当我们的树被实现时,重复键将在具有原始键的节点的右子树
中创建具有相同键值的新节点。这样做的结果是,具有新键的节点将永远不会在搜索期间被找到。处理插入重复键的更
好方法是将新键相关联的值替换旧值。我们将修复这个bug作为练习。
def put(self,key,val):
if self.root:
self._put(key,val,self.root)
else:
self.root = TreeNode(key,val)
self.size = self.size + 1
def _put(self,key,val,currentNode):
if key < currentNode.key:
if currentNode.hasLeftChild():
self._put(key,val,currentNode.leftChild)
else:
currentNode.leftChild = TreeNode(key,val,parent=currentNode)
else:
if currentNode.hasRightChild():
self._put(key,val,currentNode.rightChild)
else:
currentNode.rightChild = TreeNode(key,val,parent=currentNode)
Listing 3
def __setitem__(self,k,v):
self.put(k,v)
Listing 4
Figure 2
179
6.13.查找树实现
Listing 5 展示了 get , _get 和 __getitem__ 的代码。 _get 方法中的搜索代码使用和 _put 相同的逻辑来选择左
或右子节点。请注意, _get 方法返回一个 TreeNode ,这允许 _get 用作其他 BinarySearchTree 方法的一个灵活的
辅助方法,可能需要利用除了有效载荷之外的 TreeNode 的其他数据。
def get(self,key):
if self.root:
res = self._get(key,self.root)
if res:
return res.payload
else:
return None
else:
return None
def _get(self,key,currentNode):
if not currentNode:
return None
elif currentNode.key == key:
return currentNode
elif key < currentNode.key:
return self._get(key,currentNode.leftChild)
else:
return self._get(key,currentNode.rightChild)
def __getitem__(self,key):
return self.get(key)
Listing 5
def __contains__(self,key):
if self._get(key,self.root):
return True
else:
return False
Listing 6
if 'Northfield' in myZipTree:
print("oom ya ya")
def delete(self,key):
if self.size > 1:
nodeToRemove = self._get(key,self.root)
if nodeToRemove:
180
6.13.查找树实现
self.remove(nodeToRemove)
self.size = self.size-1
else:
raise KeyError('Error, key not in tree')
elif self.size == 1 and self.root.key == key:
self.root = None
self.size = self.size - 1
else:
raise KeyError('Error, key not in tree')
def __delitem__(self,key):
self.delete(key)
Listing 7
一旦我们找到了我们要删除的键的节点,我们必须考虑三种情况:
1. 要删除的节点没有子节点(参见Figure 3)。
2. 要删除的节点只有一个子节点(见Figure 4)。
3. 要删除的节点有两个子节点(见Figure 5)。
if currentNode.isLeaf():
if currentNode == currentNode.parent.leftChild:
currentNode.parent.leftChild = None
else:
currentNode.parent.rightChild = None
Listing 8
Figure 3
1. 如果当前节点是左子节点,则我们只需要更新左子节点的父引用以指向当前节点的父节点,然后更新父节点的左子
节点引用以指向当前节点的左子节点。
2. 如果当前节点是右子节点,则我们只需要更新左子节点的父引用以指向当前节点的父节点,然后更新父节点的右子
181
6.13.查找树实现
节点引用以指向当前节点的左子节点。
3. 如果当前节点没有父级,则它是根。在这种情况下,我们将通过在根上调用 replaceNodeData 方法来替换
key , payload , leftChild 和 rightChild 数据。
Listing 9
Figure 4
182
6.13.查找树实现
Figure 5
Listing 10
1. 如果节点有右子节点,则后继节点是右子树中的最小的键。
2. 如果节点没有右子节点并且是父节点的左子节点,则父节点是后继节点。
3. 如果节点是其父节点的右子节点,并且它本身没有右子节点,则此节点的后继节点是其父节点的后继节点,不包括
此节点。
调用 findMin 方法来查找子树中的最小键。你应该说服自己,任何二叉搜索树中的最小值键是树的最左子节点。因
此, findMin 方法简单地循环子树的每个节点中的 leftChild 引用,直到它到达没有左子节点的节点。
def findSuccessor(self):
succ = None
if self.hasRightChild():
succ = self.rightChild.findMin()
else:
if self.parent:
if self.isLeftChild():
succ = self.parent
183
6.13.查找树实现
else:
self.parent.rightChild = None
succ = self.parent.findSuccessor()
self.parent.rightChild = self
return succ
def findMin(self):
current = self
while current.hasLeftChild():
current = current.leftChild
return current
def spliceOut(self):
if self.isLeaf():
if self.isLeftChild():
self.parent.leftChild = None
else:
self.parent.rightChild = None
elif self.hasAnyChildren():
if self.hasLeftChild():
if self.isLeftChild():
self.parent.leftChild = self.leftChild
else:
self.parent.rightChild = self.leftChild
self.leftChild.parent = self.parent
else:
if self.isLeftChild():
self.parent.leftChild = self.rightChild
else:
self.parent.rightChild = self.rightChild
self.rightChild.parent = self.parent
Listing 11
我们需要查看二叉搜索树的最后一个接口方法。假设我们想要按中序遍历树中的所有键。我们肯定用字典做,为什么不
是树?你已经知道如何使用中序遍历算法按顺序遍历二叉树。然而,编写迭代器需要更多的工作,因为迭代器在每次调
用迭代器时只返回一个节点。
def __iter__(self):
if self:
if self.hasLeftChild():
for elem in self.leftChild:
yield elem
yield self.key
if self.hasRightChild():
for elem in self.rightChild:
yield elem
class TreeNode:
def __init__(self,key,val,left=None,right=None,parent=None):
self.key = key
self.payload = val
self.leftChild = left
self.rightChild = right
184
6.13.查找树实现
self.parent = parent
def hasLeftChild(self):
return self.leftChild
def hasRightChild(self):
return self.rightChild
def isLeftChild(self):
return self.parent and self.parent.leftChild == self
def isRightChild(self):
return self.parent and self.parent.rightChild == self
def isRoot(self):
return not self.parent
def isLeaf(self):
return not (self.rightChild or self.leftChild)
def hasAnyChildren(self):
return self.rightChild or self.leftChild
def hasBothChildren(self):
return self.rightChild and self.leftChild
def replaceNodeData(self,key,value,lc,rc):
self.key = key
self.payload = value
self.leftChild = lc
self.rightChild = rc
if self.hasLeftChild():
self.leftChild.parent = self
if self.hasRightChild():
self.rightChild.parent = self
class BinarySearchTree:
def __init__(self):
self.root = None
self.size = 0
def length(self):
return self.size
def __len__(self):
return self.size
def put(self,key,val):
if self.root:
self._put(key,val,self.root)
else:
self.root = TreeNode(key,val)
self.size = self.size + 1
def _put(self,key,val,currentNode):
if key < currentNode.key:
if currentNode.hasLeftChild():
self._put(key,val,currentNode.leftChild)
else:
currentNode.leftChild = TreeNode(key,val,parent=currentNode)
else:
if currentNode.hasRightChild():
self._put(key,val,currentNode.rightChild)
else:
currentNode.rightChild = TreeNode(key,val,parent=currentNode)
def __setitem__(self,k,v):
185
6.13.查找树实现
self.put(k,v)
def get(self,key):
if self.root:
res = self._get(key,self.root)
if res:
return res.payload
else:
return None
else:
return None
def _get(self,key,currentNode):
if not currentNode:
return None
elif currentNode.key == key:
return currentNode
elif key < currentNode.key:
return self._get(key,currentNode.leftChild)
else:
return self._get(key,currentNode.rightChild)
def __getitem__(self,key):
return self.get(key)
def __contains__(self,key):
if self._get(key,self.root):
return True
else:
return False
def delete(self,key):
if self.size > 1:
nodeToRemove = self._get(key,self.root)
if nodeToRemove:
self.remove(nodeToRemove)
self.size = self.size-1
else:
raise KeyError('Error, key not in tree')
elif self.size == 1 and self.root.key == key:
self.root = None
self.size = self.size - 1
else:
raise KeyError('Error, key not in tree')
def __delitem__(self,key):
self.delete(key)
def spliceOut(self):
if self.isLeaf():
if self.isLeftChild():
self.parent.leftChild = None
else:
self.parent.rightChild = None
elif self.hasAnyChildren():
if self.hasLeftChild():
if self.isLeftChild():
self.parent.leftChild = self.leftChild
else:
self.parent.rightChild = self.leftChild
self.leftChild.parent = self.parent
else:
if self.isLeftChild():
self.parent.leftChild = self.rightChild
else:
self.parent.rightChild = self.rightChild
self.rightChild.parent = self.parent
def findSuccessor(self):
186
6.13.查找树实现
succ = None
if self.hasRightChild():
succ = self.rightChild.findMin()
else:
if self.parent:
if self.isLeftChild():
succ = self.parent
else:
self.parent.rightChild = None
succ = self.parent.findSuccessor()
self.parent.rightChild = self
return succ
def findMin(self):
current = self
while current.hasLeftChild():
current = current.leftChild
return current
def remove(self,currentNode):
if currentNode.isLeaf(): #leaf
if currentNode == currentNode.parent.leftChild:
currentNode.parent.leftChild = None
else:
currentNode.parent.rightChild = None
elif currentNode.hasBothChildren(): #interior
succ = currentNode.findSuccessor()
succ.spliceOut()
currentNode.key = succ.key
currentNode.payload = succ.payload
mytree = BinarySearchTree()
mytree[3]="red"
mytree[4]="blue"
mytree[6]="yellow"
mytree[2]="at"
print(mytree[6])
print(mytree[2])
187
6.13.查找树实现
188
6.14.查找树分析
6.14.查找树分析
随着二叉搜索树的实现完成,我们将对已经实现的方法进行快速分析。让我们先来看看 put 方法。其性能的限制因素
是二叉树的高度。从词汇部分回忆一下树的高度是根和最深叶节点之间的边的数量。高度是限制因素,因为当我们寻找
合适的位置将一个节点插入到树中时,我们需要在树的每个级别最多进行一次比较。
二叉树的高度可能是多少?这个问题的答案取决于如何将键添加到树。如果按照随机顺序添加键,树的高度将在 log2^n
附近,其中 n 是树中的节点数。这是因为如果键是随机分布的,其中大约一半将小于根,一半大于根。请记住,在二叉
树中,根节点有一个节点,下一级节点有两个节点,下一个节点有四个节点。任何特定级别的节点数为 2^d ,其中 d 是
级别的深度。完全平衡的二叉树中的节点总数为 2^h+1 - 1,其中 h 表示树的高度。
Figure 6
189
6.15.平衡二叉搜索树
6.15.平衡二叉搜索树
在上一节中,我们考虑构建一个二叉搜索树。正如我们所学到的,二叉搜索树的性能可以降级到 O(n) 的操作,如 get
和 put ,如果树变得不平衡。在本节中,我们将讨论一种特殊类型的二叉搜索树,它自动确保树始终保持平衡。这棵
树被称为 AVL树,以其发明人命名:G.M. Adelson-Velskii 和E.M.Landis。
使用上面给出的平衡因子的定义,我们说如果平衡因子大于零,则子树是左重的。如果平衡因子小于零,则子树是右重
的。如果平衡因子是零,那么树是完美的平衡。为了实现AVL树,并且获得具有平衡树的好处,如果平衡因子是 -1,0 或
1,我们将定义树平衡。一旦树中的节点的平衡因子是在这个范围之外,我们将需要一个程序来使树恢复平衡。Figure 1
展示了不平衡,右重树和每个节点的平衡因子的示例。
Figure 1
190
6.16.AVL平衡二叉搜索树
6.16.AVL平衡二叉搜索树
在我们继续之前,我们来看看执行这个新的平衡因子要求的结果。我们的主张是,通过确保树总是具有 -1,0或1 的平衡
因子,我们可以获得更好的操作性能的关键操作。 让我们开始思考这种平衡条件如何改变最坏情况的树。有两种可能
性,一个左重树和一个右重树。 如果我们考虑高度0,1,2和3的树,Figure 2 展示了在新规则下可能的最不平衡的左重
树。
Figure 2
看树中节点的总数,我们看到对于高度为0的树,有1个节点,对于高度为1的树,有1 + 1 = 2个节点,对于高度为2的树
是1 + 1 + 2 = 4,对于高度为3的树,有1 + 2 + 4 = 7。 更一般地,我们看到的高度h(Nh) 的树中的节点数量的模式是:
这种可能看起来很熟悉,因为它非常类似于斐波纳契序列。 给定树中节点的数量,我们可以使用这个事实来导出AVL树
的高度的公式。 回想一下,对于斐波纳契数列,第i个斐波纳契数字由下式给出:
通过用其黄金比例近似替换斐波那契参考,我们得到:
191
6.16.AVL平衡二叉搜索树
如果我们重新排列这些项,并取两边的底数为2的对数,然后求解 h,我们得到以下推导:
这个推导告诉我们,在任何时候,我们的AVL树的高度等于树中节点数目的对数的常数(1.44)倍。 这是搜索我们的
AVL树的好消息,因为它将搜索限制为O(logN)。
192
6.17.AVL平衡二叉搜索树实现
6.17.AVL平衡二叉搜索树实现
现在我们已经证明保持 AVL树的平衡将是一个很大的性能改进,让我们看看如何增加过程来插入一个新的键到树。由于
所有新的键作为叶节点插入到树中,并且我们知道新叶的平衡因子为零,所以刚刚插入的节点没有新的要求。但一旦添
加新叶,我们必须更新其父的平衡因子。这个新叶如何影响父的平衡因子取决于叶节点是左孩子还是右孩子。如果新节
点是右子节点,则父节点的平衡因子将减少1。如果新节点是左子节点,则父节点的平衡因子将增加1。这个关系可以递
归地应用到新节点的祖父节点,并且应用到每个祖先一直到树的根。由于这是一个递归过程,我们来看一下用于更新平
衡因子的两种基本情况:
递归调用已到达树的根。
父节点的平衡因子已调整为零。你应该说服自己,一旦一个子树的平衡因子为零,那么它的祖先节点的平衡不会改
变。
def _put(self,key,val,currentNode):
if key < currentNode.key:
if currentNode.hasLeftChild():
self._put(key,val,currentNode.leftChild)
else:
currentNode.leftChild = TreeNode(key,val,parent=currentNode)
self.updateBalance(currentNode.leftChild)
else:
if currentNode.hasRightChild():
self._put(key,val,currentNode.rightChild)
else:
currentNode.rightChild = TreeNode(key,val,parent=currentNode)
self.updateBalance(currentNode.rightChild)
def updateBalance(self,node):
if node.balanceFactor > 1 or node.balanceFactor < -1:
self.rebalance(node)
return
if node.parent != None:
if node.isLeftChild():
node.parent.balanceFactor += 1
elif node.isRightChild():
node.parent.balanceFactor -= 1
if node.parent.balanceFactor != 0:
self.updateBalance(node.parent)
Listing 1
当需要树重新平衡时,我们如何做呢?有效的重新平衡是使AVL树在不牺牲性能的情况下正常工作的关键。为了使AVL
树恢复平衡,我们将在树上执行一个或多个旋转。
193
6.17.AVL平衡二叉搜索树实现
Figure 3
要执行左旋转,我们基本上执行以下操作:
提升右孩子(B)成为子树的根。
将旧根(A)移动为新根的左子节点。
如果新根(B)已经有一个左孩子,那么使它成为新左孩子(A)的右孩子。注意:由于新根(B)是A的右孩子,
A 的右孩子在这一点上保证为空。这允许我们添加一个新的节点作为右孩子,不需进一步的考虑。
虽然这个过程在概念上相当容易,但是代码的细节有点棘手,因为我们需要按照正确的顺序移动事物,以便保留二叉搜
索树的所有属性。此外,我们需要确保适当地更新所有的父指针。
提升左子节点(C)为子树的根。
将旧根(E)移动为新根的右子树。
如果新根(C)已经有一个正确的孩子(D),那么使它成为新的右孩子(E)的左孩子。注意:由于新根(C)是
E 的左子节点,因此 E 的左子节点在此时保证为空。这允许我们添加一个新节点作为左孩子,不需进一步的考虑。
Figure 4
194
6.17.AVL平衡二叉搜索树实现
现在你已经看到了旋转,并且有旋转的工作原理的基本概念,让我们看看代码。Listing 2展示了右旋转和左旋转的代
码。在第2行中,我们创建一个临时变量来跟踪子树的新根。正如我们之前所说的,新的根是上一个根的右孩子。现在
对这个临时变量存储了一个对右孩子的引用,我们用新的左孩子替换旧根的右孩子。
def rotateLeft(self,rotRoot):
newRoot = rotRoot.rightChild
rotRoot.rightChild = newRoot.leftChild
if newRoot.leftChild != None:
newRoot.leftChild.parent = rotRoot
newRoot.parent = rotRoot.parent
if rotRoot.isRoot():
self.root = newRoot
else:
if rotRoot.isLeftChild():
rotRoot.parent.leftChild = newRoot
else:
rotRoot.parent.rightChild = newRoot
newRoot.leftChild = rotRoot
rotRoot.parent = newRoot
rotRoot.balanceFactor = rotRoot.balanceFactor + 1 - min(newRoot.balanceFactor, 0)
newRoot.balanceFactor = newRoot.balanceFactor + 1 + max(rotRoot.balanceFactor, 0)
Listing 2
Figure 5
newBal(B)=hA−hC oldBal(B)=hA−hD
oldBal(B)=hA−(1+max(hC,hE))
195
6.17.AVL平衡二叉搜索树实现
newBal(B)−oldBal(B)=hA−hC−(hA−(1+max(hC,hE))) newBal(B)−oldBal(B)=hA−hC−hA+(1+max(hC,hE))
newBal(B)−oldBal(B)=hA−hA+1+max(hC,hE)−hC newBal(B)−oldBal(B)=1+max(hC,hE)−hC
newBal(B)=oldBal(B)+1+max(hC−hC,hE−hC)
newBal(B)=oldBal(B)+1+max(0,−oldBal(D)) newBal(B)=oldBal(B)+1−min(0,oldBal(D))
Figure 6
Figure 7
196
6.17.AVL平衡二叉搜索树实现
要纠正这个问题,我们必须使用以下规则集:
如果子树需要左旋转使其平衡,首先检查右子节点的平衡因子。 如果右孩子是重的,那么对右孩子做右旋转,然
后是原来的左旋转。
如果子树需要右旋转使其平衡,首先检查左子节点的平衡因子。 如果左孩子是重的,那么对左孩子做左旋转,然
后是原来的右旋转。
Figure 8
def rebalance(self,node):
if node.balanceFactor < 0:
if node.rightChild.balanceFactor > 0:
self.rotateRight(node.rightChild)
self.rotateLeft(node)
else:
self.rotateLeft(node)
elif node.balanceFactor > 0:
if node.leftChild.balanceFactor < 0:
self.rotateLeft(node.leftChild)
self.rotateRight(node)
else:
self.rotateRight(node)
Listing 3
在这一点上,我们已经实现了一个功能AVL树,除非你需要删除一个节点的能力。我们保留删除节点和随后的更新和重
新平衡作为一个练习。
197
6.18.Map抽象数据结构总结
6.18.Map抽象数据结构总结
在前面两章中,我们已经研究了可以用于实现 Map 抽象数据类型的几个数据结构。二叉搜索表,散列表,二叉搜索树
和平衡二叉搜索树。 总结这一节,让我们总结 Map ADT 定义的关键操作的每个数据结构的性能(见Table 1)。
198
6.19.总结
6.19.总结
在这一章中,我们看了树的数据结构。 树数据结构使我们能够编写许多有趣的算法。 在本章中,我们研究了使用树来
执行以下操作的算法:
用于解析和计算表达式的二叉树。
用于实现 Map ADT的二叉树。
用于实现 Map ADT的平衡二叉树(AVL树)。
一个二叉树实现一个最小堆。
用于实现优先级队列的最小堆。
199
7.图和图的算法
200
7.1.目标
7.1.目标
了解图是什么,以及如何使用它。
使用多个内部表示来实现图抽象数据类型。
看看如何使用图来解决各种各样的问题
在本章中,我们将研究图。图是比我们在上一章中研究的树更通用的结构;实际上你可以认为树是一种特殊的图。图可
以用来表示我们世界上许多有趣的事情,包括道路系统,从城市到城市的航空公司航班,互联网如何连接,甚至是完成
计算机科学专业必须完成的课程顺序。我们将在本章中看到,一旦我们有一个问题的好的表示,我们可以使用一些标准
图算法来解决,否则可能是一个非常困难的问题。
虽然人们相对容易看路线图并且理解不同地点之间的关系,但计算机没有这样的知识。然而,我们也可以将路线图视为
图。当我们这样做时,我们可以让我们的计算机为我们做有趣的事情。如果你曾经使用过一个互联网地图网站,你知道
一台计算机可以找到从一个地方到另一个地方最短,最快或最简单的路径。
作为计算机科学的学生,你可能想知道你必须学习的课程,以获得一个学位。图是表示学该课程之前的先决条件和其他
相互依存关系的好方法。Figure 1 展示了另一个图。这个代表了在路德学院完成计算机科学专业的课程和顺序。
201
7.2.词汇和定义
7.2.词汇和定义
现在我们已经看了一些图的示例,我们将更正式地定义图及其组件。我们已经从对树的讨论中知道了一些术语。
顶点 顶点(也称为“节点”)是图的基本部分。它可以有一个名称,我们将称为“键”。一个顶点也可能有额外的信息。我
们将这个附加信息称为“有效载荷”。
边 边(也称为“弧”)是图的另一个基本部分。边连接两个顶点,以表明它们之间存在关系。边可以是单向的或双向的。
如果图中的边都是单向的,我们称该图是 有向图 。上面显示的课程先决条件显然是一个图,因为你必须在其他课程之前
学习一些课程。
权重 边可以被加权以示出从一个顶点到另一个顶点的成本。例如,在将一个城市连接到另一个城市的道路的图表中,
边上的权重可以表示两个城市之间的距离。
Figure 2 展示了简单加权有向图的另一示例。正式地,我们可以将该图表示为六个顶点的集合:
V={V0,V1,V2,V3,V4,V5}
和 9 条边的集合
E={(v0,v1,5),(v1,v2,4),(v2,v3,9),(v3,v4,7),(v4,v0,1),(v0,v5,2),(v5,v4,8),(v3,v5,3),(v5,v2,1)}
Figure 2
figure 2 中的示例图有助于说明两个其他关键图形术语:
202
7.2.词汇和定义
} 。
203
7.3.图抽象数据类型
7.3.图抽象数据类型
图抽象数据类型(ADT)定义如下:
Graph() 创建一个新的空图。
addVertex(vert) 向图中添加一个顶点实例。
addEdge(fromVert, toVert) 向连接两个顶点的图添加一个新的有向边。
addEdge(fromVert, toVert, weight) 向连接两个顶点的图添加一个新的加权的有向边。
getVertex(vertKey) 在图中找到名为 vertKey 的顶点。
getVertices() 返回图中所有顶点的列表。
in 返回 True 如果 vertex in graph 里给定的顶点在图中,否则返回False。
204
7.4.邻接矩阵
7.4.邻接矩阵
实现图的最简单的方法之一是使用二维矩阵。在该矩阵实现中,每个行和列表示图中的顶点。存储在行 v 和列 w 的交
叉点处的单元中的值表示是否存在从顶点 v 到顶点 w 的边。 当两个顶点通过边连接时,我们说它们是相邻的。 Figure
3 展示了 Figure 2 中的图的邻接矩阵。单元格中的值表示从顶点 v 到顶点 w 的边的权重。
Figure 3
邻接矩阵的优点是简单,对于小图,很容易看到哪些节点连接到其他节点。 然而,注意矩阵中的大多数单元格是空
的。 因为大多数单元格是空的,我们说这个矩阵是“稀疏的”。矩阵不是一种非常有效的方式来存储稀疏数据。 事实上,
在Python中,你甚至要创建一个如 Figure 3所示的矩阵结构。
当边的数量大时,邻接矩阵是图的良好实现。但是什么是大?填充矩阵需要多少边? 由于图中每个顶点有一行和一
列,填充矩阵所需的边数为 |V|^2。 当每个顶点连接到每个其他顶点时,矩阵是满的。有几个真实的问题,接近这种连
接。 我们在本章中讨论的问题都涉及稀疏连接的图。
205
7.5.邻接表
7.5.邻接表
实现稀疏连接图的更空间高效的方法是使用邻接表。在邻接表实现中,我们保存Graph 对象中的所有顶点的主列表,然
后图中的每个顶点对象维护连接到的其他顶点的列表。 在我们的顶点类的实现中,我们将使用字典而不是列表,其中
字典键是顶点,值是权重。 Figure 4 展示了 Figure 2中的图的邻接列表示。
Figure 4
邻接表实现的优点是它允许我们紧凑地表示稀疏图。 邻接表还允许我们容易找到直接连接到特定顶点的所有链接。
206
7.6.实现
7.6.实现
使用字典,很容易在 Python 中实现邻接表。在我们的 Graph 抽象数据类型的实现中,我们将创建两个类(见 Listing 1
和 Listing 2),Graph(保存顶点的主列表)和 Vertex(将表示图中的每个顶点)。
class Vertex:
def __init__(self,key):
self.id = key
self.connectedTo = {}
def addNeighbor(self,nbr,weight=0):
self.connectedTo[nbr] = weight
def __str__(self):
return str(self.id) + ' connectedTo: ' + str([x.id for x in self.connectedTo])
def getConnections(self):
return self.connectedTo.keys()
def getId(self):
return self.id
def getWeight(self,nbr):
return self.connectedTo[nbr]
Listing 1
class Graph:
def __init__(self):
self.vertList = {}
self.numVertices = 0
def addVertex(self,key):
self.numVertices = self.numVertices + 1
newVertex = Vertex(key)
self.vertList[key] = newVertex
return newVertex
def getVertex(self,n):
if n in self.vertList:
return self.vertList[n]
else:
return None
def __contains__(self,n):
return n in self.vertList
def addEdge(self,f,t,cost=0):
if f not in self.vertList:
nv = self.addVertex(f)
if t not in self.vertList:
207
7.6.实现
nv = self.addVertex(t)
self.vertList[f].addNeighbor(self.vertList[t], cost)
def getVertices(self):
return self.vertList.keys()
def __iter__(self):
return iter(self.vertList.values())
Listing 2
>>> g = Graph()
>>> for i in range(6):
... g.addVertex(i)
>>> g.vertList
{0: <adjGraph.Vertex instance at 0x41e18>,
1: <adjGraph.Vertex instance at 0x7f2b0>,
2: <adjGraph.Vertex instance at 0x7f288>,
3: <adjGraph.Vertex instance at 0x7f350>,
4: <adjGraph.Vertex instance at 0x7f328>,
5: <adjGraph.Vertex instance at 0x7f300>}
>>> g.addEdge(0,1,5)
>>> g.addEdge(0,5,2)
>>> g.addEdge(1,2,4)
>>> g.addEdge(2,3,9)
>>> g.addEdge(3,4,7)
>>> g.addEdge(3,5,3)
>>> g.addEdge(4,0,1)
>>> g.addEdge(5,4,8)
>>> g.addEdge(5,2,1)
>>> for v in g:
... for w in v.getConnections():
... print("( %s , %s )" % (v.getId(), w.getId()))
...
( 0 , 5 )
( 0 , 1 )
( 1 , 2 )
( 2 , 3 )
( 3 , 4 )
( 3 , 5 )
( 4 , 0 )
( 5 , 4 )
( 5 , 2 )
Figure 2
208
7.7.字梯的问题
7.7.字梯的问题
让我们从下面的叫字梯的难题开始图算法研究。将单词 “FOOL” 转换为单词 “SAGE”。 在字梯中你通过改变一个字母逐
渐发生变化。 在每一步,你必须将一个字变换成另一个字。 字梯益智游戏是刘易斯卡罗尔 1878 年发明的,爱丽丝梦
游仙境的作者。下面的单词序列示出了对上述问题的一种可能的解决方案。
FOOL
POOL
POLL
POLE
PALE
SALE
SAGE
有许多关于字梯问题的变种。例如,可能附加了完成转换的特定数量的步骤,或者可能需要使用特定的词。在本节中,
我们将计算起始字转换为结束字所需的最小转换次数。
毫不奇怪,因为这一章是图,我们可以使用图算法解决这个问题。 这里是我们需要的步骤:
将字之间的关系表示为图。
使用称为广度优先搜索的图算法来找到从起始字到结束字的有效路径。
209
7.8.构建字梯图
7.8.构建字梯图
我们的第一个问题是弄清楚如何将大量的单词集合转换为图。 如果两个词只有一个字母不同,我们就创建从一个词到
另一个词的边。如果我们可以创建这样的图,则从一个词到另一个词的任意路径就是词梯子拼图的解决方案。 Figure 1
展示了一些解决 FOOL 到 SAGE 字梯问题的单词的小图。 请注意,图是无向图,边未加权。
Figure 1
我们可以使用几种不同的方法来创建解决这个问题的图。假设我们有一个长度相同的单词列表。作为起点,我们可以在
图中为列表中的每个单词创建一个顶点。为了弄清楚如何连接单词,我们可以比较列表中的每个单词。比较时我们看有
多少字母是不同的。如果所讨论的两个字只有一个字母不同,我们可以在图中创建它们之间的边。对于小的列表,这种
方法会正常工作;然而假设我们有一个 5,110 词的列表。粗略地说,将一个字与列表上的每个其他词进行比较是 O(n^2
)。对于5110 个词,n^2 是超过2600万的比较。
我们可以通过以下方法做得更好。假设我们有大量的桶,每个桶在外面有一个四个字母的单词,除了标签中的一个字母
已经被下划线替代。例如,看 Figure 2,我们可能有一个标记为 “pop” 的桶。当我们处理列表中的每个单词时,我们使
用 “” 作为通配符比较每个桶的单词,所以 “pope” 和 “pops “ 将匹配 ”pop_“。每次我们找到一个匹配的桶,我们就把单
词放在那个桶。一旦我们把所有单词放到适当的桶中,就知道桶中的所有单词必须连接。
Figure 2
210
7.8.构建字梯图
在 Python 中,我们使用字典来实现我们刚才描述的方案。我们刚才描述的桶上的标签是我们字典中的键。该键存储的
值是单词列表。 一旦我们建立了字典,我们可以创建图。 我们通过为图中的每个单词创建一个顶点来开始图。 然后,
我们在字典中的相同键下找到的所有顶点创建边。 Listing 1 展示了构建图所需的 Python 代码。
def buildGraph(wordFile):
d = {}
g = Graph()
wfile = open(wordFile,'r')
# create buckets of words that differ by one letter
for line in wfile:
word = line[:-1]
for i in range(len(word)):
bucket = word[:i] + '_' + word[i+1:]
if bucket in d:
d[bucket].append(word)
else:
d[bucket] = [word]
# add vertices and edges for words in the same bucket
for bucket in d.keys():
for word1 in d[bucket]:
for word2 in d[bucket]:
if word1 != word2:
g.addEdge(word1,word2)
return g
Listing 1
因为这是我们的第一个真实世界图问题,你可能想知道图是如何稀疏?这个问题的四个字母的单词列表是 5,110 字
长。 如果我们使用邻接矩阵,则矩阵将具有 5,110 * 5,110 = 26,112,100 个格。 由 buildGraph 函数构造的图正好有
53,286 个边,所以矩阵只有 0.20% 的单元格填充! 这是一个非常稀疏的矩阵。
211
7.9.实现广度优先搜索
7.9.实现广度优先搜索
通过构建图,我们现在可以将注意力转向我们将使用的算法来找到字梯问题的最短解。我们将使用的图算法称为“宽度
优先搜索”算法。宽度优先搜索(BFS)是用于搜索图的最简单的算法之一。它也作为几个其他重要的图算法的原型,
我们将在以后研究。
为了跟踪进度,BFS 将每个顶点着色为白色,灰色或黑色。当它们被构造时,所有顶点被初始化为白色。白色顶点是未
发现的顶点。当一个顶点最初被发现时它变成灰色的,当 BFS 完全探索完一个顶点时,它被着色为黑色。这意味着一
旦顶点变黑色,就没有与它相邻的白色顶点。另一方面,灰色节点可能有与其相邻的一些白色顶点,表示仍有额外的顶
点要探索。
BFS 从起始顶点开始,颜色从灰色开始,表明它正在被探索。另外两个值,即距离和前导,对于起始顶点分别初始化为
0 和 None 。最后,放到一个队列中。下一步是开始系统地检查队列前面的顶点。我们通过迭代它的邻接表来探索队列
前面的每个新节点。当检查邻接表上的每个节点时,检查其颜色。如果它是白色的,顶点是未开发的,有四件事情发
生:
1. 新的,未开发的顶点 nbr,被着色为灰色。
2. nbr 的前导被设置为当前节点 currentVert
def bfs(g,start):
start.setDistance(0)
start.setPred(None)
vertQueue = Queue()
vertQueue.enqueue(start)
while (vertQueue.size() > 0):
currentVert = vertQueue.dequeue()
for nbr in currentVert.getConnections():
if (nbr.getColor() == 'white'):
nbr.setColor('gray')
nbr.setDistance(currentVert.getDistance() + 1)
nbr.setPred(currentVert)
vertQueue.enqueue(nbr)
currentVert.setColor('black')
Listing 2
212
7.9.实现广度优先搜索
Figure 3
Figure 4
213
7.9.实现广度优先搜索
Figure 5-6
214
7.9.实现广度优先搜索
def traverse(y):
x = y
while (x.getPred()):
print(x.getId())
x = x.getPred()
print(x.getId())
traverse(g.getVertex('sage'))
Listing 3
215
7.10.广度优先搜索分析
7.10.广度优先搜索分析
在继续使用其他图算法之前,让我们分析广度优先搜索算法的运行时性能。首先要观察的是,对于图中的每个顶点
|V| 最多执行一次 while 循环。因为一个顶点必须是白色,才能被检查和添加到队列。这给出了用于 while 循环的
O(v)。嵌套在 while 内部的 for 循环对于图中的每个边执行最多一次, |E| 。原因是每个顶点最多被出列一次,并且仅
当节点 u 出队时,我们才检查从节点 u 到节点 v 的边。这给出了用于 for 循环的 O(E) 。组合这两个环路给出了
O(V+E)。
当然做广度优先搜索只是任务的一部分。从起始节点到目标节点的链接之后是任务的另一部分。最糟糕的情况是,如果
图是单个长链。在这种情况下,遍历所有顶点将是 O(V)。正常情况将是 |V| 的一小部分但我们仍然写 O(V)。
216
7.11.骑士之旅
7.11.骑士之旅
另一个经典问题,我们可以用来说明第二个通用图算法称为 “骑士之旅”。骑士之旅图是在一个棋盘上用一个棋子当骑士
玩。图的目的是找到一系列的动作,让骑士访问板上的每格一次。一个这样的序列被称为“旅游”。骑士的旅游难题已经
吸引了象棋玩家,数学家和计算机科学家多年。一个 8×8 棋盘的可能的游览次数的上限为 1.305×10^35 ;然而,还有更
多可能的死胡同。显然,这是一个需要脑力,计算能力,或两者都需要的问题。
虽然研究人员已经研究了许多不同的算法来解决骑士的旅游问题,图搜索是最容易理解的程序之一。再次,我们将使用
两个主要步骤解决问题:
表示骑士在棋盘上作为图的动作。
使用图算法来查找长度为 rows×columns-1 的路径,其中图上的每个顶点都被访问一次。
217
7.12.构建骑士之旅图
7.12.构建骑士之旅图
为了将骑士的旅游问题表示为图,我们将使用以下两个点:棋盘上的每个正方形可以表示为图形中的一个节点。 骑士
的每个合法移动可以表示为图形中的边。 Figure 1 展示了骑士的移动以及图中的对应边。
Figure 1
def knightGraph(bdSize):
ktGraph = Graph()
for row in range(bdSize):
for col in range(bdSize):
nodeId = posToNodeId(row,col,bdSize)
newPositions = genLegalMoves(row,col,bdSize)
for e in newPositions:
nid = posToNodeId(e[0],e[1],bdSize)
ktGraph.addEdge(nodeId,nid)
return ktGraph
Listing 1
def genLegalMoves(x,y,bdSize):
newMoves = []
moveOffsets = [(-1,-2),(-1,2),(-2,-1),(-2,1),
( 1,-2),( 1,2),( 2,-1),( 2,1)]
for i in moveOffsets:
218
7.12.构建骑士之旅图
newX = x + i[0]
newY = y + i[1]
if legalCoord(newX,bdSize) and \
legalCoord(newY,bdSize):
newMoves.append((newX,newY))
return newMoves
def legalCoord(x,bdSize):
if x >= 0 and x < bdSize:
return True
else:
return False
Listing 2
Figure 2
219
7.13.实现骑士之旅
7.13.实现骑士之旅
我们将用来解决骑士旅游问题的搜索算法称为 深度优先搜索(DFS)。尽管在前面部分中讨论的广度优先搜索算法一
次建立一个搜索树,但是深度优先搜索通过尽可能深地探索树的一个分支来创建搜索树。在本节中,我们将介绍实现深
度优先搜索的两种算法。我们将看到的第一个算法通过明确地禁止一个节点被访问多次来直接解决骑士的旅行问题。第
二种实现是更通用的,但是允许在构建树时多次访问节点。第二个版本在后续部分中用于开发其他图形算法。
图的深度优先搜索正是我们需要的,来找到有 63 个边的路径。我们将看到,当深度优先搜索算法找到死角(图中没有
可移动的地方)时,它将回到下一个最深的顶点,允许它进行移动。
DFS 还使用颜色来跟踪图中的哪些顶点已被访问。未访问的顶点是白色的,访问的顶点是灰色的。如果已经探索了特
定顶点的所有邻居,并且我们尚未达到64个顶点的目标长度,我们已经到达死胡同。当我们到达死胡同时,我们必须回
溯。当我们从状态为 False 的 knightTour 返回时,发生回溯。在广度优先搜索中,我们使用一个队列来跟踪下一个要
访问的顶点。由于深度优先搜索是递归的,我们隐式使用一个栈来帮助我们回溯。当我们从第 11 行的状态为 False
的 knightTour 调用返回时,我们保持在 while 循环中,并查看 nbrList 中的下一个顶点。
Listing 3
220
7.13.实现骑士之旅
221
7.13.实现骑士之旅
222
7.13.实现骑士之旅
Figure 3-10
223
7.13.实现骑士之旅
Figure 10
224
7.14.骑士之旅分析
7.14.骑士之旅分析
有最后关于骑士之旅一个有趣的话题,然后我们将继续到深度优先搜索的通用版本。主题是性能。特别
是, knightTour 对于你选择下一个要访问的顶点的方法非常敏感。例如,在一个5乘5的板上,你可以在快速计算机上
处理路径花费大约1.5秒。但是如果你尝试一个 8×8 的板,会发生什么?在这种情况下,根据计算机的速度,你可能需
要等待半小时才能获得结果!这样做的原因是我们到目前为止所实现的骑士之旅问题是大小为 O(k^N ) 的指数算法,其
中 N 是棋盘上的方格数,k 是小常数。Figure 12 可以帮助我们搞清楚为什么会这样。树的根表示搜索的起点。从那
里,算法生成并检查骑士可以做出的每个可能的移动。正如我们之前注意到的,可能的移动次数取决于骑士在板上的位
置。在角落只有两个合法的动作,在角落邻近的正方形有三个,在板的中间有八个。Figure 13 展示了板上每个位置可
能的移动次数。在树的下一级,再次有 2 到 8 个可能的下一个移动。要检查的可能位置的数量对应于搜索树中的节点的
数量。
Figure 12-13
225
7.14.骑士之旅分析
幸运的是有一种方法来加速八乘八的情况,使其在一秒钟内运行完成。在下面的列表中,我们将展示加速 knightTour
使用具有最多可用移动的顶点作为路径上的下一个顶点的问题是,它倾向于让骑士在游览中早访问中间的方格。当这种
情况发生时,骑士很容易陷入板的一侧,在那里它不能到达在板的另一侧的未访问的方格。另一方面,访问具有最少可
用移动的方块首先推动骑士访问围绕板的边缘的方块。这确保了骑士能够尽早地访问难以到达的角落,并且只有在必要
时才使用中间的方块跳过棋盘。利用这种知识加速算法被称为启发式。人类每天都使用启发式来帮助做出决策,启发式
搜索通常用于人工智能领域。这个特定的启发式称为 Warnsdorff 算法,由 H. C. Warnsdorff 命名,他在 1823 年发
表了他的算法。
def orderByAvail(n):
resList = []
for v in n.getConnections():
if v.getColor() == 'white':
c = 0
for w in v.getConnections():
if w.getColor() == 'white':
c = c + 1
resList.append((c,v))
resList.sort(key=lambda x: x[0])
return [y[1] for y in resList]
Next Section - 7.15. General Depth First Search
226
7.15.通用深度优先搜索
7.15.通用深度优先搜索
骑士之旅是深度优先搜索的特殊情况,其目的是创建最深的第一棵树,没有任何分支。更一般的深度优先搜索实际上更
容易。它的目标是尽可能深的搜索,在图中连接尽可能多的节点,并在必要时创建分支。
甚至可能的是,深度优先搜索将创建多于一个树。当深度优先搜索算法创建一组树时,我们称之为深度优先森林。与广
度优先搜索一样,我们的深度优先搜索使用前导链接来构造树。此外,深度优先搜索将在顶点类中使用两个附加的实例
变量。新实例变量是发现和完成时间。发现时间跟踪首次遇到顶点之前的步骤数。完成时间是顶点着色为黑色之前的步
骤数。正如我们看到的算法,节点的发现和完成时间提供了一些有趣的属性,我们可以在以后的算法中使用。
def dfs(self):
for aVertex in self:
aVertex.setColor('white')
aVertex.setPred(-1)
for aVertex in self:
if aVertex.getColor() == 'white':
self.dfsvisit(aVertex)
def dfsvisit(self,startVertex):
startVertex.setColor('gray')
self.time += 1
startVertex.setDiscovery(self.time)
for nextVertex in startVertex.getConnections():
if nextVertex.getColor() == 'white':
nextVertex.setPred(startVertex)
self.dfsvisit(nextVertex)
startVertex.setColor('black')
self.time += 1
startVertex.setFinish(self.time)
Listing 5
以下图的序列展示了针对小图的深度优先搜索算法。在这些图中,虚线指示检查的边,但是在边的另一端的节点已经被
添加到深度优先树。在代码中,通过检查另一个节点的颜色是非白色的。
227
7.15.通用深度优先搜索
228
7.15.通用深度优先搜索
229
7.15.通用深度优先搜索
230
7.15.通用深度优先搜索
231
7.15.通用深度优先搜索
Figure 14-25
Figure 26
232
7.16.深度优先搜索分析
7.16.深度优先搜索分析
深度优先搜索的一般运行时间如下。 dfs 中的循环都在 O(V) 中运行,不计入 dfsvisit 中发生的情况,因为它们对图
中的每个顶点执行一次。 在 dfsvisit 中,对当前顶点的邻接表中的每个边执行一次循环。 由于只有当顶点为白色
时, dfsvisit 才被递归调用,所以循环对图中的每个边或 O(E) 执行最多一次。 因此,深度优先搜索的总时间是 O(V
+ E)。
233
7.17.拓扑排序
7.17.拓扑排序
为了表明计算机科学家可以把任何东西变成一个图问题,让我们考虑做一批煎饼的问题。 菜谱真的很简单:1个鸡蛋,
1杯煎饼粉,1汤匙油 和 3/4 杯牛奶。 要制作煎饼,你必须加热炉子,将所有的成分混合在一起,勺子搅拌。 当开始冒
泡,你把它们翻过来,直到他们底部变金黄色。 在你吃煎饼之前,你会想要加热一些糖浆。 Figure 27将该过程示为
图。
Figure 27
拓扑排序是深度优先搜索的简单但有用的改造。拓扑排序的算法如下:
1. 对于某些图 g 调用 dfs(g)。我们想要调用深度优先搜索的主要原因是计算每个顶点的完成时间。
2. 以完成时间的递减顺序将顶点存储在列表中。
3. 返回有序列表作为拓扑排序的结果。
234
7.17.拓扑排序
Figure 28
Figure 29
235
7.18.强连通分量
7.18.强连通分量
在本章的剩余部分,我们将把注意力转向一些非常大的图。我们将用来研究一些附加算法的图,由互联网上的主机之间
的连接和网页之间的链接产生的图。 我们将从网页开始。
Figure 30
可以帮助找到图中高度互连的顶点的集群的一种图算法被称为强连通分量算法(SCC)。我们正式定义图 G 的强连通
分量 C 作为顶点 C⊂V 的最大子集,使得对于每对顶点 v,w∈C,我们具有从 v 到 w 的路径和从 w 到 v 的路径。Figure
27 展示了具有三个强连接分量的简单图。强连接分量由不同的阴影区域标识。
236
7.18.强连通分量
Figure 27
一旦确定了强连通分量,我们就可以通过将一个强连通分量中的所有顶点组合成一个较大的顶点来显示该图的简化视
图。 Figure 31中的曲线图的简化版本如 Figure 32所示。
Figure 32
237
7.18.强连通分量
Figure 33-34
我们现在可以描述用于计算图的强连通分量的算法。
1. 调用 dfs 为图 G 计算每个顶点的完成时间。
2. 计算 G^T 。
3. 为图 G^T 调用 dfs,但在 DFS 的主循环中,以完成时间的递减顺序探查每个顶点。
4. 在步骤 3 中计算的森林中的每个树是强连通分量。输出森林中每个树中每个顶点的顶点标识组件。
238
7.18.强连通分量
Figure 36
239
7.18.强连通分量
Figure 37
240
7.19.最短路径问题
7.19.最短路径问题
当你在网上冲浪,发送电子邮件,或从校园的另一个地方登录实验室计算机时,大量的工作正在幕后进行,以获取你计
算机上的信息传输到另一台计算机。 深入研究信息如何通过互联网从一台计算机流向另一台计算机是计算机网络中的
一个主要课题。 然而,我们将讨论互联网如何工作足以理解另一个非常重要的图算法。
Figure 1
1 192.203.196.1
2 hilda.luther.edu (216.159.75.1)
3 ICN-Luther-Ether.icn.state.ia.us (207.165.237.137)
4 ICN-ISP-1.icn.state.ia.us (209.56.255.1)
5 p3-0.hsa1.chi1.bbnplanet.net (4.24.202.13)
6 ae-1-54.bbr2.Chicago1.Level3.net (4.68.101.97)
7 so-3-0-0.mpls2.Minneapolis1.Level3.net (64.159.4.214)
8 ge-3-0.hsa2.Minneapolis1.Level3.net (4.68.112.18)
9 p1-0.minnesota.bbnplanet.net (4.24.226.74)
10 TelecomB-BR-01-V4002.ggnet.umn.edu (192.42.152.37)
11 TelecomB-BN-01-Vlan-3000.ggnet.umn.edu (128.101.58.1)
12 TelecomB-CN-01-Vlan-710.ggnet.umn.edu (128.101.80.158)
13 baldrick.cs.umn.edu (128.101.80.129)(N!) 88.631 ms (N!)
241
7.19.最短路径问题
Figure 2
Figure 2 展示了表示互联网中的路由器的互连的加权图的一个小例子。我们要解决的问题是找到具有最小总权重的路
径,沿着该路径路由传送任何给定的消息。这个问题听起来很熟悉,因为它类似于我们使用广度优先搜索解决的问题,
我们这里关心的是路径的总权重,而不是路径中的跳数。应当注意,如果所有权重相等,则问题是相同的。
242
7.20.Dijkstra算法
7.20.Dijkstra算法
我们将用于确定最短路径的算法称为“Dijkstra算法”。Dijkstra算法是一种迭代算法,它为我们提供从一个特定起始节点
到图中所有其他节点的最短路径。这也类似于广度优先搜索的结果。
Listing 1
Dijkstra的算法使用优先级队列。你可能还记得,优先级队列是基于我们在树章节中实现的堆。这个简单的实现和我们
用于Dijkstra算法的实现之间有几个区别。首先,PriorityQueue 类存储键值对的元组。这对于Dijkstra的算法很重要,因
为优先级队列中的键必须匹配图中顶点的键。其次,值用于确定优先级,并且用于确定键在优先级队列中的位置。在这
个实现中,我们使用到顶点的距离作为优先级,因为我们看到当探索下一个顶点时,我们总是要探索具有最小距离的顶
点。第二个区别是增加 decreaseKey 方法。正如你看到的,当一个已经在队列中的顶点的距离减小时,使用这个方
法,将该顶点移动到队列的前面。
243
7.20.Dijkstra算法
244
7.20.Dijkstra算法
245
7.20.Dijkstra算法
246
7.20.Dijkstra算法
重要的是要注意,Dijkstra的算法只有当权重都是正数时才起作用。 如果你在图的边引入一个负权重,算法永远不会退
出。
247
7.21.Dijkstra算法分析
7.21.Dijkstra算法分析
最后,让我们看看 Dijkstra 算法的运行时间。我们首先注意到,构建优先级队列需要 O(v) 时间,因为我们最初将图中
的每个顶点添加到优先级队列。 一旦构造了队列,则对于每个顶点执行一次 while 循环,因为顶点都在开始处添加,并
且在那之后才被移除。 在该循环中每次调用 delMin,需要 O(log^V )时间。 将该部分循环和对 delMin 的调用取为
O(Vlog^V )。 for 循环对于图中的每个边执行一次,并且在 for 循环中,对 decreaseKey 的调用需要时间O(Elog^V) 。
因此,组合运行时间为 O((V + E)log^V )。
248
7.22.Prim生成树算法
7.22.Prim生成树算法
对于我们最后的图算法,让我们考虑一个在线游戏设计师和网络收音机提供商面临的问题。 问题是他们想有效地将一
条信息传递给任何人和每个可能在听的人。 这在游戏中是重要的,使得所有玩家知道每个其他玩家的最新位置。 对于
网络收音机是重要的,以便所有该调频的收听者获得他们需要的所有数据来刷新他们正在收听的歌曲。 Figure 9 说明了
广播问题。
Figure 9
这个问题有一些强力的解决方案,所以先看看他们如何更好地理解广播问题。这也将帮助你理解我们最后提出的解决方
案。首先,广播主机有一些收听者都需要接收的信息。最简单的解决方案是广播主机保存所有收听者的列表并向每个收
听者发送单独的消息。在 Figure 9中,我们展示了有广播公司和一些收听者的小型网络。使用第一种方法,将发送每个
消息的四个副本。假设使用最小成本路径,让我们看看每个路由器处理同一消息的次数。
暴力解决方案是广播主机发送广播消息的单个副本,并让路由器整理出来。在这种情况下,最简单的解决方案是称为
不受控泛洪 的策略。洪水策略工作如下。每个消息开始于将存活时间(ttl)值设置为大于或等于广播主机与其最远听者
之间的边数量的某个数。每个路由器获得消息的副本,并将消息传递到其所有相邻路由器。当消息传递到 ttl 减少。每
个路由器继续向其所有邻居发送消息的副本,直到 ttl 值达到 0。不受控制的洪泛比我们的第一个策略产生更多的不必要
的消息。
249
7.22.Prim生成树算法
Figure 10 展示了广播图的简化版本并突出了生成图的最小生成树的边。现在为了解决我们的广播问题,广播主机简单
地将广播消息的单个副本发送到网络中。每个路由器将消息转发到作为生成树的一部分邻居,排除刚刚向其发送消息的
邻居。在这个例子中 A 将消息转发到 B,B 将消息转发到 D 和 C。D 将消息转发到 E,E将它转发到 F,F 转发到 G。
没有路由器看到任何消息的多个副本,所有感兴趣的收听者都会看到消息的副本。
Figure 10
构建生成树的基本思想如下:
诀窍是指导我们 “找到一个安全的边”。我们定义一个安全边作为将生成树中的顶点连接到不在生成树中的顶点的任何
边。这确保树将始终保持为树并且没有循环。
def prim(G,start):
pq = PriorityQueue()
for v in G:
v.setDistance(sys.maxsize)
v.setPred(None)
start.setDistance(0)
pq.buildHeap([(v.getDistance(),v) for v in G])
while not pq.isEmpty():
currentVert = pq.delMin()
for nextVert in currentVert.getConnections():
newCost = currentVert.getWeight(nextVert)
250
7.22.Prim生成树算法
Listing 2
251
7.22.Prim生成树算法
252
7.22.Prim生成树算法
253
7.22.Prim生成树算法
254
7.22.Prim生成树算法
255
7.23.总结
7.23.总结
在本章中,我们讨论了图抽象数据类型,以及图的一些实现。 图使我们能够解决许多问题,只要我们可以将原始问题
转换为可以由图表示的东西。 特别是,我们已经看到,图有助于解决以下领域的问题。
广度优先搜索找到未加权的最短路径。
Dijkstra的加权最短路径算法。
深度优先搜索图探索。
强连通分量,用于简化图。
排序任务的拓扑排序。
广播消息的最小权重生成树。
256