四、栈与队列
1.栈的定义
栈(stack)是限定仅在表尾进行插入和删除操作的线性表。
我们把允许插入和删除的一端称为栈顶(top),另一端称为栈底(bottom),不含任何数据元素的栈称为空栈。栈又称为后进先出(Last In First Out)的线性表,简称LIFO结构。
栈的插入操作,叫做进栈,也称压栈、入栈。
栈的删除操作,叫做出栈,也有的叫作弹栈。
2.栈的抽象数据类型
ADT 栈(stack)
Data
同线性表。元素具有相同的类型,相邻元素具有前驱和后继关系。
Operation
InitStack(*S): 初始化操作,建立一个空栈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的元素个数
ednADT
由于栈本身就是一个线性表,所以上一章我们讨论的线性表的顺序存储和链式存储,对于栈来说,同样适用。
3.栈的顺序存储结构
3.1 定义
既然栈是线性表的特例,那么栈的顺序存储其实也是线性表顺序存储的简化,我们简称为顺序栈。
#define MAXSIZE 20 // 存储空间初始分配量
typedef int SElemType; // SElemType类型根据实际情况而定,这里假定为int
typedef struct {
SElemType data[MAXSIZE]; // 数组存储数据元素,最大值为MAXSIZE
int top; // 用于栈顶指针
}SqStack;
3.2 进栈操作
/* 插入元素e为新的栈顶元素 */
Status Push (SqStack *S, SElemType) {
if (S->top == MAXSIZE - 1) // 栈满
return ERROR;
S->top++; // 栈顶指针加1
S->data[S->top] = e; // 将新插入元素赋值给栈顶空间
return OK;
}
3.3 出栈操作
/* 删除S的栈顶元素,用e返回 */
Status Pop(SqStack *S, SElemType *e) {
if (S->top == -1)
return ERROR;
*e = S->data[S->top];
S->top--;
return OK;
}
3.4 两栈共享空间
3.4.1 定义
栈1和栈2的栈顶指针top1和top2分别从数组的两端开始,向中间靠拢,top1和top2见面时,栈满。
/* 两栈共享空间结构 */
typedef struct {
SElemType data[MAXSIZE];
int top1;
int top2;
}SqDoubleStack;
3.4.2 进栈操作
对于两栈共享空间的push方法,我们除了要插入元素值参数外,还需要有一个判断是栈1还是栈2的栈号参数stackNumber。
/* 插入元素e为新的栈顶元素 */
Status Push(SqDoubleStack *S, SElemType e, int stackNumber) {
if (S->top1+1 == S->top2) // 栈满
return ERROR;
if (stackNumber == 1)
S->data[++S->top1] = e;
else if (stackNumber == 2)
S->data[--S->top2] = e;
return OK;
}
3.4.3 出栈操作
/*c删除S的栈顶元素,用e返回 */
Status Pop(SqDoubleStack *S, SElemType *e, int stackNumber) {
if (stackNumber == 1) {
if (S->top1 == -1)
return ERROR;
*e = S->data[S->top1--];
} else if (stackNumber == 2) {
if (S->top2 == MAXSIZE)
return ERROR;
*e = S->data[S->top2++];
}
return OK;
}
事实上,使用这样的数据结构,通常都是当两个栈的空间需求有相反关系时,也就是一个栈增长时另一个栈在缩短的情况。否则两个栈都在不停地增长,那很快就会因栈满而溢出了。
4.栈的链式存储结构
4.1 定义
typedef struct StackNode {
// 结点结构
SElemType data;
struct StackNode *next;
} StackNode, *LinkStackPtr;
typedef struct LinkStack {
// 栈的链表结构
LinkStackPtr top;
int count;
} LinkStack;
4.2 进栈操作
/* 插入元素e为新的栈顶元素 */
Status Push(LinkStack *S, SElemType e) {
LinkStackPtr s = (LinkStackPtr)malloc(sizeof(StackNode));
s->data = e;
s->next = S->top;
S->top = s;
S->count++;
return OK;
}
4.3 出栈操作
/* 删除栈顶元素,用e返回其值 */
Status Pop(LinkStack *S,SElemType *e) {
LinkStackPtr p;
if (StackEmpty(*S))
return ERROR;
*e = S->top->data;
p = S->top;
S->top = S->top->next; // 使栈顶指针下移一位,指向后一结点,如图中④
free(p);
S->count--;
return OK;
}
小结:如果栈的使用过程中元素变化不可预料,有时很小,有时非常大,那么最好是用链栈,反之,如果它的变化在可控范围内,建议使用顺序栈会更好一些。
5.栈的作用
栈的引入简化了程序设计的问题,划分了不同关注层次,使得思考范围缩小,更加聚焦于我们要解决的问题核心。反之,像数组等,因为要分散精力去考虑数组的下标增减等细节问题,反而掩盖了问题的本质。
6.栈的应用-递归
6.1 斐波拉契数列的两种实现
第一种,迭代
int main() {
int i;
int a[40];
a[0] = 0;
a[1] = 1;
printf("%d", a[0]);
printf("%d", a[1]);
for (i=2; i<40; i++) {
a[i] = a[i-1] + a[i-2];
printf("%d",a[i]);
}
return 0;
}
第二种,递归
int Fbi(int i) {
if (i<2)
return i==0 ? 0 : 1;
return Fbi(i-1) + Fbi(i-2);
}
int main() {
int i;
for (int i=0; i<40; i++)
printf("%d", Fbi(i));
return 0;
}
模拟Fbi(5)的实现过程:
6.2 递归的定义
我们把一个直接调用自己或通过一系列的调用语句间接地调用自己的函数,称作递归函数。
每个递归定义必须至少有一个条件,满足时递归不再进行,即不再引用自身而是返回值退出。
对比两种实现斐波拉契的代码。迭代和递归的区别是:迭代使用的是循环结构,递归使用的是选择结构。递归能使程序的结构更清晰、更简洁、更容易让人理解,从而减少读代码的时机。但是大量的递归调用会建立函数的副本,会消耗大量的时间和内存。迭代则不需要反复调用函数和占用额外的内存。因此我们应该视不同情况选择不同的代码实现方式。
7.栈的应用-四则运算表达式求值
7.1 后缀(逆波兰)表示法定义
逆波兰表示:一种不需要括号的后缀表达式。
如:9+(3-1)×3+10÷2 表示为 9 3 1 - 3 * + 10 2 / +
7.2 后缀表达式计算结果
后缀表达式:9 3 1 - 3 * + 10 2 / +
规则:从左到右遍历表达式的每个数字和符号,遇到是数字就进栈,遇到是符号就将处于栈顶的两个数字出栈,进行运算,运算结果进栈,一直到最终获得结果。
-
初始化一个空栈。此栈用来对要运算的数字进出使用。
-
后缀表达式中前三个都是数字,所以9、3、1进栈。
-
接下来是“-”,所以将栈中的1出栈作为减数,3出栈作为被减数,并运算3-1得到2,再将2进栈。
-
接着是数字3进栈。
-
后面是“*”,也就意味着栈中3和2出栈,2与3相乘,得到6,并将6进栈。
-
下面是“+”,所以栈中6和9出栈,9与6相加,得到15,将15进栈。
-
接着是10与2两个数字进栈。
-
接下来是“/”,因此,栈顶的2与10出栈,10与2相除,得到5,将5进栈。
-
最后一个符号“+”,所以15与5出栈并相加,得到20,将20进栈。
-
结果是20出栈,栈变为空。
7.3 中缀表达式转后缀表达式
我们把平时所用的标准四则运算表达式,即“9+(3-1)×3+10÷2”叫做中缀表达式。
这里要把中缀表达式“9+(3-1)×3+10÷2”转化为后缀表达式“ 9 3 1 - 3 * + 10 2 / +”。
规则:从左到右遍历中缀表达式的每个数字和符号,若是数字就输出,即成为后缀表达式的一部分;若是符号,则判断其与栈顶符号的优先级,是右括号或优先级不高于栈顶符号(乘除优先于加减),则栈顶元素依次出栈并输出,并将当前符号进栈,一直到最终输出后缀表达式为止。
-
初始化一个空栈,用来对符号进出栈使用。
-
第一个字符是数字9,输出9,后面是符号“+”,进栈。
-
第三个字符是“(”,依然是符号,因其只是左括号,还未配对,故进栈。
-
第四个字符是数字3,输出,总表达式为9 3,接着是“-”,进栈。
-
接下来是数字1,输出,总表达式为9 3 1,后面是符号“)”,此时,我们需要去配对此前的“(”,所以栈顶依次出栈,并输出,直到“(”出栈为止。此时左括号上方只有“-”,因此输出“-”。总的输出表达式为9 3 1 - 。
-
紧接着是符号“×”,因为此时的栈顶符号为“+”号,优先级低于“×”,因此不输出,“*”进栈。接着是数字3,输出,总的表达式为9 3 1 - 3。
-
之后是符号“+”,此时当前栈顶元素“*”比这个“+”的优先级高,因此栈中元素出栈并输出(没有比“+”号更低的优先级,所以全部出栈),总输出表达式为9 3 1 - 3 * +。然后将当前这个符号“+”进栈。
-
紧接着数字10,输出,总表达式变为9 3 1 - 3 * + 10。后面是符号“÷”,所以“/”进栈。
-
最后是一个数字2,输出,总表达式为9 3 1 - 3 * + 10 2。
-
因已经到最后,所以将栈中符号全部出栈并输出。最终输出的后缀表达式为9 3 1 - 3 * + 10 2 / +。
小结:
想要让计算机具有处理我们通常的标准(中缀)表达式的能力,最重要的就是两步:
- 将中缀表达式转化为后缀表达式。
- 将后缀表达式进行运算得出结果。
8.队列的定义
队列(queue)是只允许在一端进行插入操作,而在另一端进行删除操作的线性表。
队列是一种先进先出(First In First Out)的线性表,简称FIFO,允许插入的一端称为队尾,允许删除的一端称为队头。
9.队列的抽象数据类型
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的元素个数
endADT
线性表有顺序存储和链式存储,栈是线性表,所以有这两种存储方式。同样,队列作为一种特殊的线性表,也同样存在这两种存储方式。
10.队列的顺序存储结构->循环列队
10.1 队列顺序存储的不足
我们假设一个队列有n个元素,则顺序存储的队列需建立一个大于n的数组,并把队列的所有元素存储在数组的前n个单元,数组下标为0的一端即是队头。所谓的入队列操作,其实就是在队尾追加一个元素,不需要移动任何元素,因此时间复杂度为O(1)。
与栈不同的是,队列元素的出列是在队头,即下标为0的位置,那也就意味着,队列中的所有元素都得向前移动,以保证队列的队头不为空,时间复杂度为O(n)。
再想想,其实出列时duck不必全部移动,如果不去限制队列的元素必须存储在数组的前n个单元这一条件,出列的性能就会大大增加。也就是说,队头不需要一定在下标为0的位置。
为了避免当只有一个元素时,队头和队尾重合使处理变得麻烦,所以引入两个指针,front指针指向队头元素,rear指针指向队尾的下一个位置,这样当front等于rear时,此队列不是剩一个元素,而是空队列。
出队a1、a2,则front指针指向下标为2的位置,rear不变,再入队a5,此时front指针不变,rear指针移动到数组之外,出问题了!!!
问题还不止于此。假设这个队列的总个数不超过5个,但目前如果接着入队的话,因数组末尾元素已经占用,再向后加,就会产生数组越界的错误,可实际上,我们的队列在下标为0和1的地方还是空闲的。我们把这种现象叫做“假溢出”。
10.2 循环队列定义
解决溢出的办法就是后面满了,就再从头开始,也就是头尾相接的循环。我们把队列的这种头尾相接的顺序储存结构称为循环队列。
上图的rear改为指向下标为0的位置,这样就不会造成指针指向不明的问题了。
接着入队a6,将它放置于下标为0处,rear指针指向下标为1处。若再入队a7,则rear指针就与front指针重合,同时指向下标为2的位置。
此时问题又来了,我们刚才说,空队列时,front等于rear,现在当队列满时,也是front等于rear,那么如何判断此时的队列究竟是空还是满呢?
办法一是设置一个标志变量flag,当front等于rear,且flag=0时为队列空,当front等于rear,且flag=1时为队列满。
办法二是当队列空时,条件就是front==rear,当队列满时,我们修改其条件,保留一个元素空间。也就是说,队列满时,数组中还是有一个空闲单元。如图,我们就认为队列满。
我们重点来讨论第二种方法,由于rear可能比front大,也可能比front小,所以尽管它们只相差一个位置时就是满的情况,但也可能是相差整整一圈。所以若队列的最大尺寸为QueueSize,那么队列满的条件是 (rear+1)%QueueSize == front(取模“%”的目的是为了整合rear和front大小为一个问题)。此条件可适用于上图的两种情况。
另外,通用的计算队列长度公式为:(rear+QueueSize)%QueueSize
循环队列的顺序存储结构代码:
typedef int QElemType; // QElemType 类型根据实际情况而定,这里假设为int
typedef struct {
QElemType data[MAXSIZE];
int front; // 头指针
int rear; // 尾指针
}SqQueue;
循环队列的初始化代码:
/* 初始化一个空队列 */
Status InitQueue(SqQueue *Q) {
Q->front=0;
Q->rear=0;
return OK;
}
循环队列求队列长度代码:
/* 返回Q的元素个数,也就是队列的当前长度 */
int QueueLength(SqQueue Q) {
return (Q.rear-Q.front+MAXSIZE)%MAXSIZE;
}
循环队列的入队操作:
Status EnQueue(SqQueue *Q, QElemType e) {
if ((Q->rear+1)%MAXSIZE == Q->front) // 队列满的判断
return ERROR;
Q->data[Q->rear] = e;
Q->rear = (Q->rear+1)%MAXSIZE;
return OK;
}
循环队列的出队操作:
/* 若队列不空,则删除Q中队头元素,用e返回其值 */
Status DeQueue(SqQueue *Q, QElemType *e) {
if (Q->front == Q->rear) // 队列空的判断
return ERROR;
*e = Q->data[Q->front]; // 将队头元素赋值给e
Q->front = (Q->front+1)%MAXSIZE; // front指针向后移动一位,若到最后则转到数组头部
return OK;
}
11.队列的链式存储结构
队列的链式存储结构,其实就是线性表的单链表,只不过它只能尾进头出而已,我们把它简称为链队列。
空指针时,front和rear都指向头结点.
11.1 链队列的结构
typedef int QElemType;
typedef struct QNode {
// 结点结构
QElemType data;
struct QNode *next;
}QNode, *QueuePtr;
typedef struct {
// 队列的链表结构
QueuePtr front,rear; // 队头、队尾指针
}LinkQueue;
11.2 入队操作
/* 插入元素e为Q的新的队尾元素 */
Status EnQueue(LinkQueue *Q, QElemType e) {
QueuePtr s = (QueuePtr)malloc(sizeof(QNode));
if (!s) // 存储分配失败
exit(OVERFLOW);
s->data = e;
s->next = NULL;
Q->rear->next = s;
Q->rear = s;
return OK;
}
11.3 出队操作
/* 若队列不为空,删除Q点队头元素,用e返回其值,并返回OK,否则返回ERROR */
Status DeQueue(LinkQueue *Q, QElemType *e) {
QueuePtr p;
if (Q->front == Q->rear)
return ERROR;
p=Q->front->next;
*e = p->data;
Q->front->next = p->next;
if (Q->rear == p) // 若队头是队尾,则删除后将rear指向头结点,见上图的右图
Q->rear = Q->front;
free(p);
return OK;
}
至此,栈和队列就讲完了!