上节介绍了线性表。栈(Stack)也是一种操作受限的线性表。
只允许在一端进行插入和删除操作
的线性表即为栈。
栈类似理解为盘子或烤串。取放盘子时只能在一摞盘子顶部进行取放。烤肉时肯定是从签子的顶部串肉,吃串的时候也是从顶部开吃。
进栈顺序:a->b->c->d->e。
有哪些合法的出栈顺序?
最简单的情况是依次进栈,所有元素都进栈之后,进行出栈操作,此时出栈的顺序即为:e,d,c,b,a
如果是进栈出栈穿插进行,会有很多种情况。如ab先入栈,然后执行一次出栈操作,继续使cde入栈,然后执行出栈操作:顺序为:b,e,d,c,a
顺序栈使用顺序存储方式实现,与顺序表类似。
顺序栈的定义(代码实现):
#define MaxSize 10 //定义栈中元素最大个数
typedef int ElemType; //此处代码示例使用int类型
typedef struct{
ElemType data [MaxSize]; //使用静态数组存放栈中元素
int top; //栈顶指针,一般指向栈顶元素
}SqStack;
void testStack(){
SqStack S; //声明一个顺序栈
//***后续操作***
}
上述栈的顺序实现方式,给各个数据元素分配连续的存储空间。大小为:MaxSize*Sizeof(ElemType)
初始化空栈时,一般将栈顶指针赋值为-1,判空同理。
//初始化栈
void InitStack(SqStack &S){
S.top=-1; //初始化栈顶指针
}
//判断栈空
bool StackEmpty(SqStack S){
if(S.top==-1){ //栈空
return true;
}else{ //不空
return false;
}
}
进栈操作相当于增删改查中的增,顺序栈进栈操作的基本思路如下:
代码实现如下:
//新元素入栈(注意操作顺序)
bool Push(SqStack &S,ElemType x){
if(S.top==MaxSize-1){ //栈满
return false;
}
S.top=S.top+1; //指针先加1
S.data[S.top]=x; //新元素入栈
return true;
}
指针加1和新元素入栈操作也可以简化为一步:
S.data[++S.top]=x; //运行顺序为:先++,再赋值
出栈操作相当于增删改查中的删,顺序栈出栈操作的基本思路如下:
代码实现如下:
//出栈
bool Pop(SqStack &S,ElemType &x){
if(S.top==-1){ //栈空报错
return false;
}
x=S.data[S.top]; //栈顶元素先出栈
S.top=S.top-1; //指针再减一
return true;
}
新元素出栈和指针减一操作也可以简化为一步:
x=S.data[S.top--]; //运行顺序为:先赋值,再--
注意:出栈之后数据还残留在内存中,只是逻辑上被删除了。
读取栈顶元素和出栈操作的区别在于,出栈会删除元素,读取栈顶元素不会,因此读取栈顶元素代码实现如下:
//读取栈顶元素
bool GetTop(SqStack S,ElemType &x){
if(S.top==-1){ //栈空,报错
return false;
}
x=S.data[S.top]; //x记录栈顶元素
return true;
}
顺序栈的栈顶指针初始化时也可以指向0,即top指针可以指向下一个可以插入元素的位置,这种情况下,进栈操作和出栈操作也会有所变化,如下:
S.data[S.top++]=x; //此为进栈操作。先赋值,再自增
x=S.data[--S.top]; //此即为出栈操作。先自减,再赋值
由于使用了静态数组,不难发现顺序栈的一个缺点:栈的大小不可变。
如何解决顺序栈的大小不可变的问题呢?
共享栈的特点:
共享栈的定义和初始化:
//共享栈的定义
typedef struct{
ElemType data[MaxSize];
int top0;
int top1;
}ShStack;
void InitStack(ShStack &S){
S.top0=-1; //初始化0号栈顶指针
S.top1=MaxSize; //初始化1号栈顶指针
}
根据共享栈结构,共享栈栈满的条件为 top0+1==top1;
如下图即为共享栈栈满的情形:
回顾头插法建立单链表代码
//头插法建立单链表(带头结点)
LinkList List_HeadInsert(LinkList &L){
//①初始化一个空的单链表
L=(LinkList)malloc(sizeof(LNode));
L->next=NULL;
//②输入结点的值
int x;
scanf("%d",x);
while(x!=9999){
//③对头结点进行后插
LNode *s=(LNode *)malloc(sizeof(LNode));
s->data=x;
s->next=L->next;
L->next=s;
scanf("%d",&x); //继续输入下一个待插入值
}
return L;
}
上述头插法建立单链表即每次都对头结点进行后插操作,建立起一个单链表。而此操作正好只在链头的一端进行,符合进栈操作的特性。同理,如果规定单链表只能在此端删除,此受限的单链表就和链栈等同了起来。此即为单链表的链式存储,该类型描述为以下代码。
typedef struct Linknode{
ElemType data; //数据域
struct Linknode *next; //指针域
}*LiStack; //链栈类型定义
队列也是一种操作受限的线性表。
队列(Queue):
只允许在一端进行插入操作,另一端进行删除操作
的线性表即为栈。
可以形象理解为,排队打饭的场景、排队过收费站的场景。都是队尾加入元素,队头删除元素。
队列采用顺序存储方式时,可以使用静态数组存放队列中的元素。
队列(顺序存储方式)的定义代码实现:
#define MaxSize 10 //定义队列最大元素个数
typedef int ElemType;
typedef struct{
ElemType data[MaxSize]; //用静态数组存放队列元素
int front,rear; //队头指针和队尾指针
}SqQueue;
void testQueue(){
SqQueue Q; //声明一个队列
//后续操作。。。
}
注意,由于队列操作受限,队头删除,队尾插入,因此需要两个指针分别指向队头和队尾。
上述队列的顺序实现方式,给各个数据元素分配连续的存储空间。大小为:MaxSize*Sizeof(ElemType)
InitQUeue(&Q):初始化队列,构造一个空队列Q;
QueueEmpty(Q):判断队列是否为空,返回true或false;
一般规定,front队头指针指向队头元素,rear队尾指针指向队尾元素的后一个位置(即下一个应该插入元素的位置)。如下图所示:
顺序队列初始化的时候需要让队尾指针和队头指针同时指向0。同时这也是判断顺序队列是否为空的依据。 代码实现如下:
//初始化队列
void InitQueue(SqQueue &Q){
//初始化时,队头队尾指针均指向0
Q.front=Q.rear=0;
}
//判断队列是否为空
bool QueueEmpty(SqQueue Q){
if(Q.rear==Q.front){ //队空条件
return true;
}else{
return false;
}
}
EnQueue(&Q,x):入队,若队列Q未满,将下加入,使之成为新的队尾;
DeQueue(&Q,&x):出队,若队列Q非空,删除队头元素,并用x返回;
①入队操作,即向队尾添加元素,具体思路如下:
思路很简单,但需要思考队列满的条件是什么?
首先,使用rear==MaxSize并不能表达队列存满。因为此时若有队头元素出队,队头指针会后移,静态数组出现空闲空间,如果此时有新元素入队,要能插入到队列前面空闲的位置。因此需思考新元素插入队尾后,如何让队尾指针重新指向队列中下一个空闲位置。
可以通过取余操作使队尾指针重新指向队列前面的空闲部分,代码语句是:Q.rear=(Q.rear+1)%MaxSize;
比如,假设静态数组最大容量为10,在rear为9时,插入元素,之后执行此语句使队尾指针指向下一个待插入位置,则此时 (9+1)%10=0,就指向数组下表为0的位置,使用模运算将存储空间在逻辑上变为了环状。
因此队满情形,即队尾指针的下一个位置是队头,代码语句为:(Q.rear+1)%MaxSize==Q.front;
。使用模运算将存储空间在逻辑上变为了环状,队满的图示如下:
需要牺牲一个存储单元,队满情形下,不可让rear和front指向同一个位置,因为rear==front是判断队列是否为空的条件。
因此顺序队列完整的入队操作的代码如下:
//入队操作
bool EnQueue(SqQueue &Q,ElemType x){
if((Q.rear+1)%MaxSize==Q.front){ //队满情形
return false; //报错
}
Q.data[Q.rear]=x; //将x插入队尾
Q.rear=(Q.rear+1)%MaxSize; //队尾指针后移指向下一个待插入位置
return true;
}
②队列的出队操作即从队头删除元素并用变量x返回其值,具体思路如下:
出队操作代码实现如下:
//出队操作
bool DeQueue(SqQueue &Q,ElemType &x){
if(Q.rear==Q.front){
return false; //判断队空则报错
}
x=Q.data[Q.front];
Q.front=(Q.front+1)%MaxSize; //队头指针后移(转圈移动)
return true;
}
GetHead(Q,&x):读队头元素,若队列Q非空,则将队头元素赋值给x;
代码实现如下:
//获取队头元素的值
bool GetHead(SqQueue Q,ElemType &x){
if(Q.rear==Q.front){ //队空则报错
return false;
}
x=Q.data[Q.front];
return true;
}
通过以上学习,了解到队满和队空的条件为:
根据队头指针和队尾指针的值,我们可以很方便地计算出队列内元素的个数:
队列内元素个数为:(rear+MaxSize-front)%MaxSize 个;
上图例子中,rear为2,front为3,则当前队列内元素个数为(2+10-3)%10 = 9个。
在上述队列的顺序实现方式,我们牺牲了一个存储空间来区别队满和队空两种状态。但有些算法题中可能会要求不准浪费这一个单位的存储空间。那我们应当如何设计来保证队列的性能呢?
方案一:在队列结构内定义变量size,记录队列此时存放的数据元素个数。
代码定义如下:
//另一种实现方式
#define MaxSize 10
typedef struct{
ElemType data[MaxSize];
int front,rear;
int size; //记录队列当前长度
}SqQueue;
虽然此时队满和队空时,队头指针和队尾指针都是指向同一个位置,但可通过size的值进行判断队满还是队空:
方案二:在队列结构内定义变量tag,用来标识最近进行的是插入操作还是删除操作。
(因为只有删除操作才会导致队空,只有插入操作才会导致队满)
代码定义如下:
//方案二
#define MaxSize 10
typedef struct{
ElemType data[MaxSize];
int front,rear;
int tag; //标识位,最近执行的是删除操作tag=0,插入操作tag=1;
}SqQueue;
此时,可以搭配tag值判断队满还是队空:
链式队列和普通的单链表相比,无非是链式队列额外要求元素删除只能在队头,元素插入只能在队尾。
队列链式存储代码声明如下:
//定义链式队列内的结点
typedef struct LinkNode{
ElemType data;
struct LinkNode *next;
}LinkNode;
//定义链式队列
typedef struct{
LinkNode *front,*rear; //队列的头指针和尾指针
/*如果开发场景内经常用到队列长度,此处可以补充声明一个int型的length变量*/
}LinkQueue;
链式队列分为带头结点和不带头结点。初始化时,如下图所示:
链式队列的代码实现
①链式队列的初始化和判空操作(带头结点)
//初始化链式队列(带头结点)
void InitQueue(LinkQueue &Q){
//初始化时front和rear都指向头结点
Q.front=Q.rear=(LinkNode *)malloc(sizeof(LinkNode));
Q.front->next=NULL;
}
//判空操作(带头结点)
bool IsEmpty(LinkQueue Q){
if(Q.front==Q.rear){
return true;
}else{
return false;
}
}
②链式队列的初始化和判空操作(不带头结点)
//初始化链式队列(不带头结点)
void InitQueue(LinkQueue &Q){
//初始化时front和rear都指向NULL
Q.front=NULL;
Q.rear=NULL;
}
//判空操作(不带头结点)
bool IsEmpty(LinkQueue Q){
if(Q.front==NULL){
return true;
}else{
return false;
}
}
EnQueue(&Q,x):入队,若队列Q未满,将下加入,使之成为新的队尾;
DeQueue(&Q,&x):出队,若队列Q非空,删除队头元素,并用x返回;
①链式队列入队操作的基本思想:
代码实现如下:
//新元素入队(带头结点)
void EnQueue(LinkQueue &Q,ElemType x){
LinkNode *s=(LinkNode *)malloc(sizeof(LinkNode));
s->data=x;
s->next=NULL;
Q.rear->next=s; //新结点插入到rear之后
Q.rear=s; //修改表尾指针
}
//新元素入队(不带头结点)
void EnQueue(LinkQueue &Q,ElemType x){
//申请新结点
LinkNode *s=(LinkNode *)malloc(sizeof(LinkNode));
s->data=x;
s->next=NULL;
if(Q.front==NULL){ //不带头结点情况下,空队列中插入第一个元素需要特殊处理
Q.front=s;
Q.rear=s;
}else{
Q.rear->next=s; //新结点插入到rear结点之后
Q.rear=s; //修改rear指针
}
}
②链式队列的出队操作基本思想:
代码实现如下:
//队头元素出队(带头结点)
bool DeQueue(LinkQueue &Q,ElemType &x){
if(Q.front==Q.rear){ //空队
return false;
}
LinkNode *p=Q.front->next;
x=p->data; //使用x返回队头元素
Q.front->next=p->next; //修改头结点next指针
if(Q.rear==p){ //最后一个出队的元素,需修改rear指针
Q.rear=Q.front;
}
free(p);
return true;
}
//队头元素出队(不带头结点)
bool DeQueue(LinkQueue &Q,ElemType &x){
if(Q.front==NULL){ //空队
return false;
}
LinkNode *p=Q.front;
x=p->data; //使用x返回队头元素
Q.front=p->next; //修改front指针
if(Q.rear==p){ //最后一个出队的元素,需修改rear指针
Q.rear=NULL;
Q.front=NULL;
}
free(p);
return true;
}
注:链式存储队列一般不会存满,除非内存不足。
双端队列也是一种操作受限的线性表。它允许从两端插入,两端删除。