引言
在简单介绍完线性表(顺序表和链表)这一基本数据结构后,便可以学习稍微进阶一点的栈和队列了。
栈和队列与其说是两种数据结构,倒不如说是两种数据容器,只负责数据的存储和访问,不允许数据之间的任意操作。此外,栈和队列中元素的添加和删除都是以一个默认的顺序进行,无须以任何形式指定。
如果简单了解过栈和队列,就会发现,栈和队列是基于线性表这一结构的,可以说,线性表可以完美地完成栈和队列地工作,那么为什么还要单独设置抽象数据类型来进行栈和队列的功能呢?
这其实与数据结构的安全性有关。如果把线性表作为栈或队列使用,如list类型,那么建立的对象还是list类型,可以进行所有list类型允许的操作,这些操作包括了很多栈本身不能提供的操作,会威胁栈在使用过程中的安全性。例如,栈在进行元素删除时,只允许最后进入栈的元素被删除,而list类型可以支持任一元素的删除。
也就是说,在数据结构中,并不是说一个结构功能越强大越好,强大功能的结构不仅意味着其所需要消耗的资源多,同时也表示其更加需要维护和严格的安全性考虑。根据实际问题,选择最恰当的数据结构,尽可能降低消耗的资源,并能最小化考虑维护成本,这才是我们应该做的。
栈的介绍和实现
栈是一种后进先出(Last In First Out)的数据结构,栈分为栈顶和栈底。栈底指的是最先进入栈的元素,栈顶表示最后进入栈的元素。栈中元素的插入和删除都是发生在栈顶。
与线性表不同,栈是一个封闭的结构集合,其需要包含的功能有:
- 栈的创建,创建一个空栈。
- 判断栈是否为空,空栈不能进行元素的删除和检查
- 压栈:将元素压入栈中,也称为入栈和进栈,入栈的元素在栈顶。
- 出栈:将栈顶的元素弹出并返回其取值,也称为退栈。
- 查栈:检查栈顶的元素,但不进行任何操作。
前面也提到过,list类型完全可以满足栈应用的需要,len == 0 可以判断非空, list[-1]可以输出栈顶的元素, list.append()可以进行压栈,而list.pop()可以进行出栈。 因此在进行栈的实现时,选择基于线性表来进行。
值得注意的是,栈中元素没有位置概念,只有入栈的时间先后顺序。但在实现过程中,我们选择将线性表中元素的位置作为入栈的时间先后顺序,这一点需要明确。
基于顺序表的栈实现
在介绍基于顺序表的栈实现之前,首先先简单介绍两种顺序表:
- 简单顺序表:
- 动态顺序表:
python中list类型属于第二种动态顺序表,这里我们不关注其具体实现,只关注其用法,即如何利用list类型来实现一个简单的栈结构。
具体代码如下所示:
# 定义异常类,继承ValueError父类
class StackUnderFlow(ValueError):
pass
# 基于顺序表的栈
class SStack():
def __init__(self):
self._elems = []
def is_empty(self):
return (self._elems == [])
def push(self, elem):
return self._elems.append(elem)
def top(self):
if self._elems == []:
raise StackUnderFlow('in SStack_top')
return self._elems[-1]
def pop(self):
if self._elems == []:
raise StackUnderFlow('in SStack_top')
return self._elems.pop()
上述定义中,可以重点关注一下raise语句,异常类中均包含一个字符串实参。当触发异常时,便于根据这个字符串实参来确定异常的发生地方,用于帮助检查程序的错误。
如下代码展示了基于list的栈的用法:
st = SStack()
st.push(3)
st.push(5)
while not st.is_empty():
print(st.pop())
基于链表的栈实现
在学习完链表(可以参考我的另一篇博客:数据结构之链表),可以知道:简单的单向链表首端插入和删除元素的复杂度都是O(1),因此将链表首端作为栈顶可以极大程度地提高栈的搜索能力。与之相反的是基于list的栈实现,list类中尾端插入和删除元素的复杂度是O(1),所以列表的尾端才是栈的栈顶。
基于链表的栈实现的代码如下所示:
首先仍然是定义链表中的结点
class LNode():
def __init__(self, elem, next_=None):
self.elem = elem
self.next = next_
class LStack():
def __init__(self):
self._head = None
def is_empty(self):
return (self._head is None)
def push(self, elem):
self._head = LNode(elem, self._head)
def top(self):
if self._head is None:
raise StackUnderFlow('in LStack_top')
return self._head.elem
def pop(self):
if self._head is None:
raise StackUnderFlow('in LStack_pop')
e = self._head.elem
self._head = self._head.next
return e
基于列表的栈功能已经很完善了,基于python的list不需要考虑列满的情况,其可以自动扩大存储区,那么为什么还需要考虑基于链表的栈呢?其实这又回到了资源消耗的问题上,python中的动态顺序表每扩大一次存储区都需要做一次高代价的操作,并且其需要一块完整连续的存储区。而链表技术在这两个问题上都具有明显优势,其劣势在于更多地依赖于python解释器本身的存储管理。
队列的介绍和实现
与栈不同,队列是一种先进先出(First In First Out)的数据结构,队列分为队头和队尾。队头表示最早进入队列的元素,队尾表示最后进入队列的元素。队列中元素的插入发生在队尾,删除(取出)发生在队头。
队列跟栈一样是一个封闭的结构集合,所需包含的功能也基本一致,这里便不一一赘述。
基于顺序表的队列实现
基于顺序表的队列在实现过程中较为困难,这里将顺序表的尾端作为队尾,首端作为队头进行分析。新元素的入队发生在队尾,顺序表尾端插入新元素的复杂度是O(1),这一项没有问题。
当队头元素出队时,面临着两个问题:
(1)如果其他元素不向前移动一个单位,那么在多次入队和出队之后,队列的首端没有元素,但占据着存储空间。当队尾元素已经达到分配的存储区极限时,这时理应动态扩大存储区,但此时队头有大量空位,存在“假性溢出”问题。
(2)倘若当队头元素出队时,后面元素都向前移动一个单位,如list类型中的list.pop(0),那么在实现的过程中需要O(n)的时间复杂度,造成时间的浪费。
其实可以很自然地想到解决方法:利用环形结构。利用两个指针来记录队头和队尾的位置,当有元素发生入队或出队时,改变指针指向的元素位置来变更队头和队尾,这样便能实现出队和入队均是O(1)时间复杂度,且不造成存储浪费。
如果要采用基于List类型的环形结构,需要重点考虑的问题是队满情况。前面已经介绍python中list具有动态扩容的功能,但在实现队列时,会发生两大问题:
- list类型中元素都是默认保存在存储区的最前面,但环形结构的队列中元素保存在结构的任意一段,如果自动扩容,则会面临元素失控的问题。
- list类型没有提供检查元素存储区容量的机制,导致队列无法判断python解释器会在何时动态分配存储。
因此,需要自己考虑队列的存储问题。
首先,在进行基于list的栈实现,我们要定义一个异常类,来处理当空队时进行dequeue(出队)操作时引发的错误。
class QueueUnderFlow(ValueError):
pass
完整的基于list的队列实现代码如下所示:
# 基于list的队列实现
class SQueue():
def __init__(self, init_len=8):
self._len = init_len
self._elems = [-1] * init_len
self._head = 0
self._num = 0
def is_empty(self):
return (self._num == 0)
# 手动扩容 当队满时,进行入队操作会首先调用该函数进行扩容
def _extend(self):
old_len = self._len
self._len *= 2
new_elems = [-1] * self._len
for i in range(old_len):
new_elems[i] = self._elems[(self._head + i) % old_len] # 在扩容时不改变原先队列中的元素顺序(反映的是元素入队的时间顺序)
pass
self._head, self._elems = 0, new_elems
def enqueue(self, elem):
if self._num == self._len: # 队满
self._extend()
self._elems[(self._head + self._num) % self._len] = elem
self._num += 1
def dequeue(self):
if self._num == 0:
raise QueueUnderFlow('in LQueue_dequeue')
e = self._elems[self._head]
self._head = (self._head + 1) % self._len
self._num -= 1
return e
def peek(self):
if self._num == 0:
raise QueueUnderFlow('in LQueue_peek')
return self._elems[self._head]
需要关注的地方有两处:
- 队满时的操作:当队列存储已满时,这时需要对队列进行扩容的操作。在这里选择重新建立一个二倍存储的列表,将原列表的值按照队头 -> 队尾的顺序赋值给新列表的最前面存储区。
def _extend(self):
old_len = self._len
self._len *= 2
new_elems = [-1] * self._len
for i in range(old_len):
new_elems[i] = self._elems[(self._head + i) % old_len]
pass
self._head, self._elems = 0, new_elems
- 入队和出队的操作:前面介绍可以知道,在出队时,队头元素出队列,此时将self._head域指向出列元素的下一个,这里self._head充当的是队头指针。当入队时,可以根据self._head + self._num - 1来确定当前队尾的位置,将入队元素赋值给现队尾的后一个元素。这里不另外设置self._rear域是为了方便类的维护,self._head和self._num这两个属性域组合可以代替self._rear指针。此外,也可以在初始化时,不设置self._num域,设置self._head和self._rear的组合来代替self._num的作用。
def enqueue(self, elem):
if self._num == self._len: # 队满
self._extend()
self._elems[(self._head + self._num) % self._len] = elem
self._num += 1
def dequeue(self):
if self._num == 0:
raise QueueUnderFlow('in LQueue_dequeue')
e = self._elems[self._head]
self._head = (self._head + 1) % self._len
self._num -= 1
return e
基于链表的队列实现
基于链表的队列实现很简单,可以直接利用含有self._head和self._rear属性域的变种链表来进行队列实现,将链表的尾端作为队尾,self._rear域的存在使得在队尾添加元素的时间复杂度为O(1),而链表首端元素的插入和删除都是O(1)时间复杂度,能够有效满足我们的要求。
完整的基于链表的队列实现代码如下:
class LQueue():
def __init__(self):
self._head = None
self._rear = None
def is_empty(self):
return (self._head is None)
def enqueue(self, elem):
if self._head is None:
self._head = LNode(elem)
self._rear = self._head
else:
self._rear.next = LNode(elem)
self._rear = self._rear.next
def peek(self):
if self._head is None:
raise QueueUnderFlow('in LQueue_peek')
return self._head.elem
def dequeue(self):
if self._head is None:
raise QueueUnderFlow('in LQueue_dequeue')
e = self._head.elem
self._head = self._head.next
return e
总结
如果有线性表的基础,栈和队列的概念和实现不难理解,但需要注意的是数据结构的本质( 个 人 理 解 \color{red}{个人理解} 个人理解)。我们设计各种数据结构,设计各种抽象数据类型,目的是在解决不同问题时都能找到一种结构或者类型,能够保证在最简单的复杂度和最少的资源消耗前提下实现问题的求解。倘若忽略掉这一点,单纯只想求解问题,那么所有问题都能利用遍历求解。借用老师的一句话:当硬件达到极限时,就没有算法的事了。