数据结构——栈与队列(四)

数据结构——栈与队列(四)

作者:黑衣侠客


栈与队列:栈是限定仅在表尾进行插入和删除操作的线性表。队列时只允许在一端进行插入操作、而在另一端进行删除操作的线性表。

一、栈的定义

1. 栈的定义

栈:栈是限定仅在表尾进行插入和删除操作的线性表。
我们把插入和删除的一端称为栈顶,另一端称为栈底,不含任何数据元素的栈称为空栈。栈又称为后进先出的线性表,简称LIFO结构。

理解栈的定义需要注意:
首先,它是一个线性表,栈元素之间具有线性关系,即前驱和后继关系。只不过,它是一种特殊的线性表而已。定义中说在线性表的表尾进行插入和删除操作,这里表尾指栈顶,而不是栈底
它的特殊之处就在于限制了这个线性表的插入和删除位置,它始终只在栈顶进行。这就使得栈底是固定的,最先进栈的只能在栈底。

栈的插入操作,叫做进栈,也称压栈、入栈。
栈的删除操作,叫做出栈,也有的叫做弹栈。

数据结构——栈与队列(四)_第1张图片

2. 进栈出栈的变化形式

栈对线性表的插入和删除位置进行了限制,并没有对元素进出的时间进行限制,也就是说,在不是所有元素都进栈的情况下,事先进去的元素也可以出栈,只要保证是栈顶元素出栈就可以。

举例说明:如果我们现在是有3个整型数字元素1、2、3依次进栈,会有哪些出栈次序?

  • 第一种:1、2、3进,再3、2、1出。这是最简单的最好理解的一种,出栈次序为321。
  • 第二种: 1进,1出,2进,2出,3进,3出。也就是进一个就出一个,出栈顺序为123。
  • 第三种: 1进,2进,2出,1出,3进,3出。出栈顺序为213。
  • 第四种: 1进,1出,2进,3进,3出,2出。出栈顺序为132。
  • 第五种: 1进,2进,2出,3进,3出,1出。出栈顺序为231。

那么有没有312这样的顺序呢?
答案是一定不会的,因为3先出栈,就意味着3已经进栈了,那也就意味着,1和2已经进栈了,那么2一定相对于1更接近栈顶,那么出栈只可能321,不然不满足123进栈要求,所以不会发生1比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的元素个数;

由于栈本身是一个线性表,那么上一章我们学习的线性表的顺序存储和链式存储,对于栈来说,也是同样适用的。

三、栈的顺序存储结构及实现

1. 栈的顺序存储结构

栈是线性表的特例,那么栈的顺序存储其实就是线性表顺序存储的简化,我们称其为顺序栈。线性表是用数组来实现的,通常我们用下标为0的一端作为栈底比较好,因为首元素都存在栈底,变化最小,所以让它作栈底。我们定义一个top变量来元素在数组中的位置,它可以来回移动。同理,若存储栈的长度为StackSize,则栈顶位置top必须小于StackSize。通常将空栈的判定条件定为top=-1,因此,当栈存在一个元素时,top=0;

typedef int SElemType;	//SElemType类型根据实际情况而定,这里假设为int
typedef struct
{
	SElemType data[MAXSIZE];
	int top;	//用于栈顶指针
}SqStack;

若现在有一个栈,StackSize是5,则栈普通情况、空栈和栈满的情况如下:

数据结构——栈与队列(四)_第2张图片

2. 栈的顺序存储结构——进栈操作

对于进栈操作:

//插入元素e为新的栈顶元素
Status Push(SqStack *S, SElemType e)
{
	if(S->top == MAXSIZE - 1)	//栈满
	{
		return ERROR;
	}
	S->top++;	//栈顶指针增加一
	S->data[S->top]=e;	//将新插入元素赋值给栈顶空间
	return OK;
}

3. 栈的顺序存储结构——出栈操作

出栈代码:

//若栈不空,则删除S的栈顶元素,用e返回其值,并返回OK;否则返回ERROR;
Status Pop(SqStack *S, SElemType *e)
{
	if(S->top==-1)
		return ERROR;
	*e=S->data[S->top];		//将要删除的栈顶元素赋值给e
	S->top--;		//栈顶指针减一
	return OK;
}

由于进栈、出栈操作没有使用任何循环语句,因此,时间复杂度是O(1)。

四、两栈共享空间

数组有两个端点,两个栈有两个栈底,让一个栈的栈底为数组的始端,即下标为0处,另一个栈为栈的末端,即下标为数组长度n-1处,这样两个栈如果增加元素,就会从两端点向中间延伸。
数据结构——栈与队列(四)_第3张图片

其实算法思路是:栈1的指针为top1,栈2的指针为top2,top1从-1开始,top2从n开始,依次向数组中间存入元素,当top1+1=top2的时候,意味着栈已存满。

//两栈共享空间结构
typedef struct
{
	SElemType data[MAXSIZE];
	int top1;	//栈1栈顶指针
	int top2;	//栈2栈顶指针
}SqDoubleStack;

插入元素代码:

//插入元素e为新的栈顶指针元素
Status Push(SqDoubleStack *S, SElemType e, int stackNumber)
{
	if(S->top1+1==S->top2)	//栈已满,不能再push新元素了
		return ERROR;
	if(stackNumber==1)		//栈1有元素进栈
		S->data[++S->top1]=e;	//若栈1则先top1+1后给数组元素赋值
	else if(stackNumber==2)		//栈2有元素进栈
		S->data[--S->top2]=e;	//若栈2则先top2-1后给数组元素赋值
	return OK;
}

对于两栈共享空间的pop方法,参数就只是判断栈1和栈2的参数stackNumber
代码如下:

//若栈不为空,则删除S的栈顶元素,用e返回其值,并返回OK,否则返回ERROR
Status Pop(SqDoubleStack *S, SElemType *e,int stackNumber)
{
	if(stackNumber==1)
	{
		if(S->top1==-1)
			return ERROR;		//说明栈1已经是空栈,溢出
		*e=S->data[S->top1--];	//将栈1的栈顶元素出栈
	}
	else if(stackNumber==2)
	{
		if(S->top2==MAXSIZE)
			return ERROR;		//说明栈2已经是空栈,溢出
		*e=S->data[S->top2++];	//将栈2的栈顶元素出栈
	}
	return OK;
}
对于两栈共享,只是针对两个局域相同数据类型的栈而言的,如果,数据类型不同,切忌用此方法。

五、栈的链式存储结构及实现

1. 栈的链式存储结构

栈的链式存储结构简称链栈
链栈的结构代码如下:

typedef struct StackNode
{
	SElemType data;
	struct StackNode *next;
}STackNode, *LinkStackPtr;
typedef struct LinkStack
{
	LinkStackPtr top;
	int count;
}LinkStack;

链栈的操作绝大部分都和单链表类似,只是在插入和删除上有一些特殊。

2. 栈的链式存储结构——进栈操作

对于链栈的进栈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->top = s;			//将新的结点s赋值给栈顶指针
	S->count++;
	return OK; 
}

3. 栈的链式存储结构——出栈操作

简单的三句操作

  1. 假设变量p用来存储要删除的栈顶结点
  2. 将栈顶指针下移一位
  3. 最后释放p即可
//若栈不空,则删除S的栈顶元素,用e返回其值,并返回OK,否则返回ERROR
Status Pop(LinkStack *S, SElemType *e)
{
	LinkStackPtr p;
	if(StackEmpty(*S))
		return ERROR;
	*e=S->top->data;
	p=S->top;		//将栈顶结点赋值给p
	S->top = S->top->next;	//使得栈顶指针下移一位,指向后一结点
	free(p);			//释放结点p
	S->count--;
	return OK;
}

六、栈的应用——递归

1. 斐波那锲数列的实现

如果,兔子在出生两个月后,就有繁殖能力,一对兔子每个月能生出一对小兔子来。假设所有兔都不死,那么一年后可以繁殖多少对兔子呢?

我们来分析一下:

  • 第一个月小兔子没有繁殖能力,所以还是一对,两个月后,生下一对小兔子,现在共两对,三个月后,老兔子又生下一对,因为小兔子还没有繁殖能力,所以一共是三对,以此类推…
经过的月数 1 2 3 4 5 6 7 8 9 10 11 12
兔子对数 1 1 2 3 5 8 13 21 34 55 89 144

表中数字1,1,2,3,5,8,13…构成了一个序列。这个数列有个十分明显的特点,就是:前面相邻两项之和,构成了后一项。

Fn=

  • 0,当n=0;
  • 1,当n=1;
  • F(n-1)+F(n-2),当n>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);	//这里Fbi就是函数自己,它在调用自己
}
int main()
{
	int i;
	for(int i=0;i<40;i++)
		printf("%d",Fbi(i));
	return 0;
}

思维图:
数据结构——栈与队列(四)_第4张图片

2. 递归定义

在高级语言中,调用自己和其他函数并没有什么本质的区别,我们把一个直接调用自己或通过一系列的调用间接的调用自己的函数,称为递归函数。

一个递归程序最怕的就是陷入永不结束的无穷递归中,每个递归必须由一个条件,满足时递归不再进行,即不再引用自身而是返回值退出。

对比上述两种代码:
我们发现迭代和递归的区别是:

迭代使用的是循环结构,递归使用的是选择结构。递归能使程序的结构更清晰、更简洁、更容易让人理解,从而减少读懂代码的时间。但是大量的递归调用会建立函数的副本,会消耗大量的时间和内存。迭代则不需要反复调用函数和占用额外的内存。因此我们应该视不同情况选择不同的代码实现方式。

七、栈的应用——四则运算表达式求值

1. 后缀(逆波兰)表示法定义

栈的现实应用有很多,我们来重点讲一个比较常见的应用:数学表达式求值。

我们来看一个例子:对于“9+(3-1)× 3+10÷2”这个表达式,化为后缀表达式就是:“9 3 1 - 3 * + 10 2 / +”。叫做后缀的原因是:所有的符号都是在要运算的数字的后面出现。

2. 后缀表达式的计算结果

为了解释后缀表达式的好处,我们先来看看,计算机是如何应用后缀表达式来计算出最终的结果20的。

后缀表达式:9 3 1 - 3 * + 10 2 / +

规则:从左到右遍历表达式的每一个数字和符号,遇到是数字就进栈,遇到是符号,就将处于栈顶的两个数字出栈,进行运算,运算结果进栈,一直到最终获得的结果为止。

  1. 初始化一个空栈。此栈用来对要运算的数字进出使用。
  2. 后缀表达式中前三个都是;数字,所以9、3、1进栈。
    数据结构——栈与队列(四)_第5张图片
  3. 接下来是“—”,所以将栈中的1出栈作为减数,3出栈作为被减数,并运算3-1得到2,再将2进栈。
  4. 接着是数字3进栈。
    数据结构——栈与队列(四)_第6张图片
  5. 后面是“*”,也就是意味着,栈中的3和2出栈,2与3相乘,得到6,并将6进栈。
  6. 下面是“+”,所以栈中6和9出栈,9与6相加,得到15,将15进栈。
    数据结构——栈与队列(四)_第7张图片
  7. 接着是10与2两数字进栈。
  8. 接下来是符号“/”,因此,栈顶的2与10出栈,10与2相除,得到5,将5进栈。
    数据结构——栈与队列(四)_第8张图片
  9. 最后一个是符号“+”,所以15与5出栈并相加,得到20,将20进栈。
  10. 结果是20出栈,栈变为空。
    数据结构——栈与队列(四)_第9张图片

2. 中缀表达式转后缀表达式

我们把标准的四则运算表达式,即“9+(3-1)×3+10÷2”叫做中缀表达式
因为,所有的运算符号都在两数字的中间,现在我们的问题就是中缀到后缀的转化。

中缀表达式“9+(3-1)×3+10÷2”转化为后缀表达式“9 3 1 - 3 * + 10 2 / +”。
规则:从左到右遍历中缀表达式的每个数字和符号,若是数字就输出,即成为后缀表达式的一部分;若是符号,则判断其与栈顶符号的优先级,是右括号或优先级低于栈顶符号(乘除优先加减)则栈顶元素依次出栈并输出,并将当前符号进栈,一直到最终输出后缀表达式为止。

  1. 初始化一空栈,用来对符号进出栈使用。
    数据结构——栈与队列(四)_第10张图片
  2. 第一个字符是数字9,输出9,后面是符号“+”,进栈。
  3. 第三个字符是“(”,依然是符号,因其只是左括号,还未配对,故进栈。
  4. 第四个字符是数字3,输出,总表达式为9 3,接着是“—”,进栈。
    数据结构——栈与队列(四)_第11张图片
  5. 接下来是数字1,输出,总表达式为9 3 1,后面是符号“)”,此时,我们需要去匹配此前的“(”,所以栈顶依次出栈,并输出,直到“(”出栈为止。此时左括号上方只有“—”,因此,输出“—”。总的输出表达式为9 3 1 —。
  6. 接着是数字3,输出,总的表达式为9 3 1 - 3。紧接着是符号“×”,因为此时的栈顶符号为“+”号,优先级低于“×”,因此不输出,“*”进栈。
    数据结构——栈与队列(四)_第12张图片
  7. 之后是符号“+”,此时当前栈顶元素“*”比这个“+”的优先级高,因此栈中元素出栈并输出(没有比“+”号更低的优先级,所以全部出栈),总输出表达式为9 3 1 - 3 * +。然后将当前这符号“+”进栈。也就是说,前6张图的栈底的“+”是指中缀表达式中开头的9后面的那个“+”。
    下图的“+”是表达式中最后一个“+”
  8. 接着,数字10,输出,总表达式变为 9 3 1 - 3 * + 10.后面是符号“÷”,所以“/”进栈。
    数据结构——栈与队列(四)_第13张图片
  9. 最后一个数字2,输出,总的表达式为9 3 1 - 3 * + 10 2。
  10. 因为,已经到了最后,所以将栈中符号全部出栈并输出。最终输出的后缀表达式结果为9 3 1 - 3 * + 10 2 / +。
    数据结构——栈与队列(四)_第14张图片
  • 所以我们说,通常计算机中利用的计算方法就是:
    中缀表达式转后缀表达式,后缀表达式计算。

八、队列的定义

我们在使用电脑时,总会经历过,机器处于死机的状态,鼠标点什么似乎都没有用,正当我们想要重启时,电脑突然好了,它把我们之前的操作都执行了一遍。这其实是因为操作系统中的多个程序需要通过一个通道输出,而按先后次序排队等待造成的。

操作系统中,采用了一种数据结构来实现我们说到的先进先出的排队功能,这就是队列。

队列时只允许在一端进行插入操作,而在另一端进行删除操作的线性表。 队列是一种先进先出的线性表,简称FIFO。允许插入的一端称为队尾,允许删除的一端称为队头。

假设队列是q=(a1,a2,…,an), 那么a1就是队头元素,而an是队尾元素。这样我们删除时,总是从a1开始,而插入时,列在最后。这也比较符合我们通常生活中的习惯,排在第一个的优先出列,最后来的当然排在队伍最后。

队列在程序设计领域应用十分广泛,例如:键盘进行各种字母或数字的输入,到显示器上如记事本软件上的输出,其实就是队列的典型应用。

数据结构——栈与队列(四)_第15张图片

九、队列的抽象数据类型

队列也有类似于线性表的各种操作,不同的就是插入数据只能在队尾进行,删除数据只能在队头进行。

ADT 队列(Queue)
Data
	通线性表。元素具有相同的类型,相邻元素具有前驱和后继关系。
Operation	
	InitQueue(*Q):初始化操作,建立一个空队列Q;
	DestroyQueue(*Q):若队列Q存在,则销毁它;
	ClearQueue(*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的元素个数;

十、循环队列

线性表有顺序存储和链式存储,栈是线性表,所以有这两种存储方式。同样,队列作为一种特殊的线性表,也同样存在这两种存储方式。接下来,我们来看看:队列的顺序存储结构。

1. 队列顺序存储的不足

我们假设一个队列有n个元素,则顺序存储的队列需建立一个大于n的数组,并把队列的所有元素存储在数组的前n个单元中,数组下标为0的一端即是队头。所谓的入队列操作,其实就是在队尾追加一个元素,不需要移动任何元素,因此时间复杂度是O(1)。

与栈不同的是,队列元素的出列是在队头,即下标为0的位置,那也就意味着,队列中的所有元素都得向前移动,以保证队列的队头,也就是下标为0的位置不为空,此时的时间复杂度是O(n)。

这里的实现和线性表的顺序存储结构完全相同。

为了避免当只有一个元素时,队头和队尾重合使处理变得麻烦,所以引入两个指针,front指针指向队头元素,rear指针指向队尾元素的下一个位置,这样当front等于rear时,此队列不是还剩一个元素,而是空队列

在我们使用队列的时候,可能会发生假溢出现象(队列的前面有空位置,但是后面的位置已满,无法从后面存入元素),所以,解决假溢出的办法就是后面满了,再重头开始,也就是头尾相接的循环。我们把队列的这种头尾相接的顺序存储结构称为循环队列。

2. 循环队列

顺序存储结构代码:

typedef int QElemType;	//QElemType类型根据实际情况而定,这里假设为int
/*循环队列的顺序存储结构*/
typedef struct
{
	QElemType data[MAXSIZE];
	int front;			//头指针
	int rear;			//尾指针,若队列不空,指向队列尾元素的下一个位置
}SqQueue;

循环队列初始代码:

//初始化一个空队列Q
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->front == Q->rear)		//队列空的判断
		return ERROR;
	*e=Q->data[Q->front];		//将队头元素赋值给e
	Q->front = (Q->front+1)%MAXSIZE;	//front指针向后移一位置,若到最后,则转到数组头部
	return OK;
}

十一、队列的链式存储结构及实现

队列的链式存储结构其实就是线性表的单链表,只不过它只能尾进头出而已,我们把它简称为链队列。

为了操作方便,我们将队头指针指向链队列的头结点,而队尾指针指向终端尾结点。

数据结构——栈与队列(四)_第16张图片

空队列时,front和rear都指向头结点

数据结构——栈与队列(四)_第17张图片
链队列的结构为:

typedef int QElemType;			//QElemType类型根据实际情况而定,这里假设为int
typedef struct QNode			//结点结构
{
	QElemType data;
	struct QNode *next;
}QNode,*QueuePtr;
typedef struct					//队列的链表结构
{
	QueuePtr front,rear;		//队头、队尾指针
}LinkQueue;

1. 队列的链式存储结构——入队操作

入队其实就是在链表尾部插入结点
数据结构——栈与队列(四)_第18张图片

具体实现如下:

//插入元素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;
	//把拥有元素e新结点s赋值给原队尾结点的后继
	Q->rear = s;	//把当前的s设置为队尾结点,rear指向s
	return OK;
}

2. 队列的链式存储结构——出队操作

出队列操作时,就是头结点的后继结点出队,将头结点的后继改为它后面的结点,若链表除头结点外只剩一个元素时,则需要将rear指向头结点。
数据结构——栈与队列(四)_第19张图片
代码操作如下:

//若队列不空,删除Q的队头元素,用e返回其值,并返回OK,否则返回ERROR
STatus DeQueue(LinkQUeue *Q, QELemType *e)
{
	QueuePtr p;
	if(Q->front==Q->rear)
		return ERROR;
	p=Q->front->next;		//将欲删除的队头结点暂存给p
	*e=p->data;				//将欲删除的队头结点的值赋给e
	Q->front->next=p->next;	//将原队头结点后继p->next赋值给头结点后继
	if(Q->rear==p)			//若队头是队尾,则删除后将rear指向头结点
		Q->rear=Q->front;
	free(p);
	return OK;
}

对于循环队列与链队列的比较,可以从两方面来考虑,从时间上,其实它们的基本操作都是常数时间,即都为O(1)的,不过循环队列是事先申请好空间,使用期限不释放,而对于链队列,每次申请和释放结点也会存在一些时间开销,如果入队出队频繁,则两者还是有细微差异的。对于空间上来说,循环队列必须有一个固定的长度,所以就有了存储元素个数和空间浪费的问题。而链队列不存在这个问题,尽管它需要一个指针域,会产生一些空间上的开销,但也可以接受。所以,在空间上,链队列更加灵活。总的来说,在可以确定队列长度最大值的情况下,建议使用循环队列,如果无法预估队列的长度,则用链队列。

总结:

栈和队列都是特殊的线性表,只不过对插入和删除做了一些限制。

栈:栈是限定仅在表尾进行插入和删除操作的线性表。

队列:队列是只允许在一端进行插入操作,而在另一端进行删除操作的线性表。

它们均可用用线性表的顺序存储结构来实现,但都存在着顺序存储的一些弊端。因此,它们各自有各自的方法来解决这个问题。

对于栈来说,如果是两个相同数据类型的栈,则可以用数组的两端作栈底的方法来让两个栈共享数据,这就可以最大化地利用数组的空间了。

对于队列来说,为了避免数组插入和删除时需要移动数据,于是就引入了循环队列,使得队头和队尾可以在数组中循环变化。解决了移动数据的时间损耗,使得本来插入和删除是O(n)的时间复杂度变成了O(1)。

队列
顺序栈 顺序队列
两栈共享空间 循环队列
链栈 链队列

你可能感兴趣的:(数据结构,数据结构,队列)