大话数据结构学习笔记(四)——栈与队列

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

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

1 栈的定义

1.1 栈的定义

栈(stack)是限定仅在表尾进行插入和删除操作的线性表。
我们把允许插入和删除的一端称为栈顶(top),另一端称为栈底(bottom), 不含任何数据元素的栈称为空栈。 栈又称为后进先出(Last In First Out) 的线性表, 简称LIFO结构
理解栈的定义需要注意:
首先它是一个线性表,也就是说,栈元素具有线性关系,即前驱后继关系。 只不过它是一种特殊的线性表而已。定义中说是在线性表的表尾进行插入和删除操作,这里表尾是指栈顶,而不是栈底。
它的特殊之处就在于限制了这个线性表的插入和删除位置,它始终只在栈顶进行。这也就使得:栈底是固定的,最先进栈的只能在栈底。
栈的插入操作,叫作进栈,也称压栈入栈

image-20220426183613659

栈的删除操作,叫作出栈,也有的叫作弹栈

image-20220426183656636

1.2 进栈出栈变化形式

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

2 栈的抽象数据类型

对于栈来讲,理论上线性表的操作特性它都具备,可由于它的特殊性,所以针对它在操作上会有些变化。特别是插入和删除操作,我们改名为push和pop,英文直译的话是压和弹,更容易理解。你就把它当成是弹夹的子弹压入和弹出就好记忆了,我们一般叫进栈和出栈。

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的元素个数。
endADT

3 栈的顺序存储结构及实现

3.1 栈的顺序存储结构

栈的结构定义:

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

3.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.3 出栈操作

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

4 两栈共享空间

如果我们有两个相同类型的栈,我们为它们各自开辟了数组空间,极有可能是第一个栈已经满了, 再进栈就溢出了,而另一个栈还有很多存储空间空闲。这又何必呢?我们完全可以用一个数组来存储两个栈,只不过需要点小技巧。
我们的做法下图,数组有两个端点,两个栈有两个栈底,让一个栈的栈底为数组的始端,即下标为0处,另一个栈为数组的末端,即下标为数组长度n-1处。这样,两个栈如果增加元素,就是两端点向中间延伸。

其实关键思路是:它们是在数组的两端,向中间靠拢。top1和top2是栈1和栈2的栈顶指针,可以想象,只要它们俩不见面,两个栈就可以一直使用。
从这里也就可以分析出来,栈1为空时,就是top1等于-1时;而当top2等于n时,即是栈2为空时, 那什么时候栈满呢?
想想极端的情况,若栈2是空栈,栈1的top1等于n-1时,就是栈1满了。反之, 当栈1为空栈时, top2等于0时,为栈2满。但更多的情况,其实就是我刚才说的,两个栈见面之时,也就是两个指针之间相差1时,即top1 + 1 == top2为栈满。

两栈共享空间的结构代码如下:

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

插入元素的代码如下:

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

因为在开始已经判断了是否有栈满的情况,所以后面的top1 + 1top2 - 1是不担心溢出问题的。
对于两栈共享空间的pop方法,参数就只是判断栈1栈2的参数stackNumber,代码如下:

// 若栈不空,则删除S的栈顶元素,用e返回其值,并返回OK;否则返回ERROR
Status Pop(SqDoubleStack *S, SElemType *e, int stackNumber)
{
    if (stackNumber == 1)
    {
        // 说明栈1已经是空栈,溢出
        if (S -> top1 == 1)
            return ERROR;
        // 将栈1的栈顶元素出栈
        *e = S -> data[S -> top1 --];
    }
    else if (stackNumber == 2)
    {
        // 说明栈2已经是空栈,溢出
        if (S -> top2 == MAXSIZE)
            return ERROR;
        // 将栈2的栈顶元素出栈
        *e = S -> data[S -> top2 ++];
    }
    return OK;
}

事实上,使用这样的数据结构,通常都是当两个栈的空间需求有相反关系时,也就是一个栈增长时另一个栈在缩短的情况。就像买卖股票一样,你买入时,一定是有一个你不知道的人在做卖出操作。有人赚钱,就一定是有人赔钱。这样使用两栈共享空间存储方法才有比较大的意义。否则两个栈都在不停地增长,那很快就会因栈满而溢出了。

5 栈的链式存储结构及实现

5.1 栈的链式存储结构

栈的链式存储结构,简称为链栈

想想看,栈只是栈顶来做插入和删除操作,栈顶放在链表的头部还是尾部呢?由于单链表有头指针,而栈顶指针也是必须的,那干吗不让它俩合二为一呢,所以比较好的办法是把栈顶放在单链表的头部(如下图所示)。另外,都已经有了栈顶在头部了,单链表中比较常用的头结点也就失去了意义,通常对于链栈来说,是不需要头结点的。

对于链栈来说,基本不存在栈满的情况,除非内存已经没有可以使用的空间,如果真的发生,那此时的计算机操作系统已经面临死机崩溃的情况,而不是这个链栈是否溢出的问题。
但对于空栈来说,链表原定义是头指针指向空,那么链栈的空其实就是top = NULL的时候。
链栈的结构代码如下:

// 栈中元素
typedef struct StackNode
{
    SElemType data;
    struct StackNode *next;
} StackNode, *LinkStackPtr;
// 栈顶指针
typedef struct LinkStack
{
    LinkStackPtr top;
    int count;
} LinkStack;

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

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

对于链栈的进栈push操作,假设元素值为e的新结点是stop为栈顶指针,示意图如下:

进栈代码如下:

// 插入元素e为新的栈顶元素
Status Push(LinkStack *S, SElemType e)
{
    LinkStackPtr s = (LinkStackPtr) malloc(sizeof(StackNode));
    s -> data = e;
    // 把当前的栈顶元素赋值给新结点的直接后继,如上图中①
    s -> next = S -> top;
    // 将新的节点s赋值给栈顶指针,如上图中②
    S -> top = s;
    S -> count ++;
    return OK;
}

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

至于链栈的出栈pop操作,也是很简单的三句操作。假设变量p用来存储要删除的栈顶结点,将栈顶指针下移一位,最后释放p即可,如下图所示:

出栈代码如下:

// 若栈不空,则删除S的栈顶元素,用e返回其值,并返回OK;否则返回ERROR
Status Pop(LinkStack *S, SElemType *e)
{
    LinkStackPtr p;
    if (StackEmpty(*S))
        return ERROR;
    *e = S -> top -> data;
    // 将栈顶结点赋值给p,如上图中③
    p = S -> top;
    // 使得栈顶指针下移以为,指向后一结点,如上图中④
    S -> top = S -> top -> next;
    // 释放结点p
    free(p);
    S -> count --;
    return OK;
}

链栈的进栈push和出栈pop操作都很简单,没有任何循环操作,时间复杂度均为O(1)
对比一下顺序栈与链栈,它们在时间复杂度上是一样的,均为O(1)。对于空间性能,顺序栈需要事先确定一个固定的长度,可能会存在内存空间浪费的问题,但它的优势是存取时定位很方便,而链栈则要求每个元素都有指针域,这同时也增加了一些内存开销,但对于栈的长度无限制。所以它们的区别和线性表中讨论的一样,如果栈的使用过程中元素变化不可预料,有时很小,有时非常大, 那么最好是用链栈,反之,如果它的变化在可控范围内,建议使用顺序栈会更好一些。

6 栈的作用

栈的引入简化了程序设计的问题,划分了不同关注层次,使得思考范围缩小,更加聚焦于我们要解决的问题核心。反之,像数组等,因为要分散精力去考虑数组的下标增减等细节问题,反而掩盖了问题的本质。
所以现在的许多高级语言,比如JavaC#等都有对栈结构的封装,你可以不用关注它的实现细节,就可以直接使用Stackpushpop方法,非常方便。

7 栈的应用——递归

栈有一个很重要的应用:在程序设计语言中实现了递归。那么什么是递归呢?
当你往镜子前面一站,镜子里面就有一个你的像。但你试过两面镜子一起照吗?如果A、B两面镜子相互面对面放着,你往中间一站,两面镜子里都有你的千百个“化身”。为什么会有这么奇妙的现象呢?原来,A镜子里有B镜子的像,B镜子里也有A镜子的像,这样反反复复,就会产生一连串的“像中像”。这是一种递归现象。

7.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……构成了一个序列。这个数列有个十分明显的特点,那就是:前面相邻两项之和,构成了后一项,如下图所示:

可以发现,编号①的一对兔子经过六个月就变成8对兔子了。如果我们用数学函数来定义就是:

F(n)={0,n=01,n=1F(n1)+F(n2),n>1

假设我们需要打印出前40位的斐波那契数列。代码如下:

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

函数怎么可以自己调用自己?听起来有些难以理解,不过你可以不要把一个递归函数中调用自己的函数看作是在调用自己,而就当它是在调另一个函数。只不过,这个函数和自己长得一样而已。
我们来模拟代码中的Fbi(i)函数当i=5的执行过程,如下图所示:

7.2 递归定义

在高级语言中,调用自己和其他函数并没有本质的不同。我们把一个直接调用自己或通过一系列的调用语句间接地调用自己的函数,称做递归函数

当然,写递归程序最怕的就是陷入永不结束的无穷递归中,所以,每个递归定义必须至少有一个条件,满足时递归不再进行,即不再引用自身而是返回值退出。比如刚才的例子,总有一次递归会使得i < 2的,这样就可以执行return i的语句而不用继续递归了。

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

那么我们讲了这么多递归的内容, 和栈有什么关系呢?这得从计算机系统的内部说起。
前面我们已经看到递归是如何执行它的前行和退回阶段的。递归过程退回的顺序是它前行顺序的逆序。在退回过程中,可能要执行某些动作,包括恢复在前行过程中存储起来的某些数据。
这种存储某些数据,并在后面又以存储的逆序恢复这些数据,以提供之后使用的需求,显然很符合栈这样的数据结构,因此,编译器使用栈实现递归就没什么好惊讶的了。
简单的说,就是在前行阶段,对于每一层递归,函数的局部变量、参数值以及返回地址都被压入栈中。在退回阶段,位于栈顶的局部变量、参数值和返回地址被弹出,用于返回调用层次中执行代码的其余部分,也就是恢复了调用的状态。
当然,对于现在的高级语言,这样的递归问题是不需要用户来管理这个栈的,一切都由系统代劳了。

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

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

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

如果让你用C语言或其他高级语言实现对数学表达式的求值,你打算如何做?
这里面的困难就在于乘除在加减的后面,却要先运算,而加入了括号后,就变得更加复杂。不知道该如何处理。
但仔细观察后发现,括号都是成对出现的,有左括号就一定会有右括号,对于多重括号,最终也是完全嵌套匹配的。这用栈结构正好合适,只要碰到左括号,就将此左括号进栈,不管表达式有多少重括号,反正遇到左括号就进栈,而后面出现右括号时,就让栈顶的左括号出栈,期间让数字运算,这样,最终有括号的表达式从左到右巡查一遍,栈应该是由空到有元素,最终再因全部匹配成功后成为空栈。
但对于四则运算,括号也只是当中的一部分,先乘除后加减使得问题依然复杂,如何有效地处理它们呢? 我们伟大的科学家想到了好办法。
20世纪50年代,波兰逻辑学家Jan·ukasiewicz,当时也和我们现在的同学们一样, 困惑于如何才可以搞定这个四则运算,不知道他是否也像牛顿被苹果砸到头而想到万有引力的原理,或者还是阿基米德在浴缸中洗澡时想到判断皇冠是否纯金的办法,总之他也是灵感突现,想到了一种不需要括号的后缀表达法,我们也把它称为逆波兰(Reverse Polish Notation, RPN)表示。我想可能是他的名字太复杂了,所以后人只用他的国籍而不是姓名来命名,实在可惜。这也告诉我们,想要流芳百
世,名字还要起得朗朗上口才行。 这种后缀表示法,是表达式的一种新的显示方式,非常巧妙地解决了程序实现四则运算的难题。
我们先来看看,对于9 + (3 - 1) × 3 + 10 ÷ 2,如果要用后缀表示法应该是什么样子: 9 3 1 - 3 * + 10 2 / +,这样的表达式称为后缀表达式,叫后缀的原因在于所有的符号都是在要运算数字的后面出现。

8.2 后缀表达式计算结果

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

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

  1. 初始化一个空栈。此栈用来对要运算的数字进出使用。如下左图所示。

  2. 后缀表达式中前三个都是数字,所以9、3、1进栈,如下右图所示。

  3. 接下来是“-”号,所以将栈中的1出栈作为减数,3出栈作为被减数,并运算3-1得到2,再将2进栈,如下左图所示。

  4. 接着是数字3进栈,如下右图所示。

  5. 后面是“*”号,也就意味着栈中3和2出栈,2与3相乘,得到6,并将6进栈,如下左图所示。

  6. 下面是“+”号,所以栈中6和9出栈,9与6相加,得到15,将15进栈,如下右图所示。

  7. 接着是10与2两数字进栈,如下左图所示。

  8. 接下来是“/”号,因此,栈顶的2与10出栈,10与2相除,得到5,将5进栈,如下右图所示。

  9. 最后一个符号是“+”号,所以15与5出栈并相加,得到20,将20进栈,如下左图所示。

  10. 结果是20出栈,栈变为空,如下右图所示。

从刚才的推导中你会发现,要想让计算机具有处理我们通常的标准(中缀)表达式的能力,最重要的就是两步:

  1. 将中缀表达式转化为后缀表达式(栈用来进出运算的符号)。
  2. 将后缀表达式进行运算得出结果(栈用来进出运算的数字)。

整个过程,都充分利用了栈的后进先出特性来处理,理解好它其实也就理解好了栈这个数据结构。

9 队列的定义

操作系统和客服系统中,都是应用了一种数据结构来实现刚才提到的先进先出的排队功能, 这就是队列。
队列(queue)是只允许在一端进行插入操作, 而在另一端进行删除操作的线性表

队列是一种先进先出(First In First Out)的线性表, 简称FIFO。允许插入的一端称为队尾,允许删除的一端称为队头。假设队列是q=(a[1],a[2],......,a[n]),那么a[1]就是队头元素,而a[n]是队尾元素。这样我们就可以删除时,总是从a[1]开始,而插入时,列在最后。这也比较符合我们通常生活中的习惯,排在第一个的优先出列,最后来的当然排在队伍最后,如下图所示。

9.1 队列的抽象数据类型

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

ADT 队列(Queue)
Data
	同线性表。元素具有相同的类型,相邻元素具有前驱和后继关系。
Operation
	InitQueue(*Q):	初始化操作,简历一个空队列QDestroyQueue(*Q): 	若队列Q存在,则销毁它。
	ClearQueue(*Q):		将队列Q清空。
	QueueEmpty(Q): 		若队列为空,返回true,否则返回falseGetHead(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的位置,那也就意味着,队列中的所有元素都得向前移动,以保证队列的队头,也就是下标为0的位置不为空,此时时间复杂度为O(n),如下图所示。

可有时想想,为什么出队列时一定要全部移动呢,如果不去限制队列的元素必须存储在数组的前n个单元这一条件,出队的性能就会大大增加。也就是说,队头不需要一定在下标为0的位置,如下图所示。

为了避免当只有一个元素时,队头和队尾重合使处理变得麻烦,所以引入两个指针,front指针指向队头元素,rear指针指向队尾元素的下一个位置,这样当front等于rear时,此队列不是还剩一个元素,而是空队列。
假设是长度为5的数组,初始状态,空队列如下左图所示,frontrear指针均指向下标为0的位置。然后入队a[1]a[2]a[3]a[4]front指针依然指向下标为0位置,而rear指针指向下标为4的位置,如下右图所示。

出队a[1]a[2],则front指针指向下标为2的位置,rear不变,如下左图所示,再入队a[5],此时front指针不变,rear指针移动到数组之外。数组之外,那将是哪里?如下右图所示。

问题还不止于此。假设这个队列的总个数不超过5个,但目前如果接着入队的话,因数组末尾元素已经占用,再向后加,就会产生数组越界的错误,可实际上,我们的队列在下标为0和1的地方还是空闲的。我们把这种现象叫做“假溢出”。

10.2 循环队列定义

所以解决假溢出的办法就是后面满了,就再从头开始,也就是头尾相接的循环。我们把队列的这种头尾相接的顺序存储结构称为循环队列

刚才的例子继续,rear可以改为指向下标为0的位置,这样就不会造成指针指向不明的问题了,如下图所示。

接着入队a[6],将它放置于下标为0处,rear指针指向下标为1处,如下左图所示。若再入队a[7],则rear指针就与front指针重合,同时指向下标为2的位置,如下右图所示。

此时问题又出来了,我们刚才说,空队列时,front等于rear,现在当队列满时,也是front等于rear,那么如何判断此时的队列究竟是空还是满呢?
办法一是设置一个标志变量flag,当fron == trear,且flag = 0时为队列空,当front == rear,且flag = 1时为队列满。
办法二是当队列空时,条件就是front = rear,当队列满时,我们修改其条件,保留一个元素空间。也就是说,队列满时,数组中还有一个空闲单元。例如下图所示,我们就认为此队列已经满了,也就是说,我们不允许上右图情况出现。

我们重点来讨论第二种方法,由于rear可能比front大,也可能比front小,所以尽管它们只相差一个位置时就是满的情况,但也可能是相差整整一圈。所以若队列的最大尺寸为QueueSize,那么队列满的条件是(rear + 1) % QueueSize == front(取模“%”的目的就是为了整合rearfront大小为一个问题)。比如上面这个例子,QueueSize = 5, 上左图中front = 0,而rear = 4(4 + 1) % 5 = 0,所以此时队列满。再比如上右图,front = 2rear = 1(1 + 1) % 5 = 2,所以此时队列也是满的。而对于上上上图,front = 2rear = 0(0 + 1) % 5 = 11 ≠ 2,所以此时队列并没有满。
另外,当rear > front时,即上上上上上右图和上上上上左图,此时队列的长度为rear - front。但当rear < front时, 如上上上图和上上左图,队列长度分为两段,一段是QueueSize - front,另一段是0 + rear,加在一起,队列长度为rear - front + QueueSize。因此通用的计算队列长度公式为:

(rear - front + QueueSize) % QueueSize

循环队列的顺序存储结构代码如下:

// QElemType类型根据实际情况而定,这里假设为int
typedef int QElemType;
// 循环队列的顺序存储结构
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;
    // 将元素e赋值给队尾
    Q -> data[Q -> rear] = e;
    // rear指针向后移一位
    Q -> rear = (Q -> rear + 1) % MAXSIZE;
    // 若到最后则转到数组头部
    return OK;
}

循环队列的出队列操作代码如下:

// 若队列不空,则删除Q中队头元素,用e返回其值
Status DeQueue(SqQueue *Q, QElemType *e)
{
    // 队列空的判断
    if (Q -> front == Q -> rear)
        return ERROR;
    // 将队头元素赋值给e
    *e = Q -> data[Q -> front];
    // front指针向后移一位置
    Q -> front = (Q -> front + 1) % MAXSIZE;
    // 若到最后则转到数组头部
    return OK;
}

11 队列的链式存储结构及实现

列的链式存储结构,其实就是线性表的单链表,只不过它只能尾进头出而已,我们把它简称为链队列。为了操作上的方便,我们将队头指针指向链队列的头结点,而队尾指针指向终端结点,如下图所示。

空队列时,frontrear都指向头结点,如下图所示。

链队列的结构为:

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

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

入队操作时,其实就是在链表尾部插入结点,如下图所示。

实现代码如下:

// 插入元素e为Q的新的队尾元素
Status EnQueue(LinkQueue *Q, QElemType e)\
{
    QueuePtr s = (QueuePtr) malloc(sizeof(QNode));
    //存储分配失败
    if (!s)
        exit(OVERFLOW);
    s -> data = e;
    s -> next = NULL;
    // 把拥有元素e新结点s赋值给原队尾结点的后继
    Q -> rear -> next = s;
    // 见上图中①
    // 把当前的s设置为队尾结点,rear指向s,见上图中②
    Q -> rear = s;
    return OK;
}

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

出队操作时,就是头结点的后继结点出队,将头结点的后继改为它后面的结点,若链表除头结点外只剩一个元素时,则需将rear指向头结点,如下图所示。

实现代码如下:

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

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

posted @   JapserTang  阅读(201)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 阿里最新开源QwQ-32B,效果媲美deepseek-r1满血版,部署成本又又又降低了!
· 单线程的Redis速度为什么快?
· SQL Server 2025 AI相关能力初探
· AI编程工具终极对决:字节Trae VS Cursor,谁才是开发者新宠?
· 展开说说关于C#中ORM框架的用法!
Live2D
欢迎阅读『大话数据结构学习笔记(四)——栈与队列』
点击右上角即可分享
微信分享提示