栈和队列比较简单,而且实用性非常广泛,这里主要介绍一下他们的概念和实现,在很多算法中,栈和队列的运用很重要,因此,虽然简单确是最重要的数据结构之一,必须重视。
栈是保证元素后进先出(后存入者先使用,Last In First Out,LIFO)的关系结构。
队列是保证元素先进先出(先存入者先使用,First In First Out,FIFO)的关系结构。
栈
栈的顺序表实现
class
顺序表操作的后端插入和删除操作都是o(1)操作。
栈的链接表实现:
顺序表的操作时间复杂度为o(1),那么为什么还要考虑链接表?因为顺序表扩大存储区需要做一次高代价的操作,另外顺序表需要完整的大块存储区。采用链接表实现可以缓解这两个缺陷,但是链接表也有自身的缺点:更多依赖于解释器的存储管理,每个结点的链接开销以及链接结点在实际计算机内存中的任意散布可能带来操作开销。
采用链接表技术,自然是表头一端作为栈顶,表尾作为栈底。
class
栈和递归
这里要介绍一下栈和递归的关系,我们平时用的递归算法,其内部原理就是运用了栈。
递归:在一个函数定义中引用了被定义的对象(被定义的函数本身)。
在递归的结构中,递归的部分必须比原来的整体简单,这样才有可能达到某种终点(递归定义的出口),显然,终点不能是递归的。递归结构中必须包含非递归的基本结构构成的部分,否则就会出现无限递归。
例如阶乘函数n!,其递归的代码实现:
def
现在来解析这一递归结构,表述其于栈之间的关系:
- 为了得到factor(n)的结果,必须先计算出factor(n-1)。
- 递归调用计算出factor(n-1)时还需要乘n,以得到factor(n)的结果,说明在递归调用factor(n-1)时,参数n的值需要被记录,同样计算factor(n-1)调用factor(n-2)时,也需要记录这个调用之前参数n-1的记录,向下继续...
- 显然需要记录的数据的量与递归的层数成正比,一般而言没有数量上的上限,不能用几个整形变量来保存。
- 在这一系列调用中保存的数据,如n,n-1..较后保存的将先被使用,因为函数返回的顺序与调用顺序相反,后进入的层次先返回。
这种后进先出的使用方式和数据项数的无明确限制,就说明需要用一个栈来支持递归函数的实际运行,这个栈称为程序运行栈。
下图表示的是阶乘函数factor(3)的是如何递归计算的。
第一个图表示函数调用factor(3)开始执行的状态,参数3压入栈。随后执行调用factor(2),参数2压入栈,直到factor(0)压入栈。然后开始逐一返回,其中factor(0)直接返回1(递归出口),factor(1)同样返回1*factor(0),计算得到factor值为1,factor(2)返回2factor(1)=2,factor(3)返回3*factor(2)=6。
递归的实现依赖于运行栈,对递归函数的每次调用都在栈上开辟一块区域,称为函数帧,函数的执行总以栈顶的帧作为当前帧。所有局部变量都在这里体现。当函数从下一层递归调用中返回时,函数的上一层执行取得下层函数调用得到的结果,执行系统弹出已经结束的调用对应的帧,然后回到调用前的那一层执行时的状态。
函数的嵌套调用秉持着“先调用后返回”的规则,函数调用时的内部动作分为两个部分:在进入新的函数调用前需要保存一些信息,退出一次函数调用时需要恢复调用前的状态。这被称为函数调用的前序动作和后序动作。
函数调用的前序动作包括:
- 为被调用函数的局部变量和形式参数分配存储区(称为函数帧/活动记录/数据区)
- 将所有实参和函数的返回地址存入函数帧(实参和形参的结合/传值)
- 将控制转到被调用函数入口
函数调用的后序动作包括:
- 将被调用函数的计算结果存入指定位置
- 释放被调用函数的存储区
- 按以前保存的返回地址将控制转回调用函数
函数的调用是有代价的,在得到程序代码的模块化和语义清晰性等优势的同时,可能会付出执行时间的代价。
递归与非递归:
将一个递归定义的函数变成非递归的形式,可以自己建立一个栈来模拟程序运行栈:
def
任何一个递归定义的函数都可以通过引入一个栈保存中间结果的方式,翻译为一个非递归的过程。与此对应的是,任何一个包含循环的程序都可以翻译为一个不包括循环的递归函数。
栈的应用:简单背包问题(动态规划思想)
其求解算法用递归的方式描述很简单,但是通过自己管理一个栈来存储中间信息定义非递归算法,则比较复杂。
问题描述:一个背包里可放入重量为weight的物品,现有n件物品的集合S,其中物品的重量分别为w0,w1...wn-1。问题是能否从中选出若干件物品,其重量之和正好等于weight。如果存在就说这一背包有解,否则无解。
问题的求解:假设weight>=0,n>=0。用记法knap(weight,n)表示n件物品相对于总重量weight的背包问题,在考虑它是否有解时,通过考虑一件物品的选或者不选,可以把问题分为两种情况
- 如果不选最后一件物品(wn-1),那么knap(weight,n-1)的解也就是knap(weight,n)的解,如果找到前者的解也就找到了后者的解。
- 如果选择最后一件物品,那么knap(weight-wn-1,n-1)有解,其解加上最后一件物品就是knap(weight,n)的解,即前者有解后者也有解。
定义递归出口:
- 重量weight已经等于0,说明问题有解
- 重量weight已经小于0,由于不断归结中所需的重量递减,有可能出现这种情况,这说明按照已做的安排不能得到一个解。
- 重量大于0但已经没有物品可用,说明按照这种安排无解。
def
函数三个参数分别是总重量weight,各物体重量表wlist和物品数量n,前两个if处理三种简单情况,返回True或False(递归出口),后两种情况通过递归调用得到结果。
队列
队列的链接表实现:
带有首指针的单链表其插入操作enqueue()为o(1)操作,但是在另一端的去除操作dequeue()确实o(n)操作。那么为了实现首尾两端的插入和删除操作都为o(1)操作,则需要首尾指针都有的单链表。
class
队列的list实现:
基于顺序表实现队列的困难:出队操作如果在首端操作,取出当时的元素后,需要将后面的元素全部前移需要o(n)时间。如果是从尾端弹出元素,时间是o(1)操作,但是尾端插入时间为o(n),因此无论何种方式进行插入和删除,都避免不了o(n)的操作。另一种可能是队首的元素弹出后,后面的元素不前移,但记住新表头元素的位置,这样做会带来另一个问题,队首的空位会越变越多,造成空间的浪费。
有一种想法:如果入队时队尾已经达到存储区的末尾,应该考虑转到存储区开始的位置去入队新元素。
循环顺序表:
- 在队列使用中,顺序表的开始位置并不改变,上图是一个包含8个位置的表,例如变量q.elems始终指向表元素区开始。
- 队头变量q.head记录当前队列里第一个元素的位置(图中位置4),队尾变量q.rear记录当前队列里最后元素之后的第一个空位(图中位置1)。
- 队列元素保存在顺序表的一段连续单元,用python的写法是[q.head:q.rear],左闭右开区间里。图中有5个元素,从位置4到位置0的一段。两个变量的值之差(取模存储区长度)就是队列里的元素个数。
初始时队列为空,q.head和q.rear取相同值,表示顺序表里的为空,出队和入队操作时需要更新q.head和q.rear
q
如下图所示,如果队列再存一个元素,q.rear和q.head就会相同,这与判断队列为空时一样,产生冲突。事实上这种状态就看成了队列满,即定义其条件为(q.rear+1)%q.len=q.head。当队列满时又需要继续插入元素的话,就需要更换存储区更大的空间。
循环队列无法直接利用python的list里的自动存储扩充机制:队列元素的存储方式与list元素的默认存储方式不一致,list元素总是在存储区的最前面一段,而队列的元素可能是表里的任意一段,有时还分为头尾两段,如果list自动扩充,其中的队列元素就有可能失控,另一方面list没提供检查存储区容量的机制,队列操作中无法判断系统何时扩容。
考虑其基本设计:
- 定义队列名SQueue对象里有一个list类型的成分_elems存放队列元素。
- 分别考虑两个属性head和_num表示队首元素所在位置以及表中元素的个数。
- 用python的list作为队列存储区。需要检查当前的表是否已满,必要时换一个存储表,因此要记录当前表的长度,下面用属性_len。
数据不变式:实现一种数据结构时,最基本的问题是这些操作需要维护对象属性之间的正确关系。
- 所有构造对象的操作,都必须把对象成分设置为满足数据不变式的状态,也就是说对象的初始状态要满足数据不变式。
- 每个对象操作都应该保证不破坏数据不变式,也就是说如果对一个状态完好的对象应用一个操作,该操作完成时,还必须保证对象处于完好的状态。
下面队列实现中,考虑数据不变式:
- elems属性引用着队列的元素存储区,它是一个list对象,_len属性记录存储区的有效容量。
- _head是队列的首元素的下标,_num始终记录着队列中元素的个数。
- 当时队列里的元素总保存在elems里从head开始的连续位置中,新入队的元素存入由_head+_num算出的位置,但如果需要把元素存入下标_len的位置时,改为在下标0位置存入该元素。
- 在_num==_len的情况下出现入队操作,就扩大存储区。
class
双端队列:
允许两端插入和删除元素,因此功能覆盖以上所有结构,作为效率要求很高的结构,这里要求两端插入和删除操作时间复杂度均为o(1)。双链表结构可以实现两端的常量时间插入和删除操作,可以实现双端队列。python中的collections库中定义了一种deque类型双端队列,支持元素两端的插入和删除。deque采用一种双链表技术实现,每个链接结点里顺序存储一组元素。
顺序表与链表的计算机内存分配问题:
对于现在计算机内存机制,如果连续进行的一批内存访问是局部的,操作速度就会块得多。人们在考虑效率的同时,一个重要线索就是尽可能使计算机内存的使用局部化。python中顺序表是局部化的代表,在可能的情况下应该尽量使用。链接结构的一个特点是,其中的结点可能在内存中任意分配。即使程序顺着链接逐个访问结点,在内存层面的表现通常也是在许多位置随机的单元之间跳来跳去。因此链接表再带来灵活性的同时,效率上可能有明显的付出。这些情况就是需要考虑顺序表实现的最重要原因。另外在其它的一些语言中,建立顺序表结构还可以避免复杂的存储管理。
参考书籍:《数据结构与算法—python语言描述》—裘宗燕