3. 数据结构-栈与队列

175 阅读16分钟

考纲要求 💕

第3章 栈和队列

  • 1.
    (1)栈的定义及基本运算
    (2)栈的存储结构及基本运算的实现
    (3)栈的简单应用
  • 2.队列
    (1)队列的定义及基本运算
    (2)队列的存储结构及基本运算的实现
    (3)队列的简单应用

知识点(考纲要求)

  • 1.掌握栈和队列的特性以及它们之间的差异

  • 2.重点掌握顺序栈和链栈上实现栈的基本操作注意栈满和栈空的条件

  • 3.重点掌握顺序队列和链队列上实现队列的基本操作注意循环队列上队满和队空的条件

  • 4.了解栈和队列的简单应用


前言:

栈和队列,严格意义上来说,也属于线性表,因为它们也都用于存储逻辑关系为 "一对一" 的数据,但由于它们比较特殊,因此将其单独作为一章,做重点讲解。

使用栈结构存储数据,讲究“先进后出”,即最先进栈的数据,最后出栈;使用队列存储数据,讲究 "先进先出",即最先进队列的数据,也最先出队列。

既然栈和队列都属于线性表,根据线性表分为顺序表和链表的特点,栈也可分为顺序栈和链表,队列也分为顺序队列和链队列


下面先讲解栈吧

1. ▶️ 栈 ✨


image.png


栈的基本定义

首先我们来讲讲栈,栈是只能在表尾进行插入或删除操作的线性表,通常我们称表尾端为栈顶表头端为栈底,它是一种先进后出的线性表,既只能在表尾端插入元素,称为入栈,也只能在表尾端删除元素,称为出栈,如下图所示:

3-2.1-1

栈既然也是线性表,那么它也有顺序存储结构和链式存储结构两种表示方法,这两种表示方法实现类似。在之后我们将会讲解栈的顺序存储结构实现


❗✨ 进出栈的可能顺序

值得注意的是,栈对线性表的插入和删除是在位置上进行了限制,但是并没有对进出时机进行限制。也就说,刚进去的元素也可以立即出栈,只要保证位置是栈顶即可

比如现在有3个元素1 11、2 22、3 33依次进栈,会有哪些出栈次序呢?

  • 第一种: 1 11、2 22、3 33进,然后再3 33、2 22、1 11。出栈次序为3 332 221 11
  • 第二种:1 11进、1 11出;2 22进、2 22出;3 33进、3 33出。出栈次序为123 123123
  • 第三种:1 11进、2 22进、2 22出、1 11出、3 33进、3 33出。出栈次序为213 213213
  • 第四种:1 11进、1 11出、2 22进、3 33进、3 33出、2 22出。出栈次序为132 132132
  • 第五种:1 11进、2 22进、2 22出、3 33进、3 33出、1 11出。出栈次序为231 231231

列举完毕共有5种情况。除此之外,像312 312312这种情况是绝对不可能出现的。因为3 33先出栈,就意味着3 33曾经进栈,既然3 33都进栈了,那么也就意味着1 11和2 22已经进栈了,此时2 22一定是在1 11的上面,出栈只能是321 321321


公式:

其实,n 个不同元素进栈,出栈元素不同排列的个数可由卡特兰(CatalanCatalanCatalan)\color{green}{卡特兰(C a t a l a n CatalanCatalan)数}

image.png

image.png


栈的基本操作

一个栈的基本操作如下

  • InitList(&L) :初始化表
  • DestoryList(&L) :销毁操作
  • ListInsert(&L,i,e) :插入操作
  • ListDelete(&L,i,&e) :删除操作
  • LocateElem(L,e) :按值查找
  • GetElem(L,i) :按位查找

其它常用操作

  • Length(L) :求表长
  • PrintList(L) :输出操作
  • Empty(L) :判空操作

下面先来实现栈的顺序表示把


❗ 1.1 栈的顺序表示 ✨


image.png


❗ 1.1.1 结构定义

顺序栈:顺序栈使用数组来实现。其中下标为0的一端作为栈底,将数组尾部作为栈顶,以进行插入和删除

由于尾部元素作为栈顶,也即最后一个元素是栈顶元素,所以需要使用一个 变量top 进行标识,top之外元素将不属于栈的定义范围,通常把top=-1定义为空栈

顺序栈的结构定义如下:

#define MaxSize 10  //定义栈中元素最大个数
typedef int ElemType //表元素类型 int

typedef struct 
{
	ElemType data[MaxSize]; //静态数组存放栈中元素
	int top;//栈顶指针
}SqStack;

图片.png


❗ 1.1.2 初始化栈

就是初始化,刚开始栈为空,条件为 top=-1

void InitStack(SqStack &S)
{
    S.top=-1; //栈顶指针
}

//判断一下栈空不

bool StackEmpty(SqStack S)
{
    if(S.top==-1) //栈空
    return true;
    else 
    return false;//栈不空
}

❗ 1.1.3 进栈

进栈:进栈时,先栈顶指针+1,后进行元素赋值(若top初值为0逻辑恰好相反)

图片.png

代码如下:

bool Push(SqStack &S,ElemType e)
{
     if(S.top==MaxSize-1)
     return false; //栈满了
     
     S.top+=1;            //指针先+1
     S.date[S.top]=e;     //数组top元素e——入栈
      return true;
}

❗ 1.1.4 出栈

出栈:出栈时,先保存元素,后栈顶指针-1(若top初值为0逻辑恰好相反)

  • 注意这种删除只是逻辑上的删除,其元素仍然会留在那片区域里。因为我们研究的是栈,只研究的是0~top这个范围内的情况

代码如下,注意栈空判断(if(s.top==-1))

bool Pop(SqStack &S,ElemType &e)
{
	if(S.top==-1)
	return false;//栈空
                
	e=S.data[S.top]; //栈顶元素先出栈
	S.top-=1;        //指针减1
	return true;
}

这就相当于top-1,然后把出去的栈元素保留给e。

注意,这里出栈的top指针的数据并没有消去,还残留在内存中,只是逻辑上删除了。如下图:

图片.png


❗ 1.1.5 读取栈顶元素

bool GetTop(SqStack& S,ElemType &e)
{
	if(S.top==-1)
		return false;//栈空
	e=S.data[S.top];
	return true;
}

顺序栈其缺点就在于事先要确定空间大小,万一不够用了还需要进行扩容,很是麻烦。有一种解决方法就是利用共享栈:二个栈共享同一片空间。

❗ 1.1.6 共享栈

共享栈:有两个相同类型的栈,如果一个栈空间满了,就可以借助另一个栈的空间。 具体来说,可以让一个栈的栈底为“栈”始端,即数组下标0处,另一个栈的栈底为“栈”末端,即数组下标n-1处,增加元素时,两端点相向而行

如下图描述:

图片.png

最后合成起来是这样的:

图片.png

不难想象,其判空和判满条件如下

  • 判空:top1==-1top2==n(超出范围)
  • 判满:普通情况下,top1top2一般不碰面。但是当top1+1==top2时肯定就满了

他们的进出栈:

  • 当0号栈进栈时top0先加1再赋值,1号栈进栈时top1先减1再赋值;出栈时则刚好相反


  • 共享栈的结构定义如下:
#define MaxSize 10  //定义栈中元素的最大个数 
typedef struct
{
	ElemType data[MaxSize];
	int top1;//栈1的栈顶指针
	int top2;//栈2的栈顶指针
}SqDoubleStack;
  • 初始化代码如下:
void InitStack(SqDoubleStack &S)
{
    S.top1=0; 
    S.top2=MaxSize;
}
  • 共享栈压栈代码如下:
bool Push(SqDoubleStack &S,ElemType e,int StackNumber)
{
        if(S.top1+1==S.top2)   //栈满
		return false;
	if(StackNumber==1)//栈1进栈
		S.data[++s.top1]=e; //先指针+1,再赋值
	if(StackNumber==2)//栈1进栈
		S.data[--s.top2]=e;  //先指针-1,再赋值
	return true;
}
  • 共享栈出栈代码如下:
bool Pop(SqDoubleStack& S,ElemType &e,int StackNumber)
{
	if(StackNumber==1)  //栈1出栈
	{
		if(S.top1==-1)//栈1已经是空栈
			return false;
		e=S.data[S.top1--]; //先取值,再指针-1
	}
	else if(StackNumber==2)  //栈2出栈
	{
		if(S.top2==MaxSize)//栈2是空栈
			return false
		e=S.data[S.top2++]; //先取值,再指针+1
	}
	return true;
}

上面完成了共享栈,下面来看看链式表达栈


❗ 1.2 栈的链式表示


图片.png


1.2.1 链栈的定义 ✨

**链栈:就是栈的链式存储结构。

图片.png


❗ 1.2.2 结构定义

typedef struct StackNode
{
	ElemType data;          //数据域
	struct StackNode* next;  //指针域
}StackNode;

❗ 1.2.3 初始化链栈

可分为带头结点的和不带的

带头结点

//初始化一个链栈
void initstack(StackNode &L)
{
	L=(StackNode*)malloc(sizeof(StackNode));//制造头节点
	L->next=NULL;
}

不带头结点

//初始化一个链栈
void initstack(StackNode &L)
{
	L->next=NULL;
}

判断栈空

//判断链栈是否为空
int isEmpty(StackNode &L)
{
	if(L->next==NULL)
		return true;
	else
		return false;
}

❗ 1.2.3 进栈

进栈:链栈进栈相当于单链表的插入操作

代码如下,无须考虑栈满情况

//进栈
void push(StackNode &L,int x)
{
	StackNode *p;
	p=(StackNode*)malloc(sizeof(StackNode)); //申请一个新节点
	p->next=NULL;
	//头插法
	p->data=x;
	p->next=L->next;
	L->next=p;
}

插入了头结点后面


❗ 1.2.4 出栈

出栈:链栈进栈相当于单链表的删除操作

代码如下,- 需要考虑栈空情况:

//出栈
int pop(StackNode &L,int &x)
{
	StackNode *p;  //新建一个结点,用于销毁
	if(L->next==NULL) //栈空,出不了
		return false;
                
	//单链表删除
	p=L->next;
	x=p->data;
	L->next=p->next;
	free(p);
	return true;
}

我看有些具有的是有二个结构体,一个是结点,一个记录数量和top指针

参考:


2. ▶️ 队列 ✨


图片.png

图片.png


✨ 队列基本概念

队列(Queue):是一种只允许在一端插入,在另一端删除的线性表。我们把允许插入的一端称之为队尾(rear),把允许删除的一端称之为队头(front)。不含任何数据元素的队列称之为空队列。队列遵循先进先出(FIFO)原则

图片.png


队列的基本操作

一个队列的基本操作如下

  • InitQueue(&Q) :初始化队列
  • DestoryQueue(&Q) :销毁队列
  • EnQueue(&Q,x) :入队
  • DeQueue(&Q,&x) :出队
  • GetHead(Q,&x) :读队头元素
  • QueueEmpty(Q) :队列判空

下面就来实现队列的顺序表达和链式表达和一些操作


❗ 2.1 顺序队列


图片.png


❗ 2.1.1 结构定义

这里实现队列的顺序结构:

define MaxSize 10   //定义队列中元素的最大个数
typedef struct{
ElemType data[MaxSize]; //存在数组元素
int front,rear;          //队头指针和队尾指针
} SqQueue;         

然后进行初始化


❗ 2.1.2 初始化顺序队列

void InitQueue{SqQueue &Q){
//初始时候,队头队尾指针指向0
Q.rear=Q.front=0; 
}

判空 ✨

bool QueueEmpty(SqQueue Q){
    if(Q.rear==Q.front)       //队空条件
    return true;
    else 
    return false;
}


有助于下面理解入队出队

图片.png


❗ 2.1.3 入队

  • 入队列

入队列的演示如下图:

图片描述

只能从队尾插入了

bool EnQueue(SqQueue &Q,ElemType x){
        if(Q.rear==MaxSize)  //队满,看下面解释。其实是假溢出了,本来应该是回到0
        return false;
        
        Q.data[Q.rear]=x; //把x插入队尾
        Q.rear=Q.rear+1;  //队尾指针后移
        return true;
   }

图片.png

所以这里看着像是越界了,所以要引入咱们下面要讲的循环队列

参考 : 顺序队列的基本操作C语言详解


❗ 2.1.4 出队

  • 出队列

出队列的演示如下图:

图片描述

图片.png

bool DeQueue(SqQueue &Q, int &e)
{
	if (Q->rear == Q->front)          //队空
		return false;
	e = Q->data[Q->front];            //对头元素赋值给e
	Q->front++;                       //对头指针后移
	return true;
}

下面关于针对上面越界的问题,引用循环队列


❗ 2.2 循环队列

2.2.1 ❗ 定义和判空判满

循环队列:为了解决假溢出问题,可以将数组“头尾相接”形成一种逻辑上的环形结构

图片.png

这种“环形”只是一种逻辑上的感觉,其底层仍然是连续的空间,想要实现操作上的环形那么就必须要对其下标的变换做一定的文章。(其实就是利用了%元素,例如1%12会把结果映射到0~11这个范围)

  • rear移动时:rear=(rear+1)%Maxsize)
  • front移动时:front=(front+1)%Maxsize)

所以使用这种方式,当a5插入时,rear=(4+1)%5=0,于是就又回到了开头

图片.png


数组空间毕竟是有限的,那么这样的结构其队空队满的条件是什么呢?如下

  • 队空:rear=front
  • 队满:(rear+1)%Maxsize=front

比如下面,rear开始为1,插入 a7后,rear=(1+1)%5=2=front,此时队满

图片.png


2.2.2 ❗ 结构定义

typedef struct
{
	DataType data[MaxSize];
	int front;//头指针
	int rear;//尾指针。若队列不空,指向队尾元素的下一个位置
}SqQueue;

2.2.3 ❗ 初始化循环队列

bool InitQueue(SqQueue &Q)
{
	Q.front=0;  //开始都是队头 0
	Q.rear=0;
	return true;
}

判空 ✨

bool QueueEmpty(SqQueue Q){
    if(Q.rear==Q.front)       //队空条件
    return true;
    else 
    return false;
}

2.2.4 ❗ 入队

入队:入队时,先元素赋值,后rear指针+1(因为要保证rear指向队尾下一个位置)

bool EnQueue(SqQueue &Q,DataType e)
{
	if(Q.front==(Q.rear+1)%MaxSize)
		return false;//队满
	Q.data[Q.rear]=e;
	Q.rear=(Q.rear+1)%MaxSize;
	
	return true;
}

2.2.5 ❗ 出队

bool DeQueue(SqQueue& Q,DataType *e)
{
	if(Q.front==Q.rear)
		return false;//队空
	*e=Q.data[Q.front];
	Q.front=(Q.front+1)%MaxSize;
	return true;
}

所以说基本上还是用循环队列牢靠点,记住改变的是(Q.front+1)%MaxSize,指针这样+1,然后是队满是Q.front==(Q.rear+1)%MaxSize

参考: 循环队列基本操作C语言详解


下面要讲解的就是链式队列了


❗ 2.3 链式队列


图片.png

参考: 链式队列的基本操作C语言详解


❗ 2.3.1 定义

链式队列:其本质仍然是单链表,不过只能尾进头出。为了操作方便,将front指针指向头结点,将rear指针指向队尾结点

图片.png

于是,在队列为空时,frontrear都将指向头结点

图片.png

❗ 2.3.2 结构定义

typedef struct QNode//结点
{
	DateType data;
	struct QNode* next;
}QNode;

typedef struct LinkQueue//链式栈
{
	QNode* rear;//队尾指针
	QNode* front;//队头指针
}LinkQueue;

❗ 2.3.3 初始化链式队列

分带头结点和不带头结点,一般都是带头结点的

带头结点

void InitQueue(LinkQueue &Q)
{
    //初始时候,front和rear都指向头结点
    Q.front=Q.rear=(QNode *)malloc(sizeof(QNode));//申请结点空间
    Q.front->next=Q.rear->next=NULL;  
}

不带头结点

void InitQueue(LinkQueue &Q)
{
    //初始时候,front和rear是指针在队头,是空队
    Q.front=Q.rear=NULL;  
}

❗ 2.3.4 判断队空

bool IsEmpty(LinkQueue Q)
{
            if (Q.front == Q.rear)		//头尾指针同时指向头结点为空		   
            return true;
	else return false;
}

❗ 2.3.5 入队

带头结点

图片.png

void EnQueue(LinkQueue &Q, int e)
{
    QNode *s=(QNode *)malloc(sizeof(QNode));//申请结点空间
	if (s == NULL)
		return false; //申请内存空间失败
                
	s->data = e;
	s->next = NULL;          
	Q->rear->next = s;	//新结点插入rear之后					
	Q->rear = s;           //rear指针指向这个新建立的队尾结点
}

不带头结点

不带头结点的队列,第一个元素入队时候需要特殊处理,使新建立的指针指向front和rear

图片.png

void EnQueue(LinkQueue &Q, int e)
{
    QNode *s=(QNode *)malloc(sizeof(QNode));//申请结点空间
	if (s == NULL)
		return false; //申请内存空间失败
                
	s->data = e;
	s->next = NULL;          
	
        /* 第一个元素入队*/
        if(Q.front==NULL){
        Q.front=s;   //修改队头队尾指针
        Q.rear=s;}
        
        else
        {
        Q.rear->next=s;   //新结点插入到rear结点之后
        Q.rear=s;         //rear指针指向这个新建立的队尾结点
        }
   
}

❗ 2.3.6 出队

带头结点

图片.png

bool DeQueue(LinkQueue &Q, int  &e)
{
	
	if (Q->front == Q->rear) //队空
		return false;
        
        QNode *p= Q->front->next;           //将欲删除的结点暂存给p结点
	e = p->data;		//获取元素值
        
         Q->front->next = p->next;  //修改头结点的next指针,跳过要删除的
         
	if (Q->rear == p) //删除的是最后一个结点
		Q->rear = Q->front;  //修改rear指针,使只剩头结点
                
	free(p);
	return true;
}

不带头结点

图片.png

bool DeQueue(LinkQueue &Q, int  &e)
{
	
	if (Q->front == NULL) //队空
		return false;
        
        QNode *p= Q->front;      //p指向此次出队的结点
	e = p->data;		//获取队头元素
        
        Q->front=p->next;  //修改front指针,跳到下一个,等于出去一个了
         
	if (Q->rear == p) //删除的是最后一个结点
		Q->rear=Q->front=NULL;  //front和rear都指向NULL,空队
                
	free(p);
	return true;
}

❗ 2.4 双端队列

前言:


定义


图片.png


图片.png

如下几种情况:

图片.png

图片.png


❗ 2.4.1 双端队列输出序列合法性判断

双端队列是一种非常特殊的线性表,如果将其限制在只能在一端插入、删除那么它就退化为了栈结构,在这种基础上又有两个变化方向

  • 如果继续限制另一端只能删除那么就是输入受限的双端队列
  • 如果继续限制另一端只能插入那么就是输出受限的双端队列

下面举个例子说明:

测试用例:

  • 输入数据的顺序为 1,2,3,4

先不管输出是否合法,总的输出的可能性总共有:

图片.png

图片.png


下面判断他是否合法

  • 栈规则 :先进后出

既然栈是一种特殊的双端队列,那么对于栈来说是合法的次序,自然对双端队列也是合法的次序(输入受限和输出受限肯定都满足) ,根据卡特兰数计算可知:

图片.png

下面绿色的是合法的:

图片.png

为啥呢?比如拿一个做例子:

  • 2134:是合法的,12进->2出->1出->3进->3出->4进->4出
  • 3124:不合法的,123进->3出->1没法先出,绕过2

接着需要判断红色部分的序列,因为红色部分在输入受限和输出受限中可能会不满足

  • 输入受限和输出受限

输入受限双端队列合理序列:绿色部分+绿色部分(下划线):

图片.png

这里下划线是输入受限合理的,举个例子:

  • 4132: 只能一个地方输入,但输出二个地方。 1234进->4出->123->1出->23->3出->2出,合法
  • 4213:1234进->4出->123->2在中间出不了,不能按照顺序,不合法

输出受限双端队列合理序列:绿色部分+绿色部分(下划线):

图片.png

这里下划线是输出受限合理的,举个例子:

  • 4132: 二个地方输入,但输出一个地方。 删除是一个方向,要成功得输入是2314,3不可能绕过12在中间,无论左边进还是右边进。
  • 4213: 从右向左输入12,这时候是12,再从左向右输入3,是312,再从右向左输入4,这时候是3124,删除只能一个方向,是从左向右,依次是4213

关于双端队列的更多待续


关于栈的应用举例和栈的递归详见专栏数据结构和C语言实现数据结构