1、栈的定义
(1)栈的定义
栈(stack)是限定仅在表尾进行插入和删除操作的线性表。
把允许插入和删除的一端称为栈顶(top),另一端称为栈底(bottom),即表尾,不含任何数据元素的栈称为空栈。栈又称为后进先出(Last In First Out)的线性表,简称为LIFO结构。
栈中的元素具有先驱后继的关系。
栈的插入操作叫作进栈,也称为压栈、入栈;栈的删除操作叫作出栈,也称为弹栈。如下图:
(1)进栈出栈变化形式
栈对线性表的插入和删除的位置进行了限制,但并没有对元素进出的时间进行限制。也就是说,在不是所有元素都进栈的情况下,事先进栈的元素也可以出栈,只要保证是栈顶元素出栈就可以。
例如,现在有三个整型数字元素1、2、3依次进栈,则有如下出栈顺序:
第一种:1、2、3进,3、2、1出,出栈顺序为321;
第二种:1、2进,2、1出,3进,3出,出栈顺序为213;
第三种:1进1出,2进2出,3进3出,出栈顺序为123;
第四种:1进1出,2、3进,3、2出,出栈顺序为132;
第五种:1、2进,2出,3进,3、1出,出栈顺序为231。
如果元素数量增多,出栈的变化将会更多。
2、栈的抽象数据类型
对于栈来说,理论上线性表的操作它都具备,可由于它的特殊性,所以针对它在操作上会有些变化。
由于栈本身就是一个线性表,所以上一章讨论的线性表的顺序存储和链式存储对于栈来说,同样适用。
3、栈的顺序存储结构
既然栈是线性表的特例,那么栈的顺序存储其实也是线性表顺序存储的简化,简称为顺序栈。线性表是用数组来实现的,那么对于栈这种只能在一端进行插入和删除操作的线性表来说,用下标为0的一端作为栈底,因为首元素都存在栈底,变化最小,所以让它作为栈底。
栈的结构定义:
/*栈的结构定义*/
typedef int SElemType; /*SElemType的类型依据实际而定,这里假设为int*/
typedef struct
{
SElemType data[MAXSIZE];
int top; /*用于栈顶指针*/
}SqStack;
若现在有一个栈,StackSIze为5,则栈普通情况、栈满和栈空情况示意图如下:
(1)进栈操作
进栈push操作,其代码如下:
/*插入元素e为新的栈顶元素*/
Status Push(SqStack* s,SElemType e)
{
if (s->top == MAXSIZE - 1) /*栈满*/
return ERROR;
s->top++; /*栈顶指针增加1*/
s->data[s->top] = e; /*在栈顶插入新元素*/
return OK;
}
出栈pop操作,代码如下:
/*若S不空,则删除S的栈顶元素,用e返回其值,并返回OK;否则返回ERROR*/
Status Pop(SqStack* S, SElemType* e)
{
if (S->top == -1)/*栈空*/
return ERROR;
*e = S->data[S->top];/*用e返回栈顶值一*/
S->top--;/*栈顶指针减一*/
}
4、两栈共享空间
其实栈的顺序存储还是很方便的,因为它只准栈顶进出元素,所以不存在线性表插入和删除时需要移动元素的问题。不过它有一个很大的缺陷,就是必须事先确定数组存储空间的大小,万一不够用了,就需要编程手段来扩展数组空间的容量,非常麻烦。对于一个栈,我们也只能尽量考虑周全,设计出合适大小的数组来处理,但对于两个相同类型的栈,我们却可以做到最大限度地利用事先开辟地存储空间来进行操作。
用一个数组来存储两个相同类型地栈:
其做法如下图,数组有两个端点,两个栈有两个栈底,让一个栈的栈底为数组的始端,即下标为0处,另一个栈为栈的末端,即下标为数组长度n-1处。这样,两个栈如果增加元素,就是两端点向中间延伸。
其关键思路是:它们在数组的两端,向中间靠拢,top1和top2是栈1和栈2的栈顶指针,可以想象,只要它俩不见面,两个栈就可以一直使用。
可以分析出,栈1为空时,top1=-1,栈2为空时,top2=n,栈满时,top1+1=top2。
两栈共享空间的结构代码如下:
/*两栈共享空间结构*/
typedef struct
{
SElemType data[MAXSIZE];
int top1; /*栈1栈顶指针*/
int top2; /*栈2栈顶指针*/
}SqDoubeStack;
两栈共享空间的push方法:
/*插入元素e为新的栈顶元素*/
Status Push(SqDoubeStack* S,SElemType e,int stackNumber)
{
if (S->top1 + 1 == S->top2) /*栈已满,不能再push新元素l*/
return ERROR;
if (1 == stackNumber)/*栈1有元素进栈*/
S->data[++S->top1] = e;
else if(2 == stackNumber)/*栈2有元素进栈*/
S->data[--S->top1] = e;
return OK;
}
两栈共享空间的pop方法:
/*若栈不空,删除S的栈顶元素,并用e返回其值,返回OK;否则返回ERROR*/
Status Pop(SqDoubeStack* S,SElemType* e,int stackNumber)
{
if (1 == stackNumber)/*栈1有元素出栈*/
{
if (-1 == S->top1)/*栈1为空*/
return ERROR;
*e = S->data[--S->top1];
}
else if (2 == stackNumber)/*栈2有元素出栈*/
{
if (MAXSIZE == S->top2)/*栈2为空*/
return ERROR;
*e = S->data[++S->top2];
}
return OK;
}
事实上,使用这样的数据结构,通常都是当两个栈的空间需求用相反关系时,也就是一个栈增长时另一个栈在缩短的情况。否则两个栈都在不停地增长,很快就会因为栈满而溢出了。
个人疑问:线性表的顺序存储结构和栈的顺序存储结构都用数组实现。会好奇这些用数组实现的数据结构是否对外提供下标访问的功能,因为数组支持下标访问。后来发现,对这些结构体的数据成员可以使用下标访问,但如果这些数据成员对外隐藏,只是使用这些数据成员的函数,如push等对外开放,那么就用户就不能使用下标访问这些数据结构。C++中的访问权限可以实现数据隐藏,但不知道C语言中如何实现。
5、栈的链式存储结构及其实现
(1)栈的链式存储结构
栈的链式存储结构简称链栈。
将栈顶指针和单链表中的头指针合二为一,及把栈顶放在单链表的头部,故通常对于链栈来说,不需要头结点。如下图:
对于链栈来说,基本不 存在栈满的情况,除非内存已经没有可以使用的空间。
但对于空栈来说,链表原定义是头指针指向空,那么链 栈的空 其实就是top=NULL的时候。
链栈的结构代码如下:
对于链栈来说,基本不 存在栈满的情况,除非内存已经没有可以使用的空间。
但对于空栈来说,链表原定义是头指针指向空,那么链 栈的空 其实就是top=NULL的时候。
链栈的结构代码如下:
typedef struct StackNode/*链结点为单链表*/
{
SElemType data;
StackNode* next;
}StackNode,*LinkStackPtr;
typedef struct LinkStack/*链栈的栈顶为单链表类型*/
{
LinkStackPtr top;/*链栈栈顶指针为单链表类型*/
int count;
}
链栈的操作绝大部分和单链表类似,只是在插入和删除上,特殊一些。
(2)进栈操作
对于链栈的进栈操作push,假设元素值为e的新结点是s,top为栈顶指针,示意图和相应的代码如下:
/*插入元素为e的新的栈顶元素*/
Status Push(LinkStack* S,SElemType e)
{
LinkStackPtr s = (LinkStackPtr)malloc(sizeof(StackNode));/*创建新结点*/
s->next = S->top;/*新元素的后继指向原来的栈顶指针*/
S->top = s;/*更新栈顶*/
s->data = e;/*给新结点元素赋值*/
S->count++;/*栈结点加一*/
return OK;
}
(3)出栈操作
假设变量p用来存储要删除的栈顶结点,将栈顶指针下移一位,最后释放p即可,如下图:
/*如果链栈不为空,则删除栈顶结点,并返回OK;否则返回ERROR*/
Status Pop(LinkStack* S, SElemType* e)
{
if (StackEmpty(S))/*链栈为空*/
return ERROR;
LinkStackPtr p = S->top;/*用p保存要释放的栈顶结点*/
*e = p->data;
S->top = p->next;/*更新栈顶结点*/
free(p);/*释放原来的栈顶结点*/
S->count--;/*栈结点减一*/
return OK;
}
补充:链栈是否为空判断:
/*判断栈是否为空*/
Status StackEmpty(LinkStack *S)
{
if (NULL == S->top)
return OK;
else
return ERROR;
}
对比一下线性栈和链栈,它们在时间复杂度上是一样的,均为O(1)。对于空间性能,顺序栈需要事先确定一个固定长度,可能会存在内存空间浪费问题,但它的优势是存取时定位很方便,而链栈则要求每个元素都有指针域,这同时也增加了一些内存开销,但对于栈的长度无限制。所以它们的区别和线性表中讨论的一样,如果栈的使用过程中元素变化不可预料,有时很小,有时很大,那么建议使用链栈,反之,如果它的变化在可控范围之内,那么使用顺序栈可能会更好些。
6、栈的作用
栈的引入简化了程序设计的问题,划分了不同关注层次,使得思考范围缩小,更加聚焦于我们要解决的问题的核心。反之,像数组等,因为要分散精力去考虑数组的下标增减等细节问题,反而掩盖了问题的本质。
所以现在的很多高级语言,比如java、C#等都有对栈结构的封装,你可以不用关注它的实现细节,就可以直接使用Stack的pop和push等方法,非常方便。
7、栈的应用——递归
栈有一个很重要的应用:在程序设计语言中实现了递归。
一个经典的递归例子:斐波那契数列(Fibonacci)。
(1)斐波那契数列实现
说如果兔子在出生两个月后,就有繁殖能力,一个兔子每个月能生出一对小兔子来。假设所有兔子都不死,那么一年以后可以繁殖多少对兔子呢?
拿新出生的一对兔子分析一下:第一个月小兔子没有繁殖能力,所以还是一对;两个月后,生下一对小兔子,共有两对;三个月以后,老兔子又剩下一对,小兔子因为还没有繁殖能力,所以一共有三对……依次类推可以列出下表:
表中数字:1,2,3,5,8,13……构成了一个数列。这个数列有个十分明显的特点,那是:前面相邻两项之和,构成了后一项,如下图:
用数学函数来定义就是:
满足这样规律的数称为斐波那契数列数。
假如需要打印前40位的斐波那契数列数,常规迭代做法代码如下:
int main()
{
int i;
int a[40] = {0};
a[0] = 1;
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 main()
{
for (int i = 0; i < 40; i++)
{
printf("%d", Fbi(i));
}
return 0;
}
为了更好理解上述递归算法,模拟函数Fbi(i)中i=5的代码执行过程,如下图:
(2)递归定义
在高级语言中,调用自己和其他函数并没有本质的不同。
把一个直接调用自己或通过一系列的调用语句间接调用自己的函数,称做递归函数。
写递归程序最怕进入的是永不结束的无穷递归中,所以,每个递归必须至少具有一个条件,满足时递归不再进行,即不再引用自身而是返回值退出。比如刚才的例子,总有一次递归会使得i<2的,这样就可以执行return i的语句而不用继续递归了。
对比了两种实现斐波那契的代码,迭代和递归的区别是:迭代使用的是循环结构,递归使用的是选择结构。递归能使程序的结构更清晰、更简洁、更容易让人理解,从而减少读懂代码的时间。但是大量的递归调用会建立函数的副本,会耗费大量的时间和内存。迭代则不需要反复调用函数和占用额外的内存。因此,应该视不同的情况选择不同的代码实现方式。
前面我们已经看到递归是如何执行它的前行(main函数中for循环中的不同i)和退回(图4-8-3)阶段的。递归过程退回的顺序是它前行顺序的逆序。在退回过程中,可能要执行某些动作,包括恢复在前行过程中存储起来的某些数据。
这种存储某些数据,并在后面又以存储的逆序恢复这些数据,以提供之后使用的需求,显然很符合栈这样的数据结构。因此,编译器使用栈实现递归。
简单地说,就是在前行阶段,对于每一层递归,函数地局部变量、参数值以及返回地址都被压入栈中。在退回阶段,位于栈顶的局部变量、参数值和返回地址被弹出 ,用于返回顶层调用中执行代码的其余部分,也就是恢复了调用状态。
当然,对于现在的高级语言,这样的递归问题是不需要用户来管理这个栈的,一切都由系统代劳了。
8、栈的应用——四则运算表达式求值
(1)后缀(逆波兰)表示法定义
四则运算规则:先乘除,后加减,从左算到右,先括号内后括号外 。
逆波兰(Reverse Polish Notation,RPN )表示:一种不需要括号的后缀表示法。
例如:9+(3-1)✖3+10➗2的后缀表示表达式为:9 3 1-3*+10 2/+。叫后缀的原因在于所有的符号都是在运算数字的后面出现。
后缀表达式虽然 比较难以理解,但是计算机喜欢的方式。
(2)后缀表达式计算结果
计算机应用后缀表达式计算出最终结果的过程:
后缀表达式:9 3 1-3*+10 2/+
规则:从左到右遍历表达式的每个数字和符号,遇到是数字就进栈,遇到是符号,就将处于栈顶的两个数字弹出,进行运算,运算结果进栈,一直到获得最终结果。
(3)中缀表达式转后缀表达式
把平常所用的标准四则运算表达式,即“9+(3-1)✖3+10➗2“叫做中缀表达式。
规则:从左到右遍历中缀表达式的每个数字和符号,若是数字就输出,即称为后缀表达式的一部分;若是符号,则判断其与栈顶符号的优先级,是右括号或优先级低于栈顶符号(乘除优先于加减),则栈顶元素依次出栈并弹出,并将当前符号进栈,直到最终输出后缀表达式为止。
从刚才的推导中会发现,要想让计算机具有处理我们通常标准表达式的能力,最重要的是两步:
1)将中缀表达式转为后缀表达式(栈用来进出运算的符号)。
2)将后缀表达式进行运算得出结果(栈用来进出运算的数字)。
9、队列的定义
队列(queue)是只允许在一端进行插入操作,另一端进行删除操作的线性表
队列是一种先入先出(First In First Out)的线性表,简称FIFO。允许插入的一端称为队尾,允许删除的一端称为对头。假设队列q(a1,a2,……,an),那么a1就是对头元素,an就是队尾元素。如下图:
10、队列的抽象数据类型
同样是线性表,队列也有类似线性表的各种操作,不同的是插入数据只能在队尾进行,删除数据只能在队头进行。
11、循环队列
队列作为一种特殊的线性表,也有顺序存储和链式存储两种存储方式。
(1)队列顺序存储的不足
假设一个队列有n个元素,则顺序存储的队列需要建立一个大于n的数组,并把队列的所有元素存储在这个数组的前n个单元,数组下标为0的一端即是队头。所谓的入队操作,其实就是在队尾追加一个元素,不需要移动任何元素,因此时间复杂度为O(1),如下图:
与栈不同的是,队列元素的出列是在队头,即下标为0的位置,那也就意味着,队列中的所有元素都得向前移动,以保证队列的队头,也就是下标为0的位置不为空,此时时间复杂度为O(n),如下图
这里的实现和线性表的顺序存储结构完全相同,不再详述。
如果不去限制队列的元素必须存储在数组的前n个单元这一条件,出队的性能就会大大增加。也就是说,队头不一定是在下标为0的位置,如下图:
为了避免只有一个元素时,队头和队尾重合使处理变得麻烦,引入两个指针,front指针指向队头元素,rear指针指向队尾元素的下一个位置,这样当front等于rear时,此队列不是还剩一个元素,而是空队列。如下图
(2)循环队列
为了解决rear指针移到数组之外以及假溢出(在如下数组中再添加元素的话会造成数组溢出)的问题,如下图:
把队列的这种头尾相接的顺序存储结构称为循环队列。
这样即可解决上述两个问题,如下图:
如果在图4-12-6的基础上再插入数据,则会出现下图所示情况,即front指针与rear指针重合。
考虑到队列为空时,front指针也是和rear指针重合。区分这两种情况的办法如下:
办法一是设置一个标志量flag,当frontrear且0flag时,队列为空;当frontrear且flag=1时,队列为满。
办法二是当队列空时 ,条件就是frontrear,当队列满时,我们修改其条件,保留一个元素空间。也就是说,当数组满时,数组还有一个空闲单元,如下图:
现在重点来讨论第二种方法,由于rear可能比front大,也可能比front小,所以尽管它们只差一个位置时就满的情况,但也可能相差整整一圈。所以,若队列的最大尺寸为QueueSize,那么队满的条件是(rear+1)%QueueSize==front(取模的目的就是为了整合rear和front大小为一个问题)。比如图4-12-8的例子,QueueSize=5,front=0,rear=4,满足(4+1)%5=0;front=2,rear=1,满足(1+1)%5=2。故两种情况队列均为满。
通用的计算队列长度的公式为:
(rear-front+QueueSize)%QueueSize。
循环队列的顺序存储结构代码如下:
typedef int QElemType;/*QElemType类型根据实际情况而定,这里假设为int*/
typedef struct
{
QElemType data[MAXSIZE];/*队列数据类型,用数组实现*/
int front;/*头指针,指向队列中的头元素*/
int rear;/*尾指针,若队列不为空,则指向队尾元素的下一个元素*/
};
循环队列的初始化代码如下:
/*初始化一个空队列*/
Status InitQueue(SqQueue* Q)
{
Q->front = 0;
Q->rear = 0;
return OK;
}
循环队列求队列长度代码如下:
/*返回Q的元素个数,也就是队列的当前长度*/
int QueueLength(SqQueue* Q)
{
return (Q->rear - Q->front + MAXSIZE) % MAXSIZE;
}
循环队列的入队列操作代码如下:
/*若队列未满,则插入元素e为Q新的队尾元素*/
Status EnQueue(SqQueue* Q,QElemType e)
{
if ((Q->rear + 1) % MAXSIZE == Q->front)/*队列为满*/
return ERROR;
Q->data[Q->rear] = e;/*将元素e赋值给队尾*/
Q->rear = (Q->rear + 1)%MAXSIZE;/*rear指针后移一位置*/
/*若到最后则转到数组头部*/
return OK;
}
循环队列的出队列代码如下:
/*若队列不空,则删除Q中队头元素,用e返回其值*/
Status DeQueue(SqQueue* Q,QElemType* e)
{
if (Q->rear == Q->front)/*队列为空*/
return ERROR;
*e = Q->data[Q->front];/*将队头元素赋值给e*/
Q->front = (Q->front + 1)%MAXSIZE;/*队头指针向后移一位置*/
/*若到最后则转到数组头部*/
return OK;
}
循环队列提高了算法的时间性能,即往队列中插入和删除元素的时间复杂度都是O(1)。但还是会存在数组可能溢出的问题,故需要研究不担心队列长度的链式存储结构。
12、队列的链式存储结构
队列的链式存储结构,其实就是线性表的单链表,只不过它只能尾进头出而已,我们把它简称为链队列。
为了操作上的方便,将队头指针指向队列的头结点,而队尾指针指向终端结点如下图:
空队列时,front和rear都指向头结点,如下图:
链队列的结构为
typedef int QElemType;/*QElemType依据实际情况而定,这里假设为int*/
typedef struct/*结点结构*/
{
QElemType data;
struct QNode* next;
}QNode,*QueuePtr;
typedef struct/*队列的链表结构*/
{
QueuePtr front, rear;/*队头、队尾指针*/
}LinkQueue;
(1)入队操作
入队操作时,其实就是在链表尾部插入结点,如下图:
其代码如下:
/*插入元素e为新的队尾元素*/
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;
}
(2)出队操作
出队操作时,就是头结点的后继结点出队,将头结点的后继节点改为它后面的结点,若链表除头结点外只剩一个元素时,则需将rear指向头结点,如下图:
代码如下:
/*若队列不空,则删除Q的队头元素,用e返回其值,并返回OK,否则返回ERROR*/
Status DeQueue(LinkQueue* Q, QElemType* e)
{
if (Q->rear == Q->front)/*队列为空*/
return ERROR;
QueuePtr q = Q->front->next;/*获得要删除的队头结点*/
*e = q->data;/*用e返回队头结点的元素值*/
Q->front->next = q->next;/*如果链表Q除了头结点外只有一个结点,则其结果为NULL*/
if (Q->rear == Q->front->next)/*链表Q只有头结点和尾结点,删除结点q之后链表为空*/
Q->rear = Q->front;
free(q);/*释放结点q*/
return OK;
}
补充:链队列初始化代码:
/*初始化链队列*/
Status InitQueue(LinkQueue* Q)
{
Q->front = Q->rear = (QueuePtr)malloc(sizeof(QNode));
Q->front->next = NULL;
}
对于循环队列和链队列的比较,可以从两方面来考虑,从时间上,其实它们的基本操作都是常数时间,即都为O(1)的,不过循环队列是事先申请好空间,使用期间不释放,而对于链队列,每次申请和释放结点也会存在一些时间开销,如果入队和出队频繁,则两者还是有细微差异。对于空间上来说,循环队列必须有一个固定长度,所以就有了存储元素个数和空间浪费的问题。而链队列不存在这个问题,尽管它需要一个指针域,会产生一些空间上的开销,但也可以接受。所以在空间上,链队列更加灵活。
总的来说 ,在可以确定队列最大值的情况下,建议用循环队列,如果无法预估队列的长度时,则用链队列。