基本结构(下)

655 阅读18分钟

数据结构的基本结构,内容包括队列抽象数据类型、热土豆、打印任务、双端队列抽象数据类型、无需表抽象数据类型、无序表的链式表现、有序表抽象数据类型、线性结构小结。

一、队列抽象数据类型

1. 队列Queue:什么是队列?

队列是一种有次序的数据集合,其特征是新数据项的添加总发生在一端(通常称为“尾rear”端),而现存数据项的移除总发生在另一端(通常称为“首front”端)

当数据项加入队列,首先出现在队尾,随着队首数据项的移除,它逐渐接近队首。

新加入的数据项必须在数据集末尾等待,而等待时间最长的数据项则是队首这种次序安排的原则称为(FIFO:First-infirst-out)先进先出或“先到先服务first-come first-served”

队列的例子出现在我们日常生活的方方面面:排队

队列仅有一个入口和一个出口,不允许数据项直接插入队中,也不允许从中间移除数据项

2. 计算机科学中队列的例子:打印队列

一台打印机面向多个用户/程序提供服务

  • 打印速度比打印请求提交的速度要慢得多
  • 有任务正在打印时,后来的打印请求就要排成队列,以FIFO的形式等待被处理。

3. 计算机科学中队列的例子:进程调度

操作系统核心采用多个队列来对系统中同时运行的进程进行调度

  • 进程数远多于CPU核心数
  • 有些进程还要等待不同类型I/O事件

调度原则综合了“先到先服务”及“资源充分利用”两个出发点

4. 计算机科学中队列的例子:键盘缓冲

键盘敲击并不马上显示在屏幕上

  • 需要有个队列性质的缓冲区,将尚未显示的敲击字符暂存其中,
  • 队列的先进先出性质则保证了字符的输入和显示次序一致性。

5. 抽象数据类型Queue

抽象数据类型Queue是一个有次序的数据集合,数据项仅添加到“尾rear”端,而且仅从“首front”端移除,Queue具有FIFO的操作次序

抽象数据类型Queue由如下操作定义:

  1. Queue():创建一个空队列对象,返回值为Queue对象;
  2. enqueue(item):将数据项item添加到队尾,无返回值;
  3. dequeue():从队首移除数据项,返回值为队首数据项,队列被修改;
  4. isEmpty():测试是否空队列,返回值为布尔值
  5. size():返回队列中数据项的个数。

6. Python实现ADT Queue

二、队列的应用:热土豆

1. 热土豆问题(约瑟夫问题)

“击鼓传花”的土豆版本,传烫手的热土豆,鼓声停的时候,手里有土豆的小孩就要出列。

如果去掉鼓,改为传过固定人数,就成了“现代版”的约瑟夫问题。传说犹太人反叛罗马人,落到困境,约瑟夫和39人决定殉难,坐成一圈儿,报数1~7,报到7的人由旁边杀死,结果约瑟夫给自己安排了个位置,最后活了下来……

2. 热土豆问题:算法

用队列来实现热土豆问题的算法,参加游戏的人名列表,以及传土豆次数num,算法返回最后剩下的人名

模拟程序采用队列来存放所有参加游戏的人名,按照传递土豆方向从队首排到队尾

  • 游戏时,队首始终是持有土豆的人 模拟游戏开始,只需要将队首的人出队,随即再到队尾入队,算是土豆的一次传递
  • 传递了num次后,将队首的人移除,不再入队
  • 如此反复,直到队列中剩余1人

3. 热土豆问题:代码

三、队列的应用:打印任务

1. 模拟算法:打印任务

多人共享一台打印机,采取“先到先服务”的队列策略来执行打印任务

在这种设定下,一个首要的问题就是:

  • 这种打印作业系统的容量有多大?
  • 在能够接受的等待时间内,系统能容纳多少用户
  • 以多高频率提交多少打印任务?

一个具体的实例配置如下:

  • 一个实验室,在任意的一个小时内,大约有10名学生在场,
  • 这一小时中,每人会发起2次左右的打印,每次1~20页

打印机的性能是:

  • 以草稿模式打印的话,每分钟10页,
  • 以正常模式打印的话,打印质量好,但速度下降为每分钟5页。

问题是:怎么设定打印机的模式,让大家都不会等太久的前提下尽量提高打印质量?

这是一个典型的决策支持问题,但无法通过规则直接计算

我们要用一段程序来模拟这种打印任务场景,然后对程序运行结果进行分析,以支持对打印机模式设定的决策。

2. 如何对问题建模?

首先对问题进行抽象,确定相关的对象和过程

  • 抛弃那些对问题实质没有关系的学生性别、年龄、打印机型号、打印内容、纸张大小等等众多细节

对象:打印任务、打印队列、打印机

  • 打印任务的属性:提交时间、打印页数
  • 打印队列的属性:具有FIFO性质的打印任务队列
  • 打印机的属性:打印速度、是否忙

过程:生成和提交打印任务

  • 确定生成概率:实例为每小时会有10个学生提交的20个作业,这样,概率是每180秒会有1个作业生成并提交,概率为每秒1/180。
  • 确定打印页数:实例是1~20页,那么就是1~20页之间概率相同。

过程:实施打印

  • 当前的打印作业:正在打印的作业
  • 打印结束倒计时:新作业开始打印时开始倒计时,回0表示打印完毕,可以处理下一个作业

模拟时间:

  • 统一的时间框架:以最小单位(秒)均匀流逝的时间,设定结束时间
  • 同步所有过程:在一个时间单位里,对生成打印任务和实施打印两个过程各处理一次

3. 打印任务问题:模拟流程

创建打印队列对象

时间按照秒的单位流逝

  • 按照概率生成打印作业,加入打印队列
  • 如果打印机空闲,且队列不空,则取出队首作业打印,记录此作业等待时间
  • 如果打印机忙,则按照打印速度进行1秒打印
  • 如果当前作业打印完成,则打印机进入空闲

时间用尽,开始统计平均等待时间

作业的等待时间

  • 生成作业时,记录生成的时间戳
  • 开始打印时,当前时间减去生成时间即可

作业的打印时间

  • 生成作业时,记录作业的页数
  • 开始打印时,页数除以打印速度即可

4. 打印任务问题:Python代码

5. 打印任务问题:运行和分析1

按5PPM、1小时的设定,模拟运行10次

  • 总平均等待时间93.1秒,最长的平均等待164秒,最短的平均等待26秒
  • 有3次模拟,还有作业没开始打印

6. 打印任务问题:运行和分析2

提升打印速度到10PPM、1小时的设定

  • 总平均等待时间12秒,最长的平均等待35秒,最短的平均等待0秒,就是一提交就打印了
  • 而且,所有作业都打印了

7. 打印任务问题:讨论

为了对打印模式设置进行决策,我们用模拟程序来评估任务等待时间

  • 通过两种情况模拟仿真结果的分析,我们认识到
  • 如果有那么多学生要拿着打印好的程序源代码赶去上课的话
  • 那么,必须得牺牲打印质量,提高打印速度。

模拟系统对现实的仿真

  • 在不耗费现实资源的情况下——有时候真实的实验是无法进行的
  • 可以以不同的设定,反复多次模拟来帮助我们进行决策。

打印任务模拟程序还可以加进不同设定,来进行更丰富的模拟

  • 学生数量加倍了会怎么样?
  • 如果在周末,学生不需要赶去上课,能接受更长等待时间,会怎么样?
  • 如果改用Python编程,源代码大大减少,打印的页数减少了,会怎么样?

更真实的模拟,来源于对问题的更精细建模,以及以真实数据进行设定和运行也可以扩展到其它类似决策支持问题

  • 如:饭馆的餐桌设置,使得顾客排队时间变短

四、双端队列抽象数据类型

1. 双端队列Deque:什么是Deque?

双端队列Deque是一种有次序的数据集。

跟队列相似,其两端可以称作“首”“尾”端,但deque中数据项既可以从队首加入,也可以从队尾加入;数据项也可以从两端移除。某种意义上说,双端队列集成了栈和队列的能力

但双端队列并不具有内在的LIFO或者FIFO特性

  • 如果用双端队列来模拟栈或队列
  • 需要由使用者自行维护操作的一致性

2. 抽象数据类型Deque

deque定义的操作如下:

  • Deque():创建一个空双端队列
  • addFront(item):将item加入队首
  • addRear(item):将item加入队尾
  • removeFront():从队首移除数据项,返回值为移除的数据项
  • removeRear():从队尾移除数据项,返回值为移除的数据项
  • isEmpty():返回deque是否为空
  • size():返回deque中包含数据项的个数

3. Python实现ADT Deque

4. “回文词”判定

“回文词”指正读和反读都一样的词

  • 如radar、madam、toot
  • 中文“上海自来水来自海上”,“山东落花生花落东山”

用双端队列很容易解决“回文词”问题

  • 先将需要判定的词从队尾加入deque
  • 再从两端同时移除字符判定是否相同,直到deque中剩下0个或1个字符

5. “回文词”判定:代码

五、无序表抽象数据类型

1. 列表List:什么是列表?

在前面基本数据结构的讨论中,我们采用Python List来实现了多种线性数据结构

列表List是一种简单强大的数据集结构,提供了丰富的操作接口,但并不是所有的编程语言都提供了List数据类型,有时候需要程序员自己实现。

列表是一种数据项按照相对位置存放的数据集

  • 特别的,被称为“无序表unordered list”

  • 其中数据项只按照存放位置来索引,如第1个、第2个……、最后一个等。(为了简单起见,假设表中不存在重复数据项)

  • 如一个考试分数的集合“54, 26, 93, 17,77和31”

  • 如果用无序表来表示,就是[54, 26, 93,17, 77, 31]

2. 抽象数据类型:无序表List

无序表List的操作如下:

  1. List():创建一个空列表
  2. add(item):添加一个数据项到列表中,假设item原先不存在于列表中
  3. remove(item):从列表中移除item,列表被修改,item原先应存在于表中
  4. search(item):在列表中查找item,返回布尔类型值
  5. isEmpty():返回列表是否为空
  6. size():返回列表包含了多少数据项
  7. append(item):添加一个数据项到表末尾,假设item原先不存在于列表中
  8. index(item):返回数据项在表中的位置
  9. insert(pos, item):将数据项插入到位置pos,假设item原先不存在与列表中,同时原列表具有足够多个数据项,能让item占据位置pos
  10. pop():从列表末尾移除数据项,假设原列表至少有1个数据项
  11. pop(pos):移除位置为pos的数据项,假设原列表存在位置pos

3. 采用链表实现无序表

为了实现无序表数据结构,可以采用链接表的方案。

虽然列表数据结构要求保持数据项的前后相对位置,但这种前后位置的保持,并不要求数据项依次存放在连续的存储空间

如下图,数据项存放位置并没有规则,但如果在数据项之间建立链接指向,就可以保持其前后相对位置

第一个和最后一个数据项需要显式标记出来,一个是队首,一个是队尾,后面再无数据了。

4. 链表实现:节点Node

链表实现的最基本元素是节点Node

  • 每个节点至少要包含2个信息:数据项本身,以及指向下一个节点的引用信息
  • 注意next为None的意义是没有下一个节点了,这个很重要

5. 链表实现:无序表UnorderedList

可以采用链接节点的方式构建数据集来实现无序表

链表的第一个和最后一个节点最重要

  • 如果想访问到链表中的所有节点,就必须从第一个节点开始沿着链接遍历下去

所以无序表必须要有对第一个节点的引用信息

  • 设立一个属性head,保存对第一个节点的引用空表的head为None

随着数据项的加入,无序表的head始终指向链条中的第一个节点

  • 注意!无序表mylist对象本身并不包含数据项(数据项在节点中)
  • 其中包含的head只是对首个节点Node的引用
  • 判断空表的isEmpty()很容易实现
return self.head == None

六、无序表的链式表现

1. 链表实现:无序表UnorderedList

接下来,考虑如何实现向无序表中添加数据项,实现add方法

  • 由于无序表并没有限定数据项之间的顺序
  • 新数据项可以加入到原表的任何位置
  • 按照实现的性能考虑,应添加到最容易加入的位置上。

由链表结构我们知道:要访问到整条链上的所有数据项,都必须从表头head开始沿着next链接逐个向后查找。所以添加新数据项最快捷的位置是表头, 整个链表的首位置。

2. 链表实现:add方法实现

链接次序很重要!

3. 链表实现:size

size:从链条头head开始遍历到表尾同时用变量累加经过的节点个数。

4. 链表实现:search

从链表头head开始遍历到表尾,同时判断当前节点的数据项是否目标

5. 链表实现:remove(item)方法

首先要找到item,这个过程跟search一样,但在删除节点时,需要特别的技巧

  • current指向的是当前匹配数据项的节点
  • 而删除需要把前一个节点的next指向current的下一个节点
  • 所以我们在search current的同时,还要维护前一个(previous)节点的引用

找到item之后,current指向item节点,previous指向前一个节点,开始执行删除,需要区分两种情况:

  • current是首个节点;
  • 或者是位于链条中间的节

6. 链表实现:remove(item)代码

七、有序表抽象数据类型

1. 抽象数据类型:有序表OrderedList

有序表是一种数据项依照其某可比性质(如整数大小、字母表先后)来决定在列表中的位置

越“小”的数据项越靠近列表的头,越靠“前”

OrderedList所定义的操作如下:

  1. OrderedList():创建一个空的有序表
  2. add(item):在表中添加一个数据项,并保持整体顺序,此项原不存在
  3. remove(item):从有序表中移除一个数据项,此项应存在,有序表被修改
  4. search(item):在有序表中查找数据项,返回是否存在
  5. isEmpty():是否空表
  6. size():返回表中数据项的个数
  7. index(item):返回数据项在表中的位置,此项应存在
  8. pop():移除并返回有序表中最后一项,表中应至少存在一项
  9. pop(pos):移除并返回有序表中指定位置的数据项,此位1. 置应存在

2. 有序表OrderedList实现

在实现有序表的时候,需要记住的是,数据项的相对位置,取决于它们之间的“大小”比较

由于Python的扩展性,下面对数据项的讨论并不仅适用于整数,可适用于所有定义了__gt__方法(即'>'操作符)的数据类型

以整数数据项为例,(17, 26, 31, 54, 77,93)的链表形式如图

同样采用链表方法实现

  • Node定义相同
  • OrderedList也设置一个head来保存链表表头的引用

对于isEmpty/size/remove这些方法,与节点的次序无关,所以其实现跟UnorderedList是一样的。

search/add方法则需要有修改

3. 有序表实现:search方法

无序表的search中,如果需要查找的数据项不存在,则会搜遍整个链表,直到表尾

对于有序表来说,可以利用链表节点有序排列的特性,来为search节省不存在数据项的查找时间。一旦当前节点的数据项大于所要查找的数据项,则说明链表后面已经不可能再有要查找的数据项,可以直接返回False

如我们要在下图查找数据项45

4. 有序表实现:add方法

相比无序表,改变最大的方法是add,因为add方法必须保证加入的数据项添加在合适的位置,以维护整个链表的有序性

比如在(17, 26, 54, 77, 93)的有序表中,加入数据项31,我们需要沿着链表,找到第一个比 31大的数据项54,将31插入到54的前面

由于涉及到的插入位置是当前节点之前,而链表无法得到“前驱”节点的引用,所以要跟remove方法类似,引入一个previous的引用,跟随当前节点current,一旦找到首个比31大的数据项,previous就派上用场了

5. 有序表OrderedList实现:add方法

6. 链表实现的算法分析

对于链表复杂度的分析,主要是看相应的方法是否涉及到链表的遍历

对于一个包含节点数为n的链表

  • isEmpty是O(1),因为仅需要检查head是否为None
  • size是O(n),因为除了遍历到表尾,没有其它办法得知节点的数量
  • search/remove以及有序表的add方法,则是O(n),因为涉及到链表的遍历,按照概率其平均操作的次数是n/2
  • 无序表的add方法是O(1),因为仅需要插入到表头

链表实现的List,跟Python内置的列表数据类型,在有些相同方法的实现上的时间复杂度不同,主要是因为Python内置的列表数据类型是基于顺序存储来实现的,并进行了优化。

八、线性结构小结

  1. 书写表达式的方法有前缀prefix、中缀infix和后缀postfix三种
    • 由于栈结构具有次序反转的特性,所以栈结构适合用于开发表达式求值和转换的算法
  2. 模拟系统”可以通过一个对现实世界问题进行抽象建模,并加入随机数动态运行,为复杂问题的决策提供各种情况的参考
    • 队列queue可以用来进行模拟系统的开发
  3. 双端队列Deque可以同时具备栈和队列的功能
    • deque的主要操作包括addFront, addRear, removeFront, removeRear, isEmpty
  4. 列表List是数据项能够维持相对位置的数据集
  5. 链表的实现,可以保持列表维持相对位置的特点,而不需要连续的存储空间
  6. 链表实现时,其各种方法,对链表头部head需要特别的处理

「资料来源:数据结构与算法Python-陈斌」