栈与队列

栈(Stack)

62c9fc1b-d0b4-4206-8763-6df76f9a1b1e

栈这种结构强调的是栈运算,实现了如下3种运算的结构就是栈:

1) \(push(x)\):存入\(x\)元素(也称为压栈);

2) \(pop()\):删除最后一次存入的元素并返回该元素(也称为弹栈);

3) \(top()\):返回最后一次存入的元素(也称为栈顶,所以写作top);

直观上栈像狭长的储物箱,只有一面开口,只能看到最顶上的物品,取最里面的物品时要自顶向下的先拿出所有其他物品。这3种运算使栈中的元素在逻辑上也蕴含着前驱后继关系,所以栈也是线性表。但栈不必像数组和链表那样,必须提供查看任意元素、在任意元素间插入、删除任意元素等各种运算,所以直观上栈并不像一张“表”,而像是有3个运算的“黑箱”,所以栈也被称为操作受限的线性表。当然如果在链表或数组的基础上实现栈运算的话,那也可以作为栈。栈作为后进先出(LIFO, Last In First Out)结构,看起来不够“自然”,但栈的应用很广,其中一个原因是其LIFO的逻辑能让栈每执行完一次\(pop\)就能回到最后一次执行\(push\)前的状态。在一些使用栈的算法中,会用到这个性质。

程序语言中很少有专门实现栈的数据类型,需要栈时一般使用基于数组和链表的数据类型实现。Python的list提供尾部的插入和删除运算,所以list可直接当栈使用,根据Python文档list的尾部操作的代价大致是\(O(1)\)。然后自己在程序中实现基于链表的栈,把栈顶放在表头能更容易实现\(O(1)\)代价的\(push,pop\),具体如下。

 

用栈展开递归函数

栈和程序语言中的递归函数有内在联系,在递归调用过程中,外层的递归比内层更早的开始,但只有在内层递归结束后外层递归才结束,所以递归调用和栈一样是“先进后出”的。事实上程序语言底层是在内存这个“大数组”上模拟了栈,进而才能让函数有递归的能力。所以使用递归程序等于使用程序底层提供的“栈”,这就意味着可以不用这个“栈”,直接在程序中自己构建和维护栈,把程序改为非递归的。其具体实现方式因问题而异,比较灵活,良好的实现能有优于递归的性能。

下面第1个程序是递归实现的求\(n\)阶乘(\(n>0\)),然后考虑用栈来模拟该递归逻辑,其步骤如下:

1) 按次序将\(n,n-1,…,1\)入栈;

2) 出栈一个元素并将其计为\(x\);

3) 若此时栈为空说明\(x\)是原问题的结果,程序在该步返回\(x\),否则什么也不做;

4) 出栈一个元素并将其计为\(y\),计算\(xy\)并将\(xy\)入栈,回到步骤(2);

上述过程中(1)对应一层层进入递归调用,(2)对应一层层结束递归调用,并返回利用子递归和输入计算的结果,下面第2个程序就实现了这个过程。这里也是直接用Python的list来作为栈使用的。

 

队列(Queue)

ea5db502-0179-47a3-934c-6b55975a7330

队列也是操作受限的线性表,其和栈的唯一区别是队列是先进先出(LIFO, First In First Out)的。直观上队列和排队的队伍一样,最先从队尾进入队伍的人一定会最先从队头出队。队列需要实现如下运算:

1) \(get()\):将队尾元素出队并返回该元素;

2) \(put(x)\):将元素\(x\)从队头入队;

这种LIFO结构比栈更“自然”,但这使队列不具备栈的性质,即从队列出队一个元素后,队列不会回到最后一次入队前的状态。队列的应用同样广泛,在服务器程序中,经常用队列缓冲短期大量的请求。在多线程编程中,经常用队列实现“生产者-消费者”设计模式。Python内置了队列库Queue,其能够实现\(O(1)\)的出入队运算,但其是专门用来做线程间通信的。下面基于Python单链表实现队列,为使出入队代价都是\(O(1)\),用额外的\(tail\)变量维护链表尾节点。

 

队列的环形数组实现

573827b3-840d-4965-8d2b-8389a605ce6f

直接用数组实现队列不是个好方法,比如让数组从左边出队,右边入队,那每次出队都要让后面所有元素改变索引,出队的代价就是\(O(n)\)。如果反过来让数组从右边出队左边入队,那入队的代价就是\(O(n)\)。为了解决这种问题,需要引入环形数组的概念,就是假想把数组尾接到数组头上。然后基于环形数组实现出入队都是\(O(1)\)的队列。需要注意的是由于使用了数组,数组创建时是要指定最大容量的,所以这种队列也是需要确定最大容量的。实现思路如上图所示,先根据容量\(size\)创建容量\(size+1\)的数组\(L\),多1位是为了之后处理边界情况。然后初始化\(rear = front = 0\)分别表示“下次入队的位置”和“下次出队的位置”。利用取余(\(\% \))运算实现“环形”移动\(rear\)和\(front\)。下面给出实现队列运算的步骤:

1) \(isFull\):判断队列是否已满,方法是若\((rear +1) ~\%~ size = front ~\%~ size\),则队列已满;

2) \(isEmpty\):判断是否为空队列,方法是若\(rear ~\%~ size = front ~\%~ size\),则队列为空;

3) \(put(x)\):若队列未满,令\(L[rear ~\%~ size] = x\),令\(rear\)自增;

4) \(get()\):若队列非空,令\(x = L[front ~\%~ size]\),令\(front\)自增,返回\(x\);

下面是以Python的list为数组实现的环形数组队列。

 

双端队列(Deque)

双端队列(Double-Ended Queue)是能在任意时刻执行“左端入队、左端出队、右端入队、右端出队”的结构,其在算法中的应用是十分广泛的,因为在任意时刻都能既当作栈又当作队列。Python内置的collections库提供了双端队列collections.deque供程序使用,其支持上述4种运算,且代价都是\(O(1)\),其底层是链表的实现。在算法需要使用队列/栈/双端队列时,都可以直接用这个对象。这里用双向链表自己实现双端队列,其能够保证4个操作的时间复杂度都是\(O(1)\),具体程序如下。

滚动至顶部