本文笔记来自《大话数据结构》
栈和队列这两种数据结构,其实它们都是特殊的线性表,只不过对插入和删除操作做了限制。
下面我们再来详细的了解了解它们。
栈是限定仅在表尾进行插入和删除操作的线性表。
栈有一个很形象的比喻,那就是弹夹。相信我们都了解,手枪弹夹的子弹,都是一颗一颗压进弹夹的,后一个子弹把前一个子弹压到弹夹底部,开枪时,则是后压进的子弹先射出去。
这种弹夹的特性,和我们要讲的栈的结构是一样的。都是后进先出(Last In First Out),这种结构我们简称为:LIFO 结构。
在这种结构中,我们把允许插入和删除的一端称为栈顶,另一端则称为栈底,没有任何数据时则称为空栈。
插入数据的操作,叫做进栈,也称压栈、入栈。删除操作叫做出栈、也叫做弹栈。
有一点我们可能需要注意一下,就是在开头介绍栈栈的定义时说到:栈是仅在表尾进行插入和删除操作的线性表。这里的表尾是栈顶,而不是栈底,这个表所说的是线性表喔,大家可千万别弄混了。
对于栈来说,最先进栈的元素不一定是最后出栈的。
这是因为,栈对线性表的插入和删除数据的位置进行了限定,限定在了表尾。但是没有对元素的进出时间进行限定。
也就是说,在不是所有元素都进栈的情况下,进去的元素也是可以出栈的,只要保证是栈中最顶部的元素出栈就行了。
ADT 栈(stack)
Data
同线性表。元素具有相同类型,相邻元素具有前驱和后继关系。
Operation
initStack(*S):初始化操作,建立一个空栈 S。
destroyStack(*S):若栈存在,则销毁它。
clearStack(*S):将栈清空。
stackEmtpy(S):若栈为空,则返回 true,否则返回 false。
getTop(S, *e):若栈存在且非空,用 e 返回栈顶元素。
push(*S, e):若栈存在,插入新元素 e 到 S 栈的栈顶。
pop(*S, e):若栈存在,删除栈顶元素,并用 e 返回其值。
stackLength(S):返回 S 栈中元素的个数。
由于栈的本身就是一个线性表,那么在上篇中讲到的线性表的顺序存储结构实现和链式存储结构实现都是适用于栈的。
在上篇中我已经就已经知道了,线性表的顺序存储结构是用数组来实现的。
在栈的顺序存储结构中,我们将数组下标为 0 的一端作为栈的栈底。这是因为,栈底的元素变化最小。如果用数组的尾部作为栈底,那么当数组进行扩容时,原数组尾部的元素,就不再是最后一个了。所以我们将数组下标为 0 的一端作为栈底。
另外,我们还定义一个变量:top。我们称它为栈顶指针,用于记录栈顶元素在数组中的位置。
当栈中没有数据时,top 的值为 -1。当有一个元素时,top 的值为 0,有两个元素时,值为 1。top 的大小不能超出栈的长度。若栈的长度为 5,那么 top 的值最大只能为 4。
栈的定义如下:
public class OrderStack<E> {
/** * 栈的最大长度. */
protected static final int MAX_LENGTH = 100;
/** * 存储元素的容器. */
protected Object[] elementData;
/** * 栈顶指针, 默认为 -1, 表示栈中没有元素. */
protected int top = -1;
}
进栈的代码如下:
/** * 将指定的数据元素进栈. * @param item 要进栈的元素. * @return 插入成功,返回 item,否则返回 NULL. */
public E push(E item){
// 栈满
if(top == elementData.length - 1){
return null;
}
// 栈顶指针 + 1
top ++;
// 存储元素
elementData[top] = item;
return item;
}
出栈的代码如下:
/** * 栈顶元素出栈. * @return 返回出栈的栈顶元素,没有返回 NULL. */
public Object pop(){
// 空栈
if(top == -1){
return null;
}
// 获取要出栈的元素,用于返回
Object item = elementData[top];
// 删除元素
elementData[top] = null;
// 栈顶指针 - 1
top --;
return item;
}
通过上面的代码例子,我们可以发现其实栈的顺序存储还是很方便的。因为只准栈顶进出元素,所以不存在移动元素的情况。但是它有一个很大的缺陷,就是无法确定数组存储空间的大小。一旦数组已经存储满了,那么就需要进行动态扩容,当然,为实现动态扩容肯定会麻烦一些,并对性能有一些影响。
如果我们要实现两个相同类型的栈,分别用两个数组来实现,那么就可能会出现,一个栈已经存满、而另一个栈却空很多的情况。
那么这时其实可以用一个数组来实现两个栈,其中一个栈的栈底为数组下标 0 处,另一个栈的栈底是数组另一端,最大下标 -1 处。存储元素时两端都往中间靠拢,以尽量节省空间。
判断是否是空栈,依然是根据栈顶指针来判断。假设 top1 是栈 1 的栈顶指针,top2 是栈 2 的栈顶指针,那么当 top1 = -1 时则表示栈 1 是空栈,当 top2 = 数组的最大长度,则表示栈 2 是空栈。
判断栈满则是根据 top1 + 1 == top2 来判断已经栈满了。
一般使用这种结构,通常都是两个栈的空间需求有相反关系。比如说,股票的买入和卖出。有人买进了,则绝对有另一个人在卖。
栈的链式存储结构,也简称为:「链栈」。
在上篇线性表的文章中我们了解到,对于单链表来说,链表有头指针与头结点。而在链栈当中,我们只需要头指针即可。
头指针结点一直指向链栈的栈顶结点,当是空栈时,头指针指向 NULL。
链栈的的定义代码如下:
/** * 栈的链式存储结构实现.(简称:链栈) * @param 元素类型 */
public class LinkedStack<E> {
/** * 头指针,指向链栈中的栈顶结点. * 当头指针为 NULL 时,表示链栈是空栈. */
protected Node top;
/** * 存储的元素个数. */
protected int count;
/** * 结点. * @param 元素类型 */
public class Node<E>{
// 存储的元素
E data;
// 指向下一个结点的指针
Node next;
}
}
链栈的进栈代码如下:
/** * 将指定的元素 e 进栈. * @param e 要进栈的元素. * @return 插入成功返回 e,否则返回 NULL. */
public E push(E e){
// 创建新结点
Node node = new Node<>();
// 存储指定元素
node.data = e;
// 将栈顶指针赋值给新结点的后继结点
node.next = top;
// 栈顶指针为新结点
top = node;
// 元素 + 1
count ++;
return e;
}
/** * 将栈顶元素出栈. * @return 有元素出栈则返回出栈的元素, 否则返回 NULL. */
public Object pop(){
// 空栈
if(isEmpty()){
return null;
}
// 获取要出栈的栈顶结点存储的元素,以便于返回
Object result = top.data;
// 将栈顶指针的引入复制给 node
Node node = top;
// 栈顶指针指向栈顶结点的后继结点
top = top.next;
// 释放删除的结点
node.data = null;
node.next = null;
node = null;
// 元素 - 1
count --;
return result;
}
/** * 返回是否是空栈. * @return 返回 true,表示栈是空栈. */
public boolean isEmpty(){
return top == null;
}
链栈的进栈与出栈都很简单,并且时间复杂度都是 O(1)。效率很高。
如果在栈的使用过程中,元素的变化不可预料,有时很小,有时很大,那么这时用链栈更适合,因为链栈的大小理论上没有限制,除非内存满了。如果元素已知,那么可以用栈的顺序存储结构,在刚开始就初始化好数组的大小。
我们虽然可以直接用数组或链表直接实现功能,但是栈这种数据结构的引入,简化了程序设计的问题,划分了不同关注层次,使得思考范围缩小,更加聚焦于我们要解决问题的核心。
栈有一个很重要的作用,就是在程序设计语言中实现了递归。
那么什么是递归呢?举个兔子的例子,问题是:
如果兔子在出生两个月后,就有繁殖能力,一对兔子每个月能生一对小兔子。假设所有兔子都不死,那么一年后可以繁殖多少对兔子呢?
没错,这是一个经典的斐波那契数列问题。这个数列有一个很明显的特点,就是前面两项的和等于后一项。
如果我们用编程来解决这个问题,可以用如下两种方法:
public static void fbi(int i){
int result[] = new int[i + 1];
// 两个月前没有繁殖能力
result[0] = 0;
result[1] = 1;
System.out.println("第 0 月有 " + result[0] + " 对兔子。");
System.out.println("第 1 月有 " + result[1] + " 对兔子。");
//前面两项的和等于后一项
for (int j = 2; j <= i; j++) {
result[j] = result[j - 1] + result[j - 2];
System.out.println("第 " + j + " 月有 " + result[j] + " 对兔子。");
}
}
public static int fbi2(int i){
if(i < 2){
return i == 1 ? 1 : 0;
}
return fbi2(i - 1) + fbi2(i - 2);
}
对比两种实现,可以发现第二种明显要简洁的多。而第二种实现所用的函数就是递归函数:
直接调用自己或通过一系列的调用语句间接地调用自己的函数,称作递归函数。每个递归因至少有一个条件,满足时递归不再进行。
递归的缺点就是,大量的递归会建立额外的函数,会消耗大量的时间和内存。所以我们应该视不同的情况来选择用哪种实现。
对于我们所用的编程语言而言,对于递归这种,在前进时,每一层递归,函数的局部变量、参数值以及返回地址都被压入栈中。在退回阶段,位于栈顶的局部变量、参数值和返回地址被弹出,用于返回调用层次中执行代码的其余部分。这种情况编译器用栈来实现更贴合。
四则运算,小学数学,也就是加减乘除。在上小学的时候,老师总跟我们说,先乘除,后加减。
那么如果我们要用编程语言来实现四则运算,那该如何去计算那些带括号的复杂的四则运算呢?
我们可以用后缀表达式来计算,比如下面的这个四则运算:
9 + ( 3 - 1)* 3 + 10 / 2
如果用后缀表达式就是:
9 3 1 - 3 * + 10 2 / +
后缀表达式因为没有了括号,所以看起来我们会感觉怪怪的。虽然我们不喜欢,但是计算机喜欢啊,计算机对后缀表达式的计算是:
从左到右遍历表达式的每个数字和符号,遇到是数字就进栈,遇到是符号,就将处于栈顶两个数字出栈,进行运算,运算结果进栈,一直到获得最终的结果。
我们平时所用的标准四则运算表达式,叫做中缀表达式。意思也就是运算符号在两数字的中间。将中缀表达式转成后缀表达式的规则是:
从左到右遍历中缀表达式的每个数字和符号,若是数字就输出,即称为后缀表达式的一部分;若是符号,则判断其与栈顶符号的优先级,是右括号或优先级底于栈顶符号(乘除优先加减)则栈顶元素依次出栈并输出,并将当前符号进栈,一直到最终输出后缀表达式为止。
队列是只允许在一端进行插入操作,而在另一端进行删除操作的线性表。
队列是一种先进先出的(First In First Out)线性表,简称 FIFO。允许插入的一端称为队尾,删除的一端称为队头。
在生活以及程序中,其实很多地方都用到队列结构,比如说打客服电话,键盘打字等等。
ADT 队列(Queue)
Data
同线性表。元素具有相同的类型,相邻元素具有前驱和后继关系。
Operation
initQueue(*Q):初始化,建立一个空队列 Q。
destroyQueue(*Q):若队列 Q 存在,销毁它。
clearQueue(*Q):将队列 Q 清空。
queueEmpty(Q):若队列 Q 为空,则返回 true,否则返回 false。
getHead(Q, *e):若队列 Q 存在并不为空,用 e 返回队列 Q 的队头元素。
enQueue(*Q, *e):若队列 Q 存在,插入新元素 e 到队列 Q 中并成为队尾元素。
deQueue(*Q, *e):删除队列 Q 中队头元素,并用 e 返回其值。
queueLength(Q):返回队列 Q 中元素个数。
线性表有顺序和链表两种存储结构,队列也是如此。只不过队列的顺序存储结构有一个不足。先来举个例子,比如我们现在有一个队列,队列中有 a、b、c 三个元素,那么就要创建一个大于这三个元素的数组,并将元素依次从队头存储到数组当中,数组下标为 0 的一端为队头,队尾为最后一个元素末尾。如果现在要入队一个新元素 d,那么就要在队尾处添加元素 d,添加完后就是:a、b、c、d 了。不需要移动任何元素,因此时间复杂度为 O(1)。但是如果是出列的话,那么数组下标 0 处的元素就得移除掉,那么后续的元素就需要依次移动,填满这个空缺的位置。因此,其时间复杂度为 O(n)。
为了避免出队列时需要移动后续所有元素,我们可以用循环队列来解决。
循环队列:队列头尾相接的顺序存储结构称为循环队列。
为了实现一个循环队列,我们并不需要限制队头在数组下标 0 的位置。我们可以引入两个指针,分别为 front 指针,指向队头元素,rear 指针,指向队尾元素的下一个位置,这样当 front 等于 rear 时,此队列不是还剩下一个元素,就是空队列。
举个例子,假设现在有一个长度为 5 的空数组,那么 front 和 rear 指针则都指向下标为 0 的位置。如果此时存入 a、b、c、d 四个元素,那么此时的 front 指针则依然在下标 0 的位置,rear 指针则在下标为 4 的位置上,也就是下标 3 的队尾元素 d 的下一个位置上。
如果此时将 a、b 两个元素出队,则 front 指针就在下标为 2,也就是元素 c 的位置上。rear 不变。
如果此时元素 e 入队了,那么 e 的位置就在下标为 4 的位置上,front 指针不变,rear 指针则要重新指向下一个位置了,但是下标为 4 的位置已经是数组的最后一个位置了,于是此时 rear 指针则从头开始,指向了下标为 0 的空位置。此时如果再入队 f、g 两个元素,那么 rear 指针则和 front 指针一样,同样指向了下标为 2 的位置。此时表示队列已满。
那么问题就来了,就是当 front 和 rear 指针相等时,既可以是队列已满,也可能是个空队列。该如何区分呢?
有两种方法,一种是设置标志变量 flag,当 flag == 0 并且 front == rear 时,队列为空,当 flag == 1 并且 front == rear 时,队列已满。
另一种是,我们在队列中保留一个元素空间。不填满它。也就说,队列满时,数组中还有一个空闲的单元。
假设 queueSize 为队列的最大长度,那么队列满的条件是:(rear + 1) % queueSize == front。
计算队列长度的公式为:(rear - front + queueSize) % queueSize。
实现循环队列的代码实例如下:
/** * 循环队列. */
public class LoopQueue<E> {
/** * 最大要存储的元素. (实例代码, 并没有实现对数组的动态扩容, 如要实现动态扩容, 可参照 ArrayList 的源码做法) */
private static final int MAX_SIZE = 10;
/** * 存储队列元素的数组. */
protected Object[] element;
/** * 头指针. 指向队头元素. */
protected int front;
/** * 尾指针. 若队列不空, 则指向队尾元素的下一个位置. */
protected int rear;
public LoopQueue(){
// 初始化数组, 指针
element = new Object[MAX_SIZE];
front = 0;
rear = 0;
}
/** * 返回队列的长度. 也就是存储元素的个数. * @return 队列长度. */
public int length(){
return (rear - front + MAX_SIZE) % MAX_SIZE;
}
/** * 添加元素 data 到队列中. * @param data 要添加的元素. * @return 添加成功, 返回元素 data, 否则返回 NULL. */
public E add(E data){
// 队列已满
if((rear + 1) % MAX_SIZE == front){
return null;
}
// 将元素 data 赋值给队尾, 也就是指针 rear 指向的位置
element[rear] = data;
// rear 指向往后移动一个位置
rear = (rear + 1) % MAX_SIZE;
return data;
}
/** * 队头元素出队. * @return 移除成功返回被移除的元素, 否则返回 NULL. */
public Object remove(){
// 空队列
if(rear == front){
return null;
}
// 获取要移除的队头元素
Object data = element[front];
// 移除头元素
element[front] = null;
// front 指针往后移动一个位置
front = (front + 1) % MAX_SIZE;
// 返回移除掉的队头元素
return data;
}
}
队列的链式存储结构,其实就是线性表的单链表,只不过它只能尾进头出而已,我们把它简称为链队列。
链队列的代码示例如下:
/** * 队列的链表结构, 简称链队列. * * 该队列的实现是把队头指针直接指向队列的头结点, 书中的实现是把指针的 next 指针指向队头结点. * 感觉这种更容易理解一些. */
public class LinkedQueue<E> {
/** * 队头指针. 指向链队列的头结点. */
protected Node front;
/** * 队尾指针. 指向链队列的终结点. */
protected Node rear;
/** * 队列大小. 表示存储元素的个数. */
protected int size;
public LinkedQueue(){
front = null;
rear = null;
size = 0;
}
/** * 将元素 data 入队. * @param data 要入队的元素. * @return 入队成功, 返回入队的元素 data. */
public E add(E data){
// 创建新结点
Node node = new Node();
node.data = data;
node.next = null;
// 入队的是否是第一个元素
if(isEmpty()){
// 是第一个元素, 队头和队尾指针指向新结点
rear = node;
front = rear;
}else{
// 新结点赋值给队尾结点的后继结点
rear.next = node;
// 队尾指针指向新结点
rear = node;
}
// 长度 + 1
size ++;
// 返回添加的元素
return data;
}
/** * 删除队头元素, 并返回. * @return 删除成功, 返回被删除的元素. 否则返回 NULL. */
public Object remove(){
// 空队列
if(isEmpty()){
return null;
}
// 获取要移除的队头结点
Node oldNode = front;
Object data = oldNode.data;
// 将队头指针指向的队头结点重新指向其后继结点
front = oldNode.next;
// 解除与链表关联
oldNode.next = null;
oldNode = null;
// 长度 - 1
size --;
// 返回被移除的元素
return data;
}
/** * 返回是否是空队列. 当 size 为 0 时,表示是一个空队列. * @return 返回 true, 表示当前队列是空队列. 否则相反. */
public boolean isEmpty(){
return size == 0;
}
/** * 返回队列的长度. * @return 队列长度 */
public int size(){
return size;
}
/** * 结点. * @param 元素类型. */
public class Node<E>{
/** 存储的元素 */
E data;
/** 指向下一个结点指针 */
Node next;
}
}
对于循环队列和链队列的比较,在时间上,它们的基本操作都是常数时间,也就是 O(1),不过循环队列是事先申请好空间,使用期间不释放。链队列则是需要的时候申请,每次在申请和释放结点的时候也会存在时间上的开销。如果出队和入队频繁,则两者还是有细微差别。
在空间上,循环队列必须有一个固定的长度,所以就有了元素个数限制和空间浪费的问题。链队列则没有这个问题,理论上可存储的元素是无限的。尽管额外增加了指针域,但是也是可以接受的。
所以我的推荐是,在确定队列大小的情况下,使用循环队列,否则使用链队列。
在该篇中,我们学习了栈、队列。知道了两者都是特殊的线性表,相当于对线性表也进行了一遍复习。
栈(Stack)是仅限在线性表表尾进行插入和删除操作的线性表。
队列(Queue)是只允许在一端进行插入操作,另一端进行删除操作的线性表。
它们都可以使用顺序存储结构来实现,但是会有一些弊端,不过也可以通过各自的技巧来解决,像两栈共享空间、循环队列。
当然它们也可以使用链式存储结构来实现。至于哪种实现更好,就取决于在实际使用中的场景了。