线性结构(Linear Structure)
- 视频链接:https://www.bilibili.com/video/BV1VC4y1x7uv
- 章节:P14-P28
概念
线性结构是一种有序数据项的集合,其中每个数据项都有唯一的前驱和后继,
第一个没有前驱,最后一个没有后继。
新的数据项加入到数据集中时,只会加入到原有某个数据项之前或之后,具有这种性质的数据集,就称为线性结构。
-
两端
线性结构有两端,在不同的情况下,两端的称呼也不同,有时候称为“左” “右”端、“前” “后”端、
“顶” “底”端。
-
数据增减
不同线性结构的关键区别在于数据项增减的方式,有的结构只允许数据项从一端添加,而有的结构则允许数据项从两端移除。
-
常用数据结构
以不同数据项增减的方式进行区分,常用的线性数据结构有:栈Stack,队列Queue,双端队列Deque 和列表List。
生活中的栈结构,只能从一端进行增减;队列,从不同的两端进出。
栈(stack)
一种有次序的数据项集合,在栈中,数据项的加入和移除都仅发生在同一端,这一端叫栈 “顶top”,另一端叫栈 “底base”。
距离栈底越近的数据,留在栈中的时间就越长,而最新加入栈的数据项会被最先移除,这种次序通常称为“后进先出LIFO”:‘Last in First out’(逆序)。
这是一种基于数据保存时间的次序,时间越短的离栈顶越近,而时间越长的离栈低越近。
-
实用示例
浏览器的“后退back”按钮,最先back的是最近访问的网页。
Word的“Undo”按钮,最先撤销的是最近操作。
-
定义
抽象数据类型“栈”是一个有次序的数据集,每个数据项仅从“栈顶”一端加入到数据集中、从数据集中移除,栈具有后进先出LIFO的特性。
-
常用操作
Stack()
:创建一个空栈,不包含任何数据项。push(item)
:将item
加入栈顶,无返回值。pop()
:将栈顶数据项移除,并返回,栈被修改。peek()
:“窥视”栈顶数据项,返回栈顶的数据项但不移除,栈不被修改。is_empty()
:返回栈是否为空栈。size()
:返回栈中有多少个数据项。
-
python实现
使用python自有的
List
实现栈结构,使用List
的末端(index=-1
作为栈顶的话,可以直接使用List
的append
和pop
方法来实现栈的数据增减。
class Stack:
def __init__(self):
self.items = []
def is_empty(self):
return self.items == []
def push(self, item):
self.items.append(item)
def pop(self):
return self.items.pop()
def peek(self):
return self.items[-1]
def size(self):
return len(self.items)
-
换一种实现方式
假设把
List
的另一端(首端index=0)
作为Stack的桟顶,也可以实现Stack。 -
算法分析
两种实现方式性能有所不同,
列表首端的版本,其
push/pop
的复杂度为0(n)
,list.insert(0)
、list.pop(0)
的复杂度。列表尾端的实现 ,其
push/pop
的复杂度为0(1)
,list.append()
、list.pop()
的复杂度。所以使用列表尾端实现方式性能更优。
栈的应用:简单括号匹配
-
括号特性
括号的使用必遵循“平衡”规制, 每个开(左)括号要恰好对一个闭(右)括号,每对开括号要正确的嵌套 。
-
思路
从左到右扫描括号串,最新(后)打开的左括号,应该匹配最先遇到的右括号,
第一个左括号(最早打开),匹配最后一个右括号(最后遇到)。
这种次序反转的识别,正好符合栈的特性。
-
操作流程
-
遍历字符串,
-
如果是左括号,压栈,
-
如果是右括号,判断栈是否为空,
-
为空,失败
-
不为空,出栈
-
-
-
-
字符串遍历结束后,判断栈是否为空,
- 为空,通过
- 不为空,失败 -
代码实现
str1 = "((())))" bracket_left = '(' bracket_right = ')' ts = Stack() flag = False # 遍历 for i, s in enumerate(str1): # 左括号 if s == bracket_left: ts.push(s) # 右括号 if s == bracket_right: if ts.is_empty(): print(str1[0:i + 1]) break ts.pop() else: # 遍历结束 if ts.is_empty(): # 栈为空 flag = True else: print(ts.items) print(flag)
-
延伸-通用括号匹配
字符串中包含多种括号"{} [] ()",需要注意不同括号之间的开闭情况。
代码优化:
- 碰到各种左括号仍然入栈;
- 碰到各种右括号的时候需要判断栈顶的左括号是否跟右括号为同一种类。
str1 = "[(({()})))]" bracket_left = '({[' bracket_right = ')}]' ts = Stack() flag = False # 遍历 for i, s in enumerate(str1): # 左括号 if s in bracket_left: ts.push(s) # 右括号 if s in bracket_right: if ts.is_empty(): print(str1[0:i + 1]) break t = ts.pop() # 左括号和右括号类型不对应 if bracket_left.index(t) != bracket_right.index(s) break else: # 遍历结束 if ts.is_empty(): # 栈为空 flag = True else: print(ts.items) print(flag)
-
成对(开闭)出现的符号匹配
HTML/XML文档也有类似于括号的开闭标记,这种层次结构化文档的校验、操作也可以通过栈来实现。
栈的应用:进制转换
进制就是用多少个字符来表示整数,十进制是0-9这十个数字字符,二进制是0、1两 个字符。
数字的排列原理是多少数字的几次幂
-
十进制转换为2进制
十进制转换为二进制,采用的是“除以2求余数”的算法,将整数不断除以2,每次得到的余数就是由低到高的二进制位。
“除以2”的过程,得到的余数是从低到高的次序,而输出则是从高到低,所以需要一个栈来反转次序。
-
代码实现
def int2bin(int_n): stack1 = Stack() # 被除数不为1 while int_n != 1: # 余数入栈 stack1.push(int_n % 2) # 商位下次的被除数 int_n = int_n // 2 # 最后的余数必为1,入栈 stack1.push(1) bin1 = '' # 拼接2进制,先入后出的原则 while not stack1.is_empty(): bin1 += str(stack1.pop()) return bin1 print(int2bin(8))
-
延伸至10进制转成任意进制
十进制转换为二进制的算法,可以扩展为转换到任意N进制,只需要将“除以2求余数”算法改为“除以N求余 数”算法。
def int2other(int_n, base): # 最大实现16进制 base_map = "0123456789abcdef" stack1 = Stack() while int_n >= base: # 根据余数大小转成位置的字符 stack1.push(base_map[int_n % base]) int_n = int_n // base stack1.push(int_n) base_other = '' while not stack1.is_empty(): base_other += str(stack1.pop()) return base_other print(int2other(15, 16))
-
栈的应用:表达式转换
-
中缀表达式
通常看到的表达式像这样:
B*C
,很容易知道这是B乘以C。这种操作符(operator)介于操作数( operand)中间的表示法,称为“中綴” 表示法。
问题:有时候中缀表示法会引起混淆,如
A+B*C
,是A+B
然后再乗以C ,还是B*C
然后再去加A?优先级:规定高优先级的操作符先计算,相同优先级的操作符从左到右依次计算,这样
A+B*C
就没有疑义是A加上B与C的乘积。括号:表示强制优先级,括号的优先级最高,而且在嵌套的括号中,内层的优先级更高。
这样(A+B)*C
就是A与B的和再乘以C。全括号表达式:在所有的表达式项两边都加上括号,
A+B*C+D
,表示为((A+(B*C))+D)
。 -
前缀和后缀
前缀:将操作符移到前面,变为
+AB
;后缀:将操作符移到最后,变为AB+
;
以操作符相对于操作数的位置来定义。在中缀表达式里必须的括号,在前缀和后缀表达式中消失了,在前缀和后缀表达式中,操作符的次序完全决定了运算的次序,不再有混淆,所以在很多情况下,表达式的计算机表示都避免用复杂的中缀形式。
-
其他示例
-
中缀表达式转换为前缀和后缀形式
为了分解算法的复杂度,从“全括号 ”中缀表达式入手,将A+B*C
->(A+(B*C))
,显式表达了计算次序
作符和操作数。- 运算符替换右括号
子表达式
(B*C)
,如果把操作符*
移到右括号的位置,替代它,再删去左括号,得到BC*
,这个正好把子表达式转换为后缀形式,再把更多的操作符移动到相应的右括号处替代之,再删去左括号,就完成了到后缀表达式的转换。-
运算符替换左括号
如果我们把操作符移动到左括号的位置替代之,然后删掉所有的右括号,也就得到了前缀表达式。
无论表达式多复杂,需要转换成前缀或者后缀,只需要两个步骤:
- 将中缀表达式转换为全括号形式;
- 将所有的操作符移动到子表达式所在的左括号( 前缀)或者右括号(后缀)处,替代之,再删除所有的括号。
-
通用的中缀转后缀算法
-
过程分析
-
首先看中缀表达式
A+B*C
,其对应的后缀表达式是ABC*+
,操作数ABC的顺序没有改变。
操作符的出现顺序,在后缀表达式中反转了,由于*
的优先级比+
高,所以后缀表达式中操作符的出现顺序与运算次序一致。操作数顺序不变,使用队列保存,操作符顺序反转就可能利用栈的特性
-
转换过程中,操作符比操作数要晚输出(
A+B->AB+
),所以在扫描到对应的第二个操作数之前,需要把操作符先保存起来,而这些暂存的操作符,由于优先级的规则 ,可能要反转次序输出。在
A+B*C
中,+
虽然先出现,但优先级比后面这个*
要低,所以它要等*
处理完后,才能再处理。(A+B*C->ABC*+
)这种反转特性,使得我们考虑用栈来保存暂时未处理的操作符。 -
再看看
(A+B)*C-> AB+C*
,这里+
的输出比*
要早,是因为括号使得+
的优先级高于括号外的*
,回顾的“全括号”表达式,后缀表达式中操作符出现在左括号对应的右括号位置,所以遇到左括号,要标记下,其后出现的操作符优先级提升了,一旦描到的右括号,就可以马上输出左右括号之间的操作符。 -
总结下,在从左到右扫描逐个字符扫描中缀表达式的过程中,采用一个栈来暂存未处理的操作符。
这样,栈顶的操作符就是最近暂存进去的,当遇到一个新的操作符,就需要跟栈顶的操作符比较下优先级,再行处理。
-
-
操作过程
-
中缀表达式是由列表存储各个操作符和操作数;
-
创建空栈
opstack
用于暂存操作符,空表postfix_list
用于保存后缀表达式;中缀表达式列表:
A+B*C
=split=>['A','+','B','*','C']
-
从左到右遍历中缀表达式单词列表:
- 如果单词是操作数,直接添加到后綴表达式列表的末尾;
- 如果单词是左括号“(”,则圧入
opstack
栈顶; - 如果单词是右括号“)”,反复弹出
opstack
桟顶操作符,加入到输出列表末尾,直到到左括号; - 如果单词是操作符“*/±”,要与栈顶操作符比较优先级:
- 如果栈顶的低于它,直接入栈;
- 如果栈顶的高于或等于它,就要反复弹出栈顶操作符 ,加入到输出列表末尾,直到栈顶的操作符优先级低于它再入栈。
-
中缀表达式遍历结束后,把
opstack
栈中的所有剩余操作符依次弹出,添加到输出列表末尾,把输出列表再用join
方法合并成后缀表达式字符串。
-
-
代码实现
from test_stack import Stack def infix2postfix(infix): infix = infix.split(' ') # 操作符优先级映射表 operators_map = {'+': 1, '-': 1, '*': 2, '(': 3, ')': 3} # 操作符栈 operators_stack = Stack() # 操作数列表 operands_list = [] for s in infix: # 操作符 if s in operators_map: # 栈不为空 while not operators_stack.is_empty(): # 右括号,弹出操作符,直至左括号 if s == ')': while 1: last = operators_stack.pop() if last == '(': break # 非括号操作符入列 operands_list.append(last) break # 左括号、栈顶为左括号,操作符优先级大于栈顶操作符 if s == ')' or operators_stack.peek() == '(' or operators_map[s] > operators_map[operators_stack.peek()]: # 入栈,并继续遍历 operators_stack.push(s) break else: # 操作符优先级小于栈顶操作符,弹出栈顶操作符,入列 operands_list.append(operators_stack.pop()) else: # 栈为空,当前操作符入栈 operators_stack.push(s) else: # 操作数直接入列 operands_list.append(s) # 遍历完成,栈中的操作符全部入列 for i in range(operators_stack.size()): operands_list.append(operators_stack.pop()) print(' '.join(operands_list)) return operands_list infix2postfix('A + B * C + D')
-
栈的应用:后缀表达式计算
在对后缀表达式从左到右扫描的过程中,由于操作符在操作数的后面,所以要暂存操作数,在碰到操作符的时候 ,再将暂存的两个操作数进行实际的计算,*
作用于A和B。
栈的特性:操作符只作用于离它最近的两个操作数。
-
操作步骤
- 扫描后缀表达式,
- 遇到操作数保存到栈里面
- 遇到操作符,栈结构弹出操作数,第一个为右操作数,第二个弹出为左操作数,计算结果再入栈
- 直至最后一个操作符的计算结果为后缀表达式的计算结果
-
图例
后缀表示式:
4 5 6 * +
-
代码实现
def operate_postfix(postfix): # 操作符列表 operators_list = ['+', '-', '*', '/'] # 操作数栈 operands_stack = Stack() for s in postfix: # 操作数入栈 if s not in operators_list: operands_stack.push(s) else: # 栈顶-右操作数 last_r = int(operands_stack.pop()) # 左操作数 last_l = int(operands_stack.pop()) if s == '+': res = last_l + last_r elif s == '-': res = last_l - last_r elif s == '*': res = last_l * last_r elif s == '/': res = last_l / last_r else: return None operands_stack.push(res) return operands_stack.pop()
队列(Queue)
- 概念
队列是一种有次序的数据集合,其特征是新数据项的添加发生在一端(“尾 rear”端),而数据项的移除总发生在另一端( “首front”端)。
当数据加入队列,首先出现在队尾,随着队首数据的移除,它逐渐接近队首。
-
特征
新加入的数据项必须在数据集末尾等待,而等待时间最长的数据项则是队首,这种次序安排的原则称为(FIFO:First-in first-out)先进先出或“先到先服务 first-come first-served”。类似我们日常生活中排队。
队列仅有一个入口和一个出口,不允许数据项直接插入队中,也不允许从中间移除数据项。
抽象数据类型:Queue
抽象数据类型Queue是一个有次序的数据集合,
特征:1、数据项仅添加到“尾rear”端;2、仅从“首front”端移除;3、Queue具有FIFO的操作次序。
-
操作
Queue()
:创建一个空队列对象,返回值为Queue
对象;enqueue(item)
:将数据项item
添加到队尾,无返回值;dequeue()
:从队首移除数据项,返回值为队首数据项,队列被修改;is_empty()
:测试是否空队列,返回值为布尔值;size()
:返回队列中数据项的个数。
-
python实现
class Queue: def __init__(self): self.items = [] def is_empty(self): return self.items == [] def enqueue(self, item): self.items.insert(0, item) def dequeue(self): return self.items.pop() def size(self): return len(self.items)
-
算法分析
将List
首端作为队列尾端,List
的末端作为队列首端(左进右出),enqueue()
复杂度为O(n)
,dequeue()
复杂度为O(1)
。首尾倒过来实现,复杂度也倒过来。
队列的应用:热土豆
约瑟夫问题:“击鼓传花”的土豆版本,传烫手的热土豆,鼓声停的时候,手里有土豆的小孩就要出列。
-
约瑟夫问题
如果去掉鼓,改为固定人数,就成了 “现代版”的约瑟夫间题,
犹太人反叛罗马人,落到困境,约瑟夫和39人决定殉难,坐成一圏儿,根数1~7,报到7的人由旁边杀死,结果约瑟夫自己安排了个位置,最后活了下来… -
算法
用队列来实现热土豆问题的算法,参加游戏的人名列表,以及传土豆次数
num
,算法返回最后剩下的人名。 -
步骤
-
采用队列来存放所有参加游戏的人名,按照传递土豆方向从队首排到队尾,游戏时队首始终是持有土豆的人;
-
游戏开始,只需要将队首的人出队,随即再到队尾入队,算是土豆的一次传递;
-
传递了
num
次后,将队首的人移除,不再入队,如此反复,直到队列中剩余1人。
-
-
代码实现
def hot_potato(name_list, num): sim_queue = Queue() for name in name_list: sim_queue.enqueue(name) while sim_queue.size() > 1: for i in range(num): # 循环n次 sim_queue.enqueue(sim_queue.dequeue()) # 一次传递 sim_queue.dequeue() # 永久出队 return sim_queue.dequeue() print(hot_potato(["Bill", "David", "Susan", "Jane", "Kent", "Brad"], 7))
队列的应用:模拟打印任务
多人共享一台打印机,采取“先到先服务”的队列策略来执行打印任务,在这种设定下,问题就是:
- 这种打印作业系统的容量有多大?
- 在能够接受的等待时间内,系统能容纳多少用户,以多高频率提交多少打印任务?
-
实例
一个实验室,在任意的一个小时内,大约有10名学生在场,这一小时中,每人会发起2次左右的打印,每次1 ~20页 -
打印机的性能是:以草稿模式打印的话,每分钟10页;以正常模式打印的话,打印质量好,但速度下降为每分钟5页。
-
问题
怎么设定打印机的模式,让大家都不会等太久的前提下尽量提高打印质量?这是一个典型的决策支持问题,但无法通过规则直接计算。
我们要用一段程序来模拟这种打印任务场景,然后对程序运行结果进行分析,以支持对打印机模式设定的决策。
-
问题建模-对象
- 首先对问题进行抽象,确定相关的对象和过程,抛弃那些对问题实质没有关系的学生性别、年龄 、打印机型号、打印内容、纸张大小等等众多细节。
- 分析得到3个问题対象:打印任务、打印队列、打印机。
- 打印任务的属性:提交时间、打印页数
- 打印队列的属性:具有FIFO性的打印任务队列
- 打印机的属性:打印速度、是否正在打印
-
问题建模-过程
生成和提交打印任务
- 确定生成概率:实例为每小时会有10个学生提交的20个作业,这样,概率是每180秒会有1个作业生成并提交,概率为每秒1/180。
- 确定打印页数:实例是120页,那么就是1 20页之间概率相同。
实施打印
- 当前的打印作业:正在打印的作业;
- 打印结束倒计时:新作业开始打印时开始倒计时 ,回0表示打印完毕,可以处理下一个作业。
模拟时间:以最小单位(秒)均匀流逝的时间,设定结束时间
同步所有过程:在一个时间单位里,对生成打印任务和实施打印两个过程各处理一次。
-
操作步骤
- 创建打印队列对象;
- 时间按照秒的单位流逝:
- 按照概率生成打印作业,加入打印队列,记录入队的时间戳;
- 如果打印机空闲,且队列不空,则取出队首作打印任务,记录此作业等待时间(出队时间-入队时间);
- 如果打印机忙,则按照打印速度进行1秒打印(任务打印总时长为页数/速度);
- 如果当前作业打印完成,则打印机进入空闲。
- 时间用尽,开始统计平均等待时间;
- 使用不同模拟时间和对应打印机模式,综合分析结果,确定在哪种情况下使用哪种模式更高效。
-
代码实现
import random from queue import Queue # 打印机 class Printer: def __init__(self, ppm): # 打印速度,每分钟打印页数 self.page_rate = ppm # 当前打印任务 self.current_task = None # 当前任务离结束的时间,单位为秒 self.time_remaining = 0 # 打印机每1s操作 def tick(self): # 打印报告 if self.current_task is not None: self.time_remaining = self.time_remaining - 1 if self.time_remaining <= 0: self.current_task = None # 打印机状态(忙 or 空闲) def busy(self): if self.current_task is not None: return True else: return False # 下发新打印作业 def start_next(self, new_task): self.current_task = new_task # 生成新的倒计时(秒),page_rate为每分钟的页数,*60换算为秒 self.time_remaining = new_task.get_pages() * 60 / self.page_rate # 打印任务 class Task: def __init__(self, time): # 生成任务的时间(入队时间) self.timestamp = time # 打印报告树 self.pages = random.randrange(1, 21) def get_stamp(self): return self.timestamp # 打印数 def get_pages(self): return self.pages # 任务等待时间 def wait_time(self, current_time): """ :param current_time: 开始打印(出队时间) :return: """ return current_time - self.timestamp # 生成打印任务 def new_print_task(): num = random.randrange(1, 181) # 1/180概率生成作业 if num == 180: return True else: return False # 模拟打印 def simulation(num_seconds, pages_per_minute): """ 模拟指定时间内,特定模式下,打印机的平均等待时间 :param num_seconds: 模拟时长,单位为秒 :param pages_per_minute: 每分钟打印页数5 or 10,打印模式:草稿、高质量 :return: """ lab_printer = Printer(pages_per_minute) print_queue = Queue() waiting_time_list = [] # 时间流逝 for current_second in range(num_seconds): # 生成新的打印任务 if new_print_task(): # 生成打印任务,初始化生成时间和打印页数 task = Task(current_second) # 入队等待 print_queue.enqueue(task) # 打印机空闲及有打印任务 if (not lab_printer.busy()) and (not print_queue.is_empty()): # 下一个任务出队 next_task = print_queue.dequeue() # 收集每个出队任务的等待时间 waiting_time_list.append(next_task.wait_time(current_second)) # 开始新的打印任务,初始化打印倒计时 lab_printer.start_next(next_task) # 打印机执行1s,空闲状态无操作、打印状态任务运行1s(倒计时-1) lab_printer.tick() # 输出所有任务的平均等待时间,及剩余等待任务 average_wait_time = sum(waiting_time_list) / len(waiting_time_list) print("Average Wait %6.2f secs %3d tasks remaining." % (average_wait_time, print_queue.size())) simulation(3600,5) simulation(3600,10)
-
结果分析
- 按5 PPM、1小时的设定,模拟运行10次,总平均等待时间100秒,最长的平均等待304秒 ,最短的平均等待30秒,有4次模拟,还有作业没开始打印。
for i in range(10): simulation(3600, 5)
Average Wait 30.47 secs 0 tasks remaining. Average Wait 60.00 secs 0 tasks remaining. Average Wait 304.08 secs 1 tasks remaining. Average Wait 157.47 secs 3 tasks remaining. Average Wait 63.15 secs 1 tasks remaining. Average Wait 103.56 secs 0 tasks remaining. Average Wait 55.58 secs 0 tasks remaining. Average Wait 86.53 secs 0 tasks remaining. Average Wait 75.73 secs 1 tasks remaining. Average Wait 70.00 secs 0 tasks remaining.
- 按10 PPM、1小时的设定,模拟运行10次,总平均等待时间23秒,最长的平均等待32秒 ,最短的平均等待10秒,有1次模拟,还有作业没开始打印。
for i in range(10): simulation(3600, 10)
Average Wait 15.43 secs 0 tasks remaining. Average Wait 31.47 secs 0 tasks remaining. Average Wait 27.38 secs 0 tasks remaining. Average Wait 32.72 secs 0 tasks remaining. Average Wait 15.30 secs 0 tasks remaining. Average Wait 25.38 secs 0 tasks remaining. Average Wait 30.00 secs 0 tasks remaining. Average Wait 24.33 secs 1 tasks remaining. Average Wait 20.95 secs 0 tasks remaining. Average Wait 10.83 secs 0 tasks remaining.
-
为了对打印模式设置进行决策,我们用模拟程序来评估任务等待时间,通过两种情况模拟仿真结果的分析,我们认识到:
-
如果有那么多学生要拿着打印好的程序源代码赶去上课的话,那么,必须得牺牲打印质量,提高打印速度。
-
如果在周末,学生不需要赶去上课,能接受更长等待时间,可以选择高质量打印模式
-
等等根据不同的情况选择合适的打印模式
-
-
模拟系统对现实的仿真
使在不耗费现实资源的情况下(有时候真实的实验是无法进行的),可以以不同的设定,反复多次模拟来帮助我们进行决策。
更真实的模拟,来源于对问题的更精细建模,以及以真实数据进行设定和运行。
可以扩展到其它类似决策支持问题,如:饭馆的餐桌设置,使得顾客排队时间变短
双端队列
双端队列Deque
是一种有次序的数据集, 跟队列相似,其两端可以称作“首” “尾”端,但deque
中数据项既可以从队首加入,也可以从队尾加入;数据项也可以从两端移除。某种意义上说,双端队列集成了栈和队列的能力。
但双端队列并不具有内在的LIFO
或者FIFO
特性,如果用双端队列来模拟栈或队列,需要由使用者自行维护操作的一致性。
-
基本操作
Deque()
:创建一个空双端队列;add_front(item)
:将item
加入队首;add_rear(item)
:将item
加入队尾;remove_front()
:从队首移除数据项,返回值为移除的数据项;remove_rear()
:从队尾移除数据项,返回值为移除的数据项;is_empty()
:返回deque
是否为空;size()
:返回deque
中包含数据项的个数。
-
代码实现
class Deque: def __init__(self): self.items = [] def is_empty(self): return self.items == [] def add_front(self, item): self.items.append(item) def add_rear(self, item): self.items.insert(0, item) def remove_front(self): return self.items.pop() def remove_rear(self): return self.items.pop(0) def size(self): return len(self.items)
-
采用
List
实现List
下标 0 作为deque
的尾端,List
下标 -1 作为deque
的首端。 -
操作复度
add_front/remove_font O(1)
add_rear/remove_rear O(n)
-
-
应用:回文词判定
“回文词”指正读和反读都一样的词,如radar、madam、toot,中文的“上海自来水来自海上”、“山东落花生花落东山”。
- 解决方案
先将需要判定的词从尾加入deque
,再从两端同时移除字符同时判断是否相同,直到deque
中剰下0个或1个字符。如果中间出现字符不相同的场景则判定失败。
- 解决方案
无序表
一种数据项按照相对位置存放的数据集,特别的,被称为“无序表unordered list",其中数据项只按照存放位置来索引,如第1个、 第2个……、最后一个等。(为了简单起见,假设表中不存在重复数据项)
如一个考试分数的集合 “54, 26, 93, 17, 77和31”
如果用无序表来表示,就是[54, 26, 93, 17, 77, 31]
- 无序表
List
的操作List()
:创建一个空列表;add(item)
:添加一个数据项到列表中,假设 item原先不存在于列表中;remove(item)
:从列表中移除item,列表被修改,item原先应存在于表中;search(item)
:在列表中查找item,返回布尔类型值;is_empty()
:返回列表是否为空;size()
:返回列表包含了多少数据项;append(item)
:添加一个数据项到表末尾,假设item
原先不存在于列表中;index(item)
:返回数据项在表中的位置;insert(pos,item)
:将数据项插入到位置pos
,假设item
原先不存在与列表中,同时原列表具有足够多个数据项,能让item
占据位置pos
;pop()
:从列表末尾移除数据项,假设原列表至少有1个数据项;pop(pos)
:移除位置为pos
的数据项,假设原列表存在位置pos
。
使用链表实现无序表
列表数构要求保持数据的前后相対位置,但这种前后位置的保持,并不要求数据依次存放在的连续的存储空间。
-
链表实现:节点
Node
链表实现的最基本元素是节点(
Node
),每个节点至少要包含2个信息:数据项本身(data
),以及指向下一个节点的引用信息(next
),注意next
为None
的意义是没有下一个节点了。
class Node: def __init__(self, init_data): self.data = init_data self.next = None def get_data(self): return self.data def get_next(self): return self.next def set_data(self, new_data): self.data = new_data def set_next(self, new_next): self.next = new_next
-
链表实现
链表的第一个和最后一个节点最重要。
如果想访问到链表中的所有节点,必须从第一个节点开始沿着链接遍历下去,所以无序表必须要有对第一个节点的引用信息,设立一个属性
head
,保存对第一个节点的引用。空表的
head
为None
class UnorderedList: def __init__(self): self.head = None my_list = UnorderedList()
随着数据项的加入,无序表的
head
始终指向链条中的第一个节点,注意:无序表
my_list
对象本身并不包含数据项 (数据项在节点中),其中包含的head
只是对首个节点Node
的引用。-
is_empty()
def is_empty(self): return self.head is None
-
add方法
由于无序表没有限定数据之间的顺序,新数据可以加入到原表的任何位置,从性能考虑添加到
head
位置最方便。def add(self, item): temp = Node(item) temp.set_next(self.head) self.head = temp
-
size
从链条头
head
开始遍历到表尾同时用变量累加经过的节点个数。def size(self): current = self.head count = 0 while current is not None: count = count + 1 current = current.get_next() return count
-
search
从链表头
head
开始遍历到表尾,同时判断当前节点的数据项是否目标。def search(self, item): current = self.head found = False while current is not None and not found: if current.get_data() == item: found = True else: current = current.get_next() return found
-
remove(item)
首先要找到
item
,这个过程跟search
一 样,但在删除节点时,需要特别的技巧。current
指向的是当前匹配数据项的节点,而删除需要把前一个节点的next
指向current
的下一个节点
所以我们在search current
的同时,还要维护前一个previous
节点的引用。找到
item
之后,current
指向item
节点,previous
指向前一个节点,开始执行删除,需要区分两种情况:1.current
是首个节点;2.位于链条中间的节点。def remove(self, item): current = self.head previous = None found = False while not found: if current.get_data() == item: found = True else: previous = current current = current.get_next() if previous is None: self.head = current.get_next() else: previous.set_next(current.get_next())
-
有序表
有序表是一种数据项依照其某可比性质( 如整数大小、字母表先后)来决定在列表中的位置,越“小”的数据项越靠近列表的头,越靠 “前"。
- 基本操作
OrderedList()
:创建一个空的有序表;add(item)
:在表中添加一个数据项,并保持整体顺序, 此项原不存在;remove(item)
:从有序表中移除一个数据项,此项应存在,有序表被修改;search(item)
:在有序表中查找数据项,返回是否存在;is_empty()
:是否空表;size()
:返回表中数据项的个数;index(item)
:返回数据项在表中的位置,此项应存在 ;pop()
:移除并返回有序表中最后一项,表中应至少存在 一项;pop(pos)
:移除并返回有序表中指定位置的数据项,此位置应存在。
数据项的相对位置,取决于它们之间的“大 小”比较
由于Python的扩展性,对数据项的讨论并不仅适用于整数,可适用于所有定义了
__gt__
方法(即’>'操作符)的数据类型
以整数数据项为例,(17,26,31,54,77,93)
的链表形式如图
-
使用链表实现有序表
-
Node定义相同
-
OrderedList
也设置一个head
来保存链表表头的引用 -
is_empty/size/remove
这些方法, 与节点的次序无关,实现跟UnorderedList
是一样的 -
search
在无序表的
search
中,如果需要查找的数据项不存在,则会搜遍整个链表,直到表尾。对于有序表,利用链表节点有序排列的特性,一旦当前节点的数据项大于所要查找的数据项, 则说明链表后面已经不可能再有要查找的数据项 ,直接返回
False
。def search(self, item): current = self.head found = False stop = False while current is not None and not found and not stop: if current.get_data() == item: found = True else: if current.get_data() > item: stop = True else: current = current.get_next() return found
-
add
add方法必须保证加入的数据项添加在合适的位置,以维护整个链表的有序性,比如在
(17,26,54,77,93)
的有序表中,加 入数据项31,我们需要沿着链表,找到第一个比31大的数据项54,将31插入到54的前面。引入一个
previous
的引用,跟随当前点current
一旦找到首个比31大的数据,previous
就派上用场了。def add(self, item): current = self.head previous = None stop = False while current is not None and not stop: # 发现插入位置 if current.get_data() > item: stop = True else: previous = current current = current.get_next() temp = Node(item) # 插入表头 if previous is None: temp.set_next(self.head) self.head = temp else: # 插入表中 temp.set_next(current) previous.set_next(temp)
-
-
算法分析
对于链表复杂度的分析,主要是看相应的方法是否涉及到链表的遍历。
对于一个包含节点数为
n
的链表,is_empty
是O(1)
,因为仅需要检查head
是否为None
,size
是O(n)
,因为需要遍历到表尾,
search/remove以及有序表的add
方法,则是O(n)
,因为涉及到链表的遍历,按照概率其平均操作的次数是n/2
。
无序表的add
方法是O(1)
,因为仅需要插入到表头。 -
对比
链表实现的
List
跟Python
内置的列表数据类型,在有些相同方法的实现上的时间复杂度不同,主要是因为Python
内置的列表数据类型是基于顺序存储来实现的,并进行了优化。顺序存储,所有元素存放在一块连续地址的内存空间中
线性结构小结
线性数据结构Linear DS
将数据项以某种线性的次序组织起来
2个数据项之间只有先后关系,除了首尾,其他数据项只有唯一的前驱和后继
-
栈
栈
Stack
维持了数据后进先出LIFO
的次序,stack
的基本操作包括push
,pop
,is_empty
进出都在同一端
栈的应用:书写表达式的方法有前缀
prefix
、中缀infix
和后缀postfix
三种,由于栈结构具有次序反转的特性,所以栈结构适合用于开发表达式求值和转换的算法。 -
队列
队列
Queue
维持了数据先进先出FIFO
的次序,queue
的基本操作包括enqueue
,dequeue
,is_empty
添加和移除在不同的两端
队列的应用:“模拟系统”可以通过一个对现实世界问题进行抽象建模,并加入随机数动态运行 ,为复杂问题的决策提供各种情况的参考,队列
queue
可以用来进行模拟系统的开发。 -
双端队列
双端队列
Deque
可以同时具栈和列的功能,deque
的主要操作包括add_front
,add_rear
,remove_front
,remove_rear
,is_empty
。 -
列表
列表
List
是数据项能够维持相对位置的数据集。链表的实现,可以保持列表维持相对位置的特点,而不需要连续的存储空间,链表实现时,其各种方法(比如添加、删除),对链表头部head
需要特别的处理。