定义:栈是限定仅在表尾进行插入和删除操作的线性表
。我们把允许插入和删除的一端称为栈顶,另一端称为栈底,不含任何数据元素的栈称为空栈。栈又称后进先出(Last In First Out)的线性表,简称LIFO结构。
栈的插入操作,叫作进栈(Push),也称压栈、入栈。栈的删除操作,叫作出栈(Pop),也有的叫作弹栈。示意图如下:
栈本身就是一个线性表,所以关于线性表的操作,对栈基本上也是符合的,只是在某些方面会有少许变化,以下是栈的抽象数据类型:
ADT 栈(stack)
Data
同线性表。元素具有相同的类型,相邻元素具有前驱和后继关系。
Operation
InitStack(*S): 初始化操作,建立一个空栈。
DestroyStack(*S): 若栈存在,则销毁它。
ClearStack(*S): 将栈清空。
StackEmpty(S): 若栈为空,返回true,否则返回false。
GetTop(S,*e): 若栈存在且非空,用e返回S的栈顶元素。
Push(*S,e): 若栈S存在,插入新元素e到栈S中并成为栈顶元素。
Pop(*S,*e): 删除栈S中栈顶元素,并用e返回其值。
StackLength(S): 返回栈S的元素个数。
endADT
栈的顺序存储结构其实是线性表顺序存储结构的简化,我们简称为顺序栈。在没有指针的高级程序语言中,我们使用数组来实现线性表,栈也是同样的道理,不过因为栈后进先出的特点,我们定义下标为0的一端作为栈底,因为首元素在栈底,变化最小。
栈的结构定义如下:
//SElemType类型根据实际情况而定,这里假设为int
typedef int SElemType;
typedef struct{
SElemType data[MAXSIZE];
//用于栈顶指针
int top;
}SqStack;
压栈(入栈)
压栈的示意图如上,下面我们来看下push的代码算法实现:
//时间复杂度为O(1)
//插入元素e为新的栈顶元素
Status Push(SqStack *S,SElemType e){
//栈满
if(S->top == MAXSIZE - 1){
return ERROR;
}
//栈顶指针+1
S->top++;
//将新插入元素赋值给栈顶空间
S-data[S->top] = e;
return OK;
}
弹栈(出栈)
弹栈的示意图如上,下面我们来看下pop的代码算法实现:
//时间复杂度为O(1)
//若栈不空,则删除S的栈顶元素,用e返回其值,并返回OK;否则返回ERROR
Status Push(SqStack *S,SElemType e){
//空栈
if(S->top == -1){
return ERROR;
}
//将要删除的栈顶元素赋值给e
*e = S-data[S->top];
//栈顶指针-1
S->top--;
return OK;
}
栈的顺序存储结构有一个很大的缺陷,就是必须事先确定数组存储空间大小,万一不够用了,就需要编程手段来扩展数组的容量,非常麻烦。对于一个栈,我们也只能尽量考虑周全,设计出合适大小的数组来处理,但对于两个相同类型的栈(前提)
,我们却可以做到最大限度地利用其实现开辟的存储空间来进行操作,这就是两栈共享空间。
做法如下,数组有两个端点,两个栈有两个栈底,让一个栈的栈底为数组的始端,即下标为0处,另一个栈为数组的末端,即下标为数组长度n-1处。这样,两个栈如果增加元素,就是两端点向中间延伸。
两栈共享空间的结构的代码如下:
//两栈共享空间结构
typedef struct{
SElemType data[MAXSIZE];
//栈1栈顶指针
int top1;
//栈2栈顶指针
int top2;
}SqDoubleStack;
两栈共享空间的push方法:
//插入元素e为新的栈顶元素。除了插入元素值外,还需要有一个判断是栈1还是栈2的栈号stackNumber
Status push(SqDoubleStack *S,SElemType e,int stackNumber){
//栈已满,不能再push新元素了
if(S->top1+1 == S->top2){
return ERROR;
}
//栈1有元素进栈
if(stackNumber == 1){
S->data[++S->top1] = e;
}
//栈2有元素进栈
else if(stackNumber == 2){
S->data[--S->top2] =e;
}
return OK;
}
两栈共享空间的pop方法:
//若栈不空,则删除S的栈顶元素,用e返回其值,并返回OK;否则返回ERROR
Status pop(SqDoubleStack *S,SElemType *e,int stackNumber){
if(stackNumber == 1){
if(S->top1 == -1){
//说明栈1已经是空栈,溢出
return ERROR;
}
//将栈1的栈顶元素出栈
*e = S->data[S->top1--];
}else if(stackNumber == 2){
if(S->top2 == MAXSIZE){
//说明栈2已经是空栈,溢出
return ERROR;
}
//将栈2的栈顶元素出栈
*e = S->data[S->top2++];
}
return OK;
}
事实上,使用这样的数据结构,通常都是当两个栈的空间需求有相反关系时,也就是一个栈增长时另一个栈在缩短的情况。
栈的链式存储结构,简称链栈。由于单链表有头指针,而栈顶指针也是必须的,所以比较好的办法是把栈顶放在单链表的头部(如下图)。因为有了栈顶在头部,单链表中比较常用的头结点也就失去意义,通常对于链栈来说,是不需要头结点的。
链栈基本上不存在栈满的情况,除非内存已经没有可以使用的空间。但对于空栈来说,链表原定义是头指针指向空,那么链栈的空其实就是top=NULL。
链栈的结构代码如下:
typedef struct StackNode{
SElemType data;
struct StackNode *next;
}StackNode,*LinkStackPtr;
typedef struct LinkStack{
LinkStackPtr top;
int count;
}LinkStack;
1.进栈(上图左侧)
进栈即push操作,假设元素值为e的新结点为s,top为栈顶指针,代码实现如下:
//插入元素e为新的栈顶元素
Status Push(LinkStack *S,SElemType e){
LinkStackPtr s = (LinkStackPtr)malloc(sizeof(StackNode));
s->data = e;
//把当前的栈顶元素赋值给新结点的直接后继
s->next = S->top;
//将新结点s赋值给栈顶指针
S->top = s;
S->count++;
return OK;
}
2.出栈(上图右侧)
出栈即pop操作,假设变量p用来存储要删除的栈顶结点,将栈顶指针下移一位,最后释放p,代码实现如下:
//若栈不空,则删除S的栈顶元素,用e返回其值,并返回OK;否则返回ERROR
Status Pop(LinkStack *S,SElemType *e){
LinkStackPtr p;
if(StackEmpty(*S)){
return ERROR;
}
*e = S->top->data;
//将栈顶结点赋值给p
p = S->top;
//使得栈顶指针下移一位,指向后一结点
S->top = S->top->next;
free(p);
S->count--;
return OK;
}
如果栈的使用过程中元素变化不可预料,有时很大,有时很小,那么最好使用链栈;反之,如果它的变化在可控范围之内,最好使用顺序栈。
定义:队列是只允许一端进行插入操作,而在另一端进行删除操作的线性表
。允许插入的一端称为队尾,允许删除的一端称为队头。
队列是**先进先出(First In First Out)**的线性表
假设队列是q={a1,a2,…,an},那么a1就是队头元素,而an是队尾元素。删除时总是从a1开始,而插入时,列在最后。
队列的操作基本和线性表差不多,不同的就是插入数据只能在队尾,删除数据只能在队头。
ADT 队列(Queue)
Data
同线性表。元素具有相同的类型,相邻元素具有前驱和后继关系。
Operation
InitQueue(*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的元素个数。
endADT
假设一个队列有n个元素,则顺序存储的队列需建立一个大于n的数组,并把队列的所有元素存储在数组的前n个单元,数组下标为0的一端即是队头。现在我们来研究一下关于入队和出队的流程:
从上面我们可以看出根据正常的队列定义,我们入队的时间复杂度为O(1),但是出队的时间复杂度确实O(n),原因是队头是下标为0的位置,每一次在队头出队一个元素,后面的元素就要全部向前移一格。这样来看性能是很不好的,有什么方式来优化呢?很简单,就是我们不再限制把队列中的所有元素存在数组的前
n个单元,具体如何实现呢,我们来看一下一个新的流程图:
在这里我们引入了两个指针,分别是front指向队头元素;rear指向队尾的下一个位置。在出队一个元素之后,队头元素后移一位,此时出队的时间复杂度由O(n)优化为了O(1),但是当前这个模式有一个问题,那就是假溢出
。为了解决假溢出问题,我们的循环队列出来了。循环队列
的定义很简单:我们把队列的这种头尾相接的顺序存储结构称为循环队列。
什么是假溢出呢?我们以上图为例,当前队列的总容量为5,我们先移除下标0、1中的元素,然后填充下标2、3、 4中的元素,此时队头指针指向下标2,那队尾呢?好像已经越界了,这就是假溢出,因为实际上当前数组中下标0、1还是可以填充队列元素的。
根据循环队列的定义,我们把上面出现假溢出的案例的流程图补上:
这样我们就圆满的解决了假溢出的bug。但是不要高兴太早,新bug来啦,那就是我们该如何判断队列什么时候是空,什么时候是满?
为了解决这个问题,我们把队列空和队列满的条件做了一点修改:
(1)队列空:front=rear
(2)队列满:若数组中海油一个空闲单元,我们就认为队列满(见下图)。
在队列满这里,有一点需要注意,那就是rear既可能比front大,也可能比front小,所以尽管它们只相差一个位置时就是满的,但也可能相差整整一圈,假设队列的最大长度为QueueSize,那么队列满的判断条件是:
(rear+1) % QueueSize == front
同时,在计算队列的实际长度时也存在rear > front和rear < front,通用的计算队列长度公式为:
(rear - front + QueueSize) % QueueSize
下面我们开始介绍循环队列的结构和一些常见操作。
##循环队列的顺序存储结构
//QElemType类型根据实际情况而定,这里假设为int
typedef int QElemType;
//循环队列的顺序存储结构
typedef struct{
QElemType data[MAXSIZE];
//头指针
int front;
//尾指针,若队列不为空,指向队尾元素的下一个位置
int rear;
}
1.初始化
//初始化空队列
Status InitQueue(SqQueue *Q){
Q->front = 0;
Q->rear = 0;
return OK;
}
2.队列长度
//返回队列Q的元素个数
int QueueLength(SqQueue Q){
return (Q.rear-Q.font+MAXSIZE)%MAXSIZE;
}
3.入队
//若队列Q未满,则插入元素e为Q新的队尾元素
Status EnQueue(SqQueue *Q,QElemType e){
//队列已满
if((Q->rear+1)%MAXSIZE == Q->front){
return ERROR;
}
//将元素e赋值给队尾
Q->data[Q->rear] = e;
//rear指针后移一位,若到最后则转到数组头部
Q->rear=(Q->rear+1)%MAXSIZE;
return OK;
}
4.出队
//若队列Q不空,则删除Q中队头元素,用e返回其值
Status EnQueue(SqQueue *Q,QElemType *e){
//队列为空
if(Q->rear == Q->front){
return ERROR;
}
//将队头元素赋值给e
*e = Q->data[Q->front];
//front指针后移一位,若到最后则转到数组头部
Q->front = (Q->front+1)%MAXSIZE;
return OK;
}
从上面的介绍我们可以发现,单是顺序存储,若不是循环队列,算法的时间性能不高,但循环队列又面临着数组可能会溢出的问题,所以我们还需要研究一下队列的链式存储结构。
##队列的链式存储结构及实现
队列的链式存储结构,其实就是单链表,只不过它只能尾进头出而已,我们把它称为链队列。为了操作上的方便,我们将队头指针front指向链队列的头结点,将队尾指针rear指向终端结点:
链队列的结构:
//QElemType类型根据实际情况而定,这里假设为int
typedef int QElemType;
//结点结构
typedef struct QNode{
QElemType data;
struct QNode *next;
}QNode,*QueuePtr;
//队列的链表结构
typedef struct{
QueuePtr front,near;
}LinkQueue;
//插入元素e为Q的新的队尾元素
Status EnQueue(LinkQueue *Q,QElemType e){
//s即为新插入的元素e
QueuePtr s = (QueuePtr)malloc(sizeof(QNode));
if(!s){
//存储分配失败
exit(OVERFLOW);
}
s->data = e;
s->next = NULL;
//把拥有元素e新结点s赋值给原队尾结点的后继
Q->rear->next = s;
//把当前的s设置为队尾结点,rear指向s
Q->rear = s;
return OK;
}
//若队列不空,删除Q的队头元素,用e返回其值,并返回OK,否则返回ERROR
Status DeQueue(LinkQueue *Q,QElemType *e){
QueuePtr p;
//此时是空队列
if(Q->front == Q->rear){
return ERROR;
}
//将欲删除的队头结点暂存给p
p = Q->front->next;
//将欲删除的队头结点的值赋值给e
*e = p->data;
//将原队头结点后继p->next赋值给头结点后继
Q->front->next = p->next;
if(Q->rear == p){
Q->rear = Q->front;
}
free(p);
return OK;
}
对于循环队列和链队列的比较,可以从两方面考虑,时间上,它们的基本操作都是常数时间,即O(1),不过循环队列是事先申请好空间,使用期间不释放,而对于链队列,每次申请和释放结点存在一定的时间开销;空间上,循环队列必须有一个固定的长度,所以就有了空间浪费的问题,而链队列不存在这个问题,尽管它需要一个指针域,会产生一些空间上的开销,但也可以接受。